diff --git a/docs/commands.md b/docs/commands.md index 50c29ab..7a5d2a4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -65,7 +65,7 @@ Modified Enter, paste, and mouse input require state-dependent encoding: - Shift+Enter and Alt+Enter are sent as `ESC CR`, which is distinct from plain Enter. Shift requires a terminal that reports modified keys; terminals that do not report it send plain `CR`. - Paste travels as one message. Bracketed-paste-aware children receive paste markers with embedded terminators removed; other children receive line endings as `CR`. `fleetcom` text fields strip control characters. -- Mouse-protocol children receive clicks, drags, releases, and wheel events in the negotiated encoding. Full-screen children without a mouse protocol use alternate scroll. For inline children without a mouse protocol, wheel-up enters `fleetcom`'s scrollback view. When `fleetcom` captures the mouse (for mouse-protocol children, inline children, or scrollback), terminal selection requires the terminal's selection-override modifier. Otherwise, drag selects normally. +- Mouse-protocol children receive clicks, drags, releases, and wheel events in the negotiated encoding. Full-screen children without a mouse protocol use alternate scroll when enabled. For a live child without mouse reporting, a left drag selects child-screen text whenever `fleetcom` owns mouse capture: on inline screens and in full-screen programs that disable alternate scroll. Release copies the highlighted span to the system clipboard via OSC 52 and shows a `copied N chars` status-bar notice. Trailing padding is trimmed from each selected row, and concealed (SGR 8) cells copy as the blanks shown on screen. Whenever `fleetcom` captures the mouse, terminal-native selection requires the terminal's selection-override modifier (typically Shift) and covers the terminal's view, including `fleetcom`'s chrome. On the dashboard, in peek, and in full-screen programs using alternate scroll, drag remains terminal-native. - Attached children do not receive kitty keyboard-protocol or application-keypad sequences. Keypad digits send their normal characters. #### Scrollback diff --git a/src/app.rs b/src/app.rs index 777aa97..1159a76 100644 --- a/src/app.rs +++ b/src/app.rs @@ -31,6 +31,7 @@ use crate::{ ClipboardKind, Command, Event, Key, Lifecycle, Mods, MouseBtn, MouseKind, RecoveryEntry, ScreenView, ScrollAction, TaskView, }, + selection::Selection, transport::{ExitIntent, SocketTransport, ThreadTransport, Transport}, ui, }; @@ -233,6 +234,10 @@ pub struct App { mouse_captured: bool, /// Whether the attached task is displaying scrollback. view_scroll: bool, + /// Active live-screen drag selection in attached-pane cell coordinates. + /// Cleared when its attachment context or viewport changes, mouse reporting + /// takes over, or host mouse capture ends. + selection: Option, } /// Whether the attached view captures the mouse. Scrollback, inline views, @@ -396,6 +401,7 @@ impl App { should_quit: false, mouse_captured: false, view_scroll: false, + selection: None, exit_intent: ExitIntent::Disconnect, } } @@ -440,6 +446,11 @@ impl App { self.focused_screen.as_ref().filter(|s| s.id == id) } + /// The active live-screen selection displayed by the attached overlay. + pub fn selection(&self) -> Option<&Selection> { + self.selection.as_ref() + } + pub fn dir_label(&self, path: &Path) -> String { path::abbreviate(path) } @@ -599,6 +610,8 @@ impl App { fn set_watch(&mut self, want: Option<(u64, bool)>) { if want != self.watched { self.watched = want; + // A selection is scoped to one watch target and attachment mode. + self.selection = None; // Drop the now-irrelevant screen so a stale one can't flash before // the new target's first frame arrives. if want.is_none() { @@ -725,6 +738,7 @@ impl App { self.mode = Mode::Disconnected; self.focused_id = None; self.status = None; + self.selection = None; } if self.term_signal.load(Ordering::Relaxed) { @@ -741,6 +755,7 @@ impl App { if self.mode == Mode::Attached && self.focused_task().is_none() { self.mode = Mode::Dashboard; self.focused_id = None; + self.selection = None; } // Emit accepted clipboard stores before painting the next frame. @@ -774,6 +789,8 @@ impl App { } fn on_resize(&mut self, rows: u16, cols: u16) { + // A terminal resize invalidates the selected pane coordinates. + self.selection = None; self.rows = rows; self.cols = cols; // Send the *content* size; the core resizes every PTY to it. @@ -1259,6 +1276,7 @@ impl App { self.focused_id = None; // The watch change resets the task viewport. self.view_scroll = false; + self.selection = None; // Repaint from scratch next tick; wipe the child's screen now. let _ = execute!(out, Clear(ClearType::All), MoveTo(0, 0)); return Ok(()); @@ -1291,6 +1309,8 @@ impl App { .intersects(KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT) { self.view_scroll = true; + // Entering scrollback replaces the selected live rows. + self.selection = None; self.send_scrollback(ScrollAction::Up(page)); return Ok(()); } @@ -1345,8 +1365,8 @@ impl App { } } - /// Forward attached mouse events to the supervisor. Wheel events queued - /// during a mode transition still move dashboard and peek selection. + /// Route mouse input to dashboard or peek navigation, attached scrollback, + /// live-screen selection, or the attached child's PTY. fn on_mouse(&mut self, m: MouseEvent) { let btn = |b: MouseButton| match b { MouseButton::Left => MouseBtn::Left, @@ -1379,6 +1399,56 @@ impl App { return; } if let Some(id) = self.focused_id { + // Without child mouse reporting, left-button gestures + // select text on captured live screens. + if matches!(self.screen_for(id), Some(s) if !s.wants_mouse) { + match kind { + // Wheel navigation cancels the active drag. + MouseKind::WheelUp | MouseKind::WheelDown => self.selection = None, + MouseKind::Press(MouseBtn::Left) => { + // A selection needs the rest of its gesture + let fresh = self + .screen_for(id) + .is_some_and(|s| s.lines.len() == self.pane_rows() as usize); + self.selection = (self.mouse_captured + && fresh + && m.row < self.rows.saturating_sub(1)) + .then(|| { + Selection::begin( + m.row, + m.column.min(self.cols.saturating_sub(1)), + ) + }); + if self.selection.is_some() { + return; + } + } + MouseKind::Drag(MouseBtn::Left) if self.selection.is_some() => { + let row = m.row.min(self.pane_rows().saturating_sub(1)); + let col = m.column.min(self.cols.saturating_sub(1)); + if let Some(sel) = self.selection.as_mut() { + sel.extend(row, col); + } + return; + } + MouseKind::Release(MouseBtn::Left) if self.selection.is_some() => { + // The release cell is the final head, including + // for flicks with no intermediate drag event. + let row = m.row.min(self.pane_rows().saturating_sub(1)); + let col = m.column.min(self.cols.saturating_sub(1)); + if let Some(sel) = self.selection.as_mut() { + sel.extend(row, col); + } + self.finish_selection(id); + return; + } + _ => {} + } + } else if self.selection.is_some() { + // Mouse reporting can turn on mid-gesture. Discard the + // client selection before forwarding subsequent events. + self.selection = None; + } // Wheel-up enters scrollback for inline children that do // not receive mouse events. let inline = matches!( @@ -1401,6 +1471,30 @@ impl App { } } + /// Queue the selected text unless the gesture is a click or selects only + /// whitespace. + fn finish_selection(&mut self, id: u64) { + let Some(sel) = self.selection.take() else { + return; + }; + if sel.is_click() { + return; + } + // Copy the screen contents visible when the gesture completes. + let Some(text) = self.screen_for(id).map(|s| sel.extract(&s.lines)) else { + return; + }; + // A drag released at the bottom of a mostly empty screen + // must not stuff the clipboard with newlines the user + // never saw selected + let text = text.trim_end_matches('\n'); + if text.trim().is_empty() { + return; + } + self.pending_clipboard + .push((ClipboardKind::Clipboard, text.to_string())); + } + /// Move the attached task's scrollback viewport. fn send_scrollback(&mut self, action: ScrollAction) { if let Some(id) = self.focused_id { @@ -1420,6 +1514,9 @@ impl App { if capture { execute!(out, EnableMouseCapture)?; } else { + // Disabling capture ends mouse delivery; clear the selection + // before its release event becomes unavailable. + self.selection = None; execute!(out, DisableMouseCapture)?; } self.mouse_captured = capture; diff --git a/src/app_tests.rs b/src/app_tests.rs index 0bf15f1..d5c70c3 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -2150,3 +2150,478 @@ fn dashboard_status_event_sets_only_the_status() { assert!(app.notice().is_none(), "no mirror outside attached mode"); let _ = std::fs::remove_dir_all(&dir); } + +// --- drag-copy selection ------------------------------------------------ + +/// A left-button mouse event at `(row, col)`. +fn left(kind: MouseEventKind, row: u16, col: u16) -> MouseEvent { + MouseEvent { + kind, + column: col, + row, + modifiers: KeyModifiers::NONE, + } +} + +fn press(row: u16, col: u16) -> MouseEvent { + left(MouseEventKind::Down(MouseButton::Left), row, col) +} + +fn drag_to(row: u16, col: u16) -> MouseEvent { + left(MouseEventKind::Drag(MouseButton::Left), row, col) +} + +fn release(row: u16, col: u16) -> MouseEvent { + left(MouseEventKind::Up(MouseButton::Left), row, col) +} + +impl App { + /// Attach to a freshly spawned inline child and install a screen whose + /// `lines` the test controls. + fn attached_with_lines(lines: &[&str]) -> App { + let mut app = App::new_local(30, 100); + let dir = app.invocation_dir.clone(); + app.spawn_in("sleep 5", dir); + app.pump(); + app.resolve_selection(); + app.attach(); + let id = app.focused_id.expect("attached"); + let mut lines: Vec = lines.iter().map(|l| l.to_string()).collect(); + lines.resize(app.pane_rows() as usize, String::new()); + app.focused_screen = Some(ScreenView { + id, + lines, + formatted: Vec::new(), + cursor: (0, 0), + hide_cursor: false, + wants_mouse: false, + alt_screen: false, + alt_scroll: false, + scrollback: 0, + }); + app.mouse_captured = true; + app + } + + /// Spawn `cmd`, attach, and wait for a `ScreenView` satisfying `ready`. + fn attached_watching(cmd: &str, ready: impl Fn(&ScreenView) -> bool) -> (App, u64) { + let mut app = App::new_local(30, 100); + let cwd = app.invocation_dir.clone(); + app.spawn_in(cmd, cwd); + app.pump(); + app.resolve_selection(); + app.attach(); + let id = app.focused_id.expect("attached"); + app.set_watch(Some((id, true))); + assert!( + wait_until(Duration::from_secs(5), || { + app.pump(); + app.screen_for(id).is_some_and(&ready) + }), + "the expected screen never reached the client" + ); + app.mouse_captured = true; + (app, id) + } +} + +/// A press-drag-release over the live view copies the selected text through +/// the OSC 52 path and shows the copy notice. +#[test] +fn drag_copy_gesture_emits_the_selection_via_osc52() { + let mut app = App::attached_with_lines(&["hello world", "second row"]); + app.on_mouse(press(0, 0)); + app.on_mouse(drag_to(0, 4)); + app.on_mouse(release(0, 4)); + assert_eq!( + app.pending_clipboard, + vec![(ClipboardKind::Clipboard, "hello".to_string())] + ); + assert!(app.selection.is_none(), "release must clear the selection"); + let mut out = Vec::new(); + app.flush_clipboard(&mut out).unwrap(); + assert_eq!(out, b"\x1b]52;c;aGVsbG8=\x07"); + assert_eq!(app.notice(), Some("copied 5 chars")); +} + +/// A drag across rows samples the screen's rendered rows at release. +#[test] +fn drag_copy_spans_rows() { + let mut app = App::attached_with_lines(&["hello world", "second row"]); + app.on_mouse(press(0, 6)); + app.on_mouse(drag_to(1, 5)); + app.on_mouse(release(1, 5)); + assert_eq!( + app.pending_clipboard, + vec![(ClipboardKind::Clipboard, "world\nsecond".to_string())] + ); +} + +/// A motionless click queues no copy and shows no notice. +#[test] +fn click_without_drag_copies_nothing() { + let mut app = App::attached_with_lines(&["hello world"]); + app.on_mouse(press(0, 3)); + app.on_mouse(release(0, 3)); + assert!(app.pending_clipboard.is_empty()); + assert!(app.selection.is_none()); + let mut out = Vec::new(); + app.flush_clipboard(&mut out).unwrap(); + assert!(out.is_empty()); + assert_eq!(app.notice(), None); +} + +/// Whitespace-only selected text is not queued for copying. +#[test] +fn all_whitespace_selection_copies_nothing() { + let mut app = App::attached_with_lines(&["abc ", " "]); + app.on_mouse(press(0, 5)); + app.on_mouse(drag_to(1, 8)); + app.on_mouse(release(1, 8)); + assert!(app.pending_clipboard.is_empty()); + assert!(app.selection.is_none()); +} + +/// A press on the bar row (outside the child pane) starts no selection. +#[test] +fn bar_row_press_starts_no_selection() { + let mut app = App::attached_with_lines(&["hello world"]); + let bar = app.rows - 1; + app.on_mouse(press(bar, 3)); + assert!(app.selection.is_none(), "the bar row is not selectable"); + app.on_mouse(drag_to(bar, 8)); + app.on_mouse(release(bar, 8)); + assert!(app.pending_clipboard.is_empty()); +} + +/// A wheel event cancels an active drag and still enters inline scrollback. +#[test] +fn wheel_cancels_a_live_drag_and_keeps_its_function() { + let mut app = App::attached_with_lines(&["hello world"]); + app.on_mouse(press(0, 0)); + app.on_mouse(drag_to(0, 4)); + assert!(app.selection.is_some()); + app.on_mouse(left(MouseEventKind::ScrollUp, 0, 0)); + assert!(app.selection.is_none(), "wheel must drop the drag"); + assert!(app.view_scroll, "wheel-up must still enter scrollback"); + assert!(app.pending_clipboard.is_empty(), "a cancel is not a copy"); +} + +/// Resize, detach, scrollback entry, and watch changes clear active selections. +#[test] +fn coordinate_invalidation_clears_the_selection() { + let start = |app: &mut App| { + app.on_mouse(press(0, 0)); + app.on_mouse(drag_to(0, 4)); + assert!(app.selection.is_some(), "the drag must be live"); + }; + let mut out = io::stdout(); + + let mut app = App::attached_with_lines(&["hello world"]); + start(&mut app); + app.on_resize(31, 101); + assert!(app.selection.is_none(), "resize must clear"); + + let mut app = App::attached_with_lines(&["hello world"]); + start(&mut app); + app.on_key_attached(&mut out, ctrl(KeyCode::Char('\\'))) + .unwrap(); + assert!(app.mode == Mode::Dashboard, "ctrl-\\ detaches"); + assert!(app.selection.is_none(), "detach must clear"); + + let mut app = App::attached_with_lines(&["hello world"]); + start(&mut app); + app.on_key_attached( + &mut out, + KeyEvent::new(KeyCode::PageUp, KeyModifiers::SHIFT), + ) + .unwrap(); + assert!(app.view_scroll); + assert!(app.selection.is_none(), "scrollback entry must clear"); + + let mut app = App::attached_with_lines(&["hello world"]); + // Change an established attached watch. + let id = app.focused_id.expect("attached"); + app.set_watch(Some((id, true))); + start(&mut app); + app.set_watch(None); + assert!(app.selection.is_none(), "a watch change must clear"); +} + +/// Row-faithful screen lines reach the client: one entry per pane row, the +/// blank bottom row included. +#[test] +fn attached_screen_lines_cover_every_pane_row() { + let (app, id) = App::attached_watching("printf 'top'; sleep 5", |s| { + s.lines.first().is_some_and(|l| l == "top") + }); + let lines = &app.screen_for(id).unwrap().lines; + // 30-row client, one-row status bar: the pane grid is 29 rows. + assert_eq!(lines.len(), 29, "one entry per pane row"); + assert_eq!(lines[28], "", "the blank bottom row keeps its slot"); +} + +/// A drag from beyond the penultimate row's text into the blank bottom row +/// selects only blank cells and copies nothing. +#[test] +fn bottom_row_drag_does_not_alias_onto_the_penultimate_row() { + // CUP is 1-based: row 28 is 0-based row 27, the 29-row pane's penultimate. + let (mut app, _) = App::attached_watching("printf '\\033[28;1Hbottomtext'; sleep 5", |s| { + s.lines.iter().any(|l| l == "bottomtext") + }); + // Press past the text's end (col 15 > "bottomtext"), drag into the blank + // bottom row: the highlight shows blank cells only. + app.on_mouse(press(27, 15)); + app.on_mouse(drag_to(28, 0)); + app.on_mouse(release(28, 0)); + assert!( + app.pending_clipboard.is_empty(), + "a drag over blank cells must copy nothing, got {:?}", + app.pending_clipboard + ); +} + +/// Enabling child mouse reporting mid-gesture discards the client selection +/// before subsequent events are forwarded. +#[test] +fn mid_drag_wants_mouse_flip_drops_the_selection() { + let mut app = App::attached_with_lines(&["hello world"]); + app.on_mouse(press(0, 0)); + app.on_mouse(drag_to(0, 4)); + assert!(app.selection.is_some()); + // Install a new screen snapshot with mouse reporting enabled. + let mut flipped = app.focused_screen.clone().expect("screen installed"); + flipped.wants_mouse = true; + app.focused_screen = Some(flipped); + app.on_mouse(drag_to(0, 6)); + assert!(app.selection.is_none(), "the flip must drop the selection"); + app.on_mouse(release(0, 6)); + assert!(app.selection.is_none()); + assert!( + app.pending_clipboard.is_empty(), + "a canceled drag is not a copy" + ); +} + +/// The child enables mouse reporting mid-drag: the selection cancels, and +/// the rerouted drag/release bytes reach the child's PTY. +#[test] +fn mid_drag_mouse_enable_reroutes_the_gesture_to_the_child() { + let dir = temp("app_drag_flip"); + let out_file = dir.join("bytes"); + // The child enables button-motion+SGR reporting only after one byte of + // stdin arrives, so the flip lands mid-gesture under test control. + let cmd = format!( + "stty -icanon -echo min 1 time 0; head -c 1 >/dev/null; \ + printf '\\033[?1002h\\033[?1006h'; head -c 19 > {}", + out_file.display() + ); + let (mut app, id) = App::attached_watching(&cmd, |s| !s.wants_mouse); + app.on_mouse(press(1, 2)); + assert!(app.selection.is_some(), "the press must start a drag"); + // Trigger the child's mouse enable and wait for the flip to arrive. + app.transport.send(Command::Input { + id, + bytes: b"\n".to_vec(), + }); + assert!( + wait_until(Duration::from_secs(5), || { + app.pump(); + matches!(app.screen_for(id), Some(s) if s.wants_mouse) + }), + "the mouse-mode flip never reached the client" + ); + app.on_mouse(drag_to(1, 5)); + assert!(app.selection.is_none(), "the flip must drop the selection"); + app.on_mouse(release(1, 5)); + assert!( + app.pending_clipboard.is_empty(), + "a canceled drag is not a copy" + ); + // SGR: drag `\x1b[<32;6;2M`, release `\x1b[<0;6;2m`; the press stayed + // client-side, so the child sees exactly the rerouted pair. + let mut got = Vec::new(); + wait_until(Duration::from_secs(5), || { + got = std::fs::read(&out_file).unwrap_or_default(); + got.len() >= 19 + }); + assert_eq!(got, b"\x1b[<32;6;2M\x1b[<0;6;2m".to_vec()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Concealed (SGR 8) text reaches the client as the blanks the screen shows, +/// and a selection over it copies nothing: highlight and clipboard agree. +#[test] +fn selection_over_concealed_text_copies_nothing() { + // Row 1: nine hidden cells, then a visible sentinel to key arrival on. + let (mut app, id) = App::attached_watching( + "printf 'ok\\r\\n\\033[8mTOPSECRET\\033[28mZ'; sleep 5", + |s| s.lines.get(1).is_some_and(|l| l.ends_with('Z')), + ); + assert_eq!( + app.screen_for(id).unwrap().lines[1], + " Z", + "concealed cells must read as spaces" + ); + app.on_mouse(press(1, 0)); + app.on_mouse(drag_to(1, 8)); + app.on_mouse(release(1, 8)); + assert!( + app.pending_clipboard.is_empty(), + "concealed text must not copy, got {:?}", + app.pending_clipboard + ); +} + +/// A press→release flick with no drag event between copies the span from +/// press to release: the release coordinate is part of the gesture. +#[test] +fn release_without_drag_copies_the_flick_span() { + let mut app = App::attached_with_lines(&["hello world", "second row"]); + app.on_mouse(press(0, 0)); + app.on_mouse(release(0, 4)); + assert_eq!( + app.pending_clipboard, + vec![(ClipboardKind::Clipboard, "hello".to_string())] + ); + assert!(app.selection.is_none(), "release must clear the selection"); +} + +/// A release past the pane clamps like a drag: the copy extends through the +/// bottom-most, right-most cell the highlight can show. +#[test] +fn release_past_the_view_clamps_like_a_drag() { + let mut app = App::attached_with_lines(&["hello world", "second row"]); + app.on_mouse(press(0, 6)); + app.on_mouse(drag_to(0, 8)); + app.on_mouse(release(u16::MAX, u16::MAX)); + assert_eq!( + app.pending_clipboard, + vec![(ClipboardKind::Clipboard, "world\nsecond row".to_string())] + ); +} + +/// Disabling host mouse capture clears a live selection before mouse delivery +/// stops. +#[test] +fn capture_drop_clears_a_live_selection() { + let mut app = App::attached_with_lines(&["alpha beta", "gamma"]); + app.mouse_captured = true; + app.on_mouse(press(0, 2)); + app.on_mouse(drag_to(0, 6)); + assert!(app.selection().is_some(), "premise: a drag is live"); + // Alternate scroll on the alternate screen disables host mouse capture. + if let Some(s) = app.focused_screen.as_mut() { + s.alt_screen = true; + s.alt_scroll = true; + } + app.sync_input_modes(&mut std::io::stdout()).unwrap(); + assert!(!app.mouse_captured, "premise: capture dropped"); + assert!(app.selection().is_none(), "the drop must clear the drag"); + assert!(app.pending_clipboard.is_empty(), "nothing may copy"); +} + +/// A press queued before capture dropped is processed after it +#[test] +fn press_after_capture_drop_starts_no_selection() { + let mut app = App::attached_with_lines(&["alpha beta", "gamma"]); + app.mouse_captured = false; + app.on_mouse(press(0, 2)); + assert!(app.selection().is_none(), "no capture, no gesture to come"); + app.on_mouse(drag_to(0, 6)); + app.on_mouse(release(0, 6)); + assert!(app.pending_clipboard.is_empty(), "nothing may copy"); +} + +/// Press eligibility requires the screen's row count to match the current pane geometry. +#[test] +fn press_on_a_stale_geometry_screen_starts_no_selection() { + let mut app = App::attached_with_lines(&["hello world"]); + app.on_resize(40, 100); + app.on_mouse(press(0, 0)); + assert!(app.selection().is_none(), "stale geometry must not select"); + // The first post-resize frame restores eligibility + let rows = app.pane_rows() as usize; + if let Some(s) = app.focused_screen.as_mut() { + s.lines.resize(rows, String::new()); + } + app.on_mouse(press(0, 0)); + assert!(app.selection().is_some(), "a fresh frame selects again"); +} + +/// At one terminal row the status bar covers the child pane, leaving no +/// selectable rows. +#[test] +fn one_row_terminal_has_no_selectable_pane() { + let mut app = App::new_local(1, 80); + let dir = app.invocation_dir.clone(); + app.spawn_in("sleep 5", dir); + app.pump(); + app.resolve_selection(); + app.attach(); + app.mouse_captured = true; + let id = app.focused_id.expect("attached"); + app.focused_screen = Some(ScreenView { + id, + lines: vec!["hidden".to_string()], + formatted: Vec::new(), + cursor: (0, 0), + hide_cursor: false, + wants_mouse: false, + alt_screen: false, + alt_scroll: false, + scrollback: 0, + }); + app.on_mouse(press(0, 0)); + assert!(app.selection().is_none(), "the bar row is not selectable"); + app.on_mouse(drag_to(0, 5)); + app.on_mouse(release(0, 5)); + assert!(app.pending_clipboard.is_empty(), "nothing may copy"); +} + +/// With a mouse-aware child the left button forwards: the SGR +/// press/drag/release bytes reach the child's PTY, and no selection +/// state forms. +#[test] +fn wants_mouse_child_keeps_the_left_button() { + let dir = temp("app_drag_fwd"); + let out_file = dir.join("bytes"); + let mut app = App::new_local(30, 100); + let cwd = app.invocation_dir.clone(); + // 1002 (button motion) reports drags; 1006 selects the SGR encoding. + let cmd = format!( + "stty -icanon -echo min 1 time 0; printf '\\033[?1002h\\033[?1006h'; head -c 28 > {}", + out_file.display() + ); + app.spawn_in(&cmd, cwd); + app.pump(); + app.resolve_selection(); + app.attach(); + let id = app.focused_id.expect("attached"); + app.set_watch(Some((id, true))); + // Wait for the child's mouse mode to reach the client. + assert!( + wait_until(Duration::from_secs(5), || { + app.pump(); + matches!(app.screen_for(id), Some(s) if s.wants_mouse) + }), + "mouse mode never reached the client" + ); + + app.on_mouse(press(1, 2)); + assert!(app.selection.is_none(), "a wants_mouse press must forward"); + app.on_mouse(drag_to(1, 5)); + assert!(app.selection.is_none(), "a wants_mouse drag must forward"); + app.on_mouse(release(1, 5)); + assert!(app.selection.is_none()); + assert!(app.pending_clipboard.is_empty(), "nothing may copy"); + + let mut got = Vec::new(); + wait_until(Duration::from_secs(5), || { + got = std::fs::read(&out_file).unwrap_or_default(); + got.len() >= 28 + }); + // SGR: press `\x1b[<0;col+1;row+1M`, drag adds 32, release ends in `m`. + assert_eq!(got, b"\x1b[<0;3;2M\x1b[<32;6;2M\x1b[<0;6;2m".to_vec()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/src/main.rs b/src/main.rs index fefe6a2..0f9ee4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ compile_error!("fleetcom supports Unix platforms only."); // Terminal-attached client. mod app; mod editbuf; +mod selection; mod ui; // Task lifecycle and dashboard-preview core. diff --git a/src/selection.rs b/src/selection.rs new file mode 100644 index 0000000..e26b950 --- /dev/null +++ b/src/selection.rs @@ -0,0 +1,311 @@ +//! Cell-coordinate drag selections and width-aware text extraction from +//! plain-text screen rows. + +use unicode_width::UnicodeWidthChar; + +/// An in-progress drag selection: a pair of 0-based `(row, col)` cells. +/// +/// The anchor is the pressed cell and never moves; the head tracks the +/// pointer. Either may precede the other: [`Selection::extract`] normalizes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Selection { + anchor: (u16, u16), + head: (u16, u16), +} + +impl Selection { + /// Start a selection at the pressed cell: anchor and head coincide. + pub fn begin(row: u16, col: u16) -> Self { + Self { + anchor: (row, col), + head: (row, col), + } + } + + /// Move the head to the dragged cell; the anchor stays at the press. + pub fn extend(&mut self, row: u16, col: u16) { + self.head = (row, col); + } + + /// Whether the head and anchor occupy the same cell. + pub fn is_click(&self) -> bool { + self.anchor == self.head + } + + /// Extract the selected text from `rows`, the rendered screen top-down. + /// + /// Endpoints are ordered by row and column. The first row starts at the + /// first endpoint, intermediate rows are included in full, and the final + /// row includes the cell under the second endpoint. A boundary inside a + /// wide glyph includes the whole glyph. Trailing whitespace is removed + /// from each segment, and segments are joined with `\n`. + /// + /// Rows below the screen clamp to its last row. Columns beyond a row select + /// no text, and an empty screen produces an empty string. + pub fn extract(&self, rows: &[String]) -> String { + let Some(last) = rows.len().checked_sub(1) else { + return String::new(); + }; + let (start, end) = self.bounds(last); + let mut out = String::new(); + for (row, text) in rows.iter().enumerate().take(end.0 + 1).skip(start.0) { + if row > start.0 { + out.push('\n'); + } + let from = if row == start.0 { start.1 } else { 0 }; + let to = (row == end.0).then_some(end.1); + out.push_str(segment(text, from, to).trim_end()); + } + out + } + + /// Return the selected text on `row` and its starting display column. + /// Uses the same endpoint ordering, row clamping, wide-glyph expansion, + /// and trailing-whitespace removal as [`Selection::extract`]. Returns + /// `None` outside the selection or when the row's selected span is empty. + pub fn row_segment<'a>( + &self, + row: u16, + text: &'a str, + last_row: usize, + ) -> Option<(u16, &'a str)> { + let (start, end) = self.bounds(last_row); + let row = row as usize; + if row < start.0 || row > end.0 { + return None; + } + let from = if row == start.0 { start.1 } else { 0 }; + let to = (row == end.0).then_some(end.1); + let (col, seg) = segment_span(text, from, to)?; + let seg = seg.trim_end(); + // The first selected glyph starts at or before a `u16` endpoint. + (!seg.is_empty()).then_some((col as u16, seg)) + } + + /// Clamp endpoints to the last row, then order them by row and column. + /// Clamping first handles endpoints that collapse onto the same row. + fn bounds(&self, last: usize) -> ((usize, usize), (usize, usize)) { + let clamp = |(row, col): (u16, u16)| ((row as usize).min(last), col as usize); + let (mut start, mut end) = (clamp(self.anchor), clamp(self.head)); + if start > end { + std::mem::swap(&mut start, &mut end); + } + (start, end) + } +} + +/// Return the text overlapping display cells `from..=to` and its starting +/// display column. `None` for `to` extends through the row. Wide glyphs are +/// included whole, and zero-width characters following a selected glyph are +/// included with it. +fn segment_span(row: &str, from: usize, to: Option) -> Option<(usize, &str)> { + // Exclusive right edge; `to` is the inclusive cell under the head. + let to = to.map_or(usize::MAX, |t| t.saturating_add(1)); + let mut col = 0; + let mut start = None; + let mut end = 0; + let mut taken = false; + for (i, c) in row.char_indices() { + let w = c.width().unwrap_or(0); + if w == 0 { + // A zero-width tail extends the glyph it follows. + if taken { + end = i + c.len_utf8(); + } + continue; + } + // The glyph spans cells [col, col + w); take it on any overlap. + taken = col < to && col + w > from; + if taken { + start.get_or_insert((i, col)); + end = i + c.len_utf8(); + } + col += w; + } + start.map(|(s, c)| (c, &row[s..end])) +} + +/// [`segment_span`] without the column, for whole-selection extraction. +fn segment(row: &str, from: usize, to: Option) -> &str { + segment_span(row, from, to).map_or("", |(_, s)| s) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn screen(rows: &[&str]) -> Vec { + rows.iter().map(|r| r.to_string()).collect() + } + + /// A selection pressed at `a` and dragged to `b`, both `(row, col)`. + fn drag(a: (u16, u16), b: (u16, u16)) -> Selection { + let mut s = Selection::begin(a.0, a.1); + s.extend(b.0, b.1); + s + } + + #[test] + fn forward_and_reversed_drags_extract_identical_text() { + let rows = screen(&["hello world"]); + let fwd = drag((0, 2), (0, 6)).extract(&rows); + let rev = drag((0, 6), (0, 2)).extract(&rows); + assert_eq!(fwd, "llo w"); + assert_eq!(fwd, rev); + } + + #[test] + fn multi_row_takes_middle_rows_whole() { + let rows = screen(&["first ", "middle ", "last "]); + let fwd = drag((0, 4), (2, 1)).extract(&rows); + let rev = drag((2, 1), (0, 4)).extract(&rows); + assert_eq!(fwd, "t\nmiddle\nla"); + assert_eq!(fwd, rev); + } + + #[test] + fn dragging_onto_a_cell_selects_it() { + let rows = screen(&["abcdef"]); + // The end column is inclusive: the head's cell is part of the text. + assert_eq!(drag((0, 1), (0, 3)).extract(&rows), "bcd"); + // Extraction of a motionless click yields the cell under it; the + // caller gates on `is_click`, not on emptiness. + assert_eq!(drag((0, 0), (0, 0)).extract(&rows), "a"); + } + + #[test] + fn motionless_click_is_degenerate() { + let mut s = Selection::begin(3, 7); + assert!(s.is_click()); + // A drag event that stays on the pressed cell is still a click. + s.extend(3, 7); + assert!(s.is_click()); + s.extend(3, 8); + assert!(!s.is_click()); + // Returning to the pressed cell reads as a click again. + s.extend(3, 7); + assert!(s.is_click()); + } + + #[test] + fn trailing_padding_is_trimmed_per_row() { + let rows = screen(&["top ", " ", "bottom"]); + // The all-space row contributes an empty segment but keeps its + // newline slot. + assert_eq!(drag((0, 0), (2, 5)).extract(&rows), "top\n\nbottom"); + } + + #[test] + fn column_inside_a_wide_glyph_takes_the_whole_glyph() { + // Cells: a=0, 日=1-2, 本=3-4, b=5. + let rows = screen(&["a日本b"]); + // Start edge inside 日. + assert_eq!(drag((0, 2), (0, 5)).extract(&rows), "日本b"); + // End edge inside 本. + assert_eq!(drag((0, 0), (0, 3)).extract(&rows), "a日本"); + // Both edges inside glyphs. + assert_eq!(drag((0, 2), (0, 3)).extract(&rows), "日本"); + } + + #[test] + fn wide_emoji_rounds_like_cjk() { + // Cells: 😀=0-1, z=2. + let rows = screen(&["😀z"]); + assert_eq!(drag((0, 1), (0, 2)).extract(&rows), "😀z"); + assert_eq!(drag((0, 0), (0, 0)).extract(&rows), "😀"); + } + + #[test] + fn zero_width_marks_travel_with_their_glyph() { + // VS16 reports zero width on the char walk; it rides in ❤'s cell. + let rows = screen(&["x❤\u{fe0f}y"]); + assert_eq!(drag((0, 1), (0, 1)).extract(&rows), "❤\u{fe0f}"); + assert_eq!(drag((0, 1), (0, 2)).extract(&rows), "❤\u{fe0f}y"); + // A combining mark rides with its base. + let rows = screen(&["e\u{0301}f"]); + assert_eq!(drag((0, 0), (0, 0)).extract(&rows), "e\u{0301}"); + } + + #[test] + fn columns_past_the_row_clamp() { + let rows = screen(&["ab", "cd"]); + assert_eq!(drag((0, 40), (0, 90)).extract(&rows), ""); + // A start column beyond row 0 selects nothing there; row 1 still + // yields, and the empty first segment keeps its newline slot. + assert_eq!(drag((0, 40), (1, 0)).extract(&rows), "\nc"); + } + + #[test] + fn rows_below_the_screen_land_on_the_bottom_row() { + let rows = screen(&["ab", "cd"]); + assert_eq!(drag((0, 0), (9, 0)).extract(&rows), "ab\nc"); + // Both endpoints below the screen: the drag collapses onto the + // bottom row, and clamping inverts the endpoint order. + assert_eq!(drag((5, 1), (9, 0)).extract(&rows), "cd"); + } + + #[test] + fn empty_screen_extracts_nothing() { + assert_eq!(drag((0, 0), (3, 3)).extract(&[]), ""); + } + + #[test] + fn zero_length_rows_hold_their_line_slots() { + let rows = screen(&["a", "", "b"]); + assert_eq!(drag((0, 0), (2, 0)).extract(&rows), "a\n\nb"); + assert_eq!(drag((1, 0), (1, 5)).extract(&rows), ""); + } + + #[test] + fn row_segment_starts_at_the_glyph_not_the_boundary() { + // Cells: a=0, 日=1-2, 本=3-4, b=5. A start boundary inside 日 rounds + // back to cell 1, the glyph's first cell: the repaint position. + let s = drag((0, 2), (0, 4)); + assert_eq!(s.row_segment(0, "a日本b", 0), Some((1, "日本"))); + assert_eq!(s.extract(&screen(&["a日本b"])), "日本"); + } + + #[test] + fn row_segment_middle_rows_span_from_column_zero() { + let s = drag((0, 3), (2, 1)); + assert_eq!(s.row_segment(0, "aaaa", 2), Some((3, "a"))); + assert_eq!(s.row_segment(1, "bbbb", 2), Some((0, "bbbb"))); + assert_eq!(s.row_segment(2, "cccc", 2), Some((0, "cc"))); + } + + #[test] + fn row_segment_outside_the_selection_is_none() { + let s = drag((1, 0), (2, 1)); + assert_eq!(s.row_segment(0, "above", 3), None); + assert_eq!(s.row_segment(3, "below", 3), None); + } + + #[test] + fn row_segment_clamps_like_extract() { + // Both endpoints below a two-row screen collapse onto the bottom row, + // matching `extract`'s clamping (including the ordering inversion). + let s = drag((5, 1), (9, 0)); + assert_eq!(s.extract(&screen(&["ab", "cd"])), "cd"); + assert_eq!(s.row_segment(0, "ab", 1), None); + assert_eq!(s.row_segment(1, "cd", 1), Some((0, "cd"))); + } + + #[test] + fn row_segment_trims_padding_to_none() { + let s = drag((0, 0), (2, 3)); + assert_eq!(s.row_segment(1, " ", 2), None); + // A column range past the row's content is likewise empty. + assert_eq!(drag((0, 40), (0, 90)).row_segment(0, "ab", 0), None); + } + + #[test] + fn normalization_is_row_major_not_column_major() { + // The anchor's column (4) is past the head's (1), but the head is on + // a later row: row order decides, not column order. + let rows = screen(&["abcde", "fghij"]); + let fwd = drag((0, 4), (1, 1)).extract(&rows); + let rev = drag((1, 1), (0, 4)).extract(&rows); + assert_eq!(fwd, "e\nfg"); + assert_eq!(fwd, rev); + } +} diff --git a/src/task.rs b/src/task.rs index adb3f39..297f902 100644 --- a/src/task.rs +++ b/src/task.rs @@ -517,11 +517,14 @@ impl Task { grid(&self.parser).formatted() } - /// Snapshot of visible rows for the peek overlay. + /// Return one plain-text string per visible grid row for peek and drag + /// selection, including a blank final row. pub fn screen_lines(&self) -> Vec { + // `contents()` separates grid rows with `\n`; `split` preserves the + // trailing empty field that represents a blank final row. grid(&self.parser) .contents() - .lines() + .split('\n') .map(str::to_string) .collect() } diff --git a/src/task_tests.rs b/src/task_tests.rs index 2fd7a87..503b16f 100644 --- a/src/task_tests.rs +++ b/src/task_tests.rs @@ -271,6 +271,19 @@ fn viewport_scrolls_and_snaps_live_on_input() { t.terminate(); } +/// `screen_lines` returns one entry per grid row, including a blank final row. +#[test] +fn screen_lines_yields_one_entry_per_grid_row() { + let mut t = spawn(60, "sleep 300"); + grid(&t.parser).process(b"top"); + let lines = t.screen_lines(); + // The spawn helper's grid is 24x80. + assert_eq!(lines.len(), 24, "one entry per grid row"); + assert_eq!(lines[0], "top"); + assert_eq!(lines[23], "", "the blank bottom row keeps its slot"); + t.terminate(); +} + /// The per-task writer worker delivers queued messages in FIFO order. #[test] fn queued_writes_reach_the_child_in_order() { diff --git a/src/terminal/ansi.rs b/src/terminal/ansi.rs index 15fd3a5..2836542 100644 --- a/src/terminal/ansi.rs +++ b/src/terminal/ansi.rs @@ -215,9 +215,9 @@ pub fn formatted(term: &Term) -> (Vec, (u16, u16), bool) { /// Plain-text contents of the displayed screen, one line per row, honoring /// the display offset. Paired wide-char spacers are skipped so wide glyphs -/// appear once; zero-width marks ride their base character; `'\t'` cells and -/// orphaned wide halves read as the blank the replayed screen shows; trailing -/// spaces are trimmed per row. +/// appear once; zero-width marks ride their base character; `'\t'` cells, +/// concealed (SGR 8) cells, and orphaned wide halves read as the blank the +/// replayed screen shows; trailing spaces are trimmed per row. pub fn contents(term: &Term) -> String { let grid = term.grid(); let cols = grid.columns(); @@ -232,6 +232,12 @@ pub fn contents(term: &Term) -> String { let line = &grid[Line(row as i32 - offset)]; for col in 0..cols { let cell = &line[Column(col)]; + // Each concealed cell contributes one blank display column; + // attached zero-width marks remain concealed as well. + if cell.flags.contains(Flags::HIDDEN) { + out.push(' '); + continue; + } if paired_spacer(line, col) { continue; } @@ -943,6 +949,28 @@ mod tests { assert_eq!(contents(&source), "one\ntwo 漢\u{301}字\n x\n"); } + #[test] + fn contents_conceals_hidden_cells() { + // SGR 8 cells display blank, so the plain-text view reads them as + // spaces (`formatted` re-emits SGR 8 and both views must agree). The + // combining mark on the hidden 'S' must not leak, and the trailing + // hidden run trims away like padding. + let source = parse( + "ab\x1b[8mS\u{301}ECRET\x1b[28mcd \x1b[8mtail".as_bytes(), + 2, + 20, + ); + assert_eq!(contents(&source), "ab cd\n"); + } + + #[test] + fn hidden_wide_glyphs_keep_both_columns() { + // A concealed wide glyph occupies two cells; both read as spaces so + // later glyphs keep their columns. + let source = parse("\x1b[8m日\x1b[28mx".as_bytes(), 1, 10); + assert_eq!(contents(&source), " x"); + } + // Randomized escape-soup round trip: deterministic (fixed seeds, no // wall-clock-dependent sequences), covering interleavings the targeted // cases cannot enumerate. diff --git a/src/ui.rs b/src/ui.rs index 96dfa16..0012c28 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -17,6 +17,7 @@ use crate::{ editbuf::EditBuffer, format::{pad, rel_time, truncate}, protocol::{Lifecycle, Preview, PreviewSource, RecoveryEntry, TaskView}, + selection::Selection, }; pub fn render(out: &mut Stdout, app: &mut App) -> io::Result<()> { @@ -705,6 +706,17 @@ fn render_attached(out: &mut impl Write, app: &App) -> io::Result<()> { queue!(out, Hide, MoveTo(0, 0))?; if let Some(s) = screen { out.write_all(&s.formatted)?; + // Repaint selected spans as reverse-video plain text over the child's + // formatted output. + for (row, col, text) in selection_overlay(app.selection(), &s.lines) { + queue!( + out, + MoveTo(col, row), + SetAttribute(Attribute::Reverse), + Print(text), + SetAttribute(Attribute::Reset) + )?; + } } let cols = app.cols as usize; @@ -720,6 +732,23 @@ fn render_attached(out: &mut impl Write, app: &App) -> io::Result<()> { Ok(()) } +/// Return one `(row, start_col, text)` overlay for each nonempty selected row. +fn selection_overlay<'a>(sel: Option<&Selection>, lines: &'a [String]) -> Vec<(u16, u16, &'a str)> { + let (Some(sel), Some(last)) = (sel, lines.len().checked_sub(1)) else { + return Vec::new(); + }; + lines + .iter() + .enumerate() + .filter_map(|(row, text)| { + // Screen row counts are bounded by the terminal's `u16` height. + let row = row as u16; + sel.row_segment(row, text, last) + .map(|(col, seg)| (row, col, seg)) + }) + .collect() +} + /// Build the attached or scrollback bar, showing notices only in live view. fn attached_bar(title: &str, scrollback: usize, notice: Option<&str>) -> String { match (scrollback, notice) { @@ -745,6 +774,24 @@ pub fn scroll_window(sel: usize, total: usize, max: usize) -> (usize, usize) { mod tests { use super::*; + #[test] + fn selection_overlay_spans_rows_and_rounds_wide_glyphs() { + let lines: Vec = ["a日本b", " mid ", "tail"] + .iter() + .map(|s| s.to_string()) + .collect(); + // A boundary inside 日 expands to the glyph's first cell; the middle + // row starts at column 0 and excludes trailing whitespace. + let mut sel = Selection::begin(0, 2); + sel.extend(2, 1); + assert_eq!( + selection_overlay(Some(&sel), &lines), + vec![(0, 1, "日本b"), (1, 0, " mid"), (2, 0, "ta")] + ); + assert!(selection_overlay(None, &lines).is_empty()); + assert!(selection_overlay(Some(&sel), &[]).is_empty()); + } + #[test] fn scroll_window_keeps_selection_visible() { assert_eq!(scroll_window(0, 5, 8), (0, 5)); // fits, no scroll