diff --git a/crates/edit/src/bin/edit/draw_editor.rs b/crates/edit/src/bin/edit/draw_editor.rs index ce03bbeb4cb..9b238daa6e2 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) { @@ -354,3 +399,71 @@ fn validate_goto_point(line: &str) -> Option { } Some(Point { x: coords[0], y: coords[1] }) } + +#[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}" + ); + 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}"); + } + } +} 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) {