//! Cross-platform tray icon + right-click menu + on-demand tutor window.
//!
//! macOS gets its native menu bar icon via `tray-icon` (which wraps `NSStatusItem`); tray events flow naturally through the NSApp event loop that winit already owns on the main thread.
//!
//! Linux gets a StatusNotifierItem registered via DBus — works out of the box on KDE, XFCE, Cinnamon, MATE; on GNOME the user needs `gnome-shell-extension-appindicator` installed and enabled. The icon crate requires `gtk::init` + a running `gtk::main` loop for menu callbacks to fire, and winit doesn't pump gtk. So on Linux we spawn a dedicated gtk thread that owns the tray + menu items and runs `gtk::main()`; the main thread keeps running winit for the tutor window. Menu clicks flow via `tray-icon`'s `MenuEvent` receiver → `EventLoopProxy` → winit `UserEvent`; menu-item labels and icon state are kept in sync by a glib timeout on the gtk thread that polls the shared atomics.

use winit::application::ApplicationHandler;
use winit::dpi::{PhysicalPosition, PhysicalSize};
use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
use winit::keyboard::{Key, ModifiersState, NamedKey};
use winit::window::{CursorIcon, ResizeDirection, Window, WindowId};

use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};

use crate::hand::KeyEvent as RheKeyEvent;
use crate::interpreter::FallbackMode;
use crate::layout::chords::{BriefTable, PhonemeTable};
use crate::tutor::drill::{TutorState, build_practice, cell_label, key_state_to_mask};
use crate::tutor::ui::compositor::{
    HIT_CLOSE_BUTTON, HIT_MAXIMIZE_BUTTON, HIT_MINIMIZE_BUTTON, HIT_NONE, TutorApp,
};
use crate::tutor::ui::drawing;
use crate::tutor::ui::renderer::Renderer;
use crate::tutor::ui::text_rasterizing::TextRenderer;
use crate::tutor::ui::theme;
use crate::tutor::wiki::SentenceStream;
use crate::word_lookup::WordLookup;
use std::sync::Arc;

/// Hover brightness delta for a chrome button hit-test ID.
fn hover_delta(hit: u8) -> u32 {
    match hit {
        HIT_CLOSE_BUTTON => theme::CLOSE_HOVER,
        HIT_MAXIMIZE_BUTTON => theme::MAXIMIZE_HOVER,
        HIT_MINIMIZE_BUTTON => theme::MINIMIZE_HOVER,
        _ => 0,
    }
}
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};

/// Rendered tray-icon bitmap dimensions. 44 = 2× the conventional 22-px Linux SNI / 22-pt macOS NSStatusItem size, so retina / HiDPI displays get an exact pixel match and 1× displays downscale cleanly. Tray-icon 0.22 takes a single bitmap; the OS handles further scaling.
const ICON_SIZE: u32 = 44;

/// Logo embedded at compile time. 1024×1024 RGB PNG.
const LOGO_PNG_BYTES: &[u8] = include_bytes!("../logo.png");

/// Decode and scale-down logo.png to a square RGB buffer of `diameter`× `diameter`. Cached after the first call. Nearest-neighbour scaling — fine for tray-icon sizes where per-pixel sharpness matters more than filter fidelity.
fn scaled_logo_rgb(diameter: usize) -> Vec<u8> {
    use std::sync::OnceLock;
    static DECODED: OnceLock<(Vec<u8>, usize, usize)> = OnceLock::new();
    let (rgb, src_w, src_h) = DECODED.get_or_init(|| {
        let decoder = png::Decoder::new(LOGO_PNG_BYTES);
        let mut reader = decoder.read_info().expect("logo.png invalid");
        let info = reader.info();
        let w = info.width as usize;
        let h = info.height as usize;
        let mut buf = vec![0u8; reader.output_buffer_size()];
        let frame = reader.next_frame(&mut buf).expect("logo.png decode");
        let color = frame.color_type;
        // Normalise to RGB regardless of source colour type.
        let rgb = match color {
            png::ColorType::Rgb => buf,
            png::ColorType::Rgba => buf
                .chunks_exact(4)
                .flat_map(|p| [p[0], p[1], p[2]])
                .collect(),
            png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g]).collect(),
            png::ColorType::GrayscaleAlpha => buf
                .chunks_exact(2)
                .flat_map(|p| [p[0], p[0], p[0]])
                .collect(),
            png::ColorType::Indexed => panic!("indexed PNG not supported"),
        };
        (rgb, w, h)
    });

    let mut dst = vec![0u8; diameter * diameter * 3];
    for dy in 0..diameter {
        let sy = dy * src_h / diameter;
        for dx in 0..diameter {
            let sx = dx * src_w / diameter;
            let src_idx = (sy * src_w + sx) * 3;
            let dst_idx = (dy * diameter + dx) * 3;
            dst[dst_idx] = rgb[src_idx];
            dst[dst_idx + 1] = rgb[src_idx + 1];
            dst[dst_idx + 2] = rgb[src_idx + 2];
        }
    }
    dst
}

use crate::tutor::ui::layout::TutorLayout;

/// Press marker on a chord cell — uses photon's anti-aliased `draw_black_circle` / `draw_white_circle` so the dot reads against any underlying fill:
/// - **bright cell** (target / lit) → subtract white → fades to black at centre with smooth AA edges.
/// - **dark cell** (idle / wrong) → add white → fades to white at centre with smooth AA edges. Press-indicator dot. The drawing helpers (`draw_black_circle`/`draw_white_circle`) require: `radius ≥ 1`, `cx ± radius ∈ [0, buf_w)`, `cy ± radius ∈ [0, buf_h)`. This function is the validation boundary: it accepts arbitrary layout-derived coordinates and returns early if the circle would extend past any window edge, since the helpers cast `cx + dx` straight to `usize` and would otherwise either panic on `pixels[idx]` (y out of range) or silently bleed across rows (negative or oversized x wrapping during the `as usize` cast).
fn press_circle(
    pixels: &mut [u32],
    buf_w: usize,
    buf_h: usize,
    cx: i32,
    cy: i32,
    radius: i32,
    on_bright: bool,
) {
    if radius < 1
        || cx < radius
        || cy < radius
        || (cx + radius) as usize >= buf_w
        || (cy + radius) as usize >= buf_h
    {
        return;
    }
    if on_bright {
        crate::tutor::ui::compositor::TutorApp::draw_black_circle(
            pixels,
            buf_w,
            cx as usize,
            cy as usize,
            radius as usize,
        );
    } else {
        crate::tutor::ui::compositor::TutorApp::draw_white_circle(
            pixels,
            buf_w,
            cx as usize,
            cy as usize,
            radius as usize,
        );
    }
}

/// Pill-shaped press indicator for the wide mod / word bars. Same vertical diameter as `press_circle`'s circle (= 2*radius), just elongated horizontally so the shape sits at a uniform distance from every edge of the cell. Top/bottom margin is `cell_h/2 - radius`; left/right margin is the same — gives the press dot the same border feel on a wide cell as on a square one.
///
/// Validation boundary identical to `press_circle`: the pill helpers assume the bounding box `[cx ± pill_w/2] × [cy ± pill_h/2]` fits entirely within `[0, buf_w) × [0, buf_h)`. This function returns early if any edge would spill out.
fn press_pill(
    pixels: &mut [u32],
    buf_w: usize,
    buf_h: usize,
    cx: i32,
    cy: i32,
    cell_w: i32,
    cell_h: i32,
    radius: i32,
    on_bright: bool,
) {
    if radius < 1 || cell_h < 2 * radius || cell_w < cell_h {
        // radius < 1: helpers require radius ≥ 1 (r_inner = radius - 1).
        // cell_h < 2*radius: pill_h would underflow.
        // cell_w < cell_h: would invert the v_margin → pill_w math.
        return;
    }
    let pill_h = 2 * radius;
    let v_margin = cell_h - pill_h;
    let pill_w = cell_w - v_margin;
    if cx < pill_w / 2
        || cy < pill_h / 2
        || (cx + pill_w / 2) as usize >= buf_w
        || (cy + pill_h / 2) as usize >= buf_h
    {
        return;
    }
    if on_bright {
        crate::tutor::ui::compositor::TutorApp::draw_black_pill(
            pixels,
            buf_w,
            cx as usize,
            cy as usize,
            pill_w as usize,
            pill_h as usize,
        );
    } else {
        crate::tutor::ui::compositor::TutorApp::draw_white_pill(
            pixels,
            buf_w,
            cx as usize,
            cy as usize,
            pill_w as usize,
            pill_h as usize,
        );
    }
}

fn finger_cell_fill(cell_idx: usize, is_target: bool, is_primary: bool) -> u32 {
    if !is_target {
        let inner = cell_idx == 4 || cell_idx == 5;
        return if inner {
            theme::CELL_INNER_IDLE
        } else {
            theme::CELL_IDLE
        };
    }
    if is_primary {
        theme::KEY_COLOURS[cell_idx]
    } else {
        theme::DOT_COLOURS[cell_idx]
    }
}

/// Pill-shaped cell via the compositor's draw_button. Square: width = height = `cell_d`. `pressed` swaps the bevel highlight/shadow so the cell reads as pushed-in instead of raised — mirrors the physical key state for live feedback. Photon-style fixed bevel constants (no per-fill arithmetic): same two colours every cell, just swapped on press.
fn cell(
    pixels: &mut [u32],
    hit: &mut [u8],
    w: usize,
    h: usize,
    cx: i32,
    cy: i32,
    cell_d: i32,
    fill: u32,
    pressed: bool,
) {
    if cx < cell_d / 2 + 1 || cy < cell_d / 2 + 1 {
        return;
    }
    let (top_edge, bot_edge) = if pressed {
        (theme::BUTTON_SHADOW_EDGE, theme::BUTTON_LIGHT_EDGE)
    } else {
        (theme::BUTTON_LIGHT_EDGE, theme::BUTTON_SHADOW_EDGE)
    };
    crate::tutor::ui::compositor::TutorApp::draw_button(
        pixels,
        hit,
        None,
        w,
        h,
        cx as usize,
        cy as usize,
        cell_d as usize,
        cell_d as usize,
        crate::tutor::ui::compositor::HIT_NONE,
        fill,
        top_edge,
        bot_edge,
    );
}

/// Pill-shaped cell, allowing distinct width/height (used for the thumb / mod cell which is rendered wider). `pressed` flips the bevels same as `cell`.
fn cell_wide(
    pixels: &mut [u32],
    hit: &mut [u8],
    w: usize,
    h: usize,
    cx: i32,
    cy: i32,
    cell_w: i32,
    cell_h: i32,
    fill: u32,
    pressed: bool,
) {
    if cx < cell_w / 2 + 1 || cy < cell_h / 2 + 1 {
        return;
    }
    let (top_edge, bot_edge) = if pressed {
        (theme::BUTTON_SHADOW_EDGE, theme::BUTTON_LIGHT_EDGE)
    } else {
        (theme::BUTTON_LIGHT_EDGE, theme::BUTTON_SHADOW_EDGE)
    };
    crate::tutor::ui::compositor::TutorApp::draw_button(
        pixels,
        hit,
        None,
        w,
        h,
        cx as usize,
        cy as usize,
        cell_w as usize,
        cell_h as usize,
        crate::tutor::ui::compositor::HIT_NONE,
        fill,
        top_edge,
        bot_edge,
    );
}

/// Render the tray icon: ring around a scaled-down logo.png via the compositor's `draw_avatar` in straight-alpha mode (so the outer AA fringe carries real alpha and the icon fades to transparent outside the ring instead of leaving an opaque half-bright ring).
fn make_tray_icon(size: u32, online: bool) -> Icon {
    use crate::tutor::ui::compositor::TutorApp;

    let w = size as usize;
    let h = size as usize;

    // -2, not -1: with cx = size/2 on an even canvas the solid ring
    // would otherwise extend one pixel past the canvas edge on the
    // bottom-right (parity quirk). Subtracting 2 puts the last solid
    // pixel exactly at the edge and leaves the alpha-0 outer-AA off-
    // canvas where it isn't visible.
    let radius = (size as isize) / 2 - 2;
    let diameter = (radius * 2) as usize;
    let cx = (size / 2) as isize;
    let cy = cx;

    let mut pixels = vec![0u32; w * h];
    let logo = scaled_logo_rgb(diameter);
    let ring = if online {
        theme::TRAY_RING_ON
    } else {
        theme::TRAY_RING_OFF
    };
    TutorApp::draw_avatar(
        &mut pixels,
        None,
        w,
        h,
        cx,
        cy,
        radius,
        Some(&logo),
        Some(ring),
        false,
        true, // straight_alpha — tray icons need real edge alpha
    );

    // u32 ARGB → RGBA bytes for tray-icon's Icon::from_rgba.
    let mut rgba = Vec::with_capacity(w * h * 4);
    for px in pixels {
        rgba.push(((px >> 16) & 0xFF) as u8);
        rgba.push(((px >> 8) & 0xFF) as u8);
        rgba.push((px & 0xFF) as u8);
        rgba.push(((px >> 24) & 0xFF) as u8);
    }
    Icon::from_rgba(rgba, size, size).expect("tray icon build failed")
}

/// Events that wake the winit event loop.
#[derive(Debug, Clone)]
pub enum TrayEvent {
    /// The engine thread toggled the `enabled` flag (via a solo Caps press). The tray icon + check item need refreshing. On Linux the gtk thread's glib timeout notices this via atomics directly, so this variant is just a no-op wake for the winit thread.
    StateChanged,
    /// A menu item was clicked.
    Menu(MenuId),
    /// The engine thread observed a key event. Forwarded to the tutor window if it's open so the drill state machine can advance. Sent unconditionally — the tray drops it on the floor when no tutor window exists.
    DrillKey(RheKeyEvent),
}

/// Handle a tray can pass to other threads so they can wake the event loop.
pub type TrayProxy = EventLoopProxy<TrayEvent>;

/// Generate a filled circle icon as RGBA bytes.
fn circle_icon(r: u8, g: u8, b: u8, a: u8) -> Icon {
    let mut rgba = vec![0u8; (ICON_SIZE * ICON_SIZE * 4) as usize];
    let center = ICON_SIZE as f64 / 2.0;
    let radius = center - 1.0;

    for y in 0..ICON_SIZE {
        for x in 0..ICON_SIZE {
            let dx = x as f64 - center;
            let dy = y as f64 - center;
            let dist = (dx * dx + dy * dy).sqrt();

            if dist <= radius {
                let offset = ((y * ICON_SIZE + x) * 4) as usize;
                rgba[offset] = r;
                rgba[offset + 1] = g;
                rgba[offset + 2] = b;
                rgba[offset + 3] = a;
            }
        }
    }

    Icon::from_rgba(rgba, ICON_SIZE, ICON_SIZE).expect("failed to create icon")
}

/// Build the event loop + proxy before spawning the engine. The caller gives the proxy to any thread that wants to notify the tray of state changes (primarily the evdev reader on Linux / HID callback on macOS).
pub fn build() -> (EventLoop<TrayEvent>, TrayProxy) {
    let event_loop = EventLoop::<TrayEvent>::with_user_event()
        .build()
        .expect("failed to build winit event loop");
    let proxy = event_loop.create_proxy();
    (event_loop, proxy)
}

/// Menu IDs exposed to the winit thread so it can dispatch clicks back to the right action without holding the `MenuItem`s directly (which would be a problem on Linux, where the items must live on the gtk thread).
#[derive(Clone)]
struct TrayIds {
    tutor: MenuId,
    test_text: MenuId,
    brown_corpus: MenuId,
    mode: MenuId,
    enabled: MenuId,
    quit: MenuId,
}

struct TrayApp {
    enabled: Arc<AtomicBool>,
    quit: Arc<AtomicBool>,
    fallback: Arc<AtomicU8>,
    /// Bitfield mirroring the live Interpreter's sub-mode (number / future symbol etc.). Cloned from the engine thread so the tutor's adaptive cell labels match what the next press would actually emit. See `interpreter::MODE_FLAG_*`.
    mode_flags: Arc<AtomicU8>,
    ids: TrayIds,

    // macOS and Windows keep the tray + menu items on the winit
    // event-loop thread; Linux hands them off to a dedicated gtk
    // thread, so on Linux this stays None.
    #[cfg(any(target_os = "macos", target_os = "windows"))]
    mac_state: Option<MacTrayState>,

    // Tutor window state. Declared renderer-before-window so they drop
    // in that order (renderer holds `&'static Window` transmuted from
    // the window below, and must be dropped first to avoid dangling).
    tutor_renderer: Option<Renderer>,
    tutor_window: Option<Window>,
    tutor_state: Option<TutorState>,
    text_renderer: Option<TextRenderer>,

    // Cached drill resources, kept alive between word transitions so a
    // wiki wraparound can rebuild Practice without re-parsing cmudict /
    // briefs. Built lazily on first `open_tutor`, dropped on close.
    tutor_wiki_stream: Option<SentenceStream>,
    tutor_word_lookup: Option<WordLookup>,
    tutor_brief_table: Option<BriefTable>,

    /// User zoom multiplier applied on top of span-derived sizes. Adjusted live by Ctrl+scroll; 1.0 is the default.
    tutor_ru: f32,
    /// While `Some(deadline)` and `now < deadline`, the top-left zoom-percentage hint renders. Bumped on every ru change to `now + 1s`. about_to_wait schedules a `WaitUntil(deadline)` so the hint auto-fades without further user input.
    tutor_zoom_hint_until: Option<std::time::Instant>,
    /// Last observed modifier state for the tutor window. Updated on every ModifiersChanged event so MouseWheel can consult it.
    tutor_mods: ModifiersState,
    /// Last cursor position inside the tutor window, in physical px.
    tutor_cursor: PhysicalPosition<f64>,
    /// Hit-test map, one byte per pixel. Photon's draw_* fns populate this with HIT_* constants; we consult it on click to dispatch to the right action.
    tutor_hit_test: Vec<u8>,
    /// Textbox alpha mask (0 = outside textbox, 255 = inside, AA edge values in between). Populated by draw_textbox, consumed by apply_textbox_glow. Sized to pixels.len().
    tutor_textbox_mask: Vec<u8>,

    // Debug toggles (photon parity): Ctrl+D counters, Ctrl+H hitmap
    // overlay, Ctrl+T textbox-mask overlay.
    tutor_debug: bool,
    tutor_debug_hit_test: bool,
    tutor_show_textbox_mask: bool,
    tutor_debug_hit_colours: Vec<(u8, u8, u8)>,
    tutor_debug_colour_seed: u32,
    /// Manual Ctrl tracking derived from KeyboardInput events. Used as a fallback when winit's ModifiersChanged doesn't fire on some Wayland compositors (or before the window has keyboard focus).
    tutor_ctrl_held: bool,

    /// Currently hovered chrome button (for hover fill effect).
    tutor_hovered_button: u8,

    // Manual resize/move drag state (Photon parity — winit's
    // drag_resize_window is unreliable on macOS).
    tutor_dragging_resize: bool,
    tutor_dragging_move: bool,
    tutor_mouse_pressed: bool,
    tutor_resize_edge: Option<ResizeDirection>,
    tutor_drag_start_size: (u32, u32),
    tutor_drag_start_window_pos: (i32, i32),
    tutor_drag_start_screen_pos: (f64, f64),

    // Frame/redraw counters for the debug HUD.
    tutor_frame_counter: u64,
    tutor_redraw_counter: u64,
}

#[cfg(any(target_os = "macos", target_os = "windows"))]
struct MacTrayState {
    tray: TrayIcon,
    icon_on: Icon,
    icon_off: Icon,
    mode_item: MenuItem,
    enabled_item: MenuItem,
}

impl TrayApp {
    #[cfg(any(target_os = "macos", target_os = "windows"))]
    fn refresh_enabled_ui(&mut self) {
        if let Some(state) = self.mac_state.as_mut() {
            let on = self.enabled.load(Ordering::Relaxed);
            state
                .enabled_item
                .set_text(if on { "rhe" } else { "keyboard" });
            let icon = if on {
                state.icon_on.clone()
            } else {
                state.icon_off.clone()
            };
            state.tray.set_icon(Some(icon)).ok();
        }
    }

    #[cfg(target_os = "linux")]
    fn refresh_enabled_ui(&mut self) {
        // No-op on Linux: the gtk thread's glib timeout owns the UI.
    }

    fn open_tutor(&mut self, event_loop: &ActiveEventLoop) {
        if self.tutor_window.is_some() {
            if let Some(w) = &self.tutor_window {
                w.focus_window();
            }
            return;
        }

        // Size and position the window from the primary monitor so it
        // looks right at any DPI / resolution. Mirrors photon's
        // resumed() — query the monitor, take a fraction of its size,
        // centre on screen. Falls back to a fixed pair only if we
        // somehow can't get a monitor.
        let (window_width, window_height, window_x, window_y) = event_loop
            .primary_monitor()
            .or_else(|| event_loop.available_monitors().next())
            .map(|monitor| {
                let s = monitor.size();
                let w = s.width / crate::tutor::ui::layout::WINDOW_W_FRAC;
                let h = s.height / crate::tutor::ui::layout::WINDOW_H_FRAC;
                let x = (s.width - w) / 2;
                let y = (s.height - h) / 2;
                (w, h, x as i32, y as i32)
            })
            .unwrap_or((800, 500, 0, 0));

        let attrs = Window::default_attributes()
            .with_title("rhe tutor")
            .with_inner_size(PhysicalSize::new(window_width, window_height))
            .with_position(PhysicalPosition::new(window_x, window_y))
            .with_decorations(false)
            .with_transparent(true)
            // Request activation on creation so a tray-menu launch
            // (which doesn't naturally hand focus to a brand-new
            // window) pops to the front instead of opening behind
            // whatever app the user was in. NOT AlwaysOnTop — that
            // confuses some compositors into stranding other windows
            // on top after the level swap.
            .with_active(true)
            .with_resizable(cfg!(not(target_os = "macos")));
        let window = match event_loop.create_window(attrs) {
            Ok(w) => w,
            Err(e) => {
                crate::rerror!("failed to create tutor window: {e}");
                return;
            }
        };

        let size = window.inner_size();
        self.tutor_window = Some(window);

        // Safe only because:
        //   1. tutor_renderer is declared before tutor_window in the
        //      struct, so it drops first.
        //   2. We never move tutor_window out of self except to drop it
        //      (close path assigns None; renderer is cleared first).
        if let Some(w_ref) = self.tutor_window.as_ref() {
            #[cfg(target_os = "macos")]
            {
                use winit::platform::macos::WindowExtMacOS;
                w_ref.set_has_shadow(false);
            }
            self.tutor_renderer = Some(Renderer::new(w_ref, size.width, size.height));
            // Explicit focus request — needed on some compositors
            // (Wayland in particular) to start delivering
            // ModifiersChanged / KeyboardInput events without an
            // intermediate user click.
            w_ref.focus_window();
            w_ref.request_redraw();
        }

        // Build the drill state on first open. Wiki stream blocks for
        // its first article (then prefetches the next in the
        // background); falls back to bundled Alice text when the
        // network isn't reachable.
        if self.tutor_state.is_none() {
            let cmudict = crate::data::load_cmudict();
            let lookup = WordLookup::new(&cmudict);
            let brief_table = crate::briefs::load_briefs();

            let stream = SentenceStream::new();
            let initial = stream.initial();
            let lines: Vec<String> = if initial.is_empty() {
                crate::tutor::drill::brown_corpus_lines()
            } else {
                initial
            };
            let practice = build_practice(&lookup, &brief_table, lines, false);
            self.tutor_state = Some(TutorState::new(practice));
            self.tutor_wiki_stream = Some(stream);
            self.tutor_word_lookup = Some(lookup);
            self.tutor_brief_table = Some(brief_table);
        }
    }

    fn close_tutor(&mut self) {
        // Order matters: renderer first (holds &'static Window), window second.
        self.tutor_renderer = None;
        self.tutor_window = None;
        // Drop drill state too — it'll get rebuilt fresh next open.
        self.tutor_state = None;
        self.tutor_wiki_stream = None;
        self.tutor_word_lookup = None;
        self.tutor_brief_table = None;
        self.tutor_zoom_hint_until = None;
    }

    /// Show the top-left zoom-percentage hint and arm a 1-second auto-fade. Called from every ru-changing site (Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+scroll).
    fn bump_zoom_hint(&mut self) {
        self.tutor_zoom_hint_until =
            Some(std::time::Instant::now() + std::time::Duration::from_millis(1000));
    }

    /// On wraparound, swap the drill to the next prefetched wiki batch if one is ready. Otherwise just clear the flag — the same batch loops, and the next wrap retries the prefetch.
    fn maybe_swap_practice(&mut self) {
        let Some(state) = self.tutor_state.as_mut() else {
            return;
        };
        if !state.practice.wrapped {
            return;
        }
        if let (Some(stream), Some(lookup), Some(brief_table)) = (
            self.tutor_wiki_stream.as_ref(),
            self.tutor_word_lookup.as_ref(),
            self.tutor_brief_table.as_ref(),
        ) {
            if let Some(new_lines) = stream.try_next() {
                state.practice = build_practice(lookup, brief_table, new_lines, false);
                return;
            }
        }
        state.practice.wrapped = false;
    }

    fn ensure_buffers(&mut self, size_px: usize) {
        if self.tutor_hit_test.len() != size_px {
            self.tutor_hit_test.resize(size_px, HIT_NONE);
        }
        if self.tutor_textbox_mask.len() != size_px {
            self.tutor_textbox_mask.resize(size_px, 0);
        }
    }

    fn redraw_tutor(&mut self) {
        let Some(window) = self.tutor_window.as_ref() else {
            return;
        };
        let size = window.inner_size();
        let width = size.width as usize;
        let height = size.height as usize;
        if width == 0 || height == 0 {
            return;
        }

        if self.text_renderer.is_none() {
            self.text_renderer = Some(TextRenderer::new());
        }

        self.ensure_buffers(width * height);

        let Some(renderer) = self.tutor_renderer.as_mut() else {
            return;
        };
        renderer.resize(width as u32, height as u32);

        // CRITICAL: mark the whole frame dirty on the Renderer BEFORE
        // locking the buffer. Photon's Linux softbuffer path only
        // copies rows in the current dirty range to the compositor
        // buffer on present(); after the first frame that range is
        // empty unless we re-mark, so every subsequent redraw would
        // otherwise silently present zero bytes and the screen would
        // freeze at the initial frame. The `buf.mark_all()` method on
        // the SoftbufferBuffer guard is a stub — it has to be called
        // on the Renderer itself.
        renderer.mark_all();

        let mut buf = renderer.lock_buffer();
        let pixels = buf.as_mut();

        // Patchwork background texture — gives the chord cells'
        // bevels something varied to read against. The chrome's
        // edges-and-mask pass clears the squircle corners to fully
        // transparent on top of this, so what's underneath the
        // squircle stays untouched by the texture.
        drawing::draw_background_texture(pixels, width, height, 0, true, 0);
        self.tutor_hit_test.fill(HIT_NONE);
        self.tutor_textbox_mask.fill(0);

        // Photon's chrome. window_controls returns the squircle bounds
        // that edges_and_mask + button_hairlines both need.
        let (start, crossings, button_x_start, button_height) = TutorApp::draw_window_controls(
            pixels,
            &mut self.tutor_hit_test,
            size.width,
            size.height,
            self.tutor_ru,
        );
        TutorApp::draw_window_edges_and_mask(
            pixels,
            &mut self.tutor_hit_test,
            size.width,
            size.height,
            start,
            &crossings,
        );
        TutorApp::draw_button_hairlines(
            pixels,
            &mut self.tutor_hit_test,
            size.width,
            size.height,
            button_x_start,
            button_height,
            start,
            &crossings,
        );

        // Chrome-button hover fill: brighten every pixel tagged
        // with the currently-hovered button's hit id by the photon
        // hover delta. Done after chrome is drawn so the delta lands
        // on top of the chrome fill / glyph, not under it.
        if self.tutor_hovered_button != HIT_NONE {
            let delta = hover_delta(self.tutor_hovered_button);
            let target_id = self.tutor_hovered_button;
            for (idx, &h) in self.tutor_hit_test.iter().enumerate() {
                if h == target_id {
                    if let Some(p) = pixels.get_mut(idx) {
                        *p = p.wrapping_add(delta);
                    }
                }
            }
        }

        // ── Drill view ─────────────────────────────────────────────
        // Layout: target word (big, centered, Oxanium Bold) at top of
        // the content area, step hint below (small, Josefin Slab,
        // either IPA / Autospell letters or a number-mode glyph),
        // then a row of keyboard cells reflecting the current target
        // and live key state. Geometry computed once via TutorLayout
        // (outer positions span-only; cell + font sizes scale with ru).
        let layout =
            TutorLayout::compute(size.width, size.height, button_height as i32, self.tutor_ru);

        let word_text = self
            .tutor_state
            .as_ref()
            .and_then(|s| s.practice.current_word())
            .map(|w| w.word.clone())
            .unwrap_or_default();
        let drill_errored = self
            .tutor_state
            .as_ref()
            .map(|s| s.errored)
            .unwrap_or(false);
        let is_brief_mode = self
            .tutor_state
            .as_ref()
            .map(|s| matches!(s.practice.mode, crate::tutor::drill::WordMode::Brief))
            .unwrap_or(false);
        let step_hint = self
            .tutor_state
            .as_ref()
            .and_then(|s| s.practice.current_step())
            .map(|step| {
                if let Some(g) = &step.number_glyph {
                    g.clone()
                } else if let Some(p) = step.phoneme {
                    p.to_ipa().to_string()
                } else {
                    String::new()
                }
            })
            .unwrap_or_default();
        let errored = self
            .tutor_state
            .as_ref()
            .map(|s| s.errored)
            .unwrap_or(false);

        // Sentence context: the full current sentence rendered as a
        // single line, anchored so the current word stays centered.
        // As word_idx advances the line shifts left, sliding completed
        // words off and bringing upcoming words in.
        // Flat word list across every sentence in the practice
        // (no sentence boundaries / no line returns), with the
        // current word's index in the flat list. Used by the top
        // line render which walks outward from the current word
        // until it spills past the window edges, so the strip is
        // continuous text in both directions regardless of how the
        // wiki batch was split into sentences.
        let (sentence_words, cur_word_idx): (Vec<String>, usize) = {
            let mut flat: Vec<String> = Vec::new();
            let mut cur = 0usize;
            if let Some(state) = self.tutor_state.as_ref() {
                let practice = &state.practice;
                let cs = practice.sentence_idx;
                let cw = practice.word_idx;
                for (si, sentence) in practice.sentences.iter().enumerate() {
                    for (wi, w) in sentence.iter().enumerate() {
                        if si == cs && wi == cw {
                            cur = flat.len();
                        }
                        flat.push(w.word.clone());
                    }
                }
            }
            (flat, cur)
        };

        // Phoneme line: every chord-firing step in the current
        // target word's `current_steps()`, filtered to only those
        // with a displayable glyph (phoneme IPA or number_glyph).
        // Skips entry-only advance_on_mod and space_only commits so
        // the strip is just content, not state markers. cur_phon_idx
        // is the position of the current step in the filtered list,
        // pinned at the most-recent displayable step ≤ step_idx so
        // the highlight tracks the user's progress through commits /
        // entry steps too.
        let (phoneme_glyphs, cur_phon_idx): (Vec<String>, usize) = {
            let mut glyphs: Vec<String> = Vec::new();
            let mut cur = 0usize;
            if let Some(state) = self.tutor_state.as_ref() {
                if let Some(steps) = state.practice.current_steps() {
                    let cur_step = state.practice.step_idx;
                    for (i, step) in steps.iter().enumerate() {
                        let glyph = if let Some(p) = step.phoneme {
                            Some(p.to_ipa().to_string())
                        } else {
                            step.number_glyph.clone()
                        };
                        if let Some(g) = glyph {
                            if i <= cur_step {
                                cur = glyphs.len();
                            }
                            glyphs.push(g);
                        }
                    }
                }
            }
            (glyphs, cur)
        };

        if let Some(text) = self.text_renderer.as_mut() {
            // Sentence line — italic Bona Nova, current word centred
            // on target_cx and bold; past / future words walk
            // outward until they spill past the window edges with a
            // quarter-span overflow margin. Each word is measured at
            // its actual render weight (400 vs 700) so bold's wider
            // glyphs don't stomp on adjacent regular words.
            if !sentence_words.is_empty() && cur_word_idx < sentence_words.len() {
                let span = crate::tutor::ui::span(size.width, size.height);
                let stop_left = -span * 0.25;
                let stop_right = (size.width as f32) + span * 0.25;

                // Current word: measure at weight 700 (its render
                // weight), centre on target_cx.
                let cur_w = text.draw_text_left_u32(
                    pixels,
                    width,
                    &sentence_words[cur_word_idx],
                    -1.0e6,
                    -1.0e6,
                    layout.sentence_font,
                    700,
                    0,
                    "Bona Nova",
                    true,
                );
                let cur_x = layout.target_cx - cur_w / 2.0;
                text.draw_text_left_u32(
                    pixels,
                    width,
                    &sentence_words[cur_word_idx],
                    cur_x,
                    layout.sentence_cy,
                    layout.sentence_font,
                    700,
                    theme::SENTENCE_CURRENT,
                    "Bona Nova",
                    true,
                );

                // Forward walk (future words to the right of current).
                // Wraps cyclically through the flat list so the line
                // keeps streaming text past the window edge regardless
                // of how many words remain in the practice batch.
                let n = sentence_words.len();
                let mut x = cur_x + cur_w + layout.sentence_space_w;
                let mut i = cur_word_idx + 1;
                while x < stop_right {
                    let idx = i % n;
                    let w = text.draw_text_left_u32(
                        pixels,
                        width,
                        &sentence_words[idx],
                        -1.0e6,
                        -1.0e6,
                        layout.sentence_font,
                        400,
                        0,
                        "Bona Nova",
                        true,
                    );
                    text.draw_text_left_u32(
                        pixels,
                        width,
                        &sentence_words[idx],
                        x,
                        layout.sentence_cy,
                        layout.sentence_font,
                        400,
                        theme::SENTENCE_FUTURE,
                        "Bona Nova",
                        true,
                    );
                    x += w + layout.sentence_space_w;
                    i += 1;
                }

                // Backward walk (past words to the left of current).
                // Same cyclic wrap as the forward walk.
                let mut x_right = cur_x - layout.sentence_space_w;
                let mut i = cur_word_idx;
                while x_right > stop_left {
                    let idx = (i + n - 1) % n;
                    i = idx;
                    let w = text.draw_text_left_u32(
                        pixels,
                        width,
                        &sentence_words[idx],
                        -1.0e6,
                        -1.0e6,
                        layout.sentence_font,
                        400,
                        0,
                        "Bona Nova",
                        true,
                    );
                    let xl = x_right - w;
                    text.draw_text_left_u32(
                        pixels,
                        width,
                        &sentence_words[idx],
                        xl,
                        layout.sentence_cy,
                        layout.sentence_font,
                        400,
                        theme::SENTENCE_PAST,
                        "Bona Nova",
                        true,
                    );
                    x_right = xl - layout.sentence_space_w;
                }
            }

            // Big centred target word — red on error, white otherwise.
            let word_colour = if drill_errored { 0xFFFF0000 } else { theme::TARGET_WORD };
            text.draw_text_center_u32(
                pixels,
                width,
                &word_text,
                layout.target_cx,
                layout.target_cy,
                layout.target_font,
                700,
                word_colour,
                "Bona Nova",
                false,
            );

            // Brief mode: render the word in photon-logo style — black
            // glyph with a soft horizontal smear behind it. Visually
            // unmistakable from the italic IPA glyph strip that the
            // same line shows in phoneme mode, signalling "one-shot
            // chord shortcut, not a sounded-out spelling."
            if is_brief_mode && !word_text.is_empty() {
                let logo_colour = if drill_errored { 0xFFFF0000 } else { theme::LOGO_TEXT };
                crate::tutor::ui::compositor::TutorApp::draw_logo_text(
                    pixels,
                    text,
                    width,
                    &word_text,
                    layout.target_cx,
                    layout.phoneme_cy,
                    layout.phoneme_font,
                    700,
                    "Bona Nova",
                    logo_colour,
                );
            } else
            // Phoneme line: italic Bona Nova. Each glyph is measured
            // at its actual render weight (400 for past/future, 700
            // for current) so the bold current glyph doesn't crowd
            // its neighbours. Outward walk centred on target_cx,
            // matching the sentence line.
            if !phoneme_glyphs.is_empty() && cur_phon_idx < phoneme_glyphs.len() {
                // Current glyph at weight 700, centred on target_cx.
                let cur_w = text.draw_text_left_u32(
                    pixels,
                    width,
                    &phoneme_glyphs[cur_phon_idx],
                    -1.0e6,
                    -1.0e6,
                    layout.phoneme_font,
                    700,
                    0,
                    "Bona Nova",
                    true,
                );
                let cur_x = layout.target_cx - cur_w / 2.0;
                let phon_colour = if drill_errored { 0xFFFF0000 } else { theme::SENTENCE_CURRENT };
                text.draw_text_left_u32(
                    pixels,
                    width,
                    &phoneme_glyphs[cur_phon_idx],
                    cur_x,
                    layout.phoneme_cy,
                    layout.phoneme_font,
                    700,
                    phon_colour,
                    "Bona Nova",
                    true,
                );

                // Forward walk (future glyphs, weight 400). Phonemes
                // pack flush against each other — no inter-glyph
                // spacing — since the strip is the spelling of the
                // word, not a sentence of separate tokens.
                let mut x = cur_x + cur_w;
                for g in phoneme_glyphs.iter().skip(cur_phon_idx + 1) {
                    let w = text.draw_text_left_u32(
                        pixels,
                        width,
                        g,
                        -1.0e6,
                        -1.0e6,
                        layout.phoneme_font,
                        400,
                        0,
                        "Bona Nova",
                        true,
                    );
                    let fut_colour = if drill_errored { 0xFFFF0000 } else { theme::SENTENCE_FUTURE };
                    text.draw_text_left_u32(
                        pixels,
                        width,
                        g,
                        x,
                        layout.phoneme_cy,
                        layout.phoneme_font,
                        400,
                        fut_colour,
                        "Bona Nova",
                        true,
                    );
                    x += w;
                }

                // Backward walk (past glyphs, weight 400).
                let mut x_right = cur_x;
                for g in phoneme_glyphs[..cur_phon_idx].iter().rev() {
                    let w = text.draw_text_left_u32(
                        pixels,
                        width,
                        g,
                        -1.0e6,
                        -1.0e6,
                        layout.phoneme_font,
                        400,
                        0,
                        "Bona Nova",
                        true,
                    );
                    let xl = x_right - w;
                    let past_colour = if drill_errored { 0xFFFF0000 } else { theme::SENTENCE_PAST };
                    text.draw_text_left_u32(
                        pixels,
                        width,
                        g,
                        xl,
                        layout.phoneme_cy,
                        layout.phoneme_font,
                        400,
                        past_colour,
                        "Bona Nova",
                        true,
                    );
                    x_right = xl;
                }
            }

            if !step_hint.is_empty() {
                // Hint glyph in the hand_gap centre between cells 4
                // and 5 (same Y as the chord row). Bona Nova regular,
                // bold weight so it reads clearly against any
                // KEY_COLOURS-bright cell next to it.
                text.draw_text_center_u32(
                    pixels,
                    width,
                    &step_hint,
                    layout.hint_cx,
                    layout.hint_cy,
                    layout.hint_font,
                    700,
                    theme::TARGET_WORD, // always white
                    "Bona Nova",
                    false,
                );
            }
        }

        // Keyboard diagram: 10 home/inner cells in a row + thumb cell
        // below the row's centre. Cell colour signals (target,
        // pressed, errored) state.
        //
        // Adaptive label per cell: shows what the cell's key would emit
        // as part of the currently-held chord. Labels are computed
        // ahead of cell rendering so the immutable tutor_state borrow
        // doesn't conflict with the mutable text_renderer borrow that
        // comes after.
        let label_data: Option<(_, [String; 10], _, _, _)> =
            if let Some(state) = self.tutor_state.as_ref() {
                let target = state.practice.current_target().copied().unwrap_or_default();
                let key_state = state.key_state.clone();
                let held_mask = key_state_to_mask(&key_state);
                let held_word = key_state.word;
                let first_down = state.tutor_first_down;
                // Live mode bits straight from the engine's
                // Interpreter (single relaxed load on a shared
                // atomic). Always coherent with what the next press
                // would actually emit, regardless of where the drill
                // happens to be. has_number_context flips L-hand
                // brief cells to number-form labels (alt suffix
                // meaning when a pure integer is one slot back).
                let mode_bits = self.mode_flags.load(Ordering::Relaxed);
                let in_number_mode = mode_bits & crate::interpreter::MODE_FLAG_NUMBER != 0;
                let has_number_context = mode_bits & crate::interpreter::MODE_FLAG_HAS_NUMBER != 0;
                let phonemes = PhonemeTable::new();
                let briefs = self.tutor_brief_table.as_ref();
                const CELL_SCANS: [u8; 10] = [
                    crate::scan::L_PINKY,
                    crate::scan::L_RING,
                    crate::scan::L_MID,
                    crate::scan::L_IDX,
                    crate::scan::L_IDX_INNER,
                    crate::scan::R_IDX_INNER,
                    crate::scan::R_IDX,
                    crate::scan::R_MID,
                    crate::scan::R_RING,
                    crate::scan::R_PINKY,
                ];
                let labels: [String; 10] = std::array::from_fn(|i| {
                    if let Some(b) = briefs {
                        cell_label(
                            CELL_SCANS[i],
                            held_mask,
                            held_word,
                            first_down,
                            &phonemes,
                            b,
                            in_number_mode,
                            has_number_context,
                        )
                    } else {
                        String::new()
                    }
                });
                Some((target, labels, key_state, errored, ()))
            } else {
                None
            };

        if let Some((target, labels, key_state, errored, _)) = label_data.as_ref() {
            // Errored frames render as if there were no target — every
            // cell goes idle, the cue being "everything dark, release
            // and try again" rather than a specific red highlight.
            let target = if *errored {
                crate::tutor::drill::Target::default()
            } else {
                *target
            };
            let first_down = self.tutor_state.as_ref().and_then(|s| s.tutor_first_down);
            let cell_d = layout.cell_d;
            let cell_gap = layout.cell_gap;
            let hand_gap = layout.hand_gap;
            let row_x = layout.row_x;
            let cy = layout.row_cy;

            // Cell scancodes in display order; needed both for cell-
            // colour primary/secondary checks and for the label pass.
            const CELL_SCANS: [u8; 10] = [
                crate::scan::L_PINKY,
                crate::scan::L_RING,
                crate::scan::L_MID,
                crate::scan::L_IDX,
                crate::scan::L_IDX_INNER,
                crate::scan::R_IDX_INNER,
                crate::scan::R_IDX,
                crate::scan::R_MID,
                crate::scan::R_RING,
                crate::scan::R_PINKY,
            ];
            // Primary = bright key colour. Every target cell is primary
            // when there's no ordering constraint, OR once the user has
            // committed to a lead (any key down). Otherwise only cells
            // listed in target.accepted_leads are bright; the rest dim
            // to dot_color as a "press one of these first" cue.
            let is_primary = |cell_scan: u8| -> bool {
                if target.accepted_leads.is_empty() {
                    return true;
                }
                if first_down.is_some() {
                    return true;
                }
                target.accepted_leads.test(cell_scan)
            };

            // Cell rectangles in display order (left 0..5, right 5..10),
            // captured during drawing so the label pass can reuse the
            // exact centres without repeating the layout math.
            let mut cell_centres: [(i32, i32); 10] = [(0, 0); 10];

            // Per-cell pressed state in display order. Drives both the
            // press-darken on idle grey and the bevel reversal in
            // `cell()` so any held key reads as pushed-in.
            let pressed: [bool; 10] = [
                key_state.left[0],  // L pinky
                key_state.left[1],  // L ring
                key_state.left[2],  // L mid
                key_state.left[3],  // L idx
                key_state.left[4],  // L idx-inner
                key_state.right[5], // R idx-inner
                key_state.right[0], // R idx
                key_state.right[1], // R mid
                key_state.right[2], // R ring
                key_state.right[3], // R pinky
            ];

            let left_bits = [3usize, 2, 1, 0, 4];
            let right_bits = [5usize, 0, 1, 2, 3];

            // 11 slots, uniform cell_d + cell_gap step. Slot 5 is
            // the invisible "between hands" slot — skip rendering,
            // its space holds the in-hand-gap step-hint glyph.
            // Press circle: black on lit / target cells, white on
            // dark / idle cells. Drawn over the pill after the cell
            // body so the contrast inverts cleanly.
            // Half the visual size, +1px floor so the dot is at
            // least one pixel even when cell_d is tiny.
            let press_dot_radius = cell_d / 10 + 1;
            for slot in 0..11usize {
                let cx_cell = row_x + slot as i32 * (cell_d + cell_gap) + cell_d / 2;
                if slot == 5 {
                    continue;
                }
                let cell_idx = if slot < 5 { slot } else { slot - 1 };
                let is_target = if cell_idx < 5 {
                    (target.left & (1u8 << left_bits[cell_idx])) != 0
                } else {
                    (target.right & (1u8 << right_bits[cell_idx - 5])) != 0
                };
                let fill = finger_cell_fill(cell_idx, is_target, is_primary(CELL_SCANS[cell_idx]));
                cell(
                    pixels,
                    &mut self.tutor_hit_test,
                    width,
                    height,
                    cx_cell,
                    cy,
                    cell_d,
                    fill,
                    pressed[cell_idx],
                );
                if pressed[cell_idx] {
                    press_circle(
                        pixels,
                        width,
                        height,
                        cx_cell,
                        cy,
                        press_dot_radius,
                        is_target,
                    );
                }
                cell_centres[cell_idx] = (cx_cell, cy);
            }
            let _ = hand_gap; // slot 5 width is implicit in the slot
            // walk above; field kept for callers
            // that want the in-gap centre Y.

            // Second row below the chord cells: word bar (long, left-
            // aligned under L-pinky) + mod cell (2 cells wide, right-
            // aligned under R-pinky). Word = purple, mod = green —
            // distinct from the finger gradient so the thumb/word
            // roles read at a glance.
            let bottom_cy = layout.bottom_cy;
            let mod_w = layout.mod_w;
            let mod_cx = layout.mod_cx;
            let mod_target = (target.right & (1u8 << 4)) != 0;
            let mod_fill = if mod_target {
                theme::MOD_PRIMARY
            } else {
                theme::CELL_IDLE
            };
            let mod_pressed = key_state.right[4];
            cell_wide(
                pixels,
                &mut self.tutor_hit_test,
                width,
                height,
                mod_cx,
                bottom_cy,
                mod_w,
                cell_d,
                mod_fill,
                mod_pressed,
            );
            if mod_pressed {
                press_pill(
                    pixels,
                    width,
                    height,
                    mod_cx,
                    bottom_cy,
                    mod_w,
                    cell_d,
                    press_dot_radius,
                    mod_target,
                );
            }

            let word_w = layout.word_w;
            let word_cx = layout.word_cx;
            let word_fill = if target.word {
                theme::WORD_PRIMARY
            } else {
                theme::CELL_IDLE
            };
            let word_pressed = key_state.word;
            cell_wide(
                pixels,
                &mut self.tutor_hit_test,
                width,
                height,
                word_cx,
                bottom_cy,
                word_w,
                cell_d,
                word_fill,
                word_pressed,
            );
            if word_pressed {
                press_pill(
                    pixels,
                    width,
                    height,
                    word_cx,
                    bottom_cy,
                    word_w,
                    cell_d,
                    press_dot_radius,
                    target.word,
                );
            }
            let _ = theme::WORD_SECONDARY;

            // Adaptive labels: drawn ABOVE each key (not on top), so
            // the cell pill stays clean and the label sits in the
            // strip between the chord row and whatever's above it.
            // Each label is measured once with an off-screen draw
            // and trimmed character-by-character from the end if it
            // would otherwise spill outside its cell width — that
            // keeps long ordered-brief labels from bleeding into
            // neighbouring cells.
            if let Some(text) = self.text_renderer.as_mut() {
                let label_font = layout.label_font;
                let max_w = cell_d as f32;
                for i in 0..10 {
                    if labels[i].is_empty() {
                        continue;
                    }
                    let (cx_cell, _cy_cell) = cell_centres[i];

                    // Trim to fit. Off-screen measure pass: cosmic-
                    // text's bounds check skips per-pixel writes at
                    // far-negative coords, so this is effectively a
                    // measure with no rendering cost.
                    let mut display: String = labels[i].clone();
                    let mut measured = text.draw_text_left_u32(
                        pixels,
                        width,
                        &display,
                        -1.0e6,
                        -1.0e6,
                        label_font,
                        500,
                        0,
                        "Bona Nova",
                        false,
                    );
                    while measured > max_w && display.chars().count() > 1 {
                        let mut chars: Vec<char> = display.chars().collect();
                        chars.pop();
                        display = chars.into_iter().collect();
                        measured = text.draw_text_left_u32(
                            pixels,
                            width,
                            &display,
                            -1.0e6,
                            -1.0e6,
                            label_font,
                            500,
                            0,
                            "Bona Nova",
                            false,
                        );
                    }

                    text.draw_text_center_u32(
                        pixels,
                        width,
                        &display,
                        cx_cell as f32,
                        layout.label_cy,
                        label_font,
                        500,
                        theme::CELL_LABEL,
                        "Bona Nova",
                        false,
                    );
                }
            }
        }

        // Debug overlays — photon-parity visualisations toggled with
        // Ctrl+H / Ctrl+T. Drawn LAST so they cover any UI underneath.
        if self.tutor_debug_hit_test {
            for (idx, &id) in self.tutor_hit_test.iter().enumerate() {
                if let Some(&(r, g, b)) = self.tutor_debug_hit_colours.get(id as usize) {
                    pixels[idx] = 0xFF00_0000 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32);
                }
            }
        }
        if self.tutor_show_textbox_mask {
            for (idx, &a) in self.tutor_textbox_mask.iter().enumerate() {
                let v = a as u32;
                pixels[idx] = 0xFF00_0000 | (v << 16) | (v << 8) | v;
            }
        }

        // Top-left zoom-percentage hint. Bumped to `now + 1s` on
        // every ru change (Ctrl+= / Ctrl+- / Ctrl+0 / Ctrl+scroll);
        // about_to_wait schedules a wake at the deadline so this
        // disappears on its own. Photon-parity with theme::ZOOM_HINT_TEXT
        // for the colour.
        if let Some(until) = self.tutor_zoom_hint_until {
            if std::time::Instant::now() < until {
                if let Some(text) = self.text_renderer.as_mut() {
                    let span = crate::tutor::ui::span(size.width, size.height);
                    let hint_size = (span / 24.0).max(10.0);
                    let zoom_text = format!("{:.0}%", self.tutor_ru * 100.0);
                    let chrome_h = (span / 16.0).max(8.0);
                    text.draw_text_left_u32(
                        pixels,
                        width,
                        &zoom_text,
                        hint_size,
                        chrome_h + hint_size,
                        hint_size,
                        500,
                        theme::ZOOM_HINT_TEXT,
                        "Bona Nova",
                        false,
                    );
                }
            } else {
                self.tutor_zoom_hint_until = None;
            }
        }

        // Debug HUD — counters + current zoom. Visible when Ctrl+D
        // toggled the global debug flag. Photon's layout: bottom strip
        // with R/U/F counters + top-left zoom percentage.
        self.tutor_redraw_counter += 1;
        self.tutor_frame_counter += 1;
        if self.tutor_debug {
            if let Some(text) = self.text_renderer.as_mut() {
                let span = crate::tutor::ui::span(size.width, size.height);
                let counter_size = (span / 24.0).max(10.0);
                let strip_h = (counter_size * 2.0) as i32;

                // Dark strip along the bottom for contrast.
                let stride = width;
                let start_row = (height as i32 - strip_h).max(0) as usize;
                for y in start_row..height {
                    let base = y * stride;
                    for x in 0..width {
                        let p = pixels[base + x];
                        pixels[base + x] = (p >> 1 & 0xFF7F_7F7F) | 0xFF00_0000;
                    }
                }

                let y = height as f32 - counter_size;
                let redraw_text = format!("R:{}", self.tutor_redraw_counter);
                let frame_text = format!("F:{}", self.tutor_frame_counter);
                let zoom_text = format!("ru:{:.0}%", self.tutor_ru * 100.0);
                text.draw_text_left_u32(
                    pixels,
                    width,
                    &redraw_text,
                    counter_size,
                    y,
                    counter_size,
                    400,
                    theme::COUNTER_TEXT,
                    "Bona Nova",
                    false,
                );
                text.draw_text_center_u32(
                    pixels,
                    width,
                    &zoom_text,
                    width as f32 / 2.0,
                    y,
                    counter_size,
                    400,
                    theme::COUNTER_TEXT,
                    "Bona Nova",
                    false,
                );
                text.draw_text_right_u32(
                    pixels,
                    width,
                    &frame_text,
                    width as f32 - counter_size,
                    y,
                    counter_size,
                    400,
                    theme::COUNTER_TEXT,
                    "Bona Nova",
                    false,
                );
            }
        }

        let _ = buf.present();
    }

    /// Photon-parity resize-edge detection. Returns Some(direction) if the cursor is inside the `span/32` border strip on any side or corner of the window. Mirrors photon's `get_resize_edge`.
    fn resize_edge_at_cursor(&self) -> Option<ResizeDirection> {
        // Chrome buttons take priority — no resize over close/max/min.
        let hit = self.hit_at_cursor();
        if hit == HIT_CLOSE_BUTTON || hit == HIT_MAXIMIZE_BUTTON || hit == HIT_MINIMIZE_BUTTON {
            return None;
        }
        let window = self.tutor_window.as_ref()?;
        let size = window.inner_size();
        let x = self.tutor_cursor.x as f32;
        let y = self.tutor_cursor.y as f32;
        let border = (crate::tutor::ui::span(size.width, size.height) / 32.0).ceil();
        let at_left = x < border;
        let at_right = x > size.width as f32 - border;
        let at_top = y < border;
        let at_bottom = y > size.height as f32 - border;
        let dir = if at_top && at_left {
            ResizeDirection::NorthWest
        } else if at_top && at_right {
            ResizeDirection::NorthEast
        } else if at_bottom && at_left {
            ResizeDirection::SouthWest
        } else if at_bottom && at_right {
            ResizeDirection::SouthEast
        } else if at_top {
            ResizeDirection::North
        } else if at_bottom {
            ResizeDirection::South
        } else if at_left {
            ResizeDirection::West
        } else if at_right {
            ResizeDirection::East
        } else {
            return None;
        };
        Some(dir)
    }

    /// Compute and apply the new window size/position from the current cursor vs the drag start point. Ported from Photon's apply_resize.
    fn apply_resize(&self) {
        let Some(window) = self.tutor_window.as_ref() else {
            return;
        };
        let Some(window_pos) = window.outer_position().ok() else {
            return;
        };
        let cur_x = window_pos.x as f64 + self.tutor_cursor.x;
        let cur_y = window_pos.y as f64 + self.tutor_cursor.y;
        let dx = (cur_x - self.tutor_drag_start_screen_pos.0) as f32;
        let dy = (cur_y - self.tutor_drag_start_screen_pos.1) as f32;
        let min = 128f32;
        let (sw, sh) = self.tutor_drag_start_size;
        let (wx, wy) = self.tutor_drag_start_window_pos;

        let (nw, nh, do_move, nx, ny) = match self.tutor_resize_edge {
            Some(ResizeDirection::East) => ((sw as f32 + dx).max(min) as u32, sh, false, 0, 0),
            Some(ResizeDirection::West) => {
                let w = (sw as f32 - dx).max(min) as u32;
                (w, sh, true, wx + sw as i32 - w as i32, wy)
            }
            Some(ResizeDirection::South) => (sw, (sh as f32 + dy).max(min) as u32, false, 0, 0),
            Some(ResizeDirection::North) => {
                let h = (sh as f32 - dy).max(min) as u32;
                (sw, h, true, wx, wy + sh as i32 - h as i32)
            }
            Some(ResizeDirection::NorthEast) => {
                let w = (sw as f32 + dx).max(min) as u32;
                let h = (sh as f32 - dy).max(min) as u32;
                (w, h, true, wx, wy + sh as i32 - h as i32)
            }
            Some(ResizeDirection::NorthWest) => {
                let w = (sw as f32 - dx).max(min) as u32;
                let h = (sh as f32 - dy).max(min) as u32;
                (
                    w,
                    h,
                    true,
                    wx + sw as i32 - w as i32,
                    wy + sh as i32 - h as i32,
                )
            }
            Some(ResizeDirection::SouthEast) => {
                let w = (sw as f32 + dx).max(min) as u32;
                let h = (sh as f32 + dy).max(min) as u32;
                (w, h, false, 0, 0)
            }
            Some(ResizeDirection::SouthWest) => {
                let w = (sw as f32 - dx).max(min) as u32;
                let h = (sh as f32 + dy).max(min) as u32;
                (w, h, true, wx + sw as i32 - w as i32, wy)
            }
            _ => (sw, sh, false, 0, 0),
        };

        if do_move {
            let _ = window.set_outer_position(PhysicalPosition::new(nx, ny));
        }
        let _ = window.request_inner_size(PhysicalSize::new(nw, nh));
    }

    /// macOS: poll mouse position and button state directly from AppKit. winit stops delivering CursorMoved when the cursor leaves the window during a drag, so we query NSEvent directly. Returns true if the drag ended (caller should request redraw).
    #[cfg(target_os = "macos")]
    fn poll_macos_drag(&mut self) -> bool {
        use std::ffi::{c_char, c_void};

        let Some(window) = self.tutor_window.as_ref() else {
            return false;
        };

        #[repr(C)]
        #[derive(Clone, Copy)]
        struct NSPoint {
            x: f64,
            y: f64,
        }

        unsafe extern "C" {
            fn objc_msgSend(receiver: *const c_void, sel: *const c_void) -> usize;
            fn sel_registerName(name: *const c_char) -> *const c_void;
            fn objc_getClass(name: *const c_char) -> *const c_void;
        }

        unsafe {
            let cls = objc_getClass(b"NSEvent\0".as_ptr() as *const c_char);
            let sel_loc = sel_registerName(b"mouseLocation\0".as_ptr() as *const c_char);
            let mouse_location: extern "C" fn(*const c_void, *const c_void) -> NSPoint =
                std::mem::transmute(objc_msgSend as *const ());
            let ns_point = mouse_location(cls, sel_loc);

            let sel_btn = sel_registerName(b"pressedMouseButtons\0".as_ptr() as *const c_char);
            let buttons = objc_msgSend(cls, sel_btn);
            let left_held = buttons & 1 != 0;

            // Convert AppKit coords (logical, bottom-left) → physical top-left
            let scale = window.scale_factor();
            let screen_cls = objc_getClass(b"NSScreen\0".as_ptr() as *const c_char);
            let sel_main = sel_registerName(b"mainScreen\0".as_ptr() as *const c_char);
            let main_screen = objc_msgSend(screen_cls, sel_main) as *const c_void;
            #[repr(C)]
            #[derive(Clone, Copy)]
            struct NSRect {
                origin: NSPoint,
                size: NSPoint,
            }
            let sel_frame = sel_registerName(b"frame\0".as_ptr() as *const c_char);
            let screen_frame: extern "C" fn(*const c_void, *const c_void) -> NSRect =
                std::mem::transmute(objc_msgSend as *const ());
            let frame = screen_frame(main_screen, sel_frame);
            let screen_h = (frame.size.y * scale) as f64;

            let phys_x = ns_point.x * scale;
            let phys_y = screen_h - ns_point.y * scale;

            if let Ok(wp) = window.outer_position() {
                self.tutor_cursor =
                    PhysicalPosition::new(phys_x - wp.x as f64, phys_y - wp.y as f64);
            }

            if !left_held {
                self.tutor_dragging_resize = false;
                self.tutor_dragging_move = false;
                self.tutor_mouse_pressed = false;
                self.tutor_resize_edge = None;
                return true;
            }

            if self.tutor_dragging_resize {
                self.apply_resize();
            } else if self.tutor_dragging_move {
                if let Ok(wp) = window.outer_position() {
                    let sx = wp.x as f64 + self.tutor_cursor.x;
                    let sy = wp.y as f64 + self.tutor_cursor.y;
                    let dx = (sx - self.tutor_drag_start_screen_pos.0) as i32;
                    let dy = (sy - self.tutor_drag_start_screen_pos.1) as i32;
                    let nx = self.tutor_drag_start_window_pos.0 + dx;
                    let ny = self.tutor_drag_start_window_pos.1 + dy;
                    let _ = window.set_outer_position(PhysicalPosition::new(nx, ny));
                }
            }
        }
        false
    }

    fn handle_keyboard(&mut self, event: KeyEvent) {
        // Track Ctrl via the key event directly — ModifiersChanged can
        // skip events on some Wayland compositors until after the
        // window has held keyboard focus for a bit.
        if matches!(event.logical_key, Key::Named(NamedKey::Control)) {
            self.tutor_ctrl_held = event.state == ElementState::Pressed;
        }
        if event.state != ElementState::Pressed {
            return;
        }
        let ctrl = self.tutor_mods.control_key() || self.tutor_ctrl_held;
        // Plain Enter (no modifiers) wipes the tutor log so the user
        // can isolate a single repro attempt. Only fires while the
        // tutor window has focus, so it doesn't clobber log state
        // when the user hits Enter elsewhere on the desktop.
        if !ctrl
            && !self.tutor_mods.shift_key()
            && !self.tutor_mods.alt_key()
            && !self.tutor_mods.super_key()
            && matches!(event.logical_key, Key::Named(NamedKey::Enter))
        {
            crate::log::reset();
            return;
        }
        if !ctrl {
            return;
        }
        let Key::Character(c) = &event.logical_key else {
            return;
        };

        // rhe's evdev grab eats the home-row letters (A/S/D/F/G/H/J/K/L/;)
        // as chord roles, so photon's default Ctrl+D / Ctrl+H never
        // reach this handler while rhe is enabled. We use digit keys
        // for the primary shortcuts so they pass through the grab
        // untouched. Letter aliases are kept as a courtesy for muscle
        // memory from photon — they work whenever rhe is disabled.
        //
        //   Ctrl+1 / Ctrl+D   debug counters HUD
        //   Ctrl+2 / Ctrl+H   hit-map colour overlay
        //   Ctrl+3 / Ctrl+T   textbox mask overlay
        //   Ctrl+= or +       zoom in
        //   Ctrl+-            zoom out
        //   Ctrl+0            reset zoom
        let matched = if c == "1" || c.eq_ignore_ascii_case("d") {
            self.tutor_debug = !self.tutor_debug;
            true
        } else if c == "2" || c.eq_ignore_ascii_case("h") {
            self.tutor_debug_hit_test = !self.tutor_debug_hit_test;
            self.tutor_show_textbox_mask = false;
            if self.tutor_debug_hit_test {
                // Cycle colours each toggle so regions are
                // distinguishable across presses.
                self.tutor_debug_hit_colours.clear();
                let mut seed: u32 = self.tutor_debug_colour_seed;
                for _ in 0..=255u8 {
                    seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
                    let r = (seed >> 16) as u8;
                    let g = (seed >> 8) as u8;
                    let b = seed as u8;
                    self.tutor_debug_hit_colours.push((r, g, b));
                }
                self.tutor_debug_colour_seed = seed;
            }
            true
        } else if c == "3" || c.eq_ignore_ascii_case("t") {
            self.tutor_show_textbox_mask = !self.tutor_show_textbox_mask;
            self.tutor_debug_hit_test = false;
            true
        } else if c == "=" || c == "+" {
            self.tutor_ru = (self.tutor_ru * crate::tutor::ui::layout::ZOOM_STEP).clamp(
                crate::tutor::ui::layout::ZOOM_MIN,
                crate::tutor::ui::layout::ZOOM_MAX,
            );
            self.bump_zoom_hint();
            true
        } else if c == "-" {
            self.tutor_ru = (self.tutor_ru / crate::tutor::ui::layout::ZOOM_STEP).clamp(
                crate::tutor::ui::layout::ZOOM_MIN,
                crate::tutor::ui::layout::ZOOM_MAX,
            );
            self.bump_zoom_hint();
            true
        } else if c == "0" {
            self.tutor_ru = 1.0;
            self.bump_zoom_hint();
            true
        } else {
            false
        };
        if matched {
            if let Some(w) = self.tutor_window.as_ref() {
                w.request_redraw();
            }
        }
    }

    /// Read the hit-test map at the current cursor position. Returns 0 (HIT_NONE) when cursor is out of bounds.
    fn hit_at_cursor(&self) -> u8 {
        let Some(window) = self.tutor_window.as_ref() else {
            return HIT_NONE;
        };
        let size = window.inner_size();
        let width = size.width as usize;
        let height = size.height as usize;
        let x = self.tutor_cursor.x as i32;
        let y = self.tutor_cursor.y as i32;
        if x < 0 || y < 0 || x >= width as i32 || y >= height as i32 {
            return HIT_NONE;
        }
        let idx = y as usize * width + x as usize;
        self.tutor_hit_test.get(idx).copied().unwrap_or(HIT_NONE)
    }

    /// Swap the active drill source to the contents of a dropped text file. Returns `Err` with a user-facing message on any failure (oversize / unreadable / not UTF-8 / empty); caller logs. Mirrors `photon::ui::app::handle_dropped_file`'s pattern: pure parsing in a free fn (`parse_dropped_drill_text`), state mutation kept out of the parse step so the parser is unit-testable.
    fn load_dropped_text_file(&mut self, path: std::path::PathBuf) -> Result<(), String> {
        let lines = read_and_parse_drill_file(&path)?;

        if self.tutor_word_lookup.is_none() {
            let cmudict = crate::data::load_cmudict();
            self.tutor_word_lookup = Some(WordLookup::new(&cmudict));
            self.tutor_brief_table = Some(crate::briefs::load_briefs());
        }
        let lookup = self
            .tutor_word_lookup
            .as_ref()
            .ok_or("word lookup not initialised")?;
        let briefs = self
            .tutor_brief_table
            .as_ref()
            .ok_or("brief table not initialised")?;
        let practice = build_practice(lookup, briefs, lines, false);
        self.tutor_state = Some(TutorState::new(practice));
        self.tutor_wiki_stream = None;
        if let Some(w) = self.tutor_window.as_ref() {
            w.request_redraw();
        }
        Ok(())
    }

    fn on_menu_click(&mut self, event_loop: &ActiveEventLoop, id: MenuId) {
        if id == self.ids.tutor {
            self.open_tutor(event_loop);
        } else if id == self.ids.test_text {
            self.open_tutor(event_loop);
            if self.tutor_word_lookup.is_none() {
                let cmudict = crate::data::load_cmudict();
                self.tutor_word_lookup = Some(WordLookup::new(&cmudict));
                self.tutor_brief_table = Some(crate::briefs::load_briefs());
            }
            if let (Some(lookup), Some(briefs)) = (
                self.tutor_word_lookup.as_ref(),
                self.tutor_brief_table.as_ref(),
            ) {
                let lines: Vec<String> = crate::tutor::drill::TEST_SENTENCES
                    .iter()
                    .map(|s| s.to_string())
                    .collect();
                let practice = build_practice(lookup, briefs, lines, true);
                self.tutor_state = Some(TutorState::new(practice));
                self.tutor_wiki_stream = None;
                if let Some(w) = self.tutor_window.as_ref() {
                    w.request_redraw();
                }
            }
        } else if id == self.ids.brown_corpus {
            self.open_tutor(event_loop);
            if self.tutor_word_lookup.is_none() {
                let cmudict = crate::data::load_cmudict();
                self.tutor_word_lookup = Some(WordLookup::new(&cmudict));
                self.tutor_brief_table = Some(crate::briefs::load_briefs());
            }
            if let (Some(lookup), Some(briefs)) = (
                self.tutor_word_lookup.as_ref(),
                self.tutor_brief_table.as_ref(),
            ) {
                let lines = crate::tutor::drill::brown_corpus_lines();
                let practice = build_practice(lookup, briefs, lines, false);
                self.tutor_state = Some(TutorState::new(practice));
                self.tutor_wiki_stream = None;
                if let Some(w) = self.tutor_window.as_ref() {
                    w.request_redraw();
                }
            }
        } else if id == self.ids.mode {
            let current = FallbackMode::from_u8(self.fallback.load(Ordering::Relaxed));
            let next = match current {
                FallbackMode::Autospell => FallbackMode::Ipa,
                FallbackMode::Ipa => FallbackMode::Autospell,
            };
            self.fallback.store(next.as_u8(), Ordering::Relaxed);
            #[cfg(any(target_os = "macos", target_os = "windows"))]
            if let Some(state) = self.mac_state.as_mut() {
                state.mode_item.set_text(match next {
                    FallbackMode::Autospell => "Autospell",
                    FallbackMode::Ipa => "IPA",
                });
            }
            // Linux: gtk thread's glib timeout catches the atomic delta.
        } else if id == self.ids.enabled {
            let now = !self.enabled.load(Ordering::Relaxed);
            self.enabled.store(now, Ordering::Relaxed);
            self.refresh_enabled_ui();
        } else if id == self.ids.quit {
            self.quit.store(true, Ordering::Relaxed);
            event_loop.exit();
        }
    }
}

impl ApplicationHandler<TrayEvent> for TrayApp {
    #[cfg(any(target_os = "macos", target_os = "windows"))]
    fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
        // macOS / Windows: build the tray on the event-loop thread so
        // the platform's status-bar item is constructed on the right
        // thread (NSApp main on Mac, message-pump owner on Windows).
        if self.mac_state.is_some() {
            return;
        }
        self.mac_state = Some(build_mac_tray(&self.ids, &self.enabled, &self.fallback));
    }

    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
    fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
        // Linux: tray built on the gtk thread by spawn_linux_tray_thread.
    }

    fn window_event(&mut self, _event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
        let Some(window) = self.tutor_window.as_ref() else {
            return;
        };
        if window.id() != id {
            return;
        }

        match event {
            WindowEvent::CloseRequested => {
                self.close_tutor();
            }
            WindowEvent::Resized(size) => {
                if let Some(renderer) = self.tutor_renderer.as_mut() {
                    renderer.resize(size.width, size.height);
                }
                if let Some(w) = self.tutor_window.as_ref() {
                    w.request_redraw();
                }
            }
            WindowEvent::ModifiersChanged(mods) => {
                self.tutor_mods = mods.state();
            }
            WindowEvent::CursorMoved { position, .. } => {
                self.tutor_cursor = position;

                // Safety net: if we missed a release, clear stale drag.
                if !self.tutor_mouse_pressed {
                    self.tutor_dragging_resize = false;
                    self.tutor_dragging_move = false;
                    self.tutor_resize_edge = None;
                }

                // Active move drag
                if self.tutor_dragging_move {
                    if let Some(w) = self.tutor_window.as_ref() {
                        if let Ok(wp) = w.outer_position() {
                            let sx = wp.x as f64 + position.x;
                            let sy = wp.y as f64 + position.y;
                            let dx = (sx - self.tutor_drag_start_screen_pos.0) as i32;
                            let dy = (sy - self.tutor_drag_start_screen_pos.1) as i32;
                            let nx = self.tutor_drag_start_window_pos.0 + dx;
                            let ny = self.tutor_drag_start_window_pos.1 + dy;
                            let _ = w.set_outer_position(PhysicalPosition::new(nx, ny));
                        }
                    }
                    return;
                }

                // Active resize drag
                if self.tutor_dragging_resize {
                    self.apply_resize();
                    return;
                }

                // Hover cursor — chrome buttons → resize edges → default
                if let Some(w) = self.tutor_window.as_ref() {
                    let hit = self.hit_at_cursor();
                    let is_button = hit == HIT_CLOSE_BUTTON
                        || hit == HIT_MAXIMIZE_BUTTON
                        || hit == HIT_MINIMIZE_BUTTON;
                    let icon =
                        if is_button {
                            CursorIcon::Pointer
                        } else {
                            match self.resize_edge_at_cursor() {
                                Some(ResizeDirection::NorthWest)
                                | Some(ResizeDirection::SouthEast) => CursorIcon::NwseResize,
                                Some(ResizeDirection::NorthEast)
                                | Some(ResizeDirection::SouthWest) => CursorIcon::NeswResize,
                                Some(ResizeDirection::North) | Some(ResizeDirection::South) => {
                                    CursorIcon::NsResize
                                }
                                Some(ResizeDirection::East) | Some(ResizeDirection::West) => {
                                    CursorIcon::EwResize
                                }
                                Some(_) | None => CursorIcon::Default,
                            }
                        };
                    w.set_cursor(icon);

                    // Hover fill: track which chrome button (if any)
                    // the cursor is over and request a redraw on
                    // change. The actual delta is applied during
                    // the full redraw_tutor pass over the chrome
                    // pixels, not here — a partial cpu_buffer patch
                    // wasn't marking dirty rows on the renderer, so
                    // the present() silently dropped the change.
                    let new_hover = if is_button { hit } else { HIT_NONE };
                    if new_hover != self.tutor_hovered_button {
                        self.tutor_hovered_button = new_hover;
                        w.request_redraw();
                    }
                }
            }
            WindowEvent::MouseInput { state, button, .. } => {
                if button != MouseButton::Left {
                    return;
                }

                if state == ElementState::Released {
                    self.tutor_dragging_resize = false;
                    self.tutor_dragging_move = false;
                    self.tutor_mouse_pressed = false;
                    self.tutor_resize_edge = None;
                    return;
                }

                // Pressed
                self.tutor_mouse_pressed = true;
                let Some(window) = self.tutor_window.as_ref() else {
                    return;
                };

                // Priority: chrome buttons → resize edges → drag-move.
                let hit = self.hit_at_cursor();
                if hit == HIT_CLOSE_BUTTON {
                    self.close_tutor();
                    return;
                }
                if hit == HIT_MAXIMIZE_BUTTON {
                    let was_max = window.is_maximized();
                    window.set_maximized(!was_max);
                    return;
                }
                if hit == HIT_MINIMIZE_BUTTON {
                    window.set_minimized(true);
                    return;
                }

                // Capture drag start state (shared by resize and move)
                let size = window.inner_size();
                self.tutor_drag_start_size = (size.width, size.height);
                if let Ok(wp) = window.outer_position() {
                    self.tutor_drag_start_window_pos = (wp.x, wp.y);
                    self.tutor_drag_start_screen_pos = (
                        wp.x as f64 + self.tutor_cursor.x,
                        wp.y as f64 + self.tutor_cursor.y,
                    );
                }

                if let Some(dir) = self.resize_edge_at_cursor() {
                    self.tutor_dragging_resize = true;
                    self.tutor_resize_edge = Some(dir);
                    return;
                }

                // Nothing hit — drag-move the window.
                self.tutor_dragging_move = true;
            }
            WindowEvent::KeyboardInput { event, .. } => {
                self.handle_keyboard(event);
            }
            WindowEvent::Focused(true) => {
                // Nudge: some compositors don't deliver the initial
                // ModifiersChanged until the window has focus. Request
                // a redraw in case anything depends on mods state.
                if let Some(w) = self.tutor_window.as_ref() {
                    w.request_redraw();
                }
            }
            WindowEvent::MouseWheel { delta, .. } => {
                if self.tutor_mods.control_key() || self.tutor_ctrl_held {
                    let steps = match delta {
                        MouseScrollDelta::LineDelta(_, y) => y,
                        MouseScrollDelta::PixelDelta(p) => (p.y / 50.0) as f32,
                    };
                    if steps != 0.0 {
                        // Zoom-per-notch matches photon's feel (1.1×
                        // per scroll step). Clamped so nobody can zoom
                        // into the abyss.
                        let factor = crate::tutor::ui::layout::ZOOM_STEP.powf(steps);
                        self.tutor_ru = (self.tutor_ru * factor).clamp(
                            crate::tutor::ui::layout::ZOOM_MIN,
                            crate::tutor::ui::layout::ZOOM_MAX,
                        );
                        self.bump_zoom_hint();
                        if let Some(w) = self.tutor_window.as_ref() {
                            w.request_redraw();
                        }
                    }
                }
            }
            WindowEvent::RedrawRequested => {
                self.redraw_tutor();
            }
            WindowEvent::DroppedFile(path) => {
                if let Err(e) = self.load_dropped_text_file(path) {
                    crate::rerror!("drop rejected: {e}");
                }
            }
            _ => {}
        }
    }

    fn user_event(&mut self, event_loop: &ActiveEventLoop, event: TrayEvent) {
        match event {
            TrayEvent::Menu(id) => self.on_menu_click(event_loop, id),
            TrayEvent::StateChanged => self.refresh_enabled_ui(),
            TrayEvent::DrillKey(ev) => {
                if let Some(state) = self.tutor_state.as_mut() {
                    state.tick(ev);
                    self.maybe_swap_practice();
                    if let Some(w) = self.tutor_window.as_ref() {
                        w.request_redraw();
                    }
                }
            }
        }

        if self.quit.load(Ordering::Relaxed) {
            event_loop.exit();
        }
    }

    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
        if self.quit.load(Ordering::Relaxed) {
            event_loop.exit();
            return;
        }

        // macOS: poll mouse during resize/move drag — winit stops
        // delivering CursorMoved when the cursor leaves the window.
        #[cfg(target_os = "macos")]
        if self.tutor_dragging_resize || self.tutor_dragging_move {
            let ended = self.poll_macos_drag();
            if ended {
                if let Some(w) = self.tutor_window.as_ref() {
                    w.request_redraw();
                }
            }
            event_loop.set_control_flow(ControlFlow::WaitUntil(
                std::time::Instant::now() + std::time::Duration::from_millis(8),
            ));
            return;
        }

        // Zoom hint auto-fade. While the hint is armed, schedule a
        // wake at its deadline so we redraw and clear it without
        // any user input. If the deadline already passed, kick a
        // redraw immediately so the next frame draws without it.
        if let Some(until) = self.tutor_zoom_hint_until {
            let now = std::time::Instant::now();
            if now >= until {
                self.tutor_zoom_hint_until = None;
                if let Some(w) = self.tutor_window.as_ref() {
                    w.request_redraw();
                }
            } else {
                event_loop.set_control_flow(ControlFlow::WaitUntil(until));
                return;
            }
        }

        event_loop.set_control_flow(ControlFlow::Wait);
    }
}

/// Hard cap on dropped-file size. Sized for "any reasonable book" (War and Peace ≈ 3 MiB plain text, the entire King James ≈ 4 MiB) with headroom; rejects accidental log/dump drops before they OOM the tutor.
const MAX_DROPPED_FILE_BYTES: u64 = 16 * 1024 * 1024;

/// Read a dropped file from disk and parse it into drill lines. Stat → read → parse, with the size cap enforced before reading. Errors are user-facing strings the caller can log or surface in the UI.
fn read_and_parse_drill_file(path: &std::path::Path) -> Result<Vec<String>, String> {
    let md = std::fs::metadata(path)
        .map_err(|e| format!("cannot stat {}: {e}", path.display()))?;
    if md.len() > MAX_DROPPED_FILE_BYTES {
        return Err(format!(
            "{} is {} bytes, exceeds {} MiB cap",
            path.display(),
            md.len(),
            MAX_DROPPED_FILE_BYTES / (1024 * 1024)
        ));
    }
    let bytes =
        std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
    parse_dropped_drill_text(&bytes).map_err(|e| format!("{}: {e}", path.display()))
}

/// Pure parser: bytes → drill lines. UTF-8 with BOM tolerance, blank lines and surrounding whitespace stripped. Returns `Err` for invalid UTF-8 or zero usable lines. Pulled out of the I/O path so it can be unit-tested without touching the filesystem.
fn parse_dropped_drill_text(bytes: &[u8]) -> Result<Vec<String>, String> {
    let text = std::str::from_utf8(bytes).map_err(|e| format!("not valid UTF-8: {e}"))?;
    let lines: Vec<String> = text
        .trim_start_matches('\u{feff}')
        .lines()
        .map(|l| l.trim().to_string())
        .filter(|l| !l.is_empty())
        .collect();
    if lines.is_empty() {
        return Err("contains no usable lines".to_string());
    }
    Ok(lines)
}

/// Forwarder thread: pipe `tray-icon`'s internal menu-click channel into the winit event loop. Runs on any platform — the menu-event receiver itself is thread-agnostic.
fn spawn_menu_forwarder(proxy: TrayProxy) {
    std::thread::spawn(move || {
        let rx = MenuEvent::receiver();
        while let Ok(event) = rx.recv() {
            if proxy.send_event(TrayEvent::Menu(event.id)).is_err() {
                break;
            }
        }
    });
}

#[cfg(any(target_os = "macos", target_os = "windows"))]
fn build_mac_tray(
    ids: &TrayIds,
    enabled: &Arc<AtomicBool>,
    fallback: &Arc<AtomicU8>,
) -> MacTrayState {
    let icon_off = make_tray_icon(ICON_SIZE, false);
    let icon_on = make_tray_icon(ICON_SIZE, true);

    let initial_enabled = enabled.load(Ordering::Relaxed);
    let initial_fallback = FallbackMode::from_u8(fallback.load(Ordering::Relaxed));
    let is_autospell = initial_fallback == FallbackMode::Autospell;

    // Rebuild MenuItems with the IDs we pre-allocated so that the
    // winit thread can match click events.
    let tutor_item = MenuItem::with_id(ids.tutor.clone(), "Open Tutor", true, None);
    let test_item = MenuItem::with_id(ids.test_text.clone(), "Test Text", true, None);
    let brown_item = MenuItem::with_id(ids.brown_corpus.clone(), "Brown Corpus", true, None);
    let mode_item = MenuItem::with_id(
        ids.mode.clone(),
        if is_autospell { "Autospell" } else { "IPA" },
        true,
        None,
    );
    let enabled_item = MenuItem::with_id(
        ids.enabled.clone(),
        if initial_enabled { "rhe" } else { "keyboard" },
        true,
        None,
    );
    let quit_item = MenuItem::with_id(ids.quit.clone(), "Exit", true, None);

    let menu = Menu::new();
    menu.append(&tutor_item).ok();
    menu.append(&test_item).ok();
    menu.append(&brown_item).ok();
    menu.append(&PredefinedMenuItem::separator()).ok();
    menu.append(&mode_item).ok();
    menu.append(&enabled_item).ok();
    menu.append(&PredefinedMenuItem::separator()).ok();
    menu.append(&quit_item).ok();

    let initial_icon = if initial_enabled {
        icon_on.clone()
    } else {
        icon_off.clone()
    };

    let tray = TrayIconBuilder::new()
        .with_icon(initial_icon)
        .with_tooltip("rhe")
        .with_menu(Box::new(menu))
        .build()
        .expect("failed to build tray icon");
    tray.set_icon_as_template(false);

    MacTrayState {
        tray,
        icon_on,
        icon_off,
        mode_item,
        enabled_item,
    }
}

#[cfg(target_os = "linux")]
fn spawn_linux_tray_thread(
    ids: TrayIds,
    enabled: Arc<AtomicBool>,
    quit: Arc<AtomicBool>,
    fallback: Arc<AtomicU8>,
) {
    std::thread::spawn(move || {
        use gtk::glib;

        gtk::init().expect("gtk::init failed");

        let icon_off = make_tray_icon(ICON_SIZE, false);
        let icon_on = make_tray_icon(ICON_SIZE, true);

        let initial_enabled = enabled.load(Ordering::Relaxed);
        let initial_fallback = FallbackMode::from_u8(fallback.load(Ordering::Relaxed));
        let is_autospell = initial_fallback == FallbackMode::Autospell;

        let tutor_item = MenuItem::with_id(ids.tutor.clone(), "Open Tutor", true, None);
        let test_item = MenuItem::with_id(ids.test_text.clone(), "Test Text", true, None);
        let brown_item = MenuItem::with_id(ids.brown_corpus.clone(), "Brown Corpus", true, None);
        let mode_item = MenuItem::with_id(
            ids.mode.clone(),
            if is_autospell { "Autospell" } else { "IPA" },
            true,
            None,
        );
        let enabled_item = MenuItem::with_id(
            ids.enabled.clone(),
            if initial_enabled { "rhe" } else { "keyboard" },
            true,
            None,
        );
        let quit_item = MenuItem::with_id(ids.quit.clone(), "Exit", true, None);

        let menu = Menu::new();
        menu.append(&tutor_item).ok();
        menu.append(&test_item).ok();
        menu.append(&brown_item).ok();
        menu.append(&PredefinedMenuItem::separator()).ok();
        menu.append(&mode_item).ok();
        menu.append(&enabled_item).ok();
        menu.append(&PredefinedMenuItem::separator()).ok();
        menu.append(&quit_item).ok();

        let initial_icon = if initial_enabled {
            icon_on.clone()
        } else {
            icon_off.clone()
        };

        let tray = TrayIconBuilder::new()
            .with_icon(initial_icon)
            .with_tooltip("rhe")
            .with_menu(Box::new(menu))
            .build()
            .expect("failed to build tray icon");

        // Stateful closure that polls the shared atomics and updates the
        // tray icon + menu labels when they diverge. Runs on this (gtk)
        // thread via glib::timeout_add_local — safe for MenuItem/TrayIcon
        // which are !Send on Linux.
        let mut last_enabled = initial_enabled;
        let mut last_fallback = initial_fallback.as_u8();
        glib::timeout_add_local(std::time::Duration::from_millis(50), move || {
            let e = enabled.load(Ordering::Relaxed);
            if e != last_enabled {
                last_enabled = e;
                enabled_item.set_text(if e { "rhe" } else { "keyboard" });
                let icon = if e { icon_on.clone() } else { icon_off.clone() };
                tray.set_icon(Some(icon)).ok();
            }
            let f = fallback.load(Ordering::Relaxed);
            if f != last_fallback {
                last_fallback = f;
                let fb = FallbackMode::from_u8(f);
                mode_item.set_text(match fb {
                    FallbackMode::Autospell => "Autospell",
                    FallbackMode::Ipa => "IPA",
                });
            }
            if quit.load(Ordering::Relaxed) {
                gtk::main_quit();
                return glib::ControlFlow::Break;
            }
            glib::ControlFlow::Continue
        });

        gtk::main();
    });
}

/// Run the tray event loop on the main thread. Blocks until `quit` is set or the menu's Quit item fires.
///
/// `fallback` is the shared `AtomicU8` the interpreter reads each time it hits the fallback branch — the Fallback submenu writes to it directly.
pub fn run_tray(
    event_loop: EventLoop<TrayEvent>,
    enabled: Arc<AtomicBool>,
    quit: Arc<AtomicBool>,
    fallback: Arc<AtomicU8>,
    mode_flags: Arc<AtomicU8>,
) {
    let ids = TrayIds {
        tutor: MenuId::new("rhe.tutor"),
        test_text: MenuId::new("rhe.test_text"),
        brown_corpus: MenuId::new("rhe.brown_corpus"),
        mode: MenuId::new("rhe.mode"),
        enabled: MenuId::new("rhe.enabled"),
        quit: MenuId::new("rhe.quit"),
    };

    spawn_menu_forwarder(event_loop.create_proxy());

    #[cfg(target_os = "linux")]
    spawn_linux_tray_thread(ids.clone(), enabled.clone(), quit.clone(), fallback.clone());

    let mut app = TrayApp {
        enabled,
        quit,
        fallback,
        mode_flags,
        ids,
        #[cfg(any(target_os = "macos", target_os = "windows"))]
        mac_state: None,
        tutor_renderer: None,
        tutor_window: None,
        tutor_state: None,
        text_renderer: None,
        tutor_wiki_stream: None,
        tutor_word_lookup: None,
        tutor_brief_table: None,
        tutor_ru: 1.0,
        tutor_zoom_hint_until: None,
        tutor_mods: ModifiersState::empty(),
        tutor_cursor: PhysicalPosition::new(0.0, 0.0),
        tutor_hit_test: Vec::new(),
        tutor_textbox_mask: Vec::new(),
        tutor_debug: false,
        tutor_debug_hit_test: false,
        tutor_show_textbox_mask: false,
        tutor_debug_hit_colours: Vec::new(),
        tutor_debug_colour_seed: 0x9E3779B9,
        tutor_hovered_button: HIT_NONE,
        tutor_dragging_resize: false,
        tutor_dragging_move: false,
        tutor_mouse_pressed: false,
        tutor_resize_edge: None,
        tutor_drag_start_size: (0, 0),
        tutor_drag_start_window_pos: (0, 0),
        tutor_drag_start_screen_pos: (0.0, 0.0),
        tutor_frame_counter: 0,
        tutor_redraw_counter: 0,
        tutor_ctrl_held: false,
    };

    event_loop.run_app(&mut app).ok();
}

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

    #[test]
    fn parse_drops_blank_lines_and_trims() {
        let bytes = b"  first line  \n\nsecond\n   \nthird\n";
        let lines = parse_dropped_drill_text(bytes).unwrap();
        assert_eq!(lines, vec!["first line", "second", "third"]);
    }

    #[test]
    fn parse_strips_utf8_bom() {
        let mut bytes = vec![0xEF, 0xBB, 0xBF];
        bytes.extend_from_slice(b"hello\nworld\n");
        let lines = parse_dropped_drill_text(&bytes).unwrap();
        assert_eq!(lines, vec!["hello", "world"]);
    }

    #[test]
    fn parse_rejects_invalid_utf8() {
        let bytes = [0xFFu8, 0xFE, 0xFD];
        assert!(parse_dropped_drill_text(&bytes).is_err());
    }

    #[test]
    fn parse_rejects_empty_input() {
        assert!(parse_dropped_drill_text(b"").is_err());
        assert!(parse_dropped_drill_text(b"   \n\n  \n").is_err());
    }

    #[test]
    fn parse_keeps_unicode_lines() {
        let bytes = "naïve café\nrésumé\n".as_bytes();
        let lines = parse_dropped_drill_text(bytes).unwrap();
        assert_eq!(lines, vec!["naïve café", "résumé"]);
    }
}

