this repo has no description
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}