Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
62 KiB
Badge bar, slide-over and dock -- step 1 (the shell) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the sidebar's tab switcher with a plot-edge slide-over panel reached from a badge bar under the plot (Trigger and Source badges), keeping Measure and Signals reachable in a temporary always-on dock, so the app is usable after every task.
Architecture: Two new units, SlideOver (src/ui_slideover.cpp, a GtkRevealer + GtkStack placed by margins as one opaque GtkOverlay child above the plot) and BadgeBar (src/ui_badges.cpp, a fixed-height row between the plot and the status line). The existing trigger form and the source kind's SourcePanel widgets are reparented into slide-over pages. The right AdwOverlaySplitView sidebar stays, but its AdwViewSwitcher/AdwViewStack become one scroller holding the signal list, the cursors and the measurements. The left events pane and everything else is untouched (spec step 4).
Tech Stack: C++20, GTK4, libadwaita >= 1.8 (AdwToggleGroup is 1.7+), CMake. No unit-test suite: verification is the app's own headless self-check (xvfb-run, mock sources) -- extend src/ui.cpp / src/ui_probe.cpp probes, never a separate harness.
Spec: docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md (read it first). Branch: feature/ui-badges-dock.
Global Constraints
Copied from the spec and the repository's rules; every task includes them.
- Layout must not depend on content (
docs/claude/invariants-plot-and-layout.md): the dock has a fixed width, the badge bar a fixed height, the slide-over overlays the plot and never resizes it. Rows that appear by trigger condition appear inside the slide-over's scroller. - A
GtkOverlaychild is picked wherever its allocation is: the slide-over is one opaque widget aligned to the end edge byhalign=ENDand a 360 px width, never a full-area transparent child, so pan, drop and hover on the rest of the plot are untouched. - The header's Trigger / Cursors / Pause toggles keep their two-way mirroring with the panels.
SourcePanel{id, title, widget}is unchanged: the E-1608 and Flat UDP panels need no change.- Page ids:
trigger,signal:<name>,signals,measure,cursors,events,source-<id>. (Step 1 implementstriggerandsource-<id>only.) - The open slide-over page is not saved. The session file drops
side_tab; an old file'sside_tabis ignored. - Esc or a click outside closes the slide-over; one page at a time; each page has its own scroller.
- "Disable, do not hide": the dock is shown but insensitive until the stream has signals.
- Text buttons only for new controls (no icon-only buttons, no icons in badges).
- Every new source file carries the SPDX MIT header (
// SPDX-License-Identifier: MIT/// SPDX-FileCopyrightText: 2026 Martino Ferrari). - Baselines: a clean build emits exactly one warning (the icons'
-Woverlength-strings), an incremental build zero; the dump'sgtk warnings:reads0; noGtk-CRITICAL/Gtk-WARNING. - Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs
GDK_BACKEND=x11(the host hasWAYLAND_DISPLAY) and, for screenshots,GSK_RENDERER=cairo+import -window root. - Do not push, merge or amend. Commit per task on
feature/ui-badges-dock.
Deliberate step-1 deviations from the spec (state them in the final report)
- The dock keeps the sidebar's 320 px (spec: ~280). The panels it holds were built for 320; the width change belongs to step 3 when they become compact readouts.
- The session file gains the dock's shown state only, not a width: the dock's width is fixed, so there is nothing to persist.
- The Trigger badge shows
off / armed / triggeredpluscapturinganddisarmed, so the label never claims a state the engine is not in.
File Structure
| File | Create/Modify | Responsibility |
|---|---|---|
src/ui_slideover.cpp |
Create | The slide-over: build, page registry, open/close/toggle, Esc, click-away, slideover probe. |
src/ui_badges.cpp |
Create | The badge bar: Trigger and Source badges, per-tick state, pure trigger_badge_state(), badges probe. |
src/ui_app.hpp |
Modify | SlideOver/BadgeBar structs on App, declarations, removal of side_stack/side_switcher/side_tab/keep_tabs_icon_free. |
src/ui/main-window.ui |
Modify | plot_overlay around plot_box; badge_bar row; the sidebar tab switcher/stack replaced by one dock scroller. |
src/ui_widgets.cpp |
Modify | CSS for badges and the slide-over (install_css). |
src/ui.cpp |
Modify | Build/wire both units in on_activate; tick sync; Esc; dock visibility; remove tab code. |
src/ui_source.cpp |
Modify | Source panels become slide-over pages; keep_tabs_icon_free deleted. |
src/ui_tour.cpp, src/ui_context_help.cpp |
Modify | Tour/context-help re-pointed (task 5). |
src/ui_layout.cpp, src/layout_store.{hpp,cpp} |
Modify | side_tab out, dock_shown in. |
src/shortcuts.{hpp,cpp}, src/ui/shortcuts.ui |
Modify | SideTabMeasure/Signals -> ShowMeasure/ShowSignals. |
src/ui_probe.cpp |
Modify | New probes; every probe that touched the tab stack re-pointed. |
CMakeLists.txt |
Modify | Add the two new .cpp files to the udpscope sources (next to src/ui_source.cpp). |
README.md, docs/claude/*.md |
Modify | Task 6. (USER-GUIDE.md and the Mallard manual's figures are the spec's step 4.) |
Task 1: SlideOver unit and the plot overlay (confirms the compositing risk)
Spec step 1 says: confirm the overlay-above-GL risk before anything else is built on it. This task builds the slide-over with a throw-away probe page and proves composite, geometry and input.
Files:
- Create:
src/ui_slideover.cpp - Modify:
src/ui/main-window.ui,src/ui_app.hpp,src/ui.cpp,src/ui_widgets.cpp,src/ui_probe.cpp(or put the probe inui_slideover.cpp;ui_app.hppdeclares it either way),CMakeLists.txt,docs/claude/verification.md(probe env var line)
Interfaces:
- Produces (all in
namespace udpscope::ui_impl, declared inui_app.hpp):
/// The slide-over panel's widgets and state (src/ui_slideover.cpp). `page` is the open page's id, empty when closed.
struct SlideOver {
GtkWidget* overlay = nullptr; ///< the GtkOverlay around the plot (main-window.ui `plot_overlay`)
GtkWidget* revealer = nullptr; ///< the one overlay child; halign END, valign FILL, 360 px
GtkWidget* title = nullptr; ///< GtkLabel in the panel header
GtkWidget* stack = nullptr; ///< GtkStack: one scroller per page
GtkWidget* source_switch = nullptr; ///< AdwToggleGroup, shown on a source page when there are >= 2
std::vector<std::string> source_pages;
std::string page;
bool syncing = false; ///< true while we write the toggle group ourselves
};
constexpr int kSlideOverWidth = 360;
void build_slideover(App& app); // once, after the plot_overlay exists
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content);
bool slideover_has_page(const App& app, const std::string& id);
bool slideover_open(App& app, const std::string& id); // false when the id is unknown
void slideover_close(App& app);
bool slideover_toggle(App& app, const std::string& id); // open if closed or on another page, else close; false if unknown
const std::string& slideover_page(const App& app); // "" when closed
bool slideover_escape(App& app); // closes if open; true when it did
bool slideover_click_away(App& app, double x, double y); // x,y in overlay coords; closes unless inside the panel; true when it closed
gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS
App gains SlideOver slideover; and GtkWidget* plot_overlay = nullptr;. Task 2 consumes slideover_add_page, slideover_toggle, slideover_page; task 3 consumes source_pages.
- Step 1: Capture the UDPS baseline from the current HEAD
The tabs are about to go, so freeze today's self-check first. Build HEAD (cmake --build build -j), then:
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
sleep 1.5
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-badges-base.log 2>&1
kill $MOCK
sed 's/[0-9.]\+/N/g' /tmp/ui-badges-base.log > .superpowers/sdd/ui-badges-baseline-udps.txt
grep -c 'Gtk-CRITICAL' /tmp/ui-badges-base.log; grep 'gtk warnings' /tmp/ui-badges-base.log
Expected: 0 Gtk-CRITICAL, gtk warnings: 0. Also keep /tmp/ui-badges-base.log un-normalised. Every later task diffs its own normalised run against this file and must explain every differing line from that task's "allowed baseline changes" list; any other difference is a defect.
- Step 2: Wrap the plot in
plot_overlayinsrc/ui/main-window.ui
In left_column, replace the first <child> (the bare plot_box) with:
<child>
<!-- The plot, and the slide-over panel that opens over its right edge. The panel is
one opaque overlay child placed by `halign=end` and a 360 px width -- never a
full-area transparent child, which would be picked wherever its allocation is
and swallow the plot's pan, drop and hover (docs/claude/invariants-plot-and-layout.md).
The overlay is the only parent change: `plot_box` keeps its id and its content. -->
<object class="GtkOverlay" id="plot_overlay">
<property name="hexpand">1</property>
<property name="vexpand">1</property>
<child>
<object class="GtkBox" id="plot_box">
... (the existing plot_box, unchanged) ...
</object>
</child>
</object>
</child>
Update the file's header comment: the plot column is plot_overlay > plot_box.
- Step 3: Add the CSS to
install_css()(src/ui_widgets.cpp)
Append to kCss before the final menubutton.udpscope-hint rules (keep the string a single literal, each rule on its own "...\n" line):
// The slide-over panel over the plot's right edge: opaque (it must hide the plot under it),
// with an edge and a shadow so it reads as above the plot rather than beside it.
".udpscope-slideover { background-color: @window_bg_color; border-left: 1px solid @borders;"
" box-shadow: -3px 0 10px rgba(0, 0, 0, 0.35); }\n"
".udpscope-slideover-title { font-weight: bold; }\n"
- Step 4: Write
src/ui_slideover.cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The slide-over panel: one panel at a time over the plot's right edge, a page per setup surface.
// See docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md.
#include "ui_app.hpp"
#include <adwaita.h>
#include <cstdio>
#include <cstring>
namespace udpscope {
namespace ui_impl {
namespace {
void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast<App*>(user)); }
void on_source_switch(GObject* o, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->slideover.syncing) return;
const char* name = adw_toggle_group_get_active_name(ADW_TOGGLE_GROUP(o));
if (name != nullptr && app->slideover.page != name) slideover_open(*app, name);
}
/// Observes every press on the plot overlay, in the capture phase, and never claims the sequence: the
/// plot's own pan/zoom/drop gestures see the press exactly as they did before.
void on_press_capture(GtkGestureClick*, int, double x, double y, gpointer user) {
slideover_click_away(*static_cast<App*>(user), x, y);
}
bool is_source_page(const std::string& id) { return id.rfind("source-", 0) == 0; }
} // namespace
void build_slideover(App& app) {
SlideOver& s = app.slideover;
s.overlay = app.plot_overlay;
if (s.overlay == nullptr) return;
GtkWidget* panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(panel, "udpscope-slideover");
gtk_widget_set_size_request(panel, kSlideOverWidth, -1);
gtk_widget_set_hexpand(panel, FALSE);
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_start(head, 12);
gtk_widget_set_margin_end(head, 6);
gtk_widget_set_margin_top(head, 6);
gtk_widget_set_margin_bottom(head, 6);
s.title = gtk_label_new("");
gtk_widget_add_css_class(s.title, "udpscope-slideover-title");
gtk_label_set_xalign(GTK_LABEL(s.title), 0.0f);
gtk_widget_set_hexpand(s.title, TRUE);
gtk_label_set_ellipsize(GTK_LABEL(s.title), PANGO_ELLIPSIZE_END);
GtkWidget* close = gtk_button_new_with_label("Close");
gtk_widget_add_css_class(close, "flat");
g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &app);
gtk_box_append(GTK_BOX(head), s.title);
gtk_box_append(GTK_BOX(head), close);
gtk_box_append(GTK_BOX(panel), head);
// A source with several panels (the E-1608: Analog out, Digital I/O) gets a segmented switch under
// the header; it is hidden unless the open page is a source page and there are two or more.
s.source_switch = adw_toggle_group_new();
gtk_widget_set_margin_start(s.source_switch, 12);
gtk_widget_set_margin_end(s.source_switch, 12);
gtk_widget_set_margin_bottom(s.source_switch, 6);
gtk_widget_set_visible(s.source_switch, FALSE);
g_signal_connect(s.source_switch, "notify::active-name", G_CALLBACK(on_source_switch), &app);
gtk_box_append(GTK_BOX(panel), s.source_switch);
gtk_box_append(GTK_BOX(panel), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
s.stack = gtk_stack_new();
gtk_widget_set_vexpand(s.stack, TRUE);
gtk_box_append(GTK_BOX(panel), s.stack);
s.revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(s.revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_LEFT);
gtk_revealer_set_transition_duration(GTK_REVEALER(s.revealer), 120);
gtk_revealer_set_child(GTK_REVEALER(s.revealer), panel);
gtk_widget_set_halign(s.revealer, GTK_ALIGN_END);
gtk_widget_set_valign(s.revealer, GTK_ALIGN_FILL);
// Closed, it must not be a pick target for the frames of its slide-out.
gtk_widget_set_can_target(s.revealer, FALSE);
gtk_overlay_add_overlay(GTK_OVERLAY(s.overlay), s.revealer);
gtk_overlay_set_clip_overlay(GTK_OVERLAY(s.overlay), s.revealer, TRUE);
GtkGesture* click = gtk_gesture_click_new();
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE);
g_signal_connect(click, "pressed", G_CALLBACK(on_press_capture), &app);
gtk_widget_add_controller(s.overlay, GTK_EVENT_CONTROLLER(click));
}
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content) {
SlideOver& s = app.slideover;
if (s.stack == nullptr || content == nullptr || id.empty() || slideover_has_page(app, id)) {
return false;
}
// A scroller per page: pages differ in height, and one shared scroller would leave a page scrolled
// to somewhere it never asked for. It does not propagate the content's height, so the panel's
// height is the plot's and never the page's.
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_child(GTK_SCROLLED_WINDOW(scroller), content);
gtk_stack_add_titled(GTK_STACK(s.stack), scroller, id.c_str(), title.c_str());
if (is_source_page(id)) {
s.source_pages.push_back(id);
AdwToggle* t = adw_toggle_new();
adw_toggle_set_name(t, id.c_str());
adw_toggle_set_label(t, title.c_str());
adw_toggle_group_add(ADW_TOGGLE_GROUP(s.source_switch), t); // takes the reference
}
return true;
}
bool slideover_has_page(const App& app, const std::string& id) {
return app.slideover.stack != nullptr &&
gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr;
}
bool slideover_open(App& app, const std::string& id) {
SlideOver& s = app.slideover;
if (!slideover_has_page(app, id)) return false;
GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str());
gtk_stack_set_visible_child(GTK_STACK(s.stack), child);
s.page = id;
GtkStackPage* sp = gtk_stack_get_page(GTK_STACK(s.stack), child);
const char* title = gtk_stack_page_get_title(sp);
gtk_label_set_text(GTK_LABEL(s.title), title != nullptr ? title : "");
const bool several = is_source_page(id) && s.source_pages.size() >= 2;
gtk_widget_set_visible(s.source_switch, several);
if (several) {
s.syncing = true;
adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str());
s.syncing = false;
}
gtk_widget_set_can_target(s.revealer, TRUE);
gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), TRUE);
sync_badge_bar(app);
return true;
}
void slideover_close(App& app) {
SlideOver& s = app.slideover;
if (s.revealer == nullptr || s.page.empty()) return;
s.page.clear();
gtk_widget_set_can_target(s.revealer, FALSE);
gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), FALSE);
sync_badge_bar(app);
}
bool slideover_toggle(App& app, const std::string& id) {
if (!slideover_has_page(app, id)) return false;
if (app.slideover.page == id) {
slideover_close(app);
} else {
slideover_open(app, id);
}
return true;
}
const std::string& slideover_page(const App& app) { return app.slideover.page; }
bool slideover_escape(App& app) {
if (app.slideover.page.empty()) return false;
slideover_close(app);
return true;
}
bool slideover_click_away(App& app, double x, double y) {
SlideOver& s = app.slideover;
if (s.page.empty() || s.overlay == nullptr || s.revealer == nullptr) return false;
graphene_point_t in = GRAPHENE_POINT_INIT(static_cast<float>(x), static_cast<float>(y));
graphene_point_t out;
if (gtk_widget_compute_point(s.overlay, s.revealer, &in, &out) &&
gtk_widget_contains(s.revealer, out.x, out.y)) {
return false;
}
slideover_close(app);
return true;
}
// --- the probe ---------------------------------------------------------------------------------
namespace {
struct ProbeState {
App* app;
int plot_w_before = 0;
};
bool under(GtkWidget* w, GtkWidget* ancestor) {
return w != nullptr && ancestor != nullptr && (w == ancestor || gtk_widget_is_ancestor(w, ancestor));
}
gboolean slideover_probe_read(gpointer user) {
ProbeState* ps = static_cast<ProbeState*>(user);
App& a = *ps->app;
SlideOver& s = a.slideover;
const int ow = gtk_widget_get_width(s.overlay);
const int oh = gtk_widget_get_height(s.overlay);
graphene_rect_t r;
const bool have = gtk_widget_compute_bounds(s.revealer, s.overlay, &r);
const int rw = have ? static_cast<int>(r.size.width + 0.5f) : -1;
const int rx = have ? static_cast<int>(r.origin.x + 0.5f) : -1;
const int rh = have ? static_cast<int>(r.size.height + 0.5f) : -1;
// Outside the panel, a quarter in: the plot must be what answers. Inside it, the panel.
GtkWidget* outside = gtk_widget_pick(s.overlay, ow / 4.0, oh / 2.0, GTK_PICK_DEFAULT);
GtkWidget* inside = gtk_widget_pick(s.overlay, ow - kSlideOverWidth / 2.0, oh / 2.0, GTK_PICK_DEFAULT);
const int plot_w_open = gtk_widget_get_width(a.plot_box);
std::fprintf(stderr,
"[slideover] open page=%s panel=%dx%d at x=%d overlay=%dx%d edge_flush=%d full_height=%d "
"outside_hits_plot=%d inside_hits_panel=%d plot_w before=%d open=%d plot_unchanged=%d\n",
s.page.c_str(), rw, rh, rx, ow, oh, rx + rw == ow ? 1 : 0, rh == oh ? 1 : 0,
under(outside, a.plot_box) ? 1 : 0, under(inside, s.revealer) ? 1 : 0, ps->plot_w_before,
plot_w_open, ps->plot_w_before == plot_w_open ? 1 : 0);
// Click-away from outside the panel closes; a press inside does not.
const bool inside_closed = slideover_click_away(a, ow - kSlideOverWidth / 2.0, oh / 2.0);
const bool open_after_inside = !s.page.empty();
const bool outside_closed = slideover_click_away(a, ow / 4.0, oh / 2.0);
slideover_open(a, "probe");
const bool esc_closed = slideover_escape(a);
std::fprintf(stderr,
"[slideover] click_inside_closes=%d still_open=%d click_outside_closes=%d esc_closes=%d "
"closed=%d\n",
inside_closed ? 1 : 0, open_after_inside ? 1 : 0, outside_closed ? 1 : 0, esc_closed ? 1 : 0,
s.page.empty() ? 1 : 0);
delete ps;
return G_SOURCE_REMOVE;
}
} // namespace
gboolean slideover_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->slideover.stack == nullptr) return G_SOURCE_REMOVE;
// A page of the probe's own: the real pages arrive in later tasks, and this one has an entry so the
// panel has something that takes focus and keys.
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_box_append(GTK_BOX(box), gtk_label_new("slide-over probe page"));
gtk_box_append(GTK_BOX(box), gtk_entry_new());
slideover_add_page(*a, "probe", "Probe", box);
ProbeState* ps = new ProbeState{a, gtk_widget_get_width(a->plot_box)};
slideover_open(*a, "probe");
// The revealer's slide is 120 ms; the reading is taken well after it has settled.
g_timeout_add(500, slideover_probe_read, ps);
return G_SOURCE_REMOVE;
}
} // namespace ui_impl
} // namespace udpscope
sync_badge_bar(App&) does not exist until task 2: in this task add to ui_app.hpp a declaration and, in ui_slideover.cpp's neighbour, nothing -- instead temporarily omit the two sync_badge_bar(app); calls and add them in task 2 step 4 (they are marked // task 2 if you prefer a comment). Check every GTK/libadwaita signature against the installed headers (/usr/include/gtk-4.0, /usr/include/libadwaita-1); fix names rather than guessing.
-
Step 5: Declare, wire, register
ui_app.hpp: add theSlideOverstruct,kSlideOverWidth, the function declarations from Interfaces, and onApp:SlideOver slideover; GtkWidget* plot_overlay = nullptr;.ui.cppon_activate: next to the othergtk_builder_get_objectlookups addapp->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));, add it to the null guard, and callbuild_slideover(*app);right afterapp->plot_splitis resolved and the tour overlay is built (the plot overlay exists from the builder, so any point beforemake_areaworks).ui.cppprobe scheduling (next to line ~5906side_tab_probe):if (const char* env = std::getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) g_timeout_add(static_cast<guint>(std::atoi(env)), slideover_probe, &app);CMakeLists.txt: addsrc/ui_slideover.cppaftersrc/ui_source.cpp.docs/claude/verification.md: add an entry forUDPSCOPE_PROBE_SLIDEOVER_MS=2000(withUDPSCOPE_EXIT_AFTER_MS=5000): the two[slideover]lines and what each field must read (below).
-
Step 6: Build and run the probe
cmake --build build -j 2>&1 | grep -E 'warning|error' ; echo "build rc=$?"
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
sleep 1.5
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 \
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[slideover\]|Gtk-|gtk warnings'
kill $MOCK
Expected: the build has zero warnings (incremental) and [slideover] line 1 reads panel=360x<overlay height> ... edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1; line 2 reads click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1; gtk warnings: 0. If outside_hits_plot=0 the panel has an allocation wider than 360 or a transparent parent -- stop and fix, do not go on.
- Step 7: Confirm the compositing by eye (the risk the spec names)
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
sleep 1.5
xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 \
UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8114 \
>/tmp/so.log 2>&1 & sleep 2.4; import -window root /tmp/slideover.png; wait'
kill $MOCK
The probe closes the page at ~2.0 s, so for the screenshot only: temporarily leave it open by setting UDPSCOPE_PROBE_SLIDEOVER_HOLD=1, which makes slideover_probe_read return before the click/esc part (add that early if (std::getenv("UDPSCOPE_PROBE_SLIDEOVER_HOLD")) { delete ps; return G_SOURCE_REMOVE; } after the first fprintf). Read /tmp/slideover.png with the Read tool and confirm: the panel is drawn over the traces on the plot's right edge, opaque, with its header, "Close" button, entry; the plot to its left is intact. Record the result in the task report. If the GL surface draws over the panel or the panel is transparent, stop: the design's premise failed, report BLOCKED.
- Step 8: Baseline diff and commit
Re-run Step 1's command into /tmp/ui-badges-t1.log, normalise, diff against .superpowers/sdd/ui-badges-baseline-udps.txt.
Allowed baseline changes: none (a layout line that prints the plot area's size may differ by the panel not being open -- it must not).
git add src/ui_slideover.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp CMakeLists.txt docs/claude/verification.md
git commit -m "ui: a slide-over panel over the plot's right edge, with its probe"
Task 2: BadgeBar with the Trigger badge, and the trigger form into the slide-over
Files:
- Create:
src/ui_badges.cpp - Modify:
src/ui/main-window.ui,src/ui_app.hpp,src/ui.cpp,src/ui_widgets.cpp,src/ui_slideover.cpp,src/ui_probe.cpp,CMakeLists.txt,docs/claude/verification.md
Interfaces:
- Consumes: Task 1's
slideover_add_page,slideover_toggle,slideover_page,slideover_open. - Produces:
/// One badge: a button with a name and a state line. `page` is the slide-over page it opens.
struct Badge {
GtkWidget* button = nullptr;
GtkWidget* name = nullptr;
GtkWidget* state = nullptr;
std::string page;
};
struct BadgeBar {
GtkWidget* bar = nullptr; ///< main-window.ui `badge_bar`, fixed height
GtkWidget* signals_slot = nullptr; ///< left; empty in step 1, step 2 fills it
GtkWidget* system_slot = nullptr; ///< right: Trigger, Source
Badge trigger;
Badge source; ///< task 3
};
/// The Trigger badge's state word. Pure, so the probe can assert the whole table.
const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event);
void build_badge_bar(App& app);
void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed
gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS
App gains BadgeBar badges; and, via the builder, badge_bar. trigger_badge_state truth table (enabled, armed, pending, has_event) -> word: !enabled -> off; has_event && !pending -> triggered; pending -> capturing; armed -> armed; else disarmed.
- Step 1: Add the badge bar row to
main-window.ui
In left_column, between plot_overlay and status_row:
<child>
<!-- Setup is reached by the object it configures: a badge per signal on the left
(step 2), the system badges (Trigger, Source) on the right. A fixed height, so
the plot above it never moves when badges come and go. -->
<object class="GtkBox" id="badge_bar">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="height-request">36</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<child>
<object class="GtkBox" id="badge_signals">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="hexpand">1</property>
</object>
</child>
<child>
<object class="GtkBox" id="badge_system">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="halign">end</property>
</object>
</child>
</object>
</child>
Add the three ids to the guard in on_activate.
- Step 2: CSS (
install_css)
// The badge bar: a badge is a pill carrying a name and a state; the one whose page is open is
// marked, as a bench scope marks the open menu's button.
".udpscope-badge { padding: 2px 12px; min-height: 0; border-radius: 14px; }\n"
".udpscope-badge-open { background-color: alpha(@accent_bg_color, 0.28); }\n"
".udpscope-badge-state { opacity: 0.7; }\n"
- Step 3: Write
src/ui_badges.cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The badge bar under the plot: setup is reached by the object it configures.
#include "ui_app.hpp"
#include <cstdio>
#include <cstring>
namespace udpscope {
namespace ui_impl {
const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event) {
if (!enabled) return "off";
if (has_event && !pending) return "triggered";
if (pending) return "capturing";
return armed ? "armed" : "disarmed";
}
namespace {
void set_text_if_changed(GtkWidget* label, const char* text) {
if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text);
}
void set_open(GtkWidget* button, bool open) {
if ((gtk_widget_has_css_class(button, "udpscope-badge-open") != FALSE) == open) return;
if (open) {
gtk_widget_add_css_class(button, "udpscope-badge-open");
} else {
gtk_widget_remove_css_class(button, "udpscope-badge-open");
}
}
void on_badge_clicked(GtkButton*, gpointer user) {
Badge* b = static_cast<Badge*>(user);
// The App is reached through the badge's button: the badges live inside App, so the pointer is stable.
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(b->button), "udpscope-app"));
if (app != nullptr) slideover_toggle(*app, b->page);
}
/// A badge: a flat pill, name on the left, state dimmed beside it. Words only.
void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const std::string& page) {
b.page = page;
b.button = gtk_button_new();
gtk_widget_add_css_class(b.button, "flat");
gtk_widget_add_css_class(b.button, "udpscope-badge");
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
b.name = gtk_label_new(name);
b.state = gtk_label_new("");
gtk_widget_add_css_class(b.state, "udpscope-badge-state");
gtk_box_append(GTK_BOX(row), b.name);
gtk_box_append(GTK_BOX(row), b.state);
gtk_button_set_child(GTK_BUTTON(b.button), row);
g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app);
g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b);
gtk_box_append(GTK_BOX(parent), b.button);
}
} // namespace
void build_badge_bar(App& app) {
BadgeBar& bb = app.badges;
bb.bar = app.badge_bar;
bb.signals_slot = app.badge_signals;
bb.system_slot = app.badge_system;
make_badge(app, bb.trigger, bb.system_slot, "Trigger", "trigger");
sync_badge_bar(app);
}
void sync_badge_bar(App& app) {
BadgeBar& bb = app.badges;
if (bb.trigger.button == nullptr) return;
const bool enabled = app.trigger_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE;
const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed);
const bool pending = app.model->trigger_pending();
const bool has_event = app.model->event() != nullptr;
set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event));
set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page);
}
gboolean badges_probe(gpointer user) {
App* a = static_cast<App*>(user);
// The whole table of the pure function: 16 inputs, 5 words.
struct Row { bool e, a, p, v; const char* want; };
static const Row kRows[] = {
{false, false, false, false, "off"}, {false, true, true, true, "off"},
{true, false, false, true, "triggered"}, {true, true, false, true, "triggered"},
{true, true, true, false, "capturing"}, {true, false, true, true, "capturing"},
{true, true, false, false, "armed"}, {true, false, false, false, "disarmed"},
};
int bad = 0;
for (const Row& r : kRows) {
if (std::strcmp(trigger_badge_state(r.e, r.a, r.p, r.v), r.want) != 0) ++bad;
}
// The geometry: fixed height whatever the state, and the open mark follows the slide-over.
const int h_closed = gtk_widget_get_height(a->badges.bar);
slideover_toggle(*a, "trigger");
const bool marked_open = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") != FALSE;
const bool page_open = slideover_page(*a) == "trigger";
slideover_toggle(*a, "trigger");
const bool marked_closed = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") == FALSE;
const int h_after = gtk_widget_get_height(a->badges.bar);
std::fprintf(stderr,
"[badges] table_bad=%d trigger_state=%s bar_h=%d bar_h_after=%d fixed=%d open_marked=%d "
"page_open=%d closed_unmarked=%d\n",
bad, gtk_label_get_text(GTK_LABEL(a->badges.trigger.state)), h_closed, h_after,
h_closed == h_after && h_closed >= 36 ? 1 : 0, marked_open ? 1 : 0, page_open ? 1 : 0,
marked_closed ? 1 : 0);
return G_SOURCE_REMOVE;
}
} // namespace ui_impl
} // namespace udpscope
(The probe checks trigger_badge_state against the full table the declaration gives. Add the 8 remaining combinations if you want all 16; the 8 above cover every word and every branch.)
-
Step 4: Wire it
ui_app.hpp:Badge,BadgeBar, declarations; onApp:BadgeBar badges; GtkWidget* badge_bar = nullptr; GtkWidget* badge_signals = nullptr; GtkWidget* badge_system = nullptr;.ui_slideover.cpp: restore the twosync_badge_bar(app);calls from task 1 (end ofslideover_openandslideover_close).ui.cppon_activate: resolve the three ids, thenbuild_badge_bar(*app);afterapp->trigger_toggleexists (it is created inbuild_toolbar, which runs before) and afterbuild_slideover.ui.cpptick(): callsync_badge_bar(*app);next tosync_source_panels(*app);.ui.cpp: probe schedulingUDPSCOPE_PROBE_BADGES_MS->badges_probe.CMakeLists.txt: addsrc/ui_badges.cpp.
-
Step 5: Move the trigger form into the slide-over's
triggerpage
In on_activate (currently ~ui.cpp:5187):
// Signals: the trigger picks a channel from the list beside it.
app->trigger_panel = build_trigger_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->trigger_panel);
gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app->signal_panel = build_signal_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
becomes
// The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the
// sidebar until the dock replaces it.
app->trigger_panel = build_trigger_panel(*app);
slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel);
app->signal_panel = build_signal_panel(*app);
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
Then deal with everything that assumed the trigger was on the Signals page and mapped: grep -n "trigger_panel\|trigger_body\|trigger_head\|trigger_summary\|dbg_trigger_\|dbg_signal_page_current" src/*.cpp. Specifically exit_probe_step3 (ui_probe.cpp ~100-206) reads gtk_widget_get_width/height(a->trigger_panel), on_signals && gtk_widget_get_sensitive(a->trigger_body); any probe that reads a trigger widget's allocation must call slideover_open(*a, "trigger") and wait one settled frame (the reveal is 120 ms) before reading, and close it after. Reads of sensitivity/visibility/value need no open page. dbg_signal_page_current / dbg_trigger_sensitive keep their meaning but on_signals is now about the sidebar tab only for the signal list: dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body).
The trigger form's header toggle mirroring (trigger_switch <-> trigger_toggle), sync_trigger_rows and the locked summary view are all widget-state, not page-state; they must be unaffected -- the existing trigger probes are the proof (Step 6).
- Step 6: Verify
Build (0 warnings). Then, serially, with a mock each:
[badges]probe:UDPSCOPE_PROBE_BADGES_MS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500against the UDPS mock ->table_bad=0 fixed=1 open_marked=1 page_open=1 closed_unmarked=1,gtk warnings: 0.- Every existing trigger probe in
docs/claude/verification.md(grepUDPSCOPE_PROBE_.*TRIGGER\|FORCE\|ARM\|PERSIST\|DENSITY\|CONDITION\|LOCK\|SUMMARY; run those whose names mention the trigger panel, arming, locking, the summary or the panel switch) and compare against the reading each entry indocs/claude/probe-readings.mdstates. A probe that now reads zero because its widget is unallocated is the fix described in Step 5, not a regression to hide. - Screenshot with
UDPSCOPE_PROBE_BADGES_HOLD-style early exit is not needed: addslideover_open(*a,"trigger")to nothing; instead take one screenshot (Task 1 step 7's command withUDPSCOPE_PROBE_BADGES_MSreplaced by a one-off envUDPSCOPE_OPEN_PAGE=triggerread inon_activate's tail -- remove it after) and view it: badge bar under the plot, Trigger badge pressed, the trigger form in the panel. Do not commit the temporary env. - Baseline diff (normalised) vs
.superpowers/sdd/ui-badges-baseline-udps.txt. Allowed baseline changes: theside/triggersize lines (trigger_panelis read through the slide-over), plus the new[badges]lines.
- Step 7: Commit
git add src/ui_badges.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp src/ui_slideover.cpp src/ui_probe.cpp CMakeLists.txt docs/claude/verification.md
git commit -m "ui: a badge bar with a Trigger badge, and the trigger form in the slide-over"
Task 3: The Source badge and the source panels in the slide-over
Files:
- Modify:
src/ui_source.cpp,src/ui_badges.cpp,src/ui_app.hpp,src/ui.cpp,src/ui_probe.cpp,docs/claude/verification.md
Interfaces:
-
Consumes:
slideover_add_page,slideover.source_pages,Badge/BadgeBar::source. -
Produces:
build_source_panels(App&)now registerssource-<id>slide-over pages (id andSourcePanelPage::nameunchanged);sync_badge_baralso writes the Source badge.SourcePanelPageis unchanged. -
Step 1:
build_source_panelsregisters slide-over pages
In src/ui_source.cpp, replace the scroller + adw_view_stack_add_titled block with:
const std::string name = "source-" + p.id;
// The panel is a slide-over page now: the slide-over wraps it in its own scroller, and with two or
// more source pages it draws the segmented switch (the E-1608: Analog out, Digital I/O).
if (!slideover_add_page(app, name, p.title, p.widget)) continue;
app.source_panels.push_back({name, p.title, p.widget});
and change the guard at the top of the function from app.side_stack == nullptr to app.slideover.stack == nullptr. apply_source_panels/sync_source_panels (sensitivity from the connection flag) are unchanged.
Leave keep_tabs_icon_free in place for now (the sidebar tabs still exist until task 4); it must not be called with source pages any more -- it only hides images, so it is harmless.
- Step 2: The Source badge
In build_badge_bar, after the Trigger badge:
// The first source page is where the badge goes; a source with none (UDPS) has nothing to open, so its
// badge is a read-out: not a target, not focusable, but still on screen.
const std::string first = app.source_panels.empty() ? std::string() : app.source_panels.front().name;
make_badge(app, bb.source, bb.system_slot, "Source", first);
if (first.empty()) {
gtk_widget_set_can_target(bb.source.button, FALSE);
gtk_widget_set_focusable(bb.source.button, FALSE);
}
build_badge_bar must therefore run after build_source_panels(*app) -- move its call there. (Order in on_activate: build_slideover, trigger page, build_source_panels, build_badge_bar.) For a source's open mark, the Source badge is "open" for any source-* page: in sync_badge_bar, set_open(bb.source.button, slideover_page(app).rfind("source-", 0) == 0).
State text (written in sync_badge_bar): the name label is the source kind's display_name() (find_kind(app.source_kind), include source/registry.hpp; fall back to "Source"), the state is connected / disconnected from app.model->stats().connected.load(std::memory_order_relaxed); in file mode (app.replaying or !app.model->config().open_waveform.empty()) the name is "File" and the state is empty. Set the name label with set_text_if_changed too.
The badge click on a multi-page source opens the first page; the segmented switch inside the panel changes page (Task 1's on_source_switch). If the open page is already a source page, a click closes it (that is slideover_toggle's same-page rule, which the badge's page = first page gives for the first page; for any other open source page make on_badge_clicked close the panel when b == &app->badges.source and the open page starts with source-).
-
Step 3: Re-point the probes that read the source pages
ui_probe.cpp~1958 and ~2400:adw_view_stack_get_child_by_name(side_stack, name) != nullptr->slideover_has_page(*a, name).ui_probe.cpp~2495:adw_view_stack_set_visible_child_name(side_stack, "source-ao")->slideover_open(*a, "source-ao"); the probe reads panel widgets' values and sensitivity, and any read of an allocation needs the page open and one settled frame (>= 150 ms) -- keep the page open for the probe's reads and close it at the end.- Add to the dump (or a
UDPSCOPE_PROBE_BADGES_MSextension) a[badges] source name=<...> state=<...> pages=<n> switch_visible=<0|1>line: afterslideover_open(*a, <first source page>),switch_visiblemust read1for the E-1608 (two pages) and0for the probe source (one page); then select the second page viaadw_toggle_group_set_active_nameand read backslideover_page(*a).
-
Step 4: Verify (serial; kill each mock)
- Probe source:
UDPSCOPE_PROBE_SOURCE_KINDS=3000 UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_PROBE_BADGES_MS=2500 ./build/udpscope --source probe --source-opt rate=500->[badges] source name=... pages=1 switch_visible=0;[source-kinds]lines unchanged. - E-1608:
python3 tools/mock_e1608.py(default port 54211), run the app with--source e1608the waydocs/claude/verification.mdstates, with the E-1608 panel probes (UDPSCOPE_PROBE_E1608_*) andUDPSCOPE_PROBE_BADGES_MS->pages=2 switch_visible=1, the panel probes' readings unchanged,gtk warnings: 0. - UDPS mock baseline diff. Allowed baseline changes: none beyond Task 2's; the Source badge on UDPS is the not-a-target read-out.
- Screenshot of the E-1608 run with the Source page open (cairo, as Task 1 step 7), view it: the segmented switch under the header, the Analog out page below it.
- Probe source:
-
Step 5: Commit
git add src/ui_source.cpp src/ui_badges.cpp src/ui_app.hpp src/ui.cpp src/ui_probe.cpp docs/claude/verification.md
git commit -m "ui: the Source badge, and the source panels as slide-over pages"
Task 4: Replace the sidebar tabs with the temporary dock; session and keyboard
Files:
- Modify:
src/ui/main-window.ui,src/ui.cpp,src/ui_source.cpp,src/ui_app.hpp,src/ui_layout.cpp,src/layout_store.hpp,src/layout_store.cpp,src/shortcuts.hpp,src/shortcuts.cpp,src/ui/shortcuts.ui(if it lists the two actions),src/ui_probe.cpp,docs/claude/*entries touched by their probes - Read first:
docs/claude/invariants-session-and-export.md,docs/claude/invariants-plot-and-layout.md
Interfaces:
-
Produces:
App::dock_page(the dock'sGtkBox),void dock_show_section(App&, const char* section)("signals"or"measure"; scrolls the dock to that section; unknown name is a no-op),AppAction::ShowMeasure/AppAction::ShowSignals(renamed fromSideTabMeasure/SideTabSignals; same keysmandg, labels "Go to Measure" / "Go to Signals"),SessionState::dock_shown(bool, default true). Removed:App::side_stack,App::side_switcher,App::side_tab,select_side_tab,on_side_tab_changed,keep_tabs_icon_free,SessionState::side_tab,App::dbg_switcher_*,dbg_signal_page_current's tab meaning. -
Step 1: The dock in
main-window.ui
Replace the side_column subtree (switcher + stack + the two page scrollers) with:
<!-- The dock: one scroller holding the signal list, then the cursors and the measurements.
It replaces the tab switcher (four text tabs did not fit 320 px and a source can add
more); it is the temporary home of Measure and Signals until steps 2 and 3 turn them
into the badge bar's signals and the compact readouts. -->
<object class="GtkBox" id="side_column">
<property name="orientation">vertical</property>
<property name="width-request">300</property>
<child>
<object class="GtkScrolledWindow" id="dock_scroller">
<property name="vexpand">1</property>
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="child">
<object class="GtkBox" id="dock_page">
<property name="orientation">vertical</property>
</object>
</property>
</object>
</child>
</object>
Update the file's header comment (the sidebar is the dock; the panels appended to dock_page).
-
Step 2: Fill the dock in
on_activateand delete the tab code- Resolve
dock_page,dock_scrollerinstead ofside_stack,side_switcher,measure_page,signals_page; guard them;app->side_box = side;stays (the tour and the layout probes measure it). AddGtkWidget* dock_page; GtkWidget* dock_scroller;toApp. - Order in the dock, top to bottom:
signal_panel, separator,cursors_panel, separator,build_measures_panel(*app). The signal list is first because it is what is dragged onto the plot. - Delete: the
adw_view_stack_set_visible_child_name(side_stack, "signals")block and itsnotify::visible-child-nameconnection,keep_tabs_icon_free(*app)and its definition/declaration inui_source.cpp/ui_app.hpp(withhide_tab_icons,on_tab_pages_changed),on_side_tab_changed,select_side_tab(ui.cpp~2867-2882),App::side_stack/side_switcher/side_tab, and thedbg_switcher_*members. - Add in
ui.cpp:
- Resolve
void dock_show_section(App& app, const char* section) {
if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return;
GtkWidget* target = std::strcmp(section, "measure") == 0 ? app.cursors_panel
: std::strcmp(section, "signals") == 0 ? app.signal_panel
: nullptr;
if (target == nullptr) return;
graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f);
graphene_point_t at;
if (!gtk_widget_compute_point(target, app.dock_page, &zero, &at)) return;
GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller));
gtk_adjustment_set_value(adj, at.y);
}
-
run_app_action:case AppAction::ShowMeasure: dock_show_section(app, "measure"); break;/ShowSignalslikewise. Rename inshortcuts.hpp,shortcuts.cpp(labels "Go to Measure" / "Go to Signals", same group "Panes and editors"),shortcuts.uiif present, and the keyboard probe (ui_probe.cpp~6278-6305: it comparedside_tabbefore/after; compare the dock scroller'svadjustmentvalue instead --mmust move it to the cursors panel's y,gback to 0). -
Add
case Escape: inon_app_key_pressed, directly after the tour's Escape branch and before the editable check:if (keyval == GDK_KEY_Escape && slideover_escape(*app)) return TRUE;. -
Step 3: Dock visibility -- shown, insensitive until signals
update_side_visibility (ui.cpp ~2887) and the per-frame block in tick() (~4029-4047) both compute want = have_signals && sidebar_wanted. Change both to want = sidebar_wanted, and state the dock's availability instead:
// "Disable, do not hide": the dock is on screen from the start and does nothing until the stream has signals.
gtk_widget_set_sensitive(app.side_box, have_signals);
gtk_widget_set_sensitive(app.sidebar_toggle, TRUE);
(trigger_toggle's visibility from have_signals is unchanged; update_side_visibility keeps the early-return guard on app.side_box == nullptr instead of side_stack.) Update the comments above both blocks.
-
Step 4: Session --
side_tabout,dock_showninlayout_store.hpp: removeside_tab; addbool dock_shown = true;documented as "the dock's shown state (the user's last word on the toolbar's Dock button)".layout_store.cpp: writer (~743) emitsdock = true|falsein the same top section whereside_tabwas written (keep key order per the session invariants); parser: replace thekey == "side_tab"branch withkey == "dock"->st.dock_shown = value == "true"; an old file'sside_tab = ...falls through to the existing unknown-key handling (check the finalelseof that chain ignores it -- read it; do not add a branch to swallow it if it already does). Bump nothing else.ui_layout.cpp:67->st.dock_shown = app.sidebar_wanted;;:430->app.sidebar_wanted = st.dock_shown;thenupdate_side_visibility(app);.- A change of the user's wish must schedule a save: in
on_sidebar_toggled, after recordingsidebar_wantedwhen!sidebar_syncing,if (!app->updating_panels) schedule_layout_save(*app);. - If there are session round-trip probes (
grep -n "side_tab" src/ui_probe.cpp docs/claude/*.md), extend them: save with the dock hidden, reload, readsidebar_wanted == false; and a file containingside_tab = "measure"loads with no error anddock_showndefault.
-
Step 5: Re-point the layout/sidebar probes
ui_probe.cpp: exit_probe_step3 (~100-206), side_tab_probe* (~1800-1875), sidebar_probe (~3532-3567), the select_side_tab(*a, "measure") calls at ~4519 and ~4678, and the tour-surface probes (done in Task 5). Rules:
-
dbg_switcher_*and theswitcher:dump line (ui.cpp ~2998, 3055-3090, and the printing at ~6224-6436) are deleted; replace with onedock: shown=<0|1> sensitive=<0|1> w=<px> scroller_pages=1line and document it. -
side_tab_probekeeps its job (measurements keep accumulating while the Measure section is scrolled out of view) withdock_show_section(*a, "measure")/"signals"in place ofselect_side_tab; rename itdock_section_probe, envUDPSCOPE_PROBE_SIDE_TAB_MSis kept as is (do not break scripts); itsadvanced=4assertion is unchanged. -
dbg_signal_page_currentbecomes "the signal list has rows" (no tab),dbg_trigger_sensitiveis read directly. -
Step 6: Verify
Build (0 warnings incremental; clean build has exactly one). Serially: UDPS mock baseline diff; UDPSCOPE_PROBE_SIDE_TAB_MS=3000 UDPSCOPE_EXIT_AFTER_MS=11000 (advanced=4); the sidebar/collapse probe; the keyboard probe; the session round-trip probe; the pane-drag probe (dbg_drag_hid_sidebar/restored both 1: dragging a signal from an overlaid dock still hides it for the drag); one run with no signals (UDPS mock with --source that has none, or a run before CONFIG) reading dock: shown=1 sensitive=0; E-1608 and probe-source runs from Task 3 again. Screenshot at 1280x800: plot, badge bar, dock with the list, cursors, measurements.
Allowed baseline changes: the switcher: line goes; a dock: line appears; tab/page readings that named signals/measure become the section readings; [sidebar] lines may differ only by shown when no signals existed.
- Step 7: Commit
git add -A src docs/claude
git commit -m "ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state"
Task 5: Tour, contextual help and the help figures
Files:
- Modify:
src/ui_tour.cpp,src/ui_context_help.cpp,src/ui_app.hpp,src/ui_probe.cpp; readtools/check_help.sh,tools/make-help-shots.sh
Interfaces:
-
Produces:
TourView::on_page--void (*on_page)(void* user, const char* page) = nullptr; void* page_user = nullptr;. When set it replaces thepage_stackmechanism for that view:select_view_pagecallson_page(page_user, stop->page)(page may benullptr, meaning "no page needed"). The chooser keepspage_stack.TourStep::pagefor the main window now names a slide-over page ("trigger") or isnullptr. -
Step 1: The page hook in the shared tour machinery (
ui_tour.cpp/ui_app.hpp)- Add the two members to
TourView. select_view_page: first lineif (view.on_page != nullptr) { view.on_page(view.page_user, page); return; }.stop_is_walkable:if (stop.page != nullptr && view.on_page != nullptr) return true;before thepage_stackrule -- a slide-over page can always be opened, whatever the dock is doing.start_tour: replaceapp.tour.page_stack = app.side_stack;withapp.tour.on_page = +[](void* u, const char* page) { App& a = *static_cast<App*>(u); if (page != nullptr) slideover_open(a, page); else slideover_close(a); }; app.tour.page_user = &app;. A stop withpage == nullptrcloses the panel, so the card never walks to the status line over an open panel. Ontour_end, close the panel too (find where the tour tears down and callslideover_closethrough the same hook withnullptr).
- Add the two members to
-
Step 2: Rewrite the main window's stops
kTourSteps becomes (titles/bodies in plain words; keep the existing style):
| id | page | target | text (replace with your own wording of this meaning) |
|---|---|---|---|
plot |
nullptr | &App::area_overlay |
unchanged |
badges |
nullptr | &App::badge_bar |
"Setup is reached by the thing it configures. The Trigger badge opens the trigger form; the Source badge opens the source's own controls. Click it again, press Esc or click the plot to close the panel." |
trigger |
"trigger" |
&App::trigger_body |
existing trigger text (describe an event, arm it) |
side |
nullptr | &App::side_box |
"The dock beside the plot: the list of signals, then the cursors and the measurements. The buttons in the bar above show and hide it." |
signals |
nullptr | &App::signal_list |
unchanged |
status |
nullptr | &App::status_row |
unchanged |
Add GtkWidget* App::badge_bar to the table (it is already an App member from Task 2). Order matters for the walk: the trigger stop opens the panel, the side stop closes it. Update kFigureSurfaces (ui_tour.cpp ~484): drop side_switcher and side_stack; add {"badge_bar", &App::badge_bar} and {"dock", &App::side_box}. If tools/check_help.sh, tools/make-help-shots.sh or any docs/help/** figure data names side_switcher/side_stack, grep for them (grep -rn "side_switcher\|side_stack" tools docs src data 2>/dev/null): keep side_stack as a second name for the dock in kFigureSurfaces until the manual's figures are redone in step 4 (record that in the ledger), and drop side_switcher only if nothing names it.
- Step 3: Contextual help (
ui_context_help.cpp)
The hint table (~line 92 {"signals", "The signals", ...}) anchors "(i)" marks to panel hosts. Read the whole table. Hints whose host is now in the slide-over (the trigger's) are on an unmapped widget while the panel is closed: confirm the code that places them tolerates an unmapped host (hints are overlay children of the host; they should appear with the panel) and that context_help_count / the hints=N ids={...} reading is the same set of ids as before. Add a hint for the badge bar only if the table's pattern is "one hint per panel"; otherwise do not.
- Step 4: Re-point the tour probes
ui_probe.cpp ~6565-6875 ([tour], [toursurface]): side_tab= becomes slideover=<page|none> printed from slideover_page(a); select_side_tab(*a, "measure") + mapped(a->side_stack) become slideover_open(*a, "trigger") and mapped(a->side_box); the "collapsed" leg (dock collapsed) must show that the trigger stop is still walkable (it is the new behaviour: the slide-over does not depend on the dock) and the signals stop is dropped. Read docs/claude/probe-readings.md for the [toursurface] entry and update it in Task 6.
- Step 5: Verify
Tour probes (grep -n "TOUR\|HELPUI\|CONTEXT" docs/claude/verification.md for their env names) -> the walk lists the stops above in order, slideover=trigger at the trigger stop and slideover=none at the next, spotlight rectangles contained in the overlay, hints= ids as before; tools/check_help.sh passes. UDPS baseline diff; allowed changes: tour step count and stop ids/titles, side_tab= fields.
- Step 6: Commit
git add src/ui_tour.cpp src/ui_context_help.cpp src/ui_app.hpp src/ui_probe.cpp
git commit -m "ui: the tour and the contextual help follow the badge bar, the slide-over and the dock"
Task 6: Documentation for step 1, and the final check
Files:
-
Modify:
README.md,docs/claude/architecture.md,docs/claude/verification.md,docs/claude/probe-readings.md,docs/claude/invariants-plot-and-layout.md,docs/claude/invariants-gtk-and-chrome.md,docs/claude/build-and-run.md(only if it names a changed file),CLAUDE.md(only its table if a row's text is now wrong) -
Not in this plan (spec step 4):
USER-GUIDE.md, the Mallard manual's text and figures. -
Step 1: Sweep the references
grep -rn "side_switcher\|side_stack\|side_tab\|switcher:\|keep_tabs_icon_free\|SideTab\|Measure tab\|Signals tab\|tab switcher\|sidebar tab" README.md CLAUDE.md docs/claude src tools -- every hit outside docs/superpowers/ is rewritten or deleted. Specifically:
-
README.md: the layout description (badge bar, slide-over, dock), the keyboard table (m/gare "Go to Measure / Signals"), the session file's keys, the deviations list (the three step-1 deviations above, as known limitations of the in-progress redesign). -
docs/claude/architecture.md: the three new units and the rule that the slide-over/badge bar are built inon_activate, per-tick state insync_badge_bar. -
docs/claude/invariants-plot-and-layout.md: the slide-over as the second thing placed over the plot (after the layout editor's buttons), with the pick rule; the badge bar's fixed height. -
docs/claude/invariants-gtk-and-chrome.md: delete the tab-icon paragraph andkeep_tabs_icon_free(the switcher is gone); add: a capture-phase observer gesture that never claims the sequence is how click-away coexists with the plot's gestures;AdwToggleGroupis the segmented switch. -
docs/claude/probe-readings.mdandverification.md: the new lines[slideover],[badges],dock:, the removal ofswitcher:; the new baseline statement for the UDPS diff ("unchanged except ..." now relative to the commit before this series, with the allowed list); the renamed keyboard actions. -
docs/claude/verification.mdheader sentence about the UDPS baseline: say a self-check taken beforec572ec2is no longer the baseline for these lines. -
Step 2: The final run, serially
Clean build count: touch src/icons/udpscope.gresource.xml && cmake --build build -j 2>&1 | grep -c warning -> 1; incremental -> 0. UDPS mock full self-check: gtk warnings: 0, no Gtk-CRITICAL, buffer allocated: lines 2-3. Each probe touched in Tasks 1-5 once more, on the final tree. A last screenshot at 1280x800 with the trigger page open and one with a source page open (E-1608 mock) for the report. Kill every mock.
- Step 3: Commit
git add README.md CLAUDE.md docs/claude
git commit -m "docs: the badge bar, the slide-over and the dock (step 1 of the redesign)"
Self-review (against the spec)
- Spec step 1 -- "plot overlay, badge bar with Trigger and Source, slide-over holding the existing trigger form and source pages, sidebar tabs removed, existing Measure and Signals in a temporary dock": Task 1 (overlay + slide-over), Task 2 (badge bar, Trigger, trigger form), Task 3 (Source, source pages), Task 4 (tabs removed, temporary dock).
- Risks the spec assigns to step 1: overlay above GL -> Task 1 steps 6-7 before anything else; reparenting the trigger form -> Task 2 step 6 (existing trigger probes). "Many signals" is step 2.
- Header: the Events toggle removal and the Dock toggle rename are not step 1 (the events pane stays until step 4); the sidebar toggle already is the dock toggle in behaviour -- its tooltip/label text, if it says "sidebar", is renamed "Dock" in Task 4 step 3 (look at
header-bar.ui). - Keyboard / session / probes / tour / docs: Tasks 4, 4, 4-5, 5, 6.
- Left to later steps (by the spec's rollout): signal badges and drag (2), compact readouts and the 280 px dock (3), events section and deleting the left pane (4), USER-GUIDE and manual figures (4).
- Names used across tasks:
slideover_add_page/has_page/open/close/toggle/page/escape/click_away,SlideOver::source_pages,build_badge_bar,sync_badge_bar,trigger_badge_state,BadgeBar::{trigger,source},dock_show_section,ShowMeasure/ShowSignals,SessionState::dock_shown,TourView::on_page/page_userare used with one spelling throughout.