Adds a caret mode for keyboard-driven navigation of the scrollback
Uses VIM hotkeys as the default. The implementation is functionally the same as vim visual mode or the copy mode found in tmux & WezTerm. Written as an optional feature which is off by default. Requires a simple one line addition to the config file to create a keybind which activates caret mode.pull/12326/head
parent
49a43bf560
commit
d49ff88c8e
129
src/Surface.zig
129
src/Surface.zig
|
|
@ -2678,6 +2678,13 @@ pub fn keyCallback(
|
|||
if (self.io.terminal.modes.get(.disable_keyboard)) return .consumed;
|
||||
}
|
||||
|
||||
// In caret mode, unbound keys are dicarded.
|
||||
{
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
if (self.io.terminal.screens.active.caret_mode) return .consumed;
|
||||
}
|
||||
|
||||
// If this input event has text, then we hide the mouse if configured.
|
||||
// We only do this on pressed events to avoid hiding the mouse when we
|
||||
// change focus due to a keybinding (i.e. switching tabs).
|
||||
|
|
@ -5819,6 +5826,128 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
|||
screen.dirty.selection = true;
|
||||
try self.queueRender();
|
||||
},
|
||||
|
||||
.enter_caret_mode => {
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
|
||||
const screen: *terminal.Screen = self.io.terminal.screens.active;
|
||||
if (screen.caret_mode) return false;
|
||||
|
||||
try screen.enterCaretMode();
|
||||
|
||||
// Push the "caret" key table so caret bindings become active.
|
||||
if (self.config.keybind.tables.getPtr("caret")) |set| {
|
||||
if (self.keyboard.table_stack.items.len < max_active_key_tables) {
|
||||
try self.keyboard.table_stack.append(self.alloc, .{
|
||||
.set = set,
|
||||
.once = false,
|
||||
});
|
||||
_ = self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.key_table,
|
||||
.{ .activate = "caret" },
|
||||
) catch |err| {
|
||||
log.warn("failed to notify app of key table err={}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll viewport to show the caret (it starts at the terminal
|
||||
// cursor which is always in the active area, so this is a no-op
|
||||
// in the common case but handles edge cases).
|
||||
if (screen.caret_pin) |cp| caret_scroll: {
|
||||
const viewport_tl = screen.pages.getTopLeft(.viewport);
|
||||
const viewport_br = screen.pages.getBottomRight(.viewport).?;
|
||||
if (cp.*.isBetween(viewport_tl, viewport_br)) break :caret_scroll;
|
||||
screen.scroll(.{ .pin = cp.* });
|
||||
}
|
||||
|
||||
try self.queueRender();
|
||||
},
|
||||
|
||||
.exit_caret_mode => {
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
|
||||
const screen: *terminal.Screen = self.io.terminal.screens.active;
|
||||
if (!screen.caret_mode) return false;
|
||||
|
||||
screen.exitCaretMode();
|
||||
|
||||
// Pop the "caret" key table.
|
||||
switch (self.keyboard.table_stack.items.len) {
|
||||
0 => {},
|
||||
1 => self.keyboard.table_stack.clearAndFree(self.alloc),
|
||||
else => _ = self.keyboard.table_stack.pop(),
|
||||
}
|
||||
_ = self.rt_app.performAction(
|
||||
.{ .surface = self },
|
||||
.key_table,
|
||||
.deactivate,
|
||||
) catch |err| {
|
||||
log.warn("failed to notify app of key table err={}", .{err});
|
||||
};
|
||||
|
||||
try self.queueRender();
|
||||
},
|
||||
|
||||
.move_caret => |direction| {
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
|
||||
const screen: *terminal.Screen = self.io.terminal.screens.active;
|
||||
if (!screen.caret_mode) return false;
|
||||
|
||||
screen.moveCaret(switch (direction) {
|
||||
.left => .left,
|
||||
.right => .right,
|
||||
.up => .up,
|
||||
.down => .down,
|
||||
.page_up => .page_up,
|
||||
.page_down => .page_down,
|
||||
.home => .home,
|
||||
.end => .end,
|
||||
.beginning_of_line => .beginning_of_line,
|
||||
.end_of_line => .end_of_line,
|
||||
});
|
||||
|
||||
// Scroll viewport to keep caret in view.
|
||||
if (screen.caret_pin) |cp| caret_scroll: {
|
||||
const viewport_tl = screen.pages.getTopLeft(.viewport);
|
||||
const viewport_br = screen.pages.getBottomRight(.viewport).?;
|
||||
if (cp.*.isBetween(viewport_tl, viewport_br)) break :caret_scroll;
|
||||
|
||||
const target = if (cp.*.before(viewport_tl))
|
||||
cp.*
|
||||
else
|
||||
cp.*.up(screen.pages.rows - 1) orelse cp.*;
|
||||
|
||||
screen.scroll(.{ .pin = target });
|
||||
}
|
||||
|
||||
screen.dirty.caret = true;
|
||||
try self.queueRender();
|
||||
},
|
||||
|
||||
.toggle_caret_selection => {
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
|
||||
const screen: *terminal.Screen = self.io.terminal.screens.active;
|
||||
if (!screen.caret_mode) return false;
|
||||
|
||||
if (screen.selection != null) {
|
||||
// Clear the existing selection.
|
||||
screen.clearSelection();
|
||||
} else if (screen.caret_pin) |cp| {
|
||||
// Anchor a new selection at the caret position.
|
||||
const sel = terminal.Selection.init(cp.*, cp.*, false);
|
||||
try screen.select(sel);
|
||||
}
|
||||
|
||||
try self.queueRender();
|
||||
},
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -6581,6 +6581,60 @@ pub const Keybinds = struct {
|
|||
.{ .performable = true },
|
||||
);
|
||||
|
||||
// Built-in "caret" key table for keyboard-driven scrollback navigation.
|
||||
// Users activate it by binding `enter_caret_mode` to a key.
|
||||
{
|
||||
const gop = try self.tables.getOrPut(alloc, "caret");
|
||||
if (!gop.found_existing) {
|
||||
gop.key_ptr.* = "caret";
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
const t = gop.value_ptr;
|
||||
|
||||
// Line movement
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'j' } }, .{ .move_caret = .down });
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'k' } }, .{ .move_caret = .up });
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'h' } }, .{ .move_caret = .left });
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'l' } }, .{ .move_caret = .right });
|
||||
|
||||
// Arrow key movement
|
||||
try t.put(alloc, .{ .key = .{ .physical = .arrow_up } }, .{ .move_caret = .up });
|
||||
try t.put(alloc, .{ .key = .{ .physical = .arrow_down } }, .{ .move_caret = .down });
|
||||
try t.put(alloc, .{ .key = .{ .physical = .arrow_left } }, .{ .move_caret = .left });
|
||||
try t.put(alloc, .{ .key = .{ .physical = .arrow_right } }, .{ .move_caret = .right });
|
||||
|
||||
// Page movement
|
||||
try t.put(alloc, .{ .key = .{ .physical = .page_up } }, .{ .move_caret = .page_up });
|
||||
try t.put(alloc, .{ .key = .{ .physical = .page_down } }, .{ .move_caret = .page_down });
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'd' }, .mods = .{ .ctrl = true } }, .{ .move_caret = .page_down });
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'u' }, .mods = .{ .ctrl = true } }, .{ .move_caret = .page_up });
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'f' }, .mods = .{ .ctrl = true } }, .{ .move_caret = .page_down });
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'b' }, .mods = .{ .ctrl = true } }, .{ .move_caret = .page_up });
|
||||
|
||||
// Jump to top/bottom
|
||||
t.parseAndPut(alloc, "g>g=move_caret:home") catch unreachable;
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'G' }, .mods = .{ .shift = true } }, .{ .move_caret = .end });
|
||||
try t.put(alloc, .{ .key = .{ .unicode = '0' } }, .{ .move_caret = .beginning_of_line });
|
||||
try t.put(alloc, .{ .key = .{ .unicode = '$' }, .mods = .{ .shift = true } }, .{ .move_caret = .end_of_line });
|
||||
|
||||
// Search
|
||||
try t.put(alloc, .{ .key = .{ .unicode = '/' } }, .start_search);
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'n' } }, .{ .navigate_search = .next });
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'N' }, .mods = .{ .shift = true } }, .{ .navigate_search = .previous });
|
||||
|
||||
// Selection
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'v' } }, .toggle_caret_selection);
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'y' } }, .{ .copy_to_clipboard = .mixed });
|
||||
|
||||
// Exit
|
||||
try t.put(alloc, .{ .key = .{ .physical = .escape } }, .exit_caret_mode);
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'q' } }, .exit_caret_mode);
|
||||
try t.put(alloc, .{ .key = .{ .unicode = 'i' } }, .exit_caret_mode);
|
||||
|
||||
// Swallow all unbound keys so they don't reach the terminal.
|
||||
t.parseAndPut(alloc, "catch_all=ignore") catch unreachable;
|
||||
}
|
||||
|
||||
// Tabs common to all platforms
|
||||
try self.set.put(
|
||||
alloc,
|
||||
|
|
|
|||
|
|
@ -482,6 +482,26 @@ pub const Action = union(enum) {
|
|||
///
|
||||
adjust_selection: AdjustSelection,
|
||||
|
||||
/// Enter caret (keyboard navigation) mode. The caret is placed at the
|
||||
/// current terminal cursor position. Also pushes the "caret" key table.
|
||||
/// No-op if caret mode is already active.
|
||||
enter_caret_mode,
|
||||
|
||||
/// Exit caret mode. Also pops the "caret" key table.
|
||||
exit_caret_mode,
|
||||
|
||||
/// Move the caret in caret mode. No-op if caret mode is not active.
|
||||
///
|
||||
/// Valid arguments are the same as `adjust_selection`:
|
||||
/// `left`, `right`, `up`, `down`, `page_up`, `page_down`,
|
||||
/// `home`, `end`, `beginning_of_line`, `end_of_line`.
|
||||
move_caret: MoveCaret,
|
||||
|
||||
/// Toggle a selection anchored at the caret position. If no selection
|
||||
/// exists, one is created at the current caret. If a selection exists,
|
||||
/// it is cleared. No-op if caret mode is not active.
|
||||
toggle_caret_selection,
|
||||
|
||||
/// Jump the viewport forward or back by the given number of prompts.
|
||||
///
|
||||
/// Requires shell integration.
|
||||
|
|
@ -994,6 +1014,19 @@ pub const Action = union(enum) {
|
|||
end_of_line,
|
||||
};
|
||||
|
||||
pub const MoveCaret = enum {
|
||||
left,
|
||||
right,
|
||||
up,
|
||||
down,
|
||||
page_up,
|
||||
page_down,
|
||||
home,
|
||||
end,
|
||||
beginning_of_line,
|
||||
end_of_line,
|
||||
};
|
||||
|
||||
pub const SplitDirection = enum {
|
||||
right,
|
||||
down,
|
||||
|
|
@ -1347,6 +1380,10 @@ pub const Action = union(enum) {
|
|||
.scroll_page_fractional,
|
||||
.scroll_page_lines,
|
||||
.adjust_selection,
|
||||
.enter_caret_mode,
|
||||
.exit_caret_mode,
|
||||
.move_caret,
|
||||
.toggle_caret_selection,
|
||||
.jump_to_prompt,
|
||||
.write_scrollback_file,
|
||||
.write_screen_file,
|
||||
|
|
|
|||
|
|
@ -696,6 +696,10 @@ fn actionCommands(action: Action.Key) []const Command {
|
|||
.scroll_page_fractional,
|
||||
.scroll_page_lines,
|
||||
.adjust_selection,
|
||||
.enter_caret_mode,
|
||||
.exit_caret_mode,
|
||||
.move_caret,
|
||||
.toggle_caret_selection,
|
||||
.jump_to_prompt,
|
||||
.write_scrollback_file,
|
||||
.goto_tab,
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ pub const Contents = struct {
|
|||
fg_rows.lists[0].deinit(alloc);
|
||||
fg_rows.lists[0] = try .initCapacity(alloc, 1);
|
||||
fg_rows.lists[size.rows + 1].deinit(alloc);
|
||||
fg_rows.lists[size.rows + 1] = try .initCapacity(alloc, 1);
|
||||
fg_rows.lists[size.rows + 1] = try .initCapacity(alloc, 2);
|
||||
|
||||
// Perform the swap, no going back from here.
|
||||
errdefer comptime unreachable;
|
||||
|
|
@ -156,6 +156,14 @@ pub const Contents = struct {
|
|||
}
|
||||
}
|
||||
|
||||
/// Set the caret (keyboard navigation) glyph. Unlike setCursor, this does
|
||||
/// not clear any existing cursor glyphs — it appends alongside them.
|
||||
/// The caret is always drawn last (on top of text).
|
||||
pub fn setCaret(self: *Contents, v: shaderpkg.CellText) void {
|
||||
if (self.size.rows == 0) return;
|
||||
self.fg_rows.lists[self.size.rows + 1].appendAssumeCapacity(v);
|
||||
}
|
||||
|
||||
/// Returns the current cursor glyph if present, checking both cursor lists.
|
||||
pub fn getCursorGlyph(self: *Contents) ?shaderpkg.CellText {
|
||||
if (self.size.rows == 0) return null;
|
||||
|
|
|
|||
|
|
@ -2577,6 +2577,43 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
}
|
||||
}
|
||||
|
||||
// Draw the caret (keyboard navigation cursor) as a hollow block.
|
||||
caret: {
|
||||
const caret_vp = state.caret orelse break :caret;
|
||||
const x: u16 = if (caret_vp.wide_tail)
|
||||
caret_vp.x - 1
|
||||
else
|
||||
caret_vp.x;
|
||||
|
||||
const caret_color = state.colors.foreground;
|
||||
|
||||
const render = self.font_grid.renderGlyph(
|
||||
self.alloc,
|
||||
font.sprite_index,
|
||||
@intFromEnum(font.Sprite.cursor_hollow_rect),
|
||||
.{
|
||||
.cell_width = 1,
|
||||
.grid_metrics = self.grid_metrics,
|
||||
},
|
||||
) catch |err| {
|
||||
log.warn("error rendering caret glyph err={}", .{err});
|
||||
break :caret;
|
||||
};
|
||||
|
||||
self.cells.setCaret(.{
|
||||
.atlas = .grayscale,
|
||||
.bools = .{ .is_cursor_glyph = true },
|
||||
.grid_pos = .{ x, caret_vp.y },
|
||||
.color = .{ caret_color.r, caret_color.g, caret_color.b, 255 },
|
||||
.glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
|
||||
.glyph_size = .{ render.glyph.width, render.glyph.height },
|
||||
.bearings = .{
|
||||
@intCast(render.glyph.offset_x),
|
||||
@intCast(render.glyph.offset_y),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Setup our preedit text.
|
||||
if (preedit) |preedit_v| preedit: {
|
||||
const range = preedit_range orelse break :preedit;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ saved_cursor: ?SavedCursor = null,
|
|||
/// automatically setup tracking.
|
||||
selection: ?Selection = null,
|
||||
|
||||
/// True when caret (keyboard navigation) mode is active.
|
||||
caret_mode: bool = false,
|
||||
|
||||
/// Tracked position of the caret for keyboard navigation. Always a tracked
|
||||
/// pin so it stays valid as the buffer scrolls. Null when caret_mode is false.
|
||||
caret_pin: ?*Pin = null,
|
||||
|
||||
/// The charset state
|
||||
charset: CharsetState = .{},
|
||||
|
||||
|
|
@ -89,6 +96,9 @@ pub const Dirty = packed struct {
|
|||
/// When an OSC8 hyperlink is hovered, we set the full screen as dirty
|
||||
/// because links can span multiple lines.
|
||||
hyperlink_hover: bool = false,
|
||||
|
||||
/// Set when the caret position changes or caret mode is toggled.
|
||||
caret: bool = false,
|
||||
};
|
||||
|
||||
pub const SemanticPrompt = struct {
|
||||
|
|
@ -2435,6 +2445,95 @@ pub fn clearSelection(self: *Screen) void {
|
|||
self.selection = null;
|
||||
}
|
||||
|
||||
/// Enter caret mode. The caret is initialized at the current terminal
|
||||
/// cursor position. If already in caret mode this is a no-op.
|
||||
pub fn enterCaretMode(self: *Screen) Allocator.Error!void {
|
||||
if (self.caret_mode) return;
|
||||
const tracked = try self.pages.trackPin(self.cursor.page_pin.*);
|
||||
self.caret_pin = tracked;
|
||||
self.caret_mode = true;
|
||||
self.dirty.caret = true;
|
||||
}
|
||||
|
||||
/// Exit caret mode, releasing the tracked caret pin and clearing any
|
||||
/// active selection. Copy before exiting if you want to keep the selection.
|
||||
pub fn exitCaretMode(self: *Screen) void {
|
||||
if (!self.caret_mode) return;
|
||||
if (self.caret_pin) |pin| {
|
||||
self.pages.untrackPin(pin);
|
||||
self.caret_pin = null;
|
||||
}
|
||||
self.caret_mode = false;
|
||||
self.dirty.caret = true;
|
||||
self.clearSelection();
|
||||
}
|
||||
|
||||
/// Move the caret by the given adjustment. If a selection is active its
|
||||
/// end point is updated to follow the caret. No-op if caret mode is inactive.
|
||||
pub fn moveCaret(self: *Screen, adjustment: Selection.Adjustment) void {
|
||||
const pin = self.caret_pin orelse return;
|
||||
switch (adjustment) {
|
||||
.up => if (pin.up(1)) |new_pin| {
|
||||
pin.* = new_pin;
|
||||
},
|
||||
|
||||
.down => if (pin.down(1)) |new_pin| {
|
||||
pin.* = new_pin;
|
||||
},
|
||||
|
||||
.left => {
|
||||
var it = pin.cellIterator(.left_up, null);
|
||||
_ = it.next();
|
||||
if (it.next()) |next| pin.* = next;
|
||||
},
|
||||
|
||||
.right => {
|
||||
var it = pin.cellIterator(.right_down, null);
|
||||
_ = it.next();
|
||||
if (it.next()) |next| pin.* = next;
|
||||
},
|
||||
|
||||
.page_up => if (pin.up(self.pages.rows)) |new_pin| {
|
||||
pin.* = new_pin;
|
||||
} else {
|
||||
while (pin.up(1)) |new_pin| pin.* = new_pin;
|
||||
},
|
||||
|
||||
.page_down => if (pin.down(self.pages.rows)) |new_pin| {
|
||||
pin.* = new_pin;
|
||||
} else {
|
||||
while (pin.down(1)) |new_pin| pin.* = new_pin;
|
||||
},
|
||||
|
||||
.home => pin.* = self.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?,
|
||||
|
||||
.end => {
|
||||
var it = self.pages.rowIterator(.left_up, .{ .screen = .{} }, null);
|
||||
while (it.next()) |next| {
|
||||
const rac = next.rowAndCell();
|
||||
const cells = next.node.data.getCells(rac.row);
|
||||
if (Cell.hasTextAny(cells)) {
|
||||
pin.* = next;
|
||||
pin.x = @intCast(cells.len - 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
.beginning_of_line => pin.x = 0,
|
||||
|
||||
.end_of_line => pin.x = pin.node.data.size.cols - 1,
|
||||
}
|
||||
|
||||
self.dirty.caret = true;
|
||||
|
||||
// If a selection is active, extend its end to follow the caret.
|
||||
if (self.selection) |*sel| {
|
||||
sel.endPtr().* = pin.*;
|
||||
self.dirty.selection = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub const SelectionString = struct {
|
||||
/// The selection to convert to a string.
|
||||
sel: Selection,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ pub const RenderState = struct {
|
|||
/// Cursor state within the viewport.
|
||||
cursor: Cursor,
|
||||
|
||||
/// Caret position within the viewport. Null when caret mode is inactive
|
||||
/// or the caret is scrolled out of view.
|
||||
caret: ?Cursor.Viewport = null,
|
||||
|
||||
/// The rows (y=0 is top) of the viewport. Guaranteed to be `rows` length.
|
||||
///
|
||||
/// This is a MultiArrayList because only the update cares about
|
||||
|
|
@ -319,6 +323,7 @@ pub const RenderState = struct {
|
|||
// probably cache this by comparing the cursor pin and viewport pin
|
||||
// but may not be worth it.
|
||||
self.cursor.viewport = null;
|
||||
self.caret = null;
|
||||
|
||||
// Colors.
|
||||
self.colors.cursor = t.colors.cursor.get();
|
||||
|
|
@ -421,6 +426,25 @@ pub const RenderState = struct {
|
|||
};
|
||||
}
|
||||
|
||||
// Find the caret position within the viewport.
|
||||
if (s.caret_mode) {
|
||||
if (self.caret == null) {
|
||||
if (s.caret_pin) |cp| {
|
||||
if (row_pin.node == cp.node and row_pin.y == cp.y) {
|
||||
const rac = cp.rowAndCell();
|
||||
self.caret = .{
|
||||
.y = y,
|
||||
.x = cp.x,
|
||||
.wide_tail = if (cp.x > 0)
|
||||
rac.cell.wide == .spacer_tail
|
||||
else
|
||||
false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store our pin. We have to store these even if we're not dirty
|
||||
// because dirty is only a renderer optimization. It doesn't
|
||||
// apply to memory movement. This will let us remap any cell
|
||||
|
|
|
|||
Loading…
Reference in New Issue