From ce39dfba83ae75116eb4faf111bf174386c10be7 Mon Sep 17 00:00:00 2001 From: Lunor Date: Wed, 15 Apr 2026 16:41:07 +0700 Subject: [PATCH 1/5] feat: change mode 1002 -> 1003 to get any event mouse --- crates/edit/src/bin/edit/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index 30ad149b20b..96914f6c440 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -554,7 +554,7 @@ impl Drop for RestoreModes { // Same as in the beginning but in the reverse order. // It also includes DECSCUSR 0 to reset the cursor style and DECTCEM to show the cursor. // We specifically don't reset mode 1036, because most applications expect it to be set nowadays. - sys::write_stdout("\x1b[0 q\x1b[?25h\x1b]0;\x07\x1b[?1002;1006;2004l\x1b[?1049l"); + sys::write_stdout("\x1b[0 q\x1b[?25h\x1b]0;\x07\x1b[?1003;1006;2004l\x1b[?1049l"); } } @@ -563,11 +563,11 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) // 1049: Alternative Screen Buffer // I put the ASB switch in the beginning, just in case the terminal performs // some additional state tracking beyond the modes we enable/disable. - // 1002: Cell Motion Mouse Tracking + // 1003: Any Event Mouse Tracking // 1006: SGR Mouse Mode // 2004: Bracketed Paste Mode // 1036: Xterm: "meta sends escape" (Alt keypresses should be encoded with ESC + char) - "\x1b[?1049h\x1b[?1002;1006;2004h\x1b[?1036h", + "\x1b[?1049h\x1b[?1003;1006;2004h\x1b[?1036h", // OSC 4 color table requests for indices 0 through 15 (base colors). "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?\x07", "\x1b]4;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07", From a6d2afb33118e1187a10b6eec49491c2a4e7b1c1 Mon Sep 17 00:00:00 2001 From: Lunor Date: Wed, 15 Apr 2026 16:41:45 +0700 Subject: [PATCH 2/5] feat: add hightlight hover menu --- crates/edit/src/tui.rs | 135 +++++++++++++++++++++++++++++++---------- 1 file changed, 102 insertions(+), 33 deletions(-) diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 314cdc30cc6..f51b078884f 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -348,6 +348,10 @@ pub struct Tui { mouse_click_counter: CoordType, /// The path to the node that was clicked on. mouse_down_node_path: Vec, + /// Path to the node currently under the mouse cursor. + hovered_node_path: Vec, + /// This is check mouse hover enabled + mouse_hover_enabled: bool, /// The position of the first click in a double/triple click series. first_click_position: Point, /// The node ID of the node that was first clicked on @@ -407,6 +411,8 @@ impl Tui { mouse_is_drag: false, mouse_click_counter: 0, mouse_down_node_path: Vec::with_capacity(16), + hovered_node_path: Vec::with_capacity(16), + mouse_hover_enabled: true, first_click_position: Point::MIN, first_click_target: 0, @@ -422,10 +428,16 @@ impl Tui { read_timeout: time::Duration::MAX, }; Self::clean_node_path(&mut tui.mouse_down_node_path); + Self::clean_node_path(&mut tui.hovered_node_path); Self::clean_node_path(&mut tui.focused_node_path); Ok(tui) } + // Helper to check if a node is in the hover path + fn is_subtree_hovered(&self, node: &Node) -> bool { + self.hovered_node_path.get(node.depth) == Some(&node.id) + } + /// Sets up the framebuffer's color palette. pub fn setup_indexed_colors(&mut self, colors: [StraightRgba; INDEXED_COLORS_COUNT]) { self.framebuffer.set_indexed_colors(colors); @@ -548,6 +560,7 @@ impl Tui { self.size = resize; } Some(Input::Text(text)) => { + self.mouse_hover_enabled = false; input_text = Some(text); // TODO: the .len()==1 check causes us to ignore keyboard inputs that are faster than we process them. // For instance, imagine the user presses "A" twice and we happen to read it in a single chunk. @@ -560,15 +573,18 @@ impl Tui { } } Some(Input::Paste(paste)) => { + self.mouse_hover_enabled = false; let clipboard = self.clipboard_mut(); clipboard.write(paste); clipboard.mark_as_synchronized(); input_keyboard = Some(kbmod::CTRL | vk::V); } Some(Input::Keyboard(keyboard)) => { + self.mouse_hover_enabled = false; input_keyboard = Some(keyboard); } Some(Input::Mouse(mouse)) => { + self.mouse_hover_enabled = true; let mut next_state = mouse.state; let next_position = mouse.position; let next_scroll = mouse.scroll; @@ -583,38 +599,69 @@ impl Tui { let mut hovered_node = None; // Needed for `mouse_down` let mut focused_node = None; // Needed for `mouse_down` and `is_click` - if mouse_down || mouse_up { - // Roots (aka windows) are ordered in Z order, so we iterate - // them in reverse order, from topmost to bottommost. - for root in self.prev_tree.iterate_roots_rev() { - // Find the node that contains the cursor. - Tree::visit_all(root, root, true, |node| { - let n = node.borrow(); - if !n.outer_clipped.contains(next_position) { - // Skip the entire sub-tree, because it doesn't contain the cursor. - return VisitControl::SkipChildren; - } - hovered_node = Some(node); - if n.attributes.focusable { - focused_node = Some(node); - } - VisitControl::Continue - }); - // This root/window contains the cursor. - // We don't care about any lower roots. - if hovered_node.is_some() { - break; + // Calculate the hovered node for ALL mouse events, not just clicks. + // Roots (aka windows) are ordered in Z order... + for root in self.prev_tree.iterate_roots_rev() { + Tree::visit_all(root, root, true, |node| { + let n = node.borrow(); + if !n.outer_clipped.contains(next_position) { + return VisitControl::SkipChildren; } - - // This root is modal and swallows all clicks, - // no matter whether the click was inside it or not. - if matches!(root.borrow().content, NodeContent::Modal(_)) { - break; + hovered_node = Some(node); + if n.attributes.focusable { + focused_node = Some(node); } + VisitControl::Continue + }); + if hovered_node.is_some() { + break; } + if matches!(root.borrow().content, NodeContent::Modal(_)) { + break; + } + } + + // Update the hovered path and trigger redraw if it changed + let mut next_hovered_path = Vec::with_capacity(16); + Self::build_node_path(hovered_node, &mut next_hovered_path); + if self.hovered_node_path != next_hovered_path { + self.hovered_node_path = next_hovered_path; + self.needs_more_settling(); } + // if mouse_down || mouse_up { + // // Roots (aka windows) are ordered in Z order, so we iterate + // // them in reverse order, from topmost to bottommost. + // for root in self.prev_tree.iterate_roots_rev() { + // // Find the node that contains the cursor. + // Tree::visit_all(root, root, true, |node| { + // let n = node.borrow(); + // if !n.outer_clipped.contains(next_position) { + // // Skip the entire sub-tree, because it doesn't contain the cursor. + // return VisitControl::SkipChildren; + // } + // hovered_node = Some(node); + // if n.attributes.focusable { + // focused_node = Some(node); + // } + // VisitControl::Continue + // }); + + // // This root/window contains the cursor. + // // We don't care about any lower roots. + // if hovered_node.is_some() { + // break; + // } + + // // This root is modal and swallows all clicks, + // // no matter whether the click was inside it or not. + // if matches!(root.borrow().content, NodeContent::Modal(_)) { + // break; + // } + // } + // } + if is_scroll { next_state = self.mouse_state; } else if is_drag { @@ -3165,15 +3212,25 @@ impl<'a> Context<'a, '_> { && !contains_focus && self.consume_shortcut(kbmod::ALT | InputKey::new(accelerator as u32)); - if contains_focus || keyboard_focus { + // If the menubar is already active (another menu is open) and we are hovered, + // steal the focus to open this menu automatically. + if !contains_focus && self.is_hovered() { + let menubar_active = self.tui.is_subtree_focused(&self.tree.current_node.borrow()); + if menubar_active { + self.steal_focus(); + } + } + + let is_highlighted = self.is_focused() || self.is_hovered(); + if is_highlighted { + self.attr_background_rgba(self.indexed(IndexedColor::Green)); + self.attr_foreground_rgba(self.contrasted(self.indexed(IndexedColor::Green))); + } else if contains_focus || keyboard_focus { self.attr_background_rgba(self.tui.floater_default_bg); self.attr_foreground_rgba(self.tui.floater_default_fg); + } - if self.is_focused() { - self.attr_background_rgba(self.indexed(IndexedColor::Green)); - self.attr_foreground_rgba(self.contrasted(self.indexed(IndexedColor::Green))); - } - + if contains_focus || keyboard_focus { self.next_block_id_mixin(mixin); self.table_begin("flyout"); self.attr_float(FloatSpec { @@ -3186,6 +3243,9 @@ impl<'a> Context<'a, '_> { self.attr_border(); self.attr_focus_well(); + self.attr_background_rgba(self.tui.floater_default_bg); + self.attr_foreground_rgba(self.tui.floater_default_fg); + if keyboard_focus { self.steal_focus(); } @@ -3223,7 +3283,7 @@ impl<'a> Context<'a, '_> { self.inherit_focus(); } - if self.is_focused() { + if self.is_focused() || self.is_hovered() { self.attr_background_rgba(self.indexed(IndexedColor::Green)); self.attr_foreground_rgba(self.contrasted(self.indexed(IndexedColor::Green))); } @@ -3280,6 +3340,15 @@ impl<'a> Context<'a, '_> { self.table_end(); } + /// Returns whether the current node is hovered. + pub fn is_hovered(&mut self) -> bool { + if !self.tui.mouse_hover_enabled { + return false; + } + let last_node = self.tree.last_node.borrow(); + self.tui.is_subtree_hovered(&last_node) + } + /// Renders a button label with an optional accelerator character /// May also renders a checkbox or square brackets for inline buttons fn button_label(&mut self, classname: &'static str, text: &str, style: ButtonStyle) { From baf008c6f21cf7005fe557113b03e64c6c4ab67d Mon Sep 17 00:00:00 2001 From: Lunor Date: Wed, 15 Apr 2026 16:49:40 +0700 Subject: [PATCH 3/5] chore: remove part not use --- crates/edit/src/tui.rs | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index f51b078884f..3b7cabfe317 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -630,38 +630,6 @@ impl Tui { self.needs_more_settling(); } - // if mouse_down || mouse_up { - // // Roots (aka windows) are ordered in Z order, so we iterate - // // them in reverse order, from topmost to bottommost. - // for root in self.prev_tree.iterate_roots_rev() { - // // Find the node that contains the cursor. - // Tree::visit_all(root, root, true, |node| { - // let n = node.borrow(); - // if !n.outer_clipped.contains(next_position) { - // // Skip the entire sub-tree, because it doesn't contain the cursor. - // return VisitControl::SkipChildren; - // } - // hovered_node = Some(node); - // if n.attributes.focusable { - // focused_node = Some(node); - // } - // VisitControl::Continue - // }); - - // // This root/window contains the cursor. - // // We don't care about any lower roots. - // if hovered_node.is_some() { - // break; - // } - - // // This root is modal and swallows all clicks, - // // no matter whether the click was inside it or not. - // if matches!(root.borrow().content, NodeContent::Modal(_)) { - // break; - // } - // } - // } - if is_scroll { next_state = self.mouse_state; } else if is_drag { From 3533860049b702d293874b3378ec56012fe6dc85 Mon Sep 17 00:00:00 2001 From: Lunor Date: Tue, 4 Aug 2026 09:37:09 +0700 Subject: [PATCH 4/5] chore: fix missing bracket --- crates/edit/src/tui.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 96116aaed08..185ef0750c0 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -629,6 +629,7 @@ impl Tui { // no matter whether the click was inside it or not. if matches!(root.borrow().content, NodeContent::Modal(_)) { break; + } } // Update the hovered path and trigger redraw if it changed From 13715d8df1c034f848d4d74d835de04fd5a82f93 Mon Sep 17 00:00:00 2001 From: Lunor Date: Wed, 21 Oct 2026 13:25:22 +0700 Subject: [PATCH 5/5] chore: add option for enable mouse.hover --- README.md | 15 ++++++++++ crates/edit/src/bin/edit/main.rs | 15 ++++++++-- crates/edit/src/bin/edit/settings.rs | 44 ++++++++++++++++++++++++++-- crates/edit/src/tui.rs | 28 +++++++++++++----- 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b62a023e5d4..aa3b5572e9b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,21 @@ You can install the latest version with Homebrew: brew install msedit ``` +## Settings + +Open **File > Preferences** to edit `settings.json`. Settings take effect after restarting Edit. + +To enable menu highlighting and switching between open menus with the mouse: + +```json +{ + "mouse.hover": true +} +``` + +`mouse.hover` defaults to `false`. When enabled, Edit requests all mouse motion +from the terminal. When disabled, mouse clicks and dragging remain available. + ## Build Instructions * [Install Rust](https://www.rust-lang.org/tools/install) diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index 4a83bf0a1a8..1c2a93c429e 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -584,20 +584,25 @@ impl Drop for RestoreModes { // Same as in the beginning but in the reverse order. // It also includes DECSCUSR 0 to reset the cursor style and DECTCEM to show the cursor. // We specifically don't reset mode 1036, because most applications expect it to be set nowadays. - sys::write_stdout("\x1b[0 q\x1b[?25h\x1b]0;\x07\x1b[?1003;1006;2004l\x1b[?1049l"); + sys::write_stdout("\x1b[0 q\x1b[?25h\x1b]0;\x07\x1b[?1002;1003;1006;2004l\x1b[?1049l"); } } +// Reset the other tracking mode before enabling the requested one. +// 1002 reports button motion; 1003 also reports motion without a pressed button. +fn mouse_tracking_sequence(hover: bool) -> &'static str { + if hover { "\x1b[?1002l\x1b[?1003h" } else { "\x1b[?1003l\x1b[?1002h" } +} + fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) -> RestoreModes { sys::write_stdout(concat!( // 1049: Alternative Screen Buffer // I put the ASB switch in the beginning, just in case the terminal performs // some additional state tracking beyond the modes we enable/disable. - // 1003: Any Event Mouse Tracking // 1006: SGR Mouse Mode // 2004: Bracketed Paste Mode // 1036: Xterm: "meta sends escape" (Alt keypresses should be encoded with ESC + char) - "\x1b[?1049h\x1b[?1003;1006;2004h\x1b[?1036h", + "\x1b[?1049h\x1b[?1006;2004h\x1b[?1036h", // OSC 4 color table requests for indices 0 through 15 (base colors). "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?\x07", "\x1b]4;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07", @@ -615,6 +620,10 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) "\x1b[c", )); + let mouse_hover = Settings::borrow().mouse_hover; + tui.set_mouse_hover_enabled(mouse_hover); + sys::write_stdout(mouse_tracking_sequence(mouse_hover)); + let mut done = false; let mut osc_buffer = String::new(); let mut indexed_colors = framebuffer::DEFAULT_THEME; diff --git a/crates/edit/src/bin/edit/settings.rs b/crates/edit/src/bin/edit/settings.rs index 570762ed619..bc1cb9b9817 100644 --- a/crates/edit/src/bin/edit/settings.rs +++ b/crates/edit/src/bin/edit/settings.rs @@ -10,6 +10,7 @@ use crate::apperr; pub struct Settings { pub path: PathBuf, + pub mouse_hover: bool, pub file_associations: Vec<(String, &'static Language)>, } @@ -27,7 +28,7 @@ impl Settings { } const fn new() -> Self { - Settings { path: PathBuf::new(), file_associations: Vec::new() } + Settings { path: PathBuf::new(), mouse_hover: false, file_associations: Vec::new() } } pub fn borrow() -> Ref<'static, Settings> { @@ -57,13 +58,23 @@ impl Settings { Err(err) => return Err(err.into()), Ok(str) => str, }; - let Ok(json) = json::parse(&scratch, &str) else { + self.load_json(&str) + } + + fn load_json(&mut self, text: &str) -> apperr::Result<()> { + let scratch = scratch_arena(None); + let Ok(json) = json::parse(&scratch, text) else { return Err(apperr::Error::SettingsInvalid("Invalid JSON")); }; let Some(root) = json.as_object() else { return Err(apperr::Error::SettingsInvalid("Non-object root")); }; + if let Some(value) = root.get("mouse.hover") { + self.mouse_hover = + value.as_bool().ok_or(apperr::Error::SettingsInvalid("mouse.hover"))?; + } + if let Some(f) = root.get_object("files.associations") { for &(mut key, ref value) in f.iter() { if !key.contains('/') { @@ -116,3 +127,32 @@ fn config_dir() -> Option { .map(|p| push(p, "msedit")) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mouse_hover_settings() { + for (text, expected) in [ + ("{}", false), + (r#"{"mouse.hover": false}"#, false), + (r#"{"mouse.hover": true}"#, true), + ] { + let mut settings = Settings::new(); + settings.load_json(text).unwrap(); + assert_eq!(settings.mouse_hover, expected); + } + } + + #[test] + fn mouse_hover_rejects_invalid_types() { + for value in ["null", "0", r#""true""#, "[]", "{}"] { + let mut settings = Settings::new(); + assert!(matches!( + settings.load_json(&format!(r#"{{"mouse.hover": {value}}}"#)), + Err(apperr::Error::SettingsInvalid("mouse.hover")) + )); + } + } +} diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index eb0399732ad..dcd005b82f0 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -350,8 +350,10 @@ pub struct Tui { mouse_down_node_path: Vec, /// Path to the node currently under the mouse cursor. hovered_node_path: Vec, - /// This is check mouse hover enabled + /// Whether hover interactions are enabled by the caller. mouse_hover_enabled: bool, + /// Whether mouse input is more recent than keyboard input. + mouse_hover_active: bool, /// The position of the first click in a double/triple click series. first_click_position: Point, /// The node ID of the node that was first clicked on @@ -417,7 +419,8 @@ impl Tui { mouse_hover_node_path: Vec::with_capacity(16), mouse_down_node_path: Vec::with_capacity(16), hovered_node_path: Vec::with_capacity(16), - mouse_hover_enabled: true, + mouse_hover_enabled: false, + mouse_hover_active: false, first_click_position: Point::MIN, first_click_target: 0, @@ -440,6 +443,15 @@ impl Tui { Ok(tui) } + /// Enables highlighting and menu switching on hover. + /// The caller must also configure terminal mouse motion reporting. + pub fn set_mouse_hover_enabled(&mut self, enabled: bool) { + if self.mouse_hover_enabled != enabled { + self.mouse_hover_enabled = enabled; + self.needs_more_settling(); + } + } + // Helper to check if a node is in the hover path fn is_subtree_hovered(&self, node: &Node) -> bool { self.hovered_node_path.get(node.depth) == Some(&node.id) @@ -572,7 +584,7 @@ impl Tui { self.size = resize; } Some(Input::Text(text)) => { - self.mouse_hover_enabled = false; + self.mouse_hover_active = false; input_text = Some(text); // TODO: the .len()==1 check causes us to ignore keyboard inputs that are faster than we process them. // For instance, imagine the user presses "A" twice and we happen to read it in a single chunk. @@ -585,18 +597,18 @@ impl Tui { } } Some(Input::Paste(paste)) => { - self.mouse_hover_enabled = false; + self.mouse_hover_active = false; let clipboard = self.clipboard_mut(); clipboard.write(paste); clipboard.mark_as_synchronized(); input_keyboard = Some(kbmod::CTRL | vk::V); } Some(Input::Keyboard(keyboard)) => { - self.mouse_hover_enabled = false; + self.mouse_hover_active = false; input_keyboard = Some(keyboard); } Some(Input::Mouse(mouse)) => { - self.mouse_hover_enabled = true; + self.mouse_hover_active = true; let mut next_state = mouse.state; let next_position = mouse.position; let mut next_scroll = mouse.scroll; @@ -643,7 +655,7 @@ impl Tui { // Update the hovered path and trigger redraw if it changed let mut next_hovered_path = Vec::with_capacity(16); Self::build_node_path(hovered_node, &mut next_hovered_path); - if self.hovered_node_path != next_hovered_path { + if self.mouse_hover_enabled && self.hovered_node_path != next_hovered_path { self.hovered_node_path = next_hovered_path; self.needs_more_settling(); } @@ -3391,7 +3403,7 @@ impl<'a> Context<'a, '_> { /// Returns whether the current node is hovered. pub fn is_hovered(&mut self) -> bool { - if !self.tui.mouse_hover_enabled { + if !self.tui.mouse_hover_enabled || !self.tui.mouse_hover_active { return false; } let last_node = self.tree.last_node.borrow();