feat: add custom window decoration and light theme overrides
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d3aa7197d1
commit
557900315c
8 changed files with 130 additions and 36 deletions
|
|
@ -140,10 +140,20 @@ impl Theme {
|
||||||
Config::new(LIGHT_THEME_ID, Self::VERSION)
|
Config::new(LIGHT_THEME_ID, Self::VERSION)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
/// Get the built-in light theme with customized defaults.
|
||||||
/// get the built in light theme
|
///
|
||||||
|
/// This produces a clean, minimal light theme with pure white backgrounds,
|
||||||
|
/// generous window rounding, and subtle gray surfaces.
|
||||||
pub fn light_default() -> Self {
|
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]
|
#[inline]
|
||||||
|
|
|
||||||
4
res/icons/window-close.svg
Normal file
4
res/icons/window-close.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<line x1="4" y1="4" x2="12" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<line x1="12" y1="4" x2="4" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 311 B |
10
res/icons/window-maximize.svg
Normal file
10
res/icons/window-maximize.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<!-- Top-left arrow -->
|
||||||
|
<polyline points="6,3 3,3 3,6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- Top-right arrow -->
|
||||||
|
<polyline points="10,3 13,3 13,6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- Bottom-left arrow -->
|
||||||
|
<polyline points="3,10 3,13 6,13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- Bottom-right arrow -->
|
||||||
|
<polyline points="13,10 13,13 10,13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 719 B |
3
res/icons/window-minimize.svg
Normal file
3
res/icons/window-minimize.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<line x1="4" y1="8" x2="12" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 206 B |
10
res/icons/window-restore.svg
Normal file
10
res/icons/window-restore.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<!-- Top-left inward arrow -->
|
||||||
|
<polyline points="3,6 6,6 6,3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- Top-right inward arrow -->
|
||||||
|
<polyline points="13,6 10,6 10,3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- Bottom-left inward arrow -->
|
||||||
|
<polyline points="3,10 6,10 6,13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<!-- Bottom-right inward arrow -->
|
||||||
|
<polyline points="13,10 10,10 10,13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 747 B |
|
|
@ -301,6 +301,11 @@ where
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns an optional icon handle to display in the header bar before the title.
|
||||||
|
fn app_icon(&self) -> Option<crate::widget::icon::Handle> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Attaches elements to the start section of the header.
|
/// Attaches elements to the start section of the header.
|
||||||
fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
|
fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|
@ -684,9 +689,18 @@ impl<App: Application> ApplicationExt for App {
|
||||||
border_padding,
|
border_padding,
|
||||||
])
|
])
|
||||||
}));
|
}));
|
||||||
|
let content_inset = if maximized { 0 } else { 16 };
|
||||||
let content: Element<_> = if content_container {
|
let content: Element<_> = if content_container {
|
||||||
content_col
|
let inner: Element<_> = content_col
|
||||||
.apply(container)
|
.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)
|
.width(iced::Length::Fill)
|
||||||
.height(iced::Length::Fill)
|
.height(iced::Length::Fill)
|
||||||
.apply(|w| id_container(w, iced_core::id::Id::new("COSMIC_content_container")))
|
.apply(|w| id_container(w, iced_core::id::Id::new("COSMIC_content_container")))
|
||||||
|
|
@ -699,10 +713,7 @@ impl<App: Application> ApplicationExt for App {
|
||||||
let window_corner_radius = if sharp_corners {
|
let window_corner_radius = if sharp_corners {
|
||||||
crate::theme::active().cosmic().radius_0()
|
crate::theme::active().cosmic().radius_0()
|
||||||
} else {
|
} else {
|
||||||
crate::theme::active()
|
crate::theme::active().cosmic().radius_window()
|
||||||
.cosmic()
|
|
||||||
.radius_s()
|
|
||||||
.map(|x| if x < 4.0 { x } else { x + 4.0 })
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let view_column = crate::widget::column::with_capacity(2)
|
let view_column = crate::widget::column::with_capacity(2)
|
||||||
|
|
@ -719,6 +730,10 @@ impl<App: Application> ApplicationExt for App {
|
||||||
.on_double_click(crate::Action::Cosmic(Action::Maximize))
|
.on_double_click(crate::Action::Cosmic(Action::Maximize))
|
||||||
.is_condensed(is_condensed);
|
.is_condensed(is_condensed);
|
||||||
|
|
||||||
|
if let Some(icon) = self.app_icon() {
|
||||||
|
header = header.app_icon(icon);
|
||||||
|
}
|
||||||
|
|
||||||
if self.nav_model().is_some() {
|
if self.nav_model().is_some() {
|
||||||
let toggle = crate::widget::nav_bar_toggle()
|
let toggle = crate::widget::nav_bar_toggle()
|
||||||
.active(core.nav_bar_active())
|
.active(core.nav_bar_active())
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,7 @@ pub enum Container<'a> {
|
||||||
WindowBackground,
|
WindowBackground,
|
||||||
Background,
|
Background,
|
||||||
Card,
|
Card,
|
||||||
|
ContentArea,
|
||||||
ContextDrawer,
|
ContextDrawer,
|
||||||
Custom(Box<dyn Fn(&Theme) -> iced_container::Style + 'a>),
|
Custom(Box<dyn Fn(&Theme) -> iced_container::Style + 'a>),
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -595,6 +596,18 @@ impl iced_container::Catalog for Theme {
|
||||||
shadow: Shadow::default(),
|
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 => {
|
Container::Card => {
|
||||||
let cosmic = self.cosmic();
|
let cosmic = self.cosmic();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,13 @@ use apply::Apply;
|
||||||
use derive_setters::Setters;
|
use derive_setters::Setters;
|
||||||
use iced::Length;
|
use iced::Length;
|
||||||
use iced_core::{Vector, Widget, widget::tree};
|
use iced_core::{Vector, Widget, widget::tree};
|
||||||
use std::{borrow::Cow, cmp};
|
use std::borrow::Cow;
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn header_bar<'a, Message>() -> HeaderBar<'a, Message> {
|
pub fn header_bar<'a, Message>() -> HeaderBar<'a, Message> {
|
||||||
HeaderBar {
|
HeaderBar {
|
||||||
title: Cow::Borrowed(""),
|
title: Cow::Borrowed(""),
|
||||||
|
app_icon: None,
|
||||||
on_close: None,
|
on_close: None,
|
||||||
on_drag: None,
|
on_drag: None,
|
||||||
on_maximize: None,
|
on_maximize: None,
|
||||||
|
|
@ -38,6 +39,10 @@ pub struct HeaderBar<'a, Message> {
|
||||||
#[setters(skip)]
|
#[setters(skip)]
|
||||||
title: Cow<'a, str>,
|
title: Cow<'a, str>,
|
||||||
|
|
||||||
|
/// Optional app icon displayed before the title
|
||||||
|
#[setters(skip)]
|
||||||
|
app_icon: Option<widget::icon::Handle>,
|
||||||
|
|
||||||
/// A message emitted when the close button is pressed.
|
/// A message emitted when the close button is pressed.
|
||||||
#[setters(strip_option)]
|
#[setters(strip_option)]
|
||||||
on_close: Option<Message>,
|
on_close: Option<Message>,
|
||||||
|
|
@ -106,6 +111,13 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
|
||||||
self
|
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.
|
/// Pushes an element to the start region.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn start(mut self, widget: impl Into<Element<'a, Message>> + 'a) -> Self {
|
pub fn start(mut self, widget: impl Into<Element<'a, Message>> + 'a) -> Self {
|
||||||
|
|
@ -314,7 +326,7 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
|
||||||
} = theme::spacing();
|
} = theme::spacing();
|
||||||
|
|
||||||
// Take ownership of the regions to be packed.
|
// 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 center = std::mem::take(&mut self.center);
|
||||||
let mut end = std::mem::take(&mut self.end);
|
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.
|
// Also packs the window controls at the very end.
|
||||||
end.push(self.window_controls());
|
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
|
// 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) {
|
let padding = match self.density.unwrap_or_else(crate::config::header_size) {
|
||||||
Density::Compact => {
|
Density::Compact => {
|
||||||
if self.maximized {
|
if self.maximized {
|
||||||
[4, 8, 4, 8]
|
[4, 8, 4, 8]
|
||||||
} else {
|
} else {
|
||||||
[3, 7, 4, 7]
|
[3, 16, 4, 16]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if self.maximized {
|
if self.maximized {
|
||||||
[8, 8, 8, 8]
|
[8, 8, 8, 8]
|
||||||
} else {
|
} else {
|
||||||
[7, 7, 8, 7]
|
[7, 16, 8, 16]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -359,7 +397,7 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
|
||||||
.round() as u16)
|
.round() as u16)
|
||||||
.max(1);
|
.max(1);
|
||||||
let (left_portion, right_portion) =
|
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 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;
|
let right_to_left_ratio = right_len as f32 / left_len.max(1) as f32;
|
||||||
if right_to_left_ratio > 2. || left_len < 1 {
|
if right_to_left_ratio > 2. || left_len < 1 {
|
||||||
|
|
@ -372,10 +410,9 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
|
||||||
} else {
|
} else {
|
||||||
(portion, portion)
|
(portion, portion)
|
||||||
};
|
};
|
||||||
let title_portion = cmp::max(left_portion, right_portion) * 2;
|
|
||||||
// Creates the headerbar widget.
|
// Creates the headerbar widget.
|
||||||
let mut widget = widget::row::with_capacity(3)
|
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(
|
.push(
|
||||||
widget::row::with_children(start)
|
widget::row::with_children(start)
|
||||||
.spacing(space_xxxs)
|
.spacing(space_xxxs)
|
||||||
|
|
@ -384,8 +421,7 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
|
||||||
.align_x(iced::Alignment::Start)
|
.align_x(iced::Alignment::Start)
|
||||||
.width(Length::FillPortion(left_portion)),
|
.width(Length::FillPortion(left_portion)),
|
||||||
)
|
)
|
||||||
// If elements exist in the center region, use them here.
|
// Center region: only explicit center elements.
|
||||||
// This will otherwise use the title as a widget if a title was defined.
|
|
||||||
.push_maybe(if !center.is_empty() {
|
.push_maybe(if !center.is_empty() {
|
||||||
Some(
|
Some(
|
||||||
widget::row::with_children(center)
|
widget::row::with_children(center)
|
||||||
|
|
@ -395,10 +431,8 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
|
||||||
.center_x(Length::Fill)
|
.center_x(Length::Fill)
|
||||||
.into(),
|
.into(),
|
||||||
)
|
)
|
||||||
} else if !self.title.is_empty() && !self.is_condensed {
|
|
||||||
Some(self.title_widget(title_portion))
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None::<Element<'a, Message>>
|
||||||
})
|
})
|
||||||
.push(
|
.push(
|
||||||
widget::row::with_children(end)
|
widget::row::with_children(end)
|
||||||
|
|
@ -440,22 +474,17 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
|
||||||
widget.into()
|
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.
|
/// Creates the widget for window controls.
|
||||||
fn window_controls(&mut self) -> Element<'a, Message> {
|
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 {
|
macro_rules! icon {
|
||||||
($name:expr, $size:expr, $on_press:expr) => {{
|
($svg_bytes:expr, $size:expr, $on_press:expr) => {{
|
||||||
let icon = {
|
let icon = {
|
||||||
widget::icon::from_name($name)
|
widget::icon::from_svg_bytes($svg_bytes)
|
||||||
.apply(widget::button::icon)
|
.apply(widget::button::icon)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
};
|
};
|
||||||
|
|
@ -471,19 +500,19 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
|
||||||
.push_maybe(
|
.push_maybe(
|
||||||
self.on_minimize
|
self.on_minimize
|
||||||
.take()
|
.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| {
|
.push_maybe(self.on_maximize.take().map(|m| {
|
||||||
if self.maximized {
|
if self.maximized {
|
||||||
icon!("window-restore-symbolic", 16, m)
|
icon!(ICON_RESTORE, 16, m)
|
||||||
} else {
|
} else {
|
||||||
icon!("window-maximize-symbolic", 16, m)
|
icon!(ICON_MAXIMIZE, 16, m)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.push_maybe(
|
.push_maybe(
|
||||||
self.on_close
|
self.on_close
|
||||||
.take()
|
.take()
|
||||||
.map(|m| icon!("window-close-symbolic", 16, m)),
|
.map(|m| icon!(ICON_CLOSE, 16, m)),
|
||||||
)
|
)
|
||||||
.spacing(theme::spacing().space_xxs)
|
.spacing(theme::spacing().space_xxs)
|
||||||
.apply(widget::container)
|
.apply(widget::container)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue