1use 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 displayed: Theme,
33 fade: Option<ThemeFade>,
35 cover: Option<Cover>,
36}
37
38impl App {
39 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 let picker = Cover::make_picker();
56
57 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 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 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), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Min(10), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
132 .split(area);
133
134 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 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 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 let (played, total) = (84u32, 227u32); 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 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 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 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 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
279fn 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}