╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ───────────╮ │src/ui/Repo/Refs.zig│ ╰────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │269 │ │296 - // the active column's row count, used as a clamp-to-end delta for home/end. │ │297 - fn columnRowCount(self: *View) isize { │ │298 - const col = self.activeColumn() orelse return 0; │ │299 - return @intCast(col.children.count()); │ │270 + fn activeIndex(self: *View) ?usize { │ │271 + const id = self.box.getFocus().child_id orelse return null; │ │272 + return self.box.children.getIndex(id); │ │273 + } │ │274 + │ │275 + // [0] = the fixed label, [1] = the scrolling flow │ │276 + fn scrollAt(self: *View, index: usize) *wgt.Scroll(ui.Widget) { │ │277 + return &self.box.children.values()[index].widget.box.children.values()[1].widget.scroll; │ │278 + } │ │279 + │ │280 + fn flowAt(self: *View, index: usize) *ui.widget.TagFlow { │ │281 + return &self.scrollAt(index).child.tag_flow; │ │282 + } │ │283 + │ │284 + fn rowStart(flow: *ui.widget.TagFlow, item: usize) bool { │ │285 + const rects = flow.rects.items; │ │286 + return item == 0 or rects[item - 1].y != rects[item].y; │ │287 } │ │288 │ │302 - fn activeScroll(self: *View) ?*wgt.Scroll(ui.Widget) { │ │303 - const id = self.box.getFocus().child_id orelse return null; │ │304 - const index = self.box.children.getIndex(id) orelse return null; │ │305 - return columnScroll(&self.box.children.values()[index].widget.box); │ │289 + fn rowEnd(flow: *ui.widget.TagFlow, item: usize) bool { │ │290 + const rects = flow.rects.items; │ │291 + return item + 1 == rects.len or rects[item + 1].y != rects[item].y; │ │292 } │ │293 │ │308 - fn activeColumn(self: *View) ?*wgt.Box(ui.Widget) { │ │309 - return &(self.activeScroll() orelse return null).child.box; │ │294 + // land on the other column's row at or above the current one: its first │ │295 + // name when moving right, its last when moving left. names are laid out in │ │296 + // order, so that row ends at the last name no lower than the current one. │ │297 + fn crossTo(self: *View, target: usize, from_y: isize, root_focus: *Focus) void { │ │298 + // the other column may have no names at all │ │299 + const rects = self.flowAt(target).rects.items; │ │300 + if (rects.len == 0) return; │ │301 + var last: usize = 0; │ │302 + for (rects, 0..) |rect, i| { │ │303 + if (rect.y <= from_y) last = i; │ │304 + } │ │305 + var first = last; │ │306 + while (first > 0 and rects[first - 1].y == rects[last].y) first -= 1; │ │307 + self.focusName(target, if (target == right_col) first else last, root_focus); │ │308 } │ │309 │ │312 - fn switchColumn(self: *View, root_focus: *Focus, target: usize) !void { │ │313 - if (target >= self.box.children.count()) return; │ │314 - const cur_col = self.activeColumn() orelse return; │ │315 - const target_scroll = columnScroll(&self.box.children.values()[target].widget.box); │ │316 - const target_col = &target_scroll.child.box; │ │317 - // an empty column has nothing to select; ignore. │ │318 - if (target_col.children.count() == 0) return; │ │319 - // keep the same row offset, clamped to the target column's last row. │ │320 - const cur_id = cur_col.getFocus().child_id orelse return; │ │321 - const cur = cur_col.children.getIndex(cur_id) orelse return; │ │322 - const last = target_col.children.count() - 1; │ │323 - const next = @min(cur, last); │ │324 - root_focus.setFocus(target_col.children.keys()[next]); │ │325 - if (target_col.children.values()[next].rect) |rect| target_scroll.scrollToRect(rect); │ │310 + fn focusName(self: *View, index: usize, item: usize, root_focus: *Focus) void { │ │311 + const flow = self.flowAt(index); │ │312 + root_focus.setFocus(flow.text_boxes.items[item].getFocus().id); │ │313 + // the browser scrolls natively; the terminal brings the name into view │ │314 + if (self.session.is_terminal and item < flow.rects.items.len) self.scrollAt(index).scrollToRect(flow.rects.items[item]);│ │315 } │ │316 │ │317 pub fn clearGrid(self: *View) void { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │326 return self.box.getFocus(); │ │327 } │ │328 │ │329 + // on the active flow's first row, where up leaves the tab │ │330 pub fn atTop(self: *View) bool { │ │341 - const col = self.activeColumn() orelse return false; │ │342 - const cur_id = col.getFocus().child_id orelse return false; │ │343 - return col.children.getIndex(cur_id) == 0; │ │331 + const index = self.activeIndex() orelse return false; │ │332 + const flow = self.flowAt(index); │ │333 + const cur = flow.indexOfFocusId(flow.focus.child_id orelse return false) orelse return false;│ │334 + return flow.rowStep(cur, false) == null; │ │335 } │ │336 }; │ │337 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯