bezel-ui 0.2.25

SwiftUI-flavored components for gpui — popovers, menus, buttons, toggles, and loaders
Documentation
//! Pure offset math, kept free of gpui so it can be unit-tested.

use super::*;

/// UTF-16 offset (what the platform IME speaks) → byte offset.
pub fn offset_from_utf16(text: &str, offset: usize) -> usize {
    let mut utf8_offset = 0;
    let mut utf16_count = 0;
    for ch in text.chars() {
        if utf16_count >= offset {
            break;
        }
        utf16_count += ch.len_utf16();
        utf8_offset += ch.len_utf8();
    }
    utf8_offset
}

/// Byte offset → UTF-16 offset.
pub fn offset_to_utf16(text: &str, offset: usize) -> usize {
    let mut utf16_offset = 0;
    let mut utf8_count = 0;
    for ch in text.chars() {
        if utf8_count >= offset {
            break;
        }
        utf8_count += ch.len_utf8();
        utf16_offset += ch.len_utf16();
    }
    utf16_offset
}

/// Platform range → byte range, clamped to character boundaries in `text`.
pub fn range_from_utf16(text: &str, range: Range<usize>) -> Range<usize> {
    offset_from_utf16(text, range.start)..offset_from_utf16(text, range.end)
}

/// Byte range → platform range.
pub fn range_to_utf16(text: &str, range: Range<usize>) -> Range<usize> {
    offset_to_utf16(text, range.start)..offset_to_utf16(text, range.end)
}

/// An IME selection is relative to the replacement text, not the document.
pub fn composition_selection(
    text: &str,
    start: usize,
    selection: Option<Range<usize>>,
) -> Range<usize> {
    let range = selection
        .map(|range| range_from_utf16(text, range))
        .unwrap_or(text.len()..text.len());
    start + range.start..start + range.end
}

/// Previous *grapheme* boundary, so arrow keys and backspace step over a flag
/// emoji or a combining mark as one unit instead of splitting it into pieces
/// that render as garbage.
pub fn previous_boundary(text: &str, offset: usize) -> usize {
    text.grapheme_indices(true)
        .rev()
        .find_map(|(idx, _)| (idx < offset).then_some(idx))
        .unwrap_or(0)
}

/// Next grapheme boundary; clamps to the end of the text.
pub fn next_boundary(text: &str, offset: usize) -> usize {
    text.grapheme_indices(true)
        .find_map(|(idx, _)| (idx > offset).then_some(idx))
        .unwrap_or(text.len())
}

/// Whether an edit continues the group the last one opened, rather than
/// starting an undo step of its own.
///
/// Two conditions, both structural: the same kind of edit, landing where the
/// last one left the caret. Deliberately not "within N milliseconds" — a time
/// threshold is a number nobody has measured, and adjacency is what actually
/// distinguishes a run of typing from a fresh thought somewhere else.
pub fn joins_group(last: Option<(EditKind, usize)>, kind: EditKind, at: usize) -> bool {
    last.is_some_and(|(last_kind, offset)| last_kind == kind && at == offset)
}

/// The line breaks a field of this shape is allowed to hold.
///
/// CRLF is folded to LF whatever the shape: `shape_text` splits on `\n` alone,
/// so a surviving `\r` shapes as a glyph and puts every offset after it out by
/// one. A single-line field then keeps the text but not the breaks — a pasted
/// newline becomes a space rather than silently truncating what was pasted.
///
/// The invariant this buys: a [`Shape::Line`] field's content never contains a
/// newline, so nothing downstream has to ask whether it might.
pub fn normalize(text: &str, shape: Shape) -> String {
    let text = text.replace("\r\n", "\n").replace('\r', "\n");
    if shape.is_multiline() {
        text
    } else {
        text.replace('\n', " ")
    }
}

/// Start of the logical line holding `offset` — the byte after the previous
/// newline.
///
/// Logical, not visual: with soft wrapping these two readings diverge, and
/// `ctrl-a` here goes to the start of the whole paragraph rather than stopping
/// at the wrap. That is emacs' `C-a`, and a deliberate divergence from macOS
/// `NSTextView`, which stops at the visual row. On text with no newline — every
/// [`Shape::Line`] field — the two are identical.
pub fn line_start(text: &str, offset: usize) -> usize {
    text[..offset].rfind('\n').map_or(0, |at| at + 1)
}

/// End of the logical line holding `offset` — the byte before the next newline.
pub fn line_end(text: &str, offset: usize) -> usize {
    text[offset..]
        .find('\n')
        .map_or(text.len(), |at| offset + at)
}

/// A word-bound segment counts as a word if it has any alphanumeric content;
/// whitespace and punctuation runs are the things word motion skips over.
pub(super) fn is_word(segment: &str) -> bool {
    segment.chars().any(char::is_alphanumeric)
}

/// Start of the word at or before `offset` — option-left.
///
/// Word units are Unicode word bounds (UAX#29), not space-delimited runs. In
/// practice that means `foo.bar` and `foo_bar` are ONE word — a dot or
/// underscore between letters does not break — while `a-b`, `path/to/file` and
/// `foo, bar` do break. Good defaults for identifiers and paths, and verified
/// against the segmenter rather than assumed (see tests).
pub fn previous_word_boundary(text: &str, offset: usize) -> usize {
    text.split_word_bound_indices()
        .filter(|(start, _)| *start < offset)
        .rfind(|(_, segment)| is_word(segment))
        .map(|(start, _)| start)
        .unwrap_or(0)
}

/// End of the word at or after `offset` — option-right.
pub fn next_word_boundary(text: &str, offset: usize) -> usize {
    text.split_word_bound_indices()
        .filter(|(start, segment)| start + segment.len() > offset)
        .find(|(_, segment)| is_word(segment))
        .map(|(start, segment)| start + segment.len())
        .unwrap_or(text.len())
}

/// What a press selects by — a click places a caret, a double-click takes a
/// word, a triple-click a line — and what a drag from that press extends by.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Granularity {
    Char,
    Word,
    Line,
}

impl Granularity {
    pub fn of_clicks(click_count: usize) -> Self {
        match click_count {
            0 | 1 => Self::Char,
            2 => Self::Word,
            _ => Self::Line,
        }
    }

    /// The span of this unit holding `offset`. A word is the word-bound
    /// segment touching `offset`, preferring one with alphanumeric content; a
    /// line is the logical line.
    pub fn around(self, text: &str, offset: usize) -> Range<usize> {
        match self {
            Self::Char => offset..offset,
            Self::Word => {
                let segments = || {
                    text.split_word_bound_indices()
                        .map(|(start, segment)| (start..start + segment.len(), is_word(segment)))
                };
                let after = segments().find(|(range, _)| range.contains(&offset));
                let before = segments().find(|(range, _)| range.end == offset);
                match (after, before) {
                    (Some((range, true)), _) | (_, Some((range, true))) => range,
                    (Some((range, _)), _) | (_, Some((range, _))) => range,
                    (None, None) => offset..offset,
                }
            }
            Self::Line => line_start(text, offset)..line_end(text, offset),
        }
    }
}

/// The `(anchor, head)` a drag holds once the pointer is over `span`, the unit
/// under it, from a press that selected `pressed`. All of `pressed` stays
/// selected whichever way the drag goes.
pub fn drag_selection<T: Ord + std::marker::Copy>(pressed: Range<T>, span: Range<T>) -> (T, T) {
    if span.start < pressed.start {
        (pressed.end, span.start)
    } else {
        (pressed.start, span.end.max(pressed.end))
    }
}

/// Where atoms — ranges of a source text painted as something else — line up
/// between that text and the text painted for it, in document order, as
/// `(source, shown)` pairs.
///
/// An offset inside an atom's source has no place in what it shows, and one
/// inside what it shows has none in the source: each lands on an end.
#[derive(Clone, Debug, Default)]
pub struct Shown(std::rc::Rc<[(Range<usize>, Range<usize>)]>);

impl Shown {
    /// `pairs` must be in document order and must not overlap.
    pub fn new(pairs: Vec<(Range<usize>, Range<usize>)>) -> Self {
        Self(pairs.into())
    }

    /// Where a source offset shows. One inside an atom lands on its start.
    pub fn at(&self, offset: usize) -> usize {
        self.map(offset, false)
    }

    /// Where a source range shows. An end inside an atom takes all of it.
    pub fn range(&self, range: &Range<usize>) -> Range<usize> {
        self.map(range.start, false)..self.map(range.end, true)
    }

    fn map(&self, offset: usize, end: bool) -> usize {
        let mut shift = 0isize;
        for (source, shown) in self.0.iter() {
            if offset <= source.start {
                break;
            }
            if offset < source.end {
                return if end { shown.end } else { shown.start };
            }
            shift = shown.end as isize - source.end as isize;
        }
        offset.saturating_add_signed(shift)
    }

    /// The source offset of a shown one. One inside an atom lands on the
    /// nearer end.
    pub fn offset(&self, shown: usize) -> usize {
        let mut shift = 0isize;
        for (source, painted) in self.0.iter() {
            if shown <= painted.start {
                break;
            }
            if shown < painted.end {
                return if shown - painted.start <= painted.end - shown {
                    source.start
                } else {
                    source.end
                };
            }
            shift = source.end as isize - painted.end as isize;
        }
        shown.saturating_add_signed(shift)
    }
}