#include "fgc/ui/TuiUi.h" #include #include "fgc/ChannelTopics.h" #include "fgc/DumpParser.h" #include "fgc/HelpText.h" #include "fgc/Logger.h" #include "fgc/ui/Scroll.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace fgc { using namespace ftxui; namespace { Color toColor(UiColor c) { switch (c) { case UiColor::Green: return Color::Green; case UiColor::Yellow: return Color::Yellow; case UiColor::Red: return Color::Red; case UiColor::Cyan: return Color::Cyan; case UiColor::Dim: return Color::GrayDark; case UiColor::Default: default: return Color::Default; } } // A bordered panel with a colored, bold title. 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); } // 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, Heading }; // ---- heading-setup wizard (Overlay::Heading) ------------------------------- // // Ties encoder counts to true bearings by having the operator aim at landmarks // they can identify on a map. Deliberately does NOT need an IMU: not every tower // has one, and IMU yaw drifts too much to be a heading reference. // // The interaction lives here because arrow keys and typed input exist only in the // TUI; the fit itself is `gimbal heading fit`, an ordinary command, so it stays // testable and usable on a headless tower. enum class JogPhase { Coarse, Fine, VeryFine }; // The finest step is 25 counts, not 1. ENC_CONST makes X_ENC advance in // 12.8-microstep quanta, so a smaller step moves the motor but records a position // the gimbal cannot reproduce - and 25 counts is already ~0.05 deg on yaw, half // the rig's pointing uncertainty, so it is never the limiting factor. constexpr long kVeryFineCounts = 25; constexpr double kCoarseTravelPct = 5.0; constexpr double kFineTravelPct = 0.5; const char* jogPhaseName(JogPhase p) { switch (p) { case JogPhase::Coarse: return "coarse"; case JogPhase::Fine: return "fine"; case JogPhase::VeryFine: return "very fine"; } return "?"; } // Step size in counts for a phase, given that axis's homed endstop-to-endstop // travel. Falls back to the very-fine step when travel is unknown (no dump yet), // which is slow but never wild. long jogStepCounts(JogPhase p, long travel_counts) { if (p == JogPhase::VeryFine || travel_counts <= 0) return kVeryFineCounts; const double pct = (p == JogPhase::Coarse) ? kCoarseTravelPct : kFineTravelPct; long step = static_cast(pct / 100.0 * static_cast(travel_counts)); return step > kVeryFineCounts ? step : kVeryFineCounts; } struct Sighting { long counts = 0; double bearing = 0.0; }; struct HeadingWizard { JogPhase phase = JogPhase::Coarse; bool entering = false; // typing a bearing for pending_counts long pending_counts = 0; std::vector sightings; std::string note; // last action / error, shown in the pane }; // Angular size of a step, when the yaw scale is known. Shown alongside the count // so the operator can judge the phase without doing the arithmetic. std::string jogStepLabel(JogPhase p, long travel_counts, double counts_per_deg) { const long step = jogStepCounts(p, travel_counts); std::string out = std::to_string(step) + " counts"; if (std::fabs(counts_per_deg) > 1e-9) out += " (~" + num(std::fabs(step / counts_per_deg), 3) + " deg)"; return out; } Element headingWizardPanel(const UiSnapshot& s, const HeadingWizard& w, Component bearing_input) { const AxisView& y = s.gimbal.yaw; const long travel = y.has_limits ? std::labs(y.lim_pos - y.lim_neg) : 0; Elements rows; rows.push_back(text("Aim at a landmark whose true bearing you know, then record it.") | bold); rows.push_back(text("Two landmarks minimum; the further apart, the better the fit.") | dim); rows.push_back(separator()); // Live position, in both the count the fit will use and the bearing the // current (possibly wrong) calibration believes. rows.push_back(hbox({text("position ") | dim, text(std::to_string(y.xenc) + " counts") | bold, text(" currently reads " + num(y.deg, 2) + " deg") | dim})); rows.push_back(hbox({text("step ") | dim, text(jogPhaseName(w.phase)) | bold | color(Color::Cyan), text(" " + jogStepLabel(w.phase, travel, y.counts_per_deg)) | dim})); if (!y.has_limits) rows.push_back(text(" no homed travel yet - steps fall back to the finest; " "home or press d in the gimbal view") | color(Color::Yellow)); rows.push_back(separator()); if (w.sightings.empty()) { rows.push_back(text("no sightings recorded yet") | dim); } else { rows.push_back(text("sightings") | bold); for (size_t i = 0; i < w.sightings.size(); ++i) rows.push_back(text(" " + std::to_string(i + 1) + ". " + std::to_string(w.sightings[i].counts) + " counts -> " + num(w.sightings[i].bearing, 2) + " deg true")); } rows.push_back(separator()); if (w.entering) { rows.push_back(hbox({text("bearing of the landmark at " + std::to_string(w.pending_counts) + " counts (deg true): "), bearing_input->Render() | flex})); rows.push_back(text("Enter to record, Esc to discard this sighting") | dim); } else if (s.capture.snapshot_armed) { rows.push_back(text("snapshot armed - waiting for the gimbal to settle") | color(Color::Yellow)); } else if (s.capture.has_last) { // The operator identifies the landmark from the image, which reaches them // through the ground station; the path is here so they can find the file. rows.push_back(hbox({text("last image ") | dim, text(s.capture.config.output_dir + "/" + s.capture.last_label + "/" + std::to_string(s.capture.last_ts_ms) + ".jxl")})); } if (!w.note.empty()) rows.push_back(text(w.note) | color(Color::Yellow)); rows.push_back(separator()); rows.push_back(text(w.sightings.size() >= 2 ? "f to fit and apply" : "record at least two sightings before fitting") | (w.sightings.size() >= 2 ? color(Color::Green) : dim)); return vbox(std::move(rows)) | flex; } // 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, bool manual) { 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")); if (manual) e.push_back(keyHint("H", "Heading")); e.push_back(keyHint("p", "Pause")); // Hand aiming is refused while a scan owns the axes, so stop // advertising it rather than inviting a keypress that only warns. if (manual) { e.push_back(keyHint(kArrows4, "Nudge")); e.push_back(keyHint("Spc", "Snap")); } 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; case Overlay::Heading: // Replaces the common set entirely: start/stop are refused during // heading setup, so advertising them here would only mislead. e.clear(); e.push_back(keyHint(kArrows4, "Aim")); e.push_back(keyHint("Tab", "Step size")); e.push_back(keyHint("Spc", "Snap")); e.push_back(keyHint("Enter", "Record")); e.push_back(keyHint("f", "Fit")); 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)); } // The "are you sure?" block that replaces the key bar when q is pressed. // // q and Ctrl-b d are one keystroke apart and mean opposite things when the tower // is operated over ssh, and nothing on screen said so at the moment of choosing // (scripts/fgc resorts to warning about it in its tmux status line). This asks. Element exitPrompt(bool can_background) { Element bg = can_background ? hbox({text(" b ") | bold, text("Background \xE2\x80\x94 detach, leave it running " "(same as Ctrl-b d)")}) : hbox({text(" b ") | dim, text("Background \xE2\x80\x94 unavailable, needs scripts/fgc") | dim}); return vbox({ text(" Exit the dashboard? ") | bold | color(Color::Black) | bgcolor(Color::Yellow), hbox({text(" q ") | bold, text("Quit \xE2\x80\x94 stops capture, closes the camera, ends the program")}), bg, hbox({text(" Esc ") | bold, text("Cancel") | dim}), }) | border | color(Color::Yellow); } Element badge(const std::string& s, bool on, Color on_color) { auto e = text(" " + s + " "); return on ? (e | color(on_color) | bold) : (e | dim); } Element axisRow(const AxisView& a) { Element state = text(std::string(" ") + axisStateLabel(a.state) + " ") | color(toColor(axisStateColor(a.state))) | bold; Element heading = text(formatDegrees(a.deg)) | bold; return vbox({ hbox({text(a.label + " ") | bold, state, filler(), text("→ " + formatDegrees(a.target_deg)) | dim}), hbox({text(" "), heading, text(" x=" + std::to_string(a.xactual)) | dim, text(" enc=" + std::to_string(a.xenc)) | dim}), hbox({text(" "), badge("STILL", a.standstill, Color::Cyan), badge("MOVE", a.moving, Color::Cyan), badge("STALL", a.deviation, Color::Red), badge("OT", a.overtemp, Color::Red), badge("L", a.endstop_l, Color::Cyan), badge("R", a.endstop_r, Color::Cyan)}), }); } Element gimbalPanel(const GimbalView& g) { std::vector rows; if (!g.present) rows.push_back(text("link down") | color(Color::Red) | bold); rows.push_back(axisRow(g.yaw)); if (g.pitch_present) { rows.push_back(separator()); rows.push_back(axisRow(g.pitch)); } return panel("[g] GIMBAL", Color::Cyan, vbox(std::move(rows))); } Element sensorsPanel(const SensorsView& s) { // One labelled subsection per physical sensor: a title + status header, then // its readings. Keeps MTi (orientation/device temp) and the ambient env // sensor (SHT41) clearly separated. auto group = [](const SensorGroup& g, Element trailing) { std::vector rows; Element status = g.present ? (text(g.status) | color(Color::Green)) : (text(g.status) | dim); rows.push_back(hbox({text(g.title) | bold, text(" "), status, filler(), std::move(trailing)})); for (const auto& f : g.fields) { Element val = text(f.value + (f.unit.empty() ? "" : " " + f.unit)); val = f.present ? (val | bold) : (val | dim); rows.push_back(hbox({text(" "), text(f.label) | dim, filler(), val})); } return vbox(std::move(rows)); }; // The title already says "[i]"; repeating "i to expand" in the body was noise. // A misconfigured MTi reports plausible-looking numbers about the wrong axes, // so the readings themselves can never reveal it - it has to be said here. Element imu_badge = s.imu_config_alert ? (text(" IMU CFG ") | color(Color::Red) | bold) : text(""); return panel("[i] SENSORS", Color::Magenta, vbox({group(s.imu, std::move(imu_badge)), separator(), group(s.env, text(""))})); } Element cameraPanel(const CaptureView& c) { std::string labels; for (size_t i = 0; i < c.labels.size(); ++i) labels += (i ? "," : "") + c.labels[i]; Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold) : (text(" idle ") | dim); std::vector rows = { 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(labelRow("scan", hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold, text(" c for details") | dim}))); { // One line per camera: "