╭ changed ────╮ │build.zig.zon│ ╰─────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 3 .version = "0.0.0", │ │ 4 .dependencies = .{ │ │ 5 .xit = .{ │ │ 6 - .url = "git+https://github.com/xit-vcs/xit#51c8c051d3bf8565be16638002be763f0afa2669",│ │ 7 - .hash = "xit-0.0.0-Ub3nDDrhTwHmZ3DrhShBb-Sy3jw3jvkkIrXzDN0yd6Ot", │ │ 6 + .url = "git+https://github.com/xit-vcs/xit#335bfaefb4a99c34156f9282ea97a45c45040321",│ │ 7 + .hash = "xit-0.0.0-Ub3nDLTjTwE_Rf543BJ0rgO_LwZgWWWY9qRsKYxqMCqu", │ │ 8 }, │ │ 9 }, │ │10 .paths = .{ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────────────────────────────────────╮ │191 text_input: wgt.TextInput(Widget),│ │192 scroll: wgt.Scroll(Widget), │ │193 stack: wgt.Stack(Widget), │ │194 - selectable_list: SelectableList, │ │194 + flow_box: FlowBox, │ │195 spacer: Spacer, │ │196 center: Center, │ │197 home: Home.View, │ ╰───────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │238 } │ │239 }; │ │240 │ │241 -// a scrollable list of selectable single-line items. content is replaced │ │242 -// via setItems, which dupes the strings into the widget's own allocation. │ │243 -pub const SelectableList = struct { │ │244 - scroll: wgt.Scroll(Widget), │ │241 +pub const FlowBox = struct { │ │242 + focus: Focus, │ │243 + grid: ?Grid, │ │244 + text_boxes: std.ArrayList(wgt.TextBox(Widget)), │ │245 lines: std.ArrayList([]const u8), │ │246 + // column count from the last build — FlowBox.Scroll.input uses it so arrow │ │247 + // up/down can step by a row's worth of items. │ │248 + last_cols: usize, │ │249 + options: Options, │ │250 │ │247 - pub fn init(allocator: std.mem.Allocator) !SelectableList { │ │248 - var self = blk: { │ │249 - var inner_box = wgt.Box(Widget).init(.{ .border_style = null, .rounded_corners = true, .direction = .vert });│ │250 - errdefer inner_box.deinit(allocator); │ │251 + const border_rows: usize = 2; │ │252 │ │252 - var scroll = try wgt.Scroll(Widget).init(allocator, .{ .box = inner_box }, .vert); │ │253 - errdefer scroll.deinit(allocator); │ │253 + pub const Options = struct { │ │254 + cell_width: usize = 40, │ │255 + cell_height: usize = 3, │ │256 + }; │ │257 │ │255 - break :blk SelectableList{ │ │256 - .scroll = scroll, │ │257 - .lines = .empty, │ │258 - }; │ │258 + pub fn init(options: Options) FlowBox { │ │259 + return .{ │ │260 + .focus = Focus.init(.container), │ │261 + .grid = null, │ │262 + .text_boxes = .empty, │ │263 + .lines = .empty, │ │264 + .last_cols = 1, │ │265 + .options = options, │ │266 }; │ │260 - errdefer self.deinit(allocator); │ │261 - return self; │ │267 } │ │268 │ │264 - pub fn deinit(self: *SelectableList, allocator: std.mem.Allocator) void { │ │265 - self.scroll.deinit(allocator); │ │269 + pub fn deinit(self: *FlowBox, allocator: std.mem.Allocator) void { │ │270 + self.focus.deinit(allocator); │ │271 + if (self.grid) |*grid| { │ │272 + grid.deinit(); │ │273 + self.grid = null; │ │274 + } │ │275 + for (self.text_boxes.items) |*tb| tb.deinit(allocator); │ │276 + self.text_boxes.deinit(allocator); │ │277 for (self.lines.items) |line| allocator.free(line); │ │278 self.lines.deinit(allocator); │ │279 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │269 - │ │270 - pub fn setItems(self: *SelectableList, allocator: std.mem.Allocator, items: []const []const u8) !void {│ │271 - const inner_box = &self.scroll.child.box; │ │280 │ │273 - for (inner_box.children.values()) |*child| { │ │274 - child.widget.deinit(allocator); │ │275 - } │ │276 - inner_box.children.clearAndFree(allocator); │ │281 + pub fn setItems(self: *FlowBox, allocator: std.mem.Allocator, items: []const []const u8) !void { │ │282 + for (self.text_boxes.items) |*tb| tb.deinit(allocator); │ │283 + self.text_boxes.clearAndFree(allocator); │ │284 │ │285 for (self.lines.items) |line| allocator.free(line); │ │286 self.lines.clearAndFree(allocator); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │287 │ │281 - self.scroll.x = 0; │ │282 - self.scroll.y = 0; │ │283 - // keep the focus tree in sync with the widget tree so setFocus can │ │284 - // descend even when the pane isn't laid out this build (e.g. narrow │ │285 - // terminal where only the focused pane fits). build's clear+addChild│ │286 - // would do this otherwise, but it gets skipped for unbuilt panes. │ │287 - inner_box.getFocus().clear(); │ │288 - inner_box.getFocus().child_id = null; │ │288 + self.focus.clear(); │ │289 + self.focus.child_id = null; │ │290 │ │291 for (items) |item| { │ │292 const line = try allocator.dupe(u8, item); │ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │295 try self.lines.append(allocator, line); │ │296 } │ │297 │ │297 - var text_box = try wgt.TextBox(Widget).init(allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .none });│ │298 + var text_box = try wgt.TextBox(Widget).init(allocator, line, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word });│ │299 errdefer text_box.deinit(allocator); │ │300 text_box.getFocus().focusable = true; │ │300 - try inner_box.children.put(allocator, text_box.getFocus().id, .{ .widget = .{ .text_box = text_box }, .rect = null, .min_size = null });│ │301 - try inner_box.getFocus().addChild(allocator, text_box.getFocus(), .{ .width = 0, .height = 0 }, 0, 0); │ │301 + try self.text_boxes.append(allocator, text_box); │ │302 } │ │303 │ │304 - if (inner_box.children.count() > 0) { │ │305 - inner_box.getFocus().child_id = inner_box.children.keys()[0]; │ │304 + if (self.text_boxes.items.len > 0) { │ │305 + self.focus.child_id = self.text_boxes.items[0].getFocus().id; │ │306 } │ │307 } │ │308 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │309 - pub fn build(self: *SelectableList, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void {│ │309 + pub fn build(self: *FlowBox, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │310 self.clearGrid(); │ │311 - const children = &self.scroll.child.box.children; │ │312 - for (children.keys(), children.values()) |id, *item| { │ │313 - item.widget.text_box.options.border_style = if (self.getFocus().child_id == id) .single else .hidden; │ │311 + self.focus.clear(); │ │312 + │ │313 + const cell_width = self.options.cell_width; │ │314 + const max_width = constraint.max_size.width orelse cell_width; │ │315 + const cols = if (cell_width == 0) 1 else @max(1, max_width / cell_width); │ │316 + self.last_cols = cols; │ │317 + │ │318 + const count = self.text_boxes.items.len; │ │319 + if (count == 0) return; │ │320 + const slot_height = self.options.cell_height + border_rows; │ │321 + if (slot_height == 0) return; │ │322 + │ │323 + // build at the slot size so every text box fits a single grid cell │ │324 + for (self.text_boxes.items) |*tb| { │ │325 + tb.options.border_style = if (self.focus.child_id == tb.getFocus().id) .single else .hidden; │ │326 + try tb.build(allocator, .{ │ │327 + .min_size = .{ .width = cell_width, .height = null }, │ │328 + .max_size = .{ .width = cell_width, .height = slot_height }, │ │329 + }, root_focus); │ │330 } │ │315 - try self.scroll.build(allocator, constraint, root_focus); │ │331 + │ │332 + const rows = (count + cols - 1) / cols; │ │333 + const content_height = rows * slot_height; │ │334 + const total_width = cols * cell_width; │ │335 + if (total_width == 0 or content_height == 0) return; │ │336 + │ │337 + var grid = try Grid.init(allocator, .{ .width = total_width, .height = content_height }); │ │338 + errdefer grid.deinit(); │ │339 + │ │340 + for (self.text_boxes.items, 0..) |*tb, i| { │ │341 + const tb_grid = tb.getGrid() orelse continue; │ │342 + const col = i % cols; │ │343 + const row = i / cols; │ │344 + const cell_x = col * cell_width; │ │345 + const cell_y = row * slot_height; │ │346 + try self.focus.addChild(allocator, tb.getFocus(), tb_grid.size, cell_x, cell_y); │ │347 + try grid.drawGrid(tb_grid, cell_x, cell_y); │ │348 + } │ │349 + │ │350 + self.grid = grid; │ │351 } │ │352 │ │318 - pub fn input(self: *SelectableList, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { │ │353 + pub fn input(self: *FlowBox, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { │ │354 + _ = self; │ │355 _ = allocator; │ │320 - if (self.getFocus().child_id) |child_id| { │ │321 - const children = &self.scroll.child.box.children; │ │322 - if (children.getIndex(child_id)) |current_index| { │ │323 - var index = current_index; │ │356 + _ = key; │ │357 + _ = root_focus; │ │358 + } │ │359 │ │325 - switch (key) { │ │326 - .arrow_up => { │ │327 - index -|= 1; │ │328 - }, │ │329 - .arrow_down => { │ │330 - if (index + 1 < children.count()) { │ │331 - index += 1; │ │332 - } │ │333 - }, │ │334 - .home => { │ │335 - index = 0; │ │336 - }, │ │337 - .end => { │ │338 - if (children.count() > 0) { │ │339 - index = children.count() - 1; │ │340 - } │ │341 - }, │ │342 - .page_up => { │ │343 - if (self.getGrid()) |grid| { │ │344 - const half_count = (grid.size.height / 3) / 2; │ │345 - index -|= half_count; │ │346 - } │ │347 - }, │ │348 - .page_down => { │ │349 - if (self.getGrid()) |grid| { │ │350 - if (children.count() > 0) { │ │351 - const half_count = (grid.size.height / 3) / 2; │ │352 - index = @min(index + half_count, children.count() - 1); │ │353 - } │ │354 - } │ │355 - }, │ │356 - .mouse => |mouse| switch (mouse.action) { │ │357 - .scroll => |dir| switch (dir) { │ │358 - .up => index -|= 1, │ │359 - .down => if (index + 1 < children.count()) { │ │360 - index += 1; │ │361 - }, │ │360 + pub fn clearGrid(self: *FlowBox) void { │ │361 + if (self.grid) |*grid| { │ │362 + grid.deinit(); │ │363 + self.grid = null; │ │364 + } │ │365 + for (self.text_boxes.items) |*tb| tb.clearGrid(); │ │366 + } │ │367 + │ │368 + pub fn getGrid(self: FlowBox) ?Grid { │ │369 + return self.grid; │ │370 + } │ │371 + │ │372 + pub fn getFocus(self: *FlowBox) *Focus { │ │373 + return &self.focus; │ │374 + } │ │375 + │ │376 + pub fn cellRect(self: FlowBox, index: usize) ?layout.IRect { │ │377 + if (self.last_cols == 0 or index >= self.text_boxes.items.len) return null; │ │378 + const slot_height = self.options.cell_height + border_rows; │ │379 + const col = index % self.last_cols; │ │380 + const row = index / self.last_cols; │ │381 + return .{ │ │382 + .x = @intCast(col * self.options.cell_width), │ │383 + .y = @intCast(row * slot_height), │ │384 + .size = .{ .width = self.options.cell_width, .height = slot_height }, │ │385 + }; │ │386 + } │ │387 + │ │388 + pub fn indexOfFocusId(self: FlowBox, focus_id: usize) ?usize { │ │389 + for (self.text_boxes.items, 0..) |tb, i| { │ │390 + if (tb.box.focus.id == focus_id) return i; │ │391 + } │ │392 + return null; │ │393 + } │ │394 + │ │395 + pub const Scroll = struct { │ │396 + scroll: wgt.Scroll(Widget), │ │397 + │ │398 + pub fn init(allocator: std.mem.Allocator, options: FlowBox.Options) !Scroll { │ │399 + var layout_inner = FlowBox.init(options); │ │400 + errdefer layout_inner.deinit(allocator); │ │401 + var scroll = try wgt.Scroll(Widget).init(allocator, .{ .flow_box = layout_inner }, .vert); │ │402 + errdefer scroll.deinit(allocator); │ │403 + return .{ .scroll = scroll }; │ │404 + } │ │405 + │ │406 + pub fn deinit(self: *Scroll, allocator: std.mem.Allocator) void { │ │407 + self.scroll.deinit(allocator); │ │408 + } │ │409 + │ │410 + pub fn setItems(self: *Scroll, allocator: std.mem.Allocator, items: []const []const u8) !void { │ │411 + self.scroll.x = 0; │ │412 + self.scroll.y = 0; │ │413 + try self.inner().setItems(allocator, items); │ │414 + } │ │415 + │ │416 + pub fn build(self: *Scroll, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { │ │417 + try self.scroll.build(allocator, constraint, root_focus); │ │418 + } │ │419 + │ │420 + pub fn input(self: *Scroll, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void { │ │421 + _ = allocator; │ │422 + const in = self.inner(); │ │423 + const child_id = in.focus.child_id orelse return; │ │424 + const current_index = in.indexOfFocusId(child_id) orelse return; │ │425 + const count = in.text_boxes.items.len; │ │426 + if (count == 0) return; │ │427 + const cols = in.last_cols; │ │428 + const slot_height = in.options.cell_height + FlowBox.border_rows; │ │429 + │ │430 + var index = current_index; │ │431 + switch (key) { │ │432 + .arrow_up => index -|= cols, │ │433 + .arrow_down => if (index + cols < count) { │ │434 + index += cols; │ │435 + }, │ │436 + .arrow_left => index -|= 1, │ │437 + .arrow_right => if (index + 1 < count) { │ │438 + index += 1; │ │439 + }, │ │440 + .home => index = 0, │ │441 + .end => index = count - 1, │ │442 + .page_up => { │ │443 + if (self.scroll.grid) |grid| if (slot_height > 0) { │ │444 + const rows_per_page = grid.size.height / slot_height; │ │445 + index -|= rows_per_page * cols; │ │446 + }; │ │447 + }, │ │448 + .page_down => { │ │449 + if (self.scroll.grid) |grid| if (slot_height > 0) { │ │450 + const rows_per_page = grid.size.height / slot_height; │ │451 + index = @min(index + rows_per_page * cols, count - 1); │ │452 + }; │ │453 + }, │ │454 + // scroll wheel moves the focused cell by a full row so the │ │455 + // viewport (via scrollToRect) follows in row-sized steps, │ │456 + // matching how a scroll wheel feels in a grid view. │ │457 + .mouse => |mouse| switch (mouse.action) { │ │458 + .scroll => |dir| switch (dir) { │ │459 + .up => index -|= cols, │ │460 + .down => if (index + cols < count) { │ │461 + index += cols; │ │462 }, │ │363 - else => {}, │ │463 }, │ │464 else => {}, │ │366 - } │ │465 + }, │ │466 + else => {}, │ │467 + } │ │468 │ │368 - if (index != current_index) { │ │369 - try root_focus.setFocus(children.keys()[index]); │ │370 - self.updateScroll(index); │ │469 + if (index != current_index) { │ │470 + try root_focus.setFocus(in.text_boxes.items[index].getFocus().id); │ │471 + if (in.cellRect(index)) |rect| { │ │472 + self.scroll.scrollToRect(rect); │ │473 } │ │474 } │ │475 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────╮ │374 - } │ │476 │ │376 - pub fn clearGrid(self: *SelectableList) void { │ │377 - self.scroll.clearGrid(); │ │378 - } │ │477 + pub fn clearGrid(self: *Scroll) void { │ │478 + self.scroll.clearGrid(); │ │479 + } │ │480 │ │380 - pub fn getGrid(self: SelectableList) ?Grid { │ │381 - return self.scroll.getGrid(); │ │382 - } │ │481 + pub fn getGrid(self: Scroll) ?Grid { │ │482 + return self.scroll.getGrid(); │ │483 + } │ │484 │ │384 - pub fn getFocus(self: *SelectableList) *Focus { │ │385 - return self.scroll.getFocus(); │ │386 - } │ │485 + pub fn getFocus(self: *Scroll) *Focus { │ │486 + return self.scroll.getFocus(); │ │487 + } │ │488 │ │388 - pub fn getSelectedIndex(self: SelectableList) ?usize { │ │389 - if (self.scroll.child.box.focus.child_id) |child_id| { │ │390 - const children = &self.scroll.child.box.children; │ │391 - return children.getIndex(child_id); │ │392 - } else { │ │393 - return null; │ │489 + pub fn getSelectedIndex(self: Scroll) ?usize { │ │490 + const in = self.scroll.child.flow_box; │ │491 + const child_id = in.focus.child_id orelse return null; │ │492 + return in.indexOfFocusId(child_id); │ │493 } │ │395 - } │ │494 │ │397 - fn updateScroll(self: *SelectableList, index: usize) void { │ │398 - const inner_box = &self.scroll.child.box; │ │399 - if (inner_box.children.values()[index].rect) |rect| { │ │400 - self.scroll.scrollToRect(rect); │ │495 + fn inner(self: *Scroll) *FlowBox { │ │496 + return &self.scroll.child.flow_box; │ │497 } │ │402 - } │ │498 + }; │ │499 }; │ │500 │ │501 // an invisible widget that fills the horizontal space granted by its parent.│ ╰──────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────────╮ │src/ui/Home/Repos.zig│ ╰─────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────╮ │41 } │ │42 │ │43 pub const View = struct { │ │44 - list: ui.SelectableList, │ │44 + list: ui.FlowBox.Scroll, │ │45 data: *const Self, │ │46 │ │47 pub fn init(allocator: std.mem.Allocator, data: *const Self) !View {│ ╰────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────╮ │48 var self = blk: { │ │49 - var list = try ui.SelectableList.init(allocator); │ │49 + var list = try ui.FlowBox.Scroll.init(allocator, .{});│ │50 errdefer list.deinit(allocator); │ │51 │ │52 break :blk View{ │ ╰──────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯