tlc: more editor bindings — Ctrl-Insert, Shift-Delete, Ctrl-N, Shift-Tab

Ctrl-Insert — Copy selection (MC Store, parity with F5).

Shift-Delete — Cut selection (MC Cut, parity with F6).

Ctrl-N — EditNew: clear buffer after save prompt if dirty.

Shift-Tab — Unindent selection by one tab width (MC parity).

Editor::unindent_selection() handles tab/space removal. Tests cover each binding's positive and negative paths.
This commit is contained in:
2026-06-20 11:18:46 +03:00
parent d6aaf4e8af
commit d97e5b0f1a
2 changed files with 241 additions and 2 deletions
@@ -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;
+194 -1
View File
@@ -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<u64>) -> 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<u64>) -> 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());
}
}