Add searchable input controls and display profile editing

This commit is contained in:
aria committed 2026-10-28 08:19:05 +09:00
1 parent 78ce1ed590
commit a2be421974
24 files changed
+3418 -241

No files matched your search

Generated
+1
View File
@@ -2046,6 +2046,7 @@ dependencies = [
"shlex 1.3.0", "shlex 1.3.0",
"tempfile", "tempfile",
"tokio", "tokio",
"wayland-client",
"zbus", "zbus",
"zeroize", "zeroize",
] ]
+173 -6
View File
@@ -291,7 +291,7 @@
"Static": "静态", "Static": "静态",
"Scrolling": "滚动", "Scrolling": "滚动",
"Tiling": "平铺", "Tiling": "平铺",
"Floating": "浮动", "Floating": "悬浮",
"Always": "始终", "Always": "始终",
"Never": "从不", "Never": "从不",
"Automatic": "自动", "Automatic": "自动",
@@ -776,7 +776,7 @@
"Allow this device to pair?": "允许此设备配对吗?", "Allow this device to pair?": "允许此设备配对吗?",
"Another device is already pairing.": "另一个设备正在配对。", "Another device is already pairing.": "另一个设备正在配对。",
"Argument": "参数", "Argument": "参数",
"Attached to the screen edge": "贴合屏幕边缘", "Attached to the screen edge": "连接屏幕边缘",
"Auto (battery)": "自动(电池)", "Auto (battery)": "自动(电池)",
"Auto appearance: off": "自动外观:关", "Auto appearance: off": "自动外观:关",
"Auto appearance: on": "自动外观:开", "Auto appearance: on": "自动外观:开",
@@ -799,7 +799,7 @@
"Command, direction, or action argument": "命令、方向或动作参数。", "Command, direction, or action argument": "命令、方向或动作参数。",
"Configuration schema query timed out": "配置结构查询超时", "Configuration schema query timed out": "配置结构查询超时",
"Confirm": "确认", "Confirm": "确认",
"Connected groups with rounded ends": "圆角端点相连的分组", "Connected groups with rounded ends": "以圆角端点连接的分组",
"Connected · ": "已连接 · ", "Connected · ": "已连接 · ",
"Connecting · ": "正在连接 · ", "Connecting · ": "正在连接 · ",
"Connection services are unavailable. Use Refresh to try again.": "连接服务不可用,请点击刷新重试。", "Connection services are unavailable. Use Refresh to try again.": "连接服务不可用,请点击刷新重试。",
@@ -837,7 +837,7 @@
"Forget this connection?": "忘记此连接吗?", "Forget this connection?": "忘记此连接吗?",
"Full": "完整", "Full": "完整",
"Group arrangement & spacing": "分组排列与间距", "Group arrangement & spacing": "分组排列与间距",
"Groups blend where they meet": "分组在相接处融合", "Groups blend where they meet": "在交汇处融合分组",
"Headphones": "耳机", "Headphones": "耳机",
"Hidden network": "隐藏网络", "Hidden network": "隐藏网络",
"Holiday name": "节假日名称", "Holiday name": "节假日名称",
@@ -913,7 +913,7 @@
"Schema query failed": "配置结构查询失败", "Schema query failed": "配置结构查询失败",
"Searching for devices…": "正在搜索设备…", "Searching for devices…": "正在搜索设备…",
"Select display": "选择显示器", "Select display": "选择显示器",
"Separate floating groups": "独立悬浮分组", "Separate floating groups": "彼此分离的悬浮分组",
"Shared": "共享", "Shared": "共享",
"Shortcut": "快捷键", "Shortcut": "快捷键",
"Show +n for additional activities.": "以 +n 表示更多活动。", "Show +n for additional activities.": "以 +n 表示更多活动。",
@@ -1042,5 +1042,172 @@
"Loading configuration options…": "正在载入配置选项…", "Loading configuration options…": "正在载入配置选项…",
"Retry": "重试", "Retry": "重试",
"Dark only": "仅深色", "Dark only": "仅深色",
"Light only": "仅浅色" "Light only": "仅浅色",
"Panel": "面板",
"Shell": "Shell",
"Keyboard layouts, variants and repeat behavior.": "键盘布局、变体与按键重复行为。",
"Mouse buttons, pointer movement and scrolling.": "鼠标按键、指针移动与滚轮。",
"Touchpad taps, scrolling and gestures.": "触控板轻点、滚动与手势。",
"Shared surfaces, launcher and system menus.": "共享表面、启动器与系统菜单。",
"Shell surfaces": "Shell 表面",
"Default variant": "默认变体",
"Loading keyboard layouts…": "正在载入键盘布局…",
"Search layouts…": "搜索键盘布局…",
"Search variants…": "搜索布局变体…",
"Select a layout to add…": "选择要添加的布局…",
"Add layout": "添加布局",
"Remove layout": "移除布局",
"Move up": "上移",
"Switch keyboard layout": "切换键盘布局",
"No layout switching shortcut": "不使用快捷键切换布局",
"Search layout switching shortcuts…": "搜索布局切换快捷键…",
"Add shortcut": "添加快捷键",
"Search shortcuts…": "搜索快捷键…",
"Edit shortcut": "编辑快捷键",
"New shortcut": "新建快捷键",
"No shortcut selected": "尚未选择组合键",
"Record shortcut": "录入组合键",
"Press a shortcut · Esc cancels": "请按组合键 · Esc 取消",
"Preparing recording…": "正在准备录入…",
"Choose keys from menu": "从菜单选择按键",
"Search keys or gestures…": "搜索按键或手势…",
"Search actions…": "搜索动作…",
"Save shortcut": "保存快捷键",
"Remove shortcut": "移除快捷键",
"Show active shortcuts": "查看现有效快捷键",
"Built-in shortcuts remain active. Add a shortcut here to customize or override one.": "内置快捷键继续生效。在这里添加快捷键即可自定义或覆盖现有组合键。",
"Record or select a shortcut first": "请先录入或选择组合键",
"Choose an action": "请选择动作",
"Choose a direction": "请选择方向",
"Workspace number must be between 1 and 255": "工作区编号须在 1 到 255 之间",
"Choose a binding mode": "请选择快捷键模式",
"This shortcut belongs to another binding. Edit that binding instead.": "此组合键已属于另一项快捷键,请编辑对应项目。",
"Recording timed out. You can also select keys from the menu.": "录入超时,也可以从菜单选择按键。",
"Custom program": "自定义程序",
"Workspace number": "工作区编号",
"Launch a program": "运行程序",
"Maximize or restore window": "最大化或还原窗口",
"Focus window": "切换窗口焦点",
"Move window": "移动窗口",
"Resize window": "调整窗口大小",
"Go to workspace": "转到工作区",
"Move window to workspace": "将窗口移到工作区",
"Return to previous workspace": "返回上一工作区",
"Switch between tiled and floating windows": "在平铺窗口与浮动窗口间切换",
"Toggle stacked or tabbed column": "切换堆叠或标签列",
"Join tab group": "加入标签组",
"Join window into column": "将窗口并入列",
"Leave column": "从列移出窗口",
"Switch binding mode": "切换快捷键模式",
"Log out with confirmation": "注销(先确认)",
"Disable shortcut": "停用快捷键",
"Customize gestures": "自定义手势",
"{count}-finger swipe {direction}": "{count} 指向{direction}滑动",
"Left mouse button": "鼠标左键",
"Right mouse button": "鼠标右键",
"Middle mouse button": "鼠标中键",
"Back mouse button": "鼠标后退键",
"Forward mouse button": "鼠标前进键",
"Wheel up": "滚轮向上",
"Wheel down": "滚轮向下",
"Wheel left": "滚轮向左",
"Wheel right": "滚轮向右",
"Page up": "上一页",
"Page down": "下一页",
"Left arrow": "左方向键",
"Right arrow": "右方向键",
"Up arrow": "上方向键",
"Down arrow": "下方向键",
"Backspace": "退格",
"Escape": "Esc",
"Enter": "回车",
"Space": "空格",
"Tab": "Tab",
"Delete": "删除",
"Insert": "插入",
"Home": "Home",
"Print": "截屏",
"Display arrangement": "显示器排列",
"Save current layout as a profile": "将当前布局保存为配置",
"Profile name": "配置名称",
"Rename profile": "重命名配置",
"Remove profile": "移除配置",
"Use this profile": "使用此配置",
"Display layout": "显示模式",
"Extend": "扩展",
"Mirror": "镜像",
"Internal display only": "仅内置显示器",
"External displays only": "仅外接显示器",
"Output settings": "输出设置",
"Enable display": "启用显示器",
"At least one connected display must remain enabled.": "至少保留一个已连接的显示器启用。",
"Rotation and reflection": "旋转与翻转",
"Landscape": "横向",
"Portrait right": "纵向(向右旋转)",
"Landscape inverted": "横向倒置",
"Portrait left": "纵向(向左旋转)",
"Reflected landscape": "横向翻转",
"Reflected portrait right": "纵向翻转(向右)",
"Reflected landscape inverted": "横向倒置并翻转",
"Reflected portrait left": "纵向翻转(向左)",
"Apply position": "应用位置",
"Automatic position": "自动排列",
"Positions use logical pixels after scaling and rotation. Negative values are supported.": "位置使用缩放和旋转后的逻辑像素,支持负值。",
"Keep these display settings? Unconfirmed changes revert automatically.": "保留这些显示设置?未经确认的更改将自动回退。",
"Keep changes": "保留更改",
"Revert changes": "回退更改",
"Restore previous display configuration": "恢复修改前的显示配置",
"This display is managed by the host session. Saved profiles apply in a standalone session.": "此显示器由宿主会话管理;保存的配置将在独立会话中应用。",
"Connect a display before creating a profile": "请先连接显示器,再创建配置",
"Choose a unique profile name": "请使用非空且不重复的配置名称",
"Position must use whole logical pixels": "位置须使用整数逻辑像素",
"Desktop {number}": "桌面 {number}",
"Offline": "离线",
"Window dragging and resizing require a mouse button": "窗口拖拽和指针缩放需要使用鼠标按键",
"Default shortcuts": "默认快捷键",
"Record by key label": "按字符重新录入",
"This binding matches physical key positions. Recording chooses key labels instead.": "此绑定按物理位置匹配;重新录入将改为按键字符匹配。",
"This shortcut will replace the existing binding.": "此组合键将替换已有的自定义绑定。",
"Shortcuts apply throughout the Ferese session. Matching keys override built-in shortcuts.": "快捷键在整个 Ferese 会话内生效;相同组合键将覆盖内置快捷键。",
"Replace binding": "替换绑定",
"Current display arrangement": "当前显示器排列",
"This session does not support recording. Choose a combination from the key menu.": "此会话不支持录入组合键,请从按键菜单选择。",
"Open or close launcher": "打开或关闭启动器",
"Open or close overview": "打开或关闭概览",
"Close window": "关闭窗口",
"Toggle fullscreen": "切换全屏",
"Toggle floating window": "切换浮动窗口",
"Next workspace": "下一个工作区",
"Previous workspace": "上一个工作区",
"Return to last focused window": "返回上次聚焦的窗口",
"Next window in focus history": "焦点历史中的下一个窗口",
"Previous window in focus history": "焦点历史中的上一个窗口",
"Switch workspace layout": "切换工作区布局",
"Cycle column width": "循环切换列宽",
"Center focused column": "居中当前列",
"Leave tab group": "移出标签组",
"Next tab": "下一个标签",
"Previous tab": "上一个标签",
"Drag window": "拖动窗口",
"Resize window with pointer": "用鼠标调整窗口大小",
"Play or pause media": "播放或暂停媒体",
"Next track": "下一曲",
"Previous track": "上一曲",
"Open display mode chooser": "打开显示模式选择器",
"Open or close shortcut hint": "打开或关闭快捷键提示",
"Panel appearance": "面板外观",
"Edge to edge": "贯穿屏幕",
"A continuous edge-to-edge panel": "沿屏幕边缘连续铺开的面板",
"Capsules": "胶囊",
"Notch": "刘海",
"Frame": "框架",
"Corner wrap": "包裹边角",
"Fused islands": "融合岛",
"Morph": "动态岛",
"Adaptive": "自适应",
"Window hug": "贴合窗口",
"A detached panel with rounded corners": "带圆角的悬浮面板",
"Separate rounded groups": "独立圆角分组",
"Expands around active content": "随活动内容展开",
"A clock hub with expandable controls": "可展开控制项的时钟中心"
} }
+60
View File
@@ -350,6 +350,36 @@ impl Document {
Ok(()) Ok(())
} }
/// Restore one top-level setting from an earlier document, including its
/// authored nodes and comments, while retaining all other current settings.
pub fn restore_top_level(&mut self, key: &str, baseline: &Self) -> Result<(), Error> {
if key.is_empty() || key.contains('.') {
return Err(Error("Expected a top-level setting".into()));
}
let mut candidate = self.clone();
let index = candidate
.doc
.nodes()
.iter()
.position(|node| field(node.name().value(), "") == key)
.unwrap_or(candidate.doc.nodes().len());
candidate
.doc
.nodes_mut()
.retain(|node| field(node.name().value(), "") != key);
let restored = baseline
.doc
.nodes()
.iter()
.filter(|node| field(node.name().value(), "") == key)
.cloned();
let index = index.min(candidate.doc.nodes().len());
candidate.doc.nodes_mut().splice(index..index, restored);
candidate.refresh()?;
*self = candidate;
Ok(())
}
fn refresh(&mut self) -> Result<(), Error> { fn refresh(&mut self) -> Result<(), Error> {
self.value = Value::Object(object(&self.doc, "")?); self.value = Value::Object(object(&self.doc, "")?);
Ok(()) Ok(())
@@ -1124,6 +1154,36 @@ pub fn from_str<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn restoring_display_records_keeps_authored_comments_and_other_current_settings() {
let baseline=Document::parse("// desk note\noutput-profile \"Desk\" { output match=\"DP-1\" scale=2; }\ninput { repeat-rate 25; }\n").unwrap();
let mut current = baseline.clone();
current
.set("output_profiles.0.outputs.0.scale", 1.5.into())
.unwrap();
current.set("input.repeat_rate", 31.into()).unwrap();
current
.restore_top_level("output_profiles", &baseline)
.unwrap();
assert_eq!(
current.get("output_profiles"),
baseline.get("output_profiles")
);
assert_eq!(
current.get("input.repeat_rate"),
Some(&serde_json::json!(31))
);
assert!(current.to_string().contains("// desk note"));
current
.restore_top_level("output_profiles", &Document::parse("").unwrap())
.unwrap();
assert!(current.get("output_profiles").is_none());
assert_eq!(
current.get("input.repeat_rate"),
Some(&serde_json::json!(31))
);
}
#[test] #[test]
fn structured_activity_rules_use_native_kdl_syntax() { fn structured_activity_rules_use_native_kdl_syntax() {
let mut doc = Document::parse("activities { max-visible 2; }\n").unwrap(); let mut doc = Document::parse("activities { max-visible 2; }\n").unwrap();
+6 -4
View File
@@ -1,5 +1,7 @@
# Input # Input
Settings separates **Keyboard**, **Mouse** and **Touchpad**. In Keyboard, search installed XKB layouts and variants by their localized name or code; each layout has its own variant. Add, remove or reorder layouts without editing comma-separated configuration strings. The layout-switching menu changes only `grp:*` options and preserves other XKB options. Catalog loading runs in the background and uses `XKB_CONFIG_ROOT/rules/evdev.lst` when set, otherwise `/usr/share/X11/xkb/rules/evdev.lst`.
| `input` key | Type | Default | Meaning | | `input` key | Type | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `focus-follows-mouse` | boolean | `false` | Focus visible windows under the pointer without raising them or scrolling; click or use keyboard focus to reveal a window | | `focus-follows-mouse` | boolean | `false` | Focus visible windows under the pointer without raising them or scrolling; click or use keyboard focus to reveal a window |
@@ -25,7 +27,7 @@ The touchpad keys `tap`, `natural-scroll` and `disable-while-typing` default to
`true`. Set `disable-while-mouse-connected #true` to pause a built-in touchpad `true`. Set `disable-while-mouse-connected #true` to pause a built-in touchpad
while an external mouse is connected. It defaults to `false` and uses libinput's while an external mouse is connected. It defaults to `false` and uses libinput's
external-mouse policy on supported touchpads, including when a mouse is plugged external-mouse policy on supported touchpads, including when a mouse is plugged
in or removed during the session. The toggle is also in **Settings → Keyboard & mouse**. in or removed during the session. The toggle is also in **Settings → Touchpad**.
These device settings apply to DRM sessions; nested previews use the host's These device settings apply to DRM sessions; nested previews use the host's
physical input settings. physical input settings.
@@ -67,8 +69,8 @@ by other monitors and stop at the first/last workspace. Short or cancelled navig
swipes slide back; diagonal swipes do not navigate. Navigation pauses while locked, during swipes slide back; diagonal swipes do not navigate. Navigation pauses while locked, during
window grabs, or when an application inhibits shortcuts. window grabs, or when an application inhibits shortcuts.
Assign swipes in **Settings → Keyboard & mouse → Shortcuts** using the same actions and arguments as Assign swipes in **Settings → Shortcuts** using the same actions and arguments as
keyboard bindings. Choose a "Customize swipe" button to add an override. The keyboard bindings. Use **Customize gestures** from Touchpad, then choose a gesture and action in the shortcut editor. The
gesture keys are `Swipe3Up`, `Swipe3Down`, `Swipe3Left`, and `Swipe3Right`; 4 or 5 gesture keys are `Swipe3Up`, `Swipe3Down`, `Swipe3Left`, and `Swipe3Right`; 4 or 5
fingers are also supported. Changes reload live: fingers are also supported. Changes reload live:
@@ -85,7 +87,7 @@ keyboard modifiers or physical key matching. The trigger is automatically
recognized from its name. Gesture navigation uses native touchpad events in a recognized from its name. Gesture navigation uses native touchpad events in a
hardware session. hardware session.
Adjust recognition distance in **Settings → Keyboard & mouse**, or with Adjust recognition distance in **Settings → Touchpad**, or with
`swipe-threshold 80` under `input.touchpad` (integer 16–1000 logical pixels). `swipe-threshold 80` under `input.touchpad` (integer 16–1000 logical pixels).
## Mouse bindings ## Mouse bindings
+4
View File
@@ -1,5 +1,9 @@
# Displays # Displays
**Settings → Displays** shows the current arrangement, live resolution, refresh rate, scale and connection status. Save the current desktop as a named profile, rename or remove profiles, and configure resolution, refresh policy, scale, rotation/reflection and logical pixel position for each output. Unique hardware identities are preferred over connector names; identical monitors fall back to their current connectors. The arrangement diagram selects outputs but does not drag them.
Hardware changes use the compositor confirmation timeout. **Keep changes** accepts them; **Revert changes** restores the previous runtime arrangement and the display configuration captured before the first edit. **Restore previous display configuration** remains available for the source configuration after an automatic runtime rollback. Other settings and their comments are preserved. Nested sessions show the host-managed output and save configuration only; test physical modes and positioning in a standalone session.
## Color temperature ## Color temperature
Night light warms the display using its hardware gamma ramp. It is off by default. Night light warms the display using its hardware gamma ramp. It is off by default.
+1
View File
@@ -9,6 +9,7 @@ to `0` disables every path that can read the screen, including the built-in
screenshot command, not just the Wayland protocol. A session launched by hand screenshot command, not just the Wayland protocol. A session launched by hand
rather than through `ferese-session` needs `FERESE_ENABLE_SCREENCOPY=1` in its rather than through `ferese-session` needs `FERESE_ENABLE_SCREENCOPY=1` in its
environment for screenshots to work. environment for screenshots to work.
Settings checks the actual Wayland registry before offering shortcut recording. With shortcut inhibition disabled, use its modifier buttons and searchable key menu. Enabling the launch-time flag requires starting a new Ferese session.
Text input is available by default; Ctrl+Alt+Escape releases an active shortcut Text input is available by default; Ctrl+Alt+Escape releases an active shortcut
inhibitor. Use `feresectl --help` for runtime control commands. inhibitor. Use `feresectl --help` for runtime control commands.
+1 -1
View File
@@ -2,7 +2,7 @@
| `status` key | Type | Default | Meaning | | `status` key | Type | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `keybinding-guide` | boolean | `true` | Show the active shortcut guide at login until disabled in Settings → Keyboard & mouse → Shortcuts. | | `keybinding-guide` | boolean | `true` | Show the active shortcut guide at login until disabled in Settings → Shortcuts. |
| `low-battery-threshold` | integer 0–100 | `20` | Warning-color threshold | | `low-battery-threshold` | integer 0–100 | `20` | Warning-color threshold |
| `settings-command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action | | `settings-command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action |
+49 -10
View File
@@ -1,8 +1,8 @@
# Ferese Graphite 视觉方案 # Ferese Graphite 视觉方案
- 文档版本:1.6 - 文档版本:1.7
- 整理日期:2026-10-28 - 整理日期:2026-10-28
- 视觉基线:`1e34ec4`、`9908dde`、`3264659`、`6671960`、`81b9970`;本版修正活动窗口的应用图标来源。 - 视觉基线:`1e34ec4`、`9908dde`、`3264659`、`6671960`、`81b9970`;本版增加搜索选择、快捷键编辑及显示器配置流程。
- 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。 - 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。
## 1. 视觉目标 ## 1. 视觉目标
@@ -135,7 +135,7 @@ Appearance 作为全部设置的入口,内容按实际功能分组:
| --- | --- | | --- | --- |
| 快速访问 | 常规、全部配置 | | 快速访问 | 常规、全部配置 |
| 桌面 | 主题、壁纸、面板与 Shell、托盘图标 | | 桌面 | 主题、壁纸、面板与 Shell、托盘图标 |
| 交互 | 窗口、键盘与鼠标、辅助功能 | | 交互 | 窗口、键盘、鼠标、触控板、快捷键、辅助功能 |
| 系统 | 显示器、连接、通知、日历、锁屏、登录启动项 | | 系统 | 显示器、连接、通知、日历、锁屏、登录启动项 |
总览使用等宽卡片,文字区域占用弹性空间,箭头在卡片末端对齐。 总览使用等宽卡片,文字区域占用弹性空间,箭头在卡片末端对齐。
@@ -158,7 +158,11 @@ Appearance 作为全部设置的入口,内容按实际功能分组:
| 常规 | 界面 | | 常规 | 界面 |
| 日历 | 日期显示、日本祝日、自定义节假日 | | 日历 | 日期显示、日本祝日、自定义节假日 |
| 窗口 | 工作区与布局、间距与圆角、焦点与窗口效果 | | 窗口 | 工作区与布局、间距与圆角、焦点与窗口效果 |
| 键盘与鼠标 | 键盘布局、按键重复、鼠标、触控板 | | 键盘 | 键盘布局与变体、布局切换、按键重复 |
| 鼠标 | 鼠标行为、滚轮与指针 |
| 触控板 | 触控板行为、指针与手势 |
| 面板与 Shell | 面板布局与项目、Shell 圆角与材质,分为两个子页 |
| 显示器 | 当前排列、配置档、输出设置 |
| 主题 | 配色与强调色、表面与透明度、字体与排版;自动外观按需展开 | | 主题 | 配色与强调色、表面与透明度、字体与排版;自动外观按需展开 |
| 辅助功能 | 视觉辅助、动画 | | 辅助功能 | 视觉辅助、动画 |
| 通知 | 通知行为、通知时长 | | 通知 | 通知行为、通知时长 |
@@ -166,9 +170,25 @@ Appearance 作为全部设置的入口,内容按实际功能分组:
| 托盘图标 | 应用托盘、应用图标、面板状态图标 | | 托盘图标 | 应用托盘、应用图标、面板状态图标 |
| 快捷键 | 快捷键指南、自定义快捷键 | | 快捷键 | 快捷键指南、自定义快捷键 |
显示器、连接和面板编辑器延续各自已有的功能分区;登录启动项按应用组织。窗口效果预览放入对应分组,高度收至 84 px,避免独占页首的大块空间。 连接和面板编辑器保留各自的功能分区;登录启动项按应用组织。窗口效果预览放入对应分组,高度收至 84 px,避免独占页首的大块空间。
### 6.2 留白与窗口操作 ### 6.2 搜索选择与快速绑定
- 键盘布局、变体与布局切换组合键使用可搜索菜单,显示本地化名称及 XKB 代码。变体列表随所属布局更新;多布局新增、删除和上移时保持布局与变体一一对应,不要求填写逗号分隔字符串。
- 菜单高度随过滤结果收缩,超出可用视口后滚动,避免固定高度产生大片空白。菜单与输入框使用现有石墨灰配色、MiSans 和 5 px 圆角。
- 快捷键独立入口提供新增、编辑、删除、搜索及当前快捷键指南。编辑时先选择组合键,再搜索动作,并按动作展示方向、工作区、模式或程序参数。
- 录入前检测会话是否提供快捷键屏蔽协议;只有收到屏蔽确认后才提示按键。Esc、失焦、离开页面或超时结束录入。不支持的会话直接提供修饰键按钮和按键菜单,避免误触原有快捷键。
- 手势从触控板页进入同一快捷键编辑器。自定义组合键覆盖会话级绑定;编辑过程中明确保留物理位置匹配与字符匹配的区别。
### 6.3 显示器与面板结构
- 显示器页先呈现当前输出排列,再提供配置档和所选输出设置。排列图支持负坐标、旋转与不同缩放,点击显示器进行选择;图示表达当前状态,数值设置表达所选配置档。
- 当前布局可保存为配置档;提供重命名、删除与启用操作。输出按硬件身份匹配,同型号身份重复时使用当前接口,避免将接口名固定解释为物理位置。
- 分辨率与刷新率继续采用硬件提供的选项;缩放、旋转/反射、逻辑像素位置在同一输出分组中调整。至少保留一个已连接输出,禁用开关以草稿和实时状态共同判断。
- 硬件变更保留确认和自动回退。主动回退时仅还原显示器配置片段;自动回退后仍可恢复原配置文件,避免重载时再次套用错误布局。嵌套预览明确提示物理输出由宿主管理。
- 面板子页管理几何、布局、项目与拖动编排;Shell 子页集中圆角、材质与不透明度,并链接托盘、日历和通知。移除无字段的空分组。
### 6.4 留白与窗口操作
- 双击页面顶部、底部、左右边缘及超宽窗口外侧的留白,切换最大化与还原;标题区域和侧栏未被控件占用的区域同样支持。 - 双击页面顶部、底部、左右边缘及超宽窗口外侧的留白,切换最大化与还原;标题区域和侧栏未被控件占用的区域同样支持。
- 在上述区域移动指针超过拖动阈值后开始拖窗,单击不立即拖动。 - 在上述区域移动指针超过拖动阈值后开始拖窗,单击不立即拖动。
@@ -303,13 +323,13 @@ status {
| 验证项 | 结果 | | 验证项 | 结果 |
| --- | --- | | --- | --- |
| 设置测试 | 128 项通过,10 项可选检查默认跳过 | | 设置测试 | 140 项通过,11 项可选检查默认跳过 |
| 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 | | 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 |
| 配置测试 | 51 项单元测试及 11 项集成测试通过 | | 配置测试 | 52 项单元测试及 11 项集成测试通过 |
| 共享主题 / 面板 / Shell | 34 / 69 / 256 项通过 | | 共享主题 / 面板 / Shell | 34 / 69 / 256 项通过 |
| 布局检查 | 覆盖 480–1800 px 窗口宽度 | | 布局检查 | 覆盖 480–1800 px 窗口宽度 |
| 原生预览 | 已检查深浅外观及 631 px 窄窗口 | | 原生预览 | 已检查深浅外观及 631 px 窄窗口 |
| 导航居中 | 全部 16 项;包含独立滚动条通道检查 | | 导航居中 | 全部 19 项;包含独立滚动条通道检查 |
| Rime | 原生输入、候选、翻页、主题变化与进程恢复通过 | | Rime | 原生输入、候选、翻页、主题变化与进程恢复通过 |
| 边缘双击 | 原生预览内 1272 px 最大化与 631 px 还原双向切换通过,配置内容不变 | | 边缘双击 | 原生预览内 1272 px 最大化与 631 px 还原双向切换通过,配置内容不变 |
| 导航抽屉 | 已移除关闭按钮,外侧点击和 Esc 关闭均检查通过 | | 导航抽屉 | 已移除关闭按钮,外侧点击和 Esc 关闭均检查通过 |
@@ -320,8 +340,14 @@ status {
| 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 | | 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 |
| 原生主题预览 | 检查配对颜色、透明外角和选中标记图层顺序;保留比例与键盘导航 | | 原生主题预览 | 检查配对颜色、透明外角和选中标记图层顺序;保留比例与键盘导航 |
| 后台 schema | 将查询人为延迟 2 秒,窗口提前显示载入状态;结果完成后显示选项,查询只执行一次;覆盖失败重试与最新配置接续 | | 后台 schema | 将查询人为延迟 2 秒,窗口提前显示载入状态;结果完成后显示选项,查询只执行一次;覆盖失败重试与最新配置接续 |
| 布局与变体菜单 | 原生搜索 `de` 与 `nodead`,选择德语及无死键变体,写回配置并经 compositor 校验通过 |
| 快捷键编辑 | 原生录入 `Super+Q`、中文搜索动作并保存停用覆盖;窗口保持运行。Esc 取消后窗口快捷键恢复;480 px 编辑器可用 |
| 录入能力检测 | 分别检查关闭与启用快捷键屏蔽协议的隔离会话;前者展示菜单选择,后者收到屏蔽确认后录入 |
| 显示配置 | 原生创建、校验、重命名与恢复配置档通过;恢复后保留键盘和快捷键设置。身份重复与禁用保护由模型测试覆盖 |
| 面板与 Shell | 原生检查分开的面板/Shell 子页,统一分组标题、页脚和面板样式翻译 |
| 本轮分类渲染 | WGPU、2 倍缩放下渲染 21 页共 378 帧,覆盖 631 / 1272 px;未变更实际显示器 |
滚动渲染专项记录沿用前版 `9908dde`;边缘双击与窄窗口抽屉本轮再次检查通过。本版原生检查使用独立嵌套预览,保留原预览中的应用;没有重新执行关机、挂起或切换实际桌面会话。主机显示器、默认应用关联和 GNOME 输入源配置与开始时一致。 滚动渲染专项记录沿用前版 `9908dde`;边缘双击与窄窗口抽屉本轮再次检查通过。本版原生检查使用独立嵌套预览,保留原预览中的 SOUP 和设置进程;没有重新执行关机、挂起或切换实际桌面会话。本轮没有切换主机显示器、默认应用关联或 GNOME 输入源。真实多屏模式切换、确认超时和驱动应用仍需在独立硬件会话复核;嵌套预览与模型测试不能代替这些检查。
### 性能采样 ### 性能采样
@@ -387,3 +413,16 @@ status {
| 布局及对齐回归检查 | `shell/ferese-settings/src/scroll_tests.rs` | | 布局及对齐回归检查 | `shell/ferese-settings/src/scroll_tests.rs` |
前版记录保存在 `.artifacts/visual-reference-20261028/` 与 `.artifacts/settings-density-20261028/`;本版日历、函数编辑器和通知标记的记录保存在 `.artifacts/time-hub-20261028/`。这些本机验收资料不随仓库分发。 前版记录保存在 `.artifacts/visual-reference-20261028/` 与 `.artifacts/settings-density-20261028/`;本版日历、函数编辑器和通知标记的记录保存在 `.artifacts/time-hub-20261028/`。这些本机验收资料不随仓库分发。
本版搜索选择与快捷绑定的验收记录位于 `.artifacts/settings-controls-20261028/`,包含原生截图、配置写回结果及 21 页渲染记录。
本版新增实现:
| 功能 | 位置 |
| --- | --- |
| XKB 目录、变体、布局切换 | `shell/ferese-settings/src/keyboard_controls.rs` |
| 搜索选择器 | `shell/ferese-settings/src/selection.rs` |
| 快捷键录入、菜单与保存 | `shell/ferese-settings/src/shortcut_controls.rs` |
| 显示器排列图 | `shell/ferese-settings/src/display_map.rs` |
| 显示配置档与回退 | `shell/ferese-settings/src/display_controls.rs`、`displays_ui.rs` |
| 保留其他配置的片段恢复 | `crates/ferese-config/src/document.rs` |
+7 -1
View File
@@ -1,11 +1,17 @@
# Shortcuts and gestures # Shortcuts and gestures
`Super` is usually the Windows key. These are the built-in defaults. Open `Super` is usually the Windows key. These are the built-in defaults. Open
**Settings → Keyboard & mouse → Shortcuts** to change bindings and gestures or disable the login **Settings → Shortcuts** to change bindings and gestures or disable the login
guide. Your saved bindings can override these defaults. guide. Your saved bindings can override these defaults.
The [example config](../packaging/config.kdl) adds the app, media and arrow-key The [example config](../packaging/config.kdl) adds the app, media and arrow-key
shortcuts listed below. shortcuts listed below.
## Edit bindings in Settings
Choose **Add shortcut**, record the combination, then search for an action. Recording temporarily inhibits Ferese shortcuts after the compositor confirms the inhibition; Escape, focus loss, leaving the page or the 15-second timeout releases it. A key menu with modifier buttons is also available for combinations reserved by a host desktop. Mouse buttons, wheel directions and three-, four- or five-finger swipes use the same editor.
The editor provides action-specific arguments and an application command field. Commands are parsed into an argument vector; no shell is invoked. Adding an existing custom combination updates that binding; editing another binding into a duplicate reports a conflict. Changes apply globally. A physical-key binding retains its matching mode when only its action changes; recording or choosing a new key switches to key-label matching.
## Launcher ## Launcher
**Super + Space** opens the launcher. Type to search, press **Tab** to accept **Super + Space** opens the launcher. Type to search, press **Tab** to accept
+1
View File
@@ -17,6 +17,7 @@ libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] }
iced_accessibility.workspace = true iced_accessibility.workspace = true
ferese-ipc = { path = "../../crates/ferese-ipc" } ferese-ipc = { path = "../../crates/ferese-ipc" }
serde_json.workspace = true serde_json.workspace = true
wayland-client.workspace = true
tempfile.workspace = true tempfile.workspace = true
shlex = "1.3.0" shlex = "1.3.0"
notify = "8.2.0" notify = "8.2.0"
@@ -0,0 +1,342 @@
use crate::{
App, Message, Snapshot, Task,
store::{Edit, set},
};
#[derive(Default)]
pub(super) struct State {
pub busy: bool,
pub recovery: Option<Snapshot>,
pub restoring: bool,
restore_after_revert: bool,
pub queued: Option<Command>,
}
#[derive(Clone, Debug)]
pub(super) enum Command {
Confirm,
Revert,
Profile(String),
}
#[derive(Clone, Debug)]
pub(super) enum Input {
Create,
Rename(usize),
Delete(usize),
Position(String),
AutoPosition(String),
Activate(String),
Confirm,
Revert,
Restore,
}
fn profile_index(prefix: &str) -> Option<usize> {
prefix
.strip_prefix("output_profiles.")?
.split('.')
.next()?
.parse()
.ok()
}
pub(super) fn affects_outputs(edit: &Edit) -> bool {
let path = match edit {
Edit::Set(p, _) | Edit::Unset(p) | Edit::Add(p, _) | Edit::Remove(p, _) => p,
};
path == "output_profiles" || path.starts_with("output_profiles.")
}
impl App {
pub(super) fn remember_display_config(&mut self) {
if self.page == crate::schema::Page::Displays
&& !self.display_state.restoring
&& self.display_state.recovery.is_none()
{
self.display_state.recovery = Some(self.current.clone());
}
}
pub(super) fn display_input(&mut self, input: Input) -> Task<Message> {
if self.saving || self.display_state.busy {
return Task::none();
}
match input {
Input::Create => match crate::displays::profile(&self.draft, &self.displays) {
Ok((name, edit)) => {
let matcher = self
.displays
.iter()
.find(|d| d.connected)
.map(|d| crate::displays::selector(d, &self.displays))
.map(str::to_owned)
.unwrap_or_default();
self.display_selection = Some(format!("{name}\0{matcher}"));
return self.change(edit);
}
Err(error) => self.error = Some(error),
},
Input::Rename(index) => {
let old = self
.draft
.string(&format!("output_profiles.{index}.name"), "");
let name = self
.inputs
.get("$display.profile_name")
.map(|v| v.trim())
.unwrap_or(&old)
.to_owned();
if name.is_empty()
|| (0..self.draft.records("output_profiles")).any(|i| {
i != index
&& self.draft.string(&format!("output_profiles.{i}.name"), "") == name
})
{
self.error = Some("Choose a unique profile name".into());
return Task::none();
}
if name == old {
self.inputs.remove("$display.profile_name");
return Task::none();
}
if self
.displays
.iter()
.any(|d| d.hardware && d.profile.as_deref() == Some(&old))
{
self.display_state.queued = Some(Command::Profile(name.clone()));
}
if let Some(key) = self
.display_selection
.as_ref()
.and_then(|k| k.split_once('\0'))
.map(|(_, selector)| selector.to_owned())
{
self.display_selection = Some(format!("{name}\0{key}"));
}
self.inputs.remove("$display.profile_name");
return self.change(set(&format!("output_profiles.{index}.name"), name));
}
Input::Delete(index) => {
let name = self
.draft
.string(&format!("output_profiles.{index}.name"), "");
if self
.displays
.iter()
.any(|d| d.hardware && d.profile.as_deref() == Some(&name))
{
self.display_state.queued = Some(Command::Profile("auto".into()));
}
self.display_selection = None;
self.inputs.retain(|key, _| !key.starts_with("$display."));
return self.change(Edit::Remove("output_profiles".into(), index));
}
Input::Position(prefix) => {
let matcher = self.draft.string(&format!("{prefix}.match"), "");
let live = self
.displays
.iter()
.find(|d| d.connector == matcher || d.identity == matcher);
let saved = crate::displays::configured_position(&self.draft, &prefix, live);
let mut position = [0, 0];
for (i, axis) in ["x", "y"].into_iter().enumerate() {
let key = format!("$display.{prefix}.{axis}");
let value = self
.inputs
.get(&key)
.cloned()
.unwrap_or_else(|| saved[i].to_string());
match value.parse::<i32>() {
Ok(v) => position[i] = v,
Err(_) => {
self.error = Some("Position must use whole logical pixels".into());
return Task::none();
}
}
}
for axis in ["x", "y"] {
self.inputs.remove(&format!("$display.{prefix}.{axis}"));
}
return self.change(set(
&format!("{prefix}.position"),
serde_json::json!(position),
));
}
Input::AutoPosition(prefix) => {
return self.change(Edit::Unset(format!("{prefix}.position")));
}
Input::Activate(name) => return self.display_command(Command::Profile(name)),
Input::Confirm => return self.display_command(Command::Confirm),
Input::Revert => {
self.display_state.restore_after_revert = self.display_state.recovery.is_some();
return self.display_command(Command::Revert);
}
Input::Restore => return self.restore_display_config(),
}
Task::none()
}
fn restore_display_config(&mut self) -> Task<Message> {
let Some(previous) = self.display_state.recovery.as_ref() else {
return Task::none();
};
let mut restored = self.current.doc.clone();
if let Err(error) = restored.restore_top_level("output_profiles", &previous.doc) {
self.error = Some(error.to_string());
return Task::none();
}
self.display_state.restoring = true;
self.start_save(restored.to_string())
}
pub(super) fn display_command(&mut self, command: Command) -> Task<Message> {
if self.display_state.busy {
return Task::none();
}
self.display_state.busy = true;
cosmic::task::future(async move {
let operation = command.clone();
let result = tokio::task::spawn_blocking(move || {
let mut connection =
ferese_ipc::Connection::connect().map_err(|e| e.to_string())?;
let (name, args) = match operation {
Command::Confirm => ("output-confirm", serde_json::json!({})),
Command::Revert => ("output-revert", serde_json::json!({})),
Command::Profile(name) => ("output-profile", serde_json::json!({"name":name})),
};
connection.call(name, args).map(|_| ())
})
.await
.unwrap_or_else(|e| Err(e.to_string()));
Message::DisplayCommandFinished(command, result)
})
}
pub(super) fn display_command_finished(
&mut self,
command: Command,
result: Result<(), String>,
) -> Task<Message> {
self.display_state.busy = false;
if let Err(error) = result {
self.error = Some(error);
self.display_state.restore_after_revert = false;
return self.refresh_displays(true);
}
match command {
Command::Confirm => {
self.display_state.recovery = None;
}
Command::Revert if self.display_state.restore_after_revert => {
self.display_state.restore_after_revert = false;
return self.restore_display_config();
}
_ => {}
}
self.refresh_displays(true)
}
}
pub(super) fn index(prefix: &str) -> Option<usize> {
profile_index(prefix)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn position_keeps_the_other_live_axis_and_rejects_invalid_edits_together() {
let mut app = <App as cosmic::Application>::init(
cosmic::app::Core::default(),
(
"/unused/display-position.kdl".into(),
Snapshot::parse("output-profile \"desk\" { output match=\"DP-3\"; }".into()),
None,
None,
),
)
.0;
app.displays = vec![crate::displays::Display {
connector: "DP-3".into(),
position: Some([-1920, 120]),
connected: true,
enabled: true,
..Default::default()
}];
let prefix = "output_profiles.0.outputs.0";
app.inputs
.insert(format!("$display.{prefix}.x"), "-2560".into());
app.inputs
.insert(format!("$display.{prefix}.y"), "invalid".into());
drop(app.display_input(Input::Position(prefix.into())));
assert!(app.draft.item(&format!("{prefix}.position")).is_none());
assert_eq!(app.inputs[&format!("$display.{prefix}.x")], "-2560");
app.inputs.remove(&format!("$display.{prefix}.y"));
drop(app.display_input(Input::Position(prefix.into())));
assert_eq!(
app.draft.item(&format!("{prefix}.position")),
Some(&serde_json::json!([-2560, 120]))
);
}
#[test]
fn duplicate_monitor_identities_use_connectors_and_enabled_guard_reads_the_draft() {
let displays = vec![
crate::displays::Display {
connector: "DP-1".into(),
identity: "Identical monitor".into(),
connected: true,
enabled: true,
..Default::default()
},
crate::displays::Display {
connector: "DP-2".into(),
identity: "Identical monitor".into(),
connected: true,
enabled: true,
..Default::default()
},
];
assert_eq!(crate::displays::selector(&displays[1], &displays), "DP-2");
let snapshot=Snapshot::parse("output-profile \"desk\" { output match=\"DP-1\" enabled=#false; output match=\"DP-2\" enabled=#true; }".into()).unwrap();
assert_eq!(
crate::displays::enabled_count(&snapshot, &displays, Some("desk")),
1
);
assert_eq!(
crate::displays::enabled_count(&snapshot, &displays, None),
2
);
}
#[test]
fn profiles_capture_live_identity_and_layout_without_replacing_existing_profiles() {
let mut snapshot = Snapshot::parse(
"// keep\noutput-profile \"Other desk\" { output match=\"HDMI-A-1\"; }".into(),
)
.unwrap();
let displays = vec![crate::displays::Display {
connector: "DP-3".into(),
identity: "Vendor Model Serial".into(),
connected: true,
enabled: true,
position: Some([-1920, 0]),
scale: 2.,
transform: "90".into(),
current: Some(crate::displays::Mode {
width: 3840,
height: 2160,
refresh: 144000,
}),
..Default::default()
}];
let (_, edit) = crate::displays::profile(&snapshot, &displays).unwrap();
snapshot.edit(&edit).unwrap();
assert_eq!(snapshot.records("output_profiles"), 2);
assert!(snapshot.source.contains("// keep"));
assert_eq!(
snapshot.string("output_profiles.1.outputs.0.match", ""),
"Vendor Model Serial"
);
assert_eq!(
snapshot
.item("output_profiles.1.outputs.0.position")
.unwrap(),
&serde_json::json!([-1920, 0])
);
assert_eq!(
snapshot.string("output_profiles.1.outputs.0.transform", ""),
"rotate_90"
);
}
}
+169
View File
@@ -0,0 +1,169 @@
use crate::displays::Display;
use crate::{App, Element, Length, Message, visuals};
use cosmic::widget::{button, column, container};
fn dimensions(display: &Display) -> (f32, f32) {
let scale = display.scale.max(0.25);
let mut size = display
.current
.map(|m| (m.width as f64 / scale, m.height as f64 / scale))
.unwrap_or((1920., 1080.));
if ["rotate_90", "rotate_270", "flipped_90", "flipped_270"]
.contains(&display.transform.as_str())
{
size = (size.1, size.0);
}
(
display.logical_width.unwrap_or(size.0 as i32).max(1) as f32,
display.logical_height.unwrap_or(size.1 as i32).max(1) as f32,
)
}
pub(super) fn positions(
displays: &[Display],
width: f32,
height: f32,
) -> Vec<(usize, cosmic::iced::Rectangle)> {
let mut next = 0.;
let raw: Vec<_> = displays
.iter()
.enumerate()
.filter(|(_, d)| d.connected && d.enabled)
.map(|(index, display)| {
let (w, h) = dimensions(display);
let (x, y) = display
.position
.map(|[x, y]| (x as f32, y as f32))
.unwrap_or((next, 0.));
next = x + w;
(index, x, y, w, h)
})
.collect();
if raw.is_empty() {
return Vec::new();
}
let left = raw.iter().map(|r| r.1).fold(f32::INFINITY, f32::min);
let top = raw.iter().map(|r| r.2).fold(f32::INFINITY, f32::min);
let right = raw
.iter()
.map(|r| r.1 + r.3)
.fold(f32::NEG_INFINITY, f32::max);
let bottom = raw
.iter()
.map(|r| r.2 + r.4)
.fold(f32::NEG_INFINITY, f32::max);
let scale = ((width - 24.).max(1.) / (right - left).max(1.))
.min((height - 24.).max(1.) / (bottom - top).max(1.));
let ox = (width - (right - left) * scale) / 2.;
let oy = (height - (bottom - top) * scale) / 2.;
raw.into_iter()
.map(|(i, x, y, w, h)| {
(
i,
cosmic::iced::Rectangle {
x: ox + (x - left) * scale,
y: oy + (y - top) * scale,
width: (w * scale - 4.).max(1.),
height: (h * scale - 4.).max(1.),
},
)
})
.collect()
}
impl App {
pub(super) fn display_map(&self) -> Element<'_, Message> {
cosmic::iced::widget::responsive(move |size| {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let targets = crate::displays::targets(&self.draft, &self.displays);
let selected = crate::displays::selected(&targets, self.display_selection.as_deref());
let mut layers: Vec<Element<'_, Message>> = vec![
container(cosmic::widget::Space::new().width(Length::Fill).height(170))
.class(visuals::surface(p.sidebar, 5.))
.into(),
];
for (i, rect) in positions(&self.displays, size.width, 170.) {
let display = &self.displays[i];
let matches = |t: &&crate::displays::Target| {
t.connected && (t.matcher == display.connector || t.matcher == display.identity)
};
let target = targets
.iter()
.filter(matches)
.find(|t| selected.is_some_and(|s| s.profile == t.profile))
.or_else(|| targets.iter().filter(matches).find(|t| t.active))
.or_else(|| targets.iter().find(matches));
let active = selected.is_some_and(|t| {
t.matcher == display.connector || t.matcher == display.identity
});
let label = column([])
.spacing(4)
.align_x(cosmic::iced::Alignment::Center)
.push(self.label(display.connector.clone(), 12.))
.push(self.label(
format!(
"{} × {}",
dimensions(display).0 as i32,
dimensions(display).1 as i32
),
10.5,
));
let tile = button::custom(
container(label)
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.name(display.connector.clone())
.width(rect.width)
.height(rect.height)
.padding(4)
.class(visuals::button_style(p, active))
.on_press_maybe(target.map(|t| Message::SelectDisplay(t.key.clone())));
layers.push(cosmic::iced::widget::pin(tile).x(rect.x).y(rect.y).into());
}
cosmic::iced::widget::stack(layers)
.width(Length::Fill)
.height(170)
.into()
})
.width(Length::Fill)
.height(170)
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_preserves_negative_positions_rotation_and_monitor_order() {
let displays: Vec<_> = [-1920, 0, 1920]
.into_iter()
.map(|x| Display {
position: Some([x, 0]),
logical_width: Some(1920),
logical_height: Some(1080),
enabled: true,
connected: true,
..Default::default()
})
.collect();
let rects = positions(&displays, 900., 170.);
assert_eq!(rects.len(), 3);
for pair in rects.windows(2) {
assert!(pair[0].1.x + pair[0].1.width < pair[1].1.x)
}
assert!(rects.iter().all(|(_, r)| r.x >= 0.
&& r.y >= 0.
&& r.x + r.width <= 900.
&& r.y + r.height <= 170.));
let rotated = Display {
current: Some(crate::displays::Mode {
width: 3840,
height: 2160,
refresh: 60000,
}),
scale: 2.,
transform: "rotate_90".into(),
..Default::default()
};
assert_eq!(dimensions(&rotated), (1080., 1920.));
}
}
+165 -4
View File
@@ -36,12 +36,21 @@ impl Mode {
} }
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, Default)]
pub struct Display { pub struct Display {
pub connector: String, pub connector: String,
pub identity: String, pub identity: String,
pub profile: Option<String>, pub profile: Option<String>,
pub logical_width: Option<i32>, pub logical_width: Option<i32>,
pub logical_height: Option<i32>,
pub position: Option<[i32; 2]>,
pub scale: f64,
pub transform: String,
pub enabled: bool,
pub connected: bool,
pub hardware: bool,
pub confirmation_pending: bool,
pub configuration_error: Option<String>,
pub focused: bool, pub focused: bool,
pub current: Option<Mode>, pub current: Option<Mode>,
pub modes: Vec<Mode>, pub modes: Vec<Mode>,
@@ -62,6 +71,21 @@ pub fn load() -> Result<Vec<Display>, String> {
.as_i64() .as_i64()
.and_then(|width| width.try_into().ok()) .and_then(|width| width.try_into().ok())
.filter(|width| *width > 0), .filter(|width| *width > 0),
logical_height: value["height"]
.as_i64()
.and_then(|v| v.try_into().ok())
.filter(|h| *h > 0),
position: value["x"]
.as_i64()
.zip(value["y"].as_i64())
.and_then(|(x, y)| Some([x.try_into().ok()?, y.try_into().ok()?])),
scale: value["scale"].as_f64().unwrap_or(1.),
transform: config_transform(value["transform"].as_str().unwrap_or("normal")).into(),
enabled: value["enabled"].as_bool().unwrap_or(true),
connected: value["connected"].as_bool().unwrap_or(true),
hardware: value.get("requested_enabled").is_some(),
confirmation_pending: value["confirmation_pending"].as_bool().unwrap_or(false),
configuration_error: value["configuration_error"].as_str().map(str::to_owned),
focused: value["focused"].as_bool().unwrap_or(false), focused: value["focused"].as_bool().unwrap_or(false),
current: Mode::from_value(&value["current_mode"]), current: Mode::from_value(&value["current_mode"]),
modes: value["available_modes"] modes: value["available_modes"]
@@ -75,6 +99,118 @@ pub fn load() -> Result<Vec<Display>, String> {
.collect()) .collect())
} }
pub(super) fn config_transform(value: &str) -> &str {
match value {
"90" => "rotate_90",
"180" => "rotate_180",
"270" => "rotate_270",
"flipped-90" => "flipped_90",
"flipped-180" => "flipped_180",
"flipped-270" => "flipped_270",
other => other,
}
}
pub(super) fn profile(
snapshot: &crate::store::Snapshot,
displays: &[Display],
) -> Result<(String, Edit), String> {
let connected: Vec<_> = displays.iter().filter(|d| d.connected).collect();
if connected.is_empty() {
return Err("Connect a display before creating a profile".into());
}
let mut serial = 1;
let name = loop {
let name =
ferese_config::locale::format("Desktop {number}", &[("number", serial.to_string())]);
if !(0..snapshot.records("output_profiles"))
.any(|i| snapshot.string(&format!("output_profiles.{i}.name"), "") == name)
{
break name;
}
serial += 1;
};
let outputs:Vec<_>=connected.into_iter().map(|d|{
let mut value=serde_json::json!({"match":selector(d,displays),"enabled":d.enabled,"required":true,"scale":if d.scale>0.{d.scale}else{1.},"transform":config_transform(if d.transform.is_empty(){"normal"}else{&d.transform})});
if let Some(mode)=d.current {value["mode"]=mode.config().into();}
if let Some(position)=d.position {value["position"]=serde_json::json!(position);}
value
}).collect();
Ok((
name.clone(),
Edit::Add(
"output_profiles".into(),
vec![
("name".into(), name.into()),
("layout".into(), "extend".into()),
("confirm_timeout".into(), 15.into()),
("outputs".into(), outputs.into()),
],
),
))
}
pub(super) fn selector<'a>(display: &'a Display, all: &[Display]) -> &'a str {
if !display.identity.is_empty()
&& all
.iter()
.filter(|d| d.connected && d.identity == display.identity)
.count()
== 1
{
&display.identity
} else {
&display.connector
}
}
pub(super) fn configured_position(
snapshot: &crate::Snapshot,
prefix: &str,
live: Option<&Display>,
) -> [i32; 2] {
let value = snapshot
.item(&format!("{prefix}.position"))
.and_then(|v| v.as_array());
value
.and_then(|a| {
Some([
a.first()?.as_i64()?.try_into().ok()?,
a.get(1)?.as_i64()?.try_into().ok()?,
])
})
.or_else(|| live.and_then(|d| d.position))
.unwrap_or([0, 0])
}
/// Count the draft's enabled connected outputs, including edits that have not
/// reached the compositor yet. Unmatched outputs keep their current state.
pub(super) fn enabled_count(
snapshot: &crate::Snapshot,
displays: &[Display],
profile: Option<&str>,
) -> usize {
let index = profile.and_then(|name| {
(0..snapshot.records("output_profiles"))
.find(|i| snapshot.string(&format!("output_profiles.{i}.name"), "") == name)
});
displays
.iter()
.filter(|d| d.connected)
.filter(|d| {
let output = index.and_then(|i| {
let prefix = format!("output_profiles.{i}.outputs");
(0..snapshot.records(&prefix)).find_map(|j| {
let path = format!("{prefix}.{j}");
let matcher = snapshot.string(&format!("{path}.match"), "");
(matcher == d.connector || matcher == d.identity)
.then(|| snapshot.boolean(&format!("{path}.enabled"), true))
})
});
output.unwrap_or(d.enabled)
})
.count()
}
pub fn choices(display: &Display, configured: &str) -> Vec<Mode> { pub fn choices(display: &Display, configured: &str) -> Vec<Mode> {
let size = configured let size = configured
.split('@') .split('@')
@@ -117,8 +253,14 @@ impl std::fmt::Display for Target {
f, f,
"{} · {}{}", "{} · {}{}",
self.title, self.title,
self.profile.as_deref().unwrap_or("Automatic"), self.profile
if self.connected { "" } else { " (offline)" } .as_deref()
.unwrap_or(ferese_config::tr!("Automatic")),
if self.connected {
String::new()
} else {
format!(" · {}", ferese_config::tr!("Offline"))
}
) )
} }
} }
@@ -133,7 +275,7 @@ pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) -
let matcher = snapshot.string(&format!("{prefix}.match"), ""); let matcher = snapshot.string(&format!("{prefix}.match"), "");
let live = displays let live = displays
.iter() .iter()
.find(|d| d.connector == matcher || d.identity == matcher); .find(|d| d.connected && (d.connector == matcher || d.identity == matcher));
targets.push(Target { targets.push(Target {
key: format!("{name}\0{matcher}"), key: format!("{name}\0{matcher}"),
title: live title: live
@@ -148,6 +290,9 @@ pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) -
} }
} }
for display in displays { for display in displays {
if !display.connected {
continue;
}
if !targets if !targets
.iter() .iter()
.any(|t| t.matcher == display.connector || t.matcher == display.identity) .any(|t| t.matcher == display.connector || t.matcher == display.identity)
@@ -310,6 +455,10 @@ mod tests {
refresh: 59940, refresh: 59940,
}, },
], ],
connected: true,
enabled: true,
scale: 1.,
..Default::default()
}; };
let choices = choices(&display, "2880x1800@120"); let choices = choices(&display, "2880x1800@120");
assert_eq!(choices.len(), 2); assert_eq!(choices.len(), 2);
@@ -375,6 +524,10 @@ mod tests {
focused: true, focused: true,
current: None, current: None,
modes: vec![], modes: vec![],
connected: true,
enabled: true,
scale: 1.,
..Default::default()
}]; }];
let values = targets(&snapshot, &live); let values = targets(&snapshot, &live);
assert_eq!( assert_eq!(
@@ -407,6 +560,10 @@ mod tests {
focused: true, focused: true,
current: None, current: None,
modes: vec![], modes: vec![],
connected: true,
enabled: true,
scale: 1.,
..Default::default()
}]; }];
let values = targets(&snapshot, &live); let values = targets(&snapshot, &live);
assert_eq!(values.len(), 1); assert_eq!(values.len(), 1);
@@ -432,6 +589,10 @@ mod tests {
mode(2880, 1800, 120000), mode(2880, 1800, 120000),
mode(2560, 1440, 90000), mode(2560, 1440, 90000),
], ],
connected: true,
enabled: true,
scale: 1.,
..Default::default()
}; };
assert_eq!(resolutions(&display).len(), 2); assert_eq!(resolutions(&display).len(), 2);
let high = Resolution { let high = Resolution {
+225 -5
View File
@@ -10,7 +10,48 @@ impl App {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let targets = displays::targets(&self.draft, &self.displays); let targets = displays::targets(&self.draft, &self.displays);
let selected = displays::selected(&targets, self.display_selection.as_deref()).cloned(); let selected = displays::selected(&targets, self.display_selection.as_deref()).cloned();
let mut panel = column([]).spacing(6); let pending = self.displays.iter().any(|d| d.confirmation_pending);
let available = !self.saving && !self.display_state.busy;
let mut panel = column([])
.spacing(10)
.push(self.panel_heading(ferese_config::tr!("Current display arrangement"), 14.))
.push(self.display_map());
if pending {
panel =
panel
.push(self.note(
"Keep these display settings? Unconfirmed changes revert automatically.",
))
.push(
row([])
.spacing(8)
.push(self.settings_button(
"Keep changes",
"M5 12l4 4L19 6",
available.then_some(Message::DisplayInput(
crate::display_controls::Input::Confirm,
)),
true,
))
.push(self.settings_button(
"Revert changes",
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
available.then_some(Message::DisplayInput(
crate::display_controls::Input::Revert,
)),
false,
)),
);
} else if self.display_state.recovery.is_some() {
panel = panel.push(self.settings_button(
"Restore previous display configuration",
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
available.then_some(Message::DisplayInput(
crate::display_controls::Input::Restore,
)),
false,
));
}
panel = panel.push( panel = panel.push(
row([]) row([])
.spacing(8) .spacing(8)
@@ -26,7 +67,7 @@ impl App {
selected.clone(), selected.clone(),
|target| Message::SelectDisplay(target.key), |target| Message::SelectDisplay(target.key),
) )
.placeholder("Select display") .placeholder(ferese_config::tr!("Select display"))
.font(self.font) .font(self.font)
.text_size(12) .text_size(12)
.padding([4, 8]) .padding([4, 8])
@@ -41,6 +82,14 @@ impl App {
Some(Message::RefreshDisplays), Some(Message::RefreshDisplays),
)), )),
); );
panel = panel.push(self.settings_button(
"Save current layout as a profile",
"M12 5v14 M5 12h14",
(available && self.displays.iter().any(|d| d.connected)).then_some(
Message::DisplayInput(crate::display_controls::Input::Create),
),
false,
));
if let Some(error) = &self.display_error { if let Some(error) = &self.display_error {
panel = panel.push(cosmic::widget::tooltip( panel = panel.push(cosmic::widget::tooltip(
self.note(ferese_config::tr!("Live display information unavailable")), self.note(ferese_config::tr!("Live display information unavailable")),
@@ -59,8 +108,119 @@ impl App {
.map(|mode| format!("{} × {} · {}", mode.width, mode.height, mode.label())) .map(|mode| format!("{} × {} · {}", mode.width, mode.height, mode.label()))
.unwrap_or_else(|| "Off".into()); .unwrap_or_else(|| "Off".into());
panel = panel.push(self.note(&status)); panel = panel.push(self.note(&status));
if !display.identity.is_empty() {
panel = panel.push(self.note(&display.identity));
}
if let Some(error) = &display.configuration_error {
panel = panel.push(self.note(error));
}
if !display.hardware {
panel=panel.push(self.note("This display is managed by the host session. Saved profiles apply in a standalone session."));
}
} }
if let Some(prefix) = target.prefix { if let Some(prefix) = target.prefix {
if let Some(index) = crate::display_controls::index(&prefix) {
let profile_prefix = format!("output_profiles.{index}");
let name = self
.inputs
.get("$display.profile_name")
.cloned()
.unwrap_or_else(|| {
self.draft.string(&format!("{profile_prefix}.name"), "")
});
panel = panel
.push(self.panel_heading(ferese_config::tr!("Display profile"), 14.))
.push(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(
cosmic::widget::text_input(
ferese_config::tr!("Profile name"),
name,
)
.font(self.font)
.size(12)
.style(visuals::input_style(palette))
.on_input(|name| {
Message::Draft("$display.profile_name".into(), name)
})
.on_submit(move |_| {
Message::DisplayInput(
crate::display_controls::Input::Rename(index),
)
})
.width(Length::Fill),
)
.push(self.settings_icon_button(
"Rename profile",
"M4 20l4-1L20 7l-3-3L5 16z",
(available && !pending).then_some(Message::DisplayInput(
crate::display_controls::Input::Rename(index),
)),
))
.push(self.settings_icon_button(
"Remove profile",
"M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13",
(available && !pending).then_some(Message::DisplayInput(
crate::display_controls::Input::Delete(index),
)),
)),
);
if live.is_some_and(|d| d.hardware) && !target.active {
panel = panel.push(self.settings_button(
"Use this profile",
"M5 12l4 4L19 6",
(available && !pending).then_some(Message::DisplayInput(
crate::display_controls::Input::Activate(
target.profile.clone().unwrap_or_default(),
),
)),
true,
));
}
panel = panel.push(self.field(schema::choice(
&format!("{profile_prefix}.layout"),
"Display layout",
"",
"extend",
&[
("extend", "Extend"),
("mirror", "Mirror"),
("internal-only", "Internal display only"),
("external-only", "External displays only"),
],
)));
}
panel = panel.push(self.panel_heading(ferese_config::tr!("Output settings"), 14.));
let enabled = self.draft.boolean(&format!("{prefix}.enabled"), true);
let can_disable = !enabled
|| !target.active
|| displays::enabled_count(
&self.draft,
&self.displays,
target.profile.as_deref(),
) > 1;
let enable_path = format!("{prefix}.enabled");
panel = panel.push(
self.display_control(
"Enable display",
container(
ferese_theme::controls::switch(enabled, palette)
.name(ferese_config::tr!("Enable display"))
.on_press_maybe((available && can_disable).then_some(
Message::Change(crate::store::set(&enable_path, !enabled)),
)),
)
.width(225)
.align_right(Length::Fill)
.into(),
),
);
if enabled && !can_disable {
panel = panel
.push(self.note("At least one connected display must remain enabled."));
}
let configured = self.draft.string(&format!("{prefix}.mode"), ""); let configured = self.draft.string(&format!("{prefix}.mode"), "");
let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false); let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false);
if let Some(display) = live { if let Some(display) = live {
@@ -150,13 +310,73 @@ impl App {
"", "",
schema::RangeSpec { schema::RangeSpec {
default: 1., default: 1.,
min: 0.75, min: 0.5,
max: 3., max: 4.,
step: 0.05, step: 0.25,
suffix: "×", suffix: "×",
integer: false, integer: false,
}, },
))); )));
panel = panel.push(self.field(schema::choice(
&format!("{prefix}.transform"),
"Rotation and reflection",
"",
"normal",
&[
("normal", "Landscape"),
("rotate_90", "Portrait right"),
("rotate_180", "Landscape inverted"),
("rotate_270", "Portrait left"),
("flipped", "Reflected landscape"),
("flipped_90", "Reflected portrait right"),
("flipped_180", "Reflected landscape inverted"),
("flipped_270", "Reflected portrait left"),
],
)));
let saved = displays::configured_position(&self.draft, &prefix, live);
let mut position = row([])
.spacing(8)
.align_y(Alignment::Center)
.push(self.label(ferese_config::tr!("Position"), 13.));
for (i, axis) in ["x", "y"].into_iter().enumerate() {
let key = format!("$display.{prefix}.{axis}");
let fallback = saved[i];
let value = self
.inputs
.get(&key)
.cloned()
.unwrap_or_else(|| fallback.to_string());
let path = prefix.clone();
position = position.push(
cosmic::widget::text_input(axis, value)
.font(self.font)
.size(12)
.style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(key.clone(), value))
.on_submit(move |_| {
Message::DisplayInput(crate::display_controls::Input::Position(
path.clone(),
))
})
.width(Length::Fill),
);
}
position = position
.push(self.settings_icon_button(
"Apply position",
"M5 12l4 4L19 6",
available.then_some(Message::DisplayInput(
crate::display_controls::Input::Position(prefix.clone()),
)),
))
.push(self.settings_icon_button(
"Automatic position",
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
available.then_some(Message::DisplayInput(
crate::display_controls::Input::AutoPosition(prefix.clone()),
)),
));
panel=panel.push(position).push(self.note("Positions use logical pixels after scaling and rotation. Negative values are supported."));
panel = panel.push(row([]).spacing(5).align_y(Alignment::Center) panel = panel.push(row([]).spacing(5).align_y(Alignment::Center)
.push(cosmic::widget::tooltip(visuals::action_icon("M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 11v6 M12 7h.01", palette.muted), .push(cosmic::widget::tooltip(visuals::action_icon("M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 11v6 M12 7h.01", palette.muted),
self.label(ferese_config::tr!("Auto uses 60 Hz below 30% battery and returns to normal on AC or at 35%. Applying a display mode can briefly blank the screen."), 11.), cosmic::widget::tooltip::Position::Top)) self.label(ferese_config::tr!("Auto uses 60 Hz below 30% battery and returns to normal on AC or at 35%. Applying a display mode can briefly blank the screen."), 11.), cosmic::widget::tooltip::Position::Top))
@@ -0,0 +1,604 @@
use crate::{
App, Element, Length, Message, Snapshot, Task,
store::{Edit, set},
visuals,
};
use cosmic::widget::{column, combo_box, container, row};
use std::{
collections::BTreeMap,
io::Read,
sync::{Arc, OnceLock},
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Choice {
pub code: String,
pub name: String,
translated: Option<String>,
}
impl std::fmt::Display for Choice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = if ferese_config::locale::resolved() == ferese_config::locale::Language::Chinese
{
self.translated
.clone()
.unwrap_or_else(|| ferese_config::locale::translate(&self.name))
} else {
self.name.clone()
};
if self.code.is_empty() {
f.write_str(&name)
} else {
write!(f, "{} · {}", name, self.code)
}
}
}
#[derive(Default, Debug)]
pub(super) struct Catalog {
layouts: Vec<Choice>,
variants: BTreeMap<String, Vec<Choice>>,
switches: Vec<Choice>,
}
impl Catalog {
fn parse(source: &str) -> Self {
let mut result = Self::default();
let mut section = "";
for line in source.lines().map(str::trim).filter(|s| !s.is_empty()) {
if let Some(header) = line.strip_prefix('!') {
section = header.trim();
continue;
}
let Some((code, rest)) = line.split_once(char::is_whitespace) else {
continue;
};
if section == "layout" {
result.layouts.push(Choice {
code: code.into(),
name: rest.trim().into(),
translated: None,
});
} else if section == "variant"
&& let Some((layout, name)) = rest.trim().split_once(':')
{
result
.variants
.entry(layout.trim().into())
.or_default()
.push(Choice {
code: code.into(),
name: name.trim().into(),
translated: None,
});
} else if section == "option" && code.starts_with("grp:") {
result.switches.push(Choice {
code: code.into(),
name: rest.trim().into(),
translated: None,
});
}
}
result.layouts.sort_by_key(|c| c.name.to_lowercase());
result.layouts.dedup_by(|a, b| a.code == b.code);
result
}
}
pub(super) fn catalog() -> Result<Arc<Catalog>, String> {
static CACHE: OnceLock<Arc<Catalog>> = OnceLock::new();
if let Some(value) = CACHE.get() {
return Ok(value.clone());
}
let root = std::env::var_os("XKB_CONFIG_ROOT")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| "/usr/share/X11/xkb".into());
let mut source = String::new();
std::fs::File::open(root.join("rules/evdev.lst"))
.map_err(|e| e.to_string())?
.take(1024 * 1024 + 1)
.read_to_string(&mut source)
.map_err(|e| e.to_string())?;
if source.len() > 1024 * 1024 {
return Err("Keyboard catalog exceeds the size limit".into());
}
let mut parsed = Catalog::parse(&source);
if let Ok(file) = std::fs::File::open("/usr/share/locale/zh_CN/LC_MESSAGES/xkeyboard-config.mo")
{
let mut bytes = Vec::new();
if file
.take(4 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)
.is_ok()
&& bytes.len() <= 4 * 1024 * 1024
{
let translations = translations(&bytes);
for choice in parsed
.layouts
.iter_mut()
.chain(parsed.variants.values_mut().flatten())
.chain(parsed.switches.iter_mut())
{
choice.translated = translations.get(&choice.name).cloned();
}
}
}
if parsed.layouts.is_empty() {
return Err("No keyboard layouts found".into());
}
let value = Arc::new(parsed);
let _ = CACHE.set(value.clone());
Ok(value)
}
// Read the installed XKB gettext catalog without changing process-wide locale.
fn translations(bytes: &[u8]) -> BTreeMap<String, String> {
let mut result = BTreeMap::new();
let Some(magic) = bytes
.get(..4)
.and_then(|s| s.try_into().ok())
.map(u32::from_le_bytes)
else {
return result;
};
let big = match magic {
0x950412de => false,
0xde120495 => true,
_ => return result,
};
let word = |offset: usize| -> Option<usize> {
let raw: [u8; 4] = bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?;
Some(if big {
u32::from_be_bytes(raw)
} else {
u32::from_le_bytes(raw)
} as usize)
};
let Some((count, original, translated)) = word(8)
.zip(word(12))
.zip(word(16))
.map(|((a, b), c)| (a, b, c))
else {
return result;
};
if count > 100_000 {
return result;
}
let string = |table: usize, index: usize| -> Option<&str> {
let entry = table.checked_add(index.checked_mul(8)?)?;
let length = word(entry)?;
let start = word(entry.checked_add(4)?)?;
std::str::from_utf8(bytes.get(start..start.checked_add(length)?)?).ok()
};
for i in 0..count {
if let Some((key, value)) = string(original, i).zip(string(translated, i))
&& !key.is_empty()
&& !value.is_empty()
{
result.insert(
key.split('\0').next().unwrap_or_default().into(),
value.split('\0').next().unwrap_or_default().into(),
);
}
}
result
}
pub(super) struct LayoutRow {
layouts: combo_box::State<Choice>,
variants: combo_box::State<Choice>,
layout: Choice,
variant: Choice,
}
#[derive(Default)]
pub(super) struct State {
catalog: Option<Arc<Catalog>>,
loading: bool,
error: Option<String>,
rows: Vec<LayoutRow>,
signature: String,
pub adding: bool,
add_choices: Option<combo_box::State<Choice>>,
switches: Option<combo_box::State<Choice>>,
switch: Option<Choice>,
}
#[derive(Clone, Debug)]
pub(super) enum Input {
Layout(usize, Choice),
Variant(usize, Choice),
Switch(Choice),
Remove(usize),
Move(usize, bool),
Add,
Retry,
}
fn configured_layouts(snapshot: &Snapshot) -> Vec<(String, String)> {
let variants = snapshot.string("input.xkb_variant", "");
let variants: Vec<_> = variants.split(',').collect();
snapshot
.string("input.xkb_layout", "us")
.split(',')
.enumerate()
.map(|(i, l)| {
(
l.to_owned(),
variants.get(i).copied().unwrap_or_default().to_owned(),
)
})
.collect()
}
fn edits(rows: &[(String, String)]) -> Vec<Edit> {
vec![
set(
"input.xkb_layout",
rows.iter()
.map(|r| r.0.as_str())
.collect::<Vec<_>>()
.join(","),
),
set(
"input.xkb_variant",
rows.iter()
.map(|r| r.1.as_str())
.collect::<Vec<_>>()
.join(","),
),
]
}
impl State {
pub fn sync(&mut self, snapshot: &Snapshot) {
let signature = format!(
"{}|{}|{:?}|{}",
snapshot.string("input.xkb_layout", "us"),
snapshot.string("input.xkb_variant", ""),
ferese_config::locale::resolved(),
self.adding
);
let signature = format!("{signature}|{:?}", snapshot.item("input.xkb_options"));
if signature == self.signature {
return;
}
self.signature = signature;
let Some(catalog) = &self.catalog else { return };
let mut switches = vec![Choice {
code: String::new(),
name: "No layout switching shortcut".into(),
translated: None,
}];
switches.extend(catalog.switches.clone());
let configured = snapshot
.item("input.xkb_options")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
.find(|v| v.starts_with("grp:"))
.unwrap_or("");
let selected = switches
.iter()
.find(|s| s.code == configured)
.cloned()
.unwrap_or_else(|| Choice {
code: configured.into(),
name: configured.into(),
translated: None,
});
if !switches.contains(&selected) {
switches.push(selected.clone());
}
self.switch = Some(selected);
self.switches = Some(combo_box::State::new(switches));
self.rows = configured_layouts(snapshot)
.into_iter()
.map(|(layout, variant)| {
let mut layouts = catalog.layouts.clone();
let layout = layouts
.iter()
.find(|c| c.code == layout)
.cloned()
.unwrap_or_else(|| Choice {
code: layout.clone(),
name: layout,
translated: None,
});
if !layouts.contains(&layout) {
layouts.push(layout.clone());
}
let mut variants = vec![Choice {
code: String::new(),
name: "Default variant".into(),
translated: None,
}];
variants.extend(
catalog
.variants
.get(&layout.code)
.cloned()
.unwrap_or_default(),
);
let variant = variants
.iter()
.find(|v| v.code == variant)
.cloned()
.unwrap_or_else(|| Choice {
code: variant.clone(),
name: variant,
translated: None,
});
if !variants.contains(&variant) {
variants.push(variant.clone());
}
LayoutRow {
layouts: combo_box::State::new(layouts),
variants: combo_box::State::new(variants),
layout,
variant,
}
})
.collect();
}
}
impl App {
pub(super) fn load_keyboard_catalog(&mut self) -> Task<Message> {
if self.keyboard.catalog.is_some() || self.keyboard.loading {
return Task::none();
}
self.keyboard.loading = true;
cosmic::task::future(async {
Message::KeyboardCatalog(
tokio::task::spawn_blocking(catalog)
.await
.unwrap_or_else(|e| Err(e.to_string())),
)
})
}
pub(super) fn keyboard_catalog_loaded(&mut self, result: Result<Arc<Catalog>, String>) {
self.keyboard.loading = false;
match result {
Ok(value) => {
self.keyboard.catalog = Some(value);
self.keyboard.error = None;
self.keyboard.signature.clear();
self.keyboard.sync(&self.draft)
}
Err(error) => self.keyboard.error = Some(error),
}
}
pub(super) fn keyboard_input(&mut self, input: Input) -> Task<Message> {
match input {
Input::Retry => return self.load_keyboard_catalog(),
Input::Add => {
self.keyboard.adding = true;
self.keyboard.add_choices = self
.keyboard
.catalog
.as_ref()
.map(|c| combo_box::State::new(c.layouts.clone()));
return Task::none();
}
_ => {}
}
if let Input::Switch(choice) = input {
let mut options: Vec<_> = self
.draft
.item("input.xkb_options")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
.filter(|v| !v.starts_with("grp:"))
.map(str::to_owned)
.collect();
if !choice.code.is_empty() {
options.push(choice.code);
}
return self.change(set("input.xkb_options", options));
}
let mut rows = configured_layouts(&self.draft);
match input {
Input::Layout(index, choice) => {
if index == rows.len() {
rows.push((choice.code, String::new()));
self.keyboard.adding = false;
self.keyboard.add_choices = None;
} else if let Some(row) = rows.get_mut(index) {
row.0 = choice.code;
row.1.clear();
}
}
Input::Variant(index, choice) => {
if let Some(row) = rows.get_mut(index) {
row.1 = choice.code;
}
}
Input::Remove(index) if rows.len() > 1 && index < rows.len() => {
rows.remove(index);
}
Input::Move(index, up) if index < rows.len() => {
let next = if up {
index.saturating_sub(1)
} else {
(index + 1).min(rows.len() - 1)
};
rows.swap(index, next);
}
_ => return Task::none(),
}
let task = self.edit_many(edits(&rows));
self.keyboard.sync(&self.draft);
task
}
pub(super) fn keyboard_layout_controls(&self) -> Element<'_, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let mut items = column([])
.spacing(10)
.push(self.panel_heading(ferese_config::tr!("Keyboard layouts"), 14.));
if let Some(error) = &self.keyboard.error {
items = items.push(self.note(error)).push(self.settings_button(
"Retry",
"M20 7V3l-3 3a8 8 0 1 0 3 6",
Some(Message::Keyboard(Input::Retry)),
false,
));
} else if self.keyboard.catalog.is_none() {
items = items.push(self.note("Loading keyboard layouts…"));
}
for (i, entry) in self.keyboard.rows.iter().enumerate() {
items = items.push(
column([])
.spacing(6)
.push(
row([])
.spacing(8)
.align_y(cosmic::iced::Alignment::Center)
.push(self.search_select(
&entry.layouts,
ferese_config::tr!("Search layouts…"),
Some(&entry.layout),
move |v| Message::Keyboard(Input::Layout(i, v)),
))
.push(
self.settings_icon_button(
"Move up",
"M5 15l7-7 7 7",
(i > 0 && !self.saving)
.then_some(Message::Keyboard(Input::Move(i, true))),
),
)
.push(
self.settings_icon_button(
"Remove layout",
"M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13",
(self.keyboard.rows.len() > 1 && !self.saving)
.then_some(Message::Keyboard(Input::Remove(i))),
),
),
)
.push(self.search_select(
&entry.variants,
ferese_config::tr!("Search variants…"),
Some(&entry.variant),
move |v| Message::Keyboard(Input::Variant(i, v)),
)),
);
}
if let Some(options) = &self.keyboard.switches {
items = items
.push(self.label(ferese_config::tr!("Switch keyboard layout"), 12.))
.push(self.search_select(
options,
ferese_config::tr!("Search layout switching shortcuts…"),
self.keyboard.switch.as_ref(),
|v| Message::Keyboard(Input::Switch(v)),
));
}
if self.keyboard.adding
&& let Some(choices) = &self.keyboard.add_choices
{
let index = self.keyboard.rows.len();
items = items.push(self.search_select(
choices,
ferese_config::tr!("Select a layout to add…"),
None,
move |v| Message::Keyboard(Input::Layout(index, v)),
));
}
items = items.push(
row([])
.spacing(8)
.push(
self.settings_button(
"Add layout",
"M12 5v14 M5 12h14",
(!self.saving && self.keyboard.catalog.is_some())
.then_some(Message::Keyboard(Input::Add)),
false,
),
)
.push(self.settings_button(
"Shortcuts",
"M8 8h8v8H8z",
Some(Message::Page(crate::schema::Page::Shortcuts)),
false,
)),
);
container(items)
.padding(10)
.width(Length::Fill)
.class(visuals::section(p))
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::Application;
#[test]
fn switching_layouts_preserves_other_xkb_options_and_catalog_loads_coalesce() {
let (mut app, _) = App::init(
cosmic::app::Core::default(),
(
"/unused/keyboard-test.kdl".into(),
Snapshot::parse(
"input { xkb-options \"compose:ralt\" \"grp:alt_shift_toggle\"; }".into(),
),
None,
None,
),
);
assert_eq!(app.load_keyboard_catalog().units(), 1);
assert_eq!(app.load_keyboard_catalog().units(), 0);
app.keyboard_catalog_loaded(Ok(Arc::new(Catalog::parse(
"! layout\n us English (US)\n de German\n! option\n grp:ctrl_shift_toggle Ctrl+Shift\n",
))));
app.saving = true;
drop(app.keyboard_input(Input::Switch(Choice {
code: "grp:ctrl_shift_toggle".into(),
name: "Ctrl+Shift".into(),
translated: None,
})));
assert_eq!(
app.draft.item("input.xkb_options").unwrap(),
&serde_json::json!(["compose:ralt", "grp:ctrl_shift_toggle"])
);
assert_eq!(app.load_keyboard_catalog().units(), 0);
}
#[test]
fn gettext_reader_is_bounded_and_does_not_change_the_process_locale() {
let mut bytes = Vec::new();
for word in [0x950412de_u32, 0, 1, 28, 36, 0, 0, 6, 44, 6, 50] {
bytes.extend_from_slice(&word.to_le_bytes());
}
bytes.extend_from_slice("German德语".as_bytes());
assert_eq!(
translations(&bytes).get("German").map(String::as_str),
Some("德语")
);
for end in 0..bytes.len() {
let _ = translations(&bytes[..end]);
}
let language = ferese_config::locale::resolved();
let _ = translations(&bytes);
assert_eq!(language, ferese_config::locale::resolved());
}
#[test]
fn catalog_variants_stay_with_their_layout_and_roundtrip_aligned_lists() {
let c = Catalog::parse(
"! layout\n us English (US)\n de German\n! variant\n intl us: English (US, intl.)\n nodeadkeys de: German (no dead keys)\n! option\n ignored nope\n",
);
assert_eq!(c.layouts.len(), 2);
assert_eq!(c.variants["us"][0].code, "intl");
assert_eq!(c.variants["de"][0].code, "nodeadkeys");
let mut s =
Snapshot::parse("input { xkb-layout \"us,de\"; xkb-variant \",nodeadkeys\"; }".into())
.unwrap();
let mut rows = configured_layouts(&s);
rows.swap(0, 1);
for e in edits(&rows) {
s.edit(&e).unwrap();
}
assert_eq!(s.string("input.xkb_variant", ""), "nodeadkeys,");
let mut state = State {
catalog: Some(Arc::new(c)),
..Default::default()
};
state.sync(&s);
assert_eq!(state.rows[0].variant.code, "nodeadkeys");
}
}
+123 -28
View File
@@ -15,10 +15,15 @@ mod tray_controls;
use theme_controls::gallery_id; use theme_controls::gallery_id;
mod display_controls;
mod display_map;
mod displays; mod displays;
mod displays_ui; mod displays_ui;
mod fonts; mod fonts;
mod keyboard_controls;
mod schema; mod schema;
mod selection;
mod shortcut_controls;
mod store; mod store;
mod visuals; mod visuals;
mod wallpaper_controls; mod wallpaper_controls;
@@ -73,6 +78,12 @@ fn main() -> cosmic::iced::Result {
Some("general") => Some(InitialPage::Settings(Page::General)), Some("general") => Some(InitialPage::Settings(Page::General)),
Some("calendar") => Some(InitialPage::Settings(Page::Calendar)), Some("calendar") => Some(InitialPage::Settings(Page::Calendar)),
Some("theme") => Some(InitialPage::Settings(Page::Theme)), Some("theme") => Some(InitialPage::Settings(Page::Theme)),
Some("keyboard") => Some(InitialPage::Settings(Page::Keyboard)),
Some("mouse") => Some(InitialPage::Settings(Page::Mouse)),
Some("touchpad") => Some(InitialPage::Settings(Page::Touchpad)),
Some("shortcuts") => Some(InitialPage::Settings(Page::Shortcuts)),
Some("displays") => Some(InitialPage::Settings(Page::Displays)),
Some("shell") => Some(InitialPage::Settings(Page::Shell)),
Some("tray") => Some(InitialPage::Settings(Page::Tray)), Some("tray") => Some(InitialPage::Settings(Page::Tray)),
Some("all-options") => Some(InitialPage::Settings(Page::Configuration)), Some("all-options") => Some(InitialPage::Settings(Page::Configuration)),
_ => { _ => {
@@ -118,6 +129,11 @@ enum InitialPage {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
DisplayInput(display_controls::Input),
DisplayCommandFinished(display_controls::Command, Result<(), String>),
Shortcut(shortcut_controls::Input),
Keyboard(keyboard_controls::Input),
KeyboardCatalog(Result<std::sync::Arc<keyboard_controls::Catalog>, String>),
Options(all_options::Input), Options(all_options::Input),
LoadOptions, LoadOptions,
OptionsLoaded(Result<std::sync::Arc<serde_json::Value>, String>), OptionsLoaded(Result<std::sync::Arc<serde_json::Value>, String>),
@@ -188,12 +204,14 @@ enum Message {
AddHoliday, AddHoliday,
CalendarFunction(calendar_controls::FunctionInput), CalendarFunction(calendar_controls::FunctionInput),
AddCommand, AddCommand,
AddSwipe(&'static str),
Remove(String, usize), Remove(String, usize),
Thumbnail(String, Result<widget::image::Handle, String>), Thumbnail(String, Result<widget::image::Handle, String>),
} }
struct App { struct App {
display_state: display_controls::State,
shortcuts: shortcut_controls::State,
keyboard: keyboard_controls::State,
tray_apps: Vec<tray_controls::Application>, tray_apps: Vec<tray_controls::Application>,
tray_refresh: refresh::Gate, tray_refresh: refresh::Gate,
options: all_options::State, options: all_options::State,
@@ -312,6 +330,9 @@ impl cosmic::Application for App {
ferese_config::locale::set(ferese_config::locale::from_document(&current.doc)); ferese_config::locale::set(ferese_config::locale::from_document(&current.doc));
let options = all_options::State::pending(&current); let options = all_options::State::pending(&current);
let mut app = Self { let mut app = Self {
display_state: Default::default(),
shortcuts: Default::default(),
keyboard: Default::default(),
tray_apps: Vec::new(), tray_apps: Vec::new(),
tray_refresh: Default::default(), tray_refresh: Default::default(),
options, options,
@@ -400,7 +421,7 @@ impl cosmic::Application for App {
Task::none() Task::none()
}; };
let thumbnails = app.load_thumbnail(); let thumbnails = app.load_thumbnail();
let displays = if app.page == Page::Bar { let displays = if matches!(app.page, Page::Bar | Page::Displays) {
app.load_displays() app.load_displays()
} else { } else {
Task::none() Task::none()
@@ -410,9 +431,14 @@ impl cosmic::Application for App {
} else { } else {
Task::none() Task::none()
}; };
let keyboard = if app.page == Page::Keyboard {
app.load_keyboard_catalog()
} else {
Task::none()
};
( (
app, app,
Task::batch([task, connections, thumbnails, displays, options]), Task::batch([task, connections, thumbnails, displays, options, keyboard]),
) )
} }
@@ -422,6 +448,8 @@ impl cosmic::Application for App {
if matches!( if matches!(
message, message,
Message::Draft(..) Message::Draft(..)
| Message::Shortcut(_)
| Message::Keyboard(_)
| Message::Range(..) | Message::Range(..)
| Message::Options(_) | Message::Options(_)
| Message::CalendarFunction(_) | Message::CalendarFunction(_)
@@ -429,6 +457,13 @@ impl cosmic::Application for App {
self.edit_revision = self.edit_revision.wrapping_add(1); self.edit_revision = self.edit_revision.wrapping_add(1);
} }
match message { match message {
Message::DisplayInput(input) => return self.display_input(input),
Message::DisplayCommandFinished(command, result) => {
return self.display_command_finished(command, result);
}
Message::Shortcut(input) => return self.shortcut_input(input),
Message::Keyboard(input) => return self.keyboard_input(input),
Message::KeyboardCatalog(result) => self.keyboard_catalog_loaded(result),
Message::TrayApps(apps) => { Message::TrayApps(apps) => {
self.tray_refresh.finish(); self.tray_refresh.finish();
if let Ok(apps) = apps { if let Ok(apps) = apps {
@@ -453,7 +488,10 @@ impl cosmic::Application for App {
Message::ConnectionsRefreshed(snapshot) => self.connections_refreshed(*snapshot), Message::ConnectionsRefreshed(snapshot) => self.connections_refreshed(*snapshot),
Message::ConnectionFinished(result) => return self.connection_finished(result), Message::ConnectionFinished(result) => return self.connection_finished(result),
Message::ConnectionsLeft => {} Message::ConnectionsLeft => {}
Message::SelectDisplay(key) => self.display_selection = Some(key), Message::SelectDisplay(key) => {
self.inputs.remove("$display.profile_name");
self.display_selection = Some(key);
}
Message::DisplayResolution(prefix, size) => { Message::DisplayResolution(prefix, size) => {
let matcher = self.draft.string(&format!("{prefix}.match"), ""); let matcher = self.draft.string(&format!("{prefix}.match"), "");
let configured = self.draft.string(&format!("{prefix}.mode"), ""); let configured = self.draft.string(&format!("{prefix}.mode"), "");
@@ -525,6 +563,7 @@ impl cosmic::Application for App {
|| !self.ranges.is_empty() || !self.ranges.is_empty()
|| !self.pending.is_empty() || !self.pending.is_empty()
|| self.options.dirty() || self.options.dirty()
|| self.shortcuts.opened
|| self.calendar_editor.dirty(&self.draft) || self.calendar_editor.dirty(&self.draft)
{ {
self.status = self.status =
@@ -539,6 +578,10 @@ impl cosmic::Application for App {
self.edit_revision = self.edit_revision.wrapping_add(1); self.edit_revision = self.edit_revision.wrapping_add(1);
self.current = snapshot.clone(); self.current = snapshot.clone();
self.draft = snapshot; self.draft = snapshot;
self.display_state.recovery = None;
self.display_state.queued = None;
self.keyboard.sync(&self.draft);
self.shortcuts.localize();
self.family_sections = theme_controls::FamilySections::new( self.family_sections = theme_controls::FamilySections::new(
&self.resolved.families, &self.resolved.families,
&self.draft, &self.draft,
@@ -601,9 +644,10 @@ impl cosmic::Application for App {
.map(cosmic::Action::App); .map(cosmic::Action::App);
} }
Message::Page(page) => { Message::Page(page) => {
let stop_recording = self.stop_shortcut_capture();
self.sidebar_open = false; self.sidebar_open = false;
if self.page == page && self.search.is_empty() { if self.page == page && self.search.is_empty() {
return Task::none(); return stop_recording;
} }
self.page_transition = self.profile_pages.then(std::time::Instant::now); self.page_transition = self.profile_pages.then(std::time::Instant::now);
@@ -624,6 +668,7 @@ impl cosmic::Application for App {
cosmic::iced::widget::scrollable::RelativeOffset::START.into(), cosmic::iced::widget::scrollable::RelativeOffset::START.into(),
); );
let reset = Task::batch([ let reset = Task::batch([
stop_recording,
reset, reset,
cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation( cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(
page, page,
@@ -635,6 +680,7 @@ impl cosmic::Application for App {
let load = match page { let load = match page {
Page::Displays | Page::Bar => self.load_displays(), Page::Displays | Page::Bar => self.load_displays(),
Page::Wallpaper => self.load_thumbnail(), Page::Wallpaper => self.load_thumbnail(),
Page::Keyboard => self.load_keyboard_catalog(),
Page::Tray => self.load_tray_apps(), Page::Tray => self.load_tray_apps(),
Page::Configuration => self.load_options(), Page::Configuration => self.load_options(),
_ => Task::none(), _ => Task::none(),
@@ -653,6 +699,9 @@ impl cosmic::Application for App {
if page == Page::Wallpaper { if page == Page::Wallpaper {
return Task::batch([reset, self.load_thumbnail()]); return Task::batch([reset, self.load_thumbnail()]);
} }
if page == Page::Keyboard {
return Task::batch([reset, self.load_keyboard_catalog()]);
}
if page == Page::Configuration { if page == Page::Configuration {
return Task::batch([reset, self.load_options()]); return Task::batch([reset, self.load_options()]);
} }
@@ -864,6 +913,10 @@ impl cosmic::Application for App {
self.saving = false; self.saving = false;
match result { match result {
Ok((snapshot, live)) => { Ok((snapshot, live)) => {
if self.display_state.restoring {
self.display_state.restoring = false;
self.display_state.recovery = None;
}
self.undo = Some(std::mem::take(&mut self.saving_previous)); self.undo = Some(std::mem::take(&mut self.saving_previous));
self.current = snapshot; self.current = snapshot;
ferese_config::locale::set(ferese_config::locale::from_document( ferese_config::locale::set(ferese_config::locale::from_document(
@@ -876,6 +929,8 @@ impl cosmic::Application for App {
&self.resolved.presented.tokens.typography.font_family, &self.resolved.presented.tokens.typography.font_family,
); );
self.draft = self.current.clone(); self.draft = self.current.clone();
self.keyboard.sync(&self.draft);
self.shortcuts.localize();
for edit in &self.pending { for edit in &self.pending {
if let Err(error) = self.draft.edit(edit) { if let Err(error) = self.draft.edit(edit) {
@@ -912,35 +967,57 @@ impl cosmic::Application for App {
) )
}) })
.unwrap_or_else(Task::none); .unwrap_or_else(Task::none);
let display_command = self
.display_state
.queued
.take()
.map(|command| self.display_command(command))
.unwrap_or_else(Task::none);
let display_refresh = if self.page == Page::Displays {
self.refresh_displays(true)
} else {
Task::none()
};
return Task::batch([ return Task::batch([
title, title,
self.update_theme(), self.update_theme(),
self.flush(), self.flush(),
self.load_thumbnail(), self.load_thumbnail(),
expire, expire,
display_command,
display_refresh,
]); ]);
} }
Err(error) => { Err(error) => {
self.display_state.queued = None;
self.display_state.restoring = false;
self.error = Some(error); self.error = Some(error);
self.pending.clear(); self.pending.clear();
self.draft = self.current.clone(); self.draft = self.current.clone();
self.keyboard.sync(&self.draft);
self.shortcuts.localize();
self.options.applying = false; self.options.applying = false;
self.status = "Not saved".into(); self.status = "Not saved".into();
} }
} }
} }
Message::Reload if !self.saving && !self.reloading => { Message::Reload if !self.saving && !self.reloading => {
self.shortcuts.opened = false;
let stop_recording = self.stop_shortcut_capture();
self.reloading = true; self.reloading = true;
let revision = self.edit_revision; let revision = self.edit_revision;
let path = self.path.clone(); let path = self.path.clone();
return cosmic::task::future(async move { return Task::batch([
stop_recording,
cosmic::task::future(async move {
Message::Reloaded( Message::Reloaded(
revision, revision,
tokio::task::spawn_blocking(move || Snapshot::read(&path)) tokio::task::spawn_blocking(move || Snapshot::read(&path))
.await .await
.unwrap_or_else(|e| Err(e.to_string())), .unwrap_or_else(|e| Err(e.to_string())),
) )
}); }),
]);
} }
Message::Reloaded(revision, result) => { Message::Reloaded(revision, result) => {
self.reloading = false; self.reloading = false;
@@ -956,6 +1033,10 @@ impl cosmic::Application for App {
self.calendar_editor.refresh(&snapshot); self.calendar_editor.refresh(&snapshot);
self.current = snapshot.clone(); self.current = snapshot.clone();
self.draft = snapshot; self.draft = snapshot;
self.display_state.recovery = None;
self.display_state.queued = None;
self.keyboard.sync(&self.draft);
self.shortcuts.localize();
self.family_sections = theme_controls::FamilySections::new( self.family_sections = theme_controls::FamilySections::new(
&self.resolved.families, &self.resolved.families,
&self.draft, &self.draft,
@@ -1185,24 +1266,6 @@ impl cosmic::Application for App {
self.error = Some("Enter a program and arguments with balanced quotes.".into()) self.error = Some("Enter a program and arguments with balanced quotes.".into())
} }
}, },
Message::AddSwipe(keys) if !self.saving => {
let (action, argument) = match keys {
"Swipe3Up" => ("workspace-next", None),
"Swipe3Down" => ("workspace-previous", None),
"Swipe3Left" => ("focus", Some("right")),
_ => ("focus", Some("left")),
};
let mut fields = vec![
("keys".into(), keys.into()),
("action".into(), action.into()),
];
if let Some(argument) = argument {
fields.push(("argument".into(), argument.into()));
}
return self.change(Edit::Add("bindings".into(), fields));
}
Message::Remove(table, index) if !self.saving => { Message::Remove(table, index) if !self.saving => {
self.inputs.clear(); self.inputs.clear();
let task = self.change(Edit::Remove(table, index)); let task = self.change(Edit::Remove(table, index));
@@ -1215,6 +1278,26 @@ impl cosmic::Application for App {
fn subscription(&self) -> cosmic::iced::Subscription<Message> { fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::Subscription::batch([ cosmic::iced::Subscription::batch([
if self.shortcuts.recording {
cosmic::iced::event::listen_with(|event, _, _| match event {
cosmic::iced::Event::Keyboard(event) => {
Some(Message::Shortcut(shortcut_controls::Input::Key(event)))
}
cosmic::iced::Event::Window(cosmic::iced::window::Event::Unfocused) => {
Some(Message::Shortcut(shortcut_controls::Input::Stop))
}
cosmic::iced::Event::PlatformSpecific(
cosmic::iced::event::PlatformSpecific::Wayland(
cosmic::iced::event::wayland::Event::ShortcutsInhibited(value),
),
) => Some(Message::Shortcut(shortcut_controls::Input::Inhibited(
value,
))),
_ => None,
})
} else {
cosmic::iced::Subscription::none()
},
cosmic::iced::event::listen_with(|event, _, id| match event { cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => { cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
Some(Message::ReadRenderScale(id)) Some(Message::ReadRenderScale(id))
@@ -1292,6 +1375,9 @@ impl App {
cosmic::command::set_theme(palette.native_theme()) cosmic::command::set_theme(palette.native_theme())
} }
fn edit_many(&mut self, edits: Vec<Edit>) -> Task<Message> { fn edit_many(&mut self, edits: Vec<Edit>) -> Task<Message> {
if edits.iter().any(display_controls::affects_outputs) {
self.remember_display_config();
}
self.edit_revision = self.edit_revision.wrapping_add(1); self.edit_revision = self.edit_revision.wrapping_add(1);
for edit in edits { for edit in edits {
if let Err(error) = self.draft.edit(&edit) { if let Err(error) = self.draft.edit(&edit) {
@@ -1324,6 +1410,9 @@ impl App {
} }
fn change(&mut self, edit: Edit) -> Task<Message> { fn change(&mut self, edit: Edit) -> Task<Message> {
if display_controls::affects_outputs(&edit) {
self.remember_display_config();
}
self.edit_revision = self.edit_revision.wrapping_add(1); self.edit_revision = self.edit_revision.wrapping_add(1);
if let Edit::Set(path, value) = &edit if let Edit::Set(path, value) = &edit
&& matches!( && matches!(
@@ -1375,6 +1464,8 @@ impl App {
} }
fn flush(&mut self) -> Task<Message> { fn flush(&mut self) -> Task<Message> {
self.keyboard.sync(&self.draft);
self.shortcuts.localize();
if self.saving || self.pending.is_empty() { if self.saving || self.pending.is_empty() {
return Task::none(); return Task::none();
} }
@@ -1443,6 +1534,10 @@ mod tests {
focused: true, focused: true,
current: None, current: None,
modes: vec![], modes: vec![],
connected: true,
enabled: true,
scale: 1.,
..Default::default()
}]))), }]))),
); );
assert_eq!(app.load_displays().units(), 0); assert_eq!(app.load_displays().units(), 0);
@@ -1951,9 +2046,9 @@ mod tests {
for (parent, query, destination) in [ for (parent, query, destination) in [
(Page::Windows, "motion", Page::Motion), (Page::Windows, "motion", Page::Motion),
(Page::Windows, "animation speed", Page::Motion), (Page::Windows, "animation speed", Page::Motion),
(Page::Keyboard, "shortcuts", Page::Shortcuts), (Page::Shortcuts, "shortcuts", Page::Shortcuts),
(Page::Keyboard, "swipe actions", Page::Shortcuts), (Page::Shortcuts, "swipe actions", Page::Shortcuts),
(Page::Keyboard, "touchpad", Page::Keyboard), (Page::Touchpad, "touchpad", Page::Touchpad),
] { ] {
assert!(parent.matches(query)); assert!(parent.matches(query));
assert_eq!(parent.search_destination(query), destination); assert_eq!(parent.search_destination(query), destination);
+42 -106
View File
@@ -307,6 +307,43 @@ impl App {
{ {
body = body.push(self.panel_controls()); body = body.push(self.panel_controls());
} }
if self.page == Page::Keyboard {
body = body.push(self.keyboard_layout_controls());
}
if self.page == Page::Touchpad {
body = body.push(self.settings_button(
"Customize gestures",
"M3 8h9a3 3 0 1 0-3-3",
Some(Message::Shortcut(
crate::shortcut_controls::Input::OpenGesture,
)),
false,
));
}
if self.page == Page::Shell {
body = body.push(
row([])
.spacing(8)
.push(self.settings_button(
"Tray icons",
"M3 5h18v14H3z",
Some(Message::Page(Page::Tray)),
false,
))
.push(self.settings_button(
"Calendar",
"M4 5h16v16H4z M4 10h16",
Some(Message::Page(Page::Calendar)),
false,
))
.push(self.settings_button(
"Notifications",
"M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z",
Some(Message::Page(Page::Notifications)),
false,
)),
);
}
let fields = if self.page == Page::Bar { let fields = if self.page == Page::Bar {
Vec::new() Vec::new()
} else if self.page == Page::Wallpaper { } else if self.page == Page::Wallpaper {
@@ -318,10 +355,12 @@ impl App {
let fields = fields let fields = fields
.into_iter() .into_iter()
.filter(|field| { .filter(|field| {
self.page != Page::Theme !(self.page == Page::Keyboard
&& (field.path == "input.xkb_layout" || field.path == "input.xkb_variant"))
&& (self.page != Page::Theme
|| !(field.path == "theme.mode" || !(field.path == "theme.mode"
|| field.path.starts_with("theme.schedule.") || field.path.starts_with("theme.schedule.")
|| field.path.ends_with(".file")) || field.path.ends_with(".file")))
}) })
.collect(); .collect();
for group in schema::grouped_fields(self.page, fields) { for group in schema::grouped_fields(self.page, fields) {
@@ -472,84 +511,7 @@ impl App {
), ),
); );
} }
Page::Shortcuts => { Page::Shortcuts => body = body.push(self.shortcut_controls()),
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 [
("Swipe3Up", "Customize swipe up"),
("Swipe3Down", "Customize swipe down"),
("Swipe3Left", "Customize swipe left"),
("Swipe3Right", "Customize swipe right"),
] {
let exists = (0..self.draft.records("bindings")).any(|index| {
self.draft
.string(&format!("bindings.{index}.keys"), "")
.eq_ignore_ascii_case(keys)
});
if !exists {
gestures = gestures.push(self.settings_icon_button(
label,
match keys {
"Swipe3Up" => "M12 20V4 M5 11l7-7 7 7",
"Swipe3Down" => "M12 4v16 M5 13l7 7 7-7",
"Swipe3Left" => "M20 12H4 M11 5l-7 7 7 7",
_ => "M4 12h16 M13 5l7 7-7 7",
},
(!self.saving).then_some(Message::AddSwipe(keys)),
));
}
}
body = body.push(gestures);
for index in 0..self.draft.records("bindings") {
let content: Element<'static, Message> =
if self.visible_rows.contains(&("bindings", index)) {
let prefix = format!("bindings.{index}");
let group = column([])
.spacing(1)
.push(self.field(schema::text(
format!("{prefix}.keys"),
"Shortcut",
"Keys or swipe, such as Super+Return",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.action"),
"Action",
"Action name, such as spawn or focus",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.argument"),
"Argument",
"Command, direction, or action argument",
"",
)));
container(group)
.padding(4)
.height(164)
.class(visuals::section(palette))
.into()
} else {
widget::Space::new().height(164).width(Length::Fill).into()
};
body = body.push(
cosmic::iced::widget::sensor(content)
.key(("bindings", index))
.anticipate(200)
.on_show(move |_| Message::RowVisibility("bindings", index, true))
.on_hide(Message::RowVisibility("bindings", index, false)),
);
}
if self.draft.records("bindings") == 0 {
body = body.push(self.note(ferese_config::tr!("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.")));
}
}
Page::Displays => body = body.push(self.displays_view()), Page::Displays => body = body.push(self.displays_view()),
_ => {} _ => {}
} }
@@ -695,32 +657,6 @@ impl App {
)); ));
let footer: Element<'_, Message> = if self.page == Page::Connections { let footer: Element<'_, Message> = if self.page == Page::Connections {
self.note(ferese_config::tr!("Connections are managed by the system. Passwords are not saved in your Ferese config.")) self.note(ferese_config::tr!("Connections are managed by the system. Passwords are not saved in your Ferese config."))
} else if self.page == Page::Bar {
container(
row([])
.spacing(12)
.align_y(Alignment::Center)
.push(self.note(if self.saving {
"Saving…"
} else {
&self.status
}))
.push(widget::Space::new().width(Length::Fill))
.push(self.settings_button(
ferese_config::tr!("Undo"),
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
(self.undo.is_some() && !self.saving).then_some(Message::Undo),
false,
))
.push(self.settings_button(
ferese_config::tr!("Reload configuration"),
"M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6",
(!self.saving).then_some(Message::Reload),
false,
)),
)
.padding([10, 0])
.into()
} else { } else {
footer.into() footer.into()
}; };
+12 -22
View File
@@ -113,7 +113,7 @@ impl App {
), ),
), ),
) )
.padding(14) .padding(10)
.width(cosmic::iced::Length::Fill) .width(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 14.)), .class(crate::visuals::surface(palette.card, 14.)),
); );
@@ -150,12 +150,12 @@ impl App {
.into(), .into(),
), ),
) )
.padding(16) .padding(10)
.class(crate::visuals::surface(palette.card, 14.)), .class(crate::visuals::surface(palette.card, 14.)),
); );
let mut appearance = widget::column([]) let mut appearance = widget::column([])
.spacing(16) .spacing(12)
.push(self.panel_heading(ferese_config::tr!("Appearance"), 15.)) .push(self.panel_heading(ferese_config::tr!("Panel appearance"), 14.))
.push(self.panel_style_choices(preset.unwrap_or_default())) .push(self.panel_style_choices(preset.unwrap_or_default()))
.push(self.panel_divider()) .push(self.panel_divider())
.push(self.panel_opacity_controls()) .push(self.panel_opacity_controls())
@@ -267,7 +267,7 @@ impl App {
rows = rows.push( rows = rows.push(
widget::container(appearance) widget::container(appearance)
.id("panel-appearance-settings") .id("panel-appearance-settings")
.padding(16) .padding(10)
.width(cosmic::iced::Length::Fill) .width(cosmic::iced::Length::Fill)
.class(crate::visuals::surface(palette.card, 14.)), .class(crate::visuals::surface(palette.card, 14.)),
); );
@@ -278,7 +278,7 @@ impl App {
let mut settings = widget::column([]).spacing(4).push( let mut settings = widget::column([]).spacing(4).push(
widget::container( widget::container(
widget::row![ widget::row![
self.panel_heading(ferese_config::tr!("Size & spacing"), 15.) self.panel_heading(ferese_config::tr!("Size & spacing"), 14.)
.width(cosmic::iced::Length::Fill), .width(cosmic::iced::Length::Fill),
self.settings_button( self.settings_button(
ferese_config::tr!("Reset to style"), ferese_config::tr!("Reset to style"),
@@ -362,20 +362,6 @@ impl App {
.padding(12) .padding(12)
.class(crate::visuals::surface(palette.card, 14.)), .class(crate::visuals::surface(palette.card, 14.)),
); );
let mut shell = widget::column([])
.spacing(4)
.push(self.panel_heading(ferese_config::tr!("Shell appearance"), 15.));
for field in crate::schema::fields(Page::Bar)
.into_iter()
.filter(|field| field.path.starts_with("theme."))
{
shell = shell.push(self.field(field));
}
rows = rows.push(
widget::container(shell)
.padding(12)
.class(crate::visuals::surface(palette.card, 14.)),
);
} }
PanelPage::Arrange => return self.panel_workspace(), PanelPage::Arrange => return self.panel_workspace(),
} }
@@ -656,7 +642,7 @@ impl App {
cosmic::iced::widget::responsive(move |size| { cosmic::iced::widget::responsive(move |size| {
let editor = widget::column([]) let editor = widget::column([])
.spacing(12) .spacing(12)
.push(self.panel_heading(ferese_config::tr!("Arrangement"), 15.)) .push(self.panel_heading(ferese_config::tr!("Arrangement"), 14.))
.push(self.note(ferese_config::tr!( .push(self.note(ferese_config::tr!(
"Drag items to reorder. Select an item or group to edit." "Drag items to reorder. Select an item or group to edit."
))) )))
@@ -736,7 +722,7 @@ impl App {
"M4 4h16v16H4z M14 4v16 M7 8h4 M7 12h4", "M4 4h16v16H4z M14 4v16 M7 8h4 M7 12h4",
palette.muted, palette.muted,
)) ))
.push(self.panel_heading(ferese_config::tr!("Make it your own"), 15.)) .push(self.panel_heading(ferese_config::tr!("Make it your own"), 14.))
.push(self.note( .push(self.note(
ferese_config::tr!("Select an item to adjust how it appears, or a group to edit its placement and spacing."), ferese_config::tr!("Select an item to adjust how it appears, or a group to edit its placement and spacing."),
)), )),
@@ -1038,6 +1024,10 @@ mod tests {
focused: true, focused: true,
current: None, current: None,
modes: Vec::new(), modes: Vec::new(),
connected: true,
enabled: true,
scale: 1.,
..Default::default()
}]; }];
app.panel_preview_resolution.items.insert( app.panel_preview_resolution.items.insert(
ferese_panel::ItemId("clock".into()), ferese_panel::ItemId("clock".into()),
+1 -1
View File
@@ -49,7 +49,7 @@ impl App {
row([]) row([])
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push( .push(
self.label(ferese_config::tr!("Composition preview"), 14.) self.panel_heading(ferese_config::tr!("Composition preview"), 14.)
.width(Length::Fill), .width(Length::Fill),
) )
.push(self.note(ferese_config::tr!("Sample content"))), .push(self.note(ferese_config::tr!("Sample content"))),
@@ -19,6 +19,7 @@ fn fixture() -> App {
), ),
); );
app.options_loaded(all_options::catalog()); app.options_loaded(all_options::catalog());
app.keyboard_catalog_loaded(crate::keyboard_controls::catalog());
app.connections.snapshot = Some(connections::performance_snapshot()); app.connections.snapshot = Some(connections::performance_snapshot());
app.tray_apps = (0..12) app.tray_apps = (0..12)
.map(|i| tray_controls::Application { .map(|i| tray_controls::Application {
@@ -32,6 +33,8 @@ fn fixture() -> App {
identity: format!("Test display {i}"), identity: format!("Test display {i}"),
profile: None, profile: None,
logical_width: Some(1920), logical_width: Some(1920),
logical_height: Some(1080),
position: Some([(i - 1) * 1920, 0]),
focused: i == 0, focused: i == 0,
current: Some(displays::Mode { current: Some(displays::Mode {
width: 3840, width: 3840,
@@ -50,6 +53,10 @@ fn fixture() -> App {
refresh: 60000, refresh: 60000,
}, },
], ],
connected: true,
enabled: true,
scale: 2.,
..Default::default()
}) })
.collect(); .collect();
app app
+79 -50
View File
@@ -18,6 +18,9 @@ pub enum Page {
Displays, Displays,
Connections, Connections,
Calendar, Calendar,
Mouse,
Touchpad,
Shell,
} }
impl Page { impl Page {
@@ -29,7 +32,14 @@ impl Page {
), ),
( (
"Interaction", "Interaction",
&[Self::Windows, Self::Keyboard, Self::Accessibility], &[
Self::Windows,
Self::Keyboard,
Self::Mouse,
Self::Touchpad,
Self::Shortcuts,
Self::Accessibility,
],
), ),
( (
"System", "System",
@@ -49,7 +59,7 @@ impl Page {
pub fn navigation_page(self) -> Self { pub fn navigation_page(self) -> Self {
match self { match self {
Self::Motion => Self::Windows, Self::Motion => Self::Windows,
Self::Shortcuts => Self::Keyboard, Self::Shell => Self::Bar,
_ => self, _ => self,
} }
} }
@@ -57,10 +67,7 @@ impl Page {
pub fn subpages(self) -> &'static [(Self, &'static str)] { pub fn subpages(self) -> &'static [(Self, &'static str)] {
match self.navigation_page() { match self.navigation_page() {
Self::Windows => &[(Self::Windows, "Windows"), (Self::Motion, "Motion")], Self::Windows => &[(Self::Windows, "Windows"), (Self::Motion, "Motion")],
Self::Keyboard => &[ Self::Bar => &[(Self::Bar, "Panel"), (Self::Shell, "Shell")],
(Self::Keyboard, "Keyboard & mouse"),
(Self::Shortcuts, "Shortcuts"),
],
_ => &[], _ => &[],
} }
} }
@@ -77,7 +84,7 @@ impl Page {
} }
#[cfg(test)] #[cfg(test)]
pub const ALL: [Self; 18] = [ pub const ALL: [Self; 21] = [
Self::Appearance, Self::Appearance,
Self::General, Self::General,
Self::Theme, Self::Theme,
@@ -96,6 +103,9 @@ impl Page {
Self::Displays, Self::Displays,
Self::Connections, Self::Connections,
Self::Calendar, Self::Calendar,
Self::Mouse,
Self::Touchpad,
Self::Shell,
]; ];
pub fn title(self) -> &'static str { pub fn title(self) -> &'static str {
@@ -115,7 +125,10 @@ impl Page {
Self::LockScreen => "Lock screen", Self::LockScreen => "Lock screen",
Self::Windows => "Windows", Self::Windows => "Windows",
Self::Motion => "Motion", Self::Motion => "Motion",
Self::Keyboard => "Keyboard & mouse", Self::Keyboard => "Keyboard",
Self::Mouse => "Mouse",
Self::Touchpad => "Touchpad",
Self::Shell => "Shell",
Self::Shortcuts => "Shortcuts", Self::Shortcuts => "Shortcuts",
Self::Startup => "Login items", Self::Startup => "Login items",
Self::Displays => "Displays", Self::Displays => "Displays",
@@ -140,7 +153,10 @@ impl Page {
Self::LockScreen => "Your desktop, safely put away.", Self::LockScreen => "Your desktop, safely put away.",
Self::Windows => "Window layouts, spacing and focus effects.", Self::Windows => "Window layouts, spacing and focus effects.",
Self::Motion => "Animations and transition speed.", Self::Motion => "Animations and transition speed.",
Self::Keyboard => "Keyboard layouts, pointer behavior and touchpad gestures.", Self::Keyboard => "Keyboard layouts, variants and repeat behavior.",
Self::Mouse => "Mouse buttons, pointer movement and scrolling.",
Self::Touchpad => "Touchpad taps, scrolling and gestures.",
Self::Shell => "Shared surfaces, launcher and system menus.",
Self::Shortcuts => "Keyboard shortcuts and swipe actions.", Self::Shortcuts => "Keyboard shortcuts and swipe actions.",
Self::Startup => "Ready when you sign in.", Self::Startup => "Ready when you sign in.",
Self::Displays => "A place for every screen.", Self::Displays => "A place for every screen.",
@@ -173,6 +189,9 @@ impl Page {
Self::Keyboard => { Self::Keyboard => {
"M3 6h18v12H3z M6 9h.01 M10 9h.01 M14 9h.01 M18 9h.01 M6 12h.01 M10 12h.01 M14 12h.01 M18 12h.01 M8 15h8" "M3 6h18v12H3z M6 9h.01 M10 9h.01 M14 9h.01 M18 9h.01 M6 12h.01 M10 12h.01 M14 12h.01 M18 12h.01 M8 15h8"
} }
Self::Mouse => "M8 10V7a4 4 0 0 1 8 0v10a4 4 0 0 1-8 0z M12 3v7 M8 10h8",
Self::Touchpad => "M3 5h18v14H3z M3 15h18 M12 15v4",
Self::Shell => "M3 4h18v16H3z M3 9h18 M8 9v11",
Self::Shortcuts => { Self::Shortcuts => {
"M8 8h8v8H8z M8 8H5a3 3 0 1 1 3-3v3z M16 8V5a3 3 0 1 1 3 3h-3z M16 16h3a3 3 0 1 1-3 3v-3z M8 16v3a3 3 0 1 1-3-3h3z" "M8 8h8v8H8z M8 8H5a3 3 0 1 1 3-3v3z M16 8V5a3 3 0 1 1 3 3h-3z M16 16h3a3 3 0 1 1-3 3v-3z M8 16v3a3 3 0 1 1-3-3h3z"
} }
@@ -552,7 +571,7 @@ pub fn fields(page: Page) -> Vec<Field> {
}, },
), ),
], ],
Page::Bar => vec![ Page::Shell => vec![
range( range(
"theme.geometry.shell_radius", "theme.geometry.shell_radius",
"Shell corner radius", "Shell corner radius",
@@ -586,6 +605,8 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: false, integer: false,
}, },
), ),
],
Page::Bar => vec![
range( range(
"panels.0.geometry.height", "panels.0.geometry.height",
"Height", "Height",
@@ -867,43 +888,8 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: true, integer: true,
}, },
), ),
toggle( ],
"input.touchpad.tap", Page::Mouse => vec![
"Tap to click",
"Tap the touchpad to click. Updates connected devices immediately.",
true,
),
toggle(
"input.touchpad.natural_scroll",
"Natural scrolling",
"Move content in the direction of your fingers. Updates immediately.",
true,
),
toggle(
"input.touchpad.disable_while_typing",
"Ignore touchpad while typing",
"Avoid accidental pointer movement.",
true,
),
toggle(
"input.touchpad.disable_while_mouse_connected",
"Disable touchpad with a mouse",
"Pause the built-in touchpad while an external mouse is connected.",
false,
),
range(
"input.touchpad.accel_speed",
"Touchpad speed",
"Adjust pointer acceleration for the touchpad.",
RangeSpec {
default: 0.0,
min: -1.0,
max: 1.0,
step: 0.05,
suffix: "",
integer: false,
},
),
toggle( toggle(
"input.mouse.natural_scroll", "input.mouse.natural_scroll",
"Mouse natural scrolling", "Mouse natural scrolling",
@@ -948,6 +934,45 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: false, integer: false,
}, },
), ),
],
Page::Touchpad => vec![
toggle(
"input.touchpad.tap",
"Tap to click",
"Tap the touchpad to click. Updates connected devices immediately.",
true,
),
toggle(
"input.touchpad.natural_scroll",
"Natural scrolling",
"Move content in the direction of your fingers. Updates immediately.",
true,
),
toggle(
"input.touchpad.disable_while_typing",
"Ignore touchpad while typing",
"Avoid accidental pointer movement.",
true,
),
toggle(
"input.touchpad.disable_while_mouse_connected",
"Disable touchpad with a mouse",
"Pause the built-in touchpad while an external mouse is connected.",
false,
),
range(
"input.touchpad.accel_speed",
"Touchpad speed",
"Adjust pointer acceleration for the touchpad.",
RangeSpec {
default: 0.0,
min: -1.0,
max: 1.0,
step: 0.05,
suffix: "",
integer: false,
},
),
range( range(
"input.touchpad.swipe_threshold", "input.touchpad.swipe_threshold",
"Swipe distance", "Swipe distance",
@@ -1055,6 +1080,9 @@ pub fn grouped_fields(page: Page, fields: Vec<Field>) -> Vec<FieldGroup> {
"Focus and window effects" "Focus and window effects"
} }
} }
Page::Shell => "Shell surfaces",
Page::Mouse => "Mouse",
Page::Touchpad => "Touchpad",
Page::Keyboard => { Page::Keyboard => {
if p.starts_with("input.xkb_") { if p.starts_with("input.xkb_") {
"Keyboard layout" "Keyboard layout"
@@ -1173,9 +1201,10 @@ mod group_tests {
actual.sort(); actual.sort();
assert_eq!(actual, expected, "{page:?}"); assert_eq!(actual, expected, "{page:?}");
} }
let keyboard = grouped_fields(Page::Keyboard, fields(Page::Keyboard)); let mouse = grouped_fields(Page::Mouse, fields(Page::Mouse));
let touchpad = grouped_fields(Page::Touchpad, fields(Page::Touchpad));
assert!( assert!(
keyboard mouse
.iter() .iter()
.find(|g| g.title == "Mouse") .find(|g| g.title == "Mouse")
.unwrap() .unwrap()
@@ -1184,7 +1213,7 @@ mod group_tests {
.all(|f| f.path.starts_with("input.mouse.")) .all(|f| f.path.starts_with("input.mouse."))
); );
assert!( assert!(
keyboard touchpad
.iter() .iter()
.find(|g| g.title == "Touchpad") .find(|g| g.title == "Touchpad")
.unwrap() .unwrap()
+13 -3
View File
@@ -1144,7 +1144,11 @@ fn appearance_cards_reflow_without_overlap_and_keep_every_destination() {
.operate(&mut tree, Layout::new(&node), &renderer, &mut cards); .operate(&mut tree, Layout::new(&node), &renderer, &mut cards);
assert_eq!( assert_eq!(
cards.bounds.len(), cards.bounds.len(),
15, crate::schema::Page::NAVIGATION
.iter()
.map(|(_, p)| p.len())
.sum::<usize>()
- 1,
"{width}: all settings destinations must be reachable" "{width}: all settings destinations must be reachable"
); );
for bounds in cards.bounds.values() { for bounds in cards.bounds.values() {
@@ -1255,8 +1259,14 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() {
let mut centers = Centers::default(); let mut centers = Centers::default();
view.as_widget_mut() view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut centers); .operate(&mut tree, Layout::new(&node), &renderer, &mut centers);
assert_eq!(centers.buttons.len(), 16); assert_eq!(
assert_eq!(centers.parts.len(), 64); centers.buttons.len(),
crate::schema::Page::NAVIGATION
.iter()
.map(|(_, p)| p.len())
.sum::<usize>()
);
assert_eq!(centers.parts.len(), centers.buttons.len() * 4);
let sidebar = centers.sidebar.unwrap(); let sidebar = centers.sidebar.unwrap();
assert!( assert!(
centers centers
+68
View File
@@ -0,0 +1,68 @@
//! Searchable choices share the settings palette and fit their filtered results.
use crate::{App, Element, Message, visuals};
use cosmic::iced::{Border, Length};
use cosmic::widget::combo_box;
impl App {
pub(super) fn search_select<'a, T: Clone + std::fmt::Display + 'static>(
&self,
state: &'a combo_box::State<T>,
placeholder: &'a str,
selected: Option<&T>,
select: impl Fn(T) -> Message + 'static,
) -> Element<'a, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let picker: cosmic::iced::widget::ComboBox<
'a,
T,
Message,
cosmic::iced::Theme,
cosmic::Renderer,
> = cosmic::iced::widget::combo_box(state, placeholder, selected, select);
let picker = picker
.font(self.font)
.size(12)
.padding([7, 10])
.width(Length::Fill)
.menu_height(Length::Shrink)
.input_style(move |_, status| cosmic::iced::widget::text_input::Style {
background: ferese_theme::mix(p.background, p.card, 0.4).into(),
border: Border {
width: 1.,
radius: 5.into(),
color: if matches!(
status,
cosmic::iced::widget::text_input::Status::Focused { .. }
) {
p.accent
} else {
ferese_theme::mix(p.card, p.text, 0.16)
},
..Default::default()
},
icon: p.muted,
placeholder: p.muted,
value: p.text,
selection: p.accent,
})
.menu_style(move |_| cosmic::iced::overlay::menu::Style {
background: p.card.into(),
border: Border {
width: 1.,
radius: 5.into(),
color: ferese_theme::mix(p.card, p.text, 0.2),
..Default::default()
},
text_color: p.text,
selected_text_color: p.text,
selected_background: ferese_theme::mix(p.card, p.accent, 0.2).into(),
shadow: Default::default(),
});
let theme = if p.background.r + p.background.g + p.background.b > 1.5 {
cosmic::iced::Theme::Light
} else {
cosmic::iced::Theme::Dark
};
cosmic::iced::widget::Themer::new(Some(theme), picker).into()
}
}
File diff suppressed because it is too large. Load diff