From 9908dde9f8da87584c11e5ad9ba117544706cc75 Mon Sep 17 00:00:00 2001 From: aria Date: Wed, 28 Oct 2026 04:49:27 +0900 Subject: [PATCH] feat(settings): refine density, group detailed options and add edge maximize gestures --- crates/ferese-config/locales/zh-CN.json | 21 +- crates/ferese-rime/src/bin/probe.rs | 45 ++-- docs/development/visual-direction.md | 77 ++++-- shell/ferese-settings/src/all_options.rs | 14 +- shell/ferese-settings/src/form_controls.rs | 66 +++-- shell/ferese-settings/src/main.rs | 11 +- shell/ferese-settings/src/pages.rs | 268 ++++++++++++--------- shell/ferese-settings/src/schema.rs | 144 +++++++++++ shell/ferese-settings/src/scroll_tests.rs | 131 +++++++++- shell/ferese-settings/src/tray_controls.rs | 2 +- shell/ferese-settings/src/visuals.rs | 4 +- 11 files changed, 588 insertions(+), 195 deletions(-) diff --git a/crates/ferese-config/locales/zh-CN.json b/crates/ferese-config/locales/zh-CN.json index 7b167d9..c8526b4 100644 --- a/crates/ferese-config/locales/zh-CN.json +++ b/crates/ferese-config/locales/zh-CN.json @@ -569,5 +569,24 @@ "Panel status icons": "面板状态图标", "Install the matching Ferese compositor beside Settings.": "请将匹配版本的 Ferese 主程序与设置程序安装在同一目录。", "Quick access": "快速访问", - "Saving…": "正在保存…" + "Saving…": "正在保存…", + "Workspaces and layout": "工作区与布局", + "Spacing and corners": "间距与圆角", + "Focus and window effects": "焦点与窗口效果", + "Keyboard layout": "键盘布局", + "Keyboard repeat": "按键重复", + "Clock and date": "时钟与日期", + "Lock background": "锁屏背景", + "Display power": "显示器电源", + "Colors and accents": "配色与强调色", + "Surface and transparency": "表面与透明度", + "Typography": "字体与排版", + "Interface": "界面", + "Visual accessibility": "视觉辅助", + "Notification behavior": "通知行为", + "Notification timing": "通知时长", + "Wallpaper image": "壁纸图像", + "Shortcut guide": "快捷键指南", + "Custom shortcuts": "自定义快捷键", + "Animations": "动画" } diff --git a/crates/ferese-rime/src/bin/probe.rs b/crates/ferese-rime/src/bin/probe.rs index 0ec8a94..f9d7444 100644 --- a/crates/ferese-rime/src/bin/probe.rs +++ b/crates/ferese-rime/src/bin/probe.rs @@ -127,12 +127,12 @@ fn main() -> Result<(), Box> { } hovers.push((v[0], v[1])); } - "--click" => { + "--click" | "--double-click" => { let v = parse_numbers(&args.next().ok_or("missing click position")?)?; if v.len() != 2 { return Err("invalid click".into()); } - clicks.push((v[0], v[1])); + clicks.push((v[0], v[1], if arg == "--double-click" { 2 } else { 1 })); } "--drag" => { let v = parse_numbers(&args.next().ok_or("missing drag positions")?)?; @@ -273,7 +273,7 @@ fn main() -> Result<(), Box> { queue.roundtrip(&mut state)?; std::thread::sleep(Duration::from_millis(120)); } - for (x, y) in clicks { + for (x, y, count) in clicks { pointer.motion_absolute( started.elapsed().as_millis() as u32, x, @@ -284,22 +284,29 @@ fn main() -> Result<(), Box> { pointer.frame(); queue.roundtrip(&mut state)?; std::thread::sleep(Duration::from_millis(80)); - pointer.button( - started.elapsed().as_millis() as u32, - 0x110, - wl_pointer::ButtonState::Pressed, - ); - pointer.frame(); - queue.roundtrip(&mut state)?; - std::thread::sleep(Duration::from_millis(80)); - pointer.button( - started.elapsed().as_millis() as u32, - 0x110, - wl_pointer::ButtonState::Released, - ); - pointer.frame(); - queue.roundtrip(&mut state)?; - std::thread::sleep(Duration::from_millis(150)); + for index in 0..count { + pointer.button( + started.elapsed().as_millis() as u32, + 0x110, + wl_pointer::ButtonState::Pressed, + ); + pointer.frame(); + queue.roundtrip(&mut state)?; + std::thread::sleep(Duration::from_millis(80)); + pointer.button( + started.elapsed().as_millis() as u32, + 0x110, + wl_pointer::ButtonState::Released, + ); + pointer.frame(); + queue.roundtrip(&mut state)?; + // Consecutive clicks must fit Iced's 300 ms double-click window. + std::thread::sleep(Duration::from_millis(if index + 1 < count { + 80 + } else { + 150 + })); + } } if let Some(v) = drag { pointer.motion_absolute( diff --git a/docs/development/visual-direction.md b/docs/development/visual-direction.md index 78822cb..ddb3f2f 100644 --- a/docs/development/visual-direction.md +++ b/docs/development/visual-direction.md @@ -1,8 +1,8 @@ # Ferese Graphite 视觉方案 -- 文档版本:1.0 +- 文档版本:1.1 - 整理日期:2026-10-28 -- 实现基线:`1e34ec4` +- 视觉基线:`1e34ec4`;本版补充内容密度、详细页分组与窗口交互规则。 - 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。 ## 1. 视觉目标 @@ -15,6 +15,7 @@ 4. **文字分层**:用字号、字重和次级文字颜色区分标题、说明与辅助信息。 5. **克制装饰**:降低光晕、玻璃染色和阴影的存在感,保留必要的层次提示。 6. **状态清晰**:分别表达悬停、按下、选中、键盘焦点和禁用状态。 +7. **紧凑但舒展**:减少重复说明、强制换行和无用途的空白;将空间留给设置行、卡片之间的适度间距。 ## 2. 参考视频分析 @@ -70,14 +71,14 @@ | 角色 | 逻辑字号 | 字重 / 用法 | | --- | ---: | --- | -| 页面标题 | 24 px | Medium | -| 总览卡片标题 | 14 px | Medium | +| 页面标题 | 16 px | Medium,比分组标题大 2 px | +| 分组标题,含侧栏和详细页 | 14 px | Bold | +| 总览卡片标题 | 14 px | Regular | | 侧栏导航、普通字段名称 | 13 px | Regular | -| 总览分组标题 | 12 px | Semibold、次级色 | -| 页面说明、卡片说明 | 12 px | Regular、次级色 | -| 侧栏分组、字段辅助说明、保存状态 | 11 px | Regular、次级色 | +| 字段辅助说明、卡片说明 | 12 px | Regular、次级色 | +| 保存状态与辅助提示 | 11–12 px | Regular、次级色 | -卡片高度跟随文字换行,适应中文和英文。标题、说明与操作控件各自保留清楚的空间。 +字段名称与短说明在同一段内自然排版,仅在可用宽度不足时换行;说明使用次级色区分,不另起固定的一行。页面标题下不重复展示泛化副标题。卡片高度跟随实际内容,适应中英文;细则与控件文字均使用标准字重。 ### 4.2 图标 @@ -95,12 +96,17 @@ | 项目 | 规格 | | --- | --- | -| 页面上边距、左右边距 | 24 px | -| 总览分组间距 | 24 px | -| 同组卡片间距 | 12 px | +| 页面上 / 下边距 | 16 / 12 px | +| 页面左右边距 | 20 px | +| 总览及详细页分组间距 | 20 px | +| 分组标题与设置容器间距 | 10 px | +| 同组卡片间距 | 16 px | | 卡片内部边距 | 16 px | | 卡片图标与文字间距 | 12 px | | 卡片标题与说明间距 | 4 px | +| 设置行内边距 | 上下 12 px、左右 14 px | +| 设置行标签与控件间距 | 12 px | +| 同组设置行间距 | 4 px | | 侧栏宽度 | 208 px | | 折叠侧栏抽屉宽度 | 244 px | | 侧栏内边距 | 上下 24 px、左右 12 px | @@ -132,12 +138,37 @@ Appearance 作为全部设置的入口,内容按实际功能分组: - 总览**可用内容宽度 ≥ 680 逻辑像素**:双列。 - 总览**可用内容宽度 < 680 逻辑像素**:单列。 - **窗口宽度 < 960 逻辑像素**:侧栏收起,通过按钮打开导航抽屉。 -- 常规页面内容框最大宽度为 1040 px;面板编辑页为 1120 px。 +- 页面内容框最大宽度为 1280 px,包含左右各 20 px 的留白;更宽窗口居中显示内容框。 - 卡片高度由内容决定,说明换行后保持完整显示。 - 超出视口的内容通过滚动访问,保存状态与撤销、重新加载操作位于页脚。 卡片列数依据正文可用宽度计算,侧栏折叠依据窗口宽度计算,二者使用独立阈值。 +### 6.1 详细页分组 + +设置进入详细页后,继续按功能组织,分组标题采用 14 px 粗体,设置名称与细则采用标准字重。分组随实际字段出现,避免空组。 + +| 详细页 | 主要分组 | +| --- | --- | +| 常规 | 界面 | +| 窗口 | 工作区与布局、间距与圆角、焦点与窗口效果 | +| 键盘与鼠标 | 键盘布局、按键重复、鼠标、触控板 | +| 主题 | 配色与强调色、表面与透明度、字体与排版;自动外观按需展开 | +| 辅助功能 | 视觉辅助、动画 | +| 通知 | 通知行为、通知时长 | +| 锁屏 | 时钟与日期、锁屏背景、显示器电源,按支持的配置出现 | +| 托盘图标 | 应用托盘、应用图标、面板状态图标 | +| 快捷键 | 快捷键指南、自定义快捷键 | + +显示器、连接和面板编辑器延续各自已有的功能分区;登录启动项按应用组织。窗口效果预览放入对应分组,高度收至 84 px,避免独占页首的大块空间。 + +### 6.2 留白与窗口操作 + +- 双击页面顶部、底部、左右边缘及超宽窗口外侧的留白,切换最大化与还原;标题区域和侧栏未被控件占用的区域同样支持。 +- 在上述区域移动指针超过拖动阈值后开始拖窗,单击不立即拖动。 +- 设置内容与控件遵循自身交互,双击字段标签不会触发最大化。 +- 窄窗口导航抽屉通过点击右侧空白或按 Esc 关闭;移除独立的“关闭导航”按钮。 + ## 7. 组件状态与对齐 | 状态 | 视觉表达 | @@ -170,7 +201,7 @@ Appearance 作为全部设置的入口,内容按实际功能分组: 保留现有动画与“减少动画”配置。动效服务于状态变化与空间关系,悬停和按下反馈保持简洁。 -参考视频没有提供可验证的交互时长或缓动参数,因此本轮视觉方案没有新增动画时长、缓动曲线或输入手势。 +参考视频没有提供可验证的交互时长或缓动参数,因此本轮视觉方案没有新增动画时长或缓动曲线。窗口操作按 6.2 节执行。 ## 9. 配置示例 @@ -196,24 +227,28 @@ theme { - 导航文字、图标和指示条相对完整按钮区域居中。 - 窄窗口单列、宽窗口双列,所有设置入口均可到达。 - 中文与英文换行后,卡片内容完整且互不重叠。 +- 页面标题比分组标题大 2 px;分组为粗体,设置名称和细则为标准字体。 +- 详细页按功能分组,短说明自然流动,不保留重复副标题和大型装饰留白。 +- 双击边缘留白能最大化和还原,设置控件不会误触发;导航抽屉保留外侧点击和 Esc 关闭。 - 原生 Rime 候选保留横向排列、选字、翻页与主题跟随。 ### 已完成的验证 -基于实现基线 `1e34ec4`: +本版密度与交互修订: | 验证项 | 结果 | | --- | --- | -| 设置测试 | 115 项通过 | -| 共享主题与控件测试 | 32 项通过 | -| Shell 测试 | 247 项通过 | -| 配置测试 | 46 项通过 | +| 设置测试 | 117 项通过,9 项可选检查默认跳过 | +| 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 | +| 配置测试 | 46 项单元测试及 11 项集成测试通过 | | 布局检查 | 覆盖 480–1800 px 窗口宽度 | | 原生预览 | 已检查深浅外观及 631 px 窄窗口 | | 导航居中 | 全部 15 项;包含“交互”分组和实际悬停检查 | | Rime | 原生输入、候选、翻页、主题变化与进程恢复通过 | +| 边缘双击 | 原生预览内 1272 px 最大化与 631 px 还原双向切换通过,配置内容不变 | +| 导航抽屉 | 已移除关闭按钮,外侧点击和 Esc 关闭均检查通过 | -可选性能分析测试未计入上述通过项。 +共享主题 32 项、Shell 247 项的验证记录来自前版基线 `1e34ec4`。本版未改动这两部分的实现。 ### 实现位置 @@ -223,8 +258,10 @@ theme { | 主题族注册 | `crates/ferese-config/src/families.rs` | | 共享按钮、导航、输入框状态 | `crates/ferese-theme/src/controls.rs` | | 页面结构与导航居中 | `shell/ferese-settings/src/pages.rs` | +| 详细页字段分组 | `shell/ferese-settings/src/schema.rs` | +| 行内说明与标题字重 | `shell/ferese-settings/src/form_controls.rs` | | 总览卡片与双列布局 | `shell/ferese-settings/src/all_options.rs` | | 分区表面与图标 | `shell/ferese-settings/src/visuals.rs` | | 布局及对齐回归检查 | `shell/ferese-settings/src/scroll_tests.rs` | -本机参考帧、深浅预览、导航悬停截图及验收记录保存在 `.artifacts/visual-reference-20261028/`,这些验收资料不随仓库分发。 +前版参考帧及导航居中记录保存在 `.artifacts/visual-reference-20261028/`;本版详细页、导航抽屉与窗口双击记录保存在 `.artifacts/settings-density-20261028/`。这些本机验收资料不随仓库分发。 diff --git a/shell/ferese-settings/src/all_options.rs b/shell/ferese-settings/src/all_options.rs index 9c60e07..ce5f87e 100644 --- a/shell/ferese-settings/src/all_options.rs +++ b/shell/ferese-settings/src/all_options.rs @@ -506,7 +506,7 @@ impl App { } fn hub_layout(&self, columns: usize) -> Element<'_, Message> { let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let mut out = column([]).spacing(24); + let mut out = column([]).spacing(20); for (name, pages) in Page::NAVIGATION { if name == "Advanced" { continue; @@ -527,12 +527,12 @@ impl App { } else { name }), - 12., + visuals::SECTION_TITLE_SIZE, ) .class(cosmic::theme::Text::Color(palette.muted)), ); for pair in pages.chunks(columns) { - let mut cards = row([]).spacing(12); + let mut cards = row([]).spacing(16); for &page in pair { cards = cards.push(self.hub_card(page)); } @@ -555,7 +555,7 @@ impl App { column([]) .spacing(4) .push(self.label(p.title(), 14.).font(cosmic::iced::Font { - weight: cosmic::iced::font::Weight::Medium, + weight: cosmic::iced::font::Weight::Normal, ..self.font })) .push( @@ -928,9 +928,9 @@ impl App { container(out).width(Length::Fill).padding([5, 2]).into() } pub fn tray_status_controls(&self) -> Element<'_, Message> { - let mut out = column([]).spacing(8).push(self.note(t( - "Status icons can also be hidden individually in Panel & Shell.", - ))); + let mut out = column([]) + .spacing(12) + .push(self.panel_heading(t("Panel status icons"), 14.)); let panels: Vec = self .draft .item("panels") diff --git a/shell/ferese-settings/src/form_controls.rs b/shell/ferese-settings/src/form_controls.rs index 9a96b94..ce51198 100644 --- a/shell/ferese-settings/src/form_controls.rs +++ b/shell/ferese-settings/src/form_controls.rs @@ -1,5 +1,3 @@ -use cosmic::widget::column; - use super::{ Alignment, App, Element, Field, Kind, Length, Message, button, container, row, set, slider, text_input, visuals, widget, @@ -14,15 +12,26 @@ impl App { ferese_theme::text(text, self.font).size(size) } - pub(super) fn panel_heading( + pub(super) fn window_chrome<'a>( &self, - text: impl Into> + 'static, + content: impl Into> + 'a, + ) -> Element<'a, Message> { + widget::mouse_area(content) + .on_drag(Message::DragWindow) + .on_double_click(Message::ToggleMaximize) + .into() + } + + pub(super) fn panel_heading<'a>( + &self, + text: impl Into> + 'a, size: f32, - ) -> widget::Text<'static, cosmic::Theme, cosmic::Renderer> { - self.label(text, size).font(cosmic::iced::Font { - weight: cosmic::iced::font::Weight::Semibold, - ..self.font - }) + ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { + self.label(text, size.min(visuals::SECTION_TITLE_SIZE)) + .font(cosmic::iced::Font { + weight: cosmic::iced::font::Weight::Bold, + ..self.font + }) } pub(super) fn note(&self, text: &str) -> Element<'static, Message> { @@ -121,16 +130,37 @@ impl App { } } let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let mut labels = column([]) - .spacing(2) - .push(self.label(ferese_config::locale::translate(&field.label), 13.)); + let regular = cosmic::iced::Font { + weight: cosmic::iced::font::Weight::Normal, + ..self.font + }; + let mut spans = vec![ + cosmic::iced::widget::span::<(), _>(ferese_config::locale::translate(&field.label)) + .font(regular) + .size(13.) + .color(palette.text), + ]; if !field.description.is_empty() { - labels = labels.push( - self.label(ferese_config::locale::translate(&field.description), 11.) - .class(cosmic::theme::Text::Color(palette.muted)), + spans.push( + cosmic::iced::widget::span(format!( + " {}", + ferese_config::locale::translate(&field.description) + )) + .font(regular) + .size(12.) + .color(palette.muted), ); } - let labels = labels.width(Length::Fill); + // Short explanations flow beside the label. The paragraph wraps only + // when the remaining width actually requires another line. + let labels = container( + cosmic::iced::widget::rich_text(spans) + .font(regular) + .size(13.) + .width(Length::Fill), + ) + .id(widget::Id::new(format!("setting-label-{}", field.path))) + .width(Length::Fill); let path = field.path.clone(); let control: Element<'static, Message> = match field.kind.clone() { Kind::Toggle(default) => { @@ -243,12 +273,12 @@ impl App { }; container( row([]) - .spacing(10) + .spacing(12) .align_y(Alignment::Center) .push(labels) .push(control), ) - .padding([10, 12]) + .padding([12, 14]) .width(Length::Fill) .class(visuals::surface(cosmic::iced::Color::TRANSPARENT, 0.)) .into() diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index ed4b9cb..9d729cd 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -128,6 +128,7 @@ enum Message { RefreshRate(String, displays::Mode, bool), ThemeChanged(Box), DragWindow, + ToggleMaximize, Page(Page), PagePresented(Page), RowVisibility(&'static str, usize, bool), @@ -378,6 +379,7 @@ impl cosmic::Application for App { Message::RefreshTrayApps => return self.load_tray_apps(), Message::Options(input) => return self.options_input(input), Message::DragWindow => return self.core.drag(None), + Message::ToggleMaximize => return self.core.toggle_maximize(None), Message::ReadRenderScale(id) => { return cosmic::iced::window::scale_factor(id) .map(|scale| cosmic::Action::App(Message::RenderScale(scale))); @@ -1268,9 +1270,11 @@ mod tests { let mut app = app(); app.core.set_main_window_id(None); assert_eq!(app.update(Message::DragWindow).units(), 0); + assert_eq!(app.update(Message::ToggleMaximize).units(), 0); app.core .set_main_window_id(Some(cosmic::iced::window::Id::unique())); assert_eq!(app.update(Message::DragWindow).units(), 1); + assert_eq!(app.update(Message::ToggleMaximize).units(), 1); } fn shortcuts(count: usize) -> App { @@ -1636,10 +1640,9 @@ mod tests { app.visible_rows .extend((0..100).map(|index| ("bindings", index))); let eager = nodes(&app); - assert!( - eager > visible + 96 * 10, - "visible={visible}, eager={eager}" - ); + // Mounting only visible rows should cut the widget tree substantially, + // independently of how many wrappers a field label uses. + assert!(eager > visible * 2, "visible={visible}, eager={eager}"); } #[test] diff --git a/shell/ferese-settings/src/pages.rs b/shell/ferese-settings/src/pages.rs index 84688cf..563a4fa 100644 --- a/shell/ferese-settings/src/pages.rs +++ b/shell/ferese-settings/src/pages.rs @@ -17,7 +17,7 @@ impl App { 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([]) + let sidebar = column([]) .spacing(4) .push( widget::mouse_area( @@ -31,7 +31,8 @@ impl App { ) .width(Length::Fill), ) - .on_press(Message::DragWindow) + .on_drag(Message::DragWindow) + .on_double_click(Message::ToggleMaximize) .interaction(cosmic::iced::mouse::Interaction::Grab), ) .push(widget::Space::new().height(14)) @@ -44,15 +45,7 @@ impl App { .size(12), ) .push(widget::Space::new().height(8)); - if compact { - sidebar = sidebar.push(self.settings_button( - ferese_config::tr!("Close navigation"), - "M6 6l12 12 M6 18L18 6", - Some(Message::Sidebar(false)), - false, - )); - } - let mut navigation = column([]).spacing(24); + let mut navigation = column([]).spacing(20); for (title, pages) in Page::NAVIGATION { let mut section = column([]).spacing(3).push( container( @@ -62,8 +55,12 @@ impl App { } else { title }), - 11., + visuals::SECTION_TITLE_SIZE, ) + .font(cosmic::iced::Font { + weight: cosmic::iced::font::Weight::Bold, + ..self.font + }) .class(cosmic::theme::Text::Color(palette.muted)), ) .padding([0, 8]), @@ -140,35 +137,21 @@ impl App { .height(Length::Fill) .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" - }, - 24., - ) - .font(cosmic::iced::Font { - weight: cosmic::iced::font::Weight::Medium, - ..self.font - }), - ) - .push( - self.label( - if self.search.is_empty() { - self.page.subtitle() - } else { - "Find a setting for your desktop." - }, - 12., - ) - .class(cosmic::theme::Text::Color(palette.muted)), - ) - .width(Length::Fill), + let sidebar = self.window_chrome(sidebar); + let mut heading = row([]).align_y(Alignment::Center).spacing(16).push( + self.label( + if self.search.is_empty() { + self.page.navigation_page().title() + } else { + ferese_config::tr!("Search") + }, + visuals::PAGE_TITLE_SIZE, + ) + .font(cosmic::iced::Font { + weight: cosmic::iced::font::Weight::Medium, + ..self.font + }) + .width(Length::Fill), ); if self.page == Page::Theme && self.search.is_empty() { let automatic = self.draft.string("theme.mode", "dark") == "auto"; @@ -223,7 +206,7 @@ impl App { )); } - let mut body = column([]).spacing(6); + let mut body = column([]).spacing(20); if !self.search.is_empty() { let pages = || { @@ -260,23 +243,12 @@ impl App { } else { if self.page == Page::Appearance { body = body.push( - self.settings_hub((width - if compact { 0. } else { 208. }).min(1040.) - 62.), + self.settings_hub((width - if compact { 0. } else { 208. }).min(1280.) - 54.), ); } if self.page == Page::Configuration { body = body.push(self.all_options_view()); } - if self.page == Page::Tray { - body = body - .push(self.tray_application_controls()) - .push(self.tray_status_controls()); - } - if self.page == Page::Windows { - body = body.push(visuals::preview_resolved( - &self.draft, - &self.resolved.presented, - )); - } if self.page == Page::Theme { let split = visuals::split(&self.draft); if split { @@ -315,55 +287,84 @@ impl App { schema::fields(self.page) }; - if !fields.is_empty() { - let mut group = column([]).spacing(1); - if self.page == Page::Theme && self.auto_details { - group = group.push(self.auto_controls()); - } - for field in fields { - if self.page == Page::Theme - && (field.path == "theme.mode" + let fields = fields + .into_iter() + .filter(|field| { + self.page != Page::Theme + || !(field.path == "theme.mode" || field.path.starts_with("theme.schedule.") || field.path.ends_with(".file")) - { - continue; - } - group = group.push(self.field(field)); + }) + .collect(); + for group in schema::grouped_fields(self.page, fields) { + let title = group.title; + let mut rows = column([]).spacing(4); + if self.page == Page::Windows && title == "Focus and window effects" { + rows = rows.push(visuals::preview_resolved( + &self.draft, + &self.resolved.presented, + )); } - if self.page == Page::Theme { - group = group.push( - button::custom( - row([]) - .spacing(8) - .align_y(Alignment::Center) - .push(self.label(ferese_config::tr!("Advanced customization"), 13.)) - .push(visuals::action_icon( - if self.advanced_theme { - "M6 15l6-6 6 6" - } else { - "M6 9l6 6 6-6" - }, - palette.muted, - )), - ) - .name(if self.advanced_theme { - "Collapse advanced customization" - } else { - "Expand advanced customization" - }) - .padding([7, 10]) - .width(Length::Fill) - .class(visuals::button_style(palette, false)) - .on_press(Message::AdvancedTheme(!self.advanced_theme)), + for field in group.fields { + rows = rows.push(self.field(field)); + } + body = body.push( + column([]) + .spacing(10) + .push(self.panel_heading( + ferese_config::locale::text(title), + visuals::SECTION_TITLE_SIZE, + )) + .push(container(rows).padding(4).class(visuals::section(palette))), + ); + } + if self.page == Page::Theme { + if self.auto_details { + body = body.push( + column([]) + .spacing(10) + .push( + self.panel_heading(ferese_config::tr!("Automatic appearance"), 14.), + ) + .push(self.auto_controls()), ); - if self.advanced_theme { - group = group.push(self.theme_file_picker()); - } } - body = body.push(container(group).padding(4).class(visuals::section(palette))); + body = body.push( + button::custom( + row([]) + .spacing(10) + .align_y(Alignment::Center) + .push(self.label(ferese_config::tr!("Advanced customization"), 13.)) + .push(visuals::action_icon( + if self.advanced_theme { + "M6 15l6-6 6 6" + } else { + "M6 9l6 6 6-6" + }, + palette.muted, + )), + ) + .name(if self.advanced_theme { + "Collapse advanced customization" + } else { + "Expand advanced customization" + }) + .padding([8, 12]) + .width(Length::Fill) + .class(visuals::button_style(palette, false)) + .on_press(Message::AdvancedTheme(!self.advanced_theme)), + ); + if self.advanced_theme { + body = body.push(self.theme_file_picker()); + } } match self.page { + Page::Tray => { + body = body + .push(self.tray_application_controls()) + .push(self.tray_status_controls()) + } Page::Connections => body = body.push(self.connections_view()), Page::LockScreen => { body = body.push(self.note(ferese_config::tr!("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens."))); @@ -443,6 +444,8 @@ impl App { ); } Page::Shortcuts => { + body = + body.push(self.panel_heading(ferese_config::tr!("Custom shortcuts"), 14.)); body = body.push(self.note(ferese_config::tr!("Keyboard, mouse and swipe bindings share the same actions. Mouse shortcuts can use MouseLeft, MouseRight, MouseMiddle, MouseBack, MouseForward or WheelUp/Down/Left/Right with modifiers. Use pointer-move or pointer-resize for dragging. Swipes use Swipe3Up, Swipe3Down, Swipe3Left or Swipe3Right (also supports 4 or 5 fingers). Actions such as toggle-overview and none need no argument."))); let mut gestures = row([]).spacing(6); for (keys, label) in [ @@ -536,7 +539,8 @@ impl App { } page_heading = page_heading.push( widget::mouse_area(heading.width(Length::Fill)) - .on_press(Message::DragWindow) + .on_drag(Message::DragWindow) + .on_double_click(Message::ToggleMaximize) .interaction(cosmic::iced::mouse::Interaction::Grab), ); let mut content = column([]).spacing(16).push(page_heading); @@ -700,28 +704,64 @@ impl App { } else { sidebar.into() }; + let body_width = (width - if compact { 0. } else { 208. }).max(0.); + let outer_gutter = ((body_width - 1280.) / 2.).max(0.); + let blank = |id: &'static str, w: Length, h: Length| { + self.window_chrome(container(widget::Space::new().width(w).height(h)).id(id)) + }; + let frame = column([]) + .push(blank( + "settings-top-blank", + Length::Fill, + Length::Fixed(16.), + )) + .push( + row([]) + .push(blank( + "settings-left-blank", + Length::Fixed(20.), + Length::Fill, + )) + .push( + column([]) + .push(content.height(Length::Fill)) + .push(footer) + .spacing(8) + .width(Length::Fill) + .height(Length::Fill), + ) + .push(blank( + "settings-right-blank", + Length::Fixed(20.), + Length::Fill, + )) + .height(Length::Fill), + ) + .push(blank( + "settings-bottom-blank", + Length::Fill, + Length::Fixed(12.), + )) + .width(Length::Fill) + .height(Length::Fill); let view = row([]).push(sidebar).push( container( - container( - column([]) - .push(content.height(Length::Fill)) - .push(footer) - .spacing(6), - ) - .padding(cosmic::iced::Padding { - top: 24., - right: 24., - bottom: 8., - left: 24., - }) - .max_width(if self.page == Page::Bar { 1120 } else { 1040 }) - .width(Length::Fill) - .height(Length::Fill), + row([]) + .push(blank( + "settings-outer-left-blank", + Length::Fixed(outer_gutter), + Length::Fill, + )) + .push(frame) + .push(blank( + "settings-outer-right-blank", + Length::Fixed(outer_gutter), + Length::Fill, + )), ) - .width(Length::Fill) - .center_x(Length::Fill) - .height(Length::Fill) .id("settings-page") + .width(Length::Fill) + .height(Length::Fill) .class(visuals::surface(palette.background, 0.)), ); // Keep the page mounted and full-width while compact navigation is open. diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 7ea9957..eb5feec 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -1014,3 +1014,147 @@ pub fn fields(page: Page) -> Vec { | Page::Connections => vec![], } } + +pub struct FieldGroup { + pub title: &'static str, + pub fields: Vec, +} + +/// Semantic groups, preserving every field and the declaration order in each group. +pub fn grouped_fields(page: Page, fields: Vec) -> Vec { + let mut groups: Vec = Vec::new(); + for field in fields { + let p = field.path.as_str(); + let title = match page { + Page::General => "Interface", + Page::Windows => { + if p.starts_with("workspaces.") || p == "layout.mode" || p.starts_with("scrolling.") + { + "Workspaces and layout" + } else if p == "layout.inner_gap" + || p == "layout.outer_gap" + || p == "theme.geometry.window_radius" + { + "Spacing and corners" + } else { + "Focus and window effects" + } + } + Page::Keyboard => { + if p.starts_with("input.xkb_") { + "Keyboard layout" + } else if p.starts_with("input.repeat_") { + "Keyboard repeat" + } else if p.starts_with("input.mouse.") { + "Mouse" + } else { + "Touchpad" + } + } + Page::LockScreen => { + if p.starts_with("lock_screen.background_") { + "Lock background" + } else if p.contains("after_seconds") { + "Display power" + } else { + "Clock and date" + } + } + Page::Theme => { + if p.ends_with(".file") { + "Theme overrides" + } else if p == "theme.mode" || p.starts_with("theme.schedule.") { + "Automatic appearance" + } else if p.starts_with("theme.typography.") { + "Typography" + } else if p.starts_with("theme.material.") { + "Surface and transparency" + } else { + "Colors and accents" + } + } + Page::Wallpaper => "Wallpaper image", + Page::Notifications => { + if p.ends_with("timeout_ms") { + "Notification timing" + } else { + "Notification behavior" + } + } + Page::Accessibility => { + if p.starts_with("animations.") { + "Motion" + } else { + "Visual accessibility" + } + } + Page::Motion => { + if p.starts_with("animations.") { + "Animations" + } else { + "Focus transition" + } + } + Page::Shortcuts => "Shortcut guide", + Page::Tray => "Application tray", + _ => "General", + }; + if let Some(group) = groups.iter_mut().find(|group| group.title == title) { + group.fields.push(field) + } else { + groups.push(FieldGroup { + title, + fields: vec![field], + }); + } + } + groups +} + +#[cfg(test)] +mod group_tests { + use super::*; + #[test] + fn semantic_groups_keep_every_field_once_and_separate_input_devices() { + for page in Page::ALL { + let mut expected = fields(page).into_iter().map(|f| f.path).collect::>(); + let groups = grouped_fields(page, fields(page)); + assert!(groups.iter().all(|g| !g.fields.is_empty())); + let mut actual = groups + .into_iter() + .flat_map(|g| g.fields.into_iter().map(|f| f.path)) + .collect::>(); + expected.sort(); + actual.sort(); + assert_eq!(actual, expected, "{page:?}"); + } + let keyboard = grouped_fields(Page::Keyboard, fields(Page::Keyboard)); + assert!( + keyboard + .iter() + .find(|g| g.title == "Mouse") + .unwrap() + .fields + .iter() + .all(|f| f.path.starts_with("input.mouse.")) + ); + assert!( + keyboard + .iter() + .find(|g| g.title == "Touchpad") + .unwrap() + .fields + .iter() + .all(|f| f.path.starts_with("input.touchpad.")) + ); + let windows = grouped_fields(Page::Windows, fields(Page::Windows)); + assert_eq!( + windows.iter().map(|g| g.title).collect::>(), + vec![ + "Workspaces and layout", + "Spacing and corners", + "Focus and window effects" + ] + ); + } +} diff --git a/shell/ferese-settings/src/scroll_tests.rs b/shell/ferese-settings/src/scroll_tests.rs index 7709551..f230e70 100644 --- a/shell/ferese-settings/src/scroll_tests.rs +++ b/shell/ferese-settings/src/scroll_tests.rs @@ -133,15 +133,16 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() { fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) { children(self); } - fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) { - if matches!( - text, - "Shell corner radius" - | "Shell opacity" - | "Background blur" - | "Interface font" - | "Focus border" - ) { + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if [ + "theme.accent", + "theme.focus_ring.style", + "theme.material.blur_radius", + "theme.typography.font_family", + ] + .iter() + .any(|path| id == Some(&widget::Id::new(format!("setting-label-{path}")))) + { self.labels.push(bounds); } } @@ -187,7 +188,7 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() { ), ) .0; - app.page = Page::Appearance; + app.page = Page::Theme; let theme = app.native_palette.native_theme(); let mut tree: Option = None; let mut previous = 0.; @@ -1254,3 +1255,113 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() { } } } + +#[test] +fn double_clicking_blank_edges_toggles_maximize_without_changing_settings() { + #[derive(Default)] + struct Blank { + bounds: Option, + label: Option, + } + impl Operation for Blank { + fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) { + children(self); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if id == Some(&widget::Id::new("settings-right-blank")) { + self.bounds = Some(bounds); + } + if id == Some(&widget::Id::new("setting-label-ui.language")) { + self.label = Some(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/edge-gesture.kdl".into(), + Snapshot::parse(String::new()), + Some(InitialPage::Settings(Page::General)), + ), + ) + .0; + let before = app.draft.source.clone(); + let mut view = app.page_view(); + let mut tree = Tree::new(&view); + let viewport = Rectangle::with_size(Size::new(1280., 760.)); + let node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, viewport.size()), + ); + let mut blank = Blank::default(); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &renderer, &mut blank); + let bounds = blank.bounds.unwrap(); + let cursor = mouse::Cursor::Available(bounds.center()); + let mut messages = Vec::new(); + for _ in 0..2 { + for event in [ + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + view.as_widget_mut().update( + &mut tree, + &Event::Mouse(event), + Layout::new(&node), + cursor, + &renderer, + &mut clipboard::Null, + &mut Shell::new(&mut messages), + &viewport, + ); + } + } + assert_eq!( + messages + .iter() + .filter(|m| matches!(m, Message::ToggleMaximize)) + .count(), + 1 + ); + assert!( + !messages + .iter() + .any(|m| matches!(m, Message::Change(_) | Message::DragWindow)) + ); + assert_eq!(app.draft.source, before); + messages.clear(); + let cursor = mouse::Cursor::Available(blank.label.unwrap().center()); + for _ in 0..2 { + for event in [ + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + view.as_widget_mut().update( + &mut tree, + &Event::Mouse(event), + Layout::new(&node), + cursor, + &renderer, + &mut clipboard::Null, + &mut Shell::new(&mut messages), + &viewport, + ); + } + } + assert!( + messages.is_empty(), + "field labels must not act as window chrome" + ); +} diff --git a/shell/ferese-settings/src/tray_controls.rs b/shell/ferese-settings/src/tray_controls.rs index d6ffbf7..b401738 100644 --- a/shell/ferese-settings/src/tray_controls.rs +++ b/shell/ferese-settings/src/tray_controls.rs @@ -73,7 +73,7 @@ impl App { } } let mut out = column([]) - .spacing(8) + .spacing(12) .push(self.panel_heading(t("Application icons"), 15.)); for app in apps { let visible = !hidden.contains(&app.id); diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index 48eae6c..8b98b10 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -2,6 +2,8 @@ use cosmic::Element; use cosmic::iced::{Color, Length}; use cosmic::widget::icon as svg_icon; pub use ferese_theme::Palette; +pub const PAGE_TITLE_SIZE: f32 = 16.; +pub const SECTION_TITLE_SIZE: f32 = 14.; pub use ferese_theme::controls::{ button_style, navigation_style, settings_input as input_style, surface, }; @@ -358,7 +360,7 @@ fn preview( .symbolic(false) .icon() .width(Length::Fill) - .height(Length::Fixed(132.)) + .height(Length::Fixed(84.)) .content_fit(cosmic::iced::ContentFit::Contain) .into() }