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;
14
15const assert = std.debug.assert;
16
17const Base64Encoder = std.base64.standard.Encoder;
18const Bind = comlink.Bind;
19const Completer = comlink.Completer;
20const Event = comlink.Event;
21const Lua = ziglua.Lua;
22const TextInput = vaxis.widgets.TextInput;
23const WriteRequest = comlink.WriteRequest;
24
25const log = std.log.scoped(.app);
26
27const State = struct {
28 mouse: ?vaxis.Mouse = null,
29 members: struct {
30 scroll_offset: usize = 0,
31 width: usize = 16,
32 resizing: bool = false,
33 } = .{},
34 messages: struct {
35 scroll_offset: usize = 0,
36 pending_scroll: isize = 0,
37 } = .{},
38 buffers: struct {
39 scroll_offset: usize = 0,
40 count: usize = 0,
41 selected_idx: usize = 0,
42 width: usize = 16,
43 resizing: bool = false,
44 } = .{},
45 paste: struct {
46 pasting: bool = false,
47 has_newline: bool = false,
48
49 fn showDialog(self: @This()) bool {
50 return !self.pasting and self.has_newline;
51 }
52 } = .{},
53};
54
55pub const App = struct {
56 alloc: std.mem.Allocator,
57 /// System certificate bundle
58 bundle: std.crypto.Certificate.Bundle = .{},
59 /// List of all configured clients
60 clients: std.ArrayList(*irc.Client),
61 /// if we have already called deinit
62 deinited: bool = false,
63 /// Process environment
64 env: std.process.EnvMap,
65 /// Local timezone
66 tz: zeit.TimeZone,
67 /// Instance of vaxis
68 vx: vaxis.Vaxis,
69 /// The tty we are talking to
70 tty: vaxis.Tty,
71
72 state: State = .{},
73
74 completer: ?Completer = null,
75
76 should_quit: bool = false,
77
78 binds: std.ArrayList(Bind),
79
80 paste_buffer: std.ArrayList(u8),
81
82 /// initialize vaxis, lua state
83 pub fn init(alloc: std.mem.Allocator) !App {
84 const vx = try vaxis.init(alloc, .{});
85 const env = try std.process.getEnvMap(alloc);
86 var app: App = .{
87 .alloc = alloc,
88 .clients = std.ArrayList(*irc.Client).init(alloc),
89 .env = env,
90 .vx = vx,
91 .tty = try vaxis.Tty.init(),
92 .binds = try std.ArrayList(Bind).initCapacity(alloc, 16),
93 .paste_buffer = std.ArrayList(u8).init(alloc),
94 .tz = try zeit.local(alloc, &env),
95 };
96
97 try app.binds.append(.{
98 .key = .{
99 .codepoint = 'c',
100 .mods = .{ .ctrl = true },
101 },
102 .command = .quit,
103 });
104 try app.binds.append(.{
105 .key = .{
106 .codepoint = vaxis.Key.up,
107 .mods = .{ .alt = true },
108 },
109 .command = .@"prev-channel",
110 });
111 try app.binds.append(.{
112 .key = .{
113 .codepoint = vaxis.Key.down,
114 .mods = .{ .alt = true },
115 },
116 .command = .@"next-channel",
117 });
118 try app.binds.append(.{
119 .key = .{
120 .codepoint = 'l',
121 .mods = .{ .ctrl = true },
122 },
123 .command = .redraw,
124 });
125
126 // Get our system tls certs
127 try app.bundle.rescan(alloc);
128
129 return app;
130 }
131
132 /// close the application. This closes the TUI, disconnects clients, and cleans
133 /// up all resources
134 pub fn deinit(self: *App) void {
135 if (self.deinited) return;
136 self.deinited = true;
137
138 // clean up clients
139 {
140 for (self.clients.items, 0..) |_, i| {
141 var client = self.clients.items[i];
142 client.deinit();
143 if (builtin.mode == .Debug) {
144 // We only clean up clients in Debug mode so we can check for memory leaks
145 // without failing for this. We don't care about it in any other mode since we
146 // are exiting anyways and we want to do it fast. If we destroy, our readthread
147 // could panic so we don't do it unless we have to.
148 self.alloc.destroy(client);
149 }
150 }
151 self.clients.deinit();
152 }
153
154 self.bundle.deinit(self.alloc);
155 self.vx.deinit(self.alloc, self.tty.anyWriter());
156 self.tty.deinit();
157
158 if (self.completer) |*completer| completer.deinit();
159 self.binds.deinit();
160 self.paste_buffer.deinit();
161 self.tz.deinit();
162 self.env.deinit();
163 }
164
165 pub fn run(self: *App, lua_state: *Lua) !void {
166 const writer = self.tty.anyWriter();
167
168 var loop: comlink.EventLoop = .{ .vaxis = &self.vx, .tty = &self.tty };
169 try loop.init();
170 try loop.start();
171 defer loop.stop();
172
173 try self.vx.enterAltScreen(writer);
174 try self.vx.queryTerminal(writer, 1 * std.time.ns_per_s);
175 try self.vx.setMouseMode(writer, true);
176 try self.vx.setBracketedPaste(writer, true);
177
178 // start our write thread
179 var write_queue: comlink.WriteQueue = .{};
180 const write_thread = try std.Thread.spawn(.{}, writeLoop, .{&write_queue});
181 defer {
182 write_queue.push(.join);
183 write_thread.join();
184 }
185
186 // initialize lua state
187 try lua.init(self, lua_state, &loop);
188
189 var input = TextInput.init(self.alloc, &self.vx.unicode);
190 defer input.deinit();
191
192 var last_frame: i64 = std.time.milliTimestamp();
193 loop: while (!self.should_quit) {
194 var redraw: bool = false;
195 std.time.sleep(8 * std.time.ns_per_ms);
196 if (self.state.messages.pending_scroll != 0) {
197 redraw = true;
198 if (self.state.messages.pending_scroll > 0) {
199 self.state.messages.pending_scroll -= 1;
200 self.state.messages.scroll_offset += 1;
201 } else {
202 self.state.messages.pending_scroll += 1;
203 self.state.messages.scroll_offset -|= 1;
204 }
205 }
206 while (loop.tryEvent()) |event| {
207 redraw = true;
208 switch (event) {
209 .redraw => {},
210 .key_press => |key| {
211 if (self.state.paste.showDialog()) {
212 if (key.matches(vaxis.Key.escape, .{})) {
213 self.state.paste.has_newline = false;
214 self.paste_buffer.clearAndFree();
215 }
216 break;
217 }
218 if (self.state.paste.pasting) {
219 if (key.matches(vaxis.Key.enter, .{})) {
220 self.state.paste.has_newline = true;
221 try self.paste_buffer.append('\n');
222 continue :loop;
223 }
224 const text = key.text orelse continue :loop;
225 try self.paste_buffer.appendSlice(text);
226 continue;
227 }
228 for (self.binds.items) |bind| {
229 if (key.matches(bind.key.codepoint, bind.key.mods)) {
230 switch (bind.command) {
231 .quit => self.should_quit = true,
232 .@"next-channel" => self.nextChannel(),
233 .@"prev-channel" => self.prevChannel(),
234 .redraw => self.vx.queueRefresh(),
235 .lua_function => |ref| try lua.execFn(lua_state, ref),
236 else => {},
237 }
238 break;
239 }
240 } else if (key.matches(vaxis.Key.tab, .{})) {
241 // if we already have a completion word, then we are
242 // cycling through the options
243 if (self.completer) |*completer| {
244 const line = completer.next();
245 input.clearRetainingCapacity();
246 try input.insertSliceAtCursor(line);
247 } else {
248 var completion_buf: [irc.maximum_message_size]u8 = undefined;
249 const content = input.sliceToCursor(&completion_buf);
250 self.completer = try Completer.init(self.alloc, content);
251 }
252 } else if (key.matches(vaxis.Key.tab, .{ .shift = true })) {
253 if (self.completer) |*completer| {
254 const line = completer.prev();
255 input.clearRetainingCapacity();
256 try input.insertSliceAtCursor(line);
257 }
258 } else if (key.matches(vaxis.Key.enter, .{})) {
259 const buffer = self.selectedBuffer() orelse @panic("no buffer");
260 const content = try input.toOwnedSlice();
261 if (content.len == 0) continue;
262 defer self.alloc.free(content);
263 if (content[0] == '/')
264 self.handleCommand(lua_state, buffer, content) catch |err| {
265 log.err("couldn't handle command: {}", .{err});
266 }
267 else {
268 switch (buffer) {
269 .channel => |channel| {
270 var buf: [1024]u8 = undefined;
271 const msg = try std.fmt.bufPrint(
272 &buf,
273 "PRIVMSG {s} :{s}\r\n",
274 .{
275 channel.name,
276 content,
277 },
278 );
279 try channel.client.queueWrite(msg);
280 },
281 .client => log.err("can't send message to client", .{}),
282 }
283 }
284 if (self.completer != null) {
285 self.completer.?.deinit();
286 self.completer = null;
287 }
288 } else if (key.matches(vaxis.Key.page_up, .{})) {
289 self.state.messages.scroll_offset +|= 3;
290 } else if (key.matches(vaxis.Key.page_down, .{})) {
291 self.state.messages.scroll_offset -|= 3;
292 } else if (key.matches(vaxis.Key.home, .{})) {
293 self.state.messages.scroll_offset = 0;
294 } else {
295 if (self.completer != null and !key.isModifier()) {
296 self.completer.?.deinit();
297 self.completer = null;
298 }
299 log.debug("{}", .{key});
300 try input.update(.{ .key_press = key });
301 }
302 },
303 .paste_start => self.state.paste.pasting = true,
304 .paste_end => {
305 self.state.paste.pasting = false;
306 if (self.state.paste.has_newline) {
307 log.warn("NEWLINE", .{});
308 } else {
309 try input.insertSliceAtCursor(self.paste_buffer.items);
310 defer self.paste_buffer.clearAndFree();
311 }
312 },
313 .focus_out => self.state.mouse = null,
314 .mouse => |mouse| {
315 self.state.mouse = mouse;
316 },
317 .winsize => |ws| try self.vx.resize(self.alloc, writer, ws),
318 .connect => |cfg| {
319 const client = try self.alloc.create(irc.Client);
320 client.* = try irc.Client.init(self.alloc, self, &write_queue, cfg);
321 client.thread = try std.Thread.spawn(.{}, irc.Client.readLoop, .{ client, &loop });
322 try self.clients.append(client);
323 },
324 .irc => |irc_event| {
325 const msg: irc.Message = .{ .bytes = irc_event.msg.slice() };
326 const client = irc_event.client;
327 defer irc_event.msg.deinit();
328 switch (msg.command()) {
329 .unknown => {},
330 .CAP => {
331 // syntax: <client> <ACK/NACK> :caps
332 var iter = msg.paramIterator();
333 _ = iter.next() orelse continue; // client
334 const ack_or_nak = iter.next() orelse continue;
335 const caps = iter.next() orelse continue;
336 var cap_iter = mem.splitScalar(u8, caps, ' ');
337 while (cap_iter.next()) |cap| {
338 if (mem.eql(u8, ack_or_nak, "ACK")) {
339 client.ack(cap);
340 if (mem.eql(u8, cap, "sasl"))
341 try client.queueWrite("AUTHENTICATE PLAIN\r\n");
342 } else if (mem.eql(u8, ack_or_nak, "NAK")) {
343 log.debug("CAP not supported {s}", .{cap});
344 }
345 }
346 },
347 .AUTHENTICATE => {
348 var iter = msg.paramIterator();
349 while (iter.next()) |param| {
350 // A '+' is the continuuation to send our
351 // AUTHENTICATE info
352 if (!mem.eql(u8, param, "+")) continue;
353 var buf: [4096]u8 = undefined;
354 const config = client.config;
355 const sasl = try std.fmt.bufPrint(
356 &buf,
357 "{s}\x00{s}\x00{s}",
358 .{ config.user, config.nick, config.password },
359 );
360
361 // Create a buffer big enough for the base64 encoded string
362 const b64_buf = try self.alloc.alloc(u8, Base64Encoder.calcSize(sasl.len));
363 defer self.alloc.free(b64_buf);
364 const encoded = Base64Encoder.encode(b64_buf, sasl);
365 // Make our message
366 const auth = try std.fmt.bufPrint(
367 &buf,
368 "AUTHENTICATE {s}\r\n",
369 .{encoded},
370 );
371 try client.queueWrite(auth);
372 if (config.network_id) |id| {
373 const bind = try std.fmt.bufPrint(
374 &buf,
375 "BOUNCER BIND {s}\r\n",
376 .{id},
377 );
378 try client.queueWrite(bind);
379 }
380 try client.queueWrite("CAP END\r\n");
381 }
382 },
383 .RPL_WELCOME => {
384 const now = try zeit.instant(.{});
385 var now_buf: [30]u8 = undefined;
386 const now_fmt = try now.time().bufPrint(&now_buf, .rfc3339);
387
388 const past = now.subtract(.{ .days = 7 });
389 var past_buf: [30]u8 = undefined;
390 const past_fmt = try past.time().bufPrint(&past_buf, .rfc3339);
391
392 var buf: [128]u8 = undefined;
393 const targets = try std.fmt.bufPrint(
394 &buf,
395 "CHATHISTORY TARGETS timestamp={s} timestamp={s} 50\r\n",
396 .{ now_fmt, past_fmt },
397 );
398 try client.queueWrite(targets);
399 // on_connect callback
400 try lua.onConnect(lua_state, client);
401 },
402 .RPL_YOURHOST => {},
403 .RPL_CREATED => {},
404 .RPL_MYINFO => {},
405 .RPL_ISUPPORT => {
406 // syntax: <client> <token>[ <token>] :are supported
407 var iter = msg.paramIterator();
408 _ = iter.next() orelse continue; // client
409 while (iter.next()) |token| {
410 if (mem.eql(u8, token, "WHOX"))
411 client.supports.whox = true
412 else if (mem.startsWith(u8, token, "PREFIX")) {
413 const prefix = blk: {
414 const idx = mem.indexOfScalar(u8, token, ')') orelse
415 // default is "@+"
416 break :blk try self.alloc.dupe(u8, "@+");
417 break :blk try self.alloc.dupe(u8, token[idx + 1 ..]);
418 };
419 client.supports.prefix = prefix;
420 }
421 }
422 },
423 .RPL_LOGGEDIN => {},
424 .RPL_TOPIC => {
425 // syntax: <client> <channel> :<topic>
426 var iter = msg.paramIterator();
427 _ = iter.next() orelse continue :loop; // client ("*")
428 const channel_name = iter.next() orelse continue :loop; // channel
429 const topic = iter.next() orelse continue :loop; // topic
430
431 var channel = try client.getOrCreateChannel(channel_name);
432 if (channel.topic) |old_topic| {
433 self.alloc.free(old_topic);
434 }
435 channel.topic = try self.alloc.dupe(u8, topic);
436 },
437 .RPL_SASLSUCCESS => {},
438 .RPL_WHOREPLY => {
439 // syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name>
440 var iter = msg.paramIterator();
441 _ = iter.next() orelse continue :loop; // client
442 const channel_name = iter.next() orelse continue :loop; // channel
443 if (mem.eql(u8, channel_name, "*")) continue;
444 _ = iter.next() orelse continue :loop; // username
445 _ = iter.next() orelse continue :loop; // host
446 _ = iter.next() orelse continue :loop; // server
447 const nick = iter.next() orelse continue :loop; // nick
448 const flags = iter.next() orelse continue :loop; // nick
449
450 const user_ptr = try client.getOrCreateUser(nick);
451 if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true;
452 var channel = try client.getOrCreateChannel(channel_name);
453
454 const prefix = for (flags) |c| {
455 if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| {
456 break c;
457 }
458 } else ' ';
459
460 try channel.addMember(user_ptr, .{ .prefix = prefix });
461 },
462 .RPL_WHOSPCRPL => {
463 // syntax: <client> <channel> <nick> <flags>
464 var iter = msg.paramIterator();
465 _ = iter.next() orelse continue;
466 const channel_name = iter.next() orelse continue; // channel
467 const nick = iter.next() orelse continue;
468 const flags = iter.next() orelse continue;
469
470 const user_ptr = try client.getOrCreateUser(nick);
471 if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true;
472 var channel = try client.getOrCreateChannel(channel_name);
473
474 const prefix = for (flags) |c| {
475 if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| {
476 break c;
477 }
478 } else ' ';
479
480 try channel.addMember(user_ptr, .{ .prefix = prefix });
481 },
482 .RPL_ENDOFWHO => {
483 // syntax: <client> <mask> :End of WHO list
484 var iter = msg.paramIterator();
485 _ = iter.next() orelse continue :loop; // client
486 const channel_name = iter.next() orelse continue :loop; // channel
487 if (mem.eql(u8, channel_name, "*")) continue;
488 var channel = try client.getOrCreateChannel(channel_name);
489 channel.in_flight.who = false;
490 },
491 .RPL_NAMREPLY => {
492 // syntax: <client> <symbol> <channel> :[<prefix>]<nick>{ [<prefix>]<nick>}
493 var iter = msg.paramIterator();
494 _ = iter.next() orelse continue; // client
495 _ = iter.next() orelse continue; // symbol
496 const channel_name = iter.next() orelse continue; // channel
497 const names = iter.next() orelse continue;
498 var channel = try client.getOrCreateChannel(channel_name);
499 var name_iter = std.mem.splitScalar(u8, names, ' ');
500 while (name_iter.next()) |name| {
501 const nick, const prefix = for (client.supports.prefix) |ch| {
502 if (name[0] == ch) {
503 break .{ name[1..], name[0] };
504 }
505 } else .{ name, ' ' };
506
507 if (prefix != ' ') {
508 log.debug("HAS PREFIX {s}", .{name});
509 }
510
511 const user_ptr = try client.getOrCreateUser(nick);
512
513 try channel.addMember(user_ptr, .{ .prefix = prefix, .sort = false });
514 }
515
516 channel.sortMembers();
517 },
518 .RPL_ENDOFNAMES => {
519 // syntax: <client> <channel> :End of /NAMES list
520 var iter = msg.paramIterator();
521 _ = iter.next() orelse continue; // client
522 const channel_name = iter.next() orelse continue; // channel
523 var channel = try client.getOrCreateChannel(channel_name);
524 channel.in_flight.names = false;
525 },
526 .BOUNCER => {
527 var iter = msg.paramIterator();
528 while (iter.next()) |param| {
529 if (mem.eql(u8, param, "NETWORK")) {
530 const id = iter.next() orelse continue;
531 const attr = iter.next() orelse continue;
532 // check if we already have this network
533 for (self.clients.items, 0..) |cl, i| {
534 if (cl.config.network_id) |net_id| {
535 if (mem.eql(u8, net_id, id)) {
536 if (mem.eql(u8, attr, "*")) {
537 // * means the network was
538 // deleted
539 cl.deinit();
540 _ = self.clients.swapRemove(i);
541 }
542 continue :loop;
543 }
544 }
545 }
546
547 var attr_iter = std.mem.splitScalar(u8, attr, ';');
548 const name: ?[]const u8 = name: while (attr_iter.next()) |kv| {
549 const n = std.mem.indexOfScalar(u8, kv, '=') orelse continue;
550 if (mem.eql(u8, kv[0..n], "name"))
551 break :name try self.alloc.dupe(u8, kv[n + 1 ..]);
552 } else null;
553
554 var cfg = client.config;
555 cfg.network_id = try self.alloc.dupe(u8, id);
556 cfg.name = name;
557 loop.postEvent(.{ .connect = cfg });
558 }
559 }
560 },
561 .AWAY => {
562 const src = msg.source() orelse continue :loop;
563 var iter = msg.paramIterator();
564 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
565 const user = try client.getOrCreateUser(src[0..n]);
566 // If there are any params, the user is away. Otherwise
567 // they are back.
568 user.away = if (iter.next()) |_| true else false;
569 },
570 .BATCH => {
571 var iter = msg.paramIterator();
572 const tag = iter.next() orelse continue;
573 switch (tag[0]) {
574 '+' => {
575 const batch_type = iter.next() orelse continue;
576 if (mem.eql(u8, batch_type, "chathistory")) {
577 const target = iter.next() orelse continue;
578 var channel = try client.getOrCreateChannel(target);
579 channel.at_oldest = true;
580 const duped_tag = try self.alloc.dupe(u8, tag[1..]);
581 try client.batches.put(duped_tag, channel);
582 }
583 },
584 '-' => {
585 const key = client.batches.getKey(tag[1..]) orelse continue;
586 var chan = client.batches.get(key) orelse @panic("key should exist here");
587 chan.history_requested = false;
588 _ = client.batches.remove(key);
589 self.alloc.free(key);
590 },
591 else => {},
592 }
593 },
594 .CHATHISTORY => {
595 var iter = msg.paramIterator();
596 const should_targets = iter.next() orelse continue;
597 if (!mem.eql(u8, should_targets, "TARGETS")) continue;
598 const target = iter.next() orelse continue;
599 // we only add direct messages, not more channels
600 assert(target.len > 0);
601 if (target[0] == '#') continue;
602
603 var channel = try client.getOrCreateChannel(target);
604 const user_ptr = try client.getOrCreateUser(target);
605 const me_ptr = try client.getOrCreateUser(client.config.nick);
606 try channel.addMember(user_ptr, .{});
607 try channel.addMember(me_ptr, .{});
608 // we set who_requested so we don't try to request
609 // who on DMs
610 channel.who_requested = true;
611 var buf: [128]u8 = undefined;
612 const mark_read = try std.fmt.bufPrint(
613 &buf,
614 "MARKREAD {s}\r\n",
615 .{channel.name},
616 );
617 try client.queueWrite(mark_read);
618 try client.requestHistory(.after, channel);
619 },
620 .JOIN => {
621 // get the user
622 const src = msg.source() orelse continue :loop;
623 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
624 const user = try client.getOrCreateUser(src[0..n]);
625
626 // get the channel
627 var iter = msg.paramIterator();
628 const target = iter.next() orelse continue;
629 var channel = try client.getOrCreateChannel(target);
630
631 // If it's our nick, we request chat history
632 if (mem.eql(u8, user.nick, client.config.nick))
633 try client.requestHistory(.after, channel)
634 else
635 try channel.addMember(user, .{});
636 },
637 .MARKREAD => {
638 var iter = msg.paramIterator();
639 const target = iter.next() orelse continue;
640 const timestamp = iter.next() orelse continue;
641 const equal = std.mem.indexOfScalar(u8, timestamp, '=') orelse continue;
642 const last_read = zeit.instant(.{
643 .source = .{
644 .iso8601 = timestamp[equal + 1 ..],
645 },
646 }) catch |err| {
647 log.err("couldn't convert timestamp: {}", .{err});
648 continue;
649 };
650 var channel = try client.getOrCreateChannel(target);
651 channel.last_read = last_read.unixTimestamp();
652 const last_msg = channel.messages.getLastOrNull() orelse continue;
653 const time = last_msg.time() orelse continue;
654 if (time.unixTimestamp() > channel.last_read)
655 channel.has_unread = true
656 else
657 channel.has_unread = false;
658 },
659 .PART => {
660 // get the user
661 const src = msg.source() orelse continue :loop;
662 const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len;
663 const user = try client.getOrCreateUser(src[0..n]);
664
665 // get the channel
666 var iter = msg.paramIterator();
667 const target = iter.next() orelse continue;
668
669 if (mem.eql(u8, user.nick, client.config.nick)) {
670 for (client.channels.items, 0..) |channel, i| {
671 if (!mem.eql(u8, channel.name, target)) continue;
672 var chan = client.channels.orderedRemove(i);
673 chan.deinit(self.alloc);
674 break;
675 }
676 } else {
677 const channel = try client.getOrCreateChannel(target);
678 channel.removeMember(user);
679 }
680 },
681 .PRIVMSG, .NOTICE => {
682 // syntax: <target> :<message>
683 const msg2: irc.Message = .{
684 .bytes = try self.alloc.dupe(u8, msg.bytes),
685 };
686 var iter = msg2.paramIterator();
687 const target = blk: {
688 const tgt = iter.next() orelse continue;
689 if (mem.eql(u8, tgt, client.config.nick)) {
690 // If the target is us, it likely has our
691 // hostname in it.
692 const source = msg2.source() orelse continue;
693 const n = mem.indexOfScalar(u8, source, '!') orelse source.len;
694 break :blk source[0..n];
695 } else break :blk tgt;
696 };
697
698 // We handle batches separately. When we encounter a
699 // PRIVMSG from a batch, we use the original target
700 // from the batch start. We also never notify from a
701 // batched message. Batched messages also require
702 // sorting
703 var tag_iter = msg2.tagIterator();
704 while (tag_iter.next()) |tag| {
705 if (mem.eql(u8, tag.key, "batch")) {
706 const entry = client.batches.getEntry(tag.value) orelse @panic("TODO");
707 var channel = entry.value_ptr.*;
708 try channel.messages.append(msg2);
709 std.sort.insertion(irc.Message, channel.messages.items, {}, irc.Message.compareTime);
710 channel.at_oldest = false;
711 const time = msg2.time() orelse continue;
712 if (time.unixTimestamp() > channel.last_read) {
713 channel.has_unread = true;
714 const content = iter.next() orelse continue;
715 if (std.mem.indexOf(u8, content, client.config.nick)) |_| {
716 channel.has_unread_highlight = true;
717 }
718 }
719 break;
720 }
721 } else {
722 // standard handling
723 var channel = try client.getOrCreateChannel(target);
724 try channel.messages.append(msg2);
725 const content = iter.next() orelse continue;
726 var has_highlight = false;
727 {
728 const sender: []const u8 = blk: {
729 const src = msg2.source() orelse break :blk "";
730 const l = std.mem.indexOfScalar(u8, src, '!') orelse
731 std.mem.indexOfScalar(u8, src, '@') orelse
732 src.len;
733 break :blk src[0..l];
734 };
735 try lua.onMessage(lua_state, client, channel.name, sender, content);
736 }
737 if (std.mem.indexOf(u8, content, client.config.nick)) |_| {
738 var buf: [64]u8 = undefined;
739 const title_or_err = if (msg2.source()) |source|
740 std.fmt.bufPrint(&buf, "{s} - {s}", .{ channel.name, source })
741 else
742 std.fmt.bufPrint(&buf, "{s}", .{channel.name});
743 const title = title_or_err catch title: {
744 const len = @min(buf.len, channel.name.len);
745 @memcpy(buf[0..len], channel.name[0..len]);
746 break :title buf[0..len];
747 };
748 try self.vx.notify(writer, title, content);
749 has_highlight = true;
750 }
751 const time = msg2.time() orelse continue;
752 if (time.unixTimestamp() > channel.last_read) {
753 channel.has_unread_highlight = has_highlight;
754 channel.has_unread = true;
755 }
756 }
757 },
758 }
759 },
760 }
761 }
762
763 if (redraw) {
764 try self.draw(&input);
765 last_frame = std.time.milliTimestamp();
766 }
767 }
768 }
769 pub fn nextChannel(self: *App) void {
770 const state = self.state.buffers;
771 if (state.selected_idx >= state.count - 1)
772 self.state.buffers.selected_idx = 0
773 else
774 self.state.buffers.selected_idx +|= 1;
775 }
776
777 pub fn prevChannel(self: *App) void {
778 switch (self.state.buffers.selected_idx) {
779 0 => self.state.buffers.selected_idx = self.state.buffers.count - 1,
780 else => self.state.buffers.selected_idx -|= 1,
781 }
782 }
783
784 /// handle a command
785 pub fn handleCommand(self: *App, lua_state: *Lua, buffer: irc.Buffer, cmd: []const u8) !void {
786 const command: comlink.Command = blk: {
787 const start: u1 = if (cmd[0] == '/') 1 else 0;
788 const end = mem.indexOfScalar(u8, cmd, ' ') orelse cmd.len;
789 if (comlink.Command.fromString(cmd[start..end])) |internal|
790 break :blk internal;
791 if (comlink.Command.user_commands.get(cmd[start..end])) |ref| {
792 const str = if (end == cmd.len) "" else std.mem.trim(u8, cmd[end..], " ");
793 return lua.execUserCommand(lua_state, str, ref);
794 }
795 return error.UnknownCommand;
796 };
797 var buf: [1024]u8 = undefined;
798 const client: *irc.Client = switch (buffer) {
799 .client => |client| client,
800 .channel => |channel| channel.client,
801 };
802 const channel: ?*irc.Channel = switch (buffer) {
803 .client => null,
804 .channel => |channel| channel,
805 };
806 switch (command) {
807 .quote => {
808 const start = mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
809 const msg = try std.fmt.bufPrint(
810 &buf,
811 "{s}\r\n",
812 .{cmd[start + 1 ..]},
813 );
814 return client.queueWrite(msg);
815 },
816 .join => {
817 const start = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
818 const msg = try std.fmt.bufPrint(
819 &buf,
820 "JOIN {s}\r\n",
821 .{
822 cmd[start + 1 ..],
823 },
824 );
825 return client.queueWrite(msg);
826 },
827 .me => {
828 if (channel == null) return error.InvalidCommand;
829 const msg = try std.fmt.bufPrint(
830 &buf,
831 "PRIVMSG {s} :\x01ACTION {s}\x01\r\n",
832 .{
833 channel.?.name,
834 cmd[4..],
835 },
836 );
837 return client.queueWrite(msg);
838 },
839 .msg => {
840 //syntax: /msg <nick> <msg>
841 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
842 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse return error.InvalidCommand;
843 const msg = try std.fmt.bufPrint(
844 &buf,
845 "PRIVMSG {s} :{s}\r\n",
846 .{
847 cmd[s + 1 .. e],
848 cmd[e + 1 ..],
849 },
850 );
851 return client.queueWrite(msg);
852 },
853 .names => {
854 if (channel == null) return error.InvalidCommand;
855 const msg = try std.fmt.bufPrint(&buf, "NAMES {s}\r\n", .{channel.?.name});
856 return client.queueWrite(msg);
857 },
858 .@"next-channel" => self.nextChannel(),
859 .@"prev-channel" => self.prevChannel(),
860 .quit => self.should_quit = true,
861 .who => {
862 if (channel == null) return error.InvalidCommand;
863 const msg = try std.fmt.bufPrint(
864 &buf,
865 "WHO {s}\r\n",
866 .{
867 channel.?.name,
868 },
869 );
870 return client.queueWrite(msg);
871 },
872 .part, .close => {
873 if (channel == null) return error.InvalidCommand;
874 var it = std.mem.tokenizeScalar(u8, cmd, ' ');
875
876 // Skip command
877 _ = it.next();
878
879 const msg = try std.fmt.bufPrint(
880 &buf,
881 "PART {s}\r\n",
882 .{
883 it.next() orelse channel.?.name,
884 },
885 );
886 return client.queueWrite(msg);
887 },
888 .redraw => self.vx.queueRefresh(),
889 .version => {
890 if (channel == null) return error.InvalidCommand;
891 const msg = try std.fmt.bufPrint(
892 &buf,
893 "NOTICE {s} :\x01VERSION comlink {s}\x01\r\n",
894 .{
895 channel.?.name,
896 main.version,
897 },
898 );
899 return client.queueWrite(msg);
900 },
901 .lua_function => {}, // we don't handle these from the text-input
902 }
903 }
904
905 pub fn selectedBuffer(self: *App) ?irc.Buffer {
906 var i: usize = 0;
907 for (self.clients.items) |client| {
908 if (i == self.state.buffers.selected_idx) return .{ .client = client };
909 i += 1;
910 for (client.channels.items) |*channel| {
911 if (i == self.state.buffers.selected_idx) return .{ .channel = channel };
912 i += 1;
913 }
914 }
915 return null;
916 }
917
918 fn draw(self: *App, input: *TextInput) !void {
919 var arena = std.heap.ArenaAllocator.init(self.alloc);
920 defer arena.deinit();
921 const allocator = arena.allocator();
922
923 // reset window state
924 const win = self.vx.window();
925 win.clear();
926 self.vx.setMouseShape(.default);
927
928 // Handle resize of sidebars
929 if (self.state.mouse) |mouse| {
930 if (self.state.buffers.resizing) {
931 self.state.buffers.width = @min(mouse.col, win.width -| self.state.members.width);
932 } else if (self.state.members.resizing) {
933 self.state.members.width = win.width -| mouse.col + 1;
934 }
935
936 if (mouse.col == self.state.buffers.width) {
937 self.vx.setMouseShape(.@"ew-resize");
938 switch (mouse.type) {
939 .press => {
940 if (mouse.button == .left) self.state.buffers.resizing = true;
941 },
942 .release => self.state.buffers.resizing = false,
943 else => {},
944 }
945 } else if (mouse.col == win.width -| self.state.members.width + 1) {
946 self.vx.setMouseShape(.@"ew-resize");
947 switch (mouse.type) {
948 .press => {
949 if (mouse.button == .left) self.state.members.resizing = true;
950 },
951 .release => self.state.members.resizing = false,
952 else => {},
953 }
954 }
955 }
956
957 // Define the layout
958 const buf_list_w = self.state.buffers.width;
959 const mbr_list_w = self.state.members.width;
960 const message_list_width = win.width -| buf_list_w -| mbr_list_w;
961
962 const channel_list_win = win.child(.{
963 .width = .{ .limit = self.state.buffers.width + 1 },
964 .border = .{ .where = .right },
965 });
966
967 const member_list_win = win.child(.{
968 .x_off = buf_list_w + message_list_width + 1,
969 .border = .{ .where = .left },
970 });
971
972 const middle_win = win.child(.{
973 .x_off = buf_list_w + 1,
974 .width = .{ .limit = message_list_width },
975 });
976
977 const topic_win = middle_win.child(.{
978 .height = .{ .limit = 2 },
979 .border = .{ .where = .bottom },
980 });
981
982 const message_list_win = middle_win.child(.{
983 .y_off = 2,
984 .height = .{ .limit = middle_win.height -| 4 },
985 .width = .{ .limit = middle_win.width -| 1 },
986 });
987
988 // Draw the buffer list
989 try self.drawBufferList(self.clients.items, channel_list_win);
990
991 // Get our currently selected buffer and draw it
992 const buffer = self.selectedBuffer() orelse return;
993 switch (buffer) {
994 .client => {}, // nothing to do
995
996 .channel => |channel| {
997 // Mark the channel as read
998 try channel.markRead();
999
1000 // Request WHO if we don't already have it
1001 if (!channel.who_requested) try channel.client.whox(channel);
1002
1003 // Set the title of the terminal
1004 {
1005 var buf: [64]u8 = undefined;
1006 const title = std.fmt.bufPrint(&buf, "{s} - comlink", .{channel.name}) catch title: {
1007 // If the channel name is too long to fit in our buffer just truncate
1008 const len = @min(buf.len, channel.name.len);
1009 @memcpy(buf[0..len], channel.name[0..len]);
1010 break :title buf[0..len];
1011 };
1012 try self.vx.setTitle(self.tty.anyWriter(), title);
1013 }
1014
1015 // Draw the topic
1016 try self.drawTopic(topic_win, channel.topic orelse "");
1017
1018 // Draw the member list
1019 try self.drawMemberList(member_list_win, channel);
1020
1021 // Draw the message list
1022 try self.drawMessageList(allocator, message_list_win, channel);
1023
1024 // draw a scrollbar
1025 {
1026 const scrollbar: Scrollbar = .{
1027 .total = channel.messages.items.len,
1028 .view_size = message_list_win.height / 3, // ~3 lines per message
1029 .bottom = self.state.messages.scroll_offset,
1030 };
1031 const scrollbar_win = middle_win.child(.{
1032 .x_off = message_list_win.width,
1033 .y_off = 2,
1034 .height = .{ .limit = middle_win.height -| 4 },
1035 });
1036 scrollbar.draw(scrollbar_win);
1037 }
1038
1039 // draw the completion list
1040 if (self.completer) |*completer| {
1041 try completer.findMatches(channel);
1042
1043 var completion_style: vaxis.Style = .{ .bg = .{ .index = 8 } };
1044 const completion_win = middle_win.child(.{
1045 .width = .{ .limit = completer.widestMatch(win) + 1 },
1046 .height = .{ .limit = @min(completer.numMatches(), middle_win.height -| 1) },
1047 .x_off = completer.start_idx,
1048 .y_off = middle_win.height -| completer.numMatches() -| 1,
1049 });
1050 completion_win.fill(.{
1051 .char = .{ .grapheme = " ", .width = 1 },
1052 .style = completion_style,
1053 });
1054 var completion_row: usize = 0;
1055 while (completion_row < completion_win.height) : (completion_row += 1) {
1056 log.debug("COMPLETION ROW {d}, selected_idx {d}", .{ completion_row, completer.selected_idx orelse 0 });
1057 if (completer.selected_idx) |idx| {
1058 if (completion_row == idx)
1059 completion_style.reverse = true
1060 else {
1061 completion_style = .{ .bg = .{ .index = 8 } };
1062 }
1063 }
1064 var seg = [_]vaxis.Segment{
1065 .{
1066 .text = completer.options.items[completer.options.items.len - 1 - completion_row],
1067 .style = completion_style,
1068 },
1069 .{
1070 .text = " ",
1071 .style = completion_style,
1072 },
1073 };
1074 _ = try completion_win.print(&seg, .{
1075 .row_offset = completion_win.height -| completion_row -| 1,
1076 });
1077 }
1078 }
1079 },
1080 }
1081
1082 const input_win = middle_win.child(.{
1083 .y_off = win.height -| 1,
1084 .width = .{ .limit = middle_win.width -| 7 },
1085 .height = .{ .limit = 1 },
1086 });
1087 const len_win = middle_win.child(.{
1088 .x_off = input_win.width,
1089 .y_off = win.height -| 1,
1090 .width = .{ .limit = 7 },
1091 .height = .{ .limit = 1 },
1092 });
1093 const buf_name_len = blk: {
1094 const sel_buf = self.selectedBuffer() orelse @panic("no buffer");
1095 switch (sel_buf) {
1096 .channel => |chan| break :blk chan.name.len,
1097 else => break :blk 0,
1098 }
1099 };
1100 // PRIVMSG <channel_name> :<message>\r\n = 12 bytes of overhead
1101 const max_len = irc.maximum_message_size - buf_name_len - 12;
1102 var len_buf: [7]u8 = undefined;
1103 const msg_len = input.buf.realLength();
1104 _ = try std.fmt.bufPrint(&len_buf, "{d: >3}/{d}", .{ msg_len, max_len });
1105
1106 var len_segs = [_]vaxis.Segment{
1107 .{
1108 .text = len_buf[0..3],
1109 .style = .{ .fg = if (msg_len > max_len)
1110 .{ .index = 1 }
1111 else
1112 .{ .index = 8 } },
1113 },
1114 .{
1115 .text = len_buf[3..],
1116 .style = .{ .fg = .{ .index = 8 } },
1117 },
1118 };
1119
1120 _ = try len_win.print(&len_segs, .{});
1121 input.draw(input_win);
1122
1123 if (self.state.paste.showDialog()) {
1124 // Draw a modal dialog for how to handle multi-line paste
1125 const multiline_paste_win = vaxis.widgets.alignment.center(win, win.width - 10, win.height - 10);
1126 const bordered = vaxis.widgets.border.all(multiline_paste_win, .{});
1127 bordered.clear();
1128 const warning_width: usize = 37;
1129 const title_win = multiline_paste_win.child(.{
1130 .height = .{ .limit = 2 },
1131 .y_off = 1,
1132 .x_off = multiline_paste_win.width / 2 - warning_width / 2,
1133 });
1134 const title_seg = [_]vaxis.Segment{
1135 .{
1136 .text = "/!\\ Warning: Multiline paste detected",
1137 .style = .{
1138 .fg = .{ .index = 3 },
1139 .bold = true,
1140 },
1141 },
1142 };
1143 _ = try title_win.print(&title_seg, .{ .wrap = .none });
1144 var segs = [_]vaxis.Segment{
1145 .{ .text = self.paste_buffer.items },
1146 };
1147 _ = try bordered.print(&segs, .{ .wrap = .grapheme, .row_offset = 2 });
1148 // const button: Button = .{
1149 // .label = "Accept",
1150 // .style = .{ .bg = .{ .index = 7 } },
1151 // };
1152 // try button.draw(bordered.child(.{
1153 // .x_off = 3,
1154 // .y_off = bordered.height - 4,
1155 // .height = .{ .limit = 3 },
1156 // .width = .{ .limit = 10 },
1157 // }));
1158 }
1159
1160 var buffered = self.tty.bufferedWriter();
1161 try self.vx.render(buffered.writer().any());
1162 try buffered.flush();
1163 }
1164
1165 fn drawMessageList(
1166 self: *App,
1167 arena: std.mem.Allocator,
1168 win: vaxis.Window,
1169 channel: *irc.Channel,
1170 ) !void {
1171 if (channel.messages.items.len == 0) return;
1172 const client = channel.client;
1173 const last_msg_idx = channel.messages.items.len -| self.state.messages.scroll_offset;
1174 const messages = channel.messages.items[0..@max(1, last_msg_idx)];
1175 // We draw a gutter for time information
1176 const gutter_width: usize = 6;
1177
1178 // Our message list is offset by the gutter width
1179 const message_offset_win = win.child(.{ .x_off = gutter_width });
1180
1181 // Handle mouse
1182 if (win.hasMouse(self.state.mouse)) |mouse| {
1183 switch (mouse.button) {
1184 .wheel_up => {
1185 self.state.messages.scroll_offset +|= 1;
1186 self.state.mouse.?.button = .none;
1187 self.state.messages.pending_scroll += 2;
1188 },
1189 .wheel_down => {
1190 self.state.messages.scroll_offset -|= 1;
1191 self.state.mouse.?.button = .none;
1192 self.state.messages.pending_scroll -= 2;
1193 },
1194 else => {},
1195 }
1196 }
1197 self.state.messages.scroll_offset = @min(
1198 self.state.messages.scroll_offset,
1199 channel.messages.items.len -| 1,
1200 );
1201
1202 // Define a few state variables for the loop
1203 const last_msg = messages[messages.len -| 1];
1204
1205 // Initialize prev_time to the time of the last message, falling back to "now"
1206 var prev_time: zeit.Instant = last_msg.localTime(&self.tz) orelse
1207 try zeit.instant(.{ .source = .now, .timezone = &self.tz });
1208
1209 // Initialize prev_sender to the sender of the last message
1210 var prev_sender: []const u8 = if (last_msg.source()) |src| blk: {
1211 if (std.mem.indexOfScalar(u8, src, '!')) |idx|
1212 break :blk src[0..idx];
1213 if (std.mem.indexOfScalar(u8, src, '@')) |idx|
1214 break :blk src[0..idx];
1215 break :blk src;
1216 } else "";
1217
1218 // y_off is the row we are printing on
1219 var y_off: usize = win.height;
1220
1221 // Formatted message segments
1222 var segments = std.ArrayList(vaxis.Segment).init(arena);
1223
1224 var msg_iter = std.mem.reverseIterator(messages);
1225 var i: usize = messages.len;
1226 while (msg_iter.next()) |message| {
1227 i -|= 1;
1228 segments.clearRetainingCapacity();
1229
1230 // Get the sender nick
1231 const sender: []const u8 = if (message.source()) |src| blk: {
1232 if (std.mem.indexOfScalar(u8, src, '!')) |idx|
1233 break :blk src[0..idx];
1234 if (std.mem.indexOfScalar(u8, src, '@')) |idx|
1235 break :blk src[0..idx];
1236 break :blk src;
1237 } else "";
1238
1239 // Save sender state after this loop
1240 defer prev_sender = sender;
1241
1242 // Before we print the message, we need to decide if we should print the sender name of
1243 // the previous message. There are two cases we do this:
1244 // 1. The previous message was sent by someone other than the current message
1245 // 2. A certain amount of time has elapsed between messages
1246 //
1247 // Each case requires that we have space in the window to print the sender (y_off > 0)
1248 const time_gap = if (message.localTime(&self.tz)) |time| blk: {
1249 // Save message state for next loop
1250 defer prev_time = time;
1251 // time_gap is true when the difference between this message and last message is
1252 // greater than 5 minutes
1253 break :blk (prev_time.timestamp -| time.timestamp) > (5 * std.time.ns_per_min);
1254 } else false;
1255
1256 // Print the sender of the previous message
1257 if (y_off > 0 and (time_gap or !std.mem.eql(u8, prev_sender, sender))) {
1258 // Go up one line
1259 y_off -|= 1;
1260
1261 // Get the user so we have the correct color
1262 const user = try client.getOrCreateUser(prev_sender);
1263 const sender_win = message_offset_win.child(.{
1264 .y_off = y_off,
1265 .height = .{ .limit = 1 },
1266 });
1267
1268 // We will use the result to see if our mouse is hovering over the nickname
1269 const sender_result = try sender_win.printSegment(
1270 .{
1271 .text = prev_sender,
1272 .style = .{ .fg = user.color, .bold = true },
1273 },
1274 .{ .wrap = .none },
1275 );
1276
1277 // If our mouse is over the nickname, we set it to a pointer
1278 const result_win = sender_win.child(.{ .width = .{ .limit = sender_result.col } });
1279 if (result_win.hasMouse(self.state.mouse)) |_| {
1280 self.vx.setMouseShape(.pointer);
1281 }
1282
1283 // Go up one more line to print the next message
1284 y_off -|= 1;
1285 }
1286
1287 // We are out of space
1288 if (y_off == 0) break;
1289
1290 const user = try client.getOrCreateUser(sender);
1291 try format.message(&segments, user, message);
1292
1293 // Get the line count for this message
1294 const content_height = lineCountForWindow(message_offset_win, segments.items);
1295
1296 const content_win = message_offset_win.child(
1297 .{
1298 .y_off = y_off -| content_height,
1299 .height = .{ .limit = content_height },
1300 },
1301 );
1302 if (content_win.hasMouse(self.state.mouse)) |mouse| {
1303 var bg_idx: u8 = 8;
1304 if (mouse.type == .press and mouse.button == .middle) {
1305 var list = std.ArrayList(u8).init(self.alloc);
1306 defer list.deinit();
1307 for (segments.items) |item| {
1308 try list.appendSlice(item.text);
1309 }
1310 try self.vx.copyToSystemClipboard(self.tty.anyWriter(), list.items, self.alloc);
1311 bg_idx = 3;
1312 }
1313 content_win.fill(.{
1314 .char = .{
1315 .grapheme = " ",
1316 .width = 1,
1317 },
1318 .style = .{
1319 .bg = .{ .index = bg_idx },
1320 },
1321 });
1322 for (segments.items) |*item| {
1323 item.style.bg = .{ .index = bg_idx };
1324 }
1325 }
1326 var iter = message.paramIterator();
1327 // target is the channel, and we already handled that
1328 _ = iter.next() orelse continue;
1329
1330 const content = iter.next() orelse continue;
1331 if (std.mem.indexOf(u8, content, client.config.nick)) |_| {
1332 for (segments.items) |*item| {
1333 if (item.style.fg == .default)
1334 item.style.fg = .{ .index = 3 };
1335 }
1336 }
1337 _ = try content_win.print(
1338 segments.items,
1339 .{
1340 .wrap = .word,
1341 },
1342 );
1343 if (content_height > y_off) break;
1344 const gutter = win.child(.{
1345 .y_off = y_off -| content_height,
1346 .width = .{ .limit = 6 },
1347 });
1348
1349 if (message.localTime(&self.tz)) |instant| {
1350 var date: bool = false;
1351 const time = instant.time();
1352 var buf = try std.fmt.allocPrint(
1353 arena,
1354 "{d:0>2}:{d:0>2}",
1355 .{ time.hour, time.minute },
1356 );
1357 if (i != 0 and channel.messages.items[i - 1].time() != null) {
1358 const prev = channel.messages.items[i - 1].localTime(&self.tz).?.time();
1359 if (time.day != prev.day) {
1360 date = true;
1361 buf = try std.fmt.allocPrint(
1362 arena,
1363 "{d:0>2}/{d:0>2}",
1364 .{ @intFromEnum(time.month), time.day },
1365 );
1366 }
1367 }
1368 if (i == 0) {
1369 date = true;
1370 buf = try std.fmt.allocPrint(
1371 arena,
1372 "{d:0>2}/{d:0>2}",
1373 .{ @intFromEnum(time.month), time.day },
1374 );
1375 }
1376 const fg: vaxis.Color = if (date)
1377 .default
1378 else
1379 .{ .index = 8 };
1380 var time_seg = [_]vaxis.Segment{
1381 .{
1382 .text = buf,
1383 .style = .{ .fg = fg },
1384 },
1385 };
1386 _ = try gutter.print(&time_seg, .{});
1387 }
1388
1389 y_off -|= content_height;
1390
1391 // If we are on the first message, print the sender
1392 if (i == 0) {
1393 y_off -|= 1;
1394 const sender_win = win.child(.{
1395 .x_off = 6,
1396 .y_off = y_off,
1397 .height = .{ .limit = 1 },
1398 });
1399 const sender_result = try sender_win.print(
1400 &.{.{
1401 .text = sender,
1402 .style = .{
1403 .fg = user.color,
1404 .bold = true,
1405 },
1406 }},
1407 .{ .wrap = .word },
1408 );
1409 const result_win = sender_win.child(.{ .width = .{ .limit = sender_result.col } });
1410 if (result_win.hasMouse(self.state.mouse)) |_| {
1411 self.vx.setMouseShape(.pointer);
1412 }
1413 }
1414
1415 // if we are on the oldest message, request more history
1416 if (i == 0 and !channel.at_oldest) {
1417 try client.requestHistory(.before, channel);
1418 }
1419 }
1420 }
1421
1422 fn drawMemberList(self: *App, win: vaxis.Window, channel: *irc.Channel) !void {
1423 // Handle mouse
1424 {
1425 if (win.hasMouse(self.state.mouse)) |mouse| {
1426 switch (mouse.button) {
1427 .wheel_up => {
1428 self.state.members.scroll_offset -|= 3;
1429 self.state.mouse.?.button = .none;
1430 },
1431 .wheel_down => {
1432 self.state.members.scroll_offset +|= 3;
1433 self.state.mouse.?.button = .none;
1434 },
1435 else => {},
1436 }
1437 }
1438
1439 self.state.members.scroll_offset = @min(
1440 self.state.members.scroll_offset,
1441 channel.members.items.len -| win.height,
1442 );
1443 }
1444
1445 // Draw the list
1446 var member_row: usize = 0;
1447 for (channel.members.items) |*member| {
1448 defer member_row += 1;
1449 if (member_row < self.state.members.scroll_offset) continue;
1450 const member_seg = [_]vaxis.Segment{
1451 .{
1452 .text = std.mem.asBytes(&member.prefix),
1453 },
1454 .{
1455 .text = member.user.nick,
1456 .style = .{
1457 .fg = if (member.user.away)
1458 .{ .index = 8 }
1459 else
1460 member.user.color,
1461 },
1462 },
1463 };
1464 _ = try win.print(&member_seg, .{
1465 .row_offset = member_row -| self.state.members.scroll_offset,
1466 });
1467 }
1468 }
1469
1470 fn drawTopic(_: *App, win: vaxis.Window, topic: []const u8) !void {
1471 _ = try win.printSegment(.{ .text = topic }, .{ .wrap = .none });
1472 }
1473
1474 fn drawBufferList(self: *App, clients: []*irc.Client, win: vaxis.Window) !void {
1475 const buf_list_w = self.state.buffers.width;
1476 var row: usize = 0;
1477
1478 defer self.state.buffers.count = row;
1479 for (clients) |client| {
1480 var style: vaxis.Style = if (row == self.state.buffers.selected_idx)
1481 .{
1482 .fg = if (client.status == .disconnected) .{ .index = 8 } else .default,
1483 .reverse = true,
1484 }
1485 else
1486 .{
1487 .fg = if (client.status == .disconnected) .{ .index = 8 } else .default,
1488 };
1489 const network_win = win.child(.{
1490 .y_off = row,
1491 .height = .{ .limit = 1 },
1492 });
1493 if (network_win.hasMouse(self.state.mouse)) |_| {
1494 self.vx.setMouseShape(.pointer);
1495 style.bg = .{ .index = 8 };
1496 }
1497 _ = try network_win.print(
1498 &.{.{
1499 .text = client.config.name orelse client.config.server,
1500 .style = style,
1501 }},
1502 .{},
1503 );
1504 if (network_win.hasMouse(self.state.mouse)) |_| {
1505 self.vx.setMouseShape(.pointer);
1506 }
1507 row += 1;
1508 for (client.channels.items) |*channel| {
1509 const channel_win = win.child(.{
1510 .y_off = row,
1511 .height = .{ .limit = 1 },
1512 });
1513 if (channel_win.hasMouse(self.state.mouse)) |mouse| {
1514 if (mouse.type == .press and mouse.button == .left) {
1515 self.state.buffers.selected_idx = row;
1516 }
1517 }
1518
1519 const is_current = row == self.state.buffers.selected_idx;
1520 var chan_style: vaxis.Style = if (is_current)
1521 .{
1522 .fg = if (client.status == .disconnected) .{ .index = 8 } else .default,
1523 .reverse = true,
1524 }
1525 else if (channel.has_unread)
1526 .{
1527 .fg = .{ .index = 4 },
1528 .bold = true,
1529 }
1530 else
1531 .{
1532 .fg = if (client.status == .disconnected) .{ .index = 8 } else .default,
1533 };
1534 defer row += 1;
1535 const prefix: []const u8 = if (channel.name[0] == '#') "#" else "";
1536 const name_offset: usize = if (prefix.len > 0) 1 else 0;
1537
1538 if (channel_win.hasMouse(self.state.mouse)) |mouse| {
1539 self.vx.setMouseShape(.pointer);
1540 if (mouse.button == .left)
1541 chan_style.reverse = true
1542 else
1543 chan_style.bg = .{ .index = 8 };
1544 }
1545
1546 const first_seg: vaxis.Segment = if (channel.has_unread_highlight)
1547 .{ .text = " ●︎", .style = .{ .fg = .{ .index = 1 } } }
1548 else
1549 .{ .text = " " };
1550
1551 var chan_seg = [_]vaxis.Segment{
1552 first_seg,
1553 .{
1554 .text = prefix,
1555 .style = .{ .fg = .{ .index = 8 } },
1556 },
1557 .{
1558 .text = channel.name[name_offset..],
1559 .style = chan_style,
1560 },
1561 };
1562 const result = try channel_win.print(
1563 &chan_seg,
1564 .{},
1565 );
1566 if (result.overflow)
1567 win.writeCell(
1568 buf_list_w -| 1,
1569 row,
1570 .{
1571 .char = .{
1572 .grapheme = "…",
1573 .width = 1,
1574 },
1575 .style = chan_style,
1576 },
1577 );
1578 }
1579 }
1580 }
1581
1582 /// generate vaxis.Segments for the message content
1583 fn formatMessageContent(self: *App, client: *irc.Client, msg: irc.Message) !void {
1584 const ColorState = enum {
1585 ground,
1586 fg,
1587 bg,
1588 };
1589 const LinkState = enum {
1590 h,
1591 t1,
1592 t2,
1593 p,
1594 s,
1595 colon,
1596 slash,
1597 consume,
1598 };
1599
1600 var iter = msg.paramIterator();
1601 _ = iter.next() orelse return error.InvalidMessage;
1602 const content = iter.next() orelse return error.InvalidMessage;
1603 var start: usize = 0;
1604 var i: usize = 0;
1605 var style: vaxis.Style = .{};
1606 while (i < content.len) : (i += 1) {
1607 const b = content[i];
1608 switch (b) {
1609 0x01 => { // https://modern.ircdocs.horse/ctcp
1610 if (i == 0 and
1611 content.len > 7 and
1612 mem.startsWith(u8, content[1..], "ACTION"))
1613 {
1614 // get the user of this message
1615 const sender: []const u8 = blk: {
1616 const src = msg.source() orelse break :blk "";
1617 const l = std.mem.indexOfScalar(u8, src, '!') orelse
1618 std.mem.indexOfScalar(u8, src, '@') orelse
1619 src.len;
1620 break :blk src[0..l];
1621 };
1622 const user = try client.getOrCreateUser(sender);
1623 style.italic = true;
1624 const user_style: vaxis.Style = .{
1625 .fg = user.color,
1626 .italic = true,
1627 };
1628 try self.content_segments.append(.{
1629 .text = user.nick,
1630 .style = user_style,
1631 });
1632 i += 6; // "ACTION"
1633 } else {
1634 try self.content_segments.append(.{
1635 .text = content[start..i],
1636 .style = style,
1637 });
1638 }
1639 start = i + 1;
1640 },
1641 0x02 => {
1642 try self.content_segments.append(.{
1643 .text = content[start..i],
1644 .style = style,
1645 });
1646 style.bold = !style.bold;
1647 start = i + 1;
1648 },
1649 0x03 => {
1650 try self.content_segments.append(.{
1651 .text = content[start..i],
1652 .style = style,
1653 });
1654 i += 1;
1655 var state: ColorState = .ground;
1656 var fg_idx: ?u8 = null;
1657 var bg_idx: ?u8 = null;
1658 while (i < content.len) : (i += 1) {
1659 const d = content[i];
1660 switch (state) {
1661 .ground => {
1662 switch (d) {
1663 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => {
1664 state = .fg;
1665 fg_idx = d - '0';
1666 },
1667 else => {
1668 style.fg = .default;
1669 style.bg = .default;
1670 start = i;
1671 break;
1672 },
1673 }
1674 },
1675 .fg => {
1676 switch (d) {
1677 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => {
1678 const fg = fg_idx orelse 0;
1679 if (fg > 9) {
1680 style.fg = irc.toVaxisColor(fg);
1681 start = i;
1682 break;
1683 } else {
1684 fg_idx = fg * 10 + (d - '0');
1685 }
1686 },
1687 else => {
1688 if (fg_idx) |fg| {
1689 style.fg = irc.toVaxisColor(fg);
1690 start = i;
1691 }
1692 if (d == ',') state = .bg else break;
1693 },
1694 }
1695 },
1696 .bg => {
1697 switch (d) {
1698 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => {
1699 const bg = bg_idx orelse 0;
1700 if (i - start == 2) {
1701 style.bg = irc.toVaxisColor(bg);
1702 start = i;
1703 break;
1704 } else {
1705 bg_idx = bg * 10 + (d - '0');
1706 }
1707 },
1708 else => {
1709 if (bg_idx) |bg| {
1710 style.bg = irc.toVaxisColor(bg);
1711 start = i;
1712 }
1713 break;
1714 },
1715 }
1716 },
1717 }
1718 }
1719 },
1720 0x0F => {
1721 try self.content_segments.append(.{
1722 .text = content[start..i],
1723 .style = style,
1724 });
1725 style = .{};
1726 start = i + 1;
1727 },
1728 0x16 => {
1729 try self.content_segments.append(.{
1730 .text = content[start..i],
1731 .style = style,
1732 });
1733 style.reverse = !style.reverse;
1734 start = i + 1;
1735 },
1736 0x1D => {
1737 try self.content_segments.append(.{
1738 .text = content[start..i],
1739 .style = style,
1740 });
1741 style.italic = !style.italic;
1742 start = i + 1;
1743 },
1744 0x1E => {
1745 try self.content_segments.append(.{
1746 .text = content[start..i],
1747 .style = style,
1748 });
1749 style.strikethrough = !style.strikethrough;
1750 start = i + 1;
1751 },
1752 0x1F => {
1753 try self.content_segments.append(.{
1754 .text = content[start..i],
1755 .style = style,
1756 });
1757
1758 style.ul_style = if (style.ul_style == .off) .single else .off;
1759 start = i + 1;
1760 },
1761 else => {
1762 if (b == 'h') {
1763 var state: LinkState = .h;
1764 const h_start = i;
1765 // consume until a space or EOF
1766 i += 1;
1767 while (i < content.len) : (i += 1) {
1768 const b1 = content[i];
1769 switch (state) {
1770 .h => {
1771 if (b1 == 't') state = .t1 else break;
1772 },
1773 .t1 => {
1774 if (b1 == 't') state = .t2 else break;
1775 },
1776 .t2 => {
1777 if (b1 == 'p') state = .p else break;
1778 },
1779 .p => {
1780 if (b1 == 's')
1781 state = .s
1782 else if (b1 == ':')
1783 state = .colon
1784 else
1785 break;
1786 },
1787 .s => {
1788 if (b1 == ':') state = .colon else break;
1789 },
1790 .colon => {
1791 if (b1 == '/') state = .slash else break;
1792 },
1793 .slash => {
1794 if (b1 == '/') {
1795 state = .consume;
1796 try self.content_segments.append(.{
1797 .text = content[start..h_start],
1798 .style = style,
1799 });
1800 start = h_start;
1801 } else break;
1802 },
1803 .consume => {
1804 switch (b1) {
1805 0x00...0x20, 0x7F => {
1806 try self.content_segments.append(.{
1807 .text = content[h_start..i],
1808 .style = .{
1809 .fg = .{ .index = 4 },
1810 },
1811 .link = .{
1812 .uri = content[h_start..i],
1813 },
1814 });
1815 start = i;
1816 // backup one
1817 i -= 1;
1818 break;
1819 },
1820 else => {
1821 if (i == content.len) {
1822 try self.content_segments.append(.{
1823 .text = content[h_start..],
1824 .style = .{
1825 .fg = .{ .index = 4 },
1826 },
1827 .link = .{
1828 .uri = content[h_start..],
1829 },
1830 });
1831 return;
1832 }
1833 },
1834 }
1835 },
1836 }
1837 }
1838 }
1839 },
1840 }
1841 }
1842 if (start < i and start < content.len) {
1843 try self.content_segments.append(.{
1844 .text = content[start..],
1845 .style = style,
1846 });
1847 }
1848 }
1849};
1850
1851/// this loop is run in a separate thread and handles writes to all clients.
1852/// Message content is deallocated when the write request is completed
1853fn writeLoop(queue: *comlink.WriteQueue) !void {
1854 log.debug("starting write thread", .{});
1855 while (true) {
1856 const req = queue.pop();
1857 switch (req) {
1858 .write => |w| {
1859 try w.client.write(w.msg);
1860 w.allocator.free(w.msg);
1861 },
1862 .join => {
1863 while (queue.tryPop()) |r| {
1864 switch (r) {
1865 .write => |w| w.allocator.free(w.msg),
1866 else => {},
1867 }
1868 }
1869 return;
1870 },
1871 }
1872 }
1873}
1874
1875/// Returns the number of lines the segments would consume in the given window
1876fn lineCountForWindow(win: vaxis.Window, segments: []const vaxis.Segment) usize {
1877 // Fastpath if we have fewer bytes than the width
1878 var byte_count: usize = 0;
1879 for (segments) |segment| {
1880 byte_count += segment.text.len;
1881 }
1882 // One line if we are fewer bytes than the width
1883 if (byte_count <= win.width) return 1;
1884
1885 // Slow path. We have to layout the text
1886 const result = win.print(segments, .{ .commit = false, .wrap = .word }) catch return 0;
1887 if (result.col == 0)
1888 return result.row
1889 else
1890 return result.row + 1;
1891}