1#![allow(clippy::too_many_arguments)]
2use std::f64::consts::PI;
3
4use ratatui::layout::{Alignment, Rect};
5use ratatui::style::Color;
6use ratatui::symbols::Marker;
7use ratatui::text::{Line, Span};
8use ratatui::widgets::canvas::{Canvas, Circle, Points};
9use ratatui::widgets::{Paragraph, Wrap};
10use ratatui::Frame;
11
12use crate::model::{TimerMode, TimerState};
13use crate::timer::Timer;
14
15const ARC_STEPS: usize = 360;
16const PARTICLE_COUNT: usize = 12;
17
18pub struct TimerCanvasStyle {
21 pub track: Color,
22 pub progress: Color,
23 pub progress_dim: Color,
24 pub task_track: Color,
25 pub task_progress: Color,
26 pub cap: Color,
27 pub text: Color,
28 pub dim: Color,
29}
30
31pub struct TimerCanvasOptions {
32 pub task_progress: Option<f64>,
33 pub breathe: bool,
34}
35
36impl Default for TimerCanvasOptions {
37 fn default() -> Self {
38 Self {
39 task_progress: None,
40 breathe: true,
41 }
42 }
43}
44
45pub struct SceneStyle {
47 pub mode: Color,
48 pub track: Color,
49 pub task: Color,
50 pub task_dim: Color,
51 pub bg: Color,
52 pub bg_mid: Color,
53 pub bg_light: Color,
54 pub wave: Color,
55 pub core: Color,
56 pub glow: Color,
57 pub particle: Color,
58 pub text: Color,
59 pub session_on: Color,
60 pub session_off: Color,
61}
62
63pub type DashboardSceneStyle = SceneStyle;
64pub type ZenSceneStyle = SceneStyle;
65
66#[derive(Clone, Copy, PartialEq, Eq)]
67pub enum SceneLayout {
68 Dashboard,
69 Zen,
70}
71
72#[derive(Clone, Copy)]
73pub struct SceneOptions {
74 pub task_progress: Option<f64>,
75 pub pending_tasks: u32,
76 pub active_task_index: Option<u32>,
77 pub sessions_done: u32,
78 pub sessions_total: u32,
79 pub layout: SceneLayout,
80}
81
82pub type DashboardSceneOptions = SceneOptions;
83pub type ZenSceneOptions = SceneOptions;
84
85const BREAK_TIPS: &[&str] = &[
88 "Look at something 20 feet (6 m) away for 20 seconds — the 20-20-20 rule protects your eyes.",
89 "Stand up and roll your shoulders back slowly for 30 seconds.",
90 "Blink slowly 10 times to re-wet tired eyes.",
91 "Take 4 deep breaths: inhale 4 s, hold 4 s, exhale 6 s.",
92 "Walk to a window and focus on the farthest object you can see.",
93 "Gently turn your neck left and right — never force the stretch.",
94 "Close your eyes for 20 seconds and let them fully rest.",
95 "Stand and do 10 calf raises to boost leg circulation.",
96 "Roll your wrists clockwise, then counterclockwise.",
97 "Massage your temples with slow, gentle circular motions.",
98 "Drink a glass of water — hydration helps focus and energy.",
99 "Reach your arms overhead and stretch your whole spine.",
100 "Look outside at greenery — natural scenes relax eye muscles.",
101 "Unclench your jaw and let your tongue rest on the roof of your mouth.",
102 "Stand, touch your toes, or do a gentle forward fold for 20 seconds.",
103 "Focus on a distant horizon line to relax your ciliary muscles.",
104 "Open a window for fresh air and take three slow breaths.",
105 "Stretch your fingers wide, then make a fist — repeat 8 times.",
106 "Shift your gaze between near and far objects three times.",
107 "Stand up every break — sitting too long strains your back and hips.",
108];
109
110const TIP_SLOT_SECS: f64 = 9.0;
111
112#[derive(Debug, Clone)]
113pub struct BreakTip {
114 pub text: String,
115 pub fade: f64,
116 pub reveal: f64,
117}
118
119pub fn current_break_tip(timer: &Timer) -> Option<BreakTip> {
120 match timer.mode {
121 TimerMode::ShortBreak | TimerMode::LongBreak => {}
122 _ => return None,
123 }
124
125 if matches!(timer.state, TimerState::Idle) {
126 return Some(BreakTip {
127 text: "Press start — use this break to rest your eyes and body.".into(),
128 fade: 0.65,
129 reveal: 1.0,
130 });
131 }
132
133 let elapsed = timer.current_elapsed_secs_f64();
134 let slot = TIP_SLOT_SECS;
135 let idx = (elapsed / slot).floor() as usize % BREAK_TIPS.len();
136 let phase = (elapsed % slot) / slot;
137
138 Some(BreakTip {
139 text: BREAK_TIPS[idx].to_string(),
140 fade: tip_fade(phase),
141 reveal: tip_reveal(phase),
142 })
143}
144
145fn tip_fade(phase: f64) -> f64 {
146 const IN: f64 = 0.12;
147 const OUT: f64 = 0.88;
148 if phase < IN {
149 smoothstep(phase / IN)
150 } else if phase > OUT {
151 smoothstep((1.0 - phase) / (1.0 - OUT))
152 } else {
153 1.0
154 }
155}
156
157fn tip_reveal(phase: f64) -> f64 {
158 const IN: f64 = 0.35;
159 if phase < IN {
160 smoothstep(phase / IN)
161 } else {
162 1.0
163 }
164}
165
166pub fn draw_break_tip(
167 f: &mut Frame,
168 area: Rect,
169 timer: &Timer,
170 accent: Color,
171 text: Color,
172 dim: Color,
173 heart: &str,
174) {
175 if area.height == 0 || area.width < 4 {
176 return;
177 }
178 let Some(tip) = current_break_tip(timer) else {
179 return;
180 };
181
182 let visible_chars = ((tip.text.chars().count() as f64) * tip.reveal).ceil() as usize;
183 let shown: String = tip.text.chars().take(visible_chars).collect();
184 let fg = blend_color(dim, text, tip.fade);
185 let prefix_fg = blend_color(dim, accent, tip.fade);
186
187 f.render_widget(
188 Paragraph::new(Line::from(vec![
189 Span::styled(
190 format!("{} ", heart),
191 ratatui::style::Style::default().fg(prefix_fg),
192 ),
193 Span::styled(shown, ratatui::style::Style::default().fg(fg)),
194 ]))
195 .wrap(Wrap { trim: true })
196 .alignment(Alignment::Center),
197 area,
198 );
199}
200
201pub fn draw_dashboard_canvas(
204 f: &mut Frame,
205 area: Rect,
206 timer: &Timer,
207 style: &DashboardSceneStyle,
208 options: &DashboardSceneOptions,
209) {
210 let mut opts = *options;
211 opts.layout = SceneLayout::Dashboard;
212 draw_scene_canvas(f, area, timer, style, &opts);
213}
214
215pub fn draw_zen_canvas(
216 f: &mut Frame,
217 area: Rect,
218 timer: &Timer,
219 style: &ZenSceneStyle,
220 options: &ZenSceneOptions,
221) {
222 let mut opts = *options;
223 opts.layout = SceneLayout::Zen;
224 draw_scene_canvas(f, area, timer, style, &opts);
225}
226
227pub fn draw_scene_canvas(
228 f: &mut Frame,
229 area: Rect,
230 timer: &Timer,
231 style: &SceneStyle,
232 options: &SceneOptions,
233) {
234 if area.width < 8 || area.height < 4 {
235 return;
236 }
237
238 let remaining = (1.0 - timer.progress()).clamp(0.0, 1.0);
239 let motion = scene_motion(timer);
240 let (xb, yb) = square_bounds(area);
241 let marker = canvas_marker(area);
242 let t = time_s();
243 let zen = options.layout == SceneLayout::Zen;
244 let compact = !zen;
245 let extent = canvas_extent(xb, yb);
246 let base_r = fit_base_r(extent, options.layout);
247
248 let bg = style.bg;
249 let bg_mid = style.bg_mid;
250 let bg_light = style.bg_light;
251 let wave = style.wave;
252 let core = style.core;
253 let glow = style.glow;
254 let particle = style.particle;
255 let mode = style.mode;
256 let track = style.track;
257 let task = style.task;
258 let task_dim = style.task_dim;
259 let text = style.text;
260 let session_on = style.session_on;
261 let session_off = style.session_off;
262 let pending = options.pending_tasks;
263 let active_idx = options.active_task_index;
264 let task_prog = options.task_progress;
265 let sessions_done = options.sessions_done;
266 let sessions_total = options.sessions_total;
267 let timer_state = timer.state;
268 let timer_mode = timer.mode;
269
270 let canvas = Canvas::default()
271 .marker(marker)
272 .x_bounds(xb)
273 .y_bounds(yb)
274 .paint(move |ctx| {
275 let (cx, cy) = center(xb, yb);
276 let breath = motion.breath;
277 let base = base_r * motion.scale * (0.86 + 0.14 * remaining);
278
279 if compact {
280 draw_bg_wash_compact(ctx, cx, cy, base, bg, bg_mid);
281 } else {
282 draw_bg_wash(ctx, (xb, yb), (cx, cy, base), (bg, bg_mid, bg_light));
283 }
284 draw_bg_waves(
285 ctx,
286 (xb, yb),
287 (cx, cy, base, t),
288 motion,
289 (wave, bg_mid),
290 timer_mode,
291 compact,
292 );
293 draw_particles(
294 ctx,
295 (cx, cy, base, t),
296 motion,
297 (particle, bg),
298 if compact {
299 5
300 } else if zen && pending == 0 {
301 PARTICLE_COUNT + 4
302 } else {
303 PARTICLE_COUNT
304 },
305 );
306
307 if pending == 0 && !compact {
308 let moon_x = if zen {
309 cx + base * 0.95
310 } else {
311 cx - base * 0.88
312 };
313 draw_idle_marker(ctx, moon_x, cy - base * 0.72, t, glow, core);
314 } else if zen {
315 draw_task_constellation(
316 ctx,
317 (cx, cy, base, t),
318 motion,
319 pending,
320 active_idx,
321 (task, task_dim, bg),
322 );
323 } else {
324 draw_task_orbit(
325 ctx,
326 (cx, cy, base, t),
327 motion,
328 pending,
329 active_idx,
330 (task, task_dim, bg),
331 );
332 }
333
334 draw_soft_progress_wreath(
335 ctx,
336 (cx, cy, base * if zen { 1.18 } else { 1.12 }, t),
337 remaining,
338 (track, mode),
339 timer_state,
340 compact,
341 );
342
343 draw_timer_orb(
344 ctx,
345 (cx, cy, base, breath),
346 motion,
347 (bg, glow, core, mode),
348 timer_state,
349 timer_mode,
350 compact,
351 );
352
353 if let Some(tp) = task_prog {
354 if tp > 0.001 {
355 draw_task_fill(ctx, cx, cy, base * 0.38, tp, task, bg);
356 }
357 }
358
359 if sessions_total > 0 {
360 draw_session_stars(
361 ctx,
362 (cx, cy, base, t),
363 sessions_done,
364 sessions_total,
365 (session_on, session_off, bg),
366 if zen {
367 SessionArc::Top
368 } else {
369 SessionArc::Bottom
370 },
371 );
372 }
373
374 if timer_state == TimerState::Paused {
375 draw_soft_pause(ctx, cx, cy, base * 0.22, text);
376 }
377
378 if timer_state == TimerState::Finished {
379 draw_completion_shimmer(ctx, cx, cy, base, t, mode, glow);
380 }
381 });
382
383 f.render_widget(canvas, area);
384}
385
386#[derive(Clone, Copy)]
387struct SceneMotion {
388 breath: f64,
389 speed: f64,
390 scale: f64,
391 glow: f64,
392}
393
394fn scene_motion(timer: &Timer) -> SceneMotion {
395 let t = time_s();
396 let on_break = is_break_mode(timer.mode);
397 let breath_hz = if on_break { 0.18 } else { 0.22 };
398 let breath = 0.5 + 0.5 * (t * breath_hz * 2.0 * PI).sin();
399
400 match timer.state {
401 TimerState::Running => SceneMotion {
402 breath,
403 speed: if on_break { 0.55 } else { 1.0 },
404 scale: 1.0 + 0.018 * (t * 0.9).sin(),
405 glow: 1.0,
406 },
407 TimerState::Idle => SceneMotion {
408 breath,
409 speed: 0.45,
410 scale: 0.98 + 0.025 * (t * 0.55).sin(),
411 glow: 0.88,
412 },
413 TimerState::Paused => SceneMotion {
414 breath: 0.52,
415 speed: 0.05,
416 scale: 0.94,
417 glow: 0.42,
418 },
419 TimerState::Finished => SceneMotion {
420 breath: 0.5 + 0.5 * (t * 1.4).sin(),
421 speed: 0.7,
422 scale: 1.04 + 0.025 * (t * 1.8).sin(),
423 glow: 1.15,
424 },
425 }
426}
427
428fn canvas_extent(xb: [f64; 2], yb: [f64; 2]) -> f64 {
429 (xb[1] - xb[0]).min(yb[1] - yb[0])
430}
431
432fn fit_base_r(extent: f64, layout: SceneLayout) -> f64 {
433 let frac = match layout {
434 SceneLayout::Zen => 0.36,
435 SceneLayout::Dashboard => 0.30,
436 };
437 (extent * frac).clamp(7.0, 34.0)
438}
439
440fn draw_bg_wash(
441 ctx: &mut ratatui::widgets::canvas::Context,
442 bounds: ([f64; 2], [f64; 2]),
443 geom: (f64, f64, f64),
444 colors: (Color, Color, Color),
445) {
446 let (xb, yb) = bounds;
447 let (cx, cy, base) = geom;
448 let (bg, bg_mid, bg_light) = colors;
449 let w = xb[1] - xb[0];
450 let h = yb[1] - yb[0];
451 let bands = [(0.18, bg), (0.42, bg_mid), (0.68, bg_light)];
452 for (frac, color) in bands {
453 let ry = cy - base * 0.2 + h * frac * 0.35;
454 let rx = w * 0.52;
455 draw_soft_ellipse(ctx, cx, ry, rx, base * 0.55, color, bg);
456 }
457}
458
459fn draw_bg_wash_compact(
460 ctx: &mut ratatui::widgets::canvas::Context,
461 cx: f64,
462 cy: f64,
463 base: f64,
464 bg: Color,
465 bg_mid: Color,
466) {
467 draw_soft_ellipse(
468 ctx,
469 cx,
470 cy - base * 0.15,
471 base * 1.35,
472 base * 1.05,
473 bg_mid,
474 bg,
475 );
476}
477
478fn draw_soft_ellipse(
479 ctx: &mut ratatui::widgets::canvas::Context,
480 cx: f64,
481 cy: f64,
482 rx: f64,
483 ry: f64,
484 color: Color,
485 bg: Color,
486) {
487 let steps = 72;
488 let mut coords = Vec::with_capacity(steps);
489 for i in 0..steps {
490 let a = 2.0 * PI * i as f64 / steps as f64;
491 coords.push((cx + a.cos() * rx, cy + a.sin() * ry));
492 }
493 ctx.draw(&Points {
494 coords: &coords,
495 color: blend_color(bg, color, 0.55),
496 });
497}
498
499fn draw_bg_waves(
500 ctx: &mut ratatui::widgets::canvas::Context,
501 bounds: ([f64; 2], [f64; 2]),
502 geom: (f64, f64, f64, f64),
503 motion: SceneMotion,
504 colors: (Color, Color),
505 mode: TimerMode,
506 compact: bool,
507) {
508 let (xb, _yb) = bounds;
509 let (_cx, cy, base, t) = geom;
510 let (wave, bg) = colors;
511 let w = xb[1] - xb[0];
512 let on_break = is_break_mode(mode);
513 let bands = if compact {
514 1
515 } else if on_break {
516 2
517 } else {
518 3
519 };
520 let samples = if compact { 40 } else { 64 };
521
522 for band in 0..bands {
523 let mut coords = Vec::with_capacity(samples);
524 let y0 = cy - base * 0.55 + band as f64 * 5.5;
525 let phase = t * motion.speed * 0.12 + band as f64 * 1.7;
526 for i in 0..samples {
527 let x = xb[0] + w * i as f64 / (samples - 1) as f64;
528 let wave = (x * 0.06 + phase).sin() * 3.5 + (x * 0.025 + phase * 1.4).sin() * 2.0;
529 coords.push((x, y0 + wave));
530 }
531 let mix = 0.28 + band as f64 * 0.12;
532 ctx.draw(&Points {
533 coords: &coords,
534 color: blend_color(bg, wave, mix * motion.glow),
535 });
536 }
537}
538
539fn draw_particles(
540 ctx: &mut ratatui::widgets::canvas::Context,
541 geom: (f64, f64, f64, f64),
542 motion: SceneMotion,
543 colors: (Color, Color),
544 count: usize,
545) {
546 let (cx, cy, base, t) = geom;
547 let (particle, bg) = colors;
548 let count = count.min(PARTICLE_COUNT + 4);
549 let spread = base * 1.45;
550
551 for i in 0..count {
552 let seed = i as f64 * 2.399_963_229_728_653;
553 let fx = cx + (seed * 1.7 + t * (0.08 + i as f64 * 0.008)).sin() * spread;
554 let fy = cy + (seed * 2.1 + t * (0.06 + i as f64 * 0.006)).cos() * spread * 0.65;
555 let twinkle = 0.5 + 0.5 * (t * 1.6 + seed).sin();
556 if twinkle < 0.35 {
557 continue;
558 }
559 let r = 0.6 + twinkle * 0.9 * motion.glow;
560 ctx.draw(&Circle {
561 x: fx,
562 y: fy,
563 radius: r,
564 color: blend_color(bg, particle, twinkle * 0.85 * motion.glow),
565 });
566 }
567}
568
569fn draw_idle_marker(
570 ctx: &mut ratatui::widgets::canvas::Context,
571 mx: f64,
572 my: f64,
573 t: f64,
574 glow: Color,
575 core: Color,
576) {
577 let pulse = 1.0 + 0.06 * (t * 0.7).sin();
578 draw_soft_disc(ctx, mx, my, 4.5 * pulse, glow, glow, 5);
579 ctx.draw(&Circle {
580 x: mx,
581 y: my,
582 radius: 2.2,
583 color: core,
584 });
585}
586
587fn draw_task_constellation(
588 ctx: &mut ratatui::widgets::canvas::Context,
589 geom: (f64, f64, f64, f64),
590 motion: SceneMotion,
591 count: u32,
592 active_idx: Option<u32>,
593 colors: (Color, Color, Color),
594) {
595 let (cx, cy, base, t) = geom;
596 let (task, task_dim, bg) = colors;
597 let n = (count as usize).clamp(1, 12);
598 let mut points = Vec::with_capacity(n);
599
600 for i in 0..n {
601 let frac = if n == 1 {
602 0.5
603 } else {
604 i as f64 / (n - 1) as f64
605 };
606 let angle = PI * 1.08 + PI * 0.84 * frac;
607 let wobble = (t * 0.35 + i as f64 * 1.3).sin() * 1.8;
608 let dist = base * (1.08 + 0.06 * (i as f64 * 0.5).sin()) + wobble;
609 let px = cx + angle.cos() * dist;
610 let py = cy + angle.sin() * dist * 0.55 - base * 0.08;
611 points.push((px, py, i));
612 }
613
614 if n > 1 {
615 let mut lines = Vec::new();
616 for w in points.windows(2) {
617 let (x0, y0, _) = w[0];
618 let (x1, y1, _) = w[1];
619 let steps = 6;
620 for s in 0..=steps {
621 let f = s as f64 / steps as f64;
622 lines.push((lerp(x0, x1, f), lerp(y0, y1, f)));
623 }
624 }
625 ctx.draw(&Points {
626 coords: &lines,
627 color: blend_color(bg, task_dim, 0.35),
628 });
629 }
630
631 for (px, py, i) in points {
632 let active = active_idx == Some(i as u32);
633 let tw = if active {
634 0.75 + 0.25 * (t * 2.2).sin()
635 } else {
636 0.3 + 0.2 * (t * 0.9 + i as f64).sin().max(0.0)
637 };
638 let color = blend_color(task_dim, task, tw * motion.glow);
639 let r = if active {
640 2.0 + 0.4 * (t * 2.5).sin()
641 } else {
642 1.1
643 };
644 if active {
645 draw_soft_disc(ctx, px, py, r * 2.2, color, bg, 4);
646 }
647 ctx.draw(&Circle {
648 x: px,
649 y: py,
650 radius: r,
651 color,
652 });
653 }
654
655 if count > 12 {
656 draw_ring(
657 ctx,
658 cx,
659 cy - base * 0.05,
660 base * 1.28,
661 blend_color(bg, task_dim, 0.5),
662 );
663 }
664}
665
666fn draw_task_orbit(
667 ctx: &mut ratatui::widgets::canvas::Context,
668 geom: (f64, f64, f64, f64),
669 motion: SceneMotion,
670 count: u32,
671 active_idx: Option<u32>,
672 colors: (Color, Color, Color),
673) {
674 let (cx, cy, base, t) = geom;
675 let (task, task_dim, bg) = colors;
676 let n = (count as usize).clamp(1, 12);
677 let orbit = base * 1.2;
678 let spin = t * motion.speed * 0.22;
679
680 draw_ring(ctx, cx, cy, orbit, blend_color(bg, task_dim, 0.22));
681
682 for i in 0..n {
683 let angle = spin + 2.0 * PI * i as f64 / n as f64;
684 let active = active_idx == Some(i as u32);
685 let dist = if active { orbit * 0.92 } else { orbit };
686 let wobble = (t * 0.4 + i as f64).sin() * 0.8;
687 let px = cx + angle.cos() * (dist + wobble);
688 let py = cy + angle.sin() * (dist + wobble) * 0.88;
689 let tw = if active {
690 0.8 + 0.2 * (t * 2.0).sin()
691 } else {
692 0.35 + 0.15 * (t * 0.8 + i as f64).sin().max(0.0)
693 };
694 let color = blend_color(task_dim, task, tw * motion.glow);
695 let r = if active {
696 1.9 + 0.35 * (t * 2.2).sin()
697 } else {
698 1.05
699 };
700 if active {
701 let tether: Vec<(f64, f64)> = (1..=10)
702 .map(|s| {
703 let f = s as f64 / 10.0 * 0.55;
704 (
705 cx + angle.cos() * dist * f,
706 cy + angle.sin() * dist * f * 0.88,
707 )
708 })
709 .collect();
710 ctx.draw(&Points {
711 coords: &tether,
712 color: blend_color(bg, task_dim, 0.4),
713 });
714 draw_soft_disc(ctx, px, py, r * 2.0, color, bg, 4);
715 }
716 ctx.draw(&Circle {
717 x: px,
718 y: py,
719 radius: r,
720 color,
721 });
722 }
723
724 if count > 12 {
725 let pulse = 1.0 + 0.04 * (t * 2.0).sin();
726 draw_ring(
727 ctx,
728 cx,
729 cy,
730 orbit * 1.1 * pulse,
731 blend_color(bg, task, 0.35),
732 );
733 }
734}
735
736#[derive(Clone, Copy)]
737enum SessionArc {
738 Top,
739 Bottom,
740}
741
742fn draw_soft_progress_wreath(
743 ctx: &mut ratatui::widgets::canvas::Context,
744 geom: (f64, f64, f64, f64),
745 remaining: f64,
746 colors: (Color, Color),
747 timer_state: TimerState,
748 compact: bool,
749) {
750 let (cx, cy, base, t) = geom;
751 let (track, mode) = colors;
752 let dots = if compact { 48 } else { 72 };
753 for i in 0..dots {
754 let frac = i as f64 / dots as f64;
755 let a = -PI / 2.0 + 2.0 * PI * frac;
756 let px = cx + a.cos() * base;
757 let py = cy + a.sin() * base;
758 let filled = frac <= remaining + 0.001;
759 let pulse = if filled && timer_state == TimerState::Running {
760 1.0 + 0.15 * (t * 3.0 + frac * 12.0).sin()
761 } else {
762 1.0
763 };
764 let color = if filled {
765 if timer_state == TimerState::Paused {
766 blend_color(mode, track, 0.5)
767 } else {
768 mode
769 }
770 } else {
771 track
772 };
773 ctx.draw(&Circle {
774 x: px,
775 y: py,
776 radius: if filled {
777 if compact {
778 0.55 * pulse
779 } else {
780 0.75 * pulse
781 }
782 } else if compact {
783 0.35
784 } else {
785 0.45
786 },
787 color,
788 });
789 }
790}
791
792fn draw_timer_orb(
793 ctx: &mut ratatui::widgets::canvas::Context,
794 geom: (f64, f64, f64, f64),
795 motion: SceneMotion,
796 colors: (Color, Color, Color, Color),
797 timer_state: TimerState,
798 timer_mode: TimerMode,
799 compact: bool,
800) {
801 let (cx, cy, base, breath) = geom;
802 let (bg, glow, core, mode) = colors;
803 let on_break = is_break_mode(timer_mode);
804 let warm = if on_break {
805 blend_color(mode, core, 0.45)
806 } else {
807 blend_color(mode, glow, 0.35)
808 };
809 let scale = 0.9 + 0.1 * breath;
810 let intensity = motion.glow;
811
812 let layers: [(f64, Color, f64); 5] = if compact {
813 [
814 (0.95, blend_color(bg, glow, 0.22 * intensity), 0.35),
815 (0.68, blend_color(bg, warm, 0.5 * intensity), 0.6),
816 (0.38, blend_color(glow, core, 0.7 * intensity), 0.85),
817 (0.0, bg, 0.0),
818 (0.22, blend_color(core, mode, 0.45), 0.95),
819 ]
820 } else {
821 [
822 (1.18, blend_color(bg, glow, 0.12 * intensity), 0.15),
823 (0.95, blend_color(bg, glow, 0.28 * intensity), 0.35),
824 (0.72, blend_color(bg, warm, 0.55 * intensity), 0.55),
825 (0.48, blend_color(glow, core, 0.65 * intensity), 0.75),
826 (0.22, blend_color(core, mode, 0.4), 0.95),
827 ]
828 };
829
830 for (frac, color, alpha) in layers {
831 if frac <= 0.001 || alpha <= 0.001 {
832 continue;
833 }
834 let r = base * frac * scale;
835 let rings = if compact {
836 (5.0 * alpha) as usize
837 } else {
838 (8.0 * alpha) as usize
839 };
840 draw_soft_disc(ctx, cx, cy, r, color, bg, rings.max(3));
841 }
842
843 if timer_state == TimerState::Running {
844 let halo_r = base * (1.02 + 0.04 * breath);
845 draw_ring(ctx, cx, cy, halo_r, blend_color(bg, warm, 0.4 * intensity));
846 }
847}
848
849fn draw_soft_disc(
850 ctx: &mut ratatui::widgets::canvas::Context,
851 cx: f64,
852 cy: f64,
853 r: f64,
854 color: Color,
855 bg: Color,
856 rings: usize,
857) {
858 let rings = rings.max(3);
859 for i in 1..=rings {
860 let frac = i as f64 / rings as f64;
861 let rr = r * frac;
862 let mix = frac * frac;
863 draw_ring(ctx, cx, cy, rr, blend_color(bg, color, mix));
864 }
865}
866
867fn draw_task_fill(
868 ctx: &mut ratatui::widgets::canvas::Context,
869 cx: f64,
870 cy: f64,
871 max_r: f64,
872 progress: f64,
873 task: Color,
874 bg: Color,
875) {
876 let r = max_r * progress.clamp(0.0, 1.0);
877 if r > 0.5 {
878 draw_soft_disc(ctx, cx, cy, r, task, bg, 6);
879 }
880}
881
882fn draw_session_stars(
883 ctx: &mut ratatui::widgets::canvas::Context,
884 geom: (f64, f64, f64, f64),
885 sessions_done: u32,
886 sessions_total: u32,
887 colors: (Color, Color, Color),
888 arc: SessionArc,
889) {
890 let (cx, cy, base, t) = geom;
891 let (session_on, session_off, _bg) = colors;
892 let orbit = base * 1.32;
893 let span = PI * 0.55;
894 let start = match arc {
895 SessionArc::Top => PI / 2.0 - span / 2.0,
896 SessionArc::Bottom => -PI / 2.0 - span / 2.0,
897 };
898 for i in 0..sessions_total {
899 let frac = if sessions_total == 1 {
900 0.5
901 } else {
902 i as f64 / (sessions_total - 1) as f64
903 };
904 let a = start + frac * span;
905 let tw = if i < sessions_done {
906 0.8 + 0.2 * (t * 2.0 + i as f64).sin()
907 } else {
908 0.4
909 };
910 let color = if i < sessions_done {
911 blend_color(session_off, session_on, tw)
912 } else {
913 session_off
914 };
915 ctx.draw(&Circle {
916 x: cx + a.cos() * orbit,
917 y: cy + a.sin() * orbit,
918 radius: if i < sessions_done { 1.5 * tw } else { 1.0 },
919 color,
920 });
921 }
922}
923
924fn draw_soft_pause(
925 ctx: &mut ratatui::widgets::canvas::Context,
926 cx: f64,
927 cy: f64,
928 r: f64,
929 color: Color,
930) {
931 for side in [-1.0_f64, 1.0] {
932 ctx.draw(&Circle {
933 x: cx + side * r * 0.55,
934 y: cy,
935 radius: r * 0.35,
936 color: blend_color(color, color, 0.7),
937 });
938 }
939}
940
941fn draw_completion_shimmer(
942 ctx: &mut ratatui::widgets::canvas::Context,
943 cx: f64,
944 cy: f64,
945 base: f64,
946 t: f64,
947 mode: Color,
948 glow: Color,
949) {
950 let pulse = 1.0 + 0.08 * (t * 2.8).sin();
951 draw_ring(
952 ctx,
953 cx,
954 cy,
955 base * 1.22 * pulse,
956 blend_color(mode, glow, 0.65),
957 );
958 for i in 0..8 {
959 let a = t * 0.5 + i as f64 * PI / 4.0;
960 let dist = base * (1.05 + 0.06 * (t * 3.0 + i as f64).sin());
961 ctx.draw(&Circle {
962 x: cx + a.cos() * dist,
963 y: cy + a.sin() * dist,
964 radius: 0.9,
965 color: glow,
966 });
967 }
968}
969
970pub fn draw_timer_canvas(
971 f: &mut Frame,
972 area: Rect,
973 timer: &Timer,
974 style: &TimerCanvasStyle,
975 options: &TimerCanvasOptions,
976) {
977 let scene = SceneStyle {
978 mode: style.progress,
979 track: style.track,
980 task: style.task_progress,
981 task_dim: style.task_track,
982 bg: style.progress_dim,
983 bg_mid: style.track,
984 bg_light: style.track,
985 wave: style.progress,
986 core: style.cap,
987 glow: style.progress,
988 particle: style.dim,
989 text: style.text,
990 session_on: style.task_progress,
991 session_off: style.dim,
992 };
993 draw_scene_canvas(
994 f,
995 area,
996 timer,
997 &scene,
998 &SceneOptions {
999 task_progress: options.task_progress,
1000 pending_tasks: if options.task_progress.is_some() {
1001 1
1002 } else {
1003 0
1004 },
1005 active_task_index: if options.task_progress.is_some() {
1006 Some(0)
1007 } else {
1008 None
1009 },
1010 sessions_done: 0,
1011 sessions_total: 0,
1012 layout: SceneLayout::Zen,
1013 },
1014 );
1015}
1016
1017fn draw_ring(ctx: &mut ratatui::widgets::canvas::Context, cx: f64, cy: f64, r: f64, color: Color) {
1020 let coords: Vec<(f64, f64)> = (0..ARC_STEPS)
1021 .map(|i| {
1022 let a = arc_angle(i);
1023 (cx + a.cos() * r, cy + a.sin() * r)
1024 })
1025 .collect();
1026 ctx.draw(&Points {
1027 coords: &coords,
1028 color,
1029 });
1030}
1031
1032fn arc_angle(i: usize) -> f64 {
1033 -PI / 2.0 + 2.0 * PI * (i as f64 / ARC_STEPS as f64)
1034}
1035
1036fn smoothstep(x: f64) -> f64 {
1037 let x = x.clamp(0.0, 1.0);
1038 x * x * (3.0 - 2.0 * x)
1039}
1040
1041fn blend_color(a: Color, b: Color, t: f64) -> Color {
1042 let t = t.clamp(0.0, 1.0);
1043 let (ar, ag, ab) = color_rgb(a);
1044 let (br, bg, bb) = color_rgb(b);
1045 Color::Rgb(
1046 lerp(ar, br, t) as u8,
1047 lerp(ag, bg, t) as u8,
1048 lerp(ab, bb, t) as u8,
1049 )
1050}
1051
1052fn color_rgb(c: Color) -> (f64, f64, f64) {
1053 match c {
1054 Color::Rgb(r, g, b) => (r as f64, g as f64, b as f64),
1055 Color::Black => (0.0, 0.0, 0.0),
1056 Color::White => (255.0, 255.0, 255.0),
1057 Color::Red => (255.0, 0.0, 0.0),
1058 Color::Green => (0.0, 255.0, 0.0),
1059 Color::Blue => (0.0, 0.0, 255.0),
1060 Color::Yellow => (255.0, 255.0, 0.0),
1061 Color::Cyan => (0.0, 255.0, 255.0),
1062 Color::Magenta => (255.0, 0.0, 255.0),
1063 Color::Gray => (128.0, 128.0, 128.0),
1064 Color::DarkGray => (64.0, 64.0, 64.0),
1065 Color::LightRed => (255.0, 128.0, 128.0),
1066 Color::LightGreen => (128.0, 255.0, 128.0),
1067 Color::LightBlue => (128.0, 128.0, 255.0),
1068 Color::LightYellow => (255.0, 255.0, 128.0),
1069 Color::LightMagenta => (255.0, 128.0, 255.0),
1070 Color::LightCyan => (128.0, 255.0, 255.0),
1071 Color::Indexed(i) => {
1072 let v = (i as f64 / 255.0) * 255.0;
1073 (v, v, v)
1074 }
1075 Color::Reset => (200.0, 200.0, 200.0),
1076 }
1077}
1078
1079fn lerp(a: f64, b: f64, t: f64) -> f64 {
1080 a + (b - a) * t
1081}
1082
1083fn time_s() -> f64 {
1084 std::time::SystemTime::now()
1085 .duration_since(std::time::UNIX_EPOCH)
1086 .map(|d| d.as_millis() as f64 / 1000.0)
1087 .unwrap_or(0.0)
1088}
1089
1090fn canvas_marker(area: Rect) -> Marker {
1091 if area.width >= 20 {
1093 Marker::Braille
1094 } else {
1095 Marker::HalfBlock
1096 }
1097}
1098
1099fn square_bounds(area: Rect) -> ([f64; 2], [f64; 2]) {
1100 let w = area.width as f64;
1101 let h = area.height as f64 * 2.0;
1102 if w >= h {
1103 let pad = (w - h) / 2.0;
1104 ([pad, pad + h], [0.0, h])
1105 } else {
1106 let pad = (h - w) / 2.0;
1107 ([0.0, w], [pad, pad + w])
1108 }
1109}
1110
1111fn center(xb: [f64; 2], yb: [f64; 2]) -> (f64, f64) {
1112 ((xb[0] + xb[1]) / 2.0, (yb[0] + yb[1]) / 2.0)
1113}
1114
1115pub fn session_dots(done_in_cycle: u32, cycle_length: u32, in_focus: bool) -> String {
1116 let cycle = cycle_length.max(1);
1117 let done = done_in_cycle % cycle;
1118 (1..=cycle)
1119 .map(|i| {
1120 if i <= done {
1121 '●'
1122 } else if in_focus && i == done + 1 {
1123 '◉'
1124 } else {
1125 '○'
1126 }
1127 })
1128 .collect()
1129}
1130
1131pub fn format_time_stack(timer: &Timer) -> (String, String, String) {
1132 let (main, tenths) = timer.format_remaining_parts();
1133 let mode = timer.mode.label().to_string();
1134 (main, tenths, mode)
1135}
1136
1137pub struct SimpleTimerStyle {
1138 pub track: Color,
1139 pub fill: Color,
1140 pub fill_dim: Color,
1141 pub task: Color,
1142 pub text: Color,
1143 pub dim: Color,
1144}
1145
1146const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1147
1148pub fn draw_simple_timer(
1149 f: &mut Frame,
1150 area: Rect,
1151 timer: &Timer,
1152 style: &SimpleTimerStyle,
1153 task_progress: Option<f64>,
1154) {
1155 if area.height < 3 || area.width < 8 {
1156 return;
1157 }
1158
1159 let remaining = (1.0 - timer.progress()).clamp(0.0, 1.0);
1160 let paused = timer.state == TimerState::Paused;
1161 let running = timer.state == TimerState::Running;
1162 let finished = timer.state == TimerState::Finished;
1163
1164 let fill_color = if paused { style.fill_dim } else { style.fill };
1165
1166 let layout = ratatui::layout::Layout::default()
1167 .direction(ratatui::layout::Direction::Vertical)
1168 .constraints([
1169 ratatui::layout::Constraint::Length(1),
1170 ratatui::layout::Constraint::Length(1),
1171 ratatui::layout::Constraint::Min(1),
1172 ])
1173 .split(area);
1174
1175 let segments = area.width.saturating_sub(2) as usize;
1176 let filled = (remaining * segments as f64).round() as usize;
1177 let bar: String = (0..segments)
1178 .map(|i| if i < filled { '█' } else { '░' })
1179 .collect();
1180
1181 let status_glyph = if finished {
1182 '✓'
1183 } else if paused {
1184 '❚'
1185 } else if running {
1186 let idx = (std::time::SystemTime::now()
1187 .duration_since(std::time::UNIX_EPOCH)
1188 .map(|d| d.as_millis() / 80)
1189 .unwrap_or(0) as usize)
1190 % SPINNER.len();
1191 SPINNER[idx]
1192 } else {
1193 '○'
1194 };
1195
1196 f.render_widget(
1197 Paragraph::new(Line::from(vec![
1198 Span::styled(
1199 format!("{} ", status_glyph),
1200 ratatui::style::Style::default().fg(fill_color),
1201 ),
1202 Span::styled(bar, ratatui::style::Style::default().fg(fill_color)),
1203 Span::styled(
1204 format!(" {:>3}%", (remaining * 100.0) as u32),
1205 ratatui::style::Style::default().fg(style.dim),
1206 ),
1207 ])),
1208 layout[0],
1209 );
1210
1211 if let Some(tp) = task_progress {
1212 let task_segments = area.width.saturating_sub(6) as usize;
1213 let task_filled = (tp.clamp(0.0, 1.0) * task_segments as f64).round() as usize;
1214 let task_bar: String = (0..task_segments)
1215 .map(|i| if i < task_filled { '▰' } else { '▱' })
1216 .collect();
1217 f.render_widget(
1218 Paragraph::new(Line::from(vec![
1219 Span::styled("task ", ratatui::style::Style::default().fg(style.dim)),
1220 Span::styled(task_bar, ratatui::style::Style::default().fg(style.task)),
1221 ])),
1222 layout[1],
1223 );
1224 } else {
1225 f.render_widget(
1226 Paragraph::new(Span::styled(
1227 "─ no active task ─",
1228 ratatui::style::Style::default().fg(style.dim),
1229 ))
1230 .alignment(Alignment::Center),
1231 layout[1],
1232 );
1233 }
1234}
1235
1236pub fn is_break_mode(mode: TimerMode) -> bool {
1237 matches!(mode, TimerMode::ShortBreak | TimerMode::LongBreak)
1238}