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.

cute

John Bakhmat 40c0bf34

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

This is a binary file and will not be displayed.