an experimental irc client

ui: implement member list clicking

+262 -715
+42 -658
src/app.zig
··· 27 27 const log = std.log.scoped(.app); 28 28 29 29 const State = struct { 30 - mouse: ?vaxis.Mouse = null, 31 - members: struct { 32 - scroll_offset: usize = 0, 33 - width: u16 = 16, 34 - resizing: bool = false, 35 - } = .{}, 36 - messages: struct { 37 - scroll_offset: usize = 0, 38 - pending_scroll: isize = 0, 39 - } = .{}, 40 30 buffers: struct { 41 - scroll_offset: usize = 0, 42 31 count: usize = 0, 43 - selected_idx: usize = 0, 44 32 width: u16 = 16, 45 - resizing: bool = false, 46 33 } = .{}, 47 34 paste: struct { 48 35 pasting: bool = false, ··· 88 75 unicode: *const vaxis.Unicode, 89 76 90 77 title_buf: [128]u8, 78 + 79 + // Only valid during an event handler 80 + ctx: ?*vxfw.EventContext, 81 + last_height: u16, 91 82 92 83 const default_rhs: vxfw.Text = .{ .text = "TODO: update this text" }; 93 84 ··· 125 116 }, 126 117 .unicode = unicode, 127 118 .title_buf = undefined, 119 + .ctx = null, 120 + .last_height = 0, 128 121 }; 129 122 130 123 self.lua = try Lua.init(&self.alloc); ··· 198 191 } 199 192 200 193 fn typeErasedCaptureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 201 - // const self: *App = @ptrCast(@alignCast(ptr)); 202 - _ = ptr; 194 + const self: *App = @ptrCast(@alignCast(ptr)); 195 + // Rewrite the ctx pointer every frame. We don't actually need to do this with the current 196 + // vxfw runtime, because the context pointer is always valid. But for safe keeping, we will 197 + // do it this way. 198 + // 199 + // In general, this is bad practice. But we need to be able to access this from lua 200 + // callbacks 201 + self.ctx = ctx; 203 202 switch (event) { 204 203 .key_press => |key| { 205 204 if (key.matches('c', .{ .ctrl = true })) { ··· 212 211 213 212 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 214 213 const self: *App = @ptrCast(@alignCast(ptr)); 214 + self.ctx = ctx; 215 215 switch (event) { 216 216 .init => { 217 217 const title = try std.fmt.bufPrint(&self.title_buf, "comlink", .{}); ··· 222 222 if (key.matches('c', .{ .ctrl = true })) { 223 223 ctx.quit = true; 224 224 } 225 + for (self.binds.items) |bind| { 226 + if (key.matches(bind.key.codepoint, bind.key.mods)) { 227 + switch (bind.command) { 228 + .quit => self.should_quit = true, 229 + .@"next-channel" => self.nextChannel(), 230 + .@"prev-channel" => self.prevChannel(), 231 + // .redraw => self.vx.queueRefresh(), 232 + .lua_function => |ref| try lua.execFn(self.lua, ref), 233 + else => {}, 234 + } 235 + return ctx.consumeAndRedraw(); 236 + } 237 + } 225 238 }, 226 239 .tick => { 227 240 for (self.clients.items) |client| { ··· 241 254 242 255 fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 243 256 const self: *App = @ptrCast(@alignCast(ptr)); 257 + const max = ctx.max.size(); 258 + self.last_height = max.height; 244 259 if (self.selectedBuffer()) |buffer| { 245 260 switch (buffer) { 246 261 .client => |client| self.view.rhs = client.view(), ··· 249 264 } else self.view.rhs = default_rhs.widget(); 250 265 251 266 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena); 252 - _ = &children; 253 267 254 268 // UI is a tree of splits 255 269 // │ │ │ │ ··· 318 332 return text.draw(ctx); 319 333 } 320 334 321 - // pub fn run(self: *App, lua_state: *Lua) !void { 322 - // const writer = self.tty.anyWriter(); 323 - // 324 - // var loop: comlink.EventLoop = .{ .vaxis = &self.vx, .tty = &self.tty }; 325 - // try loop.init(); 326 - // try loop.start(); 327 - // defer loop.stop(); 328 - // 329 - // try self.vx.enterAltScreen(writer); 330 - // try self.vx.queryTerminal(writer, 1 * std.time.ns_per_s); 331 - // try self.vx.setMouseMode(writer, true); 332 - // try self.vx.setBracketedPaste(writer, true); 333 - // 334 - // // start our write thread 335 - // var write_queue: comlink.WriteQueue = .{}; 336 - // const write_thread = try std.Thread.spawn(.{}, writeLoop, .{ self.alloc, &write_queue }); 337 - // defer { 338 - // write_queue.push(.join); 339 - // write_thread.join(); 340 - // } 341 - // 342 - // // initialize lua state 343 - // try lua.init(self, lua_state, &loop); 344 - // 345 - // var input = TextInput.init(self.alloc, &self.vx.unicode); 346 - // defer input.deinit(); 347 - // 348 - // var last_frame: i64 = std.time.milliTimestamp(); 349 - // loop: while (!self.should_quit) { 350 - // var redraw: bool = false; 351 - // std.time.sleep(8 * std.time.ns_per_ms); 352 - // if (self.state.messages.pending_scroll != 0) { 353 - // redraw = true; 354 - // if (self.state.messages.pending_scroll > 0) { 355 - // self.state.messages.pending_scroll -= 1; 356 - // self.state.messages.scroll_offset += 1; 357 - // } else { 358 - // self.state.messages.pending_scroll += 1; 359 - // self.state.messages.scroll_offset -|= 1; 360 - // } 361 - // } 362 - // while (loop.tryEvent()) |event| { 363 - // redraw = true; 364 - // switch (event) { 365 - // .redraw => {}, 366 - // .key_press => |key| { 367 - // if (self.state.paste.showDialog()) { 368 - // if (key.matches(vaxis.Key.escape, .{})) { 369 - // self.state.paste.has_newline = false; 370 - // self.paste_buffer.clearAndFree(); 371 - // } 372 - // break; 373 - // } 374 - // if (self.state.paste.pasting) { 375 - // if (key.matches(vaxis.Key.enter, .{})) { 376 - // self.state.paste.has_newline = true; 377 - // try self.paste_buffer.append('\n'); 378 - // continue :loop; 379 - // } 380 - // const text = key.text orelse continue :loop; 381 - // try self.paste_buffer.appendSlice(text); 382 - // continue; 383 - // } 384 - // for (self.binds.items) |bind| { 385 - // if (key.matches(bind.key.codepoint, bind.key.mods)) { 386 - // switch (bind.command) { 387 - // .quit => self.should_quit = true, 388 - // .@"next-channel" => self.nextChannel(), 389 - // .@"prev-channel" => self.prevChannel(), 390 - // .redraw => self.vx.queueRefresh(), 391 - // .lua_function => |ref| try lua.execFn(lua_state, ref), 392 - // else => {}, 393 - // } 394 - // break; 395 - // } 396 - // } else if (key.matches(vaxis.Key.tab, .{})) { 397 - // // if we already have a completion word, then we are 398 - // // cycling through the options 399 - // if (self.completer) |*completer| { 400 - // const line = completer.next(); 401 - // input.clearRetainingCapacity(); 402 - // try input.insertSliceAtCursor(line); 403 - // } else { 404 - // var completion_buf: [irc.maximum_message_size]u8 = undefined; 405 - // const content = input.sliceToCursor(&completion_buf); 406 - // self.completer = try Completer.init(self.alloc, content); 407 - // } 408 - // } else if (key.matches(vaxis.Key.tab, .{ .shift = true })) { 409 - // if (self.completer) |*completer| { 410 - // const line = completer.prev(); 411 - // input.clearRetainingCapacity(); 412 - // try input.insertSliceAtCursor(line); 413 - // } 414 - // } else if (key.matches(vaxis.Key.enter, .{})) { 415 - // const buffer = self.selectedBuffer() orelse @panic("no buffer"); 416 - // const content = try input.toOwnedSlice(); 417 - // if (content.len == 0) continue; 418 - // defer self.alloc.free(content); 419 - // if (content[0] == '/') 420 - // self.handleCommand(lua_state, buffer, content) catch |err| { 421 - // log.err("couldn't handle command: {}", .{err}); 422 - // } 423 - // else { 424 - // switch (buffer) { 425 - // .channel => |channel| { 426 - // var buf: [1024]u8 = undefined; 427 - // const msg = try std.fmt.bufPrint( 428 - // &buf, 429 - // "PRIVMSG {s} :{s}\r\n", 430 - // .{ 431 - // channel.name, 432 - // content, 433 - // }, 434 - // ); 435 - // try channel.client.queueWrite(msg); 436 - // }, 437 - // .client => log.err("can't send message to client", .{}), 438 - // } 439 - // } 440 - // if (self.completer != null) { 441 - // self.completer.?.deinit(); 442 - // self.completer = null; 443 - // } 444 - // } else if (key.matches(vaxis.Key.page_up, .{})) { 445 - // self.state.messages.scroll_offset +|= 3; 446 - // } else if (key.matches(vaxis.Key.page_down, .{})) { 447 - // self.state.messages.scroll_offset -|= 3; 448 - // } else if (key.matches(vaxis.Key.home, .{})) { 449 - // self.state.messages.scroll_offset = 0; 450 - // } else { 451 - // if (self.completer != null and !key.isModifier()) { 452 - // self.completer.?.deinit(); 453 - // self.completer = null; 454 - // } 455 - // log.debug("{}", .{key}); 456 - // try input.update(.{ .key_press = key }); 457 - // } 458 - // }, 459 - // .paste_start => self.state.paste.pasting = true, 460 - // .paste_end => { 461 - // self.state.paste.pasting = false; 462 - // if (self.state.paste.has_newline) { 463 - // log.warn("NEWLINE", .{}); 464 - // } else { 465 - // try input.insertSliceAtCursor(self.paste_buffer.items); 466 - // defer self.paste_buffer.clearAndFree(); 467 - // } 468 - // }, 469 - // .focus_out => self.state.mouse = null, 470 - // .mouse => |mouse| { 471 - // self.state.mouse = mouse; 472 - // }, 473 - // .winsize => |ws| try self.vx.resize(self.alloc, writer, ws), 474 - // .connect => |cfg| { 475 - // const client = try self.alloc.create(irc.Client); 476 - // client.* = try irc.Client.init(self.alloc, self, &write_queue, cfg); 477 - // client.thread = try std.Thread.spawn(.{}, irc.Client.readLoop, .{ client, &loop }); 478 - // try self.clients.append(client); 479 - // }, 480 - // .irc => |irc_event| { 481 - // const msg: irc.Message = .{ .bytes = irc_event.msg.slice() }; 482 - // const client = irc_event.client; 483 - // defer irc_event.msg.deinit(); 484 - // switch (msg.command()) { 485 - // .unknown => {}, 486 - // .CAP => { 487 - // // syntax: <client> <ACK/NACK> :caps 488 - // var iter = msg.paramIterator(); 489 - // _ = iter.next() orelse continue; // client 490 - // const ack_or_nak = iter.next() orelse continue; 491 - // const caps = iter.next() orelse continue; 492 - // var cap_iter = mem.splitScalar(u8, caps, ' '); 493 - // while (cap_iter.next()) |cap| { 494 - // if (mem.eql(u8, ack_or_nak, "ACK")) { 495 - // client.ack(cap); 496 - // if (mem.eql(u8, cap, "sasl")) 497 - // try client.queueWrite("AUTHENTICATE PLAIN\r\n"); 498 - // } else if (mem.eql(u8, ack_or_nak, "NAK")) { 499 - // log.debug("CAP not supported {s}", .{cap}); 500 - // } 501 - // } 502 - // }, 503 - // .AUTHENTICATE => { 504 - // var iter = msg.paramIterator(); 505 - // while (iter.next()) |param| { 506 - // // A '+' is the continuuation to send our 507 - // // AUTHENTICATE info 508 - // if (!mem.eql(u8, param, "+")) continue; 509 - // var buf: [4096]u8 = undefined; 510 - // const config = client.config; 511 - // const sasl = try std.fmt.bufPrint( 512 - // &buf, 513 - // "{s}\x00{s}\x00{s}", 514 - // .{ config.user, config.nick, config.password }, 515 - // ); 516 - // 517 - // // Create a buffer big enough for the base64 encoded string 518 - // const b64_buf = try self.alloc.alloc(u8, Base64Encoder.calcSize(sasl.len)); 519 - // defer self.alloc.free(b64_buf); 520 - // const encoded = Base64Encoder.encode(b64_buf, sasl); 521 - // // Make our message 522 - // const auth = try std.fmt.bufPrint( 523 - // &buf, 524 - // "AUTHENTICATE {s}\r\n", 525 - // .{encoded}, 526 - // ); 527 - // try client.queueWrite(auth); 528 - // if (config.network_id) |id| { 529 - // const bind = try std.fmt.bufPrint( 530 - // &buf, 531 - // "BOUNCER BIND {s}\r\n", 532 - // .{id}, 533 - // ); 534 - // try client.queueWrite(bind); 535 - // } 536 - // try client.queueWrite("CAP END\r\n"); 537 - // } 538 - // }, 539 - // .RPL_WELCOME => { 540 - // const now = try zeit.instant(.{}); 541 - // var now_buf: [30]u8 = undefined; 542 - // const now_fmt = try now.time().bufPrint(&now_buf, .rfc3339); 543 - // 544 - // const past = try now.subtract(.{ .days = 7 }); 545 - // var past_buf: [30]u8 = undefined; 546 - // const past_fmt = try past.time().bufPrint(&past_buf, .rfc3339); 547 - // 548 - // var buf: [128]u8 = undefined; 549 - // const targets = try std.fmt.bufPrint( 550 - // &buf, 551 - // "CHATHISTORY TARGETS timestamp={s} timestamp={s} 50\r\n", 552 - // .{ now_fmt, past_fmt }, 553 - // ); 554 - // try client.queueWrite(targets); 555 - // // on_connect callback 556 - // try lua.onConnect(lua_state, client); 557 - // }, 558 - // .RPL_YOURHOST => {}, 559 - // .RPL_CREATED => {}, 560 - // .RPL_MYINFO => {}, 561 - // .RPL_ISUPPORT => { 562 - // // syntax: <client> <token>[ <token>] :are supported 563 - // var iter = msg.paramIterator(); 564 - // _ = iter.next() orelse continue; // client 565 - // while (iter.next()) |token| { 566 - // if (mem.eql(u8, token, "WHOX")) 567 - // client.supports.whox = true 568 - // else if (mem.startsWith(u8, token, "PREFIX")) { 569 - // const prefix = blk: { 570 - // const idx = mem.indexOfScalar(u8, token, ')') orelse 571 - // // default is "@+" 572 - // break :blk try self.alloc.dupe(u8, "@+"); 573 - // break :blk try self.alloc.dupe(u8, token[idx + 1 ..]); 574 - // }; 575 - // client.supports.prefix = prefix; 576 - // } 577 - // } 578 - // }, 579 - // .RPL_LOGGEDIN => {}, 580 - // .RPL_TOPIC => { 581 - // // syntax: <client> <channel> :<topic> 582 - // var iter = msg.paramIterator(); 583 - // _ = iter.next() orelse continue :loop; // client ("*") 584 - // const channel_name = iter.next() orelse continue :loop; // channel 585 - // const topic = iter.next() orelse continue :loop; // topic 586 - // 587 - // var channel = try client.getOrCreateChannel(channel_name); 588 - // if (channel.topic) |old_topic| { 589 - // self.alloc.free(old_topic); 590 - // } 591 - // channel.topic = try self.alloc.dupe(u8, topic); 592 - // }, 593 - // .RPL_SASLSUCCESS => {}, 594 - // .RPL_WHOREPLY => { 595 - // // syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name> 596 - // var iter = msg.paramIterator(); 597 - // _ = iter.next() orelse continue :loop; // client 598 - // const channel_name = iter.next() orelse continue :loop; // channel 599 - // if (mem.eql(u8, channel_name, "*")) continue; 600 - // _ = iter.next() orelse continue :loop; // username 601 - // _ = iter.next() orelse continue :loop; // host 602 - // _ = iter.next() orelse continue :loop; // server 603 - // const nick = iter.next() orelse continue :loop; // nick 604 - // const flags = iter.next() orelse continue :loop; // flags 605 - // 606 - // const user_ptr = try client.getOrCreateUser(nick); 607 - // if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true; 608 - // var channel = try client.getOrCreateChannel(channel_name); 609 - // 610 - // const prefix = for (flags) |c| { 611 - // if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| { 612 - // break c; 613 - // } 614 - // } else ' '; 615 - // 616 - // try channel.addMember(user_ptr, .{ .prefix = prefix }); 617 - // }, 618 - // .RPL_WHOSPCRPL => { 619 - // // syntax: <client> <channel> <nick> <flags> :<realname> 620 - // var iter = msg.paramIterator(); 621 - // _ = iter.next() orelse continue; 622 - // const channel_name = iter.next() orelse continue; // channel 623 - // const nick = iter.next() orelse continue; 624 - // const flags = iter.next() orelse continue; 625 - // 626 - // const user_ptr = try client.getOrCreateUser(nick); 627 - // if (iter.next()) |real_name| { 628 - // if (user_ptr.real_name) |old_name| { 629 - // self.alloc.free(old_name); 630 - // } 631 - // user_ptr.real_name = try self.alloc.dupe(u8, real_name); 632 - // } 633 - // if (mem.indexOfScalar(u8, flags, 'G')) |_| user_ptr.away = true; 634 - // var channel = try client.getOrCreateChannel(channel_name); 635 - // 636 - // const prefix = for (flags) |c| { 637 - // if (std.mem.indexOfScalar(u8, client.supports.prefix, c)) |_| { 638 - // break c; 639 - // } 640 - // } else ' '; 641 - // 642 - // try channel.addMember(user_ptr, .{ .prefix = prefix }); 643 - // }, 644 - // .RPL_ENDOFWHO => { 645 - // // syntax: <client> <mask> :End of WHO list 646 - // var iter = msg.paramIterator(); 647 - // _ = iter.next() orelse continue :loop; // client 648 - // const channel_name = iter.next() orelse continue :loop; // channel 649 - // if (mem.eql(u8, channel_name, "*")) continue; 650 - // var channel = try client.getOrCreateChannel(channel_name); 651 - // channel.in_flight.who = false; 652 - // }, 653 - // .RPL_NAMREPLY => { 654 - // // syntax: <client> <symbol> <channel> :[<prefix>]<nick>{ [<prefix>]<nick>} 655 - // var iter = msg.paramIterator(); 656 - // _ = iter.next() orelse continue; // client 657 - // _ = iter.next() orelse continue; // symbol 658 - // const channel_name = iter.next() orelse continue; // channel 659 - // const names = iter.next() orelse continue; 660 - // var channel = try client.getOrCreateChannel(channel_name); 661 - // var name_iter = std.mem.splitScalar(u8, names, ' '); 662 - // while (name_iter.next()) |name| { 663 - // const nick, const prefix = for (client.supports.prefix) |ch| { 664 - // if (name[0] == ch) { 665 - // break .{ name[1..], name[0] }; 666 - // } 667 - // } else .{ name, ' ' }; 668 - // 669 - // if (prefix != ' ') { 670 - // log.debug("HAS PREFIX {s}", .{name}); 671 - // } 672 - // 673 - // const user_ptr = try client.getOrCreateUser(nick); 674 - // 675 - // try channel.addMember(user_ptr, .{ .prefix = prefix, .sort = false }); 676 - // } 677 - // 678 - // channel.sortMembers(); 679 - // }, 680 - // .RPL_ENDOFNAMES => { 681 - // // syntax: <client> <channel> :End of /NAMES list 682 - // var iter = msg.paramIterator(); 683 - // _ = iter.next() orelse continue; // client 684 - // const channel_name = iter.next() orelse continue; // channel 685 - // var channel = try client.getOrCreateChannel(channel_name); 686 - // channel.in_flight.names = false; 687 - // }, 688 - // .BOUNCER => { 689 - // var iter = msg.paramIterator(); 690 - // while (iter.next()) |param| { 691 - // if (mem.eql(u8, param, "NETWORK")) { 692 - // const id = iter.next() orelse continue; 693 - // const attr = iter.next() orelse continue; 694 - // // check if we already have this network 695 - // for (self.clients.items, 0..) |cl, i| { 696 - // if (cl.config.network_id) |net_id| { 697 - // if (mem.eql(u8, net_id, id)) { 698 - // if (mem.eql(u8, attr, "*")) { 699 - // // * means the network was 700 - // // deleted 701 - // cl.deinit(); 702 - // _ = self.clients.swapRemove(i); 703 - // } 704 - // continue :loop; 705 - // } 706 - // } 707 - // } 708 - // 709 - // var cfg = client.config; 710 - // cfg.network_id = try self.alloc.dupe(u8, id); 711 - // 712 - // var attr_iter = std.mem.splitScalar(u8, attr, ';'); 713 - // while (attr_iter.next()) |kv| { 714 - // const n = std.mem.indexOfScalar(u8, kv, '=') orelse continue; 715 - // const key = kv[0..n]; 716 - // if (mem.eql(u8, key, "name")) 717 - // cfg.name = try self.alloc.dupe(u8, kv[n + 1 ..]) 718 - // else if (mem.eql(u8, key, "nickname")) 719 - // cfg.network_nick = try self.alloc.dupe(u8, kv[n + 1 ..]); 720 - // } 721 - // loop.postEvent(.{ .connect = cfg }); 722 - // } 723 - // } 724 - // }, 725 - // .AWAY => { 726 - // const src = msg.source() orelse continue :loop; 727 - // var iter = msg.paramIterator(); 728 - // const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len; 729 - // const user = try client.getOrCreateUser(src[0..n]); 730 - // // If there are any params, the user is away. Otherwise 731 - // // they are back. 732 - // user.away = if (iter.next()) |_| true else false; 733 - // }, 734 - // .BATCH => { 735 - // var iter = msg.paramIterator(); 736 - // const tag = iter.next() orelse continue; 737 - // switch (tag[0]) { 738 - // '+' => { 739 - // const batch_type = iter.next() orelse continue; 740 - // if (mem.eql(u8, batch_type, "chathistory")) { 741 - // const target = iter.next() orelse continue; 742 - // var channel = try client.getOrCreateChannel(target); 743 - // channel.at_oldest = true; 744 - // const duped_tag = try self.alloc.dupe(u8, tag[1..]); 745 - // try client.batches.put(duped_tag, channel); 746 - // } 747 - // }, 748 - // '-' => { 749 - // const key = client.batches.getKey(tag[1..]) orelse continue; 750 - // var chan = client.batches.get(key) orelse @panic("key should exist here"); 751 - // chan.history_requested = false; 752 - // _ = client.batches.remove(key); 753 - // self.alloc.free(key); 754 - // }, 755 - // else => {}, 756 - // } 757 - // }, 758 - // .CHATHISTORY => { 759 - // var iter = msg.paramIterator(); 760 - // const should_targets = iter.next() orelse continue; 761 - // if (!mem.eql(u8, should_targets, "TARGETS")) continue; 762 - // const target = iter.next() orelse continue; 763 - // // we only add direct messages, not more channels 764 - // assert(target.len > 0); 765 - // if (target[0] == '#') continue; 766 - // 767 - // var channel = try client.getOrCreateChannel(target); 768 - // const user_ptr = try client.getOrCreateUser(target); 769 - // const me_ptr = try client.getOrCreateUser(client.nickname()); 770 - // try channel.addMember(user_ptr, .{}); 771 - // try channel.addMember(me_ptr, .{}); 772 - // // we set who_requested so we don't try to request 773 - // // who on DMs 774 - // channel.who_requested = true; 775 - // var buf: [128]u8 = undefined; 776 - // const mark_read = try std.fmt.bufPrint( 777 - // &buf, 778 - // "MARKREAD {s}\r\n", 779 - // .{channel.name}, 780 - // ); 781 - // try client.queueWrite(mark_read); 782 - // try client.requestHistory(.after, channel); 783 - // }, 784 - // .JOIN => { 785 - // // get the user 786 - // const src = msg.source() orelse continue :loop; 787 - // const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len; 788 - // const user = try client.getOrCreateUser(src[0..n]); 789 - // 790 - // // get the channel 791 - // var iter = msg.paramIterator(); 792 - // const target = iter.next() orelse continue; 793 - // var channel = try client.getOrCreateChannel(target); 794 - // 795 - // // If it's our nick, we request chat history 796 - // if (mem.eql(u8, user.nick, client.nickname())) { 797 - // try client.requestHistory(.after, channel); 798 - // if (self.explicit_join) { 799 - // self.selectChannelName(client, target); 800 - // self.explicit_join = false; 801 - // } 802 - // } else try channel.addMember(user, .{}); 803 - // }, 804 - // .MARKREAD => { 805 - // var iter = msg.paramIterator(); 806 - // const target = iter.next() orelse continue; 807 - // const timestamp = iter.next() orelse continue; 808 - // const equal = std.mem.indexOfScalar(u8, timestamp, '=') orelse continue; 809 - // const last_read = zeit.instant(.{ 810 - // .source = .{ 811 - // .iso8601 = timestamp[equal + 1 ..], 812 - // }, 813 - // }) catch |err| { 814 - // log.err("couldn't convert timestamp: {}", .{err}); 815 - // continue; 816 - // }; 817 - // var channel = try client.getOrCreateChannel(target); 818 - // channel.last_read = last_read.unixTimestamp(); 819 - // const last_msg = channel.messages.getLastOrNull() orelse continue; 820 - // const time = last_msg.time() orelse continue; 821 - // if (time.unixTimestamp() > channel.last_read) 822 - // channel.has_unread = true 823 - // else 824 - // channel.has_unread = false; 825 - // }, 826 - // .PART => { 827 - // // get the user 828 - // const src = msg.source() orelse continue :loop; 829 - // const n = std.mem.indexOfScalar(u8, src, '!') orelse src.len; 830 - // const user = try client.getOrCreateUser(src[0..n]); 831 - // 832 - // // get the channel 833 - // var iter = msg.paramIterator(); 834 - // const target = iter.next() orelse continue; 835 - // 836 - // if (mem.eql(u8, user.nick, client.nickname())) { 837 - // for (client.channels.items, 0..) |channel, i| { 838 - // if (!mem.eql(u8, channel.name, target)) continue; 839 - // var chan = client.channels.orderedRemove(i); 840 - // self.state.buffers.selected_idx -|= 1; 841 - // chan.deinit(self.alloc); 842 - // break; 843 - // } 844 - // } else { 845 - // const channel = try client.getOrCreateChannel(target); 846 - // channel.removeMember(user); 847 - // } 848 - // }, 849 - // .PRIVMSG, .NOTICE => { 850 - // // syntax: <target> :<message> 851 - // const msg2: irc.Message = .{ 852 - // .bytes = try self.alloc.dupe(u8, msg.bytes), 853 - // }; 854 - // var iter = msg2.paramIterator(); 855 - // const target = blk: { 856 - // const tgt = iter.next() orelse continue; 857 - // if (mem.eql(u8, tgt, client.nickname())) { 858 - // // If the target is us, it likely has our 859 - // // hostname in it. 860 - // const source = msg2.source() orelse continue; 861 - // const n = mem.indexOfScalar(u8, source, '!') orelse source.len; 862 - // break :blk source[0..n]; 863 - // } else break :blk tgt; 864 - // }; 865 - // 866 - // // We handle batches separately. When we encounter a 867 - // // PRIVMSG from a batch, we use the original target 868 - // // from the batch start. We also never notify from a 869 - // // batched message. Batched messages also require 870 - // // sorting 871 - // var tag_iter = msg2.tagIterator(); 872 - // while (tag_iter.next()) |tag| { 873 - // if (mem.eql(u8, tag.key, "batch")) { 874 - // const entry = client.batches.getEntry(tag.value) orelse @panic("TODO"); 875 - // var channel = entry.value_ptr.*; 876 - // try channel.messages.append(msg2); 877 - // std.sort.insertion(irc.Message, channel.messages.items, {}, irc.Message.compareTime); 878 - // channel.at_oldest = false; 879 - // const time = msg2.time() orelse continue; 880 - // if (time.unixTimestamp() > channel.last_read) { 881 - // channel.has_unread = true; 882 - // const content = iter.next() orelse continue; 883 - // if (std.mem.indexOf(u8, content, client.nickname())) |_| { 884 - // channel.has_unread_highlight = true; 885 - // } 886 - // } 887 - // break; 888 - // } 889 - // } else { 890 - // // standard handling 891 - // var channel = try client.getOrCreateChannel(target); 892 - // try channel.messages.append(msg2); 893 - // const content = iter.next() orelse continue; 894 - // var has_highlight = false; 895 - // { 896 - // const sender: []const u8 = blk: { 897 - // const src = msg2.source() orelse break :blk ""; 898 - // const l = std.mem.indexOfScalar(u8, src, '!') orelse 899 - // std.mem.indexOfScalar(u8, src, '@') orelse 900 - // src.len; 901 - // break :blk src[0..l]; 902 - // }; 903 - // try lua.onMessage(lua_state, client, channel.name, sender, content); 904 - // } 905 - // if (std.mem.indexOf(u8, content, client.nickname())) |_| { 906 - // var buf: [64]u8 = undefined; 907 - // const title_or_err = if (msg2.source()) |source| 908 - // std.fmt.bufPrint(&buf, "{s} - {s}", .{ channel.name, source }) 909 - // else 910 - // std.fmt.bufPrint(&buf, "{s}", .{channel.name}); 911 - // const title = title_or_err catch title: { 912 - // const len = @min(buf.len, channel.name.len); 913 - // @memcpy(buf[0..len], channel.name[0..len]); 914 - // break :title buf[0..len]; 915 - // }; 916 - // try self.vx.notify(writer, title, content); 917 - // has_highlight = true; 918 - // } 919 - // const time = msg2.time() orelse continue; 920 - // if (time.unixTimestamp() > channel.last_read) { 921 - // channel.has_unread_highlight = has_highlight; 922 - // channel.has_unread = true; 923 - // } 924 - // } 925 - // 926 - // // If we get a message from the current user mark the channel as 927 - // // read, since they must have just sent the message. 928 - // const sender: []const u8 = blk: { 929 - // const src = msg2.source() orelse break :blk ""; 930 - // const l = std.mem.indexOfScalar(u8, src, '!') orelse 931 - // std.mem.indexOfScalar(u8, src, '@') orelse 932 - // src.len; 933 - // break :blk src[0..l]; 934 - // }; 935 - // if (std.mem.eql(u8, sender, client.nickname())) { 936 - // self.markSelectedChannelRead(); 937 - // } 938 - // }, 939 - // } 940 - // }, 941 - // } 942 - // } 943 - // 944 - // if (redraw) { 945 - // try self.draw(&input); 946 - // last_frame = std.time.milliTimestamp(); 947 - // } 948 - // } 949 - // } 950 - 951 335 pub fn connect(self: *App, cfg: irc.Client.Config) !void { 952 336 const client = try self.alloc.create(irc.Client); 953 337 client.* = try irc.Client.init(self.alloc, self, &self.write_queue, cfg); ··· 958 342 // When leaving a channel we mark it as read, so we make sure that's done 959 343 // before we change to the new channel. 960 344 self.markSelectedChannelRead(); 961 - 962 - const state = self.state.buffers; 963 - if (state.selected_idx >= state.count - 1) 964 - self.state.buffers.selected_idx = 0 965 - else 966 - self.state.buffers.selected_idx +|= 1; 345 + if (self.ctx) |ctx| { 346 + self.buffer_list.nextItem(ctx); 347 + } 967 348 } 968 349 969 350 pub fn prevChannel(self: *App) void { 970 351 // When leaving a channel we mark it as read, so we make sure that's done 971 352 // before we change to the new channel. 972 353 self.markSelectedChannelRead(); 973 - 974 - switch (self.state.buffers.selected_idx) { 975 - 0 => self.state.buffers.selected_idx = self.state.buffers.count - 1, 976 - else => self.state.buffers.selected_idx -|= 1, 354 + if (self.ctx) |ctx| { 355 + self.buffer_list.prevItem(ctx); 977 356 } 978 357 } 979 358 ··· 984 363 for (client.channels.items) |channel| { 985 364 if (cl == client) { 986 365 if (std.mem.eql(u8, name, channel.name)) { 987 - self.state.buffers.selected_idx = i; 366 + self.selectBuffer(.{ .channel = channel }); 988 367 } 989 368 } 990 369 i += 1; ··· 1116 495 for (client.channels.items, 0..) |search, i| { 1117 496 if (!mem.eql(u8, search.name, target)) continue; 1118 497 var chan = client.channels.orderedRemove(i); 1119 - self.state.buffers.selected_idx -|= 1; 498 + self.buffer_list.cursor -|= 1; 499 + self.buffer_list.ensureScroll(); 1120 500 chan.deinit(self.alloc); 501 + self.alloc.destroy(chan); 1121 502 break; 1122 503 } 1123 504 } else { ··· 1185 566 self.buffer_list.cursor = i; 1186 567 self.buffer_list.ensureScroll(); 1187 568 if (target.messageViewIsAtBottom()) target.has_unread = false; 569 + if (self.ctx) |ctx| { 570 + ctx.requestFocus(channel.text_field.widget()) catch {}; 571 + } 1188 572 return; 1189 573 } 1190 574 i += 1;
+78 -42
src/completer.zig
··· 4 4 const emoji = @import("emoji.zig"); 5 5 6 6 const irc = comlink.irc; 7 + const vxfw = vaxis.vxfw; 7 8 const Command = comlink.Command; 8 9 9 10 const Kind = enum { ··· 13 14 }; 14 15 15 16 pub const Completer = struct { 17 + const style: vaxis.Style = .{ .bg = .{ .index = 8 } }; 18 + const selected: vaxis.Style = .{ .bg = .{ .index = 8 }, .reverse = true }; 19 + 16 20 word: []const u8, 17 21 start_idx: usize, 18 - options: std.ArrayList([]const u8), 19 - selected_idx: ?usize, 22 + options: std.ArrayList(vxfw.Text), 20 23 widest: ?usize, 21 24 buf: [irc.maximum_message_size]u8 = undefined, 22 25 kind: Kind = .nick, 26 + list_view: vxfw.ListView, 27 + selected: bool, 23 28 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, 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 = "", 32 34 .widest = null, 35 + .list_view = undefined, 36 + .selected = false, 33 37 }; 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 + 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(); 38 50 } 39 - if (last_word.len > 0 and last_word[0] == ':') { 40 - completer.kind = .emoji; 41 - try completer.findEmojiMatches(); 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(); 42 73 } 43 - return completer; 74 + if (self.word.len > 0 and self.word[0] == ':') { 75 + self.kind = .emoji; 76 + try self.findEmojiMatches(); 77 + } 44 78 } 45 79 46 80 pub fn deinit(self: *Completer) void { ··· 50 84 /// cycles to the next option, returns the replacement text. Note that we 51 85 /// start from the bottom, so a selected_idx = 0 means we are on _the last_ 52 86 /// item 53 - pub fn next(self: *Completer) []const u8 { 87 + pub fn next(self: *Completer, ctx: *vxfw.EventContext) []const u8 { 54 88 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; 89 + if (self.selected) { 90 + self.list_view.prevItem(ctx); 61 91 } 92 + self.selected = true; 62 93 return self.replacementText(); 63 94 } 64 95 65 - pub fn prev(self: *Completer) []const u8 { 96 + pub fn prev(self: *Completer, ctx: *vxfw.EventContext) []const u8 { 66 97 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 - } 98 + self.list_view.nextItem(ctx); 99 + self.selected = true; 74 100 return self.replacementText(); 75 101 } 76 102 77 103 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.?]; 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; 80 107 switch (self.kind) { 81 108 .command => { 82 109 self.buf[0] = '/'; ··· 118 145 } 119 146 } 120 147 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); 148 + try self.options.ensureTotalCapacity(members.items.len); 122 149 for (members.items) |member| { 123 - try self.options.append(member.user.nick); 150 + try self.options.append(.{ .text = member.user.nick }); 124 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(); 125 155 } 126 156 127 157 pub fn findCommandMatches(self: *Completer) !void { ··· 130 160 for (commands) |cmd| { 131 161 if (std.mem.eql(u8, cmd, "lua_function")) continue; 132 162 if (std.ascii.startsWithIgnoreCase(cmd, self.word[1..])) { 133 - try self.options.append(cmd); 163 + try self.options.append(.{ .text = cmd }); 134 164 } 135 165 } 136 166 var iter = Command.user_commands.keyIterator(); 137 167 while (iter.next()) |cmd| { 138 168 if (std.ascii.startsWithIgnoreCase(cmd.*, self.word[1..])) { 139 - try self.options.append(cmd.*); 169 + try self.options.append(.{ .text = cmd.* }); 140 170 } 141 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(); 142 175 } 143 176 144 177 pub fn findEmojiMatches(self: *Completer) !void { ··· 148 181 149 182 for (keys, values) |shortcode, glyph| { 150 183 if (std.mem.indexOf(u8, shortcode, self.word[1..])) |_| 151 - try self.options.append(glyph); 184 + try self.options.append(.{ .text = glyph }); 152 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(); 153 189 } 154 190 155 - pub fn widestMatch(self: *Completer, win: vaxis.Window) usize { 191 + pub fn widestMatch(self: *Completer, ctx: vxfw.DrawContext) usize { 156 192 if (self.widest) |w| return w; 157 193 var widest: usize = 0; 158 194 for (self.options.items) |opt| { 159 - const width = win.gwidth(opt); 195 + const width = ctx.stringWidth(opt.text); 160 196 if (width > widest) widest = width; 161 197 } 162 198 self.widest = widest;
+142 -15
src/irc.zig
··· 5 5 const vaxis = @import("vaxis"); 6 6 const zeit = @import("zeit"); 7 7 8 + const Completer = @import("completer.zig").Completer; 8 9 const Scrollbar = @import("Scrollbar.zig"); 9 10 const testing = std.testing; 10 11 const mem = std.mem; ··· 128 129 129 130 /// Pending scroll we have to handle while drawing. This could be up or down. By convention 130 131 /// we say positive is a scroll up. 131 - pending: i16 = 0, 132 + pending: i17 = 0, 132 133 } = .{}, 133 134 134 135 message_view: struct { 135 136 mouse: ?vaxis.Mouse = null, 136 137 hovered_message: ?Message = null, 137 138 } = .{}, 139 + 140 + completer: Completer, 141 + completer_shown: bool = false, 138 142 139 143 // Gutter (left side where time is printed) width 140 144 const gutter_width = 6; ··· 145 149 /// Highest channel membership prefix (or empty space if no prefix) 146 150 prefix: u8, 147 151 152 + channel: *Channel, 153 + has_mouse: bool = false, 154 + 148 155 pub fn compare(_: void, lhs: Member, rhs: Member) bool { 149 156 return if (lhs.prefix != ' ' and rhs.prefix == ' ') 150 157 true ··· 157 164 pub fn widget(self: *Member) vxfw.Widget { 158 165 return .{ 159 166 .userdata = self, 167 + .eventHandler = Member.eventHandler, 160 168 .drawFn = Member.draw, 161 169 }; 162 170 } 163 171 172 + fn eventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 173 + const self: *Member = @ptrCast(@alignCast(ptr)); 174 + switch (event) { 175 + .mouse => |mouse| { 176 + if (!self.has_mouse) { 177 + self.has_mouse = true; 178 + try ctx.setMouseShape(.pointer); 179 + } 180 + switch (mouse.type) { 181 + .press => { 182 + if (mouse.button == .left) { 183 + // Open a private message with this user 184 + const client = self.channel.client; 185 + const ch = try client.getOrCreateChannel(self.user.nick); 186 + try client.requestHistory(.after, ch); 187 + client.app.selectChannelName(client, ch.name); 188 + return ctx.consumeAndRedraw(); 189 + } 190 + if (mouse.button == .right) { 191 + // Insert nick at cursor 192 + try self.channel.text_field.insertSliceAtCursor(self.user.nick); 193 + return ctx.consumeAndRedraw(); 194 + } 195 + }, 196 + else => {}, 197 + } 198 + }, 199 + .mouse_enter => { 200 + self.has_mouse = true; 201 + try ctx.setMouseShape(.pointer); 202 + }, 203 + .mouse_leave => { 204 + self.has_mouse = false; 205 + try ctx.setMouseShape(.default); 206 + }, 207 + else => {}, 208 + } 209 + } 210 + 164 211 pub fn draw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 165 212 const self: *Member = @ptrCast(@alignCast(ptr)); 166 - const style: vaxis.Style = if (self.user.away) 213 + var style: vaxis.Style = if (self.user.away) 167 214 .{ .fg = .{ .index = 8 } } 168 215 else 169 216 .{ .fg = self.user.color }; 217 + if (self.has_mouse) style.reverse = true; 170 218 var prefix = try ctx.arena.alloc(u8, 1); 171 219 prefix[0] = self.prefix; 172 220 const text: vxfw.RichText = .{ ··· 176 224 }, 177 225 .softwrap = false, 178 226 }; 179 - return text.draw(ctx); 227 + var surface = try text.draw(ctx); 228 + surface.widget = self.widget(); 229 + return surface; 180 230 } 181 231 }; 182 232 ··· 208 258 .draw_cursor = false, 209 259 }, 210 260 .text_field = vxfw.TextField.init(gpa, unicode), 261 + .completer = Completer.init(gpa), 211 262 }; 212 263 213 264 self.text_field.userdata = self; ··· 222 273 try self.client.print("PRIVMSG {s} :{s}\r\n", .{ self.name, input }); 223 274 } 224 275 ctx.redraw = true; 276 + self.completer_shown = false; 225 277 self.text_field.clearAndFree(); 226 278 } 227 279 ··· 236 288 } 237 289 self.messages.deinit(); 238 290 self.text_field.deinit(); 291 + self.completer.deinit(); 239 292 } 240 293 241 294 pub fn compare(_: void, lhs: *Channel, rhs: *Channel) bool { ··· 366 419 prefix: ?u8 = null, 367 420 sort: bool = true, 368 421 }) Allocator.Error!void { 369 - if (args.prefix) |p| { 370 - log.debug("adding member: nick={s}, prefix={c}", .{ user.nick, p }); 371 - } 372 422 for (self.members.items) |*member| { 373 423 if (user == member.user) { 374 424 // Update the prefix for an existing member if the prefix is ··· 378 428 } 379 429 } 380 430 381 - try self.members.append(.{ .user = user, .prefix = args.prefix orelse ' ' }); 431 + try self.members.append(.{ 432 + .user = user, 433 + .prefix = args.prefix orelse ' ', 434 + .channel = self, 435 + }); 382 436 383 437 if (args.sort) { 384 438 self.sortMembers(); ··· 399 453 pub fn markRead(self: *Channel) !void { 400 454 self.has_unread = false; 401 455 self.has_unread_highlight = false; 402 - const last_msg = self.messages.getLast(); 456 + const last_msg = self.messages.getLastOrNull() orelse return; 403 457 const time_tag = last_msg.getTag("time") orelse return; 404 458 try self.client.print( 405 459 "MARKREAD {s} timestamp={s}\r\n", ··· 413 467 pub fn contentWidget(self: *Channel) vxfw.Widget { 414 468 return .{ 415 469 .userdata = self, 470 + .captureHandler = Channel.captureEvent, 416 471 .drawFn = Channel.typeErasedViewDraw, 417 472 }; 418 473 } 419 474 475 + fn captureEvent(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 476 + const self: *Channel = @ptrCast(@alignCast(ptr)); 477 + switch (event) { 478 + .key_press => |key| { 479 + if (key.matches(vaxis.Key.tab, .{})) { 480 + ctx.redraw = true; 481 + // if we already have a completion word, then we are 482 + // cycling through the options 483 + if (self.completer_shown) { 484 + const line = self.completer.next(ctx); 485 + self.text_field.clearRetainingCapacity(); 486 + try self.text_field.insertSliceAtCursor(line); 487 + } else { 488 + var completion_buf: [maximum_message_size]u8 = undefined; 489 + const content = self.text_field.sliceToCursor(&completion_buf); 490 + try self.completer.reset(content); 491 + if (self.completer.kind == .nick) { 492 + try self.completer.findMatches(self); 493 + } 494 + self.completer_shown = true; 495 + } 496 + return; 497 + } 498 + if (key.matches(vaxis.Key.tab, .{ .shift = true })) { 499 + if (self.completer_shown) { 500 + const line = self.completer.prev(ctx); 501 + self.text_field.clearRetainingCapacity(); 502 + try self.text_field.insertSliceAtCursor(line); 503 + } 504 + return; 505 + } 506 + if (key.matches(vaxis.Key.page_up, .{})) { 507 + self.scroll.pending += self.client.app.last_height / 2; 508 + try self.doScroll(ctx); 509 + return ctx.consumeAndRedraw(); 510 + } 511 + if (key.matches(vaxis.Key.page_down, .{})) { 512 + self.scroll.pending -|= self.client.app.last_height / 2; 513 + try self.doScroll(ctx); 514 + return ctx.consumeAndRedraw(); 515 + } 516 + if (key.matches(vaxis.Key.home, .{})) { 517 + self.scroll.pending -= self.scroll.offset; 518 + self.scroll.msg_offset = null; 519 + try self.doScroll(ctx); 520 + return ctx.consumeAndRedraw(); 521 + } 522 + if (!key.isModifier()) { 523 + self.completer_shown = false; 524 + } 525 + }, 526 + else => {}, 527 + } 528 + } 529 + 420 530 fn typeErasedViewDraw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 421 531 const self: *Channel = @ptrCast(@alignCast(ptr)); 422 532 if (!self.who_requested) { ··· 479 589 .bottom = self.scroll.offset, 480 590 }; 481 591 const scrollbar_surface = try scrollbars.draw(scrollbar_ctx); 482 - // Draw the text field 483 592 try children.append(.{ 484 593 .origin = .{ .col = max.width - 1, .row = 2 }, 485 594 .surface = scrollbar_surface, ··· 490 599 .origin = .{ .col = 0, .row = max.height - 1 }, 491 600 .surface = try self.text_field.draw(ctx), 492 601 }); 602 + 603 + if (self.completer_shown) { 604 + const widest: u16 = @intCast(self.completer.widestMatch(ctx)); 605 + const completer_ctx = ctx.withConstraints(ctx.min, .{ .height = 10, .width = widest }); 606 + const surface = try self.completer.list_view.draw(completer_ctx); 607 + const height: u16 = @intCast(@min(10, self.completer.options.items.len)); 608 + try children.append(.{ 609 + .origin = .{ .col = 0, .row = max.height -| 1 -| height }, 610 + .surface = surface, 611 + }); 612 + } 493 613 494 614 return .{ 495 615 .size = max, ··· 578 698 579 699 // Scroll up 580 700 if (self.scroll.pending > 0) { 581 - // TODO: check if we need to get more history 582 - // TODO: cehck if we are at oldest, and shouldn't scroll up anymore 583 - 584 701 // Consume 1 line, and schedule a tick 585 702 self.scroll.offset += 1; 586 703 self.scroll.pending -= 1; ··· 1346 1463 pub fn view(self: *Client) vxfw.Widget { 1347 1464 return .{ 1348 1465 .userdata = self, 1466 + .eventHandler = Client.eventHandler, 1349 1467 .drawFn = Client.typeErasedViewDraw, 1350 1468 }; 1469 + } 1470 + 1471 + fn eventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 1472 + _ = ptr; 1473 + _ = ctx; 1474 + _ = event; 1351 1475 } 1352 1476 1353 1477 fn typeErasedViewDraw(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 1354 - _ = ptr; 1478 + const self: *Client = @ptrCast(@alignCast(ptr)); 1355 1479 const text: vxfw.Text = .{ .text = "content" }; 1356 - return text.draw(ctx); 1480 + var surface = try text.draw(ctx); 1481 + surface.widget = self.view(); 1482 + return surface; 1357 1483 } 1358 1484 1359 1485 pub fn nameWidget(self: *Client, selected: bool) vxfw.Widget { ··· 1806 1932 for (client.channels.items, 0..) |channel, i| { 1807 1933 if (!mem.eql(u8, channel.name, target)) continue; 1808 1934 var chan = client.channels.orderedRemove(i); 1809 - self.app.state.buffers.selected_idx -|= 1; 1810 1935 chan.deinit(self.app.alloc); 1811 1936 self.alloc.destroy(chan); 1937 + self.app.buffer_list.cursor -|= 1; 1938 + self.app.buffer_list.ensureScroll(); 1812 1939 break; 1813 1940 } 1814 1941 } else {