sara-tasks 2.7.9

Sara — folder-aware task manager
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::canvas::{Canvas, Circle, Line as CanvasLine};
use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Sparkline, Wrap};

use crate::commands::shared::strength_label;
use crate::infrastructure::tui;

use super::effects::{PULSE_TICKS, accent, body_style, materialized_body};
use crate::commands::dream::types::{DreamData, NodeKind};

pub(in crate::commands::dream) fn ui(
    f: &mut ratatui::Frame,
    data: &DreamData,
    frame: u64,
    selected: usize,
    breadcrumb: &[String],
    show_help: bool,
) {
    let area = f.area();
    let ac = accent(data.provisional);

    let outer = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(1), Constraint::Min(0)])
        .split(area);

    let mut path: Vec<Span> = vec![Span::styled(" ✦ ", Style::default().fg(ac))];
    for b in breadcrumb {
        path.push(Span::styled(
            b.clone(),
            Style::default().fg(Color::DarkGray),
        ));
        path.push(Span::styled(" ⟶ ", Style::default().fg(Color::DarkGray)));
    }
    path.push(Span::styled(
        data.label.clone(),
        Style::default().fg(ac).add_modifier(Modifier::BOLD),
    ));
    if data.provisional {
        path.push(Span::styled(
            "  (provisional — an unreviewed dream)",
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::ITALIC),
        ));
    }
    path.push(Span::styled(
        "   [?] help  [q] wake up",
        Style::default().fg(Color::DarkGray),
    ));
    f.render_widget(Paragraph::new(Line::from(path)), outer[0]);

    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(52), Constraint::Percentage(48)])
        .split(outer[1]);

    render_neuron(f, cols[0], data, frame, selected);
    render_detail(f, cols[1], data, frame, selected);

    if show_help {
        tui::render_help_overlay(
            f,
            "dreaming",
            &[
                ("Tab / arrows", "select a dendrite"),
                ("Enter", "drift into the linked memory"),
                ("Backspace / Esc", "drift back along your path"),
                ("q", "wake up"),
            ],
        );
    }
}

fn render_neuron(
    f: &mut ratatui::Frame,
    area: Rect,
    data: &DreamData,
    frame: u64,
    selected: usize,
) {
    let ac = accent(data.provisional);
    let n = data.neighbors.len();

    let canvas = Canvas::default()
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(if data.provisional {
                    Color::Magenta
                } else {
                    Color::DarkGray
                }))
                .title(Span::styled(" the neuron ", Style::default().fg(ac))),
        )
        .x_bounds([-100.0, 100.0])
        .y_bounds([-75.0, 75.0])
        .paint(move |ctx| {
            if frame < PULSE_TICKS {
                let t = frame as f64 / PULSE_TICKS as f64;
                for lag in 0..3 {
                    let r = (t - lag as f64 * 0.18).max(0.0) * 70.0;
                    if r > 0.5 {
                        ctx.draw(&Circle {
                            x: 0.0,
                            y: 0.0,
                            radius: r,
                            color: Color::Rgb(
                                (80.0 * (1.0 - t)) as u8 + 20,
                                (200.0 * (1.0 - t)) as u8 + 30,
                                (220.0 * (1.0 - t)) as u8 + 35,
                            ),
                        });
                    }
                }
                let float_y = 8.0 + t * 30.0;
                ctx.print(
                    6.0,
                    float_y,
                    Line::from(Span::styled(
                        "+0.1",
                        Style::default()
                            .fg(Color::Green)
                            .add_modifier(Modifier::BOLD),
                    )),
                );
            }

            for (i, nb) in data.neighbors.iter().enumerate() {
                let angle = std::f64::consts::TAU * (i as f64 / n.max(1) as f64)
                    + 0.35
                    + (frame as f64 * 0.004);
                let (r_x, r_y) = (72.0, 52.0);
                let x = angle.cos() * r_x;
                let y = angle.sin() * r_y;
                let is_sel = i == selected;

                let (edge_color, glyph, name) = match &nb.kind {
                    NodeKind::Memory { label, relation } => {
                        let c = if relation.contains("supersedes") {
                            Color::Red
                        } else {
                            Color::Cyan
                        };
                        (c, "●", format!("{label} {relation}"))
                    }
                    NodeKind::Task { id, source, .. } => {
                        (Color::Yellow, "◆", format!("#{id} ({source})"))
                    }
                    NodeKind::File { name } => (Color::Green, "▪", name.clone()),
                };
                let dim = Color::Rgb(60, 60, 70);
                ctx.draw(&CanvasLine {
                    x1: 0.0,
                    y1: 0.0,
                    x2: x,
                    y2: y,
                    color: if is_sel { edge_color } else { dim },
                });
                let node_style = if is_sel {
                    Style::default().fg(edge_color).add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(dim)
                };
                ctx.print(
                    x,
                    y,
                    Line::from(Span::styled(format!("{glyph} {name}"), node_style)),
                );
            }

            let breath = ((frame as f64 * 0.06).sin() + 1.0) / 2.0;
            let glyphs = ["◌", "○", "◎", "◉"];
            let gi = (breath * (glyphs.len() - 1) as f64).round() as usize;
            let lum = (120.0 + breath * 120.0 * (data.strength / 2.5).min(1.0)) as u8;
            let core_color = if data.provisional {
                Color::Rgb(lum, 60, lum)
            } else {
                Color::Rgb(60, lum, lum)
            };
            ctx.draw(&Circle {
                x: 0.0,
                y: 0.0,
                radius: 4.0 + breath * 2.0,
                color: core_color,
            });
            ctx.print(
                -3.0,
                0.0,
                Line::from(Span::styled(
                    format!("{} {}", glyphs[gi], data.label),
                    Style::default().fg(core_color).add_modifier(Modifier::BOLD),
                )),
            );
        });
    f.render_widget(canvas, area);
}

fn render_detail(
    f: &mut ratatui::Frame,
    area: Rect,
    data: &DreamData,
    frame: u64,
    selected: usize,
) {
    let ac = accent(data.provisional);
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Length(4),
            Constraint::Min(5),
            Constraint::Length(4),
        ])
        .split(area);

    let ratio = (data.strength / 2.5).min(1.0);
    let gauge_color = if data.strength >= 2.0 {
        Color::White
    } else if data.strength >= 1.5 {
        Color::Cyan
    } else {
        Color::DarkGray
    };
    f.render_widget(
        Gauge::default()
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_style(Style::default().fg(Color::DarkGray))
                    .title(Span::styled(" vividness ", Style::default().fg(ac))),
            )
            .gauge_style(Style::default().fg(gauge_color).bg(Color::Rgb(25, 25, 32)))
            .ratio(ratio)
            .label(format!(
                "{} ({:.1})",
                strength_label(data.strength),
                data.strength
            )),
        rows[0],
    );

    f.render_widget(
        Sparkline::default()
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_style(Style::default().fg(Color::DarkGray))
                    .title(Span::styled(
                        format!(" recall pulse — {} in 30d ", data.recall_total_30d),
                        Style::default().fg(ac),
                    )),
            )
            .data(&data.sparkline)
            .style(Style::default().fg(Color::Cyan)),
        rows[1],
    );

    let resolved = materialized_body(&data.item.body, frame, data.strength);
    let real_style = body_style(data.strength, data.provisional);
    let noise_style = Style::default().fg(Color::Rgb(60, 60, 80));
    let mut lines: Vec<Line> = vec![];
    let mut spans: Vec<Span> = vec![];
    for (c, is_real) in resolved {
        if c == '\n' {
            lines.push(Line::from(std::mem::take(&mut spans)));
        } else {
            spans.push(Span::styled(
                c.to_string(),
                if is_real { real_style } else { noise_style },
            ));
        }
    }
    if !spans.is_empty() {
        lines.push(Line::from(spans));
    }
    let age = chrono::Utc::now()
        .signed_duration_since(data.item.created)
        .num_days();
    f.render_widget(
        Paragraph::new(lines).wrap(Wrap { trim: false }).block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::DarkGray))
                .title(Span::styled(
                    format!(" the memory — {age}d old "),
                    Style::default().fg(ac),
                )),
        ),
        rows[2],
    );

    let mut foot: Vec<Line> = vec![Line::from(vec![
        Span::styled("tags ", Style::default().fg(Color::DarkGray)),
        Span::styled(
            data.item.tags.join(", "),
            Style::default().fg(Color::Yellow),
        ),
        Span::styled(
            format!("   files {}", data.files.len()),
            Style::default().fg(Color::DarkGray),
        ),
    ])];
    if let Some(nb) = data.neighbors.get(selected) {
        let desc = match &nb.kind {
            NodeKind::Memory { label, relation } => {
                format!("● {label} — {relation} (Enter to drift into it)")
            }
            NodeKind::Task { id, desc, source } => format!("◆ task #{id} ({source}) — {desc}"),
            NodeKind::File { name } => format!("▪ {name}"),
        };
        foot.push(Line::from(Span::styled(
            desc,
            Style::default().fg(Color::Gray),
        )));
    }
    f.render_widget(
        Paragraph::new(foot).wrap(Wrap { trim: true }).block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::DarkGray)),
        ),
        rows[3],
    );
}