diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index f5342dd6e3..167e3b4496 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -38,6 +38,12 @@ import { getCommandDescription, isPromptOnlyCommand } from "../lib/prompt-comman import { parseFightCommand } from "../lib/fight/challenge.mjs"; import { FIGHT_MANIFEST } from "../lib/fight/protocol.mjs"; import { createHandleAutocomplete } from "../lib/autocomplete.mjs"; +import { + escapeColorCodes, + escapedIndexMap, + mapColorCodes, + stripColorCodes, +} from "../lib/color-codes.mjs"; import { iOS } from "../lib/platform.mjs"; // 🎨 Handle Colors System @@ -135,21 +141,13 @@ function getFontTimestampGap(fontId) { return fontConfig.timestampGap ?? 4; } +// What the text looks like once the `\color\` markup is gone — the string to +// measure, so a measured width matches a drawn width. Shares one scanner with +// the renderer; measuring with a different notion of escaping than `write` uses +// is how a line ends up wider than the box it was fitted to. function stripInlineColorCodes(s) { - // Removes inline color markup of the form: \color\text\reset\ - // Single-pass O(n) scanner. if (!s || typeof s !== "string") return ""; - let out = "", i = 0; - while (i < s.length) { - if (s[i] === "\\") { - i++; // skip opening backslash - while (i < s.length && s[i] !== "\\") i++; // skip color spec - i++; // skip closing backslash - } else { - out += s[i++]; - } - } - return out; + return stripColorCodes(s); } let input, inputBtn, handleBtn, token, currentUserSub; @@ -1198,8 +1196,11 @@ function paint( const lineStart = tempCharPos; const lineEnd = tempCharPos + line.length; - // Build color-coded version of this line - let colorCodedLine = line; + // Build color-coded version of this line. The line is chat text, so it + // gets escaped before any of our own `\…\` codes go near it; `at` moves + // element offsets onto the escaped copy. + const at = escapedIndexMap(line); + let colorCodedLine = escapeColorCodes(line); // Find elements that overlap with this line and apply colors (in reverse order) const lineElements = parsedElements @@ -1238,7 +1239,7 @@ function paint( for (let ci = 0; ci < elementText.length; ci++) { const char = elementText[ci]; const col = customColors[ci]; - perCharText += `\\${col.r},${col.g},${col.b}\\${char}\\${textColorStr}\\`; + perCharText += `\\${col.r},${col.g},${col.b}\\${escapeColorCodes(char)}\\${textColorStr}\\`; } customColorCodedText = perCharText; } else { @@ -1267,28 +1268,35 @@ function paint( color = isHovered ? theme.youtubeHover : theme.youtube; } + // Splice against the escaped line, so the offsets have to move too. + const spliceStart = at[elemStartInLine]; + const spliceEnd = at[elemEndInLine]; + if (customColorCodedText) { colorCodedLine = - colorCodedLine.substring(0, elemStartInLine) + + colorCodedLine.substring(0, spliceStart) + customColorCodedText + - colorCodedLine.substring(elemEndInLine); + colorCodedLine.substring(spliceEnd); } else if (color) { const colorStr = Array.isArray(color) ? color.join(',') : color; const textColorStr = Array.isArray(theme.messageText) ? theme.messageText.join(',') : theme.messageText; - const colorCodedText = `\\${colorStr}\\${elementText}\\${textColorStr}\\`; + const colorCodedText = `\\${colorStr}\\${escapeColorCodes(elementText)}\\${textColorStr}\\`; colorCodedLine = - colorCodedLine.substring(0, elemStartInLine) + + colorCodedLine.substring(0, spliceStart) + colorCodedText + - colorCodedLine.substring(elemEndInLine); + colorCodedLine.substring(spliceEnd); } } } - // Pre-compute shadow line - const shadowLine = colorCodedLine.replace( - /\\(\d+),(\d+),(\d+)(?:,\d+)?\\/g, - (_, r, g, b) => `\\${Math.floor(r * 0.25)},${Math.floor(g * 0.25)},${Math.floor(b * 0.25)}\\` - ); + // Pre-compute shadow line. Walked code-by-code rather than by regex: + // a regex would read the two halves of an escaped `\\` in the message + // text as a code delimiter and corrupt it. + const shadowLine = mapColorCodes(colorCodedLine, (code) => { + const rgb = code.match(/^(\d+),(\d+),(\d+)(?:,\d+)?$/); + if (!rgb) return code; + return `${floor(rgb[1] * 0.25)},${floor(rgb[2] * 0.25)},${floor(rgb[3] * 0.25)}`; + }); cachedLines.push({ colorCodedLine, shadowLine }); tempCharPos += line.length; diff --git a/system/public/aesthetic.computer/lib/chat-highlighting.mjs b/system/public/aesthetic.computer/lib/chat-highlighting.mjs index 2cc3d643ee..2985870f20 100644 --- a/system/public/aesthetic.computer/lib/chat-highlighting.mjs +++ b/system/public/aesthetic.computer/lib/chat-highlighting.mjs @@ -2,6 +2,7 @@ // Shared utilities for parsing and highlighting chat messages with @handles, URLs, 'prompts', and KidLisp code. import { isKidlispSource, tokenize, KidLisp } from "./kidlisp.mjs"; +import { escapeColorCodes, escapedIndexMap } from "./color-codes.mjs"; // Simple client-side check for potentially sensitive URLs // These URLs will show as "[click to reveal link]" in chat @@ -284,12 +285,19 @@ export function parseMessageElements(message) { // Returns a string with embedded \color\ codes for rendering // colorMap should map element types to color strings/arrays: { handle: "pink", url: "cyan", ... } export function applyColorCodes(message, elements, colorMap, defaultColor = [255, 255, 255]) { + // The `\…\` codes below are markup we own; the message is somebody else's + // typing. Escape it on the way in so a chatter who types `\red\hi\reset\` + // sees those characters instead of dyeing the room red. if (!elements || elements.length === 0) { - return message; // No highlighting needed + return escapeColorCodes(message); // No highlighting needed } + // Escaping shifts every character right of a backslash, so element offsets — + // measured against the raw message — have to move with it. + const at = escapedIndexMap(message); + // Build the color-coded string by processing elements in reverse order (to maintain indices) - let colorCodedMessage = message; + let colorCodedMessage = escapeColorCodes(message); const sortedElements = [...elements].sort((a, b) => b.start - a.start); for (const element of sortedElements) { @@ -311,9 +319,10 @@ export function applyColorCodes(message, elements, colorMap, defaultColor = [255 const colorStr = Array.isArray(color) ? color.join(',') : color; const defaultColorStr = Array.isArray(defaultColor) ? defaultColor.join(',') : defaultColor; - const textBefore = colorCodedMessage.substring(0, element.start); - const elementText = displayText || colorCodedMessage.substring(element.start, element.end); - const textAfter = colorCodedMessage.substring(element.end); + const textBefore = colorCodedMessage.substring(0, at[element.start]); + const elementText = + displayText || colorCodedMessage.substring(at[element.start], at[element.end]); + const textAfter = colorCodedMessage.substring(at[element.end]); colorCodedMessage = `${textBefore}\\${colorStr}\\${elementText}\\${defaultColorStr}\\${textAfter}`; } diff --git a/system/public/aesthetic.computer/lib/color-codes.mjs b/system/public/aesthetic.computer/lib/color-codes.mjs new file mode 100644 index 0000000000..81b0c884a0 --- /dev/null +++ b/system/public/aesthetic.computer/lib/color-codes.mjs @@ -0,0 +1,88 @@ +// 🎨 Inline color codes — the `\color\text\reset\` markup that `write` reads. + +// One scanner, shared by the renderer (`disk.mjs`) and by every producer that +// splices colors into text it did not author (chat, hotlink, moods, prompt). +// The producers own the markup; the text they wrap around is often somebody +// else's typing, so it gets escaped on the way in: `\\` is a literal +// backslash. That is the whole trick — a user who types `\red\hi\reset\` sees +// those characters instead of red text, and nothing is silently eaten. + +// Walk a string once, splitting it into rendered text and color codes. +// Returns the alternating array [text, code, text, code, …, text] — always +// odd length, always text first, the same shape `String.split` gave us before. +// A `\` with no closing `\` after it is just a backslash, not a broken code. +export function splitColorCodes(str) { + if (!str) return [str || ""]; + const parts = [""]; + let i = 0; + while (i < str.length) { + if (str[i] !== "\\") { + parts[parts.length - 1] += str[i]; + i += 1; + } else if (str[i + 1] === "\\") { + parts[parts.length - 1] += "\\"; // an escaped backslash renders as one + i += 2; + } else { + const close = str.indexOf("\\", i + 1); + if (close === -1) { + parts[parts.length - 1] += "\\"; // unterminated — just a character + i += 1; + } else { + parts.push(str.slice(i + 1, close), ""); + i = close + 1; + } + } + } + return parts; +} + +// What the text looks like once the codes are gone — the string to measure so +// a measured width matches a drawn width. +export function stripColorCodes(str) { + if (!str) return str; + const parts = splitColorCodes(str); + let out = ""; + for (let i = 0; i < parts.length; i += 2) out += parts[i]; + return out; +} + +// Is there a real `\code\` pair in here? (Used to sniff kidlisp-ish text.) +export function hasColorCodes(str) { + if (!str) return false; + return splitColorCodes(str).length > 1; +} + +// Make text safe to splice next to color codes. +export function escapeColorCodes(str) { + if (!str) return str || ""; + return str.replace(/\\/g, "\\\\"); +} + +// Escaping shifts every character right of a backslash, so a producer that +// holds offsets into the raw string needs to translate them. `map[i]` is where +// raw character `i` landed in `escapeColorCodes(str)`; `map[str.length]` is the +// end, so a raw [start, end) slice maps straight across. +export function escapedIndexMap(str) { + const map = new Array((str?.length || 0) + 1); + let shift = 0; + for (let i = 0; i < (str?.length || 0); i++) { + map[i] = i + shift; + if (str[i] === "\\") shift += 1; + } + map[str?.length || 0] = (str?.length || 0) + shift; + return map; +} + +// Rewrite every code in place (shadow passes do this), leaving the text alone. +// Return `null` from `fn` to drop a code entirely. +export function mapColorCodes(str, fn) { + if (!str) return str; + const parts = splitColorCodes(str); + let out = escapeColorCodes(parts[0]); + for (let i = 1; i < parts.length; i += 2) { + const replacement = fn(parts[i]); + if (replacement !== null && replacement !== undefined) out += `\\${replacement}\\`; + out += escapeColorCodes(parts[i + 1]); + } + return out; +} diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index 52456ccb2f..afea2c23f1 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -29,6 +29,12 @@ import * as vec3 from "../dep/gl-matrix/vec3.mjs"; import * as vec4 from "../dep/gl-matrix/vec4.mjs"; import * as graph from "./graph.mjs"; +import { + splitColorCodes, + stripColorCodes as stripCodes, + hasColorCodes, + mapColorCodes, +} from "./color-codes.mjs"; import * as num from "./num.mjs"; import * as text from "./text.mjs"; @@ -591,9 +597,9 @@ function replaceColorCodesWithShadows(text, defaultTextColor = "white") { let currentTextColor = defaultTextColor; - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - return text.replace(COLOR_CODE_MATCH_REGEX, (match, colorStr) => { - if (!colorStr) return match; + // Rewrite every code, leave the text (and its escapes) alone. + return mapColorCodes(text, (colorStr) => { + if (!colorStr) return colorStr; const normalized = colorStr.trim(); const lower = normalized.toLowerCase(); @@ -604,11 +610,9 @@ function replaceColorCodesWithShadows(text, defaultTextColor = "white") { currentTextColor = normalized; } - // Get the appropriate shadow color for this text color - const shadowColor = getShadowColorForText(currentTextColor); - - // Return the shadow color code - return `\\${shadowColor}\\`; + // Get the appropriate shadow color for this text color. + // mapColorCodes re-wraps this in the `\…\` delimiters itself. + return getShadowColorForText(currentTextColor); }); } @@ -2074,26 +2078,17 @@ if (typeof window !== 'undefined') { } //currentPromptButton; -const COLOR_CODE_MATCH_REGEX = /\\([^\\]+)\\/g; -const COLOR_CODE_TEST_REGEX = /\\[^\\]+\\/; +// Color codes are scanned by lib/color-codes.mjs, which knows that `\\` is an +// escaped backslash — the producers escape text they didn't author, so a user +// who types `\red\` gets those characters instead of red text. // Utility function to strip color codes from text function stripColorCodes(str) { - if (!str) return str; - // Remove all \\color\\ sequences including: - // - Named colors: \\red\\, \\blue\\, \\cyan\\ - // - RGB values: \\255,20,147\\, \\192,192,192\\ - // - Complex patterns: \\color(args)\\ - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - return str.replace( - COLOR_CODE_MATCH_REGEX, - "", - ); + return stripCodes(str); } function textContainsColorCodes(str) { - if (!str) return false; - return COLOR_CODE_TEST_REGEX.test(str); + return hasColorCodes(str); } function hasKidLispMarkers(text) { @@ -5589,9 +5584,10 @@ const $paintApi = { let charColors = []; let currentColor = null; - // Split text by color codes and process each segment - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - const segments = text.split(COLOR_CODE_MATCH_REGEX); + // Split text by color codes and process each segment. + // [text, code, text, code, …] — the text halves arrive unescaped, so a + // literal backslash the author typed lands in cleanText as one character. + const segments = splitColorCodes(text); for (let i = 0; i < segments.length; i++) { if (i % 2 === 0) { @@ -5651,8 +5647,6 @@ const $paintApi = { } } - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - // Check if we have any actual text to display after removing color codes if (cleanText.trim().length === 0) { return $activePaintApi; // Exit silently if no text content remains