// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Finding open panes, and profiles, from GNOME Shell's search: what each //! is known by, and which a search's terms find. A pane found is shown; a //! profile found is started. //! //! The Shell splits what was typed into terms on whitespace, and they are //! matched as `match` says: a pane is known by its title and then its //! location, a profile by its name and then what it runs. The location is //! as the title bar's subtitle writes it, host and all, so a host's name //! finds the panes on it. Open panes come before the profiles that would //! open another, each best match first. const std = @import("std"); const Allocator = std.mem.Allocator; const match = @import("match.zig"); /// The most results a search returns. pub const max_results = 20; /// A pane as the search sees it: copied out of the window it is in, since /// the search runs on the session bus's thread. pub const Pane = struct { id: u64, title: []const u8, /// Its location, as `location.format` writes it; empty when it has /// none. location: []const u8, }; /// A profile as the search sees it, copied out of the configuration. pub const Profile = struct { name: []const u8, /// What it runs, as the profile selector describes it. runs: []const u8, /// How a result shows it, made where the translations are. description: []const u8, /// It opens as a tab rather than a window. tab: bool, }; /// How well `terms` match `pane`, by its title and then its location. pub fn score(pane: Pane, terms: []const []const u8) ?match.Score { return match.score(terms, .{ .text = pane.title }, .{ .text = pane.location, .path = true }); } /// How well `terms` match `profile`, by its name and then what it runs. pub fn scoreProfile(profile: Profile, terms: []const []const u8) ?match.Score { return match.score(terms, .{ .text = profile.name }, .{ .text = profile.runs }); } /// The panes and then the profiles `terms` find, as result identifiers /// allocated in `arena`: a pane already open before a profile that would /// open another, each best first, and among equals in the order given; at /// most `max_results`. /// /// A search narrowed by more typing is made afresh rather than among what /// it found before: those were cut off at `max_results`, and what more /// typing finds may be among what was cut. pub fn find( arena: Allocator, panes: []const Pane, profiles: []const Profile, terms: []const []const u8, ) Allocator.Error![]const []const u8 { var found: std.ArrayList(Found) = .empty; for (panes) |p| { const s = score(p, terms) orelse continue; const id = try std.fmt.allocPrint(arena, "{f}", .{Result{ .pane = p.id }}); try found.append(arena, .{ .id = id, .score = s }); } const n_panes = found.items.len; for (profiles) |p| { const s = scoreProfile(p, terms) orelse continue; const id = try std.fmt.allocPrint(arena, "{f}", .{Result{ .profile = p.name }}); try found.append(arena, .{ .id = id, .score = s }); } // Stable, so that equals keep the order given. std.sort.insertion(Found, found.items[0..n_panes], {}, Found.better); std.sort.insertion(Found, found.items[n_panes..], {}, Found.better); const n = @min(found.items.len, max_results); const out = try arena.alloc([]const u8, n); for (out, found.items[0..n]) |*o, f| o.* = f.id; return out; } const Found = struct { id: []const u8, score: match.Score, fn better(_: void, a: Found, b: Found) bool { return a.score.better(b.score); } }; /// What a result's identifier names. pub const Result = union(enum) { pane: u64, profile: []const u8, /// `pane-` and the pane's ID, or `profile-` and the profile's name. pub fn parse(id: []const u8) ?Result { if (std.mem.startsWith(u8, id, pane_prefix)) return .{ .pane = std.fmt.parseInt(u64, id[pane_prefix.len..], 10) catch return null }; if (std.mem.startsWith(u8, id, profile_prefix) and id.len > profile_prefix.len) return .{ .profile = id[profile_prefix.len..] }; return null; } pub fn format(r: Result, w: *std.Io.Writer) std.Io.Writer.Error!void { switch (r) { .pane => |id| try w.print(pane_prefix ++ "{d}", .{id}), .profile => |name| try w.print(profile_prefix ++ "{s}", .{name}), } } const pane_prefix = "pane-"; const profile_prefix = "profile-"; }; /// A pane result's identifier, in `buf`. pub fn resultId(buf: []u8, id: u64) []const u8 { return std.fmt.bufPrint(buf, "{f}", .{Result{ .pane = id }}) catch unreachable; } /// The pane a result's identifier names, or null if it names none. pub fn paneOf(result: []const u8) ?u64 { const r = Result.parse(result) orelse return null; return if (r == .pane) r.pane else null; } const testing = std.testing; test "every term must be found, in the title or the location" { const p: Pane = .{ .id = 1, .title = "vim README.md", .location = "~/dev/harrier" }; try testing.expect(score(p, &.{"vim"}) != null); try testing.expect(score(p, &.{ "VIM", "harrier" }) != null); try testing.expect(score(p, &.{"rdme"}) != null); try testing.expect(score(p, &.{ "vim", "emacs" }) == null); try testing.expect(score(.{ .id = 2, .title = "top", .location = "" }, &.{"harrier"}) == null); // Another machine's name finds the panes on it. const there: Pane = .{ .id = 3, .title = "vim", .location = "router:/etc/nginx" }; try testing.expect(score(there, &.{ "router", "nginx" }) != null); // Nothing typed finds nothing. try testing.expect(score(p, &.{}) == null); } test "panes come before profiles, each best first, equals in order" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const panes = [_]Pane{ .{ .id = 1, .title = "bash", .location = "~/src/router-firmware" }, .{ .id = 2, .title = "router", .location = "~" }, .{ .id = 3, .title = "top", .location = "~" }, .{ .id = 4, .title = "router", .location = "/tmp" }, }; const profiles = [_]Profile{ .{ .name = "nas", .runs = "ssh nas.lan", .description = "", .tab = false }, .{ .name = "router", .runs = "ssh admin@router", .description = "", .tab = false }, }; const got = try find(a, &panes, &profiles, &.{"rtr"}); try testing.expectEqual(@as(usize, 4), got.len); // Found in the title, in order; then only in the location; then the // profile, though it matches as well as the best pane. try testing.expectEqualStrings("pane-2", got[0]); try testing.expectEqualStrings("pane-4", got[1]); try testing.expectEqualStrings("pane-1", got[2]); try testing.expectEqualStrings("profile-router", got[3]); } test "the best are kept when there are more than can be returned" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); var panes: [max_results + 5]Pane = undefined; for (&panes, 0..) |*p, i| p.* = .{ .id = i, .title = "a-b-c", .location = "" }; panes[max_results + 2].title = "abc"; const got = try find(arena.allocator(), &panes, &.{}, &.{"abc"}); try testing.expectEqual(@as(usize, max_results), got.len); try testing.expectEqualStrings("pane-22", got[0]); } test "more typing finds what the first words found too many of to return" { var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); var profiles: [max_results + 5]Profile = undefined; for (&profiles, 0..) |*p, i| p.* = .{ .name = try std.fmt.allocPrint(a, "ankeny-b01-stk-{d}", .{i}), .runs = "", .description = "", .tab = false }; profiles[max_results + 2].name = "ankeny-b06-r18-core-1"; try testing.expectEqual(@as(usize, max_results), (try find(a, &.{}, &profiles, &.{"ank"})).len); const got = try find(a, &.{}, &profiles, &.{ "ank", "core" }); try testing.expectEqual(@as(usize, 1), got.len); try testing.expectEqualStrings("profile-ankeny-b06-r18-core-1", got[0]); } test "result identifiers name panes and profiles" { var buf: [32]u8 = undefined; try testing.expectEqualStrings("pane-42", resultId(&buf, 42)); try testing.expectEqual(@as(?u64, 42), paneOf("pane-42")); try testing.expectEqual(@as(?u64, null), paneOf("pane-")); try testing.expectEqual(@as(?u64, null), paneOf("tab-42")); try testing.expectEqualStrings("admin pwsh", Result.parse("profile-admin pwsh").?.profile); try testing.expectEqual(@as(?u64, null), paneOf("profile-router")); try testing.expect(Result.parse("profile-") == null); try testing.expectEqualStrings("profile-router", try std.fmt.bufPrint(&buf, "{f}", .{Result{ .profile = "router" }})); } test "a profile is found by its name or what it runs" { const p: Profile = .{ .name = "router", .runs = "ssh admin@router.lan", .description = "", .tab = false }; try testing.expect(scoreProfile(p, &.{"ROUTER"}) != null); try testing.expect(scoreProfile(p, &.{ "ssh", "admin" }) != null); try testing.expect(scoreProfile(p, &.{ "router", "nas" }) == null); try testing.expect(scoreProfile(p, &.{}) == null); }