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