const std = @import("std"); // Generic transient panel stack state, independent of concrete product panels. // req: ui/001, ui/002, session/003, testing/001, testing/002, testing/003, testing/004 test { _ = Stack; } pub const Error = error{ InvalidPanelTitle, InvalidListItem, InvalidListFilter, NoPanelOpen, ActivePanelIsNotList, EmptyList, }; pub const PanelKind = enum { empty, list, }; pub const ListItem = struct { text: []u8, }; pub const ListState = struct { items: std.ArrayList(ListItem) = .empty, filter: std.ArrayList(u8) = .empty, cursor: usize = 0, selected: ?[]u8 = null, fn deinit(self: *ListState, allocator: std.mem.Allocator) void { for (self.items.items) |item| allocator.free(item.text); self.items.deinit(allocator); self.filter.deinit(allocator); if (self.selected) |selected| allocator.free(selected); self.* = undefined; } }; pub const Panel = struct { title: []u8, kind: PanelKind = .empty, list: ?ListState = null, fn deinit(self: *Panel, allocator: std.mem.Allocator) void { allocator.free(self.title); if (self.list) |*list| list.deinit(allocator); self.* = undefined; } }; pub const State = struct { depth: usize, active_index: ?usize, active_title: ?[]const u8, active_kind: ?PanelKind, }; pub const Stack = struct { allocator: std.mem.Allocator, panels: std.ArrayList(Panel) = .empty, active_index: ?usize = null, pub fn init(allocator: std.mem.Allocator) Stack { return .{ .allocator = allocator }; } pub fn deinit(self: *Stack) void { for (self.panels.items) |*panel| panel.deinit(self.allocator); self.panels.deinit(self.allocator); self.* = undefined; } pub fn open(self: *Stack, title: []const u8) !void { try validateTitle(title); const owned = try self.allocator.dupe(u8, title); errdefer self.allocator.free(owned); try self.panels.append(self.allocator, .{ .title = owned }); self.active_index = self.panels.items.len - 1; } pub fn openList(self: *Stack, title: []const u8, items: []const []const u8) !void { try validateTitle(title); if (items.len == 0) return Error.EmptyList; var list = ListState{}; errdefer list.deinit(self.allocator); for (items) |item| { try validateListItem(item); const owned_item = try self.allocator.dupe(u8, item); errdefer self.allocator.free(owned_item); try list.items.append(self.allocator, .{ .text = owned_item }); } const owned_title = try self.allocator.dupe(u8, title); errdefer self.allocator.free(owned_title); try self.panels.append(self.allocator, .{ .title = owned_title, .kind = .list, .list = list }); self.active_index = self.panels.items.len - 1; self.clampListCursor(); } pub fn closeActive(self: *Stack) !void { const index = self.active_index orelse return Error.NoPanelOpen; var removed = self.panels.orderedRemove(index); removed.deinit(self.allocator); if (self.panels.items.len == 0) { self.active_index = null; } else if (index >= self.panels.items.len) { self.active_index = self.panels.items.len - 1; } else { self.active_index = index; } } pub fn next(self: *Stack) !void { const index = self.active_index orelse return Error.NoPanelOpen; self.active_index = (index + 1) % self.panels.items.len; } pub fn previous(self: *Stack) !void { const index = self.active_index orelse return Error.NoPanelOpen; self.active_index = if (index == 0) self.panels.items.len - 1 else index - 1; } pub fn filterList(self: *Stack, filter: []const u8) !void { try validateFilter(filter); var list = try self.activeList(); list.filter.clearRetainingCapacity(); try list.filter.appendSlice(self.allocator, filter); list.cursor = 0; self.clampListCursor(); } pub fn listDown(self: *Stack) !void { var list = try self.activeList(); const visible = self.visibleCount() catch 0; if (visible == 0) return; if (list.cursor + 1 < visible) list.cursor += 1; } pub fn listUp(self: *Stack) !void { var list = try self.activeList(); if (list.cursor > 0) list.cursor -= 1; } pub fn selectList(self: *Stack) ![]const u8 { var list = try self.activeList(); const selected = self.visibleItemAt(list.cursor) orelse return Error.EmptyList; const owned = try self.allocator.dupe(u8, selected); if (list.selected) |old| self.allocator.free(old); list.selected = owned; return list.selected.?; } pub fn activeListItem(self: *const Stack) ![]const u8 { const index = self.active_index orelse return Error.NoPanelOpen; const panel = self.panels.items[index]; if (panel.kind != .list or panel.list == null) return Error.ActivePanelIsNotList; const list = panel.list.?; return self.visibleItemAt(list.cursor) orelse Error.EmptyList; } pub fn cancelList(self: *Stack) !void { _ = try self.activeList(); try self.closeActive(); } pub fn state(self: *const Stack) State { const index = self.active_index; return .{ .depth = self.panels.items.len, .active_index = index, .active_title = if (index) |i| self.panels.items[i].title else null, .active_kind = if (index) |i| self.panels.items[i].kind else null, }; } pub fn pathAlloc(self: *const Stack, allocator: std.mem.Allocator) ![]u8 { if (self.panels.items.len == 0) return allocator.dupe(u8, "-"); var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); for (self.panels.items, 0..) |panel, index| { if (index > 0) try out.append(allocator, '>'); if (self.active_index != null and self.active_index.? == index) { try out.append(allocator, '['); try out.appendSlice(allocator, panel.title); try out.append(allocator, ']'); } else { try out.appendSlice(allocator, panel.title); } } return out.toOwnedSlice(allocator); } pub fn summaryAlloc(self: *const Stack, allocator: std.mem.Allocator) ![]u8 { const index = self.active_index orelse return allocator.dupe(u8, "-"); const panel = &self.panels.items[index]; switch (panel.kind) { .empty => return allocator.dupe(u8, "empty"), .list => { const list = &panel.list.?; const visible = try self.visibleCount(); return std.fmt.allocPrint( allocator, "list:filter={s},cursor={d},visible={d},selected={s}", .{ if (list.filter.items.len == 0) "-" else list.filter.items, list.cursor, visible, if (list.selected) |selected| selected else "-", }, ); }, } } pub fn activeListRowsAlloc(self: *const Stack, allocator: std.mem.Allocator, max_rows: usize) ![][]u8 { const index = self.active_index orelse return Error.NoPanelOpen; const panel = &self.panels.items[index]; if (panel.kind != .list) return Error.ActivePanelIsNotList; const list = &panel.list.?; var rows = std.ArrayList([]u8).empty; errdefer { for (rows.items) |row| allocator.free(row); rows.deinit(allocator); } var visible_index: usize = 0; for (list.items.items) |item| { if (!matchesFilter(item.text, list.filter.items)) continue; if (rows.items.len >= max_rows) break; const marker: []const u8 = if (visible_index == list.cursor) "> " else " "; const row = try std.fmt.allocPrint(allocator, "{s}{s}", .{ marker, item.text }); try rows.append(allocator, row); visible_index += 1; } if (rows.items.len == 0 and max_rows > 0) { try rows.append(allocator, try allocator.dupe(u8, " no matches")); } return rows.toOwnedSlice(allocator); } fn activeList(self: *Stack) !*ListState { const index = self.active_index orelse return Error.NoPanelOpen; const panel = &self.panels.items[index]; if (panel.kind != .list) return Error.ActivePanelIsNotList; return &panel.list.?; } fn visibleItemAt(self: *const Stack, wanted_index: usize) ?[]const u8 { const index = self.active_index orelse return null; const panel = &self.panels.items[index]; if (panel.kind != .list) return null; const list = &panel.list.?; var visible_index: usize = 0; for (list.items.items) |item| { if (!matchesFilter(item.text, list.filter.items)) continue; if (visible_index == wanted_index) return item.text; visible_index += 1; } return null; } fn visibleCount(self: *const Stack) !usize { const index = self.active_index orelse return Error.NoPanelOpen; const panel = &self.panels.items[index]; if (panel.kind != .list) return Error.ActivePanelIsNotList; const list = &panel.list.?; var count: usize = 0; for (list.items.items) |item| { if (matchesFilter(item.text, list.filter.items)) count += 1; } return count; } fn clampListCursor(self: *Stack) void { const index = self.active_index orelse return; const panel = &self.panels.items[index]; if (panel.kind != .list) return; const list = &panel.list.?; const visible = self.visibleCount() catch 0; if (visible == 0) { list.cursor = 0; } else if (list.cursor >= visible) { list.cursor = visible - 1; } } }; fn validateTitle(title: []const u8) !void { if (title.len == 0) return Error.InvalidPanelTitle; if (!std.unicode.utf8ValidateSlice(title)) return Error.InvalidPanelTitle; for (title) |byte| { if (byte <= 0x20 or byte == '>' or byte == '[' or byte == ']') return Error.InvalidPanelTitle; } } fn validateListItem(item: []const u8) !void { if (item.len == 0) return Error.InvalidListItem; if (!std.unicode.utf8ValidateSlice(item)) return Error.InvalidListItem; for (item) |byte| { if (byte <= 0x20 or byte == '|') return Error.InvalidListItem; } } fn validateFilter(filter: []const u8) !void { if (!std.unicode.utf8ValidateSlice(filter)) return Error.InvalidListFilter; for (filter) |byte| { if (byte <= 0x20 or byte == '|') return Error.InvalidListFilter; } } fn matchesFilter(item: []const u8, filter: []const u8) bool { return filter.len == 0 or std.mem.indexOf(u8, item, filter) != null; } test "regular: opens nested panels and tracks active path" { var stack = Stack.init(std.testing.allocator); defer stack.deinit(); try stack.open("files"); try stack.open("diagnostics"); const state = stack.state(); try std.testing.expectEqual(@as(usize, 2), state.depth); try std.testing.expectEqual(@as(?usize, 1), state.active_index); try std.testing.expectEqualStrings("diagnostics", state.active_title.?); const path = try stack.pathAlloc(std.testing.allocator); defer std.testing.allocator.free(path); try std.testing.expectEqualStrings("files>[diagnostics]", path); } test "regular: next previous and close preserve stack order" { var stack = Stack.init(std.testing.allocator); defer stack.deinit(); try stack.open("files"); try stack.open("git"); try stack.open("jobs"); try stack.previous(); try std.testing.expectEqualStrings("git", stack.state().active_title.?); try stack.next(); try std.testing.expectEqualStrings("jobs", stack.state().active_title.?); try stack.closeActive(); try std.testing.expectEqualStrings("git", stack.state().active_title.?); const path = try stack.pathAlloc(std.testing.allocator); defer std.testing.allocator.free(path); try std.testing.expectEqualStrings("files>[git]", path); } test "regular: list panel filters moves and selects" { var stack = Stack.init(std.testing.allocator); defer stack.deinit(); try stack.openList("files", &.{ "src/main.zig", "src/panel.zig", "README.md" }); try stack.filterList("src"); try stack.listDown(); const selected = try stack.selectList(); try std.testing.expectEqualStrings("src/panel.zig", selected); const summary = try stack.summaryAlloc(std.testing.allocator); defer std.testing.allocator.free(summary); try std.testing.expectEqualStrings("list:filter=src,cursor=1,visible=2,selected=src/panel.zig", summary); } test "regular: active list rows expose cursor and empty match state" { var stack = Stack.init(std.testing.allocator); defer stack.deinit(); try stack.openList("files", &.{ "a.zig", "b.zig" }); try stack.listDown(); const rows = try stack.activeListRowsAlloc(std.testing.allocator, 4); defer { for (rows) |row| std.testing.allocator.free(row); std.testing.allocator.free(rows); } try std.testing.expectEqualStrings(" a.zig", rows[0]); try std.testing.expectEqualStrings("> b.zig", rows[1]); try stack.filterList("none"); const no_match_rows = try stack.activeListRowsAlloc(std.testing.allocator, 4); defer { for (no_match_rows) |row| std.testing.allocator.free(row); std.testing.allocator.free(no_match_rows); } try std.testing.expectEqualStrings(" no matches", no_match_rows[0]); } test "regular: list cancel closes the active list panel" { var stack = Stack.init(std.testing.allocator); defer stack.deinit(); try stack.open("root"); try stack.openList("files", &.{"a.zig"}); try stack.cancelList(); try std.testing.expectEqual(@as(usize, 1), stack.state().depth); try std.testing.expectEqualStrings("root", stack.state().active_title.?); } test "adversarial: empty stack operations fail clearly" { var stack = Stack.init(std.testing.allocator); defer stack.deinit(); try std.testing.expectError(Error.NoPanelOpen, stack.closeActive()); try std.testing.expectError(Error.NoPanelOpen, stack.next()); try std.testing.expectError(Error.NoPanelOpen, stack.previous()); const path = try stack.pathAlloc(std.testing.allocator); defer std.testing.allocator.free(path); try std.testing.expectEqualStrings("-", path); } test "adversarial: invalid panel titles are rejected" { var stack = Stack.init(std.testing.allocator); defer stack.deinit(); try std.testing.expectError(Error.InvalidPanelTitle, stack.open("")); try std.testing.expectError(Error.InvalidPanelTitle, stack.open("two words")); try std.testing.expectError(Error.InvalidPanelTitle, stack.open("bad>path")); const bad_utf8 = [_]u8{ 0xc3, 0x28 }; try std.testing.expectError(Error.InvalidPanelTitle, stack.open(&bad_utf8)); try std.testing.expectEqual(@as(usize, 0), stack.state().depth); } test "adversarial: list rejects invalid items filters and non-list operations" { var stack = Stack.init(std.testing.allocator); defer stack.deinit(); try std.testing.expectError(Error.EmptyList, stack.openList("files", &.{})); try std.testing.expectError(Error.InvalidListItem, stack.openList("files", &.{"bad item"})); try stack.open("plain"); try std.testing.expectError(Error.ActivePanelIsNotList, stack.filterList("src")); try std.testing.expectError(Error.ActivePanelIsNotList, stack.listDown()); try stack.closeActive(); try stack.openList("files", &.{"a.zig"}); try std.testing.expectError(Error.InvalidListFilter, stack.filterList("bad filter")); }