an experimental irc client
at 8afedaf067830153d85352ca641f4e0e1106e276 649 lines 23 kB view raw
1const std = @import("std"); 2const builtin = @import("builtin"); 3const comlink = @import("comlink.zig"); 4const vaxis = @import("vaxis"); 5const zeit = @import("zeit"); 6const ziglua = @import("ziglua"); 7const Scrollbar = @import("Scrollbar.zig"); 8const main = @import("main.zig"); 9const format = @import("format.zig"); 10 11const irc = comlink.irc; 12const lua = comlink.lua; 13const mem = std.mem; 14const vxfw = vaxis.vxfw; 15 16const assert = std.debug.assert; 17 18const Allocator = std.mem.Allocator; 19const Base64Encoder = std.base64.standard.Encoder; 20const Bind = comlink.Bind; 21const Completer = comlink.Completer; 22const Event = comlink.Event; 23const Lua = ziglua.Lua; 24const TextInput = vaxis.widgets.TextInput; 25const WriteRequest = comlink.WriteRequest; 26 27const log = std.log.scoped(.app); 28 29const State = struct { 30 buffers: struct { 31 count: usize = 0, 32 width: u16 = 16, 33 } = .{}, 34 paste: struct { 35 pasting: bool = false, 36 has_newline: bool = false, 37 38 fn showDialog(self: @This()) bool { 39 return !self.pasting and self.has_newline; 40 } 41 } = .{}, 42}; 43 44pub const App = struct { 45 explicit_join: bool, 46 alloc: std.mem.Allocator, 47 /// System certificate bundle 48 bundle: std.crypto.Certificate.Bundle, 49 /// List of all configured clients 50 clients: std.ArrayList(*irc.Client), 51 /// if we have already called deinit 52 deinited: bool, 53 /// Process environment 54 env: std.process.EnvMap, 55 /// Local timezone 56 tz: zeit.TimeZone, 57 58 state: State, 59 60 completer: ?Completer, 61 62 should_quit: bool, 63 64 binds: std.ArrayList(Bind), 65 66 paste_buffer: std.ArrayList(u8), 67 68 lua: *Lua, 69 70 write_queue: comlink.WriteQueue, 71 write_thread: std.Thread, 72 73 view: vxfw.SplitView, 74 buffer_list: vxfw.ListView, 75 unicode: *const vaxis.Unicode, 76 77 title_buf: [128]u8, 78 79 // Only valid during an event handler 80 ctx: ?*vxfw.EventContext, 81 last_height: u16, 82 83 const default_rhs: vxfw.Text = .{ .text = "TODO: update this text" }; 84 85 /// initialize vaxis, lua state 86 pub fn init(self: *App, gpa: std.mem.Allocator, unicode: *const vaxis.Unicode) !void { 87 self.* = .{ 88 .alloc = gpa, 89 .state = .{}, 90 .clients = std.ArrayList(*irc.Client).init(gpa), 91 .env = try std.process.getEnvMap(gpa), 92 .binds = try std.ArrayList(Bind).initCapacity(gpa, 16), 93 .paste_buffer = std.ArrayList(u8).init(gpa), 94 .tz = try zeit.local(gpa, null), 95 .lua = undefined, 96 .write_queue = .{}, 97 .write_thread = undefined, 98 .view = .{ 99 .width = self.state.buffers.width, 100 .lhs = self.buffer_list.widget(), 101 .rhs = default_rhs.widget(), 102 }, 103 .explicit_join = false, 104 .bundle = .{}, 105 .deinited = false, 106 .completer = null, 107 .should_quit = false, 108 .buffer_list = .{ 109 .children = .{ 110 .builder = .{ 111 .userdata = self, 112 .buildFn = App.bufferBuilderFn, 113 }, 114 }, 115 .draw_cursor = false, 116 }, 117 .unicode = unicode, 118 .title_buf = undefined, 119 .ctx = null, 120 .last_height = 0, 121 }; 122 123 self.lua = try Lua.init(&self.alloc); 124 self.write_thread = try std.Thread.spawn(.{}, writeLoop, .{ self.alloc, &self.write_queue }); 125 126 try lua.init(self); 127 128 try self.binds.append(.{ 129 .key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } }, 130 .command = .quit, 131 }); 132 try self.binds.append(.{ 133 .key = .{ .codepoint = vaxis.Key.up, .mods = .{ .alt = true } }, 134 .command = .@"prev-channel", 135 }); 136 try self.binds.append(.{ 137 .key = .{ .codepoint = vaxis.Key.down, .mods = .{ .alt = true } }, 138 .command = .@"next-channel", 139 }); 140 try self.binds.append(.{ 141 .key = .{ .codepoint = 'l', .mods = .{ .ctrl = true } }, 142 .command = .redraw, 143 }); 144 145 // Get our system tls certs 146 try self.bundle.rescan(gpa); 147 } 148 149 /// close the application. This closes the TUI, disconnects clients, and cleans 150 /// up all resources 151 pub fn deinit(self: *App) void { 152 if (self.deinited) return; 153 self.deinited = true; 154 // Push a join command to the write thread 155 self.write_queue.push(.join); 156 157 // clean up clients 158 { 159 // Loop first to close connections. This will help us close faster by getting the 160 // threads exited 161 for (self.clients.items) |client| { 162 client.close(); 163 } 164 for (self.clients.items) |client| { 165 client.deinit(); 166 self.alloc.destroy(client); 167 } 168 self.clients.deinit(); 169 } 170 171 self.bundle.deinit(self.alloc); 172 173 if (self.completer) |*completer| completer.deinit(); 174 self.binds.deinit(); 175 self.paste_buffer.deinit(); 176 self.tz.deinit(); 177 178 // Join the write thread 179 self.write_thread.join(); 180 self.env.deinit(); 181 self.lua.deinit(); 182 } 183 184 pub fn widget(self: *App) vxfw.Widget { 185 return .{ 186 .userdata = self, 187 .captureHandler = App.typeErasedCaptureHandler, 188 .eventHandler = App.typeErasedEventHandler, 189 .drawFn = App.typeErasedDrawFn, 190 }; 191 } 192 193 fn typeErasedCaptureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 194 const self: *App = @ptrCast(@alignCast(ptr)); 195 // Rewrite the ctx pointer every frame. We don't actually need to do this with the current 196 // vxfw runtime, because the context pointer is always valid. But for safe keeping, we will 197 // do it this way. 198 // 199 // In general, this is bad practice. But we need to be able to access this from lua 200 // callbacks 201 self.ctx = ctx; 202 switch (event) { 203 .key_press => |key| { 204 if (key.matches('c', .{ .ctrl = true })) { 205 ctx.quit = true; 206 } 207 }, 208 else => {}, 209 } 210 } 211 212 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 213 const self: *App = @ptrCast(@alignCast(ptr)); 214 self.ctx = ctx; 215 switch (event) { 216 .init => { 217 const title = try std.fmt.bufPrint(&self.title_buf, "comlink", .{}); 218 try ctx.setTitle(title); 219 try ctx.tick(8, self.widget()); 220 }, 221 .key_press => |key| { 222 if (key.matches('c', .{ .ctrl = true })) { 223 ctx.quit = true; 224 } 225 for (self.binds.items) |bind| { 226 if (key.matches(bind.key.codepoint, bind.key.mods)) { 227 switch (bind.command) { 228 .quit => self.should_quit = true, 229 .@"next-channel" => self.nextChannel(), 230 .@"prev-channel" => self.prevChannel(), 231 // .redraw => self.vx.queueRefresh(), 232 .lua_function => |ref| try lua.execFn(self.lua, ref), 233 else => {}, 234 } 235 return ctx.consumeAndRedraw(); 236 } 237 } 238 }, 239 .tick => { 240 for (self.clients.items) |client| { 241 if (client.status.load(.unordered) == .disconnected and 242 client.retry_delay_s == 0) 243 { 244 ctx.redraw = true; 245 try irc.Client.retryTickHandler(client, ctx, .tick); 246 } 247 client.drainFifo(ctx); 248 } 249 try ctx.tick(8, self.widget()); 250 }, 251 else => {}, 252 } 253 } 254 255 fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 256 const self: *App = @ptrCast(@alignCast(ptr)); 257 const max = ctx.max.size(); 258 self.last_height = max.height; 259 if (self.selectedBuffer()) |buffer| { 260 switch (buffer) { 261 .client => |client| self.view.rhs = client.view(), 262 .channel => |channel| self.view.rhs = channel.view.widget(), 263 } 264 } else self.view.rhs = default_rhs.widget(); 265 266 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena); 267 268 // UI is a tree of splits 269 // │ │ │ │ 270 // │ │ │ │ 271 // │ buffers │ buffer content │ members │ 272 // │ │ │ │ 273 // │ │ │ │ 274 // │ │ │ │ 275 // │ │ │ │ 276 277 const sub: vxfw.SubSurface = .{ 278 .origin = .{ .col = 0, .row = 0 }, 279 .surface = try self.view.widget().draw(ctx), 280 }; 281 try children.append(sub); 282 283 return .{ 284 .size = ctx.max.size(), 285 .widget = self.widget(), 286 .buffer = &.{}, 287 .children = children.items, 288 }; 289 } 290 291 fn bufferBuilderFn(ptr: *const anyopaque, idx: usize, cursor: usize) ?vxfw.Widget { 292 const self: *const App = @ptrCast(@alignCast(ptr)); 293 var i: usize = 0; 294 for (self.clients.items) |client| { 295 if (i == idx) return client.nameWidget(i == cursor); 296 i += 1; 297 for (client.channels.items) |channel| { 298 if (i == idx) return channel.nameWidget(i == cursor); 299 i += 1; 300 } 301 } 302 return null; 303 } 304 305 fn contentWidget(self: *App) vxfw.Widget { 306 return .{ 307 .userdata = self, 308 .captureHandler = null, 309 .eventHandler = null, 310 .drawFn = App.typeErasedContentDrawFn, 311 }; 312 } 313 314 fn typeErasedContentDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 315 _ = ptr; 316 const text: vxfw.Text = .{ .text = "content" }; 317 return text.draw(ctx); 318 } 319 320 fn memberWidget(self: *App) vxfw.Widget { 321 return .{ 322 .userdata = self, 323 .captureHandler = null, 324 .eventHandler = null, 325 .drawFn = App.typeErasedMembersDrawFn, 326 }; 327 } 328 329 fn typeErasedMembersDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 330 _ = ptr; 331 const text: vxfw.Text = .{ .text = "members" }; 332 return text.draw(ctx); 333 } 334 335 pub fn connect(self: *App, cfg: irc.Client.Config) !void { 336 const client = try self.alloc.create(irc.Client); 337 client.* = try irc.Client.init(self.alloc, self, &self.write_queue, cfg); 338 try self.clients.append(client); 339 } 340 341 pub fn nextChannel(self: *App) void { 342 // When leaving a channel we mark it as read, so we make sure that's done 343 // before we change to the new channel. 344 self.markSelectedChannelRead(); 345 if (self.ctx) |ctx| { 346 self.buffer_list.nextItem(ctx); 347 if (self.selectedBuffer()) |buffer| { 348 switch (buffer) { 349 .client => {}, 350 .channel => |channel| { 351 ctx.requestFocus(channel.text_field.widget()) catch {}; 352 }, 353 } 354 } 355 } 356 } 357 358 pub fn prevChannel(self: *App) void { 359 // When leaving a channel we mark it as read, so we make sure that's done 360 // before we change to the new channel. 361 self.markSelectedChannelRead(); 362 if (self.ctx) |ctx| { 363 self.buffer_list.prevItem(ctx); 364 if (self.selectedBuffer()) |buffer| { 365 switch (buffer) { 366 .client => {}, 367 .channel => |channel| { 368 ctx.requestFocus(channel.text_field.widget()) catch {}; 369 }, 370 } 371 } 372 } 373 } 374 375 pub fn selectChannelName(self: *App, cl: *irc.Client, name: []const u8) void { 376 var i: usize = 0; 377 for (self.clients.items) |client| { 378 i += 1; 379 for (client.channels.items) |channel| { 380 if (cl == client) { 381 if (std.mem.eql(u8, name, channel.name)) { 382 self.selectBuffer(.{ .channel = channel }); 383 } 384 } 385 i += 1; 386 } 387 } 388 } 389 390 /// handle a command 391 pub fn handleCommand(self: *App, buffer: irc.Buffer, cmd: []const u8) !void { 392 const lua_state = self.lua; 393 const command: comlink.Command = blk: { 394 const start: u1 = if (cmd[0] == '/') 1 else 0; 395 const end = mem.indexOfScalar(u8, cmd, ' ') orelse cmd.len; 396 if (comlink.Command.fromString(cmd[start..end])) |internal| 397 break :blk internal; 398 if (comlink.Command.user_commands.get(cmd[start..end])) |ref| { 399 const str = if (end == cmd.len) "" else std.mem.trim(u8, cmd[end..], " "); 400 return lua.execUserCommand(lua_state, str, ref); 401 } 402 return error.UnknownCommand; 403 }; 404 var buf: [1024]u8 = undefined; 405 const client: *irc.Client = switch (buffer) { 406 .client => |client| client, 407 .channel => |channel| channel.client, 408 }; 409 const channel: ?*irc.Channel = switch (buffer) { 410 .client => null, 411 .channel => |channel| channel, 412 }; 413 switch (command) { 414 .quote => { 415 const start = mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 416 const msg = try std.fmt.bufPrint( 417 &buf, 418 "{s}\r\n", 419 .{cmd[start + 1 ..]}, 420 ); 421 return client.queueWrite(msg); 422 }, 423 .join => { 424 const start = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 425 const msg = try std.fmt.bufPrint( 426 &buf, 427 "JOIN {s}\r\n", 428 .{ 429 cmd[start + 1 ..], 430 }, 431 ); 432 // Ensure buffer exists 433 self.explicit_join = true; 434 return client.queueWrite(msg); 435 }, 436 .me => { 437 if (channel == null) return error.InvalidCommand; 438 const msg = try std.fmt.bufPrint( 439 &buf, 440 "PRIVMSG {s} :\x01ACTION {s}\x01\r\n", 441 .{ 442 channel.?.name, 443 cmd[4..], 444 }, 445 ); 446 return client.queueWrite(msg); 447 }, 448 .msg => { 449 //syntax: /msg <nick> <msg> 450 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 451 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse return error.InvalidCommand; 452 const msg = try std.fmt.bufPrint( 453 &buf, 454 "PRIVMSG {s} :{s}\r\n", 455 .{ 456 cmd[s + 1 .. e], 457 cmd[e + 1 ..], 458 }, 459 ); 460 return client.queueWrite(msg); 461 }, 462 .query => { 463 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 464 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse cmd.len; 465 if (cmd[s + 1] == '#') return error.InvalidCommand; 466 467 const ch = try client.getOrCreateChannel(cmd[s + 1 .. e]); 468 try client.requestHistory(.after, ch); 469 self.selectChannelName(client, ch.name); 470 //handle sending the message 471 if (cmd.len - e > 1) { 472 const msg = try std.fmt.bufPrint( 473 &buf, 474 "PRIVMSG {s} :{s}\r\n", 475 .{ 476 cmd[s + 1 .. e], 477 cmd[e + 1 ..], 478 }, 479 ); 480 return client.queueWrite(msg); 481 } 482 }, 483 .names => { 484 if (channel == null) return error.InvalidCommand; 485 const msg = try std.fmt.bufPrint(&buf, "NAMES {s}\r\n", .{channel.?.name}); 486 return client.queueWrite(msg); 487 }, 488 .@"next-channel" => self.nextChannel(), 489 .@"prev-channel" => self.prevChannel(), 490 .quit => self.should_quit = true, 491 .who => { 492 if (channel == null) return error.InvalidCommand; 493 const msg = try std.fmt.bufPrint( 494 &buf, 495 "WHO {s}\r\n", 496 .{ 497 channel.?.name, 498 }, 499 ); 500 return client.queueWrite(msg); 501 }, 502 .part, .close => { 503 if (channel == null) return error.InvalidCommand; 504 var it = std.mem.tokenizeScalar(u8, cmd, ' '); 505 506 // Skip command 507 _ = it.next(); 508 const target = it.next() orelse channel.?.name; 509 510 if (target[0] != '#') { 511 for (client.channels.items, 0..) |search, i| { 512 if (!mem.eql(u8, search.name, target)) continue; 513 var chan = client.channels.orderedRemove(i); 514 self.buffer_list.cursor -|= 1; 515 self.buffer_list.ensureScroll(); 516 chan.deinit(self.alloc); 517 self.alloc.destroy(chan); 518 break; 519 } 520 } else { 521 const msg = try std.fmt.bufPrint( 522 &buf, 523 "PART {s}\r\n", 524 .{ 525 target, 526 }, 527 ); 528 return client.queueWrite(msg); 529 } 530 }, 531 .redraw => {}, 532 // .redraw => self.vx.queueRefresh(), 533 .version => { 534 if (channel == null) return error.InvalidCommand; 535 const msg = try std.fmt.bufPrint( 536 &buf, 537 "NOTICE {s} :\x01VERSION comlink {s}\x01\r\n", 538 .{ 539 channel.?.name, 540 main.version, 541 }, 542 ); 543 return client.queueWrite(msg); 544 }, 545 .lua_function => {}, // we don't handle these from the text-input 546 } 547 } 548 549 pub fn selectedBuffer(self: *App) ?irc.Buffer { 550 var i: usize = 0; 551 for (self.clients.items) |client| { 552 if (i == self.buffer_list.cursor) return .{ .client = client }; 553 i += 1; 554 for (client.channels.items) |channel| { 555 if (i == self.buffer_list.cursor) return .{ .channel = channel }; 556 i += 1; 557 } 558 } 559 return null; 560 } 561 562 pub fn selectBuffer(self: *App, buffer: irc.Buffer) void { 563 self.markSelectedChannelRead(); 564 var i: u32 = 0; 565 switch (buffer) { 566 .client => |target| { 567 for (self.clients.items) |client| { 568 if (client == target) { 569 self.buffer_list.cursor = i; 570 self.buffer_list.ensureScroll(); 571 return; 572 } 573 i += 1; 574 for (client.channels.items) |_| i += 1; 575 } 576 }, 577 .channel => |target| { 578 for (self.clients.items) |client| { 579 i += 1; 580 for (client.channels.items) |channel| { 581 if (channel == target) { 582 self.buffer_list.cursor = i; 583 self.buffer_list.ensureScroll(); 584 if (target.messageViewIsAtBottom()) target.has_unread = false; 585 if (self.ctx) |ctx| { 586 ctx.requestFocus(channel.text_field.widget()) catch {}; 587 } 588 return; 589 } 590 i += 1; 591 } 592 } 593 }, 594 } 595 } 596 597 pub fn markSelectedChannelRead(self: *App) void { 598 const buffer = self.selectedBuffer() orelse return; 599 600 switch (buffer) { 601 .channel => |channel| { 602 if (channel.messageViewIsAtBottom()) channel.markRead() catch return; 603 }, 604 else => {}, 605 } 606 } 607}; 608 609/// this loop is run in a separate thread and handles writes to all clients. 610/// Message content is deallocated when the write request is completed 611fn writeLoop(alloc: std.mem.Allocator, queue: *comlink.WriteQueue) !void { 612 log.debug("starting write thread", .{}); 613 while (true) { 614 const req = queue.pop(); 615 switch (req) { 616 .write => |w| { 617 try w.client.write(w.msg); 618 alloc.free(w.msg); 619 }, 620 .join => { 621 while (queue.tryPop()) |r| { 622 switch (r) { 623 .write => |w| alloc.free(w.msg), 624 else => {}, 625 } 626 } 627 return; 628 }, 629 } 630 } 631} 632 633/// Returns the number of lines the segments would consume in the given window 634fn lineCountForWindow(win: vaxis.Window, segments: []const vaxis.Segment) usize { 635 // Fastpath if we have fewer bytes than the width 636 var byte_count: usize = 0; 637 for (segments) |segment| { 638 byte_count += segment.text.len; 639 } 640 // One line if we are fewer bytes than the width 641 if (byte_count <= win.width) return 1; 642 643 // Slow path. We have to layout the text 644 const result = win.print(segments, .{ .commit = false, .wrap = .word }) catch return 0; 645 if (result.col == 0) 646 return result.row 647 else 648 return result.row + 1; 649}