Make export destinations smarter, and add a zip bundle

bf97ff0 · gmorell · 2026-09-22 04:24

11 files +757 -17
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