mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-19 05:50:27 +00:00
Merge remote-tracking branch 'upstream/main' into grapheme-width-changes
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = &.{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 16–231 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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {} },
|
||||
|
||||
@@ -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 (232–255) 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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user