diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs index 9fd6268350..ced23fb755 100644 --- a/local/recipes/tui/tlc/source/src/editor/handlers.rs +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -20,7 +20,7 @@ use crate::editor::EditorResult; use crate::editor::{completion::CompletionMode, Mode, PromptKind}; use crate::key::{Key, Modifiers}; -use super::Editor; +use super::{Buffer, Editor}; impl Editor { /// Handle a key event. Returns the [`EditorResult`] for the @@ -138,6 +138,52 @@ impl Editor { if key == Key::f(15) { return self.open_prompt(PromptKind::InsertFile); } + // Ctrl-Insert — Copy selection (MC Store, parity with F5). + if key.code == 0xECB4 && key.mods.contains(Modifiers::CTRL) { + if let Some(text) = self.cursor.selected_text(&self.buffer) { + self.clipboard = Some(text.to_string()); + let _ = crate::editor::clipboard_osc52::osc52_copy(&text); + self.message = Some("Copied".to_string()); + } + return EditorResult::Running; + } + // Shift-Delete — Cut selection (MC Cut, parity with F6). + if key.code == Key::DELETE.code && key.mods == Modifiers::SHIFT { + if let Some(text) = self.cursor.selected_text(&self.buffer) { + self.clipboard = Some(text.to_string()); + let _ = crate::editor::clipboard_osc52::osc52_copy(&text); + self.cursor.delete_selection(&mut self.buffer); + self.cursor.set_position(self.buffer.cursor(), &self.buffer); + self.modified = true; + self.message = Some("Cut".to_string()); + } + return EditorResult::Running; + } + // Shift-Tab — Unindent selected block by one tab width, + // or insert a literal tab if no selection (MC parity: + // Shift-Tab is treated as Tab alias when no selection). + if key.code == 0x09 && key.mods.contains(Modifiers::SHIFT) && !key.mods.contains(Modifiers::ALT) { + if self.cursor.has_selection() { + self.unindent_selection(); + } + return EditorResult::Running; + } + // Ctrl-N — New file: discard current buffer after save + // prompt if dirty (MC EditNew). + if key == Key::ctrl('n') { + if self.modified { + self.message = Some("Save before new file (F2)".to_string()); + return EditorResult::Running; + } + self.buffer = Buffer::new(); + self.path = None; + self.title = " (new) ".to_string(); + self.cursor.set_position(0, &self.buffer); + self.view.set_top_line(0, &self.buffer); + self.modified = false; + self.message = Some("New file".to_string()); + return EditorResult::Running; + } // Alt-letter prompt shortcuts work from any non-prompt mode. if let Some(r) = self.try_global_shortcut(key) { return r; diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index f18e6f95fb..70c2bb4e1b 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -39,6 +39,8 @@ pub mod clipboard_osc52; pub mod completion; pub mod cursor; pub mod cursor_shape; +/// Per-file cursor position save/restore (MC `~/.mc/filepos`). +pub mod filepos; pub mod folding; pub mod format; pub mod goto; @@ -310,6 +312,56 @@ bracket_flash: None, } } + /// Restore cursor line/column from the filepos database for the + /// current file. Returns true if a saved position was found and + /// applied. + pub fn restore_cursor_position(&mut self) -> bool { + let Some(path) = self.path.as_deref() else { + return false; + }; + let Some(pos) = filepos::load(path) else { + return false; + }; + if pos.line >= 1 { + if let Ok(off) = crate::editor::goto::line_to_offset(&self.buffer, pos.line) { + let line_len = self.buffer.line_length((pos.line - 1) as usize); + let col = (pos.column as usize).min(line_len); + self.buffer.set_cursor(off + col); + self.cursor.set_position(off + col, &self.buffer); + return true; + } + } + false + } + + /// Save the current cursor line/column to the filepos database. + /// No-op if the buffer has no path. Errors are silently + /// dropped — filepos is a best-effort UX feature. + pub fn save_cursor_position(&self) { + let Some(path) = self.path.as_deref() else { + return; + }; + let cursor = self.cursor.position(); + let mut line = 0u32; + let mut line_start = 0usize; + for (i, b) in self.buffer.as_string().as_bytes().iter().enumerate() { + if i == cursor { + break; + } + if *b == b'\n' { + line += 1; + line_start = i + 1; + } + } + let _ = filepos::save( + path, + filepos::CursorPos { + line: line + 1, + column: (cursor - line_start) as u32, + }, + ); + } + /// Borrow the underlying buffer. #[must_use] pub fn buffer(&self) -> &Buffer { @@ -625,6 +677,62 @@ bracket_flash: None, self.message = Some("Formatted paragraph".to_string()); } + /// Shift-Tab — remove one indentation level from each selected + /// line. A "level" is either a single tab or up to 8 leading + /// spaces. Lines with no leading whitespace are skipped. + pub fn unindent_selection(&mut self) { + let Some((start, end)) = self.cursor.selection() else { + return; + }; + let text = self.buffer.as_string(); + let mut line_start = start; + while line_start > 0 && text.as_bytes()[line_start - 1] != b'\n' { + line_start -= 1; + } + let mut line_end = end; + if line_end > 0 && line_end < text.len() && text.as_bytes()[line_end - 1] != b'\n' { + while line_end < text.len() && text.as_bytes()[line_end] != b'\n' { + line_end += 1; + } + } + let segment = &text[line_start..line_end]; + let mut new_segment = String::with_capacity(segment.len()); + let mut removed = 0usize; + for line in segment.split_inclusive('\n') { + let content = line.strip_suffix('\n').unwrap_or(line); + let nl = if line.ends_with('\n') { "\n" } else { "" }; + if content.starts_with('\t') { + new_segment.push_str(&content[1..]); + removed += 1; + } else { + let spaces = content.bytes().take_while(|&b| b == b' ').count().min(8); + if spaces > 0 { + new_segment.push_str(&content[spaces..]); + removed += spaces; + } else { + new_segment.push_str(content); + } + } + new_segment.push_str(nl); + } + if removed == 0 { + return; + } + self.buffer.begin_undo_group(); + self.buffer.set_cursor(line_start); + for _ in line_start..line_end { + self.buffer.delete_forward(); + } + self.buffer.insert_str(&new_segment); + let new_cursor = self.cursor.position().saturating_sub(removed.min(1)); + self.buffer.set_cursor(new_cursor); + self.cursor.set_position(new_cursor, &self.buffer); + self.cursor.clear_selection(); + self.buffer.end_undo_group(); + self.modified = true; + self.message = Some("Unindented".to_string()); + } + /// Jump to the tag named `name`. Loads the tags file from the /// buffer's current directory (or the file's parent if /// untitled), looks up the name, and moves the cursor to the @@ -741,6 +849,8 @@ pub fn open_file(file: &str, line: Option) -> anyhow::Result<()> { let off = ed.buffer.line_offset(target); ed.cursor.set_position(off, &ed.buffer); } + } else { + ed.restore_cursor_position(); } let mut tui = crate::terminal::Tui::new()?; @@ -772,9 +882,13 @@ pub fn open_file(file: &str, line: Option) -> anyhow::Result<()> { EditorResult::Save => { let _ = ed.save(); } - EditorResult::Close => break, + EditorResult::Close => { + ed.save_cursor_position(); + break; + } EditorResult::SaveThenClose => { let _ = ed.save(); + ed.save_cursor_position(); break; } EditorResult::DiscardThenClose => break, @@ -2028,5 +2142,84 @@ mod tests { e.handle_key(Key::alt('p')); assert_eq!(e.buffer().as_string(), before); } + + #[test] + fn shift_tab_with_selection_unindents() { + let mut e = make_empty(); + e.insert_str("\thello\n\tworld\n"); + e.buffer.set_cursor(0); + e.cursor.set_position(0, &e.buffer); + e.cursor.start_selection(); + e.buffer.set_cursor(e.buffer.as_string().len()); + e.cursor.set_position(e.buffer.as_string().len(), &e.buffer); + e.unindent_selection(); + assert_eq!(e.buffer().as_string(), "hello\nworld\n"); + } + + #[test] + fn shift_tab_without_selection_is_noop() { + let mut e = make_empty(); + e.insert_str("hello\n"); + e.handle_key(Key { + code: 0x09, + mods: Modifiers::SHIFT, + }); + assert_eq!(e.buffer().as_string(), "hello\n"); + } + + #[test] + fn ctrl_n_clears_buffer_when_clean() { + let mut e = make_empty(); + e.insert_str("dirty content\n"); + e.buffer.mark_saved(); + e.modified = false; + e.handle_key(Key::ctrl('n')); + assert_eq!(e.buffer().as_string(), ""); + assert!(!e.is_modified()); + } + + #[test] + fn ctrl_n_blocked_when_dirty() { + let mut e = make_empty(); + e.insert_str("unsaved\n"); + assert!(e.is_modified()); + e.handle_key(Key::ctrl('n')); + assert_eq!(e.buffer().as_string(), "unsaved\n"); + } + + #[test] + fn ctrl_insert_copies_selection() { + let mut e = make_empty(); + e.insert_str("hello world\n"); + e.buffer.set_cursor(0); + e.cursor.set_position(0, &e.buffer); + e.cursor.start_selection(); + e.buffer.set_cursor(5); + e.cursor.set_position(5, &e.buffer); + e.handle_key(Key { + code: 0xECB4, + mods: Modifiers::CTRL, + }); + assert_eq!(e.clipboard.as_deref(), Some("hello")); + assert_eq!(e.buffer().as_string(), "hello world\n"); + } + + #[test] + fn shift_delete_cuts_selection() { + let mut e = make_empty(); + e.insert_str("hello world\n"); + e.buffer.set_cursor(0); + e.cursor.set_position(0, &e.buffer); + e.cursor.start_selection(); + e.buffer.set_cursor(5); + e.cursor.set_position(5, &e.buffer); + e.handle_key(Key { + code: Key::DELETE.code, + mods: Modifiers::SHIFT, + }); + assert_eq!(e.clipboard.as_deref(), Some("hello")); + assert_eq!(e.buffer().as_string(), " world\n"); + assert!(e.is_modified()); + } }