From b394c45fef8bb3e2c2556acda4d022b8ce2e0a06 Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Thu, 10 Oct 2024 17:06:26 +0200 Subject: [PATCH] improv(appearance): move fonts into context view --- .../pages/desktop/appearance/font_config.rs | 385 ++++---------- .../pages/desktop/appearance/icon_themes.rs | 329 ++++++++++++ .../src/pages/desktop/appearance/mod.rs | 500 +++++------------- cosmic-settings/src/widget/mod.rs | 23 + i18n/en/cosmic_settings.ftl | 33 +- 5 files changed, 609 insertions(+), 661 deletions(-) create mode 100644 cosmic-settings/src/pages/desktop/appearance/icon_themes.rs diff --git a/cosmic-settings/src/pages/desktop/appearance/font_config.rs b/cosmic-settings/src/pages/desktop/appearance/font_config.rs index dc1be53..14c0a4a 100644 --- a/cosmic-settings/src/pages/desktop/appearance/font_config.rs +++ b/cosmic-settings/src/pages/desktop/appearance/font_config.rs @@ -1,155 +1,129 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use std::sync::Arc; + use cosmic::{ config::{CosmicTk, FontConfig}, + iced::Length, + iced_core::text::Wrap, widget::{self, settings}, Apply, Command, Element, }; use cosmic_config::ConfigSet; -use cosmic_settings_page::Section; use ustr::Ustr; const INTERFACE_FONT: &str = "interface_font"; const MONOSPACE_FONT: &str = "monospace_font"; -pub fn section() -> Section { - crate::slab!(descriptions { - font_family_txt = fl!("font-family"); - font_weight_txt = fl!("font-weight"); - font_stretch_txt = fl!("font-stretch"); - font_style_txt = fl!("font-style"); - interface_font_txt = fl!("interface-font"); - monospace_font_txt = fl!("monospace-font"); +pub fn load_font_families() -> (Vec>, Vec>) { + let mut font_system = cosmic::iced::advanced::graphics::text::font_system() + .write() + .unwrap(); + + let (mut interface, mut mono): (Vec>, Vec>) = + font_system.raw().db().faces().fold( + (Vec::new(), Vec::new()), + |(mut interface, mut mono), face| { + if face.stretch != fontdb::Stretch::Normal + || face.weight != fontdb::Weight::NORMAL + || face.style != fontdb::Style::Normal + { + return (interface, mono); + } + + let font_name = match face.families.first() { + Some(name) => &name.0, + None => return (interface, mono), + }; + + if face.monospaced { + if mono + .last() + .map_or(true, |name| &**name != font_name.as_str()) + { + mono.push(Arc::from(font_name.as_str())); + } + } else if interface + .last() + .map_or(true, |name| &**name != font_name.as_str()) + { + interface.push(Arc::from(font_name.as_str())); + } + + (interface, mono) + }, + ); + + interface.sort_unstable(); + interface.dedup(); + mono.sort_unstable(); + mono.dedup(); + + (interface, mono) +} + +pub fn selection_context<'a>( + families: &'a [Arc], + search: &'a str, + system: bool, +) -> Element<'a, super::Message> { + let search_input = widget::search_input(fl!("type-to-search"), search) + .on_input(super::Message::FontSearch) + .on_clear(super::Message::FontSearch(String::new())); + + let list = families.iter().fold(widget::list_column(), |list, family| { + list.add( + settings::item_row(vec![ + widget::text::body(&**family).wrap(Wrap::Word).into(), + widget::horizontal_space(Length::Fill).into(), + ]) + .apply(widget::container) + .style(cosmic::theme::Container::List) + .apply(widget::button::custom) + .style(cosmic::theme::Button::Transparent) + .on_press(super::Message::FontSelect(system, family.clone())), + ) }); - Section::default() - .title(fl!("font-config")) - .descriptions(descriptions) - .view::(move |_binder, page, section| { - let descriptions = §ion.descriptions; + widget::column() + .padding([2, 0]) + .spacing(32) + .push(search_input) + .push(list) + .into() +} - let interface_font_family = settings::item::builder(&descriptions[font_family_txt]) - .control(widget::dropdown( - &page.font_config.interface_font_families, - page.font_config.interface_font_family, - |id| super::Message::FontConfig(Message::InterfaceFontFamily(id)), - )); - - let interface_font_weight = settings::item::builder(&descriptions[font_weight_txt]) - .control(widget::dropdown( - &page.font_config.font_weights, - page.font_config.interface_font_weight, - |id| super::Message::FontConfig(Message::InterfaceFontWeight(id)), - )); - - let interface_font_stretch = settings::item::builder(&descriptions[font_stretch_txt]) - .control(widget::dropdown( - &page.font_config.font_stretches, - page.font_config.interface_font_stretch, - |id| super::Message::FontConfig(Message::InterfaceFontStretch(id)), - )); - - let interface_font_style = settings::item::builder(&descriptions[font_style_txt]) - .control(widget::dropdown( - &page.font_config.font_styles, - page.font_config.interface_font_style, - |id| super::Message::FontConfig(Message::InterfaceFontStyle(id)), - )); - - let monospace_font_family = settings::item::builder(&descriptions[font_family_txt]) - .control(widget::dropdown( - &page.font_config.monospace_font_families, - page.font_config.monospace_font_family, - |id| super::Message::FontConfig(Message::MonospaceFontFamily(id)), - )); - - let monospace_font_weight = settings::item::builder(&descriptions[font_weight_txt]) - .control(widget::dropdown( - &page.font_config.font_weights, - page.font_config.monospace_font_weight, - |id| super::Message::FontConfig(Message::MonospaceFontWeight(id)), - )); - - let interface_font = settings::section() - .title(&descriptions[interface_font_txt]) - .add(interface_font_family) - .add(interface_font_weight) - .add(interface_font_stretch) - .add(interface_font_style); - - let monospace_font = settings::section() - .title(&descriptions[monospace_font_txt]) - .add(monospace_font_family) - .add(monospace_font_weight); - - widget::column::with_capacity(2) - .push(interface_font) - .push(monospace_font) - .spacing(8) - .apply(Element::from) - .map(crate::pages::Message::Appearance) - }) +/// Set the preferred icon theme for GNOME/GTK applications. +pub async fn set_gnome_font_name(font_name: &str) { + let _res = tokio::process::Command::new("gsettings") + .args(["set", "org.gnome.desktop.interface", "font-name", font_name]) + .status() + .await; } #[derive(Debug, Clone)] pub enum Message { InterfaceFontFamily(usize), - InterfaceFontStretch(usize), - InterfaceFontStyle(usize), - InterfaceFontWeight(usize), - LoadedFonts(Vec, Vec), + LoadedFonts(Vec>, Vec>), MonospaceFontFamily(usize), - MonospaceFontWeight(usize), } #[derive(Debug, Default)] pub struct Model { - pub font_weights: Vec, - pub font_stretches: Vec, - pub font_styles: Vec, - pub interface_font_families: Vec, + pub interface_font_families: Vec>, pub interface_font_family: Option, - pub interface_font_weight: Option, - pub interface_font_stretch: Option, - pub interface_font_style: Option, - pub monospace_font_families: Vec, + pub monospace_font_families: Vec>, pub monospace_font_family: Option, - pub monospace_font_weight: Option, } impl Model { - pub fn new() -> Model { + pub const fn new() -> Model { Model { - font_weights: vec![ - fl!("font-weight", "thin"), - fl!("font-weight", "extra-light"), - fl!("font-weight", "light"), - fl!("font-weight", "normal"), - fl!("font-weight", "medium"), - fl!("font-weight", "semibold"), - fl!("font-weight", "bold"), - fl!("font-weight", "extra-bold"), - fl!("font-weight", "black"), - ], - - font_stretches: vec![ - fl!("font-stretch", "condensed"), - fl!("font-stretch", "normal"), - fl!("font-stretch", "expanded"), - ], - - font_styles: vec![ - fl!("font-style", "normal"), - fl!("font-style", "italic"), - fl!("font-style", "oblique"), - ], - interface_font_families: Vec::new(), interface_font_family: None, - interface_font_stretch: None, - interface_font_style: None, - interface_font_weight: None, monospace_font_families: Vec::new(), monospace_font_family: None, - monospace_font_weight: None, } } @@ -157,54 +131,26 @@ impl Model { match message { Message::InterfaceFontFamily(id) => { if let Some(family) = self.interface_font_families.get(id) { + let family = Ustr::from(family); + update_config( INTERFACE_FONT, FontConfig { - family: Ustr::from(family), - ..cosmic::config::interface_font() + family, + weight: cosmic::iced::font::Weight::Normal, + style: cosmic::iced::font::Style::Normal, + stretch: cosmic::iced::font::Stretch::Normal, }, ); self.interface_font_family = Some(id); + + tokio::spawn(async move { + set_gnome_font_name(family.as_str()).await; + }); } } - Message::InterfaceFontStretch(id) => { - update_config( - INTERFACE_FONT, - FontConfig { - stretch: font_stretch_by_id(id), - ..cosmic::config::interface_font() - }, - ); - - self.interface_font_stretch = Some(id); - } - - Message::InterfaceFontStyle(id) => { - update_config( - INTERFACE_FONT, - FontConfig { - style: font_style_by_id(id), - ..cosmic::config::interface_font() - }, - ); - - self.interface_font_style = Some(id); - } - - Message::InterfaceFontWeight(id) => { - update_config( - INTERFACE_FONT, - FontConfig { - weight: font_weight_by_id(id), - ..cosmic::config::interface_font() - }, - ); - - self.interface_font_weight = Some(id); - } - Message::LoadedFonts(interface, mono) => { self.interface_font_families = interface; self.monospace_font_families = mono; @@ -212,13 +158,9 @@ impl Model { let interface_font = cosmic::config::interface_font(); let monospace_font = cosmic::config::monospace_font(); - self.interface_font_stretch = font_stretch_to_pos(interface_font.stretch); - self.interface_font_style = font_style_to_pos(interface_font.style); - self.interface_font_weight = font_weight_to_pos(interface_font.weight); self.interface_font_family = font_family_to_pos(&self.interface_font_families, &interface_font.family); - self.monospace_font_weight = font_weight_to_pos(monospace_font.weight); self.monospace_font_family = font_family_to_pos(&self.monospace_font_families, &monospace_font.family); } @@ -229,96 +171,23 @@ impl Model { MONOSPACE_FONT, FontConfig { family: Ustr::from(family), - ..cosmic::config::monospace_font() + weight: cosmic::iced::font::Weight::Normal, + style: cosmic::iced::font::Style::Normal, + stretch: cosmic::iced::font::Stretch::Normal, }, ); self.monospace_font_family = Some(id); } } - - Message::MonospaceFontWeight(id) => { - update_config( - MONOSPACE_FONT, - FontConfig { - weight: font_weight_by_id(id), - ..cosmic::config::monospace_font() - }, - ); - - self.monospace_font_weight = Some(id); - } } Command::none() } } -fn font_family_to_pos(families: &[String], family: &str) -> Option { - families.iter().position(|f| f.as_str() == family) -} - -fn font_weight_by_id(id: usize) -> cosmic::iced::font::Weight { - match id { - 0 => cosmic::iced::font::Weight::Thin, - 1 => cosmic::iced::font::Weight::ExtraLight, - 2 => cosmic::iced::font::Weight::Light, - 3 => cosmic::iced::font::Weight::Normal, - 4 => cosmic::iced::font::Weight::Medium, - 5 => cosmic::iced::font::Weight::Semibold, - 6 => cosmic::iced::font::Weight::Bold, - 7 => cosmic::iced::font::Weight::ExtraBold, - 8 => cosmic::iced::font::Weight::Black, - _ => cosmic::iced::font::Weight::Normal, - } -} -fn font_weight_to_pos(weight: cosmic::iced::font::Weight) -> Option { - match weight { - cosmic::iced::font::Weight::Thin => Some(0), - cosmic::iced::font::Weight::Light => Some(1), - cosmic::iced::font::Weight::ExtraLight => Some(2), - cosmic::iced::font::Weight::Normal => Some(3), - cosmic::iced::font::Weight::Medium => Some(4), - cosmic::iced::font::Weight::Semibold => Some(5), - cosmic::iced::font::Weight::Bold => Some(6), - cosmic::iced::font::Weight::ExtraBold => Some(7), - cosmic::iced::font::Weight::Black => Some(8), - } -} - -fn font_stretch_by_id(id: usize) -> cosmic::iced::font::Stretch { - match id { - 0 => cosmic::iced::font::Stretch::Condensed, - 1 => cosmic::iced::font::Stretch::Normal, - 2 => cosmic::iced::font::Stretch::Expanded, - _ => cosmic::iced::font::Stretch::Normal, - } -} - -fn font_stretch_to_pos(stretch: cosmic::iced::font::Stretch) -> Option { - match stretch { - cosmic::iced::font::Stretch::Condensed => Some(0), - cosmic::iced::font::Stretch::Normal => Some(1), - cosmic::iced::font::Stretch::Expanded => Some(2), - _ => None, - } -} - -fn font_style_by_id(id: usize) -> cosmic::iced::font::Style { - match id { - 0 => cosmic::iced::font::Style::Normal, - 1 => cosmic::iced::font::Style::Italic, - 2 => cosmic::iced::font::Style::Oblique, - _ => cosmic::iced::font::Style::Normal, - } -} - -fn font_style_to_pos(style: cosmic::iced::font::Style) -> Option { - match style { - cosmic::iced::font::Style::Normal => Some(0), - cosmic::iced::font::Style::Italic => Some(1), - cosmic::iced::font::Style::Oblique => Some(2), - } +fn font_family_to_pos(families: &[Arc], family: &str) -> Option { + families.iter().position(|f| &**f == family) } fn update_config(variant: &str, font: FontConfig) { @@ -326,43 +195,3 @@ fn update_config(variant: &str, font: FontConfig) { _ = config.set(variant, font); } } - -pub fn load_font_families() -> (Vec, Vec) { - let mut font_system = cosmic::iced::advanced::graphics::text::font_system() - .write() - .unwrap(); - - let (mut interface, mut mono) = font_system.raw().db().faces().fold( - (Vec::new(), Vec::new()), - |(mut interface, mut mono), face| { - if face.stretch != fontdb::Stretch::Normal - || face.weight != fontdb::Weight::NORMAL - || face.style != fontdb::Style::Normal - { - return (interface, mono); - } - - let font_name = match face.families.first() { - Some(name) => &name.0, - None => return (interface, mono), - }; - - if face.monospaced { - if mono.last().map_or(true, |name| name != font_name) { - mono.push(font_name.clone()); - } - } else if interface.last().map_or(true, |name| name != font_name) { - interface.push(font_name.clone()); - } - - (interface, mono) - }, - ); - - interface.sort_unstable(); - interface.dedup(); - mono.sort_unstable(); - mono.dedup(); - - (interface, mono) -} diff --git a/cosmic-settings/src/pages/desktop/appearance/icon_themes.rs b/cosmic-settings/src/pages/desktop/appearance/icon_themes.rs new file mode 100644 index 0000000..e7ba117 --- /dev/null +++ b/cosmic-settings/src/pages/desktop/appearance/icon_themes.rs @@ -0,0 +1,329 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use std::{collections::BTreeMap, path::PathBuf}; + +use super::Message; +use cosmic::{ + iced::{Background, Length}, + prelude::CollectionWidget, + widget::{button, icon, text}, + Element, +}; +use tokio::io::AsyncBufReadExt; + +const ICON_PREV_N: usize = 6; +const ICON_PREV_ROW: usize = 3; +const ICON_TRY_SIZES: [u16; 3] = [32, 48, 64]; +const ICON_THUMB_SIZE: u16 = 32; +const ICON_NAME_TRUNC: usize = 20; + +pub type IconThemes = Vec; +pub type IconHandles = Vec<[icon::Handle; ICON_PREV_N]>; + +/// Button with a preview of the icon theme. +pub fn button( + name: &str, + handles: &[icon::Handle], + id: usize, + selected: bool, +) -> Element<'static, Message> { + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); + let background = Background::Color(theme.palette.neutral_4.into()); + + cosmic::widget::column() + .push( + cosmic::widget::button::custom_image_button( + cosmic::widget::column::with_children(vec![ + cosmic::widget::row() + .extend( + handles + .iter() + .take(ICON_PREV_ROW) + .cloned() + // TODO: Maybe allow choosable sizes/zooming + .map(|handle| handle.icon().size(ICON_THUMB_SIZE)), + ) + .spacing(theme.space_xxxs()) + .into(), + cosmic::widget::row() + .extend( + handles + .iter() + .skip(ICON_PREV_ROW) + .cloned() + // TODO: Maybe allow choosable sizes/zooming + .map(|handle| handle.icon().size(ICON_THUMB_SIZE)), + ) + .spacing(theme.space_xxxs()) + .into(), + ]) + .spacing(theme.space_xxxs()), + None, + ) + .on_press(Message::IconTheme(id)) + .selected(selected) + .padding([theme.space_xs(), theme.space_xs() + 1]) + // Image button's style mostly works, but it needs a background to fit the design + .style(button::Style::Custom { + active: Box::new(move |focused, theme| { + let mut appearance = ::active( + theme, + focused, + selected, + &cosmic::theme::Button::Image, + ); + appearance.background = Some(background); + appearance + }), + disabled: Box::new(move |theme| { + let mut appearance = ::disabled( + theme, + &cosmic::theme::Button::Image, + ); + appearance.background = Some(background); + appearance + }), + hovered: Box::new(move |focused, theme| { + let mut appearance = ::hovered( + theme, + focused, + selected, + &cosmic::theme::Button::Image, + ); + appearance.background = Some(background); + appearance + }), + pressed: Box::new(move |focused, theme| { + let mut appearance = ::pressed( + theme, + focused, + selected, + &cosmic::theme::Button::Image, + ); + appearance.background = Some(background); + appearance + }), + }), + ) + .push( + text::body(if name.len() > ICON_NAME_TRUNC { + format!("{name:.ICON_NAME_TRUNC$}...") + } else { + name.into() + }) + .width(Length::Fixed((ICON_THUMB_SIZE * 3) as _)), + ) + .spacing(theme.space_xxs()) + .into() +} + +/// Find all icon themes available on the system. +pub async fn fetch() -> Message { + let mut icon_themes = BTreeMap::new(); + let mut theme_paths: BTreeMap = BTreeMap::new(); + + let mut buffer = String::new(); + + let xdg_data_home = std::env::var("XDG_DATA_HOME") + .ok() + .and_then(|value| { + if value.is_empty() { + None + } else { + Some(PathBuf::from(value)) + } + }) + .or_else(dirs::home_dir) + .map(|dir| dir.join(".local/share/icons")); + + let xdg_data_dirs = std::env::var("XDG_DATA_DIRS").ok(); + + let xdg_data_dirs = xdg_data_dirs + .as_deref() + // Default from the XDG Base Directory Specification + .or(Some("/usr/local/share/:/usr/share/")) + .into_iter() + .flat_map(|arg| std::env::split_paths(arg).map(|dir| dir.join("icons"))); + + for icon_dir in xdg_data_dirs.chain(xdg_data_home) { + let Ok(read_dir) = std::fs::read_dir(&icon_dir) else { + continue; + }; + + 'icon_dir: for entry in read_dir.filter_map(Result::ok) { + let Ok(path) = entry.path().canonicalize() else { + continue; + }; + + let Some(id) = entry.file_name().to_str().map(String::from) else { + continue; + }; + + let manifest = path.join("index.theme"); + + if !manifest.exists() { + continue; + } + + let Ok(file) = tokio::fs::File::open(&manifest).await else { + continue; + }; + + buffer.clear(); + let mut name = None; + let mut valid_dirs = Vec::new(); + + let mut line_reader = tokio::io::BufReader::new(file); + while let Ok(read) = line_reader.read_line(&mut buffer).await { + if read == 0 { + break; + } + + if let Some(is_hidden) = buffer.strip_prefix("Hidden=") { + if is_hidden.trim() == "true" { + continue 'icon_dir; + } + } else if name.is_none() { + if let Some(value) = buffer.strip_prefix("Name=") { + name = Some(value.trim().to_owned()); + } + } + + if valid_dirs.is_empty() { + if let Some(value) = buffer.strip_prefix("Inherits=") { + valid_dirs.extend(value.trim().split(',').map(|fallback| { + if let Some(path) = theme_paths.get(fallback) { + path.iter() + .last() + .and_then(|os| os.to_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| fallback.to_owned()) + } else { + fallback.to_owned() + } + })); + } + } + + buffer.clear(); + } + + if let Some(name) = name { + // Name of the directory theme was found in (e.g. Pop for Pop) + valid_dirs.push( + path.iter() + .last() + .and_then(|os| os.to_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| name.clone()), + ); + theme_paths.entry(name.clone()).or_insert(path); + + let theme = id.clone(); + // `icon::from_name` may perform blocking I/O + if let Ok(handles) = + tokio::task::spawn_blocking(|| preview_handles(theme, valid_dirs)).await + { + icon_themes.insert(IconTheme { id, name }, handles); + } + } + } + } + + Message::Entered(icon_themes.into_iter().unzip()) +} + +/// Set the preferred icon theme for GNOME/GTK applications. +pub async fn set_gnome_icon_theme(theme: String) { + let _res = tokio::process::Command::new("gsettings") + .args([ + "set", + "org.gnome.desktop.interface", + "icon-theme", + theme.as_str(), + ]) + .status() + .await; +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct IconTheme { + // COSMIC uses the file name of the folder containing the theme + pub id: String, + // GTK uses the name of the theme as specified in its index file + pub name: String, +} + +/// Generate [icon::Handle]s to use for icon theme previews. +fn preview_handles(theme: String, inherits: Vec) -> [icon::Handle; ICON_PREV_N] { + // Cache current default and set icon theme as a temporary default + let default = cosmic::icon_theme::default(); + cosmic::icon_theme::set_default(theme); + + // Evaluate handles with the temporary theme + let handles = [ + icon_handle("folder", "folder-symbolic", &inherits), + icon_handle("user-home", "user-home-symbolic", &inherits), + icon_handle("text-x-generic", "text-x-generic-symbolic", &inherits), + icon_handle("image-x-generic", "images-x-generic-symbolic", &inherits), + icon_handle("audio-x-generic", "audio-x-generic-symbolic", &inherits), + icon_handle("video-x-generic", "video-x-generic-symbolic", &inherits), + ]; + + // Reset default icon theme. + cosmic::icon_theme::set_default(default); + handles +} + +/// Evaluate an icon handle for a specific theme. +/// +/// `alternate` is a fallback icon name such as a symbolic variant. +/// +/// `valid_dirs` should be a slice of directories from which we consider an icon to be valid. Valid +/// directories would usually be inherited themes as well as the actual theme's location. +fn icon_handle(icon_name: &str, alternate: &str, valid_dirs: &[String]) -> icon::Handle { + ICON_TRY_SIZES + .iter() + .zip(std::iter::repeat(icon_name).take(ICON_TRY_SIZES.len())) + // Try fallback icon name after the default + .chain( + ICON_TRY_SIZES + .iter() + .zip(std::iter::repeat(alternate)) + .take(ICON_TRY_SIZES.len()), + ) + .find_map(|(&size, name)| { + icon::from_name(name) + // Set the size on the handle to evaluate the correct icon + .size(size) + // Get the path to the icon for the currently set theme. + // Without the exact path, the handles will all resolve to icons from the same theme in + // [`icon_theme_button`] rather than the icons for each different theme + .path() + // `libcosmic` should always return a path if the default theme is installed + // The returned path has to be verified as an icon from the set theme or an + // inherited theme + .and_then(|path| { + let mut theme_dir = &*path; + while let Some(parent) = theme_dir.parent() { + if parent.ends_with("icons") { + break; + } + theme_dir = parent; + } + + if let Some(dir_name) = + theme_dir.iter().last().and_then(std::ffi::OsStr::to_str) + { + valid_dirs + .iter() + .any(|valid| dir_name == valid) + .then(|| icon::from_path(path)) + } else { + None + } + }) + }) + // Fallback icon handle + .unwrap_or_else(|| icon::from_name(icon_name).size(ICON_THUMB_SIZE).handle()) +} diff --git a/cosmic-settings/src/pages/desktop/appearance/mod.rs b/cosmic-settings/src/pages/desktop/appearance/mod.rs index 6866b6d..149ace6 100644 --- a/cosmic-settings/src/pages/desktop/appearance/mod.rs +++ b/cosmic-settings/src/pages/desktop/appearance/mod.rs @@ -2,10 +2,9 @@ // SPDX-License-Identifier: GPL-3.0-only pub mod font_config; +pub mod icon_themes; use std::borrow::Cow; -use std::collections::BTreeMap; -use std::path::PathBuf; use std::sync::Arc; use ashpd::desktop::file_chooser::{FileFilter, SelectedFiles}; @@ -16,10 +15,9 @@ use cosmic::cosmic_theme::{ CornerRadii, Density, Spacing, Theme, ThemeBuilder, ThemeMode, DARK_THEME_BUILDER_ID, LIGHT_THEME_BUILDER_ID, }; -use cosmic::iced_core::{alignment, Background, Color, Length}; +use cosmic::iced_core::{alignment, Color, Length}; use cosmic::iced_widget::scrollable; -use cosmic::prelude::CollectionWidget; -use cosmic::widget::icon::{self, from_name, icon}; +use cosmic::widget::icon::{from_name, icon}; use cosmic::widget::{ button, color_picker::ColorPickerUpdate, container, flex_row, horizontal_space, radio, row, settings, spin_button, text, ColorPickerModel, @@ -30,26 +28,17 @@ use cosmic_panel_config::CosmicPanelConfig; use cosmic_settings_page::Section; use cosmic_settings_page::{self as page, section}; use cosmic_settings_wallpaper as wallpaper; +use icon_themes::{IconHandles, IconThemes}; use ron::ser::PrettyConfig; use serde::Serialize; use slab::Slab; use slotmap::SlotMap; -use tokio::io::AsyncBufReadExt; use crate::app; use crate::widget::color_picker_context_view; use super::wallpaper::widgets::color_image; -const ICON_PREV_N: usize = 6; -const ICON_PREV_ROW: usize = 3; -const ICON_TRY_SIZES: [u16; 3] = [32, 48, 64]; -const ICON_THUMB_SIZE: u16 = 32; -const ICON_NAME_TRUNC: usize = 20; - -pub type IconThemes = Vec; -pub type IconHandles = Vec<[icon::Handle; ICON_PREV_N]>; - crate::cache_dynamic_lazy! { static HEX: String = fl!("hex"); static RGB: String = fl!("rgb"); @@ -64,16 +53,10 @@ enum ContextView { ContainerBackground, ControlComponent, CustomAccent, - Experimental, + IconsAndToolkit, InterfaceText, -} - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub struct IconTheme { - // COSMIC uses the file name of the folder containing the theme - id: String, - // GTK uses the name of the theme as specified in its index file - name: String, + MonospaceFont, + SystemFont, } pub struct Page { @@ -88,12 +71,14 @@ pub struct Page { control_component: ColorPickerModel, roundness: Roundness, + font_config: font_config::Model, + font_filter: Vec>, + font_search: String, + icon_theme_active: Option, icon_themes: IconThemes, icon_handles: IconHandles, - font_config: font_config::Model, - theme: Theme, theme_mode: ThemeMode, theme_mode_config: Option, @@ -210,6 +195,8 @@ impl ), no_custom_window_hint: theme_builder.window_hint.is_none(), font_config: font_config::Model::new(), + font_filter: Vec::new(), + font_search: String::new(), icon_theme_active: None, icon_themes: Vec::new(), icon_handles: Vec::new(), @@ -280,12 +267,16 @@ pub enum Message { CustomAccent(ColorPickerUpdate), DarkMode(bool), Density(Density), + DisplayMonoFont, + DisplaySystemFont, Entered((IconThemes, IconHandles)), - ExperimentalContextDrawer, + IconsAndToolkit, ExportError, ExportFile(Arc), ExportSuccess, FontConfig(font_config::Message), + FontSearch(String), + FontSelect(bool, Arc), GapSize(spin_button::Message), IconTheme(usize), ImportError, @@ -367,7 +358,7 @@ impl From for Roundness { } impl Page { - fn experimental_context_view(&self) -> Element<'_, crate::pages::Message> { + fn icons_and_toolkit(&self) -> Element<'_, crate::pages::Message> { let active = self.icon_theme_active; let theme = cosmic::theme::active(); let theme = theme.cosmic(); @@ -391,7 +382,7 @@ impl Page { .enumerate() .map(|(i, (theme, handles))| { let selected = active.map(|j| i == j).unwrap_or_default(); - icon_theme_button(&theme.name, handles, i, selected) + icon_themes::button(&theme.name, handles, i, selected) }) .collect(), ) @@ -415,6 +406,77 @@ impl Page { let mut needs_sync = false; match message { + Message::DisplayMonoFont => { + self.context_view = Some(ContextView::MonospaceFont); + self.font_search.clear(); + + return cosmic::command::message(crate::app::Message::OpenContextDrawer( + fl!("monospace-font").into(), + )); + } + + Message::DisplaySystemFont => { + self.context_view = Some(ContextView::SystemFont); + self.font_search.clear(); + + return cosmic::command::message(crate::app::Message::OpenContextDrawer( + fl!("interface-font").into(), + )); + } + + Message::FontConfig(message) => { + return self.font_config.update(message); + } + + Message::FontSearch(input) => { + self.font_search = input.to_lowercase(); + self.font_filter.clear(); + + match self.context_view { + Some(ContextView::SystemFont) => { + self.font_config + .interface_font_families + .iter() + .filter(|f| f.to_lowercase().contains(&self.font_search)) + .for_each(|f| self.font_filter.push(f.clone())); + } + + Some(ContextView::MonospaceFont) => { + self.font_config + .monospace_font_families + .iter() + .filter(|f| f.to_lowercase().contains(&self.font_search)) + .for_each(|f| self.font_filter.push(f.clone())); + } + + _ => (), + } + } + + Message::FontSelect(is_system, family) => { + if is_system { + if let Some(id) = self + .font_config + .interface_font_families + .iter() + .position(|f| f == &family) + { + return self + .font_config + .update(font_config::Message::InterfaceFontFamily(id)); + } + } else if let Some(id) = self + .font_config + .monospace_font_families + .iter() + .position(|f| f == &family) + { + return self + .font_config + .update(font_config::Message::MonospaceFontFamily(id)); + } + } + Message::NewTheme(theme) => { self.theme = theme; } @@ -469,7 +531,7 @@ impl Page { _ = config.set::("icon_theme", theme.id); } - tokio::spawn(set_gnome_icon_theme(theme.name)); + tokio::spawn(icon_themes::set_gnome_icon_theme(theme.name)); } } @@ -983,8 +1045,8 @@ impl Page { return Command::none(); } - Message::ExperimentalContextDrawer => { - self.context_view = Some(ContextView::Experimental); + Message::IconsAndToolkit => { + self.context_view = Some(ContextView::IconsAndToolkit); return cosmic::command::message(crate::app::Message::OpenContextDrawer("".into())); } @@ -992,10 +1054,6 @@ impl Page { self.day_time = day_time; return Command::none(); } - - Message::FontConfig(message) => { - return self.font_config.update(message); - } } // If the theme builder changed, write a new theme to disk on a background thread. @@ -1305,7 +1363,6 @@ impl page::Page for Page { sections.insert(mode_and_colors()), sections.insert(style()), sections.insert(interface_density()), - sections.insert(font_config::section()), sections.insert(window_management()), sections.insert(experimental()), sections.insert(reset_button()), @@ -1339,7 +1396,7 @@ impl page::Page for Page { ) -> Command { command::batch(vec![ // Load icon themes - command::future(fetch_icon_themes()).map(crate::pages::Message::Appearance), + command::future(icon_themes::fetch()).map(crate::pages::Message::Appearance), // Load font families command::future(async move { let (mono, interface) = font_config::load_font_families(); @@ -1395,8 +1452,6 @@ impl page::Page for Page { ) .map(crate::pages::Message::Appearance), - ContextView::Experimental => self.experimental_context_view(), - ContextView::InterfaceText => color_picker_context_view( None, RESET_TO_DEFAULT.as_str().into(), @@ -1404,6 +1459,30 @@ impl page::Page for Page { &self.interface_text, ) .map(crate::pages::Message::Appearance), + + ContextView::SystemFont => { + let filter = if self.font_search.is_empty() { + &self.font_config.interface_font_families + } else { + &self.font_filter + }; + + font_config::selection_context(filter, &self.font_search, true) + .map(crate::pages::Message::Appearance) + } + + ContextView::MonospaceFont => { + let filter = if self.font_search.is_empty() { + &self.font_config.monospace_font_families + } else { + &self.font_filter + }; + + font_config::selection_context(filter, &self.font_search, false) + .map(crate::pages::Message::Appearance) + } + + ContextView::IconsAndToolkit => self.icons_and_toolkit(), }; Some(view) @@ -1857,30 +1936,40 @@ pub fn window_management() -> Section { } pub fn experimental() -> Section { - let mut descriptions = Slab::new(); - - let experimental_label = descriptions.insert(fl!("experimental-settings")); + crate::slab!(descriptions { + interface_font_txt = fl!("interface-font"); + monospace_font_txt = fl!("monospace-font"); + icons_and_toolkit_txt = fl!("icons-and-toolkit"); + }); Section::default() + .title(fl!("experimental-settings")) .descriptions(descriptions) .view::(move |_binder, _page, section| { let descriptions = §ion.descriptions; - let control = row::with_children(vec![ - horizontal_space(Length::Fill).into(), - icon::from_name("go-next-symbolic").size(16).into(), - ]); + let system_font = crate::widget::go_next_with_item( + &descriptions[interface_font_txt], + text::body(cosmic::config::interface_font().family.as_str()), + Message::DisplaySystemFont, + ); + + let mono_font = crate::widget::go_next_with_item( + &descriptions[monospace_font_txt], + text::body(cosmic::config::monospace_font().family.as_str()), + Message::DisplayMonoFont, + ); + + let icons_and_toolkit = crate::widget::go_next_item( + &descriptions[icons_and_toolkit_txt], + Message::IconsAndToolkit, + ); settings::section() - .add( - settings::item::builder(&descriptions[experimental_label]) - .control(control) - .apply(container) - .style(cosmic::theme::Container::List) - .apply(button::custom) - .style(cosmic::theme::Button::Transparent) - .on_press(Message::ExperimentalContextDrawer), - ) + .title(&*section.title) + .add(system_font) + .add(mono_font) + .add(icons_and_toolkit) .apply(Element::from) .map(crate::pages::Message::Appearance) }) @@ -1930,302 +2019,3 @@ pub fn color_button<'a, Message: 'a + Clone>( .height(Length::Fixed(f32::from(height))) .into() } - -/// Find all icon themes available on the system. -async fn fetch_icon_themes() -> Message { - let mut icon_themes = BTreeMap::new(); - let mut theme_paths: BTreeMap = BTreeMap::new(); - - let mut buffer = String::new(); - - let xdg_data_home = std::env::var("XDG_DATA_HOME") - .ok() - .and_then(|value| { - if value.is_empty() { - None - } else { - Some(PathBuf::from(value)) - } - }) - .or_else(dirs::home_dir) - .map(|dir| dir.join(".local/share/icons")); - - let xdg_data_dirs = std::env::var("XDG_DATA_DIRS").ok(); - - let xdg_data_dirs = xdg_data_dirs - .as_deref() - // Default from the XDG Base Directory Specification - .or(Some("/usr/local/share/:/usr/share/")) - .into_iter() - .flat_map(|arg| std::env::split_paths(arg).map(|dir| dir.join("icons"))); - - for icon_dir in xdg_data_dirs.chain(xdg_data_home) { - let Ok(read_dir) = std::fs::read_dir(&icon_dir) else { - continue; - }; - - 'icon_dir: for entry in read_dir.filter_map(Result::ok) { - let Ok(path) = entry.path().canonicalize() else { - continue; - }; - - let Some(id) = entry.file_name().to_str().map(String::from) else { - continue; - }; - - let manifest = path.join("index.theme"); - - if !manifest.exists() { - continue; - } - - let Ok(file) = tokio::fs::File::open(&manifest).await else { - continue; - }; - - buffer.clear(); - let mut name = None; - let mut valid_dirs = Vec::new(); - - let mut line_reader = tokio::io::BufReader::new(file); - while let Ok(read) = line_reader.read_line(&mut buffer).await { - if read == 0 { - break; - } - - if let Some(is_hidden) = buffer.strip_prefix("Hidden=") { - if is_hidden.trim() == "true" { - continue 'icon_dir; - } - } else if name.is_none() { - if let Some(value) = buffer.strip_prefix("Name=") { - name = Some(value.trim().to_owned()); - } - } - - if valid_dirs.is_empty() { - if let Some(value) = buffer.strip_prefix("Inherits=") { - valid_dirs.extend(value.trim().split(',').map(|fallback| { - if let Some(path) = theme_paths.get(fallback) { - path.iter() - .last() - .and_then(|os| os.to_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| fallback.to_owned()) - } else { - fallback.to_owned() - } - })); - } - } - - buffer.clear(); - } - - if let Some(name) = name { - // Name of the directory theme was found in (e.g. Pop for Pop) - valid_dirs.push( - path.iter() - .last() - .and_then(|os| os.to_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| name.clone()), - ); - theme_paths.entry(name.clone()).or_insert(path); - - let theme = id.clone(); - // `icon::from_name` may perform blocking I/O - if let Ok(handles) = - tokio::task::spawn_blocking(|| preview_handles(theme, valid_dirs)).await - { - icon_themes.insert(IconTheme { id, name }, handles); - } - } - } - } - - Message::Entered(icon_themes.into_iter().unzip()) -} - -/// Set the preferred icon theme for GNOME/GTK applications. -async fn set_gnome_icon_theme(theme: String) { - let _res = tokio::process::Command::new("gsettings") - .args([ - "set", - "org.gnome.desktop.interface", - "icon-theme", - theme.as_str(), - ]) - .status() - .await; -} - -/// Generate [icon::Handle]s to use for icon theme previews. -fn preview_handles(theme: String, inherits: Vec) -> [icon::Handle; ICON_PREV_N] { - // Cache current default and set icon theme as a temporary default - let default = cosmic::icon_theme::default(); - cosmic::icon_theme::set_default(theme); - - // Evaluate handles with the temporary theme - let handles = [ - icon_handle("folder", "folder-symbolic", &inherits), - icon_handle("user-home", "user-home-symbolic", &inherits), - icon_handle("text-x-generic", "text-x-generic-symbolic", &inherits), - icon_handle("image-x-generic", "images-x-generic-symbolic", &inherits), - icon_handle("audio-x-generic", "audio-x-generic-symbolic", &inherits), - icon_handle("video-x-generic", "video-x-generic-symbolic", &inherits), - ]; - - // Reset default icon theme. - cosmic::icon_theme::set_default(default); - handles -} - -/// Evaluate an icon handle for a specific theme. -/// -/// `alternate` is a fallback icon name such as a symbolic variant. -/// -/// `valid_dirs` should be a slice of directories from which we consider an icon to be valid. Valid -/// directories would usually be inherited themes as well as the actual theme's location. -fn icon_handle(icon_name: &str, alternate: &str, valid_dirs: &[String]) -> icon::Handle { - ICON_TRY_SIZES - .iter() - .zip(std::iter::repeat(icon_name).take(ICON_TRY_SIZES.len())) - // Try fallback icon name after the default - .chain( - ICON_TRY_SIZES - .iter() - .zip(std::iter::repeat(alternate)) - .take(ICON_TRY_SIZES.len()), - ) - .find_map(|(&size, name)| { - icon::from_name(name) - // Set the size on the handle to evaluate the correct icon - .size(size) - // Get the path to the icon for the currently set theme. - // Without the exact path, the handles will all resolve to icons from the same theme in - // [`icon_theme_button`] rather than the icons for each different theme - .path() - // `libcosmic` should always return a path if the default theme is installed - // The returned path has to be verified as an icon from the set theme or an - // inherited theme - .and_then(|path| { - let mut theme_dir = &*path; - while let Some(parent) = theme_dir.parent() { - if parent.ends_with("icons") { - break; - } - theme_dir = parent; - } - - if let Some(dir_name) = - theme_dir.iter().last().and_then(std::ffi::OsStr::to_str) - { - valid_dirs - .iter() - .any(|valid| dir_name == valid) - .then(|| icon::from_path(path)) - } else { - None - } - }) - }) - // Fallback icon handle - .unwrap_or_else(|| icon::from_name(icon_name).size(ICON_THUMB_SIZE).handle()) -} - -/// Button with a preview of the icon theme. -fn icon_theme_button( - name: &str, - handles: &[icon::Handle], - id: usize, - selected: bool, -) -> Element<'static, Message> { - let theme = cosmic::theme::active(); - let theme = theme.cosmic(); - let background = Background::Color(theme.palette.neutral_4.into()); - - cosmic::widget::column() - .push( - cosmic::widget::button::custom_image_button( - cosmic::widget::column::with_children(vec![ - cosmic::widget::row() - .extend( - handles - .iter() - .take(ICON_PREV_ROW) - .cloned() - // TODO: Maybe allow choosable sizes/zooming - .map(|handle| handle.icon().size(ICON_THUMB_SIZE)), - ) - .spacing(theme.space_xxxs()) - .into(), - cosmic::widget::row() - .extend( - handles - .iter() - .skip(ICON_PREV_ROW) - .cloned() - // TODO: Maybe allow choosable sizes/zooming - .map(|handle| handle.icon().size(ICON_THUMB_SIZE)), - ) - .spacing(theme.space_xxxs()) - .into(), - ]) - .spacing(theme.space_xxxs()), - None, - ) - .on_press(Message::IconTheme(id)) - .selected(selected) - .padding([theme.space_xs(), theme.space_xs() + 1]) - // Image button's style mostly works, but it needs a background to fit the design - .style(button::Style::Custom { - active: Box::new(move |focused, theme| { - let mut appearance = ::active( - theme, - focused, - selected, - &cosmic::theme::Button::Image, - ); - appearance.background = Some(background); - appearance - }), - disabled: Box::new(move |theme| { - let mut appearance = ::disabled( - theme, - &cosmic::theme::Button::Image, - ); - appearance.background = Some(background); - appearance - }), - hovered: Box::new(move |focused, theme| { - let mut appearance = ::hovered( - theme, - focused, - selected, - &cosmic::theme::Button::Image, - ); - appearance.background = Some(background); - appearance - }), - pressed: Box::new(move |focused, theme| { - let mut appearance = ::pressed( - theme, - focused, - selected, - &cosmic::theme::Button::Image, - ); - appearance.background = Some(background); - appearance - }), - }), - ) - .push( - text::body(if name.len() > ICON_NAME_TRUNC { - format!("{name:.ICON_NAME_TRUNC$}...") - } else { - name.into() - }) - .width(Length::Fixed((ICON_THUMB_SIZE * 3) as _)), - ) - .spacing(theme.space_xxs()) - .into() -} diff --git a/cosmic-settings/src/widget/mod.rs b/cosmic-settings/src/widget/mod.rs index 3eca3db..0f2bd79 100644 --- a/cosmic-settings/src/widget/mod.rs +++ b/cosmic-settings/src/widget/mod.rs @@ -201,3 +201,26 @@ pub fn go_next_item(description: &str, msg: Msg) -> cosmic .on_press(msg) .into() } + +pub fn go_next_with_item<'a, Msg: Clone + 'static>( + description: &'a str, + item: impl Into>, + msg: Msg, +) -> cosmic::Element<'_, Msg> { + settings::item_row(vec![ + text::body(description).wrap(Wrap::Word).into(), + horizontal_space(Length::Fill).into(), + widget::row::with_capacity(2) + .push(item) + .push(icon::from_name("go-next-symbolic").size(16).icon()) + .align_items(alignment::Alignment::Center) + .spacing(cosmic::theme::active().cosmic().spacing.space_s) + .into(), + ]) + .apply(widget::container) + .style(cosmic::theme::Container::List) + .apply(button::custom) + .style(theme::Button::Transparent) + .on_press(msg) + .into() +} diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index 09ce0c9..b6e4d0b 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -180,8 +180,6 @@ control-tint = Control component tint frosted = Frosted glass effect on system interface .desc = Applies background blur to panel, dock, applets, launcher, and application library. -experimental-settings = Experimental settings - enable-export = Apply this theme to GNOME apps. .desc = Not all toolkits support auto-switching. Non-COSMIC apps may need to be restarted after a theme change. @@ -205,33 +203,12 @@ window-management-appearance = Window Management .active-hint = Active window hint size .gaps = Gaps around tiled windows -### Appearance: Font +### Experimental -font-config = Font Configuration -interface-font = System Font -monospace-font = Monospace Font -font-family = Family - -font-weight = Weight - .thin = Thin - .extra-light = Extra Light - .light = Light - .normal = Normal - .medium = Medium - .semibold = Semi Bold - .bold = Bold - .extra-bold = Extra Bold - .black = Black - -font-style = Style - .normal = Normal - .italic = Italic - .oblique = Oblique - -font-stretch = Stretch - .condensed = Condensed - .normal = Normal - .expanded = Expanded +experimental-settings = Experimental Settings +icons-and-toolkit = Icons and toolkit theming +interface-font = System font +monospace-font = Monospace font ## Desktop: Notifications