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