Quitting the TUI now asks whether to shut down or detach

This commit is contained in:
pgdalmeida 2026-09-02 13:36:50 +02:00
parent c887a6c9b8
commit 7db8d2092a
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
6 changed files with 81 additions and 7 deletions

View File

@ -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 '/'.

View File

@ -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"
}

View File

@ -132,6 +132,11 @@ const std::vector<HelpSection>& 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 -",

View File

@ -10,6 +10,7 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdlib>
#include <cstdio>
#include <map>
#include <sstream>
@ -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<int>(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; }

View File

@ -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;

View File

@ -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
}