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 Command = comlink.Command;
8
9const Kind = enum {
10 command,
11 emoji,
12 nick,
13};
14
15pub const Completer = struct {
16 word: []const u8,
17 start_idx: usize,
18 options: std.ArrayList([]const u8),
19 selected_idx: ?usize,
20 widest: ?usize,
21 buf: [irc.maximum_message_size]u8 = undefined,
22 kind: Kind = .nick,
23
24 pub fn init(alloc: std.mem.Allocator, line: []const u8) !Completer {
25 const start_idx = if (std.mem.lastIndexOfScalar(u8, line, ' ')) |idx| idx + 1 else 0;
26 const last_word = line[start_idx..];
27 var completer: Completer = .{
28 .options = std.ArrayList([]const u8).init(alloc),
29 .start_idx = start_idx,
30 .word = last_word,
31 .selected_idx = null,
32 .widest = null,
33 };
34 @memcpy(completer.buf[0..line.len], line);
35 if (last_word.len > 0 and last_word[0] == '/') {
36 completer.kind = .command;
37 try completer.findCommandMatches();
38 }
39 if (last_word.len > 0 and last_word[0] == ':') {
40 completer.kind = .emoji;
41 try completer.findEmojiMatches();
42 }
43 return completer;
44 }
45
46 pub fn deinit(self: *Completer) void {
47 self.options.deinit();
48 }
49
50 /// cycles to the next option, returns the replacement text. Note that we
51 /// start from the bottom, so a selected_idx = 0 means we are on _the last_
52 /// item
53 pub fn next(self: *Completer) []const u8 {
54 if (self.options.items.len == 0) return "";
55 {
56 const last_idx = self.options.items.len - 1;
57 if (self.selected_idx == null or self.selected_idx.? == last_idx)
58 self.selected_idx = 0
59 else
60 self.selected_idx.? +|= 1;
61 }
62 return self.replacementText();
63 }
64
65 pub fn prev(self: *Completer) []const u8 {
66 if (self.options.items.len == 0) return "";
67 {
68 const last_idx = self.options.items.len - 1;
69 if (self.selected_idx == null or self.selected_idx.? == 0)
70 self.selected_idx = last_idx
71 else
72 self.selected_idx.? -|= 1;
73 }
74 return self.replacementText();
75 }
76
77 pub fn replacementText(self: *Completer) []const u8 {
78 if (self.selected_idx == null or self.options.items.len == 0) return "";
79 const replacement = self.options.items[self.options.items.len - 1 - self.selected_idx.?];
80 switch (self.kind) {
81 .command => {
82 self.buf[0] = '/';
83 @memcpy(self.buf[1 .. 1 + replacement.len], replacement);
84 const append_space = if (Command.fromString(replacement)) |cmd|
85 cmd.appendSpace()
86 else
87 true;
88 if (append_space) self.buf[1 + replacement.len] = ' ';
89 return self.buf[0 .. 1 + replacement.len + @as(u1, if (append_space) 1 else 0)];
90 },
91 .emoji => {
92 const start = self.start_idx;
93 @memcpy(self.buf[start .. start + replacement.len], replacement);
94 return self.buf[0 .. start + replacement.len];
95 },
96 .nick => {
97 const start = self.start_idx;
98 @memcpy(self.buf[start .. start + replacement.len], replacement);
99 if (self.start_idx == 0) {
100 @memcpy(self.buf[start + replacement.len .. start + replacement.len + 2], ": ");
101 return self.buf[0 .. start + replacement.len + 2];
102 } else {
103 @memcpy(self.buf[start + replacement.len .. start + replacement.len + 1], " ");
104 return self.buf[0 .. start + replacement.len + 1];
105 }
106 },
107 }
108 }
109
110 pub fn findMatches(self: *Completer, chan: *irc.Channel) !void {
111 if (self.options.items.len > 0) return;
112 const alloc = self.options.allocator;
113 var members = std.ArrayList(irc.Channel.Member).init(alloc);
114 defer members.deinit();
115 for (chan.members.items) |member| {
116 if (std.ascii.startsWithIgnoreCase(member.user.nick, self.word)) {
117 try members.append(member);
118 }
119 }
120 std.sort.insertion(irc.Channel.Member, members.items, chan, irc.Channel.compareRecentMessages);
121 self.options = try std.ArrayList([]const u8).initCapacity(alloc, members.items.len);
122 for (members.items) |member| {
123 try self.options.append(member.user.nick);
124 }
125 }
126
127 pub fn findCommandMatches(self: *Completer) !void {
128 if (self.options.items.len > 0) return;
129 const commands = std.meta.fieldNames(Command);
130 for (commands) |cmd| {
131 if (std.mem.eql(u8, cmd, "lua_function")) continue;
132 if (std.ascii.startsWithIgnoreCase(cmd, self.word[1..])) {
133 try self.options.append(cmd);
134 }
135 }
136 var iter = Command.user_commands.keyIterator();
137 while (iter.next()) |cmd| {
138 if (std.ascii.startsWithIgnoreCase(cmd.*, self.word[1..])) {
139 try self.options.append(cmd.*);
140 }
141 }
142 }
143
144 pub fn findEmojiMatches(self: *Completer) !void {
145 if (self.options.items.len > 0) return;
146 const keys = emoji.map.keys();
147 const values = emoji.map.values();
148
149 for (keys, values) |shortcode, glyph| {
150 if (std.mem.indexOf(u8, shortcode, self.word[1..])) |_|
151 try self.options.append(glyph);
152 }
153 }
154
155 pub fn widestMatch(self: *Completer, win: vaxis.Window) usize {
156 if (self.widest) |w| return w;
157 var widest: usize = 0;
158 for (self.options.items) |opt| {
159 const width = win.gwidth(opt);
160 if (width > widest) widest = width;
161 }
162 self.widest = widest;
163 return widest;
164 }
165
166 pub fn numMatches(self: *Completer) usize {
167 return self.options.items.len;
168 }
169};