//! RustFig desktop and web UI (layer L6), built on egui.
//!
//! The UI is thin: panels read engine state and act through
//! [`App::run`], which executes engine commands or the UI-only commands in
//! [`menus::UI_COMMANDS`].

pub mod app_icon;
pub mod attributes;
#[cfg(not(target_arch = "wasm32"))]
pub mod autosave;
pub mod bevel;
pub mod canvas;
pub mod capture;
pub mod charmap;
#[cfg(not(target_arch = "wasm32"))]
pub mod clipboard;
pub mod color_dialog;
pub mod command_search;
pub mod context_help;
#[cfg(not(target_arch = "wasm32"))]
pub mod control;
pub mod dialogs;
pub mod digitize;
pub mod dimline_panel;
#[cfg(not(target_arch = "wasm32"))]
pub mod export_bg;
pub mod file_panel;
pub mod font_chooser;
pub mod help;
pub mod help_pages;
pub mod inject;
pub mod input;
pub mod key_font;
pub mod keys_sheet;
pub mod library;
pub mod menus;
pub mod mode_icons;
pub mod panels;
pub mod platform;
pub mod plot_dialog;
pub mod point_entry;
pub mod print_dialog;
pub mod reload;
pub mod rulers;
pub mod screen_pick;
pub mod settings;

/// Listen for the system's "open this file" requests (Finder double-click, `open -a`); call
/// before the event loop starts (P3). A no-op except on macOS.
pub fn install_open_handler() {
    rustfig_os::open_docs::install_open_handler();
}
pub mod spell_dialog;
pub mod styles;
#[cfg(not(target_arch = "wasm32"))]
pub mod tex_gyre;
pub mod theme;
pub mod vector_icons;
pub mod web_autosave;
#[cfg(target_arch = "wasm32")]
pub mod web_fetch;
pub mod web_io;
pub mod xfig_icons;

use rustfig_engine::{Engine, UiRequest};
use serde_json::{json, Value};

/// Most messages kept in the message panel's history.
pub const MAX_MESSAGES: usize = 200;

/// A message shown in the message panel / status bar.
#[derive(Debug, Clone)]
pub struct Message {
    pub text: String,
    pub error: bool,
}

/// What to do once the user has dealt with unsaved changes.
#[derive(Debug, Clone, PartialEq)]
pub enum Pending {
    New,
    Open(Option<String>),
    /// A file the browser picked or dropped (C2).
    OpenWeb(web_io::WebPick),
    Quit,
}

/// The application.
pub struct App {
    pub engine: Engine,
    pub canvas: canvas::CanvasState,
    pub dialogs: dialogs::Dialogs,
    /// The latest message.
    pub message: Option<Message>,
    /// Message history, oldest first.
    pub messages: Vec<Message>,
    pub show_depths: bool,
    /// xfig's "Show info balloons" (Meta-Y): tooltips on or off.
    pub show_balloons: bool,
    /// A theme to switch to on the next frame (`app.theme`).
    pub theme_request: Option<egui::ThemePreference>,
    /// The screen capture command and its thread (D11).
    pub capture: capture::Capture,
    /// E9: the theme setting, "system", "light" or "dark" (persisted).
    pub theme: String,
    /// E9: the UI scale, 1.0 = 100 % (persisted).
    pub ui_scale: f32,
    /// Button style: bevelled "3D" buttons (default) or flat ones.
    pub button_3d: bool,
    /// xfig's mode menu popped up by Alt+wheel over the canvas: drawing
    /// (wheel up) or editing modes, at the pointer.
    pub mode_popup: Option<(bool, egui::Pos2)>,
    /// Crash-recovery copies of the drawing (A6); `None` until enabled.
    #[cfg(not(target_arch = "wasm32"))]
    autosave: Option<autosave::AutoSave>,
    /// Browser autosave (C3); wasm only.
    #[cfg(target_arch = "wasm32")]
    web_autosave: Option<web_autosave::WebAutosave<web_autosave::BrowserStore>>,
    /// A drawing the last browser session left unsaved, offered at load (C3).
    pub web_restorable: Option<web_autosave::Saved>,
    /// Drawings left by instances that stopped without saving, offered at
    /// start: (recovery id, the file they came from).
    pub recoverable: Vec<(String, Option<String>)>,
    /// How long the last frames took on the UI thread (ms), newest last,
    /// at most 120 (native; `ui.state` reports them).
    pub frame_times: std::collections::VecDeque<f64>,
    /// Where the preferences are kept (E1); nowhere until `App::new` or a
    /// test sets one.
    pub settings: settings::Settings,
    /// xfig's compose key while typing text (`menus::compose_text`).
    pub(crate) compose: menus::Compose,
    /// The file-name box while it is a text field: the text being typed.
    pub file_edit: Option<String>,
    /// While dragging across depth checkboxes: the state they are set to.
    depth_drag: Option<bool>,
    /// Mouse hints shown instead of the tool's while the pointer is over a
    /// panel with its own button functions (the attribute buttons).
    pub hint_override: Option<(&'static str, &'static str, &'static str)>,
    /// Alt + click as the middle button, Ctrl + click as the right one (macOS).
    pub ctrl_click: input::CtrlClick,
    /// Linux, Windows: left + right together as the middle button.
    pub chord: input::Chord,
    /// Input queued by the control channel (`ui.pointer`, `ui.key`).
    pub injector: inject::Injector,
    /// Labelled widgets of the last frame (filled once `attach` ran).
    pub widgets: inject::Registry,
    /// Requests waiting for their injected input to be fed: reply, id, result, sequence.
    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
    pub(crate) input_waiters: Vec<(std::sync::mpsc::Sender<String>, Value, Value, u64)>,
    /// xfig's keyboard accelerators (Meta menu keys, Ctrl mode keys).
    pub xfig_keys: bool,
    /// A classic menu to pop up on the next frame (xfig's Meta-F/E/V/H).
    pub open_menu: Option<&'static str>,
    /// The Unit/Scale settings window (right click on the units box).
    pub unit_panel_open: bool,
    /// The Styles window (E6).
    pub styles: styles::StylesWindow,
    /// The Character Map window (E5).
    pub charmap: charmap::CharMap,
    /// Open/Merge with preview (W10-12).
    pub file_panel: file_panel::FilePanel,
    /// The library panel (D6).
    pub library: library::LibraryPanel,
    /// The shortcut sheet (E8).
    pub keys_sheet: keys_sheet::KeysSheet,
    /// Command search (W18).
    pub command_search: command_search::CommandSearch,
    /// F1 context help notes (W18).
    pub context_help: context_help::ContextHelp,
    /// Which text flag the Text Flags button shows (xfig's `cur_flagshown`:
    /// hidden, TeX, rigid; the right and middle buttons cycle).
    pub flag_shown: usize,
    /// The mouse cheat sheet (W18).
    pub mouse_sheet: help_pages::MouseSheet,
    /// The "What's new" window (W18).
    pub whats_new_open: bool,
    /// The typed-coordinates window (xfig's keyboard panel, Ctrl+K).
    pub point_entry: point_entry::PointEntry,
    /// The override set during the previous frame, shown in this one.
    pub hint_shown: Option<(&'static str, &'static str, &'static str)>,
    pub pending: Option<Pending>,
    /// Files the system asked us to open that wait their turn (P3).
    system_open_queue: Vec<std::path::PathBuf>,
    /// The Export panel's export running on its thread (W10-13).
    #[cfg(not(target_arch = "wasm32"))]
    pub export_bg: Option<export_bg::Running>,
    /// "Install TeX Gyre fonts…" running on its thread (F1).
    #[cfg(not(target_arch = "wasm32"))]
    pub tex_gyre: Option<tex_gyre::Running>,
    /// The file the last background export wrote ("Show in folder").
    #[cfg(not(target_arch = "wasm32"))]
    pub last_export: Option<String>,
    /// The Digitize window (S4-8).
    pub digitize: digitize::Digitize,
    /// The Plot dialog (E12).
    pub plot_dialog: plot_dialog::PlotDialog,
    /// Autorefresh mode and the changed-on-disk question (S4-9).
    pub reload: reload::Reload,
    /// The action (quit, new, open) that resumes once the Save All / Save
    /// Part query of an open compound has saved the figure.
    pub after_save: Option<Pending>,
    /// File pickers (native dialogs; replaced in tests).
    pub file_dialogs: Box<dyn dialogs::FileDialogs>,
    /// Files the browser picked, waiting to be opened (C2).
    pub web_inbox: web_io::WebInbox,
    /// A picture the browser picked for the picture panel's Browse/Relink
    /// (kept in memory under this file name); the panel takes it.
    pub web_relink_name: Option<String>,
    quit_confirmed: bool,
    /// Open pop-ups, menus, windows and dialogs as of the last frame (for `ui.state`).
    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
    pub(crate) ui_open: Value,
    #[cfg(not(target_arch = "wasm32"))]
    control: Option<control::Server>,
    #[cfg(not(target_arch = "wasm32"))]
    screenshot_waiters: Vec<(String, std::sync::mpsc::Sender<String>, Value)>,
}

impl App {
    /// Create the app without eframe (used by `new` and by UI tests).
    pub fn with_engine(engine: Engine) -> App {
        App {
            engine,
            canvas: canvas::CanvasState::default(),
            dialogs: dialogs::Dialogs::default(),
            message: None,
            messages: Vec::new(),
            show_depths: true,
            show_balloons: true,
            theme_request: None,
            capture: capture::Capture::default(),
            theme: "system".to_string(),
            ui_scale: 1.0,
            button_3d: true,
            mode_popup: None,
            #[cfg(not(target_arch = "wasm32"))]
            autosave: None,
            #[cfg(target_arch = "wasm32")]
            web_autosave: None,
            web_restorable: None,
            recoverable: Vec::new(),
            frame_times: std::collections::VecDeque::new(),
            settings: settings::Settings::default(),
            compose: menus::Compose::default(),
            file_edit: None,
            depth_drag: None,
            hint_override: None,
            ctrl_click: input::CtrlClick::default(),
            chord: input::Chord::default(),
            injector: inject::Injector::default(),
            widgets: inject::Registry::default(),
            input_waiters: Vec::new(),
            hint_shown: None,
            unit_panel_open: false,
            styles: styles::StylesWindow::default(),
            charmap: charmap::CharMap::default(),
            file_panel: file_panel::FilePanel::default(),
            library: library::LibraryPanel::default(),
            keys_sheet: keys_sheet::KeysSheet::default(),
            command_search: command_search::CommandSearch::default(),
            context_help: context_help::ContextHelp::default(),
            flag_shown: 0,
            mouse_sheet: help_pages::MouseSheet::default(),
            whats_new_open: false,
            point_entry: point_entry::PointEntry::default(),
            xfig_keys: true,
            open_menu: None,
            pending: None,
            system_open_queue: Vec::new(),
            #[cfg(not(target_arch = "wasm32"))]
            export_bg: None,
            #[cfg(not(target_arch = "wasm32"))]
            tex_gyre: None,
            #[cfg(not(target_arch = "wasm32"))]
            last_export: None,
            digitize: digitize::Digitize::default(),
            plot_dialog: plot_dialog::PlotDialog::default(),
            reload: reload::Reload::default(),
            after_save: None,
            file_dialogs: Box::new(dialogs::NativeDialogs),
            web_inbox: web_io::WebInbox::default(),
            web_relink_name: None,
            quit_confirmed: false,
            ui_open: Value::Null,
            #[cfg(not(target_arch = "wasm32"))]
            control: None,
            #[cfg(not(target_arch = "wasm32"))]
            screenshot_waiters: Vec::new(),
        }
    }

    /// Create the app. `control_port` starts the JSON-lines control server (native only).
    pub fn new(cc: &eframe::CreationContext<'_>, engine: Engine, control_port: Option<u16>) -> App {
        cc.egui_ctx.set_theme(egui::ThemePreference::System);
        // Mutated only by the native control server below.
        #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
        let mut app = App::with_engine(engine);
        // TeX Gyre fonts installed earlier (F1) join the font lookup.
        #[cfg(not(target_arch = "wasm32"))]
        tex_gyre::register_installed();
        // Copy as picture (E14) uses the system clipboard; tests and wasm have none.
        #[cfg(not(target_arch = "wasm32"))]
        app.engine
            .set_picture_clipboard(Some(Box::new(clipboard::SystemPictureClipboard::default())));
        // Print (B5, B5b) shows the operating system's print dialog (the browser's viewer on the web).
        app.engine.set_spooler(Box::new(print_dialog::OsSpooler));
        #[cfg(not(target_arch = "wasm32"))]
        if let Some(port) = control_port {
            app.attach(&cc.egui_ctx);
            match control::Server::start(port, cc.egui_ctx.clone()) {
                Ok(s) => {
                    log::info!("control channel listening on 127.0.0.1:{port}");
                    app.control = Some(s);
                }
                Err(e) => {
                    log::error!("control channel: failed to bind 127.0.0.1:{port}: {e}");
                    app.error(format!("Control port {port} unavailable: {e}"));
                }
            }
        }
        #[cfg(target_arch = "wasm32")]
        let _ = control_port;
        #[cfg(target_arch = "wasm32")]
        {
            let mut w = web_autosave::WebAutosave::new(web_autosave::BrowserStore);
            app.web_restorable = w.find();
            app.web_autosave = Some(w);
        }
        #[cfg(not(target_arch = "wasm32"))]
        if let Some(dir) = rustfig_engine::recovery::default_dir() {
            app.enable_recovery(dir);
        }
        app.settings = settings::Settings::platform();
        settings::load_and_apply(&mut app);
        app
    }

    /// Keep crash-recovery copies in `dir` and offer the drawings that
    /// stopped instances left there (A6).
    #[cfg(not(target_arch = "wasm32"))]
    pub fn enable_recovery(&mut self, dir: std::path::PathBuf) {
        self.engine.recovery_dir = Some(dir.clone());
        self.autosave = Some(autosave::AutoSave::new(dir));
        if let Ok(v) = self.engine.execute("file.recoverable", &serde_json::json!({})) {
            self.recoverable = v
                .get("drawings")
                .and_then(serde_json::Value::as_array)
                .into_iter()
                .flatten()
                .filter_map(|d| {
                    let id = d.get("id")?.as_str()?.to_string();
                    let original = d
                        .get("original")
                        .and_then(serde_json::Value::as_str)
                        .map(str::to_string);
                    Some((id, original))
                })
                .collect();
        }
    }

    fn push_message(&mut self, m: Message) {
        self.messages.push(m.clone());
        if self.messages.len() > MAX_MESSAGES {
            let extra = self.messages.len() - MAX_MESSAGES;
            self.messages.drain(..extra);
        }
        self.message = Some(m);
    }

    /// Show what a mouse event made the engine say (`Engine::status`) on
    /// the message line; pointer moves (lengths while drawing) replace the
    /// line without filling the message history.
    pub fn show_tool_status(&mut self, transient: bool) {
        let s = self.engine.status().to_string();
        if s.is_empty() {
            return;
        }
        self.engine.set_status("");
        if transient {
            self.message = Some(Message { text: s, error: false });
        } else {
            self.info(s);
        }
    }

    pub fn info(&mut self, text: impl Into<String>) {
        self.push_message(Message {
            text: text.into(),
            error: false,
        });
    }

    pub fn error(&mut self, text: impl Into<String>) {
        self.push_message(Message {
            text: text.into(),
            error: true,
        });
    }

    /// Run a command (engine or UI-only). Errors are shown in the status bar.
    pub fn run(&mut self, id: &str, params: Value) -> Option<Value> {
        match self.try_run(id, &params) {
            Ok(v) => Some(v),
            Err(e) => {
                self.error(e);
                None
            }
        }
    }

    /// Run a command and return its result or error message.
    pub fn try_run(&mut self, id: &str, params: &Value) -> Result<Value, String> {
        if menus::find_ui(id).is_some() {
            return menus::run_ui(self, id, params);
        }
        let r = self.engine.execute(id, params).map_err(|e| e.message);
        if r.is_ok() && !self.engine.status().is_empty() {
            let s = self.engine.status().to_string();
            self.info(s);
            self.engine.set_status("");
        }
        r
    }

    /// Whether a command is currently enabled.
    pub fn enabled(&self, id: &str) -> bool {
        if let Some(c) = rustfig_engine::cmd::find(id) {
            return (c.enabled)(&self.engine);
        }
        menus::find_ui(id).is_some()
    }

    fn handle_requests(&mut self, ctx: &egui::Context) {
        for r in self.engine.take_requests() {
            match r {
                // The clipboard's text arrives as a paste event next frame,
                // which the text tool types.
                UiRequest::PasteText => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
                UiRequest::EditObject { id } => self.dialogs.open_edit(&self.engine, id),
                UiRequest::OpenLibrary => {
                    let roots = self.engine.library_roots();
                    let source = self
                        .engine
                        .prefs
                        .get("library_dir")
                        .and_then(|v| v.as_str().map(str::to_string))
                        .unwrap_or_else(|| "~/xfiglib".to_string());
                    self.library.start_scan(roots, source);
                }
                UiRequest::PlotDialog { rect, edit } => match edit {
                    None => self
                        .plot_dialog
                        .open_new(rect.map(|r| [r.min.x, r.min.y, r.width(), r.height()])),
                    Some(id) => match self.try_run("plot.get", &json!({"id": id.0})) {
                        Ok(v) => self.plot_dialog.open_edit(&v, id.0),
                        Err(e) => self.error(e),
                    },
                },
                UiRequest::EditFigureComments => self.dialogs.open_figure_comments(&self.engine),
                UiRequest::EditSplinePoint { id, index } => self.dialogs.open_spline_point(&self.engine, id, index),
                // The browser has no paths: its picker answers later (web_io).
                UiRequest::PickPicture { rect, keep_aspect } if self.file_dialogs.is_web() => {
                    self.web_pick(
                        web_io::WebPurpose::PictureBox {
                            rect: [rect.min.x, rect.min.y, rect.width(), rect.height()],
                            keep_aspect,
                        },
                        "Pictures",
                        dialogs::PICTURE_EXTS,
                    );
                }
                UiRequest::PickPicture { rect, keep_aspect } => {
                    if let Some(path) = self.file_dialogs.pick_file("Pictures", dialogs::PICTURE_EXTS) {
                        self.add_picture(
                            &path,
                            [rect.min.x, rect.min.y, rect.width(), rect.height()],
                            keep_aspect,
                        );
                    }
                }
            }
        }
    }

    /// Open the browser's file picker for `purpose`; the file arrives some
    /// frames later in the inbox (C2).
    pub fn web_pick(&mut self, purpose: web_io::WebPurpose, what: &str, exts: &[&str]) {
        self.web_inbox.request(purpose);
        self.file_dialogs.pick_web(what, exts, &self.web_inbox);
    }

    /// Picture mode's result: the picture in the box drawn (`[x, y, w, h]`),
    /// then xfig's edit panel for it.
    pub fn add_picture(&mut self, path: &str, rect: [f64; 4], keep_aspect: bool) {
        let [x, y, w, h] = rect;
        let added = self.run(
            "shape.picture",
            json!({"path": path, "x": x, "y": y, "width": w, "height": h, "keep_aspect": keep_aspect}),
        );
        // xfig opens the picture's edit panel after the box is drawn.
        if let Some(id) = added.and_then(|v| v.get("id").and_then(serde_json::Value::as_u64)) {
            self.dialogs.open_edit(&self.engine, rustfig_model::ObjectId(id));
        }
    }

    /// Ask before discarding unsaved changes.
    /// Whether quitting was confirmed (the window closes on the next frame).
    pub fn quit_requested(&self) -> bool {
        self.quit_confirmed
    }

    pub fn guard_unsaved(&mut self, action: Pending) {
        if self.engine.is_modified() {
            self.pending = Some(action);
        } else {
            self.do_pending(action);
        }
    }

    pub(crate) fn do_pending(&mut self, action: Pending) {
        match action {
            Pending::New => {
                self.run("file.new", json!({}));
                self.engine.set_modified(false);
            }
            Pending::OpenWeb(pick) => web_io::open_pick(self, &pick),
            Pending::Open(path) if path.is_none() && self.file_dialogs.is_web() => {
                self.web_pick(web_io::WebPurpose::Open, "Figures", dialogs::FIGURE_EXTS);
            }
            Pending::Open(path) => {
                let path = path.or_else(|| self.file_dialogs.pick_file("Figures", dialogs::FIGURE_EXTS));
                if let Some(p) = path {
                    dialogs::open_or_merge(self, &p, false, json!({}));
                }
            }
            Pending::Quit => {
                self.quit_confirmed = true;
            }
        }
    }

    fn window_title(&self) -> String {
        let name = self
            .engine
            .path()
            .and_then(|p| std::path::Path::new(p).file_name())
            .and_then(|n| n.to_str())
            .unwrap_or("Untitled");
        format!(
            "{}{} — RustFig",
            name,
            if self.engine.is_modified() { " •" } else { "" }
        )
    }
}

impl eframe::App for App {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        self.ui(ctx);
    }

    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
        self.feed_input(raw_input);
        if self.chord.waiting() {
            // A held-back press must get out after the chord interval.
            ctx.request_repaint_after(std::time::Duration::from_secs_f64(input::CHORD_INTERVAL));
        }
    }

    fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
        // Unsaved work the user didn't discard keeps its recovery copy.
        #[cfg(not(target_arch = "wasm32"))]
        if let Some(a) = &mut self.autosave {
            if self.quit_confirmed || !self.engine.is_modified() {
                a.finish();
            }
        }
    }
}

impl App {
    /// Start collecting the labelled widgets for `ui.state` and `ui.click_label`.
    pub fn attach(&mut self, ctx: &egui::Context) {
        self.widgets = inject::install_registry(ctx);
    }

    /// Add the next injected input step to a frame's raw input, then apply the
    /// platform conventions (what `raw_input_hook` does for every frame).
    pub fn feed_input(&mut self, raw: &mut egui::RawInput) {
        self.injector.feed(raw);
        self.ctrl_click.apply(raw, platform::is_mac());
        if !platform::is_mac() {
            self.chord.apply(raw);
        }
    }

    /// Run one UI frame (guarded against panics).
    pub fn ui(&mut self, ctx: &egui::Context) {
        key_font::install(ctx);
        platform::note_os(ctx);
        #[cfg(not(target_arch = "wasm32"))]
        let start = std::time::Instant::now();
        let frame_result = rustfig_engine::guard::catch(|| self.frame(ctx));
        #[cfg(not(target_arch = "wasm32"))]
        {
            if self.frame_times.len() >= 120 {
                self.frame_times.pop_front();
            }
            self.frame_times.push_back(start.elapsed().as_secs_f64() * 1000.0);
        }
        if let Err(msg) = frame_result {
            // xfig's emergency save: keep the work before reporting.
            #[cfg(not(target_arch = "wasm32"))]
            if let Some(a) = &mut self.autosave {
                a.emergency(&self.engine);
            }
            #[cfg(target_arch = "wasm32")]
            {
                let (modified, rev) = (self.engine.is_modified(), self.engine.revision());
                if let Some(w) = &mut self.web_autosave {
                    let engine = &mut self.engine;
                    w.emergency(modified, rev, || web_fig(engine));
                }
            }
            self.error(format!("internal error (please report): {msg}"));
        }
    }

    fn frame(&mut self, ctx: &egui::Context) {
        apply_balloons(ctx, self.show_balloons);
        if let Some(t) = self.theme_request.take() {
            ctx.set_theme(t);
        }
        if (ctx.zoom_factor() - self.ui_scale).abs() > 1e-3 {
            ctx.set_zoom_factor(self.ui_scale);
        }
        #[cfg(not(target_arch = "wasm32"))]
        self.poll_control(ctx);
        self.poll_capture(ctx);
        // A figure, or a PDF/SVG/PNG export with the figure embedded (E16),
        // dropped on the window opens. The picture panel takes its own drops.
        self.web_inbox.attach(ctx);
        #[cfg(target_arch = "wasm32")]
        web_fetch::attach(ctx);
        if self.dialogs.edit.is_none() {
            // A path on the desktop; the browser gives the bytes and a name (C2).
            let dropped = ctx.input(|i| {
                i.raw.dropped_files.iter().find_map(|f| match (&f.path, &f.bytes) {
                    (Some(p), _) => p.to_str().map(|p| Pending::Open(Some(p.to_string()))),
                    (None, Some(b)) => Some(Pending::OpenWeb(web_io::WebPick {
                        name: f.name.clone(),
                        bytes: b.to_vec(),
                    })),
                    (None, None) => None,
                })
            });
            // Asked by the system (Finder, `open -a`, P3): the first one now, the rest after.
            let dropped = dropped.or_else(|| {
                if self.pending.is_some() {
                    return None;
                }
                self.system_open_queue
                    .extend(rustfig_os::open_docs::take_opened_files());
                if self.system_open_queue.is_empty() {
                    return None;
                }
                let p = self.system_open_queue.remove(0);
                p.to_str().map(|p| Pending::Open(Some(p.to_string())))
            });
            if let Some(action) = dropped {
                self.guard_unsaved(action);
            }
        }
        // The picker's answer; for Open the unsaved changes are asked after it,
        // and an open edit panel waits (its own Relink pick is for it).
        if self.pending.is_none() {
            let edit_open = self.dialogs.edit.is_some();
            for (purpose, pick) in self.web_inbox.take() {
                if purpose == web_io::WebPurpose::Open {
                    if edit_open || self.pending.is_some() {
                        self.web_inbox.requeue(purpose, pick);
                    } else {
                        self.guard_unsaved(Pending::OpenWeb(pick));
                    }
                } else {
                    web_io::handle_pick(self, purpose, &pick);
                }
            }
        }
        let tokens = theme::Tokens::new(ctx.style().visuals.dark_mode, self.button_3d);
        // Panels set the hint override while hovered; show last frame's.
        self.hint_shown = self.hint_override.take();
        context_help::frame(self, ctx);
        menus::handle_shortcuts(self, ctx);
        egui::TopBottomPanel::top("xfig_top").show(ctx, |ui| panels::classic_top(self, ui, &tokens));
        let ind =
            egui::TopBottomPanel::bottom("indicator").show(ctx, |ui| attributes::attribute_panel(self, ui, &tokens));
        self.context_help
            .zones
            .push((ind.response.rect, context_help::INDICATOR_TOPIC));
        egui::SidePanel::left("modes")
            .resizable(false)
            .exact_width(panels::MODE_PANEL_WIDTH)
            .frame(egui::Frame::side_top_panel(&ctx.style()).inner_margin(panels::MODE_PANEL_MARGIN))
            .show(ctx, |ui| panels::mode_panel(self, ui, &tokens));
        if self.show_depths {
            let depths = egui::SidePanel::right("depths")
                .default_width(140.0)
                .show(ctx, |ui| panels::depth_panel(self, ui, &tokens));
            self.context_help
                .zones
                .push((depths.response.rect, context_help::DEPTH_TOPIC));
        }
        egui::CentralPanel::default()
            .frame(egui::Frame::NONE)
            .show(ctx, |ui| canvas::show(self, ui, &tokens));
        dialogs::show(self, ctx);
        #[cfg(not(target_arch = "wasm32"))]
        export_bg::tick(self, ctx);
        #[cfg(not(target_arch = "wasm32"))]
        tex_gyre::tick(self, ctx);
        digitize::show(self, ctx);
        plot_dialog::show(self, ctx);
        charmap::show(self, ctx);
        file_panel::show(self, ctx);
        library::show(self, ctx);
        keys_sheet::show(self, ctx);
        command_search::show(self, ctx);
        help_pages::show_mouse_sheet(self, ctx);
        help_pages::show_whats_new(self, ctx);
        reload::tick(self, ctx);
        reload::ask(self, ctx);
        panels::mode_popup(self, ctx, &tokens);
        panels::close_compound_popup(self, ctx);
        self.handle_requests(ctx);
        settings::tick(self);
        #[cfg(target_arch = "wasm32")]
        if self.web_restorable.is_none() {
            let now = ctx.input(|i| i.time);
            let (modified, rev) = (self.engine.is_modified(), self.engine.revision());
            if let Some(w) = &mut self.web_autosave {
                let engine = &mut self.engine;
                let wait = w.tick(modified, rev, now, || web_fig(engine));
                ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
            }
        }
        #[cfg(not(target_arch = "wasm32"))]
        if let Some(a) = &mut self.autosave {
            if let Some(wait) = a.tick(&self.engine, std::time::Instant::now()) {
                ctx.request_repaint_after(wait);
            }
        }
        ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title()));
        if ctx.input(|i| i.viewport().close_requested()) && !self.quit_confirmed && self.engine.is_modified() {
            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
            self.pending = Some(Pending::Quit);
        }
        if self.quit_confirmed {
            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
        }
    }

    #[cfg(not(target_arch = "wasm32"))]
    fn poll_control(&mut self, ctx: &egui::Context) {
        // Screenshots arrive as events a frame or two after the request.
        let shots: Vec<std::sync::Arc<egui::ColorImage>> = ctx.input(|i| {
            i.raw
                .events
                .iter()
                .filter_map(|e| match e {
                    egui::Event::Screenshot { image, .. } => Some(image.clone()),
                    _ => None,
                })
                .collect()
        });
        if let Some(img) = shots.last() {
            for (path, reply, id) in self.screenshot_waiters.drain(..) {
                let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect();
                let res = rustfig_render::raster::encode_rgba_png(img.size[0] as u32, img.size[1] as u32, &rgba)
                    .and_then(|png| std::fs::write(&path, png).map_err(|e| e.to_string()));
                let msg = match res {
                    Ok(()) => json!({"id": id, "result": {"path": path, "width": img.size[0], "height": img.size[1]}}),
                    Err(e) => json!({"id": id, "error": {"code": "io", "message": e}}),
                };
                let _ = reply.send(msg.to_string()); // the client may have gone away
            }
        }
        // Answer requests whose injected input has been fed; keep frames coming meanwhile.
        let (done, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.input_waiters)
            .into_iter()
            .partition(|w| self.injector.reached(w.3));
        self.input_waiters = waiting;
        for (reply, id, result, _) in done {
            let _ = reply.send(json!({"id": id, "result": result}).to_string());
            // the client may have gone away
        }
        if !self.injector.is_idle() {
            ctx.request_repaint();
        }
        self.ui_open = control::open_state(self, ctx);
        let reqs = match &self.control {
            Some(s) => s.drain(),
            None => return,
        };
        for r in reqs {
            if r.method == "ui.screenshot" {
                let path = r
                    .params
                    .get("path")
                    .and_then(Value::as_str)
                    .unwrap_or("rustfig-screenshot.png")
                    .to_string();
                self.screenshot_waiters.push((path, r.reply, r.id));
                ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
                ctx.request_repaint();
                continue;
            }
            if matches!(
                r.method.as_str(),
                "ui.pointer" | "ui.key" | "ui.click_label" | "ui.modifiers"
            ) {
                let queued = control::input_steps(self, &r.method, &r.params)
                    .and_then(|(steps, result)| self.injector.push(steps).map(|seq| (seq, result)));
                match queued {
                    Ok((seq, result)) => self.input_waiters.push((r.reply, r.id, result, seq)),
                    Err(m) => {
                        let msg = json!({"id": r.id, "error": {"code": "params", "message": m}});
                        let _ = r.reply.send(msg.to_string()); // the client may have gone away
                    }
                }
                ctx.request_repaint();
                continue;
            }
            let res = control::handle(self, &r.method, &r.params);
            let msg = match res {
                Ok(v) => json!({"id": r.id, "result": v}),
                Err((code, m)) => json!({"id": r.id, "error": {"code": code, "message": m}}),
            };
            let _ = r.reply.send(msg.to_string()); // the client may have gone away
            ctx.request_repaint();
        }
    }
}

/// Tooltip delay with balloons on (egui's default) and a delay that never
/// passes with them off.
const BALLOON_DELAY: f32 = 0.5;
const NO_BALLOONS: f32 = 1.0e9;

fn apply_balloons(ctx: &egui::Context, on: bool) {
    let want = if on { BALLOON_DELAY } else { NO_BALLOONS };
    if ctx.style().interaction.tooltip_delay != want {
        ctx.style_mut(|s| s.interaction.tooltip_delay = want);
    }
}

/// Room for a dialog's content: the window's height less its title bar and
/// frame.
pub(crate) fn dialog_max_height(ctx: &egui::Context) -> f32 {
    (ctx.content_rect().height() - 56.0).max(120.0)
}

/// A dialog's content in a scroll area: as tall as the content, scrolling
/// vertically when it doesn't fit on the screen. (Horizontal scrolling would
/// give rows unlimited width, which right-aligned and full-width rows fill.)
pub(crate) fn dialog_body<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
    // A window offers its content egui's default height (420 points);
    // the content may be as tall as the screen.
    let max_h = dialog_max_height(ui.ctx());
    ui.set_max_height(max_h);
    egui::ScrollArea::vertical()
        .max_height(max_h)
        .auto_shrink([true, true])
        .show(ui, add)
        .inner
}

/// The drawing as Fig text with its file name, for the browser autosave.
#[cfg(target_arch = "wasm32")]
fn web_fig(engine: &mut Engine) -> web_autosave::Snapshot {
    let v = engine
        .execute("file.save_data", &serde_json::json!({"format": "fig"}))
        .ok()?;
    let data = v.get("data")?.as_str()?.to_string();
    Some((data, engine.path().map(str::to_string)))
}
