Skip to main content

theme_demo/
theme_demo.rs

1//! Live showcase of the myx design system.
2//!
3//! Cycles themes with ←/→ (or h/l), quits with q/Esc. Renders a mock now-playing
4//! surface using every primitive: 3-layer backgrounds, left-bar focus, pills,
5//! gradient badges, a gradient progress bar, and real album art (ratatui-image).
6
7use std::io::{self, Stdout};
8use std::time::Duration;
9
10use crossterm::event::{self, Event, KeyCode};
11use crossterm::execute;
12use crossterm::terminal::{
13    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
14};
15use ratatui::backend::CrosstermBackend;
16use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect};
17use ratatui::style::{Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Block, Paragraph};
20use ratatui::{Frame, Terminal};
21
22use myx::anim::ThemeFade;
23use myx::components::{gradient_line, gradient_pill, gradient_progress, left_bar_block, pill};
24use myx::cover::Cover;
25use myx::reactive::derive_theme;
26use myx::theme::{Theme, THEMES};
27
28struct App {
29    themes: Vec<Theme>,
30    theme_idx: usize,
31    /// The theme currently on screen (may be mid-fade).
32    displayed: Theme,
33    /// Active cross-fade, if any.
34    fade: Option<ThemeFade>,
35    cover: Option<Cover>,
36}
37
38impl App {
39    /// Begin a ~300ms cross-fade from whatever's on screen now to `themes[idx]`.
40    fn fade_to(&mut self, idx: usize) {
41        self.theme_idx = idx;
42        let target = self.themes[idx];
43        self.fade = Some(ThemeFade::new(
44            self.displayed,
45            target,
46            Duration::from_millis(300),
47        ));
48    }
49}
50
51fn main() -> io::Result<()> {
52    let mut terminal = init()?;
53
54    // Build the image picker *after* raw mode so the terminal query round-trips.
55    let picker = Cover::make_picker();
56
57    // Load the cover once, derive a reactive theme from the same pixels, then hand
58    // the decoded image to the Cover renderer.
59    let mut themes: Vec<Theme> = Vec::new();
60    let cover = match image::open("assets/cover.jpg") {
61        Ok(img) => {
62            themes.push(derive_theme(&img, "album ✦"));
63            Some(Cover::from_image(img, picker))
64        }
65        Err(_) => None,
66    };
67    themes.extend_from_slice(THEMES);
68
69    let mut app = App {
70        displayed: themes[0],
71        themes,
72        theme_idx: 0,
73        fade: None,
74        cover,
75    };
76
77    loop {
78        // Advance any running cross-fade before drawing.
79        if let Some(fade) = &app.fade {
80            app.displayed = fade.current();
81            if fade.is_done() {
82                app.displayed = app.themes[app.theme_idx];
83                app.fade = None;
84            }
85        }
86
87        terminal.draw(|f| render(f, &mut app))?;
88
89        // Tick fast while fading for smooth motion; idle otherwise.
90        let timeout = if app.fade.is_some() {
91            Duration::from_millis(16)
92        } else {
93            Duration::from_millis(120)
94        };
95
96        if event::poll(timeout)? {
97            if let Event::Key(key) = event::read()? {
98                match key.code {
99                    KeyCode::Char('q') | KeyCode::Esc => break,
100                    KeyCode::Right | KeyCode::Char('l') => {
101                        app.fade_to((app.theme_idx + 1) % app.themes.len());
102                    }
103                    KeyCode::Left | KeyCode::Char('h') => {
104                        app.fade_to((app.theme_idx + app.themes.len() - 1) % app.themes.len());
105                    }
106                    _ => {}
107                }
108            }
109        }
110    }
111
112    restore(terminal)
113}
114
115fn render(f: &mut Frame, app: &mut App) {
116    let theme = app.displayed;
117    let area = f.area();
118
119    f.render_widget(Block::default().style(theme.base()), area);
120    let area = area.inner(Margin::new(2, 1));
121
122    let rows = Layout::vertical([
123        Constraint::Length(1), // title
124        Constraint::Length(1), // spacer
125        Constraint::Length(3), // pill row
126        Constraint::Length(1), // spacer
127        Constraint::Min(10),   // two-pane body
128        Constraint::Length(1), // spacer
129        Constraint::Length(1), // progress
130        Constraint::Length(1), // footer
131    ])
132    .split(area);
133
134    // ---- Title (gradient heading) ----
135    let title: Vec<Span> = gradient_line("myx  —  design system", &[theme.primary, theme.accent]);
136    f.render_widget(Paragraph::new(Line::from(title)), rows[0]);
137
138    // ---- Pill row: solid + gradient badges ----
139    let mut pills: Vec<Span> = Vec::new();
140    pills.extend(pill("EXPLICIT", theme.error, theme.background));
141    pills.push(Span::raw("  "));
142    pills.extend(pill("PLAYLIST", theme.info, theme.background));
143    pills.push(Span::raw("  "));
144    pills.extend(pill("♥ LIKED", theme.success, theme.background));
145    pills.push(Span::raw("  "));
146    pills.extend(gradient_pill(
147        "NOW PLAYING",
148        &[theme.primary, theme.secondary, theme.accent],
149        theme.background,
150    ));
151    f.render_widget(
152        Paragraph::new(Line::from(pills)).block(Block::default().style(theme.base())),
153        rows[2].inner(Margin::new(0, 1)),
154    );
155
156    // ---- Two-pane body ----
157    let body = Layout::horizontal([Constraint::Percentage(38), Constraint::Percentage(62)])
158        .spacing(2)
159        .split(rows[4]);
160
161    render_library(f, theme, body[0]);
162    render_now_playing(f, app, theme, body[1]);
163
164    // ---- Gradient progress bar ----
165    let (played, total) = (84u32, 227u32); // 1:24 / 3:47
166    let bar_width = rows[6].width.saturating_sub(14) as usize;
167    let filled = ((played as f32 / total as f32) * bar_width as f32) as usize;
168    let mut prog: Vec<Span> = vec![Span::styled("1:24 ", theme.muted())];
169    prog.extend(gradient_progress(
170        bar_width,
171        filled,
172        &[theme.primary, theme.accent],
173        theme.border_dimmest,
174    ));
175    prog.push(Span::styled(" 3:47", theme.muted()));
176    f.render_widget(Paragraph::new(Line::from(prog)), rows[6]);
177
178    // ---- Footer ----
179    let footer = Line::from(vec![
180        Span::styled("  ⏮  ⏯  ⏭    🔀 🔁    ♥      ", theme.muted()),
181        Span::styled(theme.name, theme.heading()),
182        Span::styled("      ←/→ cross-fade   q quit", theme.muted()),
183    ]);
184    f.render_widget(Paragraph::new(footer), rows[7]);
185}
186
187fn render_library(f: &mut Frame, theme: Theme, area: Rect) {
188    f.render_widget(Block::default().style(theme.panel()), area);
189    let inner = area.inner(Margin::new(1, 0));
190
191    let items = [
192        ("Liked Songs", true),
193        ("Discover Weekly", false),
194        ("Daily Mix 1", false),
195        ("lofi beats", false),
196        ("deep focus", false),
197    ];
198
199    let mut y = inner.y;
200    f.render_widget(
201        Paragraph::new(Line::from(vec![Span::styled("LIBRARY", theme.heading())]))
202            .block(Block::default().style(theme.panel())),
203        Rect { x: inner.x, y, width: inner.width, height: 1 },
204    );
205    y += 2;
206
207    for (label, selected) in items {
208        if y >= inner.bottom() {
209            break;
210        }
211        let row = Rect { x: inner.x, y, width: inner.width, height: 1 };
212        let bg = if selected {
213            theme.background_element.into()
214        } else {
215            theme.background_panel.into()
216        };
217        let block = left_bar_block(&theme, selected, bg);
218        let text_style = if selected {
219            Style::default().fg(theme.text.into()).add_modifier(Modifier::BOLD)
220        } else {
221            theme.muted()
222        };
223        f.render_widget(
224            Paragraph::new(Line::from(Span::styled(format!(" {label}"), text_style))).block(block),
225            row,
226        );
227        y += 1;
228    }
229}
230
231fn render_now_playing(f: &mut Frame, app: &mut App, theme: Theme, area: Rect) {
232    f.render_widget(Block::default().style(theme.panel()), area);
233    let inner = area.inner(Margin::new(2, 1));
234
235    // Split: album art on the left, metadata on the right.
236    // Cover box is sized ~square in *pixels*: cells are ~1:2, so width ≈ 2×height.
237    let art_h = inner.height.min(9);
238    let art_w = (art_h * 2).min(inner.width.saturating_sub(20));
239    let cols = Layout::horizontal([Constraint::Length(art_w), Constraint::Min(10)])
240        .spacing(2)
241        .split(inner);
242    let art_rect = Rect { x: cols[0].x, y: cols[0].y, width: art_w, height: art_h };
243    let meta_rect = cols[1];
244
245    // Album art (or a placeholder box if the image failed to load).
246    match app.cover.as_mut() {
247        Some(cover) => cover.render(f, art_rect),
248        None => f.render_widget(
249            Paragraph::new("[ no art ]")
250                .style(theme.muted())
251                .alignment(Alignment::Center)
252                .block(Block::default().style(theme.element())),
253            art_rect,
254        ),
255    }
256
257    // Metadata + mock visualizer.
258    let lines = vec![
259        Line::from(vec![Span::styled("NOW PLAYING", theme.heading())]),
260        Line::raw(""),
261        Line::from(vec![Span::styled(
262            "Midnight City",
263            Style::default().fg(theme.text.into()).add_modifier(Modifier::BOLD),
264        )]),
265        Line::from(vec![Span::styled("M83", theme.muted())]),
266        Line::from(vec![Span::styled("Hurry Up, We're Dreaming · 2011", theme.muted())]),
267        Line::raw(""),
268        Line::from(gradient_line(
269            "▁▂▃▅▇▆▄▂▁▃▅▇▆▄▂▁▂▃▅▇▆▄▃▂▁▂▄▆▇▅▃▁",
270            &[theme.info, theme.primary, theme.accent],
271        )),
272    ];
273    f.render_widget(
274        Paragraph::new(lines).block(Block::default().style(theme.panel())),
275        meta_rect,
276    );
277}
278
279// ---- terminal plumbing ----
280
281fn init() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
282    enable_raw_mode()?;
283    let mut stdout = io::stdout();
284    execute!(stdout, EnterAlternateScreen)?;
285    Terminal::new(CrosstermBackend::new(stdout))
286}
287
288fn restore(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
289    disable_raw_mode()?;
290    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
291    terminal.show_cursor()?;
292    Ok(())
293}