Skip to content

Commit e04a52d

Browse files
committed
feat(tui): add timer system for presentations
- Add `Timer` struct with start, stop, pause, resume functionality - Add `TimerDisplay` component with toggle visibility and overlay rendering - Integrate per-slide timers and global presentation timer - Add timer update handling in slide and main model updates - Add 't' key binding to toggle timer display - Add timer navigation helpers to pause/resume on slide transitions
1 parent ccf9e01 commit e04a52d

File tree

3 files changed

+215
-23
lines changed

3 files changed

+215
-23
lines changed

internal/tui/slide.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Slide struct {
1818
ActiveTransition transitions.Transition
1919
Properties config.Properties
2020
Title string
21+
Timer Timer
2122

2223
preRenderedFrame string
2324
}
@@ -33,7 +34,12 @@ func (s *Slide) Update() (*Slide, tea.Cmd) {
3334
if cmd == nil {
3435
s.preRenderedFrame = ""
3536
}
36-
return s, cmd
37+
38+
// Update timer
39+
var timerCmd tea.Cmd
40+
s.Timer, timerCmd = s.Timer.Update(TimerTickMsg{})
41+
42+
return s, tea.Batch(cmd, timerCmd)
3743
}
3844

3945
func (s Slide) View() string {

internal/tui/timer.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
tea "github.com/charmbracelet/bubbletea"
8+
"github.com/charmbracelet/lipgloss"
9+
)
10+
11+
type TimerTickMsg struct{}
12+
13+
type Timer struct {
14+
startTime time.Time
15+
duration time.Duration
16+
running bool
17+
}
18+
19+
func NewTimer() Timer {
20+
return Timer{
21+
startTime: time.Now(),
22+
duration: 0,
23+
running: false,
24+
}
25+
}
26+
27+
func (t Timer) Start() Timer {
28+
if !t.running {
29+
t.startTime = time.Now()
30+
t.duration = 0
31+
t.running = true
32+
}
33+
return t
34+
}
35+
36+
37+
func (t Timer) Reset() Timer {
38+
t.startTime = time.Now()
39+
t.duration = 0
40+
return t
41+
}
42+
43+
func (t Timer) Pause() Timer {
44+
if t.running {
45+
t.duration = time.Since(t.startTime)
46+
t.running = false
47+
}
48+
return t
49+
}
50+
51+
func (t Timer) Resume() Timer {
52+
if !t.running {
53+
t.startTime = time.Now().Add(-t.duration)
54+
t.running = true
55+
}
56+
return t
57+
}
58+
59+
func (t Timer) Update(msg tea.Msg) (Timer, tea.Cmd) {
60+
switch msg.(type) {
61+
case TimerTickMsg:
62+
if t.running {
63+
t.duration = time.Since(t.startTime)
64+
}
65+
return t, tea.Tick(time.Second, func(time.Time) tea.Msg {
66+
return TimerTickMsg{}
67+
})
68+
}
69+
return t, nil
70+
}
71+
72+
func (t Timer) Duration() time.Duration {
73+
if t.running {
74+
return time.Since(t.startTime)
75+
}
76+
return t.duration
77+
}
78+
79+
func (t Timer) FormatDuration() string {
80+
d := t.Duration()
81+
totalSeconds := int(d.Seconds())
82+
minutes := totalSeconds / 60
83+
seconds := totalSeconds % 60
84+
return fmt.Sprintf("%02d:%02d", minutes, seconds)
85+
}
86+
87+
func (t Timer) IsRunning() bool {
88+
return t.running
89+
}
90+
91+
type TimerDisplay struct {
92+
visible bool
93+
}
94+
95+
func NewTimerDisplay() TimerDisplay {
96+
return TimerDisplay{
97+
visible: false,
98+
}
99+
}
100+
101+
func (td TimerDisplay) Update(msg tea.Msg) (TimerDisplay, tea.Cmd) {
102+
// TimerDisplay doesn't need to update timers, just handle display state
103+
return td, nil
104+
}
105+
106+
func (td TimerDisplay) Show(slideView string, width, height int, globalTimer Timer, slideTimer Timer) string {
107+
if !td.visible {
108+
return slideView
109+
}
110+
111+
globalTimeStr := globalTimer.FormatDuration()
112+
slideTimeStr := slideTimer.FormatDuration()
113+
114+
timerContent := lipgloss.NewStyle().
115+
Background(lipgloss.Color("#2A2A2A")).
116+
Foreground(lipgloss.Color("#DDDDDD")).
117+
Padding(0, 1).
118+
Render(fmt.Sprintf("Total: %s\nSlide: %s", globalTimeStr, slideTimeStr))
119+
120+
// Position in top left
121+
timerX := 3
122+
timerY := 1
123+
124+
return placeOverlay(timerX, timerY, timerContent, slideView)
125+
}
126+
127+
func (td TimerDisplay) IsVisible() bool {
128+
return td.visible
129+
}
130+
131+
func (td TimerDisplay) ToggleVisible() TimerDisplay {
132+
td.visible = !td.visible
133+
return td
134+
}
135+
136+
func EnsureTimerInitialized(slide *Slide) {
137+
if slide != nil && slide.Timer.startTime.IsZero() && slide.Timer.duration == 0 && !slide.Timer.running {
138+
slide.Timer = NewTimer()
139+
}
140+
}

internal/tui/tui.go

Lines changed: 68 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
tea "github.com/charmbracelet/bubbletea"
77
"github.com/charmbracelet/lipgloss"
88

9+
"time"
10+
911
"github.com/museslabs/kyma/internal/config"
1012
"github.com/museslabs/kyma/internal/tui/transitions"
1113
)
@@ -19,6 +21,7 @@ type keyMap struct {
1921
Command key.Binding
2022
GoTo key.Binding
2123
Jump key.Binding
24+
Timer key.Binding
2225
}
2326

2427
func (k keyMap) ShortHelp() []key.Binding {
@@ -62,36 +65,66 @@ var keys = keyMap{
6265
key.WithKeys("1", "2", "3", "4", "5", "6", "7", "8", "9"),
6366
key.WithHelp("1-9", "jump slides"),
6467
),
68+
Timer: key.NewBinding(
69+
key.WithKeys("t"),
70+
key.WithHelp("t", "toggle timer"),
71+
),
6572
}
6673

6774
func style(width, height int, styleConfig config.StyleConfig) config.SlideStyle {
6875
return styleConfig.Apply(width, height)
6976
}
7077

78+
// navigateToSlide handles the common pattern of pausing current timer, switching slides, and resuming
79+
func (m *model) navigateToSlide(newSlide *Slide) {
80+
if m.slide != nil {
81+
m.slide.Timer = m.slide.Timer.Pause()
82+
}
83+
m.slide = newSlide
84+
EnsureTimerInitialized(m.slide)
85+
if m.slide != nil {
86+
m.slide.Timer = m.slide.Timer.Resume()
87+
}
88+
}
89+
7190
type model struct {
7291
width int
7392
height int
7493

75-
slide *Slide
76-
keys keyMap
77-
help help.Model
78-
command *Command
79-
goTo *GoTo
80-
jump *Jump
81-
rootSlide *Slide
94+
slide *Slide
95+
keys keyMap
96+
help help.Model
97+
command *Command
98+
goTo *GoTo
99+
jump *Jump
100+
rootSlide *Slide
101+
globalTimer Timer
102+
timerDisplay TimerDisplay
82103
}
83104

84105
func New(rootSlide *Slide) model {
106+
// Initialize timer only for the first slide
107+
if rootSlide != nil {
108+
rootSlide.Timer = NewTimer().Start()
109+
}
110+
85111
return model{
86-
slide: rootSlide,
87-
keys: keys,
88-
help: help.New(),
89-
rootSlide: rootSlide,
112+
slide: rootSlide,
113+
keys: keys,
114+
help: help.New(),
115+
rootSlide: rootSlide,
116+
globalTimer: NewTimer().Start(),
117+
timerDisplay: NewTimerDisplay(),
90118
}
91119
}
92120

93121
func (m model) Init() tea.Cmd {
94-
return tea.ClearScreen
122+
return tea.Batch(
123+
tea.ClearScreen,
124+
tea.Tick(time.Second, func(time.Time) tea.Msg {
125+
return TimerTickMsg{}
126+
}),
127+
)
95128
}
96129

97130
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -101,7 +134,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101134

102135
if command.quitting || command.Choice() != nil {
103136
if command.Choice() != nil {
104-
m.slide = command.Choice()
137+
m.navigateToSlide(command.Choice())
105138
}
106139
m.command = nil
107140
return m, nil
@@ -121,7 +154,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121154
slide = slide.Next
122155
}
123156
if slide != nil {
124-
m.slide = slide
157+
m.navigateToSlide(slide)
125158
}
126159
}
127160
m.goTo = nil
@@ -136,17 +169,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
136169

137170
if jump.Quitting() {
138171
if steps := jump.JumpSteps(); steps != 0 {
172+
newSlide := m.slide
139173
if steps > 0 {
140174
// Jump forward
141-
for i := 0; i < steps && m.slide.Next != nil; i++ {
142-
m.slide = m.slide.Next
175+
for i := 0; i < steps && newSlide.Next != nil; i++ {
176+
newSlide = newSlide.Next
143177
}
144178
} else {
145179
// Jump backward
146-
for i := 0; i < -steps && m.slide.Prev != nil; i++ {
147-
m.slide = m.slide.Prev
180+
for i := 0; i < -steps && newSlide.Prev != nil; i++ {
181+
newSlide = newSlide.Prev
148182
}
149183
}
184+
m.navigateToSlide(newSlide)
150185
}
151186
m.jump = nil
152187
return m, nil
@@ -208,18 +243,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
208243
jump, cmd := jump.Update(msg)
209244
m.jump = &jump
210245
return m, cmd
246+
} else if key.Matches(msg, m.keys.Timer) {
247+
m.timerDisplay = m.timerDisplay.ToggleVisible()
248+
return m, nil
211249
} else if key.Matches(msg, m.keys.Next) {
212250
if m.slide.Next == nil || m.slide.ActiveTransition != nil && m.slide.ActiveTransition.Animating() {
213251
return m, nil
214252
}
215-
m.slide = m.slide.Next
253+
m.navigateToSlide(m.slide.Next)
216254
m.slide.ActiveTransition = m.slide.Properties.Transition.Start(m.width, m.height, transitions.Forwards)
217255
return m, transitions.Animate(transitions.Fps)
218256
} else if key.Matches(msg, m.keys.Prev) {
219257
if m.slide.Prev == nil || m.slide.ActiveTransition != nil && m.slide.ActiveTransition.Animating() {
220258
return m, nil
221259
}
222-
m.slide = m.slide.Prev
260+
m.navigateToSlide(m.slide.Prev)
223261
m.slide.ActiveTransition = m.slide.
224262
Next.
225263
Properties.
@@ -229,16 +267,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
229267

230268
return m, transitions.Animate(transitions.Fps)
231269
} else if key.Matches(msg, m.keys.Top) {
232-
m.slide = m.slide.First()
270+
m.navigateToSlide(m.slide.First())
233271
return m, nil
234272
} else if key.Matches(msg, m.keys.Bottom) {
235-
m.slide = m.slide.Last()
273+
m.navigateToSlide(m.slide.Last())
236274
return m, nil
237275
}
238276
case transitions.FrameMsg:
239277
slide, cmd := m.slide.Update()
240278
m.slide = slide
241279
return m, cmd
280+
case TimerTickMsg:
281+
var cmd tea.Cmd
282+
m.globalTimer, cmd = m.globalTimer.Update(msg)
283+
return m, cmd
242284
}
243285

244286
return m, nil
@@ -267,5 +309,9 @@ func (m model) View() string {
267309
return m.jump.Show(slideView, m.width, m.height)
268310
}
269311

312+
if m.timerDisplay.IsVisible() {
313+
return m.timerDisplay.Show(slideView, m.width, m.height, m.globalTimer, m.slide.Timer)
314+
}
315+
270316
return slideView
271317
}

0 commit comments

Comments
 (0)