Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
592 lines
28 KiB
C++
592 lines
28 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
// The compact readouts at the top of the dock: a read-only copy of the cursors table and of the
|
|
// measurement values, in a fixed width, with an Edit button for each that opens the full panel.
|
|
//
|
|
// The strings are not formatted here. `update_cursors_pane()` and `update_measures()` format each value
|
|
// once and hand the same variable to the page's label and to `dock_cursor_cell()` / `dock_measure_value()`,
|
|
// so the two views cannot disagree. Every label is ellipsized at a fixed `width-chars`, so the dock's
|
|
// layout does not depend on what a value reads; the full text is the label's tooltip.
|
|
//
|
|
// The same strings are shown a third time in the readouts strip under the plot (`readouts_strip`, one
|
|
// fixed-height line: a "Cursors" button, one chip per cursor line with its delta, a "Measures" button, one
|
|
// chip per measurement with its value), which is on screen whenever the dock is not beside the plot (hidden
|
|
// with the Dock button, or the window collapsed and the dock an overlay). The setters below
|
|
// write the strip's labels in the same call as the dock's, from the same argument, so there is still one
|
|
// string and no second formatter.
|
|
//
|
|
// Below them, the Events block: one compact row per cached event (marker, title, time; no thumbnails),
|
|
// a Manage button for the full list, and one click on a row to replay it. Its rows are built from the
|
|
// same `event_store->list()` and the same `event_row_title()` as the events page, so the two cannot
|
|
// disagree.
|
|
|
|
#include "ui_app.hpp"
|
|
|
|
#include <gtk/gtk.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstring>
|
|
#include <ctime>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace udpscope {
|
|
namespace ui_impl {
|
|
|
|
namespace {
|
|
|
|
/// The width, in characters, of a value cell and of a `who` / type cell. Fixed, so the dock is as wide
|
|
/// with `--` as with `-123.456 mV`; their sum (5 + 3 x 9 characters) is what keeps the readouts inside the
|
|
/// dock's 280 px.
|
|
constexpr int kValueChars = 9;
|
|
constexpr int kNameChars = 5;
|
|
|
|
GtkWidget* fixed_label(const char* text, int chars, GtkAlign halign) {
|
|
GtkWidget* l = gtk_label_new(text);
|
|
gtk_widget_set_halign(l, halign);
|
|
gtk_label_set_ellipsize(GTK_LABEL(l), PANGO_ELLIPSIZE_END);
|
|
gtk_label_set_width_chars(GTK_LABEL(l), chars);
|
|
gtk_label_set_max_width_chars(GTK_LABEL(l), chars);
|
|
gtk_widget_add_css_class(l, "caption");
|
|
return l;
|
|
}
|
|
|
|
/// Sets a value label's text and, with it, the tooltip that carries what the ellipsis cut. The text is
|
|
/// compared first so the per-frame write of an unchanged value costs nothing.
|
|
void set_value_text(GtkWidget* label, const std::string& text) {
|
|
if (label == nullptr) return;
|
|
if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text.c_str()) == 0) return;
|
|
gtk_label_set_text(GTK_LABEL(label), text.c_str());
|
|
gtk_widget_set_tooltip_text(label, text.c_str());
|
|
}
|
|
|
|
void on_edit_cursors(GtkButton*, gpointer user) {
|
|
slideover_open(*static_cast<App*>(user), "cursors");
|
|
}
|
|
|
|
void on_edit_measures(GtkButton*, gpointer user) {
|
|
slideover_open(*static_cast<App*>(user), "measure");
|
|
}
|
|
|
|
/// A click on a measurement's line opens that measurement's dialog, as the page row's `edit` button
|
|
/// does. The line carries its index (plus one, so that zero is "none") in its data.
|
|
void on_measure_line_pressed(GtkGestureClick* gesture, int n_press, double, double, gpointer user) {
|
|
if (n_press != 1) return;
|
|
GtkWidget* line = gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(gesture));
|
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(line), "udpscope-measure-index")) - 1;
|
|
if (idx < 0) return;
|
|
open_measure_dialog(*static_cast<App*>(user), idx);
|
|
}
|
|
|
|
/// The page's `who` cell, verbatim, onto a copy: a chip and a name as markup, or plain text, and its
|
|
/// tooltip. The dock's cell and the strip's chip are both made by it.
|
|
void copy_who(GtkWidget* dst, GtkWidget* src) {
|
|
if (src == nullptr) return;
|
|
const char* text = gtk_label_get_label(GTK_LABEL(src));
|
|
if (gtk_label_get_use_markup(GTK_LABEL(src))) {
|
|
gtk_label_set_markup(GTK_LABEL(dst), text);
|
|
} else {
|
|
gtk_label_set_text(GTK_LABEL(dst), text);
|
|
}
|
|
const char* tip = gtk_widget_get_tooltip_text(src);
|
|
gtk_widget_set_tooltip_text(dst, tip != nullptr ? tip : gtk_label_get_text(GTK_LABEL(src)));
|
|
}
|
|
|
|
void clear_box(GtkWidget* box) {
|
|
GtkWidget* child = gtk_widget_get_first_child(box);
|
|
while (child != nullptr) {
|
|
GtkWidget* next = gtk_widget_get_next_sibling(child);
|
|
gtk_box_remove(GTK_BOX(box), child);
|
|
child = next;
|
|
}
|
|
}
|
|
|
|
/// The strip's cell widths, in characters. Fixed like the dock's, so the strip's chips are as wide with
|
|
/// `--` as with `-123.456 mV` (what does not fit scrolls).
|
|
constexpr int kStripWhoChars = 6;
|
|
constexpr int kStripTypeChars = 10;
|
|
|
|
/// A flat text button that opens a slide-over page. It only opens a page, so a rebuild can never free the
|
|
/// widget its own signal was emitted from.
|
|
GtkWidget* strip_button(const char* label, GCallback on_click, App* app, const char* tip) {
|
|
GtkWidget* b = gtk_button_new_with_label(label);
|
|
gtk_widget_add_css_class(b, "flat");
|
|
gtk_widget_add_css_class(b, "udpscope-strip-button");
|
|
gtk_widget_set_valign(b, GTK_ALIGN_CENTER);
|
|
gtk_widget_set_tooltip_text(b, tip);
|
|
g_signal_connect(b, "clicked", on_click, app);
|
|
return b;
|
|
}
|
|
|
|
/// A section's heading row: its title at the left, its Edit button at the right.
|
|
GtkWidget* section_head(const char* title, GtkWidget** edit_out, GCallback on_edit, App* app,
|
|
const char* tip) {
|
|
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
|
GtkWidget* t = gtk_label_new(title);
|
|
gtk_widget_set_halign(t, GTK_ALIGN_START);
|
|
gtk_widget_set_hexpand(t, true);
|
|
gtk_widget_add_css_class(t, "caption-heading");
|
|
gtk_box_append(GTK_BOX(row), t);
|
|
GtkWidget* edit = gtk_button_new_with_label("Edit");
|
|
gtk_widget_add_css_class(edit, "flat");
|
|
gtk_widget_set_tooltip_text(edit, tip);
|
|
g_signal_connect(edit, "clicked", on_edit, app);
|
|
gtk_box_append(GTK_BOX(row), edit);
|
|
*edit_out = edit;
|
|
return row;
|
|
}
|
|
|
|
/// The same header `cursors_header()` writes into the page's table, small.
|
|
void dock_cursors_header(GtkGrid* grid) {
|
|
const char* texts[4] = {"", "A", "B", "delta"};
|
|
for (int c = 0; c < 4; ++c) {
|
|
GtkWidget* l = fixed_label(texts[c], c == 0 ? kNameChars : kValueChars,
|
|
c == 0 ? GTK_ALIGN_START : GTK_ALIGN_END);
|
|
gtk_widget_add_css_class(l, "dim-label");
|
|
gtk_grid_attach(grid, l, c, 0, 1, 1);
|
|
}
|
|
}
|
|
|
|
/// Fills the strip `main-window.ui` declares: the two buttons and the two boxes the rebuilds fill.
|
|
void build_readouts_strip(App& app) {
|
|
if (app.strip_box == nullptr) return;
|
|
clear_box(app.strip_box);
|
|
app.strip_cursor_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
|
app.strip_measure_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
|
// Each section is a group of its own so the strip can show only the one that has something to say.
|
|
app.strip_cursor_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
|
gtk_box_append(GTK_BOX(app.strip_cursor_group),
|
|
strip_button("Cursors", G_CALLBACK(on_edit_cursors), &app, "the cursors' full panel"));
|
|
gtk_box_append(GTK_BOX(app.strip_cursor_group), app.strip_cursor_box);
|
|
app.strip_sep = gtk_separator_new(GTK_ORIENTATION_VERTICAL);
|
|
gtk_widget_set_margin_top(app.strip_sep, 5);
|
|
gtk_widget_set_margin_bottom(app.strip_sep, 5);
|
|
app.strip_measure_group = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
|
gtk_box_append(GTK_BOX(app.strip_measure_group),
|
|
strip_button("Measures", G_CALLBACK(on_edit_measures), &app,
|
|
"the measurements' full panel"));
|
|
gtk_box_append(GTK_BOX(app.strip_measure_group), app.strip_measure_box);
|
|
gtk_box_append(GTK_BOX(app.strip_box), app.strip_cursor_group);
|
|
gtk_box_append(GTK_BOX(app.strip_box), app.strip_sep);
|
|
gtk_box_append(GTK_BOX(app.strip_box), app.strip_measure_group);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
GtkWidget* build_readouts_dock(App& app) {
|
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
|
|
gtk_widget_set_margin_top(box, 6);
|
|
gtk_widget_set_margin_bottom(box, 6);
|
|
gtk_widget_set_margin_start(box, 6);
|
|
gtk_widget_set_margin_end(box, 6);
|
|
|
|
GtkWidget* heading = gtk_label_new("Readouts");
|
|
gtk_widget_set_halign(heading, GTK_ALIGN_START);
|
|
gtk_widget_add_css_class(heading, "heading");
|
|
gtk_box_append(GTK_BOX(box), heading);
|
|
|
|
gtk_box_append(GTK_BOX(box), section_head("Cursors", &app.dock_cursors_edit,
|
|
G_CALLBACK(on_edit_cursors), &app,
|
|
"the cursors' full panel"));
|
|
app.dock_cursor_grid = gtk_grid_new();
|
|
gtk_grid_set_row_spacing(GTK_GRID(app.dock_cursor_grid), 2);
|
|
gtk_grid_set_column_spacing(GTK_GRID(app.dock_cursor_grid), 4);
|
|
gtk_box_append(GTK_BOX(box), app.dock_cursor_grid);
|
|
|
|
gtk_box_append(GTK_BOX(box), section_head("Measurements", &app.dock_measures_edit,
|
|
G_CALLBACK(on_edit_measures), &app,
|
|
"the measurements' full panel"));
|
|
app.dock_measures_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
|
gtk_box_append(GTK_BOX(box), app.dock_measures_box);
|
|
|
|
app.dock_readouts = box;
|
|
build_readouts_strip(app);
|
|
// The tables are built from the pages' current lines, which exist already when the pages were built
|
|
// first; otherwise the pages' own rebuild fills them.
|
|
dock_rebuild_cursors(app);
|
|
dock_rebuild_measures(app);
|
|
return box;
|
|
}
|
|
|
|
void dock_rebuild_cursors(App& app) {
|
|
if (app.dock_cursor_grid == nullptr) return;
|
|
GtkGrid* grid = GTK_GRID(app.dock_cursor_grid);
|
|
GtkWidget* child = gtk_widget_get_first_child(app.dock_cursor_grid);
|
|
while (child != nullptr) {
|
|
GtkWidget* next = gtk_widget_get_next_sibling(child);
|
|
gtk_grid_remove(grid, child);
|
|
child = next;
|
|
}
|
|
app.dock_cursor_lines.clear();
|
|
// The strip's chips are rebuilt with the dock's lines (one per line, same order), so the counts agree by
|
|
// construction. The box itself keeps its sensitivity across the rebuild.
|
|
if (app.strip_cursor_box != nullptr) clear_box(app.strip_cursor_box);
|
|
app.strip_cursor_who.clear();
|
|
app.strip_cursor_deltas.clear();
|
|
dock_cursors_header(grid);
|
|
int at = 1;
|
|
for (const App::CursorLine& src : app.cursors_lines) {
|
|
App::DockCursorLine line;
|
|
line.label = fixed_label("", kNameChars, GTK_ALIGN_START);
|
|
copy_who(line.label, src.label);
|
|
gtk_grid_attach(grid, line.label, 0, at, 1, 1);
|
|
GtkWidget** cells[3] = {&line.a, &line.b, &line.d};
|
|
for (int c = 0; c < 3; ++c) {
|
|
GtkWidget* v = fixed_label("", kValueChars, GTK_ALIGN_END);
|
|
gtk_widget_add_css_class(v, "numeric");
|
|
gtk_label_set_selectable(GTK_LABEL(v), true);
|
|
gtk_grid_attach(grid, v, c + 1, at, 1, 1);
|
|
*cells[c] = v;
|
|
}
|
|
app.dock_cursor_lines.push_back(line);
|
|
if (app.strip_cursor_box != nullptr) {
|
|
// A chip: the line's `who` and its delta. Not selectable, so a drag on it scrolls the strip.
|
|
GtkWidget* chip = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
|
GtkWidget* who = fixed_label("", kStripWhoChars, GTK_ALIGN_START);
|
|
copy_who(who, src.label);
|
|
GtkWidget* delta = fixed_label("", kValueChars, GTK_ALIGN_END);
|
|
gtk_widget_add_css_class(delta, "numeric");
|
|
gtk_box_append(GTK_BOX(chip), who);
|
|
gtk_box_append(GTK_BOX(chip), delta);
|
|
gtk_box_append(GTK_BOX(app.strip_cursor_box), chip);
|
|
app.strip_cursor_who.push_back(who);
|
|
app.strip_cursor_deltas.push_back(delta);
|
|
}
|
|
++at;
|
|
}
|
|
}
|
|
|
|
void dock_rebuild_measures(App& app) {
|
|
if (app.dock_measures_box == nullptr) return;
|
|
clear_box(app.dock_measures_box);
|
|
if (app.strip_measure_box != nullptr) clear_box(app.strip_measure_box);
|
|
app.dock_measure_labels.clear();
|
|
app.strip_measure_labels.clear();
|
|
if (app.measures.empty()) {
|
|
GtkWidget* none = gtk_label_new("no measurements");
|
|
gtk_widget_set_halign(none, GTK_ALIGN_START);
|
|
gtk_widget_add_css_class(none, "caption");
|
|
gtk_widget_add_css_class(none, "dim-label");
|
|
gtk_box_append(GTK_BOX(app.dock_measures_box), none);
|
|
// The strip has no placeholder: its Measures group is hidden while there are no measurements.
|
|
return;
|
|
}
|
|
for (std::size_t i = 0; i < app.measures.size(); ++i) {
|
|
const Measure& m = app.measures[i];
|
|
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
|
g_object_set_data(G_OBJECT(line), "udpscope-measure-index",
|
|
GINT_TO_POINTER(static_cast<int>(i) + 1));
|
|
GtkWidget* chip = gtk_label_new("");
|
|
gtk_label_set_markup(GTK_LABEL(chip), measure_chip_markup(app, m.signal).c_str());
|
|
gtk_widget_set_tooltip_text(chip, m.signal.c_str());
|
|
gtk_box_append(GTK_BOX(line), chip);
|
|
GtkWidget* kind = fixed_label(measure_type_name(m.type), 12, GTK_ALIGN_START);
|
|
gtk_widget_set_hexpand(kind, true);
|
|
gtk_widget_set_tooltip_text(kind, (m.signal + "\n" + measure_type_name(m.type)).c_str());
|
|
gtk_box_append(GTK_BOX(line), kind);
|
|
GtkWidget* value = fixed_label("--", kValueChars, GTK_ALIGN_END);
|
|
gtk_widget_add_css_class(value, "numeric");
|
|
gtk_label_set_selectable(GTK_LABEL(value), true);
|
|
gtk_box_append(GTK_BOX(line), value);
|
|
// On the line and not on the value label, which is selectable and so keeps its own clicks.
|
|
GtkGesture* click = gtk_gesture_click_new();
|
|
g_signal_connect(click, "pressed", G_CALLBACK(on_measure_line_pressed), &app);
|
|
gtk_widget_add_controller(line, GTK_EVENT_CONTROLLER(click));
|
|
gtk_box_append(GTK_BOX(app.dock_measures_box), line);
|
|
app.dock_measure_labels.push_back(value);
|
|
|
|
if (app.strip_measure_box != nullptr) {
|
|
// The strip's chip: the signal's chip, the type's short name and the value; a click on it opens the
|
|
// same dialog as the dock's line.
|
|
GtkWidget* sline = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
|
g_object_set_data(G_OBJECT(sline), "udpscope-measure-index",
|
|
GINT_TO_POINTER(static_cast<int>(i) + 1));
|
|
GtkWidget* schip = gtk_label_new("");
|
|
gtk_label_set_markup(GTK_LABEL(schip), measure_chip_markup(app, m.signal).c_str());
|
|
gtk_widget_set_tooltip_text(schip, m.signal.c_str());
|
|
gtk_box_append(GTK_BOX(sline), schip);
|
|
GtkWidget* skind = fixed_label(measure_type_name(m.type), kStripTypeChars, GTK_ALIGN_START);
|
|
gtk_widget_set_tooltip_text(skind, (m.signal + "\n" + measure_type_name(m.type)).c_str());
|
|
gtk_box_append(GTK_BOX(sline), skind);
|
|
GtkWidget* svalue = fixed_label("--", kValueChars, GTK_ALIGN_END);
|
|
gtk_widget_add_css_class(svalue, "numeric");
|
|
gtk_box_append(GTK_BOX(sline), svalue);
|
|
GtkGesture* sclick = gtk_gesture_click_new();
|
|
g_signal_connect(sclick, "pressed", G_CALLBACK(on_measure_line_pressed), &app);
|
|
gtk_widget_add_controller(sline, GTK_EVENT_CONTROLLER(sclick));
|
|
gtk_box_append(GTK_BOX(app.strip_measure_box), sline);
|
|
app.strip_measure_labels.push_back(svalue);
|
|
}
|
|
}
|
|
}
|
|
|
|
void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b,
|
|
const std::string& d) {
|
|
if (line >= app.dock_cursor_lines.size()) return; // mid-rebuild
|
|
const App::DockCursorLine& l = app.dock_cursor_lines[line];
|
|
set_value_text(l.a, a);
|
|
set_value_text(l.b, b);
|
|
set_value_text(l.d, d);
|
|
// The strip's chip takes the delta from the same argument, in the same call.
|
|
if (line < app.strip_cursor_deltas.size()) set_value_text(app.strip_cursor_deltas[line], d);
|
|
}
|
|
|
|
void dock_measure_value(App& app, std::size_t i, const std::string& text) {
|
|
if (i >= app.dock_measure_labels.size()) return; // mid-rebuild
|
|
set_value_text(app.dock_measure_labels[i], text);
|
|
if (i < app.strip_measure_labels.size()) set_value_text(app.strip_measure_labels[i], text);
|
|
}
|
|
|
|
void dock_set_cursors_sensitive(App& app, bool on) {
|
|
// Disabled, not hidden: the dock's grid and the strip's chips go grey together and read `--`.
|
|
if (app.dock_cursor_grid != nullptr) gtk_widget_set_sensitive(app.dock_cursor_grid, on);
|
|
if (app.strip_cursor_box != nullptr) gtk_widget_set_sensitive(app.strip_cursor_box, on);
|
|
}
|
|
|
|
void sync_readouts_strip(App& app) {
|
|
if (app.readouts_strip == nullptr || app.side_view == nullptr) return;
|
|
// The strip is on screen whenever the dock is not *beside* the plot: hidden with the Dock button, or
|
|
// the window collapsed (compact mode), where the dock overlays the plot -- even while the overlay is
|
|
// open -- and is not a readout you can keep an eye on while looking at the trace. `collapsed` is the
|
|
// split view's value from the previous frame (the tick calls this before `set_collapsed()`), so the
|
|
// strip follows a collapse or a widening one tick late; `show-sidebar` is the wish just re-asserted.
|
|
//
|
|
// And only with something to read: a section is there when its feature is (cursors switched on,
|
|
// at least one measurement), and with neither the strip is gone -- it is a readout, not a control
|
|
// strip, and an empty line under the plot is 26 px of nothing. (The one place "hide" beats "disable":
|
|
// the dock keeps both blocks on screen, so the empty state is never invisible.)
|
|
AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(app.side_view);
|
|
const bool cursors = app.cursors_toggle != nullptr &&
|
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.cursors_toggle));
|
|
const bool measures = !app.measures.empty();
|
|
const bool want = (!adw_overlay_split_view_get_show_sidebar(split) ||
|
|
adw_overlay_split_view_get_collapsed(split)) &&
|
|
(cursors || measures);
|
|
auto show = [](GtkWidget* w, bool v) {
|
|
if (w != nullptr && (gtk_widget_get_visible(w) != FALSE) != v) gtk_widget_set_visible(w, v);
|
|
};
|
|
show(app.strip_cursor_group, cursors);
|
|
show(app.strip_measure_group, measures);
|
|
show(app.strip_sep, cursors && measures);
|
|
show(app.readouts_strip, want);
|
|
}
|
|
|
|
namespace {
|
|
|
|
/// The widths of an event row's three cells, in characters. Fixed, so the Events block is as wide with
|
|
/// one event as with thirty (the marker is one cell wide even when it is empty).
|
|
constexpr int kEventTitleChars = 18;
|
|
constexpr int kEventSeqChars = 8;
|
|
|
|
/// The one-character mark of a row: filled for the event on screen, hollow for a member of the group on
|
|
/// screen, blank for the rest. Both questions are the ones `event_row_title()` asks (`ui_events.cpp`).
|
|
const char* dock_event_marker(const App& app, const EventRecord& r) {
|
|
if (event_on_screen(app, r)) return "\xE2\x97\x8F"; // U+25CF black circle
|
|
if (event_in_group(app, r)) return "\xE2\x97\x8B"; // U+25CB white circle
|
|
return " ";
|
|
}
|
|
|
|
/// Opens the `events` page through `open_events_page()` (which refreshes the list only if it is not
|
|
/// already open). The button is not a child of either list, so the rebuild cannot free the widget it runs from.
|
|
void on_manage_events(GtkButton*, gpointer user) {
|
|
open_events_page(*static_cast<App*>(user));
|
|
}
|
|
|
|
/// One click on a row replays it, as the page list's activation does: `replay_event()` and then the in-place
|
|
/// re-mark. The record is copied first, because nothing it triggers may be allowed to free the row it
|
|
/// was read from.
|
|
///
|
|
/// Safety depends on `replay_event()` never rebuilding the dock's list (it must go through
|
|
/// `dock_mark_events()`, which rewrites labels in place): a rebuild here would free this very row, and
|
|
/// `row`, `record` and the list box's own emission of `row-activated` would all be dangling. The copy
|
|
/// guards the record only, not the row. If a replay ever has to refresh the cache's view, defer it.
|
|
void on_dock_event_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
|
App& app = *static_cast<App*>(user);
|
|
if (row == nullptr) return;
|
|
const auto* record =
|
|
static_cast<const EventRecord*>(g_object_get_data(G_OBJECT(row), "udpscope-event"));
|
|
if (record == nullptr) return;
|
|
const EventRecord copy = *record;
|
|
replay_event(app, copy);
|
|
mark_events_on_screen(app);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
GtkWidget* build_events_dock(App& app) {
|
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
|
|
gtk_widget_set_margin_top(box, 6);
|
|
gtk_widget_set_margin_bottom(box, 6);
|
|
gtk_widget_set_margin_start(box, 6);
|
|
gtk_widget_set_margin_end(box, 6);
|
|
|
|
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
|
GtkWidget* title = gtk_label_new("Events");
|
|
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
|
gtk_widget_set_hexpand(title, true);
|
|
gtk_widget_add_css_class(title, "heading");
|
|
gtk_box_append(GTK_BOX(head), title);
|
|
GtkWidget* manage = gtk_button_new_with_label("Manage");
|
|
gtk_widget_add_css_class(manage, "flat");
|
|
gtk_widget_set_tooltip_text(manage, "the events' full list and actions");
|
|
g_signal_connect(manage, "clicked", G_CALLBACK(on_manage_events), &app);
|
|
gtk_box_append(GTK_BOX(head), manage);
|
|
gtk_box_append(GTK_BOX(box), head);
|
|
|
|
GtkWidget* list = gtk_list_box_new();
|
|
gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_NONE);
|
|
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(list), true);
|
|
gtk_widget_add_css_class(list, "boxed-list");
|
|
g_signal_connect(list, "row-activated", G_CALLBACK(on_dock_event_activated), &app);
|
|
|
|
// A fixed height (about four rows), so the block is as tall with no events as with thirty and the
|
|
// readouts above it do not move when one is latched.
|
|
GtkWidget* scroller = gtk_scrolled_window_new();
|
|
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
|
|
gtk_scrolled_window_set_min_content_height(GTK_SCROLLED_WINDOW(scroller), 112);
|
|
gtk_scrolled_window_set_max_content_height(GTK_SCROLLED_WINDOW(scroller), 112);
|
|
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), list);
|
|
gtk_box_append(GTK_BOX(box), scroller);
|
|
|
|
app.dock_events_box = box;
|
|
app.dock_events_list = list;
|
|
app.dock_events_manage = manage;
|
|
// Insensitive until the first availability sync says otherwise: on screen and greyed, never hidden.
|
|
gtk_widget_set_sensitive(box, FALSE);
|
|
dock_rebuild_events(app);
|
|
return box;
|
|
}
|
|
|
|
void dock_rebuild_events(App& app) {
|
|
if (app.dock_events_list == nullptr || app.event_store == nullptr) return;
|
|
dock_rebuild_events(app, app.event_store->list());
|
|
}
|
|
|
|
namespace {
|
|
|
|
/// Puts the list's vertical scroll back after a refill. The adjustment's bounds are only updated at the
|
|
/// next allocation, so the value set at once is clamped against the old ones; an idle (which runs after the
|
|
/// frame that laid the new rows out) sets it again against the new bounds, where the clamp is exact.
|
|
void restore_dock_scroll(GtkWidget* list, double value) {
|
|
GtkWidget* scroller = gtk_widget_get_ancestor(list, GTK_TYPE_SCROLLED_WINDOW);
|
|
if (scroller == nullptr) return;
|
|
GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(scroller));
|
|
gtk_adjustment_set_value(adj, value);
|
|
struct Pending {
|
|
GtkAdjustment* adj;
|
|
double value;
|
|
};
|
|
auto* p = new Pending{GTK_ADJUSTMENT(g_object_ref(adj)), value};
|
|
g_idle_add_full(
|
|
G_PRIORITY_DEFAULT_IDLE,
|
|
[](gpointer d) -> gboolean {
|
|
auto* q = static_cast<Pending*>(d);
|
|
gtk_adjustment_set_value(q->adj, q->value);
|
|
g_object_unref(q->adj);
|
|
delete q;
|
|
return G_SOURCE_REMOVE;
|
|
},
|
|
p, nullptr);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void dock_rebuild_events(App& app, const std::vector<EventRecord>& records) {
|
|
if (app.dock_events_list == nullptr) return;
|
|
GtkListBox* list = GTK_LIST_BOX(app.dock_events_list);
|
|
GtkWidget* scroller = gtk_widget_get_ancestor(app.dock_events_list, GTK_TYPE_SCROLLED_WINDOW);
|
|
const double scroll =
|
|
scroller != nullptr
|
|
? gtk_adjustment_get_value(gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(scroller)))
|
|
: 0.0;
|
|
gtk_list_box_remove_all(list);
|
|
if (records.empty()) {
|
|
GtkWidget* none = gtk_label_new("no events yet");
|
|
gtk_widget_set_halign(none, GTK_ALIGN_START);
|
|
gtk_widget_add_css_class(none, "caption");
|
|
gtk_widget_add_css_class(none, "dim-label");
|
|
gtk_widget_set_margin_start(none, 6);
|
|
gtk_widget_set_margin_top(none, 4);
|
|
gtk_widget_set_margin_bottom(none, 4);
|
|
GtkWidget* row = gtk_list_box_row_new();
|
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), none);
|
|
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), false);
|
|
gtk_list_box_append(list, row);
|
|
return;
|
|
}
|
|
// The newest `kDockEventsMax` only: the list is newest first, and a block that builds four widgets per
|
|
// cached event is a cost that grows with a cache the user may have set to unbounded.
|
|
const std::size_t shown = std::min(records.size(), kDockEventsMax);
|
|
for (std::size_t i = 0; i < shown; ++i) {
|
|
const EventRecord& r = records[i];
|
|
GtkWidget* row = gtk_list_box_row_new();
|
|
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
|
gtk_widget_set_margin_start(line, 6);
|
|
gtk_widget_set_margin_end(line, 6);
|
|
gtk_widget_set_margin_top(line, 3);
|
|
gtk_widget_set_margin_bottom(line, 3);
|
|
|
|
GtkWidget* marker = fixed_label(dock_event_marker(app, r), 1, GTK_ALIGN_CENTER);
|
|
gtk_box_append(GTK_BOX(line), marker);
|
|
const std::string text = event_row_title(app, r);
|
|
GtkWidget* name = fixed_label(text.c_str(), kEventTitleChars, GTK_ALIGN_START);
|
|
gtk_widget_set_hexpand(name, true);
|
|
gtk_widget_set_tooltip_text(name, text.c_str());
|
|
gtk_box_append(GTK_BOX(line), name);
|
|
// The latch's sequence number, as the page row's third line ends with it: the title already carries the
|
|
// clock, so this is the part of the row that is not in it.
|
|
const std::string seq = "#" + std::to_string(r.seq);
|
|
GtkWidget* num = fixed_label(seq.c_str(), kEventSeqChars, GTK_ALIGN_END);
|
|
gtk_widget_set_tooltip_text(num, ("latch " + seq).c_str());
|
|
gtk_widget_add_css_class(num, "dim-label");
|
|
gtk_box_append(GTK_BOX(line), num);
|
|
|
|
g_object_set_data(G_OBJECT(row), "udpscope-event-marker", marker);
|
|
g_object_set_data(G_OBJECT(row), "udpscope-event-title", name);
|
|
g_object_set_data_full(G_OBJECT(row), "udpscope-event", new EventRecord(r),
|
|
[](gpointer p) { delete static_cast<EventRecord*>(p); });
|
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
|
|
gtk_list_box_append(list, row);
|
|
}
|
|
if (records.size() > shown) {
|
|
// A last row with no record (like the placeholder: `dock_mark_events()` skips it, and it does nothing
|
|
// when activated). Fixed text and the same ellipsis as the rest, so it cannot widen the dock.
|
|
const std::string more = "\xE2\x80\xA6 " + std::to_string(records.size() - shown) + " more, see Manage";
|
|
GtkWidget* label = fixed_label(more.c_str(), kEventTitleChars + kEventSeqChars, GTK_ALIGN_START);
|
|
gtk_widget_add_css_class(label, "dim-label");
|
|
gtk_widget_set_tooltip_text(label, more.c_str());
|
|
gtk_widget_set_margin_start(label, 6);
|
|
gtk_widget_set_margin_top(label, 4);
|
|
gtk_widget_set_margin_bottom(label, 4);
|
|
GtkWidget* row = gtk_list_box_row_new();
|
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
|
|
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), false);
|
|
gtk_list_box_append(list, row);
|
|
}
|
|
restore_dock_scroll(app.dock_events_list, scroll);
|
|
}
|
|
|
|
void dock_mark_events(App& app) {
|
|
if (app.dock_events_list == nullptr) return;
|
|
for (GtkWidget* row = gtk_widget_get_first_child(app.dock_events_list); row != nullptr;
|
|
row = gtk_widget_get_next_sibling(row)) {
|
|
const auto* record =
|
|
static_cast<const EventRecord*>(g_object_get_data(G_OBJECT(row), "udpscope-event"));
|
|
GtkWidget* marker = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-marker"));
|
|
GtkWidget* name = GTK_WIDGET(g_object_get_data(G_OBJECT(row), "udpscope-event-title"));
|
|
// The "no events yet" placeholder carries none of the three: skipped.
|
|
if (record == nullptr || marker == nullptr || name == nullptr) continue;
|
|
gtk_label_set_text(GTK_LABEL(marker), dock_event_marker(app, *record));
|
|
set_value_text(name, event_row_title(app, *record));
|
|
}
|
|
}
|
|
|
|
void dock_events_set_sensitive(App& app, bool available) {
|
|
if (app.dock_events_box == nullptr) return;
|
|
gtk_widget_set_sensitive(app.dock_events_box, available);
|
|
}
|
|
|
|
} // namespace ui_impl
|
|
} // namespace udpscope
|