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