this repo has no description
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

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