Skip to main content

idet_core/
lineops.rs

1//! Moving the current line, paragraph or markdown section up or down.
2
3/// Direction in which [`move_segment`] shifts text.
4#[derive(Clone, Copy)]
5pub enum LineMove {
6    /// Move towards the start of the text.
7    Up,
8    /// Move towards the end of the text.
9    Down,
10}
11
12/// How much text [`move_segment`] treats as one unit.
13#[derive(Clone, Copy, PartialEq, Eq)]
14pub enum Segment {
15    /// The single line holding the cursor.
16    Line,
17    /// The run of non-blank lines around the cursor.
18    Block,
19    /// A markdown heading with everything below it, subsections included.
20    Section,
21}
22
23/// Swaps the line at `cursor` with its neighbour in `direction`.
24///
25/// `cursor` is a character index; the cursor stays on the moved line. Returns
26/// the new text and cursor, or `None` when there is no neighbouring line.
27#[must_use]
28pub fn move_line(text: &str, cursor: usize, direction: LineMove) -> Option<(String, usize)> {
29    move_segment(text, cursor, Segment::Line, direction)
30}
31
32/// Swaps the segment holding `cursor` with the neighbouring one in `direction`.
33///
34/// `cursor` is a character index and keeps its position inside the segment as
35/// it moves. Blank lines separating two blocks stay where they are. Returns
36/// the new text and cursor, or `None` when the cursor is outside any segment
37/// or has no neighbour to swap with.
38#[must_use]
39pub fn move_segment(
40    text: &str,
41    cursor: usize,
42    segment: Segment,
43    direction: LineMove,
44) -> Option<(String, usize)> {
45    let lines: Vec<&str> = text.split('\n').collect();
46    let (line, column) = line_column(text, cursor);
47    let (start, end) = segment_range(&lines, line, segment)?;
48    let other = neighbour_range(&lines, start, end, segment, direction)?;
49    let (swapped, moved_start) = swap_ranges(&lines, (start, end), other)?;
50    let target_line = moved_start + (line - start);
51    let target_column = column.min(swapped.get(target_line)?.chars().count());
52    let moved_cursor = cursor_at(&swapped, target_line, target_column);
53    Some((swapped.join("\n"), moved_cursor))
54}
55
56/// Moves everything `selection` touches past the neighbouring segment in
57/// `direction`, as one block.
58///
59/// `selection` is a pair of character indices in either order and grows to
60/// whole segments, so selecting part of two paragraphs moves both of them. A
61/// selection ending exactly at the start of a line leaves that line out.
62/// Returns the new text and the selection covering the same text again, or
63/// `None` when there is nothing to swap with.
64#[must_use]
65pub fn move_segments(
66    text: &str,
67    selection: (usize, usize),
68    segment: Segment,
69    direction: LineMove,
70) -> Option<(String, (usize, usize))> {
71    let lines: Vec<&str> = text.split('\n').collect();
72    let (from, to) = (selection.0.min(selection.1), selection.0.max(selection.1));
73    let (first_line, first_column) = line_column(text, from);
74    let (last_line, last_column) = line_column(text, to);
75    let last = if last_column == 0 && last_line > first_line {
76        last_line - 1
77    } else {
78        last_line
79    };
80    let (start, end) = selection_range(&lines, first_line, last, segment)?;
81    let other = neighbour_range(&lines, start, end, segment, direction)?;
82    let (swapped, moved_start) = swap_ranges(&lines, (start, end), other)?;
83    let moved = swapped.join("\n");
84    let moved_from = cursor_in(
85        &moved,
86        first_line.max(start) + moved_start - start,
87        first_column,
88    );
89    let moved_to = cursor_in(
90        &moved,
91        last_line.min(end) + moved_start - start,
92        last_column,
93    );
94    Some((moved, (moved_from, moved_to)))
95}
96
97fn selection_range(
98    lines: &[&str],
99    first: usize,
100    last: usize,
101    segment: Segment,
102) -> Option<(usize, usize)> {
103    let (first, last) = if segment == Segment::Block {
104        let filled = |line: &usize| lines.get(*line).is_some_and(|text| !text.trim().is_empty());
105        (
106            (first..=last).find(filled)?,
107            (first..=last).rev().find(filled)?,
108        )
109    } else {
110        (first, last)
111    };
112    let (start, first_end) = segment_range(lines, first, segment)?;
113    let (last_start, end) = segment_range(lines, last, segment)?;
114    Some((start.min(last_start), end.max(first_end)))
115}
116
117fn swap_ranges<'a>(
118    lines: &[&'a str],
119    range: (usize, usize),
120    other: (usize, usize),
121) -> Option<(Vec<&'a str>, usize)> {
122    let moving_first = range.0 < other.0;
123    let (first, second) = if moving_first {
124        (range, other)
125    } else {
126        (other, range)
127    };
128    let mut swapped: Vec<&str> = Vec::with_capacity(lines.len());
129    swapped.extend_from_slice(lines.get(..first.0)?);
130    swapped.extend_from_slice(lines.get(second.0..second.1)?);
131    swapped.extend_from_slice(lines.get(first.1..second.0)?);
132    swapped.extend_from_slice(lines.get(first.0..first.1)?);
133    swapped.extend_from_slice(lines.get(second.1..)?);
134    let moved_start = if moving_first {
135        first.0 + (second.1 - second.0) + (second.0 - first.1)
136    } else {
137        first.0
138    };
139    Some((swapped, moved_start))
140}
141
142fn segment_range(lines: &[&str], line: usize, segment: Segment) -> Option<(usize, usize)> {
143    match segment {
144        Segment::Line => Some((line, line + 1)),
145        Segment::Block => {
146            if lines.get(line)?.trim().is_empty() {
147                return None;
148            }
149            let start = line - filled_run(lines.iter().take(line).rev());
150            let end = line + 1 + filled_run(lines.iter().skip(line + 1));
151            Some((start, end))
152        }
153        Segment::Section => {
154            let start = lines
155                .iter()
156                .take(line + 1)
157                .rposition(|text| heading_level(text).is_some())?;
158            let level = heading_level(lines.get(start)?)?;
159            Some((start, section_end(lines, start, level)))
160        }
161    }
162}
163
164fn neighbour_range(
165    lines: &[&str],
166    start: usize,
167    end: usize,
168    segment: Segment,
169    direction: LineMove,
170) -> Option<(usize, usize)> {
171    match (segment, direction) {
172        (Segment::Line, LineMove::Up) => Some((start.checked_sub(1)?, start)),
173        (Segment::Line, LineMove::Down) => (end < lines.len()).then_some((end, end + 1)),
174        (Segment::Block, LineMove::Up) => {
175            let other_end = start - blank_run(lines.iter().take(start).rev());
176            let last = other_end.checked_sub(1)?;
177            let other_start = last - filled_run(lines.iter().take(last).rev());
178            Some((other_start, other_end))
179        }
180        (Segment::Block, LineMove::Down) => {
181            let other_start = end + blank_run(lines.iter().skip(end));
182            if other_start >= lines.len() {
183                return None;
184            }
185            let other_end = other_start + 1 + filled_run(lines.iter().skip(other_start + 1));
186            Some((other_start, other_end))
187        }
188        (Segment::Section, LineMove::Up) => {
189            let level = heading_level(lines.get(start)?)?;
190            let other_start = lines
191                .iter()
192                .take(start)
193                .rposition(|text| heading_level(text).is_some())?;
194            (heading_level(lines.get(other_start)?) == Some(level)).then_some((other_start, start))
195        }
196        (Segment::Section, LineMove::Down) => {
197            let level = heading_level(lines.get(start)?)?;
198            (heading_level(lines.get(end)?) == Some(level))
199                .then(|| (end, section_end(lines, end, level)))
200        }
201    }
202}
203
204fn blank_run<'a>(lines: impl Iterator<Item = &'a &'a str>) -> usize {
205    lines.take_while(|text| text.trim().is_empty()).count()
206}
207
208fn filled_run<'a>(lines: impl Iterator<Item = &'a &'a str>) -> usize {
209    lines.take_while(|text| !text.trim().is_empty()).count()
210}
211
212fn section_end(lines: &[&str], start: usize, level: usize) -> usize {
213    lines
214        .iter()
215        .enumerate()
216        .skip(start + 1)
217        .find(|(_, text)| heading_level(text).is_some_and(|found| found <= level))
218        .map_or(lines.len(), |(index, _)| index)
219}
220
221fn heading_level(line: &str) -> Option<usize> {
222    let hashes = line.chars().take_while(|&c| c == '#').count();
223    (hashes > 0 && line.chars().nth(hashes) == Some(' ')).then_some(hashes)
224}
225
226/// Locates the zero-indexed `(line, column)` of the character at `cursor`.
227#[must_use]
228pub fn line_column(text: &str, cursor: usize) -> (usize, usize) {
229    let mut line = 0;
230    let mut column = 0;
231    for (index, character) in text.chars().enumerate() {
232        if index == cursor {
233            break;
234        }
235        if character == '\n' {
236            line += 1;
237            column = 0;
238        } else {
239            column += 1;
240        }
241    }
242    (line, column)
243}
244
245/// Converts a `(line, column)` position into a character index into `text`,
246/// clamped to the last line and to the width of the line it lands on.
247#[must_use]
248pub fn cursor_in(text: &str, line: usize, column: usize) -> usize {
249    let lines: Vec<&str> = text.split('\n').collect();
250    let line = line.min(lines.len().saturating_sub(1));
251    let width = lines.get(line).map_or(0, |text| text.chars().count());
252    cursor_at(&lines, line, column.min(width))
253}
254
255/// Converts a `(line, column)` position back into a character index, given
256/// `text` already split on `\n`.
257#[must_use]
258pub fn cursor_at(lines: &[&str], line: usize, column: usize) -> usize {
259    let preceding: usize = lines
260        .iter()
261        .take(line)
262        .map(|text| text.chars().count() + 1)
263        .sum();
264    preceding + column
265}