use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::library::{PlaylistId, TrackId};
const BG: Color = Color::Rgb(18, 18, 18);
const ACCENT: Color = Color::Rgb(100, 180, 255);
const SUBTLE: Color = Color::Rgb(80, 80, 80);
const TEXT: Color = Color::White;
const TEXT_DIM: Color = Color::Rgb(179, 179, 179);
const DANGER: Color = Color::Rgb(255, 80, 80);
#[derive(Debug, Default, Clone)]
pub struct TextInput {
pub value: String,
pub cursor: usize, }
impl TextInput {
pub fn with_value(v: impl Into<String>) -> Self {
let value = v.into();
let cursor = value.len();
Self { value, cursor }
}
pub fn push(&mut self, c: char) {
self.value.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
pub fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let mut new_cursor = self.cursor - 1;
while !self.value.is_char_boundary(new_cursor) {
new_cursor -= 1;
}
self.value.remove(new_cursor);
self.cursor = new_cursor;
}
pub fn move_left(&mut self) {
if self.cursor == 0 {
return;
}
let mut c = self.cursor - 1;
while !self.value.is_char_boundary(c) {
c -= 1;
}
self.cursor = c;
}
pub fn move_right(&mut self) {
if self.cursor >= self.value.len() {
return;
}
let mut c = self.cursor + 1;
while !self.value.is_char_boundary(c) {
c += 1;
}
self.cursor = c;
}
}
#[derive(Debug, Clone)]
pub enum RemoveTarget {
TrackFromQueue { queue_idx: usize },
TrackFromLibrary { track_id: TrackId },
Playlist { playlist_id: PlaylistId },
}
#[derive(Debug, Clone)]
pub enum Modal {
TrackAdded { name: String },
ConfirmRemove {
description: String,
target: RemoveTarget,
},
Rename {
kind: String,
id: u64,
input: TextInput,
},
NewPlaylist { input: TextInput },
AddToPlaylist {
track_id: TrackId,
track_name: String,
choices: Vec<(PlaylistId, String)>,
cursor: usize,
},
Help,
}
pub enum ModalOutcome {
Consumed,
Confirm(ModalConfirm),
Dismissed,
}
#[derive(Debug)]
#[allow(dead_code)]
pub enum ModalConfirm {
Remove(RemoveTarget),
Rename {
kind: String,
id: u64,
new_name: String,
},
NewPlaylist {
name: String,
},
AddToPlaylist {
track_id: TrackId,
playlist_id: PlaylistId,
},
None,
}
impl Modal {
pub fn handle_key(&mut self, code: KeyCode) -> ModalOutcome {
match self {
Modal::TrackAdded { .. } | Modal::Help => ModalOutcome::Dismissed,
Modal::ConfirmRemove { target, .. } => match code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
ModalOutcome::Confirm(ModalConfirm::Remove(target.clone()))
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('q') => {
ModalOutcome::Dismissed
}
_ => ModalOutcome::Consumed,
},
Modal::Rename { kind, id, input } => match code {
KeyCode::Enter => {
let name = input.value.trim().to_string();
if name.is_empty() {
return ModalOutcome::Consumed;
}
ModalOutcome::Confirm(ModalConfirm::Rename {
kind: kind.clone(),
id: *id,
new_name: name,
})
}
KeyCode::Esc => ModalOutcome::Dismissed,
KeyCode::Char(c) => {
input.push(c);
ModalOutcome::Consumed
}
KeyCode::Backspace => {
input.backspace();
ModalOutcome::Consumed
}
KeyCode::Left => {
input.move_left();
ModalOutcome::Consumed
}
KeyCode::Right => {
input.move_right();
ModalOutcome::Consumed
}
_ => ModalOutcome::Consumed,
},
Modal::NewPlaylist { input } => match code {
KeyCode::Enter => {
let name = input.value.trim().to_string();
if name.is_empty() {
return ModalOutcome::Consumed;
}
ModalOutcome::Confirm(ModalConfirm::NewPlaylist { name })
}
KeyCode::Esc => ModalOutcome::Dismissed,
KeyCode::Char(c) => {
input.push(c);
ModalOutcome::Consumed
}
KeyCode::Backspace => {
input.backspace();
ModalOutcome::Consumed
}
KeyCode::Left => {
input.move_left();
ModalOutcome::Consumed
}
KeyCode::Right => {
input.move_right();
ModalOutcome::Consumed
}
_ => ModalOutcome::Consumed,
},
Modal::AddToPlaylist {
choices,
cursor,
track_id,
..
} => match code {
KeyCode::Char('j') | KeyCode::Down => {
if !choices.is_empty() {
*cursor = (*cursor + 1).min(choices.len() - 1);
}
ModalOutcome::Consumed
}
KeyCode::Char('k') | KeyCode::Up => {
*cursor = cursor.saturating_sub(1);
ModalOutcome::Consumed
}
KeyCode::Enter => {
if let Some((playlist_id, _)) = choices.get(*cursor) {
ModalOutcome::Confirm(ModalConfirm::AddToPlaylist {
track_id: *track_id,
playlist_id: *playlist_id,
})
} else {
ModalOutcome::Dismissed
}
}
KeyCode::Esc | KeyCode::Char('q') => ModalOutcome::Dismissed,
_ => ModalOutcome::Consumed,
},
}
}
}
pub fn render_modal(frame: &mut Frame, modal: &Modal) {
match modal {
Modal::TrackAdded { name } => {
render_notification(
frame,
"Track Added",
&format!("\"{}\" added to library.", name),
);
}
Modal::Help => render_help(frame),
Modal::ConfirmRemove { description, .. } => {
render_confirm(frame, description);
}
Modal::Rename { kind, input, .. } => {
render_text_input(frame, &format!("Rename {}", kind), input);
}
Modal::NewPlaylist { input } => {
render_text_input(frame, "New Playlist", input);
}
Modal::AddToPlaylist {
track_name,
choices,
cursor,
..
} => {
render_playlist_picker(frame, track_name, choices, *cursor);
}
}
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect {
x,
y,
width: width.min(area.width),
height: height.min(area.height),
}
}
fn modal_block(title: &str) -> Block<'_> {
Block::default()
.title(format!(" {} ", title))
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ACCENT))
.style(Style::default().bg(BG))
}
fn render_notification(frame: &mut Frame, title: &str, message: &str) {
let area = frame.area();
let rect = centered_rect(50, 5, area);
frame.render_widget(Clear, rect);
frame.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(message, Style::default().fg(TEXT))),
Line::from(Span::styled(
"Press any key to dismiss",
Style::default().fg(TEXT_DIM),
)),
])
.alignment(Alignment::Center)
.block(modal_block(title)),
rect,
);
}
fn render_confirm(frame: &mut Frame, description: &str) {
let area = frame.area();
let rect = centered_rect(52, 7, area);
frame.render_widget(Clear, rect);
frame.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(description, Style::default().fg(TEXT))),
Line::from(""),
Line::from(vec![
Span::styled(
"[Y]",
Style::default().fg(DANGER).add_modifier(Modifier::BOLD),
),
Span::styled(" confirm ", Style::default().fg(TEXT_DIM)),
Span::styled("[N / Esc]", Style::default().fg(ACCENT)),
Span::styled(" cancel", Style::default().fg(TEXT_DIM)),
]),
])
.alignment(Alignment::Center)
.wrap(Wrap { trim: true })
.block(modal_block("Confirm")),
rect,
);
}
fn render_text_input(frame: &mut Frame, title: &str, input: &TextInput) {
let area = frame.area();
let rect = centered_rect(52, 7, area);
frame.render_widget(Clear, rect);
let block = modal_block(title);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new(Span::styled("Enter name:", Style::default().fg(TEXT_DIM))),
rows[0],
);
let before = &input.value[..input.cursor];
let after = &input.value[input.cursor..];
let spans = vec![
Span::styled(before.to_string(), Style::default().fg(TEXT)),
Span::styled("█", Style::default().fg(ACCENT)),
Span::styled(after.to_string(), Style::default().fg(TEXT)),
];
frame.render_widget(Paragraph::new(Line::from(spans)), rows[1]);
frame.render_widget(
Paragraph::new(Span::styled(
"[Enter] confirm [Esc] cancel",
Style::default().fg(SUBTLE),
)),
rows[2],
);
}
fn render_playlist_picker(
frame: &mut Frame,
track_name: &str,
choices: &[(PlaylistId, String)],
cursor: usize,
) {
let area = frame.area();
let height = (choices.len() as u16 + 6).min(area.height.saturating_sub(4));
let rect = centered_rect(52, height, area);
frame.render_widget(Clear, rect);
let block = modal_block("Add to Playlist");
let inner = block.inner(rect);
frame.render_widget(block, rect);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(inner);
frame.render_widget(
Paragraph::new(Span::styled(
format!("Track: {}", track_name),
Style::default().fg(TEXT_DIM),
)),
rows[0],
);
if choices.is_empty() {
frame.render_widget(
Paragraph::new(Span::styled(
"No playlists yet. Press N to create one.",
Style::default().fg(SUBTLE),
)),
rows[2],
);
} else {
let items: Vec<ListItem> = choices
.iter()
.map(|(_, name)| ListItem::new(name.clone()))
.collect();
let mut list_state = ListState::default();
list_state.select(Some(cursor));
frame.render_stateful_widget(
List::new(items)
.highlight_style(
Style::default()
.fg(TEXT)
.bg(Color::Rgb(40, 40, 40))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> "),
rows[2],
&mut list_state,
);
}
frame.render_widget(
Paragraph::new(Span::styled(
"[Enter] add [j/k] navigate [Esc] cancel",
Style::default().fg(SUBTLE),
)),
rows[3],
);
}
fn render_help(frame: &mut Frame) {
let area = frame.area();
let rect = centered_rect(60, 28, area);
frame.render_widget(Clear, rect);
let block = modal_block("Help — Keybindings");
let inner = block.inner(rect);
frame.render_widget(block, rect);
let bindings: &[(&str, &str)] = &[
("q", "Quit"),
("Tab", "Cycle panel focus"),
("?", "Toggle this help"),
("", ""),
("j / ↓", "Move cursor down"),
("k / ↑", "Move cursor up"),
("", ""),
("Space", "Play / Pause"),
("n", "Next track"),
("N", "Previous track"),
("+ / =", "Volume up"),
("-", "Volume down"),
("", ""),
("Enter", "Play selected track"),
("a", "Add track to queue"),
("p", "Add track to playlist"),
("d", "Remove track from library"),
("r", "Rename track / playlist"),
("", ""),
("c", "Create new playlist"),
("x", "Remove item from queue"),
("", ""),
("f", "Open file picker"),
];
let items: Vec<Line> = bindings
.iter()
.map(|(key, desc)| {
if key.is_empty() {
Line::from("")
} else {
Line::from(vec![
Span::styled(format!(" {:>10} ", key), Style::default().fg(ACCENT)),
Span::styled(*desc, Style::default().fg(TEXT_DIM)),
])
}
})
.collect();
frame.render_widget(Paragraph::new(items), inner);
}