agg-gui 0.3.0

Immediate-mode Rust GUI library with AGG rendering, Y-up layout, widgets, text, SVG, and native/WASM adapters
Documentation
//! Cursor movement and text-mutation primitives for [`TextArea`], split out of
//! `text_area.rs` to keep that file under the project's 800-line cap.
//!
//! These are the low-level funnels the widget's key/mouse handlers call:
//! `insert_str`/`delete` mutate the buffer (and fire `on_change` — see
//! `callbacks.rs`), while `move_cursor_to`/`move_char`/`move_line` reposition
//! the caret without touching the text. They are `pub(super)` so the sibling
//! `widget_impl` and `scroll` modules can drive them.

use super::*;

impl TextArea {
    /// Insert a string at the cursor, replacing any active selection.
    pub(super) fn insert_str(&mut self, s: &str) {
        let mut st = self.edit.borrow_mut();
        let (lo, hi) = (st.cursor.min(st.anchor), st.cursor.max(st.anchor));
        // Make sure we slice at grapheme boundaries.
        let lo = lo.min(st.text.len());
        let hi = hi.min(st.text.len());
        st.text.replace_range(lo..hi, s);
        st.cursor = lo + s.len();
        st.anchor = st.cursor;
        st.note_text_change();
        drop(st);
        self.mark_dirty();
        self.notify_change();
    }

    /// Delete the current selection, or (if empty) `dir` chars toward
    /// the supplied side.  `-1` = backspace, `+1` = delete, `0` = just
    /// collapse the selection (cut path).
    pub(super) fn delete(&mut self, dir: i32) {
        let mut st = self.edit.borrow_mut();
        let (lo, hi) = (st.cursor.min(st.anchor), st.cursor.max(st.anchor));
        if lo != hi {
            st.text.replace_range(lo..hi, "");
            st.cursor = lo;
            st.anchor = lo;
        } else if dir < 0 && st.cursor > 0 {
            let cur = st.cursor;
            let prev = prev_char_boundary(&st.text, cur);
            st.text.replace_range(prev..cur, "");
            st.cursor = prev;
            st.anchor = prev;
        } else if dir > 0 && st.cursor < st.text.len() {
            let cur = st.cursor;
            let next = next_char_boundary(&st.text, cur);
            st.text.replace_range(cur..next, "");
        }
        st.note_text_change();
        drop(st);
        self.mark_dirty();
        self.notify_change();
    }

    /// Handle the caret/selection change for a fresh pointer press. `clicks` is
    /// the multi-click count (1 = single, 2 = double, 3 = triple). A double
    /// selects the word under `off`, a triple the logical line (paragraph);
    /// `shift` extends the existing selection to `off`.
    pub(super) fn begin_pointer_selection(&mut self, off: usize, clicks: u32, shift: bool) {
        if shift {
            self.select_granularity = SelectGranularity::Char;
            self.select_pivot = (off, off);
            self.move_cursor_to(off, /*with_selection=*/ true);
            return;
        }
        let text = self.edit.borrow().text.clone();
        match clicks {
            n if n >= 3 => {
                self.select_granularity = SelectGranularity::Line;
                let (start, end) = paragraph_range_at(&text, off);
                self.select_pivot = (start, end);
                self.set_selection(start, end);
            }
            2 => {
                self.select_granularity = SelectGranularity::Word;
                let (start, end) = word_range_at(&text, off);
                self.select_pivot = (start, end);
                self.set_selection(start, end);
            }
            _ => {
                self.select_granularity = SelectGranularity::Char;
                self.select_pivot = (off, off);
                self.move_cursor_to(off, /*with_selection=*/ false);
            }
        }
    }

    /// Extend the selection during a drag, honouring the granularity the
    /// initiating click established. `off` is the caret byte offset under the
    /// pointer.
    pub(super) fn extend_selection_drag(&mut self, off: usize) {
        match self.select_granularity {
            SelectGranularity::Char => self.move_cursor_to(off, /*with_selection=*/ true),
            SelectGranularity::Word => {
                let text = self.edit.borrow().text.clone();
                let (pivot_start, pivot_end) = self.select_pivot;
                let (cs, ce) = word_range_at(&text, off);
                if off >= pivot_end {
                    self.set_caret_anchor(ce, pivot_start);
                } else {
                    self.set_caret_anchor(cs, pivot_end);
                }
            }
            SelectGranularity::Line => {
                let text = self.edit.borrow().text.clone();
                let (pivot_start, pivot_end) = self.select_pivot;
                let (cs, ce) = paragraph_range_at(&text, off);
                if off >= pivot_end {
                    self.set_caret_anchor(ce, pivot_start);
                } else {
                    self.set_caret_anchor(cs, pivot_end);
                }
            }
        }
    }

    /// Set anchor and cursor to a sorted `[start, end)` selection, caret at the
    /// end.
    pub(super) fn set_selection(&mut self, start: usize, end: usize) {
        let mut st = self.edit.borrow_mut();
        let len = st.text.len();
        st.anchor = start.min(len);
        st.cursor = end.min(len);
    }

    /// Place the caret at `cursor` with the selection anchored at `anchor`
    /// (both clamped to the text length).
    fn set_caret_anchor(&mut self, cursor: usize, anchor: usize) {
        let mut st = self.edit.borrow_mut();
        let len = st.text.len();
        st.cursor = cursor.min(len);
        st.anchor = anchor.min(len);
    }

    /// Move cursor to an absolute byte offset.  `with_selection=false`
    /// collapses anchor with cursor; `true` leaves the anchor alone
    /// so a selection is extended.
    pub(super) fn move_cursor_to(&mut self, pos: usize, with_selection: bool) {
        let mut st = self.edit.borrow_mut();
        let p = pos.min(st.text.len());
        st.cursor = p;
        if !with_selection {
            st.anchor = p;
        }
    }

    /// Cursor one char left / right.
    pub(super) fn move_char(&mut self, dir: i32, with_selection: bool) {
        let st = self.edit.borrow();
        let p = if dir < 0 {
            prev_char_boundary(&st.text, st.cursor)
        } else {
            next_char_boundary(&st.text, st.cursor)
        };
        drop(st);
        self.move_cursor_to(p, with_selection);
    }

    /// Cursor one visual line up / down.  `dir` = −1 for up, +1 for down.
    pub(super) fn move_line(&mut self, dir: i32, with_selection: bool) {
        self.move_lines(dir as isize, with_selection);
    }

    /// Cursor `delta` visual lines up (negative) or down (positive), preserving
    /// the pixel column. Clamps to the first/last visual line. Shared by arrow
    /// navigation ([`move_line`](Self::move_line)) and PageUp/PageDown.
    pub(super) fn move_lines(&mut self, delta: isize, with_selection: bool) {
        if self.cached_lines.is_empty() {
            return;
        }
        let cursor = self.edit.borrow().cursor;
        let cur_line = self.line_for_cursor(cursor) as isize;
        let last = self.cached_lines.len() as isize - 1;
        let target_line = (cur_line + delta).clamp(0, last);
        if target_line == cur_line {
            return;
        }
        let cur_line = cur_line as usize;
        let target_line = target_line as usize;
        // Preserve horizontal position (pixel column, not byte column),
        // measured relative to the current line's aligned start so left /
        // center / right alignment all keep the caret in the same column.
        let cur_line_x = self
            .cached_lines
            .get(cur_line)
            .map(|l| self.line_x_start(l))
            .unwrap_or(self.padding);
        let cur_x = self.pos_for_cursor(cursor).x - cur_line_x;
        // Find byte offset in target_line closest to `cur_x`.
        let line = &self.cached_lines[target_line];
        let txt = &line.text;
        let mut best_byte = 0usize;
        let mut best_delta = f64::INFINITY;
        let mut acc = 0.0_f64;
        let mut prev_byte = 0usize;
        for (i, _) in txt.char_indices().chain(std::iter::once((txt.len(), ' '))) {
            let w = if i > prev_byte {
                measure_advance(&self.font, &txt[prev_byte..i], self.font_size)
            } else {
                0.0
            };
            acc += w;
            let d = (acc - cur_x).abs();
            if d < best_delta {
                best_delta = d;
                best_byte = i;
            }
            prev_byte = i;
        }
        let target = line.start + best_byte;
        self.move_cursor_to(target, with_selection);
    }
}