32.5 KiB · text History bf97ff0
//! 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::palette::{self, NamedColor, Theme};
use crate::render;
use crate::spec::{Background, Foreground, IconSpec};
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.
fn cache_key(spec: &IconSpec, size: u32, masked: bool) -> String {
    let fg = match &spec.foreground {
        Foreground::Icon(c) => format!("i{}", *c as u32),
        Foreground::Text(t) => format!("t{t}"),
    };
    format!(
        "{fg}|{}|{:?}|{:?}|{:?}|{:?}|{:.1}|{:.3}|{}|{}|{size}|{masked}",
        spec.sub_banner,
        spec.fg_color.rgb,
        spec.bg_a.rgb,
        spec.bg_b.rgb,
        spec.background,
        spec.angle,
        spec.scale,
        spec.filled,
        spec.show_guides,
    )
}

pub struct Studio {
    spec: IconSpec,
    theme_idx: usize,
    all_icons: Vec<IconEntry>,
    icon_query: String,
    text_mode: bool,
    text_value: String,

    /// 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,
    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(),
            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,
            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;
    }

    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.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");
    }

    /// 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);
        ui.horizontal_wrapped(|ui| {
            for c in &swatches {
                let current = match target {
                    0 => self.spec.fg_color,
                    1 => self.spec.bg_a,
                    _ => self.spec.bg_b,
                };
                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() {
                    match target {
                        0 => self.spec.fg_color = *c,
                        1 => self.spec.bg_a = *c,
                        _ => self.spec.bg_b = *c,
                    }
                }
                resp.on_hover_text(c.role);
            }
        });
    }

    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| {
            ui.selectable_value(&mut self.text_mode, false, "Icon");
            ui.selectable_value(&mut self.text_mode, true, "Text");
        });

        if self.text_mode {
            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 {
            ui.horizontal(|ui| {
                ui.label("Search");
                ui.text_edit_singleline(&mut self.icon_query);
            });
            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 selected =
                                matches!(self.spec.foreground, Foreground::Icon(c) if c == e.ch);
                            let btn = ui.selectable_label(
                                selected,
                                RichText::new(e.ch.to_string()).size(22.0),
                            );
                            if btn.clicked() {
                                self.spec.foreground = Foreground::Icon(e.ch);
                            }
                            btn.on_hover_text(e.name);
                        }
                    });
                });
        }

        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.add(egui::Slider::new(&mut self.spec.scale, 0.2..=0.95).text("Scale"));
        ui.checkbox(&mut self.spec.filled, "Filled face");
        ui.checkbox(&mut self.spec.show_guides, "Show safe-zone guides");

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

    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),
                    );
                }
                if ui.button("Export").clicked() {
                    self.do_export(resolved.root.clone());
                }
            }
            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) {
        match export::export_mipmaps(&self.spec, &root, &self.export_name) {
            Ok(files) => {
                self.status = format!(
                    "Wrote {} files across {} densities into {}.",
                    files.len(),
                    export::DENSITIES.len(),
                    shorten(&root.display().to_string()),
                )
            }
            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));
    }
}