From 4d04d01c0ad8e9f5a377d142382d16835836d17e Mon Sep 17 00:00:00 2001 From: Sawyer X Date: Wed, 5 Aug 2026 20:56:50 +0200 Subject: [PATCH] GH 943: Add more hotkeys for deleting: * Delete to line start - Linux/Windows: Control + Shift + Backspace - Mac: Command + Backspace * Delete to line end - Linux/Windows: Control + Shift + Delete - Mac: Command + Delete For Linux/Windows, Shift should be explicitly disabled on "delete previous word" and "delete next word" (was not checked before) so it doesn't conflate. Lots and lots of testing. --- src/Window.zig | 12 +- src/widgets/TextEntryWidget.zig | 203 ++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 4 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index 7843a6978..e169912fb 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -321,8 +321,10 @@ pub fn init( try self.keybinds.putNoClobber(self.gpa, "char_up_select", .{ .key = .up, .shift = true }); try self.keybinds.putNoClobber(self.gpa, "char_down_select", .{ .key = .down, .shift = true }); - try self.keybinds.putNoClobber(self.gpa, "delete_prev_word", .{ .key = .backspace, .control = true }); - try self.keybinds.putNoClobber(self.gpa, "delete_next_word", .{ .key = .delete, .control = true }); + try self.keybinds.putNoClobber(self.gpa, "delete_prev_word", .{ .key = .backspace, .control = true, .shift = false }); + try self.keybinds.putNoClobber(self.gpa, "delete_next_word", .{ .key = .delete, .control = true, .shift = false }); + try self.keybinds.putNoClobber(self.gpa, "delete_to_line_start", .{ .key = .backspace, .control = true, .shift = true }); + try self.keybinds.putNoClobber(self.gpa, "delete_to_line_end", .{ .key = .delete, .control = true, .shift = true }); // zig fmt: on }, .mac => { @@ -360,8 +362,10 @@ pub fn init( try self.keybinds.putNoClobber(self.gpa, "char_up_select", .{ .key = .up, .shift = true, .command = false }); try self.keybinds.putNoClobber(self.gpa, "char_down_select", .{ .key = .down, .shift = true, .command = false }); - try self.keybinds.putNoClobber(self.gpa, "delete_prev_word", .{ .key = .backspace, .alt = true }); - try self.keybinds.putNoClobber(self.gpa, "delete_next_word", .{ .key = .delete, .alt = true }); + try self.keybinds.putNoClobber(self.gpa, "delete_prev_word", .{ .key = .backspace, .alt = true }); + try self.keybinds.putNoClobber(self.gpa, "delete_next_word", .{ .key = .delete, .alt = true }); + try self.keybinds.putNoClobber(self.gpa, "delete_to_line_start", .{ .key = .backspace, .command = true, .alt = false }); + try self.keybinds.putNoClobber(self.gpa, "delete_to_line_end", .{ .key = .delete, .command = true, .alt = false }); // zig fmt: on }, } diff --git a/src/widgets/TextEntryWidget.zig b/src/widgets/TextEntryWidget.zig index 90d282834..f626199ad 100644 --- a/src/widgets/TextEntryWidget.zig +++ b/src/widgets/TextEntryWidget.zig @@ -970,6 +970,20 @@ pub fn processEvent(self: *TextEntryWidget, e: *Event) void { sel.end = sel.start; sel.cursor = sel.start; self.textLayout.scroll_to_cursor = true; + } else if (ke.matchBind("delete_to_line_start")) { + // delete from the start of the current line up to the cursor + + const oldcur = sel.cursor; + // line starts just after the previous newline, or at buffer start + sel.cursor = if (std.mem.findLastAny(u8, self.text[0..oldcur], "\n")) |nl| nl + 1 else 0; + + // delete from sel.cursor to oldcur + if (sel.cursor != oldcur) self.textChangedRemoved(sel.cursor, oldcur); + @memmove(self.text[sel.cursor..][0 .. self.len - oldcur], self.text[oldcur..self.len]); + self.setLen(self.len - (oldcur - sel.cursor)); + sel.end = sel.cursor; + sel.start = sel.cursor; + self.textLayout.scroll_to_cursor = true; } else if (ke.matchBind("delete_prev_word")) { // delete word before cursor @@ -1024,6 +1038,21 @@ pub fn processEvent(self: *TextEntryWidget, e: *Event) void { sel.end = sel.start; sel.cursor = sel.start; self.textLayout.scroll_to_cursor = true; + } else if (ke.matchBind("delete_to_line_end")) { + // delete from the cursor up to the end of the current line + + const oldcur = sel.cursor; + // line ends at the next newline, or at buffer end + const line_end = if (std.mem.findAny(u8, self.text[oldcur..self.len], "\n")) |rel| oldcur + rel else self.len; + + // delete from oldcur to line_end + if (line_end != oldcur) self.textChangedRemoved(oldcur, line_end); + @memmove(self.text[oldcur..][0 .. self.len - line_end], self.text[line_end..self.len]); + self.setLen(self.len - (line_end - oldcur)); + sel.cursor = oldcur; + sel.end = sel.cursor; + sel.start = sel.cursor; + self.textLayout.scroll_to_cursor = true; } else if (ke.matchBind("delete_next_word")) { // delete word after cursor @@ -1395,3 +1424,177 @@ test "text array_list" { _ = try dvui.testing.step(Local.frame); try std.testing.expectEqualStrings(text, Local.text); } + +test "text delete to line start and end" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + // testing.init pins the .windows keybind set, so the line-delete chord is + // Ctrl+Shift+Backspace / Ctrl+Shift+Delete regardless of host OS + var ctrl_shift: dvui.enums.Mod = .lcontrol; + ctrl_shift.combine(.lshift); + + const Local = struct { + var text: []const u8 = ""; + // When set, the frame replaces the buffer with this and clears the flag, + // giving each case known starting point with the cursor at the end + var reset: ?[]const u8 = null; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{}, .{ .tag = "entry" }); + defer entry.deinit(); + + if (reset) |s| { + entry.textSet(s, false); + reset = null; + } + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + // Delete to line start clears everything before the cursor + // With the cursor at end of single-line entry, that empties the field + Local.reset = "hello world"; + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("hello world", Local.text); + try dvui.testing.pressKey(.backspace, ctrl_shift); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("", Local.text); + + // Delete to line end clears everything from the cursor onward + // With the cursor at start (Ctrl+Home), that empties the field + Local.reset = "hello world"; + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.home, .lcontrol); + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.delete, ctrl_shift); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("", Local.text); + + // Mid-line delete to start removes only text left of cursor + // The cursor sits just before 'w', so "hello " goes and "world" stays + Local.reset = "hello world"; + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.home, .lcontrol); + try dvui.testing.settle(Local.frame); + for (0..6) |_| try dvui.testing.pressKey(.right, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.backspace, ctrl_shift); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("world", Local.text); + + // Mid-line delete to end removes only text right of the cursor + // The cursor sits just after "hello", so " world" goes and "hello" stays + Local.reset = "hello world"; + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.home, .lcontrol); + try dvui.testing.settle(Local.frame); + for (0..5) |_| try dvui.testing.pressKey(.right, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.delete, ctrl_shift); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("hello", Local.text); + + // Regression for the shift=false correction on the word binds + // plain Ctrl+Backspace must still delete the previous word, not whole line + Local.reset = "hello world"; + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.backspace, .lcontrol); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("hello ", Local.text); +} + +test "text delete to line start and end multiline" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + var ctrl_shift: dvui.enums.Mod = .lcontrol; + ctrl_shift.combine(.lshift); + + const Local = struct { + var text: []const u8 = ""; + var reset: ?[]const u8 = null; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ .multiline = true }, .{ .tag = "entry" }); + defer entry.deinit(); + + if (reset) |s| { + entry.textSet(s, false); + reset = null; + } + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + // Three lines: "abc" / "def" / "ghi" + // Positioning uses line-boundary snaps (Ctrl+Home, Home, End, Up) + // so it never depends on how many caret steps a newline costs + + // Delete to line start stops at the newline before the current line: + // only the last line's text is removed, the earlier lines and newlines stay + Local.reset = "abc\ndef\nghi"; + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.backspace, ctrl_shift); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("abc\ndef\n", Local.text); + + // Delete to line end stops at the newline after the current line: + // only the first line's text is removed, its trailing newline and the rest stay + Local.reset = "abc\ndef\nghi"; + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.home, .lcontrol); + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.delete, ctrl_shift); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("\ndef\nghi", Local.text); + + // On the middle line, delete to start with cursor at line end removes + // exactly that line's content, leaving both surrounding newlines + Local.reset = "abc\ndef\nghi"; + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.up, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.end, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.backspace, ctrl_shift); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("abc\n\nghi", Local.text); + + // Column 0 of a middle line: delete to start is no-op + // It must not eat preceding newline and join with line above + Local.reset = "abc\ndef\nghi"; + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.up, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.home, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.backspace, ctrl_shift); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("abc\ndef\nghi", Local.text); + // Same cursor, delete to end removes the line's content + // Confirms cursor really was at start of middle line + try dvui.testing.pressKey(.delete, ctrl_shift); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings("abc\n\nghi", Local.text); +}