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
	}
}