Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The profile selector: the configuration's profiles, narrowed to those//! matching what has been typed, and the one selected to open.//!//! What is typed matches a profile as `match` says: each word of it found,//! its letters in order, in the profile's name or, failing that, in what//! the profile runs — `rtr` finds `router`. Profiles are ordered by how//! well they match, words found in the name first. Equally good matches//! keep the configuration's order, and with nothing typed every profile is//! listed in it.
const std = @import("std");const Allocator = std.mem.Allocator;const match = @import("match.zig");
const Config = @import("config/Config.zig");const l10n = @import("l10n.zig");
const ProfileSelector = @This();
gpa: Allocator,/// What has been typed.query: std.ArrayList(u8) = .empty,/// The profiles matching it, best first, as indexes into the/// configuration's.matches: std.ArrayList(usize) = .empty,/// Which of `matches` is selected.selected: usize = 0,/// The first of `matches` shown, when there are more than fit.scroll: usize = 0,
/// How many profiles are shown at once.pub const rows_shown = 10;
pub fn init(gpa: Allocator, profiles: []const Config.Profile) Allocator.Error!ProfileSelector { var p: ProfileSelector = .{ .gpa = gpa }; try p.filter(profiles); return p;}
pub fn deinit(p: *ProfileSelector) void { p.query.deinit(p.gpa); p.matches.deinit(p.gpa); p.* = undefined;}
/// Adds `text` to the query.pub fn append(p: *ProfileSelector, profiles: []const Config.Profile, text: []const u8) Allocator.Error!void { try p.query.appendSlice(p.gpa, text); try p.filter(profiles);}
/// Adds pasted `text` to the query, its control characters — a newline at/// its end, say — left out.pub fn paste(p: *ProfileSelector, profiles: []const Config.Profile, text: []const u8) Allocator.Error!void { for (text) |c| if (c >= 0x20 and c != 0x7f) try p.query.append(p.gpa, c); try p.filter(profiles);}
/// Takes the query's last character off.pub fn backspace(p: *ProfileSelector, profiles: []const Config.Profile) Allocator.Error!void { const q = p.query.items; if (q.len == 0) return; var i = q.len - 1; // Back to the first byte of the last UTF-8 sequence. while (i > 0 and q[i] & 0xc0 == 0x80) i -= 1; p.query.shrinkRetainingCapacity(i); try p.filter(profiles);}
pub fn clear(p: *ProfileSelector, profiles: []const Config.Profile) Allocator.Error!void { p.query.clearRetainingCapacity(); try p.filter(profiles);}
/// Finds the profiles matching the query, again: after it changed, or the/// configuration did. The first is selected.pub fn filter(p: *ProfileSelector, profiles: []const Config.Profile) Allocator.Error!void { p.matches.clearRetainingCapacity(); p.selected = 0; p.scroll = 0; var wbuf: [match.max_words][]const u8 = undefined; const ws = match.words(p.query.items, &wbuf); if (ws.len == 0) { for (0..profiles.len) |i| try p.matches.append(p.gpa, i); return; } var scores: std.ArrayList(match.Score) = .empty; defer scores.deinit(p.gpa); for (profiles, 0..) |*e, i| { var buf: [512]u8 = undefined; const s = match.score(ws, .{ .text = e.name }, .{ .text = describe(e, &buf) }) orelse continue; try p.matches.append(p.gpa, i); try scores.append(p.gpa, s); } // Best first; the configuration's order among equals. const Ctx = struct { scores: []match.Score, matches: []usize, pub fn lessThan(c: @This(), a: usize, b: usize) bool { return c.scores[a].better(c.scores[b]); } pub fn swap(c: @This(), a: usize, b: usize) void { std.mem.swap(match.Score, &c.scores[a], &c.scores[b]); std.mem.swap(usize, &c.matches[a], &c.matches[b]); } }; std.sort.insertionContext(0, p.matches.items.len, Ctx{ .scores = scores.items, .matches = p.matches.items });}
/// Where what has been typed was found in `name` and in `what`, the/// profile's name and what it runs: the byte offsets of the letters/// matched, in `name_buf` and `what_buf`.pub fn found( p: *const ProfileSelector, name: []const u8, what: []const u8, name_buf: []usize, what_buf: []usize,) struct { name: []const usize, what: []const usize } { var wbuf: [match.max_words][]const u8 = undefined; const h = match.highlight(match.words(p.query.items, &wbuf), .{ .text = name }, .{ .text = what }, name_buf, what_buf); return .{ .name = h.first, .what = h.second };}
/// The selected profile, as an index into the configuration's.pub fn current(p: *const ProfileSelector) ?usize { if (p.selected >= p.matches.items.len) return null; return p.matches.items[p.selected];}
/// Moves the selection by `by`: a step past either end wraps round to the/// other, as a list of a few things expects, and a page stops at the end.pub fn move(p: *ProfileSelector, by: isize) void { const n = p.matches.items.len; if (n == 0) return; const at: isize = @intCast(p.selected); const to = at + by; p.selected = if (by == 1 or by == -1) @intCast(@mod(to, @as(isize, @intCast(n)))) else @intCast(std.math.clamp(to, 0, @as(isize, @intCast(n - 1)))); p.reveal();}
/// Selects the `row`th of those shown.pub fn pick(p: *ProfileSelector, row: usize) bool { const i = p.scroll + row; if (row >= rows_shown or i >= p.matches.items.len) return false; p.selected = i; return true;}
/// Scrolls by `rows`, the selection staying where it is.pub fn scrollBy(p: *ProfileSelector, rows: isize) void { const top = p.matches.items.len -| rows_shown; const to = @as(isize, @intCast(p.scroll)) + rows; p.scroll = @intCast(std.math.clamp(to, 0, @as(isize, @intCast(top))));}
/// Scrolls the selection into view.fn reveal(p: *ProfileSelector) void { if (p.selected < p.scroll) p.scroll = p.selected; if (p.selected >= p.scroll + rows_shown) p.scroll = p.selected + 1 - rows_shown;}
/// What `e` runs, as the profile selector shows it after its name: its/// command, its port, or nothing for the shell.pub fn describe(e: *const Config.Profile, buf: []u8) []const u8 { var w: std.Io.Writer = .fixed(buf); if (e.serial) |s| { // A speed, not a quantity: 115200, never 115,200. var baud_buf: [16]u8 = undefined; const baud = std.fmt.bufPrint(&baud_buf, "{d}", .{s.baud}) catch unreachable; l10n.write(&w, "profile-selector-serial", &.{ l10n.str("path", s.path), l10n.str("baud", baud) }) catch {}; } else if (e.command) |c| for (c, 0..) |arg, i| { if (i > 0) w.writeByte(' ') catch break; w.writeAll(arg) catch break; }; return w.buffered();}
const testing = std.testing;
const test_profiles = [_]Config.Profile{ .{ .name = "nas", .command = &.{ "ssh", "nas.lan" } }, .{ .name = "router", .command = &.{ "ssh", "-p", "2222", "admin@router" } }, .{ .name = "build-server", .command = &.{ "ssh", "builder" } }, .{ .name = "console", .serial = .{ .path = "/dev/ttyUSB0", .baud = 9600 } },};
fn names(p: *const ProfileSelector, buf: [][]const u8) [][]const u8 { for (p.matches.items, 0..) |i, n| buf[n] = test_profiles[i].name; return buf[0..p.matches.items.len];}
test "nothing typed lists every profile in order" { var p: ProfileSelector = try .init(testing.allocator, &test_profiles); defer p.deinit(); var buf: [8][]const u8 = undefined; try testing.expectEqualDeep(@as([]const []const u8, &.{ "nas", "router", "build-server", "console" }), names(&p, &buf)); try testing.expectEqual(0, p.current());}
test "letters in order match, the better match first" { var p: ProfileSelector = try .init(testing.allocator, &test_profiles); defer p.deinit(); var buf: [8][]const u8 = undefined; try p.append(&test_profiles, "rtr"); try testing.expectEqualDeep(@as([]const []const u8, &.{"router"}), names(&p, &buf)); try p.clear(&test_profiles); // "s" starts a word in "build-server", and is only inside "nas" and // "console". try p.append(&test_profiles, "se"); try testing.expectEqualStrings("build-server", names(&p, &buf)[0]); // What a profile runs matches too, after every name that does. try p.clear(&test_profiles); try p.append(&test_profiles, "BUILDER"); try testing.expectEqualDeep(@as([]const []const u8, &.{"build-server"}), names(&p, &buf)); try p.clear(&test_profiles); try p.append(&test_profiles, "2222"); try testing.expectEqualDeep(@as([]const []const u8, &.{"router"}), names(&p, &buf)); try p.append(&test_profiles, "x"); try testing.expectEqual(0, p.matches.items.len); try testing.expectEqual(null, p.current());}
test "each word is found on its own, in the name or what it runs" { var p: ProfileSelector = try .init(testing.allocator, &test_profiles); defer p.deinit(); var buf: [8][]const u8 = undefined; // "ssh" only in what three of them run, "bld" only in one's name. try p.append(&test_profiles, "ssh bld"); try testing.expectEqualDeep(@as([]const []const u8, &.{"build-server"}), names(&p, &buf)); // Found in the name, a word counts for more than found in what runs. try p.clear(&test_profiles); try p.append(&test_profiles, "nas"); try testing.expectEqualStrings("nas", names(&p, &buf)[0]);}
test "what was typed is found where it matched, for underlining" { var p: ProfileSelector = try .init(testing.allocator, &test_profiles); defer p.deinit(); try p.append(&test_profiles, "rtr 2222"); var nb: [32]usize = undefined; var wb: [32]usize = undefined; const f = p.found("router", "ssh -p 2222 admin@router", &nb, &wb); try testing.expectEqualSlices(usize, &.{ 0, 3, 5 }, f.name); try testing.expectEqualSlices(usize, &.{ 7, 8, 9, 10 }, f.what);}
test "a paste leaves out control characters, and backspace a character" { var p: ProfileSelector = try .init(testing.allocator, &test_profiles); defer p.deinit(); try p.paste(&test_profiles, "rou\r\n"); try testing.expectEqualStrings("rou", p.query.items); try p.append(&test_profiles, "é"); try p.backspace(&test_profiles); try testing.expectEqualStrings("rou", p.query.items); try testing.expectEqual(1, p.current());}
test "a step wraps round; a page stops at the end, and stays in view" { var many: [25]Config.Profile = undefined; for (&many) |*e| e.* = .{ .name = "x" }; var p: ProfileSelector = try .init(testing.allocator, &many); defer p.deinit(); p.move(-1); try testing.expectEqual(24, p.selected); try testing.expectEqual(15, p.scroll); p.move(1); try testing.expectEqual(0, p.selected); try testing.expectEqual(0, p.scroll); p.move(rows_shown); try testing.expectEqual(10, p.selected); try testing.expectEqual(1, p.scroll); p.move(100); try testing.expectEqual(24, p.selected); p.scrollBy(-100); try testing.expectEqual(0, p.scroll); try testing.expect(p.pick(3)); try testing.expectEqual(3, p.selected); try testing.expect(!p.pick(rows_shown));}
test "a profile is described by what it runs" { var buf: [128]u8 = undefined; try testing.expectEqualStrings("ssh -p 2222 admin@router", describe(&test_profiles[1], &buf)); try testing.expect(std.mem.find(u8, describe(&test_profiles[3], &buf), "/dev/ttyUSB0 · 9600") != null); try testing.expectEqualStrings("", describe(&.{ .name = "shell" }, &buf));}