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