╭ changed ───────────────╮ │src/ui/widget/thread.zig│ ╰────────────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────╮ │← previous│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1903 │ │1904 fn listBox(self: *This, index: usize) *wgt.Box(Widget) { │ │1905 return &self.listScroll(index).child.box; │ │1123 - } │ │1124 - │ │1125 - // the tool row is the first child inside the detail scroll. │ │1126 - const tool_row_index: usize = 0; │ │1127 - const detail_scroll_index: usize = 0; │ │1128 - │ │1129 - fn detailOuter(self: *This, index: usize) *wgt.Box(Widget) { │ │1130 - return &self.resultsBox(index).children.values()[detail_index].widget.box; │ │1131 - } │ │1132 - │ │1133 - fn toolRow(self: *This, index: usize) *wgt.Box(Widget) { │ │1134 - return &self.detailInner(index).children.values()[tool_row_index].widget.box; │ │1906 } │ │1907 │ │1137 - fn detailScroll(self: *This, index: usize) *wgt.Scroll(Widget) { │ │1138 - return &self.detailOuter(index).children.values()[detail_scroll_index].widget.scroll;│ │1908 + fn detail(self: *This, index: usize) *DetailType { │ │1909 + const frame = &self.resultsBox(index).children.values()[detail_index].widget.box; │ │1910 + return &@field(frame.children.values()[0].widget, Data.detail_widget_name); │ │1911 } │ │1912 │ │1141 - fn detailInner(self: *This, index: usize) *wgt.Box(Widget) { │ │1142 - return &self.detailScroll(index).child.box; │ │1143 - } │ │1144 - │ │1913 fn window(self: *This, index: usize) *const Window { │ │1914 if (supports_conflicts and index == conflict_view_index) return &self.data.conflicts;│ │1915 if (supports_drafts and index == drafts_view_index) return &self.data.drafts; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1974 if (self.selectedSplitIndex()) |i| { │ │1975 // swap the detail pane to the selected thread when it changes. │ │1976 if (self.selectedThreadIndex(i)) |selected| { │ │1209 - const changed = if (self.detailed_index[i]) |current| current != selected else true; │ │1977 + const entry = self.window(i).items[selected]; │ │1978 + const changed = if (self.detail(i).entry) |current| !std.mem.eql(u8, current.id, entry.id) else true;│ │1979 if (changed) { │ │1211 - try self.populateDetail(allocator, i, selected); │ │1212 - self.detailed_index[i] = selected; │ │1980 + try self.detail(i).setEntry(allocator, entry); │ │1981 } │ │1982 } │ │1983 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │1991 } │ │1992 } │ │1993 │ │1226 - // the pane's selected child shows the selection border: the │ │1227 - // description directly, the tags via the flow's selected item. │ │1994 // cap the list at list_max_width only while the detail pane fits │ │1995 // beside it. the box drops the detail when the width can't hold │ │1996 // both minimums, so when it's that narrow we lift the cap and let│ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1997 // the list fill the whole width. │ │1232 - if (self.detail_only) { │ │1233 - self.box.children.values()[header_index].min_size = .{ .width = null, .height = 0 }; │ │1234 - self.box.children.values()[header_index].max_size = .{ .width = null, .height = 0 }; │ │1235 - self.resultsBox(i).children.values()[list_index].min_size = .{ .width = 0, .height = null }; │ │1236 - self.resultsBox(i).children.values()[list_index].max_size = .{ .width = 0, .height = null }; │ │1237 - self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = constraint.max_size.width, .height = null }; │ │1998 + const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; │ │1999 + self.resultsBox(i).children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; │ │2000 + │ │2001 + // stretch the detail pane across the rest of the width so it fills │ │2002 + // the area rather than shrinking to its content; its scroll fills │ │2003 + // the pane. │ │2004 + if (constraint.max_size.width) |w| { │ │2005 + self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null }; │ │2006 } else { │ │1239 - const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; │ │1240 - self.resultsBox(i).children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; │ │1241 - │ │1242 - // stretch the detail pane across the rest of the width so it fills │ │1243 - // the area rather than shrinking to its content; its scroll fills │ │1244 - // the pane. │ │1245 - if (constraint.max_size.width) |w| { │ │1246 - self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null };│ │1247 - } else { │ │1248 - self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; │ │1249 - } │ │2007 + self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; │ │2008 } │ │2009 } │ │2010 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2020 } │ │2021 │ │2022 try self.box.build(allocator, constraint, root_focus); │ │1265 - } │ │1266 - │ │1267 - fn populateDetail(self: *This, allocator: std.mem.Allocator, index: usize, sel: usize) !void { │ │1268 - const entry = self.window(index).items[sel]; │ │1269 - const inner = self.detailInner(index); │ │1270 - // the /description page replaces its thread's detail with a back link │ │1271 - // and the whole description; other threads keep their normal detail. │ │1272 - const description_page = self.data.description_page and std.mem.eql(u8, entry.id, self.data.selected_id); │ │1273 - const comment_page = if (std.mem.eql(u8, entry.id, self.data.selected_id)) self.data.comment_page else null; │ │1274 - │ │1275 - for (inner.children.values()) |*child| child.widget.deinit(allocator); │ │1276 - inner.children.clearAndFree(allocator); │ │1277 - inner.getFocus().child_id = null; │ │1278 - self.title_id[index] = null; │ │1279 - self.author_id[index] = null; │ │1280 - │ │1281 - // the tool row is part of the scroll; the description and comment │ │1282 - // pages leave it empty. │ │1283 - { │ │1284 - var row = try wgt.Box(Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); │ │1285 - errdefer row.deinit(allocator); │ │1286 - try inner.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); │ │1287 - } │ │1288 - if (!description_page and comment_page == null) { │ │1289 - const row = self.toolRow(index); │ │1290 - const pa = self.session.page_arena.allocator(); │ │1291 - │ │1292 - if (entryHasFork(entry) and self.session.data.current_page.parent() != .fork) { │ │1293 - const route = forkRoute(self.data.identity, entry.id) orelse return error.RouteTooLong; │ │1294 - try addToolButton(allocator, row, "view fork", try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); │ │1295 - } │ │1296 - │ │1297 - { │ │1298 - var spacer = try Spacer.init(allocator); │ │1299 - errdefer spacer.deinit(allocator); │ │1300 - try row.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); │ │1301 - } │ │1302 - │ │1303 - if (supports_drafts and entryDraft(entry)) { │ │1304 - if (entry.revision_ready) { │ │1305 - const route = listRoute(self.data.identity, @enumFromInt(0), "", entry.id) orelse return error.RouteTooLong; │ │1306 - row.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "form:{s}/publish", .{try route.toUrl(self.session.page_arena)}) }; │ │1307 - try addToolButton(allocator, row, "publish", "submit"); │ │1308 - } │ │1309 - if (self.session.data.user_id != null) { │ │1310 - const edit_route = ui.RoutablePage.repoThreadEditRoute(kind, self.data.identity, entry.id) orelse return error.RouteTooLong; │ │1311 - try addToolButton(allocator, row, "edit", try std.fmt.allocPrint(pa, "a:{s}", .{try edit_route.toUrl(self.session.page_arena)})); │ │1312 - const remove_route = ui.RoutablePage.repoThreadRemoveRoute(kind, self.data.identity, entry.id, "") orelse return error.RouteTooLong; │ │1313 - try addToolButton(allocator, row, "✕", try std.fmt.allocPrint(pa, "a:{s}", .{try remove_route.toUrl(self.session.page_arena)})); │ │1314 - } │ │1315 - } else { │ │1316 - // a terminal has no file picker, so attaching is web only. the │ │1317 - // renderer covers this button with a file input, so clicking it │ │1318 - // opens the browser's own picker and posts what it chose. │ │1319 - if (!self.session.is_terminal and !entryConflicted(entry) and (self.session.data.is_local or self.session.data.user_id != null)) { │ │1320 - const label = "add attachment"; │ │1321 - const action = if (self.data.identity.len == 0) │ │1322 - try std.fmt.allocPrint(pa, "{s}/{s}:{s}/attach", .{ ui.file_input_prefix, @tagName(kind), entry.id }) │ │1323 - else │ │1324 - try std.fmt.allocPrint(pa, "{s}/repo/{s}/{s}:{s}/attach", .{ ui.file_input_prefix, self.data.identity, @tagName(kind), entry.id }); │ │1325 - try addToolButton(allocator, row, label, action); │ │1326 - } │ │1327 - │ │1328 - // a conflicted thread's only action is resolving it. the button │ │1329 - // stays visible logged out; the resolve page shows the │ │1330 - // unauthorized view then. │ │1331 - if (supports_conflicts and entryConflicted(entry)) { │ │1332 - const label = "resolve conflict"; │ │1333 - const route = resolveRoute(self.data.identity, entry.id, "") orelse return error.RouteTooLong; │ │1334 - try addToolButton(allocator, row, label, try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); │ │1335 - } else { │ │1336 - // a logged-out session can't flip the status, so it gets no │ │1337 - // open/close button │ │1338 - if (has_status and (self.session.data.is_local or self.session.data.user_id != null)) { │ │1339 - if (statusChange(entryStatus(entry))) |change| { │ │1340 - const route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong; │ │1341 - row.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "form:{s}/{s}", .{ try route.toUrl(self.session.page_arena), change.action }) }; │ │1342 - try addToolButton(allocator, row, change.action, "submit"); │ │1343 - } │ │1344 - } │ │1345 - │ │1346 - // the edit button links to the thread's edit page. │ │1347 - { │ │1348 - const route = ui.RoutablePage.repoThreadEditRoute(kind, self.data.identity, entry.id) orelse return error.RouteTooLong; │ │1349 - try addToolButton(allocator, row, "edit", try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); │ │1350 - } │ │1351 - } │ │1352 - │ │1353 - if (self.session.data.is_local or self.session.data.user_id != null) { │ │1354 - const route = ui.RoutablePage.repoThreadRemoveRoute(kind, self.data.identity, entry.id, "") orelse return error.RouteTooLong; │ │1355 - try addToolButton(allocator, row, "✕", try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)})); │ │1356 - } │ │1357 - } │ │1358 - │ │1359 - for (row.children.keys(), row.children.values()) |id, *child| { │ │1360 - if (!child.widget.getFocus().focusable) continue; │ │1361 - row.getFocus().child_id = id; │ │1362 - break; │ │1363 - } │ │1364 - } │ │1365 - │ │1366 - if (supports_drafts and !description_page and comment_page == null) { │ │1367 - try self.data.appendDetails(allocator, inner, self.session, entry); │ │1368 - } │ │1369 - │ │1370 - if (comment_page) |page| { │ │1371 - var back_label_buf: ["← back to ".len + thread_name.len]u8 = undefined; │ │1372 - const back_label = try std.fmt.bufPrint(&back_label_buf, "← back to {s}", .{thread_name}); │ │1373 - var back = try Comment.linkBox(allocator, self.session, back_label, ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong); │ │1374 - errdefer back.deinit(allocator); │ │1375 - try inner.children.put(allocator, back.getFocus().id, .{ .widget = .{ .text_box = back }, .rect = null, .min_size = null }); │ │1376 - │ │1377 - try Comment.appendComment(allocator, inner, self.session, self.data.identity, kind, page.selected); │ │1378 - try Comment.appendCount(allocator, inner, page.replies.count, "reply", "replies"); │ │1379 - for (page.replies.comments) |comment| try Comment.appendComment(allocator, inner, self.session, self.data.identity, kind, comment); │ │1380 - try Comment.appendWindowNav(allocator, inner, self.session, self.data.identity, kind, &page.selected.comment.event.thread_id, &page.selected.id, page.replies); │ │1381 - │ │1382 - var spacer = try Spacer.init(allocator); │ │1383 - errdefer spacer.deinit(allocator); │ │1384 - try inner.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); │ │1385 - │ │1386 - inner.getFocus().child_id = inner.children.keys()[tool_row_index + 1]; │ │1387 - const sc = self.detailScroll(index); │ │1388 - sc.x = 0; │ │1389 - sc.y = 0; │ │1390 - sc.getFocus().version +%= 1; │ │1391 - return; │ │1392 - } │ │1393 - │ │1394 - if (description_page) { │ │1395 - // the back link, in the title slot so the pane's input handling │ │1396 - // applies to it unchanged. │ │1397 - var back_label_buf: ["← back to ".len + thread_name.len]u8 = undefined; │ │1398 - const back_label = try std.fmt.bufPrint(&back_label_buf, "← back to {s}", .{thread_name}); │ │1399 - var tb = try wgt.TextBox.init(allocator, back_label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); │ │1400 - errdefer tb.deinit(allocator); │ │1401 - tb.getFocus().focusable = true; │ │1402 - tb.getFocus().kind = .{ .custom = try listLink(self.session.page_arena, self.data.identity, entryStatus(entry), "", entry.id) }; │ │1403 - try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │1404 - self.title_id[index] = tb.getFocus().id; │ │1405 - } else { │ │1406 - // the thread's title as a focusable word-wrapped text box. │ │1407 - self.title_id[index] = blk: { │ │1408 - var tb = try wgt.TextBox.init(allocator, entry.record.event.title, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = " title " }); │ │1409 - errdefer tb.deinit(allocator); │ │1410 - tb.getFocus().focusable = true; │ │1411 - try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │1412 - break :blk tb.getFocus().id; │ │1413 - }; │ │1414 - │ │1415 - self.author_id[index] = blk: { │ │1416 - var tb = try ui.authorBox(allocator, self.session.page_arena, entry.author); │ │1417 - errdefer tb.deinit(allocator); │ │1418 - try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │1419 - break :blk tb.getFocus().id; │ │1420 - }; │ │1421 - │ │1422 - // the thread's tags, each linking to this status's list filtered to │ │1423 - // that tag. │ │1424 - { │ │1425 - var items: std.ArrayList(TagFlow.Item) = .empty; │ │1426 - defer items.deinit(allocator); │ │1427 - var tag_iter = Event.tagIterator(entry.record.event.tags); │ │1428 - while (tag_iter.next()) |tag| { │ │1429 - if (tag.len == 0) continue; │ │1430 - try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, entryStatus(entry), tag) }); │ │1431 - } │ │1432 - if (items.items.len > 0) { │ │1433 - var tf = try TagFlow.init(allocator); │ │1434 - errdefer tf.deinit(allocator); │ │1435 - try tf.setItems(allocator, items.items); │ │1436 - try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); │ │1437 - } │ │1438 - } │ │1439 - } │ │1440 - │ │1441 - // the description as a focusable word-wrapped text box. past the limit │ │1442 - // it's cut at the last whole line and links to the /description page, │ │1443 - // which shows the whole thing. │ │1444 - self.description_id[index] = blk: { │ │1445 - const whole = entry.record.event.description; │ │1446 - const cut_short = !description_page and whole.len > Data.max_description_size; │ │1447 - const shown = if (cut_short) │ │1448 - std.mem.trimEnd(u8, whole[0 .. std.mem.lastIndexOfScalar(u8, whole[0..Data.max_description_size], '\n') orelse 0], " \t\r\n") │ │1449 - else if (whole.len == 0) │ │1450 - "(no description)" │ │1451 - else │ │1452 - whole; │ │1453 - var tb = try wgt.TextBox.init(allocator, shown, .{ │ │1454 - .border_style = .single, │ │1455 - .rounded_corners = true, │ │1456 - .wrap_kind = .word, │ │1457 - .label = " description ", │ │1458 - .bottom_label = if (cut_short) " click or press enter to see more " else "", │ │1459 - }); │ │1460 - errdefer tb.deinit(allocator); │ │1461 - tb.getFocus().focusable = true; │ │1462 - if (cut_short) { │ │1463 - const route = ui.RoutablePage.repoThreadDescriptionRoute(kind, self.data.identity, entry.id) orelse return error.RouteTooLong; │ │1464 - tb.getFocus().kind = .{ .custom = try std.fmt.allocPrint(self.session.page_arena.allocator(), "a:{s}", .{try route.toUrl(self.session.page_arena)}) }; │ │1465 - } │ │1466 - try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); │ │1467 - break :blk tb.getFocus().id; │ │1468 - }; │ │1469 - │ │1470 - if (!supports_drafts or !entryDraft(entry)) { │ │1471 - const parent_route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, entry.id, 0) orelse return error.RouteTooLong; │ │1472 - try Attachment.appendRows(allocator, inner, self.session, self.data.identity, try parent_route.toUrl(self.session.page_arena), entry.attachments); │ │1473 - │ │1474 - if (!description_page) { │ │1475 - var reply = try Comment.linkBox(allocator, self.session, "new comment", ui.RoutablePage.repoThreadCommentNewRoute(kind, self.data.identity, entry.id, "") orelse return error.RouteTooLong);│ │1476 - errdefer reply.deinit(allocator); │ │1477 - try inner.children.put(allocator, reply.getFocus().id, .{ .widget = .{ .text_box = reply }, .rect = null, .min_size = null }); │ │1478 - │ │1479 - try Comment.appendCount(allocator, inner, entry.comments.count, "comment", "comments"); │ │1480 - for (entry.comments.comments) |comment| try Comment.appendComment(allocator, inner, self.session, self.data.identity, kind, comment); │ │1481 - try Comment.appendWindowNav(allocator, inner, self.session, self.data.identity, kind, entry.id, null, entry.comments); │ │1482 - │ │1483 - var spacer = try Spacer.init(allocator); │ │1484 - errdefer spacer.deinit(allocator); │ │1485 - try inner.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); │ │1486 - } │ │1487 - } │ │1488 - │ │1489 - // select the first focusable widget below the tool row │ │1490 - for (tool_row_index + 1..inner.children.count()) |child_index| { │ │1491 - if (!self.detailChildFocusable(index, child_index)) continue; │ │1492 - inner.getFocus().child_id = inner.children.keys()[child_index]; │ │1493 - break; │ │1494 - } │ │1495 - │ │1496 - // reset the scroll to the top for the newly-shown thread: directly on the │ │1497 - // terminal (the wasm offset), and via a version bump on the web (so the │ │1498 - // renderer's scroll id changes and JS drops the preserved position). │ │1499 - const sc = self.detailScroll(index); │ │1500 - sc.x = 0; │ │1501 - sc.y = 0; │ │1502 - sc.getFocus().version +%= 1; │ │2023 } │ │2024 │ │2025 pub fn input(self: *This, allocator: std.mem.Allocator, raw_key: Key, root_focus: *Focus) !void { │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ │2063 } │ │2064 const i = self.selectedSplitIndex() orelse return; │ │2065 if (self.detailActive(i)) { │ │1546 - try self.detailInput(allocator, i, key, root_focus); │ │2066 + switch (try self.detail(i).inputWithExit(allocator, key, root_focus)) {│ │2067 + .none => {}, │ │2068 + .list => self.focusList(i, root_focus), │ │2069 + .header => self.focusHeader(root_focus), │ │2070 + } │ │2071 } else { │ │2072 self.listInput(i, key, root_focus); │ │2073 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2101 if (delta < 0 and at_top) return self.focusHeader(root_focus); │ │2102 moveRowFocus(lb, self.listScroll(index), root_focus, delta); │ │2103 return; │ │1580 - } │ │1581 - switch (key) { │ │1582 - .enter, .arrow_right => _ = self.focusDetail(root_focus), │ │1583 - else => {}, │ │1584 - } │ │1585 - } │ │1586 - │ │1587 - fn detailInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { │ │1588 - switch (key) { │ │1589 - .page_up => { │ │1590 - self.pageDetail(index, root_focus, -10); │ │1591 - return; │ │1592 - }, │ │1593 - .page_down => { │ │1594 - self.pageDetail(index, root_focus, 10); │ │1595 - return; │ │1596 - }, │ │1597 - .home => { │ │1598 - self.jumpDetail(index, root_focus, false); │ │1599 - return; │ │1600 - }, │ │1601 - .end => { │ │1602 - self.jumpDetail(index, root_focus, true); │ │1603 - return; │ │1604 - }, │ │1605 - else => {}, │ │1606 - } │ │1607 - if (self.data.comment_page != null and std.mem.eql(u8, self.window(index).items[self.detailed_index[index] orelse return].id, self.data.selected_id)) {│ │1608 - self.commentInput(index, key, root_focus); │ │1609 - return; │ │1610 - } │ │1611 - if (self.toolRowFocused(index, root_focus)) { │ │1612 - try self.toolRowInput(allocator, index, key, root_focus); │ │1613 - return; │ │1614 - } │ │1615 - │ │1616 - const child_index = self.focusedDetailChild(index, root_focus) orelse return; │ │1617 - const inner = self.detailInner(index); │ │1618 - const child_id = inner.children.keys()[child_index]; │ │1619 - const child = &inner.children.values()[child_index].widget; │ │1620 - │ │1621 - if (child_id == self.title_id[index]) { │ │1622 - switch (key) { │ │1623 - .arrow_left => self.focusList(index, root_focus), │ │1624 - .arrow_up => if (!self.moveDetailVertical(index, root_focus, false)) self.focusToolRow(index, root_focus), │ │1625 - .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), │ │1626 - else => {}, │ │1627 - } │ │1628 - return; │ │1629 - } │ │1630 - if (child_id == self.author_id[index]) { │ │1631 - // the author's a: link is followed by the host; arrows cross to │ │1632 - // neighboring widgets. │ │1633 - switch (key) { │ │1634 - .arrow_left => self.focusList(index, root_focus), │ │1635 - .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), │ │1636 - .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), │ │1637 - else => {}, │ │1638 - } │ │1639 - return; │ │1640 - } │ │1641 - if (child_id == self.description_id[index]) { │ │1642 - self.descriptionInput(index, key, root_focus); │ │1643 - return; │ │1644 - } │ │1645 - │ │1646 - switch (child.*) { │ │1647 - .tag_flow => |*tf| { │ │1648 - self.tagsInput(index, child_index, tf, key, root_focus); │ │1649 - return; │ │1650 - }, │ │1651 - .copyable_text => |*copyable| { │ │1652 - switch (key) { │ │1653 - .arrow_up => if (!self.moveDetailVertical(index, root_focus, false)) self.focusToolRow(index, root_focus), │ │1654 - .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), │ │1655 - .arrow_left => if (copyable.selected == 0) │ │1656 - self.focusList(index, root_focus) │ │1657 - else │ │1658 - try copyable.input(allocator, key, root_focus), │ │1659 - else => try copyable.input(allocator, key, root_focus), │ │1660 - } │ │1661 - return; │ │1662 - }, │ │1663 - .box => |*row| if (Attachment.removeId(row)) |attachment_id| { │ │1664 - const remove_id = row.children.keys()[1]; │ │1665 - switch (key) { │ │1666 - .arrow_left => if (!self.moveCommentHorizontal(index, root_focus, false)) self.focusList(index, root_focus), │ │1667 - .arrow_right => _ = self.moveCommentHorizontal(index, root_focus, true), │ │1668 - .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), │ │1669 - .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), │ │1670 - .enter => if (row.getFocus().child_id == remove_id) try self.removeEvent(allocator, .attach, attachment_id), │ │1671 - .mouse => |mouse| if (inp.leftClickOn(root_focus, remove_id, mouse)) try self.removeEvent(allocator, .attach, attachment_id), │ │1672 - else => {}, │ │1673 - } │ │1674 - return; │ │1675 - }, │ │1676 - else => {}, │ │2104 } │ │1678 - self.commentInput(index, key, root_focus); │ │1679 - } │ │1680 - │ │1681 - // enter or a click runs the primary action; links are handled by the host │ │1682 - fn toolRowInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { │ │1683 - const row = self.toolRow(index); │ │1684 - const cur = if (row.getFocus().child_id) |cid| row.children.getIndex(cid) orelse return else return; │ │1685 - const child_id = row.children.keys()[cur]; │ │1686 - const on_primary = switch (row.children.values()[cur].widget.getFocus().kind) { │ │1687 - .custom => |action| std.mem.eql(u8, action, "submit"), │ │1688 - else => false, │ │1689 - }; │ │2105 switch (key) { │ │1691 - .arrow_left => { │ │1692 - var previous = cur; │ │1693 - while (previous > 0) { │ │1694 - previous -= 1; │ │1695 - if (!row.children.values()[previous].widget.getFocus().focusable) continue; │ │1696 - root_focus.setFocus(row.children.keys()[previous]); │ │1697 - return; │ │1698 - } │ │1699 - self.focusList(index, root_focus); │ │1700 - }, │ │1701 - .arrow_right => { │ │1702 - var next = cur + 1; │ │1703 - while (next < row.children.count()) : (next += 1) { │ │1704 - if (!row.children.values()[next].widget.getFocus().focusable) continue; │ │1705 - root_focus.setFocus(row.children.keys()[next]); │ │1706 - return; │ │1707 - } │ │1708 - }, │ │1709 - .arrow_up => self.focusHeader(root_focus), │ │1710 - .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), │ │1711 - .enter => if (on_primary) try self.primaryAction(allocator, index), │ │1712 - .mouse => |mouse| if (on_primary and inp.leftClickOn(root_focus, child_id, mouse)) try self.primaryAction(allocator, index), │ │2106 + .enter, .arrow_right => _ = self.detail(index).focusFirst(root_focus), │ │2107 else => {}, │ │1714 - } │ │1715 - } │ │1716 - │ │1717 - fn primaryAction(self: *This, allocator: std.mem.Allocator, index: usize) !void { │ │1718 - const selected = self.detailed_index[index] orelse return; │ │1719 - const entry = self.window(index).items[selected]; │ │1720 - if (supports_drafts and entryDraft(entry)) │ │1721 - try self.publishDraft(allocator, index) │ │1722 - else │ │1723 - try self.toggleStatus(allocator, index); │ │1724 - } │ │1725 - │ │1726 - fn publishDraft(self: *This, allocator: std.mem.Allocator, index: usize) !void { │ │1727 - if (!supports_drafts or comptime wasm) return; │ │1728 - const selected = self.detailed_index[index] orelse return; │ │1729 - const entry = self.window(index).items[selected]; │ │1730 - if (!entryDraft(entry) or !entry.revision_ready) return; │ │1731 - try self.data.publishDraft(self.session, allocator, entry.id); │ │1732 - const route = listRoute(self.data.identity, @enumFromInt(0), "", entry.id) orelse return; │ │1733 - try self.session.navigate(route); │ │1734 - } │ │1735 - │ │1736 - fn descriptionInput(self: *This, index: usize, key: Key, root_focus: *Focus) void { │ │1737 - const sc = self.detailScroll(index); │ │1738 - switch (key) { │ │1739 - .arrow_left => return self.focusList(index, root_focus), │ │1740 - // the terminal moves through the description one line at a time; │ │1741 - // the web lets focus changes drive its native scroll. │ │1742 - .arrow_up => { │ │1743 - if (!self.session.is_terminal) { │ │1744 - _ = self.moveDetailVertical(index, root_focus, false); │ │1745 - return; │ │1746 - } │ │1747 - if (self.focusAdjacentVisible(index, root_focus, false)) return; │ │1748 - const before = sc.y; │ │1749 - sc.y -= 1; │ │1750 - sc.clampToContent(); │ │1751 - if (sc.y == before) _ = self.moveDetailVertical(index, root_focus, false); │ │1752 - return; │ │1753 - }, │ │1754 - .arrow_down => { │ │1755 - if (!self.session.is_terminal) { │ │1756 - _ = self.moveDetailVertical(index, root_focus, true); │ │1757 - return; │ │1758 - } │ │1759 - if (self.focusAdjacentVisible(index, root_focus, true)) return; │ │1760 - const before = sc.y; │ │1761 - sc.y += 1; │ │1762 - sc.clampToContent(); │ │1763 - if (sc.y == before) _ = self.moveDetailVertical(index, root_focus, true); │ │1764 - return; │ │1765 - }, │ │1766 - else => return, │ │1767 - } │ │1768 - } │ │1769 - │ │1770 - fn commentInput(self: *This, index: usize, key: Key, root_focus: *Focus) void { │ │1771 - switch (key) { │ │1772 - .arrow_left => if (!self.moveCommentHorizontal(index, root_focus, false)) return self.focusList(index, root_focus), │ │1773 - .arrow_right => _ = self.moveCommentHorizontal(index, root_focus, true), │ │1774 - .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), │ │1775 - .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), │ │1776 - else => return, │ │1777 - } │ │1778 - } │ │1779 - │ │1780 - // page the detail scroll, then focus the first or last visible widget. │ │1781 - fn pageDetail(self: *This, index: usize, root_focus: *Focus, delta: isize) void { │ │1782 - if (!self.session.is_terminal) return; │ │1783 - const sc = self.detailScroll(index); │ │1784 - sc.y += delta; │ │1785 - sc.clampToContent(); │ │1786 - self.focusVisible(index, root_focus, delta > 0); │ │1787 - } │ │1788 - │ │1789 - // jump to the detail's first or last focusable widget. the terminal pins │ │1790 - // its synthetic scroll too; the browser scrolls to the new focus itself. │ │1791 - fn jumpDetail(self: *This, index: usize, root_focus: *Focus, to_end: bool) void { │ │1792 - if (self.session.is_terminal) { │ │1793 - const sc = self.detailScroll(index); │ │1794 - sc.y = if (to_end) std.math.maxInt(isize) else 0; │ │1795 - sc.clampToContent(); │ │1796 - self.focusVisible(index, root_focus, to_end); │ │1797 - } else { │ │1798 - self.focusDetailEdge(index, root_focus, to_end); │ │1799 - } │ │1800 - } │ │1801 - │ │1802 - fn focusDetailEdge(self: *This, index: usize, root_focus: *Focus, last: bool) void { │ │1803 - const focus = self.detailInner(index).getFocus(); │ │1804 - var chosen: ?usize = null; │ │1805 - for (focus.children.keys(), focus.children.values()) |id, child| { │ │1806 - if (!child.focus.focusable) continue; │ │1807 - chosen = id; │ │1808 - if (!last) break; │ │1809 - } │ │1810 - if (chosen) |id| root_focus.setFocus(id); │ │1811 - } │ │1812 - │ │1813 - fn detailRectVisible(self: *This, index: usize, rect: layout.IRect) bool { │ │1814 - const sc = self.detailScroll(index); │ │1815 - const viewport = sc.grid orelse return false; │ │1816 - const top = sc.y; │ │1817 - const bottom = top + @as(isize, @intCast(viewport.size.height - sc.bar_h)); │ │1818 - return rect.y + @as(isize, @intCast(rect.size.height)) > top and rect.y < bottom; │ │1819 - } │ │1820 - │ │1821 - // focus the top- or bottom-most focusable widget intersecting the viewport. │ │1822 - fn focusVisible(self: *This, index: usize, root_focus: *Focus, last: bool) void { │ │1823 - const inner = self.detailInner(index); │ │1824 - var offset: usize = 0; │ │1825 - while (offset < inner.children.count()) : (offset += 1) { │ │1826 - const child_index = if (last) inner.children.count() - 1 - offset else offset; │ │1827 - if (self.focusVisibleChild(index, child_index, root_focus, last)) return; │ │1828 - } │ │1829 - } │ │1830 - │ │1831 - fn focusVisibleChild(self: *This, index: usize, child_index: usize, root_focus: *Focus, last: bool) bool { │ │1832 - const child = &self.detailInner(index).children.values()[child_index]; │ │1833 - const child_rect = child.rect orelse return false; │ │1834 - var chosen: ?usize = null; │ │1835 - if (child.widget.getFocus().focusable and self.detailRectVisible(index, child_rect)) { │ │1836 - const id = self.detailInner(index).children.keys()[child_index]; │ │1837 - chosen = id; │ │1838 - if (!last) { │ │1839 - root_focus.setFocus(id); │ │1840 - return true; │ │1841 - } │ │1842 - } │ │1843 - │ │1844 - for (child.widget.getFocus().children.keys(), child.widget.getFocus().children.values()) |id, focus_child| { │ │1845 - if (!focus_child.focus.focusable) continue; │ │1846 - const rect: layout.IRect = .{ │ │1847 - .x = child_rect.x + @as(isize, @intCast(focus_child.rect.x)), │ │1848 - .y = child_rect.y + @as(isize, @intCast(focus_child.rect.y)), │ │1849 - .size = focus_child.rect.size, │ │1850 - }; │ │1851 - if (!self.detailRectVisible(index, rect)) continue; │ │1852 - chosen = id; │ │1853 - if (!last) break; │ │1854 - } │ │1855 - if (chosen) |id| { │ │1856 - root_focus.setFocus(id); │ │1857 - return true; │ │1858 - } │ │1859 - return false; │ │1860 - } │ │1861 - │ │1862 - fn focusAdjacentVisible(self: *This, index: usize, root_focus: *Focus, down: bool) bool { │ │1863 - const inner = self.detailInner(index); │ │1864 - var next = self.focusedDetailChild(index, root_focus) orelse return false; │ │1865 - while (true) { │ │1866 - if (down) { │ │1867 - next += 1; │ │1868 - if (next >= inner.children.count()) return false; │ │1869 - } else { │ │1870 - if (next == 0) return false; │ │1871 - next -= 1; │ │1872 - } │ │1873 - if (self.focusVisibleChild(index, next, root_focus, !down)) { │ │1874 - self.focusDetailChild(index, next, !down, root_focus); │ │1875 - return true; │ │1876 - } │ │1877 - } │ │1878 - } │ │1879 - │ │1880 - fn focusedDetailChild(self: *This, index: usize, root_focus: *Focus) ?usize { │ │1881 - const inner = self.detailInner(index); │ │1882 - var id = root_focus.grandchild_id orelse return null; │ │1883 - while (inner.getFocus().children.get(id)) |child| { │ │1884 - if (child.parent_id == inner.getFocus().id) return inner.children.getIndex(id); │ │1885 - id = child.parent_id; │ │1886 - } │ │1887 - return null; │ │1888 - } │ │1889 - │ │1890 - fn detailChildFocusable(self: *This, index: usize, child_index: usize) bool { │ │1891 - const child = &self.detailInner(index).children.values()[child_index].widget; │ │1892 - const focus = child.getFocus(); │ │1893 - if (focus.focusable) return true; │ │1894 - for (focus.children.values()) |item| { │ │1895 - if (item.focus.focusable) return true; │ │1896 - } │ │1897 - return false; │ │1898 - } │ │1899 - │ │1900 - fn focusDetailChild(self: *This, index: usize, child_index: usize, last: bool, root_focus: *Focus) void { │ │1901 - const inner = self.detailInner(index); │ │1902 - const child = &inner.children.values()[child_index]; │ │1903 - var target = inner.children.keys()[child_index]; │ │1904 - var rect = child.rect orelse return; │ │1905 - switch (child.widget) { │ │1906 - .repo_comment => |*comment| { │ │1907 - if (comment.rowRect(last)) |row_rect| { │ │1908 - rect.x += row_rect.x; │ │1909 - rect.y += row_rect.y; │ │1910 - rect.size = row_rect.size; │ │1911 - } │ │1912 - if (last) comment.focusBody(root_focus) else comment.focusMetadata(root_focus); │ │1913 - }, │ │1914 - .box => |*box| if (box.children.count() > 0) { │ │1915 - target = if (child_index == tool_row_index) │ │1916 - box.getFocus().child_id orelse return │ │1917 - else │ │1918 - box.children.keys()[if (last) box.children.count() - 1 else 0]; │ │1919 - root_focus.setFocus(target); │ │1920 - }, │ │1921 - else => root_focus.setFocus(target), │ │1922 - } │ │1923 - const sc = self.detailScroll(index); │ │1924 - if (sc.grid) |viewport| { │ │1925 - const viewport_height = viewport.size.height - sc.bar_h; │ │1926 - if (rect.size.height > viewport_height) { │ │1927 - const bottom_aligned = rect.y + @as(isize, @intCast(rect.size.height - viewport_height)); │ │1928 - sc.y = std.math.clamp(sc.y, rect.y, bottom_aligned); │ │1929 - sc.clampToContent(); │ │1930 - return; │ │1931 - } │ │2108 } │ │1933 - sc.scrollToRect(rect); │ │2109 } │ │1935 - │ │1936 - fn moveDetailVertical(self: *This, index: usize, root_focus: *Focus, down: bool) bool { │ │1937 - const inner = self.detailInner(index); │ │1938 - const current = self.focusedDetailChild(index, root_focus) orelse return false; │ │1939 - if (inner.children.values()[current].widget == .repo_comment) { │ │1940 - const comment = &inner.children.values()[current].widget.repo_comment; │ │1941 - if (down and !comment.bodyFocused()) { │ │1942 - self.focusDetailChild(index, current, true, root_focus); │ │1943 - return true; │ │1944 - } │ │1945 - if (!down and comment.bodyFocused()) { │ │1946 - self.focusDetailChild(index, current, false, root_focus); │ │1947 - return true; │ │1948 - } │ │1949 - } │ │2110 │ │1951 - var next = current; │ │1952 - while (true) { │ │1953 - if (down) { │ │1954 - next += 1; │ │1955 - if (next >= inner.children.count()) return false; │ │1956 - } else { │ │1957 - if (next == 0) return false; │ │1958 - next -= 1; │ │1959 - } │ │1960 - if (!self.detailChildFocusable(index, next)) continue; │ │1961 - self.focusDetailChild(index, next, !down, root_focus); │ │1962 - return true; │ │1963 - } │ │1964 - } │ │1965 - │ │1966 - fn moveCommentHorizontal(self: *This, index: usize, root_focus: *Focus, right: bool) bool { │ │1967 - const inner = self.detailInner(index); │ │1968 - const current = self.focusedDetailChild(index, root_focus) orelse return false; │ │1969 - const child = &inner.children.values()[current]; │ │1970 - if (child.widget == .repo_comment) { │ │1971 - const moved = child.widget.repo_comment.moveHorizontal(root_focus, right); │ │1972 - if (moved) if (child.rect) |rect| self.detailScroll(index).scrollToRect(rect); │ │1973 - return moved; │ │1974 - } │ │1975 - const row = switch (child.widget) { │ │1976 - .box => |*box| box, │ │1977 - else => return false, │ │1978 - }; │ │1979 - const selected = row.getFocus().child_id orelse return false; │ │1980 - const selected_index = row.children.getIndex(selected) orelse return false; │ │1981 - const target = if (right) selected_index + 1 else selected_index -| 1; │ │1982 - if (target == selected_index or target >= row.children.count()) return false; │ │1983 - root_focus.setFocus(row.children.keys()[target]); │ │1984 - if (child.rect) |rect| self.detailScroll(index).scrollToRect(rect); │ │1985 - return true; │ │1986 - } │ │1987 - │ │1988 - // up/down (and tab/shift+tab) move between a form's fields; up from the │ │1989 - // title crosses into the header tabs. the multiline description keeps │ │1990 - // enter and any up/down that has a row to move to. │ │2111 fn formInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { │ │2112 const form = self.threadForm() orelse return; │ │2113 const cid = form.getFocus().child_id orelse return; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │2536 const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex) orelse return; │ │2537 try self.session.navigate(route); │ │2538 } │ │2419 - │ │2420 - // flip the shown thread's status by re-emitting its event, then reload the │ │2421 - // page rooted at the thread so the view reflects the change. this is the │ │2422 - // terminal path; the web posts the button's form to the status route. │ │2423 - fn toggleStatus(self: *This, allocator: std.mem.Allocator, index: usize) !void { │ │2424 - if (comptime !has_status) return; │ │2425 - if (comptime wasm) return; │ │2426 - const io = self.session.io orelse return; │ │2427 - const src = self.data.repo_source orelse return; │ │2428 - const sel = self.detailed_index[index] orelse return; │ │2429 - const entry = self.window(index).items[sel]; │ │2430 - const author = (try self.session.eventAuthor()) orelse return; │ │2431 - │ │2432 - const status = (statusChange(entryStatus(entry)) orelse return).status; │ │2539 │ │2434 - const id_bytes = try evt.parseEventId(entry.id); │ │2435 - │ │2436 - switch (src.repo_kind) { │ │2437 - inline else => |repo_kind| { │ │2438 - var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); │ │2439 - defer any_repo.deinit(io, allocator); │ │2440 - switch (any_repo) { │ │2441 - inline else => |*repo| try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .status = status }, author),│ │2442 - } │ │2443 - }, │ │2444 - } │ │2445 - │ │2446 - const route = listRoute(self.data.identity, status, "", entry.id) orelse return; │ │2447 - try self.session.navigate(route); │ │2448 - } │ │2449 - │ │2450 - // arrow keys move the tag selection; at the flow's edges focus crosses to │ │2451 - // the neighboring widgets. │ │2452 - fn tagsInput(self: *This, index: usize, child_index: usize, tf: *TagFlow, key: Key, root_focus: *Focus) void { │ │2453 - const cid = tf.focus.child_id orelse return; │ │2454 - const cur = tf.indexOfFocusId(cid) orelse return; │ │2455 - const count = tf.text_boxes.items.len; │ │2456 - switch (key) { │ │2457 - .arrow_left => if (cur > 0) self.focusTag(index, child_index, tf, root_focus, cur - 1) else self.focusList(index, root_focus), │ │2458 - .arrow_right => if (cur + 1 < count) self.focusTag(index, child_index, tf, root_focus, cur + 1), │ │2459 - .arrow_up => if (tf.rowStep(cur, false)) |i| { │ │2460 - self.focusTag(index, child_index, tf, root_focus, i); │ │2461 - } else { │ │2462 - _ = self.moveDetailVertical(index, root_focus, false); │ │2463 - }, │ │2464 - .arrow_down => if (tf.rowStep(cur, true)) |i| { │ │2465 - self.focusTag(index, child_index, tf, root_focus, i); │ │2466 - } else { │ │2467 - _ = self.moveDetailVertical(index, root_focus, true); │ │2468 - }, │ │2469 - .home => self.focusTag(index, child_index, tf, root_focus, 0), │ │2470 - .end => self.focusTag(index, child_index, tf, root_focus, count - 1), │ │2471 - else => {}, │ │2472 - } │ │2473 - } │ │2474 - │ │2475 - fn toolRowFocused(self: *This, index: usize, root_focus: *Focus) bool { │ │2476 - return self.focusedDetailChild(index, root_focus) == tool_row_index; │ │2477 - } │ │2478 - │ │2479 - fn focusTag(self: *This, index: usize, child_index: usize, tf: *TagFlow, root_focus: *Focus, item: usize) void { │ │2480 - root_focus.setFocus(tf.text_boxes.items[item].getFocus().id); │ │2481 - // keep the tag visible on the terminal: its rect offset by the flow's │ │2482 - // position in the pane. │ │2483 - if (self.session.is_terminal and item < tf.rects.items.len) { │ │2484 - if (self.detailInner(index).children.values()[child_index].rect) |flow_rect| { │ │2485 - var rect = tf.rects.items[item]; │ │2486 - rect.x += flow_rect.x; │ │2487 - rect.y += flow_rect.y; │ │2488 - self.detailScroll(index).scrollToRect(rect); │ │2489 - } │ │2490 - } │ │2491 - } │ │2492 - │ │2493 - // return to the tool row's last-focused button (the header when the │ │2494 - // description page shows no row). │ │2495 - fn focusToolRow(self: *This, index: usize, root_focus: *Focus) void { │ │2496 - const cid = self.toolRow(index).getFocus().child_id orelse return self.focusHeader(root_focus); │ │2497 - root_focus.setFocus(cid); │ │2498 - if (self.detailInner(index).children.values()[tool_row_index].rect) |rect| │ │2499 - self.detailScroll(index).scrollToRect(rect); │ │2500 - } │ │2501 - │ │2502 - // enter the detail at its first focusable widget below the tool row. │ │2503 - pub fn focusDetail(self: *This, root_focus: *Focus) bool { │ │2504 - const index = self.selectedSplitIndex() orelse return false; │ │2505 - for (tool_row_index + 1..self.detailInner(index).children.count()) |child_index| { │ │2506 - if (!self.detailChildFocusable(index, child_index)) continue; │ │2507 - self.focusDetailChild(index, child_index, false, root_focus); │ │2508 - return true; │ │2509 - } │ │2510 - return false; │ │2511 - } │ │2512 - │ │2540 // return to the list. │ │2541 fn focusList(self: *This, index: usize, root_focus: *Focus) void { │ │2515 - if (self.detail_only) return; │ │2542 root_focus.setFocus(self.listScroll(index).getFocus().id); │ │2543 } │ │2544 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────╮ │2545 // cross to the header tabs above the stack. │ │2546 fn focusHeader(self: *This, root_focus: *Focus) void { │ │2521 - if (self.detail_only) return; │ │2547 root_focus.setFocus(self.box.children.keys()[header_index]);│ │2548 } │ │2549 │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │2559 return self.box.getFocus(); │ │2560 } │ │2561 │ │2537 - pub fn atTop(self: *This, root_focus: *Focus) bool { │ │2538 - if (!self.detail_only) return self.getSelectedIndex() == 0; │ │2539 - const index = self.selectedSplitIndex() orelse return false; │ │2540 - const focused = self.focusedDetailChild(index, root_focus) orelse return false;│ │2541 - for (0..self.detailInner(index).children.count()) |child_index| { │ │2542 - if (!self.detailChildFocusable(index, child_index)) continue; │ │2543 - return focused == child_index; │ │2544 - } │ │2545 - return false; │ │2546 - } │ │2547 - │ │2562 // for the parent's "scroll up at the top jumps to the header" check: at the │ │2563 // top only while the header tabs hold focus, so up from the split │ │2564 // crosses into the tabs first. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯