Merge remote-tracking branch 'upstream/main' into jacob/uucode

This commit is contained in:
Jacob Sandlund
2025-09-01 01:34:03 -04:00
74 changed files with 3060 additions and 1182 deletions

View File

@@ -272,6 +272,7 @@ const DerivedConfig = struct {
title_report: bool,
links: []Link,
link_previews: configpkg.LinkPreviews,
scroll_to_bottom: configpkg.Config.ScrollToBottom,
const Link = struct {
regex: oni.Regex,
@@ -340,6 +341,7 @@ const DerivedConfig = struct {
.title_report = config.@"title-report",
.links = links,
.link_previews = config.@"link-previews",
.scroll_to_bottom = config.@"scroll-to-bottom",
// Assignments happen sequentially so we have to do this last
// so that the memory is captured from allocs above.
@@ -2280,7 +2282,8 @@ pub fn keyCallback(
try self.setSelection(null);
}
try self.io.terminal.scrollViewport(.{ .bottom = {} });
if (self.config.scroll_to_bottom.keystroke) try self.io.terminal.scrollViewport(.bottom);
try self.queueRender();
}
@@ -2766,8 +2769,21 @@ pub fn scrollCallback(
// that a wheel tick of 1 results in single scroll event.
const yoff_adjusted: f64 = if (scroll_mods.precision)
yoff
else
yoff * cell_size * self.config.mouse_scroll_multiplier;
else yoff_adjusted: {
// Round out the yoff to an absolute minimum of 1. macos tries to
// simulate precision scrolling with non precision events by
// ramping up the magnitude of the offsets as it detects faster
// scrolling. Single click (very slow) scrolls are reported with a
// magnitude of 0.1 which would normally require a few clicks
// before we register an actual scroll event (depending on cell
// height and the mouse_scroll_multiplier setting).
const yoff_max: f64 = if (yoff > 0)
@max(yoff, 1)
else
@min(yoff, -1);
break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier;
};
// Add our previously saved pending amount to the offset to get the
// new offset value. The signs of the pending and yoff should match
@@ -4701,10 +4717,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
.close_tab => return try self.rt_app.performAction(
.close_tab => |v| return try self.rt_app.performAction(
.{ .surface = self },
.close_tab,
{},
switch (v) {
.this => .this,
.other => .other,
},
),
inline .previous_tab,

View File

@@ -70,13 +70,15 @@ pub const Runtime = enum {
gtk,
pub fn default(target: std.Target) Runtime {
// The Linux default is GTK because it is a full featured application.
if (target.os.tag == .linux) return .@"gtk-ng";
// Otherwise, we do NONE so we don't create an exe and we
// create libghostty. On macOS, Xcode is used to build the app
// that links to libghostty.
return .none;
return switch (target.os.tag) {
// The Linux and FreeBSD default is GTK because it is a full
// featured application.
.linux, .freebsd => .@"gtk-ng",
// Otherwise, we do NONE so we don't create an exe and we create
// libghostty. On macOS, Xcode is used to build the app that links
// to libghostty.
else => .none,
};
}
};

View File

@@ -83,8 +83,9 @@ pub const Action = union(Key) {
/// the tab should be opened in a new window.
new_tab,
/// Closes the tab belonging to the currently focused split.
close_tab,
/// Closes the tab belonging to the currently focused split, or all other
/// tabs, depending on the mode.
close_tab: CloseTabMode,
/// Create a new split. The value determines the location of the split
/// relative to the target.
@@ -701,3 +702,11 @@ pub const OpenUrl = struct {
};
}
};
/// sync with ghostty_action_close_tab_mode_e in ghostty.h
pub const CloseTabMode = enum(c_int) {
/// Close the current tab.
this,
/// Close all other tabs.
other,
};

View File

@@ -447,6 +447,9 @@ pub const Surface = struct {
/// Input to send to the command after it is started.
initial_input: ?[*:0]const u8 = null,
/// Wait after the command exits
wait_after_command: bool = false,
};
pub fn init(self: *Surface, app: *App, opts: Options) !void {
@@ -540,6 +543,11 @@ pub const Surface = struct {
);
}
// Wait after command
if (opts.wait_after_command) {
config.@"wait-after-command" = true;
}
// Initialize our surface right away. We're given a view that is
// ready to use.
try self.core_surface.init(

View File

@@ -116,6 +116,11 @@ pub const Application = extern struct {
/// and initialization was successful.
transient_cgroup_base: ?[]const u8 = null,
/// This is set to true so long as we request a window exactly
/// once. This prevents quitting the app before we've shown one
/// window.
requested_window: bool = false,
/// This is set to false internally when the event loop
/// should exit and the application should quit. This must
/// only be set by the main loop thread.
@@ -461,7 +466,13 @@ pub const Application = extern struct {
// If the quit timer has expired, quit.
if (priv.quit_timer == .expired) break :q true;
// There's no quit timer running, or it hasn't expired, don't quit.
// If we have no windows attached to our app, also quit.
if (priv.requested_window and @as(
?*glib.List,
self.as(gtk.Application).getWindows(),
) == null) break :q true;
// No quit conditions met
break :q false;
};
@@ -488,7 +499,15 @@ pub const Application = extern struct {
const parent: ?*gtk.Widget = parent: {
const list = gtk.Window.listToplevels();
defer list.free();
const focused = list.findCustom(null, findActiveWindow);
const focused = @as(?*glib.List, list.findCustom(
null,
findActiveWindow,
)) orelse {
// If we have an active surface then we should have
// a window available but in the rare case we don't we
// should exit so we don't crash.
break :parent null;
};
break :parent @ptrCast(@alignCast(focused.f_data));
};
@@ -542,7 +561,7 @@ pub const Application = extern struct {
value: apprt.Action.Value(action),
) !bool {
switch (action) {
.close_tab => return Action.closeTab(target),
.close_tab => return Action.closeTab(target, value),
.close_window => return Action.closeWindow(target),
.config_change => try Action.configChange(
@@ -713,27 +732,24 @@ pub const Application = extern struct {
}
}
fn loadRuntimeCss(
self: *Self,
) Allocator.Error!void {
fn loadRuntimeCss(self: *Self) Allocator.Error!void {
const alloc = self.allocator();
var buf: std.ArrayListUnmanaged(u8) = .empty;
const config = self.private().config.get();
var buf: std.ArrayListUnmanaged(u8) = try .initCapacity(alloc, 2048);
defer buf.deinit(alloc);
const writer = buf.writer(alloc);
const config = self.private().config.get();
const window_theme = config.@"window-theme";
const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background;
const headerbar_background = config.@"window-titlebar-background" orelse config.background;
const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
try writer.print(
\\widget.unfocused-split {{
\\ opacity: {d:.2};
\\ background-color: rgb({d},{d},{d});
\\}}
\\
, .{
1.0 - config.@"unfocused-split-opacity",
unfocused_fill.r,
@@ -747,6 +763,7 @@ pub const Application = extern struct {
\\ color: rgb({[r]d},{[g]d},{[b]d});
\\ background: rgb({[r]d},{[g]d},{[b]d});
\\}}
\\
, .{
.r = color.r,
.g = color.g,
@@ -759,9 +776,129 @@ pub const Application = extern struct {
\\.window headerbar {{
\\ font-family: "{[font_family]s}";
\\}}
\\
, .{ .font_family = font_family });
}
try loadRuntimeCss414(config, &writer);
try loadRuntimeCss416(config, &writer);
// ensure that we have a sentinel
try writer.writeByte(0);
const data = buf.items[0 .. buf.items.len - 1 :0];
log.debug("runtime CSS is {d} bytes", .{data.len + 1});
// Clears any previously loaded CSS from this provider
loadCssProviderFromData(
self.private().css_provider,
data,
);
}
/// Load runtime CSS for older than GTK 4.16
fn loadRuntimeCss414(
config: *const CoreConfig,
writer: *const std.ArrayListUnmanaged(u8).Writer,
) Allocator.Error!void {
if (gtk_version.runtimeAtLeast(4, 16, 0)) return;
const window_theme = config.@"window-theme";
const headerbar_background = config.@"window-titlebar-background" orelse config.background;
const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
switch (window_theme) {
.ghostty => try writer.print(
\\windowhandle {{
\\ background-color: rgb({d},{d},{d});
\\ color: rgb({d},{d},{d});
\\}}
\\windowhandle:backdrop {{
\\ background-color: oklab(from rgb({d},{d},{d}) calc(l * 0.9) a b / alpha);
\\}}
\\
, .{
headerbar_background.r,
headerbar_background.g,
headerbar_background.b,
headerbar_foreground.r,
headerbar_foreground.g,
headerbar_foreground.b,
headerbar_background.r,
headerbar_background.g,
headerbar_background.b,
}),
else => {},
}
}
/// Load runtime for GTK 4.16 and newer
fn loadRuntimeCss416(
config: *const CoreConfig,
writer: *const std.ArrayListUnmanaged(u8).Writer,
) Allocator.Error!void {
if (gtk_version.runtimeUntil(4, 16, 0)) return;
const window_theme = config.@"window-theme";
const headerbar_background = config.@"window-titlebar-background" orelse config.background;
const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground;
try writer.writeAll(
\\/*
\\ * Child Exited Overlay
\\ */
\\
\\.child-exited.normal revealer widget {
\\ background-color: color-mix(
\\ in srgb,
\\ var(--success-bg-color),
\\ transparent 50%
\\ );
\\}
\\
\\.child-exited.abnormal revealer widget {
\\ background-color: color-mix(
\\ in srgb,
\\ var(--error-bg-color),
\\ transparent 50%
\\ );
\\}
\\
\\/*
\\ * Surface
\\ */
\\
\\.surface progressbar.error trough progress {
\\ background-color: color-mix(
\\ in srgb,
\\ var(--error-bg-color),
\\ transparent 50%
\\ );
\\}
\\
\\.surface .bell-overlay {
\\ border-color: color-mix(
\\ in srgb,
\\ var(--accent-color),
\\ transparent 50%
\\ );
\\}
\\
\\/*
\\ * Splits
\\ */
\\
\\.window .split paned > separator {
\\ background-color: color-mix(
\\ in srgb,
\\ var(--window-bg-color),
\\ transparent 0%
\\ );
\\}
\\
);
switch (window_theme) {
.ghostty => try writer.print(
\\:root {{
@@ -794,15 +931,6 @@ pub const Application = extern struct {
}),
else => {},
}
const data = try alloc.dupeZ(u8, buf.items);
defer alloc.free(data);
// Clears any previously loaded CSS from this provider
loadCssProviderFromData(
self.private().css_provider,
data,
);
}
fn loadCustomCss(self: *Self) !void {
@@ -872,7 +1000,8 @@ pub const Application = extern struct {
self.syncActionAccelerator("win.close", .{ .close_window = {} });
self.syncActionAccelerator("win.new-window", .{ .new_window = {} });
self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} });
self.syncActionAccelerator("win.close-tab", .{ .close_tab = {} });
self.syncActionAccelerator("win.close-tab::this", .{ .close_tab = .this });
self.syncActionAccelerator("tab.close::this", .{ .close_tab = .this });
self.syncActionAccelerator("win.split-right", .{ .new_split = .right });
self.syncActionAccelerator("win.split-down", .{ .new_split = .down });
self.syncActionAccelerator("win.split-left", .{ .new_split = .left });
@@ -1576,12 +1705,16 @@ pub const Application = extern struct {
/// All apprt action handlers
const Action = struct {
pub fn closeTab(target: apprt.Target) bool {
pub fn closeTab(target: apprt.Target, value: apprt.Action.Value(.close_tab)) bool {
switch (target) {
.app => return false,
.surface => |core| {
const surface = core.rt_surface.surface;
return surface.as(gtk.Widget).activateAction("tab.close", null) != 0;
return surface.as(gtk.Widget).activateAction(
"tab.close",
glib.ext.VariantType.stringFor([:0]const u8),
@as([*:0]const u8, @tagName(value)),
) != 0;
},
}
}
@@ -1853,6 +1986,13 @@ const Action = struct {
self: *Application,
parent: ?*CoreSurface,
) !void {
// Note that we've requested a window at least once. This is used
// to trigger quit on no windows. Note I'm not sure if this is REALLY
// necessary, but I don't want to risk a bug where on a slow machine
// or something we quit immediately after starting up because there
// was a delay in the event loop before we created a Window.
self.private().requested_window = true;
const win = Window.new(self);
initAndShowWindow(self, win, parent);
}

View File

@@ -10,7 +10,7 @@ const Common = @import("../class.zig").Common;
const Config = @import("config.zig").Config;
const Dialog = @import("dialog.zig").Dialog;
const log = std.log.scoped(.gtk_ghostty_config_errors_dialog);
const log = std.log.scoped(.gtk_ghostty_close_confirmation_dialog);
pub const CloseConfirmationDialog = extern struct {
const Self = @This();

View File

@@ -105,6 +105,24 @@ pub const Surface = extern struct {
);
};
pub const @"error" = struct {
pub const name = "error";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"error",
),
},
);
};
pub const @"font-size-request" = struct {
pub const name = "font-size-request";
const impl = gobject.ext.defineProperty(
@@ -472,6 +490,12 @@ pub const Surface = extern struct {
// false by a parent widget.
bell_ringing: bool = false,
/// True if this surface is in an error state. This is currently
/// a simple boolean with no additional information on WHAT the
/// error state is, because we don't yet need it or use it. For now,
/// if this is true, then it means the terminal is non-functional.
@"error": bool = false,
/// A weak reference to an inspector window.
inspector: ?*InspectorWindow = null,
@@ -571,6 +595,17 @@ pub const Surface = extern struct {
return @intFromBool(config.@"bell-features".border);
}
fn closureStackChildName(
_: *Self,
error_: c_int,
) callconv(.c) ?[*:0]const u8 {
const err = error_ != 0;
return if (err)
glib.ext.dupeZ(u8, "error")
else
glib.ext.dupeZ(u8, "terminal");
}
pub fn toggleFullscreen(self: *Self) void {
signals.@"toggle-fullscreen".impl.emit(
self,
@@ -1540,6 +1575,12 @@ pub const Surface = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"bell-ringing".impl.param_spec);
}
pub fn setError(self: *Self, v: bool) void {
const priv = self.private();
priv.@"error" = v;
self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec);
}
fn propConfig(
self: *Self,
_: *gobject.ParamSpec,
@@ -1592,6 +1633,28 @@ pub const Surface = extern struct {
}
}
fn propError(
self: *Self,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
const priv = self.private();
if (priv.@"error") {
// Ensure we have an opaque background. The window will NOT set
// this if we have transparency set and we need an opaque
// background for the error message to be readable.
self.as(gtk.Widget).addCssClass("background");
} else {
// Regardless of transparency setting, we remove the background
// CSS class from this widget. Parent widgets will set it
// appropriately (see window.zig for example).
self.as(gtk.Widget).removeCssClass("background");
}
// Note above: in both cases setting our error view is handled by
// a Gtk.Stack visible-child-name binding.
}
fn propMouseHoverUrl(
self: *Self,
_: *gobject.ParamSpec,
@@ -1942,8 +2005,11 @@ pub const Surface = extern struct {
// Bell stops ringing if any mouse button is pressed.
self.setBellRinging(false);
// If we don't have focus, grab it.
// Get our surface. If we don't have one, ignore this.
const priv = self.private();
const core_surface = priv.core_surface orelse return;
// If we don't have focus, grab it.
const gl_area_widget = priv.gl_area.as(gtk.Widget);
if (gl_area_widget.hasFocus() == 0) {
_ = gl_area_widget.grabFocus();
@@ -1951,10 +2017,10 @@ pub const Surface = extern struct {
// Report the event
const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton());
const consumed = if (priv.core_surface) |surface| consumed: {
const consumed = consumed: {
const gtk_mods = event.getModifierState();
const mods = gtk_key.translateMods(gtk_mods);
break :consumed surface.mouseButtonCallback(
break :consumed core_surface.mouseButtonCallback(
.press,
button,
mods,
@@ -1962,7 +2028,7 @@ pub const Surface = extern struct {
log.warn("error in key callback err={}", .{err});
break :err false;
};
} else false;
};
// If a right click isn't consumed, mouseButtonCallback selects the hovered
// word and returns false. We can use this to handle the context menu
@@ -2303,21 +2369,23 @@ pub const Surface = extern struct {
) callconv(.c) void {
log.debug("realize", .{});
// Make the GL area current so we can detect any OpenGL errors. If
// we have errors here we can't render and we switch to the error
// state.
const priv = self.private();
priv.gl_area.makeCurrent();
if (priv.gl_area.getError()) |err| {
log.warn("failed to make GL context current: {s}", .{err.f_message orelse "(no message)"});
log.warn("this error is almost always due to a library, driver, or GTK issue", .{});
log.warn("this is a common cause of this issue: https://ghostty.org/docs/help/gtk-opengl-context", .{});
self.setError(true);
return;
}
// If we already have an initialized surface then we notify it.
// If we don't, we'll initialize it on the first resize so we have
// our proper initial dimensions.
const priv = self.private();
if (priv.core_surface) |v| realize: {
// We need to make the context current so we can call GL functions.
// This is required for all surface operations.
priv.gl_area.makeCurrent();
if (priv.gl_area.getError()) |err| {
log.warn("failed to make GL context current: {s}", .{err.f_message orelse "(no message)"});
log.warn("this error is usually due to a driver or gtk bug", .{});
log.warn("this is a common cause of this issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/4950", .{});
break :realize;
}
v.renderer.displayRealized() catch |err| {
log.warn("core displayRealized failed err={}", .{err});
break :realize;
@@ -2662,11 +2730,13 @@ pub const Surface = extern struct {
class.bindTemplateCallback("child_exited_close", &childExitedClose);
class.bindTemplateCallback("context_menu_closed", &contextMenuClosed);
class.bindTemplateCallback("notify_config", &propConfig);
class.bindTemplateCallback("notify_error", &propError);
class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl);
class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden);
class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
class.bindTemplateCallback("notify_bell_ringing", &propBellRinging);
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
class.bindTemplateCallback("stack_child_name", &closureStackChildName);
// Properties
gobject.ext.registerProperties(class, &.{
@@ -2674,6 +2744,7 @@ pub const Surface = extern struct {
properties.config.impl,
properties.@"child-exited".impl,
properties.@"default-size".impl,
properties.@"error".impl,
properties.@"font-size-request".impl,
properties.focused.impl,
properties.@"min-size".impl,

View File

@@ -18,7 +18,6 @@ const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const Config = @import("config.zig").Config;
const Application = @import("application.zig").Application;
const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseConfirmationDialog;
const SplitTree = @import("split_tree.zig").SplitTree;
const Surface = @import("surface.zig").Surface;
@@ -199,8 +198,11 @@ pub const Tab = extern struct {
}
fn initActionMap(self: *Self) void {
const s_param_type = glib.ext.VariantType.newFor([:0]const u8);
defer s_param_type.free();
const actions = [_]ext.actions.Action(Self){
.init("close", actionClose, null),
.init("close", actionClose, s_param_type),
.init("ring-bell", actionRingBell, null),
};
@@ -314,18 +316,44 @@ pub const Tab = extern struct {
fn actionClose(
_: *gio.SimpleAction,
_: ?*glib.Variant,
param_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
const param = param_ orelse {
log.warn("tab.close-tab called without a parameter", .{});
return;
};
var str: ?[*:0]const u8 = null;
param.get("&s", &str);
const tab_view = ext.getAncestor(
adw.TabView,
self.as(gtk.Widget),
) orelse return;
const page = tab_view.getPage(self.as(gtk.Widget));
const mode = std.meta.stringToEnum(
apprt.action.CloseTabMode,
std.mem.span(
str orelse {
log.warn("invalid mode provided to tab.close-tab", .{});
return;
},
),
) orelse {
// Need to be defensive here since actions can be triggered externally.
log.warn("invalid mode provided to tab.close-tab: {s}", .{str.?});
return;
};
// Delegate to our parent to handle this, since this will emit
// a close-page signal that the parent can intercept.
tab_view.closePage(page);
switch (mode) {
.this => tab_view.closePage(page),
.other => tab_view.closeOtherPages(page),
}
}
fn actionRingBell(

View File

@@ -320,10 +320,13 @@ pub const Window = extern struct {
/// Setup our action map.
fn initActionMap(self: *Self) void {
const s_variant_type = glib.ext.VariantType.newFor([:0]const u8);
defer s_variant_type.free();
const actions = [_]ext.actions.Action(Self){
.init("about", actionAbout, null),
.init("close", actionClose, null),
.init("close-tab", actionCloseTab, null),
.init("close-tab", actionCloseTab, s_variant_type),
.init("new-tab", actionNewTab, null),
.init("new-window", actionNewWindow, null),
.init("ring-bell", actionRingBell, null),
@@ -961,7 +964,14 @@ pub const Window = extern struct {
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
self.addToast(i18n._("Reloaded the configuration"));
const priv = self.private();
if (priv.config) |config_obj| {
const config = config_obj.get();
if (config.@"app-notifications".@"config-reload") {
self.addToast(i18n._("Reloaded the configuration"));
}
}
self.syncAppearance();
}
@@ -980,6 +990,22 @@ pub const Window = extern struct {
};
}
fn propIsActive(
_: *gtk.Window,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
// Don't change urgency if we're not the active window.
if (self.as(gtk.Window).isActive() == 0) return;
self.winproto().setUrgent(false) catch |err| {
log.warn(
"winproto failed to reset urgency={}",
.{err},
);
};
}
fn propGdkSurfaceWidth(
_: *gdk.Surface,
_: *gobject.ParamSpec,
@@ -1656,10 +1682,31 @@ pub const Window = extern struct {
fn actionCloseTab(
_: *gio.SimpleAction,
_: ?*glib.Variant,
param_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.close_tab);
const param = param_ orelse {
log.warn("win.close-tab called without a parameter", .{});
return;
};
var str: ?[*:0]const u8 = null;
param.get("&s", &str);
const mode = std.meta.stringToEnum(
input.Binding.Action.CloseTabMode,
std.mem.span(
str orelse {
log.warn("invalid mode provided to win.close-tab", .{});
return;
},
),
) orelse {
log.warn("invalid mode provided to win.close-tab: {s}", .{str.?});
return;
};
self.performBindingAction(.{ .close_tab = mode });
}
fn actionNewWindow(
@@ -1758,10 +1805,13 @@ pub const Window = extern struct {
native.beep();
}
if (config.@"bell-features".attention) {
if (config.@"bell-features".attention) attention: {
// Dont set urgency if the window is already active.
if (self.as(gtk.Window).isActive() != 0) break :attention;
// Request user attention
self.winproto().setUrgent(true) catch |err| {
log.warn("failed to request user attention={}", .{err});
log.warn("winproto failed to set urgency={}", .{err});
};
}
}
@@ -1905,6 +1955,7 @@ pub const Window = extern struct {
class.bindTemplateCallback("notify_selected_page", &tabViewSelectedPage);
class.bindTemplateCallback("notify_config", &propConfig);
class.bindTemplateCallback("notify_fullscreened", &propFullscreened);
class.bindTemplateCallback("notify_is_active", &propIsActive);
class.bindTemplateCallback("notify_maximized", &propMaximized);
class.bindTemplateCallback("notify_menu_active", &propMenuActive);
class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal);

View File

@@ -12,7 +12,7 @@ window.ssd.no-border-radius {
border-radius: 0 0;
}
/*
/*
* GhosttySurface URL overlay
*/
label.url-overlay {
@@ -83,13 +83,13 @@ label.resize-overlay {
*/
.child-exited.normal revealer widget {
background-color: rgba(38, 162, 105, 0.5);
/* after GTK 4.16 is a requirement, switch to the following:
/* after GTK 4.16 is a requirement, switch to the following: */
/* background-color: color-mix(in srgb, var(--success-bg-color), transparent 50%); */
}
.child-exited.abnormal revealer widget {
background-color: rgba(192, 28, 40, 0.5);
/* after GTK 4.16 is a requirement, switch to the following:
/* after GTK 4.16 is a requirement, switch to the following: */
/* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */
}
@@ -97,13 +97,15 @@ label.resize-overlay {
* Surface
*/
.surface progressbar.error trough progress {
background-color: rgb(192, 28, 40);
background-color: rgba(192, 28, 40, 0.5);
/* after GTK 4.16 is a requirement, switch to the following: */
/* background-color: color-mix(in srgb, var(--error-bg-color), transparent); */
/* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */
}
.surface .bell-overlay {
border-color: color-mix(in srgb, var(--accent-color), transparent 50%);
border-color: rgba(58, 148, 74, 0.5);
/* after GTK 4.16 is a requirement, switch to the following: */
/* background-color: color-mix(in srgb, var(--accent-color), transparent 50%); */
border-width: 3px;
border-style: solid;
}
@@ -127,6 +129,8 @@ label.resize-overlay {
.window .split paned > separator {
background-color: rgba(250, 250, 250, 1);
/* after GTK 4.16 is a requirement, switch to the following: */
/* background-color: color-mix(in srgb, var(--window-bg-color), transparent 0%); */
background-clip: content-box;
/* This works around the oversized drag area for the right side of GtkPaned.

View File

@@ -7,4 +7,6 @@ template $GhosttyCloseConfirmationDialog: $GhosttyDialog {
cancel: _("Cancel"),
close: _("Close") destructive,
]
close-response: "cancel";
}

View File

@@ -8,146 +8,172 @@ template $GhosttySurface: Adw.Bin {
notify::bell-ringing => $notify_bell_ringing();
notify::config => $notify_config();
notify::error => $notify_error();
notify::mouse-hover-url => $notify_mouse_hover_url();
notify::mouse-hidden => $notify_mouse_hidden();
notify::mouse-shape => $notify_mouse_shape();
Overlay {
focusable: false;
focus-on-click: false;
Stack {
StackPage {
name: "terminal";
child: Box {
hexpand: true;
vexpand: true;
child: Overlay {
focusable: false;
focus-on-click: false;
GLArea gl_area {
realize => $gl_realize();
unrealize => $gl_unrealize();
render => $gl_render();
resize => $gl_resize();
hexpand: true;
vexpand: true;
focusable: true;
focus-on-click: true;
has-stencil-buffer: false;
has-depth-buffer: false;
allowed-apis: gl;
}
child: Box {
hexpand: true;
vexpand: true;
PopoverMenu context_menu {
closed => $context_menu_closed();
menu-model: context_menu_model;
flags: nested;
halign: start;
has-arrow: false;
}
};
GLArea gl_area {
realize => $gl_realize();
unrealize => $gl_unrealize();
render => $gl_render();
resize => $gl_resize();
hexpand: true;
vexpand: true;
focusable: true;
focus-on-click: true;
has-stencil-buffer: false;
has-depth-buffer: false;
allowed-apis: gl;
}
[overlay]
ProgressBar progress_bar_overlay {
styles [
"osd",
]
PopoverMenu context_menu {
closed => $context_menu_closed();
menu-model: context_menu_model;
flags: nested;
halign: start;
has-arrow: false;
}
};
visible: false;
halign: fill;
valign: start;
[overlay]
ProgressBar progress_bar_overlay {
styles [
"osd",
]
visible: false;
halign: fill;
valign: start;
}
[overlay]
// The "border" bell feature is implemented here as an overlay rather than
// just adding a border to the GLArea or other widget for two reasons.
// First, adding a border to an existing widget causes a resize of the
// widget which undesirable side effects. Second, we can make it reactive
// here in the blueprint with relatively little code.
Revealer {
reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as <bool>;
transition-type: crossfade;
transition-duration: 500;
Box bell_overlay {
styles [
"bell-overlay",
]
halign: fill;
valign: fill;
}
}
[overlay]
$GhosttySurfaceChildExited child_exited_overlay {
visible: bind template.child-exited;
close-request => $child_exited_close();
}
[overlay]
$GhosttyResizeOverlay resize_overlay {}
[overlay]
Label url_left {
styles [
"background",
"url-overlay",
]
visible: false;
halign: start;
valign: end;
label: bind template.mouse-hover-url;
EventControllerMotion url_ec_motion {
enter => $url_mouse_enter();
leave => $url_mouse_leave();
}
}
[overlay]
Label url_right {
styles [
"background",
"url-overlay",
]
visible: false;
halign: end;
valign: end;
label: bind template.mouse-hover-url;
}
// Event controllers for interactivity
EventControllerFocus {
enter => $focus_enter();
leave => $focus_leave();
}
EventControllerKey {
key-pressed => $key_pressed();
key-released => $key_released();
}
EventControllerMotion {
motion => $mouse_motion();
leave => $mouse_leave();
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
}
GestureClick {
pressed => $mouse_down();
released => $mouse_up();
button: 0;
}
DropTarget drop_target {
drop => $drop();
actions: copy;
}
};
}
[overlay]
// The "border" bell feature is implemented here as an overlay rather than
// just adding a border to the GLArea or other widget for two reasons.
// First, adding a border to an existing widget causes a resize of the
// widget which undesirable side effects. Second, we can make it reactive
// here in the blueprint with relatively little code.
Revealer {
reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as <bool>;
transition-type: crossfade;
transition-duration: 500;
StackPage {
name: "error";
Box bell_overlay {
styles [
"bell-overlay",
]
child: Adw.StatusPage {
icon-name: "computer-fail-symbolic";
title: _("Oh, no.");
description: _("Unable to acquire an OpenGL context for rendering.");
halign: fill;
valign: fill;
}
child: LinkButton {
label: "https://ghostty.org/docs/help/gtk-opengl-context";
uri: "https://ghostty.org/docs/help/gtk-opengl-context";
};
};
}
[overlay]
$GhosttySurfaceChildExited child_exited_overlay {
visible: bind template.child-exited;
close-request => $child_exited_close();
}
[overlay]
$GhosttyResizeOverlay resize_overlay {}
[overlay]
Label url_left {
styles [
"background",
"url-overlay",
]
visible: false;
halign: start;
valign: end;
label: bind template.mouse-hover-url;
EventControllerMotion url_ec_motion {
enter => $url_mouse_enter();
leave => $url_mouse_leave();
}
}
[overlay]
Label url_right {
styles [
"background",
"url-overlay",
]
visible: false;
halign: end;
valign: end;
label: bind template.mouse-hover-url;
}
}
// Event controllers for interactivity
EventControllerFocus {
enter => $focus_enter();
leave => $focus_leave();
}
EventControllerKey {
key-pressed => $key_pressed();
key-released => $key_released();
}
EventControllerMotion {
motion => $mouse_motion();
leave => $mouse_leave();
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
}
GestureClick {
pressed => $mouse_down();
released => $mouse_up();
button: 0;
}
DropTarget drop_target {
drop => $drop();
actions: copy;
// The order matters here: we can only set this after the stack
// pages above have been created.
visible-child-name: bind $stack_child_name(template.error) as <string>;
}
}
@@ -228,7 +254,8 @@ menu context_menu_model {
item {
label: _("Close Tab");
action: "win.close-tab";
action: "tab.close";
target: "this";
}
}

View File

@@ -10,6 +10,7 @@ template $GhosttyWindow: Adw.ApplicationWindow {
realize => $realize();
notify::config => $notify_config();
notify::fullscreened => $notify_fullscreened();
notify::is-active => $notify_is_active();
notify::maximized => $notify_maximized();
notify::quick-terminal => $notify_quick_terminal();
notify::scale-factor => $notify_scale_factor();
@@ -225,6 +226,7 @@ menu main_menu {
item {
label: _("Close Tab");
action: "win.close-tab";
target: "this";
}
}

View File

@@ -491,7 +491,7 @@ pub fn performAction(
.toggle_maximize => self.toggleMaximize(target),
.toggle_fullscreen => self.toggleFullscreen(target, value),
.new_tab => try self.newTab(target),
.close_tab => return try self.closeTab(target),
.close_tab => return try self.closeTab(target, value),
.goto_tab => return self.gotoTab(target, value),
.move_tab => self.moveTab(target, value),
.new_split => try self.newSplit(target, value),
@@ -585,7 +585,7 @@ fn newTab(_: *App, target: apprt.Target) !void {
}
}
fn closeTab(_: *App, target: apprt.Target) !bool {
fn closeTab(_: *App, target: apprt.Target, value: apprt.Action.Value(.close_tab)) !bool {
switch (target) {
.app => return false,
.surface => |v| {
@@ -597,8 +597,16 @@ fn closeTab(_: *App, target: apprt.Target) !bool {
return false;
};
tab.closeWithConfirmation();
return true;
switch (value) {
.this => {
tab.closeWithConfirmation();
return true;
},
.other => {
log.warn("close-tab:other is not implemented", .{});
return false;
},
}
},
}
}
@@ -1145,7 +1153,7 @@ fn syncActionAccelerators(self: *App) !void {
try self.syncActionAccelerator("win.close", .{ .close_window = {} });
try self.syncActionAccelerator("win.new-window", .{ .new_window = {} });
try self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} });
try self.syncActionAccelerator("win.close-tab", .{ .close_tab = {} });
try self.syncActionAccelerator("win.close-tab", .{ .close_tab = .this });
try self.syncActionAccelerator("win.split-right", .{ .new_split = .right });
try self.syncActionAccelerator("win.split-down", .{ .new_split = .down });
try self.syncActionAccelerator("win.split-left", .{ .new_split = .left });

View File

@@ -772,7 +772,9 @@ pub fn focusCurrentTab(self: *Window) void {
}
pub fn onConfigReloaded(self: *Window) void {
self.sendToast(i18n._("Reloaded the configuration"));
if (self.app.config.@"app-notifications".@"config-reload") {
self.sendToast(i18n._("Reloaded the configuration"));
}
}
pub fn sendToast(self: *Window, title: [*:0]const u8) void {
@@ -1074,7 +1076,7 @@ fn gtkActionCloseTab(
_: ?*glib.Variant,
self: *Window,
) callconv(.c) void {
self.performBindingAction(.{ .close_tab = {} });
self.performBindingAction(.{ .close_tab = .this });
}
fn gtkActionSplitRight(

View File

@@ -131,6 +131,13 @@ pub const VTable = struct {
};
test Benchmark {
// This test fails on FreeBSD so skip:
//
// /home/runner/work/ghostty/ghostty/src/benchmark/Benchmark.zig:165:5: 0x3cd2de1 in decltest.Benchmark (ghostty-test)
// try testing.expect(result.duration > 0);
// ^
if (builtin.os.tag == .freebsd) return error.SkipZigTest;
const testing = std.testing;
const Simple = struct {
const Self = @This();

View File

@@ -1,13 +1,20 @@
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const Action = @import("ghostty.zig").Action;
const args = @import("args.zig");
const x11_color = @import("../terminal/main.zig").x11_color;
const vaxis = @import("vaxis");
const tui = @import("tui.zig");
pub const Options = struct {
pub fn deinit(self: Options) void {
_ = self;
}
/// If `true`, print without formatting even if printing to a tty
plain: bool = false,
/// Enables "-h" and "--help" to work.
pub fn help(self: Options) !void {
_ = self;
@@ -17,7 +24,12 @@ pub const Options = struct {
/// The `list-colors` command is used to list all the named RGB colors in
/// Ghostty.
pub fn run(alloc: std.mem.Allocator) !u8 {
///
/// Flags:
///
/// * `--plain`: will disable formatting and make the output more
/// friendly for Unix tooling. This is default when not printing to a tty.
pub fn run(alloc: Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();
@@ -27,7 +39,7 @@ pub fn run(alloc: std.mem.Allocator) !u8 {
try args.parse(Options, alloc, &opts, &iter);
}
const stdout = std.io.getStdOut().writer();
const stdout = std.io.getStdOut();
var keys = std.ArrayList([]const u8).init(alloc);
defer keys.deinit();
@@ -39,15 +51,163 @@ pub fn run(alloc: std.mem.Allocator) !u8 {
}
}.lessThan);
for (keys.items) |name| {
const rgb = x11_color.map.get(name).?;
try stdout.print("{s} = #{x:0>2}{x:0>2}{x:0>2}\n", .{
name,
rgb.r,
rgb.g,
rgb.b,
});
// Despite being under the posix namespace, this also works on Windows as of zig 0.13.0
if (tui.can_pretty_print and !opts.plain and std.posix.isatty(stdout.handle)) {
var arena = std.heap.ArenaAllocator.init(alloc);
defer arena.deinit();
return prettyPrint(arena.allocator(), keys.items);
} else {
const writer = stdout.writer();
for (keys.items) |name| {
const rgb = x11_color.map.get(name).?;
try writer.print("{s} = #{x:0>2}{x:0>2}{x:0>2}\n", .{
name,
rgb.r,
rgb.g,
rgb.b,
});
}
}
return 0;
}
fn prettyPrint(alloc: Allocator, keys: [][]const u8) !u8 {
// Set up vaxis
var tty = try vaxis.Tty.init();
defer tty.deinit();
var vx = try vaxis.init(alloc, .{});
defer vx.deinit(alloc, tty.anyWriter());
// We know we are ghostty, so let's enable mode 2027. Vaxis normally does this but you need an
// event loop to auto-enable it.
vx.caps.unicode = .unicode;
try tty.anyWriter().writeAll(vaxis.ctlseqs.unicode_set);
defer tty.anyWriter().writeAll(vaxis.ctlseqs.unicode_reset) catch {};
var buf_writer = tty.bufferedWriter();
const writer = buf_writer.writer().any();
const winsize: vaxis.Winsize = switch (builtin.os.tag) {
// We use some default, it doesn't really matter for what
// we're doing because we don't do any wrapping.
.windows => .{
.rows = 24,
.cols = 120,
.x_pixel = 1024,
.y_pixel = 768,
},
else => try vaxis.Tty.getWinsize(tty.fd),
};
try vx.resize(alloc, tty.anyWriter(), winsize);
const win = vx.window();
var max_name_len: usize = 0;
for (keys) |name| {
if (name.len > max_name_len) max_name_len = name.len;
}
// max name length plus " = #RRGGBB XX" plus " " gutter between columns
const column_size = max_name_len + 15;
// add two to take into account lack of gutter after last column
const columns: usize = @divFloor(win.width + 2, column_size);
var i: usize = 0;
const step = @divFloor(keys.len, columns) + 1;
while (i < step) : (i += 1) {
win.clear();
var result: vaxis.Window.PrintResult = .{ .col = 0, .row = 0, .overflow = false };
for (0..columns) |j| {
const k = i + (step * j);
if (k >= keys.len) continue;
const name = keys[k];
const rgb = x11_color.map.get(name).?;
const style1: vaxis.Style = .{
.fg = .{
.rgb = .{ rgb.r, rgb.g, rgb.b },
},
};
const style2: vaxis.Style = .{
.fg = .{
.rgb = .{ rgb.r, rgb.g, rgb.b },
},
.bg = .{
.rgb = .{ rgb.r, rgb.g, rgb.b },
},
};
// name of the color
result = win.printSegment(
.{ .text = name },
.{ .col_offset = result.col },
);
// push the color data to the end of the column
for (0..max_name_len - name.len) |_| {
result = win.printSegment(
.{ .text = " " },
.{ .col_offset = result.col },
);
}
result = win.printSegment(
.{ .text = " = " },
.{ .col_offset = result.col },
);
// rgb triple
result = win.printSegment(.{
.text = try std.fmt.allocPrint(
alloc,
"#{x:0>2}{x:0>2}{x:0>2}",
.{
rgb.r, rgb.g, rgb.b,
},
),
.style = style1,
}, .{ .col_offset = result.col });
result = win.printSegment(
.{ .text = " " },
.{ .col_offset = result.col },
);
// colored block
result = win.printSegment(
.{
.text = " ",
.style = style2,
},
.{ .col_offset = result.col },
);
// add the gutter if needed
if (j + 1 < columns) {
result = win.printSegment(
.{
.text = " ",
},
.{ .col_offset = result.col },
);
}
}
// clear the rest of the line
while (result.col != 0) {
result = win.printSegment(
.{
.text = " ",
},
.{ .col_offset = result.col },
);
}
// output the data
try vx.prettyPrint(writer);
}
// be sure to flush!
try buf_writer.flush();
return 0;
}

View File

@@ -767,6 +767,22 @@ palette: Palette = .{},
/// the mouse is shown again when a new window, tab, or split is created.
@"mouse-hide-while-typing": bool = false,
/// When to scroll the surface to the bottom. The format of this is a list of
/// options to enable separated by commas. If you prefix an option with `no-`
/// then it is disabled. If you omit an option, its default value is used.
///
/// Available options:
///
/// - `keystroke` If set, scroll the surface to the bottom when the user
/// presses a key that results in data being sent to the PTY (basically
/// 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.)
///
/// The default is `keystroke, no-output`.
@"scroll-to-bottom": ScrollToBottom = .default,
/// Determines whether running programs can detect the shift key pressed with a
/// mouse click. Typically, the shift key is used to extend mouse selection.
///
@@ -2499,6 +2515,8 @@ keybind: Keybinds = .{},
///
/// - `clipboard-copy` (default: true) - Show a notification when text is copied
/// to the clipboard.
/// - `config-reload` (default: true) - Show a notification when
/// the configuration is reloaded.
///
/// To specify a notification to enable, specify the name of the notification.
/// To specify a notification to disable, prefix the name with `no-`. For
@@ -3017,6 +3035,13 @@ else
/// Available since Ghostty 1.2.0.
@"bold-color": ?BoldColor = null,
/// The opacity level (opposite of transparency) of the faint text. A value of
/// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0
/// or greater than 1 will be clamped to the nearest valid value.
///
/// Available since Ghostty 1.2.0.
@"faint-opacity": f64 = 0.5,
/// This will be used to set the `TERM` environment variable.
/// HACK: We set this with an `xterm` prefix because vim uses that to enable key
/// protocols (specifically this will enable `modifyOtherKeys`), among other
@@ -3999,6 +4024,8 @@ pub fn finalize(self: *Config) !void {
if (self.@"auto-update-channel" == null) {
self.@"auto-update-channel" = build_config.release_channel;
}
self.@"faint-opacity" = std.math.clamp(self.@"faint-opacity", 0.0, 1.0);
}
/// Callback for src/cli/args.zig to allow us to handle special cases
@@ -5596,7 +5623,7 @@ pub const Keybinds = struct {
try self.set.put(
alloc,
.{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } },
.{ .close_tab = {} },
.{ .close_tab = .this },
);
try self.set.putFlags(
alloc,
@@ -5902,7 +5929,7 @@ pub const Keybinds = struct {
try self.set.put(
alloc,
.{ .key = .{ .unicode = 'w' }, .mods = .{ .super = true, .alt = true } },
.{ .close_tab = {} },
.{ .close_tab = .this },
);
try self.set.put(
alloc,
@@ -7058,6 +7085,7 @@ pub const GtkTitlebarStyle = enum(c_int) {
/// See app-notifications
pub const AppNotifications = packed struct {
@"clipboard-copy": bool = true,
@"config-reload": bool = true,
};
/// See bell-features
@@ -7195,6 +7223,53 @@ pub const QuickTerminalSize = struct {
height: u32,
};
/// C API structure for QuickTerminalSize
pub const C = extern struct {
primary: C.Size,
secondary: C.Size,
pub const Size = extern struct {
tag: Tag,
value: Value,
pub const Tag = enum(u8) { none, percentage, pixels };
pub const Value = extern union {
percentage: f32,
pixels: u32,
};
pub const none: C.Size = .{ .tag = .none, .value = undefined };
pub fn percentage(v: f32) C.Size {
return .{
.tag = .percentage,
.value = .{ .percentage = v },
};
}
pub fn pixels(v: u32) C.Size {
return .{
.tag = .pixels,
.value = .{ .pixels = v },
};
}
};
};
pub fn cval(self: QuickTerminalSize) C {
return .{
.primary = if (self.primary) |p| switch (p) {
.percentage => |v| .percentage(v),
.pixels => |v| .pixels(v),
} else .none,
.secondary = if (self.secondary) |s| switch (s) {
.percentage => |v| .percentage(v),
.pixels => |v| .pixels(v),
} else .none,
};
}
pub fn calculate(
self: QuickTerminalSize,
position: QuickTerminalPosition,
@@ -7268,6 +7343,7 @@ pub const QuickTerminalSize = struct {
try formatter.formatEntry([]const u8, fbs.getWritten());
}
test "parse QuickTerminalSize" {
const testing = std.testing;
var v: QuickTerminalSize = undefined;
@@ -7980,6 +8056,14 @@ pub const WindowPadding = struct {
}
};
/// See scroll-to-bottom
pub const ScrollToBottom = packed struct {
keystroke: bool = true,
output: bool = false,
pub const default: ScrollToBottom = .{};
};
test "parse duration" {
inline for (Duration.units) |unit| {
var buf: [16]u8 = undefined;

View File

@@ -552,11 +552,15 @@ pub const Action = union(enum) {
/// of the `confirm-close-surface` configuration setting.
close_surface,
/// Close the current tab and all splits therein.
/// Close the current tab and all splits therein _or_ close all tabs and
/// splits thein of tabs _other_ than the current tab, depending on the
/// mode.
///
/// If the mode is not specified, defaults to closing the current tab.
///
/// This might trigger a close confirmation popup, depending on the value
/// of the `confirm-close-surface` configuration setting.
close_tab,
close_tab: CloseTabMode,
/// Close the current window and all tabs and splits therein.
///
@@ -858,6 +862,13 @@ pub const Action = union(enum) {
hide,
};
pub const CloseTabMode = enum {
this,
other,
pub const default: CloseTabMode = .this;
};
fn parseEnum(comptime T: type, value: []const u8) !T {
return std.meta.stringToEnum(T, value) orelse return Error.InvalidFormat;
}

View File

@@ -393,11 +393,18 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Close the current terminal.",
}},
.close_tab => comptime &.{.{
.action = .close_tab,
.title = "Close Tab",
.description = "Close the current tab.",
}},
.close_tab => comptime &.{
.{
.action = .{ .close_tab = .this },
.title = "Close Tab",
.description = "Close the current tab.",
},
.{
.action = .{ .close_tab = .other },
.title = "Close Other Tabs",
.description = "Close all tabs in this window except the current one.",
},
},
.close_window => comptime &.{.{
.action = .close_window,

View File

@@ -49,6 +49,7 @@ pub const locales = [_][:0]const u8{
"ca_ES.UTF-8",
"bg_BG.UTF-8",
"ga_IE.UTF-8",
"hu_HU.UTF-8",
"he_IL.UTF-8",
};

View File

@@ -522,6 +522,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
selection_background: ?configpkg.Config.TerminalColor,
selection_foreground: ?configpkg.Config.TerminalColor,
bold_color: ?configpkg.BoldColor,
faint_opacity: u8,
min_contrast: f32,
padding_color: configpkg.WindowPaddingColor,
custom_shaders: configpkg.RepeatablePath,
@@ -584,6 +585,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.background = config.background.toTerminalRGB(),
.foreground = config.foreground.toTerminalRGB(),
.bold_color = config.@"bold-color",
.faint_opacity = @intFromFloat(@ceil(config.@"faint-opacity" * 255)),
.min_contrast = @floatCast(config.@"minimum-contrast"),
.padding_color = config.@"window-padding-color",
@@ -2225,23 +2227,44 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]);
const cursor_height: f32 = @floatFromInt(cursor.glyph_size[1]);
// Left edge of the cell the cursor is in.
var pixel_x: f32 = @floatFromInt(
cursor.grid_pos[0] * cell.width + padding.left,
);
// Top edge, relative to the top of the
// screen, of the cell the cursor is in.
var pixel_y: f32 = @floatFromInt(
cursor.grid_pos[1] * cell.height + padding.top,
);
pixel_x += @floatFromInt(cursor.bearings[0]);
pixel_y += @floatFromInt(cursor.bearings[1]);
// If +Y is up in our shaders, we need to flip the coordinate.
// If +Y is up in our shaders, we need to flip the coordinate
// so that it's instead the top edge of the cell relative to
// the *bottom* of the screen.
if (!GraphicsAPI.custom_shader_y_is_down) {
pixel_y = @as(f32, @floatFromInt(screen.height)) - pixel_y;
// We need to add the cursor height because we need the +Y
// edge for the Y coordinate, and flipping means that it's
// the -Y edge now.
pixel_y += cursor_height;
}
// Add the X bearing to get the -X (left) edge of the cursor.
pixel_x += @floatFromInt(cursor.bearings[0]);
// How we deal with the Y bearing depends on which direction
// is "up", since we want our final `pixel_y` value to be the
// +Y edge of the cursor.
if (GraphicsAPI.custom_shader_y_is_down) {
// As a reminder, the Y bearing is the distance from the
// bottom of the cell to the top of the glyph, so to get
// the +Y edge we need to add the cell height, subtract
// the Y bearing, and add the glyph height to get the +Y
// (bottom) edge of the cursor.
pixel_y += @floatFromInt(cell.height);
pixel_y -= @floatFromInt(cursor.bearings[1]);
pixel_y += @floatFromInt(cursor.glyph_size[1]);
} else {
// If the Y direction is reversed though, we instead want
// the *top* edge of the cursor, which means we just need
// to subtract the cell height and add the Y bearing.
pixel_y -= @floatFromInt(cell.height);
pixel_y += @floatFromInt(cursor.bearings[1]);
}
const new_cursor: [4]f32 = .{
@@ -2612,7 +2635,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
};
// Foreground alpha for this cell.
const alpha: u8 = if (style.flags.faint) 175 else 255;
const alpha: u8 = if (style.flags.faint) self.config.faint_opacity else 255;
// Set the cell's background color.
{

View File

@@ -193,7 +193,7 @@ pub const Action = union(enum) {
/// Maximum number of intermediate characters during parsing. This is
/// 4 because we also use the intermediates array for UTF8 decoding which
/// can be at most 4 bytes.
const MAX_INTERMEDIATE = 4;
pub const MAX_INTERMEDIATE = 4;
/// Maximum number of CSI parameters. This is arbitrary. Practically, the
/// only CSI command that uses more than 3 parameters is the SGR command
@@ -206,7 +206,7 @@ const MAX_INTERMEDIATE = 4;
/// number. I implore TUI authors to not use more than this number of CSI
/// params, but I suspect we'll introduce a slow path with heap allocation
/// one day.
const MAX_PARAMS = 24;
pub const MAX_PARAMS = 24;
/// Current state of the state machine
state: State,
@@ -949,6 +949,55 @@ test "csi: too many params" {
}
}
test "csi: sgr with up to our max parameters" {
for (1..MAX_PARAMS + 1) |max| {
var p = init();
_ = p.next(0x1B);
_ = p.next('[');
for (0..max - 1) |_| {
_ = p.next('1');
_ = p.next(';');
}
_ = p.next('2');
{
const a = p.next('H');
try testing.expect(p.state == .ground);
try testing.expect(a[0] == null);
try testing.expect(a[1].? == .csi_dispatch);
try testing.expect(a[2] == null);
const csi = a[1].?.csi_dispatch;
try testing.expectEqual(@as(usize, max), csi.params.len);
try testing.expectEqual(@as(u16, 2), csi.params[max - 1]);
}
}
}
test "csi: sgr beyond our max drops it" {
// Has to be +2 for the loops below
const max = MAX_PARAMS + 2;
var p = init();
_ = p.next(0x1B);
_ = p.next('[');
for (0..max - 1) |_| {
_ = p.next('1');
_ = p.next(';');
}
_ = p.next('2');
{
const a = p.next('H');
try testing.expect(p.state == .ground);
try testing.expect(a[0] == null);
try testing.expect(a[1] == null);
try testing.expect(a[2] == null);
}
}
test "dcs: XTGETTCAP" {
var p = init();
_ = p.next(0x1B);

View File

@@ -147,25 +147,28 @@ pub const Command = union(enum) {
/// End a hyperlink (OSC 8)
hyperlink_end: void,
/// Sleep (OSC 9;1)
sleep: struct {
/// ConEmu sleep (OSC 9;1)
conemu_sleep: struct {
duration_ms: u16,
},
/// Show GUI message Box (OSC 9;2)
show_message_box: []const u8,
/// ConEmu show GUI message box (OSC 9;2)
conemu_show_message_box: []const u8,
/// Change ConEmu tab (OSC 9;3)
change_conemu_tab_title: union(enum) {
reset: void,
/// ConEmu change tab title (OSC 9;3)
conemu_change_tab_title: union(enum) {
reset,
value: []const u8,
},
/// Set progress state (OSC 9;4)
progress_report: ProgressReport,
/// ConEmu progress report (OSC 9;4)
conemu_progress_report: ProgressReport,
/// Wait input (OSC 9;5)
wait_input: void,
/// ConEmu wait input (OSC 9;5)
conemu_wait_input,
/// ConEmu GUI macro (OSC 9;6)
conemu_guimacro: []const u8,
pub const ColorOperation = union(enum) {
pub const Source = enum(u16) {
@@ -208,7 +211,6 @@ pub const Command = union(enum) {
};
pub const ProgressReport = struct {
// sync with ghostty_terminal_osc_command_progressreport_state_e in include/ghostty.h
pub const State = enum(c_int) {
remove,
set,
@@ -220,7 +222,7 @@ pub const Command = union(enum) {
state: State,
progress: ?u8 = null,
// sync with ghostty_terminal_osc_command_progressreport_s in include/ghostty.h
// sync with ghostty_action_progress_report_s
pub const C = extern struct {
state: c_int,
progress: i8,
@@ -229,7 +231,11 @@ pub const Command = union(enum) {
pub fn cval(self: ProgressReport) C {
return .{
.state = @intFromEnum(self.state),
.progress = if (self.progress) |progress| @intCast(std.math.clamp(progress, 0, 100)) else -1,
.progress = if (self.progress) |progress| @intCast(std.math.clamp(
progress,
0,
100,
)) else -1,
};
}
};
@@ -431,6 +437,7 @@ pub const Parser = struct {
conemu_progress_state,
conemu_progress_prevalue,
conemu_progress_value,
conemu_guimacro,
};
pub fn init() Parser {
@@ -957,107 +964,147 @@ pub const Parser = struct {
.osc_9 => switch (c) {
'1' => {
self.state = .conemu_sleep;
// This will end up being either a ConEmu sleep OSC 9;1,
// or a desktop notification OSC 9 that begins with '1', so
// mark as complete.
self.complete = true;
},
'2' => {
self.state = .conemu_message_box;
// This will end up being either a ConEmu message box OSC 9;2,
// or a desktop notification OSC 9 that begins with '2', so
// mark as complete.
self.complete = true;
},
'3' => {
self.state = .conemu_tab;
// This will end up being either a ConEmu message box OSC 9;3,
// or a desktop notification OSC 9 that begins with '3', so
// mark as complete.
self.complete = true;
},
'4' => {
self.state = .conemu_progress_prestate;
// This will end up being either a ConEmu progress report
// OSC 9;4, or a desktop notification OSC 9 that begins with
// '4', so mark as complete.
self.complete = true;
},
'5' => {
// Note that sending an OSC 9 desktop notification that
// starts with 5 is impossible due to this.
self.state = .swallow;
self.command = .{ .wait_input = {} };
self.command = .conemu_wait_input;
self.complete = true;
},
'6' => {
self.state = .conemu_guimacro;
// This will end up being either a ConEmu GUI macro OSC 9;6,
// or a desktop notification OSC 9 that begins with '6', so
// mark as complete.
self.complete = true;
},
// Todo: parse out other ConEmu operating system commands.
// Even if we don't support them we probably don't want
// them showing up as desktop notifications.
// Todo: parse out other ConEmu operating system commands. Even
// if we don't support them we probably don't want them showing
// up as desktop notifications.
else => self.showDesktopNotification(),
},
.conemu_sleep => switch (c) {
';' => {
self.command = .{ .sleep = .{ .duration_ms = 100 } };
self.command = .{ .conemu_sleep = .{ .duration_ms = 100 } };
self.buf_start = self.buf_idx;
self.complete = true;
self.state = .conemu_sleep_value;
},
else => self.state = .invalid,
},
.conemu_message_box => switch (c) {
';' => {
self.command = .{ .show_message_box = undefined };
self.temp_state = .{ .str = &self.command.show_message_box };
self.buf_start = self.buf_idx;
self.complete = true;
self.prepAllocableString();
},
else => self.state = .invalid,
// OSC 9;1 <something other than semicolon> is a desktop
// notification.
else => self.showDesktopNotification(),
},
.conemu_sleep_value => switch (c) {
else => self.complete = true,
},
.conemu_message_box => switch (c) {
';' => {
self.command = .{ .conemu_show_message_box = undefined };
self.temp_state = .{ .str = &self.command.conemu_show_message_box };
self.buf_start = self.buf_idx;
self.complete = true;
self.prepAllocableString();
},
// OSC 9;2 <something other than semicolon> is a desktop
// notification.
else => self.showDesktopNotification(),
},
.conemu_tab => switch (c) {
';' => {
self.state = .conemu_tab_txt;
self.command = .{ .change_conemu_tab_title = .{ .reset = {} } };
self.command = .{ .conemu_change_tab_title = .reset };
self.buf_start = self.buf_idx;
self.complete = true;
},
else => self.state = .invalid,
// OSC 9;3 <something other than semicolon> is a desktop
// notification.
else => self.showDesktopNotification(),
},
.conemu_tab_txt => {
self.command = .{ .change_conemu_tab_title = .{ .value = undefined } };
self.temp_state = .{ .str = &self.command.change_conemu_tab_title.value };
self.command = .{ .conemu_change_tab_title = .{ .value = undefined } };
self.temp_state = .{ .str = &self.command.conemu_change_tab_title.value };
self.complete = true;
self.prepAllocableString();
},
.conemu_progress_prestate => switch (c) {
';' => {
self.command = .{ .progress_report = .{
self.command = .{ .conemu_progress_report = .{
.state = undefined,
} };
self.state = .conemu_progress_state;
},
// OSC 9;4 <something other than semicolon> is a desktop
// notification.
else => self.showDesktopNotification(),
},
.conemu_progress_state => switch (c) {
'0' => {
self.command.progress_report.state = .remove;
self.command.conemu_progress_report.state = .remove;
self.state = .swallow;
self.complete = true;
},
'1' => {
self.command.progress_report.state = .set;
self.command.progress_report.progress = 0;
self.command.conemu_progress_report.state = .set;
self.command.conemu_progress_report.progress = 0;
self.state = .conemu_progress_prevalue;
},
'2' => {
self.command.progress_report.state = .@"error";
self.command.conemu_progress_report.state = .@"error";
self.complete = true;
self.state = .conemu_progress_prevalue;
},
'3' => {
self.command.progress_report.state = .indeterminate;
self.command.conemu_progress_report.state = .indeterminate;
self.complete = true;
self.state = .swallow;
},
'4' => {
self.command.progress_report.state = .pause;
self.command.conemu_progress_report.state = .pause;
self.complete = true;
self.state = .conemu_progress_prevalue;
},
// OSC 9;4; <something other than 0-4> is a desktop
// notification.
else => self.showDesktopNotification(),
},
@@ -1066,6 +1113,8 @@ pub const Parser = struct {
self.state = .conemu_progress_value;
},
// OSC 9;4;<0-4> <something other than semicolon> is a desktop
// notification.
else => self.showDesktopNotification(),
},
@@ -1077,8 +1126,16 @@ pub const Parser = struct {
// If we aren't a set substate, then we don't care
// about the value.
const p = &self.command.progress_report;
if (p.state != .set and p.state != .@"error" and p.state != .pause) break :value;
const p = &self.command.conemu_progress_report;
switch (p.state) {
.remove,
.indeterminate,
=> break :value,
.set,
.@"error",
.pause,
=> {},
}
if (p.state == .set)
assert(p.progress != null)
@@ -1104,6 +1161,20 @@ pub const Parser = struct {
},
},
.conemu_guimacro => switch (c) {
';' => {
self.command = .{ .conemu_guimacro = undefined };
self.temp_state = .{ .str = &self.command.conemu_guimacro };
self.buf_start = self.buf_idx;
self.state = .string;
self.complete = true;
},
// OSC 9;6 <something other than semicolon> is a desktop
// notification.
else => self.showDesktopNotification(),
},
.semantic_prompt => switch (c) {
'A' => {
self.state = .semantic_option_start;
@@ -1212,6 +1283,11 @@ pub const Parser = struct {
self.temp_state = .{ .str = &self.command.show_desktop_notification.body };
self.state = .string;
// Set as complete as we've already seen one character that should be
// part of the notification. If we wait for another character to set
// `complete` when the state is `.string` we won't be able to send any
// single character notifications.
self.complete = true;
}
fn prepAllocableString(self: *Parser) void {
@@ -1332,7 +1408,7 @@ pub const Parser = struct {
fn endConEmuSleepValue(self: *Parser) void {
switch (self.command) {
.sleep => |*v| v.duration_ms = value: {
.conemu_sleep => |*v| v.duration_ms = value: {
const str = self.buf[self.buf_start..self.buf_idx];
if (str.len == 0) break :value 100;
@@ -1595,6 +1671,26 @@ pub const Parser = struct {
.hyperlink_uri => self.endHyperlink(),
.string => self.endString(),
.conemu_sleep_value => self.endConEmuSleepValue(),
// We received OSC 9;X ST, but nothing else, finish off as a
// desktop notification with "X" as the body.
.conemu_sleep,
.conemu_message_box,
.conemu_tab,
.conemu_progress_prestate,
.conemu_progress_state,
.conemu_guimacro,
=> {
self.showDesktopNotification();
self.endString();
},
// A ConEmu progress report that has reached these states is
// complete, don't do anything to them.
.conemu_progress_prevalue,
.conemu_progress_value,
=> {},
.allocable_string => self.endAllocableString(),
.kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true),
.kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true),
@@ -2770,7 +2866,7 @@ test "OSC: OSC104: empty palette index" {
try std.testing.expect(it.next() == null);
}
test "OSC: conemu sleep" {
test "OSC: OSC 9;1 ConEmu sleep" {
const testing = std.testing;
var p: Parser = .init();
@@ -2780,11 +2876,11 @@ test "OSC: conemu sleep" {
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .sleep);
try testing.expectEqual(420, cmd.sleep.duration_ms);
try testing.expect(cmd == .conemu_sleep);
try testing.expectEqual(420, cmd.conemu_sleep.duration_ms);
}
test "OSC: conemu sleep with no value default to 100ms" {
test "OSC: OSC 9;1 ConEmu sleep with no value default to 100ms" {
const testing = std.testing;
var p: Parser = .init();
@@ -2794,11 +2890,11 @@ test "OSC: conemu sleep with no value default to 100ms" {
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .sleep);
try testing.expectEqual(100, cmd.sleep.duration_ms);
try testing.expect(cmd == .conemu_sleep);
try testing.expectEqual(100, cmd.conemu_sleep.duration_ms);
}
test "OSC: conemu sleep cannot exceed 10000ms" {
test "OSC: OSC 9;1 conemu sleep cannot exceed 10000ms" {
const testing = std.testing;
var p: Parser = .init();
@@ -2808,11 +2904,11 @@ test "OSC: conemu sleep cannot exceed 10000ms" {
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .sleep);
try testing.expectEqual(10000, cmd.sleep.duration_ms);
try testing.expect(cmd == .conemu_sleep);
try testing.expectEqual(10000, cmd.conemu_sleep.duration_ms);
}
test "OSC: conemu sleep invalid input" {
test "OSC: OSC 9;1 conemu sleep invalid input" {
const testing = std.testing;
var p: Parser = .init();
@@ -2822,11 +2918,39 @@ test "OSC: conemu sleep invalid input" {
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .sleep);
try testing.expectEqual(100, cmd.sleep.duration_ms);
try testing.expect(cmd == .conemu_sleep);
try testing.expectEqual(100, cmd.conemu_sleep.duration_ms);
}
test "OSC: show desktop notification" {
test "OSC: OSC 9;1 conemu sleep -> desktop notification 1" {
const testing = std.testing;
var p: Parser = .init();
const input = "9;1";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("1", cmd.show_desktop_notification.body);
}
test "OSC: OSC 9;1 conemu sleep -> desktop notification 2" {
const testing = std.testing;
var p: Parser = .init();
const input = "9;1a";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("1a", cmd.show_desktop_notification.body);
}
test "OSC: OSC 9 show desktop notification" {
const testing = std.testing;
var p: Parser = .init();
@@ -2836,11 +2960,25 @@ test "OSC: show desktop notification" {
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings(cmd.show_desktop_notification.title, "");
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Hello world");
try testing.expectEqualStrings("", cmd.show_desktop_notification.title);
try testing.expectEqualStrings("Hello world", cmd.show_desktop_notification.body);
}
test "OSC: show desktop notification with title" {
test "OSC: OSC 9 show single character desktop notification" {
const testing = std.testing;
var p: Parser = .init();
const input = "9;H";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("", cmd.show_desktop_notification.title);
try testing.expectEqualStrings("H", cmd.show_desktop_notification.body);
}
test "OSC: OSC 777 show desktop notification with title" {
const testing = std.testing;
var p: Parser = .init();
@@ -2854,7 +2992,7 @@ test "OSC: show desktop notification with title" {
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body");
}
test "OSC: conemu message box" {
test "OSC: OSC 9;2 ConEmu message box" {
const testing = std.testing;
var p: Parser = .init();
@@ -2863,11 +3001,11 @@ test "OSC: conemu message box" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_message_box);
try testing.expectEqualStrings("hello world", cmd.show_message_box);
try testing.expect(cmd == .conemu_show_message_box);
try testing.expectEqualStrings("hello world", cmd.conemu_show_message_box);
}
test "OSC: conemu message box invalid input" {
test "OSC: 9;2 ConEmu message box invalid input" {
const testing = std.testing;
var p: Parser = .init();
@@ -2875,11 +3013,12 @@ test "OSC: conemu message box invalid input" {
const input = "9;2";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b');
try testing.expect(cmd == null);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("2", cmd.show_desktop_notification.body);
}
test "OSC: conemu message box empty message" {
test "OSC: 9;2 ConEmu message box empty message" {
const testing = std.testing;
var p: Parser = .init();
@@ -2888,11 +3027,11 @@ test "OSC: conemu message box empty message" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_message_box);
try testing.expectEqualStrings("", cmd.show_message_box);
try testing.expect(cmd == .conemu_show_message_box);
try testing.expectEqualStrings("", cmd.conemu_show_message_box);
}
test "OSC: conemu message box spaces only message" {
test "OSC: 9;2 ConEmu message box spaces only message" {
const testing = std.testing;
var p: Parser = .init();
@@ -2901,11 +3040,39 @@ test "OSC: conemu message box spaces only message" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_message_box);
try testing.expectEqualStrings(" ", cmd.show_message_box);
try testing.expect(cmd == .conemu_show_message_box);
try testing.expectEqualStrings(" ", cmd.conemu_show_message_box);
}
test "OSC: conemu change tab title" {
test "OSC: OSC 9;2 message box -> desktop notification 1" {
const testing = std.testing;
var p: Parser = .init();
const input = "9;2";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("2", cmd.show_desktop_notification.body);
}
test "OSC: OSC 9;2 message box -> desktop notification 2" {
const testing = std.testing;
var p: Parser = .init();
const input = "9;2a";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("2a", cmd.show_desktop_notification.body);
}
test "OSC: 9;3 ConEmu change tab title" {
const testing = std.testing;
var p: Parser = .init();
@@ -2914,11 +3081,11 @@ test "OSC: conemu change tab title" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .change_conemu_tab_title);
try testing.expectEqualStrings("foo bar", cmd.change_conemu_tab_title.value);
try testing.expect(cmd == .conemu_change_tab_title);
try testing.expectEqualStrings("foo bar", cmd.conemu_change_tab_title.value);
}
test "OSC: conemu change tab reset title" {
test "OSC: 9;3 ConEmu change tab title reset" {
const testing = std.testing;
var p: Parser = .init();
@@ -2928,11 +3095,11 @@ test "OSC: conemu change tab reset title" {
const cmd = p.end('\x1b').?;
const expected_command: Command = .{ .change_conemu_tab_title = .{ .reset = {} } };
const expected_command: Command = .{ .conemu_change_tab_title = .reset };
try testing.expectEqual(expected_command, cmd);
}
test "OSC: conemu change tab spaces only title" {
test "OSC: 9;3 ConEmu change tab title spaces only" {
const testing = std.testing;
var p: Parser = .init();
@@ -2942,11 +3109,11 @@ test "OSC: conemu change tab spaces only title" {
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .change_conemu_tab_title);
try testing.expectEqualStrings(" ", cmd.change_conemu_tab_title.value);
try testing.expect(cmd == .conemu_change_tab_title);
try testing.expectEqualStrings(" ", cmd.conemu_change_tab_title.value);
}
test "OSC: conemu change tab invalid input" {
test "OSC: OSC 9;3 change tab title -> desktop notification 1" {
const testing = std.testing;
var p: Parser = .init();
@@ -2954,11 +3121,27 @@ test "OSC: conemu change tab invalid input" {
const input = "9;3";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b');
try testing.expect(cmd == null);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("3", cmd.show_desktop_notification.body);
}
test "OSC: OSC9 progress set" {
test "OSC: OSC 9;3 message box -> desktop notification 2" {
const testing = std.testing;
var p: Parser = .init();
const input = "9;3a";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("3a", cmd.show_desktop_notification.body);
}
test "OSC: OSC 9;4 ConEmu progress set" {
const testing = std.testing;
var p: Parser = .init();
@@ -2967,12 +3150,12 @@ test "OSC: OSC9 progress set" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .set);
try testing.expect(cmd.progress_report.progress == 100);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .set);
try testing.expect(cmd.conemu_progress_report.progress == 100);
}
test "OSC: OSC9 progress set overflow" {
test "OSC: OSC 9;4 ConEmu progress set overflow" {
const testing = std.testing;
var p: Parser = .init();
@@ -2981,12 +3164,12 @@ test "OSC: OSC9 progress set overflow" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .set);
try testing.expect(cmd.progress_report.progress == 100);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .set);
try testing.expectEqual(100, cmd.conemu_progress_report.progress);
}
test "OSC: OSC9 progress set single digit" {
test "OSC: OSC 9;4 ConEmu progress set single digit" {
const testing = std.testing;
var p: Parser = .init();
@@ -2995,12 +3178,12 @@ test "OSC: OSC9 progress set single digit" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .set);
try testing.expect(cmd.progress_report.progress == 9);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .set);
try testing.expect(cmd.conemu_progress_report.progress == 9);
}
test "OSC: OSC9 progress set double digit" {
test "OSC: OSC 9;4 ConEmu progress set double digit" {
const testing = std.testing;
var p: Parser = .init();
@@ -3009,12 +3192,12 @@ test "OSC: OSC9 progress set double digit" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .set);
try testing.expect(cmd.progress_report.progress == 94);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .set);
try testing.expectEqual(94, cmd.conemu_progress_report.progress);
}
test "OSC: OSC9 progress set extra semicolon ignored" {
test "OSC: OSC 9;4 ConEmu progress set extra semicolon ignored" {
const testing = std.testing;
var p: Parser = .init();
@@ -3023,12 +3206,12 @@ test "OSC: OSC9 progress set extra semicolon ignored" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .set);
try testing.expect(cmd.progress_report.progress == 100);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .set);
try testing.expectEqual(100, cmd.conemu_progress_report.progress);
}
test "OSC: OSC9 progress remove with no progress" {
test "OSC: OSC 9;4 ConEmu progress remove with no progress" {
const testing = std.testing;
var p: Parser = .init();
@@ -3037,12 +3220,12 @@ test "OSC: OSC9 progress remove with no progress" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .remove);
try testing.expect(cmd.progress_report.progress == null);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .remove);
try testing.expect(cmd.conemu_progress_report.progress == null);
}
test "OSC: OSC9 progress remove with double semicolon" {
test "OSC: OSC 9;4 ConEmu progress remove with double semicolon" {
const testing = std.testing;
var p: Parser = .init();
@@ -3051,12 +3234,12 @@ test "OSC: OSC9 progress remove with double semicolon" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .remove);
try testing.expect(cmd.progress_report.progress == null);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .remove);
try testing.expect(cmd.conemu_progress_report.progress == null);
}
test "OSC: OSC9 progress remove ignores progress" {
test "OSC: OSC 9;4 ConEmu progress remove ignores progress" {
const testing = std.testing;
var p: Parser = .init();
@@ -3065,12 +3248,12 @@ test "OSC: OSC9 progress remove ignores progress" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .remove);
try testing.expect(cmd.progress_report.progress == null);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .remove);
try testing.expect(cmd.conemu_progress_report.progress == null);
}
test "OSC: OSC9 progress remove extra semicolon" {
test "OSC: OSC 9;4 ConEmu progress remove extra semicolon" {
const testing = std.testing;
var p: Parser = .init();
@@ -3079,11 +3262,11 @@ test "OSC: OSC9 progress remove extra semicolon" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .remove);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .remove);
}
test "OSC: OSC9 progress error" {
test "OSC: OSC 9;4 ConEmu progress error" {
const testing = std.testing;
var p: Parser = .init();
@@ -3092,12 +3275,12 @@ test "OSC: OSC9 progress error" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .@"error");
try testing.expect(cmd.progress_report.progress == null);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .@"error");
try testing.expect(cmd.conemu_progress_report.progress == null);
}
test "OSC: OSC9 progress error with progress" {
test "OSC: OSC 9;4 ConEmu progress error with progress" {
const testing = std.testing;
var p: Parser = .init();
@@ -3106,12 +3289,12 @@ test "OSC: OSC9 progress error with progress" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .@"error");
try testing.expect(cmd.progress_report.progress == 100);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .@"error");
try testing.expect(cmd.conemu_progress_report.progress == 100);
}
test "OSC: OSC9 progress pause" {
test "OSC: OSC 9;4 progress pause" {
const testing = std.testing;
var p: Parser = .init();
@@ -3120,12 +3303,12 @@ test "OSC: OSC9 progress pause" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .pause);
try testing.expect(cmd.progress_report.progress == null);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .pause);
try testing.expect(cmd.conemu_progress_report.progress == null);
}
test "OSC: OSC9 progress pause with progress" {
test "OSC: OSC 9;4 ConEmu progress pause with progress" {
const testing = std.testing;
var p: Parser = .init();
@@ -3134,12 +3317,68 @@ test "OSC: OSC9 progress pause with progress" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress_report);
try testing.expect(cmd.progress_report.state == .pause);
try testing.expect(cmd.progress_report.progress == 100);
try testing.expect(cmd == .conemu_progress_report);
try testing.expect(cmd.conemu_progress_report.state == .pause);
try testing.expect(cmd.conemu_progress_report.progress == 100);
}
test "OSC: OSC9 conemu wait input" {
test "OSC: OSC 9;4 progress -> desktop notification 1" {
const testing = std.testing;
var p: Parser = .init();
const input = "9;4";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("4", cmd.show_desktop_notification.body);
}
test "OSC: OSC 9;4 progress -> desktop notification 2" {
const testing = std.testing;
var p: Parser = .init();
const input = "9;4;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("4;", cmd.show_desktop_notification.body);
}
test "OSC: OSC 9;4 progress -> desktop notification 3" {
const testing = std.testing;
var p: Parser = .init();
const input = "9;4;5";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("4;5", cmd.show_desktop_notification.body);
}
test "OSC: OSC 9;4 progress -> desktop notification 4" {
const testing = std.testing;
var p: Parser = .init();
const input = "9;4;5a";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("4;5a", cmd.show_desktop_notification.body);
}
test "OSC: OSC 9;5 ConEmu wait input" {
const testing = std.testing;
var p: Parser = .init();
@@ -3148,10 +3387,10 @@ test "OSC: OSC9 conemu wait input" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .wait_input);
try testing.expect(cmd == .conemu_wait_input);
}
test "OSC: OSC9 conemu wait ignores trailing characters" {
test "OSC: OSC 9;5 ConEmu wait ignores trailing characters" {
const testing = std.testing;
var p: Parser = .init();
@@ -3160,7 +3399,7 @@ test "OSC: OSC9 conemu wait ignores trailing characters" {
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .wait_input);
try testing.expect(cmd == .conemu_wait_input);
}
test "OSC: empty param" {
@@ -3415,3 +3654,45 @@ test "OSC: kitty color protocol no key" {
try testing.expect(cmd == .kitty_color_protocol);
try testing.expectEqual(0, cmd.kitty_color_protocol.list.items.len);
}
test "OSC: 9;6: ConEmu guimacro 1" {
const testing = std.testing;
var p: Parser = .initAlloc(testing.allocator);
defer p.deinit();
const input = "9;6;a";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .conemu_guimacro);
try testing.expectEqualStrings("a", cmd.conemu_guimacro);
}
test "OSC: 9;6: ConEmu guimacro 2" {
const testing = std.testing;
var p: Parser = .initAlloc(testing.allocator);
defer p.deinit();
const input = "9;6;ab";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .conemu_guimacro);
try testing.expectEqualStrings("ab", cmd.conemu_guimacro);
}
test "OSC: 9;6: ConEmu guimacro 3 incomplete -> desktop notification" {
const testing = std.testing;
var p: Parser = .initAlloc(testing.allocator);
defer p.deinit();
const input = "9;6";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings("6", cmd.show_desktop_notification.body);
}

View File

@@ -134,7 +134,7 @@ pub const Parser = struct {
self.idx += 1;
return .{ .unknown = .{
.full = self.params,
.partial = slice[0 .. self.idx - start + 1],
.partial = slice[0..@min(self.idx - start + 1, slice.len)],
} };
},
};

View File

@@ -249,7 +249,7 @@ pub fn Stream(comptime Handler: type) type {
// the parser state to ground.
0x18, 0x1A => self.parser.state = .ground,
// A parameter digit:
'0'...'9' => if (self.parser.params_idx < 16) {
'0'...'9' => if (self.parser.params_idx < Parser.MAX_PARAMS) {
self.parser.param_acc *|= 10;
self.parser.param_acc +|= c - '0';
// The parser's CSI param action uses param_acc_idx
@@ -259,7 +259,7 @@ pub fn Stream(comptime Handler: type) type {
self.parser.param_acc_idx |= 1;
},
// A parameter separator:
':', ';' => if (self.parser.params_idx < 16) {
':', ';' => if (self.parser.params_idx < Parser.MAX_PARAMS) {
self.parser.params[self.parser.params_idx] = self.parser.param_acc;
if (c == ':') self.parser.params_sep.set(self.parser.params_idx);
self.parser.params_idx += 1;
@@ -1598,14 +1598,19 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented OSC callback: {}", .{cmd});
},
.progress_report => |v| {
.conemu_progress_report => |v| {
if (@hasDecl(T, "handleProgressReport")) {
try self.handler.handleProgressReport(v);
return;
} else log.warn("unimplemented OSC callback: {}", .{cmd});
},
.sleep, .show_message_box, .change_conemu_tab_title, .wait_input => {
.conemu_sleep,
.conemu_show_message_box,
.conemu_change_tab_title,
.conemu_wait_input,
.conemu_guimacro,
=> {
log.warn("unimplemented OSC callback: {}", .{cmd});
},
@@ -2596,3 +2601,22 @@ test "stream CSI ? W reset tab stops" {
try s.nextSlice("\x1b[?1;2;3W");
try testing.expect(s.handler.reset);
}
test "stream: SGR with 17+ parameters for underline color" {
const H = struct {
attrs: ?sgr.Attribute = null,
called: bool = false,
pub fn setAttribute(self: *@This(), attr: sgr.Attribute) !void {
self.attrs = attr;
self.called = true;
}
};
var s: Stream(H) = .init(.{});
// Kakoune-style SGR with underline color as 17th parameter
// This tests the fix where param 17 was being dropped
try s.nextSlice("\x1b[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136;0m");
try testing.expect(s.handler.called);
}

View File

@@ -1513,7 +1513,8 @@ fn execCommand(
}
return switch (command) {
.direct => |v| v,
// We need to clone the command since there's no guarantee the config remains valid.
.direct => |_| (try command.clone(alloc)).direct,
.shell => |v| shell: {
var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 4);
@@ -1688,3 +1689,35 @@ test "execCommand: direct command, error passwd" {
try testing.expectEqualStrings(result[0], "foo");
try testing.expectEqualStrings(result[1], "bar baz");
}
test "execCommand: direct command, config freed" {
if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var command_arena = ArenaAllocator.init(alloc);
const command_alloc = command_arena.allocator();
const command = try (configpkg.Command{
.direct = &.{
"foo",
"bar baz",
},
}).clone(command_alloc);
const result = try execCommand(alloc, command, struct {
fn get(_: Allocator) !PasswdEntry {
// Failed passwd entry means we can't construct a macOS
// login command and falls back to POSIX behavior.
return error.Fail;
}
});
command_arena.deinit();
try testing.expectEqual(2, result.len);
try testing.expectEqualStrings(result[0], "foo");
try testing.expectEqualStrings(result[1], "bar baz");
}