From c887a6c9b810e9179d6e18cfc8811aebef94d693 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Wed, 2 Sep 2026 13:15:19 +0200 Subject: [PATCH] TUI panels are now half-width and aligned; key bar is contextual --- src/ui/TuiUi.cpp | 178 ++++++++++++++++++++++++++++++----------------- 1 file changed, 116 insertions(+), 62 deletions(-) diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index 9998a1d..1c22fa5 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -129,11 +129,86 @@ Element cameraDetailTitle(const ScrollState& st) { return overlayTitle("CAMERA SYSTEM", "c/Esc:close", st); } +// Fixed-decimal number. Bare std::to_string on a double gives six decimals, so +// an image rate of 0.2 rendered as "0.200000 img/s". +std::string num(double v, int decimals) { + char b[32]; + std::snprintf(b, sizeof(b), "%.*f", decimals, v); + return b; +} + +// "label value" with the label in a fixed-width gutter, so the value columns +// line up across every panel instead of being hand-spaced per panel. +constexpr int kLabelW = 9; // compact dashboard panels +Element labelRow(const std::string& label, Element value, int width = kLabelW) { + return hbox({text(label) | dim | size(WIDTH, EQUAL, width), std::move(value) | flex}); +} + // "key" + label pair for the nano-style bottom bar. Element keyHint(const std::string& key, const std::string& label) { return hbox({text(" " + key + " ") | inverted, text(" " + label + " ")}); } +// Which overlay owns the body. Declared here so keyBar() can switch on it; the +// render loop keeps the authoritative value. +enum class Overlay { None, Help, Gimbal, Sensors, Cameras, Conn }; + +// The bottom bar, contextual: the overview advertises everyday operations and +// the overlay keys, and each overlay advertises what its own body responds to. +// +// This changes only what is ADVERTISED. Every binding still works everywhere - +// 's' starts a scan from inside the gimbal view exactly as before - so the bar +// is a hint about what is useful here, not a restriction. +// +// flexbox (Wrap::Wrap by default) rather than hbox: the overview set is wider +// than an 80-column terminal, and hbox silently truncated it mid-word ("q Qui"). +Element keyBar(Overlay overlay) { + static const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93"; + static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93"; + + // Start/stop are wanted from every view, so they lead everywhere. + Elements e{keyHint("s", "Start"), keyHint("x", "Stop")}; + + switch (overlay) { + case Overlay::None: + e.push_back(keyHint("h", "Home")); + e.push_back(keyHint("r", "Refresh")); + e.push_back(keyHint("g i c m", "Views")); + e.push_back(keyHint("p", "Pause")); + e.push_back(keyHint(kArrows4, "Nudge")); + e.push_back(keyHint("PgUp/Dn", "Log")); + break; + case Overlay::Help: + e.push_back(keyHint(kArrowsV, "Section")); + e.push_back(keyHint("PgUp/Dn", "Scroll")); + break; + case Overlay::Gimbal: + e.push_back(keyHint("d", "Dump")); + e.push_back(keyHint(kArrowsV, "Scroll")); + e.push_back(keyHint("PgUp/Dn", "Page")); + e.push_back(keyHint("Home/End", "Ends")); + break; + case Overlay::Sensors: + e.push_back(keyHint("r", "Refresh")); + e.push_back(keyHint(kArrowsV, "Scroll")); + e.push_back(keyHint("PgUp/Dn", "Page")); + e.push_back(keyHint("Home/End", "Ends")); + break; + case Overlay::Cameras: + case Overlay::Conn: + e.push_back(keyHint(kArrowsV, "Scroll")); + e.push_back(keyHint("PgUp/Dn", "Page")); + e.push_back(keyHint("Home/End", "Ends")); + break; + } + + if (overlay != Overlay::None) e.push_back(keyHint("Esc", "Close")); + e.push_back(keyHint(":", "Cmd")); + e.push_back(keyHint("?", "Help")); + e.push_back(keyHint("q", "Quit")); + return flexbox(std::move(e)); +} + Element badge(const std::string& s, bool on, Color on_color) { auto e = text(" " + s + " "); return on ? (e | color(on_color) | bold) : (e | dim); @@ -187,10 +262,9 @@ Element sensorsPanel(const SensorsView& s) { } return vbox(std::move(rows)); }; + // The title already says "[i]"; repeating "i to expand" in the body was noise. return panel("[i] SENSORS", Color::Magenta, - vbox({group(s.imu, "i to expand"), - separator(), - group(s.env, "")})); + vbox({group(s.imu, ""), separator(), group(s.env, "")})); } Element cameraPanel(const CaptureView& c) { @@ -201,43 +275,35 @@ Element cameraPanel(const CaptureView& c) { Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold) : (text(" idle ") | dim); std::vector rows = { - hbox({text("cameras ") | dim, text(std::to_string(c.camera_count) + " "), - text(labels) | dim}), - hbox({text("capture ") | dim, active, filler(), - text(std::to_string(c.image_rate) + " img/s") | dim}), + labelRow("cameras", hbox({text(std::to_string(c.camera_count) + " "), + text(labels) | dim})), + labelRow("capture", hbox({active, filler(), + text(num(c.image_rate, 2) + " img/s") | dim})), }; if (!c.scan_error.empty()) - rows.push_back(hbox({text("scan ") | dim, - text(" GRID LOAD FAILED ") | color(Color::Red) | bold, - text(" c for details") | dim})); - rows.push_back(hbox({text("mode ") | dim, - text(c.config.mode.empty() ? "—" : c.config.mode) | color(Color::Cyan)})); + rows.push_back(labelRow("scan", hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold, + text(" c for details") | dim}))); + rows.push_back(labelRow("mode", text(c.config.mode.empty() ? "\xE2\x80\x94" : c.config.mode) | + color(Color::Cyan))); if (c.has_last) { long long now = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); - rows.push_back(hbox({text("last ") | dim, - text(c.last_label + " "), - text(formatDegrees(c.last_heading_deg) + " / " + - formatDegrees(c.last_pitch_deg) + " ") | dim, - text(formatTimeAgo(now, c.last_ts_ms)) | dim})); + rows.push_back(labelRow("last", hbox({ + text(c.last_label + " "), + text(formatDegrees(c.last_heading_deg) + " / " + + formatDegrees(c.last_pitch_deg) + " ") | dim, + text(formatTimeAgo(now, c.last_ts_ms)) | dim}))); } else { - rows.push_back(hbox({text("last ") | dim, text("—") | dim})); + rows.push_back(labelRow("last", text("\xE2\x80\x94") | dim)); } - return panel("CAMERA", Color::Blue, vbox(std::move(rows))); + return panel("[c] CAMERA", Color::Blue, vbox(std::move(rows))); } // Expanded camera view ('c'): the full camera system — identity, live sensor // telemetry, imaging config, acquisition health, encoding/output — with the // auto-sweep scan grid below. The whole body scrolls (yframe) if it overflows. Element cameraDetailPanel(const CaptureView& c, ScrollState& st) { - auto num = [](double v, int dec) { - char b[32]; - std::snprintf(b, sizeof(b), "%.*f", dec, v); - return std::string(b); - }; - auto row = [](const std::string& k, Element v) { - return hbox({text(k) | dim | size(WIDTH, EQUAL, 14), std::move(v)}); - }; + auto row = [](const std::string& k, Element v) { return labelRow(k, std::move(v), 14); }; std::vector rows; @@ -368,12 +434,12 @@ Element connPanel(const ConnView& v) { : v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold) : (text(" disconnected ") | color(Color::Red) | bold); std::string mode = v.control_code == 0 ? "0 auto-sweep" : "1 directed"; - return panel("CONNECTIVITY", Color::Green, vbox({ - hbox({text("MQTT ") | dim, mqtt, filler(), text(v.broker) | dim}), - hbox({text("tower ") | dim, text(v.tower)}), - hbox({text("control ") | dim, text(mode), - filler(), text("hdg " + v.target_heading) | dim}), - hbox({text("status ") | dim, text(std::to_string(v.last_status_code))}), + return panel("[m] CONNECTIVITY", Color::Green, vbox({ + labelRow("MQTT", hbox({mqtt, filler(), text(v.broker) | dim})), + labelRow("tower", text(v.tower)), + labelRow("control", hbox({text(mode), filler(), + text("hdg " + v.target_heading) | dim})), + labelRow("status", text(std::to_string(v.last_status_code))), })); } @@ -385,9 +451,7 @@ Element connPanel(const ConnView& v) { // the link is down without queueing, retrying or logging, so a lost CamEvent - // and with it an image the ground station never fetches - is otherwise invisible. Element connDetailPanel(const ConnView& v, ScrollState& st) { - auto row = [](const std::string& k, Element val) { - return hbox({text(k) | dim | size(WIDTH, EQUAL, 12), std::move(val)}); - }; + auto row = [](const std::string& k, Element val) { return labelRow(k, std::move(val), 12); }; const long long now = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); @@ -976,7 +1040,6 @@ void TuiUi::refreshLoop() { void TuiUi::uiLoop() { std::string cmd_buffer; bool command_mode = false; - enum class Overlay { None, Help, Gimbal, Sensors, Cameras, Conn }; Overlay overlay = Overlay::None; // which takeover panel owns the main area int help_sel = 0; bool gimbal_dump_requested = false; // auto-pull a dump the first time @@ -1022,32 +1085,23 @@ void TuiUi::uiLoop() { text(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim, }); - Element top = hbox({gimbalPanel(s.gimbal), sensorsPanel(s.sensors)}); - Element middle = hbox({cameraPanel(s.capture), connPanel(s.conn)}); + // Pin the LEFT panel of each row to exactly half the terminal and let the + // right one flex into the remainder, so both rows split at the same + // column. Two `| flex` panels do not: FTXUI grows each from its own + // min_size (box_helper.cpp), so differing content moved the seam. + // size(WIDTH, EQUAL, n) also zeroes flex_grow (size.cpp), which is what + // makes the left panel hold its width. + ScreenInteractive* sc = screen_.load(); + const int half = sc ? sc->dimx() / 2 : 0; + auto halve = [half](Element e) { + return half > 0 ? std::move(e) | size(WIDTH, EQUAL, half) : std::move(e); + }; + Element top = hbox({halve(gimbalPanel(s.gimbal)), sensorsPanel(s.sensors)}); + Element middle = hbox({halve(cameraPanel(s.capture)), connPanel(s.conn)}); - const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93"; - Element bottom; - if (command_mode) { - bottom = hbox({text(" : ") | inverted, input->Render() | flex}) | border; - } else if (overlay != Overlay::None) { - // The nudge arrows are inert while an overlay owns the body, so the bar - // advertises what the keys actually do here instead. - bottom = hbox({ - (overlay == Overlay::Help ? keyHint(kArrowsV, "Section") - : keyHint(kArrowsV, "Scroll")), - keyHint("PgUp/Dn", "Page"), keyHint("Home/End", "Ends"), - keyHint("p", "Pause"), keyHint(":", "Cmd"), filler(), - keyHint("Esc", "Close"), keyHint("q", "Quit"), - }); - } else { - bottom = hbox({ - keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"), - keyHint("g", "Gimbal"), keyHint("i", "Sensors"), keyHint("c", "Scan"), - keyHint("m", "Comms"), keyHint("p", "Pause"), keyHint("r", "Refresh"), - keyHint("\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93", "Nudge"), - keyHint(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"), - }); - } + Element bottom = command_mode + ? hbox({text(" : ") | inverted, input->Render() | flex}) | border + : keyBar(overlay); // An open overlay takes over the whole body (the dashboard panels are // hidden) so it has the full height — otherwise tall content like the