feat(settings): refine density, group detailed options and add edge maximize gestures

This commit is contained in:
aria committed 2026-10-28 04:49:27 +09:00
1 parent d7e122711a
commit 9908dde9f8
11 files changed
+533 -140

No files matched your search

+20 -1
View File
@@ -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": "动画"
}
+11 -4
View File
@@ -127,12 +127,12 @@ fn main() -> Result<(), Box<dyn Error>> {
}
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<dyn Error>> {
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,6 +284,7 @@ fn main() -> Result<(), Box<dyn Error>> {
pointer.frame();
queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(80));
for index in 0..count {
pointer.button(
started.elapsed().as_millis() as u32,
0x110,
@@ -299,7 +300,13 @@ fn main() -> Result<(), Box<dyn Error>> {
);
pointer.frame();
queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(150));
// 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(
+57 -20
View File
@@ -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/`。这些本机验收资料不随仓库分发。
+7 -7
View File
@@ -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<ferese_panel::Panel> = self
.draft
.item("panels")
+46 -16
View File
@@ -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,13 +12,24 @@ 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<std::borrow::Cow<'static, str>> + 'static,
content: impl Into<Element<'a, Message>> + '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<std::borrow::Cow<'a, str>> + 'a,
size: f32,
) -> widget::Text<'static, cosmic::Theme, cosmic::Renderer> {
self.label(text, size).font(cosmic::iced::Font {
weight: cosmic::iced::font::Weight::Semibold,
) -> 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
})
}
@@ -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()
+7 -4
View File
@@ -128,6 +128,7 @@ enum Message {
RefreshRate(String, displays::Mode, bool),
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
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]
+116 -76
View File
@@ -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,34 +137,20 @@ 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(
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 {
"Search"
ferese_config::tr!("Search")
},
24.,
visuals::PAGE_TITLE_SIZE,
)
.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),
);
if self.page == Page::Theme && self.search.is_empty() {
@@ -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,26 +287,52 @@ 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;
})
.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,
));
}
group = group.push(self.field(field));
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 {
group = group.push(
if self.auto_details {
body = body.push(
column([])
.spacing(10)
.push(
self.panel_heading(ferese_config::tr!("Automatic appearance"), 14.),
)
.push(self.auto_controls()),
);
}
body = body.push(
button::custom(
row([])
.spacing(8)
.spacing(10)
.align_y(Alignment::Center)
.push(self.label(ferese_config::tr!("Advanced customization"), 13.))
.push(visuals::action_icon(
@@ -351,19 +349,22 @@ impl App {
} else {
"Expand advanced customization"
})
.padding([7, 10])
.padding([8, 12])
.width(Length::Fill)
.class(visuals::button_style(palette, false))
.on_press(Message::AdvancedTheme(!self.advanced_theme)),
);
if self.advanced_theme {
group = group.push(self.theme_file_picker());
body = body.push(self.theme_file_picker());
}
}
body = body.push(container(group).padding(4).class(visuals::section(palette)));
}
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 view = row([]).push(sidebar).push(
container(
container(
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(6),
)
.padding(cosmic::iced::Padding {
top: 24.,
right: 24.,
bottom: 8.,
left: 24.,
})
.max_width(if self.page == Page::Bar { 1120 } else { 1040 })
.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)
.center_x(Length::Fill)
.height(Length::Fill)
.height(Length::Fill);
let view = row([]).push(sidebar).push(
container(
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,
)),
)
.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.
+144
View File
@@ -1014,3 +1014,147 @@ pub fn fields(page: Page) -> Vec<Field> {
| Page::Connections => vec![],
}
}
pub struct FieldGroup {
pub title: &'static str,
pub fields: Vec<Field>,
}
/// Semantic groups, preserving every field and the declaration order in each group.
pub fn grouped_fields(page: Page, fields: Vec<Field>) -> Vec<FieldGroup> {
let mut groups: Vec<FieldGroup> = 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::<Vec<_>>();
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::<Vec<_>>();
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<_>>(),
vec![
"Workspaces and layout",
"Spacing and corners",
"Focus and window effects"
]
);
}
}
+121 -10
View File
@@ -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<Tree> = 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<Rectangle>,
label: Option<Rectangle>,
}
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(<cosmic::Renderer as Headless>::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"
);
}
+1 -1
View File
@@ -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);
+3 -1
View File
@@ -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()
}