qframe/icons/kinds/mod.rs
1//! What a file is, told from its name: the icon a list draws for it and the family it belongs to.
2//!
3//! A person should know what a file is from its icon before reading its name. [`file_kind`] picks
4//! the icon key from the name alone, so a folder of ten thousand entries is drawn without opening
5//! any of them. The keys are icons of the built-in set: in a Nerd Font each kind draws its own
6//! glyph (the Rust logo, a PDF page, a zipper), and without one each draws its family's shape, so
7//! code, pictures and archives are still told apart in Unicode and in ASCII.
8//!
9//! The folders of a person's home, whose names depend on their language, are
10//! [`UserFolders`]' to recognise.
11
12mod table;
13mod user;
14
15#[cfg(test)]
16mod tests;
17
18pub use user::UserFolders;
19
20/// The family a kind of file belongs to: what its shape is outside a Nerd Font, and what colour it
21/// takes where kinds are coloured.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23#[non_exhaustive]
24pub enum KindFamily {
25 /// A folder, whatever it holds.
26 Folder,
27 /// Plain text: notes, logs, a readme, a licence.
28 Text,
29 /// A document laid out for reading: PDF, a word processor's file, a book.
30 Document,
31 /// A spreadsheet, a table of values or a presentation.
32 Sheet,
33 /// Source code and the files that build it.
34 Code,
35 /// Data and settings: JSON, TOML, YAML, a database, a lock file.
36 Data,
37 /// A picture, a drawing or a 3D scene.
38 Image,
39 /// Sound.
40 Audio,
41 /// Moving pictures.
42 Video,
43 /// An archive of other files.
44 Archive,
45 /// A package to install, or the image of a whole disk.
46 Package,
47 /// A program or a library of one.
48 Executable,
49 /// A key, a signature or a certificate.
50 Key,
51 /// A font.
52 Font,
53 /// A file whose kind is not known.
54 File,
55}
56
57impl KindFamily {
58 /// The theme colour the family takes where kinds are coloured, or `None` for a file whose kind
59 /// is not known, which keeps the row's own colour.
60 ///
61 /// Folders take the accent, and the files four of the theme's series tones: code and writing
62 /// one, pictures, sound, video and fonts one, data and keys one, and archives, packages and
63 /// programs one. The first series tone is the accent itself in every built-in theme, so the
64 /// folders stand for it. The colour only repeats what the shape already says.
65 #[must_use]
66 pub fn tone(self) -> Option<&'static str> {
67 match self {
68 Self::Folder => Some("accent"),
69 Self::Code | Self::Text | Self::Document | Self::Sheet => Some("series-2"),
70 Self::Image | Self::Audio | Self::Video | Self::Font => Some("series-3"),
71 Self::Data | Self::Key => Some("series-4"),
72 Self::Archive | Self::Package | Self::Executable => Some("series-5"),
73 Self::File => None,
74 }
75 }
76}
77
78/// The kind of a file or folder: the icon it is drawn with and the family that icon belongs to.
79///
80/// Found by [`file_kind`], or by [`UserFolders::kind`] for the folders of a home.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub struct FileKind {
83 icon: &'static str,
84 family: KindFamily,
85}
86
87impl FileKind {
88 /// A file whose kind is not known: the `file` icon.
89 const FILE: Self = Self { icon: "file", family: KindFamily::File };
90
91 /// A folder whose name says nothing more: the `folder` icon.
92 const FOLDER: Self = Self { icon: "folder", family: KindFamily::Folder };
93
94 /// A file that is run: the `file-executable` icon.
95 const EXECUTABLE: Self = Self { icon: "file-executable", family: KindFamily::Executable };
96
97 /// The kind drawn with the icon `key` of the tables, in the family the tables give it.
98 fn of(key: &'static str) -> Self {
99 let family =
100 table::KINDS.binary_search_by(|(kind, _)| kind.cmp(&key)).map_or(KindFamily::File, |at| table::KINDS[at].1);
101 Self { icon: key, family }
102 }
103
104 /// The icon key of the built-in set, such as `"file-rust"`, `"folder-git"`, or `"file"` and
105 /// `"folder"` when nothing more is known. A theme or an application can restyle any of them.
106 #[must_use]
107 pub fn icon(&self) -> &'static str {
108 self.icon
109 }
110
111 /// The family the kind belongs to.
112 #[must_use]
113 pub fn family(&self) -> KindFamily {
114 self.family
115 }
116}
117
118/// The kind of the entry called `name`: a folder when `folder`, and a file that may be run when
119/// `executable`.
120///
121/// The name alone decides, in this order: the whole name (`Cargo.toml`, `Dockerfile`, `PKGBUILD`,
122/// `.bashrc`, and `README` or `LICENSE` with any text ending such as `README.de.md` or
123/// `LICENSE-MIT`), then an ending of several parts (`.tar.gz`, `.pkg.tar.zst`, `.d.ts`), then the
124/// extension, then, for a folder, a name that says what it holds (`.git`, `node_modules`, `src`),
125/// and a folder the platform hides that says nothing more (`.mozilla`).
126/// A file that may be run shows as a program only when nothing before that recognised it, so
127/// `build.sh` stays a shell script. Anything else is `file` or `folder`: every entry has an icon.
128/// Letter case never matters.
129///
130/// Nothing is read from the disk. The folders of a home, such as `Downloads` or its translation,
131/// are [`UserFolders`]' to recognise, because their names are the person's own.
132///
133/// ```
134/// use qframe::icons::{KindFamily, file_kind};
135///
136/// assert_eq!(file_kind("main.rs", false, false).icon(), "file-rust");
137/// assert_eq!(file_kind("backup.TAR.GZ", false, false).family(), KindFamily::Archive);
138/// assert_eq!(file_kind("configure", false, true).icon(), "file-executable");
139/// assert_eq!(file_kind(".git", true, false).icon(), "folder-git");
140/// assert_eq!(file_kind("notes", true, false).icon(), "folder");
141/// ```
142#[must_use]
143pub fn file_kind(name: &str, folder: bool, executable: bool) -> FileKind {
144 let lower = name.to_ascii_lowercase();
145 if folder {
146 let hidden = lower.starts_with('.').then_some("folder-hidden");
147 return find(table::FOLDERS, &lower).or(hidden).map_or(FileKind::FOLDER, FileKind::of);
148 }
149 let known = find(table::NAMES, &lower).or_else(|| by_lead(&lower)).or_else(|| by_ending(&lower));
150 match known {
151 Some(key) => FileKind::of(key),
152 None if executable => FileKind::EXECUTABLE,
153 None => FileKind::FILE,
154 }
155}
156
157/// The value of `key` in the sorted `table`.
158fn find(table: &'static [(&'static str, &'static str)], key: &str) -> Option<&'static str> {
159 table.binary_search_by(|(name, _)| (*name).cmp(key)).ok().map(|at| table[at].1)
160}
161
162/// Names that keep their kind whatever follows them: `README.de.md`, `LICENSE-MIT`, `COPYING`.
163const LEADS: &[(&str, &str)] =
164 &[("copying", "file-license"), ("licence", "file-license"), ("license", "file-license"), ("readme", "file-readme")];
165
166/// Endings a readme or a licence is written with. Any other ending is a file of its own kind that
167/// only starts with the word, such as `license.rs` or `readme_test.py`.
168const TEXT_ENDINGS: &[&str] = &["adoc", "markdown", "md", "org", "rst", "txt"];
169
170/// The kind of a readme or a licence, however its name goes on.
171fn by_lead(lower: &str) -> Option<&'static str> {
172 LEADS.iter().find_map(|&(lead, key)| {
173 let rest = lower.strip_prefix(lead)?;
174 let joined = rest.is_empty() || rest.starts_with(['.', '-', '_']);
175 let ending = rest.rsplit_once('.').map(|(_, ending)| ending);
176 (joined && ending.is_none_or(|ending| TEXT_ENDINGS.contains(&ending))).then_some(key)
177 })
178}
179
180/// The kind the end of the name says: the longest ending of up to three parts that the tables
181/// know, so `x.pkg.tar.zst` is a package before it is a `.tar.zst` archive or a `.zst` file.
182///
183/// The dot a hidden name starts with begins no ending: `.bashrc` has none.
184fn by_ending(lower: &str) -> Option<&'static str> {
185 // The dots of the last three endings, nearest first; a dot at the very start is not one.
186 let mut dots = [0usize; 3];
187 let mut count = 0;
188 for (at, _) in lower.rmatch_indices('.').filter(|(at, _)| *at > 0).take(dots.len()) {
189 dots[count] = at;
190 count += 1;
191 }
192 dots[..count].iter().rev().find_map(|&at| {
193 let ending = &lower[at + 1..];
194 if ending.contains('.') { find(table::DOUBLES, ending) } else { find(table::EXTENSIONS, ending) }
195 })
196}