mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feature(gui) themes + chore(deps) version bump
This commit is contained in:
1 parent
4e6778e1ea
commit
ad24557588
18 files changed
+782
-92
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user