diff options
| -rw-r--r-- | surfaces/draw.c | 36 | ||||
| -rw-r--r-- | surfaces/pixels.c | 3 | ||||
| -rw-r--r-- | surfaces/vega.h | 10 | ||||
| -rw-r--r-- | surfaces/web.c | 7 | ||||
| -rw-r--r-- | surfaces/width.c | 8 | ||||
| -rw-r--r-- | tests/browser.mjs | 4 | ||||
| -rw-r--r-- | tests/check.py | 96 |
7 files changed, 149 insertions, 15 deletions
diff --git a/surfaces/draw.c b/surfaces/draw.c index ec0b536..3135705 100644 --- a/surfaces/draw.c +++ b/surfaces/draw.c @@ -6,15 +6,16 @@ static const char *const tone_names[VEGA_TONES] = {"normal", "muted", "accent", /* ---- CLI ------------------------------------------------------------ */ -/* Text for a pipe: control characters become their pictures so a value never - * breaks a line. */ +/* Text for a pipe: what would act on the output instead of being shown + * becomes its picture (vega_cell), so a value never breaks a line nor turns + * one around. */ static void put_text(FILE *out, const char *text, size_t length) { for (size_t at = 0; at < length;) { uint32_t codepoint; size_t size = vega_decode(text, length, at, &codepoint); - if (vega_visible(codepoint) == codepoint) fwrite(text + at, 1, size, out); + if (vega_cell(codepoint) == codepoint) fwrite(text + at, 1, size, out); else { - uint32_t picture = vega_visible(codepoint); + uint32_t picture = vega_cell(codepoint); fputc(0xE0 | (int)(picture >> 12), out); fputc(0x80 | (int)(picture >> 6 & 0x3F), out); fputc(0x80 | (int)(picture & 0x3F), out); @@ -125,10 +126,29 @@ void vega_print(FILE *out, PureApp *app, const VegaScreen *screen, bool color) { /* ---- NUI ------------------------------------------------------------ */ -/* Spoken text: control characters become spaces, and trailing ones go. */ +/* A code point as it is said: what would act on the output (controls, line + * and paragraph separators) as a space, the controls that turn text around + * not at all (0), else itself. */ +static uint32_t spoken(uint32_t codepoint) { + if (vega_visible(codepoint) != codepoint) return ' '; + return vega_cell(codepoint) != codepoint ? 0 : codepoint; +} + +/* Spoken text: as spoken says, without the spaces it ends with. */ static void speak(FILE *out, const char *text, size_t length) { - while (length && ((unsigned char)text[length - 1] <= 0x20)) --length; - for (size_t i = 0; i < length; ++i) fputc((unsigned char)text[i] < 0x20 ? ' ' : text[i], out); + size_t end = 0; + for (size_t at = 0; at < length;) { + uint32_t codepoint, said; + at += vega_decode(text, length, at, &codepoint); + if ((said = spoken(codepoint)) && said != ' ') end = at; + } + for (size_t at = 0; at < end;) { + uint32_t codepoint, said; + size_t size = vega_decode(text, end, at, &codepoint); + if ((said = spoken(codepoint)) == ' ') fputc(' ', out); + else if (said) fwrite(text + at, 1, size, out); + at += size; + } } /* "Item 2 of 3: milk, done." Tones other than normal and muted are said. */ @@ -292,7 +312,7 @@ static void put_cells(Pen *pen, const char *text, size_t length) { for (size_t at = 0; at < length;) { uint32_t codepoint; at += vega_decode(text, length, at, &codepoint); - codepoint = vega_visible(codepoint); + codepoint = vega_cell(codepoint); unsigned width = vega_width(codepoint); if (!width) { if (pen->column && !line[pen->column - 1].mark) { diff --git a/surfaces/pixels.c b/surfaces/pixels.c index d58d5b2..59d5d07 100644 --- a/surfaces/pixels.c +++ b/surfaces/pixels.c @@ -312,8 +312,7 @@ static bool set_type(const VegaGrid *grid, VegaFace *face, uint32_t *out, size_t for (size_t at = 0; at < t->length;) { uint32_t code; at += vega_decode(grid->text + t->at, t->length, at, &code); - code = vega_visible(code); - w.codes[count++] = code == 0x2028 || code == 0x2029 ? ' ' : code; /* one line: no breaks in it */ + w.codes[count++] = vega_visible(code); /* one line: no breaks in it */ } } if (j < n && grid->spans[w.order[j]].row == s->row && grid->spans[w.order[j]].column < end) diff --git a/surfaces/vega.h b/surfaces/vega.h index 0ab87dd..c172d31 100644 --- a/surfaces/vega.h +++ b/surfaces/vega.h @@ -91,8 +91,16 @@ unsigned long vega_evaluations(void); unsigned vega_width(uint32_t codepoint); /* The code point at text[at] (valid UTF-8) and the bytes it takes. */ size_t vega_decode(const char *text, size_t length, size_t at, uint32_t *codepoint); -/* A control character shown as its control picture (U+2400 block), else itself. */ +/* What a value's code point is shown as, on every surface: a control + * character as its control picture (the U+2400 block, DEL too), a C1 control + * as U+FFFD, a line or paragraph separator as U+2424 (a value never breaks + * its line), else itself. */ uint32_t vega_visible(uint32_t codepoint); +/* In a terminal's cell (the TUI, the CLI) also the controls that would turn + * the rest of the terminal's line around (embeddings, overrides, isolates), + * as U+FFFD: pixels lay a value's text out themselves, within its place. */ +uint32_t vega_cell(uint32_t codepoint); +/* The cells text takes, as vega_cell shows it. */ unsigned vega_text_width(const char *text, size_t length); /* CLI: everything, as plain lines for pipes; color only when asked. */ diff --git a/surfaces/web.c b/surfaces/web.c index a4759c4..dfa9470 100644 --- a/surfaces/web.c +++ b/surfaces/web.c @@ -193,6 +193,8 @@ static const char *const page_parts[] = { "ul{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden}" "li,tbody tr{cursor:pointer}li{display:flex;gap:10px;padding:7px 12px;border-top:1px solid var(--line)}" "li:first-child{border-top:0}.label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:pre}" +/* Each value its own paragraph as bidi goes: what turns text around stays in it. */ +".label,.detail,.badge,td,th,dd,dt,h1,h2 span{unicode-bidi:isolate}" ".detail{color:var(--muted);white-space:pre}.badge{font-size:12px;padding:1px 8px;border:1px solid currentColor;" "border-radius:10px;align-self:center}[aria-selected=true]{background:var(--focus);color:#fff}" "table{width:100%;border-collapse:collapse;border:1px solid var(--line)}th{text-align:left;color:var(--muted);" @@ -229,7 +231,10 @@ static const char *const page_parts[] = { "finally{busy=false;if(again){again=false;refresh()}}}" "function fit(parent,count,make){while(parent.children.length>count)parent.lastChild.remove();" "while(parent.children.length<count)parent.append(make());return[...parent.children]}" -"function text(node,value){if(node.textContent!==value)node.textContent=value}" +/* A value shown as the other surfaces show it (vega_visible): controls as their pictures. */ +"function visible(value){return String(value).replace(/[\\x00-\\x1f\\x7f-\\x9f\\u2028\\u2029]/g,c=>{const n=c.charCodeAt(0);" +"return String.fromCharCode(n<32?0x2400+n:n===127?0x2421:n<160?0xfffd:0x2424)})}" +"function text(node,value){value=visible(value);if(node.textContent!==value)node.textContent=value}" "function attr(node,name,value){if(value==null)node.removeAttribute(name);" "else if(node.getAttribute(name)!==String(value))node.setAttribute(name,value)}" "const views=new Map();" diff --git a/surfaces/width.c b/surfaces/width.c index 1e893f6..645e309 100644 --- a/surfaces/width.c +++ b/surfaces/width.c @@ -46,9 +46,15 @@ uint32_t vega_visible(uint32_t codepoint) { if (codepoint < 0x20) return 0x2400 + codepoint; if (codepoint == 0x7F) return 0x2421; if (codepoint >= 0x80 && codepoint < 0xA0) return 0xFFFD; + if (codepoint == 0x2028 || codepoint == 0x2029) return 0x2424; return codepoint; } +uint32_t vega_cell(uint32_t codepoint) { + if ((codepoint >= 0x202A && codepoint <= 0x202E) || (codepoint >= 0x2066 && codepoint <= 0x2069)) return 0xFFFD; + return vega_visible(codepoint); +} + size_t vega_decode(const char *text, size_t length, size_t at, uint32_t *codepoint) { const unsigned char *p = (const unsigned char *)text + at; size_t left = length - at, need = p[0] < 0x80 ? 1 : p[0] < 0xE0 ? 2 : p[0] < 0xF0 ? 3 : 4; @@ -64,7 +70,7 @@ unsigned vega_text_width(const char *text, size_t length) { for (size_t at = 0; at < length;) { uint32_t codepoint; at += vega_decode(text, length, at, &codepoint); - width += vega_width(vega_visible(codepoint)); + width += vega_width(vega_cell(codepoint)); } return width; } diff --git a/tests/browser.mjs b/tests/browser.mjs index 0f77585..15c94a8 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -129,6 +129,10 @@ try { [a.options.map(name), await evaluate(places)]); const entry = a.nodes.find(n => role(n) === 'textbox'); check('the text input is named for its input', entry && name(entry) === 'add', entry); + // A value holding what would act on a screen: shown as the other surfaces show it, its bidi kept within it. + await evaluate(`post('/text', 'esc \\u001b[31m sep \\u2028 rlo \\u202e.')`); + await until('a value is shown as the other surfaces show it', `document.querySelectorAll('li')[4]?.querySelector('.label').textContent === 'esc \\u241b[31m sep \\u2424 rlo \\u202e.'`); + check('its bidi stays within it', await evaluate(`getComputedStyle(document.querySelectorAll('li')[4].querySelector('.label')).unicodeBidi`) === 'isolate', null); await finish(true); } catch (error) { await finish(false, String(error.message || error)); diff --git a/tests/check.py b/tests/check.py index 7bb4c41..4f87d20 100644 --- a/tests/check.py +++ b/tests/check.py @@ -493,7 +493,7 @@ class WebSurface: def browser(vega, module, work): - """The page in headless Chromium: keys, a click on a row, typed text.""" + """The page in headless Chromium: keys, a click on a row, typed text, a value holding controls.""" import shutil if not shutil.which('node') or not shutil.which('chromium'): return 'skipped' @@ -507,7 +507,8 @@ def browser(vega, module, work): outcome = json.loads(result.stdout) assert result.returncode == 0 and outcome['ok'], outcome after = run(vega, 'cli', module, '--store', store, stdin='show\n').stdout.splitlines() - assert after == ['To do', 'left: 3 of 4', 'tasks_list (4)', ' a [done] (muted)', ' b', ' c', '> 브라우저'], after + assert after == ['To do', 'left: 4 of 5', 'tasks_list (5)', ' a [done] (muted)', ' b', ' c', ' 브라우저', + '> esc \u241b[31m sep \u2424 rlo \ufffd.'], after return len(outcome['steps']) @@ -1807,6 +1808,96 @@ def shared_board(serve, module, work): stop(name) return {'cards': len(together), 'after_joining': len(joined)} +SCREEN_APP = """use vega +record Line { + t: text +} +state lines: list of Line +state at: index of lines +input add = text +input up = key ArrowUp +input down = key ArrowDown +on add { + lines.append(Line { t: add }) +} +on up.down { at = at - 1 } +on down.down { at = at + 1 } +view lines_list = each l in lines { + label = l.t +} +view lines_list_focus = at.position +""" +# What would act on a screen instead of being shown: escape sequences, C0 and C1 controls, DEL, line and paragraph +# separators, the controls that turn text around (embeddings, overrides, isolates); and what is shown as it is. +SCREEN_TEXTS = ['plain', 'esc \x1b[31mred\x1b[0m \x1b]0;title\x07', 'csi8 \u009b31m c1', 'nel \u0085 x', 'del \x7f x', + 'nul \x00 bell \x07', 'tab\tnl\nx\rcr', 'sep \u2028 x \u2029 y', 'rlo \u202e abc', 'lre \u202a x \u202c', + 'isolate \u2068abc\u2069', 'rlm \u200f x', 'zwj \U0001f469\u200d\U0001f4bb', 'bom \ufeff', '한글 שלום', + 'trail \x01 '] + + +def screen_visible(c): + """As Vega's vega_visible: a control as its picture, a C1 control as U+FFFD, a line or paragraph separator as U+2424.""" + n = ord(c) + return (chr(0x2400 + n) if n < 0x20 else '␡' if n == 0x7f else '�' if 0x80 <= n < 0xa0 else + '' if n in (0x2028, 0x2029) else c) + + +def screen_cell(c): + """As vega_cell: in a terminal's cell, the controls that turn text around as U+FFFD too.""" + n = ord(c) + return '�' if 0x202a <= n <= 0x202e or 0x2066 <= n <= 0x2069 else screen_visible(c) + + +def screen_meaning(vega, aup, work): + """A value is shown within its place, as itself, on every surface; what + would act on the screen instead is shown as a picture (the CLI and the + TUI's cells), said as a space or not at all (speech), and kept as it is + in the web surface's JSON, whose page shows the pictures and keeps each + value's bidi within it. No byte a value holds reaches a terminal as a + control.""" + source = work / 'screen.aup' + source.write_text(SCREEN_APP) + module = work / 'screen.pmod' + built = run(aup, 'build', source, '-o', module, env=dict(os.environ, AUP_PATH=str(ROOT / 'lib'))) + assert built.returncode == 0, built.stderr + script = ''.join('text ' + t.encode().hex() + '\n' for t in SCREEN_TEXTS) + cells = [''.join(map(screen_cell, t)) for t in SCREEN_TEXTS] + acting = {chr(n) for n in [*range(0x20), 0x7f, *range(0x80, 0xa0), 0x2028, 0x2029, *range(0x202a, 0x202f), + *range(0x2066, 0x206a)]} - {'\n'} + + def output(*surface, stdin): + result = subprocess.run([str(vega), *map(str, surface)], input=stdin.encode(), capture_output=True, timeout=60) + assert result.returncode == 0, (surface, result.stderr[-400:]) + shown = result.stdout.decode('utf-8') + stray = {hex(ord(c)) for c in shown if c in acting} + assert not stray or (surface[0] == 'tui' and stray == {'0x9'}), (surface, stray) # the dump's own tabs + return shown.splitlines() + + lines = output('cli', module, stdin=script + 'show\n') + listed = lines[lines.index(f'lines_list ({len(cells)})') + 1:][:len(cells)] + assert [line[2:] for line in listed] == cells, [(a[2:], b) for a, b in zip(listed, cells) if a[2:] != b] + dump = output('tui', module, '--dump', f'120x{len(cells) + 6}', stdin=script + 'show\n') + rows = dump[dump.index(f'|lines_list ({len(cells)})') + 1:][:len(cells)] + assert [row[3:].rstrip(' ') for row in rows] == [c.rstrip(' ') for c in cells], (rows, cells) + said = output('say', module, stdin=script + 'key ArrowDown\n' * len(cells)) + spoken = [''.join(' ' if screen_visible(c) != c else '' if screen_cell(c) != c else c for c in t).rstrip(' ') + for t in SCREEN_TEXTS] + heard = {line.split(': ', 1)[0]: line.split(': ', 1)[1] for line in said if line.startswith('Item ')} + for k, words in enumerate(spoken): + assert heard[f'Item {k + 1} of {len(cells)}'] == words + '.', (k, heard[f'Item {k + 1} of {len(cells)}'], words) + surface = WebSurface(vega, module) + try: + for text in SCREEN_TEXTS: + assert surface.call('POST', '/text', text.encode())[0] == 200 + status, body = surface.call('GET', f'/screen?rows={len(cells)}&first=0') + assert status == 200 + labels = [item['label'] for item in json.loads(body)['parts'][0]['items']] + assert labels == SCREEN_TEXTS, [(a, b) for a, b in zip(labels, SCREEN_TEXTS) if a != b] + finally: + surface.stop() + return {'values': len(SCREEN_TEXTS), 'surfaces': 4} + + def main(): parser = argparse.ArgumentParser() parser.add_argument('--vega', type=Path, required=True) @@ -1857,6 +1948,7 @@ def main(): report['live_display'] = live_display(vega, modules['todo'], work) report['node_display'] = node_display(vega, args.serve.resolve(), modules['todo'], work) report['messenger'] = messenger(vega, args.serve.resolve(), modules['messenger'], work) + report['screen_meaning'] = screen_meaning(vega, aup, work) report['shared_board'] = shared_board(args.serve.resolve(), modules['shared-board'], work) print(json.dumps(report)) |
