use crate::input::Key;
use crate::vk::{Frame, Text, Thumb};
#[derive(Debug, PartialEq, Eq)]
pub enum Action {
None,
Open(usize),
Exit,
}
pub struct Menu {
pub title: String,
pub items: Vec<String>,
pub selected: usize,
}
impl Menu {
pub fn new(title: &str, items: &[&str]) -> Menu {
Menu {
title: title.into(),
items: items.iter().map(|s| s.to_string()).collect(),
selected: 0,
}
}
pub fn handle(&mut self, key: Key) -> Action {
self.handle_with_page(key, 8)
}
pub fn handle_with_page(&mut self, key: Key, capacity: usize) -> Action {
let n = self.items.len();
let capacity = capacity.max(1);
self.selected = self.selected.min(n.saturating_sub(1));
match key {
Key::Up if n > 0 => {
self.selected = (self.selected + n - 1) % n;
Action::None
}
Key::Down if n > 0 => {
self.selected = (self.selected + 1) % n;
Action::None
}
Key::PageUp => {
self.selected = self.selected.saturating_sub(capacity);
Action::None
}
Key::PageDown => {
self.selected = self.selected.saturating_add(capacity).min(n.saturating_sub(1));
Action::None
}
Key::Home => {
self.selected = 0;
Action::None
}
Key::End => {
self.selected = n.saturating_sub(1);
Action::None
}
Key::Select if n > 0 => Action::Open(self.selected),
Key::Back => Action::Exit,
_ => Action::None,
}
}
}
const FG: [f32; 4] = [0.92, 0.94, 0.98, 1.0];
const DIM: [f32; 4] = [0.60, 0.64, 0.72, 1.0];
const ACCENT: [f32; 4] = [0.16, 0.42, 0.90, 0.85];
const TITLE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const BG: [f32; 4] = [0.06, 0.07, 0.10, 1.0];
pub fn render(frame: &mut Frame, text: &Text, menu: &Menu, w: f32, h: f32) {
frame.clear();
let rows: Vec<Row> = menu.items.iter().map(|s| (s.clone(), String::new())).collect();
render_list(frame, text, &menu.title, &rows, menu.selected, w, h);
}
pub type Row = (String, String);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Layout {
Text,
List,
Grid,
}
pub struct Card {
pub title: String,
pub sub: String,
pub meta: String,
pub thumb: Option<Thumb>,
pub icon: Icon, }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Icon {
None,
Audio,
Video,
Folder,
}
const PLACEHOLDER: [f32; 4] = [0.13, 0.15, 0.21, 1.0];
pub struct BrowseView<'a> {
pub title: &'a str,
pub layout: Layout,
pub rows: &'a [Row], pub cards: &'a [Card], pub first: usize,
pub selected: usize,
pub matches: usize,
pub total: usize,
pub query: &'a str,
pub searching: bool,
pub search_focused: bool,
pub keyboard: Option<usize>,
}
#[derive(Clone, Copy)]
struct ListLayout {
margin: f32,
width: f32,
title_y: f32,
title_px: f32,
top: f32,
bottom: f32,
row_h: f32,
item_px: f32,
footer_y: f32,
hint_y: f32,
}
impl ListLayout {
fn new(w: f32, h: f32, browse: bool, keyboard: bool) -> Self {
let margin = w.min(h).max(0.0) * 0.06;
Self {
margin,
width: (w - 2.0 * margin).max(0.0),
title_y: h * 0.055,
title_px: h * 0.064,
top: h * if browse { 0.30 } else { 0.21 },
bottom: h * if keyboard { 0.55 } else { 0.87 },
row_h: h * 0.064,
item_px: h * 0.043,
footer_y: h * 0.895,
hint_y: h * 0.945,
}
}
fn capacity(self) -> usize {
if self.row_h <= 0.0 || self.width <= 0.0 { return 0; }
((self.bottom - self.top) / self.row_h).floor().max(1.0) as usize
}
}
pub fn browse_shape(w: f32, h: f32, keyboard: bool, layout: Layout) -> (usize, usize) {
match layout {
Layout::Text => (1, ListLayout::new(w, h, true, keyboard).capacity()),
Layout::List => (1, thumb_list_layout(w, h, keyboard).capacity()),
Layout::Grid => {
let g = GridLayout::new(w, h, keyboard);
if g.card_w <= 0.0 || h <= 0.0 { (g.cols, 0) } else { (g.cols, g.cols * g.rows) }
}
}
}
const COMPACT_SEARCH_Y: f32 = 0.14;
const COMPACT_SEARCH_H: f32 = 0.064;
const COMPACT_TOP: f32 = 0.225;
const LINE: f32 = 1.2;
const LIST_TITLE_PX: f32 = 0.032;
const LIST_SUB_PX: f32 = 0.025;
const GRID_TITLE_PX: f32 = 0.025;
const GRID_SUB_PX: f32 = 0.021;
fn thumb_list_layout(w: f32, h: f32, keyboard: bool) -> ListLayout {
let mut layout = ListLayout::new(w, h, true, keyboard);
layout.top = h * COMPACT_TOP;
layout.row_h = h * 0.125;
layout
}
#[derive(Clone, Copy)]
struct GridLayout {
list: ListLayout,
cols: usize,
rows: usize,
card_w: f32,
thumb_h: f32,
card_h: f32,
gap_x: f32,
gap_y: f32,
title_px: f32,
sub_px: f32,
}
impl GridLayout {
fn new(w: f32, h: f32, keyboard: bool) -> Self {
let mut list = ListLayout::new(w, h, true, keyboard);
list.top = h * COMPACT_TOP;
if !keyboard {
list.bottom = h * 0.885;
}
let cols = if h > 0.0 { (w / h * 2.8).round().clamp(2.0, 7.0) as usize } else { 1 };
let gap_x = list.width * 0.018;
let gap_y = h * 0.02;
let area = list.width * 0.988; let card_w = ((area - gap_x * (cols - 1) as f32) / cols as f32).max(0.0);
let thumb_h = card_w * 9.0 / 16.0;
let (title_px, sub_px) = (h * GRID_TITLE_PX, h * GRID_SUB_PX);
let card_h = thumb_h + title_px * 0.3 + (3.0 * title_px + sub_px) * LINE + h * 0.008;
let rows = ((list.bottom - list.top + gap_y) / (card_h + gap_y)).floor().max(1.0) as usize;
list.row_h = card_h + gap_y; Self { list, cols, rows, card_w, thumb_h, card_h, gap_x, gap_y, title_px, sub_px }
}
}
#[allow(clippy::too_many_arguments)]
fn draw_thumb(frame: &mut Frame, text: &Text, card: &Card, x: f32, y: f32, w: f32, h: f32, shade: f32) {
frame.rect(text, x, y, w, h, PLACEHOLDER);
let Some(t) = card.thumb.filter(|t| t.w > 0.0 && t.h > 0.0) else {
draw_icon(frame, text, card.icon, x, y, w, h);
return;
};
let tint = [shade, shade, shade, 1.0];
if ((t.w / t.h) / (w / h) - 1.0).abs() < 0.12 {
frame.image(t.cover(w, h), x, y, w, h, tint);
} else {
let scale = (w / t.w).min(h / t.h);
let (dw, dh) = (t.w * scale, t.h * scale);
frame.image(t.uv, x + (w - dw) / 2.0, y + (h - dh) / 2.0, dw, dh, tint);
}
}
const ICON: [f32; 4] = [0.36, 0.40, 0.50, 1.0];
fn draw_icon(frame: &mut Frame, text: &Text, icon: Icon, x: f32, y: f32, w: f32, h: f32) {
let glyph = match icon {
Icon::None => return,
Icon::Audio => "♫",
Icon::Video => "►",
Icon::Folder => {
let (fw, fh) = (h * 0.5, h * 0.36);
let (fx, fy) = (x + (w - fw) / 2.0, y + (h - fh) / 2.0);
let tab = fh * 0.18;
frame.rect(text, fx, fy, fw * 0.4, tab + 1.0, ICON);
frame.rect(text, fx, fy + tab, fw, fh - tab, ICON);
return;
}
};
let px = h * 0.42;
let iw = frame.measure(text, px, glyph);
frame.text(text, x + (w - iw) / 2.0, y + (h - px) / 2.0, px, glyph, ICON);
}
fn draw_badge(frame: &mut Frame, text: &Text, label: &str, right: f32, bottom: f32, px: f32) {
if label.is_empty() { return; }
let pad = px * 0.3;
let width = frame.measure(text, px, label) + 2.0 * pad;
let (x, y) = (right - width - pad, bottom - px * 1.25 - pad);
frame.rect(text, x, y, width, px * 1.25, [0.0, 0.0, 0.0, 0.78]);
frame.text(text, x + pad, y + px * 0.08, px, label, TITLE);
}
fn draw_thumb_rows(frame: &mut Frame, text: &Text, cards: &[Card], first: usize, selected: Option<usize>, layout: ListLayout, h: f32) {
let gutter = layout.width * 0.025;
let avail = (layout.width - gutter).max(0.0);
let thumb_h = layout.row_h * 0.84;
let thumb_w = thumb_h * 16.0 / 9.0;
let (title_px, sub_px) = (h * LIST_TITLE_PX, h * LIST_SUB_PX);
let budget = (2.0 * title_px + sub_px) * LINE;
let text_x = layout.margin + thumb_w + gutter;
let mut y = layout.top;
for (i, card) in cards.iter().enumerate() {
let active = Some(first.saturating_add(i)) == selected;
if active {
frame.rect(text, layout.margin - gutter * 0.3, y - layout.row_h * 0.05,
avail + gutter * 0.6, layout.row_h * 0.94, ACCENT);
}
draw_thumb(frame, text, card, layout.margin, y, thumb_w, thumb_h, if active { 1.0 } else { 0.85 });
let color = if active { FG } else { DIM };
let meta_w = if card.meta.is_empty() {
0.0
} else {
let shown = fit(frame, text, title_px, &card.meta, avail * 0.2);
let mw = frame.measure(text, title_px, &shown);
frame.text(text, layout.margin + avail - mw, y + thumb_h * 0.08, title_px, &shown, color);
mw + gutter
};
let text_w = layout.margin + avail - meta_w - text_x;
let (title, sub) = card_text(frame, text, card, title_px, sub_px, text_w, 2, budget);
let block = (title.len() as f32 * title_px + sub.len() as f32 * sub_px) * LINE;
let mut ty = y + ((thumb_h - block) / 2.0).max(0.0);
for line in &title {
frame.text(text, text_x, ty, title_px, line, color);
ty += title_px * LINE;
}
for line in &sub {
frame.text(text, text_x, ty, sub_px, line, if active { [0.82, 0.86, 0.94, 1.0] } else { DIM });
ty += sub_px * LINE;
}
y += layout.row_h;
}
}
#[allow(clippy::too_many_arguments)]
fn card_text(frame: &Frame, text: &Text, card: &Card, title_px: f32, sub_px: f32, width: f32,
max_title: usize, budget: f32) -> (Vec<String>, Vec<String>) {
let title = wrap_measured(&card.title, width, max_title, |v| frame.measure(text, title_px, v));
let left = budget - title.len() as f32 * title_px * LINE;
let sub_lines = ((left / (sub_px * LINE)) + 1e-3).floor().clamp(0.0, 2.0) as usize;
let sub = wrap_measured(&card.sub, width, sub_lines, |v| frame.measure(text, sub_px, v));
(title, sub)
}
fn draw_grid(frame: &mut Frame, text: &Text, cards: &[Card], first: usize, selected: Option<usize>, g: GridLayout) {
let frame_w = g.gap_x * 0.25;
for (i, card) in cards.iter().enumerate() {
let (col, row) = (i % g.cols, i / g.cols);
if row >= g.rows { break; }
let x = g.list.margin + col as f32 * (g.card_w + g.gap_x);
let y = g.list.top + row as f32 * (g.card_h + g.gap_y);
let active = Some(first.saturating_add(i)) == selected;
if active {
frame.rect(text, x - frame_w, y - frame_w, g.card_w + 2.0 * frame_w, g.thumb_h + 2.0 * frame_w,
[ACCENT[0], ACCENT[1], ACCENT[2], 1.0]);
}
draw_thumb(frame, text, card, x, y, g.card_w, g.thumb_h, if active { 1.0 } else { 0.78 });
draw_badge(frame, text, &card.meta, x + g.card_w, y + g.thumb_h, g.sub_px);
let budget = (3.0 * g.title_px + g.sub_px) * LINE;
let (title, sub) = card_text(frame, text, card, g.title_px, g.sub_px, g.card_w, 3, budget);
let mut ty = y + g.thumb_h + g.title_px * 0.3;
for line in &title {
frame.text(text, x, ty, g.title_px, line, if active { TITLE } else { DIM });
ty += g.title_px * LINE;
}
for line in &sub {
frame.text(text, x, ty, g.sub_px, line, DIM);
ty += g.sub_px * LINE;
}
}
}
pub fn list_capacity(w: f32, h: f32) -> usize {
ListLayout::new(w, h, false, false).capacity()
}
pub fn playback_capacity(w: f32, h: f32) -> usize {
playback_layout(w, h).0.capacity()
}
fn playback_layout(w: f32, h: f32) -> (ListLayout, [f32; 4]) {
let mut layout = ListLayout::new(w, h, false, false);
let legend = if w >= h * 1.25 {
let x = layout.margin + layout.width * 0.46;
let width = layout.width * 0.54;
layout.width *= 0.42;
[x, h * 0.185, width, h * 0.685]
} else {
layout.bottom = h * 0.42;
layout.footer_y = h * 0.425;
[layout.margin, h * 0.48, layout.width, h * 0.49]
};
(layout, legend)
}
fn visible_range(count: usize, selected: usize, capacity: usize) -> std::ops::Range<usize> {
let selected = selected.min(count.saturating_sub(1));
let first = selected.saturating_sub(capacity / 2).min(count.saturating_sub(capacity));
first..first.saturating_add(capacity).min(count)
}
fn range_label(first: usize, visible: usize, count: usize) -> String {
if visible == 0 || first >= count { return format!("0 / {count}"); }
format!("{}–{} / {count}", first + 1, first.saturating_add(visible).min(count))
}
pub fn render_browse(frame: &mut Frame, text: &Text, view: &BrowseView<'_>, w: f32, h: f32) {
frame.clear();
frame.rect(text, 0.0, 0.0, w, h, BG);
let keyboard = view.keyboard.is_some();
let grid = GridLayout::new(w, h, keyboard);
let layout = match view.layout {
Layout::Text => ListLayout::new(w, h, true, keyboard),
Layout::List => thumb_list_layout(w, h, keyboard),
Layout::Grid => grid.list,
};
draw_title(frame, text, view.title, layout);
let (search_y, search_h) = match view.layout {
Layout::Text => (h * 0.16, h * 0.082),
_ => (h * COMPACT_SEARCH_Y, h * COMPACT_SEARCH_H),
};
frame.rect(text, layout.margin, search_y, layout.width, search_h,
if view.searching { [0.10, 0.17, 0.29, 1.0] } else { [0.10, 0.12, 0.17, 1.0] });
if view.search_focused {
frame.rect(text, layout.margin, search_y, h * 0.006, search_h, ACCENT);
}
let query = if view.query.is_empty() && !view.searching {
if view.layout == Layout::Text { "Search files and folders" } else { "Search" }.to_string()
} else {
format!("Search: {}{}", view.query, if view.searching { " |" } else { "" })
};
let query_px = search_h * 0.43;
let shown = fit(frame, text, query_px, &query, layout.width - h * 0.04);
frame.text(text, layout.margin + h * 0.02, search_y + (search_h - query_px) * 0.45, query_px, &shown, FG);
let selected = (!view.search_focused && !keyboard).then_some(view.selected);
let (first, visible) = match view.layout {
Layout::Text => {
let available = view.rows.len().min(view.matches.saturating_sub(view.first));
let range = visible_range(available, view.selected.saturating_sub(view.first), layout.capacity());
draw_rows(frame, text, &view.rows[range.clone()], view.first + range.start, selected, layout);
(view.first + range.start, range.len())
}
Layout::List => {
let cards = &view.cards[..view.cards.len().min(layout.capacity())];
draw_thumb_rows(frame, text, cards, view.first, selected, layout, h);
(view.first, cards.len())
}
Layout::Grid => {
draw_grid(frame, text, view.cards, view.first, selected, grid);
(view.first, view.cards.len().min(grid.cols * grid.rows))
}
};
if view.matches == 0 {
let message = if view.total == 0 { "Empty directory" } else { "No matches" };
let shown = fit(frame, text, layout.item_px, message, layout.width);
frame.text(text, layout.margin, layout.top, layout.item_px, &shown, DIM);
}
if view.layout == Layout::Grid {
let rows = |n: usize| n.div_ceil(grid.cols);
draw_scrollbar(frame, text, first / grid.cols, rows(visible), rows(view.matches), layout);
} else {
draw_scrollbar(frame, text, first, visible, view.matches, layout);
}
let mut count = range_label(first, visible, view.matches);
if !view.query.is_empty() {
count.push_str(&format!(" · {} total", view.total));
}
draw_footer(frame, text, &count, if keyboard {
"Arrows: keys · OK: type · Done: close"
} else if view.search_focused {
"OK: keyboard · Down: results · Back: results"
} else if view.searching {
"Type to filter · Enter: open · Back: clear"
} else {
match view.layout {
Layout::Text => "Search: Up from first item, /, Ctrl-F · Keyboard: Blue/Menu · Page: Left/Right",
Layout::List => "Yellow/F4: wide view · Search: Up from first item · Keyboard: Blue · Page: Left/Right",
Layout::Grid => "Yellow/F4: list view · Arrows: move · Search: Up from top row · Keyboard: Blue · Page: CH−/CH+",
}
}, layout, h);
if let Some(selected) = view.keyboard {
draw_keyboard(frame, text, selected, layout, h);
}
}
pub fn render_status(frame: &mut Frame, text: &Text, label: &str, step: Option<usize>, w: f32, h: f32) {
let px = h * 0.034;
let pad = h * 0.03;
let size = if step.is_some() { h * 0.065 } else { 0.0 };
let gap = if step.is_some() { h * 0.025 } else { 0.0 };
let shown = fit(frame, text, px, label, w * 0.8 - size - gap - 2.0 * pad);
let panel_w = frame.measure(text, px, &shown) + size + gap + 2.0 * pad;
let panel_h = size.max(px * 1.3) + 2.0 * pad;
let (x, y) = ((w - panel_w) / 2.0, (h - panel_h) / 2.0);
frame.rect(text, x, y, panel_w, panel_h, [0.03, 0.04, 0.06, 0.92]);
if let Some(step) = step {
draw_spinner(frame, text, x + pad + size / 2.0, y + panel_h / 2.0, size, step);
}
frame.text(text, x + pad + size + gap, y + (panel_h - px) / 2.0, px, &shown, FG);
}
fn draw_spinner(frame: &mut Frame, text: &Text, cx: f32, cy: f32, size: f32, step: usize) {
let dot = size * 0.19;
let radius = (size - dot) / 2.0;
for i in 0..12 {
let angle = i as f32 * std::f32::consts::TAU / 12.0 - std::f32::consts::FRAC_PI_2;
let age = (step % 12 + 12 - i) % 12; let alpha = 1.0 - age as f32 / 12.0 * 0.85;
let (x, y) = (cx + radius * angle.cos(), cy + radius * angle.sin());
let color = [FG[0], FG[1], FG[2], alpha];
frame.rect(text, x - dot / 2.0, y - dot * 0.3, dot, dot * 0.6, color);
frame.rect(text, x - dot * 0.3, y - dot / 2.0, dot * 0.6, dot * 0.2, color);
frame.rect(text, x - dot * 0.3, y + dot * 0.3, dot * 0.6, dot * 0.2, color);
}
}
pub fn render_overlay(frame: &mut Frame, text: &Text, title: &str, items: &[String], selected: usize, w: f32, h: f32) {
frame.clear();
frame.rect(text, 0.0, 0.0, w, h, [0.0, 0.0, 0.0, 0.55]);
let rows: Vec<Row> = items.iter().map(|s| (s.clone(), String::new())).collect();
let (layout, legend) = playback_layout(w, h);
draw_title(frame, text, title, layout);
let range = visible_range(rows.len(), selected, layout.capacity());
draw_rows(frame, text, &rows[range.clone()], range.start, Some(selected.min(rows.len().saturating_sub(1))), layout);
draw_scrollbar(frame, text, range.start, range.len(), rows.len(), layout);
draw_footer(frame, text, &range_label(range.start, range.len(), rows.len()), "", layout, h);
draw_cec_legend(frame, text, legend);
}
pub fn render_signin(
frame: &mut Frame,
text: &Text,
user_code: &str,
url: &str,
status: &str,
qr: Option<(usize, &[bool])>,
w: f32,
h: f32,
) {
frame.clear();
frame.rect(text, 0.0, 0.0, w, h, BG);
let center = |frame: &mut Frame, y: f32, px: f32, s: &str, color: [f32; 4]| {
let x = (w - frame.measure(text, px, s)) / 2.0;
frame.text(text, x, y, px, s, color);
};
center(frame, h * 0.08, h * 0.055, "Sign in to YouTube", TITLE);
let Some((n, modules)) = qr.filter(|(n, m)| *n > 0 && m.len() >= n * n) else {
return render_signin_text(frame, text, user_code, url, status, w, h, center);
};
center(frame, h * 0.18, h * 0.034, "Scan with your phone to sign in", DIM);
let side = w.min(h) * 0.42;
draw_qr(frame, text, modules, n, (w - side) / 2.0, h * 0.24, side);
let foot = h * 0.24 + side + h * 0.05;
center(frame, foot, h * 0.030, "or open", DIM);
center(frame, foot + h * 0.05, h * 0.040, url, FG);
center(frame, foot + h * 0.11, h * 0.030, &format!("and enter {user_code}"), DIM);
center(frame, h * 0.93, h * 0.028, status, DIM);
center(frame, h * 0.97, h * 0.022, "Back cancels", DIM);
}
pub fn render_signin_setup(frame: &mut Frame, text: &Text, w: f32, h: f32) {
frame.clear();
frame.rect(text, 0.0, 0.0, w, h, BG);
let center = |frame: &mut Frame, y: f32, px: f32, s: &str, color: [f32; 4]| {
let x = (w - frame.measure(text, px, s)) / 2.0;
frame.text(text, x, y, px, s, color);
};
center(frame, h * 0.09, h * 0.055, "Sign in to YouTube", TITLE);
center(frame, h * 0.18, h * 0.032, "One-time setup (~5 min); then every sign-in is just a QR scan.", DIM);
let steps = [
"1. console.cloud.google.com — create a project",
"2. APIs & Services → Library → enable \"YouTube Data API v3\"",
"3. OAuth consent screen → External; add your account as a Test user",
"4. Credentials → OAuth client ID → \"TVs and Limited Input devices\"",
"5. Save the client ID and secret to ~/medias/youtube_oauth.json :",
" { \"client_id\": \"...\", \"client_secret\": \"...\" }",
"6. Restart medias and choose Sign in again.",
];
let left = w * 0.14;
let px = h * 0.034;
let mut y = h * 0.28;
for step in steps {
let shown = fit(frame, text, px, step, w - 2.0 * left);
frame.text(text, left, y, px, &shown, FG);
y += h * 0.075;
}
center(frame, h * 0.93, h * 0.024, "Back cancels", DIM);
}
fn draw_qr(frame: &mut Frame, text: &Text, modules: &[bool], n: usize, x: f32, y: f32, side: f32) {
frame.rect(text, x, y, side, side, [1.0, 1.0, 1.0, 1.0]);
let quiet = 4.0;
let module = side / (n as f32 + 2.0 * quiet);
let origin = quiet * module;
for row in 0..n {
for col in 0..n {
if modules[row * n + col] {
frame.rect(text, x + origin + col as f32 * module, y + origin + row as f32 * module,
module, module, [0.0, 0.0, 0.0, 1.0]);
}
}
}
}
fn render_signin_text(
frame: &mut Frame,
text: &Text,
user_code: &str,
url: &str,
status: &str,
w: f32,
h: f32,
center: impl Fn(&mut Frame, f32, f32, &str, [f32; 4]),
) {
center(frame, h * 0.30, h * 0.032, "On another device, open", DIM);
center(frame, h * 0.36, h * 0.050, url, FG);
center(frame, h * 0.50, h * 0.032, "and enter this code:", DIM);
let code_px = h * 0.10;
let code_w = frame.measure(text, code_px, user_code);
let (box_w, box_h) = (code_w + h * 0.08, code_px * 1.4);
frame.rect(text, (w - box_w) / 2.0, h * 0.56, box_w, box_h, ACCENT);
center(frame, h * 0.56 + (box_h - code_px) / 2.0, code_px, user_code, TITLE);
center(frame, h * 0.82, h * 0.030, status, DIM);
center(frame, h * 0.90, h * 0.024, "Back cancels", DIM);
}
const RED: [f32; 4] = [1.0, 0.36, 0.36, 1.0];
const GREEN: [f32; 4] = [0.42, 0.92, 0.48, 1.0];
const BLUE: [f32; 4] = [0.42, 0.67, 1.0, 1.0];
const YELLOW: [f32; 4] = [1.0, 0.87, 0.34, 1.0];
const CEC_MENU_BUTTONS: &[(&str, &str, [f32; 4])] = &[
("Up / Down", "Move selection", FG),
("OK", "Select", FG),
("Back", "Previous menu / resume", FG),
("Play / Pause", "Resume playback", FG),
("Blue", "Resume playback", BLUE),
("Stop", "Stop and browse", FG),
("REW / FF · CH− / CH+", "Previous / next page", FG),
];
const CEC_PLAYBACK_BUTTONS: &[(&str, &str, [f32; 4])] = &[
("Play", "Play / pause", FG),
("OK / Pause", "Playback menu", FG),
("Blue", "Playback menu", BLUE),
("Back", "Browse library (pause)", FG),
("Stop", "Stop and browse", FG),
("Left / Right", "Seek −10 / +10 s", FG),
("REW / FF · CH− / CH+", "Seek −60 / +60 s", FG),
("Up / Down", "Volume + / −", FG),
("Red", "Cycle subtitles", RED),
("Green / Audio", "Cycle audio", GREEN),
("Yellow", "Playback info", YELLOW),
];
fn draw_cec_legend(frame: &mut Frame, text: &Text, rect: [f32; 4]) {
let [x, y, w, h] = rect;
if w <= 0.0 || h <= 0.0 { return; }
frame.rect(text, x, y, w, h, [0.025, 0.035, 0.055, 0.94]);
let pad = h * 0.03;
let width = (w - 2.0 * pad).max(0.0);
let line = (h - 2.0 * pad) / (CEC_MENU_BUTTONS.len() + CEC_PLAYBACK_BUTTONS.len() + 4) as f32;
let px = line * 0.70;
let key_width = width * 0.48;
let gap = width * 0.04;
let mut y = y + pad;
for (heading, buttons) in [
("CEC · In this menu", CEC_MENU_BUTTONS),
("CEC · During playback", CEC_PLAYBACK_BUTTONS),
] {
let shown = fit(frame, text, line * 0.90, heading, width);
frame.text(text, x + pad, y, line * 0.90, &shown, TITLE);
y += line * 1.5;
for &(button, action, color) in buttons {
let button = fit(frame, text, px, button, key_width);
let action = fit(frame, text, px, action, width - key_width - gap);
frame.text(text, x + pad, y, px, &button, color);
frame.text(text, x + pad + key_width + gap, y, px, &action, FG);
y += line;
}
y += line * 0.5;
}
}
pub fn render_list(frame: &mut Frame, text: &Text, title: &str, rows: &[Row], selected: usize, w: f32, h: f32) {
let layout = ListLayout::new(w, h, false, false);
draw_title(frame, text, title, layout);
let range = visible_range(rows.len(), selected, layout.capacity());
draw_rows(frame, text, &rows[range.clone()], range.start, Some(selected.min(rows.len().saturating_sub(1))), layout);
draw_scrollbar(frame, text, range.start, range.len(), rows.len(), layout);
if rows.is_empty() {
frame.text(text, layout.margin, layout.top, layout.item_px, "No items", DIM);
}
draw_footer(frame, text, &range_label(range.start, range.len(), rows.len()),
"Arrows: move · OK: select · PgUp/PgDn: page · Home/End · Back: close", layout, h);
}
fn draw_title(frame: &mut Frame, text: &Text, title: &str, layout: ListLayout) {
let shown = fit(frame, text, layout.title_px, title, layout.width);
frame.text(text, layout.margin, layout.title_y, layout.title_px, &shown, TITLE);
}
fn draw_rows(frame: &mut Frame, text: &Text, rows: &[Row], first: usize, selected: Option<usize>, layout: ListLayout) {
let mut y = layout.top;
let gutter = layout.width * 0.025;
let avail = (layout.width - gutter).max(0.0);
for (i, (label, meta)) in rows.iter().enumerate() {
let active = Some(first.saturating_add(i)) == selected;
if active {
frame.rect(text, layout.margin - gutter * 0.3, y - layout.row_h * 0.08,
avail + gutter * 0.6, layout.row_h * 0.94, ACCENT);
}
let color = if active { FG } else { DIM };
let meta_w = if meta.is_empty() {
0.0
} else {
let shown = fit(frame, text, layout.item_px, meta, avail * 0.36);
let mw = frame.measure(text, layout.item_px, &shown);
frame.text(text, layout.margin + avail - mw, y, layout.item_px, &shown, color);
mw + gutter
};
let shown = fit(frame, text, layout.item_px, label, avail - meta_w);
frame.text(text, layout.margin, y, layout.item_px, &shown, color);
y += layout.row_h;
}
}
fn draw_scrollbar(frame: &mut Frame, text: &Text, first: usize, visible: usize, count: usize, layout: ListLayout) {
if visible == 0 || count <= visible { return; }
let track = layout.bottom - layout.top;
let thumb = (track * visible as f32 / count as f32).max(layout.row_h * 0.25).min(track);
let progress = first.min(count - visible) as f32 / (count - visible) as f32;
let y = layout.top + (track - thumb) * progress;
let width = layout.width * 0.005;
let x = layout.margin + layout.width - width;
frame.rect(text, x, layout.top, width, track, [0.24, 0.27, 0.33, 1.0]);
frame.rect(text, x, y, width, thumb, FG);
}
fn draw_footer(frame: &mut Frame, text: &Text, count: &str, hint: &str, layout: ListLayout, h: f32) {
for (y, label) in [(layout.footer_y, count), (layout.hint_y, hint)] {
let shown = fit(frame, text, h * 0.024, label, layout.width);
frame.text(text, layout.margin, y, h * 0.024, &shown, DIM);
}
}
fn draw_keyboard(frame: &mut Frame, text: &Text, selected: usize, layout: ListLayout, h: f32) {
use crate::browser::{KEYBOARD_COLS, KEYBOARD_KEYS};
let rows = KEYBOARD_KEYS.len().div_ceil(KEYBOARD_COLS);
let top = h * 0.59;
let cell_w = layout.width / KEYBOARD_COLS as f32;
let cell_h = h * 0.28 / rows as f32;
let px = (h * 0.026).min(cell_w * 0.22);
for (i, key) in KEYBOARD_KEYS.iter().enumerate() {
let x = layout.margin + (i % KEYBOARD_COLS) as f32 * cell_w;
let y = top + (i / KEYBOARD_COLS) as f32 * cell_h;
frame.rect(text, x, y, cell_w * 0.94, cell_h * 0.88,
if i == selected { ACCENT } else { [0.13, 0.15, 0.21, 1.0] });
let shown = fit(frame, text, px, key, cell_w * 0.86);
let width = frame.measure(text, px, &shown);
frame.text(text, x + (cell_w * 0.94 - width) / 2.0, y + (cell_h * 0.88 - px) / 2.0,
px, &shown, if i == selected { TITLE } else { FG });
}
}
fn fit(frame: &Frame, text: &Text, px: f32, s: &str, max_w: f32) -> String {
fit_measured(s, max_w, |value| frame.measure(text, px, value))
}
fn wrap_measured(s: &str, max_w: f32, max_lines: usize, measure: impl Fn(&str) -> f32) -> Vec<String> {
let mut lines = Vec::new();
if max_w <= 0.0 {
return lines;
}
let mut rest = s.trim();
while !rest.is_empty() && lines.len() < max_lines {
if lines.len() + 1 == max_lines || measure(rest) <= max_w {
lines.push(fit_measured(rest, max_w, &measure));
break;
}
let fits = |end: usize| measure(rest[..end].trim_end()) <= max_w;
let word_end = rest
.char_indices()
.filter(|&(i, c)| i > 0 && c.is_whitespace())
.map(|(i, _)| i)
.take_while(|&i| fits(i))
.last();
let end = word_end.unwrap_or_else(|| {
let first = rest.chars().next().map_or(rest.len(), char::len_utf8);
rest.char_indices().map(|(i, c)| i + c.len_utf8()).take_while(|&i| fits(i)).last().unwrap_or(first)
});
lines.push(rest[..end].trim_end().to_string());
rest = rest[end..].trim_start();
}
lines
}
fn fit_measured(s: &str, max_w: f32, measure: impl Fn(&str) -> f32) -> String {
if max_w <= 0.0 { return String::new(); }
if measure(s) <= max_w {
return s.to_string();
}
let suffix = ["...", "..", ".", ""].into_iter().find(|s| measure(s) <= max_w).unwrap_or("");
let mut t: String = s.to_string();
while !t.is_empty() && measure(&format!("{}{suffix}", t.trim_end())) > max_w {
t.pop();
}
format!("{}{suffix}", t.trim_end())
}
#[allow(clippy::too_many_arguments)]
pub fn render_blank(frame: &mut Frame, text: &Text, w: f32, h: f32) {
frame.clear();
frame.rect(text, 0.0, 0.0, w, h, [0.0, 0.0, 0.0, 1.0]);
}
pub fn render_osd(
frame: &mut Frame,
text: &Text,
title: &str,
pos: f64,
dur: f64,
vol: f64,
show_bar: bool,
show_vol: bool,
toast: Option<&str>,
w: f32,
h: f32,
) {
frame.clear();
if let Some(msg) = toast {
let (tw, th) = (w * 0.44, h * 0.08);
let (tx, ty) = ((w - tw) / 2.0, h * 0.16);
frame.rect(text, tx, ty, tw, th, [0.04, 0.05, 0.08, 0.80]);
let shown = fit(frame, text, h * 0.036, msg, tw * 0.88);
frame.text(text, tx + tw * 0.06, ty + th * 0.26, h * 0.036, &shown, FG);
}
if show_vol {
let (vw, vh) = (w * 0.30, h * 0.09);
let (vx, vy) = ((w - vw) / 2.0, h * 0.06);
frame.rect(text, vx, vy, vw, vh, [0.04, 0.05, 0.08, 0.80]);
frame.text(text, vx + vw * 0.08, vy + vh * 0.20, h * 0.035, &format!("Volume {vol:.0}%"), FG);
let (bx, bw) = (vx + vw * 0.08, vw * 0.84);
frame.rect(text, bx, vy + vh * 0.72, bw, 5.0, [0.30, 0.32, 0.38, 1.0]);
frame.rect(text, bx, vy + vh * 0.72, bw * (vol / 100.0).clamp(0.0, 1.0) as f32, 5.0, ACCENT);
}
if show_bar {
draw_bar(frame, text, title, pos, dur, w, h);
}
}
fn draw_bar(frame: &mut Frame, text: &Text, title: &str, pos: f64, dur: f64, w: f32, h: f32) {
let bar_h = h * 0.12;
let y = h - bar_h;
frame.rect(text, 0.0, y, w, bar_h, [0.04, 0.05, 0.08, 0.72]);
let pad = w * 0.04;
let shown = fit(frame, text, h * 0.040, title, (w - 2.0 * pad).max(0.0));
frame.text(text, pad, y + bar_h * 0.18, h * 0.040, &shown, FG);
frame.text(text, pad, y + bar_h * 0.58, h * 0.030, &format!("{} / {}", clock(pos), clock(dur)), DIM);
if dur > 0.0 {
let frac = (pos / dur).clamp(0.0, 1.0) as f32;
frame.rect(text, pad, y + bar_h * 0.90, (w - 2.0 * pad) * frac, 5.0, ACCENT);
}
}
pub fn render_seek(frame: &mut Frame, text: &Text, title: &str, pos: f64, dur: f64, items: &[String], selected: usize, w: f32, h: f32) {
frame.clear();
frame.rect(text, 0.0, 0.0, w, h, [0.0, 0.0, 0.0, 0.40]);
let item_px = h * 0.05;
let gap = w * 0.035;
let widths: Vec<f32> = items.iter().map(|s| frame.measure(text, item_px, s)).collect();
let total: f32 = widths.iter().sum::<f32>() + gap * items.len().saturating_sub(1) as f32;
let mut x = (w - total) / 2.0;
let y = h * 0.40;
for (i, s) in items.iter().enumerate() {
if i == selected {
frame.rect(text, x - gap * 0.35, y - item_px * 0.35, widths[i] + gap * 0.7, item_px * 1.6, ACCENT);
}
frame.text(text, x, y, item_px, s, if i == selected { FG } else { DIM });
x += widths[i] + gap;
}
draw_bar(frame, text, title, pos, dur, w, h);
}
fn clock(secs: f64) -> String {
let s = secs.max(0.0) as u64;
format!("{}:{:02}", s / 60, s % 60)
}
#[cfg(test)]
mod tests {
use super::*;
fn menu() -> Menu {
Menu::new("Home", &["Movies", "Series", "Music", "YouTube"])
}
#[test]
fn down_wraps() {
let mut m = menu();
assert_eq!(m.handle(Key::Down), Action::None);
assert_eq!(m.selected, 1);
m.selected = 3;
m.handle(Key::Down);
assert_eq!(m.selected, 0); }
#[test]
fn up_wraps() {
let mut m = menu();
m.handle(Key::Up);
assert_eq!(m.selected, 3); }
#[test]
fn select_and_back() {
let mut m = menu();
m.selected = 2;
assert_eq!(m.handle(Key::Select), Action::Open(2));
assert_eq!(m.handle(Key::Back), Action::Exit);
}
#[test]
fn ignored_keys() {
let mut m = menu();
assert_eq!(m.handle(Key::Play), Action::None);
assert_eq!(m.selected, 0);
}
#[test]
fn pages_and_endpoints_clamp_to_existing_items() {
let mut m = Menu::new("Long list", &["item"; 25]);
m.handle(Key::PageDown);
assert_eq!(m.selected, 8);
m.handle(Key::End);
assert_eq!(m.selected, 24);
m.handle(Key::PageDown);
assert_eq!(m.selected, 24);
m.handle(Key::PageUp);
assert_eq!(m.selected, 16);
m.handle(Key::Home);
m.handle(Key::PageUp);
assert_eq!(m.selected, 0);
m.items.clear();
m.handle(Key::End);
assert_eq!(m.handle(Key::Select), Action::None);
}
#[test]
fn every_list_window_is_bounded_and_contains_the_selection() {
for count in [0usize, 1, 9, 10, 11, 1000] {
for capacity in 1..=12 {
for selected in [0, count / 2, count.saturating_sub(1), usize::MAX] {
let range = visible_range(count, selected, capacity);
assert!(range.len() <= capacity);
assert!(range.end <= count);
if count > 0 { assert!(range.contains(&selected.min(count - 1))); }
}
}
}
assert!(visible_range(10, 4, 0).is_empty());
}
#[test]
fn layouts_reserve_title_search_footer_and_keyboard() {
for (w, h) in [(1920.0, 1080.0), (1280.0, 720.0), (720.0, 1280.0), (320.0, 240.0)] {
let normal = ListLayout::new(w, h, true, false);
let keyboard = ListLayout::new(w, h, true, true);
assert!(normal.capacity() > keyboard.capacity());
assert_eq!(browse_shape(w, h, false, Layout::Text), (1, normal.capacity()));
assert_eq!(browse_shape(w, h, true, Layout::Text), (1, keyboard.capacity()));
for layout in [normal, keyboard, ListLayout::new(w, h, false, false)] {
assert!(layout.top + layout.capacity() as f32 * layout.row_h <= layout.bottom);
assert!(layout.title_y + layout.title_px < layout.top);
assert!(layout.bottom < layout.footer_y);
assert!(layout.hint_y + h * 0.024 < h);
assert!(layout.width + 2.0 * layout.margin <= w + 0.001);
}
assert!(keyboard.bottom < h * 0.59);
assert!(h * (0.59 + 0.28) < keyboard.footer_y);
}
for layout in [Layout::Text, Layout::List, Layout::Grid] {
assert_eq!(browse_shape(0.0, 720.0, false, layout).1, 0);
assert_eq!(browse_shape(1280.0, 0.0, false, layout).1, 0);
}
}
#[test]
fn thumbnail_layouts_fit_between_search_and_footer() {
assert_eq!(browse_shape(1920.0, 1080.0, false, Layout::Grid), (5, 10));
assert_eq!(browse_shape(3840.0, 2160.0, false, Layout::Grid), (5, 10));
assert_eq!(browse_shape(1920.0, 1080.0, true, Layout::Grid), (5, 5));
assert_eq!(browse_shape(1920.0, 1080.0, false, Layout::List), (1, 5));
assert_eq!(browse_shape(720.0, 1280.0, false, Layout::Grid).0, 2);
for (w, h) in [(1920.0, 1080.0), (1280.0, 720.0), (720.0, 1280.0), (1024.0, 768.0)] {
for keyboard in [false, true] {
let g = GridLayout::new(w, h, keyboard);
assert!(h * (COMPACT_SEARCH_Y + COMPACT_SEARCH_H) < g.list.top);
let used = g.cols as f32 * g.card_w + (g.cols - 1) as f32 * g.gap_x;
assert!(used <= g.list.width + 0.001);
let last = g.list.top + g.rows as f32 * g.card_h + (g.rows - 1) as f32 * g.gap_y;
assert!(g.rows == 1 || last <= g.list.bottom, "{w}x{h} kb={keyboard}");
assert!(g.list.bottom < g.list.footer_y);
let list = thumb_list_layout(w, h, keyboard);
assert!(list.top + list.capacity() as f32 * list.row_h <= list.bottom);
}
}
}
#[test]
fn visible_range_labels_include_absolute_positions_and_count() {
assert_eq!(range_label(100, 8, 1000), "101–108 / 1000");
assert_eq!(range_label(998, 8, 1000), "999–1000 / 1000");
assert_eq!(range_label(0, 0, 0), "0 / 0");
}
#[test]
fn truncation_preserves_unicode_and_never_exceeds_available_width() {
let measure = |s: &str| s.chars().count() as f32;
assert_eq!(fit_measured("short", 5.0, measure), "short");
assert_eq!(fit_measured("日本語の長い名前", 6.0, measure), "日本語...");
assert_eq!(fit_measured("long", 2.0, measure), "..");
assert_eq!(fit_measured("long", 0.5, measure), "");
assert_eq!(fit_measured("long", -1.0, measure), "");
}
#[test]
fn wrap_breaks_at_words_and_cuts_the_last_line() {
let measure = |s: &str| s.chars().count() as f32;
assert_eq!(wrap_measured("one two three", 20.0, 3, measure), ["one two three"]);
assert_eq!(wrap_measured("one two three four", 9.0, 3, measure), ["one two", "three", "four"]);
assert_eq!(wrap_measured("one two three four five", 9.0, 2, measure), ["one two", "three..."]);
assert_eq!(wrap_measured("abcdefghijk xy", 4.0, 4, measure), ["abcd", "efgh", "ijk", "xy"]);
assert_eq!(wrap_measured("日本語の長い名前です", 4.0, 2, measure), ["日本語の", "長..."]);
assert!(wrap_measured("text", 5.0, 0, measure).is_empty());
assert!(wrap_measured(" ", 5.0, 2, measure).is_empty());
}
}