this repo has no description
3
fork

Configure Feed

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

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