inkflow 0.1.0.dev0__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.
Files changed (48) hide show
  1. inkflow/__init__.py +31 -0
  2. inkflow/cli.py +266 -0
  3. inkflow/content.py +227 -0
  4. inkflow/export.py +123 -0
  5. inkflow/layout.py +279 -0
  6. inkflow/manifest.py +145 -0
  7. inkflow/markdown.py +158 -0
  8. inkflow/ns.py +9 -0
  9. inkflow/pdf.html +19 -0
  10. inkflow/pipeline.py +244 -0
  11. inkflow/presenter.css +196 -0
  12. inkflow/presenter.html +50 -0
  13. inkflow/presenter.js +400 -0
  14. inkflow/server.py +380 -0
  15. inkflow/theme/layouts/center.svg +3 -0
  16. inkflow/theme/layouts/cover.svg +5 -0
  17. inkflow/theme/layouts/default.svg +4 -0
  18. inkflow/theme/layouts/end.svg +4 -0
  19. inkflow/theme/layouts/fact.svg +4 -0
  20. inkflow/theme/layouts/media-left.svg +5 -0
  21. inkflow/theme/layouts/media-right.svg +5 -0
  22. inkflow/theme/layouts/quote.svg +4 -0
  23. inkflow/theme/layouts/section.svg +4 -0
  24. inkflow/theme/layouts/statement.svg +3 -0
  25. inkflow/theme/layouts/two-cols-header.svg +5 -0
  26. inkflow/theme/layouts/two-cols.svg +5 -0
  27. inkflow/theme/main.svg +3 -0
  28. inkflow/theme/numbered-main.svg +6 -0
  29. inkflow/theme/showcase/deck.py +18 -0
  30. inkflow/theme/showcase/slides/01-cover.md +7 -0
  31. inkflow/theme/showcase/slides/02-section.md +7 -0
  32. inkflow/theme/showcase/slides/03-default.md +11 -0
  33. inkflow/theme/showcase/slides/04-center.md +8 -0
  34. inkflow/theme/showcase/slides/05-two-cols.md +17 -0
  35. inkflow/theme/showcase/slides/06-two-cols-header.md +13 -0
  36. inkflow/theme/showcase/slides/07-fact.md +7 -0
  37. inkflow/theme/showcase/slides/08-quote.md +7 -0
  38. inkflow/theme/showcase/slides/09-statement.md +3 -0
  39. inkflow/theme/showcase/slides/10-media-left.md +15 -0
  40. inkflow/theme/showcase/slides/11-media-right.md +16 -0
  41. inkflow/theme/showcase/slides/12-end.md +3 -0
  42. inkflow/theme/styles.css +55 -0
  43. inkflow/tui.py +128 -0
  44. inkflow-0.1.0.dev0.dist-info/METADATA +12 -0
  45. inkflow-0.1.0.dev0.dist-info/RECORD +48 -0
  46. inkflow-0.1.0.dev0.dist-info/WHEEL +4 -0
  47. inkflow-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  48. inkflow-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
inkflow/server.py ADDED
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import errno
6
+ import importlib.resources
7
+ import importlib.util
8
+ import json
9
+ import os
10
+ import signal
11
+ import sys
12
+ import termios
13
+ import time
14
+ import traceback
15
+ import tty
16
+ import webbrowser
17
+ from collections.abc import Awaitable, Callable
18
+ from pathlib import Path
19
+ from typing import TypedDict, cast
20
+
21
+ from rich.console import Console
22
+ from rich.live import Live
23
+ from rich.text import Text
24
+ from watchfiles import awatch # pyright: ignore[reportUnknownVariableType]
25
+ from websockets.asyncio.server import ServerConnection
26
+ from websockets.asyncio.server import serve as ws_serve
27
+
28
+ from inkflow.layout import resolve_theme_dir
29
+ from inkflow.manifest import Deck
30
+ from inkflow.pipeline import process_deck, resolve_transitions
31
+ from inkflow.tui import LiveUI
32
+
33
+ # ── Shared mutable state ──────────────────────────────────────────────────────
34
+
35
+
36
+ class State(TypedDict):
37
+ slides: list[str]
38
+ transitions: list[dict[str, object]]
39
+ ws_clients: set[ServerConnection]
40
+ error: str | None
41
+ styles_css: str
42
+ dark_mode: bool
43
+
44
+
45
+ _state: State = {
46
+ "slides": [],
47
+ "transitions": [],
48
+ "ws_clients": set(),
49
+ "error": None,
50
+ "styles_css": "",
51
+ "dark_mode": True,
52
+ }
53
+
54
+
55
+ # ── Deck loader ───────────────────────────────────────────────────────────────
56
+
57
+
58
+ def load_deck(deck_path: Path) -> Deck:
59
+ spec = importlib.util.spec_from_file_location("_inkflow_deck", deck_path)
60
+ if spec is None or spec.loader is None:
61
+ raise ImportError(f"Cannot load module from {deck_path}")
62
+ mod = importlib.util.module_from_spec(spec)
63
+ spec.loader.exec_module(mod)
64
+ if not hasattr(mod, "deck"):
65
+ raise AttributeError(
66
+ f"{deck_path} must define a module-level variable named 'deck'"
67
+ )
68
+ return cast(Deck, mod.deck)
69
+
70
+
71
+ # ── Build pipeline ────────────────────────────────────────────────────────────
72
+
73
+
74
+ async def rebuild(deck_path: Path, ui: LiveUI) -> None:
75
+ ui.set_building()
76
+
77
+ async def _animate() -> None:
78
+ while True:
79
+ await asyncio.sleep(0.1)
80
+ ui.refresh()
81
+
82
+ spin = asyncio.create_task(_animate())
83
+ t0 = time.monotonic()
84
+ try:
85
+ deck = await asyncio.to_thread(load_deck, deck_path)
86
+ project_dir = deck_path.parent
87
+ slides = await asyncio.to_thread(process_deck, deck, project_dir)
88
+ transitions = resolve_transitions(deck)
89
+ styles_css = await asyncio.to_thread(load_styles, deck, project_dir)
90
+ _state["slides"] = slides
91
+ _state["transitions"] = transitions
92
+ _state["styles_css"] = styles_css
93
+ _state["dark_mode"] = deck.dark_mode
94
+ _state["error"] = None
95
+ ui.set_ok(len(slides), time.monotonic() - t0)
96
+ await broadcast(
97
+ json.dumps({"type": "update", "slides": slides, "transitions": transitions})
98
+ )
99
+ except Exception:
100
+ tb = traceback.format_exc()
101
+ _state["error"] = tb
102
+ ui.set_error(tb)
103
+ await broadcast(json.dumps({"type": "error", "message": tb}))
104
+ finally:
105
+ spin.cancel()
106
+ with contextlib.suppress(asyncio.CancelledError):
107
+ await spin
108
+ ui.refresh()
109
+
110
+
111
+ # ── WebSocket broadcast ───────────────────────────────────────────────────────
112
+
113
+
114
+ async def broadcast(msg: str) -> None:
115
+ dead: set[ServerConnection] = set()
116
+ for ws in list(_state["ws_clients"]):
117
+ try:
118
+ await ws.send(msg)
119
+ except Exception:
120
+ dead.add(ws)
121
+ _state["ws_clients"] -= dead
122
+
123
+
124
+ # ── WebSocket handler ─────────────────────────────────────────────────────────
125
+
126
+
127
+ def make_ws_handler(ui: LiveUI) -> Callable[[ServerConnection], Awaitable[None]]:
128
+ async def handler(websocket: ServerConnection) -> None:
129
+ _state["ws_clients"].add(websocket)
130
+ ui.refresh()
131
+ try:
132
+ await websocket.wait_closed()
133
+ finally:
134
+ _state["ws_clients"].discard(websocket)
135
+ ui.refresh()
136
+
137
+ return handler
138
+
139
+
140
+ # ── HTTP handler ──────────────────────────────────────────────────────────────
141
+
142
+ _StreamHandler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], Awaitable[None]]
143
+
144
+
145
+ def load_styles(deck: Deck, project_dir: Path) -> str:
146
+ pkg = importlib.resources.files("inkflow")
147
+ parts = [pkg.joinpath("theme", "styles.css").read_text(encoding="utf-8")]
148
+
149
+ if deck.theme is not None:
150
+ try:
151
+ theme_dir = resolve_theme_dir(deck.theme, project_dir)
152
+ theme_css = theme_dir / "styles.css"
153
+ if theme_css.exists():
154
+ parts.append(theme_css.read_text(encoding="utf-8"))
155
+ except ValueError:
156
+ pass
157
+
158
+ project_css = project_dir / "styles.css"
159
+ if project_css.exists():
160
+ parts.append(project_css.read_text(encoding="utf-8"))
161
+
162
+ return "\n".join(parts)
163
+
164
+
165
+ def build_html(state: State, ws_port: int | None) -> bytes:
166
+ pkg = importlib.resources.files("inkflow")
167
+ template = pkg.joinpath("presenter.html").read_text(encoding="utf-8")
168
+ css = pkg.joinpath("presenter.css").read_text(encoding="utf-8")
169
+ js = pkg.joinpath("presenter.js").read_text(encoding="utf-8")
170
+ data_theme = "" if state["dark_mode"] else "light"
171
+ ws_port_js = "null" if ws_port is None else str(ws_port)
172
+ html = (
173
+ template.replace("__CSS__", css)
174
+ .replace("__JS__", js)
175
+ .replace("__STYLES__", state["styles_css"])
176
+ .replace("__DATA_THEME__", data_theme)
177
+ .replace("__SLIDES_JSON__", json.dumps(state["slides"]))
178
+ .replace("__TRANSITIONS_JSON__", json.dumps(state["transitions"]))
179
+ .replace("__WS_PORT__", ws_port_js)
180
+ .replace("__ERROR_JSON__", json.dumps(state["error"]))
181
+ )
182
+ return html.encode("utf-8")
183
+
184
+
185
+ _SERVED_SUFFIXES = {".mp4", ".webm", ".ogg", ".mov"}
186
+ _MIME_TYPES = {
187
+ ".mp4": "video/mp4",
188
+ ".webm": "video/webm",
189
+ ".ogg": "video/ogg",
190
+ ".mov": "video/quicktime",
191
+ }
192
+
193
+
194
+ def make_http_handler(ws_port: int, project_dir: Path | None = None) -> _StreamHandler:
195
+ async def handler(
196
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter
197
+ ) -> None:
198
+ try:
199
+ raw = await asyncio.wait_for(reader.read(4096), timeout=10)
200
+ request_line = raw.split(b"\r\n", 1)[0].decode(errors="replace")
201
+ parts = request_line.split(" ", 2)
202
+ request_path = parts[1] if len(parts) >= 2 else "/"
203
+
204
+ if project_dir is not None and request_path != "/":
205
+ asset_path = project_dir / request_path.lstrip("/")
206
+ suffix = asset_path.suffix.lower()
207
+ if asset_path.is_file() and suffix in _SERVED_SUFFIXES:
208
+ mime = _MIME_TYPES[suffix]
209
+ body = asset_path.read_bytes()
210
+ header = (
211
+ b"HTTP/1.1 200 OK\r\n"
212
+ + f"Content-Type: {mime}\r\n".encode()
213
+ + b"Cache-Control: no-store\r\n"
214
+ + b"Connection: close\r\n"
215
+ + b"Content-Length: "
216
+ + str(len(body)).encode()
217
+ + b"\r\n\r\n"
218
+ )
219
+ writer.write(header + body)
220
+ await writer.drain()
221
+ return
222
+
223
+ body = build_html(_state, ws_port)
224
+ header = (
225
+ b"HTTP/1.1 200 OK\r\n"
226
+ + b"Content-Type: text/html; charset=utf-8\r\n"
227
+ + b"Cache-Control: no-store\r\n"
228
+ + b"Connection: close\r\n"
229
+ + b"Content-Length: "
230
+ + str(len(body)).encode()
231
+ + b"\r\n\r\n"
232
+ )
233
+ writer.write(header + body)
234
+
235
+ await writer.drain()
236
+ except Exception:
237
+ pass
238
+ finally:
239
+ writer.close()
240
+ with contextlib.suppress(Exception):
241
+ await writer.wait_closed()
242
+
243
+ return handler
244
+
245
+
246
+ # ── File watcher ──────────────────────────────────────────────────────────────
247
+
248
+
249
+ async def _watch(deck_path: Path, ui: LiveUI, lock: asyncio.Lock) -> None:
250
+ async for _changes in awatch(str(deck_path.parent)):
251
+ if not lock.locked(): # skip if a rebuild is already in progress
252
+ async with lock:
253
+ await rebuild(deck_path, ui)
254
+
255
+
256
+ # ── Keyboard handler ──────────────────────────────────────────────────────────
257
+
258
+
259
+ def _open_browser(url: str) -> None:
260
+ # Redirect fd 1/2 to /dev/null so the browser process can't write startup
261
+ # noise to the terminal and corrupt the Rich Live cursor tracking.
262
+ devnull = os.open(os.devnull, os.O_WRONLY)
263
+ saved_out = os.dup(1)
264
+ saved_err = os.dup(2)
265
+ try:
266
+ os.dup2(devnull, 1)
267
+ os.dup2(devnull, 2)
268
+ webbrowser.open(url)
269
+ finally:
270
+ os.dup2(saved_out, 1)
271
+ os.dup2(saved_err, 2)
272
+ os.close(devnull)
273
+ os.close(saved_out)
274
+ os.close(saved_err)
275
+
276
+
277
+ async def _read_keys(
278
+ deck_path: Path,
279
+ http_port: int,
280
+ ui: LiveUI,
281
+ lock: asyncio.Lock,
282
+ shutdown: asyncio.Event,
283
+ ) -> None:
284
+ if not sys.stdin.isatty():
285
+ return
286
+
287
+ loop = asyncio.get_running_loop()
288
+ queue: asyncio.Queue[str] = asyncio.Queue()
289
+
290
+ def _on_stdin() -> None:
291
+ ch = sys.stdin.read(1)
292
+ loop.call_soon_threadsafe(queue.put_nowait, ch)
293
+
294
+ fd = sys.stdin.fileno()
295
+ old_settings = termios.tcgetattr(fd)
296
+ try:
297
+ tty.setcbreak(fd) # single-char reads, output processing (ONLCR) intact
298
+ loop.add_reader(fd, _on_stdin)
299
+ while True:
300
+ ch = await queue.get()
301
+ if ch in ("\x04", "q"): # Ctrl-D, q (Ctrl-C handled via SIGINT)
302
+ shutdown.set()
303
+ return
304
+ elif ch == "o":
305
+ _open_browser(f"http://localhost:{http_port}")
306
+ elif ch == "r":
307
+ async with lock:
308
+ await rebuild(deck_path, ui)
309
+ elif ch == "t":
310
+ ui.toggle_trace()
311
+ finally:
312
+ loop.remove_reader(fd)
313
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
314
+
315
+
316
+ # ── Public entry point ────────────────────────────────────────────────────────
317
+
318
+
319
+ async def serve(deck_path: Path, http_port: int, ws_port: int) -> None:
320
+ console = Console()
321
+ rebuild_lock = asyncio.Lock()
322
+ shutdown = asyncio.Event()
323
+
324
+ loop = asyncio.get_running_loop()
325
+ loop.add_signal_handler(signal.SIGINT, shutdown.set)
326
+ loop.add_signal_handler(signal.SIGTERM, shutdown.set)
327
+
328
+ try:
329
+ http_handler = make_http_handler(ws_port, deck_path.parent)
330
+ # Bind before the Live UI so port conflicts fail fast with a clean message
331
+ try:
332
+ http_server = await asyncio.start_server(
333
+ http_handler, "127.0.0.1", http_port
334
+ )
335
+ except OSError as e:
336
+ if e.errno == errno.EADDRINUSE:
337
+ msg = (
338
+ f"[red]error:[/red] port {http_port} is already in use."
339
+ f" Pass [dim]--port PORT[/dim] to serve on a different port."
340
+ )
341
+ console.print(msg)
342
+ return
343
+ raise
344
+
345
+ with Live(Text(""), console=console, auto_refresh=False) as live:
346
+ ui = LiveUI(
347
+ live,
348
+ http_port,
349
+ deck_path.parent,
350
+ get_clients=lambda: len(_state["ws_clients"]),
351
+ )
352
+ try:
353
+ async with (
354
+ http_server,
355
+ ws_serve(make_ws_handler(ui), "127.0.0.1", ws_port),
356
+ ):
357
+ await rebuild(deck_path, ui)
358
+ tasks = [
359
+ asyncio.create_task(http_server.serve_forever()),
360
+ asyncio.create_task(_watch(deck_path, ui, rebuild_lock)),
361
+ asyncio.create_task(
362
+ _read_keys(deck_path, http_port, ui, rebuild_lock, shutdown)
363
+ ),
364
+ ]
365
+ await shutdown.wait()
366
+ for t in tasks:
367
+ t.cancel()
368
+ await asyncio.gather(*tasks, return_exceptions=True)
369
+ except OSError as e:
370
+ if e.errno == errno.EADDRINUSE:
371
+ msg = (
372
+ f"[red]error:[/red] port {ws_port} is already in use."
373
+ f" Pass [dim]--ws-port PORT[/dim] to use a different one."
374
+ )
375
+ console.print(msg)
376
+ else:
377
+ raise
378
+ finally:
379
+ loop.remove_signal_handler(signal.SIGINT)
380
+ loop.remove_signal_handler(signal.SIGTERM)
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-content" x="80" y="80" width="1760" height="900"/>
3
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-title" x="160" y="340" width="1600" height="220"/>
3
+ <rect id="zone-subtitle" x="160" y="580" width="1600" height="120"/>
4
+ <rect id="zone-note" x="160" y="730" width="1600" height="100"/>
5
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-title" x="80" y="60" width="1760" height="120"/>
3
+ <rect id="zone-content" x="80" y="220" width="1760" height="780"/>
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-title" x="160" y="380" width="1600" height="220"/>
3
+ <rect id="zone-subtitle" x="160" y="620" width="1600" height="120"/>
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-fact" x="80" y="300" width="1760" height="300"/>
3
+ <rect id="zone-caption" x="80" y="650" width="1760" height="160"/>
4
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-title" x="80" y="60" width="1760" height="120"/>
3
+ <rect id="zone-media" x="80" y="220" width="840" height="780"/>
4
+ <rect id="zone-content" x="1000" y="220" width="840" height="780"/>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-title" x="80" y="60" width="1760" height="120"/>
3
+ <rect id="zone-content" x="80" y="220" width="840" height="780"/>
4
+ <rect id="zone-media" x="1000" y="220" width="840" height="780"/>
5
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-quote" x="160" y="300" width="1600" height="400"/>
3
+ <rect id="zone-attribution" x="160" y="730" width="1600" height="100"/>
4
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-section-title" x="80" y="380" width="1760" height="220"/>
3
+ <rect id="zone-section-subtitle" x="80" y="620" width="1760" height="120"/>
4
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-statement" x="80" y="280" width="1760" height="520"/>
3
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-header" x="80" y="60" width="1760" height="120"/>
3
+ <rect id="zone-left" x="80" y="220" width="840" height="780"/>
4
+ <rect id="zone-right" x="1000" y="220" width="840" height="780"/>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="../numbered-main.svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect id="zone-title" x="80" y="60" width="1760" height="120"/>
3
+ <rect id="zone-left" x="80" y="220" width="840" height="780"/>
4
+ <rect id="zone-right" x="1000" y="220" width="840" height="780"/>
5
+ </svg>
inkflow/theme/main.svg ADDED
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" width="1920" height="1080">
2
+ <rect width="1920" height="1080" class="slide-bg"/>
3
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:inkflow="urn:inkflow" inkflow:parent="./main.svg" viewBox="0 0 1920 1080" width="1920" height="1080" version="1.1" id="svg1">
2
+ <defs id="defs1"/>
3
+ <text id="zone-slide-number" x="1820" y="1055" class="theme-text-muted" text-anchor="end" font-size="28px" font-family="sans-serif">99</text>
4
+ <text id="zone-slide-total" x="1860" y="1055" class="theme-text-muted" font-size="28px" font-family="sans-serif">99</text>
5
+ <text id="text1" x="1832.489" y="1055" class="theme-text-muted" font-size="28px" font-family="sans-serif">/</text>
6
+ </svg>
@@ -0,0 +1,18 @@
1
+ from inkflow import Crossfade, Deck, MarkdownSlide
2
+
3
+ deck = Deck(transition=Crossfade())
4
+
5
+ deck.slides = [
6
+ MarkdownSlide("cover", content="01-cover"),
7
+ MarkdownSlide("section", content="02-section"),
8
+ MarkdownSlide("default", content="03-default", steps=True),
9
+ MarkdownSlide("center", content="04-center"),
10
+ MarkdownSlide("two-cols", content="05-two-cols"),
11
+ MarkdownSlide("two-cols-header", content="06-two-cols-header"),
12
+ MarkdownSlide("fact", content="07-fact"),
13
+ MarkdownSlide("quote", content="08-quote"),
14
+ MarkdownSlide("statement", content="09-statement"),
15
+ MarkdownSlide("media-left", content="10-media-left"),
16
+ MarkdownSlide("media-right", content="11-media-right"),
17
+ MarkdownSlide("end", content="12-end"),
18
+ ]
@@ -0,0 +1,7 @@
1
+ # inkflow built-in theme
2
+
3
+ ## A tour of all twelve layouts
4
+
5
+ ::note::
6
+
7
+ Press Space or → to advance
@@ -0,0 +1,7 @@
1
+ ::section-title::
2
+
3
+ Part 1: Full-slide layouts
4
+
5
+ ::section-subtitle::
6
+
7
+ `cover` · `section` · `end`
@@ -0,0 +1,11 @@
1
+ # The `default` layout
2
+
3
+ Title at the top, content fills the rest.
4
+
5
+ - Supports **bold**, _italic_, and `inline code`
6
+ - Bullet lists can be stepped with `steps=True`
7
+ - Code blocks, blockquotes, and nested lists all work
8
+
9
+ ::step::
10
+
11
+ Each `::step::` marker reveals the next chunk on click.
@@ -0,0 +1,8 @@
1
+ ::content::
2
+
3
+ # `center`
4
+
5
+ One large zone spans the entire slide.
6
+
7
+ Great for a single strong statement, a full-bleed diagram,
8
+ or anything that needs maximum breathing room.
@@ -0,0 +1,17 @@
1
+ # `two-cols`
2
+
3
+ ::left::
4
+
5
+ ## Left column
6
+
7
+ - Independent content
8
+ - Each column scrolls on its own
9
+ - Use for comparisons or before/after
10
+
11
+ ::right::
12
+
13
+ ## Right column
14
+
15
+ - Counters or complements the left
16
+ - Both columns sit below a shared title
17
+ - Swap to `two-cols-header` for a wider header zone
@@ -0,0 +1,13 @@
1
+ ::header::
2
+
3
+ `two-cols-header` — shared header, two equal columns
4
+
5
+ ::left::
6
+
7
+ The header zone is wider than `two-cols` title zone and sits
8
+ above both columns, providing shared context.
9
+
10
+ ::right::
11
+
12
+ Use this layout when the header is the primary label and the
13
+ columns carry parallel or contrasting detail underneath.
@@ -0,0 +1,7 @@
1
+ ::fact::
2
+
3
+ # 0 type errors
4
+
5
+ ::caption::
6
+
7
+ `fact` — one large number or short claim, with a caption below for context.
@@ -0,0 +1,7 @@
1
+ ::quote::
2
+
3
+ "Simple things should be simple, complex things should be possible."
4
+
5
+ ::attribution::
6
+
7
+ — Alan Kay
@@ -0,0 +1,3 @@
1
+ ::statement::
2
+
3
+ ## One idea per slide.
@@ -0,0 +1,15 @@
1
+ # `media-left`
2
+
3
+ ::media::
4
+
5
+ _media zone_
6
+
7
+ Image or video fills the left half.
8
+ Pass `media=Media("file.jpg")` to `MarkdownSlide`.
9
+
10
+ ::content::
11
+
12
+ Text content occupies the right half.
13
+
14
+ Use this layout when a visual element is the primary focus and
15
+ the text provides supporting context.
@@ -0,0 +1,16 @@
1
+ # `media-right`
2
+
3
+ ::content::
4
+
5
+ Text content occupies the left half.
6
+
7
+ Use this layout when the narrative leads and the visual
8
+ confirms or illustrates — the natural reading direction
9
+ carries the eye from text to image.
10
+
11
+ ::media::
12
+
13
+ _media zone_
14
+
15
+ Image or video fills the right half.
16
+ Pass `media=Media("file.jpg")` to `MarkdownSlide`.
@@ -0,0 +1,3 @@
1
+ # That's all twelve layouts.
2
+
3
+ ## `uv run inkflow serve src/inkflow/theme/showcase/deck.py`