diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk-ng/class.zig index 82762b542..68879d19c 100644 --- a/src/apprt/gtk-ng/class.zig +++ b/src/apprt/gtk-ng/class.zig @@ -53,6 +53,23 @@ pub fn Common( } }).private else {}; + /// A helper that creates a property that reads and writes a + /// private field with only shallow copies. This is good for primitives + /// such as bools, numbers, etc. + pub fn privateShallowFieldAccessor( + comptime name: []const u8, + ) gobject.ext.Accessor( + Self, + @FieldType(Private.?, name), + ) { + return gobject.ext.privateFieldAccessor( + Self, + Private.?, + &Private.?.offset, + name, + ); + } + /// A helper that can be used to create a property that reads and /// writes a private boxed gobject field type. /// diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index ed5eb9ff0..28d1e6a22 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -2043,7 +2043,7 @@ const Action = struct { pub fn ringBell(target: apprt.Target) void { switch (target) { .app => {}, - .surface => |v| v.rt_surface.surface.ringBell(), + .surface => |v| v.rt_surface.surface.setBellRinging(true), } } diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk-ng/class/split_tree.zig index 10815bb0a..1da0896a2 100644 --- a/src/apprt/gtk-ng/class/split_tree.zig +++ b/src/apprt/gtk-ng/class/split_tree.zig @@ -418,6 +418,20 @@ pub const SplitTree = extern struct { if (entry.view.getFocused()) return entry.handle; } + // If none are currently focused, the most previously focused + // surface (if it exists) is our active surface. This lets things + // like apprt actions and bell ringing continue to work in the + // background. + if (self.private().last_focused.get()) |v| { + defer v.unref(); + + // We need to find the handle of the last focused surface. + it = tree.iterator(); + while (it.next()) |entry| { + if (entry.view == v) return entry.handle; + } + } + return null; } diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk-ng/class/surface.zig index 8487b24b0..631b93e42 100644 --- a/src/apprt/gtk-ng/class/surface.zig +++ b/src/apprt/gtk-ng/class/surface.zig @@ -47,6 +47,19 @@ pub const Surface = extern struct { pub const Tree = datastruct.SplitTree(Self); pub const properties = struct { + pub const @"bell-ringing" = struct { + pub const name = "bell-ringing"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = C.privateShallowFieldAccessor("bell_ringing"), + }, + ); + }; + pub const config = struct { pub const name = "config"; const impl = gobject.ext.defineProperty( @@ -257,21 +270,6 @@ pub const Surface = extern struct { ); }; - /// The bell is rung. - /// - /// The surface view handles the audio bell feature but none of the - /// others so it is up to the embedding widget to react to this. - pub const bell = struct { - pub const name = "bell"; - pub const connect = impl.connect; - const impl = gobject.ext.defineSignal( - name, - Self, - &.{}, - void, - ); - }; - /// Emitted whenever the clipboard has been written. pub const @"clipboard-write" = struct { pub const name = "clipboard-write"; @@ -456,6 +454,11 @@ pub const Surface = extern struct { // Progress bar progress_bar_timer: ?c_uint = null, + // True while the bell is ringing. This will be set to false (after + // true) under various scenarios, but can also manually be set to + // false by a parent widget. + bell_ringing: bool = false, + // Template binds child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, @@ -520,18 +523,6 @@ pub const Surface = extern struct { priv.gl_area.queueRender(); } - /// Ring the bell. - pub fn ringBell(self: *Self) void { - // TODO: Audio feature - - signals.bell.impl.emit( - self, - null, - .{}, - null, - ); - } - pub fn toggleFullscreen(self: *Self) void { signals.@"toggle-fullscreen".impl.emit( self, @@ -691,7 +682,7 @@ pub const Surface = extern struct { keycode: c_uint, gtk_mods: gdk.ModifierType, ) bool { - log.warn("keyEvent action={}", .{action}); + //log.warn("keyEvent action={}", .{action}); const event = ec_key.as(gtk.EventController).getCurrentEvent() orelse return false; const key_event = gobject.ext.cast(gdk.KeyEvent, event) orelse return false; const priv = self.private(); @@ -881,6 +872,10 @@ pub const Surface = extern struct { surface.preeditCallback(null) catch {}; } + // Bell stops ringing when any key is pressed that is used by + // the core in any way. + self.setBellRinging(false); + return true; }, } @@ -1383,6 +1378,17 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"mouse-hover-url".impl.param_spec); } + pub fn getBellRinging(self: *Self) bool { + return self.private().bell_ringing; + } + + pub fn setBellRinging(self: *Self, ringing: bool) void { + const priv = self.private(); + if (priv.bell_ringing == ringing) return; + priv.bell_ringing = ringing; + self.as(gobject.Object).notifyByPspec(properties.@"bell-ringing".impl.param_spec); + } + fn propConfig( self: *Self, _: *gobject.ParamSpec, @@ -1515,6 +1521,64 @@ pub const Surface = extern struct { priv.gl_area.as(gtk.Widget).setCursorFromName(name.ptr); } + fn propBellRinging( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + if (!priv.bell_ringing) return; + + // Activate actions if they exist + _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null); + _ = self.as(gtk.Widget).activateAction("win.ring-bell", null); + + // Do our sound + const config = if (priv.config) |c| c.get() else return; + if (config.@"bell-features".audio) audio: { + const config_path = config.@"bell-audio-path" orelse break :audio; + const path, const required = switch (config_path) { + .optional => |path| .{ path, false }, + .required => |path| .{ path, true }, + }; + + const volume = std.math.clamp( + config.@"bell-audio-volume", + 0.0, + 1.0, + ); + + assert(std.fs.path.isAbsolute(path)); + const media_file = gtk.MediaFile.newForFilename(path); + + // If the audio file is marked as required, we'll emit an error if + // there was a problem playing it. Otherwise there will be silence. + if (required) { + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + mediaFileError, + null, + .{ .detail = "error" }, + ); + } + + // Watch for the "ended" signal so that we can clean up after + // ourselves. + _ = gobject.Object.signals.notify.connect( + media_file, + ?*anyopaque, + mediaFileEnded, + null, + .{ .detail = "ended" }, + ); + + const media_stream = media_file.as(gtk.MediaStream); + media_stream.setVolume(volume); + media_stream.play(); + } + } + //--------------------------------------------------------------- // Signal Handlers @@ -1668,6 +1732,9 @@ pub const Surface = extern struct { priv.im_context.as(gtk.IMContext).focusIn(); _ = glib.idleAddOnce(idleFocus, self.ref()); self.as(gobject.Object).notifyByPspec(properties.focused.impl.param_spec); + + // Bell stops ringing as soon as we gain focus + self.setBellRinging(false); } fn ecFocusLeave(_: *gtk.EventControllerFocus, self: *Self) callconv(.c) void { @@ -1704,6 +1771,9 @@ pub const Surface = extern struct { ) callconv(.c) void { const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; + // Bell stops ringing if any mouse button is pressed. + self.setBellRinging(false); + // If we don't have focus, grab it. const priv = self.private(); const gl_area_widget = priv.gl_area.as(gtk.Widget); @@ -2314,6 +2384,35 @@ pub const Surface = extern struct { right.setVisible(0); } + fn mediaFileError( + media_file: *gtk.MediaFile, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const path = path: { + const file = media_file.getFile() orelse break :path null; + break :path file.getPath(); + }; + defer if (path) |p| glib.free(p); + + const media_stream = media_file.as(gtk.MediaStream); + const err = media_stream.getError() orelse return; + log.warn("error playing bell from {s}: {s} {d} {s}", .{ + path orelse "<>", + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "", + }); + } + + fn mediaFileEnded( + media_file: *gtk.MediaFile, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + media_file.unref(); + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -2378,9 +2477,11 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); + class.bindTemplateCallback("notify_bell_ringing", &propBellRinging); // Properties gobject.ext.registerProperties(class, &.{ + properties.@"bell-ringing".impl, properties.config.impl, properties.@"child-exited".impl, properties.@"default-size".impl, @@ -2397,7 +2498,6 @@ pub const Surface = extern struct { // Signals signals.@"close-request".impl.register(.{}); - signals.bell.impl.register(.{}); signals.@"clipboard-read".impl.register(.{}); signals.@"clipboard-write".impl.register(.{}); signals.init.impl.register(.{}); diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk-ng/class/tab.zig index 247a0351c..9a65cd2d7 100644 --- a/src/apprt/gtk-ng/class/tab.zig +++ b/src/apprt/gtk-ng/class/tab.zig @@ -11,6 +11,7 @@ const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const input = @import("../../../input.zig"); const CoreSurface = @import("../../../Surface.zig"); +const ext = @import("../ext.zig"); const gtk_version = @import("../gtk_version.zig"); const adw_version = @import("../adw_version.zig"); const gresource = @import("../build/gresource.zig"); @@ -175,6 +176,9 @@ pub const Tab = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + // Init our actions + self.initActions(); + // If our configuration is null then we get the configuration // from the application. const priv = self.private(); @@ -194,6 +198,46 @@ pub const Tab = extern struct { }; } + /// Setup our action map. + fn initActions(self: *Self) void { + // The set of actions. Each action has (in order): + // [0] The action name + // [1] The callback function + // [2] The glib.VariantType of the parameter + // + // For action names: + // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html + const actions = .{ + .{ "ring-bell", actionRingBell, null }, + }; + + // We need to collect our actions into a group since we're just + // a plain widget that doesn't implement ActionGroup directly. + const group = gio.SimpleActionGroup.new(); + errdefer group.unref(); + const map = group.as(gio.ActionMap); + inline for (actions) |entry| { + const action = gio.SimpleAction.new( + entry[0], + entry[2], + ); + defer action.unref(); + _ = gio.SimpleAction.signals.activate.connect( + action, + *Self, + entry[1], + self, + .{}, + ); + map.addAction(action.as(gio.Action)); + } + + self.as(gtk.Widget).insertActionGroup( + "tab", + group.as(gio.ActionGroup), + ); + } + //--------------------------------------------------------------- // Properties @@ -223,6 +267,15 @@ pub const Tab = extern struct { return core_surface.needsConfirmQuit(); } + /// Get the tab page holding this tab, if any. + fn getTabPage(self: *Self) ?*adw.TabPage { + const tab_view = ext.getAncestor( + adw.TabView, + self.as(gtk.Widget), + ) orelse return null; + return tab_view.getPage(self.as(gtk.Widget)); + } + //--------------------------------------------------------------- // Virtual methods @@ -291,33 +344,66 @@ pub const Tab = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec); } + fn actionRingBell( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + // Future note: I actually don't like this logic living here at all. + // I think a better approach will be for the ring bell action to + // specify its sending surface and then do all this in the window. + + // If the page is selected already we don't mark it as needing + // attention. We only want to mark unfocused pages. This will then + // clear when the page is selected. + const page = self.getTabPage() orelse return; + if (page.getSelected() != 0) return; + page.setNeedsAttention(@intFromBool(true)); + } + fn closureComputedTitle( _: *Self, + config_: ?*Config, plain_: ?[*:0]const u8, zoomed_: c_int, + bell_ringing_: c_int, + _: *gobject.ParamSpec, ) callconv(.c) ?[*:0]const u8 { const zoomed = zoomed_ != 0; + const bell_ringing = bell_ringing_ != 0; + const plain = plain: { const default = "Ghostty"; const plain = plain_ orelse break :plain default; break :plain std.mem.span(plain); }; - // If we're zoomed, prefix with the magnifying glass emoji. - if (zoomed) zoomed: { - // This results in an extra allocation (that we free), but I - // prefer using the Zig APIs so much more than the libc ones. - const alloc = Application.default().allocator(); - const slice = std.fmt.allocPrint( - alloc, - "🔍 {s}", - .{plain}, - ) catch break :zoomed; - defer alloc.free(slice); - return glib.ext.dupeZ(u8, slice); + // We don't need a config in every case, but if we don't have a config + // let's just assume something went terribly wrong and use our + // default title. Its easier then guarding on the config existing + // in every case for something so unlikely. + const config = if (config_) |v| v.get() else { + log.warn("config unavailable for computed title, likely bug", .{}); + return glib.ext.dupeZ(u8, plain); + }; + + // Use an allocator to build up our string as we write it. + var buf: std.ArrayList(u8) = .init(Application.default().allocator()); + defer buf.deinit(); + const writer = buf.writer(); + + // If our bell is ringing, then we prefix the bell icon to the title. + if (bell_ringing and config.@"bell-features".title) { + writer.writeAll("🔔 ") catch {}; } - return glib.ext.dupeZ(u8, plain); + // If we're zoomed, prefix with the magnifying glass emoji. + if (zoomed) { + writer.writeAll("🔍 ") catch {}; + } + + writer.writeAll(plain) catch return glib.ext.dupeZ(u8, plain); + return glib.ext.dupeZ(u8, buf.items); } const C = Common(Self, Private); diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk-ng/class/window.zig index 447fb0a40..a480ed217 100644 --- a/src/apprt/gtk-ng/class/window.zig +++ b/src/apprt/gtk-ng/class/window.zig @@ -336,6 +336,7 @@ pub const Window = extern struct { .{ "close-tab", actionCloseTab, null }, .{ "new-tab", actionNewTab, null }, .{ "new-window", actionNewWindow, null }, + .{ "ring-bell", actionRingBell, null }, .{ "split-right", actionSplitRight, null }, .{ "split-left", actionSplitLeft, null }, .{ "split-up", actionSplitUp, null }, @@ -1317,6 +1318,10 @@ pub const Window = extern struct { // Setup our binding group. This ensures things like the title // are synced from the active tab. priv.tab_bindings.setSource(child.as(gobject.Object)); + + // If the tab was previously marked as needing attention + // (e.g. due to a bell character), we now unmark that + page.setNeedsAttention(@intFromBool(false)); } fn tabViewPageAttached( @@ -1729,6 +1734,30 @@ pub const Window = extern struct { self.performBindingAction(.clear_screen); } + fn actionRingBell( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + const priv = self.private(); + const config = if (priv.config) |v| v.get() else return; + + if (config.@"bell-features".system) system: { + const native = self.as(gtk.Native).getSurface() orelse { + log.warn("unable to get native surface from window", .{}); + break :system; + }; + native.beep(); + } + + if (config.@"bell-features".attention) { + // Request user attention + self.winproto().setUrgent(true) catch |err| { + log.warn("failed to request user attention={}", .{err}); + }; + } + } + /// Toggle the command palette. /// /// TODO: accept the surface that toggled the command palette as a parameter diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk-ng/ui/1.2/surface.blp index 23499c7f3..49aae0a04 100644 --- a/src/apprt/gtk-ng/ui/1.2/surface.blp +++ b/src/apprt/gtk-ng/ui/1.2/surface.blp @@ -6,6 +6,7 @@ template $GhosttySurface: Adw.Bin { "surface", ] + notify::bell-ringing => $notify_bell_ringing(); notify::config => $notify_config(); notify::mouse-hover-url => $notify_mouse_hover_url(); notify::mouse-hidden => $notify_mouse_hidden(); diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk-ng/ui/1.5/tab.blp index 6431bb5c9..4b92e38f2 100644 --- a/src/apprt/gtk-ng/ui/1.5/tab.blp +++ b/src/apprt/gtk-ng/ui/1.5/tab.blp @@ -8,7 +8,7 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; - title: bind $computed_title(split_tree.active-surface as <$GhosttySurface>.title, split_tree.is-zoomed) as ; + title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; $GhosttySplitTree split_tree {