41.3 KiB · text History 6811748
//! The egui front end: controls on the left, live preview in the middle, and
//! the combination grid on the right.

use std::collections::HashMap;
use std::path::PathBuf;

use egui::{Color32, ComboBox, RichText, TextureHandle, TextureOptions};

use crate::config::Config;
use crate::doc::{self, Document, Session};
use crate::export;
use crate::icons::{self, IconEntry};
use crate::manifest;
use crate::palette::{self, NamedColor, Theme};
use crate::render;
use crate::spec::{Background, Foreground, IconSpec, Layer};
use crate::target::{self, Kind};

const GRID_CELL: u32 = 96;
const PREVIEW: u32 = 320;
/// The grid is a cartesian product, so it needs a ceiling or a wide selection
/// locks the frame up rendering thousands of cells.
const GRID_LIMIT: usize = 240;

fn to_image(pm: &tiny_skia::Pixmap) -> egui::ColorImage {
    // tiny-skia stores premultiplied; egui wants straight alpha.
    let mut buf = Vec::with_capacity((pm.width() * pm.height() * 4) as usize);
    for p in pm.pixels() {
        let d = p.demultiply();
        buf.extend_from_slice(&[d.red(), d.green(), d.blue(), d.alpha()]);
    }
    egui::ColorImage::from_rgba_unmultiplied([pm.width() as usize, pm.height() as usize], &buf)
}

/// Re-resolves a colour into `theme`: same role where it exists, otherwise
/// `default_role`, otherwise the theme's first swatch.
fn rebind(c: NamedColor, theme: &'static Theme, default_role: &str) -> NamedColor {
    theme
        .get(c.role)
        .or_else(|| theme.get(default_role))
        .or_else(|| theme.swatches().first().copied())
        .unwrap_or(c)
}

/// Trims a long path from the left so the meaningful tail stays visible in a
/// narrow side panel.
fn shorten(p: &str) -> String {
    const MAX: usize = 44;
    let chars: Vec<char> = p.chars().collect();
    if chars.len() <= MAX {
        return p.to_string();
    }
    let tail: String = chars[chars.len() - (MAX - 1)..].iter().collect();
    format!("…{tail}")
}

fn swatch_color(c: NamedColor) -> Color32 {
    Color32::from_rgb(c.rgb[0], c.rgb[1], c.rgb[2])
}

/// Identifies a rendered image so textures can be reused between frames.
///
/// Built from the spec's debug form, so every field — including each layer's —
/// is part of the key without having to be listed here and kept in step.
fn cache_key(spec: &IconSpec, size: u32, masked: bool) -> String {
    format!("{spec:?}|{size}|{masked}")
}

/// What the detail panel's swatches edit: the main mark's colour is the
/// foreground itself, while a layer's may be unset to follow it.
enum ColorSlot<'a> {
    Mark(&'a mut NamedColor),
    Layer(&'a mut Option<NamedColor>, NamedColor),
}

/// A row of palette swatches; clicking one assigns it to `current`.
fn swatch_picker(ui: &mut egui::Ui, swatches: &[NamedColor], current: &mut NamedColor) {
    ui.horizontal_wrapped(|ui| {
        for c in swatches {
            let selected = current.role == c.role && current.rgb == c.rgb;
            let (rect, resp) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click());
            ui.painter().rect_filled(rect, 4.0, swatch_color(*c));
            if selected {
                ui.painter().rect_stroke(
                    rect,
                    4.0,
                    egui::Stroke::new(2.0_f32, ui.visuals().strong_text_color()),
                    egui::StrokeKind::Outside,
                );
            }
            if resp.clicked() {
                *current = *c;
            }
            resp.on_hover_text(c.role);
        }
    });
}

pub struct Studio {
    spec: IconSpec,
    theme_idx: usize,
    all_icons: Vec<IconEntry>,
    icon_query: String,
    text_mode: bool,
    text_value: String,
    /// Which stacked layer the icon picker is choosing for; `None` is the
    /// main mark.
    editing_layer: Option<usize>,

    /// Which swatches and backgrounds feed the combination grid.
    /// Palette roles, not indices: swatch lists differ between themes, so
    /// an index means something different after a theme change.
    grid_colors: Vec<String>,
    grid_backgrounds: Vec<Background>,
    grid_masked: bool,

    export_dir: String,
    export_name: String,
    /// Folder of mipmap directories, or a single zip.
    export_zip: bool,
    /// Point AndroidManifest.xml at the icon as part of exporting.
    patch_manifest: bool,
    config: Config,
    status: String,

    /// Where the current document lives, once it has been saved somewhere.
    path: Option<PathBuf>,
    /// The document as last written or read, for dirty comparison.
    saved: Option<Document>,
    /// Set when New/Open would discard unsaved work.
    pending: Option<PendingAction>,

    textures: HashMap<String, TextureHandle>,
}

/// What to do once the user answers the unsaved-changes prompt.
#[derive(Clone, Copy, PartialEq)]
enum PendingAction {
    New,
    Open,
}

impl Default for Studio {
    fn default() -> Self {
        Self::new()
    }
}

impl Studio {
    pub fn new() -> Self {
        let theme_idx = 0;
        let theme = &palette::THEMES[theme_idx];
        let sw = theme.swatches();
        let pick = |role: &str, fallback: usize| -> NamedColor {
            theme.get(role).unwrap_or(sw[fallback.min(sw.len() - 1)])
        };
        Self {
            spec: IconSpec {
                fg_color: pick("base-200", 0),
                bg_a: pick("primary", 0),
                bg_b: pick("accent", 1),
                ..Default::default()
            },
            theme_idx,
            all_icons: icons::all(),
            icon_query: "lock".into(),
            text_mode: false,
            text_value: "1234".into(),
            editing_layer: None,
            grid_colors: vec!["primary".into(), "accent".into(), "base-200".into()],
            grid_backgrounds: vec![
                Background::Solid,
                Background::DiagonalSplit,
                Background::LinearGradient,
                Background::RadialGradient,
            ],
            grid_masked: false,
            export_dir: String::new(),
            export_name: "ic_icon".into(),
            export_zip: false,
            patch_manifest: true,
            config: Config::load(),
            status: String::new(),
            path: None,
            saved: None,
            pending: None,
            textures: HashMap::new(),
        }
    }

    // ---- documents ----

    /// The glyph's name for display and for writing into documents.
    ///
    /// Several names can share a codepoint — e899 is both `https` and `lock` —
    /// so pick the shortest, then alphabetically, to be deterministic and to
    /// land on the plainer word.
    fn icon_name_for(&self, ch: char) -> String {
        self.all_icons
            .iter()
            .filter(|e| e.ch == ch)
            .min_by_key(|e| (e.name.len(), e.name))
            .map(|e| e.name.to_string())
            .unwrap_or_default()
    }

    fn session(&self) -> Session {
        Session {
            spec: self.spec.clone(),
            palette: self.theme().name.to_string(),
            export_name: self.export_name.clone(),
            export_dir: self.export_dir.clone(),
            grid_masked: self.grid_masked,
            grid_colors: self.grid_colors.clone(),
            grid_backgrounds: self.grid_backgrounds.clone(),
        }
    }

    fn document(&self) -> Document {
        self.session().to_document(|ch| self.icon_name_for(ch))
    }

    fn is_dirty(&self) -> bool {
        match &self.saved {
            Some(d) => *d != self.document(),
            None => true,
        }
    }

    fn apply_session(&mut self, s: Session) {
        if let Some(i) = palette::THEMES.iter().position(|t| t.name == s.palette) {
            self.theme_idx = i;
        }
        self.grid_colors = s.grid_colors.clone();
        self.grid_backgrounds = s.grid_backgrounds;
        self.grid_masked = s.grid_masked;
        self.export_name = s.export_name;
        self.export_dir = s.export_dir;

        match &s.spec.foreground {
            Foreground::Text(t) => {
                self.text_mode = true;
                self.text_value = t.clone();
            }
            Foreground::Icon(ch) => {
                self.text_mode = false;
                let name = self.icon_name_for(*ch);
                if !name.is_empty() {
                    self.icon_query = name;
                }
            }
        }
        self.spec = s.spec;
        self.editing_layer = None;
    }

    fn new_document(&mut self) {
        let fresh = Studio::new();
        self.spec = fresh.spec;
        self.theme_idx = fresh.theme_idx;
        self.text_mode = fresh.text_mode;
        self.text_value = fresh.text_value;
        self.editing_layer = None;
        self.icon_query = fresh.icon_query;
        self.grid_colors = fresh.grid_colors;
        self.grid_backgrounds = fresh.grid_backgrounds;
        self.grid_masked = fresh.grid_masked;
        self.export_name = fresh.export_name;
        self.export_dir = fresh.export_dir;
        self.path = None;
        self.saved = Some(self.document());
        self.status = "New document.".into();
    }

    fn open_path(&mut self, p: PathBuf) {
        match doc::load(&p).and_then(|d| Session::from_document(&d).map(|s| (d, s))) {
            Ok((d, s)) => {
                self.apply_session(s);
                self.status = format!("Opened {}", p.display());
                self.path = Some(p);
                self.saved = Some(d);
            }
            Err(e) => self.status = format!("Open failed: {e}"),
        }
    }

    fn do_open(&mut self) {
        let mut dlg = rfd::FileDialog::new().add_filter("Icon Studio document", &[doc::EXTENSION]);
        if let Some(dir) = self.path.as_ref().and_then(|p| p.parent()) {
            dlg = dlg.set_directory(dir);
        }
        if let Some(p) = dlg.pick_file() {
            self.open_path(p);
        }
    }

    fn save_to(&mut self, p: PathBuf) {
        let d = self.document();
        match doc::save(&p, &d) {
            Ok(()) => {
                self.status = format!("Saved {}", p.display());
                self.path = Some(p);
                self.saved = Some(d);
            }
            Err(e) => self.status = format!("Save failed: {e}"),
        }
    }

    fn do_save(&mut self) {
        match self.path.clone() {
            Some(p) => self.save_to(p),
            None => self.do_save_as(),
        }
    }

    fn do_save_as(&mut self) {
        let suggested = self
            .path
            .as_ref()
            .and_then(|p| p.file_name().map(|f| f.to_string_lossy().to_string()))
            .unwrap_or_else(|| format!("{}.{}", self.export_name, doc::EXTENSION));
        let mut dlg = rfd::FileDialog::new()
            .add_filter("Icon Studio document", &[doc::EXTENSION])
            .set_file_name(suggested);
        if let Some(dir) = self.path.as_ref().and_then(|p| p.parent()) {
            dlg = dlg.set_directory(dir);
        }
        if let Some(p) = dlg.save_file() {
            self.save_to(doc::with_extension(p));
        }
    }

    /// New/Open route through here so unsaved work prompts first.
    fn guard(&mut self, action: PendingAction) {
        if self.is_dirty() {
            self.pending = Some(action);
        } else {
            self.run(action);
        }
    }

    fn run(&mut self, action: PendingAction) {
        match action {
            PendingAction::New => self.new_document(),
            PendingAction::Open => self.do_open(),
        }
    }

    fn file_controls(&mut self, ui: &mut egui::Ui) {
        ui.horizontal(|ui| {
            if ui.button("New").clicked() {
                self.guard(PendingAction::New);
            }
            if ui.button("Open…").clicked() {
                self.guard(PendingAction::Open);
            }
            if ui.button("Save").clicked() {
                self.do_save();
            }
            if ui.button("Save as…").clicked() {
                self.do_save_as();
            }
        });
        let title = match &self.path {
            Some(p) => p
                .file_name()
                .map(|f| f.to_string_lossy().to_string())
                .unwrap_or_default(),
            None => "untitled".into(),
        };
        ui.small(format!(
            "{title}{}",
            if self.is_dirty() { " •" } else { "" }
        ));
    }

    fn unsaved_prompt(&mut self, ctx: &egui::Context) {
        let Some(action) = self.pending else { return };
        egui::Window::new("Unsaved changes")
            .collapsible(false)
            .resizable(false)
            .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
            .show(ctx, |ui| {
                ui.label("This document has changes that have not been saved.");
                ui.horizontal(|ui| {
                    if ui.button("Save first").clicked() {
                        self.pending = None;
                        self.do_save();
                        // Only continue if the save actually happened; a
                        // cancelled dialog should not discard the work.
                        if !self.is_dirty() {
                            self.run(action);
                        }
                    }
                    if ui.button("Discard").clicked() {
                        self.pending = None;
                        self.run(action);
                    }
                    if ui.button("Cancel").clicked() {
                        self.pending = None;
                    }
                });
            });
    }

    /// Switches palette, carrying the current choices across by role.
    ///
    /// Changing theme has to move the colours with it, or the swatches update
    /// and the icon stays exactly as it was. A role the new palette does not
    /// define falls back to `default_role` so the slot still changes, rather
    /// than silently keeping a colour from the palette you just left.
    pub fn set_theme(&mut self, idx: usize) {
        if idx >= palette::THEMES.len() || idx == self.theme_idx {
            return;
        }
        self.theme_idx = idx;
        let theme = self.theme();
        self.spec.fg_color = rebind(self.spec.fg_color, theme, "base-content");
        self.spec.bg_a = rebind(self.spec.bg_a, theme, "primary");
        self.spec.bg_b = rebind(self.spec.bg_b, theme, "accent");
        // A layer's role may be missing from the new palette; it then lands
        // with the foreground rather than on an arbitrary swatch.
        let fg_role = self.spec.fg_color.role;
        for layer in &mut self.spec.layers {
            if let Some(c) = layer.color {
                layer.color = Some(rebind(c, theme, fg_role));
            }
        }
    }

    /// The spec as currently configured; exposed so the theme behaviour can be
    /// tested without standing up a window.
    pub fn spec(&self) -> &IconSpec {
        &self.spec
    }

    /// Sets the background-A colour directly. Exists so tests can put a role
    /// into the spec that only some palettes define.
    pub fn set_theme_color_for_test(&mut self, c: NamedColor) {
        self.spec.bg_a = c;
    }

    pub fn theme_name(&self) -> &'static str {
        self.theme().name
    }

    fn theme(&self) -> &'static Theme {
        &palette::THEMES[self.theme_idx]
    }

    fn texture(
        &mut self,
        ctx: &egui::Context,
        spec: &IconSpec,
        size: u32,
        masked: bool,
    ) -> TextureHandle {
        let key = cache_key(spec, size, masked);
        if let Some(t) = self.textures.get(&key) {
            return t.clone();
        }
        let pm = render::render_composed(spec, size);
        let pm = if masked { render::masked(&pm) } else { pm };
        let handle = ctx.load_texture(&key, to_image(&pm), TextureOptions::LINEAR);
        self.textures.insert(key, handle.clone());
        handle
    }

    fn swatch_row(&mut self, ui: &mut egui::Ui, label: &str, target: usize) {
        let swatches = self.theme().swatches();
        ui.label(label);
        let current = match target {
            0 => &mut self.spec.fg_color,
            1 => &mut self.spec.bg_a,
            _ => &mut self.spec.bg_b,
        };
        swatch_picker(ui, &swatches, current);
    }

    fn controls(&mut self, ui: &mut egui::Ui) {
        ui.heading("Icon Studio");
        self.file_controls(ui);
        ui.add_space(4.0);

        let theme_names: Vec<&str> = palette::THEMES.iter().map(|t| t.name).collect();
        ComboBox::from_label("Palette")
            .selected_text(theme_names[self.theme_idx])
            .show_ui(ui, |ui| {
                let mut pick = self.theme_idx;
                for (i, n) in theme_names.iter().enumerate() {
                    ui.selectable_value(&mut pick, i, *n);
                }
                // Assigned through set_theme rather than bound directly, so the
                // colours follow the palette.
                self.set_theme(pick);
            });
        ui.small(if self.theme().light {
            "light scheme"
        } else {
            "dark scheme"
        });

        ui.separator();
        ui.horizontal(|ui| {
            // The kind switch is about the main mark; touching it moves the
            // picker back there too.
            let before = self.text_mode;
            ui.selectable_value(&mut self.text_mode, false, "Icon");
            ui.selectable_value(&mut self.text_mode, true, "Text");
            if self.text_mode != before {
                self.editing_layer = None;
            }
        });

        if self.text_mode && self.editing_layer.is_none() {
            if ui.text_edit_singleline(&mut self.text_value).changed() {
                self.spec.foreground = Foreground::Text(self.text_value.clone());
            }
            if !matches!(self.spec.foreground, Foreground::Text(_)) {
                self.spec.foreground = Foreground::Text(self.text_value.clone());
            }
        } else {
            self.icon_picker(ui);
        }

        ui.separator();
        self.layer_controls(ui);

        ui.separator();
        ui.horizontal(|ui| {
            ui.label("Sub banner");
            ui.text_edit_singleline(&mut self.spec.sub_banner);
        });
        ui.small("No shipped icon used one — layout here is a reconstruction.");

        ui.separator();
        self.swatch_row(ui, "Foreground", 0);
        self.swatch_row(ui, "Background A", 1);
        if self.spec.background.uses_second_color() {
            self.swatch_row(ui, "Background B", 2);
        }

        ui.separator();
        ComboBox::from_label("Background")
            .selected_text(self.spec.background.label())
            .show_ui(ui, |ui| {
                for b in Background::ALL {
                    ui.selectable_value(&mut self.spec.background, *b, b.label());
                }
            });
        if self.spec.background == Background::DiagonalSplit {
            ui.small("The one style the original studio is known to have shipped.");
        }

        ui.add(egui::Slider::new(&mut self.spec.angle, 0.0..=360.0).text("Angle"));
        ui.checkbox(&mut self.spec.show_guides, "Show safe-zone guides");

        ui.separator();
        self.export_controls(ui);
    }

    /// Glyph search and grid. Picks for whichever layer is being edited.
    fn icon_picker(&mut self, ui: &mut egui::Ui) {
        ui.horizontal(|ui| {
            ui.label("Search");
            ui.text_edit_singleline(&mut self.icon_query);
        });
        let current = match self.editing_layer {
            Some(i) => self.spec.layers.get(i).map(|l| l.glyph),
            None => match self.spec.foreground {
                Foreground::Icon(c) => Some(c),
                Foreground::Text(_) => None,
            },
        };
        let hits = icons::search(&self.all_icons, &self.icon_query, 60);
        egui::ScrollArea::vertical()
            .max_height(150.0)
            .id_salt("iconlist")
            .show(ui, |ui| {
                ui.horizontal_wrapped(|ui| {
                    for e in hits {
                        let btn = ui.selectable_label(
                            current == Some(e.ch),
                            RichText::new(e.ch.to_string()).size(22.0),
                        );
                        if btn.clicked() {
                            match self.editing_layer.and_then(|i| self.spec.layers.get_mut(i)) {
                                Some(layer) => layer.glyph = e.ch,
                                None => self.spec.foreground = Foreground::Icon(e.ch),
                            }
                        }
                        btn.on_hover_text(e.name);
                    }
                });
            });
    }

    /// The main mark and the glyphs stacked on its centre, one row each with
    /// the same controls, under one overall scale.
    fn layer_controls(&mut self, ui: &mut egui::Ui) {
        ui.label("Layers");
        ui.add(egui::Slider::new(&mut self.spec.scale, 0.2..=0.95).text("Overall scale"))
            .on_hover_text("Size of the combined icon; every layer keeps its proportion");

        // Up to twice the overall scale, so a layer can frame the others.
        fn size_slider(v: &mut f32) -> egui::Slider<'_> {
            egui::Slider::new(v, 0.1..=2.0)
                .text("Size")
                .fixed_decimals(2)
        }

        ui.horizontal(|ui| {
            let label = match &self.spec.foreground {
                Foreground::Icon(c) => RichText::new(c.to_string()).size(20.0),
                Foreground::Text(t) => RichText::new(format!("\"{t}\"")),
            };
            if ui
                .selectable_label(self.editing_layer.is_none(), label)
                .on_hover_text("Main mark — click to pick its glyph")
                .clicked()
            {
                self.editing_layer = None;
            }
            ui.add(size_slider(&mut self.spec.mark_size))
                .on_hover_text("Size relative to the overall scale");
            ui.checkbox(&mut self.spec.filled, "Filled");
        });

        let mut remove = None;
        for i in 0..self.spec.layers.len() {
            let name = self.icon_name_for(self.spec.layers[i].glyph);
            let editing = self.editing_layer == Some(i);
            let layer = &mut self.spec.layers[i];
            ui.horizontal(|ui| {
                let label = RichText::new(layer.glyph.to_string()).size(20.0);
                if ui
                    .selectable_label(editing, label)
                    .on_hover_text(format!("{name} — click to pick its glyph"))
                    .clicked()
                {
                    self.editing_layer = Some(i);
                }
                ui.add(size_slider(&mut layer.size))
                    .on_hover_text("Size relative to the overall scale");
                ui.checkbox(&mut layer.filled, "Filled");
                if ui.small_button("✕").on_hover_text("Remove layer").clicked() {
                    remove = Some(i);
                }
            });
        }
        if let Some(i) = remove {
            self.spec.layers.remove(i);
            self.editing_layer = match self.editing_layer {
                Some(e) if e == i => None,
                Some(e) if e > i => Some(e - 1),
                e => e,
            };
        }

        self.selected_layer_controls(ui);

        if ui
            .button("Add layer")
            .on_hover_text("Stack another glyph on the centre of the mark")
            .clicked()
        {
            // Start small enough to sit inside the mark, so the new layer is
            // visible straight away rather than hidden under it.
            self.spec.layers.push(Layer {
                glyph: '\u{e899}', // lock
                color: None,
                size: 0.45 * self.spec.mark_size,
                rotation: 0.0,
                offset: [0.0, 0.0],
                filled: false,
            });
            self.editing_layer = Some(self.spec.layers.len() - 1);
            self.icon_query = "lock".into();
        }
    }

    /// Placement and colour for whichever row is selected — the main mark or
    /// a layer. Kept out of the rows themselves so the list stays scannable.
    fn selected_layer_controls(&mut self, ui: &mut egui::Ui) {
        let swatches = self.theme().swatches();
        let title = match self.editing_layer {
            Some(i) => format!("Layer {}", i + 1),
            None => "Main mark".to_string(),
        };
        let fg = self.spec.fg_color;
        let (rotation, offset, color) =
            match self.editing_layer.and_then(|i| self.spec.layers.get_mut(i)) {
                Some(l) => (
                    &mut l.rotation,
                    &mut l.offset,
                    ColorSlot::Layer(&mut l.color, fg),
                ),
                None => (
                    &mut self.spec.mark_rotation,
                    &mut self.spec.mark_offset,
                    ColorSlot::Mark(&mut self.spec.fg_color),
                ),
            };

        ui.group(|ui| {
            ui.horizontal(|ui| {
                ui.strong(title);
                if ui
                    .small_button("Recentre")
                    .on_hover_text("Clear rotation and offset")
                    .clicked()
                {
                    *rotation = 0.0;
                    *offset = [0.0, 0.0];
                }
            });
            ui.add(
                egui::Slider::new(rotation, -180.0..=180.0)
                    .text("Rotation")
                    .suffix("°"),
            );
            // Offsets are in units of the overall scale, so ±1 reaches the
            // edge of the combined icon at any size.
            ui.add(egui::Slider::new(&mut offset[0], -1.0..=1.0).text("X"));
            ui.add(egui::Slider::new(&mut offset[1], -1.0..=1.0).text("Y"))
                .on_hover_text("Positive is down");
            match color {
                ColorSlot::Mark(c) => swatch_picker(ui, &swatches, c),
                ColorSlot::Layer(c, fg) => {
                    let mut follow = c.is_none();
                    if ui
                        .checkbox(&mut follow, "Use foreground colour")
                        .on_hover_text("Follow the Foreground colour, including later changes")
                        .changed()
                    {
                        *c = if follow { None } else { Some(fg) };
                    }
                    // Picking a swatch gives the layer its own colour; while
                    // it follows, the foreground shows as the selection.
                    let mut shown = c.unwrap_or(fg);
                    swatch_picker(ui, &swatches, &mut shown);
                    if c.is_some() || shown != fg {
                        *c = Some(shown);
                    }
                }
            }
        });
    }

    fn export_controls(&mut self, ui: &mut egui::Ui) {
        ui.label("Export");
        ui.horizontal(|ui| {
            ui.label("Name");
            ui.text_edit_singleline(&mut self.export_name);
        });
        ui.horizontal(|ui| {
            ui.selectable_value(&mut self.export_zip, false, "To folder");
            ui.selectable_value(&mut self.export_zip, true, "Zip bundle");
        });

        if self.export_zip {
            ui.small("One .zip holding the mipmap tree and this document.");
            if ui.button("Export bundle…").clicked() {
                self.do_export_zip();
            }
            if !self.status.is_empty() {
                ui.small(RichText::new(&self.status).italics());
            }
            return;
        }

        ui.horizontal(|ui| {
            if ui.button("Choose folder…").clicked() {
                let mut dlg = rfd::FileDialog::new();
                if let Some(start) = self.start_dir() {
                    dlg = dlg.set_directory(start);
                }
                if let Some(d) = dlg.pick_folder() {
                    self.set_target(&d);
                }
            }

            // Somewhere you exported to before is far more likely to be where
            // you want to go than wherever the dialog opens by default.
            // Owned, so the closure below does not hold a borrow of self.
            let recent: Vec<String> = self
                .config
                .live_targets()
                .into_iter()
                .map(str::to_string)
                .collect();
            if !recent.is_empty() {
                let mut chosen: Option<String> = None;
                ComboBox::from_id_salt("recent-targets")
                    .selected_text("Recent…")
                    .show_ui(ui, |ui| {
                        for r in &recent {
                            if ui.selectable_label(false, shorten(r)).clicked() {
                                chosen = Some(r.clone());
                            }
                        }
                    });
                if let Some(c) = chosen {
                    self.set_target(std::path::Path::new(&c));
                }
            }
        });

        if self.export_dir.is_empty() {
            ui.small("No destination chosen.");
            return;
        }

        let root = std::path::PathBuf::from(&self.export_dir);
        let resolved = target::resolve(&root);

        // Say what was inferred and why, so a wrong guess is visible before it
        // writes fifteen files somewhere unexpected.
        let tint = match resolved.kind {
            Kind::AndroidRes | Kind::AndroidProject => ui.visuals().strong_text_color(),
            Kind::Plain => ui.visuals().warn_fg_color,
        };
        ui.small(RichText::new(shorten(&resolved.root.display().to_string())).color(tint));
        ui.small(&resolved.note);

        match target::writable(&resolved.root) {
            Ok(()) => {
                let plan = target::plan(&resolved.root, &self.export_name);
                ui.small(target::summarise(&plan));
                if plan.iter().any(|f| f.overwrites) {
                    ui.small(
                        RichText::new("Existing icons will be replaced.")
                            .color(ui.visuals().warn_fg_color),
                    );
                }

                // Only offered where there is a manifest to patch; on a plain
                // folder the checkbox would be a lie.
                let manifest_path = manifest::manifest_for(&resolved.root);
                if let Some(mp) = &manifest_path {
                    ui.checkbox(&mut self.patch_manifest, "Point the manifest at it");
                    ui.small(shorten(&mp.display().to_string()));
                } else if resolved.kind != Kind::Plain {
                    ui.small(
                        RichText::new("No AndroidManifest.xml beside this res directory.")
                            .color(ui.visuals().warn_fg_color),
                    );
                }

                if ui.button("Export").clicked() {
                    self.do_export(resolved.root.clone(), manifest_path);
                }
            }
            Err(e) => {
                ui.small(RichText::new(e).color(ui.visuals().error_fg_color));
            }
        }

        if !self.status.is_empty() {
            ui.small(RichText::new(&self.status).italics());
        }
    }

    /// Where a folder dialog should open: the last destination, else the
    /// document's own folder, else let the dialog decide.
    fn start_dir(&self) -> Option<std::path::PathBuf> {
        if !self.export_dir.is_empty() {
            let p = std::path::PathBuf::from(&self.export_dir);
            if p.is_dir() {
                return Some(p);
            }
        }
        self.config
            .live_targets()
            .first()
            .map(std::path::PathBuf::from)
            .or_else(|| {
                self.path
                    .as_ref()
                    .and_then(|p| p.parent().map(|d| d.to_path_buf()))
            })
    }

    fn set_target(&mut self, picked: &std::path::Path) {
        self.export_dir = picked.display().to_string();
        // Remember what was picked, not what it resolved to: next time the
        // dialog should open where the user was actually browsing.
        self.config.remember_target(picked);
        self.config.save();
        self.status.clear();
    }

    fn do_export(&mut self, root: std::path::PathBuf, manifest_path: Option<std::path::PathBuf>) {
        match export::export_mipmaps(&self.spec, &root, &self.export_name) {
            Ok(files) => {
                let mut note = format!(
                    "Wrote {} files into {}.",
                    files.len(),
                    shorten(&root.display().to_string()),
                );
                // Reported separately from the write: the images landing
                // correctly while the manifest still points elsewhere is
                // precisely the failure that looks like success.
                if self.patch_manifest {
                    if let Some(mp) = manifest_path {
                        match manifest::patch_file(&mp, &self.export_name, true) {
                            Ok(change) => {
                                note.push(' ');
                                note.push_str(&change.describe(&self.export_name));
                            }
                            Err(e) => {
                                note.push_str(&format!(" Manifest not patched: {e}"));
                            }
                        }
                    }
                }
                self.status = note;
            }
            Err(e) => self.status = format!("Export failed: {e}"),
        }
    }

    fn do_export_zip(&mut self) {
        let suggested = format!("{}.zip", self.export_name);
        let mut dlg = rfd::FileDialog::new()
            .add_filter("Zip archive", &["zip"])
            .set_file_name(suggested);
        if let Some(start) = self.start_dir() {
            dlg = dlg.set_directory(start);
        }
        let Some(path) = dlg.save_file() else { return };
        let path = if path.extension().is_some() {
            path
        } else {
            path.with_extension("zip")
        };

        let document = doc::to_toml(&self.document()).ok();
        let doc_name = format!("{}.toml", self.export_name);
        let entry = document.as_deref().map(|body| (doc_name.as_str(), body));

        match export::export_zip(&self.spec, &path, &self.export_name, entry) {
            Ok(names) => {
                if let Some(dir) = path.parent() {
                    self.config.remember_target(dir);
                    self.config.save();
                }
                self.status = format!(
                    "Bundled {} entries into {}.",
                    names.len(),
                    shorten(&path.display().to_string())
                );
            }
            Err(e) => self.status = format!("Bundle failed: {e}"),
        }
    }

    fn preview(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) {
        let spec = self.spec.clone();
        let full = self.texture(ctx, &spec, PREVIEW, false);
        let round = self.texture(ctx, &spec, PREVIEW, true);
        ui.heading("Preview");
        ui.horizontal(|ui| {
            ui.vertical(|ui| {
                ui.image((full.id(), egui::vec2(PREVIEW as f32, PREVIEW as f32)));
                ui.small("adaptive layers, full bleed");
            });
            ui.vertical(|ui| {
                ui.image((round.id(), egui::vec2(PREVIEW as f32, PREVIEW as f32)));
                ui.small("as a launcher will mask it");
            });
        });
    }

    fn grid(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) {
        ui.heading("Combinations");
        let swatches = self.theme().swatches();

        ui.collapsing("Colours in play", |ui| {
            ui.horizontal_wrapped(|ui| {
                for c in swatches.iter() {
                    let mut on = self.grid_colors.iter().any(|r| r == c.role);
                    if ui.checkbox(&mut on, "").changed() {
                        if on {
                            self.grid_colors.push(c.role.to_string());
                        } else {
                            self.grid_colors.retain(|r| r != c.role);
                        }
                    }
                    let (rect, resp) =
                        ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
                    ui.painter().rect_filled(rect, 3.0, swatch_color(*c));
                    resp.on_hover_text(c.role);
                }
            });
        });

        ui.collapsing("Backgrounds in play", |ui| {
            for b in Background::ALL {
                let mut on = self.grid_backgrounds.contains(b);
                if ui.checkbox(&mut on, b.label()).changed() {
                    if on {
                        self.grid_backgrounds.push(*b);
                    } else {
                        self.grid_backgrounds.retain(|x| x != b);
                    }
                }
            }
        });
        ui.checkbox(&mut self.grid_masked, "Mask grid cells");

        // Every background style against every ordered colour pair drawn from
        // the selection — "all the combinations the choices give you".
        // Roles the current theme does not define are skipped rather than
        // dropped, so switching away and back keeps the selection.
        let chosen: Vec<NamedColor> = self
            .grid_colors
            .iter()
            .filter_map(|role| swatches.iter().find(|c| c.role == role).copied())
            .collect();
        let mut pairs: Vec<(NamedColor, NamedColor)> = Vec::new();
        for a in &chosen {
            for b in &chosen {
                if a.role != b.role {
                    pairs.push((*a, *b));
                }
            }
        }
        if pairs.is_empty() {
            ui.small("Select at least two colours.");
            return;
        }

        let mut cells: Vec<IconSpec> = Vec::new();
        'outer: for bg in &self.grid_backgrounds {
            for (a, b) in &pairs {
                if cells.len() >= GRID_LIMIT {
                    break 'outer;
                }
                cells.push(IconSpec {
                    background: *bg,
                    bg_a: *a,
                    bg_b: *b,
                    ..self.spec.clone()
                });
                // Solid ignores the second colour, so every pair sharing a
                // first colour would render identically.
                if !bg.uses_second_color() {
                    break;
                }
            }
        }

        ui.small(format!("{} combinations", cells.len()));
        egui::ScrollArea::vertical().id_salt("grid").show(ui, |ui| {
            ui.horizontal_wrapped(|ui| {
                for c in cells {
                    let t = self.texture(ctx, &c, GRID_CELL, self.grid_masked);
                    let resp = ui
                        .add(
                            egui::ImageButton::new((
                                t.id(),
                                egui::vec2(GRID_CELL as f32, GRID_CELL as f32),
                            ))
                            .frame(false),
                        )
                        .on_hover_text(format!(
                            "{} · {} over {}",
                            c.background.label(),
                            c.bg_b.role,
                            c.bg_a.role
                        ));
                    if resp.clicked() {
                        self.spec.background = c.background;
                        self.spec.bg_a = c.bg_a;
                        self.spec.bg_b = c.bg_b;
                    }
                }
            });
        });
    }
}

impl Studio {
    fn shortcuts(&mut self, ctx: &egui::Context) {
        // Blocked while the prompt is up, so a stray Ctrl+O cannot stack
        // another pending action behind the one being answered.
        if self.pending.is_some() {
            return;
        }
        let (new, open, save, save_as) = ctx.input_mut(|i| {
            use egui::{Key, Modifiers};
            (
                i.consume_key(Modifiers::COMMAND, Key::N),
                i.consume_key(Modifiers::COMMAND, Key::O),
                i.consume_key(Modifiers::COMMAND, Key::S),
                i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, Key::S),
            )
        });
        if save_as {
            self.do_save_as();
        } else if save {
            self.do_save();
        } else if new {
            self.guard(PendingAction::New);
        } else if open {
            self.guard(PendingAction::Open);
        }
    }
}

impl eframe::App for Studio {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        // Textures are keyed by content; without a bound the cache grows with
        // every slider tick.
        if self.textures.len() > 2000 {
            self.textures.clear();
        }

        // Title tracks the document so the window is identifiable when several
        // are open, with the usual bullet for unsaved changes.
        let title = format!(
            "{}{} — Icon Studio",
            self.path
                .as_ref()
                .and_then(|p| p.file_name().map(|f| f.to_string_lossy().to_string()))
                .unwrap_or_else(|| "untitled".into()),
            if self.is_dirty() { " •" } else { "" },
        );
        ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));

        self.shortcuts(ctx);
        self.unsaved_prompt(ctx);

        egui::SidePanel::left("controls")
            .resizable(true)
            .default_width(330.0)
            .show(ctx, |ui| {
                egui::ScrollArea::vertical().show(ui, |ui| self.controls(ui));
            });

        egui::SidePanel::right("grid")
            .resizable(true)
            .default_width(520.0)
            .show(ctx, |ui| self.grid(ui, ctx));

        egui::CentralPanel::default().show(ctx, |ui| self.preview(ui, ctx));
    }
}