Added scrolling in TUI
This commit is contained in:
parent
4bc5e762e7
commit
4d5e2e4194
|
|
@ -66,6 +66,7 @@ add_library(fgc_core STATIC
|
||||||
src/core/GatedCameraSource.cpp
|
src/core/GatedCameraSource.cpp
|
||||||
src/sensors/Sht41Protocol.cpp
|
src/sensors/Sht41Protocol.cpp
|
||||||
src/ui/UiSnapshot.cpp
|
src/ui/UiSnapshot.cpp
|
||||||
|
src/ui/Scroll.cpp
|
||||||
src/ui/HeadlessUi.cpp
|
src/ui/HeadlessUi.cpp
|
||||||
ini.c
|
ini.c
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -54,6 +54,10 @@ private:
|
||||||
// Log ring buffer (newest last), filled by the Logger sink.
|
// Log ring buffer (newest last), filled by the Logger sink.
|
||||||
std::mutex log_mutex_;
|
std::mutex log_mutex_;
|
||||||
std::deque<LogLine> log_;
|
std::deque<LogLine> 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;
|
static constexpr size_t kLogCap = 500;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ const std::vector<HelpSection>& helpCatalog() {
|
||||||
"Example: gimbal steps 100000,250000"}},
|
"Example: gimbal steps 100000,250000"}},
|
||||||
{"gimbal nudge <yaw|pitch> <+/-pct>",
|
{"gimbal nudge <yaw|pitch> <+/-pct>",
|
||||||
"Relative step move by a percent of the axis travel.", {
|
"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"}},
|
"Example: gimbal nudge yaw -5"}},
|
||||||
{"gimbal home [y|p]",
|
{"gimbal home [y|p]",
|
||||||
"Run the endstop-finding home sequence (both axes, or one).", {
|
"Run the endstop-finding home sequence (both axes, or one).", {
|
||||||
|
|
@ -92,6 +93,25 @@ const std::vector<HelpSection>& helpCatalog() {
|
||||||
"Toggle verbatim wire-trace categories.", {
|
"Toggle verbatim wire-trace categories.", {
|
||||||
"Example: trace serial on / trace off"}},
|
"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.", {
|
{"Session", "Help and exit.", {
|
||||||
{"refresh",
|
{"refresh",
|
||||||
"Re-read live device state (TUI: press 'r').", {
|
"Re-read live device state (TUI: press 'r').", {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
#include "fgc/ui/Scroll.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
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<std::size_t>(max_cols, 1);
|
||||||
|
min_rows = std::max<std::size_t>(min_rows, 1);
|
||||||
|
const std::size_t needed = (item_count + max_cols - 1) / max_cols; // ceil
|
||||||
|
return std::max(min_rows, needed);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fgc
|
||||||
276
src/ui/TuiUi.cpp
276
src/ui/TuiUi.cpp
|
|
@ -3,7 +3,10 @@
|
||||||
#include "fgc/DumpParser.h"
|
#include "fgc/DumpParser.h"
|
||||||
#include "fgc/HelpText.h"
|
#include "fgc/HelpText.h"
|
||||||
#include "fgc/Logger.h"
|
#include "fgc/Logger.h"
|
||||||
|
#include "fgc/ui/Scroll.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <map>
|
#include <map>
|
||||||
|
|
@ -14,6 +17,9 @@
|
||||||
#include <ftxui/component/event.hpp>
|
#include <ftxui/component/event.hpp>
|
||||||
#include <ftxui/component/screen_interactive.hpp>
|
#include <ftxui/component/screen_interactive.hpp>
|
||||||
#include <ftxui/dom/elements.hpp>
|
#include <ftxui/dom/elements.hpp>
|
||||||
|
#include <ftxui/dom/node.hpp>
|
||||||
|
#include <ftxui/dom/requirement.hpp>
|
||||||
|
#include <ftxui/screen/box.hpp>
|
||||||
#include <ftxui/screen/color.hpp>
|
#include <ftxui/screen/color.hpp>
|
||||||
|
|
||||||
namespace fgc {
|
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;
|
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<Impl>(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.
|
// "key" + label pair for the nano-style bottom bar.
|
||||||
Element keyHint(const std::string& key, const std::string& label) {
|
Element keyHint(const std::string& key, const std::string& label) {
|
||||||
return hbox({text(" " + key + " ") | inverted, text(" " + 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
|
// Expanded camera view ('c'): the full camera system — identity, live sensor
|
||||||
// telemetry, imaging config, acquisition health, encoding/output — with the
|
// telemetry, imaging config, acquisition health, encoding/output — with the
|
||||||
// auto-sweep scan grid below. The whole body scrolls (yframe) if it overflows.
|
// 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) {
|
auto num = [](double v, int dec) {
|
||||||
char b[32];
|
char b[32];
|
||||||
std::snprintf(b, sizeof(b), "%.*f", dec, v);
|
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(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 "
|
rows.push_back(text("Check [Scan] grid_file in the loaded config (see the startup "
|
||||||
"'Loaded config:' log line).") | dim);
|
"'Loaded config:' log line).") | dim);
|
||||||
return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
|
return window(cameraDetailTitle(st),
|
||||||
vbox(std::move(rows)) | yframe);
|
scrollableBody(vbox(std::move(rows)), st));
|
||||||
}
|
}
|
||||||
if (c.scan_grid.empty()) {
|
if (c.scan_grid.empty()) {
|
||||||
rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim);
|
rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim);
|
||||||
return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
|
return window(cameraDetailTitle(st),
|
||||||
vbox(std::move(rows)) | yframe);
|
scrollableBody(vbox(std::move(rows)), st));
|
||||||
}
|
}
|
||||||
|
|
||||||
// " # | yaw / pitch", current row inverted. Pitch column dropped on 1-axis.
|
// " # | 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<Element> cols;
|
std::vector<Element> cols;
|
||||||
std::vector<Element> col;
|
std::vector<Element> col;
|
||||||
auto flushColumn = [&] {
|
auto flushColumn = [&] {
|
||||||
|
|
@ -295,8 +389,7 @@ Element cameraDetailPanel(const CaptureView& c) {
|
||||||
rows.push_back(hbox({text(" # ") | dim,
|
rows.push_back(hbox({text(" # ") | dim,
|
||||||
text(c.scan_pitch ? "yaw / pitch" : "yaw") | dim}));
|
text(c.scan_pitch ? "yaw / pitch" : "yaw") | dim}));
|
||||||
rows.push_back(hbox(std::move(cols)));
|
rows.push_back(hbox(std::move(cols)));
|
||||||
return window(text(" CAMERA SYSTEM (c/Esc:close) ") | bold | color(Color::Cyan),
|
return window(cameraDetailTitle(st), scrollableBody(vbox(std::move(rows)), st));
|
||||||
vbox(std::move(rows)) | yframe);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Element connPanel(const ConnView& v) {
|
Element connPanel(const ConnView& v) {
|
||||||
|
|
@ -313,7 +406,7 @@ Element connPanel(const ConnView& v) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
Element logPanel(const std::vector<LogLine>& lines) {
|
Element logPanel(const std::vector<LogLine>& lines, ScrollState& st) {
|
||||||
std::vector<Element> rows;
|
std::vector<Element> rows;
|
||||||
for (const auto& l : lines) {
|
for (const auto& l : lines) {
|
||||||
Color c = Color::Default;
|
Color c = Color::Default;
|
||||||
|
|
@ -327,8 +420,15 @@ Element logPanel(const std::vector<LogLine>& lines) {
|
||||||
rows.push_back(text(l.text) | color(c));
|
rows.push_back(text(l.text) | color(c));
|
||||||
}
|
}
|
||||||
if (rows.empty()) rows.push_back(text("(no log output yet)") | dim);
|
if (rows.empty()) rows.push_back(text("(no log output yet)") | dim);
|
||||||
return window(text(" LOG ") | bold | color(Color::GrayLight),
|
// Frozen scrollback withholds new lines, which is invisible without a cue.
|
||||||
vbox(std::move(rows)) | focusPositionRelative(0, 1) | yframe);
|
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
|
// 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
|
// Inline help pane (toggled with '?'). Lists every command section; the
|
||||||
// `sel`-th section is expanded to show each entry's detail. The Diagnostics
|
// `sel`-th section is expanded to show each entry's detail. The Diagnostics
|
||||||
// section additionally renders the last captured firmware DUMP block.
|
// 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();
|
const auto& cat = helpCatalog();
|
||||||
std::vector<Element> rows;
|
std::vector<Element> rows;
|
||||||
for (int i = 0; i < static_cast<int>(cat.size()); ++i) {
|
for (int i = 0; i < static_cast<int>(cat.size()); ++i) {
|
||||||
|
|
@ -395,8 +495,9 @@ Element helpPanel(int sel, const DumpView& dump) {
|
||||||
}
|
}
|
||||||
rows.push_back(text(""));
|
rows.push_back(text(""));
|
||||||
}
|
}
|
||||||
return window(text(" HELP (?:close Up/Down:section) ") | bold | color(Color::Cyan),
|
return window(overlayTitle("HELP", "?:close Up/Down:section", st, Color::Cyan,
|
||||||
vbox(std::move(rows)) | yframe);
|
/*arrows_scroll=*/false),
|
||||||
|
scrollableBody(vbox(std::move(rows)), st));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aligned "label: value" row for the detail view.
|
// 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.
|
// (incl. RAMP_STAT, the last one) is visible without scrolling.
|
||||||
Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d,
|
Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d,
|
||||||
char letter, bool calib_has, const CalibAxisView& cal,
|
char letter, bool calib_has, const CalibAxisView& cal,
|
||||||
const DiagAxisView* diag) {
|
const DiagAxisView* diag, ScrollState& st) {
|
||||||
auto fmt = [](const char* f, double v) {
|
auto fmt = [](const char* f, double v) {
|
||||||
char b[32];
|
char b[32];
|
||||||
std::snprintf(b, sizeof(b), f, v);
|
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);
|
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
|
// Full-screen gimbal view (toggled with 'g'): one column per axis, each with
|
||||||
// live telemetry above its decoded firmware register dump + last calibration.
|
// live telemetry above its decoded firmware register dump + last calibration.
|
||||||
Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const CalibResultView& calib,
|
Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const CalibResultView& calib,
|
||||||
const DiagResultView& diag) {
|
const DiagResultView& diag, std::array<ScrollState, 2>& st) {
|
||||||
DumpData d = parseDump(dump.text);
|
DumpData d = parseDump(dump.text);
|
||||||
auto diagFor = [&](char axis) -> const DiagAxisView* {
|
auto diagFor = [&](char axis) -> const DiagAxisView* {
|
||||||
for (const auto& a : diag.axes)
|
for (const auto& a : diag.axes)
|
||||||
|
|
@ -580,11 +683,12 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
|
||||||
});
|
});
|
||||||
|
|
||||||
std::vector<Element> cols;
|
std::vector<Element> 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)
|
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}));
|
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
|
// then the ambient sensor. The two devices get their own headed sections, and
|
||||||
// each temperature is labelled with its provenance ("internal" vs "ambient"),
|
// each temperature is labelled with its provenance ("internal" vs "ambient"),
|
||||||
// so the reading the main window shows is never confused with the MTi's.
|
// 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
|
// 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
|
// 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
|
// 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 + " live ") | color(Color::Green) | bold)
|
||||||
: (text(" " + env_name + " no reading ") | color(Color::Red) |
|
: (text(" " + env_name + " no reading ") | color(Color::Red) |
|
||||||
bold));
|
bold));
|
||||||
// yframe so a long body (MTi channels + config + ambient) stays reachable on a
|
// Scrolled so a long body (MTi channels + config + ambient) stays reachable on
|
||||||
// short terminal instead of being silently cut off, as the camera view does.
|
// a short terminal instead of being silently cut off.
|
||||||
return window(text(" SENSORS (i/Esc:close r:refresh config) ") | bold | color(Color::Magenta),
|
return window(overlayTitle("SENSORS", "i/Esc:close r:refresh config", st, Color::Magenta),
|
||||||
vbox({hbox({imu_status, text(" "), env_status, filler()}), separator(),
|
vbox({hbox({imu_status, text(" "), env_status, filler()}), separator(),
|
||||||
vbox(std::move(body)) | yframe | flex}));
|
scrollableBody(vbox(std::move(body)), st) | flex}));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
@ -777,7 +881,10 @@ void TuiUi::stop() {
|
||||||
void TuiUi::pushLog(LogLevel level, const std::string& line) {
|
void TuiUi::pushLog(LogLevel level, const std::string& line) {
|
||||||
std::lock_guard<std::mutex> lock(log_mutex_);
|
std::lock_guard<std::mutex> lock(log_mutex_);
|
||||||
log_.push_back({level, line});
|
log_.push_back({level, line});
|
||||||
while (log_.size() > kLogCap) log_.pop_front();
|
while (log_.size() > kLogCap) {
|
||||||
|
log_.pop_front();
|
||||||
|
++log_dropped_;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TuiUi::refreshLoop() {
|
void TuiUi::refreshLoop() {
|
||||||
|
|
@ -807,13 +914,30 @@ void TuiUi::uiLoop() {
|
||||||
bool gimbal_dump_requested = false; // auto-pull a dump the first time
|
bool gimbal_dump_requested = false; // auto-pull a dump the first time
|
||||||
bool calib_prompt = false; // a yes/no calib-save question is showing
|
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<ScrollState, 2> 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 input = Input(&cmd_buffer, "type a command, Enter to run, Esc to cancel");
|
||||||
|
|
||||||
auto renderer = Renderer(input, [&] {
|
auto renderer = Renderer(input, [&] {
|
||||||
UiSnapshot s = snapshot_ ? snapshot_() : UiSnapshot{};
|
UiSnapshot s = snapshot_ ? snapshot_() : UiSnapshot{};
|
||||||
|
size_t log_dropped_now = 0;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(log_mutex_);
|
std::lock_guard<std::mutex> lock(log_mutex_);
|
||||||
s.log.assign(log_.begin(), log_.end());
|
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<int>(log_dropped_now - log_dropped_at_freeze);
|
||||||
|
log_dropped_at_freeze = log_dropped_now;
|
||||||
}
|
}
|
||||||
calib_prompt = !s.activity.prompt.empty();
|
calib_prompt = !s.activity.prompt.empty();
|
||||||
gimbal_overlay_open_.store(overlay == Overlay::Gimbal); // refreshLoop polls a dump while open
|
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 top = hbox({gimbalPanel(s.gimbal), sensorsPanel(s.sensors)});
|
||||||
Element middle = hbox({cameraPanel(s.capture), connPanel(s.conn)});
|
Element middle = hbox({cameraPanel(s.capture), connPanel(s.conn)});
|
||||||
|
|
||||||
|
const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93";
|
||||||
Element bottom;
|
Element bottom;
|
||||||
if (command_mode) {
|
if (command_mode) {
|
||||||
bottom = hbox({text(" : ") | inverted, input->Render() | flex}) | border;
|
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 {
|
} else {
|
||||||
bottom = hbox({
|
bottom = hbox({
|
||||||
keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"),
|
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.
|
// gimbal register dump overflows the small log-sized area and clips.
|
||||||
switch (overlay) {
|
switch (overlay) {
|
||||||
case Overlay::Help:
|
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:
|
case Overlay::Gimbal:
|
||||||
return vbox({header, separator(),
|
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:
|
case Overlay::Sensors:
|
||||||
return vbox({header, separator(),
|
return vbox({header, separator(),
|
||||||
sensorsDetailPanel(s.imu, s.env) | flex, bottom});
|
sensorsDetailPanel(s.imu, s.env, sensors_scroll) | flex, bottom});
|
||||||
case Overlay::Cameras:
|
case Overlay::Cameras:
|
||||||
return vbox({header, separator(), cameraDetailPanel(s.capture) | flex, bottom});
|
return vbox({header, separator(), cameraDetailPanel(s.capture, cam_scroll) | flex, bottom});
|
||||||
default: {
|
default: {
|
||||||
// Activity strip sits between the log and the key bar; shown only
|
// 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.
|
// once a special op has run or is running, else it costs no space.
|
||||||
std::vector<Element> col = {header, separator(), top, middle,
|
std::vector<Element> 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())
|
if (s.activity.active || s.activity.has_result || !s.activity.prompt.empty())
|
||||||
col.push_back(activityPanel(s.activity));
|
col.push_back(activityPanel(s.activity));
|
||||||
col.push_back(bottom);
|
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) {
|
auto root = CatchEvent(renderer, [&](Event e) {
|
||||||
if (command_mode) {
|
if (command_mode) {
|
||||||
if (e == Event::Return) {
|
if (e == Event::Return) {
|
||||||
|
|
@ -890,14 +1045,61 @@ void TuiUi::uiLoop() {
|
||||||
}
|
}
|
||||||
const int n = static_cast<int>(helpCatalog().size());
|
const int n = static_cast<int>(helpCatalog().size());
|
||||||
if (overlay == Overlay::Help) { // help pane navigation
|
if (overlay == Overlay::Help) { // help pane navigation
|
||||||
if (e == Event::ArrowDown) { help_sel = (help_sel + 1) % n; return true; }
|
// Selecting a different section replaces the body wholesale, so the
|
||||||
if (e == Event::ArrowUp) { help_sel = (help_sel - 1 + n) % n; return true; }
|
// 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; }
|
if (overlay != Overlay::None && e == Event::Escape) { overlay = Overlay::None; return true; }
|
||||||
// Esc with no overlay open cancels a running procedure (test/calib/homing).
|
// Esc with no overlay open cancels a running procedure (test/calib/homing).
|
||||||
if (overlay == Overlay::None && e == Event::Escape && sink_) {
|
if (overlay == Overlay::None && e == Event::Escape && sink_) {
|
||||||
if (snapshot_ && snapshot_().activity.cancelable) { sink_("cancel"); return true; }
|
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<std::mutex> 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):
|
// Arrow keys nudge the gimbal in steps (only when no overlay is open):
|
||||||
// Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel.
|
// Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel.
|
||||||
if (overlay == Overlay::None && sink_) {
|
if (overlay == Overlay::None && sink_) {
|
||||||
|
|
@ -915,6 +1117,7 @@ void TuiUi::uiLoop() {
|
||||||
}
|
}
|
||||||
if (c == "?") {
|
if (c == "?") {
|
||||||
overlay = (overlay == Overlay::Help) ? Overlay::None : Overlay::Help;
|
overlay = (overlay == Overlay::Help) ? Overlay::None : Overlay::Help;
|
||||||
|
if (overlay == Overlay::Help) help_scroll = {};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (c == "g") {
|
if (c == "g") {
|
||||||
|
|
@ -922,6 +1125,7 @@ void TuiUi::uiLoop() {
|
||||||
overlay = Overlay::None;
|
overlay = Overlay::None;
|
||||||
} else {
|
} else {
|
||||||
overlay = Overlay::Gimbal;
|
overlay = Overlay::Gimbal;
|
||||||
|
gimbal_scroll = {};
|
||||||
if (!gimbal_dump_requested) { // auto-pull a dump the first time
|
if (!gimbal_dump_requested) { // auto-pull a dump the first time
|
||||||
if (sink_) sink_("gimbal dump");
|
if (sink_) sink_("gimbal dump");
|
||||||
gimbal_dump_requested = true;
|
gimbal_dump_requested = true;
|
||||||
|
|
@ -935,10 +1139,12 @@ void TuiUi::uiLoop() {
|
||||||
}
|
}
|
||||||
if (c == "i") {
|
if (c == "i") {
|
||||||
overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors;
|
overlay = (overlay == Overlay::Sensors) ? Overlay::None : Overlay::Sensors;
|
||||||
|
if (overlay == Overlay::Sensors) sensors_scroll = {};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (c == "c") {
|
if (c == "c") {
|
||||||
overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras;
|
overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras;
|
||||||
|
if (overlay == Overlay::Cameras) cam_scroll = {};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (c == "p") { // freeze/unfreeze the live refresh so text can be selected
|
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
|
return true; // this keypress redraws once so the indicator updates
|
||||||
}
|
}
|
||||||
if (overlay == Overlay::Help) { // vim-style section nav while help is open
|
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 == "j") { help_sel = (help_sel + 1) % n; help_scroll = {}; return true; }
|
||||||
if (c == "k") { help_sel = (help_sel - 1 + n) % n; 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 == "q") { if (sink_) sink_("exit"); return true; }
|
||||||
if (c == "s") { if (sink_) sink_("start"); return true; }
|
if (c == "s") { if (sink_) sink_("start"); return true; }
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ add_executable(fgc_tests
|
||||||
test_geometry.cpp
|
test_geometry.cpp
|
||||||
test_scangrid.cpp
|
test_scangrid.cpp
|
||||||
test_uisnapshot.cpp
|
test_uisnapshot.cpp
|
||||||
|
test_scroll.cpp
|
||||||
test_mtiprotocol.cpp
|
test_mtiprotocol.cpp
|
||||||
test_sht41.cpp
|
test_sht41.cpp
|
||||||
test_imagequality.cpp
|
test_imagequality.cpp
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
||||||
|
#include "fgc/ui/Scroll.h"
|
||||||
|
|
||||||
|
#include <initializer_list>
|
||||||
|
|
||||||
|
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<std::size_t>{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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue