diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs index ced23fb755..13d075de63 100644 --- a/local/recipes/tui/tlc/source/src/editor/handlers.rs +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -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 diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 70c2bb4e1b..a2eb30313f 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -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"); + } }