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