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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 474e433100802366e483635b1255b07c3f6b287eb5d7d5a8e9e4a4300bba02c1
4
+ data.tar.gz: 79318b1e1f04856ca57dcde476c1bae8921e261ab018c7d75c1d8637dbecf06e
5
+ SHA512:
6
+ metadata.gz: 4d8b7743fe9f90c69f69a8905a93604d251ab4f64617a319cd26e223c8a3c750a712d3ed06a5d21c1554d5cb480345a0d008e6669c18b7ced1ef9f68e0fba813
7
+ data.tar.gz: 016f73a329b50181cb044a2561a097c48f3b5e8b504295d25577f034c712e0199a55251436ba7475d80b10843798be823888d5c648411a3f819899c7a647e1d5
data/bin/presently ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "async/service"
5
+ require_relative "../lib/presently/environment/application"
6
+
7
+ configuration = Async::Service::Configuration.build do
8
+ service "presently" do
9
+ include Presently::Environment::Application
10
+ end
11
+ end
12
+
13
+ Async::Service::Controller.run(configuration)
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "live"
7
+ require "lively"
8
+
9
+ require_relative "presentation"
10
+ require_relative "presentation_controller"
11
+ require_relative "state"
12
+ require_relative "display_view"
13
+ require_relative "presenter_view"
14
+ require_relative "page"
15
+
16
+ module Presently
17
+ # Extends {Live::Resolver} to pass shared state to views on construction.
18
+ #
19
+ # When the browser reconnects via WebSocket, the resolver creates new view
20
+ # instances with the shared {PresentationController} so all clients stay in sync.
21
+ class Resolver < Live::Resolver
22
+ # Initialize a new resolver with shared state.
23
+ # @parameter state [Hash] Key-value pairs to pass to view constructors.
24
+ def initialize(**state)
25
+ super()
26
+ @state = state
27
+ end
28
+
29
+ # @attribute [Hash] The shared state passed to view constructors.
30
+ attr :state
31
+
32
+ # Resolve a client-side element to a server-side instance with shared state.
33
+ # @parameter id [String] The unique element identifier.
34
+ # @parameter data [Hash] The element data attributes.
35
+ # @returns [Live::Element | Nil] The resolved element, or `nil`.
36
+ def call(id, data)
37
+ if klass = @allowed[data[:class]]
38
+ return klass.new(id, data, **@state)
39
+ end
40
+ end
41
+ end
42
+
43
+ # The main Presently application middleware.
44
+ #
45
+ # Handles routing for the display view (`/`), presenter view (`/presenter`),
46
+ # and WebSocket connections (`/live`). Creates a shared {PresentationController}
47
+ # that keeps all connected clients in sync.
48
+ class Application < Lively::Application
49
+ # Initialize a new Presently application.
50
+ # @parameter delegate [Protocol::HTTP::Middleware] The next middleware in the chain.
51
+ # @parameter slides_root [String] The directory containing slide files.
52
+ # @parameter templates_root [String | Nil] The directory containing custom templates.
53
+ # @parameter options [Hash] Additional options passed to the parent.
54
+ def initialize(delegate, slides_root: "slides", templates_root: nil, **options)
55
+ presentation = Presentation.load(slides_root, templates_root: templates_root)
56
+
57
+ state = State.new
58
+
59
+ resolver = Resolver.new(
60
+ controller: PresentationController.new(presentation, state: state),
61
+ ).tap do |resolver|
62
+ resolver.allow(DisplayView, PresenterView)
63
+ end
64
+
65
+ super(delegate, resolver: resolver, **options)
66
+ end
67
+
68
+ # The shared presentation controller.
69
+ # @returns [PresentationController] The controller instance.
70
+ def controller
71
+ resolver.state[:controller]
72
+ end
73
+
74
+ # The application title shown in the browser.
75
+ # @returns [String] The page title.
76
+ def title
77
+ "Presently"
78
+ end
79
+
80
+ # Create the body view for the given request path.
81
+ # @parameter request [Protocol::HTTP::Request] The incoming request.
82
+ # @returns [Live::View | Nil] The view for the path, or `nil` for unknown paths.
83
+ def body(request)
84
+ case request.path
85
+ when "/"
86
+ DisplayView.new(controller: controller)
87
+ when "/presenter"
88
+ PresenterView.new(controller: controller)
89
+ end
90
+ end
91
+
92
+ # Handle an HTTP request by rendering the appropriate page.
93
+ # @parameter request [Protocol::HTTP::Request] The incoming request.
94
+ # @returns [Protocol::HTTP::Response] The HTTP response.
95
+ def handle(request)
96
+ if body = self.body(request)
97
+ page = Page.new(title: title, body: body)
98
+ return Protocol::HTTP::Response[200, [], [page.call]]
99
+ else
100
+ return Protocol::HTTP::Response[404, [], ["Not Found"]]
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Presently
7
+ # A simple clock that tracks elapsed time with start, pause, resume, and reset.
8
+ #
9
+ # The clock accumulates elapsed time while running and freezes it when paused.
10
+ class Clock
11
+ # Initialize a new clock in the stopped state.
12
+ def initialize
13
+ @elapsed = 0
14
+ @running = false
15
+ @last_tick = nil
16
+ end
17
+
18
+ # Whether the clock has been started at least once.
19
+ # @returns [Boolean]
20
+ def started?
21
+ !@last_tick.nil?
22
+ end
23
+
24
+ # Whether the clock is currently running and accumulating time.
25
+ # @returns [Boolean]
26
+ def running?
27
+ @running
28
+ end
29
+
30
+ # Whether the clock has been started but is currently paused.
31
+ # @returns [Boolean]
32
+ def paused?
33
+ started? && !@running
34
+ end
35
+
36
+ # The total elapsed time in seconds.
37
+ # Includes time accumulated up to now if running, or frozen time if paused.
38
+ # @returns [Numeric] The elapsed time in seconds.
39
+ def elapsed
40
+ if @running
41
+ @elapsed + (Time.now - @last_tick)
42
+ else
43
+ @elapsed
44
+ end
45
+ end
46
+
47
+ # Start the clock. Begins accumulating time from now.
48
+ def start!
49
+ @running = true
50
+ @last_tick = Time.now
51
+ end
52
+
53
+ # Pause the clock. Freezes the elapsed time at the current value.
54
+ def pause!
55
+ return unless @running
56
+
57
+ @elapsed += Time.now - @last_tick
58
+ @running = false
59
+ end
60
+
61
+ # Resume the clock after a pause. Continues accumulating time from now.
62
+ def resume!
63
+ return if @running
64
+
65
+ @running = true
66
+ @last_tick = Time.now
67
+ end
68
+
69
+ # Reset the elapsed time to the given value.
70
+ # If running, continues from the new value. If paused, sets the frozen value.
71
+ # @parameter elapsed [Numeric] The new elapsed time in seconds.
72
+ def reset!(elapsed = 0)
73
+ @elapsed = elapsed
74
+ @last_tick = Time.now if @running
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,73 @@
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 audience-facing display view that renders the current slide full-screen.
11
+ #
12
+ # Connects to the {PresentationController} as a listener and updates
13
+ # whenever the slide changes. Pushes the current state on WebSocket reconnect.
14
+ class DisplayView < Live::View
15
+ # Initialize a new display 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
+ @slide_renderer = SlideView.new(css_class: "slide current", controller: controller)
23
+ end
24
+
25
+ # Bind this view to a page and register as a listener.
26
+ # Immediately pushes the current state to the client.
27
+ # @parameter page [Live::Page] The page this view is bound to.
28
+ def bind(page)
29
+ super
30
+ @controller.add_listener(self)
31
+ self.update!
32
+ end
33
+
34
+ # Close this view and unregister as a listener.
35
+ def close
36
+ @controller.remove_listener(self)
37
+ super
38
+ end
39
+
40
+ # Called by the controller when the slide changes.
41
+ def slide_changed!
42
+ self.update!
43
+ end
44
+
45
+ # Handle an event from the client.
46
+ # @parameter event [Hash] The event data with `:detail` containing the action.
47
+ def handle(event)
48
+ case event.dig(:detail, :action)
49
+ when "next"
50
+ @controller.advance!
51
+ when "previous"
52
+ @controller.retreat!
53
+ end
54
+ end
55
+
56
+ # Render the display view.
57
+ # @parameter builder [XRB::Builder] The HTML builder.
58
+ def render(builder)
59
+ slide = @controller.current_slide
60
+ return unless slide
61
+
62
+ builder.tag(:div, class: "display", data: {transition: slide.transition}) do
63
+ builder.tag(:div, class: "slide-container") do
64
+ @slide_renderer.render_slide(builder, slide)
65
+ end
66
+
67
+ builder.tag(:div, class: "slide-counter") do
68
+ builder.text("#{@controller.current_index + 1} / #{@controller.slide_count}")
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../application"
7
+ require "lively/environment/application"
8
+
9
+ module Presently
10
+ module Environment
11
+ # The environment configuration for a Presently application server.
12
+ #
13
+ # Extends the Lively environment with Presently-specific middleware and configuration.
14
+ # Override {#slides_root} and {#templates_root} to customize paths.
15
+ module Application
16
+ include Lively::Environment::Application
17
+
18
+ # The root directory containing slide Markdown files.
19
+ # @returns [String] Absolute path to the slides root.
20
+ def slides_root
21
+ File.expand_path("slides", self.root)
22
+ end
23
+
24
+ # The root directory containing slide templates.
25
+ # Defaults to the gem's bundled templates.
26
+ # @returns [String] Absolute path to the templates root.
27
+ def templates_root
28
+ File.expand_path("../../../templates", __dir__)
29
+ end
30
+
31
+ # The application class to use.
32
+ # @returns [Class] The Presently application class.
33
+ def application
34
+ Presently::Application
35
+ end
36
+
37
+ # Build the middleware stack with Presently's public assets.
38
+ # @returns [Protocol::HTTP::Middleware] The complete middleware stack.
39
+ def middleware
40
+ application = self.application
41
+ slides_root = self.slides_root
42
+ templates_root = self.templates_root
43
+ root = self.root
44
+
45
+ ::Protocol::HTTP::Middleware.build do |builder|
46
+ # Serve assets from the user's public directory:
47
+ builder.use Lively::Assets, root: File.expand_path("public", root)
48
+
49
+ # Serve Presently's bundled assets (syntax-js, CSS, etc.):
50
+ builder.use Lively::Assets, root: File.expand_path("../../../public", __dir__)
51
+
52
+ # Serve Lively's built-in assets (Live.js, morphdom, etc.):
53
+ builder.use Lively::Assets, root: File.expand_path("public", Gem.loaded_specs["lively"].full_gem_path)
54
+
55
+ builder.use application,
56
+ slides_root: slides_root,
57
+ templates_root: templates_root
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "xrb/template"
7
+
8
+ module Presently
9
+ # The HTML page shell for a Presently view.
10
+ #
11
+ # Renders the initial HTML page with the import map, stylesheets, and the
12
+ # embedded Live view component. Uses a custom XRB template that includes
13
+ # Presently's assets (syntax highlighting, etc.).
14
+ class Page
15
+ # The compiled XRB template for the page shell.
16
+ TEMPLATE = XRB::Template.load_file(File.expand_path("page.xrb", __dir__))
17
+
18
+ # Initialize a new page.
19
+ # @parameter title [String] The page title.
20
+ # @parameter body [Live::View | Nil] The Live view to embed in the page.
21
+ def initialize(title: "Presently", body: nil)
22
+ @title = title
23
+ @body = body
24
+ end
25
+
26
+ # @attribute [String] The page title.
27
+ attr :title
28
+
29
+ # @attribute [Live::View | Nil] The Live view to embed.
30
+ attr :body
31
+
32
+ # Render the page to an HTML string.
33
+ # @returns [String] The rendered HTML.
34
+ def call
35
+ TEMPLATE.to_string(self)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>#{self.title}</title>
5
+
6
+ <meta charset="UTF-8" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
8
+
9
+ <link rel="icon" type="image/png" href="/_static/icon.png" />
10
+ <link rel="stylesheet" href="/_static/site.css" type="text/css" media="screen" />
11
+ <link rel="stylesheet" href="/_static/index.css" type="text/css" media="screen" />
12
+ <link rel="stylesheet" href="/_components/@socketry/syntax/themes/base/syntax.css" type="text/css" media="screen" />
13
+
14
+ <script type="importmap">
15
+ {
16
+ "imports": {
17
+ "live": "/_components/@socketry/live/Live.js",
18
+ "live-audio": "/_components/@socketry/live-audio/Live/Audio.js",
19
+ "morphdom": "/_components/morphdom/morphdom-esm.js",
20
+ "@socketry/syntax": "/_components/@socketry/syntax/Syntax.js"
21
+ }
22
+ }
23
+ </script>
24
+
25
+ <script type="module" src="/application.js"></script>
26
+ </head>
27
+
28
+ <body>
29
+ #{self.body&.to_html || "No body specified!"}
30
+ </body>
31
+ </html>
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "slide"
7
+
8
+ module Presently
9
+ # An immutable collection of slides with configuration.
10
+ #
11
+ # Use {.load} to create a presentation from a directory of Markdown files,
12
+ # or initialize directly with an array of {Slide} instances.
13
+ class Presentation
14
+ # Load a presentation from a directory of Markdown slide files.
15
+ # @parameter slides_root [String] The directory containing `.md` slide files.
16
+ # @parameter options [Hash] Additional options passed to {#initialize}.
17
+ # @returns [Presentation] A new presentation with slides loaded from the directory.
18
+ def self.load(slides_root = "slides", **options)
19
+ pattern = File.join(slides_root, "*.md")
20
+ slides = Dir.glob(pattern).sort.map{|path| Slide.new(path)}
21
+
22
+ new(slides, slides_root: slides_root, **options)
23
+ end
24
+
25
+ # Initialize a new presentation.
26
+ # @parameter slides [Array(Slide)] The ordered list of slides.
27
+ # @parameter slides_root [String | Nil] The directory slides were loaded from, used by {#reload!}.
28
+ # @parameter templates_root [String | Nil] The directory containing custom `.xrb` templates.
29
+ def initialize(slides = [], slides_root: nil, templates_root: nil)
30
+ @slides = slides
31
+ @slides_root = slides_root
32
+ @templates_root = templates_root
33
+ end
34
+
35
+ # @attribute [Array(Slide)] The ordered list of slides.
36
+ attr :slides
37
+
38
+ # @attribute [String | Nil] The directory slides were loaded from.
39
+ attr :slides_root
40
+
41
+ # @attribute [String | Nil] The directory containing custom templates.
42
+ attr :templates_root
43
+
44
+ # The number of slides in the presentation.
45
+ # @returns [Integer] The slide count.
46
+ def slide_count
47
+ @slides.length
48
+ end
49
+
50
+ # The total expected duration of the presentation in seconds.
51
+ # @returns [Numeric] The sum of all slide durations.
52
+ def total_duration
53
+ @slides.sum(&:duration)
54
+ end
55
+
56
+ # Calculate the expected elapsed time for slides up to the given index.
57
+ # @parameter index [Integer] The slide index (exclusive).
58
+ # @returns [Numeric] The sum of durations for slides before the given index.
59
+ def expected_time_at(index)
60
+ @slides[0...index].sum(&:duration)
61
+ end
62
+
63
+ # Reload slides from the original directory.
64
+ # Only works if the presentation was created with {.load}.
65
+ def reload!
66
+ if @slides_root
67
+ pattern = File.join(@slides_root, "*.md")
68
+ @slides = Dir.glob(pattern).sort.map{|path| Slide.new(path)}
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "clock"
7
+ require_relative "presentation"
8
+ require_relative "state"
9
+
10
+ module Presently
11
+ # Manages the mutable state of a presentation: current slide, clock, and listeners.
12
+ #
13
+ # Wraps an immutable {Presentation} and provides navigation, timing, and listener notification.
14
+ # Multiple views (display, presenter) can register as listeners to receive updates.
15
+ class PresentationController
16
+ # Initialize a new controller for the given presentation.
17
+ # @parameter presentation [Presentation] The presentation to control.
18
+ # @parameter state [State | Nil] The state persistence object. If provided, state is saved on changes and restored on initialization.
19
+ def initialize(presentation, state: nil)
20
+ @presentation = presentation
21
+ @current_index = 0
22
+ @clock = Clock.new
23
+ @listeners = []
24
+ @state = state
25
+
26
+ @state&.load(self)
27
+ end
28
+
29
+ # @attribute [Presentation] The underlying presentation data.
30
+ attr :presentation
31
+
32
+ # @attribute [Integer] The index of the current slide.
33
+ attr :current_index
34
+
35
+ # @attribute [Clock] The presentation timer.
36
+ attr :clock
37
+
38
+ # The templates root directory, delegated to the presentation.
39
+ # @returns [String | Nil] The templates root path.
40
+ def templates_root
41
+ @presentation.templates_root
42
+ end
43
+
44
+ # The ordered list of slides, delegated to the presentation.
45
+ # @returns [Array(Slide)] The slides.
46
+ def slides
47
+ @presentation.slides
48
+ end
49
+
50
+ # The currently displayed slide.
51
+ # @returns [Slide | Nil] The current slide, or `nil` if no slides are loaded.
52
+ def current_slide
53
+ @presentation.slides[@current_index]
54
+ end
55
+
56
+ # The slide following the current one.
57
+ # @returns [Slide | Nil] The next slide, or `nil` if on the last slide.
58
+ def next_slide
59
+ @presentation.slides[@current_index + 1]
60
+ end
61
+
62
+ # The slide preceding the current one.
63
+ # @returns [Slide | Nil] The previous slide, or `nil` if on the first slide.
64
+ def previous_slide
65
+ @presentation.slides[@current_index - 1] if @current_index > 0
66
+ end
67
+
68
+ # The total number of slides.
69
+ # @returns [Integer] The slide count.
70
+ def slide_count
71
+ @presentation.slide_count
72
+ end
73
+
74
+ # The total expected duration of the presentation.
75
+ # @returns [Numeric] The total duration in seconds.
76
+ def total_duration
77
+ @presentation.total_duration
78
+ end
79
+
80
+ # The progress through the current slide's allocated time.
81
+ # @returns [Float] A value between 0.0 and 1.0.
82
+ def slide_progress
83
+ return 0.0 unless @clock.started?
84
+
85
+ slide = current_slide
86
+ return 0.0 unless slide
87
+
88
+ time_into_slide = @clock.elapsed - @presentation.expected_time_at(@current_index)
89
+ (time_into_slide / slide.duration).clamp(0.0, 1.0)
90
+ end
91
+
92
+ # Reset the timer so that elapsed time matches the expected time for the current slide.
93
+ def reset_timer!
94
+ @clock.reset!(@presentation.expected_time_at(@current_index))
95
+ notify_listeners!
96
+ end
97
+
98
+ # The current pacing status relative to the slide timing.
99
+ # @returns [Symbol] One of `:on_time`, `:ahead`, or `:behind`.
100
+ def pacing
101
+ return :on_time unless @clock.started?
102
+
103
+ elapsed = @clock.elapsed
104
+ slide_start = @presentation.expected_time_at(@current_index)
105
+ slide_end = @presentation.expected_time_at(@current_index + 1)
106
+
107
+ if elapsed > slide_end
108
+ :behind
109
+ elsif elapsed < slide_start
110
+ :ahead
111
+ else
112
+ :on_time
113
+ end
114
+ end
115
+
116
+ # The estimated time remaining in the presentation.
117
+ # @returns [Numeric] The remaining time in seconds.
118
+ def time_remaining
119
+ return total_duration unless @clock.started?
120
+
121
+ expected_remaining = @presentation.expected_time_at(slide_count) - @clock.elapsed
122
+
123
+ [expected_remaining, 0].max
124
+ end
125
+
126
+ # Navigate to a specific slide by index.
127
+ # Ignores out-of-bounds indices. Notifies listeners on change.
128
+ # @parameter index [Integer] The slide index to navigate to.
129
+ def go_to(index)
130
+ return if index < 0 || index >= slide_count
131
+
132
+ @current_index = index
133
+ notify_listeners!
134
+ end
135
+
136
+ # Advance to the next slide.
137
+ def advance!
138
+ go_to(@current_index + 1)
139
+ end
140
+
141
+ # Go back to the previous slide.
142
+ def retreat!
143
+ go_to(@current_index - 1)
144
+ end
145
+
146
+ # Register a listener to be notified when the slide changes.
147
+ # The listener must respond to `#slide_changed!`.
148
+ # @parameter listener [Object] The listener to add.
149
+ def add_listener(listener)
150
+ @listeners << listener
151
+ end
152
+
153
+ # Remove a previously registered listener.
154
+ # @parameter listener [Object] The listener to remove.
155
+ def remove_listener(listener)
156
+ @listeners.delete(listener)
157
+ end
158
+
159
+ # Reload slides from disk and notify listeners.
160
+ def reload!
161
+ @presentation.reload!
162
+ notify_listeners!
163
+ end
164
+
165
+ # Persist the current state to disk.
166
+ def save_state!
167
+ @state&.save(self)
168
+ end
169
+
170
+ private
171
+
172
+ # Notify all registered listeners that the slide has changed, and persist state.
173
+ def notify_listeners!
174
+ @state&.save(self)
175
+
176
+ @listeners.each do |listener|
177
+ listener.slide_changed! rescue nil
178
+ end
179
+ end
180
+ end
181
+ end