╭ changed ───────────╮ │src/ui/Repo/Refs.zig│ ╰────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────────────────────────────────────╮ │12 const inp = @import("../input.zig"); │ │13 │ │14 // how many refs one window of a column shows. │ │15 -pub const page_size = 20; │ │15 +pub const page_size = 50; │ │16 │ │17 // "owner/name", needed to build the columns' window-navigation links.│ │18 identity: []const u8, │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │123 } │ │124 │ │125 pub const View = struct { │ │126 - // a horizontal box of two vertical columns. each column is a fixed, │ │127 - // centered, non-focusable label above a Scroll of focusable rows, so the │ │128 - // label stays visible while the rows scroll. focus tracks a single selected │ │129 - // row across both columns: the box's focus path points at the active column,│ │130 - // that column's at its scroll, and the scroll's (its rows') at the row. │ │126 + // a horizontal box of two columns, each a fixed label above a Scroll of a │ │127 + // TagFlow, so the names wrap across the column and scroll beneath the │ │128 + // label. focus points at the active column and, in its flow, the selected │ │129 + // name. │ │130 box: wgt.Box(ui.Widget), │ │132 - data: *const Self, │ │131 + session: *ui.Session, │ │132 │ │133 const left_col = 0; │ │134 const right_col = 1; │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │140 try addColumn(allocator, &box, session, data.identity, .branch, &data.branches);│ │141 try addColumn(allocator, &box, session, data.identity, .tag, &data.tags); │ │142 │ │144 - var self = View{ .box = box, .data = data }; │ │145 - // select the first row of the first column that has one. │ │146 - for (self.box.children.keys(), self.box.children.values()) |id, *child| { │ │143 + // select the first name of the first column that has one │ │144 + for (box.children.keys(), box.children.values()) |id, *child| { │ │145 if (child.widget.box.getFocus().child_id != null) { │ │148 - self.box.getFocus().child_id = id; │ │146 + box.getFocus().child_id = id; │ │147 break; │ │148 } │ │149 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │152 - return self; │ │150 + return .{ .box = box, .session = session };│ │151 } │ │152 │ │153 fn addColumn( │ ╰────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │162 errdefer column.deinit(allocator); │ │163 │ │164 // a fixed, non-focusable header in the SubTitle font, nudged off the │ │167 - // left edge by a one-column space. it stays visible while the rows │ │168 - // scroll below, and declares its height (2 rows) as a min so the (fill) │ │169 - // scroll reserves room for it rather than consuming the whole column. │ │165 + // left edge by a one-column space. it declares its height (2 rows plus │ │166 + // a blank one beneath) as a min so the (fill) scroll reserves room for it. │ │167 { │ │168 var header_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz });│ │169 errdefer header_box.deinit(allocator); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │177 errdefer header.deinit(allocator); │ │178 try header_box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .sub_title = header }, .rect = null, .min_size = null }); │ │179 } │ │183 - try column.children.put(allocator, header_box.getFocus().id, .{ .widget = .{ .box = header_box }, .rect = null, .min_size = .{ .width = null, .height = 2 } });│ │180 + try column.children.put(allocator, header_box.getFocus().id, .{ .widget = .{ .box = header_box }, .rect = null, .min_size = .{ .width = null, .height = 3 } });│ │181 } │ │182 │ │186 - // a long ref list overflows the page height; the rows scroll on their │ │187 - // own (the label above stays put) so they lay out under a fixed height. │ │183 + // each name links to the files tab at that ref's root. window │ │184 + // navigation brackets them: "← previous" off the first window, "next →" │ │185 + // when more remain, each a full reload. │ │186 { │ │189 - var scroll = blk: { │ │190 - var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │191 - errdefer rows.deinit(allocator); │ │192 - │ │193 - // window-navigation rows bracket the names: "← previous" off the │ │194 - // first window, "next →" when more remain. each is a full reload. │ │195 - if (data.prev) |p| try addRow(allocator, &rows, "← previous", try windowLink(session.page_arena, identity, kind, p)); │ │196 - // each ref name links to the files tab at that ref's root. │ │197 - for (data.names) |name| try addRow(allocator, &rows, name, try refLink(session.page_arena, identity, kind, name)); │ │198 - if (data.next) |n| try addRow(allocator, &rows, "next →", try windowLink(session.page_arena, identity, kind, n)); │ │199 - │ │200 - if (rows.children.count() > 0) rows.getFocus().child_id = rows.children.keys()[0]; │ │187 + var items: std.ArrayList(ui.widget.TagFlow.Item) = .empty; │ │188 + defer items.deinit(allocator); │ │189 + if (data.prev) |p| try items.append(allocator, .{ .text = "← previous", .link = try windowLink(session.page_arena, identity, kind, p) }); │ │190 + for (data.names) |name| try items.append(allocator, .{ .text = name, .link = try refLink(session.page_arena, identity, kind, name) }); │ │191 + if (data.next) |n| try items.append(allocator, .{ .text = "next →", .link = try windowLink(session.page_arena, identity, kind, n) }); │ │192 │ │202 - // fill the column (rows top-aligned, scroll bar pinned to the │ │203 - // edge) rather than shrinking to the widest row. │ │204 - break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); │ │193 + var scroll = blk: { │ │194 + var flow = try ui.widget.TagFlow.init(allocator); │ │195 + errdefer flow.deinit(allocator); │ │196 + try flow.setItems(allocator, items.items); │ │197 + break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .tag_flow = flow }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); │ │198 }; │ │199 errdefer scroll.deinit(allocator); │ │200 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │208 - // the column's focus path skips the label and points at the scroll. │ │201 + // the column's focus path skips the label and points at the flow │ │202 if (scroll.getFocus().child_id != null) column.getFocus().child_id = scroll.getFocus().id; │ │203 try column.children.put(allocator, scroll.getFocus().id, .{ .widget = .{ .scroll = scroll }, .rect = null, .min_size = null });│ │204 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │205 │ │206 try box.children.put(allocator, column.getFocus().id, .{ .widget = .{ .box = column }, .rect = null, .min_size = null }); │ │214 - } │ │215 - │ │216 - // [0] = the fixed label, [1] = the scrollable rows. │ │217 - fn columnScroll(column: *wgt.Box(ui.Widget)) *wgt.Scroll(ui.Widget) { │ │218 - return &column.children.values()[1].widget.scroll; │ │219 - } │ │220 - │ │221 - // a focusable row in a column. `link`, when present, is the row's `a:` │ │222 - // navigation kind (the window-navigation rows); ref names pass null. │ │223 - fn addRow(allocator: std.mem.Allocator, col: *wgt.Box(ui.Widget), label: []const u8, link: ?[]const u8) !void { │ │224 - var row = try wgt.TextBox.init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none });│ │225 - errdefer row.deinit(allocator); │ │226 - row.getFocus().mode = .all; │ │227 - if (link) |l| row.getFocus().kind = .{ .custom = l }; │ │228 - try col.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null }); │ │207 } │ │208 │ │209 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │213 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │214 self.clearGrid(); │ │215 │ │238 - // the selected row (in the active column) shows a single border; the │ │239 - // focused TextBox upgrades it to a double border itself. │ │240 - const active_col_id = self.box.getFocus().child_id; │ │241 - for (self.box.children.values()) |*child| { │ │242 - const column = &child.widget.box; │ │243 - const col = &columnScroll(column).child.box; │ │244 - const active = column.getFocus().id == active_col_id; │ │245 - for (col.children.keys(), col.children.values()) |id, *row| { │ │246 - switch (row.widget) { │ │247 - .text_box => |*tb| tb.options.border_style = if (active and col.getFocus().child_id == id) .single else .hidden,│ │248 - else => {}, │ │249 - } │ │250 - } │ │251 - } │ │252 - │ │216 // split the available width evenly between the two columns when it's │ │254 - // known; otherwise let them size to their content. each column's Scroll │ │255 - // has `fill = true`, so it stretches across the column (rows/scroll bar) │ │256 - // rather than hugging the widest row. │ │217 + // known; otherwise let them size to their content. │ │218 if (constraint.max_size.width) |w| { │ │219 const half = w / 2; │ │220 for (self.box.children.values()) |*child| { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │238 │ │239 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │240 _ = allocator; │ │280 - // up/down (and the scroll wheel) move within the active column a row; │ │281 - // page up/down and home/end jump; left/right switch columns, keeping the │ │282 - // same row offset. the parent (Repo) intercepts up at the top row to move │ │283 - // focus to the header. │ │284 - if (inp.rowDelta(key, self.columnRowCount())) |delta| { │ │285 - const scroll = self.activeScroll() orelse return; │ │286 - ui.widget.moveRowFocus(&scroll.child.box, scroll, root_focus, delta); │ │287 - return; │ │241 + // arrows and the scroll wheel move the selection within the active │ │242 + // flow. left from a row's first name and right from its last cross to │ │243 + // the other column's same row. the parent (Repo) takes up from the first │ │244 + // row to the header. │ │245 + const index = self.activeIndex() orelse return; │ │246 + const flow = self.flowAt(index); │ │247 + const cur = flow.indexOfFocusId(flow.focus.child_id orelse return) orelse return; │ │248 + // a flow last laid out at zero width has names without positions │ │249 + if (cur >= flow.rects.items.len) return; │ │250 + const count = flow.text_boxes.items.len; │ │251 + const cur_y = flow.rects.items[cur].y; │ │252 + switch (inp.vertDirection(key)) { │ │253 + .up => if (flow.rowStep(cur, false)) |i| return self.focusName(index, i, root_focus), │ │254 + .down => if (flow.rowStep(cur, true)) |i| return self.focusName(index, i, root_focus),│ │255 + .none => {}, │ │256 } │ │257 switch (key) { │ │290 - .arrow_left => try self.switchColumn(root_focus, left_col), │ │291 - .arrow_right => try self.switchColumn(root_focus, right_col), │ │258 + .arrow_left => if (index == right_col and rowStart(flow, cur)) { │ │259 + self.crossTo(left_col, cur_y, root_focus); │ │260 + } else if (cur > 0) self.focusName(index, cur - 1, root_focus), │ │261 + .arrow_right => if (index == left_col and rowEnd(flow, cur)) { │ │262 + self.crossTo(right_col, cur_y, root_focus); │ │263 + } else if (cur + 1 < count) self.focusName(index, cur + 1, root_focus), │ │264 + .home => self.focusName(index, 0, root_focus), │ │265 + .end => self.focusName(index, count - 1, root_focus), │ │266 else => {}, │ │267 } │ │268 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯