diff --git a/CLAUDE.md b/CLAUDE.md
index 61cd4c8..50d317c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -116,6 +116,16 @@ Use the build scripts in `buildapp/`:
- `My_BASIC_Programs/`: Example BASIC programs for testing
- TVDOS filesystem uses custom format with specialised drivers
+### TSVM JavaScript Source Encoding
+
+**Do not normalise `\uXXXX` or `\xXX` escapes in .js / .mjs files that run inside
+TSVM.** TSVM's character set is not Unicode, and the JS string literal parser
+behaves differently for raw bytes vs. escape sequences. Both forms appear in
+existing code intentionally — leave each one as-is. When writing new content,
+prefer raw UTF-8 characters in string literals (e.g. write the character `ù`
+directly, rather than a `\uXXXX`-style escape) unless you are matching a
+pattern already established in the surrounding code.
+
## Videotron2K
The Videotron2K is a specialised video display controller with:
diff --git a/assets/disk0/tvdos/bin/command.js b/assets/disk0/tvdos/bin/command.js
index d9e735a..e45a921 100644
--- a/assets/disk0/tvdos/bin/command.js
+++ b/assets/disk0/tvdos/bin/command.js
@@ -77,56 +77,31 @@ function printmotd() {
let motd = motdFile.sread().trim()
let width = con.getmaxyx()[1]
+ let ts = require("typesetter")
if (goFancy) {
let margin = 4
let internalWidth = width - 2*margin
+ let textWidth = internalWidth - 2 // one space of padding inside each ribbon edge
- con.color_pair(255,253) // white text, transparent back (initial ribbon)
-
- let [cy, cx] = con.getyx()
-
- con.mvaddch(cy, 4, 16);con.curs_right();print(' ')
-
- const PCX_INIT = margin - 2
- let tcnt = 0
- let pcx = PCX_INIT
- con.color_pair(240,253) // black text, white back (first line of text)
- while (tcnt <= motd.length) {
- let char = motd.charAt(tcnt)
-
- if (char != '\n') {
- // prevent the line starting from ' '
- if (pcx != PCX_INIT || char != ' ') {
- print(motd.charAt(tcnt))
- }
- pcx += 1
- }
-
- if ('\n' == char || pcx % internalWidth == 0 && pcx != 0 || tcnt == motd.length) {
- // current line ending
- let [_, ncx] = con.getyx()
- for (let k = 0; k < width - margin - ncx + 1; k++) print(' ')
- con.color_pair(255,253) // white text, transparent back
- con.addch(17);println()
-
- if (tcnt == motd.length) break
-
- // next line header
- let [ncy, __] = con.getyx()
- con.color_pair(255,253) // white text, transparent back
- con.mvaddch(ncy, 4, 16);con.curs_right();print(' ');con.color_pair(240,253) // black text, white back (subsequent lines of the text)
- pcx = PCX_INIT
- }
-
- tcnt += 1
- }
-
+ let lines = ts.typeset(motd, textWidth)
+ lines.forEach(line => {
+ let [cy, _cx] = con.getyx()
+ con.color_pair(255,253) // ribbon edge: white text, transparent back
+ con.mvaddch(cy, margin, 16); con.curs_right()
+ print(' ')
+ con.color_pair(240,253) // body: black text, white back
+ print(line)
+ con.color_pair(255,253)
+ print(' ')
+ con.addch(17); println()
+ })
con.reset_graphics()
}
else {
println()
- println(motd)
+ let lines = ts.typeset(motd, width)
+ lines.forEach(line => println(line))
}
println()
diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js
index a490347..8db681f 100644
--- a/assets/disk0/tvdos/bin/taut.js
+++ b/assets/disk0/tvdos/bin/taut.js
@@ -2297,7 +2297,7 @@ function ordersInput(wo, event) {
// PATTERN EDITOR PANEL
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
-// Returns the visual width of a TSVM string (handles Nnu escape sequences)
+// Returns the visual width of a TSVM string (handles \u0084Nnu escape sequences)
function visWidth(s) {
let w = 0, i = 0
while (i < s.length) {
diff --git a/assets/disk0/tvdos/bin/taut_helpmsg.js b/assets/disk0/tvdos/bin/taut_helpmsg.js
index 8eebf8e..ef89491 100644
--- a/assets/disk0/tvdos/bin/taut_helpmsg.js
+++ b/assets/disk0/tvdos/bin/taut_helpmsg.js
@@ -1,6 +1,8 @@
if (!_G.TAUT) _G.TAUT = {};
let help = {}
+let ts = require("typesetter")
+
////////////////////////////////////////////////////////////////////////////////////////////////////
/*
@@ -90,7 +92,7 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
&bul;W&mdot;E&mdot;R : toggles timeline view mode. W-most detailed, R-most abridged
&bul;n : toggles soloing of the selected voice
&bul;m : toggles muting of the selected voice
-&bul;[&mdot;] : changes tick rate of playhead
+&bul;[&mdot;] : changes tick rate of playhead
EDIT MODE
\u00B7${'\u00B8'.repeat(9)}\u00B9
@@ -143,259 +145,12 @@ Mixer flags define how should the mixer behave.
// assemble help text pieces to complete help message
-const SCRW = con.getmaxyx()[1]
const HRULE = '\u00B4\u00B5'.repeat((_G.TAUT.HELPMSG_WIDTH) >>> 1) + '\n'
-// Display-command palette. taut.js's popup uses (HELP_COL_TEXT on background) as the
-// default colour pair, so embedded `\x1B[38;5;Nm` codes switch foreground only.
-const HELP_COL_TEXT = 239 // popup body default (== colWHITE)
-const HELP_COL_EMPH = 230 // ... highlight (== colVoiceHdr)
-const HELP_COL_BRAND = 211 // first half of "Microtone"
-const HELP_COL_BRAND_DIM = 239 // second half of "Microtone"
-
-const fgEsc = (n) => `\x1B[38;5;${n}m`
-const ESC_DEFAULT = fgEsc(HELP_COL_TEXT)
-const ESC_EMPH = fgEsc(HELP_COL_EMPH)
-const MICROTONE = `${fgEsc(HELP_COL_BRAND)}Micro${fgEsc(HELP_COL_BRAND_DIM)}tone${ESC_DEFAULT}`
-
-// Replace &xxx; entities with their final printable representations.
-function expandEntities(s) {
- return s
- .replaceAll('µtone;', MICROTONE)
- .replaceAll('&bul;', '\u00F9')
- .replaceAll('&ddot;', '\u008419u')
- .replaceAll('&mdot;', '\u00FA')
- .replaceAll('&updn;', '\u008418u')
- .replaceAll('&udlr;', '\u008428u\u008429u')
- .replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1')
- .replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
- .replaceAll(' ', '\u007F')
- .replaceAll('', '')
- .replaceAll('<', '<')
- .replaceAll('>', '>')
- .replaceAll('&demisharp;', '\u0080\u0081')
- .replaceAll('♯', '\u0082\u0083')
- .replaceAll('&sesquisharp;', '\u0084132u\u0085')
- .replaceAll('&doublesharp;', '\u0086\u0087')
- .replaceAll('&triplesharp;', '\u0088\u0089')
- .replaceAll('&quadsharp;', '\u008A\u008B')
- .replaceAll('&demiflat;', '\u008C\u008D')
- .replaceAll('♭', '\u008E\u008F')
- .replaceAll('&sesquiflat;', '\u0090\u0091')
- .replaceAll('&doubleflat;', '\u0092\u0093')
- .replaceAll('&tripleflat;', '\u0094\u0095')
- .replaceAll('&quadflat;', '\u0096\u0097')
- .replaceAll('&accuptick;', '\u009A')
- .replaceAll('&accdntick;', '\u009B')
- .replaceAll('&accupup;', '\u009C')
- .replaceAll('&accdndn;', '\u009D')
-}
-
-// Tokenise a (post-entity-expansion) line. Returns an array of:
-// {type:'word', text:String, w:int} - non-breakable run of visible chars (may carry ANSI escapes)
-// {type:'sp'} - a single soft space (eligible for break/expansion)
-// {type:'anchor', open:Boolean} - / markers (zero width)
-//
-// Width accounting:
-// - ANSI escapes (`\x1B[...m`) : 0 visible chars
-// - TSVM unicode escapes (`..u`) : 1 visible char
-// - non-breaking space ( ) : 1 visible char (consumed as part of a word)
-// - soft hyphen () : dropped (not implemented as a break point)
-// - everything else : 1 visible char
-function tokenise(line) {
- const tokens = []
- let buf = ''
- let bufW = 0
- let i = 0
-
- const flushWord = () => {
- if (buf.length > 0) {
- tokens.push({type: 'word', text: buf, w: bufW})
- buf = ''
- bufW = 0
- }
- }
-
- while (i < line.length) {
- // inline tags (case-sensitive for , case-insensitive for )
- if (line.slice(i, i + 3) === '') { buf += ESC_EMPH; i += 3; continue }
- if (line.slice(i, i + 4) === '') { buf += ESC_DEFAULT; i += 4; continue }
- const head3 = line.slice(i, i + 3).toLowerCase()
- const head4 = line.slice(i, i + 4).toLowerCase()
- if (head3 === '') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
- if (head4 === '') { flushWord(); tokens.push({type: 'anchor', open: false}); i += 4; continue }
-
- const c = line[i]
- const cc = line.charCodeAt(i)
-
- if (cc === 0x1B) {
- // pre-existing ANSI escape - copy verbatim, zero visible width
- const m = line.indexOf('m', i)
- const end = (m < 0) ? line.length : m + 1
- buf += line.slice(i, end)
- i = end
- }
- else if (cc === 0x84) {
- // TSVM u escape - copy verbatim, one visible char
- const u = line.indexOf('u', i)
- const end = (u < 0) ? line.length : u + 1
- buf += line.slice(i, end)
- bufW += 1
- i = end
- }
- else if (c === ' ') {
- flushWord()
- tokens.push({type: 'sp'})
- i += 1
- }
- else if (cc === 0x00AD) {
- // soft hyphen: drop (no break-point handling for now)
- i += 1
- }
- else {
- buf += c
- bufW += 1
- i += 1
- }
- }
- flushWord()
- return tokens
-}
-
-// Build wrapped lines from a token stream then format each one according to alignment.
-// Returns an array of strings, each exactly `width` visible chars wide (padded with
-// trailing spaces) so the caller can blit them without further math.
-function wrapAndAlign(tokens, width, alignment) {
- const lines = [] // each: {tokens, indent, contentW}
- let curTokens = []
- let curW = 0
- let curIndent = 0
- let nextIndent = 0 // indent the *next* flushed line should use
-
- const flushLine = () => {
- // strip trailing soft spaces
- while (curTokens.length > 0 && curTokens[curTokens.length - 1].type === 'sp') {
- curTokens.pop()
- curW -= 1
- }
- lines.push({tokens: curTokens, indent: curIndent, contentW: curW})
- curTokens = []
- curW = 0
- curIndent = nextIndent
- }
-
- for (const tok of tokens) {
- if (tok.type === 'anchor') {
- // anchor opens at the current visible column (accounting for indent)
- if (tok.open) nextIndent = curIndent + curW
- else nextIndent = 0
- continue
- }
-
- if (tok.type === 'sp') {
- // ignore leading soft spaces on a fresh line
- if (curW === 0) continue
- // hard wrap if the line is already at the right edge
- if (curIndent + curW + 1 > width) { flushLine(); continue }
- curTokens.push(tok)
- curW += 1
- continue
- }
-
- // word
- const tw = tok.w
- if (curIndent + curW + tw > width) {
- flushLine()
- // word too wide for the wrapped line: emit it on its own row (possibly clipped by terminal)
- if (curIndent + tw > width) {
- curTokens.push(tok)
- curW += tw
- flushLine()
- continue
- }
- }
- curTokens.push(tok)
- curW += tw
- }
-
- if (curTokens.length > 0 || lines.length === 0) flushLine()
-
- return lines.map((line, i) => formatLine(line, width, alignment, i === lines.length - 1))
-}
-
-function formatLine(line, totalWidth, alignment, isLast) {
- if (line.tokens.length === 0) return ' '.repeat(totalWidth)
-
- const indent = ' '.repeat(line.indent)
- const remaining = totalWidth - line.indent - line.contentW
- const pad = (n) => (n > 0) ? ' '.repeat(n) : ''
- const flatText = () => line.tokens.map(t => (t.type === 'sp') ? ' ' : t.text).join('')
-
- if (alignment === 'c') {
- const left = remaining >> 1
- return indent + pad(left) + flatText() + pad(remaining - left)
- }
- if (alignment === 'r') return indent + pad(remaining) + flatText()
- if (alignment === 'l') return indent + flatText() + pad(remaining)
-
- // justified: only expand spaces when there's slack and we're not on the
- // last (or single) wrapped line
- if (isLast || remaining <= 0) return indent + flatText() + pad(remaining)
-
- const spaceCount = line.tokens.reduce((n, t) => n + (t.type === 'sp' ? 1 : 0), 0)
- if (spaceCount === 0) return indent + flatText() + pad(remaining)
-
- const baseExtra = (remaining / spaceCount) | 0
- let leftover = remaining - baseExtra * spaceCount
-
- let out = indent
- for (const tok of line.tokens) {
- if (tok.type === 'sp') {
- const extra = baseExtra + (leftover > 0 ? 1 : 0)
- if (leftover > 0) leftover -= 1
- out += ' '.repeat(1 + extra)
- } else {
- out += tok.text
- }
- }
- return out
-}
-
-// Process a single source line: peel a leading // alignment tag (if present),
-// strip its matching close tag, then tokenise + wrap.
-function typesetSourceLine(line, width) {
- if (line.length === 0) return [' '.repeat(width)]
-
- let alignment = 'j' // justified default
- const startMatch = line.match(/^<([crl])>/i)
- if (startMatch) {
- alignment = startMatch[1].toLowerCase()
- line = line.slice(startMatch[0].length)
- const closeRe = new RegExp(`${alignment}>$`, 'i')
- line = line.replace(closeRe, '')
- }
-
- const tokens = tokenise(line)
- return wrapAndAlign(tokens, width, alignment)
-}
-
-function typesetText(text, width) {
- text = expandEntities(text)
- const out = []
- for (const srcLine of text.split('\n')) {
- for (const outLine of typesetSourceLine(srcLine, width)) out.push(outLine)
- }
- return out
-}
-
-function typeset(text, customWidth) {
- let typesetWidth = customWidth
- if (typesetWidth === undefined) typesetWidth = _G.TAUT.HELPMSG_WIDTH
- if (typesetWidth === undefined) {
- const currentPosX = con.getyx()[1] // 1-indexed
- typesetWidth = SCRW - currentPosX + 1
- }
- return typesetText(text, typesetWidth)
+// taut.js's popup uses (HELP_COL_TEXT on background) as the default colour pair.
+// The shared typesetter module owns the palette and the markup expander.
+function typeset(text) {
+ return ts.typeset(text, _G.TAUT.HELPMSG_WIDTH)
}
let helpMessages = [ // index: taut.js PANEL_NAMES
@@ -410,7 +165,7 @@ let helpMessages = [ // index: taut.js PANEL_NAMES
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))
help.typeset = typeset
-help.COL_TEXT = HELP_COL_TEXT
-help.COL_EMPH = HELP_COL_EMPH
+help.COL_TEXT = ts.COL_TEXT
+help.COL_EMPH = ts.COL_EMPH
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;
diff --git a/assets/disk0/tvdos/hopper/textedit.hop.per b/assets/disk0/tvdos/hopper/textedit.hop.per
new file mode 100644
index 0000000..e993c13
--- /dev/null
+++ b/assets/disk0/tvdos/hopper/textedit.hop.per
@@ -0,0 +1,12 @@
+HopperManifestVersion:1
+HopperPackageName:textedit
+HopperPackageVersion:1.0.0
+HopperPackageMaintainer:CuriousTorvald
+HopperProvides:edit;
+HopperRequires:tvdos 1.*
+ProperName:edit.js
+ProperAuthor:CuriousTorvald
+ProperDescription:TVDOS default text editor
+Licence:MIT
+SupportMe:https://github.com/sponsors/curioustorvald/
+SystemPackagePath:/tvdos/bin/edit.js
diff --git a/assets/disk0/tvdos/hopper/tvdos.hop.per b/assets/disk0/tvdos/hopper/tvdos.hop.per
index 8000c8a..90a9f5a 100644
--- a/assets/disk0/tvdos/hopper/tvdos.hop.per
+++ b/assets/disk0/tvdos/hopper/tvdos.hop.per
@@ -9,4 +9,4 @@ ProperAuthor:CuriousTorvald
ProperDescription:TSVM Disk Operating System
Licence:MIT
SupportMe:https://github.com/sponsors/curioustorvald/
-SystemPackagePath:/tvdos/TVDOS.SYS
+SystemPackagePath:/tvdos/TVDOS.SYS;/tvdos/hyve.SYS;/tvdos/HSDPADRV.SYS;/tvdos/bin/command.js;/tvdos/sbin/sysctl.js;/tvdos/include/font.mjs;/tvdos/include/keysym.mjs;/tvdos/include/mload.mjs;/tvdos/include/playgui.mjs;/tvdos/include/typesetter.mjs