feat(settings): delete user themes and enlarge the theme link preview

This commit is contained in:
nolight132 committed 2026-10-27 18:07:03 +01:00
1 parent 9a7df49688
commit e492a926cb
10 files changed
+266 -75

No files matched your search

+1
View File
@@ -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
+5 -2
View File
@@ -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
+31
View File
@@ -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);
+26
View File
@@ -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> {
+38
View File
@@ -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
+25 -44
View File
@@ -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;
+9 -1
View File
@@ -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))
}
}
+85 -4
View File
@@ -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.
+42 -24
View File
@@ -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))
}