Add resolved appearance engine and paired theme settings
Resolve light, dark and automatic appearance centrally and publish live theme snapshots to native surfaces and the appearance portal. Add paired preset families, custom theme imports, accessibility transforms and contrast-safe transitions. Refine the Settings gallery and Auto controls, move overrides under advanced customization, and share palette-aware switches and circular slider thumbs. Document configuration and include theme licenses and integration coverage.
This commit is contained in:
1 parent
2bb4c086f5
commit
3d950a0411
58 files changed
+5556
-644
No files matched your search
@@ -6,6 +6,8 @@ license.workspace = true
|
||||
rust-version = "1.95"
|
||||
|
||||
[dependencies]
|
||||
ferese-ipc = { path = "../../crates/ferese-ipc" }
|
||||
serde_json.workspace = true
|
||||
ferese-theme = { path = "../../crates/ferese-theme" }
|
||||
ferese-config = { path = "../../crates/ferese-config" }
|
||||
libcosmic.workspace = true
|
||||
|
||||
@@ -4,6 +4,7 @@ use ferese_config::Document;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Appearance {
|
||||
pub appearance: ferese_config::theme::Appearance,
|
||||
pub dim: f32,
|
||||
pub show_clock: bool,
|
||||
pub show_date: bool,
|
||||
@@ -11,17 +12,42 @@ pub struct Appearance {
|
||||
pub panel: Color,
|
||||
pub text: Color,
|
||||
pub accent: Color,
|
||||
pub on_accent: Color,
|
||||
pub radius: f32,
|
||||
pub font: cosmic::font::Font,
|
||||
pub wallpaper: image::Handle,
|
||||
pub wallpaper_path: std::path::PathBuf,
|
||||
pub wallpaper_blur: f32,
|
||||
pub wallpaper_revision: u64,
|
||||
pub reduce_transparency: bool,
|
||||
pub avatar: Option<image::Handle>,
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
pub fn load(user: &str, path: Option<&std::path::Path>) -> Self {
|
||||
let doc = path
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| Document::parse(&s).ok());
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|source| Document::parse(&source).ok());
|
||||
let mut theme = ferese_theme::service::current().presented;
|
||||
if let Some(path) = path.filter(|path| ferese_config::config_path().as_deref() != Some(*path))
|
||||
&& let Some(document) = &doc
|
||||
&& let Ok(mut connection) = ferese_ipc::theme::Connection::connect()
|
||||
{
|
||||
let args = serde_json::json!({
|
||||
"source": document.to_string(),
|
||||
"directory": path.parent(),
|
||||
});
|
||||
if let Ok(value) = connection.call("theme-preview", args)
|
||||
&& let Ok(preview) = serde_json::from_value(value["theme"].clone())
|
||||
{
|
||||
theme = preview;
|
||||
}
|
||||
}
|
||||
Self::from_document(user, doc, &theme)
|
||||
}
|
||||
|
||||
fn from_document(user: &str, doc: Option<Document>, theme: &ferese_config::theme::ResolvedTheme) -> Self {
|
||||
let doc = doc.map(|doc| doc.with_theme(theme));
|
||||
let string = |key: &str, fallback: &str| {
|
||||
doc.as_ref()
|
||||
.and_then(|d| d.get(key))
|
||||
@@ -29,7 +55,7 @@ impl Appearance {
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
};
|
||||
let palette = ferese_theme::Palette::from_document(doc.as_ref());
|
||||
let palette = ferese_theme::Palette::from_resolved(&theme);
|
||||
let font = string("theme.typography.font_family", "Inter");
|
||||
let background = string("theme.background.path", ferese_config::default_wallpaper());
|
||||
let path = string("theme.background.lock_path", &background);
|
||||
@@ -40,29 +66,18 @@ impl Appearance {
|
||||
} else {
|
||||
path
|
||||
};
|
||||
let wallpaper = image::Handle::from_path(path);
|
||||
// Soften a bounded thumbnail once, before requesting the lock. The
|
||||
// renderer scales this shared image across outputs; no per-frame blur.
|
||||
let pixels = cosmic::iced::advanced::graphics::image::load(&wallpaper)
|
||||
.or_else(|_| {
|
||||
cosmic::iced::advanced::graphics::image::load(&image::Handle::from_bytes(
|
||||
include_bytes!("../../../assets/wallpapers/ferese.png").as_slice(),
|
||||
))
|
||||
})
|
||||
.expect("bundled wallpaper is valid");
|
||||
let wallpaper_path = std::path::PathBuf::from(path);
|
||||
let blur = doc
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("lock_screen.background_blur"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.and_then(|doc| doc.get("lock_screen.background_blur"))
|
||||
.and_then(|value| value.as_f64())
|
||||
.unwrap_or(18.)
|
||||
.clamp(0., 40.) as f32;
|
||||
let wallpaper = if blur > 0. {
|
||||
let thumbnail = ::image::imageops::thumbnail(&pixels, 640, 640);
|
||||
let softened = ::image::imageops::blur(&thumbnail, blur);
|
||||
image::Handle::from_rgba(softened.width(), softened.height(), softened.into_raw())
|
||||
} else {
|
||||
image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw())
|
||||
};
|
||||
let reduce_transparency = theme.accessibility.reduce_transparency;
|
||||
let wallpaper =
|
||||
load_wallpaper(&wallpaper_path, if reduce_transparency { 0. } else { blur }).unwrap_or_else(|_| {
|
||||
image::Handle::from_bytes(include_bytes!("../../../assets/wallpapers/ferese.png").as_slice())
|
||||
});
|
||||
let boolean = |key, fallback| {
|
||||
doc.as_ref()
|
||||
.and_then(|d| d.get(key))
|
||||
@@ -70,6 +85,7 @@ impl Appearance {
|
||||
.unwrap_or(fallback)
|
||||
};
|
||||
Self {
|
||||
appearance: theme.appearance,
|
||||
dim: doc
|
||||
.as_ref()
|
||||
.and_then(|d| d.get("lock_screen.background_dim"))
|
||||
@@ -82,9 +98,14 @@ impl Appearance {
|
||||
panel: palette.sidebar,
|
||||
text: palette.text,
|
||||
accent: palette.accent,
|
||||
on_accent: palette.on_accent,
|
||||
radius: palette.radius,
|
||||
font: ferese_theme::font(Some(&font)),
|
||||
wallpaper,
|
||||
wallpaper_path,
|
||||
wallpaper_blur: blur,
|
||||
wallpaper_revision: 0,
|
||||
reduce_transparency,
|
||||
avatar: account_picture(user),
|
||||
}
|
||||
}
|
||||
@@ -95,12 +116,14 @@ impl Appearance {
|
||||
|
||||
pub fn theme(&self) -> cosmic::Theme {
|
||||
ferese_theme::Palette {
|
||||
appearance: self.appearance,
|
||||
background: self.panel,
|
||||
sidebar: self.panel,
|
||||
card: self.panel,
|
||||
text: self.text,
|
||||
muted: self.text.scale_alpha(0.55),
|
||||
accent: self.accent,
|
||||
on_accent: self.on_accent,
|
||||
radius: self.radius,
|
||||
error: Color::from_rgb8(235, 98, 98),
|
||||
}
|
||||
@@ -166,12 +189,41 @@ mod tests {
|
||||
"##,
|
||||
)
|
||||
.unwrap();
|
||||
let appearance = Appearance::load("no-such-test-user", Some(&config));
|
||||
let document = Document::parse(&std::fs::read_to_string(&config).unwrap()).unwrap();
|
||||
let theme = ferese_config::theme::resolve(&document, directory.path(), jiff::Timestamp::now(), |_| {
|
||||
Err("unexpected file".into())
|
||||
})
|
||||
.unwrap()
|
||||
.theme;
|
||||
let appearance = Appearance::from_document("no-such-test-user", Some(document), &theme);
|
||||
assert_eq!(appearance.radius, 0.);
|
||||
assert_eq!(appearance.accent, Color::from_rgb8(255, 85, 0));
|
||||
assert_eq!(theme.requested_accent, "#FF5500");
|
||||
assert_eq!(
|
||||
appearance.accent,
|
||||
ferese_theme::parse_color(&theme.tokens.colors.accent).unwrap()
|
||||
);
|
||||
assert!(ferese_theme::contrast(appearance.accent.into(), appearance.on_accent.into()) >= 4.5);
|
||||
assert!(!appearance.show_clock && !appearance.show_date);
|
||||
assert_eq!(appearance.clock_format(), "%I:%M %p");
|
||||
assert_eq!(appearance.dim, 0.7);
|
||||
assert_eq!(appearance.theme().cosmic().corner_radii.radius_m, [0.; 4]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result<image::Handle, String> {
|
||||
let (width, height) = ::image::image_dimensions(path).map_err(|e| e.to_string())?;
|
||||
if u64::from(width) * u64::from(height) * 4 > 256 * 1024 * 1024 {
|
||||
return Err("Lock wallpaper exceeds the decode limit".into());
|
||||
}
|
||||
let pixels = ::image::open(path).map_err(|e| e.to_string())?.into_rgba8();
|
||||
let pixels = if blur > 0. {
|
||||
::image::imageops::blur(&::image::imageops::thumbnail(&pixels, 640, 640), blur)
|
||||
} else {
|
||||
pixels
|
||||
};
|
||||
Ok(image::Handle::from_rgba(
|
||||
pixels.width(),
|
||||
pixels.height(),
|
||||
pixels.into_raw(),
|
||||
))
|
||||
}
|
||||
@@ -82,6 +82,8 @@ fn main() -> iced::Result {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
WallpaperLoaded(u64, Result<widget::image::Handle, String>),
|
||||
Event(Box<Event>),
|
||||
Input(String),
|
||||
Submit,
|
||||
@@ -188,6 +190,11 @@ impl cosmic::Application for Locker {
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
if self.preview {
|
||||
Subscription::none()
|
||||
} else {
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged)
|
||||
},
|
||||
event::listen_with(|event, _, _| match event {
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(
|
||||
wayland::Event::Output(..) | wayland::Event::SessionLock(..),
|
||||
@@ -202,6 +209,50 @@ impl cosmic::Application for Locker {
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
let palette = ferese_theme::Palette::from_resolved(&snapshot.presented);
|
||||
self.appearance.appearance = snapshot.presented.appearance;
|
||||
self.appearance.panel = palette.sidebar;
|
||||
self.appearance.text = palette.text;
|
||||
self.appearance.accent = palette.accent;
|
||||
self.appearance.on_accent = palette.on_accent;
|
||||
self.appearance.radius = palette.radius;
|
||||
self.appearance.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family));
|
||||
let theme = cosmic::command::set_theme(self.appearance.theme());
|
||||
let target = &snapshot.theme;
|
||||
let path = target
|
||||
.tokens
|
||||
.background
|
||||
.lock_path
|
||||
.as_ref()
|
||||
.or(target.tokens.background.path.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ferese_config::default_wallpaper().into());
|
||||
let reduce = target.accessibility.reduce_transparency;
|
||||
if path != self.appearance.wallpaper_path || reduce != self.appearance.reduce_transparency {
|
||||
self.appearance.wallpaper_path = path.clone();
|
||||
self.appearance.reduce_transparency = reduce;
|
||||
self.appearance.wallpaper_revision = self.appearance.wallpaper_revision.wrapping_add(1);
|
||||
let revision = self.appearance.wallpaper_revision;
|
||||
let blur = if reduce { 0. } else { self.appearance.wallpaper_blur };
|
||||
let wallpaper = cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || appearance::load_wallpaper(&path, blur))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::WallpaperLoaded(revision, result)
|
||||
});
|
||||
return Task::batch([theme, wallpaper]);
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
Message::WallpaperLoaded(revision, result) => {
|
||||
if revision == self.appearance.wallpaper_revision {
|
||||
match result {
|
||||
Ok(image) => self.appearance.wallpaper = image,
|
||||
Err(error) => eprintln!("ferese-lock: retained wallpaper: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Event(box Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers))) => {
|
||||
self.caps_lock = modifiers.contains(iced::keyboard::Modifiers::CAPS_LOCK);
|
||||
}
|
||||
|
||||
@@ -13,33 +13,35 @@ use crate::PromptEvent;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Appearance {
|
||||
appearance: ferese_config::theme::Appearance,
|
||||
surface: Color,
|
||||
text: Color,
|
||||
muted: Color,
|
||||
accent: Color,
|
||||
on_accent: Color,
|
||||
radius: f32,
|
||||
font: cosmic::font::Font,
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
fn load() -> Self {
|
||||
let document = ferese_config::config_path()
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|source| ferese_config::Document::parse(&source).ok());
|
||||
let palette = ferese_theme::Palette::from_document(document.as_ref()).flat();
|
||||
let family = document
|
||||
.as_ref()
|
||||
.and_then(|document| document.get("theme.typography.font_family"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("Inter");
|
||||
let snapshot = ferese_theme::service::current();
|
||||
Self::from_resolved(&snapshot.presented)
|
||||
}
|
||||
|
||||
fn from_resolved(theme: &ferese_config::theme::ResolvedTheme) -> Self {
|
||||
let palette = ferese_theme::Palette::from_resolved(theme).flat();
|
||||
let family = &theme.tokens.typography.font_family;
|
||||
Self {
|
||||
appearance: theme.appearance,
|
||||
surface: Color {
|
||||
a: ferese_theme::material_opacity(document.as_ref()),
|
||||
a: ferese_theme::service::opacity(theme),
|
||||
..palette.sidebar
|
||||
},
|
||||
text: palette.text,
|
||||
muted: palette.muted,
|
||||
accent: palette.accent,
|
||||
on_accent: palette.on_accent,
|
||||
radius: palette.radius,
|
||||
font: ferese_theme::font(Some(family)),
|
||||
}
|
||||
@@ -47,12 +49,14 @@ impl Appearance {
|
||||
|
||||
fn palette(&self) -> ferese_theme::Palette {
|
||||
ferese_theme::Palette {
|
||||
appearance: self.appearance,
|
||||
background: self.surface,
|
||||
sidebar: self.surface,
|
||||
card: self.surface,
|
||||
text: self.text,
|
||||
muted: self.muted,
|
||||
accent: self.accent,
|
||||
on_accent: self.on_accent,
|
||||
radius: self.radius,
|
||||
error: Color::from_rgb8(235, 98, 98),
|
||||
}
|
||||
@@ -65,6 +69,7 @@ impl Appearance {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
WindowOpened(cosmic::iced::window::Id),
|
||||
MaterialAttached(Result<ferese_theme::material::ModalMaterial, String>),
|
||||
Tick,
|
||||
@@ -224,6 +229,7 @@ impl cosmic::Application for Prompt {
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged),
|
||||
cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick),
|
||||
cosmic::iced::event::listen_with(|event, _, id| match event {
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
|
||||
@@ -236,6 +242,10 @@ impl cosmic::Application for Prompt {
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
self.appearance = Appearance::from_resolved(&snapshot.presented);
|
||||
return cosmic::command::set_theme(self.appearance.theme());
|
||||
}
|
||||
Message::WindowOpened(id) => {
|
||||
return cosmic::iced::window::run(id, ferese_theme::material::ModalMaterial::attach)
|
||||
.map(|result| cosmic::Action::App(Message::MaterialAttached(result)));
|
||||
|
||||
+544
-112
@@ -9,8 +9,9 @@ use std::path::PathBuf;
|
||||
|
||||
use cosmic::app::{Core, Settings, Task};
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::widget::{button, column, container, row, scrollable, slider, text_input, toggler};
|
||||
use cosmic::widget::{button, column, container, row, scrollable, slider, text_input};
|
||||
use cosmic::{ApplicationExt, Element, widget};
|
||||
use ferese_theme::gallery;
|
||||
use schema::{Field, Kind, Page};
|
||||
use store::{Edit, Snapshot, set};
|
||||
|
||||
@@ -41,6 +42,7 @@ fn main() -> cosmic::iced::Result {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
DragWindow,
|
||||
Page(Page),
|
||||
Search(String),
|
||||
@@ -54,7 +56,17 @@ enum Message {
|
||||
Reload,
|
||||
ExternalConfig(Result<Snapshot, String>),
|
||||
Undo,
|
||||
Preset(usize),
|
||||
Family(String, Option<ferese_config::theme::Appearance>),
|
||||
SplitThemes(bool),
|
||||
AutoAppearance(bool),
|
||||
AutoDetails(bool),
|
||||
AdvancedTheme(bool),
|
||||
ThemeFileTarget(usize),
|
||||
PickThemeFile(&'static str),
|
||||
ThemeFilePicked(&'static str, Result<Option<String>, String>),
|
||||
ImportTheme,
|
||||
ThemeImported(Result<Option<(String, String)>, String>),
|
||||
ExpireUndo(u64),
|
||||
PickWallpaper,
|
||||
PreviewLock,
|
||||
LockPreviewStarted(Result<(), String>),
|
||||
@@ -88,6 +100,11 @@ struct App {
|
||||
new_command: String,
|
||||
font: cosmic::font::Font,
|
||||
native_palette: visuals::Palette,
|
||||
resolved: ferese_config::theme::Snapshot,
|
||||
undo_revision: u64,
|
||||
auto_details: bool,
|
||||
advanced_theme: bool,
|
||||
theme_file_target: usize,
|
||||
thumbnail: Option<widget::image::Handle>,
|
||||
thumbnail_path: String,
|
||||
thumbnail_loading: bool,
|
||||
@@ -125,8 +142,9 @@ impl cosmic::Application for App {
|
||||
.into();
|
||||
let error = initial.as_ref().err().cloned();
|
||||
let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap());
|
||||
let font = visuals::configured_font(¤t);
|
||||
let native_palette = visuals::Palette::from_document(Some(¤t.doc));
|
||||
let resolved = ferese_theme::service::current();
|
||||
let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family));
|
||||
let native_palette = visuals::Palette::from_resolved(&resolved.presented);
|
||||
let mut app = Self {
|
||||
note_editors: HashMap::new(),
|
||||
core,
|
||||
@@ -146,6 +164,11 @@ impl cosmic::Application for App {
|
||||
new_command: String::new(),
|
||||
font,
|
||||
native_palette,
|
||||
resolved,
|
||||
undo_revision: 0,
|
||||
auto_details: false,
|
||||
advanced_theme: false,
|
||||
theme_file_target: 0,
|
||||
thumbnail: None,
|
||||
thumbnail_path: String::new(),
|
||||
thumbnail_loading: false,
|
||||
@@ -163,6 +186,11 @@ impl cosmic::Application for App {
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
self.resolved = snapshot;
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
return self.update_theme();
|
||||
}
|
||||
Message::NoteAction(id, action) => {
|
||||
if let Some(editor) = self.note_editors.get_mut(&id) {
|
||||
let edited = action.is_edit();
|
||||
@@ -232,7 +260,7 @@ impl cosmic::Application for App {
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.sync_notes();
|
||||
self.font = visuals::configured_font(&self.current);
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
self.undo = None;
|
||||
self.error = None;
|
||||
self.status = "Updated from your config".into();
|
||||
@@ -319,7 +347,7 @@ impl cosmic::Application for App {
|
||||
Ok((snapshot, live)) => {
|
||||
self.undo = Some(std::mem::take(&mut self.saving_previous));
|
||||
self.current = snapshot;
|
||||
self.font = visuals::configured_font(&self.current);
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
self.draft = self.current.clone();
|
||||
|
||||
for edit in &self.pending {
|
||||
@@ -338,7 +366,13 @@ impl cosmic::Application for App {
|
||||
}
|
||||
.into();
|
||||
|
||||
return Task::batch([self.update_theme(), self.flush(), self.load_thumbnail()]);
|
||||
self.undo_revision = self.undo_revision.wrapping_add(1);
|
||||
let revision = self.undo_revision;
|
||||
let expire = cosmic::task::future(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
Message::ExpireUndo(revision)
|
||||
});
|
||||
return Task::batch([self.update_theme(), self.flush(), self.load_thumbnail(), expire]);
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
@@ -354,7 +388,7 @@ impl cosmic::Application for App {
|
||||
self.draft = snapshot;
|
||||
self.note_editors.clear();
|
||||
self.sync_notes();
|
||||
self.font = visuals::configured_font(&self.current);
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
self.pending.clear();
|
||||
self.inputs.clear();
|
||||
self.ranges.clear();
|
||||
@@ -371,58 +405,101 @@ impl cosmic::Application for App {
|
||||
return self.start_save(source);
|
||||
}
|
||||
}
|
||||
Message::Preset(index) => {
|
||||
for edit in visuals::preset(index) {
|
||||
if let Err(error) = self.draft.edit(&edit) {
|
||||
self.error = Some(error);
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
self.pending.push(edit);
|
||||
}
|
||||
return self.flush();
|
||||
Message::Family(id, appearance) => {
|
||||
let edits = visuals::choose_family(&id, appearance);
|
||||
let focus = cosmic::iced::advanced::widget::operate(
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
|
||||
);
|
||||
return Task::batch([self.edit_many(edits), focus.map(cosmic::Action::App)]);
|
||||
}
|
||||
Message::AutoDetails(enabled) => self.auto_details = enabled,
|
||||
Message::AdvancedTheme(enabled) => self.advanced_theme = enabled,
|
||||
Message::AutoAppearance(enabled) => {
|
||||
let mode = if enabled {
|
||||
"auto"
|
||||
} else if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
return self.change(set("theme.mode", mode));
|
||||
}
|
||||
Message::ThemeFileTarget(index) => self.theme_file_target = index.min(2),
|
||||
Message::PickThemeFile(path) => {
|
||||
return cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
store::pick_file("Choose theme overrides", "KDL themes | *.kdl")
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::ThemeFilePicked(path, result)
|
||||
});
|
||||
}
|
||||
Message::ThemeFilePicked(path, Ok(Some(file))) => return self.change(set(path, file)),
|
||||
Message::ThemeFilePicked(_, Err(error)) => self.error = Some(error),
|
||||
Message::SplitThemes(enabled) => {
|
||||
return self.edit_many(visuals::toggle_split(
|
||||
&self.draft,
|
||||
enabled,
|
||||
self.resolved.theme.appearance,
|
||||
));
|
||||
}
|
||||
Message::ImportTheme => {
|
||||
let source = self.draft.source.clone();
|
||||
let directory = self.path.parent().map(ToOwned::to_owned);
|
||||
return cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<_, String> {
|
||||
let Some(path) = store::pick_file("Import theme", "KDL themes | *.kdl")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut snapshot = Snapshot::parse(source)?;
|
||||
let stem = PathBuf::from(&path)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let stem: String = stem
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let mut id = format!("custom-{stem}");
|
||||
let mut suffix = 2;
|
||||
while snapshot.item(&format!("theme.custom_themes.{id}")).is_some() {
|
||||
id = format!("custom-{stem}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
snapshot.edit(&set(&format!("theme.custom_themes.{id}.file"), path.as_str()))?;
|
||||
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?;
|
||||
let value = connection.call(
|
||||
"theme-preview",
|
||||
serde_json::json!({"source": snapshot.source, "directory": directory}),
|
||||
)?;
|
||||
let _: Vec<ferese_config::families::Family> =
|
||||
serde_json::from_value(value["families"].clone()).map_err(|e| e.to_string())?;
|
||||
Ok(Some((id, path)))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::ThemeImported(result)
|
||||
});
|
||||
}
|
||||
Message::ThemeImported(Ok(Some((id, path)))) => {
|
||||
return self.change(set(&format!("theme.custom_themes.{id}.file"), path));
|
||||
}
|
||||
Message::ThemeImported(Err(error)) => self.error = Some(error),
|
||||
Message::ExpireUndo(revision) if revision == self.undo_revision => self.undo = None,
|
||||
Message::PickWallpaper => {
|
||||
return cosmic::task::future(async {
|
||||
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
|
||||
// Native GTK file chooser, independent of the desktop portal.
|
||||
// Waiting for user input must never block the UI executor.
|
||||
std::thread::spawn(move || {
|
||||
let result = std::process::Command::new("zenity")
|
||||
.args([
|
||||
"--file-selection",
|
||||
"--title=Choose a wallpaper",
|
||||
"--file-filter=Images | *.png *.jpg *.jpeg *.webp *.PNG *.JPG *.JPEG *.WEBP",
|
||||
"--file-filter=All files | *",
|
||||
])
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Could not open the file chooser: {error}. Install zenity or enter an image path."
|
||||
)
|
||||
})
|
||||
.and_then(|output| {
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None); // User cancelled.
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"File chooser failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let path = String::from_utf8(output.stdout)
|
||||
.map_err(|_| "The image path is not valid UTF-8.".to_owned())?;
|
||||
let path = path.trim_end_matches(['\r', '\n']);
|
||||
Ok((!path.is_empty()).then(|| path.to_owned()))
|
||||
});
|
||||
let _ = send.send(result);
|
||||
});
|
||||
Message::WallpaperPicked(
|
||||
receive
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("File chooser worker stopped.".into())),
|
||||
)
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
store::pick_file(
|
||||
"Choose a wallpaper",
|
||||
"Images | *.png *.jpg *.jpeg *.webp *.PNG *.JPG *.JPEG *.WEBP",
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::WallpaperPicked(result)
|
||||
});
|
||||
}
|
||||
Message::WallpaperPicked(Ok(Some(path))) => {
|
||||
@@ -495,11 +572,14 @@ impl cosmic::Application for App {
|
||||
}
|
||||
|
||||
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
|
||||
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes)
|
||||
cosmic::iced::Subscription::batch([
|
||||
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes),
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged),
|
||||
])
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut sidebar = column([])
|
||||
.spacing(3)
|
||||
.push(
|
||||
@@ -550,7 +630,7 @@ impl cosmic::Application for App {
|
||||
.height(Length::Fill)
|
||||
.padding([20, 10])
|
||||
.class(visuals::surface(palette.sidebar, 0.));
|
||||
let heading = row([]).align_y(Alignment::Center).spacing(20).push(
|
||||
let mut heading = row([]).align_y(Alignment::Center).spacing(20).push(
|
||||
column([])
|
||||
.spacing(5)
|
||||
.push(self.label(
|
||||
@@ -574,6 +654,56 @@ impl cosmic::Application for App {
|
||||
)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
if self.page == Page::Appearance && self.search.is_empty() {
|
||||
let automatic = self.draft.string("theme.mode", "dark") == "auto";
|
||||
heading = heading.push(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label("Auto", 12.))
|
||||
.push(
|
||||
ferese_theme::controls::switch(automatic, palette)
|
||||
.name(if automatic {
|
||||
"Auto appearance: on"
|
||||
} else {
|
||||
"Auto appearance: off"
|
||||
})
|
||||
.on_press(Message::AutoAppearance(!automatic)),
|
||||
),
|
||||
);
|
||||
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
|
||||
let source = if scheduled { "Schedule" } else { "System" };
|
||||
let label = if automatic {
|
||||
format!(
|
||||
"{source} · {}",
|
||||
if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
|
||||
"Light"
|
||||
} else {
|
||||
"Dark"
|
||||
},
|
||||
)
|
||||
} else {
|
||||
source.to_owned()
|
||||
};
|
||||
let explanation = if scheduled {
|
||||
format!(
|
||||
"Auto uses Light from {} and Dark from {} ({})",
|
||||
self.draft.string("theme.schedule.light_at", "07:00"),
|
||||
self.draft.string("theme.schedule.dark_at", "19:00"),
|
||||
self.draft.string("theme.schedule.timezone", "system"),
|
||||
)
|
||||
} else {
|
||||
"Auto follows the GTK/GNOME appearance preference".to_owned()
|
||||
};
|
||||
heading = heading.push(widget::tooltip(
|
||||
ferese_theme::controls::text_button(label, self.font, palette, self.auto_details)
|
||||
.name("Automatic appearance settings")
|
||||
.on_press(Message::AutoDetails(!self.auto_details)),
|
||||
self.label(explanation, 12.),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
}
|
||||
|
||||
let mut body = column([]).spacing(12);
|
||||
|
||||
if !self.search.is_empty() {
|
||||
@@ -596,54 +726,26 @@ impl cosmic::Application for App {
|
||||
}
|
||||
} else {
|
||||
if matches!(self.page, Page::Bar | Page::Windows) {
|
||||
body = body.push(visuals::preview(&self.draft));
|
||||
body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented));
|
||||
}
|
||||
if self.page == Page::Appearance {
|
||||
let split = visuals::split(&self.draft);
|
||||
if split {
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light)));
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark)));
|
||||
} else {
|
||||
body = body.push(self.theme_gallery(None));
|
||||
}
|
||||
body = body.push(
|
||||
column([])
|
||||
.spacing(4)
|
||||
.push(self.label("Theme", 15.))
|
||||
.push(self.note("Choose a look for your desktop.")),
|
||||
widget::checkbox(split)
|
||||
.label("Use different themes for light and dark")
|
||||
.text_size(12)
|
||||
.font(self.font)
|
||||
.on_toggle(Message::SplitThemes),
|
||||
);
|
||||
let mut presets = column([]).spacing(10);
|
||||
for (row_index, choices) in visuals::PRESETS.chunks(3).enumerate() {
|
||||
let mut tiles = row([]).spacing(10);
|
||||
for (column_index, preset) in choices.iter().enumerate() {
|
||||
let index = row_index * 3 + column_index;
|
||||
let selected = visuals::preset_selected(&self.draft, index);
|
||||
let indicator: Element<'_, Message> = if selected {
|
||||
visuals::action_icon("M5 12l4 4L19 6", palette.accent).into()
|
||||
} else {
|
||||
widget::Space::new().width(16).height(16).into()
|
||||
};
|
||||
let tile = button::custom(
|
||||
column([])
|
||||
.spacing(8)
|
||||
.push(visuals::preset_preview(index))
|
||||
.push(
|
||||
row([])
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label(preset.name, 12.).width(Length::Fill))
|
||||
.push(indicator),
|
||||
)
|
||||
.push(
|
||||
self.label(preset.description, 10.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(10)
|
||||
.class(visuals::button_style(palette, selected));
|
||||
tiles = tiles.push(if self.saving {
|
||||
tile
|
||||
} else {
|
||||
tile.on_press(Message::Preset(index))
|
||||
});
|
||||
}
|
||||
presets = presets.push(tiles);
|
||||
if let Some(note) = &self.resolved.fallback_note {
|
||||
body = body.push(self.note(note));
|
||||
}
|
||||
body = body.push(presets);
|
||||
}
|
||||
|
||||
if self.page == Page::Wallpaper {
|
||||
@@ -669,9 +771,51 @@ impl cosmic::Application for App {
|
||||
|
||||
if !fields.is_empty() {
|
||||
let mut group = column([]).spacing(1);
|
||||
if self.page == Page::Appearance {
|
||||
if self.auto_details {
|
||||
group = group.push(self.auto_controls());
|
||||
}
|
||||
}
|
||||
for field in fields {
|
||||
if self.page == Page::Appearance
|
||||
&& (field.path == "theme.mode"
|
||||
|| field.path.starts_with("theme.schedule.")
|
||||
|| field.path.ends_with(".file"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
group = group.push(self.field(field));
|
||||
}
|
||||
if self.page == Page::Appearance {
|
||||
group = group.push(
|
||||
button::custom(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label("Advanced customization", 13.))
|
||||
.push(visuals::action_icon(
|
||||
if self.advanced_theme {
|
||||
"M6 15l6-6 6 6"
|
||||
} else {
|
||||
"M6 9l6 6 6-6"
|
||||
},
|
||||
palette.muted,
|
||||
)),
|
||||
)
|
||||
.name(if self.advanced_theme {
|
||||
"Collapse advanced customization"
|
||||
} else {
|
||||
"Expand advanced customization"
|
||||
})
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.class(visuals::button_style(palette, false))
|
||||
.on_press(Message::AdvancedTheme(!self.advanced_theme)),
|
||||
);
|
||||
if self.advanced_theme {
|
||||
group = group.push(self.theme_file_picker());
|
||||
}
|
||||
}
|
||||
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
|
||||
}
|
||||
|
||||
@@ -963,12 +1107,12 @@ impl cosmic::Application for App {
|
||||
|
||||
impl App {
|
||||
fn update_theme(&mut self) -> Task<Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
if palette == self.native_palette {
|
||||
return Task::none();
|
||||
}
|
||||
self.native_palette = palette;
|
||||
cosmic::command::set_theme(visuals::native_theme(Some(&self.draft)))
|
||||
cosmic::command::set_theme(palette.native_theme())
|
||||
}
|
||||
|
||||
fn note_index(&self, id: &str) -> Option<usize> {
|
||||
@@ -1057,14 +1201,258 @@ impl App {
|
||||
}
|
||||
|
||||
fn note(&self, text: &str) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
self.label(text.to_owned(), 12.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn field(&self, field: Field) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
fn theme_file_picker(&self) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let path = ["theme.file", "theme.light.file", "theme.dark.file"][self.theme_file_target];
|
||||
let file = self.draft.string(path, "");
|
||||
let filename = if file.is_empty() {
|
||||
"No override file".to_owned()
|
||||
} else {
|
||||
PathBuf::from(&file)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
let labels = column([])
|
||||
.spacing(3)
|
||||
.width(Length::Fill)
|
||||
.push(self.label("Theme overrides", 13.))
|
||||
.push(widget::tooltip(
|
||||
self.label(filename, 11.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
self.label(
|
||||
if file.is_empty() {
|
||||
"Choose a partial KDL theme file".to_owned()
|
||||
} else {
|
||||
file.clone()
|
||||
},
|
||||
11.,
|
||||
),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
let mut scopes = row([]).spacing(2);
|
||||
for (index, label) in ["Shared", "Light", "Dark"].into_iter().enumerate() {
|
||||
scopes = scopes.push(
|
||||
button::custom(self.label(label, 12.))
|
||||
.name(format!("Theme file: {label}"))
|
||||
.padding([6, 8])
|
||||
.class(visuals::button_style(palette, index == self.theme_file_target))
|
||||
.on_press(Message::ThemeFileTarget(index)),
|
||||
);
|
||||
}
|
||||
let mut controls = row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
container(scopes)
|
||||
.padding(3)
|
||||
.class(visuals::surface(palette.sidebar, 10.)),
|
||||
)
|
||||
.push(
|
||||
ferese_theme::controls::text_button(
|
||||
if file.is_empty() { "Choose…" } else { "Replace…" },
|
||||
self.font,
|
||||
palette,
|
||||
false,
|
||||
)
|
||||
.on_press(Message::PickThemeFile(path)),
|
||||
);
|
||||
if !file.is_empty() {
|
||||
controls = controls.push(widget::tooltip(
|
||||
button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted))
|
||||
.padding(5)
|
||||
.on_press(Message::Change(Edit::Unset(path.into()))),
|
||||
self.label("Remove override", 11.),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
}
|
||||
container(
|
||||
row([])
|
||||
.spacing(16)
|
||||
.align_y(Alignment::Center)
|
||||
.push(labels)
|
||||
.push(controls),
|
||||
)
|
||||
.padding([12, 12])
|
||||
.width(Length::Fill)
|
||||
.class(visuals::surface(palette.card, 0.))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn auto_controls(&self) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
|
||||
let mut sources = row([]).spacing(4);
|
||||
for (source, label) in [("system", "System"), ("schedule", "Schedule")] {
|
||||
sources = sources.push(
|
||||
ferese_theme::controls::text_button(label, self.font, palette, scheduled == (source == "schedule"))
|
||||
.name(format!("Auto source: {label}"))
|
||||
.on_press(Message::Change(set("theme.schedule.source", source))),
|
||||
);
|
||||
}
|
||||
let description = if scheduled {
|
||||
"Switch between light and dark at set times."
|
||||
} else {
|
||||
"Follow the GTK/GNOME appearance preference."
|
||||
};
|
||||
let mut options = column([]).spacing(8).push(
|
||||
row([])
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
column([])
|
||||
.spacing(3)
|
||||
.width(Length::Fill)
|
||||
.push(self.label("Automatic appearance", 13.))
|
||||
.push(self.note(description)),
|
||||
)
|
||||
.push(sources),
|
||||
);
|
||||
if scheduled {
|
||||
let mut times = row([]).spacing(16).align_y(Alignment::Center);
|
||||
for field in schema::fields(Page::Appearance)
|
||||
.into_iter()
|
||||
.filter(|field| field.path.starts_with("theme.schedule."))
|
||||
{
|
||||
let path = field.path.clone();
|
||||
let Kind::Text { default, .. } = &field.kind else {
|
||||
continue;
|
||||
};
|
||||
let value = self
|
||||
.inputs
|
||||
.get(&path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.draft.string(&path, default));
|
||||
let submit = field.clone();
|
||||
let input = text_input(*default, value)
|
||||
.font(self.font)
|
||||
.size(12)
|
||||
.style(visuals::input_style(palette))
|
||||
.on_input(move |value| Message::Draft(path.clone(), value))
|
||||
.on_submit(move |_| Message::Commit(submit.clone()))
|
||||
.on_unfocus(Message::Commit(field.clone()))
|
||||
.width(if field.path.ends_with("timezone") { 150 } else { 75 });
|
||||
times = times.push(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label(field.label, 12.))
|
||||
.push(input),
|
||||
);
|
||||
}
|
||||
options = options.push(times);
|
||||
}
|
||||
container(options)
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.class(visuals::surface(palette.card, 0.))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn theme_gallery(&self, appearance: Option<ferese_config::theme::Appearance>) -> Element<'static, Message> {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let selected_id = visuals::family_selection(&self.draft, appearance);
|
||||
let choices = self.resolved.families.clone();
|
||||
let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect();
|
||||
let mut heading = row([]).align_y(Alignment::Center).spacing(12).push(
|
||||
self.label(
|
||||
match appearance {
|
||||
Some(Light) => "Light theme",
|
||||
Some(Dark) => "Dark theme",
|
||||
None => "Theme",
|
||||
},
|
||||
15.,
|
||||
)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
if appearance != Some(Dark) {
|
||||
if self.undo.is_some() {
|
||||
heading = heading.push(
|
||||
ferese_theme::controls::text_button("Undo", self.font, palette, false)
|
||||
.on_press_maybe((!self.saving).then_some(Message::Undo)),
|
||||
);
|
||||
}
|
||||
heading = heading.push(
|
||||
ferese_theme::controls::text_button("Import…", self.font, palette, false)
|
||||
.on_press(Message::ImportTheme),
|
||||
);
|
||||
}
|
||||
let mut gallery = column([]).spacing(10).push(heading);
|
||||
for (chunk, families) in choices.chunks(3).enumerate() {
|
||||
let mut tiles = row([]).spacing(12);
|
||||
for (offset, family) in families.iter().enumerate() {
|
||||
let selected = family.id == selected_id;
|
||||
let index = chunk * 3 + offset;
|
||||
let id = family.id.clone();
|
||||
let active = selected
|
||||
.then_some(self.resolved.theme.appearance)
|
||||
.filter(|a| appearance.is_none_or(|variant| variant == *a));
|
||||
let ids = ids.clone();
|
||||
let tile_id = gallery_id(&id, appearance);
|
||||
tiles = tiles.push(gallery::tile(
|
||||
family,
|
||||
appearance,
|
||||
active,
|
||||
selected,
|
||||
palette,
|
||||
self.font,
|
||||
tile_id,
|
||||
Message::Family(id, appearance),
|
||||
move |key| {
|
||||
gallery::neighbor(index, ids.len(), key)
|
||||
.map(|index| Message::Family(ids[index].clone(), appearance))
|
||||
},
|
||||
));
|
||||
}
|
||||
for _ in families.len()..3 {
|
||||
tiles = tiles.push(widget::Space::new().width(Length::Fill));
|
||||
}
|
||||
gallery = gallery.push(tiles);
|
||||
}
|
||||
gallery::group(
|
||||
container(gallery).width(Length::Fill).max_width(640),
|
||||
cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")),
|
||||
match appearance {
|
||||
Some(Light) => "Light theme",
|
||||
Some(Dark) => "Dark theme",
|
||||
None => "Theme",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn edit_many(&mut self, edits: Vec<Edit>) -> Task<Message> {
|
||||
for edit in edits {
|
||||
if let Err(error) = self.draft.edit(&edit) {
|
||||
self.error = Some(error);
|
||||
return Task::none();
|
||||
}
|
||||
self.pending.push(edit);
|
||||
}
|
||||
self.flush()
|
||||
}
|
||||
|
||||
fn field(&self, mut field: Field) -> Element<'static, Message> {
|
||||
if field.path == "theme.material.opacity" {
|
||||
let appearance = self.resolved.theme.appearance;
|
||||
let name = match appearance {
|
||||
ferese_config::theme::Appearance::Light => "light",
|
||||
ferese_config::theme::Appearance::Dark => "dark",
|
||||
};
|
||||
field.path = format!("theme.{name}.material.opacity");
|
||||
field.description = format!("Opacity for the current {name} appearance. Text and icons stay opaque.");
|
||||
if let Kind::Range { default, .. } = &mut field.kind {
|
||||
*default = self.resolved.theme.tokens.material.opacity;
|
||||
}
|
||||
}
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut labels = column([]).spacing(3).push(self.label(field.label.clone(), 13.));
|
||||
if !field.description.is_empty() {
|
||||
labels = labels.push(
|
||||
@@ -1120,10 +1508,13 @@ impl App {
|
||||
)
|
||||
.into()
|
||||
}
|
||||
Kind::Toggle(default) => toggler(self.draft.boolean(&path, default))
|
||||
.size(22)
|
||||
.on_toggle(move |value| Message::Change(set(&path, value)))
|
||||
.into(),
|
||||
Kind::Toggle(default) => {
|
||||
let enabled = self.draft.boolean(&path, default);
|
||||
ferese_theme::controls::switch(enabled, palette)
|
||||
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
|
||||
.on_press(Message::Change(set(&path, !enabled)))
|
||||
.into()
|
||||
}
|
||||
Kind::Range {
|
||||
default,
|
||||
min,
|
||||
@@ -1149,6 +1540,7 @@ impl App {
|
||||
.push(
|
||||
slider(min..=max, value, move |value| Message::Range(field.clone(), value))
|
||||
.step(step)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(145),
|
||||
)
|
||||
@@ -1277,6 +1669,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn gallery_id(id: &str, appearance: Option<ferese_config::theme::Appearance>) -> cosmic::iced::advanced::widget::Id {
|
||||
cosmic::iced::advanced::widget::Id::new(format!("theme-{appearance:?}-{id}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cosmic::Application;
|
||||
@@ -1293,6 +1689,42 @@ mod tests {
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_auto_keeps_the_effective_appearance() {
|
||||
for appearance in [
|
||||
ferese_config::theme::Appearance::Light,
|
||||
ferese_config::theme::Appearance::Dark,
|
||||
] {
|
||||
let mut app = app();
|
||||
app.resolved.theme.appearance = appearance;
|
||||
let _ = app.update(Message::AutoAppearance(false));
|
||||
assert_eq!(
|
||||
app.draft.string("theme.mode", ""),
|
||||
if appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
}
|
||||
);
|
||||
let _ = app.update(Message::AutoAppearance(true));
|
||||
assert_eq!(app.draft.string("theme.mode", ""), "auto");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_picker_applies_to_the_scope_it_was_opened_for_and_cancel_keeps_it() {
|
||||
let mut app = app();
|
||||
let _ = app.update(Message::ThemeFileTarget(2));
|
||||
let _ = app.update(Message::ThemeFilePicked(
|
||||
"theme.light.file",
|
||||
Ok(Some("/tmp/light.kdl".into())),
|
||||
));
|
||||
assert_eq!(app.draft.string("theme.light.file", ""), "/tmp/light.kdl");
|
||||
assert!(app.draft.item("theme.dark.file").is_none());
|
||||
let _ = app.update(Message::ThemeFilePicked("theme.light.file", Ok(None)));
|
||||
assert_eq!(app.draft.string("theme.light.file", ""), "/tmp/light.kdl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_save_only_the_latest_revision_and_preserve_multiline_text() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Page {
|
||||
Appearance,
|
||||
Accessibility,
|
||||
Wallpaper,
|
||||
Desktop,
|
||||
Bar,
|
||||
@@ -15,8 +16,9 @@ pub enum Page {
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub const ALL: [Self; 12] = [
|
||||
pub const ALL: [Self; 13] = [
|
||||
Self::Appearance,
|
||||
Self::Accessibility,
|
||||
Self::Wallpaper,
|
||||
Self::Desktop,
|
||||
Self::Bar,
|
||||
@@ -33,6 +35,7 @@ impl Page {
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "Appearance",
|
||||
Self::Accessibility => "Accessibility",
|
||||
Self::Wallpaper => "Wallpaper",
|
||||
Self::Desktop => "Desktop widgets",
|
||||
Self::Bar => "Menu bar",
|
||||
@@ -50,6 +53,7 @@ impl Page {
|
||||
pub fn subtitle(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "A desktop that feels like yours.",
|
||||
Self::Accessibility => "Make the desktop easier to see and use.",
|
||||
Self::Wallpaper => "Set the scene for your workspace.",
|
||||
Self::Desktop => "A clock that feels at home on your wallpaper.",
|
||||
Self::Bar => "Everything you need, within reach.",
|
||||
@@ -66,6 +70,7 @@ impl Page {
|
||||
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Self::Accessibility => "M12 3a2 2 0 1 0 0 4a2 2 0 1 0 0-4 M4 9h16 M12 9v6 M12 15l-5 6 M12 15l5 6",
|
||||
Self::Appearance => {
|
||||
"M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01"
|
||||
}
|
||||
@@ -178,7 +183,7 @@ pub fn text(
|
||||
Field::new(path, label, description, Kind::Text { default, argv: false })
|
||||
}
|
||||
|
||||
fn choice(
|
||||
pub fn choice(
|
||||
path: &str,
|
||||
label: &str,
|
||||
description: &str,
|
||||
@@ -591,7 +596,70 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
false,
|
||||
),
|
||||
],
|
||||
Page::Accessibility => vec![
|
||||
toggle(
|
||||
"theme.accessibility.increase_contrast",
|
||||
"Increase contrast",
|
||||
"Stronger text and borders.",
|
||||
false,
|
||||
),
|
||||
toggle(
|
||||
"theme.accessibility.reduce_transparency",
|
||||
"Reduce transparency",
|
||||
"Solid surfaces; background blur is disabled.",
|
||||
false,
|
||||
),
|
||||
toggle(
|
||||
"animations.reduced_motion",
|
||||
"Reduce motion",
|
||||
"Use immediate changes in place of animations.",
|
||||
false,
|
||||
),
|
||||
],
|
||||
Page::Appearance => vec![
|
||||
choice(
|
||||
"theme.mode",
|
||||
"Appearance",
|
||||
"Choose a fixed appearance or let Auto follow a source.",
|
||||
"dark",
|
||||
&[("light", "Light"), ("dark", "Dark"), ("auto", "Auto")],
|
||||
),
|
||||
text(
|
||||
"theme.file",
|
||||
"Shared theme file",
|
||||
"Optional KDL overrides. Relative paths start in your config folder.",
|
||||
"",
|
||||
),
|
||||
text(
|
||||
"theme.light.file",
|
||||
"Light theme file",
|
||||
"Optional overrides for Light appearance.",
|
||||
"",
|
||||
),
|
||||
text(
|
||||
"theme.dark.file",
|
||||
"Dark theme file",
|
||||
"Optional overrides for Dark appearance.",
|
||||
"",
|
||||
),
|
||||
text(
|
||||
"theme.schedule.light_at",
|
||||
"Light at",
|
||||
"Local time in HH:MM format.",
|
||||
"07:00",
|
||||
),
|
||||
text(
|
||||
"theme.schedule.dark_at",
|
||||
"Dark at",
|
||||
"Local time in HH:MM format.",
|
||||
"19:00",
|
||||
),
|
||||
text(
|
||||
"theme.schedule.timezone",
|
||||
"Timezone",
|
||||
"system follows your desktop timezone; or enter an IANA timezone.",
|
||||
"system",
|
||||
),
|
||||
range(
|
||||
"theme.geometry.shell_radius",
|
||||
"Shell corner radius",
|
||||
@@ -604,17 +672,11 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
false,
|
||||
),
|
||||
text(
|
||||
"theme.colors.accent",
|
||||
"theme.accent",
|
||||
"Accent color",
|
||||
"Focus rings and selected controls. Use a hex color.",
|
||||
"#3D7BE6",
|
||||
),
|
||||
text(
|
||||
"theme.colors.surface_base",
|
||||
"Surface color",
|
||||
"The base color for desktop surfaces.",
|
||||
"#111821",
|
||||
),
|
||||
choice(
|
||||
"theme.material.style",
|
||||
"Surface style",
|
||||
@@ -860,12 +922,6 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
"×",
|
||||
false,
|
||||
),
|
||||
toggle(
|
||||
"animations.reduced_motion",
|
||||
"Reduce motion",
|
||||
"Use immediate changes in place of animations.",
|
||||
false,
|
||||
),
|
||||
range(
|
||||
"appearance.inactive_dim.duration_ms",
|
||||
"Focus transition",
|
||||
|
||||
@@ -78,6 +78,7 @@ impl Snapshot {
|
||||
|
||||
pub fn edit(&mut self, edit: &Edit) -> Result<(), String> {
|
||||
match edit {
|
||||
Edit::Unset(path) => self.doc.unset(path),
|
||||
Edit::Set(path, value) => self.doc.set(path, value.clone()),
|
||||
Edit::Add(path, fields) => self.doc.add(path, fields.clone()),
|
||||
Edit::Remove(path, index) => self.doc.remove(path, *index),
|
||||
@@ -90,6 +91,7 @@ impl Snapshot {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Edit {
|
||||
Unset(String),
|
||||
Set(String, Value),
|
||||
Add(String, Vec<(String, Value)>),
|
||||
Remove(String, usize),
|
||||
@@ -108,6 +110,35 @@ pub fn config_path() -> PathBuf {
|
||||
path.canonicalize().unwrap_or(path)
|
||||
}
|
||||
|
||||
pub fn pick_file(title: &str, filter: &str) -> Result<Option<String>, String> {
|
||||
let mut debug = std::env::var("GDK_DEBUG").unwrap_or_default();
|
||||
if !debug.is_empty() {
|
||||
debug.push(',');
|
||||
}
|
||||
debug.push_str("no-portals");
|
||||
let output = Command::new("zenity")
|
||||
.env("GDK_DEBUG", debug)
|
||||
.env("GTK_USE_PORTAL", "0")
|
||||
.arg("--file-selection")
|
||||
.arg(format!("--title={title}"))
|
||||
.arg(format!("--file-filter={filter}"))
|
||||
.arg("--file-filter=All files | *")
|
||||
.output()
|
||||
.map_err(|e| format!("Could not open the file chooser: {e}. Install zenity to choose files."))?;
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"File chooser failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let path = String::from_utf8(output.stdout).map_err(|_| "The file path is not valid UTF-8.".to_owned())?;
|
||||
let path = path.trim_end_matches(['\r', '\n']);
|
||||
Ok((!path.is_empty()).then(|| path.to_owned()))
|
||||
}
|
||||
|
||||
pub fn sibling(name: &str) -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use cosmic::Element;
|
||||
use cosmic::iced::{Color, Length};
|
||||
use cosmic::widget::icon as svg_icon;
|
||||
pub use ferese_theme::Palette;
|
||||
pub use ferese_theme::controls::{button_style, navigation_style, settings_input as input_style, surface};
|
||||
pub use ferese_theme::{Palette, mix, surface_shade};
|
||||
|
||||
use crate::Message;
|
||||
use crate::schema::Page;
|
||||
@@ -21,12 +21,8 @@ fn hex(c: Color) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
|
||||
Palette::from_document(snapshot.map(|snapshot| &snapshot.doc)).native_theme()
|
||||
}
|
||||
|
||||
pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
|
||||
ferese_theme::font(Some(&snapshot.string("theme.typography.font_family", "Inter")))
|
||||
pub fn native_theme(_snapshot: Option<&Snapshot>) -> cosmic::Theme {
|
||||
Palette::from_resolved(&ferese_theme::service::current().presented).native_theme()
|
||||
}
|
||||
|
||||
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
|
||||
@@ -44,107 +40,149 @@ pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon {
|
||||
ferese_theme::icons::outline(path, tint, 16)
|
||||
}
|
||||
|
||||
pub use ferese_theme::{PRESETS, Preset};
|
||||
#[cfg(test)]
|
||||
pub use ferese_theme::PRESETS;
|
||||
|
||||
pub fn split(snapshot: &Snapshot) -> bool {
|
||||
snapshot
|
||||
.item("theme.split")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or_else(|| {
|
||||
ferese_config::families::family_id(&snapshot.string("theme.light.preset", "ferese-blue-light"))
|
||||
!= ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn family_selection(snapshot: &Snapshot, appearance: Option<ferese_config::theme::Appearance>) -> String {
|
||||
let Some(appearance) = appearance else {
|
||||
return snapshot.string(
|
||||
"theme.family",
|
||||
ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue")),
|
||||
);
|
||||
};
|
||||
let kind = if appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
let preset = snapshot.string(
|
||||
&format!("theme.{kind}.preset"),
|
||||
if kind == "light" {
|
||||
"ferese-blue-light"
|
||||
} else {
|
||||
"ferese-blue"
|
||||
},
|
||||
);
|
||||
snapshot.string(
|
||||
&format!("theme.{kind}.family"),
|
||||
ferese_config::families::family_id(&preset),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn choose_family(id: &str, appearance: Option<ferese_config::theme::Appearance>) -> Vec<Edit> {
|
||||
let kinds: &[&str] = match appearance {
|
||||
Some(ferese_config::theme::Appearance::Light) => &["light"],
|
||||
Some(ferese_config::theme::Appearance::Dark) => &["dark"],
|
||||
None => &[],
|
||||
};
|
||||
let mut edits = vec![set(
|
||||
&appearance.map_or_else(
|
||||
|| "theme.family".into(),
|
||||
|a| {
|
||||
format!(
|
||||
"theme.{}.family",
|
||||
if a == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
id,
|
||||
)];
|
||||
for kind in kinds {
|
||||
for token in ["colors", "surface", "border", "focus_ring"] {
|
||||
edits.push(Edit::Unset(format!("theme.{kind}.{token}")));
|
||||
}
|
||||
}
|
||||
edits
|
||||
}
|
||||
|
||||
pub fn toggle_split(snapshot: &Snapshot, enabled: bool, active: ferese_config::theme::Appearance) -> Vec<Edit> {
|
||||
let mut edits = vec![];
|
||||
if enabled {
|
||||
let id = family_selection(snapshot, None);
|
||||
for kind in ["light", "dark"] {
|
||||
if snapshot.item(&format!("theme.{kind}.family")).is_none() {
|
||||
let selection = if snapshot.item("theme.family").is_some() {
|
||||
id.clone()
|
||||
} else {
|
||||
family_selection(
|
||||
snapshot,
|
||||
Some(if kind == "light" {
|
||||
ferese_config::theme::Appearance::Light
|
||||
} else {
|
||||
ferese_config::theme::Appearance::Dark
|
||||
}),
|
||||
)
|
||||
};
|
||||
edits.push(set(&format!("theme.{kind}.family"), selection));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
edits.push(set("theme.family", family_selection(snapshot, Some(active))));
|
||||
}
|
||||
edits.push(set("theme.split", enabled));
|
||||
edits
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn preset(index: usize) -> Vec<Edit> {
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
return Vec::new();
|
||||
return vec![];
|
||||
};
|
||||
|
||||
vec![
|
||||
set("theme.colors.accent", preset.accent),
|
||||
set("theme.colors.surface_base", preset.base),
|
||||
set("theme.colors.text_primary", preset.text),
|
||||
set("theme.colors.text_muted", preset.muted),
|
||||
set("theme.colors.border", preset.border),
|
||||
set("theme.colors.shadow", "#00000055"),
|
||||
set("theme.surface.bar.background", preset.base),
|
||||
set("theme.surface.bar.text_primary", preset.text),
|
||||
set("theme.surface.bar.text_muted", preset.muted),
|
||||
set("theme.focus_ring.gradient.from", preset.accent),
|
||||
set("theme.focus_ring.gradient.to", preset.gradient_end),
|
||||
set("theme.focus_ring.gradient.angle", 0.0),
|
||||
]
|
||||
let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
let mut edits = vec![set(&format!("theme.{kind}.preset"), preset.id)];
|
||||
for token in ["colors", "surface", "border", "focus_ring"] {
|
||||
edits.push(Edit::Unset(format!("theme.{kind}.{token}")));
|
||||
}
|
||||
edits
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
return false;
|
||||
let Some(preset) = PRESETS.get(index) else { return false };
|
||||
let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
|
||||
[
|
||||
("theme.colors.accent", preset.accent, "#3D7BE6"),
|
||||
("theme.colors.surface_base", preset.base, "#111821"),
|
||||
("theme.colors.text_primary", preset.text, "#F4F7FB"),
|
||||
("theme.colors.text_muted", preset.muted, "#8793A2"),
|
||||
("theme.colors.border", preset.border, "#FFFFFF18"),
|
||||
("theme.colors.shadow", "#00000055", "#00000055"),
|
||||
("theme.surface.bar.background", preset.base, "#1C202EF2"),
|
||||
("theme.surface.bar.text_primary", preset.text, "#F0F3FA"),
|
||||
("theme.surface.bar.text_muted", preset.muted, "#AAB4C7"),
|
||||
("theme.focus_ring.gradient.from", preset.accent, "#3D7BE6"),
|
||||
("theme.focus_ring.gradient.to", preset.gradient_end, "#3D7BE6"),
|
||||
]
|
||||
.into_iter()
|
||||
.all(|(path, expected, fallback)| {
|
||||
if index == 0 && snapshot.item(path).is_none() {
|
||||
return true;
|
||||
}
|
||||
snapshot.string(path, fallback).eq_ignore_ascii_case(expected)
|
||||
}) && snapshot.number("theme.focus_ring.gradient.angle", 0.) == 0.
|
||||
snapshot.string(
|
||||
&format!("theme.{kind}.preset"),
|
||||
if kind == "dark" {
|
||||
"ferese-blue"
|
||||
} else {
|
||||
"ferese-blue-light"
|
||||
},
|
||||
) == preset.id
|
||||
&& ["colors", "surface", "border", "focus_ring"]
|
||||
.iter()
|
||||
.all(|token| snapshot.item(&format!("theme.{kind}.{token}")).is_none())
|
||||
}
|
||||
|
||||
fn preset_preview_handle(preset: &Preset) -> svg_icon::Handle {
|
||||
let base = preset.base;
|
||||
let accent = preset.accent;
|
||||
let end = preset.gradient_end;
|
||||
let text = preset.text;
|
||||
let muted = preset.muted;
|
||||
let surface = color(base, Color::BLACK);
|
||||
let card = hex(mix(surface, surface_shade(surface), 0.055));
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="240" height="132" viewBox="0 0 240 132">
|
||||
<defs>
|
||||
<linearGradient id="wall" x2="1" y2="1">
|
||||
<stop stop-color="{accent}" stop-opacity=".24"/>
|
||||
<stop offset="1" stop-color="{end}" stop-opacity=".06"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="132" rx="10" fill="{base}"/>
|
||||
<rect width="240" height="132" rx="10" fill="url(#wall)"/>
|
||||
<path d="M0 112Q52 48 120 93T240 67V132H0Z" fill="{accent}" opacity=".09"/>
|
||||
<rect x="8" y="8" width="224" height="12" rx="4" fill="{base}"/>
|
||||
<rect x="14" y="12" width="6" height="4" rx="1" fill="{accent}"/>
|
||||
<path d="M26 14h4m4 0h4m164 0h6m4 0h6" stroke="{muted}" stroke-width="2" stroke-linecap="round"/>
|
||||
<rect x="16" y="34" width="121" height="82" rx="7" fill="{card}"/>
|
||||
<path d="M26 44h102" stroke="{muted}" opacity=".16"/>
|
||||
<rect x="23" y="50" width="25" height="59" rx="3" fill="{base}"/>
|
||||
<rect x="26" y="57" width="19" height="7" rx="2" fill="{accent}" opacity=".6"/>
|
||||
<path d="M29 73h11m-11 9h9m-9 9h12" stroke="{muted}" opacity=".6" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M56 59h38m-38 10h63m-63 8h49m-49 8h58" stroke="{text}" opacity=".55" stroke-width="2" stroke-linecap="round"/>
|
||||
<rect x="55" y="94" width="29" height="9" rx="3" fill="{accent}"/>
|
||||
<rect x="145" y="37" width="78" height="70" rx="7" fill="{card}" stroke="{accent}" stroke-width="1.2"/>
|
||||
<circle cx="154" cy="45" r="1.5" fill="{accent}"/>
|
||||
<path d="M161 45h17" stroke="{muted}" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M155 58h27m-27 9h56m-56 9h37m-37 9h49m-49 9h30" stroke="{text}" opacity=".55" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>"##,
|
||||
);
|
||||
svg_icon::from_svg_bytes(svg.into_bytes()).symbolic(false)
|
||||
pub fn preview_resolved<'a>(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'a, Message> {
|
||||
let mut snapshot = snapshot.clone();
|
||||
snapshot.doc = snapshot.doc.with_theme(theme);
|
||||
preview(&snapshot, theme)
|
||||
}
|
||||
|
||||
pub fn preset_preview(index: usize) -> Element<'static, Message> {
|
||||
static HANDLES: std::sync::OnceLock<Vec<svg_icon::Handle>> = std::sync::OnceLock::new();
|
||||
HANDLES.get_or_init(|| PRESETS.iter().map(preset_preview_handle).collect())[index]
|
||||
.clone()
|
||||
.icon()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(90.))
|
||||
.content_fit(cosmic::iced::ContentFit::Contain)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
|
||||
let p = Palette::from_document(Some(&snapshot.doc));
|
||||
fn preview(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'static, Message> {
|
||||
let p = Palette::from_resolved(theme);
|
||||
let base = hex(p.sidebar);
|
||||
let card = hex(p.card);
|
||||
let accent = hex(p.accent);
|
||||
@@ -210,17 +248,64 @@ mod tests {
|
||||
ferese_theme::contrast(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn galleries_follow_mode_and_keep_selection_independent() {
|
||||
let mut snapshot = Snapshot::parse(String::from(r#"theme { mode "auto"; }"#)).unwrap();
|
||||
for index in 0..PRESETS.len() {
|
||||
for edit in preset(index) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert_eq!(snapshot.string("theme.mode", ""), "auto");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_toggle_restores_the_pair_and_uses_the_active_family_when_linked() {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let mut snapshot = Snapshot::parse(r#"theme { family "ferese-blue"; split #true; light { family "gruvbox"; }; dark { family "catppuccin"; }; }"#.into()).unwrap();
|
||||
for edit in toggle_split(&snapshot, false, Light) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert!(!split(&snapshot));
|
||||
assert_eq!(family_selection(&snapshot, None), "gruvbox");
|
||||
for edit in choose_family("everforest", None) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
for edit in toggle_split(&snapshot, true, Dark) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert!(split(&snapshot));
|
||||
assert_eq!(family_selection(&snapshot, Some(Light)), "gruvbox");
|
||||
assert_eq!(family_selection(&snapshot, Some(Dark)), "catppuccin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_split_starts_with_both_variants_of_the_single_family() {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let mut snapshot = Snapshot::parse(r#"theme { family "tokyo-night"; }"#.into()).unwrap();
|
||||
for edit in toggle_split(&snapshot, true, Dark) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert_eq!(family_selection(&snapshot, Some(Light)), "tokyo-night");
|
||||
assert_eq!(family_selection(&snapshot, Some(Dark)), "tokyo-night");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_are_distinct_and_readable_on_settings_surfaces() {
|
||||
for (index, preset) in PRESETS.iter().enumerate() {
|
||||
let snapshot = configured_preset(index);
|
||||
let palette = Palette::from_document(Some(&snapshot.doc));
|
||||
let tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap();
|
||||
let mut theme = ferese_config::theme::ResolvedTheme::default();
|
||||
theme.appearance = preset.appearance;
|
||||
theme.tokens = tokens;
|
||||
let palette = Palette::from_resolved(&theme);
|
||||
for background in [palette.background, palette.sidebar, palette.card] {
|
||||
assert!(contrast(palette.text, background) >= 4.5, "{} text", preset.name);
|
||||
assert!(contrast(palette.muted, background) >= 4.5, "{} muted text", preset.name);
|
||||
}
|
||||
for other in PRESETS.iter().skip(index + 1) {
|
||||
assert_ne!(preset.accent, other.accent);
|
||||
if preset.appearance == other.appearance {
|
||||
assert_ne!(preset.accent, other.accent);
|
||||
}
|
||||
assert_ne!(preset.base, other.base);
|
||||
}
|
||||
}
|
||||
@@ -231,12 +316,19 @@ mod tests {
|
||||
for index in 0..PRESETS.len() {
|
||||
let mut snapshot = configured_preset(index);
|
||||
assert!(preset_selected(&snapshot, index));
|
||||
let kind = if PRESETS[index].appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
snapshot
|
||||
.edit(&set("theme.surface.bar.text_primary", "#123456"))
|
||||
.edit(&set(&format!("theme.{kind}.surface.bar.text_primary"), "#123456"))
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
let mut snapshot = configured_preset(index);
|
||||
snapshot.edit(&set("theme.focus_ring.gradient.to", "#123456")).unwrap();
|
||||
snapshot
|
||||
.edit(&set(&format!("theme.{kind}.focus_ring.gradient.to"), "#123456"))
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
}
|
||||
assert!(preset_selected(&Snapshot::parse(String::new()).unwrap(), 0));
|
||||
@@ -250,8 +342,14 @@ mod tests {
|
||||
assert!(logo.contains(PRESETS[0].accent));
|
||||
|
||||
for (index, item) in PRESETS.iter().enumerate() {
|
||||
let snapshot = configured_preset(index);
|
||||
assert_eq!(native_theme(Some(&snapshot)).cosmic().is_dark, item.name != "Ayu Light");
|
||||
let _ = index;
|
||||
let mut resolved = ferese_config::theme::ResolvedTheme::default();
|
||||
resolved.appearance = item.appearance;
|
||||
resolved.tokens = ferese_config::theme::preset(item.id, item.appearance).unwrap();
|
||||
assert_eq!(
|
||||
Palette::from_resolved(&resolved).native_theme().cosmic().is_dark,
|
||||
item.appearance == ferese_config::theme::Appearance::Dark
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub(crate) struct ShellConfig {
|
||||
pub(crate) font_family: Option<String>,
|
||||
pub(crate) wallpaper: WallpaperConfig,
|
||||
pub(crate) theme: ShellTheme,
|
||||
pub(crate) theme_mode: ferese_config::theme::Mode,
|
||||
pub(crate) status: StatusConfig,
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ impl Default for StatusConfig {
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct ShellTheme {
|
||||
pub(crate) appearance: ferese_config::theme::Appearance,
|
||||
pub(crate) bar_background: [u8; 4],
|
||||
pub(crate) bar_text_primary: [u8; 4],
|
||||
pub(crate) bar_text_muted: [u8; 4],
|
||||
@@ -48,6 +50,7 @@ pub(crate) struct ShellTheme {
|
||||
pub(crate) text_primary: [u8; 4],
|
||||
pub(crate) text_muted: [u8; 4],
|
||||
pub(crate) accent: [u8; 4],
|
||||
pub(crate) on_accent: [u8; 4],
|
||||
pub(crate) border: [u8; 4],
|
||||
pub(crate) shadow: [u8; 4],
|
||||
pub(crate) bar_height: f32,
|
||||
@@ -66,6 +69,7 @@ pub(crate) struct ShellTheme {
|
||||
impl Default for ShellTheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
appearance: Default::default(),
|
||||
bar_background: [28, 32, 46, 255],
|
||||
bar_text_primary: [240, 243, 250, 255],
|
||||
bar_text_muted: [170, 180, 199, 255],
|
||||
@@ -74,6 +78,7 @@ impl Default for ShellTheme {
|
||||
text_primary: [244, 247, 251, 255],
|
||||
text_muted: [135, 147, 162, 255],
|
||||
accent: [61, 123, 230, 255],
|
||||
on_accent: [244, 247, 251, 255],
|
||||
border: [255, 255, 255, 24],
|
||||
shadow: [0, 0, 0, 85],
|
||||
bar_height: 28.0,
|
||||
@@ -96,12 +101,14 @@ impl ShellTheme {
|
||||
let color = |[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
|
||||
let surface = color(self.surface_base);
|
||||
ferese_theme::Palette {
|
||||
appearance: self.appearance,
|
||||
background: surface,
|
||||
sidebar: surface,
|
||||
card: surface,
|
||||
text: color(self.text_primary),
|
||||
muted: color(self.text_muted),
|
||||
accent: color(self.accent),
|
||||
on_accent: color(self.on_accent),
|
||||
radius: self.material_radius,
|
||||
error: cosmic::iced::Color::from_rgb8(235, 98, 98),
|
||||
}
|
||||
@@ -229,6 +236,8 @@ struct ThemeColorsConfig {
|
||||
text_muted: String,
|
||||
#[serde(default = "default_accent")]
|
||||
accent: String,
|
||||
#[serde(default = "default_text_primary")]
|
||||
on_accent: String,
|
||||
#[serde(default = "default_border")]
|
||||
border: String,
|
||||
#[serde(default = "default_shadow")]
|
||||
@@ -242,6 +251,7 @@ impl Default for ThemeColorsConfig {
|
||||
text_primary: default_text_primary(),
|
||||
text_muted: default_text_muted(),
|
||||
accent: default_accent(),
|
||||
on_accent: default_text_primary(),
|
||||
border: default_border(),
|
||||
shadow: default_shadow(),
|
||||
}
|
||||
@@ -327,11 +337,28 @@ pub(crate) fn load() -> ShellConfig {
|
||||
}
|
||||
|
||||
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
|
||||
match ferese_config::from_str::<FereseConfig>(source) {
|
||||
let document = ferese_config::Document::parse(source)?;
|
||||
let snapshot = ferese_theme::service::current();
|
||||
parse_document(
|
||||
&document.with_theme(&snapshot.presented),
|
||||
snapshot.presented.appearance,
|
||||
snapshot.mode,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_document(
|
||||
document: &ferese_config::Document,
|
||||
appearance: ferese_config::theme::Appearance,
|
||||
mode: ferese_config::theme::Mode,
|
||||
) -> Result<ShellConfig, ferese_config::Error> {
|
||||
match serde_json::from_value::<FereseConfig>(document.value().clone())
|
||||
.map_err(|error| ferese_config::Error::from(error.to_string()))
|
||||
{
|
||||
Ok(config) => {
|
||||
config.notifications.validate().map_err(ferese_config::Error::from)?;
|
||||
config.desktop_widgets.validate().map_err(ferese_config::Error::from)?;
|
||||
let mut theme = shell_theme(&config.theme);
|
||||
theme.appearance = appearance;
|
||||
theme.material_radius = nonnegative_or(
|
||||
config
|
||||
.theme
|
||||
@@ -351,6 +378,7 @@ pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::E
|
||||
font_family: config.theme.typography.font_family,
|
||||
wallpaper: config.theme.background,
|
||||
theme,
|
||||
theme_mode: mode,
|
||||
status: StatusConfig {
|
||||
low_battery_threshold: config.status.low_battery_threshold.min(100),
|
||||
..config.status
|
||||
@@ -361,6 +389,21 @@ pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::E
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellConfig {
|
||||
pub(crate) fn apply_theme(&mut self, theme: &ferese_config::theme::ResolvedTheme) {
|
||||
let config: ThemeConfig = serde_json::from_value(serde_json::to_value(&theme.tokens).unwrap()).unwrap();
|
||||
self.theme = shell_theme(&config);
|
||||
self.theme.appearance = theme.appearance;
|
||||
self.theme.material_radius = theme.tokens.geometry.shell_radius as f32;
|
||||
self.theme.bar_radius = self.theme.material_radius;
|
||||
self.font_family = Some(theme.tokens.typography.font_family.clone());
|
||||
self.wallpaper = config.background;
|
||||
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
|
||||
self.wallpaper.path = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
let defaults = ShellTheme::default();
|
||||
let opacity = if theme.material.style == "translucent" {
|
||||
@@ -378,6 +421,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
let alpha = (opacity * 255.0).round() as u8;
|
||||
|
||||
ShellTheme {
|
||||
appearance: defaults.appearance,
|
||||
material_radius: defaults.material_radius,
|
||||
bar_background: {
|
||||
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
|
||||
@@ -395,6 +439,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
||||
text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary),
|
||||
text_muted: parse_color(&theme.colors.text_muted).unwrap_or(defaults.text_muted),
|
||||
accent: parse_color(&theme.colors.accent).unwrap_or(defaults.accent),
|
||||
on_accent: parse_color(&theme.colors.on_accent).unwrap_or(defaults.on_accent),
|
||||
border: parse_color(&theme.colors.border).unwrap_or(defaults.border),
|
||||
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
|
||||
bar_height: positive_or(theme.geometry.top_bar_height, defaults.bar_height),
|
||||
@@ -527,7 +572,7 @@ pub(crate) fn config_path() -> Option<PathBuf> {
|
||||
mod tests {
|
||||
#[test]
|
||||
fn desktop_clock_parses_and_rejects_invalid_reload_values() {
|
||||
let clock = super::parse_source(
|
||||
let clock = parse_test_source(
|
||||
r#"desktop-widgets {
|
||||
clock {
|
||||
enabled #true
|
||||
@@ -544,16 +589,21 @@ mod tests {
|
||||
.clock;
|
||||
assert!(clock.enabled);
|
||||
assert_eq!(clock.anchor, ferese_core::desktop::Anchor::BottomRight);
|
||||
assert!(super::parse_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n").is_err());
|
||||
assert!(super::parse_source("desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n").is_err());
|
||||
assert!(parse_test_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n").is_err());
|
||||
assert!(parse_test_source("desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n").is_err());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
fn parse_test_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
|
||||
let document = ferese_config::Document::parse(source)?;
|
||||
parse_document(&document, Default::default(), Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keybinding_guide_is_enabled_until_explicitly_disabled() {
|
||||
assert!(parse_source("").unwrap().status.keybinding_guide);
|
||||
assert!(parse_test_source("").unwrap().status.keybinding_guide);
|
||||
assert!(
|
||||
!parse_source("status { keybinding-guide #false; }")
|
||||
!parse_test_source("status { keybinding-guide #false; }")
|
||||
.unwrap()
|
||||
.status
|
||||
.keybinding_guide
|
||||
@@ -590,14 +640,14 @@ theme {
|
||||
#[test]
|
||||
fn shell_radius_unifies_surfaces_and_preserves_legacy_fallbacks() {
|
||||
for radius in [0.0, 18.0] {
|
||||
let config = parse_source(&format!("appearance {{\n corner-radius 9\n}}\ntheme {{\n geometry {{\n shell-radius {radius}\n top-bar-radius 5\n window-radius 23\n }}\n}}")).unwrap();
|
||||
let config = parse_test_source(&format!("appearance {{\n corner-radius 9\n}}\ntheme {{\n geometry {{\n shell-radius {radius}\n top-bar-radius 5\n window-radius 23\n }}\n}}")).unwrap();
|
||||
assert_eq!(config.theme.material_radius, radius);
|
||||
assert_eq!(config.theme.bar_radius, radius);
|
||||
}
|
||||
let legacy = parse_source("theme {\n geometry {\n top-bar-radius 7\n }\n}").unwrap();
|
||||
let legacy = parse_test_source("theme {\n geometry {\n top-bar-radius 7\n }\n}").unwrap();
|
||||
assert_eq!(legacy.theme.material_radius, 7.0);
|
||||
assert_eq!(legacy.theme.bar_radius, 7.0);
|
||||
let legacy = parse_source("appearance {\n corner-radius 6\n}").unwrap();
|
||||
let legacy = parse_test_source("appearance {\n corner-radius 6\n}").unwrap();
|
||||
assert_eq!(legacy.theme.material_radius, 6.0);
|
||||
assert_eq!(legacy.theme.bar_radius, 6.0);
|
||||
}
|
||||
|
||||
@@ -166,6 +166,7 @@ struct FereseShell {
|
||||
recorder: recording::Recorder,
|
||||
calendar_offset: i32,
|
||||
status_error: Option<String>,
|
||||
theme_error: Option<String>,
|
||||
menu: Option<status_ui::OpenMenu>,
|
||||
system_modal: Option<system_modal::SystemModal>,
|
||||
guide_shown: bool,
|
||||
@@ -212,6 +213,9 @@ struct OutputSurfaces {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
ThemeMode(ferese_config::theme::Mode),
|
||||
ThemeModeSet(Result<(), String>),
|
||||
BeginNoteEdit(String),
|
||||
NoteAction(String, cosmic::widget::text_editor::Action),
|
||||
SaveNote(String, u64),
|
||||
@@ -282,6 +286,7 @@ impl cosmic::Application for FereseShell {
|
||||
recorder: recording::Recorder::default(),
|
||||
calendar_offset: 0,
|
||||
status_error: None,
|
||||
theme_error: None,
|
||||
menu: None,
|
||||
system_modal: None,
|
||||
guide_shown: false,
|
||||
@@ -322,6 +327,7 @@ impl cosmic::Application for FereseShell {
|
||||
|
||||
fn subscription(&self) -> Subscription<Self::Message> {
|
||||
Subscription::batch([
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged),
|
||||
if self
|
||||
.system_modal
|
||||
.as_ref()
|
||||
@@ -409,6 +415,38 @@ impl cosmic::Application for FereseShell {
|
||||
self.update_power_materials();
|
||||
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
let mut config = self.config.clone();
|
||||
config.apply_theme(&snapshot.presented);
|
||||
config.theme_mode = snapshot.mode;
|
||||
if snapshot.error != self.theme_error {
|
||||
if let Some(error) = &snapshot.error {
|
||||
self.notifications.service_error("Theme could not be loaded", error);
|
||||
}
|
||||
self.theme_error = snapshot.error;
|
||||
}
|
||||
return self.apply_config(config);
|
||||
}
|
||||
Message::ThemeMode(mode) => {
|
||||
return cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?;
|
||||
connection
|
||||
.call("theme-set-mode", serde_json::json!({"mode": mode}))
|
||||
.map(|_| ())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
cosmic::Action::App(Message::ThemeModeSet(result))
|
||||
});
|
||||
}
|
||||
Message::ThemeModeSet(result) => {
|
||||
if let Err(error) = result {
|
||||
self.notifications
|
||||
.service_error("Appearance could not be changed", &error);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
Message::BeginNoteEdit(id) => {
|
||||
let mut tasks = vec![self.finish_note_edit()];
|
||||
|
||||
@@ -814,7 +852,7 @@ impl cosmic::Application for FereseShell {
|
||||
|
||||
impl FereseShell {
|
||||
fn reload_config(&mut self, source: String) -> Task<Message> {
|
||||
let mut config = match config::parse_source(&source) {
|
||||
let config = match config::parse_source(&source) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: reload rejected; keeping current config: {error}");
|
||||
@@ -822,6 +860,10 @@ impl FereseShell {
|
||||
}
|
||||
};
|
||||
|
||||
self.apply_config(config)
|
||||
}
|
||||
|
||||
fn apply_config(&mut self, mut config: config::ShellConfig) -> Task<Message> {
|
||||
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
|
||||
config.wallpaper.path = None;
|
||||
}
|
||||
@@ -2017,9 +2059,7 @@ impl FereseShell {
|
||||
let clock = container(motion::button(
|
||||
button::custom(
|
||||
row![
|
||||
text(date)
|
||||
.size(12)
|
||||
.class(theme::Text::Color(color(shell_theme.text_muted))),
|
||||
text(date).size(12).class(theme::Text::Color(foreground)),
|
||||
text(time).size(bar.text_size).class(theme::Text::Color(foreground)),
|
||||
]
|
||||
.spacing(8)
|
||||
|
||||
@@ -34,6 +34,7 @@ pub enum Event {
|
||||
}
|
||||
|
||||
enum Command {
|
||||
Local(String, String),
|
||||
Close(u32, u64, u32),
|
||||
Action(u32, u64, String),
|
||||
}
|
||||
@@ -205,7 +206,7 @@ impl Service {
|
||||
let server = Server {
|
||||
events: sender.clone(),
|
||||
registry: registry.clone(),
|
||||
timeout: worker_timeout,
|
||||
timeout: worker_timeout.clone(),
|
||||
};
|
||||
let connection = match address.as_deref() {
|
||||
Some(address) => zbus::blocking::connection::Builder::address(address),
|
||||
@@ -224,6 +225,15 @@ impl Service {
|
||||
let _ = sender.try_send(Event::Ready);
|
||||
while let Ok(command) = receiver.recv() {
|
||||
let result = match command {
|
||||
Command::Local(title, body) => {
|
||||
let server = Server {
|
||||
events: sender.clone(),
|
||||
registry: registry.clone(),
|
||||
timeout: worker_timeout.clone(),
|
||||
};
|
||||
let _ = server.notify("Ferese", 0, "", &title, &body, vec![], HashMap::new(), -1);
|
||||
continue;
|
||||
}
|
||||
Command::Close(id, revision, reason) => {
|
||||
if !registry.lock().unwrap().close(id, revision, reason) {
|
||||
continue;
|
||||
@@ -287,6 +297,13 @@ impl Center {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn service_error(&self, title: &str, body: &str) {
|
||||
let _ = self
|
||||
.service
|
||||
.commands
|
||||
.try_send(Command::Local(title.into(), body.into()));
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, config: ferese_core::notifications::NotificationConfig) {
|
||||
if self.config.do_not_disturb != config.do_not_disturb {
|
||||
self.dnd = config.do_not_disturb;
|
||||
|
||||
@@ -440,6 +440,35 @@ impl FereseShell {
|
||||
if kind != Menu::Calendar {
|
||||
rows = rows.push(heading);
|
||||
}
|
||||
if kind == Menu::System {
|
||||
use ferese_config::theme::{Appearance, Mode};
|
||||
let mut modes = row([]).spacing(6);
|
||||
for (mode, label) in [(Mode::Light, "☀ Light"), (Mode::Dark, "☾ Dark"), (Mode::Auto, "◐ Auto")] {
|
||||
modes = modes.push(
|
||||
button::custom(text(label).font(shell_font()).size(13))
|
||||
.width(Length::Fill)
|
||||
.padding(9)
|
||||
.class(ferese_theme::controls::button_style(
|
||||
theme.palette(),
|
||||
self.config.theme_mode == mode,
|
||||
))
|
||||
.on_press(cosmic::Action::App(Message::ThemeMode(mode))),
|
||||
);
|
||||
}
|
||||
rows = rows.push(modes);
|
||||
if self.config.theme_mode == Mode::Auto {
|
||||
rows = rows.push(
|
||||
text(if theme.appearance == Appearance::Light {
|
||||
"Auto · currently light"
|
||||
} else {
|
||||
"Auto · currently dark"
|
||||
})
|
||||
.font(shell_font())
|
||||
.size(12)
|
||||
.class(cosmic::theme::Text::Color(muted)),
|
||||
);
|
||||
}
|
||||
}
|
||||
if kind == Menu::Calendar {
|
||||
rows = rows.push(calendar_grid(self.calendar_offset, theme, p));
|
||||
} else {
|
||||
@@ -1119,7 +1148,7 @@ fn slider_row(
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(24)
|
||||
.class(ferese_theme::menus::slider(foreground, opacity, motion::radius(5.)));
|
||||
.class(ferese_theme::menus::slider(foreground, opacity));
|
||||
row![
|
||||
accented_icon(source, 18, foreground, icon_accent),
|
||||
control,
|
||||
|
||||
Reference in new issue
Block a user