aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--surfaces/draw.c36
-rw-r--r--surfaces/pixels.c3
-rw-r--r--surfaces/vega.h10
-rw-r--r--surfaces/web.c7
-rw-r--r--surfaces/width.c8
-rw-r--r--tests/browser.mjs4
-rw-r--r--tests/check.py96
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))