A small TUI based timer with Work/Rest cycles and sound
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 446 lines 10 kB view raw
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}