TUI panels are now half-width and aligned; key bar is contextual

This commit is contained in:
pgdalmeida 2026-09-02 13:15:19 +02:00
parent bc4d2ec002
commit c887a6c9b8
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
1 changed files with 116 additions and 62 deletions

View File

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