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