Merge branch 'grapheme-break' into grapheme-width-changes

This commit is contained in:
Jacob Sandlund
2025-12-09 08:45:54 -05:00
36 changed files with 2630 additions and 593 deletions

View File

@@ -155,6 +155,9 @@ command_timer: ?std.time.Instant = null,
/// Search state
search: ?Search = null,
/// Used to rate limit BEL handling.
last_bell_time: ?std.time.Instant = null,
/// The effect of an input event. This can be used by callers to take
/// the appropriate action after an input event. For example, key
/// input can be forwarded to the OS for further processing if it
@@ -1026,7 +1029,12 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.password_input => |v| try self.passwordInput(v),
.ring_bell => {
.ring_bell => bell: {
const now = std.time.Instant.now() catch unreachable;
if (self.last_bell_time) |last| {
if (now.since(last) < 100 * std.time.ns_per_ms) break :bell;
}
self.last_bell_time = now;
_ = self.rt_app.performAction(
.{ .surface = self },
.ring_bell,
@@ -2584,6 +2592,8 @@ pub fn keyCallback(
{
// Refresh our link state
const pos = self.rt_surface.getCursorPos() catch break :mouse_mods;
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
self.mouseRefreshLinks(
pos,
self.posToViewport(pos.x, pos.y),
@@ -3464,6 +3474,8 @@ fn mouseReport(
.five => 65,
.six => 66,
.seven => 67,
.eight => 128,
.nine => 129,
else => return, // unsupported
};
}
@@ -5020,8 +5032,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
},
.copy_to_clipboard => |format| {
// We can read from the renderer state without holding
// the lock because only we will write to this field.
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
if (self.io.terminal.screens.active.selection) |sel| {
try self.copySelectionToClipboards(
sel,
@@ -5049,8 +5062,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.copy_url_to_clipboard => {
// If the mouse isn't over a link, nothing we can do.
if (!self.mouse.over_link) return false;
const pos = try self.rt_surface.getCursorPos();
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
if (try self.linkAtPos(pos)) |link_info| {
const url_text = switch (link_info[0]) {
.open => url_text: {
@@ -5426,6 +5441,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
),
.select_all => {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
const sel = self.io.terminal.screens.active.selectAll();
if (sel) |s| {
try self.setSelection(s);

View File

@@ -43,6 +43,7 @@ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 5, .name = "inspector-widget" },
.{ .major = 1, .minor = 5, .name = "inspector-window" },
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
.{ .major = 1, .minor = 2, .name = "search-overlay" },
.{ .major = 1, .minor = 5, .name = "split-tree" },
.{ .major = 1, .minor = 5, .name = "split-tree-split" },
.{ .major = 1, .minor = 2, .name = "surface" },

View File

@@ -727,6 +727,11 @@ pub const Application = extern struct {
.show_on_screen_keyboard => return Action.showOnScreenKeyboard(target),
.command_finished => return Action.commandFinished(target, value),
.start_search => Action.startSearch(target),
.end_search => Action.endSearch(target),
.search_total => Action.searchTotal(target, value),
.search_selected => Action.searchSelected(target, value),
// Unimplemented
.secure_input,
.close_all_windows,
@@ -741,10 +746,6 @@ pub const Application = extern struct {
.check_for_updates,
.undo,
.redo,
.start_search,
.end_search,
.search_total,
.search_selected,
=> {
log.warn("unimplemented action={}", .{action});
return false;
@@ -2341,6 +2342,34 @@ const Action = struct {
}
}
pub fn startSearch(target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchActive(true),
}
}
pub fn endSearch(target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchActive(false),
}
}
pub fn searchTotal(target: apprt.Target, value: apprt.action.SearchTotal) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchTotal(value.total),
}
}
pub fn searchSelected(target: apprt.Target, value: apprt.action.SearchSelected) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchSelected(value.selected),
}
}
pub fn setTitle(
target: apprt.Target,
value: apprt.action.SetTitle,

View File

@@ -0,0 +1,486 @@
const std = @import("std");
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");
const gdk = @import("gdk");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const log = std.log.scoped(.gtk_ghostty_search_overlay);
/// The overlay that shows the current size while a surface is resizing.
/// This can be used generically to show pretty much anything with a
/// disappearing overlay, but we have no other use at this point so it
/// is named specifically for what it does.
///
/// General usage:
///
/// 1. Add it to an overlay
/// 2. Set the label with `setLabel`
/// 3. Schedule to show it with `schedule`
///
/// Set any properties to change the behavior.
pub const SearchOverlay = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttySearchOverlay",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const active = struct {
pub const name = "active";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{
.getter = getSearchActive,
.setter = setSearchActive,
},
),
},
);
};
pub const @"search-total" = struct {
pub const name = "search-total";
const impl = gobject.ext.defineProperty(
name,
Self,
u64,
.{
.default = 0,
.minimum = 0,
.maximum = std.math.maxInt(u64),
.accessor = gobject.ext.typedAccessor(
Self,
u64,
.{ .getter = getSearchTotal },
),
},
);
};
pub const @"has-search-total" = struct {
pub const name = "has-search-total";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{ .getter = getHasSearchTotal },
),
},
);
};
pub const @"search-selected" = struct {
pub const name = "search-selected";
const impl = gobject.ext.defineProperty(
name,
Self,
u64,
.{
.default = 0,
.minimum = 0,
.maximum = std.math.maxInt(u64),
.accessor = gobject.ext.typedAccessor(
Self,
u64,
.{ .getter = getSearchSelected },
),
},
);
};
pub const @"has-search-selected" = struct {
pub const name = "has-search-selected";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{ .getter = getHasSearchSelected },
),
},
);
};
pub const @"halign-target" = struct {
pub const name = "halign-target";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.Align,
.{
.default = .end,
.accessor = C.privateShallowFieldAccessor("halign_target"),
},
);
};
pub const @"valign-target" = struct {
pub const name = "valign-target";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.Align,
.{
.default = .start,
.accessor = C.privateShallowFieldAccessor("valign_target"),
},
);
};
};
pub const signals = struct {
/// Emitted when the search is stopped (e.g., Escape pressed).
pub const @"stop-search" = struct {
pub const name = "stop-search";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
/// Emitted when the search text changes (debounced).
pub const @"search-changed" = struct {
pub const name = "search-changed";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{?[*:0]const u8},
void,
);
};
/// Emitted when navigating to the next match.
pub const @"next-match" = struct {
pub const name = "next-match";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
/// Emitted when navigating to the previous match.
pub const @"previous-match" = struct {
pub const name = "previous-match";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
};
const Private = struct {
/// The search entry widget.
search_entry: *gtk.SearchEntry,
/// True when a search is active, meaning we should show the overlay.
active: bool = false,
/// Total number of search matches (null means unknown/none).
search_total: ?usize = null,
/// Currently selected match index (null means none selected).
search_selected: ?usize = null,
/// Target horizontal alignment for the overlay.
halign_target: gtk.Align = .end,
/// Target vertical alignment for the overlay.
valign_target: gtk.Align = .start,
pub var offset: c_int = 0;
};
fn init(self: *Self, _: *Class) callconv(.c) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
/// Grab focus on the search entry and select all text.
pub fn grabFocus(self: *Self) void {
const priv = self.private();
_ = priv.search_entry.as(gtk.Widget).grabFocus();
// Select all text in the search entry field. -1 is distance from
// the end, causing the entire text to be selected.
priv.search_entry.as(gtk.Editable).selectRegion(0, -1);
}
// Set active status, and update search on activation
fn setSearchActive(self: *Self, active: bool) void {
const priv = self.private();
if (!priv.active and active) {
const text = priv.search_entry.as(gtk.Editable).getText();
signals.@"search-changed".impl.emit(self, null, .{text}, null);
}
priv.active = active;
}
/// Set the total number of search matches.
pub fn setSearchTotal(self: *Self, total: ?usize) void {
const priv = self.private();
const had_total = priv.search_total != null;
if (priv.search_total == total) return;
priv.search_total = total;
self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec);
if (had_total != (total != null)) {
self.as(gobject.Object).notifyByPspec(properties.@"has-search-total".impl.param_spec);
}
}
/// Set the currently selected match index.
pub fn setSearchSelected(self: *Self, selected: ?usize) void {
const priv = self.private();
const had_selected = priv.search_selected != null;
if (priv.search_selected == selected) return;
priv.search_selected = selected;
self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec);
if (had_selected != (selected != null)) {
self.as(gobject.Object).notifyByPspec(properties.@"has-search-selected".impl.param_spec);
}
}
fn getSearchActive(self: *Self) bool {
return self.private().active;
}
fn getSearchTotal(self: *Self) u64 {
return self.private().search_total orelse 0;
}
fn getHasSearchTotal(self: *Self) bool {
return self.private().search_total != null;
}
fn getSearchSelected(self: *Self) u64 {
return self.private().search_selected orelse 0;
}
fn getHasSearchSelected(self: *Self) bool {
return self.private().search_selected != null;
}
fn closureMatchLabel(
_: *Self,
has_selected: bool,
selected: u64,
has_total: bool,
total: u64,
) callconv(.c) ?[*:0]const u8 {
if (!has_total or total == 0) return glib.ext.dupeZ(u8, "0/0");
var buf: [32]u8 = undefined;
const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{
if (has_selected) selected + 1 else 0,
total,
}) catch return null;
return glib.ext.dupeZ(u8, label);
}
//---------------------------------------------------------------
// Template callbacks
fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void {
const text = entry.as(gtk.Editable).getText();
signals.@"search-changed".impl.emit(self, null, .{text}, null);
}
// NOTE: The callbacks below use anyopaque for the first parameter
// because they're shared with multiple widgets in the template.
fn stopSearch(_: *anyopaque, self: *Self) callconv(.c) void {
signals.@"stop-search".impl.emit(self, null, .{}, null);
}
fn nextMatch(_: *anyopaque, self: *Self) callconv(.c) void {
signals.@"next-match".impl.emit(self, null, .{}, null);
}
fn previousMatch(_: *anyopaque, self: *Self) callconv(.c) void {
signals.@"previous-match".impl.emit(self, null, .{}, null);
}
fn searchEntryKeyPressed(
_: *gtk.EventControllerKey,
keyval: c_uint,
_: c_uint,
gtk_mods: gdk.ModifierType,
self: *Self,
) callconv(.c) c_int {
if (keyval == gdk.KEY_Return or keyval == gdk.KEY_KP_Enter) {
if (gtk_mods.shift_mask) {
signals.@"previous-match".impl.emit(self, null, .{}, null);
} else {
signals.@"next-match".impl.emit(self, null, .{}, null);
}
return 1;
}
return 0;
}
fn onDragEnd(
_: *gtk.GestureDrag,
offset_x: f64,
offset_y: f64,
self: *Self,
) callconv(.c) void {
// On drag end, we want to move our halign/valign if we crossed
// the midpoint on either axis. This lets the search overlay be
// moved to different corners of the parent container.
const priv = self.private();
const widget = self.as(gtk.Widget);
const parent = widget.getParent() orelse return;
const parent_width: f64 = @floatFromInt(parent.getAllocatedWidth());
const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight());
const self_width: f64 = @floatFromInt(widget.getAllocatedWidth());
const self_height: f64 = @floatFromInt(widget.getAllocatedHeight());
const self_x: f64 = if (priv.halign_target == .start) 0 else parent_width - self_width;
const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height;
const new_x = self_x + offset_x + (self_width / 2);
const new_y = self_y + offset_y + (self_height / 2);
const new_halign: gtk.Align = if (new_x > parent_width / 2) .end else .start;
const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start;
var changed = false;
if (new_halign != priv.halign_target) {
priv.halign_target = new_halign;
self.as(gobject.Object).notifyByPspec(properties.@"halign-target".impl.param_spec);
changed = true;
}
if (new_valign != priv.valign_target) {
priv.valign_target = new_valign;
self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec);
changed = true;
}
if (changed) self.as(gtk.Widget).queueResize();
}
//---------------------------------------------------------------
// Virtual methods
fn dispose(self: *Self) callconv(.c) void {
const priv = self.private();
_ = priv;
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
fn finalize(self: *Self) callconv(.c) void {
const priv = self.private();
_ = priv;
gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
);
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const unref = C.unref;
const private = C.private;
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.c) void {
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 2,
.name = "search-overlay",
}),
);
// Bindings
class.bindTemplateChildPrivate("search_entry", .{});
// Template Callbacks
class.bindTemplateCallback("stop_search", &stopSearch);
class.bindTemplateCallback("search_changed", &searchChanged);
class.bindTemplateCallback("match_label_closure", &closureMatchLabel);
class.bindTemplateCallback("next_match", &nextMatch);
class.bindTemplateCallback("previous_match", &previousMatch);
class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed);
class.bindTemplateCallback("on_drag_end", &onDragEnd);
// Properties
gobject.ext.registerProperties(class, &.{
properties.active.impl,
properties.@"search-total".impl,
properties.@"has-search-total".impl,
properties.@"search-selected".impl,
properties.@"has-search-selected".impl,
properties.@"halign-target".impl,
properties.@"valign-target".impl,
});
// Signals
signals.@"stop-search".impl.register(.{});
signals.@"search-changed".impl.register(.{});
signals.@"next-match".impl.register(.{});
signals.@"previous-match".impl.register(.{});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};

View File

@@ -25,6 +25,7 @@ const Common = @import("../class.zig").Common;
const Application = @import("application.zig").Application;
const Config = @import("config.zig").Config;
const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay;
const SearchOverlay = @import("search_overlay.zig").SearchOverlay;
const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog;
@@ -549,6 +550,9 @@ pub const Surface = extern struct {
/// The resize overlay
resize_overlay: *ResizeOverlay,
/// The search overlay
search_overlay: *SearchOverlay,
/// The apprt Surface.
rt_surface: ApprtSurface = undefined,
@@ -1135,13 +1139,14 @@ pub const Surface = extern struct {
if (entry.native == keycode) break :w3c entry.key;
} else .unidentified;
// If the key should be remappable, then consult the pre-remapped
// XKB keyval/keysym to get the (possibly) remapped key.
// Consult the pre-remapped XKB keyval/keysym to get the (possibly)
// remapped key. If the W3C key or the remapped key
// is eligible for remapping, we use it.
//
// See the docs for `shouldBeRemappable` for why we even have to
// do this in the first place.
if (w3c_key.shouldBeRemappable()) {
if (gtk_key.keyFromKeyval(keyval)) |remapped|
if (gtk_key.keyFromKeyval(keyval)) |remapped| {
if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable())
break :keycode remapped;
}
@@ -1951,6 +1956,29 @@ pub const Surface = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec);
}
pub fn setSearchActive(self: *Self, active: bool) void {
const priv = self.private();
var value = gobject.ext.Value.newFrom(active);
defer value.unset();
gobject.Object.setProperty(
priv.search_overlay.as(gobject.Object),
SearchOverlay.properties.active.name,
&value,
);
if (active) {
priv.search_overlay.grabFocus();
}
}
pub fn setSearchTotal(self: *Self, total: ?usize) void {
self.private().search_overlay.setSearchTotal(total);
}
pub fn setSearchSelected(self: *Self, selected: ?usize) void {
self.private().search_overlay.setSearchSelected(selected);
}
fn propConfig(
self: *Self,
_: *gobject.ParamSpec,
@@ -3170,6 +3198,35 @@ pub const Surface = extern struct {
self.setTitleOverride(if (title.len == 0) null else title);
}
fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.end_search) catch |err| {
log.warn("unable to perform end_search action err={}", .{err});
};
_ = self.private().gl_area.as(gtk.Widget).grabFocus();
}
fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.{ .search = std.mem.sliceTo(needle orelse "", 0) }) catch |err| {
log.warn("unable to perform search action err={}", .{err});
};
}
fn searchNextMatch(_: *SearchOverlay, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.{ .navigate_search = .next }) catch |err| {
log.warn("unable to perform navigate_search action err={}", .{err});
};
}
fn searchPreviousMatch(_: *SearchOverlay, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.{ .navigate_search = .previous }) catch |err| {
log.warn("unable to perform navigate_search action err={}", .{err});
};
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
@@ -3184,6 +3241,7 @@ pub const Surface = extern struct {
fn init(class: *Class) callconv(.c) void {
gobject.ext.ensureType(ResizeOverlay);
gobject.ext.ensureType(SearchOverlay);
gobject.ext.ensureType(ChildExited);
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
@@ -3203,6 +3261,7 @@ pub const Surface = extern struct {
class.bindTemplateChildPrivate("error_page", .{});
class.bindTemplateChildPrivate("progress_bar_overlay", .{});
class.bindTemplateChildPrivate("resize_overlay", .{});
class.bindTemplateChildPrivate("search_overlay", .{});
class.bindTemplateChildPrivate("terminal_page", .{});
class.bindTemplateChildPrivate("drop_target", .{});
class.bindTemplateChildPrivate("im_context", .{});
@@ -3240,6 +3299,10 @@ pub const Surface = extern struct {
class.bindTemplateCallback("notify_vadjustment", &propVAdjustment);
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown);
class.bindTemplateCallback("search_stop", &searchStop);
class.bindTemplateCallback("search_changed", &searchChanged);
class.bindTemplateCallback("search_next_match", &searchNextMatch);
class.bindTemplateCallback("search_previous_match", &searchPreviousMatch);
// Properties
gobject.ext.registerProperties(class, &.{

View File

@@ -34,6 +34,18 @@ label.url-overlay.right {
border-radius: 6px 0px 0px 0px;
}
/*
* GhosttySurface search overlay
*/
.search-overlay {
padding: 6px 8px;
margin: 8px;
border-radius: 8px;
outline-style: solid;
outline-color: #555555;
outline-width: 1px;
}
/*
* GhosttySurface resize overlay
*/

View File

@@ -0,0 +1,94 @@
using Gtk 4.0;
using Gdk 4.0;
using Adw 1;
template $GhosttySearchOverlay: Adw.Bin {
visible: bind template.active;
halign-target: end;
valign-target: start;
halign: bind template.halign-target;
valign: bind template.valign-target;
GestureDrag {
button: 1;
propagation-phase: capture;
drag-end => $on_drag_end();
}
Adw.Bin {
Box container {
styles [
"background",
"search-overlay",
]
orientation: horizontal;
spacing: 6;
SearchEntry search_entry {
placeholder-text: _("Find…");
width-chars: 20;
hexpand: true;
stop-search => $stop_search();
search-changed => $search_changed();
next-match => $next_match();
previous-match => $previous_match();
EventControllerKey {
// We need this so we capture before the SearchEntry.
propagation-phase: capture;
key-pressed => $search_entry_key_pressed();
}
}
Label {
styles [
"dim-label",
]
label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as <string>;
width-chars: 6;
xalign: 1.0;
}
Box button_box {
orientation: horizontal;
spacing: 1;
styles [
"linked",
]
Button prev_button {
icon-name: "go-up-symbolic";
tooltip-text: _("Previous Match");
clicked => $next_match();
cursor: Gdk.Cursor {
name: "pointer";
};
}
Button next_button {
icon-name: "go-down-symbolic";
tooltip-text: _("Next Match");
clicked => $previous_match();
cursor: Gdk.Cursor {
name: "pointer";
};
}
}
Button close_button {
icon-name: "window-close-symbolic";
tooltip-text: _("Close");
clicked => $stop_search();
cursor: Gdk.Cursor {
name: "pointer";
};
}
}
}
}

View File

@@ -41,6 +41,34 @@ Overlay terminal_page {
halign: start;
has-arrow: false;
}
EventControllerFocus {
enter => $focus_enter();
leave => $focus_leave();
}
EventControllerKey {
key-pressed => $key_pressed();
key-released => $key_released();
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
}
EventControllerMotion {
motion => $mouse_motion();
leave => $mouse_leave();
}
GestureClick {
pressed => $mouse_down();
released => $mouse_up();
button: 0;
}
};
[overlay]
@@ -64,6 +92,10 @@ Overlay terminal_page {
reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as <bool>;
transition-type: crossfade;
transition-duration: 500;
// Revealers take up the full size, we need this to not capture events.
can-focus: false;
can-target: false;
focusable: false;
Box bell_overlay {
styles [
@@ -115,12 +147,26 @@ Overlay terminal_page {
label: bind template.mouse-hover-url;
}
[overlay]
$GhosttySearchOverlay search_overlay {
stop-search => $search_stop();
search-changed => $search_changed();
next-match => $search_next_match();
previous-match => $search_previous_match();
}
[overlay]
// Apply unfocused-split-fill and unfocused-split-opacity to current surface
// this is only applied when a tab has more than one surface
Revealer {
reveal-child: bind $should_unfocused_split_be_shown(template.focused, template.is-split) as <bool>;
transition-duration: 0;
// This is all necessary so that the Revealer itself doesn't override
// any input events from the other overlays. Namely, if you don't have
// these then the search overlay won't get mouse events.
can-focus: false;
can-target: false;
focusable: false;
DrawingArea {
styles [
@@ -129,35 +175,6 @@ Overlay terminal_page {
}
}
// Event controllers for interactivity
EventControllerFocus {
enter => $focus_enter();
leave => $focus_leave();
}
EventControllerKey {
key-pressed => $key_pressed();
key-released => $key_released();
}
EventControllerMotion {
motion => $mouse_motion();
leave => $mouse_leave();
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
}
GestureClick {
pressed => $mouse_down();
released => $mouse_up();
button: 0;
}
DropTarget drop_target {
drop => $drop();
actions: copy;

View File

@@ -170,11 +170,11 @@ pub const Resource = struct {
/// Returns true if the dist path exists at build time.
pub fn exists(self: *const Resource, b: *std.Build) bool {
if (std.fs.accessAbsolute(b.pathFromRoot(self.dist), .{})) {
if (b.build_root.handle.access(self.dist, .{})) {
// If we have a ".git" directory then we're a git checkout
// and we never want to use the dist path. This shouldn't happen
// so show a warning to the user.
if (std.fs.accessAbsolute(b.pathFromRoot(".git"), .{})) {
if (b.build_root.handle.access(".git", .{})) {
std.log.warn(
"dist resource '{s}' should not be in a git checkout",
.{self.dist},

View File

@@ -6098,6 +6098,20 @@ pub const Keybinds = struct {
.{ .jump_to_prompt = 1 },
);
// Search
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'f' }, .mods = .{ .ctrl = true, .shift = true } },
.start_search,
.{ .performable = true },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .physical = .escape } },
.end_search,
.{ .performable = true },
);
// Inspector, matching Chromium
try self.set.put(
alloc,
@@ -7973,7 +7987,8 @@ pub const QuickTerminalSize = struct {
tag: Tag,
value: Value,
pub const Tag = enum(u8) { none, percentage, pixels };
/// c_int because it needs to be extern compatible
pub const Tag = enum(c_int) { none, percentage, pixels };
pub const Value = extern union {
percentage: f32,

View File

@@ -69,6 +69,7 @@ test {
_ = i18n;
_ = path;
_ = uri;
_ = shell;
if (comptime builtin.os.tag == .linux) {
_ = kernel_info;

View File

@@ -5,8 +5,6 @@ const Writer = std.Io.Writer;
/// Writer that escapes characters that shells treat specially to reduce the
/// risk of injection attacks or other such weirdness. Specifically excludes
/// linefeeds so that they can be used to delineate lists of file paths.
///
/// T should be a Zig type that follows the `std.Io.Writer` interface.
pub const ShellEscapeWriter = struct {
writer: Writer,
child: *Writer,
@@ -33,7 +31,7 @@ pub const ShellEscapeWriter = struct {
var count: usize = 0;
for (data[0 .. data.len - 1]) |chunk| try self.writeEscaped(chunk, &count);
for (0..splat) |_| try self.writeEscaped(data[data.len], &count);
for (0..splat) |_| try self.writeEscaped(data[data.len - 1], &count);
return count;
}
@@ -67,7 +65,7 @@ pub const ShellEscapeWriter = struct {
test "shell escape 1" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("abc");
try testing.expectEqualStrings("abc", writer.buffered());
}
@@ -75,7 +73,7 @@ test "shell escape 1" {
test "shell escape 2" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a c");
try testing.expectEqualStrings("a\\ c", writer.buffered());
}
@@ -83,7 +81,7 @@ test "shell escape 2" {
test "shell escape 3" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a?c");
try testing.expectEqualStrings("a\\?c", writer.buffered());
}
@@ -91,7 +89,7 @@ test "shell escape 3" {
test "shell escape 4" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a\\c");
try testing.expectEqualStrings("a\\\\c", writer.buffered());
}
@@ -99,7 +97,7 @@ test "shell escape 4" {
test "shell escape 5" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a|c");
try testing.expectEqualStrings("a\\|c", writer.buffered());
}
@@ -107,7 +105,7 @@ test "shell escape 5" {
test "shell escape 6" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a\"c");
try testing.expectEqualStrings("a\\\"c", writer.buffered());
}
@@ -115,7 +113,7 @@ test "shell escape 6" {
test "shell escape 7" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a(1)");
try testing.expectEqualStrings("a\\(1\\)", writer.buffered());
}

View File

@@ -7,6 +7,7 @@
//! [1]: https://github.com/WebKit/WebKit/blob/main/Source/WebCore/page/Quirks.cpp
const std = @import("std");
const builtin = @import("builtin");
const font = @import("font/main.zig");
@@ -41,6 +42,16 @@ pub fn disableDefaultFontFeatures(face: *const font.Face) bool {
/// is negligible, but we have some asserts inside tight loops and hotpaths
/// that cause significant overhead (as much as 15-20%) when they don't get
/// optimized out.
pub inline fn inlineAssert(ok: bool) void {
if (!ok) unreachable;
}
pub const inlineAssert = switch (builtin.mode) {
// In debug builds we just use std.debug.assert because this
// fixes up stack traces. `inline` causes broken stack traces. This
// is probably a Zig compiler bug but until it is fixed we have to
// do this for development sanity.
.Debug => std.debug.assert,
.ReleaseSmall, .ReleaseSafe, .ReleaseFast => (struct {
inline fn assert(ok: bool) void {
if (!ok) unreachable;
}
}).assert,
};

View File

@@ -83,8 +83,10 @@ from the `zsh` directory. The existing `ZDOTDIR` is retained so that
after loading the Ghostty shell integration the normal Zsh loading
sequence occurs.
```bash
```zsh
if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then
source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration
fi
```
Shell integration requires Zsh 5.1+.

View File

@@ -15,11 +15,16 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# This script is sourced automatically by zsh when ZDOTDIR is set to this
# directory. It therefore assumes it's running within our shell integration
# environment and should not be sourced manually (unlike ghostty-integration).
#
# This file can get sourced with aliases enabled. To avoid alias expansion
# we quote everything that can be quoted. Some aliases will still break us
# though.
# Restore the original ZDOTDIR value.
# Restore the original ZDOTDIR value if GHOSTTY_ZSH_ZDOTDIR is set.
# Otherwise, unset the ZDOTDIR that was set during shell injection.
if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then
'builtin' 'export' ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR"
'builtin' 'unset' 'GHOSTTY_ZSH_ZDOTDIR'
@@ -43,12 +48,6 @@ fi
[[ ! -r "$_ghostty_file" ]] || 'builtin' 'source' '--' "$_ghostty_file"
} always {
if [[ -o 'interactive' ]]; then
'builtin' 'autoload' '--' 'is-at-least'
'is-at-least' "5.1" || {
builtin echo "ZSH ${ZSH_VERSION} is too old for ghostty shell integration" > /dev/stderr
'builtin' 'unset' '_ghostty_file'
return
}
# ${(%):-%x} is the path to the current file.
# On top of it we add :A:h to get the directory.
'builtin' 'typeset' _ghostty_file="${${(%):-%x}:A:h}"/ghostty-integration

View File

@@ -1,3 +1,5 @@
# vim:ft=zsh
#
# Based on (started as) a copy of Kitty's zsh integration. Kitty is
# distributed under GPLv3, so this file is also distributed under GPLv3.
# The license header is reproduced below:
@@ -41,6 +43,13 @@ _entrypoint() {
[[ -o interactive ]] || builtin return 0 # non-interactive shell
(( ! $+_ghostty_state )) || builtin return 0 # already initialized
# We require zsh 5.1+ (released Sept 2015) for features like functions_source,
# introspection arrays, and array pattern substitution.
if ! { builtin autoload -- is-at-least 2>/dev/null && is-at-least 5.1; }; then
builtin echo "Zsh ${ZSH_VERSION} is too old for ghostty shell integration (5.1+ required)" >&2
builtin return 1
fi
# 0: no OSC 133 [AC] marks have been written yet.
# 1: the last written OSC 133 C has not been closed with D yet.
# 2: none of the above.

View File

@@ -2608,7 +2608,9 @@ pub fn adjustCapacity(
errdefer self.destroyNode(new_node);
const new_page: *Page = &new_node.data;
assert(new_page.capacity.rows >= page.capacity.rows);
assert(new_page.capacity.cols >= page.capacity.cols);
new_page.size.rows = page.size.rows;
new_page.size.cols = page.size.cols;
try new_page.cloneFrom(page, 0, page.size.rows);
// Fix up all our tracked pins to point to the new page.
@@ -6257,6 +6259,39 @@ test "PageList adjustCapacity to increase hyperlinks" {
}
}
test "PageList adjustCapacity after col shrink" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 2, 0);
defer s.deinit();
// Shrink columns - this updates size.cols but not capacity.cols
try s.resize(.{ .cols = 5, .reflow = false });
try testing.expectEqual(5, s.cols);
{
const page = &s.pages.first.?.data;
// capacity.cols is still 10, but size.cols should be 5
try testing.expectEqual(5, page.size.cols);
try testing.expect(page.capacity.cols >= 10);
}
// Now adjust capacity (e.g., to increase styles)
// This should preserve the current size.cols, not revert to capacity.cols
_ = try s.adjustCapacity(
s.pages.first.?,
.{ .styles = std_capacity.styles * 2 },
);
{
const page = &s.pages.first.?.data;
// After adjustCapacity, size.cols should still be 5, not 10
try testing.expectEqual(5, page.size.cols);
try testing.expectEqual(5, s.cols);
}
}
test "PageList pageIterator single page" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -26,7 +26,7 @@ pub const Handler = struct {
assert(self.state == .inactive);
// Initialize our state to ignore in case of error
self.state = .{ .ignore = {} };
self.state = .ignore;
// Try to parse the hook.
const hk_ = self.tryHook(alloc, dcs) catch |err| {
@@ -70,7 +70,7 @@ pub const Handler = struct {
),
},
},
.command = .{ .tmux = .{ .enter = {} } },
.command = .{ .tmux = .enter },
};
},
@@ -116,7 +116,7 @@ pub const Handler = struct {
// On error we just discard our state and ignore the rest
log.info("error putting byte into DCS handler err={}", .{err});
self.discard();
self.state = .{ .ignore = {} };
self.state = .ignore;
return null;
};
}
@@ -158,7 +158,7 @@ pub const Handler = struct {
// Note: we do NOT call deinit here on purpose because some commands
// transfer memory ownership. If state needs cleanup, the switch
// prong below should handle it.
defer self.state = .{ .inactive = {} };
defer self.state = .inactive;
return switch (self.state) {
.inactive,
@@ -167,7 +167,7 @@ pub const Handler = struct {
.tmux => if (comptime build_options.tmux_control_mode) tmux: {
self.state.deinit();
break :tmux .{ .tmux = .{ .exit = {} } };
break :tmux .{ .tmux = .exit };
} else unreachable,
.xtgettcap => |*list| xtgettcap: {
@@ -200,7 +200,7 @@ pub const Handler = struct {
fn discard(self: *Handler) void {
self.state.deinit();
self.state = .{ .inactive = {} };
self.state = .inactive;
}
};
@@ -213,7 +213,7 @@ pub const Command = union(enum) {
/// Tmux control mode
tmux: if (build_options.tmux_control_mode)
terminal.tmux.Notification
terminal.tmux.ControlNotification
else
void,
@@ -255,21 +255,15 @@ pub const Command = union(enum) {
decstbm,
decslrm,
};
/// Tmux control mode
pub const Tmux = union(enum) {
enter: void,
exit: void,
};
};
const State = union(enum) {
/// We're not in a DCS state at the moment.
inactive: void,
inactive,
/// We're hooked, but its an unknown DCS command or one that went
/// invalid due to some bad input, so we're ignoring the rest.
ignore: void,
ignore,
/// XTGETTCAP
xtgettcap: std.Io.Writer.Allocating,
@@ -282,7 +276,7 @@ const State = union(enum) {
/// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode
tmux: if (build_options.tmux_control_mode)
terminal.tmux.Client
terminal.tmux.ControlParser
else
void,

View File

@@ -385,6 +385,7 @@ pub const RenderState = struct {
const row_rows = row_data.items(.raw);
const row_cells = row_data.items(.cells);
const row_sels = row_data.items(.selection);
const row_highlights = row_data.items(.highlights);
const row_dirties = row_data.items(.dirty);
// Track the last page that we know was dirty. This lets us
@@ -468,6 +469,7 @@ pub const RenderState = struct {
_ = arena.reset(.retain_capacity);
row_cells[y].clearRetainingCapacity();
row_sels[y] = null;
row_highlights[y] = .empty;
}
row_dirties[y] = true;
@@ -1314,3 +1316,62 @@ test "string" {
const expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n";
try testing.expectEqualStrings(expected, result);
}
test "dirty row resets highlights" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{
.cols = 10,
.rows = 3,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("ABC");
var state: RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Reset dirty state
state.dirty = .false;
{
const row_data = state.row_data.slice();
const dirty = row_data.items(.dirty);
@memset(dirty, false);
}
// Manually add a highlight to row 0
{
const row_data = state.row_data.slice();
const row_arenas = row_data.items(.arena);
const row_highlights = row_data.items(.highlights);
var arena = row_arenas[0].promote(alloc);
defer row_arenas[0] = arena.state;
try row_highlights[0].append(arena.allocator(), .{
.tag = 1,
.range = .{ 0, 2 },
});
}
// Verify we have a highlight
{
const row_data = state.row_data.slice();
const row_highlights = row_data.items(.highlights);
try testing.expectEqual(1, row_highlights[0].items.len);
}
// Write to row 0 to make it dirty
try s.nextSlice("\x1b[H"); // Move to home
try s.nextSlice("X");
try state.update(alloc, &t);
// Verify the highlight was reset on the dirty row
{
const row_data = state.row_data.slice();
const row_highlights = row_data.items(.highlights);
try testing.expectEqual(0, row_highlights[0].items.len);
}
}

View File

@@ -1,435 +1,12 @@
//! This file contains the implementation for tmux control mode. See
//! tmux(1) for more information on control mode. Some basics are documented
//! here but this is not meant to be a comprehensive source of protocol
//! documentation.
//! Types and functions related to tmux protocols.
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const oni = @import("oniguruma");
const control = @import("tmux/control.zig");
const layout = @import("tmux/layout.zig");
pub const output = @import("tmux/output.zig");
pub const ControlParser = control.Parser;
pub const ControlNotification = control.Notification;
pub const Layout = layout.Layout;
const log = std.log.scoped(.terminal_tmux);
/// A tmux control mode client. It is expected that the caller establishes
/// the connection in some way (i.e. detects the opening DCS sequence). This
/// just works on a byte stream.
pub const Client = struct {
/// Current state of the client.
state: State = .idle,
/// The buffer used to store in-progress notifications, output, etc.
buffer: std.Io.Writer.Allocating,
/// The maximum size in bytes of the buffer. This is used to limit
/// memory usage. If the buffer exceeds this size, the client will
/// enter a broken state (the control mode session will be forcibly
/// exited and future data dropped).
max_bytes: usize = 1024 * 1024,
const State = enum {
/// Outside of any active notifications. This should drop any output
/// unless it is '%' on the first byte of a line. The buffer will be
/// cleared when it sees '%', this is so that the previous notification
/// data is valid until we receive/process new data.
idle,
/// We experienced unexpected input and are in a broken state
/// so we cannot continue processing. When this state is set,
/// the buffer has been deinited and must not be accessed.
broken,
/// Inside an active notification (started with '%').
notification,
/// Inside a begin/end block.
block,
};
pub fn deinit(self: *Client) void {
// If we're in a broken state, we already deinited
// the buffer, so we don't need to do anything.
if (self.state == .broken) return;
self.buffer.deinit();
}
// Handle a byte of input.
pub fn put(self: *Client, byte: u8) !?Notification {
// If we're in a broken state, just do nothing.
//
// We have to do this check here before we check the buffer, because if
// we're in a broken state then we'd have already deinited the buffer.
if (self.state == .broken) return null;
if (self.buffer.written().len >= self.max_bytes) {
self.broken();
return error.OutOfMemory;
}
switch (self.state) {
// Drop because we're in a broken state.
.broken => return null,
// Waiting for a notification so if the byte is not '%' then
// we're in a broken state. Control mode output should always
// be wrapped in '%begin/%end' orelse we expect a notification.
// Return an exit notification.
.idle => if (byte != '%') {
self.broken();
return .{ .exit = {} };
} else {
self.buffer.clearRetainingCapacity();
self.state = .notification;
},
// If we're in a notification and its not a newline then
// we accumulate. If it is a newline then we have a
// complete notification we need to parse.
.notification => if (byte == '\n') {
// We have a complete notification, parse it.
return try self.parseNotification();
},
// If we're in a block then we accumulate until we see a newline
// and then we check to see if that line ended the block.
.block => if (byte == '\n') {
const written = self.buffer.written();
const idx = if (std.mem.lastIndexOfScalar(
u8,
written,
'\n',
)) |v| v + 1 else 0;
const line = written[idx..];
if (std.mem.startsWith(u8, line, "%end") or
std.mem.startsWith(u8, line, "%error"))
{
const err = std.mem.startsWith(u8, line, "%error");
const output = std.mem.trimRight(u8, written[0..idx], "\r\n");
// If it is an error then log it.
if (err) log.warn("tmux control mode error={s}", .{output});
// Important: do not clear buffer since the notification
// contains it.
self.state = .idle;
return if (err) .{ .block_err = output } else .{ .block_end = output };
}
// Didn't end the block, continue accumulating.
},
}
try self.buffer.writer.writeByte(byte);
return null;
}
fn parseNotification(self: *Client) !?Notification {
assert(self.state == .notification);
const line = line: {
var line = self.buffer.written();
if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
break :line line;
};
const cmd = cmd: {
const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len;
break :cmd line[0..idx];
};
// The notification MUST exist because we guard entering the notification
// state on seeing at least a '%'.
if (std.mem.eql(u8, cmd, "%begin")) {
// We don't use the rest of the tokens for now because tmux
// claims to guarantee that begin/end are always in order and
// never intermixed. In the future, we should probably validate
// this.
// TODO(tmuxcc): do this before merge?
// Move to block state because we expect a corresponding end/error
// and want to accumulate the data.
self.state = .block;
self.buffer.clearRetainingCapacity();
return null;
} else if (std.mem.eql(u8, cmd, "%output")) cmd: {
var re = try oni.Regex.init(
"^%output %([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const data = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .output = .{ .pane_id = id, .data = data } };
} else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: {
var re = try oni.Regex.init(
"^%session-changed \\$([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const name = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .session_changed = .{ .id = id, .name = name } };
} else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: {
if (!std.mem.eql(u8, line, "%sessions-changed")) {
log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line });
break :cmd;
}
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .sessions_changed = {} };
} else if (std.mem.eql(u8, cmd, "%window-add")) cmd: {
var re = try oni.Regex.init(
"^%window-add @([0-9]+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .window_add = .{ .id = id } };
} else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: {
var re = try oni.Regex.init(
"^%window-renamed @([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const name = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .window_renamed = .{ .id = id, .name = name } };
} else {
// Unknown notification, log it and return to idle state.
log.warn("unknown tmux control mode notification={s}", .{cmd});
}
// Unknown command. Clear the buffer and return to idle state.
self.buffer.clearRetainingCapacity();
self.state = .idle;
return null;
}
// Mark the tmux state as broken.
fn broken(self: *Client) void {
self.state = .broken;
self.buffer.deinit();
}
};
/// Possible notification types from tmux control mode. These are documented
/// in tmux(1).
pub const Notification = union(enum) {
enter: void,
exit: void,
block_end: []const u8,
block_err: []const u8,
output: struct {
pane_id: usize,
data: []const u8, // unescaped
},
session_changed: struct {
id: usize,
name: []const u8,
},
sessions_changed: void,
window_add: struct {
id: usize,
},
window_renamed: struct {
id: usize,
name: []const u8,
},
};
test "tmux begin/end empty" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("", n.block_end);
}
test "tmux begin/error empty" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_err);
try testing.expectEqualStrings("", n.block_err);
}
test "tmux begin/end data" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("hello\nworld", n.block_end);
}
test "tmux output" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .output);
try testing.expectEqual(42, n.output.pane_id);
try testing.expectEqualStrings("foo bar baz", n.output.data);
}
test "tmux session-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .session_changed);
try testing.expectEqual(42, n.session_changed.id);
try testing.expectEqualStrings("foo", n.session_changed.name);
}
test "tmux sessions-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .sessions_changed);
}
test "tmux sessions-changed carriage return" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .sessions_changed);
}
test "tmux window-add" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_add);
try testing.expectEqual(14, n.window_add.id);
}
test "tmux window-renamed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_renamed);
try testing.expectEqual(42, n.window_renamed.id);
try testing.expectEqualStrings("bar", n.window_renamed.name);
test {
@import("std").testing.refAllDecls(@This());
}

View File

@@ -0,0 +1,701 @@
//! This file contains the implementation for tmux control mode. See
//! tmux(1) for more information on control mode. Some basics are documented
//! here but this is not meant to be a comprehensive source of protocol
//! documentation.
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = @import("../../quirks.zig").inlineAssert;
const oni = @import("oniguruma");
const log = std.log.scoped(.terminal_tmux);
/// A tmux control mode parser. This takes in output from tmux control
/// mode and parses it into a structured notifications.
///
/// It is up to the caller to establish the connection to the tmux
/// control mode session in some way (e.g. via exec, a network socket,
/// whatever). This is fully agnostic to how the data is received and sent.
pub const Parser = struct {
/// Current state of the client.
state: State = .idle,
/// The buffer used to store in-progress notifications, output, etc.
buffer: std.Io.Writer.Allocating,
/// The maximum size in bytes of the buffer. This is used to limit
/// memory usage. If the buffer exceeds this size, the client will
/// enter a broken state (the control mode session will be forcibly
/// exited and future data dropped).
max_bytes: usize = 1024 * 1024,
const State = enum {
/// Outside of any active notifications. This should drop any output
/// unless it is '%' on the first byte of a line. The buffer will be
/// cleared when it sees '%', this is so that the previous notification
/// data is valid until we receive/process new data.
idle,
/// We experienced unexpected input and are in a broken state
/// so we cannot continue processing. When this state is set,
/// the buffer has been deinited and must not be accessed.
broken,
/// Inside an active notification (started with '%').
notification,
/// Inside a begin/end block.
block,
};
pub fn deinit(self: *Parser) void {
// If we're in a broken state, we already deinited
// the buffer, so we don't need to do anything.
if (self.state == .broken) return;
self.buffer.deinit();
}
// Handle a byte of input.
//
// If we reach our byte limit this will return OutOfMemory. It only
// does this on the first time we exceed the limit; subsequent calls
// will return null as we drop all input in a broken state.
pub fn put(self: *Parser, byte: u8) Allocator.Error!?Notification {
// If we're in a broken state, just do nothing.
//
// We have to do this check here before we check the buffer, because if
// we're in a broken state then we'd have already deinited the buffer.
if (self.state == .broken) return null;
if (self.buffer.written().len >= self.max_bytes) {
self.broken();
return error.OutOfMemory;
}
switch (self.state) {
// Drop because we're in a broken state.
.broken => return null,
// Waiting for a notification so if the byte is not '%' then
// we're in a broken state. Control mode output should always
// be wrapped in '%begin/%end' orelse we expect a notification.
// Return an exit notification.
.idle => if (byte != '%') {
self.broken();
return .{ .exit = {} };
} else {
self.buffer.clearRetainingCapacity();
self.state = .notification;
},
// If we're in a notification and its not a newline then
// we accumulate. If it is a newline then we have a
// complete notification we need to parse.
.notification => if (byte == '\n') {
// We have a complete notification, parse it.
return self.parseNotification() catch {
// If parsing failed, then we do not mark the state
// as broken because we may be able to continue parsing
// other types of notifications.
//
// In the future we may want to emit a notification
// here about unknown or unsupported notifications.
return null;
};
},
// If we're in a block then we accumulate until we see a newline
// and then we check to see if that line ended the block.
.block => if (byte == '\n') {
const written = self.buffer.written();
const idx = if (std.mem.lastIndexOfScalar(
u8,
written,
'\n',
)) |v| v + 1 else 0;
const line = written[idx..];
if (std.mem.startsWith(u8, line, "%end") or
std.mem.startsWith(u8, line, "%error"))
{
const err = std.mem.startsWith(u8, line, "%error");
const output = std.mem.trimRight(u8, written[0..idx], "\r\n");
// If it is an error then log it.
if (err) log.warn("tmux control mode error={s}", .{output});
// Important: do not clear buffer since the notification
// contains it.
self.state = .idle;
return if (err) .{ .block_err = output } else .{ .block_end = output };
}
// Didn't end the block, continue accumulating.
},
}
self.buffer.writer.writeByte(byte) catch |err| switch (err) {
error.WriteFailed => return error.OutOfMemory,
};
return null;
}
const ParseError = error{RegexError};
fn parseNotification(self: *Parser) ParseError!?Notification {
assert(self.state == .notification);
const line = line: {
var line = self.buffer.written();
if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
break :line line;
};
const cmd = cmd: {
const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len;
break :cmd line[0..idx];
};
// The notification MUST exist because we guard entering the notification
// state on seeing at least a '%'.
if (std.mem.eql(u8, cmd, "%begin")) {
// We don't use the rest of the tokens for now because tmux
// claims to guarantee that begin/end are always in order and
// never intermixed. In the future, we should probably validate
// this.
// TODO(tmuxcc): do this before merge?
// Move to block state because we expect a corresponding end/error
// and want to accumulate the data.
self.state = .block;
self.buffer.clearRetainingCapacity();
return null;
} else if (std.mem.eql(u8, cmd, "%output")) cmd: {
var re = oni.Regex.init(
"^%output %([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const data = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .output = .{ .pane_id = id, .data = data } };
} else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: {
var re = oni.Regex.init(
"^%session-changed \\$([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const name = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .session_changed = .{ .id = id, .name = name } };
} else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: {
if (!std.mem.eql(u8, line, "%sessions-changed")) {
log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line });
break :cmd;
}
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .sessions_changed = {} };
} else if (std.mem.eql(u8, cmd, "%layout-change")) cmd: {
var re = oni.Regex.init(
"^%layout-change @([0-9]+) (.+) (.+) (.*)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const layout = line[@intCast(starts[2])..@intCast(ends[2])];
const visible_layout = line[@intCast(starts[3])..@intCast(ends[3])];
const raw_flags = line[@intCast(starts[4])..@intCast(ends[4])];
// Important: do not clear buffer here since layout strings point to it
self.state = .idle;
return .{ .layout_change = .{
.window_id = id,
.layout = layout,
.visible_layout = visible_layout,
.raw_flags = raw_flags,
} };
} else if (std.mem.eql(u8, cmd, "%window-add")) cmd: {
var re = oni.Regex.init(
"^%window-add @([0-9]+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .window_add = .{ .id = id } };
} else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: {
var re = oni.Regex.init(
"^%window-renamed @([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const name = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .window_renamed = .{ .id = id, .name = name } };
} else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: {
var re = oni.Regex.init(
"^%window-pane-changed @([0-9]+) %([0-9]+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const window_id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const pane_id = std.fmt.parseInt(
usize,
line[@intCast(starts[2])..@intCast(ends[2])],
10,
) catch unreachable;
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } };
} else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: {
var re = oni.Regex.init(
"^%client-detached (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const client = line[@intCast(starts[1])..@intCast(ends[1])];
// Important: do not clear buffer here since client points to it
self.state = .idle;
return .{ .client_detached = .{ .client = client } };
} else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: {
var re = oni.Regex.init(
"^%client-session-changed (.+) \\$([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const client = line[@intCast(starts[1])..@intCast(ends[1])];
const session_id = std.fmt.parseInt(
usize,
line[@intCast(starts[2])..@intCast(ends[2])],
10,
) catch unreachable;
const name = line[@intCast(starts[3])..@intCast(ends[3])];
// Important: do not clear buffer here since client/name point to it
self.state = .idle;
return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } };
} else {
// Unknown notification, log it and return to idle state.
log.warn("unknown tmux control mode notification={s}", .{cmd});
}
// Unknown command. Clear the buffer and return to idle state.
self.buffer.clearRetainingCapacity();
self.state = .idle;
return null;
}
// Mark the tmux state as broken.
fn broken(self: *Parser) void {
self.state = .broken;
self.buffer.deinit();
}
};
/// Possible notification types from tmux control mode. These are documented
/// in tmux(1). A lot of the simple documentation was copied from that man
/// page here.
pub const Notification = union(enum) {
/// Entering tmux control mode. This isn't an actual event sent by
/// tmux but is one sent by us to indicate that we have detected that
/// tmux control mode is starting.
enter,
/// Exit.
///
/// NOTE: The tmux protocol contains a "reason" string (human friendly)
/// associated with this. We currently drop it because we don't need it
/// but this may be something we want to add later. If we do add it,
/// we have to consider buffer limits and how we handle those (dropping
/// vs truncating, etc.).
exit,
/// Dispatched at the end of a begin/end block with the raw data.
/// The control mode parser can't parse the data because it is unaware
/// of the command that was sent to trigger this output.
block_end: []const u8,
block_err: []const u8,
/// Raw output from a pane.
output: struct {
pane_id: usize,
data: []const u8, // unescaped
},
/// The client is now attached to the session with ID session-id, which is
/// named name.
session_changed: struct {
id: usize,
name: []const u8,
},
/// A session was created or destroyed.
sessions_changed,
/// The layout of the window with ID window-id changed.
layout_change: struct {
window_id: usize,
layout: []const u8,
visible_layout: []const u8,
raw_flags: []const u8,
},
/// The window with ID window-id was linked to the current session.
window_add: struct {
id: usize,
},
/// The window with ID window-id was renamed to name.
window_renamed: struct {
id: usize,
name: []const u8,
},
/// The active pane in the window with ID window-id changed to the pane
/// with ID pane-id.
window_pane_changed: struct {
window_id: usize,
pane_id: usize,
},
/// The client has detached.
client_detached: struct {
client: []const u8,
},
/// The client is now attached to the session with ID session-id, which is
/// named name.
client_session_changed: struct {
client: []const u8,
session_id: usize,
name: []const u8,
},
};
test "tmux begin/end empty" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("", n.block_end);
}
test "tmux begin/error empty" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_err);
try testing.expectEqualStrings("", n.block_err);
}
test "tmux begin/end data" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("hello\nworld", n.block_end);
}
test "tmux output" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .output);
try testing.expectEqual(42, n.output.pane_id);
try testing.expectEqualStrings("foo bar baz", n.output.data);
}
test "tmux session-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .session_changed);
try testing.expectEqual(42, n.session_changed.id);
try testing.expectEqualStrings("foo", n.session_changed.name);
}
test "tmux sessions-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .sessions_changed);
}
test "tmux sessions-changed carriage return" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .sessions_changed);
}
test "tmux layout-change" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%layout-change @2 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} *-") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .layout_change);
try testing.expectEqual(2, n.layout_change.window_id);
try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.layout);
try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.visible_layout);
try testing.expectEqualStrings("*-", n.layout_change.raw_flags);
}
test "tmux window-add" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_add);
try testing.expectEqual(14, n.window_add.id);
}
test "tmux window-renamed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_renamed);
try testing.expectEqual(42, n.window_renamed.id);
try testing.expectEqualStrings("bar", n.window_renamed.name);
}
test "tmux window-pane-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_pane_changed);
try testing.expectEqual(42, n.window_pane_changed.window_id);
try testing.expectEqual(2, n.window_pane_changed.pane_id);
}
test "tmux client-detached" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .client_detached);
try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client);
}
test "tmux client-session-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .client_session_changed);
try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client);
try testing.expectEqual(2, n.client_session_changed.session_id);
try testing.expectEqualStrings("mysession", n.client_session_changed.name);
}

View File

@@ -0,0 +1,638 @@
const std = @import("std");
const testing = std.testing;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
/// A tmux layout.
///
/// This is a tree structure so by definition it pretty much needs to be
/// allocated. We leave allocation up to the user of this struct, but
/// a general recommendation is to use an arena allocator for simplicity
/// in freeing the entire layout at once.
pub const Layout = struct {
/// Width, height of the node
width: usize,
height: usize,
/// X and Y offset from the top-left corner of the window.
x: usize,
y: usize,
/// The content of this node, either a pane (leaf) or more nodes
/// (split) horizontally or vertically.
content: Content,
pub const Content = union(enum) {
pane: usize,
horizontal: []const Layout,
vertical: []const Layout,
};
pub const ParseError = Allocator.Error || error{SyntaxError};
/// Parse a layout string that includes a 4-character checksum prefix.
///
/// The expected format is: `XXXX,layout_string` where XXXX is the
/// 4-character hexadecimal checksum and the layout string follows
/// after the comma. For example: `f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}`.
///
/// Returns `ChecksumMismatch` if the checksum doesn't match the layout.
/// Returns `SyntaxError` if the format is invalid.
pub fn parseWithChecksum(
alloc: Allocator,
str: []const u8,
) (ParseError || error{ChecksumMismatch})!Layout {
// If the string is less than 5 characters, it can't possibly
// be correct. 4-char checksum + comma. In practice it should
// be even longer, but that'll fail parse later.
if (str.len < 5) return error.SyntaxError;
if (str[4] != ',') return error.SyntaxError;
// The layout string should start with a 4-character checksum.
const checksum: Checksum = .calculate(str[5..]);
if (!std.mem.startsWith(
u8,
str,
&checksum.asString(),
)) return error.ChecksumMismatch;
// Checksum matches, parse the rest.
return try parse(alloc, str[5..]);
}
/// Parse a layout string into a Layout structure. The given allocator
/// will be used for all allocations within the layout. Note that
/// individual nodes can't be freed so this allocator must be some
/// kind of arena allocator.
///
/// The layout string must be fully provided as a single string.
/// Layouts are generally small so this should not be a problem.
///
/// Tmux layout strings have the following format:
///
/// - WxH,X,Y,ID Leaf pane: width×height, x-offset, y-offset, pane ID
/// - WxH,X,Y{...} Horizontal split (left-right), children comma-separated
/// - WxH,X,Y[...] Vertical split (top-bottom), children comma-separated
pub fn parse(alloc: Allocator, str: []const u8) ParseError!Layout {
var offset: usize = 0;
const root = try parseNext(
alloc,
str,
&offset,
);
if (offset != str.len) return error.SyntaxError;
return root;
}
fn parseNext(
alloc: Allocator,
str: []const u8,
offset: *usize,
) ParseError!Layout {
// Find the first `x` to grab the width.
const width: usize = if (std.mem.indexOfScalar(
u8,
str[offset.*..],
'x',
)) |idx| width: {
defer offset.* += idx + 1; // Consume `x`
break :width std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Find the height, up to a comma.
const height: usize = if (std.mem.indexOfScalar(
u8,
str[offset.*..],
',',
)) |idx| height: {
defer offset.* += idx + 1; // Consume `,`
break :height std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Find X
const x: usize = if (std.mem.indexOfScalar(
u8,
str[offset.*..],
',',
)) |idx| x: {
defer offset.* += idx + 1; // Consume `,`
break :x std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Find Y, which can end in any of `,{,[`
const y: usize = if (std.mem.indexOfAny(
u8,
str[offset.*..],
",{[",
)) |idx| y: {
defer offset.* += idx; // Don't consume the delimiter!
break :y std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Determine our child node.
const content: Layout.Content = switch (str[offset.*]) {
',' => content: {
// Consume the delimiter
offset.* += 1;
// Leaf pane. Read up to `,}]` because we may be in
// a set of nodes. If none exist, end of string is fine.
const idx = std.mem.indexOfAny(
u8,
str[offset.*..],
",}]",
) orelse str.len - offset.*;
defer offset.* += idx; // Consume the pane ID, not the delimiter
const pane_id = std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
break :content .{ .pane = pane_id };
},
'{', '[' => |opening| content: {
var nodes: std.ArrayList(Layout) = .empty;
defer nodes.deinit(alloc);
// Move beyond our opening
offset.* += 1;
while (true) {
try nodes.append(alloc, try parseNext(
alloc,
str,
offset,
));
// We should not reach the end of string here because
// we expect a closing bracket.
if (offset.* >= str.len) return error.SyntaxError;
// If it is a comma, we expect another node.
if (str[offset.*] == ',') {
offset.* += 1; // Consume
continue;
}
// We expect a closing bracket now.
switch (opening) {
'{' => if (str[offset.*] != '}') return error.SyntaxError,
'[' => if (str[offset.*] != ']') return error.SyntaxError,
else => return error.SyntaxError,
}
// Successfully parsed all children.
offset.* += 1; // Consume closing bracket
break :content switch (opening) {
'{' => .{ .horizontal = try nodes.toOwnedSlice(alloc) },
'[' => .{ .vertical = try nodes.toOwnedSlice(alloc) },
else => unreachable,
};
}
},
// indexOfAny above guarantees we have only the above
else => unreachable,
};
return .{
.width = width,
.height = height,
.x = x,
.y = y,
.content = content,
};
}
};
pub const Checksum = enum(u16) {
_,
/// Calculate the checksum of a tmux layout string.
/// The algorithm rotates the checksum right by 1 bit (with wraparound)
/// and adds the ASCII value of each character.
pub fn calculate(str: []const u8) Checksum {
var result: u16 = 0;
for (str) |c| {
// Rotate right by 1: (result >> 1) + ((result & 1) << 15)
result = (result >> 1) | ((result & 1) << 15);
result +%= c;
}
return @enumFromInt(result);
}
/// Convert the checksum to a 4-character hexadecimal string. This
/// is always zero-padded to match the tmux implementation
/// (in layout-custom.c).
pub fn asString(self: Checksum) [4]u8 {
const value = @intFromEnum(self);
const charset = "0123456789abcdef";
return .{
charset[(value >> 12) & 0xf],
charset[(value >> 8) & 0xf],
charset[(value >> 4) & 0xf],
charset[value & 0xf],
};
}
};
test "simple single pane" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0,42");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
try testing.expectEqual(0, layout.x);
try testing.expectEqual(0, layout.y);
try testing.expectEqual(42, layout.content.pane);
}
test "single pane with offset" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "40x12,10,5,7");
try testing.expectEqual(40, layout.width);
try testing.expectEqual(12, layout.height);
try testing.expectEqual(10, layout.x);
try testing.expectEqual(5, layout.y);
try testing.expectEqual(7, layout.content.pane);
}
test "single pane large values" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "1920x1080,100,200,999");
try testing.expectEqual(1920, layout.width);
try testing.expectEqual(1080, layout.height);
try testing.expectEqual(100, layout.x);
try testing.expectEqual(200, layout.y);
try testing.expectEqual(999, layout.content.pane);
}
test "horizontal split two panes" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0,2}");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
try testing.expectEqual(0, layout.x);
try testing.expectEqual(0, layout.y);
const children = layout.content.horizontal;
try testing.expectEqual(2, children.len);
try testing.expectEqual(40, children[0].width);
try testing.expectEqual(24, children[0].height);
try testing.expectEqual(0, children[0].x);
try testing.expectEqual(0, children[0].y);
try testing.expectEqual(1, children[0].content.pane);
try testing.expectEqual(40, children[1].width);
try testing.expectEqual(24, children[1].height);
try testing.expectEqual(40, children[1].x);
try testing.expectEqual(0, children[1].y);
try testing.expectEqual(2, children[1].content.pane);
}
test "vertical split two panes" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12,2]");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
try testing.expectEqual(0, layout.x);
try testing.expectEqual(0, layout.y);
const children = layout.content.vertical;
try testing.expectEqual(2, children.len);
try testing.expectEqual(80, children[0].width);
try testing.expectEqual(12, children[0].height);
try testing.expectEqual(0, children[0].x);
try testing.expectEqual(0, children[0].y);
try testing.expectEqual(1, children[0].content.pane);
try testing.expectEqual(80, children[1].width);
try testing.expectEqual(12, children[1].height);
try testing.expectEqual(0, children[1].x);
try testing.expectEqual(12, children[1].y);
try testing.expectEqual(2, children[1].content.pane);
}
test "horizontal split three panes" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "120x24,0,0{40x24,0,0,1,40x24,40,0,2,40x24,80,0,3}");
try testing.expectEqual(120, layout.width);
try testing.expectEqual(24, layout.height);
const children = layout.content.horizontal;
try testing.expectEqual(3, children.len);
try testing.expectEqual(1, children[0].content.pane);
try testing.expectEqual(2, children[1].content.pane);
try testing.expectEqual(3, children[2].content.pane);
}
test "nested horizontal in vertical" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Vertical split with top pane and bottom horizontal split
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12{40x12,0,12,2,40x12,40,12,3}]");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
const vert_children = layout.content.vertical;
try testing.expectEqual(2, vert_children.len);
// First child is a simple pane
try testing.expectEqual(1, vert_children[0].content.pane);
// Second child is a horizontal split
const horiz_children = vert_children[1].content.horizontal;
try testing.expectEqual(2, horiz_children.len);
try testing.expectEqual(2, horiz_children[0].content.pane);
try testing.expectEqual(3, horiz_children[1].content.pane);
}
test "nested vertical in horizontal" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Horizontal split with left pane and right vertical split
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0[40x12,40,0,2,40x12,40,12,3]}");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
const horiz_children = layout.content.horizontal;
try testing.expectEqual(2, horiz_children.len);
// First child is a simple pane
try testing.expectEqual(1, horiz_children[0].content.pane);
// Second child is a vertical split
const vert_children = horiz_children[1].content.vertical;
try testing.expectEqual(2, vert_children.len);
try testing.expectEqual(2, vert_children[0].content.pane);
try testing.expectEqual(3, vert_children[1].content.pane);
}
test "deeply nested layout" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Three levels deep
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0[40x12,0,0,1,40x12,0,12,2],40x24,40,0,3}");
const horiz = layout.content.horizontal;
try testing.expectEqual(2, horiz.len);
const vert = horiz[0].content.vertical;
try testing.expectEqual(2, vert.len);
try testing.expectEqual(1, vert[0].content.pane);
try testing.expectEqual(2, vert[1].content.pane);
try testing.expectEqual(3, horiz[1].content.pane);
}
test "syntax error empty string" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), ""));
}
test "syntax error missing width" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "x24,0,0,1"));
}
test "syntax error missing height" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x,0,0,1"));
}
test "syntax error missing x" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,,0,1"));
}
test "syntax error missing y" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,,1"));
}
test "syntax error missing pane id" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,"));
}
test "syntax error non-numeric width" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "abcx24,0,0,1"));
}
test "syntax error non-numeric pane id" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,abc"));
}
test "syntax error unclosed horizontal bracket" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1"));
}
test "syntax error unclosed vertical bracket" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1"));
}
test "syntax error mismatched brackets" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1]"));
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1}"));
}
test "syntax error trailing data" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,1extra"));
}
test "syntax error no x separator" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "8024,0,0,1"));
}
test "syntax error no content delimiter" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0"));
}
// parseWithChecksum tests
test "parseWithChecksum valid" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parseWithChecksum(arena.allocator(), "f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
}
test "parseWithChecksum mismatch" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.ChecksumMismatch, Layout.parseWithChecksum(arena.allocator(), "0000,80x24,0,0{40x24,0,0,1,40x24,40,0,2}"));
}
test "parseWithChecksum too short" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62"));
try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), ""));
}
test "parseWithChecksum missing comma" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62x159x48,0,0"));
}
// Checksum tests
test "checksum empty string" {
const checksum = Checksum.calculate("");
try testing.expectEqual(@as(u16, 0), @intFromEnum(checksum));
try testing.expectEqualStrings("0000", &checksum.asString());
}
test "checksum single character" {
// 'A' = 65, first iteration: csum = 0 >> 1 | 0 = 0, then 0 + 65 = 65
const checksum = Checksum.calculate("A");
try testing.expectEqual(@as(u16, 65), @intFromEnum(checksum));
try testing.expectEqualStrings("0041", &checksum.asString());
}
test "checksum two characters" {
// 'A' (65): csum = 0, rotate = 0, add 65 => 65
// 'B' (66): csum = 65, rotate => (65 >> 1) | ((65 & 1) << 15) = 32 | 32768 = 32800
// add 66 => 32800 + 66 = 32866
const checksum = Checksum.calculate("AB");
try testing.expectEqual(@as(u16, 32866), @intFromEnum(checksum));
try testing.expectEqualStrings("8062", &checksum.asString());
}
test "checksum simple layout" {
const checksum = Checksum.calculate("80x24,0,0,42");
try testing.expectEqualStrings("d962", &checksum.asString());
}
test "checksum horizontal split layout" {
const checksum = Checksum.calculate("80x24,0,0{40x24,0,0,1,40x24,40,0,2}");
try testing.expectEqualStrings("f8f9", &checksum.asString());
}
test "checksum asString zero padding" {
// Value 0x000f should produce "000f"
const checksum: Checksum = @enumFromInt(0x000f);
try testing.expectEqualStrings("000f", &checksum.asString());
}
test "checksum asString all digits" {
// Value 0x1234 should produce "1234"
const checksum: Checksum = @enumFromInt(0x1234);
try testing.expectEqualStrings("1234", &checksum.asString());
}
test "checksum asString with letters" {
// Value 0xabcd should produce "abcd"
const checksum: Checksum = @enumFromInt(0xabcd);
try testing.expectEqualStrings("abcd", &checksum.asString());
}
test "checksum asString max value" {
// Value 0xffff should produce "ffff"
const checksum: Checksum = @enumFromInt(0xffff);
try testing.expectEqualStrings("ffff", &checksum.asString());
}
test "checksum wraparound" {
const checksum = Checksum.calculate("\xff\xff\xff\xff\xff\xff\xff\xff");
try testing.expectEqualStrings("03fc", &checksum.asString());
}
test "checksum deterministic" {
// Same input should always produce same output
const str = "159x48,0,0{79x48,0,0,79x48,80,0}";
const checksum1 = Checksum.calculate(str);
const checksum2 = Checksum.calculate(str);
try testing.expectEqual(checksum1, checksum2);
}
test "checksum different inputs different outputs" {
const checksum1 = Checksum.calculate("80x24,0,0,1");
const checksum2 = Checksum.calculate("80x24,0,0,2");
try testing.expect(@intFromEnum(checksum1) != @intFromEnum(checksum2));
}
test "checksum known tmux layout bb62" {
// From tmux documentation: "bb62,159x48,0,0{79x48,0,0,79x48,80,0}"
// The checksum "bb62" corresponds to the layout "159x48,0,0{79x48,0,0,79x48,80,0}"
const checksum = Checksum.calculate("159x48,0,0{79x48,0,0,79x48,80,0}");
try testing.expectEqualStrings("bb62", &checksum.asString());
}

View File

@@ -0,0 +1,205 @@
const std = @import("std");
const testing = std.testing;
pub const ParseError = error{
MissingEntry,
ExtraEntry,
FormatError,
};
/// Parse the output from a command with the given format struct
/// (returned usually by FormatStruct). The format struct is expected
/// to be in the order of the variables used in the format string and
/// the variables are expected to be plain variables (no conditionals,
/// extra formatting, etc.). Each variable is expected to be separated
/// by a single `delimiter` character.
pub fn parseFormatStruct(
comptime T: type,
str: []const u8,
delimiter: u8,
) ParseError!T {
// Parse all our fields
const fields = @typeInfo(T).@"struct".fields;
var it = std.mem.splitScalar(u8, str, delimiter);
var result: T = undefined;
inline for (fields) |field| {
const part = it.next() orelse return error.MissingEntry;
@field(result, field.name) = Variable.parse(
@field(Variable, field.name),
part,
) catch return error.FormatError;
}
// We should have consumed all parts now.
if (it.next() != null) return error.ExtraEntry;
return result;
}
/// Returns a struct type that contains fields for each of the given
/// format variables. This can be used with `parseFormatStruct` to
/// parse an output string into a format struct.
pub fn FormatStruct(comptime vars: []const Variable) type {
var fields: [vars.len]std.builtin.Type.StructField = undefined;
for (vars, &fields) |variable, *field| {
field.* = .{
.name = @tagName(variable),
.type = variable.Type(),
.default_value_ptr = null,
.is_comptime = false,
.alignment = @alignOf(variable.Type()),
};
}
return @Type(.{ .@"struct" = .{
.layout = .auto,
.fields = &fields,
.decls = &.{},
.is_tuple = false,
} });
}
/// Possible variables in a tmux format string that we support.
///
/// Tmux supports a large number of variables, but we only implement
/// a subset of them here that are relevant to the use case of implementing
/// control mode for terminal emulators.
pub const Variable = enum {
session_id,
window_id,
window_width,
window_height,
window_layout,
/// Parse the given string value into the appropriate resulting
/// type for this variable.
pub fn parse(comptime self: Variable, value: []const u8) !Type(self) {
return switch (self) {
.session_id => if (value.len >= 2 and value[0] == '$')
try std.fmt.parseInt(usize, value[1..], 10)
else
return error.FormatError,
.window_id => if (value.len >= 2 and value[0] == '@')
try std.fmt.parseInt(usize, value[1..], 10)
else
return error.FormatError,
.window_width => try std.fmt.parseInt(usize, value, 10),
.window_height => try std.fmt.parseInt(usize, value, 10),
.window_layout => value,
};
}
/// The type of the parsed value for this variable type.
pub fn Type(comptime self: Variable) type {
return switch (self) {
.session_id => usize,
.window_id => usize,
.window_width => usize,
.window_height => usize,
.window_layout => []const u8,
};
}
};
test "parse session id" {
try testing.expectEqual(42, try Variable.parse(.session_id, "$42"));
try testing.expectEqual(0, try Variable.parse(.session_id, "$0"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, "0"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, "@0"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, "$"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, ""));
try testing.expectError(error.InvalidCharacter, Variable.parse(.session_id, "$abc"));
}
test "parse window id" {
try testing.expectEqual(42, try Variable.parse(.window_id, "@42"));
try testing.expectEqual(0, try Variable.parse(.window_id, "@0"));
try testing.expectEqual(12345, try Variable.parse(.window_id, "@12345"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, "0"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, "$0"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, "@"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, ""));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_id, "@abc"));
}
test "parse window width" {
try testing.expectEqual(80, try Variable.parse(.window_width, "80"));
try testing.expectEqual(0, try Variable.parse(.window_width, "0"));
try testing.expectEqual(12345, try Variable.parse(.window_width, "12345"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "abc"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "80px"));
try testing.expectError(error.Overflow, Variable.parse(.window_width, "-1"));
}
test "parse window height" {
try testing.expectEqual(24, try Variable.parse(.window_height, "24"));
try testing.expectEqual(0, try Variable.parse(.window_height, "0"));
try testing.expectEqual(12345, try Variable.parse(.window_height, "12345"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "abc"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "24px"));
try testing.expectError(error.Overflow, Variable.parse(.window_height, "-1"));
}
test "parse window layout" {
try testing.expectEqualStrings("abc123", try Variable.parse(.window_layout, "abc123"));
try testing.expectEqualStrings("", try Variable.parse(.window_layout, ""));
try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)"));
}
test "parseFormatStruct single field" {
const T = FormatStruct(&.{.session_id});
const result = try parseFormatStruct(T, "$42", ' ');
try testing.expectEqual(42, result.session_id);
}
test "parseFormatStruct multiple fields" {
const T = FormatStruct(&.{ .session_id, .window_id, .window_width, .window_height });
const result = try parseFormatStruct(T, "$1 @2 80 24", ' ');
try testing.expectEqual(1, result.session_id);
try testing.expectEqual(2, result.window_id);
try testing.expectEqual(80, result.window_width);
try testing.expectEqual(24, result.window_height);
}
test "parseFormatStruct with string field" {
const T = FormatStruct(&.{ .window_id, .window_layout });
const result = try parseFormatStruct(T, "@5,abc123", ',');
try testing.expectEqual(5, result.window_id);
try testing.expectEqualStrings("abc123", result.window_layout);
}
test "parseFormatStruct different delimiter" {
const T = FormatStruct(&.{ .window_width, .window_height });
const result = try parseFormatStruct(T, "120\t40", '\t');
try testing.expectEqual(120, result.window_width);
try testing.expectEqual(40, result.window_height);
}
test "parseFormatStruct missing entry" {
const T = FormatStruct(&.{ .session_id, .window_id });
try testing.expectError(error.MissingEntry, parseFormatStruct(T, "$1", ' '));
}
test "parseFormatStruct extra entry" {
const T = FormatStruct(&.{.session_id});
try testing.expectError(error.ExtraEntry, parseFormatStruct(T, "$1 @2", ' '));
}
test "parseFormatStruct format error" {
const T = FormatStruct(&.{.session_id});
try testing.expectError(error.FormatError, parseFormatStruct(T, "42", ' '));
try testing.expectError(error.FormatError, parseFormatStruct(T, "@42", ' '));
try testing.expectError(error.FormatError, parseFormatStruct(T, "$abc", ' '));
}
test "parseFormatStruct empty string" {
const T = FormatStruct(&.{.session_id});
try testing.expectError(error.FormatError, parseFormatStruct(T, "", ' '));
}
test "parseFormatStruct with empty layout field" {
const T = FormatStruct(&.{ .session_id, .window_layout });
const result = try parseFormatStruct(T, "$1,", ',');
try testing.expectEqual(1, result.session_id);
try testing.expectEqualStrings("", result.window_layout);
}

View File

@@ -659,12 +659,12 @@ fn setupZsh(
resource_dir: []const u8,
env: *EnvMap,
) !void {
// Preserve the old zdotdir value so we can recover it.
// Preserve an existing ZDOTDIR value. We're about to overwrite it.
if (env.get("ZDOTDIR")) |old| {
try env.put("GHOSTTY_ZSH_ZDOTDIR", old);
}
// Set our new ZDOTDIR
// Set our new ZDOTDIR to point to our shell resource directory.
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const integ_dir = try std.fmt.bufPrint(
&path_buf,