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