an experimental irc client
at main 7.4 kB view raw
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};