ailloli_ui_editor/state.rs
1//! Mutable generic editor input, buffer, viewport, and multi-click state.
2
3use std::ops::Range;
4use std::time::{Duration, Instant};
5
6use ailloli_ui_core::Point;
7use ailloli_ui_text::{TextBuffer, TextEditAction, TextEditState, TextSelection};
8
9use crate::code::{EditorLanguage, SyntaxToken};
10use crate::input::scroll::scroll_by;
11use crate::input::selection::{select_line_at, select_word_at};
12use crate::{EditorConfig, EditorInputOutcome};
13use ailloli_ui_core::scroll::ScrollMetrics;
14use ailloli_ui_core::Offset;
15
16/// Maximum inclusive interval between clicks in one multi-click sequence.
17const MULTI_CLICK_MAX_DELAY: Duration = Duration::from_millis(500);
18/// Maximum inclusive Euclidean click displacement in logical pixels.
19const MULTI_CLICK_MAX_DISTANCE: f32 = 4.0;
20
21/// Editor area in which a pointer click occurred.
22///
23/// Text and gutter clicks never continue the same multi-click sequence.
24///
25/// # Examples
26///
27/// ```
28/// use ailloli_ui_editor::EditorClickZone;
29/// assert_ne!(EditorClickZone::Text, EditorClickZone::Gutter);
30/// ```
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum EditorClickZone {
33 /// Shaped text viewport.
34 Text,
35 /// Code-editor gutter.
36 Gutter,
37}
38
39/// History used to recognize single, double, and triple clicks.
40///
41/// State starts empty. Click counts are capped at three and a sequence requires
42/// compatible timestamps, equal byte/zone, at most 500 ms, and at most four
43/// logical pixels of movement.
44///
45/// # Examples
46///
47/// ```
48/// use std::time::Duration;
49/// use ailloli_ui_core::Point;
50/// use ailloli_ui_editor::{EditorClickZone, EditorSession};
51/// use ailloli_ui_text::TextBuffer;
52/// let mut session = EditorSession::new(TextBuffer::new());
53/// assert_eq!(session.register_pointer_click_at(Duration::ZERO, Point::new(0.0, 0.0), 0, EditorClickZone::Text), 1);
54/// assert_eq!(session.register_pointer_click_at(Duration::from_millis(100), Point::new(1.0, 0.0), 0, EditorClickZone::Text), 2);
55/// ```
56#[derive(Debug, Clone, Default)]
57pub struct EditorClickState {
58 /// Timestamp of the preceding click.
59 last_at: Option<EditorClickTimestamp>,
60 /// Position of the preceding click in logical pixels.
61 last_pos: Option<Point>,
62 /// Byte index supplied for the preceding click.
63 last_byte: usize,
64 /// Zone of the preceding click.
65 last_zone: Option<EditorClickZone>,
66 /// Current count in `0..=3`; zero means no click has been registered.
67 click_count: u8,
68}
69
70/// Timestamp domain used for one click sequence.
71#[derive(Debug, Clone, Copy)]
72enum EditorClickTimestamp {
73 /// Provider-neutral monotonic event time.
74 Event(Duration),
75 /// Legacy process-local instant.
76 Legacy(Instant),
77}
78
79/// Computes elapsed time only within a matching timestamp domain.
80impl EditorClickTimestamp {
81 /// Returns a checked elapsed duration, or `None` across domains/backward time.
82 fn elapsed_since(self, earlier: Self) -> Option<Duration> {
83 match (self, earlier) {
84 (Self::Event(now), Self::Event(earlier)) => now.checked_sub(earlier),
85 (Self::Legacy(now), Self::Legacy(earlier)) => now.checked_duration_since(earlier),
86 _ => None,
87 }
88 }
89}
90
91/// Mutable editor session state: text buffer plus edit, viewport, and click state.
92///
93/// # Examples
94///
95/// ```
96/// use ailloli_ui_editor::{EditorSession, EditorWrapMode};
97/// use ailloli_ui_text::TextBuffer;
98/// let session = EditorSession::new(TextBuffer::from_string("hello"));
99/// assert_eq!(session.buffer.as_str(), "hello");
100/// assert_eq!(session.config.wrap_mode, EditorWrapMode::SoftWrap);
101/// assert_eq!(session.edit.caret_byte, 0);
102/// ```
103#[derive(Debug, Clone)]
104pub struct EditorSession {
105 /// Editable rope-backed UTF-8 buffer.
106 pub buffer: TextBuffer,
107 /// Caret, selection, IME, scroll, drag, and undo/redo state.
108 pub edit: TextEditState,
109 /// Current style and wrapping policy.
110 pub config: EditorConfig,
111 /// Multi-click recognition history.
112 pub click_state: EditorClickState,
113}
114
115/// Applies input and configuration transitions to an editor session.
116impl EditorSession {
117 /// Creates a session with default configuration and empty edit/click state.
118 ///
119 /// # Examples
120 ///
121 /// ```
122 /// use ailloli_ui_editor::EditorSession;
123 /// use ailloli_ui_text::TextBuffer;
124 /// let session = EditorSession::new(TextBuffer::from_string("abc"));
125 /// assert_eq!(session.edit.caret_byte, 0);
126 /// assert_eq!(session.buffer.as_str(), "abc");
127 /// ```
128 pub fn new(buffer: TextBuffer) -> Self {
129 Self {
130 buffer,
131 edit: TextEditState::new(),
132 config: EditorConfig::default(),
133 click_state: EditorClickState::default(),
134 }
135 }
136
137 /// Creates a session with an explicit configuration.
138 ///
139 /// Edit and click state still start empty; configuration is stored verbatim.
140 ///
141 /// # Examples
142 ///
143 /// ```
144 /// use ailloli_ui_editor::{EditorConfig, EditorSession, EditorWrapMode};
145 /// use ailloli_ui_text::TextBuffer;
146 /// let config = EditorConfig { wrap_mode: EditorWrapMode::NoWrap, ..EditorConfig::default() };
147 /// let session = EditorSession::with_config(TextBuffer::new(), config);
148 /// assert_eq!(session.config.wrap_mode, EditorWrapMode::NoWrap);
149 /// ```
150 pub fn with_config(buffer: TextBuffer, config: EditorConfig) -> Self {
151 Self {
152 buffer,
153 edit: TextEditState::new(),
154 config,
155 click_state: EditorClickState::default(),
156 }
157 }
158
159 /// Replaces configuration when different.
160 ///
161 /// Returns `false` for exact equality. Switching to soft wrap also forces
162 /// horizontal scroll to zero; switching to no-wrap preserves it. An equal
163 /// soft-wrap configuration does not repair a manually nonzero scroll value.
164 ///
165 /// # Examples
166 ///
167 /// ```
168 /// use ailloli_ui_editor::{EditorConfig, EditorSession, EditorWrapMode};
169 /// use ailloli_ui_text::TextBuffer;
170 /// let mut session = EditorSession::new(TextBuffer::new());
171 /// session.edit.scroll_x = 20.0;
172 /// let nowrap = EditorConfig { wrap_mode: EditorWrapMode::NoWrap, ..EditorConfig::default() };
173 /// assert!(session.set_config(nowrap));
174 /// assert_eq!(session.edit.scroll_x, 20.0);
175 /// assert!(session.set_config(EditorConfig::default()));
176 /// assert_eq!(session.edit.scroll_x, 0.0);
177 /// ```
178 pub fn set_config(&mut self, config: EditorConfig) -> bool {
179 if self.config == config {
180 return false;
181 }
182 self.config = config;
183 if matches!(self.config.wrap_mode, crate::EditorWrapMode::SoftWrap) {
184 self.edit.scroll_x = 0.0;
185 }
186 true
187 }
188
189 /// Replaces the buffer only when its text differs.
190 ///
191 /// Equal text returns `false` and retains the existing buffer, even if the
192 /// supplied buffer has different internal revisions. A replacement clamps
193 /// generic edit state to valid UTF-8 bounds.
194 ///
195 /// # Examples
196 ///
197 /// ```
198 /// use ailloli_ui_editor::EditorSession;
199 /// use ailloli_ui_text::TextBuffer;
200 /// let mut session = EditorSession::new(TextBuffer::from_string("long"));
201 /// session.edit.caret_byte = 4;
202 /// assert!(session.replace_buffer_if_changed(TextBuffer::from_string("x")));
203 /// assert_eq!(session.edit.caret_byte, 1);
204 /// assert!(!session.replace_buffer_if_changed(TextBuffer::from_string("x")));
205 /// ```
206 pub fn replace_buffer_if_changed(&mut self, buffer: TextBuffer) -> bool {
207 if self.buffer.as_str() == buffer.as_str() {
208 return false;
209 }
210 self.buffer = buffer;
211 self.edit.clamp_to_buffer(&self.buffer);
212 true
213 }
214
215 /// Applies one text-edit action and returns its neutral side effects.
216 ///
217 /// Buffer mutation, undo/redo, selection, IME, and clipboard semantics are
218 /// inherited from [`TextEditState::apply`].
219 ///
220 /// # Examples
221 ///
222 /// ```
223 /// use ailloli_ui_editor::EditorSession;
224 /// use ailloli_ui_text::{TextBuffer, TextEditAction};
225 /// let mut session = EditorSession::new(TextBuffer::new());
226 /// let outcome = session.apply_edit_action(TextEditAction::InsertText { text: "hi".into() });
227 /// assert!(outcome.text_changed);
228 /// assert_eq!(session.buffer.as_str(), "hi");
229 /// ```
230 pub fn apply_edit_action(&mut self, action: TextEditAction) -> EditorInputOutcome {
231 self.edit.apply(&mut self.buffer, action).into()
232 }
233
234 /// Applies a logical-pixel scroll delta and clamps it to content metrics.
235 ///
236 /// Soft wrap disables horizontal movement. Returns whether either stored
237 /// offset changed; non-finite values are normalized by scroll primitives.
238 ///
239 /// # Examples
240 ///
241 /// ```
242 /// use ailloli_ui_core::{Offset, ScrollMetrics, Size};
243 /// use ailloli_ui_editor::EditorSession;
244 /// use ailloli_ui_text::TextBuffer;
245 /// let mut session = EditorSession::new(TextBuffer::new());
246 /// let metrics = ScrollMetrics::new(Size::new(100.0, 50.0), Size::new(100.0, 200.0));
247 /// assert!(session.scroll_by(Offset::new(20.0, 30.0), metrics));
248 /// assert_eq!((session.edit.scroll_x, session.edit.scroll_y), (0.0, 30.0));
249 /// ```
250 pub fn scroll_by(&mut self, delta: Offset, metrics: ScrollMetrics) -> bool {
251 scroll_by(&mut self.edit, self.config.wrap_mode, delta, metrics)
252 }
253
254 /// Starts pointer selection and moves or extends the caret.
255 ///
256 /// `drag_anchor` stores the supplied byte verbatim, while the caret setter
257 /// clamps backward to a valid UTF-8 boundary. Returns whether caret,
258 /// selection, or desired horizontal position changed; setting only a new
259 /// drag anchor can therefore return `false`.
260 ///
261 /// # Examples
262 ///
263 /// ```
264 /// use ailloli_ui_editor::EditorSession;
265 /// use ailloli_ui_text::TextBuffer;
266 /// let mut session = EditorSession::new(TextBuffer::from_string("abc"));
267 /// session.begin_pointer_selection(2, false);
268 /// assert_eq!(session.edit.drag_anchor, Some(2));
269 /// assert_eq!(session.edit.caret_byte, 2);
270 /// ```
271 pub fn begin_pointer_selection(&mut self, byte: usize, extend: bool) -> bool {
272 self.edit.drag_anchor = Some(byte);
273 self.edit.set_caret(&self.buffer, byte, extend)
274 }
275
276 /// Updates a drag selection from supplied byte offsets.
277 ///
278 /// Both offsets are stored verbatim in [`TextSelection`]. The separate
279 /// `edit.caret_byte` is numerically clamped to buffer length but not repaired
280 /// to a UTF-8 boundary. Hit-test callers must therefore provide valid,
281 /// in-range boundaries. This method reports no change flag and does not
282 /// modify `drag_anchor` or `desired_x`.
283 ///
284 /// # Examples
285 ///
286 /// ```
287 /// use ailloli_ui_editor::EditorSession;
288 /// use ailloli_ui_text::TextBuffer;
289 /// let mut session = EditorSession::new(TextBuffer::from_string("abc"));
290 /// session.update_pointer_selection(1, 99);
291 /// assert_eq!(session.edit.selection.unwrap().normalized(), (1, 99));
292 /// assert_eq!(session.edit.caret_byte, 3);
293 /// ```
294 pub fn update_pointer_selection(&mut self, anchor: usize, byte: usize) {
295 self.edit.selection = Some(TextSelection {
296 anchor,
297 caret: byte,
298 });
299 self.edit.caret_byte = byte.min(self.buffer.len_bytes());
300 }
301
302 /// Ends an active pointer drag.
303 ///
304 /// Returns `true` only when a drag anchor was present. Selection and caret
305 /// remain unchanged.
306 ///
307 /// # Examples
308 ///
309 /// ```
310 /// use ailloli_ui_editor::EditorSession;
311 /// use ailloli_ui_text::TextBuffer;
312 /// let mut session = EditorSession::new(TextBuffer::from_string("abc"));
313 /// session.begin_pointer_selection(1, false);
314 /// assert!(session.end_pointer_selection());
315 /// assert!(!session.end_pointer_selection());
316 /// ```
317 pub fn end_pointer_selection(&mut self) -> bool {
318 if self.edit.drag_anchor.is_some() {
319 self.edit.drag_anchor = None;
320 true
321 } else {
322 false
323 }
324 }
325
326 /// Registers a click using a process-local monotonic [`Instant`].
327 ///
328 /// Returns a count in `1..=3`. A sequence continues only within 500 ms and
329 /// four logical pixels at the identical byte and zone. Legacy and explicit
330 /// event timestamps never mix into one sequence.
331 ///
332 /// # Examples
333 ///
334 /// ```
335 /// use std::time::Instant;
336 /// use ailloli_ui_core::Point;
337 /// use ailloli_ui_editor::{EditorClickZone, EditorSession};
338 /// use ailloli_ui_text::TextBuffer;
339 /// let mut session = EditorSession::new(TextBuffer::new());
340 /// let now = Instant::now();
341 /// assert_eq!(session.register_pointer_click(now, Point::new(0.0, 0.0), 0, EditorClickZone::Text), 1);
342 /// assert_eq!(session.register_pointer_click(now, Point::new(4.0, 0.0), 0, EditorClickZone::Text), 2);
343 /// ```
344 pub fn register_pointer_click(
345 &mut self,
346 now: Instant,
347 pos: Point,
348 byte: usize,
349 zone: EditorClickZone,
350 ) -> u8 {
351 self.register_pointer_click_with_timestamp(
352 EditorClickTimestamp::Legacy(now),
353 pos,
354 byte,
355 zone,
356 )
357 }
358
359 /// Registers a click using a provider-neutral monotonic event timestamp.
360 ///
361 /// This is the deterministic counterpart to [`Self::register_pointer_click`]
362 /// for hosts that attach an explicit timestamp to each input event. Backward
363 /// timestamps reset the sequence. Counts saturate at three.
364 ///
365 /// # Examples
366 ///
367 /// ```
368 /// use std::time::Duration;
369 /// use ailloli_ui_core::Point;
370 /// use ailloli_ui_editor::{EditorClickZone, EditorSession};
371 /// use ailloli_ui_text::TextBuffer;
372 /// let mut session = EditorSession::new(TextBuffer::new());
373 /// for expected in [1, 2, 3, 3] {
374 /// let count = session.register_pointer_click_at(Duration::from_millis(expected as u64), Point::new(0.0, 0.0), 0, EditorClickZone::Text);
375 /// assert_eq!(count, expected.min(3));
376 /// }
377 /// ```
378 pub fn register_pointer_click_at(
379 &mut self,
380 timestamp: Duration,
381 pos: Point,
382 byte: usize,
383 zone: EditorClickZone,
384 ) -> u8 {
385 self.register_pointer_click_with_timestamp(
386 EditorClickTimestamp::Event(timestamp),
387 pos,
388 byte,
389 zone,
390 )
391 }
392
393 /// Updates multi-click history in either supported timestamp domain.
394 fn register_pointer_click_with_timestamp(
395 &mut self,
396 now: EditorClickTimestamp,
397 pos: Point,
398 byte: usize,
399 zone: EditorClickZone,
400 ) -> u8 {
401 let continues = self
402 .click_state
403 .last_at
404 .zip(self.click_state.last_pos)
405 .is_some_and(|(last_at, last_pos)| {
406 now.elapsed_since(last_at)
407 .is_some_and(|elapsed| elapsed <= MULTI_CLICK_MAX_DELAY)
408 && point_distance_sq(pos, last_pos)
409 <= MULTI_CLICK_MAX_DISTANCE * MULTI_CLICK_MAX_DISTANCE
410 && self.click_state.last_byte == byte
411 && self.click_state.last_zone == Some(zone)
412 });
413 self.click_state.click_count = if continues {
414 self.click_state.click_count.saturating_add(1).min(3)
415 } else {
416 1
417 };
418 self.click_state.last_at = Some(now);
419 self.click_state.last_pos = Some(pos);
420 self.click_state.last_byte = byte;
421 self.click_state.last_zone = Some(zone);
422 self.click_state.click_count
423 }
424
425 /// Selects a syntax token or lexical word around a byte offset.
426 ///
427 /// If no selectable unit exists, this instead moves the caret and clears
428 /// selection. Otherwise selection is ordered start-to-end and the caret is
429 /// placed at its end. Returns whether editor state changed.
430 ///
431 /// # Examples
432 ///
433 /// ```
434 /// use ailloli_ui_editor::{EditorLanguage, EditorSession};
435 /// use ailloli_ui_text::TextBuffer;
436 /// let mut session = EditorSession::new(TextBuffer::from_string("hello world"));
437 /// assert!(session.select_word_at_byte(7, None, EditorLanguage::PlainText));
438 /// assert_eq!(session.edit.selection.unwrap().normalized(), (6, 11));
439 /// ```
440 pub fn select_word_at_byte(
441 &mut self,
442 byte: usize,
443 syntax_tokens: Option<&[SyntaxToken]>,
444 language: EditorLanguage,
445 ) -> bool {
446 let text = self.buffer.as_str();
447 let Some(range) = select_word_at(&text, byte, syntax_tokens, language) else {
448 return self.edit.set_caret(&self.buffer, byte, false);
449 };
450 self.set_selection_range(range)
451 }
452
453 /// Selects the logical line containing a byte, excluding its newline.
454 ///
455 /// The byte is clamped to a UTF-8 boundary by the line selector. The caret
456 /// moves to the range end and drag/desired-x state is cleared. Returns whether
457 /// those stored values changed.
458 ///
459 /// # Examples
460 ///
461 /// ```
462 /// use ailloli_ui_editor::EditorSession;
463 /// use ailloli_ui_text::TextBuffer;
464 /// let mut session = EditorSession::new(TextBuffer::from_string("one\ntwo"));
465 /// assert!(session.select_line_at_byte(5));
466 /// assert_eq!(session.edit.selection.unwrap().normalized(), (4, 7));
467 /// ```
468 pub fn select_line_at_byte(&mut self, byte: usize) -> bool {
469 let text = self.buffer.as_str();
470 let range = select_line_at(&text, byte);
471 self.set_selection_range(range)
472 }
473
474 /// Installs a selection range after numeric length clamping.
475 fn set_selection_range(&mut self, range: Range<usize>) -> bool {
476 let start = range.start.min(self.buffer.len_bytes());
477 let end = range.end.min(self.buffer.len_bytes());
478 let next_selection = Some(TextSelection {
479 anchor: start,
480 caret: end,
481 });
482 let changed = self.edit.selection != next_selection
483 || self.edit.caret_byte != end
484 || self.edit.drag_anchor.is_some()
485 || self.edit.desired_x.is_some();
486 self.edit.selection = next_selection;
487 self.edit.caret_byte = end;
488 self.edit.drag_anchor = None;
489 self.edit.desired_x = None;
490 changed
491 }
492}
493
494/// Returns squared Euclidean distance in logical pixels.
495fn point_distance_sq(a: Point, b: Point) -> f32 {
496 let dx = a.x - b.x;
497 let dy = a.y - b.y;
498 dx * dx + dy * dy
499}