From c972aba4d3365d2aab7a5a10e177069480289bd1 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Mon, 29 Jun 2026 22:33:00 +0200 Subject: [PATCH] Anchored yaw to 0-360 degrees range --- .deploy.env | 2 +- config/config.example.ini | 22 +++++- config/scan.csv | 48 ++++++------ include/fgc/Config.h | 6 ++ include/fgc/DumpParser.h | 1 + include/fgc/mock/MockMotorController.h | 2 +- include/fgc/ui/TuiUi.h | 3 + include/fgc/ui/UiSnapshot.h | 1 + src/core/Application.cpp | 102 ++++++++++++++++++++++++- src/core/CaptureScheduler.cpp | 12 ++- src/core/Config.cpp | 20 ++++- src/core/DumpParser.cpp | 1 + src/ui/TuiUi.cpp | 27 ++++++- 13 files changed, 209 insertions(+), 38 deletions(-) diff --git a/.deploy.env b/.deploy.env index 637bf3c..82bf932 100644 --- a/.deploy.env +++ b/.deploy.env @@ -2,4 +2,4 @@ REMOTE_HOST=ggs@10.11.12.111 REMOTE_DIR=/home/ggs/projects/fwt_2a/software CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON" -RUN_ARGS="--start --trace serial" +RUN_ARGS="--start" diff --git a/config/config.example.ini b/config/config.example.ini index 3c58c20..dfcf592 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -51,16 +51,32 @@ baud = 115200 ; used by MQTT (target_HDG) and CamEvent. CALIBRATE on the rig after homing: ; counts = zero_count + deg * counts_per_deg ; counts_per_deg may be negative to flip direction. min/max_deg clamp commands. +; +; Mechanical travel of this gimbal (between hard endstops, found at homing): +; Yaw ~320 deg (0 .. 320; NOT a full revolution, cannot wrap) +; Pitch 40 deg (-10 .. +30; -10 below horizon to +30 above) +; Keep all scan waypoints inside these ranges or the move clamps short. +; ; Yaw starting point: firmware steps_per_rev=177000 over ~180 deg => ~983.33. yaw_counts_per_deg = 983.33 yaw_zero_count = 500000 yaw_min_deg = -90 yaw_max_deg = 90 -; Pitch (physical endstops, ~0..60 deg). CALIBRATE counts_per_deg/zero_count. +; Re-anchor the yaw zero from homing instead of the fixed yaw_zero_count above: +; off (default) - use yaw_zero_count as written +; low - the lower-count endstop becomes 0 deg, degrees rise toward +; the higher-count endstop (which lands near +360) +; high - the higher-count endstop becomes 0 deg, degrees rise toward +; the lower-count endstop (which lands near +360) +; Keeps yaw_counts_per_deg as the scale; only the zero + direction come from the +; homed endstops, so the yaw origin is fixed by homing (no zero recalibration). +; yaw_min_deg/yaw_max_deg are auto-set to 0..(homed range). Pitch is unaffected. +yaw_home_zero = off +; Pitch (physical endstops, -10..+30 deg = 40 deg travel). CALIBRATE counts_per_deg/zero_count. pitch_counts_per_deg = 8333.33 pitch_zero_count = 0 -pitch_min_deg = 0 -pitch_max_deg = 60 +pitch_min_deg = -10 +pitch_max_deg = 30 [Scan] ; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through). diff --git a/config/scan.csv b/config/scan.csv index 14a7873..0b6c666 100644 --- a/config/scan.csv +++ b/config/scan.csv @@ -1,24 +1,24 @@ -# Scan grid — capture waypoints, one "yaw_deg,pitch_deg" per line. -# Edit these to define the exact scan coordinates. Blank lines and lines -# starting with '#' are ignored. The auto-sweep (ControlCode 0) walks the list -# forward then backward (ping-pong), triggering the cameras at each settled -# point. Degrees are converted to encoder counts via the [Motor] calibration. -# -# Reference this file from config.ini: [Scan] grid_file = config/scan.csv -# -# Example: three elevation rows across a 180° yaw arc. --90,10 --45,10 -0,10 -45,10 -90,10 --90,30 --45,30 -0,30 -45,30 -90,30 --90,50 --45,50 -0,50 -45,50 -90,50 +# Scan grid — capture waypoints," one ""yaw_deg","pitch_deg"" per line." +# Edit these to define the exact scan coordinates. Blank lines and lines,, +# starting with '#' are ignored. The auto-sweep (ControlCode 0) walks the list,, +# forward then backward (ping-pong), triggering the cameras at each settled, +# point. Degrees are converted to encoder counts via the [Motor] calibration.,, +#,, +# Reference this file from config.ini: [Scan] grid_file = config/scan.csv,, +#,, +# Example: three elevation rows across a 180° yaw arc.,, +0,10, +45,10, +90,10, +135,10, +180,10, +0,5, +45,5, +90,5, +135,5, +180,5, +0,15, +45,15, +90,15, +135,15, +180,15, diff --git a/include/fgc/Config.h b/include/fgc/Config.h index 1f5dc6b..f7b68e6 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -77,6 +77,11 @@ struct ScanConfig { std::string pitch_levels = "0"; // generated: comma list of pitch elevations (deg) }; +// Which homed endstop becomes yaw 0 deg, with degrees rising toward the other +// limit (which then lands near +360, a bit less for the soft-limit/mechanical +// gap). Off = use the configured/calibrated yaw zero_count as-is. +enum class YawHomeZero { Off, Low, High }; + struct AppConfig { GeneralConfig general; NetworkConfig network; @@ -87,6 +92,7 @@ struct AppConfig { LoggingConfig logging; UiConfig ui; // [UI] terminal dashboard toggle Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch) + YawHomeZero yaw_home_zero = YawHomeZero::Off; // [Motor] re-anchor yaw zero on homing ScanConfig scan; // [Scan] grid source ImuConfig imu; // [IMU] Xsens MTi serial device diff --git a/include/fgc/DumpParser.h b/include/fgc/DumpParser.h index 957dc0b..75ed950 100644 --- a/include/fgc/DumpParser.h +++ b/include/fgc/DumpParser.h @@ -18,6 +18,7 @@ struct DumpAxis { bool has_encoder = false; long lim_neg = 0; long lim_pos = 0; + long soft_margin = 0; // counts kept clear of each hard limit long hold_target = 0; long speed = 0; int hsub = 0; diff --git a/include/fgc/mock/MockMotorController.h b/include/fgc/mock/MockMotorController.h index 5112dd1..797da9a 100644 --- a/include/fgc/mock/MockMotorController.h +++ b/include/fgc/mock/MockMotorController.h @@ -58,7 +58,7 @@ public: d << "DUMP " << L << " state=" << st << " hsub=0 enabled=" << (homed_ ? 1 : 0) << " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc - << " speed=200000 eeprom_restored=1 has_encoder=1\n"; + << " speed=200000 eeprom_restored=1 soft_margin=5000 has_encoder=1\n"; d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000" << " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000" << " XACTUAL=0x" << std::hex << (a.xactual & 0xffffffff) diff --git a/include/fgc/ui/TuiUi.h b/include/fgc/ui/TuiUi.h index cf2010d..988dfa5 100644 --- a/include/fgc/ui/TuiUi.h +++ b/include/fgc/ui/TuiUi.h @@ -44,6 +44,9 @@ private: std::thread ui_thread_; std::thread refresh_thread_; std::atomic running_{false}; + // When set, the 100ms auto-redraw is suspended so the screen holds still and + // the operator can select/copy text (terminals drop a selection on repaint). + std::atomic paused_{false}; // Log ring buffer (newest last), filled by the Logger sink. std::mutex log_mutex_; diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index 5825eb8..2859f10 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -94,6 +94,7 @@ struct CaptureView { std::vector scan_grid; bool scan_from_file = false; // grid_file CSV vs generated bool scan_pitch = false; // 2-axis rig (show pitch column) + std::string scan_error; // grid_file load failure (auto-sweep off) // Last published capture (from ImagePipeline::lastEvent()). bool has_last = false; std::string last_label; diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 9549c6d..e93f704 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -155,6 +156,7 @@ struct Application::Impl { std::unique_ptr calib; unsigned last_diag_seq = 0; ScanGrid grid; // outlives scheduler (holds a reference to it) + std::string scan_load_error_; // non-empty if grid_file failed to load // Persisted last-results for the TUI activity strip (survive in the log churn). bool diag_running_ = false; @@ -355,6 +357,7 @@ struct Application::Impl { // scheduler that mutates it, so no locking is needed). s.capture.scan_from_file = !cfg.scan.grid_file.empty(); s.capture.scan_pitch = t.pitch_present; + s.capture.scan_error = scan_load_error_; s.capture.scan_grid.clear(); const auto& pts = grid.points(); s.capture.scan_grid.reserve(pts.size()); @@ -415,9 +418,19 @@ struct Application::Impl { } else if (scheduler && scheduler->captureActive()) { a.active = true; a.title = "CAPTURE"; - a.status = s.capture.has_last - ? ("scanning · last " + s.capture.last_label) - : "scanning…"; + const char* phase = scheduler->moving() ? "moving" : "dwell"; + if (scheduler->controlCode() == 1) { + a.status = "directed → heading " + scheduler->targetHeading() + "° · " + + (scheduler->moving() ? "moving" : "holding"); + } else if (!grid.empty()) { + const ScanPoint& p = grid.current(); + std::string coord = "yaw " + formatDegrees(p.yaw_deg); + if (s.capture.scan_pitch) coord += " / pitch " + formatDegrees(p.pitch_deg); + a.status = "waypoint " + std::to_string(grid.index() + 1) + "/" + + std::to_string(grid.size()) + " · " + coord + " · " + phase; + } else { + a.status = "no scan grid — auto-sweep disabled"; + } } // Persisted result: the more recent of the last calibration / diagnostics. @@ -472,6 +485,7 @@ struct Application::Impl { } // Wait for completion (both axes READY again) or the homing timeout. + bool homed = false; const auto deadline = std::chrono::steady_clock::now() + 65s; while (running && std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(250ms); @@ -484,15 +498,81 @@ struct Application::Impl { } if (allReady(t)) { LOG_INFO << "Gimbal homed (both axes READY)"; + homed = true; break; } } + // Re-anchor the yaw zero to an endstop now that the homed limits are known. + if (homed) applyYawHomeZero(); // Production move speeds for subsequent MOVE commands. motor->sendCommand("SPEED Y 50000"); motor->sendCommand("SPEED P 150000"); LOG_INFO << "Gimbal init finished"; } + // With [Motor] yaw_home_zero = low|high, redefine the yaw degree origin from + // the freshly homed endstops: the chosen endstop becomes 0 deg and degrees + // rise toward the other limit (forced positive, so it lands near +360). The + // counts_per_deg magnitude is kept from config/calibration; only the zero and + // sign change, so the yaw zero is fixed by homing and never needs a separate + // calibration. Pitch is left untouched. + void applyYawHomeZero() { + using namespace std::chrono_literals; + if (cfg.yaw_home_zero == YawHomeZero::Off) return; + + // The homed limits live in the firmware DUMP; pull a fresh one and wait + // briefly for it to arrive with yaw endstops populated. + motor->sendCommand("DUMP"); + long lo = 0, hi = 0, margin = 0; + bool got = false; + const auto deadline = std::chrono::steady_clock::now() + 3s; + while (running && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(100ms); + publishSnapshot(); + DumpData dd = parseDump(motor->lastDump()); + if (dd.valid) + for (const auto& a : dd.axes) + if (a.axis == 'Y' && a.lim_pos != a.lim_neg) { + lo = a.lim_neg; hi = a.lim_pos; margin = a.soft_margin; got = true; + } + if (got) break; + } + if (!got) { + LOG_WARN << "yaw home-zero: no homed yaw limits in dump; keeping configured zero"; + return; + } + if (margin <= 0) + LOG_WARN << "yaw home-zero: firmware reported no soft_margin (update firmware); " + "anchoring at the hard homing limit instead of the soft limit"; + + const double mag = std::fabs(cfg.geometry.yaw.counts_per_deg); + if (mag < 1e-9) { + LOG_WARN << "yaw home-zero: yaw_counts_per_deg ~0; calibrate first; keeping zero"; + return; + } + // Anchor at the soft limits (the actual reachable travel), not the hard + // homing endstops, so the rightmost *reachable* position reads exactly 0. + const long lo_soft = lo + margin; + const long hi_soft = hi - margin; + const bool zero_high = (cfg.yaw_home_zero == YawHomeZero::High); + const long zero_cnt = zero_high ? hi_soft : lo_soft; + const long other_cnt = zero_high ? lo_soft : hi_soft; + // Sign so the other endstop sits at positive degrees (rises toward ~+360). + const double cpd = (other_cnt > zero_cnt) ? mag : -mag; + const double range_deg = std::fabs(static_cast(other_cnt - zero_cnt)) / mag; + + AxisMap& y = cfg.geometry.yaw; + y.zero_count = zero_cnt; + y.counts_per_deg = cpd; + y.min_deg = 0.0; + y.max_deg = range_deg; + if (scheduler) scheduler->setGeometry(cfg.geometry); // re-target the scan with it + + LOG_INFO << "yaw zero anchored at " << (zero_high ? "high" : "low") + << "-count endstop: 0.0.." << range_deg << " deg (zero_count=" << zero_cnt + << ", counts_per_deg=" << cpd << ")"; + } + void startCapture() { // The scheduler arms regardless, but a scan can't progress until the axes // are homed: before READY the firmware leaves coils disabled and clamps @@ -651,6 +731,11 @@ struct Application::Impl { } LOG_INFO << "gimbal raw -> " << raw; motor->sendCommand(raw); + } else if (sub == "home" && tok.size() <= 2) { + // Full re-home: enable + HOME + wait for READY + re-anchor the yaw zero + // (same path as startup --init), so the homing key behaves like a boot + // home rather than a fire-and-forget HOME that skips the re-anchor. + runInitSequence(); } else if (sub == "home" || sub == "stop" || sub == "reset" || sub == "enable" || sub == "disable" || sub == "speed" || sub == "setpos" || sub == "status") { // Passthrough to the (case-insensitive) firmware verb + args. @@ -874,6 +959,7 @@ struct Application::Impl { try { grid = ScanGrid::fromConfig(cfg.scan); } catch (const std::exception& e) { + scan_load_error_ = e.what(); LOG_WARN << "scan grid load failed (" << e.what() << "); auto-sweep disabled"; } LOG_INFO << "scan grid: " << grid.size() << " waypoints"; @@ -904,7 +990,15 @@ struct Application::Impl { cmd_queue.push(line); }); - if (opts.init) runInitSequence(); + if (opts.init) { + runInitSequence(); // homes, then re-anchors the yaw zero + } else if (cfg.yaw_home_zero != YawHomeZero::Off) { + // No homing this launch: the firmware restores its homed limits from + // EEPROM to READY on boot, so re-derive the same yaw zero from them. + // This makes the homing-anchored zero persist across software restarts + // without re-homing. Self-guards (no-op + note) if not yet homed. + applyYawHomeZero(); + } if (opts.start) startCapture(); LOG_INFO << "Entering control loop (type 'exit' to quit)"; diff --git a/src/core/CaptureScheduler.cpp b/src/core/CaptureScheduler.cpp index ab9ef16..7531333 100644 --- a/src/core/CaptureScheduler.cpp +++ b/src/core/CaptureScheduler.cpp @@ -95,8 +95,10 @@ void CaptureScheduler::tick() { const ScanPoint& p = grid_.current(); yaw_target_ = geometry_.yaw.toCounts(p.yaw_deg); pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg); - LOG_TRACE_CAT(LogCat::Control) - << "sweep -> grid yaw=" << p.yaw_deg << " pitch=" << p.pitch_deg; + // One INFO line per move so the operator can follow the scan in the + // log without enabling the control wire-trace. + LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size() + << ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)"; } else { // ControlCode 1: directed yaw, pitch held at current position. try { yaw_target_ = geometry_.yaw.toCounts(std::stod(target_heading_)); @@ -104,7 +106,7 @@ void CaptureScheduler::tick() { return; // bad heading; wait for a valid one } pitch_target_ = t.pitch.xenc; - LOG_TRACE_CAT(LogCat::Control) << "directed -> heading " << target_heading_; + LOG_INFO << "directed move: heading " << target_heading_ << " (deg)"; } sendMove(); moving_ = true; @@ -117,6 +119,10 @@ void CaptureScheduler::tick() { LOG_TRACE_CAT(LogCat::Control) << "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; triggering"; if (camera_.trigger()) { + if (control_code_ == 0) + LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size(); + else + LOG_INFO << "captured (directed)"; moving_ = false; if (control_code_ == 0) grid_.advance(); resetTimer(); diff --git a/src/core/Config.cpp b/src/core/Config.cpp index fed59cd..662821c 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -1,8 +1,10 @@ #include "fgc/Config.h" #include "fgc/Paths.h" +#include #include #include +#include #include #include #include @@ -131,6 +133,13 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { cfg.geometry.pitch.zero_count = getLong(kv, "Motor.pitch_zero_count", cfg.geometry.pitch.zero_count); cfg.geometry.pitch.min_deg = getDouble(kv, "Motor.pitch_min_deg", cfg.geometry.pitch.min_deg); cfg.geometry.pitch.max_deg = getDouble(kv, "Motor.pitch_max_deg", cfg.geometry.pitch.max_deg); + { + std::string hz = get(kv, "Motor.yaw_home_zero", "off"); + for (auto& ch : hz) ch = static_cast(std::tolower((unsigned char)ch)); + cfg.yaw_home_zero = hz == "high" ? YawHomeZero::High + : hz == "low" ? YawHomeZero::Low + : YawHomeZero::Off; + } // [Scan]: scan-grid source (explicit CSV file, else generated). cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file); @@ -156,7 +165,16 @@ AppConfig ConfigLoader::loadFromFile(const std::string& path) { if (rc > 0) throw std::runtime_error("Parse error in config file " + path + " at line " + std::to_string(rc)); - return fromMap(kv); + AppConfig cfg = fromMap(kv); + // Anchor a relative scan grid_file to the config file's directory so it resolves + // no matter which working directory the process is launched from (the config + // itself is found via a search path that may be unrelated to the cwd). + if (!cfg.scan.grid_file.empty()) { + std::filesystem::path gf(paths::expandUser(cfg.scan.grid_file)); + if (gf.is_relative()) gf = std::filesystem::path(path).parent_path() / gf; + cfg.scan.grid_file = gf.lexically_normal().string(); + } + return cfg; } namespace { diff --git a/src/core/DumpParser.cpp b/src/core/DumpParser.cpp index f9e7a20..94dcff7 100644 --- a/src/core/DumpParser.cpp +++ b/src/core/DumpParser.cpp @@ -159,6 +159,7 @@ DumpData parseDump(const std::string& block) { ax.has_encoder = asInt(get(m, "has_encoder", "0")) != 0; ax.lim_neg = asInt(get(m, "lim_neg", "0")); ax.lim_pos = asInt(get(m, "lim_pos", "0")); + ax.soft_margin = asInt(get(m, "soft_margin", "0")); ax.hold_target = asInt(get(m, "hold_target", "0")); ax.speed = asInt(get(m, "speed", "0")); } diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index e2c62e1..a2ad7bc 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -116,6 +116,10 @@ Element cameraPanel(const CaptureView& c) { hbox({text("capture ") | dim, active, filler(), text(std::to_string(c.image_rate) + " 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})); if (c.has_last) { long long now = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); @@ -145,6 +149,16 @@ Element cameraDetailPanel(const CaptureView& c) { separator(), }; + if (!c.scan_error.empty()) { + rows.push_back(hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold, + text(" auto-sweep disabled") | dim})); + rows.push_back(hbox({text("reason ") | dim, text(c.scan_error) | color(Color::Red)})); + rows.push_back(text("Check [Scan] grid_file in the loaded config (see the startup " + "'Loaded config:' log line).") | dim); + return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan), + vbox(std::move(rows))); + } + if (c.scan_grid.empty()) { rows.push_back(text("(no scan grid defined — auto-sweep disabled)") | dim); return window(text(" SCAN GRID (c/Esc:close) ") | bold | color(Color::Cyan), @@ -573,6 +587,7 @@ void TuiUi::refreshLoop() { while (running_) { std::this_thread::sleep_for(100ms); if (!running_) break; + if (paused_) continue; // frozen for text selection; don't repaint if (auto* s = screen_.load()) s->PostEvent(Event::Custom); // force a redraw } } @@ -602,6 +617,8 @@ void TuiUi::uiLoop() { text(" " + s.header.tower + " ") | bold, text("build " + s.header.build) | dim, filler(), + (paused_ ? text(" PAUSED ") | color(Color::Black) | bgcolor(Color::Yellow) | bold + : text("")), text(" " + mode + " ") | (s.header.live ? color(Color::Green) : color(Color::Yellow)) | bold, text(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim, }); @@ -616,7 +633,7 @@ void TuiUi::uiLoop() { bottom = hbox({ keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"), keyHint("g", "Gimbal"), keyHint("i", "IMU"), keyHint("c", "Scan"), - keyHint("r", "Refresh"), + 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"), }); @@ -712,6 +729,10 @@ void TuiUi::uiLoop() { overlay = (overlay == Overlay::Cameras) ? Overlay::None : Overlay::Cameras; return true; } + if (c == "p") { // freeze/unfreeze the live refresh so text can be selected + paused_ = !paused_; + return true; // this keypress redraws once so the indicator updates + } if (overlay == Overlay::Help) { // vim-style section nav while help is open if (c == "j") { help_sel = (help_sel + 1) % n; return true; } if (c == "k") { help_sel = (help_sel - 1 + n) % n; return true; } @@ -728,6 +749,10 @@ void TuiUi::uiLoop() { // ScreenInteractive can't be moved, so it lives on this thread's stack; the // atomic pointer lets stop()/refreshLoop() reach it. ScreenInteractive screen = ScreenInteractive::Fullscreen(); + // The dashboard is keyboard-only, so don't grab the mouse: with mouse + // tracking off the terminal keeps its native click-drag selection, so the + // operator can select/copy text (waypoints, log lines, register dumps). + screen.TrackMouse(false); screen_.store(&screen); if (running_) screen.Loop(root); screen_.store(nullptr);