presently 0.1.0

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 (215) hide show
  1. checksums.yaml +7 -0
  2. data/bin/presently +13 -0
  3. data/lib/presently/application.rb +104 -0
  4. data/lib/presently/clock.rb +77 -0
  5. data/lib/presently/display_view.rb +73 -0
  6. data/lib/presently/environment/application.rb +62 -0
  7. data/lib/presently/page.rb +38 -0
  8. data/lib/presently/page.xrb +31 -0
  9. data/lib/presently/presentation.rb +72 -0
  10. data/lib/presently/presentation_controller.rb +181 -0
  11. data/lib/presently/presenter_view.rb +264 -0
  12. data/lib/presently/slide.rb +148 -0
  13. data/lib/presently/slide_view.rb +92 -0
  14. data/lib/presently/state.rb +66 -0
  15. data/lib/presently/version.rb +9 -0
  16. data/lib/presently.rb +9 -0
  17. data/license.md +21 -0
  18. data/public/_components/@socketry/syntax/Syntax/CodeElement.js +337 -0
  19. data/public/_components/@socketry/syntax/Syntax/Errors.js +52 -0
  20. data/public/_components/@socketry/syntax/Syntax/Language/apache.js +49 -0
  21. data/public/_components/@socketry/syntax/Syntax/Language/applescript.js +157 -0
  22. data/public/_components/@socketry/syntax/Syntax/Language/assembly.js +42 -0
  23. data/public/_components/@socketry/syntax/Syntax/Language/bash-script.js +108 -0
  24. data/public/_components/@socketry/syntax/Syntax/Language/bash.js +32 -0
  25. data/public/_components/@socketry/syntax/Syntax/Language/basic.js +232 -0
  26. data/public/_components/@socketry/syntax/Syntax/Language/c++.js +1 -0
  27. data/public/_components/@socketry/syntax/Syntax/Language/c.js +1 -0
  28. data/public/_components/@socketry/syntax/Syntax/Language/clang.js +201 -0
  29. data/public/_components/@socketry/syntax/Syntax/Language/cpp.js +1 -0
  30. data/public/_components/@socketry/syntax/Syntax/Language/csharp.js +166 -0
  31. data/public/_components/@socketry/syntax/Syntax/Language/css.js +244 -0
  32. data/public/_components/@socketry/syntax/Syntax/Language/diff.js +24 -0
  33. data/public/_components/@socketry/syntax/Syntax/Language/go.js +135 -0
  34. data/public/_components/@socketry/syntax/Syntax/Language/haskell.js +110 -0
  35. data/public/_components/@socketry/syntax/Syntax/Language/html.js +69 -0
  36. data/public/_components/@socketry/syntax/Syntax/Language/io.js +68 -0
  37. data/public/_components/@socketry/syntax/Syntax/Language/java.js +134 -0
  38. data/public/_components/@socketry/syntax/Syntax/Language/javascript.js +89 -0
  39. data/public/_components/@socketry/syntax/Syntax/Language/json.js +36 -0
  40. data/public/_components/@socketry/syntax/Syntax/Language/lisp.js +38 -0
  41. data/public/_components/@socketry/syntax/Syntax/Language/lua.js +87 -0
  42. data/public/_components/@socketry/syntax/Syntax/Language/markdown.js +112 -0
  43. data/public/_components/@socketry/syntax/Syntax/Language/nginx.js +37 -0
  44. data/public/_components/@socketry/syntax/Syntax/Language/objective-c.js +1 -0
  45. data/public/_components/@socketry/syntax/Syntax/Language/ocaml.js +225 -0
  46. data/public/_components/@socketry/syntax/Syntax/Language/pascal.js +166 -0
  47. data/public/_components/@socketry/syntax/Syntax/Language/patch.js +2 -0
  48. data/public/_components/@socketry/syntax/Syntax/Language/perl5.js +317 -0
  49. data/public/_components/@socketry/syntax/Syntax/Language/php-script.js +112 -0
  50. data/public/_components/@socketry/syntax/Syntax/Language/php.js +18 -0
  51. data/public/_components/@socketry/syntax/Syntax/Language/plain.js +20 -0
  52. data/public/_components/@socketry/syntax/Syntax/Language/protobuf.js +77 -0
  53. data/public/_components/@socketry/syntax/Syntax/Language/python.js +208 -0
  54. data/public/_components/@socketry/syntax/Syntax/Language/ruby.js +124 -0
  55. data/public/_components/@socketry/syntax/Syntax/Language/scala.js +81 -0
  56. data/public/_components/@socketry/syntax/Syntax/Language/smalltalk.js +30 -0
  57. data/public/_components/@socketry/syntax/Syntax/Language/sql.js +865 -0
  58. data/public/_components/@socketry/syntax/Syntax/Language/super-collider.js +70 -0
  59. data/public/_components/@socketry/syntax/Syntax/Language/swift.js +176 -0
  60. data/public/_components/@socketry/syntax/Syntax/Language/xml.js +76 -0
  61. data/public/_components/@socketry/syntax/Syntax/Language/xrb.js +33 -0
  62. data/public/_components/@socketry/syntax/Syntax/Language/yaml.js +29 -0
  63. data/public/_components/@socketry/syntax/Syntax/Language.js +276 -0
  64. data/public/_components/@socketry/syntax/Syntax/Loader.js +78 -0
  65. data/public/_components/@socketry/syntax/Syntax/Match.js +546 -0
  66. data/public/_components/@socketry/syntax/Syntax/Rule.js +306 -0
  67. data/public/_components/@socketry/syntax/Syntax.js +356 -0
  68. data/public/_components/@socketry/syntax/bin/syntax-ast.js +42 -0
  69. data/public/_components/@socketry/syntax/examples/_template.html +53 -0
  70. data/public/_components/@socketry/syntax/examples/apache.html +72 -0
  71. data/public/_components/@socketry/syntax/examples/applescript.html +72 -0
  72. data/public/_components/@socketry/syntax/examples/assembly.html +74 -0
  73. data/public/_components/@socketry/syntax/examples/bash.html +90 -0
  74. data/public/_components/@socketry/syntax/examples/basic.html +87 -0
  75. data/public/_components/@socketry/syntax/examples/c.html +141 -0
  76. data/public/_components/@socketry/syntax/examples/clang.html +202 -0
  77. data/public/_components/@socketry/syntax/examples/csharp.html +110 -0
  78. data/public/_components/@socketry/syntax/examples/css-colors.html +179 -0
  79. data/public/_components/@socketry/syntax/examples/custom-theme.html +155 -0
  80. data/public/_components/@socketry/syntax/examples/diff.html +142 -0
  81. data/public/_components/@socketry/syntax/examples/examples.css +216 -0
  82. data/public/_components/@socketry/syntax/examples/go.html +413 -0
  83. data/public/_components/@socketry/syntax/examples/haskell.html +373 -0
  84. data/public/_components/@socketry/syntax/examples/html.html +316 -0
  85. data/public/_components/@socketry/syntax/examples/index.html +97 -0
  86. data/public/_components/@socketry/syntax/examples/io.html +552 -0
  87. data/public/_components/@socketry/syntax/examples/java.html +786 -0
  88. data/public/_components/@socketry/syntax/examples/javascript.html +199 -0
  89. data/public/_components/@socketry/syntax/examples/json.html +150 -0
  90. data/public/_components/@socketry/syntax/examples/lisp.html +476 -0
  91. data/public/_components/@socketry/syntax/examples/lua.html +737 -0
  92. data/public/_components/@socketry/syntax/examples/markdown.html +121 -0
  93. data/public/_components/@socketry/syntax/examples/mixed.html +306 -0
  94. data/public/_components/@socketry/syntax/examples/nginx.html +554 -0
  95. data/public/_components/@socketry/syntax/examples/ocaml.html +596 -0
  96. data/public/_components/@socketry/syntax/examples/pascal.html +762 -0
  97. data/public/_components/@socketry/syntax/examples/perl5.html +488 -0
  98. data/public/_components/@socketry/syntax/examples/php-script.html +142 -0
  99. data/public/_components/@socketry/syntax/examples/php.html +95 -0
  100. data/public/_components/@socketry/syntax/examples/plain.html +222 -0
  101. data/public/_components/@socketry/syntax/examples/protobuf.html +405 -0
  102. data/public/_components/@socketry/syntax/examples/python.html +82 -0
  103. data/public/_components/@socketry/syntax/examples/readme.md +79 -0
  104. data/public/_components/@socketry/syntax/examples/ruby.html +58 -0
  105. data/public/_components/@socketry/syntax/examples/scala.html +41 -0
  106. data/public/_components/@socketry/syntax/examples/smalltalk.html +436 -0
  107. data/public/_components/@socketry/syntax/examples/sql.html +373 -0
  108. data/public/_components/@socketry/syntax/examples/super-collider.html +55 -0
  109. data/public/_components/@socketry/syntax/examples/swift.html +176 -0
  110. data/public/_components/@socketry/syntax/examples/wrap-demo.html +103 -0
  111. data/public/_components/@socketry/syntax/examples/xml.html +112 -0
  112. data/public/_components/@socketry/syntax/examples/xrb.html +37 -0
  113. data/public/_components/@socketry/syntax/examples/yaml.html +72 -0
  114. data/public/_components/@socketry/syntax/license.md +21 -0
  115. data/public/_components/@socketry/syntax/package-lock.json +834 -0
  116. data/public/_components/@socketry/syntax/package.json +43 -0
  117. data/public/_components/@socketry/syntax/readme.md +162 -0
  118. data/public/_components/@socketry/syntax/test/Syntax/CodeElement.js +306 -0
  119. data/public/_components/@socketry/syntax/test/Syntax/ErrorHandling.js +85 -0
  120. data/public/_components/@socketry/syntax/test/Syntax/Language/apache.js +153 -0
  121. data/public/_components/@socketry/syntax/test/Syntax/Language/applescript.js +198 -0
  122. data/public/_components/@socketry/syntax/test/Syntax/Language/assembly.js +209 -0
  123. data/public/_components/@socketry/syntax/test/Syntax/Language/bash-script.js +225 -0
  124. data/public/_components/@socketry/syntax/test/Syntax/Language/bash.js +162 -0
  125. data/public/_components/@socketry/syntax/test/Syntax/Language/basic.js +265 -0
  126. data/public/_components/@socketry/syntax/test/Syntax/Language/clang.js +390 -0
  127. data/public/_components/@socketry/syntax/test/Syntax/Language/csharp.js +436 -0
  128. data/public/_components/@socketry/syntax/test/Syntax/Language/css.js +431 -0
  129. data/public/_components/@socketry/syntax/test/Syntax/Language/diff.js +206 -0
  130. data/public/_components/@socketry/syntax/test/Syntax/Language/go.js +386 -0
  131. data/public/_components/@socketry/syntax/test/Syntax/Language/haskell.js +454 -0
  132. data/public/_components/@socketry/syntax/test/Syntax/Language/html.js +111 -0
  133. data/public/_components/@socketry/syntax/test/Syntax/Language/io.js +229 -0
  134. data/public/_components/@socketry/syntax/test/Syntax/Language/java.js +362 -0
  135. data/public/_components/@socketry/syntax/test/Syntax/Language/javascript.js +101 -0
  136. data/public/_components/@socketry/syntax/test/Syntax/Language/json.js +101 -0
  137. data/public/_components/@socketry/syntax/test/Syntax/Language/lisp.js +224 -0
  138. data/public/_components/@socketry/syntax/test/Syntax/Language/lua.js +307 -0
  139. data/public/_components/@socketry/syntax/test/Syntax/Language/markdown.js +163 -0
  140. data/public/_components/@socketry/syntax/test/Syntax/Language/nginx.js +267 -0
  141. data/public/_components/@socketry/syntax/test/Syntax/Language/ocaml.js +299 -0
  142. data/public/_components/@socketry/syntax/test/Syntax/Language/pascal.js +311 -0
  143. data/public/_components/@socketry/syntax/test/Syntax/Language/perl5.js +333 -0
  144. data/public/_components/@socketry/syntax/test/Syntax/Language/php-script.js +197 -0
  145. data/public/_components/@socketry/syntax/test/Syntax/Language/php.js +92 -0
  146. data/public/_components/@socketry/syntax/test/Syntax/Language/plain.js +327 -0
  147. data/public/_components/@socketry/syntax/test/Syntax/Language/protobuf.js +294 -0
  148. data/public/_components/@socketry/syntax/test/Syntax/Language/python.js +213 -0
  149. data/public/_components/@socketry/syntax/test/Syntax/Language/ruby.js +70 -0
  150. data/public/_components/@socketry/syntax/test/Syntax/Language/scala.js +75 -0
  151. data/public/_components/@socketry/syntax/test/Syntax/Language/smalltalk.js +223 -0
  152. data/public/_components/@socketry/syntax/test/Syntax/Language/sql.js +281 -0
  153. data/public/_components/@socketry/syntax/test/Syntax/Language/super-collider.js +66 -0
  154. data/public/_components/@socketry/syntax/test/Syntax/Language/swift.js +71 -0
  155. data/public/_components/@socketry/syntax/test/Syntax/Language/xml.js +170 -0
  156. data/public/_components/@socketry/syntax/test/Syntax/Language/xrb.js +57 -0
  157. data/public/_components/@socketry/syntax/test/Syntax/Language/yaml.js +123 -0
  158. data/public/_components/@socketry/syntax/test/Syntax/Language.js +62 -0
  159. data/public/_components/@socketry/syntax/test/Syntax/Match.js +40 -0
  160. data/public/_components/@socketry/syntax/test/Syntax/Rule.js +251 -0
  161. data/public/_components/@socketry/syntax/test/Syntax.js +38 -0
  162. data/public/_components/@socketry/syntax/test/helpers/ast-matcher.js +90 -0
  163. data/public/_components/@socketry/syntax/themes/base/apache.css +1 -0
  164. data/public/_components/@socketry/syntax/themes/base/applescript.css +1 -0
  165. data/public/_components/@socketry/syntax/themes/base/assembly.css +1 -0
  166. data/public/_components/@socketry/syntax/themes/base/bash.css +1 -0
  167. data/public/_components/@socketry/syntax/themes/base/basic.css +1 -0
  168. data/public/_components/@socketry/syntax/themes/base/c.css +1 -0
  169. data/public/_components/@socketry/syntax/themes/base/clang.css +0 -0
  170. data/public/_components/@socketry/syntax/themes/base/csharp.css +1 -0
  171. data/public/_components/@socketry/syntax/themes/base/css.css +22 -0
  172. data/public/_components/@socketry/syntax/themes/base/diff.css +48 -0
  173. data/public/_components/@socketry/syntax/themes/base/go.css +1 -0
  174. data/public/_components/@socketry/syntax/themes/base/haskell.css +1 -0
  175. data/public/_components/@socketry/syntax/themes/base/html.css +1 -0
  176. data/public/_components/@socketry/syntax/themes/base/io.css +1 -0
  177. data/public/_components/@socketry/syntax/themes/base/java.css +1 -0
  178. data/public/_components/@socketry/syntax/themes/base/javascript.css +1 -0
  179. data/public/_components/@socketry/syntax/themes/base/json.css +41 -0
  180. data/public/_components/@socketry/syntax/themes/base/lisp.css +1 -0
  181. data/public/_components/@socketry/syntax/themes/base/lua.css +1 -0
  182. data/public/_components/@socketry/syntax/themes/base/markdown.css +16 -0
  183. data/public/_components/@socketry/syntax/themes/base/nginx.css +1 -0
  184. data/public/_components/@socketry/syntax/themes/base/ocaml.css +1 -0
  185. data/public/_components/@socketry/syntax/themes/base/pascal.css +1 -0
  186. data/public/_components/@socketry/syntax/themes/base/perl5.css +1 -0
  187. data/public/_components/@socketry/syntax/themes/base/php-script.css +1 -0
  188. data/public/_components/@socketry/syntax/themes/base/php.css +1 -0
  189. data/public/_components/@socketry/syntax/themes/base/plain.css +1 -0
  190. data/public/_components/@socketry/syntax/themes/base/protobuf.css +1 -0
  191. data/public/_components/@socketry/syntax/themes/base/python.css +1 -0
  192. data/public/_components/@socketry/syntax/themes/base/ruby.css +23 -0
  193. data/public/_components/@socketry/syntax/themes/base/scala.css +3 -0
  194. data/public/_components/@socketry/syntax/themes/base/smalltalk.css +1 -0
  195. data/public/_components/@socketry/syntax/themes/base/sql.css +1 -0
  196. data/public/_components/@socketry/syntax/themes/base/super-collider.css +33 -0
  197. data/public/_components/@socketry/syntax/themes/base/swift.css +1 -0
  198. data/public/_components/@socketry/syntax/themes/base/syntax.css +63 -0
  199. data/public/_components/@socketry/syntax/themes/base/xml.css +1 -0
  200. data/public/_components/@socketry/syntax/themes/base/xrb.css +29 -0
  201. data/public/_components/@socketry/syntax/themes/base/yaml.css +1 -0
  202. data/public/_components/@socketry/syntax/themes/theming.md +233 -0
  203. data/public/_components/@socketry/syntax/update-examples.js +135 -0
  204. data/public/_static/index.css +593 -0
  205. data/public/application.js +147 -0
  206. data/readme.md +69 -0
  207. data/releases.md +3 -0
  208. data/templates/code.xrb +12 -0
  209. data/templates/default.xrb +5 -0
  210. data/templates/image.xrb +8 -0
  211. data/templates/section.xrb +5 -0
  212. data/templates/title.xrb +8 -0
  213. data/templates/translation.xrb +8 -0
  214. data/templates/two_column.xrb +8 -0
  215. metadata +280 -0
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "live"
7
+ require_relative "slide_view"
8
+
9
+ module Presently
10
+ # The presenter-facing view with notes, timing, and slide previews.
11
+ #
12
+ # Shows the current slide, next slide preview, presenter notes, timing controls,
13
+ # and pacing indicators. Updates the timing display every second via a background task.
14
+ class PresenterView < Live::View
15
+ # Initialize a new presenter view.
16
+ # @parameter id [String] The unique element identifier.
17
+ # @parameter data [Hash] The element data attributes.
18
+ # @parameter controller [PresentationController | Nil] The shared presentation controller.
19
+ def initialize(id = Live::Element.unique_id, data = {}, controller: nil)
20
+ super(id, data)
21
+ @controller = controller
22
+ @clock_task = nil
23
+ end
24
+
25
+ # Bind this view to a page and start the timing update loop.
26
+ # @parameter page [Live::Page] The page this view is bound to.
27
+ def bind(page)
28
+ super
29
+ @controller.add_listener(self)
30
+
31
+ # Update only the timing section every second.
32
+ @clock_task = Async do
33
+ while true
34
+ update_timing!
35
+ sleep 1
36
+ end
37
+ end
38
+ end
39
+
40
+ # Close this view and stop the timing update loop.
41
+ def close
42
+ @clock_task&.stop
43
+ @controller.remove_listener(self)
44
+ super
45
+ end
46
+
47
+ # Called by the controller when the slide changes.
48
+ def slide_changed!
49
+ self.update!
50
+ end
51
+
52
+ # Push an update to just the timing section.
53
+ def update_timing!
54
+ replace(".timing") do |builder|
55
+ render_timing(builder, @controller.current_slide)
56
+ end
57
+ end
58
+
59
+ # Handle an event from the client.
60
+ # @parameter event [Hash] The event data with `:detail` containing the action.
61
+ def handle(event)
62
+ action = event.dig(:detail, :action)
63
+
64
+ case action
65
+ when "next"
66
+ @controller.advance!
67
+ when "previous"
68
+ @controller.retreat!
69
+ when "pause"
70
+ if !@controller.clock.started?
71
+ @controller.clock.start!
72
+ elsif @controller.clock.paused?
73
+ @controller.clock.resume!
74
+ else
75
+ @controller.clock.pause!
76
+ end
77
+ @controller.save_state!
78
+ when "reset"
79
+ @controller.reset_timer!
80
+ when "reload"
81
+ @controller.reload!
82
+ when "jump"
83
+ if index = event.dig(:detail, :index)
84
+ @controller.go_to(index.to_i)
85
+ end
86
+ end
87
+ end
88
+
89
+ # Render the timing bar with controls, elapsed/remaining time, and pacing.
90
+ # @parameter builder [XRB::Builder] The HTML builder.
91
+ # @parameter slide [Slide | Nil] The current slide.
92
+ def render_timing(builder, slide)
93
+ progress = (@controller.slide_progress * 100).round(1)
94
+ builder.tag(:div, class: "timing", style: "--slide-progress: #{progress}%") do
95
+ pacing = @controller.pacing
96
+ pacing_class = case pacing
97
+ when :behind then "behind"
98
+ when :ahead then "ahead"
99
+ else "on-time"
100
+ end
101
+
102
+ builder.tag(:div, class: "timing-info #{pacing_class}") do
103
+ builder.tag(:button,
104
+ class: "pause-button",
105
+ onClick: forward_event(action: "pause")
106
+ ) do
107
+ label = if !@controller.clock.started?
108
+ "▶ Start"
109
+ elsif @controller.clock.paused?
110
+ "▶ Resume"
111
+ else
112
+ "⏸ Pause"
113
+ end
114
+ builder.text(label)
115
+ end
116
+
117
+ builder.tag(:button,
118
+ class: "pause-button",
119
+ onClick: forward_event(action: "reset")
120
+ ) do
121
+ builder.text("↺ Reset")
122
+ end
123
+
124
+ builder.tag(:span, class: "elapsed") do
125
+ builder.text("Elapsed: #{format_duration(@controller.clock.elapsed)}")
126
+ end
127
+
128
+ builder.tag(:span, class: "remaining") do
129
+ builder.text("Remaining: #{format_duration(@controller.time_remaining)}")
130
+ end
131
+
132
+ builder.tag(:span, class: "pacing-indicator") do
133
+ indicator = case pacing
134
+ when :behind then "⏩ Speed up"
135
+ when :ahead then "⏪ Slow down"
136
+ else "✓ On time"
137
+ end
138
+ builder.text(indicator)
139
+ end
140
+
141
+ if slide
142
+ builder.tag(:span, class: "slide-duration") do
143
+ builder.text("Slide: #{format_duration(slide.duration)}")
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+
150
+ # Format a duration in seconds as `M:SS`.
151
+ # @parameter seconds [Numeric] The duration in seconds.
152
+ # @returns [String] The formatted duration string.
153
+ def format_duration(seconds)
154
+ seconds = seconds.to_i
155
+ minutes = seconds / 60
156
+ secs = seconds % 60
157
+ format("%d:%02d", minutes, secs)
158
+ end
159
+
160
+ # Render the full presenter view.
161
+ # @parameter builder [XRB::Builder] The HTML builder.
162
+ def render(builder)
163
+ slide = @controller.current_slide
164
+ next_slide = @controller.next_slide
165
+
166
+ builder.tag(:div, class: "presenter") do
167
+ # Controls bar
168
+ builder.tag(:div, class: "controls") do
169
+ builder.tag(:button,
170
+ onClick: forward_event(action: "previous")
171
+ ) do
172
+ builder.text("← Previous")
173
+ end
174
+
175
+ builder.tag(:span, class: "slide-info") do
176
+ builder.text("Slide #{@controller.current_index + 1} of #{@controller.slide_count}")
177
+ end
178
+
179
+ builder.tag(:button,
180
+ onClick: forward_event(action: "next")
181
+ ) do
182
+ builder.text("Next →")
183
+ end
184
+
185
+ # Jump-to dropdown for marked slides
186
+ markers = []
187
+ @controller.slides.each_with_index do |s, i|
188
+ if s.marker
189
+ markers << [i, s.marker]
190
+ end
191
+ end
192
+
193
+ unless markers.empty?
194
+ builder.tag(:select,
195
+ class: "jump-to",
196
+ onChange: "live.forwardEvent(#{JSON.dump(@id)}, event, {action: 'jump', index: parseInt(this.value)}); this.value = '';"
197
+ ) do
198
+ builder.tag(:option, value: "", disabled: true, selected: true) do
199
+ builder.text("Jump to…")
200
+ end
201
+
202
+ markers.each do |index, label|
203
+ builder.tag(:option, value: index) do
204
+ builder.text(label)
205
+ end
206
+ end
207
+ end
208
+ end
209
+
210
+ builder.tag(:button,
211
+ onClick: forward_event(action: "reload"),
212
+ class: "reload"
213
+ ) do
214
+ builder.text("↻ Reload")
215
+ end
216
+ end
217
+
218
+ # Slide previews
219
+ builder.tag(:div, class: "previews") do
220
+ # Current slide
221
+ builder.tag(:div, class: "preview current-preview") do
222
+ builder.tag(:h3){builder.text("Current")}
223
+ builder.tag(:div, class: "preview-frame") do
224
+ if slide
225
+ renderer = SlideView.new(css_class: "slide preview-slide", controller: @controller)
226
+ renderer.render_slide(builder, slide)
227
+ end
228
+ end
229
+ end
230
+
231
+ # Next slide
232
+ builder.tag(:div, class: "preview next-preview") do
233
+ builder.tag(:h3){builder.text("Next")}
234
+ builder.tag(:div, class: "preview-frame") do
235
+ if next_slide
236
+ renderer = SlideView.new(css_class: "slide preview-slide", controller: @controller)
237
+ renderer.render_slide(builder, next_slide)
238
+ else
239
+ builder.tag(:div, class: "no-slide") do
240
+ builder.text("End of presentation")
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
246
+
247
+ # Timing
248
+ render_timing(builder, slide)
249
+
250
+ # Presenter notes
251
+ builder.tag(:div, class: "notes") do
252
+ builder.tag(:h3){builder.text("Notes")}
253
+ builder.tag(:div, class: "notes-content") do
254
+ if slide&.notes
255
+ builder.raw(slide.notes)
256
+ else
257
+ builder.tag(:p, class: "no-notes"){builder.text("No presenter notes for this slide.")}
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "yaml"
7
+ require "markly"
8
+
9
+ module Presently
10
+ # A single slide parsed from a Markdown file.
11
+ #
12
+ # Each slide has YAML frontmatter for metadata (template, duration, focus), content sections
13
+ # split by Markdown headings, and optional presenter notes separated by `---`.
14
+ class Slide
15
+ # Initialize a new slide by parsing the given Markdown file.
16
+ # @parameter path [String] The file path to the Markdown slide.
17
+ def initialize(path)
18
+ @path = path
19
+ @frontmatter = nil
20
+ @content = nil
21
+ @notes = nil
22
+
23
+ parse!
24
+ end
25
+
26
+ # @attribute [String] The file path of the slide.
27
+ attr :path
28
+
29
+ # @attribute [Hash | Nil] The parsed YAML frontmatter.
30
+ attr :frontmatter
31
+
32
+ # @attribute [Hash(String, String)] The content sections keyed by heading name.
33
+ attr :content
34
+
35
+ # @attribute [String | Nil] The rendered HTML presenter notes.
36
+ attr :notes
37
+
38
+ # The template to use for rendering this slide.
39
+ # @returns [String] The template name from frontmatter, or `"default"`.
40
+ def template
41
+ @frontmatter&.fetch("template", "default") || "default"
42
+ end
43
+
44
+ # The expected duration of this slide in seconds.
45
+ # @returns [Integer] The duration from frontmatter, or `60`.
46
+ def duration
47
+ @frontmatter&.fetch("duration", 60) || 60
48
+ end
49
+
50
+ # The title of this slide.
51
+ # @returns [String] The title from frontmatter, or the filename without extension.
52
+ def title
53
+ @frontmatter&.fetch("title", File.basename(@path, ".md")) || File.basename(@path, ".md")
54
+ end
55
+
56
+ # The navigation marker for this slide, used in the presenter's jump-to dropdown.
57
+ # @returns [String | Nil] The marker label, or `nil` if not marked.
58
+ def marker
59
+ @frontmatter&.fetch("marker", nil)
60
+ end
61
+
62
+ # The transition type for animating into this slide.
63
+ # @returns [String | Nil] The transition name (e.g. `"fade"`, `"slide-left"`, `"magic-move"`), or `nil` for instant swap.
64
+ def transition
65
+ @frontmatter&.fetch("transition", nil)
66
+ end
67
+
68
+ # The line range to focus on for code slides.
69
+ # @returns [Array(Integer, Integer) | Nil] The `[start, end]` line numbers (1-based), or `nil`.
70
+ def focus
71
+ if range = @frontmatter&.fetch("focus", nil)
72
+ parts = range.to_s.split("-").map(&:to_i)
73
+ parts.length == 2 ? parts : nil
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ # Parse the Markdown file into frontmatter, content sections, and notes.
80
+ def parse!
81
+ raw = File.read(@path)
82
+
83
+ # Extract YAML frontmatter:
84
+ if raw.start_with?("---\n")
85
+ parts = raw.split("---\n", 3)
86
+ if parts.length >= 3
87
+ @frontmatter = YAML.safe_load(parts[1])
88
+ body = parts[2]
89
+ else
90
+ body = raw
91
+ end
92
+ else
93
+ body = raw
94
+ end
95
+
96
+ # Split content and presenter notes (notes come after "---" on its own line):
97
+ if body.include?("\n---\n")
98
+ content_part, notes_part = body.split("\n---\n", 2)
99
+ @content = parse_sections(content_part.strip)
100
+ @notes = render_markdown(notes_part.strip)
101
+ else
102
+ @content = parse_sections(body.strip)
103
+ @notes = nil
104
+ end
105
+ end
106
+
107
+ # Parse content into sections based on Markdown headings.
108
+ # Each heading becomes a named key for the template.
109
+ # @parameter text [String] The Markdown content to parse.
110
+ # @returns [Hash(String, String)] Sections keyed by heading name, with rendered HTML values.
111
+ def parse_sections(text)
112
+ sections = {}
113
+ current_key = "body"
114
+ current_content = []
115
+
116
+ text.each_line do |line|
117
+ if line.match?(/\A#+\s+/)
118
+ # Save previous section:
119
+ unless current_content.empty?
120
+ sections[current_key] = render_markdown(current_content.join)
121
+ end
122
+
123
+ # Extract heading text as the key:
124
+ heading_text = line.sub(/\A#+\s+/, "").strip.downcase.gsub(/\s+/, "_")
125
+ current_key = heading_text
126
+ current_content = []
127
+ else
128
+ current_content << line
129
+ end
130
+ end
131
+
132
+ # Save last section:
133
+ unless current_content.empty?
134
+ sections[current_key] = render_markdown(current_content.join)
135
+ end
136
+
137
+ sections
138
+ end
139
+
140
+ # Render Markdown text to HTML.
141
+ # @parameter text [String] The Markdown text.
142
+ # @returns [String] The rendered HTML.
143
+ def render_markdown(text)
144
+ return "" if text.nil? || text.empty?
145
+ Markly.render_html(text, flags: Markly::UNSAFE)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "live"
7
+ require "xrb/template"
8
+ require "xrb/markup"
9
+
10
+ module Presently
11
+ # The default directory containing bundled slide templates.
12
+ DEFAULT_TEMPLATES_ROOT = File.expand_path("../../templates", __dir__)
13
+
14
+ # Renders a single slide using its XRB template.
15
+ #
16
+ # Loads templates from the configured templates root and renders slides
17
+ # by passing their content sections to the template via {TemplateScope}.
18
+ class SlideView < Live::View
19
+ # Initialize a new slide view.
20
+ # @parameter id [String] The unique element identifier.
21
+ # @parameter data [Hash] The element data attributes.
22
+ # @parameter css_class [String] The CSS class for the slide container.
23
+ # @parameter controller [PresentationController | Nil] The controller to get the templates root from.
24
+ def initialize(id = Live::Element.unique_id, data = {}, css_class: "slide", controller: nil)
25
+ super(id, data)
26
+ @css_class = css_class
27
+ @templates_root = controller&.templates_root || DEFAULT_TEMPLATES_ROOT
28
+ @templates = {}
29
+ end
30
+
31
+ # Load and cache a template by name.
32
+ # @parameter name [String] The template name (without extension).
33
+ # @returns [XRB::Template] The loaded template.
34
+ def template_for(name)
35
+ @templates[name] ||= begin
36
+ path = File.join(@templates_root, "#{name}.xrb")
37
+ XRB::Template.load_file(path)
38
+ end
39
+ end
40
+
41
+ # Render a slide using its template into the builder.
42
+ # @parameter builder [XRB::Builder] The HTML builder.
43
+ # @parameter slide [Slide] The slide to render.
44
+ # @parameter extra_class [String | Nil] An additional CSS class for the container.
45
+ def render_slide(builder, slide, extra_class: nil)
46
+ return unless slide
47
+
48
+ template = template_for(slide.template)
49
+ scope = TemplateScope.new(slide)
50
+ html = template.to_string(scope)
51
+
52
+ classes = [@css_class, extra_class].compact.join(" ")
53
+ builder.tag(:div, class: classes, data: {template: slide.template}) do
54
+ builder.raw(html)
55
+ end
56
+ end
57
+
58
+ # Render the current slide.
59
+ # @parameter builder [XRB::Builder] The HTML builder.
60
+ def render(builder)
61
+ slide = nil
62
+ render_slide(builder, slide)
63
+ end
64
+ end
65
+
66
+ # Provides the scope for XRB template rendering.
67
+ #
68
+ # Templates access slide content via `self.section(name)` and slide metadata via `self.slide`.
69
+ class TemplateScope
70
+ # Initialize a new template scope for the given slide.
71
+ # @parameter slide [Slide] The slide being rendered.
72
+ def initialize(slide)
73
+ @slide = slide
74
+ end
75
+
76
+ # @attribute [Slide] The slide being rendered.
77
+ attr :slide
78
+
79
+ # The content sections of the slide.
80
+ # @returns [Hash(String, String)] Sections keyed by heading name.
81
+ def content
82
+ @slide.content
83
+ end
84
+
85
+ # Get a named content section as raw HTML markup.
86
+ # @parameter name [String] The section name (derived from the Markdown heading).
87
+ # @returns [XRB::MarkupString] The rendered HTML content, safe for embedding.
88
+ def section(name)
89
+ XRB::MarkupString.raw(@slide.content[name] || "")
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "json"
7
+
8
+ module Presently
9
+ # Persists and restores presentation controller state to/from a JSON file.
10
+ #
11
+ # Tracks the current slide index, clock elapsed time, and clock running state.
12
+ # This allows the presentation to survive server restarts without losing position.
13
+ class State
14
+ # The default state file path.
15
+ DEFAULT_PATH = ".presently.json"
16
+
17
+ # Initialize a new state instance.
18
+ # @parameter path [String] The file path for the state file.
19
+ def initialize(path = DEFAULT_PATH)
20
+ @path = path
21
+ end
22
+
23
+ # @attribute [String] The file path for the state file.
24
+ attr :path
25
+
26
+ # Save the controller's current state to disk.
27
+ # @parameter controller [PresentationController] The controller to save.
28
+ def save(controller)
29
+ data = {
30
+ current_index: controller.current_index,
31
+ elapsed: controller.clock.elapsed,
32
+ running: controller.clock.running?,
33
+ started: controller.clock.started?,
34
+ }
35
+
36
+ File.write(@path, JSON.pretty_generate(data))
37
+ rescue => error
38
+ Console.warn(self, "Failed to save state", error: error)
39
+ end
40
+
41
+ # Restore state into the given controller.
42
+ # @parameter controller [PresentationController] The controller to restore into.
43
+ def load(controller)
44
+ return unless File.exist?(@path)
45
+
46
+ data = JSON.parse(File.read(@path), symbolize_names: true)
47
+
48
+ # Restore slide position:
49
+ if index = data[:current_index]
50
+ controller.go_to(index.to_i)
51
+ end
52
+
53
+ # Restore clock state:
54
+ if data[:started]
55
+ controller.clock.start!
56
+ controller.clock.reset!(data[:elapsed].to_f)
57
+
58
+ unless data[:running]
59
+ controller.clock.pause!
60
+ end
61
+ end
62
+ rescue => error
63
+ Console.warn(self, "Failed to load state", error: error)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ # @namespace
7
+ module Presently
8
+ VERSION = "0.1.0"
9
+ end
data/lib/presently.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "presently/version"
7
+
8
+ require_relative "presently/application"
9
+ require_relative "presently/environment/application"
data/license.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright, 2026, by Samuel Williams.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.