diff --git a/src/Surface.zig b/src/Surface.zig index dfc3a50ea..3c9ad7f10 100644 --- a/src/Surface.zig +++ b/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; diff --git a/src/config/Config.zig b/src/config/Config.zig index 13f78eea6..05ad50a93 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 62a4e39ac..6c9bcbcf2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -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, diff --git a/src/input/command.zig b/src/input/command.zig index ac048eec0..564737a48 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -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, diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 196ebb175..be17197e1 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -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; diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 0f4a294bc..db25d3fc0 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -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; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index b56701838..7596e88c4 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -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, diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 98e142245..3f01bfbcd 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -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