Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,14 @@ typedef struct {
int8_t progress;
} ghostty_action_progress_report_s;

// apprt.action.CommandFinished.C
typedef struct {
// -1 if no exit code was reported, otherwise 0-255
int16_t exit_code;
// number of nanoseconds that command was running for
uint64_t duration;
} ghostty_action_command_finished_s;
Comment thread
jcollie marked this conversation as resolved.

// apprt.Action.Key
typedef enum {
GHOSTTY_ACTION_QUIT,
Expand Down Expand Up @@ -788,6 +796,7 @@ typedef enum {
GHOSTTY_ACTION_SHOW_CHILD_EXITED,
GHOSTTY_ACTION_PROGRESS_REPORT,
GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD,
GHOSTTY_ACTION_COMMAND_FINISHED,
} ghostty_action_tag_e;

typedef union {
Expand Down Expand Up @@ -819,6 +828,7 @@ typedef union {
ghostty_action_close_tab_mode_e close_tab_mode;
ghostty_surface_message_childexited_s child_exited;
ghostty_action_progress_report_s progress_report;
ghostty_action_command_finished_s command_finished;
} ghostty_action_u;

typedef struct {
Expand Down
38 changes: 38 additions & 0 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const font = @import("font/main.zig");
const Command = @import("Command.zig");
const terminal = @import("terminal/main.zig");
const configpkg = @import("config.zig");
const Duration = configpkg.Config.Duration;
const input = @import("input.zig");
const App = @import("App.zig");
const internal_os = @import("os/main.zig");
Expand Down Expand Up @@ -147,6 +148,13 @@ focused: bool = true,
/// Used to determine whether to continuously scroll.
selection_scroll_active: bool = false,

/// Used to send notifications that long running commands have finished.
/// Requires that shell integration be active. Should represent a nanosecond
/// precision timestamp. It does not necessarily need to correspond to the
/// actual time, but we must be able to compare two subsequent timestamps to get
/// the wall clock time that has elapsed between timestamps.
command_timer: ?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
Expand Down Expand Up @@ -280,6 +288,9 @@ const DerivedConfig = struct {
links: []Link,
link_previews: configpkg.LinkPreviews,
scroll_to_bottom: configpkg.Config.ScrollToBottom,
notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish,
notify_on_command_finish_action: configpkg.Config.NotifyOnCommandFinishAction,
notify_on_command_finish_after: Duration,

const Link = struct {
regex: oni.Regex,
Expand Down Expand Up @@ -350,6 +361,9 @@ const DerivedConfig = struct {
.links = links,
.link_previews = config.@"link-previews",
.scroll_to_bottom = config.@"scroll-to-bottom",
.notify_on_command_finish = config.@"notify-on-command-finish",
.notify_on_command_finish_action = config.@"notify-on-command-finish-action",
.notify_on_command_finish_after = config.@"notify-on-command-finish-after",

// Assignments happen sequentially so we have to do this last
// so that the memory is captured from allocs above.
Expand Down Expand Up @@ -984,6 +998,30 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
self.selection_scroll_active = active;
try self.selectionScrollTick();
},

.start_command => {
self.command_timer = try .now();
},

.stop_command => |v| timer: {
const end: std.time.Instant = try .now();
const start = self.command_timer orelse break :timer;
self.command_timer = null;

const duration: Duration = .{ .duration = end.since(start) };
log.debug("command took {}", .{duration});

_ = self.rt_app.performAction(
.{ .surface = self },
.command_finished,
.{
.exit_code = v,
.duration = duration,
},
) catch |err| {
log.warn("apprt failed to notify command finish={}", .{err});
};
},
}
}

Expand Down
22 changes: 22 additions & 0 deletions src/apprt/action.zig
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,9 @@ pub const Action = union(Key) {
/// Show the on-screen keyboard.
show_on_screen_keyboard,

/// A command has finished,
command_finished: CommandFinished,

/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
Expand Down Expand Up @@ -350,6 +353,7 @@ pub const Action = union(Key) {
show_child_exited,
progress_report,
show_on_screen_keyboard,
command_finished,
};

/// Sync with: ghostty_action_u
Expand Down Expand Up @@ -741,3 +745,21 @@ pub const CloseTabMode = enum(c_int) {
/// Close all other tabs.
other,
};

pub const CommandFinished = struct {
exit_code: ?u8,
duration: configpkg.Config.Duration,

/// sync with ghostty_action_command_finished_s in ghostty.h
pub const C = extern struct {
exit_code: i16,
duration: u64,
};

pub fn cval(self: CommandFinished) C {
return .{
.exit_code = self.exit_code orelse -1,
.duration = self.duration.duration,
};
}
};
31 changes: 18 additions & 13 deletions src/apprt/gtk/class/application.zig
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,7 @@ pub const Application = extern struct {
.toggle_command_palette => return Action.toggleCommandPalette(target),
.toggle_split_zoom => return Action.toggleSplitZoom(target),
.show_on_screen_keyboard => return Action.showOnScreenKeyboard(target),
.command_finished => return Action.commandFinished(target, value),

// Unimplemented
.secure_input,
Expand Down Expand Up @@ -1824,13 +1825,13 @@ const Action = struct {
target: apprt.Target,
n: apprt.action.DesktopNotification,
) void {
// TODO: We should move the surface target to a function call
// on Surface and emit a signal that embedders can connect to. This
// will let us handle notifications differently depending on where
// a surface is presented. At the time of writing this, we always
// want to show the notification AND the logic below was directly
// ported from "legacy" GTK so this is fine, but I want to leave this
// note so we can do it one day.
switch (target) {
.app => {},
.surface => |v| {
v.rt_surface.gobj().sendDesktopNotification(n.title, n.body);
return;
},
}

// Set a default title if we don't already have one
const t = switch (n.title.len) {
Expand All @@ -1845,14 +1846,9 @@ const Action = struct {
const icon = gio.ThemedIcon.new("com.mitchellh.ghostty");
defer icon.unref();
notification.setIcon(icon.as(gio.Icon));

const pointer = glib.Variant.newUint64(switch (target) {
.app => 0,
.surface => |v| @intFromPtr(v),
});
notification.setDefaultActionAndTargetValue(
"app.present-surface",
pointer,
glib.Variant.newUint64(0),
);

// We set the notification ID to the body content. If the content is the
Expand Down Expand Up @@ -2457,6 +2453,15 @@ const Action = struct {
},
}
}

pub fn commandFinished(target: apprt.Target, value: apprt.Action.Value(.command_finished)) bool {
switch (target) {
.app => return false,
.surface => |surface| {
return surface.rt_surface.gobj().commandFinished(value);
},
}
}
};

/// This sets various GTK-related environment variables as necessary
Expand Down
2 changes: 1 addition & 1 deletion src/apprt/gtk/class/split_tree.zig
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ pub const SplitTree = extern struct {
.init("zoom", actionZoom, null),
};

ext.actions.addAsGroup(Self, self, "split-tree", &actions);
_ = ext.actions.addAsGroup(Self, self, "split-tree", &actions);
}

/// Create a new split in the given direction from the currently
Expand Down
118 changes: 116 additions & 2 deletions src/apprt/gtk/class/surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog;
const Window = @import("window.zig").Window;
const WeakRef = @import("../weak_ref.zig").WeakRef;
const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
const i18n = @import("../../../os/i18n.zig");

const log = std.log.scoped(.gtk_ghostty_surface);

Expand Down Expand Up @@ -545,6 +546,8 @@ pub const Surface = extern struct {
// unfocused-split-* options
is_split: bool = false,

action_group: ?*gio.SimpleActionGroup = null,

// Template binds
child_exited_overlay: *ChildExited,
context_menu: *gtk.PopoverMenu,
Expand Down Expand Up @@ -809,6 +812,63 @@ pub const Surface = extern struct {
);
}

pub fn commandFinished(self: *Self, value: apprt.Action.Value(.command_finished)) bool {
const app = Application.default();
const alloc = app.allocator();
const priv: *Private = self.private();

const notify_next_command_finish = notify: {
const simple_action_group = priv.action_group orelse break :notify false;
const action_group = simple_action_group.as(gio.ActionGroup);
const state = action_group.getActionState("notify-on-next-command-finish") orelse break :notify false;
const bool_variant_type = glib.ext.VariantType.newFor(bool);
defer bool_variant_type.free();
if (state.isOfType(bool_variant_type) == 0) break :notify false;
const notify = state.getBoolean() != 0;
action_group.changeActionState("notify-on-next-command-finish", glib.Variant.newBoolean(@intFromBool(false)));
break :notify notify;
};

const config = priv.config orelse return false;

const cfg = config.get();

if (!notify_next_command_finish) {
if (cfg.@"notify-on-command-finish" == .never) return true;
if (cfg.@"notify-on-command-finish" == .unfocused and self.getFocused()) return true;
}

const action = cfg.@"notify-on-command-finish-action";

if (action.bell) self.setBellRinging(true);

if (action.notify) notify: {
const title_ = title: {
const exit_code = value.exit_code orelse break :title i18n._("Command Finished");
if (exit_code == 0) break :title i18n._("Command Succeeded");
break :title i18n._("Command Failed");
};
const title = std.mem.span(title_);
const body = body: {
const exit_code = value.exit_code orelse break :body std.fmt.allocPrintZ(
alloc,
"Command took {}.",
.{value.duration.round(std.time.ns_per_ms)},
) catch break :notify;
break :body std.fmt.allocPrintZ(
alloc,
"Command took {} and exited with code {d}.",
.{ value.duration.round(std.time.ns_per_ms), exit_code },
) catch break :notify;
};
defer alloc.free(body);

self.sendDesktopNotification(title, body);
}

return true;
}

/// Key press event (press or release).
///
/// At a high level, we want to construct an `input.KeyEvent` and
Expand Down Expand Up @@ -1404,6 +1464,34 @@ pub const Surface = extern struct {
_ = priv.gl_area.as(gtk.Widget).grabFocus();
}

pub fn sendDesktopNotification(self: *Self, title: [:0]const u8, body: [:0]const u8) void {
const app = Application.default();

const t = switch (title.len) {
0 => "Ghostty",
else => title,
};

const notification = gio.Notification.new(t);
defer notification.unref();
notification.setBody(body);

const icon = gio.ThemedIcon.new("com.mitchellh.ghostty");
defer icon.unref();
notification.setIcon(icon.as(gio.Icon));

const pointer = glib.Variant.newUint64(@intFromPtr(self));
notification.setDefaultActionAndTargetValue(
"app.present-surface",
pointer,
);

// We set the notification ID to the body content. If the content is the
// same, this notification may replace a previous notification
const gio_app = app.as(gio.Application);
gio_app.sendNotification(body, notification);
}

//---------------------------------------------------------------
// Virtual Methods

Expand Down Expand Up @@ -1460,11 +1548,23 @@ pub const Surface = extern struct {
}

fn initActionMap(self: *Self) void {
const priv: *Private = self.private();

const actions = [_]ext.actions.Action(Self){
.init("prompt-title", actionPromptTitle, null),
.init(
"prompt-title",
actionPromptTitle,
null,
),
.initStateful(
"notify-on-next-command-finish",
actionNotifyOnNextCommandFinish,
null,
glib.Variant.newBoolean(@intFromBool(false)),
),
};

ext.actions.addAsGroup(Self, self, "surface", &actions);
priv.action_group = ext.actions.addAsGroup(Self, self, "surface", &actions);
}

fn dispose(self: *Self) callconv(.c) void {
Expand Down Expand Up @@ -1966,6 +2066,20 @@ pub const Surface = extern struct {
};
}

pub fn actionNotifyOnNextCommandFinish(
action: *gio.SimpleAction,
_: ?*glib.Variant,
_: *Self,
) callconv(.c) void {
const state = action.as(gio.Action).getState() orelse glib.Variant.newBoolean(@intFromBool(false));
defer state.unref();
const bool_variant_type = glib.ext.VariantType.newFor(bool);
defer bool_variant_type.free();
if (state.isOfType(bool_variant_type) == 0) return;
const value = state.getBoolean() != 0;
action.setState(glib.Variant.newBoolean(@intFromBool(!value)));
}

fn childExitedClose(
_: *ChildExited,
self: *Self,
Expand Down
2 changes: 1 addition & 1 deletion src/apprt/gtk/class/tab.zig
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ pub const Tab = extern struct {
.init("ring-bell", actionRingBell, null),
};

ext.actions.addAsGroup(Self, self, "tab", &actions);
_ = ext.actions.addAsGroup(Self, self, "tab", &actions);
}

//---------------------------------------------------------------
Expand Down
Loading