tangled
alpha
login
or
join now
rockorager.dev
/
comlink
2
fork
atom
an experimental irc client
2
fork
atom
overview
issues
pulls
pipelines
ui: add /list command
rockorager.dev
1 year ago
50dbba8e
b1d6aeeb
verified
This commit was signed with the committer's
known signature
.
rockorager.dev
SSH Key Fingerprint:
SHA256:qn/Fjy7CpbcogGEPB14Y53hLnQleZNFY9lkQnuudFLs=
+346
-3
4 changed files
expand all
collapse all
unified
split
build.zig.zon
src
app.zig
comlink.zig
irc.zig
+2
-2
build.zig.zon
···
4
.version = "0.0.0",
5
.dependencies = .{
6
.vaxis = .{
7
-
.url = "git+https://github.com/rockorager/libvaxis#6b5a011f58eb7201a18d894383b96eba4b9101f9",
8
-
.hash = "vaxis-0.1.0-BWNV_NzyCADDrd6hjXVEXAZSWA3FUhwoxnE4Pk0SbeRz",
9
},
10
.zeit = .{
11
.url = "git+https://github.com/rockorager/zeit#44bebf856693332b168d8ba2c45b9d1ec15511de",
···
4
.version = "0.0.0",
5
.dependencies = .{
6
.vaxis = .{
7
+
.url = "git+https://github.com/rockorager/libvaxis#6a37605dde55898dcca4769dd3eb1e333959c209",
8
+
.hash = "vaxis-0.1.0-BWNV_K3yCACrTy3A5cbZElLyICx5a2O2EzPxmgVRcbKJ",
9
},
10
.zeit = .{
11
.url = "git+https://github.com/rockorager/zeit#44bebf856693332b168d8ba2c45b9d1ec15511de",
+30
-1
src/app.zig
···
354
};
355
try children.append(sub);
356
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
357
return .{
358
.size = ctx.max.size(),
359
.widget = self.widget(),
···
518
},
519
.join => {
520
const start = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
0
0
0
0
0
0
0
521
const msg = try std.fmt.bufPrint(
522
&buf,
523
"JOIN {s}\r\n",
524
.{
525
-
cmd[start + 1 ..],
526
},
527
);
0
0
528
// Ensure buffer exists
529
self.explicit_join = true;
530
return client.queueWrite(msg);
0
0
0
0
531
},
532
.me => {
533
if (channel == null) return error.InvalidCommand;
···
354
};
355
try children.append(sub);
356
357
+
for (self.clients.items) |client| {
358
+
if (client.list_modal.is_shown) {
359
+
const padding: u16 = 8;
360
+
const modal_ctx = ctx.withConstraints(ctx.min, .{
361
+
.width = max.width -| padding * 2,
362
+
.height = max.height -| padding,
363
+
});
364
+
const border: vxfw.Border = .{ .child = client.list_modal.widget() };
365
+
try children.append(.{
366
+
.origin = .{ .row = padding / 2, .col = padding },
367
+
.surface = try border.draw(modal_ctx),
368
+
});
369
+
break;
370
+
}
371
+
}
372
+
373
return .{
374
.size = ctx.max.size(),
375
.widget = self.widget(),
···
534
},
535
.join => {
536
const start = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
537
+
const chan_name = cmd[start + 1 ..];
538
+
for (client.channels.items) |chan| {
539
+
if (std.mem.eql(u8, chan.name, chan_name)) {
540
+
client.app.selectBuffer(.{ .channel = chan });
541
+
return;
542
+
}
543
+
}
544
const msg = try std.fmt.bufPrint(
545
&buf,
546
"JOIN {s}\r\n",
547
.{
548
+
chan_name,
549
},
550
);
551
+
552
+
// Check
553
// Ensure buffer exists
554
self.explicit_join = true;
555
return client.queueWrite(msg);
556
+
},
557
+
.list => {
558
+
client.list_modal.expecting_response = true;
559
+
return client.queueWrite("LIST\r\n");
560
},
561
.me => {
562
if (channel == null) return error.InvalidCommand;
+2
src/comlink.zig
···
66
/// a raw irc command. Sent verbatim
67
quote,
68
join,
0
69
me,
70
msg,
71
query,
···
86
const map = std.StaticStringMap(Command).initComptime(.{
87
.{ "quote", .quote },
88
.{ "join", .join },
0
89
.{ "me", .me },
90
.{ "msg", .msg },
91
.{ "query", .query },
···
66
/// a raw irc command. Sent verbatim
67
quote,
68
join,
69
+
list,
70
me,
71
msg,
72
query,
···
87
const map = std.StaticStringMap(Command).initComptime(.{
88
.{ "quote", .quote },
89
.{ "join", .join },
90
+
.{ "list", .list },
91
.{ "me", .me },
92
.{ "msg", .msg },
93
.{ "query", .query },
+312
src/irc.zig
···
45
RPL_MYINFO, // 004
46
RPL_ISUPPORT, // 005
47
0
0
48
RPL_ENDOFWHO, // 315
0
0
0
49
RPL_TOPIC, // 332
50
RPL_WHOREPLY, // 352
51
RPL_NAMREPLY, // 353
···
78
.{ "004", .RPL_MYINFO },
79
.{ "005", .RPL_ISUPPORT },
80
0
0
81
.{ "315", .RPL_ENDOFWHO },
0
0
0
82
.{ "332", .RPL_TOPIC },
83
.{ "352", .RPL_WHOREPLY },
84
.{ "353", .RPL_NAMREPLY },
···
1659
text_field: vxfw.TextField,
1660
completer_shown: bool,
1661
0
0
1662
pub fn init(
1663
self: *Client,
1664
alloc: std.mem.Allocator,
···
1684
.retry_delay_s = 0,
1685
.text_field = .init(alloc, app.unicode),
1686
.completer_shown = false,
0
1687
};
0
1688
self.text_field.style = .{ .bg = self.app.blendBg(10) };
1689
self.text_field.userdata = self;
1690
self.text_field.onSubmit = Client.onSubmit;
···
1733
self.alloc.destroy(channel);
1734
}
1735
self.channels.deinit();
0
0
1736
1737
var user_iter = self.users.valueIterator();
1738
while (user_iter.next()) |user| {
···
2090
}
2091
channel.topic = try self.alloc.dupe(u8, topic);
2092
},
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
2093
.RPL_SASLSUCCESS => {},
2094
.RPL_WHOREPLY => {
2095
// syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name>
···
3105
pub const ChatHistoryCommand = enum {
3106
before,
3107
after,
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
3108
};
3109
3110
test "caseFold" {
···
45
RPL_MYINFO, // 004
46
RPL_ISUPPORT, // 005
47
48
+
RPL_TRYAGAIN, // 263
49
+
50
RPL_ENDOFWHO, // 315
51
+
RPL_LISTSTART, // 321
52
+
RPL_LIST, // 322
53
+
RPL_LISTEND, // 323
54
RPL_TOPIC, // 332
55
RPL_WHOREPLY, // 352
56
RPL_NAMREPLY, // 353
···
83
.{ "004", .RPL_MYINFO },
84
.{ "005", .RPL_ISUPPORT },
85
86
+
.{ "263", .RPL_TRYAGAIN },
87
+
88
.{ "315", .RPL_ENDOFWHO },
89
+
.{ "321", .RPL_LISTSTART },
90
+
.{ "322", .RPL_LIST },
91
+
.{ "323", .RPL_LISTEND },
92
.{ "332", .RPL_TOPIC },
93
.{ "352", .RPL_WHOREPLY },
94
.{ "353", .RPL_NAMREPLY },
···
1669
text_field: vxfw.TextField,
1670
completer_shown: bool,
1671
1672
+
list_modal: ListModal,
1673
+
1674
pub fn init(
1675
self: *Client,
1676
alloc: std.mem.Allocator,
···
1696
.retry_delay_s = 0,
1697
.text_field = .init(alloc, app.unicode),
1698
.completer_shown = false,
1699
+
.list_modal = undefined,
1700
};
1701
+
self.list_modal.init(alloc, self);
1702
self.text_field.style = .{ .bg = self.app.blendBg(10) };
1703
self.text_field.userdata = self;
1704
self.text_field.onSubmit = Client.onSubmit;
···
1747
self.alloc.destroy(channel);
1748
}
1749
self.channels.deinit();
1750
+
1751
+
self.list_modal.deinit(self.alloc);
1752
1753
var user_iter = self.users.valueIterator();
1754
while (user_iter.next()) |user| {
···
2106
}
2107
channel.topic = try self.alloc.dupe(u8, topic);
2108
},
2109
+
.RPL_TRYAGAIN => {
2110
+
if (self.list_modal.expecting_response) {
2111
+
self.list_modal.expecting_response = false;
2112
+
try self.list_modal.finish(ctx);
2113
+
}
2114
+
},
2115
+
.RPL_LISTSTART => try self.list_modal.reset(),
2116
+
.RPL_LIST => {
2117
+
// We might not always get a RPL_LISTSTART, so we check if we have a list already
2118
+
// and if it needs reseting
2119
+
if (self.list_modal.finished) {
2120
+
try self.list_modal.reset();
2121
+
}
2122
+
self.list_modal.expecting_response = false;
2123
+
try self.list_modal.addMessage(self.alloc, msg);
2124
+
},
2125
+
.RPL_LISTEND => try self.list_modal.finish(ctx),
2126
.RPL_SASLSUCCESS => {},
2127
.RPL_WHOREPLY => {
2128
// syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name>
···
3138
pub const ChatHistoryCommand = enum {
3139
before,
3140
after,
3141
+
};
3142
+
3143
+
pub const ListModal = struct {
3144
+
client: *Client,
3145
+
/// the individual items we received
3146
+
items: std.ArrayListUnmanaged(Item),
3147
+
/// the list view
3148
+
list_view: vxfw.ListView,
3149
+
text_field: vxfw.TextField,
3150
+
3151
+
filtered_items: std.ArrayList(Item),
3152
+
3153
+
finished: bool,
3154
+
is_shown: bool,
3155
+
expecting_response: bool,
3156
+
3157
+
focus: enum { text_field, list },
3158
+
3159
+
const name_width = 24;
3160
+
const count_width = 8;
3161
+
3162
+
// Item is a single RPL_LIST response
3163
+
const Item = struct {
3164
+
name: []const u8,
3165
+
topic: []const u8,
3166
+
count_str: []const u8,
3167
+
count: u32,
3168
+
3169
+
fn deinit(self: Item, alloc: Allocator) void {
3170
+
alloc.free(self.name);
3171
+
alloc.free(self.topic);
3172
+
alloc.free(self.count_str);
3173
+
}
3174
+
3175
+
fn widget(self: *Item) vxfw.Widget {
3176
+
return .{
3177
+
.userdata = self,
3178
+
.drawFn = Item.draw,
3179
+
};
3180
+
}
3181
+
3182
+
fn lessThan(_: void, lhs: Item, rhs: Item) bool {
3183
+
return lhs.count > rhs.count;
3184
+
}
3185
+
3186
+
fn draw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
3187
+
const self: *Item = @ptrCast(@alignCast(ptr));
3188
+
3189
+
var children: std.ArrayListUnmanaged(vxfw.SubSurface) = try .initCapacity(ctx.arena, 3);
3190
+
3191
+
const name_ctx = ctx.withConstraints(.{ .width = name_width, .height = 1 }, ctx.max);
3192
+
const count_ctx = ctx.withConstraints(.{ .width = count_width, .height = 1 }, ctx.max);
3193
+
const topic_ctx = ctx.withConstraints(.{
3194
+
.width = ctx.max.width.? -| name_width -| count_width - 2,
3195
+
.height = 1,
3196
+
}, ctx.max);
3197
+
3198
+
const name: vxfw.Text = .{ .text = self.name, .softwrap = false };
3199
+
const count: vxfw.Text = .{ .text = self.count_str, .softwrap = false, .text_align = .right };
3200
+
const spans = try formatMessage(ctx.arena, undefined, self.topic);
3201
+
const topic: vxfw.RichText = .{ .text = spans, .softwrap = false };
3202
+
3203
+
children.appendAssumeCapacity(.{
3204
+
.origin = .{ .col = 0, .row = 0 },
3205
+
.surface = try name.draw(name_ctx),
3206
+
});
3207
+
children.appendAssumeCapacity(.{
3208
+
.origin = .{ .col = name_width, .row = 0 },
3209
+
.surface = try topic.draw(topic_ctx),
3210
+
});
3211
+
children.appendAssumeCapacity(.{
3212
+
.origin = .{ .col = ctx.max.width.? -| count_width, .row = 0 },
3213
+
.surface = try count.draw(count_ctx),
3214
+
});
3215
+
3216
+
return .{
3217
+
.size = .{ .width = ctx.max.width.?, .height = 1 },
3218
+
.widget = self.widget(),
3219
+
.buffer = &.{},
3220
+
.children = children.items,
3221
+
};
3222
+
}
3223
+
};
3224
+
3225
+
fn init(self: *ListModal, gpa: Allocator, client: *Client) void {
3226
+
self.* = .{
3227
+
.client = client,
3228
+
.filtered_items = std.ArrayList(Item).init(gpa),
3229
+
.items = .empty,
3230
+
.list_view = .{
3231
+
.children = .{
3232
+
.builder = .{
3233
+
.userdata = self,
3234
+
.buildFn = ListModal.getItem,
3235
+
},
3236
+
},
3237
+
},
3238
+
.text_field = .init(gpa, client.app.unicode),
3239
+
.finished = true,
3240
+
.is_shown = false,
3241
+
.focus = .text_field,
3242
+
.expecting_response = false,
3243
+
};
3244
+
self.text_field.style.bg = client.app.blendBg(10);
3245
+
self.text_field.userdata = self;
3246
+
self.text_field.onChange = ListModal.onChange;
3247
+
}
3248
+
3249
+
fn reset(self: *ListModal) !void {
3250
+
self.items.clearRetainingCapacity();
3251
+
self.filtered_items.clearAndFree();
3252
+
self.text_field.clearAndFree();
3253
+
self.finished = false;
3254
+
self.focus = .text_field;
3255
+
self.is_shown = false;
3256
+
}
3257
+
3258
+
fn show(self: *ListModal, ctx: *vxfw.EventContext) !void {
3259
+
self.is_shown = true;
3260
+
switch (self.focus) {
3261
+
.text_field => try ctx.requestFocus(self.text_field.widget()),
3262
+
.list => try ctx.requestFocus(self.list_view.widget()),
3263
+
}
3264
+
return ctx.consumeAndRedraw();
3265
+
}
3266
+
3267
+
pub fn widget(self: *ListModal) vxfw.Widget {
3268
+
return .{
3269
+
.userdata = self,
3270
+
.captureHandler = ListModal.captureHandler,
3271
+
.drawFn = ListModal._draw,
3272
+
};
3273
+
}
3274
+
3275
+
fn deinit(self: *ListModal, alloc: std.mem.Allocator) void {
3276
+
for (self.items.items) |item| {
3277
+
item.deinit(alloc);
3278
+
}
3279
+
self.items.deinit(alloc);
3280
+
self.filtered_items.deinit();
3281
+
self.text_field.deinit();
3282
+
self.* = undefined;
3283
+
}
3284
+
3285
+
fn addMessage(self: *ListModal, alloc: Allocator, msg: Message) !void {
3286
+
var iter = msg.paramIterator();
3287
+
// client, we skip this one
3288
+
_ = iter.next() orelse return;
3289
+
const channel = iter.next() orelse {
3290
+
log.warn("got RPL_LIST without channel", .{});
3291
+
return;
3292
+
};
3293
+
const count = iter.next() orelse {
3294
+
log.warn("got RPL_LIST without count", .{});
3295
+
return;
3296
+
};
3297
+
const topic = iter.next() orelse {
3298
+
log.warn("got RPL_LIST without topic", .{});
3299
+
return;
3300
+
};
3301
+
const item: Item = .{
3302
+
.name = try alloc.dupe(u8, channel),
3303
+
.count_str = try alloc.dupe(u8, count),
3304
+
.topic = try alloc.dupe(u8, topic),
3305
+
.count = try std.fmt.parseUnsigned(u32, count, 10),
3306
+
};
3307
+
try self.items.append(alloc, item);
3308
+
}
3309
+
3310
+
fn finish(self: *ListModal, ctx: *vxfw.EventContext) !void {
3311
+
self.finished = true;
3312
+
self.is_shown = true;
3313
+
std.mem.sort(Item, self.items.items, {}, Item.lessThan);
3314
+
self.filtered_items.clearRetainingCapacity();
3315
+
try self.filtered_items.appendSlice(self.items.items);
3316
+
try ctx.requestFocus(self.text_field.widget());
3317
+
}
3318
+
3319
+
fn onChange(ptr: ?*anyopaque, ctx: *vxfw.EventContext, input: []const u8) anyerror!void {
3320
+
const self: *ListModal = @ptrCast(@alignCast(ptr orelse unreachable));
3321
+
self.filtered_items.clearRetainingCapacity();
3322
+
for (self.items.items) |item| {
3323
+
if (std.mem.indexOf(u8, item.name, input)) |_| {
3324
+
try self.filtered_items.append(item);
3325
+
} else if (std.mem.indexOf(u8, item.topic, input)) |_| {
3326
+
try self.filtered_items.append(item);
3327
+
}
3328
+
}
3329
+
return ctx.consumeAndRedraw();
3330
+
}
3331
+
3332
+
fn captureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
3333
+
const self: *ListModal = @ptrCast(@alignCast(ptr));
3334
+
switch (event) {
3335
+
.key_press => |key| {
3336
+
switch (self.focus) {
3337
+
.text_field => {
3338
+
if (key.matches(vaxis.Key.enter, .{})) {
3339
+
try ctx.requestFocus(self.list_view.widget());
3340
+
self.focus = .list;
3341
+
return ctx.consumeAndRedraw();
3342
+
} else if (key.matches(vaxis.Key.escape, .{})) {
3343
+
self.close(ctx);
3344
+
return;
3345
+
} else if (key.matches(vaxis.Key.up, .{})) {
3346
+
self.list_view.prevItem(ctx);
3347
+
return ctx.consumeAndRedraw();
3348
+
} else if (key.matches(vaxis.Key.down, .{})) {
3349
+
self.list_view.nextItem(ctx);
3350
+
return ctx.consumeAndRedraw();
3351
+
}
3352
+
},
3353
+
.list => {
3354
+
if (key.matches(vaxis.Key.escape, .{})) {
3355
+
try ctx.requestFocus(self.text_field.widget());
3356
+
self.focus = .text_field;
3357
+
return ctx.consumeAndRedraw();
3358
+
} else if (key.matches(vaxis.Key.enter, .{})) {
3359
+
if (self.filtered_items.items.len > 0) {
3360
+
// join the selected room, and deinit the view
3361
+
var buf: [128]u8 = undefined;
3362
+
const item = self.filtered_items.items[self.list_view.cursor];
3363
+
const cmd = try std.fmt.bufPrint(&buf, "/join {s}", .{item.name});
3364
+
try self.client.app.handleCommand(.{ .client = self.client }, cmd);
3365
+
}
3366
+
self.close(ctx);
3367
+
return;
3368
+
}
3369
+
},
3370
+
}
3371
+
},
3372
+
else => {},
3373
+
}
3374
+
}
3375
+
3376
+
fn close(self: *ListModal, ctx: *vxfw.EventContext) void {
3377
+
self.is_shown = false;
3378
+
const selected = self.client.app.selectedBuffer() orelse unreachable;
3379
+
self.client.app.selectBuffer(selected);
3380
+
return ctx.consumeAndRedraw();
3381
+
}
3382
+
3383
+
fn getItem(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
3384
+
const self: *const ListModal = @ptrCast(@alignCast(ptr));
3385
+
if (idx < self.filtered_items.items.len) {
3386
+
return self.filtered_items.items[idx].widget();
3387
+
}
3388
+
return null;
3389
+
}
3390
+
3391
+
fn _draw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
3392
+
const self: *ListModal = @ptrCast(@alignCast(ptr));
3393
+
return self.draw(ctx);
3394
+
}
3395
+
3396
+
fn draw(self: *ListModal, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
3397
+
const max = ctx.max.size();
3398
+
var children: std.ArrayListUnmanaged(vxfw.SubSurface) = .empty;
3399
+
3400
+
try children.append(ctx.arena, .{
3401
+
.origin = .{ .col = 0, .row = 0 },
3402
+
.surface = try self.text_field.draw(ctx),
3403
+
});
3404
+
const list_ctx = ctx.withConstraints(
3405
+
ctx.min,
3406
+
.{ .width = max.width, .height = max.height - 2 },
3407
+
);
3408
+
try children.append(ctx.arena, .{
3409
+
.origin = .{ .col = 0, .row = 2 },
3410
+
.surface = try self.list_view.draw(list_ctx),
3411
+
});
3412
+
3413
+
return .{
3414
+
.size = max,
3415
+
.widget = self.widget(),
3416
+
.buffer = &.{},
3417
+
.children = children.items,
3418
+
};
3419
+
}
3420
};
3421
3422
test "caseFold" {