From 7db8d2092ab1e453b5f82556eccd496b4e136f42 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Wed, 2 Sep 2026 13:36:50 +0200 Subject: [PATCH] Quitting the TUI now asks whether to shut down or detach --- include/fgc/ui/UiSnapshot.h | 8 ++++++ scripts/fgc | 10 +++---- src/core/HelpText.cpp | 5 ++++ src/ui/TuiUi.cpp | 53 +++++++++++++++++++++++++++++++++++-- src/ui/UiSnapshot.cpp | 6 +++++ tests/test_uisnapshot.cpp | 6 +++++ 6 files changed, 81 insertions(+), 7 deletions(-) diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index d89afa2..f78d768 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -315,6 +315,14 @@ std::string formatDegrees(double deg); // Returns "—" when then_ms is 0 (never). std::string formatTimeAgo(long long now_ms, long long then_ms); +// Whether the dashboard can be sent to the background, i.e. whether it is +// running inside a tmux pane that a client can be detached from. Pass getenv +//("TMUX"): tmux sets it to the server socket path, so a set, non-empty value is +// both the test and what lets `tmux detach-client` find the right client. +// Takes the value as an argument rather than reading the environment itself so +// the rule is testable. +bool backgroundAvailable(const char* tmux_env); + // Last path segment of an MQTT topic ("GGS/FWT/Tower/CamEvent" -> "CamEvent"). // Every row in the connectivity view shares the same prefix, which only eats // width. Returns the input unchanged when there is no '/'. diff --git a/scripts/fgc b/scripts/fgc index fc55a2b..0dc8e40 100755 --- a/scripts/fgc +++ b/scripts/fgc @@ -14,8 +14,8 @@ # scripts/fgc peek print what the pane shows right now # scripts/fgc restart [args...] stop, then start # -# Detaching is Ctrl-b d. NOTE: the dashboard's own `q` key *exits the program*, -# it does not detach — that is the one thing to get right when attached. +# Detaching is Ctrl-b d. The dashboard's own `q` asks first, offering both: +# quit the program, or background it (which detaches, same as Ctrl-b d). # # Only one instance may run. The binary enforces that itself with a lockfile # (see src/core/SingleInstance.cpp), so a bare ./build/fire_gimbal_control is @@ -159,7 +159,7 @@ cmd_start() { tmux set-option -t "$pane" status-left-length 30 >/dev/null tmux set-option -t "$pane" status-right-length 80 >/dev/null tmux set-option -t "$pane" status-right \ - ' Ctrl-b d = detach, keeps running | q = quit the program ' >/dev/null + ' Ctrl-b d = detach, keeps running | q = quit or background ' >/dev/null tmux respawn-pane -k -t "$win" -c "$repo" "$launch" >/dev/null @@ -182,7 +182,7 @@ cmd_start() { fi echo "fgc: started detached as PID $(pane_pid) (session '$session')" - echo " attach: scripts/fgc attach (Ctrl-b d to detach; 'q' quits the program)" + echo " attach: scripts/fgc attach (Ctrl-b d to detach; 'q' asks quit/background)" } # Hidden: the command tmux runs inside the pane. Not for direct use. @@ -228,7 +228,7 @@ cmd_attach() { if [[ -n "${TMUX:-}" ]]; then die "already inside tmux; use 'tmux switch-client -t $session'" fi - echo "fgc: attaching — Ctrl-b d detaches and leaves it running ('q' would quit it)" >&2 + echo "fgc: attaching — Ctrl-b d detaches and leaves it running ('q' asks first)" >&2 exec tmux attach-session -t "$target" } diff --git a/src/core/HelpText.cpp b/src/core/HelpText.cpp index 6dddec2..eaa492e 100644 --- a/src/core/HelpText.cpp +++ b/src/core/HelpText.cpp @@ -132,6 +132,11 @@ const std::vector& helpCatalog() { "still, and the LOG title shows 'scrollback'."}}, {"Home/End", "Jump to the top/bottom; on the log, End resumes following the tail.", {}}, + {"q", + "Ask whether to quit or leave the dashboard running in the background.", { + "Quitting stops capture and closes the camera; backgrounding detaches the", + "tmux client (same as Ctrl-b d) and leaves everything running. Ctrl-C still", + "shuts down immediately."}}, {"p", "Freeze the 10 Hz repaint so terminal text selection survives.", { "Any keypress repaints, so scrolling while paused drops the selection -", diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index 1c22fa5..4d9cd69 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -209,6 +210,27 @@ Element keyBar(Overlay overlay) { 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); @@ -1044,6 +1066,7 @@ void TuiUi::uiLoop() { int help_sel = 0; 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 exit_prompt = false; // 'q' asked; awaiting quit/background/cancel // Per-pane scroll positions. UI-thread-only view state (the renderer lambda // and CatchEvent below both run on this thread), so unlike screen_/paused_ @@ -1101,7 +1124,8 @@ void TuiUi::uiLoop() { Element bottom = command_mode ? hbox({text(" : ") | inverted, input->Render() | flex}) | border - : keyBar(overlay); + : exit_prompt ? exitPrompt(backgroundAvailable(std::getenv("TMUX"))) + : 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 @@ -1167,6 +1191,31 @@ void TuiUi::uiLoop() { } return false; // let the Input edit the buffer } + + // The exit question owns the keyboard while it is up: every key is + // consumed, so a stray press cannot reach the dashboard behind it. + if (exit_prompt) { + if (e == Event::Escape) { exit_prompt = false; return true; } + if (e == Event::Return) { exit_prompt = false; if (sink_) sink_("exit"); return true; } + if (e.is_character()) { + const std::string& k = e.character(); + if (k == "q" || k == "Q") { + exit_prompt = false; + if (sink_) sink_("exit"); + } else if ((k == "b" || k == "B") && backgroundAvailable(std::getenv("TMUX"))) { + exit_prompt = false; + // Detach the client viewing this pane; the program keeps + // running and drawing into the pty, exactly as Ctrl-b d does. + // A failure must never read as "quit", so on error we say so + // and stay put. + if (std::system("tmux detach-client") != 0) + LOG_WARN << "could not detach from tmux; still running " + "(detach with Ctrl-b d, or press q to quit)"; + } + } + return true; // swallow everything else + } + const int n = static_cast(helpCatalog().size()); if (overlay == Overlay::Help) { // help pane navigation // Selecting a different section replaces the body wholesale, so the @@ -1287,7 +1336,7 @@ void TuiUi::uiLoop() { 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 == "q") { exit_prompt = true; return true; } if (c == "s") { if (sink_) sink_("start"); return true; } if (c == "x") { if (sink_) sink_("stop"); return true; } if (c == "h") { if (sink_) sink_("gimbal home"); return true; } diff --git a/src/ui/UiSnapshot.cpp b/src/ui/UiSnapshot.cpp index 1761844..27c947c 100644 --- a/src/ui/UiSnapshot.cpp +++ b/src/ui/UiSnapshot.cpp @@ -48,6 +48,12 @@ std::string formatTimeAgo(long long now_ms, long long then_ms) { return buf; } +bool backgroundAvailable(const char* tmux_env) { + // An empty TMUX is not "in tmux": the variable exists but names no server, + // so detach-client would have nothing to talk to. + return tmux_env != nullptr && *tmux_env != '\0'; +} + std::string topicLeaf(const std::string& topic) { const auto slash = topic.rfind('/'); if (slash == std::string::npos) return topic; diff --git a/tests/test_uisnapshot.cpp b/tests/test_uisnapshot.cpp index a8cea09..c8cbf96 100644 --- a/tests/test_uisnapshot.cpp +++ b/tests/test_uisnapshot.cpp @@ -118,3 +118,9 @@ TEST_CASE("formatClockMs renders wall-clock time, and never for 0") { CHECK(t[8] == '.'); CHECK(t.substr(9) == "678"); // the ms field comes straight off the input } + +TEST_CASE("backgroundAvailable only accepts a set, non-empty TMUX") { + CHECK(backgroundAvailable("/tmp/tmux-1000/default,12345,0")); + CHECK_FALSE(backgroundAvailable(nullptr)); // not under tmux at all + CHECK_FALSE(backgroundAvailable("")); // set but names no server +}