diff --git a/CHANGELOG.md b/CHANGELOG.md index a35216a..d7fc250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - add `--maximized` and `--fullscreen` flags to start the window in that mode +- add `window.copy_on_select` to control whether mouse selection auto-copies to the clipboard - persist and restore window size, maximized, and fullscreen state per session - REP (`CSI Ps b`): repeat the last printed character `Ps` times diff --git a/README.md b/README.md index c55a473..02bb319 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ width = 800 height = 600 title = "mmterm" cursor_blink_ms = 500 +copy_on_select = true # auto-copy mouse selection to the clipboard [shell] # program = "/bin/zsh" # defaults to $SHELL diff --git a/assets/config.toml b/assets/config.toml index fbbd7ef..144e2bc 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -11,6 +11,7 @@ title = "mmterm" cursor_blink_ms = 500 inactive_dim = 0.55 detect_urls = true +copy_on_select = true [shell] # program = "/bin/zsh" diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..d667c4e 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -221,6 +221,7 @@ Screenshot capture is a two-step flow: region selection followed by a name promp | window | cursor_blink_ms | uint | `500` | | window | inactive_dim | float | `0.55` | | window | detect_urls | bool | `true` | +| window | copy_on_select | bool | `true` | | terminal | scrollback_lines | uint | `10000` (min 100) | | shell | program | string? | `$SHELL` | | logging | auto_log | bool | `false` | diff --git a/src/config/config_test.rs b/src/config/config_test.rs index 4bd6877..a38f326 100644 --- a/src/config/config_test.rs +++ b/src/config/config_test.rs @@ -157,6 +157,40 @@ palette = [] assert!(cfg.window.detect_urls); } +#[test] +fn default_copy_on_select_value() { + assert!(default_copy_on_select()); +} + +#[test] +fn default_copy_on_select_is_true() { + let cfg = Config::default(); + assert!(cfg.window.copy_on_select); +} + +#[test] +fn copy_on_select_default_applied_when_missing() { + let toml = r###" +[font] +family = "Mono" +size = 14.0 +[window] +width = 800 +height = 600 +title = "t" +cursor_blink_ms = 500 +[shell] +[colors] +background = "#000000" +foreground = "#ffffff" +cursor = "#ffffff" +selection = "#333333" +palette = [] +"###; + let cfg: Config = toml::from_str(toml).expect("parse failed"); + assert!(cfg.window.copy_on_select); +} + #[test] fn save_does_not_panic() { Config::default().save(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 571c12a..c6858e1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -147,6 +147,9 @@ fn default_inactive_dim() -> f32 { fn default_detect_urls() -> bool { true } +fn default_copy_on_select() -> bool { + true +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WindowConfig { @@ -158,6 +161,8 @@ pub struct WindowConfig { pub inactive_dim: f32, #[serde(default = "default_detect_urls")] pub detect_urls: bool, + #[serde(default = "default_copy_on_select")] + pub copy_on_select: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 6060b52..4776180 100644 --- a/src/config/tui_config.rs +++ b/src/config/tui_config.rs @@ -33,6 +33,7 @@ const F_AUTO_UPDATE_CHECK: usize = 37; const F_AUTO_UPDATE_INSTALL: usize = 38; const F_SHELL_INTEGRATION: usize = 39; const F_DESKTOP_NOTIFICATIONS: usize = 40; +const F_COPY_ON_SELECT: usize = 41; const PALETTE_LABELS: [&str; 16] = [ "Palette 0 black", @@ -302,6 +303,14 @@ impl ConfigPanel { section: None, }); + fields.push(Field { + label: "Copy On Select", + hint: "true or false — auto-copy mouse selection to clipboard", + value: cfg.window.copy_on_select.to_string(), + kind: FieldKind::Bool, + section: None, + }); + let mut collapsed = HashSet::new(); collapsed.insert("Palette"); @@ -624,6 +633,9 @@ impl ConfigPanel { let detect_urls = get(F_DETECT_URLS) .parse::() .map_err(|_| "Invalid detect_urls — use true or false")?; + let copy_on_select = get(F_COPY_ON_SELECT) + .parse::() + .map_err(|_| "Invalid copy_on_select — use true or false")?; let shell = { let s = get(F_SHELL); if s.is_empty() { None } else { Some(s) } @@ -687,6 +699,7 @@ impl ConfigPanel { cursor_blink_ms: blink_ms, inactive_dim, detect_urls, + copy_on_select, }, shell: ShellConfig { program: shell }, terminal: TerminalConfig { scrollback_lines }, diff --git a/src/config/tui_config_test.rs b/src/config/tui_config_test.rs index 5f8381b..5731115 100644 --- a/src/config/tui_config_test.rs +++ b/src/config/tui_config_test.rs @@ -10,8 +10,8 @@ fn make_panel() -> ConfigPanel { #[test] fn from_config_has_correct_field_count() { let panel = make_panel(); - // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify = 41 - assert_eq!(panel.fields.len(), 41); + // 9 base + 1 scrollback + 2 logging + 1 theme + 4 colors + 16 palette + 1 status_bar + 3 general + 2 updates + 2 shell/notify + 1 copy_on_select = 42 + assert_eq!(panel.fields.len(), 42); } #[test] @@ -305,6 +305,7 @@ fn distinct_config() -> Config { cursor_blink_ms: 523, inactive_dim: 0.42, detect_urls: true, + copy_on_select: false, }, shell: ShellConfig { program: Some("/bin/xyzsh".into()), @@ -375,6 +376,7 @@ fn field_index_sanity() { F_AUTO_UPDATE_INSTALL, F_SHELL_INTEGRATION, F_DESKTOP_NOTIFICATIONS, + F_COPY_ON_SELECT, ]; occupied.extend((0..16).map(|i| F_PALETTE + i)); occupied.sort_unstable(); @@ -433,6 +435,18 @@ fn build_config_roundtrip_toggles_desktop_notifications() { } } +#[test] +fn build_config_roundtrip_toggles_copy_on_select() { + let mut panel = make_panel(); + assert_eq!(panel.fields[F_COPY_ON_SELECT].value, "true"); + panel.fields[F_COPY_ON_SELECT].value = "false".to_string(); + if let ConfigAction::Save(cfg) = panel.save() { + assert!(!cfg.window.copy_on_select); + } else { + panic!("expected Save action"); + } +} + #[test] fn build_config_shell_empty_becomes_none() { let mut panel = make_panel(); @@ -680,8 +694,8 @@ fn palette_collapsed_by_default() { #[test] fn visible_indices_hides_palette_body() { let panel = make_panel(); - // 41 total - 15 palette body fields = 26 visible - assert_eq!(panel.visible_indices().len(), 26); + // 42 total - 15 palette body fields = 27 visible + assert_eq!(panel.visible_indices().len(), 27); } #[test] @@ -690,7 +704,7 @@ fn toggle_on_palette_header_expands() { panel.selected = F_PALETTE; panel.toggle_collapse(); assert!(!panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 41); + assert_eq!(panel.visible_indices().len(), 42); } #[test] @@ -700,7 +714,7 @@ fn toggle_twice_restores_collapsed() { panel.toggle_collapse(); panel.toggle_collapse(); assert!(panel.collapsed.contains("Palette")); - assert_eq!(panel.visible_indices().len(), 26); + assert_eq!(panel.visible_indices().len(), 27); } #[test] @@ -755,10 +769,10 @@ fn move_up_skips_collapsed_palette() { #[test] fn move_down_at_last_visible_clamps() { let mut panel = make_panel(); - // F_DESKTOP_NOTIFICATIONS is the last field and is always visible - panel.selected = F_DESKTOP_NOTIFICATIONS; + // F_COPY_ON_SELECT is the last field and is always visible + panel.selected = F_COPY_ON_SELECT; panel.handle_down(); - assert_eq!(panel.selected, F_DESKTOP_NOTIFICATIONS); + assert_eq!(panel.selected, F_COPY_ON_SELECT); } #[test] diff --git a/src/input/mouse_ops.rs b/src/input/mouse_ops.rs index 05ea1fb..5af7a8a 100644 --- a/src/input/mouse_ops.rs +++ b/src/input/mouse_ops.rs @@ -138,7 +138,9 @@ impl App { cur_row: row, anchored: true, }; - self.copy_selection_to_clipboard(start, row, end, row); + if self.state.config.window.copy_on_select { + self.copy_selection_to_clipboard(start, row, end, row); + } } self.state.mouse_selecting = false; if let Some(w) = &self.window { @@ -185,7 +187,9 @@ impl App { } return; } - self.copy_selection_to_clipboard(start_col, start_row, cur_col, cur_row); + if self.state.config.window.copy_on_select { + self.copy_selection_to_clipboard(start_col, start_row, cur_col, cur_row); + } } if let Some(w) = &self.window { w.request_redraw(); @@ -224,3 +228,7 @@ impl App { } } } + +#[cfg(test)] +#[path = "mouse_ops_test.rs"] +mod tests; diff --git a/src/input/mouse_ops_test.rs b/src/input/mouse_ops_test.rs new file mode 100644 index 0000000..3937d92 --- /dev/null +++ b/src/input/mouse_ops_test.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; + +use crate::App; +use crate::app_state::{AppState, TabState}; +use crate::config::Config; +use crate::dpi::Logical; +use crate::input::InputMode; +use crate::renderer::FontMetrics; +use crate::ui::layout::Layout; + +/// Builds an EventLoop that works from any thread (needed for tests). +/// Falls back gracefully if no display is available. Mirrors `pane_ops_test.rs`. +fn make_event_loop() -> Option> { + #[cfg(target_os = "linux")] + { + use winit::event_loop::EventLoopBuilder; + + #[cfg(feature = "x11")] + { + use winit::platform::x11::EventLoopBuilderExtX11; + if let Ok(el) = EventLoopBuilder::new().with_any_thread(true).build() { + return Some(el); + } + } + + #[cfg(feature = "wayland")] + { + use winit::platform::wayland::EventLoopBuilderExtWayland; + if let Ok(el) = EventLoopBuilder::new().with_any_thread(true).build() { + return Some(el); + } + } + + { + use winit::platform::x11::EventLoopBuilderExtX11; + EventLoopBuilder::new().with_any_thread(true).build().ok() + } + } + + #[cfg(not(target_os = "linux"))] + { + winit::event_loop::EventLoop::new().ok() + } +} + +fn metrics(font_px: f32, cw: u32, ch: u32) -> FontMetrics { + FontMetrics { + font_px, + cell_width: cw, + cell_height: ch, + baseline: ch.saturating_sub(3), + } +} + +fn empty_tab() -> TabState { + TabState { + panes: HashMap::new(), + layout: Layout::new(1, 800, 600), + active: 1, + name: None, + zoomed: false, + has_activity: false, + bell_flash_start: None, + bell_flash_until: None, + bell_cooldown_until: None, + passthrough: false, + mode: InputMode::Insert, + } +} + +/// Headless App with a single pane (id 1) whose grid contains the word "hi" +/// starting at (row 0, col 0). Returns `None` when no display is available. +fn app_with_word() -> Option { + let el = make_event_loop()?; + let proxy = el.create_proxy(); + std::mem::forget(el); + let mut app = App::new(Config::default(), proxy, None, None); + + let mut tab = empty_tab(); + let entry = AppState::test_pane_entry(Logical(16.0), metrics(16.0, 8, 16)); + if let Some(mut grid) = entry.pane.grid_write() { + grid.write_char('h'); + grid.write_char('i'); + } + tab.panes.insert(1, entry); + app.state.tabs.push(tab); + app.state.active_tab = app.state.tabs.len() - 1; + app.state.next_pane_id = 2; + // Start from a clean slate so `get_or_insert_with` is an accurate + // "did the copy path run" signal. + app.state.clipboard = None; + Some(app) +} + +fn set_drag_selection(app: &mut App) { + app.state.tab_mut().mode = InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 1, + cur_row: 0, + anchored: true, + }; +} + +// ── finish_mouse_selection (drag release) ────────────────────────────────────── + +#[test] +fn finish_selection_does_not_copy_when_disabled() { + let Some(mut app) = app_with_word() else { + return; + }; + app.state.config.window.copy_on_select = false; + set_drag_selection(&mut app); + app.finish_mouse_selection(); + // Copy path never reached → clipboard was not lazily created. + assert!(app.state.clipboard.is_none()); + assert!(matches!(app.state.mode(), InputMode::Insert)); +} + +#[test] +fn finish_selection_copies_when_enabled() { + // Skip when the system clipboard is unavailable (e.g. headless CI). + if arboard::Clipboard::new().is_err() { + return; + } + let Some(mut app) = app_with_word() else { + return; + }; + app.state.config.window.copy_on_select = true; + set_drag_selection(&mut app); + app.finish_mouse_selection(); + // Copy path reached → clipboard lazily created for the write. + assert!(app.state.clipboard.is_some()); + assert!(matches!(app.state.mode(), InputMode::Insert)); +} + +// ── select_word_at (double-click) ────────────────────────────────────────────── + +#[test] +fn word_select_does_not_copy_when_disabled() { + let Some(mut app) = app_with_word() else { + return; + }; + app.state.config.window.copy_on_select = false; + // Pixel (1, 23) lands on grid cell (col 0, row 0) with 8x16 cells and a + // 22px tab bar → selects the word "hi". + app.select_word_at(1.0, 23.0); + assert!(app.state.clipboard.is_none()); + // The word stays highlighted in anchored Visual mode. + assert!(matches!( + app.state.mode(), + InputMode::Visual { anchored: true, .. } + )); +} + +#[test] +fn word_select_copies_when_enabled() { + if arboard::Clipboard::new().is_err() { + return; + } + let Some(mut app) = app_with_word() else { + return; + }; + app.state.config.window.copy_on_select = true; + app.select_word_at(1.0, 23.0); + assert!(app.state.clipboard.is_some()); +}