95 lines
3.4 KiB
Rust
95 lines
3.4 KiB
Rust
//! Source indentation is one workspace edit, with physical line endings intact.
|
|
use iced::{
|
|
advanced::{graphics, text},
|
|
widget::text_editor::{Action, Content, Cursor, Edit, Position},
|
|
};
|
|
use std::sync::Arc;
|
|
|
|
const INDENT: &str = " ";
|
|
|
|
/// Plain Tab inserts one canonical four-space level at a caret, or prefixes
|
|
/// every selected line. Shift+Tab removes one leading tab or up to four spaces.
|
|
/// A selection ending at column zero excludes that final line. All patches are
|
|
/// local to lines, so CRLF, LF and Unicode bytes outside indentation survive.
|
|
/// The caller records the resulting buffer once in workspace undo/history.
|
|
pub fn apply<R>(content: &mut Content<R>, outdent: bool)
|
|
where
|
|
R: text::Renderer<Editor = graphics::text::Editor>,
|
|
{
|
|
let raw = content.cursor();
|
|
let selected = content.selection().is_some_and(|s| !s.is_empty());
|
|
if !selected && !outdent {
|
|
content.perform(Action::Edit(Edit::Paste(Arc::new(INDENT.into()))));
|
|
return;
|
|
}
|
|
// Determine affected lines before normalizing Word/Line mode. Those modes
|
|
// expand columns within their endpoint lines; the copied LF count reveals
|
|
// whether a zero-column endpoint includes the next line or only its edge.
|
|
let anchor = raw.selection.unwrap_or(raw.position);
|
|
let first = raw.position.line.min(anchor.line);
|
|
let mut last = raw.position.line.max(anchor.line);
|
|
if selected {
|
|
let selected_text = content.selection().unwrap();
|
|
let line_breaks = selected_text.bytes().filter(|b| *b == b'\n').count();
|
|
// Effective selection starts on the minimum raw endpoint line.
|
|
last = first + line_breaks;
|
|
if last > first && selected_text.ends_with('\n') {
|
|
last -= 1;
|
|
}
|
|
}
|
|
let patches: Vec<_> = (first..=last)
|
|
.filter_map(|line| {
|
|
let removed = if outdent {
|
|
let text = content.line(line)?;
|
|
if text.text.starts_with('\t') {
|
|
1
|
|
} else {
|
|
text.text.bytes().take(4).take_while(|b| *b == b' ').count()
|
|
}
|
|
} else {
|
|
0
|
|
};
|
|
(!outdent || removed > 0).then_some((line, removed))
|
|
})
|
|
.collect();
|
|
if patches.is_empty() {
|
|
return;
|
|
}
|
|
let cursor = crate::source_navigation::selection::capture_for_edit(content);
|
|
let replacement = Arc::new(if outdent {
|
|
String::new()
|
|
} else {
|
|
INDENT.into()
|
|
});
|
|
// Descending order leaves Iced's earliest changed line at the first patch.
|
|
for &(line, removed) in patches.iter().rev() {
|
|
content.move_to(Cursor {
|
|
position: Position { line, column: 0 },
|
|
selection: Some(Position {
|
|
line,
|
|
column: removed,
|
|
}),
|
|
});
|
|
content.perform(Action::Edit(Edit::Paste(replacement.clone())));
|
|
}
|
|
let transform = |mut position: Position| {
|
|
if let Some((_, removed)) = patches.iter().find(|(line, _)| *line == position.line) {
|
|
position.column = if outdent {
|
|
position.column.saturating_sub(*removed)
|
|
} else {
|
|
position.column + INDENT.len()
|
|
};
|
|
}
|
|
position
|
|
};
|
|
content.move_to(Cursor {
|
|
position: transform(cursor.position),
|
|
selection: cursor.selection.map(transform),
|
|
});
|
|
}
|
|
|
|
#[cfg(all(test, debug_assertions))]
|
|
mod keyboard_tests;
|
|
#[cfg(test)]
|
|
mod tests;
|