diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 1dd842d..7ea07c2 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -356,3 +356,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear surface closes the window popover. That needs no hit test beyond the button, because a popover *is* its own surface and a click inside it never arrives; the button is left to its own click, whose toggle is what closes it. `[windowpop]` reads both halves, with autohide off so it is testing the rule and not the grab. + +- **A notice shares the status line with the glance, so it has to *look* like a message.** The status bar + renders `app.notice + " | " + glance` in one label, which is exactly where a reader skimming the + routine text ("live · 9.97 kS/s · 2 s window") will not see it: a refused drop that said "plot 1 holds four + signals already" was read as no feedback at all. While a notice is fresh the label carries the stock + `.warning` class, so a message looks like a message and the class goes when the notice does. Two corollaries + worth knowing: the line composes on its own cadence, so a notice can appear a beat after it was set (do not + read the label in the same tick a handler ran), and a refusal should say what to *do* about it -- "drop it on + another plot, or add a row with the layout editor (L)" rather than the bare statement of the limit. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index f1d9ba6..6af7672 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1229,3 +1229,17 @@ probe, exactly as the menu probe does it -- a popover dismisses itself when GTK grab goes missing, where the press has to close the bubble anyway. `click_gestures` reads that the controller is *connected* as well as that its handler works; the last press is on the button itself, which must be left to its own toggle (`want 1` rather than `0`). + +## `[drop-probe]` and what a refused drop says + +``` +[drop-probe] full row refused the drop: 0 (want 0) +[drop-probe] refused feedback: notice='plot 1 holds four signals already: drop it on another plot, or add a row with the layout editor (L)' warning=1 +[drop-probe] the status line, composed: warning=1 status='plot 1 holds four signals already: ... | live · 9.97 kS/s · 2 s window' +``` + +`UDPSCOPE_PROBE_DROP_MS`. The first line is the refusal itself (a full row takes no more signals); the +second is that it *said so* -- the message and the stock `.warning` class the status line carries while a +notice is fresh; the third is the composed line the user reads, which is why the label is only checked here +and not in the second: the status line folds the notice into itself on its own cadence, so a beat after the +refusal it may still hold the previous text. diff --git a/src/ui.cpp b/src/ui.cpp index dcb0a98..af04f34 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -4009,10 +4009,16 @@ gboolean tick(gpointer user) { } if (glance.size() > app->glance_max_chars) app->glance_max_chars = glance.size(); + // A notice is not the glance. It is prefixed onto the same line, which is exactly where a reader + // skimming the routine text will not see it -- "there is no clear feedback" was that, with a refusal + // ("plot 2 holds four signals already") looking like part of the status. The stock `.warning` class is + // what makes a message look like a message, and it goes when the notice does. if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) { + gtk_widget_add_css_class(app->status_label, "warning"); const std::string with_notice = app->notice + " | " + glance; gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str()); } else { + gtk_widget_remove_css_class(app->status_label, "warning"); app->notice.clear(); gtk_label_set_text(GTK_LABEL(app->status_label), glance.c_str()); } diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 7d5a162..499c66c 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -3367,6 +3367,49 @@ gboolean drop_probe(gpointer user) { ok ? 1 : 0, a->rows->row(0).name[0].c_str(), a->rows->row(0).name[1].c_str(), a->rows->row(0).name[2].c_str(), a->rows->row(0).name[3].c_str()); } + // Fill the row first, so that the drop below is the *refusal*: a drop can otherwise land in a slot + // whose signal is named but not acquired, which is a success rather than the case under test. + { + const std::vector& channels = a->model->channels(); + for (int slot = 0; slot < RowSet::kSlotsPerRow && !channels.empty(); ++slot) { + if (a->rows->slot_bound(0, slot)) continue; + const ChannelInfo& ch = channels[static_cast(slot) % channels.size()]; + a->rows->assign(0, slot, ch.name, ch.name, 0); + } + } + + // The refusal's *feedback*, a tick later: the status line composes the notice into itself on the + // next tick, so this is where both the text and the class that makes it visible can be read. + g_timeout_add(500, +[](gpointer u) -> gboolean { + App* app = static_cast(u); + // And the drop that the full row has to refuse, fired here so its feedback is what the reading below + // sees rather than the last success's. + GValue refused_value = G_VALUE_INIT; + g_value_init(&refused_value, G_TYPE_STRING); + g_value_set_string(&refused_value, "1"); + const gboolean refused = on_drop_channel(nullptr, &refused_value, 40.0, 20.0, app); + g_value_unset(&refused_value); + std::fprintf(stderr, "[drop-probe] full row refused the drop: %d (want 0)\n", refused ? 1 : 0); + // The message and its class, which are set the moment the drop is refused. The *label* is read in + // the later line instead: the status line composes the notice into itself on its own cadence, so + // here it may still hold the previous one. + std::fprintf(stderr, "[drop-probe] refused feedback: notice='%s' warning=%d\n", + app->notice.c_str(), + gtk_widget_has_css_class(app->status_label, "warning") ? 1 : 0); + std::fflush(stderr); + // Again a second and a half later: if the label is still the previous notice then the status line + // never recomposes it, which would be the whole of the "no clear feedback" report. + g_timeout_add(1500, +[](gpointer u) -> gboolean { + App* app = static_cast(u); + std::fprintf(stderr, + "[drop-probe] the status line, composed: warning=%d status='%s'\n", + gtk_widget_has_css_class(app->status_label, "warning") ? 1 : 0, + gtk_label_get_text(GTK_LABEL(app->status_label))); + std::fflush(stderr); + return G_SOURCE_REMOVE; + }, app); + return G_SOURCE_REMOVE; + }, a); std::fflush(stderr); return G_SOURCE_REMOVE; } diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index 6a66f79..f4e853d 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -290,8 +290,7 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y if (signal >= app->model->signals().size()) return FALSE; const auto notice = [&](const std::string& what) { - app->notice = what; - app->notice_until_us = g_get_monotonic_time() + 4 * G_TIME_SPAN_SECOND; + set_notice(*app, what, 4); std::fprintf(stderr, "[udpscope] drop: %s\n", what.c_str()); std::fflush(stderr); }; @@ -334,7 +333,11 @@ gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y } } if (slot < 0) { - notice("plot " + std::to_string(row + 1) + " already holds four signals"); + // Say what to do about it. A refusal a reader cannot act on is only half an answer: the two ways + // out are another plot or another row. + notice("plot " + std::to_string(row + 1) + + " holds four signals already: drop it on another plot, or add a row with the layout " + "editor (L)"); return FALSE; } // The slot remembers the *signal* (and element), never the channel index: the