- commit
- 2e6b487ada58c1e66789798dd6ef34385f61e63f
- parent
- f564ae3fd8f0ec2dd5c809f9239e27a8c521c7aa
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 11 | ++++++- |
| M | src/ | 45 | +++++++++++++--------------- |
| M | src/ | 12 | +++++++- |
| A | src/ | 49 | ++++++++++++++++++++++++++++++ |
| M | tests/ | 12 | +++++++ |
| A | tests/ | 41 | +++++++++++++++++++++++++ |
@@ -1,5 +1,6 @@
11 import { nativeHorizontalFacing } from './facing.js';22 import { actionMatches, keycapPressFrame } from './motion.js';3+import { fitSceneTitle } from './title-fitting.js';34 45 /**56 * Pure canvas renderer for the INDEX map.@@ -225,8 +226,14 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
225226 // On narrow screens the banner gets the full row above the controls, so226227 // it stays legible instead of being squeezed into their gap.227228 const bannerWidth = narrowFooter ? Math.max(1, canvasWidth - margin * 2) : Math.max(84, keyStart - margin * 1.2);228- ctx.font = fitFont(ctx, banner, bannerWidth, Math.max(16, cell * 0.15), 800);229- ctx.fillText(banner, margin, narrowFooter ? footerY - keyHeight * 1.15 : footerY);229+ const bannerFit = fitSceneTitle(ctx, banner, { maxWidth: bannerWidth, maxSize: 22, minSize: 16, maxLines: 2 });230+ const bannerLineHeight = bannerFit.lineHeight;231+ const bannerTop = narrowFooter232+ ? footerY - keyHeight * 1.15 - (bannerFit.lines.length - 1) * bannerLineHeight233+ : footerY - (bannerFit.lines.length - 1) * bannerLineHeight;234+ ctx.font = `800 ${bannerFit.size}px ${FONT}`;235+ bannerFit.lines.forEach((line, index) => ctx.fillText(line, margin, bannerTop + index * bannerLineHeight));236+ geometry.banner = { x: margin, y: bannerTop, width: bannerWidth, height: bannerFit.lines.length * bannerLineHeight, ...bannerFit };230237 if (bannerNode.locked) { ctx.restore(); return geometry; }231238 let keyX = keyStart;232239 for (const group of groups) {@@ -7,6 +7,7 @@ import {
77 drawStepCounter,88 drawTouchControls,99 } from "./stage-hud.js";10+import { fitSceneTitle } from './title-fitting.js';1011 1112 /**1213 * Draws the playable proof scene. All inputs are supplied in the view object;@@ -42,19 +43,10 @@ export function renderProofScene(ctx, state, view = {}) {
4243 ),4344 );4445 const x = Math.floor((width - state.width * cell) / 2);45- const y =46+ const baseY =4647 width <= 7604748 ? 1204849 : Math.max(118, Math.floor((height - state.height * cell) / 2 - 30));49- const geometry = drawBoard(ctx, state, {50- x,51- y,52- cell,53- game: view.game,54- palette,55- glyphs,56- frame: view.frame,57- });5850 const controlled = state.objects?.find(5951 (object) =>6052 object.kind === "object" &&@@ -78,25 +70,20 @@ export function renderProofScene(ctx, state, view = {}) {
7870 ctx.fillText(proofNumber, 49, 44);7971 ctx.textAlign = "left";8072 const fullProofTitle = String(state.title || "Proof");73+ const titleFit = fitSceneTitle(ctx, fullProofTitle, { maxWidth: Math.max(1, width - 82 - Math.max(64, width * 0.16)), maxSize: 22, minSize: 16, maxLines: 2 });74+ const titleTop = 30;75+ const sublineTop = titleTop + titleFit.lines.length * titleFit.lineHeight + 3;76+ const hudBottom = sublineTop + 12;77+ const y = Math.max(baseY, hudBottom + 40);8178 ctx.fillStyle = "#F2EEE6";82- ctx.font = "800 22px Recursive, monospace";83- const titleMaxWidth = Math.max(96, width - 190);84- let visibleProofTitle = fullProofTitle;85- if (ctx.measureText(visibleProofTitle).width > titleMaxWidth) {86- while (87- visibleProofTitle.length > 4 &&88- ctx.measureText(`${visibleProofTitle}…`).width > titleMaxWidth89- )90- visibleProofTitle = visibleProofTitle.slice(0, -1);91- visibleProofTitle = `${visibleProofTitle.trimEnd()}…`;92- }93- ctx.fillText(visibleProofTitle, 78, 31);79+ ctx.font = `800 ${titleFit.size}px Recursive, monospace`;80+ titleFit.lines.forEach((line, index) => ctx.fillText(line, 82, titleTop + index * titleFit.lineHeight));9481 ctx.font = "600 10px Recursive, monospace";9582 ctx.fillStyle = "#8E877C";9683 ctx.fillText(9784 `PROOF ${proofNumber} OF ${String(state.proofTotal ?? 9).padStart(2, "0")}`,98- 78,99- 58,85+ 82,86+ sublineTop,10087 );10188 ctx.fillStyle = "#F2EEE6";10289 drawStepCounter(ctx, state.moves || 0, width - 32, 39, {@@ -109,6 +96,15 @@ export function renderProofScene(ctx, state, view = {}) {
10996 ctx.fillStyle = "#8E877C";11097 ctx.fillText("STEPS", width - 32, 70);11198 ctx.textAlign = "left";99+ const geometry = drawBoard(ctx, state, {100+ x,101+ y,102+ cell,103+ game: view.game,104+ palette,105+ glyphs,106+ frame: view.frame,107+ });112108 const ledger = drawLedger(113109 ctx,114110 state.rules,@@ -190,6 +186,7 @@ export function renderProofScene(ctx, state, view = {}) {
190186 );191187 return {192188 geometry,189+ titleLayout: { ...titleFit, x: 82, y: titleTop, width: Math.max(1, width - 82 - Math.max(64, width * 0.16)), sublineTop, hudBottom, boardTop: geometry.y },193190 controlledCenter,194191 ariaLabel: `Miau Is True proof: ${fullProofTitle}`,195192 };@@ -4,6 +4,7 @@ import {
44 drawSentenceTiles,55 drawStackedAction,66 } from './stage-hud.js';7+import { fitSceneTitle } from './title-fitting.js';78 89 /**910 * D22's win-card clock. `winStart` is the beginning of the win beat, before@@ -252,13 +253,22 @@ function drawCard(ctx, view, frame, winningRule) {
252253 ctx.lineWidth = 2;253254 ctx.strokeRect(cardX, cardY, cardWidth, cardHeight);254255 256+ const proofTitle = String(view.boardState?.title || '').trim();257+ const titleFit = fitSceneTitle(ctx, proofTitle, { maxWidth: cardWidth - 64, maxSize: 22, minSize: 16, maxLines: 2 });258+ const titleTop = cardY + 20;259+ ctx.fillStyle = '#F2EEE6';260+ ctx.font = `800 ${titleFit.size}px Recursive, monospace`;261+ ctx.textAlign = 'center';262+ ctx.textBaseline = 'top';263+ titleFit.lines.filter(Boolean).forEach((line, index) => ctx.fillText(line, w / 2, titleTop + index * titleFit.lineHeight));264+255265 const sentence = sentenceFor(view.boardState, winningRule);256266 const words = sentence.split(/\s+/).filter(Boolean);257267 const sentenceSize = Math.min(22, Math.max(14, cardWidth * 0.035));258268 ctx.font = `800 ${sentenceSize}px Recursive, monospace`;259269 const sentenceWidth = words.reduce((total, word) => total + Math.max(sentenceSize * 1.6, ctx.measureText(word.toUpperCase()).width + sentenceSize), 0) + Math.max(0, words.length - 1) * sentenceSize * 0.4;260270 const sentenceX = cardX + Math.max(32, (cardWidth - sentenceWidth) / 2);261- const tileY = cardY + 52;271+ const tileY = cardY + 52 + Math.max(0, titleFit.lines.length - 1) * titleFit.lineHeight;262272 let cursor = sentenceX;263273 words.forEach((word, index) => {264274 const progress = frame.tileProgress[index] ?? frame.tileProgress.at(-1) ?? 1;@@ -0,0 +1,49 @@
1+/**2+ * Fit authored scene titles without changing their copy. The returned lines3+ * are always complete words; an overlong second line is reported instead of4+ * being ellipsized or silently shortened.5+ */6+export function fitSceneTitle(ctx, value, options = {}) {7+ const text = String(value ?? '').trim();8+ const maxWidth = Math.max(1, Number(options.maxWidth) || 1);9+ const maxSize = Math.max(1, Number(options.maxSize) || 22);10+ const minSize = Math.min(maxSize, Math.max(1, Number(options.minSize) || 16));11+ const maxLines = Math.max(1, Number(options.maxLines) || 2);12+ const weight = options.weight || 800;13+ const family = options.family || 'Recursive, monospace';14+ const measure = (line, size) => {15+ if (ctx && typeof ctx.measureText === 'function') {16+ ctx.font = `${weight} ${size}px ${family}`;17+ return ctx.measureText(line).width;18+ }19+ return String(line).length * size * 0.6;20+ };21+ const words = text ? text.split(/\s+/) : [''];22+ const wrap = (size) => {23+ const lines = [];24+ let line = '';25+ for (const word of words) {26+ const candidate = line ? `${line} ${word}` : word;27+ if (line && measure(candidate, size) > maxWidth) {28+ lines.push(line);29+ line = word;30+ } else line = candidate;31+ }32+ if (line || !lines.length) lines.push(line);33+ if (lines.length <= maxLines) return lines;34+ return [lines[0], lines.slice(1).join(' ')];35+ };36+ for (let size = maxSize; size >= minSize; size -= 1) {37+ if (measure(text, size) <= maxWidth)38+ return { text, lines: [text], size, lineHeight: Math.ceil(size * 1.16), overflow: false };39+ }40+ const lines = wrap(minSize);41+ return {42+ text,43+ lines,44+ size: minSize,45+ lineHeight: Math.ceil(minSize * 1.16),46+ overflow: lines.some(line => measure(line, minSize) > maxWidth),47+ };48+}49+@@ -176,3 +176,15 @@ test('drawIndex keeps the mobile banner readable above compact keycaps', () => {
176176 assert.ok(banner[4] !== enter[4], 'banner should use the larger readable text size');177177 assert.ok(banner[2] < enter[2], 'banner should sit above the keycap row');178178 });179+180+test('drawIndex keeps a long authored banner complete across two lines', () => {181+ const title = 'A deliberately long authored proof title for mobile review';182+ const layout = decorateIndex(createIndexLayout([level('core', 'long', title)]), null);183+ const navigator = createIndexNavigator(layout);184+ const ctx = context(390, 420);185+ const geometry = drawIndex(ctx, layout, navigator, { availableWidth: 390, availableHeight: 420 });186+ assert.equal(geometry.banner.lines.join(' '), `01 ${title}`);187+ assert.equal(geometry.banner.lines.some(line => line.includes('…')), false);188+ assert.equal(geometry.banner.lines.length, 2);189+ assert.ok(geometry.banner.size >= 16);190+});@@ -0,0 +1,41 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { fitSceneTitle } from '../src/render/title-fitting.js';4+5+function context() {6+ return {7+ font: '',8+ measureText(value) {9+ const size = Number(this.font.match(/(\d+(?:\.\d+)?)px/)?.[1] || 16);10+ return { width: String(value).length * size * 0.5 };11+ },12+ };13+}14+15+test('title fitting keeps a short title at the 22px floor ceiling', () => {16+ const fit = fitSceneTitle(context(), 'Break the rule', { maxWidth: 240 });17+ assert.equal(fit.size, 22);18+ assert.deepEqual(fit.lines, ['Break the rule']);19+ assert.equal(fit.overflow, false);20+});21+22+test('title fitting shrinks complete copy before wrapping', () => {23+ const fit = fitSceneTitle(context(), 'A medium authored title', { maxWidth: 220 });24+ assert.ok(fit.size < 22);25+ assert.deepEqual(fit.lines, ['A medium authored title']);26+ assert.equal(fit.lines.join(' '), fit.text);27+});28+29+test('title fitting wraps long authored copy at words with no ellipsis', () => {30+ const fit = fitSceneTitle(context(), 'A deliberately long authored proof title for mobile review', { maxWidth: 150 });31+ assert.equal(fit.size, 16);32+ assert.equal(fit.lines.length, 2);33+ assert.equal(fit.lines.join(' '), fit.text);34+ assert.equal(fit.lines.some(line => line.includes('…')), false);35+});36+37+test('title fitting reports an unbreakable overflow without dropping copy', () => {38+ const fit = fitSceneTitle(context(), 'Supercalifragilisticexpialidocious proof', { maxWidth: 80 });39+ assert.equal(fit.lines.join(' '), fit.text);40+ assert.equal(fit.overflow, true);41+});