15.2 KiB · text History e745ef6
//! 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 egui::{Color32, ComboBox, RichText, TextureHandle, TextureOptions};

use crate::export;
use crate::icons::{self, IconEntry};
use crate::palette::{self, NamedColor, Theme};
use crate::render;
use crate::spec::{Background, Foreground, IconSpec};

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)
}

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.
    grid_colors: Vec<usize>,
    grid_backgrounds: Vec<Background>,
    grid_masked: bool,

    export_dir: String,
    export_name: String,
    status: String,

    textures: HashMap<String, TextureHandle>,
}

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![0, 1, 2],
            grid_backgrounds: vec![
                Background::Solid,
                Background::DiagonalSplit,
                Background::LinearGradient,
                Background::RadialGradient,
            ],
            grid_masked: false,
            export_dir: String::new(),
            export_name: "ic_icon".into(),
            status: String::new(),
            textures: HashMap::new(),
        }
    }

    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");
        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| {
                for (i, n) in theme_names.iter().enumerate() {
                    ui.selectable_value(&mut self.theme_idx, i, *n);
                }
            });
        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();
        ui.label("Export Android mipmaps");
        ui.horizontal(|ui| {
            ui.label("Name");
            ui.text_edit_singleline(&mut self.export_name);
        });
        ui.horizontal(|ui| {
            if ui.button("Choose folder…").clicked() {
                if let Some(d) = rfd::FileDialog::new().pick_folder() {
                    self.export_dir = d.display().to_string();
                }
            }
            if ui.button("Export").clicked() {
                self.do_export();
            }
        });
        if !self.export_dir.is_empty() {
            ui.small(&self.export_dir);
        }
        if !self.status.is_empty() {
            ui.small(RichText::new(&self.status).italics());
        }
    }

    fn do_export(&mut self) {
        if self.export_dir.is_empty() {
            self.status = "Pick a folder first.".into();
            return;
        }
        let root = std::path::PathBuf::from(&self.export_dir);
        match export::export_mipmaps(&self.spec, &root, &self.export_name) {
            Ok(files) => {
                self.status = format!(
                    "Wrote {} files across {} densities.",
                    files.len(),
                    export::DENSITIES.len()
                )
            }
            Err(e) => self.status = format!("Export 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 (i, c) in swatches.iter().enumerate() {
                    let mut on = self.grid_colors.contains(&i);
                    if ui.checkbox(&mut on, "").changed() {
                        if on {
                            self.grid_colors.push(i);
                        } else {
                            self.grid_colors.retain(|x| *x != i);
                        }
                    }
                    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".
        let mut pairs: Vec<(NamedColor, NamedColor)> = Vec::new();
        for &i in &self.grid_colors {
            for &j in &self.grid_colors {
                if i == j {
                    continue;
                }
                if let (Some(a), Some(b)) = (swatches.get(i), swatches.get(j)) {
                    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 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();
        }

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