#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 #include #include namespace fgc { using namespace ftxui; namespace { std::string upperStr(std::string s) { for (char& c : s) c = static_cast(std::toupper(static_cast(c))); return s; } 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; // pinned to the newest (bottom) line instead // Last "new lines" stamp seen (followNew): when it moves, the pane snaps back // to the bottom - unless the screen is paused, which is how one reads back. unsigned long long seen = 0; bool seen_any = false; // 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; } }; // New lines arrived in a pane that streams them (the log, the message history, // a fresh dump): show the newest. Paused, nothing moves - pausing is how the // operator reads further back. void followNew(ScrollState& st, unsigned long long stamp, bool paused) { // First sight: a view opens where it opens (the log is already following). if (!st.seen_any) { st.seen = stamp; st.seen_any = true; return; } if (stamp == st.seen) return; st.seen = stamp; if (!paused) st.follow = true; } // 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) { std::string hint = keys; if (st.scrollable()) hint += " PgUp/PgDn:scroll"; return text(" " + name + " (" + hint + ") ") | bold | color(c); } Element cameraDetailTitle(const ScrollState& st) { return overlayTitle("CAMERAS", "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 setup (in the activity strip) --------------------------------- // // Ties encoder counts to true bearings by having the operator aim at landmarks // they can identify on a map. The marks and the fit belong to the program // (`gimbal heading setup|mark|fit|cancel`), so the same steps work headless and // over MQTT; what lives here is only the aiming - arrow keys in three step sizes. 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; } // 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; } // The bottom bar, contextual: the overview advertises everyday operations and // the view keys, and each view advertises what its own body responds to. // // This changes only what is ADVERTISED. Every binding still works everywhere - // 'a' switches to auto 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, bool heading_setup) { static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93"; Elements e; if (heading_setup) { // Replaces the common set: mode changes are refused during heading // setup, so advertising them here would only mislead. e.push_back(keyHint(kArrows4, "Aim")); e.push_back(keyHint("Tab", "Step")); e.push_back(keyHint("Enter", "Mark")); e.push_back(keyHint("Spc", "Snap")); e.push_back(keyHint("f", "Fit")); e.push_back(keyHint("Esc", "Cancel")); } else { // The modes are wanted from every view, so they lead everywhere. e.push_back(keyHint("a", "Auto")); e.push_back(keyHint("p", "Pan")); e.push_back(keyHint("m", "Manual")); switch (overlay) { case Overlay::None: e.push_back(keyHint("h", "Home")); e.push_back(keyHint("g s c n", "Views")); e.push_back(keyHint("f", "Pause")); e.push_back(keyHint("PgUp/Dn", "Log")); break; case Overlay::Help: e.push_back(keyHint("j/k", "Section")); e.push_back(keyHint("PgUp/Dn", "Scroll")); break; case Overlay::Gimbal: e.push_back(keyHint("d", "Dump")); e.push_back(keyHint("PgUp/Dn", "Scroll")); break; case Overlay::Sensors: case Overlay::Cameras: case Overlay::Conn: e.push_back(keyHint("PgUp/Dn", "Scroll")); break; } // Hand aiming is refused outside manual, 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")); } 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 "[s]"; repeating "s 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("[s] 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: "