Merge branch 'main' into fix_2271

This commit is contained in:
Christian Kugler
2024-09-29 14:15:56 +02:00
committed by GitHub
88 changed files with 7733 additions and 2365 deletions

View File

@@ -138,7 +138,13 @@ pub fn addSurface(self: *App, rt_surface: *apprt.Surface) !void {
// Since we have non-zero surfaces, we can cancel the quit timer.
// It is up to the apprt if there is a quit timer at all and if it
// should be canceled.
if (@hasDecl(apprt.App, "cancelQuitTimer")) rt_surface.app.cancelQuitTimer();
rt_surface.app.performAction(
.app,
.quit_timer,
.stop,
) catch |err| {
log.warn("error stopping quit timer err={}", .{err});
};
}
/// Delete the surface from the known surface list. This will NOT call the
@@ -166,8 +172,13 @@ pub fn deleteSurface(self: *App, rt_surface: *apprt.Surface) void {
// If we have no surfaces, we can start the quit timer. It is up to the
// apprt to determine if this is necessary.
if (@hasDecl(apprt.App, "startQuitTimer") and
self.surfaces.items.len == 0) rt_surface.app.startQuitTimer();
if (self.surfaces.items.len == 0) rt_surface.app.performAction(
.app,
.quit_timer,
.start,
) catch |err| {
log.warn("error starting quit timer err={}", .{err});
};
}
/// The last focused surface. This is only valid while on the main thread
@@ -194,7 +205,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
log.debug("mailbox message={s}", .{@tagName(message)});
switch (message) {
.reload_config => try self.reloadConfig(rt_app),
.open_config => try self.openConfig(rt_app),
.open_config => try self.performAction(rt_app, .open_config),
.new_window => |msg| try self.newWindow(rt_app, msg),
.close => |surface| try self.closeSurface(surface),
.quit => try self.setQuit(),
@@ -205,12 +216,6 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void {
}
}
pub fn openConfig(self: *App, rt_app: *apprt.App) !void {
_ = self;
log.debug("opening configuration", .{});
try rt_app.openConfig();
}
pub fn reloadConfig(self: *App, rt_app: *apprt.App) !void {
log.debug("reloading configuration", .{});
if (try rt_app.reloadConfig()) |new| {
@@ -241,19 +246,17 @@ fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) !voi
/// Create a new window
pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void {
if (!@hasDecl(apprt.App, "newWindow")) {
log.warn("newWindow is not supported by this runtime", .{});
return;
}
const target: apprt.Target = target: {
const parent = msg.parent orelse break :target .app;
if (self.hasSurface(parent)) break :target .{ .surface = parent };
break :target .app;
};
const parent = if (msg.parent) |parent| parent: {
break :parent if (self.hasSurface(parent))
parent
else
null;
} else null;
try rt_app.newWindow(parent);
try rt_app.performAction(
target,
.new_window,
{},
);
}
/// Start quitting
@@ -262,6 +265,99 @@ pub fn setQuit(self: *App) !void {
self.quit = true;
}
/// Handle a key event at the app-scope. If this key event is used,
/// this will return true and the caller shouldn't continue processing
/// the event. If the event is not used, this will return false.
pub fn keyEvent(
self: *App,
rt_app: *apprt.App,
event: input.KeyEvent,
) bool {
switch (event.action) {
// We don't care about key release events.
.release => return false,
// Continue processing key press events.
.press, .repeat => {},
}
// Get the keybind entry for this event. We don't support key sequences
// so we can look directly in the top-level set.
const entry = rt_app.config.keybind.set.getEvent(event) orelse return false;
const leaf: input.Binding.Set.Leaf = switch (entry) {
// Sequences aren't supported. Our configuration parser verifies
// this for global keybinds but we may still get an entry for
// a non-global keybind.
.leader => return false,
// Leaf entries are good
.leaf => |leaf| leaf,
};
// We only care about global keybinds
if (!leaf.flags.global) return false;
// Perform the action
self.performAllAction(rt_app, leaf.action) catch |err| {
log.warn("error performing global keybind action action={s} err={}", .{
@tagName(leaf.action),
err,
});
};
return true;
}
/// Perform a binding action. This only accepts actions that are scoped
/// to the app. Callers can use performAllAction to perform any action
/// and any non-app-scoped actions will be performed on all surfaces.
pub fn performAction(
self: *App,
rt_app: *apprt.App,
action: input.Binding.Action.Scoped(.app),
) !void {
switch (action) {
.unbind => unreachable,
.ignore => {},
.quit => try self.setQuit(),
.new_window => try self.newWindow(rt_app, .{ .parent = null }),
.open_config => try rt_app.performAction(.app, .open_config, {}),
.reload_config => try self.reloadConfig(rt_app),
.close_all_windows => try rt_app.performAction(.app, .close_all_windows, {}),
.toggle_quick_terminal => try rt_app.performAction(.app, .toggle_quick_terminal, {}),
}
}
/// Perform an app-wide binding action. If the action is surface-specific
/// then it will be performed on all surfaces. To perform only app-scoped
/// actions, use performAction.
pub fn performAllAction(
self: *App,
rt_app: *apprt.App,
action: input.Binding.Action,
) !void {
switch (action.scope()) {
// App-scoped actions are handled by the app so that they aren't
// repeated for each surface (since each surface forwards
// app-scoped actions back up).
.app => try self.performAction(
rt_app,
action.scoped(.app).?, // asserted through the scope match
),
// Surface-scoped actions are performed on all surfaces. Errors
// are logged but processing continues.
.surface => for (self.surfaces.items) |surface| {
_ = surface.core_surface.performBindingAction(action) catch |err| {
log.warn("error performing binding action on surface ptr={X} err={}", .{
@intFromPtr(surface),
err,
});
};
},
}
}
/// Handle a window message
fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !void {
// We want to ensure our window is still active. Window messages

View File

@@ -515,14 +515,25 @@ pub fn init(
errdefer self.io.deinit();
// Report initial cell size on surface creation
try rt_surface.setCellSize(cell_size.width, cell_size.height);
try rt_app.performAction(
.{ .surface = self },
.cell_size,
.{ .width = cell_size.width, .height = cell_size.height },
);
// Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app
// but is otherwise somewhat arbitrary.
try rt_surface.setSizeLimits(.{
.width = cell_size.width * 10,
.height = cell_size.height * 4,
}, null);
try rt_app.performAction(
.{ .surface = self },
.size_limit,
.{
.min_width = cell_size.width * 10,
.min_height = cell_size.height * 4,
// No max:
.max_width = 0,
.max_height = 0,
},
);
// Call our size callback which handles all our retina setup
// Note: this shouldn't be necessary and when we clean up the surface
@@ -576,13 +587,23 @@ pub fn init(
padding.top +
padding.bottom;
rt_surface.setInitialWindowSize(final_width, final_height) catch |err| {
rt_app.performAction(
.{ .surface = self },
.initial_size,
.{ .width = final_width, .height = final_height },
) catch |err| {
// We don't treat this as a fatal error because not setting
// an initial size shouldn't stop our terminal from working.
log.warn("unable to set initial window size: {s}", .{err});
};
}
if (config.title) |title| {
try rt_surface.setTitle(title);
try rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = title },
);
} else if ((comptime builtin.os.tag == .linux) and
config.@"_xdg-terminal-exec")
xdg: {
@@ -599,7 +620,11 @@ pub fn init(
break :xdg;
};
defer alloc.free(title);
try rt_surface.setTitle(title);
try rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = title },
);
}
}
}
@@ -743,15 +768,15 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
// We know that our title should end in 0.
const slice = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(v)), 0);
log.debug("changing title \"{s}\"", .{slice});
try self.rt_surface.setTitle(slice);
try self.rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = slice },
);
},
.report_title => |style| {
const title: ?[:0]const u8 = title: {
if (!@hasDecl(apprt.runtime.Surface, "getTitle")) break :title null;
break :title self.rt_surface.getTitle();
};
const title: ?[:0]const u8 = self.rt_surface.getTitle();
const data = switch (style) {
.csi_21_t => try std.fmt.allocPrint(
self.alloc,
@@ -773,7 +798,11 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.set_mouse_shape => |shape| {
log.debug("changing mouse shape: {}", .{shape});
try self.rt_surface.setMouseShape(shape);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
shape,
);
},
.clipboard_read => |clipboard| {
@@ -837,6 +866,18 @@ fn passwordInput(self: *Surface, v: bool) !void {
self.io.terminal.flags.password_input = v;
}
// Notify our apprt so it can do whatever it wants.
self.rt_app.performAction(
.{ .surface = self },
.secure_input,
if (v) .on else .off,
) catch |err| {
// We ignore this error because we don't want to fail this
// entire operation just because the apprt failed to set
// the secure input state.
log.warn("apprt failed to set secure input state err={}", .{err});
};
try self.queueRender();
}
@@ -889,8 +930,13 @@ fn modsChanged(self: *Surface, mods: input.Mods) void {
/// Called when our renderer health state changes.
fn updateRendererHealth(self: *Surface, health: renderer.Health) void {
log.warn("renderer health status change status={}", .{health});
if (!@hasDecl(apprt.runtime.Surface, "updateRendererHealth")) return;
self.rt_surface.updateRendererHealth(health);
self.rt_app.performAction(
.{ .surface = self },
.renderer_health,
health,
) catch |err| {
log.warn("failed to notify app of renderer health change err={}", .{err});
};
}
/// Update our configuration at runtime.
@@ -1146,10 +1192,8 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void {
// Check if our runtime supports the selection clipboard at all.
// We can save a lot of work if it doesn't.
if (@hasDecl(apprt.runtime.Surface, "supportsClipboard")) {
if (!self.rt_surface.supportsClipboard(clipboard)) {
return;
}
if (!self.rt_surface.supportsClipboard(clipboard)) {
return;
}
const buf = self.io.terminal.screen.selectionString(self.alloc, .{
@@ -1189,7 +1233,11 @@ fn setCellSize(self: *Surface, size: renderer.CellSize) !void {
}, .unlocked);
// Notify the window
try self.rt_surface.setCellSize(size.width, size.height);
try self.rt_app.performAction(
.{ .surface = self },
.cell_size,
.{ .width = size.width, .height = size.height },
);
}
/// Change the font size.
@@ -1454,7 +1502,7 @@ pub fn keyCallback(
// mod changes can affect link highlighting.
self.mouse.link_point = null;
const pos = self.rt_surface.getCursorPos() catch break :mouse_mods;
self.cursorPosCallback(pos) catch {};
self.cursorPosCallback(pos, null) catch {};
if (rehide) self.mouse.hidden = true;
}
@@ -1467,8 +1515,11 @@ pub fn keyCallback(
.mods = self.mouse.mods,
.over_link = self.mouse.over_link,
.hidden = self.mouse.hidden,
}).keyToMouseShape()) |shape|
try self.rt_surface.setMouseShape(shape);
}).keyToMouseShape()) |shape| try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
shape,
);
// We've processed a key event that produced some data so we want to
// track the last pressed key.
@@ -1556,19 +1607,8 @@ fn maybeHandleBinding(
const entry: input.Binding.Set.Entry = entry: {
const set = self.keyboard.bindings orelse &self.config.keybind.set;
var trigger: input.Binding.Trigger = .{
.mods = event.mods.binding(),
.key = .{ .translated = event.key },
};
if (set.get(trigger)) |v| break :entry v;
trigger.key = .{ .physical = event.physical_key };
if (set.get(trigger)) |v| break :entry v;
if (event.unshifted_codepoint > 0) {
trigger.key = .{ .unicode = event.unshifted_codepoint };
if (set.get(trigger)) |v| break :entry v;
}
// Get our entry from the set for the given event.
if (set.getEvent(event)) |v| break :entry v;
// No entry found. If we're not looking at the root set of the
// bindings we need to encode everything up to this point and
@@ -1585,7 +1625,7 @@ fn maybeHandleBinding(
};
// Determine if this entry has an action or if its a leader key.
const action: input.Binding.Action, const consumed: bool = switch (entry) {
const leaf: input.Binding.Set.Leaf = switch (entry) {
.leader => |set| {
// Setup the next set we'll look at.
self.keyboard.bindings = set;
@@ -1600,8 +1640,20 @@ fn maybeHandleBinding(
return .consumed;
},
.action => |v| .{ v, true },
.action_unconsumed => |v| .{ v, false },
.leaf => |leaf| leaf,
};
const action = leaf.action;
// consumed determines if the input is consumed or if we continue
// encoding the key (if we have a key to encode).
const consumed = consumed: {
// If the consumed flag is explicitly set, then we are consumed.
if (leaf.flags.consumed) break :consumed true;
// If the global or all flag is set, we always consume.
if (leaf.flags.global or leaf.flags.all) break :consumed true;
break :consumed false;
};
// We have an action, so at this point we're handling SOMETHING so
@@ -1613,8 +1665,22 @@ fn maybeHandleBinding(
self.keyboard.bindings = null;
// Attempt to perform the action
log.debug("key event binding consumed={} action={}", .{ consumed, action });
const performed = try self.performBindingAction(action);
log.debug("key event binding flags={} action={}", .{
leaf.flags,
action,
});
const performed = performed: {
// If this is a global or all action, then we perform it on
// the app and it applies to every surface.
if (leaf.flags.global or leaf.flags.all) {
try self.app.performAllAction(self.rt_app, action);
// "All" actions are always performed since they are global.
break :performed true;
}
break :performed try self.performBindingAction(action);
};
// If we performed an action and it was a closing action,
// our "self" pointer is not safe to use anymore so we need to
@@ -2410,15 +2476,33 @@ pub fn mouseButtonCallback(
if (mods.shift and
self.mouse.left_click_count > 0 and
!shift_capture)
{
extend_selection: {
// We split this conditional out on its own because this is the
// only one that requires a renderer mutex grab which is VERY
// expensive because it could block all our threads.
if (self.hasSelection()) {
const pos = try self.rt_surface.getCursorPos();
try self.cursorPosCallback(pos);
return true;
if (!self.hasSelection()) break :extend_selection;
// If we are within the interval that the click would register
// an increment then we do not extend the selection.
if (std.time.Instant.now()) |now| {
const since = now.since(self.mouse.left_click_time);
if (since <= self.config.mouse_interval) {
// Click interval very short, we may be increasing
// click counts so we don't extend the selection.
break :extend_selection;
}
} else |err| {
// This is a weird behavior, I think either behavior is actually
// fine. This failure should be exceptionally rare anyways.
// My thinking here is that we can't be sure if we should extend
// the selection or not so we just don't.
log.warn("failed to get time, not extending selection err={}", .{err});
break :extend_selection;
}
const pos = try self.rt_surface.getCursorPos();
try self.cursorPosCallback(pos, null);
return true;
}
}
@@ -2882,9 +2966,18 @@ pub fn mousePressureCallback(
}
}
/// Cursor position callback.
///
/// The mods parameter is optional because some apprts do not provide
/// modifier information on cursor position events. If mods is null then
/// we'll use the last known mods. This is usually accurate since mod events
/// will trigger key press events but on some platforms we don't get them.
/// For example, on macOS, unfocused surfaces don't receive key events but
/// do receive mouse events so we have to rely on updated mods.
pub fn cursorPosCallback(
self: *Surface,
pos: apprt.CursorPos,
mods: ?input.Mods,
) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
@@ -2893,6 +2986,9 @@ pub fn cursorPosCallback(
// Always show the mouse again if it is hidden
if (self.mouse.hidden) self.showMouse();
// Update our modifiers if they changed
if (mods) |v| self.modsChanged(v);
// The mouse position in the viewport
const pos_vp = self.posToViewport(pos.x, pos.y);
@@ -2943,7 +3039,11 @@ pub fn cursorPosCallback(
// We also queue a render so the renderer can undo the rendered link
// state.
if (over_link) {
self.rt_surface.mouseOverLink(null);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = "" },
);
try self.queueRender();
}
@@ -3029,7 +3129,11 @@ pub fn cursorPosCallback(
self.renderer_state.mouse.point = pos_vp;
self.mouse.over_link = true;
self.renderer_state.terminal.screen.dirty.hyperlink_hover = true;
try self.rt_surface.setMouseShape(.pointer);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
.pointer,
);
switch (link[0]) {
.open => {
@@ -3038,7 +3142,11 @@ pub fn cursorPosCallback(
.trim = false,
});
defer self.alloc.free(str);
self.rt_surface.mouseOverLink(str);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = str },
);
},
._open_osc8 => link: {
@@ -3048,14 +3156,26 @@ pub fn cursorPosCallback(
log.warn("failed to get URI for OSC8 hyperlink", .{});
break :link;
};
self.rt_surface.mouseOverLink(uri);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = uri },
);
},
}
try self.queueRender();
} else if (over_link) {
try self.rt_surface.setMouseShape(self.io.terminal.mouse_shape);
self.rt_surface.mouseOverLink(null);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
self.io.terminal.mouse_shape,
);
try self.rt_app.performAction(
.{ .surface = self },
.mouse_over_link,
.{ .url = "" },
);
try self.queueRender();
}
}
@@ -3364,13 +3484,25 @@ fn scrollToBottom(self: *Surface) !void {
fn hideMouse(self: *Surface) void {
if (self.mouse.hidden) return;
self.mouse.hidden = true;
self.rt_surface.setMouseVisibility(false);
self.rt_app.performAction(
.{ .surface = self },
.mouse_visibility,
.hidden,
) catch |err| {
log.warn("apprt failed to set mouse visibility err={}", .{err});
};
}
fn showMouse(self: *Surface) void {
if (!self.mouse.hidden) return;
self.mouse.hidden = false;
self.rt_surface.setMouseVisibility(true);
self.rt_app.performAction(
.{ .surface = self },
.mouse_visibility,
.visible,
) catch |err| {
log.warn("apprt failed to set mouse visibility err={}", .{err});
};
}
/// Perform a binding action. A binding is a keybinding. This function
@@ -3384,14 +3516,25 @@ fn showMouse(self: *Surface) void {
/// will ever return false. We can expand this in the future if it becomes
/// useful. We did previous/next tab so we could implement #498.
pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {
switch (action) {
.unbind => unreachable,
.ignore => {},
// Forward app-scoped actions to the app. Some app-scoped actions are
// special-cased here because they do some special things when performed
// from the surface.
if (action.scoped(.app)) |app_action| {
switch (app_action) {
.new_window => try self.app.newWindow(
self.rt_app,
.{ .parent = self },
),
.open_config => try self.app.openConfig(self.rt_app),
.reload_config => try self.app.reloadConfig(self.rt_app),
else => try self.app.performAction(
self.rt_app,
action.scoped(.app).?,
),
}
return true;
}
switch (action.scoped(.surface).?) {
.csi, .esc => |data| {
// We need to send the CSI/ESC sequence as a single write request.
// If you split it across two then the shell can interpret it
@@ -3613,109 +3756,105 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
v,
),
.new_window => try self.app.newWindow(self.rt_app, .{ .parent = self }),
.new_tab => try self.rt_app.performAction(
.{ .surface = self },
.new_tab,
{},
),
.new_tab => {
if (@hasDecl(apprt.Surface, "newTab")) {
try self.rt_surface.newTab();
} else log.warn("runtime doesn't implement newTab", .{});
},
inline .previous_tab,
.next_tab,
.last_tab,
.goto_tab,
=> |v, tag| try self.rt_app.performAction(
.{ .surface = self },
.goto_tab,
switch (tag) {
.previous_tab => .previous,
.next_tab => .next,
.last_tab => .last,
.goto_tab => @enumFromInt(v),
else => comptime unreachable,
},
),
.previous_tab => {
if (@hasDecl(apprt.Surface, "hasTabs")) {
if (!self.rt_surface.hasTabs()) {
log.debug("surface has no tabs, ignoring previous_tab binding", .{});
return false;
}
}
.new_split => |direction| try self.rt_app.performAction(
.{ .surface = self },
.new_split,
switch (direction) {
.right => .right,
.down => .down,
.auto => if (self.screen_size.width > self.screen_size.height)
.right
else
.down,
},
),
if (@hasDecl(apprt.Surface, "gotoTab")) {
self.rt_surface.gotoTab(.previous);
} else log.warn("runtime doesn't implement gotoTab", .{});
},
.goto_split => |direction| try self.rt_app.performAction(
.{ .surface = self },
.goto_split,
switch (direction) {
inline else => |tag| @field(
apprt.action.GotoSplit,
@tagName(tag),
),
},
),
.next_tab => {
if (@hasDecl(apprt.Surface, "hasTabs")) {
if (!self.rt_surface.hasTabs()) {
log.debug("surface has no tabs, ignoring next_tab binding", .{});
return false;
}
}
.resize_split => |value| try self.rt_app.performAction(
.{ .surface = self },
.resize_split,
.{
.amount = value[1],
.direction = switch (value[0]) {
inline else => |tag| @field(
apprt.action.ResizeSplit.Direction,
@tagName(tag),
),
},
},
),
if (@hasDecl(apprt.Surface, "gotoTab")) {
self.rt_surface.gotoTab(.next);
} else log.warn("runtime doesn't implement gotoTab", .{});
},
.equalize_splits => try self.rt_app.performAction(
.{ .surface = self },
.equalize_splits,
{},
),
.last_tab => {
if (@hasDecl(apprt.Surface, "hasTabs")) {
if (!self.rt_surface.hasTabs()) {
log.debug("surface has no tabs, ignoring last_tab binding", .{});
return false;
}
}
.toggle_split_zoom => try self.rt_app.performAction(
.{ .surface = self },
.toggle_split_zoom,
{},
),
if (@hasDecl(apprt.Surface, "gotoTab")) {
self.rt_surface.gotoTab(.last);
} else log.warn("runtime doesn't implement gotoTab", .{});
},
.toggle_fullscreen => try self.rt_app.performAction(
.{ .surface = self },
.toggle_fullscreen,
switch (self.config.macos_non_native_fullscreen) {
.false => .native,
.true => .macos_non_native,
.@"visible-menu" => .macos_non_native_visible_menu,
},
),
.goto_tab => |n| {
if (@hasDecl(apprt.Surface, "gotoTab")) {
self.rt_surface.gotoTab(@enumFromInt(n));
} else log.warn("runtime doesn't implement gotoTab", .{});
},
.toggle_window_decorations => try self.rt_app.performAction(
.{ .surface = self },
.toggle_window_decorations,
{},
),
.new_split => |direction| {
if (@hasDecl(apprt.Surface, "newSplit")) {
try self.rt_surface.newSplit(switch (direction) {
.right => .right,
.down => .down,
.auto => if (self.screen_size.width > self.screen_size.height)
.right
else
.down,
});
} else log.warn("runtime doesn't implement newSplit", .{});
},
.toggle_tab_overview => try self.rt_app.performAction(
.{ .surface = self },
.toggle_tab_overview,
{},
),
.goto_split => |direction| {
if (@hasDecl(apprt.Surface, "gotoSplit")) {
self.rt_surface.gotoSplit(direction);
} else log.warn("runtime doesn't implement gotoSplit", .{});
},
.resize_split => |param| {
if (@hasDecl(apprt.Surface, "resizeSplit")) {
const direction = param[0];
const amount = param[1];
self.rt_surface.resizeSplit(direction, amount);
} else log.warn("runtime doesn't implement resizeSplit", .{});
},
.equalize_splits => {
if (@hasDecl(apprt.Surface, "equalizeSplits")) {
self.rt_surface.equalizeSplits();
} else log.warn("runtime doesn't implement equalizeSplits", .{});
},
.toggle_split_zoom => {
if (@hasDecl(apprt.Surface, "toggleSplitZoom")) {
self.rt_surface.toggleSplitZoom();
} else log.warn("runtime doesn't implement toggleSplitZoom", .{});
},
.toggle_fullscreen => {
if (@hasDecl(apprt.Surface, "toggleFullscreen")) {
self.rt_surface.toggleFullscreen(self.config.macos_non_native_fullscreen);
} else log.warn("runtime doesn't implement toggleFullscreen", .{});
},
.toggle_window_decorations => {
if (@hasDecl(apprt.Surface, "toggleWindowDecorations")) {
self.rt_surface.toggleWindowDecorations();
} else log.warn("runtime doesn't implement toggleWindowDecorations", .{});
},
.toggle_secure_input => try self.rt_app.performAction(
.{ .surface = self },
.secure_input,
.toggle,
),
.select_all => {
const sel = self.io.terminal.screen.selectAll();
@@ -3725,24 +3864,21 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
}
},
.inspector => |mode| {
if (@hasDecl(apprt.Surface, "controlInspector")) {
self.rt_surface.controlInspector(mode);
} else log.warn("runtime doesn't implement controlInspector", .{});
},
.inspector => |mode| try self.rt_app.performAction(
.{ .surface = self },
.inspector,
switch (mode) {
inline else => |tag| @field(
apprt.action.Inspector,
@tagName(tag),
),
},
),
.close_surface => self.close(),
.close_window => try self.app.closeSurface(self),
.close_all_windows => {
if (@hasDecl(apprt.Surface, "closeAllWindows")) {
self.rt_surface.closeAllWindows();
} else log.warn("runtime doesn't implement closeAllWindows", .{});
},
.quit => try self.app.setQuit(),
.crash => |location| switch (location) {
.main => @panic("crash binding action, crashing intentionally"),
@@ -4124,11 +4260,6 @@ fn completeClipboardReadOSC52(
}
fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void {
if (comptime !@hasDecl(apprt.Surface, "showDesktopNotification")) {
log.warn("runtime doesn't support desktop notifications", .{});
return;
}
// Wyhash is used to hash the contents of the desktop notification to limit
// how fast identical notifications can be sent sequentially.
const hash_algorithm = std.hash.Wyhash;
@@ -4164,7 +4295,14 @@ fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const
self.app.last_notification_time = now;
self.app.last_notification_digest = new_digest;
try self.rt_surface.showDesktopNotification(title, body);
try self.rt_app.performAction(
.{ .surface = self },
.desktop_notification,
.{
.title = title,
.body = body,
},
);
}
fn crashThreadState(self: *Surface) crash.sentry.ThreadState {
@@ -4177,9 +4315,11 @@ fn crashThreadState(self: *Surface) crash.sentry.ThreadState {
/// Tell the surface to present itself to the user. This may involve raising the
/// window and switching tabs.
fn presentSurface(self: *Surface) !void {
if (@hasDecl(apprt.Surface, "presentSurface")) {
self.rt_surface.presentSurface();
} else log.warn("runtime doesn't support presentSurface", .{});
try self.rt_app.performAction(
.{ .surface = self },
.present_terminal,
{},
);
}
pub const face_ttf = @embedFile("font/res/JetBrainsMono-Regular.ttf");

View File

@@ -14,6 +14,7 @@ const build_config = @import("build_config.zig");
const structs = @import("apprt/structs.zig");
pub const action = @import("apprt/action.zig");
pub const glfw = @import("apprt/glfw.zig");
pub const gtk = @import("apprt/gtk.zig");
pub const none = @import("apprt/none.zig");
@@ -21,17 +22,17 @@ pub const browser = @import("apprt/browser.zig");
pub const embedded = @import("apprt/embedded.zig");
pub const surface = @import("apprt/surface.zig");
pub const Action = action.Action;
pub const Target = action.Target;
pub const ContentScale = structs.ContentScale;
pub const Clipboard = structs.Clipboard;
pub const ClipboardRequest = structs.ClipboardRequest;
pub const ClipboardRequestType = structs.ClipboardRequestType;
pub const ColorScheme = structs.ColorScheme;
pub const CursorPos = structs.CursorPos;
pub const DesktopNotification = structs.DesktopNotification;
pub const GotoTab = structs.GotoTab;
pub const IMEPos = structs.IMEPos;
pub const Selection = structs.Selection;
pub const SplitDirection = structs.SplitDirection;
pub const SurfaceSize = structs.SurfaceSize;
/// The implementation to use for the app runtime. This is comptime chosen
@@ -84,4 +85,6 @@ pub const Runtime = enum {
test {
_ = Runtime;
_ = runtime;
_ = action;
_ = structs;
}

407
src/apprt/action.zig Normal file
View File

@@ -0,0 +1,407 @@
const std = @import("std");
const assert = std.debug.assert;
const apprt = @import("../apprt.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
const CoreSurface = @import("../Surface.zig");
/// The target for an action. This is generally the thing that had focus
/// while the action was made but the concept of "focus" is not guaranteed
/// since actions can also be triggered by timers, scripts, etc.
pub const Target = union(Key) {
app,
surface: *CoreSurface,
// Sync with: ghostty_target_tag_e
pub const Key = enum(c_int) {
app,
surface,
};
// Sync with: ghostty_target_u
pub const CValue = extern union {
app: void,
surface: *apprt.Surface,
};
// Sync with: ghostty_target_s
pub const C = extern struct {
key: Key,
value: CValue,
};
/// Convert to ghostty_target_s.
pub fn cval(self: Target) C {
return .{
.key = @as(Key, self),
.value = switch (self) {
.app => .{ .app = {} },
.surface => |v| .{ .surface = v.rt_surface },
},
};
}
};
/// The possible actions an apprt has to react to. Actions are one-way
/// messages that are sent to the app runtime to trigger some behavior.
///
/// Actions are very often key binding actions but can also be triggered
/// by lifecycle events. For example, the `quit_timer` action is not bindable.
///
/// Importantly, actions are generally OPTIONAL to implement by an apprt.
/// Required functionality is called directly on the runtime structure so
/// there is a compiler error if an action is not implemented.
pub const Action = union(Key) {
// A GUIDE TO ADDING NEW ACTIONS:
//
// 1. Add the action to the `Key` enum. The order of the enum matters
// because it maps directly to the libghostty C enum. For ABI
// compatibility, new actions should be added to the end of the enum.
//
// 2. Add the action and optional value to the Action union.
//
// 3. If the value type is not void, ensure the value is C ABI
// compatible (extern). If it is not, add a `C` decl to the value
// and a `cval` function to convert to the C ABI compatible value.
//
// 4. Update `include/ghostty.h`: add the new key, value, and union
// entry. If the value type is void then only the key needs to be
// added. Ensure the order matches exactly with the Zig code.
/// Open a new window. The target determines whether properties such
/// as font size should be inherited.
new_window,
/// Open a new tab. If the target is a surface it should be opened in
/// the same window as the surface. If the target is the app then
/// the tab should be opened in a new window.
new_tab,
/// Create a new split. The value determines the location of the split
/// relative to the target.
new_split: SplitDirection,
/// Close all open windows.
close_all_windows,
/// Toggle fullscreen mode.
toggle_fullscreen: Fullscreen,
/// Toggle tab overview.
toggle_tab_overview,
/// Toggle whether window directions are shown.
toggle_window_decorations,
/// Toggle the quick terminal in or out.
toggle_quick_terminal,
/// Jump to a specific tab. Must handle the scenario that the tab
/// value is invalid.
goto_tab: GotoTab,
/// Jump to a specific split.
goto_split: GotoSplit,
/// Resize the split in the given direction.
resize_split: ResizeSplit,
/// Equalize all the splits in the target window.
equalize_splits,
/// Toggle whether a split is zoomed or not. A zoomed split is resized
/// to take up the entire window.
toggle_split_zoom,
/// Present the target terminal whether its a tab, split, or window.
present_terminal,
/// Sets a size limit (in pixels) for the target terminal.
size_limit: SizeLimit,
/// Specifies the initial size of the target terminal. This will be
/// sent only during the initialization of a surface. If it is received
/// after the surface is initialized it should be ignored.
initial_size: InitialSize,
/// The cell size has changed to the given dimensions in pixels.
cell_size: CellSize,
/// Control whether the inspector is shown or hidden.
inspector: Inspector,
/// The inspector for the given target has changes and should be
/// rendered at the next opportunity.
render_inspector,
/// Show a desktop notification.
desktop_notification: DesktopNotification,
/// Set the title of the target.
set_title: SetTitle,
/// Set the mouse cursor shape.
mouse_shape: terminal.MouseShape,
/// Set whether the mouse cursor is visible or not.
mouse_visibility: MouseVisibility,
/// Called when the mouse is over or recently left a link.
mouse_over_link: MouseOverLink,
/// The health of the renderer has changed.
renderer_health: renderer.Health,
/// Open the Ghostty configuration. This is platform-specific about
/// what it means; it can mean opening a dedicated UI or just opening
/// a file in a text editor.
open_config,
/// Called when there are no more surfaces and the app should quit
/// after the configured delay. This can be cancelled by sending
/// another quit_timer action with "stop". Multiple "starts" shouldn't
/// happen and can be ignored or cause a restart it isn't that important.
quit_timer: QuitTimer,
/// Set the secure input functionality on or off. "Secure input" means
/// that the user is currently at some sort of prompt where they may be
/// entering a password or other sensitive information. This can be used
/// by the app runtime to change the appearance of the cursor, setup
/// system APIs to not log the input, etc.
secure_input: SecureInput,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
new_window,
new_tab,
new_split,
close_all_windows,
toggle_fullscreen,
toggle_tab_overview,
toggle_window_decorations,
toggle_quick_terminal,
goto_tab,
goto_split,
resize_split,
equalize_splits,
toggle_split_zoom,
present_terminal,
size_limit,
initial_size,
cell_size,
inspector,
render_inspector,
desktop_notification,
set_title,
mouse_shape,
mouse_visibility,
mouse_over_link,
renderer_health,
open_config,
quit_timer,
secure_input,
};
/// Sync with: ghostty_action_u
pub const CValue = cvalue: {
const key_fields = @typeInfo(Key).Enum.fields;
var union_fields: [key_fields.len]std.builtin.Type.UnionField = undefined;
for (key_fields, 0..) |field, i| {
const action = @unionInit(Action, field.name, undefined);
const Type = t: {
const Type = @TypeOf(@field(action, field.name));
// Types can provide custom types for their CValue.
if (Type != void and @hasDecl(Type, "C")) break :t Type.C;
break :t Type;
};
union_fields[i] = .{
.name = field.name,
.type = Type,
.alignment = @alignOf(Type),
};
}
break :cvalue @Type(.{ .Union = .{
.layout = .@"extern",
.tag_type = Key,
.fields = &union_fields,
.decls = &.{},
} });
};
/// Sync with: ghostty_action_s
pub const C = extern struct {
key: Key,
value: CValue,
};
/// Returns the value type for the given key.
pub fn Value(comptime key: Key) type {
inline for (@typeInfo(Action).Union.fields) |field| {
const field_key = @field(Key, field.name);
if (field_key == key) return field.type;
}
unreachable;
}
/// Convert to ghostty_action_s.
pub fn cval(self: Action) C {
const value: CValue = switch (self) {
inline else => |v, tag| @unionInit(
CValue,
@tagName(tag),
if (@TypeOf(v) != void and @hasDecl(@TypeOf(v), "cval")) v.cval() else v,
),
};
return .{
.key = @as(Key, self),
.value = value,
};
}
};
// This is made extern (c_int) to make interop easier with our embedded
// runtime. The small size cost doesn't make a difference in our union.
pub const SplitDirection = enum(c_int) {
right,
down,
};
// This is made extern (c_int) to make interop easier with our embedded
// runtime. The small size cost doesn't make a difference in our union.
pub const GotoSplit = enum(c_int) {
previous,
next,
top,
left,
bottom,
right,
};
/// The amount to resize the split by and the direction to resize it in.
pub const ResizeSplit = extern struct {
amount: u16,
direction: Direction,
pub const Direction = enum(c_int) {
up,
down,
left,
right,
};
};
/// The tab to jump to. This is non-exhaustive so that integer values represent
/// the index (zero-based) of the tab to jump to. Negative values are special
/// values.
pub const GotoTab = enum(c_int) {
previous = -1,
next = -2,
last = -3,
_,
};
/// The fullscreen mode to toggle to if we're moving to fullscreen.
pub const Fullscreen = enum(c_int) {
native,
/// macOS has a non-native fullscreen mode that is more like a maximized
/// window. This is much faster to enter and exit than the native mode.
macos_non_native,
macos_non_native_visible_menu,
};
pub const SecureInput = enum(c_int) {
on,
off,
toggle,
};
/// The inspector mode to toggle to if we're toggling the inspector.
pub const Inspector = enum(c_int) {
toggle,
show,
hide,
};
pub const QuitTimer = enum(c_int) {
start,
stop,
};
pub const MouseVisibility = enum(c_int) {
visible,
hidden,
};
pub const MouseOverLink = struct {
url: []const u8,
// Sync with: ghostty_action_mouse_over_link_s
pub const C = extern struct {
url: [*]const u8,
len: usize,
};
pub fn cval(self: MouseOverLink) C {
return .{
.url = self.url.ptr,
.len = self.url.len,
};
}
};
pub const SizeLimit = extern struct {
min_width: u32,
min_height: u32,
max_width: u32,
max_height: u32,
};
pub const InitialSize = extern struct {
width: u32,
height: u32,
};
pub const CellSize = extern struct {
width: u32,
height: u32,
};
pub const SetTitle = struct {
title: [:0]const u8,
// Sync with: ghostty_action_set_title_s
pub const C = extern struct {
title: [*:0]const u8,
};
pub fn cval(self: SetTitle) C {
return .{
.title = self.title.ptr,
};
}
};
/// The desktop notification to show.
pub const DesktopNotification = struct {
title: [:0]const u8,
body: [:0]const u8,
// Sync with: ghostty_action_desktop_notification_s
pub const C = extern struct {
title: [*:0]const u8,
body: [*:0]const u8,
};
pub fn cval(self: DesktopNotification) C {
return .{
.title = self.title.ptr,
.body = self.body.ptr,
};
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -127,9 +127,87 @@ pub const App = struct {
glfw.postEmptyEvent();
}
/// Open the configuration in the system editor.
pub fn openConfig(self: *App) !void {
try configpkg.edit.open(self.app.alloc);
/// Perform a given action.
pub fn performAction(
self: *App,
target: apprt.Target,
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
) !void {
switch (action) {
.new_window => _ = try self.newSurface(switch (target) {
.app => null,
.surface => |v| v,
}),
.new_tab => try self.newTab(switch (target) {
.app => null,
.surface => |v| v,
}),
.size_limit => switch (target) {
.app => {},
.surface => |surface| try surface.rt_surface.setSizeLimits(.{
.width = value.min_width,
.height = value.min_height,
}, if (value.max_width > 0) .{
.width = value.max_width,
.height = value.max_height,
} else null),
},
.initial_size => switch (target) {
.app => {},
.surface => |surface| try surface.rt_surface.setInitialWindowSize(
value.width,
value.height,
),
},
.toggle_fullscreen => self.toggleFullscreen(target),
.open_config => try configpkg.edit.open(self.app.alloc),
.set_title => switch (target) {
.app => {},
.surface => |surface| try surface.rt_surface.setTitle(value.title),
},
.mouse_shape => switch (target) {
.app => {},
.surface => |surface| try surface.rt_surface.setMouseShape(value),
},
.mouse_visibility => switch (target) {
.app => {},
.surface => |surface| surface.rt_surface.setMouseVisibility(switch (value) {
.visible => true,
.hidden => false,
}),
},
// Unimplemented
.new_split,
.goto_split,
.resize_split,
.equalize_splits,
.toggle_split_zoom,
.present_terminal,
.close_all_windows,
.toggle_tab_overview,
.toggle_window_decorations,
.toggle_quick_terminal,
.goto_tab,
.inspector,
.render_inspector,
.quit_timer,
.secure_input,
.desktop_notification,
.mouse_over_link,
.cell_size,
.renderer_health,
=> log.info("unimplemented action={}", .{action}),
}
}
/// Reload the configuration. This should return the new configuration.
@@ -150,8 +228,12 @@ pub const App = struct {
}
/// Toggle the window to fullscreen mode.
pub fn toggleFullscreen(self: *App, surface: *Surface) void {
fn toggleFullscreen(self: *App, target: apprt.Target) void {
_ = self;
const surface: *Surface = switch (target) {
.app => return,
.surface => |v| v.rt_surface,
};
const win = surface.window;
if (surface.isFullscreen()) {
@@ -195,18 +277,18 @@ pub const App = struct {
win.setMonitor(monitor, 0, 0, video_mode.getWidth(), video_mode.getHeight(), 0);
}
/// Create a new window for the app.
pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
_ = try self.newSurface(parent_);
}
/// Create a new tab in the parent surface.
fn newTab(self: *App, parent: *CoreSurface) !void {
fn newTab(self: *App, parent_: ?*CoreSurface) !void {
if (!Darwin.enabled) {
log.warn("tabbing is not supported on this platform", .{});
return;
}
const parent = parent_ orelse {
_ = try self.newSurface(null);
return;
};
// Create the new window
const window = try self.newSurface(parent);
@@ -370,7 +452,6 @@ pub const Surface = struct {
/// Initialize the surface into the given self pointer. This gives a
/// stable pointer to the destination that can be used for callbacks.
pub fn init(self: *Surface, app: *App) !void {
// Create our window
const win = glfw.Window.create(
640,
@@ -525,20 +606,11 @@ pub const Surface = struct {
}
}
/// Create a new tab in the window containing this surface.
pub fn newTab(self: *Surface) !void {
try self.app.newTab(&self.core_surface);
}
/// Checks if the glfw window is in fullscreen.
pub fn isFullscreen(self: *Surface) bool {
return self.window.getMonitor() != null;
}
pub fn toggleFullscreen(self: *Surface, _: Config.NonNativeFullscreen) void {
self.app.toggleFullscreen(self);
}
/// Close this surface.
pub fn close(self: *Surface, processActive: bool) void {
_ = processActive;
@@ -550,7 +622,7 @@ pub const Surface = struct {
/// Set the initial window size. This is called exactly once at
/// surface initialization time. This may be called before "self"
/// is fully initialized.
pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void {
fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void {
const monitor = self.window.getMonitor() orelse glfw.Monitor.getPrimary() orelse {
log.warn("window is not on a monitor, not setting initial size", .{});
return;
@@ -563,18 +635,11 @@ pub const Surface = struct {
});
}
/// Set the cell size. Unused by GLFW.
pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void {
_ = self;
_ = width;
_ = height;
}
/// Set the size limits of the window.
/// Note: this interface is not good, we should redo it if we plan
/// to use this more. i.e. you can't set max width but no max height,
/// or no mins.
pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void {
fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void {
self.window.setSizeLimits(.{
.width = min.width,
.height = min.height,
@@ -624,7 +689,7 @@ pub const Surface = struct {
}
/// Set the title of the window.
pub fn setTitle(self: *Surface, slice: [:0]const u8) !void {
fn setTitle(self: *Surface, slice: [:0]const u8) !void {
if (self.title_text) |t| self.core_surface.alloc.free(t);
self.title_text = try self.core_surface.alloc.dupeZ(u8, slice);
self.window.setTitle(self.title_text.?.ptr);
@@ -636,7 +701,7 @@ pub const Surface = struct {
}
/// Set the shape of the cursor.
pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void {
fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void {
if ((comptime builtin.target.isDarwin()) and
!internal_os.macosVersionAtLeast(13, 0, 0))
{
@@ -672,15 +737,20 @@ pub const Surface = struct {
self.cursor = new;
}
pub fn mouseOverLink(self: *Surface, uri: ?[]const u8) void {
// We don't do anything in GLFW.
_ = self;
_ = uri;
/// Set the visibility of the mouse cursor.
fn setMouseVisibility(self: *Surface, visible: bool) void {
self.window.setInputModeCursor(if (visible) .normal else .hidden);
}
/// Set the visibility of the mouse cursor.
pub fn setMouseVisibility(self: *Surface, visible: bool) void {
self.window.setInputModeCursor(if (visible) .normal else .hidden);
pub fn supportsClipboard(
self: *const Surface,
clipboard_type: apprt.Clipboard,
) bool {
_ = self;
return switch (clipboard_type) {
.standard => true,
.selection, .primary => comptime builtin.os.tag == .linux,
};
}
/// Start an async clipboard request.
@@ -1040,7 +1110,7 @@ pub const Surface = struct {
core_win.cursorPosCallback(.{
.x = @floatCast(pos.xpos),
.y = @floatCast(pos.ypos),
}) catch |err| {
}, null) catch |err| {
log.err("error in cursor pos callback err={}", .{err});
return;
};

View File

@@ -17,6 +17,7 @@ const apprt = @import("../../apprt.zig");
const configpkg = @import("../../config.zig");
const input = @import("../../input.zig");
const internal_os = @import("../../os/main.zig");
const terminal = @import("../../terminal/main.zig");
const Config = configpkg.Config;
const CoreApp = @import("../../App.zig");
const CoreSurface = @import("../../Surface.zig");
@@ -27,6 +28,7 @@ const Surface = @import("Surface.zig");
const Window = @import("Window.zig");
const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig");
const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig");
const Split = @import("Split.zig");
const c = @import("c.zig").c;
const version = @import("version.zig");
const inspector = @import("inspector.zig");
@@ -86,6 +88,16 @@ quit_timer: union(enum) {
pub fn init(core_app: *CoreApp, opts: Options) !App {
_ = opts;
// Log our GTK version
log.info("GTK version build={d}.{d}.{d} runtime={d}.{d}.{d}", .{
c.GTK_MAJOR_VERSION,
c.GTK_MINOR_VERSION,
c.GTK_MICRO_VERSION,
c.gtk_get_major_version(),
c.gtk_get_minor_version(),
c.gtk_get_micro_version(),
});
if (version.atLeast(4, 16, 0)) {
// From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE
_ = internal_os.setenv("GDK_DISABLE", "gles-api");
@@ -340,9 +352,340 @@ pub fn terminate(self: *App) void {
self.config.deinit();
}
/// Open the configuration in the system editor.
pub fn openConfig(self: *App) !void {
try configpkg.edit.open(self.core_app.alloc);
/// Perform a given action.
pub fn performAction(
self: *App,
target: apprt.Target,
comptime action: apprt.Action.Key,
value: apprt.Action.Value(action),
) !void {
switch (action) {
.new_window => _ = try self.newWindow(switch (target) {
.app => null,
.surface => |v| v,
}),
.toggle_fullscreen => self.toggleFullscreen(target, value),
.new_tab => try self.newTab(target),
.goto_tab => self.gotoTab(target, value),
.new_split => try self.newSplit(target, value),
.resize_split => self.resizeSplit(target, value),
.equalize_splits => self.equalizeSplits(target),
.goto_split => self.gotoSplit(target, value),
.open_config => try configpkg.edit.open(self.core_app.alloc),
.inspector => self.controlInspector(target, value),
.desktop_notification => self.showDesktopNotification(target, value),
.set_title => try self.setTitle(target, value),
.present_terminal => self.presentTerminal(target),
.initial_size => try self.setInitialSize(target, value),
.mouse_visibility => self.setMouseVisibility(target, value),
.mouse_shape => try self.setMouseShape(target, value),
.mouse_over_link => self.setMouseOverLink(target, value),
.toggle_tab_overview => self.toggleTabOverview(target),
.toggle_window_decorations => self.toggleWindowDecorations(target),
.quit_timer => self.quitTimer(value),
// Unimplemented
.close_all_windows,
.toggle_split_zoom,
.toggle_quick_terminal,
.size_limit,
.cell_size,
.secure_input,
.render_inspector,
.renderer_health,
=> log.warn("unimplemented action={}", .{action}),
}
}
fn newTab(_: *App, target: apprt.Target) !void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"new_tab invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
try window.newTab(v);
},
}
}
fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"gotoTab invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
switch (tab) {
.previous => window.gotoPreviousTab(v.rt_surface),
.next => window.gotoNextTab(v.rt_surface),
.last => window.gotoLastTab(),
else => window.gotoTab(@intCast(@intFromEnum(tab))),
}
},
}
}
fn newSplit(
self: *App,
target: apprt.Target,
direction: apprt.action.SplitDirection,
) !void {
switch (target) {
.app => {},
.surface => |v| {
const alloc = self.core_app.alloc;
_ = try Split.create(alloc, v.rt_surface, direction);
},
}
}
fn equalizeSplits(_: *App, target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| {
const tab = v.rt_surface.container.tab() orelse return;
const top_split = switch (tab.elem) {
.split => |s| s,
else => return,
};
_ = top_split.equalize();
},
}
}
fn gotoSplit(
_: *const App,
target: apprt.Target,
direction: apprt.action.GotoSplit,
) void {
switch (target) {
.app => {},
.surface => |v| {
const s = v.rt_surface.container.split() orelse return;
const map = s.directionMap(switch (v.rt_surface.container) {
.split_tl => .top_left,
.split_br => .bottom_right,
.none, .tab_ => unreachable,
});
const surface_ = map.get(direction) orelse return;
if (surface_) |surface| surface.grabFocus();
},
}
}
fn resizeSplit(
_: *const App,
target: apprt.Target,
resize: apprt.action.ResizeSplit,
) void {
switch (target) {
.app => {},
.surface => |v| {
const s = v.rt_surface.container.firstSplitWithOrientation(
Split.Orientation.fromResizeDirection(resize.direction),
) orelse return;
s.moveDivider(resize.direction, resize.amount);
},
}
}
fn presentTerminal(
_: *const App,
target: apprt.Target,
) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.present(),
}
}
fn controlInspector(
_: *const App,
target: apprt.Target,
mode: apprt.action.Inspector,
) void {
const surface: *Surface = switch (target) {
.app => return,
.surface => |v| v.rt_surface,
};
surface.controlInspector(mode);
}
fn toggleFullscreen(
_: *App,
target: apprt.Target,
_: apprt.action.Fullscreen,
) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleFullscreen invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
window.toggleFullscreen();
},
}
}
fn toggleTabOverview(_: *App, target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleTabOverview invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
window.toggleTabOverview();
},
}
}
fn toggleWindowDecorations(
_: *App,
target: apprt.Target,
) void {
switch (target) {
.app => {},
.surface => |v| {
const window = v.rt_surface.container.window() orelse {
log.info(
"toggleFullscreen invalid for container={s}",
.{@tagName(v.rt_surface.container)},
);
return;
};
window.toggleWindowDecorations();
},
}
}
fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void {
switch (mode) {
.start => self.startQuitTimer(),
.stop => self.stopQuitTimer(),
}
}
fn setTitle(
_: *App,
target: apprt.Target,
title: apprt.action.SetTitle,
) !void {
switch (target) {
.app => {},
.surface => |v| try v.rt_surface.setTitle(title.title),
}
}
fn setMouseVisibility(
_: *App,
target: apprt.Target,
visibility: apprt.action.MouseVisibility,
) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.setMouseVisibility(switch (visibility) {
.visible => true,
.hidden => false,
}),
}
}
fn setMouseShape(
_: *App,
target: apprt.Target,
shape: terminal.MouseShape,
) !void {
switch (target) {
.app => {},
.surface => |v| try v.rt_surface.setMouseShape(shape),
}
}
fn setMouseOverLink(
_: *App,
target: apprt.Target,
value: apprt.action.MouseOverLink,
) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.mouseOverLink(if (value.url.len > 0)
value.url
else
null),
}
}
fn setInitialSize(
_: *App,
target: apprt.Target,
value: apprt.action.InitialSize,
) !void {
switch (target) {
.app => {},
.surface => |v| try v.rt_surface.setInitialWindowSize(
value.width,
value.height,
),
}
}
fn showDesktopNotification(
self: *App,
target: apprt.Target,
n: apprt.action.DesktopNotification,
) void {
// Set a default title if we don't already have one
const t = switch (n.title.len) {
0 => "Ghostty",
else => n.title,
};
const notification = c.g_notification_new(t.ptr);
defer c.g_object_unref(notification);
c.g_notification_set_body(notification, n.body.ptr);
const icon = c.g_themed_icon_new("com.mitchellh.ghostty");
defer c.g_object_unref(icon);
c.g_notification_set_icon(notification, icon);
const pointer = c.g_variant_new_uint64(switch (target) {
.app => 0,
.surface => |v| @intFromPtr(v),
});
c.g_notification_set_default_action_and_target_value(
notification,
"app.present-surface",
pointer,
);
const g_app: *c.GApplication = @ptrCast(self.app);
// We set the notification ID to the body content. If the content is the
// same, this notification may replace a previous notification
c.g_application_send_notification(g_app, n.body.ptr, notification);
}
/// Reload the configuration. This should return the new configuration.
@@ -442,9 +785,9 @@ fn loadRuntimeCss(config: *const Config, provider: *c.GtkCssProvider) !void {
\\ opacity: {d:.2};
\\ background-color: rgb({d},{d},{d});
\\}}
\\window.ghostty-theme-inherit headerbar,
\\window.ghostty-theme-inherit toolbarview > revealer > windowhandle,
\\window.ghostty-theme-inherit box > tabbar {{
\\window.window-theme-ghostty .top-bar,
\\window.window-theme-ghostty .bottom-bar,
\\window.window-theme-ghostty box > tabbar {{
\\ background-color: rgb({d},{d},{d});
\\ color: rgb({d},{d},{d});
\\}}
@@ -564,9 +907,9 @@ pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean {
}
/// This will get called when there are no more open surfaces.
pub fn startQuitTimer(self: *App) void {
fn startQuitTimer(self: *App) void {
// Cancel any previous timer.
self.cancelQuitTimer();
self.stopQuitTimer();
// This is a no-op unless we are configured to quit after last window is closed.
if (!self.config.@"quit-after-last-window-closed") return;
@@ -581,7 +924,7 @@ pub fn startQuitTimer(self: *App) void {
}
/// This will get called when a new surface gets opened.
pub fn cancelQuitTimer(self: *App) void {
fn stopQuitTimer(self: *App) void {
switch (self.quit_timer) {
.off => {},
.expired => self.quit_timer = .{ .off = {} },
@@ -607,7 +950,7 @@ pub fn redrawInspector(self: *App, surface: *Surface) void {
}
/// Called by CoreApp to create a new window with a new surface.
pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
fn newWindow(self: *App, parent_: ?*CoreSurface) !void {
const alloc = self.core_app.alloc;
// Allocate a fixed pointer for our window. We try to minimize
@@ -856,8 +1199,12 @@ fn gtkActionPresentSurface(
return;
}
// Convert that u64 to pointer to a core surface.
const surface: *CoreSurface = @ptrFromInt(c.g_variant_get_uint64(parameter));
// Convert that u64 to pointer to a core surface. A value of zero
// means that there was no target surface for the notification so
// we dont' focus any surface.
const ptr_int: u64 = c.g_variant_get_uint64(parameter);
if (ptr_int == 0) return;
const surface: *CoreSurface = @ptrFromInt(ptr_int);
// Send a message through the core app mailbox rather than presenting the
// surface directly so that it can validate that the surface pointer is

View File

@@ -7,7 +7,6 @@ const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const apprt = @import("../../apprt.zig");
const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const CoreSurface = @import("../../Surface.zig");
const Surface = @import("Surface.zig");
@@ -21,14 +20,14 @@ pub const Orientation = enum {
horizontal,
vertical,
pub fn fromDirection(direction: apprt.SplitDirection) Orientation {
pub fn fromDirection(direction: apprt.action.SplitDirection) Orientation {
return switch (direction) {
.right => .horizontal,
.down => .vertical,
};
}
pub fn fromResizeDirection(direction: input.SplitResizeDirection) Orientation {
pub fn fromResizeDirection(direction: apprt.action.ResizeSplit.Direction) Orientation {
return switch (direction) {
.up, .down => .vertical,
.left, .right => .horizontal,
@@ -58,7 +57,7 @@ bottom_right: Surface.Container.Elem,
pub fn create(
alloc: Allocator,
sibling: *Surface,
direction: apprt.SplitDirection,
direction: apprt.action.SplitDirection,
) !*Split {
var split = try alloc.create(Split);
errdefer alloc.destroy(split);
@@ -69,7 +68,7 @@ pub fn create(
pub fn init(
self: *Split,
sibling: *Surface,
direction: apprt.SplitDirection,
direction: apprt.action.SplitDirection,
) !void {
// Create the new child surface for the other direction.
const alloc = sibling.app.core_app.alloc;
@@ -164,7 +163,11 @@ fn removeChild(
}
/// Move the divider in the given direction by the given amount.
pub fn moveDivider(self: *Split, direction: input.SplitResizeDirection, amount: u16) void {
pub fn moveDivider(
self: *Split,
direction: apprt.action.ResizeSplit.Direction,
amount: u16,
) void {
const min_pos = 10;
const pos = c.gtk_paned_get_position(self.paned);
@@ -263,7 +266,7 @@ fn updateChildren(self: *const Split) void {
/// A mapping of direction to the element (if any) in that direction.
pub const DirectionMap = std.EnumMap(
input.SplitFocusDirection,
apprt.action.GotoSplit,
?*Surface,
);

View File

@@ -9,6 +9,7 @@ const configpkg = @import("../../config.zig");
const apprt = @import("../../apprt.zig");
const font = @import("../../font/main.zig");
const input = @import("../../input.zig");
const renderer = @import("../../renderer.zig");
const terminal = @import("../../terminal/main.zig");
const CoreSurface = @import("../../Surface.zig");
const internal_os = @import("../../os/main.zig");
@@ -688,7 +689,10 @@ pub fn close(self: *Surface, processActive: bool) void {
c.gtk_widget_show(alert);
}
pub fn controlInspector(self: *Surface, mode: input.InspectorMode) void {
pub fn controlInspector(
self: *Surface,
mode: apprt.action.Inspector,
) void {
const show = switch (mode) {
.toggle => self.inspector == null,
.show => true,
@@ -715,30 +719,6 @@ pub fn controlInspector(self: *Surface, mode: input.InspectorMode) void {
};
}
pub fn toggleFullscreen(self: *Surface, mac_non_native: configpkg.NonNativeFullscreen) void {
const window = self.container.window() orelse {
log.info(
"toggleFullscreen invalid for container={s}",
.{@tagName(self.container)},
);
return;
};
window.toggleFullscreen(mac_non_native);
}
pub fn toggleWindowDecorations(self: *Surface) void {
const window = self.container.window() orelse {
log.info(
"toggleWindowDecorations invalid for container={s}",
.{@tagName(self.container)},
);
return;
};
window.toggleWindowDecorations();
}
pub fn getTitleLabel(self: *Surface) ?*c.GtkWidget {
switch (self.title) {
.none => return null,
@@ -749,69 +729,6 @@ pub fn getTitleLabel(self: *Surface) ?*c.GtkWidget {
}
}
pub fn newSplit(self: *Surface, direction: apprt.SplitDirection) !void {
const alloc = self.app.core_app.alloc;
_ = try Split.create(alloc, self, direction);
}
pub fn gotoSplit(self: *const Surface, direction: input.SplitFocusDirection) void {
const s = self.container.split() orelse return;
const map = s.directionMap(switch (self.container) {
.split_tl => .top_left,
.split_br => .bottom_right,
.none, .tab_ => unreachable,
});
const surface_ = map.get(direction) orelse return;
if (surface_) |surface| surface.grabFocus();
}
pub fn resizeSplit(self: *const Surface, direction: input.SplitResizeDirection, amount: u16) void {
const s = self.container.firstSplitWithOrientation(
Split.Orientation.fromResizeDirection(direction),
) orelse return;
s.moveDivider(direction, amount);
}
pub fn equalizeSplits(self: *const Surface) void {
const tab = self.container.tab() orelse return;
const top_split = switch (tab.elem) {
.split => |s| s,
else => return,
};
_ = top_split.equalize();
}
pub fn newTab(self: *Surface) !void {
const window = self.container.window() orelse {
log.info("surface cannot create new tab when not attached to a window", .{});
return;
};
try window.newTab(&self.core_surface);
}
pub fn hasTabs(self: *const Surface) bool {
const window = self.container.window() orelse return false;
return window.hasTabs();
}
pub fn gotoTab(self: *Surface, tab: apprt.GotoTab) void {
const window = self.container.window() orelse {
log.info(
"gotoTab invalid for container={s}",
.{@tagName(self.container)},
);
return;
};
switch (tab) {
.previous => window.gotoPreviousTab(self),
.next => window.gotoNextTab(self),
.last => window.gotoLastTab(),
else => window.gotoTab(@intCast(@intFromEnum(tab))),
}
}
pub fn setShouldClose(self: *Surface) void {
_ = self;
}
@@ -867,18 +784,6 @@ pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void
);
}
pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void {
_ = self;
_ = width;
_ = height;
}
pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void {
_ = self;
_ = min;
_ = max_;
}
pub fn grabFocus(self: *Surface) void {
if (self.container.tab()) |tab| tab.focus_child = self;
@@ -1026,6 +931,19 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void {
self.url_widget = URLWidget.init(self, uriZ);
}
pub fn supportsClipboard(
self: *const Surface,
clipboard_type: apprt.Clipboard,
) bool {
_ = self;
return switch (clipboard_type) {
.standard,
.selection,
.primary,
=> true,
};
}
pub fn clipboardRequest(
self: *Surface,
clipboard_type: apprt.Clipboard,
@@ -1386,7 +1304,7 @@ fn gtkMouseUp(
}
fn gtkMouseMotion(
_: *c.GtkEventControllerMotion,
ec: *c.GtkEventControllerMotion,
x: c.gdouble,
y: c.gdouble,
ud: ?*anyopaque,
@@ -1415,7 +1333,12 @@ fn gtkMouseMotion(
self.grabFocus();
}
self.core_surface.cursorPosCallback(self.cursor_pos) catch |err| {
// Get our modifiers
const event = c.gtk_event_controller_get_current_event(@ptrCast(ec));
const gtk_mods = c.gdk_event_get_modifier_state(event);
const mods = translateMods(gtk_mods);
self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| {
log.err("error in cursor pos callback err={}", .{err});
return;
};
@@ -1975,7 +1898,7 @@ fn translateMods(state: c.GdkModifierType) input.Mods {
return mods;
}
pub fn presentSurface(self: *Surface) void {
pub fn present(self: *Surface) void {
if (self.container.window()) |window| {
if (self.container.tab()) |tab| {
if (window.notebook.getTabPosition(tab)) |position|
@@ -1983,5 +1906,6 @@ pub fn presentSurface(self: *Surface) void {
}
c.gtk_window_present(window.window);
}
self.grabFocus();
}

View File

@@ -35,6 +35,10 @@ window: *c.GtkWindow,
/// GtkHeaderBar depending on if adw is enabled and linked.
header: ?*c.GtkWidget,
/// The tab overview for the window. This is possibly null since there is no
/// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0).
tab_overview: ?*c.GtkWidget,
/// The notebook (tab grouping) for this window.
/// can be either c.GtkNotebook or c.AdwTabView.
notebook: Notebook,
@@ -68,6 +72,7 @@ pub fn init(self: *Window, app: *App) !void {
.app = app,
.window = undefined,
.header = null,
.tab_overview = null,
.notebook = undefined,
.context_menu = undefined,
.toast_overlay = undefined,
@@ -97,7 +102,7 @@ pub fn init(self: *Window, app: *App) !void {
// Apply class to color headerbar if window-theme is set to `ghostty`.
if (app.config.@"window-theme" == .ghostty) {
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "ghostty-theme-inherit");
c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window-theme-ghostty");
}
// Remove the window's background if any of the widgets need to be transparent
@@ -114,7 +119,7 @@ pub fn init(self: *Window, app: *App) !void {
const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0);
// If we are using an AdwWindow then we can support the tab overview.
const tab_overview_: ?*c.GtkWidget = if (self.isAdwWindow()) overview: {
self.tab_overview = if (self.isAdwWindow()) overview: {
const tab_overview = c.adw_tab_overview_new();
c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1);
_ = c.g_signal_connect_data(
@@ -152,14 +157,15 @@ pub fn init(self: *Window, app: *App) !void {
c.gtk_widget_set_tooltip_text(btn, "Main Menu");
c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic");
c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu)));
if (self.isAdwWindow())
c.adw_header_bar_pack_end(@ptrCast(header), btn)
else
c.gtk_header_bar_pack_end(@ptrCast(header), btn);
if (self.isAdwWindow()) {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
c.adw_header_bar_pack_end(@ptrCast(header), btn);
} else c.gtk_header_bar_pack_end(@ptrCast(header), btn);
}
// If we're using an AdwWindow then we can support the tab overview.
if (tab_overview_) |tab_overview| {
if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
assert(self.isAdwWindow());
const btn = c.gtk_toggle_button_new();
@@ -235,7 +241,8 @@ pub fn init(self: *Window, app: *App) !void {
};
// If we have a tab overview then we can set it on our notebook.
if (tab_overview_) |tab_overview| {
if (self.tab_overview) |tab_overview| {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
assert(self.notebook == .adw_tab_view);
c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view);
}
@@ -256,7 +263,8 @@ pub fn init(self: *Window, app: *App) !void {
// Our actions for the menu
initActions(self);
if (self.hasAdwToolbar()) {
if (self.isAdwWindow()) {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new());
const header_widget: *c.GtkWidget = @ptrCast(@alignCast(self.header.?));
@@ -289,7 +297,7 @@ pub fn init(self: *Window, app: *App) !void {
// Set our application window content. The content depends on if
// we're using an AdwTabOverview or not.
if (tab_overview_) |tab_overview| {
if (self.tab_overview) |tab_overview| {
c.adw_tab_overview_set_child(
@ptrCast(tab_overview),
@ptrCast(@alignCast(toolbar_view)),
@@ -391,18 +399,9 @@ pub fn deinit(self: *Window) void {
/// paths that are not enabled.
inline fn isAdwWindow(self: *Window) bool {
return (comptime adwaita.versionAtLeast(1, 4, 0)) and
adwaita.enabled(&self.app.config) and
self.app.config.@"gtk-titlebar" and
adwaita.versionAtLeast(1, 4, 0);
}
/// This must be `inline` so that the comptime check noops conditional
/// paths that are not enabled.
inline fn hasAdwToolbar(self: *Window) bool {
return ((comptime adwaita.versionAtLeast(1, 4, 0)) and
adwaita.enabled(&self.app.config) and
adwaita.versionAtLeast(1, 4, 0) and
self.app.config.@"gtk-titlebar");
self.app.config.@"gtk-titlebar";
}
/// Add a new tab to this window.
@@ -421,11 +420,6 @@ pub fn closeTab(self: *Window, tab: *Tab) void {
self.notebook.closeTab(tab);
}
/// Returns true if this window has any tabs.
pub fn hasTabs(self: *const Window) bool {
return self.notebook.nPages() > 0;
}
/// Go to the previous tab for a surface.
pub fn gotoPreviousTab(self: *Window, surface: *Surface) void {
const tab = surface.container.tab() orelse {
@@ -463,8 +457,17 @@ pub fn gotoTab(self: *Window, n: usize) void {
}
}
/// Toggle tab overview (if present)
pub fn toggleTabOverview(self: *Window) void {
if (self.tab_overview) |tab_overview_widget| {
if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable;
const tab_overview: *c.AdwTabOverview = @ptrCast(@alignCast(tab_overview_widget));
c.adw_tab_overview_set_open(tab_overview, 1 - c.adw_tab_overview_get_open(tab_overview));
}
}
/// Toggle fullscreen for this window.
pub fn toggleFullscreen(self: *Window, _: configpkg.NonNativeFullscreen) void {
pub fn toggleFullscreen(self: *Window) void {
const is_fullscreen = c.gtk_window_is_fullscreen(self.window);
if (is_fullscreen == 0) {
c.gtk_window_fullscreen(self.window);

View File

@@ -66,9 +66,11 @@ pub const Notebook = union(enum) {
const tab_view: *c.AdwTabView = c.adw_tab_view_new().?;
// Adwaita enables all of the shortcuts by default.
// We want to manage keybindings ourselves.
c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS);
if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) {
// Adwaita enables all of the shortcuts by default.
// We want to manage keybindings ourselves.
c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS);
}
_ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT);
@@ -261,6 +263,10 @@ pub const Notebook = union(enum) {
_ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT);
// Tab settings
c.gtk_notebook_set_tab_reorderable(notebook, box_widget, 1);
c.gtk_notebook_set_tab_detachable(notebook, box_widget, 1);
if (self.nPages() > 1) {
c.gtk_notebook_set_show_tabs(notebook, 1);
}

40
src/apprt/gtk/version.zig Normal file
View File

@@ -0,0 +1,40 @@
const c = @import("c.zig").c;
/// Verifies that the GTK version is at least the given version.
///
/// This can be run in both a comptime and runtime context. If it
/// is run in a comptime context, it will only check the version
/// in the headers. If it is run in a runtime context, it will
/// check the actual version of the library we are linked against.
///
/// This is inlined so that the comptime checks will disable the
/// runtime checks if the comptime checks fail.
pub inline fn atLeast(
comptime major: u16,
comptime minor: u16,
comptime micro: u16,
) bool {
// If our header has lower versions than the given version,
// we can return false immediately. This prevents us from
// compiling against unknown symbols and makes runtime checks
// very slightly faster.
if (comptime c.GTK_MAJOR_VERSION < major or
c.GTK_MINOR_VERSION < minor or
c.GTK_MICRO_VERSION < micro) return false;
// If we're in comptime then we can't check the runtime version.
if (@inComptime()) return true;
// We use the functions instead of the constants such as
// c.GTK_MINOR_VERSION because the function gets the actual
// runtime version.
if (c.gtk_get_major_version() >= major) {
if (c.gtk_get_major_version() > major) return true;
if (c.gtk_get_minor_version() >= minor) {
if (c.gtk_get_minor_version() > minor) return true;
return c.gtk_get_micro_version() >= micro;
}
}
return false;
}

View File

@@ -52,33 +52,6 @@ pub const ClipboardRequest = union(ClipboardRequestType) {
osc_52_write: Clipboard,
};
/// A desktop notification.
pub const DesktopNotification = struct {
/// The title of the notification. May be an empty string to not show a
/// title.
title: []const u8,
/// The body of a notification. This will always be shown.
body: []const u8,
};
/// The tab to jump to. This is non-exhaustive so that integer values represent
/// the index (zero-based) of the tab to jump to. Negative values are special
/// values.
pub const GotoTab = enum(c_int) {
previous = -1,
next = -2,
last = -3,
_,
};
// This is made extern (c_int) to make interop easier with our embedded
// runtime. The small size cost doesn't make a difference in our union.
pub const SplitDirection = enum(c_int) {
right,
down,
};
/// The color scheme in use (light vs dark).
pub const ColorScheme = enum(u2) {
light = 0,

View File

@@ -12,7 +12,7 @@ pub const fish_completions = comptimeGenerateFishCompletions();
fn comptimeGenerateFishCompletions() []const u8 {
comptime {
@setEvalBranchQuota(16000);
@setEvalBranchQuota(17000);
var counter = std.io.countingWriter(std.io.null_writer);
try writeFishCompletions(&counter.writer());

View File

@@ -116,7 +116,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
while (iter.next()) |bind| {
const action = switch (bind.value_ptr.*) {
.leader => continue, // TODO: support this
.action, .action_unconsumed => |action| action,
.leaf => |leaf| leaf.action,
};
const key = switch (bind.key_ptr.key) {
.translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}),

File diff suppressed because it is too large Load Diff

45
src/cli/lorem_ipsum.txt Normal file
View File

@@ -0,0 +1,45 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras hendrerit aliquet
turpis non dictum. Mauris pulvinar nisl sit amet dui cursus tempus. Pellentesque
ut dui justo. Etiam quis magna sagittis nisi pretium consequat vitae ut nisl.
Sed at metus id odio pulvinar sodales. Vestibulum sollicitudin, sem id tristique
vestibulum, neque ante dictum tortor, in convallis mi enim ac lorem. Suspendisse
orci ex, ullamcorper sed leo vitae, mattis egestas nisl. Morbi id est vel
ipsum mollis convallis vel at mauris. Duis vehicula facilisis placerat. Aliquam
venenatis auctor ipsum vel elementum. Proin ac tincidunt lacus. Sed facilisis
tellus ullamcorper bibendum lobortis. Pellentesque porta, lacus quis efficitur
pulvinar, sem mi varius ante, sed finibus diam ante et risus.
Morbi ut sollicitudin justo. Nulla mattis mi ac mauris tincidunt tempor. Morbi
vel gravida erat. Ut eu risus quis nisi facilisis aliquet varius id orci.
Pellentesque tortor diam, porttitor nec urna nec, convallis consectetur dui.
Vestibulum et hendrerit ipsum. Morbi pharetra dictum turpis in elementum. Ut
nec volutpat nunc, at venenatis leo. Morbi eget nulla luctus, tincidunt dui vel,
cursus urna. Maecenas ac pellentesque nisi. Quisque ut lorem porta, eleifend
metus id, pellentesque tellus.
Vivamus gravida convallis felis, at hendrerit dolor. Vestibulum tincidunt id
augue quis hendrerit. Praesent venenatis elit quis posuere gravida. Praesent
at massa a purus maximus tempus. Proin dui leo, feugiat et erat ac, tincidunt
aliquam risus. Aenean rutrum hendrerit turpis, sit amet consectetur justo porta
non. Sed auctor justo elit, sed mollis odio ullamcorper nec. Pellentesque ac
hendrerit tortor. Praesent quis viverra dui, sit amet imperdiet magna.
Mauris iaculis maximus felis, aliquet vehicula neque sagittis nec. Duis
convallis purus enim, vel scelerisque purus dignissim eu. Donec congue sapien
a neque rhoncus, sit amet accumsan libero tincidunt. Proin vitae placerat urna.
Donec dolor sapien, fringilla sed semper sit amet, sollicitudin sit amet orci.
Mauris maximus convallis vehicula. Aliquam urna ipsum, fermentum ac iaculis vel,
blandit eget lorem. Sed enim ante, sodales a diam in, convallis interdum quam.
Duis non urna risus. Proin ac neque at risus ullamcorper mattis eu vel nunc.
Proin et ipsum euismod, ullamcorper justo et, imperdiet est. Curabitur quis
arcu faucibus, bibendum nisl nec, hendrerit sapien. Curabitur vitae ante risus.
Praesent eget sagittis tortor.
Mauris aliquam nec nibh eu congue. Nullam congue auctor vestibulum. Donec
posuere sapien nec massa efficitur tincidunt. Vestibulum ante ipsum primis in
faucibus orci luctus et ultrices posuere cubilia curae; Proin molestie, nisl
in tincidunt condimentum, ante metus fermentum felis, ac molestie lacus dui vel
dolor. Donec ornare laoreet posuere. Etiam id tincidunt ante. Maecenas semper
diam ac tortor facilisis egestas. Nam eu bibendum nisl. Integer tempor nisl nec
ex consectetur, quis lobortis enim finibus. Sed ac erat posuere, fermentum metus
sed, suscipit nisl.

View File

@@ -223,7 +223,7 @@ const c = @cImport({
@"font-codepoint-map": RepeatableCodepointMap = .{},
/// Draw fonts with a thicker stroke, if supported. This is only supported
/// currently on MacOS.
/// currently on macOS.
@"font-thicken": bool = false,
/// All of the configurations behavior adjust various metrics determined by the
@@ -429,6 +429,8 @@ palette: Palette = .{},
/// Hide the mouse immediately when typing. The mouse becomes visible again when
/// the mouse is used. The mouse is only hidden if the mouse cursor is over the
/// active terminal surface.
///
/// macOS: This feature requires macOS 15.0 (Sequoia) or later.
@"mouse-hide-while-typing": bool = false,
/// Determines whether running programs can detect the shift key pressed with a
@@ -649,7 +651,8 @@ class: ?[:0]const u8 = null,
@"working-directory": ?[]const u8 = null,
/// Key bindings. The format is `trigger=action`. Duplicate triggers will
/// overwrite previously set values.
/// overwrite previously set values. The list of actions is available in
/// the documentation or using the `ghostty +list-actions` command.
///
/// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`,
/// `ctrl+shift+b`, `up`. Some notes:
@@ -701,6 +704,9 @@ class: ?[:0]const u8 = null,
/// `ctrl+a>t`, and then bind `ctrl+a` directly, both `ctrl+a>n` and
/// `ctrl+a>t` will become unbound.
///
/// * Trigger sequences are not allowed for `global:` or `all:`-prefixed
/// triggers. This is a limitation we could remove in the future.
///
/// Action is the action to take when the trigger is satisfied. It takes the
/// format `action` or `action:param`. The latter form is only valid if the
/// action requires a parameter.
@@ -720,6 +726,9 @@ class: ?[:0]const u8 = null,
/// * `text:text` - Send a string. Uses Zig string literal syntax.
/// i.e. `text:\x15` sends Ctrl-U.
///
/// * All other actions can be found in the documentation or by using the
/// `ghostty +list-actions` command.
///
/// Some notes for the action:
///
/// * The parameter is taken as-is after the `:`. Double quotes or
@@ -734,11 +743,48 @@ class: ?[:0]const u8 = null,
/// removes ALL keybindings up to this point, including the default
/// keybindings.
///
/// A keybind by default causes the input to be consumed. This means that the
/// associated encoding (if any) will not be sent to the running program
/// in the terminal. If you wish to send the encoded value to the program,
/// specify the "unconsumed:" prefix before the entire keybind. For example:
/// "unconsumed:ctrl+a=reload_config"
/// The keybind trigger can be prefixed with some special values to change
/// the behavior of the keybind. These are:
///
/// * `all:` - Make the keybind apply to all terminal surfaces. By default,
/// keybinds only apply to the focused terminal surface. If this is true,
/// then the keybind will be sent to all terminal surfaces. This only
/// applies to actions that are surface-specific. For actions that
/// are already global (i.e. `quit`), this prefix has no effect.
///
/// * `global:` - Make the keybind global. By default, keybinds only work
/// within Ghostty and under the right conditions (application focused,
/// sometimes terminal focused, etc.). If you want a keybind to work
/// globally across your system (i.e. even when Ghostty is not focused),
/// specify this prefix. This prefix implies `all:`. Note: this does not
/// work in all environments; see the additional notes below for more
/// information.
///
/// * `unconsumed:` - Do not consume the input. By default, a keybind
/// will consume the input, meaning that the associated encoding (if
/// any) will not be sent to the running program in the terminal. If
/// you wish to send the encoded value to the program, specify the
/// `unconsumed:` prefix before the entire keybind. For example:
/// `unconsumed:ctrl+a=reload_config`. `global:` and `all:`-prefixed
/// keybinds will always consume the input regardless of this setting.
/// Since they are not associated with a specific terminal surface,
/// they're never encoded.
///
/// Keybind trigger are not unique per prefix combination. For example,
/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind
/// set later will overwrite the keybind set earlier. In this case, the
/// `global:` keybind will be used.
///
/// Multiple prefixes can be specified. For example,
/// `global:unconsumed:ctrl+a=reload_config` will make the keybind global
/// and not consume the input to reload the config.
///
/// A note on `global:`: this feature is only supported on macOS. On macOS,
/// this feature requires accessibility permissions to be granted to Ghostty.
/// When a `global:` keybind is specified and Ghostty is launched or reloaded,
/// Ghostty will attempt to request these permissions. If the permissions are
/// not granted, the keybind will not work. On macOS, you can find these
/// permissions in System Preferences -> Privacy & Security -> Accessibility.
keybind: Keybinds = .{},
/// Horizontal window padding. This applies padding between the terminal cells
@@ -845,13 +891,16 @@ keybind: Keybinds = .{},
///
/// * `true`
/// * `false` - windows won't have native decorations, i.e. titlebar and
/// borders. On MacOS this also disables tabs and tab overview.
/// borders. On macOS this also disables tabs and tab overview.
///
/// The "toggle_window_decoration" keybind action can be used to create
/// a keybinding to toggle this setting at runtime.
///
/// Changing this configuration in your configuration and reloading will
/// only affect new windows. Existing windows will not be affected.
///
/// macOS: To hide the titlebar without removing the native window borders
/// or rounded corners, use `macos-titlebar-style = hidden` instead.
@"window-decoration": bool = true,
/// The font that will be used for the application's window and tab titles.
@@ -1171,6 +1220,40 @@ keybind: Keybinds = .{},
/// window is ever created. Only implemented on Linux.
@"initial-window": bool = true,
/// The position of the "quick" terminal window. To learn more about the
/// quick terminal, see the documentation for the `toggle_quick_terminal`
/// binding action.
///
/// Valid values are:
///
/// * `top` - Terminal appears at the top of the screen.
/// * `bottom` - Terminal appears at the bottom of the screen.
/// * `left` - Terminal appears at the left of the screen.
/// * `right` - Terminal appears at the right of the screen.
///
/// Changing this configuration requires restarting Ghostty completely.
@"quick-terminal-position": QuickTerminalPosition = .top,
/// The screen where the quick terminal should show up.
///
/// Valid values are:
///
/// * `main` - The screen that the operating system recommends as the main
/// screen. On macOS, this is the screen that is currently receiving
/// keyboard input. This screen is defined by the operating system and
/// not chosen by Ghostty.
///
/// * `mouse` - The screen that the mouse is currently hovered over.
///
/// * `macos-menu-bar` - The screen that contains the macOS menu bar as
/// set in the display settings on macOS. This is a bit confusing because
/// every screen on macOS has a menu bar, but this is the screen that
/// contains the primary menu bar.
///
/// The default value is `main` because this is the recommended screen
/// by the operating system.
@"quick-terminal-screen": QuickTerminalScreen = .main,
/// Whether to enable shell integration auto-injection or not. Shell integration
/// greatly enhances the terminal experience by enabling a number of features:
///
@@ -1304,7 +1387,7 @@ keybind: Keybinds = .{},
@"macos-non-native-fullscreen": NonNativeFullscreen = .false,
/// The style of the macOS titlebar. Available values are: "native",
/// "transparent", and "tabs".
/// "transparent", "tabs", and "hidden".
///
/// The "native" style uses the native macOS titlebar with zero customization.
/// The titlebar will match your window theme (see `window-theme`).
@@ -1321,6 +1404,13 @@ keybind: Keybinds = .{},
/// macOS 14 does not have this issue and any other macOS version has not
/// been tested.
///
/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`,
/// however, it does not remove the frame from the window or cause it to have
/// squared corners. Changing to or from this option at run-time may affect
/// existing windows in buggy ways. The top titlebar area of the window will
/// continue to drag the window around and you will not be able to use
/// the mouse for terminal events in this space.
///
/// The default value is "transparent". This is an opinionated choice
/// but its one I think is the most aesthetically pleasing and works in
/// most cases.
@@ -1348,6 +1438,34 @@ keybind: Keybinds = .{},
/// find false more visually appealing.
@"macos-window-shadow": bool = true,
/// If true, Ghostty on macOS will automatically enable the "Secure Input"
/// feature when it detects that a password prompt is being displayed.
///
/// "Secure Input" is a macOS security feature that prevents applications from
/// reading keyboard events. This can always be enabled manually using the
/// `Ghostty > Secure Keyboard Entry` menu item.
///
/// Note that automatic password prompt detection is based on heuristics
/// and may not always work as expected. Specifically, it does not work
/// over SSH connections, but there may be other cases where it also
/// doesn't work.
///
/// A reason to disable this feature is if you find that it is interfering
/// with legitimate accessibility software (or software that uses the
/// accessibility APIs), since secure input prevents any application from
/// reading keyboard events.
@"macos-auto-secure-input": bool = true,
/// If true, Ghostty will show a graphical indication when secure input is
/// enabled. This indication is generally recommended to know when secure input
/// is enabled.
///
/// Normally, secure input is only active when a password prompt is displayed
/// or it is manually (and typically temporarily) enabled. However, if you
/// always have secure input enabled, the indication can be distracting and
/// you may want to disable it.
@"macos-secure-input-indication": bool = true,
/// Put every surface (tab, split, window) into a dedicated Linux cgroup.
///
/// This makes it so that resource management can be done on a per-surface
@@ -3664,11 +3782,16 @@ pub const Keybinds = struct {
)) return false,
// Actions are compared by field directly
inline .action, .action_unconsumed => |_, tag| if (!equalField(
inputpkg.Binding.Action,
@field(self_entry.value_ptr.*, @tagName(tag)),
@field(other_entry.value_ptr.*, @tagName(tag)),
)) return false,
.leaf => {
const self_leaf = self_entry.value_ptr.*.leaf;
const other_leaf = other_entry.value_ptr.*.leaf;
if (!equalField(
inputpkg.Binding.Set.Leaf,
self_leaf,
other_leaf,
)) return false;
},
}
}
@@ -4241,6 +4364,7 @@ pub const MacTitlebarStyle = enum {
native,
transparent,
tabs,
hidden,
};
/// See gtk-single-instance
@@ -4311,6 +4435,21 @@ pub const ResizeOverlayPosition = enum {
@"bottom-right",
};
/// See quick-terminal-position
pub const QuickTerminalPosition = enum {
top,
bottom,
left,
right,
};
/// See quick-terminal-screen
pub const QuickTerminalScreen = enum {
main,
mouse,
@"macos-menu-bar",
};
/// See grapheme-width-method
pub const GraphemeWidthMethod = enum {
legacy,

View File

@@ -5,6 +5,7 @@
const dir = @import("dir.zig");
const sentry_envelope = @import("sentry_envelope.zig");
pub const minidump = @import("minidump.zig");
pub const sentry = @import("sentry.zig");
pub const Envelope = sentry_envelope.Envelope;
pub const defaultDir = dir.defaultDir;

7
src/crash/minidump.zig Normal file
View File

@@ -0,0 +1,7 @@
pub const reader = @import("minidump/reader.zig");
pub const stream = @import("minidump/stream.zig");
pub const Reader = reader.Reader;
test {
@import("std").testing.refAllDecls(@This());
}

View File

@@ -0,0 +1,59 @@
//! This file contains the external structs and constants for the minidump
//! format. Most are from the Microsoft documentation on the minidump format:
//! https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/
//!
//! Wherever possible, we also compare our definitions to other projects
//! such as rust-minidump, libmdmp, breakpad, etc. to ensure we're doing
//! the right thing.
/// "MDMP" in little-endian.
pub const signature = 0x504D444D;
/// The version of the minidump format.
pub const version = 0xA793;
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_header
pub const Header = extern struct {
signature: u32,
version: packed struct(u32) { low: u16, high: u16 },
stream_count: u32,
stream_directory_rva: u32,
checksum: u32,
time_date_stamp: u32,
flags: u64,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_directory
pub const Directory = extern struct {
stream_type: u32,
location: LocationDescriptor,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_location_descriptor
pub const LocationDescriptor = extern struct {
data_size: u32,
rva: u32,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_memory_descriptor
pub const MemoryDescriptor = extern struct {
start_of_memory_range: u64,
memory: LocationDescriptor,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_thread_list
pub const ThreadList = extern struct {
number_of_threads: u32,
threads: [1]Thread,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_thread
pub const Thread = extern struct {
thread_id: u32,
suspend_count: u32,
priority_class: u32,
priority: u32,
teb: u64,
stack: MemoryDescriptor,
thread_context: LocationDescriptor,
};

View File

@@ -0,0 +1,242 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const external = @import("external.zig");
const stream = @import("stream.zig");
const EncodedStream = stream.EncodedStream;
const log = std.log.scoped(.minidump_reader);
/// Possible minidump-specific errors that can occur when reading a minidump.
/// This isn't the full error set since IO errors can also occur depending
/// on the Source type.
pub const ReadError = error{
InvalidHeader,
InvalidVersion,
StreamSizeMismatch,
};
/// Reader creates a new minidump reader for the given source type. The
/// source must have both a "reader()" and "seekableStream()" function.
///
/// Given the format of a minidump file, we must keep the source open and
/// continually access it because the format of the minidump is full of
/// pointers and offsets that we must follow depending on the stream types.
/// Also, since we're not aware of all stream types (in fact its impossible
/// to be aware since custom stream types are allowed), its possible any stream
/// type can define their own pointers and offsets. So, the source must always
/// be available so callers can decode the streams as needed.
pub fn Reader(comptime S: type) type {
return struct {
const Self = @This();
/// The source data.
source: Source,
/// The endianness of the minidump file. This is detected by reading
/// the byte order of the header.
endian: std.builtin.Endian,
/// The number of streams within the minidump file. This is read from
/// the header and stored here so we can quickly access them. Note
/// the stream types require reading the source; this is an optimization
/// to avoid any allocations on the reader and the caller can choose
/// to store them if they want.
stream_count: u32,
stream_directory_rva: u32,
const SourceCallable = switch (@typeInfo(Source)) {
.Pointer => |v| v.child,
.Struct => Source,
else => @compileError("Source type must be a pointer or struct"),
};
const SourceReader = @typeInfo(@TypeOf(SourceCallable.reader)).Fn.return_type.?;
const SourceSeeker = @typeInfo(@TypeOf(SourceCallable.seekableStream)).Fn.return_type.?;
/// A limited reader for reading data from the source.
pub const LimitedReader = std.io.LimitedReader(SourceReader);
/// The source type for the reader.
pub const Source = S;
/// The stream types for reading
pub const ThreadList = stream.thread_list.ThreadListReader(Self);
/// The reader type for stream reading. This has some other methods so
/// you must still call reader() on the result to get the actual
/// reader to read the data.
pub const StreamReader = struct {
source: Source,
endian: std.builtin.Endian,
directory: external.Directory,
/// Should not be accessed directly. This is setup whenever
/// reader() is called.
limit_reader: LimitedReader = undefined,
pub const Reader = LimitedReader.Reader;
/// Returns a Reader implementation that reads the bytes of the
/// stream.
///
/// The reader is dependent on the state of Source so any
/// state-changing operations on Source will invalidate the
/// reader. For example, making another reader, reading another
/// stream directory, closing the source, etc.
pub fn reader(self: *StreamReader) LimitedReader.Reader {
try self.source.seekableStream().seekTo(self.directory.location.rva);
self.limit_reader = .{
.inner_reader = self.source.reader(),
.bytes_left = self.directory.location.data_size,
};
return self.limit_reader.reader();
}
/// Seeks the source to the location of the directory.
pub fn seekToPayload(self: *StreamReader) !void {
try self.source.seekableStream().seekTo(self.directory.location.rva);
}
};
/// Iterator type to read over the streams in the minidump file.
pub const StreamIterator = struct {
reader: *const Self,
i: u32 = 0,
pub fn next(self: *StreamIterator) !?StreamReader {
if (self.i >= self.reader.stream_count) return null;
const dir = try self.reader.directory(self.i);
self.i += 1;
return try self.reader.streamReader(dir);
}
};
/// Initialize a reader. The source must remain available for the entire
/// lifetime of the reader. The reader does not take ownership of the
/// source so if it has resources that need to be cleaned up, the caller
/// must do so once the reader is no longer needed.
pub fn init(source: Source) !Self {
const header, const endian = try readHeader(Source, source);
return .{
.source = source,
.endian = endian,
.stream_count = header.stream_count,
.stream_directory_rva = header.stream_directory_rva,
};
}
/// Return an iterator to read over the streams in the minidump file.
/// This is very similar to using a simple for loop to stream_count
/// and calling directory() on each index, but is more idiomatic
/// Zig.
pub fn streamIterator(self: *const Self) StreamIterator {
return .{ .reader = self };
}
/// Return a StreamReader for the given directory type. This streams
/// from the underlying source so the returned reader is only valid
/// as long as the source is unmodified (i.e. the source is not
/// closed, the source seek position is not moved, etc.).
pub fn streamReader(
self: *const Self,
dir: external.Directory,
) SourceSeeker.SeekError!StreamReader {
return .{
.source = self.source,
.endian = self.endian,
.directory = dir,
};
}
/// Get the directory entry with the given index.
///
/// Asserts the index is valid (idx < stream_count).
pub fn directory(self: *const Self, idx: usize) !external.Directory {
assert(idx < self.stream_count);
// Seek to the directory.
const offset: u32 = @intCast(@sizeOf(external.Directory) * idx);
const rva: u32 = self.stream_directory_rva + offset;
try self.source.seekableStream().seekTo(rva);
// Read the directory.
return try self.source.reader().readStructEndian(
external.Directory,
self.endian,
);
}
/// Return a reader for the given location descriptor. This is only
/// valid until the reader source is modified in some way.
pub fn locationReader(
self: *const Self,
loc: external.LocationDescriptor,
) !LimitedReader {
try self.source.seekableStream().seekTo(loc.rva);
return .{
.inner_reader = self.source.reader(),
.bytes_left = loc.data_size,
};
}
};
}
/// Reads the header for the minidump file and returns endianness of
/// the file.
fn readHeader(comptime T: type, source: T) !struct {
external.Header,
std.builtin.Endian,
} {
// Start by trying LE.
var endian: std.builtin.Endian = .little;
var header = try source.reader().readStructEndian(external.Header, endian);
// If the signature doesn't match, we assume its BE.
if (header.signature != external.signature) {
// Seek back to the start of the file so we can reread.
try source.seekableStream().seekTo(0);
// Try BE, if the signature doesn't match, return an error.
endian = .big;
header = try source.reader().readStructEndian(external.Header, endian);
if (header.signature != external.signature) return ReadError.InvalidHeader;
}
// "The low-order word is MINIDUMP_VERSION. The high-order word is an
// internal value that is implementation specific."
if (header.version.low != external.version) return ReadError.InvalidVersion;
return .{ header, endian };
}
// Uncomment to dump some debug information for a minidump file.
test "minidump debug" {
var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp"));
const r = try Reader(*@TypeOf(fbs)).init(&fbs);
var it = r.streamIterator();
while (try it.next()) |s| {
log.warn("directory i={} dir={}", .{ it.i - 1, s.directory });
}
}
test "minidump read" {
const testing = std.testing;
const alloc = testing.allocator;
var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp"));
const r = try Reader(*@TypeOf(fbs)).init(&fbs);
try testing.expectEqual(std.builtin.Endian.little, r.endian);
try testing.expectEqual(7, r.stream_count);
{
const dir = try r.directory(0);
try testing.expectEqual(3, dir.stream_type);
try testing.expectEqual(584, dir.location.data_size);
var bytes = std.ArrayList(u8).init(alloc);
defer bytes.deinit();
var sr = try r.streamReader(dir);
try sr.reader().readAllArrayList(&bytes, std.math.maxInt(usize));
try testing.expectEqual(584, bytes.items.len);
}
}

View File

@@ -0,0 +1,30 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.minidump_stream);
/// The known stream types.
pub const thread_list = @import("stream_threadlist.zig");
/// A stream within the minidump file. A stream can be either in an encoded
/// form or decoded form. The encoded form are raw bytes and aren't validated
/// until they're decoded. The decoded form is a structured form of the stream.
///
/// The decoded form is more ergonomic to work with but the encoded form is
/// more efficient to read/write.
pub const Stream = union(enum) {
encoded: EncodedStream,
};
/// An encoded stream value. It is "encoded" in the sense that it is raw bytes
/// with a type associated. The raw bytes are not validated to be correct for
/// the type.
pub const EncodedStream = struct {
type: u32,
data: []const u8,
};
test {
@import("std").testing.refAllDecls(@This());
}

View File

@@ -0,0 +1,117 @@
const std = @import("std");
const assert = std.debug.assert;
const external = @import("external.zig");
const readerpkg = @import("reader.zig");
const Reader = readerpkg.Reader;
const ReadError = readerpkg.ReadError;
const log = std.log.scoped(.minidump_stream);
/// This is the list of threads from the process.
///
/// This is the Reader implementation. You usually do not use this directly.
/// Instead, use Reader(T).ThreadList which will get you the same thing.
///
/// ThreadList is stream type 0x3.
/// StreamReader is the Reader(T).StreamReader type.
pub fn ThreadListReader(comptime R: type) type {
return struct {
const Self = @This();
/// The number of threads in the list.
count: u32,
/// The rva to the first thread in the list.
rva: u32,
/// Source data and endianness so we can read.
source: R.Source,
endian: std.builtin.Endian,
pub fn init(r: *R.StreamReader) !Self {
assert(r.directory.stream_type == 0x3);
try r.seekToPayload();
const reader = r.source.reader();
// Our count is always a u32 in the header.
const count = try reader.readInt(u32, r.endian);
// Determine if we have padding in our header. It is possible
// for there to be padding if the list header was written by
// a 32-bit process but is being read on a 64-bit process.
const padding = padding: {
const maybe_size = @sizeOf(u32) + (@sizeOf(external.Thread) * count);
switch (std.math.order(maybe_size, r.directory.location.data_size)) {
// It should never be larger than what the directory says.
.gt => return ReadError.StreamSizeMismatch,
// If the sizes match exactly we're good.
.eq => break :padding 0,
.lt => {
const padding = r.directory.location.data_size - maybe_size;
if (padding != 4) return ReadError.StreamSizeMismatch;
break :padding padding;
},
}
};
// Rva is the location of the first thread in the list.
const rva = r.directory.location.rva + @as(u32, @sizeOf(u32)) + padding;
return .{
.count = count,
.rva = rva,
.source = r.source,
.endian = r.endian,
};
}
/// Get the thread entry for the given index.
///
/// Index is asserted to be less than count.
pub fn thread(self: *const Self, i: usize) !external.Thread {
assert(i < self.count);
// Seek to the thread
const offset: u32 = @intCast(@sizeOf(external.Thread) * i);
const rva: u32 = self.rva + offset;
try self.source.seekableStream().seekTo(rva);
// Read the thread
return try self.source.reader().readStructEndian(
external.Thread,
self.endian,
);
}
};
}
test "minidump: threadlist" {
const testing = std.testing;
const alloc = testing.allocator;
var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp"));
const R = Reader(*@TypeOf(fbs));
const r = try R.init(&fbs);
// Get our thread list stream
const dir = try r.directory(0);
try testing.expectEqual(3, dir.stream_type);
var sr = try r.streamReader(dir);
// Get our rich structure
const v = try R.ThreadList.init(&sr);
log.warn("threadlist count={} rva={}", .{ v.count, v.rva });
try testing.expectEqual(12, v.count);
for (0..v.count) |i| {
const t = try v.thread(i);
log.warn("thread i={} thread={}", .{ i, t });
// Read our stack memory
var stack_reader = try r.locationReader(t.stack.memory);
const bytes = try stack_reader.reader().readAllAlloc(alloc, t.stack.memory.data_size);
defer alloc.free(bytes);
}
}

BIN
src/crash/testdata/macos.dmp vendored Normal file

Binary file not shown.

View File

@@ -594,30 +594,37 @@ pub const Face = struct {
// All of these metrics are based on our layout above.
const cell_height = @ceil(layout_metrics.height);
const cell_baseline = @ceil(layout_metrics.height - layout_metrics.ascent);
const underline_thickness = @ceil(@as(f32, @floatCast(ct_font.getUnderlineThickness())));
const strikethrough_position = strikethrough_position: {
// This is the height above baseline consumed by text. We must take
// into account that our cell height splits the leading between two
// rows so we subtract leading space (blank space).
const above_baseline = layout_metrics.ascent - (layout_metrics.leading / 2);
// We want to position the strikethrough at 65% of the height.
// This generally gives a nice visual appearance. The number 65%
// is somewhat arbitrary but is a common value across terminals.
const pos = above_baseline * 0.65;
const underline_thickness = @ceil(@as(f32, @floatCast(ct_font.getUnderlineThickness())));
const strikethrough_thickness = underline_thickness;
const strikethrough_position = strikethrough_position: {
// This is the height of lower case letters in our font.
const ex_height = ct_font.getXHeight();
// We want to position the strikethrough so that it's
// vertically centered on any lower case text. This is
// a fairly standard choice for strikethrough positioning.
//
// Because our `strikethrough_position` is relative to the
// top of the cell we start with the ascent metric, which
// is the distance from the top down to the baseline, then
// we subtract half of the ex height to go back up to the
// correct height that should evenly split lowercase text.
const pos = layout_metrics.ascent -
ex_height * 0.5 -
strikethrough_thickness * 0.5;
break :strikethrough_position @ceil(pos);
};
const strikethrough_thickness = underline_thickness;
// Underline position reported is usually something like "-1" to
// represent the amount under the baseline. We add this to our real
// baseline to get the actual value from the bottom (+y is up).
// The final underline position is +y from the TOP (confusing)
// so we have to subtract from the cell height.
const underline_position = cell_height -
(cell_baseline + @ceil(@as(f32, @floatCast(ct_font.getUnderlinePosition())))) +
1;
const underline_position = @ceil(layout_metrics.ascent -
@as(f32, @floatCast(ct_font.getUnderlinePosition())));
// Note: is this useful?
// const units_per_em = ct_font.getUnitsPerEm();

View File

@@ -607,6 +607,20 @@ pub const Face = struct {
break :cell_width f26dot6ToFloat(size_metrics.max_advance);
};
// Ex height is calculated by measuring the height of the `x` glyph.
// If that fails then we just pretend it's 65% of the ascent height.
const ex_height: f32 = ex_height: {
if (face.getCharIndex('x')) |glyph_index| {
if (face.loadGlyph(glyph_index, .{ .render = true })) {
break :ex_height f26dot6ToFloat(face.handle.*.glyph.*.metrics.height);
} else |_| {
// Ignore the error since we just fall back to 65% of the ascent below
}
}
break :ex_height f26dot6ToFloat(size_metrics.ascender) * 0.65;
};
// Cell height is calculated as the maximum of multiple things in order
// to handle edge cases in fonts: (1) the height as reported in metadata
// by the font designer (2) the maximum glyph height as measured in the
@@ -646,50 +660,55 @@ pub const Face = struct {
// is reversed.
const cell_baseline = -1 * f26dot6ToFloat(size_metrics.descender);
const underline_thickness = @max(@as(f32, 1), fontUnitsToPxY(
face,
face.handle.*.underline_thickness,
));
// The underline position. This is a value from the top where the
// underline should go.
const underline_position: f32 = underline_pos: {
// The ascender is already scaled for scalable fonts, but the
// underline position is not.
const ascender_px = @as(i32, @intCast(size_metrics.ascender)) >> 6;
const declared_px = freetype.mulFix(
// From the FreeType docs:
// > `underline_position`
// > The position, in font units, of the underline line for
// > this face. It is the center of the underlining stem.
const declared_px = @as(f32, @floatFromInt(freetype.mulFix(
face.handle.*.underline_position,
@intCast(face.handle.*.size.*.metrics.y_scale),
) >> 6;
))) / 64;
// We use the declared underline position if its available
const declared = ascender_px - declared_px;
// We use the declared underline position if its available.
const declared = @ceil(cell_height - cell_baseline - declared_px - underline_thickness * 0.5);
if (declared > 0)
break :underline_pos @floatFromInt(declared);
break :underline_pos declared;
// If we have no declared underline position, we go slightly under the
// cell height (mainly: non-scalable fonts, i.e. emoji)
break :underline_pos cell_height - 1;
};
const underline_thickness = @max(@as(f32, 1), fontUnitsToPxY(
face,
face.handle.*.underline_thickness,
));
// The strikethrough position. We use the position provided by the
// font if it exists otherwise we calculate a best guess.
const strikethrough: struct {
pos: f32,
thickness: f32,
} = if (face.getSfntTable(.os2)) |os2| .{
.pos = pos: {
// Ascender is scaled, strikeout pos is not
const ascender_px = @as(i32, @intCast(size_metrics.ascender)) >> 6;
const declared_px = freetype.mulFix(
os2.yStrikeoutPosition,
@as(i32, @intCast(face.handle.*.size.*.metrics.y_scale)),
) >> 6;
} = if (face.getSfntTable(.os2)) |os2| st: {
const thickness = @max(@as(f32, 1), fontUnitsToPxY(face, os2.yStrikeoutSize));
break :pos @floatFromInt(ascender_px - declared_px);
},
.thickness = @max(@as(f32, 1), fontUnitsToPxY(face, os2.yStrikeoutSize)),
const pos = @as(f32, @floatFromInt(freetype.mulFix(
os2.yStrikeoutPosition,
@as(i32, @intCast(face.handle.*.size.*.metrics.y_scale)),
))) / 64;
break :st .{
.pos = @ceil(cell_height - cell_baseline - pos),
.thickness = thickness,
};
} else .{
.pos = cell_baseline * 0.6,
// Exactly 50% of the ex height so that our strikethrough is
// centered through lowercase text. This is a common choice.
.pos = @ceil(cell_height - cell_baseline - ex_height * 0.5 - underline_thickness * 0.5),
.thickness = underline_thickness,
};
@@ -832,7 +851,7 @@ test "metrics" {
.cell_width = 16,
.cell_height = 35,
.cell_baseline = 7,
.underline_position = 36,
.underline_position = 35,
.underline_thickness = 2,
.strikethrough_position = 20,
.strikethrough_thickness = 2,

View File

@@ -27,189 +27,174 @@ pub fn renderGlyph(
line_pos: u32,
line_thickness: u32,
) !font.Glyph {
// Create the canvas we'll use to draw. We draw the underline in
// a full cell size and position it according to "pos".
var canvas = try font.sprite.Canvas.init(alloc, width, height);
// Draw the appropriate sprite
var canvas: font.sprite.Canvas, const offset_y: i32 = switch (sprite) {
.underline => try drawSingle(alloc, width, line_thickness),
.underline_double => try drawDouble(alloc, width, line_thickness),
.underline_dotted => try drawDotted(alloc, width, line_thickness),
.underline_dashed => try drawDashed(alloc, width, line_thickness),
.underline_curly => try drawCurly(alloc, width, line_thickness),
.strikethrough => try drawSingle(alloc, width, line_thickness),
else => unreachable,
};
defer canvas.deinit(alloc);
// Perform the actual drawing
(Draw{
.width = width,
.height = height,
.pos = line_pos,
.thickness = line_thickness,
}).draw(&canvas, sprite);
// Write the drawing to the atlas
const region = try canvas.writeAtlas(alloc, atlas);
// Our coordinates start at the BOTTOM for our renderers so we have to
// specify an offset of the full height because we rendered a full size
// cell.
const offset_y = @as(i32, @intCast(height));
return font.Glyph{
.width = width,
.height = height,
.height = @intCast(region.height),
.offset_x = 0,
.offset_y = offset_y,
// Glyph.offset_y is the distance between the top of the glyph and the
// bottom of the cell. We want the top of the glyph to be at line_pos
// from the TOP of the cell, and then offset by the offset_y from the
// draw function.
.offset_y = @as(i32, @intCast(height - line_pos)) - offset_y,
.atlas_x = region.x,
.atlas_y = region.y,
.advance_x = @floatFromInt(width),
};
}
/// Stores drawing state.
const Draw = struct {
width: u32,
height: u32,
pos: u32,
thickness: u32,
/// A tuple with the canvas that the desired sprite was drawn on and
/// a recommended offset (+Y = down) to shift its Y position by, to
/// correct for underline styles with additional thickness.
const CanvasAndOffset = struct { font.sprite.Canvas, i32 };
/// Draw a specific underline sprite to the canvas.
fn draw(self: Draw, canvas: *font.sprite.Canvas, sprite: Sprite) void {
switch (sprite) {
.underline => self.drawSingle(canvas),
.underline_double => self.drawDouble(canvas),
.underline_dotted => self.drawDotted(canvas),
.underline_dashed => self.drawDashed(canvas),
.underline_curly => self.drawCurly(canvas),
.strikethrough => self.drawSingle(canvas),
else => unreachable,
}
}
/// Draw a single underline.
fn drawSingle(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset {
const height: u32 = thickness;
var canvas = try font.sprite.Canvas.init(alloc, width, height);
/// Draw a single underline.
fn drawSingle(self: Draw, canvas: *font.sprite.Canvas) void {
// Ensure we never overflow out of bounds on the canvas
const y_max = self.height -| 1;
const bottom = @min(self.pos + self.thickness, y_max);
const y = bottom -| self.thickness;
const max_height = self.height - y;
canvas.rect(.{
.x = 0,
.y = 0,
.width = width,
.height = thickness,
}, .on);
const offset_y: i32 = 0;
return .{ canvas, offset_y };
}
/// Draw a double underline.
fn drawDouble(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset {
const height: u32 = thickness * 3;
var canvas = try font.sprite.Canvas.init(alloc, width, height);
canvas.rect(.{
.x = 0,
.y = 0,
.width = width,
.height = thickness,
}, .on);
canvas.rect(.{
.x = 0,
.y = @intCast(thickness * 2),
.width = width,
.height = thickness,
}, .on);
const offset_y: i32 = -@as(i32, @intCast(thickness));
return .{ canvas, offset_y };
}
/// Draw a dotted underline.
fn drawDotted(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset {
const height: u32 = thickness;
var canvas = try font.sprite.Canvas.init(alloc, width, height);
const dot_width = @max(thickness, 3);
const dot_count = @max((width / dot_width) / 2, 1);
const gap_width = try std.math.divCeil(u32, width -| (dot_count * dot_width), dot_count);
var i: u32 = 0;
while (i < dot_count) : (i += 1) {
// Ensure we never go out of bounds for the rect
const x = @min(i * (dot_width + gap_width), width - 1);
const rect_width = @min(width - x, dot_width);
canvas.rect(.{
.x = 0,
.y = @intCast(y),
.width = self.width,
.height = @min(self.thickness, max_height),
.x = @intCast(x),
.y = 0,
.width = rect_width,
.height = thickness,
}, .on);
}
/// Draw a double underline.
fn drawDouble(self: Draw, canvas: *font.sprite.Canvas) void {
// The maximum y value has to have space for the bottom underline.
// If we underflow (saturated) to 0, then we don't draw. This should
// never happen but we don't want to draw something undefined.
const y_max = self.height -| 1 -| self.thickness;
if (y_max == 0) return;
const offset_y: i32 = 0;
const space = self.thickness * 2;
const bottom = @min(self.pos + space, y_max);
const top = bottom - space;
return .{ canvas, offset_y };
}
/// Draw a dashed underline.
fn drawDashed(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset {
const height: u32 = thickness;
var canvas = try font.sprite.Canvas.init(alloc, width, height);
const dash_width = width / 3 + 1;
const dash_count = (width / dash_width) + 1;
var i: u32 = 0;
while (i < dash_count) : (i += 2) {
// Ensure we never go out of bounds for the rect
const x = @min(i * dash_width, width - 1);
const rect_width = @min(width - x, dash_width);
canvas.rect(.{
.x = 0,
.y = @intCast(top),
.width = self.width,
.height = self.thickness,
}, .on);
canvas.rect(.{
.x = 0,
.y = @intCast(bottom),
.width = self.width,
.height = self.thickness,
.x = @intCast(x),
.y = 0,
.width = rect_width,
.height = thickness,
}, .on);
}
/// Draw a dotted underline.
fn drawDotted(self: Draw, canvas: *font.sprite.Canvas) void {
const y_max = self.height -| 1 -| self.thickness;
if (y_max == 0) return;
const y = @min(self.pos, y_max);
const dot_width = @max(self.thickness, 3);
const dot_count = self.width / dot_width;
var i: u32 = 0;
while (i < dot_count) : (i += 2) {
// Ensure we never go out of bounds for the rect
const x = @min(i * dot_width, self.width - 1);
const width = @min(self.width - 1 - x, dot_width);
canvas.rect(.{
.x = @intCast(i * dot_width),
.y = @intCast(y),
.width = width,
.height = self.thickness,
}, .on);
const offset_y: i32 = 0;
return .{ canvas, offset_y };
}
/// Draw a curly underline. Thanks to Wez Furlong for providing
/// the basic math structure for this since I was lazy with the
/// geometry.
fn drawCurly(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset {
const height: u32 = thickness * 4;
var canvas = try font.sprite.Canvas.init(alloc, width, height);
// Calculate the wave period for a single character
// `2 * pi...` = 1 peak per character
// `4 * pi...` = 2 peaks per character
const wave_period = 2 * std.math.pi / @as(f64, @floatFromInt(width - 1));
// The full amplitude of the wave can be from the bottom to the
// underline position. We also calculate our mid y point of the wave
const half_amplitude: f64 = @as(f64, @floatFromInt(thickness));
const y_mid: f64 = half_amplitude + 1;
// follow Xiaolin Wu's antialias algorithm to draw the curve
var x: u32 = 0;
while (x < width) : (x += 1) {
const cosx: f64 = @cos(@as(f64, @floatFromInt(x)) * wave_period);
const y: f64 = y_mid + half_amplitude * cosx;
const y_upper: u32 = @intFromFloat(@floor(y));
const y_lower: u32 = y_upper + thickness + (thickness >> 1);
const alpha: u8 = @intFromFloat(255 * @abs(y - @floor(y)));
// upper and lower bounds
canvas.pixel(x, @min(y_upper, height), @enumFromInt(255 - alpha));
canvas.pixel(x, @min(y_lower, height), @enumFromInt(alpha));
// fill between upper and lower bound
var y_fill: u32 = y_upper + 1;
while (y_fill < y_lower) : (y_fill += 1) {
canvas.pixel(x, @min(y_fill, height), .on);
}
}
/// Draw a dashed underline.
fn drawDashed(self: Draw, canvas: *font.sprite.Canvas) void {
const y_max = self.height -| 1 -| self.thickness;
if (y_max == 0) return;
const y = @min(self.pos, y_max);
const dash_width = self.width / 3 + 1;
const dash_count = (self.width / dash_width) + 1;
var i: u32 = 0;
while (i < dash_count) : (i += 2) {
// Ensure we never go out of bounds for the rect
const x = @min(i * dash_width, self.width - 1);
const width = @min(self.width - 1 - x, dash_width);
canvas.rect(.{
.x = @intCast(x),
.y = @intCast(y),
.width = width,
.height = self.thickness,
}, .on);
}
}
const offset_y: i32 = -@as(i32, @intCast(thickness * 2));
/// Draw a curly underline. Thanks to Wez Furlong for providing
/// the basic math structure for this since I was lazy with the
/// geometry.
fn drawCurly(self: Draw, canvas: *font.sprite.Canvas) void {
// This is the lowest that the curl can go.
const y_max = self.height - 1;
// Calculate the wave period for a single character
// `2 * pi...` = 1 peak per character
// `4 * pi...` = 2 peaks per character
const wave_period = 2 * std.math.pi / @as(f64, @floatFromInt(self.width - 1));
// Some fonts put the underline too close to the bottom of the
// cell height and this doesn't allow us to make a high enough
// wave. This constant is arbitrary, change it for aesthetics.
const pos: u32 = pos: {
const MIN_AMPLITUDE: u32 = @max(self.height / 9, 2);
break :pos y_max - (MIN_AMPLITUDE * 2);
};
// The full amplitude of the wave can be from the bottom to the
// underline position. We also calculate our mid y point of the wave
const double_amplitude: f64 = @floatFromInt(y_max - pos);
const half_amplitude: f64 = @max(1, double_amplitude / 4);
const y_mid: u32 = pos + @as(u32, @intFromFloat(2 * half_amplitude));
// follow Xiaolin Wu's antialias algorithm to draw the curve
var x: u32 = 0;
while (x < self.width) : (x += 1) {
const y: f64 = @as(f64, @floatFromInt(y_mid)) + (half_amplitude * @cos(@as(f64, @floatFromInt(x)) * wave_period));
const y_upper: u32 = @intFromFloat(@floor(y));
const y_lower: u32 = y_upper + self.thickness;
const alpha: u8 = @intFromFloat(255 * @abs(y - @floor(y)));
// upper and lower bounds
canvas.pixel(x, @min(y_upper, y_max), @enumFromInt(255 - alpha));
canvas.pixel(x, @min(y_lower, y_max), @enumFromInt(alpha));
// fill between upper and lower bound
var y_fill: u32 = y_upper + 1;
while (y_fill < y_lower) : (y_fill += 1) {
canvas.pixel(x, @min(y_fill, y_max), .on);
}
}
}
};
return .{ canvas, offset_y };
}
test "single" {
const testing = std.testing;

View File

@@ -6,6 +6,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const key = @import("key.zig");
const KeyEvent = key.KeyEvent;
/// The trigger that needs to be performed to execute the action.
trigger: Trigger,
@@ -13,21 +14,36 @@ trigger: Trigger,
/// The action to take if this binding matches
action: Action,
/// True if this binding should consume the input when the
/// action is triggered.
consumed: bool = true,
/// Boolean flags that can be set per binding.
flags: Flags = .{},
pub const Error = error{
InvalidFormat,
InvalidAction,
};
/// Flags the full binding-scoped flags that can be set per binding.
pub const Flags = packed struct {
/// True if this binding should consume the input when the
/// action is triggered.
consumed: bool = true,
/// True if this binding should be forwarded to all active surfaces
/// in the application.
all: bool = false,
/// True if this binding is global. Global bindings should work system-wide
/// and not just while Ghostty is focused. This may not work on all platforms.
/// See the keybind config documentation for more information.
global: bool = false,
};
/// Full binding parser. The binding parser is implemented as an iterator
/// which yields elements to support multi-key sequences without allocation.
pub const Parser = struct {
unconsumed: bool = false,
trigger_it: SequenceIterator,
action: Action,
flags: Flags = .{},
pub const Elem = union(enum) {
/// A leader trigger in a sequence.
@@ -38,11 +54,7 @@ pub const Parser = struct {
};
pub fn init(raw_input: []const u8) Error!Parser {
// If our entire input is prefixed with "unconsumed:" then we are
// not consuming this keybind when the action is triggered.
const unconsumed_prefix = "unconsumed:";
const unconsumed = std.mem.startsWith(u8, raw_input, unconsumed_prefix);
const start_idx = if (unconsumed) unconsumed_prefix.len else 0;
const flags, const start_idx = try parseFlags(raw_input);
const input = raw_input[start_idx..];
// Find the first = which splits are mapping into the trigger
@@ -52,24 +64,63 @@ pub const Parser = struct {
// Sequence iterator goes up to the equal, action is after. We can
// parse the action now.
return .{
.unconsumed = unconsumed,
.trigger_it = .{ .input = input[0..eql_idx] },
.action = try Action.parse(input[eql_idx + 1 ..]),
.flags = flags,
};
}
fn parseFlags(raw_input: []const u8) Error!struct { Flags, usize } {
var flags: Flags = .{};
var start_idx: usize = 0;
var input: []const u8 = raw_input;
while (true) {
// Find the next prefix
const idx = std.mem.indexOf(u8, input, ":") orelse break;
const prefix = input[0..idx];
// If the prefix is one of our flags then set it.
if (std.mem.eql(u8, prefix, "all")) {
if (flags.all) return Error.InvalidFormat;
flags.all = true;
} else if (std.mem.eql(u8, prefix, "global")) {
if (flags.global) return Error.InvalidFormat;
flags.global = true;
} else if (std.mem.eql(u8, prefix, "unconsumed")) {
if (!flags.consumed) return Error.InvalidFormat;
flags.consumed = false;
} else {
// If we don't recognize the prefix then we're done.
// There are trigger-specific prefixes like "physical:" so
// this lets us fall into that.
break;
}
// Move past the prefix
start_idx += idx + 1;
input = input[idx + 1 ..];
}
return .{ flags, start_idx };
}
pub fn next(self: *Parser) Error!?Elem {
// Get our trigger. If we're out of triggers then we're done.
const trigger = (try self.trigger_it.next()) orelse return null;
// If this is our last trigger then it is our final binding.
if (!self.trigger_it.done()) return .{ .leader = trigger };
if (!self.trigger_it.done()) {
// Global/all bindings can't be sequences
if (self.flags.global or self.flags.all) return error.InvalidFormat;
return .{ .leader = trigger };
}
// Out of triggers, yield the final action.
return .{ .binding = .{
.trigger = trigger,
.action = self.action,
.consumed = !self.unconsumed,
.flags = self.flags,
} };
}
@@ -228,7 +279,8 @@ pub const Action = union(enum) {
/// available values.
write_selection_file: WriteScreenAction,
/// Open a new window.
/// Open a new window. If the application isn't currently focused,
/// this will bring it to the front.
new_window: void,
/// Open a new tab.
@@ -246,6 +298,10 @@ pub const Action = union(enum) {
/// Go to the tab with the specific number, 1-indexed.
goto_tab: usize,
/// Toggle the tab overview.
/// This only works with libadwaita enabled currently.
toggle_tab_overview: void,
/// Create a new split in the given direction. The new split will appear in
/// the direction given.
new_split: SplitDirection,
@@ -297,6 +353,37 @@ pub const Action = union(enum) {
/// Toggle window decorations on and off. This only works on Linux.
toggle_window_decorations: void,
/// Toggle secure input mode on or off. This is used to prevent apps
/// that monitor input from seeing what you type. This is useful for
/// entering passwords or other sensitive information.
///
/// This applies to the entire application, not just the focused
/// terminal. You must toggle it off to disable it, or quit Ghostty.
///
/// This only works on macOS, since this is a system API on macOS.
toggle_secure_input: void,
/// Toggle the "quick" terminal. The quick terminal is a terminal that
/// appears on demand from a keybinding, often sliding in from a screen
/// edge such as the top. This is useful for quick access to a terminal
/// without having to open a new window or tab.
///
/// When the quick terminal loses focus, it disappears. The terminal state
/// is preserved between appearances, so you can always press the keybinding
/// to bring it back up.
///
/// The quick terminal has some limitations:
///
/// - It is a singleton; only one instance can exist at a time.
/// - It does not support tabs.
/// - It does not support fullscreen.
/// - It will not be restored when the application is restarted
/// (for systems that support window restoration).
///
/// See the various configurations for the quick terminal in the
/// configuration file to customize its behavior.
toggle_quick_terminal: void,
/// Quit ghostty.
quit: void,
@@ -348,8 +435,7 @@ pub const Action = union(enum) {
// Note: we don't support top or left yet
};
// Extern because it is used in the embedded runtime ABI.
pub const SplitFocusDirection = enum(c_int) {
pub const SplitFocusDirection = enum {
previous,
next,
@@ -359,8 +445,7 @@ pub const Action = union(enum) {
right,
};
// Extern because it is used in the embedded runtime ABI.
pub const SplitResizeDirection = enum(c_int) {
pub const SplitResizeDirection = enum {
up,
down,
left,
@@ -378,7 +463,7 @@ pub const Action = union(enum) {
};
// Extern because it is used in the embedded runtime ABI.
pub const InspectorMode = enum(c_int) {
pub const InspectorMode = enum {
toggle,
show,
hide,
@@ -479,6 +564,144 @@ pub const Action = union(enum) {
return Error.InvalidAction;
}
/// The scope of an action. The scope is the context in which an action
/// must be executed.
pub const Scope = enum {
app,
surface,
};
/// Returns the scope of an action.
pub fn scope(self: Action) Scope {
return switch (self) {
// Doesn't really matter, so we'll see app.
.ignore,
.unbind,
=> .app,
// Obviously app actions.
.open_config,
.reload_config,
.close_all_windows,
.quit,
.toggle_quick_terminal,
=> .app,
// These are app but can be special-cased in a surface context.
.new_window,
=> .app,
// Obviously surface actions.
.csi,
.esc,
.text,
.cursor_key,
.reset,
.copy_to_clipboard,
.paste_from_clipboard,
.paste_from_selection,
.increase_font_size,
.decrease_font_size,
.reset_font_size,
.clear_screen,
.select_all,
.scroll_to_top,
.scroll_to_bottom,
.scroll_page_up,
.scroll_page_down,
.scroll_page_fractional,
.scroll_page_lines,
.adjust_selection,
.jump_to_prompt,
.write_scrollback_file,
.write_screen_file,
.write_selection_file,
.close_surface,
.close_window,
.toggle_fullscreen,
.toggle_window_decorations,
.toggle_secure_input,
.crash,
=> .surface,
// These are less obvious surface actions. They're surface
// actions because they are relevant to the surface they
// come from. For example `new_window` needs to be sourced to
// a surface so inheritance can be done correctly.
.new_tab,
.previous_tab,
.next_tab,
.last_tab,
.goto_tab,
.toggle_tab_overview,
.new_split,
.goto_split,
.toggle_split_zoom,
.resize_split,
.equalize_splits,
.inspector,
=> .surface,
};
}
/// Returns a union type that only contains actions that are scoped to
/// the given scope.
pub fn Scoped(comptime s: Scope) type {
const all_fields = @typeInfo(Action).Union.fields;
// Find all fields that are app-scoped
var i: usize = 0;
var union_fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
var enum_fields: [all_fields.len]std.builtin.Type.EnumField = undefined;
for (all_fields) |field| {
const action = @unionInit(Action, field.name, undefined);
if (action.scope() == s) {
union_fields[i] = field;
enum_fields[i] = .{ .name = field.name, .value = i };
i += 1;
}
}
// Build our union
return @Type(.{ .Union = .{
.layout = .auto,
.tag_type = @Type(.{ .Enum = .{
.tag_type = std.math.IntFittingRange(0, i),
.fields = enum_fields[0..i],
.decls = &.{},
.is_exhaustive = true,
} }),
.fields = union_fields[0..i],
.decls = &.{},
} });
}
/// Returns the scoped version of this action. If the action is not
/// scoped to the given scope then this returns null.
///
/// The benefit of this function is that it allows us to use Zig's
/// exhaustive switch safety to ensure we always properly handle certain
/// scoped actions.
pub fn scoped(self: Action, comptime s: Scope) ?Scoped(s) {
switch (self) {
inline else => |v, tag| {
// Use comptime to prune out non-app actions
if (comptime @unionInit(
Action,
@tagName(tag),
undefined,
).scope() != s) return null;
// Initialize our app action
return @unionInit(
Scoped(s),
@tagName(tag),
v,
);
},
}
}
/// Implements the formatter for the fmt package. This encodes the
/// action back into the format used by parse.
pub fn format(
@@ -534,10 +757,15 @@ pub const Action = union(enum) {
/// action.
pub fn hash(self: Action) u64 {
var hasher = std.hash.Wyhash.init(0);
self.hashIncremental(&hasher);
return hasher.final();
}
/// Hash the action into the given hasher.
fn hashIncremental(self: Action, hasher: anytype) void {
// Always has the active tag.
const Tag = @typeInfo(Action).Union.tag_type.?;
std.hash.autoHash(&hasher, @as(Tag, self));
std.hash.autoHash(hasher, @as(Tag, self));
// Hash the value of the field.
switch (self) {
@@ -552,25 +780,23 @@ pub const Action = union(enum) {
// signed zeros but these are not cases we expect for
// our bindings.
f32 => std.hash.autoHash(
&hasher,
hasher,
@as(u32, @bitCast(field)),
),
f64 => std.hash.autoHash(
&hasher,
hasher,
@as(u64, @bitCast(field)),
),
// Everything else automatically handle.
else => std.hash.autoHashStrat(
&hasher,
hasher,
field,
.DeepRecursive,
),
}
},
}
return hasher.final();
}
};
@@ -727,11 +953,16 @@ pub const Trigger = struct {
/// Returns a hash code that can be used to uniquely identify this trigger.
pub fn hash(self: Trigger) u64 {
var hasher = std.hash.Wyhash.init(0);
std.hash.autoHash(&hasher, self.key);
std.hash.autoHash(&hasher, self.mods.binding());
self.hashIncremental(&hasher);
return hasher.final();
}
/// Hash the trigger into the given hasher.
fn hashIncremental(self: Trigger, hasher: anytype) void {
std.hash.autoHash(hasher, self.key);
std.hash.autoHash(hasher, self.mods.binding());
}
/// Convert the trigger to a C API compatible trigger.
pub fn cval(self: Trigger) C {
return .{
@@ -808,10 +1039,8 @@ pub const Set = struct {
leader: *Set,
/// This trigger completes a sequence and the value is the action
/// to take. The "_unconsumed" variant is used for triggers that
/// should not consume the input.
action: Action,
action_unconsumed: Action,
/// to take along with the flags that may define binding behavior.
leaf: Leaf,
/// Implements the formatter for the fmt package. This encodes the
/// action back into the format used by parse.
@@ -836,14 +1065,28 @@ pub const Set = struct {
}
},
.action, .action_unconsumed => |action| {
.leaf => |leaf| {
// action implements the format
try writer.print("={s}", .{action});
try writer.print("={s}", .{leaf.action});
},
}
}
};
/// Leaf node of a set is an action to trigger. This is a "leaf" compared
/// to the inner nodes which are "leaders" for sequences.
pub const Leaf = struct {
action: Action,
flags: Flags,
pub fn hash(self: Leaf) u64 {
var hasher = std.hash.Wyhash.init(0);
self.action.hash(&hasher);
std.hash.autoHash(&hasher, self.flags);
return hasher.final();
}
};
pub fn deinit(self: *Set, alloc: Allocator) void {
// Clear any leaders if we have them
var it = self.bindings.iterator();
@@ -852,7 +1095,7 @@ pub const Set = struct {
s.deinit(alloc);
alloc.destroy(s);
},
.action, .action_unconsumed => {},
.leaf => {},
};
self.bindings.deinit(alloc);
@@ -924,7 +1167,7 @@ pub const Set = struct {
error.OutOfMemory => return error.OutOfMemory,
},
.action, .action_unconsumed => {
.leaf => {
// Remove the existing action. Fallthrough as if
// we don't have a leader.
set.remove(alloc, t);
@@ -948,11 +1191,11 @@ pub const Set = struct {
set.remove(alloc, t);
if (old) |entry| switch (entry) {
.leader => unreachable, // Handled above
inline .action, .action_unconsumed => |action, tag| set.put_(
.leaf => |leaf| set.putFlags(
alloc,
t,
action,
tag == .action,
leaf.action,
leaf.flags,
) catch {},
};
},
@@ -967,11 +1210,12 @@ pub const Set = struct {
return error.SequenceUnbind;
},
else => if (b.consumed) {
try set.put(alloc, b.trigger, b.action);
} else {
try set.putUnconsumed(alloc, b.trigger, b.action);
},
else => try set.putFlags(
alloc,
b.trigger,
b.action,
b.flags,
),
},
}
}
@@ -984,29 +1228,16 @@ pub const Set = struct {
t: Trigger,
action: Action,
) Allocator.Error!void {
try self.put_(alloc, t, action, true);
try self.putFlags(alloc, t, action, .{});
}
/// Same as put but marks the trigger as unconsumed. An unconsumed
/// trigger will evaluate the action and continue to encode for the
/// terminal.
///
/// This is a separate function because this case is rare.
pub fn putUnconsumed(
/// Add a binding to the set with explicit flags.
pub fn putFlags(
self: *Set,
alloc: Allocator,
t: Trigger,
action: Action,
) Allocator.Error!void {
try self.put_(alloc, t, action, false);
}
fn put_(
self: *Set,
alloc: Allocator,
t: Trigger,
action: Action,
consumed: bool,
flags: Flags,
) Allocator.Error!void {
// unbind should never go into the set, it should be handled prior
assert(action != .unbind);
@@ -1022,7 +1253,7 @@ pub const Set = struct {
// If we have an existing binding for this trigger, we have to
// update the reverse mapping to remove the old action.
.action, .action_unconsumed => {
.leaf => {
const t_hash = t.hash();
var it = self.reverse.iterator();
while (it.next()) |reverse_entry| it: {
@@ -1034,11 +1265,10 @@ pub const Set = struct {
},
};
gop.value_ptr.* = if (consumed) .{
gop.value_ptr.* = .{ .leaf = .{
.action = action,
} else .{
.action_unconsumed = action,
};
.flags = flags,
} };
errdefer _ = self.bindings.remove(t);
try self.reverse.put(alloc, action, t);
errdefer _ = self.reverse.remove(action);
@@ -1055,6 +1285,31 @@ pub const Set = struct {
return self.reverse.get(a);
}
/// Get an entry for the given key event. This will attempt to find
/// a binding using multiple parts of the event in the following order:
///
/// 1. Translated key (event.key)
/// 2. Physical key (event.physical_key)
/// 3. Unshifted Unicode codepoint (event.unshifted_codepoint)
///
pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry {
var trigger: Trigger = .{
.mods = event.mods.binding(),
.key = .{ .translated = event.key },
};
if (self.get(trigger)) |v| return v;
trigger.key = .{ .physical = event.physical_key };
if (self.get(trigger)) |v| return v;
if (event.unshifted_codepoint > 0) {
trigger.key = .{ .unicode = event.unshifted_codepoint };
if (self.get(trigger)) |v| return v;
}
return null;
}
/// Remove a binding for a given trigger.
pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void {
const entry = self.bindings.get(t) orelse return;
@@ -1073,15 +1328,16 @@ pub const Set = struct {
// Note: we'd LIKE to replace this with the most recent binding but
// our hash map obviously has no concept of ordering so we have to
// choose whatever. Maybe a switch to an array hash map here.
.action, .action_unconsumed => |action| {
const action_hash = action.hash();
.leaf => |leaf| {
const action_hash = leaf.action.hash();
var it = self.bindings.iterator();
while (it.next()) |it_entry| {
switch (it_entry.value_ptr.*) {
.leader => {},
.action, .action_unconsumed => |action_search| {
if (action_search.hash() == action_hash) {
self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*);
.leaf => |leaf_search| {
if (leaf_search.action.hash() == action_hash) {
self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*);
break;
}
},
@@ -1089,7 +1345,7 @@ pub const Set = struct {
} else {
// No over trigger points to this action so we remove
// the reverse mapping completely.
_ = self.reverse.remove(action);
_ = self.reverse.remove(leaf.action);
}
},
}
@@ -1106,7 +1362,7 @@ pub const Set = struct {
var it = result.bindings.iterator();
while (it.next()) |entry| switch (entry.value_ptr.*) {
// No data to clone
.action, .action_unconsumed => {},
.leaf => {},
// Must be deep cloned.
.leader => |*s| {
@@ -1208,7 +1464,7 @@ test "parse: triggers" {
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.consumed = false,
.flags = .{ .consumed = false },
}, try parseSingle("unconsumed:shift+a=ignore"));
// unconsumed physical keys
@@ -1218,7 +1474,7 @@ test "parse: triggers" {
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
.consumed = false,
.flags = .{ .consumed = false },
}, try parseSingle("unconsumed:physical:a+shift=ignore"));
// invalid key
@@ -1231,6 +1487,92 @@ test "parse: triggers" {
try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore"));
}
test "parse: global triggers" {
const testing = std.testing;
// global keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .global = true },
}, try parseSingle("global:shift+a=ignore"));
// global physical keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .global = true },
}, try parseSingle("global:physical:a+shift=ignore"));
// global unconsumed keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{
.global = true,
.consumed = false,
},
}, try parseSingle("unconsumed:global:a+shift=ignore"));
// global sequences not allowed
{
var p = try Parser.init("global:a>b=ignore");
try testing.expectError(Error.InvalidFormat, p.next());
}
}
test "parse: all triggers" {
const testing = std.testing;
// all keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .all = true },
}, try parseSingle("all:shift+a=ignore"));
// all physical keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .physical = .a },
},
.action = .{ .ignore = {} },
.flags = .{ .all = true },
}, try parseSingle("all:physical:a+shift=ignore"));
// all unconsumed keys
try testing.expectEqual(Binding{
.trigger = .{
.mods = .{ .shift = true },
.key = .{ .translated = .a },
},
.action = .{ .ignore = {} },
.flags = .{
.all = true,
.consumed = false,
},
}, try parseSingle("unconsumed:all:a+shift=ignore"));
// all sequences not allowed
{
var p = try Parser.init("all:a>b=ignore");
try testing.expectError(Error.InvalidFormat, p.next());
}
}
test "parse: modifier aliases" {
const testing = std.testing;
@@ -1456,8 +1798,9 @@ test "set: parseAndPut typical binding" {
// Creates forward mapping
{
const action = s.get(.{ .key = .{ .translated = .a } }).?.action;
try testing.expect(action == .new_window);
const action = s.get(.{ .key = .{ .translated = .a } }).?.leaf;
try testing.expect(action.action == .new_window);
try testing.expectEqual(Flags{}, action.flags);
}
// Creates reverse mapping
@@ -1479,8 +1822,9 @@ test "set: parseAndPut unconsumed binding" {
// Creates forward mapping
{
const trigger: Trigger = .{ .key = .{ .translated = .a } };
const action = s.get(trigger).?.action_unconsumed;
try testing.expect(action == .new_window);
const action = s.get(trigger).?.leaf;
try testing.expect(action.action == .new_window);
try testing.expectEqual(Flags{ .consumed = false }, action.flags);
}
// Creates reverse mapping
@@ -1526,8 +1870,9 @@ test "set: parseAndPut sequence" {
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
@@ -1550,14 +1895,16 @@ test "set: parseAndPut sequence with two actions" {
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
{
const t: Trigger = .{ .key = .{ .translated = .c } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_tab);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_tab);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
@@ -1580,8 +1927,9 @@ test "set: parseAndPut overwrite sequence" {
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
@@ -1604,8 +1952,9 @@ test "set: parseAndPut overwrite leader" {
{
const t: Trigger = .{ .key = .{ .translated = .b } };
const e = current.get(t).?;
try testing.expect(e == .action);
try testing.expect(e.action == .new_window);
try testing.expect(e == .leaf);
try testing.expect(e.leaf.action == .new_window);
try testing.expectEqual(Flags{}, e.leaf.flags);
}
}
@@ -1734,11 +2083,19 @@ test "set: consumed state" {
defer s.deinit(alloc);
try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed);
try s.putUnconsumed(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action_unconsumed);
try s.putFlags(
alloc,
.{ .key = .{ .translated = .a } },
.{ .new_window = {} },
.{ .consumed = false },
);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf);
try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed);
try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} });
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf);
try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed);
}

View File

@@ -1,3 +1,4 @@
const std = @import("std");
const build_config = @import("build_config.zig");
/// See build_config.ExeEntrypoint for why we do this.
@@ -16,6 +17,12 @@ const entrypoint = switch (build_config.exe_entrypoint) {
/// The main entrypoint for the program.
pub const main = entrypoint.main;
/// Standard options such as logger overrides.
pub const std_options: std.Options = if (@hasDecl(entrypoint, "std_options"))
entrypoint.std_options
else
.{};
test {
_ = entrypoint;
}

View File

@@ -2523,6 +2523,45 @@ fn updateCell(
}
}
// If the cell has an underline, draw it before the character glyph,
// so that it layers underneath instead of overtop, since that can
// make text difficult to read.
if (underline != .none) {
const sprite: font.Sprite = switch (underline) {
.none => unreachable,
.single => .underline,
.double => .underline_double,
.dotted => .underline_dotted,
.dashed => .underline_dashed,
.curly => .underline_curly,
};
const render = try self.font_grid.renderGlyph(
self.alloc,
font.sprite_index,
@intFromEnum(sprite),
.{
.cell_width = if (cell.wide == .wide) 2 else 1,
.grid_metrics = self.grid_metrics,
},
);
const color = style.underlineColor(palette) orelse colors.fg;
try self.cells.add(self.alloc, .underline, .{
.mode = .fg,
.grid_pos = .{ @intCast(coord.x), @intCast(coord.y) },
.constraint_width = cell.gridWidth(),
.color = .{ color.r, color.g, color.b, alpha },
.glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
.glyph_size = .{ render.glyph.width, render.glyph.height },
.bearings = .{
@intCast(render.glyph.offset_x),
@intCast(render.glyph.offset_y),
},
});
}
// If the shaper cell has a glyph, draw it.
if (shaper_cell.glyph_index) |glyph_index| glyph: {
// Render
@@ -2566,42 +2605,6 @@ fn updateCell(
});
}
if (underline != .none) {
const sprite: font.Sprite = switch (underline) {
.none => unreachable,
.single => .underline,
.double => .underline_double,
.dotted => .underline_dotted,
.dashed => .underline_dashed,
.curly => .underline_curly,
};
const render = try self.font_grid.renderGlyph(
self.alloc,
font.sprite_index,
@intFromEnum(sprite),
.{
.cell_width = if (cell.wide == .wide) 2 else 1,
.grid_metrics = self.grid_metrics,
},
);
const color = style.underlineColor(palette) orelse colors.fg;
try self.cells.add(self.alloc, .underline, .{
.mode = .fg,
.grid_pos = .{ @intCast(coord.x), @intCast(coord.y) },
.constraint_width = cell.gridWidth(),
.color = .{ color.r, color.g, color.b, alpha },
.glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
.glyph_size = .{ render.glyph.width, render.glyph.height },
.bearings = .{
@intCast(render.glyph.offset_x),
@intCast(render.glyph.offset_y),
},
});
}
if (style.flags.strikethrough) {
const render = try self.font_grid.renderGlyph(
self.alloc,

View File

@@ -1761,52 +1761,9 @@ fn updateCell(
@intFromFloat(@max(0, @min(255, @round(self.config.background_opacity * 255)))),
};
// If the cell has a character, draw it
if (cell.hasText()) fg: {
// Render
const render = try self.font_grid.renderGlyph(
self.alloc,
shaper_run.font_index,
shaper_cell.glyph_index orelse break :fg,
.{
.grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken,
},
);
// If we're rendering a color font, we use the color atlas
const mode: CellProgram.CellMode = switch (try fgMode(
render.presentation,
cell_pin,
)) {
.normal => .fg,
.color => .fg_color,
.constrained => .fg_constrained,
.powerline => .fg_powerline,
};
try self.cells.append(self.alloc, .{
.mode = mode,
.grid_col = @intCast(x),
.grid_row = @intCast(y),
.grid_width = cell.gridWidth(),
.glyph_x = render.glyph.atlas_x,
.glyph_y = render.glyph.atlas_y,
.glyph_width = render.glyph.width,
.glyph_height = render.glyph.height,
.glyph_offset_x = render.glyph.offset_x + shaper_cell.x_offset,
.glyph_offset_y = render.glyph.offset_y + shaper_cell.y_offset,
.r = colors.fg.r,
.g = colors.fg.g,
.b = colors.fg.b,
.a = alpha,
.bg_r = bg[0],
.bg_g = bg[1],
.bg_b = bg[2],
.bg_a = bg[3],
});
}
// If the cell has an underline, draw it before the character glyph,
// so that it layers underneath instead of overtop, since that can
// make text difficult to read.
if (underline != .none) {
const sprite: font.Sprite = switch (underline) {
.none => unreachable,
@@ -1851,6 +1808,58 @@ fn updateCell(
});
}
// If the shaper cell has a glyph, draw it.
if (shaper_cell.glyph_index) |glyph_index| glyph: {
// Render
const render = try self.font_grid.renderGlyph(
self.alloc,
shaper_run.font_index,
glyph_index,
.{
.grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken,
},
);
// If the glyph is 0 width or height, it will be invisible
// when drawn, so don't bother adding it to the buffer.
if (render.glyph.width == 0 or render.glyph.height == 0) {
break :glyph;
}
// If we're rendering a color font, we use the color atlas
const mode: CellProgram.CellMode = switch (try fgMode(
render.presentation,
cell_pin,
)) {
.normal => .fg,
.color => .fg_color,
.constrained => .fg_constrained,
.powerline => .fg_powerline,
};
try self.cells.append(self.alloc, .{
.mode = mode,
.grid_col = @intCast(x),
.grid_row = @intCast(y),
.grid_width = cell.gridWidth(),
.glyph_x = render.glyph.atlas_x,
.glyph_y = render.glyph.atlas_y,
.glyph_width = render.glyph.width,
.glyph_height = render.glyph.height,
.glyph_offset_x = render.glyph.offset_x + shaper_cell.x_offset,
.glyph_offset_y = render.glyph.offset_y + shaper_cell.y_offset,
.r = colors.fg.r,
.g = colors.fg.g,
.b = colors.fg.b,
.a = alpha,
.bg_r = bg[0],
.bg_g = bg[1],
.bg_b = bg[2],
.bg_a = bg[3],
});
}
if (style.flags.strikethrough) {
const render = try self.font_grid.renderGlyph(
self.alloc,

View File

@@ -224,30 +224,30 @@ vertex CellTextVertexOut cell_text_vertex(
out.color = float4(in.color) / 255.0f;
// === Grid Cell ===
//
// offset.x = bearings.x
// .|.
// | |
// +-------+_.
// ._| | |
// | | .###. | |
// | | #...# | +- bearings.y
// glyph_size.y -+ | ##### | |
// | | #.... | |
// ^ |_| .#### |_| _.
// | | | +- offset.y = cell_size.y - bearings.y
// . cell_pos -> +-------+ -'
// +Y. |_._|
// . |
// | glyph_size.x
// 0,0--...->
// +X
// 0,0--...->
// |
// . offset.x = bearings.x
// +Y. .|.
// . | |
// | cell_pos -> +-------+ _.
// v ._| |_. _|- offset.y = cell_size.y - bearings.y
// | | .###. | |
// | | #...# | |
// glyph_size.y -+ | ##### | |
// | | #.... | +- bearings.y
// |_| .#### | |
// | |_|
// +-------+
// |_._|
// |
// glyph_size.x
//
// In order to get the bottom left of the glyph, we compute an offset based
// on the bearings. The Y bearing is the distance from the top of the cell
// to the bottom of the glyph, so we subtract it from the cell height to get
// the y offset. The X bearing is the distance from the left of the cell to
// the left of the glyph, so it works as the x offset directly.
// In order to get the top left of the glyph, we compute an offset based on
// the bearings. The Y bearing is the distance from the bottom of the cell
// to the top of the glyph, so we subtract it from the cell height to get
// the y offset. The X bearing is the distance from the left of the cell
// to the left of the glyph, so it works as the x offset directly.
float2 size = float2(in.glyph_size);
float2 offset = float2(in.bearings);

View File

@@ -1694,7 +1694,14 @@ pub fn grow(self: *PageList) !?*List.Node {
// If allocation would exceed our max size, we prune the first page.
// We don't need to reallocate because we can simply reuse that first
// page.
if (self.page_size + PagePool.item_size > self.maxSize()) prune: {
//
// We only take this path if we have more than one page since pruning
// reuses the popped page. It is possible to have a single page and
// exceed the max size if that page was adjusted to be larger after
// initial allocation.
if (self.pages.len > 1 and
self.page_size + PagePool.item_size > self.maxSize())
prune: {
// If we need to add more memory to ensure our active area is
// satisfied then we do not prune.
if (self.growRequiredForActive()) break :prune;
@@ -3772,6 +3779,51 @@ test "PageList grow allows exceeding max size for active area" {
try testing.expectEqual(start_pages + 1, s.totalPages());
}
test "PageList grow prune required with a single page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 80, 24, 0);
defer s.deinit();
// This block is all test setup. There is nothing required about this
// behavior during a refactor. This is setting up a scenario that is
// possible to trigger a bug (#2280).
{
// Adjust our capacity until our page is larger than the standard size.
// This is important because it triggers a scenario where our calculated
// minSize() which is supposed to accommodate 2 pages is no longer true.
var cap = std_capacity;
while (true) {
cap.grapheme_bytes *= 2;
const layout = Page.layout(cap);
if (layout.total_size > std_size) break;
}
// Adjust to that capacity. After we should still have one page.
_ = try s.adjustCapacity(
s.pages.first.?,
.{ .grapheme_bytes = cap.grapheme_bytes },
);
try testing.expect(s.pages.first != null);
try testing.expect(s.pages.first == s.pages.last);
}
// Figure out the remaining number of rows. This is the amount that
// can be added to the current page before we need to allocate a new
// page.
const rem = rem: {
const page = s.pages.first.?;
break :rem page.data.capacity.rows - page.data.size.rows;
};
for (0..rem) |_| try testing.expect(try s.grow() == null);
// The next one we add will trigger a new page.
const new = try s.grow();
try testing.expect(new != null);
try testing.expect(new != s.pages.first);
}
test "PageList scroll top" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -122,6 +122,26 @@ test "garbage Kitty command" {
try testing.expect(h.end() == null);
}
test "Kitty command with overflow u32" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
h.start();
for ("Ga=p,i=10000000000") |c| h.feed(alloc, c);
try testing.expect(h.end() == null);
}
test "Kitty command with overflow i32" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
h.start();
for ("Ga=p,i=1,z=-9999999999") |c| h.feed(alloc, c);
try testing.expect(h.end() == null);
}
test "valid Kitty command" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -23,10 +23,10 @@ pub const Parser = struct {
/// This is the list of KV pairs that we're building up.
kv: KV = .{},
/// This is used as a buffer to store the key/value of a KV pair.
/// The value of a KV pair is at most a 32-bit integer which at most
/// is 10 characters (4294967295).
kv_temp: [10]u8 = undefined,
/// This is used as a buffer to store the key/value of a KV pair. The value
/// of a KV pair is at most a 32-bit integer which at most is 10 characters
/// (4294967295), plus one character for the sign bit on signed ints.
kv_temp: [11]u8 = undefined,
kv_temp_len: u4 = 0,
kv_current: u8 = 0, // Current kv key
@@ -237,16 +237,14 @@ pub const Parser = struct {
}
}
// Only "z" is currently signed. This is a bit of a kloodge; if more
// fields become signed we can rethink this but for now we parse
// "z" as i32 then bitcast it to u32 then bitcast it back later.
if (self.kv_current == 'z') {
const v = try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10);
try self.kv.put(alloc, self.kv_current, @bitCast(v));
} else {
const v = try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10);
try self.kv.put(alloc, self.kv_current, v);
}
// Handle integer fields, parsing signed fields accordingly. We still
// store the fields as u32 as they can be bitcast back later during
// building of the higher-level command tree.
const v: u32 = switch (self.kv_current) {
'z', 'H', 'V' => @bitCast(try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10)),
else => try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10),
};
try self.kv.put(alloc, self.kv_current, v);
// Clear our temp buffer
self.kv_temp_len = 0;
@@ -505,8 +503,8 @@ pub const Display = struct {
virtual_placement: bool = false, // U
parent_id: u32 = 0, // P
parent_placement_id: u32 = 0, // Q
horizontal_offset: u32 = 0, // H
vertical_offset: u32 = 0, // V
horizontal_offset: i32 = 0, // H
vertical_offset: i32 = 0, // V
z: i32 = 0, // z
pub const CursorMovement = enum {
@@ -591,11 +589,13 @@ pub const Display = struct {
}
if (kv.get('H')) |v| {
result.horizontal_offset = v;
// We can bitcast here because of how we parse it earlier.
result.horizontal_offset = @bitCast(v);
}
if (kv.get('V')) |v| {
result.vertical_offset = v;
// We can bitcast here because of how we parse it earlier.
result.vertical_offset = @bitCast(v);
}
return result;
@@ -1069,6 +1069,95 @@ test "ignore very long values" {
try testing.expectEqual(@as(u32, 0), v.height);
}
test "ensure very large negative values don't get skipped" {
const testing = std.testing;
const alloc = testing.allocator;
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=1,z=-2000000000";
for (input) |c| try p.feed(c);
const command = try p.complete();
defer command.deinit(alloc);
try testing.expect(command.control == .display);
const v = command.control.display;
try testing.expectEqual(1, v.image_id);
try testing.expectEqual(-2000000000, v.z);
}
test "ensure proper overflow error for u32" {
const testing = std.testing;
const alloc = testing.allocator;
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=10000000000";
for (input) |c| try p.feed(c);
try testing.expectError(error.Overflow, p.complete());
}
test "ensure proper overflow error for i32" {
const testing = std.testing;
const alloc = testing.allocator;
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=1,z=-9999999999";
for (input) |c| try p.feed(c);
try testing.expectError(error.Overflow, p.complete());
}
test "all i32 values" {
const testing = std.testing;
const alloc = testing.allocator;
{
// 'z' (usually z-axis values)
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=1,z=-1";
for (input) |c| try p.feed(c);
const command = try p.complete();
defer command.deinit(alloc);
try testing.expect(command.control == .display);
const v = command.control.display;
try testing.expectEqual(1, v.image_id);
try testing.expectEqual(-1, v.z);
}
{
// 'H' (relative placement, horizontal offset)
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=1,H=-1";
for (input) |c| try p.feed(c);
const command = try p.complete();
defer command.deinit(alloc);
try testing.expect(command.control == .display);
const v = command.control.display;
try testing.expectEqual(1, v.image_id);
try testing.expectEqual(-1, v.horizontal_offset);
}
{
// 'V' (relative placement, vertical offset)
var p = Parser.init(alloc);
defer p.deinit();
const input = "a=p,i=1,V=-1";
for (input) |c| try p.feed(c);
const command = try p.complete();
defer command.deinit(alloc);
try testing.expect(command.control == .display);
const v = command.control.display;
try testing.expectEqual(1, v.image_id);
try testing.expectEqual(-1, v.vertical_offset);
}
}
test "response: encode nothing without ID or image number" {
const testing = std.testing;
var buf: [1024]u8 = undefined;

View File

@@ -495,3 +495,37 @@ test "kittygfx default format is rgba" {
const img = storage.imageById(1).?;
try testing.expectEqual(command.Transmission.Format.rgba, img.format);
}
test "kittygfx test valid u32 (expect invalid image ID)" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const cmd = try command.Parser.parseString(
alloc,
"a=p,i=4294967295",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqual(resp.message, "ENOENT: image not found");
}
test "kittygfx test valid i32 (expect invalid image ID)" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
defer t.deinit(alloc);
const cmd = try command.Parser.parseString(
alloc,
"a=p,i=1,z=-2147483648",
);
defer cmd.deinit(alloc);
const resp = execute(alloc, &t, &cmd).?;
try testing.expect(!resp.ok());
try testing.expectEqual(resp.message, "ENOENT: image not found");
}

View File

@@ -165,6 +165,8 @@ pub const Parser = struct {
9 => return Attribute{ .strikethrough = {} },
21 => return Attribute{ .underline = .double },
22 => return Attribute{ .reset_bold = {} },
23 => return Attribute{ .reset_italic = {} },

View File

@@ -154,7 +154,7 @@ pub fn threadEnter(
processExit,
);
// Start our termios timer. We only support this on Windows.
// Start our termios timer. We don't support this on Windows.
// Fundamentally, we could support this on Windows so we're just
// waiting for someone to implement it.
if (comptime builtin.os.tag != .windows) {
@@ -1257,9 +1257,19 @@ const Subprocess = struct {
// descendents are well and truly dead. We will not rest
// until the entire family tree is obliterated.
while (true) {
if (c.killpg(pgid, c.SIGHUP) < 0) {
log.warn("error killing process group pgid={}", .{pgid});
return error.KillFailed;
switch (posix.errno(c.killpg(pgid, c.SIGHUP))) {
.SUCCESS => log.debug("process group killed pgid={}", .{pgid}),
else => |err| killpg: {
if ((comptime builtin.target.isDarwin()) and
err == .PERM)
{
log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{});
break :killpg;
}
log.warn("error killing process group pgid={} err={}", .{ pgid, err });
return error.KillFailed;
},
}
// See Command.zig wait for why we specify WNOHANG.
@@ -1267,6 +1277,7 @@ const Subprocess = struct {
// are still alive without blocking so that we can
// kill them again.
const res = posix.waitpid(pid, std.c.W.NOHANG);
log.debug("waitpid result={}", .{res.pid});
if (res.pid != 0) break;
std.time.sleep(10 * std.time.ns_per_ms);
}