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 c1128c5b571..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[?1002;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. - // 1002: Cell Motion 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[?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 fe5b5216874..dcd005b82f0 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -348,6 +348,12 @@ pub struct Tui { mouse_hover_node_path: Vec, /// 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, + /// 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 @@ -412,6 +418,9 @@ impl Tui { mouse_click_counter: 0, 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: false, + mouse_hover_active: false, first_click_position: Point::MIN, first_click_target: 0, @@ -429,10 +438,25 @@ impl Tui { }; Self::clean_node_path(&mut tui.mouse_hover_node_path); 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) } + /// 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) + } + /// 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); @@ -560,6 +584,7 @@ impl Tui { self.size = resize; } Some(Input::Text(text)) => { + 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. @@ -572,15 +597,18 @@ impl Tui { } } Some(Input::Paste(paste)) => { + 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_active = false; input_keyboard = Some(keyboard); } Some(Input::Mouse(mouse)) => { + self.mouse_hover_active = true; let mut next_state = mouse.state; let next_position = mouse.position; let mut next_scroll = mouse.scroll; @@ -595,14 +623,13 @@ impl Tui { let mut hovered_node = None; // Needed for `mouse_down` let mut focused_node = None; // Needed for `mouse_down` and `is_click` - // Roots (aka windows) are ordered in Z order, so we iterate - // them in reverse order, from topmost to bottommost. + + // 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() { - // 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); @@ -625,6 +652,14 @@ 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.mouse_hover_enabled && self.hovered_node_path != next_hovered_path { + self.hovered_node_path = next_hovered_path; + self.needs_more_settling(); + } + Self::build_node_path(hovered_node, &mut self.mouse_hover_node_path); if is_scroll { @@ -3228,19 +3263,29 @@ impl<'a> Context<'a, '_> { return false; } - if contains_focus || keyboard_focus { - // Arming this only while no button is held keeps the press - // that opened the menu from closing it again right away. - if self.tui.mouse_state != InputMouseState::Left { - self.tui.menubar_toggle_id = button_id; + // 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 { + // Arming this only while no button is held keeps the press + // that opened the menu from closing it again right away. + if self.tui.mouse_state != InputMouseState::Left { + self.tui.menubar_toggle_id = button_id; } self.next_block_id_mixin(mixin); @@ -3255,6 +3300,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(); } @@ -3292,7 +3340,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))); } @@ -3353,6 +3401,15 @@ impl<'a> Context<'a, '_> { } } + /// Returns whether the current node is hovered. + pub fn is_hovered(&mut self) -> bool { + if !self.tui.mouse_hover_enabled || !self.tui.mouse_hover_active { + 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) {