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:
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user