Character codes in typed text; the site and relay at app.kokkoja.com
With the Text tool, Klammertext's codes give letters a keyboard lacks: ^o" is ö, ^s^ ß, ^a1 ā, ^130ED^ a code point of 4 to 6 digits, ^9053 53EF^ a sequence, ^^ a caret, replaced as each code is completed. The public site and relay moved to app.kokkoja.com; the old name redirects. The Markdown documentation is withdrawn from the release while it is converted to Klammertext; the README's links to it are stale until then. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
34ca33d365
commit
89b4a41b2a
15 files changed
+364
-1273
No files matched your search
@@ -0,0 +1,100 @@
|
||||
"""The client's reading of Klammertext's character codes (character_codes.js)
|
||||
against the tables `kdesc -c` prints and the rules of the live Klammertext
|
||||
tree, K/mac (character.cpp, katom_process.cpp; K/src is deprecated), as
|
||||
`ktext -s` showed them on 2026-10-13. Needs node (the type checker's).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
CLIENT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client", "js")
|
||||
|
||||
HARNESS = """
|
||||
import { codeAtEnd } from %s;
|
||||
const texts = JSON.parse(await new Promise((resolve) => {
|
||||
let data = '';
|
||||
process.stdin.on('data', (chunk) => { data += chunk; });
|
||||
process.stdin.on('end', () => resolve(data));
|
||||
}));
|
||||
console.log(JSON.stringify(texts.map((text) => codeAtEnd(text))));
|
||||
"""
|
||||
|
||||
# `kdesc -c`, 2026-10-13: Displayed, Written.
|
||||
DIACRITICS = [("é", "^e'"), ("à", "^a`"), ("ô", "^oh"), ("ñ", "^n~"), ("ü", '^u"'), ("ç", "^cc"),
|
||||
("ō", "^o-"), ("ğ", "^gb"), ("å", "^ar"), ("ė", "^ed"), ("š", "^sw"), ("ő", "^oa")]
|
||||
EXTENDED_LATIN = [("ß", "^s^"), ("ı", "^i^"), ("İ", "^I^"), ("þ", "^t^"), ("Þ", "^T^"), ("ð", "^e^"),
|
||||
("Ð", "^E^"), ("ø", "^o^"), ("Ø", "^O^"), ("đ", "^d^"), ("Đ", "^D^"), ("æ", "^ae^"),
|
||||
("Æ", "^AE^"), ("œ", "^oe^"), ("Œ", "^OE^")]
|
||||
PINYIN = [("ā", "^a1"), ("á", "^a2"), ("ǎ", "^a3"), ("à", "^a4")]
|
||||
|
||||
|
||||
def codes_at_end(texts):
|
||||
harness = HARNESS % json.dumps(os.path.join(CLIENT, "character_codes.js"))
|
||||
result = subprocess.run(["node", "--input-type=module", "-e", harness],
|
||||
input=json.dumps(texts), capture_output=True, text=True, check=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
class CharacterCodesTest(unittest.TestCase):
|
||||
|
||||
def check(self, cases):
|
||||
"""[(text, expected)]: expected is the character, or None."""
|
||||
results = codes_at_end([text for text, _ in cases])
|
||||
for (text, expected), result in zip(cases, results):
|
||||
with self.subTest(text=text):
|
||||
if expected is None:
|
||||
self.assertIsNone(result)
|
||||
else:
|
||||
self.assertEqual(result, {"length": len(text) - len("Vor "), "character": expected})
|
||||
|
||||
def test_the_tables(self):
|
||||
cases = [("Vor " + written, shown) for shown, written in DIACRITICS + EXTENDED_LATIN + PINYIN]
|
||||
self.check(cases)
|
||||
# Precomposed, one code point each, as the tables display them.
|
||||
for shown, _ in DIACRITICS + EXTENDED_LATIN + PINYIN:
|
||||
self.assertEqual(len(shown), 1)
|
||||
|
||||
def test_other_bases_and_capitals(self):
|
||||
"""The base of a diacritic is a letter of any alphabet, not a
|
||||
digit, a space or ASCII punctuation."""
|
||||
self.check([("Vor ^A'", "Á"), ("Vor ^U\"", "Ü"), ("Vor ^Ss", None), ("Vor ^Sw", "Š"),
|
||||
("Vor ^c'", "ć"), ("Vor ^Ccedilla", None), ("Vor ^e1", "ē"), ("Vor ^u3", "ǔ"),
|
||||
("Vor ^x1", None), ("Vor ^q~", "q̃"), ("Vor ^1~", None), ("Vor ^.~", None),
|
||||
("Vor ^ö-", "\u022B"), ("Vor ^α'", "ά")])
|
||||
|
||||
def test_code_points_and_the_caret(self):
|
||||
self.check([("Vor ^00E9^", "é"), ("Vor ^00e9^", "é"), ("Vor ^20AC^", "€"), ("Vor ^^", "^"),
|
||||
("Vor ^00AE^", "®"), # not ^E^ at its end
|
||||
("Vor ^130ED^", "𓃭"), ("Vor ^1F600^", "😀"), ("Vor ^10FFFD^", "\U0010FFFD"),
|
||||
# 1 to 3 digits are not a code point: a control
|
||||
# character, or a collision with the mnemonics.
|
||||
("Vor ^a^", None), ("Vor ^7^", None), ("Vor ^ae^", "æ"), ("Vor ^0E9^", None),
|
||||
# Not a character: beyond the range, a surrogate, a
|
||||
# noncharacter. Stays as typed (an error in a document).
|
||||
("Vor ^110000^", None), ("Vor ^D800^", None), ("Vor ^FFFE^", None), ("Vor ^FDD0^", None)])
|
||||
|
||||
def test_sequences(self):
|
||||
"""Code points separated by spaces between one pair of carets."""
|
||||
self.check([("Vor ^9053 53EF 9053 975E 5A38 9053^", "道可道非娸道"), ("Vor ^9053 53EF^", "道可"),
|
||||
("Vor ^0041 1F600^", "A😀"),
|
||||
("Vor ^ 9053^", None), ("Vor ^9053 ^", None), # no space inside the carets
|
||||
("Vor ^9053 D800^", None), # one bad member spoils it
|
||||
# A single character stands for itself, as in
|
||||
# Klammertext, once a code point is among the members
|
||||
# (Andy, 2026-10-13); without one, x^2 + y^2 stays.
|
||||
("Vor ^9053 a 53EF^", "道a可"), ("Vor ^a 1F600^", "a😀"), ("Vor ^9053 @^", "道@"),
|
||||
("Vor ^2 + y^", None), ("Vor ^a b^", None), ("Vor ^ab 9053^", None),
|
||||
("Vor ^a^", None)])
|
||||
|
||||
def test_an_unfinished_or_no_code(self):
|
||||
"""A keystroke at a time: nothing until the code is complete, and
|
||||
a caret with something else after it stays."""
|
||||
self.check([("Vor ^", None), ("Vor ^o", None), ("Vor ^ae", None), ("Vor ^00E", None),
|
||||
("Vor ^00E9", None), ("Vor ^ a", None), ("Vor ^o\"a", None), ("Vor 2^3", None),
|
||||
("Vor ^@'", None), ("Vor ", None), ("", None)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1346,6 +1346,51 @@ class EndToEndTest(unittest.TestCase):
|
||||
firefox.switch_to(host_tab)
|
||||
firefox.go("about:blank")
|
||||
|
||||
def test_character_codes_in_text(self):
|
||||
"""Andy, 2026-10-13: Klammertext's character codes in a text
|
||||
block, replaced as each is completed; the field's own undo
|
||||
survives a replacement."""
|
||||
firefox = self.firefox
|
||||
host_tab = firefox.current_tab()
|
||||
firefox.go(self.address("Late"))
|
||||
self.enter_name("Typist", "start_session")
|
||||
invitation = firefox.run("return document.getElementById('invitation').value;")
|
||||
guest_tab = firefox.new_tab()
|
||||
firefox.go(self.address("Student", join=invitation))
|
||||
self.enter_name("Onlooker", "join_session")
|
||||
self.wait_until(lambda s: s["page"]["number"] == 1 and len(s["participants"]) == 2, "the onlooker")
|
||||
firefox.switch_to(host_tab)
|
||||
field_text = lambda: firefox.run("return document.querySelector('.text_field').value;")
|
||||
|
||||
firefox.run("document.querySelector('[data-tool=text]').click();")
|
||||
firefox.run(TEXT_GESTURE, 0.2, 0.3, 0.2, 0.3)
|
||||
firefox.type_into(".text_field", 'Gr^u"^s^e, ^a` 2^^3, ^OE^uvre, ^00E9^, m^a1, ^130ED^ ^9053 53EF^')
|
||||
self.assertEqual(field_text(), "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可")
|
||||
self.wait_until(lambda s: s["texts"]["Typist"] and s["texts"]["Typist"][0]["text"] == "Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可",
|
||||
"the text with its codes replaced")
|
||||
# The caret counts UTF-16 units, so the hieroglyph counts twice.
|
||||
self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"),
|
||||
len("Grüße, à 2^3, Œuvre, é, mā, 𓃭 道可".encode("utf-16-le")) // 2)
|
||||
|
||||
# In the middle of the text too, and only at the caret: the ^
|
||||
# left earlier stays.
|
||||
firefox.run("const f = document.querySelector('.text_field'); f.setSelectionRange(2, 2);")
|
||||
firefox.type_into(".text_field", "^o-")
|
||||
self.assertEqual(field_text(), "Grōüße, à 2^3, Œuvre, é, mā, 𓃭 道可")
|
||||
self.assertEqual(firefox.run("return document.querySelector('.text_field').selectionStart;"), 3)
|
||||
|
||||
# The browser's own undo in the field is not lost by a replacement:
|
||||
# one step gives back the code as typed.
|
||||
firefox.chord("\ue009", "z")
|
||||
self.assertEqual(field_text(), "Gr^o-üße, à 2^3, Œuvre, é, mā, 𓃭 道可")
|
||||
|
||||
firefox.run("document.querySelector('[data-tool=pen]').click();")
|
||||
shown = self.wait_until(lambda s: s["texts"]["Typist"] and "üße" in s["texts"]["Typist"][0]["text"],
|
||||
"the finished block")["texts"]["Typist"][0]["text"]
|
||||
firefox.switch_to(guest_tab)
|
||||
self.wait_until(lambda s: s["texts"].get("Typist") and s["texts"]["Typist"][0]["text"] == shown,
|
||||
"the onlooker to see the same text")
|
||||
|
||||
def test_text_being_typed_stays_on_the_page_left(self):
|
||||
"""Andy, 2026-10-06: a guest was typing when the host pressed New,
|
||||
and the text reappeared on the new page."""
|
||||
|
||||
@@ -64,7 +64,7 @@ class QrCodeTest(unittest.TestCase):
|
||||
self.assertEqual(matrix, expected)
|
||||
|
||||
def test_invitation_link(self):
|
||||
link = ("https://kokkoja.andykopra.com/#join=wss://kokkoja.andykopra.com/relay%23"
|
||||
link = ("https://app.kokkoja.com/#join=wss://app.kokkoja.com/relay%23"
|
||||
"AAAAAAAAAAAAAAAAAAAAAA.BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
|
||||
self.check([(link, "M"), ("", "M"), ("Kokkoja", "M"), ("Mahendra & Artha", "L"),
|
||||
("Übung 1: äöü", "M")])
|
||||
|
||||
Reference in new issue
Block a user