relm4_icons_build/
lib.rs

1//! Utilities for build scripts using `relm4-icons`.
2
3use std::collections::{BTreeMap, HashMap};
4use std::env;
5use std::fs;
6use std::fs::File;
7use std::io::BufWriter;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11use gvdb::gresource::{BundleBuilder, FileData, PreprocessOptions};
12use serde::Deserialize;
13use serde::Serialize;
14use walkdir::WalkDir;
15
16/// Stores data for each icon:
17struct IconData {
18    /// actual location on disk
19    path: PathBuf,
20    /// whether the icon is part of the shipped set
21    is_shipped: bool,
22}
23
24#[derive(Serialize, Deserialize)]
25struct Config {}
26
27/// Constants file with paths to icons.
28pub mod constants {
29    pub const SHIPPED_ICONS_PATH: &str =
30        include_str!(concat!(env!("OUT_DIR"), "/shipped_icons.txt"));
31}
32
33const GENERAL_PREFIX: &str = "/org/relm4/icons";
34
35/// Parse a filename into icon name.
36/// - Strips `.svg`
37pub fn path_to_icon_alias(path: impl AsRef<Path>) -> Option<String> {
38    match path.as_ref().to_str() {
39        Some(path) => {
40            if path.ends_with(".svg") {
41                println!("{path}");
42                Some(path.trim_end_matches(".svg").to_owned())
43            } else {
44                println!("Found non-icon file `{path}`, ignoring");
45                None
46            }
47        }
48        None => panic!(
49            "Failed to convert file path `{:?}` to string",
50            path.as_ref()
51        ),
52    }
53}
54
55/// Bundles icons into a `.gresource` file and generates Rust constants for icon names.
56///
57/// - Custom icons keep their original symbolic state based on the filename.
58/// - Shipped icons are always treated as symbolic internally, but their constant names do **not** get `_SYMBOLIC`.
59pub fn bundle_icons<P, I, S>(
60    out_file_name: &str,
61    app_id: Option<&str>,
62    base_resource_path: Option<&str>,
63    icons_folder: Option<P>,
64    icon_names: I,
65) where
66    P: AsRef<Path>,
67    I: IntoIterator<Item = S>,
68    S: AsRef<str>,
69{
70    let out_dir = env::var("OUT_DIR").unwrap();
71    let out_dir = Path::new(&out_dir);
72    let mut icons: HashMap<String, IconData> = HashMap::new();
73
74    // Package custom icons
75    if let Some(folder) = &icons_folder {
76        println!("cargo:rerun-if-changed={}", folder.as_ref().display());
77
78        let read_dir = WalkDir::new(folder);
79        for entry in read_dir {
80            let entry = entry
81                .expect("Couldn't open icon path specified in config (relative to the manifest)");
82            if let Some(icon) = path_to_icon_alias(entry.path()) {
83                if icons
84                    .insert(
85                        icon.replace('/', "-").replace('\\', "-").clone(),
86                        IconData {
87                            path: entry.path().to_path_buf(),
88                            is_shipped: false,
89                        },
90                    )
91                    .is_some()
92                {
93                    panic!("Icon with name `{icon}` exists twice");
94                }
95            }
96        }
97    }
98
99    let shipped_icons_folder = constants::SHIPPED_ICONS_PATH;
100
101    let dirs = fs::read_dir(shipped_icons_folder)
102        .expect("Couldn't open folder of shipped icons")
103        .map(|entry| {
104            entry
105                .expect("Couldn't open directories in shipped icon folder")
106                .path()
107        })
108        .collect::<Vec<_>>();
109
110    for icon in icon_names {
111        let icon = icon.as_ref();
112        let icon_path = dirs
113            .iter()
114            .find_map(|dir| {
115                let icon_file_name = format!("{icon}-symbolic.svg");
116                let icon_path = dir.join(icon_file_name);
117                icon_path.exists().then_some(icon_path)
118            })
119            .unwrap_or_else(|| panic!("Icon with name `{icon}` does not exist"));
120
121        if icons
122            .insert(
123                icon.to_string(),
124                IconData {
125                    path: icon_path,
126                    is_shipped: true,
127                },
128            )
129            .is_some()
130        {
131            panic!("Icon with name `{icon}` exists twice");
132        }
133    }
134
135    let prefix = if let Some(base_resource_path) = &base_resource_path {
136        format!("{base_resource_path}/icons")
137    } else if let Some(app_id) = app_id {
138        format!("/{}/icons", app_id.replace('.', "/"))
139    } else {
140        GENERAL_PREFIX.into()
141    };
142    let gresource_file_name = format!("{out_file_name}.gresource");
143
144    // Generate resource bundle
145    {
146        let resources = icons
147            .iter()
148            .map(|(icon, IconData { path, is_shipped })| {
149                FileData::from_file(
150                    if *is_shipped {
151                        format!("{prefix}/scalable/actions/{icon}-symbolic.svg")
152                    } else {
153                        format!("{prefix}/scalable/actions/{icon}.svg")
154                    },
155                    path,
156                    true,
157                    &PreprocessOptions::xml_stripblanks(),
158                )
159                .unwrap()
160            })
161            .collect();
162
163        let data = BundleBuilder::from_file_data(resources)
164            .build()
165            .expect("Failed to build resource bundle");
166
167        fs::write(out_dir.join(&gresource_file_name), data).unwrap();
168    }
169
170    // Create file that contains the icon names as constants
171    {
172        let mut out_file = BufWriter::new(File::create(out_dir.join(out_file_name)).unwrap());
173
174        writeln!(out_file, "#[rustfmt::skip]").unwrap();
175        writeln!(
176            out_file,
177            "pub mod shipped {{\n\
178            //! module contains shipped icons\n"
179        )
180        .unwrap();
181        for (icon, IconData { path, is_shipped }) in &icons {
182            if *is_shipped {
183                let const_name = icon.to_uppercase().replace('-', "_");
184                let path = path.display();
185                writeln!(
186                    out_file,
187                    "/// Icon name of the icon `{icon}`, found at `{path}`\n\
188                    pub const {const_name}: &str = \"{icon}\";"
189                )
190                .unwrap();
191            }
192        }
193        writeln!(out_file, "}}\n").unwrap();
194
195        writeln!(
196            out_file,
197            "pub mod custom {{\n\
198            //! module contains user's custom icons\n"
199        )
200        .unwrap();
201        let mut modules = BTreeMap::<Vec<&str>, Vec<(String, String)>>::new();
202        for (icon, IconData { path, is_shipped }) in &icons {
203            if !*is_shipped {
204                let mut path_vec = path
205                    .strip_prefix(icons_folder.as_ref().unwrap())
206                    .unwrap()
207                    .to_str()
208                    .unwrap()
209                    .split(&['/', '\\'])
210                    .collect::<Vec<_>>();
211
212                let file_name = path_vec.pop().unwrap().trim_end_matches(".svg");
213                let dir_components = path_vec;
214
215                let const_name = file_name.to_uppercase().replace('-', "_");
216                modules
217                    .entry(dir_components)
218                    .or_default()
219                    .push((const_name, icon.to_string()));
220            }
221        }
222        for (module_path, constants) in &modules {
223            if module_path.is_empty() {
224                for (const_name, const_value) in constants {
225                    writeln!(
226                        out_file,
227                        "pub const {const_name}: &str = \"{const_value}\";"
228                    )
229                    .unwrap();
230                }
231            } else {
232                for part in module_path.iter() {
233                    writeln!(out_file, "pub mod {} {{", part.replace('-', "_")).unwrap();
234                }
235                for (const_name, const_value) in constants {
236                    writeln!(
237                        out_file,
238                        "pub const {const_name}: &str = \"{const_value}\";"
239                    )
240                    .unwrap();
241                }
242                for _ in 0..module_path.len() {
243                    writeln!(out_file, "}}").unwrap();
244                }
245            }
246        }
247        writeln!(out_file, "}}").unwrap();
248        write!(
249            out_file,
250            "/// `GResource` file contents\n\
251            pub const GRESOURCE_BYTES: &[u8] = include_bytes!(\"{gresource_file_name}\");\n\
252            /// Resource prefix used in generated `.gresource` file\n\
253            pub const RESOURCE_PREFIX: &str = \"{prefix}\";"
254        )
255        .unwrap();
256    }
257}