╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Threads.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1298 .arrow_up => if (tf.rowStep(cur, false)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id) else self.focusHeader(root_focus), 1299 .arrow_down => if (tf.rowStep(cur, true)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id), 1300 .home => root_focus.setFocus(tf.text_boxes.items[0].getFocus().id), 1301 .end => root_focus.setFocus(tf.text_boxes.items[count - 1].getFocus().id), 1302 else => {}, 1303 } 1304 } 1305 1306 fn listInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1307 // up/down (and the scroll wheel) move the selection a row; page up/down 1308 // jump a fixed amount. right/Enter cross into the detail pane. up from 1309 // the top row crosses into the header tabs. 1310 if (inp.rowDelta(key, @intCast(self.listBox(index).children.count()))) |delta| { 1311 const lb = self.listBox(index); 1312 const at_top = if (lb.getFocus().child_id) |cid| lb.children.getIndex(cid) == 0 else true; 1313 if (delta < 0 and at_top) return self.focusHeader(root_focus); 1314 ui.moveRowFocus(lb, self.listScroll(index), root_focus, delta); 1315 return; 1316 } 1317 switch (key) { 1318 .enter, .arrow_right => try self.focusDetail(index, root_focus), 1319 else => {}, 1320 } 1321 } 1322 1323 fn detailInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1324 switch (key) { 1325 .page_up => { 1326 self.pageDetail(index, root_focus, -10); 1327 return; 1328 }, 1329 .page_down => { 1330 self.pageDetail(index, root_focus, 10); 1331 return; 1332 }, 1333 .home => { 1334 self.jumpDetail(index, root_focus, false); 1335 return; 1336 }, 1337 .end => { 1338 self.jumpDetail(index, root_focus, true); 1339 return; 1340 }, 1341 else => {}, 1342 } 1343 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)) { 1344 try self.commentInput(allocator, index, key, root_focus); 1345 } else if (self.toolRowFocused(index)) { 1346 try self.toolRowInput(allocator, index, key, root_focus); 1347 } else if (self.titleFocused(index)) { 1348 try self.titleInput(index, key, root_focus); 1349 } else if (self.authorFocused(index)) { 1350 try self.authorInput(index, key, root_focus); 1351 } else if (self.tagsFocused(index)) { 1352 try self.tagsInput(index, key, root_focus); 1353 } else if (self.descriptionFocused(index)) { 1354 try self.descriptionInput(index, key, root_focus); 1355 } else if (try self.attachmentInput(allocator, index, key, root_focus)) {} else { 1356 try self.commentInput(allocator, index, key, root_focus); 1357 } 1358 } 1359 1360 fn attachmentInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !bool { 1361 const child_index = self.focusedDetailChild(index, root_focus) orelse return false; 1362 const child = &self.detailInner(index).children.values()[child_index].widget; 1363 const row = switch (child.*) { 1364 .box => |*box| box, 1365 else => return false, 1366 }; 1367 const attachment_id = Attachment.removeId(row) orelse return false; 1368 if (row.children.count() < 2) return false; 1369 const remove_id = row.children.keys()[1]; 1370 switch (key) { 1371 .arrow_left => if (!self.moveCommentHorizontal(index, root_focus, false)) try self.focusList(index, root_focus), 1372 .arrow_right => _ = self.moveCommentHorizontal(index, root_focus, true), 1373 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1374 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1375 .enter => if (row.getFocus().child_id == remove_id) try self.removeEvent(allocator, .attach, attachment_id), 1376 .mouse => |mouse| if (inp.leftClickOn(root_focus, remove_id, mouse)) try self.removeEvent(allocator, .attach, attachment_id), 1377 else => {}, 1378 } 1379 return true; 1380 } 1381 1382 // the tool row: enter or a click on the open/close button flips the 1383 // issue's status; the edit and resolve buttons are a: links the host 1384 // follows. arrows cross between the buttons and to the neighboring 1385 // widgets. 1386 fn toolRowInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1387 const row = self.toolRow(index); 1388 const cur = if (row.getFocus().child_id) |cid| row.children.getIndex(cid) orelse return else return; 1389 const on_status = if (self.status_button_id[index]) |id| row.getFocus().child_id == id else false; 1390 switch (key) { 1391 .arrow_left => if (cur > first_in_row_index) root_focus.setFocus(row.children.keys()[cur - 1]) else try self.focusList(index, root_focus), 1392 .arrow_right => if (cur + 1 < row.children.count()) root_focus.setFocus(row.children.keys()[cur + 1]), 1393 .arrow_up => self.focusHeader(root_focus), 1394 .arrow_down => { 1395 self.focusDetailEdge(index, root_focus, false); 1396 self.detailScroll(index).y = 0; 1397 }, 1398 .enter => if (on_status) try self.toggleStatus(allocator, index), 1399 .mouse => |mouse| if (self.status_button_id[index]) |id| { 1400 if (inp.leftClickOn(root_focus, id, mouse)) try self.toggleStatus(allocator, index); 1401 }, 1402 else => {}, 1403 } 1404 } 1405 1406 fn titleInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1407 switch (key) { 1408 .arrow_left => try self.focusList(index, root_focus), 1409 .arrow_up => self.focusToolRow(index, root_focus), 1410 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1411 else => {}, 1412 } 1413 } 1414 1415 // the author box's a: link (when it has one) is followed by the host on 1416 // enter / a click; arrows cross to the neighboring widgets. 1417 fn authorInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1418 switch (key) { 1419 .arrow_left => try self.focusList(index, root_focus), 1420 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1421 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1422 else => {}, 1423 } 1424 } 1425 1426 fn descriptionInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 1427 const sc = self.detailScroll(index); 1428 switch (key) { 1429 .arrow_left => return self.focusList(index, root_focus), 1430 // the terminal moves through the description one line at a time; 1431 // the web lets focus changes drive its native scroll. 1432 .arrow_up => { 1433 if (!self.session.is_terminal) { 1434 _ = self.moveDetailVertical(index, root_focus, false); 1435 return; 1436 } 1437 if (self.focusAdjacentVisible(index, root_focus, false)) return; 1438 const before = sc.y; 1439 sc.y -= 1; 1440 sc.clampToContent(); 1441 if (sc.y == before) _ = self.moveDetailVertical(index, root_focus, false); 1442 return; 1443 }, 1444 .arrow_down => { 1445 if (!self.session.is_terminal) { 1446 _ = self.moveDetailVertical(index, root_focus, true); 1447 return; 1448 } 1449 if (self.focusAdjacentVisible(index, root_focus, true)) return; 1450 const before = sc.y; 1451 sc.y += 1; 1452 sc.clampToContent(); 1453 if (sc.y == before) _ = self.moveDetailVertical(index, root_focus, true); 1454 return; 1455 }, 1456 else => return, 1457 } 1458 sc.clampToContent(); 1459 } 1460 1461 fn commentInput(self: *This, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1462 _ = allocator; 1463 switch (key) { 1464 .arrow_left => if (!self.moveCommentHorizontal(index, root_focus, false)) return self.focusList(index, root_focus), 1465 .arrow_right => _ = self.moveCommentHorizontal(index, root_focus, true), 1466 .arrow_up => _ = self.moveDetailVertical(index, root_focus, false), 1467 .arrow_down => _ = self.moveDetailVertical(index, root_focus, true), 1468 else => return, 1469 } 1470 } 1471 1472 // page the detail scroll, then focus the first or last visible widget. 1473 fn pageDetail(self: *This, index: usize, root_focus: *Focus, delta: isize) void { 1474 if (!self.session.is_terminal) return; 1475 const sc = self.detailScroll(index); 1476 sc.y += delta; 1477 sc.clampToContent(); 1478 self.focusVisible(index, root_focus, delta > 0); 1479 } 1480 1481 // jump to the detail's first or last focusable widget. the terminal pins 1482 // its synthetic scroll too; the browser scrolls to the new focus itself. 1483 fn jumpDetail(self: *This, index: usize, root_focus: *Focus, to_end: bool) void { 1484 if (self.session.is_terminal) { 1485 const sc = self.detailScroll(index); 1486 sc.y = if (to_end) std.math.maxInt(isize) else 0; 1487 sc.clampToContent(); 1488 self.focusVisible(index, root_focus, to_end); 1489 } else { 1490 self.focusDetailEdge(index, root_focus, to_end); 1491 } 1492 } 1493 1494 fn focusDetailEdge(self: *This, index: usize, root_focus: *Focus, last: bool) void { 1495 const focus = self.detailInner(index).getFocus(); 1496 var chosen: ?usize = null; 1497 for (focus.children.keys(), focus.children.values()) |id, child| { 1498 if (!child.focus.focusable) continue; 1499 chosen = id; 1500 if (!last) break; 1501 } 1502 if (chosen) |id| root_focus.setFocus(id); 1503 } 1504 1505 fn detailRectVisible(self: *This, index: usize, rect: layout.IRect) bool { 1506 const sc = self.detailScroll(index); 1507 const viewport = sc.grid orelse return false; 1508 const top = sc.y; 1509 const bottom = top + @as(isize, @intCast(viewport.size.height - sc.bar_h)); 1510 return rect.y + @as(isize, @intCast(rect.size.height)) > top and rect.y < bottom; 1511 } 1512 1513 // focus the top- or bottom-most focusable widget intersecting the viewport. 1514 fn focusVisible(self: *This, index: usize, root_focus: *Focus, last: bool) void { 1515 const inner = self.detailInner(index); 1516 var offset: usize = 0; 1517 while (offset < inner.children.count()) : (offset += 1) { 1518 const child_index = if (last) inner.children.count() - 1 - offset else offset; 1519 if (self.focusVisibleChild(index, child_index, root_focus, last)) return; 1520 } 1521 } 1522 1523 fn focusVisibleChild(self: *This, index: usize, child_index: usize, root_focus: *Focus, last: bool) bool { 1524 const child = &self.detailInner(index).children.values()[child_index]; 1525 const child_rect = child.rect orelse return false; 1526 var chosen: ?usize = null; 1527 if (child.widget.getFocus().focusable and self.detailRectVisible(index, child_rect)) { 1528 const id = self.detailInner(index).children.keys()[child_index]; 1529 chosen = id; 1530 if (!last) { 1531 root_focus.setFocus(id); 1532 return true; 1533 } 1534 } 1535 1536 for (child.widget.getFocus().children.keys(), child.widget.getFocus().children.values()) |id, focus_child| { 1537 if (!focus_child.focus.focusable) continue; 1538 const rect: layout.IRect = .{ 1539 .x = child_rect.x + @as(isize, @intCast(focus_child.rect.x)), 1540 .y = child_rect.y + @as(isize, @intCast(focus_child.rect.y)), 1541 .size = focus_child.rect.size, 1542 }; 1543 if (!self.detailRectVisible(index, rect)) continue; 1544 chosen = id; 1545 if (!last) break; 1546 } 1547 if (chosen) |id| { 1548 root_focus.setFocus(id); 1549 return true; 1550 } 1551 return false; 1552 } 1553 1554 fn focusAdjacentVisible(self: *This, index: usize, root_focus: *Focus, down: bool) bool { 1555 const inner = self.detailInner(index); 1556 var next = self.focusedDetailChild(index, root_focus) orelse return false; 1557 while (true) { 1558 if (down) { 1559 next += 1; 1560 if (next >= inner.children.count()) return false; 1561 } else { 1562 if (next == 0) return false; 1563 next -= 1; 1564 } 1565 if (self.focusVisibleChild(index, next, root_focus, !down)) { 1566 self.focusDetailChild(index, next, !down, root_focus); 1567 return true; 1568 } 1569 } 1570 } 1571 1572 fn focusedDetailChild(self: *This, index: usize, root_focus: *Focus) ?usize { 1573 const inner = self.detailInner(index); 1574 var id = root_focus.grandchild_id orelse return null; 1575 while (inner.getFocus().children.get(id)) |child| { 1576 if (child.parent_id == inner.getFocus().id) return inner.children.getIndex(id); 1577 id = child.parent_id; 1578 } 1579 return null; 1580 } 1581 1582 fn detailChildFocusable(self: *This, index: usize, child_index: usize) bool { 1583 const child = &self.detailInner(index).children.values()[child_index].widget; 1584 const focus = child.getFocus(); 1585 if (focus.focusable) return true; 1586 for (focus.children.values()) |item| { 1587 if (item.focus.focusable) return true; 1588 } 1589 return false; 1590 } 1591 1592 fn focusDetailChild(self: *This, index: usize, child_index: usize, last: bool, root_focus: *Focus) void { 1593 const inner = self.detailInner(index); 1594 const child = &inner.children.values()[child_index]; 1595 var target = inner.children.keys()[child_index]; 1596 var rect = child.rect orelse return; 1597 switch (child.widget) { 1598 .repo_comment => |*comment| { 1599 if (comment.rowRect(last)) |row_rect| { 1600 rect.x += row_rect.x; 1601 rect.y += row_rect.y; 1602 rect.size = row_rect.size; 1603 } 1604 if (last) comment.focusBody(root_focus) else comment.focusMetadata(root_focus); 1605 }, 1606 .box => |*box| if (box.children.count() > 0) { 1607 target = box.children.keys()[if (last) box.children.count() - 1 else 0]; 1608 root_focus.setFocus(target); 1609 }, 1610 else => root_focus.setFocus(target), 1611 } 1612 const sc = self.detailScroll(index); 1613 if (sc.grid) |viewport| { 1614 const viewport_height = viewport.size.height - sc.bar_h; 1615 if (rect.size.height > viewport_height) { 1616 const bottom_aligned = rect.y + @as(isize, @intCast(rect.size.height - viewport_height)); 1617 sc.y = std.math.clamp(sc.y, rect.y, bottom_aligned); 1618 sc.clampToContent(); 1619 return; 1620 } 1621 } 1622 sc.scrollToRect(rect); 1623 } 1624 1625 fn moveDetailVertical(self: *This, index: usize, root_focus: *Focus, down: bool) bool { 1626 const inner = self.detailInner(index); 1627 const current = self.focusedDetailChild(index, root_focus) orelse return false; 1628 if (inner.children.values()[current].widget == .repo_comment) { 1629 const comment = &inner.children.values()[current].widget.repo_comment; 1630 if (down and !comment.bodyFocused()) { 1631 self.focusDetailChild(index, current, true, root_focus); 1632 return true; 1633 } 1634 if (!down and comment.bodyFocused()) { 1635 self.focusDetailChild(index, current, false, root_focus); 1636 return true; 1637 } 1638 } 1639 1640 var next = current; 1641 while (true) { 1642 if (down) { 1643 next += 1; 1644 if (next >= inner.children.count()) return false; 1645 } else { 1646 if (next == 0) return false; 1647 next -= 1; 1648 } 1649 if (!self.detailChildFocusable(index, next)) continue; 1650 self.focusDetailChild(index, next, !down, root_focus); 1651 return true; 1652 } 1653 } 1654 1655 fn moveCommentHorizontal(self: *This, index: usize, root_focus: *Focus, right: bool) bool { 1656 const inner = self.detailInner(index); 1657 const current = self.focusedDetailChild(index, root_focus) orelse return false; 1658 const child = &inner.children.values()[current]; 1659 if (child.widget == .repo_comment) { 1660 const moved = child.widget.repo_comment.moveHorizontal(root_focus, right); 1661 if (moved) if (child.rect) |rect| self.detailScroll(index).scrollToRect(rect); 1662 return moved; 1663 } 1664 const row = switch (child.widget) { 1665 .box => |*box| box, 1666 else => return false, 1667 }; 1668 const selected = row.getFocus().child_id orelse return false; 1669 const selected_index = row.children.getIndex(selected) orelse return false; 1670 const target = if (right) selected_index + 1 else selected_index -| 1; 1671 if (target == selected_index or target >= row.children.count()) return false; 1672 root_focus.setFocus(row.children.keys()[target]); 1673 if (child.rect) |rect| self.detailScroll(index).scrollToRect(rect); 1674 return true; 1675 } 1676 1677 // up/down (and tab/shift+tab) move between a form's fields; up from the 1678 // title crosses into the header tabs. the multiline description keeps 1679 // enter and any up/down that has a row to move to. 1680 fn formInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1681 const form = self.threadForm() orelse return; 1682 const cid = form.getFocus().child_id orelse return; 1683 const cur = form.children.getIndex(cid) orelse return; 1684 1685 if (cur == description_field_index) { 1686 const child = &form.children.values()[cur]; 1687 if (try multilineKeepsKey(&child.widget.text_input, allocator, key)) 1688 return child.widget.input(allocator, key, root_focus); 1689 } 1690 1691 switch (key) { 1692 .arrow_up, .back_tab => if (cur > 0) 1693 root_focus.setFocus(form.children.keys()[cur - 1]) 1694 else 1695 self.focusHeader(root_focus), 1696 .arrow_down, .tab => if (cur < submit_field_index) { 1697 root_focus.setFocus(form.children.keys()[cur + 1]); 1698 }, 1699 .enter => if (cur == submit_field_index) try self.submitForm(allocator), 1700 .mouse => |mouse| if (cur == submit_field_index) { 1701 const submit = &form.children.values()[cur].widget.submit_button; 1702 if (inp.leftClickOn(root_focus, submit.buttonId(), mouse)) try self.submitForm(allocator); 1703 }, 1704 else => try form.children.values()[cur].widget.input(allocator, key, root_focus), 1705 } 1706 } 1707 1708 fn commentFormInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1709 const form = self.threadForm() orelse return; 1710 const cid = form.getFocus().child_id orelse return; 1711 const cur = form.children.getIndex(cid) orelse return; 1712 const child = &form.children.values()[cur]; 1713 1714 if (cur == comment_body_field_index and try multilineKeepsKey(&child.widget.text_input, allocator, key)) 1715 return child.widget.input(allocator, key, root_focus); 1716 1717 switch (key) { 1718 .arrow_up, .back_tab => if (cur > comment_author_field_index) 1719 root_focus.setFocus(form.children.keys()[cur - 1]) 1720 else 1721 self.focusHeader(root_focus), 1722 .arrow_down, .tab => if (cur < comment_submit_field_index) 1723 root_focus.setFocus(form.children.keys()[cur + 1]), 1724 .enter => if (cur == comment_submit_field_index) try self.submitComment(allocator), 1725 .mouse => |mouse| if (cur == comment_submit_field_index) { 1726 const submit = &child.widget.submit_button; 1727 if (inp.leftClickOn(root_focus, submit.buttonId(), mouse)) try self.submitComment(allocator); 1728 }, 1729 else => try child.widget.input(allocator, key, root_focus), 1730 } 1731 } 1732 1733 fn removeFormInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1734 const form = self.removeForm() orelse return; 1735 const button_id = form.children.keys()[1]; 1736 switch (key) { 1737 .arrow_up, .back_tab => self.focusHeader(root_focus), 1738 .enter => try self.submitRemove(allocator), 1739 .mouse => |mouse| if (inp.leftClickOn(root_focus, button_id, mouse)) try self.submitRemove(allocator), 1740 else => {}, 1741 } 1742 } 1743 1744 // the multiline hunk inputs keep enter and any up/down that has a row to 1745 // move to; enter on a "use this" link is a navigation the host follows 1746 // before this runs. 1747 fn resolveInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1748 const form = self.threadForm() orelse return; 1749 const cid = form.getFocus().child_id orelse return; 1750 const cur = form.children.getIndex(cid) orelse return; 1751 const child = &form.children.values()[cur]; 1752 1753 if (child.widget == .text_input) { 1754 const ti = &child.widget.text_input; 1755 if (ti.options.multiline and try multilineKeepsKey(ti, allocator, key)) 1756 return child.widget.input(allocator, key, root_focus); 1757 } 1758 1759 const on_submit = child.widget == .submit_button; 1760 1761 switch (key) { 1762 .arrow_up, .back_tab => if (resolveStep(form, cur, false)) |i| self.focusResolveChild(form, i, root_focus) else self.focusHeader(root_focus), 1763 .arrow_down, .tab => if (resolveStep(form, cur, true)) |i| self.focusResolveChild(form, i, root_focus), 1764 .arrow_left, .arrow_right => switch (child.widget) { 1765 .box => |*row| { 1766 const row_cid = row.getFocus().child_id orelse return; 1767 const row_index = row.children.getIndex(row_cid) orelse return; 1768 if (key == .arrow_left) { 1769 if (row_index > 0) root_focus.setFocus(row.children.keys()[row_index - 1]); 1770 } else if (row_index + 1 < row.children.count()) { 1771 root_focus.setFocus(row.children.keys()[row_index + 1]); 1772 } 1773 }, 1774 else => try child.widget.input(allocator, key, root_focus), 1775 }, 1776 .enter => if (on_submit) try self.submitResolution(allocator), 1777 .mouse => |mouse| if (on_submit) { 1778 if (inp.leftClickOn(root_focus, child.widget.submit_button.buttonId(), mouse)) try self.submitResolution(allocator); 1779 }, 1780 else => try child.widget.input(allocator, key, root_focus), 1781 } 1782 } 1783 1784 // a multiline input keeps enter and any up/down that has a row to move to 1785 fn multilineKeepsKey(text_input: *wgt.TextInput(ui.Widget), allocator: std.mem.Allocator, key: Key) !bool { 1786 return switch (key) { 1787 .enter => true, 1788 .arrow_up => !try text_input.cursorOnFirstRow(allocator), 1789 .arrow_down => !try text_input.cursorOnLastRow(allocator), 1790 else => false, 1791 }; 1792 } 1793 1794 // the neighboring focusable resolve-form child, skipping the spacer and 1795 // the blank gap rows 1796 fn resolveStep(form: *wgt.Box(ui.Widget), cur: usize, down: bool) ?usize { 1797 var i = cur; 1798 while (true) { 1799 if (down) { 1800 i += 1; 1801 if (i >= form.children.count()) return null; 1802 } else { 1803 if (i == 0) return null; 1804 i -= 1; 1805 } 1806 switch (form.children.values()[i].widget) { 1807 .spacer, .text => continue, 1808 else => return i, 1809 } 1810 } 1811 } 1812 1813 // focus the resolve-form child at `i` (a version row focuses its selected 1814 // child) and keep it visible on the terminal 1815 fn focusResolveChild(self: *This, form: *wgt.Box(ui.Widget), i: usize, root_focus: *Focus) void { 1816 const child = &form.children.values()[i]; 1817 switch (child.widget) { 1818 .box => |*row| root_focus.setFocus(row.getFocus().child_id orelse form.children.keys()[i]), 1819 else => root_focus.setFocus(form.children.keys()[i]), 1820 } 1821 if (self.session.is_terminal) { 1822 if (self.resolveScroll()) |sc| { 1823 if (child.rect) |rect| sc.scrollToRect(rect); 1824 } 1825 } 1826 } 1827 1828 // re-emit the conflicted issue's event with the resolved content; any 1829 // event on the issue settles its conflict entry. this is the terminal 1830 // path; the web posts the form to the resolve route. 1831 fn submitResolution(self: *This, allocator: std.mem.Allocator) !void { 1832 if (comptime wasm) return; 1833 const io = self.session.io orelse return; 1834 const src = self.data.repo_source orelse return; 1835 const entry = self.data.selectedThread() orelse return; 1836 const author = (try self.session.eventAuthor()) orelse return; 1837 const form = self.threadForm() orelse return; 1838 1839 // gather the inputs by name; the d<n> hunk inputs appear in chunk order 1840 var title: ?[]u8 = null; 1841 var tags: ?[]u8 = null; 1842 var hunks: std.ArrayList([]const u8) = .empty; 1843 defer { 1844 if (title) |t| allocator.free(t); 1845 if (tags) |t| allocator.free(t); 1846 for (hunks.items) |h| allocator.free(h); 1847 hunks.deinit(allocator); 1848 } 1849 for (form.children.values()) |*child| switch (child.widget) { 1850 .text_input => |*ti| { 1851 const text = try ti.text(allocator); 1852 errdefer allocator.free(text); 1853 if (std.mem.eql(u8, ti.options.name, "title")) { 1854 title = text; 1855 } else if (std.mem.eql(u8, ti.options.name, "tags")) { 1856 tags = text; 1857 } else { 1858 try hunks.append(allocator, text); 1859 } 1860 }, 1861 else => {}, 1862 }; 1863 1864 var id_bytes: [evt.event_id_size]u8 = undefined; 1865 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 1866 1867 switch (src.repo_kind) { 1868 inline else => |repo_kind| { 1869 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1870 defer any_repo.deinit(io, allocator); 1871 switch (any_repo) { 1872 inline else => |*repo| Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .resolve = .{ 1873 .title = title, 1874 .tags = tags, 1875 .hunks = hunks.items, 1876 } }, author) catch |err| switch (err) { 1877 // leave the form up for correction 1878 error.InvalidFields => return, 1879 else => |e| return e, 1880 }, 1881 } 1882 }, 1883 } 1884 1885 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 1886 try self.session.navigate(route); 1887 } 1888 1889 // commit a new or edited comment and navigate to its permalink. this is the 1890 // terminal path; the web posts the form to its comment route. 1891 fn submitComment(self: *This, allocator: std.mem.Allocator) !void { 1892 if (comptime wasm) return; 1893 const io = self.session.io orelse return; 1894 const src = self.data.repo_source orelse return; 1895 const author = (try self.session.eventAuthor()) orelse return; 1896 const form = self.threadForm() orelse return; 1897 const body_input = &form.children.values()[comment_body_field_index].widget.text_input; 1898 const body = try body_input.text(allocator); 1899 defer allocator.free(body); 1900 if (!evt.Comment.fieldsValid(body)) return; 1901 1902 var thread_id: [evt.event_id_size * 2]u8 = undefined; 1903 @memcpy(&thread_id, self.data.selected_id); 1904 const parent_id = if (self.data.comment_id.len == 0) thread_id else blk: { 1905 var parent: [evt.event_id_size * 2]u8 = undefined; 1906 @memcpy(&parent, self.data.comment_id); 1907 break :blk parent; 1908 }; 1909 var event_id_hex: [evt.event_id_size * 2]u8 = undefined; 1910 const editing = self.data.view == .edit_comment; 1911 switch (src.repo_kind) { 1912 inline else => |repo_kind| { 1913 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1914 defer any_repo.deinit(io, allocator); 1915 switch (any_repo) { 1916 inline else => |*repo| if (editing) { 1917 var comment_id: [evt.event_id_size]u8 = undefined; 1918 _ = std.fmt.hexToBytes(&comment_id, self.data.comment_id) catch return; 1919 try evt.Comment.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &comment_id, body, author); 1920 event_id_hex = std.fmt.bytesToHex(comment_id, .lower); 1921 } else { 1922 event_id_hex = try evt.Comment.create(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &parent_id, body, author); 1923 }, 1924 } 1925 }, 1926 } 1927 1928 body_input.clear(allocator); 1929 const route = commentRoute(self.data.identity, &thread_id, &event_id_hex, 0) orelse return; 1930 try self.session.navigate(route); 1931 } 1932 1933 // the terminal submit paths: the new form commits a new-issue event, the 1934 // edit form re-emits the selected issue's event with the form's content. 1935 // the web posts the forms to their routes instead. 1936 fn submitForm(self: *This, allocator: std.mem.Allocator) !void { 1937 if (self.data.view == .edit) { 1938 try self.submitEditedThread(allocator); 1939 } else { 1940 try self.submitNewThread(allocator); 1941 } 1942 } 1943 1944 // commit the new issue to the repo's events branch and navigate to it. 1945 // this is the terminal path; the web posts the form to the issue route, 1946 // so the wasm side never runs (or compiles) the repo access below. 1947 fn submitNewThread(self: *This, allocator: std.mem.Allocator) !void { 1948 if (comptime wasm) return; 1949 const io = self.session.io orelse return; 1950 const src = self.data.repo_source orelse return; 1951 const author = (try self.session.eventAuthor()) orelse return; 1952 1953 const form = self.threadForm() orelse return; 1954 const title_input = &form.children.values()[title_field_index].widget.text_input; 1955 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 1956 const description_input = &form.children.values()[description_field_index].widget.text_input; 1957 1958 const title = try title_input.text(allocator); 1959 defer allocator.free(title); 1960 const tags = try tags_input.text(allocator); 1961 defer allocator.free(tags); 1962 const description = try description_input.text(allocator); 1963 defer allocator.free(description); 1964 1965 if (!Event.fieldsValid(title, tags)) return; 1966 1967 var id_bytes: [evt.event_id_size]u8 = undefined; 1968 io.random(&id_bytes); 1969 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 1970 1971 const event = evt.EventWithId{ 1972 .id = event_id_hex, 1973 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 1974 .author = author, 1975 .event = @unionInit(evt.Event, @tagName(kind), Event{ 1976 .title = title, 1977 .description = description, 1978 .tags = tags, 1979 }), 1980 }; 1981 1982 switch (src.repo_kind) { 1983 inline else => |repo_kind| { 1984 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 1985 defer any_repo.deinit(io, allocator); 1986 switch (any_repo) { 1987 inline else => |*repo| try evt.consume(repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &.{event}), 1988 } 1989 }, 1990 } 1991 1992 // wipe the form so a return visit starts fresh 1993 title_input.clear(allocator); 1994 tags_input.clear(allocator); 1995 description_input.clear(allocator); 1996 1997 const route = listRoute(self.data.identity, @enumFromInt(0), "", &event_id_hex) orelse return; 1998 try self.session.navigate(route); 1999 } 2000 2001 // re-emit the selected issue's event with the form's content (status 2002 // preserved), then reload the issue's page. this is the terminal path; 2003 // the web posts the form to the edit route. 2004 fn submitEditedThread(self: *This, allocator: std.mem.Allocator) !void { 2005 if (comptime wasm) return; 2006 const io = self.session.io orelse return; 2007 const src = self.data.repo_source orelse return; 2008 const entry = self.data.selectedThread() orelse return; 2009 const author = (try self.session.eventAuthor()) orelse return; 2010 2011 const form = self.threadForm() orelse return; 2012 const title_input = &form.children.values()[title_field_index].widget.text_input; 2013 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 2014 const description_input = &form.children.values()[description_field_index].widget.text_input; 2015 2016 const title = try title_input.text(allocator); 2017 defer allocator.free(title); 2018 const tags = try tags_input.text(allocator); 2019 defer allocator.free(tags); 2020 const description = try description_input.text(allocator); 2021 defer allocator.free(description); 2022 2023 if (!Event.fieldsValid(title, tags)) return; 2024 2025 var id_bytes: [evt.event_id_size]u8 = undefined; 2026 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 2027 2028 switch (src.repo_kind) { 2029 inline else => |repo_kind| { 2030 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2031 defer any_repo.deinit(io, allocator); 2032 switch (any_repo) { 2033 inline else => |*repo| if (has_status) 2034 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .fields = .{ 2035 .title = title, 2036 .tags = tags, 2037 .description = description, 2038 } }, author) 2039 else 2040 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, title, tags, description, author), 2041 } 2042 }, 2043 } 2044 2045 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 2046 try self.session.navigate(route); 2047 } 2048 2049 fn submitRemove(self: *This, allocator: std.mem.Allocator) !void { 2050 const comment = self.data.comment_id.len != 0; 2051 const event_id = if (comment) self.data.comment_id else self.data.selected_id; 2052 var id: [evt.event_id_size]u8 = undefined; 2053 _ = std.fmt.hexToBytes(&id, event_id) catch return; 2054 try self.removeEvent(allocator, if (comment) .comment else kind, id); 2055 } 2056 2057 fn removeEvent(self: *This, allocator: std.mem.Allocator, event_kind: evt.EventKind, id: [evt.event_id_size]u8) !void { 2058 if (comptime wasm) return; 2059 const io = self.session.io orelse return; 2060 const src = self.data.repo_source orelse return; 2061 const author = (try self.session.eventAuthor()) orelse return; 2062 2063 switch (src.repo_kind) { 2064 inline else => |repo_kind| { 2065 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2066 defer any_repo.deinit(io, allocator); 2067 switch (any_repo) { 2068 inline else => |*repo| try evt.remove(repo_kind, repo.self_repo_opts, io, allocator, repo, &id, event_kind, author), 2069 } 2070 }, 2071 } 2072 2073 const id_hex = std.fmt.bytesToHex(id, .lower); 2074 const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex) orelse return; 2075 try self.session.navigate(route); 2076 } 2077 2078 // flip the shown issue's status by re-emitting its event, then reload the 2079 // page rooted at the issue so the view reflects the change. this is the 2080 // terminal path; the web posts the button's form to the status route. 2081 fn toggleStatus(self: *This, allocator: std.mem.Allocator, index: usize) !void { 2082 if (comptime !has_status) return; 2083 if (comptime wasm) return; 2084 const io = self.session.io orelse return; 2085 const src = self.data.repo_source orelse return; 2086 const sel = self.detailed_index[index] orelse return; 2087 const entry = self.window(index).items[sel]; 2088 const author = (try self.session.eventAuthor()) orelse return; 2089 2090 const status: Status = switch (entryStatus(entry)) { 2091 .open => .closed, 2092 .closed => .open, 2093 }; 2094 2095 var id_bytes: [evt.event_id_size]u8 = undefined; 2096 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 2097 2098 switch (src.repo_kind) { 2099 inline else => |repo_kind| { 2100 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2101 defer any_repo.deinit(io, allocator); 2102 switch (any_repo) { 2103 inline else => |*repo| try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .status = status }, author), 2104 } 2105 }, 2106 } 2107 2108 const route = listRoute(self.data.identity, status, "", entry.id) orelse return; 2109 try self.session.navigate(route); 2110 } 2111 2112 // arrow keys move the tag selection; at the flow's edges focus crosses to 2113 // the neighboring widgets. 2114 fn tagsInput(self: *This, index: usize, key: Key, root_focus: *Focus) !void { 2115 const tf = self.tagFlow(index) orelse return; 2116 const cid = tf.focus.child_id orelse return; 2117 const cur = tf.indexOfFocusId(cid) orelse return; 2118 const count = tf.text_boxes.items.len; 2119 switch (key) { 2120 .arrow_left => if (cur > 0) self.focusTag(index, tf, root_focus, cur - 1) else try self.focusList(index, root_focus), 2121 .arrow_right => if (cur + 1 < count) self.focusTag(index, tf, root_focus, cur + 1), 2122 .arrow_up => if (tf.rowStep(cur, false)) |i| { 2123 self.focusTag(index, tf, root_focus, i); 2124 } else { 2125 _ = self.moveDetailVertical(index, root_focus, false); 2126 }, 2127 .arrow_down => if (tf.rowStep(cur, true)) |i| { 2128 self.focusTag(index, tf, root_focus, i); 2129 } else { 2130 _ = self.moveDetailVertical(index, root_focus, true); 2131 }, 2132 .home => self.focusTag(index, tf, root_focus, 0), 2133 .end => self.focusTag(index, tf, root_focus, count - 1), 2134 else => {}, 2135 } 2136 } 2137 2138 const title_child_index: usize = 0; 2139 const tags_child_index: usize = 2; 2140 2141 // the tool row's first child after the spacer that pushes it right 2142 const first_in_row_index: usize = 1; 2143 2144 fn tagFlow(self: *This, index: usize) ?*ui.TagFlow { 2145 const inner = self.detailInner(index); 2146 if (inner.children.count() <= tags_child_index) return null; 2147 return switch (inner.children.values()[tags_child_index].widget) { 2148 .tag_flow => |*tf| tf, 2149 else => null, 2150 }; 2151 } 2152 2153 fn tagsFocused(self: *This, index: usize) bool { 2154 const inner = self.detailInner(index); 2155 const cid = inner.getFocus().child_id orelse return false; 2156 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow(index) != null; 2157 } 2158 2159 fn toolRowFocused(self: *This, index: usize) bool { 2160 const outer = self.detailOuter(index); 2161 const cid = outer.getFocus().child_id orelse return false; 2162 return outer.children.getIndex(cid) == tool_row_index; 2163 } 2164 2165 fn focusTag(self: *This, index: usize, tf: *ui.TagFlow, root_focus: *Focus, item: usize) void { 2166 root_focus.setFocus(tf.text_boxes.items[item].getFocus().id); 2167 // keep the tag visible on the terminal: its rect offset by the flow's 2168 // position in the pane. 2169 if (self.session.is_terminal and item < tf.rects.items.len) { 2170 if (self.detailInner(index).children.values()[tags_child_index].rect) |flow_rect| { 2171 var rect = tf.rects.items[item]; 2172 rect.x += flow_rect.x; 2173 rect.y += flow_rect.y; 2174 self.detailScroll(index).scrollToRect(rect); 2175 } 2176 } 2177 } 2178 2179 fn titleFocused(self: *This, index: usize) bool { 2180 const inner = self.detailInner(index); 2181 const cid = inner.getFocus().child_id orelse return false; 2182 return inner.children.getIndex(cid) == title_child_index; 2183 } 2184 2185 fn authorFocused(self: *This, index: usize) bool { 2186 const inner = self.detailInner(index); 2187 const cid = inner.getFocus().child_id orelse return false; 2188 return cid == self.author_id[index]; 2189 } 2190 2191 fn descriptionFocused(self: *This, index: usize) bool { 2192 const cid = self.detailInner(index).getFocus().child_id orelse return false; 2193 return cid == self.description_id[index]; 2194 } 2195 2196 // return to the tool row's last-focused button (the header when the 2197 // description page shows no row). 2198 fn focusToolRow(self: *This, index: usize, root_focus: *Focus) void { 2199 const cid = self.toolRow(index).getFocus().child_id orelse return self.focusHeader(root_focus); 2200 root_focus.setFocus(cid); 2201 } 2202 2203 // enter the detail pane. an empty pane (no issues) can't be entered. 2204 fn focusDetail(self: *This, index: usize, root_focus: *Focus) !void { 2205 if (self.detailInner(index).children.count() == 0) return; 2206 root_focus.setFocus(self.detailOuter(index).getFocus().id); 2207 } 2208 2209 // return to the list. 2210 fn focusList(self: *This, index: usize, root_focus: *Focus) !void { 2211 root_focus.setFocus(self.listScroll(index).getFocus().id); 2212 } 2213 2214 // cross to the header tabs above the stack. 2215 fn focusHeader(self: *This, root_focus: *Focus) void { 2216 root_focus.setFocus(self.box.children.keys()[header_index]); 2217 } 2218 2219 pub fn clearGrid(self: *This) void { 2220 self.box.clearGrid(); 2221 } 2222 2223 pub fn getGrid(self: This) ?Grid { 2224 return self.box.getGrid(); 2225 } 2226 2227 pub fn getFocus(self: *This) *Focus { 2228 return self.box.getFocus(); 2229 } 2230 2231 // for the parent's "scroll up at the top jumps to the header" check: at the 2232 // top only while the header tabs hold focus, so up from the split 2233 // crosses into the tabs first. 2234 pub fn getSelectedIndex(self: *This) ?usize { 2235 return if (self.headerActive()) 0 else 1; 2236 } 2237 }; 2238 } 2239