6 Commits

Author SHA1 Message Date
Mitchell Hashimoto
1f104cc09c apprt/gtk-ng: audio bell 2025-08-13 09:15:25 -07:00
Mitchell Hashimoto
2444fcdce5 apprt/gtk-ng: split tree active focus should be last focused fallback 2025-08-13 09:06:04 -07:00
Mitchell Hashimoto
f9896c8ef7 apprt/gtk-ng: tab attention for bell 2025-08-13 08:59:36 -07:00
Mitchell Hashimoto
156278e6c1 apprt/gtk-ng: win.ring-bell 2025-08-13 08:53:18 -07:00
Mitchell Hashimoto
02e3aac1e5 apprt/gtk-ng: hook up bell into title 2025-08-13 08:43:42 -07:00
Mitchell Hashimoto
c1b187d120 apprt/gtk-ng: surface bell-ringing property 2025-08-13 08:32:52 -07:00
8 changed files with 291 additions and 44 deletions

View File

@@ -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.
///

View File

@@ -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),
}
}

View File

@@ -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;
}

View File

@@ -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 "<<unknown>>",
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(.{});

View File

@@ -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);

View File

@@ -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

View File

@@ -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();

View File

@@ -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 <string>;
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 <string>;
tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd;
$GhosttySplitTree split_tree {