//! 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::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};
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)
}
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,
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(),
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();
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 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));
}
}
neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
26.7 KiB · text
History
b869c97