scriptcast 0.1.0__py3-none-any.whl
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.
- scriptcast/__init__.py +0 -0
- scriptcast/__main__.py +371 -0
- scriptcast/assets/__init__.py +0 -0
- scriptcast/assets/fonts/DMSans-Regular.ttf +0 -0
- scriptcast/assets/fonts/Pacifico.ttf +0 -0
- scriptcast/assets/themes/aurora.sh +20 -0
- scriptcast/assets/themes/dark.sh +19 -0
- scriptcast/assets/themes/light.sh +19 -0
- scriptcast/config.py +199 -0
- scriptcast/directives.py +444 -0
- scriptcast/export.py +595 -0
- scriptcast/generator.py +265 -0
- scriptcast/recorder.py +212 -0
- scriptcast/shell/__init__.py +20 -0
- scriptcast/shell/adapter.py +13 -0
- scriptcast/shell/bash.py +11 -0
- scriptcast/shell/zsh.py +11 -0
- scriptcast-0.1.0.dist-info/METADATA +21 -0
- scriptcast-0.1.0.dist-info/RECORD +33 -0
- scriptcast-0.1.0.dist-info/WHEEL +5 -0
- scriptcast-0.1.0.dist-info/entry_points.txt +2 -0
- scriptcast-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_cli.py +304 -0
- tests/test_config.py +400 -0
- tests/test_directives.py +606 -0
- tests/test_export.py +986 -0
- tests/test_generator.py +434 -0
- tests/test_integration.py +97 -0
- tests/test_recorder.py +462 -0
- tests/test_registry.py +57 -0
- tests/test_shell.py +34 -0
- tests/test_theme.py +204 -0
scriptcast/__init__.py
ADDED
|
File without changes
|
scriptcast/__main__.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# scriptcast/__main__.py
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import urllib.request
|
|
11
|
+
import zipfile as _zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from .config import ScriptcastConfig, extract_config_prefix
|
|
18
|
+
from .export import AggNotFoundError, apply_scriptcast_watermark, generate_export
|
|
19
|
+
from .generator import build_config_from_sc_text, generate_from_sc
|
|
20
|
+
from .recorder import record as do_record
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default_shell() -> str:
|
|
26
|
+
return os.environ.get("SHELL", "bash")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_theme(theme: str) -> Path:
|
|
30
|
+
_BUILTIN_THEMES_DIR = Path(__file__).parent / "assets" / "themes"
|
|
31
|
+
builtin = _BUILTIN_THEMES_DIR / f"{theme}.sh"
|
|
32
|
+
theme_path = builtin if builtin.exists() else Path(theme)
|
|
33
|
+
if not theme_path.exists():
|
|
34
|
+
raise click.ClickException(f"Theme not found: {theme!r}")
|
|
35
|
+
return theme_path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_config(
|
|
39
|
+
script_path: Path | None,
|
|
40
|
+
theme_path: Path | None,
|
|
41
|
+
directive_prefix: str,
|
|
42
|
+
trace_prefix: str,
|
|
43
|
+
shell: str,
|
|
44
|
+
) -> ScriptcastConfig:
|
|
45
|
+
"""Build a fully resolved ScriptcastConfig before recording starts.
|
|
46
|
+
|
|
47
|
+
Layer order: defaults → theme prefix → script prefix.
|
|
48
|
+
Theme and script prefixes are concatenated into a single tmp .sh,
|
|
49
|
+
recorded (fast — all no-ops/assignments), and fed into
|
|
50
|
+
build_config_from_sc_text so shell variable expansion is handled
|
|
51
|
+
correctly (e.g. : SC set prompt "${GREEN} > ${RESET}").
|
|
52
|
+
|
|
53
|
+
For .sc inputs, also applies the .sc's pre-scene set directives on top.
|
|
54
|
+
"""
|
|
55
|
+
base = ScriptcastConfig(
|
|
56
|
+
directive_prefix=directive_prefix,
|
|
57
|
+
trace_prefix=trace_prefix,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
prefix_parts: list[str] = []
|
|
61
|
+
if theme_path is not None:
|
|
62
|
+
prefix_parts.append(extract_config_prefix(theme_path.read_text(), directive_prefix))
|
|
63
|
+
if script_path is not None and script_path.suffix.lower() == ".sh":
|
|
64
|
+
prefix_parts.append(extract_config_prefix(script_path.read_text(), directive_prefix))
|
|
65
|
+
|
|
66
|
+
if prefix_parts:
|
|
67
|
+
combined = "#!/bin/sh\n" + "\n".join(filter(None, prefix_parts))
|
|
68
|
+
tmp_fd, tmp_sh_str = tempfile.mkstemp(suffix=".sh")
|
|
69
|
+
os.close(tmp_fd)
|
|
70
|
+
tmp_sh = Path(tmp_sh_str)
|
|
71
|
+
tmp_sc = tmp_sh.with_suffix(".sc")
|
|
72
|
+
try:
|
|
73
|
+
tmp_sh.write_text(combined)
|
|
74
|
+
tmp_sh.chmod(0o755)
|
|
75
|
+
do_record(tmp_sh, tmp_sc, base, shell)
|
|
76
|
+
if tmp_sc.exists():
|
|
77
|
+
base = build_config_from_sc_text(tmp_sc.read_text())
|
|
78
|
+
base.directive_prefix = directive_prefix
|
|
79
|
+
base.trace_prefix = trace_prefix
|
|
80
|
+
finally:
|
|
81
|
+
tmp_sh.unlink(missing_ok=True)
|
|
82
|
+
tmp_sc.unlink(missing_ok=True)
|
|
83
|
+
|
|
84
|
+
if script_path is not None and script_path.suffix.lower() == ".sc":
|
|
85
|
+
base = build_config_from_sc_text(script_path.read_text(), base=base)
|
|
86
|
+
|
|
87
|
+
return base
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _ScriptOrSubcommandGroup(click.Group):
|
|
91
|
+
"""Click group that treats an unknown first positional as an input file path.
|
|
92
|
+
|
|
93
|
+
Overrides ``parse_args`` so that when the first remaining token after
|
|
94
|
+
option parsing is not a registered subcommand name, the group delegates to
|
|
95
|
+
``click.Command.parse_args`` instead of ``click.Group.parse_args``. This
|
|
96
|
+
leaves the token in ``ctx.args`` (never in ``ctx._protected_args``) and
|
|
97
|
+
allows ``invoke_without_command=True`` to route straight to the callback.
|
|
98
|
+
No private Click APIs are accessed.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
102
|
+
# Use click.Command.parse_args (grandparent) to parse options, which
|
|
103
|
+
# puts remaining tokens into ctx.args without touching _protected_args.
|
|
104
|
+
rest = click.Command.parse_args(self, ctx, args)
|
|
105
|
+
if rest and rest[0] not in self.commands:
|
|
106
|
+
# First remaining token is not a subcommand — treat it as an input
|
|
107
|
+
# file path positional. Keep everything in ctx.args so that
|
|
108
|
+
# Group.invoke sees empty _protected_args and routes via
|
|
109
|
+
# invoke_without_command to our callback.
|
|
110
|
+
return rest
|
|
111
|
+
# First remaining token IS a known subcommand (or there are no tokens).
|
|
112
|
+
# Delegate to the standard Group.parse_args to set up routing correctly.
|
|
113
|
+
# We reset ctx first since Command.parse_args already wrote to ctx.args.
|
|
114
|
+
# ctx.params is already populated from the first pass above.
|
|
115
|
+
# click.Group.parse_args calls Command.parse_args internally, but
|
|
116
|
+
# Parameter.handle_parse_result skips keys already present in ctx.params,
|
|
117
|
+
# so options are not double-applied. This holds as of Click 8.x.
|
|
118
|
+
ctx.args = args
|
|
119
|
+
return click.Group.parse_args(self, ctx, args)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@click.group(
|
|
123
|
+
cls=_ScriptOrSubcommandGroup,
|
|
124
|
+
invoke_without_command=True,
|
|
125
|
+
context_settings={"allow_extra_args": True, "allow_interspersed_args": False},
|
|
126
|
+
)
|
|
127
|
+
@click.option("--output-dir", default=None, type=click.Path())
|
|
128
|
+
@click.option("--no-export", is_flag=True, default=False,
|
|
129
|
+
help="Stop after generating .cast file(s); do not export to image.")
|
|
130
|
+
@click.option("--theme", default=None,
|
|
131
|
+
help="Visual theme: built-in name (e.g. 'aurora') or path to a .sh theme file.")
|
|
132
|
+
@click.option(
|
|
133
|
+
"--format", "output_format",
|
|
134
|
+
default="png",
|
|
135
|
+
type=click.Choice(["gif", "png"]),
|
|
136
|
+
show_default=True,
|
|
137
|
+
help="Export format.",
|
|
138
|
+
)
|
|
139
|
+
@click.option("--directive-prefix", default="SC", show_default=True)
|
|
140
|
+
@click.option("--trace-prefix", default="+", show_default=True)
|
|
141
|
+
@click.option("--shell", default=None)
|
|
142
|
+
@click.option("--split-scenes/--no-split-scenes", default=False)
|
|
143
|
+
@click.option("--verbose", "-v", is_flag=True, default=False, help="Enable debug logging.")
|
|
144
|
+
@click.option("--xtrace-log", is_flag=True, default=False,
|
|
145
|
+
help="Save raw xtrace capture to <stem>.xtrace (only valid for .sh input).")
|
|
146
|
+
@click.pass_context
|
|
147
|
+
def cli(
|
|
148
|
+
ctx: click.Context,
|
|
149
|
+
output_dir: str | None,
|
|
150
|
+
no_export: bool,
|
|
151
|
+
theme: str | None,
|
|
152
|
+
output_format: str,
|
|
153
|
+
directive_prefix: str,
|
|
154
|
+
trace_prefix: str,
|
|
155
|
+
shell: str | None,
|
|
156
|
+
split_scenes: bool,
|
|
157
|
+
verbose: bool,
|
|
158
|
+
xtrace_log: bool,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Generate terminal demos from shell scripts, .sc files, or .cast files.
|
|
161
|
+
|
|
162
|
+
The input file type determines the start stage:
|
|
163
|
+
|
|
164
|
+
\b
|
|
165
|
+
.sh record → generate → export (all stages)
|
|
166
|
+
.sc generate → export
|
|
167
|
+
.cast export
|
|
168
|
+
|
|
169
|
+
Use --no-export to stop after the generate stage (.sh and .sc only).
|
|
170
|
+
|
|
171
|
+
Options must be placed before the input file path:
|
|
172
|
+
|
|
173
|
+
scriptcast [OPTIONS] INPUT
|
|
174
|
+
"""
|
|
175
|
+
log_level = logging.DEBUG if verbose else logging.INFO
|
|
176
|
+
log_format = "%(name)s %(levelname)s %(message)s" if verbose else "%(message)s"
|
|
177
|
+
logging.basicConfig(level=log_level, format=log_format)
|
|
178
|
+
|
|
179
|
+
if ctx.invoked_subcommand is not None:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
if not ctx.args:
|
|
183
|
+
click.echo(ctx.get_help())
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if len(ctx.args) > 1:
|
|
187
|
+
raise click.UsageError(
|
|
188
|
+
f"Unexpected arguments after input path: {ctx.args[1:]}. "
|
|
189
|
+
"All options must be placed before the input path."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
in_path = Path(ctx.args[0])
|
|
193
|
+
if not in_path.exists():
|
|
194
|
+
raise click.ClickException(f"File not found: {ctx.args[0]}")
|
|
195
|
+
|
|
196
|
+
suffix = in_path.suffix.lower()
|
|
197
|
+
if suffix not in (".sh", ".sc", ".cast"):
|
|
198
|
+
raise click.UsageError(
|
|
199
|
+
f"Unsupported file type '{suffix}'. Expected .sh, .sc, or .cast."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if no_export and suffix == ".cast":
|
|
203
|
+
raise click.UsageError(
|
|
204
|
+
"--no-export requires a .sh or .sc input (a .cast file is already at the export stage)."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if xtrace_log and suffix != ".sh":
|
|
208
|
+
raise click.UsageError(
|
|
209
|
+
"--xtrace-log requires a .sh input (xtrace is captured during recording)."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
out_dir = Path(output_dir) if output_dir else in_path.parent
|
|
213
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
resolved_shell = shell or _default_shell()
|
|
215
|
+
theme_path = _resolve_theme(theme) if theme else None
|
|
216
|
+
|
|
217
|
+
config = build_config(
|
|
218
|
+
script_path=in_path if suffix != ".cast" else None,
|
|
219
|
+
theme_path=theme_path,
|
|
220
|
+
directive_prefix=directive_prefix,
|
|
221
|
+
trace_prefix=trace_prefix,
|
|
222
|
+
shell=resolved_shell,
|
|
223
|
+
)
|
|
224
|
+
logger.debug(
|
|
225
|
+
"Config: width=%d height=%d type_speed=%d prompt=%r",
|
|
226
|
+
config.width, config.height, config.type_speed, config.prompt,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Stage 1: record (only for .sh input)
|
|
230
|
+
sc_path: Path | None = None
|
|
231
|
+
if suffix == ".sh":
|
|
232
|
+
sc_path = out_dir / in_path.with_suffix(".sc").name
|
|
233
|
+
logger.info("Recording %s ...", in_path.name)
|
|
234
|
+
do_record(in_path, sc_path, config, resolved_shell, xtrace_log=xtrace_log)
|
|
235
|
+
elif suffix == ".sc":
|
|
236
|
+
sc_path = in_path
|
|
237
|
+
|
|
238
|
+
# Stage 2: generate .cast(s)
|
|
239
|
+
if suffix == ".cast":
|
|
240
|
+
cast_paths = [in_path]
|
|
241
|
+
else:
|
|
242
|
+
assert sc_path is not None
|
|
243
|
+
logger.info("Generating .cast ...")
|
|
244
|
+
cast_paths = generate_from_sc(
|
|
245
|
+
sc_path,
|
|
246
|
+
out_dir,
|
|
247
|
+
output_stem=in_path.stem,
|
|
248
|
+
split_scenes=split_scenes,
|
|
249
|
+
base=config,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if no_export:
|
|
253
|
+
for p in cast_paths:
|
|
254
|
+
logger.info("Generated: %s", p)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
# Stage 3: export
|
|
258
|
+
logger.info("Exporting to %s ...", output_format.upper())
|
|
259
|
+
for cast_path in cast_paths:
|
|
260
|
+
_bar: list[Any] = [None]
|
|
261
|
+
|
|
262
|
+
def on_frame(current: int, total: int) -> None:
|
|
263
|
+
if _bar[0] is None:
|
|
264
|
+
pb = click.progressbar(
|
|
265
|
+
length=total,
|
|
266
|
+
label=f"{output_format.upper()} ",
|
|
267
|
+
width=0,
|
|
268
|
+
show_eta=True,
|
|
269
|
+
file=sys.stderr,
|
|
270
|
+
)
|
|
271
|
+
pb.__enter__()
|
|
272
|
+
_bar[0] = pb
|
|
273
|
+
_bar[0].update(1)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
export_path = generate_export(
|
|
277
|
+
cast_path,
|
|
278
|
+
config.theme if config.theme.frame else None,
|
|
279
|
+
format=output_format,
|
|
280
|
+
on_frame=on_frame,
|
|
281
|
+
)
|
|
282
|
+
if not config.theme.frame and config.theme.scriptcast_watermark:
|
|
283
|
+
apply_scriptcast_watermark(export_path, config.theme)
|
|
284
|
+
except (AggNotFoundError, RuntimeError) as e:
|
|
285
|
+
raise click.ClickException(str(e))
|
|
286
|
+
finally:
|
|
287
|
+
if _bar[0] is not None:
|
|
288
|
+
_bar[0].__exit__(None, None, None)
|
|
289
|
+
logger.info("Generated: %s", export_path)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@cli.command()
|
|
293
|
+
@click.option(
|
|
294
|
+
"--prefix",
|
|
295
|
+
default=str(Path.home() / ".local" / "bin"),
|
|
296
|
+
show_default=True,
|
|
297
|
+
help="Installation directory for agg and fonts.",
|
|
298
|
+
)
|
|
299
|
+
def install(prefix: str) -> None:
|
|
300
|
+
"""Install the agg binary and JetBrains Mono fonts."""
|
|
301
|
+
prefix_path = Path(prefix).expanduser()
|
|
302
|
+
prefix_path.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
fonts_dir = prefix_path / "fonts"
|
|
304
|
+
fonts_dir.mkdir(exist_ok=True)
|
|
305
|
+
|
|
306
|
+
machine = platform.machine().lower()
|
|
307
|
+
if sys.platform == "linux":
|
|
308
|
+
if machine == "x86_64":
|
|
309
|
+
agg_asset = "agg-x86_64-unknown-linux-gnu"
|
|
310
|
+
elif machine in ("aarch64", "arm64"):
|
|
311
|
+
agg_asset = "agg-aarch64-unknown-linux-gnu"
|
|
312
|
+
else:
|
|
313
|
+
raise click.ClickException(f"Unsupported Linux architecture: {machine}")
|
|
314
|
+
elif sys.platform == "darwin":
|
|
315
|
+
if machine in ("arm64", "aarch64"):
|
|
316
|
+
agg_asset = "agg-aarch64-apple-darwin"
|
|
317
|
+
else:
|
|
318
|
+
agg_asset = "agg-x86_64-apple-darwin"
|
|
319
|
+
else:
|
|
320
|
+
raise click.ClickException(f"Unsupported OS: {sys.platform}")
|
|
321
|
+
|
|
322
|
+
agg_url = f"https://github.com/asciinema/agg/releases/latest/download/{agg_asset}"
|
|
323
|
+
|
|
324
|
+
with urllib.request.urlopen(
|
|
325
|
+
"https://api.github.com/repos/JetBrains/JetBrainsMono/releases/latest"
|
|
326
|
+
) as _resp:
|
|
327
|
+
_release = json.loads(_resp.read())
|
|
328
|
+
font_url = next(
|
|
329
|
+
a["browser_download_url"]
|
|
330
|
+
for a in _release["assets"]
|
|
331
|
+
if a["name"].endswith(".zip") and "JetBrainsMono" in a["name"]
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
agg_bin = prefix_path / ".agg-real"
|
|
335
|
+
click.echo("Downloading agg ...")
|
|
336
|
+
urllib.request.urlretrieve(agg_url, agg_bin)
|
|
337
|
+
agg_bin.chmod(0o755)
|
|
338
|
+
|
|
339
|
+
agg_wrapper = prefix_path / "agg"
|
|
340
|
+
agg_wrapper.write_text(
|
|
341
|
+
'#!/bin/sh\n'
|
|
342
|
+
'exec "$(dirname "$0")/.agg-real"'
|
|
343
|
+
' --font-dir "$(dirname "$0")/fonts"'
|
|
344
|
+
' --font-family "JetBrains Mono"'
|
|
345
|
+
' "$@"\n'
|
|
346
|
+
)
|
|
347
|
+
agg_wrapper.chmod(0o755)
|
|
348
|
+
|
|
349
|
+
click.echo("Downloading JetBrains Mono fonts ...")
|
|
350
|
+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tf:
|
|
351
|
+
zip_path = Path(tf.name)
|
|
352
|
+
try:
|
|
353
|
+
urllib.request.urlretrieve(font_url, zip_path)
|
|
354
|
+
with _zipfile.ZipFile(zip_path) as zf:
|
|
355
|
+
for name in zf.namelist():
|
|
356
|
+
if name.startswith("fonts/ttf/") and name.endswith(".ttf"):
|
|
357
|
+
(fonts_dir / Path(name).name).write_bytes(zf.read(name))
|
|
358
|
+
finally:
|
|
359
|
+
zip_path.unlink(missing_ok=True)
|
|
360
|
+
|
|
361
|
+
click.echo(f"Installed to {prefix_path}")
|
|
362
|
+
if shutil.which("agg") is None:
|
|
363
|
+
click.echo(f"Warning: {prefix_path} is not in PATH. Add it to use agg directly:", err=True)
|
|
364
|
+
click.echo(f' export PATH="{prefix_path}:$PATH"', err=True)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
if __name__ == "__main__":
|
|
368
|
+
if len(sys.argv) >= 2 and sys.argv[1].startswith("--") and " " in sys.argv[1]:
|
|
369
|
+
extra = shlex.split(sys.argv[1])
|
|
370
|
+
sys.argv = [sys.argv[0]] + extra + sys.argv[2:]
|
|
371
|
+
cli()
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# scriptcast built-in theme: aurora
|
|
2
|
+
: SC set terminal-theme dark
|
|
3
|
+
: SC set theme-frame true
|
|
4
|
+
: SC set theme-frame-bar true
|
|
5
|
+
: SC set theme-frame-bar-title Terminal
|
|
6
|
+
: SC set theme-frame-bar-color 252535
|
|
7
|
+
: SC set theme-frame-bar-buttons true
|
|
8
|
+
: SC set theme-scriptcast-watermark true
|
|
9
|
+
: SC set theme-background 1e1b4b,0d3b66
|
|
10
|
+
: SC set theme-margin 82 82 120
|
|
11
|
+
: SC set theme-padding 14
|
|
12
|
+
: SC set theme-radius 12
|
|
13
|
+
: SC set theme-border-color ffffff30
|
|
14
|
+
: SC set theme-border-width 1
|
|
15
|
+
: SC set theme-shadow true
|
|
16
|
+
: SC set theme-shadow-color 0000004d
|
|
17
|
+
: SC set theme-shadow-radius 20
|
|
18
|
+
: SC set theme-shadow-offset-y 21
|
|
19
|
+
: SC set theme-shadow-offset-x 0
|
|
20
|
+
: SC set prompt "\x1b[92m> \x1b[0m"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# scriptcast built-in theme: dark
|
|
2
|
+
: SC set terminal-theme dark
|
|
3
|
+
: SC set theme-frame true
|
|
4
|
+
: SC set theme-frame-bar true
|
|
5
|
+
: SC set theme-frame-bar-title Terminal
|
|
6
|
+
: SC set theme-frame-bar-color ffffff30
|
|
7
|
+
: SC set theme-frame-bar-buttons true
|
|
8
|
+
: SC set theme-scriptcast-watermark true
|
|
9
|
+
: SC set theme-background 1a1a2e,16213e
|
|
10
|
+
: SC set theme-margin 82 82 120
|
|
11
|
+
: SC set theme-padding 14
|
|
12
|
+
: SC set theme-radius 12
|
|
13
|
+
: SC set theme-border-color ffffff30
|
|
14
|
+
: SC set theme-border-width 1
|
|
15
|
+
: SC set theme-shadow true
|
|
16
|
+
: SC set theme-shadow-color 0000004d
|
|
17
|
+
: SC set theme-shadow-radius 20
|
|
18
|
+
: SC set theme-shadow-offset-y 21
|
|
19
|
+
: SC set theme-shadow-offset-x 0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# scriptcast built-in theme: light
|
|
2
|
+
: SC set terminal-theme light
|
|
3
|
+
: SC set theme-frame true
|
|
4
|
+
: SC set theme-frame-bar true
|
|
5
|
+
: SC set theme-frame-bar-title Terminal
|
|
6
|
+
: SC set theme-frame-bar-color e8ecf8
|
|
7
|
+
: SC set theme-frame-bar-buttons true
|
|
8
|
+
: SC set theme-scriptcast-watermark true
|
|
9
|
+
: SC set theme-background f0f4ff,e2e8f8
|
|
10
|
+
: SC set theme-border-color 00000020
|
|
11
|
+
: SC set theme-border-width 1
|
|
12
|
+
: SC set theme-margin 82 82 120
|
|
13
|
+
: SC set theme-padding 14
|
|
14
|
+
: SC set theme-radius 12
|
|
15
|
+
: SC set theme-shadow true
|
|
16
|
+
: SC set theme-shadow-color 00000030
|
|
17
|
+
: SC set theme-shadow-radius 20
|
|
18
|
+
: SC set theme-shadow-offset-y 21
|
|
19
|
+
: SC set theme-shadow-offset-x 0
|
scriptcast/config.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# scriptcast/config.py
|
|
2
|
+
import copy
|
|
3
|
+
import re as _re
|
|
4
|
+
import typing
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
_INT_KEYS = {
|
|
8
|
+
"type_speed", "cmd_wait", "input_wait", "exit_wait",
|
|
9
|
+
"width", "height", "enter_wait", "word_speed", "cr_delay",
|
|
10
|
+
}
|
|
11
|
+
_STR_KEYS = {"terminal_theme", "prompt", "directive_prefix", "trace_prefix"}
|
|
12
|
+
_BOOL_KEYS = {"split_scenes"}
|
|
13
|
+
|
|
14
|
+
_INT_THEME_PROPS = {
|
|
15
|
+
"radius", "border-width",
|
|
16
|
+
"shadow-radius", "shadow-offset-y", "shadow-offset-x", "watermark-size",
|
|
17
|
+
"margin-top", "margin-right", "margin-bottom", "margin-left",
|
|
18
|
+
"padding-top", "padding-right", "padding-bottom", "padding-left",
|
|
19
|
+
}
|
|
20
|
+
_BOOL_THEME_PROPS = {"shadow", "frame-bar", "frame-bar-buttons", "frame", "scriptcast-watermark"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_css_shorthand(value: str) -> tuple[int, int, int, int]:
|
|
24
|
+
"""Parse 1–4 space-separated ints using CSS shorthand rules (top, right, bottom, left)."""
|
|
25
|
+
vals = [int(p) for p in value.split()]
|
|
26
|
+
match len(vals):
|
|
27
|
+
case 1:
|
|
28
|
+
return (vals[0], vals[0], vals[0], vals[0])
|
|
29
|
+
case 2:
|
|
30
|
+
return (vals[0], vals[1], vals[0], vals[1])
|
|
31
|
+
case 3:
|
|
32
|
+
return (vals[0], vals[1], vals[2], vals[1])
|
|
33
|
+
case 4:
|
|
34
|
+
return (vals[0], vals[1], vals[2], vals[3])
|
|
35
|
+
case _:
|
|
36
|
+
raise ValueError(f"CSS shorthand expects 1-4 values, got {len(vals)}: {value!r}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ThemeConfig:
|
|
41
|
+
# Inner padding — individual sides
|
|
42
|
+
padding_top: int = 14
|
|
43
|
+
padding_right: int = 14
|
|
44
|
+
padding_bottom: int = 14
|
|
45
|
+
padding_left: int = 14
|
|
46
|
+
|
|
47
|
+
# Window appearance
|
|
48
|
+
radius: int = 12
|
|
49
|
+
border_color: str = "ffffff30" # RGBA hex, no # prefix
|
|
50
|
+
border_width: int = 1
|
|
51
|
+
|
|
52
|
+
# Outer background (None = no background)
|
|
53
|
+
background: str | None = "1e1b4b,0d3b66" # aurora gradient
|
|
54
|
+
|
|
55
|
+
# Outer margins — individual sides (None = auto: 0 if no bg, 82 if bg set)
|
|
56
|
+
margin_top: int | None = None
|
|
57
|
+
margin_right: int | None = None
|
|
58
|
+
margin_bottom: int | None = None
|
|
59
|
+
margin_left: int | None = None
|
|
60
|
+
|
|
61
|
+
# Drop shadow
|
|
62
|
+
shadow: bool = True
|
|
63
|
+
shadow_color: str = "0000004d" # RGBA hex, no # prefix
|
|
64
|
+
shadow_radius: int = 20
|
|
65
|
+
shadow_offset_y: int = 21
|
|
66
|
+
shadow_offset_x: int = 0
|
|
67
|
+
|
|
68
|
+
# Title bar
|
|
69
|
+
frame_bar: bool = True
|
|
70
|
+
frame_bar_title: str = "Terminal"
|
|
71
|
+
frame_bar_color: str = "252535"
|
|
72
|
+
frame_bar_buttons: bool = True
|
|
73
|
+
|
|
74
|
+
# Watermark (opt-in)
|
|
75
|
+
watermark: str | None = None
|
|
76
|
+
watermark_color: str = "ffffff"
|
|
77
|
+
watermark_size: int | None = None
|
|
78
|
+
|
|
79
|
+
# Frame style
|
|
80
|
+
frame: bool = True
|
|
81
|
+
|
|
82
|
+
# Scriptcast brand watermark (opt-out)
|
|
83
|
+
scriptcast_watermark: bool = True
|
|
84
|
+
|
|
85
|
+
def apply(self, key: str, value: str) -> None:
|
|
86
|
+
"""Apply a single theme key-value pair. key is dash-form without 'theme-' prefix."""
|
|
87
|
+
if key == "margin":
|
|
88
|
+
t, r, b, left = _parse_css_shorthand(value)
|
|
89
|
+
self.margin_top, self.margin_right = t, r
|
|
90
|
+
self.margin_bottom, self.margin_left = b, left
|
|
91
|
+
elif key == "padding":
|
|
92
|
+
t, r, b, left = _parse_css_shorthand(value)
|
|
93
|
+
self.padding_top, self.padding_right = t, r
|
|
94
|
+
self.padding_bottom, self.padding_left = b, left
|
|
95
|
+
elif key in _INT_THEME_PROPS:
|
|
96
|
+
setattr(self, key.replace("-", "_"), int(value))
|
|
97
|
+
elif key in _BOOL_THEME_PROPS:
|
|
98
|
+
setattr(self, key.replace("-", "_"), value.lower() in ("1", "true", "yes"))
|
|
99
|
+
else:
|
|
100
|
+
attr = key.replace("-", "_")
|
|
101
|
+
if hasattr(self, attr):
|
|
102
|
+
hints = typing.get_type_hints(type(self))
|
|
103
|
+
hint = hints.get(attr)
|
|
104
|
+
is_nullable = hint is not None and type(None) in typing.get_args(hint)
|
|
105
|
+
setattr(self, attr, None if (value.lower() == "none" and is_nullable) else value)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class ScriptcastConfig:
|
|
110
|
+
type_speed: int = 40 # ms per character when typing commands
|
|
111
|
+
cmd_wait: int = 80 # ms after typing a command, before output appears
|
|
112
|
+
input_wait: int = 80 # ms to pause before typing an InputLine response
|
|
113
|
+
exit_wait: int = 120 # ms after last output line of a command
|
|
114
|
+
enter_wait: int = 80 # ms to pause at scene start, after clear and optional title
|
|
115
|
+
word_speed: int | None = None # ms extra pause after each space; None = same as type_speed
|
|
116
|
+
cr_delay: int = 0 # ms to wait between \r-split segments of an out event
|
|
117
|
+
width: int = 100
|
|
118
|
+
height: int = 28
|
|
119
|
+
terminal_theme: str = "dark" # was: theme
|
|
120
|
+
prompt: str = "$ "
|
|
121
|
+
directive_prefix: str = "SC"
|
|
122
|
+
trace_prefix: str = "+"
|
|
123
|
+
split_scenes: bool = False
|
|
124
|
+
theme: ThemeConfig = field(default_factory=ThemeConfig)
|
|
125
|
+
|
|
126
|
+
def apply(self, name: str, args: list[str]) -> None:
|
|
127
|
+
"""Apply an SC directive. Only 'set' directives mutate config; others are ignored."""
|
|
128
|
+
if name != "set" or len(args) < 2:
|
|
129
|
+
return
|
|
130
|
+
raw_key = args[0] # original dash-form key
|
|
131
|
+
key = raw_key.replace("-", "_") # underscore-normalised
|
|
132
|
+
value = " ".join(args[1:]) # join multi-word values (e.g. "14 0 0 0")
|
|
133
|
+
if raw_key.startswith("theme-"):
|
|
134
|
+
self.theme.apply(raw_key[6:], value) # strip "theme-" prefix, pass dash form
|
|
135
|
+
elif key in _INT_KEYS:
|
|
136
|
+
setattr(self, key, int(value))
|
|
137
|
+
elif key in _STR_KEYS:
|
|
138
|
+
setattr(self, key, value)
|
|
139
|
+
elif key in _BOOL_KEYS:
|
|
140
|
+
setattr(self, key, value.lower() in ("1", "true", "yes"))
|
|
141
|
+
|
|
142
|
+
def copy(self) -> "ScriptcastConfig":
|
|
143
|
+
return copy.deepcopy(self)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def effective_word_pause_s(self) -> float:
|
|
147
|
+
"""Pause to insert after each space when typing, in seconds."""
|
|
148
|
+
ms = self.word_speed if self.word_speed is not None else self.type_speed
|
|
149
|
+
return ms / 1000.0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def extract_config_prefix(sh_text: str, directive_prefix: str = "SC") -> str:
|
|
153
|
+
"""Return the config-safe prefix of a .sh script.
|
|
154
|
+
|
|
155
|
+
Collects lines from the top of the script until the first non-config-safe
|
|
156
|
+
line is encountered. Config-safe lines are:
|
|
157
|
+
- Blank lines and comments
|
|
158
|
+
- Variable assignments (FOO=..., export FOO=..., readonly FOO=...)
|
|
159
|
+
- `: <prefix> set ...` directives
|
|
160
|
+
- `: <prefix> record pause` ... `: <prefix> record resume` blocks
|
|
161
|
+
|
|
162
|
+
Stops at (and excludes) the first line that is any other SC directive
|
|
163
|
+
(scene, type, expect, filter, sleep, mock, ...) or any real shell command.
|
|
164
|
+
"""
|
|
165
|
+
dp = _re.escape(directive_prefix)
|
|
166
|
+
_sc_set = _re.compile(rf"^\s*:\s+{dp}\s+set\b")
|
|
167
|
+
_sc_rec_pause = _re.compile(rf"^\s*:\s+{dp}\s+record\s+pause\b")
|
|
168
|
+
_sc_rec_resume = _re.compile(rf"^\s*:\s+{dp}\s+record\s+resume\b")
|
|
169
|
+
_sc_any = _re.compile(rf"^\s*:\s+{dp}\s+\w+")
|
|
170
|
+
_var_assign = _re.compile(r"^(export\s+|readonly\s+)?\w+=")
|
|
171
|
+
_blank_or_comment = _re.compile(r"^\s*(#.*)?$")
|
|
172
|
+
|
|
173
|
+
result: list[str] = []
|
|
174
|
+
in_pause = False
|
|
175
|
+
|
|
176
|
+
for raw_line in sh_text.splitlines(keepends=True):
|
|
177
|
+
line = raw_line.rstrip("\n").rstrip("\r")
|
|
178
|
+
|
|
179
|
+
if in_pause:
|
|
180
|
+
result.append(raw_line)
|
|
181
|
+
if _sc_rec_resume.match(line):
|
|
182
|
+
in_pause = False
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if _blank_or_comment.match(line):
|
|
186
|
+
result.append(raw_line)
|
|
187
|
+
elif _sc_rec_pause.match(line):
|
|
188
|
+
result.append(raw_line)
|
|
189
|
+
in_pause = True
|
|
190
|
+
elif _sc_set.match(line):
|
|
191
|
+
result.append(raw_line)
|
|
192
|
+
elif _sc_any.match(line):
|
|
193
|
+
break # non-config-safe SC directive
|
|
194
|
+
elif _var_assign.match(line):
|
|
195
|
+
result.append(raw_line)
|
|
196
|
+
else:
|
|
197
|
+
break # real shell command
|
|
198
|
+
|
|
199
|
+
return "".join(result)
|