Skip to main content

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}