Skip to main content

redox_core/text/
mod.rs

1//! Shared text position types and helpers used by the buffer.
2//!
3//! This module is intentionally "rope-agnostic" (it operates on indices and
4//! provides conversions given line-start information), so it can be reused
5//! whether the backing store is a Rope, a gap buffer, etc.
6//!
7//! When used with `ropey::Rope`, you typically pair these helpers with:
8//! - `Rope::len_chars()`
9//! - `Rope::line_to_char(line)`
10//! - `Rope::char_to_line(char_idx)`
11//! - `Rope::line(line).len_chars()` (includes newline if present)
12
13use core::cmp::{max, min};
14use core::fmt;
15
16/// A 0-based character index (Unicode scalar value index).
17///
18/// In ropey, most cursor-safe indexing is done in **char indices** (not bytes).
19#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
20pub struct CharIdx(pub usize);
21
22impl CharIdx {
23    #[inline]
24    pub const fn new(v: usize) -> Self {
25        Self(v)
26    }
27
28    #[inline]
29    pub const fn get(self) -> usize {
30        self.0
31    }
32
33    #[inline]
34    pub const fn saturating_add(self, delta: usize) -> Self {
35        Self(self.0.saturating_add(delta))
36    }
37
38    #[inline]
39    pub const fn saturating_sub(self, delta: usize) -> Self {
40        Self(self.0.saturating_sub(delta))
41    }
42}
43
44impl fmt::Debug for CharIdx {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.debug_tuple("CharIdx").field(&self.0).finish()
47    }
48}
49
50/// A 0-based line index.
51#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
52pub struct LineIdx(pub usize);
53
54impl LineIdx {
55    #[inline]
56    pub const fn new(v: usize) -> Self {
57        Self(v)
58    }
59
60    #[inline]
61    pub const fn get(self) -> usize {
62        self.0
63    }
64}
65
66impl fmt::Debug for LineIdx {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.debug_tuple("LineIdx").field(&self.0).finish()
69    }
70}
71
72/// A 0-based column index in **characters** within a line.
73#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
74pub struct ColIdx(pub usize);
75
76impl ColIdx {
77    #[inline]
78    pub const fn new(v: usize) -> Self {
79        Self(v)
80    }
81
82    #[inline]
83    pub const fn get(self) -> usize {
84        self.0
85    }
86}
87
88impl fmt::Debug for ColIdx {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.debug_tuple("ColIdx").field(&self.0).finish()
91    }
92}
93
94/// A (line, column) location in the buffer.
95#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
96pub struct LineCol {
97    pub line: LineIdx,
98    pub col: ColIdx,
99}
100
101impl LineCol {
102    #[inline]
103    pub const fn new(line: usize, col: usize) -> Self {
104        Self {
105            line: LineIdx(line),
106            col: ColIdx(col),
107        }
108    }
109}
110
111impl fmt::Debug for LineCol {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        f.debug_struct("LineCol")
114            .field("line", &self.line.0)
115            .field("col", &self.col.0)
116            .finish()
117    }
118}
119
120/// A half-open character range: `[start, end)`.
121///
122/// Invariant expected by users:
123/// - `start <= end`
124///
125/// When working with ropey, indices are in **chars** (not bytes).
126#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
127pub struct CharRange {
128    pub start: CharIdx,
129    pub end: CharIdx,
130}
131
132impl CharRange {
133    #[inline]
134    pub const fn new(start: CharIdx, end: CharIdx) -> Self {
135        Self { start, end }
136    }
137
138    #[inline]
139    pub const fn is_empty(self) -> bool {
140        self.start.0 >= self.end.0
141    }
142
143    #[inline]
144    pub const fn len(self) -> usize {
145        self.end.0.saturating_sub(self.start.0)
146    }
147
148    /// Normalizes so that `start <= end`.
149    #[inline]
150    pub const fn normalized(self) -> Self {
151        if self.start.0 <= self.end.0 {
152            self
153        } else {
154            Self {
155                start: self.end,
156                end: self.start,
157            }
158        }
159    }
160
161    /// Clamp the range to `[0, max]`.
162    #[inline]
163    pub fn clamp_to_len(self, max_len: usize) -> Self {
164        let s = min(self.start.0, max_len);
165        let e = min(self.end.0, max_len);
166        Self {
167            start: CharIdx(s),
168            end: CharIdx(e),
169        }
170        .normalized()
171    }
172}
173
174impl fmt::Debug for CharRange {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        f.debug_struct("CharRange")
177            .field("start", &self.start.0)
178            .field("end", &self.end.0)
179            .finish()
180    }
181}
182
183/// A small helper for "preferred column" behavior (vim-like vertical motion).
184///
185/// When you move up/down, you usually want to keep the *goal* column even if a
186/// particular line is shorter. Store the goal separately from the actual column.
187///
188/// Typical usage:
189/// - Update `goal_col` when moving left/right or after inserting text.
190/// - When moving up/down, clamp to the target line length, but keep `goal_col`.
191#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
192pub struct GoalCol {
193    pub goal_col: ColIdx,
194}
195
196impl GoalCol {
197    #[inline]
198    pub const fn new(goal_col: usize) -> Self {
199        Self {
200            goal_col: ColIdx(goal_col),
201        }
202    }
203}
204
205/// Computes a `(line, col)` for a given `char_idx` using a provided `line_to_char`
206/// function.
207///
208/// - `line_to_char(line)` must return the char index of the start of `line`.
209/// - `line_count` is the number of lines in the document.
210/// - The result is clamped to valid bounds.
211///
212/// This is rope-agnostic: you can pass ropey’s `Rope::line_to_char` directly.
213pub fn char_to_line_col(
214    char_idx: CharIdx,
215    line_count: usize,
216    mut line_to_char: impl FnMut(usize) -> usize,
217) -> LineCol {
218    if line_count == 0 {
219        return LineCol::new(0, 0);
220    }
221
222    // Binary search the greatest line whose start <= char_idx
223    let target = char_idx.0;
224    let mut lo = 0usize;
225    let mut hi = line_count - 1;
226
227    while lo < hi {
228        // bias upwards to avoid infinite loop
229        let mid = (lo + hi + 1) / 2;
230        let mid_start = line_to_char(mid);
231        if mid_start <= target {
232            lo = mid;
233        } else {
234            hi = mid - 1;
235        }
236    }
237
238    let line = lo;
239    let line_start = line_to_char(line);
240    let col = target.saturating_sub(line_start);
241
242    LineCol {
243        line: LineIdx(line),
244        col: ColIdx(col),
245    }
246}
247
248/// Computes a char index for a given `(line, col)` using a provided `line_to_char`
249/// function and a `line_len_chars` function.
250///
251/// - `line_to_char(line)` must return the char index of the start of `line`.
252/// - `line_len_chars(line)` must return the length of that line in chars.
253///
254/// This clamps:
255/// - `line` to `[0, line_count-1]` (when `line_count > 0`)
256/// - `col` to `[0, line_len]`
257pub fn line_col_to_char(
258    pos: LineCol,
259    line_count: usize,
260    mut line_to_char: impl FnMut(usize) -> usize,
261    mut line_len_chars: impl FnMut(usize) -> usize,
262) -> CharIdx {
263    if line_count == 0 {
264        return CharIdx(0);
265    }
266
267    let line = min(pos.line.0, line_count - 1);
268    let line_start = line_to_char(line);
269    let line_len = line_len_chars(line);
270    let col = min(pos.col.0, line_len);
271
272    CharIdx(line_start.saturating_add(col))
273}
274
275/// Clamps a char index into `[0, len_chars]`.
276#[inline]
277pub fn clamp_char(char_idx: CharIdx, len_chars: usize) -> CharIdx {
278    CharIdx(min(char_idx.0, len_chars))
279}
280
281/// Normalizes and clamps a range into `[0, len_chars]`.
282#[inline]
283pub fn clamp_range(range: CharRange, len_chars: usize) -> CharRange {
284    range.normalized().clamp_to_len(len_chars)
285}
286
287/// Given a line length in chars, clamp a goal column to the line.
288#[inline]
289pub fn clamp_col_to_line(goal: ColIdx, line_len_chars: usize) -> ColIdx {
290    ColIdx(min(goal.0, line_len_chars))
291}
292
293/// Computes a safe "visual" line length in chars, excluding a trailing `\n`
294/// if present (common for ropey lines).
295///
296/// Many editors treat the newline as not part of the line's editable columns.
297/// If you want newline-inclusive semantics, don't use this helper.
298#[inline]
299pub fn line_len_without_newline(
300    line_len_chars_including_newline: usize,
301    ends_with_newline: bool,
302) -> usize {
303    if ends_with_newline {
304        line_len_chars_including_newline.saturating_sub(1)
305    } else {
306        line_len_chars_including_newline
307    }
308}
309
310/// Compute the next/prev character index with clamping.
311///
312/// This is useful for cursor movement where you never want to go out of bounds.
313#[inline]
314pub fn move_char_clamped(current: CharIdx, delta: isize, len_chars: usize) -> CharIdx {
315    if delta == 0 {
316        return clamp_char(current, len_chars);
317    }
318
319    if delta > 0 {
320        let d = delta as usize;
321        CharIdx(min(current.0.saturating_add(d), len_chars))
322    } else {
323        let d = (-delta) as usize;
324        CharIdx(current.0.saturating_sub(d))
325    }
326}
327
328/// Returns `(min, max)` ordering of two char indices.
329#[inline]
330pub fn ordered_pair(a: CharIdx, b: CharIdx) -> (CharIdx, CharIdx) {
331    if a.0 <= b.0 { (a, b) } else { (b, a) }
332}
333
334/// Updates `(actual_col, goal_col)` when moving vertically.
335///
336/// Pass:
337/// - `goal_col`: previously remembered preferred column
338/// - `target_line_len`: length of target line in chars (already excluding newline if desired)
339///
340/// Returns the actual column to place the cursor at (clamped), while keeping the
341/// goal column unchanged.
342#[inline]
343pub fn apply_goal_col(goal_col: ColIdx, target_line_len: usize) -> ColIdx {
344    clamp_col_to_line(goal_col, target_line_len)
345}
346
347/// Computes the common "cursor line start" and "cursor line end" bounds.
348///
349/// Inputs are char indices:
350/// - `line_start`: start of the line containing the cursor
351/// - `line_len_chars`: length of that line in chars (including newline if present)
352///
353/// Outputs are clamped half-open bounds of the line's editable area:
354/// - `editable_start = line_start`
355/// - `editable_end = line_start + line_len_without_newline(...)`
356#[inline]
357pub fn line_editable_bounds(
358    line_start: CharIdx,
359    line_len_chars_including_newline: usize,
360    ends_with_newline: bool,
361) -> (CharIdx, CharIdx) {
362    let editable_len =
363        line_len_without_newline(line_len_chars_including_newline, ends_with_newline);
364    let start = line_start;
365    let end = CharIdx(line_start.0.saturating_add(editable_len));
366    (start, end)
367}
368
369/// Clamp a cursor char index into the editable bounds of its line.
370///
371/// If `cursor` lies past the editable end (e.g. on newline), it will be clamped back.
372#[inline]
373pub fn clamp_cursor_to_line_editable(
374    cursor: CharIdx,
375    line_start: CharIdx,
376    editable_end: CharIdx,
377) -> CharIdx {
378    CharIdx(max(line_start.0, min(cursor.0, editable_end.0)))
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn char_to_line_col_uses_binary_search_correctly() {
387        let line_starts = [0usize, 4, 6];
388        let line_count = line_starts.len();
389
390        let pos = char_to_line_col(CharIdx(5), line_count, |line| line_starts[line]);
391        assert_eq!(pos, LineCol::new(1, 1));
392    }
393
394    #[test]
395    fn line_col_to_char_clamps_line_and_column() {
396        let line_starts = [0usize, 4, 6];
397        let line_lens = [3usize, 1, 0];
398        let line_count = line_starts.len();
399
400        let idx = line_col_to_char(
401            LineCol::new(99, 99),
402            line_count,
403            |line| line_starts[line],
404            |line| line_lens[line],
405        );
406        assert_eq!(idx, CharIdx(6));
407    }
408
409    #[test]
410    fn clamp_helpers_keep_values_in_bounds() {
411        assert_eq!(clamp_char(CharIdx(9), 4), CharIdx(4));
412        assert_eq!(clamp_col_to_line(ColIdx(8), 3), ColIdx(3));
413        assert_eq!(apply_goal_col(ColIdx(8), 3), ColIdx(3));
414    }
415
416    #[test]
417    fn clamp_range_normalizes_and_clamps() {
418        let out = clamp_range(CharRange::new(CharIdx(9), CharIdx(2)), 5);
419        assert_eq!(out.start, CharIdx(2));
420        assert_eq!(out.end, CharIdx(5));
421    }
422
423    #[test]
424    fn line_helpers_respect_newline_exclusion() {
425        assert_eq!(line_len_without_newline(5, true), 4);
426        assert_eq!(line_len_without_newline(5, false), 5);
427
428        let (start, end) = line_editable_bounds(CharIdx(10), 4, true);
429        assert_eq!(start, CharIdx(10));
430        assert_eq!(end, CharIdx(13));
431
432        assert_eq!(
433            clamp_cursor_to_line_editable(CharIdx(20), start, end),
434            CharIdx(13)
435        );
436        assert_eq!(
437            clamp_cursor_to_line_editable(CharIdx(8), start, end),
438            CharIdx(10)
439        );
440    }
441
442    #[test]
443    fn movement_and_ordering_helpers_are_saturating() {
444        assert_eq!(move_char_clamped(CharIdx(2), -5, 10), CharIdx(0));
445        assert_eq!(move_char_clamped(CharIdx(2), 99, 10), CharIdx(10));
446        assert_eq!(
447            ordered_pair(CharIdx(9), CharIdx(3)),
448            (CharIdx(3), CharIdx(9))
449        );
450    }
451}