From c828e81da2a7abdb46ad0a9a4836b1a8a6d29935 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sun, 23 Aug 2026 20:17:18 +0530 Subject: [PATCH 1/3] test: cover editor right-click context menu --- crates/edit/src/bin/edit/draw_editor.rs | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/edit/src/bin/edit/draw_editor.rs b/crates/edit/src/bin/edit/draw_editor.rs index ce03bbeb4cb..0923b07c3d5 100644 --- a/crates/edit/src/bin/edit/draw_editor.rs +++ b/crates/edit/src/bin/edit/draw_editor.rs @@ -340,6 +340,68 @@ pub fn draw_goto_menu(ctx: &mut Context, state: &mut State) { } } +#[cfg(test)] +mod tests { + use super::*; + use edit::input::{Input, InputMouse, InputMouseState}; + use stdext::arena; + + fn draw_frame(tui: &mut Tui, state: &mut State, input: Option>) { + let mut ctx = tui.create_context(input); + draw_editor(&mut ctx, state); + } + + fn settle(tui: &mut Tui, state: &mut State) { + while tui.needs_settling() { + draw_frame(tui, state, None); + } + } + + #[test] + fn right_clicking_selected_text_opens_context_menu() { + let _ = arena::init(128 * MEBI); + let mut tui = Tui::new().unwrap(); + let mut state = State::new().unwrap(); + let buffer = state.documents.add_untitled().unwrap().buffer.clone(); + + { + let mut tb = buffer.borrow_mut(); + tb.copy_from_str(&String::from("selected text")); + tb.cursor_move_to_logical(Point { x: 0, y: 0 }); + tb.start_selection(); + tb.selection_update_logical(Point { x: 8, y: 0 }); + } + + draw_frame( + &mut tui, + &mut state, + Some(Input::Resize(Size { width: 80, height: 24 })), + ); + settle(&mut tui, &mut state); + + let margin = buffer.borrow().margin_width(); + draw_frame( + &mut tui, + &mut state, + Some(Input::Mouse(InputMouse { + state: InputMouseState::Right, + modifiers: kbmod::NONE, + position: Point { x: margin + 1, y: 0 }, + scroll: Point::default(), + drag: false, + })), + ); + settle(&mut tui, &mut state); + + let scratch = arena::scratch_arena(None); + let layout = tui.debug_layout(&scratch); + assert!( + layout.contains("editor_context_menu"), + "right-clicking selected text should open the context menu:\n{layout}" + ); + } +} + fn validate_goto_point(line: &str) -> Option { let mut coords = [0; 2]; let (y, x) = line.split_once(':').unwrap_or((line, "1")); From 17a74e6877f05fed95ff0c04d571bf4d7d180e60 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sun, 23 Aug 2026 20:20:43 +0530 Subject: [PATCH 2/3] feat: show context menu for selected text --- crates/edit/src/bin/edit/draw_editor.rs | 82 +++++++++++++++++++------ crates/edit/src/bin/edit/state.rs | 2 + crates/edit/src/tui.rs | 69 +++++++++++++++++++++ 3 files changed, 133 insertions(+), 20 deletions(-) diff --git a/crates/edit/src/bin/edit/draw_editor.rs b/crates/edit/src/bin/edit/draw_editor.rs index 0923b07c3d5..b2e991b3ac6 100644 --- a/crates/edit/src/bin/edit/draw_editor.rs +++ b/crates/edit/src/bin/edit/draw_editor.rs @@ -27,12 +27,57 @@ pub fn draw_editor(ctx: &mut Context, state: &mut State) { if let Some(doc) = state.documents.active() { ctx.textarea("textarea", doc.buffer.clone()); ctx.inherit_focus(); + if let Some(position) = ctx.take_textarea_context_menu_position() { + state.editor_context_menu_position = Some(position); + } } else { ctx.block_begin("empty"); ctx.block_end(); } ctx.attr_intrinsic_size(Size { width: 0, height: size.height - height_reduction }); + + draw_editor_context_menu(ctx, state); +} + +fn draw_editor_context_menu(ctx: &mut Context, state: &mut State) { + let Some(position) = state.editor_context_menu_position else { + return; + }; + let Some(buffer) = state.documents.active().map(|doc| doc.buffer.clone()) else { + state.editor_context_menu_position = None; + return; + }; + + ctx.context_menu_begin("editor_context_menu", position); + + if ctx.context_menu_button(loc(LocId::EditCut), 'T', kbmod::CTRL | vk::X) { + buffer.borrow_mut().cut(ctx.clipboard_mut()); + ctx.needs_rerender(); + } + if ctx.context_menu_button(loc(LocId::EditCopy), 'C', kbmod::CTRL | vk::C) { + buffer.borrow_mut().copy(ctx.clipboard_mut()); + ctx.needs_rerender(); + } + if ctx.context_menu_button(loc(LocId::EditPaste), 'P', kbmod::CTRL | vk::V) { + buffer.borrow_mut().paste(ctx.clipboard_ref(), false); + ctx.needs_rerender(); + } + if ctx.context_menu_button(loc(LocId::EditSelectAll), 'A', kbmod::CTRL | vk::A) { + buffer.borrow_mut().select_all(); + ctx.needs_rerender(); + } + if state.wants_search.kind != StateSearchKind::Disabled + && ctx.context_menu_button(loc(LocId::EditFind), 'F', kbmod::CTRL | vk::F) + { + state.wants_search.kind = StateSearchKind::Search; + state.wants_search.focus = true; + ctx.needs_rerender(); + } + + if ctx.context_menu_end() { + state.editor_context_menu_position = None; + } } fn draw_search(ctx: &mut Context, state: &mut State) { @@ -340,6 +385,21 @@ pub fn draw_goto_menu(ctx: &mut Context, state: &mut State) { } } +fn validate_goto_point(line: &str) -> Option { + let mut coords = [0; 2]; + let (y, x) = line.split_once(':').unwrap_or((line, "1")); + // Using a loop here avoids 2 copies of the str->int code. + // This makes the binary more compact. + for (i, s) in [x, y].iter().enumerate() { + coords[i] = s.parse::().ok()?; + } + // Counting backwards is only supported for lines. + if coords[0] < 1 { + return None; + } + Some(Point { x: coords[0], y: coords[1] }) +} + #[cfg(test)] mod tests { use super::*; @@ -372,11 +432,7 @@ mod tests { tb.selection_update_logical(Point { x: 8, y: 0 }); } - draw_frame( - &mut tui, - &mut state, - Some(Input::Resize(Size { width: 80, height: 24 })), - ); + draw_frame(&mut tui, &mut state, Some(Input::Resize(Size { width: 80, height: 24 }))); settle(&mut tui, &mut state); let margin = buffer.borrow().margin_width(); @@ -399,20 +455,6 @@ mod tests { layout.contains("editor_context_menu"), "right-clicking selected text should open the context menu:\n{layout}" ); + assert_eq!(layout.matches("classname: menu_checkbox").count(), 5, "{layout}"); } } - -fn validate_goto_point(line: &str) -> Option { - let mut coords = [0; 2]; - let (y, x) = line.split_once(':').unwrap_or((line, "1")); - // Using a loop here avoids 2 copies of the str->int code. - // This makes the binary more compact. - for (i, s) in [x, y].iter().enumerate() { - coords[i] = s.parse::().ok()?; - } - // Counting backwards is only supported for lines. - if coords[0] < 1 { - return None; - } - Some(Point { x: coords[0], y: coords[1] }) -} diff --git a/crates/edit/src/bin/edit/state.rs b/crates/edit/src/bin/edit/state.rs index 13a1cefbbea..de04b056db8 100644 --- a/crates/edit/src/bin/edit/state.rs +++ b/crates/edit/src/bin/edit/state.rs @@ -154,6 +154,7 @@ pub struct State { pub search_replacement: String, pub search_options: buffer::SearchOptions, pub search_success: bool, + pub editor_context_menu_position: Option, pub wants_language_picker: bool, @@ -204,6 +205,7 @@ impl State { search_replacement: Default::default(), search_options: Default::default(), search_success: true, + editor_context_menu_position: None, wants_language_picker: false, diff --git a/crates/edit/src/tui.rs b/crates/edit/src/tui.rs index 9826c91cfc7..c2358a980d6 100644 --- a/crates/edit/src/tui.rs +++ b/crates/edit/src/tui.rs @@ -538,6 +538,7 @@ impl Tui { let mut input_keyboard = None; let mut input_mouse_modifiers = kbmod::NONE; let mut input_mouse_click = 0; + let mut input_mouse_right_down = false; let mut input_scroll_delta = Point { x: 0, y: 0 }; // `input_consumed` should be `true` if we're in the settling phase which is indicated by // `self.needs_settling() == true`. However, there's a possibility for it being true from @@ -637,6 +638,7 @@ impl Tui { } else if mouse_down { // Transition from no mouse input to some mouse input --> Record the mouse down position. self.mouse_down_node_path.replace_range(.., &self.mouse_hover_node_path); + input_mouse_right_down = next_state == InputMouseState::Right; // On left-mouse-down we change focus. let mut target = 0; @@ -716,8 +718,10 @@ impl Tui { input_keyboard, input_mouse_modifiers, input_mouse_click, + input_mouse_right_down, input_scroll_delta, input_consumed, + textarea_context_menu_position: None, tree, last_modal: None, @@ -1381,9 +1385,11 @@ pub struct Context<'a, 'input> { input_keyboard: Option, input_mouse_modifiers: InputKeyMod, input_mouse_click: CoordType, + input_mouse_right_down: bool, /// By how much the mouse wheel was scrolled since the last frame. input_scroll_delta: Point, input_consumed: bool, + textarea_context_menu_position: Option, tree: Tree<'a>, last_modal: Option<&'a NodeCell<'a>>, @@ -1744,6 +1750,11 @@ impl<'a> Context<'a, '_> { if self.input_consumed { None } else { self.input_keyboard } } + /// Returns and clears a request to open a context menu for a textarea. + pub fn take_textarea_context_menu_position(&mut self) -> Option { + self.textarea_context_menu_position.take() + } + #[inline] pub fn set_input_consumed(&mut self) { debug_assert!(!self.input_consumed); @@ -2293,6 +2304,22 @@ impl<'a> Context<'a, '_> { y: mouse.y - inner.top + tc.scroll_offset.y, }; + if self.input_mouse_right_down + && text_rect.contains(self.tui.mouse_down_position) + && tb.selection_range().is_some_and(|(beg, end)| { + let beg = beg.visual_pos; + let end = end.visual_pos; + pos.y >= beg.y + && pos.y <= end.y + && (pos.y != beg.y || pos.x >= beg.x) + && (pos.y != end.y || pos.x < end.x) + }) + { + self.textarea_context_menu_position = Some(mouse); + self.set_input_consumed(); + return false; + } + if select_rect.contains(self.tui.mouse_down_position) { if self.tui.mouse_is_drag { tb.selection_update_visual(pos); @@ -3346,6 +3373,48 @@ impl<'a> Context<'a, '_> { } } + /// Starts a context menu at the given viewport position. + pub fn context_menu_begin(&mut self, classname: &'static str, position: Point) { + let open_left = position.x >= self.tui.size.width / 2; + let open_up = position.y >= self.tui.size.height / 2; + + self.table_begin(classname); + self.attr_float(FloatSpec { + anchor: Anchor::Root, + gravity_x: open_left as u8 as f32, + gravity_y: open_up as u8 as f32, + offset_x: (position.x + !open_left as CoordType) as f32, + offset_y: (position.y + !open_up as CoordType) as f32, + }); + self.attr_border(); + self.attr_focus_well(); + self.focus_on_first_present(); + } + + /// Appends a button to the current context menu. + pub fn context_menu_button( + &mut self, + text: &str, + accelerator: char, + shortcut: InputKey, + ) -> bool { + self.menubar_menu_button(text, accelerator, shortcut) + } + + /// Ends a context menu and returns whether it should be closed. + pub fn context_menu_end(&mut self) -> bool { + let escape = !self.input_consumed + && self.input_keyboard == Some(vk::ESCAPE) + && self.contains_focus(); + if escape { + self.set_input_consumed(); + Tui::clean_node_path(&mut self.tui.focused_node_path); + } + + self.table_end(); + escape || !self.contains_focus() + } + /// 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 425d5bbe8251665cccb5c61d082f0fd920921ea5 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sun, 23 Aug 2026 20:24:43 +0530 Subject: [PATCH 3/3] test: verify context menu command labels --- crates/edit/src/bin/edit/draw_editor.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/bin/edit/draw_editor.rs b/crates/edit/src/bin/edit/draw_editor.rs index b2e991b3ac6..9b238daa6e2 100644 --- a/crates/edit/src/bin/edit/draw_editor.rs +++ b/crates/edit/src/bin/edit/draw_editor.rs @@ -455,6 +455,15 @@ mod tests { layout.contains("editor_context_menu"), "right-clicking selected text should open the context menu:\n{layout}" ); - assert_eq!(layout.matches("classname: menu_checkbox").count(), 5, "{layout}"); + for id in [ + LocId::EditCut, + LocId::EditCopy, + LocId::EditPaste, + LocId::EditSelectAll, + LocId::EditFind, + ] { + let label = format!("text: \" {}\"", loc(id)); + assert!(layout.contains(&label), "missing context-menu label {label}:\n{layout}"); + } } }