an experimental irc client

ui: show most irc messages in network tab

rockorager.dev 17687522 50c0afc4

verified
+397 -9
+397 -9
src/irc.zig
··· 33 /// Number of failed pings before we consider the connection failed 34 const keepalive_retries: i32 = 3; 35 36 pub const Buffer = union(enum) { 37 client: *Client, 38 channel: *Channel, ··· 71 MARKREAD, 72 NOTICE, 73 PART, 74 PRIVMSG, 75 TAGMSG, 76 ··· 108 .{ "MARKREAD", .MARKREAD }, 109 .{ "NOTICE", .NOTICE }, 110 .{ "PART", .PART }, 111 .{ "PRIVMSG", .PRIVMSG }, 112 .{ "TAGMSG", .TAGMSG }, 113 }); ··· 168 completer_shown: bool = false, 169 typing_last_active: u32 = 0, 170 typing_last_sent: u32 = 0, 171 - 172 - // Gutter (left side where time is printed) width 173 - const gutter_width = 6; 174 175 pub const Member = struct { 176 user: *User, ··· 1386 return msg; 1387 } 1388 1389 pub const ParamIterator = struct { 1390 params: ?[]const u8, 1391 index: usize = 0, ··· 1673 completer_shown: bool, 1674 1675 list_modal: ListModal, 1676 1677 pub fn init( 1678 self: *Client, ··· 1700 .text_field = .init(alloc, app.unicode), 1701 .completer_shown = false, 1702 .list_modal = undefined, 1703 }; 1704 self.list_modal.init(alloc, self); 1705 self.text_field.style = .{ .bg = self.app.blendBg(10) }; ··· 1752 self.channels.deinit(); 1753 1754 self.list_modal.deinit(self.alloc); 1755 1756 var user_iter = self.users.valueIterator(); 1757 while (user_iter.next()) |user| { ··· 1828 1829 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena); 1830 { 1831 // Draw the character limit. 14 is length of message overhead "PRIVMSG :\r\n" 1832 const max_limit = 510; 1833 const limit = try std.fmt.allocPrint( ··· 1999 const msg = Message.init(line); 2000 const client = self; 2001 switch (msg.command()) { 2002 - .unknown => {}, 2003 .CAP => { 2004 // syntax: <client> <ACK/NACK> :caps 2005 var iter = msg.paramIterator(); 2006 _ = iter.next() orelse return; // client ··· 2056 } 2057 }, 2058 .RPL_WELCOME => { 2059 const now = try zeit.instant(.{}); 2060 var now_buf: [30]u8 = undefined; 2061 const now_fmt = try now.time().bufPrint(&now_buf, .rfc3339); ··· 2074 // on_connect callback 2075 try lua.onConnect(self.app.lua, client); 2076 }, 2077 - .RPL_YOURHOST => {}, 2078 - .RPL_CREATED => {}, 2079 - .RPL_MYINFO => {}, 2080 .RPL_ISUPPORT => { 2081 // syntax: <client> <token>[ <token>] :are supported 2082 var iter = msg.paramIterator(); 2083 _ = iter.next() orelse return; // client ··· 2095 } 2096 } 2097 }, 2098 - .RPL_LOGGEDIN => {}, 2099 .RPL_TOPIC => { 2100 // syntax: <client> <channel> :<topic> 2101 var iter = msg.paramIterator(); ··· 2110 channel.topic = try self.alloc.dupe(u8, topic); 2111 }, 2112 .RPL_TRYAGAIN => { 2113 if (self.list_modal.expecting_response) { 2114 self.list_modal.expecting_response = false; 2115 try self.list_modal.finish(ctx); ··· 2126 try self.list_modal.addMessage(self.alloc, msg); 2127 }, 2128 .RPL_LISTEND => try self.list_modal.finish(ctx), 2129 - .RPL_SASLSUCCESS => {}, 2130 .RPL_WHOREPLY => { 2131 // syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name> 2132 var iter = msg.paramIterator(); ··· 2224 ctx.redraw = true; 2225 }, 2226 .BOUNCER => { 2227 var iter = msg.paramIterator(); 2228 while (iter.next()) |param| { 2229 if (mem.eql(u8, param, "NETWORK")) { ··· 2780 ); 2781 channel.history_requested = true; 2782 }, 2783 } 2784 } 2785 };
··· 33 /// Number of failed pings before we consider the connection failed 34 const keepalive_retries: i32 = 3; 35 36 + // Gutter (left side where time is printed) width 37 + const gutter_width = 6; 38 + 39 pub const Buffer = union(enum) { 40 client: *Client, 41 channel: *Channel, ··· 74 MARKREAD, 75 NOTICE, 76 PART, 77 + PONG, 78 PRIVMSG, 79 TAGMSG, 80 ··· 112 .{ "MARKREAD", .MARKREAD }, 113 .{ "NOTICE", .NOTICE }, 114 .{ "PART", .PART }, 115 + .{ "PONG", .PONG }, 116 .{ "PRIVMSG", .PRIVMSG }, 117 .{ "TAGMSG", .TAGMSG }, 118 }); ··· 173 completer_shown: bool = false, 174 typing_last_active: u32 = 0, 175 typing_last_sent: u32 = 0, 176 177 pub const Member = struct { 178 user: *User, ··· 1388 return msg; 1389 } 1390 1391 + pub fn dupe(self: Message, alloc: std.mem.Allocator) Allocator.Error!Message { 1392 + return .{ 1393 + .bytes = try alloc.dupe(u8, self.bytes), 1394 + .timestamp_s = self.timestamp_s, 1395 + }; 1396 + } 1397 + 1398 pub const ParamIterator = struct { 1399 params: ?[]const u8, 1400 index: usize = 0, ··· 1682 completer_shown: bool, 1683 1684 list_modal: ListModal, 1685 + messages: std.ArrayListUnmanaged(Message), 1686 + scroll: struct { 1687 + /// Line offset from the bottom message 1688 + offset: u16 = 0, 1689 + /// Message offset into the list of messages. We use this to lock the viewport if we have a 1690 + /// scroll. Otherwise, when offset == 0 this is effectively ignored (and should be 0) 1691 + msg_offset: ?usize = null, 1692 + 1693 + /// Pending scroll we have to handle while drawing. This could be up or down. By convention 1694 + /// we say positive is a scroll up. 1695 + pending: i17 = 0, 1696 + } = .{}, 1697 + can_scroll_up: bool = false, 1698 + message_view: struct { 1699 + mouse: ?vaxis.Mouse = null, 1700 + hovered_message: ?Message = null, 1701 + } = .{}, 1702 1703 pub fn init( 1704 self: *Client, ··· 1726 .text_field = .init(alloc, app.unicode), 1727 .completer_shown = false, 1728 .list_modal = undefined, 1729 + .messages = .empty, 1730 }; 1731 self.list_modal.init(alloc, self); 1732 self.text_field.style = .{ .bg = self.app.blendBg(10) }; ··· 1779 self.channels.deinit(); 1780 1781 self.list_modal.deinit(self.alloc); 1782 + for (self.messages.items) |msg| { 1783 + self.alloc.free(msg.bytes); 1784 + } 1785 + self.messages.deinit(self.alloc); 1786 1787 var user_iter = self.users.valueIterator(); 1788 while (user_iter.next()) |user| { ··· 1859 1860 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena); 1861 { 1862 + const message_view_ctx = ctx.withConstraints(ctx.min, .{ 1863 + .height = max.height - 2, 1864 + .width = max.width, 1865 + }); 1866 + const s = try self.drawMessageView(message_view_ctx); 1867 + try children.append(.{ 1868 + .origin = .{ .col = 0, .row = 0 }, 1869 + .surface = s, 1870 + }); 1871 + } 1872 + 1873 + { 1874 // Draw the character limit. 14 is length of message overhead "PRIVMSG :\r\n" 1875 const max_limit = 510; 1876 const limit = try std.fmt.allocPrint( ··· 2042 const msg = Message.init(line); 2043 const client = self; 2044 switch (msg.command()) { 2045 + .unknown => { 2046 + const msg2 = try msg.dupe(self.alloc); 2047 + try self.messages.append(self.alloc, msg2); 2048 + }, 2049 + .PONG => {}, 2050 .CAP => { 2051 + const msg2 = try msg.dupe(self.alloc); 2052 + try self.messages.append(self.alloc, msg2); 2053 // syntax: <client> <ACK/NACK> :caps 2054 var iter = msg.paramIterator(); 2055 _ = iter.next() orelse return; // client ··· 2105 } 2106 }, 2107 .RPL_WELCOME => { 2108 + const msg2 = try msg.dupe(self.alloc); 2109 + try self.messages.append(self.alloc, msg2); 2110 const now = try zeit.instant(.{}); 2111 var now_buf: [30]u8 = undefined; 2112 const now_fmt = try now.time().bufPrint(&now_buf, .rfc3339); ··· 2125 // on_connect callback 2126 try lua.onConnect(self.app.lua, client); 2127 }, 2128 + .RPL_YOURHOST => { 2129 + const msg2 = try msg.dupe(self.alloc); 2130 + try self.messages.append(self.alloc, msg2); 2131 + }, 2132 + .RPL_CREATED => { 2133 + const msg2 = try msg.dupe(self.alloc); 2134 + try self.messages.append(self.alloc, msg2); 2135 + }, 2136 + .RPL_MYINFO => { 2137 + const msg2 = try msg.dupe(self.alloc); 2138 + try self.messages.append(self.alloc, msg2); 2139 + }, 2140 .RPL_ISUPPORT => { 2141 + const msg2 = try msg.dupe(self.alloc); 2142 + try self.messages.append(self.alloc, msg2); 2143 // syntax: <client> <token>[ <token>] :are supported 2144 var iter = msg.paramIterator(); 2145 _ = iter.next() orelse return; // client ··· 2157 } 2158 } 2159 }, 2160 + .RPL_LOGGEDIN => { 2161 + const msg2 = try msg.dupe(self.alloc); 2162 + try self.messages.append(self.alloc, msg2); 2163 + }, 2164 .RPL_TOPIC => { 2165 // syntax: <client> <channel> :<topic> 2166 var iter = msg.paramIterator(); ··· 2175 channel.topic = try self.alloc.dupe(u8, topic); 2176 }, 2177 .RPL_TRYAGAIN => { 2178 + const msg2 = try msg.dupe(self.alloc); 2179 + try self.messages.append(self.alloc, msg2); 2180 if (self.list_modal.expecting_response) { 2181 self.list_modal.expecting_response = false; 2182 try self.list_modal.finish(ctx); ··· 2193 try self.list_modal.addMessage(self.alloc, msg); 2194 }, 2195 .RPL_LISTEND => try self.list_modal.finish(ctx), 2196 + .RPL_SASLSUCCESS => { 2197 + const msg2 = try msg.dupe(self.alloc); 2198 + try self.messages.append(self.alloc, msg2); 2199 + }, 2200 .RPL_WHOREPLY => { 2201 // syntax: <client> <channel> <username> <host> <server> <nick> <flags> :<hopcount> <real name> 2202 var iter = msg.paramIterator(); ··· 2294 ctx.redraw = true; 2295 }, 2296 .BOUNCER => { 2297 + const msg2 = try msg.dupe(self.alloc); 2298 + try self.messages.append(self.alloc, msg2); 2299 var iter = msg.paramIterator(); 2300 while (iter.next()) |param| { 2301 if (mem.eql(u8, param, "NETWORK")) { ··· 2852 ); 2853 channel.history_requested = true; 2854 }, 2855 + } 2856 + } 2857 + 2858 + fn messageViewWidget(self: *Client) vxfw.Widget { 2859 + return .{ 2860 + .userdata = self, 2861 + .eventHandler = Client.handleMessageViewEvent, 2862 + .drawFn = Client.typeErasedDrawMessageView, 2863 + }; 2864 + } 2865 + 2866 + fn handleMessageViewEvent(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 2867 + const self: *Client = @ptrCast(@alignCast(ptr)); 2868 + switch (event) { 2869 + .mouse => |mouse| { 2870 + if (self.message_view.mouse) |last_mouse| { 2871 + // We need to redraw if the column entered the gutter 2872 + if (last_mouse.col >= gutter_width and mouse.col < gutter_width) 2873 + ctx.redraw = true 2874 + // Or if the column exited the gutter 2875 + else if (last_mouse.col < gutter_width and mouse.col >= gutter_width) 2876 + ctx.redraw = true 2877 + // Or if the row changed 2878 + else if (last_mouse.row != mouse.row) 2879 + ctx.redraw = true 2880 + // Or if we did a middle click, and now released it 2881 + else if (last_mouse.button == .middle) 2882 + ctx.redraw = true; 2883 + } else { 2884 + // If we didn't have the mouse previously, we redraw 2885 + ctx.redraw = true; 2886 + } 2887 + 2888 + // Save this mouse state for when we draw 2889 + self.message_view.mouse = mouse; 2890 + 2891 + // A middle press on a hovered message means we copy the content 2892 + if (mouse.type == .press and 2893 + mouse.button == .middle and 2894 + self.message_view.hovered_message != null) 2895 + { 2896 + const msg = self.message_view.hovered_message orelse unreachable; 2897 + try ctx.copyToClipboard(msg.bytes); 2898 + return ctx.consumeAndRedraw(); 2899 + } 2900 + if (mouse.button == .wheel_down) { 2901 + self.scroll.pending -|= 1; 2902 + ctx.consume_event = true; 2903 + ctx.redraw = true; 2904 + } 2905 + if (mouse.button == .wheel_up) { 2906 + self.scroll.pending +|= 1; 2907 + ctx.consume_event = true; 2908 + ctx.redraw = true; 2909 + } 2910 + if (self.scroll.pending != 0) { 2911 + try self.doScroll(ctx); 2912 + } 2913 + }, 2914 + .mouse_leave => { 2915 + self.message_view.mouse = null; 2916 + self.message_view.hovered_message = null; 2917 + ctx.redraw = true; 2918 + }, 2919 + .tick => { 2920 + try self.doScroll(ctx); 2921 + }, 2922 + else => {}, 2923 + } 2924 + } 2925 + 2926 + fn typeErasedDrawMessageView(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 2927 + const self: *Client = @ptrCast(@alignCast(ptr)); 2928 + return self.drawMessageView(ctx); 2929 + } 2930 + 2931 + fn drawMessageView(self: *Client, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface { 2932 + self.message_view.hovered_message = null; 2933 + const max = ctx.max.size(); 2934 + if (max.width == 0 or max.height == 0 or self.messages.items.len == 0) { 2935 + return .{ 2936 + .size = max, 2937 + .widget = self.messageViewWidget(), 2938 + .buffer = &.{}, 2939 + .children = &.{}, 2940 + }; 2941 + } 2942 + 2943 + var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena); 2944 + 2945 + // Row is the row we are printing on. We add the offset to achieve our scroll location 2946 + var row: i17 = max.height + self.scroll.offset; 2947 + // Message offset 2948 + const offset = self.scroll.msg_offset orelse self.messages.items.len; 2949 + 2950 + const messages = self.messages.items[0..offset]; 2951 + var iter = std.mem.reverseIterator(messages); 2952 + 2953 + assert(messages.len > 0); 2954 + // Initialize sender and maybe_instant to the last message values 2955 + const last_msg = iter.next() orelse unreachable; 2956 + // Reset iter index 2957 + iter.index += 1; 2958 + var this_instant = last_msg.localTime(&self.app.tz); 2959 + 2960 + while (iter.next()) |msg| { 2961 + // Break if we have gone past the top of the screen 2962 + if (row < 0) break; 2963 + 2964 + // Get the server time for the *next* message. We'll use this to decide printing of 2965 + // username and time 2966 + const maybe_next_instant: ?zeit.Instant = blk: { 2967 + const next_msg = iter.next() orelse break :blk null; 2968 + // Fix the index of the iterator 2969 + iter.index += 1; 2970 + break :blk next_msg.localTime(&self.app.tz); 2971 + }; 2972 + 2973 + defer { 2974 + // After this loop, we want to save these values for the next iteration 2975 + if (maybe_next_instant) |next_instant| { 2976 + this_instant = next_instant; 2977 + } 2978 + } 2979 + 2980 + // Draw the message so we have it's wrapped height 2981 + const text: vxfw.Text = .{ .text = msg.bytes }; 2982 + const child_ctx = ctx.withConstraints( 2983 + .{ .width = max.width -| gutter_width, .height = 1 }, 2984 + .{ .width = max.width -| gutter_width, .height = null }, 2985 + ); 2986 + const surface = try text.draw(child_ctx); 2987 + 2988 + // See if our message contains the mouse. We'll highlight it if it does 2989 + const message_has_mouse: bool = blk: { 2990 + const mouse = self.message_view.mouse orelse break :blk false; 2991 + break :blk mouse.col >= gutter_width and 2992 + mouse.row < row and 2993 + mouse.row >= row - surface.size.height; 2994 + }; 2995 + 2996 + if (message_has_mouse) { 2997 + const last_mouse = self.message_view.mouse orelse unreachable; 2998 + // If we had a middle click, we highlight yellow to indicate we copied the text 2999 + const bg: vaxis.Color = if (last_mouse.button == .middle and last_mouse.type == .press) 3000 + .{ .index = 3 } 3001 + else 3002 + .{ .index = 8 }; 3003 + // Set the style for the entire message 3004 + for (surface.buffer) |*cell| { 3005 + cell.style.bg = bg; 3006 + } 3007 + // Create a surface to highlight the entire area under the message 3008 + const hl_surface = try vxfw.Surface.init( 3009 + ctx.arena, 3010 + text.widget(), 3011 + .{ .width = max.width -| gutter_width, .height = surface.size.height }, 3012 + ); 3013 + const base: vaxis.Cell = .{ .style = .{ .bg = bg } }; 3014 + @memset(hl_surface.buffer, base); 3015 + 3016 + try children.append(.{ 3017 + .origin = .{ .row = row - surface.size.height, .col = gutter_width }, 3018 + .surface = hl_surface, 3019 + }); 3020 + 3021 + self.message_view.hovered_message = msg; 3022 + } 3023 + 3024 + // Adjust the row we print on for the wrapped height of this message 3025 + row -= surface.size.height; 3026 + try children.append(.{ 3027 + .origin = .{ .row = row, .col = gutter_width }, 3028 + .surface = surface, 3029 + }); 3030 + 3031 + var style: vaxis.Style = .{ .dim = true }; 3032 + // The time text we will print 3033 + const buf: []const u8 = blk: { 3034 + const time = this_instant.time(); 3035 + // Check our next time. If *this* message occurs on a different day, we want to 3036 + // print the date 3037 + if (maybe_next_instant) |next_instant| { 3038 + const next_time = next_instant.time(); 3039 + if (time.day != next_time.day) { 3040 + style = .{}; 3041 + break :blk try std.fmt.allocPrint( 3042 + ctx.arena, 3043 + "{d:0>2}/{d:0>2}", 3044 + .{ @intFromEnum(time.month), time.day }, 3045 + ); 3046 + } 3047 + } 3048 + 3049 + // if it is the first message, we also want to print the date 3050 + if (iter.index == 0) { 3051 + style = .{}; 3052 + break :blk try std.fmt.allocPrint( 3053 + ctx.arena, 3054 + "{d:0>2}/{d:0>2}", 3055 + .{ @intFromEnum(time.month), time.day }, 3056 + ); 3057 + } 3058 + 3059 + // Otherwise, we print clock time 3060 + break :blk try std.fmt.allocPrint( 3061 + ctx.arena, 3062 + "{d:0>2}:{d:0>2}", 3063 + .{ time.hour, time.minute }, 3064 + ); 3065 + }; 3066 + 3067 + const time_text: vxfw.Text = .{ 3068 + .text = buf, 3069 + .style = style, 3070 + .softwrap = false, 3071 + }; 3072 + const time_ctx = ctx.withConstraints( 3073 + .{ .width = 0, .height = 1 }, 3074 + .{ .width = max.width -| gutter_width, .height = null }, 3075 + ); 3076 + try children.append(.{ 3077 + .origin = .{ .row = row, .col = 0 }, 3078 + .surface = try time_text.draw(time_ctx), 3079 + }); 3080 + } 3081 + 3082 + // Set the can_scroll_up flag. this is true if we drew past the top of the screen 3083 + self.can_scroll_up = row <= 0; 3084 + if (row > 0) { 3085 + row -= 1; 3086 + // If we didn't draw past the top of the screen, we must have reached the end of 3087 + // history. Draw an indicator letting the user know this 3088 + const bot = "━"; 3089 + var writer = try std.ArrayList(u8).initCapacity(ctx.arena, bot.len * max.width); 3090 + try writer.writer().writeBytesNTimes(bot, max.width); 3091 + 3092 + const border: vxfw.Text = .{ 3093 + .text = writer.items, 3094 + .style = .{ .fg = .{ .index = 8 } }, 3095 + .softwrap = false, 3096 + }; 3097 + const border_ctx = ctx.withConstraints(.{}, .{ .height = 1, .width = max.width }); 3098 + 3099 + const unread: vxfw.SubSurface = .{ 3100 + .origin = .{ .col = 0, .row = row }, 3101 + .surface = try border.draw(border_ctx), 3102 + }; 3103 + 3104 + try children.append(unread); 3105 + const no_more_history: vxfw.Text = .{ 3106 + .text = " Perhaps the archives are incomplete ", 3107 + .style = .{ .fg = .{ .index = 8 } }, 3108 + .softwrap = false, 3109 + }; 3110 + const no_history_surf = try no_more_history.draw(border_ctx); 3111 + const new_sub: vxfw.SubSurface = .{ 3112 + .origin = .{ .col = (max.width -| no_history_surf.size.width) / 2, .row = row }, 3113 + .surface = no_history_surf, 3114 + }; 3115 + try children.append(new_sub); 3116 + } 3117 + return .{ 3118 + .size = max, 3119 + .widget = self.messageViewWidget(), 3120 + .buffer = &.{}, 3121 + .children = children.items, 3122 + }; 3123 + } 3124 + 3125 + /// Consumes any pending scrolls and schedules another tick if needed 3126 + fn doScroll(self: *Client, ctx: *vxfw.EventContext) anyerror!void { 3127 + defer { 3128 + // At the end of this function, we anchor our msg_offset if we have any amount of 3129 + // scroll. This prevents new messages from automatically scrolling us 3130 + if (self.scroll.offset > 0 and self.scroll.msg_offset == null) { 3131 + self.scroll.msg_offset = @intCast(self.messages.items.len); 3132 + } 3133 + // If we have no offset, we reset our anchor 3134 + if (self.scroll.offset == 0) { 3135 + self.scroll.msg_offset = null; 3136 + } 3137 + } 3138 + const animation_tick: u32 = 30; 3139 + // No pending scroll. Return early 3140 + if (self.scroll.pending == 0) return; 3141 + 3142 + // Scroll up 3143 + if (self.scroll.pending > 0) { 3144 + // Check if we can scroll up. If we can't, we are done 3145 + if (!self.can_scroll_up) { 3146 + self.scroll.pending = 0; 3147 + return; 3148 + } 3149 + // Consume 1 line, and schedule a tick 3150 + self.scroll.offset += 1; 3151 + self.scroll.pending -= 1; 3152 + ctx.redraw = true; 3153 + return ctx.tick(animation_tick, self.messageViewWidget()); 3154 + } 3155 + 3156 + // From here, we only scroll down. First, we check if we are at the bottom already. If we 3157 + // are, we have nothing to do 3158 + if (self.scroll.offset == 0) { 3159 + // Already at bottom. Nothing to do 3160 + self.scroll.pending = 0; 3161 + return; 3162 + } 3163 + 3164 + // Scroll down 3165 + if (self.scroll.pending < 0) { 3166 + // Consume 1 line, and schedule a tick 3167 + self.scroll.offset -= 1; 3168 + self.scroll.pending += 1; 3169 + ctx.redraw = true; 3170 + return ctx.tick(animation_tick, self.messageViewWidget()); 3171 } 3172 } 3173 };