From 557900315cd4ec4ca9e644278b6235e66be584dd Mon Sep 17 00:00:00 2001 From: Mathieu Comandon Date: Fri, 6 Feb 2026 16:25:24 -0800 Subject: [PATCH] feat: add custom window decoration and light theme overrides Co-Authored-By: Claude Opus 4.6 --- cosmic-theme/src/model/theme.rs | 16 +++++-- res/icons/window-close.svg | 4 ++ res/icons/window-maximize.svg | 10 ++++ res/icons/window-minimize.svg | 3 ++ res/icons/window-restore.svg | 10 ++++ src/app/mod.rs | 25 ++++++++-- src/theme/style/iced.rs | 13 +++++ src/widget/header_bar.rs | 85 ++++++++++++++++++++++----------- 8 files changed, 130 insertions(+), 36 deletions(-) create mode 100644 res/icons/window-close.svg create mode 100644 res/icons/window-maximize.svg create mode 100644 res/icons/window-minimize.svg create mode 100644 res/icons/window-restore.svg diff --git a/cosmic-theme/src/model/theme.rs b/cosmic-theme/src/model/theme.rs index 250e61cb..7d070ad7 100644 --- a/cosmic-theme/src/model/theme.rs +++ b/cosmic-theme/src/model/theme.rs @@ -140,10 +140,20 @@ impl Theme { Config::new(LIGHT_THEME_ID, Self::VERSION) } - #[inline] - /// get the built in light theme + /// Get the built-in light theme with customized defaults. + /// + /// This produces a clean, minimal light theme with pure white backgrounds, + /// generous window rounding, and subtle gray surfaces. pub fn light_default() -> Self { - LIGHT_PALETTE.clone().into() + let mut builder = ThemeBuilder::light() + .bg_color(Srgba::new(1.0, 1.0, 1.0, 1.0)) + .primary_container_bg(Srgba::new(0.96, 0.96, 0.96, 1.0)) + .corner_radii(CornerRadii { + radius_window: [16.0; 4], + ..CornerRadii::default() + }); + builder.secondary_container_bg = Some(Srgba::new(0.93, 0.93, 0.93, 1.0)); + builder.build() } #[inline] diff --git a/res/icons/window-close.svg b/res/icons/window-close.svg new file mode 100644 index 00000000..34853139 --- /dev/null +++ b/res/icons/window-close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/icons/window-maximize.svg b/res/icons/window-maximize.svg new file mode 100644 index 00000000..709c7166 --- /dev/null +++ b/res/icons/window-maximize.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/res/icons/window-minimize.svg b/res/icons/window-minimize.svg new file mode 100644 index 00000000..da61d59d --- /dev/null +++ b/res/icons/window-minimize.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/icons/window-restore.svg b/res/icons/window-restore.svg new file mode 100644 index 00000000..4aca309e --- /dev/null +++ b/res/icons/window-restore.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/app/mod.rs b/src/app/mod.rs index 67636dac..00b905bf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -301,6 +301,11 @@ where None } + /// Returns an optional icon handle to display in the header bar before the title. + fn app_icon(&self) -> Option { + None + } + /// Attaches elements to the start section of the header. fn header_start(&self) -> Vec> { Vec::new() @@ -684,9 +689,18 @@ impl ApplicationExt for App { border_padding, ]) })); + let content_inset = if maximized { 0 } else { 16 }; let content: Element<_> = if content_container { - content_col + let inner: Element<_> = content_col .apply(container) + .padding([7, 0, 0, 0]) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .class(crate::theme::Container::ContentArea) + .into(); + + container(inner) + .padding([8, content_inset, content_inset, content_inset]) .width(iced::Length::Fill) .height(iced::Length::Fill) .apply(|w| id_container(w, iced_core::id::Id::new("COSMIC_content_container"))) @@ -699,10 +713,7 @@ impl ApplicationExt for App { let window_corner_radius = if sharp_corners { crate::theme::active().cosmic().radius_0() } else { - crate::theme::active() - .cosmic() - .radius_s() - .map(|x| if x < 4.0 { x } else { x + 4.0 }) + crate::theme::active().cosmic().radius_window() }; let view_column = crate::widget::column::with_capacity(2) @@ -719,6 +730,10 @@ impl ApplicationExt for App { .on_double_click(crate::Action::Cosmic(Action::Maximize)) .is_condensed(is_condensed); + if let Some(icon) = self.app_icon() { + header = header.app_icon(icon); + } + if self.nav_model().is_some() { let toggle = crate::widget::nav_bar_toggle() .active(core.nav_bar_active()) diff --git a/src/theme/style/iced.rs b/src/theme/style/iced.rs index 36870cff..0fb5ae57 100644 --- a/src/theme/style/iced.rs +++ b/src/theme/style/iced.rs @@ -389,6 +389,7 @@ pub enum Container<'a> { WindowBackground, Background, Card, + ContentArea, ContextDrawer, Custom(Box iced_container::Style + 'a>), Dialog, @@ -595,6 +596,18 @@ impl iced_container::Catalog for Theme { shadow: Shadow::default(), }, + Container::ContentArea => iced_container::Style { + icon_color: Some(Color::from(cosmic.background.on)), + text_color: Some(Color::from(cosmic.background.on)), + background: Some(iced::Background::Color(cosmic.background.base.into())), + border: Border { + radius: cosmic.corner_radii.radius_s.into(), + width: 1.0, + color: cosmic.background.divider.into(), + }, + shadow: Shadow::default(), + }, + Container::Card => { let cosmic = self.cosmic(); diff --git a/src/widget/header_bar.rs b/src/widget/header_bar.rs index c5bde28f..b3d4af66 100644 --- a/src/widget/header_bar.rs +++ b/src/widget/header_bar.rs @@ -7,12 +7,13 @@ use apply::Apply; use derive_setters::Setters; use iced::Length; use iced_core::{Vector, Widget, widget::tree}; -use std::{borrow::Cow, cmp}; +use std::borrow::Cow; #[must_use] pub fn header_bar<'a, Message>() -> HeaderBar<'a, Message> { HeaderBar { title: Cow::Borrowed(""), + app_icon: None, on_close: None, on_drag: None, on_maximize: None, @@ -38,6 +39,10 @@ pub struct HeaderBar<'a, Message> { #[setters(skip)] title: Cow<'a, str>, + /// Optional app icon displayed before the title + #[setters(skip)] + app_icon: Option, + /// A message emitted when the close button is pressed. #[setters(strip_option)] on_close: Option, @@ -106,6 +111,13 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { self } + /// Sets the app icon displayed before the title + #[must_use] + pub fn app_icon(mut self, icon: widget::icon::Handle) -> Self { + self.app_icon = Some(icon); + self + } + /// Pushes an element to the start region. #[must_use] pub fn start(mut self, widget: impl Into> + 'a) -> Self { @@ -314,7 +326,7 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { } = theme::spacing(); // Take ownership of the regions to be packed. - let start = std::mem::take(&mut self.start); + let mut start = std::mem::take(&mut self.start); let center = std::mem::take(&mut self.center); let mut end = std::mem::take(&mut self.end); @@ -324,20 +336,46 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { // Also packs the window controls at the very end. end.push(self.window_controls()); + // Build the title element (with optional app icon) and place it in the start region. + if !self.title.is_empty() && !self.is_condensed { + let mut title = Cow::default(); + std::mem::swap(&mut title, &mut self.title); + + let title_text: Element<'a, Message> = + widget::text::heading(title).into(); + + let title_element: Element<'a, Message> = if let Some(icon_handle) = self.app_icon.take() { + widget::row::with_capacity(2) + .push( + widget::icon::icon(icon_handle) + .size(20), + ) + .push(title_text) + .spacing(space_xxs) + .align_y(iced::Alignment::Center) + .into() + } else { + title_text + }; + + start.push(title_element); + } + // Center content depending on window border + // Non-maximized windows use larger horizontal padding to clear rounded corners. let padding = match self.density.unwrap_or_else(crate::config::header_size) { Density::Compact => { if self.maximized { [4, 8, 4, 8] } else { - [3, 7, 4, 7] + [3, 16, 4, 16] } } _ => { if self.maximized { [8, 8, 8, 8] } else { - [7, 7, 8, 7] + [7, 16, 8, 16] } } }; @@ -359,7 +397,7 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { .round() as u16) .max(1); let (left_portion, right_portion) = - if center.is_empty() && (self.title.is_empty() || self.is_condensed) { + if center.is_empty() { let left_to_right_ratio = left_len as f32 / right_len.max(1) as f32; let right_to_left_ratio = right_len as f32 / left_len.max(1) as f32; if right_to_left_ratio > 2. || left_len < 1 { @@ -372,10 +410,9 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { } else { (portion, portion) }; - let title_portion = cmp::max(left_portion, right_portion) * 2; // Creates the headerbar widget. let mut widget = widget::row::with_capacity(3) - // If elements exist in the start region, append them here. + // Start region: includes app icon + title + user start elements. .push( widget::row::with_children(start) .spacing(space_xxxs) @@ -384,8 +421,7 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { .align_x(iced::Alignment::Start) .width(Length::FillPortion(left_portion)), ) - // If elements exist in the center region, use them here. - // This will otherwise use the title as a widget if a title was defined. + // Center region: only explicit center elements. .push_maybe(if !center.is_empty() { Some( widget::row::with_children(center) @@ -395,10 +431,8 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { .center_x(Length::Fill) .into(), ) - } else if !self.title.is_empty() && !self.is_condensed { - Some(self.title_widget(title_portion)) } else { - None + None::> }) .push( widget::row::with_children(end) @@ -440,22 +474,17 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { widget.into() } - fn title_widget(&mut self, title_portion: u16) -> Element<'a, Message> { - let mut title = Cow::default(); - std::mem::swap(&mut title, &mut self.title); - - widget::text::heading(title) - .apply(widget::container) - .center(Length::FillPortion(title_portion)) - .into() - } - /// Creates the widget for window controls. fn window_controls(&mut self) -> Element<'a, Message> { + const ICON_MINIMIZE: &[u8] = include_bytes!("../../res/icons/window-minimize.svg"); + const ICON_MAXIMIZE: &[u8] = include_bytes!("../../res/icons/window-maximize.svg"); + const ICON_RESTORE: &[u8] = include_bytes!("../../res/icons/window-restore.svg"); + const ICON_CLOSE: &[u8] = include_bytes!("../../res/icons/window-close.svg"); + macro_rules! icon { - ($name:expr, $size:expr, $on_press:expr) => {{ + ($svg_bytes:expr, $size:expr, $on_press:expr) => {{ let icon = { - widget::icon::from_name($name) + widget::icon::from_svg_bytes($svg_bytes) .apply(widget::button::icon) .padding(8) }; @@ -471,19 +500,19 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> { .push_maybe( self.on_minimize .take() - .map(|m: Message| icon!("window-minimize-symbolic", 16, m)), + .map(|m: Message| icon!(ICON_MINIMIZE, 16, m)), ) .push_maybe(self.on_maximize.take().map(|m| { if self.maximized { - icon!("window-restore-symbolic", 16, m) + icon!(ICON_RESTORE, 16, m) } else { - icon!("window-maximize-symbolic", 16, m) + icon!(ICON_MAXIMIZE, 16, m) } })) .push_maybe( self.on_close .take() - .map(|m| icon!("window-close-symbolic", 16, m)), + .map(|m| icon!(ICON_CLOSE, 16, m)), ) .spacing(theme::spacing().space_xxs) .apply(widget::container)