use ratatui::{
Frame,
crossterm::event::{KeyCode, KeyModifiers},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Clear, List, ListItem, ListState, Padding, Paragraph, Wrap,
},
};
use crate::library::{PlaylistId, TrackId};
use crate::numeric::usize_to_u16_saturating;
use crate::settings::ColorMode;
use crate::ui::layout::{
Theme, console_themes, cursor_spans, cursor_spans_windowed, format_duration, h_window,
h_window_start, themes, truncate,
};
#[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, Default, Clone)]
pub struct TextArea {
pub lines: Vec<String>,
pub cursor_row: usize,
pub cursor_col: usize,
}
impl TextArea {
pub fn from_text(text: &str) -> Self {
let lines: Vec<String> = text
.lines()
.map(|l| l.trim_end_matches('\r').to_string())
.collect();
let lines = if lines.is_empty() {
vec![String::new()]
} else {
lines
};
let cursor_row = lines.len() - 1;
let cursor_col = lines[cursor_row].len();
Self {
lines,
cursor_row,
cursor_col,
}
}
pub fn as_string(&self) -> String {
self.lines.join("\n")
}
pub fn insert_char(&mut self, c: char) {
self.lines[self.cursor_row].insert(self.cursor_col, c);
self.cursor_col += c.len_utf8();
}
pub fn delete_char(&mut self) {
if self.cursor_col > 0 {
let line = &self.lines[self.cursor_row];
let mut col = self.cursor_col - 1;
while !line.is_char_boundary(col) {
col -= 1;
}
self.lines[self.cursor_row].remove(col);
self.cursor_col = col;
} else if self.cursor_row > 0 {
let current = self.lines.remove(self.cursor_row);
self.cursor_row -= 1;
let prev_len = self.lines[self.cursor_row].len();
self.lines[self.cursor_row].push_str(¤t);
self.cursor_col = prev_len;
}
}
pub fn delete_next_char(&mut self) {
let line_len = self.lines[self.cursor_row].len();
if self.cursor_col < line_len {
self.lines[self.cursor_row].remove(self.cursor_col);
} else if self.cursor_row + 1 < self.lines.len() {
let next = self.lines.remove(self.cursor_row + 1);
self.lines[self.cursor_row].push_str(&next);
}
}
pub fn insert_newline(&mut self) {
let rest = self.lines[self.cursor_row][self.cursor_col..].to_string();
self.lines[self.cursor_row].truncate(self.cursor_col);
self.cursor_row += 1;
self.lines.insert(self.cursor_row, rest);
self.cursor_col = 0;
}
pub fn move_left(&mut self) {
if self.cursor_col > 0 {
let line = &self.lines[self.cursor_row];
let mut col = self.cursor_col - 1;
while !line.is_char_boundary(col) {
col -= 1;
}
self.cursor_col = col;
}
}
pub fn move_right(&mut self) {
let line = &self.lines[self.cursor_row];
if self.cursor_col < line.len()
&& let Some(c) = line[self.cursor_col..].chars().next()
{
self.cursor_col += c.len_utf8();
}
}
pub fn move_up(&mut self) {
if self.cursor_row > 0 {
self.cursor_row -= 1;
self.cursor_col = clamp_col(&self.lines[self.cursor_row], self.cursor_col);
}
}
pub fn move_down(&mut self) {
if self.cursor_row + 1 < self.lines.len() {
self.cursor_row += 1;
self.cursor_col = clamp_col(&self.lines[self.cursor_row], self.cursor_col);
}
}
pub const fn move_line_start(&mut self) {
self.cursor_col = 0;
}
pub fn move_line_end(&mut self) {
self.cursor_col = self.lines[self.cursor_row].len();
}
}
fn clamp_col(line: &str, col: usize) -> usize {
let mut c = col.min(line.len());
while c > 0 && !line.is_char_boundary(c) {
c -= 1;
}
c
}
pub type LyricsTextArea = TextArea;
#[derive(Debug, Clone, Copy)]
pub enum RemoveTarget {
TrackFromQueue {
queue_idx: usize,
},
TrackFromLibrary {
track_id: TrackId,
},
TrackFromPlaylist {
playlist_id: PlaylistId,
track_id: TrackId,
},
Playlist {
playlist_id: PlaylistId,
},
Queue,
}
#[derive(Debug, Clone)]
pub struct SettingsState {
pub cursor: usize,
pub volume_pct: u32,
pub seek_secs: u64,
pub resume_playback: bool,
pub preview_theme_idx: usize,
pub transparent: bool,
pub preview_console_idx: usize,
pub color_mode: ColorMode,
pub detected_truecolor: bool,
}
#[derive(Debug, Clone)]
pub enum Modal {
Notify {
message: String,
},
ConfirmRemove {
description: String,
target: RemoveTarget,
},
EditPlaylist {
id: PlaylistId,
input: TextInput,
},
NewPlaylist {
input: TextInput,
add_track: Option<TrackId>,
},
AddToPlaylist {
track_id: TrackId,
track_name: String,
choices: Vec<(PlaylistId, String)>,
cursor: usize,
},
Help {
scroll: usize,
},
ShuffleView {
view_name: String,
},
Menu {
cursor: usize,
},
About,
ConfirmQuit,
Settings(SettingsState),
EditMetadata {
track_id: TrackId,
fields: [TextInput; META_FIELDS],
active_field: usize,
original_name: String,
},
EditLyrics {
track_id: TrackId,
textarea: LyricsTextArea,
},
}
const MENU_ENTRIES: usize = 3;
pub enum ModalOutcome {
Consumed,
Confirm(ModalConfirm),
Dismissed,
}
#[derive(Debug)]
pub struct SettingsSave {
pub volume_pct: u32,
pub seek_secs: u64,
pub resume_playback: bool,
pub theme_name: String,
pub console_theme_name: String,
pub transparent: bool,
pub color_mode: ColorMode,
}
#[derive(Debug)]
pub enum ModalConfirm {
Remove(RemoveTarget),
RenamePlaylist {
id: PlaylistId,
new_name: String,
},
NewPlaylist {
name: String,
add_track: Option<TrackId>,
},
AddToPlaylist {
track_id: TrackId,
playlist_id: PlaylistId,
},
SaveSettings(SettingsSave),
PreviewTheme {
theme_name: String,
console_theme_name: String,
transparent: bool,
color_mode: ColorMode,
},
ShuffleView,
OpenSettings,
OpenAbout,
Quit,
SaveMetadata {
track_id: TrackId,
name: String,
artist: Option<String>,
album: Option<String>,
},
SaveLyrics {
track_id: TrackId,
lyrics: Option<String>,
},
SaveMetadataAndEditLyrics {
track_id: TrackId,
name: String,
artist: Option<String>,
album: Option<String>,
},
}
fn nonempty_opt(s: &str) -> Option<String> {
let t = s.trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
}
enum TextInputResult {
Consumed,
Dismissed,
Submitted(String),
}
fn handle_text_key(input: &mut TextInput, code: KeyCode) -> TextInputResult {
match code {
KeyCode::Enter => {
let name = input.value.trim().to_string();
if name.is_empty() {
TextInputResult::Consumed
} else {
TextInputResult::Submitted(name)
}
}
KeyCode::Esc => TextInputResult::Dismissed,
KeyCode::Char(c) => {
input.push(c);
TextInputResult::Consumed
}
KeyCode::Backspace => {
input.backspace();
TextInputResult::Consumed
}
KeyCode::Left => {
input.move_left();
TextInputResult::Consumed
}
KeyCode::Right => {
input.move_right();
TextInputResult::Consumed
}
_ => TextInputResult::Consumed,
}
}
const SET_VOLUME: usize = 0;
const SET_SEEK: usize = 1;
const SET_RESUME: usize = 2;
const SET_COLOR_MODE: usize = 3;
const SET_THEME: usize = 4;
const SET_TRANSPARENCY: usize = 5;
const SET_ROWS: usize = 6;
const fn settings_enabled(color_mode: ColorMode, detected: bool) -> [bool; SET_ROWS] {
let tc = color_mode.truecolor(detected);
[true, true, true, true, true, tc]
}
fn handle_settings_key(code: KeyCode, s: &mut SettingsState) -> ModalOutcome {
let enabled = settings_enabled(s.color_mode, s.detected_truecolor);
let preview = |s: &SettingsState| {
ModalOutcome::Confirm(ModalConfirm::PreviewTheme {
theme_name: themes()[s.preview_theme_idx].name.to_string(),
console_theme_name: console_themes()[s.preview_console_idx].name.to_string(),
transparent: s.transparent,
color_mode: s.color_mode,
})
};
match code {
KeyCode::Char('j') | KeyCode::Down => {
let mut n = s.cursor;
while n + 1 < SET_ROWS {
n += 1;
if enabled[n] {
s.cursor = n;
break;
}
}
ModalOutcome::Consumed
}
KeyCode::Char('k') | KeyCode::Up => {
let mut n = s.cursor;
while n > 0 {
n -= 1;
if enabled[n] {
s.cursor = n;
break;
}
}
ModalOutcome::Consumed
}
KeyCode::Char('g') | KeyCode::Home | KeyCode::PageUp => {
s.cursor = 0; ModalOutcome::Consumed
}
KeyCode::Char('G') | KeyCode::End | KeyCode::PageDown => {
s.cursor = enabled.iter().rposition(|&e| e).unwrap_or(0);
ModalOutcome::Consumed
}
KeyCode::Left | KeyCode::Right | KeyCode::Char('h' | 'l') => {
let left = matches!(code, KeyCode::Left | KeyCode::Char('h'));
match s.cursor {
SET_VOLUME => {
if left {
s.volume_pct = s.volume_pct.saturating_sub(1);
} else {
s.volume_pct = (s.volume_pct + 1).min(100);
}
ModalOutcome::Consumed
}
SET_SEEK => {
if left {
s.seek_secs = s.seek_secs.saturating_sub(1).max(1);
} else {
s.seek_secs = (s.seek_secs + 1).min(120);
}
ModalOutcome::Consumed
}
SET_RESUME => {
s.resume_playback = !s.resume_playback;
ModalOutcome::Consumed
}
SET_COLOR_MODE => {
s.color_mode = s.color_mode.toggle(s.detected_truecolor);
preview(s)
}
SET_THEME => {
let (idx, len) = if s.color_mode.truecolor(s.detected_truecolor) {
(&mut s.preview_theme_idx, themes().len())
} else {
(&mut s.preview_console_idx, console_themes().len())
};
*idx = if left {
idx.checked_sub(1).unwrap_or(len - 1)
} else {
(*idx + 1) % len
};
preview(s)
}
_ => {
s.transparent = !s.transparent;
preview(s)
}
}
}
KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q') => {
ModalOutcome::Confirm(ModalConfirm::SaveSettings(SettingsSave {
volume_pct: s.volume_pct,
seek_secs: s.seek_secs,
resume_playback: s.resume_playback,
theme_name: themes()[s.preview_theme_idx].name.to_string(),
console_theme_name: console_themes()[s.preview_console_idx].name.to_string(),
transparent: s.transparent,
color_mode: s.color_mode,
}))
}
_ => ModalOutcome::Consumed,
}
}
pub const META_FIELDS: usize = 3;
const META_ROWS: usize = META_FIELDS + 1;
fn handle_edit_metadata_key(
code: KeyCode,
track_id: TrackId,
fields: &mut [TextInput; META_FIELDS],
active_field: &mut usize,
original_name: &str,
) -> ModalOutcome {
match code {
KeyCode::Esc | KeyCode::Enter => {
let typed = fields[0].value.trim();
let name = if typed.is_empty() {
original_name.to_string()
} else {
typed.to_string()
};
let artist = nonempty_opt(&fields[1].value);
let album = nonempty_opt(&fields[2].value);
if matches!(code, KeyCode::Enter) && *active_field == META_FIELDS {
ModalOutcome::Confirm(ModalConfirm::SaveMetadataAndEditLyrics {
track_id,
name,
artist,
album,
})
} else {
ModalOutcome::Confirm(ModalConfirm::SaveMetadata {
track_id,
name,
artist,
album,
})
}
}
KeyCode::Tab | KeyCode::Down => {
*active_field = (*active_field + 1) % META_ROWS;
ModalOutcome::Consumed
}
KeyCode::BackTab | KeyCode::Up => {
*active_field = (*active_field + META_ROWS - 1) % META_ROWS;
ModalOutcome::Consumed
}
KeyCode::Char(c) if *active_field < META_FIELDS => {
fields[*active_field].push(c);
ModalOutcome::Consumed
}
KeyCode::Backspace if *active_field < META_FIELDS => {
fields[*active_field].backspace();
ModalOutcome::Consumed
}
KeyCode::Left if *active_field < META_FIELDS => {
fields[*active_field].move_left();
ModalOutcome::Consumed
}
KeyCode::Right if *active_field < META_FIELDS => {
fields[*active_field].move_right();
ModalOutcome::Consumed
}
_ => ModalOutcome::Consumed,
}
}
fn handle_edit_lyrics_key(
code: KeyCode,
track_id: TrackId,
textarea: &mut TextArea,
) -> ModalOutcome {
match code {
KeyCode::Esc => {
let s = textarea.as_string();
let lyrics = if s.trim().is_empty() { None } else { Some(s) };
return ModalOutcome::Confirm(ModalConfirm::SaveLyrics { track_id, lyrics });
}
KeyCode::Enter => textarea.insert_newline(),
KeyCode::Backspace => textarea.delete_char(),
KeyCode::Delete => textarea.delete_next_char(),
KeyCode::Up => textarea.move_up(),
KeyCode::Down => textarea.move_down(),
KeyCode::Left => textarea.move_left(),
KeyCode::Right => textarea.move_right(),
KeyCode::Home => textarea.move_line_start(),
KeyCode::End => textarea.move_line_end(),
KeyCode::Char(c) => textarea.insert_char(c),
_ => {}
}
ModalOutcome::Consumed
}
fn handle_add_to_playlist_key(
code: KeyCode,
choices: &[(PlaylistId, String)],
cursor: &mut usize,
track_id: TrackId,
) -> ModalOutcome {
if let Some(new) = crate::nav::list_move(code, *cursor, choices.len()) {
*cursor = new;
return ModalOutcome::Consumed;
}
match code {
KeyCode::Enter => {
if let Some((playlist_id, _)) = choices.get(*cursor) {
ModalOutcome::Confirm(ModalConfirm::AddToPlaylist {
track_id,
playlist_id: *playlist_id,
})
} else {
ModalOutcome::Dismissed
}
}
KeyCode::Esc | KeyCode::Char('q') => ModalOutcome::Dismissed,
_ => ModalOutcome::Consumed,
}
}
enum Confirm {
Yes,
No,
Ignore,
}
const fn confirm_key(code: KeyCode) -> Confirm {
match code {
KeyCode::Char('y' | 'Y') | KeyCode::Enter => Confirm::Yes,
KeyCode::Char('n' | 'N' | 'q') | KeyCode::Esc => Confirm::No,
_ => Confirm::Ignore,
}
}
impl Modal {
pub fn handle_key(&mut self, code: KeyCode, _modifiers: KeyModifiers) -> ModalOutcome {
match self {
Self::Notify { .. } | Self::About => ModalOutcome::Dismissed,
Self::Help { scroll } => handle_help_key(code, scroll),
Self::ConfirmQuit => match confirm_key(code) {
Confirm::Yes => ModalOutcome::Confirm(ModalConfirm::Quit),
Confirm::No => ModalOutcome::Dismissed,
Confirm::Ignore => ModalOutcome::Consumed,
},
Self::Menu { cursor } => {
if let Some(new) = crate::nav::list_move(code, *cursor, MENU_ENTRIES) {
*cursor = new;
return ModalOutcome::Consumed;
}
match code {
KeyCode::Enter => match *cursor {
0 => ModalOutcome::Confirm(ModalConfirm::OpenSettings),
1 => ModalOutcome::Confirm(ModalConfirm::OpenAbout),
_ => ModalOutcome::Confirm(ModalConfirm::Quit),
},
KeyCode::Esc | KeyCode::Char('q') => ModalOutcome::Dismissed,
_ => ModalOutcome::Consumed,
}
}
Self::ShuffleView { .. } => match confirm_key(code) {
Confirm::Yes => ModalOutcome::Confirm(ModalConfirm::ShuffleView),
Confirm::No => ModalOutcome::Dismissed,
Confirm::Ignore => ModalOutcome::Consumed,
},
Self::Settings(state) => handle_settings_key(code, state),
Self::ConfirmRemove { target, .. } => match confirm_key(code) {
Confirm::Yes => ModalOutcome::Confirm(ModalConfirm::Remove(*target)),
Confirm::No => ModalOutcome::Dismissed,
Confirm::Ignore => ModalOutcome::Consumed,
},
Self::EditPlaylist { id, input } => match handle_text_key(input, code) {
TextInputResult::Submitted(name) => {
ModalOutcome::Confirm(ModalConfirm::RenamePlaylist {
id: *id,
new_name: name,
})
}
TextInputResult::Dismissed => {
let name = input.value.trim();
if name.is_empty() {
ModalOutcome::Dismissed
} else {
ModalOutcome::Confirm(ModalConfirm::RenamePlaylist {
id: *id,
new_name: name.to_string(),
})
}
}
TextInputResult::Consumed => ModalOutcome::Consumed,
},
Self::NewPlaylist { input, add_track } => match handle_text_key(input, code) {
TextInputResult::Submitted(name) => {
ModalOutcome::Confirm(ModalConfirm::NewPlaylist {
name,
add_track: *add_track,
})
}
TextInputResult::Dismissed => ModalOutcome::Dismissed,
TextInputResult::Consumed => ModalOutcome::Consumed,
},
Self::AddToPlaylist {
choices,
cursor,
track_id,
..
} => handle_add_to_playlist_key(code, choices, cursor, *track_id),
Self::EditMetadata {
track_id,
fields,
active_field,
original_name,
} => handle_edit_metadata_key(code, *track_id, fields, active_field, original_name),
Self::EditLyrics { track_id, textarea } => {
handle_edit_lyrics_key(code, *track_id, textarea)
}
}
}
}
pub fn render_modal(frame: &mut Frame<'_>, modal: &Modal, theme: &Theme) {
match modal {
Modal::Notify { message } => render_notification(frame, "Notice", message, theme),
Modal::Help { scroll } => render_help(frame, *scroll, theme),
Modal::About => render_about(frame, theme),
Modal::ConfirmQuit => render_confirm(frame, "Quit audium?", theme),
Modal::Menu { cursor } => render_menu(frame, *cursor, theme),
Modal::ConfirmRemove { description, .. } => render_confirm(frame, description, theme),
Modal::EditPlaylist { input, .. } => {
render_text_input(
frame,
"Edit Playlist",
input,
&[hint("Enter / Esc", "save")],
theme,
);
}
Modal::NewPlaylist { input, add_track } => {
let (title, create) = if add_track.is_some() {
("Add to New Playlist", "create & add")
} else {
("New Playlist", "create")
};
render_text_input(
frame,
title,
input,
&[hint("Enter", create), hint("Esc", "cancel")],
theme,
);
}
Modal::AddToPlaylist {
track_name,
choices,
cursor,
..
} => render_playlist_picker(frame, track_name, choices, *cursor, theme),
Modal::Settings(state) => render_settings(frame, state, theme),
Modal::ShuffleView { view_name } => render_confirm(
frame,
&format!("Shuffle \"{view_name}\"? This will clear the current queue."),
theme,
),
Modal::EditMetadata {
fields,
active_field,
..
} => render_edit_metadata(frame, fields, *active_field, theme),
Modal::EditLyrics { textarea, .. } => render_edit_lyrics(frame, textarea, theme),
}
}
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),
}
}
pub const MODAL_PAD_X: u16 = 2;
pub const MODAL_PAD_Y: u16 = 1;
pub const MODAL_CHROME_H: u16 = 2 + 2 * MODAL_PAD_Y;
#[derive(Clone, Copy)]
pub struct Hint<'a> {
key: &'a str,
action: &'a str,
danger: bool,
}
pub const fn hint<'a>(key: &'a str, action: &'a str) -> Hint<'a> {
Hint {
key,
action,
danger: false,
}
}
const fn danger_hint<'a>(key: &'a str, action: &'a str) -> Hint<'a> {
Hint {
key,
action,
danger: true,
}
}
fn hint_lines<'a>(hints: &[Hint<'a>], width: usize, theme: &Theme) -> Vec<Line<'a>> {
let sep = theme.glyphs().sep;
let sep_w = sep.chars().count();
let action_style = Style::default().fg(theme.subtle);
let mut lines: Vec<Line<'a>> = Vec::new();
let mut spans: Vec<Span<'a>> = Vec::new();
let mut used = 0usize;
for h in hints {
let pair_w = h.key.chars().count() + h.action.chars().count() + 3; let needed = if spans.is_empty() {
pair_w
} else {
pair_w + sep_w
};
if !spans.is_empty() && used + needed > width {
lines.push(Line::from(std::mem::take(&mut spans)));
used = 0;
}
if !spans.is_empty() {
spans.push(Span::styled(sep, action_style));
used += sep_w;
}
let key_style = if h.danger {
Style::default()
.fg(theme.danger)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.accent)
};
spans.push(Span::styled(format!("[{}]", h.key), key_style));
spans.push(Span::styled(format!(" {}", h.action), action_style));
used += pair_w;
}
if !spans.is_empty() {
lines.push(Line::from(spans));
}
lines
}
pub fn hint_height(hints: &[Hint<'_>], width: usize, theme: &Theme) -> u16 {
usize_to_u16_saturating(hint_lines(hints, width, theme).len())
}
pub fn render_hints(frame: &mut Frame<'_>, area: Rect, hints: &[Hint<'_>], theme: &Theme) {
frame.render_widget(
Paragraph::new(hint_lines(hints, area.width as usize, theme)).alignment(Alignment::Center),
area,
);
}
fn wrapped_height(text: &str, width: usize) -> u16 {
if width == 0 {
return 1;
}
let mut rows = 1usize;
let mut used = 0usize;
for word in text.split_whitespace() {
let w = crate::ui::layout::str_width(word);
if used > 0 && used + 1 + w > width {
rows += 1;
used = 0;
}
if w > width {
let extra = (w - 1) / width;
rows += extra;
used = w - extra * width;
} else {
used += if used == 0 { w } else { 1 + w };
}
}
usize_to_u16_saturating(rows)
}
const fn modal_inner_width(w: u16) -> usize {
(w.saturating_sub(2 + 2 * MODAL_PAD_X)) as usize
}
pub fn modal_block<'a>(title: &'a str, theme: &Theme) -> Block<'a> {
Block::default()
.padding(Padding::new(
MODAL_PAD_X,
MODAL_PAD_X,
MODAL_PAD_Y,
MODAL_PAD_Y,
))
.title(format!(" {title} "))
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.style(theme.apply_bg(Style::default()))
}
fn render_notification(frame: &mut Frame<'_>, title: &str, message: &str, theme: &Theme) {
const WIDTH: u16 = 50;
let hints = [hint("any key", "dismiss")];
let hint_h = hint_height(&hints, modal_inner_width(WIDTH), theme);
let msg_h = wrapped_height(message, modal_inner_width(WIDTH));
let area = frame.area();
let rect = centered_rect(WIDTH, msg_h + 1 + hint_h + MODAL_CHROME_H, area);
frame.render_widget(Clear, rect);
let block = modal_block(title, theme);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(msg_h), Constraint::Length(1), Constraint::Length(hint_h), ])
.split(inner);
frame.render_widget(
Paragraph::new(Span::styled(message, Style::default().fg(theme.text)))
.alignment(Alignment::Center)
.wrap(Wrap { trim: false }),
rows[0],
);
render_hints(frame, rows[2], &hints, theme);
}
fn render_confirm(frame: &mut Frame<'_>, description: &str, theme: &Theme) {
const WIDTH: u16 = 52;
let hints = [
danger_hint("Y / Enter", "confirm"),
hint("N / Esc", "cancel"),
];
let hint_h = hint_height(&hints, modal_inner_width(WIDTH), theme);
let msg_h = wrapped_height(description, modal_inner_width(WIDTH));
let area = frame.area();
let rect = centered_rect(WIDTH, msg_h + 1 + hint_h + MODAL_CHROME_H, area);
frame.render_widget(Clear, rect);
let block = modal_block("Confirm", theme);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(msg_h), Constraint::Length(1), Constraint::Length(hint_h), ])
.split(inner);
frame.render_widget(
Paragraph::new(Span::styled(description, Style::default().fg(theme.text)))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
rows[0],
);
render_hints(frame, rows[2], &hints, theme);
}
fn render_text_input(
frame: &mut Frame<'_>,
title: &str,
input: &TextInput,
hints: &[Hint<'_>],
theme: &Theme,
) {
const WIDTH: u16 = 52;
let hint_h = hint_height(hints, modal_inner_width(WIDTH), theme);
let area = frame.area();
let rect = centered_rect(WIDTH, 3 + hint_h + MODAL_CHROME_H, area);
frame.render_widget(Clear, rect);
let block = modal_block(title, theme);
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), Constraint::Length(hint_h), ])
.split(inner);
frame.render_widget(
Paragraph::new(Span::styled(
"Enter name:",
Style::default().fg(theme.text_dim),
)),
rows[0],
);
frame.render_widget(
Paragraph::new(Line::from(cursor_spans_windowed(
&input.value,
input.cursor,
rows[1].width as usize,
theme,
))),
rows[1],
);
render_hints(frame, rows[3], hints, theme);
}
fn render_playlist_picker(
frame: &mut Frame<'_>,
track_name: &str,
choices: &[(PlaylistId, String)],
cursor: usize,
theme: &Theme,
) {
const WIDTH: u16 = 52;
let area = frame.area();
let hints = [
hint("Enter", "add"),
hint("j/k", "navigate"),
hint("Esc", "cancel"),
];
let hint_h = hint_height(&hints, modal_inner_width(WIDTH), theme);
let height = (usize_to_u16_saturating(choices.len()) + 3 + hint_h + MODAL_CHROME_H)
.min(area.height.saturating_sub(4));
let rect = centered_rect(WIDTH, height, area);
frame.render_widget(Clear, rect);
let block = modal_block("Add to Playlist", theme);
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), Constraint::Length(hint_h), ])
.split(inner);
frame.render_widget(
Paragraph::new(Span::styled(
format!("Track: {track_name}"),
Style::default().fg(theme.text_dim),
)),
rows[0],
);
if choices.is_empty() {
frame.render_widget(
Paragraph::new(Span::styled(
"No playlists yet.",
Style::default().fg(theme.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(theme.selection_style())
.highlight_symbol("> "),
rows[2],
&mut list_state,
);
}
render_hints(frame, rows[4], &hints, theme);
}
const HELP_PAGE: usize = 10;
const fn handle_help_key(code: KeyCode, scroll: &mut usize) -> ModalOutcome {
let moved = match code {
KeyCode::Char('j') | KeyCode::Down => scroll.saturating_add(1),
KeyCode::Char('k') | KeyCode::Up => scroll.saturating_sub(1),
KeyCode::PageDown => scroll.saturating_add(HELP_PAGE),
KeyCode::PageUp => scroll.saturating_sub(HELP_PAGE),
KeyCode::Char('g') | KeyCode::Home => 0,
_ => return ModalOutcome::Dismissed,
};
*scroll = moved;
ModalOutcome::Consumed
}
struct HelpSection {
title: &'static str,
keys: &'static [(&'static str, &'static str)],
}
const HELP: &[HelpSection] = &[
HelpSection {
title: "Getting around",
keys: &[
("Tab / S-Tab", "Next / previous panel"),
("j / k", "Move cursor"),
("g / G", "Jump to top / bottom"),
("PgUp / PgDn", "Page up / down"),
("Enter", "Open / play selection"),
("J / K", "Move item down / up"),
("/", "Filter the track list"),
],
},
HelpSection {
title: "Playback",
keys: &[
("Space", "Play / pause"),
("n / N", "Next / previous track"),
("Left / Right", "Seek backward / forward"),
("+ / -", "Volume up / down"),
("[ / ]", "Speed down / up"),
("l", "Cycle loop mode"),
("y", "Lyrics overlay"),
],
},
HelpSection {
title: "Queue",
keys: &[
("a", "Add selection to queue"),
("z", "Shuffle view into queue"),
("d", "Remove from queue"),
("D", "Clear the queue"),
],
},
HelpSection {
title: "Library",
keys: &[
("f", "Import files"),
("c", "Create a playlist"),
("p", "Add track to a playlist"),
("e", "Edit track / playlist"),
("d", "Delete track / playlist"),
],
},
HelpSection {
title: "Application",
keys: &[
("m", "Menu, settings and about"),
("?", "Toggle this help"),
("q", "Quit"),
],
},
];
fn help_lines<'a>(sections: &[&'a HelpSection], theme: &Theme) -> Vec<Line<'a>> {
let key_w = sections
.iter()
.flat_map(|s| s.keys.iter())
.map(|(k, _)| k.chars().count())
.max()
.unwrap_or(0);
let mut lines = Vec::new();
for (i, section) in sections.iter().enumerate() {
if i > 0 {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
section.title,
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)));
for (key, desc) in section.keys {
lines.push(Line::from(vec![
Span::styled(
format!(" {key:<key_w$} "),
Style::default().fg(theme.text),
),
Span::styled(*desc, Style::default().fg(theme.text_dim)),
]));
}
}
lines
}
fn help_columns() -> (Vec<&'static HelpSection>, Vec<&'static HelpSection>) {
let all: Vec<&HelpSection> = HELP.iter().collect();
let rows = |s: &HelpSection| s.keys.len() + 2;
let total: usize = all.iter().map(|s| rows(s)).sum();
let mut used = 0;
let mut best = (usize::MAX, 1);
for (i, section) in all.iter().enumerate() {
used += rows(section);
let diff = used.abs_diff(total - used);
if diff < best.0 {
best = (diff, i + 1);
}
}
let (left, right) = all.split_at(best.1.min(all.len()));
(left.to_vec(), right.to_vec())
}
const HELP_COL_GAP: u16 = 4;
fn lines_width(lines: &[Line<'_>]) -> u16 {
usize_to_u16_saturating(lines.iter().map(Line::width).max().unwrap_or(0))
}
fn render_help(frame: &mut Frame<'_>, scroll: usize, theme: &Theme) {
let area = frame.area();
let (left_sections, right_sections) = help_columns();
let left = help_lines(&left_sections, theme);
let right = help_lines(&right_sections, theme);
let (left_w, right_w) = (lines_width(&left), lines_width(&right));
let chrome = 2 + 2 * MODAL_PAD_X;
let two_col = !right.is_empty() && left_w + HELP_COL_GAP + right_w + chrome <= area.width;
let (body_lines, content_w) = if two_col {
(left.len().max(right.len()), left_w + HELP_COL_GAP + right_w)
} else {
(0, left_w.max(right_w))
};
let hints = [hint("j/k", "scroll"), hint("any key", "close")];
let hint_h = hint_height(&hints, usize::from(content_w), theme);
let width = (content_w + chrome).min(area.width.saturating_sub(2));
let stacked: Vec<Line<'_>>;
let (lines_len, single) = if two_col {
(body_lines, None)
} else {
let all: Vec<&HelpSection> = HELP.iter().collect();
stacked = help_lines(&all, theme);
(stacked.len(), Some(&stacked))
};
let max_h = area.height.saturating_sub(2);
let fixed = hint_h + 1 + MODAL_CHROME_H;
let height = (usize_to_u16_saturating(lines_len) + fixed).min(max_h);
let visible = usize::from(height.saturating_sub(fixed));
let scrollable = lines_len > visible;
let scroll = if scrollable {
usize_to_u16_saturating(scroll.min(lines_len - visible))
} else {
0
};
let rect = centered_rect(width, height, area);
frame.render_widget(Clear, rect);
let block = modal_block("Help", theme);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(1),
Constraint::Length(hint_h),
])
.split(inner);
if let Some(all) = single {
frame.render_widget(Paragraph::new(all.clone()).scroll((scroll, 0)), rows[0]);
} else {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(left_w + HELP_COL_GAP),
Constraint::Min(0),
])
.split(rows[0]);
frame.render_widget(Paragraph::new(left).scroll((scroll, 0)), cols[0]);
frame.render_widget(Paragraph::new(right).scroll((scroll, 0)), cols[1]);
}
let shown: Vec<Hint<'_>> = if scrollable {
hints.to_vec()
} else {
vec![hint("any key", "close")]
};
render_hints(frame, rows[2], &shown, theme);
}
fn render_menu(frame: &mut Frame<'_>, cursor: usize, theme: &Theme) {
const WIDTH: u16 = 40;
let area = frame.area();
let hints = [
hint("j/k", "navigate"),
hint("Enter", "select"),
hint("Esc", "close"),
];
let hint_h = hint_height(&hints, modal_inner_width(WIDTH), theme);
let rect = centered_rect(WIDTH, 4 + hint_h + MODAL_CHROME_H, area);
frame.render_widget(Clear, rect);
let block = modal_block("Menu", theme);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let entries = ["Settings", "About", "Quit"];
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(hint_h), ])
.split(inner);
for (i, label) in entries.iter().enumerate() {
let selected = cursor == i;
let prefix = if selected {
theme.glyphs().marker
} else {
" "
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(theme.accent)),
Span::styled(
*label,
if selected {
Style::default().fg(theme.text).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_dim)
},
),
])),
rows[i],
);
}
render_hints(frame, rows[4], &hints, theme);
}
fn render_about(frame: &mut Frame<'_>, theme: &Theme) {
const WIDTH: u16 = 64;
const LOGO: [&str; 7] = [
" mm ## ",
" ## \"\" ",
" m#####m ## ## m###m## #### ## ## ####m##m",
" \" mmm## ## ## ##\" \"## ## ## ## ## ## ##",
"m##\"\"\"## ## ## ## ## ## ## ## ## ## ##",
"##mmm### ##mmm### \"##mm### mmm##mmm ##mmm### ## ## ##",
" \"\"\"\" \"\" \"\"\"\" \"\" \"\"\" \"\" \"\"\"\"\"\"\"\" \"\"\"\" \"\" \"\" \"\" \"\"",
];
let hints = [hint("any key", "close")];
let hint_h = hint_height(&hints, modal_inner_width(WIDTH), theme);
let area = frame.area();
let rect = centered_rect(WIDTH, 13 + hint_h + MODAL_CHROME_H, area);
frame.render_widget(Clear, rect);
let block = modal_block("About", theme);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(hint_h), ])
.split(inner);
let logo: Vec<Line<'_>> = LOGO
.iter()
.map(|l| {
Line::from(Span::styled(
*l,
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
))
})
.collect();
frame.render_widget(Paragraph::new(logo).alignment(Alignment::Center), rows[0]);
let version = env!("CARGO_PKG_VERSION");
let meta: [(&str, &str); 4] = [
("version", version),
("author", "takashialpha"),
("license", "GPL-3.0-or-later"),
("source", "github.com/takashialpha/audium"),
];
for (i, (label, value)) in meta.iter().enumerate() {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!("{label:>8} "), Style::default().fg(theme.subtle)),
Span::styled(*value, Style::default().fg(theme.text)),
])),
rows[2 + i],
);
}
render_hints(frame, rows[7], &hints, theme);
}
fn render_settings(frame: &mut Frame<'_>, view: &SettingsState, theme: &Theme) {
const WIDTH: u16 = 60;
let truecolor = view.color_mode.truecolor(view.detected_truecolor);
let area = frame.area();
let hints = [
hint("j/k", "select"),
hint("h/l", "adjust"),
hint("Enter / Esc", "close"),
];
let hint_h = hint_height(&hints, modal_inner_width(WIDTH), theme);
let rect = centered_rect(WIDTH, 21 + hint_h + MODAL_CHROME_H, area);
frame.render_widget(Clear, rect);
let block = modal_block("Settings", theme);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Length(2), Constraint::Min(1), Constraint::Length(hint_h), ])
.split(inner);
render_settings_row(
frame,
rows[0],
"Default volume",
view.cursor == SET_VOLUME,
volume_bar(view.volume_pct, theme),
theme,
);
render_settings_row(
frame,
rows[1],
"Seek step",
view.cursor == SET_SEEK,
seek_display(view.seek_secs, theme),
theme,
);
render_settings_row(
frame,
rows[2],
"Resume playback",
view.cursor == SET_RESUME,
toggle_display(if view.resume_playback { "on" } else { "off" }, theme),
theme,
);
render_settings_row(
frame,
rows[3],
"Color mode",
view.cursor == SET_COLOR_MODE,
color_mode_display(view.color_mode, view.detected_truecolor, theme),
theme,
);
let theme_name = if truecolor {
themes()[view.preview_theme_idx].name
} else {
console_themes()[view.preview_console_idx].name
};
let theme_value = theme_cycle_display(theme_name, theme);
render_settings_row(
frame,
rows[4],
"Theme",
view.cursor == SET_THEME,
theme_value,
theme,
);
let transparency_value = if truecolor {
toggle_display(if view.transparent { "on" } else { "off" }, theme)
} else {
locked_display("unavailable", theme)
};
render_settings_row(
frame,
rows[5],
"Terminal transparency",
view.cursor == SET_TRANSPARENCY,
transparency_value,
theme,
);
frame.render_widget(
Paragraph::new(Span::styled(
settings_row_description(view.cursor, truecolor),
Style::default().fg(theme.text_dim),
))
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
rows[6],
);
render_hints(frame, rows[8], &hints, theme);
}
const fn settings_row_description(cursor: usize, truecolor: bool) -> &'static str {
match cursor {
SET_VOLUME => "Volume applied each time audium starts.",
SET_SEEK => "How far the seek keys jump, in seconds.",
SET_RESUME => "Reopen with the last queue and position, paused. Off forgets it.",
SET_COLOR_MODE if truecolor => {
"Auto detects truecolor support. Switch to 16-color only if your terminal does not truly support it."
}
SET_COLOR_MODE => {
"Auto detects truecolor support. Switch to Truecolor only if your terminal actually supports it."
}
SET_THEME if truecolor => "Color scheme for the interface.",
SET_THEME => "Color scheme built from your terminal's own 16 colors.",
SET_TRANSPARENCY if truecolor => {
"Shows the terminal background through the UI. Best with a transparent or blurred terminal."
}
_ => "Console themes already leave the background untouched, so there is nothing to blend.",
}
}
fn render_settings_row<'a>(
frame: &mut Frame<'_>,
area: Rect,
label: &'a str,
selected: bool,
value_line: Line<'a>,
theme: &Theme,
) {
let row_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(if selected { theme.accent } else { theme.subtle }));
let row_inner = row_block.inner(area);
frame.render_widget(row_block, area);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(24)])
.split(row_inner);
frame.render_widget(
Paragraph::new(Span::styled(
label,
Style::default().fg(if selected { theme.text } else { theme.text_dim }),
)),
cols[0],
);
frame.render_widget(
Paragraph::new(value_line).alignment(Alignment::Right),
cols[1],
);
}
fn cycle_line(middle: Vec<Span<'static>>, theme: &Theme) -> Line<'static> {
let g = theme.glyphs();
let arrow = Style::default().fg(theme.subtle);
let mut spans = Vec::with_capacity(middle.len() + 4);
spans.push(Span::styled(g.arrow_left, arrow));
spans.push(Span::raw(" "));
spans.extend(middle);
spans.push(Span::raw(" "));
spans.push(Span::styled(g.arrow_right, arrow));
Line::from(spans)
}
fn cycle_value(value: &str, color: Color, theme: &Theme) -> Line<'static> {
cycle_line(
vec![Span::styled(
value.to_owned(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
)],
theme,
)
}
fn volume_bar(pct: u32, theme: &Theme) -> Line<'static> {
let filled = (pct / 10) as usize;
let empty = 10usize.saturating_sub(filled);
let g = theme.glyphs();
let bar = g.bar_fill.repeat(filled) + &g.bar_empty.repeat(empty);
cycle_line(
vec![
Span::styled(bar, Style::default().fg(theme.accent)),
Span::styled(
format!(" {pct:>3}%"),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
],
theme,
)
}
fn seek_display(secs: u64, theme: &Theme) -> Line<'static> {
cycle_value(&format_duration(secs), theme.text, theme)
}
fn theme_cycle_display(name: &str, theme: &Theme) -> Line<'static> {
cycle_value(name, theme.accent, theme)
}
fn color_mode_display(mode: ColorMode, detected: bool, theme: &Theme) -> Line<'static> {
let text = if matches!(mode, ColorMode::Auto) {
let resolved = if mode.truecolor(detected) {
"truecolor"
} else {
"16-color"
};
format!("auto ({resolved})")
} else {
mode.label().to_string()
};
cycle_value(&text, theme.accent, theme)
}
fn toggle_display(value: &str, theme: &Theme) -> Line<'static> {
let col = if value == "on" {
theme.now_playing
} else {
theme.text_dim
};
cycle_value(value, col, theme)
}
fn locked_display(value: &'static str, theme: &Theme) -> Line<'static> {
Line::from(Span::styled(value, Style::default().fg(theme.subtle)))
}
const META_LABELS: [&str; META_FIELDS] = ["Name", "Artist", "Album"];
fn render_metadata_field(
frame: &mut Frame<'_>,
row: Rect,
label: &str,
input: &TextInput,
is_active: bool,
theme: &Theme,
) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(9), Constraint::Min(0)])
.split(row);
frame.render_widget(
Paragraph::new(Span::styled(
format!("{label:>7} "),
Style::default().fg(if is_active {
theme.accent
} else {
theme.text_dim
}),
)),
cols[0],
);
if is_active {
frame.render_widget(
Paragraph::new(Line::from(cursor_spans_windowed(
&input.value,
input.cursor,
cols[1].width as usize,
theme,
))),
cols[1],
);
} else {
let (text, style) = if input.value.is_empty() {
("-".to_string(), Style::default().fg(theme.subtle))
} else {
(
truncate(&input.value, cols[1].width as usize),
Style::default().fg(theme.text_dim),
)
};
frame.render_widget(Paragraph::new(Span::styled(text, style)), cols[1]);
}
}
fn render_edit_lyrics_button(frame: &mut Frame<'_>, row: Rect, active: bool, theme: &Theme) {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
if active { theme.glyphs().marker } else { " " },
Style::default().fg(theme.accent),
),
Span::styled(
format!("Edit Lyrics {}", theme.glyphs().arrow_right),
Style::default()
.fg(if active { theme.text } else { theme.text_dim })
.add_modifier(if active {
Modifier::BOLD
} else {
Modifier::empty()
}),
),
])),
row,
);
}
fn render_edit_metadata(
frame: &mut Frame<'_>,
fields: &[TextInput; META_FIELDS],
active_field: usize,
theme: &Theme,
) {
const WIDTH: u16 = 62;
let area = frame.area();
let hints = [
hint("Tab", "next field"),
hint("arrows", "move cursor"),
hint("Esc / Enter", "close"),
];
let hint_h = hint_height(&hints, modal_inner_width(WIDTH), theme);
let mut constraints = vec![Constraint::Length(1); META_FIELDS];
constraints.extend([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(hint_h), ]);
let content_h = usize_to_u16_saturating(META_FIELDS) + 3;
let rect = centered_rect(WIDTH, content_h + hint_h + MODAL_CHROME_H, area);
frame.render_widget(Clear, rect);
let block = modal_block("Edit Track", theme);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner);
for (i, (label, input)) in META_LABELS.iter().zip(fields.iter()).enumerate() {
render_metadata_field(frame, rows[i], label, input, active_field == i, theme);
}
render_edit_lyrics_button(
frame,
rows[META_FIELDS + 1],
active_field == META_FIELDS,
theme,
);
render_hints(frame, rows[META_FIELDS + 3], &hints, theme);
}
pub fn make_lyrics_textarea(raw: &str) -> LyricsTextArea {
TextArea::from_text(raw)
}
fn render_edit_lyrics(frame: &mut Frame<'_>, textarea: &TextArea, theme: &Theme) {
let area = frame.area();
let width = area.width.saturating_sub(8).max(40);
let height = area.height.saturating_sub(4).max(12);
let rect = centered_rect(width, height, area);
frame.render_widget(Clear, rect);
let block = modal_block("Edit Lyrics", theme);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let hints = [
hint("arrows", "navigate"),
hint("Home/End", "line start/end"),
hint("Enter", "new line"),
hint("Backspace", "delete"),
hint("Esc", "save"),
];
let hint_h = hint_height(&hints, inner.width as usize, theme);
let splits = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), Constraint::Length(hint_h), ])
.split(inner);
frame.render_widget(
Paragraph::new(Span::styled(
"synced lyrics: prefix a line with [mm:ss.xx]",
Style::default().fg(theme.subtle),
))
.alignment(Alignment::Center),
splits[2],
);
render_hints(frame, splits[3], &hints, theme);
let visible = usize::from(splits[0].height);
let scroll = textarea
.cursor_row
.saturating_sub(visible.saturating_sub(1))
.min(textarea.lines.len().saturating_sub(visible));
let width = usize::from(splits[0].width);
let cursor_line = textarea
.lines
.get(textarea.cursor_row)
.map_or("", String::as_str);
let h_scroll = h_window_start(cursor_line, textarea.cursor_col, width);
let items: Vec<Line<'_>> = textarea
.lines
.iter()
.enumerate()
.skip(scroll)
.take(visible)
.map(|(row, line)| {
let shown = h_window(line, h_scroll, width);
if row == textarea.cursor_row {
let col = shown
.char_indices()
.nth(
line[..textarea.cursor_col]
.chars()
.count()
.saturating_sub(h_scroll),
)
.map_or(shown.len(), |(i, _)| i);
Line::from(cursor_spans(&shown, col, theme))
} else {
Line::from(Span::styled(shown, Style::default().fg(theme.text_dim)))
}
})
.collect();
frame.render_widget(Paragraph::new(items), splits[0]);
}