an experimental irc client
at ec2fb6ec28aa8aa40e3c97fa3ebb23afeba605bf 1958 lines 90 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 mouse: ?vaxis.Mouse = null, 31 members: struct { 32 scroll_offset: usize = 0, 33 width: u16 = 16, 34 resizing: bool = false, 35 } = .{}, 36 messages: struct { 37 scroll_offset: usize = 0, 38 pending_scroll: isize = 0, 39 } = .{}, 40 buffers: struct { 41 scroll_offset: usize = 0, 42 count: usize = 0, 43 selected_idx: usize = 0, 44 width: u16 = 16, 45 resizing: bool = false, 46 } = .{}, 47 paste: struct { 48 pasting: bool = false, 49 has_newline: bool = false, 50 51 fn showDialog(self: @This()) bool { 52 return !self.pasting and self.has_newline; 53 } 54 } = .{}, 55}; 56 57pub const App = struct { 58 explicit_join: bool, 59 alloc: std.mem.Allocator, 60 /// System certificate bundle 61 bundle: std.crypto.Certificate.Bundle, 62 /// List of all configured clients 63 clients: std.ArrayList(*irc.Client), 64 /// if we have already called deinit 65 deinited: bool, 66 /// Process environment 67 env: std.process.EnvMap, 68 /// Local timezone 69 tz: zeit.TimeZone, 70 71 state: State, 72 73 completer: ?Completer, 74 75 should_quit: bool, 76 77 binds: std.ArrayList(Bind), 78 79 paste_buffer: std.ArrayList(u8), 80 81 lua: *Lua, 82 83 write_queue: comlink.WriteQueue, 84 write_thread: std.Thread, 85 86 view: vxfw.SplitView, 87 buffer_list: vxfw.ListView, 88 unicode: *const vaxis.Unicode, 89 90 title_buf: [128]u8, 91 92 const default_rhs: vxfw.Text = .{ .text = "TODO: update this text" }; 93 94 /// initialize vaxis, lua state 95 pub fn init(self: *App, gpa: std.mem.Allocator, unicode: *const vaxis.Unicode) !void { 96 self.* = .{ 97 .alloc = gpa, 98 .state = .{}, 99 .clients = std.ArrayList(*irc.Client).init(gpa), 100 .env = try std.process.getEnvMap(gpa), 101 .binds = try std.ArrayList(Bind).initCapacity(gpa, 16), 102 .paste_buffer = std.ArrayList(u8).init(gpa), 103 .tz = try zeit.local(gpa, null), 104 .lua = undefined, 105 .write_queue = .{}, 106 .write_thread = undefined, 107 .view = .{ 108 .width = self.state.buffers.width, 109 .lhs = self.buffer_list.widget(), 110 .rhs = default_rhs.widget(), 111 }, 112 .explicit_join = false, 113 .bundle = .{}, 114 .deinited = false, 115 .completer = null, 116 .should_quit = false, 117 .buffer_list = .{ 118 .children = .{ 119 .builder = .{ 120 .userdata = self, 121 .buildFn = App.bufferBuilderFn, 122 }, 123 }, 124 .draw_cursor = false, 125 }, 126 .unicode = unicode, 127 .title_buf = undefined, 128 }; 129 130 self.lua = try Lua.init(&self.alloc); 131 self.write_thread = try std.Thread.spawn(.{}, writeLoop, .{ self.alloc, &self.write_queue }); 132 133 try lua.init(self); 134 135 try self.binds.append(.{ 136 .key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } }, 137 .command = .quit, 138 }); 139 try self.binds.append(.{ 140 .key = .{ .codepoint = vaxis.Key.up, .mods = .{ .alt = true } }, 141 .command = .@"prev-channel", 142 }); 143 try self.binds.append(.{ 144 .key = .{ .codepoint = vaxis.Key.down, .mods = .{ .alt = true } }, 145 .command = .@"next-channel", 146 }); 147 try self.binds.append(.{ 148 .key = .{ .codepoint = 'l', .mods = .{ .ctrl = true } }, 149 .command = .redraw, 150 }); 151 152 // Get our system tls certs 153 try self.bundle.rescan(gpa); 154 } 155 156 /// close the application. This closes the TUI, disconnects clients, and cleans 157 /// up all resources 158 pub fn deinit(self: *App) void { 159 if (self.deinited) return; 160 self.deinited = true; 161 // Push a join command to the write thread 162 self.write_queue.push(.join); 163 164 // clean up clients 165 { 166 for (self.clients.items, 0..) |_, i| { 167 var client = self.clients.items[i]; 168 client.deinit(); 169 if (builtin.mode == .Debug) { 170 // We only clean up clients in Debug mode so we can check for memory leaks 171 // without failing for this. We don't care about it in any other mode since we 172 // are exiting anyways and we want to do it fast. If we destroy, our readthread 173 // could panic so we don't do it unless we have to. 174 self.alloc.destroy(client); 175 } 176 } 177 self.clients.deinit(); 178 } 179 180 self.bundle.deinit(self.alloc); 181 182 if (self.completer) |*completer| completer.deinit(); 183 self.binds.deinit(); 184 self.paste_buffer.deinit(); 185 self.tz.deinit(); 186 187 // Join the write thread 188 self.write_thread.join(); 189 self.env.deinit(); 190 self.lua.deinit(); 191 } 192 193 pub fn widget(self: *App) vxfw.Widget { 194 return .{ 195 .userdata = self, 196 .captureHandler = App.typeErasedCaptureHandler, 197 .eventHandler = App.typeErasedEventHandler, 198 .drawFn = App.typeErasedDrawFn, 199 }; 200 } 201 202 fn typeErasedCaptureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 203 // const self: *App = @ptrCast(@alignCast(ptr)); 204 _ = ptr; 205 switch (event) { 206 .key_press => |key| { 207 if (key.matches('c', .{ .ctrl = true })) { 208 ctx.quit = true; 209 } 210 }, 211 else => {}, 212 } 213 } 214 215 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 216 const self: *App = @ptrCast(@alignCast(ptr)); 217 switch (event) { 218 .init => { 219 const title = try std.fmt.bufPrint(&self.title_buf, "comlink", .{}); 220 try ctx.setTitle(title); 221 try ctx.tick(8, self.widget()); 222 }, 223 .key_press => |key| { 224 if (key.matches('c', .{ .ctrl = true })) { 225 ctx.quit = true; 226 } 227 }, 228 .tick => { 229 for (self.clients.items) |client| { 230 client.drainFifo(ctx); 231 } 232 try ctx.tick(8, self.widget()); 233 }, 234 else => {}, 235 } 236 } 237 238 fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 239 const self: *App = @ptrCast(@alignCast(ptr)); 240 if (self.selectedBuffer()) |buffer| { 241 switch (buffer) { 242 .client => |client| self.view.rhs = client.view(), 243 .channel => |channel| self.view.rhs = channel.view.widget(), 244 } 245 } else self.view.rhs = default_rhs.widget(); 246 247 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena); 248 _ = &children; 249 250 // UI is a tree of splits 251 // │ │ │ │ 252 // │ │ │ │ 253 // │ buffers │ buffer content │ members │ 254 // │ │ │ │ 255 // │ │ │ │ 256 // │ │ │ │ 257 // │ │ │ │ 258 259 const sub: vxfw.SubSurface = .{ 260 .origin = .{ .col = 0, .row = 0 }, 261 .surface = try self.view.widget().draw(ctx), 262 }; 263 try children.append(sub); 264 265 return .{ 266 .size = ctx.max.size(), 267 .widget = self.widget(), 268 .buffer = &.{}, 269 .children = children.items, 270 }; 271 } 272 273 fn bufferBuilderFn(ptr: *const anyopaque, idx: usize, cursor: usize) ?vxfw.Widget { 274 const self: *const App = @ptrCast(@alignCast(ptr)); 275 var i: usize = 0; 276 for (self.clients.items) |client| { 277 if (i == idx) return client.nameWidget(i == cursor); 278 i += 1; 279 for (client.channels.items) |channel| { 280 if (i == idx) return channel.nameWidget(i == cursor); 281 i += 1; 282 } 283 } 284 return null; 285 } 286 287 fn contentWidget(self: *App) vxfw.Widget { 288 return .{ 289 .userdata = self, 290 .captureHandler = null, 291 .eventHandler = null, 292 .drawFn = App.typeErasedContentDrawFn, 293 }; 294 } 295 296 fn typeErasedContentDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 297 _ = ptr; 298 const text: vxfw.Text = .{ .text = "content" }; 299 return text.draw(ctx); 300 } 301 302 fn memberWidget(self: *App) vxfw.Widget { 303 return .{ 304 .userdata = self, 305 .captureHandler = null, 306 .eventHandler = null, 307 .drawFn = App.typeErasedMembersDrawFn, 308 }; 309 } 310 311 fn typeErasedMembersDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 312 _ = ptr; 313 const text: vxfw.Text = .{ .text = "members" }; 314 return text.draw(ctx); 315 } 316 317 // pub fn run(self: *App, lua_state: *Lua) !void { 318 // const writer = self.tty.anyWriter(); 319 // 320 // var loop: comlink.EventLoop = .{ .vaxis = &self.vx, .tty = &self.tty }; 321 // try loop.init(); 322 // try loop.start(); 323 // defer loop.stop(); 324 // 325 // try self.vx.enterAltScreen(writer); 326 // try self.vx.queryTerminal(writer, 1 * std.time.ns_per_s); 327 // try self.vx.setMouseMode(writer, true); 328 // try self.vx.setBracketedPaste(writer, true); 329 // 330 // // start our write thread 331 // var write_queue: comlink.WriteQueue = .{}; 332 // const write_thread = try std.Thread.spawn(.{}, writeLoop, .{ self.alloc, &write_queue }); 333 // defer { 334 // write_queue.push(.join); 335 // write_thread.join(); 336 // } 337 // 338 // // initialize lua state 339 // try lua.init(self, lua_state, &loop); 340 // 341 // var input = TextInput.init(self.alloc, &self.vx.unicode); 342 // defer input.deinit(); 343 // 344 // var last_frame: i64 = std.time.milliTimestamp(); 345 // loop: while (!self.should_quit) { 346 // var redraw: bool = false; 347 // std.time.sleep(8 * std.time.ns_per_ms); 348 // if (self.state.messages.pending_scroll != 0) { 349 // redraw = true; 350 // if (self.state.messages.pending_scroll > 0) { 351 // self.state.messages.pending_scroll -= 1; 352 // self.state.messages.scroll_offset += 1; 353 // } else { 354 // self.state.messages.pending_scroll += 1; 355 // self.state.messages.scroll_offset -|= 1; 356 // } 357 // } 358 // while (loop.tryEvent()) |event| { 359 // redraw = true; 360 // switch (event) { 361 // .redraw => {}, 362 // .key_press => |key| { 363 // if (self.state.paste.showDialog()) { 364 // if (key.matches(vaxis.Key.escape, .{})) { 365 // self.state.paste.has_newline = false; 366 // self.paste_buffer.clearAndFree(); 367 // } 368 // break; 369 // } 370 // if (self.state.paste.pasting) { 371 // if (key.matches(vaxis.Key.enter, .{})) { 372 // self.state.paste.has_newline = true; 373 // try self.paste_buffer.append('\n'); 374 // continue :loop; 375 // } 376 // const text = key.text orelse continue :loop; 377 // try self.paste_buffer.appendSlice(text); 378 // continue; 379 // } 380 // for (self.binds.items) |bind| { 381 // if (key.matches(bind.key.codepoint, bind.key.mods)) { 382 // switch (bind.command) { 383 // .quit => self.should_quit = true, 384 // .@"next-channel" => self.nextChannel(), 385 // .@"prev-channel" => self.prevChannel(), 386 // .redraw => self.vx.queueRefresh(), 387 // .lua_function => |ref| try lua.execFn(lua_state, ref), 388 // else => {}, 389 // } 390 // break; 391 // } 392 // } else if (key.matches(vaxis.Key.tab, .{})) { 393 // // if we already have a completion word, then we are 394 // // cycling through the options 395 // if (self.completer) |*completer| { 396 // const line = completer.next(); 397 // input.clearRetainingCapacity(); 398 // try input.insertSliceAtCursor(line); 399 // } else { 400 // var completion_buf: [irc.maximum_message_size]u8 = undefined; 401 // const content = input.sliceToCursor(&completion_buf); 402 // self.completer = try Completer.init(self.alloc, content); 403 // } 404 // } else if (key.matches(vaxis.Key.tab, .{ .shift = true })) { 405 // if (self.completer) |*completer| { 406 // const line = completer.prev(); 407 // input.clearRetainingCapacity(); 408 // try input.insertSliceAtCursor(line); 409 // } 410 // } else if (key.matches(vaxis.Key.enter, .{})) { 411 // const buffer = self.selectedBuffer() orelse @panic("no buffer"); 412 // const content = try input.toOwnedSlice(); 413 // if (content.len == 0) continue; 414 // defer self.alloc.free(content); 415 // if (content[0] == '/') 416 // self.handleCommand(lua_state, buffer, content) catch |err| { 417 // log.err("couldn't handle command: {}", .{err}); 418 // } 419 // else { 420 // switch (buffer) { 421 // .channel => |channel| { 422 // var buf: [1024]u8 = undefined; 423 // const msg = try std.fmt.bufPrint( 424 // &buf, 425 // "PRIVMSG {s} :{s}\r\n", 426 // .{ 427 // channel.name, 428 // content, 429 // }, 430 // ); 431 // try channel.client.queueWrite(msg); 432 // }, 433 // .client => log.err("can't send message to client", .{}), 434 // } 435 // } 436 // if (self.completer != null) { 437 // self.completer.?.deinit(); 438 // self.completer = null; 439 // } 440 // } else if (key.matches(vaxis.Key.page_up, .{})) { 441 // self.state.messages.scroll_offset +|= 3; 442 // } else if (key.matches(vaxis.Key.page_down, .{})) { 443 // self.state.messages.scroll_offset -|= 3; 444 // } else if (key.matches(vaxis.Key.home, .{})) { 445 // self.state.messages.scroll_offset = 0; 446 // } else { 447 // if (self.completer != null and !key.isModifier()) { 448 // self.completer.?.deinit(); 449 // self.completer = null; 450 // } 451 // log.debug("{}", .{key}); 452 // try input.update(.{ .key_press = key }); 453 // } 454 // }, 455 // .paste_start => self.state.paste.pasting = true, 456 // .paste_end => { 457 // self.state.paste.pasting = false; 458 // if (self.state.paste.has_newline) { 459 // log.warn("NEWLINE", .{}); 460 // } else { 461 // try input.insertSliceAtCursor(self.paste_buffer.items); 462 // defer self.paste_buffer.clearAndFree(); 463 // } 464 // }, 465 // .focus_out => self.state.mouse = null, 466 // .mouse => |mouse| { 467 // self.state.mouse = mouse; 468 // }, 469 // .winsize => |ws| try self.vx.resize(self.alloc, writer, ws), 470 // .connect => |cfg| { 471 // const client = try self.alloc.create(irc.Client); 472 // client.* = try irc.Client.init(self.alloc, self, &write_queue, cfg); 473 // client.thread = try std.Thread.spawn(.{}, irc.Client.readLoop, .{ client, &loop }); 474 // try self.clients.append(client); 475 // }, 476 // .irc => |irc_event| { 477 // const msg: irc.Message = .{ .bytes = irc_event.msg.slice() }; 478 // const client = irc_event.client; 479 // defer irc_event.msg.deinit(); 480 // switch (msg.command()) { 481 // .unknown => {}, 482 // .CAP => { 483 // // syntax: <client> <ACK/NACK> :caps 484 // var iter = msg.paramIterator(); 485 // _ = iter.next() orelse continue; // client 486 // const ack_or_nak = iter.next() orelse continue; 487 // const caps = iter.next() orelse continue; 488 // var cap_iter = mem.splitScalar(u8, caps, ' '); 489 // while (cap_iter.next()) |cap| { 490 // if (mem.eql(u8, ack_or_nak, "ACK")) { 491 // client.ack(cap); 492 // if (mem.eql(u8, cap, "sasl")) 493 // try client.queueWrite("AUTHENTICATE PLAIN\r\n"); 494 // } else if (mem.eql(u8, ack_or_nak, "NAK")) { 495 // log.debug("CAP not supported {s}", .{cap}); 496 // } 497 // } 498 // }, 499 // .AUTHENTICATE => { 500 // var iter = msg.paramIterator(); 501 // while (iter.next()) |param| { 502 // // A '+' is the continuuation to send our 503 // // AUTHENTICATE info 504 // if (!mem.eql(u8, param, "+")) continue; 505 // var buf: [4096]u8 = undefined; 506 // const config = client.config; 507 // const sasl = try std.fmt.bufPrint( 508 // &buf, 509 // "{s}\x00{s}\x00{s}", 510 // .{ config.user, config.nick, config.password }, 511 // ); 512 // 513 // // Create a buffer big enough for the base64 encoded string 514 // const b64_buf = try self.alloc.alloc(u8, Base64Encoder.calcSize(sasl.len)); 515 // defer self.alloc.free(b64_buf); 516 // const encoded = Base64Encoder.encode(b64_buf, sasl); 517 // // Make our message 518 // const auth = try std.fmt.bufPrint( 519 // &buf, 520 // "AUTHENTICATE {s}\r\n", 521 // .{encoded}, 522 // ); 523 // try client.queueWrite(auth); 524 // if (config.network_id) |id| { 525 // const bind = try std.fmt.bufPrint( 526 // &buf, 527 // "BOUNCER BIND {s}\r\n", 528 // .{id}, 529 // ); 530 // try client.queueWrite(bind); 531 // } 532 // try client.queueWrite("CAP END\r\n"); 533 // } 534 // }, 535 // .RPL_WELCOME => { 536 // const now = try zeit.instant(.{}); 537 // var now_buf: [30]u8 = undefined; 538 // const now_fmt = try now.time().bufPrint(&now_buf, .rfc3339); 539 // 540 // const past = try now.subtract(.{ .days = 7 }); 541 // var past_buf: [30]u8 = undefined; 542 // const past_fmt = try past.time().bufPrint(&past_buf, .rfc3339); 543 // 544 // var buf: [128]u8 = undefined; 545 // const targets = try std.fmt.bufPrint( 546 // &buf, 547 // "CHATHISTORY TARGETS timestamp={s} timestamp={s} 50\r\n", 548 // .{ now_fmt, past_fmt }, 549 // ); 550 // try client.queueWrite(targets); 551 // // on_connect callback 552 // try lua.onConnect(lua_state, client); 553 // }, 554 // .RPL_YOURHOST => {}, 555 // .RPL_CREATED => {}, 556 // .RPL_MYINFO => {}, 557 // .RPL_ISUPPORT => { 558 // // syntax: <client> <token>[ <token>] :are supported 559 // var iter = msg.paramIterator(); 560 // _ = iter.next() orelse continue; // client 561 // while (iter.next()) |token| { 562 // if (mem.eql(u8, token, "WHOX")) 563 // client.supports.whox = true 564 // else if (mem.startsWith(u8, token, "PREFIX")) { 565 // const prefix = blk: { 566 // const idx = mem.indexOfScalar(u8, token, ')') orelse 567 // // default is "@+" 568 // break :blk try self.alloc.dupe(u8, "@+"); 569 // break :blk try self.alloc.dupe(u8, token[idx + 1 ..]); 570 // }; 571 // client.supports.prefix = prefix; 572 // } 573 // } 574 // }, 575 // .RPL_LOGGEDIN => {}, 576 // .RPL_TOPIC => { 577 // // syntax: <client> <channel> :<topic> 578 // var iter = msg.paramIterator(); 579 // _ = iter.next() orelse continue :loop; // client ("*") 580 // const channel_name = iter.next() orelse continue :loop; // channel 581 // const topic = iter.next() orelse continue :loop; // topic 582 // 583 // var channel = try client.getOrCreateChannel(channel_name); 584 // if (channel.topic) |old_topic| { 585 // self.alloc.free(old_topic); 586 // } 587 // channel.topic = try self.alloc.dupe(u8, topic); 588 // }, 589 // .RPL_SASLSUCCESS => {}, 590 // .RPL_WHOREPLY => { 591 // // syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name> 592 // var iter = msg.paramIterator(); 593 // _ = iter.next() orelse continue :loop; // client 594 // const channel_name = iter.next() orelse continue :loop; // channel 595 // if (mem.eql(u8, channel_name, "*")) continue; 596 // _ = iter.next() orelse continue :loop; // username 597 // _ = iter.next() orelse continue :loop; // host 598 // _ = iter.next() orelse continue :loop; // server 599 // const nick = iter.next() orelse continue :loop; // nick 600 // const flags = iter.next() orelse continue :loop; // flags 601 // 602 // const user_ptr = try client.getOrCreateUser(nick); 603 // if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true; 604 // var channel = try client.getOrCreateChannel(channel_name); 605 // 606 // const prefix = for (flags) |c| { 607 // if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| { 608 // break c; 609 // } 610 // } else ' '; 611 // 612 // try channel.addMember(user_ptr, .{ .prefix = prefix }); 613 // }, 614 // .RPL_WHOSPCRPL => { 615 // // syntax: <client> <channel> <nick> <flags> :<realname> 616 // var iter = msg.paramIterator(); 617 // _ = iter.next() orelse continue; 618 // const channel_name = iter.next() orelse continue; // channel 619 // const nick = iter.next() orelse continue; 620 // const flags = iter.next() orelse continue; 621 // 622 // const user_ptr = try client.getOrCreateUser(nick); 623 // if (iter.next()) |real_name| { 624 // if (user_ptr.real_name) |old_name| { 625 // self.alloc.free(old_name); 626 // } 627 // user_ptr.real_name = try self.alloc.dupe(u8, real_name); 628 // } 629 // if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true; 630 // var channel = try client.getOrCreateChannel(channel_name); 631 // 632 // const prefix = for (flags) |c| { 633 // if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| { 634 // break c; 635 // } 636 // } else ' '; 637 // 638 // try channel.addMember(user_ptr, .{ .prefix = prefix }); 639 // }, 640 // .RPL_ENDOFWHO => { 641 // // syntax: <client> <mask> :End of WHO list 642 // var iter = msg.paramIterator(); 643 // _ = iter.next() orelse continue :loop; // client 644 // const channel_name = iter.next() orelse continue :loop; // channel 645 // if (mem.eql(u8, channel_name, "*")) continue; 646 // var channel = try client.getOrCreateChannel(channel_name); 647 // channel.in_flight.who = false; 648 // }, 649 // .RPL_NAMREPLY => { 650 // // syntax: <client> <symbol> <channel> :[<prefix>]<nick>{ [<prefix>]<nick>} 651 // var iter = msg.paramIterator(); 652 // _ = iter.next() orelse continue; // client 653 // _ = iter.next() orelse continue; // symbol 654 // const channel_name = iter.next() orelse continue; // channel 655 // const names = iter.next() orelse continue; 656 // var channel = try client.getOrCreateChannel(channel_name); 657 // var name_iter = std.mem.splitScalar(u8, names, ' '); 658 // while (name_iter.next()) |name| { 659 // const nick, const prefix = for (client.supports.prefix) |ch| { 660 // if (name[0] == ch) { 661 // break .{ name[1..], name[0] }; 662 // } 663 // } else .{ name, ' ' }; 664 // 665 // if (prefix != ' ') { 666 // log.debug("HAS PREFIX {s}", .{name}); 667 // } 668 // 669 // const user_ptr = try client.getOrCreateUser(nick); 670 // 671 // try channel.addMember(user_ptr, .{ .prefix = prefix, .sort = false }); 672 // } 673 // 674 // channel.sortMembers(); 675 // }, 676 // .RPL_ENDOFNAMES => { 677 // // syntax: <client> <channel> :End of /NAMES list 678 // var iter = msg.paramIterator(); 679 // _ = iter.next() orelse continue; // client 680 // const channel_name = iter.next() orelse continue; // channel 681 // var channel = try client.getOrCreateChannel(channel_name); 682 // channel.in_flight.names = false; 683 // }, 684 // .BOUNCER => { 685 // var iter = msg.paramIterator(); 686 // while (iter.next()) |param| { 687 // if (mem.eql(u8, param, "NETWORK")) { 688 // const id = iter.next() orelse continue; 689 // const attr = iter.next() orelse continue; 690 // // check if we already have this network 691 // for (self.clients.items, 0..) |cl, i| { 692 // if (cl.config.network_id) |net_id| { 693 // if (mem.eql(u8, net_id, id)) { 694 // if (mem.eql(u8, attr, "*")) { 695 // // * means the network was 696 // // deleted 697 // cl.deinit(); 698 // _ = self.clients.swapRemove(i); 699 // } 700 // continue :loop; 701 // } 702 // } 703 // } 704 // 705 // var cfg = client.config; 706 // cfg.network_id = try self.alloc.dupe(u8, id); 707 // 708 // var attr_iter = std.mem.splitScalar(u8, attr, ';'); 709 // while (attr_iter.next()) |kv| { 710 // const n = std.mem.indexOfScalar(u8, kv, '=') orelse continue; 711 // const key = kv[0..n]; 712 // if (mem.eql(u8, key, "name")) 713 // cfg.name = try self.alloc.dupe(u8, kv[n + 1 ..]) 714 // else if (mem.eql(u8, key, "nickname")) 715 // cfg.network_nick = try self.alloc.dupe(u8, kv[n + 1 ..]); 716 // } 717 // loop.postEvent(.{ .connect = cfg }); 718 // } 719 // } 720 // }, 721 // .AWAY => { 722 // const src = msg.source() orelse continue :loop; 723 // var iter = msg.paramIterator(); 724 // const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len; 725 // const user = try client.getOrCreateUser(src[0..n]); 726 // // If there are any params, the user is away. Otherwise 727 // // they are back. 728 // user.away = if (iter.next()) |_| true else false; 729 // }, 730 // .BATCH => { 731 // var iter = msg.paramIterator(); 732 // const tag = iter.next() orelse continue; 733 // switch (tag[0]) { 734 // '+' => { 735 // const batch_type = iter.next() orelse continue; 736 // if (mem.eql(u8, batch_type, "chathistory")) { 737 // const target = iter.next() orelse continue; 738 // var channel = try client.getOrCreateChannel(target); 739 // channel.at_oldest = true; 740 // const duped_tag = try self.alloc.dupe(u8, tag[1..]); 741 // try client.batches.put(duped_tag, channel); 742 // } 743 // }, 744 // '-' => { 745 // const key = client.batches.getKey(tag[1..]) orelse continue; 746 // var chan = client.batches.get(key) orelse @panic("key should exist here"); 747 // chan.history_requested = false; 748 // _ = client.batches.remove(key); 749 // self.alloc.free(key); 750 // }, 751 // else => {}, 752 // } 753 // }, 754 // .CHATHISTORY => { 755 // var iter = msg.paramIterator(); 756 // const should_targets = iter.next() orelse continue; 757 // if (!mem.eql(u8, should_targets, "TARGETS")) continue; 758 // const target = iter.next() orelse continue; 759 // // we only add direct messages, not more channels 760 // assert(target.len > 0); 761 // if (target[0] == '#') continue; 762 // 763 // var channel = try client.getOrCreateChannel(target); 764 // const user_ptr = try client.getOrCreateUser(target); 765 // const me_ptr = try client.getOrCreateUser(client.nickname()); 766 // try channel.addMember(user_ptr, .{}); 767 // try channel.addMember(me_ptr, .{}); 768 // // we set who_requested so we don't try to request 769 // // who on DMs 770 // channel.who_requested = true; 771 // var buf: [128]u8 = undefined; 772 // const mark_read = try std.fmt.bufPrint( 773 // &buf, 774 // "MARKREAD {s}\r\n", 775 // .{channel.name}, 776 // ); 777 // try client.queueWrite(mark_read); 778 // try client.requestHistory(.after, channel); 779 // }, 780 // .JOIN => { 781 // // get the user 782 // const src = msg.source() orelse continue :loop; 783 // const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len; 784 // const user = try client.getOrCreateUser(src[0..n]); 785 // 786 // // get the channel 787 // var iter = msg.paramIterator(); 788 // const target = iter.next() orelse continue; 789 // var channel = try client.getOrCreateChannel(target); 790 // 791 // // If it's our nick, we request chat history 792 // if (mem.eql(u8, user.nick, client.nickname())) { 793 // try client.requestHistory(.after, channel); 794 // if (self.explicit_join) { 795 // self.selectChannelName(client, target); 796 // self.explicit_join = false; 797 // } 798 // } else try channel.addMember(user, .{}); 799 // }, 800 // .MARKREAD => { 801 // var iter = msg.paramIterator(); 802 // const target = iter.next() orelse continue; 803 // const timestamp = iter.next() orelse continue; 804 // const equal = std.mem.indexOfScalar(u8, timestamp, '=') orelse continue; 805 // const last_read = zeit.instant(.{ 806 // .source = .{ 807 // .iso8601 = timestamp[equal + 1 ..], 808 // }, 809 // }) catch |err| { 810 // log.err("couldn't convert timestamp: {}", .{err}); 811 // continue; 812 // }; 813 // var channel = try client.getOrCreateChannel(target); 814 // channel.last_read = last_read.unixTimestamp(); 815 // const last_msg = channel.messages.getLastOrNull() orelse continue; 816 // const time = last_msg.time() orelse continue; 817 // if (time.unixTimestamp() > channel.last_read) 818 // channel.has_unread = true 819 // else 820 // channel.has_unread = false; 821 // }, 822 // .PART => { 823 // // get the user 824 // const src = msg.source() orelse continue :loop; 825 // const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len; 826 // const user = try client.getOrCreateUser(src[0..n]); 827 // 828 // // get the channel 829 // var iter = msg.paramIterator(); 830 // const target = iter.next() orelse continue; 831 // 832 // if (mem.eql(u8, user.nick, client.nickname())) { 833 // for (client.channels.items, 0..) |channel, i| { 834 // if (!mem.eql(u8, channel.name, target)) continue; 835 // var chan = client.channels.orderedRemove(i); 836 // self.state.buffers.selected_idx -|= 1; 837 // chan.deinit(self.alloc); 838 // break; 839 // } 840 // } else { 841 // const channel = try client.getOrCreateChannel(target); 842 // channel.removeMember(user); 843 // } 844 // }, 845 // .PRIVMSG, .NOTICE => { 846 // // syntax: <target> :<message> 847 // const msg2: irc.Message = .{ 848 // .bytes = try self.alloc.dupe(u8, msg.bytes), 849 // }; 850 // var iter = msg2.paramIterator(); 851 // const target = blk: { 852 // const tgt = iter.next() orelse continue; 853 // if (mem.eql(u8, tgt, client.nickname())) { 854 // // If the target is us, it likely has our 855 // // hostname in it. 856 // const source = msg2.source() orelse continue; 857 // const n = mem.indexOfScalar(u8, source, '!') orelse source.len; 858 // break :blk source[0..n]; 859 // } else break :blk tgt; 860 // }; 861 // 862 // // We handle batches separately. When we encounter a 863 // // PRIVMSG from a batch, we use the original target 864 // // from the batch start. We also never notify from a 865 // // batched message. Batched messages also require 866 // // sorting 867 // var tag_iter = msg2.tagIterator(); 868 // while (tag_iter.next()) |tag| { 869 // if (mem.eql(u8, tag.key, "batch")) { 870 // const entry = client.batches.getEntry(tag.value) orelse @panic("TODO"); 871 // var channel = entry.value_ptr.*; 872 // try channel.messages.append(msg2); 873 // std.sort.insertion(irc.Message, channel.messages.items, {}, irc.Message.compareTime); 874 // channel.at_oldest = false; 875 // const time = msg2.time() orelse continue; 876 // if (time.unixTimestamp() > channel.last_read) { 877 // channel.has_unread = true; 878 // const content = iter.next() orelse continue; 879 // if (std.mem.indexOf(u8, content, client.nickname())) |_| { 880 // channel.has_unread_highlight = true; 881 // } 882 // } 883 // break; 884 // } 885 // } else { 886 // // standard handling 887 // var channel = try client.getOrCreateChannel(target); 888 // try channel.messages.append(msg2); 889 // const content = iter.next() orelse continue; 890 // var has_highlight = false; 891 // { 892 // const sender: []const u8 = blk: { 893 // const src = msg2.source() orelse break :blk ""; 894 // const l = std.mem.indexOfScalar(u8, src, '!') orelse 895 // std.mem.indexOfScalar(u8, src, '@') orelse 896 // src.len; 897 // break :blk src[0..l]; 898 // }; 899 // try lua.onMessage(lua_state, client, channel.name, sender, content); 900 // } 901 // if (std.mem.indexOf(u8, content, client.nickname())) |_| { 902 // var buf: [64]u8 = undefined; 903 // const title_or_err = if (msg2.source()) |source| 904 // std.fmt.bufPrint(&buf, "{s} - {s}", .{ channel.name, source }) 905 // else 906 // std.fmt.bufPrint(&buf, "{s}", .{channel.name}); 907 // const title = title_or_err catch title: { 908 // const len = @min(buf.len, channel.name.len); 909 // @memcpy(buf[0..len], channel.name[0..len]); 910 // break :title buf[0..len]; 911 // }; 912 // try self.vx.notify(writer, title, content); 913 // has_highlight = true; 914 // } 915 // const time = msg2.time() orelse continue; 916 // if (time.unixTimestamp() > channel.last_read) { 917 // channel.has_unread_highlight = has_highlight; 918 // channel.has_unread = true; 919 // } 920 // } 921 // 922 // // If we get a message from the current user mark the channel as 923 // // read, since they must have just sent the message. 924 // const sender: []const u8 = blk: { 925 // const src = msg2.source() orelse break :blk ""; 926 // const l = std.mem.indexOfScalar(u8, src, '!') orelse 927 // std.mem.indexOfScalar(u8, src, '@') orelse 928 // src.len; 929 // break :blk src[0..l]; 930 // }; 931 // if (std.mem.eql(u8, sender, client.nickname())) { 932 // self.markSelectedChannelRead(); 933 // } 934 // }, 935 // } 936 // }, 937 // } 938 // } 939 // 940 // if (redraw) { 941 // try self.draw(&input); 942 // last_frame = std.time.milliTimestamp(); 943 // } 944 // } 945 // } 946 947 pub fn connect(self: *App, cfg: irc.Client.Config) !void { 948 const client = try self.alloc.create(irc.Client); 949 client.* = try irc.Client.init(self.alloc, self, &self.write_queue, cfg); 950 client.thread = try std.Thread.spawn(.{}, irc.Client.readLoop, .{client}); 951 try self.clients.append(client); 952 } 953 954 pub fn nextChannel(self: *App) void { 955 // When leaving a channel we mark it as read, so we make sure that's done 956 // before we change to the new channel. 957 self.markSelectedChannelRead(); 958 959 const state = self.state.buffers; 960 if (state.selected_idx >= state.count - 1) 961 self.state.buffers.selected_idx = 0 962 else 963 self.state.buffers.selected_idx +|= 1; 964 } 965 966 pub fn prevChannel(self: *App) void { 967 // When leaving a channel we mark it as read, so we make sure that's done 968 // before we change to the new channel. 969 self.markSelectedChannelRead(); 970 971 switch (self.state.buffers.selected_idx) { 972 0 => self.state.buffers.selected_idx = self.state.buffers.count - 1, 973 else => self.state.buffers.selected_idx -|= 1, 974 } 975 } 976 977 pub fn selectChannelName(self: *App, cl: *irc.Client, name: []const u8) void { 978 var i: usize = 0; 979 for (self.clients.items) |client| { 980 i += 1; 981 for (client.channels.items) |channel| { 982 if (cl == client) { 983 if (std.mem.eql(u8, name, channel.name)) { 984 self.state.buffers.selected_idx = i; 985 } 986 } 987 i += 1; 988 } 989 } 990 } 991 992 /// handle a command 993 pub fn handleCommand(self: *App, buffer: irc.Buffer, cmd: []const u8) !void { 994 const lua_state = self.lua; 995 const command: comlink.Command = blk: { 996 const start: u1 = if (cmd[0] == '/') 1 else 0; 997 const end = mem.indexOfScalar(u8, cmd, ' ') orelse cmd.len; 998 if (comlink.Command.fromString(cmd[start..end])) |internal| 999 break :blk internal; 1000 if (comlink.Command.user_commands.get(cmd[start..end])) |ref| { 1001 const str = if (end == cmd.len) "" else std.mem.trim(u8, cmd[end..], " "); 1002 return lua.execUserCommand(lua_state, str, ref); 1003 } 1004 return error.UnknownCommand; 1005 }; 1006 var buf: [1024]u8 = undefined; 1007 const client: *irc.Client = switch (buffer) { 1008 .client => |client| client, 1009 .channel => |channel| channel.client, 1010 }; 1011 const channel: ?*irc.Channel = switch (buffer) { 1012 .client => null, 1013 .channel => |channel| channel, 1014 }; 1015 switch (command) { 1016 .quote => { 1017 const start = mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 1018 const msg = try std.fmt.bufPrint( 1019 &buf, 1020 "{s}\r\n", 1021 .{cmd[start + 1 ..]}, 1022 ); 1023 return client.queueWrite(msg); 1024 }, 1025 .join => { 1026 const start = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 1027 const msg = try std.fmt.bufPrint( 1028 &buf, 1029 "JOIN {s}\r\n", 1030 .{ 1031 cmd[start + 1 ..], 1032 }, 1033 ); 1034 // Ensure buffer exists 1035 self.explicit_join = true; 1036 return client.queueWrite(msg); 1037 }, 1038 .me => { 1039 if (channel == null) return error.InvalidCommand; 1040 const msg = try std.fmt.bufPrint( 1041 &buf, 1042 "PRIVMSG {s} :\x01ACTION {s}\x01\r\n", 1043 .{ 1044 channel.?.name, 1045 cmd[4..], 1046 }, 1047 ); 1048 return client.queueWrite(msg); 1049 }, 1050 .msg => { 1051 //syntax: /msg <nick> <msg> 1052 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 1053 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse return error.InvalidCommand; 1054 const msg = try std.fmt.bufPrint( 1055 &buf, 1056 "PRIVMSG {s} :{s}\r\n", 1057 .{ 1058 cmd[s + 1 .. e], 1059 cmd[e + 1 ..], 1060 }, 1061 ); 1062 return client.queueWrite(msg); 1063 }, 1064 .query => { 1065 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand; 1066 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse cmd.len; 1067 if (cmd[s + 1] == '#') return error.InvalidCommand; 1068 1069 const ch = try client.getOrCreateChannel(cmd[s + 1 .. e]); 1070 try client.requestHistory(.after, ch); 1071 self.selectChannelName(client, ch.name); 1072 //handle sending the message 1073 if (cmd.len - e > 1) { 1074 const msg = try std.fmt.bufPrint( 1075 &buf, 1076 "PRIVMSG {s} :{s}\r\n", 1077 .{ 1078 cmd[s + 1 .. e], 1079 cmd[e + 1 ..], 1080 }, 1081 ); 1082 return client.queueWrite(msg); 1083 } 1084 }, 1085 .names => { 1086 if (channel == null) return error.InvalidCommand; 1087 const msg = try std.fmt.bufPrint(&buf, "NAMES {s}\r\n", .{channel.?.name}); 1088 return client.queueWrite(msg); 1089 }, 1090 .@"next-channel" => self.nextChannel(), 1091 .@"prev-channel" => self.prevChannel(), 1092 .quit => self.should_quit = true, 1093 .who => { 1094 if (channel == null) return error.InvalidCommand; 1095 const msg = try std.fmt.bufPrint( 1096 &buf, 1097 "WHO {s}\r\n", 1098 .{ 1099 channel.?.name, 1100 }, 1101 ); 1102 return client.queueWrite(msg); 1103 }, 1104 .part, .close => { 1105 if (channel == null) return error.InvalidCommand; 1106 var it = std.mem.tokenizeScalar(u8, cmd, ' '); 1107 1108 // Skip command 1109 _ = it.next(); 1110 const target = it.next() orelse channel.?.name; 1111 1112 if (target[0] != '#') { 1113 for (client.channels.items, 0..) |search, i| { 1114 if (!mem.eql(u8, search.name, target)) continue; 1115 var chan = client.channels.orderedRemove(i); 1116 self.state.buffers.selected_idx -|= 1; 1117 chan.deinit(self.alloc); 1118 break; 1119 } 1120 } else { 1121 const msg = try std.fmt.bufPrint( 1122 &buf, 1123 "PART {s}\r\n", 1124 .{ 1125 target, 1126 }, 1127 ); 1128 return client.queueWrite(msg); 1129 } 1130 }, 1131 .redraw => {}, 1132 // .redraw => self.vx.queueRefresh(), 1133 .version => { 1134 if (channel == null) return error.InvalidCommand; 1135 const msg = try std.fmt.bufPrint( 1136 &buf, 1137 "NOTICE {s} :\x01VERSION comlink {s}\x01\r\n", 1138 .{ 1139 channel.?.name, 1140 main.version, 1141 }, 1142 ); 1143 return client.queueWrite(msg); 1144 }, 1145 .lua_function => {}, // we don't handle these from the text-input 1146 } 1147 } 1148 1149 pub fn selectedBuffer(self: *App) ?irc.Buffer { 1150 var i: usize = 0; 1151 for (self.clients.items) |client| { 1152 if (i == self.buffer_list.cursor) return .{ .client = client }; 1153 i += 1; 1154 for (client.channels.items) |channel| { 1155 if (i == self.buffer_list.cursor) return .{ .channel = channel }; 1156 i += 1; 1157 } 1158 } 1159 return null; 1160 } 1161 1162 pub fn selectBuffer(self: *App, buffer: irc.Buffer) void { 1163 self.markSelectedChannelRead(); 1164 var i: u32 = 0; 1165 switch (buffer) { 1166 .client => |target| { 1167 for (self.clients.items) |client| { 1168 if (client == target) { 1169 self.buffer_list.cursor = i; 1170 self.buffer_list.ensureScroll(); 1171 return; 1172 } 1173 i += 1; 1174 for (client.channels.items) |_| i += 1; 1175 } 1176 }, 1177 .channel => |target| { 1178 for (self.clients.items) |client| { 1179 i += 1; 1180 for (client.channels.items) |channel| { 1181 if (channel == target) { 1182 self.buffer_list.cursor = i; 1183 self.buffer_list.ensureScroll(); 1184 if (target.messageViewIsAtBottom()) target.has_unread = false; 1185 return; 1186 } 1187 i += 1; 1188 } 1189 } 1190 }, 1191 } 1192 } 1193 1194 fn draw(self: *App, input: *TextInput) !void { 1195 var arena = std.heap.ArenaAllocator.init(self.alloc); 1196 defer arena.deinit(); 1197 const allocator = arena.allocator(); 1198 1199 // reset window state 1200 const win = self.vx.window(); 1201 win.clear(); 1202 self.vx.setMouseShape(.default); 1203 1204 // Handle resize of sidebars 1205 if (self.state.mouse) |mouse| { 1206 if (self.state.buffers.resizing) { 1207 self.state.buffers.width = @min(mouse.col, win.width -| self.state.members.width); 1208 } else if (self.state.members.resizing) { 1209 self.state.members.width = win.width -| mouse.col + 1; 1210 } 1211 1212 if (mouse.col == self.state.buffers.width) { 1213 self.vx.setMouseShape(.@"ew-resize"); 1214 switch (mouse.type) { 1215 .press => { 1216 if (mouse.button == .left) self.state.buffers.resizing = true; 1217 }, 1218 .release => self.state.buffers.resizing = false, 1219 else => {}, 1220 } 1221 } else if (mouse.col == win.width -| self.state.members.width + 1) { 1222 self.vx.setMouseShape(.@"ew-resize"); 1223 switch (mouse.type) { 1224 .press => { 1225 if (mouse.button == .left) self.state.members.resizing = true; 1226 }, 1227 .release => self.state.members.resizing = false, 1228 else => {}, 1229 } 1230 } 1231 } 1232 1233 // Define the layout 1234 const buf_list_w = self.state.buffers.width; 1235 const mbr_list_w = self.state.members.width; 1236 const message_list_width = win.width -| buf_list_w -| mbr_list_w; 1237 1238 const channel_list_win = win.child(.{ 1239 .width = .{ .limit = self.state.buffers.width + 1 }, 1240 .border = .{ .where = .right }, 1241 }); 1242 1243 const member_list_win = win.child(.{ 1244 .x_off = buf_list_w + message_list_width + 1, 1245 .border = .{ .where = .left }, 1246 }); 1247 1248 const middle_win = win.child(.{ 1249 .x_off = buf_list_w + 1, 1250 .width = .{ .limit = message_list_width }, 1251 }); 1252 1253 const topic_win = middle_win.child(.{ 1254 .height = .{ .limit = 2 }, 1255 .border = .{ .where = .bottom }, 1256 }); 1257 1258 const message_list_win = middle_win.child(.{ 1259 .y_off = 2, 1260 .height = .{ .limit = middle_win.height -| 4 }, 1261 .width = .{ .limit = middle_win.width -| 1 }, 1262 }); 1263 1264 // Draw the buffer list 1265 try self.drawBufferList(self.clients.items, channel_list_win); 1266 1267 // Get our currently selected buffer and draw it 1268 const buffer = self.selectedBuffer() orelse return; 1269 switch (buffer) { 1270 .client => {}, // nothing to do 1271 1272 .channel => |channel| { 1273 // Request WHO if we don't already have it 1274 if (!channel.who_requested) try channel.client.whox(channel); 1275 1276 // Set the title of the terminal 1277 { 1278 var buf: [64]u8 = undefined; 1279 const title = std.fmt.bufPrint(&buf, "{s} - comlink", .{channel.name}) catch title: { 1280 // If the channel name is too long to fit in our buffer just truncate 1281 const len = @min(buf.len, channel.name.len); 1282 @memcpy(buf[0..len], channel.name[0..len]); 1283 break :title buf[0..len]; 1284 }; 1285 try self.vx.setTitle(self.tty.anyWriter(), title); 1286 } 1287 1288 // Draw the topic 1289 try self.drawTopic(topic_win, channel.topic orelse ""); 1290 1291 // Draw the member list 1292 try self.drawMemberList(member_list_win, channel); 1293 1294 // Draw the message list 1295 try self.drawMessageList(allocator, message_list_win, channel); 1296 1297 // draw a scrollbar 1298 { 1299 const scrollbar: Scrollbar = .{ 1300 .total = channel.messages.items.len, 1301 .view_size = message_list_win.height / 3, // ~3 lines per message 1302 .bottom = self.state.messages.scroll_offset, 1303 }; 1304 const scrollbar_win = middle_win.child(.{ 1305 .x_off = message_list_win.width, 1306 .y_off = 2, 1307 .height = .{ .limit = middle_win.height -| 4 }, 1308 }); 1309 scrollbar.draw(scrollbar_win); 1310 } 1311 1312 // draw the completion list 1313 if (self.completer) |*completer| { 1314 try completer.findMatches(channel); 1315 1316 var completion_style: vaxis.Style = .{ .bg = .{ .index = 8 } }; 1317 const completion_win = middle_win.child(.{ 1318 .width = .{ .limit = completer.widestMatch(win) + 1 }, 1319 .height = .{ .limit = @min(completer.numMatches(), middle_win.height -| 1) }, 1320 .x_off = completer.start_idx, 1321 .y_off = middle_win.height -| completer.numMatches() -| 1, 1322 }); 1323 completion_win.fill(.{ 1324 .char = .{ .grapheme = " ", .width = 1 }, 1325 .style = completion_style, 1326 }); 1327 var completion_row: usize = 0; 1328 while (completion_row < completion_win.height) : (completion_row += 1) { 1329 log.debug("COMPLETION ROW {d}, selected_idx {d}", .{ completion_row, completer.selected_idx orelse 0 }); 1330 if (completer.selected_idx) |idx| { 1331 if (completion_row == idx) 1332 completion_style.reverse = true 1333 else { 1334 completion_style = .{ .bg = .{ .index = 8 } }; 1335 } 1336 } 1337 var seg = [_]vaxis.Segment{ 1338 .{ 1339 .text = completer.options.items[completer.options.items.len - 1 - completion_row], 1340 .style = completion_style, 1341 }, 1342 .{ 1343 .text = " ", 1344 .style = completion_style, 1345 }, 1346 }; 1347 _ = try completion_win.print(&seg, .{ 1348 .row_offset = completion_win.height -| completion_row -| 1, 1349 }); 1350 } 1351 } 1352 }, 1353 } 1354 1355 const input_win = middle_win.child(.{ 1356 .y_off = win.height -| 1, 1357 .width = .{ .limit = middle_win.width -| 7 }, 1358 .height = .{ .limit = 1 }, 1359 }); 1360 const len_win = middle_win.child(.{ 1361 .x_off = input_win.width, 1362 .y_off = win.height -| 1, 1363 .width = .{ .limit = 7 }, 1364 .height = .{ .limit = 1 }, 1365 }); 1366 const buf_name_len = blk: { 1367 const sel_buf = self.selectedBuffer() orelse @panic("no buffer"); 1368 switch (sel_buf) { 1369 .channel => |chan| break :blk chan.name.len, 1370 else => break :blk 0, 1371 } 1372 }; 1373 // PRIVMSG <channel_name> :<message>\r\n = 12 bytes of overhead 1374 const max_len = irc.maximum_message_size - buf_name_len - 12; 1375 var len_buf: [7]u8 = undefined; 1376 const msg_len = input.buf.realLength(); 1377 _ = try std.fmt.bufPrint(&len_buf, "{d: >3}/{d}", .{ msg_len, max_len }); 1378 1379 var len_segs = [_]vaxis.Segment{ 1380 .{ 1381 .text = len_buf[0..3], 1382 .style = .{ .fg = if (msg_len > max_len) 1383 .{ .index = 1 } 1384 else 1385 .{ .index = 8 } }, 1386 }, 1387 .{ 1388 .text = len_buf[3..], 1389 .style = .{ .fg = .{ .index = 8 } }, 1390 }, 1391 }; 1392 1393 _ = try len_win.print(&len_segs, .{}); 1394 input.draw(input_win); 1395 1396 if (self.state.paste.showDialog()) { 1397 // Draw a modal dialog for how to handle multi-line paste 1398 const multiline_paste_win = vaxis.widgets.alignment.center(win, win.width - 10, win.height - 10); 1399 const bordered = vaxis.widgets.border.all(multiline_paste_win, .{}); 1400 bordered.clear(); 1401 const warning_width: usize = 37; 1402 const title_win = multiline_paste_win.child(.{ 1403 .height = .{ .limit = 2 }, 1404 .y_off = 1, 1405 .x_off = multiline_paste_win.width / 2 - warning_width / 2, 1406 }); 1407 const title_seg = [_]vaxis.Segment{ 1408 .{ 1409 .text = "/!\\ Warning: Multiline paste detected", 1410 .style = .{ 1411 .fg = .{ .index = 3 }, 1412 .bold = true, 1413 }, 1414 }, 1415 }; 1416 _ = try title_win.print(&title_seg, .{ .wrap = .none }); 1417 var segs = [_]vaxis.Segment{ 1418 .{ .text = self.paste_buffer.items }, 1419 }; 1420 _ = try bordered.print(&segs, .{ .wrap = .grapheme, .row_offset = 2 }); 1421 // const button: Button = .{ 1422 // .label = "Accept", 1423 // .style = .{ .bg = .{ .index = 7 } }, 1424 // }; 1425 // try button.draw(bordered.child(.{ 1426 // .x_off = 3, 1427 // .y_off = bordered.height - 4, 1428 // .height = .{ .limit = 3 }, 1429 // .width = .{ .limit = 10 }, 1430 // })); 1431 } 1432 1433 var buffered = self.tty.bufferedWriter(); 1434 try self.vx.render(buffered.writer().any()); 1435 try buffered.flush(); 1436 } 1437 1438 fn drawMessageList( 1439 self: *App, 1440 arena: std.mem.Allocator, 1441 win: vaxis.Window, 1442 channel: *irc.Channel, 1443 ) !void { 1444 if (channel.messages.items.len == 0) return; 1445 const client = channel.client; 1446 const last_msg_idx = channel.messages.items.len -| self.state.messages.scroll_offset; 1447 const messages = channel.messages.items[0..@max(1, last_msg_idx)]; 1448 // We draw a gutter for time information 1449 const gutter_width: usize = 6; 1450 1451 // Our message list is offset by the gutter width 1452 const message_offset_win = win.child(.{ .x_off = gutter_width }); 1453 1454 // Handle mouse 1455 if (win.hasMouse(self.state.mouse)) |mouse| { 1456 switch (mouse.button) { 1457 .wheel_up => { 1458 self.state.messages.scroll_offset +|= 1; 1459 self.state.mouse.?.button = .none; 1460 self.state.messages.pending_scroll += 2; 1461 }, 1462 .wheel_down => { 1463 self.state.messages.scroll_offset -|= 1; 1464 self.state.mouse.?.button = .none; 1465 self.state.messages.pending_scroll -= 2; 1466 }, 1467 else => {}, 1468 } 1469 } 1470 self.state.messages.scroll_offset = @min( 1471 self.state.messages.scroll_offset, 1472 channel.messages.items.len -| 1, 1473 ); 1474 1475 // Define a few state variables for the loop 1476 const last_msg = messages[messages.len -| 1]; 1477 1478 // Initialize prev_time to the time of the last message, falling back to "now" 1479 var prev_time: zeit.Instant = last_msg.localTime(&self.tz) orelse 1480 try zeit.instant(.{ .source = .now, .timezone = &self.tz }); 1481 1482 // Initialize prev_sender to the sender of the last message 1483 var prev_sender: []const u8 = if (last_msg.source()) |src| blk: { 1484 if (std.mem.indexOfScalar(u8, src, '!')) |idx| 1485 break :blk src[0..idx]; 1486 if (std.mem.indexOfScalar(u8, src, '@')) |idx| 1487 break :blk src[0..idx]; 1488 break :blk src; 1489 } else ""; 1490 1491 // y_off is the row we are printing on 1492 var y_off: usize = win.height; 1493 1494 // Formatted message segments 1495 var segments = std.ArrayList(vaxis.Segment).init(arena); 1496 1497 var msg_iter = std.mem.reverseIterator(messages); 1498 var i: usize = messages.len; 1499 while (msg_iter.next()) |message| { 1500 i -|= 1; 1501 segments.clearRetainingCapacity(); 1502 1503 // Get the sender nick 1504 const sender: []const u8 = if (message.source()) |src| blk: { 1505 if (std.mem.indexOfScalar(u8, src, '!')) |idx| 1506 break :blk src[0..idx]; 1507 if (std.mem.indexOfScalar(u8, src, '@')) |idx| 1508 break :blk src[0..idx]; 1509 break :blk src; 1510 } else ""; 1511 1512 // Save sender state after this loop 1513 defer prev_sender = sender; 1514 1515 // Before we print the message, we need to decide if we should print the sender name of 1516 // the previous message. There are two cases we do this: 1517 // 1. The previous message was sent by someone other than the current message 1518 // 2. A certain amount of time has elapsed between messages 1519 // 1520 // Each case requires that we have space in the window to print the sender (y_off > 0) 1521 const time_gap = if (message.localTime(&self.tz)) |time| blk: { 1522 // Save message state for next loop 1523 defer prev_time = time; 1524 // time_gap is true when the difference between this message and last message is 1525 // greater than 5 minutes 1526 break :blk (prev_time.timestamp_ns -| time.timestamp_ns) > (5 * std.time.ns_per_min); 1527 } else false; 1528 1529 // Print the sender of the previous message 1530 if (y_off > 0 and (time_gap or !std.mem.eql(u8, prev_sender, sender))) { 1531 // Go up one line 1532 y_off -|= 1; 1533 1534 // Get the user so we have the correct color 1535 const user = try client.getOrCreateUser(prev_sender); 1536 const sender_win = message_offset_win.child(.{ 1537 .y_off = y_off, 1538 .height = .{ .limit = 1 }, 1539 }); 1540 1541 // We will use the result to see if our mouse is hovering over the nickname 1542 const sender_result = try sender_win.printSegment( 1543 .{ 1544 .text = prev_sender, 1545 .style = .{ .fg = user.color, .bold = true }, 1546 }, 1547 .{ .wrap = .none }, 1548 ); 1549 1550 // If our mouse is over the nickname, we set it to a pointer 1551 const result_win = sender_win.child(.{ .width = .{ .limit = sender_result.col } }); 1552 if (result_win.hasMouse(self.state.mouse)) |_| { 1553 self.vx.setMouseShape(.pointer); 1554 // If we have a realname we print it 1555 if (user.real_name) |real_name| { 1556 _ = try sender_win.printSegment( 1557 .{ 1558 .text = real_name, 1559 .style = .{ .italic = true, .dim = true }, 1560 }, 1561 .{ 1562 .wrap = .none, 1563 .col_offset = sender_result.col + 1, 1564 }, 1565 ); 1566 } 1567 } 1568 1569 // Go up one more line to print the next message 1570 y_off -|= 1; 1571 } 1572 1573 // We are out of space 1574 if (y_off == 0) break; 1575 1576 const user = try client.getOrCreateUser(sender); 1577 try format.message(&segments, user, message); 1578 1579 // Get the line count for this message 1580 const content_height = lineCountForWindow(message_offset_win, segments.items); 1581 1582 const content_win = message_offset_win.child( 1583 .{ 1584 .y_off = y_off -| content_height, 1585 .height = .{ .limit = content_height }, 1586 }, 1587 ); 1588 if (content_win.hasMouse(self.state.mouse)) |mouse| { 1589 var bg_idx: u8 = 8; 1590 if (mouse.type == .press and mouse.button == .middle) { 1591 var list = std.ArrayList(u8).init(self.alloc); 1592 defer list.deinit(); 1593 for (segments.items) |item| { 1594 try list.appendSlice(item.text); 1595 } 1596 try self.vx.copyToSystemClipboard(self.tty.anyWriter(), list.items, self.alloc); 1597 bg_idx = 3; 1598 } 1599 content_win.fill(.{ 1600 .char = .{ 1601 .grapheme = " ", 1602 .width = 1, 1603 }, 1604 .style = .{ 1605 .bg = .{ .index = bg_idx }, 1606 }, 1607 }); 1608 for (segments.items) |*item| { 1609 item.style.bg = .{ .index = bg_idx }; 1610 } 1611 } 1612 var iter = message.paramIterator(); 1613 // target is the channel, and we already handled that 1614 _ = iter.next() orelse continue; 1615 1616 const content = iter.next() orelse continue; 1617 if (std.mem.indexOf(u8, content, client.nickname())) |_| { 1618 for (segments.items) |*item| { 1619 if (item.style.fg == .default) 1620 item.style.fg = .{ .index = 3 }; 1621 } 1622 } 1623 1624 // Color the background of unread messages gray. 1625 if (message.localTime(&self.tz)) |instant| { 1626 if (instant.unixTimestamp() > channel.last_read) { 1627 for (segments.items) |*item| { 1628 item.style.bg = .{ .index = 8 }; 1629 } 1630 } 1631 } 1632 1633 _ = try content_win.print( 1634 segments.items, 1635 .{ 1636 .wrap = .word, 1637 }, 1638 ); 1639 if (content_height > y_off) break; 1640 const gutter = win.child(.{ 1641 .y_off = y_off -| content_height, 1642 .width = .{ .limit = 6 }, 1643 }); 1644 1645 if (message.localTime(&self.tz)) |instant| { 1646 var date: bool = false; 1647 const time = instant.time(); 1648 var buf = try std.fmt.allocPrint( 1649 arena, 1650 "{d:0>2}:{d:0>2}", 1651 .{ time.hour, time.minute }, 1652 ); 1653 if (i != 0 and channel.messages.items[i - 1].time() != null) { 1654 const prev = channel.messages.items[i - 1].localTime(&self.tz).?.time(); 1655 if (time.day != prev.day) { 1656 date = true; 1657 buf = try std.fmt.allocPrint( 1658 arena, 1659 "{d:0>2}/{d:0>2}", 1660 .{ @intFromEnum(time.month), time.day }, 1661 ); 1662 } 1663 } 1664 if (i == 0) { 1665 date = true; 1666 buf = try std.fmt.allocPrint( 1667 arena, 1668 "{d:0>2}/{d:0>2}", 1669 .{ @intFromEnum(time.month), time.day }, 1670 ); 1671 } 1672 const fg: vaxis.Color = if (date) 1673 .default 1674 else 1675 .{ .index = 8 }; 1676 var time_seg = [_]vaxis.Segment{ 1677 .{ 1678 .text = buf, 1679 .style = .{ .fg = fg }, 1680 }, 1681 }; 1682 _ = try gutter.print(&time_seg, .{}); 1683 } 1684 1685 y_off -|= content_height; 1686 1687 // If we are on the first message, print the sender 1688 if (i == 0) { 1689 y_off -|= 1; 1690 const sender_win = win.child(.{ 1691 .x_off = 6, 1692 .y_off = y_off, 1693 .height = .{ .limit = 1 }, 1694 }); 1695 const sender_result = try sender_win.print( 1696 &.{.{ 1697 .text = sender, 1698 .style = .{ 1699 .fg = user.color, 1700 .bold = true, 1701 }, 1702 }}, 1703 .{ .wrap = .word }, 1704 ); 1705 const result_win = sender_win.child(.{ .width = .{ .limit = sender_result.col } }); 1706 if (result_win.hasMouse(self.state.mouse)) |_| { 1707 self.vx.setMouseShape(.pointer); 1708 } 1709 } 1710 1711 // if we are on the oldest message, request more history 1712 if (i == 0 and !channel.at_oldest) { 1713 try client.requestHistory(.before, channel); 1714 } 1715 } 1716 } 1717 1718 fn drawMemberList(self: *App, win: vaxis.Window, channel: *irc.Channel) !void { 1719 // Handle mouse 1720 { 1721 if (win.hasMouse(self.state.mouse)) |mouse| { 1722 switch (mouse.button) { 1723 .wheel_up => { 1724 self.state.members.scroll_offset -|= 3; 1725 self.state.mouse.?.button = .none; 1726 }, 1727 .wheel_down => { 1728 self.state.members.scroll_offset +|= 3; 1729 self.state.mouse.?.button = .none; 1730 }, 1731 else => {}, 1732 } 1733 } 1734 1735 self.state.members.scroll_offset = @min( 1736 self.state.members.scroll_offset, 1737 channel.members.items.len -| win.height, 1738 ); 1739 } 1740 1741 // Draw the list 1742 var member_row: usize = 0; 1743 for (channel.members.items) |*member| { 1744 defer member_row += 1; 1745 if (member_row < self.state.members.scroll_offset) continue; 1746 var member_seg = [_]vaxis.Segment{ 1747 .{ 1748 .text = std.mem.asBytes(&member.prefix), 1749 }, 1750 .{ 1751 .text = member.user.nick, 1752 .style = .{ 1753 .fg = if (member.user.away) 1754 .{ .index = 8 } 1755 else 1756 member.user.color, 1757 }, 1758 }, 1759 }; 1760 _ = try win.print(&member_seg, .{ 1761 .row_offset = member_row -| self.state.members.scroll_offset, 1762 }); 1763 } 1764 } 1765 1766 fn drawTopic(_: *App, win: vaxis.Window, topic: []const u8) !void { 1767 _ = try win.printSegment(.{ .text = topic }, .{ .wrap = .none }); 1768 } 1769 1770 fn drawBufferList(self: *App, clients: []*irc.Client, win: vaxis.Window) !void { 1771 // Handle mouse 1772 { 1773 if (win.hasMouse(self.state.mouse)) |mouse| { 1774 switch (mouse.button) { 1775 .wheel_up => { 1776 self.state.buffers.scroll_offset -|= 3; 1777 self.state.mouse.?.button = .none; 1778 }, 1779 .wheel_down => { 1780 self.state.buffers.scroll_offset +|= 3; 1781 self.state.mouse.?.button = .none; 1782 }, 1783 else => {}, 1784 } 1785 } 1786 1787 self.state.buffers.scroll_offset = @min( 1788 self.state.buffers.scroll_offset, 1789 self.state.buffers.count -| win.height, 1790 ); 1791 } 1792 const buf_list_w = self.state.buffers.width; 1793 var row: usize = 0; 1794 1795 defer self.state.buffers.count = row; 1796 for (clients) |client| { 1797 const scroll_offset = self.state.buffers.scroll_offset; 1798 if (!(row < scroll_offset)) { 1799 var style: vaxis.Style = if (row == self.state.buffers.selected_idx) 1800 .{ 1801 .fg = if (client.status == .disconnected) .{ .index = 8 } else .default, 1802 .reverse = true, 1803 } 1804 else 1805 .{ 1806 .fg = if (client.status == .disconnected) .{ .index = 8 } else .default, 1807 }; 1808 const network_win = win.child(.{ 1809 .y_off = row, 1810 .height = .{ .limit = 1 }, 1811 }); 1812 if (network_win.hasMouse(self.state.mouse)) |_| { 1813 self.vx.setMouseShape(.pointer); 1814 style.bg = .{ .index = 8 }; 1815 } 1816 _ = try network_win.print( 1817 &.{.{ 1818 .text = client.config.name orelse client.config.server, 1819 .style = style, 1820 }}, 1821 .{}, 1822 ); 1823 if (network_win.hasMouse(self.state.mouse)) |_| { 1824 self.vx.setMouseShape(.pointer); 1825 } 1826 } 1827 row += 1; 1828 for (client.channels.items) |*channel| { 1829 defer row += 1; 1830 if (row < scroll_offset) continue; 1831 const channel_win = win.child(.{ 1832 .y_off = row -| scroll_offset, 1833 .height = .{ .limit = 1 }, 1834 }); 1835 if (channel_win.hasMouse(self.state.mouse)) |mouse| { 1836 if (mouse.type == .press and mouse.button == .left and self.state.buffers.selected_idx != row) { 1837 // When leaving a channel we mark it as read, so we make sure that's done 1838 // before we change to the new channel. 1839 self.markSelectedChannelRead(); 1840 self.state.buffers.selected_idx = row; 1841 } 1842 } 1843 1844 const is_current = row == self.state.buffers.selected_idx; 1845 var chan_style: vaxis.Style = if (is_current) 1846 .{ 1847 .fg = if (client.status == .disconnected) .{ .index = 8 } else .default, 1848 .reverse = true, 1849 } 1850 else if (channel.has_unread) 1851 .{ 1852 .fg = .{ .index = 4 }, 1853 .bold = true, 1854 } 1855 else 1856 .{ 1857 .fg = if (client.status == .disconnected) .{ .index = 8 } else .default, 1858 }; 1859 const prefix: []const u8 = if (channel.name[0] == '#') "#" else ""; 1860 const name_offset: usize = if (prefix.len > 0) 1 else 0; 1861 1862 if (channel_win.hasMouse(self.state.mouse)) |mouse| { 1863 self.vx.setMouseShape(.pointer); 1864 if (mouse.button == .left) 1865 chan_style.reverse = true 1866 else 1867 chan_style.bg = .{ .index = 8 }; 1868 } 1869 1870 const first_seg: vaxis.Segment = if (channel.has_unread_highlight) 1871 .{ .text = " ●︎", .style = .{ .fg = .{ .index = 1 } } } 1872 else 1873 .{ .text = " " }; 1874 1875 var chan_seg = [_]vaxis.Segment{ 1876 first_seg, 1877 .{ 1878 .text = prefix, 1879 .style = .{ .fg = .{ .index = 8 } }, 1880 }, 1881 .{ 1882 .text = channel.name[name_offset..], 1883 .style = chan_style, 1884 }, 1885 }; 1886 const result = try channel_win.print( 1887 &chan_seg, 1888 .{}, 1889 ); 1890 if (result.overflow) 1891 win.writeCell( 1892 buf_list_w -| 1, 1893 row -| scroll_offset, 1894 .{ 1895 .char = .{ 1896 .grapheme = "", 1897 .width = 1, 1898 }, 1899 .style = chan_style, 1900 }, 1901 ); 1902 } 1903 } 1904 } 1905 1906 pub fn markSelectedChannelRead(self: *App) void { 1907 const buffer = self.selectedBuffer() orelse return; 1908 1909 switch (buffer) { 1910 .channel => |channel| { 1911 if (channel.messageViewIsAtBottom()) channel.markRead() catch return; 1912 }, 1913 else => {}, 1914 } 1915 } 1916}; 1917 1918/// this loop is run in a separate thread and handles writes to all clients. 1919/// Message content is deallocated when the write request is completed 1920fn writeLoop(alloc: std.mem.Allocator, queue: *comlink.WriteQueue) !void { 1921 log.debug("starting write thread", .{}); 1922 while (true) { 1923 const req = queue.pop(); 1924 switch (req) { 1925 .write => |w| { 1926 try w.client.write(w.msg); 1927 alloc.free(w.msg); 1928 }, 1929 .join => { 1930 while (queue.tryPop()) |r| { 1931 switch (r) { 1932 .write => |w| alloc.free(w.msg), 1933 else => {}, 1934 } 1935 } 1936 return; 1937 }, 1938 } 1939 } 1940} 1941 1942/// Returns the number of lines the segments would consume in the given window 1943fn lineCountForWindow(win: vaxis.Window, segments: []const vaxis.Segment) usize { 1944 // Fastpath if we have fewer bytes than the width 1945 var byte_count: usize = 0; 1946 for (segments) |segment| { 1947 byte_count += segment.text.len; 1948 } 1949 // One line if we are fewer bytes than the width 1950 if (byte_count <= win.width) return 1; 1951 1952 // Slow path. We have to layout the text 1953 const result = win.print(segments, .{ .commit = false, .wrap = .word }) catch return 0; 1954 if (result.col == 0) 1955 return result.row 1956 else 1957 return result.row + 1; 1958}