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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 12 additions & 3 deletions crates/edit/src/bin/edit/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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;
Expand Down
44 changes: 42 additions & 2 deletions crates/edit/src/bin/edit/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::apperr;

pub struct Settings {
pub path: PathBuf,
pub mouse_hover: bool,
pub file_associations: Vec<(String, &'static Language)>,
}

Expand All @@ -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> {
Expand Down Expand Up @@ -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('/') {
Expand Down Expand Up @@ -116,3 +127,32 @@ fn config_dir() -> Option<PathBuf> {
.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"))
));
}
}
}
83 changes: 70 additions & 13 deletions crates/edit/src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ pub struct Tui {
mouse_hover_node_path: Vec<u64>,
/// The path to the node that was clicked on.
mouse_down_node_path: Vec<u64>,
/// Path to the node currently under the mouse cursor.
hovered_node_path: Vec<u64>,
/// 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
Expand Down Expand Up @@ -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,

Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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();
}
Expand Down Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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) {
Expand Down