aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/search/Thread.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/search/Thread.zig
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/search/Thread.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/search/Thread.zig606
1 files changed, 157 insertions, 449 deletions
diff --git a/vendor/libghostty-vt/src/terminal/search/Thread.zig b/vendor/libghostty-vt/src/terminal/search/Thread.zig
index d7f0d562..0b781321 100644
--- a/vendor/libghostty-vt/src/terminal/search/Thread.zig
+++ b/vendor/libghostty-vt/src/terminal/search/Thread.zig
@@ -12,9 +12,9 @@ const std = @import("std");
const builtin = @import("builtin");
const testing = std.testing;
const Allocator = std.mem.Allocator;
-const ArenaAllocator = std.heap.ArenaAllocator;
-const Mutex = std.Thread.Mutex;
-const xev = @import("../../global.zig").xev;
+const Mutex = std.Io.Mutex;
+const global = @import("../../global.zig");
+const xev = global.xev;
const internal_os = @import("../../os/main.zig");
const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue;
const MessageData = @import("../../datastruct/main.zig").MessageData;
@@ -22,11 +22,10 @@ const point = @import("../point.zig");
const FlattenedHighlight = @import("../highlight.zig").Flattened;
const UntrackedHighlight = @import("../highlight.zig").Untracked;
const ScreenSet = @import("../ScreenSet.zig");
-const Selection = @import("../Selection.zig");
const Terminal = @import("../Terminal.zig");
const ScreenSearch = @import("screen.zig").ScreenSearch;
-const ViewportSearch = @import("viewport.zig").ViewportSearch;
+const TerminalSearch = @import("terminal.zig").TerminalSearch;
const log = std.log.scoped(.search_thread);
@@ -71,7 +70,10 @@ refresh_active: bool = false,
/// Search state. Starts as null and is populated when a search is
/// started (a needle is given).
-search: ?Search = null,
+search: ?TerminalSearch = null,
+
+/// The last-notified values used to diff search state into events.
+notify_state: NotifyState = .{},
/// The options used to initialize this thread.
opts: Options,
@@ -122,8 +124,8 @@ pub fn deinit(self: *Thread) void {
self.mailbox.destroy(self.alloc);
if (self.search) |*s| {
- self.opts.mutex.lock();
- defer self.opts.mutex.unlock();
+ self.opts.mutex.lockUncancelable(global.io());
+ defer self.opts.mutex.unlock(global.io());
s.deinit(self.opts.terminal);
}
}
@@ -182,14 +184,14 @@ fn threadMain_(self: *Thread) !void {
while (true) {
// If our loop is canceled then we drain our messages and quit.
if (self.loop.stopped()) {
- while (self.mailbox.pop()) |message| {
+ while (self.mailbox.pop(global.io())) |message| {
log.debug("mailbox message ignored during shutdown={}", .{message});
}
return;
}
- const s: *Search = if (self.search) |*s| s else {
+ const s: *TerminalSearch = if (self.search) |*s| s else {
// If we're not actively searching, we can block the loop
// until it does some work.
try self.loop.run(.once);
@@ -200,11 +202,7 @@ fn threadMain_(self: *Thread) !void {
// notifications. Even if the search is complete, there may be
// notifications to send.
if (self.opts.event_cb) |cb| {
- s.notify(
- self.alloc,
- cb,
- self.opts.event_userdata,
- );
+ self.notify(s, cb, self.opts.event_userdata);
}
if (s.isComplete()) {
@@ -225,9 +223,9 @@ fn threadMain_(self: *Thread) !void {
// All searches are blocked. Let's grab the lock and feed data.
.blocked => {
- self.opts.mutex.lock();
- defer self.opts.mutex.unlock();
- s.feed(self.alloc, self.opts.terminal);
+ self.opts.mutex.lockUncancelable(global.io());
+ defer self.opts.mutex.unlock(global.io());
+ self.feedLocked(s);
},
}
@@ -240,9 +238,24 @@ fn threadMain_(self: *Thread) !void {
}
}
+/// Feed the search from the terminal state. The terminal mutex must
+/// be held by the caller.
+fn feedLocked(self: *Thread, s: *TerminalSearch) void {
+ const t = self.opts.terminal;
+
+ // See the `search_viewport_dirty` flag on the terminal to know
+ // what exactly this is for. But, if this is set, we know the renderer
+ // found the viewport/active area dirty, so the active area must be
+ // re-scanned.
+ const active_dirty = t.flags.search_viewport_dirty;
+ t.flags.search_viewport_dirty = false;
+
+ s.feed(t, active_dirty);
+}
+
/// Drain the mailbox.
fn drainMailbox(self: *Thread) !void {
- while (self.mailbox.pop()) |message| {
+ while (self.mailbox.pop(global.io())) |message| {
log.debug("mailbox message={}", .{message});
switch (message) {
.change_needle => |v| {
@@ -257,54 +270,15 @@ fn drainMailbox(self: *Thread) !void {
fn select(self: *Thread, sel: ScreenSearch.Select) !void {
const s = if (self.search) |*s| s else return;
- self.opts.mutex.lock();
- defer self.opts.mutex.unlock();
-
- // A screen can be removed or replaced between refresh ticks. Reconcile
- // while holding the terminal lock before touching any ScreenSearch pins.
- s.feed(self.alloc, self.opts.terminal);
- const screen_search = s.screens.getPtr(s.last_screen.key) orelse return;
-
- // Make the selection. Ignore the result because we don't
- // care if the selection didn't change.
- _ = try screen_search.select(sel);
-
- // Grab our match if we have one. If we don't have a selection
- // then we do nothing.
- const flattened = screen_search.selectedMatch() orelse return;
-
- // No matter what we reset our selected match cache. This will
- // trigger a callback which will trigger the renderer to wake up
- // so it can be notified the screen scrolled.
- s.last_screen.selected = null;
-
- // Grab the current screen and see if this match is visible within
- // the viewport already. If it is, we do nothing.
- const screen = self.opts.terminal.screens.get(
- s.last_screen.key,
- ) orelse return;
-
- // Grab the viewport. Viewports and selections are usually small
- // so this check isn't very expensive, despite appearing O(N^2),
- // both Ns are usually equal to 1.
- var it = screen.pages.pageIterator(
- .right_down,
- .{ .viewport = .{} },
- null,
- );
- const hl_chunks = flattened.chunks.slice();
- while (it.next()) |chunk| {
- for (0..hl_chunks.len) |i| {
- const hl_chunk = hl_chunks.get(i);
- if (chunk.overlaps(.{
- .node = hl_chunk.node,
- .start = hl_chunk.start,
- .end = hl_chunk.end,
- })) return;
- }
- }
+ self.opts.mutex.lockUncancelable(global.io());
+ defer self.opts.mutex.unlock(global.io());
- screen.scroll(.{ .pin = flattened.startPin() });
+ if (try s.select(self.opts.terminal, sel, .if_needed)) {
+ // No matter what we reset our selected match cache. This will
+ // trigger a callback which will trigger the renderer to wake up
+ // so it can be notified the screen scrolled.
+ self.notify_state.selected = null;
+ }
}
/// Change the search term to the given value.
@@ -314,14 +288,15 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void {
// Stop the previous search
if (self.search) |*s| {
// If our search is unchanged, do nothing.
- if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return;
+ if (std.ascii.eqlIgnoreCase(s.needle(), needle)) return;
{
- self.opts.mutex.lock();
- defer self.opts.mutex.unlock();
+ self.opts.mutex.lockUncancelable(global.io());
+ defer self.opts.mutex.unlock(global.io());
s.deinit(self.opts.terminal);
}
self.search = null;
+ self.notify_state = .{};
// When the search changes then we need to emit that it stopped.
if (self.opts.event_cb) |cb| {
@@ -345,11 +320,98 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void {
// Setup our search state.
self.search = try .init(self.alloc, needle);
+ self.notify_state = .{};
// We need to grab the terminal lock and do an initial feed.
- self.opts.mutex.lock();
- defer self.opts.mutex.unlock();
- self.search.?.feed(self.alloc, self.opts.terminal);
+ self.opts.mutex.lockUncancelable(global.io());
+ defer self.opts.mutex.unlock(global.io());
+ self.feedLocked(&self.search.?);
+}
+
+/// Notify about any changes to the search state by diffing against
+/// the last-notified values.
+///
+/// This doesn't require any locking as it only reads search-owned state.
+fn notify(
+ self: *Thread,
+ s: *TerminalSearch,
+ cb: EventCallback,
+ ud: ?*anyopaque,
+) void {
+ const state = &self.notify_state;
+
+ // A screen switch makes all previously notified per-screen state
+ // stale, so reset it to force recalculations and notifications.
+ if (state.key != s.active_key) {
+ state.key = s.active_key;
+ state.total = null;
+ state.selected = null;
+ }
+
+ const screen_search = s.activeScreenSearch() orelse return;
+
+ // Check our total match data
+ const total = screen_search.matchesLen();
+ if (total != state.total) {
+ log.debug("notifying total matches={}", .{total});
+ state.total = total;
+ cb(.{ .total_matches = total }, ud);
+ }
+
+ // Check our viewport matches. If they're stale, we collect them
+ // now. We do this as part of notify and not tick because the
+ // viewport search is very fast and doesn't require ticked progress
+ // or feeds.
+ if (s.stale_viewport_matches) viewport: {
+ const matches = s.viewportMatches() catch |err| {
+ log.warn("error collecting viewport matches err={}", .{err});
+ break :viewport;
+ };
+
+ log.debug("notifying viewport matches len={}", .{matches.len});
+ cb(.{ .viewport_matches = matches }, ud);
+ }
+
+ // Check our last selected match data.
+ if (screen_search.selected) |m| match: {
+ const flattened = screen_search.selectedMatch() orelse break :match;
+ const untracked = flattened.untracked();
+ if (state.selected) |prev| {
+ if (prev.idx == m.idx and prev.highlight.eql(untracked)) {
+ // Same selection, don't update it.
+ break :match;
+ }
+ }
+
+ // New selection, notify!
+ state.selected = .{
+ .idx = m.idx,
+ .highlight = untracked,
+ };
+
+ log.debug("notifying selection updated idx={}", .{m.idx});
+ cb(
+ .{ .selected_match = .{
+ .idx = m.idx,
+ .highlight = flattened,
+ } },
+ ud,
+ );
+ } else if (state.selected != null) {
+ log.debug("notifying selection cleared", .{});
+ state.selected = null;
+ cb(
+ .{ .selected_match = null },
+ ud,
+ );
+ }
+
+ // Send our complete notification if we just completed.
+ if (!state.complete and s.isComplete()) {
+ log.debug("notifying search complete", .{});
+ state.complete = true;
+ cb(.complete, ud);
+ }
}
fn startRefreshTimer(self: *Thread) void {
@@ -423,9 +485,9 @@ fn refreshCallback(
// Run our feed if we have a search active.
if (self.search) |*s| {
- self.opts.mutex.lock();
- defer self.opts.mutex.unlock();
- s.feed(self.alloc, self.opts.terminal);
+ self.opts.mutex.lockUncancelable(global.io());
+ defer self.opts.mutex.unlock(global.io());
+ self.feedLocked(s);
}
// Only continue if we're still active
@@ -501,357 +563,30 @@ pub const Event = union(enum) {
};
};
-/// Search state.
-const Search = struct {
- /// Active viewport search for the active screen.
- viewport: ViewportSearch,
-
- /// The searchers for all the screens.
- screens: std.EnumMap(ScreenSet.Key, ScreenSearch),
+/// The last-notified values used by `notify` to diff search state
+/// into events.
+const NotifyState = struct {
+ /// The active screen key the state below was captured against.
+ key: ScreenSet.Key = .primary,
- /// ScreenSet generations captured when each searcher was initialized.
- /// Allocators may reuse a destroyed Screen address, so pointer equality
- /// alone cannot distinguish replacement screens from stale handles.
- screen_generations: std.EnumMap(ScreenSet.Key, usize),
+ /// Last notified total matches count
+ total: ?usize = null,
- /// All state related to screen switches, collected so that when
- /// we switch screens it makes everything related stale, too.
- last_screen: ScreenState,
+ /// Last notified selected match
+ selected: ?Selected = null,
/// True if we sent the complete notification yet.
- last_complete: bool,
-
- /// The last viewport matches we found.
- stale_viewport_matches: bool,
-
- const ScreenState = struct {
- /// Last active screen key
- key: ScreenSet.Key,
-
- /// Last notified total matches count
- total: ?usize = null,
-
- /// Last notified selected match index
- selected: ?SelectedMatch = null,
-
- const SelectedMatch = struct {
- idx: usize,
- highlight: UntrackedHighlight,
- };
- };
-
- pub fn init(
- alloc: Allocator,
- needle: []const u8,
- ) Allocator.Error!Search {
- var vp: ViewportSearch = try .init(alloc, needle);
- errdefer vp.deinit();
-
- // We use dirty tracking for active area changes. Start with it
- // dirty so the first change is re-searched.
- vp.active_dirty = true;
-
- return .{
- .viewport = vp,
- .screens = .init(.{}),
- .screen_generations = .init(.{}),
- .last_screen = .{ .key = .primary },
- .last_complete = false,
- .stale_viewport_matches = true,
- };
- }
+ complete: bool = false,
- pub fn deinit(self: *Search, t: *Terminal) void {
- self.viewport.deinit();
- var it = self.screens.iterator();
- while (it.next()) |entry| {
- if (self.screenIsValid(&t.screens, entry.key, entry.value)) {
- entry.value.deinit();
- } else {
- entry.value.deinitScreenInvalid();
- }
- }
- }
-
- fn screenIsValid(
- self: *const Search,
- screens: *const ScreenSet,
- key: ScreenSet.Key,
- search: *const ScreenSearch,
- ) bool {
- const generation = self.screen_generations.get(key) orelse return false;
- if (generation != screens.generation(key)) return false;
- const actual = screens.get(key) orelse return false;
- return actual == search.screen;
- }
-
- /// Returns true if all searches on all screens are complete.
- pub fn isComplete(self: *Search) bool {
- var it = self.screens.iterator();
- while (it.next()) |entry| {
- if (!entry.value.state.isComplete()) return false;
- }
-
- return true;
- }
-
- pub const Tick = enum {
- /// All searches are complete.
- complete,
-
- /// Progress was made on at least one screen.
- progress,
-
- /// All incomplete searches are blocked on feed.
- blocked,
+ const Selected = struct {
+ idx: usize,
+ highlight: UntrackedHighlight,
};
-
- /// Tick the search forward as much as possible without acquiring
- /// the big lock. Returns the overall tick progress.
- pub fn tick(self: *Search) Tick {
- var result: Tick = .complete;
- var it = self.screens.iterator();
- while (it.next()) |entry| {
- if (entry.value.tick()) {
- result = .progress;
- } else |err| switch (err) {
- // Ignore... nothing we can do.
- error.OutOfMemory => log.warn(
- "error ticking screen search key={} err={}",
- .{ entry.key, err },
- ),
-
- // Ignore, good for us. State remains whatever it is.
- error.SearchComplete => {},
-
- // Ignore, too, progressed
- error.FeedRequired => switch (result) {
- // If we think we're complete, we're not because we're
- // blocked now (nothing made progress).
- .complete => result = .blocked,
-
- // If we made some progress, we remain in progress
- // since blocked means no progress at all.
- .progress => {},
-
- // If we're blocked already then we remain blocked.
- .blocked => {},
- },
- }
- }
-
- // log.debug("tick result={}", .{result});
- return result;
- }
-
- /// Grab the mutex and update any state that requires it, such as
- /// feeding additional data to the searches or updating the active screen.
- pub fn feed(
- self: *Search,
- alloc: Allocator,
- t: *Terminal,
- ) void {
- // Update our active screen
- if (t.screens.active_key != self.last_screen.key) {
- // The default values will force resets of a bunch of other
- // state too to force recalculations and notifications.
- self.last_screen = .{ .key = t.screens.active_key };
- }
-
- // Reconcile our screens with the terminal screens. Remove
- // searchers for screens that no longer exist and add searchers
- // for screens that do exist but we don't have yet.
- {
- // Remove screens we have that no longer exist or changed.
- var it = self.screens.iterator();
- while (it.next()) |entry| {
- const remove = !self.screenIsValid(
- &t.screens,
- entry.key,
- entry.value,
- );
-
- if (remove) {
- entry.value.deinitScreenInvalid();
- _ = self.screens.remove(entry.key);
- _ = self.screen_generations.remove(entry.key);
- }
- }
- }
- {
- // Add screens that exist but we don't have yet.
- var it = t.screens.all.iterator();
- while (it.next()) |entry| {
- if (self.screens.contains(entry.key)) continue;
- const screen_search = ScreenSearch.init(
- alloc,
- entry.value.*,
- self.viewport.needle(),
- ) catch |err| switch (err) {
- error.OutOfMemory => {
- // OOM is probably going to sink the entire ship but
- // we can just ignore it and wait on the next
- // reconciliation to try again.
- log.warn(
- "error initializing screen search for key={} err={}",
- .{ entry.key, err },
- );
- continue;
- },
- };
- self.screens.put(entry.key, screen_search);
- self.screen_generations.put(
- entry.key,
- t.screens.generation(entry.key),
- );
- }
- }
-
- // See the `search_viewport_dirty` flag on the terminal to know
- // what exactly this is for. But, if this is set, we know the renderer
- // found the viewport/active area dirty, so we should mark it as
- // dirty in our viewport searcher so it forces a re-search.
- if (t.flags.search_viewport_dirty) {
- t.flags.search_viewport_dirty = false;
-
- // Mark our viewport dirty so it researches the active
- self.viewport.active_dirty = true;
-
- // Reload our active area for our active screen
- if (self.screens.getPtr(t.screens.active_key)) |screen_search| {
- screen_search.reloadActive() catch |err| switch (err) {
- error.OutOfMemory => log.warn(
- "error reloading active area for screen key={} err={}",
- .{ t.screens.active_key, err },
- ),
- };
- }
- }
-
- // Check our viewport for changes.
- if (self.viewport.update(&t.screens.active.pages)) |updated| {
- if (updated) self.stale_viewport_matches = true;
- } else |err| switch (err) {
- error.OutOfMemory => log.warn(
- "error updating viewport search err={}",
- .{err},
- ),
- }
-
- // Feed data
- var it = self.screens.iterator();
- while (it.next()) |entry| {
- if (entry.value.state.needsFeed()) {
- entry.value.feed() catch |err| switch (err) {
- error.OutOfMemory => log.warn(
- "error feeding screen search key={} err={}",
- .{ entry.key, err },
- ),
- };
- }
- }
- }
-
- /// Notify about any changes to the search state.
- ///
- /// This doesn't require any locking as it only reads internal state.
- pub fn notify(
- self: *Search,
- alloc: Allocator,
- cb: EventCallback,
- ud: ?*anyopaque,
- ) void {
- const screen_search = self.screens.get(self.last_screen.key) orelse return;
-
- // Check our total match data
- const total = screen_search.matchesLen();
- if (total != self.last_screen.total) {
- log.debug("notifying total matches={}", .{total});
- self.last_screen.total = total;
- cb(.{ .total_matches = total }, ud);
- }
-
- // Check our viewport matches. If they're stale, we do the
- // viewport search now. We do this as part of notify and not
- // tick because the viewport search is very fast and doesn't
- // require ticked progress or feeds.
- if (self.stale_viewport_matches) viewport: {
- // We always make stale as false. Even if we fail below
- // we require a re-feed to re-search the viewport. The feed
- // process will make it stale again.
- self.stale_viewport_matches = false;
-
- var arena: ArenaAllocator = .init(alloc);
- defer arena.deinit();
- const arena_alloc = arena.allocator();
- var results: std.ArrayList(FlattenedHighlight) = .empty;
- while (self.viewport.next()) |hl| {
- const hl_cloned = hl.clone(arena_alloc) catch continue;
- results.append(arena_alloc, hl_cloned) catch |err| switch (err) {
- error.OutOfMemory => {
- log.warn(
- "error collecting viewport matches err={}",
- .{err},
- );
-
- // Reset the viewport so we force an update on the
- // next feed.
- self.viewport.reset();
- break :viewport;
- },
- };
- }
-
- log.debug("notifying viewport matches len={}", .{results.items.len});
- cb(.{ .viewport_matches = results.items }, ud);
- }
-
- // Check our last selected match data.
- if (screen_search.selected) |m| match: {
- const flattened = screen_search.selectedMatch() orelse break :match;
- const untracked = flattened.untracked();
- if (self.last_screen.selected) |prev| {
- if (prev.idx == m.idx and prev.highlight.eql(untracked)) {
- // Same selection, don't update it.
- break :match;
- }
- }
-
- // New selection, notify!
- self.last_screen.selected = .{
- .idx = m.idx,
- .highlight = untracked,
- };
-
- log.debug("notifying selection updated idx={}", .{m.idx});
- cb(
- .{ .selected_match = .{
- .idx = m.idx,
- .highlight = flattened,
- } },
- ud,
- );
- } else if (self.last_screen.selected != null) {
- log.debug("notifying selection cleared", .{});
- self.last_screen.selected = null;
- cb(
- .{ .selected_match = null },
- ud,
- );
- }
-
- // Send our complete notification if we just completed.
- if (!self.last_complete and self.isComplete()) {
- log.debug("notifying search complete", .{});
- self.last_complete = true;
- cb(.complete, ud);
- }
- }
};
const TestUserData = struct {
const Self = @This();
- reset: std.Thread.ResetEvent = .{},
+ reset: std.Io.Event = .unset,
total: usize = 0,
selected: ?Event.SelectedMatch = null,
viewport: []FlattenedHighlight = &.{},
@@ -865,7 +600,7 @@ const TestUserData = struct {
const ud: *Self = @ptrCast(@alignCast(userdata.?));
switch (event) {
.quit => {},
- .complete => ud.reset.set(),
+ .complete => ud.reset.set(global.io()),
.total_matches => |v| ud.total = v,
.selected_match => |v| ud.selected = v,
.viewport_matches => |v| {
@@ -886,8 +621,9 @@ const TestUserData = struct {
test {
const alloc = testing.allocator;
- var mutex: std.Thread.Mutex = .{};
- var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 });
+ const io = testing.io;
+ var mutex: std.Io.Mutex = .init;
+ var t: Terminal = try .init(io, alloc, .{ .cols = 20, .rows = 2 });
defer t.deinit(alloc);
var stream = t.vtStream();
@@ -912,6 +648,7 @@ test {
// Start our search
_ = thread.mailbox.push(
+ io,
.{ .change_needle = try .init(
alloc,
@as([]const u8, "world"),
@@ -921,7 +658,7 @@ test {
try thread.wakeup.notify();
// Wait for completion
- try ud.reset.timedWait(100 * std.time.ns_per_ms);
+ try ud.reset.waitTimeout(testing.io, .{ .duration = .{ .clock = .awake, .raw = .fromMilliseconds(100) } });
// Stop the thread
try thread.stop.notify();
@@ -942,32 +679,3 @@ test {
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
}
-
-test "select after active screen removal" {
- const alloc = testing.allocator;
- var mutex: std.Thread.Mutex = .{};
- var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 });
- defer t.deinit(alloc);
-
- _ = try t.switchScreen(.alternate);
-
- var search: Search = try .init(alloc, "needle");
- search.feed(alloc, &t);
- try testing.expectEqual(ScreenSet.Key.alternate, search.last_screen.key);
- try testing.expect(search.screens.contains(.alternate));
-
- var thread: Thread = undefined;
- thread.search = search;
- thread.opts = .{
- .mutex = &mutex,
- .terminal = &t,
- };
- defer if (thread.search) |*active| active.deinit(&t);
-
- _ = try t.switchScreen(.primary);
- t.screens.remove(alloc, .alternate);
-
- try thread.select(.next);
- try testing.expectEqual(ScreenSet.Key.primary, thread.search.?.last_screen.key);
- try testing.expect(!thread.search.?.screens.contains(.alternate));
-}