feat(ui): refine Graphite visuals and center navigation content
This commit is contained in:
1 parent
cc3b6f903f
commit
1e34ec47ad
14 files changed
+646
-121
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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…": "正在保存…"
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
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 {
|
} 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;
|
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 {
|
||||||
p.accent
|
if p.high_contrast {
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
section = section.push(cards);
|
||||||
}
|
}
|
||||||
|
out = out.push(section);
|
||||||
}
|
}
|
||||||
column([])
|
out.into()
|
||||||
.spacing(12)
|
|
||||||
.push(self.hub_card(Page::General))
|
|
||||||
.push(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.muted))
|
||||||
.push(visuals::icon(p, palette.accent))
|
.push(
|
||||||
.push(
|
column([])
|
||||||
column([])
|
.spacing(4)
|
||||||
.spacing(3)
|
.push(self.label(p.title(), 14.).font(cosmic::iced::Font {
|
||||||
.push(self.label(p.title(), 15.))
|
weight: cosmic::iced::font::Weight::Medium,
|
||||||
.push(self.label(p.subtitle(), 12.)),
|
..self.font
|
||||||
)
|
}))
|
||||||
.push(self.label(ferese_config::tr!("›"), 20.)),
|
.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())
|
.id(cosmic::widget::Id::new(format!(
|
||||||
.padding(14)
|
"settings-hub-{}",
|
||||||
|
p as usize
|
||||||
|
)))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.class(visuals::button_style(palette, false))
|
|
||||||
.on_press(Message::Page(p))
|
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
pub fn all_options_view(&self) -> Element<'_, Message> {
|
pub fn all_options_view(&self) -> Element<'_, Message> {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,37 +52,83 @@ 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(
|
||||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
ferese_config::locale::text(if title == "Appearance" {
|
||||||
|
"Settings"
|
||||||
|
} else {
|
||||||
|
title
|
||||||
|
}),
|
||||||
|
11.,
|
||||||
|
)
|
||||||
|
.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;
|
||||||
|
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 =
|
||||||
section.push(
|
section.push(
|
||||||
button::custom(
|
button::custom(
|
||||||
row([])
|
container(content)
|
||||||
.spacing(8)
|
.center_y(Length::Fill)
|
||||||
.align_y(Alignment::Center)
|
.width(Length::Fill),
|
||||||
.push(visuals::icon(
|
|
||||||
page,
|
|
||||||
if selected {
|
|
||||||
palette.accent
|
|
||||||
} else {
|
|
||||||
palette.muted
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.push(self.label(page.title(), 13.)),
|
|
||||||
)
|
)
|
||||||
.width(Length::Fill)
|
.id(widget::Id::new(format!(
|
||||||
.padding([8, 8])
|
"settings-nav-button-{}",
|
||||||
.class(visuals::navigation_style(palette, selected))
|
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(
|
||||||
if self.search.is_empty() {
|
self.label(
|
||||||
self.page.navigation_page().title()
|
if self.search.is_empty() {
|
||||||
} else {
|
self.page.navigation_page().title()
|
||||||
"Search"
|
} else {
|
||||||
},
|
"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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 ¢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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user