Skip to main content

void/
canvas_timer.rs

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
18// ── public types ─────────────────────────────────────────────────────────────
19
20pub 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
45/// Palette for the dashboard / zen timer canvas scene.
46pub 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
85// ── break wellness tips ──────────────────────────────────────────────────────
86
87const 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
201// ── timer scene ──────────────────────────────────────────────────────────────
202
203pub 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
1017// ── geometry helpers ─────────────────────────────────────────────────────────
1018
1019fn 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    // Braille is 2× finer — use it whenever the panel is wide enough.
1092    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}