Skip to content

Commit 050ee0a

Browse files
feat(lib): enhance notes support (#324)
* feat(lib): enhance notes support TODO: fix keyboard inputs and window order with `present`. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * chore(lint): allow too complex * wip: presenter mode * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat(cli): add presenter view --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent a9b8081 commit 050ee0a

File tree

6 files changed

+208
-27
lines changed

6 files changed

+208
-27
lines changed

manim_slides/config.py

+12
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from functools import wraps
44
from inspect import Parameter, signature
55
from pathlib import Path
6+
from textwrap import dedent
67
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
78

89
import rtoml
@@ -145,6 +146,7 @@ class BaseSlideConfig(BaseModel): # type: ignore
145146
playback_rate: float = 1.0
146147
reversed_playback_rate: float = 1.0
147148
notes: str = ""
149+
dedent_notes: bool = True
148150

149151
@classmethod
150152
def wrapper(cls, arg_name: str) -> Callable[..., Any]:
@@ -188,6 +190,16 @@ def __wrapper__(*args: Any, **kwargs: Any) -> Any: # noqa: N807
188190

189191
return _wrapper_
190192

193+
@model_validator(mode="after")
194+
@classmethod
195+
def apply_dedent_notes(
196+
cls, base_slide_config: "BaseSlideConfig"
197+
) -> "BaseSlideConfig":
198+
if base_slide_config.dedent_notes:
199+
base_slide_config.notes = dedent(base_slide_config.notes)
200+
201+
return base_slide_config
202+
191203

192204
class PreSlideConfig(BaseSlideConfig):
193205
"""Slide config to be used prior to rendering."""

manim_slides/convert.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ class PowerPoint(Converter):
489489
def open(self, file: Path) -> None:
490490
return open_with_default(file)
491491

492-
def convert_to(self, dest: Path) -> None:
492+
def convert_to(self, dest: Path) -> None: # noqa: C901
493493
"""Convert this configuration into a PowerPoint presentation, saved to DEST."""
494494
prs = pptx.Presentation()
495495
prs.slide_width = self.width * 9525
@@ -557,6 +557,9 @@ def save_first_image_from_video_file(file: Path) -> Optional[str]:
557557
poster_frame_image=poster_frame_image,
558558
mime_type=mime_type,
559559
)
560+
if slide_config.notes != "":
561+
slide.notes_slide.notes_text_frame.text = slide_config.notes
562+
560563
if self.auto_play_media:
561564
auto_play_media(movie, loop=slide_config.loop)
562565

manim_slides/present/player.py

+182-21
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1+
from datetime import datetime
12
from pathlib import Path
2-
from typing import Any, List, Optional
3+
from typing import List, Optional
34

4-
from PySide6.QtCore import Qt, QUrl, Signal, Slot
5+
from PySide6.QtCore import Qt, QTimer, QUrl, Signal, Slot
56
from PySide6.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen
67
from PySide6.QtMultimedia import QMediaPlayer
78
from PySide6.QtMultimediaWidgets import QVideoWidget
8-
from PySide6.QtWidgets import QDialog, QGridLayout, QLabel, QMainWindow, QVBoxLayout
9+
from PySide6.QtWidgets import (
10+
QHBoxLayout,
11+
QLabel,
12+
QMainWindow,
13+
QVBoxLayout,
14+
QWidget,
15+
)
916

1017
from ..config import Config, PresentationConfig, SlideConfig
1118
from ..logger import logger
@@ -14,33 +21,145 @@
1421
WINDOW_NAME = "Manim Slides"
1522

1623

17-
class Info(QDialog): # type: ignore[misc]
18-
def __init__(self, *args: Any, **kwargs: Any) -> None:
19-
super().__init__(*args, **kwargs)
24+
class Info(QWidget): # type: ignore[misc]
25+
key_press_event: Signal = Signal(QKeyEvent)
26+
close_event: Signal = Signal(QCloseEvent)
27+
28+
def __init__(
29+
self,
30+
*,
31+
full_screen: bool,
32+
aspect_ratio_mode: Qt.AspectRatioMode,
33+
screen: Optional[QScreen],
34+
) -> None:
35+
super().__init__()
36+
37+
if screen:
38+
self.setScreen(screen)
39+
self.move(screen.geometry().topLeft())
40+
41+
if full_screen:
42+
self.setWindowState(Qt.WindowFullScreen)
43+
44+
layout = QHBoxLayout()
45+
46+
# Current slide view
47+
48+
left_layout = QVBoxLayout()
49+
left_layout.addWidget(
50+
QLabel("Current slide"),
51+
alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter,
52+
)
53+
main_video_widget = QVideoWidget()
54+
main_video_widget.setAspectRatioMode(aspect_ratio_mode)
55+
main_video_widget.setFixedSize(720, 480)
56+
self.video_sink = main_video_widget.videoSink()
57+
left_layout.addWidget(main_video_widget)
58+
59+
# Current slide informations
2060

21-
main_layout = QVBoxLayout()
22-
labels_layout = QGridLayout()
23-
notes_layout = QVBoxLayout()
2461
self.scene_label = QLabel()
2562
self.slide_label = QLabel()
26-
self.slide_notes = QLabel("")
63+
self.start_time = datetime.now()
64+
self.time_label = QLabel()
65+
self.elapsed_label = QLabel("00h00m00s")
66+
self.timer = QTimer()
67+
self.timer.start(1000) # every second
68+
self.timer.timeout.connect(self.update_time)
69+
70+
bottom_left_layout = QHBoxLayout()
71+
bottom_left_layout.addWidget(
72+
QLabel("Scene:"),
73+
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
74+
)
75+
bottom_left_layout.addWidget(
76+
self.scene_label,
77+
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
78+
)
79+
bottom_left_layout.addWidget(
80+
QLabel("Slide:"),
81+
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
82+
)
83+
bottom_left_layout.addWidget(
84+
self.slide_label,
85+
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
86+
)
87+
bottom_left_layout.addWidget(
88+
QLabel("Time:"),
89+
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
90+
)
91+
bottom_left_layout.addWidget(
92+
self.time_label,
93+
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
94+
)
95+
bottom_left_layout.addWidget(
96+
QLabel("Elapsed:"),
97+
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
98+
)
99+
bottom_left_layout.addWidget(
100+
self.elapsed_label,
101+
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
102+
)
103+
left_layout.addLayout(bottom_left_layout)
104+
layout.addLayout(left_layout)
105+
106+
layout.addSpacing(20)
107+
108+
# Next slide preview
109+
110+
right_layout = QVBoxLayout()
111+
right_layout.addWidget(
112+
QLabel("Next slide"),
113+
alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter,
114+
)
115+
next_video_widget = QVideoWidget()
116+
next_video_widget.setAspectRatioMode(aspect_ratio_mode)
117+
next_video_widget.setFixedSize(360, 240)
118+
self.next_media_player = QMediaPlayer()
119+
self.next_media_player.setVideoOutput(next_video_widget)
120+
self.next_media_player.setLoops(-1)
121+
122+
right_layout.addWidget(next_video_widget)
123+
124+
# Notes
125+
126+
self.slide_notes = QLabel()
27127
self.slide_notes.setWordWrap(True)
128+
self.slide_notes.setTextFormat(Qt.TextFormat.MarkdownText)
129+
self.slide_notes.setFixedWidth(360)
130+
right_layout.addWidget(
131+
self.slide_notes,
132+
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
133+
)
134+
layout.addLayout(right_layout)
28135

29-
labels_layout.addWidget(QLabel("Scene:"), 1, 1)
30-
labels_layout.addWidget(QLabel("Slide:"), 2, 1)
31-
labels_layout.addWidget(self.scene_label, 1, 2)
32-
labels_layout.addWidget(self.slide_label, 2, 2)
136+
widget = QWidget()
33137

34-
notes_layout.addWidget(self.slide_notes)
138+
widget.setLayout(layout)
35139

36-
main_layout.addLayout(labels_layout)
37-
main_layout.addLayout(notes_layout)
140+
main_layout = QVBoxLayout()
141+
main_layout.addWidget(widget, alignment=Qt.AlignmentFlag.AlignCenter)
38142

39143
self.setLayout(main_layout)
40144

41-
if parent := self.parent():
42-
self.closeEvent = parent.closeEvent
43-
self.keyPressEvent = parent.keyPressEvent
145+
@Slot()
146+
def update_time(self) -> None:
147+
now = datetime.now()
148+
seconds = (now - self.start_time).total_seconds()
149+
hours, seconds = divmod(seconds, 3600)
150+
minutes, seconds = divmod(seconds, 60)
151+
self.time_label.setText(now.strftime("%Y/%m/%d %H:%M:%S"))
152+
self.elapsed_label.setText(
153+
f"{int(hours):02d}h{int(minutes):02d}m{int(seconds):02d}s"
154+
)
155+
156+
@Slot()
157+
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
158+
self.close_event.emit(event)
159+
160+
@Slot()
161+
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
162+
self.key_press_event.emit(event)
44163

45164

46165
class Player(QMainWindow): # type: ignore[misc]
@@ -107,6 +226,7 @@ def __init__(
107226
self.setWindowIcon(self.icon)
108227

109228
self.video_widget = QVideoWidget()
229+
self.video_sink = self.video_widget.videoSink()
110230
self.video_widget.setAspectRatioMode(aspect_ratio_mode)
111231
self.setCentralWidget(self.video_widget)
112232

@@ -117,7 +237,14 @@ def __init__(
117237
self.presentation_changed.connect(self.presentation_changed_callback)
118238
self.slide_changed.connect(self.slide_changed_callback)
119239

120-
self.info = Info(parent=self)
240+
self.info = Info(
241+
full_screen=full_screen, aspect_ratio_mode=aspect_ratio_mode, screen=screen
242+
)
243+
self.info.close_event.connect(self.closeEvent)
244+
self.info.key_press_event.connect(self.keyPressEvent)
245+
self.video_sink.videoFrameChanged.connect(
246+
lambda frame: self.info.video_sink.setVideoFrame(frame)
247+
)
121248
self.hide_info_window = hide_info_window
122249

123250
# Connecting key callbacks
@@ -228,6 +355,28 @@ def current_file(self) -> Path:
228355
def current_file(self, file: Path) -> None:
229356
self.__current_file = file
230357

358+
@property
359+
def next_slide_config(self) -> Optional[SlideConfig]:
360+
if self.playing_reversed_slide:
361+
return self.current_slide_config
362+
elif self.current_slide_index < self.current_slides_count - 1:
363+
return self.presentation_configs[self.current_presentation_index].slides[
364+
self.current_slide_index + 1
365+
]
366+
elif self.current_presentation_index < self.presentations_count - 1:
367+
return self.presentation_configs[
368+
self.current_presentation_index + 1
369+
].slides[0]
370+
else:
371+
return None
372+
373+
@property
374+
def next_file(self) -> Optional[Path]:
375+
if slide_config := self.next_slide_config:
376+
return slide_config.file # type: ignore[no-any-return]
377+
378+
return None
379+
231380
@property
232381
def playing_reversed_slide(self) -> bool:
233382
return self.__playing_reversed_slide
@@ -286,6 +435,7 @@ def load_previous_slide(self) -> None:
286435
def load_next_slide(self) -> None:
287436
if self.playing_reversed_slide:
288437
self.playing_reversed_slide = False
438+
self.preview_next_slide() # Slide number did not change, but next did
289439
elif self.current_slide_index < self.current_slides_count - 1:
290440
self.current_slide_index += 1
291441
elif self.current_presentation_index < self.presentations_count - 1:
@@ -321,6 +471,13 @@ def slide_changed_callback(self) -> None:
321471
count = self.current_slides_count
322472
self.info.slide_label.setText(f"{index+1:4d}/{count:4<d}")
323473
self.info.slide_notes.setText(self.current_slide_config.notes)
474+
self.preview_next_slide()
475+
476+
def preview_next_slide(self) -> None:
477+
if slide_config := self.next_slide_config:
478+
url = QUrl.fromLocalFile(slide_config.file)
479+
self.info.next_media_player.setSource(url)
480+
self.info.next_media_player.play()
324481

325482
def show(self) -> None:
326483
super().show()
@@ -331,6 +488,7 @@ def show(self) -> None:
331488
@Slot()
332489
def close(self) -> None:
333490
logger.info("Closing gracefully...")
491+
self.info.close()
334492
super().close()
335493

336494
@Slot()
@@ -353,6 +511,7 @@ def previous(self) -> None:
353511
@Slot()
354512
def reverse(self) -> None:
355513
self.load_reversed_slide()
514+
self.preview_next_slide()
356515

357516
@Slot()
358517
def replay(self) -> None:
@@ -381,9 +540,11 @@ def hide_mouse(self) -> None:
381540
else:
382541
self.setCursor(Qt.BlankCursor)
383542

543+
@Slot()
384544
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
385545
self.close()
386546

547+
@Slot()
387548
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
388549
key = event.key()
389550
self.dispatch(key)

manim_slides/render.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def render(ce: bool, gl: bool, args: Tuple[str, ...]) -> None:
4343
"""
4444
Render SCENE(s) from the input FILE, using the specified renderer.
4545
46-
Use 'manim-slides render --help' to see help information for
46+
Use ``manim-slides render --help`` to see help information for
4747
a the specified renderer.
4848
"""
4949
if ce and gl:

manim_slides/slide/base.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -289,10 +289,14 @@ def next_slide(
289289
290290
Note that this is only supported by ``manim-slides present``.
291291
:param notes:
292-
Presenter notes, in HTML format.
292+
Presenter notes, in Markdown format.
293+
294+
Note that PowerPoint does not support Markdown.
293295
294296
Note that this is only supported by ``manim-slides present``
295-
and ``manim-slides convert --to=html``.
297+
and ``manim-slides convert --to=html/pptx``.
298+
:param dedent_notes:
299+
If set, apply :func:`textwrap.dedent` to notes.
296300
:param kwargs:
297301
Keyword arguments to be passed to
298302
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,

manim_slides/templates/revealjs.html

+3-2
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
4242
{%- endif -%}>
4343
{% if slide_config.notes != "" -%}
44-
<aside class="notes">{{ slide_config.notes }}</aside>
44+
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
4545
{%- endif %}
4646
</section>
4747
{%- endfor -%}
@@ -54,14 +54,15 @@
5454
<!-- To include plugins, see: https://revealjs.com/plugins/ -->
5555

5656
{% if has_notes -%}
57+
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/markdown/markdown.min.js"></script>
5758
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script>
5859
{%- endif -%}
5960

6061
<!-- <script src="index.js"></script> -->
6162
<script>
6263
Reveal.initialize({
6364
{% if has_notes -%}
64-
plugins: [ RevealNotes ],
65+
plugins: [ RevealMarkdown, RevealNotes ],
6566
{%- endif %}
6667
// The "normal" size of the presentation, aspect ratio will
6768
// be preserved when the presentation is scaled to fit different

0 commit comments

Comments
 (0)