Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions assets/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions doc/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
38 changes: 38 additions & 0 deletions src/config/config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)]
Expand Down
19 changes: 18 additions & 1 deletion src/config/tui_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -678,6 +692,8 @@ impl ConfigPanel {
.parse::<bool>()
.map_err(|_| "Invalid desktop_notifications — use true or false")?;

let cursor_style = get(F_CURSOR_STYLE);

Ok(Config {
font: FontConfig { family, size },
window: WindowConfig {
Expand All @@ -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 },
Expand Down
47 changes: 38 additions & 9 deletions src/config/tui_config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
9 changes: 7 additions & 2 deletions src/pane_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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:
Expand Down
21 changes: 19 additions & 2 deletions src/terminal/grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions src/terminal/grid_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
7 changes: 6 additions & 1 deletion src/terminal/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading