Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,6 @@ Beyond `init`, `build`, and `preview`, Great Docs includes a full suite of quali
| `great-docs termshow play` | Preview a recording in the terminal |
| `great-docs termshow render` | Render SVG frames without a full site build |
| `great-docs termshow import-cast` | Import an asciinema `.cast` file |
| `great-docs termshow import-tape` | Import a VHS `.tape` file |

## Recipes

Expand Down
150 changes: 1 addition & 149 deletions great_docs/_term_player/importer.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""Importers for asciicast and VHS tape formats."""
"""Importer for asciicast format."""

from __future__ import annotations

import json
import re
from pathlib import Path

from .parser import Recording, parse_asciicast
Expand Down Expand Up @@ -31,153 +30,6 @@ def import_asciicast(source: str | Path, output: str | Path) -> Recording:
return recording


def import_tape(source: str | Path, output: str | Path) -> Recording:
"""Import a VHS .tape file and convert to .termshow.

Parses the VHS tape DSL and generates synthetic terminal events.
This produces a scripted recording rather than a real capture.

Parameters
----------
source
Path to .tape file.
output
Path to write .termshow file.

Returns
-------
Recording
The generated recording.
"""
path = Path(source)
text = path.read_text(encoding="utf-8")

recording = _parse_tape(text)
_write_termshow(recording, output)
return recording


def _parse_tape(text: str) -> Recording:
"""Parse VHS tape DSL into a Recording with synthetic events."""
from .parser import Event, Recording, TermInfo

cols = 80
rows = 24
typing_speed = 0.05 # seconds per character
events: list[Event] = []
current_time = 0.0

lines = text.strip().splitlines()

for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue

# Parse Set commands
set_match = re.match(r"Set\s+(\w+)\s+(.+)", line)
if set_match:
key, value = set_match.group(1), set_match.group(2).strip().strip('"')
if key == "Width":
cols = int(value)
elif key == "Height":
rows = int(value)
elif key == "TypingSpeed":
typing_speed = _parse_duration(value)
continue

# Parse Type command
type_match = re.match(r"Type(?:@(\S+))?\s+(.+)", line)
if type_match:
speed_override = type_match.group(1)
text_content = type_match.group(2).strip().strip('"').strip("`")
speed = _parse_duration(speed_override) if speed_override else typing_speed

for ch in text_content:
current_time += speed
events.append(Event(time=current_time, code="o", data=ch))
continue

# Parse Sleep command
sleep_match = re.match(r"Sleep\s+(.+)", line)
if sleep_match:
duration = _parse_duration(sleep_match.group(1).strip())
current_time += duration
continue

# Parse Enter
if re.match(r"Enter(\s+\d+)?", line):
count_match = re.search(r"\d+", line)
count = int(count_match.group()) if count_match else 1
for _ in range(count):
current_time += 0.05
events.append(Event(time=current_time, code="o", data="\r\n"))
continue

# Parse key commands
key_match = re.match(r"(Backspace|Tab|Space|Up|Down|Left|Right|Escape)(\s+\d+)?", line)
if key_match:
key_name = key_match.group(1)
count_str = key_match.group(2)
count = int(count_str.strip()) if count_str else 1
char_map = {
"Backspace": "\x08",
"Tab": "\t",
"Space": " ",
"Up": "\x1b[A",
"Down": "\x1b[B",
"Right": "\x1b[C",
"Left": "\x1b[D",
"Escape": "\x1b",
}
char = char_map.get(key_name, "")
for _ in range(count):
current_time += 0.05
events.append(Event(time=current_time, code="o", data=char))
continue

# Parse Ctrl+key
ctrl_match = re.match(r"Ctrl\+(\w)", line)
if ctrl_match:
ch = ctrl_match.group(1).upper()
ctrl_char = chr(ord(ch) - 64)
current_time += 0.05
events.append(Event(time=current_time, code="o", data=ctrl_char))
continue

# Hide/Show are noted as markers
if line == "Hide":
events.append(Event(time=current_time, code="m", data="[hidden]"))
continue
if line == "Show":
events.append(Event(time=current_time, code="m", data="[visible]"))
continue

recording = Recording(
version=1,
format="termshow",
term=TermInfo(cols=cols, rows=rows),
title="",
events=events,
)

return recording


def _parse_duration(s: str) -> float:
"""Parse a VHS duration string (e.g., '500ms', '1s', '2.5')."""
s = s.strip()
if s.endswith("ms"):
return float(s[:-2]) / 1000.0
elif s.endswith("s"):
return float(s[:-1])
else:
try:
return float(s)
except ValueError:
return 0.1


def _write_termshow(recording: Recording, output: str | Path) -> None:
"""Write a Recording to .termshow format."""
path = Path(output)
Expand Down
16 changes: 0 additions & 16 deletions great_docs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3339,25 +3339,9 @@ def term_import_cast(source: str, output: str) -> None:
click.echo(f" Duration: {recording.duration:.1f}s | Events: {len(recording.events)}")


@click.command("import-tape")
@click.argument("source", type=click.Path(exists=True))
@click.argument("output", type=click.Path())
def term_import_tape(source: str, output: str) -> None:
"""Import a VHS .tape file to .termshow format."""
from ._term_player.importer import import_tape

if not output.endswith(".termshow"):
output += ".termshow"

recording = import_tape(source, output)
click.echo(f"✓ Imported {source} → {output}")
click.echo(f" Duration: {recording.duration:.1f}s | Events: {len(recording.events)}")


termshow.add_command(term_record)
termshow.add_command(term_render)
termshow.add_command(term_import_cast)
termshow.add_command(term_import_tape)


@click.command("edit")
Expand Down
10 changes: 2 additions & 8 deletions test-packages/synthetic/specs/gdtest_termshow.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,14 +642,11 @@
"\n"
"### Importing Existing Recordings\n"
"\n"
"Already have recordings from asciinema or VHS?\n"
"Already have recordings from asciinema?\n"
"\n"
"```bash\n"
"# Import from asciinema\n"
"great-docs termshow import-cast recording.cast demos/my-demo\n"
"\n"
"# Import from VHS tape\n"
"great-docs termshow import-tape demo.tape demos/my-demo\n"
"```\n"
),
# ── Detailed termshow guide ───────────────────────────────────
Expand Down Expand Up @@ -990,14 +987,11 @@
"\n"
"## Importing Existing Recordings\n"
"\n"
"Already have terminal recordings from other tools? Import them:\n"
"Already have terminal recordings from asciinema? Import them:\n"
"\n"
"```bash\n"
"# From asciinema (.cast files)\n"
"great-docs termshow import-cast recording.cast demos/my-demo\n"
"\n"
"# From VHS (.tape files)\n"
"great-docs termshow import-tape demo.tape demos/my-demo\n"
"```\n"
"\n"
"The import preserves timing and terminal dimensions. You'll still\n"
Expand Down
47 changes: 0 additions & 47 deletions tests/test_term_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,6 @@ def _make_asciicast_file(tmp_path: Path, name: str = "test.cast") -> Path:
return f


def _make_tape_file(tmp_path: Path, name: str = "test.tape") -> Path:
"""Create a minimal VHS tape file."""
content = 'Set Width 80\nSet Height 24\nType "hello"\nEnter\n'
f = tmp_path / name
f.write_text(content, encoding="utf-8")
return f


# ---------------------------------------------------------------------------
# term render
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -173,44 +165,6 @@ def test_import_cast_shows_stats(self, tmp_path: Path):
assert "Events:" in result.output


# ---------------------------------------------------------------------------
# term import-tape
# ---------------------------------------------------------------------------


class TestTermImportTape:
def test_import_tape(self, tmp_path: Path):
source = _make_tape_file(tmp_path)
output = tmp_path / "imported.termshow"

runner = CliRunner()
result = runner.invoke(cli, ["termshow", "import-tape", str(source), str(output)])

assert result.exit_code == 0
assert "Imported" in result.output
assert output.exists()

def test_import_tape_appends_extension(self, tmp_path: Path):
source = _make_tape_file(tmp_path)
output = tmp_path / "imported"

runner = CliRunner()
result = runner.invoke(cli, ["termshow", "import-tape", str(source), str(output)])

assert result.exit_code == 0
assert (tmp_path / "imported.termshow").exists()

def test_import_tape_shows_stats(self, tmp_path: Path):
source = _make_tape_file(tmp_path)
output = tmp_path / "out.termshow"

runner = CliRunner()
result = runner.invoke(cli, ["termshow", "import-tape", str(source), str(output)])

assert "Duration:" in result.output
assert "Events:" in result.output


# ---------------------------------------------------------------------------
# term (group help)
# ---------------------------------------------------------------------------
Expand All @@ -224,7 +178,6 @@ def test_help(self):
assert "record" in result.output
assert "render" in result.output
assert "import-cast" in result.output
assert "import-tape" in result.output
assert "edit" in result.output


Expand Down
Loading
Loading