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}