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