// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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)); }