443 lines
16 KiB
Zig
443 lines
16 KiB
Zig
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"));
|
|
}
|