A small TUI based timer with Work/Rest cycles and sound
1package main
2import "core:c"
3import "core:fmt"
4import "core:sys/posix"
5import "core:time"
6
7foreign import libc "system:c"
8
9@(default_calling_convention="c")
10foreign libc {
11 system :: proc(command: cstring) -> c.int ---
12}
13
14Phase :: enum {
15 WORK,
16 BREAK,
17}
18State :: enum {
19 IDLE,
20 RUNNING,
21 PAUSED,
22}
23
24App :: struct {
25 work_s, break_s: int,
26 phase: Phase,
27 state: State,
28 accumulated: f64, // seconds elapsed before last resume
29 start_ts: time.Time, // when resumed
30 last_rendered_sec: int, // track when to re-render
31 preset_idx: int, // current preset index (0-3)
32}
33
34orig_termios: posix.termios
35
36raw_on :: proc() {
37 posix.tcgetattr(posix.STDIN_FILENO, &orig_termios)
38 raw := orig_termios
39 raw.c_lflag &= ~{.ECHO, .ICANON}
40 raw.c_cc[.VMIN] = 0
41 raw.c_cc[.VTIME] = 0
42 posix.tcsetattr(posix.STDIN_FILENO, .TCSANOW, &raw)
43 fmt.print("\x1b[?25l") // hide cursor
44}
45
46raw_off :: proc() {
47 posix.tcsetattr(posix.STDIN_FILENO, .TCSANOW, &orig_termios)
48 fmt.print("\x1b[?25h") // show cursor
49}
50
51now_s :: proc() -> f64 {
52 return time.duration_seconds(time.since(time.Time{}))
53}
54
55elapsed :: proc(a: ^App) -> f64 {
56 if a.state == .RUNNING {
57 return a.accumulated + time.duration_seconds(time.since(a.start_ts))
58 }
59 return a.accumulated
60}
61
62duration :: proc(a: ^App) -> int {
63 return a.work_s if a.phase == .WORK else a.break_s
64}
65
66poll_key :: proc() -> (c: u8, ok: bool) {
67 fds: posix.fd_set
68 posix.FD_ZERO(&fds)
69 posix.FD_SET(posix.STDIN_FILENO, &fds)
70 tv := posix.timeval{0, 0}
71 if posix.select(posix.STDIN_FILENO + 1, &fds, nil, nil, &tv) > 0 {
72 buf: [1]u8
73 if posix.read(posix.STDIN_FILENO, raw_data(&buf), 1) == 1 {
74 return buf[0], true
75 }
76 }
77 return 0, false
78}
79
80rgb :: proc(r, g, b: u8) -> string {
81 return fmt.tprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
82}
83
84bg_rgb :: proc(r, g, b: u8) -> string {
85 return fmt.tprintf("\x1b[48;2;%d;%d;%dm", r, g, b)
86}
87
88gradient_color :: proc(pos: f64, is_work: bool) -> (r, g, b: u8) {
89 t := clamp(pos, 0, 1)
90 if is_work {
91 // Work: green (50, 205, 50) -> yellow (255, 215, 0) -> red (255, 50, 50)
92 if t < 0.5 {
93 t2 := t * 2
94 r = u8(50 + (255 - 50) * t2)
95 g = u8(205 + (215 - 205) * t2)
96 b = u8(50 + (0 - 50) * t2)
97 } else {
98 t2 := (t - 0.5) * 2
99 r = 255
100 g = u8(215 + (50 - 215) * t2)
101 b = u8(0 + (50 - 0) * t2)
102 }
103 } else {
104 // Break: blue (30, 144, 255) -> cyan (0, 255, 255) -> green (50, 205, 50)
105 if t < 0.5 {
106 t2 := t * 2
107 r = u8(30 + (0 - 30) * t2)
108 g = u8(144 + (255 - 144) * t2)
109 b = 255
110 } else {
111 t2 := (t - 0.5) * 2
112 r = u8(0 + (50 - 0) * t2)
113 g = 255
114 b = u8(255 + (50 - 255) * t2)
115 }
116 }
117 return
118}
119
120// Terminal theme colors (ANSI 16-color palette)
121C_BORDER :: "\x1b[36m" // Cyan
122C_GOLD :: "\x1b[33m" // Yellow
123C_WHITE :: "\x1b[97m" // Bright white
124C_GRAY :: "\x1b[37m" // White (normal)
125C_DIM :: "\x1b[90m" // Bright black (gray)
126C_DARK :: "\x1b[37m" // White for inactive presets
127C_GREEN :: "\x1b[92m" // Bright green for active preset
128C_RESET :: "\x1b[0m"
129
130// Preset data
131Preset :: struct {
132 key: u8,
133 name: string,
134 work_min: int,
135 break_min: int,
136}
137
138presets :: [?]Preset{
139 {'1', "Testing", 2, 1},
140 {'2', "Classic", 25, 5},
141 {'3', "Long", 50, 10},
142}
143
144apply_preset :: proc(a: ^App, idx: int) {
145 a.preset_idx = idx
146 switch idx {
147 case 0:
148 a.work_s = 2 * 60
149 a.break_s = 1 * 60
150 case 1:
151 a.work_s = 25 * 60
152 a.break_s = 5 * 60
153 case 2:
154 a.work_s = 50 * 60
155 a.break_s = 10 * 60
156 }
157 a.phase = .WORK
158 a.state = .IDLE
159 a.accumulated = 0
160}
161
162render :: proc(a: ^App) {
163 dur := duration(a)
164 el := int(elapsed(a))
165 rem := max(0, dur - el)
166 pct := f64(el) / f64(dur) if dur > 0 else 0.0
167 pct = clamp(pct, 0, 1)
168 bar_width :: 26
169 fill := int(pct * f64(bar_width))
170
171 fmt.print("\x1b[H\x1b[2J")
172
173 // Top border with SUPERMEMORY
174 fmt.printfln(
175 " %s┌─[%sPOMODORO TIMER%s]────────────────────┐%s",
176 C_BORDER,
177 C_GOLD,
178 C_BORDER,
179 C_RESET,
180 )
181 fmt.printfln(
182 " %s│%s %s│%s",
183 C_BORDER,
184 C_RESET,
185 C_BORDER,
186 C_RESET,
187 )
188 fmt.printfln(
189 " %s│%s %s│%s",
190 C_BORDER,
191 C_RESET,
192 C_BORDER,
193 C_RESET,
194 )
195
196 // Phase and state - hardcoded for perfect alignment
197 // Content width = 37 chars
198 if a.phase == .WORK && a.state == .IDLE {
199 fmt.printfln(" %s│%s %sPhase: 💼 WORK State: ⏹️ IDLE %s│%s", C_BORDER, C_RESET, C_GRAY, C_BORDER, C_RESET)
200 } else if a.phase == .WORK && a.state == .RUNNING {
201 fmt.printfln(" %s│%s %sPhase: 💼 WORK State: ▶️ RUNNING %s│%s", C_BORDER, C_RESET, C_GRAY, C_BORDER, C_RESET)
202 } else if a.phase == .WORK && a.state == .PAUSED {
203 fmt.printfln(" %s│%s %sPhase: 💼 WORK State: ⏸️ PAUSED %s│%s", C_BORDER, C_RESET, C_GRAY, C_BORDER, C_RESET)
204 } else if a.phase == .BREAK && a.state == .IDLE {
205 fmt.printfln(" %s│%s %sPhase: ☕ BREAK State: ⏹️ IDLE %s│%s", C_BORDER, C_RESET, C_GRAY, C_BORDER, C_RESET)
206 } else if a.phase == .BREAK && a.state == .RUNNING {
207 fmt.printfln(" %s│%s %sPhase: ☕ BREAK State: ▶️ RUNNING %s│%s", C_BORDER, C_RESET, C_GRAY, C_BORDER, C_RESET)
208 } else if a.phase == .BREAK && a.state == .PAUSED {
209 fmt.printfln(" %s│%s %sPhase: ☕ BREAK State: ⏸️ PAUSED %s│%s", C_BORDER, C_RESET, C_GRAY, C_BORDER, C_RESET)
210 }
211 fmt.printfln(
212 " %s│%s %s│%s",
213 C_BORDER,
214 C_RESET,
215 C_BORDER,
216 C_RESET,
217 )
218
219 // Time
220 mins_el, secs_el := el / 60, el % 60
221 mins_rem, secs_rem := rem / 60, rem % 60
222 fmt.printfln(
223 " %s│%s %sElapsed: %02d:%02d %s│%s",
224 C_BORDER,
225 C_RESET,
226 C_GRAY,
227 mins_el,
228 secs_el,
229 C_BORDER,
230 C_RESET,
231 )
232 fmt.printfln(
233 " %s│%s %sRemaining: %02d:%02d %s│%s",
234 C_BORDER,
235 C_RESET,
236 C_GRAY,
237 mins_rem,
238 secs_rem,
239 C_BORDER,
240 C_RESET,
241 )
242 fmt.printfln(
243 " %s│%s %s│%s",
244 C_BORDER,
245 C_RESET,
246 C_BORDER,
247 C_RESET,
248 )
249
250 // Progress bar
251 fmt.print(" "); fmt.print(C_BORDER); fmt.print("│ ["); fmt.print(C_RESET)
252 for i in 0 ..< bar_width {
253 if i < fill {
254 pos := f64(i) / f64(bar_width - 1) if bar_width > 1 else 0
255 r, g, b := gradient_color(pos, a.phase == .WORK)
256 fmt.print(rgb(r, g, b))
257 fmt.print("=")
258 } else {
259 fmt.print(rgb(60, 60, 60))
260 fmt.print("-")
261 }
262 }
263 fmt.print(C_RESET); fmt.print("]")
264 fmt.printfln(" %3d%% %s│%s", int(pct * 100), C_BORDER, C_RESET)
265 fmt.printfln(
266 " %s│%s %s│%s",
267 C_BORDER,
268 C_RESET,
269 C_BORDER,
270 C_RESET,
271 )
272
273 // Presets
274 fmt.printfln(
275 " %s│%s %s--- PRESETS --- %s│%s",
276 C_BORDER,
277 C_RESET,
278 C_DIM,
279 C_BORDER,
280 C_RESET,
281 )
282 preset_keys := [?]u8{'1', '2', '3'}
283 preset_names := [?]string{"Testing", "Classic", "Long"}
284 preset_work := [?]int{2, 25, 50}
285 preset_break := [?]int{1, 5, 10}
286 for idx in 0..<3 {
287 marker := ""
288 is_active := idx == a.preset_idx
289 if is_active {
290 marker = " <--"
291 }
292 val := fmt.tprintf("%d/%d", preset_work[idx], preset_break[idx])
293 line := fmt.tprintf("[%c] %-8s %-5s%-4s", preset_keys[idx], preset_names[idx], val, marker)
294 color := C_GREEN if is_active else C_DARK
295 fmt.printfln(" %s│%s %s%-35s%s│%s", C_BORDER, C_RESET, color, line, C_BORDER, C_RESET)
296 }
297 fmt.printfln(
298 " %s│%s %s│%s",
299 C_BORDER,
300 C_RESET,
301 C_BORDER,
302 C_RESET,
303 )
304
305 // Controls
306 fmt.printfln(
307 " %s│%s %s--- CONTROLS --- %s│%s",
308 C_BORDER,
309 C_RESET,
310 C_DIM,
311 C_BORDER,
312 C_RESET,
313 )
314 fmt.printfln(
315 " %s│%s %s[s] start [p] pause %s│%s",
316 C_BORDER,
317 C_RESET,
318 C_GRAY,
319 C_BORDER,
320 C_RESET,
321 )
322 fmt.printfln(
323 " %s│%s %s[x] stop [q] quit %s│%s",
324 C_BORDER,
325 C_RESET,
326 C_GRAY,
327 C_BORDER,
328 C_RESET,
329 )
330 fmt.printfln(
331 " %s│%s %s│%s",
332 C_BORDER,
333 C_RESET,
334 C_BORDER,
335 C_RESET,
336 )
337 fmt.printfln(" %s└─────────────────────────────────────┘%s", C_BORDER, C_RESET)
338 fmt.println("")
339
340 a.last_rendered_sec = el
341}
342
343main :: proc() {
344 app := App {
345 work_s = 2 * 60,
346 break_s = 1 * 60,
347 phase = .WORK,
348 state = .IDLE,
349 accumulated = 0,
350 last_rendered_sec = -1,
351 preset_idx = 0,
352 }
353 raw_on()
354 defer raw_off()
355 render(&app)
356 for {
357 dirty := false
358 // poll input
359 for {
360 c, ok := poll_key()
361 if !ok do break
362
363 // Handle escape sequences (arrow keys)
364 if c == 27 { // ESC
365 // Check for arrow key sequence ESC [ X
366 c2, ok2 := poll_key()
367 if ok2 && c2 == '[' {
368 c3, ok3 := poll_key()
369 if ok3 {
370 switch c3 {
371 case 'A': // Up arrow - previous preset
372 new_idx := app.preset_idx - 1
373 if new_idx < 0 {
374 new_idx = len(presets) - 1
375 }
376 apply_preset(&app, new_idx)
377 dirty = true
378 case 'B': // Down arrow - next preset
379 new_idx := app.preset_idx + 1
380 if new_idx >= len(presets) {
381 new_idx = 0
382 }
383 apply_preset(&app, new_idx)
384 dirty = true
385 }
386 }
387 }
388 continue
389 }
390
391 switch c {
392 case 'q':
393 return
394 case '1':
395 apply_preset(&app, 0)
396 dirty = true
397 case '2':
398 apply_preset(&app, 1)
399 dirty = true
400 case '3':
401 apply_preset(&app, 2)
402 dirty = true
403 case 'x':
404 app.phase = .WORK
405 app.state = .IDLE
406 app.accumulated = 0
407 dirty = true
408 case 'p':
409 if app.state == .RUNNING {
410 app.accumulated = elapsed(&app)
411 app.state = .PAUSED
412 dirty = true
413 }
414 case 's':
415 if app.state == .IDLE || app.state == .PAUSED {
416 app.start_ts = time.now()
417 app.state = .RUNNING
418 dirty = true
419 }
420 }
421 }
422 // check phase transition
423 if app.state == .RUNNING {
424 if int(elapsed(&app)) >= duration(&app) {
425 app.phase = .BREAK if app.phase == .WORK else .WORK
426 app.accumulated = 0
427 app.start_ts = time.now()
428 dirty = true
429 // Play sound.ogg at 20% volume using ffplay
430 system("ffplay -nodisp -autoexit -volume 20 sound.ogg > /dev/null 2>&1 &")
431 }
432 }
433 // check if second changed (only re-render on tick)
434 if app.state == .RUNNING {
435 cur_sec := int(elapsed(&app))
436 if cur_sec != app.last_rendered_sec {
437 dirty = true
438 }
439 }
440 if dirty {
441 render(&app)
442 }
443 // sleep ~50ms to avoid busy loop
444 time.sleep(50 * time.Millisecond)
445 }
446}