feat(settings): refine density, group detailed options and add edge maximize gestures
This commit is contained in:
1 parent
d7e122711a
commit
9908dde9f8
11 files changed
+533
-140
No files matched your search
@@ -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": "动画"
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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/`。这些本机验收资料不随仓库分发。
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user