a73x

src/gui/popover.zig

Ref:   Size: 11.9 KiB   History

//! Small reusable modal presentation state. Workflows own the meanings of
//! rows and events; this module owns the displayed copy, editor, selection,
//! scrolling, and the geometry shared by painting and hit testing.
const std = @import("std");

pub const Rect = struct {
    x: u32 = 0,
    y: u32 = 0,
    w: u32 = 0,
    h: u32 = 0,
    pub fn contains(self: Rect, x: u32, y: u32) bool {
        return x >= self.x and y >= self.y and x - self.x < self.w and y - self.y < self.h;
    }
    pub fn intersect(a: Rect, b: Rect) Rect {
        const left = @max(a.x, b.x);
        const top = @max(a.y, b.y);
        const right = @min(a.x +| a.w, b.x +| b.w);
        const bottom = @min(a.y +| a.h, b.y +| b.h);
        return .{ .x = left, .y = top, .w = right -| left, .h = bottom -| top };
    }
};
pub const Row = struct { label: []const u8, enabled: bool = true };
pub const Editor = struct { limit: usize };
pub const Spec = struct {
    title: []const u8 = "",
    context: []const u8 = "",
    notice: []const u8 = "",
    hint: []const u8 = "",
    rows: []const Row = &.{},
    editor: ?Editor = null,
};
pub const Metrics = struct { cell_w: u16, cell_h: u16, max_rows: usize = 40, width_cells: u32 = 84 };
/// `submit` borrows the editor until its next mutation, replacement, or deinit;
/// asynchronous workflows must copy it before retaining it.
pub const Event = union(enum) { select: usize, submit: []const u8, dismiss };
pub const Layout = struct {
    rect: Rect,
    first: usize,
    shown: usize,
    cell_width: u16,
    row_height: u16,
    header_rows: usize,
    editor: ?Rect,
    pub fn rowRect(self: Layout, index: usize) Rect {
        if (index < self.first or index - self.first >= self.shown) return .{};
        return Rect.intersect(.{ .x = self.rect.x, .y = self.rect.y + self.row_height * @as(u32, @intCast(self.header_rows + index - self.first)), .w = self.rect.w, .h = self.row_height }, self.rect);
    }
};
/// Borrowed rendering data, valid until the next State mutation or deinit.
pub const Presentation = struct {
    title: []const u8,
    context: []const u8,
    notice: []const u8,
    hint: []const u8,
    rows: []const Row,
    editor: ?[]const u8,
    selected: ?usize,
};

const Owned = struct {
    title: []u8,
    context: []u8,
    hint: []u8,
    rows: std.ArrayListUnmanaged(Row) = .empty,
    editor: ?Editor,
    fn deinit(self: *Owned, alloc: std.mem.Allocator) void {
        alloc.free(self.title);
        alloc.free(self.context);
        alloc.free(self.hint);
        for (self.rows.items) |row| alloc.free(@constCast(row.label));
        self.rows.deinit(alloc);
        self.* = undefined;
    }
    fn init(alloc: std.mem.Allocator, spec: Spec) !Owned {
        const title = try alloc.dupe(u8, spec.title);
        errdefer alloc.free(title);
        const context = try alloc.dupe(u8, spec.context);
        errdefer alloc.free(context);
        const hint = try alloc.dupe(u8, spec.hint);
        errdefer alloc.free(hint);
        var result: Owned = .{ .title = title, .context = context, .hint = hint, .editor = spec.editor };
        errdefer {
            for (result.rows.items) |row| alloc.free(@constCast(row.label));
            result.rows.deinit(alloc);
        }
        for (spec.rows) |row| {
            if (!std.unicode.utf8ValidateSlice(row.label)) return error.InvalidUtf8;
            const label = try alloc.dupe(u8, row.label);
            errdefer alloc.free(label);
            try result.rows.append(alloc, .{ .label = label, .enabled = row.enabled });
        }
        return result;
    }
};

pub const State = struct {
    alloc: std.mem.Allocator,
    owned: Owned,
    notice: [1024]u8 = @splat(0),
    notice_len: usize = 0,
    input: std.ArrayListUnmanaged(u8) = .empty,
    selected: ?usize = null,
    scroll: usize = 0,
    press: ?struct { row: ?usize, outside: bool } = null,
    pub fn init(alloc: std.mem.Allocator, spec: Spec) !State {
        var result: State = .{ .alloc = alloc, .owned = try Owned.init(alloc, spec) };
        result.setNotice(spec.notice);
        result.selectFirst();
        return result;
    }
    pub fn deinit(self: *State) void {
        self.input.deinit(self.alloc);
        self.owned.deinit(self.alloc);
        self.* = undefined;
    }
    pub fn view(self: *const State) Presentation {
        return .{ .title = self.owned.title, .context = self.owned.context, .notice = self.noticeText(), .hint = self.owned.hint, .rows = self.owned.rows.items, .editor = if (self.owned.editor != null) self.input.items else null, .selected = self.selected };
    }
    /// Replacement allocates the full new presentation first, so failure leaves
    /// both the visible screen and editor untouched.
    pub fn replace(self: *State, spec: Spec) !void {
        var notice: [1024]u8 = undefined;
        const notice_len = @min(spec.notice.len, notice.len);
        std.mem.copyForwards(u8, notice[0..notice_len], spec.notice[0..notice_len]);
        var replacement = try Owned.init(self.alloc, spec);
        errdefer replacement.deinit(self.alloc);
        self.owned.deinit(self.alloc);
        self.owned = replacement;
        self.setNotice(notice[0..notice_len]);
        self.scroll = 0;
        self.press = null;
        self.selectFirst();
    }
    pub fn layout(self: *State, width: u32, height: u32, metrics: Metrics) Layout {
        if (metrics.cell_w == 0 or metrics.cell_h == 0) return .{ .rect = .{}, .first = 0, .shown = 0, .cell_width = 0, .row_height = 0, .header_rows = 0, .editor = null };
        const available = height / metrics.cell_h;
        // The legacy picker has title, variable body, context, notice and
        // hint. Editors occupy the body row; all five regions share this one
        // centered geometry contract.
        const shown = @min(self.owned.rows.items.len, @min(metrics.max_rows, available -| 4));
        const rows = 4 + shown;
        const w = @min(width, @as(u32, metrics.cell_w) * metrics.width_cells);
        const h = @min(height, @as(u32, @intCast(rows)) * metrics.cell_h);
        if (self.selected) |selected| if (shown != 0) {
            if (selected < self.scroll) self.scroll = selected else if (selected - self.scroll >= shown) self.scroll = selected - shown + 1;
        };
        self.scroll = @min(self.scroll, self.owned.rows.items.len -| shown);
        const rect: Rect = .{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h };
        return .{ .rect = rect, .first = self.scroll, .shown = shown, .cell_width = metrics.cell_w, .row_height = metrics.cell_h, .header_rows = 1, .editor = if (self.owned.editor != null) Rect.intersect(.{ .x = rect.x, .y = rect.y + metrics.cell_h, .w = rect.w, .h = metrics.cell_h }, rect) else null };
    }
    pub fn navigate(self: *State, next: bool) void {
        const current = self.selected orelse return;
        var at: isize = @intCast(current);
        while (true) {
            at += if (next) 1 else -1;
            if (at < 0 or at >= @as(isize, @intCast(self.owned.rows.items.len))) return;
            if (self.owned.rows.items[@intCast(at)].enabled) {
                self.selected = @intCast(at);
                return;
            }
        }
    }
    pub fn activate(self: *State) ?Event {
        return if (self.selected) |index| .{ .select = index } else null;
    }
    pub fn submit(self: *State) ?Event {
        return if (self.owned.editor != null) .{ .submit = self.input.items } else self.activate();
    }
    pub fn append(self: *State, bytes: []const u8) !bool {
        const editor = self.owned.editor orelse return false;
        if (!std.unicode.utf8ValidateSlice(bytes)) return false;
        for (bytes) |byte| if (byte < 0x20 or byte == 0x7f) return false;
        if (self.input.items.len + bytes.len > editor.limit) return error.TextTooLong;
        try self.input.appendSlice(self.alloc, bytes);
        return true;
    }
    pub fn backspace(self: *State) bool {
        if (self.owned.editor == null or self.input.items.len == 0) return false;
        var n = self.input.items.len - 1;
        while (n > 0 and self.input.items[n] & 0xc0 == 0x80) n -= 1;
        self.input.items.len = n;
        return true;
    }
    pub fn pointerDown(self: *State, layout_: Layout, x: u32, y: u32) void {
        self.press = .{ .row = self.itemAt(layout_, x, y), .outside = !layout_.rect.contains(x, y) };
    }
    pub fn pointerUp(self: *State, layout_: Layout, x: u32, y: u32) ?Event {
        const press = self.press orelse return null;
        self.press = null;
        if (press.outside) return if (!layout_.rect.contains(x, y)) .dismiss else null;
        const index = press.row orelse return null;
        if (self.itemAt(layout_, x, y) == index and self.owned.rows.items[index].enabled) {
            self.selected = index;
            return .{ .select = index };
        }
        return null;
    }
    pub fn cancelPress(self: *State) void {
        self.press = null;
    }
    pub fn pressing(self: *const State) bool {
        return self.press != null;
    }
    /// Unlike screen replacement, a notice never changes selection, scrolling,
    /// or the editor. It is fixed storage so outcome reporting cannot vanish
    /// while handling an allocator failure or cancellation.
    pub fn setNotice(self: *State, text: []const u8) void {
        self.notice_len = @min(text.len, self.notice.len);
        std.mem.copyForwards(u8, self.notice[0..self.notice_len], text[0..self.notice_len]);
    }
    pub fn noticeText(self: *const State) []const u8 {
        return self.notice[0..self.notice_len];
    }
    fn itemAt(_: *const State, layout_: Layout, x: u32, y: u32) ?usize {
        for (layout_.first..layout_.first + layout_.shown) |index| if (layout_.rowRect(index).contains(x, y)) return index;
        return null;
    }
    fn selectFirst(self: *State) void {
        self.selected = null;
        for (self.owned.rows.items, 0..) |row, i| if (row.enabled) {
            self.selected = i;
            return;
        };
    }
};

test "replacement is transactional and layout shares scroll geometry with hit testing" {
    var fail_index: usize = 0;
    while (true) : (fail_index += 1) {
        var state = try State.init(std.testing.allocator, .{ .title = "old", .rows = &.{ .{ .label = "one" }, .{ .label = "two" } } });
        defer state.deinit();
        var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index });
        state.alloc = failing.allocator();
        if (state.replace(.{ .title = "new", .context = "replacement", .notice = "notice", .hint = "hint", .rows = &.{.{ .label = "x" }} })) |_| {
            state.alloc = std.testing.allocator;
            try std.testing.expectEqualStrings("new", state.owned.title);
            break;
        } else |err| {
            state.alloc = std.testing.allocator;
            try std.testing.expectEqual(error.OutOfMemory, err);
            try std.testing.expectEqualStrings("old", state.owned.title);
        }
    }
    var state = try State.init(std.testing.allocator, .{ .title = "old", .rows = &.{ .{ .label = "one" }, .{ .label = "two" } } });
    defer state.deinit();
    const layout_ = state.layout(80, 60, .{ .cell_w = 8, .cell_h = 10, .max_rows = 2 });
    const row = layout_.rowRect(0);
    state.pointerDown(layout_, row.x, row.y);
    try std.testing.expectEqual(@as(?usize, 0), switch (state.pointerUp(layout_, row.x, row.y).?) {
        .select => |index| index,
        else => null,
    });
    try state.replace(.{ .title = "new", .notice = state.view().title });
    try std.testing.expectEqualStrings("old", state.noticeText());
}

test "busy presentation retains but cannot edit the submitted name" {
    var state = try State.init(std.testing.allocator, .{ .editor = .{ .limit = 20 } });
    defer state.deinit();
    try std.testing.expect(try state.append("shell"));
    try state.replace(.{ .title = "Creating" });
    try std.testing.expect(!state.backspace());
    try std.testing.expect(!(try state.append("x")));
    try std.testing.expectEqualStrings("shell", state.input.items);
}