an experimental irc client
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_report => |color| {
214 switch (color.kind) {
215 .fg => self.fg = color.value,
216 .bg => self.bg = color.value,
217 .index => |index| {
218 switch (index) {
219 3 => self.yellow = color.value,
220 else => {},
221 }
222 },
223 .cursor => {},
224 }
225 if (self.fg != null and self.bg != null) {
226 for (self.clients.items) |client| {
227 for (client.channels.items) |channel| {
228 channel.text_field.style.bg = self.blendBg(10);
229 }
230 }
231 }
232 },
233 .key_press => |key| {
234 if (self.state.paste.pasting) {
235 ctx.consume_event = true;
236 // Always ignore enter key
237 if (key.codepoint == vaxis.Key.enter) return;
238 if (key.text) |text| {
239 try self.paste_buffer.appendSlice(text);
240 }
241 return;
242 }
243 if (key.matches('c', .{ .ctrl = true })) {
244 ctx.quit = true;
245 }
246 for (self.binds.items) |bind| {
247 if (key.matches(bind.key.codepoint, bind.key.mods)) {
248 switch (bind.command) {
249 .quit => ctx.quit = true,
250 .@"next-channel" => self.nextChannel(),
251 .@"prev-channel" => self.prevChannel(),
252 .redraw => try ctx.queueRefresh(),
253 .lua_function => |ref| try lua.execFn(self.lua, ref),
254 else => {},
255 }
256 return ctx.consumeAndRedraw();
257 }
258 }
259 },
260 .paste_start => self.state.paste.pasting = true,
261 .paste_end => {
262 self.state.paste.pasting = false;
263 if (std.mem.indexOfScalar(u8, self.paste_buffer.items, '\n')) |_| {
264 log.debug("paste had line ending", .{});
265 return;
266 }
267 defer self.paste_buffer.clearRetainingCapacity();
268 if (self.selectedBuffer()) |buffer| {
269 switch (buffer) {
270 .client => {},
271 .channel => |channel| {
272 try channel.text_field.insertSliceAtCursor(self.paste_buffer.items);
273 return ctx.consumeAndRedraw();
274 },
275 }
276 }
277 },
278 .focus_out => self.has_focus = false,
279
280 .focus_in => {
281 self.has_focus = true;
282 ctx.redraw = true;
283 },
284
285 else => {},
286 }
287 }
288
289 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
290 const self: *App = @ptrCast(@alignCast(ptr));
291 self.ctx = ctx;
292 switch (event) {
293 .init => {
294 const title = try std.fmt.bufPrint(&self.title_buf, "comlink", .{});
295 try ctx.setTitle(title);
296 try ctx.tick(8, self.widget());
297 try ctx.queryColor(.fg);
298 try ctx.queryColor(.bg);
299 try ctx.queryColor(.{ .index = 3 });
300 },
301 .tick => {
302 for (self.clients.items) |client| {
303 if (client.status.load(.unordered) == .disconnected and
304 client.retry_delay_s == 0)
305 {
306 ctx.redraw = true;
307 try irc.Client.retryTickHandler(client, ctx, .tick);
308 }
309 client.drainFifo(ctx);
310 client.checkTypingStatus(ctx);
311 }
312 try ctx.tick(8, self.widget());
313 },
314 else => {},
315 }
316 }
317
318 fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
319 const self: *App = @ptrCast(@alignCast(ptr));
320 const max = ctx.max.size();
321 self.last_height = max.height;
322 if (self.selectedBuffer()) |buffer| {
323 switch (buffer) {
324 .client => |client| self.view.rhs = client.view(),
325 .channel => |channel| self.view.rhs = channel.view.widget(),
326 }
327 } else self.view.rhs = default_rhs.widget();
328
329 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena);
330
331 // UI is a tree of splits
332 // │ │ │ │
333 // │ │ │ │
334 // │ buffers │ buffer content │ members │
335 // │ │ │ │
336 // │ │ │ │
337 // │ │ │ │
338 // │ │ │ │
339
340 const sub: vxfw.SubSurface = .{
341 .origin = .{ .col = 0, .row = 0 },
342 .surface = try self.view.widget().draw(ctx),
343 };
344 try children.append(sub);
345
346 return .{
347 .size = ctx.max.size(),
348 .widget = self.widget(),
349 .buffer = &.{},
350 .children = children.items,
351 };
352 }
353
354 fn bufferBuilderFn(ptr: *const anyopaque, idx: usize, cursor: usize) ?vxfw.Widget {
355 const self: *const App = @ptrCast(@alignCast(ptr));
356 var i: usize = 0;
357 for (self.clients.items) |client| {
358 if (i == idx) return client.nameWidget(i == cursor);
359 i += 1;
360 for (client.channels.items) |channel| {
361 if (i == idx) return channel.nameWidget(i == cursor);
362 i += 1;
363 }
364 }
365 return null;
366 }
367
368 pub fn connect(self: *App, cfg: irc.Client.Config) !void {
369 const client = try self.alloc.create(irc.Client);
370 client.* = try irc.Client.init(self.alloc, self, &self.write_queue, cfg);
371 try self.clients.append(client);
372 }
373
374 pub fn nextChannel(self: *App) void {
375 if (self.ctx) |ctx| {
376 self.buffer_list.nextItem(ctx);
377 if (self.selectedBuffer()) |buffer| {
378 switch (buffer) {
379 .client => {
380 ctx.requestFocus(self.widget()) catch {};
381 },
382 .channel => |channel| {
383 ctx.requestFocus(channel.text_field.widget()) catch {};
384 },
385 }
386 }
387 }
388 }
389
390 pub fn prevChannel(self: *App) void {
391 if (self.ctx) |ctx| {
392 self.buffer_list.prevItem(ctx);
393 if (self.selectedBuffer()) |buffer| {
394 switch (buffer) {
395 .client => {
396 ctx.requestFocus(self.widget()) catch {};
397 },
398 .channel => |channel| {
399 ctx.requestFocus(channel.text_field.widget()) catch {};
400 },
401 }
402 }
403 }
404 }
405
406 pub fn selectChannelName(self: *App, cl: *irc.Client, name: []const u8) void {
407 var i: usize = 0;
408 for (self.clients.items) |client| {
409 i += 1;
410 for (client.channels.items) |channel| {
411 if (cl == client) {
412 if (std.mem.eql(u8, name, channel.name)) {
413 self.selectBuffer(.{ .channel = channel });
414 }
415 }
416 i += 1;
417 }
418 }
419 }
420
421 /// Blend fg and bg, otherwise return index 8. amt will be clamped to [0,100]. amt will be
422 /// interpreted as percentage of fg to blend into bg
423 pub fn blendBg(self: *App, amt: u8) vaxis.Color {
424 const bg = self.bg orelse return .{ .index = 8 };
425 const fg = self.fg orelse return .{ .index = 8 };
426 // Clamp to (0,100)
427 if (amt == 0) return .{ .rgb = bg };
428 if (amt >= 100) return .{ .rgb = fg };
429
430 const fg_r: u16 = std.math.mulWide(u8, fg[0], amt);
431 const fg_g: u16 = std.math.mulWide(u8, fg[1], amt);
432 const fg_b: u16 = std.math.mulWide(u8, fg[2], amt);
433
434 const bg_multiplier: u8 = 100 - amt;
435 const bg_r: u16 = std.math.mulWide(u8, bg[0], bg_multiplier);
436 const bg_g: u16 = std.math.mulWide(u8, bg[1], bg_multiplier);
437 const bg_b: u16 = std.math.mulWide(u8, bg[2], bg_multiplier);
438
439 return .{
440 .rgb = .{
441 @intCast((fg_r + bg_r) / 100),
442 @intCast((fg_g + bg_g) / 100),
443 @intCast((fg_b + bg_b) / 100),
444 },
445 };
446 }
447
448 /// Blend fg and bg, otherwise return index 8. amt will be clamped to [0,100]. amt will be
449 /// interpreted as percentage of fg to blend into bg
450 pub fn blendYellow(self: *App, amt: u8) vaxis.Color {
451 const bg = self.bg orelse return .{ .index = 3 };
452 const yellow = self.yellow orelse return .{ .index = 3 };
453 // Clamp to (0,100)
454 if (amt == 0) return .{ .rgb = bg };
455 if (amt >= 100) return .{ .rgb = yellow };
456
457 const yellow_r: u16 = std.math.mulWide(u8, yellow[0], amt);
458 const yellow_g: u16 = std.math.mulWide(u8, yellow[1], amt);
459 const yellow_b: u16 = std.math.mulWide(u8, yellow[2], amt);
460
461 const bg_multiplier: u8 = 100 - amt;
462 const bg_r: u16 = std.math.mulWide(u8, bg[0], bg_multiplier);
463 const bg_g: u16 = std.math.mulWide(u8, bg[1], bg_multiplier);
464 const bg_b: u16 = std.math.mulWide(u8, bg[2], bg_multiplier);
465
466 return .{
467 .rgb = .{
468 @intCast((yellow_r + bg_r) / 100),
469 @intCast((yellow_g + bg_g) / 100),
470 @intCast((yellow_b + bg_b) / 100),
471 },
472 };
473 }
474
475 /// handle a command
476 pub fn handleCommand(self: *App, buffer: irc.Buffer, cmd: []const u8) !void {
477 const lua_state = self.lua;
478 const command: comlink.Command = blk: {
479 const start: u1 = if (cmd[0] == '/') 1 else 0;
480 const end = mem.indexOfScalar(u8, cmd, ' ') orelse cmd.len;
481 if (comlink.Command.fromString(cmd[start..end])) |internal|
482 break :blk internal;
483 if (comlink.Command.user_commands.get(cmd[start..end])) |ref| {
484 const str = if (end == cmd.len) "" else std.mem.trim(u8, cmd[end..], " ");
485 return lua.execUserCommand(lua_state, str, ref);
486 }
487 return error.UnknownCommand;
488 };
489 var buf: [1024]u8 = undefined;
490 const client: *irc.Client = switch (buffer) {
491 .client => |client| client,
492 .channel => |channel| channel.client,
493 };
494 const channel: ?*irc.Channel = switch (buffer) {
495 .client => null,
496 .channel => |channel| channel,
497 };
498 switch (command) {
499 .quote => {
500 const start = mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
501 const msg = try std.fmt.bufPrint(
502 &buf,
503 "{s}\r\n",
504 .{cmd[start + 1 ..]},
505 );
506 return client.queueWrite(msg);
507 },
508 .join => {
509 const start = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
510 const msg = try std.fmt.bufPrint(
511 &buf,
512 "JOIN {s}\r\n",
513 .{
514 cmd[start + 1 ..],
515 },
516 );
517 // Ensure buffer exists
518 self.explicit_join = true;
519 return client.queueWrite(msg);
520 },
521 .me => {
522 if (channel == null) return error.InvalidCommand;
523 const msg = try std.fmt.bufPrint(
524 &buf,
525 "PRIVMSG {s} :\x01ACTION {s}\x01\r\n",
526 .{
527 channel.?.name,
528 cmd[4..],
529 },
530 );
531 return client.queueWrite(msg);
532 },
533 .msg => {
534 //syntax: /msg <nick> <msg>
535 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
536 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse return error.InvalidCommand;
537 const msg = try std.fmt.bufPrint(
538 &buf,
539 "PRIVMSG {s} :{s}\r\n",
540 .{
541 cmd[s + 1 .. e],
542 cmd[e + 1 ..],
543 },
544 );
545 return client.queueWrite(msg);
546 },
547 .query => {
548 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
549 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse cmd.len;
550 if (cmd[s + 1] == '#') return error.InvalidCommand;
551
552 const ch = try client.getOrCreateChannel(cmd[s + 1 .. e]);
553 try client.requestHistory(.after, ch);
554 self.selectChannelName(client, ch.name);
555 //handle sending the message
556 if (cmd.len - e > 1) {
557 const msg = try std.fmt.bufPrint(
558 &buf,
559 "PRIVMSG {s} :{s}\r\n",
560 .{
561 cmd[s + 1 .. e],
562 cmd[e + 1 ..],
563 },
564 );
565 return client.queueWrite(msg);
566 }
567 },
568 .names => {
569 if (channel == null) return error.InvalidCommand;
570 const msg = try std.fmt.bufPrint(&buf, "NAMES {s}\r\n", .{channel.?.name});
571 return client.queueWrite(msg);
572 },
573 .@"next-channel" => self.nextChannel(),
574 .@"prev-channel" => self.prevChannel(),
575 .quit => {
576 if (self.ctx) |ctx| ctx.quit = true;
577 },
578 .who => {
579 if (channel == null) return error.InvalidCommand;
580 const msg = try std.fmt.bufPrint(
581 &buf,
582 "WHO {s}\r\n",
583 .{
584 channel.?.name,
585 },
586 );
587 return client.queueWrite(msg);
588 },
589 .part, .close => {
590 if (channel == null) return error.InvalidCommand;
591 var it = std.mem.tokenizeScalar(u8, cmd, ' ');
592
593 // Skip command
594 _ = it.next();
595 const target = it.next() orelse channel.?.name;
596
597 if (target[0] != '#') {
598 for (client.channels.items, 0..) |search, i| {
599 if (!mem.eql(u8, search.name, target)) continue;
600 client.app.prevChannel();
601 var chan = client.channels.orderedRemove(i);
602 chan.deinit(self.alloc);
603 self.alloc.destroy(chan);
604 break;
605 }
606 } else {
607 const msg = try std.fmt.bufPrint(
608 &buf,
609 "PART {s}\r\n",
610 .{
611 target,
612 },
613 );
614 return client.queueWrite(msg);
615 }
616 },
617 .redraw => {},
618 // .redraw => self.vx.queueRefresh(),
619 .version => {
620 if (channel == null) return error.InvalidCommand;
621 const msg = try std.fmt.bufPrint(
622 &buf,
623 "NOTICE {s} :\x01VERSION comlink {s}\x01\r\n",
624 .{
625 channel.?.name,
626 main.version,
627 },
628 );
629 return client.queueWrite(msg);
630 },
631 .lua_function => {}, // we don't handle these from the text-input
632 }
633 }
634
635 pub fn selectedBuffer(self: *App) ?irc.Buffer {
636 var i: usize = 0;
637 for (self.clients.items) |client| {
638 if (i == self.buffer_list.cursor) return .{ .client = client };
639 i += 1;
640 for (client.channels.items) |channel| {
641 if (i == self.buffer_list.cursor) return .{ .channel = channel };
642 i += 1;
643 }
644 }
645 return null;
646 }
647
648 pub fn selectBuffer(self: *App, buffer: irc.Buffer) void {
649 var i: u32 = 0;
650 switch (buffer) {
651 .client => |target| {
652 for (self.clients.items) |client| {
653 if (client == target) {
654 if (self.ctx) |ctx| {
655 ctx.requestFocus(self.widget()) catch {};
656 }
657 self.buffer_list.cursor = i;
658 self.buffer_list.ensureScroll();
659 return;
660 }
661 i += 1;
662 for (client.channels.items) |_| i += 1;
663 }
664 },
665 .channel => |target| {
666 for (self.clients.items) |client| {
667 i += 1;
668 for (client.channels.items) |channel| {
669 if (channel == target) {
670 self.buffer_list.cursor = i;
671 self.buffer_list.ensureScroll();
672 channel.doSelect();
673 if (self.ctx) |ctx| {
674 ctx.requestFocus(channel.text_field.widget()) catch {};
675 }
676 return;
677 }
678 i += 1;
679 }
680 }
681 },
682 }
683 }
684};
685
686/// this loop is run in a separate thread and handles writes to all clients.
687/// Message content is deallocated when the write request is completed
688fn writeLoop(alloc: std.mem.Allocator, queue: *comlink.WriteQueue) !void {
689 log.debug("starting write thread", .{});
690 while (true) {
691 const req = queue.pop();
692 switch (req) {
693 .write => |w| {
694 try w.client.write(w.msg);
695 alloc.free(w.msg);
696 },
697 .join => {
698 while (queue.tryPop()) |r| {
699 switch (r) {
700 .write => |w| alloc.free(w.msg),
701 else => {},
702 }
703 }
704 return;
705 },
706 }
707 }
708}