Merge remote-tracking branch 'upstream/main' into grapheme-width-changes

This commit is contained in:
Jacob Sandlund
2026-02-23 08:39:10 -05:00
155 changed files with 2652 additions and 1662 deletions

View File

@@ -312,6 +312,7 @@ const DerivedConfig = struct {
mouse_reporting: bool,
mouse_scroll_multiplier: configpkg.MouseScrollMultiplier,
mouse_shift_capture: configpkg.MouseShiftCapture,
fullscreen: configpkg.Fullscreen,
macos_non_native_fullscreen: configpkg.NonNativeFullscreen,
macos_option_as_alt: ?input.OptionAsAlt,
selection_clear_on_copy: bool,
@@ -389,6 +390,7 @@ const DerivedConfig = struct {
.mouse_reporting = config.@"mouse-reporting",
.mouse_scroll_multiplier = config.@"mouse-scroll-multiplier",
.mouse_shift_capture = config.@"mouse-shift-capture",
.fullscreen = config.fullscreen,
.macos_non_native_fullscreen = config.@"macos-non-native-fullscreen",
.macos_option_as_alt = config.@"macos-option-as-alt",
.selection_clear_on_copy = config.@"selection-clear-on-copy",
@@ -1174,7 +1176,7 @@ fn selectionScrollTick(self: *Surface) !void {
}
// Scroll the viewport as required
try t.scrollViewport(.{ .delta = delta });
t.scrollViewport(.{ .delta = delta });
// Next, trigger our drag behavior
const pin = t.screens.active.pages.pin(.{
@@ -2779,7 +2781,7 @@ pub fn keyCallback(
try self.setSelection(null);
}
if (self.config.scroll_to_bottom.keystroke) try self.io.terminal.scrollViewport(.bottom);
if (self.config.scroll_to_bottom.keystroke) self.io.terminal.scrollViewport(.bottom);
try self.queueRender();
}
@@ -3532,7 +3534,7 @@ pub fn scrollCallback(
// Modify our viewport, this requires a lock since it affects
// rendering. We have to switch signs here because our delta
// is negative down but our viewport is positive down.
try self.io.terminal.scrollViewport(.{ .delta = y.delta * -1 });
self.io.terminal.scrollViewport(.{ .delta = y.delta * -1 });
}
}
@@ -5063,7 +5065,7 @@ pub fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordin
///
/// Precondition: the render_state mutex must be held.
fn scrollToBottom(self: *Surface) !void {
try self.io.terminal.scrollViewport(.{ .bottom = {} });
self.io.terminal.scrollViewport(.{ .bottom = {} });
try self.queueRender();
}

View File

@@ -109,7 +109,7 @@ pub const Action = union(Key) {
/// Toggle the quick terminal in or out.
toggle_quick_terminal,
/// Toggle the command palette. This currently only works on macOS.
/// Toggle the command palette.
toggle_command_palette,
/// Toggle the visibility of all Ghostty terminal windows.

View File

@@ -308,7 +308,7 @@ pub const Window = extern struct {
if (priv.config) |config_obj| {
const config = config_obj.get();
if (config.maximize) self.as(gtk.Window).maximize();
if (config.fullscreen) self.as(gtk.Window).fullscreen();
if (config.fullscreen != .false) self.as(gtk.Window).fullscreen();
// If we have an explicit title set, we set that immediately
// so that any applications inspecting the window states see
@@ -383,6 +383,10 @@ pub const Window = extern struct {
.config = priv.config,
});
if (parent_) |p| {
// For a new window's first tab, inherit the parent's initial size hints.
if (context == .window) {
surfaceInit(p.rt_surface.gobj(), self);
}
tab.setParentWithContext(p, context);
}

View File

@@ -62,6 +62,13 @@ pub fn initShared(
.{ .include_extensions = &.{".h"} },
);
if (lib.rootModuleTarget().abi.isAndroid()) {
// Support 16kb page sizes, required for Android 15+.
lib.link_z_max_page_size = 16384; // 16kb
try @import("android_ndk").addPaths(b, lib);
}
if (lib.rootModuleTarget().os.tag.isDarwin()) {
// Self-hosted x86_64 doesn't work for darwin. It may not work
// for other platforms too but definitely darwin.

View File

@@ -20,12 +20,17 @@ fn computeWidth(
_ = backing;
_ = tracking;
// This condition is to get the previous behavior of uucode's `wcwidth`,
// returning the width of a code point in a grapheme cluster but with the
// exception to treat emoji modifiers as width 2 so they can be displayed
// in isolation. PRs to follow will take advantage of the new uucode
// `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split.
if (data.wcwidth_zero_in_grapheme and !data.is_emoji_modifier) {
// This condition is needed as Ghostty currently has a singular concept for
// the `width` of a code point, while `uucode` splits the concept into
// `wcwidth_standalone` and `wcwidth_zero_in_grapheme`. The two cases where
// we want to use the `wcwidth_standalone` despite the code point occupying
// zero width in a grapheme (`wcwidth_zero_in_grapheme`) are emoji
// modifiers and prepend code points. For emoji modifiers we want to
// support displaying them in isolation as color patches, and if prepend
// characters were to be width 0 they would disappear from the output with
// Ghostty's current width 0 handling. Future work will take advantage of
// the new uucode `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split.
if (data.wcwidth_zero_in_grapheme and !data.is_emoji_modifier and data.grapheme_break_no_control != .prepend) {
data.width = 0;
} else {
data.width = @min(2, data.wcwidth_standalone);
@@ -37,6 +42,7 @@ const width = config.Extension{
"wcwidth_standalone",
"wcwidth_zero_in_grapheme",
"is_emoji_modifier",
"grapheme_break_no_control",
},
.compute = &computeWidth,
.fields = &.{

View File

@@ -31,6 +31,7 @@ pub const Keybinds = Config.Keybinds;
pub const MouseShiftCapture = Config.MouseShiftCapture;
pub const MouseScrollMultiplier = Config.MouseScrollMultiplier;
pub const NonNativeFullscreen = Config.NonNativeFullscreen;
pub const Fullscreen = Config.Fullscreen;
pub const RepeatableCodepointMap = Config.RepeatableCodepointMap;
pub const RepeatableFontVariation = Config.RepeatableFontVariation;
pub const RepeatableString = Config.RepeatableString;

View File

@@ -144,3 +144,101 @@ export fn ghostty_config_open_path() c.String {
const Diagnostic = extern struct {
message: [*:0]const u8 = "",
};
test "ghostty_config_get: bool" {
const testing = std.testing;
const alloc = testing.allocator;
var cfg = try Config.default(alloc);
defer cfg.deinit();
cfg.maximize = true;
var out = false;
const key = "maximize";
try testing.expect(ghostty_config_get(&cfg, &out, key, key.len));
try testing.expect(out);
}
test "ghostty_config_get: enum" {
const testing = std.testing;
const alloc = testing.allocator;
var cfg = try Config.default(alloc);
defer cfg.deinit();
cfg.@"window-theme" = .dark;
var out: [*:0]const u8 = undefined;
const key = "window-theme";
try testing.expect(ghostty_config_get(&cfg, @ptrCast(&out), key, key.len));
const str = std.mem.sliceTo(out, 0);
try testing.expectEqualStrings("dark", str);
}
test "ghostty_config_get: optional null returns false" {
const testing = std.testing;
const alloc = testing.allocator;
var cfg = try Config.default(alloc);
defer cfg.deinit();
cfg.@"unfocused-split-fill" = null;
var out: Config.Color.C = undefined;
const key = "unfocused-split-fill";
try testing.expect(!ghostty_config_get(&cfg, @ptrCast(&out), key, key.len));
}
test "ghostty_config_get: unknown key returns false" {
const testing = std.testing;
const alloc = testing.allocator;
var cfg = try Config.default(alloc);
defer cfg.deinit();
var out = false;
const key = "not-a-real-key";
try testing.expect(!ghostty_config_get(&cfg, &out, key, key.len));
}
test "ghostty_config_get: optional string null returns true" {
const testing = std.testing;
const alloc = testing.allocator;
var cfg = try Config.default(alloc);
defer cfg.deinit();
cfg.title = null;
var out: ?[*:0]const u8 = undefined;
const key = "title";
try testing.expect(ghostty_config_get(&cfg, @ptrCast(&out), key, key.len));
try testing.expect(out == null);
}
test "ghostty_config_get: float" {
const testing = std.testing;
const alloc = testing.allocator;
var cfg = try Config.default(alloc);
defer cfg.deinit();
cfg.@"background-opacity" = 0.42;
var out: f64 = 0;
const key = "background-opacity";
try testing.expect(ghostty_config_get(&cfg, &out, key, key.len));
try testing.expectApproxEqAbs(@as(f64, 0.42), out, 0.000001);
}
test "ghostty_config_get: struct cval conversion" {
const testing = std.testing;
const alloc = testing.allocator;
var cfg = try Config.default(alloc);
defer cfg.deinit();
cfg.background = .{ .r = 12, .g = 34, .b = 56 };
var out: Config.Color.C = undefined;
const key = "background";
try testing.expect(ghostty_config_get(&cfg, @ptrCast(&out), key, key.len));
try testing.expectEqual(@as(u8, 12), out.r);
try testing.expectEqual(@as(u8, 34), out.g);
try testing.expectEqual(@as(u8, 56), out.b);
}

View File

@@ -39,6 +39,7 @@ pub const Path = @import("path.zig").Path;
pub const RepeatablePath = @import("path.zig").RepeatablePath;
const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig");
const KeyRemapSet = @import("../input/key_mods.zig").RemapSet;
const string = @import("string.zig");
// We do this instead of importing all of terminal/main.zig to
// limit the dependency graph. This is important because some things
@@ -808,6 +809,24 @@ palette: Palette = .{},
/// Available since: 1.3.0
@"palette-generate": bool = true,
/// Invert the palette colors generated when `palette-generate` is enabled,
/// so that the colors go in reverse order. This allows palette-based
/// applications to work well in both light and dark mode since the
/// palettes are always relatively good colors.
///
/// This defaults to off because some legacy terminal applications
/// hardcode the assumption that palette indices 16231 are ordered from
/// darkest to lightest, so enabling this would make them unreadable.
/// This is not a generally good assumption and we encourage modern
/// terminal applications to use the indices in a more semantic way.
///
/// This has no effect if `palette-generate` is disabled.
///
/// For more information see `palette-generate`.
///
/// Available since: 1.3.0
@"palette-harmonious": bool = false,
/// The color of the cursor. If this is not set, a default will be chosen.
///
/// Direct colors can be specified as either hex (`#RRGGBB` or `RRGGBB`)
@@ -906,7 +925,7 @@ palette: Palette = .{},
/// anything but modifiers or keybinds that are processed by Ghostty).
///
/// - `output` If set, scroll the surface to the bottom if there is new data
/// to display. (Currently unimplemented.)
/// to display (e.g., when new lines are printed to the terminal).
///
/// The default is `keystroke, no-output`.
@"scroll-to-bottom": ScrollToBottom = .default,
@@ -1428,10 +1447,27 @@ maximize: bool = false,
/// does not apply to tabs, splits, etc. However, this setting will apply to all
/// new windows, not just the first one.
///
/// On macOS, this setting does not work if window-decoration is set to
/// "none", because native fullscreen on macOS requires window decorations
/// to be set.
fullscreen: bool = false,
/// Allowable values are:
///
/// * `false` - Don't start in fullscreen (default)
/// * `true` - Start in native fullscreen
/// * `non-native` - (macOS only) Start in non-native fullscreen, hiding the
/// menu bar. This is faster than native fullscreen since it doesn't use
/// animations. On non-macOS platforms, this behaves the same as `true`.
/// * `non-native-visible-menu` - (macOS only) Start in non-native fullscreen,
/// keeping the menu bar visible. On non-macOS platforms, behaves like `true`.
/// * `non-native-padded-notch` - (macOS only) Start in non-native fullscreen,
/// hiding the menu bar but padding for the notch on applicable devices.
/// On non-macOS platforms, behaves like `true`.
///
/// Important: tabs DO NOT WORK with non-native fullscreen modes. Non-native
/// fullscreen removes the titlebar and macOS native tabs require the titlebar.
/// If you use tabs, use `true` (native) instead.
///
/// On macOS, `true` (native fullscreen) does not work if `window-decoration`
/// is set to `false`, because native fullscreen on macOS requires window
/// decorations.
fullscreen: Fullscreen = .false,
/// The title Ghostty will use for the window. This will force the title of the
/// window to be this title at all times and Ghostty will ignore any set title
@@ -1822,6 +1858,12 @@ class: ?[:0]const u8 = null,
/// If an invalid key is pressed, the sequence ends but the table remains
/// active.
///
/// * Chain actions work within tables, the `chain` keyword applies to
/// the most recently defined binding in the table. e.g. if you set
/// `table/ctrl+a=new_window` you can chain by using `chain=text:hello`.
/// Important: chain itself doesn't get prefixed with the table name,
/// since it applies to the most recent binding in any table.
///
/// * Prefixes like `global:` work within tables:
/// `foo/global:ctrl+a=new_window`.
///
@@ -5136,6 +5178,17 @@ pub const NonNativeFullscreen = enum(c_int) {
@"padded-notch",
};
/// Valid values for fullscreen config option
/// c_int because it needs to be extern compatible
/// If this is changed, you must also update ghostty.h
pub const Fullscreen = enum(c_int) {
false,
true,
@"non-native",
@"non-native-visible-menu",
@"non-native-padded-notch",
};
pub const WindowPaddingColor = enum {
background,
extend,
@@ -5919,22 +5972,15 @@ pub const SelectionWordChars = struct {
pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void {
const value = input orelse return error.ValueRequired;
// Parse UTF-8 string into codepoints
// Parse string with Zig escape sequence support into codepoints
var list: std.ArrayList(u21) = .empty;
defer list.deinit(alloc);
// Always include null as first boundary
try list.append(alloc, 0);
// Parse the UTF-8 string
const utf8_view = std.unicode.Utf8View.init(value) catch {
// Invalid UTF-8, just use null boundary
self.codepoints = try list.toOwnedSlice(alloc);
return;
};
var utf8_it = utf8_view.iterator();
while (utf8_it.nextCodepoint()) |codepoint| {
var it = string.codepointIterator(value);
while (it.next() catch return error.InvalidValue) |codepoint| {
try list.append(alloc, codepoint);
}
@@ -5987,6 +6033,56 @@ pub const SelectionWordChars = struct {
try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]);
try testing.expectEqual(@as(u21, ','), chars.codepoints[4]);
}
test "parseCLI escape sequences" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
// \t escape should be parsed as tab
var chars: Self = .{};
try chars.parseCLI(alloc, " \\t;,");
try testing.expectEqual(@as(usize, 5), chars.codepoints.len);
try testing.expectEqual(@as(u21, 0), chars.codepoints[0]);
try testing.expectEqual(@as(u21, ' '), chars.codepoints[1]);
try testing.expectEqual(@as(u21, '\t'), chars.codepoints[2]);
try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]);
try testing.expectEqual(@as(u21, ','), chars.codepoints[4]);
}
test "parseCLI backslash escape" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
// \\ should be parsed as a single backslash
var chars: Self = .{};
try chars.parseCLI(alloc, "\\\\;");
try testing.expectEqual(@as(usize, 3), chars.codepoints.len);
try testing.expectEqual(@as(u21, 0), chars.codepoints[0]);
try testing.expectEqual(@as(u21, '\\'), chars.codepoints[1]);
try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]);
}
test "parseCLI unicode escape" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
// \u{2502} should be parsed as │
var chars: Self = .{};
try chars.parseCLI(alloc, "\\u{2502};");
try testing.expectEqual(@as(usize, 3), chars.codepoints.len);
try testing.expectEqual(@as(u21, 0), chars.codepoints[0]);
try testing.expectEqual(@as(u21, '│'), chars.codepoints[1]);
try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]);
}
};
/// FontVariation is a repeatable configuration value that sets a single
@@ -6123,6 +6219,15 @@ pub const Keybinds = struct {
/// which allows all table names to be available without reservation.
tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty,
/// The most recent binding target for `chain=` additions.
///
/// This is intentionally tracked at the Keybinds level so that chains can
/// apply across table boundaries according to parse order.
chain_target: union(enum) {
root,
table: []const u8,
} = .root,
pub fn init(self: *Keybinds, alloc: Allocator) !void {
// We don't clear the memory because it's in the arena and unlikely
// to be free-able anyways (since arenas can only clear the last
@@ -6130,6 +6235,7 @@ pub const Keybinds = struct {
// will be freed when the config is freed.
self.set = .{};
self.tables = .empty;
self.chain_target = .root;
// keybinds for opening and reloading config
try self.set.put(
@@ -6912,6 +7018,7 @@ pub const Keybinds = struct {
log.info("config has 'keybind = clear', all keybinds cleared", .{});
self.set = .{};
self.tables = .empty;
self.chain_target = .root;
return;
}
@@ -6949,16 +7056,39 @@ pub const Keybinds = struct {
if (binding.len == 0) {
log.debug("config has 'keybind = {s}/', table cleared", .{table_name});
gop.value_ptr.* = .{};
self.chain_target = .root;
return;
}
// Chains are only allowed at the root level. Their target is
// tracked globally by parse order in `self.chain_target`.
if (std.mem.startsWith(u8, binding, "chain=")) {
return error.InvalidFormat;
}
// Parse and add the binding to the table
try gop.value_ptr.parseAndPut(alloc, binding);
self.chain_target = .{ .table = gop.key_ptr.* };
return;
}
if (std.mem.startsWith(u8, value, "chain=")) {
switch (self.chain_target) {
.root => try self.set.parseAndPut(alloc, value),
.table => |table_name| {
const table = self.tables.getPtr(table_name) orelse {
self.chain_target = .root;
return error.InvalidFormat;
};
try table.parseAndPut(alloc, value);
},
}
return;
}
// Parse into default set
try self.set.parseAndPut(alloc, value);
self.chain_target = .root;
}
/// Deep copy of the struct. Required by Config.
@@ -7400,6 +7530,63 @@ pub const Keybinds = struct {
try testing.expect(keybinds.tables.contains("mytable"));
}
test "parseCLI chain without prior parsed binding is invalid" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try testing.expectError(
error.InvalidFormat,
keybinds.parseCLI(alloc, "chain=new_tab"),
);
}
test "parseCLI table chain syntax is invalid" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "foo/a=text:hello");
try testing.expectError(
error.InvalidFormat,
keybinds.parseCLI(alloc, "foo/chain=deactivate_key_table"),
);
}
test "parseCLI chain applies to most recent table binding" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "ctrl+n=activate_key_table:foo");
try keybinds.parseCLI(alloc, "foo/a=text:hello");
try keybinds.parseCLI(alloc, "chain=deactivate_key_table");
const root_entry = keybinds.set.get(.{
.mods = .{ .ctrl = true },
.key = .{ .unicode = 'n' },
}).?.value_ptr.*;
try testing.expect(root_entry == .leaf);
try testing.expect(root_entry.leaf.action == .activate_key_table);
const foo_entry = keybinds.tables.get("foo").?.get(.{
.key = .{ .unicode = 'a' },
}).?.value_ptr.*;
try testing.expect(foo_entry == .leaf_chained);
try testing.expectEqual(@as(usize, 2), foo_entry.leaf_chained.actions.items.len);
try testing.expect(foo_entry.leaf_chained.actions.items[0] == .text);
try testing.expect(foo_entry.leaf_chained.actions.items[1] == .deactivate_key_table);
}
test "clone with tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);

View File

@@ -36,6 +36,40 @@ pub fn parse(out: []u8, bytes: []const u8) ![]u8 {
return out[0..dst_i];
}
/// Creates an iterator that requires no allocation to extract codepoints
/// from the string literal, parsing escape sequences as it goes.
pub fn codepointIterator(bytes: []const u8) CodepointIterator {
return .{ .bytes = bytes, .i = 0 };
}
pub const CodepointIterator = struct {
bytes: []const u8,
i: usize,
pub fn next(self: *CodepointIterator) error{InvalidString}!?u21 {
if (self.i >= self.bytes.len) return null;
switch (self.bytes[self.i]) {
// An escape sequence
'\\' => return switch (std.zig.string_literal.parseEscapeSequence(
self.bytes,
&self.i,
)) {
.failure => error.InvalidString,
.success => |cp| cp,
},
// Not an escape, parse as UTF-8
else => |start| {
const cp_len = std.unicode.utf8ByteSequenceLength(start) catch
return error.InvalidString;
defer self.i += cp_len;
return std.unicode.utf8Decode(self.bytes[self.i..][0..cp_len]) catch
return error.InvalidString;
},
}
}
};
test "parse: empty" {
const testing = std.testing;
@@ -65,3 +99,48 @@ test "parse: escapes" {
try testing.expectEqualStrings("hello\u{1F601}world", result);
}
}
test "codepointIterator: empty" {
var it = codepointIterator("");
try std.testing.expectEqual(null, try it.next());
}
test "codepointIterator: ascii no escapes" {
var it = codepointIterator("abc");
try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, 'c'), (try it.next()).?);
try std.testing.expectEqual(null, try it.next());
}
test "codepointIterator: multibyte utf8" {
// │ is U+2502 (3 bytes in UTF-8)
var it = codepointIterator("a│b");
try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, '│'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?);
try std.testing.expectEqual(null, try it.next());
}
test "codepointIterator: escape sequences" {
var it = codepointIterator("a\\tb\\n\\\\");
try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, '\t'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, '\n'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, '\\'), (try it.next()).?);
try std.testing.expectEqual(null, try it.next());
}
test "codepointIterator: unicode escape" {
var it = codepointIterator("\\u{2502}x");
try std.testing.expectEqual(@as(u21, '│'), (try it.next()).?);
try std.testing.expectEqual(@as(u21, 'x'), (try it.next()).?);
try std.testing.expectEqual(null, try it.next());
}
test "codepointIterator: emoji unicode escape" {
var it = codepointIterator("\\u{1F601}");
try std.testing.expectEqual(@as(u21, 0x1F601), (try it.next()).?);
try std.testing.expectEqual(null, try it.next());
}

View File

@@ -65,11 +65,11 @@ const non_dotted_path_lookahead =
;
const dotted_path_space_segments =
\\(?:(?<!:) (?!\w+:\/\/)[\w\-.~:\/?#@!$&*+;=%]*[\/.])*
\\(?:(?<!:) (?!\w+:\/\/)(?!\.{0,2}\/)(?!~\/)[\w\-.~:\/?#@!$&*+;=%]*[\/.])*
;
const any_path_space_segments =
\\(?:(?<!:) (?!\w+:\/\/)[\w\-.~:\/?#@!$&*+;=%]+)*
\\(?:(?<!:) (?!\w+:\/\/)(?!\.{0,2}\/)(?!~\/)[\w\-.~:\/?#@!$&*+;=%]+)*
;
// Branch 1: URLs with explicit schemes (http, mailto, ftp, etc.).
@@ -110,7 +110,6 @@ const bare_relative_path_branch =
dotted_path_lookahead ++
bare_relative_path_prefix ++
path_chars ++ "+" ++
dotted_path_space_segments ++
no_trailing_colon ++
trailing_spaces_at_eol;
@@ -359,6 +358,24 @@ test "url regex" {
.input = "/tmp/test folder/file.txt",
.expect = "/tmp/test folder/file.txt",
},
.{
.input = "/tmp/test folder/file.txt",
.expect = "/tmp/test",
},
// unified diff lines
.{
.input = "diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig",
.expect = "a/src/font/shaper/harfbuzz.zig",
},
// Two space-separated absolute paths should match only the first
.{
.input = "/tmp/foo /tmp/bar",
.expect = "/tmp/foo",
},
.{
.input = "/tmp/foo.txt /tmp/bar.txt",
.expect = "/tmp/foo.txt",
},
// Bare relative file paths (no ./ or ../ prefix)
.{
.input = "src/config/url.zig",

View File

@@ -143,7 +143,7 @@ test "cursor: always block with preedit" {
// If we're scrolled though, then we don't show the cursor.
for (0..100) |_| try term.index();
try term.scrollViewport(.{ .top = {} });
term.scrollViewport(.{ .top = {} });
try state.update(alloc, &term);
// In any bool state

View File

@@ -125,6 +125,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
scrollbar: terminal.Scrollbar,
scrollbar_dirty: bool,
/// Tracks the last bottom-right pin of the screen to detect new output.
/// When the final line changes (node or y differs), new content was added.
/// Used for scroll-to-bottom on output feature.
last_bottom_node: ?usize,
last_bottom_y: terminal.size.CellCountInt,
/// The most recent viewport matches so that we can render search
/// matches in the visible frame. This is provided asynchronously
/// from the search thread so we have the dirty flag to also note
@@ -563,6 +569,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
colorspace: configpkg.Config.WindowColorspace,
blending: configpkg.Config.AlphaBlending,
background_blur: configpkg.Config.BackgroundBlur,
scroll_to_bottom_on_output: bool,
pub fn init(
alloc_gpa: Allocator,
@@ -636,6 +643,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.colorspace = config.@"window-colorspace",
.blending = config.@"alpha-blending",
.background_blur = config.@"background-blur",
.scroll_to_bottom_on_output = config.@"scroll-to-bottom".output,
.arena = arena,
};
}
@@ -699,6 +707,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.focused = true,
.scrollbar = .zero,
.scrollbar_dirty = false,
.last_bottom_node = null,
.last_bottom_y = 0,
.search_matches = null,
.search_selected_match = null,
.search_matches_dirty = false,
@@ -1166,6 +1176,26 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
return;
}
// If scroll-to-bottom on output is enabled, check if the final line
// changed by comparing the bottom-right pin. If the node pointer or
// y offset changed, new content was added to the screen.
// Update this BEFORE we update our render state so we can
// draw the new scrolled data immediately.
if (self.config.scroll_to_bottom_on_output) scroll: {
const br = state.terminal.screens.active.pages.getBottomRight(.screen) orelse break :scroll;
// If the pin hasn't changed, then don't scroll.
if (self.last_bottom_node == @intFromPtr(br.node) and
self.last_bottom_y == br.y) break :scroll;
// Update tracked pin state for next frame
self.last_bottom_node = @intFromPtr(br.node);
self.last_bottom_y = br.y;
// Scroll
state.terminal.scrollViewport(.bottom);
}
// Update our terminal state
try self.terminal_state.update(self.alloc, state.terminal);
@@ -1196,6 +1226,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// kitty state on every frame because any cell change can move
// an image.
if (self.images.kittyRequiresUpdate(state.terminal)) {
// We need to grab the draw mutex since this updates
// our image state that drawFrame uses.
self.draw_mutex.lock();
defer self.draw_mutex.unlock();
self.images.kittyUpdate(
self.alloc,
state.terminal,

View File

@@ -844,7 +844,7 @@ pub const Image = union(enum) {
/// Converts the image data to a format that can be uploaded to the GPU.
/// If the data is already in a format that can be uploaded, this is a
/// no-op.
pub fn convert(self: *Image, alloc: Allocator) wuffs.Error!void {
fn convert(self: *Image, alloc: Allocator) wuffs.Error!void {
const p = self.getPendingPointer().?;
// As things stand, we currently convert all images to RGBA before
// uploading to the GPU. This just makes things easier. In the future
@@ -867,7 +867,7 @@ pub const Image = union(enum) {
/// Prepare the pending image data for upload to the GPU.
/// This doesn't need GPU access so is safe to call any time.
pub fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void {
fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void {
assert(self.isPending());
try self.convert(alloc);
}

View File

@@ -1692,7 +1692,7 @@ pub const ScrollViewport = union(enum) {
};
/// Scroll the viewport of the terminal grid.
pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void {
pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void {
self.screens.active.scroll(switch (behavior) {
.top => .{ .top = {} },
.bottom => .{ .active = {} },

View File

@@ -90,14 +90,31 @@ pub fn generate256Color(
skip: PaletteMask,
bg: RGB,
fg: RGB,
harmonious: bool,
) Palette {
// Convert the background, foreground, and 8 base theme colors into
// CIELAB space so that all interpolation is perceptually uniform.
const bg_lab: LAB = .fromRgb(bg);
const fg_lab: LAB = .fromRgb(fg);
const base8_lab: [8]LAB = base8: {
var base8: [8]LAB = undefined;
for (0..8) |i| base8[i] = .fromRgb(base[i]);
var base8: [8]LAB = .{
.fromRgb(bg),
LAB.fromRgb(base[1]),
LAB.fromRgb(base[2]),
LAB.fromRgb(base[3]),
LAB.fromRgb(base[4]),
LAB.fromRgb(base[5]),
LAB.fromRgb(base[6]),
.fromRgb(fg),
};
// For light themes (where the foreground is darker than the
// background), the cube's dark-to-light orientation is inverted
// relative to the base color mapping. When `harmonious` is false,
// swap bg and fg so the cube still runs from black (16) to
// white (231).
const is_light_theme = base8[7].l < base8[0].l;
const invert = is_light_theme and !harmonious;
if (invert) std.mem.swap(LAB, &base8[0], &base8[7]);
break :base8 base8;
};
@@ -115,10 +132,10 @@ pub fn generate256Color(
for (0..6) |ri| {
// R-axis corners: blend base colors along the red dimension.
const tr = @as(f32, @floatFromInt(ri)) / 5.0;
const c0: LAB = .lerp(tr, bg_lab, base8_lab[1]);
const c0: LAB = .lerp(tr, base8_lab[0], base8_lab[1]);
const c1: LAB = .lerp(tr, base8_lab[2], base8_lab[3]);
const c2: LAB = .lerp(tr, base8_lab[4], base8_lab[5]);
const c3: LAB = .lerp(tr, base8_lab[6], fg_lab);
const c3: LAB = .lerp(tr, base8_lab[6], base8_lab[7]);
for (0..6) |gi| {
// G-axis edges: blend the R-interpolated corners along green.
const tg = @as(f32, @floatFromInt(gi)) / 5.0;
@@ -147,7 +164,7 @@ pub fn generate256Color(
for (0..24) |i| {
const t = @as(f32, @floatFromInt(i + 1)) / 25.0;
if (!skip.isSet(idx)) {
const c: LAB = .lerp(t, bg_lab, fg_lab);
const c: LAB = .lerp(t, base8_lab[0], base8_lab[7]);
result[idx] = c.toRgb();
}
idx += 1;
@@ -926,7 +943,7 @@ test "generate256Color: base16 preserved" {
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
const palette = generate256Color(default, .initEmpty(), bg, fg, false);
// The first 16 colors (base16) must remain unchanged.
for (0..16) |i| {
@@ -939,7 +956,7 @@ test "generate256Color: cube corners match base colors" {
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
const palette = generate256Color(default, .initEmpty(), bg, fg, false);
// Index 16 is cube (0,0,0) which should equal bg.
try testing.expectEqual(bg, palette[16]);
@@ -948,12 +965,43 @@ test "generate256Color: cube corners match base colors" {
try testing.expectEqual(fg, palette[231]);
}
test "generate256Color: cube corners black/white with harmonious=false" {
const testing = std.testing;
const black = RGB{ .r = 0, .g = 0, .b = 0 };
const white = RGB{ .r = 255, .g = 255, .b = 255 };
// Dark theme: bg=black, fg=white.
const dark = generate256Color(default, .initEmpty(), black, white, false);
try testing.expectEqual(black, dark[16]);
try testing.expectEqual(white, dark[231]);
// Light theme: bg=white, fg=black. The bg/red swap ensures
// the cube still runs from black (16) to white (231).
const light = generate256Color(default, .initEmpty(), white, black, false);
try testing.expectEqual(black, light[16]);
try testing.expectEqual(white, light[231]);
}
test "generate256Color: light theme cube corners with harmonious=true" {
const testing = std.testing;
const white = RGB{ .r = 255, .g = 255, .b = 255 };
const black = RGB{ .r = 0, .g = 0, .b = 0 };
// harmonious=true skips the bg/fg swap, so the cube preserves the
// original orientation: (0,0,0)=bg=white, (5,5,5)=fg=black.
const palette = generate256Color(default, .initEmpty(), white, black, true);
try testing.expectEqual(white, palette[16]);
try testing.expectEqual(black, palette[231]);
}
test "generate256Color: grayscale ramp monotonic luminance" {
const testing = std.testing;
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
const palette = generate256Color(default, .initEmpty(), bg, fg, false);
// The grayscale ramp (232255) should have monotonically increasing
// luminance from near-black to near-white.
@@ -977,7 +1025,7 @@ test "generate256Color: skip mask preserves original colors" {
skip.set(100);
skip.set(240);
const palette = generate256Color(default, skip, bg, fg);
const palette = generate256Color(default, skip, bg, fg, false);
try testing.expectEqual(default[20], palette[20]);
try testing.expectEqual(default[100], palette[100]);
try testing.expectEqual(default[240], palette[240]);
@@ -986,6 +1034,73 @@ test "generate256Color: skip mask preserves original colors" {
try testing.expect(!palette[21].eql(default[21]));
}
test "generate256Color: dark theme harmonious has no effect" {
const testing = std.testing;
// For a dark theme (fg lighter than bg), harmonious should not change
// the output because the inversion is only relevant for light themes.
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const normal = generate256Color(default, .initEmpty(), bg, fg, false);
const harmonious = generate256Color(default, .initEmpty(), bg, fg, true);
for (16..256) |i| {
try testing.expectEqual(normal[i], harmonious[i]);
}
}
test "generate256Color: light theme harmonious skips inversion" {
const testing = std.testing;
// For a light theme (fg darker than bg), harmonious=true skips the
// bg/red swap, producing different cube colors than harmonious=false.
const bg = RGB{ .r = 255, .g = 255, .b = 255 };
const fg = RGB{ .r = 0, .g = 0, .b = 0 };
const inverted = generate256Color(default, .initEmpty(), bg, fg, false);
const harmonious = generate256Color(default, .initEmpty(), bg, fg, true);
// Cube origin (0,0,0) at index 16: without harmonious, bg and red are
// swapped so it becomes the red base; with harmonious it stays as bg.
try testing.expectEqual(bg, harmonious[16]);
try testing.expect(!inverted[16].eql(bg));
// At least some cube colors should differ between the two modes.
var differ: usize = 0;
for (16..232) |i| {
if (!inverted[i].eql(harmonious[i])) differ += 1;
}
try testing.expect(differ > 0);
}
test "generate256Color: light theme harmonious grayscale ramp" {
const testing = std.testing;
const bg = RGB{ .r = 255, .g = 255, .b = 255 };
const fg = RGB{ .r = 0, .g = 0, .b = 0 };
// harmonious=false swaps bg/fg, so the ramp runs black→white (increasing).
{
const palette = generate256Color(default, .initEmpty(), bg, fg, false);
var prev_lum: f64 = 0.0;
for (232..256) |i| {
const lum = palette[i].luminance();
try testing.expect(lum >= prev_lum);
prev_lum = lum;
}
}
// harmonious=true keeps original order, so the ramp runs white→black (decreasing).
{
const palette = generate256Color(default, .initEmpty(), bg, fg, true);
var prev_lum: f64 = 1.0;
for (232..256) |i| {
const lum = palette[i].luminance();
try testing.expect(lum <= prev_lum);
prev_lum = lum;
}
}
}
test "LAB.toRgb" {
const testing = std.testing;

View File

@@ -1092,7 +1092,7 @@ test "cursor state out of viewport" {
try testing.expectEqual(1, state.cursor.viewport.?.y);
// Scroll the viewport
try t.scrollViewport(.top);
t.scrollViewport(.top);
try state.update(alloc, &t);
// Set a style on the cursor

View File

@@ -358,7 +358,7 @@ test "history search, no active area" {
try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last);
try s.nextSlice("Buzz\r\nFizz");
try t.scrollViewport(.top);
t.scrollViewport(.top);
var search: ViewportSearch = try .init(alloc, "Fizz");
defer search.deinit();

View File

@@ -184,12 +184,7 @@ pub const DerivedConfig = struct {
break :generate;
}
break :palette terminalpkg.color.generate256Color(
config.palette.value,
config.palette.mask,
config.background.toTerminalRGB(),
config.foreground.toTerminalRGB(),
);
break :palette terminalpkg.color.generate256Color(config.palette.value, config.palette.mask, config.background.toTerminalRGB(), config.foreground.toTerminalRGB(), config.@"palette-harmonious");
}
break :palette config.palette.value;
@@ -641,10 +636,13 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void {
}
/// Scroll the viewport
pub fn scrollViewport(self: *Termio, scroll: terminalpkg.Terminal.ScrollViewport) !void {
pub fn scrollViewport(
self: *Termio,
scroll: terminalpkg.Terminal.ScrollViewport,
) void {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
try self.terminal.scrollViewport(scroll);
self.terminal.scrollViewport(scroll);
}
/// Jump the viewport to the prompt.

View File

@@ -321,7 +321,7 @@ fn drainMailbox(
.resize => |v| self.handleResize(cb, v),
.size_report => |v| try io.sizeReport(data, v),
.clear_screen => |v| try io.clearScreen(data, v.history),
.scroll_viewport => |v| try io.scrollViewport(v),
.scroll_viewport => |v| io.scrollViewport(v),
.selection_scroll => |v| {
if (v) {
self.startScrollTimer(cb);

View File

@@ -232,7 +232,7 @@ pub const StreamHandler = struct {
.erase_display_below => self.terminal.eraseDisplay(.below, value),
.erase_display_above => self.terminal.eraseDisplay(.above, value),
.erase_display_complete => {
try self.terminal.scrollViewport(.{ .bottom = {} });
self.terminal.scrollViewport(.{ .bottom = {} });
self.terminal.eraseDisplay(.complete, value);
},
.erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value),