feat(ui): refine Graphite visuals and center navigation content

This commit is contained in:
aria committed 2026-10-28 04:09:10 +09:00
1 parent cc3b6f903f
commit 1e34ec47ad
14 files changed
+628 -103

No files matched your search

+3
View File
@@ -71,6 +71,9 @@ inside your existing Wayland desktop.
## Configure ## Configure
Open **Control Center → Settings** or edit `~/.config/ferese/config.kdl`. Open **Control Center → Settings** or edit `~/.config/ferese/config.kdl`.
The [visual direction notes](docs/development/visual-direction.md) document the
Graphite dark/light family and the responsive settings layout.
This fork adds a grouped **Appearance** settings hub. **General → Language** This fork adds a grouped **Appearance** settings hub. **General → Language**
supports the system language, 简体中文 and English and saves the choice as supports the system language, 简体中文 and English and saves the choice as
`ui { language "zh-CN"; }` (`auto` and `en` are also supported). It changes `ui { language "zh-CN"; }` (`auto` and `en` are also supported). It changes
+3 -1
View File
@@ -567,5 +567,7 @@
"Application icons": "应用图标", "Application icons": "应用图标",
"Refresh applications": "刷新应用列表", "Refresh applications": "刷新应用列表",
"Panel status icons": "面板状态图标", "Panel status icons": "面板状态图标",
"Install the matching Ferese compositor beside Settings.": "请将匹配版本的 Ferese 主程序与设置程序安装在同一目录。" "Install the matching Ferese compositor beside Settings.": "请将匹配版本的 Ferese 主程序与设置程序安装在同一目录。",
"Quick access": "快速访问",
"Saving…": "正在保存…"
} }
+2 -1
View File
@@ -1,13 +1,14 @@
use crate::theme::Appearance; use crate::theme::Appearance;
pub use ferese_theme_model::families::{Family, Palette}; pub use ferese_theme_model::families::{Family, Palette};
pub const BUILTINS: [(&str, &str, &str, &str); 6] = [ pub const BUILTINS: [(&str, &str, &str, &str); 7] = [
( (
"ferese-blue", "ferese-blue",
"Ferese Blue", "Ferese Blue",
"ferese-blue-light", "ferese-blue-light",
"ferese-blue", "ferese-blue",
), ),
("graphite", "Graphite", "graphite-light", "graphite"),
( (
"catppuccin", "catppuccin",
"Catppuccin", "Catppuccin",
+37 -1
View File
@@ -19,7 +19,7 @@ pub struct Preset {
pub shadow_opacity: f64, pub shadow_opacity: f64,
} }
pub const PRESETS: [Preset; 12] = [ pub const PRESETS: [Preset; 14] = [
Preset { Preset {
appearance: Appearance::Dark, appearance: Appearance::Dark,
id: "ferese-blue", id: "ferese-blue",
@@ -38,6 +38,24 @@ pub const PRESETS: [Preset; 12] = [
tint_strength: 0.5, tint_strength: 0.5,
shadow_opacity: 0.2, shadow_opacity: 0.2,
}, },
Preset {
appearance: Appearance::Dark,
id: "graphite",
name: "Graphite",
description: "Quiet graphite",
base: "#101113",
raised: "#1B1D21",
application_background: "#141518",
text: "#E7E9ED",
muted: "#9A9FA9",
accent: "#8EABD8",
border: "#FFFFFF16",
gradient_end: "#8EABD8",
opacity: 0.96,
blur_radius: 8.0,
tint_strength: 0.06,
shadow_opacity: 0.12,
},
Preset { Preset {
appearance: Appearance::Dark, appearance: Appearance::Dark,
id: "catppuccin-mocha", id: "catppuccin-mocha",
@@ -146,6 +164,24 @@ pub const PRESETS: [Preset; 12] = [
tint_strength: 1.0, tint_strength: 1.0,
shadow_opacity: 0.1, shadow_opacity: 0.1,
}, },
Preset {
appearance: Appearance::Light,
id: "graphite-light",
name: "Graphite",
description: "Quiet paper",
base: "#F7F8FA",
raised: "#FFFFFF",
application_background: "#F1F3F5",
text: "#23262C",
muted: "#626A76",
accent: "#3F6DAA",
border: "#00000018",
gradient_end: "#3F6DAA",
opacity: 0.98,
blur_radius: 4.0,
tint_strength: 0.02,
shadow_opacity: 0.08,
},
Preset { Preset {
appearance: Appearance::Light, appearance: Appearance::Light,
id: "catppuccin-latte", id: "catppuccin-latte",
+3 -3
View File
@@ -1358,8 +1358,8 @@ mod tests {
.iter() .iter()
.filter(|p| p.appearance == Appearance::Light) .filter(|p| p.appearance == Appearance::Light)
.collect(); .collect();
assert_eq!(dark.len(), 6); assert_eq!(dark.len(), crate::families::BUILTINS.len());
assert_eq!(light.len(), 6); assert_eq!(light.len(), crate::families::BUILTINS.len());
for (dark, light) in dark.iter().zip(light) { for (dark, light) in dark.iter().zip(light) {
assert_eq!(dark.name, light.name); assert_eq!(dark.name, light.name);
assert_ne!(dark.base, light.base); assert_ne!(dark.base, light.base);
@@ -1443,7 +1443,7 @@ mod tests {
); );
assert_eq!(resolved.policy.light.family.as_deref(), Some("gruvbox")); assert_eq!(resolved.policy.light.family.as_deref(), Some("gruvbox"));
assert_eq!(resolved.policy.dark.family.as_deref(), Some("catppuccin")); assert_eq!(resolved.policy.dark.family.as_deref(), Some("catppuccin"));
assert_eq!(resolved.families.len(), 6); assert_eq!(resolved.families.len(), crate::families::BUILTINS.len());
} }
} }
let resolved = candidate(r#"theme { mode "light"; family "catppuccin"; split #true; light { family "gruvbox"; }; dark { family "everforest"; }; }"#).unwrap(); let resolved = candidate(r#"theme { mode "light"; family "catppuccin"; split #true; light { family "gruvbox"; }; dark { family "everforest"; }; }"#).unwrap();
+21 -1
View File
@@ -107,6 +107,7 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
let mut sequences: Vec<Vec<u32>> = Vec::new(); let mut sequences: Vec<Vec<u32>> = Vec::new();
let mut clicks = Vec::new(); let mut clicks = Vec::new();
let mut hovers = Vec::new();
let mut drag = None; let mut drag = None;
let mut extent = (1280, 800); let mut extent = (1280, 800);
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
@@ -119,6 +120,13 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
extent = (v[0], v[1]); extent = (v[0], v[1]);
} }
"--hover" => {
let v = parse_numbers(&args.next().ok_or("missing hover position")?)?;
if v.len() != 2 {
return Err("invalid hover".into());
}
hovers.push((v[0], v[1]));
}
"--click" => { "--click" => {
let v = parse_numbers(&args.next().ok_or("missing click position")?)?; let v = parse_numbers(&args.next().ok_or("missing click position")?)?;
if v.len() != 2 { if v.len() != 2 {
@@ -247,12 +255,24 @@ fn main() -> Result<(), Box<dyn Error>> {
std::thread::sleep(Duration::from_millis(35)); std::thread::sleep(Duration::from_millis(35));
} }
} }
if !clicks.is_empty() || drag.is_some() { if !clicks.is_empty() || !hovers.is_empty() || drag.is_some() {
let pointer = state let pointer = state
.pointer_manager .pointer_manager
.as_ref() .as_ref()
.ok_or("test pointer is not enabled on this compositor")? .ok_or("test pointer is not enabled on this compositor")?
.create_virtual_pointer(state.seat.as_ref(), &qh, ()); .create_virtual_pointer(state.seat.as_ref(), &qh, ());
for (x, y) in hovers {
pointer.motion_absolute(
started.elapsed().as_millis() as u32,
x,
y,
extent.0,
extent.1,
);
pointer.frame();
queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(120));
}
for (x, y) in clicks { for (x, y) in clicks {
pointer.motion_absolute( pointer.motion_absolute(
started.elapsed().as_millis() as u32, started.elapsed().as_millis() as u32,
+125 -24
View File
@@ -116,14 +116,24 @@ pub fn settings_input(p: Palette) -> theme::TextInput {
let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance { let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance {
shape: Some(BorderShape::Continuous), shape: Some(BorderShape::Continuous),
outline: None, outline: None,
background: mix(p.card, p.text, if hovered { 0.06 } else { 0.035 }).into(), background: mix(p.background, p.card, if hovered { 0.55 } else { 0.35 }).into(),
border_radius: p.radius.min(7.).into(), border_radius: p.radius.min(7.).into(),
border_width: 1., border_width: 1.,
border_offset: None, border_offset: None,
border_color: if focused { border_color: if focused {
p.accent p.accent
} else { } else {
mix(p.card, p.text, if hovered { 0.26 } else { 0.18 }) mix(
p.card,
p.text,
if p.high_contrast {
0.5
} else if hovered {
0.20
} else {
0.10
},
)
}, },
icon_color: Some(p.muted), icon_color: Some(p.muted),
text_color: Some(p.text), text_color: Some(p.text),
@@ -166,25 +176,52 @@ fn styled_button(
opacity: f32, opacity: f32,
focus_visible: bool, focus_visible: bool,
) -> theme::Button { ) -> theme::Button {
let style = move |hover: bool, focused: bool| { let style = move |hover: bool, focused: bool, pressed: bool| {
let background = if selected { let resting = if navigation {
mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 })
} else if hover {
if navigation {
mix(p.card, crate::surface_shade(p.sidebar), 0.06)
} else {
mix(p.card, p.text, 0.08)
}
} else if navigation {
p.sidebar p.sidebar
} else { } else {
mix(p.card, p.text, 0.035) mix(p.background, p.card, 0.5)
}; };
let (background, on) = if selected { let selected_strength = if navigation { 0.09 } else { 0.10 };
crate::accent_pair(background, p.text) let background = if selected && navigation {
mix(
resting,
p.text,
if pressed {
0.16
} else if hover {
0.13
} else { } else {
(background, p.text) selected_strength
},
)
} else if selected {
mix(
resting,
p.accent,
if pressed {
0.20
} else if hover {
0.16
} else {
selected_strength
},
)
} else if hover {
mix(resting, p.text, if pressed { 0.09 } else { 0.055 })
} else {
resting
}; };
let mut on = crate::foreground(background, p.text);
if p.high_contrast && crate::contrast(background, on) < 7. {
on = if crate::contrast(background, Color::BLACK)
>= crate::contrast(background, Color::WHITE)
{
Color::BLACK
} else {
Color::WHITE
};
}
let mut background = background; let mut background = background;
background.a *= if hover && opacity < 1.0 { background.a *= if hover && opacity < 1.0 {
@@ -193,9 +230,15 @@ fn styled_button(
opacity opacity
}; };
let fill = if selected { let fill = if selected && !navigation {
p.selected_background( p.selected_background(
if hover { 0.24 } else { 0.17 }, if pressed {
0.20
} else if hover {
0.16
} else {
0.10
},
background.a, background.a,
on, on,
background, background,
@@ -211,11 +254,23 @@ fn styled_button(
text_color: Some(on), text_color: Some(on),
icon_color: Some(on), icon_color: Some(on),
border_radius: p.radius.min(9.).into(), border_radius: p.radius.min(9.).into(),
border_width: if selected || !navigation { 1. } else { 0. }, border_width: if !navigation || (selected && p.high_contrast) {
1.
} else {
0.
},
border_color: if selected { border_color: if selected {
if p.high_contrast {
p.accent p.accent
} else {
mix(background, p.accent, 0.40)
}
} else if !navigation { } else if !navigation {
mix(p.card, p.text, 0.18) mix(
background,
p.text,
if p.high_contrast { 0.50 } else { 0.10 },
)
} else { } else {
Color::TRANSPARENT Color::TRANSPARENT
}, },
@@ -227,10 +282,17 @@ fn styled_button(
}; };
theme::Button::Custom { theme::Button::Custom {
active: Box::new(move |focused, _| style(false, focused)), active: Box::new(move |focused, _| style(false, focused, false)),
hovered: Box::new(move |focused, _| style(true, focused)), hovered: Box::new(move |focused, _| style(true, focused, false)),
pressed: Box::new(move |focused, _| style(true, focused)), pressed: Box::new(move |focused, _| style(true, focused, true)),
disabled: Box::new(move |_| style(false, false)), disabled: Box::new(move |_| {
let mut disabled = style(false, false, false);
if !selected {
disabled.text_color = Some(p.muted);
disabled.icon_color = Some(p.muted);
}
disabled
}),
} }
} }
@@ -433,6 +495,45 @@ pub fn filled_button(fill: Color, foreground: Color, radius: f32, opacity: f32)
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn navigation_selection_is_quiet_but_keyboard_focus_and_press_are_explicit() {
use cosmic::widget::button::Catalog;
for preset in ferese_config::presets::PRESETS {
let mut resolved = ferese_config::theme::default_theme();
resolved.appearance = preset.appearance;
resolved.tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap();
for high_contrast in [false, true] {
resolved.accessibility.increase_contrast = high_contrast;
let palette = Palette::from_resolved(&resolved);
let native = palette.native_theme();
for selected in [false, true] {
let class = navigation_style(palette, selected);
let resting = native.active(false, selected, &class);
let focus = native.active(true, selected, &class);
assert_eq!(resting.outline_width, 0.);
assert_eq!(focus.outline_width, 2.);
assert_eq!(
resting.border_width,
if selected && high_contrast { 1. } else { 0. }
);
let Some(Background::Color(fill)) = resting.background else {
panic!("navigation should not be a gradient")
};
assert!(
crate::contrast(fill, resting.text_color.unwrap())
>= if high_contrast { 7. } else { 4.5 },
"{}",
preset.id
);
assert_ne!(
native.hovered(false, selected, &class).background,
native.pressed(false, selected, &class).background
);
}
}
}
}
#[test] #[test]
fn hiding_modal_focus_preserves_button_borders_and_materials() { fn hiding_modal_focus_preserves_button_borders_and_materials() {
use cosmic::widget::button::Catalog; use cosmic::widget::button::Catalog;
+56
View File
@@ -0,0 +1,56 @@
# Ferese visual direction: quiet structure
Reference reviewed on 2026-10-28: the user supplied a 9.78-second, 60 fps
2168 × 2160 screen recording. It is an edited design progression, not a recording
of a working desktop's interaction latency or animation timings. Local analysis
frames and the source fingerprint live in `.artifacts/visual-reference-20261028/`.
## What the reference changes
| Segment | Observed change | Ferese application |
| --- | --- | --- |
| 0–3 s | Spacing guides replace uneven navigation and card offsets. | A 4 px spacing scale; 24 px page/section rhythm, 12 px card gutters, 36 px navigation rows. |
| 3–5 s | Purple glows and saturated surfaces recede into neutral planes. | A selectable Graphite family, paired dark and light appearances; near-opaque surfaces and restrained tint/shadow. |
| 5–7 s | Outline icons, a smaller brand, consistent insets and quieter selection. | 24 px brand mark, small outline icons, neutral navigation selection plus an accent marker. Keyboard focus stays explicit. |
| 7–9.78 s | Typography and content become the hierarchy; color remains on meaningful data. | MiSans retained; medium-weight page/card titles, readable secondary text, color reserved for active state and status. |
## Implemented layout
Appearance is a configuration overview, with no invented metrics or dashboard
content. Quick access puts language/general preferences and the complete editor
on the first row. Desktop, interaction and system settings follow as grouped,
equal-width cards. The card text owns the flexible space; chevrons align to the
far edge. Above 680 logical pixels of content width the overview has two columns;
below that it becomes one column. Card height follows the text, including Chinese
and English line wrapping.
Normal settings use thin section boundaries instead of large colored blocks.
Each 36 px navigation hit area centers the complete content row vertically; the
row then centers its text, icon and indicator internally. Both layers are necessary
because the native custom button places its child at the top padding.
Navigation selection and keyboard focus are independent: the former uses a quiet
fill and a small accent marker, while keyboard focus has a clear outline. Stronger
boundaries remain available through the existing high-contrast preference.
Explicit gradient theme choices still apply to theme controls and window focus.
Graphite is an ordinary registered theme family (`graphite`, `graphite-light`),
so the Shell, Settings, locker and native Rime candidate popup receive it through
the existing theme protocol. Theme selection changes only in the isolated preview
for this review; choosing another family remains available in Appearance → Theme.
No new configuration keys or input gestures are introduced by the visual update.
## Acceptance
Check both Graphite appearances and all existing presets, keyboard focus and text
contrast, a narrow single-column view and a wide two-column view, card boundaries
and reachability, the complete editor's scrolling, and a native Rime candidate.
The edited video does not provide evidence for copying an easing curve or duration;
existing motion and Reduce motion settings continue to own those behaviors.
Validation completed in this fork: 115 Settings tests, 32 shared theme/control
tests, 247 Shell tests and 46 configuration tests; optional profiling tests remain
ignored. Native captures cover dark/light appearances and a 631 px window.
All 15 navigation items have a centering regression check, including the three
Interaction destinations selected in turn; a hover-only native probe confirms
Windows aligns inside its highlighted hit area. Native Rime input, horizontal
candidates, paging, theme changes and service recovery also passed.
+60 -20
View File
@@ -501,43 +501,83 @@ impl App {
} }
Task::none() Task::none()
} }
pub fn settings_hub(&self) -> Element<'_, Message> { pub fn settings_hub(&self, width: f32) -> Element<'_, Message> {
let mut out = column([]).spacing(18); self.hub_layout(if width >= 680. { 2 } else { 1 })
}
fn hub_layout(&self, columns: usize) -> Element<'_, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let mut out = column([]).spacing(24);
for (name, pages) in Page::NAVIGATION { for (name, pages) in Page::NAVIGATION {
if name == "Appearance" { if name == "Advanced" {
continue; continue;
} }
out = out.push(self.panel_heading(tr(name), 15.)); let pages = if name == "Appearance" {
for &p in pages { vec![Page::General, Page::Configuration]
out = out.push(self.hub_card(p)); } else {
pages
.iter()
.copied()
.filter(|p| *p != Page::Appearance)
.collect::<Vec<_>>()
};
let mut section = column([]).spacing(8).push(
self.panel_heading(
tr(if name == "Appearance" {
"Quick access"
} else {
name
}),
12.,
)
.class(cosmic::theme::Text::Color(palette.muted)),
);
for pair in pages.chunks(columns) {
let mut cards = row([]).spacing(12);
for &page in pair {
cards = cards.push(self.hub_card(page));
} }
if pair.len() < columns {
cards = cards.push(cosmic::widget::Space::new().width(Length::Fill));
} }
column([]) section = section.push(cards);
.spacing(12) }
.push(self.hub_card(Page::General)) out = out.push(section);
.push(out) }
.into() out.into()
} }
fn hub_card(&self, p: Page) -> Element<'static, Message> { fn hub_card(&self, p: Page) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); let palette = visuals::Palette::from_resolved(&self.resolved.presented);
button::custom( let content = row([])
row([])
.spacing(12) .spacing(12)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(visuals::icon(p, palette.accent)) .push(visuals::icon(p, palette.muted))
.push( .push(
column([]) column([])
.spacing(3) .spacing(4)
.push(self.label(p.title(), 15.)) .push(self.label(p.title(), 14.).font(cosmic::iced::Font {
.push(self.label(p.subtitle(), 12.)), weight: cosmic::iced::font::Weight::Medium,
..self.font
}))
.push(
self.label(p.subtitle(), 12.)
.class(cosmic::theme::Text::Color(palette.muted)),
) )
.push(self.label(ferese_config::tr!("›"), 20.)), .width(Length::Fill),
) )
.push(visuals::action_icon("M9 5l7 7-7 7", palette.muted));
container(
button::custom(content)
.name(p.title()) .name(p.title())
.padding(14) .padding(16)
.width(Length::Fill) .width(Length::Fill)
.class(visuals::button_style(palette, false)) .class(visuals::button_style(palette, false))
.on_press(Message::Page(p)) .on_press(Message::Page(p)),
)
.id(cosmic::widget::Id::new(format!(
"settings-hub-{}",
p as usize
)))
.width(Length::Fill)
.into() .into()
} }
pub fn all_options_view(&self) -> Element<'_, Message> { pub fn all_options_view(&self) -> Element<'_, Message> {
+2 -2
View File
@@ -248,9 +248,9 @@ impl App {
.push(labels) .push(labels)
.push(control), .push(control),
) )
.padding([7, 10]) .padding([10, 12])
.width(Length::Fill) .width(Length::Fill)
.class(visuals::surface(palette.card, 0.)) .class(visuals::surface(cosmic::iced::Color::TRANSPARENT, 0.))
.into() .into()
} }
} }
+99 -43
View File
@@ -8,17 +8,17 @@ pub(super) const SIDEBAR_BREAKPOINT: f32 = 960.;
impl App { impl App {
pub(super) fn page_view(&self) -> Element<'_, Message> { pub(super) fn page_view(&self) -> Element<'_, Message> {
cosmic::iced::widget::responsive(move |size| { cosmic::iced::widget::responsive(move |size| {
self.page_layout(size.width < SIDEBAR_BREAKPOINT, size.height) self.page_layout(size.width < SIDEBAR_BREAKPOINT, size.height, size.width)
}) })
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into() .into()
} }
fn page_layout(&self, compact: bool, height: f32) -> Element<'_, Message> { fn page_layout(&self, compact: bool, height: f32, width: f32) -> Element<'_, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let mut sidebar = column([]) let mut sidebar = column([])
.spacing(3) .spacing(4)
.push( .push(
widget::mouse_area( widget::mouse_area(
row([]) row([])
@@ -26,7 +26,7 @@ impl App {
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(visuals::brand_icon()) .push(visuals::brand_icon())
.push( .push(
self.label(ferese_config::tr!("Ferese"), 16.) self.label(ferese_config::tr!("Ferese"), 14.)
.class(cosmic::theme::Text::Color(palette.text)), .class(cosmic::theme::Text::Color(palette.text)),
) )
.width(Length::Fill), .width(Length::Fill),
@@ -52,24 +52,38 @@ impl App {
false, false,
)); ));
} }
let mut navigation = column([]).spacing(16); let mut navigation = column([]).spacing(24);
for (title, pages) in Page::NAVIGATION { for (title, pages) in Page::NAVIGATION {
let mut section = column([]).spacing(3).push( let mut section = column([]).spacing(3).push(
container( container(
self.label(ferese_config::locale::text(title), 11.) self.label(
ferese_config::locale::text(if title == "Appearance" {
"Settings"
} else {
title
}),
11.,
)
.class(cosmic::theme::Text::Color(palette.muted)), .class(cosmic::theme::Text::Color(palette.muted)),
) )
.padding([0, 8]), .padding([0, 8]),
); );
for &page in pages { for &page in pages {
let selected = self.page.navigation_page() == page; let selected = self.page.navigation_page() == page;
section = let indicator = container(widget::Space::new().width(2).height(14))
section.push( .id(widget::Id::new(format!(
button::custom( "settings-nav-marker-{}",
row([]) page as usize
.spacing(8) )))
.align_y(Alignment::Center) .class(visuals::surface(
.push(visuals::icon( if selected {
palette.accent
} else {
cosmic::iced::Color::TRANSPARENT
},
1.,
));
let icon = container(visuals::icon(
page, page,
if selected { if selected {
palette.accent palette.accent
@@ -77,12 +91,44 @@ impl App {
palette.muted palette.muted
}, },
)) ))
.push(self.label(page.title(), 13.)), .id(widget::Id::new(format!(
"settings-nav-icon-{}",
page as usize
)));
let label = container(self.label(page.title(), 13.)).id(widget::Id::new(format!(
"settings-nav-label-{}",
page as usize
)));
let content = container(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(indicator)
.push(icon)
.push(label),
) )
.width(Length::Fill) .id(widget::Id::new(format!(
.padding([8, 8]) "settings-nav-content-{}",
.class(visuals::navigation_style(palette, selected)) page as usize
)));
// The button lays its child out at its top padding. Center the
// entire row inside its 36 px hit area, as well as row children.
section =
section.push(
button::custom(
container(content)
.center_y(Length::Fill)
.width(Length::Fill),
)
.id(widget::Id::new(format!(
"settings-nav-button-{}",
page as usize
)))
.name(page.title()) .name(page.title())
.width(Length::Fill)
.height(36)
.padding([0, 8])
.class(visuals::navigation_style(palette, selected))
.on_press(Message::Page(if selected { self.page } else { page })), .on_press(Message::Page(if selected { self.page } else { page })),
); );
} }
@@ -90,21 +136,27 @@ impl App {
} }
let sidebar = container(sidebar.push(scrollable(navigation).height(Length::Fill))) let sidebar = container(sidebar.push(scrollable(navigation).height(Length::Fill)))
.id("settings-sidebar") .id("settings-sidebar")
.width(if compact { 244 } else { 204 }) .width(if compact { 244 } else { 208 })
.height(Length::Fill) .height(Length::Fill)
.padding([20, 10]) .padding([24, 12])
.class(visuals::surface(palette.sidebar, 0.)); .class(visuals::sidebar(palette));
let mut heading = row([]).align_y(Alignment::Center).spacing(12).push( let mut heading = row([]).align_y(Alignment::Center).spacing(12).push(
column([]) column([])
.spacing(3) .spacing(3)
.push(self.label( .push(
self.label(
if self.search.is_empty() { if self.search.is_empty() {
self.page.navigation_page().title() self.page.navigation_page().title()
} else { } else {
"Search" "Search"
}, },
20., 24.,
)) )
.font(cosmic::iced::Font {
weight: cosmic::iced::font::Weight::Medium,
..self.font
}),
)
.push( .push(
self.label( self.label(
if self.search.is_empty() { if self.search.is_empty() {
@@ -207,7 +259,9 @@ impl App {
} }
} else { } else {
if self.page == Page::Appearance { if self.page == Page::Appearance {
body = body.push(self.settings_hub()); body = body.push(
self.settings_hub((width - if compact { 0. } else { 208. }).min(1040.) - 62.),
);
} }
if self.page == Page::Configuration { if self.page == Page::Configuration {
body = body.push(self.all_options_view()); body = body.push(self.all_options_view());
@@ -306,11 +360,7 @@ impl App {
group = group.push(self.theme_file_picker()); group = group.push(self.theme_file_picker());
} }
} }
body = body.push( body = body.push(container(group).padding(4).class(visuals::section(palette)));
container(group)
.padding(4)
.class(visuals::surface(palette.card, 14.)),
);
} }
match self.page { match self.page {
@@ -364,11 +414,8 @@ impl App {
) )
.padding([7, 10]), .padding([7, 10]),
); );
body = body.push( body =
container(group) body.push(container(group).padding(4).class(visuals::section(palette)));
.padding(4)
.class(visuals::surface(palette.card, 14.)),
);
} }
body = body.push( body = body.push(
row([]) row([])
@@ -453,7 +500,7 @@ impl App {
container(group) container(group)
.padding(4) .padding(4)
.height(164) .height(164)
.class(visuals::surface(palette.card, 14.)) .class(visuals::section(palette))
.into() .into()
} else { } else {
widget::Space::new().height(164).width(Length::Fill).into() widget::Space::new().height(164).width(Length::Fill).into()
@@ -492,9 +539,7 @@ impl App {
.on_press(Message::DragWindow) .on_press(Message::DragWindow)
.interaction(cosmic::iced::mouse::Interaction::Grab), .interaction(cosmic::iced::mouse::Interaction::Grab),
); );
let mut content = column([]) let mut content = column([]).spacing(16).push(page_heading);
.spacing(if self.page == Page::Bar { 16 } else { 8 })
.push(page_heading);
if self.search.is_empty() && !self.page.subpages().is_empty() { if self.search.is_empty() && !self.page.subpages().is_empty() {
content = content.push( content = content.push(
container( container(
@@ -579,7 +624,18 @@ impl App {
), ),
widget::tooltip::Position::Top, widget::tooltip::Position::Top,
)) ))
.push(widget::Space::new().width(Length::Fill)) .push(
self.label(
if self.saving {
ferese_config::tr!("Saving…").to_owned()
} else {
ferese_config::locale::translate(&self.status)
},
11.,
)
.class(cosmic::theme::Text::Color(palette.muted))
.width(Length::Fill),
)
.push(widget::tooltip( .push(widget::tooltip(
button::custom(visuals::action_icon( button::custom(visuals::action_icon(
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7", "M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
@@ -653,12 +709,12 @@ impl App {
.spacing(6), .spacing(6),
) )
.padding(cosmic::iced::Padding { .padding(cosmic::iced::Padding {
top: 20., top: 24.,
right: 20., right: 24.,
bottom: 8., bottom: 8.,
left: 20., left: 24.,
}) })
.max_width(if self.page == Page::Bar { 1120 } else { 840 }) .max_width(if self.page == Page::Bar { 1120 } else { 1040 })
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill), .height(Length::Fill),
) )
+172 -2
View File
@@ -1021,10 +1021,10 @@ fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() {
let compact = width < 960.; let compact = width < 960.;
assert_eq!(bounds.sidebar.is_none(), compact); assert_eq!(bounds.sidebar.is_none(), compact);
assert_eq!(bounds.toggle.is_some(), compact); assert_eq!(bounds.toggle.is_some(), compact);
assert_eq!(bounds.page.unwrap().x, if compact { 0. } else { 204. }); assert_eq!(bounds.page.unwrap().x, if compact { 0. } else { 208. });
assert_eq!( assert_eq!(
bounds.page.unwrap().width, bounds.page.unwrap().width,
if compact { width } else { width - 204. } if compact { width } else { width - 208. }
); );
} }
let _ = app.update(Message::Sidebar(true)); let _ = app.update(Message::Sidebar(true));
@@ -1084,3 +1084,173 @@ fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() {
); );
assert!(app.pending.is_empty() && !app.saving); assert!(app.pending.is_empty() && !app.saving);
} }
#[test]
fn appearance_cards_reflow_without_overlap_and_keep_every_destination() {
#[derive(Default)]
struct Cards {
bounds: std::collections::HashMap<usize, Rectangle>,
}
impl Operation for Cards {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
for page in Page::ALL {
if id == Some(&widget::Id::new(format!("settings-hub-{}", page as usize))) {
self.bounds.insert(page as usize, bounds);
}
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let app = App::init(
Core::default(),
(
"/unused/overview.kdl".into(),
Snapshot::parse(String::new()),
None,
),
)
.0;
for width in [480., 720., 960., 1280., 1800.] {
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 760.)),
);
let mut cards = Cards::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut cards);
assert_eq!(
cards.bounds.len(),
14,
"{width}: all settings destinations must be reachable"
);
for bounds in cards.bounds.values() {
assert!(
bounds.x >= 0. && bounds.x + bounds.width <= width + 0.5,
"{width}: {bounds:?}"
);
assert!(bounds.width > 200. && bounds.height > 40.);
}
let general = cards.bounds[&(Page::General as usize)];
let complete = cards.bounds[&(Page::Configuration as usize)];
if width <= 720. {
assert!((general.x - complete.x).abs() < 0.5);
assert!(complete.y >= general.y + general.height);
} else {
assert!((general.y - complete.y).abs() < 0.5);
assert!(complete.x >= general.x + general.width + 8.);
}
let rectangles = cards.bounds.values().collect::<Vec<_>>();
for (i, a) in rectangles.iter().enumerate() {
for b in &rectangles[i + 1..] {
assert!(
a.intersection(*b).is_none(),
"{width}: cards overlap: {a:?} {b:?}"
);
}
}
}
}
#[test]
fn sidebar_rows_center_inside_the_full_button_hit_area() {
#[derive(Default)]
struct Centers {
buttons: std::collections::HashMap<usize, Rectangle>,
parts: Vec<(usize, Rectangle)>,
}
impl Operation for Centers {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: &mut dyn cosmic::iced::advanced::widget::operation::Focusable,
) {
for page in Page::ALL {
if id
== Some(&widget::Id::new(format!(
"settings-nav-button-{}",
page as usize
)))
{
self.buttons.insert(page as usize, bounds);
}
}
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
for page in Page::ALL {
for part in ["content", "icon", "label", "marker"] {
if id
== Some(&widget::Id::new(format!(
"settings-nav-{part}-{}",
page as usize
)))
{
self.parts.push((page as usize, bounds));
}
}
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut app = App::init(
Core::default(),
(
"/unused/navigation-centers.kdl".into(),
Snapshot::parse(String::new()),
None,
),
)
.0;
for selected in [Page::Windows, Page::Keyboard, Page::Accessibility] {
app.page = selected;
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1280., 760.)),
);
let mut centers = Centers::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut centers);
assert_eq!(centers.buttons.len(), 15);
assert_eq!(centers.parts.len(), 60);
for (page, part) in &centers.parts {
let button = centers.buttons[page];
assert_eq!(button.height, 36.);
assert!(
((part.y + part.height / 2.) - (button.y + button.height / 2.)).abs() < 0.51,
"page {page}: content {part:?} is not centered in {button:?}"
);
}
}
}
+12 -4
View File
@@ -389,6 +389,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let mut families = ferese_config::families::builtins(); let mut families = ferese_config::families::builtins();
let builtins = families.len();
let mut imported = families[0].clone(); let mut imported = families[0].clone();
imported.id = "sunset".into(); imported.id = "sunset".into();
families.insert(1, imported.clone()); families.insert(1, imported.clone());
@@ -398,9 +399,16 @@ mod tests {
legacy.id = "monokai".into(); legacy.id = "monokai".into();
families.push(legacy); families.push(legacy);
let sections = FamilySections::new(&families, &snapshot); let sections = FamilySections::new(&families, &snapshot);
assert_eq!(sections.custom.as_slice(), &[1, 7]); assert_eq!(sections.custom.as_slice(), &[1, builtins + 1]);
assert_eq!(sections.built_in.as_slice(), &[0, 2, 3, 4, 5, 6, 8]); assert_eq!(
assert_eq!(sections.position(7), Some((true, 1))); sections.built_in.as_slice(),
assert_eq!(sections.position(8), Some((false, 6))); std::iter::once(0)
.chain(2..=builtins)
.chain(std::iter::once(builtins + 2))
.collect::<Vec<_>>()
.as_slice()
);
assert_eq!(sections.position(builtins + 1), Some((true, 1)));
assert_eq!(sections.position(builtins + 2), Some((false, builtins)));
} }
} }
+33 -1
View File
@@ -61,6 +61,38 @@ pub fn panel_select<'a>(
cosmic::widget::container(content).class(surface(ferese_theme::mix(p.card, p.text, 0.035), 7.)) cosmic::widget::container(content).class(surface(ferese_theme::mix(p.card, p.text, 0.035), 7.))
} }
/// Low-contrast section planes replace large filled cards. High contrast
/// preferences strengthen boundaries without changing the section hierarchy.
pub fn section(p: Palette) -> cosmic::theme::Container<'static> {
cosmic::theme::Container::custom(move |_| cosmic::widget::container::Style {
background: Some(ferese_theme::mix(p.background, p.card, 0.42).into()),
border: cosmic::iced::Border {
width: 1.,
color: ferese_theme::mix(
p.background,
p.text,
if p.high_contrast { 0.45 } else { 0.09 },
),
radius: p.radius.min(10.).into(),
shape: cosmic::iced::border::Shape::Continuous,
..Default::default()
},
..Default::default()
})
}
pub fn sidebar(p: Palette) -> cosmic::theme::Container<'static> {
cosmic::theme::Container::custom(move |_| cosmic::widget::container::Style {
background: Some(p.sidebar.into()),
border: cosmic::iced::Border {
width: 1.,
color: ferese_theme::mix(p.sidebar, p.text, if p.high_contrast { 0.45 } else { 0.07 }),
radius: 0.into(),
..Default::default()
},
..Default::default()
})
}
pub fn color(value: &str, fallback: Color) -> Color { pub fn color(value: &str, fallback: Color) -> Color {
ferese_theme::parse_color(value).unwrap_or(fallback) ferese_theme::parse_color(value).unwrap_or(fallback)
} }
@@ -86,7 +118,7 @@ pub fn brand_icon() -> svg_icon::Icon {
cosmic::widget::icon::from_svg_bytes(ferese_theme::icons::APPLICATION) cosmic::widget::icon::from_svg_bytes(ferese_theme::icons::APPLICATION)
.symbolic(false) .symbolic(false)
.icon() .icon()
.size(32) .size(24)
.content_fit(cosmic::iced::ContentFit::Contain) .content_fit(cosmic::iced::ContentFit::Contain)
} }