diff --git a/CHANGELOG.md b/CHANGELOG.md index a35216a..bbb58de 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.url_opener` to configure the command used to open URLs - 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..73f715f 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ width = 800 height = 600 title = "mmterm" cursor_blink_ms = 500 +url_opener = "" # command to open URLs (empty = xdg-open/open) [shell] # program = "/bin/zsh" # defaults to $SHELL diff --git a/assets/config.toml b/assets/config.toml index fbbd7ef..2bad5be 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -11,6 +11,9 @@ title = "mmterm" cursor_blink_ms = 500 inactive_dim = 0.55 detect_urls = true +# Command used to open URLs on click. Empty = system default (xdg-open/open). +# url_opener = "firefox" +url_opener = "" [shell] # program = "/bin/zsh" diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..e38f8d8 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 | url_opener | string | `""` | | 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..cecfea9 100644 --- a/src/config/config_test.rs +++ b/src/config/config_test.rs @@ -134,6 +134,44 @@ fn default_detect_urls_value() { assert!(default_detect_urls()); } +#[test] +fn default_url_opener_is_empty() { + let cfg = Config::default(); + assert!(cfg.window.url_opener.is_empty()); +} + +#[test] +fn url_opener_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.url_opener.is_empty()); +} + +#[test] +fn config_roundtrip_preserves_url_opener() { + let mut cfg = Config::default(); + cfg.window.url_opener = "firefox".to_string(); + let s = toml::to_string_pretty(&cfg).expect("serialize failed"); + let restored: Config = toml::from_str(&s).expect("deserialize failed"); + assert_eq!(restored.window.url_opener, "firefox"); +} + #[test] fn detect_urls_default_applied_when_missing() { let toml = r###" diff --git a/src/config/mod.rs b/src/config/mod.rs index 571c12a..f942f05 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -158,6 +158,10 @@ pub struct WindowConfig { pub inactive_dim: f32, #[serde(default = "default_detect_urls")] pub detect_urls: bool, + /// Command used to open URLs. Empty string means the per-OS default + /// (xdg-open on Linux, open on macOS, cmd /c start on Windows). + #[serde(default)] + pub url_opener: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 6060b52..8fe2e00 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_URL_OPENER: usize = 41; const PALETTE_LABELS: [&str; 16] = [ "Palette 0 black", @@ -302,6 +303,14 @@ impl ConfigPanel { section: None, }); + fields.push(Field { + label: "URL Opener", + hint: "command to open URLs (empty = system default)", + value: cfg.window.url_opener.clone(), + kind: FieldKind::OptText, + section: None, + }); + let mut collapsed = HashSet::new(); collapsed.insert("Palette"); @@ -678,6 +687,8 @@ impl ConfigPanel { .parse::() .map_err(|_| "Invalid desktop_notifications — use true or false")?; + let url_opener = get(F_URL_OPENER); + Ok(Config { font: FontConfig { family, size }, window: WindowConfig { @@ -687,6 +698,7 @@ impl ConfigPanel { cursor_blink_ms: blink_ms, inactive_dim, detect_urls, + url_opener, }, 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..432ba35 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 url_opener = 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, + url_opener: "/usr/bin/xdg-open-x".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_URL_OPENER, ]; 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_roundtrips_url_opener() { + let mut panel = make_panel(); + assert!(panel.fields[F_URL_OPENER].value.is_empty()); + panel.fields[F_URL_OPENER].value = "firefox".to_string(); + if let ConfigAction::Save(cfg) = panel.save() { + assert_eq!(cfg.window.url_opener, "firefox"); + } 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_URL_OPENER is the last field and is always visible + panel.selected = F_URL_OPENER; panel.handle_down(); - assert_eq!(panel.selected, F_DESKTOP_NOTIFICATIONS); + assert_eq!(panel.selected, F_URL_OPENER); } #[test] diff --git a/src/input/mouse_ops.rs b/src/input/mouse_ops.rs index 05ea1fb..df2c3a9 100644 --- a/src/input/mouse_ops.rs +++ b/src/input/mouse_ops.rs @@ -6,7 +6,11 @@ use crate::input::InputMode; use crate::App; -pub(super) fn open_url(url: &str) { +pub(super) fn open_url(url: &str, opener: &str) { + if !opener.is_empty() { + let _ = std::process::Command::new(opener).arg(url).spawn(); + return; + } #[cfg(target_os = "linux")] { let _ = std::process::Command::new("xdg-open").arg(url).spawn(); @@ -178,7 +182,8 @@ impl App { self.state.tab_mut().mode = InputMode::Insert; if start_col == cur_col && start_row == cur_row { if let Some(url) = self.state.hovered_url.clone() { - open_url(&url); + let opener = self.state.config.window.url_opener.clone(); + open_url(&url, &opener); } if let Some(w) = &self.window { w.request_redraw();