neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Make export destinations smarter, and add a zip bundle
bf97ff0 · gmorell · 2026-09-22 04:24
Message
{commit_body(@commit)}
Files changed
modified
Cargo.lock
+50
−0
@@ -161,6 +161,15 @@ version = "0.2.2"
| 161 | 161 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 162 | 162 | checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" |
| 163 | 163 | |
| 164 | +[[package]] | |
| 165 | +name = "arbitrary" | |
| 166 | +version = "1.4.2" | |
| 167 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 168 | +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" | |
| 169 | +dependencies = [ | |
| 170 | + "derive_arbitrary", | |
| 171 | +] | |
| 172 | + | |
| 164 | 173 | [[package]] |
| 165 | 174 | name = "arboard" |
| 166 | 175 | version = "3.6.1" |
@@ -842,6 +851,17 @@ dependencies = [
| 842 | 851 | "syn 1.0.109", |
| 843 | 852 | ] |
| 844 | 853 | |
| 854 | +[[package]] | |
| 855 | +name = "derive_arbitrary" | |
| 856 | +version = "1.4.2" | |
| 857 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 858 | +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" | |
| 859 | +dependencies = [ | |
| 860 | + "proc-macro2", | |
| 861 | + "quote", | |
| 862 | + "syn 2.0.119", | |
| 863 | +] | |
| 864 | + | |
| 845 | 865 | [[package]] |
| 846 | 866 | name = "digest" |
| 847 | 867 | version = "0.10.7" |
@@ -1549,6 +1569,7 @@ dependencies = [
| 1549 | 1569 | "serde", |
| 1550 | 1570 | "tiny-skia", |
| 1551 | 1571 | "toml", |
| 1572 | + "zip", | |
| 1552 | 1573 | ] |
| 1553 | 1574 | |
| 1554 | 1575 | [[package]] |
@@ -4635,12 +4656,41 @@ dependencies = [
| 4635 | 4656 | "syn 3.0.6", |
| 4636 | 4657 | ] |
| 4637 | 4658 | |
| 4659 | +[[package]] | |
| 4660 | +name = "zip" | |
| 4661 | +version = "2.4.2" | |
| 4662 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4663 | +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" | |
| 4664 | +dependencies = [ | |
| 4665 | + "arbitrary", | |
| 4666 | + "crc32fast", | |
| 4667 | + "crossbeam-utils", | |
| 4668 | + "displaydoc", | |
| 4669 | + "flate2", | |
| 4670 | + "indexmap", | |
| 4671 | + "memchr", | |
| 4672 | + "thiserror 2.0.20", | |
| 4673 | + "zopfli", | |
| 4674 | +] | |
| 4675 | + | |
| 4638 | 4676 | [[package]] |
| 4639 | 4677 | name = "zlib-rs" |
| 4640 | 4678 | version = "0.6.7" |
| 4641 | 4679 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4642 | 4680 | checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" |
| 4643 | 4681 | |
| 4682 | +[[package]] | |
| 4683 | +name = "zopfli" | |
| 4684 | +version = "0.8.3" | |
| 4685 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4686 | +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" | |
| 4687 | +dependencies = [ | |
| 4688 | + "bumpalo", | |
| 4689 | + "crc32fast", | |
| 4690 | + "log", | |
| 4691 | + "simd-adler32", | |
| 4692 | +] | |
| 4693 | + | |
| 4644 | 4694 | [[package]] |
| 4645 | 4695 | name = "zune-core" |
| 4646 | 4696 | version = "0.5.3" |
modified
Cargo.toml
+4
−0
@@ -60,10 +60,14 @@ ab_glyph = "0.2"
| 60 | 60 | image = { version = "0.25", default-features = false, features = ["png"] } |
| 61 | 61 | serde = { version = "1", features = ["derive"] } |
| 62 | 62 | toml = "0.8" |
| 63 | +# Bundle export. No compression backends beyond deflate: a PNG is already | |
| 64 | +# deflated, so zstd/bzip2 would add dependencies to save nothing. | |
| 65 | +zip = { version = "2", default-features = false, features = ["deflate"] } | |
| 63 | 66 | rfd = { version = "0.12", default-features = false, features = ["xdg-portal"] } |
| 64 | 67 | |
| 65 | 68 | [profile.release] |
| 66 | 69 | opt-level = 2 |
| 67 | 70 | |
| 68 | 71 | [dev-dependencies] |
| 72 | +zip = { version = "2", default-features = false, features = ["deflate"] } | |
| 69 | 73 | image = { version = "0.25", default-features = false, features = ["png"] } |
modified
README.md
+29
−3
@@ -159,9 +159,35 @@ cargo run --example make_app_icon # regenerate the desktop icon
| 159 | 159 | The application's own icon is made by the application, from |
| 160 | 160 | `presets/icon-studio.toml` — change it in the studio and regenerate. |
| 161 | 161 | |
| 162 | −Export writes `ic_icon`, `ic_icon_background` and `ic_icon_foreground` across | |
| 163 | −`mipmap-mdpi` through `mipmap-xxxhdpi` (108–432px), matching the layout the | |
| 164 | −cbz apps use. | |
| 162 | +## Export | |
| 163 | + | |
| 164 | +Two forms, both writing `ic_icon`, `ic_icon_background` and | |
| 165 | +`ic_icon_foreground` across `mipmap-mdpi` through `mipmap-xxxhdpi` (108–432px), | |
| 166 | +the layout the cbz apps use. | |
| 167 | + | |
| 168 | +**To a folder.** The chosen directory is interpreted rather than taken | |
| 169 | +literally, because `mipmap-*` only means anything inside an Android `res/` — | |
| 170 | +dropping those beside `build.gradle.kts` writes fifteen files, reports success, | |
| 171 | +and leaves the app with its old icon. So: | |
| 172 | + | |
| 173 | +| You pick | It writes to | | |
| 174 | +|---|---| | |
| 175 | +| a project root | `app/src/main/res` | | |
| 176 | +| a module root | `src/main/res` | | |
| 177 | +| a `res/` directory | itself | | |
| 178 | +| a `mipmap-*` bucket | the `res/` above it | | |
| 179 | +| anything else | the folder itself, and says so | | |
| 180 | + | |
| 181 | +The resolved path and the reason are shown before you commit to it, along with | |
| 182 | +how many files would be written and how many already exist and would be | |
| 183 | +replaced. Destinations you have used before are offered in a dropdown and | |
| 184 | +remembered between sessions in `~/.config/icon-studio/config.toml` — that is a | |
| 185 | +property of the machine, not of the icon, so it never travels in a document. | |
| 186 | + | |
| 187 | +**As a zip bundle.** One archive holding the same mipmap tree plus the source | |
| 188 | +`.toml`. A bundle of bare PNGs is a dead end — whoever receives it cannot | |
| 189 | +change the icon without rebuilding the configuration by eye, which is the | |
| 190 | +problem this program exists to avoid. | |
| 165 | 191 | |
| 166 | 192 | ## Design notes |
| 167 | 193 |
modified
src/app.rs
+173
−14
@@ -6,12 +6,14 @@ use std::path::PathBuf;
| 6 | 6 | |
| 7 | 7 | use egui::{Color32, ComboBox, RichText, TextureHandle, TextureOptions}; |
| 8 | 8 | |
| 9 | +use crate::config::Config; | |
| 9 | 10 | use crate::doc::{self, Document, Session}; |
| 10 | 11 | use crate::export; |
| 11 | 12 | use crate::icons::{self, IconEntry}; |
| 12 | 13 | use crate::palette::{self, NamedColor, Theme}; |
| 13 | 14 | use crate::render; |
| 14 | 15 | use crate::spec::{Background, Foreground, IconSpec}; |
| 16 | +use crate::target::{self, Kind}; | |
| 15 | 17 | |
| 16 | 18 | const GRID_CELL: u32 = 96; |
| 17 | 19 | const PREVIEW: u32 = 320; |
@@ -39,6 +41,18 @@ fn rebind(c: NamedColor, theme: &'static Theme, default_role: &str) -> NamedColo
| 39 | 41 | .unwrap_or(c) |
| 40 | 42 | } |
| 41 | 43 | |
| 44 | +/// Trims a long path from the left so the meaningful tail stays visible in a | |
| 45 | +/// narrow side panel. | |
| 46 | +fn shorten(p: &str) -> String { | |
| 47 | + const MAX: usize = 44; | |
| 48 | + let chars: Vec<char> = p.chars().collect(); | |
| 49 | + if chars.len() <= MAX { | |
| 50 | + return p.to_string(); | |
| 51 | + } | |
| 52 | + let tail: String = chars[chars.len() - (MAX - 1)..].iter().collect(); | |
| 53 | + format!("…{tail}") | |
| 54 | +} | |
| 55 | + | |
| 42 | 56 | fn swatch_color(c: NamedColor) -> Color32 { |
| 43 | 57 | Color32::from_rgb(c.rgb[0], c.rgb[1], c.rgb[2]) |
| 44 | 58 | } |
@@ -80,6 +94,9 @@ pub struct Studio {
| 80 | 94 | |
| 81 | 95 | export_dir: String, |
| 82 | 96 | export_name: String, |
| 97 | + /// Folder of mipmap directories, or a single zip. | |
| 98 | + export_zip: bool, | |
| 99 | + config: Config, | |
| 83 | 100 | status: String, |
| 84 | 101 | |
| 85 | 102 | /// Where the current document lives, once it has been saved somewhere. |
@@ -135,6 +152,8 @@ impl Studio {
| 135 | 152 | grid_masked: false, |
| 136 | 153 | export_dir: String::new(), |
| 137 | 154 | export_name: "ic_icon".into(), |
| 155 | + export_zip: false, | |
| 156 | + config: Config::load(), | |
| 138 | 157 | status: String::new(), |
| 139 | 158 | path: None, |
| 140 | 159 | saved: None, |
@@ -540,47 +559,187 @@ impl Studio {
| 540 | 559 | ui.checkbox(&mut self.spec.show_guides, "Show safe-zone guides"); |
| 541 | 560 | |
| 542 | 561 | ui.separator(); |
| 543 | − ui.label("Export Android mipmaps"); | |
| 562 | + self.export_controls(ui); | |
| 563 | + } | |
| 564 | + | |
| 565 | + fn export_controls(&mut self, ui: &mut egui::Ui) { | |
| 566 | + ui.label("Export"); | |
| 544 | 567 | ui.horizontal(|ui| { |
| 545 | 568 | ui.label("Name"); |
| 546 | 569 | ui.text_edit_singleline(&mut self.export_name); |
| 547 | 570 | }); |
| 571 | + ui.horizontal(|ui| { | |
| 572 | + ui.selectable_value(&mut self.export_zip, false, "To folder"); | |
| 573 | + ui.selectable_value(&mut self.export_zip, true, "Zip bundle"); | |
| 574 | + }); | |
| 575 | + | |
| 576 | + if self.export_zip { | |
| 577 | + ui.small("One .zip holding the mipmap tree and this document."); | |
| 578 | + if ui.button("Export bundle…").clicked() { | |
| 579 | + self.do_export_zip(); | |
| 580 | + } | |
| 581 | + if !self.status.is_empty() { | |
| 582 | + ui.small(RichText::new(&self.status).italics()); | |
| 583 | + } | |
| 584 | + return; | |
| 585 | + } | |
| 586 | + | |
| 548 | 587 | ui.horizontal(|ui| { |
| 549 | 588 | if ui.button("Choose folder…").clicked() { |
| 550 | − if let Some(d) = rfd::FileDialog::new().pick_folder() { | |
| 551 | − self.export_dir = d.display().to_string(); | |
| 589 | + let mut dlg = rfd::FileDialog::new(); | |
| 590 | + if let Some(start) = self.start_dir() { | |
| 591 | + dlg = dlg.set_directory(start); | |
| 592 | + } | |
| 593 | + if let Some(d) = dlg.pick_folder() { | |
| 594 | + self.set_target(&d); | |
| 552 | 595 | } |
| 553 | 596 | } |
| 554 | − if ui.button("Export").clicked() { | |
| 555 | − self.do_export(); | |
| 597 | + | |
| 598 | + // Somewhere you exported to before is far more likely to be where | |
| 599 | + // you want to go than wherever the dialog opens by default. | |
| 600 | + // Owned, so the closure below does not hold a borrow of self. | |
| 601 | + let recent: Vec<String> = self | |
| 602 | + .config | |
| 603 | + .live_targets() | |
| 604 | + .into_iter() | |
| 605 | + .map(str::to_string) | |
| 606 | + .collect(); | |
| 607 | + if !recent.is_empty() { | |
| 608 | + let mut chosen: Option<String> = None; | |
| 609 | + ComboBox::from_id_salt("recent-targets") | |
| 610 | + .selected_text("Recent…") | |
| 611 | + .show_ui(ui, |ui| { | |
| 612 | + for r in &recent { | |
| 613 | + if ui.selectable_label(false, shorten(r)).clicked() { | |
| 614 | + chosen = Some(r.clone()); | |
| 615 | + } | |
| 616 | + } | |
| 617 | + }); | |
| 618 | + if let Some(c) = chosen { | |
| 619 | + self.set_target(std::path::Path::new(&c)); | |
| 620 | + } | |
| 556 | 621 | } |
| 557 | 622 | }); |
| 558 | − if !self.export_dir.is_empty() { | |
| 559 | − ui.small(&self.export_dir); | |
| 623 | + | |
| 624 | + if self.export_dir.is_empty() { | |
| 625 | + ui.small("No destination chosen."); | |
| 626 | + return; | |
| 627 | + } | |
| 628 | + | |
| 629 | + let root = std::path::PathBuf::from(&self.export_dir); | |
| 630 | + let resolved = target::resolve(&root); | |
| 631 | + | |
| 632 | + // Say what was inferred and why, so a wrong guess is visible before it | |
| 633 | + // writes fifteen files somewhere unexpected. | |
| 634 | + let tint = match resolved.kind { | |
| 635 | + Kind::AndroidRes | Kind::AndroidProject => ui.visuals().strong_text_color(), | |
| 636 | + Kind::Plain => ui.visuals().warn_fg_color, | |
| 637 | + }; | |
| 638 | + ui.small(RichText::new(shorten(&resolved.root.display().to_string())).color(tint)); | |
| 639 | + ui.small(&resolved.note); | |
| 640 | + | |
| 641 | + match target::writable(&resolved.root) { | |
| 642 | + Ok(()) => { | |
| 643 | + let plan = target::plan(&resolved.root, &self.export_name); | |
| 644 | + ui.small(target::summarise(&plan)); | |
| 645 | + if plan.iter().any(|f| f.overwrites) { | |
| 646 | + ui.small( | |
| 647 | + RichText::new("Existing icons will be replaced.") | |
| 648 | + .color(ui.visuals().warn_fg_color), | |
| 649 | + ); | |
| 650 | + } | |
| 651 | + if ui.button("Export").clicked() { | |
| 652 | + self.do_export(resolved.root.clone()); | |
| 653 | + } | |
| 654 | + } | |
| 655 | + Err(e) => { | |
| 656 | + ui.small(RichText::new(e).color(ui.visuals().error_fg_color)); | |
| 657 | + } | |
| 560 | 658 | } |
| 659 | + | |
| 561 | 660 | if !self.status.is_empty() { |
| 562 | 661 | ui.small(RichText::new(&self.status).italics()); |
| 563 | 662 | } |
| 564 | 663 | } |
| 565 | 664 | |
| 566 | − fn do_export(&mut self) { | |
| 567 | − if self.export_dir.is_empty() { | |
| 568 | − self.status = "Pick a folder first.".into(); | |
| 569 | − return; | |
| 665 | + /// Where a folder dialog should open: the last destination, else the | |
| 666 | + /// document's own folder, else let the dialog decide. | |
| 667 | + fn start_dir(&self) -> Option<std::path::PathBuf> { | |
| 668 | + if !self.export_dir.is_empty() { | |
| 669 | + let p = std::path::PathBuf::from(&self.export_dir); | |
| 670 | + if p.is_dir() { | |
| 671 | + return Some(p); | |
| 672 | + } | |
| 570 | 673 | } |
| 571 | − let root = std::path::PathBuf::from(&self.export_dir); | |
| 674 | + self.config | |
| 675 | + .live_targets() | |
| 676 | + .first() | |
| 677 | + .map(std::path::PathBuf::from) | |
| 678 | + .or_else(|| { | |
| 679 | + self.path | |
| 680 | + .as_ref() | |
| 681 | + .and_then(|p| p.parent().map(|d| d.to_path_buf())) | |
| 682 | + }) | |
| 683 | + } | |
| 684 | + | |
| 685 | + fn set_target(&mut self, picked: &std::path::Path) { | |
| 686 | + self.export_dir = picked.display().to_string(); | |
| 687 | + // Remember what was picked, not what it resolved to: next time the | |
| 688 | + // dialog should open where the user was actually browsing. | |
| 689 | + self.config.remember_target(picked); | |
| 690 | + self.config.save(); | |
| 691 | + self.status.clear(); | |
| 692 | + } | |
| 693 | + | |
| 694 | + fn do_export(&mut self, root: std::path::PathBuf) { | |
| 572 | 695 | match export::export_mipmaps(&self.spec, &root, &self.export_name) { |
| 573 | 696 | Ok(files) => { |
| 574 | 697 | self.status = format!( |
| 575 | − "Wrote {} files across {} densities.", | |
| 698 | + "Wrote {} files across {} densities into {}.", | |
| 576 | 699 | files.len(), |
| 577 | − export::DENSITIES.len() | |
| 700 | + export::DENSITIES.len(), | |
| 701 | + shorten(&root.display().to_string()), | |
| 578 | 702 | ) |
| 579 | 703 | } |
| 580 | 704 | Err(e) => self.status = format!("Export failed: {e}"), |
| 581 | 705 | } |
| 582 | 706 | } |
| 583 | 707 | |
| 708 | + fn do_export_zip(&mut self) { | |
| 709 | + let suggested = format!("{}.zip", self.export_name); | |
| 710 | + let mut dlg = rfd::FileDialog::new() | |
| 711 | + .add_filter("Zip archive", &["zip"]) | |
| 712 | + .set_file_name(suggested); | |
| 713 | + if let Some(start) = self.start_dir() { | |
| 714 | + dlg = dlg.set_directory(start); | |
| 715 | + } | |
| 716 | + let Some(path) = dlg.save_file() else { return }; | |
| 717 | + let path = if path.extension().is_some() { | |
| 718 | + path | |
| 719 | + } else { | |
| 720 | + path.with_extension("zip") | |
| 721 | + }; | |
| 722 | + | |
| 723 | + let document = doc::to_toml(&self.document()).ok(); | |
| 724 | + let doc_name = format!("{}.toml", self.export_name); | |
| 725 | + let entry = document.as_deref().map(|body| (doc_name.as_str(), body)); | |
| 726 | + | |
| 727 | + match export::export_zip(&self.spec, &path, &self.export_name, entry) { | |
| 728 | + Ok(names) => { | |
| 729 | + if let Some(dir) = path.parent() { | |
| 730 | + self.config.remember_target(dir); | |
| 731 | + self.config.save(); | |
| 732 | + } | |
| 733 | + self.status = format!( | |
| 734 | + "Bundled {} entries into {}.", | |
| 735 | + names.len(), | |
| 736 | + shorten(&path.display().to_string()) | |
| 737 | + ); | |
| 738 | + } | |
| 739 | + Err(e) => self.status = format!("Bundle failed: {e}"), | |
| 740 | + } | |
| 741 | + } | |
| 742 | + | |
| 584 | 743 | fn preview(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { |
| 585 | 744 | let spec = self.spec.clone(); |
| 586 | 745 | let full = self.texture(ctx, &spec, PREVIEW, false); |
added
src/config.rs
+74
−0
@@ -0,0 +1,74 @@
| 1 | +//! Small persisted preferences — where you last exported to, mainly. | |
| 2 | +//! | |
| 3 | +//! Separate from a document: which folder you export into is a property of | |
| 4 | +//! this machine, not of the icon, so it should not travel when a `.toml` is | |
| 5 | +//! shared or committed. | |
| 6 | + | |
| 7 | +use std::path::{Path, PathBuf}; | |
| 8 | + | |
| 9 | +use serde::{Deserialize, Serialize}; | |
| 10 | + | |
| 11 | +const MAX_RECENT: usize = 8; | |
| 12 | + | |
| 13 | +#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)] | |
| 14 | +pub struct Config { | |
| 15 | + /// Most recent first. | |
| 16 | + #[serde(default)] | |
| 17 | + pub recent_targets: Vec<String>, | |
| 18 | + #[serde(default)] | |
| 19 | + pub last_document_dir: String, | |
| 20 | +} | |
| 21 | + | |
| 22 | +pub fn config_path() -> Option<PathBuf> { | |
| 23 | + let base = std::env::var_os("XDG_CONFIG_HOME") | |
| 24 | + .map(PathBuf::from) | |
| 25 | + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?; | |
| 26 | + Some(base.join("icon-studio").join("config.toml")) | |
| 27 | +} | |
| 28 | + | |
| 29 | +impl Config { | |
| 30 | + pub fn load() -> Config { | |
| 31 | + // A missing or unreadable config is normal on first run and must never | |
| 32 | + // be fatal; the defaults are perfectly usable. | |
| 33 | + let Some(p) = config_path() else { | |
| 34 | + return Config::default(); | |
| 35 | + }; | |
| 36 | + std::fs::read_to_string(p) | |
| 37 | + .ok() | |
| 38 | + .and_then(|s| toml::from_str(&s).ok()) | |
| 39 | + .unwrap_or_default() | |
| 40 | + } | |
| 41 | + | |
| 42 | + pub fn save(&self) { | |
| 43 | + let Some(p) = config_path() else { return }; | |
| 44 | + if let Some(dir) = p.parent() { | |
| 45 | + if std::fs::create_dir_all(dir).is_err() { | |
| 46 | + return; | |
| 47 | + } | |
| 48 | + } | |
| 49 | + if let Ok(text) = toml::to_string(self) { | |
| 50 | + let _ = std::fs::write(p, text); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + /// Records a destination, most-recent-first and without duplicates. | |
| 55 | + pub fn remember_target(&mut self, path: &Path) { | |
| 56 | + let s = path.display().to_string(); | |
| 57 | + if s.is_empty() { | |
| 58 | + return; | |
| 59 | + } | |
| 60 | + self.recent_targets.retain(|r| r != &s); | |
| 61 | + self.recent_targets.insert(0, s); | |
| 62 | + self.recent_targets.truncate(MAX_RECENT); | |
| 63 | + } | |
| 64 | + | |
| 65 | + /// Recent entries that still exist, so a deleted folder stops being | |
| 66 | + /// offered rather than failing when chosen. | |
| 67 | + pub fn live_targets(&self) -> Vec<&str> { | |
| 68 | + self.recent_targets | |
| 69 | + .iter() | |
| 70 | + .map(String::as_str) | |
| 71 | + .filter(|s| Path::new(s).is_dir()) | |
| 72 | + .collect() | |
| 73 | + } | |
| 74 | +} |
modified
src/export.rs
+51
−0
@@ -1,5 +1,6 @@
| 1 | 1 | //! Writing icons out as Android mipmap resources. |
| 2 | 2 | |
| 3 | +use std::io::Write; | |
| 3 | 4 | use std::path::{Path, PathBuf}; |
| 4 | 5 | |
| 5 | 6 | use crate::render; |
@@ -45,3 +46,53 @@ pub fn export_mipmaps(
| 45 | 46 | } |
| 46 | 47 | Ok(written) |
| 47 | 48 | } |
| 49 | + | |
| 50 | +/// The mipmap tree as a single `.zip`, for handing to someone who is not | |
| 51 | +/// pointing at the project on disk. | |
| 52 | +/// | |
| 53 | +/// The source document goes in beside the images. A bundle that contains only | |
| 54 | +/// PNGs is a dead end — whoever receives it cannot change the icon without | |
| 55 | +/// rebuilding the configuration by eye, which is the exact problem this whole | |
| 56 | +/// program exists to avoid. | |
| 57 | +pub fn export_zip( | |
| 58 | + spec: &IconSpec, | |
| 59 | + path: &Path, | |
| 60 | + base_name: &str, | |
| 61 | + document: Option<(&str, &str)>, | |
| 62 | +) -> Result<Vec<String>, String> { | |
| 63 | + let file = std::fs::File::create(path).map_err(|e| format!("{}: {e}", path.display()))?; | |
| 64 | + let mut zip = zip::ZipWriter::new(file); | |
| 65 | + let options: zip::write::FileOptions<()> = | |
| 66 | + zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated); | |
| 67 | + | |
| 68 | + let mut names = Vec::new(); | |
| 69 | + | |
| 70 | + for (bucket, size) in DENSITIES { | |
| 71 | + for (suffix, pm) in [ | |
| 72 | + ("", render::render_composed(spec, *size)), | |
| 73 | + ("_background", render::render_background(spec, *size)), | |
| 74 | + ("_foreground", render::render_foreground(spec, *size)), | |
| 75 | + ] { | |
| 76 | + let name = format!("mipmap-{bucket}/{base_name}{suffix}.png"); | |
| 77 | + let png = pm | |
| 78 | + .encode_png() | |
| 79 | + .map_err(|e| format!("encoding {name}: {e}"))?; | |
| 80 | + zip.start_file(&name, options) | |
| 81 | + .map_err(|e| format!("{name}: {e}"))?; | |
| 82 | + zip.write_all(&png).map_err(|e| format!("{name}: {e}"))?; | |
| 83 | + names.push(name); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + if let Some((doc_name, body)) = document { | |
| 88 | + zip.start_file(doc_name, options) | |
| 89 | + .map_err(|e| format!("{doc_name}: {e}"))?; | |
| 90 | + zip.write_all(body.as_bytes()) | |
| 91 | + .map_err(|e| format!("{doc_name}: {e}"))?; | |
| 92 | + names.push(doc_name.to_string()); | |
| 93 | + } | |
| 94 | + | |
| 95 | + zip.finish() | |
| 96 | + .map_err(|e| format!("{}: {e}", path.display()))?; | |
| 97 | + Ok(names) | |
| 98 | +} |
modified
src/lib.rs
+2
−0
@@ -8,9 +8,11 @@
| 8 | 8 | //! binary is only the eframe shell around it. |
| 9 | 9 | |
| 10 | 10 | pub mod app; |
| 11 | +pub mod config; | |
| 11 | 12 | pub mod doc; |
| 12 | 13 | pub mod export; |
| 13 | 14 | pub mod icons; |
| 14 | 15 | pub mod palette; |
| 15 | 16 | pub mod render; |
| 16 | 17 | pub mod spec; |
| 18 | +pub mod target; |
added
src/target.rs
+175
−0
@@ -0,0 +1,175 @@
| 1 | +//! Working out where icons should actually be written. | |
| 2 | +//! | |
| 3 | +//! Export lays down `mipmap-<density>/` directories, which only mean anything | |
| 4 | +//! inside an Android `res/` directory. Picking a project root and getting | |
| 5 | +//! `mipmap-xxxhdpi/` dropped next to `build.gradle.kts` is a silent mistake — | |
| 6 | +//! the files are written, nothing complains, and the app keeps its old icon. | |
| 7 | +//! So the picked directory is interpreted rather than taken literally. | |
| 8 | + | |
| 9 | +use std::path::{Path, PathBuf}; | |
| 10 | + | |
| 11 | +use crate::export::DENSITIES; | |
| 12 | + | |
| 13 | +#[derive(Clone, Copy, PartialEq, Eq, Debug)] | |
| 14 | +pub enum Kind { | |
| 15 | + /// The directory is itself an Android `res/`. | |
| 16 | + AndroidRes, | |
| 17 | + /// An Android module or project; a `res/` was found beneath it. | |
| 18 | + AndroidProject, | |
| 19 | + /// Somewhere else. Written to literally. | |
| 20 | + Plain, | |
| 21 | +} | |
| 22 | + | |
| 23 | +#[derive(Clone, PartialEq, Debug)] | |
| 24 | +pub struct Target { | |
| 25 | + /// Where the `mipmap-*` directories will be created. | |
| 26 | + pub root: PathBuf, | |
| 27 | + pub kind: Kind, | |
| 28 | + /// Why this directory, in a sentence, for the UI to show. | |
| 29 | + pub note: String, | |
| 30 | +} | |
| 31 | + | |
| 32 | +fn looks_like_res(p: &Path) -> bool { | |
| 33 | + if !p.is_dir() { | |
| 34 | + return false; | |
| 35 | + } | |
| 36 | + if p.file_name().is_some_and(|n| n == "res") { | |
| 37 | + return true; | |
| 38 | + } | |
| 39 | + // A res directory by content rather than by name: resource folders are | |
| 40 | + // the give-away, since a project root has none of these. | |
| 41 | + let Ok(entries) = std::fs::read_dir(p) else { | |
| 42 | + return false; | |
| 43 | + }; | |
| 44 | + entries.filter_map(Result::ok).any(|e| { | |
| 45 | + let n = e.file_name(); | |
| 46 | + let n = n.to_string_lossy(); | |
| 47 | + n.starts_with("mipmap-") || n.starts_with("drawable") || n == "values" | |
| 48 | + }) | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Evidence that a directory is a resource root which does not amount to | |
| 52 | +/// "something inside it is named like a resource bucket". | |
| 53 | +fn is_definitely_res(p: &Path) -> bool { | |
| 54 | + if !p.is_dir() { | |
| 55 | + return false; | |
| 56 | + } | |
| 57 | + if p.file_name().is_some_and(|n| n == "res") { | |
| 58 | + return true; | |
| 59 | + } | |
| 60 | + p.join("values").is_dir() | |
| 61 | +} | |
| 62 | + | |
| 63 | +/// The conventional places a `res/` sits relative to a project or module root. | |
| 64 | +const RES_CANDIDATES: &[&str] = &[ | |
| 65 | + "app/src/main/res", | |
| 66 | + "src/main/res", | |
| 67 | + "app/src/debug/res", | |
| 68 | + "res", | |
| 69 | +]; | |
| 70 | + | |
| 71 | +/// Interprets a directory the user picked into somewhere to actually write. | |
| 72 | +pub fn resolve(picked: &Path) -> Target { | |
| 73 | + // Browsing into `mipmap-xxxhdpi` to see the existing icons and stopping | |
| 74 | + // there is an easy thing to do, and writing `mipmap-*/` inside it would | |
| 75 | + // nest a resource tree inside a resource directory. | |
| 76 | + if let Some(name) = picked.file_name().map(|n| n.to_string_lossy().to_string()) { | |
| 77 | + let is_resource_bucket = name.starts_with("mipmap-") | |
| 78 | + || name.starts_with("drawable") | |
| 79 | + || name.starts_with("values"); | |
| 80 | + if is_resource_bucket { | |
| 81 | + if let Some(parent) = picked.parent() { | |
| 82 | + // A stricter test than looks_like_res on purpose. That one | |
| 83 | + // counts "contains a mipmap-* directory" as evidence, which | |
| 84 | + // the parent of a bucket always satisfies — including a stray | |
| 85 | + // mipmap-foo on a desktop, whose parent is the desktop. | |
| 86 | + if is_definitely_res(parent) { | |
| 87 | + return Target { | |
| 88 | + root: parent.to_path_buf(), | |
| 89 | + kind: Kind::AndroidRes, | |
| 90 | + note: format!("Using the parent res directory, not {name}."), | |
| 91 | + }; | |
| 92 | + } | |
| 93 | + } | |
| 94 | + } | |
| 95 | + } | |
| 96 | + | |
| 97 | + if looks_like_res(picked) { | |
| 98 | + return Target { | |
| 99 | + root: picked.to_path_buf(), | |
| 100 | + kind: Kind::AndroidRes, | |
| 101 | + note: "Android resource directory.".into(), | |
| 102 | + }; | |
| 103 | + } | |
| 104 | + | |
| 105 | + for rel in RES_CANDIDATES { | |
| 106 | + let candidate = picked.join(rel); | |
| 107 | + if looks_like_res(&candidate) { | |
| 108 | + return Target { | |
| 109 | + root: candidate, | |
| 110 | + kind: Kind::AndroidProject, | |
| 111 | + note: format!("Found an Android res directory at {rel}."), | |
| 112 | + }; | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + Target { | |
| 117 | + root: picked.to_path_buf(), | |
| 118 | + kind: Kind::Plain, | |
| 119 | + note: "Not an Android project — writing mipmap directories here.".into(), | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +#[derive(Clone, PartialEq, Debug)] | |
| 124 | +pub struct PlannedFile { | |
| 125 | + pub path: PathBuf, | |
| 126 | + /// True when a file is already there and will be replaced. | |
| 127 | + pub overwrites: bool, | |
| 128 | +} | |
| 129 | + | |
| 130 | +/// Exactly what an export would write, so it can be shown before it happens. | |
| 131 | +pub fn plan(root: &Path, base_name: &str) -> Vec<PlannedFile> { | |
| 132 | + let mut out = Vec::new(); | |
| 133 | + for (bucket, _) in DENSITIES { | |
| 134 | + for suffix in ["", "_background", "_foreground"] { | |
| 135 | + let path = root | |
| 136 | + .join(format!("mipmap-{bucket}")) | |
| 137 | + .join(format!("{base_name}{suffix}.png")); | |
| 138 | + let overwrites = path.exists(); | |
| 139 | + out.push(PlannedFile { path, overwrites }); | |
| 140 | + } | |
| 141 | + } | |
| 142 | + out | |
| 143 | +} | |
| 144 | + | |
| 145 | +/// A short "12 files, 3 replaced" for the UI. | |
| 146 | +pub fn summarise(plan: &[PlannedFile]) -> String { | |
| 147 | + let replaced = plan.iter().filter(|f| f.overwrites).count(); | |
| 148 | + match replaced { | |
| 149 | + 0 => format!("{} files, none existing", plan.len()), | |
| 150 | + n => format!("{} files, {n} replaced", plan.len()), | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +/// Whether the target is somewhere icons can be written. | |
| 155 | +/// | |
| 156 | +/// Deliberately a cheap check — this runs while the panel is drawn, so it must | |
| 157 | +/// not touch the filesystem beyond a stat. The destination always comes from a | |
| 158 | +/// folder dialog or a recent entry that was filtered for existence, so | |
| 159 | +/// requiring it to exist costs nothing and makes a deleted folder visible | |
| 160 | +/// before the export rather than halfway through writing fifteen files. Only | |
| 161 | +/// the `mipmap-*` directories beneath it are created. | |
| 162 | +pub fn writable(root: &Path) -> Result<(), String> { | |
| 163 | + if root.as_os_str().is_empty() { | |
| 164 | + return Err("No folder chosen.".into()); | |
| 165 | + } | |
| 166 | + let meta = | |
| 167 | + std::fs::metadata(root).map_err(|_| format!("{} is not reachable.", root.display()))?; | |
| 168 | + if !meta.is_dir() { | |
| 169 | + return Err(format!("{} is not a directory.", root.display())); | |
| 170 | + } | |
| 171 | + if meta.permissions().readonly() { | |
| 172 | + return Err(format!("{} is read-only.", root.display())); | |
| 173 | + } | |
| 174 | + Ok(()) | |
| 175 | +} |
added
tests/targets.rs
+199
−0
@@ -0,0 +1,199 @@
| 1 | +//! Choosing where icons go, and bundling them up. | |
| 2 | + | |
| 3 | +use std::fs; | |
| 4 | +use std::path::{Path, PathBuf}; | |
| 5 | + | |
| 6 | +use icon_studio::spec::IconSpec; | |
| 7 | +use icon_studio::target::{self, Kind}; | |
| 8 | + | |
| 9 | +/// A scratch directory that cleans itself up. | |
| 10 | +struct Tmp(PathBuf); | |
| 11 | + | |
| 12 | +impl Tmp { | |
| 13 | + fn new(tag: &str) -> Tmp { | |
| 14 | + let p = std::env::temp_dir().join(format!( | |
| 15 | + "icon-studio-{tag}-{}-{:?}", | |
| 16 | + std::process::id(), | |
| 17 | + std::time::SystemTime::now() | |
| 18 | + .duration_since(std::time::UNIX_EPOCH) | |
| 19 | + .unwrap() | |
| 20 | + .as_nanos() | |
| 21 | + )); | |
| 22 | + fs::create_dir_all(&p).unwrap(); | |
| 23 | + Tmp(p) | |
| 24 | + } | |
| 25 | + fn path(&self) -> &Path { | |
| 26 | + &self.0 | |
| 27 | + } | |
| 28 | + fn mkdir(&self, rel: &str) -> PathBuf { | |
| 29 | + let p = self.0.join(rel); | |
| 30 | + fs::create_dir_all(&p).unwrap(); | |
| 31 | + p | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +impl Drop for Tmp { | |
| 36 | + fn drop(&mut self) { | |
| 37 | + let _ = fs::remove_dir_all(&self.0); | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +#[test] | |
| 42 | +fn an_android_project_root_resolves_to_its_res_directory() { | |
| 43 | + // The mistake this exists to prevent: picking the project root and | |
| 44 | + // getting mipmap directories dropped beside build.gradle.kts. | |
| 45 | + let t = Tmp::new("proj"); | |
| 46 | + t.mkdir("app/src/main/res/values"); | |
| 47 | + fs::write(t.path().join("build.gradle.kts"), "").unwrap(); | |
| 48 | + | |
| 49 | + let r = target::resolve(t.path()); | |
| 50 | + assert_eq!(r.kind, Kind::AndroidProject); | |
| 51 | + assert_eq!(r.root, t.path().join("app/src/main/res")); | |
| 52 | + assert!(r.note.contains("app/src/main/res"), "{}", r.note); | |
| 53 | +} | |
| 54 | + | |
| 55 | +#[test] | |
| 56 | +fn a_module_root_resolves_too() { | |
| 57 | + let t = Tmp::new("module"); | |
| 58 | + t.mkdir("src/main/res/values"); | |
| 59 | + let r = target::resolve(t.path()); | |
| 60 | + assert_eq!(r.kind, Kind::AndroidProject); | |
| 61 | + assert_eq!(r.root, t.path().join("src/main/res")); | |
| 62 | +} | |
| 63 | + | |
| 64 | +#[test] | |
| 65 | +fn a_res_directory_is_used_as_is() { | |
| 66 | + let t = Tmp::new("res"); | |
| 67 | + let res = t.mkdir("res"); | |
| 68 | + t.mkdir("res/values"); | |
| 69 | + let r = target::resolve(&res); | |
| 70 | + assert_eq!(r.kind, Kind::AndroidRes); | |
| 71 | + assert_eq!(r.root, res); | |
| 72 | +} | |
| 73 | + | |
| 74 | +#[test] | |
| 75 | +fn a_res_directory_is_recognised_by_content_not_only_by_name() { | |
| 76 | + // Resource directories are not always called `res`. | |
| 77 | + let t = Tmp::new("byname"); | |
| 78 | + let odd = t.mkdir("resources"); | |
| 79 | + t.mkdir("resources/mipmap-xxxhdpi"); | |
| 80 | + let r = target::resolve(&odd); | |
| 81 | + assert_eq!(r.kind, Kind::AndroidRes, "{}", r.note); | |
| 82 | +} | |
| 83 | + | |
| 84 | +#[test] | |
| 85 | +fn picking_a_density_bucket_steps_up_to_the_res_directory() { | |
| 86 | + // Browsing in to look at the current icons and stopping there would | |
| 87 | + // otherwise nest mipmap-xxxhdpi/mipmap-xxxhdpi. | |
| 88 | + let t = Tmp::new("bucket"); | |
| 89 | + t.mkdir("res/values"); | |
| 90 | + let bucket = t.mkdir("res/mipmap-xxxhdpi"); | |
| 91 | + | |
| 92 | + let r = target::resolve(&bucket); | |
| 93 | + assert_eq!(r.kind, Kind::AndroidRes); | |
| 94 | + assert_eq!(r.root, t.path().join("res")); | |
| 95 | + assert!(r.note.contains("parent res"), "{}", r.note); | |
| 96 | +} | |
| 97 | + | |
| 98 | +#[test] | |
| 99 | +fn a_lone_bucket_outside_a_res_tree_is_left_alone() { | |
| 100 | + // Only step up when the parent really is a resource directory. | |
| 101 | + let t = Tmp::new("lonebucket"); | |
| 102 | + let bucket = t.mkdir("mipmap-xxxhdpi"); | |
| 103 | + let r = target::resolve(&bucket); | |
| 104 | + assert_eq!(r.kind, Kind::Plain); | |
| 105 | + assert_eq!(r.root, bucket); | |
| 106 | +} | |
| 107 | + | |
| 108 | +#[test] | |
| 109 | +fn an_ordinary_folder_is_written_to_literally() { | |
| 110 | + let t = Tmp::new("plain"); | |
| 111 | + let r = target::resolve(t.path()); | |
| 112 | + assert_eq!(r.kind, Kind::Plain); | |
| 113 | + assert_eq!(r.root, t.path()); | |
| 114 | + assert!( | |
| 115 | + r.note.to_lowercase().contains("not an android"), | |
| 116 | + "{}", | |
| 117 | + r.note | |
| 118 | + ); | |
| 119 | +} | |
| 120 | + | |
| 121 | +#[test] | |
| 122 | +fn the_plan_lists_every_file_and_flags_replacements() { | |
| 123 | + let t = Tmp::new("plan"); | |
| 124 | + | |
| 125 | + let fresh = target::plan(t.path(), "ic_icon"); | |
| 126 | + assert_eq!(fresh.len(), icon_studio::export::DENSITIES.len() * 3); | |
| 127 | + assert!(fresh.iter().all(|f| !f.overwrites)); | |
| 128 | + assert!(target::summarise(&fresh).contains("none existing")); | |
| 129 | + | |
| 130 | + // Put one of them in place; it should come back flagged. | |
| 131 | + let victim = &fresh[0].path; | |
| 132 | + fs::create_dir_all(victim.parent().unwrap()).unwrap(); | |
| 133 | + fs::write(victim, b"x").unwrap(); | |
| 134 | + | |
| 135 | + let again = target::plan(t.path(), "ic_icon"); | |
| 136 | + assert_eq!(again.iter().filter(|f| f.overwrites).count(), 1); | |
| 137 | + assert!(target::summarise(&again).contains("1 replaced")); | |
| 138 | +} | |
| 139 | + | |
| 140 | +#[test] | |
| 141 | +fn writability_is_checked_before_writing() { | |
| 142 | + assert!(target::writable(Path::new("")).is_err(), "empty path"); | |
| 143 | + | |
| 144 | + let t = Tmp::new("writable"); | |
| 145 | + assert!(target::writable(t.path()).is_ok()); | |
| 146 | + | |
| 147 | + // A destination that no longer exists must be caught here, not on the | |
| 148 | + // fifteenth file. This is the case that actually happens: a remembered | |
| 149 | + // folder that has since been deleted or unmounted. | |
| 150 | + assert!( | |
| 151 | + target::writable(&t.path().join("since-deleted")).is_err(), | |
| 152 | + "missing directory" | |
| 153 | + ); | |
| 154 | + assert!( | |
| 155 | + target::writable(Path::new("/nonexistent-root-xyz/sub")).is_err(), | |
| 156 | + "unreachable path" | |
| 157 | + ); | |
| 158 | + | |
| 159 | + // A file is not a destination. | |
| 160 | + let f = t.path().join("a-file"); | |
| 161 | + fs::write(&f, b"x").unwrap(); | |
| 162 | + assert!(target::writable(&f).is_err(), "regular file"); | |
| 163 | +} | |
| 164 | + | |
| 165 | +#[test] | |
| 166 | +fn the_zip_bundle_carries_the_images_and_the_document() { | |
| 167 | + let t = Tmp::new("zip"); | |
| 168 | + let out = t.path().join("bundle.zip"); | |
| 169 | + | |
| 170 | + let names = icon_studio::export::export_zip( | |
| 171 | + &IconSpec::default(), | |
| 172 | + &out, | |
| 173 | + "ic_icon", | |
| 174 | + Some(("ic_icon.toml", "version = 1\n")), | |
| 175 | + ) | |
| 176 | + .expect("bundle written"); | |
| 177 | + | |
| 178 | + assert_eq!(names.len(), icon_studio::export::DENSITIES.len() * 3 + 1); | |
| 179 | + | |
| 180 | + let f = fs::File::open(&out).unwrap(); | |
| 181 | + let mut zip = zip::ZipArchive::new(f).expect("valid zip"); | |
| 182 | + let entries: Vec<String> = (0..zip.len()) | |
| 183 | + .map(|i| zip.by_index(i).unwrap().name().to_string()) | |
| 184 | + .collect(); | |
| 185 | + | |
| 186 | + assert!(entries.contains(&"mipmap-xxxhdpi/ic_icon.png".to_string())); | |
| 187 | + assert!(entries.contains(&"mipmap-mdpi/ic_icon_foreground.png".to_string())); | |
| 188 | + assert!( | |
| 189 | + entries.contains(&"ic_icon.toml".to_string()), | |
| 190 | + "the bundle should be reproducible, not just a pile of PNGs" | |
| 191 | + ); | |
| 192 | + | |
| 193 | + // And the PNGs are real PNGs at the right size. | |
| 194 | + let mut img = zip.by_name("mipmap-xxxhdpi/ic_icon.png").unwrap(); | |
| 195 | + let mut bytes = Vec::new(); | |
| 196 | + std::io::Read::read_to_end(&mut img, &mut bytes).unwrap(); | |
| 197 | + let decoded = image::load_from_memory(&bytes).expect("decodable png"); | |
| 198 | + assert_eq!(decoded.width(), 432); | |
| 199 | +} |
Parents: 14f2f6e