pull/12326/merge
brianc442 2026-06-03 14:35:18 +08:00 committed by GitHub
commit a11189204a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 393 additions and 1 deletions

View File

@ -2675,6 +2675,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).
@ -5591,6 +5598,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;

View File

@ -6595,6 +6595,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,

View File

@ -507,6 +507,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.
@ -1030,6 +1050,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,
@ -1383,6 +1416,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,

View 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,

View File

@ -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;

View File

@ -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;

View File

@ -56,6 +56,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 = .{},
@ -90,6 +97,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 {
@ -2440,6 +2450,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,

View File

@ -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