GTK: Search UI (#9756)
Fixes #189 This adds the UI for search to GTK. There is still polish to be done in follow-ups but this makes search work well with GTK to start! **AI disclosure:** Believe it or not, almost this entire PR was AI-written. Amp did an excellent job looking at our existing codebase, comparing it to the macOS codebase, writing blueprint files, etc. I reviewed everything written, modified some basics, and verified it manually and under Valgrind. ## Demo <img width="1654" height="1234" alt="CleanShot 2025-11-29 at 20 52 11@2x" src="https://github.com/user-attachments/assets/0eb38367-398f-4165-9838-7a35465857bc" /> ## Future Improvements - When dragging the overlay, we should change the cursor and show drop targets - There's probably some small stylistic tweaks we can make to this - I'm not sure the CSS is right for both light and dark modes so we may need to tweak thatpull/9761/head
commit
34fd1dd0f6
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,465 @@
|
|||
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 = C.privateShallowFieldAccessor("active"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
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 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 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;
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
||||
|
|
@ -1951,6 +1955,28 @@ 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 +3196,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 +3239,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 +3259,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 +3297,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, &.{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue