╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ Issues.zig ───────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
172 return empty; 173 }; 174 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); 175 176 // a named issue roots its own status's window at itself; the other window 177 // starts at the beginning. 178 var resolved_view = empty.view; 179 var open_root: ?[]const u8 = null; 180 var closed_root: ?[]const u8 = null; 181 if (rooted) { 182 if (empty.selected_id.len != evt.event_id_size * 2) return error.NotFound; 183 var id_bytes: [evt.event_id_size]u8 = undefined; 184 _ = std.fmt.hexToBytes(&id_bytes, empty.selected_id) catch return error.NotFound; 185 const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &id_bytes)) orelse return error.NotFound; 186 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 187 const issue_event = try evt.read(evt.Issue.Record, DB, repo_opts.hash, arena, issue_map); 188 const order_key = try aa.dupe(u8, &evt.orderKeyDesc(issue_event.created_ts, &id_bytes)); 189 190 // the named issue must be in its windowed set (a tag url can name an 191 // issue that doesn't carry the tag). 192 const set = (switch (issue_event.event.status) { 193 .open => open_set, 194 .closed => closed_set, 195 }) orelse return error.NotFound; 196 if (!try set.contains(order_key)) return error.NotFound; 197 198 switch (issue_event.event.status) { 199 .open => open_root = order_key, 200 .closed => closed_root = order_key, 201 } 202 // an edit url keeps its view; otherwise the issue's status picks it. 203 if (empty.view != .edit) resolved_view = switch (issue_event.event.status) { 204 .open => .open, 205 .closed => .closed, 206 }; 207 } 208 209 const open_window = try loadWindow(repo_opts.hash, arena, admin_moment, event_id_to_issue, open_set, open_root); 210 const closed_window = try loadWindow(repo_opts.hash, arena, admin_moment, event_id_to_issue, closed_set, closed_root); 211 212 // every tag in the repo, in the tag map's sorted order. the keys are 213 // "tag,status", so a tag's entries are adjacent and dedup by prefix. 214 var tags: std.ArrayList([]const u8) = .empty; 215 if (try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set"))) |tag_to_issues_cursor| { 216 const tag_to_issues = try DB.SortedMap(.read_only).init(tag_to_issues_cursor); 217 var tag_iter = try tag_to_issues.iterator(); 218 while (try tag_iter.next()) |kv_pair_cursor| { 219 if (tags.items.len == max_tags) break; 220 var kv_cursor = kv_pair_cursor; 221 const kv_pair = try kv_cursor.readKeyValuePair(); 222 const key = try kv_pair.key_cursor.readBytesAlloc(aa, null); 223 const space = std.mem.indexOfScalar(u8, key, ' ') orelse continue; 224 const key_tag = key[0..space]; 225 if (tags.getLastOrNull()) |last| { 226 if (std.mem.eql(u8, last, key_tag)) continue; 227 } 228 try tags.append(aa, key_tag); 229 } 230 } 231 232 return .{ 233 .identity = empty.identity, 234 .tag = empty.tag, 235 .selected_id = empty.selected_id, 236 .open = open_window, 237 .closed = closed_window, 238 .view = resolved_view, 239 .description_page = empty.description_page, 240 .tags = tags.items, 241 }; 242 } 243 244 // `status`'s sorted set within `statuses` (null when the status has no issues) 245 fn statusSet( 246 comptime DB: type, 247 statuses: DB.SortedMap(.read_only), 248 status: evt.Issue.Status, 249 ) !?DB.SortedSet(.read_only) { 250 const cursor = (try statuses.getCursor(@tagName(status))) orelse return null; 251 return try DB.SortedSet(.read_only).init(cursor); 252 } 253 254 // the set at `tag_to_issues`'s "tag,status" key (null when no issue carries 255 // `tag` with `status`, or the tag is too long to exist) 256 fn tagStatusSet( 257 comptime DB: type, 258 tag_to_issues: DB.SortedMap(.read_only), 259 tag: []const u8, 260 status: evt.Issue.Status, 261 ) !?DB.SortedSet(.read_only) { 262 var key_buffer: evt.Issue.TagStatusKey = undefined; 263 const key = evt.Issue.tagStatusKey(&key_buffer, tag, status) catch return null; 264 const cursor = (try tag_to_issues.getCursor(key)) orelse return null; 265 return try DB.SortedSet(.read_only).init(cursor); 266 } 267 268 // read one window of `set_maybe` (null = an empty listing) starting at 269 // `root_key` (null = the beginning) 270 fn loadWindow( 271 comptime hash_kind: hash.HashKind, 272 arena: *std.heap.ArenaAllocator, 273 admin_moment: ?evt.AdminDB.HashMap(.read_only), 274 event_id_to_issue: evt.EventDB(hash_kind).HashMap(.read_only), 275 set_maybe: ?evt.EventDB(hash_kind).SortedSet(.read_only), 276 root_key: ?[]const u8, 277 ) !Window { 278 const set = set_maybe orelse return .empty; 279 const DB = evt.EventDB(hash_kind); 280 const aa = arena.allocator(); 281 282 // seek once to the window start: the root key, or the set's first entry. 283 var prev_id: ?[]const u8 = null; 284 var iter = if (root_key) |key| blk: { 285 // the previous window starts page_size ranks back ("" = the first 286 // window, linked as the bare list route). 287 const rank = try set.rank(key); 288 if (rank > 0 and rank <= page_size) { 289 prev_id = ""; 290 } else if (rank > page_size) { 291 const kv = try set.getIndexKeyValuePair(@intCast(rank - page_size)) orelse return error.NotFound; 292 var prev_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 293 _ = try kv.key_cursor.readBytes(&prev_key); 294 const prev_hex = std.fmt.bytesToHex(prev_key[@sizeOf(u64)..].*, .lower); 295 prev_id = try aa.dupe(u8, &prev_hex); 296 } 297 break :blk try set.iteratorFrom(key); 298 } else try set.iteratorFromIndex(0); 299 300 // collect this window's issues, plus a peek at the one after it (its id is 301 // the next window's start). the trailing bytes of each set key are the 302 // issue event id. 303 var issues: std.ArrayList(IssueWithId) = .empty; 304 var next_id: ?[]const u8 = null; 305 while (try iter.next()) |id_cursor_val| { 306 var id_cursor = id_cursor_val; 307 const id_kv = try id_cursor.readKeyValuePair(); 308 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 309 _ = try id_kv.key_cursor.readBytes(&order_key); 310 const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 311 if (issues.items.len == page_size) { 312 next_id = try aa.dupe(u8, &id_hex); 313 break; 314 } 315 const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(hash_kind, order_key[@sizeOf(u64)..])) orelse continue; 316 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 317 const issue_event = try evt.read(evt.Issue.Record, DB, hash_kind, arena, issue_map); 318 try issues.append(aa, .{ 319 .id = try aa.dupe(u8, &id_hex), 320 .issue = issue_event, 321 .author = try ui.Author.initFromEmail(admin_moment, arena, issue_event.author_email), 322 }); 323 } 324 325 return .{ 326 .issues = issues.items, 327 .prev_id = prev_id, 328 .next_id = next_id, 329 .count = @intCast(try set.count()), 330 }; 331 } 332 333 pub const View = struct { 334 // a vertical box: the header tabs on top, then a stack holding a 335 // master-detail split (issue list + description pane) per status list, 336 // plus the tags view. 337 box: wgt.Box(ui.Widget), // vert: [header_index] = tabs, [stack_index] = stack 338 data: *const Self, 339 session: *ui.Session, 340 // per-split state, indexed like the stack's split children: the issue the 341 // pane shows, and its description and author text boxes' focus ids. 342 detailed_index: [split_count]?usize, 343 description_id: [split_count]?usize, 344 author_id: [split_count]?usize, 345 346 const header_index: usize = 0; 347 const stack_index: usize = 1; 348 // indices within the stack, 1:1 with the header tabs. 349 const open_view_index: usize = 0; 350 const closed_view_index: usize = 1; 351 const tags_view_index: usize = 2; 352 // the new-issue form, or the edit form when the page was loaded at an 353 // edit url. 354 const form_view_index: usize = 3; 355 const split_count: usize = 2; 356 // indices within a split (the horizontal box inside the stack). 357 const list_index: usize = 0; 358 const detail_index: usize = 1; 359 const list_max_width: usize = 40; 360 const detail_min_width: usize = 40; 361 // indices within the new-issue form. 362 const title_field_index: usize = 0; 363 const tags_field_index: usize = 1; 364 const description_field_index: usize = 2; 365 const submit_field_index: usize = 3; 366 367 fn viewIndex(view: ui.RoutablePage.IssuesView) usize { 368 return switch (view) { 369 .open => open_view_index, 370 .closed => closed_view_index, 371 .tags => tags_view_index, 372 .new, .edit => form_view_index, 373 // init resolves a description url to its issue's status list 374 .description => unreachable, 375 }; 376 } 377 378 fn splitStatus(index: usize) evt.Issue.Status { 379 return if (index == open_view_index) .open else .closed; 380 } 381 382 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { 383 var outer = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 384 errdefer outer.deinit(allocator); 385 386 // the tabs at the top. 387 { 388 var hdr = try Header.init(allocator, session, data); 389 errdefer hdr.deinit(allocator); 390 try outer.children.put(allocator, hdr.getFocus().id, .{ .widget = .{ .repo_issues_header = hdr }, .rect = null, .min_size = null }); 391 } 392 393 // the stack enters `outer` before its children enter it, so an error 394 // frees each child exactly once. 395 { 396 var stack = try wgt.Stack(ui.Widget).init(allocator); 397 errdefer stack.deinit(allocator); 398 try outer.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null }); 399 } 400 const stack = &outer.children.values()[stack_index].widget.stack; 401 402 // a master-detail split per status list. 403 for ([_]evt.Issue.Status{ .open, .closed }) |status| { 404 var split = try initSplit(allocator, session, data, status); 405 errdefer split.deinit(allocator); 406 try stack.children.put(allocator, split.getFocus().id, .{ .box = split }); 407 } 408 409 // the tags view 410 { 411 var tf = try ui.TagFlow.init(allocator); 412 errdefer tf.deinit(allocator); 413 var items: std.ArrayList(ui.TagFlow.Item) = .empty; 414 defer items.deinit(allocator); 415 // when filtered, the first item clears the filter 416 if (data.tag.len != 0) 417 try items.append(allocator, .{ .text = "✕", .link = try issuesLink(session.page_arena, data.identity, .open, "", "") }); 418 for (data.tags) |tag| 419 try items.append(allocator, .{ .text = tag, .link = try tagLink(session.page_arena, data.identity, .open, tag) }); 420 try tf.setItems(allocator, items.items); 421 try stack.children.put(allocator, tf.getFocus().id, .{ .tag_flow = tf }); 422 } 423 424 // the new-issue form, or — on an edit url — the edit form in its 425 // place, prefilled with the selected issue's content. 426 { 427 const aa = session.page_arena.allocator(); 428 const action = if (data.view == .edit) 429 (if (data.identity.len == 0) 430 try std.fmt.allocPrint(aa, "form:/issues/{s}/edit", .{data.selected_id}) 431 else 432 try std.fmt.allocPrint(aa, "form:/repo/{s}/issues/{s}/edit", .{ data.identity, data.selected_id })) 433 else if (data.identity.len == 0) 434 "form:/issue" 435 else 436 try std.fmt.allocPrint(aa, "form:/repo/{s}/issue", .{data.identity}); 437 const issue = if (data.view == .edit) 438 (if (data.selectedIssue()) |entry| &entry.issue else null) 439 else 440 null; 441 var form = try initIssueForm(allocator, action, issue); 442 errdefer form.deinit(allocator); 443 try stack.children.put(allocator, form.getFocus().id, .{ .box = form }); 444 } 445 446 // the stack starts on the page's view. 447 stack.getFocus().child_id = stack.children.keys()[viewIndex(data.view)]; 448 449 // focus entering the view lands on the tabs first. 450 outer.getFocus().child_id = outer.children.keys()[header_index]; 451 452 return .{ 453 .box = outer, 454 .data = data, 455 .session = session, 456 .detailed_index = .{ null, null }, 457 .description_id = .{ null, null }, 458 .author_id = .{ null, null }, 459 }; 460 } 461 462 // the master-detail split showing `status`'s window. 463 fn initSplit(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self, status: evt.Issue.Status) !wgt.Box(ui.Widget) { 464 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 465 errdefer box.deinit(allocator); 466 467 const win = data.window(status); 468 469 // the issue list (one focusable row per title), plus a "next" link that 470 // reloads the page rooted at the following issue. 471 { 472 var list_scroll = blk: { 473 var list_box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 474 errdefer list_box.deinit(allocator); 475 if (win.prev_id) |prev| 476 try addRow(allocator, &list_box, "← previous", try issuesLink(session.page_arena, data.identity, status, data.tag, prev)); 477 for (win.issues) |entry| 478 try addRow(allocator, &list_box, entry.issue.event.title, try issueRowLink(session.page_arena, data.identity, entry.id)); 479 if (win.next_id) |next| 480 try addRow(allocator, &list_box, "next →", try issuesLink(session.page_arena, data.identity, status, data.tag, next)); 481 // select the window's first issue (past a leading "previous" 482 // row) so its description shows on load. 483 if (win.issues.len > 0) 484 list_box.getFocus().child_id = list_box.children.keys()[if (win.prev_id != null) 1 else 0] 485 else if (list_box.children.count() > 0) 486 list_box.getFocus().child_id = list_box.children.keys()[0]; 487 break :blk try wgt.Scroll(ui.Widget).init(allocator, .{ .box = list_box }, .{ .direction = .vert, .web_native = !session.is_terminal }); 488 }; 489 errdefer list_scroll.deinit(allocator); 490 try box.children.put(allocator, list_scroll.getFocus().id, .{ .widget = .{ .scroll = list_scroll }, .rect = null, .min_size = .{ .width = list_max_width, .height = null }, .max_size = .{ .width = list_max_width, .height = null } }); 491 } 492 493 // the detail pane — a frame around a scroll of the description 494 { 495 var detail_outer = blk: { 496 var detail_scroll = blk2: { 497 var detail_inner = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 498 errdefer detail_inner.deinit(allocator); 499 // fill the pane (content top-left, scroll bar pinned to the 500 // edge) rather than shrinking to the description. 501 break :blk2 try wgt.Scroll(ui.Widget).init(allocator, .{ .box = detail_inner }, .{ .direction = .vert, .web_native = !session.is_terminal, .fill = true }); 502 }; 503 errdefer detail_scroll.deinit(allocator); 504 var frame = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = .hidden, .direction = .vert }); 505 errdefer frame.deinit(allocator); 506 // the tool row sits above the scroll (populateDetail fills 507 // it per issue) so it can't scroll out from under the web 508 // overlay <form>, whose position doesn't track pane scrolling. 509 { 510 var row = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 511 errdefer row.deinit(allocator); 512 try frame.children.put(allocator, row.getFocus().id, .{ .widget = .{ .box = row }, .rect = null, .min_size = null }); 513 } 514 // the frame's selected child is its scroll, so the focus chain 515 // reaches the description (populateDetail points the scroll's 516 // inner box at it), letting focus recovery descend into the pane. 517 frame.getFocus().child_id = detail_scroll.getFocus().id; 518 try frame.children.put(allocator, detail_scroll.getFocus().id, .{ .widget = .{ .scroll = detail_scroll }, .rect = null, .min_size = null }); 519 break :blk frame; 520 }; 521 errdefer detail_outer.deinit(allocator); 522 try box.children.put(allocator, detail_outer.getFocus().id, .{ .widget = .{ .box = detail_outer }, .rect = null, .min_size = .{ .width = detail_min_width, .height = null } }); 523 } 524 525 box.getFocus().child_id = box.children.keys()[list_index]; 526 return box; 527 } 528 529 // an issue form: title/tags/description inputs and a submit button, 530 // prefilled from `issue` when given. its form: subtree makes the web 531 // overlay wrap them in a <form> POSTing to `action`'s route. 532 fn initIssueForm(allocator: std.mem.Allocator, action: []const u8, issue: ?*const evt.Issue.Record) !wgt.Box(ui.Widget) { 533 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .vert }); 534 errdefer box.deinit(allocator); 535 box.getFocus().kind = .{ .custom = action }; 536 537 { 538 var title = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " title ", .name = "title", .visible_width = null, .rounded_corners = true, .render_content = !wasm }); 539 errdefer title.deinit(allocator); 540 title.getFocus().focusable = true; 541 if (issue) |i| try title.setContent(allocator, i.event.title); 542 try box.children.put(allocator, title.getFocus().id, .{ .widget = .{ .text_input = title }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 543 } 544 545 { 546 var tags = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " tags (separate with spaces) ", .name = "tags", .visible_width = null, .rounded_corners = true, .render_content = !wasm }); 547 errdefer tags.deinit(allocator); 548 tags.getFocus().focusable = true; 549 if (issue) |i| try tags.setContent(allocator, i.event.tags); 550 try box.children.put(allocator, tags.getFocus().id, .{ .widget = .{ .text_input = tags }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 551 } 552 553 { 554 var description = try wgt.TextInput(ui.Widget).init(allocator, .{ .label = " description ", .name = "description", .visible_width = null, .rounded_corners = true, .render_content = !wasm, .multiline = true, .scroll = .{ .fill = true } }); 555 errdefer description.deinit(allocator); 556 description.getFocus().focusable = true; 557 if (issue) |i| try description.setContent(allocator, i.event.description); 558 try box.children.put(allocator, description.getFocus().id, .{ .widget = .{ .text_input = description }, .rect = null, .min_size = null }); 559 } 560 561 { 562 var button = try wgt.TextBox(ui.Widget).init(allocator, "submit", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 563 errdefer button.deinit(allocator); 564 button.getFocus().focusable = true; 565 // the renderer distinguishes plain clickables from buttons that 566 // should POST to a server route by this kind. 567 button.getFocus().kind = .{ .custom = "submit" }; 568 try box.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = null, .height = 3 } }); 569 } 570 571 // absorbs the leftover min-height the box hands its last child, so 572 // the button keeps its natural height 573 { 574 var spacer = try ui.Spacer.init(allocator); 575 errdefer spacer.deinit(allocator); 576 try box.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 577 } 578 579 box.getFocus().child_id = box.children.keys()[title_field_index]; 580 return box; 581 } 582 583 fn addRow(allocator: std.mem.Allocator, box: *wgt.Box(ui.Widget), label: []const u8, link: []const u8) !void { 584 var row = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .hidden, .rounded_corners = true, .wrap_kind = .word }); 585 errdefer row.deinit(allocator); 586 row.getFocus().focusable = true; 587 if (link.len != 0) row.getFocus().kind = .{ .custom = link }; 588 try box.children.put(allocator, row.getFocus().id, .{ .widget = .{ .text_box = row }, .rect = null, .min_size = null, .max_size = .{ .width = null, .height = 5 } }); 589 } 590 591 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 592 self.box.deinit(allocator); 593 } 594 595 fn header(self: *View) *Header { 596 return &self.box.children.values()[header_index].widget.repo_issues_header; 597 } 598 599 fn viewStack(self: *View) *wgt.Stack(ui.Widget) { 600 return &self.box.children.values()[stack_index].widget.stack; 601 } 602 603 // `index`'s master-detail split inside the stack. 604 fn resultsBox(self: *View, index: usize) *wgt.Box(ui.Widget) { 605 return &self.viewStack().children.values()[index].box; 606 } 607 608 // the tags view's flow inside the stack. 609 fn tagsView(self: *View) *ui.TagFlow { 610 return &self.viewStack().children.values()[tags_view_index].tag_flow; 611 } 612 613 // the new-issue (or edit) form inside the stack. 614 fn issueForm(self: *View) *wgt.Box(ui.Widget) { 615 return &self.viewStack().children.values()[form_view_index].box; 616 } 617 618 fn listScroll(self: *View, index: usize) *wgt.Scroll(ui.Widget) { 619 return &self.resultsBox(index).children.values()[list_index].widget.scroll; 620 } 621 622 fn listBox(self: *View, index: usize) *wgt.Box(ui.Widget) { 623 return &self.listScroll(index).child.box; 624 } 625 626 // the detail frame's children: the tool row above the scroll pane. 627 const tool_row_index: usize = 0; 628 const detail_scroll_index: usize = 1; 629 630 fn detailOuter(self: *View, index: usize) *wgt.Box(ui.Widget) { 631 return &self.resultsBox(index).children.values()[detail_index].widget.box; 632 } 633 634 fn toolRow(self: *View, index: usize) *wgt.Box(ui.Widget) { 635 return &self.detailOuter(index).children.values()[tool_row_index].widget.box; 636 } 637 638 fn detailScroll(self: *View, index: usize) *wgt.Scroll(ui.Widget) { 639 return &self.detailOuter(index).children.values()[detail_scroll_index].widget.scroll; 640 } 641 642 fn detailInner(self: *View, index: usize) *wgt.Box(ui.Widget) { 643 return &self.detailScroll(index).child.box; 644 } 645 646 fn window(self: *View, index: usize) *const Window { 647 return self.data.window(splitStatus(index)); 648 } 649 650 fn stackSelectedIndex(self: *View) ?usize { 651 const stack = self.viewStack(); 652 const cid = stack.getFocus().child_id orelse return null; 653 return stack.children.getIndex(cid); 654 } 655 656 // the stack's selected master-detail split (null when the tags view or the 657 // new-issue form shows). 658 fn selectedSplitIndex(self: *View) ?usize { 659 const idx = self.stackSelectedIndex() orelse return null; 660 return if (idx < split_count) idx else null; 661 } 662 663 fn detailActive(self: *View, index: usize) bool { 664 const rb = self.resultsBox(index); 665 const cid = rb.getFocus().child_id orelse return false; 666 return rb.children.getIndex(cid) == detail_index; 667 } 668 669 fn headerActive(self: *View) bool { 670 const cid = self.box.getFocus().child_id orelse return false; 671 return self.box.children.getIndex(cid) == header_index; 672 } 673 674 fn tagsViewActive(self: *View) bool { 675 if (self.headerActive()) return false; 676 return self.stackSelectedIndex() == tags_view_index; 677 } 678 679 fn formViewActive(self: *View) bool { 680 if (self.headerActive()) return false; 681 return self.stackSelectedIndex() == form_view_index; 682 } 683 684 // the selected issue's index, or null when a window-navigation row is 685 // selected (a leading "previous" row shifts the issue rows down by one). 686 fn selectedIssueIndex(self: *View, index: usize) ?usize { 687 const lb = self.listBox(index); 688 const cid = lb.getFocus().child_id orelse return null; 689 const idx = lb.children.getIndex(cid) orelse return null; 690 const win = self.window(index); 691 const lead: usize = if (win.prev_id != null) 1 else 0; 692 if (idx < lead or idx - lead >= win.issues.len) return null; 693 return idx - lead; 694 } 695 696 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 697 self.clearGrid(); 698 699 // each header tab maps 1:1 to a stack child by position. mirror the 700 // selection into the url: the view the page's window is rooted at 701 // keeps its rooted url, the others get their list route (keeping the 702 // tag filter). 703 if (self.header().getSelectedIndex()) |index| { 704 const stack = self.viewStack(); 705 stack.getFocus().child_id = stack.children.keys()[index]; 706 self.session.data.current_page = (if (index == form_view_index and self.data.view == .edit) 707 ui.RoutablePage.repoIssuesEditRoute(self.data.identity, self.data.selected_id) 708 else if (index == viewIndex(self.data.view) and self.data.selected_id.len != 0) 709 (if (self.data.description_page) 710 ui.RoutablePage.repoIssuesDescriptionRoute(self.data.identity, self.data.selected_id) 711 else 712 ui.RoutablePage.repoIssuesRoute(self.data.identity, .open, self.data.tag, self.data.selected_id)) 713 else if (index == tags_view_index) 714 ui.RoutablePage.repoIssuesTagsRoute(self.data.identity, self.data.tag) 715 else if (index == form_view_index) 716 ui.RoutablePage.repoIssuesNewRoute(self.data.identity) 717 else 718 ui.RoutablePage.repoIssuesRoute(self.data.identity, splitStatus(index), self.data.tag, "")) orelse self.session.data.current_page; 719 } 720 721 if (self.selectedSplitIndex()) |i| { 722 // swap the detail pane to the selected issue when it changes. 723 try self.refreshDetail(allocator, i); 724 725 // mirror the focused issue into the url, but only while focus is 726 // inside the split. an issue's url is the same whether or not the 727 // list is filtered, so the mirror drops the tag. 728 if (root_focus.grandchild_id) |g| { 729 if (self.resultsBox(i).getFocus().children.contains(g)) { 730 if (self.selectedIssueIndex(i)) |sel| { 731 // selecting another issue leaves the description page 732 // behind, like it leaves the tag filter behind. 733 const id = self.window(i).issues[sel].id; 734 const mirrored = if (self.data.description_page and std.mem.eql(u8, id, self.data.selected_id)) 735 ui.RoutablePage.repoIssuesDescriptionRoute(self.data.identity, id) 736 else 737 ui.RoutablePage.repoIssuesRoute(self.data.identity, splitStatus(i), "", id); 738 if (mirrored) |route| self.session.data.current_page = route; 739 } 740 } 741 } 742 743 // the selected list row shows a border (the focused TextBox 744 // upgrades it to a double border itself); the rest stay borderless. 745 const lb = self.listBox(i); 746 for (lb.children.keys(), lb.children.values()) |id, *child| { 747 switch (child.widget) { 748 .text_box => |*tb| tb.options.border_style = if (lb.getFocus().child_id == id) .single else .hidden, 749 else => {}, 750 } 751 } 752 753 // the pane's selected child shows the selection border: the 754 // description directly, the tags via the flow's selected item. 755 // cap the list at list_max_width only while the detail pane fits 756 // beside it. the box drops the detail when the width can't hold 757 // both minimums, so when it's that narrow we lift the cap and let 758 // the list fill the whole width. 759 const both_panes_fit = if (constraint.max_size.width) |w| w >= list_max_width + detail_min_width else true; 760 self.resultsBox(i).children.values()[list_index].max_size = if (both_panes_fit) .{ .width = list_max_width, .height = null } else null; 761 762 // stretch the detail pane across the rest of the width so it fills 763 // the area rather than shrinking to its content; its scroll fills 764 // the pane. 765 if (constraint.max_size.width) |w| { 766 self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = if (both_panes_fit) w - list_max_width else w, .height = null }; 767 } else { 768 self.resultsBox(i).children.values()[detail_index].min_size = .{ .width = detail_min_width, .height = null }; 769 } 770 } 771 772 // refresh the form inputs' entries in the session's focus-id -> input 773 // map with this frame's addresses, so the web/wasm form handling can 774 // find them by focus id 775 const form = self.issueForm(); 776 const inputs_arena = self.session.arena.allocator(); 777 for (form.children.values()) |*child| switch (child.widget) { 778 .text_input => |*ti| try self.session.text_inputs.put(inputs_arena, ti.getFocus().id, ti), 779 else => {}, 780 }; 781 782 // on an edit page, register the issue's content as the inputs' 783 // page-constant initial values, which the web overlay renders 784 // into them. 785 if (self.data.view == .edit) { 786 if (self.data.selectedIssue()) |entry| { 787 const fields = form.children.values(); 788 try self.session.input_values.put(inputs_arena, fields[title_field_index].widget.text_input.getFocus().id, entry.issue.event.title); 789 try self.session.input_values.put(inputs_arena, fields[tags_field_index].widget.text_input.getFocus().id, entry.issue.event.tags); 790 try self.session.input_values.put(inputs_arena, fields[description_field_index].widget.text_input.getFocus().id, entry.issue.event.description); 791 } 792 } 793 794 try self.box.build(allocator, constraint, root_focus); 795 } 796 797 fn refreshDetail(self: *View, allocator: std.mem.Allocator, index: usize) !void { 798 const sel = self.selectedIssueIndex(index) orelse return; 799 if (self.detailed_index[index]) |d| if (d == sel) return; 800 try self.populateDetail(allocator, index, sel); 801 self.detailed_index[index] = sel; 802 } 803 804 fn populateDetail(self: *View, allocator: std.mem.Allocator, index: usize, sel: usize) !void { 805 const entry = self.window(index).issues[sel]; 806 const inner = self.detailInner(index); 807 // the /description page replaces its issue's detail with a back link 808 // and the whole description; other issues keep their normal detail. 809 const description_page = self.data.description_page and std.mem.eql(u8, entry.id, self.data.selected_id); 810 811 for (inner.children.values()) |*child| child.widget.deinit(allocator); 812 inner.children.clearAndFree(allocator); 813 inner.getFocus().child_id = null; 814 self.author_id[index] = null; 815 816 // the tool row: the open/close and edit buttons; the description page 817 // shows none. 818 { 819 const row = self.toolRow(index); 820 for (row.children.values()) |*child| child.widget.deinit(allocator); 821 row.children.clearAndFree(allocator); 822 row.getFocus().child_id = null; 823 row.getFocus().kind = .container; 824 } 825 if (!description_page) { 826 const action: []const u8 = switch (entry.issue.event.status) { 827 .open => "close", 828 .closed => "open", 829 }; 830 const row = self.toolRow(index); 831 const pa = self.session.page_arena.allocator(); 832 row.getFocus().kind = .{ .custom = if (self.data.identity.len == 0) 833 try std.fmt.allocPrint(pa, "form:/issues/{s}/{s}", .{ entry.id, action }) 834 else 835 try std.fmt.allocPrint(pa, "form:/repo/{s}/issues/{s}/{s}", .{ self.data.identity, entry.id, action }) }; 836 837 { 838 var spacer = try ui.Spacer.init(allocator); 839 errdefer spacer.deinit(allocator); 840 try row.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 841 } 842 843 { 844 var button = try wgt.TextBox(ui.Widget).init(allocator, action, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 845 errdefer button.deinit(allocator); 846 button.getFocus().focusable = true; 847 // the renderer distinguishes plain clickables from buttons that 848 // should POST to a server route by this kind. 849 button.getFocus().kind = .{ .custom = "submit" }; 850 try row.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = action.len + 2, .height = null } }); 851 } 852 853 // the edit button links to the issue's edit page. 854 { 855 var button = try wgt.TextBox(ui.Widget).init(allocator, "edit", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 856 errdefer button.deinit(allocator); 857 button.getFocus().focusable = true; 858 const route = ui.RoutablePage.repoIssuesEditRoute(self.data.identity, entry.id) orelse return error.RouteTooLong; 859 button.getFocus().kind = .{ .custom = try std.fmt.allocPrint(pa, "a:{s}", .{try route.toUrl(self.session.page_arena)}) }; 860 try row.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = "edit".len + 2, .height = null } }); 861 } 862 863 row.getFocus().child_id = row.children.keys()[button_in_row_index]; 864 } 865 866 if (description_page) { 867 // the back link, in the title slot so the pane's input handling 868 // applies to it unchanged. 869 var tb = try wgt.TextBox(ui.Widget).init(allocator, "← back to issue", .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 870 errdefer tb.deinit(allocator); 871 tb.getFocus().focusable = true; 872 tb.getFocus().kind = .{ .custom = try issuesLink(self.session.page_arena, self.data.identity, entry.issue.event.status, "", entry.id) }; 873 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 874 } else { 875 // the issue's title as a focusable word-wrapped text box. 876 { 877 var tb = try wgt.TextBox(ui.Widget).init(allocator, entry.issue.event.title, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .word, .label = " title " }); 878 errdefer tb.deinit(allocator); 879 tb.getFocus().focusable = true; 880 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 881 } 882 883 self.author_id[index] = blk: { 884 var tb = try ui.authorBox(allocator, self.session.page_arena, entry.author); 885 errdefer tb.deinit(allocator); 886 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 887 break :blk tb.getFocus().id; 888 }; 889 890 // the issue's tags, each linking to this status's list filtered to 891 // that tag. 892 { 893 var items: std.ArrayList(ui.TagFlow.Item) = .empty; 894 defer items.deinit(allocator); 895 var tag_iter = evt.Issue.tagIterator(entry.issue.event.tags); 896 while (tag_iter.next()) |tag| { 897 if (tag.len == 0) continue; 898 try items.append(allocator, .{ .text = tag, .link = try tagLink(self.session.page_arena, self.data.identity, splitStatus(index), tag) }); 899 } 900 if (items.items.len > 0) { 901 var tf = try ui.TagFlow.init(allocator); 902 errdefer tf.deinit(allocator); 903 try tf.setItems(allocator, items.items); 904 try inner.children.put(allocator, tf.getFocus().id, .{ .widget = .{ .tag_flow = tf }, .rect = null, .min_size = null }); 905 } 906 } 907 } 908 909 // the description as a focusable word-wrapped text box. past the limit 910 // it's cut at the last whole line and links to the /description page, 911 // which shows the whole thing. 912 self.description_id[index] = blk: { 913 const whole = entry.issue.event.description; 914 const cut_short = !description_page and whole.len > max_description_size; 915 const shown = if (cut_short) 916 std.mem.trimEnd(u8, whole[0 .. std.mem.lastIndexOfScalar(u8, whole[0..max_description_size], '\n') orelse 0], " \t\r\n") 917 else if (whole.len == 0) 918 "(no description)" 919 else 920 whole; 921 var tb = try wgt.TextBox(ui.Widget).init(allocator, shown, .{ 922 .border_style = .single, 923 .rounded_corners = true, 924 .wrap_kind = .word, 925 .label = " description ", 926 .bottom_label = if (cut_short) " click or press enter to see more " else "", 927 }); 928 errdefer tb.deinit(allocator); 929 tb.getFocus().focusable = true; 930 if (cut_short) tb.getFocus().kind = .{ .custom = try descriptionLink(self.session.page_arena, self.data.identity, entry.id) }; 931 try inner.children.put(allocator, tb.getFocus().id, .{ .widget = .{ .text_box = tb }, .rect = null, .min_size = null }); 932 break :blk tb.getFocus().id; 933 }; 934 935 // select the title by default 936 inner.getFocus().child_id = inner.children.keys()[title_child_index]; 937 938 // reset the scroll to the top for the newly-shown issue: directly on the 939 // terminal (the wasm offset), and via a version bump on the web (so the 940 // renderer's scroll id changes and JS drops the preserved position). 941 const sc = self.detailScroll(index); 942 sc.x = 0; 943 sc.y = 0; 944 sc.getFocus().version +%= 1; 945 } 946 947 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 948 if (self.headerActive()) { 949 // down from the tabs re-enters the stack if the selected view has 950 // something to focus; other keys move the tabs. 951 if (inp.vertDirection(key) == .down) { 952 const enterable = if (self.viewStack().getSelected()) |selected| switch (selected.*) { 953 .tag_flow => |*tf| tf.text_boxes.items.len > 0, 954 else => true, 955 } else false; 956 if (enterable) root_focus.setFocus(self.box.children.keys()[stack_index]); 957 } else { 958 try self.header().input(allocator, key, root_focus); 959 } 960 return; 961 } 962 if (self.tagsViewActive()) { 963 self.tagsViewInput(key, root_focus); 964 return; 965 } 966 if (self.formViewActive()) { 967 try self.formInput(allocator, key, root_focus); 968 return; 969 } 970 const i = self.selectedSplitIndex() orelse return; 971 if (self.detailActive(i)) { 972 try self.detailInput(allocator, i, key, root_focus); 973 } else { 974 try self.listInput(i, key, root_focus); 975 } 976 } 977 978 // arrow keys move the tag selection; up from the top row crosses to the 979 // header tabs. 980 fn tagsViewInput(self: *View, key: Key, root_focus: *Focus) void { 981 const tf = self.tagsView(); 982 const cid = tf.focus.child_id orelse return; 983 const cur = tf.indexOfFocusId(cid) orelse return; 984 const count = tf.text_boxes.items.len; 985 switch (key) { 986 .arrow_left => if (cur > 0) root_focus.setFocus(tf.text_boxes.items[cur - 1].getFocus().id), 987 .arrow_right => if (cur + 1 < count) root_focus.setFocus(tf.text_boxes.items[cur + 1].getFocus().id), 988 .arrow_up => if (tf.rowStep(cur, false)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id) else self.focusHeader(root_focus), 989 .arrow_down => if (tf.rowStep(cur, true)) |i| root_focus.setFocus(tf.text_boxes.items[i].getFocus().id), 990 .home => root_focus.setFocus(tf.text_boxes.items[0].getFocus().id), 991 .end => root_focus.setFocus(tf.text_boxes.items[count - 1].getFocus().id), 992 else => {}, 993 } 994 } 995 996 fn listInput(self: *View, index: usize, key: Key, root_focus: *Focus) !void { 997 // up/down (and the scroll wheel) move the selection a row; page up/down 998 // jump a fixed amount. right/Enter cross into the detail pane. up from 999 // the top row crosses into the header tabs. 1000 if (inp.rowDelta(key, @intCast(self.listBox(index).children.count()))) |delta| { 1001 const lb = self.listBox(index); 1002 const at_top = if (lb.getFocus().child_id) |cid| lb.children.getIndex(cid) == 0 else true; 1003 if (delta < 0 and at_top) return self.focusHeader(root_focus); 1004 ui.moveRowFocus(lb, self.listScroll(index), root_focus, delta); 1005 return; 1006 } 1007 switch (key) { 1008 .enter, .arrow_right => try self.focusDetail(index, root_focus), 1009 else => {}, 1010 } 1011 } 1012 1013 fn detailInput(self: *View, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1014 if (self.toolRowFocused(index)) { 1015 try self.toolRowInput(allocator, index, key, root_focus); 1016 } else if (self.titleFocused(index)) { 1017 try self.titleInput(index, key, root_focus); 1018 } else if (self.authorFocused(index)) { 1019 try self.authorInput(index, key, root_focus); 1020 } else if (self.tagsFocused(index)) { 1021 try self.tagsInput(index, key, root_focus); 1022 } else { 1023 try self.descriptionInput(index, key, root_focus); 1024 } 1025 } 1026 1027 // the tool row: enter or a click on the open/close button flips the 1028 // issue's status; the edit button is an a: link the host follows. 1029 // arrows cross between the buttons and to the neighboring widgets. 1030 fn toolRowInput(self: *View, allocator: std.mem.Allocator, index: usize, key: Key, root_focus: *Focus) !void { 1031 const row = self.toolRow(index); 1032 const on_edit = if (row.getFocus().child_id) |cid| row.children.getIndex(cid) == edit_in_row_index else false; 1033 switch (key) { 1034 .arrow_left => if (on_edit) root_focus.setFocus(row.children.keys()[button_in_row_index]) else try self.focusList(index, root_focus), 1035 .arrow_right => if (!on_edit) root_focus.setFocus(row.children.keys()[edit_in_row_index]), 1036 .arrow_up => self.focusHeader(root_focus), 1037 .arrow_down => self.focusTitle(index, root_focus), 1038 .enter => if (!on_edit) try self.toggleIssueStatus(allocator, index), 1039 .mouse => |mouse| switch (mouse.action) { 1040 .scroll => |dir| { 1041 const sc = self.detailScroll(index); 1042 sc.y += if (dir == .up) @as(isize, -1) else 1; 1043 sc.clampToContent(); 1044 }, 1045 else => if (self.statusButton(index)) |button| { 1046 if (inp.leftClickOn(root_focus, button.getFocus().id, mouse)) try self.toggleIssueStatus(allocator, index); 1047 }, 1048 }, 1049 else => {}, 1050 } 1051 } 1052 1053 fn titleInput(self: *View, index: usize, key: Key, root_focus: *Focus) !void { 1054 const sc = self.detailScroll(index); 1055 switch (key) { 1056 .arrow_left => try self.focusList(index, root_focus), 1057 .arrow_up => self.focusToolRow(index, root_focus), 1058 .arrow_down => if (self.authorPresent(index)) self.focusAuthor(index, root_focus) else try self.focusDescription(index, root_focus), 1059 .mouse => |mouse| switch (mouse.action) { 1060 .scroll => |dir| { 1061 sc.y += if (dir == .up) @as(isize, -1) else 1; 1062 sc.clampToContent(); 1063 }, 1064 else => {}, 1065 }, 1066 else => {}, 1067 } 1068 } 1069 1070 // the author box's a: link (when it has one) is followed by the host on 1071 // enter / a click; arrows cross to the neighboring widgets. 1072 fn authorInput(self: *View, index: usize, key: Key, root_focus: *Focus) !void { 1073 const sc = self.detailScroll(index); 1074 switch (key) { 1075 .arrow_left => try self.focusList(index, root_focus), 1076 .arrow_up => self.focusTitle(index, root_focus), 1077 .arrow_down => if (self.tagFlow(index) != null) try self.focusTags(index, root_focus) else try self.focusDescription(index, root_focus), 1078 .mouse => |mouse| switch (mouse.action) { 1079 .scroll => |dir| { 1080 sc.y += if (dir == .up) @as(isize, -1) else 1; 1081 sc.clampToContent(); 1082 }, 1083 else => {}, 1084 }, 1085 else => {}, 1086 } 1087 } 1088 1089 fn descriptionInput(self: *View, index: usize, key: Key, root_focus: *Focus) !void { 1090 const sc = self.detailScroll(index); 1091 switch (key) { 1092 .arrow_left => return self.focusList(index, root_focus), 1093 // once the scroll can't move further, cross into the tags (or the 1094 // open/close button when the issue has none). 1095 .arrow_up => { 1096 const before = sc.y; 1097 sc.y -= 1; 1098 sc.clampToContent(); 1099 if (sc.y == before) { 1100 if (self.tagFlow(index) != null) 1101 try self.focusTags(index, root_focus) 1102 else if (self.authorPresent(index)) 1103 self.focusAuthor(index, root_focus) 1104 else 1105 self.focusTitle(index, root_focus); 1106 } 1107 return; 1108 }, 1109 .arrow_down => sc.y += 1, 1110 .page_up => sc.y -= 10, 1111 .page_down => sc.y += 10, 1112 .home => sc.y = 0, 1113 .end => sc.y = std.math.maxInt(isize), 1114 .mouse => |mouse| switch (mouse.action) { 1115 .scroll => |dir| sc.y += if (dir == .up) @as(isize, -1) else 1, 1116 else => {}, 1117 }, 1118 else => return, 1119 } 1120 sc.clampToContent(); 1121 } 1122 1123 // up/down (and tab/shift+tab) move between a form's fields; up from the 1124 // title crosses into the header tabs. the multiline description keeps 1125 // enter and any up/down that has a row to move to. 1126 fn formInput(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1127 const form = self.issueForm(); 1128 const cid = form.getFocus().child_id orelse return; 1129 const cur = form.children.getIndex(cid) orelse return; 1130 1131 if (cur == description_field_index) { 1132 const child = &form.children.values()[cur]; 1133 const description = &child.widget.text_input; 1134 switch (key) { 1135 .enter => return child.widget.input(allocator, key, root_focus), 1136 .arrow_up => if (!try description.cursorOnFirstRow(allocator)) 1137 return child.widget.input(allocator, key, root_focus), 1138 .arrow_down => if (!try description.cursorOnLastRow(allocator)) 1139 return child.widget.input(allocator, key, root_focus), 1140 else => {}, 1141 } 1142 } 1143 1144 switch (key) { 1145 .arrow_up, .back_tab => if (cur > 0) 1146 root_focus.setFocus(form.children.keys()[cur - 1]) 1147 else 1148 self.focusHeader(root_focus), 1149 .arrow_down, .tab => if (cur < submit_field_index) { 1150 root_focus.setFocus(form.children.keys()[cur + 1]); 1151 }, 1152 .enter => if (cur == submit_field_index) try self.submitForm(allocator), 1153 .mouse => |mouse| if (cur == submit_field_index and inp.leftClickOn(root_focus, cid, mouse)) { 1154 try self.submitForm(allocator); 1155 }, 1156 else => try form.children.values()[cur].widget.input(allocator, key, root_focus), 1157 } 1158 } 1159 1160 // the terminal submit paths: the new form commits a new-issue event, the 1161 // edit form re-emits the selected issue's event with the form's content. 1162 // the web posts the forms to their routes instead. 1163 fn submitForm(self: *View, allocator: std.mem.Allocator) !void { 1164 if (self.data.view == .edit) { 1165 try self.submitEditedIssue(allocator); 1166 } else { 1167 try self.submitNewIssue(allocator); 1168 } 1169 } 1170 1171 // commit the new issue to the repo's events branch and navigate to it. 1172 // this is the terminal path; the web posts the form to the issue route, 1173 // so the wasm side never runs (or compiles) the repo access below. 1174 fn submitNewIssue(self: *View, allocator: std.mem.Allocator) !void { 1175 if (comptime wasm) return; 1176 const io = self.session.io orelse return; 1177 const src = self.data.repo_source orelse return; 1178 1179 const form = self.issueForm(); 1180 const title_input = &form.children.values()[title_field_index].widget.text_input; 1181 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 1182 const description_input = &form.children.values()[description_field_index].widget.text_input; 1183 1184 const title = try title_input.text(allocator); 1185 defer allocator.free(title); 1186 const tags = try tags_input.text(allocator); 1187 defer allocator.free(tags); 1188 const description = try description_input.text(allocator); 1189 defer allocator.free(description); 1190 1191 if (!evt.Issue.fieldsValid(title, tags)) return; 1192 1193 var id_bytes: [evt.event_id_size]u8 = undefined; 1194 io.random(&id_bytes); 1195 const event_id_hex = std.fmt.bytesToHex(id_bytes, .lower); 1196 1197 const event = evt.EventWithId{ 1198 .id = event_id_hex, 1199 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 1200 .author_email = try self.session.userEmail(), 1201 .event = .{ .issue = .{ 1202 .title = title, 1203 .description = description, 1204 .tags = tags, 1205 } }, 1206 }; 1207 1208 switch (src.repo_kind) { 1209 inline else => |repo_kind| { 1210 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 1211 defer any_repo.deinit(io, allocator); 1212 switch (any_repo) { 1213 inline else => |*repo| try evt.consume(repo_kind, repo.self_repo_opts, io, allocator, repo, evt.events_ref, &.{event}), 1214 } 1215 }, 1216 } 1217 1218 // wipe the form so a return visit starts fresh 1219 title_input.clear(allocator); 1220 tags_input.clear(allocator); 1221 description_input.clear(allocator); 1222 1223 const route = ui.RoutablePage.repoIssuesRoute(self.data.identity, .open, "", &event_id_hex) orelse return; 1224 try self.session.navigate(route); 1225 } 1226 1227 // re-emit the selected issue's event with the form's content (status 1228 // preserved), then reload the issue's page. this is the terminal path; 1229 // the web posts the form to the edit route. 1230 fn submitEditedIssue(self: *View, allocator: std.mem.Allocator) !void { 1231 if (comptime wasm) return; 1232 const io = self.session.io orelse return; 1233 const src = self.data.repo_source orelse return; 1234 const entry = self.data.selectedIssue() orelse return; 1235 1236 const form = self.issueForm(); 1237 const title_input = &form.children.values()[title_field_index].widget.text_input; 1238 const tags_input = &form.children.values()[tags_field_index].widget.text_input; 1239 const description_input = &form.children.values()[description_field_index].widget.text_input; 1240 1241 const title = try title_input.text(allocator); 1242 defer allocator.free(title); 1243 const tags = try tags_input.text(allocator); 1244 defer allocator.free(tags); 1245 const description = try description_input.text(allocator); 1246 defer allocator.free(description); 1247 1248 if (!evt.Issue.fieldsValid(title, tags)) return; 1249 1250 var id_bytes: [evt.event_id_size]u8 = undefined; 1251 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 1252 1253 switch (src.repo_kind) { 1254 inline else => |repo_kind| { 1255 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 1256 defer any_repo.deinit(io, allocator); 1257 switch (any_repo) { 1258 inline else => |*repo| try evt.Issue.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .fields = .{ 1259 .title = title, 1260 .tags = tags, 1261 .description = description, 1262 } }, try self.session.userEmail()), 1263 } 1264 }, 1265 } 1266 1267 const route = ui.RoutablePage.repoIssuesRoute(self.data.identity, entry.issue.event.status, "", entry.id) orelse return; 1268 try self.session.navigate(route); 1269 } 1270 1271 // flip the shown issue's status by re-emitting its event, then reload the 1272 // page rooted at the issue so the view reflects the change. this is the 1273 // terminal path; the web posts the button's form to the status route. 1274 fn toggleIssueStatus(self: *View, allocator: std.mem.Allocator, index: usize) !void { 1275 if (comptime wasm) return; 1276 const io = self.session.io orelse return; 1277 const src = self.data.repo_source orelse return; 1278 const sel = self.detailed_index[index] orelse return; 1279 const entry = self.window(index).issues[sel]; 1280 1281 const status: evt.Issue.Status = switch (entry.issue.event.status) { 1282 .open => .closed, 1283 .closed => .open, 1284 }; 1285 1286 var id_bytes: [evt.event_id_size]u8 = undefined; 1287 _ = try std.fmt.hexToBytes(&id_bytes, entry.id[0 .. evt.event_id_size * 2]); 1288 1289 switch (src.repo_kind) { 1290 inline else => |repo_kind| { 1291 var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); 1292 defer any_repo.deinit(io, allocator); 1293 switch (any_repo) { 1294 inline else => |*repo| try evt.Issue.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &id_bytes, .{ .status = status }, try self.session.userEmail()), 1295 } 1296 }, 1297 } 1298 1299 const route = ui.RoutablePage.repoIssuesRoute(self.data.identity, status, "", entry.id) orelse return; 1300 try self.session.navigate(route); 1301 } 1302 1303 // arrow keys move the tag selection; at the flow's edges focus crosses to 1304 // the neighboring widgets. 1305 fn tagsInput(self: *View, index: usize, key: Key, root_focus: *Focus) !void { 1306 const tf = self.tagFlow(index) orelse return; 1307 const cid = tf.focus.child_id orelse return; 1308 const cur = tf.indexOfFocusId(cid) orelse return; 1309 const count = tf.text_boxes.items.len; 1310 const sc = self.detailScroll(index); 1311 switch (key) { 1312 .arrow_left => if (cur > 0) self.focusTag(index, tf, root_focus, cur - 1) else try self.focusList(index, root_focus), 1313 .arrow_right => if (cur + 1 < count) self.focusTag(index, tf, root_focus, cur + 1), 1314 .arrow_up => if (tf.rowStep(cur, false)) |i| self.focusTag(index, tf, root_focus, i) else self.focusAuthor(index, root_focus), 1315 .arrow_down => if (tf.rowStep(cur, true)) |i| self.focusTag(index, tf, root_focus, i) else try self.focusDescription(index, root_focus), 1316 .home => self.focusTag(index, tf, root_focus, 0), 1317 .end => self.focusTag(index, tf, root_focus, count - 1), 1318 .mouse => |mouse| switch (mouse.action) { 1319 .scroll => |dir| { 1320 sc.y += if (dir == .up) @as(isize, -1) else 1; 1321 sc.clampToContent(); 1322 }, 1323 else => {}, 1324 }, 1325 else => {}, 1326 } 1327 } 1328 1329 const button_in_row_index: usize = 1; 1330 const edit_in_row_index: usize = 2; 1331 const title_child_index: usize = 0; 1332 const tags_child_index: usize = 2; 1333 1334 fn tagFlow(self: *View, index: usize) ?*ui.TagFlow { 1335 const inner = self.detailInner(index); 1336 if (inner.children.count() <= tags_child_index) return null; 1337 return switch (inner.children.values()[tags_child_index].widget) { 1338 .tag_flow => |*tf| tf, 1339 else => null, 1340 }; 1341 } 1342 1343 fn tagsFocused(self: *View, index: usize) bool { 1344 const inner = self.detailInner(index); 1345 const cid = inner.getFocus().child_id orelse return false; 1346 return inner.children.getIndex(cid) == tags_child_index and self.tagFlow(index) != null; 1347 } 1348 1349 // the open/close button inside the detail frame's tool row. 1350 fn statusButton(self: *View, index: usize) ?*wgt.TextBox(ui.Widget) { 1351 const row = self.toolRow(index); 1352 if (row.children.count() == 0) return null; 1353 return &row.children.values()[button_in_row_index].widget.text_box; 1354 } 1355 1356 fn toolRowFocused(self: *View, index: usize) bool { 1357 const outer = self.detailOuter(index); 1358 const cid = outer.getFocus().child_id orelse return false; 1359 return outer.children.getIndex(cid) == tool_row_index; 1360 } 1361 1362 fn focusTag(self: *View, index: usize, tf: *ui.TagFlow, root_focus: *Focus, item: usize) void { 1363 root_focus.setFocus(tf.text_boxes.items[item].getFocus().id); 1364 // keep the tag visible on the terminal: its rect offset by the flow's 1365 // position in the pane. 1366 if (self.session.is_terminal and item < tf.rects.items.len) { 1367 if (self.detailInner(index).children.values()[tags_child_index].rect) |flow_rect| { 1368 var rect = tf.rects.items[item]; 1369 rect.x += flow_rect.x; 1370 rect.y += flow_rect.y; 1371 self.detailScroll(index).scrollToRect(rect); 1372 } 1373 } 1374 } 1375 1376 fn titleFocused(self: *View, index: usize) bool { 1377 const inner = self.detailInner(index); 1378 const cid = inner.getFocus().child_id orelse return false; 1379 return inner.children.getIndex(cid) == title_child_index; 1380 } 1381 1382 fn authorPresent(self: *View, index: usize) bool { 1383 return self.author_id[index] != null; 1384 } 1385 1386 fn authorFocused(self: *View, index: usize) bool { 1387 const inner = self.detailInner(index); 1388 const cid = inner.getFocus().child_id orelse return false; 1389 return cid == self.author_id[index]; 1390 } 1391 1392 // the author sits right under the title at the top of the pane, so 1393 // focusing it scrolls there. 1394 fn focusAuthor(self: *View, index: usize, root_focus: *Focus) void { 1395 const id = self.author_id[index] orelse return; 1396 root_focus.setFocus(id); 1397 self.detailScroll(index).y = 0; 1398 } 1399 1400 // the title sits at the top of the pane, so focusing it scrolls there. 1401 fn focusTitle(self: *View, index: usize, root_focus: *Focus) void { 1402 const inner = self.detailInner(index); 1403 if (inner.children.count() == 0) return; 1404 root_focus.setFocus(inner.children.keys()[title_child_index]); 1405 self.detailScroll(index).y = 0; 1406 } 1407 1408 fn focusTags(self: *View, index: usize, root_focus: *Focus) !void { 1409 const tf = self.tagFlow(index) orelse return; 1410 if (tf.text_boxes.items.len == 0) return; 1411 const cid = tf.focus.child_id orelse tf.text_boxes.items[0].getFocus().id; 1412 const item = tf.indexOfFocusId(cid) orelse 0; 1413 self.focusTag(index, tf, root_focus, item); 1414 } 1415 1416 fn focusDescription(self: *View, index: usize, root_focus: *Focus) !void { 1417 if (self.description_id[index]) |id| root_focus.setFocus(id); 1418 } 1419 1420 // return to the tool row's last-focused button (the header when the 1421 // description page shows no row). 1422 fn focusToolRow(self: *View, index: usize, root_focus: *Focus) void { 1423 const cid = self.toolRow(index).getFocus().child_id orelse return self.focusHeader(root_focus); 1424 root_focus.setFocus(cid); 1425 } 1426 1427 // enter the detail pane. an empty pane (no issues) can't be entered. 1428 fn focusDetail(self: *View, index: usize, root_focus: *Focus) !void { 1429 if (self.detailInner(index).children.count() == 0) return; 1430 root_focus.setFocus(self.detailOuter(index).getFocus().id); 1431 } 1432 1433 // return to the list. 1434 fn focusList(self: *View, index: usize, root_focus: *Focus) !void { 1435 root_focus.setFocus(self.listScroll(index).getFocus().id); 1436 } 1437 1438 // cross to the header tabs above the stack. 1439 fn focusHeader(self: *View, root_focus: *Focus) void { 1440 root_focus.setFocus(self.box.children.keys()[header_index]); 1441 } 1442 1443 pub fn clearGrid(self: *View) void { 1444 self.box.clearGrid(); 1445 } 1446 1447 pub fn getGrid(self: View) ?Grid { 1448 return self.box.getGrid(); 1449 } 1450 1451 pub fn getFocus(self: *View) *Focus { 1452 return self.box.getFocus(); 1453 } 1454 1455 // for the parent's "scroll up at the top jumps to the header" check: at the 1456 // top only while the header tabs hold focus, so up from the split 1457 // crosses into the tabs first. 1458 pub fn getSelectedIndex(self: *View) ?usize { 1459 return if (self.headerActive()) 0 else 1; 1460 } 1461 }; 1462 1463 // the "a:" navigation link for `status`'s issues page filtered to the 1464 // url-encoded `tag` and rooted at issue `id` within `identity` ("owner/name"). 1465 // a rooted url ignores `status` (it derives its view from the issue's own); 1466 // with an empty `id` this is the bare list link. 1467 fn issuesLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: evt.Issue.Status, tag: []const u8, id: []const u8) ![]const u8 { 1468 const route = ui.RoutablePage.repoIssuesRoute(identity, status, tag, id) orelse return error.RouteTooLong; 1469 const url = try route.toUrl(page_arena); 1470 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 1471 } 1472 1473 // the in-page "ai:" anchor for selecting issue `id` in `identity`'s list; the 1474 // href is only followed with js off. 1475 fn issueRowLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 1476 const route = ui.RoutablePage.repoIssuesRoute(identity, .open, "", id) orelse return error.RouteTooLong; 1477 const url = try route.toUrl(page_arena); 1478 return std.fmt.allocPrint(page_arena.allocator(), "ai:{s}", .{url}); 1479 } 1480 1481 // the "a:" link to the whole-description page of issue `id` within `identity`. 1482 fn descriptionLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, id: []const u8) ![]const u8 { 1483 const route = ui.RoutablePage.repoIssuesDescriptionRoute(identity, id) orelse return error.RouteTooLong; 1484 const url = try route.toUrl(page_arena); 1485 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 1486 } 1487 1488 // the "a:" link to `status`'s issues list filtered to `tag` (raw; encoded here). 1489 fn tagLink(page_arena: *std.heap.ArenaAllocator, identity: []const u8, status: evt.Issue.Status, tag: []const u8) ![]const u8 { 1490 const encoded = try ui.urlEncodeRef(page_arena.allocator(), tag); 1491 const route = ui.RoutablePage.repoIssuesRoute(identity, status, encoded, "") orelse return error.RouteTooLong; 1492 const url = try route.toUrl(page_arena); 1493 return std.fmt.allocPrint(page_arena.allocator(), "a:{s}", .{url}); 1494 } 1495 1496 // tabs switching between the issues page's views. 1497 pub const Header = struct { 1498 box: wgt.Box(ui.Widget), 1499 tab_ids: std.AutoArrayHashMapUnmanaged(usize, void), 1500 1501 pub fn init(allocator: std.mem.Allocator, session: *ui.Session, data: *const Self) !Header { 1502 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border_style = null, .direction = .horiz }); 1503 errdefer box.deinit(allocator); 1504 1505 var tab_ids: std.AutoArrayHashMapUnmanaged(usize, void) = .empty; 1506 errdefer tab_ids.deinit(allocator); 1507 1508 const aa = session.page_arena.allocator(); 1509 1510 // a list tab per status, labeled with its listing's issue count 1511 for ([_]evt.Issue.Status{ .open, .closed }) |status| { 1512 const route = ui.RoutablePage.repoIssuesRoute(data.identity, status, data.tag, "") orelse return error.RouteTooLong; 1513 const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); 1514 const label = try std.fmt.allocPrint(aa, "{s} ({d})", .{ @tagName(status), data.window(status).count }); 1515 var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1516 errdefer text_box.deinit(allocator); 1517 text_box.getFocus().focusable = true; 1518 text_box.getFocus().kind = .{ .custom = link }; 1519 try tab_ids.put(allocator, text_box.getFocus().id, {}); 1520 try box.children.put(allocator, text_box.getFocus().id, .{ 1521 .widget = .{ .text_box = text_box }, 1522 .rect = null, 1523 .min_size = .{ .width = label.len + 2, .height = null }, 1524 }); 1525 } 1526 1527 // tags tab, labeled with the active tag filter 1528 { 1529 const tags_route = ui.RoutablePage.repoIssuesTagsRoute(data.identity, data.tag) orelse return error.RouteTooLong; 1530 const tags_link = try std.fmt.allocPrint(aa, "ai:{s}", .{try tags_route.toUrl(session.page_arena)}); 1531 const label = if (data.tag.len == 0) "tags" else blk: { 1532 const decoded = std.Uri.percentDecodeInPlace(try aa.dupe(u8, data.tag)); 1533 break :blk try std.fmt.allocPrint(aa, "tags ({s})", .{decoded}); 1534 }; 1535 var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1536 errdefer text_box.deinit(allocator); 1537 text_box.getFocus().focusable = true; 1538 text_box.getFocus().kind = .{ .custom = tags_link }; 1539 try tab_ids.put(allocator, text_box.getFocus().id, {}); 1540 try box.children.put(allocator, text_box.getFocus().id, .{ 1541 .widget = .{ .text_box = text_box }, 1542 .rect = null, 1543 .min_size = .{ .width = label.len + 2, .height = null }, 1544 }); 1545 } 1546 1547 // new-issue tab; an edit url shows the edit tab in its place 1548 { 1549 const route = if (data.view == .edit) 1550 ui.RoutablePage.repoIssuesEditRoute(data.identity, data.selected_id) orelse return error.RouteTooLong 1551 else 1552 ui.RoutablePage.repoIssuesNewRoute(data.identity) orelse return error.RouteTooLong; 1553 const link = try std.fmt.allocPrint(aa, "ai:{s}", .{try route.toUrl(session.page_arena)}); 1554 const label: []const u8 = if (data.view == .edit) "edit" else "new"; 1555 var text_box = try wgt.TextBox(ui.Widget).init(allocator, label, .{ .border_style = .single, .rounded_corners = true, .wrap_kind = .none }); 1556 errdefer text_box.deinit(allocator); 1557 text_box.getFocus().focusable = true; 1558 text_box.getFocus().kind = .{ .custom = link }; 1559 try tab_ids.put(allocator, text_box.getFocus().id, {}); 1560 try box.children.put(allocator, text_box.getFocus().id, .{ 1561 .widget = .{ .text_box = text_box }, 1562 .rect = null, 1563 .min_size = .{ .width = label.len + 2, .height = null }, 1564 }); 1565 } 1566 1567 var self = Header{ .box = box, .tab_ids = tab_ids }; 1568 // the tab matching the page's view is selected initially. 1569 self.getFocus().child_id = self.tab_ids.keys()[View.viewIndex(data.view)]; 1570 return self; 1571 } 1572 1573 pub fn deinit(self: *Header, allocator: std.mem.Allocator) void { 1574 self.box.deinit(allocator); 1575 self.tab_ids.deinit(allocator); 1576 } 1577 1578 pub fn build(self: *Header, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 1579 self.clearGrid(); 1580 // only the selected tab shows its border 1581 for (self.box.children.keys(), self.box.children.values()) |id, *child| { 1582 switch (child.widget) { 1583 .text_box => |*tb| tb.options.border_style = if (self.getFocus().child_id == id) .single else .hidden, 1584 else => {}, 1585 } 1586 } 1587 try self.box.build(allocator, constraint, root_focus); 1588 } 1589 1590 pub fn input(self: *Header, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 1591 _ = allocator; 1592 const current_tab = self.currentTabIndex() orelse return; 1593 if (inp.moveTab(key, current_tab, self.tab_ids.count())) |new_tab| { 1594 root_focus.setFocus(self.tab_ids.keys()[new_tab]); 1595 } 1596 } 1597 1598 pub fn clearGrid(self: *Header) void { 1599 self.box.clearGrid(); 1600 } 1601 1602 pub fn getGrid(self: Header) ?Grid { 1603 return self.box.getGrid(); 1604 } 1605 1606 pub fn getFocus(self: *Header) *Focus { 1607 return self.box.getFocus(); 1608 } 1609 1610 pub fn getSelectedIndex(self: Header) ?usize { 1611 return self.currentTabIndex(); 1612 } 1613 1614 fn currentTabIndex(self: Header) ?usize { 1615 const child_id = self.box.focus.child_id orelse return null; 1616 return self.tab_ids.getIndex(child_id); 1617 } 1618 }; 1619