feature(gui) themes + chore(deps) version bump

This commit is contained in:
nvrl committed 2026-08-15 14:49:04 +02:00
1 parent 4e6778e1ea
commit ad24557588
18 files changed
+782 -92

No files matched your search

+78 -3
View File
@@ -9,7 +9,8 @@
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// A palette family: Catppuccin's four, plus the one this app shipped with.
/// A palette family: Catppuccin's four, the one this app shipped with, and one
/// borrowed from a menu screen.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Flavor {
@@ -22,15 +23,18 @@ pub enum Flavor {
Frappe,
Macchiato,
Mocha,
/// Black, white and one loud red — the Persona 5 menus.
Persona5,
}
impl Flavor {
pub const ALL: [Self; 5] = [
pub const ALL: [Self; 6] = [
Self::Proton,
Self::Mocha,
Self::Macchiato,
Self::Frappe,
Self::Latte,
Self::Persona5,
];
pub fn label(self) -> &'static str {
@@ -40,6 +44,7 @@ impl Flavor {
Self::Frappe => "Catppuccin Frappé",
Self::Macchiato => "Catppuccin Macchiato",
Self::Mocha => "Catppuccin Mocha",
Self::Persona5 => "Persona 5",
}
}
@@ -50,6 +55,7 @@ impl Flavor {
Self::Frappe => "Dark, warm, the lowest contrast of the three",
Self::Macchiato => "Dark, between Frappé and Mocha",
Self::Mocha => "Dark, the deepest of Catppuccin's three",
Self::Persona5 => "Black and white with one loud red — pick the Red accent for it",
}
}
@@ -82,10 +88,15 @@ pub enum Accent {
Blue,
Teal,
Peach,
/// The flavour's warning colour, used on purpose. Every flavour has one
/// because everything here needs a colour for a failure, and in
/// [`Flavor::Persona5`] it is the point of the palette rather than an
/// afterthought.
Red,
}
impl Accent {
pub const ALL: [Self; 8] = [
pub const ALL: [Self; 9] = [
Self::Mauve,
Self::Pink,
Self::Sky,
@@ -94,6 +105,7 @@ impl Accent {
Self::Blue,
Self::Teal,
Self::Peach,
Self::Red,
];
pub fn label(self) -> &'static str {
@@ -106,6 +118,7 @@ impl Accent {
Self::Blue => "Blue",
Self::Teal => "Teal",
Self::Peach => "Peach",
Self::Red => "Red",
}
}
}
@@ -423,6 +436,36 @@ const MOCHA: Ramp = Ramp {
light: false,
};
/// Persona 5's menus: black, white, and a red that is not asking permission.
///
/// The three colours that matter are `crust`/`base` (black), `text` (white) and
/// `red` — everything between them is that same red bled into the black, which
/// is what keeps a card from reading as grey furniture in a palette that has no
/// grey in it. The other hues exist because an accent can be any of them; they
/// are pushed to full saturation to sit on a black page, which is the one thing
/// this palette has in common with [`PROTON`].
const PERSONA5: Ramp = Ramp {
base: rgb(0x0a0708),
mantle: rgb(0x140d0f),
crust: rgb(0x000000),
surface0: rgb(0x1d1215),
surface1: rgb(0x33191d),
subtext0: rgb(0xb59a9d),
text: rgb(0xf5f2f2),
pink: rgb(0xff2e63),
mauve: rgb(0xb13bd6),
sky: rgb(0x2ec7ff),
sapphire: rgb(0x1f9bd0),
blue: rgb(0x2f5ee0),
lavender: rgb(0xa07bff),
teal: rgb(0x12c2a0),
green: rgb(0x2fbf4f),
peach: rgb(0xff7a1a),
yellow: rgb(0xffc400),
red: rgb(0xe60012),
light: false,
};
impl Ramp {
const fn of(flavor: Flavor) -> Self {
match flavor {
@@ -431,6 +474,7 @@ impl Ramp {
Flavor::Frappe => FRAPPE,
Flavor::Macchiato => MACCHIATO,
Flavor::Mocha => MOCHA,
Flavor::Persona5 => PERSONA5,
}
}
@@ -450,6 +494,7 @@ impl Ramp {
Accent::Blue => (self.blue, self.sapphire),
Accent::Teal => (self.teal, self.green),
Accent::Peach => (self.peach, self.yellow),
Accent::Red => (self.red, self.pink),
}
}
}
@@ -630,6 +675,36 @@ mod tests {
}
}
#[test]
fn persona_5_is_black_with_that_red_in_it() {
let palette = Palette::resolve(Appearance {
flavor: Flavor::Persona5,
accent: Accent::Red,
gradients: false,
});
assert_eq!(palette.accent, PERSONA5.red);
assert!(!palette.light);
// Black enough that the artwork is the brightest thing on the page:
// darker than the palette this app shipped with, which is the other
// near-black one.
assert!(luminance(palette.background) < luminance(PROTON.base));
}
#[test]
fn every_flavour_can_wear_its_own_warning_colour() {
for flavor in Flavor::ALL {
let palette = Palette::resolve(Appearance {
flavor,
accent: Accent::Red,
gradients: true,
});
// The accent is the flavour's red — deepened on the light flavour,
// where it would otherwise be too bright to put a label on.
assert!(palette.accent.r > palette.accent.b, "{flavor:?}");
assert_ne!(palette.accent, palette.background, "{flavor:?}");
}
}
#[test]
fn turning_gradients_off_leaves_one_colour_to_draw() {
let flat = Palette::resolve(Appearance {
+52 -18
View File
@@ -337,43 +337,77 @@ impl SharedLibrary {
Ok(())
}
/// Walk one share's whole subtree, depth first, yielding every node.
/// Walk one share's whole subtree, level by level, yielding every node.
///
/// There is no recursion helper on the visitor path — the authenticated
/// client's `get_node_hierarchy` / `get_node_by_path` have no counterpart
/// there — so the walk is here.
///
/// **Breadth first and fanned out, because a media share is wide.** The
/// shape of one is a root holding a folder per series, each holding a folder
/// per season: a couple of hundred folders that are each one listing request
/// plus a detail fetch, and almost all of them siblings. Walking that one
/// folder at a time spends the whole crawl waiting on a round trip it could
/// have overlapped — the crawl was minutes of latency and seconds of work.
/// Taking a whole level at once is what collapses it.
///
/// The client is cheap to clone and its clones share one node-key cache and
/// one single-flight map, so siblings that need the same ancestor key wait
/// on one derivation rather than each starting their own. `buffered` rather
/// than `buffer_unordered`: the crawl's output order is otherwise a race,
/// and a catalog that reorders itself between two identical crawls is
/// needlessly hard to diff.
pub async fn crawl(&self, share_id: &str) -> Result<Vec<Node>> {
use futures::stream::{StreamExt as _, TryStreamExt as _};
let client = self
.client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
let root = client.get_root_node().await?;
let mut found = Vec::new();
let mut folders = vec![root.uid.clone()];
found.push(root);
let mut level = vec![root.uid.clone()];
let mut found = vec![root];
while let Some(folder) = folders.pop() {
let child_uids: Vec<NodeUid> =
client.enumerate_folder_children_node_uids(&folder).await?;
if child_uids.is_empty() {
continue;
}
// Chunked and fanned out inside the SDK, and the node keys it
// unlocks along the way are cached there, so a deep tree does not
// re-derive the same ancestors per child.
let children = client.enumerate_nodes(&child_uids).await?;
for child in children {
if child.is_folder() {
folders.push(child.uid.clone());
while !level.is_empty() {
let listings = level.into_iter().map(|folder| {
let client = client.clone();
async move {
let child_uids: Vec<NodeUid> =
client.enumerate_folder_children_node_uids(&folder).await?;
if child_uids.is_empty() {
return Ok(Vec::new());
}
// Chunked and fanned out inside the SDK too, so this is a
// multiplier on that concurrency rather than the only one.
client.enumerate_nodes(&child_uids).await
}
});
let mut listings = futures::stream::iter(listings).buffered(CRAWL_CONCURRENCY);
let mut next = Vec::new();
while let Some(children) = listings.try_next().await? {
for child in children {
if child.is_folder() {
next.push(child.uid.clone());
}
found.push(child);
}
found.push(child);
}
level = next;
}
Ok(found)
}
}
/// How many folders of one level to list at once.
///
/// Each of these fans out again inside the SDK — up to four detail batches per
/// folder — so this is not the number of requests in flight but a multiplier on
/// it. Six keeps the ceiling in the low tens: enough to hide the round trip on
/// a wide level, well short of a burst a share server would read as abuse.
const CRAWL_CONCURRENCY: usize = 6;
/// The app-version string Proton identifies this client by.
///
/// The `external-drive-` prefix is **not** decoration: the API parses the part