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
113 changes: 113 additions & 0 deletions crates/edit/src/bin/edit/draw_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -354,3 +399,71 @@ fn validate_goto_point(line: &str) -> Option<Point> {
}
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<Input<'_>>) {
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}");
}
}
}
2 changes: 2 additions & 0 deletions crates/edit/src/bin/edit/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Point>,

pub wants_language_picker: bool,

Expand Down Expand Up @@ -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,

Expand Down
69 changes: 69 additions & 0 deletions crates/edit/src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1381,9 +1385,11 @@ pub struct Context<'a, 'input> {
input_keyboard: Option<InputKey>,
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<Point>,

tree: Tree<'a>,
last_modal: Option<&'a NodeCell<'a>>,
Expand Down Expand Up @@ -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<Point> {
self.textarea_context_menu_position.take()
}

#[inline]
pub fn set_input_consumed(&mut self) {
debug_assert!(!self.input_consumed);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down