Skip to content
Open
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
1,370 changes: 1,370 additions & 0 deletions src/apprt/a11y_offsets.zig

Large diffs are not rendered by default.

438 changes: 438 additions & 0 deletions src/apprt/a11y_text.zig

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/apprt/gtk/class/application.zig
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,21 @@ pub const Application = extern struct {
// logging system rather than just getting dumped directly to stderr.
_ = glib.logSetWriterFunc(glibLogWriterFunction, null, null);

// Seed the program name and application name before any GTK /
// libadwaita initialization. The AT-SPI bridge reads
// `g_get_prgname()` for the application object's "Name" property
// (`gtkatspiroot.c`: `g_variant_new_string (g_get_prgname () ?
// g_get_prgname () : "Unnamed")`) and `g_get_application_name()`
// for "Description". Without prgname we appear to screen readers
// and to accerciser as "Unnamed", and can't be located by name.
//
// On X11, `winproto/x11.zig` may override prgname later to derive
// WM_CLASS, which is fine; on Wayland this stays as "Ghostty".
// `g_set_application_name` is one-shot, so setting it this early
// also guarantees it wins over anything GTK would otherwise pick.
glib.setPrgname("Ghostty");
glib.setApplicationName("Ghostty");

// Log our GTK versions
gtk_version.logVersion();
adw_version.logVersion();
Expand Down Expand Up @@ -3161,6 +3176,10 @@ const Action = struct {
const win = gobject.ext.newInstance(Window, .{
.application = self,
.@"quick-terminal" = true,
// Seed the accessible role for the same reason `Window.new`
// does; this path builds its instance directly and would
// otherwise get the default `.widget` role.
.@"accessible-role" = gtk.AccessibleRole.window,
});
assert(win.isQuickTerminal());
initAndShowWindow(self, win, null, .none);
Expand Down
70 changes: 57 additions & 13 deletions src/apprt/gtk/class/surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ const gresource = @import("../build/gresource.zig");
const ext = @import("../ext.zig");
const gsettings = @import("../gsettings.zig");
const gtk_key = @import("../key.zig");
const gtk_version = @import("../gtk_version.zig");
const ApprtSurface = @import("../Surface.zig");
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 KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay;
const A11y = @import("surface_a11y.zig");
const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
const TitleDialog = @import("title_dialog.zig").TitleDialog;
Expand All @@ -37,15 +39,18 @@ const InspectorWindow = @import("inspector_window.zig").InspectorWindow;
const SplitTree = @import("split_tree.zig").SplitTree;
const i18n = @import("../../../os/i18n.zig");
const global = @import("../../../global.zig");
const gtk_version = @import("../gtk_version.zig");

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

pub const Surface = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const Implements = [_]type{gtk.Scrollable};
pub const Implements = [_]type{
gtk.Scrollable,
gtk.Accessible,
gtk.AccessibleText,
};
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttySurface",
.instanceInit = &init,
Expand All @@ -54,6 +59,17 @@ pub const Surface = extern struct {
.private = .{ .Type = Private, .offset = &Private.offset },
.implements = &.{
gobject.ext.implement(gtk.Scrollable, .{}),
// Re-implement GtkAccessible (already provided by GtkWidget
// ancestry) so we can override `get_first_accessible_child`
// and present as a single text-bearing object. The iface_init
// receives the parent's already-populated vtable and only
// overrides that one child-walk hook.
gobject.ext.implement(gtk.Accessible, .{
.init = &A11y.initAccessibleInterface,
}),
gobject.ext.implement(gtk.AccessibleText, .{
.init = &A11y.initAccessibleTextInterface,
}),
},
});

Expand Down Expand Up @@ -695,6 +711,9 @@ pub const Surface = extern struct {

action_group: ?*gio.SimpleActionGroup = null,

// Accessibility state for GtkAccessibleText; see surface_a11y.zig.
a11y: A11y = .{},

// Gtk.Scrollable interface adjustments
hadj: ?*gtk.Adjustment = null,
vadj: ?*gtk.Adjustment = null,
Expand Down Expand Up @@ -768,6 +787,11 @@ pub const Surface = extern struct {
return priv.core_surface;
}

/// The accessibility state for this surface.
pub fn a11y(self: *Self) *A11y {
return &self.private().a11y;
}

pub fn rt(self: *Self) *ApprtSurface {
const priv = self.private();
return &priv.rt_surface;
Expand Down Expand Up @@ -1755,8 +1779,11 @@ pub const Surface = extern struct {
/// Focus this surface. This properly focuses the input part of
/// our surface.
pub fn grabFocus(self: *Self) void {
const priv = self.private();
_ = priv.gl_area.as(gtk.Widget).grabFocus();
// Focus the GhosttySurface itself (not the inner GLArea) so the
// focused object exposes role=terminal and GtkAccessibleText to
// AT-SPI. The EventControllerKey attached to the template root
// receives key events from this focus target.
_ = self.as(gtk.Widget).grabFocus();
}

pub fn sendDesktopNotification(self: *Self, title: [:0]const u8, body: [:0]const u8) void {
Expand Down Expand Up @@ -1814,6 +1841,7 @@ pub const Surface = extern struct {
priv.mapped = false;
priv.size = .{ .width = 0, .height = 0 };
priv.vadj_signal_group = null;
priv.a11y = .{};

// If our configuration is null then we get the configuration
// from the application.
Expand Down Expand Up @@ -2002,6 +2030,8 @@ pub const Surface = extern struct {
for (priv.key_tables.items) |s| alloc.free(s);
priv.key_tables.deinit(alloc);

priv.a11y.deinit(alloc);

gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
Expand Down Expand Up @@ -2848,11 +2878,12 @@ pub const Surface = extern struct {
const priv = self.private();
const core_surface = priv.core_surface orelse return;

// If we don't have focus, grab it.
const gl_area_widget = priv.gl_area.as(gtk.Widget);
const had_focus = gl_area_widget.hasFocus() != 0;
// If we don't have focus, grab it. Focus is tracked on the
// GhosttySurface itself (see `grabFocus`), not the GLArea.
const widget = self.as(gtk.Widget);
const had_focus = widget.hasFocus() != 0;
if (!had_focus) {
_ = gl_area_widget.grabFocus();
_ = widget.grabFocus();
}

// Report the event
Expand Down Expand Up @@ -2985,13 +3016,14 @@ pub const Surface = extern struct {
@abs(priv.cursor_pos.y - pos.y) < 1;
if (is_cursor_still) return;

// If we don't have focus, and we want it, grab it.
// If we don't have focus, and we want it, grab it. Focus lives
// on the GhosttySurface itself, not the inner GLArea.
if (priv.config) |config| {
const gl_area_widget = priv.gl_area.as(gtk.Widget);
if (gl_area_widget.hasFocus() == 0 and
const widget = self.as(gtk.Widget);
if (widget.hasFocus() == 0 and
config.get().@"focus-follows-mouse")
{
_ = gl_area_widget.grabFocus();
_ = widget.grabFocus();
}
}

Expand Down Expand Up @@ -3425,6 +3457,11 @@ pub const Surface = extern struct {
return 0;
};

// Notify AT-SPI clients of any content change, gated so that an
// unchanged frame emits nothing and an AT client is never
// interrupted mid-read by a cursor blink. See `A11y.frameRendered`.
priv.a11y.frameRendered(self);

return 1;
}

Expand Down Expand Up @@ -3496,6 +3533,7 @@ pub const Surface = extern struct {
fn initSurface(self: *Self) InitError!void {
const priv: *Private = self.private();
assert(priv.core_surface == null);

const gl_area = priv.gl_area;

// We need to make the context current so we can call GL functions.
Expand Down Expand Up @@ -3639,7 +3677,7 @@ pub const Surface = extern struct {
_ = surface.performBindingAction(.end_search) catch |err| {
log.warn("unable to perform end_search action err={}", .{err});
};
_ = self.private().gl_area.as(gtk.Widget).grabFocus();
_ = self.as(gtk.Widget).grabFocus();
}

fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void {
Expand Down Expand Up @@ -3852,6 +3890,12 @@ pub const Surface = extern struct {
gobject.ext.ensureType(SearchOverlay);
gobject.ext.ensureType(KeyStateOverlay);
gobject.ext.ensureType(ChildExited);

// Set the accessible role to terminal so screen readers
// like Orca know how to handle this widget. This matches
// GTK_ACCESSIBLE_ROLE_TERMINAL added in GTK 4.14.
gtk.WidgetClass.setAccessibleRole(class.as(gtk.Widget.Class), .terminal);

gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
Expand Down
Loading