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