//! Working out where icons should actually be written.
//!
//! Export lays down `mipmap-<density>/` directories, which only mean anything
//! inside an Android `res/` directory. Picking a project root and getting
//! `mipmap-xxxhdpi/` dropped next to `build.gradle.kts` is a silent mistake —
//! the files are written, nothing complains, and the app keeps its old icon.
//! So the picked directory is interpreted rather than taken literally.
use std::path::{Path, PathBuf};
use crate::export::DENSITIES;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Kind {
/// The directory is itself an Android `res/`.
AndroidRes,
/// An Android module or project; a `res/` was found beneath it.
AndroidProject,
/// Somewhere else. Written to literally.
Plain,
}
#[derive(Clone, PartialEq, Debug)]
pub struct Target {
/// Where the `mipmap-*` directories will be created.
pub root: PathBuf,
pub kind: Kind,
/// Why this directory, in a sentence, for the UI to show.
pub note: String,
}
fn looks_like_res(p: &Path) -> bool {
if !p.is_dir() {
return false;
}
if p.file_name().is_some_and(|n| n == "res") {
return true;
}
// A res directory by content rather than by name: resource folders are
// the give-away, since a project root has none of these.
let Ok(entries) = std::fs::read_dir(p) else {
return false;
};
entries.filter_map(Result::ok).any(|e| {
let n = e.file_name();
let n = n.to_string_lossy();
n.starts_with("mipmap-") || n.starts_with("drawable") || n == "values"
})
}
/// Evidence that a directory is a resource root which does not amount to
/// "something inside it is named like a resource bucket".
fn is_definitely_res(p: &Path) -> bool {
if !p.is_dir() {
return false;
}
if p.file_name().is_some_and(|n| n == "res") {
return true;
}
p.join("values").is_dir()
}
/// The conventional places a `res/` sits relative to a project or module root.
const RES_CANDIDATES: &[&str] = &[
"app/src/main/res",
"src/main/res",
// A repository whose Android client is a subdirectory rather than the
// whole project — Diogramos keeps a Phoenix app at the root and the
// client under android/.
"android/app/src/main/res",
"android/src/main/res",
"app/src/debug/res",
"res",
];
/// Interprets a directory the user picked into somewhere to actually write.
pub fn resolve(picked: &Path) -> Target {
// Browsing into `mipmap-xxxhdpi` to see the existing icons and stopping
// there is an easy thing to do, and writing `mipmap-*/` inside it would
// nest a resource tree inside a resource directory.
if let Some(name) = picked.file_name().map(|n| n.to_string_lossy().to_string()) {
let is_resource_bucket = name.starts_with("mipmap-")
|| name.starts_with("drawable")
|| name.starts_with("values");
if is_resource_bucket {
if let Some(parent) = picked.parent() {
// A stricter test than looks_like_res on purpose. That one
// counts "contains a mipmap-* directory" as evidence, which
// the parent of a bucket always satisfies — including a stray
// mipmap-foo on a desktop, whose parent is the desktop.
if is_definitely_res(parent) {
return Target {
root: parent.to_path_buf(),
kind: Kind::AndroidRes,
note: format!("Using the parent res directory, not {name}."),
};
}
}
}
}
if looks_like_res(picked) {
return Target {
root: picked.to_path_buf(),
kind: Kind::AndroidRes,
note: "Android resource directory.".into(),
};
}
for rel in RES_CANDIDATES {
let candidate = picked.join(rel);
if looks_like_res(&candidate) {
return Target {
root: candidate,
kind: Kind::AndroidProject,
note: format!("Found an Android res directory at {rel}."),
};
}
}
Target {
root: picked.to_path_buf(),
kind: Kind::Plain,
note: "Not an Android project — writing mipmap directories here.".into(),
}
}
#[derive(Clone, PartialEq, Debug)]
pub struct PlannedFile {
pub path: PathBuf,
/// True when a file is already there and will be replaced.
pub overwrites: bool,
}
/// Exactly what an export would write, so it can be shown before it happens.
pub fn plan(root: &Path, base_name: &str) -> Vec<PlannedFile> {
let mut out = Vec::new();
for (bucket, _) in DENSITIES {
for suffix in ["", "_background", "_foreground"] {
let path = root
.join(format!("mipmap-{bucket}"))
.join(format!("{base_name}{suffix}.png"));
let overwrites = path.exists();
out.push(PlannedFile { path, overwrites });
}
}
let xml = root
.join("mipmap-anydpi-v26")
.join(format!("{base_name}.xml"));
let overwrites = xml.exists();
out.push(PlannedFile {
path: xml,
overwrites,
});
out
}
/// A short "12 files, 3 replaced" for the UI.
pub fn summarise(plan: &[PlannedFile]) -> String {
let replaced = plan.iter().filter(|f| f.overwrites).count();
match replaced {
0 => format!("{} files, none existing", plan.len()),
n => format!("{} files, {n} replaced", plan.len()),
}
}
/// Whether the target is somewhere icons can be written.
///
/// Deliberately a cheap check — this runs while the panel is drawn, so it must
/// not touch the filesystem beyond a stat. The destination always comes from a
/// folder dialog or a recent entry that was filtered for existence, so
/// requiring it to exist costs nothing and makes a deleted folder visible
/// before the export rather than halfway through writing fifteen files. Only
/// the `mipmap-*` directories beneath it are created.
pub fn writable(root: &Path) -> Result<(), String> {
if root.as_os_str().is_empty() {
return Err("No folder chosen.".into());
}
let meta =
std::fs::metadata(root).map_err(|_| format!("{} is not reachable.", root.display()))?;
if !meta.is_dir() {
return Err(format!("{} is not a directory.", root.display()));
}
if meta.permissions().readonly() {
return Err(format!("{} is read-only.", root.display()));
}
Ok(())
}
neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
6.4 KiB · text
History
7cbb63b