╭ changed ──────────────╮ │src/ui/Repo/Commits.zig│ ╰───────────────────────╯ ╭──────────────────────────────────────╮ │3 const ui = @import("../../ui.zig");│ │4 const xit = @import("xit"); │ │5 const rp = xit.repo; │ │6 -const Diff = @import("Diff.zig"); │ │6 const Undo = @import("Undo.zig"); │ │7 const obj = xit.object; │ │8 const mrg = xit.merge; │ ╰──────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │433 // the detail pane: a frame around a scroll of its rows │ │434 { │ │435 var detail_outer = blk: { │ │437 - var detail_scroll = try Diff.View.initEmpty(allocator, session); │ │436 + var detail_scroll = blk2: { │ │437 + var rows = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │438 + errdefer rows.deinit(allocator); │ │439 + break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = rows }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true });│ │440 + }; │ │441 errdefer detail_scroll.deinit(allocator); │ │442 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); │ │443 errdefer frame.deinit(allocator); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │446 // one), letting focus recovery descend into the pane after │ │447 // it's laid out beside a too-narrow list. │ │448 frame.getFocus().child_id = detail_scroll.getFocus().id; │ │446 - try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .diff_view = detail_scroll }, .rect = null, .min_size = null });│ │449 + try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); │ │450 break :blk frame; │ │451 }; │ │452 errdefer detail_outer.deinit(allocator); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │540 } │ │541 │ │542 fn detailScroll(self: *View) *wgt.Scroll(ui.Widget) { │ │540 - return &self.detailOuter().children.values()[0].widget.diff_view.scroll;│ │543 + return &self.detailOuter().children.values()[0].widget.scroll; │ │544 } │ │545 │ │546 fn detailInner(self: *View) *wgt.Box(ui.Widget) { │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │593 else │ │594 detail_min_width; │ │595 self.contentBox().children.values()[detail_index].min_size = .{ .width = detail_width, .height = null };│ │593 - │ │594 - // the message is the pane's only wrapping row, and wrapping needs a │ │595 - // bounded width, which the pane's scroll doesn't grant (it scrolls │ │596 - // horizontally too). cap it to the pane, leaving room for the border │ │597 - // and the scrollbar column. re-capped each build so it tracks resizes. │ │598 - for (self.detailInner().children.values()) |*child| switch (child.widget) { │ │599 - .text_box => |text_box| if (text_box.options.wrap_kind == .word) { │ │600 - child.max_size = .{ .width = detail_width -| 3, .height = null }; │ │601 - }, │ │602 - else => {}, │ │603 - }; │ │596 │ │597 // the web bounds the layout to the browser viewport like the terminal; │ │598 // each Scroll's web-native mode hands its full content to a real │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │711 } │ │712 if (direction == .up and self.contentAtTop() and self.focusHeader(root_focus)) return; │ │713 if (self.detailActive()) { │ │722 - const detail_view = &self.detailOuter().children.values()[0].widget.diff_view; │ │723 - if (key == .arrow_left and self.detailScroll().x == 0) { │ │724 - if (!detail_view.moveInRow(root_focus, false)) self.focusList(root_focus); │ │725 - } else { │ │726 - try detail_view.input(allocator, key, root_focus); │ │714 + const inner = self.detailInner(); │ │715 + if (inp.rowDelta(key, @intCast(inner.children.count()))) |delta| { │ │716 + ui.widget.moveRowFocus(inner, self.detailScroll(), root_focus, delta); │ │717 + } else if (key == .arrow_left) { │ │718 + if (!ui.widget.moveInSelectedRow(inner, root_focus, false)) self.focusList(root_focus);│ │719 + } else if (key == .arrow_right) { │ │720 + _ = ui.widget.moveInSelectedRow(inner, root_focus, true); │ │721 } │ │722 } else { │ │723 try self.listInput(key, root_focus); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────────╮ │src/ui/Repo/Diff.zig│ ╰────────────────────╯ ╭────────────────────────────────────────╮ │ 9 const Key = xit.xitui.input.Key; │ │10 const Grid = xit.xitui.grid.Grid; │ │11 const Focus = xit.xitui.focus.Focus;│ │12 +const inp = @import("../input.zig");│ │13 │ │14 pub const page_size = 10; │ │15 pub const Hunk = struct { │ ╰────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │252 │ │253 pub const View = struct { │ │254 scroll: wgt.Scroll(ui.Widget), │ │254 - session: *ui.Session, │ │255 - │ │256 - pub fn initEmpty(allocator: std.mem.Allocator, session: *ui.Session) !View { │ │257 - var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │258 - errdefer box.deinit(allocator); │ │259 - return .{ │ │260 - .scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .both, .web_native = !session.is_terminal, .fill = true }), │ │261 - .session = session, │ │262 - }; │ │263 - } │ │255 │ │256 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { │ │266 - var view = try initEmpty(allocator, session); │ │257 + var view: View = blk: { │ │258 + var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); │ │259 + errdefer box.deinit(allocator); │ │260 + break :blk .{ .scroll = try wgt.Scroll(ui.Widget).init(allocator, .{ .box = box }, .{ .direction = .both, .web_native = !session.is_terminal, .fill = true }) };│ │261 + }; │ │262 errdefer view.deinit(allocator); │ │263 const box = view.inner(); │ │264 try data.appendWindow(allocator, session, box); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │293 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │294 _ = allocator; │ │295 const sc = &self.scroll; │ │296 + const box = self.inner(); │ │297 + if (inp.rowDelta(key, @intCast(box.children.count()))) |delta| return ui.widget.moveRowFocus(box, sc, root_focus, delta);│ │298 switch (key) { │ │299 // scroll horizontally within the diff │ │303 - .arrow_left => if (!self.moveInRow(root_focus, false) and sc.x > 0) { │ │300 + .arrow_left => if (!ui.widget.moveInSelectedRow(box, root_focus, false) and sc.x > 0) { │ │301 sc.x -= 1; │ │305 - self.scroll.clampToContent(); │ │302 + sc.clampToContent(); │ │303 }, │ │307 - .arrow_right => if (!self.moveInRow(root_focus, true)) { │ │304 + .arrow_right => if (!ui.widget.moveInSelectedRow(box, root_focus, true)) { │ │305 sc.x += 1; │ │309 - self.scroll.clampToContent(); │ │306 + sc.clampToContent(); │ │307 }, │ │311 - .arrow_up => self.moveDiff(root_focus, -1), │ │312 - .arrow_down => self.moveDiff(root_focus, 1), │ │313 - .page_up => self.pageDiff(root_focus, -10), │ │314 - .page_down => self.pageDiff(root_focus, 10), │ │315 - .home => self.jumpDiff(root_focus, false), │ │316 - .end => self.jumpDiff(root_focus, true), │ │308 // Enter / a click on a "previous"/"next" row follow its "a:" link in │ │309 // the host (it reloads the adjacent window), so they don't reach here. │ │319 - .mouse => |mouse| switch (mouse.action) { │ │320 - .scroll => |dir| self.moveDiff(root_focus, if (dir == .up) -1 else 1), │ │321 - else => {}, │ │322 - }, │ │310 else => {}, │ │324 - } │ │325 - } │ │326 - │ │327 - // step across the selected row's boxes, false when the row has none that way. │ │328 - pub fn moveInRow(self: *View, root_focus: *Focus, right: bool) bool { │ │329 - const box = self.inner(); │ │330 - const selected = box.getFocus().child_id orelse return false; │ │331 - return switch ((box.children.getPtr(selected) orelse return false).widget) { │ │332 - .box => |*row| ui.widget.moveInRow(row, root_focus, right), │ │333 - else => false, │ │334 - }; │ │335 - } │ │336 - │ │337 - // move one step in `delta` (+ down, - up). in the terminal, `delta` is a │ │338 - // line count unless there is a new hunk visible (in which case it is a │ │339 - // hunk count). on the web `delta` is always a hunk count. │ │340 - fn moveDiff(self: *View, root_focus: *Focus, delta: isize) void { │ │341 - const box = self.inner(); │ │342 - const keys = box.children.keys(); │ │343 - if (keys.len == 0) return; │ │344 - const cur: usize = if (box.getFocus().child_id) |c| (box.children.getIndex(c) orelse 0) else 0; │ │345 - const target = @as(isize, @intCast(cur)) + delta; │ │346 - const in_range = target >= 0 and target < @as(isize, @intCast(keys.len)); │ │347 - │ │348 - if (self.session.is_terminal) { │ │349 - if (in_range and self.hunkVisible(@intCast(target))) { │ │350 - root_focus.setFocus(keys[@intCast(target)]); │ │351 - return; │ │352 - } │ │353 - │ │354 - const sc = &self.scroll; │ │355 - sc.y += delta * 5; // magnify because it's a line count │ │356 - self.scroll.clampToContent(); │ │357 - if (in_range and self.hunkVisible(@intCast(target))) { │ │358 - root_focus.setFocus(keys[@intCast(target)]); │ │359 - } │ │360 - } else { │ │361 - if (in_range) { │ │362 - root_focus.setFocus(keys[@intCast(target)]); │ │363 - } │ │364 - } │ │365 - } │ │366 - │ │367 - // page a fixed number of lines, then focus the leading visible hunk │ │368 - // (bottom-most when paging down, top-most when paging up). in the │ │369 - // terminal, `delta` is a line count, and on the web it's a hunk count. │ │370 - fn pageDiff(self: *View, root_focus: *Focus, delta: isize) void { │ │371 - if (self.session.is_terminal) { │ │372 - const sc = &self.scroll; │ │373 - sc.y += delta * 5; // magnify because it's a line count │ │374 - self.scroll.clampToContent(); │ │375 - self.focusVisible(root_focus, delta > 0); │ │376 - } else { │ │377 - const box = self.inner(); │ │378 - const keys = box.children.keys(); │ │379 - if (keys.len == 0) return; │ │380 - const cur: isize = if (box.getFocus().child_id) |c| @intCast(box.children.getIndex(c) orelse 0) else 0; │ │381 - const target: usize = @intCast(std.math.clamp(cur + delta, 0, @as(isize, @intCast(keys.len - 1)))); │ │382 - root_focus.setFocus(keys[target]); │ │311 } │ │312 } │ │385 - │ │386 - // jump to the first or last hunk. on the web the browser scrolls to the │ │387 - // focused hunk; on the terminal pin the scroll to the top/bottom too. │ │388 - fn jumpDiff(self: *View, root_focus: *Focus, to_end: bool) void { │ │389 - const box = self.inner(); │ │390 - const keys = box.children.keys(); │ │391 - if (keys.len == 0) return; │ │392 - if (self.session.is_terminal) { │ │393 - const sc = &self.scroll; │ │394 - sc.y = if (to_end) std.math.maxInt(isize) else 0; │ │395 - self.scroll.clampToContent(); │ │396 - } │ │397 - root_focus.setFocus(if (to_end) keys[keys.len - 1] else keys[0]); │ │398 - } │ │399 - │ │400 - // whether hunk `index` is at least partly within the diff viewport, per the │ │401 - // last build's layout rects (content space) and the current scroll offset. │ │402 - fn hunkVisible(self: *View, index: usize) bool { │ │403 - const box = self.inner(); │ │404 - const sc = &self.scroll; │ │405 - const vp = sc.grid orelse return false; │ │406 - const r = box.children.values()[index].rect orelse return false; │ │407 - const top = sc.y; │ │408 - const bottom = sc.y + @as(isize, @intCast(vp.size.height - sc.bar_h)); │ │409 - return (r.y + @as(isize, @intCast(r.size.height))) > top and r.y < bottom; │ │410 - } │ │411 - │ │412 - // focus the visible hunk at the scroll's leading edge. `prefer_last` picks │ │413 - // the bottom-most visible (for downward motion), else the top-most. │ │414 - fn focusVisible(self: *View, root_focus: *Focus, prefer_last: bool) void { │ │415 - const box = self.inner(); │ │416 - const sc = &self.scroll; │ │417 - const vp = sc.grid orelse return; │ │418 - const top = sc.y; │ │419 - const bottom = sc.y + @as(isize, @intCast(vp.size.height - sc.bar_h)); │ │420 - var chosen: ?usize = null; │ │421 - for (box.children.keys(), box.children.values()) |id, *child| { │ │422 - const r = child.rect orelse continue; // content-space layout rect │ │423 - const r_top = r.y; │ │424 - const r_bot = r.y + @as(isize, @intCast(r.size.height)); │ │425 - if (r_bot <= top or r_top >= bottom) continue; // not visible │ │426 - chosen = id; │ │427 - if (!prefer_last) break; // first visible │ │428 - } │ │429 - if (chosen) |id| root_focus.setFocus(id); │ │430 - } │ │313 }; │ │314 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/ui/widget.zig│ ╰─────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │169 } │ │170 │ │171 // move the focused row in `box` by `delta`, clamped to the ends, scrolling │ │172 -// `scroll` to keep it visible. │ │172 +// `scroll` to keep it visible. in the terminal, a row taller than the view is │ │173 +// scrolled through before focus leaves it. │ │174 pub fn moveRowFocus(box: *wgt.Box(Widget), scroll: *wgt.Scroll(Widget), root_focus: *Focus, delta: isize) void {│ │175 const keys = box.children.keys(); │ │176 if (keys.len == 0) return; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯