diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 4c279c8..10d88bb 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -273,7 +273,7 @@ User-pin titles must not start with `` (the sentinel that prefixes the aut Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on React mount and `unmountElement(id)` on React unmount. The session (xterm.js instance, PTY, DOM element) persists in the registry across mount/unmount cycles — the DOM element is detached from its container but the Registry entry stays `Mounted`. -- **Create**: `getOrCreateTerminal` spawns xterm.js + FitAddon + PTY, returns existing if already created +- **Create**: `getOrCreateTerminal` spawns xterm.js + UnicodeGraphemesAddon + FitAddon + PTY, returns existing if already created. The xterm instance sets `allowProposedApi: true` because UnicodeGraphemesAddon activates through xterm's proposed Unicode API. - **Resume**: `resumeTerminal` creates xterm entry and writes replay data without spawning a new PTY. Used when the webview is recreated while the host retains Live PTYs (Link: Severed → Resuming → Live). - **Restore**: `restoreTerminal` creates xterm entry and spawns a new PTY with saved cwd and scrollback. Used on cold start from a saved Snapshot (Link: Cold → Live). - **Untouched**: new `getOrCreateTerminal` sessions start untouched. `isUntouched(id)` exposes the flag, and user-originated PTY input clears it via the registry input paths. Resume/restore seed the persisted flag; missing legacy snapshot data defaults to touched (`false`) so close confirmation remains conservative. diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index fa1e88e..b9b0544 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -8,7 +8,7 @@ Three browser-side pieces in `website/src/lib/`, mirroring the pattern in `websi - **`tut-runner.ts`** (`TutRunner`) — alt-screen TUI. Subscribes to `TutorialState` and re-renders whenever progress changes. Routes input bytes via `FakePtyAdapter.writePty(id, …)`. - **`tut-detector.ts`** (`TutDetector`) — wires app events to `TutorialState.markComplete(id)`. Subscribes to `DockviewApi.onDidActivePanelChange`, the `WallEvent` stream, the `subscribeToActivity` store from `dormouse-lib/lib/terminal-registry`, and the `subscribeToMouseSelection` store from `dormouse-lib/lib/mouse-selection`. -- **`tutorial-state.ts`** (`TutorialState`) — single in-memory progress store, persisted as a JSON array of completed item ids under the `dormouse-tut-v3` localStorage key. +- **`tutorial-state.ts`** (`TutorialState`) — single in-memory progress store, persisted as a JSON array of completed item ids under the `dormouse-tut-v3` localStorage key. The top-level GitHub star prompt persists separately under `dormouse-tut-star-v1`. - **`tut-items.ts`** — section + item definitions (titles, hints) shared by runner and detector. Item ids are stable; they are the localStorage key suffixes. ## Layout @@ -29,7 +29,13 @@ Every playground pane gets a `TutorialShell` input handler through `PlaygroundSh ## Tutorial Sections -The runner shows a top-level menu first. Selecting a section drills into its item list. Each section shows `[N/M complete]` next to its title. Inside a section, items render as one of: +The runner shows a top-level menu first. Selecting a section drills into its item list. Each section shows `[N/M complete]` next to its title. The menu helper below `Dormouse Playground Tutorial` shows only navigation shortcuts, not overall completion. + +The top-level menu also includes `Starred on GitHub`, which sits directly below `Copy paste` without a blank spacer, and shows `[not yet]` until selected and `[thanks ⭐]` after it has been resolved. Pressing Enter on that row calls `onOpenGithub`, which `/playground` and the mobile tether page wire to `window.open("https://github.com/diffplug/dormouse", "_blank", "noopener,noreferrer")`. + +After `Starred on GitHub`, the top-level menu shows the mystery row. It is `🐭 ??? 🐭` with `[LOCKED N/M]` while any section task is incomplete. `N/M` is computed from section checklist items only; `Starred on GitHub` and the mystery row do not count. When all section tasks are complete, the row becomes `🐭 Flappy Term 🐭` with a `[High score: N]` readout. Pressing Enter on the unlocked row opens Flappy Term, a runner-local mini-game: `Space`/`Up`/`Enter` flaps the bird, scoring persists as the high score, and `Esc` returns to the top-level menu. On the game-over screen, `Enter` restarts and `p` calls `onOpenPocket`, which `/playground` and the mobile tether page wire to `window.open("https://dormouse.sh/pocket", "_blank", "noopener,noreferrer")`. + +Inside a section, items render as one of: - `✓` (green) — complete - `●` (yellow active marker) — first incomplete item, with hint text shown below. This marker is intentionally static so runner re-renders do not feed the activity monitor. @@ -101,6 +107,8 @@ While the Copy paste section is open, pressing `p` toggles the **Place To Paste* ## Storage - Completion: `localStorage["dormouse-tut-v3"] = JSON.stringify([...completedItemIds])`. Removed on `TutorialState.reset()`. Unknown ids in a stored payload are filtered out on load, so renaming an id is a one-way reset for that item. +- GitHub star prompt: `localStorage["dormouse-tut-star-v1"] = "true"` after the user selects `Starred on GitHub`. Removed on `TutorialState.reset()`. +- Flappy Term high score: `localStorage["dormouse-flappy-high-v1"] = String(score)` after each new high score. Removed on `TutorialState.reset()`. - Legacy keys `dormouse-tutorial-step-N` and `dormouse-tut-v2-*` from previous designs are not read; new playground sessions get a fresh start. ## Theme Picker diff --git a/lib/package.json b/lib/package.json index 92bc66e..d3916eb 100644 --- a/lib/package.json +++ b/lib/package.json @@ -16,6 +16,7 @@ "dependencies": { "@phosphor-icons/react": "^2.1.10", "@xterm/addon-fit": "0.12.0-beta.219", + "@xterm/addon-unicode-graphemes": "0.5.0-beta.219", "@xterm/xterm": "6.1.0-beta.219", "clsx": "^2.1.1", "dockview-react": "^5.1.0", diff --git a/lib/src/lib/clipboard.test.ts b/lib/src/lib/clipboard.test.ts index fa97c33..239c7a2 100644 --- a/lib/src/lib/clipboard.test.ts +++ b/lib/src/lib/clipboard.test.ts @@ -20,6 +20,11 @@ vi.mock('./mouse-selection', () => ({ getMouseSelectionState: () => ({ bracketedPaste: false }), })); +vi.mock('./terminal-registry', () => ({ + getTerminalInstance: () => null, + markSessionTouched: vi.fn(), +})); + import { doPaste } from './clipboard'; describe('doPaste three-tier fallthrough', () => { diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 53b9aa6..f6089fa 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -1,5 +1,6 @@ import { Terminal, type IBufferRange } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; +import { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes'; import { getPlatform } from './platform'; import { requestExternalLinkConfirmation } from './external-link-confirmation'; import { attachMouseModeObserver } from './mouse-mode-observer'; @@ -73,6 +74,7 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi const theme = getTerminalTheme(); const terminal = new Terminal({ + allowProposedApi: true, fontSize: editorFontSize, fontFamily: editorFontFamily, cursorBlink: true, @@ -88,6 +90,7 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi }, }); + terminal.loadAddon(new UnicodeGraphemesAddon()); const fit = new FitAddon(); terminal.loadAddon(fit); diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 8a53be3..8422d85 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -12,6 +12,12 @@ vi.mock('@xterm/addon-fit', () => { return { FitAddon }; }); +vi.mock('@xterm/addon-unicode-graphemes', () => { + class UnicodeGraphemesAddon {} + + return { UnicodeGraphemesAddon }; +}); + vi.mock('@xterm/xterm', () => { class MockTerminal { writes: string[] = []; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bc0f5e..1f0465d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@xterm/addon-fit': specifier: 0.12.0-beta.219 version: 0.12.0-beta.219(@xterm/xterm@6.1.0-beta.219) + '@xterm/addon-unicode-graphemes': + specifier: 0.5.0-beta.219 + version: 0.5.0-beta.219(@xterm/xterm@6.1.0-beta.219) '@xterm/xterm': specifier: 6.1.0-beta.219 version: 6.1.0-beta.219 @@ -98,6 +101,9 @@ importers: '@xterm/addon-fit': specifier: 0.12.0-beta.219 version: 0.12.0-beta.219(@xterm/xterm@6.1.0-beta.219) + '@xterm/addon-unicode-graphemes': + specifier: 0.5.0-beta.219 + version: 0.5.0-beta.219(@xterm/xterm@6.1.0-beta.219) '@xterm/xterm': specifier: 6.1.0-beta.219 version: 6.1.0-beta.219 @@ -2071,6 +2077,11 @@ packages: peerDependencies: '@xterm/xterm': ^6.1.0-beta.219 + '@xterm/addon-unicode-graphemes@0.5.0-beta.219': + resolution: {integrity: sha512-507MpY9VdIdarrZtszYt8HPr5cJr1MMNKFk0AyyzJmtYq8cVQdDXHO7ukpmdaZ6v5Z5H/4+wnJgvTPBXu2prTg==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.219 + '@xterm/xterm@6.1.0-beta.219': resolution: {integrity: sha512-cQDv5UMEooIqjlybDPfNO0uI0sB0SWaaOxubHcFNXah8umLHR8SfKQbj5BPaa5gqoTKGYFkVdpowyhZlV+xywQ==} @@ -5567,6 +5578,10 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.219 + '@xterm/addon-unicode-graphemes@0.5.0-beta.219(@xterm/xterm@6.1.0-beta.219)': + dependencies: + '@xterm/xterm': 6.1.0-beta.219 + '@xterm/xterm@6.1.0-beta.219': {} acorn@8.16.0: {} diff --git a/standalone/package.json b/standalone/package.json index 8a097ba..1dbd6c2 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -16,6 +16,7 @@ "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "0.12.0-beta.219", + "@xterm/addon-unicode-graphemes": "0.5.0-beta.219", "@xterm/xterm": "6.1.0-beta.219", "dockview-react": "^5.1.0", "dormouse-lib": "workspace:*", diff --git a/website/src/data/dependencies.json b/website/src/data/dependencies.json index 1a73cfc..d0fcc5e 100644 --- a/website/src/data/dependencies.json +++ b/website/src/data/dependencies.json @@ -34,6 +34,13 @@ "author": "The xterm.js authors", "homepage": "https://github.com/xtermjs/xterm.js/tree/master#readme" }, + { + "name": "@xterm/addon-unicode-graphemes", + "version": "0.5.0-beta.219", + "license": "MIT", + "author": "The xterm.js authors", + "homepage": "https://github.com/xtermjs/xterm.js/tree/master#readme" + }, { "name": "@xterm/xterm", "version": "6.1.0-beta.219", diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index 12530a1..d0e9ffc 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -79,11 +79,13 @@ exports[`TutRunner snapshots > renders Keyboard navigation with all items incomp exports[`TutRunner snapshots > renders the top-level menu 1`] = ` " Dormouse Playground Tutorial - 0/17 complete · Esc/q to exit · Enter to open · ↑↓ to navigate + Esc/q to exit · Enter to open · ↑↓ to navigate ❯ Keyboard navigation [0/7 complete] Alert and TODO [0/6 complete] Copy paste [0/4 complete] + Starred on GitHub [not yet] + 🐭 ??? 🐭 [LOCKED 0/17] Reset progress diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 7f58353..1ef7253 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter"; import { SECTIONS, type ItemId } from "./tut-items"; import { TutRunner } from "./tut-runner"; @@ -6,7 +6,10 @@ import { TutorialState } from "./tutorial-state"; const FRAME_RESET = "\x1b[H\x1b[2J"; -function mountRunner(completedIds: ItemId[] = []) { +function mountRunner( + completedIds: ItemId[] = [], + options: { onOpenGithub?: () => void } = {}, +) { const adapter = new FakePtyAdapter(); const id = "test-pane"; adapter.spawnPty(id); @@ -25,6 +28,7 @@ function mountRunner(completedIds: ItemId[] = []) { onExit: () => { exitCount += 1; }, + onOpenGithub: options.onOpenGithub, }); adapter.setInputHandler(id, (data) => runner.handleInput(data)); runner.start(); @@ -90,4 +94,66 @@ describe("TutRunner snapshots", () => { expect(exitCount()).toBe(1); dispose(); }); + + it("opens GitHub and resolves the star prompt from the menu", () => { + const onOpenGithub = vi.fn(); + const { state, sendKeys, lastFrame, dispose } = mountRunner([], { onOpenGithub }); + + sendKeys("\x1b[B\x1b[B\x1b[B\r"); + + expect(onOpenGithub).toHaveBeenCalledTimes(1); + expect(state.isStarPromptResolved()).toBe(true); + expect(lastFrame()).toContain("[thanks ⭐]"); + expect(lastFrame()).not.toContain("[not yet]"); + dispose(); + }); + + it("clears the star prompt when reset progress is confirmed", () => { + const { state, sendKeys, dispose } = mountRunner(["kb-mode"]); + state.resolveStarPrompt(); + + sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\x1b[B\rreset\r"); + + expect(state.isComplete("kb-mode")).toBe(false); + expect(state.isStarPromptResolved()).toBe(false); + dispose(); + }); + + it("keeps Flappy Term locked until every tutorial task is complete", () => { + const { sendKeys, lastFrame, dispose } = mountRunner(); + + sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\r"); + + expect(lastFrame()).toContain("🐭 ??? 🐭"); + expect(lastFrame()).toContain("[LOCKED 0/17]"); + expect(lastFrame()).toContain("Dormouse Playground Tutorial"); + dispose(); + }); + + it("shows the unlocked Flappy Term entry with a high-score readout", () => { + const allItemIds = SECTIONS.flatMap((section) => section.items.map((i) => i.id)); + const { state, sendKeys, lastFrame, dispose } = mountRunner(allItemIds); + state.recordFlappyScore(7); + + // Navigate to (but don't enter) the Flappy Term row. + sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B"); + expect(lastFrame()).toContain("🐭 Flappy Term 🐭"); + expect(lastFrame()).toContain("[High score: 7]"); + dispose(); + }); + + it("opens Flappy Term, shows the start hint, and exits back to the menu", () => { + const allItemIds = SECTIONS.flatMap((section) => section.items.map((i) => i.id)); + const { sendKeys, lastFrame, dispose } = mountRunner(allItemIds); + + sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\r"); + const frame = lastFrame(); + expect(frame).toContain("Score: 0"); + expect(frame).toContain("Best:"); + expect(frame).toContain("Space / Up to flap"); + + sendKeys("\x1b"); + expect(lastFrame()).toContain("Dormouse Playground Tutorial"); + dispose(); + }); }); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index aef9e24..c335794 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -53,6 +53,55 @@ const SPINNER_INTERVAL_MS = 100; * silence. A static glyph keeps the pane quiet between user actions. */ const ACTIVE_ITEM_GLYPH = "●"; +const STAR_PROMPT_TITLE = "Starred on GitHub"; +const FLAPPY_LOCKED_TITLE = "🐭 ??? 🐭"; +const FLAPPY_UNLOCKED_TITLE = "🐭 Flappy Term 🐭"; + +// --- Flappy Term game constants (ported from flappy-term.html) --- +const FLAPPY_TICK_MS = 60; +const FLAPPY_GRAVITY = 0.18; +const FLAPPY_FLAP_V = -1.1; +const FLAPPY_MAX_VY = 1.3; +const FLAPPY_PIPE_SPACING_MIN = 18; +const FLAPPY_PIPE_GAP = 8; +const FLAPPY_MAX_GAP_DELTA = 8; +const FLAPPY_PIPE_W = 4; +const FLAPPY_GROUND_H = 2; + +const fg256 = (n: number) => `\x1b[38;5;${n}m`; +const bg256 = (n: number) => `\x1b[48;5;${n}m`; +const moveTo = (row: number, col: number) => `\x1b[${row};${col}H`; + +const FLAPPY_COLORS = { + bird: 226, + birdO: 208, + pipe: 34, + pipeD: 22, + ground: 94, + grass: 40, + text: 231, + dim: 245, + red: 196, +}; + +interface FlappyPipe { + x: number; + gapY: number; + gapH: number; + scored: boolean; +} + +interface FlappyGameState { + birdX: number; + birdY: number; + birdVY: number; + pipes: FlappyPipe[]; + score: number; + alive: boolean; + started: boolean; + frame: number; + nextPipeIn: number; +} interface TutRunnerOptions { adapter: FakePtyAdapter; @@ -63,9 +112,13 @@ interface TutRunnerOptions { onTriggerBusyDemo?: () => void; /** Called when the user presses `p` inside the Copy paste section. */ onTogglePlaceToPaste?: () => void; + /** Called when the user presses `Enter` on the GitHub star prompt. */ + onOpenGithub?: () => void; + /** Called when the user presses `p` on the Flappy Term game-over screen. */ + onOpenPocket?: () => void; } -type Screen = "menu" | "section" | "reset"; +type Screen = "menu" | "section" | "reset" | "flappy"; const RESET_CONFIRM_WORD = "reset"; @@ -76,6 +129,8 @@ export class TutRunner implements InteractiveProgram { private onExit: () => void; private onTriggerBusyDemo?: () => void; private onTogglePlaceToPaste?: () => void; + private onOpenGithub?: () => void; + private onOpenPocket?: () => void; private screen: Screen = "menu"; private menuIndex = 0; @@ -84,6 +139,8 @@ export class TutRunner implements InteractiveProgram { private resetMismatch = false; private spinnerFrame = 0; private spinnerTimer: ReturnType | null = null; + private flappyTimer: ReturnType | null = null; + private flappy: FlappyGameState | null = null; private stateUnsub: (() => void) | null = null; private resizeUnsub: (() => void) | null = null; private busyDemoStart: number | null = null; @@ -96,6 +153,8 @@ export class TutRunner implements InteractiveProgram { this.onExit = options.onExit; this.onTriggerBusyDemo = options.onTriggerBusyDemo; this.onTogglePlaceToPaste = options.onTogglePlaceToPaste; + this.onOpenGithub = options.onOpenGithub; + this.onOpenPocket = options.onOpenPocket; } start(): void { @@ -127,6 +186,115 @@ export class TutRunner implements InteractiveProgram { this.spinnerTimer = null; } + private startFlappyTicks(): void { + if (this.flappyTimer) return; + this.flappyTimer = setInterval(() => { + this.stepFlappy(); + this.render(); + }, FLAPPY_TICK_MS); + } + + private stopFlappyTicks(): void { + if (!this.flappyTimer) return; + clearInterval(this.flappyTimer); + this.flappyTimer = null; + } + + private resetFlappyGame(): void { + const { cols, rows } = this.adapter.getPtySize(this.terminalId); + const playH = Math.max(6, rows - FLAPPY_GROUND_H); + this.flappy = { + birdX: Math.max(2, Math.floor(cols * 0.25)), + birdY: Math.floor(playH / 2), + birdVY: 0, + pipes: [], + score: 0, + alive: true, + started: false, + frame: 0, + nextPipeIn: 10, + }; + } + + private flap(): void { + const g = this.flappy; + if (!g || !g.alive) return; + g.started = true; + g.birdVY = FLAPPY_FLAP_V; + } + + private spawnFlappyPipe(cols: number, playH: number): void { + const g = this.flappy; + if (!g) return; + const minGapY = 3; + const maxGapY = playH - FLAPPY_PIPE_GAP - 3; + let lo = minGapY; + let hi = maxGapY; + const last = g.pipes[g.pipes.length - 1]; + if (last) { + lo = Math.max(lo, last.gapY - FLAPPY_MAX_GAP_DELTA); + hi = Math.min(hi, last.gapY + FLAPPY_MAX_GAP_DELTA); + } + if (hi < lo) hi = lo; + const gapY = Math.floor(lo + Math.random() * (hi - lo + 1)); + g.pipes.push({ + x: cols + 1, + gapY, + gapH: FLAPPY_PIPE_GAP, + scored: false, + }); + } + + private stepFlappy(): void { + const g = this.flappy; + if (!g || !g.alive) return; + const { cols, rows } = this.adapter.getPtySize(this.terminalId); + const playH = Math.max(6, rows - FLAPPY_GROUND_H); + + g.frame++; + if (!g.started) return; + + g.birdVY += FLAPPY_GRAVITY; + if (g.birdVY > FLAPPY_MAX_VY) g.birdVY = FLAPPY_MAX_VY; + g.birdY += g.birdVY; + + for (const p of g.pipes) p.x -= 1; + g.pipes = g.pipes.filter((p) => p.x + FLAPPY_PIPE_W > 0); + + g.nextPipeIn--; + if (g.nextPipeIn <= 0) { + this.spawnFlappyPipe(cols, playH); + g.nextPipeIn = FLAPPY_PIPE_SPACING_MIN; + } + + const bx = Math.round(g.birdX); + for (const p of g.pipes) { + if (!p.scored && p.x + FLAPPY_PIPE_W - 1 < bx) { + p.scored = true; + g.score++; + this.state.recordFlappyScore(g.score); + } + } + + const by = Math.round(g.birdY); + if (by >= playH) { + g.birdY = playH - 1; + g.alive = false; + } else if (by < 0) { + // Bonk the ceiling but don't die — feels better than instant death. + g.birdY = 0; + g.birdVY = 0.2; + } + + for (const p of g.pipes) { + if (bx >= p.x && bx < p.x + FLAPPY_PIPE_W) { + if (by < p.gapY || by >= p.gapY + p.gapH) { + g.alive = false; + } + } + } + } + handleInput(data: string): void { if (this.disposed) return; let i = 0; @@ -169,6 +337,21 @@ export class TutRunner implements InteractiveProgram { i += 1; continue; } + if (this.screen === "flappy" && ch === " ") { + this.flap(); + i += 1; + continue; + } + if ( + this.screen === "flappy" && + this.flappy && + !this.flappy.alive && + (ch === "p" || ch === "P") + ) { + this.onOpenPocket?.(); + i += 1; + continue; + } if (ch === "q" || ch === "Q") { this.handleEscape(); return; @@ -205,11 +388,27 @@ export class TutRunner implements InteractiveProgram { // --- Input --- private menuLength(): number { - // SECTIONS + the trailing "Reset progress" entry + // SECTIONS + GitHub star + Flappy Term + the trailing "Reset progress" entry + return SECTIONS.length + 3; + } + + private starPromptIndex(): number { + return SECTIONS.length; + } + + private flappyIndex(): number { return SECTIONS.length + 1; } + private resetIndex(): number { + return SECTIONS.length + 2; + } + private handleArrow(letter: string): void { + if (this.screen === "flappy") { + if (letter === "A") this.flap(); + return; + } if (this.screen !== "menu") return; const len = this.menuLength(); if (letter === "A") { @@ -223,14 +422,45 @@ export class TutRunner implements InteractiveProgram { } private handleEnter(): void { + if (this.screen === "flappy") { + if (this.flappy && !this.flappy.alive) { + this.resetFlappyGame(); + this.render(); + } else { + this.flap(); + } + return; + } if (this.screen === "menu") { - if (this.menuIndex === SECTIONS.length) { + if (this.menuIndex === this.resetIndex()) { this.screen = "reset"; this.resetBuffer = ""; this.resetMismatch = false; this.render(); return; } + if (this.menuIndex === this.starPromptIndex()) { + const changed = this.state.resolveStarPrompt(); + this.onOpenGithub?.(); + if (!changed) this.render(); + return; + } + if (this.menuIndex === this.flappyIndex()) { + const { done, total } = this.state.totalProgress(); + if (done !== total) { + this.render(); + return; + } + this.screen = "flappy"; + this.resetFlappyGame(); + // One full clear when entering the game so leftover menu chrome + // doesn't peek through the diff-style game frames (which only + // CURSOR_HOME between ticks to avoid flicker). + this.write(CLEAR_SCREEN + CURSOR_HOME); + this.startFlappyTicks(); + this.render(); + return; + } const section = SECTIONS[this.menuIndex]; if (!section) return; this.sectionId = section.id; @@ -278,6 +508,13 @@ export class TutRunner implements InteractiveProgram { this.render(); return; } + if (this.screen === "flappy") { + this.stopFlappyTicks(); + this.flappy = null; + this.screen = "menu"; + this.render(); + return; + } this.exit(); } @@ -302,6 +539,10 @@ export class TutRunner implements InteractiveProgram { private render(): void { if (this.disposed) return; + if (this.screen === "flappy") { + this.renderFlappy(); + return; + } const lines = this.screen === "menu" ? this.renderMenu() @@ -321,7 +562,7 @@ export class TutRunner implements InteractiveProgram { lines.push(""); lines.push(` ${BOLD}Dormouse Playground Tutorial${RESET}`); lines.push( - ` ${DIM}${total.done}/${total.total} complete · \`Esc\`/\`q\` to exit · \`Enter\` to open · \`↑↓\` to navigate${RESET}`, + ` ${DIM}\`Esc\`/\`q\` to exit · \`Enter\` to open · \`↑↓\` to navigate${RESET}`, ); lines.push(""); SECTIONS.forEach((section, index) => { @@ -337,7 +578,32 @@ export class TutRunner implements InteractiveProgram { lines.push(` ${marker} ${label} ${progress}`); }); - const resetIndex = SECTIONS.length; + const starIndex = this.starPromptIndex(); + const starResolved = this.state.isStarPromptResolved(); + const starMarker = this.menuIndex === starIndex ? `${fg(36)}❯${RESET}` : " "; + const starLabel = + this.menuIndex === starIndex + ? `${BOLD}${STAR_PROMPT_TITLE}${RESET}` + : STAR_PROMPT_TITLE; + const starStatus = starResolved + ? `${fg(32)}[thanks ⭐]${RESET}` + : `${DIM}[not yet]${RESET}`; + lines.push(` ${starMarker} ${starLabel} ${starStatus}`); + + const flappyIndex = this.flappyIndex(); + const flappyUnlocked = total.done === total.total; + const flappyMarker = this.menuIndex === flappyIndex ? `${fg(36)}❯${RESET}` : " "; + const flappyTitle = flappyUnlocked ? FLAPPY_UNLOCKED_TITLE : FLAPPY_LOCKED_TITLE; + const flappyLabel = + this.menuIndex === flappyIndex + ? `${BOLD}${flappyTitle}${RESET}` + : flappyTitle; + const flappyStatus = flappyUnlocked + ? ` ${fg(32)}[High score: ${this.state.getFlappyHighScore()}]${RESET}` + : ` ${DIM}[LOCKED ${total.done}/${total.total}]${RESET}`; + lines.push(` ${flappyMarker} ${flappyLabel}${flappyStatus}`); + + const resetIndex = this.resetIndex(); const resetMarker = this.menuIndex === resetIndex ? `${fg(36)}❯${RESET}` : " "; const resetLabel = this.menuIndex === resetIndex @@ -349,6 +615,131 @@ export class TutRunner implements InteractiveProgram { return lines; } + private renderFlappy(): void { + const g = this.flappy; + if (!g) return; + const { cols, rows } = this.adapter.getPtySize(this.terminalId); + const COLS = cols; + const ROWS = rows; + const playH = Math.max(6, ROWS - FLAPPY_GROUND_H); + const C = FLAPPY_COLORS; + + // Use CURSOR_HOME (no CLEAR_SCREEN) per-frame to avoid the blank + // flash xterm would otherwise paint between frames; the full grid + // overwrites every cell so leftover content can't show through. + let out = CURSOR_HOME; + + for (let r = 0; r < ROWS; r++) { + let row = ""; + let currentColor = ""; + const setColor = (color: string) => { + if (color !== currentColor) { + row += color; + currentColor = color; + } + }; + + for (let c = 0; c < COLS; c++) { + // Ground area + if (r >= playH) { + if (r === playH) { + setColor(RESET + fg256(C.grass)); + row += "^"; + } else { + setColor(RESET + fg256(C.ground)); + row += "~"; + } + continue; + } + + // Pipes + let drewPipe = false; + for (const p of g.pipes) { + if (c >= p.x && c < p.x + FLAPPY_PIPE_W) { + const inGap = r >= p.gapY && r < p.gapY + p.gapH; + if (!inGap) { + const isEdge = c === p.x || c === p.x + FLAPPY_PIPE_W - 1; + const isCapTop = r === p.gapY - 1; + const isCapBot = r === p.gapY + p.gapH; + if (isCapTop || isCapBot) { + setColor(fg256(C.pipeD) + bg256(C.pipe)); + row += "="; + } else if (isEdge) { + setColor(fg256(C.pipeD) + bg256(C.pipe)); + row += "|"; + } else { + setColor(bg256(C.pipe) + fg256(C.pipeD)); + row += " "; + } + drewPipe = true; + break; + } + } + } + if (drewPipe) continue; + + // Bird + const bx = Math.round(g.birdX); + const by = Math.round(g.birdY); + if (r === by && c === bx) { + setColor(RESET + fg256(C.bird) + BOLD); + let glyph = ">"; + if (g.birdVY < -0.3) glyph = "^"; + else if (g.birdVY > 0.4) glyph = "v"; + row += glyph; + continue; + } + if (r === by && c === bx + 1) { + setColor(RESET + fg256(C.birdO) + BOLD); + row += ">"; + continue; + } + + setColor(RESET); + row += " "; + } + + out += row + RESET; + if (r < ROWS - 1) out += "\r\n"; + } + + // Overlays (absolute-positioned over the grid) + const scoreStr = `Score: ${g.score}`; + const bestStr = `Best: ${this.state.getFlappyHighScore()}`; + out += moveTo(1, 2) + RESET + fg256(C.text) + BOLD + scoreStr + RESET; + out += moveTo(1, Math.max(2, COLS - bestStr.length - 1)) + fg256(C.dim) + bestStr + RESET; + + if (!g.alive) { + const r = Math.max(1, Math.floor(ROWS / 2) - 3); + out += this.flappyCenteredAt(COLS, r, "+--------------------+", fg256(C.red) + BOLD); + out += this.flappyCenteredAt(COLS, r + 1, "| GAME OVER |", fg256(C.red) + BOLD); + out += this.flappyCenteredAt(COLS, r + 2, "+--------------------+", fg256(C.red) + BOLD); + out += this.flappyCenteredAt( + COLS, + r + 4, + `Score: ${g.score} Best: ${this.state.getFlappyHighScore()}`, + fg256(C.text), + ); + out += this.flappyCenteredAt(COLS, r + 6, "[ Enter to restart · Esc to exit ]", fg256(C.dim)); + out += this.flappyCenteredAt( + COLS, + r + 8, + "Checkout dormouse.sh/pocket to play on your phone [p]", + fg256(C.text), + ); + } else if (!g.started) { + const r = Math.min(ROWS, Math.floor(ROWS / 2) + 2); + out += this.flappyCenteredAt(COLS, r, "[ Space / Up to flap · Esc to exit ]", fg256(C.dim)); + } + + this.write(out); + } + + private flappyCenteredAt(cols: number, row: number, text: string, color: string): string { + const c = Math.max(1, Math.floor((cols - text.length) / 2) + 1); + return moveTo(row, c) + color + text + RESET; + } + private renderReset(): string[] { const lines: string[] = []; lines.push(""); @@ -356,7 +747,7 @@ export class TutRunner implements InteractiveProgram { lines.push(` ${DIM}\`Esc\` to cancel${RESET}`); lines.push(""); lines.push( - ` This will clear all checkmarks across every section.`, + ` This will clear all checkmarks and the GitHub star prompt.`, ); lines.push( ` ${DIM}Type \`reset\` and press \`Enter\` to confirm.${RESET}`, @@ -505,6 +896,8 @@ export class TutRunner implements InteractiveProgram { if (this.disposed) return; this.disposed = true; this.stopSpinnerTicks(); + this.stopFlappyTicks(); + this.flappy = null; this.busyDemoStart = null; this.stateUnsub?.(); this.stateUnsub = null; diff --git a/website/src/lib/tutorial-state.ts b/website/src/lib/tutorial-state.ts index 210f455..33ffab6 100644 --- a/website/src/lib/tutorial-state.ts +++ b/website/src/lib/tutorial-state.ts @@ -1,14 +1,26 @@ import { ALL_ITEM_IDS, ITEM_IDS, SECTIONS, type ItemId } from "./tut-items"; const STORAGE_KEY = "dormouse-tut-v3"; +const STAR_STORAGE_KEY = "dormouse-tut-star-v1"; +const FLAPPY_HIGH_SCORE_KEY = "dormouse-flappy-high-v1"; const KNOWN_IDS: ReadonlySet = new Set(ITEM_IDS); export class TutorialState { private completed = new Set(); + private starPromptResolved = false; + private flappyHighScore = 0; private listeners = new Set<() => void>(); private storage = typeof localStorage !== "undefined" ? localStorage : null; constructor() { + this.starPromptResolved = this.storage?.getItem(STAR_STORAGE_KEY) === "true"; + + const high = this.storage?.getItem(FLAPPY_HIGH_SCORE_KEY); + if (high) { + const parsed = Number.parseInt(high, 10); + if (Number.isFinite(parsed) && parsed >= 0) this.flappyHighScore = parsed; + } + const raw = this.storage?.getItem(STORAGE_KEY); if (!raw) return; try { @@ -35,6 +47,18 @@ export class TutorialState { return this.completed.has(id); } + isStarPromptResolved(): boolean { + return this.starPromptResolved; + } + + resolveStarPrompt(): boolean { + if (this.starPromptResolved) return false; + this.starPromptResolved = true; + this.notify(); + this.persistStarPrompt(); + return true; + } + markComplete(id: ItemId): boolean { if (this.completed.has(id)) return false; this.completed.add(id); @@ -44,12 +68,32 @@ export class TutorialState { } reset(): void { - if (this.completed.size === 0) return; + const changed = + this.completed.size > 0 || + this.starPromptResolved || + this.flappyHighScore > 0; + if (!changed) return; this.completed.clear(); + this.starPromptResolved = false; + this.flappyHighScore = 0; this.storage?.removeItem(STORAGE_KEY); + this.storage?.removeItem(STAR_STORAGE_KEY); + this.storage?.removeItem(FLAPPY_HIGH_SCORE_KEY); this.notify(); } + getFlappyHighScore(): number { + return this.flappyHighScore; + } + + recordFlappyScore(score: number): boolean { + if (!Number.isFinite(score) || score <= this.flappyHighScore) return false; + this.flappyHighScore = Math.floor(score); + this.persistFlappyHighScore(); + this.notify(); + return true; + } + sectionProgress(sectionId: string): { done: number; total: number } { const section = SECTIONS.find((s) => s.id === sectionId); if (!section) return { done: 0, total: 0 }; @@ -77,4 +121,20 @@ export class TutorialState { // listeners already fired against the new state. } } + + private persistStarPrompt(): void { + try { + this.storage?.setItem(STAR_STORAGE_KEY, "true"); + } catch { + // Quota or access errors shouldn't break in-memory progress. + } + } + + private persistFlappyHighScore(): void { + try { + this.storage?.setItem(FLAPPY_HIGH_SCORE_KEY, String(this.flappyHighScore)); + } catch { + // Quota or access errors shouldn't break in-memory progress. + } + } } diff --git a/website/src/pages/Playground.tsx b/website/src/pages/Playground.tsx index a00d4df..54439f2 100644 --- a/website/src/pages/Playground.tsx +++ b/website/src/pages/Playground.tsx @@ -48,6 +48,18 @@ function Playground() { const busyDemoDisposeRef = useRef<(() => void) | null>(null); const alertDemoPaneIdRef = useRef(null); + const handleOpenGithub = useCallback(() => { + window.open( + "https://github.com/diffplug/dormouse", + "_blank", + "noopener,noreferrer", + ); + }, []); + + const handleOpenPocket = useCallback(() => { + window.open("https://dormouse.sh/pocket", "_blank", "noopener,noreferrer"); + }, []); + const tryAutoStart = useCallback((pane: PaneSpec) => { if (autoStartedRef.current.has(pane.id)) return; const shellRegistry = shellRegistryRef.current; @@ -119,6 +131,8 @@ function Playground() { ); }, onTogglePlaceToPaste: () => setPlaceToPasteOpen((open) => !open), + onOpenGithub: handleOpenGithub, + onOpenPocket: handleOpenPocket, }); } if (name === "ascii-splash" || name === "splash") { @@ -173,7 +187,7 @@ function Playground() { busyDemoDisposeRef.current?.(); busyDemoDisposeRef.current = null; }; - }, [isPhone, tryAutoStart]); + }, [handleOpenGithub, isPhone, tryAutoStart]); const handleApiReady = useCallback((api: any) => { const shellRegistry = shellRegistryRef.current; diff --git a/website/src/pages/Pocket.tsx b/website/src/pages/Pocket.tsx index c4dd9b0..41ca3a0 100644 --- a/website/src/pages/Pocket.tsx +++ b/website/src/pages/Pocket.tsx @@ -73,6 +73,18 @@ function PocketTerminalExperience({ const cursorTouchAvailable = activeMouseState?.mouseReporting !== undefined && activeMouseState.mouseReporting !== "none"; + const handleOpenGithub = useCallback(() => { + window.open( + "https://github.com/diffplug/dormouse", + "_blank", + "noopener,noreferrer", + ); + }, []); + + const handleOpenPocket = useCallback(() => { + window.open("https://dormouse.sh/pocket", "_blank", "noopener,noreferrer"); + }, []); + const tryAutoStart = useCallback((id: string) => { if (id !== POCKET_PANE) return; if (autoStartedRef.current.has(id)) return; @@ -120,6 +132,8 @@ function PocketTerminalExperience({ ); }, onTogglePlaceToPaste: () => {}, + onOpenGithub: handleOpenGithub, + onOpenPocket: handleOpenPocket, }); } if (name === "ascii-splash" || name === "splash") { @@ -161,7 +175,7 @@ function PocketTerminalExperience({ autoStartedRef.current.clear(); adapterRef.current = null; }; - }, [tryAutoStart]); + }, [handleOpenGithub, tryAutoStart]); useEffect(() => { const reporting = activeMouseState?.mouseReporting ?? "none";