termstage 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termstage-0.1.0/.github/workflows/release.yml +28 -0
- termstage-0.1.0/.gitignore +39 -0
- termstage-0.1.0/.python-version +1 -0
- termstage-0.1.0/CHANGELOG.md +17 -0
- termstage-0.1.0/PKG-INFO +155 -0
- termstage-0.1.0/README.md +146 -0
- termstage-0.1.0/examples/demo.yaml +27 -0
- termstage-0.1.0/pyproject.toml +20 -0
- termstage-0.1.0/src/termstage/__init__.py +3 -0
- termstage-0.1.0/src/termstage/animator.py +257 -0
- termstage-0.1.0/src/termstage/cli.py +160 -0
- termstage-0.1.0/src/termstage/renderer.py +159 -0
- termstage-0.1.0/src/termstage/themes.py +40 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Release to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build-and-publish:
|
|
10
|
+
name: Build and publish to PyPI
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write # required for trusted publisher (OIDC)
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Install uv
|
|
19
|
+
uses: astral-sh/setup-uv@v5
|
|
20
|
+
|
|
21
|
+
- name: Set up Python
|
|
22
|
+
run: uv python install
|
|
23
|
+
|
|
24
|
+
- name: Build package
|
|
25
|
+
run: uv build
|
|
26
|
+
|
|
27
|
+
- name: Publish to PyPI
|
|
28
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg-info/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.egg
|
|
11
|
+
*.whl
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# uv
|
|
19
|
+
.uv/
|
|
20
|
+
uv.lock
|
|
21
|
+
|
|
22
|
+
# IDEs
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
*.swp
|
|
26
|
+
*.swo
|
|
27
|
+
|
|
28
|
+
# OS
|
|
29
|
+
.DS_Store
|
|
30
|
+
Thumbs.db
|
|
31
|
+
|
|
32
|
+
# Output SVGs (examples)
|
|
33
|
+
*.svg
|
|
34
|
+
!examples/*.svg
|
|
35
|
+
|
|
36
|
+
# Test artifacts
|
|
37
|
+
.pytest_cache/
|
|
38
|
+
.coverage
|
|
39
|
+
htmlcov/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented here.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
5
|
+
Versions follow [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## [0.1.0] — 2026-02-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Initial release
|
|
13
|
+
- YAML-driven terminal demo SVG generation — no recording required
|
|
14
|
+
- `termstage` CLI with `render` command
|
|
15
|
+
- Support for typing, pause, output, and clear actions
|
|
16
|
+
- Typer-based CLI with `--output` flag
|
|
17
|
+
- PyPI packaging via hatchling + uv
|
termstage-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: termstage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fake terminal demos. YAML in, SVG out. No recording.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: pyyaml>=6.0
|
|
7
|
+
Requires-Dist: typer>=0.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# termstage
|
|
11
|
+
|
|
12
|
+
Fake terminal demos. Write a YAML file describing the session, run `termstage render`, get an SVG. No recording, no live shell, no asciinema.
|
|
13
|
+
|
|
14
|
+
Good for README headers and docs where you want to show what a CLI looks like without screenshotting your actual terminal.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install termstage
|
|
22
|
+
# or
|
|
23
|
+
uv add termstage
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
**1. Write a demo YAML:**
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
# demo.yaml
|
|
32
|
+
title: "notes — a simple CLI notes app"
|
|
33
|
+
theme: dark
|
|
34
|
+
prompt: "$ "
|
|
35
|
+
width: 700
|
|
36
|
+
|
|
37
|
+
steps:
|
|
38
|
+
- cmd: "notes --version"
|
|
39
|
+
output: "notes 1.0.0"
|
|
40
|
+
- cmd: "notes add 'Fix the login bug before Friday'"
|
|
41
|
+
output: "Added note #1"
|
|
42
|
+
- cmd: "notes list"
|
|
43
|
+
output: |
|
|
44
|
+
#1 Fix the login bug before Friday
|
|
45
|
+
- comment: "# Search works too"
|
|
46
|
+
- cmd: "notes search 'login'"
|
|
47
|
+
output: "#1 Fix the login bug before Friday"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**2. Render:**
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
termstage render demo.yaml # → demo.svg
|
|
54
|
+
termstage render demo.yaml -o out.svg
|
|
55
|
+
termstage render demo.yaml --animated # CSS typewriter animation
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**3. Preview:**
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
termstage preview demo.yaml
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Commands
|
|
67
|
+
|
|
68
|
+
| Command | Description |
|
|
69
|
+
|---------|-------------|
|
|
70
|
+
| `termstage render <file.yaml>` | Render static SVG |
|
|
71
|
+
| `termstage render <file.yaml> -o out.svg` | Custom output path |
|
|
72
|
+
| `termstage render <file.yaml> --animated` | Animated CSS typewriter SVG |
|
|
73
|
+
| `termstage init` | Create a starter `demo.yaml` in current directory |
|
|
74
|
+
| `termstage themes` | List available themes |
|
|
75
|
+
| `termstage preview <file.yaml>` | Render and open in browser |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## YAML Format
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
title: "my cli demo" # Window title bar text
|
|
83
|
+
theme: dark # dark | light | dracula | nord
|
|
84
|
+
prompt: "$ " # Prompt string
|
|
85
|
+
width: 700 # SVG width in pixels (default: 700)
|
|
86
|
+
|
|
87
|
+
steps:
|
|
88
|
+
- cmd: "notes --version"
|
|
89
|
+
output: "notes 1.0.0"
|
|
90
|
+
|
|
91
|
+
- cmd: "notes add 'Fix the login bug before Friday'"
|
|
92
|
+
output: "Added note #1"
|
|
93
|
+
|
|
94
|
+
- cmd: "notes --help"
|
|
95
|
+
output: |
|
|
96
|
+
Usage: notes [OPTIONS] COMMAND
|
|
97
|
+
add Add a new note
|
|
98
|
+
list List all notes
|
|
99
|
+
search Search notes by keyword
|
|
100
|
+
done Mark a note as done
|
|
101
|
+
|
|
102
|
+
- comment: "# Search works too"
|
|
103
|
+
|
|
104
|
+
- cmd: "notes search 'login'"
|
|
105
|
+
output: "#1 Fix the login bug before Friday"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Two step types: `cmd` (prompt + command + optional output) and `comment` (no prompt, styled like a code comment).
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Themes
|
|
113
|
+
|
|
114
|
+
| Theme | Background | Based on |
|
|
115
|
+
|-------|------------|----------|
|
|
116
|
+
| `dark` | `#1e1e1e` | VS Code Dark+ (default) |
|
|
117
|
+
| `light` | `#ffffff` | Clean light terminal |
|
|
118
|
+
| `dracula` | `#282a36` | Dracula |
|
|
119
|
+
| `nord` | `#2e3440` | Nord |
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
termstage themes
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Animated SVG
|
|
128
|
+
|
|
129
|
+
`--animated` generates a pure CSS animated SVG: commands type out character by character, output fades in after each one, cursor blinks. No JavaScript — works in GitHub READMEs and anywhere SVG is supported.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
termstage render demo.yaml --animated -o demo-animated.svg
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Window
|
|
138
|
+
|
|
139
|
+
SVGs render with a macOS-style title bar (traffic light dots, centered title, rounded corners). Font stack: JetBrains Mono, Fira Code, Cascadia Code, monospace.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Examples
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
termstage init
|
|
147
|
+
termstage render demo.yaml -o demo.svg
|
|
148
|
+
termstage render demo.yaml --animated -o demo-animated.svg
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# termstage
|
|
2
|
+
|
|
3
|
+
Fake terminal demos. Write a YAML file describing the session, run `termstage render`, get an SVG. No recording, no live shell, no asciinema.
|
|
4
|
+
|
|
5
|
+
Good for README headers and docs where you want to show what a CLI looks like without screenshotting your actual terminal.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install termstage
|
|
13
|
+
# or
|
|
14
|
+
uv add termstage
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
**1. Write a demo YAML:**
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
# demo.yaml
|
|
23
|
+
title: "notes — a simple CLI notes app"
|
|
24
|
+
theme: dark
|
|
25
|
+
prompt: "$ "
|
|
26
|
+
width: 700
|
|
27
|
+
|
|
28
|
+
steps:
|
|
29
|
+
- cmd: "notes --version"
|
|
30
|
+
output: "notes 1.0.0"
|
|
31
|
+
- cmd: "notes add 'Fix the login bug before Friday'"
|
|
32
|
+
output: "Added note #1"
|
|
33
|
+
- cmd: "notes list"
|
|
34
|
+
output: |
|
|
35
|
+
#1 Fix the login bug before Friday
|
|
36
|
+
- comment: "# Search works too"
|
|
37
|
+
- cmd: "notes search 'login'"
|
|
38
|
+
output: "#1 Fix the login bug before Friday"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**2. Render:**
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
termstage render demo.yaml # → demo.svg
|
|
45
|
+
termstage render demo.yaml -o out.svg
|
|
46
|
+
termstage render demo.yaml --animated # CSS typewriter animation
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**3. Preview:**
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
termstage preview demo.yaml
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
| Command | Description |
|
|
60
|
+
|---------|-------------|
|
|
61
|
+
| `termstage render <file.yaml>` | Render static SVG |
|
|
62
|
+
| `termstage render <file.yaml> -o out.svg` | Custom output path |
|
|
63
|
+
| `termstage render <file.yaml> --animated` | Animated CSS typewriter SVG |
|
|
64
|
+
| `termstage init` | Create a starter `demo.yaml` in current directory |
|
|
65
|
+
| `termstage themes` | List available themes |
|
|
66
|
+
| `termstage preview <file.yaml>` | Render and open in browser |
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## YAML Format
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
title: "my cli demo" # Window title bar text
|
|
74
|
+
theme: dark # dark | light | dracula | nord
|
|
75
|
+
prompt: "$ " # Prompt string
|
|
76
|
+
width: 700 # SVG width in pixels (default: 700)
|
|
77
|
+
|
|
78
|
+
steps:
|
|
79
|
+
- cmd: "notes --version"
|
|
80
|
+
output: "notes 1.0.0"
|
|
81
|
+
|
|
82
|
+
- cmd: "notes add 'Fix the login bug before Friday'"
|
|
83
|
+
output: "Added note #1"
|
|
84
|
+
|
|
85
|
+
- cmd: "notes --help"
|
|
86
|
+
output: |
|
|
87
|
+
Usage: notes [OPTIONS] COMMAND
|
|
88
|
+
add Add a new note
|
|
89
|
+
list List all notes
|
|
90
|
+
search Search notes by keyword
|
|
91
|
+
done Mark a note as done
|
|
92
|
+
|
|
93
|
+
- comment: "# Search works too"
|
|
94
|
+
|
|
95
|
+
- cmd: "notes search 'login'"
|
|
96
|
+
output: "#1 Fix the login bug before Friday"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Two step types: `cmd` (prompt + command + optional output) and `comment` (no prompt, styled like a code comment).
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Themes
|
|
104
|
+
|
|
105
|
+
| Theme | Background | Based on |
|
|
106
|
+
|-------|------------|----------|
|
|
107
|
+
| `dark` | `#1e1e1e` | VS Code Dark+ (default) |
|
|
108
|
+
| `light` | `#ffffff` | Clean light terminal |
|
|
109
|
+
| `dracula` | `#282a36` | Dracula |
|
|
110
|
+
| `nord` | `#2e3440` | Nord |
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
termstage themes
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Animated SVG
|
|
119
|
+
|
|
120
|
+
`--animated` generates a pure CSS animated SVG: commands type out character by character, output fades in after each one, cursor blinks. No JavaScript — works in GitHub READMEs and anywhere SVG is supported.
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
termstage render demo.yaml --animated -o demo-animated.svg
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Window
|
|
129
|
+
|
|
130
|
+
SVGs render with a macOS-style title bar (traffic light dots, centered title, rounded corners). Font stack: JetBrains Mono, Fira Code, Cascadia Code, monospace.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Examples
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
termstage init
|
|
138
|
+
termstage render demo.yaml -o demo.svg
|
|
139
|
+
termstage render demo.yaml --animated -o demo-animated.svg
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
title: "notes — a simple CLI notes app"
|
|
2
|
+
theme: dark
|
|
3
|
+
prompt: "$ "
|
|
4
|
+
width: 700
|
|
5
|
+
|
|
6
|
+
steps:
|
|
7
|
+
- cmd: "notes --version"
|
|
8
|
+
output: "notes 1.0.0"
|
|
9
|
+
|
|
10
|
+
- cmd: "notes add 'Fix the login bug before Friday'"
|
|
11
|
+
output: "Added note #1"
|
|
12
|
+
|
|
13
|
+
- cmd: "notes add 'Review pull request from @alice'"
|
|
14
|
+
output: "Added note #2"
|
|
15
|
+
|
|
16
|
+
- cmd: "notes list"
|
|
17
|
+
output: |
|
|
18
|
+
#1 Fix the login bug before Friday
|
|
19
|
+
#2 Review pull request from @alice
|
|
20
|
+
|
|
21
|
+
- comment: "# Search works too"
|
|
22
|
+
|
|
23
|
+
- cmd: "notes search 'login'"
|
|
24
|
+
output: "#1 Fix the login bug before Friday"
|
|
25
|
+
|
|
26
|
+
- cmd: "notes done 1"
|
|
27
|
+
output: "Marked #1 as done"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "termstage"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Fake terminal demos. YAML in, SVG out. No recording."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"typer>=0.9",
|
|
9
|
+
"pyyaml>=6.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
termstage = "termstage.cli:app"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/termstage"]
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""CSS keyframe-based animated SVG generation for termstage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .themes import (
|
|
9
|
+
COMMENT_COLOR,
|
|
10
|
+
FONT_FAMILY,
|
|
11
|
+
FONT_SIZE,
|
|
12
|
+
LINE_HEIGHT,
|
|
13
|
+
OUTPUT_COLOR,
|
|
14
|
+
PADDING,
|
|
15
|
+
THEMES,
|
|
16
|
+
TITLE_BAR_HEIGHT,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Characters per second for typing animation
|
|
20
|
+
CHARS_PER_SEC = 30
|
|
21
|
+
# Seconds for output fade-in
|
|
22
|
+
FADE_DURATION = 0.4
|
|
23
|
+
# Seconds pause after each step before next starts
|
|
24
|
+
STEP_PAUSE = 0.3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _escape(text: str) -> str:
|
|
28
|
+
return html.escape(text, quote=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _count_lines(steps: list[dict]) -> int:
|
|
32
|
+
total = 0
|
|
33
|
+
for i, step in enumerate(steps):
|
|
34
|
+
if "comment" in step:
|
|
35
|
+
total += 1
|
|
36
|
+
elif "cmd" in step:
|
|
37
|
+
total += 1
|
|
38
|
+
output = step.get("output", "")
|
|
39
|
+
if output:
|
|
40
|
+
total += len(output.rstrip("\n").split("\n"))
|
|
41
|
+
if i < len(steps) - 1:
|
|
42
|
+
total += 1
|
|
43
|
+
return total
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _render_title_bar(width: int, title: str, theme: dict) -> str:
|
|
47
|
+
dots_y = TITLE_BAR_HEIGHT // 2
|
|
48
|
+
dots_html = ""
|
|
49
|
+
if theme.get("dots", True):
|
|
50
|
+
dot_colors = ["#ff5f57", "#febc2e", "#28c840"]
|
|
51
|
+
dot_x_start = 16
|
|
52
|
+
dot_spacing = 20
|
|
53
|
+
for i, color in enumerate(dot_colors):
|
|
54
|
+
cx = dot_x_start + i * dot_spacing
|
|
55
|
+
dots_html += f'<circle cx="{cx}" cy="{dots_y}" r="6" fill="{color}" />\n '
|
|
56
|
+
|
|
57
|
+
title_x = width // 2
|
|
58
|
+
title_svg = (
|
|
59
|
+
f'<text x="{title_x}" y="{dots_y + 5}" '
|
|
60
|
+
f'font-family={FONT_FAMILY!r} font-size="13" '
|
|
61
|
+
f'fill="{theme["text"]}" opacity="0.7" '
|
|
62
|
+
f'text-anchor="middle" dominant-baseline="middle">'
|
|
63
|
+
f"{_escape(title)}</text>"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return f""" <!-- Title bar -->
|
|
67
|
+
<rect x="0" y="0" width="{width}" height="{TITLE_BAR_HEIGHT}"
|
|
68
|
+
rx="8" ry="8" fill="{theme['title_bar']}" />
|
|
69
|
+
<rect x="0" y="{TITLE_BAR_HEIGHT - 8}" width="{width}" height="8"
|
|
70
|
+
fill="{theme['title_bar']}" />
|
|
71
|
+
{dots_html}{title_svg}"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _make_keyframes(anim_id: str, n_chars: int, start_s: float, duration_s: float) -> str:
|
|
75
|
+
"""Generate @keyframes for a typewriter clip-path width expansion."""
|
|
76
|
+
# We use a clipPath trick: text is clipped by a rect whose width grows.
|
|
77
|
+
# Each character is ~ch wide; we animate max-width via clip-path.
|
|
78
|
+
# Actually we'll use the 'steps()' timing function with clip-path width.
|
|
79
|
+
# The animation: from width=0 to width=full_width over duration_s
|
|
80
|
+
# with steps(n_chars, end) for discrete character reveals.
|
|
81
|
+
steps_fn = f"steps({max(n_chars, 1)}, end)"
|
|
82
|
+
delay_s = start_s
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
f" @keyframes type-{anim_id} {{\n"
|
|
86
|
+
f" from {{ clip-path: inset(0 100% 0 0); }}\n"
|
|
87
|
+
f" to {{ clip-path: inset(0 0% 0 0); }}\n"
|
|
88
|
+
f" }}\n"
|
|
89
|
+
f" .type-{anim_id} {{\n"
|
|
90
|
+
f" clip-path: inset(0 100% 0 0);\n"
|
|
91
|
+
f" animation: type-{anim_id} {duration_s:.3f}s {steps_fn} {delay_s:.3f}s forwards;\n"
|
|
92
|
+
f" }}\n"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _make_fade_keyframes(anim_id: str, start_s: float) -> str:
|
|
97
|
+
return (
|
|
98
|
+
f" @keyframes fade-{anim_id} {{\n"
|
|
99
|
+
f" from {{ opacity: 0; }}\n"
|
|
100
|
+
f" to {{ opacity: 1; }}\n"
|
|
101
|
+
f" }}\n"
|
|
102
|
+
f" .fade-{anim_id} {{\n"
|
|
103
|
+
f" opacity: 0;\n"
|
|
104
|
+
f" animation: fade-{anim_id} {FADE_DURATION:.2f}s ease {start_s:.3f}s forwards;\n"
|
|
105
|
+
f" }}\n"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _make_cursor_keyframes(anim_id: str, start_s: float, end_s: float) -> str:
|
|
110
|
+
"""Cursor blinks during typing, then disappears."""
|
|
111
|
+
return (
|
|
112
|
+
f" @keyframes blink-{anim_id} {{\n"
|
|
113
|
+
f" 0%, 49% {{ opacity: 1; }}\n"
|
|
114
|
+
f" 50%, 100% {{ opacity: 0; }}\n"
|
|
115
|
+
f" }}\n"
|
|
116
|
+
f" .cursor-{anim_id} {{\n"
|
|
117
|
+
f" opacity: 0;\n"
|
|
118
|
+
f" animation:\n"
|
|
119
|
+
f" blink-{anim_id} 0.6s step-end {start_s:.3f}s {int((end_s - start_s) / 0.6) + 1},\n"
|
|
120
|
+
f" fade-{anim_id}-out 0.01s linear {end_s:.3f}s forwards;\n"
|
|
121
|
+
f" }}\n"
|
|
122
|
+
f" @keyframes fade-{anim_id}-out {{\n"
|
|
123
|
+
f" to {{ opacity: 0; }}\n"
|
|
124
|
+
f" }}\n"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def render_animated_svg(config: dict[str, Any]) -> str:
|
|
129
|
+
"""
|
|
130
|
+
Render an animated SVG terminal window from config dict.
|
|
131
|
+
Uses CSS @keyframes for typewriter + fade-in effects. No JS.
|
|
132
|
+
"""
|
|
133
|
+
title = config.get("title", "termstage demo")
|
|
134
|
+
theme_name = config.get("theme", "dark")
|
|
135
|
+
prompt = config.get("prompt", "$ ")
|
|
136
|
+
width = int(config.get("width", 700))
|
|
137
|
+
steps = config.get("steps", [])
|
|
138
|
+
|
|
139
|
+
theme = THEMES.get(theme_name, THEMES["dark"])
|
|
140
|
+
|
|
141
|
+
n_lines = _count_lines(steps)
|
|
142
|
+
body_height = PADDING + n_lines * LINE_HEIGHT + PADDING
|
|
143
|
+
total_height = TITLE_BAR_HEIGHT + body_height
|
|
144
|
+
|
|
145
|
+
keyframes_parts: list[str] = []
|
|
146
|
+
elements: list[str] = []
|
|
147
|
+
|
|
148
|
+
y = TITLE_BAR_HEIGHT + PADDING + LINE_HEIGHT
|
|
149
|
+
time_cursor = 0.5 # slight initial pause
|
|
150
|
+
|
|
151
|
+
anim_counter = 0
|
|
152
|
+
|
|
153
|
+
for i, step in enumerate(steps):
|
|
154
|
+
if "comment" in step:
|
|
155
|
+
comment_text = step["comment"]
|
|
156
|
+
aid = f"c{anim_counter}"
|
|
157
|
+
anim_counter += 1
|
|
158
|
+
n_chars = len(comment_text)
|
|
159
|
+
duration = n_chars / CHARS_PER_SEC
|
|
160
|
+
|
|
161
|
+
keyframes_parts.append(_make_keyframes(aid, n_chars, time_cursor, duration))
|
|
162
|
+
time_cursor += duration
|
|
163
|
+
|
|
164
|
+
elements.append(
|
|
165
|
+
f' <text x="{PADDING}" y="{y}" '
|
|
166
|
+
f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
|
|
167
|
+
f'fill="{COMMENT_COLOR}" xml:space="preserve" class="type-{aid}">'
|
|
168
|
+
f"{_escape(comment_text)}</text>"
|
|
169
|
+
)
|
|
170
|
+
y += LINE_HEIGHT
|
|
171
|
+
time_cursor += STEP_PAUSE
|
|
172
|
+
|
|
173
|
+
elif "cmd" in step:
|
|
174
|
+
cmd = step["cmd"]
|
|
175
|
+
output = step.get("output", "")
|
|
176
|
+
|
|
177
|
+
full_line = prompt + cmd
|
|
178
|
+
aid = f"cmd{anim_counter}"
|
|
179
|
+
anim_counter += 1
|
|
180
|
+
n_chars = len(full_line)
|
|
181
|
+
duration = n_chars / CHARS_PER_SEC
|
|
182
|
+
|
|
183
|
+
# Cursor appears at start of typing, disappears at end
|
|
184
|
+
cursor_end = time_cursor + duration
|
|
185
|
+
keyframes_parts.append(_make_keyframes(aid, n_chars, time_cursor, duration))
|
|
186
|
+
keyframes_parts.append(
|
|
187
|
+
_make_cursor_keyframes(aid, time_cursor, cursor_end)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Cursor x position: after the text. Approximate at font-size * 0.6 per char
|
|
191
|
+
char_width = FONT_SIZE * 0.61
|
|
192
|
+
cursor_x = PADDING + n_chars * char_width
|
|
193
|
+
|
|
194
|
+
elements.append(
|
|
195
|
+
f' <text x="{PADDING}" y="{y}" '
|
|
196
|
+
f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
|
|
197
|
+
f'xml:space="preserve" class="type-{aid}">'
|
|
198
|
+
f'<tspan fill="{theme["prompt"]}">{_escape(prompt)}</tspan>'
|
|
199
|
+
f'<tspan fill="{theme["text"]}">{_escape(cmd)}</tspan>'
|
|
200
|
+
f"</text>"
|
|
201
|
+
)
|
|
202
|
+
# Cursor rect (blinking block)
|
|
203
|
+
elements.append(
|
|
204
|
+
f' <rect x="{cursor_x:.1f}" y="{y - FONT_SIZE}" '
|
|
205
|
+
f'width="{char_width:.1f}" height="{FONT_SIZE + 2}" '
|
|
206
|
+
f'fill="{theme["text"]}" opacity="0.7" class="cursor-{aid}" />'
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
time_cursor = cursor_end
|
|
210
|
+
y += LINE_HEIGHT
|
|
211
|
+
|
|
212
|
+
if output:
|
|
213
|
+
output_lines = output.rstrip("\n").split("\n")
|
|
214
|
+
# Fade in all output lines together
|
|
215
|
+
oid = f"out{anim_counter}"
|
|
216
|
+
anim_counter += 1
|
|
217
|
+
keyframes_parts.append(_make_fade_keyframes(oid, time_cursor))
|
|
218
|
+
time_cursor += FADE_DURATION
|
|
219
|
+
|
|
220
|
+
for line in output_lines:
|
|
221
|
+
elements.append(
|
|
222
|
+
f' <text x="{PADDING}" y="{y}" '
|
|
223
|
+
f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
|
|
224
|
+
f'fill="{OUTPUT_COLOR}" xml:space="preserve" class="fade-{oid}">'
|
|
225
|
+
f"{_escape(line)}</text>"
|
|
226
|
+
)
|
|
227
|
+
y += LINE_HEIGHT
|
|
228
|
+
|
|
229
|
+
# blank gap between steps
|
|
230
|
+
if i < len(steps) - 1:
|
|
231
|
+
y += LINE_HEIGHT
|
|
232
|
+
time_cursor += STEP_PAUSE
|
|
233
|
+
|
|
234
|
+
title_bar_svg = _render_title_bar(width, title, theme)
|
|
235
|
+
keyframes_css = "\n".join(keyframes_parts)
|
|
236
|
+
elements_joined = "\n".join(elements)
|
|
237
|
+
|
|
238
|
+
svg = f"""<svg xmlns="http://www.w3.org/2000/svg"
|
|
239
|
+
width="{width}" height="{total_height}"
|
|
240
|
+
viewBox="0 0 {width} {total_height}">
|
|
241
|
+
<style>
|
|
242
|
+
{keyframes_css}
|
|
243
|
+
</style>
|
|
244
|
+
<!-- Window frame -->
|
|
245
|
+
<rect x="0" y="0" width="{width}" height="{total_height}"
|
|
246
|
+
rx="8" ry="8" fill="{theme['bg']}" />
|
|
247
|
+
{title_bar_svg}
|
|
248
|
+
<!-- Terminal body -->
|
|
249
|
+
<clipPath id="body-clip">
|
|
250
|
+
<rect x="0" y="{TITLE_BAR_HEIGHT}" width="{width}" height="{body_height}" />
|
|
251
|
+
</clipPath>
|
|
252
|
+
<g clip-path="url(#body-clip)">
|
|
253
|
+
{elements_joined}
|
|
254
|
+
</g>
|
|
255
|
+
</svg>
|
|
256
|
+
"""
|
|
257
|
+
return svg
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""termstage CLI — typer app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from .animator import render_animated_svg
|
|
14
|
+
from .renderer import render_svg
|
|
15
|
+
from .themes import THEMES
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="termstage",
|
|
19
|
+
help="Generate polished terminal demo SVGs from YAML — no recording required.",
|
|
20
|
+
add_completion=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_yaml(path: Path) -> dict:
|
|
25
|
+
try:
|
|
26
|
+
with path.open() as f:
|
|
27
|
+
return yaml.safe_load(f)
|
|
28
|
+
except FileNotFoundError:
|
|
29
|
+
typer.echo(f"Error: file not found: {path}", err=True)
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
except yaml.YAMLError as e:
|
|
32
|
+
typer.echo(f"Error: invalid YAML: {e}", err=True)
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def render(
|
|
38
|
+
input_file: Annotated[Path, typer.Argument(help="Input YAML file")],
|
|
39
|
+
output: Annotated[
|
|
40
|
+
Optional[Path],
|
|
41
|
+
typer.Option("-o", "--output", help="Output SVG file (default: <input>.svg)"),
|
|
42
|
+
] = None,
|
|
43
|
+
animated: Annotated[
|
|
44
|
+
bool, typer.Option("--animated", help="Generate animated CSS typewriter SVG")
|
|
45
|
+
] = False,
|
|
46
|
+
):
|
|
47
|
+
"""Render a terminal demo SVG from a YAML file."""
|
|
48
|
+
config = _load_yaml(input_file)
|
|
49
|
+
|
|
50
|
+
if animated:
|
|
51
|
+
svg_content = render_animated_svg(config)
|
|
52
|
+
else:
|
|
53
|
+
svg_content = render_svg(config)
|
|
54
|
+
|
|
55
|
+
if output is None:
|
|
56
|
+
output = input_file.with_suffix(".svg")
|
|
57
|
+
|
|
58
|
+
output.write_text(svg_content, encoding="utf-8")
|
|
59
|
+
typer.echo(f"✅ Rendered {'animated ' if animated else ''}SVG → {output}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def init(
|
|
64
|
+
output: Annotated[
|
|
65
|
+
Path,
|
|
66
|
+
typer.Argument(help="Output YAML file (default: demo.yaml)"),
|
|
67
|
+
] = Path("demo.yaml"),
|
|
68
|
+
):
|
|
69
|
+
"""Scaffold a demo.yaml in the current directory."""
|
|
70
|
+
if output.exists():
|
|
71
|
+
overwrite = typer.confirm(f"{output} already exists. Overwrite?")
|
|
72
|
+
if not overwrite:
|
|
73
|
+
raise typer.Exit(0)
|
|
74
|
+
|
|
75
|
+
content = """\
|
|
76
|
+
title: "My CLI Demo"
|
|
77
|
+
theme: dark # dark | light | dracula | nord
|
|
78
|
+
prompt: "$ "
|
|
79
|
+
width: 700
|
|
80
|
+
|
|
81
|
+
steps:
|
|
82
|
+
- comment: "# Welcome to termstage — terminal demos without recording"
|
|
83
|
+
|
|
84
|
+
- cmd: "mytool encode 37.7749 -122.4194"
|
|
85
|
+
output: "8928308280fffff"
|
|
86
|
+
|
|
87
|
+
- cmd: "mytool --help"
|
|
88
|
+
output: |
|
|
89
|
+
Usage: mytool [OPTIONS] COMMAND
|
|
90
|
+
|
|
91
|
+
encode Encode lat/lng to H3 cell ID
|
|
92
|
+
decode Decode H3 cell ID to lat/lng
|
|
93
|
+
|
|
94
|
+
Options:
|
|
95
|
+
--help Show this message and exit.
|
|
96
|
+
|
|
97
|
+
- comment: "# Supports batch mode too"
|
|
98
|
+
|
|
99
|
+
- cmd: "mytool encode --batch coords.csv"
|
|
100
|
+
output: "Processed 1000 rows → output.jsonl"
|
|
101
|
+
"""
|
|
102
|
+
output.write_text(content, encoding="utf-8")
|
|
103
|
+
typer.echo(f"✅ Created {output}")
|
|
104
|
+
typer.echo("Run: termstage render demo.yaml")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def themes():
|
|
109
|
+
"""List available themes."""
|
|
110
|
+
typer.echo("Available themes:\n")
|
|
111
|
+
for name, config in THEMES.items():
|
|
112
|
+
bg = config["bg"]
|
|
113
|
+
prompt = config["prompt"]
|
|
114
|
+
typer.echo(f" {name:<10} bg={bg} prompt={prompt}")
|
|
115
|
+
typer.echo(
|
|
116
|
+
"\nUsage: set `theme: <name>` in your YAML file, or pass --theme on the CLI."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command()
|
|
121
|
+
def preview(
|
|
122
|
+
input_file: Annotated[Path, typer.Argument(help="Input YAML file")],
|
|
123
|
+
animated: Annotated[
|
|
124
|
+
bool, typer.Option("--animated", help="Generate animated CSS typewriter SVG")
|
|
125
|
+
] = False,
|
|
126
|
+
):
|
|
127
|
+
"""Render the SVG and open it in the default browser."""
|
|
128
|
+
import tempfile
|
|
129
|
+
|
|
130
|
+
config = _load_yaml(input_file)
|
|
131
|
+
|
|
132
|
+
if animated:
|
|
133
|
+
svg_content = render_animated_svg(config)
|
|
134
|
+
else:
|
|
135
|
+
svg_content = render_svg(config)
|
|
136
|
+
|
|
137
|
+
# Write to a temp file and open it
|
|
138
|
+
suffix = ".svg"
|
|
139
|
+
with tempfile.NamedTemporaryFile(
|
|
140
|
+
delete=False, suffix=suffix, mode="w", encoding="utf-8"
|
|
141
|
+
) as f:
|
|
142
|
+
f.write(svg_content)
|
|
143
|
+
tmp_path = f.name
|
|
144
|
+
|
|
145
|
+
typer.echo(f"Opening preview: {tmp_path}")
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
if sys.platform == "darwin":
|
|
149
|
+
subprocess.run(["open", tmp_path], check=True)
|
|
150
|
+
elif sys.platform == "win32":
|
|
151
|
+
subprocess.run(["start", tmp_path], shell=True, check=True)
|
|
152
|
+
else:
|
|
153
|
+
subprocess.run(["xdg-open", tmp_path], check=True)
|
|
154
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
155
|
+
typer.echo(f"Could not open browser: {e}", err=True)
|
|
156
|
+
typer.echo(f"SVG saved at: {tmp_path}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
app()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Pure Python SVG generation for termstage (static rendering)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .themes import (
|
|
9
|
+
COMMENT_COLOR,
|
|
10
|
+
FONT_FAMILY,
|
|
11
|
+
FONT_SIZE,
|
|
12
|
+
LINE_HEIGHT,
|
|
13
|
+
OUTPUT_COLOR,
|
|
14
|
+
PADDING,
|
|
15
|
+
THEMES,
|
|
16
|
+
TITLE_BAR_HEIGHT,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _escape(text: str) -> str:
|
|
21
|
+
"""HTML-escape text for safe SVG embedding."""
|
|
22
|
+
return html.escape(text, quote=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _count_lines(steps: list[dict]) -> int:
|
|
26
|
+
"""Count total rendered lines across all steps."""
|
|
27
|
+
total = 0
|
|
28
|
+
for i, step in enumerate(steps):
|
|
29
|
+
if "comment" in step:
|
|
30
|
+
total += 1
|
|
31
|
+
elif "cmd" in step:
|
|
32
|
+
total += 1 # prompt + cmd line
|
|
33
|
+
output = step.get("output", "")
|
|
34
|
+
if output:
|
|
35
|
+
total += len(output.rstrip("\n").split("\n"))
|
|
36
|
+
# blank line gap between steps (not after last)
|
|
37
|
+
if i < len(steps) - 1:
|
|
38
|
+
total += 1
|
|
39
|
+
return total
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _render_title_bar(width: int, title: str, theme: dict) -> str:
|
|
43
|
+
"""Render the macOS-style title bar."""
|
|
44
|
+
bar_y = 0
|
|
45
|
+
dots_y = TITLE_BAR_HEIGHT // 2
|
|
46
|
+
|
|
47
|
+
dots_html = ""
|
|
48
|
+
if theme.get("dots", True):
|
|
49
|
+
dot_colors = ["#ff5f57", "#febc2e", "#28c840"]
|
|
50
|
+
dot_x_start = 16
|
|
51
|
+
dot_spacing = 20
|
|
52
|
+
for i, color in enumerate(dot_colors):
|
|
53
|
+
cx = dot_x_start + i * dot_spacing
|
|
54
|
+
dots_html += (
|
|
55
|
+
f'<circle cx="{cx}" cy="{dots_y}" r="6" fill="{color}" />\n '
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
title_x = width // 2
|
|
59
|
+
title_svg = (
|
|
60
|
+
f'<text x="{title_x}" y="{dots_y + 5}" '
|
|
61
|
+
f'font-family={FONT_FAMILY!r} font-size="13" '
|
|
62
|
+
f'fill="{theme["text"]}" opacity="0.7" '
|
|
63
|
+
f'text-anchor="middle" dominant-baseline="middle">'
|
|
64
|
+
f"{_escape(title)}</text>"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return f""" <!-- Title bar -->
|
|
68
|
+
<rect x="0" y="{bar_y}" width="{width}" height="{TITLE_BAR_HEIGHT}"
|
|
69
|
+
rx="8" ry="8" fill="{theme['title_bar']}" />
|
|
70
|
+
<!-- Fake bottom corners for title bar -->
|
|
71
|
+
<rect x="0" y="{TITLE_BAR_HEIGHT - 8}" width="{width}" height="8"
|
|
72
|
+
fill="{theme['title_bar']}" />
|
|
73
|
+
{dots_html}{title_svg}"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_svg(config: dict[str, Any]) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Render a static SVG terminal window from config dict.
|
|
79
|
+
|
|
80
|
+
config keys:
|
|
81
|
+
title, theme, prompt, width, steps
|
|
82
|
+
"""
|
|
83
|
+
title = config.get("title", "termstage demo")
|
|
84
|
+
theme_name = config.get("theme", "dark")
|
|
85
|
+
prompt = config.get("prompt", "$ ")
|
|
86
|
+
width = int(config.get("width", 700))
|
|
87
|
+
steps = config.get("steps", [])
|
|
88
|
+
|
|
89
|
+
theme = THEMES.get(theme_name, THEMES["dark"])
|
|
90
|
+
|
|
91
|
+
# Calculate height
|
|
92
|
+
n_lines = _count_lines(steps)
|
|
93
|
+
body_height = PADDING + n_lines * LINE_HEIGHT + PADDING
|
|
94
|
+
total_height = TITLE_BAR_HEIGHT + body_height
|
|
95
|
+
|
|
96
|
+
lines_svg = []
|
|
97
|
+
y = TITLE_BAR_HEIGHT + PADDING + LINE_HEIGHT # baseline y of first line
|
|
98
|
+
|
|
99
|
+
for i, step in enumerate(steps):
|
|
100
|
+
if "comment" in step:
|
|
101
|
+
comment_text = step["comment"]
|
|
102
|
+
lines_svg.append(
|
|
103
|
+
f' <text x="{PADDING}" y="{y}" '
|
|
104
|
+
f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
|
|
105
|
+
f'fill="{COMMENT_COLOR}" xml:space="preserve">'
|
|
106
|
+
f"{_escape(comment_text)}</text>"
|
|
107
|
+
)
|
|
108
|
+
y += LINE_HEIGHT
|
|
109
|
+
|
|
110
|
+
elif "cmd" in step:
|
|
111
|
+
cmd = step["cmd"]
|
|
112
|
+
output = step.get("output", "")
|
|
113
|
+
|
|
114
|
+
# Render prompt + command on same line using tspan
|
|
115
|
+
lines_svg.append(
|
|
116
|
+
f' <text x="{PADDING}" y="{y}" '
|
|
117
|
+
f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
|
|
118
|
+
f'xml:space="preserve">'
|
|
119
|
+
f'<tspan fill="{theme["prompt"]}">{_escape(prompt)}</tspan>'
|
|
120
|
+
f'<tspan fill="{theme["text"]}">{_escape(cmd)}</tspan>'
|
|
121
|
+
f"</text>"
|
|
122
|
+
)
|
|
123
|
+
y += LINE_HEIGHT
|
|
124
|
+
|
|
125
|
+
if output:
|
|
126
|
+
for line in output.rstrip("\n").split("\n"):
|
|
127
|
+
lines_svg.append(
|
|
128
|
+
f' <text x="{PADDING}" y="{y}" '
|
|
129
|
+
f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
|
|
130
|
+
f'fill="{OUTPUT_COLOR}" xml:space="preserve">'
|
|
131
|
+
f"{_escape(line)}</text>"
|
|
132
|
+
)
|
|
133
|
+
y += LINE_HEIGHT
|
|
134
|
+
|
|
135
|
+
# blank gap between steps
|
|
136
|
+
if i < len(steps) - 1:
|
|
137
|
+
y += LINE_HEIGHT
|
|
138
|
+
|
|
139
|
+
title_bar_svg = _render_title_bar(width, title, theme)
|
|
140
|
+
lines_joined = "\n".join(lines_svg)
|
|
141
|
+
|
|
142
|
+
svg = f"""<svg xmlns="http://www.w3.org/2000/svg"
|
|
143
|
+
width="{width}" height="{total_height}"
|
|
144
|
+
viewBox="0 0 {width} {total_height}">
|
|
145
|
+
<!-- Window frame -->
|
|
146
|
+
<rect x="0" y="0" width="{width}" height="{total_height}"
|
|
147
|
+
rx="8" ry="8" fill="{theme['bg']}" />
|
|
148
|
+
{title_bar_svg}
|
|
149
|
+
<!-- Terminal body -->
|
|
150
|
+
<clipPath id="body-clip">
|
|
151
|
+
<rect x="0" y="{TITLE_BAR_HEIGHT}" width="{width}" height="{body_height}"
|
|
152
|
+
rx="0" ry="0" />
|
|
153
|
+
</clipPath>
|
|
154
|
+
<g clip-path="url(#body-clip)">
|
|
155
|
+
{lines_joined}
|
|
156
|
+
</g>
|
|
157
|
+
</svg>
|
|
158
|
+
"""
|
|
159
|
+
return svg
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Terminal color themes for termstage."""
|
|
2
|
+
|
|
3
|
+
THEMES: dict[str, dict] = {
|
|
4
|
+
"dark": {
|
|
5
|
+
"bg": "#1e1e1e",
|
|
6
|
+
"title_bar": "#323233",
|
|
7
|
+
"text": "#d4d4d4",
|
|
8
|
+
"prompt": "#4ec9b0",
|
|
9
|
+
"dots": True,
|
|
10
|
+
},
|
|
11
|
+
"light": {
|
|
12
|
+
"bg": "#ffffff",
|
|
13
|
+
"title_bar": "#e0e0e0",
|
|
14
|
+
"text": "#333333",
|
|
15
|
+
"prompt": "#007acc",
|
|
16
|
+
"dots": True,
|
|
17
|
+
},
|
|
18
|
+
"dracula": {
|
|
19
|
+
"bg": "#282a36",
|
|
20
|
+
"title_bar": "#44475a",
|
|
21
|
+
"text": "#f8f8f2",
|
|
22
|
+
"prompt": "#50fa7b",
|
|
23
|
+
"dots": True,
|
|
24
|
+
},
|
|
25
|
+
"nord": {
|
|
26
|
+
"bg": "#2e3440",
|
|
27
|
+
"title_bar": "#3b4252",
|
|
28
|
+
"text": "#eceff4",
|
|
29
|
+
"prompt": "#88c0d0",
|
|
30
|
+
"dots": True,
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
COMMENT_COLOR = "#6a9955"
|
|
35
|
+
OUTPUT_COLOR = "#aaaaaa"
|
|
36
|
+
FONT_FAMILY = "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace"
|
|
37
|
+
FONT_SIZE = 14
|
|
38
|
+
LINE_HEIGHT = 22
|
|
39
|
+
TITLE_BAR_HEIGHT = 38
|
|
40
|
+
PADDING = 20
|