termdeck 0.1.0__tar.gz → 0.2.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.
- {termdeck-0.1.0/termdeck.egg-info → termdeck-0.2.0}/PKG-INFO +10 -1
- {termdeck-0.1.0 → termdeck-0.2.0}/README.md +6 -0
- {termdeck-0.1.0 → termdeck-0.2.0}/pyproject.toml +3 -0
- termdeck-0.2.0/termdeck/__init__.py +1 -0
- {termdeck-0.1.0 → termdeck-0.2.0}/termdeck/app.py +4 -0
- termdeck-0.2.0/termdeck/deck.py +178 -0
- termdeck-0.2.0/termdeck/styles/default.tcss +9 -0
- {termdeck-0.1.0 → termdeck-0.2.0/termdeck.egg-info}/PKG-INFO +10 -1
- {termdeck-0.1.0 → termdeck-0.2.0}/termdeck.egg-info/requires.txt +1 -0
- termdeck-0.1.0/termdeck/__init__.py +0 -1
- termdeck-0.1.0/termdeck/deck.py +0 -67
- termdeck-0.1.0/termdeck/styles/default.tcss +0 -3
- {termdeck-0.1.0 → termdeck-0.2.0}/LICENSE +0 -0
- {termdeck-0.1.0 → termdeck-0.2.0}/setup.cfg +0 -0
- {termdeck-0.1.0 → termdeck-0.2.0}/termdeck/__main__.py +0 -0
- {termdeck-0.1.0 → termdeck-0.2.0}/termdeck/sample/p01_intro.md +0 -0
- {termdeck-0.1.0 → termdeck-0.2.0}/termdeck/sample/p02_slides.md +0 -0
- {termdeck-0.1.0 → termdeck-0.2.0}/termdeck.egg-info/SOURCES.txt +0 -0
- {termdeck-0.1.0 → termdeck-0.2.0}/termdeck.egg-info/dependency_links.txt +0 -0
- {termdeck-0.1.0 → termdeck-0.2.0}/termdeck.egg-info/entry_points.txt +0 -0
- {termdeck-0.1.0 → termdeck-0.2.0}/termdeck.egg-info/top_level.txt +0 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termdeck
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Terminal presentations, written in Markdown or Python
|
|
5
|
+
Author-email: Zorex Salvo <zorexsalvo@gmail.com>
|
|
5
6
|
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/zorexsalvo/presentazi
|
|
6
8
|
Keywords: presentation,slides,terminal,textual,markdown
|
|
7
9
|
Classifier: Environment :: Console
|
|
8
10
|
Classifier: Intended Audience :: Developers
|
|
@@ -13,6 +15,7 @@ Requires-Python: >=3.8
|
|
|
13
15
|
Description-Content-Type: text/markdown
|
|
14
16
|
License-File: LICENSE
|
|
15
17
|
Requires-Dist: textual>=0.84
|
|
18
|
+
Requires-Dist: rich-pixels>=3.0
|
|
16
19
|
Provides-Extra: music
|
|
17
20
|
Requires-Dist: musicpy; extra == "music"
|
|
18
21
|
Requires-Dist: pygame; extra == "music"
|
|
@@ -61,8 +64,14 @@ A deck is a folder of slide files. Files are sorted by name.
|
|
|
61
64
|
|
|
62
65
|
- a bullet
|
|
63
66
|
- another bullet
|
|
67
|
+
|
|
68
|
+

|
|
64
69
|
```
|
|
65
70
|
|
|
71
|
+
Image paragraphs (``) are rendered as images. Relative paths are resolved from the slide file's directory.
|
|
72
|
+
|
|
73
|
+
Supported formats: `png`, `jpg`, `jpeg`, `gif`, `webp`, `bmp`.
|
|
74
|
+
|
|
66
75
|
### Python slides
|
|
67
76
|
|
|
68
77
|
A Python slide is a [Textual `Screen`](https://textual.textualize.io/api/screen/). It must expose a `Slide` class:
|
|
@@ -37,8 +37,14 @@ A deck is a folder of slide files. Files are sorted by name.
|
|
|
37
37
|
|
|
38
38
|
- a bullet
|
|
39
39
|
- another bullet
|
|
40
|
+
|
|
41
|
+

|
|
40
42
|
```
|
|
41
43
|
|
|
44
|
+
Image paragraphs (``) are rendered as images. Relative paths are resolved from the slide file's directory.
|
|
45
|
+
|
|
46
|
+
Supported formats: `png`, `jpg`, `jpeg`, `gif`, `webp`, `bmp`.
|
|
47
|
+
|
|
42
48
|
### Python slides
|
|
43
49
|
|
|
44
50
|
A Python slide is a [Textual `Screen`](https://textual.textualize.io/api/screen/). It must expose a `Slide` class:
|
|
@@ -8,6 +8,8 @@ dynamic = ["version"]
|
|
|
8
8
|
description = "Terminal presentations, written in Markdown or Python"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Zorex Salvo", email = "zorexsalvo@gmail.com" }]
|
|
12
|
+
urls = { Homepage = "https://github.com/zorexsalvo/presentazi" }
|
|
11
13
|
requires-python = ">=3.8"
|
|
12
14
|
keywords = ["presentation", "slides", "terminal", "textual", "markdown"]
|
|
13
15
|
classifiers = [
|
|
@@ -19,6 +21,7 @@ classifiers = [
|
|
|
19
21
|
]
|
|
20
22
|
dependencies = [
|
|
21
23
|
"textual>=0.84",
|
|
24
|
+
"rich-pixels>=3.0",
|
|
22
25
|
]
|
|
23
26
|
|
|
24
27
|
[project.optional-dependencies]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from markdown_it import MarkdownIt
|
|
6
|
+
from PIL import Image as PILImage
|
|
7
|
+
from rich_pixels import Pixels
|
|
8
|
+
from textual.containers import VerticalScroll
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import Markdown, Static
|
|
11
|
+
|
|
12
|
+
SLIDE_EXTS = (".md", ".MD", ".py")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_slide(path: Path) -> type[Screen]:
|
|
16
|
+
"""Load a single slide file (markdown or python Textual screen) as a Screen."""
|
|
17
|
+
path = Path(path)
|
|
18
|
+
if path.suffix in (".md", ".MD"):
|
|
19
|
+
return _markdown_slide(path)
|
|
20
|
+
if path.suffix == ".py":
|
|
21
|
+
return _python_slide(path)
|
|
22
|
+
raise ValueError(f"Unsupported slide type: {path.suffix}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_deck(directory: Path) -> list[tuple[str, type[Screen]]]:
|
|
26
|
+
"""Load all slides in a directory, sorted by filename."""
|
|
27
|
+
slides = []
|
|
28
|
+
for path in sorted(Path(directory).iterdir()):
|
|
29
|
+
if path.name == "__init__.py":
|
|
30
|
+
continue
|
|
31
|
+
if path.suffix not in SLIDE_EXTS:
|
|
32
|
+
continue
|
|
33
|
+
slides.append((path.name, load_slide(path)))
|
|
34
|
+
return slides
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _markdown_slide(path: Path) -> type[Screen]:
|
|
38
|
+
"""Build a Screen from a Markdown file, rendering image-only paragraphs as images."""
|
|
39
|
+
path = Path(path)
|
|
40
|
+
content = path.read_text()
|
|
41
|
+
base_dir = path.parent
|
|
42
|
+
segments = _split_markdown(content, base_dir)
|
|
43
|
+
|
|
44
|
+
class Slide(Screen):
|
|
45
|
+
def compose(self):
|
|
46
|
+
with VerticalScroll(id="markdown-slide"):
|
|
47
|
+
for seg_type, seg_data in segments:
|
|
48
|
+
if seg_type == "markdown":
|
|
49
|
+
yield Markdown(seg_data)
|
|
50
|
+
elif seg_type == "image":
|
|
51
|
+
yield ImageWidget(seg_data)
|
|
52
|
+
|
|
53
|
+
return Slide
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _split_markdown(content: str, base_dir: Path) -> list[tuple[str, Path | str]]:
|
|
57
|
+
"""Split markdown into text chunks and image blocks.
|
|
58
|
+
|
|
59
|
+
Paragraphs that contain only a single image token become ("image", path).
|
|
60
|
+
Everything else becomes ("markdown", text_chunk).
|
|
61
|
+
"""
|
|
62
|
+
tokens = MarkdownIt().parse(content)
|
|
63
|
+
lines = content.splitlines()
|
|
64
|
+
images: list[tuple[int, int, Path]] = []
|
|
65
|
+
|
|
66
|
+
i = 0
|
|
67
|
+
while i < len(tokens):
|
|
68
|
+
token = tokens[i]
|
|
69
|
+
if token.type == "paragraph_open" and token.level == 0 and token.map is not None:
|
|
70
|
+
inline = tokens[i + 1] if i + 1 < len(tokens) else None
|
|
71
|
+
if inline is not None and inline.type == "inline" and inline.children:
|
|
72
|
+
children = [
|
|
73
|
+
child
|
|
74
|
+
for child in inline.children
|
|
75
|
+
if child.type != "text" or child.content.strip()
|
|
76
|
+
]
|
|
77
|
+
if len(children) == 1 and children[0].type == "image":
|
|
78
|
+
src = children[0].attrs.get("src", "")
|
|
79
|
+
img_path = _resolve_image_path(src, base_dir)
|
|
80
|
+
start, end = token.map
|
|
81
|
+
images.append((start, end, img_path))
|
|
82
|
+
i += 1
|
|
83
|
+
|
|
84
|
+
segments: list[tuple[str, Path | str]] = []
|
|
85
|
+
last_end = 0
|
|
86
|
+
for start, end, img_path in images:
|
|
87
|
+
before = "\n".join(lines[last_end:start])
|
|
88
|
+
if before.strip():
|
|
89
|
+
segments.append(("markdown", before))
|
|
90
|
+
segments.append(("image", img_path))
|
|
91
|
+
last_end = end
|
|
92
|
+
|
|
93
|
+
after = "\n".join(lines[last_end:])
|
|
94
|
+
if after.strip():
|
|
95
|
+
segments.append(("markdown", after))
|
|
96
|
+
|
|
97
|
+
return segments
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_image_path(src: str, base_dir: Path) -> Path:
|
|
101
|
+
"""Resolve an image src to an absolute path."""
|
|
102
|
+
path = Path(src)
|
|
103
|
+
if path.is_absolute():
|
|
104
|
+
return path
|
|
105
|
+
return base_dir / path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _python_slide(path: Path) -> type[Screen]:
|
|
109
|
+
spec = importlib.util.spec_from_file_location(f"termdeck_slide_{path.stem}", path)
|
|
110
|
+
module = importlib.util.module_from_spec(spec)
|
|
111
|
+
spec.loader.exec_module(module)
|
|
112
|
+
return module.Slide
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ImageWidget(Static):
|
|
116
|
+
"""A widget that renders an image file as a Rich Pixels renderable, fitted to the viewport."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, path: Path, **kwargs):
|
|
119
|
+
super().__init__("", **kwargs)
|
|
120
|
+
self.path = Path(path)
|
|
121
|
+
self._pil_image = PILImage.open(self.path)
|
|
122
|
+
if self._pil_image.mode not in ("RGB", "RGBA"):
|
|
123
|
+
self._pil_image = self._pil_image.convert("RGB")
|
|
124
|
+
|
|
125
|
+
def _fit(self, width: int, height: int) -> tuple[int, int]:
|
|
126
|
+
"""Return (pixel_width, pixel_height) that fits inside width x (height*2)."""
|
|
127
|
+
img_w, img_h = self._pil_image.size
|
|
128
|
+
max_h = max(2, height * 2)
|
|
129
|
+
scale = min(width / img_w, max_h / img_h, 1.0)
|
|
130
|
+
pixel_w = max(2, int(img_w * scale))
|
|
131
|
+
pixel_h = max(2, int(img_h * scale))
|
|
132
|
+
if pixel_h % 2 != 0:
|
|
133
|
+
pixel_h += 1
|
|
134
|
+
return pixel_w, pixel_h
|
|
135
|
+
|
|
136
|
+
def get_content_height(self, container, viewport, width: int) -> int:
|
|
137
|
+
if width < 1 or viewport.height < 1:
|
|
138
|
+
return 0
|
|
139
|
+
# Subtract 6 from the viewport to leave room for the header, footer,
|
|
140
|
+
# and any text above the image; this is approximate but keeps images
|
|
141
|
+
# on screen without requiring the user to scroll.
|
|
142
|
+
budget = max(2, viewport.height - 6)
|
|
143
|
+
_, pixel_h = self._fit(width, budget)
|
|
144
|
+
return pixel_h // 2
|
|
145
|
+
|
|
146
|
+
def render(self):
|
|
147
|
+
width = self.content_size.width
|
|
148
|
+
height = self.content_size.height
|
|
149
|
+
if width < 1 or height < 1:
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
pixel_w, pixel_h = self._fit(width, height)
|
|
153
|
+
resized = self._pil_image.resize(
|
|
154
|
+
(pixel_w, pixel_h), PILImage.Resampling.LANCZOS
|
|
155
|
+
)
|
|
156
|
+
return Pixels.from_image(resized)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main() -> None:
|
|
160
|
+
if len(sys.argv) > 1:
|
|
161
|
+
deck_dir = Path(sys.argv[1])
|
|
162
|
+
else:
|
|
163
|
+
deck_dir = Path(__file__).parent / "sample"
|
|
164
|
+
|
|
165
|
+
if not deck_dir.is_dir():
|
|
166
|
+
sys.exit(f"error: not a directory: {deck_dir}")
|
|
167
|
+
|
|
168
|
+
slides = load_deck(deck_dir)
|
|
169
|
+
if not slides:
|
|
170
|
+
sys.exit(f"error: no slides (.md/.py) found in {deck_dir}")
|
|
171
|
+
|
|
172
|
+
from termdeck.app import TermDeck
|
|
173
|
+
|
|
174
|
+
TermDeck(deck_dir).run()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
main()
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termdeck
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Terminal presentations, written in Markdown or Python
|
|
5
|
+
Author-email: Zorex Salvo <zorexsalvo@gmail.com>
|
|
5
6
|
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/zorexsalvo/presentazi
|
|
6
8
|
Keywords: presentation,slides,terminal,textual,markdown
|
|
7
9
|
Classifier: Environment :: Console
|
|
8
10
|
Classifier: Intended Audience :: Developers
|
|
@@ -13,6 +15,7 @@ Requires-Python: >=3.8
|
|
|
13
15
|
Description-Content-Type: text/markdown
|
|
14
16
|
License-File: LICENSE
|
|
15
17
|
Requires-Dist: textual>=0.84
|
|
18
|
+
Requires-Dist: rich-pixels>=3.0
|
|
16
19
|
Provides-Extra: music
|
|
17
20
|
Requires-Dist: musicpy; extra == "music"
|
|
18
21
|
Requires-Dist: pygame; extra == "music"
|
|
@@ -61,8 +64,14 @@ A deck is a folder of slide files. Files are sorted by name.
|
|
|
61
64
|
|
|
62
65
|
- a bullet
|
|
63
66
|
- another bullet
|
|
67
|
+
|
|
68
|
+

|
|
64
69
|
```
|
|
65
70
|
|
|
71
|
+
Image paragraphs (``) are rendered as images. Relative paths are resolved from the slide file's directory.
|
|
72
|
+
|
|
73
|
+
Supported formats: `png`, `jpg`, `jpeg`, `gif`, `webp`, `bmp`.
|
|
74
|
+
|
|
66
75
|
### Python slides
|
|
67
76
|
|
|
68
77
|
A Python slide is a [Textual `Screen`](https://textual.textualize.io/api/screen/). It must expose a `Slide` class:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
termdeck-0.1.0/termdeck/deck.py
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import importlib.util
|
|
2
|
-
import sys
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from textual.screen import Screen
|
|
6
|
-
from textual.widgets import Markdown
|
|
7
|
-
|
|
8
|
-
SLIDE_EXTS = (".md", ".MD", ".py")
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def load_slide(path: Path) -> type[Screen]:
|
|
12
|
-
"""Load a single slide file (markdown or python Textual screen) as a Screen."""
|
|
13
|
-
path = Path(path)
|
|
14
|
-
if path.suffix in (".md", ".MD"):
|
|
15
|
-
return _markdown_slide(path.read_text())
|
|
16
|
-
if path.suffix == ".py":
|
|
17
|
-
return _python_slide(path)
|
|
18
|
-
raise ValueError(f"Unsupported slide type: {path.suffix}")
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def load_deck(directory: Path) -> list[tuple[str, type[Screen]]]:
|
|
22
|
-
"""Load all slides in a directory, sorted by filename."""
|
|
23
|
-
slides = []
|
|
24
|
-
for path in sorted(Path(directory).iterdir()):
|
|
25
|
-
if path.name == "__init__.py":
|
|
26
|
-
continue
|
|
27
|
-
if path.suffix not in SLIDE_EXTS:
|
|
28
|
-
continue
|
|
29
|
-
slides.append((path.name, load_slide(path)))
|
|
30
|
-
return slides
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _markdown_slide(content: str) -> type[Screen]:
|
|
34
|
-
class Slide(Screen):
|
|
35
|
-
def compose(self):
|
|
36
|
-
yield Markdown(content, id="markdown")
|
|
37
|
-
|
|
38
|
-
return Slide
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _python_slide(path: Path) -> type[Screen]:
|
|
42
|
-
spec = importlib.util.spec_from_file_location(f"termdeck_slide_{path.stem}", path)
|
|
43
|
-
module = importlib.util.module_from_spec(spec)
|
|
44
|
-
spec.loader.exec_module(module)
|
|
45
|
-
return module.Slide
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def main() -> None:
|
|
49
|
-
if len(sys.argv) > 1:
|
|
50
|
-
deck_dir = Path(sys.argv[1])
|
|
51
|
-
else:
|
|
52
|
-
deck_dir = Path(__file__).parent / "sample"
|
|
53
|
-
|
|
54
|
-
if not deck_dir.is_dir():
|
|
55
|
-
sys.exit(f"error: not a directory: {deck_dir}")
|
|
56
|
-
|
|
57
|
-
slides = load_deck(deck_dir)
|
|
58
|
-
if not slides:
|
|
59
|
-
sys.exit(f"error: no slides (.md/.py) found in {deck_dir}")
|
|
60
|
-
|
|
61
|
-
from termdeck.app import TermDeck
|
|
62
|
-
|
|
63
|
-
TermDeck(deck_dir).run()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if __name__ == "__main__":
|
|
67
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|