//! The console's focusable widgets: the vertical menu list (settings / add-host / pair
//! screens) and the controller-driven on-screen keyboard — ports of the Swift
//! `GamepadMenuList` / `GamepadKeyboard`, immediate-mode: the WIDGET owns cursor,
//! springs and scroll, the SCREEN owns the domain (row content and what an activation
//! means), and every frame the screen hands the widget fresh row specs.

use crate::anim::{approach, Spring, TRAY_C, TRAY_K};
use crate::library::{BUMP_C, BUMP_K};
use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, FAINT, W, WHITE};
use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse};
use skia_safe::{Canvas, Paint, Path, RRect, Rect};

// --- Menu list -----------------------------------------------------------------------------

/// What a menu-list consumed event means for the owning screen.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ListMsg {
    None,
    /// Left/right on the focused row (the screen returns whether the value changed).
    Adjust(i32),
    /// A on the focused row.
    Activate,
}

/// One row's look, rebuilt by the screen each frame (never stale).
pub(crate) struct RowSpec {
    /// Section header drawn above this row (the first row of each group carries it).
    pub header: Option<&'static str>,
    pub label: String,
    /// `None` = an action row (its label draws centered, brand-tinted).
    pub value: Option<String>,
    /// Placeholder styling for an empty field's value.
    pub value_dim: bool,
    /// The live-edit caret after the value (this row is what the keyboard types into).
    pub caret: bool,
    /// Show ‹ › chevrons while focused (left/right steps the value).
    pub adjustable: bool,
    /// Action rows render dimmed when not yet actionable.
    pub enabled: bool,
}

impl RowSpec {
    pub(crate) fn field(label: impl Into<String>, value: String, placeholder: &str) -> RowSpec {
        let empty = value.is_empty();
        RowSpec {
            header: None,
            label: label.into(),
            value: Some(if empty {
                placeholder.to_string()
            } else {
                value
            }),
            value_dim: empty,
            caret: false,
            adjustable: false,
            enabled: true,
        }
    }

    pub(crate) fn action(label: impl Into<String>, enabled: bool) -> RowSpec {
        RowSpec {
            header: None,
            label: label.into(),
            value: None,
            value_dim: false,
            caret: false,
            adjustable: false,
            enabled,
        }
    }
}

pub(crate) const ROW_H: f64 = 50.0;
const ROW_GAP: f64 = 6.0;
const HEADER_H: f64 = 34.0;
pub(crate) const ROW_MAX_W: f64 = 620.0;

/// The focus list: authoritative cursor, spring recoil at the ends, a scroll offset
/// that chases the focused row, and a per-row focus amount for the scale/tint ease.
pub(crate) struct MenuList {
    pub cursor: usize,
    bump: Spring,
    scroll: f64,
    focus: Vec<f64>,
}

impl MenuList {
    pub(crate) fn new() -> MenuList {
        MenuList {
            cursor: 0,
            bump: Spring::rest(0.0),
            scroll: 0.0,
            focus: Vec::new(),
        }
    }

    /// Route a menu event. Up/down move focus (Boundary = recoil), left/right become
    /// [`ListMsg::Adjust`], A becomes [`ListMsg::Activate`]. B is the SCREEN's.
    pub(crate) fn menu(&mut self, ev: MenuEvent, len: usize) -> (ListMsg, Option<MenuPulse>) {
        match ev {
            MenuEvent::Move(MenuDir::Up) => (ListMsg::None, self.step(-1, len)),
            MenuEvent::Move(MenuDir::Down) => (ListMsg::None, self.step(1, len)),
            MenuEvent::Move(MenuDir::Left) => (ListMsg::Adjust(-1), None),
            MenuEvent::Move(MenuDir::Right) => (ListMsg::Adjust(1), None),
            MenuEvent::Confirm => (ListMsg::Activate, Some(MenuPulse::Confirm)),
            _ => (ListMsg::None, None),
        }
    }

    fn step(&mut self, delta: i32, len: usize) -> Option<MenuPulse> {
        let target = self.cursor as i32 + delta;
        if len == 0 || target < 0 || target >= len as i32 {
            // Refused at an end: the dull thud plus a short vertical recoil.
            self.bump = Spring {
                pos: -14.0 * f64::from(delta.signum()),
                vel: 0.0,
            };
            return Some(MenuPulse::Boundary);
        }
        self.cursor = target as usize;
        Some(MenuPulse::Move)
    }

    /// Render the rows into `rect`. `active` = this list owns focus (a keyboard tray on
    /// top parks it — rows keep their look but the focus ring rests).
    #[allow(clippy::too_many_arguments)]
    pub(crate) fn render(
        &mut self,
        canvas: &Canvas,
        rect: Rect,
        rows: &[RowSpec],
        fonts: &Fonts,
        k: f64,
        dt: f64,
        active: bool,
    ) {
        self.focus.resize(rows.len(), 0.0);
        for (i, f) in self.focus.iter_mut().enumerate() {
            let target = if active && i == self.cursor { 1.0 } else { 0.0 };
            *f = approach(*f, target, dt, 0.06);
        }
        self.bump.step(0.0, BUMP_K, BUMP_C, dt);
        self.bump.settle(0.0, 0.3, 4.0);

        // Row tops (design units) incl. headers, so scroll math and drawing agree.
        let mut tops = Vec::with_capacity(rows.len());
        let mut y = 0.0;
        for row in rows {
            if row.header.is_some() {
                y += HEADER_H;
            }
            tops.push(y);
            y += ROW_H + ROW_GAP;
        }
        let content_h = (y - ROW_GAP).max(0.0) * k;
        let view_h = f64::from(rect.height());

        // The scroll chases the focused row into the middle band, clamped to content.
        let focused_center = tops.get(self.cursor).map_or(0.0, |t| (t + ROW_H / 2.0) * k);
        let target = (focused_center - view_h / 2.0).clamp(0.0, (content_h - view_h).max(0.0));
        self.scroll = approach(self.scroll, target, dt, 0.08);

        let row_w = (ROW_MAX_W * k).min(f64::from(rect.width()) - 48.0 * k);
        let x0 = f64::from(rect.left) + (f64::from(rect.width()) - row_w) / 2.0;

        canvas.save();
        canvas.clip_rect(rect, None, true);
        for (i, row) in rows.iter().enumerate() {
            let f = self.focus[i];
            let top = f64::from(rect.top) + tops[i] * k - self.scroll + self.bump.pos * k;
            if top + ROW_H * k < f64::from(rect.top) - 8.0 * k
                || top > f64::from(rect.bottom) + 8.0 * k
            {
                continue;
            }
            if let Some(header) = row.header {
                fonts.draw_tracked(
                    canvas,
                    &header.to_uppercase(),
                    x0 + 16.0 * k,
                    top - 12.0 * k,
                    W::SemiBold,
                    12.0 * k,
                    1.4 * k,
                    white(0.45),
                );
            }
            // Focus scale eases 0.98 → 1.0 about the row center.
            let scale = 0.98 + 0.02 * f;
            let (cx, cy) = (x0 + row_w / 2.0, top + ROW_H * k / 2.0);
            canvas.save();
            canvas.translate((cx as f32, cy as f32));
            canvas.scale((scale as f32, scale as f32));
            canvas.translate((-cx as f32, -cy as f32));
            let r = Rect::from_xywh(x0 as f32, top as f32, row_w as f32, (ROW_H * k) as f32);
            let stroke = if row.caret {
                PanelStroke::Brand(0.7)
            } else {
                PanelStroke::Plain(0.06 + 0.22 * f as f32)
            };
            let tint = if row.caret {
                Some(brand(0.30))
            } else if f > 0.01 {
                Some(brand(0.30 * f as f32))
            } else {
                None
            };
            crate::theme::panel(canvas, r, 14.0, tint, stroke, k as f32);

            let baseline = cy + 16.0 * k * 0.36;
            if row.value.is_none() {
                // Action row: centered label, brand when actionable.
                let color = if row.enabled { BRAND } else { FAINT };
                let tw = fonts.measure(&row.label, W::SemiBold, 16.0 * k) as f64;
                fonts.draw(
                    canvas,
                    &row.label,
                    cx - tw / 2.0,
                    baseline,
                    W::SemiBold,
                    16.0 * k,
                    color,
                );
            } else {
                fonts.draw(
                    canvas,
                    &row.label,
                    x0 + 16.0 * k,
                    baseline,
                    W::SemiBold,
                    16.0 * k,
                    WHITE,
                );
                let value = row.value.as_deref().unwrap_or_default();
                let vcolor = if row.value_dim {
                    FAINT
                } else if f > 0.5 {
                    WHITE
                } else {
                    white(0.6 + 0.4 * f as f32)
                };
                let chevron_w = if row.adjustable { 18.0 * k } else { 0.0 };
                let caret_w = if row.caret { 8.0 * k } else { 0.0 };
                let vmax = row_w * 0.55;
                let vw = (fonts.measure(value, W::Medium, 15.0 * k) as f64).min(vmax);
                let vx = x0 + row_w - 16.0 * k - chevron_w - caret_w - vw;
                // Head-truncate: keep the END of a long address visible while typing.
                let shown = truncate_head(fonts, value, W::Medium, 15.0 * k, vmax);
                fonts.draw(canvas, &shown, vx, baseline, W::Medium, 15.0 * k, vcolor);
                if row.caret {
                    canvas.draw_rect(
                        Rect::from_xywh(
                            (vx + vw + 3.0 * k) as f32,
                            (cy - 9.0 * k) as f32,
                            (2.0 * k) as f32,
                            (18.0 * k) as f32,
                        ),
                        &Paint::new(BRAND, None),
                    );
                }
                if row.adjustable && f > 0.01 {
                    let alpha = 0.6 * f as f32;
                    chevron(canvas, vx - 11.0 * k, cy, 4.0 * k, true, alpha);
                    chevron(canvas, x0 + row_w - 16.0 * k, cy, 4.0 * k, false, alpha);
                }
            }
            canvas.restore();
        }
        canvas.restore();
    }
}

/// Middle-of-nowhere helper: drop chars from the FRONT until the tail fits.
fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> String {
    if f64::from(fonts.measure(text, w, size)) <= max_w {
        return text.to_string();
    }
    let mut s: Vec<char> = text.chars().collect();
    while s.len() > 1 {
        s.remove(0);
        let candidate: String = std::iter::once('…').chain(s.iter().copied()).collect();
        if f64::from(fonts.measure(&candidate, w, size)) <= max_w {
            return candidate;
        }
    }
    "…".into()
}

fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) {
    let dir = if left { -1.0 } else { 1.0 };
    let mut p = Paint::new(white(alpha), None);
    p.set_style(skia_safe::PaintStyle::Stroke);
    p.set_stroke_width((1.8 * r / 4.0) as f32);
    p.set_stroke_cap(skia_safe::PaintCap::Round);
    p.set_anti_alias(true);
    let mut path = Path::new();
    path.move_to(((x - dir * r / 2.0) as f32, (cy - r) as f32));
    path.line_to(((x + dir * r / 2.0) as f32, cy as f32));
    path.line_to(((x - dir * r / 2.0) as f32, (cy + r) as f32));
    canvas.draw_path(&path, &p);
}

// --- On-screen keyboard ----------------------------------------------------------------------

/// What the keyboard may type per field (backspace always works).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Charset {
    Free,
    /// Addresses/hostnames — everything but whitespace.
    Hostname,
    Digits,
}

pub(crate) fn permits(charset: Charset, ch: char) -> bool {
    match charset {
        Charset::Free => true,
        Charset::Hostname => !ch.is_whitespace(),
        Charset::Digits => ch.is_ascii_digit(),
    }
}

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum KeyMsg {
    None,
    Type(char),
    Backspace,
    Done,
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum Key {
    Char(char),
    Space,
    Backspace,
    Done,
}

/// Digits first (addresses/ports), then letters; the last char column carries the
/// hostname/address punctuation — the Swift grid, verbatim.
fn key_rows() -> &'static [Vec<Key>] {
    use std::sync::OnceLock;
    static ROWS: OnceLock<Vec<Vec<Key>>> = OnceLock::new();
    ROWS.get_or_init(|| {
        let chars = |s: &str| s.chars().map(Key::Char).collect::<Vec<_>>();
        vec![
            chars("1234567890"),
            chars("qwertyuiop"),
            chars("asdfghjkl-"),
            chars("zxcvbnm._:"),
            vec![Key::Space, Key::Backspace, Key::Done],
        ]
    })
}

/// The controller keyboard: a fixed key grid in a bottom tray. Dpad/stick moves the key
/// cursor, A types, X backspaces, B/Y/Done confirms. Edits apply live — closing IS done.
pub(crate) struct Keyboard {
    row: usize,
    col: usize,
    /// Tray slide-in (0 hidden → 1 seated), the Swift `.spring(0.32, 0.86)`.
    tray: Spring,
    key_flash: f64,
}

impl Keyboard {
    pub(crate) fn new() -> Keyboard {
        Keyboard {
            row: 1, // opens on "q"
            col: 0,
            tray: Spring::rest(0.0),
            key_flash: 0.0,
        }
    }

    /// Route a menu event; the SCREEN applies `Type`/`Backspace` to its field (charset
    /// checks included — a refusal comes back as a boundary pulse from the screen).
    pub(crate) fn menu(&mut self, ev: MenuEvent) -> (KeyMsg, Option<MenuPulse>) {
        let rows = key_rows();
        match ev {
            MenuEvent::Move(dir) => {
                let (mut row, mut col) = (self.row as i32, self.col as i32);
                match dir {
                    MenuDir::Left => col -= 1,
                    MenuDir::Right => col += 1,
                    MenuDir::Up | MenuDir::Down => {
                        let next = row + if dir == MenuDir::Down { 1 } else { -1 };
                        if next < 0 || next >= rows.len() as i32 {
                            return (KeyMsg::None, Some(MenuPulse::Boundary));
                        }
                        // Map the column proportionally between rows of different
                        // widths (Done goes up to the rightmost letters, not "e").
                        let from = (rows[row as usize].len() - 1).max(1) as f64;
                        let to = (rows[next as usize].len() - 1) as f64;
                        col = (col as f64 * to / from).round() as i32;
                        row = next;
                    }
                }
                if row < 0
                    || row >= rows.len() as i32
                    || col < 0
                    || col >= rows[row as usize].len() as i32
                {
                    return (KeyMsg::None, Some(MenuPulse::Boundary));
                }
                self.row = row as usize;
                self.col = col as usize;
                (KeyMsg::None, Some(MenuPulse::Move))
            }
            MenuEvent::Confirm => {
                self.key_flash = 1.0;
                match rows[self.row][self.col] {
                    Key::Char(c) => (KeyMsg::Type(c), None),
                    Key::Space => (KeyMsg::Type(' '), None),
                    Key::Backspace => (KeyMsg::Backspace, None),
                    Key::Done => (KeyMsg::Done, Some(MenuPulse::Confirm)),
                }
            }
            MenuEvent::Tertiary => (KeyMsg::Backspace, None),
            MenuEvent::Secondary | MenuEvent::Back => (KeyMsg::Done, Some(MenuPulse::Confirm)),
            _ => (KeyMsg::None, None),
        }
    }

    /// Advance the tray spring toward shown/hidden. Returns the current seat 0..1 —
    /// at exactly 0 while hidden, so the caller can skip rendering.
    pub(crate) fn seat(&mut self, shown: bool, dt: f64) -> f64 {
        self.tray
            .step(if shown { 1.0 } else { 0.0 }, TRAY_K, TRAY_C, dt);
        self.tray.settle(if shown { 1.0 } else { 0.0 }, 0.001, 0.01);
        self.key_flash = approach(self.key_flash, 0.0, dt, 0.10);
        self.tray.pos.clamp(0.0, 1.2)
    }

    /// The tray's design height (pre-`k`), for layout above it.
    pub(crate) fn tray_height() -> f64 {
        5.0 * 42.0 + 4.0 * 7.0 + 2.0 * 14.0
    }

    /// Render the tray with its bottom edge at `bottom`, horizontally centered, slid by
    /// `seat` (0..1). The caller clips nothing — the tray rises from below the screen.
    #[allow(clippy::too_many_arguments)]
    pub(crate) fn render(
        &self,
        canvas: &Canvas,
        fonts: &Fonts,
        w: f64,
        bottom: f64,
        seat: f64,
        k: f64,
    ) {
        let rows = key_rows();
        let tray_w = (560.0 * k).min(w - 32.0 * k);
        let tray_h = Self::tray_height() * k;
        let x0 = (w - tray_w) / 2.0;
        let y0 = bottom - tray_h * seat;
        let rect = Rect::from_xywh(x0 as f32, y0 as f32, tray_w as f32, tray_h as f32);
        crate::theme::panel(
            canvas,
            rect,
            22.0,
            Some(skia_safe::Color4f::new(0.05, 0.045, 0.09, 0.55)),
            PanelStroke::Plain(0.12),
            k as f32,
        );

        let pad = 14.0 * k;
        let gap = 7.0 * k;
        let key_h = 42.0 * k;
        for (r, row) in rows.iter().enumerate() {
            let n = row.len() as f64;
            let key_w = (tray_w - 2.0 * pad - (n - 1.0) * gap) / n;
            let y = y0 + pad + r as f64 * (key_h + gap);
            for (c, key) in row.iter().enumerate() {
                let x = x0 + pad + c as f64 * (key_w + gap);
                let focused = r == self.row && c == self.col;
                let kr = Rect::from_xywh(x as f32, y as f32, key_w as f32, key_h as f32);
                let fill = if focused {
                    let mut b = BRAND;
                    if self.key_flash > 0.02 {
                        // A just-typed key flashes brighter, then eases back.
                        let f = self.key_flash as f32;
                        b = skia_safe::Color4f::new(
                            b.r + (1.0 - b.r) * 0.5 * f,
                            b.g + (1.0 - b.g) * 0.5 * f,
                            b.b,
                            1.0,
                        );
                    }
                    b
                } else {
                    white(0.08)
                };
                canvas.draw_rrect(
                    RRect::new_rect_xy(kr, (9.0 * k) as f32, (9.0 * k) as f32),
                    &Paint::new(fill, None),
                );
                let ink = if focused {
                    skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0)
                } else {
                    WHITE
                };
                let (cx, cy) = (x + key_w / 2.0, y + key_h / 2.0);
                match key {
                    Key::Char(ch) => {
                        let s = ch.to_string();
                        let size = 18.0 * k;
                        let tw = fonts.measure(&s, W::Medium, size) as f64;
                        fonts.draw(
                            canvas,
                            &s,
                            cx - tw / 2.0,
                            cy + size * 0.36,
                            W::Medium,
                            size,
                            ink,
                        );
                    }
                    Key::Space => draw_space_icon(canvas, cx, cy, k, ink),
                    Key::Backspace => draw_backspace_icon(canvas, cx, cy, k, ink),
                    Key::Done => {
                        let size = 15.0 * k;
                        let label = "Done";
                        let tw = fonts.measure(label, W::SemiBold, size) as f64;
                        let check_w = 14.0 * k;
                        let total = check_w + 6.0 * k + tw;
                        draw_check(canvas, cx - total / 2.0 + check_w / 2.0, cy, k, ink);
                        fonts.draw(
                            canvas,
                            label,
                            cx - total / 2.0 + check_w + 6.0 * k,
                            cy + size * 0.36,
                            W::SemiBold,
                            size,
                            ink,
                        );
                    }
                }
            }
        }
    }
}

fn stroke_paint(ink: skia_safe::Color4f, width: f32) -> Paint {
    let mut p = Paint::new(ink, None);
    p.set_style(skia_safe::PaintStyle::Stroke);
    p.set_stroke_width(width);
    p.set_stroke_cap(skia_safe::PaintCap::Round);
    p.set_stroke_join(skia_safe::PaintJoin::Round);
    p.set_anti_alias(true);
    p
}

fn draw_space_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
    // ⎵ — an underline bracket.
    let (w, h) = (16.0 * k, 5.0 * k);
    let p = stroke_paint(ink, (1.6 * k) as f32);
    let mut path = Path::new();
    path.move_to(((cx - w / 2.0) as f32, (cy - h / 2.0) as f32));
    path.line_to(((cx - w / 2.0) as f32, (cy + h / 2.0) as f32));
    path.line_to(((cx + w / 2.0) as f32, (cy + h / 2.0) as f32));
    path.line_to(((cx + w / 2.0) as f32, (cy - h / 2.0) as f32));
    canvas.draw_path(&path, &p);
}

fn draw_backspace_icon(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
    // ⌫ — the left-pointing cap with an ✕ inside.
    let (w, h) = (18.0 * k, 12.0 * k);
    let nose = 6.0 * k;
    let p = stroke_paint(ink, (1.6 * k) as f32);
    let (l, r, t, b) = (cx - w / 2.0, cx + w / 2.0, cy - h / 2.0, cy + h / 2.0);
    let mut path = Path::new();
    path.move_to(((l + nose) as f32, t as f32));
    path.line_to((r as f32, t as f32));
    path.line_to((r as f32, b as f32));
    path.line_to(((l + nose) as f32, b as f32));
    path.line_to((l as f32, cy as f32));
    path.close();
    canvas.draw_path(&path, &p);
    let (xc, xr) = (cx + nose / 2.0, 2.6 * k);
    canvas.draw_line(
        ((xc - xr) as f32, (cy - xr) as f32),
        ((xc + xr) as f32, (cy + xr) as f32),
        &p,
    );
    canvas.draw_line(
        ((xc - xr) as f32, (cy + xr) as f32),
        ((xc + xr) as f32, (cy - xr) as f32),
        &p,
    );
}

fn draw_check(canvas: &Canvas, cx: f64, cy: f64, k: f64, ink: skia_safe::Color4f) {
    let p = stroke_paint(ink, (1.8 * k) as f32);
    let r = 5.0 * k;
    let mut path = Path::new();
    path.move_to(((cx - r) as f32, cy as f32));
    path.line_to(((cx - r * 0.25) as f32, (cy + r * 0.7) as f32));
    path.line_to(((cx + r) as f32, (cy - r * 0.7) as f32));
    canvas.draw_path(&path, &p);
}

#[cfg(test)]
mod tests {
    use super::*;

    fn kb() -> Keyboard {
        Keyboard::new()
    }

    #[test]
    fn keyboard_opens_on_q_and_types() {
        let mut k = kb();
        let (msg, _) = k.menu(MenuEvent::Confirm);
        assert_eq!(msg, KeyMsg::Type('q'));
    }

    #[test]
    fn keyboard_proportional_column_mapping() {
        let mut k = kb();
        // Walk to the digits row's far right ("0", col 9), then down to the bottom row:
        // col must land on Done (rightmost of 3), not clamp to some middle key.
        for _ in 0..9 {
            k.menu(MenuEvent::Move(MenuDir::Right));
        }
        k.menu(MenuEvent::Move(MenuDir::Up)); // digits row
        assert_eq!((k.row, k.col), (0, 9));
        for _ in 0..4 {
            k.menu(MenuEvent::Move(MenuDir::Down));
        }
        assert_eq!(k.row, 4);
        assert_eq!(k.col, 2, "rightmost column maps onto Done");
        let (msg, _) = k.menu(MenuEvent::Confirm);
        assert_eq!(msg, KeyMsg::Done);
    }

    #[test]
    fn keyboard_edges_refuse() {
        let mut k = kb();
        let (_, pulse) = k.menu(MenuEvent::Move(MenuDir::Left));
        assert!(matches!(pulse, Some(MenuPulse::Boundary)));
        // X backspaces, B/Y are done — from anywhere.
        assert_eq!(k.menu(MenuEvent::Tertiary).0, KeyMsg::Backspace);
        assert_eq!(k.menu(MenuEvent::Secondary).0, KeyMsg::Done);
        assert_eq!(k.menu(MenuEvent::Back).0, KeyMsg::Done);
    }

    #[test]
    fn charsets() {
        assert!(permits(Charset::Digits, '7'));
        assert!(!permits(Charset::Digits, 'a'));
        assert!(permits(Charset::Hostname, '-'));
        assert!(!permits(Charset::Hostname, ' '));
        assert!(permits(Charset::Free, ' '));
    }

    #[test]
    fn menu_list_moves_and_recoils() {
        let mut l = MenuList::new();
        assert!(matches!(
            l.menu(MenuEvent::Move(MenuDir::Down), 3).1,
            Some(MenuPulse::Move)
        ));
        assert_eq!(l.cursor, 1);
        assert!(matches!(
            l.menu(MenuEvent::Move(MenuDir::Up), 3).1,
            Some(MenuPulse::Move)
        ));
        assert!(matches!(
            l.menu(MenuEvent::Move(MenuDir::Up), 3).1,
            Some(MenuPulse::Boundary)
        ));
        assert!(l.bump.pos.abs() > 1.0, "recoil engaged");
        assert_eq!(
            l.menu(MenuEvent::Move(MenuDir::Right), 3).0,
            ListMsg::Adjust(1)
        );
        assert_eq!(l.menu(MenuEvent::Confirm, 3).0, ListMsg::Activate);
    }

    #[test]
    fn head_truncation_keeps_the_tail() {
        let fonts = crate::theme::build_fonts().unwrap();
        let t = truncate_head(&fonts, "verylonghostname.local", W::Medium, 15.0, 60.0);
        assert!(t.starts_with('…'));
        assert!(t.ends_with("local"));
    }
}
