an experimental irc client
1const std = @import("std");
2const comlink = @import("comlink.zig");
3const vaxis = @import("vaxis");
4const emoji = @import("emoji.zig");
5
6const irc = comlink.irc;
7const vxfw = vaxis.vxfw;
8const Command = comlink.Command;
9
10const Kind = enum {
11 command,
12 emoji,
13 nick,
14};
15
16pub const Completer = struct {
17 const style: vaxis.Style = .{ .bg = .{ .index = 8 } };
18 const selected: vaxis.Style = .{ .bg = .{ .index = 8 }, .reverse = true };
19
20 word: []const u8,
21 start_idx: usize,
22 options: std.ArrayList(vxfw.Text),
23 widest: ?usize,
24 buf: [irc.maximum_message_size]u8 = undefined,
25 kind: Kind = .nick,
26 list_view: vxfw.ListView,
27 has_selection: bool,
28
29 pub fn init(gpa: std.mem.Allocator) Completer {
30 return .{
31 .options = std.ArrayList(vxfw.Text).init(gpa),
32 .start_idx = 0,
33 .word = "",
34 .widest = null,
35 .list_view = undefined,
36 .has_selection = false,
37 };
38 }
39
40 fn getWidget(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
41 const self: *const Completer = @ptrCast(@alignCast(ptr));
42 if (idx < self.options.items.len) {
43 const item = &self.options.items[idx];
44 return item.widget();
45 }
46 return null;
47 }
48
49 pub fn reset(self: *Completer, line: []const u8) !void {
50 self.list_view = .{
51 .children = .{ .builder = .{
52 .userdata = self,
53 .buildFn = Completer.getWidget,
54 } },
55 };
56 self.start_idx = if (std.mem.lastIndexOfScalar(u8, line, ' ')) |idx| idx + 1 else 0;
57 self.word = line[self.start_idx..];
58 @memcpy(self.buf[0..line.len], line);
59 self.options.clearAndFree();
60 self.widest = null;
61 self.kind = .nick;
62 self.has_selection = false;
63
64 if (self.word.len > 0 and self.word[0] == '/') {
65 self.kind = .command;
66 try self.findCommandMatches();
67 }
68 if (self.word.len > 0 and self.word[0] == ':') {
69 self.kind = .emoji;
70 try self.findEmojiMatches();
71 }
72 }
73
74 pub fn deinit(self: *Completer) void {
75 self.options.deinit();
76 }
77
78 /// cycles to the next option, returns the replacement text. Note that we
79 /// start from the bottom, so a selected_idx = 0 means we are on _the last_
80 /// item
81 pub fn next(self: *Completer, ctx: *vxfw.EventContext) []const u8 {
82 if (self.options.items.len == 0) return "";
83 if (self.has_selection) {
84 self.list_view.prevItem(ctx);
85 }
86 self.has_selection = true;
87 return self.replacementText();
88 }
89
90 pub fn prev(self: *Completer, ctx: *vxfw.EventContext) []const u8 {
91 if (self.options.items.len == 0) return "";
92 self.list_view.nextItem(ctx);
93 self.has_selection = true;
94 return self.replacementText();
95 }
96
97 pub fn replacementText(self: *Completer) []const u8 {
98 if (self.options.items.len == 0) return "";
99 const replacement_widget = self.options.items[self.list_view.cursor];
100 const replacement = replacement_widget.text;
101 switch (self.kind) {
102 .command => {
103 self.buf[0] = '/';
104 @memcpy(self.buf[1 .. 1 + replacement.len], replacement);
105 const append_space = if (Command.fromString(replacement)) |cmd|
106 cmd.appendSpace()
107 else
108 true;
109 if (append_space) self.buf[1 + replacement.len] = ' ';
110 return self.buf[0 .. 1 + replacement.len + @as(u1, if (append_space) 1 else 0)];
111 },
112 .emoji => {
113 const start = self.start_idx;
114 @memcpy(self.buf[start .. start + replacement.len], replacement);
115 return self.buf[0 .. start + replacement.len];
116 },
117 .nick => {
118 const start = self.start_idx;
119 @memcpy(self.buf[start .. start + replacement.len], replacement);
120 if (self.start_idx == 0) {
121 @memcpy(self.buf[start + replacement.len .. start + replacement.len + 2], ": ");
122 return self.buf[0 .. start + replacement.len + 2];
123 } else {
124 @memcpy(self.buf[start + replacement.len .. start + replacement.len + 1], " ");
125 return self.buf[0 .. start + replacement.len + 1];
126 }
127 },
128 }
129 }
130
131 pub fn findMatches(self: *Completer, chan: *irc.Channel) !void {
132 if (self.options.items.len > 0) return;
133 const alloc = self.options.allocator;
134 var members = std.ArrayList(irc.Channel.Member).init(alloc);
135 defer members.deinit();
136 for (chan.members.items) |member| {
137 if (std.ascii.startsWithIgnoreCase(member.user.nick, self.word)) {
138 try members.append(member);
139 }
140 }
141 std.sort.insertion(irc.Channel.Member, members.items, chan, irc.Channel.compareRecentMessages);
142 try self.options.ensureTotalCapacity(members.items.len);
143 for (members.items) |member| {
144 try self.options.append(.{ .text = member.user.nick });
145 }
146 self.list_view.cursor = @intCast(self.options.items.len -| 1);
147 self.list_view.item_count = @intCast(self.options.items.len);
148 self.list_view.ensureScroll();
149 }
150
151 pub fn findCommandMatches(self: *Completer) !void {
152 if (self.options.items.len > 0) return;
153 const commands = std.meta.fieldNames(Command);
154 for (commands) |cmd| {
155 if (std.mem.eql(u8, cmd, "lua_function")) continue;
156 if (std.ascii.startsWithIgnoreCase(cmd, self.word[1..])) {
157 try self.options.append(.{ .text = cmd, .softwrap = false });
158 }
159 }
160 var iter = Command.user_commands.keyIterator();
161 while (iter.next()) |cmd| {
162 if (std.ascii.startsWithIgnoreCase(cmd.*, self.word[1..])) {
163 try self.options.append(.{ .text = cmd.*, .softwrap = false });
164 }
165 }
166 self.list_view.cursor = @intCast(self.options.items.len -| 1);
167 self.list_view.item_count = @intCast(self.options.items.len);
168 self.list_view.ensureScroll();
169 }
170
171 pub fn findEmojiMatches(self: *Completer) !void {
172 if (self.options.items.len > 0) return;
173 const keys = emoji.map.keys();
174 const values = emoji.map.values();
175
176 for (keys, values) |shortcode, glyph| {
177 if (std.mem.indexOf(u8, shortcode, self.word[1..])) |_|
178 try self.options.append(.{ .text = glyph, .softwrap = false });
179 }
180 self.list_view.cursor = @intCast(self.options.items.len -| 1);
181 self.list_view.item_count = @intCast(self.options.items.len);
182 self.list_view.ensureScroll();
183 }
184
185 pub fn widestMatch(self: *Completer, ctx: vxfw.DrawContext) usize {
186 if (self.widest) |w| return w;
187 var widest: usize = 0;
188 for (self.options.items) |opt| {
189 const width = ctx.stringWidth(opt.text);
190 if (width > widest) widest = width;
191 }
192 self.widest = widest;
193 return widest;
194 }
195
196 pub fn numMatches(self: *Completer) usize {
197 return self.options.items.len;
198 }
199};