Files
ARCADIA-Studio/crates/oa-desktop/src/source_indentation.rs
T

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;