diff --git a/README.md b/README.md index 545a348..54ae816 100644 --- a/README.md +++ b/README.md @@ -1111,8 +1111,11 @@ assertion, being a stream stamp rebased by whichever rebuild last dropped histor `probe-readings.md`. A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a -width to a window trigger. The condition is **not** persisted in the session file — the session -has never persisted the trigger — so a restart comes up on an edge trigger. +width to a window trigger. The condition **is** persisted in the session file, together with trigger +mode itself: the settings are written even while the trigger is off, because a trigger set up once and +switched off is still the trigger you come back to. The signal it names is stored by *name* — every +rebuild renumbers the channels — and the shapes by the words the panel's dropdowns show, so a +reordered enumeration cannot reinterpret a file. ### Searching the buffer @@ -2397,7 +2400,11 @@ now — they are listed so they are not mistaken for anything else: cursors and the ruler for Measure, a sine crossing its trigger level and the channel list for Signals (the same picture the toolbar's trigger button carries), drawn as a 32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and - accessible name. Each pane's heading also carries its **own switch**, the same control as the + accessible name. Both panes also have a **locked** mode, which is what a returning user gets: the + trigger pane shows one line saying what it is set to, the signal list shows only the signals that are + on, and neither offers a control that changes the configuration — each heading has an `[edit]` button + to switch to the full pane, and back. A **first run** (no session file for that source) comes up + unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the header bar's button — clicking either moves both — because a pane that is *disabled* while its feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The design spec had the switcher showing *titles* and neither page an diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index b303fb4..f1ba76a 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -4,6 +4,21 @@ What a session persists and in what order, when a save is refused, and when an e Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. +- **The trigger is persisted, settings and state both.** Its condition, its channel (by *name*), its + pre-trigger share, hold-off and mode, and whether trigger mode was on — written even while it is off, + because a trigger configured once and switched off is still the trigger you come back to. The two + enumerations are stored as the *words the panel's dropdowns show*, not as their indexes: an index ties + the file to a declaration order, which is the lesson `math_op_at()` carries, and the words are shared + with the panel so the two cannot drift. The channel resolves when the acquired channels exist + (`rebuild_trigger_signals()` picks up `App::pending_trigger_channel`, or `apply_trigger_layout()` + resolves it itself when they are already there) — a name that resolved to nothing would silently move + the trigger to channel 0. +- **A session that loaded is what locks the two panes.** Locked is the returning user's mode: the + trigger pane shows one compact line, the signal list only the signals that are on, and neither offers + a control that changes the configuration. A *first run* — no session file for that source — never + reaches `apply_layout()` and stays in configuration mode, which is what a first run is for. The mode + is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts + where a returning user expects to start. - **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the one that was closed: the window, the rows and their slot signals, the calibration, **the enabled signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 280e24f..b4f1884 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1139,6 +1139,29 @@ library measures and draws the rect in tests of its own. defect this reading exists for, and it was invisible from the frame alone: the file view pinning its axis from the ring each frame is what swallowed the gesture. +## `panes:` — the locked and configuration modes + +``` +[udpscope] panes: trigger=locked signals=locked body_visible=0 summary='window · square · 0.25…0.5 · 20 % pre' +[udpscope] panes: trigger=config signals=config body_visible=1 summary='' +``` + +Both panes have two modes. **Locked** is what a returning user gets — the trigger pane shows the one line +in `summary`, the signal list shows only the signals that are on, and neither offers a control that +changes the configuration — and **config** is the full pane, which is what a *first run* gets, because a +session file that loaded is what "we have been here before" means. Each heading's `[edit]` button flips +its pane. + +- **`summary`** is the locked trigger pane's whole content, so it is read rather than assumed: empty here + is a locked pane showing nothing at all. It quotes the shape, the channel, the figures that shape + actually reads (a level, two levels, a width plus its comparison, or a timeout) and the pre-trigger + share — never a figure the shape ignores. +- **`body_visible=0`** is the settings being off screen, which is what makes the pane 42 px tall instead + of 580 in `panels:`. +- **The locked signal list drops the rows of the signals that are off**, so `rows` counts what is on: + `signals panel: … rows=3 signals=2 divider=1` is two on signals and the `MATH` divider, not four + signals. Its `+` goes with them — a `+` on a locked list opens the editor the lock exists to keep shut. + ## `panel switches:` and the trigger panel's default sensitivity ``` @@ -1330,7 +1353,8 @@ The signals panel's frame read from the window, and in the same line the *shape* the heading and the hint the `.ui` gives it, the list the rows go in, how many rows that list holds (`rows`), how many of them are streamed signals (`signals`), how many math channels the app has (`math`), and `divider`, which is 1 when `rows == signals + 1 + math` and 0 otherwise. `labels=1` is a `.ui` that lost -one of the two labels, `list=0` one that lost the list -- both of which otherwise show as a panel quietly +one of the two labels — counted over the panel's subtree *above* the list, because the rows' own name +and unit labels are the code's and the heading sits in a heading row beside the pane's `[edit]` button — `list=0` one that lost the list -- both of which otherwise show as a panel quietly missing a piece -- and **`divider=0` is the merged list that lost its `+`**, the one control that can create a math channel. The first line above is a run with no math channels at all: the divider is there anyway, because a `+` that only appeared once a channel existed could never create the first one. `panels: … diff --git a/src/layout_store.cpp b/src/layout_store.cpp index ac4e38f..822a8b2 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -476,6 +476,48 @@ bool load_layout(const std::string& path, LayoutState& out) { return v; }; + /// One `trigger_*` key, by its name after the prefix. Text for the two enumerations and for the + /// signal, numbers for the rest; `value` arrives unquoted. + const auto trigger_key = [](LayoutState::Trigger& t, const std::string& k, const std::string& v) { + const auto num = [&](double& out) { + try { + out = std::stod(v); + } catch (...) { + } + }; + if (k == "enabled") { + t.enabled = v == "true"; + } else if (k == "signal") { + t.channel = v; + } else if (k == "type") { + t.type = v; + } else if (k == "edge") { + t.edge = v; + } else if (k == "cmp") { + t.cmp = v; + } else if (k == "mode") { + t.mode = v; + } else if (k == "level") { + num(t.level); + t.level_given = true; + } else if (k == "level2") { + num(t.level2); + } else if (k == "width") { + num(t.width_s); + } else if (k == "width2") { + num(t.width2_s); + } else if (k == "timeout") { + num(t.timeout_s); + } else if (k == "pre") { + num(t.pretrigger_pct); + } else if (k == "holdoff") { + num(t.holdoff_s); + } else if (k == "band") { + num(t.noise_band); + } else if (k == "hf") { + num(t.hf_fc); + } + }; if (section == Section::Top) { if (key == "window_s") { try { @@ -498,6 +540,8 @@ bool load_layout(const std::string& path, LayoutState& out) { st.measure_stats = value == "true"; } else if (key == "side_tab") { st.side_tab = strip_quote(value); + } else if (key.rfind("trigger_", 0) == 0) { + trigger_key(st.trigger, key.substr(8), strip_quote(value)); } else if (key == "multicast") { st.multicast = strip_quote(value); } else if (key == "interface") { @@ -648,6 +692,24 @@ bool save_layout(const std::string& path, const LayoutState& state) { } o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n"; o << "side_tab = \"" << state.side_tab << "\"\n"; + // The trigger, settings and state both. Written even when it is off: that is the point of it. + o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n"; + if (!state.trigger.channel.empty()) { + o << "trigger_signal = " << toml_escape(state.trigger.channel) << "\n"; + } + o << "trigger_type = " << toml_escape(state.trigger.type) << "\n"; + o << "trigger_edge = " << toml_escape(state.trigger.edge) << "\n"; + o << "trigger_cmp = " << toml_escape(state.trigger.cmp) << "\n"; + o << "trigger_mode = " << toml_escape(state.trigger.mode) << "\n"; + o << "trigger_level = " << state.trigger.level << "\n"; + o << "trigger_level2 = " << state.trigger.level2 << "\n"; + o << "trigger_width = " << state.trigger.width_s << "\n"; + o << "trigger_width2 = " << state.trigger.width2_s << "\n"; + o << "trigger_timeout = " << state.trigger.timeout_s << "\n"; + o << "trigger_pre = " << state.trigger.pretrigger_pct << "\n"; + o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n"; + o << "trigger_band = " << state.trigger.noise_band << "\n"; + o << "trigger_hf = " << state.trigger.hf_fc << "\n"; if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n"; if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n"; o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n"; diff --git a/src/layout_store.hpp b/src/layout_store.hpp index 169ba58..a89f7d5 100644 --- a/src/layout_store.hpp +++ b/src/layout_store.hpp @@ -73,6 +73,36 @@ struct LayoutState { /// The measurements pane, by signal *name* and with the type and options a row was built with. std::vector measures; + /// The trigger: its settings *and* whether trigger mode was on. Both, and the settings even while + /// it is off, because a trigger set up once and switched off is still the trigger you come back to + /// -- and the pane's locked view is built from exactly this. + /// + /// The signal and the two enumerations are stored as *text*: the signal by name, because every + /// rebuild renumbers the channels (the model's own rule), and the shapes by the words the panel's + /// dropdowns show, because an index would tie this file to a declaration order -- which the math + /// channel's op index already taught this store once. + struct Trigger { + bool enabled = false; + std::string channel; ///< the acquired channel's name; empty is "none chosen" + std::string type = "edge"; ///< edge | pulse width | window | dropout + std::string edge = "rising"; ///< rising | falling | both + std::string cmp = "greater"; ///< pulse width: `<` | `>` | within | outside + std::string mode = "normal"; ///< normal | single + double level = 0.0; + double level2 = 0.0; ///< window: the band's other edge + double width_s = 1.0e-3; + double width2_s = 2.0e-3; + double timeout_s = 1.0e-3; + double pretrigger_pct = 50.0; + double holdoff_s = 0.010; + double noise_band = 0.0; + double hf_fc = 0.0; + /// Whether the file said anything about the level. A session that did keeps it; one that did not + /// leaves the panel's own "follow the channel's range" default alone. + bool level_given = false; + }; + Trigger trigger; + /// Free text the user attached to this configuration: what it is for, what to look at first. /// A configuration is a *named* session, and the name alone rarely says what makes it different /// from its neighbour. diff --git a/src/ui.cpp b/src/ui.cpp index d43a7c2..e23b78f 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -982,6 +982,18 @@ void rebuild_trigger_signals(App& app) { g_object_unref(list); TriggerConfig& tc = app.model->trigger(); + // A session stores the trigger's channel by *name*: resolved here, where the acquired channels + // exist. The pending name is cleared either way, so a name this stream does not have falls through + // to the default below rather than being retried on every rebuild. + if (!app.pending_trigger_channel.empty()) { + for (std::size_t c = 0; c < channels.size(); ++c) { + if (channels[c].name == app.pending_trigger_channel) { + tc.channel = static_cast(c); + break; + } + } + app.pending_trigger_channel.clear(); + } if (tc.channel < 0 && !channels.empty()) tc.channel = 0; const int sel = tc.channel; gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_signal_drop), @@ -4388,6 +4400,39 @@ void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user) { sync_panel_switches(*app); } +/// Both panes' modes, applied together: the trigger's body against its summary line, the two [edit] +/// buttons against what they would do, and the signal list rebuilt because the lock decides what a row +/// carries. Idempotent, so a caller only flips a flag and calls this. +void apply_pane_lock(App& app) { + if (app.trigger_body != nullptr) gtk_widget_set_visible(app.trigger_body, !app.trigger_locked); + if (app.trigger_summary != nullptr) { + gtk_widget_set_visible(app.trigger_summary, app.trigger_locked); + } + const auto edit_button = [](GtkWidget* button, bool locked, const char* what) { + if (button == nullptr) return; + gtk_button_set_icon_name(GTK_BUTTON(button), + locked ? "emblem-system-symbolic" : "emblem-ok-symbolic"); + const std::string tip = std::string(locked ? "configure " : "keep this configuration of ") + what; + gtk_widget_set_tooltip_text(button, tip.c_str()); + }; + edit_button(app.trigger_edit, app.trigger_locked, "the trigger"); + edit_button(app.signals_edit, app.signals_locked, "the signals"); + sync_trigger_summary(app); + // The list, because the lock is what decides whether a row carries a tick, a menu and the `+`. + if (app.signal_list != nullptr) rebuild_signal_panels(app); +} + +/// Either pane's [edit] button: one handler, because the two differ only in which flag they flip. +void on_pane_edit_clicked(GtkButton* button, gpointer user) { + App* app = static_cast(user); + if (GTK_WIDGET(button) == app->trigger_edit) { + app->trigger_locked = !app->trigger_locked; + } else if (GTK_WIDGET(button) == app->signals_edit) { + app->signals_locked = !app->signals_locked; + } + apply_pane_lock(*app); +} + void run_app_action(App& app, AppAction action) { const auto toggle = [](GtkWidget* button) { if (button == nullptr) return; @@ -5677,13 +5722,23 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit // list the file gives it. A `.ui` that lost one of them reads here -- `labels=1` or `list=0` -- rather // than showing as a panel quietly missing its hint. GtkWidget* panel = app.signal_list != nullptr ? gtk_widget_get_parent(app.signal_list) : nullptr; + // Counted over the panel's *subtree*, not its direct children: the heading lives in a heading row + // beside the pane's [edit] button, and a count of children would read 1 whatever the file lost. int labels = 0; int lists = 0; - for (GtkWidget* c = panel != nullptr ? gtk_widget_get_first_child(panel) : nullptr; c != nullptr; - c = gtk_widget_get_next_sibling(c)) { - if (GTK_IS_LABEL(c)) ++labels; - if (GTK_IS_LIST_BOX(c)) ++lists; - } + const std::function count = [&](GtkWidget* w) { + // The list stops the walk: its rows are the code's, and this reading is about the file's frame. + if (GTK_IS_LIST_BOX(w)) { + ++lists; + return; + } + if (GTK_IS_LABEL(w)) ++labels; + for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr; + c = gtk_widget_get_next_sibling(c)) { + count(c); + } + }; + if (panel != nullptr) count(panel); // The list's shape, beside the frame: `rows` is what the list holds, `signals` the streamed // ones, and the `MATH` divider plus the math channels account for the rest -- so // `rows == signals + 1 + math` is the invariant, and `divider=0` is a list that lost its `+`. @@ -5720,6 +5775,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit app.dbg_trigger_sensitive, app.dbg_switcher_visible, rows.cursors_visible() ? 1 : 0, rows.time_window(), app.window_limit_s); + std::printf("[udpscope] panes: trigger=%s signals=%s body_visible=%d summary='%s'\n", + app.dbg_trigger_locked ? "locked" : "config", + app.dbg_signals_locked ? "locked" : "config", app.dbg_trigger_body_visible, + app.dbg_trigger_summary.c_str()); std::printf("[udpscope] panel switches: trigger=%d/%d cursors=%d/%d drove_toolbar=%d " "(pane switch / header bar button; `drove_toolbar` is the pane's own switch having " "moved the button)\n", diff --git a/src/ui/signals-panel.ui b/src/ui/signals-panel.ui index fd2b38a..15d6d3f 100644 --- a/src/ui/signals-panel.ui +++ b/src/ui/signals-panel.ui @@ -21,9 +21,27 @@ 8 8 - - Signals - start + + + horizontal + 8 + + + Signals + start + 1 + + + + + emblem-system-symbolic + center + flat + + diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 276e5e3..14d92e2 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -258,6 +258,26 @@ struct App { /// True while `sync_panel_switches()` is writing the switches: setting a `GtkSwitch` emits its own /// `notify::active`, and without this the program's own write would come back as a click. bool panel_switch_sync = false; + /// The channel a session's trigger names, until `rebuild_trigger_signals()` can resolve it: the + /// file stores a *name* and the trigger needs an index into the acquired channels, which do not + /// exist until the stream has been described. Empty means "nothing pending". + std::string pending_trigger_channel; + /// The two panes' modes. *Locked* is the one a returning user gets: the trigger pane shows a line + /// that says what it is set to, the signal list shows only the signals that are on, and neither + /// offers a control that changes the configuration. A first run -- no session file for this source + /// -- starts in configuration mode, because that is what a first run is for. The flags last for the + /// session: a lock is a mode, not a setting, so it is not persisted. + bool trigger_locked = false; + bool signals_locked = false; + GtkWidget* trigger_summary = nullptr; + GtkWidget* trigger_edit = nullptr; + GtkWidget* signals_edit = nullptr; + int dbg_trigger_locked = -1; + int dbg_signals_locked = -1; + int dbg_trigger_body_visible = -1; + /// The compact line the locked trigger pane shows. It is the pane's whole content in that mode, so it + /// is read rather than assumed: empty here is a locked pane showing nothing at all. + std::string dbg_trigger_summary; /// The cursors pane: listing what the instruments read -- T0/T1/dT always, per row an Y1/Y2/dY /// where rulers are up, and in `mix` mode the value each cursor crosses each trace at. GtkWidget* cursors_mode_drop = nullptr; @@ -1279,7 +1299,7 @@ void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user); void open_signal_color(App& app, std::size_t signal); void rebuild_signal_list(App& app, const std::vector& signals); /// The `MATH` divider row in the one channel list, and the `+` it carries. -GtkWidget* make_math_divider(App& app); +GtkWidget* make_math_divider(App& app, bool with_add); void on_math_add_clicked(GtkButton* button, gpointer user); /// The index in `App::math` of the channel a synthetic signal is named after, or `-1`. int math_index_for_signal(App& app, const std::string& name); @@ -1413,6 +1433,25 @@ void select_side_tab(App& app, const char* name); /// Reads each pane's own switch back off the header bar's button, and the other way round: the two are /// one control in two places, and `panel_switch_sync` keeps the write from looking like a click. void sync_panel_switches(App& app); +/// The trigger's four enumerations as words: the panel's dropdown labels and the session file's +/// vocabulary, in one place so the two cannot drift. +const char* trigger_type_text(TriggerType t); +TriggerType trigger_type_from_text(const std::string& s); +const char* trigger_edge_text(TriggerEdge e); +TriggerEdge trigger_edge_from_text(const std::string& s); +const char* width_cmp_text(WidthCmp c); +WidthCmp width_cmp_from_text(const std::string& s); +const char* trigger_mode_text(TriggerMode m); +TriggerMode trigger_mode_from_text(const std::string& s); +/// The panel's controls from the model, and the session's trigger into both. +void write_trigger_widgets(App& app); +/// The compact line the trigger pane shows while it is locked, and the lock itself: the panes' +/// bodies, their [edit] buttons and the signal list's rows all follow from `trigger_locked` and +/// `signals_locked`. +void sync_trigger_summary(App& app); +void apply_pane_lock(App& app); +void on_pane_edit_clicked(GtkButton* button, gpointer user); +void apply_trigger_layout(App& app, const LayoutState::Trigger& t); void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user); void update_side_visibility(App& app); void on_sidebar_toggled(GtkToggleButton* button, gpointer user); diff --git a/src/ui_layout.cpp b/src/ui_layout.cpp index 6e54e8b..e6757ad 100644 --- a/src/ui_layout.cpp +++ b/src/ui_layout.cpp @@ -59,6 +59,30 @@ LayoutState collect_layout(App& app) { st.measures = app.measures; st.measure_stats = app.measure_stats_on; st.side_tab = app.side_tab; + // The trigger, its settings *and* whether it was on. The channel goes by name, like every identity + // in this file: every rebuild renumbers the channels. + { + const TriggerConfig& tc = app.model->trigger(); + const std::vector& channels = app.model->channels(); + st.trigger.enabled = tc.enabled; + if (tc.channel >= 0 && tc.channel < static_cast(channels.size())) { + st.trigger.channel = channels[static_cast(tc.channel)].name; + } + st.trigger.type = trigger_type_text(tc.condition.type); + st.trigger.edge = trigger_edge_text(tc.condition.edge); + st.trigger.cmp = width_cmp_text(tc.condition.cmp); + st.trigger.mode = trigger_mode_text(tc.mode); + st.trigger.level = tc.condition.level; + st.trigger.level2 = tc.condition.level2; + st.trigger.width_s = tc.condition.width_s; + st.trigger.width2_s = tc.condition.width2_s; + st.trigger.timeout_s = tc.condition.timeout_s; + st.trigger.pretrigger_pct = tc.pretrigger_pct; + st.trigger.holdoff_s = tc.holdoff_s; + st.trigger.noise_band = tc.condition.noise_band; + st.trigger.hf_fc = tc.condition.hf_fc; + st.trigger.level_given = true; // written means given, so a load keeps it + } // The connection is the session's too: the file's name carries only host and port. st.multicast = app.source_multicast; st.interface_addr = app.source_interface; @@ -289,6 +313,56 @@ void schedule_layout_save(App& app) { /// Applies a loaded layout: rows, slot assignments, calibration, window. Names /// that no longer exist in the stream are skipped rather than freeing their slot /// (a stream that renamed a signal must not blank the user's layout). +/// The session's trigger into the model and the panel, called from `apply_layout()`. The *channel* it +/// names is resolved where the channels exist -- `rebuild_trigger_signals()` picks up +/// `App::pending_trigger_channel` -- because at this point the stream may not have described its +/// signals yet, and a name that resolves to nothing would silently move the trigger to channel 0. +void apply_trigger_layout(App& app, const LayoutState::Trigger& t) { + { + std::lock_guard lock(app.model->mutex()); + TriggerConfig& tc = app.model->trigger(); + tc.condition.type = trigger_type_from_text(t.type); + tc.condition.edge = trigger_edge_from_text(t.edge); + tc.condition.cmp = width_cmp_from_text(t.cmp); + tc.mode = trigger_mode_from_text(t.mode); + tc.condition.level = t.level; + tc.condition.level2 = t.level2; + tc.condition.width_s = t.width_s; + tc.condition.width2_s = t.width2_s; + tc.condition.timeout_s = t.timeout_s; + tc.condition.noise_band = t.noise_band; + tc.condition.hf_fc = t.hf_fc; + tc.pretrigger_pct = t.pretrigger_pct; + tc.holdoff_s = t.holdoff_s; + } + // A file that carried a level keeps it: the panel's own default follows the channel's range, and + // `trigger_level_touched` is what says the user has a level of their own. + if (t.level_given) app.trigger_level_touched = true; + app.pending_trigger_channel = t.channel; + { + // If the stream has already described its channels, resolve the name *now*: the signals rebuild that + // would otherwise do it has already been and gone, so a pending name would leave the trigger on + // channel 0 -- which is a trigger on whichever signal happens to be first. + const std::vector& channels = app.model->channels(); + for (std::size_t c = 0; c < channels.size(); ++c) { + if (channels[c].name != app.pending_trigger_channel) continue; + std::lock_guard lock(app.model->mutex()); + app.model->trigger().channel = static_cast(c); + app.pending_trigger_channel.clear(); + break; + } + } + write_trigger_widgets(app); + // The state is applied by *pressing the same control a user would*: the handler is what arms, tells + // the events button, greys the body and syncs the pane's own switch. Deliberately outside the + // `updating_panels` window `apply_layout()` uses for the panels, because a toggle set inside it + // would emit `toggled` into a handler that returns at once and nothing would happen. + if (app.trigger_toggle != nullptr) { + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), t.enabled); + } + sync_panel_switches(app); +} + void apply_layout(App& app, const LayoutState& st) { ScopeModel& model = *app.model; const auto& signals = model.signals(); @@ -410,6 +484,17 @@ void apply_layout(App& app, const LayoutState& st) { if (app.area != nullptr) app.area->set_figure(&app.rows->figure()); app.relayout_pending = true; rebuild_signal_panels(app); + // The trigger last: its channel names an *acquired* channel, so it is resolved once the enables + // above have built the channel list. + apply_trigger_layout(app, st.trigger); + + // A session that loaded is a user who has been here before, and that is what the lock means: the two + // panes come up showing what they are set to rather than showing every control. A first run -- no + // file for this source -- never reaches this function and stays in configuration mode, which is what + // a first run is for. + app.trigger_locked = true; + app.signals_locked = true; + apply_pane_lock(app); } void on_layout_row_remove(GtkButton* button, gpointer user) { diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 98d7ab5..6b899e1 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -2322,6 +2322,13 @@ gboolean exit_probe_step1(gpointer user) { App* a = static_cast(user); // Record the panel states before the toggles are exercised. a->dbg_trigger_sensitive_default = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0; + a->dbg_trigger_locked = a->trigger_locked ? 1 : 0; + a->dbg_signals_locked = a->signals_locked ? 1 : 0; + a->dbg_trigger_body_visible = + a->trigger_body != nullptr && gtk_widget_get_visible(a->trigger_body) ? 1 : 0; + if (a->trigger_summary != nullptr) { + a->dbg_trigger_summary = gtk_label_get_text(GTK_LABEL(a->trigger_summary)); + } a->dbg_panel_switch_live = a->trigger_switch != nullptr && gtk_widget_get_sensitive(a->trigger_switch) && a->cursors_switch != nullptr && gtk_widget_get_sensitive(a->cursors_switch) diff --git a/src/ui_signals.cpp b/src/ui_signals.cpp index 86e10d5..de18219 100644 --- a/src/ui_signals.cpp +++ b/src/ui_signals.cpp @@ -166,7 +166,7 @@ int math_index_for_signal(App& app, const std::string& name) { /// The `MATH` boundary in the one channel list, carrying the `+` that creates a channel. Not /// selectable and not activatable: it is a heading, and a heading that highlights under the /// pointer reads as a row you can plot. -GtkWidget* make_math_divider(App& app) { +GtkWidget* make_math_divider(App& app, bool with_add) { GtkWidget* row = gtk_list_box_row_new(); gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(row), FALSE); gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), FALSE); @@ -180,11 +180,16 @@ GtkWidget* make_math_divider(App& app) { gtk_widget_set_halign(label, GTK_ALIGN_START); gtk_widget_set_hexpand(label, TRUE); gtk_box_append(GTK_BOX(box), label); - GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic"); - gtk_widget_add_css_class(add, "flat"); - gtk_widget_set_tooltip_text(add, "add a math channel"); - g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app); - gtk_box_append(GTK_BOX(box), add); + // Locked: the heading stays, because the list it divides is the one being read, but the `+` that + // creates a channel does not. A `+` on a locked list is a button that opens the editor the lock + // exists to keep shut. + if (with_add) { + GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic"); + gtk_widget_add_css_class(add, "flat"); + gtk_widget_set_tooltip_text(add, "add a math channel"); + g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app); + gtk_box_append(GTK_BOX(box), add); + } gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); return row; } @@ -278,6 +283,9 @@ void rebuild_signal_list(App& app, const std::vector& signals) { std::size_t plain = 0; for (std::size_t i = 0; i < signals.size(); ++i) { if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable + // Locked: a row per signal that is *on*, which is the list a user reads rather than the list they + // configure. Turning one on is what the pane's [edit] button is for. + if (app.signals_locked && !app.model->signal_enabled(i)) continue; const std::string base = layer_base_name(signals[i].name); bool seen = false; for (std::size_t j = 0; j < i && !seen; ++j) { @@ -293,7 +301,7 @@ void rebuild_signal_list(App& app, const std::vector& signals) { // `+` that creates the first one. if (sig.math && !math_divider_done) { math_divider_done = true; - gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app)); + gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app, !app.signals_locked)); } if (!sig.math) ++plain; GtkWidget* row = gtk_list_box_row_new(); @@ -309,7 +317,7 @@ void rebuild_signal_list(App& app, const std::vector& signals) { chip, !app.model->signal_enabled(i) ? "not acquired" : plotted ? "the colour this signal is plotted in -- click to change it" : "on no plot, so there is no trace to colour"); - if (plotted) { + if (plotted && !app.signals_locked) { // A gesture on the label rather than a `GtkButton` around it: a button brings its own // padding, and padding on this row is what made the list twice as tall once already (see // this function's note). The chip also has to stay exactly one disc wide, or the names @@ -343,7 +351,9 @@ void rebuild_signal_list(App& app, const std::vector& signals) { gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty()); g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i)); g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app); - gtk_box_append(GTK_BOX(box), enable); + // Locked: the switch decides what is acquired, so it is a configuration control and it goes with + // the rest of them. + if (!app.signals_locked) gtk_box_append(GTK_BOX(box), enable); GtkWidget* name = gtk_label_new(sig.name.c_str()); gtk_widget_set_hexpand(name, true); @@ -435,7 +445,9 @@ void rebuild_signal_list(App& app, const std::vector& signals) { gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover); gtk_widget_add_css_class(more, "flat"); gtk_widget_set_tooltip_text(more, "information and calibration"); - gtk_box_append(GTK_BOX(box), more); + // Locked: the menu holds calibration, deletion and a rename, which are exactly what this mode is + // not for. The popover is left unattached, so nothing can reach it. + if (!app.signals_locked) gtk_box_append(GTK_BOX(box), more); gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box); @@ -451,7 +463,9 @@ void rebuild_signal_list(App& app, const std::vector& signals) { } // Appended when there are no math channels at all: the `+` is the only way to create the first // one, so it cannot be conditional on one already existing. - if (!math_divider_done) gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app)); + if (!math_divider_done) { + gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app, !app.signals_locked)); + } app.dbg_signal_plain = plain; app.dbg_signal_rows = 0; for (GtkWidget* c = gtk_widget_get_first_child(list); c != nullptr; @@ -567,6 +581,10 @@ GtkWidget* build_signal_panel(App& app) { GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/signals-panel.ui"); GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "signal_panel")); app.signal_list = GTK_WIDGET(gtk_builder_get_object(ui, "signal_list")); + app.signals_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit")); + if (app.signals_edit != nullptr) { + g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app); + } if (box == nullptr || app.signal_list == nullptr) { // A missing id is a mistake in the `.ui`: the panel comes up empty rather than taking the window with // it, and `[udpscope] signals panel:` reads the difference. diff --git a/src/ui_trigger.cpp b/src/ui_trigger.cpp index 6b2525e..78697fe 100644 --- a/src/ui_trigger.cpp +++ b/src/ui_trigger.cpp @@ -5,6 +5,8 @@ #include "ui_app.hpp" +#include + #include #include @@ -40,6 +42,164 @@ void on_arm_clicked(GtkButton* button, gpointer user) { /// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists /// only the conditions that are implemented, so its index is not `TriggerType`'s value. +/// The trigger's four enumerations as words, and back. These are the panel's dropdown labels *and* +/// what the session file stores: an index would tie the file to a declaration order, which is the +/// lesson `math_op_at()` already carries, and two vocabularies (one for the UI, one for the file) +/// drift apart the moment one of them gains a member. +const char* trigger_type_text(TriggerType t) { + switch (t) { + case TriggerType::Edge: return "edge"; + case TriggerType::PulseWidth: return "pulse width"; + case TriggerType::Window: return "window"; + case TriggerType::Dropout: return "dropout"; + } + return "edge"; +} +TriggerType trigger_type_from_text(const std::string& s) { + if (s == "pulse width") return TriggerType::PulseWidth; + if (s == "window") return TriggerType::Window; + if (s == "dropout") return TriggerType::Dropout; + return TriggerType::Edge; +} +const char* trigger_edge_text(TriggerEdge e) { + switch (e) { + case TriggerEdge::Rising: return "rising"; + case TriggerEdge::Falling: return "falling"; + case TriggerEdge::Both: return "both"; + } + return "rising"; +} +TriggerEdge trigger_edge_from_text(const std::string& s) { + if (s == "falling") return TriggerEdge::Falling; + if (s == "both") return TriggerEdge::Both; + return TriggerEdge::Rising; +} +const char* width_cmp_text(WidthCmp c) { + switch (c) { + case WidthCmp::Less: return "<"; + case WidthCmp::Greater: return ">"; + case WidthCmp::Within: return "within"; + case WidthCmp::Outside: return "outside"; + } + return ">"; +} +WidthCmp width_cmp_from_text(const std::string& s) { + if (s == "<") return WidthCmp::Less; + if (s == "within") return WidthCmp::Within; + if (s == "outside") return WidthCmp::Outside; + return WidthCmp::Greater; +} +const char* trigger_mode_text(TriggerMode m) { + return m == TriggerMode::Single ? "single" : "normal"; +} +TriggerMode trigger_mode_from_text(const std::string& s) { + return s == "single" ? TriggerMode::Single : TriggerMode::Normal; +} + +/// The dropdown's row for a piece of text, or 0. Asked of the widget's own model, so the mapping from +/// a stored word to a selection is the list the user sees and not a second table to keep in step. +static guint dropdown_index_of(GtkWidget* drop, const char* text) { + GListModel* model = drop != nullptr ? gtk_drop_down_get_model(GTK_DROP_DOWN(drop)) : nullptr; + const guint n = model != nullptr ? g_list_model_get_n_items(model) : 0; + for (guint i = 0; i < n; ++i) { + GtkStringObject* item = GTK_STRING_OBJECT(g_list_model_get_item(model, i)); + const bool hit = item != nullptr && std::strcmp(gtk_string_object_get_string(item), text) == 0; + if (item != nullptr) g_object_unref(item); + if (hit) return i; + } + return 0; +} + +/// The panel's controls from the model: what a session load has just put in the model, so the pane +/// shows the trigger it is about to run. Guarded, because a control's own handler publishes what it +/// holds and this is the other direction. +void write_trigger_widgets(App& app) { + if (app.trigger_level_spin == nullptr) return; + TriggerConfig tc; + { + std::lock_guard lock(app.model->mutex()); + tc = app.model->trigger(); + } + const bool was = app.updating_panels; + app.updating_panels = true; + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), tc.condition.level); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_pre_spin), tc.pretrigger_pct); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_holdoff_spin), tc.holdoff_s); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width_spin), tc.condition.width_s); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width2_spin), tc.condition.width2_s); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level2_spin), tc.condition.level2); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_timeout_spin), tc.condition.timeout_s); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_band_spin), tc.condition.noise_band); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_hf_spin), tc.condition.hf_fc); + gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_type_drop), + dropdown_index_of(app.trigger_type_drop, + trigger_type_text(tc.condition.type))); + gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_edge_drop), + dropdown_index_of(app.trigger_edge_drop, + trigger_edge_text(tc.condition.edge))); + gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_cmp_drop), + dropdown_index_of(app.trigger_cmp_drop, + width_cmp_text(tc.condition.cmp))); + gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_mode_drop), + dropdown_index_of(app.trigger_mode_drop, + trigger_mode_text(tc.mode))); + app.updating_panels = was; + sync_trigger_rows(app); +} + +/// The compact line: what the pane is set to, in the words the panel's own controls use. Read from the +/// model rather than from the widgets, because the widgets are not on screen in this mode and are not +/// rebuilt when a session loads. +void sync_trigger_summary(App& app) { + if (app.trigger_summary == nullptr) return; + TriggerConfig tc; + std::string channel; + { + std::lock_guard lock(app.model->mutex()); + tc = app.model->trigger(); + const std::vector& channels = app.model->channels(); + if (tc.channel >= 0 && tc.channel < static_cast(channels.size())) { + channel = channels[static_cast(tc.channel)].name; + } + } + char buf[192]; + std::string text = trigger_type_text(tc.condition.type); + text += " · "; + text += channel.empty() ? "no channel" : channel; + switch (tc.condition.type) { + case TriggerType::PulseWidth: + std::snprintf(buf, sizeof(buf), " · %s %.6g ms", width_cmp_text(tc.condition.cmp), + tc.condition.width_s * 1000.0); + text += buf; + break; + case TriggerType::Window: + std::snprintf(buf, sizeof(buf), " · %.6g…%.6g", tc.condition.level, tc.condition.level2); + text += buf; + break; + case TriggerType::Dropout: + std::snprintf(buf, sizeof(buf), " · %.6g ms", tc.condition.timeout_s * 1000.0); + text += buf; + break; + case TriggerType::Edge: + std::snprintf(buf, sizeof(buf), " · level %.6g", tc.condition.level); + text += buf; + break; + } + if (!(tc.condition.noise_band > 0.0) || tc.condition.type == TriggerType::Dropout) { + // A band is not offered for a dropout, so it is not quoted for one either. + } else { + std::snprintf(buf, sizeof(buf), " · band %.6g", tc.condition.noise_band); + text += buf; + } + if (tc.condition.hf_fc > 0.0) { + std::snprintf(buf, sizeof(buf), " · HF %.6g Hz", tc.condition.hf_fc); + text += buf; + } + std::snprintf(buf, sizeof(buf), " · %.6g %% pre", tc.pretrigger_pct); + text += buf; + gtk_label_set_text(GTK_LABEL(app.trigger_summary), text.c_str()); +} + TriggerType trigger_type_at(guint i) { switch (i) { case 1: @@ -248,17 +408,24 @@ GtkWidget* build_trigger_panel(App& app) { G_CALLBACK(on_trigger_signal_changed), &app); row_for(ui, "signal_row", app.trigger_signal_drop); - const char* types[] = {"edge", "pulse width", "window", "dropout", nullptr}; + const char* types[] = {trigger_type_text(TriggerType::Edge), + trigger_type_text(TriggerType::PulseWidth), + trigger_type_text(TriggerType::Window), + trigger_type_text(TriggerType::Dropout), nullptr}; app.trigger_type_drop = gtk_drop_down_new_from_strings(types); gtk_widget_set_tooltip_text(app.trigger_type_drop, "what counts as an event; the fields below change to suit it"); row_for(ui, "condition_row", app.trigger_type_drop); - const char* edges[] = {"rising", "falling", "both", nullptr}; + const char* edges[] = {trigger_edge_text(TriggerEdge::Rising), + trigger_edge_text(TriggerEdge::Falling), + trigger_edge_text(TriggerEdge::Both), nullptr}; app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges); row_for(ui, "edge_row", app.trigger_edge_drop); - const char* cmps[] = {"<", ">", "within", "outside", nullptr}; + const char* cmps[] = {width_cmp_text(WidthCmp::Less), width_cmp_text(WidthCmp::Greater), + width_cmp_text(WidthCmp::Within), width_cmp_text(WidthCmp::Outside), + nullptr}; app.trigger_cmp_drop = gtk_drop_down_new_from_strings(cmps); gtk_widget_set_tooltip_text(app.trigger_cmp_drop, "how the measured pulse width is compared: '<' is the glitch " @@ -307,7 +474,8 @@ GtkWidget* build_trigger_panel(App& app) { app.trigger_hf_spin = spin(0.0, 1.0e7, 1.0, 3, 0.0); row_for(ui, "hf_row", app.trigger_hf_spin); - const char* modes[] = {"normal", "single", nullptr}; + const char* modes[] = {trigger_mode_text(TriggerMode::Normal), + trigger_mode_text(TriggerMode::Single), nullptr}; app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes); row_for(ui, "mode_row", app.trigger_mode_drop); @@ -440,6 +608,20 @@ GtkWidget* build_trigger_panel(App& app) { gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_widget_set_hexpand(title, TRUE); gtk_box_append(GTK_BOX(head), title); + // What the pane is set to, for the mode where its settings are not on screen. + app.trigger_summary = gtk_label_new(""); + gtk_widget_add_css_class(app.trigger_summary, "dim-label"); + gtk_label_set_ellipsize(GTK_LABEL(app.trigger_summary), PANGO_ELLIPSIZE_END); + gtk_widget_set_halign(app.trigger_summary, GTK_ALIGN_START); + gtk_widget_set_hexpand(app.trigger_summary, TRUE); + gtk_widget_set_visible(app.trigger_summary, FALSE); + gtk_box_append(GTK_BOX(head), app.trigger_summary); + app.trigger_edit = gtk_button_new_from_icon_name("emblem-system-symbolic"); + gtk_widget_add_css_class(app.trigger_edit, "flat"); + gtk_widget_set_valign(app.trigger_edit, GTK_ALIGN_CENTER); + gtk_widget_set_tooltip_text(app.trigger_edit, "configure the trigger"); + g_signal_connect(app.trigger_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app); + gtk_box_append(GTK_BOX(head), app.trigger_edit); app.trigger_switch = gtk_switch_new(); gtk_widget_set_valign(app.trigger_switch, GTK_ALIGN_CENTER); gtk_widget_set_tooltip_text(app.trigger_switch,