The hamburger menu's help rows were three flat entries and a window. They are a **`Help` submenu** now, which is where every GNOME menu keeps them, with the four things a first run cannot find: - Keyboard shortcuts Ctrl+/ (the existing overview, `show_shortcuts()`) - Documentation F1 (the existing manual, `show_help()`) - Guided tour Ctrl+T (new) - Enable contextual help (new, a stateful row: its check *is* the setting) The submenu *replaces* the menu's rows rather than floating beside them, the same shape and the same reason as the plot's right-click menu: a submenu torn out of the menu it belongs to is a second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the moment anything else closed its popover. It is the pages of a `GtkStack`, not a rebuild of the box and not a swap of the popover's child: the main view holds `app.layout_toggle`, which the tick's `sync_layout_editor()` and the `L` accel read, so rebuilding would leave that pointer on a destroyed widget, and a swapped child would leave the view that is not showing without an owner. **The contextual help is one table and a "?" per surface** (`src/ui_context_help.cpp`): the plot, the trigger, the cursors, the measurements, the math channels, the signals, the event history, the time window, the status line, and the math editor's identity row. Each row names the *heading box* its button joins -- a sibling of the title, never an overlay child on a corner, which is what would cover the switch or the [edit] button already there -- so adding a surface is a row and a widget pointer. Off means *gone*, not greyed: the buttons are removed, and they are found by a data key rather than a list of pointers because the math editor's heading is built afresh on every opening and a list would go stale one dialog later. `contextual_help` and `tour_seen` are keys of `settings.toml`, written through the same `Settings`/`load_settings()`/`save_settings()` the Preferences dialog uses, whose own rows start from `load_settings()` and so carry them through untouched. **The guided tour is five steps on a `GtkOverlay`** above the window's content: the plot, the side panel, the trigger, the signals and the status line. There is no popover in it and therefore no pointer grab to leak -- four dim rectangles around the hole are what make it modal -- and the probe asserts that as a reading (`controllers=` against the count taken before it opened) rather than as a claim. Everything is placed from `gtk_widget_compute_bounds()` re-read on every frame, because a widget's allocation is 0 on the frame it is first shown and a window resize moves it under a one-shot measurement; the hole is kept from the last frame that could measure the target, because a frame that lands between two layout passes would otherwise empty it and make the spotlight jump. `Skip`, `Finish` and `Escape` all hide every child and leave nothing behind. A first run gets the tour once, decided by `tour_seen` and not by "the settings file did not exist" (a user who changed the theme has a file and has still never seen it), and the flag is written when the tour *starts* rather than when it finishes: a user who skips it in the first second has still seen it. `UDPSCOPE_PROBE_HELPUI_MS` drives all of it -- the auto-start, the menu's seven rows and the submenu's five, the toggle's effect read as a count of affordances on either side of it, and the tour started, stepped forwards and back and dismissed -- and the exit dump gains `help layer:` and `help figures:`, the latter being the ten surfaces' window-coordinate rectangles that the manual's annotated screenshots crop their boxes from. Two of them are what the D-Bus-free headless run can still not decide, and both are limitations rather than defects: export carries no "?" (it is a menu action with no surface of its own, and a popover inside the menu's popover is the nested-popover trap the invariants already warn about), and the tour card cannot miss its target when the target is the whole content -- which is what the plot is at the window's 756 px minimum, where the split view overlays the side panel instead of putting it beside the plot. The probe prints both rectangles rather than asserting a placement, so that case reads as the geometry it is. Also fixed while it was found: `gtk_widget_measure()` includes the widget's own margins (measured: a 280 px box with `margin-start 453` reports 733), so a measure that is used to *place* the widget is a feedback loop -- that is what made the tour card walk between the corner and the target from frame to frame.
169 lines
7.8 KiB
C++
169 lines
7.8 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
//
|
|
// The cursors pane: what the two vertical markers and the per-row rulers read, in the four cursor\n// modes, rebuilt only when the rows or the mode change.
|
|
|
|
#include "ui_app.hpp"
|
|
|
|
#include <gtk/gtk.h>
|
|
|
|
#include <string>
|
|
|
|
namespace udpscope {
|
|
namespace ui_impl {
|
|
|
|
/// `%.6g s` is exact enough for a cursor readout and short enough for the panel.
|
|
void update_cursors_pane(App& app) {
|
|
if (app.cursors_body == nullptr) return;
|
|
if (!app.rows->cursors_visible()) {
|
|
// A readout quotes a stored sample; with the instruments off there is no sample it quotes, so
|
|
// the cells blank rather than keeping the last ones. `"--"` is the same spelling an
|
|
// unavailable cell already uses -- two spellings of one state in one grid would be the defect.
|
|
for (const App::CursorLine& line : app.cursors_lines) {
|
|
if (line.a != nullptr) gtk_label_set_text(GTK_LABEL(line.a), "--");
|
|
if (line.b != nullptr) gtk_label_set_text(GTK_LABEL(line.b), "--");
|
|
if (line.d != nullptr) gtk_label_set_text(GTK_LABEL(line.d), "--");
|
|
}
|
|
return;
|
|
}
|
|
if (app.cursors_pane_mode != static_cast<int>(app.rows->cursor_mode()) ||
|
|
app.cursors_pane_rows != app.rows->row_count() ||
|
|
app.cursors_pane_generation != app.model->generation() ||
|
|
app.cursors_pane_bound != bound_slot_mask(app)) {
|
|
rebuild_cursors_pane(app);
|
|
}
|
|
const double t0 = app.rows->cursor_a_x();
|
|
const double t1 = app.rows->cursor_b_x();
|
|
const auto put = [](GtkWidget* w, const std::string& text) {
|
|
if (w != nullptr) gtk_label_set_text(GTK_LABEL(w), text.c_str());
|
|
};
|
|
|
|
const RowSet::CursorMode mode = app.rows->cursor_mode();
|
|
const bool rulers = mode == RowSet::CursorMode::Horizontal || mode == RowSet::CursorMode::Both;
|
|
const bool crossings = mode == RowSet::CursorMode::Mix;
|
|
for (const App::CursorLine& line : app.cursors_lines) {
|
|
if (line.a == nullptr) continue;
|
|
// One measurement per cell, each with the unit its own axis declares: the pane is a
|
|
// readout beside a plot, where the values are the point.
|
|
std::string va;
|
|
std::string vb;
|
|
std::string vd;
|
|
if (line.slot == -2) {
|
|
va = format_measure(t0, "s");
|
|
vb = format_measure(t1, "s");
|
|
vd = format_measure(t1 - t0, "s");
|
|
} else if (line.slot == -1 && rulers) {
|
|
const std::string unit = row_unit(app, line.row);
|
|
const double y1 = app.rows->ruler_y(line.row);
|
|
const double y2 = app.rows->ruler_b_y(line.row);
|
|
va = format_measure(y1, unit);
|
|
vb = format_measure(y2, unit);
|
|
vd = format_measure(y2 - y1, unit);
|
|
} else if (crossings && line.slot >= 0) {
|
|
// Where each cursor crosses this trace: the value the trace has at that time,
|
|
// read the way a crosshair reads it.
|
|
// The same call the crossing markers use, on the same trace: the pane and the
|
|
// marks on the plot cannot disagree about what the value is.
|
|
const RowSet::Row& row = app.rows->row(line.row);
|
|
double y1 = 0.0;
|
|
double y2 = 0.0;
|
|
const bool a = row.plot != nullptr &&
|
|
row.plot->crosshair_value(static_cast<plot::u32>(line.slot), t0, y1);
|
|
const bool b = row.plot != nullptr &&
|
|
row.plot->crosshair_value(static_cast<plot::u32>(line.slot), t1, y2);
|
|
if (a && b) {
|
|
const std::string unit = slot_unit(app, line.row, line.slot);
|
|
va = format_measure(y1, unit);
|
|
vb = format_measure(y2, unit);
|
|
vd = format_measure(y2 - y1, unit);
|
|
} else {
|
|
va = vb = vd = a || b ? "partial" : "--";
|
|
}
|
|
} else {
|
|
va = vb = vd = "--";
|
|
}
|
|
put(line.a, va);
|
|
put(line.b, vb);
|
|
put(line.d, vd);
|
|
}
|
|
}
|
|
|
|
GtkWidget* build_cursors_panel(App& app) {
|
|
// The panel's shape is a UI file; the *contents* of its grid are not, because they follow the cursor
|
|
// mode and the rows and are rebuilt whenever either changes.
|
|
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/cursors-panel.ui");
|
|
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "cursors_panel"));
|
|
app.cursors_mode_drop = GTK_WIDGET(gtk_builder_get_object(ui, "mode_drop"));
|
|
app.cursors_reset = GTK_WIDGET(gtk_builder_get_object(ui, "reset"));
|
|
app.cursors_body = GTK_WIDGET(gtk_builder_get_object(ui, "body"));
|
|
app.cursors_switch = GTK_WIDGET(gtk_builder_get_object(ui, "cursors_switch"));
|
|
if (app.cursors_switch != nullptr) {
|
|
gtk_widget_set_tooltip_text(app.cursors_switch,
|
|
"the cursors, the same switch the header bar carries");
|
|
g_signal_connect(app.cursors_switch, "notify::active", G_CALLBACK(on_panel_switch_toggled), &app);
|
|
}
|
|
if (box == nullptr || app.cursors_mode_drop == nullptr || app.cursors_reset == nullptr ||
|
|
app.cursors_body == nullptr) {
|
|
// A missing id is a mistake in the `.ui`. The panel comes up empty rather than taking the window
|
|
// with it, and `[cursors]` reads differently rather than the application failing to start.
|
|
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
|
}
|
|
|
|
// The panel's heading row -- its first child, which carries the title, the mode dropdown, Reset and
|
|
// the pane's switch. It is where the contextual help's "?" joins (see `ui_context_help.cpp`).
|
|
app.cursors_head = gtk_widget_get_first_child(box);
|
|
|
|
// The mode names are the dropdown's model, and they are a C array, so they are set here.
|
|
const char* names[5] = {kCursorModeNames[0], kCursorModeNames[1], kCursorModeNames[2],
|
|
kCursorModeNames[3], nullptr};
|
|
gtk_drop_down_set_model(GTK_DROP_DOWN(app.cursors_mode_drop),
|
|
G_LIST_MODEL(gtk_string_list_new(names)));
|
|
gtk_widget_set_tooltip_text(app.cursors_mode_drop,
|
|
"vertical: two times, shared by every row\n"
|
|
"horizontal: one value per row, at the two times\n"
|
|
"both: the two times and one value per row\n"
|
|
"mix: the two times, read off each trace where they cross");
|
|
g_signal_connect(app.cursors_mode_drop, "notify::selected",
|
|
G_CALLBACK(on_cursor_mode_changed), &app);
|
|
|
|
// Reset lives beside the mode it resets rather than in the overflow menu: it acts on this pane's
|
|
// instruments and is only visible while they are on, both of which are this pane's facts.
|
|
gtk_widget_set_tooltip_text(app.cursors_reset, "re-place the cursors at the window's edges");
|
|
g_signal_connect(app.cursors_reset, "clicked", G_CALLBACK(on_cursors_reset), &app);
|
|
|
|
return box;
|
|
}
|
|
|
|
void on_cursors_toggled(GtkToggleButton* button, gpointer user) {
|
|
App* app = static_cast<App*>(user);
|
|
const bool on = gtk_toggle_button_get_active(button);
|
|
app->rows->set_cursors_visible(on);
|
|
// Disabled, not hidden, and the same for the reset beside it: with the cursors off there is
|
|
// nothing to reset, but the button must stay where the user will look for it. The *body* goes
|
|
// insensitive and not the panel, because the panel's header carries the switch that turns the
|
|
// cursors back on -- and a control inside an insensitive parent cannot be clicked.
|
|
gtk_widget_set_sensitive(app->cursors_reset, on);
|
|
if (app->cursors_body != nullptr) gtk_widget_set_sensitive(app->cursors_body, on);
|
|
if (!on) update_cursors_pane(*app);
|
|
if (on) {
|
|
app->rows->reset_cursors();
|
|
rebuild_cursors_pane(*app);
|
|
update_cursors_pane(*app);
|
|
}
|
|
// The pane's own switch shows the same state, whichever of the two was clicked.
|
|
sync_panel_switches(*app);
|
|
}
|
|
|
|
void on_cursors_reset(GtkButton*, gpointer user) {
|
|
App* app = static_cast<App*>(user);
|
|
// A reset the user cannot see reads as a dead button, so it also switches the
|
|
// cursors on.
|
|
if (!gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app->cursors_toggle))) {
|
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->cursors_toggle), true);
|
|
}
|
|
app->rows->reset_cursors();
|
|
}
|
|
|
|
} // namespace ui_impl
|
|
} // namespace udpscope
|