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