ltk/event_loop/context_menu.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::app_data::AppData;
use super::surface::SurfaceFocus;
use crate::app::App;
use crate::tree::find_handlers;
use crate::types::{ Point, Rect };
/// Built-in context menu for text editing — Copy / Cut / Paste rows
/// drawn on top of the surface content. Shown on a right-click (or
/// long-press) in a [`crate::widget::text_edit::TextEdit`]; dismissed
/// by clicking outside or selecting an action. Lives on
/// [`super::surface::SurfaceState`] so a press inside an overlay shows
/// its own menu in overlay-local coordinates.
#[ derive( Clone, Debug ) ]
pub( crate ) struct ContextMenu
{
/// `widget_rects` index of the TextEdit the menu targets. Used by
/// the action dispatch so we operate on the right field even
/// after focus has moved.
pub widget_idx: usize,
/// Menu rect in surface-local physical pixels.
pub rect: Rect,
pub has_selection: bool,
pub can_paste: bool,
/// Byte offset within the target TextEdit's value that
/// corresponds to the original press point that opened the menu.
/// Paste uses this so the clipboard contents land exactly where
/// the user right-clicked / long-pressed, regardless of where
/// the cursor was beforehand. `None` only when the open path
/// could not snapshot the position (e.g. canvas missing).
pub paste_offset: Option<usize>,
}
/// Number of rows in the built-in clipboard context menu — Copy /
/// Cut / Paste / Delete. Bumped from three when Delete was added so
/// the user has a parallel UI affordance to the Backspace / Delete
/// keys for clearing a selection without touching the clipboard.
pub( crate ) const CONTEXT_MENU_ROWS: usize = 4;
impl ContextMenu
{
/// Vertical band offsets inside [`Self::rect`] for each row. The
/// returned vector always has [`CONTEXT_MENU_ROWS`] entries; the
/// last element is the per-row height. Used by both hit-testing
/// and the renderer so the two stay in sync.
pub fn row_ys( &self ) -> ( Vec<f32>, f32 )
{
let row_h = self.rect.height / CONTEXT_MENU_ROWS as f32;
let ys = ( 0..CONTEXT_MENU_ROWS )
.map( |i| self.rect.y + i as f32 * row_h )
.collect();
( ys, row_h )
}
/// Which row, if any, contains `pos`. Returns `0..CONTEXT_MENU_ROWS`
/// (Copy / Cut / Paste / Delete) or `None` if `pos` is outside the
/// menu rect.
pub fn row_at( &self, pos: Point ) -> Option<usize>
{
if !self.rect.contains( pos ) { return None; }
let row_h = self.rect.height / CONTEXT_MENU_ROWS as f32;
let i = ( ( pos.y - self.rect.y ) / row_h ).floor() as i32;
if ( 0..CONTEXT_MENU_ROWS as i32 ).contains( &i ) { Some( i as usize ) } else { None }
}
}
impl<A: App> AppData<A>
{
/// Open the built-in Copy / Cut / Paste context menu near `pos`,
/// targeting the TextEdit at `widget_idx`. Clamps the menu rect
/// to the surface so it never goes off-screen on a near-edge
/// click. Idempotent — replaces any existing menu.
pub( crate ) fn show_context_menu( &mut self, focus: SurfaceFocus, widget_idx: usize, pos: Point )
{
const W: f32 = 180.0;
const H: f32 = 44.0 * CONTEXT_MENU_ROWS as f32;
let has_selection = self.focused_selection_text( focus ).is_some();
let can_paste = !self.clipboard.is_empty();
// Snapshot the byte offset the press fell on so a later
// "Paste" lands exactly at the right-click point, even though
// the menu deliberately preserves the existing cursor /
// selection (so Copy / Cut still operate on the prior
// selection).
let paste_offset = self.text_input_geometry( focus, widget_idx )
.and_then( |( rect, value, multiline, secure, align, font_size )|
{
let cursor_pos = self.surface( focus ).cursor_state.get( &widget_idx ).copied().unwrap_or( 0 );
let canvas = self.surface( focus ).canvas.as_ref()?;
Some( crate::widget::text_edit::byte_offset_at(
canvas, rect, pos, &value, multiline, secure, cursor_pos, align, font_size,
) )
} );
let ss = self.surface( focus );
let sw = ss.width as f32 * ss.scale_factor.max( 1 ) as f32;
let sh = ss.height as f32 * ss.scale_factor.max( 1 ) as f32;
let mut x = pos.x;
let mut y = pos.y;
// Clamp inside the surface with a small breathing margin.
x = x.min( sw - W - 4.0 ).max( 4.0 );
y = y.min( sh - H - 4.0 ).max( 4.0 );
let rect = Rect { x, y, width: W, height: H };
let menu = ContextMenu { widget_idx, rect, has_selection, can_paste, paste_offset };
let ss = self.surface_mut( focus );
ss.context_menu = Some( menu );
ss.request_redraw();
}
/// Dismiss the context menu on the given surface (no-op when
/// none is shown).
pub( crate ) fn hide_context_menu( &mut self, focus: SurfaceFocus )
{
let ss = self.surface_mut( focus );
if ss.context_menu.is_some()
{
ss.context_menu = None;
ss.request_redraw();
}
}
/// Handle a primary-button press while the context menu is open.
/// Returns `true` when the click was consumed by the menu (either
/// a row activation or an outside-click dismissal); the pointer
/// dispatch then skips the regular gesture path for this press.
pub( crate ) fn handle_context_menu_press( &mut self, focus: SurfaceFocus, pos: Point ) -> bool
{
let menu = match self.surface( focus ).context_menu.clone()
{
Some( m ) => m,
None => return false,
};
// Click outside the menu rect → dismiss only.
let row = match menu.row_at( pos )
{
Some( r ) => r,
None =>
{
self.hide_context_menu( focus );
return true;
}
};
// Action gating: rows are clickable only when their
// underlying operation makes sense.
match row
{
0 => if menu.has_selection { self.handle_copy( focus ); } else { return true; }
1 => if menu.has_selection { self.handle_cut( focus ); } else { return true; }
2 =>
{
if !menu.can_paste { return true; }
// Paste lands at the click position the menu was
// opened from, not at whatever the cursor happens to
// be (which may sit at the end of the value because
// the user just focused the field). Move cursor +
// anchor to the snapshotted offset (collapsing any
// prior selection there) and then run the regular
// paste path.
if let Some( ofs ) = menu.paste_offset
{
let ss = self.surface_mut( focus );
ss.cursor_state.insert( menu.widget_idx, ofs );
ss.selection_anchor.insert( menu.widget_idx, ofs );
}
self.handle_paste( focus );
}
3 =>
{
// Delete row — clears the current selection without
// touching the clipboard (the difference vs. Cut).
// Disabled with no selection: nothing to delete.
if !menu.has_selection { return true; }
if let Some( new_value ) = self.delete_selection( focus )
{
let msg = find_handlers( &self.surface( focus ).widget_rects, menu.widget_idx )
.and_then( |h| h.text_change_msg( &new_value ) );
if let Some( m ) = msg { self.pending_msgs.push( m ); }
}
}
_ => return true,
}
self.hide_context_menu( focus );
true
}
}