╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Threads.zig ──────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
2116 const cur = form.children.getIndex(cid) orelse return; 2117 const child = &form.children.values()[cur]; 2118 2119 if (cur == comment_body_field_index and try multilineKeepsKey(&child.widget.text_input, allocator, key)) 2120 return child.widget.input(allocator, key, root_focus); 2121 2122 switch (key) { 2123 .arrow_up, .back_tab => if (cur > comment_author_field_index) 2124 root_focus.setFocus(form.children.keys()[cur - 1]) 2125 else 2126 self.focusHeader(root_focus), 2127 .arrow_down, .tab => if (cur < comment_submit_field_index) 2128 root_focus.setFocus(form.children.keys()[cur + 1]), 2129 .enter => if (cur == comment_submit_field_index) try self.submitComment(allocator), 2130 .mouse => |mouse| if (cur == comment_submit_field_index) { 2131 const submit = &child.widget.submit_button; 2132 if (inp.leftClickOn(root_focus, submit.buttonId(), mouse)) try self.submitComment(allocator); 2133 }, 2134 else => try child.widget.input(allocator, key, root_focus), 2135 } 2136 } 2137 2138 fn removeFormInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 2139 const form = self.removeForm() orelse return; 2140 const button_id = form.children.keys()[1]; 2141 switch (key) { 2142 .arrow_up, .back_tab => self.focusHeader(root_focus), 2143 .enter => try self.submitRemove(allocator), 2144 .mouse => |mouse| if (inp.leftClickOn(root_focus, button_id, mouse)) try self.submitRemove(allocator), 2145 else => {}, 2146 } 2147 } 2148 2149 // the multiline hunk inputs keep enter and any up/down that has a row to 2150 // move to; enter on a "use this" link is a navigation the host follows 2151 // before this runs. 2152 fn resolveInput(self: *This, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 2153 const form = self.threadForm() orelse return; 2154 const cid = form.getFocus().child_id orelse return; 2155 const cur = form.children.getIndex(cid) orelse return; 2156 const child = &form.children.values()[cur]; 2157 2158 if (child.widget == .text_input) { 2159 const ti = &child.widget.text_input; 2160 if (ti.options.multiline and try multilineKeepsKey(ti, allocator, key)) 2161 return child.widget.input(allocator, key, root_focus); 2162 } 2163 2164 const on_submit = child.widget == .submit_button; 2165 2166 switch (key) { 2167 .arrow_up, .back_tab => if (resolveStep(form, cur, false)) |i| self.focusResolveChild(form, i, root_focus) else self.focusHeader(root_focus), 2168 .arrow_down, .tab => if (resolveStep(form, cur, true)) |i| self.focusResolveChild(form, i, root_focus), 2169 .arrow_left, .arrow_right => switch (child.widget) { 2170 .box => |*row| { 2171 const row_cid = row.getFocus().child_id orelse return; 2172 const row_index = row.children.getIndex(row_cid) orelse return; 2173 if (key == .arrow_left) { 2174 if (row_index > 0) root_focus.setFocus(row.children.keys()[row_index - 1]); 2175 } else if (row_index + 1 < row.children.count()) { 2176 root_focus.setFocus(row.children.keys()[row_index + 1]); 2177 } 2178 }, 2179 else => try child.widget.input(allocator, key, root_focus), 2180 }, 2181 .enter => if (on_submit) try self.submitResolution(allocator), 2182 .mouse => |mouse| if (on_submit) { 2183 if (inp.leftClickOn(root_focus, child.widget.submit_button.buttonId(), mouse)) try self.submitResolution(allocator); 2184 }, 2185 else => try child.widget.input(allocator, key, root_focus), 2186 } 2187 } 2188 2189 // a multiline input keeps enter and any up/down that has a row to move to 2190 fn multilineKeepsKey(text_input: *wgt.TextInput(ui.Widget), allocator: std.mem.Allocator, key: Key) !bool { 2191 return switch (key) { 2192 .enter => true, 2193 .arrow_up => !try text_input.cursorOnFirstRow(allocator), 2194 .arrow_down => !try text_input.cursorOnLastRow(allocator), 2195 else => false, 2196 }; 2197 } 2198 2199 // the neighboring focusable resolve-form child, skipping the spacer and 2200 // the blank gap rows 2201 fn resolveStep(form: *wgt.Box(ui.Widget), cur: usize, down: bool) ?usize { 2202 var i = cur; 2203 while (true) { 2204 if (down) { 2205 i += 1; 2206 if (i >= form.children.count()) return null; 2207 } else { 2208 if (i == 0) return null; 2209 i -= 1; 2210 } 2211 switch (form.children.values()[i].widget) { 2212 .spacer, .text => continue, 2213 else => return i, 2214 } 2215 } 2216 } 2217 2218 // focus the resolve-form child at `i` (a version row focuses its selected 2219 // child) and keep it visible on the terminal 2220 fn focusResolveChild(self: *This, form: *wgt.Box(ui.Widget), i: usize, root_focus: *Focus) void { 2221 const child = &form.children.values()[i]; 2222 switch (child.widget) { 2223 .box => |*row| root_focus.setFocus(row.getFocus().child_id orelse form.children.keys()[i]), 2224 else => root_focus.setFocus(form.children.keys()[i]), 2225 } 2226 if (self.session.is_terminal) { 2227 if (self.resolveScroll()) |sc| { 2228 if (child.rect) |rect| sc.scrollToRect(rect); 2229 } 2230 } 2231 } 2232 2233 // re-emit the conflicted thread's event with the resolved content; any 2234 // event on the thread settles its conflict entry. this is the terminal 2235 // path; the web posts the form to the resolve route. 2236 fn submitResolution(self: *This, allocator: std.mem.Allocator) !void { 2237 if (comptime wasm) return; 2238 const io = self.session.io orelse return; 2239 const src = self.data.repo_source orelse return; 2240 const entry = self.data.selectedThread() orelse return; 2241 const author = (try self.session.eventAuthor()) orelse return; 2242 const form = self.threadForm() orelse return; 2243 2244 // gather the inputs by name; the d<n> hunk inputs appear in chunk order 2245 var title: ?[]u8 = null; 2246 var tags: ?[]u8 = null; 2247 var hunks: std.ArrayList([]const u8) = .empty; 2248 defer { 2249 if (title) |t| allocator.free(t); 2250 if (tags) |t| allocator.free(t); 2251 for (hunks.items) |h| allocator.free(h); 2252 hunks.deinit(allocator); 2253 } 2254 for (form.children.values()) |*child| switch (child.widget) { 2255 .text_input => |*ti| { 2256 const text = try ti.text(allocator); 2257 errdefer allocator.free(text); 2258 if (std.mem.eql(u8, ti.options.name, "title")) { 2259 title = text; 2260 } else if (std.mem.eql(u8, ti.options.name, "tags")) { 2261 tags = text; 2262 } else { 2263 try hunks.append(allocator, text); 2264 } 2265 }, 2266 else => {}, 2267 }; 2268 2269 const id_bytes = try evt.parseEventId(entry.id); 2270 2271 switch (src.repo_kind) { 2272 inline else => |repo_kind| { 2273 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2274 defer any_repo.deinit(io, allocator); 2275 switch (any_repo) { 2276 inline else => |*repo| { 2277 const change: Event.Update = if (comptime kind == .patch) 2278 .{ .resolve = .{ 2279 .title = title, 2280 .tags = tags, 2281 .hunks = hunks.items, 2282 .theirs = self.data.theirs_picks, 2283 } } 2284 else 2285 .{ .resolve = .{ 2286 .title = title, 2287 .tags = tags, 2288 .hunks = hunks.items, 2289 } }; 2290 Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, change, author) catch |err| switch (err) { 2291 // leave the form up for correction 2292 error.InvalidFields => return, 2293 else => |e| return e, 2294 }; 2295 }, 2296 } 2297 }, 2298 } 2299 2300 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 2301 try self.session.navigate(route); 2302 } 2303 2304 // commit a new or edited comment and navigate to its permalink. this is the 2305 // terminal path; the web posts the form to its comment route. 2306 fn submitComment(self: *This, allocator: std.mem.Allocator) !void { 2307 if (comptime wasm) return; 2308 const io = self.session.io orelse return; 2309 const src = self.data.repo_source orelse return; 2310 const author = (try self.session.eventAuthor()) orelse return; 2311 const form = self.threadForm() orelse return; 2312 const body_input = &form.children.values()[comment_body_field_index].widget.text_input; 2313 const body = try body_input.text(allocator); 2314 defer allocator.free(body); 2315 if (!evt.Comment.fieldsValid(body)) return; 2316 2317 var thread_id: [evt.event_id_size * 2]u8 = undefined; 2318 @memcpy(&thread_id, self.data.selected_id); 2319 const parent_id = if (self.data.comment_id.len == 0) thread_id else blk: { 2320 var parent: [evt.event_id_size * 2]u8 = undefined; 2321 @memcpy(&parent, self.data.comment_id); 2322 break :blk parent; 2323 }; 2324 var event_id_hex: [evt.event_id_size * 2]u8 = undefined; 2325 const editing = self.data.view == .edit_comment; 2326 switch (src.repo_kind) { 2327 inline else => |repo_kind| { 2328 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2329 defer any_repo.deinit(io, allocator); 2330 switch (any_repo) { 2331 inline else => |*repo| if (editing) { 2332 const comment_id = evt.parseEventId(self.data.comment_id) catch return; 2333 try evt.Comment.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &comment_id, body, author); 2334 event_id_hex = std.fmt.bytesToHex(comment_id, .lower); 2335 } else { 2336 event_id_hex = try evt.Comment.create(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &parent_id, body, author); 2337 }, 2338 } 2339 }, 2340 } 2341 2342 body_input.clear(allocator); 2343 const route = ui.RoutablePage.repoThreadCommentRoute(kind, self.data.identity, &thread_id, &event_id_hex, 0) orelse return; 2344 try self.session.navigate(route); 2345 } 2346 2347 // the terminal submit paths: the new form commits a new thread event, the 2348 // edit form re-emits the selected thread's event with the form's content. 2349 // the web posts the forms to their routes instead. 2350 fn submitForm(self: *This, allocator: std.mem.Allocator) !void { 2351 if (self.data.view == .edit) { 2352 try self.submitEditedThread(allocator); 2353 } else { 2354 try self.submitNewThread(allocator); 2355 } 2356 } 2357 2358 // commit the new thread to the repo's events branch and navigate to it. 2359 // this is the terminal path; the web posts the form to the thread route, 2360 // so the wasm side never runs (or compiles) the repo access below. 2361 fn submitNewThread(self: *This, allocator: std.mem.Allocator) !void { 2362 if (comptime wasm) return; 2363 const io = self.session.io orelse return; 2364 const src = self.data.repo_source orelse return; 2365 const author = (try self.session.eventAuthor()) orelse return; 2366 2367 const form = self.threadForm() orelse return; 2368 const title_input = &form.children.values()[title_field_index].widget.text_input; 2369 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 2370 const description_input = &form.children.values()[description_field_index].widget.text_input; 2371 2372 const title = try title_input.text(allocator); 2373 defer allocator.free(title); 2374 const tags = try tags_input.text(allocator); 2375 defer allocator.free(tags); 2376 const description = try description_input.text(allocator); 2377 defer allocator.free(description); 2378 2379 if (!Event.fieldsValid(title, tags)) return; 2380 2381 if (comptime kind == .patch) { 2382 const event_id_hex = try Data.createDraft(self.data, self.session, allocator, title, tags, description); 2383 title_input.clear(allocator); 2384 tags_input.clear(allocator); 2385 description_input.clear(allocator); 2386 const route = ui.RoutablePage.repoThreadCommentsRoute(kind, self.data.identity, &event_id_hex, 0) orelse return; 2387 try self.session.navigate(route); 2388 return; 2389 } 2390 2391 var id_bytes: [evt.event_id_size]u8 = undefined; 2392 io.random(&id_bytes); 2393 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 2394 2395 const event = evt.EventWithId{ 2396 .id = event_id_hex, 2397 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 2398 .author = author, 2399 .event = @unionInit(evt.Event, @tagName(kind), Event{ 2400 .title = title, 2401 .description = description, 2402 .tags = tags, 2403 }), 2404 }; 2405 2406 switch (src.repo_kind) { 2407 inline else => |repo_kind| { 2408 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2409 defer any_repo.deinit(io, allocator); 2410 switch (any_repo) { 2411 inline else => |*repo| try evt.consume(.repo, repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &.{event}), 2412 } 2413 }, 2414 } 2415 2416 // wipe the form so a return visit starts fresh 2417 title_input.clear(allocator); 2418 tags_input.clear(allocator); 2419 description_input.clear(allocator); 2420 2421 const route = listRoute(self.data.identity, @enumFromInt(0), "", &event_id_hex) orelse return; 2422 try self.session.navigate(route); 2423 } 2424 2425 // re-emit the selected thread's event with the form's content (status 2426 // preserved), then reload the thread's page. this is the terminal path; 2427 // the web posts the form to the edit route. 2428 fn submitEditedThread(self: *This, allocator: std.mem.Allocator) !void { 2429 if (comptime wasm) return; 2430 const io = self.session.io orelse return; 2431 const src = self.data.repo_source orelse return; 2432 const entry = self.data.selectedThread() orelse return; 2433 const author = (try self.session.eventAuthor()) orelse return; 2434 2435 const form = self.threadForm() orelse return; 2436 const title_input = &form.children.values()[title_field_index].widget.text_input; 2437 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 2438 const description_input = &form.children.values()[description_field_index].widget.text_input; 2439 2440 const title = try title_input.text(allocator); 2441 defer allocator.free(title); 2442 const tags = try tags_input.text(allocator); 2443 defer allocator.free(tags); 2444 const description = try description_input.text(allocator); 2445 defer allocator.free(description); 2446 2447 if (!Event.fieldsValid(title, tags)) return; 2448 2449 const id_bytes = try evt.parseEventId(entry.id); 2450 2451 switch (src.repo_kind) { 2452 inline else => |repo_kind| { 2453 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2454 defer any_repo.deinit(io, allocator); 2455 switch (any_repo) { 2456 inline else => |*repo| if (has_status) 2457 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .fields = .{ 2458 .title = title, 2459 .tags = tags, 2460 .description = description, 2461 } }, author) 2462 else 2463 try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, title, tags, description, author), 2464 } 2465 }, 2466 } 2467 2468 const route = listRoute(self.data.identity, entryStatus(entry.*), "", entry.id) orelse return; 2469 try self.session.navigate(route); 2470 } 2471 2472 fn submitRemove(self: *This, allocator: std.mem.Allocator) !void { 2473 const comment = self.data.comment_id.len != 0; 2474 const event_id = if (comment) self.data.comment_id else self.data.selected_id; 2475 const id = evt.parseEventId(event_id) catch return; 2476 try self.removeEvent(allocator, if (comment) .comment else kind, id); 2477 } 2478 2479 fn removeEvent(self: *This, allocator: std.mem.Allocator, event_kind: evt.EventKind, id: [evt.event_id_size]u8) !void { 2480 if (comptime wasm) return; 2481 const io = self.session.io orelse return; 2482 const src = self.data.repo_source orelse return; 2483 const author = (try self.session.eventAuthor()) orelse return; 2484 2485 switch (src.repo_kind) { 2486 inline else => |repo_kind| { 2487 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2488 defer any_repo.deinit(io, allocator); 2489 switch (any_repo) { 2490 inline else => |*repo| try evt.remove(.repo, repo_kind, repo.self_repo_opts, io, allocator, repo, &id, event_kind, author), 2491 } 2492 }, 2493 } 2494 2495 const id_hex = std.fmt.bytesToHex(id, .lower); 2496 const route = ui.RoutablePage.repoEventsRoute(self.data.identity, .removed, event_kind, &id_hex) orelse return; 2497 try self.session.navigate(route); 2498 } 2499 2500 // flip the shown thread's status by re-emitting its event, then reload the 2501 // page rooted at the thread so the view reflects the change. this is the 2502 // terminal path; the web posts the button's form to the status route. 2503 fn toggleStatus(self: *This, allocator: std.mem.Allocator, index: usize) !void { 2504 if (comptime !has_status) return; 2505 if (comptime wasm) return; 2506 const io = self.session.io orelse return; 2507 const src = self.data.repo_source orelse return; 2508 const sel = self.detailed_index[index] orelse return; 2509 const entry = self.window(index).items[sel]; 2510 const author = (try self.session.eventAuthor()) orelse return; 2511 2512 const status = (statusChange(entryStatus(entry)) orelse return).status; 2513 2514 const id_bytes = try evt.parseEventId(entry.id); 2515 2516 switch (src.repo_kind) { 2517 inline else => |repo_kind| { 2518 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); 2519 defer any_repo.deinit(io, allocator); 2520 switch (any_repo) { 2521 inline else => |*repo| try Event.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .status = status }, author), 2522 } 2523 }, 2524 } 2525 2526 const route = listRoute(self.data.identity, status, "", entry.id) orelse return; 2527 try self.session.navigate(route); 2528 } 2529 2530 // arrow keys move the tag selection; at the flow's edges focus crosses to 2531 // the neighboring widgets. 2532 fn tagsInput(self: *This, index: usize, child_index: usize, tf: *ui.TagFlow, key: Key, root_focus: *Focus) void { 2533 const cid = tf.focus.child_id orelse return; 2534 const cur = tf.indexOfFocusId(cid) orelse return; 2535 const count = tf.text_boxes.items.len; 2536 switch (key) { 2537 .arrow_left => if (cur > 0) self.focusTag(index, child_index, tf, root_focus, cur - 1) else self.focusList(index, root_focus), 2538 .arrow_right => if (cur + 1 < count) self.focusTag(index, child_index, tf, root_focus, cur + 1), 2539 .arrow_up => if (tf.rowStep(cur, false)) |i| { 2540 self.focusTag(index, child_index, tf, root_focus, i); 2541 } else { 2542 _ = self.moveDetailVertical(index, root_focus, false); 2543 }, 2544 .arrow_down => if (tf.rowStep(cur, true)) |i| { 2545 self.focusTag(index, child_index, tf, root_focus, i); 2546 } else { 2547 _ = self.moveDetailVertical(index, root_focus, true); 2548 }, 2549 .home => self.focusTag(index, child_index, tf, root_focus, 0), 2550 .end => self.focusTag(index, child_index, tf, root_focus, count - 1), 2551 else => {}, 2552 } 2553 } 2554 2555 // the tool row's first child after the spacer that pushes it right 2556 const first_in_row_index: usize = 1; 2557 2558 fn toolRowFocused(self: *This, index: usize) bool { 2559 const outer = self.detailOuter(index); 2560 const cid = outer.getFocus().child_id orelse return false; 2561 return outer.children.getIndex(cid) == tool_row_index; 2562 } 2563 2564 fn focusTag(self: *This, index: usize, child_index: usize, tf: *ui.TagFlow, root_focus: *Focus, item: usize) void { 2565 root_focus.setFocus(tf.text_boxes.items[item].getFocus().id); 2566 // keep the tag visible on the terminal: its rect offset by the flow's 2567 // position in the pane. 2568 if (self.session.is_terminal and item < tf.rects.items.len) { 2569 if (self.detailInner(index).children.values()[child_index].rect) |flow_rect| { 2570 var rect = tf.rects.items[item]; 2571 rect.x += flow_rect.x; 2572 rect.y += flow_rect.y; 2573 self.detailScroll(index).scrollToRect(rect); 2574 } 2575 } 2576 } 2577 2578 // return to the tool row's last-focused button (the header when the 2579 // description page shows no row). 2580 fn focusToolRow(self: *This, index: usize, root_focus: *Focus) void { 2581 const cid = self.toolRow(index).getFocus().child_id orelse return self.focusHeader(root_focus); 2582 root_focus.setFocus(cid); 2583 } 2584 2585 // enter the detail pane. an empty pane can't be entered. 2586 fn focusDetail(self: *This, index: usize, root_focus: *Focus) void { 2587 if (self.detailInner(index).children.count() == 0) return; 2588 root_focus.setFocus(self.detailOuter(index).getFocus().id); 2589 } 2590 2591 // return to the list. 2592 fn focusList(self: *This, index: usize, root_focus: *Focus) void { 2593 root_focus.setFocus(self.listScroll(index).getFocus().id); 2594 } 2595 2596 // cross to the header tabs above the stack. 2597 fn focusHeader(self: *This, root_focus: *Focus) void { 2598 root_focus.setFocus(self.box.children.keys()[header_index]); 2599 } 2600 2601 pub fn clearGrid(self: *This) void { 2602 self.box.clearGrid(); 2603 } 2604 2605 pub fn getGrid(self: This) ?Grid { 2606 return self.box.getGrid(); 2607 } 2608 2609 pub fn getFocus(self: *This) *Focus { 2610 return self.box.getFocus(); 2611 } 2612 2613 // for the parent's "scroll up at the top jumps to the header" check: at the 2614 // top only while the header tabs hold focus, so up from the split 2615 // crosses into the tabs first. 2616 pub fn getSelectedIndex(self: *This) ?usize { 2617 return if (self.headerActive()) 0 else 1; 2618 } 2619 }; 2620 } 2621