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.
@@ -1,8 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termdeck
3
- Version: 0.1.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
+ ![diagram](diagram.png)
64
69
  ```
65
70
 
71
+ Image paragraphs (`![alt](path)`) 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
+ ![diagram](diagram.png)
40
42
  ```
41
43
 
44
+ Image paragraphs (`![alt](path)`) 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"
@@ -34,6 +34,10 @@ class TermDeck(App):
34
34
 
35
35
  self.push_screen(names[self.slide_number])
36
36
 
37
+ def on_mount(self) -> None:
38
+ if self.names:
39
+ self.push_screen(self.names[0])
40
+
37
41
  def compose(self):
38
42
  yield Header()
39
43
  yield Footer()
@@ -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()
@@ -0,0 +1,9 @@
1
+ #markdown-slide Markdown {
2
+ margin: 0 20;
3
+ }
4
+
5
+ #markdown-slide ImageWidget {
6
+ width: 100%;
7
+ height: auto;
8
+ content-align: center middle;
9
+ }
@@ -1,8 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termdeck
3
- Version: 0.1.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
+ ![diagram](diagram.png)
64
69
  ```
65
70
 
71
+ Image paragraphs (`![alt](path)`) 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,4 +1,5 @@
1
1
  textual>=0.84
2
+ rich-pixels>=3.0
2
3
 
3
4
  [dev]
4
5
  build
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -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()
@@ -1,3 +0,0 @@
1
- #markdown {
2
- margin: 0 20;
3
- }
File without changes
File without changes
File without changes