//! Columns: three Miller columns, as in Finder: labels, threads, message. //! `h` and `l` move between them and `j` and `k` within one; whatever the //! cursor rests on fills the column to its right at once. const std = @import("std"); const siv = @import("siv"); const kit = @import("kit.zig"); const sample = @import("sample.zig"); const Prototype = @import("Prototype.zig"); const Settings = @import("Settings.zig"); const Store = @import("Store.zig"); const enums = std.enums; const math = std.math; const mem = std.mem; const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; const Key = kit.Key; const Label = sample.Label; const log = std.log.scoped(.proto); const Columns = @This(); pub const prototype: Prototype = .{ .name = "columns", .title = "Columns", .idea = "Three Miller columns, labels | threads | message; h and l walk across, j and k walk down.", .build = build, .shots = &.{ .{ .name = "threads", .keys = &kit.keys("ljj") }, .{ .name = "message", .keys = &kit.keys("l") }, .{ .name = "labeling", .keys = &kit.keys("l") }, .{ .name = "labeled", .keys = &(kit.keys("a") ++ [_]Key{.named(.escape)}) }, .{ .name = "work", .keys = &kit.keys("hhjjjjjjj") }, .{ .name = "hiring", .keys = &kit.keys("j") }, .{ .name = "starred", .keys = &kit.keys("ls") }, }, }; const Column = enum { labels, threads, message }; /// What keys mean right now: moving around, or toggling labels. const Mode = enum { browse, label }; /// A row of the labels column. A null label is All Mail. const Place = struct { label: ?Label, /// 1 for a label nested under the one before it. depth: u8, }; const system_places = [_]Place{ .{ .label = .inbox, .depth = 0 }, .{ .label = .starred, .depth = 0 }, .{ .label = .sent, .depth = 0 }, .{ .label = .drafts, .depth = 0 }, .{ .label = null, .depth = 0 }, .{ .label = .spam, .depth = 0 }, .{ .label = .trash, .depth = 0 }, }; /// Every user label in sample's order; a slash in the name nests it. const user_places = blk: { var n = 0; for (enums.values(Label)) |label| { if (label.info().kind == .user) n += 1; } var out: [n]Place = undefined; var i = 0; for (enums.values(Label)) |label| { if (label.info().kind != .user) continue; const nested = mem.findScalar(u8, label.info().name, '/') != null; out[i] = .{ .label = label, .depth = if (nested) 1 else 0 }; i += 1; } break :blk out; }; const places = system_places ++ user_places; const row_height = 58; const dimmed = 0.72; /// The nodes of a labels-column row that change as the mailbox does. const PlaceRow = struct { node: siv.Node, name: siv.Node, pill: siv.Node, unread: siv.Node, total: siv.Node, }; /// The nodes of a column's frame that show whether it has focus. const Frame = struct { node: siv.Node, edge: siv.Node, title: siv.Node, }; /// A click on a labels-column row selects that label. const PlaceClick = struct { owner: *Columns, index: u8, fn call(user_data: ?*anyopaque, _: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { if (event.phase != .up or event.button != .primary) return .pass; const click: *PlaceClick = @ptrCast(@alignCast(user_data)); click.owner.selectPlace(click.index) catch |err| { log.err("label click: {t}", .{err}); return .pass; }; return .handled; } }; /// A press anywhere in a column focuses it. const ColumnPress = struct { owner: *Columns, column: Column, fn call(user_data: ?*anyopaque, _: siv.Node, event: *const siv.Event) callconv(.c) siv.EventResult { if (event.phase != .down or event.button != .primary) return .pass; const press: *ColumnPress = @ptrCast(@alignCast(user_data)); _ = press.owner.focusColumn(press.column) catch |err| { log.err("column press: {t}", .{err}); }; return .pass; } }; arena: Allocator, window: siv.Window, store: *Store, mode: Mode, focus: Column, /// Index into `places`. place: u8, /// The threads shown, newest first; the view's items index this. ids: []Store.Id, ids_buf: [Store.capacity]Store.Id, /// The selected thread, or null when the label is empty. current: ?u32, frames: enums.EnumArray(Column, Frame), place_rows: [places.len]PlaceRow, list: siv.Node, empty: siv.Node, counts: siv.Node, message: siv.Node, message_empty: siv.Node, subject: siv.Node, from: siv.Node, chips_slot: siv.Node, chips: siv.Node, body: siv.Node, bar_slot: siv.Node, bar: siv.Node, fn build(arena: Allocator, window: siv.Window, store: *Store, _: ?Settings) anyerror!void { const self = try arena.create(Columns); self.* = .{ .arena = arena, .window = window, .store = store, .mode = .browse, .focus = .labels, .place = 0, .ids = &.{}, .ids_buf = undefined, .current = 0, .frames = undefined, .place_rows = undefined, .list = .null, .empty = .null, .counts = .null, .message = .null, .message_empty = .null, .subject = .null, .from = .null, .chips_slot = .null, .chips = .null, .body = .null, .bar_slot = .null, .bar = .null, }; const txn = try siv.txnBegin(window); errdefer siv.txnAbort(txn); const root = siv.windowGetRoot(window); siv.setVisual(txn, root, .{ .background_token = .view_bg }); const column = siv.flex(txn, root, .{ .axis = .column, .align_items = .stretch }); siv.setLayout(txn, column, .{ .width = kit.whole, .height = kit.whole }); const browser = siv.flex(txn, column, .{ .align_items = .stretch }); siv.setLayout(txn, browser, .{ .grow = 1, .basis = kit.px(0) }); try self.buildLabels(txn, try self.frame(txn, browser, .labels, "Labels", 240)); rule(txn, browser); self.buildThreads(txn, try self.frame(txn, browser, .threads, "Threads", 420)); rule(txn, browser); self.buildMessage(txn, try self.frame(txn, browser, .message, "Message", 0)); const footer = siv.flex(txn, column, .{ .axis = .column, .align_items = .stretch }); siv.setVisual(txn, footer, .{ .background_token = .window_bg, .border_token = .separator }); self.bar_slot = footer; kit.keymap(Columns, txn, root, self); try siv.txnCommit(txn); try self.sync(); } /// A column: an accent edge along the top when it has focus, a title, and /// the content box this returns. A width of 0 takes what is left. fn frame(self: *Columns, txn: siv.Txn, parent: siv.Node, column: Column, title: []const u8, width: f32) !siv.Node { const node = siv.flex(txn, parent, .{ .axis = .column, .align_items = .stretch }); if (width > 0) siv.setLayout(txn, node, .{ .width = kit.px(width), .flags = .{ .no_shrink = true } }) else siv.setLayout(txn, node, .{ .grow = 1, .basis = kit.px(0) }); if (column == .labels) siv.setVisual(txn, node, .{ .background_token = .sidebar_bg }); siv.setSemantics(txn, node, .{ .role = .group, .label = title }); const press = try self.arena.create(ColumnPress); press.* = .{ .owner = self, .column = column }; siv.on(txn, node, .pointer, ColumnPress.call, null, press); const edge = siv.box(txn, node); siv.setLayout(txn, edge, .{ .height = kit.px(3) }); const header = siv.flex(txn, node, .{ .gap = 10, .align_items = .end }); // A fixed height, so the focused column's larger title does not push its rows down. siv.setLayout(txn, header, .{ .height = kit.px(46), .padding = .{ .start = 16, .end = 16, .bottom = 8 } }); const title_node = kit.text(txn, header, title, .{ .flags = kit.one_line }); if (column == .threads) { self.counts = kit.text(txn, header, "", .{ .size_role = .caption, .color_token = .muted_fg }); } self.frames.set(column, .{ .node = node, .edge = edge, .title = title_node }); const content = siv.flex(txn, node, .{ .axis = .column, .align_items = .stretch }); siv.setLayout(txn, content, .{ .grow = 1, .basis = kit.px(0) }); return content; } fn rule(txn: siv.Txn, parent: siv.Node) void { const node = siv.box(txn, parent); siv.setLayout(txn, node, .{ .width = kit.px(1), .flags = .{ .no_shrink = true } }); siv.setVisual(txn, node, .{ .background_token = .separator }); } fn buildLabels(self: *Columns, txn: siv.Txn, parent: siv.Node) !void { const list = siv.flex(txn, parent, .{ .axis = .column, .gap = 1, .align_items = .stretch }); siv.setLayout(txn, list, .{ .padding = .{ .start = 8, .end = 8 } }); siv.setSemantics(txn, list, .{ .role = .list, .label = "Labels" }); for (places, 0..) |place, i| { if (i == 0) heading(txn, list, "MAILBOXES"); if (i == system_places.len) heading(txn, list, "LABELS"); const click = try self.arena.create(PlaceClick); click.* = .{ .owner = self, .index = @intCast(i) }; self.place_rows[i] = placeRow(txn, list, place); siv.on(txn, self.place_rows[i].node, .pointer, PlaceClick.call, null, click); } } fn heading(txn: siv.Txn, parent: siv.Node, s: []const u8) void { const node = kit.text(txn, parent, s, .{ .size_role = .caption, .weight = .semibold, .color_token = .muted_fg }); siv.setLayout(txn, node, .{ .padding = .{ .start = 10, .top = 10, .bottom = 4 } }); } fn placeRow(txn: siv.Txn, parent: siv.Node, place: Place) PlaceRow { const node = siv.flex(txn, parent, .{ .gap = 8, .align_items = .center }); const indent: f32 = 10 + 18 * @as(f32, @floatFromInt(place.depth)); siv.setLayout(txn, node, .{ .padding = .{ .start = indent, .end = 10, .top = 5, .bottom = 5 } }); siv.setCursor(txn, node, .pointer); const dot = siv.box(txn, node); siv.setLayout(txn, dot, .{ .width = kit.px(8), .height = kit.px(8) }); const color: siv.Color = if (place.label) |l| kit.color(l.info().color) else kit.tint(gray, 1); siv.setVisual(txn, dot, .{ .background = color, .radius = kit.corners(4) }); const name = kit.text(txn, node, placeName(place), .{}); siv.setLayout(txn, name, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); const pill = siv.flex(txn, node, .{}); siv.setLayout(txn, pill, .{ .padding = .{ .start = 6, .end = 6 } }); siv.setVisual(txn, pill, .{ .background_token = .accent, .radius = kit.corners(8) }); const unread = kit.text(txn, pill, "", .{ .size_role = .caption, .weight = .semibold, .color_token = .accent_fg }); const total = kit.text(txn, node, "", .{ .size_role = .caption, .color_token = .muted_fg }); siv.setLayout(txn, total, .{ .min_width = 18 }); siv.setTextStyle(txn, total, .{ .size_role = .caption, .color_token = .muted_fg, .@"align" = .end }); return .{ .node = node, .name = name, .pill = pill, .unread = unread, .total = total }; } const gray: sample.Rgb = .{ .r = 0x9c, .g = 0xa3, .b = 0xaf }; /// "All Mail" for null; a nested label shows only its own part, "Hiring". fn placeName(place: Place) []const u8 { const label = place.label orelse return "All Mail"; const name = label.info().name; const slash = mem.findScalarLast(u8, name, '/') orelse return name; return name[slash + 1 ..]; } fn buildThreads(self: *Columns, txn: siv.Txn, parent: siv.Node) void { const body = siv.stack(txn, parent, .{}); siv.setLayout(txn, body, .{ .grow = 1, .basis = kit.px(0) }); self.list = siv.listView(txn, body, .{ .label = "Threads", .user_data = self, .bind = bindRow, .item_height = row_height, .selection = .single, }); siv.setLayout(txn, self.list, .{ .width = kit.whole, .height = kit.whole }); // The list keeps keyboard focus whichever column has it; the accent edge shows which does. siv.setVisual(txn, self.list, .{ .flags = .{ .no_focus_ring = true } }); kit.on(Columns, txn, self.list, .select, self, onSelect); kit.on(Columns, txn, self.list, .activate, self, onActivate); self.empty = kit.text(txn, body, "Nothing here", .{ .color_token = .muted_fg }); siv.setLayout(txn, self.empty, .{ .align_x = .center, .align_y = .center }); } fn buildMessage(self: *Columns, txn: siv.Txn, parent: siv.Node) void { self.message = siv.flex(txn, parent, .{ .axis = .column, .gap = 10, .align_items = .start }); siv.setLayout(txn, self.message, .{ .padding = .{ .start = 28, .end = 28, .top = 8, .bottom = 16 } }); const heading_style: siv.TextStyleDesc.Options = .{ .size_role = .heading, .weight = .bold, .color_token = .view_fg, }; self.subject = kit.text(txn, self.message, "", heading_style); self.from = kit.text(txn, self.message, "", .{ .color_token = .muted_fg }); self.chips_slot = siv.box(txn, self.message); siv.setLayout(txn, self.chips_slot, .{ .width = kit.whole }); self.body = kit.text(txn, self.message, "", .{ .color_token = .view_fg }); siv.setLayout(txn, self.body, .{ .max_width = 560, .padding = .{ .top = 8 } }); self.message_empty = kit.text(txn, parent, "No thread", .{ .color_token = .muted_fg }); siv.setLayout(txn, self.message_empty, .{ .grow = 1, .align_x = .center, .align_y = .center }); } pub fn onKey(self: *Columns, key: Key) !bool { if (self.mode == .label) return self.labelKey(key); if (key.is('h') or key.isNamed(.left)) return self.step(.left); if (key.is('l') and self.focus == .message) return self.startLabeling(); if (key.is('l') or key.isNamed(.right) or key.isNamed(.@"return")) return self.step(.right); if (key.is('j') or key.isNamed(.down)) return self.move(1); if (key.is('k') or key.isNamed(.up)) return self.move(-1); if (self.focus == .labels) return false; if (key.is('e')) return self.act(.archive); if (key.is('s')) return self.act(.star); if (key.is('u')) return self.act(.unread); return false; } fn labelKey(self: *Columns, key: Key) !bool { if (key.isNamed(.escape) or key.isNamed(.@"return")) { self.mode = .browse; try self.sync(); return true; } const c = key.printable() orelse return true; const label = Label.fromKey(c) orelse return true; if (label.info().kind != .user) return true; const current = self.current orelse return true; self.store.toggleLabel(self.ids[current], label); try self.sync(); return true; } /// Moves focus one column left or right. fn step(self: *Columns, direction: enum { left, right }) !bool { const next: ?Column = switch (self.focus) { .labels => if (direction == .right) .threads else null, .threads => if (direction == .right) .message else .labels, .message => if (direction == .right) null else .threads, }; return self.focusColumn(next orelse return true); } fn focusColumn(self: *Columns, column: Column) !bool { if (column == .message and self.current == null) return true; self.mode = .browse; self.focus = column; if (column == .message) self.markRead(); try self.sync(); return true; } fn startLabeling(self: *Columns) !bool { if (self.current == null) return true; self.mode = .label; try self.sync(); return true; } /// Moves the cursor in the focused column; in the message column it steps /// through the threads, so the message follows. fn move(self: *Columns, delta: i32) !bool { if (self.focus == .labels) { const last: i32 = places.len - 1; return self.selectPlaceKeepingFocus(@intCast(math.clamp(@as(i32, self.place) + delta, 0, last))); } const current = self.current orelse return true; const last: i32 = @intCast(self.ids.len - 1); self.current = @intCast(math.clamp(@as(i32, @intCast(current)) + delta, 0, last)); if (self.focus == .message) self.markRead(); try self.sync(); return true; } fn selectPlace(self: *Columns, index: u8) !void { self.focus = .labels; _ = try self.selectPlaceKeepingFocus(index); } fn selectPlaceKeepingFocus(self: *Columns, index: u8) !bool { if (index != self.place) self.current = 0; self.place = index; self.mode = .browse; try self.sync(); return true; } const Action = enum { archive, star, unread }; fn act(self: *Columns, action: Action) !bool { const current = self.current orelse return true; const id = self.ids[current]; switch (action) { .archive => self.store.archive(id), .star => self.store.toggleLabel(id, .starred), .unread => self.store.setUnread(id, !self.store.get(id).unread), } try self.sync(); return true; } fn markRead(self: *Columns) void { const current = self.current orelse return; self.store.setUnread(self.ids[current], false); } /// Runs the label's query again, keeps the cursor near where it was, and /// redraws every column and the key bar. fn sync(self: *Columns) !void { self.ids = self.store.query(.{ .label = places[self.place].label }, &self.ids_buf); self.current = if (self.ids.len == 0) null else @min(self.current orelse 0, self.ids.len - 1); if (self.current == null) { self.mode = .browse; if (self.focus == .message) self.focus = .threads; } const txn = try siv.txnBegin(self.window); errdefer siv.txnAbort(txn); siv.viewSetCount(txn, self.list, @intCast(self.ids.len)); siv.viewRefresh(txn, self.list); siv.setHidden(txn, self.empty, self.ids.len > 0); if (self.current) |current| { siv.viewSetSelected(txn, self.list, current, true); siv.viewScrollTo(txn, self.list, current); } self.showFrames(txn); self.showPlaces(txn); self.showMessage(txn); try self.showBar(txn); try siv.txnCommit(txn); try siv.nodeFocus(self.list); } fn showFrames(self: *Columns, txn: siv.Txn) void { for (enums.values(Column)) |column| { const f = self.frames.get(column); const focused = column == self.focus; siv.setVisual(txn, f.edge, .{ .background_token = if (focused) .accent else .none }); siv.setTextStyle(txn, f.title, .{ .size_role = if (focused) .heading else .body, .weight = if (focused) .bold else .medium, .color_token = if (focused) .view_fg else .muted_fg, .flags = kit.one_line, }); siv.setOpacity(txn, f.node, if (focused) 1 else dimmed); } const name = placeName(places[self.place]); siv.setText(txn, self.frames.get(.threads).title, name); const c = self.store.count(.{ .label = places[self.place].label }); var buf: [64]u8 = undefined; siv.setText(txn, self.counts, mem.print(&buf, "{d} threads, {d} unread", .{ c.total, c.unread }) catch ""); } fn showPlaces(self: *Columns, txn: siv.Txn) void { for (places, &self.place_rows, 0..) |place, row, i| { const selected = i == self.place; const strong = selected and self.focus == .labels; siv.setVisual(txn, row.node, .{ .background_token = if (strong) .selection_bg else if (selected) .control_active else .none, .radius = kit.corners(6), }); siv.setSemantics(txn, row.node, .{ .role = .list_item, .label = placeName(place), .selected = if (selected) .on else .off, }); const c = self.store.count(.{ .label = place.label }); siv.setTextStyle(txn, row.name, .{ .weight = if (c.unread > 0) .semibold else .regular, .color_token = if (strong) .selection_fg else .view_fg, .flags = kit.one_line, }); var unread_buf: [16]u8 = undefined; var total_buf: [16]u8 = undefined; siv.setText(txn, row.unread, mem.print(&unread_buf, "{d}", .{c.unread}) catch ""); siv.setHidden(txn, row.pill, c.unread == 0); siv.setText(txn, row.total, mem.print(&total_buf, "{d}", .{c.total}) catch ""); } } fn showMessage(self: *Columns, txn: siv.Txn) void { siv.setHidden(txn, self.message, self.current == null); siv.setHidden(txn, self.message_empty, self.current != null); const current = self.current orelse return; const thread = self.store.get(self.ids[current]); siv.setText(txn, self.subject, thread.subject); var age_buf: [16]u8 = undefined; var buf: [160]u8 = undefined; const age = kit.age(&age_buf, thread.minutes_ago); const plural = if (thread.messages == 1) "" else "s"; const args = .{ thread.from, thread.messages, plural, age }; const from = mem.print(&buf, "{s} · {d} message{s} · {s} ago", args) catch thread.from; siv.setText(txn, self.from, from); siv.setText(txn, self.body, thread.snippet); if (self.chips != .null) siv.remove(txn, self.chips); self.chips = siv.flex(txn, self.chips_slot, .{ .gap = 6, .flags = .{ .wrap = true } }); siv.setLayout(txn, self.chips, .{ .width = kit.whole }); var it = thread.labels.iterator(); while (it.next()) |label| _ = kit.chip(txn, self.chips, label); } /// The bottom bar: the keys of the focused column, every one clickable. fn showBar(self: *Columns, txn: siv.Txn) !void { if (self.bar != .null) siv.remove(txn, self.bar); var hints: ArrayList(kit.Hint) = .empty; if (self.mode == .label) { try self.labelHints(&hints); } else switch (self.focus) { .labels => try hints.appendSlice(self.arena, &.{ .{ .key = .char('j'), .cap = "j", .action = "next label" }, .{ .key = .char('k'), .cap = "k", .action = "previous label" }, .{ .key = .char('l'), .cap = "l", .action = "to threads" }, }), .threads => try hints.appendSlice(self.arena, &.{ .{ .key = .char('h'), .cap = "h", .action = "to labels" }, .{ .key = .char('j'), .cap = "j", .action = "next thread" }, .{ .key = .char('k'), .cap = "k", .action = "previous thread" }, .{ .key = .char('l'), .cap = "l", .action = "to message" }, }), .message => try hints.appendSlice(self.arena, &.{ .{ .key = .char('h'), .cap = "h", .action = "to threads" }, .{ .key = .char('j'), .cap = "j", .action = "next thread" }, .{ .key = .char('k'), .cap = "k", .action = "previous thread" }, .{ .key = .char('l'), .cap = "l", .action = "label" }, }), } if (self.mode == .browse and self.focus != .labels) try hints.appendSlice(self.arena, &.{ .{ .key = .char('e'), .cap = "e", .action = "archive" }, .{ .key = .char('s'), .cap = "s", .action = "star" }, .{ .key = .char('u'), .cap = "u", .action = "unread" }, }); self.bar = try kit.hintBar(Columns, self.arena, txn, self.bar_slot, self, hints.items); } fn labelHints(self: *Columns, hints: *ArrayList(kit.Hint)) !void { const thread = if (self.current) |c| self.store.get(self.ids[c]) else null; for (enums.values(Label)) |label| { const info = label.info(); if (info.kind != .user) continue; const on = if (thread) |t| t.labels.contains(label) else false; const action = if (on) try self.arena.print("{s} ✓", .{info.name}) else info.name; try hints.append(self.arena, .{ .key = .char(info.key), .cap = label.keyCap(), .action = action }); } try hints.append(self.arena, .{ .key = .named(.escape), .cap = "Esc", .action = "done" }); } /// A click on a thread focuses the threads column and selects it. fn onSelect(self: *Columns, event: *const siv.Event) !bool { if (event.count == 0) return true; self.current = event.index; self.focus = .threads; self.mode = .browse; try self.sync(); return true; } fn onActivate(self: *Columns, event: *const siv.Event) !bool { self.current = event.index; return self.focusColumn(.message); } fn bindRow( user_data: ?*anyopaque, txn: siv.Txn, row: siv.Node, item: u64, _: u32, _: u32, recycled: bool, ) callconv(.c) void { const self: *Columns = @ptrCast(@alignCast(user_data)); if (!recycled) makeRow(txn, row); const thread = self.store.get(self.ids[@intCast(item)]); const content = siv.nodeGetRelated(row, .first_child); const top = siv.nodeGetRelated(content, .first_child); const unread_dot = siv.nodeGetRelated(top, .first_child); const from = siv.nodeGetRelated(unread_dot, .next); const age = siv.nodeGetRelated(from, .next); const bottom = siv.nodeGetRelated(top, .next); const dot_color: siv.ColorToken = if (thread.unread) .accent else .none; siv.setVisual(txn, unread_dot, .{ .background_token = dot_color, .radius = kit.corners(4) }); siv.setText(txn, from, thread.from); siv.setTextStyle(txn, from, .{ .weight = if (thread.unread) .bold else .regular, .color_token = .view_fg, .flags = kit.one_line, }); var age_buf: [16]u8 = undefined; siv.setText(txn, age, kit.age(&age_buf, thread.minutes_ago)); var line_buf: [256]u8 = undefined; const star = if (thread.labels.contains(.starred)) "★ " else ""; const line = mem.print(&line_buf, "{s}{s} — {s}", .{ star, thread.subject, thread.snippet }) catch thread.subject; siv.setText(txn, bottom, line); } fn makeRow(txn: siv.Txn, row: siv.Node) void { const content = siv.flex(txn, row, .{ .axis = .column, .gap = 2, .justify = .center, .align_items = .stretch }); siv.setLayout(txn, content, .{ .width = kit.whole, .height = kit.whole, .padding = .{ .start = 14, .end = 16 } }); const top = siv.flex(txn, content, .{ .gap = 8, .align_items = .center }); const unread_dot = siv.box(txn, top); siv.setLayout(txn, unread_dot, .{ .width = kit.px(8), .height = kit.px(8) }); const from = kit.text(txn, top, "", .{}); siv.setLayout(txn, from, .{ .grow = 1, .shrink = 1, .basis = kit.px(0) }); _ = kit.text(txn, top, "", .{ .size_role = .caption, .color_token = .muted_fg }); const bottom = kit.text(txn, content, "", .{ .color_token = .muted_fg, .flags = kit.one_line }); siv.setLayout(txn, bottom, .{ .padding = .{ .start = 16 } }); }