mirror of
https://github.com/sonorahq/sonora.git
synced 2026-10-28 08:55:04 +08:00
feat(settings): delete user themes and enlarge the theme link preview
This commit is contained in:
1 parent
9a7df49688
commit
e492a926cb
10 files changed
+266
-75
No files matched your search
@@ -19,6 +19,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- Install themes people share on sonorahq.org/themes with one click. Sonora shows a preview of
|
||||
the theme and asks before it adds it to your themes folder and switches to it.
|
||||
- Explore community themes from Settings, Appearance, Theme.
|
||||
- Delete a theme you added with the trash button on its card in Settings, Appearance, Theme.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -602,6 +602,9 @@ settings-opacity-value = { $percent }%
|
||||
settings-theme-folder = Open folder
|
||||
settings-theme-explore = Explore community themes
|
||||
settings-theme-explore-detail = Find themes other people made and install them in one click
|
||||
settings-theme-delete = Delete theme
|
||||
settings-theme-delete-title = Delete { $name }?
|
||||
settings-theme-delete-detail = This removes it from your themes folder. It cannot be undone.
|
||||
settings-theme-unavailable = This theme can be applied only when adaptive theme is disabled
|
||||
settings-adaptive = Adaptive theme
|
||||
settings-adaptive-detail = Tint the palette with the artwork of the playing album
|
||||
@@ -1058,8 +1061,7 @@ theme-dark = Dark
|
||||
theme-light = Light
|
||||
theme-unavailable = { $name } (unavailable)
|
||||
theme-install-title = Install { $name }?
|
||||
theme-install-author = By { $author }
|
||||
theme-install-detail = Sonora adds it to your themes folder and switches to it.
|
||||
theme-install-detail = This will add it to your themes folder.
|
||||
theme-install-confirm = Install
|
||||
|
||||
# corners
|
||||
@@ -1290,6 +1292,7 @@ toast-link-sign-in = Sign in to { $name } to open this link
|
||||
toast-link-unreadable = That link does not lead anywhere Sonora can open
|
||||
toast-theme-link-unreadable = That theme link is damaged or holds something other than a theme
|
||||
toast-theme-install-failed = Cannot install { $name }
|
||||
toast-theme-delete-failed = Cannot delete { $name }
|
||||
toast-code-copied = Code copied
|
||||
menu-copy-link-none = This has no link to share
|
||||
menu-copy-link-local = Local artists and playlists have no link
|
||||
|
||||
@@ -1442,6 +1442,37 @@ impl AppSettings {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Deletes the user's theme family `id` from the themes folder and says whether it worked.
|
||||
/// A selected theme in it falls back to Dark. A failure is logged and shown as a toast.
|
||||
pub fn delete_theme_family(&mut self, id: &str, cx: &mut Context<Self>) -> bool {
|
||||
let Some(family) = self.families.iter().find(|family| family.id == id).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let directory = themes_path(&self.path);
|
||||
if let Err(error) = themes::remove(&family, &directory) {
|
||||
log::warn!("settings: cannot delete theme {id}: {error:#}");
|
||||
Toasts::about(
|
||||
Outcome::Failed,
|
||||
"toast-theme-delete-failed",
|
||||
family.name,
|
||||
cx,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
log::info!("settings: deleted theme {id}");
|
||||
let loaded = themes::load(&directory, &self.themes);
|
||||
self.families = themes::catalog(&loaded.themes);
|
||||
self.themes = loaded.themes;
|
||||
if family.variant(self.theme()).is_some() {
|
||||
self.set_theme(ThemeKind::Dark.id(), cx);
|
||||
}
|
||||
if self.state.theme_variants.remove(id).is_some() {
|
||||
self.schedule_state_save(cx);
|
||||
}
|
||||
cx.notify();
|
||||
true
|
||||
}
|
||||
|
||||
/// Creates and returns the folder that holds custom themes.
|
||||
pub fn ensure_themes_directory(&self) -> PathBuf {
|
||||
let path = themes_path(&self.path);
|
||||
|
||||
@@ -430,6 +430,32 @@ pub(super) fn install(
|
||||
anyhow::bail!("cannot find a free name for theme {base}")
|
||||
}
|
||||
|
||||
/// Deletes every file of the user's `family` from `directory`, then its folder once nothing else
|
||||
/// is left in it. A file already gone counts as deleted. Bundled families are refused.
|
||||
pub(super) fn remove(family: &ThemeFamily, directory: &Path) -> Result<()> {
|
||||
anyhow::ensure!(!family.bundled, "cannot delete bundled theme {}", family.id);
|
||||
for variant in &family.variants {
|
||||
let path = directory.join(format!("{}.json", variant.id));
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(error).with_context(|| format!("cannot delete {}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// only the variants of a folder carry a slash in their id, and a folder that still holds
|
||||
// anything else stays
|
||||
if family
|
||||
.variants
|
||||
.iter()
|
||||
.any(|variant| variant.id.contains('/'))
|
||||
{
|
||||
fs::remove_dir(directory.join(&family.id)).ok();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses one theme file. The id it returns is `family`, which a caller inside a folder
|
||||
/// replaces with the full one. `origin` names the file in errors.
|
||||
fn parse(bytes: &[u8], origin: &str, family: &str) -> Result<ThemeVariant> {
|
||||
|
||||
@@ -6,6 +6,7 @@ use gpui::{
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::button::Button;
|
||||
use crate::menu::Menu;
|
||||
use crate::metrics::Text;
|
||||
use crate::motion::{HoverFade, mix};
|
||||
@@ -61,6 +62,8 @@ pub struct OptionCard {
|
||||
dashed: bool,
|
||||
tooltip: Option<SharedString>,
|
||||
on_click: Option<Click>,
|
||||
/// The tooltip key and handler of the trash button in the thumbnail's corner.
|
||||
delete: Option<(SharedString, Click)>,
|
||||
}
|
||||
|
||||
impl OptionCard {
|
||||
@@ -80,6 +83,7 @@ impl OptionCard {
|
||||
dashed: false,
|
||||
tooltip: None,
|
||||
on_click: None,
|
||||
delete: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +163,17 @@ impl OptionCard {
|
||||
self.on_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Puts a trash button in the bottom right corner of the thumbnail, named by the tooltip
|
||||
/// `key`, for an option the user can remove. It works while the card is disabled too.
|
||||
pub fn on_delete(
|
||||
mut self,
|
||||
key: impl Into<SharedString>,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.delete = Some((key.into(), Box::new(handler)));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for OptionCard {
|
||||
@@ -190,6 +205,7 @@ impl RenderOnce for OptionCard {
|
||||
dashed,
|
||||
tooltip,
|
||||
on_click,
|
||||
delete,
|
||||
} = self;
|
||||
|
||||
let theme = *cx.theme();
|
||||
@@ -245,6 +261,28 @@ impl RenderOnce for OptionCard {
|
||||
false => theme.foreground,
|
||||
}),
|
||||
)
|
||||
})
|
||||
// the button keeps its press to itself so the card under it is not chosen too
|
||||
.when_some(delete, |this, (key, handler)| {
|
||||
let corner = theme.metrics.pad / 2.;
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.right(corner)
|
||||
.bottom(corner)
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
|
||||
.child(
|
||||
Button::new((id.clone(), "delete"))
|
||||
.ghost()
|
||||
.small()
|
||||
.icon("icons/trash-2.svg")
|
||||
.tooltip(key)
|
||||
.on_click(move |event, window, cx| {
|
||||
cx.stop_propagation();
|
||||
handler(event, window, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
let mut card = base
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
//! The question before a theme from a `sonora://theme/` link is installed: a miniature of the
|
||||
//! window in its colours, its name and author, and Install or Cancel. `state::Links` holds the
|
||||
//! offer and does the installing; this only draws it.
|
||||
//! The question before a theme from a `sonora://theme/` link is installed, with the theme's
|
||||
//! name in the title, a miniature of the window in its colours, and Install or Cancel.
|
||||
//! `state::Links` holds the offer and does the installing. This only draws it.
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, FocusHandle, FontWeight, Render, Window, div};
|
||||
use gpui::{Context, Entity, FocusHandle, Pixels, Render, Window, div, px};
|
||||
use i18n::t;
|
||||
use state::{Links, Sonora};
|
||||
use ui::{
|
||||
ActiveTheme as _, Button, Dismiss, FORM_CONTEXT, Look, Modal, Submit, Text, Theme, ThemeKind,
|
||||
};
|
||||
use ui::{ActiveTheme as _, Button, Dismiss, FORM_CONTEXT, Look, Modal, Submit, Theme, ThemeKind};
|
||||
|
||||
use crate::shared::theme_miniature::{CARD, THUMB, sliced};
|
||||
use crate::shared::theme_miniature::{framed, sliced};
|
||||
|
||||
/// The modal panel's border, which counts toward its width.
|
||||
const BORDER: Pixels = px(1.);
|
||||
/// How many times a theme card's size the preview is drawn.
|
||||
const PREVIEW: f32 = 2.;
|
||||
|
||||
/// Asks whether to install the theme a link carried. It shows for as long as `Links` holds an
|
||||
/// offer, wherever the listener is in the app, and either answer hands the offer back.
|
||||
@@ -86,42 +89,17 @@ impl Render for ThemeInvite {
|
||||
}
|
||||
|
||||
let theme = *cx.theme();
|
||||
let preview = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(theme.metrics.pad)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(CARD)
|
||||
.h(THUMB)
|
||||
.rounded(theme.radius)
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.child(sliced(&[variant.palette()], theme.radius)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text(Text::Body))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.truncate()
|
||||
.child(variant.title.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text(Text::Small))
|
||||
.text_color(theme.muted_foreground)
|
||||
.truncate()
|
||||
.child(t!("theme-install-author", author = &variant.author)),
|
||||
),
|
||||
);
|
||||
let frame = framed(PREVIEW);
|
||||
let preview = div().flex().justify_center().child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(frame.width)
|
||||
.h(frame.height)
|
||||
.rounded(theme.radius)
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.child(sliced(&[variant.palette()], theme.radius, PREVIEW)),
|
||||
);
|
||||
|
||||
div()
|
||||
.absolute()
|
||||
@@ -141,6 +119,9 @@ impl Render for ThemeInvite {
|
||||
"theme-install",
|
||||
t!("theme-install-title", name = &variant.title),
|
||||
)
|
||||
// the modal pads its body by twice `pad` on each side, so the preview lines up with
|
||||
// the title and the buttons
|
||||
.w(frame.width + theme.metrics.pad * 4. + BORDER * 2.)
|
||||
.detail(t!("theme-install-detail"))
|
||||
.child(preview)
|
||||
.action(
|
||||
|
||||
@@ -540,6 +540,10 @@ impl SettingsView {
|
||||
/// prompt a sign-in raised takes the sign-in down with it. Returns false when no dialog
|
||||
/// was up.
|
||||
pub(super) fn close_dialog(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
if self.deleting_theme.is_some() {
|
||||
self.close_theme_delete(cx);
|
||||
return true;
|
||||
}
|
||||
if self.credentials_for.is_some() {
|
||||
self.abandon_credentials(cx);
|
||||
return true;
|
||||
|
||||
@@ -246,6 +246,8 @@ pub struct SettingsView {
|
||||
popovers: Popovers,
|
||||
/// The theme family whose variant menu opened last, which owns that menu's popover.
|
||||
theme_variants: Option<String>,
|
||||
/// The user's theme family waiting for the user to confirm its deletion.
|
||||
deleting_theme: Option<String>,
|
||||
server: Entity<Input>,
|
||||
username: Entity<Input>,
|
||||
password: Entity<Input>,
|
||||
@@ -403,6 +405,7 @@ impl SettingsView {
|
||||
pending_cache: None,
|
||||
popovers,
|
||||
theme_variants: None,
|
||||
deleting_theme: None,
|
||||
server: cx.new(|cx| Input::new("login-server-hint", cx)),
|
||||
username: cx.new(|cx| Input::new("login-username-hint", cx)),
|
||||
password: cx.new(|cx| Input::new("login-password-hint", cx).masked()),
|
||||
@@ -1322,7 +1325,11 @@ impl Render for SettingsView {
|
||||
let sign_in_for = self.sign_in_for.filter(|_| !taken);
|
||||
|
||||
// a dialog takes the key focus, since escape only reaches the page from inside it
|
||||
let dialog = taken || sign_in_for.is_some() || self.scrobble_prompt.is_some();
|
||||
let theme_delete = self.theme_delete_modal(cx);
|
||||
let dialog = taken
|
||||
|| sign_in_for.is_some()
|
||||
|| self.scrobble_prompt.is_some()
|
||||
|| theme_delete.is_some();
|
||||
match (dialog, self.grabbed) {
|
||||
(true, false) => {
|
||||
window.focus(&self.focus, cx);
|
||||
@@ -1377,6 +1384,7 @@ impl Render for SettingsView {
|
||||
.when_some(sign_in_for, |this, (slug, provider)| {
|
||||
this.child(self.sign_in_prompt(slug, provider, cx).into_any_element())
|
||||
})
|
||||
.when_some(theme_delete, |this, modal| this.child(modal))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ use gpui::{
|
||||
};
|
||||
use i18n::t;
|
||||
use state::ThemeFamily;
|
||||
use ui::{ActiveTheme as _, Look, Menu, MenuItem, OptionCard, Picker, Rail, Theme, ThemeKind};
|
||||
use ui::{
|
||||
ActiveTheme as _, Button, Look, Menu, MenuItem, Modal, OptionCard, Picker, Rail, Theme,
|
||||
ThemeKind,
|
||||
};
|
||||
|
||||
use super::SettingsView;
|
||||
use crate::shared::theme_miniature::{CARD, THUMB, sliced};
|
||||
@@ -133,7 +136,7 @@ impl SettingsView {
|
||||
OptionCard::new(kind.id())
|
||||
.w(CARD)
|
||||
.thumb_height(THUMB)
|
||||
.thumb(sliced(&palettes, radius))
|
||||
.thumb(sliced(&palettes, radius, 1.))
|
||||
.label(kind.label())
|
||||
.selected(chosen)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
@@ -167,12 +170,13 @@ impl SettingsView {
|
||||
None => family.name.clone(),
|
||||
};
|
||||
let id = family.id.clone();
|
||||
let doomed = family.id.clone();
|
||||
|
||||
let card =
|
||||
OptionCard::new(SharedString::from(format!("theme-family:{}", family.id)))
|
||||
.w(CARD)
|
||||
.thumb_height(THUMB)
|
||||
.thumb(sliced(&palettes, radius))
|
||||
.thumb(sliced(&palettes, radius, 1.))
|
||||
.label(label)
|
||||
.selected(chosen)
|
||||
.disabled(adaptive)
|
||||
@@ -191,7 +195,16 @@ impl SettingsView {
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}));
|
||||
}))
|
||||
.when(!family.bundled, |card| {
|
||||
card.on_delete(
|
||||
"settings-theme-delete",
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
this.deleting_theme = Some(doomed.clone());
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
});
|
||||
match picked {
|
||||
Some((picked, _)) => self.with_variants(card, &family, &picked, cx),
|
||||
None => card,
|
||||
@@ -201,6 +214,74 @@ impl SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The question before the user's theme family is deleted, or `None` while none waits or
|
||||
/// the family is already gone.
|
||||
pub(super) fn theme_delete_modal(&self, cx: &mut Context<Self>) -> Option<Modal> {
|
||||
let id = self.deleting_theme.as_deref()?;
|
||||
let name = self
|
||||
.settings
|
||||
.read(cx)
|
||||
.theme_families()
|
||||
.iter()
|
||||
.find(|family| family.id == id)?
|
||||
.name
|
||||
.clone();
|
||||
let modal = Modal::new(
|
||||
"theme-delete",
|
||||
t!("settings-theme-delete-title", name = &name),
|
||||
)
|
||||
.detail(t!("settings-theme-delete-detail"))
|
||||
.action(
|
||||
Button::new("cancel-theme-delete")
|
||||
.ghost()
|
||||
.label(t!("common-cancel"))
|
||||
.on_click(cx.listener(|this, _, _, cx| this.close_theme_delete(cx))),
|
||||
)
|
||||
.action(
|
||||
Button::new("apply-theme-delete")
|
||||
.destructive()
|
||||
.label(t!("common-delete"))
|
||||
.on_click(cx.listener(|this, _, _, cx| this.delete_theme(cx))),
|
||||
)
|
||||
.on_dismiss(cx.listener(|this, _, _, cx| this.close_theme_delete(cx)));
|
||||
Some(modal)
|
||||
}
|
||||
|
||||
pub(super) fn close_theme_delete(&mut self, cx: &mut Context<Self>) {
|
||||
self.deleting_theme = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Deletes the family the dialog asked about. When it was the theme in use the window fades
|
||||
/// to Dark, the theme settings fall back to.
|
||||
fn delete_theme(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(id) = self.deleting_theme.take() else {
|
||||
return;
|
||||
};
|
||||
let look = self.look(cx);
|
||||
let fell_back = self.settings.update(cx, |settings, cx| {
|
||||
let chosen = settings
|
||||
.theme_variant()
|
||||
.is_some_and(|(family, _)| family.id == id);
|
||||
settings.delete_theme_family(&id, cx) && chosen
|
||||
});
|
||||
if fell_back {
|
||||
let overrides = self.settings.read(cx).theme_overrides();
|
||||
Theme::fade(
|
||||
Look {
|
||||
kind: ThemeKind::Dark,
|
||||
..look
|
||||
},
|
||||
&overrides,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
if self.theme_variants.as_deref() == Some(id.as_str()) {
|
||||
self.theme_variants = None;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Gives a family's card the menu of its variants, `picked` checked, opened from its name.
|
||||
/// Only the family whose menu opened last owns the popover, so the name on any other card
|
||||
/// hands it to its own.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! colours, the picture on a theme card and in the dialog that offers a linked theme.
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{Div, Pixels, div, px};
|
||||
use gpui::{Div, Pixels, Size, div, px, size};
|
||||
use ui::Theme;
|
||||
|
||||
/// How wide a theme card stands.
|
||||
@@ -40,11 +40,21 @@ const TILE: f32 = 28.;
|
||||
const PLAYER: [f32; 4] = [45., 66., 96., 14.];
|
||||
const PLAY: [f32; 4] = [50., 70., 6., 6.];
|
||||
|
||||
/// The size of the card that holds a miniature drawn at `scale`, its border included. At a scale
|
||||
/// of 1 it is `CARD` by `THUMB`.
|
||||
pub(crate) fn framed(scale: f32) -> Size<Pixels> {
|
||||
size(
|
||||
px(WIDTH * scale) + EDGE * 2.,
|
||||
px(HEIGHT * scale) + EDGE * 2.,
|
||||
)
|
||||
}
|
||||
|
||||
/// The miniature cut into one vertical slice per palette, left to right, each slice the same
|
||||
/// window painted in its own palette so the layout runs on across the cuts. The cuts run down
|
||||
/// `SEAMS` and show only as the change of colour. `radius` is the card's, whose inside the
|
||||
/// miniature's outer corners follow.
|
||||
pub(crate) fn sliced(palettes: &[Theme], radius: Pixels) -> Div {
|
||||
/// miniature's outer corners follow. `scale` multiplies every shape, so a whole number keeps
|
||||
/// them on whole pixels, and the card that holds it measures `framed(scale)`.
|
||||
pub(crate) fn sliced(palettes: &[Theme], radius: Pixels, scale: f32) -> Div {
|
||||
let count = palettes.len().max(1);
|
||||
let cuts = (1..count).map(|index| {
|
||||
let even = WIDTH * index as f32 / count as f32;
|
||||
@@ -57,9 +67,11 @@ pub(crate) fn sliced(palettes: &[Theme], radius: Pixels) -> Div {
|
||||
.chain(cuts)
|
||||
.chain(std::iter::once(WIDTH))
|
||||
.collect();
|
||||
let mark = (radius * RADIUS_SCALE).floor();
|
||||
let mark = (radius * RADIUS_SCALE * scale).floor();
|
||||
// the card does not clip to its corners, so the miniature rounds its own inside the border
|
||||
let outer = (radius - EDGE).max(Pixels::ZERO).min(px(SIDEBAR / 2.));
|
||||
let outer = (radius - EDGE)
|
||||
.max(Pixels::ZERO)
|
||||
.min(px(SIDEBAR * scale / 2.));
|
||||
|
||||
div().relative().size_full().children(
|
||||
palettes
|
||||
@@ -74,24 +86,24 @@ pub(crate) fn sliced(palettes: &[Theme], radius: Pixels) -> Div {
|
||||
true => edge[1] + SEAM_OVERLAP,
|
||||
false => WIDTH,
|
||||
};
|
||||
rect([left, 0., right - left, HEIGHT])
|
||||
rect([left, 0., right - left, HEIGHT], scale)
|
||||
.overflow_hidden()
|
||||
.child(miniature(palette, outer, mark).left(px(-left)))
|
||||
.child(miniature(palette, outer, mark, scale).left(px(-left * scale)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The Sonora window in `palette`'s colours as a few soft, flat shapes, `WIDTH` by `HEIGHT`
|
||||
/// pixels: the sidebar with three entries, a heading over a row of tiles, the first in the
|
||||
/// accent, and a floating player with its play button. Nothing is outlined. The window and the
|
||||
/// sidebar round the outer corners by `outer`, and `mark` rounds the tiles.
|
||||
fn miniature(palette: &Theme, outer: Pixels, mark: Pixels) -> Div {
|
||||
/// pixels times `scale`: the sidebar with three entries, a heading over a row of tiles, the first
|
||||
/// in the accent, and a floating player with its play button. Nothing is outlined. The window
|
||||
/// and the sidebar round the outer corners by `outer`, and `mark` rounds the tiles.
|
||||
fn miniature(palette: &Theme, outer: Pixels, mark: Pixels, scale: f32) -> Div {
|
||||
let panel = match palette.sidebar == palette.background {
|
||||
true => palette.secondary,
|
||||
false => palette.sidebar,
|
||||
};
|
||||
let bar = |shape: [f32; 4], strength: f32| {
|
||||
rect(shape)
|
||||
rect(shape, scale)
|
||||
.rounded_full()
|
||||
.bg(palette.foreground.opacity(strength))
|
||||
};
|
||||
@@ -99,11 +111,15 @@ fn miniature(palette: &Theme, outer: Pixels, mark: Pixels) -> Div {
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.w(px(WIDTH))
|
||||
.h(px(HEIGHT))
|
||||
.w(px(WIDTH * scale))
|
||||
.h(px(HEIGHT * scale))
|
||||
.rounded(outer)
|
||||
.bg(palette.background)
|
||||
.child(rect([0., 0., SIDEBAR, HEIGHT]).rounded_l(outer).bg(panel))
|
||||
.child(
|
||||
rect([0., 0., SIDEBAR, HEIGHT], scale)
|
||||
.rounded_l(outer)
|
||||
.bg(panel),
|
||||
)
|
||||
.children(ENTRIES.into_iter().enumerate().map(|(index, entry)| {
|
||||
bar(
|
||||
entry,
|
||||
@@ -119,18 +135,20 @@ fn miniature(palette: &Theme, outer: Pixels, mark: Pixels) -> Div {
|
||||
0 => palette.primary.opacity(ACCENT),
|
||||
_ => palette.muted,
|
||||
};
|
||||
rect([left, TILE_TOP, TILE, TILE]).rounded(mark).bg(fill)
|
||||
rect([left, TILE_TOP, TILE, TILE], scale)
|
||||
.rounded(mark)
|
||||
.bg(fill)
|
||||
}))
|
||||
.child(rect(PLAYER).rounded_full().bg(panel))
|
||||
.child(rect(PLAY).rounded_full().bg(palette.primary))
|
||||
.child(rect(PLAYER, scale).rounded_full().bg(panel))
|
||||
.child(rect(PLAY, scale).rounded_full().bg(palette.primary))
|
||||
}
|
||||
|
||||
/// A box in the miniature from its left, top, width and height in pixels.
|
||||
fn rect([x, y, width, height]: [f32; 4]) -> Div {
|
||||
/// A box in the miniature from its left, top, width and height in pixels, times `scale`.
|
||||
fn rect([x, y, width, height]: [f32; 4], scale: f32) -> Div {
|
||||
div()
|
||||
.absolute()
|
||||
.left(px(x))
|
||||
.top(px(y))
|
||||
.w(px(width))
|
||||
.h(px(height))
|
||||
.left(px(x * scale))
|
||||
.top(px(y * scale))
|
||||
.w(px(width * scale))
|
||||
.h(px(height * scale))
|
||||
}
|
||||
Reference in new issue
Block a user