Skip to content
Merged
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ Bindings follow GNU screen's defaults, including the `C-x` variants
browsing the list without attaching (`C-a Up`/`C-a Down`, then
`Enter` to attach or `Esc` to cancel), resizing the sidebar
(`C-a Left`/`C-a Right`, then `Enter` to keep or `Esc` to cancel),
creating (`C-a c`), killing (`C-a k`), renaming (`C-a r`), and
searching (`C-a s`) sessions; pressing `C-a` alone lists them in
the bottom bar. See `boo help ui`.
creating (`C-a c`), killing (`C-a k`), and renaming (`C-a r`)
sessions, and going to a session by name (`C-a g`); pressing `C-a`
alone lists them in the bottom bar. See `boo help ui`.

## Automation

Expand Down
2 changes: 1 addition & 1 deletion src/help.zig
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ pub const commands = [_]Entry{
\\ C-a c create a session and focus it
\\ C-a k kill the focused session (asks y/n)
\\ C-a r rename the focused session
\\ C-a s search sessions by name and focus the match
\\ C-a g go to a session by name (best match)
\\ C-a n focus the next session
\\ C-a p focus the previous session
\\ C-a Up, C-a Down
Expand Down
104 changes: 52 additions & 52 deletions src/ui.zig
Original file line number Diff line number Diff line change
Expand Up @@ -730,10 +730,10 @@ const Ui = struct {
rename_input: ?std.ArrayList(u8) = null,
/// Session index being renamed while the prompt is open.
rename_target: usize = 0,
/// Search input buffer; non-null while the search prompt is open.
search_input: ?std.ArrayList(u8) = null,
/// Selection to restore when the search prompt is cancelled.
search_origin: ?usize = null,
/// Goto input buffer; non-null while the goto prompt is open.
goto_input: ?std.ArrayList(u8) = null,
/// Selection to restore when the goto prompt is cancelled.
goto_origin: ?usize = null,
/// Sidebar browse: armed by C-a Up/Down (or plain arrows when
/// nothing live is focused). The selection moves without
/// attaching; Enter attaches it, Esc snaps it back to the
Expand Down Expand Up @@ -785,7 +785,7 @@ const Ui = struct {
if (self.last_name) |n| self.alloc.free(n);
if (self.view_name) |n| self.alloc.free(n);
if (self.rename_input) |*input| input.deinit(self.alloc);
if (self.search_input) |*input| input.deinit(self.alloc);
if (self.goto_input) |*input| input.deinit(self.alloc);
self.message.deinit(self.alloc);
for (self.row_cache.items) |*row| row.deinit(self.alloc);
self.row_cache.deinit(self.alloc);
Expand Down Expand Up @@ -945,9 +945,9 @@ const Ui = struct {
if (self.handleRenameEvent(ev)) return;
}

// An open search prompt captures keyboard input.
if (self.search_input != null) {
if (self.handleSearchEvent(ev)) return;
// An open goto prompt captures keyboard input.
if (self.goto_input != null) {
if (self.handleGotoEvent(ev)) return;
}

// A pending kill confirmation swallows the next key.
Expand Down Expand Up @@ -1093,27 +1093,27 @@ const Ui = struct {
}
}

/// Input while the search prompt is open edits the query; the
/// Input while the goto prompt is open edits the query; the
/// sidebar selection follows the best match live. Returns true
/// when the event was consumed.
fn handleSearchEvent(self: *Ui, ev: InputEvent) bool {
const input = &(self.search_input.?);
fn handleGotoEvent(self: *Ui, ev: InputEvent) bool {
const input = &(self.goto_input.?);
switch (ev) {
.forward => |bytes| {
// A bare escape cancels; longer escape sequences
// (arrow keys and friends) are ignored.
if (bytes.len > 0 and bytes[0] == 0x1b) {
if (bytes.len == 1) self.cancelSearch();
if (bytes.len == 1) self.cancelGoto();
return true;
}
for (bytes) |byte| switch (byte) {
'\r', '\n' => {
self.commitSearch();
self.commitGoto();
return true;
},
0x7f, 0x08 => _ = input.pop(),
0x03 => {
self.cancelSearch();
self.cancelGoto();
return true;
},
else => {
Expand All @@ -1124,20 +1124,20 @@ const Ui = struct {
}
},
};
if (self.searchMatch(input.items)) |idx| {
if (self.gotoMatch(input.items)) |idx| {
self.selected = idx;
self.scrollSelectedIntoView();
}
self.need_render = true;
return true;
},
.prefix => {
self.cancelSearch();
self.cancelGoto();
return true;
},
.mouse => |m| {
if (!m.release and !m.isMotion() and !m.isWheel()) {
self.cancelSearch();
self.cancelGoto();
}
return true;
},
Expand All @@ -1150,7 +1150,7 @@ const Ui = struct {
'c', 0x03 => self.createSession(),
'k', 0x0b => self.confirmKill(),
'r', 0x12 => self.startRename(),
's', 0x13 => self.startSearch(),
'g', 0x07 => self.startGoto(),
'd', 0x04, 'q' => self.quitting = true,
'n', 0x0e => self.focusOffset(1),
'p', 0x10 => self.focusOffset(-1),
Expand Down Expand Up @@ -1812,7 +1812,7 @@ const Ui = struct {
}

/// Drop the browse and snap the selection back to the focused
/// session, mirroring how a cancelled search restores its origin.
/// session, mirroring how a cancelled goto restores its origin.
fn cancelBrowse(self: *Ui) void {
self.browsing = false;
if (self.view_name) |want| {
Expand Down Expand Up @@ -2055,7 +2055,7 @@ const Ui = struct {
/// First session whose name starts with `query`, else the first
/// whose name contains it; case-insensitive. Null for an empty
/// query or no match.
fn searchMatch(self: *Ui, query: []const u8) ?usize {
fn gotoMatch(self: *Ui, query: []const u8) ?usize {
if (query.len == 0) return null;
var contains: ?usize = null;
for (self.sessions.items, 0..) |entry, idx| {
Expand All @@ -2069,46 +2069,46 @@ const Ui = struct {
return contains;
}

fn startSearch(self: *Ui) void {
fn startGoto(self: *Ui) void {
if (self.sessions.items.len == 0) {
self.setMessage("no sessions to search", .{});
self.setMessage("no sessions to go to", .{});
return;
}
self.confirm_kill = null;
self.search_origin = self.selected;
if (self.search_input) |*old| old.deinit(self.alloc);
self.search_input = .empty;
// The prompt renders from search_input; a stale transient
self.goto_origin = self.selected;
if (self.goto_input) |*old| old.deinit(self.alloc);
self.goto_input = .empty;
// The prompt renders from goto_input; a stale transient
// message would cover it up.
self.message.clearRetainingCapacity();
self.message_deadline = 0;
self.need_render = true;
}

fn cancelSearch(self: *Ui) void {
if (self.search_input) |*input| input.deinit(self.alloc);
self.search_input = null;
fn cancelGoto(self: *Ui) void {
if (self.goto_input) |*input| input.deinit(self.alloc);
self.goto_input = null;
// Put the selection back where it was before the live
// matching moved it.
if (self.search_origin) |idx| {
if (self.goto_origin) |idx| {
if (idx < self.sessions.items.len) {
self.selected = idx;
self.scrollSelectedIntoView();
}
}
self.search_origin = null;
self.setMessage("search cancelled", .{});
self.goto_origin = null;
self.setMessage("goto cancelled", .{});
}

/// Focus the best match for the typed query and close the prompt.
fn commitSearch(self: *Ui) void {
var input = self.search_input.?;
self.search_input = null;
fn commitGoto(self: *Ui) void {
var input = self.goto_input.?;
self.goto_input = null;
defer input.deinit(self.alloc);
self.search_origin = null;
self.goto_origin = null;
self.need_render = true;
if (input.items.len == 0) return;
const idx = self.searchMatch(input.items) orelse {
const idx = self.gotoMatch(input.items) orelse {
self.setMessage("no session matches '{s}'", .{input.items});
return;
};
Expand Down Expand Up @@ -2217,7 +2217,7 @@ const Ui = struct {
fn cursorSequence(self: *Ui) CursorState {
var state: CursorState = .{};
if (self.renameCursor()) |s| return s;
if (self.searchCursor()) |s| return s;
if (self.gotoCursor()) |s| return s;
const v = self.liveView() orelse return state;
// While scrolled back the cursor coordinates belong to the
// bottom of the screen, not the history rows on display, so
Expand Down Expand Up @@ -2262,12 +2262,12 @@ const Ui = struct {
return state;
}

/// While the search prompt is open, the cursor sits at the end
/// While the goto prompt is open, the cursor sits at the end
/// of the typed query in the status bar.
fn searchCursor(self: *Ui) ?CursorState {
const input = self.search_input orelse return null;
fn gotoCursor(self: *Ui) ?CursorState {
const input = self.goto_input orelse return null;
var state: CursorState = .{};
const prompt_len = " search: ".len;
const prompt_len = " goto: ".len;
const col = @min(prompt_len + input.items.len + 1, self.layout.cols);
const text = std.fmt.bufPrint(&state.pos, "\x1b[{d};{d}H", .{
self.layout.rows,
Expand All @@ -2282,7 +2282,7 @@ const Ui = struct {
/// open prompt, the armed-prefix keybind list, an active browse,
/// resize, or scrollback, or a live message.
fn statusActive(self: *Ui) bool {
return self.rename_input != null or self.search_input != null or
return self.rename_input != null or self.goto_input != null or
self.confirm_kill != null or self.parser.pending_prefix or
self.browsing or self.resizing or self.viewScrolled() or
self.message.items.len > 0;
Expand All @@ -2309,7 +2309,7 @@ const Ui = struct {
}

const keybind_bar =
" c new k kill r rename s search n/p switch up/dn browse lt/rt resize d quit C-a last a literal l redraw esc cancel";
" c new k kill r rename g goto n/p switch up/dn browse lt/rt resize d quit C-a last a literal l redraw esc cancel";

/// Status content overlaid full-width on the last screen row
/// while present: rename prompt, kill confirmation, the keybind
Expand All @@ -2331,8 +2331,8 @@ const Ui = struct {
input.items,
});
}
} else if (self.search_input) |input| {
try text.print(alloc, " search: {s}", .{input.items});
} else if (self.goto_input) |input| {
try text.print(alloc, " goto: {s}", .{input.items});
} else if (self.confirm_kill) |idx| {
if (idx < self.sessions.items.len) {
try text.print(alloc, " kill {s}? y/n", .{self.sessions.items[idx].name});
Expand Down Expand Up @@ -2831,7 +2831,7 @@ test "ui: an empty viewport shows the splash, not a placard" {
try std.testing.expect(std.mem.indexOf(u8, out.items, "attached elsewhere") != null);
}

test "ui: search matches prefer name prefixes over substrings" {
test "ui: goto matches prefer name prefixes over substrings" {
const alloc = std.testing.allocator;
var ui: Ui = .{ .alloc = alloc, .dir = "", .tty = -1 };
defer ui.sessions.deinit(alloc);
Expand All @@ -2845,13 +2845,13 @@ test "ui: search matches prefer name prefixes over substrings" {
try ui.sessions.append(alloc, .{ .name = &ugly, .attached = false, .idle_ms = 0, .title = &no_title });

// A prefix match wins even when an earlier name contains the query.
try std.testing.expectEqual(@as(?usize, 2), ui.searchMatch("ug"));
try std.testing.expectEqual(@as(?usize, 2), ui.gotoMatch("ug"));
// Substring fallback, case-insensitive.
try std.testing.expectEqual(@as(?usize, 0), ui.searchMatch("UILD"));
try std.testing.expectEqual(@as(?usize, 1), ui.searchMatch("deb"));
try std.testing.expectEqual(@as(?usize, 0), ui.gotoMatch("UILD"));
try std.testing.expectEqual(@as(?usize, 1), ui.gotoMatch("deb"));
// No match and empty queries select nothing.
try std.testing.expectEqual(@as(?usize, null), ui.searchMatch("zzz"));
try std.testing.expectEqual(@as(?usize, null), ui.searchMatch(""));
try std.testing.expectEqual(@as(?usize, null), ui.gotoMatch("zzz"));
try std.testing.expectEqual(@as(?usize, null), ui.gotoMatch(""));
}

test "layout: geometry and hit testing" {
Expand Down
10 changes: 5 additions & 5 deletions test/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1705,7 +1705,7 @@ test "ui: a single esc cancels the rename prompt" {
try ui.waitFor("rename cancelled");
}

test "ui: C-a s searches sessions by name and focuses the match" {
test "ui: C-a g goes to a session by name" {
const alloc = std.testing.allocator;
var h = try Harness.init(alloc);
defer h.deinit();
Expand All @@ -1721,14 +1721,14 @@ test "ui: C-a s searches sessions by name and focuses the match" {
defer ui.deinit();
try ui.waitFor("BRAVO-MARK");

// C-a s opens the search prompt; a name prefix selects the
// C-a g opens the goto prompt; a name prefix selects the
// matching session and Enter focuses it. Each step waits for
// its echo: bytes that arrive in the same read as the
// committing Enter would be consumed by the prompt.
try ui.send("\x01s");
try ui.waitFor(" search: ");
try ui.send("\x01g");
try ui.waitFor(" goto: ");
try ui.send("al");
try ui.waitFor("search: al");
try ui.waitFor("goto: al");

// The commit closes the prompt and the focus switch forces a
// full repaint: the sidebar hint returning proves both.
Expand Down
Loading