From dee5c75d04e7c23c5801f55a5e2a2fd59274d046 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 25 Sep 2026 15:00:30 +1200 Subject: [PATCH 1/3] Make presenter timer controls follow slide metadata --- context/getting-started.md | 22 +++- guides/getting-started/readme.md | 23 +++- lib/presently/clock.rb | 22 ++-- lib/presently/presentation_controller.rb | 11 +- lib/presently/presenter_view.rb | 113 +++++++++++++---- public/_static/presenter.css | 29 +++++ releases.md | 4 +- slides/00-welcome.md | 9 ++ test/presently/clock.rb | 63 +++++++--- test/presently/presentation_controller.rb | 112 ++++++++++++++--- test/presently/presenter_view.rb | 144 +++++++++++++++++++++- test/presently/state.rb | 10 ++ 12 files changed, 480 insertions(+), 82 deletions(-) create mode 100644 slides/00-welcome.md diff --git a/context/getting-started.md b/context/getting-started.md index 612741c..9f1beb1 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -231,11 +231,29 @@ The presenter view at `/presenter` provides: - **Current and next slide previews** — see what's coming without switching windows. - **Presenter notes** — notes from the slide's `---` separator section. -- **Timer controls** — Start, Pause, Resume, and Reset buttons. +- **Timer controls** — Start when not started, Pause while running, and Resume or Reset while paused. - **Pacing indicator** — shows whether you're on time, ahead, or behind based on per-slide `duration` metadata. - **Progress bar** — visual indicator of time consumed for the current slide. - **Reload button** — reload slides from disk without restarting the server. +### Timer Controls + +Pause the timer before changing its elapsed time, whether you are rehearsing a section or preparing for another presentation. + +| Timer state | Available controls | +|---|---| +| Not started | **Start** begins timing. | +| Running | **Pause** freezes elapsed time. | +| Paused | **Resume** continues timing, and **Reset** prepares the current slide to begin again. | + +**Reset** normally sets elapsed time to the sum of durations before the current slide and leaves the timer paused. Press **Resume** when ready to continue. On a slide with `timer: start`, Reset instead clears elapsed time to zero and returns the timer to its initial, not-started state. Reset leaves the current slide unchanged and is only available while paused. + +After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts a fresh timer, regardless of its position in the deck. + +For slides with recognized timer metadata and a following slide, the **Next** tooltip describes what advancing will actually do. For example, a manually paused `timer: start` slide shows “Timer is paused. Advancing will leave it paused.” After Reset, the tooltip changes to “Advancing will start the timer.” The tooltip also reflects pause and resume actions, including when they would leave the clock unchanged. + +Before the timer starts, an **Auto-start** indicator replaces **Start** on a `timer: start` slide with a following slide. Its play icon gently pulses, and its tooltip says “Advancing will start the timer.” Advance to begin timing; **Pause** and **Resume** remain available once the timer has started. The animation respects reduced-motion preferences. + ### Starting the Timer from a Title Slide A title slide can stay on screen while the audience settles. Add `timer: start` to its frontmatter to start the presentation timer when you advance to the next slide: @@ -260,7 +278,7 @@ The `timer` field supports these actions: | Value | Effect when advancing from this slide | |---|---| -| `start` | Starts the timer only if it has never started. Revisiting the slide does not reset elapsed time or resume a manually paused timer. | +| `start` | Starts the timer only if it is in the not-started state. Revisiting the slide does not reset elapsed time or resume a manually paused timer. | | `pause` | Pauses the timer, preserving elapsed time. Has no effect before the timer starts. | | `resume` | Resumes a started timer, preserving elapsed time. Has no effect before the timer starts or while it is already running. | diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 7986205..9f1beb1 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -231,12 +231,29 @@ The presenter view at `/presenter` provides: - **Current and next slide previews** — see what's coming without switching windows. - **Presenter notes** — notes from the slide's `---` separator section. -- **Timer controls** — Start, Pause, Resume, and Reset buttons. - **Reset** re-aligns elapsed time with the expected time for the current slide. On the first slide it stops the timer entirely, so that it can be started again, e.g. by `timer: start`. +- **Timer controls** — Start when not started, Pause while running, and Resume or Reset while paused. - **Pacing indicator** — shows whether you're on time, ahead, or behind based on per-slide `duration` metadata. - **Progress bar** — visual indicator of time consumed for the current slide. - **Reload button** — reload slides from disk without restarting the server. +### Timer Controls + +Pause the timer before changing its elapsed time, whether you are rehearsing a section or preparing for another presentation. + +| Timer state | Available controls | +|---|---| +| Not started | **Start** begins timing. | +| Running | **Pause** freezes elapsed time. | +| Paused | **Resume** continues timing, and **Reset** prepares the current slide to begin again. | + +**Reset** normally sets elapsed time to the sum of durations before the current slide and leaves the timer paused. Press **Resume** when ready to continue. On a slide with `timer: start`, Reset instead clears elapsed time to zero and returns the timer to its initial, not-started state. Reset leaves the current slide unchanged and is only available while paused. + +After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts a fresh timer, regardless of its position in the deck. + +For slides with recognized timer metadata and a following slide, the **Next** tooltip describes what advancing will actually do. For example, a manually paused `timer: start` slide shows “Timer is paused. Advancing will leave it paused.” After Reset, the tooltip changes to “Advancing will start the timer.” The tooltip also reflects pause and resume actions, including when they would leave the clock unchanged. + +Before the timer starts, an **Auto-start** indicator replaces **Start** on a `timer: start` slide with a following slide. Its play icon gently pulses, and its tooltip says “Advancing will start the timer.” Advance to begin timing; **Pause** and **Resume** remain available once the timer has started. The animation respects reduced-motion preferences. + ### Starting the Timer from a Title Slide A title slide can stay on screen while the audience settles. Add `timer: start` to its frontmatter to start the presentation timer when you advance to the next slide: @@ -261,7 +278,7 @@ The `timer` field supports these actions: | Value | Effect when advancing from this slide | |---|---| -| `start` | Starts the timer only if it has never started. Revisiting the slide does not reset elapsed time or resume a manually paused timer. Press **Reset** on the first slide to stop the timer so it starts again. | +| `start` | Starts the timer only if it is in the not-started state. Revisiting the slide does not reset elapsed time or resume a manually paused timer. | | `pause` | Pauses the timer, preserving elapsed time. Has no effect before the timer starts. | | `resume` | Resumes a started timer, preserving elapsed time. Has no effect before the timer starts or while it is already running. | diff --git a/lib/presently/clock.rb b/lib/presently/clock.rb index 849b6d9..65c8478 100644 --- a/lib/presently/clock.rb +++ b/lib/presently/clock.rb @@ -78,20 +78,14 @@ def resume! @last_tick = Time.now end - # Reset the elapsed time to the given value. - # If running, continues from the new value. If paused, sets the frozen value. - # @parameter elapsed [Numeric] The new elapsed time in seconds. - def reset!(elapsed = 0) - @elapsed = elapsed - @last_tick = Time.now if @running - end - - # Stop the clock and return it to its initial, never-started state. - def stop! - @elapsed = 0 - @started = false - @running = false - @last_tick = nil + # Reset the clock to its initial, stopped state, or set its elapsed time. + # A numeric value preserves whether the clock is running; a stopped clock becomes paused. + # @parameter elapsed [Numeric | Nil] The elapsed time in seconds, or `nil` to clear the clock. + def reset!(elapsed = nil) + @elapsed = elapsed || 0 + @started = !elapsed.nil? + @running = @started && @running + @last_tick = @running ? Time.now : nil end end end diff --git a/lib/presently/presentation_controller.rb b/lib/presently/presentation_controller.rb index a10a271..b917f29 100644 --- a/lib/presently/presentation_controller.rb +++ b/lib/presently/presentation_controller.rb @@ -105,11 +105,14 @@ def slide_progress end end - # Reset the timer so that elapsed time matches the expected time for the current slide. - # On the first slide, stop the timer entirely so that it can be started again, e.g. by a `timer: start` action. + # Reset a paused timer to begin the current slide again. + # A `timer: start` slide clears the clock, ready to start when advancing. + # Other slides reset to their expected start time and remain paused. def reset_timer! - if @current_index.zero? - @clock.stop! + return unless @clock.paused? && current_slide + + if current_slide.timer == "start" + @clock.reset! else @clock.reset!(@presentation.expected_time_at(@current_index)) end diff --git a/lib/presently/presenter_view.rb b/lib/presently/presenter_view.rb index e25ea5c..3a41ba5 100644 --- a/lib/presently/presenter_view.rb +++ b/lib/presently/presenter_view.rb @@ -21,6 +21,7 @@ def initialize(id, data, controller:) super(id, data) @controller = controller @clock_task = nil + @timing_state = nil @preview_renderer = SlideRenderer.new(css_class: "slide preview-slide", templates: controller.templates) end @@ -51,11 +52,22 @@ def slide_changed! self.render_slide! end - # Push an update to just the timing section. + # Update the timing section and the Next tooltip when the clock state changes. + # Leave unchanged, paused controls in place to preserve keyboard focus. def update_timing! + clock = @controller.clock + state_changed = @timing_state != [clock.started?, clock.running?] + return unless clock.running? || state_changed + replace(".timing") do |builder| render_timing(builder, @controller.current_slide) end + + if state_changed + replace(".next-button") do |builder| + render_next_button(builder) + end + end end # Handle an event from the client. @@ -77,6 +89,7 @@ def handle(event) @controller.clock.pause! end @controller.save_state! + update_timing! if @page when "reset" @controller.reset_timer! when "reload" @@ -96,13 +109,53 @@ def editor_url_for(path, line = 1) Editor.url_for(path, line) end + # Describe the actual effect of the outgoing slide's timer metadata. + # @returns [String | Nil] The hint, or `nil` when there is no timer action or next slide. + def timer_action_hint + return unless @controller.next_slide + + clock = @controller.clock + case @controller.current_slide.timer + when "start" + return "Advancing will start the timer." unless clock.started? + when "pause" + return "Advancing will pause the timer." if clock.running? + when "resume" + return "Advancing will resume the timer." if clock.paused? + else + return + end + + if clock.running? + "Advancing will leave the timer running." + elsif clock.paused? + "Timer is paused. Advancing will leave it paused." + else + "Advancing will leave the timer stopped." + end + end + + # Render the Next button with a tooltip describing its timer action. + # @parameter builder [XRB::Builder] The HTML builder. + def render_next_button(builder) + builder.tag(:button, + class: "next-button", + title: timer_action_hint, + onClick: forward_event(action: "next") + ) do + builder.text("Next →") + end + end + # Render the timing bar with controls, elapsed/remaining time, and pacing. # @parameter builder [XRB::Builder] The HTML builder. # @parameter slide [Slide | Nil] The current slide. def render_timing(builder, slide) + @timing_state = [@controller.clock.started?, @controller.clock.running?] pacing = @controller.pacing progress = pacing ? (@controller.slide_progress * 100).round(1) : 0.0 next_slide = @controller.next_slide + wait_for_advance = !@controller.clock.started? && slide&.timer == "start" && !!next_slide builder.tag(:div, class: "timing", style: "--slide-progress: #{progress}%") do pacing_class = case pacing when :behind then "behind" @@ -111,25 +164,45 @@ def render_timing(builder, slide) end builder.tag(:div, class: "toolbar timing-info #{pacing_class}") do - builder.tag(:button, - class: "pause-button", - onClick: forward_event(action: "pause") - ) do - label = if !@controller.clock.started? - "▶ Start" - elsif @controller.clock.paused? - "▶ Resume" - else - "⏸ Pause" + if wait_for_advance + builder.tag(:span, + class: "auto-start", + role: "status", + title: "Advancing will start the timer." + ) do + builder.tag(:span, class: "auto-start-icon", "aria-hidden": "true"){builder.text("▶")} + builder.text("Auto-start") + end + else + builder.tag(:button, + class: "pause-button", + onClick: forward_event(action: "pause") + ) do + label = if !@controller.clock.started? + "▶ Start" + elsif @controller.clock.paused? + "▶ Resume" + else + "⏸ Pause" + end + builder.text(label) end - builder.text(label) end - builder.tag(:button, - class: "pause-button", - onClick: forward_event(action: "reset") - ) do - builder.text("↺ Reset") + if @controller.clock.paused? && slide + title = if slide.timer == "start" + "Clear the timer to its initial state" + else + "Reset to this slide's timestamp and stay paused" + end + + builder.tag(:button, + class: "pause-button", + title: title, + onClick: forward_event(action: "reset") + ) do + builder.text("↺ Reset") + end end builder.tag(:span, class: "elapsed") do @@ -200,11 +273,7 @@ def render(builder) builder.text("← Previous") end - builder.tag(:button, - onClick: forward_event(action: "next") - ) do - builder.text("Next →") - end + render_next_button(builder) builder.tag(:span, class: "slide-info") do builder.tag(:span, class: "slide-position") do diff --git a/public/_static/presenter.css b/public/_static/presenter.css index ad1b379..3a32742 100644 --- a/public/_static/presenter.css +++ b/public/_static/presenter.css @@ -106,6 +106,35 @@ background: var(--accent); } +.timing-info .auto-start { + display: inline-flex; + align-items: center; + gap: 0.5rem; + height: 2.25rem; + padding: 0.3rem 0.75rem; + border: 1px solid rgba(233, 69, 96, 0.35); + border-radius: 6px; + background: rgba(233, 69, 96, 0.08); + color: var(--accent-light); + font-size: 0.9rem; + white-space: nowrap; +} + +.auto-start-icon { + animation: auto-start-pulse 2.4s ease-in-out infinite; +} + +@keyframes auto-start-pulse { + 0%, 100% { opacity: 0.5; transform: scale(0.9); } + 50% { opacity: 1; transform: scale(1.1); } +} + +@media (prefers-reduced-motion: reduce) { + .auto-start-icon { + animation: none; + } +} + .timing-info .elapsed { font-variant-numeric: tabular-nums; } diff --git a/releases.md b/releases.md index 2e1e7be..2ccb2ac 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,9 @@ ## Unreleased - - Reset the timer to its initial, stopped state when pressing **Reset** on the first slide, so that a `timer: start` title slide starts the timer again after a rehearsal. + - Show **Reset** only while paused. Reset sets elapsed time to the current slide's expected start and stays paused, or clears the timer on a `timer: start` slide so advancing can start it again, regardless of its position in the deck. + - Describe the effect of slide timer metadata in the **Next** tooltip. Show an animated **Auto-start** indicator when advancing from the current slide will start the timer, with a tooltip explaining how to begin. + - Make `Clock#reset!` (or `reset!(nil)`) clear the clock to its initial, stopped state. Passing a numeric elapsed time preserves whether it is running, or puts a stopped clock into the paused state. ## v0.26.0 diff --git a/slides/00-welcome.md b/slides/00-welcome.md new file mode 100644 index 0000000..dd31420 --- /dev/null +++ b/slides/00-welcome.md @@ -0,0 +1,9 @@ +--- +template: title +duration: 0 +timer: start +--- + +# Welcome to Presently + +We'll begin shortly. diff --git a/test/presently/clock.rb b/test/presently/clock.rb index 6b5a0d7..4bbea45 100644 --- a/test/presently/clock.rb +++ b/test/presently/clock.rb @@ -110,33 +110,33 @@ end with "#reset!" do - it "resets elapsed to the given value" do - clock.start! - sleep 0.05 + it "sets elapsed time and keeps a running clock running" do + clock.restore!(100, running: true) clock.reset!(42) + expect(clock).to be(:running?) expect(clock.elapsed).to be_within(0.1).of(42) end - it "resets elapsed to zero by default" do - clock.start! - sleep 0.05 + it "clears a running clock by default" do + clock.restore!(100, running: true) clock.reset! - expect(clock.elapsed).to be_within(0.1).of(0) + + expect(clock).not.to be(:started?) + expect(clock).not.to be(:running?) + expect(clock).not.to be(:paused?) + expect(clock.elapsed).to be == 0 end - it "works when paused" do - clock.start! - clock.pause! + it "sets elapsed time and keeps a paused clock paused" do + clock.restore!(100, running: false) clock.reset!(10) + expect(clock).to be(:paused?) expect(clock.elapsed).to be == 10 end - end - - with "#stop!" do - it "returns to the initial state" do - clock.start! - sleep 0.05 - clock.stop! + + it "clears a paused clock with nil" do + clock.restore!(100, running: false) + clock.reset!(nil) expect(clock).not.to be(:started?) expect(clock).not.to be(:running?) @@ -146,11 +146,38 @@ it "can be started again" do clock.start! - clock.stop! + clock.reset! clock.start! expect(clock).to be(:running?) expect(clock.elapsed).to be_within(0.1).of(0) end + + it "sets a stopped clock to a paused position" do + clock.reset!(42) + expect(clock).to be(:started?) + expect(clock).to be(:paused?) + expect(clock.elapsed).to be == 42 + end + + it "preserves a running clock when resetting to zero" do + clock.restore!(100, running: true) + clock.reset!(0) + expect(clock).to be(:started?) + expect(clock).to be(:running?) + expect(clock.elapsed).to be_within(0.1).of(0) + end + + it "distinguishes a paused clock at zero from a cleared clock" do + clock.reset!(0) + expect(clock).to be(:paused?) + expect(clock.elapsed).to be == 0 + + clock.reset! + clock.reset! + expect(clock).not.to be(:started?) + expect(clock).not.to be(:running?) + expect(clock.elapsed).to be == 0 + end end end diff --git a/test/presently/presentation_controller.rb b/test/presently/presentation_controller.rb index 2c8ea0c..523041e 100644 --- a/test/presently/presentation_controller.rb +++ b/test/presently/presentation_controller.rb @@ -66,10 +66,10 @@ end with "#advance!" do - it "moves to the next slide" do + it "moves from the sample waiting slide to the next slide and starts timing" do expect(controller.advance!).to be == true expect(controller.current_index).to be == 1 - expect(controller.clock).not.to be(:started?) + expect(controller.clock).to be(:running?) end it "does not advance past the last slide" do @@ -173,6 +173,7 @@ it "starts again from the title after resetting on the first slide" do controller.advance! controller.retreat! + controller.clock.pause! controller.reset_timer! expect(controller.clock).not.to be(:started?) @@ -333,7 +334,7 @@ end it "returns on_time when within the slide window" do - controller.clock.start! + controller.advance! expect(controller.pacing).to be == :on_time end @@ -357,7 +358,7 @@ end it "returns 0.0 at the start of a slide" do - controller.clock.start! + controller.advance! expect(controller.slide_progress).to be_within(0.1).of(0.0) end end @@ -368,24 +369,105 @@ end end - with "#reset_timer!" do - it "resets elapsed to expected time for current slide" do - controller.clock.start! - sleep 0.05 - controller.go_to(2) + with "timer controls" do + include Sus::Fixtures::TemporaryDirectoryContext + + let(:presentation) {Presently::Presentation.load(root)} + let(:state) {Presently::State.new(File.join(root, "state.json"))} + let(:controller) {subject.new(presentation, state: state)} + + before do + File.write(File.join(root, "010-introduction.md"), "---\nduration: 30\n---\n# Introduction\n") + File.write(File.join(root, "020-title.md"), "---\ntimer: start\nduration: 0\n---\n# Waiting\n") + File.write(File.join(root, "030-content.md"), "---\nduration: 60\n---\n# Content\n") + end + + it "clears a paused timer on a later start slide and persists the stopped state" do + controller.go_to(1) + controller.clock.restore!(42, running: false) controller.reset_timer! - expected = presentation.slides[0..1].sum(&:duration) - expect(controller.clock.elapsed).to be_within(0.1).of(expected) + expect(controller.clock).not.to be(:started?) + expect(controller.clock.elapsed).to be == 0 + expect(controller.current_index).to be == 1 + + restored = subject.new(presentation, state: state) + expect(restored.current_index).to be == 1 + expect(restored.clock).not.to be(:started?) + expect(restored.clock.elapsed).to be == 0 end - it "stops the timer on the first slide" do - controller.clock.start! - sleep 0.05 + it "prepares a later waiting slide to start timing again" do + controller.go_to(1) + controller.advance! + controller.clock.pause! + controller.retreat! controller.reset_timer! expect(controller.clock).not.to be(:started?) - expect(controller.clock.elapsed).to be == 0 + controller.advance! + expect(controller.clock).to be(:running?) + expect(controller.clock.elapsed).to be_within(0.1).of(0) + end + + it "resets ordinary slides to their expected start and persists the paused timer" do + [0, 2].each do |index| + controller.go_to(index) + controller.clock.restore!(150, running: false) + controller.reset_timer! + + expected = presentation.expected_time_at(index) + expect(controller.clock).to be(:paused?) + expect(controller.clock.elapsed).to be == expected + expect(controller.current_index).to be == index + + restored = subject.new(presentation, state: state) + expect(restored.current_index).to be == index + expect(restored.clock).to be(:paused?) + expect(restored.clock.elapsed).to be == expected + end + end + + it "notifies listeners after resetting" do + observed = [] + controller = self.controller + listener = Object.new + listener.define_singleton_method(:slide_changed!) do + observed << [controller.clock.started?, controller.clock.running?] + end + controller.add_listener(listener) + + controller.clock.restore!(42, running: false) + controller.reset_timer! + + expect(observed).to be == [[true, false]] + end + + it "ignores reset controls unless paused" do + notified = false + listener = Object.new + listener.define_singleton_method(:slide_changed!){notified = true} + controller.add_listener(listener) + + controller.reset_timer! + expect(controller.clock).not.to be(:started?) + + controller.clock.restore!(42, running: true) + controller.reset_timer! + expect(controller.clock).to be(:running?) + expect(controller.clock.elapsed).to be >= 42 + expect(notified).to be == false + expect(File).not.to be(:exist?, state.path) + end + + it "does not reset without a current slide" do + empty = File.join(root, "empty") + Dir.mkdir(empty) + controller = subject.new(Presently::Presentation.load(empty)) + controller.clock.restore!(42, running: false) + controller.reset_timer! + expect(controller.clock).to be(:paused?) + expect(controller.clock.elapsed).to be == 42 end end diff --git a/test/presently/presenter_view.rb b/test/presently/presenter_view.rb index 816065b..c30ab51 100644 --- a/test/presently/presenter_view.rb +++ b/test/presently/presenter_view.rb @@ -59,6 +59,7 @@ expect(html).to be(:include?, "Second slide") expect(html).to be(:include?, "First notes") expect(html).to be(:include?, "▶ Start") + expect(html).not.to be(:include?, "↺ Reset") expect(html).to be(:include?, "✓ On time") expect(html).to be(:include?, "Alice") expect(html).to be(:include?, "→ Bob") @@ -70,10 +71,13 @@ controller.clock.start! running = view.to_html.to_s expect(running).to be(:include?, "⏸ Pause") + expect(running).not.to be(:include?, "↺ Reset") controller.clock.pause! paused = view.to_html.to_s expect(paused).to be(:include?, "▶ Resume") + expect(paused).to be(:include?, "↺ Reset") + expect(paused).to be(:include?, "Reset to this slide's timestamp and stay paused") controller.go_to(1) ahead = view.to_html.to_s @@ -117,6 +121,48 @@ end end + it "leaves paused controls in place between timer updates" do + controller.clock.restore!(42, running: false) + Sync do + view.bind(page) + updates.clear + view.update_timing! + view.update_timing! + expect(updates).to be(:empty?) + + view.handle(detail: {action: "pause"}) + expect(updates.last.first).to be == :replace + expect(view.to_html.to_s).to be(:include?, "⏸ Pause") + + view.handle(detail: {action: "pause"}) + expect(updates.last.first).to be == :replace + expect(view.to_html.to_s).to be(:include?, "▶ Resume") + updates.clear + view.update_timing! + expect(updates).to be(:empty?) + ensure + view.close + end + end + + it "updates controls when another presenter changes the clock state" do + Sync do + view.bind(page) + other = subject.root(controller: controller) + other.handle(detail: {action: "pause"}) + view.update_timing! + expect(view.to_html.to_s).to be(:include?, "⏸ Pause") + + other.handle(detail: {action: "pause"}) + updates.clear + view.update_timing! + expect(updates.last.first).to be == :replace + expect(view.to_html.to_s).to be(:include?, "▶ Resume") + ensure + view.close + end + end + it "handles navigation and timing actions" do view.handle(detail: {action: "next"}) expect(controller.current_index).to be == 1 @@ -136,8 +182,16 @@ view.handle(detail: {action: "jump"}) expect(controller.current_index).to be == 1 + view.handle(detail: {action: "pause"}) view.handle(detail: {action: "reset"}) - expect(controller.clock.elapsed).to be_within(0.1).of(30) + expect(controller.clock.elapsed).to be == 30 + expect(controller.clock).to be(:paused?) + expect(view.to_html.to_s).to be(:include?, "▶ Resume") + expect(controller.current_index).to be == 1 + + view.handle(detail: {action: "pause"}) + expect(controller.clock).to be(:running?) + expect(controller.clock.elapsed).to be >= 30 view.handle(detail: {action: "reload"}) expect(controller.slide_count).to be == 2 @@ -146,7 +200,7 @@ it "starts timing when advancing from a waiting slide" do File.write(File.join(root, "010-first.md"), "---\ntimer: start\nduration: 0\n---\nWaiting slide\n") - expect(view.to_html.to_s).to be(:include?, "▶ Start") + expect(view.to_html.to_s).to be(:include?, "Auto-start") view.handle(detail: {action: "next"}) expect(controller.current_index).to be == 1 expect(view.to_html.to_s).to be(:include?, "⏸ Pause") @@ -162,14 +216,94 @@ view.handle(detail: {action: "previous"}) expect(view.to_html.to_s).to be(:include?, "⏸ Pause") + view.handle(detail: {action: "pause"}) + expect(view.to_html.to_s).to be(:include?, "Timer is paused. Advancing will leave it paused.") + expect(view.to_html.to_s).to be(:include?, "Clear the timer to its initial state") view.handle(detail: {action: "reset"}) - expect(view.to_html.to_s).to be(:include?, "▶ Start") + expect(view.to_html.to_s).to be(:include?, "Auto-start") + expect(view.to_html.to_s).to be(:include?, "Advancing will start the timer.") view.handle(detail: {action: "next"}) expect(controller.clock).to be(:running?) expect(controller.clock.elapsed).to be_within(0.1).of(0) end + it "ignores stale reset events while running" do + controller.clock.restore!(42, running: true) + view.handle(detail: {action: "reset"}) + expect(controller.clock).to be(:running?) + expect(controller.clock.elapsed).to be >= 42 + end + + with "timer metadata hints" do + [ + ["start", :stopped, "Advancing will start the timer.", :running], + ["start", :running, "Advancing will leave the timer running.", :running], + ["start", :paused, "Timer is paused. Advancing will leave it paused.", :paused], + ["pause", :stopped, "Advancing will leave the timer stopped.", :stopped], + ["pause", :running, "Advancing will pause the timer.", :paused], + ["pause", :paused, "Timer is paused. Advancing will leave it paused.", :paused], + ["resume", :stopped, "Advancing will leave the timer stopped.", :stopped], + ["resume", :running, "Advancing will leave the timer running.", :running], + ["resume", :paused, "Advancing will resume the timer.", :running], + ].each do |action, initial_state, hint, final_state| + it "describes #{action} with a #{initial_state} timer", unique: "#{action}-#{initial_state}" do + File.write(File.join(root, "010-first.md"), "---\ntimer: #{action}\n---\nFirst slide\n") + unless initial_state == :stopped + controller.clock.restore!(42, running: initial_state == :running) + end + + expect(view.to_html.to_s).to be(:include?, "title=\"#{hint}\"") + view.handle(detail: {action: "next"}) + expect(controller.clock.started?).to be == (final_state != :stopped) + expect(controller.clock.running?).to be == (final_state == :running) + end + end + + it "does not show a hint without recognized timer metadata" do + expect(view.timer_action_hint).to be_nil + expect(view.to_html.to_s).not.to be(:include?, 'class="next-button" title=') + File.write(File.join(root, "010-first.md"), "---\ntimer: unknown\n---\nFirst slide\n") + controller.reload! + expect(view.timer_action_hint).to be_nil + expect(view.to_html.to_s).not.to be(:include?, 'class="next-button" title=') + end + + it "does not show a hint on the last slide" do + File.write(File.join(root, "020-second.md"), "---\ntimer: pause\n---\nLast slide\n") + controller.go_to(1) + controller.clock.start! + expect(view.timer_action_hint).to be_nil + expect(view.to_html.to_s).not.to be(:include?, 'class="next-button" title=') + view.handle(detail: {action: "next"}) + expect(controller.clock).to be(:running?) + end + + it "refreshes the hint when the clock state changes" do + File.write(File.join(root, "010-first.md"), "---\ntimer: start\n---\nFirst slide\n") + Sync do + view.bind(page) + updates.clear + view.handle(detail: {action: "pause"}) + expect(updates.last.to_s).to be(:include?, "Advancing will leave the timer running.") + + updates.clear + view.update_timing! + expect(updates.size).to be == 1 + + other = subject.root(controller: controller) + other.handle(detail: {action: "pause"}) + view.update_timing! + expect(updates.last.to_s).to be(:include?, "Timer is paused. Advancing will leave it paused.") + + view.handle(detail: {action: "reset"}) + expect(updates.last.to_s).to be(:include?, "Advancing will start the timer.") + ensure + view.close + end + end + end + it "renders an empty presentation" do empty = File.join(root, "empty") Dir.mkdir(empty) @@ -180,5 +314,9 @@ expect(html).to be(:include?, "End of presentation") expect(html).to be(:include?, "No presenter notes") expect(html).not.to be(:include?, "slide-duration") + expect(view.timer_action_hint).to be_nil + + controller.clock.restore!(42, running: false) + expect(view.to_html.to_s).not.to be(:include?, "↺ Reset") end end diff --git a/test/presently/state.rb b/test/presently/state.rb index b922cd8..f2788a1 100644 --- a/test/presently/state.rb +++ b/test/presently/state.rb @@ -48,6 +48,16 @@ expect(restored.clock).to be(:paused?) end + it "persists an elapsed time set before starting" do + controller = Presently::PresentationController.new(presentation) + controller.clock.reset!(42) + state.save(controller) + + restored = Presently::PresentationController.new(presentation, state: state) + expect(restored.clock).to be(:paused?) + expect(restored.clock.elapsed).to be == 42 + end + it "persists a running clock" do controller = Presently::PresentationController.new(presentation) controller.clock.start! From cc4cffec40c2a58237675036f75fccb658be669c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 25 Sep 2026 15:07:19 +1200 Subject: [PATCH 2/3] Preserve slide timestamps when resetting auto-start timers --- context/getting-started.md | 4 ++-- guides/getting-started/readme.md | 4 ++-- lib/presently/clock.rb | 6 ++++-- lib/presently/presentation_controller.rb | 12 +++--------- lib/presently/presenter_view.rb | 2 +- lib/presently/state.rb | 2 ++ releases.md | 4 ++-- test/presently/clock.rb | 14 ++++++++++++++ test/presently/presentation_controller.rb | 16 ++++++++++++---- test/presently/presenter_view.rb | 9 ++++++--- 10 files changed, 48 insertions(+), 25 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 9f1beb1..e0226d8 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -246,9 +246,9 @@ Pause the timer before changing its elapsed time, whether you are rehearsing a s | Running | **Pause** freezes elapsed time. | | Paused | **Resume** continues timing, and **Reset** prepares the current slide to begin again. | -**Reset** normally sets elapsed time to the sum of durations before the current slide and leaves the timer paused. Press **Resume** when ready to continue. On a slide with `timer: start`, Reset instead clears elapsed time to zero and returns the timer to its initial, not-started state. Reset leaves the current slide unchanged and is only available while paused. +**Reset** always sets elapsed time to the sum of durations before the current slide. It normally leaves the timer paused; press **Resume** when ready to continue. On a slide with `timer: start`, Reset returns the timer to the not-started state at that same timestamp, so advancing starts from the slide's expected time. Reset leaves the current slide unchanged and is only available while paused. -After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts a fresh timer, regardless of its position in the deck. +After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts timing from its expected timestamp, regardless of its position in the deck. For example, a waiting slide after five minutes of allocated content resets to `5:00`, waits, and starts from `5:00` when you advance. The timestamp and not-started state are also preserved when saving and restoring the presentation. For slides with recognized timer metadata and a following slide, the **Next** tooltip describes what advancing will actually do. For example, a manually paused `timer: start` slide shows “Timer is paused. Advancing will leave it paused.” After Reset, the tooltip changes to “Advancing will start the timer.” The tooltip also reflects pause and resume actions, including when they would leave the clock unchanged. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 9f1beb1..e0226d8 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -246,9 +246,9 @@ Pause the timer before changing its elapsed time, whether you are rehearsing a s | Running | **Pause** freezes elapsed time. | | Paused | **Resume** continues timing, and **Reset** prepares the current slide to begin again. | -**Reset** normally sets elapsed time to the sum of durations before the current slide and leaves the timer paused. Press **Resume** when ready to continue. On a slide with `timer: start`, Reset instead clears elapsed time to zero and returns the timer to its initial, not-started state. Reset leaves the current slide unchanged and is only available while paused. +**Reset** always sets elapsed time to the sum of durations before the current slide. It normally leaves the timer paused; press **Resume** when ready to continue. On a slide with `timer: start`, Reset returns the timer to the not-started state at that same timestamp, so advancing starts from the slide's expected time. Reset leaves the current slide unchanged and is only available while paused. -After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts a fresh timer, regardless of its position in the deck. +After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts timing from its expected timestamp, regardless of its position in the deck. For example, a waiting slide after five minutes of allocated content resets to `5:00`, waits, and starts from `5:00` when you advance. The timestamp and not-started state are also preserved when saving and restoring the presentation. For slides with recognized timer metadata and a following slide, the **Next** tooltip describes what advancing will actually do. For example, a manually paused `timer: start` slide shows “Timer is paused. Advancing will leave it paused.” After Reset, the tooltip changes to “Advancing will start the timer.” The tooltip also reflects pause and resume actions, including when they would leave the clock unchanged. diff --git a/lib/presently/clock.rb b/lib/presently/clock.rb index 65c8478..83f8024 100644 --- a/lib/presently/clock.rb +++ b/lib/presently/clock.rb @@ -80,10 +80,12 @@ def resume! # Reset the clock to its initial, stopped state, or set its elapsed time. # A numeric value preserves whether the clock is running; a stopped clock becomes paused. + # Pass `started: false` to wait to start from the given elapsed time. # @parameter elapsed [Numeric | Nil] The elapsed time in seconds, or `nil` to clear the clock. - def reset!(elapsed = nil) + # @parameter started [Boolean] Whether the clock has started at the reset position. + def reset!(elapsed = nil, started: !elapsed.nil?) @elapsed = elapsed || 0 - @started = !elapsed.nil? + @started = started @running = @started && @running @last_tick = @running ? Time.now : nil end diff --git a/lib/presently/presentation_controller.rb b/lib/presently/presentation_controller.rb index b917f29..5bb968e 100644 --- a/lib/presently/presentation_controller.rb +++ b/lib/presently/presentation_controller.rb @@ -106,16 +106,12 @@ def slide_progress end # Reset a paused timer to begin the current slide again. - # A `timer: start` slide clears the clock, ready to start when advancing. - # Other slides reset to their expected start time and remain paused. + # All slides reset to their expected start time. A `timer: start` slide waits + # to start when advancing; other slides remain paused. def reset_timer! return unless @clock.paused? && current_slide - if current_slide.timer == "start" - @clock.reset! - else - @clock.reset!(@presentation.expected_time_at(@current_index)) - end + @clock.reset!(@presentation.expected_time_at(@current_index), started: current_slide.timer != "start") notify_listeners! end @@ -142,8 +138,6 @@ def pacing # The estimated time remaining in the presentation. # @returns [Numeric] The remaining time in seconds. def time_remaining - return total_duration unless @clock.started? - expected_remaining = @presentation.expected_time_at(slide_count) - @clock.elapsed [expected_remaining, 0].max diff --git a/lib/presently/presenter_view.rb b/lib/presently/presenter_view.rb index 3a41ba5..ac69b66 100644 --- a/lib/presently/presenter_view.rb +++ b/lib/presently/presenter_view.rb @@ -191,7 +191,7 @@ def render_timing(builder, slide) if @controller.clock.paused? && slide title = if slide.timer == "start" - "Clear the timer to its initial state" + "Reset to this slide's timestamp and wait to start" else "Reset to this slide's timestamp and stay paused" end diff --git a/lib/presently/state.rb b/lib/presently/state.rb index 73290c0..d7634c1 100644 --- a/lib/presently/state.rb +++ b/lib/presently/state.rb @@ -54,6 +54,8 @@ def restore(controller) # Restore clock state: if data[:started] controller.clock.restore!(data[:elapsed].to_f, running: data[:running]) + else + controller.clock.reset!(data[:elapsed].to_f, started: false) end rescue => error Console.warn(self, "Failed to restore state", exception: error) diff --git a/releases.md b/releases.md index 2ccb2ac..b423a5c 100644 --- a/releases.md +++ b/releases.md @@ -2,9 +2,9 @@ ## Unreleased - - Show **Reset** only while paused. Reset sets elapsed time to the current slide's expected start and stays paused, or clears the timer on a `timer: start` slide so advancing can start it again, regardless of its position in the deck. + - Show **Reset** only while paused. Reset always sets elapsed time to the current slide's expected start. It stays paused on ordinary slides, or returns to not started at that timestamp on a `timer: start` slide. Advancing starts from the preserved timestamp, which also survives saving and restoring the presentation. - Describe the effect of slide timer metadata in the **Next** tooltip. Show an animated **Auto-start** indicator when advancing from the current slide will start the timer, with a tooltip explaining how to begin. - - Make `Clock#reset!` (or `reset!(nil)`) clear the clock to its initial, stopped state. Passing a numeric elapsed time preserves whether it is running, or puts a stopped clock into the paused state. + - Make `Clock#reset!` (or `reset!(nil)`) clear the clock to its initial, stopped state. Passing a numeric elapsed time preserves whether it is running, or puts a stopped clock into the paused state. Pass `started: false` to wait to start from a nonzero elapsed time. ## v0.26.0 diff --git a/test/presently/clock.rb b/test/presently/clock.rb index 4bbea45..91605ef 100644 --- a/test/presently/clock.rb +++ b/test/presently/clock.rb @@ -160,6 +160,20 @@ expect(clock.elapsed).to be == 42 end + it "prepares an unstarted clock at a nonzero elapsed time" do + clock.restore!(100, running: true) + clock.reset!(42, started: false) + + expect(clock).not.to be(:started?) + expect(clock).not.to be(:running?) + expect(clock).not.to be(:paused?) + expect(clock.elapsed).to be == 42 + + clock.start! + expect(clock).to be(:running?) + expect(clock.elapsed).to be_within(0.1).of(42) + end + it "preserves a running clock when resetting to zero" do clock.restore!(100, running: true) clock.reset!(0) diff --git a/test/presently/presentation_controller.rb b/test/presently/presentation_controller.rb index 523041e..7ee2783 100644 --- a/test/presently/presentation_controller.rb +++ b/test/presently/presentation_controller.rb @@ -382,19 +382,26 @@ File.write(File.join(root, "030-content.md"), "---\nduration: 60\n---\n# Content\n") end - it "clears a paused timer on a later start slide and persists the stopped state" do + it "resets a later start slide to its timestamp and persists the unstarted clock" do controller.go_to(1) controller.clock.restore!(42, running: false) controller.reset_timer! expect(controller.clock).not.to be(:started?) - expect(controller.clock.elapsed).to be == 0 + expect(controller.clock.elapsed).to be == 30 expect(controller.current_index).to be == 1 + expect(controller.time_remaining).to be == 60 restored = subject.new(presentation, state: state) expect(restored.current_index).to be == 1 expect(restored.clock).not.to be(:started?) - expect(restored.clock.elapsed).to be == 0 + expect(restored.clock.elapsed).to be == 30 + expect(restored.time_remaining).to be == 60 + + restored.advance! + expect(restored.clock).to be(:running?) + expect(restored.clock.elapsed).to be_within(0.1).of(30) + expect(restored.pacing).to be == :on_time end it "prepares a later waiting slide to start timing again" do @@ -405,9 +412,10 @@ controller.reset_timer! expect(controller.clock).not.to be(:started?) + expect(controller.clock.elapsed).to be == 30 controller.advance! expect(controller.clock).to be(:running?) - expect(controller.clock.elapsed).to be_within(0.1).of(0) + expect(controller.clock.elapsed).to be_within(0.1).of(30) end it "resets ordinary slides to their expected start and persists the paused timer" do diff --git a/test/presently/presenter_view.rb b/test/presently/presenter_view.rb index c30ab51..2445f51 100644 --- a/test/presently/presenter_view.rb +++ b/test/presently/presenter_view.rb @@ -209,23 +209,26 @@ expect(view.to_html.to_s).to be(:include?, "Waiting slide") end - it "can start timing again after resetting on the waiting slide" do + it "can start from a later waiting slide's timestamp after resetting" do + File.write(File.join(root, "005-introduction.md"), "---\nduration: 30\n---\nIntroduction\n") File.write(File.join(root, "010-first.md"), "---\ntimer: start\nduration: 0\n---\nWaiting slide\n") + controller.go_to(1) view.handle(detail: {action: "next"}) view.handle(detail: {action: "previous"}) expect(view.to_html.to_s).to be(:include?, "⏸ Pause") view.handle(detail: {action: "pause"}) expect(view.to_html.to_s).to be(:include?, "Timer is paused. Advancing will leave it paused.") - expect(view.to_html.to_s).to be(:include?, "Clear the timer to its initial state") + expect(view.to_html.to_s).to be(:include?, "Reset to this slide's timestamp and wait to start") view.handle(detail: {action: "reset"}) expect(view.to_html.to_s).to be(:include?, "Auto-start") expect(view.to_html.to_s).to be(:include?, "Advancing will start the timer.") + expect(view.to_html.to_s).to be(:include?, "Elapsed: 0:30") view.handle(detail: {action: "next"}) expect(controller.clock).to be(:running?) - expect(controller.clock.elapsed).to be_within(0.1).of(0) + expect(controller.clock.elapsed).to be_within(0.1).of(30) end it "ignores stale reset events while running" do From 80cffc4bc7172061f28f3f983e00db59f1f63e7a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 25 Sep 2026 15:25:12 +1200 Subject: [PATCH 3/3] Clarify ready timer state in documentation --- context/getting-started.md | 20 +++++++++++--------- guides/getting-started/readme.md | 20 +++++++++++--------- lib/presently/clock.rb | 17 +++++++++-------- lib/presently/presentation_controller.rb | 4 ++-- lib/presently/state.rb | 2 +- releases.md | 4 ++-- 6 files changed, 36 insertions(+), 31 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index e0226d8..cb9ed00 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -231,7 +231,7 @@ The presenter view at `/presenter` provides: - **Current and next slide previews** — see what's coming without switching windows. - **Presenter notes** — notes from the slide's `---` separator section. -- **Timer controls** — Start when not started, Pause while running, and Resume or Reset while paused. +- **Timer controls** — Start when ready, Pause while running, and Resume or Reset while paused. - **Pacing indicator** — shows whether you're on time, ahead, or behind based on per-slide `duration` metadata. - **Progress bar** — visual indicator of time consumed for the current slide. - **Reload button** — reload slides from disk without restarting the server. @@ -240,19 +240,21 @@ The presenter view at `/presenter` provides: Pause the timer before changing its elapsed time, whether you are rehearsing a section or preparing for another presentation. +A **ready** timer is waiting to start from its current timestamp. It can be ready at `0:00` or at a later position after Reset. + | Timer state | Available controls | |---|---| -| Not started | **Start** begins timing. | +| Ready | **Start** begins timing, or **Auto-start** indicates that advancing will begin timing. | | Running | **Pause** freezes elapsed time. | | Paused | **Resume** continues timing, and **Reset** prepares the current slide to begin again. | -**Reset** always sets elapsed time to the sum of durations before the current slide. It normally leaves the timer paused; press **Resume** when ready to continue. On a slide with `timer: start`, Reset returns the timer to the not-started state at that same timestamp, so advancing starts from the slide's expected time. Reset leaves the current slide unchanged and is only available while paused. +**Reset** always sets elapsed time to the sum of durations before the current slide. It normally leaves the timer paused; press **Resume** when ready to continue. On a slide with `timer: start`, Reset returns the timer to the ready state at that same timestamp, so advancing starts from the slide's expected time. Reset leaves the current slide unchanged and is only available while paused. -After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts timing from its expected timestamp, regardless of its position in the deck. For example, a waiting slide after five minutes of allocated content resets to `5:00`, waits, and starts from `5:00` when you advance. The timestamp and not-started state are also preserved when saving and restoring the presentation. +After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts timing from its expected timestamp, regardless of its position in the deck. For example, a waiting slide after five minutes of allocated content resets to `5:00`, waits, and starts from `5:00` when you advance. The timestamp and ready state are also preserved when saving and restoring the presentation. For slides with recognized timer metadata and a following slide, the **Next** tooltip describes what advancing will actually do. For example, a manually paused `timer: start` slide shows “Timer is paused. Advancing will leave it paused.” After Reset, the tooltip changes to “Advancing will start the timer.” The tooltip also reflects pause and resume actions, including when they would leave the clock unchanged. -Before the timer starts, an **Auto-start** indicator replaces **Start** on a `timer: start` slide with a following slide. Its play icon gently pulses, and its tooltip says “Advancing will start the timer.” Advance to begin timing; **Pause** and **Resume** remain available once the timer has started. The animation respects reduced-motion preferences. +While the timer is ready, an **Auto-start** indicator replaces **Start** on a `timer: start` slide with a following slide. Its play icon gently pulses, and its tooltip says “Advancing will start the timer.” Advance to begin timing; **Pause** and **Resume** are available while running and paused respectively. The animation respects reduced-motion preferences. ### Starting the Timer from a Title Slide @@ -270,7 +272,7 @@ timer: start We'll begin shortly. ``` -The timer stays stopped while the title is displayed. Advancing from it in `/presenter` or `/display` starts the timer before showing the next slide. Setting `duration: 0` excludes the waiting slide from the expected presentation duration and pacing calculations. The `title` template itself does not control the timer. +The timer stays ready while the title is displayed. Advancing from it in `/presenter` or `/display` starts the timer before showing the next slide. Setting `duration: 0` excludes the waiting slide from the expected presentation duration and pacing calculations. The `title` template itself does not control the timer. Durations are read as floating-point seconds, including numeric strings. Negative, invalid, or non-finite values are treated as `0.0`. An unspecified or null duration defaults to `0.0` seconds, meaning no time has been allocated to that slide. Set explicit durations, or apply recorded narration durations, to establish a pacing schedule. When the total allocated duration is zero, the presenter shows elapsed time without pacing indicators, a progress bar, or a remaining-time estimate. @@ -278,9 +280,9 @@ The `timer` field supports these actions: | Value | Effect when advancing from this slide | |---|---| -| `start` | Starts the timer only if it is in the not-started state. Revisiting the slide does not reset elapsed time or resume a manually paused timer. | -| `pause` | Pauses the timer, preserving elapsed time. Has no effect before the timer starts. | -| `resume` | Resumes a started timer, preserving elapsed time. Has no effect before the timer starts or while it is already running. | +| `start` | Starts a ready timer from its current timestamp. Revisiting the slide does not reset elapsed time or resume a manually paused timer. | +| `pause` | Pauses the timer, preserving elapsed time. Has no effect while ready. | +| `resume` | Resumes a paused timer, preserving elapsed time. Has no effect while ready or already running. | For a break, put `timer: pause` on the slide immediately before the break slide, and `timer: resume` on the break slide itself. Give the break slide `duration: 0` to exclude the break from pacing calculations. Advancing into the break pauses timing; advancing out resumes it. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index e0226d8..cb9ed00 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -231,7 +231,7 @@ The presenter view at `/presenter` provides: - **Current and next slide previews** — see what's coming without switching windows. - **Presenter notes** — notes from the slide's `---` separator section. -- **Timer controls** — Start when not started, Pause while running, and Resume or Reset while paused. +- **Timer controls** — Start when ready, Pause while running, and Resume or Reset while paused. - **Pacing indicator** — shows whether you're on time, ahead, or behind based on per-slide `duration` metadata. - **Progress bar** — visual indicator of time consumed for the current slide. - **Reload button** — reload slides from disk without restarting the server. @@ -240,19 +240,21 @@ The presenter view at `/presenter` provides: Pause the timer before changing its elapsed time, whether you are rehearsing a section or preparing for another presentation. +A **ready** timer is waiting to start from its current timestamp. It can be ready at `0:00` or at a later position after Reset. + | Timer state | Available controls | |---|---| -| Not started | **Start** begins timing. | +| Ready | **Start** begins timing, or **Auto-start** indicates that advancing will begin timing. | | Running | **Pause** freezes elapsed time. | | Paused | **Resume** continues timing, and **Reset** prepares the current slide to begin again. | -**Reset** always sets elapsed time to the sum of durations before the current slide. It normally leaves the timer paused; press **Resume** when ready to continue. On a slide with `timer: start`, Reset returns the timer to the not-started state at that same timestamp, so advancing starts from the slide's expected time. Reset leaves the current slide unchanged and is only available while paused. +**Reset** always sets elapsed time to the sum of durations before the current slide. It normally leaves the timer paused; press **Resume** when ready to continue. On a slide with `timer: start`, Reset returns the timer to the ready state at that same timestamp, so advancing starts from the slide's expected time. Reset leaves the current slide unchanged and is only available while paused. -After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts timing from its expected timestamp, regardless of its position in the deck. For example, a waiting slide after five minutes of allocated content resets to `5:00`, waits, and starts from `5:00` when you advance. The timestamp and not-started state are also preserved when saving and restoring the presentation. +After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts timing from its expected timestamp, regardless of its position in the deck. For example, a waiting slide after five minutes of allocated content resets to `5:00`, waits, and starts from `5:00` when you advance. The timestamp and ready state are also preserved when saving and restoring the presentation. For slides with recognized timer metadata and a following slide, the **Next** tooltip describes what advancing will actually do. For example, a manually paused `timer: start` slide shows “Timer is paused. Advancing will leave it paused.” After Reset, the tooltip changes to “Advancing will start the timer.” The tooltip also reflects pause and resume actions, including when they would leave the clock unchanged. -Before the timer starts, an **Auto-start** indicator replaces **Start** on a `timer: start` slide with a following slide. Its play icon gently pulses, and its tooltip says “Advancing will start the timer.” Advance to begin timing; **Pause** and **Resume** remain available once the timer has started. The animation respects reduced-motion preferences. +While the timer is ready, an **Auto-start** indicator replaces **Start** on a `timer: start` slide with a following slide. Its play icon gently pulses, and its tooltip says “Advancing will start the timer.” Advance to begin timing; **Pause** and **Resume** are available while running and paused respectively. The animation respects reduced-motion preferences. ### Starting the Timer from a Title Slide @@ -270,7 +272,7 @@ timer: start We'll begin shortly. ``` -The timer stays stopped while the title is displayed. Advancing from it in `/presenter` or `/display` starts the timer before showing the next slide. Setting `duration: 0` excludes the waiting slide from the expected presentation duration and pacing calculations. The `title` template itself does not control the timer. +The timer stays ready while the title is displayed. Advancing from it in `/presenter` or `/display` starts the timer before showing the next slide. Setting `duration: 0` excludes the waiting slide from the expected presentation duration and pacing calculations. The `title` template itself does not control the timer. Durations are read as floating-point seconds, including numeric strings. Negative, invalid, or non-finite values are treated as `0.0`. An unspecified or null duration defaults to `0.0` seconds, meaning no time has been allocated to that slide. Set explicit durations, or apply recorded narration durations, to establish a pacing schedule. When the total allocated duration is zero, the presenter shows elapsed time without pacing indicators, a progress bar, or a remaining-time estimate. @@ -278,9 +280,9 @@ The `timer` field supports these actions: | Value | Effect when advancing from this slide | |---|---| -| `start` | Starts the timer only if it is in the not-started state. Revisiting the slide does not reset elapsed time or resume a manually paused timer. | -| `pause` | Pauses the timer, preserving elapsed time. Has no effect before the timer starts. | -| `resume` | Resumes a started timer, preserving elapsed time. Has no effect before the timer starts or while it is already running. | +| `start` | Starts a ready timer from its current timestamp. Revisiting the slide does not reset elapsed time or resume a manually paused timer. | +| `pause` | Pauses the timer, preserving elapsed time. Has no effect while ready. | +| `resume` | Resumes a paused timer, preserving elapsed time. Has no effect while ready or already running. | For a break, put `timer: pause` on the slide immediately before the break slide, and `timer: resume` on the break slide itself. Give the break slide `duration: 0` to exclude the break from pacing calculations. Advancing into the break pauses timing; advancing out resumes it. diff --git a/lib/presently/clock.rb b/lib/presently/clock.rb index 83f8024..5ce8d64 100644 --- a/lib/presently/clock.rb +++ b/lib/presently/clock.rb @@ -6,9 +6,10 @@ module Presently # A simple clock that tracks elapsed time with start, pause, resume, and reset. # - # The clock accumulates elapsed time while running and freezes it when paused. + # A ready clock waits to start at its current elapsed time. It accumulates time + # while running and freezes it when paused. class Clock - # Initialize a new clock in the stopped state. + # Initialize a new clock in the ready state at zero. def initialize @elapsed = 0 @started = false @@ -16,7 +17,7 @@ def initialize @last_tick = nil end - # Whether the clock has been started at least once. + # Whether the clock is running or paused; false when ready. # @returns [Boolean] def started? @started @@ -35,7 +36,7 @@ def paused? end # The total elapsed time in seconds. - # Includes time accumulated up to now if running, or frozen time if paused. + # Includes time accumulated up to now if running, or frozen time if paused or ready. # @returns [Numeric] The elapsed time in seconds. def elapsed if @running @@ -78,11 +79,11 @@ def resume! @last_tick = Time.now end - # Reset the clock to its initial, stopped state, or set its elapsed time. - # A numeric value preserves whether the clock is running; a stopped clock becomes paused. - # Pass `started: false` to wait to start from the given elapsed time. + # Reset the clock to its initial, ready state, or set its elapsed time. + # A numeric value preserves whether the clock is running; a ready clock becomes paused. + # Pass `started: false` to make the clock ready at the given elapsed time. # @parameter elapsed [Numeric | Nil] The elapsed time in seconds, or `nil` to clear the clock. - # @parameter started [Boolean] Whether the clock has started at the reset position. + # @parameter started [Boolean] Whether the clock is running or paused at the reset position; false makes it ready. def reset!(elapsed = nil, started: !elapsed.nil?) @elapsed = elapsed || 0 @started = started diff --git a/lib/presently/presentation_controller.rb b/lib/presently/presentation_controller.rb index 5bb968e..8fd52f5 100644 --- a/lib/presently/presentation_controller.rb +++ b/lib/presently/presentation_controller.rb @@ -106,8 +106,8 @@ def slide_progress end # Reset a paused timer to begin the current slide again. - # All slides reset to their expected start time. A `timer: start` slide waits - # to start when advancing; other slides remain paused. + # All slides reset to their expected start time. A `timer: start` slide becomes + # ready to start when advancing; other slides remain paused. def reset_timer! return unless @clock.paused? && current_slide diff --git a/lib/presently/state.rb b/lib/presently/state.rb index d7634c1..84a4f31 100644 --- a/lib/presently/state.rb +++ b/lib/presently/state.rb @@ -9,7 +9,7 @@ module Presently # Persists and restores presentation controller state to/from a JSON file. # - # Tracks the current slide index, clock elapsed time, and clock running state. + # Tracks the current slide index, clock elapsed time, and whether it is ready, running, or paused. # This allows the presentation to survive server restarts without losing position. class State # The default state file path. diff --git a/releases.md b/releases.md index b423a5c..4f83996 100644 --- a/releases.md +++ b/releases.md @@ -2,9 +2,9 @@ ## Unreleased - - Show **Reset** only while paused. Reset always sets elapsed time to the current slide's expected start. It stays paused on ordinary slides, or returns to not started at that timestamp on a `timer: start` slide. Advancing starts from the preserved timestamp, which also survives saving and restoring the presentation. + - Show **Reset** only while paused. Reset always sets elapsed time to the current slide's expected start. It stays paused on ordinary slides, or becomes ready at that timestamp on a `timer: start` slide. Advancing starts from the preserved timestamp, which also survives saving and restoring the presentation. - Describe the effect of slide timer metadata in the **Next** tooltip. Show an animated **Auto-start** indicator when advancing from the current slide will start the timer, with a tooltip explaining how to begin. - - Make `Clock#reset!` (or `reset!(nil)`) clear the clock to its initial, stopped state. Passing a numeric elapsed time preserves whether it is running, or puts a stopped clock into the paused state. Pass `started: false` to wait to start from a nonzero elapsed time. + - Make `Clock#reset!` (or `reset!(nil)`) clear the clock to its initial, ready state. Passing a numeric elapsed time preserves whether it is running, or puts a ready clock into the paused state. Pass `started: false` to make the clock ready at a nonzero elapsed time. ## v0.26.0