Compare commits

...

15 Commits

Author SHA1 Message Date
0bc2c12eec
xP: handle the M-Tab binding from xC 2022-09-10 19:36:49 +02:00
3330683ad6
xP: handle M-a and M-! bindings from xC 2022-09-10 19:34:01 +02:00
0015d26dc8
xC/xP: support hiding unimportant messages at all 2022-09-10 19:01:42 +02:00
7d5e63be1f
xC: deal with so far unexpected multiline messages
And get rid of an outdated unmarked TODO comment.
2022-09-10 18:51:27 +02:00
e7d0f2380e
xC: split Command.BUFFER_INPUT on newlines 2022-09-10 18:51:27 +02:00
36529a46fd
xP: also scroll to bottom on window resize 2022-09-10 18:10:08 +02:00
632ac992ab
xC/xP: only send buffer stats in the initial sync
The client and frontends track these separately,
there is no need for hard synchronization.
2022-09-10 17:38:33 +02:00
d29e2cbfe8
xP: detect links in the log 2022-09-10 17:18:22 +02:00
240fac4d90
xP: only allow vertical textarea resizing 2022-09-10 17:08:14 +02:00
c06894b291
xP: fix command sequence number generation 2022-09-10 17:05:39 +02:00
9eaf78f823
xP: open links in a new tab/window 2022-09-10 17:05:39 +02:00
5f02dddd11
xP: advance unread marker when the log is visible 2022-09-10 17:05:39 +02:00
6f4a3f4657
xP: advance unread marker in an inactive tab 2022-09-10 17:05:39 +02:00
6387145adc
xP: improve line wrapping 2022-09-10 17:05:38 +02:00
f3cc137342
xC-gen-proto: reduce enums to single bytes
That's already way more than we can possibly use.
2022-09-10 16:06:35 +02:00
7 changed files with 191 additions and 95 deletions

View File

@ -110,11 +110,11 @@ function codegen_enum(name, cg, ctype) {
# XXX: This should also check if it isn't out-of-range for any reason,
# but our usage of sprintf() stands in the way a bit.
CodegenSerialize[name] = "\tstr_pack_i32(w, %s);\n"
CodegenSerialize[name] = "\tstr_pack_i8(w, %s);\n"
CodegenDeserialize[name] = \
"\t{\n" \
"\t\tint32_t v = 0;\n" \
"\t\tif (!msg_unpacker_i32(r, &v) || !v)\n" \
"\t\tint8_t v = 0;\n" \
"\t\tif (!msg_unpacker_i8(r, &v) || !v)\n" \
"\t\t\treturn false;\n" \
"\t\t%s = v;\n" \
"\t}\n"

View File

@ -161,7 +161,7 @@ function codegen_begin() {
print "\tvar n int64"
print "\tif err := json.Unmarshal(data, &n); err != nil {"
print "\t\treturn 0, err"
print "\t} else if n > math.MaxInt32 || n < math.MinInt32 {"
print "\t} else if n > math.MaxInt8 || n < math.MinInt8 {"
print "\t\treturn 0, errors.New(`integer out of range`)"
print "\t} else {"
print "\t\treturn n, nil"
@ -191,7 +191,7 @@ function codegen_enum_value(name, subname, value, cg, goname) {
function codegen_enum(name, cg, gotype, fields) {
gotype = PrefixCamel name
print "type " gotype " int"
print "type " gotype " int8"
print ""
print "const ("
@ -239,12 +239,10 @@ function codegen_enum(name, cg, gotype, fields) {
# XXX: This should also check if it isn't out-of-range for any reason,
# but our usage of sprintf() stands in the way a bit.
CodegenSerialize[name] = \
"\tdata = binary.BigEndian.AppendUint32(data, uint32(%s))\n"
CodegenSerialize[name] = "\tdata = append(data, uint8(%s))\n"
CodegenDeserialize[name] = \
"\tif len(data) >= 4 {\n" \
"\t\t%s = " gotype "(int32(binary.BigEndian.Uint32(data)))\n" \
"\t\tdata = data[4:]\n" \
"\tif len(data) >= 1 {\n" \
"\t\t%s, data = " gotype "(data[0]), data[1:]\n" \
"\t} else {\n" \
"\t\treturn nil, false\n" \
"\t}\n"

View File

@ -15,7 +15,7 @@
# Booleans are one byte each.
# Strings must be valid UTF-8, use u8<> to lift that restriction.
# String and array lengths are encoded as u32.
# Enumeration values automatically start at 1, and are encoded as i32.
# Enumeration values automatically start at 1, and are encoded as i8.
# Any struct or union field may be a variable-length array.
#
# Message framing is done externally, but also happens to prefix u32 lengths.
@ -189,6 +189,8 @@ function defenum( name, ident, value, cg) {
value = readnumber()
if (!value)
fatal("enumeration values cannot be zero")
if (value < -128 || value > 127)
fatal("enumeration value out of range")
expect(accept(","))
append(EnumValues, name, SUBSEP ident)
if (EnumValues[name, ident]++)

View File

@ -19,7 +19,7 @@ struct CommandMessage {
case HELLO:
u32 version;
// If the version check succeeds, the client will receive
// an initial stream of BUFFER_UPDATE, BUFFER_LINE,
// an initial stream of BUFFER_UPDATE, BUFFER_STATS, BUFFER_LINE,
// and finally a BUFFER_ACTIVATE message.
case ACTIVE:
void;
@ -50,6 +50,7 @@ struct EventMessage {
union EventData switch (enum Event {
PING,
BUFFER_UPDATE,
BUFFER_STATS,
BUFFER_RENAME,
BUFFER_REMOVE,
BUFFER_ACTIVATE,
@ -61,13 +62,15 @@ struct EventMessage {
case PING:
void;
case BUFFER_UPDATE:
string buffer_name;
bool hide_unimportant;
case BUFFER_STATS:
string buffer_name;
// These are cumulative, even for lines flushed out from buffers.
// Updates to these values aren't broadcasted, thus handle:
// - BUFFER_LINE by bumping/setting them as appropriate,
// - BUFFER_ACTIVATE by clearing them for the previous buffer
// (this way, they can be used to mark unread messages).
// Any updates received after the initial sync should be ignored.
u32 new_messages;
u32 new_unimportant_messages;
bool highlighted;

83
xC.c
View File

@ -3068,6 +3068,16 @@ relay_prepare_buffer_update (struct app_context *ctx, struct buffer *buffer)
struct relay_event_data_buffer_update *e = &m->data.buffer_update;
e->event = RELAY_EVENT_BUFFER_UPDATE;
e->buffer_name = str_from_cstr (buffer->name);
e->hide_unimportant = buffer->hide_unimportant;
}
static void
relay_prepare_buffer_stats (struct app_context *ctx, struct buffer *buffer)
{
struct relay_event_message *m = relay_prepare (ctx);
struct relay_event_data_buffer_stats *e = &m->data.buffer_stats;
e->event = RELAY_EVENT_BUFFER_STATS;
e->buffer_name = str_from_cstr (buffer->name);
e->new_messages = MIN (UINT32_MAX,
buffer->new_messages_count - buffer->new_unimportant_count);
e->new_unimportant_messages = MIN (UINT32_MAX,
@ -8171,7 +8181,7 @@ irc_try_parse_welcome_for_userhost (struct server *s, const char *m)
strv_free (&v);
}
static bool process_input_utf8
static bool process_input_line
(struct app_context *, struct buffer *, const char *, int);
static void on_autoaway_timer (struct app_context *ctx);
@ -8200,7 +8210,7 @@ irc_on_registered (struct server *s, const char *nickname)
if (command)
{
log_server_debug (s, "Executing \"#s\"", command);
(void) process_input_utf8 (s->ctx, s->buffer, command, 0);
(void) process_input_line (s->ctx, s->buffer, command, 0);
}
int64_t command_delay = get_config_integer (s->config, "command_delay");
@ -9066,12 +9076,21 @@ irc_autosplit_message (struct server *s, const char *message,
- 1 - (int) strlen (s->irc_user_host)
- 1 - fixed_part;
// However we don't always have the full info for message splitting
// Multiline messages can be triggered through hooks and plugins.
struct strv lines = strv_make ();
cstr_split (message, "\r\n", false, &lines);
bool success = true;
for (size_t i = 0; i < lines.len; i++)
{
// We don't always have the full info for message splitting.
if (!space_in_one_message)
strv_append (output, message);
else if (!wrap_message (message, space_in_one_message, output, e))
return false;
return true;
strv_append (output, lines.vector[i]);
else if (!(success =
wrap_message (lines.vector[i], space_in_one_message, output, e)))
break;
}
strv_free (&lines);
return success;
}
static void
@ -9080,12 +9099,11 @@ send_autosplit_message (struct server *s,
const char *prefix, const char *suffix)
{
struct buffer *buffer = str_map_find (&s->irc_buffer_map, target);
// "COMMAND target * :prefix*suffix"
int fixed_part = strlen (command) + 1 + strlen (target) + 1 + 1
+ strlen (prefix) + strlen (suffix);
// We might also want to preserve attributes across splits but
// that would make this code a lot more complicated
struct strv lines = strv_make ();
struct error *e = NULL;
if (!irc_autosplit_message (s, message, fixed_part, &lines, &e))
@ -9831,7 +9849,7 @@ lua_buffer_execute (lua_State *L)
struct lua_weak *wrapper = lua_weak_deref (L, &lua_buffer_info);
struct buffer *buffer = wrapper->object;
const char *line = lua_plugin_check_utf8 (L, 2);
(void) process_input_utf8 (wrapper->plugin->ctx, buffer, line, 0);
(void) process_input_line (wrapper->plugin->ctx, buffer, line, 0);
return 0;
}
@ -13261,13 +13279,13 @@ process_alias (struct app_context *ctx, struct buffer *buffer,
log_global_debug (ctx, "Alias expanded to: ###d: \"#s\"",
(int) i, commands->vector[i]);
for (size_t i = 0; i < commands->len; i++)
if (!process_input_utf8 (ctx, buffer, commands->vector[i], ++level))
if (!process_input_line (ctx, buffer, commands->vector[i], ++level))
return false;
return true;
}
static bool
process_input_utf8_posthook (struct app_context *ctx, struct buffer *buffer,
process_input_line_posthook (struct app_context *ctx, struct buffer *buffer,
char *input, int alias_level)
{
if (*input != '/' || *++input == '/')
@ -13316,35 +13334,27 @@ process_input_hooks (struct app_context *ctx, struct buffer *buffer,
}
static bool
process_input_utf8 (struct app_context *ctx, struct buffer *buffer,
process_input_line (struct app_context *ctx, struct buffer *buffer,
const char *input, int alias_level)
{
// Note that this also gets called on expanded aliases,
// which might or might not be desirable (we can forward "alias_level")
char *processed = process_input_hooks (ctx, buffer, xstrdup (input));
bool result = !processed
|| process_input_utf8_posthook (ctx, buffer, processed, alias_level);
|| process_input_line_posthook (ctx, buffer, processed, alias_level);
free (processed);
return result;
}
static void
process_input (struct app_context *ctx, char *user_input)
process_input (struct app_context *ctx, struct buffer *buffer,
const char *input)
{
char *input;
if (!(input = iconv_xstrdup (ctx->term_to_utf8, user_input, -1, NULL)))
print_error ("character conversion failed for: %s", "user input");
else
{
struct strv lines = strv_make ();
cstr_split (input, "\r\n", false, &lines);
for (size_t i = 0; i < lines.len; i++)
(void) process_input_utf8 (ctx,
ctx->current_buffer, lines.vector[i], 0);
(void) process_input_line (ctx, buffer, lines.vector[i], 0);
strv_free (&lines);
}
free (input);
}
// --- Word completion ---------------------------------------------------------
@ -14193,6 +14203,10 @@ on_toggle_unimportant (int count, int key, void *user_data)
(void) key;
struct app_context *ctx = user_data;
ctx->current_buffer->hide_unimportant ^= true;
relay_prepare_buffer_update (ctx, ctx->current_buffer);
relay_broadcast (ctx);
buffer_print_backlog (ctx, ctx->current_buffer);
return true;
}
@ -14580,7 +14594,7 @@ on_editline_return (EditLine *editline, int key)
}
free (line);
// process_input() expects a multibyte string
// on_pending_input() expects a multibyte string
const LineInfo *info_mb = el_line (editline);
strv_append_owned (&ctx->pending_input,
xstrndup (info_mb->buffer, info_mb->lastchar - info_mb->buffer));
@ -15170,7 +15184,15 @@ on_pending_input (struct app_context *ctx)
{
poller_idle_reset (&ctx->input_event);
for (size_t i = 0; i < ctx->pending_input.len; i++)
process_input (ctx, ctx->pending_input.vector[i]);
{
char *input = iconv_xstrdup
(ctx->term_to_utf8, ctx->pending_input.vector[i], -1, NULL);
if (input)
process_input (ctx, ctx->current_buffer, input);
else
print_error ("character conversion failed for: %s", "user input");
free (input);
}
strv_reset (&ctx->pending_input);
}
@ -15223,6 +15245,8 @@ client_resync (struct client *c)
{
relay_prepare_buffer_update (c->ctx, buffer);
relay_send (c);
relay_prepare_buffer_stats (c->ctx, buffer);
relay_send (c);
LIST_FOR_EACH (struct buffer_line, line, buffer->lines)
{
@ -15372,8 +15396,7 @@ client_process_message (struct client *c,
&m->data.buffer_complete);
break;
case RELAY_COMMAND_BUFFER_INPUT:
(void) process_input_utf8 (c->ctx,
buffer, m->data.buffer_input.text.str, 0);
process_input (c->ctx, buffer, m->data.buffer_input.text.str);
break;
case RELAY_COMMAND_BUFFER_ACTIVATE:
buffer_activate (c->ctx, buffer);

View File

@ -89,15 +89,19 @@ body {
}
.buffer {
display: grid;
grid-template-columns: max-content auto;
grid-template-columns: max-content minmax(0, 1fr);
overflow-y: auto;
}
.log {
padding: .1em .3em;
font-family: monospace;
white-space: pre-wrap;
overflow-y: auto;
}
.log, .content {
padding: .1em .3em;
/* Note: https://bugs.chromium.org/p/chromium/issues/detail?id=1261435 */
white-space: break-spaces;
overflow-wrap: break-word;
}
.leaked {
opacity: 50%;
@ -136,10 +140,6 @@ body {
.mark.action {
color: darkred;
}
.content {
padding: .1em .3em;
white-space: pre-wrap;
}
.content .b {
font-weight: bold;
}
@ -162,6 +162,7 @@ textarea {
margin: 0;
border: 2px inset #eee;
flex-shrink: 0;
resize: vertical;
}
textarea:focus {
outline: none;

View File

@ -110,8 +110,9 @@ class RelayRpc extends EventTarget {
if (typeof params !== 'object')
throw "Method parameters must be an object"
// Left shifts in Javascript convert to a 32-bit signed number.
let seq = ++this.commandSeq
if (seq >= 1 << 32)
if ((seq << 0) != seq)
seq = this.commandSeq = 0
this.ws.send(JSON.stringify({commandSeq: seq, data: params}))
@ -131,13 +132,25 @@ class RelayRpc extends EventTarget {
let rpc = new RelayRpc(proxy)
let buffers = new Map()
let bufferLast = undefined
let bufferCurrent = undefined
let bufferLog = undefined
let bufferAutoscroll = true
function resetBufferStats(b) {
b.newMessages = 0
b.newUnimportantMessages = 0
b.highlighted = false
}
function bufferActivate(name) {
rpc.send({command: 'BufferActivate', bufferName: name})
}
let connecting = true
rpc.connect().then(result => {
buffers.clear()
bufferLast = undefined
bufferCurrent = undefined
bufferLog = undefined
bufferAutoscroll = true
@ -161,13 +174,20 @@ rpc.addEventListener('Ping', event => {
rpc.addEventListener('BufferUpdate', event => {
let e = event.detail, b = buffers.get(e.bufferName)
if (b === undefined) {
buffers.set(e.bufferName, {
lines: [],
newMessages: e.newMessages,
newUnimportantMessages: e.newUnimportantMessages,
highlighted: e.highlighted,
})
buffers.set(e.bufferName, (b = {lines: []}))
resetBufferStats(b)
}
b.hideUnimportant = e.hideUnimportant
})
rpc.addEventListener('BufferStats', event => {
let e = event.detail, b = buffers.get(e.bufferName)
if (b === undefined)
return
b.newMessages = e.newMessages,
b.newUnimportantMessages = e.newUnimportantMessages
b.highlighted = e.highlighted
})
rpc.addEventListener('BufferRename', event => {
@ -179,16 +199,16 @@ rpc.addEventListener('BufferRename', event => {
rpc.addEventListener('BufferRemove', event => {
let e = event.detail
buffers.delete(e.bufferName)
if (e.bufferName === bufferLast)
bufferLast = undefined
})
rpc.addEventListener('BufferActivate', event => {
let old = buffers.get(bufferCurrent)
if (old !== undefined) {
old.newMessages = 0
old.newUnimportantMessages = 0
old.highlighted = false
}
if (old !== undefined)
resetBufferStats(old)
bufferLast = bufferCurrent
let e = event.detail, b = buffers.get(e.bufferName)
bufferCurrent = e.bufferName
bufferLog = undefined
@ -221,18 +241,21 @@ rpc.addEventListener('BufferLine', event => {
return
}
let visible = e.bufferName == bufferCurrent || e.leakToActive
let visible = !document.hidden && bufferLog === undefined &&
(e.bufferName == bufferCurrent || e.leakToActive)
b.lines.push({...line})
if (!visible || b.newMessages || b.newUnimportantMessages) {
if (!(visible || e.leakToActive) ||
b.newMessages || b.newUnimportantMessages) {
if (line.isUnimportant)
b.newUnimportantMessages++
else
b.newMessages++
}
if (e.leakToActive) {
let bc = buffers.get(bufferCurrent)
bc.lines.push({...line, leaked: true})
if (bc.newMessages || bc.newUnimportantMessages) {
if (!visible || bc.newMessages || bc.newUnimportantMessages) {
if (line.isUnimportant)
bc.newUnimportantMessages++
else
@ -273,6 +296,12 @@ for (let i = 0; i < 24; i++) {
// ---- UI ---------------------------------------------------------------------
let linkRE = [
/https?:\/\//,
/([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/,
/[^\[\](){}<>"'\s,.:]/,
].map(r => r.source).join('')
let Toolbar = {
toggleAutoscroll: () => {
bufferAutoscroll = !bufferAutoscroll
@ -304,10 +333,6 @@ let Toolbar = {
}
let BufferList = {
activate: name => {
rpc.send({command: 'BufferActivate', bufferName: name})
},
view: vnode => {
let items = Array.from(buffers, ([name, b]) => {
let classes = [], displayName = name
@ -322,7 +347,7 @@ let BufferList = {
}
}
return m('.item', {
onclick: event => BufferList.activate(name),
onclick: event => bufferActivate(name),
class: classes.join(' '),
}, displayName)
})
@ -345,17 +370,11 @@ let Content = {
},
linkify: (text, attrs) => {
let re = new RegExp([
/https?:\/\//,
/([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/,
/[^\[\](){}<>"'\s,.:]/,
].map(r => r.source).join(''), 'g')
let a = [], end = 0, match
let re = new RegExp(linkRE, 'g'), a = [], end = 0, match
while ((match = re.exec(text)) !== null) {
if (end < match.index)
a.push(m('span', attrs, text.substring(end, match.index)))
a.push(m('a', {href: match[0], ...attrs}, match[0]))
a.push(m('a[target=_blank]', {href: match[0], ...attrs}, match[0]))
end = re.lastIndex
}
if (end < text.length)
@ -425,25 +444,38 @@ let Content = {
}
let Buffer = {
oncreate: vnode => {
if (vnode.dom !== undefined && bufferAutoscroll)
vnode.dom.scrollTop = vnode.dom.scrollHeight
controller: new AbortController(),
onbeforeremove: vnode => {
Buffer.controller.abort()
},
onupdate: vnode => {
Buffer.oncreate(vnode)
if (bufferAutoscroll)
vnode.dom.scrollTop = vnode.dom.scrollHeight
},
oncreate: vnode => {
Buffer.onupdate(vnode)
window.addEventListener('resize', event => Buffer.onupdate(vnode),
{signal: Buffer.controller.signal})
},
view: vnode => {
let lines = []
let b = buffers.get(bufferCurrent)
if (b === undefined)
return
return m('.buffer')
let lastDateMark = undefined
let markBefore = b.lines.length
- b.newMessages - b.newUnimportantMessages
b.lines.forEach((line, i) => {
if (i == markBefore)
lines.push(m('.unread'))
if (line.isUnimportant && b.hideUnimportant)
return
let date = new Date(line.when)
let dateMark = date.toLocaleDateString()
if (dateMark !== lastDateMark) {
@ -451,14 +483,10 @@ let Buffer = {
lastDateMark = dateMark
}
if (i == markBefore)
lines.push(m('.unread'))
let attrs = {}
if (line.leaked)
attrs.class = 'leaked'
// TODO: Make use of isUnimportant.
lines.push(m('.time', {...attrs}, date.toLocaleTimeString()))
lines.push(m(Content, {...attrs}, line))
})
@ -476,8 +504,21 @@ let Log = {
vnode.dom.scrollTop = vnode.dom.scrollHeight
},
linkify: text => {
let re = new RegExp(linkRE, 'g'), a = [], end = 0, match
while ((match = re.exec(text)) !== null) {
if (end < match.index)
a.push(text.substring(end, match.index))
a.push(m('a[target=_blank]', {href: match[0]}, match[0]))
end = re.lastIndex
}
if (end < text.length)
a.push(text.substring(end))
return a
},
view: vnode => {
return m(".log", {}, bufferLog)
return m(".log", {}, Log.linkify(bufferLog))
},
}
@ -542,11 +583,13 @@ let Input = {
let handled = false
switch (event.keyCode) {
case 9:
if (!event.shiftKey)
if (!event.ctrlKey && !event.metaKey && !event.altKey &&
!event.shiftKey)
handled = Input.complete(textarea)
break
case 13:
if (!event.shiftKey)
if (!event.ctrlKey && !event.metaKey && !event.altKey &&
!event.shiftKey)
handled = Input.submit(textarea)
break
}
@ -570,6 +613,7 @@ let Main = {
return m('.xP', {}, [
m('.title', {}, [`xP (${state})`, m(Toolbar)]),
m('.middle', {}, [m(BufferList), m(BufferContainer)]),
// TODO: Indicate hideUnimportant.
m('.status', {}, bufferCurrent),
m(Input),
])
@ -577,3 +621,28 @@ let Main = {
}
window.addEventListener('load', () => m.mount(document.body, Main))
document.addEventListener('keydown', event => {
if (rpc.ws == undefined || event.ctrlKey || event.metaKey)
return
if (event.altKey && event.key == 'Tab') {
if (bufferLast !== undefined)
bufferActivate(bufferLast)
} else if (event.altKey && event.key == 'a') {
for (const [name, b] of buffers)
if (name !== bufferCurrent && b.newMessages) {
bufferActivate(name)
break
}
} else if (event.altKey && event.key == '!') {
for (const [name, b] of buffers)
if (name !== bufferCurrent && b.highlighted) {
bufferActivate(name)
break
}
} else
return
event.preventDefault()
})