//! 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(content: &mut Content, outdent: bool) where R: text::Renderer, { 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;