diff --git a/README.md b/README.md index 5ebc2d2..21fc4da 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ inside your existing Wayland desktop. ## Configure 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** supports the system language, 简体中文 and English and saves the choice as `ui { language "zh-CN"; }` (`auto` and `en` are also supported). It changes diff --git a/crates/ferese-config/locales/zh-CN.json b/crates/ferese-config/locales/zh-CN.json index 3f259c3..7b167d9 100644 --- a/crates/ferese-config/locales/zh-CN.json +++ b/crates/ferese-config/locales/zh-CN.json @@ -567,5 +567,7 @@ "Application icons": "应用图标", "Refresh applications": "刷新应用列表", "Panel status icons": "面板状态图标", - "Install the matching Ferese compositor beside Settings.": "请将匹配版本的 Ferese 主程序与设置程序安装在同一目录。" + "Install the matching Ferese compositor beside Settings.": "请将匹配版本的 Ferese 主程序与设置程序安装在同一目录。", + "Quick access": "快速访问", + "Saving…": "正在保存…" } diff --git a/crates/ferese-config/src/families.rs b/crates/ferese-config/src/families.rs index c6ed147..022627f 100644 --- a/crates/ferese-config/src/families.rs +++ b/crates/ferese-config/src/families.rs @@ -1,13 +1,14 @@ use crate::theme::Appearance; 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-light", "ferese-blue", ), + ("graphite", "Graphite", "graphite-light", "graphite"), ( "catppuccin", "Catppuccin", diff --git a/crates/ferese-config/src/presets.rs b/crates/ferese-config/src/presets.rs index 5993b32..f31f97d 100644 --- a/crates/ferese-config/src/presets.rs +++ b/crates/ferese-config/src/presets.rs @@ -19,7 +19,7 @@ pub struct Preset { pub shadow_opacity: f64, } -pub const PRESETS: [Preset; 12] = [ +pub const PRESETS: [Preset; 14] = [ Preset { appearance: Appearance::Dark, id: "ferese-blue", @@ -38,6 +38,24 @@ pub const PRESETS: [Preset; 12] = [ tint_strength: 0.5, 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 { appearance: Appearance::Dark, id: "catppuccin-mocha", @@ -146,6 +164,24 @@ pub const PRESETS: [Preset; 12] = [ tint_strength: 1.0, 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 { appearance: Appearance::Light, id: "catppuccin-latte", diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs index 8a1b520..3c30b27 100644 --- a/crates/ferese-config/src/theme.rs +++ b/crates/ferese-config/src/theme.rs @@ -1358,8 +1358,8 @@ mod tests { .iter() .filter(|p| p.appearance == Appearance::Light) .collect(); - assert_eq!(dark.len(), 6); - assert_eq!(light.len(), 6); + assert_eq!(dark.len(), crate::families::BUILTINS.len()); + assert_eq!(light.len(), crate::families::BUILTINS.len()); for (dark, light) in dark.iter().zip(light) { assert_eq!(dark.name, light.name); 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.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(); diff --git a/crates/ferese-rime/src/bin/probe.rs b/crates/ferese-rime/src/bin/probe.rs index 1051ac3..0ec8a94 100644 --- a/crates/ferese-rime/src/bin/probe.rs +++ b/crates/ferese-rime/src/bin/probe.rs @@ -107,6 +107,7 @@ fn main() -> Result<(), Box> { } let mut sequences: Vec> = Vec::new(); let mut clicks = Vec::new(); + let mut hovers = Vec::new(); let mut drag = None; let mut extent = (1280, 800); let mut args = std::env::args().skip(1); @@ -119,6 +120,13 @@ fn main() -> Result<(), Box> { } 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" => { let v = parse_numbers(&args.next().ok_or("missing click position")?)?; if v.len() != 2 { @@ -247,12 +255,24 @@ fn main() -> Result<(), Box> { 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 .pointer_manager .as_ref() .ok_or("test pointer is not enabled on this compositor")? .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 { pointer.motion_absolute( started.elapsed().as_millis() as u32, diff --git a/crates/ferese-theme/src/controls.rs b/crates/ferese-theme/src/controls.rs index bc1100d..3888561 100644 --- a/crates/ferese-theme/src/controls.rs +++ b/crates/ferese-theme/src/controls.rs @@ -116,14 +116,24 @@ pub fn settings_input(p: Palette) -> theme::TextInput { let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance { shape: Some(BorderShape::Continuous), 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_width: 1., border_offset: None, border_color: if focused { p.accent } 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), text_color: Some(p.text), @@ -166,25 +176,52 @@ fn styled_button( opacity: f32, focus_visible: bool, ) -> theme::Button { - let style = move |hover: bool, focused: bool| { - let background = if selected { - 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 { + let style = move |hover: bool, focused: bool, pressed: bool| { + let resting = if navigation { p.sidebar } else { - mix(p.card, p.text, 0.035) + mix(p.background, p.card, 0.5) }; - let (background, on) = if selected { - crate::accent_pair(background, p.text) + let selected_strength = if navigation { 0.09 } else { 0.10 }; + let background = if selected && navigation { + mix( + resting, + p.text, + if pressed { + 0.16 + } else if hover { + 0.13 + } else { + 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 { - (background, p.text) + 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; background.a *= if hover && opacity < 1.0 { @@ -193,9 +230,15 @@ fn styled_button( opacity }; - let fill = if selected { + let fill = if selected && !navigation { p.selected_background( - if hover { 0.24 } else { 0.17 }, + if pressed { + 0.20 + } else if hover { + 0.16 + } else { + 0.10 + }, background.a, on, background, @@ -211,11 +254,23 @@ fn styled_button( text_color: Some(on), icon_color: Some(on), 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 { - p.accent + if p.high_contrast { + p.accent + } else { + mix(background, p.accent, 0.40) + } } else if !navigation { - mix(p.card, p.text, 0.18) + mix( + background, + p.text, + if p.high_contrast { 0.50 } else { 0.10 }, + ) } else { Color::TRANSPARENT }, @@ -227,10 +282,17 @@ fn styled_button( }; theme::Button::Custom { - active: Box::new(move |focused, _| style(false, focused)), - hovered: Box::new(move |focused, _| style(true, focused)), - pressed: Box::new(move |focused, _| style(true, focused)), - disabled: Box::new(move |_| style(false, false)), + active: Box::new(move |focused, _| style(false, focused, false)), + hovered: Box::new(move |focused, _| style(true, focused, false)), + pressed: Box::new(move |focused, _| style(true, focused, true)), + 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 { 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] fn hiding_modal_focus_preserves_button_borders_and_materials() { use cosmic::widget::button::Catalog; diff --git a/docs/development/visual-direction.md b/docs/development/visual-direction.md new file mode 100644 index 0000000..9f25d4a --- /dev/null +++ b/docs/development/visual-direction.md @@ -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. diff --git a/shell/ferese-settings/src/all_options.rs b/shell/ferese-settings/src/all_options.rs index b95d23d..9c60e07 100644 --- a/shell/ferese-settings/src/all_options.rs +++ b/shell/ferese-settings/src/all_options.rs @@ -501,43 +501,83 @@ impl App { } Task::none() } - pub fn settings_hub(&self) -> Element<'_, Message> { - let mut out = column([]).spacing(18); + pub fn settings_hub(&self, width: f32) -> Element<'_, Message> { + 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 { - if name == "Appearance" { + if name == "Advanced" { continue; } - out = out.push(self.panel_heading(tr(name), 15.)); - for &p in pages { - out = out.push(self.hub_card(p)); + let pages = if name == "Appearance" { + vec![Page::General, Page::Configuration] + } else { + pages + .iter() + .copied() + .filter(|p| *p != Page::Appearance) + .collect::>() + }; + 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)); + } + section = section.push(cards); } + out = out.push(section); } - column([]) - .spacing(12) - .push(self.hub_card(Page::General)) - .push(out) - .into() + out.into() } fn hub_card(&self, p: Page) -> Element<'static, Message> { let palette = visuals::Palette::from_resolved(&self.resolved.presented); - button::custom( - row([]) - .spacing(12) - .align_y(Alignment::Center) - .push(visuals::icon(p, palette.accent)) - .push( - column([]) - .spacing(3) - .push(self.label(p.title(), 15.)) - .push(self.label(p.subtitle(), 12.)), - ) - .push(self.label(ferese_config::tr!("›"), 20.)), + let content = row([]) + .spacing(12) + .align_y(Alignment::Center) + .push(visuals::icon(p, palette.muted)) + .push( + column([]) + .spacing(4) + .push(self.label(p.title(), 14.).font(cosmic::iced::Font { + weight: cosmic::iced::font::Weight::Medium, + ..self.font + })) + .push( + self.label(p.subtitle(), 12.) + .class(cosmic::theme::Text::Color(palette.muted)), + ) + .width(Length::Fill), + ) + .push(visuals::action_icon("M9 5l7 7-7 7", palette.muted)); + container( + button::custom(content) + .name(p.title()) + .padding(16) + .width(Length::Fill) + .class(visuals::button_style(palette, false)) + .on_press(Message::Page(p)), ) - .name(p.title()) - .padding(14) + .id(cosmic::widget::Id::new(format!( + "settings-hub-{}", + p as usize + ))) .width(Length::Fill) - .class(visuals::button_style(palette, false)) - .on_press(Message::Page(p)) .into() } pub fn all_options_view(&self) -> Element<'_, Message> { diff --git a/shell/ferese-settings/src/form_controls.rs b/shell/ferese-settings/src/form_controls.rs index 68c91a6..9a96b94 100644 --- a/shell/ferese-settings/src/form_controls.rs +++ b/shell/ferese-settings/src/form_controls.rs @@ -248,9 +248,9 @@ impl App { .push(labels) .push(control), ) - .padding([7, 10]) + .padding([10, 12]) .width(Length::Fill) - .class(visuals::surface(palette.card, 0.)) + .class(visuals::surface(cosmic::iced::Color::TRANSPARENT, 0.)) .into() } } diff --git a/shell/ferese-settings/src/pages.rs b/shell/ferese-settings/src/pages.rs index bc04a0c..84688cf 100644 --- a/shell/ferese-settings/src/pages.rs +++ b/shell/ferese-settings/src/pages.rs @@ -8,17 +8,17 @@ pub(super) const SIDEBAR_BREAKPOINT: f32 = 960.; impl App { pub(super) fn page_view(&self) -> Element<'_, Message> { 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) .height(Length::Fill) .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 mut sidebar = column([]) - .spacing(3) + .spacing(4) .push( widget::mouse_area( row([]) @@ -26,7 +26,7 @@ impl App { .align_y(Alignment::Center) .push(visuals::brand_icon()) .push( - self.label(ferese_config::tr!("Ferese"), 16.) + self.label(ferese_config::tr!("Ferese"), 14.) .class(cosmic::theme::Text::Color(palette.text)), ) .width(Length::Fill), @@ -52,37 +52,83 @@ impl App { false, )); } - let mut navigation = column([]).spacing(16); + let mut navigation = column([]).spacing(24); for (title, pages) in Page::NAVIGATION { let mut section = column([]).spacing(3).push( container( - self.label(ferese_config::locale::text(title), 11.) - .class(cosmic::theme::Text::Color(palette.muted)), + self.label( + ferese_config::locale::text(if title == "Appearance" { + "Settings" + } else { + title + }), + 11., + ) + .class(cosmic::theme::Text::Color(palette.muted)), ) .padding([0, 8]), ); for &page in pages { let selected = self.page.navigation_page() == page; + let indicator = container(widget::Space::new().width(2).height(14)) + .id(widget::Id::new(format!( + "settings-nav-marker-{}", + page as usize + ))) + .class(visuals::surface( + if selected { + palette.accent + } else { + cosmic::iced::Color::TRANSPARENT + }, + 1., + )); + let icon = container(visuals::icon( + page, + if selected { + palette.accent + } else { + palette.muted + }, + )) + .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), + ) + .id(widget::Id::new(format!( + "settings-nav-content-{}", + 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( - row([]) - .spacing(8) - .align_y(Alignment::Center) - .push(visuals::icon( - page, - if selected { - palette.accent - } else { - palette.muted - }, - )) - .push(self.label(page.title(), 13.)), + container(content) + .center_y(Length::Fill) + .width(Length::Fill), ) - .width(Length::Fill) - .padding([8, 8]) - .class(visuals::navigation_style(palette, selected)) + .id(widget::Id::new(format!( + "settings-nav-button-{}", + page as usize + ))) .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 })), ); } @@ -90,21 +136,27 @@ impl App { } let sidebar = container(sidebar.push(scrollable(navigation).height(Length::Fill))) .id("settings-sidebar") - .width(if compact { 244 } else { 204 }) + .width(if compact { 244 } else { 208 }) .height(Length::Fill) - .padding([20, 10]) - .class(visuals::surface(palette.sidebar, 0.)); + .padding([24, 12]) + .class(visuals::sidebar(palette)); let mut heading = row([]).align_y(Alignment::Center).spacing(12).push( column([]) .spacing(3) - .push(self.label( - if self.search.is_empty() { - self.page.navigation_page().title() - } else { - "Search" - }, - 20., - )) + .push( + self.label( + if self.search.is_empty() { + self.page.navigation_page().title() + } else { + "Search" + }, + 24., + ) + .font(cosmic::iced::Font { + weight: cosmic::iced::font::Weight::Medium, + ..self.font + }), + ) .push( self.label( if self.search.is_empty() { @@ -207,7 +259,9 @@ impl App { } } else { 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 { body = body.push(self.all_options_view()); @@ -306,11 +360,7 @@ impl App { group = group.push(self.theme_file_picker()); } } - body = body.push( - container(group) - .padding(4) - .class(visuals::surface(palette.card, 14.)), - ); + body = body.push(container(group).padding(4).class(visuals::section(palette))); } match self.page { @@ -364,11 +414,8 @@ impl App { ) .padding([7, 10]), ); - body = body.push( - container(group) - .padding(4) - .class(visuals::surface(palette.card, 14.)), - ); + body = + body.push(container(group).padding(4).class(visuals::section(palette))); } body = body.push( row([]) @@ -453,7 +500,7 @@ impl App { container(group) .padding(4) .height(164) - .class(visuals::surface(palette.card, 14.)) + .class(visuals::section(palette)) .into() } else { widget::Space::new().height(164).width(Length::Fill).into() @@ -492,9 +539,7 @@ impl App { .on_press(Message::DragWindow) .interaction(cosmic::iced::mouse::Interaction::Grab), ); - let mut content = column([]) - .spacing(if self.page == Page::Bar { 16 } else { 8 }) - .push(page_heading); + let mut content = column([]).spacing(16).push(page_heading); if self.search.is_empty() && !self.page.subpages().is_empty() { content = content.push( container( @@ -579,7 +624,18 @@ impl App { ), 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( button::custom(visuals::action_icon( "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), ) .padding(cosmic::iced::Padding { - top: 20., - right: 20., + top: 24., + right: 24., 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) .height(Length::Fill), ) diff --git a/shell/ferese-settings/src/scroll_tests.rs b/shell/ferese-settings/src/scroll_tests.rs index f5b61ac..7709551 100644 --- a/shell/ferese-settings/src/scroll_tests.rs +++ b/shell/ferese-settings/src/scroll_tests.rs @@ -1021,10 +1021,10 @@ fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() { let compact = width < 960.; assert_eq!(bounds.sidebar.is_none(), 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!( bounds.page.unwrap().width, - if compact { width } else { width - 204. } + if compact { width } else { width - 208. } ); } 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); } + +#[test] +fn appearance_cards_reflow_without_overlap_and_keep_every_destination() { + #[derive(Default)] + struct Cards { + bounds: std::collections::HashMap, + } + 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(::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::>(); + 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, + 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(::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 ¢ers.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:?}" + ); + } + } +} diff --git a/shell/ferese-settings/src/theme_controls.rs b/shell/ferese-settings/src/theme_controls.rs index 8dd4ac0..8835ab0 100644 --- a/shell/ferese-settings/src/theme_controls.rs +++ b/shell/ferese-settings/src/theme_controls.rs @@ -389,6 +389,7 @@ mod tests { ) .unwrap(); let mut families = ferese_config::families::builtins(); + let builtins = families.len(); let mut imported = families[0].clone(); imported.id = "sunset".into(); families.insert(1, imported.clone()); @@ -398,9 +399,16 @@ mod tests { legacy.id = "monokai".into(); families.push(legacy); let sections = FamilySections::new(&families, &snapshot); - assert_eq!(sections.custom.as_slice(), &[1, 7]); - assert_eq!(sections.built_in.as_slice(), &[0, 2, 3, 4, 5, 6, 8]); - assert_eq!(sections.position(7), Some((true, 1))); - assert_eq!(sections.position(8), Some((false, 6))); + assert_eq!(sections.custom.as_slice(), &[1, builtins + 1]); + assert_eq!( + sections.built_in.as_slice(), + std::iter::once(0) + .chain(2..=builtins) + .chain(std::iter::once(builtins + 2)) + .collect::>() + .as_slice() + ); + assert_eq!(sections.position(builtins + 1), Some((true, 1))); + assert_eq!(sections.position(builtins + 2), Some((false, builtins))); } } diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index 5cabdfc..48eae6c 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -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.)) } +/// 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 { 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) .symbolic(false) .icon() - .size(32) + .size(24) .content_fit(cosmic::iced::ContentFit::Contain) }