diff --git a/great_docs/_term_player/editor.py b/great_docs/_term_player/editor.py index f181fcda..aaa8ca7e 100644 --- a/great_docs/_term_player/editor.py +++ b/great_docs/_term_player/editor.py @@ -39,6 +39,9 @@ def _build_editor_data(recording: Recording, script: Script | None) -> dict[str, "idle_time_limit": script.idle_time_limit if script else None, "speed": script.speed if script else 1.0, "window_chrome": script.window_chrome if script else "colorful", + "font_family": script.font_family if script else None, + "prompt": script.prompt if script else None, + "prompt_pattern": script.prompt_pattern if script else None, }, "chapters": [ {"time": ch.time, "label": ch.label} for ch in (script.chapters if script else []) @@ -90,6 +93,12 @@ def _serialize_script(script_data: dict[str, Any], source_path: str) -> str: lines.append(f" speed: {settings['speed']}") if settings.get("window_chrome"): lines.append(f" window_chrome: {settings['window_chrome']}") + if settings.get("font_family"): + lines.append(f' font_family: "{settings["font_family"]}"') + if settings.get("prompt"): + lines.append(f' prompt: "{settings["prompt"]}"') + if settings.get("prompt_pattern"): + lines.append(f" prompt_pattern: '{settings['prompt_pattern']}'") lines.append("") chapters = script_data.get("chapters", []) @@ -1359,6 +1368,156 @@ def _get_editor_html() -> str: transform: translateY(-4px); } +/* Settings panel (gear icon) */ +.btn-settings-circle { + position: absolute; + top: 12px; + left: 44px; + z-index: 19; + width: 26px; + height: 26px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--surface2); + color: var(--text-dim); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); +} +.btn-settings-circle:hover { + background: var(--border); + color: var(--text); +} +.btn-settings-circle.active { + background: var(--accent); + color: #000; + border-color: var(--accent); +} + +.settings-panel { + position: absolute; + top: 12px; + left: 12px; + z-index: 21; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + font-size: 11px; + min-width: 250px; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + transition: opacity 0.15s, transform 0.15s; +} +.settings-panel.hidden { + opacity: 0; + pointer-events: none; + transform: translateY(-4px); +} +.settings-panel .settings-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} +.settings-panel .settings-title { + font-weight: 600; + font-size: 12px; + color: var(--text); +} +.settings-panel .settings-close { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + font-size: 16px; + padding: 0 2px; +} +.settings-panel .settings-close:hover { color: var(--text); } +.settings-panel .setting-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + gap: 8px; +} +.settings-panel .setting-label { + color: var(--text-dim); + font-size: 11px; + white-space: nowrap; +} +.settings-panel input[type="text"], +.settings-panel input[type="number"] { + width: 100%; + padding: 5px 8px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 12px; + font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', Menlo, monospace; + outline: none; + flex: 1; +} +.settings-panel input[type="number"]::-webkit-inner-spin-button, +.settings-panel input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + appearance: none; +} +.settings-panel input[type="number"] { + -moz-appearance: textfield; + padding-right: 32px; +} +.settings-panel .number-wrap { + position: relative; + flex: 1; +} +.settings-panel input:focus { border-color: var(--accent); } +.settings-panel select { + width: 100%; + padding: 5px 8px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 12px; + outline: none; + flex: 1; +} +.settings-panel select:focus { border-color: var(--accent); } +.settings-panel .setting-divider { + border-top: 1px solid var(--border); + margin: 8px 0; +} +.settings-panel .prompt-presets { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-top: 4px; + margin-bottom: 10px; +} +.settings-panel .prompt-preset { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 12px; + padding: 3px 8px; + cursor: pointer; + transition: all 0.1s; +} +.settings-panel .prompt-preset:hover { + background: var(--border); +} +.settings-panel .prompt-preset.active { + background: var(--accent); + color: #000; + border-color: var(--accent); +} + .inspector-header { display: flex; align-items: center; @@ -1512,7 +1671,57 @@ def _get_editor_html() -> str:
+ +
@@ -1570,6 +1779,7 @@ def _get_editor_html() -> str: Seek [] Prev/Next chapter I Show Info + G Settings Y View YAML ⌘S Save Del Delete selected @@ -1938,9 +2148,19 @@ def _get_editor_html() -> str: } } + // Track prompt positions via input events + const promptRows = new Set(); // rows where prompts were detected + let lastOutputRow = -1; + for (const ev of data.recording.events) { if (ev.time > currentTime) break; + if (ev.code === 'i') { + // Input event: current row has a prompt (text before cursor) + promptRows.add(curRow); + continue; + } if (ev.code !== 'o') continue; + lastOutputRow = curRow; const s = ev.data; let i = 0; while (i < s.length) { @@ -2003,6 +2223,61 @@ def _get_editor_html() -> str: } } + // --- Prompt substitution --- + const promptSetting = data.script.settings.prompt; + const promptPattern = data.script.settings.prompt_pattern; + if (promptSetting) { + const PROMPT_CHARS = ['$', '%', '#', '>', '\u276f', '\u2192', '\u25b6', '\u27e9', '\u03bb']; + if (promptRows.size > 0) { + // Structural detection: substitute prompt char on identified rows + for (const r of promptRows) { + if (r >= rows) continue; + for (let c2 = 0; c2 < cols; c2++) { + const ch = grid[r][c2].char; + if (PROMPT_CHARS.includes(ch)) { + grid[r][c2] = {...grid[r][c2], char: promptSetting}; + break; + } + if (ch !== ' ' && !PROMPT_CHARS.includes(ch)) break; + } + } + } else if (promptPattern) { + // Regex fallback for recordings without input events + try { + const re = new RegExp(promptPattern); + for (let r = 0; r < rows; r++) { + const rowText = grid[r].map(c2 => c2.char).join(''); + if (re.test(rowText)) { + for (let c2 = 0; c2 < cols; c2++) { + const ch = grid[r][c2].char; + if (PROMPT_CHARS.includes(ch)) { + grid[r][c2] = {...grid[r][c2], char: promptSetting}; + break; + } + if (ch !== ' ' && !PROMPT_CHARS.includes(ch)) break; + } + } + } + } catch(e) { /* invalid regex, skip */ } + } else { + // Heuristic fallback: scan all rows for a leading prompt char + // (used when no input events and no prompt_pattern) + for (let r = 0; r < rows; r++) { + for (let c2 = 0; c2 < cols; c2++) { + const ch = grid[r][c2].char; + if (ch === ' ') continue; // skip leading whitespace + if (PROMPT_CHARS.includes(ch)) { + // Verify it looks like a prompt: char followed by a space + if (c2 + 1 < cols && grid[r][c2 + 1].char === ' ') { + grid[r][c2] = {...grid[r][c2], char: promptSetting}; + } + } + break; // only check the first non-space char per row + } + } + } + } + // Render grid as HTML with inline color styles const htmlLines = []; for (let r = 0; r < rows; r++) { @@ -3360,6 +3635,138 @@ def _get_editor_html() -> str: btnInspector.classList.remove('active'); } + // --- Settings panel --- + const settingsPanel = document.getElementById('settings-panel'); + const btnSettings = document.getElementById('btn-settings'); + const settingSpeed = document.getElementById('setting-speed'); + const settingIdle = document.getElementById('setting-idle'); + const settingChrome = document.getElementById('setting-chrome'); + const settingFontFamily = document.getElementById('setting-font-family'); + const settingPrompt = document.getElementById('setting-prompt'); + const settingPromptPattern = document.getElementById('setting-prompt-pattern'); + + btnSettings.addEventListener('click', toggleSettings); + document.getElementById('settings-close').addEventListener('click', hideSettings); + + // Add custom number spinner buttons to settings number inputs (same as properties panel) + settingsPanel.querySelectorAll('input[type="number"]').forEach(input => { + const wrap = document.createElement('div'); + wrap.className = 'number-wrap'; + input.parentNode.insertBefore(wrap, input); + wrap.appendChild(input); + const step = parseFloat(input.step) || 1; + const btnUp = document.createElement('button'); + btnUp.type = 'button'; + btnUp.className = 'num-btn num-btn-up'; + btnUp.innerHTML = '▴'; + btnUp.addEventListener('click', () => { input.value = (parseFloat(input.value || 0) + step).toFixed(2); input.dispatchEvent(new Event('input')); }); + const btnDown = document.createElement('button'); + btnDown.type = 'button'; + btnDown.className = 'num-btn num-btn-down'; + btnDown.innerHTML = '▾'; + btnDown.addEventListener('click', () => { input.value = (parseFloat(input.value || 0) - step).toFixed(2); input.dispatchEvent(new Event('input')); }); + wrap.appendChild(btnUp); + wrap.appendChild(btnDown); + }); + + function toggleSettings() { + if (settingsPanel.classList.contains('hidden')) { + showSettings(); + } else { + hideSettings(); + } + } + + function showSettings() { + // Populate from current data + const s = data.script.settings; + settingSpeed.value = s.speed != null ? s.speed : 1.0; + settingIdle.value = s.idle_time_limit != null ? s.idle_time_limit : ''; + settingChrome.value = s.window_chrome || 'colorful'; + settingFontFamily.value = s.font_family || ''; + settingPrompt.value = s.prompt || ''; + settingPromptPattern.value = s.prompt_pattern || ''; + updatePromptPresets(); + settingsPanel.classList.remove('hidden'); + btnSettings.classList.add('active'); + // Hide inspector if open + hideInspector(); + } + + function hideSettings() { + settingsPanel.classList.add('hidden'); + btnSettings.classList.remove('active'); + } + + function updatePromptPresets() { + const current = settingPrompt.value; + settingsPanel.querySelectorAll('.prompt-preset').forEach(btn => { + btn.classList.toggle('active', btn.dataset.prompt === current); + }); + } + + settingSpeed.addEventListener('change', () => { + data.script.settings.speed = parseFloat(settingSpeed.value); + markDirty(); + applySettingsPreview(); + }); + + settingIdle.addEventListener('input', () => { + const v = parseFloat(settingIdle.value); + if (settingIdle.value === '' || settingIdle.value == null) { + data.script.settings.idle_time_limit = null; + } else if (!isNaN(v) && v >= 0) { + data.script.settings.idle_time_limit = v; + } + markDirty(); + applySettingsPreview(); + }); + + settingChrome.addEventListener('change', () => { + data.script.settings.window_chrome = settingChrome.value; + markDirty(); + applySettingsPreview(); + }); + + settingFontFamily.addEventListener('input', () => { + const v = settingFontFamily.value.trim(); + data.script.settings.font_family = v || null; + markDirty(); + applySettingsPreview(); + }); + + settingPrompt.addEventListener('input', () => { + const v = settingPrompt.value.trim(); + data.script.settings.prompt = v || null; + updatePromptPresets(); + markDirty(); + applySettingsPreview(); + }); + + settingPromptPattern.addEventListener('input', () => { + const v = settingPromptPattern.value.trim(); + data.script.settings.prompt_pattern = v || null; + markDirty(); + applySettingsPreview(); + }); + + // Prompt preset clicks + settingsPanel.addEventListener('click', (e) => { + const preset = e.target.closest('.prompt-preset'); + if (!preset) return; + const val = preset.dataset.prompt; + settingPrompt.value = val; + data.script.settings.prompt = val; + updatePromptPresets(); + markDirty(); + applySettingsPreview(); + }); + + // Reset playhead and re-render when global settings change + function applySettingsPreview() { + seek(0); + } + // --- Resize handle (preview ↔ transport/timeline) --- (function() { const handle = document.getElementById('resize-handle'); @@ -3543,11 +3950,13 @@ def _get_editor_html() -> str: else if (e.key === 'Delete' || e.key === 'Backspace') { window.deleteSelected(); } else if (e.key === 'Escape') { if (yamlBackdrop.classList.contains('visible')) { hideYamlPreview(); } + else if (!settingsPanel.classList.contains('hidden')) { hideSettings(); } else { propsPanel.classList.remove('open'); selectedItem = null; clearCutHighlights(); } } else if (e.key === 's' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); save(); } else if (e.key === 'y' || e.key === 'Y') { showYamlPreview(); } else if (e.key === 'i' || e.key === 'I') { toggleInspector(); } + else if (e.key === 'g' || e.key === 'G') { toggleSettings(); } }); // Close panel on click outside diff --git a/great_docs/_term_player/manifest.py b/great_docs/_term_player/manifest.py index d469a4bd..0b56b1fa 100644 --- a/great_docs/_term_player/manifest.py +++ b/great_docs/_term_player/manifest.py @@ -3,10 +3,11 @@ from __future__ import annotations import json +import re from dataclasses import dataclass, field from pathlib import Path -from .emulator import TerminalEmulator +from .emulator import ScreenState, TerminalEmulator from .parser import Recording from .renderer import render_frame from .script import Annotation, Chapter, Highlight, Script, Snippet @@ -173,6 +174,17 @@ def generate_manifest( if event.code == "m": chapters.append(Chapter(time=event.time, label=event.data)) + # Detect prompt prefix for substitution (if prompt setting is configured) + prompt_prefix: str | None = None + prompt_replacement: str | None = None + prompt_pattern: str | None = None + + if script and script.prompt: + prompt_replacement = script.prompt + prompt_pattern = script.prompt_pattern + # Run a pre-pass to detect the prompt from input events + prompt_prefix = _detect_prompt_prefix(recording) + # Determine keyframe times keyframe_times = _compute_keyframe_times(recording.duration, keyframe_interval, chapters) @@ -215,6 +227,18 @@ def generate_manifest( # Capture keyframe state = emu.screen + + # Apply prompt substitution if configured + if prompt_replacement: + if prompt_prefix: + state = _apply_prompt_substitution( + state, prompt_prefix, prompt_replacement, prompt_pattern + ) + elif prompt_pattern: + state = _apply_prompt_pattern_substitution( + state, prompt_pattern, prompt_replacement + ) + frame_num = len(keyframes) filename = f"{prefix}-{frame_num:03d}.svg" @@ -293,3 +317,164 @@ def _delta_change_to_dict(change: DeltaChange) -> dict: if change.bold: d["bold"] = True return d + + +# --------------------------------------------------------------------------- +# Prompt substitution +# --------------------------------------------------------------------------- + + +def _detect_prompt_prefix(recording: Recording) -> str | None: + """Detect the prompt prefix string by correlating input events with screen state. + + Runs through the recording events with an emulator. At each input ("i") event, + captures the text on the cursor row from column 0 up to the cursor position. + Returns the most common prompt prefix found, or None if no input events exist. + """ + emu = TerminalEmulator(cols=recording.term.cols, rows=recording.term.rows) + prompt_texts: list[str] = [] + + for event in recording.events: + if event.code == "o": + emu.feed(event.data) + elif event.code == "r": + parts = event.data.split("x") + if len(parts) == 2: + try: + emu.resize(int(parts[0]), int(parts[1])) + except ValueError: + pass + elif event.code == "i": + screen = emu.screen + row = screen.cursor_row + col = screen.cursor_col + if col > 0: + # Extract text on this row up to cursor position + text = "".join(screen.cells[row][c].char for c in range(col)) + prompt_texts.append(text) + + if not prompt_texts: + return None + + # Find the most common prompt prefix + from collections import Counter + + counts = Counter(prompt_texts) + most_common = counts.most_common(1)[0][0] + return most_common + + +# Common prompt characters, ordered by specificity +_PROMPT_CHARS = ("❯", "➜", "→", "▶", "⟩", "λ", "%", "$", ">", "#") + + +def _find_prompt_char_in_prefix(prefix: str) -> tuple[int, str] | None: + """Find the last prompt character in a detected prefix string. + + Returns (col_index, char) or None if no known prompt char is found. + """ + # Search backwards for the last known prompt char + for i in range(len(prefix) - 1, -1, -1): + if prefix[i] in _PROMPT_CHARS: + return (i, prefix[i]) + return None + + +def _apply_prompt_substitution( + state: ScreenState, + prompt_prefix: str, + replacement: str, + prompt_pattern: str | None = None, +) -> ScreenState: + """Apply prompt character substitution to a screen state. + + Finds rows that start with the detected prompt prefix and replaces the + prompt character with the configured replacement string. + + Parameters + ---------- + state + The terminal screen state to modify. + prompt_prefix + The detected prompt prefix (e.g., "$ " or "user@host:~ $ "). + replacement + The string to substitute for the prompt character. + prompt_pattern + Optional regex pattern for fallback prompt detection (used when + no input events were available to detect the prefix). + + Returns + ------- + ScreenState + A copy of the state with prompt characters substituted. + """ + # Find the prompt character position within the prefix + char_info = _find_prompt_char_in_prefix(prompt_prefix) + if char_info is None: + return state + + prompt_col, original_char = char_info + + # Make a copy so we don't mutate the original + new_state = state.copy() + + for row_idx in range(new_state.rows): + # Extract the row text up to the prompt prefix length + row_text = "".join( + new_state.cells[row_idx][c].char for c in range(min(len(prompt_prefix), new_state.cols)) + ) + + # Check if this row starts with the detected prompt prefix + if row_text == prompt_prefix: + # Substitute the prompt character cell + new_state.cells[row_idx][prompt_col].char = replacement + + return new_state + + +def _apply_prompt_pattern_substitution( + state: ScreenState, + pattern: str, + replacement: str, +) -> ScreenState: + """Apply prompt substitution using a regex pattern (fallback mode). + + Used when no input events are available to detect prompts structurally. + The pattern should match the prompt portion at the start of a line. + + Parameters + ---------- + state + The terminal screen state to modify. + pattern + Regex pattern that matches the prompt at line start. The last + character of the match is replaced. + replacement + The string to substitute for the prompt character. + + Returns + ------- + ScreenState + A copy of the state with prompt characters substituted. + """ + try: + regex = re.compile(pattern) + except re.error: + return state + + new_state = state.copy() + + for row_idx in range(new_state.rows): + # Extract full row text + row_text = "".join(new_state.cells[row_idx][c].char for c in range(new_state.cols)) + + m = regex.match(row_text) + if m: + # Find the prompt char — last non-space char in the match + matched = m.group(0) + for i in range(len(matched) - 1, -1, -1): + if matched[i].strip(): + new_state.cells[row_idx][i].char = replacement + break + + return new_state diff --git a/great_docs/_term_player/script.py b/great_docs/_term_player/script.py index b3361702..6b62b98a 100644 --- a/great_docs/_term_player/script.py +++ b/great_docs/_term_player/script.py @@ -85,6 +85,8 @@ class Script: font_family: str | None = None show_cursor: bool = True window_chrome: str = "none" + prompt: str | None = None + prompt_pattern: str | None = None chapters: list[Chapter] = field(default_factory=list) cuts: list[Cut] = field(default_factory=list) annotations: list[Annotation] = field(default_factory=list) @@ -131,6 +133,12 @@ def _parse_script_data(data: dict[str, Any]) -> Script: script.font_family = settings.get("font_family") script.show_cursor = settings.get("show_cursor", True) script.window_chrome = settings.get("window_chrome", "none") + if "prompt" in settings: + script.prompt = str(settings["prompt"]) if settings["prompt"] is not None else None + if "prompt_pattern" in settings: + script.prompt_pattern = ( + str(settings["prompt_pattern"]) if settings["prompt_pattern"] is not None else None + ) # Chapters for ch in data.get("chapters", []): diff --git a/tests/test_term_editor.py b/tests/test_term_editor.py new file mode 100644 index 00000000..601fbd07 --- /dev/null +++ b/tests/test_term_editor.py @@ -0,0 +1,513 @@ +"""Tests for the _term_player.editor module (data pass and YAML serializer).""" + +from __future__ import annotations + +import yaml +import pytest + +from great_docs._term_player.editor import _build_editor_data, _serialize_script +from great_docs._term_player.parser import Event, Recording, TermInfo +from great_docs._term_player.script import ( + Annotation, + Chapter, + Cut, + Script, + Snippet, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_recording(**kwargs) -> Recording: + """Create a minimal Recording for testing.""" + duration = kwargs.pop("duration", 10.0) + defaults = { + "events": [Event(time=duration, code="o", data="")], + "term": TermInfo(cols=80, rows=24), + "title": "test", + } + defaults.update(kwargs) + return Recording(**defaults) + + +def _make_script(**kwargs) -> Script: + """Create a Script with sensible defaults, overridable via kwargs.""" + defaults = { + "source": "demo.termshow", + "idle_time_limit": None, + "speed": 1.0, + "window_chrome": "colorful", + "font_family": None, + "prompt": None, + "prompt_pattern": None, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [], + } + defaults.update(kwargs) + return Script(**defaults) + + +# --------------------------------------------------------------------------- +# _build_editor_data +# --------------------------------------------------------------------------- + + +class TestBuildEditorData: + """Tests for _build_editor_data().""" + + def test_settings_defaults_no_script(self): + rec = _make_recording() + data = _build_editor_data(rec, None) + s = data["script"]["settings"] + assert s["idle_time_limit"] is None + assert s["speed"] == 1.0 + assert s["window_chrome"] == "colorful" + assert s["font_family"] is None + assert s["prompt"] is None + assert s["prompt_pattern"] is None + + def test_settings_with_all_fields(self): + script = _make_script( + idle_time_limit=2.0, + speed=1.5, + window_chrome="simple", + font_family="JetBrains Mono, monospace", + prompt="❯", + prompt_pattern=r"^\$ ", + ) + data = _build_editor_data(_make_recording(), script) + s = data["script"]["settings"] + assert s["idle_time_limit"] == 2.0 + assert s["speed"] == 1.5 + assert s["window_chrome"] == "simple" + assert s["font_family"] == "JetBrains Mono, monospace" + assert s["prompt"] == "❯" + assert s["prompt_pattern"] == r"^\$ " + + def test_settings_prompt_none(self): + script = _make_script(prompt=None, prompt_pattern=None) + data = _build_editor_data(_make_recording(), script) + s = data["script"]["settings"] + assert s["prompt"] is None + assert s["prompt_pattern"] is None + + def test_chapters_passed_through(self): + script = _make_script( + chapters=[ + Chapter(time=0.0, label="Intro"), + Chapter(time=5.0, label="Demo"), + ] + ) + data = _build_editor_data(_make_recording(), script) + chs = data["script"]["chapters"] + assert len(chs) == 2 + assert chs[0] == {"time": 0.0, "label": "Intro"} + assert chs[1] == {"time": 5.0, "label": "Demo"} + + def test_annotations_passed_through(self): + script = _make_script( + annotations=[ + Annotation( + time=1.0, duration=3.0, text="Hello", position="top-right", style="callout" + ), + ] + ) + data = _build_editor_data(_make_recording(), script) + anns = data["script"]["annotations"] + assert len(anns) == 1 + assert anns[0]["text"] == "Hello" + assert anns[0]["position"] == "top-right" + + def test_cuts_passed_through(self): + script = _make_script( + cuts=[ + Cut(start=2.0, end=4.0, type="ellipsis"), + ] + ) + data = _build_editor_data(_make_recording(), script) + cuts = data["script"]["cuts"] + assert len(cuts) == 1 + assert cuts[0] == {"start": 2.0, "end": 4.0, "type": "ellipsis"} + + def test_snippets_passed_through(self): + script = _make_script( + snippets=[ + Snippet(time=1.0, duration=5.0, text="pip install x", match="", label="Install"), + ] + ) + data = _build_editor_data(_make_recording(), script) + snips = data["script"]["snippets"] + assert len(snips) == 1 + assert snips[0]["text"] == "pip install x" + assert snips[0]["label"] == "Install" + + def test_recording_fields(self): + rec = _make_recording( + events=[Event(time=0.5, code="o", data="hello"), Event(time=5.0, code="o", data="")], + title="My Demo", + ) + data = _build_editor_data(rec, _make_script()) + r = data["recording"] + assert r["title"] == "My Demo" + assert r["duration"] == 5.0 + assert r["term"] == {"cols": 80, "rows": 24} + assert len(r["events"]) == 2 + assert r["events"][0] == {"time": 0.5, "code": "o", "data": "hello"} + + +# --------------------------------------------------------------------------- +# _serialize_script +# --------------------------------------------------------------------------- + + +class TestSerializeScript: + """Tests for _serialize_script().""" + + def test_minimal_settings(self): + """Only window_chrome set (speed=1.0 is default, so omitted).""" + script_data = { + "settings": { + "idle_time_limit": None, + "speed": 1.0, + "window_chrome": "colorful", + "font_family": None, + "prompt": None, + "prompt_pattern": None, + }, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [], + } + yaml_str = _serialize_script(script_data, "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["source"] == "demo.termshow" + assert parsed["settings"]["window_chrome"] == "colorful" + assert "prompt" not in parsed["settings"] + assert "prompt_pattern" not in parsed["settings"] + assert "font_family" not in parsed["settings"] + + def test_prompt_serialized(self): + script_data = { + "settings": { + "idle_time_limit": None, + "speed": 1.0, + "window_chrome": "colorful", + "font_family": None, + "prompt": "❯", + "prompt_pattern": None, + }, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [], + } + yaml_str = _serialize_script(script_data, "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["settings"]["prompt"] == "❯" + assert "prompt_pattern" not in parsed["settings"] + + def test_prompt_and_pattern_serialized(self): + script_data = { + "settings": { + "idle_time_limit": None, + "speed": 1.0, + "window_chrome": "colorful", + "font_family": None, + "prompt": "→", + "prompt_pattern": r"^\$ ", + }, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [], + } + yaml_str = _serialize_script(script_data, "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["settings"]["prompt"] == "→" + assert parsed["settings"]["prompt_pattern"] == r"^\$ " + + def test_font_family_single(self): + script_data = { + "settings": { + "idle_time_limit": None, + "speed": 1.0, + "window_chrome": "colorful", + "font_family": "JetBrains Mono", + "prompt": None, + "prompt_pattern": None, + }, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [], + } + yaml_str = _serialize_script(script_data, "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["settings"]["font_family"] == "JetBrains Mono" + + def test_font_family_comma_list(self): + script_data = { + "settings": { + "idle_time_limit": None, + "speed": 1.0, + "window_chrome": "colorful", + "font_family": "JetBrains Mono, Fira Code, monospace", + "prompt": None, + "prompt_pattern": None, + }, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [], + } + yaml_str = _serialize_script(script_data, "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["settings"]["font_family"] == "JetBrains Mono, Fira Code, monospace" + + def test_speed_non_default_serialized(self): + script_data = { + "settings": { + "idle_time_limit": None, + "speed": 2.0, + "window_chrome": "colorful", + "font_family": None, + "prompt": None, + "prompt_pattern": None, + }, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [], + } + yaml_str = _serialize_script(script_data, "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["settings"]["speed"] == 2.0 + + def test_speed_default_omitted(self): + script_data = { + "settings": { + "idle_time_limit": None, + "speed": 1.0, + "window_chrome": "colorful", + "font_family": None, + "prompt": None, + "prompt_pattern": None, + }, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [], + } + yaml_str = _serialize_script(script_data, "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert "speed" not in parsed["settings"] + + def test_idle_time_limit_serialized(self): + script_data = { + "settings": { + "idle_time_limit": 2.5, + "speed": 1.0, + "window_chrome": "colorful", + "font_family": None, + "prompt": None, + "prompt_pattern": None, + }, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [], + } + yaml_str = _serialize_script(script_data, "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["settings"]["idle_time_limit"] == 2.5 + + def test_chapters_serialized_sorted(self): + script_data = { + "settings": { + "idle_time_limit": None, + "speed": 1.0, + "window_chrome": "colorful", + "font_family": None, + "prompt": None, + "prompt_pattern": None, + }, + "chapters": [ + {"time": 5.0, "label": "Second"}, + {"time": 0.0, "label": "First"}, + ], + "annotations": [], + "cuts": [], + "snippets": [], + } + yaml_str = _serialize_script(script_data, "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["chapters"][0]["label"] == "First" + assert parsed["chapters"][1]["label"] == "Second" + + def test_snippets_with_match(self): + script_data = { + "settings": { + "idle_time_limit": None, + "speed": 1.0, + "window_chrome": "colorful", + "font_family": None, + "prompt": None, + "prompt_pattern": None, + }, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [ + {"time": 1.0, "duration": 5.0, "text": "", "match": r"\$ (.+)", "label": "cmd"}, + ], + } + yaml_str = _serialize_script(script_data, "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["snippets"][0]["match"] == r"\$ (.+)" + assert parsed["snippets"][0]["label"] == "cmd" + + def test_all_settings_combined(self): + """Full settings with every field populated.""" + script_data = { + "settings": { + "idle_time_limit": 1.5, + "speed": 3.0, + "window_chrome": "simple", + "font_family": "Fira Code, monospace", + "prompt": "$", + "prompt_pattern": r"^\$ ", + }, + "chapters": [{"time": 0.0, "label": "Start"}], + "annotations": [ + { + "time": 1.0, + "duration": 2.0, + "text": "Hi", + "position": "top-right", + "style": "callout", + "width": "medium", + } + ], + "cuts": [{"start": 3.0, "end": 4.0, "type": "jump"}], + "snippets": [ + {"time": 0.5, "duration": 3.0, "text": "echo hello", "match": "", "label": "Run"} + ], + } + yaml_str = _serialize_script(script_data, "rec.termshow") + parsed = yaml.safe_load(yaml_str) + s = parsed["settings"] + assert s["idle_time_limit"] == 1.5 + assert s["speed"] == 3.0 + assert s["window_chrome"] == "simple" + assert s["font_family"] == "Fira Code, monospace" + assert s["prompt"] == "$" + assert s["prompt_pattern"] == r"^\$ " + assert len(parsed["chapters"]) == 1 + assert len(parsed["annotations"]) == 1 + assert len(parsed["cuts"]) == 1 + assert len(parsed["snippets"]) == 1 + + def test_empty_script(self): + """All None/empty produces minimal YAML.""" + script_data = { + "settings": { + "idle_time_limit": None, + "speed": 1.0, + "window_chrome": None, + "font_family": None, + "prompt": None, + "prompt_pattern": None, + }, + "chapters": [], + "annotations": [], + "cuts": [], + "snippets": [], + } + yaml_str = _serialize_script(script_data, "x.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["source"] == "x.termshow" + # No chapters/annotations/cuts/snippets keys when empty + assert parsed.get("chapters") is None + assert parsed.get("annotations") is None + assert parsed.get("cuts") is None + assert parsed.get("snippets") is None + + +# --------------------------------------------------------------------------- +# Round-trip: _build_editor_data → _serialize_script → yaml.safe_load +# --------------------------------------------------------------------------- + + +class TestEditorRoundTrip: + """Test that data passes through build → serialize → parse intact.""" + + def test_settings_round_trip(self): + script = _make_script( + idle_time_limit=2.0, + speed=1.5, + window_chrome="simple", + font_family="Cascadia Code", + prompt="❯", + prompt_pattern=r"^\$ ", + ) + data = _build_editor_data(_make_recording(), script) + yaml_str = _serialize_script(data["script"], "demo.termshow") + parsed = yaml.safe_load(yaml_str) + s = parsed["settings"] + assert s["idle_time_limit"] == 2.0 + assert s["speed"] == 1.5 + assert s["window_chrome"] == "simple" + assert s["font_family"] == "Cascadia Code" + assert s["prompt"] == "❯" + assert s["prompt_pattern"] == r"^\$ " + + def test_prompt_round_trip_none(self): + script = _make_script(prompt=None, prompt_pattern=None) + data = _build_editor_data(_make_recording(), script) + yaml_str = _serialize_script(data["script"], "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert "prompt" not in parsed["settings"] + assert "prompt_pattern" not in parsed["settings"] + + def test_font_family_round_trip_list(self): + script = _make_script(font_family="JetBrains Mono, Fira Code, monospace") + data = _build_editor_data(_make_recording(), script) + yaml_str = _serialize_script(data["script"], "demo.termshow") + parsed = yaml.safe_load(yaml_str) + assert parsed["settings"]["font_family"] == "JetBrains Mono, Fira Code, monospace" + + def test_full_round_trip(self): + script = _make_script( + idle_time_limit=1.0, + speed=2.0, + window_chrome="colorful", + font_family="Menlo", + prompt=">", + chapters=[Chapter(time=0.0, label="Start"), Chapter(time=5.0, label="End")], + annotations=[ + Annotation( + time=1.0, duration=3.0, text="Note", position="top-right", style="subtle" + ) + ], + cuts=[Cut(start=2.0, end=3.0, type="ellipsis")], + snippets=[Snippet(time=0.5, duration=4.0, text="echo hi", label="Run")], + ) + data = _build_editor_data(_make_recording(), script) + yaml_str = _serialize_script(data["script"], "demo.termshow") + parsed = yaml.safe_load(yaml_str) + + assert parsed["settings"]["prompt"] == ">" + assert parsed["settings"]["font_family"] == "Menlo" + assert len(parsed["chapters"]) == 2 + assert parsed["chapters"][0]["label"] == "Start" + assert len(parsed["annotations"]) == 1 + assert parsed["annotations"][0]["text"] == "Note" + assert len(parsed["cuts"]) == 1 + assert len(parsed["snippets"]) == 1 + assert parsed["snippets"][0]["text"] == "echo hi" diff --git a/tests/test_term_manifest.py b/tests/test_term_manifest.py index 18bd28b7..52fa0a68 100644 --- a/tests/test_term_manifest.py +++ b/tests/test_term_manifest.py @@ -12,8 +12,12 @@ DeltaEntry, KeyframeEntry, Manifest, + _apply_prompt_pattern_substitution, + _apply_prompt_substitution, _compute_keyframe_times, _delta_change_to_dict, + _detect_prompt_prefix, + _find_prompt_char_in_prefix, generate_manifest, ) from great_docs._term_player.parser import Event, Recording, TermInfo @@ -277,3 +281,219 @@ def test_manifest_json_valid(self, tmp_path: Path): assert data["version"] == 1 assert "keyframes" in data assert "chapters" in data + + +# --------------------------------------------------------------------------- +# Prompt substitution helpers +# --------------------------------------------------------------------------- + + +class TestFindPromptCharInPrefix: + def test_dollar_sign(self): + result = _find_prompt_char_in_prefix("$ ") + assert result == (0, "$") + + def test_dollar_with_path(self): + result = _find_prompt_char_in_prefix("user@host:~ $ ") + assert result == (12, "$") + + def test_chevron(self): + result = _find_prompt_char_in_prefix("❯ ") + assert result == (0, "❯") + + def test_hash(self): + result = _find_prompt_char_in_prefix("root# ") + assert result == (4, "#") + + def test_no_prompt_char(self): + result = _find_prompt_char_in_prefix("hello ") + assert result is None + + def test_percent(self): + result = _find_prompt_char_in_prefix("% ") + assert result == (0, "%") + + +class TestDetectPromptPrefix: + def test_detects_from_input_events(self): + # Simulate: prompt appears, then user types + events = [ + Event(time=0.0, code="o", data="$ "), + Event(time=0.5, code="i", data="ls"), + Event(time=1.0, code="o", data="ls\r\nfile1\r\n"), + Event(time=2.0, code="o", data="$ "), + Event(time=2.5, code="i", data="pwd"), + ] + rec = Recording(events=events, term=TermInfo(cols=80, rows=24)) + prefix = _detect_prompt_prefix(rec) + assert prefix == "$ " + + def test_no_input_events(self): + events = [ + Event(time=0.0, code="o", data="$ hello\r\n"), + ] + rec = Recording(events=events, term=TermInfo(cols=80, rows=24)) + prefix = _detect_prompt_prefix(rec) + assert prefix is None + + def test_custom_prompt(self): + events = [ + Event(time=0.0, code="o", data="❯ "), + Event(time=0.5, code="i", data="git status"), + ] + rec = Recording(events=events, term=TermInfo(cols=80, rows=24)) + prefix = _detect_prompt_prefix(rec) + assert prefix == "❯ " + + +class TestApplyPromptSubstitution: + def _make_screen(self, rows_text: list[str], cols: int = 80) -> "ScreenState": + """Create a ScreenState from text rows.""" + from great_docs._term_player.emulator import Cell, CellStyle, ScreenState + + n_rows = len(rows_text) + cells = [] + for text in rows_text: + row = [] + for i in range(cols): + ch = text[i] if i < len(text) else " " + row.append(Cell(char=ch, style=CellStyle())) + cells.append(row) + return ScreenState(cols=cols, rows=n_rows, cells=cells) + + def test_substitutes_dollar_prompt(self): + state = self._make_screen(["$ hello", "output", "$ "]) + result = _apply_prompt_substitution(state, "$ ", "❯") + # Row 0: "$ " prefix → "$" replaced with "❯" + assert result.cells[0][0].char == "❯" + # Row 1: "output" doesn't match → untouched + assert result.cells[1][0].char == "o" + # Row 2: "$ " prefix → substituted + assert result.cells[2][0].char == "❯" + + def test_does_not_touch_dollar_in_output(self): + state = self._make_screen(["$ echo", "$HOME is set", "$ "]) + result = _apply_prompt_substitution(state, "$ ", "❯") + # Row 0: matches prefix "$ " → substituted + assert result.cells[0][0].char == "❯" + # Row 1: "$HOME" does NOT start with "$ " (no space after) → untouched + assert result.cells[1][0].char == "$" + # Row 2: matches + assert result.cells[2][0].char == "❯" + + def test_does_not_mutate_original(self): + state = self._make_screen(["$ hello"]) + result = _apply_prompt_substitution(state, "$ ", "→") + assert state.cells[0][0].char == "$" + assert result.cells[0][0].char == "→" + + def test_no_match_returns_copy(self): + state = self._make_screen(["hello", "world"]) + result = _apply_prompt_substitution(state, "$ ", "❯") + assert result.cells[0][0].char == "h" + assert result.cells[1][0].char == "w" + + def test_complex_prompt_prefix(self): + state = self._make_screen(["user@host:~ $ ls", "output"]) + result = _apply_prompt_substitution(state, "user@host:~ $ ", "❯") + # The $ at col 12 should be replaced + assert result.cells[0][12].char == "❯" + # The rest is untouched + assert result.cells[0][0].char == "u" + + +class TestApplyPromptPatternSubstitution: + def _make_screen(self, rows_text: list[str], cols: int = 80) -> "ScreenState": + from great_docs._term_player.emulator import Cell, CellStyle, ScreenState + + n_rows = len(rows_text) + cells = [] + for text in rows_text: + row = [] + for i in range(cols): + ch = text[i] if i < len(text) else " " + row.append(Cell(char=ch, style=CellStyle())) + cells.append(row) + return ScreenState(cols=cols, rows=n_rows, cells=cells) + + def test_pattern_substitution(self): + state = self._make_screen(["$ hello", "output", "$ "]) + result = _apply_prompt_pattern_substitution(state, r"^\$ ", "❯") + assert result.cells[0][0].char == "❯" + assert result.cells[1][0].char == "o" + assert result.cells[2][0].char == "❯" + + def test_invalid_regex_returns_unchanged(self): + state = self._make_screen(["$ hello"]) + result = _apply_prompt_pattern_substitution(state, "[invalid", "❯") + assert result.cells[0][0].char == "$" + + def test_does_not_match_mid_line(self): + state = self._make_screen(["echo $HOME", "$ cmd"]) + result = _apply_prompt_pattern_substitution(state, r"^\$ ", "❯") + # "echo $HOME" doesn't match ^ + assert result.cells[0][5].char == "$" + # "$ cmd" matches + assert result.cells[1][0].char == "❯" + + +class TestPromptSubstitutionIntegration: + def test_generate_manifest_with_prompt(self, tmp_path: Path): + """End-to-end test: prompt setting changes rendered SVG content.""" + events = [ + Event(time=0.0, code="o", data="$ "), + Event(time=0.5, code="i", data="ls"), + Event(time=0.6, code="o", data="ls\r\n"), + Event(time=1.0, code="o", data="file1.txt\r\n"), + Event(time=2.0, code="o", data="$ "), + Event(time=2.5, code="i", data="pwd"), + ] + rec = Recording(events=events, term=TermInfo(cols=40, rows=10), title="Prompt Test") + script = Script(prompt="❯") + + out = tmp_path / "prompt_test" + manifest = generate_manifest(rec, script, output_dir=out) + + # Read the last frame (should have "$ " → "❯" substituted) + last_frame = manifest.keyframes[-1].file + svg = (out / last_frame).read_text() + + # The SVG should contain the substituted prompt char + assert "❯" in svg + # Frame at t=0.0 has only "$ " displayed + first_svg = (out / manifest.keyframes[0].file).read_text() + assert "❯" in first_svg + + def test_generate_manifest_without_prompt_no_change(self, tmp_path: Path): + """Without prompt setting, $ renders as-is.""" + events = [ + Event(time=0.0, code="o", data="$ "), + Event(time=0.5, code="i", data="ls"), + ] + rec = Recording(events=events, term=TermInfo(cols=40, rows=10)) + script = Script() # No prompt setting + + out = tmp_path / "no_prompt" + generate_manifest(rec, script, output_dir=out) + + svg = (out / "frame-000.svg").read_text() + # Should still have the original $ + assert "$" in svg + + def test_prompt_pattern_fallback(self, tmp_path: Path): + """When no input events exist, prompt_pattern regex is used.""" + events = [ + Event(time=0.0, code="o", data="$ hello\r\n"), + Event(time=1.0, code="o", data="output\r\n"), + Event(time=2.0, code="o", data="$ "), + ] + rec = Recording(events=events, term=TermInfo(cols=40, rows=10)) + script = Script(prompt="→", prompt_pattern=r"^\$ ") + + out = tmp_path / "pattern_test" + generate_manifest(rec, script, output_dir=out) + + # The last frame should have → instead of $ + last_frame_file = sorted(out.glob("frame-*.svg"))[-1] + svg = last_frame_file.read_text() + assert "→" in svg diff --git a/tests/test_term_script.py b/tests/test_term_script.py index 2396ef99..07ce40cd 100644 --- a/tests/test_term_script.py +++ b/tests/test_term_script.py @@ -364,3 +364,39 @@ def test_load_invalid_yaml_returns_empty(self, tmp_path: Path): script = load_script(f) assert isinstance(script, Script) + + def test_load_with_prompt_setting(self, tmp_path: Path): + content = """\ +settings: + prompt: "❯" +""" + f = tmp_path / "test.yml" + f.write_text(content, encoding="utf-8") + + script = load_script(f) + assert script.prompt == "❯" + assert script.prompt_pattern is None + + def test_load_with_prompt_and_pattern(self, tmp_path: Path): + content = """\ +settings: + prompt: "→" + prompt_pattern: '^\\$ ' +""" + f = tmp_path / "test.yml" + f.write_text(content, encoding="utf-8") + + script = load_script(f) + assert script.prompt == "→" + assert script.prompt_pattern == r"^\$ " + + def test_load_prompt_null_stays_none(self, tmp_path: Path): + content = """\ +settings: + prompt: null +""" + f = tmp_path / "test.yml" + f.write_text(content, encoding="utf-8") + + script = load_script(f) + assert script.prompt is None