use std::path::PathBuf;
use crate::meta::Project;
use crate::targets::Target;
mod android;
pub mod apple; mod arkui;
pub mod gtk; pub mod qt; mod winui;
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct ResourceFile {
pub name: String,
pub path: PathBuf,
pub scale: u32,
}
#[derive(Debug, Default, Clone)]
pub struct ResourceSet {
pub images: Vec<ResourceFile>,
pub data: Vec<ResourceFile>,
}
impl ResourceSet {
pub fn scan(project: &Project) -> ResourceSet {
ResourceSet {
images: scan_dir(&project.root.join("images"), true),
data: scan_dir(&project.root.join("assets"), false),
}
}
pub fn is_empty(&self) -> bool {
self.images.is_empty() && self.data.is_empty()
}
}
fn scan_dir(dir: &std::path::Path, image: bool) -> Vec<ResourceFile> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for e in entries.flatten() {
let path = e.path();
if !path.is_file() {
continue;
}
let fname = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if fname.starts_with('.') {
continue;
}
let (name, scale) = if image {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&fname)
.to_string();
parse_scale(&stem)
} else {
(fname.clone(), 1)
};
out.push(ResourceFile { name, path, scale });
}
out.sort_by(|a, b| a.name.cmp(&b.name).then(a.scale.cmp(&b.scale)));
out
}
fn parse_scale(stem: &str) -> (String, u32) {
if let Some((base, tail)) = stem.rsplit_once('@')
&& let Some(digits) = tail.strip_suffix('x')
&& let Ok(scale) = digits.parse::<u32>()
&& scale >= 1
{
return (base.to_string(), scale);
}
(stem.to_string(), 1)
}
#[derive(Debug, Clone)]
pub struct FontFile {
pub path: PathBuf,
pub family: String,
pub ident: String,
}
impl FontFile {
pub fn staged_name(&self) -> String {
let ext = self
.path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("ttf")
.to_ascii_lowercase();
format!("{}.{ext}", self.ident)
}
}
pub fn scan_fonts(project: &Project) -> Result<Vec<FontFile>, String> {
let dir = project.root.join("fonts");
let mut out: Vec<FontFile> = Vec::new();
let Ok(entries) = std::fs::read_dir(&dir) else {
return Ok(out);
};
let mut files: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.is_file()
&& !p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.starts_with('.')
})
.collect();
files.sort();
for path in files {
let fname = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
if !matches!(ext.as_str(), "ttf" | "otf") {
return Err(format!(
"fonts/{fname}: only .ttf and .otf files can be bundled (Android's res/font/ \
accepts nothing else — convert collections/other formats to single faces)"
));
}
let bytes = std::fs::read(&path).map_err(|e| format!("fonts/{fname}: {e}"))?;
let names = day_fonts::parse_font_names(&bytes).ok_or_else(|| {
format!("fonts/{fname}: not a recognizable font file (no readable name table)")
})?;
let ident = day_fonts::font_ident(&names.family);
if let Some(prev) = out.iter().find(|f| f.ident == ident) {
return Err(format!(
"fonts/{fname}: family {:?} collides with {}'s family {:?} on the sanitized \
resource name `{ident}` — bundle one face per family, or rename a family",
names.family,
prev.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?"),
prev.family,
));
}
out.push(FontFile {
path,
family: names.family,
ident,
});
}
Ok(out)
}
#[allow(dead_code)] pub fn sanitize_ident(name: &str) -> String {
let mut s: String = name
.chars()
.map(|c| {
let c = c.to_ascii_lowercase();
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
if !s.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
s.insert(0, 'r');
}
s
}
pub fn app_icon(project: &Project, toolkit: &'static str) -> Option<PathBuf> {
let icons = project.root.join("icons");
let (subdirs, ext): (&[&str], &str) = match toolkit {
"winui" => (&["windows", ""], "ico"),
_ if cfg!(target_os = "macos") => (&["macos", "png", ""], "png"),
_ => (&["linux", "png", ""], "png"),
};
for sub in subdirs {
let dir = if sub.is_empty() {
icons.clone()
} else {
icons.join(sub)
};
let mut best: Option<(u64, PathBuf)> = None;
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some(ext) {
continue;
}
let size = e.metadata().map(|m| m.len()).unwrap_or(0);
if best.as_ref().is_none_or(|(s, _)| size > *s) {
best = Some((size, p));
}
}
if let Some((_, p)) = best {
return Some(p);
}
}
None
}
pub fn stage(project: &Project, target: &Target) -> Result<(), String> {
let set = ResourceSet::scan(project);
let fonts = scan_fonts(project)?;
if set.is_empty() && fonts.is_empty() {
return Ok(());
}
match target.toolkit {
"uikit" => Ok(()),
"widget" => android::stage(project, &set, &fonts),
"arkui" => arkui::stage(project, &set, &fonts),
"gtk" => gtk::stage(project, &set),
"qt" => qt::stage(project, &set),
"winui" => winui::stage(project, &set),
_ => Ok(()),
}
}