diff --git a/Cargo.lock b/Cargo.lock index 0302f25..b004a70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -243,9 +243,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "getrandom" @@ -293,9 +293,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -482,14 +482,14 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -549,9 +549,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serial2" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" dependencies = [ "cfg-if", "libc", @@ -654,11 +654,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -674,9 +674,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", diff --git a/src/app.rs b/src/app.rs index 7885930..6927a01 100644 --- a/src/app.rs +++ b/src/app.rs @@ -202,6 +202,9 @@ pub struct App { /// Id of the attached task, if any: by id (not index) so it survives the /// task list changing underneath it. pub focused_id: Option, + /// Whether the host terminal window has focus + /// While unfocused, highlight rows mute to a bright-black background + pub terminal_focused: bool, pub rows: u16, pub cols: u16, /// Bytes of the last painted frame; the renderer skips the write when the @@ -424,6 +427,7 @@ impl App { spawn_cwd: invocation_dir.clone(), spawn_group: None, focused_id: None, + terminal_focused: true, rows, cols, last_frame: Vec::new(), @@ -881,6 +885,8 @@ impl App { CtEvent::Resize(cols, rows) => self.on_resize(rows, cols), CtEvent::Paste(s) => self.on_paste(&s), CtEvent::Mouse(m) => self.on_mouse(m), + CtEvent::FocusGained => self.terminal_focused = true, + CtEvent::FocusLost => self.terminal_focused = false, _ => {} } } diff --git a/src/app_tests.rs b/src/app_tests.rs index 1bcabb7..fabd46d 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -2436,6 +2436,42 @@ fn painted(app: &mut App) -> String { String::from_utf8_lossy(&out).into_owned() } +/// Highlight rows swap reverse video for a bright-black background while the +/// host terminal is unfocused +#[test] +fn unfocused_terminal_mutes_the_highlight_rows() { + use crossterm::style::{Attribute, Color, SetAttribute, SetBackgroundColor}; + + fn sgr(cmd: impl crossterm::Command) -> String { + let mut s = String::new(); + cmd.write_ansi(&mut s).unwrap(); + s + } + let reverse = sgr(SetAttribute(Attribute::Reverse)); + let muted = sgr(SetBackgroundColor(Color::DarkGrey)); + + let mut app = App::new_local(30, 100); + let inv = app.invocation_dir.clone(); + app.spawn_in("sleep 5", inv); + app.pump(); + app.selected_id = Some(1); + + // The dashboard's only reverse-video line is the selected row, so its + // presence tracks `rev` exactly. + let focused = painted(&mut app); + assert!(focused.contains(&reverse), "{focused:?}"); + assert!(!focused.contains(&muted), "{focused:?}"); + + app.terminal_focused = false; + let away = painted(&mut app); + assert!(away.contains(&muted), "{away:?}"); + assert!(!away.contains(&reverse), "{away:?}"); + + // Regaining focus restores the live highlight. + app.terminal_focused = true; + assert!(painted(&mut app).contains(&reverse)); +} + /// `?` opens the overlay; `?`, `Esc`, and `q` each close it. #[test] fn controls_overlay_opens_on_question_and_closes_on_peeks_key_set() { diff --git a/src/main.rs b/src/main.rs index fa3572d..1fac505 100644 --- a/src/main.rs +++ b/src/main.rs @@ -242,8 +242,8 @@ fn run() -> io::Result<()> { // Keyboard enhancement distinguishes modified Enter; bracketed paste // delivers the clipboard as one event. Keyboard flags are screen-specific, // so enable them after entering the alternate screen. Mouse capture is - // managed by `App::sync_input_modes`. Save and enable alternate scroll; - // restoration occurs in `restore_terminal`. + // managed by `App::sync_input_modes`. Save and enable alternate scroll + // and focus reporting; restoration occurs in `restore_terminal`. if kitty { execute!( out, @@ -253,7 +253,11 @@ fn run() -> io::Result<()> { // setup actually did, not what it attempted. KITTY_PUSHED.store(true, Ordering::Relaxed); } - execute!(out, EnableBracketedPaste, Print("\x1b[?1007s\x1b[?1007h"))?; + execute!( + out, + EnableBracketedPaste, + Print("\x1b[?1007s\x1b[?1007h\x1b[?1004s\x1b[?1004h") + )?; // `fleetcom [--foreground] ` loads that session at startup; the // result shows in the status line. if let Some(name) = &session { @@ -303,7 +307,7 @@ fn emit_restore_sequences(out: &mut impl io::Write, kitty_pushed: bool) -> io::R out, DisableMouseCapture, DisableBracketedPaste, - Print("\x1b[?1007r"), + Print("\x1b[?1007r\x1b[?1004r"), Show, LeaveAlternateScreen ) @@ -484,8 +488,10 @@ mod tests { for out in [&pushed, &unpushed] { assert!(contains(out, &leave)); assert!(contains(out, &show)); - // Alternate-scroll restore is a raw Print, not a crossterm command. + // Alternate-scroll and focus-reporting restores are raw Prints, + // not crossterm commands. assert!(contains(out, b"\x1b[?1007r")); + assert!(contains(out, b"\x1b[?1004r")); } } } diff --git a/src/ui.rs b/src/ui.rs index 52eaa67..f0003c1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,7 +10,7 @@ use std::{ use crossterm::{ cursor::{Hide, MoveTo, Show}, queue, - style::{Attribute, Print, SetAttribute}, + style::{Attribute, Color, Print, SetAttribute, SetBackgroundColor}, terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}, }; @@ -87,15 +87,16 @@ fn dim(out: &mut impl Write, y: u16, s: &str, cols: usize) -> io::Result<()> { ) } -/// Paint a full-width reverse-video line: selection and focused-field styling. -fn rev(out: &mut impl Write, y: u16, s: &str, cols: usize) -> io::Result<()> { - queue!( - out, - MoveTo(0, y), - SetAttribute(Attribute::Reverse), - Print(pad(s, cols)), - SetAttribute(Attribute::Reset) - ) +/// Paint a full-width highlight line: selection and focused-field styling. +fn rev(out: &mut impl Write, y: u16, s: &str, cols: usize, focused: bool) -> io::Result<()> { + queue!(out, MoveTo(0, y))?; + // When the host terminal is unfocused, the highlight is muted to a dark-grey + if focused { + queue!(out, SetAttribute(Attribute::Reverse))?; + } else { + queue!(out, SetBackgroundColor(Color::DarkGrey))?; + } + queue!(out, Print(pad(s, cols)), SetAttribute(Attribute::Reset)) } fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { @@ -183,7 +184,7 @@ fn render_dashboard(out: &mut impl Write, app: &App) -> io::Result<()> { Row::Task(ti) => { let v = &app.views[*ti]; if app.selected_id == Some(v.id) { - rev(out, y, &task_row(v, cols), cols)?; + rev(out, y, &task_row(v, cols), cols, app.terminal_focused)?; } else if v.preview.source == PreviewSource::Marker { // The marker is a placeholder, not output: dim the // preview cell so it reads as metadata. @@ -666,7 +667,7 @@ fn render_panel(out: &mut impl Write, app: &App, p: &Panel) -> io::Result<()> { let panel_h = (body + 2) as u16; let top = rows.saturating_sub(panel_h).max(2); - rev(out, top, &p.header, cols)?; + rev(out, top, &p.header, cols, app.terminal_focused)?; if total == 0 { if let Some(msg) = p.empty { @@ -679,7 +680,7 @@ fn render_panel(out: &mut impl Write, app: &App, p: &Panel) -> io::Result<()> { let marker = if idx == p.sel { "▸ " } else { " " }; let line = format!(" {marker}{}", p.labels[idx]); if idx == p.sel { - rev(out, y, &line, cols)?; + rev(out, y, &line, cols, app.terminal_focused)?; } else { put(out, y, &line, cols)?; } @@ -929,7 +930,13 @@ fn render_attached(out: &mut impl Write, app: &App) -> io::Result<()> { let cols = app.cols as usize; let title = attached_title(v); let bar = attached_bar(&title, screen.map_or(0, |s| s.scrollback), app.notice()); - rev(out, app.rows.saturating_sub(1), &bar, cols)?; + rev( + out, + app.rows.saturating_sub(1), + &bar, + cols, + app.terminal_focused, + )?; // Place the real cursor where the child's is, so typing feels native. match screen {