diff --git a/Cargo.lock b/Cargo.lock index 3f92bb1..a7f31fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2046,6 +2046,7 @@ dependencies = [ "shlex 1.3.0", "tempfile", "tokio", + "wayland-client", "zbus", "zeroize", ] diff --git a/crates/ferese-config/locales/zh-CN.json b/crates/ferese-config/locales/zh-CN.json index 27f0217..c457a80 100644 --- a/crates/ferese-config/locales/zh-CN.json +++ b/crates/ferese-config/locales/zh-CN.json @@ -291,7 +291,7 @@ "Static": "静态", "Scrolling": "滚动", "Tiling": "平铺", - "Floating": "浮动", + "Floating": "悬浮", "Always": "始终", "Never": "从不", "Automatic": "自动", @@ -776,7 +776,7 @@ "Allow this device to pair?": "允许此设备配对吗?", "Another device is already pairing.": "另一个设备正在配对。", "Argument": "参数", - "Attached to the screen edge": "贴合屏幕边缘", + "Attached to the screen edge": "连接屏幕边缘", "Auto (battery)": "自动(电池)", "Auto appearance: off": "自动外观:关", "Auto appearance: on": "自动外观:开", @@ -799,7 +799,7 @@ "Command, direction, or action argument": "命令、方向或动作参数。", "Configuration schema query timed out": "配置结构查询超时", "Confirm": "确认", - "Connected groups with rounded ends": "圆角端点相连的分组", + "Connected groups with rounded ends": "以圆角端点连接的分组", "Connected · ": "已连接 · ", "Connecting · ": "正在连接 · ", "Connection services are unavailable. Use Refresh to try again.": "连接服务不可用,请点击刷新重试。", @@ -837,7 +837,7 @@ "Forget this connection?": "忘记此连接吗?", "Full": "完整", "Group arrangement & spacing": "分组排列与间距", - "Groups blend where they meet": "分组在相接处融合", + "Groups blend where they meet": "在交汇处融合分组", "Headphones": "耳机", "Hidden network": "隐藏网络", "Holiday name": "节假日名称", @@ -913,7 +913,7 @@ "Schema query failed": "配置结构查询失败", "Searching for devices…": "正在搜索设备…", "Select display": "选择显示器", - "Separate floating groups": "独立悬浮分组", + "Separate floating groups": "彼此分离的悬浮分组", "Shared": "共享", "Shortcut": "快捷键", "Show +n for additional activities.": "以 +n 表示更多活动。", @@ -1042,5 +1042,172 @@ "Loading configuration options…": "正在载入配置选项…", "Retry": "重试", "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": "可展开控制项的时钟中心" } diff --git a/crates/ferese-config/src/document.rs b/crates/ferese-config/src/document.rs index 090a107..667aab8 100644 --- a/crates/ferese-config/src/document.rs +++ b/crates/ferese-config/src/document.rs @@ -350,6 +350,36 @@ impl Document { 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> { self.value = Value::Object(object(&self.doc, "")?); Ok(()) @@ -1124,6 +1154,36 @@ pub fn from_str(source: &str) -> Result { mod tests { 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] fn structured_activity_rules_use_native_kdl_syntax() { let mut doc = Document::parse("activities { max-visible 2; }\n").unwrap(); diff --git a/docs/configuration/input.md b/docs/configuration/input.md index efb93e0..d7a5478 100644 --- a/docs/configuration/input.md +++ b/docs/configuration/input.md @@ -1,5 +1,7 @@ # 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 | | --- | --- | --- | --- | | `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 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 -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 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 window grabs, or when an application inhibits shortcuts. -Assign swipes in **Settings → Keyboard & mouse → Shortcuts** using the same actions and arguments as -keyboard bindings. Choose a "Customize swipe" button to add an override. The +Assign swipes in **Settings → Shortcuts** using the same actions and arguments as +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 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 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). ## Mouse bindings diff --git a/docs/configuration/outputs.md b/docs/configuration/outputs.md index 8f4bcd9..90010ed 100644 --- a/docs/configuration/outputs.md +++ b/docs/configuration/outputs.md @@ -1,5 +1,9 @@ # 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 Night light warms the display using its hardware gamma ramp. It is off by default. diff --git a/docs/configuration/session-protocols.md b/docs/configuration/session-protocols.md index 686e5b2..333ec55 100644 --- a/docs/configuration/session-protocols.md +++ b/docs/configuration/session-protocols.md @@ -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 rather than through `ferese-session` needs `FERESE_ENABLE_SCREENCOPY=1` in its 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 inhibitor. Use `feresectl --help` for runtime control commands. diff --git a/docs/configuration/status.md b/docs/configuration/status.md index 60f96f8..869bd4a 100644 --- a/docs/configuration/status.md +++ b/docs/configuration/status.md @@ -2,7 +2,7 @@ | `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 | | `settings-command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action | diff --git a/docs/development/visual-direction.md b/docs/development/visual-direction.md index fbb5d55..7c27f4b 100644 --- a/docs/development/visual-direction.md +++ b/docs/development/visual-direction.md @@ -1,8 +1,8 @@ # Ferese Graphite 视觉方案 -- 文档版本:1.6 +- 文档版本:1.7 - 整理日期:2026-10-28 -- 视觉基线:`1e34ec4`、`9908dde`、`3264659`、`6671960`、`81b9970`;本版修正活动窗口的应用图标来源。 +- 视觉基线:`1e34ec4`、`9908dde`、`3264659`、`6671960`、`81b9970`;本版增加搜索选择、快捷键编辑及显示器配置流程。 - 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。 ## 1. 视觉目标 @@ -135,7 +135,7 @@ Appearance 作为全部设置的入口,内容按实际功能分组: | --- | --- | | 快速访问 | 常规、全部配置 | | 桌面 | 主题、壁纸、面板与 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 与两种主题配置 | -| 配置测试 | 51 项单元测试及 11 项集成测试通过 | +| 配置测试 | 52 项单元测试及 11 项集成测试通过 | | 共享主题 / 面板 / Shell | 34 / 69 / 256 项通过 | | 布局检查 | 覆盖 480–1800 px 窗口宽度 | | 原生预览 | 已检查深浅外观及 631 px 窄窗口 | -| 导航居中 | 全部 16 项;包含独立滚动条通道检查 | +| 导航居中 | 全部 19 项;包含独立滚动条通道检查 | | Rime | 原生输入、候选、翻页、主题变化与进程恢复通过 | | 边缘双击 | 原生预览内 1272 px 最大化与 631 px 还原双向切换通过,配置内容不变 | | 导航抽屉 | 已移除关闭按钮,外侧点击和 Esc 关闭均检查通过 | @@ -320,8 +340,14 @@ status { | 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 | | 原生主题预览 | 检查配对颜色、透明外角和选中标记图层顺序;保留比例与键盘导航 | | 后台 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` | 前版记录保存在 `.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` | diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 849758b..f86fcb4 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -1,11 +1,17 @@ # Shortcuts and gestures `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. The [example config](../packaging/config.kdl) adds the app, media and arrow-key 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 **Super + Space** opens the launcher. Type to search, press **Tab** to accept diff --git a/shell/ferese-settings/Cargo.toml b/shell/ferese-settings/Cargo.toml index 974ca6e..20926b9 100644 --- a/shell/ferese-settings/Cargo.toml +++ b/shell/ferese-settings/Cargo.toml @@ -17,6 +17,7 @@ libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] } iced_accessibility.workspace = true ferese-ipc = { path = "../../crates/ferese-ipc" } serde_json.workspace = true +wayland-client.workspace = true tempfile.workspace = true shlex = "1.3.0" notify = "8.2.0" diff --git a/shell/ferese-settings/src/display_controls.rs b/shell/ferese-settings/src/display_controls.rs new file mode 100644 index 0000000..0201e56 --- /dev/null +++ b/shell/ferese-settings/src/display_controls.rs @@ -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, + pub restoring: bool, + restore_after_revert: bool, + pub queued: Option, +} +#[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 { + 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 { + 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::() { + 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 { + 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 { + 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 { + 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 { + 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 = ::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" + ); + } +} diff --git a/shell/ferese-settings/src/display_map.rs b/shell/ferese-settings/src/display_map.rs new file mode 100644 index 0000000..1b6296f --- /dev/null +++ b/shell/ferese-settings/src/display_map.rs @@ -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> = 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.)); + } +} diff --git a/shell/ferese-settings/src/displays.rs b/shell/ferese-settings/src/displays.rs index 7811b38..fac65da 100644 --- a/shell/ferese-settings/src/displays.rs +++ b/shell/ferese-settings/src/displays.rs @@ -36,12 +36,21 @@ impl Mode { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct Display { pub connector: String, pub identity: String, pub profile: Option, pub logical_width: Option, + pub logical_height: Option, + 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, pub focused: bool, pub current: Option, pub modes: Vec, @@ -62,6 +71,21 @@ pub fn load() -> Result, String> { .as_i64() .and_then(|width| width.try_into().ok()) .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), current: Mode::from_value(&value["current_mode"]), modes: value["available_modes"] @@ -75,6 +99,118 @@ pub fn load() -> Result, String> { .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 { let size = configured .split('@') @@ -117,8 +253,14 @@ impl std::fmt::Display for Target { f, "{} · {}{}", self.title, - self.profile.as_deref().unwrap_or("Automatic"), - if self.connected { "" } else { " (offline)" } + self.profile + .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 live = displays .iter() - .find(|d| d.connector == matcher || d.identity == matcher); + .find(|d| d.connected && (d.connector == matcher || d.identity == matcher)); targets.push(Target { key: format!("{name}\0{matcher}"), title: live @@ -148,6 +290,9 @@ pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) - } } for display in displays { + if !display.connected { + continue; + } if !targets .iter() .any(|t| t.matcher == display.connector || t.matcher == display.identity) @@ -310,6 +455,10 @@ mod tests { refresh: 59940, }, ], + connected: true, + enabled: true, + scale: 1., + ..Default::default() }; let choices = choices(&display, "2880x1800@120"); assert_eq!(choices.len(), 2); @@ -375,6 +524,10 @@ mod tests { focused: true, current: None, modes: vec![], + connected: true, + enabled: true, + scale: 1., + ..Default::default() }]; let values = targets(&snapshot, &live); assert_eq!( @@ -407,6 +560,10 @@ mod tests { focused: true, current: None, modes: vec![], + connected: true, + enabled: true, + scale: 1., + ..Default::default() }]; let values = targets(&snapshot, &live); assert_eq!(values.len(), 1); @@ -432,6 +589,10 @@ mod tests { mode(2880, 1800, 120000), mode(2560, 1440, 90000), ], + connected: true, + enabled: true, + scale: 1., + ..Default::default() }; assert_eq!(resolutions(&display).len(), 2); let high = Resolution { diff --git a/shell/ferese-settings/src/displays_ui.rs b/shell/ferese-settings/src/displays_ui.rs index 55aa03d..4fe8d93 100644 --- a/shell/ferese-settings/src/displays_ui.rs +++ b/shell/ferese-settings/src/displays_ui.rs @@ -10,7 +10,48 @@ impl App { let palette = visuals::Palette::from_resolved(&self.resolved.presented); let targets = displays::targets(&self.draft, &self.displays); 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( row([]) .spacing(8) @@ -26,7 +67,7 @@ impl App { selected.clone(), |target| Message::SelectDisplay(target.key), ) - .placeholder("Select display") + .placeholder(ferese_config::tr!("Select display")) .font(self.font) .text_size(12) .padding([4, 8]) @@ -41,6 +82,14 @@ impl App { 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 { panel = panel.push(cosmic::widget::tooltip( self.note(ferese_config::tr!("Live display information unavailable")), @@ -59,8 +108,119 @@ impl App { .map(|mode| format!("{} × {} · {}", mode.width, mode.height, mode.label())) .unwrap_or_else(|| "Off".into()); 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(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 automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false); if let Some(display) = live { @@ -150,13 +310,73 @@ impl App { "", schema::RangeSpec { default: 1., - min: 0.75, - max: 3., - step: 0.05, + min: 0.5, + max: 4., + step: 0.25, suffix: "×", 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) .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)) diff --git a/shell/ferese-settings/src/keyboard_controls.rs b/shell/ferese-settings/src/keyboard_controls.rs new file mode 100644 index 0000000..8381aea --- /dev/null +++ b/shell/ferese-settings/src/keyboard_controls.rs @@ -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, +} +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, + variants: BTreeMap>, + switches: Vec, +} +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, String> { + static CACHE: OnceLock> = 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 { + 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 { + 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, + variants: combo_box::State, + layout: Choice, + variant: Choice, +} +#[derive(Default)] +pub(super) struct State { + catalog: Option>, + loading: bool, + error: Option, + rows: Vec, + signature: String, + pub adding: bool, + add_choices: Option>, + switches: Option>, + switch: Option, +} +#[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 { + vec![ + set( + "input.xkb_layout", + rows.iter() + .map(|r| r.0.as_str()) + .collect::>() + .join(","), + ), + set( + "input.xkb_variant", + rows.iter() + .map(|r| r.1.as_str()) + .collect::>() + .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 { + 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, 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 { + 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"); + } +} diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 5466a24..ff8a479 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -15,10 +15,15 @@ mod tray_controls; use theme_controls::gallery_id; +mod display_controls; +mod display_map; mod displays; mod displays_ui; mod fonts; +mod keyboard_controls; mod schema; +mod selection; +mod shortcut_controls; mod store; mod visuals; mod wallpaper_controls; @@ -73,6 +78,12 @@ fn main() -> cosmic::iced::Result { Some("general") => Some(InitialPage::Settings(Page::General)), Some("calendar") => Some(InitialPage::Settings(Page::Calendar)), 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("all-options") => Some(InitialPage::Settings(Page::Configuration)), _ => { @@ -118,6 +129,11 @@ enum InitialPage { #[derive(Clone, Debug)] enum Message { + DisplayInput(display_controls::Input), + DisplayCommandFinished(display_controls::Command, Result<(), String>), + Shortcut(shortcut_controls::Input), + Keyboard(keyboard_controls::Input), + KeyboardCatalog(Result, String>), Options(all_options::Input), LoadOptions, OptionsLoaded(Result, String>), @@ -188,12 +204,14 @@ enum Message { AddHoliday, CalendarFunction(calendar_controls::FunctionInput), AddCommand, - AddSwipe(&'static str), Remove(String, usize), Thumbnail(String, Result), } struct App { + display_state: display_controls::State, + shortcuts: shortcut_controls::State, + keyboard: keyboard_controls::State, tray_apps: Vec, tray_refresh: refresh::Gate, options: all_options::State, @@ -312,6 +330,9 @@ impl cosmic::Application for App { ferese_config::locale::set(ferese_config::locale::from_document(¤t.doc)); let options = all_options::State::pending(¤t); let mut app = Self { + display_state: Default::default(), + shortcuts: Default::default(), + keyboard: Default::default(), tray_apps: Vec::new(), tray_refresh: Default::default(), options, @@ -400,7 +421,7 @@ impl cosmic::Application for App { Task::none() }; 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() } else { Task::none() @@ -410,9 +431,14 @@ impl cosmic::Application for App { } else { Task::none() }; + let keyboard = if app.page == Page::Keyboard { + app.load_keyboard_catalog() + } else { + Task::none() + }; ( 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!( message, Message::Draft(..) + | Message::Shortcut(_) + | Message::Keyboard(_) | Message::Range(..) | Message::Options(_) | Message::CalendarFunction(_) @@ -429,6 +457,13 @@ impl cosmic::Application for App { self.edit_revision = self.edit_revision.wrapping_add(1); } 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) => { self.tray_refresh.finish(); if let Ok(apps) = apps { @@ -453,7 +488,10 @@ impl cosmic::Application for App { Message::ConnectionsRefreshed(snapshot) => self.connections_refreshed(*snapshot), Message::ConnectionFinished(result) => return self.connection_finished(result), 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) => { let matcher = self.draft.string(&format!("{prefix}.match"), ""); let configured = self.draft.string(&format!("{prefix}.mode"), ""); @@ -525,6 +563,7 @@ impl cosmic::Application for App { || !self.ranges.is_empty() || !self.pending.is_empty() || self.options.dirty() + || self.shortcuts.opened || self.calendar_editor.dirty(&self.draft) { self.status = @@ -539,6 +578,10 @@ impl cosmic::Application for App { self.edit_revision = self.edit_revision.wrapping_add(1); self.current = snapshot.clone(); 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.resolved.families, &self.draft, @@ -601,9 +644,10 @@ impl cosmic::Application for App { .map(cosmic::Action::App); } Message::Page(page) => { + let stop_recording = self.stop_shortcut_capture(); self.sidebar_open = false; 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); @@ -624,6 +668,7 @@ impl cosmic::Application for App { cosmic::iced::widget::scrollable::RelativeOffset::START.into(), ); let reset = Task::batch([ + stop_recording, reset, cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation( page, @@ -635,6 +680,7 @@ impl cosmic::Application for App { let load = match page { Page::Displays | Page::Bar => self.load_displays(), Page::Wallpaper => self.load_thumbnail(), + Page::Keyboard => self.load_keyboard_catalog(), Page::Tray => self.load_tray_apps(), Page::Configuration => self.load_options(), _ => Task::none(), @@ -653,6 +699,9 @@ impl cosmic::Application for App { if page == Page::Wallpaper { return Task::batch([reset, self.load_thumbnail()]); } + if page == Page::Keyboard { + return Task::batch([reset, self.load_keyboard_catalog()]); + } if page == Page::Configuration { return Task::batch([reset, self.load_options()]); } @@ -864,6 +913,10 @@ impl cosmic::Application for App { self.saving = false; match result { 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.current = snapshot; 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.draft = self.current.clone(); + self.keyboard.sync(&self.draft); + self.shortcuts.localize(); for edit in &self.pending { if let Err(error) = self.draft.edit(edit) { @@ -912,35 +967,57 @@ impl cosmic::Application for App { ) }) .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([ title, self.update_theme(), self.flush(), self.load_thumbnail(), expire, + display_command, + display_refresh, ]); } Err(error) => { + self.display_state.queued = None; + self.display_state.restoring = false; self.error = Some(error); self.pending.clear(); self.draft = self.current.clone(); + self.keyboard.sync(&self.draft); + self.shortcuts.localize(); self.options.applying = false; self.status = "Not saved".into(); } } } Message::Reload if !self.saving && !self.reloading => { + self.shortcuts.opened = false; + let stop_recording = self.stop_shortcut_capture(); self.reloading = true; let revision = self.edit_revision; let path = self.path.clone(); - return cosmic::task::future(async move { - Message::Reloaded( - revision, - tokio::task::spawn_blocking(move || Snapshot::read(&path)) - .await - .unwrap_or_else(|e| Err(e.to_string())), - ) - }); + return Task::batch([ + stop_recording, + cosmic::task::future(async move { + Message::Reloaded( + revision, + tokio::task::spawn_blocking(move || Snapshot::read(&path)) + .await + .unwrap_or_else(|e| Err(e.to_string())), + ) + }), + ]); } Message::Reloaded(revision, result) => { self.reloading = false; @@ -956,6 +1033,10 @@ impl cosmic::Application for App { self.calendar_editor.refresh(&snapshot); self.current = snapshot.clone(); 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.resolved.families, &self.draft, @@ -1185,24 +1266,6 @@ impl cosmic::Application for App { 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 => { self.inputs.clear(); let task = self.change(Edit::Remove(table, index)); @@ -1215,6 +1278,26 @@ impl cosmic::Application for App { fn subscription(&self) -> cosmic::iced::Subscription { 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::Window(cosmic::iced::window::Event::Opened { .. }) => { Some(Message::ReadRenderScale(id)) @@ -1292,6 +1375,9 @@ impl App { cosmic::command::set_theme(palette.native_theme()) } fn edit_many(&mut self, edits: Vec) -> Task { + if edits.iter().any(display_controls::affects_outputs) { + self.remember_display_config(); + } self.edit_revision = self.edit_revision.wrapping_add(1); for edit in edits { if let Err(error) = self.draft.edit(&edit) { @@ -1324,6 +1410,9 @@ impl App { } fn change(&mut self, edit: Edit) -> Task { + if display_controls::affects_outputs(&edit) { + self.remember_display_config(); + } self.edit_revision = self.edit_revision.wrapping_add(1); if let Edit::Set(path, value) = &edit && matches!( @@ -1375,6 +1464,8 @@ impl App { } fn flush(&mut self) -> Task { + self.keyboard.sync(&self.draft); + self.shortcuts.localize(); if self.saving || self.pending.is_empty() { return Task::none(); } @@ -1443,6 +1534,10 @@ mod tests { focused: true, current: None, modes: vec![], + connected: true, + enabled: true, + scale: 1., + ..Default::default() }]))), ); assert_eq!(app.load_displays().units(), 0); @@ -1951,9 +2046,9 @@ mod tests { for (parent, query, destination) in [ (Page::Windows, "motion", Page::Motion), (Page::Windows, "animation speed", Page::Motion), - (Page::Keyboard, "shortcuts", Page::Shortcuts), - (Page::Keyboard, "swipe actions", Page::Shortcuts), - (Page::Keyboard, "touchpad", Page::Keyboard), + (Page::Shortcuts, "shortcuts", Page::Shortcuts), + (Page::Shortcuts, "swipe actions", Page::Shortcuts), + (Page::Touchpad, "touchpad", Page::Touchpad), ] { assert!(parent.matches(query)); assert_eq!(parent.search_destination(query), destination); diff --git a/shell/ferese-settings/src/pages.rs b/shell/ferese-settings/src/pages.rs index 1408269..cdee2e0 100644 --- a/shell/ferese-settings/src/pages.rs +++ b/shell/ferese-settings/src/pages.rs @@ -307,6 +307,43 @@ impl App { { 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 { Vec::new() } else if self.page == Page::Wallpaper { @@ -318,10 +355,12 @@ impl App { let fields = fields .into_iter() .filter(|field| { - self.page != Page::Theme - || !(field.path == "theme.mode" - || field.path.starts_with("theme.schedule.") - || field.path.ends_with(".file")) + !(self.page == Page::Keyboard + && (field.path == "input.xkb_layout" || field.path == "input.xkb_variant")) + && (self.page != Page::Theme + || !(field.path == "theme.mode" + || field.path.starts_with("theme.schedule.") + || field.path.ends_with(".file"))) }) .collect(); for group in schema::grouped_fields(self.page, fields) { @@ -472,84 +511,7 @@ impl App { ), ); } - Page::Shortcuts => { - body = - body.push(self.panel_heading(ferese_config::tr!("Custom shortcuts"), 14.)); - body = body.push(self.note(ferese_config::tr!("Keyboard, mouse and swipe bindings share the same actions. Mouse shortcuts can use MouseLeft, MouseRight, MouseMiddle, MouseBack, MouseForward or WheelUp/Down/Left/Right with modifiers. Use pointer-move or pointer-resize for dragging. Swipes use Swipe3Up, Swipe3Down, Swipe3Left or Swipe3Right (also supports 4 or 5 fingers). Actions such as toggle-overview and none need no argument."))); - let mut gestures = row([]).spacing(6); - for (keys, label) in [ - ("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::Shortcuts => body = body.push(self.shortcut_controls()), Page::Displays => body = body.push(self.displays_view()), _ => {} } @@ -695,32 +657,6 @@ impl App { )); 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.")) - } 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 { footer.into() }; diff --git a/shell/ferese-settings/src/panel_controls.rs b/shell/ferese-settings/src/panel_controls.rs index 2fa0f57..1d4df33 100644 --- a/shell/ferese-settings/src/panel_controls.rs +++ b/shell/ferese-settings/src/panel_controls.rs @@ -113,7 +113,7 @@ impl App { ), ), ) - .padding(14) + .padding(10) .width(cosmic::iced::Length::Fill) .class(crate::visuals::surface(palette.card, 14.)), ); @@ -150,12 +150,12 @@ impl App { .into(), ), ) - .padding(16) + .padding(10) .class(crate::visuals::surface(palette.card, 14.)), ); let mut appearance = widget::column([]) - .spacing(16) - .push(self.panel_heading(ferese_config::tr!("Appearance"), 15.)) + .spacing(12) + .push(self.panel_heading(ferese_config::tr!("Panel appearance"), 14.)) .push(self.panel_style_choices(preset.unwrap_or_default())) .push(self.panel_divider()) .push(self.panel_opacity_controls()) @@ -267,7 +267,7 @@ impl App { rows = rows.push( widget::container(appearance) .id("panel-appearance-settings") - .padding(16) + .padding(10) .width(cosmic::iced::Length::Fill) .class(crate::visuals::surface(palette.card, 14.)), ); @@ -278,7 +278,7 @@ impl App { let mut settings = widget::column([]).spacing(4).push( widget::container( 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), self.settings_button( ferese_config::tr!("Reset to style"), @@ -362,20 +362,6 @@ impl App { .padding(12) .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(), } @@ -656,7 +642,7 @@ impl App { cosmic::iced::widget::responsive(move |size| { let editor = widget::column([]) .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!( "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", 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( 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, current: None, modes: Vec::new(), + connected: true, + enabled: true, + scale: 1., + ..Default::default() }]; app.panel_preview_resolution.items.insert( ferese_panel::ItemId("clock".into()), diff --git a/shell/ferese-settings/src/panel_preview.rs b/shell/ferese-settings/src/panel_preview.rs index 7df33dd..3b37ccd 100644 --- a/shell/ferese-settings/src/panel_preview.rs +++ b/shell/ferese-settings/src/panel_preview.rs @@ -49,7 +49,7 @@ impl App { row([]) .align_y(Alignment::Center) .push( - self.label(ferese_config::tr!("Composition preview"), 14.) + self.panel_heading(ferese_config::tr!("Composition preview"), 14.) .width(Length::Fill), ) .push(self.note(ferese_config::tr!("Sample content"))), diff --git a/shell/ferese-settings/src/performance_tests.rs b/shell/ferese-settings/src/performance_tests.rs index c8038eb..312087f 100644 --- a/shell/ferese-settings/src/performance_tests.rs +++ b/shell/ferese-settings/src/performance_tests.rs @@ -19,6 +19,7 @@ fn fixture() -> App { ), ); app.options_loaded(all_options::catalog()); + app.keyboard_catalog_loaded(crate::keyboard_controls::catalog()); app.connections.snapshot = Some(connections::performance_snapshot()); app.tray_apps = (0..12) .map(|i| tray_controls::Application { @@ -32,6 +33,8 @@ fn fixture() -> App { identity: format!("Test display {i}"), profile: None, logical_width: Some(1920), + logical_height: Some(1080), + position: Some([(i - 1) * 1920, 0]), focused: i == 0, current: Some(displays::Mode { width: 3840, @@ -50,6 +53,10 @@ fn fixture() -> App { refresh: 60000, }, ], + connected: true, + enabled: true, + scale: 2., + ..Default::default() }) .collect(); app diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index d4d364d..d93e152 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -18,6 +18,9 @@ pub enum Page { Displays, Connections, Calendar, + Mouse, + Touchpad, + Shell, } impl Page { @@ -29,7 +32,14 @@ impl Page { ), ( "Interaction", - &[Self::Windows, Self::Keyboard, Self::Accessibility], + &[ + Self::Windows, + Self::Keyboard, + Self::Mouse, + Self::Touchpad, + Self::Shortcuts, + Self::Accessibility, + ], ), ( "System", @@ -49,7 +59,7 @@ impl Page { pub fn navigation_page(self) -> Self { match self { Self::Motion => Self::Windows, - Self::Shortcuts => Self::Keyboard, + Self::Shell => Self::Bar, _ => self, } } @@ -57,10 +67,7 @@ impl Page { pub fn subpages(self) -> &'static [(Self, &'static str)] { match self.navigation_page() { Self::Windows => &[(Self::Windows, "Windows"), (Self::Motion, "Motion")], - Self::Keyboard => &[ - (Self::Keyboard, "Keyboard & mouse"), - (Self::Shortcuts, "Shortcuts"), - ], + Self::Bar => &[(Self::Bar, "Panel"), (Self::Shell, "Shell")], _ => &[], } } @@ -77,7 +84,7 @@ impl Page { } #[cfg(test)] - pub const ALL: [Self; 18] = [ + pub const ALL: [Self; 21] = [ Self::Appearance, Self::General, Self::Theme, @@ -96,6 +103,9 @@ impl Page { Self::Displays, Self::Connections, Self::Calendar, + Self::Mouse, + Self::Touchpad, + Self::Shell, ]; pub fn title(self) -> &'static str { @@ -115,7 +125,10 @@ impl Page { Self::LockScreen => "Lock screen", Self::Windows => "Windows", Self::Motion => "Motion", - Self::Keyboard => "Keyboard & mouse", + Self::Keyboard => "Keyboard", + Self::Mouse => "Mouse", + Self::Touchpad => "Touchpad", + Self::Shell => "Shell", Self::Shortcuts => "Shortcuts", Self::Startup => "Login items", Self::Displays => "Displays", @@ -140,7 +153,10 @@ impl Page { Self::LockScreen => "Your desktop, safely put away.", Self::Windows => "Window layouts, spacing and focus effects.", 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::Startup => "Ready when you sign in.", Self::Displays => "A place for every screen.", @@ -173,6 +189,9 @@ impl Page { 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" } + 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 => { "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 { }, ), ], - Page::Bar => vec![ + Page::Shell => vec![ range( "theme.geometry.shell_radius", "Shell corner radius", @@ -586,6 +605,8 @@ pub fn fields(page: Page) -> Vec { integer: false, }, ), + ], + Page::Bar => vec![ range( "panels.0.geometry.height", "Height", @@ -867,43 +888,8 @@ pub fn fields(page: Page) -> Vec { integer: true, }, ), - 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, - }, - ), + ], + Page::Mouse => vec![ toggle( "input.mouse.natural_scroll", "Mouse natural scrolling", @@ -948,6 +934,45 @@ pub fn fields(page: Page) -> Vec { 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( "input.touchpad.swipe_threshold", "Swipe distance", @@ -1055,6 +1080,9 @@ pub fn grouped_fields(page: Page, fields: Vec) -> Vec { "Focus and window effects" } } + Page::Shell => "Shell surfaces", + Page::Mouse => "Mouse", + Page::Touchpad => "Touchpad", Page::Keyboard => { if p.starts_with("input.xkb_") { "Keyboard layout" @@ -1173,9 +1201,10 @@ mod group_tests { actual.sort(); 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!( - keyboard + mouse .iter() .find(|g| g.title == "Mouse") .unwrap() @@ -1184,7 +1213,7 @@ mod group_tests { .all(|f| f.path.starts_with("input.mouse.")) ); assert!( - keyboard + touchpad .iter() .find(|g| g.title == "Touchpad") .unwrap() diff --git a/shell/ferese-settings/src/scroll_tests.rs b/shell/ferese-settings/src/scroll_tests.rs index 6ecbcc7..b2c718d 100644 --- a/shell/ferese-settings/src/scroll_tests.rs +++ b/shell/ferese-settings/src/scroll_tests.rs @@ -1144,7 +1144,11 @@ fn appearance_cards_reflow_without_overlap_and_keep_every_destination() { .operate(&mut tree, Layout::new(&node), &renderer, &mut cards); assert_eq!( cards.bounds.len(), - 15, + crate::schema::Page::NAVIGATION + .iter() + .map(|(_, p)| p.len()) + .sum::() + - 1, "{width}: all settings destinations must be reachable" ); 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(); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut centers); - assert_eq!(centers.buttons.len(), 16); - assert_eq!(centers.parts.len(), 64); + assert_eq!( + centers.buttons.len(), + crate::schema::Page::NAVIGATION + .iter() + .map(|(_, p)| p.len()) + .sum::() + ); + assert_eq!(centers.parts.len(), centers.buttons.len() * 4); let sidebar = centers.sidebar.unwrap(); assert!( centers diff --git a/shell/ferese-settings/src/selection.rs b/shell/ferese-settings/src/selection.rs new file mode 100644 index 0000000..f5f22a8 --- /dev/null +++ b/shell/ferese-settings/src/selection.rs @@ -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, + 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() + } +} diff --git a/shell/ferese-settings/src/shortcut_controls.rs b/shell/ferese-settings/src/shortcut_controls.rs new file mode 100644 index 0000000..3af02dc --- /dev/null +++ b/shell/ferese-settings/src/shortcut_controls.rs @@ -0,0 +1,1265 @@ +use crate::{ + App, Element, Message, Snapshot, Task, + store::{Edit, set}, + visuals, +}; +use cosmic::Application; +use cosmic::iced::{ + Length, + keyboard::{self, Key, Modifiers, key::Named}, +}; +use cosmic::widget::{column, combo_box, container, row, text_input}; + +// The inhibitor is deliberately optional in Ferese sessions. Inspect the actual +// registry on a worker instead of assuming an inherited environment flag means +// the compositor exposes it. Never invite a user to press a live shortcut while +// the protocol is absent. +fn recording_supported() -> bool { + use wayland_client::{ + Connection, Dispatch, QueueHandle, + globals::{GlobalListContents, registry_queue_init}, + protocol::wl_registry, + }; + struct Probe; + impl Dispatch for Probe { + fn event( + _: &mut Self, + _: &wl_registry::WlRegistry, + _: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } + } + let Ok(connection) = Connection::connect_to_env() else { + return false; + }; + let Ok((globals, _)) = registry_queue_init::(&connection) else { + return false; + }; + globals.contents().with_list(|globals| { + globals + .iter() + .any(|g| g.interface == "zwp_keyboard_shortcuts_inhibit_manager_v1") + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Argument { + None, + Direction, + Workspace, + Command, + Mode, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Action { + id: String, + label: String, + argument: Argument, +} +impl std::fmt::Display for Action { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&ferese_config::locale::translate(&self.label)) + } +} +fn actions() -> Vec { + use Argument::*; + [ + ("spawn", "Launch a program", Command), + ("toggle-launcher", "Open or close launcher", None), + ("toggle-overview", "Open or close overview", None), + ("close", "Close window", None), + ("toggle-maximized", "Maximize or restore window", None), + ("toggle-fullscreen", "Toggle fullscreen", None), + ("toggle-floating", "Toggle floating window", None), + ("focus", "Focus window", Direction), + ("move", "Move window", Direction), + ("resize", "Resize window", Direction), + ("workspace", "Go to workspace", Workspace), + ("move-to-workspace", "Move window to workspace", Workspace), + ("workspace-next", "Next workspace", None), + ("workspace-previous", "Previous workspace", None), + ( + "workspace-back-and-forth", + "Return to previous workspace", + None, + ), + ("focus-last-window", "Return to last focused window", None), + ( + "focus-floating", + "Switch between tiled and floating windows", + None, + ), + ("focus-mru-next", "Next window in focus history", None), + ( + "focus-mru-previous", + "Previous window in focus history", + None, + ), + ("toggle-layout", "Switch workspace layout", None), + ("cycle-column-width", "Cycle column width", None), + ("center-column", "Center focused column", None), + ( + "toggle-column-tabbed", + "Toggle stacked or tabbed column", + None, + ), + ("join-tab", "Join tab group", Direction), + ("leave-tab", "Leave tab group", None), + ("next-tab", "Next tab", None), + ("previous-tab", "Previous tab", None), + ("consume", "Join window into column", None), + ("expel", "Leave column", None), + ("pointer-move", "Drag window", None), + ("pointer-resize", "Resize window with pointer", None), + ("media-play-pause", "Play or pause media", None), + ("media-next", "Next track", None), + ("media-previous", "Previous track", None), + ("toggle-display-mode", "Open display mode chooser", None), + ( + "toggle-keybinding-guide", + "Open or close shortcut hint", + None, + ), + ("binding-mode", "Switch binding mode", Mode), + ("exit", "Log out with confirmation", None), + ("none", "Disable shortcut", None), + ] + .into_iter() + .map(|(id, label, argument)| Action { + id: id.into(), + label: label.into(), + argument, + }) + .collect() +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct KeyChoice { + code: String, + label: String, +} +impl std::fmt::Display for KeyChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&ferese_config::locale::translate(&self.label)) + } +} +fn keys() -> Vec { + let mut keys: Vec<_> = ('A'..='Z') + .chain('0'..='9') + .map(|c| c.to_string()) + .chain((1..=35).map(|n| format!("F{n}"))) + .chain( + [ + "Return", + "Tab", + "space", + "Escape", + "BackSpace", + "Delete", + "Insert", + "Home", + "End", + "Page_Up", + "Page_Down", + "Left", + "Right", + "Up", + "Down", + "Print", + "XF86AudioPlay", + "XF86AudioNext", + "XF86AudioPrev", + "MouseLeft", + "MouseRight", + "MouseMiddle", + "MouseBack", + "MouseForward", + "WheelUp", + "WheelDown", + "WheelLeft", + "WheelRight", + ] + .into_iter() + .map(str::to_owned), + ) + .map(|code| KeyChoice { + label: match code.as_str() { + "Return" => "Enter", + "space" => "Space", + "BackSpace" => "Backspace", + "Page_Up" => "Page up", + "Page_Down" => "Page down", + "Left" => "Left arrow", + "Right" => "Right arrow", + "Up" => "Up arrow", + "Down" => "Down arrow", + "XF86AudioPlay" => "Play or pause media", + "XF86AudioNext" => "Next track", + "XF86AudioPrev" => "Previous track", + "MouseLeft" => "Left mouse button", + "MouseRight" => "Right mouse button", + "MouseMiddle" => "Middle mouse button", + "MouseBack" => "Back mouse button", + "MouseForward" => "Forward mouse button", + "WheelUp" => "Wheel up", + "WheelDown" => "Wheel down", + "WheelLeft" => "Wheel left", + "WheelRight" => "Wheel right", + _ => &code, + } + .to_owned(), + code, + }) + .collect(); + for fingers in 3..=5 { + for direction in ["Up", "Down", "Left", "Right"] { + let code = format!("Swipe{fingers}{direction}"); + keys.push(KeyChoice { + label: ferese_config::locale::format( + "{count}-finger swipe {direction}", + &[ + ("count", fingers.to_string()), + ("direction", ferese_config::locale::translate(direction)), + ], + ), + code, + }); + } + } + keys +} +pub(super) struct State { + pub opened: bool, + pub recording: bool, + supported: Option, + checking: bool, + ready: bool, + captured: bool, + serial: u64, + index: Option, + pub keys: String, + match_mode: String, + key: Option, + modifiers: Modifiers, + action: Option, + argument: String, + program: String, + error: Option, + actions: combo_box::State, + key_choices: combo_box::State, + show_picker: bool, + pub filter: String, + language: ferese_config::locale::Language, +} +impl Default for State { + fn default() -> Self { + Self { + opened: false, + recording: false, + supported: None, + checking: false, + ready: false, + captured: false, + serial: 0, + index: None, + keys: String::new(), + match_mode: "keysym".into(), + key: None, + modifiers: Modifiers::empty(), + action: None, + argument: String::new(), + program: String::new(), + error: None, + actions: combo_box::State::new(actions()), + key_choices: combo_box::State::new(keys()), + show_picker: false, + filter: String::new(), + language: ferese_config::locale::resolved(), + } + } +} +impl State { + pub fn localize(&mut self) { + if self.language != ferese_config::locale::resolved() { + self.language = ferese_config::locale::resolved(); + self.actions = combo_box::State::new(actions()); + self.key_choices = combo_box::State::new(keys()); + } + } + fn stop(&mut self) { + self.recording = false; + self.ready = false; + self.serial = self.serial.wrapping_add(1); + } +} +#[derive(Clone, Debug)] +pub(super) enum Input { + Open(Option), + OpenGesture, + Guide, + GuideFinished(Result<(), String>), + Cancel, + Record, + Supported(bool), + Key(keyboard::Event), + Inhibited(bool), + Timeout(u64), + Stop, + SelectKey(KeyChoice), + Modifier(Modifiers), + Picker, + Action(Action), + Argument(String), + Program(String), + Save, + Filter(String), +} +fn inhibit(value: bool) -> Task { + cosmic::iced::platform_specific::shell::commands::keyboard_shortcuts_inhibit::inhibit_shortcuts( + value, + ) + .map(move |_| cosmic::Action::App(Message::Shortcut(Input::Inhibited(value)))) +} +fn chord(key: &str, modifiers: Modifiers) -> String { + let mut parts = Vec::new(); + for (active, name) in [ + (modifiers.logo(), "Super"), + (modifiers.control(), "Ctrl"), + (modifiers.alt(), "Alt"), + (modifiers.shift(), "Shift"), + ] { + if active { + parts.push(name); + } + } + parts.push(key); + parts.join("+") +} +fn key_name(key: &Key) -> Option { + Some(match key.as_ref() { + Key::Named( + Named::Super + | Named::Meta + | Named::Control + | Named::Alt + | Named::Shift + | Named::AltGraph, + ) => return None, + Key::Named(key) => match key { + Named::Enter => "Return".into(), + Named::ArrowLeft => "Left".into(), + Named::ArrowRight => "Right".into(), + Named::ArrowUp => "Up".into(), + Named::ArrowDown => "Down".into(), + Named::PageUp => "Page_Up".into(), + Named::PageDown => "Page_Down".into(), + Named::Backspace => "BackSpace".into(), + Named::PrintScreen => "Print".into(), + Named::AudioVolumeUp => "XF86AudioRaiseVolume".into(), + Named::AudioVolumeDown => "XF86AudioLowerVolume".into(), + Named::AudioVolumeMute => "XF86AudioMute".into(), + Named::MediaPlayPause => "XF86AudioPlay".into(), + Named::MediaTrackNext => "XF86AudioNext".into(), + Named::MediaTrackPrevious => "XF86AudioPrev".into(), + other => format!("{other:?}"), + }, + Key::Character(text) => match text { + " " => "space".into(), + "+" => "plus".into(), + "=" => "equal".into(), + "-" => "minus".into(), + "[" => "bracketleft".into(), + "]" => "bracketright".into(), + ";" => "semicolon".into(), + "'" => "apostrophe".into(), + "," => "comma".into(), + "." => "period".into(), + "/" => "slash".into(), + "\\" => "backslash".into(), + "`" => "grave".into(), + s if s.chars().count() == 1 => { + let c = s.chars().next()?; + if c.is_ascii() { + s.to_uppercase() + } else { + format!("U{:04X}", u32::from(c)) + } + } + _ => return None, + }, + Key::Unidentified => return None, + }) +} +fn normalized(keys: &str) -> String { + let mut modifiers = Modifiers::empty(); + let mut key = String::new(); + for part in keys.split('+').map(str::trim) { + match part.to_ascii_lowercase().as_str() { + "super" | "logo" | "mod4" => modifiers.insert(Modifiers::LOGO), + "control" | "ctrl" => modifiers.insert(Modifiers::CTRL), + "alt" => modifiers.insert(Modifiers::ALT), + "shift" => modifiers.insert(Modifiers::SHIFT), + "enter" => key = "return".into(), + other => key = other.into(), + } + } + chord(&key, modifiers) +} +fn effective_match(keys: &str, matching: &str) -> String { + let key = keys + .split('+') + .last() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if key.starts_with("swipe") { + "swipe".into() + } else if key.starts_with("mouse") + || key.starts_with("wheel") + || key.starts_with("touchpadscroll") + { + "pointer".into() + } else { + matching.into() + } +} +fn save_plan(snapshot: &Snapshot, state: &State) -> Result, String> { + if state.keys.is_empty() { + return Err("Record or select a shortcut first".into()); + } + let action = state.action.as_ref().ok_or("Choose an action")?; + let duplicate = (0..snapshot.records("bindings")).find(|i| { + Some(*i) != state.index + && effective_match( + &snapshot.string(&format!("bindings.{i}.keys"), ""), + &snapshot.string(&format!("bindings.{i}.match"), "keysym"), + ) == effective_match(&state.keys, &state.match_mode) + && normalized(&snapshot.string(&format!("bindings.{i}.keys"), "")) + == normalized(&state.keys) + }); + if state.index.is_some() && duplicate.is_some() { + return Err("This shortcut belongs to another binding. Edit that binding instead.".into()); + } + let index = state.index.or(duplicate); + let mut edits = Vec::new(); + if ["pointer-move", "pointer-resize"].contains(&action.id.as_str()) + && !state + .keys + .split('+') + .last() + .unwrap_or_default() + .to_ascii_lowercase() + .starts_with("mouse") + { + return Err("Window dragging and resizing require a mouse button".into()); + } + let mut record = index + .and_then(|i| snapshot.item(&format!("bindings.{i}")).cloned()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + record.insert("keys".into(), state.keys.clone().into()); + record.insert("action".into(), action.id.clone().into()); + record.remove("disabled"); + record.remove("argument"); + if state.match_mode == "keysym" { + record.remove("match"); + } else { + record.insert("match".into(), state.match_mode.clone().into()); + } + let argument = match action.argument { + Argument::None => None, + Argument::Direction => { + if !["left", "right", "up", "down"].contains(&state.argument.as_str()) { + return Err("Choose a direction".into()); + } + Some(state.argument.clone()) + } + Argument::Workspace => { + if state + .argument + .parse::() + .ok() + .filter(|n| *n > 0) + .is_none() + { + return Err("Workspace number must be between 1 and 255".into()); + } + Some(state.argument.clone()) + } + Argument::Mode => { + if state.argument.trim().is_empty() { + return Err("Choose a binding mode".into()); + } + Some(state.argument.clone()) + } + Argument::Command => { + if state.argument == "$custom" { + let argv = shlex::split(&state.program) + .filter(|a| !a.is_empty()) + .ok_or("Enter a program and arguments with balanced quotes.")?; + let mut serial = 1; + let name = loop { + let name = format!("shortcut_{serial}"); + if snapshot.item(&format!("commands.{name}")).is_none() { + break name; + } + serial += 1; + }; + edits.push(set(&format!("commands.{name}"), argv)); + Some(name) + } else { + Some(state.argument.clone()) + } + } + }; + if let Some(argument) = argument { + record.insert("argument".into(), argument.into()); + } + if let Some(index) = index { + for field in ["keys", "action", "argument", "disabled", "match"] { + let path = format!("bindings.{index}.{field}"); + if let Some(value) = record.get(field) { + edits.push(set(&path, value.clone())); + } else if snapshot.item(&path).is_some() { + edits.push(Edit::Unset(path)); + } + } + } else { + edits.push(Edit::Add("bindings".into(), record.into_iter().collect())); + } + Ok(edits) +} +impl App { + pub(super) fn stop_shortcut_capture(&mut self) -> Task { + if !self.shortcuts.recording { + return Task::none(); + } + self.shortcuts.stop(); + inhibit(false) + } + pub(super) fn shortcut_input(&mut self, input: Input) -> Task { + match input { + Input::OpenGesture => { + let navigation = self.update(Message::Page(crate::schema::Page::Shortcuts)); + let editor = self.shortcut_input(Input::Open(None)); + self.shortcuts.show_picker = true; + self.shortcuts.key = keys().into_iter().find(|k| k.code == "Swipe3Up"); + self.shortcuts.keys = "Swipe3Up".into(); + self.shortcuts.modifiers = Modifiers::empty(); + self.shortcuts.action = actions().into_iter().find(|a| a.id == "workspace-next"); + return Task::batch([navigation, editor]); + } + Input::Guide => { + return cosmic::task::future(async { + Message::Shortcut(Input::GuideFinished( + tokio::task::spawn_blocking(|| { + let mut connection = + ferese_ipc::Connection::connect().map_err(|e| e.to_string())?; + connection + .call("toggle-keybinding-guide", serde_json::json!({})) + .map(|_| ()) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())), + )) + }); + } + Input::GuideFinished(Err(error)) => self.error = Some(error), + Input::Open(index) => { + let stop = self.stop_shortcut_capture(); + let s = &mut self.shortcuts; + s.opened = true; + s.index = index; + if index.is_none() { + s.filter.clear(); + } + s.error = None; + s.keys.clear(); + s.action = None; + s.argument.clear(); + s.program.clear(); + s.show_picker = false; + s.key = None; + s.modifiers = Modifiers::empty(); + s.match_mode = "keysym".into(); + if let Some(i) = index { + s.keys = self.draft.string(&format!("bindings.{i}.keys"), ""); + s.match_mode = self.draft.string(&format!("bindings.{i}.match"), "keysym"); + let id = self.draft.string(&format!("bindings.{i}.action"), "none"); + s.action = actions().into_iter().find(|a| a.id == id); + s.argument = self.draft.string(&format!("bindings.{i}.argument"), ""); + let parts = normalized(&s.keys); + let key = parts.split('+').last().unwrap_or_default(); + let original = s.keys.split('+').last().unwrap_or_default().trim(); + s.key = keys() + .into_iter() + .find(|k| k.code.eq_ignore_ascii_case(key)) + .or_else(|| { + Some(KeyChoice { + code: original.to_owned(), + label: original.to_owned(), + }) + }); + for (name, modifier) in [ + ("Super", Modifiers::LOGO), + ("Ctrl", Modifiers::CTRL), + ("Alt", Modifiers::ALT), + ("Shift", Modifiers::SHIFT), + ] { + if parts.split('+').any(|p| p == name) { + s.modifiers.insert(modifier); + } + } + } + let check = if s.supported.is_none() && !s.checking { + s.checking = true; + cosmic::task::future(async { + Message::Shortcut(Input::Supported( + tokio::task::spawn_blocking(recording_supported) + .await + .unwrap_or(false), + )) + }) + } else { + Task::none() + }; + s.show_picker = s.supported == Some(false); + return Task::batch([ + stop, + check, + cosmic::iced::widget::scrollable::snap_to( + cosmic::widget::Id::new("settings-content"), + cosmic::iced::widget::scrollable::RelativeOffset::START.into(), + ), + ]); + } + Input::Cancel => { + self.shortcuts.opened = false; + return self.stop_shortcut_capture(); + } + Input::Record => { + if self.shortcuts.supported != Some(true) { + self.shortcuts.show_picker = true; + return Task::none(); + } + let s = &mut self.shortcuts; + s.recording = true; + s.ready = false; + s.captured = false; + s.serial = s.serial.wrapping_add(1); + s.error = None; + let serial = s.serial; + return Task::batch([ + inhibit(true), + cosmic::iced::advanced::widget::operate( + cosmic::iced::advanced::widget::operation::focusable::unfocus(), + ) + .map(cosmic::Action::App), + cosmic::task::future(async move { + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + Message::Shortcut(Input::Timeout(serial)) + }), + ]); + } + Input::Supported(supported) => { + self.shortcuts.supported = Some(supported); + self.shortcuts.checking = false; + if !supported { + self.shortcuts.show_picker = true; + } + } + Input::Inhibited(value) => { + self.shortcuts.ready = value; + if !value && self.shortcuts.recording { + return self.stop_shortcut_capture(); + } + } + Input::Key(event) if self.shortcuts.recording => { + if let keyboard::Event::KeyPressed { + key: Key::Named(Named::Escape), + modifiers, + .. + } = &event + && modifiers.is_empty() + { + return self.stop_shortcut_capture(); + } + if !self.shortcuts.ready { + return Task::none(); + } + match event { + keyboard::Event::KeyPressed { + key, + modifiers, + repeat: false, + .. + } => { + if let Some(key) = key_name(&key) { + self.shortcuts.keys = chord(&key, modifiers); + self.shortcuts.match_mode = "keysym".into(); + self.shortcuts.captured = true; + self.shortcuts.modifiers = modifiers; + self.shortcuts.key = Some(KeyChoice { + label: key.clone(), + code: key, + }); + } + } + keyboard::Event::KeyReleased { modifiers, .. } + | keyboard::Event::ModifiersChanged(modifiers) + if self.shortcuts.captured && modifiers.is_empty() => + { + return self.stop_shortcut_capture(); + } + _ => {} + } + } + Input::Stop => return self.stop_shortcut_capture(), + Input::Timeout(serial) + if self.shortcuts.recording && self.shortcuts.serial == serial => + { + self.shortcuts.error = + Some("Recording timed out. You can also select keys from the menu.".into()); + return self.stop_shortcut_capture(); + } + Input::Picker => { + self.shortcuts.show_picker = !self.shortcuts.show_picker; + } + Input::SelectKey(key) => { + self.shortcuts.match_mode = if key.code.starts_with("Swipe") { + "swipe" + } else { + "keysym" + } + .into(); + self.shortcuts.keys = chord(&key.code, self.shortcuts.modifiers); + self.shortcuts.key = Some(key); + } + Input::Modifier(modifier) => { + self.shortcuts.modifiers.toggle(modifier); + if let Some(key) = &self.shortcuts.key { + self.shortcuts.keys = chord(&key.code, self.shortcuts.modifiers); + } + } + Input::Action(action) => { + self.shortcuts.argument = match action.argument { + Argument::Direction => "left", + Argument::Workspace => "1", + Argument::Command => "terminal", + _ => "", + } + .into(); + self.shortcuts.action = Some(action); + self.shortcuts.error = None; + } + Input::Argument(value) => self.shortcuts.argument = value, + Input::Program(value) => self.shortcuts.program = value, + Input::Filter(value) => self.shortcuts.filter = value, + Input::Save if !self.saving && !self.shortcuts.recording => { + match save_plan(&self.draft, &self.shortcuts) { + Ok(edits) => { + self.shortcuts.opened = false; + return self.edit_many(edits); + } + Err(error) => self.shortcuts.error = Some(error), + } + } + _ => {} + } + Task::none() + } + pub(super) fn shortcut_controls(&self) -> Element<'_, Message> { + let p = visuals::Palette::from_resolved(&self.resolved.presented); + let s = &self.shortcuts; + let mut content = column([]).spacing(12).push( + row([]) + .spacing(8) + .align_y(cosmic::iced::Alignment::Center) + .push(self.settings_button( + "Add shortcut", + "M12 5v14 M5 12h14", + (!s.opened).then_some(Message::Shortcut(Input::Open(None))), + false, + )) + .push(self.settings_icon_button( + "Show active shortcuts", + "M8 8h8v8H8z", + Some(Message::Shortcut(Input::Guide)), + )) + .push( + text_input(ferese_config::tr!("Search shortcuts…"), s.filter.clone()) + .size(12) + .font(self.font) + .style(visuals::input_style(p)) + .on_input(|s| Message::Shortcut(Input::Filter(s))) + .width(Length::Fill), + ), + ); + if s.opened { + let mut editor = column([]).spacing(10).push(self.panel_heading( + if s.index.is_some() { + "Edit shortcut" + } else { + "New shortcut" + }, + 14., + )); + if s.match_mode == "physical" { + editor=editor.push(self.note("This binding matches physical key positions. Recording chooses key labels instead.")); + } + editor = editor.push( + row([]) + .spacing(8) + .align_y(cosmic::iced::Alignment::Center) + .push( + self.label( + if s.keys.is_empty() { + ferese_config::tr!("No shortcut selected") + } else { + &s.keys + }, + 13., + ) + .width(Length::Fill), + ) + .push(self.settings_button( + if s.recording { + if s.ready { + "Press a shortcut · Esc cancels" + } else { + "Preparing recording…" + } + } else { + if s.match_mode == "physical" { + "Record by key label" + } else { + "Record shortcut" + } + }, + "M3 6h18v12H3z", + (s.supported == Some(true)).then_some(Message::Shortcut(if s.recording { + Input::Stop + } else { + Input::Record + })), + s.recording, + )) + .push(self.settings_icon_button( + "Choose keys from menu", + "M6 9l6 6 6-6", + Some(Message::Shortcut(Input::Picker)), + )), + ); + if s.supported == Some(false) { + editor = editor.push(self.note("This session does not support recording. Choose a combination from the key menu.")); + } + if s.show_picker { + let mut modifiers = row([]).spacing(6); + for (name, modifier) in [ + ("Super", Modifiers::LOGO), + ("Ctrl", Modifiers::CTRL), + ("Alt", Modifiers::ALT), + ("Shift", Modifiers::SHIFT), + ] { + modifiers = modifiers.push(self.settings_button( + name, + "M8 8h8v8H8z", + Some(Message::Shortcut(Input::Modifier(modifier))), + s.modifiers.contains(modifier), + )); + } + editor = editor.push(modifiers).push(self.search_select( + &s.key_choices, + ferese_config::tr!("Search keys or gestures…"), + s.key.as_ref(), + |key| Message::Shortcut(Input::SelectKey(key)), + )); + } + editor = editor.push(self.search_select( + &s.actions, + ferese_config::tr!("Search actions…"), + s.action.as_ref(), + |a| Message::Shortcut(Input::Action(a)), + )); + if let Some(action) = &s.action { + match action.argument { + Argument::None => {} + Argument::Direction => { + let options = vec![ + OptionChoice::new("left", "Left"), + OptionChoice::new("right", "Right"), + OptionChoice::new("up", "Up"), + OptionChoice::new("down", "Down"), + ]; + let selected = options.iter().find(|c| c.id == s.argument).cloned(); + editor = editor.push(self.shortcut_argument_menu(options, selected)); + } + Argument::Workspace => { + editor = editor.push( + text_input(ferese_config::tr!("Workspace number"), s.argument.clone()) + .font(self.font) + .size(12) + .style(visuals::input_style(p)) + .on_input(|v| Message::Shortcut(Input::Argument(v))), + ) + } + Argument::Mode => { + let mut options = vec![OptionChoice::new("default", "Default shortcuts")]; + options.extend( + (0..self.draft.records("binding_modes")) + .map(|i| self.draft.string(&format!("binding_modes.{i}.name"), "")) + .filter(|n| !n.is_empty()) + .map(|name| OptionChoice::new(&name, &name)) + .collect::>(), + ); + let selected = options.iter().find(|c| c.id == s.argument).cloned(); + editor = editor.push(self.shortcut_argument_menu(options, selected)); + } + Argument::Command => { + let mut names = vec![ + "terminal".to_owned(), + "screenshot".into(), + "screenshot-full".into(), + ]; + if let Some(commands) = + self.draft.item("commands").and_then(|v| v.as_object()) + { + names.extend(commands.keys().cloned()); + } + names.sort(); + names.dedup(); + let mut options = names + .into_iter() + .map(|n| OptionChoice::new(&n, &n)) + .collect::>(); + options.push(OptionChoice::new("$custom", "Custom program")); + let selected = options.iter().find(|c| c.id == s.argument).cloned(); + editor = editor.push(self.shortcut_argument_menu(options, selected)); + if s.argument == "$custom" { + editor = editor.push( + text_input( + ferese_config::tr!("Program and arguments"), + s.program.clone(), + ) + .font(self.font) + .size(12) + .style(visuals::input_style(p)) + .on_input(|v| Message::Shortcut(Input::Program(v))), + ); + } + } + } + } + if let Some(error) = &s.error { + editor = editor.push(self.note(error)); + } + let replacing = s.index.is_none() + && !s.keys.is_empty() + && (0..self.draft.records("bindings")).any(|i| { + normalized(&self.draft.string(&format!("bindings.{i}.keys"), "")) + == normalized(&s.keys) + }); + editor=editor.push(self.note(if replacing{"This shortcut will replace the existing binding."}else{"Shortcuts apply throughout the Ferese session. Matching keys override built-in shortcuts."})); + editor = editor.push( + row([]) + .spacing(8) + .push(self.settings_button( + if replacing { + "Replace binding" + } else { + "Save shortcut" + }, + "M5 12l4 4L19 6", + (!self.saving && !s.recording).then_some(Message::Shortcut(Input::Save)), + true, + )) + .push(self.settings_button( + "Cancel", + "M6 6l12 12 M18 6L6 18", + Some(Message::Shortcut(Input::Cancel)), + false, + )), + ); + content = content.push(container(editor).padding(12).class(visuals::section(p))); + } + let query = s.filter.to_lowercase(); + let action_names: std::collections::HashMap<_, _> = actions() + .into_iter() + .map(|a| (a.id.clone(), a.to_string())) + .collect(); + for index in 0..self.draft.records("bindings") { + let prefix = format!("bindings.{index}"); + let keys = self.draft.string(&format!("{prefix}.keys"), ""); + let id = self.draft.string(&format!("{prefix}.action"), "none"); + let argument = self.draft.string(&format!("{prefix}.argument"), ""); + let action = action_names.get(&id).cloned().unwrap_or(id); + let summary = if argument.is_empty() { + action + } else { + format!("{action} · {argument}") + }; + if !query.is_empty() && !format!("{keys} {summary}").to_lowercase().contains(&query) { + continue; + } + let card: Element<'_, Message> = if self.visible_rows.contains(&("bindings", index)) { + container( + row([]) + .spacing(10) + .align_y(cosmic::iced::Alignment::Center) + .push( + column([]) + .spacing(3) + .push(self.label(keys, 13.)) + .push(self.note(&summary)) + .width(Length::Fill), + ) + .push(self.settings_icon_button( + "Edit shortcut", + "M4 20l4-1L20 7l-3-3L5 16z", + Some(Message::Shortcut(Input::Open(Some(index)))), + )) + .push(self.settings_icon_button( + "Remove shortcut", + "M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13", + (!self.saving).then_some(Message::Remove("bindings".into(), index)), + )), + ) + .id(cosmic::widget::Id::new(format!("binding-card-{index}"))) + .padding([8, 10]) + .height(64) + .class(visuals::section(p)) + .into() + } else { + cosmic::widget::Space::new() + .height(64) + .width(Length::Fill) + .into() + }; + content = content.push( + cosmic::iced::widget::sensor(card) + .key(("bindings", index)) + .anticipate(180) + .on_show(move |_| Message::RowVisibility("bindings", index, true)) + .on_hide(Message::RowVisibility("bindings", index, false)), + ); + } + if self.draft.records("bindings") == 0 { + content=content.push(self.note("Built-in shortcuts remain active. Add a shortcut here to customize or override one.")); + } + content.into() + } + fn shortcut_argument_menu( + &self, + options: Vec, + selected: Option, + ) -> Element<'static, Message> { + let p = visuals::Palette::from_resolved(&self.resolved.presented); + ferese_theme::controls::select( + cosmic::iced::widget::pick_list(options, selected, |v| { + Message::Shortcut(Input::Argument(v.id)) + }) + .font(self.font) + .text_size(12) + .padding([7, 10]) + .width(Length::Fill), + p, + ) + .width(Length::Fill) + .into() + } +} +#[derive(Clone, Debug, PartialEq, Eq)] +struct OptionChoice { + id: String, + label: String, +} +impl OptionChoice { + fn new(id: &str, label: &str) -> Self { + Self { + id: id.into(), + label: label.into(), + } + } +} +impl std::fmt::Display for OptionChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&ferese_config::locale::translate(&self.label)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> App { + let mut app = App::init( + cosmic::app::Core::default(), + ( + "/unused/shortcuts-test.kdl".into(), + Snapshot::parse(String::new()), + None, + None, + ), + ) + .0; + app.shortcuts.supported = Some(true); + app + } + #[test] + fn unavailable_recording_uses_the_picker_without_inhibiting_or_accepting_keys() { + let mut app = fixture(); + drop(app.shortcut_input(Input::Supported(false))); + assert_eq!(app.shortcut_input(Input::Record).units(), 0); + assert!(!app.shortcuts.recording); + assert!(app.shortcuts.show_picker); + } + #[test] + fn capture_waits_for_inhibition_and_releases_on_modifiers_cancel_and_navigation() { + use cosmic::iced::keyboard::{ + Location, + key::{Code, Physical}, + }; + let mut app = fixture(); + let source = app.draft.source.clone(); + drop(app.shortcut_input(Input::Open(None))); + assert!(app.shortcut_input(Input::Record).units() >= 2); + let key = || keyboard::Event::KeyPressed { + key: Key::Character("q".into()), + modified_key: Key::Character("q".into()), + physical_key: Physical::Code(Code::KeyQ), + location: Location::Standard, + modifiers: Modifiers::LOGO, + text: None, + repeat: false, + }; + drop(app.shortcut_input(Input::Key(key()))); + assert!(app.shortcuts.keys.is_empty()); + drop(app.shortcut_input(Input::Inhibited(true))); + drop(app.shortcut_input(Input::Key(key()))); + assert_eq!(app.shortcuts.keys, "Super+Q"); + assert!(app.shortcuts.recording); + assert_eq!( + app.shortcut_input(Input::Key(keyboard::Event::ModifiersChanged( + Modifiers::empty() + ))) + .units(), + 1 + ); + assert!(!app.shortcuts.recording); + assert_eq!(app.draft.source, source); + drop(app.shortcut_input(Input::Record)); + drop(app.update(Message::Page(crate::schema::Page::General))); + assert!(!app.shortcuts.recording); + drop(app.shortcut_input(Input::Record)); + drop(app.shortcut_input(Input::Stop)); + assert!(!app.shortcuts.recording); + } + #[test] + fn physical_and_gesture_overrides_keep_their_identity_and_unrelated_comments() { + let mut snapshot=Snapshot::parse("// preserve\nbinding \"Super+AD01\" match=\"physical\" action=\"close\"\nbinding \"Swipe3Up\" action=\"workspace-next\"\n".into()).unwrap(); + let mut state = State::default(); + state.index = Some(0); + state.keys = "Super+AD01".into(); + state.match_mode = "physical".into(); + state.action = actions().into_iter().find(|a| a.id == "toggle-maximized"); + for e in save_plan(&snapshot, &state).unwrap() { + snapshot.edit(&e).unwrap(); + } + assert_eq!(snapshot.string("bindings.0.match", ""), "physical"); + assert!(snapshot.source.contains("// preserve")); + state.index = None; + state.keys = "Swipe3Up".into(); + state.match_mode = "swipe".into(); + state.action = actions().into_iter().find(|a| a.id == "workspace-previous"); + for e in save_plan(&snapshot, &state).unwrap() { + snapshot.edit(&e).unwrap(); + } + assert_eq!(snapshot.records("bindings"), 2); + assert_eq!( + snapshot.string("bindings.1.action", ""), + "workspace-previous" + ); + } + #[test] + #[ignore = "requires the matching compositor binary; never executes a bound action"] + fn every_graphical_action_is_accepted_by_the_compositor() { + let backend = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/debug/ferese"); + let directory = tempfile::tempdir().unwrap(); + for action in actions() { + let mut s = State::default(); + s.keys = if action.id.starts_with("pointer-") { + "Super+MouseLeft" + } else { + "Ctrl+Alt+F12" + } + .into(); + s.argument = match action.argument { + Argument::Direction => "left", + Argument::Workspace => "1", + Argument::Command => "terminal", + Argument::Mode => "default", + Argument::None => "", + } + .into(); + s.action = Some(action.clone()); + let mut snapshot = Snapshot::parse(String::new()).unwrap(); + for e in save_plan(&snapshot, &s).unwrap() { + snapshot.edit(&e).unwrap(); + } + let path = directory.path().join("config.kdl"); + std::fs::write(&path, &snapshot.source).unwrap(); + let output = std::process::Command::new(&backend) + .arg("--check-config") + .arg(&path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}: {}", + action.id, + String::from_utf8_lossy(&output.stderr) + ); + } + } + #[test] + fn graphical_binding_plan_preserves_other_bindings_and_creates_argv_without_a_shell() { + let mut snapshot = + Snapshot::parse("binding \"Super+Q\" action=\"close\"\n".into()).unwrap(); + let mut state = State::default(); + state.keys = "Ctrl+Alt+T".into(); + state.action = actions().into_iter().find(|a| a.id == "spawn"); + state.argument = "$custom".into(); + state.program = "foot --title \"My terminal\"".into(); + for edit in save_plan(&snapshot, &state).unwrap() { + snapshot.edit(&edit).unwrap(); + } + assert_eq!(snapshot.records("bindings"), 2); + assert_eq!(snapshot.string("bindings.0.action", ""), "close"); + assert_eq!( + snapshot.item("commands.shortcut_1").unwrap(), + &serde_json::json!(["foot", "--title", "My terminal"]) + ); + state.keys = "Control+Alt+T".into(); + state.action = actions().into_iter().find(|a| a.id == "toggle-overview"); + for edit in save_plan(&snapshot, &state).unwrap() { + snapshot.edit(&edit).unwrap(); + } + assert_eq!(snapshot.records("bindings"), 2); + assert_eq!(snapshot.string("bindings.1.action", ""), "toggle-overview"); + } + #[test] + fn recording_normalizes_named_keys_without_persisting_modifier_only_chords() { + assert!(key_name(&Key::Named(Named::Super)).is_none()); + assert_eq!( + key_name(&Key::Named(Named::Enter)).as_deref(), + Some("Return") + ); + assert_eq!( + chord("Q", Modifiers::LOGO | Modifiers::SHIFT), + "Super+Shift+Q" + ); + assert_eq!(normalized(" Control + enter "), normalized("Ctrl+Return")); + } +}