tlc: line-level ops — F5/F6/F8 on no-selection + select-line + duplicate

F5 (Copy), F6 (Move/Cut), F8 (Delete) now operate on the current line when no selection is active, matching MC semantics.

New bindings:

  Alt-Shift-L — select current line (incl. trailing newline)

  Ctrl-Shift-D — duplicate current line or selected block

New methods on Editor: select_current_line, copy_line, cut_line, delete_line_no_sel, duplicate_line_or_selection. 6 new tests cover both with-selection and without-selection paths.
This commit is contained in:
2026-06-20 12:30:25 +03:00
parent f9ffe1947a
commit e2d4da441f
2 changed files with 251 additions and 17 deletions
@@ -396,40 +396,57 @@ impl Editor {
}
return EditorResult::Running;
}
// F5 — Copy block.
// F5 — Copy block (or current line if no selection).
if key == Key::f(5) {
if let Some(text) = self.cursor.selected_text(&self.buffer) {
self.clipboard = Some(text.to_string());
// Mirror to the host terminal via OSC 52 so the
// selection is available to other apps on the
// host (and over SSH). Failures are silent.
let _ = crate::editor::clipboard_osc52::osc52_copy(&text);
self.message = Some("Block copied".to_string());
if self.cursor.has_selection() {
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("Block copied".to_string());
}
} else {
let _ = self.copy_line();
}
return EditorResult::Running;
}
// F6 — Move (cut) block.
// F6 — Move (cut) block (or current line if no selection).
if key == Key::f(6) {
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("Block moved".to_string());
if self.cursor.has_selection() {
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("Block moved".to_string());
}
} else {
let _ = self.cut_line();
}
return EditorResult::Running;
}
// F8 — Delete block.
// F8 — Delete block (or current line if no selection).
if key == Key::f(8) {
if self.cursor.has_selection() {
self.cursor.delete_selection(&mut self.buffer);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
self.message = Some("Block deleted".to_string());
} else {
let _ = self.delete_line_no_sel();
}
return EditorResult::Running;
}
// Alt-Shift-L — Select current line (including newline).
if key.mods == (Modifiers::ALT | Modifiers::SHIFT) && key.code == 0x6C {
self.select_current_line();
return EditorResult::Running;
}
// Ctrl-Shift-D — Duplicate current line or selected block.
if key.mods == (Modifiers::CTRL | Modifiers::SHIFT) && key.code == 0x44 {
let _ = self.duplicate_line_or_selection();
return EditorResult::Running;
}
// Ctrl-F1 — toggle a code fold at the cursor line. The
// fold spans the next 10 lines by default; a future
// language-aware impl will derive the span from
@@ -733,6 +733,145 @@ bracket_flash: None,
self.message = Some("Unindented".to_string());
}
/// Select the current line (including its trailing newline if
/// any). Cursor moves to the start of the next line; anchor is
/// left at the start of the current line.
pub fn select_current_line(&mut self) {
let total = self.buffer.as_string().len();
let bytes = self.buffer.as_string().into_bytes();
let mut line_start = self.buffer.cursor();
while line_start > 0 && bytes[line_start - 1] != b'\n' {
line_start -= 1;
}
let mut line_end = self.buffer.cursor();
while line_end < total && bytes[line_end] != b'\n' {
line_end += 1;
}
if line_end < total {
line_end += 1;
}
self.buffer.set_cursor(line_start);
self.cursor.set_position(line_start, &self.buffer);
self.cursor.start_selection();
self.cursor.set_position(line_end, &self.buffer);
}
/// Copy the current line (with newline) to the clipboard when no
/// selection is active. Returns true if a line was copied.
pub fn copy_line(&mut self) -> bool {
if self.cursor.has_selection() {
return false;
}
let total = self.buffer.as_string().len();
let bytes = self.buffer.as_string().into_bytes();
let mut line_start = self.buffer.cursor();
while line_start > 0 && bytes[line_start - 1] != b'\n' {
line_start -= 1;
}
let mut line_end = self.buffer.cursor();
while line_end < total && bytes[line_end] != b'\n' {
line_end += 1;
}
if line_end < total {
line_end += 1;
}
let text = String::from_utf8_lossy(&bytes[line_start..line_end]).into_owned();
self.clipboard = Some(text.clone());
let _ = crate::editor::clipboard_osc52::osc52_copy(&text);
self.message = Some("Line copied".to_string());
true
}
/// Cut the current line (with newline) to the clipboard when no
/// selection is active. Returns true if a line was cut.
pub fn cut_line(&mut self) -> bool {
if self.cursor.has_selection() {
return false;
}
let total = self.buffer.as_string().len();
let bytes = self.buffer.as_string().into_bytes();
let mut line_start = self.buffer.cursor();
while line_start > 0 && bytes[line_start - 1] != b'\n' {
line_start -= 1;
}
let mut line_end = self.buffer.cursor();
while line_end < total && bytes[line_end] != b'\n' {
line_end += 1;
}
if line_end < total {
line_end += 1;
}
let text = String::from_utf8_lossy(&bytes[line_start..line_end]).into_owned();
self.clipboard = Some(text.clone());
let _ = crate::editor::clipboard_osc52::osc52_copy(&text);
self.buffer.begin_undo_group();
self.buffer.set_cursor(line_start);
for _ in line_start..line_end {
self.buffer.delete_forward();
}
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.cursor.clear_selection();
self.buffer.end_undo_group();
self.modified = true;
self.message = Some("Line cut".to_string());
true
}
/// Delete the current line (with newline) when no selection is
/// active. Returns true if a line was deleted.
pub fn delete_line_no_sel(&mut self) -> bool {
if self.cursor.has_selection() {
return false;
}
self.buffer.delete_line();
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.cursor.clear_selection();
self.modified = true;
self.message = Some("Line deleted".to_string());
true
}
/// Duplicate the current line (with newline) when no selection
/// is active, or duplicate the selected block otherwise.
/// Returns true if anything was duplicated.
pub fn duplicate_line_or_selection(&mut self) -> bool {
let total = self.buffer.as_string().len();
let bytes = self.buffer.as_string().into_bytes();
if let Some((s, e)) = self.cursor.selection() {
let text = String::from_utf8_lossy(&bytes[s..e]).into_owned();
self.buffer.begin_undo_group();
self.buffer.set_cursor(e);
self.buffer.insert_str(&text);
self.cursor.set_position(e + text.len(), &self.buffer);
self.cursor.clear_selection();
self.buffer.end_undo_group();
self.modified = true;
self.message = Some("Duplicated".to_string());
return true;
}
let mut line_start = self.buffer.cursor();
while line_start > 0 && bytes[line_start - 1] != b'\n' {
line_start -= 1;
}
let mut line_end = self.buffer.cursor();
while line_end < total && bytes[line_end] != b'\n' {
line_end += 1;
}
if line_end < total {
line_end += 1;
}
let text = String::from_utf8_lossy(&bytes[line_start..line_end]).into_owned();
self.buffer.begin_undo_group();
self.buffer.set_cursor(line_end);
self.buffer.insert_str(&text);
self.cursor.set_position(line_end, &self.buffer);
self.cursor.clear_selection();
self.buffer.end_undo_group();
self.modified = true;
self.message = Some("Line duplicated".to_string());
true
}
/// 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
@@ -2221,5 +2360,83 @@ mod tests {
assert_eq!(e.buffer().as_string(), " world\n");
assert!(e.is_modified());
}
#[test]
fn f5_without_selection_copies_line() {
let mut e = make_empty();
e.insert_str("first\nsecond\nthird\n");
e.buffer.set_cursor(8);
e.cursor.set_position(8, &e.buffer);
e.handle_key(Key::f(5));
assert_eq!(e.clipboard.as_deref(), Some("second\n"));
assert_eq!(e.buffer().as_string(), "first\nsecond\nthird\n");
}
#[test]
fn f6_without_selection_cuts_line() {
let mut e = make_empty();
e.insert_str("first\nsecond\nthird\n");
e.buffer.set_cursor(8);
e.cursor.set_position(8, &e.buffer);
e.handle_key(Key::f(6));
assert_eq!(e.clipboard.as_deref(), Some("second\n"));
assert_eq!(e.buffer().as_string(), "first\nthird\n");
assert!(e.is_modified());
}
#[test]
fn f8_without_selection_deletes_line() {
let mut e = make_empty();
e.insert_str("first\nsecond\nthird\n");
e.buffer.set_cursor(8);
e.cursor.set_position(8, &e.buffer);
e.handle_key(Key::f(8));
assert_eq!(e.buffer().as_string(), "first\nthird\n");
assert!(e.is_modified());
}
#[test]
fn alt_shift_l_selects_line() {
let mut e = make_empty();
e.insert_str("first\nsecond\nthird\n");
e.buffer.set_cursor(8);
e.cursor.set_position(8, &e.buffer);
e.handle_key(Key {
code: 0x6C,
mods: Modifiers::ALT | Modifiers::SHIFT,
});
assert!(e.cursor.has_selection());
let text = e.cursor.selected_text(&e.buffer).unwrap();
assert_eq!(text, "second\n");
}
#[test]
fn ctrl_shift_d_duplicates_line() {
let mut e = make_empty();
e.insert_str("first\nsecond\nthird\n");
e.buffer.set_cursor(8);
e.cursor.set_position(8, &e.buffer);
e.handle_key(Key {
code: 0x44,
mods: Modifiers::CTRL | Modifiers::SHIFT,
});
assert_eq!(e.buffer().as_string(), "first\nsecond\nsecond\nthird\n");
}
#[test]
fn ctrl_shift_d_duplicates_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: 0x44,
mods: Modifiers::CTRL | Modifiers::SHIFT,
});
assert_eq!(e.buffer().as_string(), "hellohello world\n");
}
}