From 4d5e2e4194144be6580eb2e020f9098627cebdaf Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Fri, 21 Aug 2026 15:25:45 +0200 Subject: [PATCH] Added scrolling in TUI --- CMakeLists.txt | 1 + include/fgc/ui/Scroll.h | 34 +++++ include/fgc/ui/TuiUi.h | 4 + src/core/HelpText.cpp | 22 +++- src/ui/Scroll.cpp | 35 +++++ src/ui/TuiUi.cpp | 276 +++++++++++++++++++++++++++++++++++----- tests/CMakeLists.txt | 1 + tests/test_scroll.cpp | 79 ++++++++++++ 8 files changed, 416 insertions(+), 36 deletions(-) create mode 100644 include/fgc/ui/Scroll.h create mode 100644 src/ui/Scroll.cpp create mode 100644 tests/test_scroll.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 15c6b34..be7819f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -66,6 +66,7 @@ add_library(fgc_core STATIC src/core/GatedCameraSource.cpp src/sensors/Sht41Protocol.cpp src/ui/UiSnapshot.cpp + src/ui/Scroll.cpp src/ui/HeadlessUi.cpp ini.c ) diff --git a/include/fgc/ui/Scroll.h b/include/fgc/ui/Scroll.h new file mode 100644 index 0000000..40896f9 --- /dev/null +++ b/include/fgc/ui/Scroll.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace fgc { + +// Viewport arithmetic for the scrollable TUI panes. Pure integer math with no +// FTXUI types, so it lives in fgc_core and is unit-testable without a terminal - +// mirroring UiSnapshot's formatting helpers. +// +// The model is a line-anchored viewport: `top` is the first body LINE we want +// visible. Heights come from the previous frame's measurements, so every entry +// point has to tolerate `view_h == 0` (nothing measured yet) without dividing by +// zero or handing back a position the renderer can't honour. + +// Largest valid `top` for a body of `content_h` lines in a `view_h` window. +// 0 when it all fits, and 0 while the view is still unmeasured. +int scrollMax(int content_h, int view_h); + +int scrollClamp(int top, int content_h, int view_h); + +// One line per keypress; `delta` is +/-1. +int scrollBy(int top, int delta, int content_h, int view_h); + +// A screenful less two lines of overlap, so the eye keeps its place. `dir` is +// +/-1; the step is at least 1 even in a one-line pane. +int scrollPage(int top, int dir, int content_h, int view_h); + +// Rows per column for a wrapped grid. Short grids keep a compact multi-column +// look (at least `min_rows` per column); past `max_cols` columns the grid grows +// DOWNWARD instead of off the right edge, where vertical scrolling can reach it. +std::size_t gridRowsPerColumn(std::size_t item_count, std::size_t max_cols, std::size_t min_rows); + +} // namespace fgc diff --git a/include/fgc/ui/TuiUi.h b/include/fgc/ui/TuiUi.h index 432706a..daeb191 100644 --- a/include/fgc/ui/TuiUi.h +++ b/include/fgc/ui/TuiUi.h @@ -54,6 +54,10 @@ private: // Log ring buffer (newest last), filled by the Logger sink. std::mutex log_mutex_; std::deque log_; + // Lines evicted from the FRONT since startup. A frozen scrollback position is + // an index from the start of the buffer, so every eviction shifts the content + // under it; the renderer subtracts the delta to hold the view still. + size_t log_dropped_ = 0; static constexpr size_t kLogCap = 500; }; diff --git a/src/core/HelpText.cpp b/src/core/HelpText.cpp index d432f27..275aa71 100644 --- a/src/core/HelpText.cpp +++ b/src/core/HelpText.cpp @@ -35,7 +35,8 @@ const std::vector& helpCatalog() { "Example: gimbal steps 100000,250000"}}, {"gimbal nudge <+/-pct>", "Relative step move by a percent of the axis travel.", { - "Arrow keys do this: Left/Right = yaw -/+5%, Up/Down = pitch +/-10%.", + "Arrow keys do this when NO overlay is open: Left/Right = yaw -/+5%,", + "Up/Down = pitch +/-10%. Inside an overlay the arrows scroll instead.", "Example: gimbal nudge yaw -5"}}, {"gimbal home [y|p]", "Run the endstop-finding home sequence (both axes, or one).", { @@ -92,6 +93,25 @@ const std::vector& helpCatalog() { "Toggle verbatim wire-trace categories.", { "Example: trace serial on / trace off"}}, }}, + {"Display", "Terminal dashboard keys (TUI only).", { + {"? / g / i / c", + "Toggle the Help, Gimbal, Sensors and Camera overlays.", { + "An overlay takes over the body, so it gets the full terminal height.", + "Esc closes it; with no overlay open, Esc cancels a running procedure."}}, + {"Up/Down, j/k", + "Scroll the open overlay one line (Help: change section).", { + "The scrollbar on the right shows how much is off-screen."}}, + {"PageUp/PageDown", + "Scroll the open overlay a screenful; on the dashboard, the log.", { + "Paging the log freezes it: new lines keep arriving but the view", + "holds still. The LOG title shows 'scrollback' while it is frozen."}}, + {"Home/End", + "Jump to the top/bottom; on the log, End resumes following the tail.", {}}, + {"p", + "Freeze the 10 Hz repaint so terminal text selection survives.", { + "Any keypress still repaints, so scrolling while paused drops a", + "selection - pause is for copying, not for reading scrollback."}}, + }}, {"Session", "Help and exit.", { {"refresh", "Re-read live device state (TUI: press 'r').", { diff --git a/src/ui/Scroll.cpp b/src/ui/Scroll.cpp new file mode 100644 index 0000000..45b9ee8 --- /dev/null +++ b/src/ui/Scroll.cpp @@ -0,0 +1,35 @@ +#include "fgc/ui/Scroll.h" + +#include + +namespace fgc { + +int scrollMax(int content_h, int view_h) { + // An unmeasured view (first frame after an overlay opens or the terminal + // resizes) pins to the top rather than guessing: the next repaint is 100ms + // away and a guess would visibly jump. + if (view_h <= 0) return 0; + return std::max(0, content_h - view_h); +} + +int scrollClamp(int top, int content_h, int view_h) { + return std::clamp(top, 0, scrollMax(content_h, view_h)); +} + +int scrollBy(int top, int delta, int content_h, int view_h) { + return scrollClamp(top + delta, content_h, view_h); +} + +int scrollPage(int top, int dir, int content_h, int view_h) { + const int step = std::max(1, view_h - 2); + return scrollClamp(top + dir * step, content_h, view_h); +} + +std::size_t gridRowsPerColumn(std::size_t item_count, std::size_t max_cols, std::size_t min_rows) { + max_cols = std::max(max_cols, 1); + min_rows = std::max(min_rows, 1); + const std::size_t needed = (item_count + max_cols - 1) / max_cols; // ceil + return std::max(min_rows, needed); +} + +} // namespace fgc diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index dbd705e..db603c2 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -3,7 +3,10 @@ #include "fgc/DumpParser.h" #include "fgc/HelpText.h" #include "fgc/Logger.h" +#include "fgc/ui/Scroll.h" +#include +#include #include #include #include @@ -14,6 +17,9 @@ #include #include #include +#include +#include +#include #include namespace fgc { @@ -39,6 +45,88 @@ Element panel(const std::string& title, Color title_color, Element body) { return window(text(" " + title + " ") | bold | color(title_color), std::move(body)) | flex; } +// ---- Scrollable panes ------------------------------------------------------- +// +// `yframe` clips its child to the available box and scrolls to reveal whichever +// element carries the "selected box". Nothing sets one by default, so a bare +// `vbox(rows) | yframe` renders the TOP of the content and silently drops the +// rest - which is what used to make long waypoint lists and register dumps +// unreachable. +// +// The anchor has to be applied DIRECTLY to the frame's child: focusPosition +// leaves requirement_.selection at NORMAL, and vbox/hbox only propagate a +// child's selected box when its selection is strictly greater than their own +// (which starts at NORMAL). Only focus(), which sets FOCUSED, would survive +// being buried inside a row - and it is unusable here because the rows have +// wildly different heights (a 3-line axis row, a 1-line separator, the whole +// scan grid as one ~20-line element), so a per-row cursor would scroll by 1, 3 +// or 40 lines a keypress. +// Capture a child's REQUESTED height. reflect() cannot do this: its Render +// intersects the box with the screen stencil, so inside a frame it hands back +// the visible height, and content/view would always compare equal. +Decorator reflectHeight(int& out) { + class Impl : public Node { + public: + Impl(Element child, int& out) : Node(unpack(std::move(child))), out_(out) {} + void ComputeRequirement() override { + Node::ComputeRequirement(); + requirement_ = children_[0]->requirement(); + out_ = requirement_.min_y; + } + void SetBox(Box box) override { + Node::SetBox(box); + children_[0]->SetBox(box); + } + + private: + int& out_; + }; + return [&out](Element child) { return std::make_shared(std::move(child), out); }; +} + +struct ScrollState { + int top = 0; // first body LINE we want visible + bool follow = false; // log only: pin to the newest line instead + + // Measured by the previous frame: the body's full requested height, and the + // visible height of the pane it is framed into. + int content_h = 0; + Box view{}; + + int contentH() const { return content_h; } + int viewH() const { return view.y_max - view.y_min + 1; } + bool scrollable() const { return scrollMax(contentH(), viewH()) > 0; } +}; + +// Frame `body` so line `st.top` sits at the top of the pane. +// +// Frame::SetBox CENTRES the viewport on the anchor (dy = selected.y_min - +// view/2 + focused/2, then clamped), so the anchor is pushed half a screen down +// to cancel that and land on `top` exactly. +Element scrollableBody(Element body, ScrollState& st) { + st.top = scrollClamp(st.top, st.contentH(), st.viewH()); + Decorator anchor = st.follow + ? focusPositionRelative(0.f, 1.f) + : focusPosition(0, st.top + st.viewH() / 2); + return std::move(body) | reflectHeight(st.content_h) | anchor | vscroll_indicator | yframe | + reflect(st.view); +} + +// Overlay window title. The scroll keys are advertised only while there is +// something off-screen, so the hint doubles as the "there is more below" cue on +// a pane that happens to fit. +Element overlayTitle(const std::string& name, const std::string& keys, const ScrollState& st, + Color c = Color::Cyan, bool arrows_scroll = true) { + std::string hint = keys; + if (st.scrollable()) + hint += arrows_scroll ? " \xE2\x86\x91\xE2\x86\x93/PgUp/PgDn:scroll" : " PgUp/PgDn:scroll"; + return text(" " + name + " (" + hint + ") ") | bold | color(c); +} + +Element cameraDetailTitle(const ScrollState& st) { + return overlayTitle("CAMERA SYSTEM", "c/Esc:close", st); +} + // "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 + " ")}); @@ -141,7 +229,7 @@ Element cameraPanel(const CaptureView& c) { // 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) { +Element cameraDetailPanel(const CaptureView& c, ScrollState& st) { auto num = [](double v, int dec) { char b[32]; std::snprintf(b, sizeof(b), "%.*f", dec, v); @@ -259,17 +347,23 @@ Element cameraDetailPanel(const CaptureView& c) { rows.push_back(hbox({text("reason ") | dim, text(c.scan_error) | color(Color::Red)})); rows.push_back(text("Check [Scan] grid_file in the loaded config (see the startup " "'Loaded config:' log line).") | dim); - return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan), - vbox(std::move(rows)) | yframe); + return window(cameraDetailTitle(st), + scrollableBody(vbox(std::move(rows)), st)); } if (c.scan_grid.empty()) { rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim); - return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan), - vbox(std::move(rows)) | yframe); + return window(cameraDetailTitle(st), + scrollableBody(vbox(std::move(rows)), st)); } // " # | yaw / pitch", current row inverted. Pitch column dropped on 1-axis. - constexpr size_t kRows = 18; // rows per column before wrapping + // Cap the COLUMN count rather than the row count: past the cap the grid grows + // downward, where the pane's vertical scrolling can reach it. Wrapping at a + // fixed 18 rows grew it rightwards instead, off the edge of a screen that + // yframe does not clip and no key can pan. + constexpr size_t kMaxCols = 3; // ~66 columns wide: fits an 80-column terminal + constexpr size_t kMinRows = 18; // short grids keep the original compact look + const size_t kRows = gridRowsPerColumn(c.scan_grid.size(), kMaxCols, kMinRows); std::vector cols; std::vector col; auto flushColumn = [&] { @@ -295,8 +389,7 @@ Element cameraDetailPanel(const CaptureView& c) { rows.push_back(hbox({text(" # ") | dim, text(c.scan_pitch ? "yaw / pitch" : "yaw") | dim})); rows.push_back(hbox(std::move(cols))); - return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan), - vbox(std::move(rows)) | yframe); + return window(cameraDetailTitle(st), scrollableBody(vbox(std::move(rows)), st)); } Element connPanel(const ConnView& v) { @@ -313,7 +406,7 @@ Element connPanel(const ConnView& v) { })); } -Element logPanel(const std::vector& lines) { +Element logPanel(const std::vector& lines, ScrollState& st) { std::vector rows; for (const auto& l : lines) { Color c = Color::Default; @@ -327,8 +420,15 @@ Element logPanel(const std::vector& lines) { rows.push_back(text(l.text) | color(c)); } if (rows.empty()) rows.push_back(text("(no log output yet)") | dim); - return window(text(" LOG ") | bold | color(Color::GrayLight), - vbox(std::move(rows)) | focusPositionRelative(0, 1) | yframe); + // Frozen scrollback withholds new lines, which is invisible without a cue. + Element label = text(" LOG ") | bold | color(Color::GrayLight); + Element title = label; + if (!st.follow) + title = hbox({label, text("scrollback ") | color(Color::Yellow) | bold, + text("(End:follow) ") | dim}); + else if (st.scrollable()) + title = hbox({label, text("(PgUp:scrollback) ") | dim}); + return window(title, scrollableBody(vbox(std::move(rows)), st)); } // Compact strip below the log: the running special op (live) + the last @@ -360,7 +460,7 @@ Element activityPanel(const ActivityView& a) { // Inline help pane (toggled with '?'). Lists every command section; the // `sel`-th section is expanded to show each entry's detail. The Diagnostics // section additionally renders the last captured firmware DUMP block. -Element helpPanel(int sel, const DumpView& dump) { +Element helpPanel(int sel, const DumpView& dump, ScrollState& st) { const auto& cat = helpCatalog(); std::vector rows; for (int i = 0; i < static_cast(cat.size()); ++i) { @@ -395,8 +495,9 @@ Element helpPanel(int sel, const DumpView& dump) { } rows.push_back(text("")); } - return window(text(" HELP (?:close Up/Down:section) ") | bold | color(Color::Cyan), - vbox(std::move(rows)) | yframe); + return window(overlayTitle("HELP", "?:close Up/Down:section", st, Color::Cyan, + /*arrows_scroll=*/false), + scrollableBody(vbox(std::move(rows)), st)); } // Aligned "label: value" row for the detail view. @@ -489,7 +590,7 @@ Element axisDumpDetail(const DumpAxis& ax) { // (incl. RAMP_STAT, the last one) is visible without scrolling. Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d, char letter, bool calib_has, const CalibAxisView& cal, - const DiagAxisView* diag) { + const DiagAxisView* diag, ScrollState& st) { auto fmt = [](const char* f, double v) { char b[32]; std::snprintf(b, sizeof(b), f, v); @@ -546,13 +647,15 @@ Element axisColumn(const std::string& title, const AxisView& live, const DumpDat col.push_back(text(calib_has ? "fit failed" : "uncalibrated this session") | dim); } - return panel(title, Color::Cyan, vbox(std::move(col)) | yframe); + // Each axis keeps its own frame so the YAW/PITCH titles stay put; framing the + // whole hbox instead would scroll the titles away with the content. + return panel(title, Color::Cyan, scrollableBody(vbox(std::move(col)), st)); } // Full-screen gimbal view (toggled with 'g'): one column per axis, each with // live telemetry above its decoded firmware register dump + last calibration. Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const CalibResultView& calib, - const DiagResultView& diag) { + const DiagResultView& diag, std::array& st) { DumpData d = parseDump(dump.text); auto diagFor = [&](char axis) -> const DiagAxisView* { for (const auto& a : diag.axes) @@ -580,11 +683,12 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib }); std::vector cols; - cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, diagFor('Y'))); + cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, diagFor('Y'), st[0])); if (g.pitch_present) - cols.push_back(axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch, diagFor('P'))); + cols.push_back( + axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch, diagFor('P'), st[1])); - return window(text(" GIMBAL (g/Esc:close d:refresh dump) ") | bold | color(Color::Cyan), + return window(overlayTitle("GIMBAL", "g/Esc:close d:refresh dump", st[0]), vbox({header, separator(), hbox(std::move(cols)) | flex})); } @@ -592,7 +696,7 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib // then the ambient sensor. The two devices get their own headed sections, and // each temperature is labelled with its provenance ("internal" vs "ambient"), // so the reading the main window shows is never confused with the MTi's. -Element sensorsDetailPanel(const ImuView& v, const EnvView& e) { +Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st) { // Fixed-width, right-aligned to 2 decimals. The constant width keeps the sign // column and decimal point from jumping as values cross zero or change digit // count, so the readout stays steady instead of flickering. The width is kept @@ -741,11 +845,11 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e) { ? (text(" " + env_name + " live ") | color(Color::Green) | bold) : (text(" " + env_name + " no reading ") | color(Color::Red) | bold)); - // yframe so a long body (MTi channels + config + ambient) stays reachable on a - // short terminal instead of being silently cut off, as the camera view does. - return window(text(" SENSORS (i/Esc:close r:refresh config) ") | bold | color(Color::Magenta), + // Scrolled so a long body (MTi channels + config + ambient) stays reachable on + // a short terminal instead of being silently cut off. + return window(overlayTitle("SENSORS", "i/Esc:close r:refresh config", st, Color::Magenta), vbox({hbox({imu_status, text(" "), env_status, filler()}), separator(), - vbox(std::move(body)) | yframe | flex})); + scrollableBody(vbox(std::move(body)), st) | flex})); } } // namespace @@ -777,7 +881,10 @@ void TuiUi::stop() { void TuiUi::pushLog(LogLevel level, const std::string& line) { std::lock_guard lock(log_mutex_); log_.push_back({level, line}); - while (log_.size() > kLogCap) log_.pop_front(); + while (log_.size() > kLogCap) { + log_.pop_front(); + ++log_dropped_; + } } void TuiUi::refreshLoop() { @@ -807,13 +914,30 @@ void TuiUi::uiLoop() { bool gimbal_dump_requested = false; // auto-pull a dump the first time bool calib_prompt = false; // a yes/no calib-save question is showing + // Per-pane scroll positions. UI-thread-only view state (the renderer lambda + // and CatchEvent below both run on this thread), so unlike screen_/paused_ + // these need no synchronisation and stay locals rather than TuiUi members. + ScrollState cam_scroll, sensors_scroll, help_scroll, log_scroll; + std::array gimbal_scroll{}; // YAW, PITCH + log_scroll.follow = true; // the log tracks the tail until told otherwise + size_t log_dropped_at_freeze = 0; // buffer evictions when scrollback was entered + auto input = Input(&cmd_buffer, "type a command, Enter to run, Esc to cancel"); auto renderer = Renderer(input, [&] { UiSnapshot s = snapshot_ ? snapshot_() : UiSnapshot{}; + size_t log_dropped_now = 0; { std::lock_guard lock(log_mutex_); s.log.assign(log_.begin(), log_.end()); + log_dropped_now = log_dropped_; + } + // Evicting from the front of the ring buffer slides every line up under a + // frozen viewport, so a busy log would drag the view the operator parked. + // Absorb the shift; the clamp in scrollableBody handles running off the top. + if (!log_scroll.follow && log_dropped_now != log_dropped_at_freeze) { + log_scroll.top -= static_cast(log_dropped_now - log_dropped_at_freeze); + log_dropped_at_freeze = log_dropped_now; } calib_prompt = !s.activity.prompt.empty(); gimbal_overlay_open_.store(overlay == Overlay::Gimbal); // refreshLoop polls a dump while open @@ -833,9 +957,20 @@ void TuiUi::uiLoop() { Element top = hbox({gimbalPanel(s.gimbal), sensorsPanel(s.sensors)}); Element middle = hbox({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"), @@ -851,20 +986,22 @@ void TuiUi::uiLoop() { // gimbal register dump overflows the small log-sized area and clips. switch (overlay) { case Overlay::Help: - return vbox({header, separator(), helpPanel(help_sel, s.dump) | flex, bottom}); + return vbox({header, separator(), helpPanel(help_sel, s.dump, help_scroll) | flex, bottom}); case Overlay::Gimbal: return vbox({header, separator(), - gimbalDetailPanel(s.gimbal, s.dump, s.calib, s.diag) | flex, bottom}); + gimbalDetailPanel(s.gimbal, s.dump, s.calib, s.diag, gimbal_scroll) | + flex, + bottom}); case Overlay::Sensors: return vbox({header, separator(), - sensorsDetailPanel(s.imu, s.env) | flex, bottom}); + sensorsDetailPanel(s.imu, s.env, sensors_scroll) | flex, bottom}); case Overlay::Cameras: - return vbox({header, separator(), cameraDetailPanel(s.capture) | flex, bottom}); + return vbox({header, separator(), cameraDetailPanel(s.capture, cam_scroll) | flex, bottom}); default: { // Activity strip sits between the log and the key bar; shown only // once a special op has run or is running, else it costs no space. std::vector col = {header, separator(), top, middle, - logPanel(s.log) | flex}; + logPanel(s.log, log_scroll) | flex}; if (s.activity.active || s.activity.has_result || !s.activity.prompt.empty()) col.push_back(activityPanel(s.activity)); col.push_back(bottom); @@ -873,6 +1010,24 @@ void TuiUi::uiLoop() { } }); + // Apply a scroll key to one pane. `arrows` is false where Up/Down already mean + // something else (help section nav; gimbal nudge on the dashboard), leaving + // PgUp/PgDn/Home/End as the only scroll keys there. + auto scrollKeys = [](ScrollState& st, const Event& e, bool arrows) { + const int ch = st.contentH(), vh = st.viewH(); + if (arrows && (e == Event::ArrowDown)) { st.top = scrollBy(st.top, 1, ch, vh); return true; } + if (arrows && (e == Event::ArrowUp)) { st.top = scrollBy(st.top, -1, ch, vh); return true; } + if (e == Event::PageDown) { st.top = scrollPage(st.top, 1, ch, vh); return true; } + if (e == Event::PageUp) { st.top = scrollPage(st.top, -1, ch, vh); return true; } + if (e == Event::Home) { st.top = 0; return true; } + if (e == Event::End) { st.top = scrollMax(ch, vh); return true; } + if (arrows && e.is_character()) { + if (e.character() == "j") { st.top = scrollBy(st.top, 1, ch, vh); return true; } + if (e.character() == "k") { st.top = scrollBy(st.top, -1, ch, vh); return true; } + } + return false; + }; + auto root = CatchEvent(renderer, [&](Event e) { if (command_mode) { if (e == Event::Return) { @@ -890,14 +1045,61 @@ void TuiUi::uiLoop() { } const int n = static_cast(helpCatalog().size()); if (overlay == Overlay::Help) { // help pane navigation - if (e == Event::ArrowDown) { help_sel = (help_sel + 1) % n; return true; } - if (e == Event::ArrowUp) { help_sel = (help_sel - 1 + n) % n; return true; } + // Selecting a different section replaces the body wholesale, so the + // old scroll position would be meaningless - start at the top. + if (e == Event::ArrowDown) { help_sel = (help_sel + 1) % n; help_scroll = {}; return true; } + if (e == Event::ArrowUp) { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; } + } + // Scroll the overlay that owns the body. Arrows are free here except in + // Help, where the two cases above already claimed them for section nav. + if (overlay != Overlay::None) { + const bool arrows = (overlay != Overlay::Help); + switch (overlay) { + case Overlay::Cameras: + if (scrollKeys(cam_scroll, e, arrows)) return true; + break; + case Overlay::Sensors: + if (scrollKeys(sensors_scroll, e, arrows)) return true; + break; + case Overlay::Help: + if (scrollKeys(help_scroll, e, arrows)) return true; + break; + case Overlay::Gimbal: { + // Both axis columns move together: they are read side by side, + // and each clamps against its own measured height. + const bool a = scrollKeys(gimbal_scroll[0], e, arrows); + const bool b = scrollKeys(gimbal_scroll[1], e, arrows); + if (a || b) return true; + break; + } + case Overlay::None: break; + } } if (overlay != Overlay::None && e == Event::Escape) { overlay = Overlay::None; return true; } // Esc with no overlay open cancels a running procedure (test/calib/homing). if (overlay == Overlay::None && e == Event::Escape && sink_) { if (snapshot_ && snapshot_().activity.cancelable) { sink_("cancel"); return true; } } + // Log scrollback. The arrows belong to the gimbal here, so this is + // PgUp/PgDn/Home/End only; End resumes following the tail. + if (overlay == Overlay::None) { + if (e == Event::End) { + log_scroll.follow = true; + log_scroll.top = scrollMax(log_scroll.contentH(), log_scroll.viewH()); + return true; + } + if (e == Event::PageUp || e == Event::PageDown || e == Event::Home) { + if (log_scroll.follow) { + // Entering scrollback: freeze where the tail currently sits, and + // start tracking evictions from this point. + log_scroll.follow = false; + log_scroll.top = scrollMax(log_scroll.contentH(), log_scroll.viewH()); + std::lock_guard lock(log_mutex_); + log_dropped_at_freeze = log_dropped_; + } + return scrollKeys(log_scroll, e, /*arrows=*/false); + } + } // Arrow keys nudge the gimbal in steps (only when no overlay is open): // Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel. if (overlay == Overlay::None && sink_) { @@ -915,6 +1117,7 @@ void TuiUi::uiLoop() { } if (c == "?") { overlay = (overlay == Overlay::Help) ? Overlay::None : Overlay::Help; + if (overlay == Overlay::Help) help_scroll = {}; return true; } if (c == "g") { @@ -922,6 +1125,7 @@ void TuiUi::uiLoop() { overlay = Overlay::None; } else { overlay = Overlay::Gimbal; + gimbal_scroll = {}; if (!gimbal_dump_requested) { // auto-pull a dump the first time if (sink_) sink_("gimbal dump"); gimbal_dump_requested = true; @@ -935,10 +1139,12 @@ void TuiUi::uiLoop() { } if (c == "i") { overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors; + if (overlay == Overlay::Sensors) sensors_scroll = {}; return true; } if (c == "c") { overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras; + if (overlay == Overlay::Cameras) cam_scroll = {}; return true; } if (c == "p") { // freeze/unfreeze the live refresh so text can be selected @@ -946,8 +1152,8 @@ void TuiUi::uiLoop() { return true; // this keypress redraws once so the indicator updates } if (overlay == Overlay::Help) { // vim-style section nav while help is open - if (c == "j") { help_sel = (help_sel + 1) % n; return true; } - if (c == "k") { help_sel = (help_sel - 1 + n) % n; return true; } + if (c == "j") { help_sel = (help_sel + 1) % n; help_scroll = {}; return true; } + if (c == "k") { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; } } if (c == "q") { if (sink_) sink_("exit"); return true; } if (c == "s") { if (sink_) sink_("start"); return true; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 67a36fd..aa47496 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable(fgc_tests test_geometry.cpp test_scangrid.cpp test_uisnapshot.cpp + test_scroll.cpp test_mtiprotocol.cpp test_sht41.cpp test_imagequality.cpp diff --git a/tests/test_scroll.cpp b/tests/test_scroll.cpp new file mode 100644 index 0000000..c27f727 --- /dev/null +++ b/tests/test_scroll.cpp @@ -0,0 +1,79 @@ +#include + +#include "fgc/ui/Scroll.h" + +#include + +using namespace fgc; + +TEST_CASE("scrollMax is zero when the content fits") { + CHECK(scrollMax(10, 20) == 0); + CHECK(scrollMax(20, 20) == 0); + CHECK(scrollMax(21, 20) == 1); + CHECK(scrollMax(100, 20) == 80); +} + +TEST_CASE("an unmeasured view pins to the top") { + // First frame after an overlay opens: reflect() has not run yet, so every + // height is 0. Scrolling must be a no-op rather than a guess. + CHECK(scrollMax(500, 0) == 0); + CHECK(scrollClamp(42, 500, 0) == 0); + CHECK(scrollBy(0, 1, 500, 0) == 0); + CHECK(scrollPage(0, 1, 500, 0) == 0); + CHECK(scrollMax(500, -3) == 0); +} + +TEST_CASE("scrollClamp pulls a stale position back into range") { + // The pane was scrolled to the bottom of a long dump, then the dump was + // replaced by a shorter one: the view must not stay parked past the end. + CHECK(scrollClamp(80, 100, 20) == 80); + CHECK(scrollClamp(80, 30, 20) == 10); + CHECK(scrollClamp(80, 10, 20) == 0); + CHECK(scrollClamp(-5, 100, 20) == 0); +} + +TEST_CASE("scrollBy steps one line and stops at the ends") { + CHECK(scrollBy(0, 1, 100, 20) == 1); + CHECK(scrollBy(5, -1, 100, 20) == 4); + CHECK(scrollBy(0, -1, 100, 20) == 0); // already at the top + CHECK(scrollBy(80, 1, 100, 20) == 80); // already at the bottom +} + +TEST_CASE("scrollPage keeps two lines of overlap") { + CHECK(scrollPage(0, 1, 100, 20) == 18); + CHECK(scrollPage(18, -1, 100, 20) == 0); + // PgDn then PgUp returns exactly where it started, away from the ends. + CHECK(scrollPage(scrollPage(30, 1, 200, 20), -1, 200, 20) == 30); +} + +TEST_CASE("scrollPage still advances in a one-line pane") { + // view_h - 2 would be negative; the step floor keeps the keys usable. + CHECK(scrollPage(0, 1, 100, 1) == 1); + CHECK(scrollPage(0, 1, 100, 2) == 1); +} + +TEST_CASE("gridRowsPerColumn keeps short grids compact") { + // Under the column cap the grid looks exactly as it did before: 18 per column. + CHECK(gridRowsPerColumn(0, 3, 18) == 18); + CHECK(gridRowsPerColumn(1, 3, 18) == 18); + CHECK(gridRowsPerColumn(30, 3, 18) == 18); + CHECK(gridRowsPerColumn(54, 3, 18) == 18); // exactly 3 full columns +} + +TEST_CASE("gridRowsPerColumn grows downward past the column cap") { + // Past the cap the grid must get taller, not wider, or it runs off the + // right edge where vertical scrolling cannot reach it. + CHECK(gridRowsPerColumn(55, 3, 18) == 19); + CHECK(gridRowsPerColumn(200, 3, 18) == 67); + // Never more than max_cols columns. + for (std::size_t n : std::initializer_list{55, 100, 200, 999}) { + const std::size_t rows = gridRowsPerColumn(n, 3, 18); + CHECK((n + rows - 1) / rows <= 3); + } +} + +TEST_CASE("gridRowsPerColumn tolerates degenerate parameters") { + CHECK(gridRowsPerColumn(10, 0, 18) == 18); // max_cols 0 => treated as 1 + CHECK(gridRowsPerColumn(100, 0, 1) == 100); + CHECK(gridRowsPerColumn(10, 3, 0) >= 1); +}