From 3a904199438a082e60097de65a79d64a4d7770d9 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sun, 24 May 2026 23:10:26 -0400 Subject: [PATCH 1/9] Add Snippet dataclass and parse snippets --- great_docs/_term_player/script.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/great_docs/_term_player/script.py b/great_docs/_term_player/script.py index 80f383da..b3361702 100644 --- a/great_docs/_term_player/script.py +++ b/great_docs/_term_player/script.py @@ -49,6 +49,17 @@ class SpeedSegment: speed: float = 1.0 +@dataclass +class Snippet: + """A copyable text snippet associated with a time range.""" + + time: float + duration: float + text: str = "" + match: str = "" + label: str = "" + + @dataclass class Highlight: """A highlighted region of the terminal.""" @@ -79,6 +90,7 @@ class Script: annotations: list[Annotation] = field(default_factory=list) speed_map: list[SpeedSegment] = field(default_factory=list) highlights: list[Highlight] = field(default_factory=list) + snippets: list[Snippet] = field(default_factory=list) def load_script(path: str | Path) -> Script: @@ -184,6 +196,19 @@ def _parse_script_data(data: dict[str, Any]) -> Script: ) ) + # Snippets (copyable text associated with time ranges) + for snip in data.get("snippets", []): + if isinstance(snip, dict) and "at" in snip and ("text" in snip or "match" in snip): + script.snippets.append( + Snippet( + time=float(snip["at"]), + duration=float(snip.get("duration", 5.0)), + text=snip.get("text", ""), + match=snip.get("match", ""), + label=snip.get("label", ""), + ) + ) + return script From cf5f6cba66a3e8b5c16fc3e0d2eef1b540db875a Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sun, 24 May 2026 23:10:42 -0400 Subject: [PATCH 2/9] Add snippet support to manifest --- great_docs/_term_player/manifest.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/great_docs/_term_player/manifest.py b/great_docs/_term_player/manifest.py index 41828f7a..d469a4bd 100644 --- a/great_docs/_term_player/manifest.py +++ b/great_docs/_term_player/manifest.py @@ -9,7 +9,7 @@ from .emulator import TerminalEmulator from .parser import Recording from .renderer import render_frame -from .script import Annotation, Chapter, Highlight, Script +from .script import Annotation, Chapter, Highlight, Script, Snippet @dataclass @@ -55,6 +55,7 @@ class Manifest: deltas: list[DeltaEntry] = field(default_factory=list) annotations: list[Annotation] = field(default_factory=list) highlights: list[Highlight] = field(default_factory=list) + snippets: list[Snippet] = field(default_factory=list) window_chrome: str = "none" def to_json(self) -> str: @@ -97,6 +98,16 @@ def to_json(self) -> str: } for h in self.highlights ], + "snippets": [ + { + "time": round(c.time, 3), + "duration": round(c.duration, 3), + "text": c.text, + "match": c.match, + "label": c.label, + } + for c in self.snippets + ], } if self.window_chrome != "none": data["window_chrome"] = self.window_chrome @@ -238,6 +249,7 @@ def generate_manifest( deltas=deltas, annotations=script.annotations if script else [], highlights=script.highlights if script else [], + snippets=script.snippets if script else [], window_chrome=window_chrome, ) From 87eacc30dc247d67573af7faade8f5ba5595f8d7 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sun, 24 May 2026 23:12:04 -0400 Subject: [PATCH 3/9] Add snippets track and UI to editor --- great_docs/_term_player/editor.py | 510 +++++++++++++++++++++++++++++- 1 file changed, 503 insertions(+), 7 deletions(-) diff --git a/great_docs/_term_player/editor.py b/great_docs/_term_player/editor.py index d2cfd427..618655e2 100644 --- a/great_docs/_term_player/editor.py +++ b/great_docs/_term_player/editor.py @@ -62,6 +62,16 @@ def _build_editor_data(recording: Recording, script: Script | None) -> dict[str, } for cut in (script.cuts if script else []) ], + "snippets": [ + { + "time": cmd.time, + "duration": cmd.duration, + "text": cmd.text, + "match": cmd.match, + "label": cmd.label, + } + for cmd in (script.snippets if script else []) + ], }, } return data @@ -113,6 +123,23 @@ def _serialize_script(script_data: dict[str, Any], source_path: str) -> str: lines.append(f" type: {cut['type']}") lines.append("") + snippets = script_data.get("snippets", []) + if snippets: + lines.append("snippets:") + for cmd in sorted(snippets, key=lambda c: c["time"]): + lines.append(f" - at: {cmd['time']}") + lines.append(f" duration: {cmd['duration']}") + text = cmd.get("text", "") + match = cmd.get("match", "") + if text: + lines.append(f' text: "{text}"') + if match: + lines.append(f" match: '{match}'") + label = cmd.get("label", "") + if label: + lines.append(f' label: "{label}"') + lines.append("") + return "\n".join(lines) + "\n" @@ -269,6 +296,7 @@ def _get_editor_html() -> str: --chapter: #f9e2af; --annotation: #a6e3a1; --cut: #f38ba8; + --snippet: #89b4fa; --playhead: #cba6f7; --radius: 6px; } @@ -404,6 +432,7 @@ def _get_editor_html() -> str: } .preview-wrapper { + position: relative; display: flex; flex-direction: column; transform: scale(var(--viewport-scale, 1)); @@ -433,11 +462,44 @@ def _get_editor_html() -> str: padding: 5px 12px; } -.chapter-title-overlay.visible + .preview-viewport { +.chapter-title-overlay.visible + .snippet-preview + .preview-viewport { border-top: none; border-radius: 0 0 8px 8px; } +.snippet-preview { + position: absolute; + top: 0; + right: 0; + z-index: 10; + padding: 4px 10px; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.snippet-preview.visible { + opacity: 1; +} + +.snippet-preview-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border: 1px solid var(--snippet); + border-radius: 4px; + background: var(--surface); + color: var(--snippet); + font-family: inherit; + font-size: 10px; + white-space: nowrap; +} + +.snippet-preview-btn svg { + opacity: 0.7; +} + .cut-indicator { position: absolute; top: 0; @@ -930,12 +992,102 @@ def _get_editor_html() -> str: cursor: crosshair; } +/* Snippet track items */ +.track-item-snippet { + position: absolute; + top: 6px; + bottom: 6px; + background: rgba(137, 180, 250, 0.15); + border: 1px solid rgba(137, 180, 250, 0.6); + border-radius: 3px; + cursor: pointer; + overflow: visible; + min-width: 8px; +} + +.track-item-snippet .cmd-text { + position: absolute; + top: 0; + bottom: 0; + left: 4px; + right: 4px; + display: flex; + align-items: center; + font-size: 9px; + color: var(--snippet); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + font-family: 'SF Mono', Menlo, Consolas, monospace; +} + +.track-item-snippet:hover { + background: rgba(137, 180, 250, 0.25); +} + +.track-item-snippet.selected { + background: rgba(137, 180, 250, 0.35); + border-color: var(--snippet); + box-shadow: 0 -4px 8px rgba(137, 180, 250, 0.3), 0 4px 8px rgba(137, 180, 250, 0.3); +} + +.track-item-snippet .cmd-handle { + position: absolute; + top: -2px; + bottom: -2px; + width: 6px; + cursor: ew-resize; + z-index: 5; +} + +.track-item-snippet .cmd-handle-left { + left: -3px; + border-left: 2px solid var(--snippet); + border-radius: 2px 0 0 2px; +} + +.track-item-snippet .cmd-handle-right { + right: -3px; + border-right: 2px solid var(--snippet); + border-radius: 0 2px 2px 0; +} + +.track-item-snippet .cmd-handle:hover, +.track-item-snippet .cmd-handle.active { + background: rgba(137, 180, 250, 0.4); +} + +.track-item-snippet .cmd-handle.selected { + top: -14px; + bottom: -14px; + width: 4px; + background: none; + z-index: 20; +} + +.track-item-snippet .cmd-handle-left.selected { + left: -2px; + border-left: 3px solid var(--snippet); + box-shadow: -2px 0 8px rgba(137, 180, 250, 0.7); +} + +.track-item-snippet .cmd-handle-right.selected { + right: -2px; + border-right: 3px solid var(--snippet); + box-shadow: 2px 0 8px rgba(137, 180, 250, 0.7); +} + +#track-snippets { + cursor: crosshair; +} + /* Playhead — single line spanning ruler + 3 tracks */ .playhead { position: absolute; top: 0; left: 0; - height: 141px; /* ruler 29px + 3 tracks × 36px + 4px overshoot */ + height: 177px; /* ruler 29px + 4 tracks × 36px + 4px overshoot */ width: 2px; background: var(--playhead); z-index: 10; @@ -1346,6 +1498,7 @@ def _get_editor_html() -> str: +
@@ -1362,6 +1515,7 @@ def _get_editor_html() -> str:
+

         
@@ -1400,6 +1554,10 @@ def _get_editor_html() -> str:
Cuts
+
+
Snippets
+
+
@@ -1408,6 +1566,7 @@ def _get_editor_html() -> str: C Add chapter A Add annotation X Mark cut + D Add snippet Seek [] Prev/Next chapter I Show Info @@ -1452,6 +1611,7 @@ def _get_editor_html() -> str: const trackChapters = document.getElementById('track-chapters'); const trackAnnotations = document.getElementById('track-annotations'); const trackCuts = document.getElementById('track-cuts'); + const trackSnippets = document.getElementById('track-snippets'); const propsPanel = document.getElementById('properties-panel'); const toast = document.getElementById('toast'); const btnPlay = document.getElementById('btn-play'); @@ -1466,6 +1626,8 @@ def _get_editor_html() -> str: function init() { fileName.textContent = data.source_file || data.recording.title || 'Untitled'; durationDisplay.textContent = formatTimePrecise(data.recording.duration); + // Ensure snippets array exists + if (!data.script.snippets) data.script.snippets = []; renderStats(); // Set terminal viewport to captured dimensions @@ -1526,6 +1688,7 @@ def _get_editor_html() -> str: const chapters = (script.chapters || []).length; const annotations = (script.annotations || []).length; const cuts = (script.cuts || []).length; + const snippets = (script.snippets || []).length; panel.innerHTML = '
Info
' + @@ -1538,6 +1701,7 @@ def _get_editor_html() -> str: statRow('Chapters', String(chapters)) + statRow('Annotations', String(annotations)) + statRow('Cuts', String(cuts)) + + statRow('Snippets', String(snippets)) + '
' + statRow('Source', fmtSize(sourceBytes)) + statRow('Keyframes', '~' + estKeyframes) + @@ -1663,6 +1827,39 @@ def _get_editor_html() -> str: trackCuts.appendChild(el); }); + // Snippets + trackSnippets.innerHTML = ''; + + (data.script.snippets || []).forEach((cmd, i) => { + const el = document.createElement('div'); + el.className = 'track-item-snippet'; + el.style.left = timeToPct(cmd.time); + el.style.width = durationToPct(cmd.duration); + const textSpan = document.createElement('span'); + textSpan.className = 'cmd-text'; + textSpan.textContent = cmd.text; + el.appendChild(textSpan); + el.addEventListener('mousedown', (e) => { + if (e.target.classList.contains('cmd-handle')) return; + e.stopPropagation(); + startBoxDrag('snippet', i, e); + }); + + // Left handle + const lh = document.createElement('div'); + lh.className = 'cmd-handle cmd-handle-left'; + lh.addEventListener('mousedown', (e) => { e.stopPropagation(); startSnippetHandleDrag(i, 'start', e); }); + el.appendChild(lh); + + // Right handle + const rh = document.createElement('div'); + rh.className = 'cmd-handle cmd-handle-right'; + rh.addEventListener('mousedown', (e) => { e.stopPropagation(); startSnippetHandleDrag(i, 'end', e); }); + el.appendChild(rh); + + trackSnippets.appendChild(el); + }); + renderStats(); } @@ -1839,6 +2036,26 @@ def _get_editor_html() -> str: termOutput.innerHTML = htmlLines.join('\\n'); renderAnnotations(); renderChapterTitle(); + renderSnippetPreview(); + } + + function renderSnippetPreview() { + const el = document.getElementById('snippet-preview'); + const snips = data.script.snippets || []; + // Find first active snippet at current time + let active = null; + for (const s of snips) { + if (currentTime >= s.time && currentTime <= s.time + s.duration) { active = s; break; } + } + if (active) { + const label = active.label || active.text || active.match || 'Copy'; + el.innerHTML = '' + + '' + + '' + escHtml(label) + ''; + el.classList.add('visible'); + } else { + el.classList.remove('visible'); + } } function renderChapterTitle() { @@ -2229,6 +2446,43 @@ def _get_editor_html() -> str: attachLiveListeners(); } + function selectSnippet(idx) { + selectedItem = { type: 'snippet', index: idx }; + clearAllHighlights(); + const cmdEls = trackSnippets.querySelectorAll('.track-item-snippet'); + if (cmdEls[idx]) cmdEls[idx].classList.add('selected'); + const cmd = (data.script.snippets || [])[idx]; + propsPanel.innerHTML = ` +
Snippet
+
+
Time (s)
+ +
+
+
Duration (s)
+ +
+
+
Text
+ +
+
+
Match (regex)
+ +
+
+
Label (optional)
+ +
+
+ + +
+ `; + propsPanel.classList.add('open'); + attachLiveListeners(); + } + // --- Live input listeners for inspector --- let undoSnapshot = null; @@ -2239,6 +2493,7 @@ def _get_editor_html() -> str: if (type === 'chapter') undoSnapshot = { ...data.script.chapters[index] }; else if (type === 'annotation') undoSnapshot = { ...data.script.annotations[index] }; else if (type === 'cut') undoSnapshot = { ...data.script.cuts[index] }; + else if (type === 'snippet') undoSnapshot = { ...(data.script.snippets || [])[index] }; } propsPanel.querySelectorAll('.prop-input, .prop-select').forEach(el => { el.addEventListener('input', liveApply); @@ -2284,6 +2539,12 @@ def _get_editor_html() -> str: data.script.cuts[index].start = parseFloat(document.getElementById('prop-start').value) || 0; data.script.cuts[index].end = parseFloat(document.getElementById('prop-end').value) || 0; data.script.cuts[index].type = document.getElementById('prop-type').value; + } else if (type === 'snippet') { + data.script.snippets[index].time = parseFloat(document.getElementById('prop-time').value) || 0; + data.script.snippets[index].duration = parseFloat(document.getElementById('prop-duration').value) || 1; + data.script.snippets[index].text = document.getElementById('prop-text').value; + data.script.snippets[index].match = document.getElementById('prop-match').value; + data.script.snippets[index].label = document.getElementById('prop-label').value; } markDirty(); @@ -2297,12 +2558,14 @@ def _get_editor_html() -> str: if (type === 'chapter') data.script.chapters[index] = { ...undoSnapshot }; else if (type === 'annotation') data.script.annotations[index] = { ...undoSnapshot }; else if (type === 'cut') data.script.cuts[index] = { ...undoSnapshot }; + else if (type === 'snippet') data.script.snippets[index] = { ...undoSnapshot }; renderTracks(); updatePlayhead(); // Re-open panel with restored values if (type === 'chapter') selectChapter(index); else if (type === 'annotation') selectAnnotation(index); else if (type === 'cut') selectCut(index); + else if (type === 'snippet') selectSnippet(index); showToast('Reverted'); }; @@ -2334,6 +2597,7 @@ def _get_editor_html() -> str: if (type === 'chapter') data.script.chapters.splice(index, 1); else if (type === 'annotation') data.script.annotations.splice(index, 1); else if (type === 'cut') data.script.cuts.splice(index, 1); + else if (type === 'snippet') data.script.snippets.splice(index, 1); selectedItem = null; propsPanel.classList.remove('open'); @@ -2508,11 +2772,186 @@ def _get_editor_html() -> str: } }); - // --- Box drag-to-reposition (annotations and cuts) --- + // --- Snippet add/drag/double-click --- + function addSnippet() { + addSnippetAt(currentTime); + } + + // Check if a time range overlaps any existing snippet (excluding skipIdx) + function snippetOverlapsAny(start, end, skipIdx) { + const snips = data.script.snippets || []; + for (let i = 0; i < snips.length; i++) { + if (i === skipIdx) continue; + const s = snips[i]; + if (start < s.time + s.duration && end > s.time) return true; + } + return false; + } + + function addSnippetAt(time) { + if (!data.script.snippets) data.script.snippets = []; + const start = roundTime(time); + const dur = roundTime(Math.min(5, data.recording.duration - start)); + if (dur <= 0) return; + // Prevent overlapping snippets + if (snippetOverlapsAny(start, start + dur, -1)) { + showToast('Cannot add snippet — overlaps an existing one'); + return; + } + data.script.snippets.push({ time: start, duration: dur, text: '# enter text here', match: '', label: '' }); + renderTracks(); + updatePlayhead(); + markDirty(); + const idx = data.script.snippets.length - 1; + selectSnippet(idx); + showToast('Snippet added — edit the text in properties'); + } + + // Snippet handle drag (resize edges) + function startSnippetHandleDrag(cmdIdx, edge, e) { + e.preventDefault(); + const rect = trackSnippets.getBoundingClientRect(); + const duration = data.recording.duration; + selectSnippet(cmdIdx); + selectedItem = { type: 'snippet', index: cmdIdx, edge: edge }; + // Highlight the edge handle + const cmdEls = trackSnippets.querySelectorAll('.track-item-snippet'); + if (cmdEls[cmdIdx]) { + const handle = cmdEls[cmdIdx].querySelector('.cmd-handle-' + (edge === 'start' ? 'left' : 'right')); + if (handle) handle.classList.add('selected'); + } + // Seek playhead to the edge position + const cmd = data.script.snippets[cmdIdx]; + seek(edge === 'start' ? cmd.time : cmd.time + cmd.duration); + + function onMove(ev) { + const ratio = (ev.clientX - rect.left) / rect.width; + const t = roundTime(Math.max(0, Math.min(ratio * duration, duration))); + const cmd = data.script.snippets[cmdIdx]; + if (edge === 'start') { + const newStart = Math.min(t, cmd.time + cmd.duration - 0.1); + const newDur = cmd.duration + (cmd.time - newStart); + if (!snippetOverlapsAny(newStart, newStart + newDur, cmdIdx)) { + cmd.time = newStart; + cmd.duration = newDur; + } + } else { + const newEnd = Math.max(t, cmd.time + 0.1); + const newDur = roundTime(newEnd - cmd.time); + if (!snippetOverlapsAny(cmd.time, cmd.time + newDur, cmdIdx)) { + cmd.duration = newDur; + } + } + renderTracks(); + updatePlayhead(); + const cmEls = trackSnippets.querySelectorAll('.track-item-snippet'); + if (cmEls[cmdIdx]) { + cmEls[cmdIdx].classList.add('selected'); + const h = cmEls[cmdIdx].querySelector('.cmd-handle-' + (edge === 'start' ? 'left' : 'right')); + if (h) h.classList.add('selected'); + } + // Update inspector live + const timeInput = document.getElementById('prop-time'); + const durInput = document.getElementById('prop-duration'); + if (timeInput) timeInput.value = cmd.time.toFixed(2); + if (durInput) durInput.value = cmd.duration.toFixed(2); + } + + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + markDirty(); + } + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + // Snippet drag-to-create on track + trackSnippets.addEventListener('mousedown', (e) => { + if (e.target !== trackSnippets) return; + e.preventDefault(); + if (!data.script.snippets) data.script.snippets = []; + const rect = trackSnippets.getBoundingClientRect(); + const startX = e.clientX; + const startRatio = (startX - rect.left) / rect.width; + const startTime = roundTime(startRatio * data.recording.duration); + let dragStarted = false; + let cmdDragIdx = null; + + function onMove(ev) { + if (!dragStarted) { + if (Math.abs(ev.clientX - startX) < 4) return; + dragStarted = true; + cmdDragIdx = data.script.snippets.length; + data.script.snippets.push({ time: startTime, duration: 0, text: '# enter text here', match: '', label: '' }); + renderTracks(); + updatePlayhead(); + } + const ratio = (ev.clientX - rect.left) / rect.width; + const t = roundTime(Math.max(0, Math.min(ratio * data.recording.duration, data.recording.duration))); + const cmd = data.script.snippets[cmdDragIdx]; + let newStart, newDur; + if (t < startTime) { + newStart = t; + newDur = roundTime(startTime - t); + } else { + newStart = startTime; + newDur = roundTime(t - startTime); + } + if (!snippetOverlapsAny(newStart, newStart + newDur, cmdDragIdx)) { + cmd.time = newStart; + cmd.duration = newDur; + } + renderTracks(); + updatePlayhead(); + } + + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (!dragStarted) return; + const cmd = data.script.snippets[cmdDragIdx]; + if (cmd.duration < 0.15) { + data.script.snippets.splice(cmdDragIdx, 1); + } else { + selectSnippet(cmdDragIdx); + markDirty(); + showToast('Snippet: ' + formatTimePrecise(cmd.time) + ' → ' + formatTimePrecise(cmd.time + cmd.duration)); + } + renderTracks(); + updatePlayhead(); + } + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + + // Double-click on snippets track to create a 5s command + trackSnippets.addEventListener('dblclick', (e) => { + if (e.target !== trackSnippets) return; + e.preventDefault(); + if (!data.script.snippets) data.script.snippets = []; + const rect = trackSnippets.getBoundingClientRect(); + const ratio = (e.clientX - rect.left) / rect.width; + const clickTime = roundTime(ratio * data.recording.duration); + const start = clickTime; + const dur = roundTime(Math.min(5, data.recording.duration - clickTime)); + if (dur > 0) { + data.script.snippets.push({ time: start, duration: dur, text: '# enter text here', match: '', label: '' }); + renderTracks(); + updatePlayhead(); + markDirty(); + selectSnippet(data.script.snippets.length - 1); + showToast('Snippet added (5s) — drag edges to adjust'); + } + }); + + // --- Box drag-to-reposition (annotations, cuts, and snippets) --- function startBoxDrag(type, index, e) { e.preventDefault(); const startX = e.clientX; - const track = type === 'annotation' ? trackAnnotations : trackCuts; + const track = type === 'annotation' ? trackAnnotations : type === 'snippet' ? trackSnippets : trackCuts; const rect = track.getBoundingClientRect(); const duration = data.recording.duration; let dragged = false; @@ -2522,6 +2961,10 @@ def _get_editor_html() -> str: item = data.script.annotations[index]; itemStart = item.time; itemDuration = item.duration; + } else if (type === 'snippet') { + item = (data.script.snippets || [])[index]; + itemStart = item.time; + itemDuration = item.duration; } else { item = data.script.cuts[index]; itemStart = item.start; @@ -2536,7 +2979,7 @@ def _get_editor_html() -> str: dragged = true; const dt = (dx / rect.width) * duration; let newStart = roundTime(Math.max(0, Math.min(origStart + dt, duration - itemDuration))); - if (type === 'annotation') { + if (type === 'annotation' || type === 'snippet') { item.time = newStart; } else { item.start = newStart; @@ -2554,6 +2997,7 @@ def _get_editor_html() -> str: } // Always select on mouseup (whether drag or click) if (type === 'annotation') selectAnnotation(index); + else if (type === 'snippet') selectSnippet(index); else selectCut(index); } @@ -2697,6 +3141,50 @@ def _get_editor_html() -> str: markDirty(); } + // --- Snippet edge nudge via keyboard --- + function highlightSnippet(idx, edge) { + trackSnippets.querySelectorAll('.track-item-snippet.selected').forEach(el => el.classList.remove('selected')); + trackSnippets.querySelectorAll('.cmd-handle.selected').forEach(el => el.classList.remove('selected')); + const cmdEls = trackSnippets.querySelectorAll('.track-item-snippet'); + if (cmdEls[idx]) { + cmdEls[idx].classList.add('selected'); + if (edge) { + const handle = cmdEls[idx].querySelector('.cmd-handle-' + (edge === 'start' ? 'left' : 'right')); + if (handle) handle.classList.add('selected'); + } + } + } + + function nudgeSnippetEdge(cmdIdx, edge, delta) { + const cmd = data.script.snippets[cmdIdx]; + if (!cmd) return; + const end = cmd.time + cmd.duration; + if (edge === 'start') { + const newStart = roundTime(Math.max(0, Math.min(cmd.time + delta, end - 0.1))); + const newDur = roundTime(end - newStart); + if (!snippetOverlapsAny(newStart, newStart + newDur, cmdIdx)) { + cmd.duration = newDur; + cmd.time = newStart; + } + seek(cmd.time); + } else { + const newEnd = roundTime(Math.max(cmd.time + 0.1, Math.min(end + delta, data.recording.duration))); + const newDur = roundTime(newEnd - cmd.time); + if (!snippetOverlapsAny(cmd.time, cmd.time + newDur, cmdIdx)) { + cmd.duration = newDur; + } + seek(cmd.time + cmd.duration); + } + renderTracks(); + updatePlayhead(); + highlightSnippet(cmdIdx, edge); + const timeInput = document.getElementById('prop-time'); + const durInput = document.getElementById('prop-duration'); + if (timeInput) timeInput.value = cmd.time.toFixed(2); + if (durInput) durInput.value = cmd.duration.toFixed(2); + markDirty(); + } + // --- Annotation drag-to-create --- let annDragState = null; @@ -2980,7 +3468,7 @@ def _get_editor_html() -> str: document.addEventListener('mouseup', onUp); }); - [trackChapters, trackAnnotations, trackCuts].forEach(track => { + [trackChapters, trackAnnotations, trackCuts, trackSnippets].forEach(track => { track.addEventListener('click', (e) => { if (e.target === track || e.target.classList.contains('playhead')) { const rect = track.getBoundingClientRect(); @@ -3017,6 +3505,7 @@ def _get_editor_html() -> str: document.getElementById('btn-add-chapter').addEventListener('click', addChapter); document.getElementById('btn-add-annotation').addEventListener('click', addAnnotation); document.getElementById('btn-add-cut').addEventListener('click', addCut); + document.getElementById('btn-add-snippet').addEventListener('click', addSnippet); document.getElementById('btn-save').addEventListener('click', save); // Keyboard shortcuts @@ -3028,12 +3517,15 @@ def _get_editor_html() -> str: else if (e.key === 'c' || e.key === 'C') { addChapter(); } else if (e.key === 'a' && !e.metaKey) { addAnnotation(); } else if (e.key === 'x' || e.key === 'X') { addCut(); } + else if (e.key === 'd' || e.key === 'D') { addSnippet(); } else if (e.key === 'ArrowRight') { e.preventDefault(); if (selectedItem && selectedItem.type === 'cut' && selectedItem.edge) { nudgeCutEdge(selectedItem.index, selectedItem.edge, 0.01); } else if (selectedItem && selectedItem.type === 'annotation' && selectedItem.edge) { nudgeAnnotationEdge(selectedItem.index, selectedItem.edge, 0.01); + } else if (selectedItem && selectedItem.type === 'snippet' && selectedItem.edge) { + nudgeSnippetEdge(selectedItem.index, selectedItem.edge, 0.01); } else { seek(currentTime + 1); } } else if (e.key === 'ArrowLeft') { @@ -3042,6 +3534,8 @@ def _get_editor_html() -> str: nudgeCutEdge(selectedItem.index, selectedItem.edge, -0.01); } else if (selectedItem && selectedItem.type === 'annotation' && selectedItem.edge) { nudgeAnnotationEdge(selectedItem.index, selectedItem.edge, -0.01); + } else if (selectedItem && selectedItem.type === 'snippet' && selectedItem.edge) { + nudgeSnippetEdge(selectedItem.index, selectedItem.edge, -0.01); } else { seek(currentTime - 1); } } else if (e.key === ']') { nextChapter(); } @@ -3058,7 +3552,7 @@ def _get_editor_html() -> str: // Close panel on click outside document.addEventListener('click', (e) => { - if (!propsPanel.contains(e.target) && !e.target.closest('.track-item-chapter, .track-item-annotation, .track-item-cut')) { + if (!propsPanel.contains(e.target) && !e.target.closest('.track-item-chapter, .track-item-annotation, .track-item-cut, .track-item-snippet')) { propsPanel.classList.remove('open'); selectedItem = null; clearCutHighlights(); @@ -3071,6 +3565,8 @@ def _get_editor_html() -> str: trackAnnotations.querySelectorAll('.ann-handle.selected').forEach(el => el.classList.remove('selected')); trackCuts.querySelectorAll('.track-item-cut.selected').forEach(el => el.classList.remove('selected')); trackCuts.querySelectorAll('.cut-handle.selected').forEach(el => el.classList.remove('selected')); + trackSnippets.querySelectorAll('.track-item-snippet.selected').forEach(el => el.classList.remove('selected')); + trackSnippets.querySelectorAll('.cmd-handle.selected').forEach(el => el.classList.remove('selected')); } function clearCutHighlights() { From b95d6a5a022aca5d052066b88bd659ef5ab9e110 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sun, 24 May 2026 23:12:21 -0400 Subject: [PATCH 4/9] Add copy-snippet buttons and handlers --- great_docs/assets/termshow.js | 121 +++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/great_docs/assets/termshow.js b/great_docs/assets/termshow.js index 70582a08..b8c6b004 100644 --- a/great_docs/assets/termshow.js +++ b/great_docs/assets/termshow.js @@ -112,6 +112,12 @@ annotationLayer.className = 'gd-tp-annotations'; viewport.appendChild(annotationLayer); + // Copy-snippet buttons (placed in chapter/title bar) + const snippetLayer = document.createElement('div'); + snippetLayer.className = 'gd-tp-snippets'; + chapterBar.appendChild(snippetLayer); + state.snippetLayer = snippetLayer; + // Center overlay (play / replay) const centerOverlay = document.createElement('div'); centerOverlay.className = 'gd-tp-center-overlay'; @@ -140,7 +146,8 @@ // Add traffic light decorations if window_chrome is set var chrome = manifest.window_chrome || 'none'; var hasChapters = manifest.chapters && manifest.chapters.length > 0; - if (chrome === 'none' && !hasChapters) { + var hasSnippets = manifest.snippets && manifest.snippets.length > 0; + if (chrome === 'none' && !hasChapters && !hasSnippets) { chapterBar.style.display = 'none'; } else if (chrome !== 'none') { chapterBar.classList.add('gd-tp-has-chrome'); @@ -427,6 +434,9 @@ // Update annotations updateAnnotations(annotationLayer, manifest.annotations, state.currentTime); + + // Update copy-snippet buttons + updateSnippets(state.snippetLayer, manifest.snippets, state.currentTime, svgContainer); } function updateChapterBar(chapterBar, manifest, time) { @@ -534,6 +544,115 @@ } } + function updateSnippets(layer, snippets, time, svgContainer) { + if (!snippets || snippets.length === 0) { + if (layer._cmdEls) { + for (var i = 0; i < layer._cmdEls.length; i++) { + layer._cmdEls[i].style.display = 'none'; + layer._cmdEls[i].style.opacity = '0'; + } + } + return; + } + + // Pre-create snippet copy buttons at init time + if (!layer._cmdEls) { + layer._cmdEls = []; + for (var j = 0; j < snippets.length; j++) { + var snip = snippets[j]; + var el = document.createElement('button'); + el.className = 'gd-tp-copy-snippet'; + el.setAttribute('type', 'button'); + el.setAttribute('aria-label', 'Copy snippet: ' + (snip.text || snip.match)); + el.dataset.text = snip.text; + el.dataset.match = snip.match || ''; + el.innerHTML = '' + + '' + (snip.label || 'Copy') + ''; + el.style.display = 'none'; + el.addEventListener('click', (function(btn, idx) { + return function(e) { + e.stopPropagation(); + var copyText = resolveSnippetText(snippets[idx], svgContainer); + if (copyText) { + navigator.clipboard.writeText(copyText).then(function() { + btn.classList.add('gd-tp-copied'); + setTimeout(function() { btn.classList.remove('gd-tp-copied'); }, 1500); + }); + } + }; + })(el, j)); + layer.appendChild(el); + layer._cmdEls.push(el); + } + } + + // Show only one snippet at a time (first active wins; overlaps are clipped) + var activeIdx = -1; + for (var k = 0; k < snippets.length; k++) { + var c = snippets[k]; + if (activeIdx < 0 && time >= c.time && time <= c.time + c.duration) { + activeIdx = k; + var elapsed = time - c.time; + var fadeIn = Math.min(1, elapsed / 0.3); + var fadeOut = Math.min(1, (c.duration - elapsed) / 0.3); + layer._cmdEls[k].style.display = 'inline-flex'; + layer._cmdEls[k].style.opacity = Math.min(fadeIn, fadeOut); + } else { + layer._cmdEls[k].style.display = 'none'; + layer._cmdEls[k].style.opacity = '0'; + } + } + } + + /** + * Resolve the text to copy for a snippet. + * If `match` is set, extract text from the SVG and run the regex. + * If regex has a capture group, use group(1); otherwise use group(0). + * Falls back to literal `text` field. + */ + function resolveSnippetText(snip, svgContainer) { + if (snip.match) { + var buffer = extractSvgText(svgContainer); + try { + var re = new RegExp(snip.match); + var m = re.exec(buffer); + if (m) return m[1] !== undefined ? m[1] : m[0]; + } catch (e) { /* invalid regex — fall through */ } + } + return snip.text || ''; + } + + /** + * Extract the plain-text content of the current SVG frame. + * Groups elements by y-coordinate to reconstruct terminal lines. + */ + function extractSvgText(svgContainer) { + var svg = svgContainer.querySelector('svg'); + if (!svg) return ''; + var textEls = svg.querySelectorAll('.gd-tp-text'); + if (!textEls.length) return ''; + // Group by y-coordinate (row) + var rows = {}; + for (var i = 0; i < textEls.length; i++) { + var el = textEls[i]; + var y = parseFloat(el.getAttribute('y')) || 0; + var x = parseFloat(el.getAttribute('x')) || 0; + var key = Math.round(y * 10); // quantize y to avoid float drift + if (!rows[key]) rows[key] = []; + rows[key].push({ x: x, text: el.textContent || '' }); + } + // Sort rows by y, spans within rows by x + var keys = Object.keys(rows).map(Number).sort(function(a, b) { return a - b; }); + var lines = []; + for (var k = 0; k < keys.length; k++) { + var spans = rows[keys[k]].sort(function(a, b) { return a.x - b.x; }); + var line = ''; + for (var s = 0; s < spans.length; s++) line += spans[s].text; + lines.push(line); + } + return lines.join('\n'); + } + // --- Controls UI --- function buildControls() { From 568eb0ef72f4f66ef5af52dd4d1897ae39cd24c8 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sun, 24 May 2026 23:12:31 -0400 Subject: [PATCH 5/9] Add copy-snippet UI styles for terminal show --- great_docs/assets/termshow.css | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/great_docs/assets/termshow.css b/great_docs/assets/termshow.css index 55d6eecb..6689e714 100644 --- a/great_docs/assets/termshow.css +++ b/great_docs/assets/termshow.css @@ -398,3 +398,75 @@ body[data-bs-theme="light"] .gd-termshow, --gd-tp-ann-bg: rgba(239, 241, 245, 0.92); --gd-tp-ann-border: rgba(30, 102, 245, 0.3); } + +/* Copy-snippet layer */ +.gd-tp-snippets { + position: absolute; + right: 0; + top: 0; + bottom: 0; + padding-right: clamp(8px, 1.8cqi, 14px); + pointer-events: none; + z-index: 4; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +.gd-tp-copy-snippet { + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border: 1px solid rgba(137, 180, 250, 0.4); + border-radius: 4px; + background: rgba(30, 30, 46, 0.7); + backdrop-filter: blur(4px); + color: var(--gd-tp-fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: clamp(9px, 1.8cqi, 11px); + cursor: pointer; + transition: opacity 0.3s ease, background 0.15s, border-color 0.15s, transform 0.1s; + white-space: nowrap; +} + +.gd-tp-copy-snippet:hover { + background: rgba(30, 30, 46, 0.95); + border-color: var(--gd-tp-accent); + transform: scale(1.03); +} + +.gd-tp-copy-snippet:active { + transform: scale(0.97); +} + +.gd-tp-copy-snippet.gd-tp-copied { + border-color: #a6e3a1; + background: rgba(166, 227, 161, 0.15); +} + +.gd-tp-copy-snippet.gd-tp-copied .gd-tp-copy-label::after { + content: ' \2713'; +} + +.gd-tp-copy-label { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Light theme copy button */ +body[data-bs-theme="light"] .gd-tp-copy-snippet, +:root:not([data-bs-theme="dark"]) .gd-termshow.gd-tp-light .gd-tp-copy-snippet { + background: rgba(239, 241, 245, 0.92); + border-color: rgba(30, 102, 245, 0.3); + color: var(--gd-tp-fg); +} + +body[data-bs-theme="light"] .gd-tp-copy-snippet:hover, +:root:not([data-bs-theme="dark"]) .gd-termshow.gd-tp-light .gd-tp-copy-snippet:hover { + border-color: var(--gd-tp-accent); + background: rgba(239, 241, 245, 0.98); +} From a275e3029f551347e9232f9256f187ac3479f28d Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sun, 24 May 2026 23:12:40 -0400 Subject: [PATCH 6/9] Add snippets support and docs for termshow --- .../synthetic/specs/gdtest_termshow.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/test-packages/synthetic/specs/gdtest_termshow.py b/test-packages/synthetic/specs/gdtest_termshow.py index 0ede2dac..a2b0e5fd 100644 --- a/test-packages/synthetic/specs/gdtest_termshow.py +++ b/test-packages/synthetic/specs/gdtest_termshow.py @@ -199,6 +199,20 @@ ' text: "Verbose mode shows each build step"\n' " position: top-right\n" " style: callout\n" + "\n" + "snippets:\n" + " - at: 0.0\n" + " duration: 4.0\n" + ' text: "pip install my-tool"\n' + ' label: "Install"\n' + " - at: 4.5\n" + " duration: 5.0\n" + ' text: "my-tool init"\n' + ' label: "Initialize"\n' + " - at: 10.0\n" + " duration: 4.5\n" + ' match: "\\\\$ (.+)"\n' + ' label: "Run"\n' ), # Recording 2: TUI interface demo "demos/tui-demo.termshow": ( @@ -469,6 +483,24 @@ ' text: "Renders .termshow recordings into SVG keyframes for embedding"\n' " position: top-right\n" " style: highlight\n" + "\n" + "snippets:\n" + " - at: 0.0\n" + " duration: 3.0\n" + ' match: "\\\\$ (.+)"\n' + ' label: "Version"\n' + " - at: 3.5\n" + " duration: 5.0\n" + ' match: "\\\\$ (.+)"\n' + ' label: "Scan"\n' + " - at: 9.0\n" + " duration: 5.0\n" + ' match: "\\\\$ (.+)"\n' + ' label: "Lint"\n' + " - at: 14.5\n" + " duration: 6.0\n" + ' match: "\\\\$ (.+)"\n' + ' label: "Render"\n' ), # ── User guide pages ───────────────────────────────────────────── "user_guide/01-quick-start.qmd": ( @@ -716,8 +748,65 @@ " - from: 8.0\n" " to: 11.0\n" " type: ellipsis # Shows '…' for the cut section\n" + "\n" + "snippets:\n" + " - at: 0.0\n" + " duration: 5.0\n" + " text: pip install my-package\n" + " label: Install\n" + " - at: 12.0\n" + " duration: 4.0\n" + ' match: "\\\\$ (.+)" # Regex: captures command after $ prompt\n' + " label: Run\n" + "```\n" + "\n" + "## Snippets\n" + "\n" + "Snippets add a **copy button** to the player that appears during a time range.\n" + "Readers can click it to copy text to their clipboard without manual retyping.\n" + "\n" + "### Literal Text\n" + "\n" + "Use `text` when you know the exact string to copy:\n" + "\n" + "```yaml\n" + "snippets:\n" + " - at: 0.5\n" + " duration: 7.0\n" + " text: pip install my-tool\n" + " label: Install\n" "```\n" "\n" + "### Regex Match\n" + "\n" + "Use `match` to extract text dynamically from the terminal buffer:\n" + "\n" + "```yaml\n" + "snippets:\n" + " - at: 0.5\n" + " duration: 7.0\n" + ' match: "\\\\$ (.+)"\n' + " label: Copy Command\n" + "```\n" + "\n" + "The regex runs against the visible terminal text when the reader clicks\n" + "the copy button. If it has a capture group, group 1 is copied; otherwise\n" + "the full match is used. This is useful when the terminal shows a typed\n" + "command and you want to extract it without hardcoding.\n" + "\n" + "### Snippet Fields\n" + "\n" + "| Field | Default | Description |\n" + "|-------|---------|-------------|\n" + "| `at` | *(required)* | Time in seconds when the button appears |\n" + "| `duration` | `5.0` | How long the button stays visible |\n" + '| `text` | `""` | Literal text copied on click |\n' + '| `match` | `""` | Regex matched against visible terminal text |\n' + '| `label` | `"Copy"` | Button label shown alongside the copy icon |\n' + "\n" + "At least one of `text` or `match` must be provided. If both are set,\n" + "`match` takes priority (with `text` as fallback).\n" + "\n" "## Settings Reference\n" "\n" "| Setting | Default | Description |\n" From 0cf03a986e54feb36a675b7dabef6861e82dab71 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sun, 24 May 2026 23:17:19 -0400 Subject: [PATCH 7/9] Update termshow-demo.termshow.yml --- assets/termshow-demo.termshow.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/assets/termshow-demo.termshow.yml b/assets/termshow-demo.termshow.yml index 67c909d8..7e7d1f91 100644 --- a/assets/termshow-demo.termshow.yml +++ b/assets/termshow-demo.termshow.yml @@ -22,3 +22,13 @@ annotations: position: top-right style: subtle +snippets: + - at: 0.8 + duration: 10.91 + text: "pip install great-tables" + label: "Install" + - at: 12.9 + duration: 10.5 + match: '\$ (.+)' + label: "Verify" + From c8ce7bceaf4deb168ace5dcddde64a4280022b2f Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sun, 24 May 2026 23:17:32 -0400 Subject: [PATCH 8/9] Round times/durations to 2 decimals --- great_docs/_term_player/editor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/great_docs/_term_player/editor.py b/great_docs/_term_player/editor.py index 618655e2..f181fcda 100644 --- a/great_docs/_term_player/editor.py +++ b/great_docs/_term_player/editor.py @@ -96,7 +96,7 @@ def _serialize_script(script_data: dict[str, Any], source_path: str) -> str: if chapters: lines.append("chapters:") for ch in sorted(chapters, key=lambda c: c["time"]): - lines.append(f" - at: {ch['time']}") + lines.append(f" - at: {round(ch['time'], 2)}") lines.append(f' label: "{ch["label"]}"') lines.append("") @@ -104,8 +104,8 @@ def _serialize_script(script_data: dict[str, Any], source_path: str) -> str: if annotations: lines.append("annotations:") for ann in sorted(annotations, key=lambda a: a["time"]): - lines.append(f" - at: {ann['time']}") - lines.append(f" duration: {ann['duration']}") + lines.append(f" - at: {round(ann['time'], 2)}") + lines.append(f" duration: {round(ann['duration'], 2)}") lines.append(f' text: "{ann["text"]}"') lines.append(f" position: {ann['position']}") width = ann.get("width", "medium") @@ -118,8 +118,8 @@ def _serialize_script(script_data: dict[str, Any], source_path: str) -> str: if cuts: lines.append("cuts:") for cut in sorted(cuts, key=lambda c: c["start"]): - lines.append(f" - from: {cut['start']}") - lines.append(f" to: {cut['end']}") + lines.append(f" - from: {round(cut['start'], 2)}") + lines.append(f" to: {round(cut['end'], 2)}") lines.append(f" type: {cut['type']}") lines.append("") @@ -127,8 +127,8 @@ def _serialize_script(script_data: dict[str, Any], source_path: str) -> str: if snippets: lines.append("snippets:") for cmd in sorted(snippets, key=lambda c: c["time"]): - lines.append(f" - at: {cmd['time']}") - lines.append(f" duration: {cmd['duration']}") + lines.append(f" - at: {round(cmd['time'], 2)}") + lines.append(f" duration: {round(cmd['duration'], 2)}") text = cmd.get("text", "") match = cmd.get("match", "") if text: From d38c98bc4934f9cb8ed1ff26b25dcebaa232d4ef Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sun, 24 May 2026 23:24:03 -0400 Subject: [PATCH 9/9] Add Snippets docs and editor UI updates --- user_guide/41-terminal-recordings.qmd | 105 +++++++++++++++++++++----- 1 file changed, 85 insertions(+), 20 deletions(-) diff --git a/user_guide/41-terminal-recordings.qmd b/user_guide/41-terminal-recordings.qmd index 6c725eff..bad2171b 100644 --- a/user_guide/41-terminal-recordings.qmd +++ b/user_guide/41-terminal-recordings.qmd @@ -414,6 +414,63 @@ The `type` field controls what viewers see during the cut: Cuts are non-destructive: they are stored in the script file, not applied to the raw recording. You can always remove or adjust them later. +## Snippets + +Snippets let you add a **copy button** to the player that appears during a time range. When visible, +readers can click it to copy the associated text to their clipboard: no manual retyping needed. + +```yaml +snippets: + - at: 0.5 + duration: 7.0 + text: "pip install great-tables" + label: Install + - at: 8.0 + duration: 6.0 + match: '\$ (.+)' + label: Verify +``` + +You can provide text in two ways: + +- **`text`**: a literal string that gets copied verbatim +- **`match`**: a regex run against the terminal buffer at that moment; copies the first capture +group (or the full match if no group is defined) + +If both are set, `match` takes priority (with `text` as fallback if the regex doesn't match). + +### Fields + +| Field | Default | Description | +|-------|---------|-------------| +| `at` | *(required)* | Time in seconds when the button appears | +| `duration` | `5.0` | How long the button stays visible | +| `text` | `""` | Literal text copied to the clipboard on click | +| `match` | `""` | Regex pattern matched against the visible terminal text | +| `label` | `"Copy"` | Button label shown alongside the copy icon | + +At least one of `text` or `match` must be provided. + +The button fades in and out smoothly, just like annotations. It appears in the **title bar** at +the far right of the player. Only one snippet button is visible at a time. If time ranges overlap, +the first one wins and subsequent ones are hidden. + +**Tips:** + +- Snippet time ranges **must not overlap**. The Editor enforces this; if you write overlapping + ranges manually in YAML, only the first active snippet is shown. +- Align snippets with chapters: set `at` to the chapter start time and `duration` to the chapter + length. This way the copy button is visible for the entire step. +- Use `text` when you know the exact string (e.g., a `pip install` invocation). Don't include + prompts (`$`). +- Use `match` when the text is dynamic or you want to extract from what's visible. For example, + `'\$ (.+)'` matches any shell prompt line and captures just the command. Use single quotes in + YAML to avoid backslash escape issues. +- Use `label` to give context (e.g., "Install", "Build", "Test") so readers know what they're + copying without reading the full snippet. +- Snippets work regardless of playback state — readers can copy while paused, playing, or at a + chapter stop. + ## Importing Existing Recordings Already have terminal recordings from other tools? Import them: @@ -521,9 +578,10 @@ thematic groups: **Editing actions** (left): -- **+ Chapter**: Add a chapter marker at the current playhead position (shortcut: `C`) -- **+ Annotation**: Add a 2-second annotation at the playhead (shortcut: `A`) -- **+ Cut**: Mark a cut region at the playhead (shortcut: `X`) +- **+ Chapter**: add a chapter marker at the current playhead position (shortcut: `C`) +- **+ Annotation**: add a 2-second annotation at the playhead (shortcut: `A`) +- **+ Cut**: mark a cut region at the playhead (shortcut: `X`) +- **+ Snippet**: add a snippet at the playhead (shortcut: `D`) **Layout presets** (center): A segmented button with three view presets that control the split between the Preview Area and the Timeline Panel: @@ -538,14 +596,14 @@ split between the Preview Area and the Timeline Panel: - **Save**: Write changes to the `.termshow.yml` file (shortcut: `Cmd+S` / `Ctrl+S`). Pulses gold when there are unsaved changes. -**Preview Area.** The center panel shows a live terminal preview. It renders the recording state at the current -playhead position, updating in real time as you scrub, drag edges, or play back. +**Preview Area.** The center panel shows a live terminal preview. It renders the recording state at +the current playhead position, updating in real time as you scrub, drag edges, or play back. The viewport uses **auto-fit scaling**: the terminal always renders at its native character grid -(matching the recorded dimensions), then scales proportionally to fill the available space. -This means wide recordings (120 columns) and tall recordings (40+ rows) display without clipping -or scrollbars. When you expand the preview area (via the resize handle or layout presets), the -terminal scales up to fill it. The scaling recalculates on window resize and during drag. +(matching the recorded dimensions), then scales proportionally to fill the available space. This +means wide recordings (120 columns) and tall recordings (40+ rows) display without clipping or +scrollbars. When you expand the preview area (via the resize handle or layout presets), the terminal +scales up to fill it. The scaling recalculates on window resize and during drag. Above the terminal viewport, a **Chapter Title Bar** displays the currently active chapter name, simulating the window chrome appearance of the final player. @@ -579,7 +637,7 @@ preset indicator. - **Timecode Display**: Shows elapsed time / total duration in `MM:SS.cc` precision. Click the current-time value to type an exact position (e.g., `01:23.45`) and press Enter to jump there. -**Timeline Panel.** The bottom panel contains three track lanes beneath a **Timeline Ruler**, the +**Timeline Panel.** The bottom panel contains four track lanes beneath a **Timeline Ruler**, the graduated strip showing time markings. Click anywhere on the ruler to reposition the playhead instantly, or drag the playhead indicator to scrub through the recording. The ruler is the safest place to navigate without accidentally selecting or creating items. @@ -597,7 +655,11 @@ place to navigate without accidentally selecting or creating items. handles. Double-click empty space to add a 2-second cut; drag across the track to create a custom-length cut. -A **Playhead** (white vertical line) spans all three tracks and moves with the current time. +4. **Snippets Track**: Blue boxes showing the snippet text. Click a box to select it; drag to + reposition. Boxes have edge handles for resizing the time range. Double-click empty space to + add a 5-second placeholder; drag across the track to create a custom-duration snippet. + +A **Playhead** (white vertical line) spans all four tracks and moves with the current time. ### Properties Panel @@ -608,6 +670,7 @@ context-specific fields: - **Chapter**: Time (seconds) and Label - **Annotation**: Time, Duration, Text, Position (dropdown), and Style (dropdown) - **Cut Region**: From (seconds), To (seconds), and Replace With (None/Ellipsis) +- **Snippet**: Time, Duration, Text (literal copy), Match (regex pattern), and Label Each panel includes: @@ -619,24 +682,25 @@ adjust values. ### Edge Editing -Both annotation boxes and cut regions have draggable edge handles (left and right). When you grab -an edge: +Both annotation boxes, cut regions, and snippet boxes have draggable edge handles (left and right). +When you grab an edge: 1. The **playhead snaps** to that edge's time position, showing the exact terminal frame at the - boundary. +boundary. 2. The edge displays a **prominent vertical stem** extending beyond the track (green for - annotations, red for cuts) with a directional glow so you know which side is active. +annotations, red for cuts, blue for snippets) with a directional glow so you know which side is +active. 3. **Arrow keys** (← →) nudge the active edge by ±10ms for frame-accurate positioning. The - playhead tracks the edge so you can see the terminal state update with each nudge. +playhead tracks the edge so you can see the terminal state update with each nudge. Clicking elsewhere or pressing `Escape` deselects and restores normal edge appearance. ### Drag-to-Create -On the Annotations and Cuts tracks, clicking and dragging across empty space creates a new item -spanning the drag distance. The drag only initiates after a 4-pixel movement threshold, preventing -accidental creation from stray clicks. If you want a fixed-size item without dragging, -double-click instead (creates a 2-second item at that position). +On the Annotations, Cuts, and Snippets tracks, clicking and dragging across empty space creates a +new item spanning the drag distance. The drag only initiates after a 4-pixel movement threshold, +preventing accidental creation from stray clicks. If you want a fixed-size item without dragging, +double-click instead (creates a 2-second item for annotations/cuts, or 5-second for snippets). ### Cut Types @@ -659,6 +723,7 @@ The Termshow Editor supports these keyboard shortcuts for efficient editing: | `C` | Add chapter at playhead | | `A` | Add annotation at playhead | | `X` | Add cut at playhead | +| `D` | Add snippet at playhead | | `I` | Toggle info panel | | `←` / `→` | Seek ±1 second (or nudge active edge ±10ms) | | `[` / `]` | Jump to previous / next chapter |