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