From f6d523dfa75a1de87ffe9a86f2a8f54c5ebc5498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Thu, 16 Jul 2026 15:08:34 -0400 Subject: [PATCH] feat(config,terminal): add window.cursor_style for default cursor shape Introduce a window.cursor_style config option (block | beam | underline) that seeds the initial DECSCUSR cursor shape and is restored on reset and on `CSI 0 SP q`. Grid gains a default_cursor_shape field, the parser resets to it on param 0, and the config panel exposes a Select field whose cycling no longer fires a spurious theme preview. --- CHANGELOG.md | 1 + README.md | 1 + assets/config.toml | 2 ++ doc/SPEC.md | 1 + src/config/config_test.rs | 38 ++++++++++++++++++++++++++++ src/config/mod.rs | 5 ++++ src/config/tui_config.rs | 19 +++++++++++++- src/config/tui_config_test.rs | 47 ++++++++++++++++++++++++++++------- src/pane_ops.rs | 9 +++++-- src/terminal/grid.rs | 21 ++++++++++++++-- src/terminal/grid_test.rs | 31 +++++++++++++++++++++++ src/terminal/parser.rs | 7 +++++- src/terminal/parser_test.rs | 11 ++++++++ 13 files changed, 178 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a35216a..2703079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- add `window.cursor_style` to set the default cursor shape (block, beam, or underline) - add `--maximized` and `--fullscreen` flags to start the window in that mode - 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..f2080cf 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ width = 800 height = 600 title = "mmterm" cursor_blink_ms = 500 +cursor_style = "block" # default cursor shape: block | beam | underline [shell] # program = "/bin/zsh" # defaults to $SHELL diff --git a/assets/config.toml b/assets/config.toml index fbbd7ef..2fd1718 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -11,6 +11,8 @@ title = "mmterm" cursor_blink_ms = 500 inactive_dim = 0.55 detect_urls = true +# default cursor shape: block | beam | underline +cursor_style = "block" [shell] # program = "/bin/zsh" diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..09eaf7b 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 | cursor_style | string | `"block"` (block, beam, or underline) | | 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..92af6b7 100644 --- a/src/config/config_test.rs +++ b/src/config/config_test.rs @@ -157,6 +157,44 @@ palette = [] assert!(cfg.window.detect_urls); } +#[test] +fn default_cursor_style_value() { + assert_eq!(default_cursor_style(), "block"); + assert_eq!(Config::default().window.cursor_style, "block"); +} + +#[test] +fn cursor_style_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_eq!(cfg.window.cursor_style, "block"); +} + +#[test] +fn config_roundtrip_preserves_cursor_style() { + let mut cfg = Config::default(); + cfg.window.cursor_style = "beam".into(); + let s = toml::to_string_pretty(&cfg).expect("serialize failed"); + let restored: Config = toml::from_str(&s).expect("deserialize failed"); + assert_eq!(restored.window.cursor_style, "beam"); +} + #[test] fn save_does_not_panic() { Config::default().save(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 571c12a..6e77300 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_cursor_style() -> String { + "block".into() +} #[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_cursor_style")] + pub cursor_style: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 6060b52..fad2db5 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_CURSOR_STYLE: usize = 41; const PALETTE_LABELS: [&str; 16] = [ "Palette 0 black", @@ -301,6 +302,13 @@ impl ConfigPanel { kind: FieldKind::Bool, section: None, }); + fields.push(Field { + label: "Cursor Style", + hint: "← / → to cycle: block, beam, underline", + value: cfg.window.cursor_style.clone(), + kind: FieldKind::Select(vec!["block".into(), "beam".into(), "underline".into()]), + section: None, + }); let mut collapsed = HashSet::new(); collapsed.insert("Palette"); @@ -577,7 +585,13 @@ impl ConfigPanel { let next = ((cur as i32 + delta).rem_euclid(len as i32)) as usize; let name = options[next].clone(); field.value = name.clone(); - ConfigAction::PreviewTheme(name) + // Only the Theme field triggers a live preview; other Select fields + // (e.g. Cursor Style) just update their value. + if self.selected == F_THEME_NAME { + ConfigAction::PreviewTheme(name) + } else { + ConfigAction::None + } } fn validate(&self, val: &str) -> bool { @@ -678,6 +692,8 @@ impl ConfigPanel { .parse::() .map_err(|_| "Invalid desktop_notifications — use true or false")?; + let cursor_style = get(F_CURSOR_STYLE); + Ok(Config { font: FontConfig { family, size }, window: WindowConfig { @@ -687,6 +703,7 @@ impl ConfigPanel { cursor_blink_ms: blink_ms, inactive_dim, detect_urls, + cursor_style, }, 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..8adf79c 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 cursor style = 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, + cursor_style: "beam".into(), }, 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_CURSOR_STYLE, ]; occupied.extend((0..16).map(|i| F_PALETTE + i)); occupied.sort_unstable(); @@ -611,6 +613,33 @@ fn field_select_cycles_forward_wraps_at_end() { assert!(matches!(action, ConfigAction::PreviewTheme(ref n) if n == "alpha")); } +#[test] +fn cursor_style_cycles_without_previewing_theme() { + let mut panel = make_panel(); + panel.selected = F_CURSOR_STYLE; + assert_eq!(panel.fields[F_CURSOR_STYLE].value, "block"); + // Cycling a non-theme Select must update the value but emit no PreviewTheme. + let action = panel.handle_right(); + assert!(matches!(action, ConfigAction::None)); + assert_eq!(panel.fields[F_CURSOR_STYLE].value, "beam"); + let action = panel.handle_right(); + assert!(matches!(action, ConfigAction::None)); + assert_eq!(panel.fields[F_CURSOR_STYLE].value, "underline"); + // Wrap back to the first option. + let action = panel.handle_right(); + assert!(matches!(action, ConfigAction::None)); + assert_eq!(panel.fields[F_CURSOR_STYLE].value, "block"); +} + +#[test] +fn build_config_roundtrip_preserves_cursor_style() { + let mut cfg = Config::default(); + cfg.window.cursor_style = "underline".into(); + let panel = ConfigPanel::from_config(&cfg); + let rebuilt = panel.build_config().expect("must rebuild"); + assert_eq!(rebuilt.window.cursor_style, "underline"); +} + #[test] fn handle_right_on_non_select_field_returns_none() { let mut panel = make_panel(); @@ -680,8 +709,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 +719,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 +729,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 +784,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_CURSOR_STYLE is the last field and is always visible + panel.selected = F_CURSOR_STYLE; panel.handle_down(); - assert_eq!(panel.selected, F_DESKTOP_NOTIFICATIONS); + assert_eq!(panel.selected, F_CURSOR_STYLE); } #[test] diff --git a/src/pane_ops.rs b/src/pane_ops.rs index 7f57df5..08987bd 100644 --- a/src/pane_ops.rs +++ b/src/pane_ops.rs @@ -33,7 +33,7 @@ impl App { let pad2 = self.scale.chrome(crate::ui::layout::PANE_PADDING) * 2; let (cols, rows) = metrics.grid_size_for(w.saturating_sub(pad2), h.saturating_sub(pad2)); let t = &self.state.theme; - let grid = Arc::new(RwLock::new(Grid::with_colors( + let mut grid = Grid::with_colors( cols, rows, GridColors { @@ -44,7 +44,12 @@ impl App { palette: t.palette, }, self.state.config.terminal.scrollback_lines, - ))); + ); + let shape = + crate::terminal::grid::cursor_shape_from_str(&self.state.config.window.cursor_style); + grid.default_cursor_shape = shape; + grid.cursor_shape = shape; + let grid = Arc::new(RwLock::new(grid)); let pane = Pane::new(grid.clone(), rect); // Bounded channel caps PTY output backlog at ~1 MB (256 × 4 KB chunks), // matching WezTerm's socketpair size. Provides natural backpressure: diff --git a/src/terminal/grid.rs b/src/terminal/grid.rs index dd38f77..e85f977 100644 --- a/src/terminal/grid.rs +++ b/src/terminal/grid.rs @@ -15,6 +15,20 @@ pub enum CursorShape { Beam, } +/// Map a config string to a [`CursorShape`] (case-insensitive). +/// Unknown values fall back to `Block` with a warning. +pub fn cursor_shape_from_str(s: &str) -> CursorShape { + match s.to_ascii_lowercase().as_str() { + "block" => CursorShape::Block, + "beam" => CursorShape::Beam, + "underline" => CursorShape::Underline, + other => { + log::warn!("unknown cursor_style {other:?}, falling back to \"block\""); + CursorShape::Block + } + } +} + /// OSC 133 shell integration state. #[derive(Clone, Copy, Debug, PartialEq, Default)] pub enum ShellState { @@ -195,6 +209,8 @@ pub struct Grid { pub cursor_visible: bool, // DECSCUSR: cursor shape set by the running program pub cursor_shape: CursorShape, + // Configured default cursor shape; restored on reset and `CSI 0 SP q`. + pub default_cursor_shape: CursorShape, // Bracketed paste mode (?2004) pub bracketed_paste: bool, // Mouse reporting mode: 0=off, 1000=click, 1002=button-motion, 1003=any-motion @@ -282,6 +298,7 @@ impl Grid { application_cursor_keys: false, cursor_visible: true, cursor_shape: CursorShape::Block, + default_cursor_shape: CursorShape::Block, bracketed_paste: false, mouse_mode: 0, mouse_sgr: false, @@ -337,7 +354,7 @@ impl Grid { self.cursor_col = 0; self.cursor_row = 0; self.cursor_visible = true; - self.cursor_shape = CursorShape::Block; + self.cursor_shape = self.default_cursor_shape; self.scroll_top = 0; self.scroll_bottom = self.max_row(); self.reset_sgr(); @@ -1036,7 +1053,7 @@ impl Grid { self.current_url = None; self.osc_title = None; self.cursor_visible = true; - self.cursor_shape = CursorShape::Block; + self.cursor_shape = self.default_cursor_shape; self.bracketed_paste = false; self.mouse_mode = 0; self.mouse_sgr = false; diff --git a/src/terminal/grid_test.rs b/src/terminal/grid_test.rs index 40caf5d..3037719 100644 --- a/src/terminal/grid_test.rs +++ b/src/terminal/grid_test.rs @@ -1224,3 +1224,34 @@ fn reset_clears_shell_integration_state() { assert_eq!(g.shell_state, ShellState::Unknown); assert_eq!(g.last_exit_code, None); } + +#[test] +fn reset_restores_configured_default_cursor_shape() { + let mut g = make_grid(10, 5); + g.default_cursor_shape = CursorShape::Beam; + g.cursor_shape = CursorShape::Underline; + g.reset(); + assert_eq!(g.cursor_shape, CursorShape::Beam); +} + +#[test] +fn alternate_screen_restores_configured_default_cursor_shape() { + let mut g = make_grid(10, 5); + g.default_cursor_shape = CursorShape::Beam; + g.cursor_shape = CursorShape::Underline; + g.enter_alternate_screen(); + assert_eq!(g.cursor_shape, CursorShape::Beam); +} + +#[test] +fn cursor_shape_from_str_maps_all_variants() { + assert_eq!(cursor_shape_from_str("block"), CursorShape::Block); + assert_eq!(cursor_shape_from_str("Block"), CursorShape::Block); + assert_eq!(cursor_shape_from_str("beam"), CursorShape::Beam); + assert_eq!(cursor_shape_from_str("BEAM"), CursorShape::Beam); + assert_eq!(cursor_shape_from_str("underline"), CursorShape::Underline); + assert_eq!(cursor_shape_from_str("Underline"), CursorShape::Underline); + // Unknown values fall back to Block. + assert_eq!(cursor_shape_from_str("nonsense"), CursorShape::Block); + assert_eq!(cursor_shape_from_str(""), CursorShape::Block); +} diff --git a/src/terminal/parser.rs b/src/terminal/parser.rs index 767fa1b..249debf 100644 --- a/src/terminal/parser.rs +++ b/src/terminal/parser.rs @@ -321,7 +321,12 @@ impl Perform for Performer<'_> { 'c' if p0 == 0 => self.grid.pending_responses.extend_from_slice(b"\x1b[?1;0c"), // DECSCUSR: cursor shape (CSI Ps SP q) 'q' if intermediates == b" " => { - self.grid.cursor_shape = cursor_shape_from_param(p0); + // `CSI 0 SP q` resets to the configured default shape. + self.grid.cursor_shape = if p0 == 0 { + self.grid.default_cursor_shape + } else { + cursor_shape_from_param(p0) + }; } // Set scroll region 'r' => self.handle_scroll_region(p0, p1), diff --git a/src/terminal/parser_test.rs b/src/terminal/parser_test.rs index 0bfa648..c2676b2 100644 --- a/src/terminal/parser_test.rs +++ b/src/terminal/parser_test.rs @@ -919,6 +919,17 @@ fn decscusr_beam_variants_set_beam_shape() { } } +#[test] +fn decscusr_zero_resets_to_default_cursor_shape() { + use super::super::grid::CursorShape; + let mut p = make_parser(80, 24); + p.grid.default_cursor_shape = CursorShape::Beam; + p.process(b"\x1b[2 q"); // explicit block + assert_eq!(p.grid.cursor_shape, CursorShape::Block); + p.process(b"\x1b[0 q"); // reset to configured default + assert_eq!(p.grid.cursor_shape, CursorShape::Beam); +} + #[test] fn decscusr_resets_to_block_on_alternate_screen() { let mut p = make_parser(80, 24);