╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ UserNotifications.zig ────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
73 try session.navigate(session.data.current_page.pageRoot()); 74 } 75 76 const logout_label = "logout"; 77 78 // the notifications and user settings sub tabs over their content, with the logout button across from them 79 pub const View = struct { 80 box: wgt.Box(ui.Widget), 81 data: *const Self, 82 session: *ui.Session, 83 // the sub tabs' ids then the logout button's, in header order 84 control_ids: [3]usize, 85 // the sub tab whose content shows 86 view_index: usize, 87 88 const header_index = 0; 89 const content_index = 1; 90 const logout_index = 2; 91 92 // `user_route` is the page's user tab route 93 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session, user_route: ui.RoutablePage) !View { 94 var box = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .vert }); 95 errdefer box.deinit(allocator); 96 97 var control_ids: [3]usize = undefined; 98 const settings_route = user_route.withUserTab(.{ .view = .settings }); 99 { 100 var header = try wgt.Box(ui.Widget).init(allocator, .{ .border = null, .direction = .horiz }); 101 errdefer header.deinit(allocator); 102 const on_tab = std.meta.activeTag(session.data.current_page) == std.meta.activeTag(user_route); 103 const routes = [_]ui.RoutablePage{ user_route.withUserTab(.{}), settings_route }; 104 for ([_]ui.RoutablePage.UserView{ .notifications, .settings }, [_][]const u8{ "notifications", "user settings" }, routes, 0..) |view, label, route, index| { 105 var tab = try wgt.TextBox.init(allocator, label, .{ .border = .hidden, .round_corners = true, .wrap_kind = .none }); 106 errdefer tab.deinit(allocator); 107 tab.getFocus().mode = .all; 108 tab.getFocus().kind = .{ .custom = try ui.inPageTabLink(session, route, on_tab and data.view == view) }; 109 control_ids[index] = tab.getFocus().id; 110 try header.children.put(allocator, tab.getFocus().id, .{ .widget = .{ .text_box = tab }, .rect = null, .min_size = .{ .width = label.len + 2, .height = null } }); 111 } 112 // the spacer pushes the logout button to the right edge 113 { 114 var spacer = try ui.widget.Spacer.init(allocator); 115 errdefer spacer.deinit(allocator); 116 try header.children.put(allocator, spacer.getFocus().id, .{ .widget = .{ .spacer = spacer }, .rect = null, .min_size = null }); 117 } 118 { 119 // the form kind makes the web renderer post the button to the logout route 120 header.getFocus().kind = .{ .custom = try session.page_arena.allocator().print("form:{s}/logout", .{try settings_route.toUrl(session.page_arena)}) }; 121 var button = try wgt.TextBox.init(allocator, logout_label, .{ .border = .single, .round_corners = true, .wrap_kind = .none }); 122 errdefer button.deinit(allocator); 123 button.getFocus().mode = .all; 124 button.getFocus().kind = .{ .custom = "submit" }; 125 control_ids[logout_index] = button.getFocus().id; 126 try header.children.put(allocator, button.getFocus().id, .{ .widget = .{ .text_box = button }, .rect = null, .min_size = .{ .width = logout_label.len + 2, .height = 3 } }); 127 } 128 header.getFocus().child_id = control_ids[@backingInt(data.view)]; 129 // a row taller than the button, leaving a blank line beneath it 130 try box.children.put(allocator, header.getFocus().id, .{ .widget = .{ .box = header }, .rect = null, .min_size = .{ .width = null, .height = 4 } }); 131 } 132 133 { 134 var stack = try wgt.Stack(ui.Widget).init(allocator); 135 errdefer stack.deinit(allocator); 136 // an empty first window has no rows, not even paging ones 137 if (data.isEmpty()) { 138 var center = try ui.widget.initEmpty(allocator); 139 errdefer center.deinit(allocator); 140 try stack.children.put(allocator, center.getFocus().id, .{ .center = center }); 141 } else { 142 var list = try ui.widget.FlowBox.Scroll.init(allocator, .{}, !session.is_terminal); 143 errdefer list.deinit(allocator); 144 145 var arena = std.heap.ArenaAllocator.init(allocator); 146 defer arena.deinit(); 147 const aa = arena.allocator(); 148 149 // a leading "previous" row off the first window, one row per 150 // notif, then a trailing "next" row when more remain 151 var items: std.ArrayList(ui.widget.FlowBox.Item) = .empty; 152 if (data.start > 0) { 153 const prev = user_route.withUserTab(.{ .start = data.start -| page_size }); 154 try items.append(aa, .{ .text = "← previous", .link = try aa.print("a:{s}", .{try prev.toUrl(&arena)}) }); 155 } 156 for (data.rows) |row| try items.append(aa, .{ 157 .text = row.title, 158 .link = try aa.print("a:{s}", .{row.url}), 159 .bottom_label = try aa.print("{s} {s}", .{ if (row.unread) "☐" else "☑", row.identity }), 160 .bold = row.unread, 161 }); 162 if (data.next_start) |next_start| { 163 const next = user_route.withUserTab(.{ .start = next_start }); 164 try items.append(aa, .{ .text = "next →", .link = try aa.print("a:{s}", .{try next.toUrl(&arena)}) }); 165 } 166 try list.setItems(allocator, items.items); 167 try stack.children.put(allocator, list.getFocus().id, .{ .flow_box_scroll = list }); 168 } 169 { 170 var settings = try ui.UserSettings.View.init(allocator, session, settings_route); 171 errdefer settings.deinit(allocator); 172 try stack.children.put(allocator, settings.getFocus().id, .{ .user_settings = settings }); 173 } 174 stack.getFocus().child_id = stack.children.keys()[@backingInt(data.view)]; 175 try box.children.put(allocator, stack.getFocus().id, .{ .widget = .{ .stack = stack }, .rect = null, .min_size = null }); 176 } 177 box.getFocus().child_id = box.children.keys()[content_index]; 178 return .{ .box = box, .data = data, .session = session, .control_ids = control_ids, .view_index = @backingInt(data.view) }; 179 } 180 181 pub fn deinit(self: *View, allocator: std.mem.Allocator) void { 182 self.box.deinit(allocator); 183 } 184 185 fn headerBox(self: *View) *wgt.Box(ui.Widget) { 186 return &self.box.children.values()[header_index].widget.box; 187 } 188 189 fn contentStack(self: *View) *wgt.Stack(ui.Widget) { 190 return &self.box.children.values()[content_index].widget.stack; 191 } 192 193 fn headerActive(self: *View) bool { 194 return self.box.getFocus().child_id == self.headerBox().getFocus().id; 195 } 196 197 pub fn build(self: *View, allocator: std.mem.Allocator, constraint: layout.Constraint, root_focus: *Focus) !void { 198 self.clearGrid(); 199 const header = self.headerBox(); 200 // focusing a sub tab shows its content, while the logout button leaves it 201 if (header.getFocus().child_id) |child_id| if (std.mem.indexOfScalar(usize, self.control_ids[0..logout_index], child_id)) |index| { 202 self.view_index = index; 203 }; 204 for (self.control_ids[0..logout_index], 0..) |id, index| { 205 const tab = header.children.getPtr(id) orelse unreachable; 206 ui.widget.markSelected(&tab.widget.text_box, index == self.view_index); 207 } 208 self.contentStack().getFocus().child_id = self.contentStack().children.keys()[self.view_index]; 209 // clear the incoming min height so the list scrolls within what's left 210 try self.box.build(allocator, .{ 211 .min_size = .{ .width = null, .height = null }, 212 .max_size = constraint.max_size, 213 }, root_focus); 214 } 215 216 pub fn input(self: *View, allocator: std.mem.Allocator, key: Key, root_focus: *Focus) !void { 217 const logout_id = self.control_ids[logout_index]; 218 if (key == .mouse and inp.leftClickOn(root_focus, logout_id, key.mouse)) return logOut(self.session); 219 const direction = inp.vertDirection(key); 220 if (self.headerActive()) { 221 const focused = root_focus.grandchild_id orelse return; 222 if (key == .enter and focused == logout_id) return logOut(self.session); 223 if (direction == .down) { 224 // the empty message has nothing to focus 225 if (self.view_index == @backingInt(ui.RoutablePage.UserView.notifications) and self.data.isEmpty()) return; 226 root_focus.setFocus(self.contentStack().getFocus().id); 227 } else if (std.mem.indexOfScalar(usize, &self.control_ids, focused)) |current| { 228 if (inp.moveTab(key, current, self.control_ids.len)) |next| root_focus.setFocus(self.control_ids[next]); 229 } 230 return; 231 } 232 if (direction == .up) if (self.contentStack().getSelected()) |selected| if (selected.atTop(root_focus)) { 233 root_focus.setFocus(self.control_ids[self.view_index]); 234 return; 235 }; 236 try self.contentStack().input(allocator, key, root_focus); 237 } 238 239 // moving up from the sub header returns to the page's tabs 240 pub fn atTop(self: *View) bool { 241 return self.headerActive(); 242 } 243 244 // arriving from the page's tabs lands on the sub header, not the content 245 pub fn focusHeader(self: *View, root_focus: *Focus) bool { 246 root_focus.setFocus(self.control_ids[self.view_index]); 247 return true; 248 } 249 250 pub fn clearGrid(self: *View) void { 251 self.box.clearGrid(); 252 } 253 254 pub fn getGrid(self: View) ?Grid { 255 return self.box.getGrid(); 256 } 257 258 pub fn getFocus(self: *View) *Focus { 259 return self.box.getFocus(); 260 } 261 }; 262