an experimental irc client

ui: add /list command

rockorager.dev 50dbba8e b1d6aeeb

verified
+346 -3
+2 -2
build.zig.zon
··· 4 .version = "0.0.0", 5 .dependencies = .{ 6 .vaxis = .{ 7 - .url = "git+https://github.com/rockorager/libvaxis#6b5a011f58eb7201a18d894383b96eba4b9101f9", 8 - .hash = "vaxis-0.1.0-BWNV_NzyCADDrd6hjXVEXAZSWA3FUhwoxnE4Pk0SbeRz", 9 }, 10 .zeit = .{ 11 .url = "git+https://github.com/rockorager/zeit#44bebf856693332b168d8ba2c45b9d1ec15511de",
··· 4 .version = "0.0.0", 5 .dependencies = .{ 6 .vaxis = .{ 7 + .url = "git+https://github.com/rockorager/libvaxis#6a37605dde55898dcca4769dd3eb1e333959c209", 8 + .hash = "vaxis-0.1.0-BWNV_K3yCACrTy3A5cbZElLyICx5a2O2EzPxmgVRcbKJ", 9 }, 10 .zeit = .{ 11 .url = "git+https://github.com/rockorager/zeit#44bebf856693332b168d8ba2c45b9d1ec15511de",
+30 -1
src/app.zig
··· 354 }; 355 try children.append(sub); 356 357 return .{ 358 .size = ctx.max.size(), 359 .widget = self.widget(), ··· 518 }, 519 .join => { 520 const start = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 521 const msg = try std.fmt.bufPrint( 522 &buf, 523 "JOIN {s}\r\n", 524 .{ 525 - cmd[start + 1 ..], 526 }, 527 ); 528 // Ensure buffer exists 529 self.explicit_join = true; 530 return client.queueWrite(msg); 531 }, 532 .me => { 533 if (channel == null) return error.InvalidCommand;
··· 354 }; 355 try children.append(sub); 356 357 + for (self.clients.items) |client| { 358 + if (client.list_modal.is_shown) { 359 + const padding: u16 = 8; 360 + const modal_ctx = ctx.withConstraints(ctx.min, .{ 361 + .width = max.width -| padding * 2, 362 + .height = max.height -| padding, 363 + }); 364 + const border: vxfw.Border = .{ .child = client.list_modal.widget() }; 365 + try children.append(.{ 366 + .origin = .{ .row = padding / 2, .col = padding }, 367 + .surface = try border.draw(modal_ctx), 368 + }); 369 + break; 370 + } 371 + } 372 + 373 return .{ 374 .size = ctx.max.size(), 375 .widget = self.widget(), ··· 534 }, 535 .join => { 536 const start = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 537 + const chan_name = cmd[start + 1 ..]; 538 + for (client.channels.items) |chan| { 539 + if (std.mem.eql(u8, chan.name, chan_name)) { 540 + client.app.selectBuffer(.{ .channel = chan }); 541 + return; 542 + } 543 + } 544 const msg = try std.fmt.bufPrint( 545 &buf, 546 "JOIN {s}\r\n", 547 .{ 548 + chan_name, 549 }, 550 ); 551 + 552 + // Check 553 // Ensure buffer exists 554 self.explicit_join = true; 555 return client.queueWrite(msg); 556 + }, 557 + .list => { 558 + client.list_modal.expecting_response = true; 559 + return client.queueWrite("LIST\r\n"); 560 }, 561 .me => { 562 if (channel == null) return error.InvalidCommand;
+2
src/comlink.zig
··· 66 /// a raw irc command. Sent verbatim 67 quote, 68 join, 69 me, 70 msg, 71 query, ··· 86 const map = std.StaticStringMap(Command).initComptime(.{ 87 .{ "quote", .quote }, 88 .{ "join", .join }, 89 .{ "me", .me }, 90 .{ "msg", .msg }, 91 .{ "query", .query },
··· 66 /// a raw irc command. Sent verbatim 67 quote, 68 join, 69 + list, 70 me, 71 msg, 72 query, ··· 87 const map = std.StaticStringMap(Command).initComptime(.{ 88 .{ "quote", .quote }, 89 .{ "join", .join }, 90 + .{ "list", .list }, 91 .{ "me", .me }, 92 .{ "msg", .msg }, 93 .{ "query", .query },
+312
src/irc.zig
··· 45 RPL_MYINFO, // 004 46 RPL_ISUPPORT, // 005 47 48 RPL_ENDOFWHO, // 315 49 RPL_TOPIC, // 332 50 RPL_WHOREPLY, // 352 51 RPL_NAMREPLY, // 353 ··· 78 .{ "004", .RPL_MYINFO }, 79 .{ "005", .RPL_ISUPPORT }, 80 81 .{ "315", .RPL_ENDOFWHO }, 82 .{ "332", .RPL_TOPIC }, 83 .{ "352", .RPL_WHOREPLY }, 84 .{ "353", .RPL_NAMREPLY }, ··· 1659 text_field: vxfw.TextField, 1660 completer_shown: bool, 1661 1662 pub fn init( 1663 self: *Client, 1664 alloc: std.mem.Allocator, ··· 1684 .retry_delay_s = 0, 1685 .text_field = .init(alloc, app.unicode), 1686 .completer_shown = false, 1687 }; 1688 self.text_field.style = .{ .bg = self.app.blendBg(10) }; 1689 self.text_field.userdata = self; 1690 self.text_field.onSubmit = Client.onSubmit; ··· 1733 self.alloc.destroy(channel); 1734 } 1735 self.channels.deinit(); 1736 1737 var user_iter = self.users.valueIterator(); 1738 while (user_iter.next()) |user| { ··· 2090 } 2091 channel.topic = try self.alloc.dupe(u8, topic); 2092 }, 2093 .RPL_SASLSUCCESS => {}, 2094 .RPL_WHOREPLY => { 2095 // syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name> ··· 3105 pub const ChatHistoryCommand = enum { 3106 before, 3107 after, 3108 }; 3109 3110 test "caseFold" {
··· 45 RPL_MYINFO, // 004 46 RPL_ISUPPORT, // 005 47 48 + RPL_TRYAGAIN, // 263 49 + 50 RPL_ENDOFWHO, // 315 51 + RPL_LISTSTART, // 321 52 + RPL_LIST, // 322 53 + RPL_LISTEND, // 323 54 RPL_TOPIC, // 332 55 RPL_WHOREPLY, // 352 56 RPL_NAMREPLY, // 353 ··· 83 .{ "004", .RPL_MYINFO }, 84 .{ "005", .RPL_ISUPPORT }, 85 86 + .{ "263", .RPL_TRYAGAIN }, 87 + 88 .{ "315", .RPL_ENDOFWHO }, 89 + .{ "321", .RPL_LISTSTART }, 90 + .{ "322", .RPL_LIST }, 91 + .{ "323", .RPL_LISTEND }, 92 .{ "332", .RPL_TOPIC }, 93 .{ "352", .RPL_WHOREPLY }, 94 .{ "353", .RPL_NAMREPLY }, ··· 1669 text_field: vxfw.TextField, 1670 completer_shown: bool, 1671 1672 + list_modal: ListModal, 1673 + 1674 pub fn init( 1675 self: *Client, 1676 alloc: std.mem.Allocator, ··· 1696 .retry_delay_s = 0, 1697 .text_field = .init(alloc, app.unicode), 1698 .completer_shown = false, 1699 + .list_modal = undefined, 1700 }; 1701 + self.list_modal.init(alloc, self); 1702 self.text_field.style = .{ .bg = self.app.blendBg(10) }; 1703 self.text_field.userdata = self; 1704 self.text_field.onSubmit = Client.onSubmit; ··· 1747 self.alloc.destroy(channel); 1748 } 1749 self.channels.deinit(); 1750 + 1751 + self.list_modal.deinit(self.alloc); 1752 1753 var user_iter = self.users.valueIterator(); 1754 while (user_iter.next()) |user| { ··· 2106 } 2107 channel.topic = try self.alloc.dupe(u8, topic); 2108 }, 2109 + .RPL_TRYAGAIN => { 2110 + if (self.list_modal.expecting_response) { 2111 + self.list_modal.expecting_response = false; 2112 + try self.list_modal.finish(ctx); 2113 + } 2114 + }, 2115 + .RPL_LISTSTART => try self.list_modal.reset(), 2116 + .RPL_LIST => { 2117 + // We might not always get a RPL_LISTSTART, so we check if we have a list already 2118 + // and if it needs reseting 2119 + if (self.list_modal.finished) { 2120 + try self.list_modal.reset(); 2121 + } 2122 + self.list_modal.expecting_response = false; 2123 + try self.list_modal.addMessage(self.alloc, msg); 2124 + }, 2125 + .RPL_LISTEND => try self.list_modal.finish(ctx), 2126 .RPL_SASLSUCCESS => {}, 2127 .RPL_WHOREPLY => { 2128 // syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name> ··· 3138 pub const ChatHistoryCommand = enum { 3139 before, 3140 after, 3141 + }; 3142 + 3143 + pub const ListModal = struct { 3144 + client: *Client, 3145 + /// the individual items we received 3146 + items: std.ArrayListUnmanaged(Item), 3147 + /// the list view 3148 + list_view: vxfw.ListView, 3149 + text_field: vxfw.TextField, 3150 + 3151 + filtered_items: std.ArrayList(Item), 3152 + 3153 + finished: bool, 3154 + is_shown: bool, 3155 + expecting_response: bool, 3156 + 3157 + focus: enum { text_field, list }, 3158 + 3159 + const name_width = 24; 3160 + const count_width = 8; 3161 + 3162 + // Item is a single RPL_LIST response 3163 + const Item = struct { 3164 + name: []const u8, 3165 + topic: []const u8, 3166 + count_str: []const u8, 3167 + count: u32, 3168 + 3169 + fn deinit(self: Item, alloc: Allocator) void { 3170 + alloc.free(self.name); 3171 + alloc.free(self.topic); 3172 + alloc.free(self.count_str); 3173 + } 3174 + 3175 + fn widget(self: *Item) vxfw.Widget { 3176 + return .{ 3177 + .userdata = self, 3178 + .drawFn = Item.draw, 3179 + }; 3180 + } 3181 + 3182 + fn lessThan(_: void, lhs: Item, rhs: Item) bool { 3183 + return lhs.count > rhs.count; 3184 + } 3185 + 3186 + fn draw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 3187 + const self: *Item = @ptrCast(@alignCast(ptr)); 3188 + 3189 + var children: std.ArrayListUnmanaged(vxfw.SubSurface) = try .initCapacity(ctx.arena, 3); 3190 + 3191 + const name_ctx = ctx.withConstraints(.{ .width = name_width, .height = 1 }, ctx.max); 3192 + const count_ctx = ctx.withConstraints(.{ .width = count_width, .height = 1 }, ctx.max); 3193 + const topic_ctx = ctx.withConstraints(.{ 3194 + .width = ctx.max.width.? -| name_width -| count_width - 2, 3195 + .height = 1, 3196 + }, ctx.max); 3197 + 3198 + const name: vxfw.Text = .{ .text = self.name, .softwrap = false }; 3199 + const count: vxfw.Text = .{ .text = self.count_str, .softwrap = false, .text_align = .right }; 3200 + const spans = try formatMessage(ctx.arena, undefined, self.topic); 3201 + const topic: vxfw.RichText = .{ .text = spans, .softwrap = false }; 3202 + 3203 + children.appendAssumeCapacity(.{ 3204 + .origin = .{ .col = 0, .row = 0 }, 3205 + .surface = try name.draw(name_ctx), 3206 + }); 3207 + children.appendAssumeCapacity(.{ 3208 + .origin = .{ .col = name_width, .row = 0 }, 3209 + .surface = try topic.draw(topic_ctx), 3210 + }); 3211 + children.appendAssumeCapacity(.{ 3212 + .origin = .{ .col = ctx.max.width.? -| count_width, .row = 0 }, 3213 + .surface = try count.draw(count_ctx), 3214 + }); 3215 + 3216 + return .{ 3217 + .size = .{ .width = ctx.max.width.?, .height = 1 }, 3218 + .widget = self.widget(), 3219 + .buffer = &.{}, 3220 + .children = children.items, 3221 + }; 3222 + } 3223 + }; 3224 + 3225 + fn init(self: *ListModal, gpa: Allocator, client: *Client) void { 3226 + self.* = .{ 3227 + .client = client, 3228 + .filtered_items = std.ArrayList(Item).init(gpa), 3229 + .items = .empty, 3230 + .list_view = .{ 3231 + .children = .{ 3232 + .builder = .{ 3233 + .userdata = self, 3234 + .buildFn = ListModal.getItem, 3235 + }, 3236 + }, 3237 + }, 3238 + .text_field = .init(gpa, client.app.unicode), 3239 + .finished = true, 3240 + .is_shown = false, 3241 + .focus = .text_field, 3242 + .expecting_response = false, 3243 + }; 3244 + self.text_field.style.bg = client.app.blendBg(10); 3245 + self.text_field.userdata = self; 3246 + self.text_field.onChange = ListModal.onChange; 3247 + } 3248 + 3249 + fn reset(self: *ListModal) !void { 3250 + self.items.clearRetainingCapacity(); 3251 + self.filtered_items.clearAndFree(); 3252 + self.text_field.clearAndFree(); 3253 + self.finished = false; 3254 + self.focus = .text_field; 3255 + self.is_shown = false; 3256 + } 3257 + 3258 + fn show(self: *ListModal, ctx: *vxfw.EventContext) !void { 3259 + self.is_shown = true; 3260 + switch (self.focus) { 3261 + .text_field => try ctx.requestFocus(self.text_field.widget()), 3262 + .list => try ctx.requestFocus(self.list_view.widget()), 3263 + } 3264 + return ctx.consumeAndRedraw(); 3265 + } 3266 + 3267 + pub fn widget(self: *ListModal) vxfw.Widget { 3268 + return .{ 3269 + .userdata = self, 3270 + .captureHandler = ListModal.captureHandler, 3271 + .drawFn = ListModal._draw, 3272 + }; 3273 + } 3274 + 3275 + fn deinit(self: *ListModal, alloc: std.mem.Allocator) void { 3276 + for (self.items.items) |item| { 3277 + item.deinit(alloc); 3278 + } 3279 + self.items.deinit(alloc); 3280 + self.filtered_items.deinit(); 3281 + self.text_field.deinit(); 3282 + self.* = undefined; 3283 + } 3284 + 3285 + fn addMessage(self: *ListModal, alloc: Allocator, msg: Message) !void { 3286 + var iter = msg.paramIterator(); 3287 + // client, we skip this one 3288 + _ = iter.next() orelse return; 3289 + const channel = iter.next() orelse { 3290 + log.warn("got RPL_LIST without channel", .{}); 3291 + return; 3292 + }; 3293 + const count = iter.next() orelse { 3294 + log.warn("got RPL_LIST without count", .{}); 3295 + return; 3296 + }; 3297 + const topic = iter.next() orelse { 3298 + log.warn("got RPL_LIST without topic", .{}); 3299 + return; 3300 + }; 3301 + const item: Item = .{ 3302 + .name = try alloc.dupe(u8, channel), 3303 + .count_str = try alloc.dupe(u8, count), 3304 + .topic = try alloc.dupe(u8, topic), 3305 + .count = try std.fmt.parseUnsigned(u32, count, 10), 3306 + }; 3307 + try self.items.append(alloc, item); 3308 + } 3309 + 3310 + fn finish(self: *ListModal, ctx: *vxfw.EventContext) !void { 3311 + self.finished = true; 3312 + self.is_shown = true; 3313 + std.mem.sort(Item, self.items.items, {}, Item.lessThan); 3314 + self.filtered_items.clearRetainingCapacity(); 3315 + try self.filtered_items.appendSlice(self.items.items); 3316 + try ctx.requestFocus(self.text_field.widget()); 3317 + } 3318 + 3319 + fn onChange(ptr: ?*anyopaque, ctx: *vxfw.EventContext, input: []const u8) anyerror!void { 3320 + const self: *ListModal = @ptrCast(@alignCast(ptr orelse unreachable)); 3321 + self.filtered_items.clearRetainingCapacity(); 3322 + for (self.items.items) |item| { 3323 + if (std.mem.indexOf(u8, item.name, input)) |_| { 3324 + try self.filtered_items.append(item); 3325 + } else if (std.mem.indexOf(u8, item.topic, input)) |_| { 3326 + try self.filtered_items.append(item); 3327 + } 3328 + } 3329 + return ctx.consumeAndRedraw(); 3330 + } 3331 + 3332 + fn captureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 3333 + const self: *ListModal = @ptrCast(@alignCast(ptr)); 3334 + switch (event) { 3335 + .key_press => |key| { 3336 + switch (self.focus) { 3337 + .text_field => { 3338 + if (key.matches(vaxis.Key.enter, .{})) { 3339 + try ctx.requestFocus(self.list_view.widget()); 3340 + self.focus = .list; 3341 + return ctx.consumeAndRedraw(); 3342 + } else if (key.matches(vaxis.Key.escape, .{})) { 3343 + self.close(ctx); 3344 + return; 3345 + } else if (key.matches(vaxis.Key.up, .{})) { 3346 + self.list_view.prevItem(ctx); 3347 + return ctx.consumeAndRedraw(); 3348 + } else if (key.matches(vaxis.Key.down, .{})) { 3349 + self.list_view.nextItem(ctx); 3350 + return ctx.consumeAndRedraw(); 3351 + } 3352 + }, 3353 + .list => { 3354 + if (key.matches(vaxis.Key.escape, .{})) { 3355 + try ctx.requestFocus(self.text_field.widget()); 3356 + self.focus = .text_field; 3357 + return ctx.consumeAndRedraw(); 3358 + } else if (key.matches(vaxis.Key.enter, .{})) { 3359 + if (self.filtered_items.items.len > 0) { 3360 + // join the selected room, and deinit the view 3361 + var buf: [128]u8 = undefined; 3362 + const item = self.filtered_items.items[self.list_view.cursor]; 3363 + const cmd = try std.fmt.bufPrint(&buf, "/join {s}", .{item.name}); 3364 + try self.client.app.handleCommand(.{ .client = self.client }, cmd); 3365 + } 3366 + self.close(ctx); 3367 + return; 3368 + } 3369 + }, 3370 + } 3371 + }, 3372 + else => {}, 3373 + } 3374 + } 3375 + 3376 + fn close(self: *ListModal, ctx: *vxfw.EventContext) void { 3377 + self.is_shown = false; 3378 + const selected = self.client.app.selectedBuffer() orelse unreachable; 3379 + self.client.app.selectBuffer(selected); 3380 + return ctx.consumeAndRedraw(); 3381 + } 3382 + 3383 + fn getItem(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget { 3384 + const self: *const ListModal = @ptrCast(@alignCast(ptr)); 3385 + if (idx < self.filtered_items.items.len) { 3386 + return self.filtered_items.items[idx].widget(); 3387 + } 3388 + return null; 3389 + } 3390 + 3391 + fn _draw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 3392 + const self: *ListModal = @ptrCast(@alignCast(ptr)); 3393 + return self.draw(ctx); 3394 + } 3395 + 3396 + fn draw(self: *ListModal, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 3397 + const max = ctx.max.size(); 3398 + var children: std.ArrayListUnmanaged(vxfw.SubSurface) = .empty; 3399 + 3400 + try children.append(ctx.arena, .{ 3401 + .origin = .{ .col = 0, .row = 0 }, 3402 + .surface = try self.text_field.draw(ctx), 3403 + }); 3404 + const list_ctx = ctx.withConstraints( 3405 + ctx.min, 3406 + .{ .width = max.width, .height = max.height - 2 }, 3407 + ); 3408 + try children.append(ctx.arena, .{ 3409 + .origin = .{ .col = 0, .row = 2 }, 3410 + .surface = try self.list_view.draw(list_ctx), 3411 + }); 3412 + 3413 + return .{ 3414 + .size = max, 3415 + .widget = self.widget(), 3416 + .buffer = &.{}, 3417 + .children = children.items, 3418 + }; 3419 + } 3420 }; 3421 3422 test "caseFold" {