From 4a77e8196720088cbdce701c88412d3ba16089b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 15:15:03 -0700 Subject: [PATCH] libghostty: add selection ordering APIs Expose selection endpoint ordering through the libghostty-vt C API so embedders can safely normalize selections whose start and end refs may be reversed. The new APIs report the current order and return a fresh untracked selection with forward or reverse bounds. Selection.Order now uses lib.Enum, matching the existing adjustment enum pattern and keeping the C ABI enum generated from the same Zig source of truth. The new functions are wired through the C API re-export and lib-vt export paths, with coverage for mirrored rectangular selection ordering. --- include/ghostty/vt/selection.h | 25 +++++++ include/ghostty/vt/terminal.h | 55 ++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/Selection.zig | 7 +- src/terminal/c/main.zig | 2 + src/terminal/c/terminal.zig | 115 +++++++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index cc20a0691..de00899aa 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -77,6 +77,31 @@ typedef struct { bool rectangle; } GhosttySelection; +/** + * Ordering of a selection's endpoints in terminal coordinates. + * + * Mirrored orders are only produced by rectangular selections whose start + * and end endpoints are on opposite diagonal corners that are not simple + * top-left-to-bottom-right or bottom-right-to-top-left orderings. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Start is before end in top-left to bottom-right order. */ + GHOSTTY_SELECTION_ORDER_FORWARD = 0, + + /** End is before start in top-left to bottom-right order. */ + GHOSTTY_SELECTION_ORDER_REVERSE = 1, + + /** Rectangular selection from top-right to bottom-left. */ + GHOSTTY_SELECTION_ORDER_MIRRORED_FORWARD = 2, + + /** Rectangular selection from bottom-left to top-right. */ + GHOSTTY_SELECTION_ORDER_MIRRORED_REVERSE = 3, + + GHOSTTY_SELECTION_ORDER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionOrder; + /** * Operation used to adjust a selection endpoint. * diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 55886e395..f9c951b47 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1152,6 +1152,61 @@ GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( GhosttySelection* selection, GhosttySelectionAdjust adjustment); +/** + * Get the current endpoint ordering of a selection snapshot. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param[out] out_order On success, receives the selection order + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, or output pointer are invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder* out_order); + +/** + * Return a selection snapshot with endpoints ordered as requested. + * + * Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds, + * and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds. + * Mirrored desired orders are accepted but normalized the same as forward. + * The output selection is a fresh untracked snapshot and is not installed as + * the terminal's current selection. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to order + * @param desired Desired endpoint order + * @param[out] out_selection On success, receives the ordered selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, selection references, desired order, or output pointer + * are invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder desired, + GhosttySelection* out_selection); + /** * Resolve a point in the terminal grid to a grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 6276f707c..1feb51932 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -240,6 +240,8 @@ comptime { @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); + @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); + @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 4d7d4a2fa..5258210cf 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -196,7 +196,12 @@ pub fn bottomRight(self: Selection, s: *const Screen) Pin { /// operations only flip the x or y axis, not both. Depending on the y axis /// direction, this is either mirrored_forward or mirrored_reverse. /// -pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; +pub const Order = lib.Enum(lib.target, &.{ + "forward", + "reverse", + "mirrored_forward", + "mirrored_reverse", +}); pub fn order(self: Selection, s: *const Screen) Order { const start_pt = s.pages.pointFromPin(.screen, self.start()).?.screen; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 35fb8b197..e495cda1a 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -171,6 +171,8 @@ pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; pub const terminal_selection_adjust = terminal.selection_adjust; +pub const terminal_selection_order = terminal.selection_order; +pub const terminal_selection_ordered = terminal.selection_ordered; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index fef6fbad9..98208c102 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -685,6 +685,50 @@ pub fn selection_adjust( return .success; } +pub fn selection_order( + terminal_: Terminal, + selection: ?*const selection_c.CSelection, + out_order: ?*Selection.Order, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_order orelse return .invalid_value; + if (!selectionValid(t, sel)) return .invalid_value; + + out.* = sel.order(t.screens.active); + return .success; +} + +pub fn selection_ordered( + terminal_: Terminal, + selection: ?*const selection_c.CSelection, + desired: Selection.Order, + out_selection: ?*selection_c.CSelection, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Selection.Order, @intFromEnum(desired)) catch { + log.warn("terminal_selection_ordered invalid desired value={d}", .{@intFromEnum(desired)}); + return .invalid_value; + }; + } + + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const sel = (selection orelse return .invalid_value).toZig() orelse + return .invalid_value; + const out = out_selection orelse return .invalid_value; + if (!selectionValid(t, sel)) return .invalid_value; + + out.* = .fromZig(sel.ordered(t.screens.active, desired)); + return .success; +} + +fn selectionValid(t: *ZigTerminal, sel: Selection) bool { + const screen = t.screens.active; + return screen.pages.pointFromPin(.screen, sel.start()) != null and + screen.pages.pointFromPin(.screen, sel.end()) != null; +} + fn getTyped( terminal_: Terminal, comptime data: TerminalData, @@ -1458,6 +1502,77 @@ test "selection_adjust mutates snapshot end" { try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x); } +test "selection_order and selection_ordered" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + vt_write(t, "Hello\r\nWorld", 12); + + var start_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 3, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 1, .y = 1 } }, + }, &end_ref)); + + const sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + .rectangle = true, + }; + + var order: Selection.Order = undefined; + try testing.expectEqual(Result.success, selection_order(t, &sel, &order)); + try testing.expectEqual(Selection.Order.mirrored_forward, order); + + var out: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, selection_ordered(t, &sel, .forward, &out)); + try testing.expectEqual(@as(u16, 1), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y); + try testing.expectEqual(@as(u16, 3), out.end.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y); + try testing.expect(out.rectangle); + + try testing.expectEqual(Result.success, selection_ordered(t, &sel, .reverse, &out)); + try testing.expectEqual(@as(u16, 3), out.start.toPin().?.x); + try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y); + try testing.expectEqual(@as(u16, 1), out.end.toPin().?.x); + try testing.expectEqual(@as(u16, 0), out.end.toPin().?.y); + try testing.expect(out.rectangle); +} + +test "selection_order invalid values" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var order: Selection.Order = undefined; + try testing.expectEqual(Result.invalid_value, selection_order(null, null, &order)); + try testing.expectEqual(Result.invalid_value, selection_order(t, null, &order)); +} + test "grid_ref" { var t: Terminal = null; try testing.expectEqual(Result.success, new(