feat(settings): add grouped configuration hub, languages and tray controls

This commit is contained in:
aria committed 2026-10-28 03:37:22 +09:00
1 parent 6f34fd080f
commit cc3b6f903f
90 files changed
+3625 -408

No files matched your search

+1
View File
@@ -9,4 +9,5 @@ rust-version.workspace = true
workspace = true
[dependencies]
schemars.workspace = true
serde.workspace = true
+4 -4
View File
@@ -3,7 +3,7 @@
use crate::Time;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub work_minutes: u16,
@@ -46,7 +46,7 @@ impl Settings {
}) * 60_000
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Phase {
Work,
@@ -63,7 +63,7 @@ impl Phase {
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Status {
Running,
@@ -71,7 +71,7 @@ pub enum Status {
Ready,
Finished,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
pub struct Session {
pub settings: Settings,
pub phase: Phase,
+13 -9
View File
@@ -7,14 +7,18 @@ const BOOST_MS: u64 = 5_000;
pub const MAX_TEXT: usize = 256;
pub const MAX_LEASE_MS: u64 = 300_000;
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
#[derive(
Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize, schemars::JsonSchema,
)]
#[serde(deny_unknown_fields)]
pub struct Owner {
pub pid: u32,
pub birth: u64,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
#[derive(
Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize, schemars::JsonSchema,
)]
#[serde(deny_unknown_fields)]
pub struct Id {
pub owner: Owner,
@@ -23,7 +27,7 @@ pub struct Id {
pub session: u64,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(
tag = "kind",
content = "value",
@@ -52,7 +56,7 @@ impl Value {
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(
tag = "kind",
content = "data",
@@ -66,7 +70,7 @@ pub enum Template {
List { rows: Vec<String> },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Source {
Publisher,
@@ -79,7 +83,7 @@ pub enum Source {
Process,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum State {
Running,
@@ -88,7 +92,7 @@ pub enum State {
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Data {
pub source: Source,
@@ -204,7 +208,7 @@ fn text(value: &str) -> Result<(), &'static str> {
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Limits {
pub max_visible: usize,
@@ -228,7 +232,7 @@ impl Default for Limits {
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
pub struct Activity {
pub id: Id,
pub data: Data,
+1
View File
@@ -9,5 +9,6 @@ rust-version.workspace = true
workspace = true
[dependencies]
schemars.workspace = true
ferese-layout = { path = "../ferese-layout" }
serde.workspace = true
+13 -2
View File
@@ -1,7 +1,9 @@
use crate::{CrossingPolicy, SpringConfig};
/// One configuration contract for compositor and out-of-process shell motion.
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
#[derive(
Clone, Copy, Debug, PartialEq, serde::Deserialize, schemars::JsonSchema, serde::Serialize,
)]
#[serde(default, deny_unknown_fields)]
pub struct MotionSettings {
pub enabled: bool,
@@ -63,7 +65,16 @@ impl MotionSettings {
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Deserialize)]
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
serde::Deserialize,
schemars::JsonSchema,
serde::Serialize,
)]
#[serde(default, deny_unknown_fields)]
pub struct SpringSettings {
pub duration_ms: Option<f64>,
+1
View File
@@ -9,6 +9,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
schemars.workspace = true
ferese-activities = { path = "../ferese-activities" }
regex = "1.12"
ferese-panel = { path = "../ferese-panel" }
+571
View File
@@ -0,0 +1,571 @@
{
"Appearance": "外观与设置",
"Theme": "主题",
"General": "常规",
"Language": "界面语言",
"Follow system": "跟随系统",
"English": "English",
"Desktop": "桌面",
"Interaction": "交互",
"System": "系统",
"Advanced": "高级",
"All options": "全部配置",
"Tray icons": "托盘图标",
"Application tray": "应用托盘",
"Show tray icons": "显示托盘图标",
"Save": "保存",
"Cancel": "取消",
"Apply": "应用",
"Reload": "重新加载",
"Undo": "撤销",
"Search settings": "搜索设置",
"Input": "输入",
"Show passive icons": "显示被动图标",
"Tray icon size": "托盘图标大小",
"Hidden applications": "隐藏的应用",
"Application IDs to hide, separated by spaces.": "要隐藏的应用 ID,以空格分隔。",
"Include applications without active status.": "同时显示没有活动状态的应用。",
"Show application icons in panel tray items.": "在面板的应用托盘组件中显示图标。",
"Size of application tray icons.": "应用托盘图标的大小。",
"Status icons can also be hidden individually in Panel & Shell.": "也可以在“面板与 Shell”中单独隐藏状态图标。",
"Manage tray icons": "管理托盘图标",
"All Ferese settings, grouped by function.": "按功能分组管理 Ferese 的全部配置。",
"Language and desktop preferences.": "界面语言与桌面偏好设置。",
"Changes the Ferese interface language. Applies immediately.": "切换 Ferese 界面语言,立即生效。",
"Choose which status and application icons appear in the panel.": "选择面板中显示的状态图标和应用图标。",
"Every supported configuration option, including advanced settings.": "管理全部受支持的配置选项,包括高级设置。",
"Advanced changes are saved together. Press Enter to confirm text fields, then Apply.": "高级配置统一保存。文本输入后按回车确认,再点击“应用”。",
"Config changed externally. Reload before applying this draft.": "配置已被其他程序修改。请重新加载后再应用此草稿。",
"Confirm text fields with Enter before applying.": "请先按回车确认文本字段,再应用配置。",
"Cannot load configuration schema": "无法加载配置结构",
"Maximum nesting depth reached": "已达到最大嵌套层数",
"Discard draft": "放弃草稿",
"New key": "新建键",
"Text": "文本",
"Value": "数值",
"Enable": "启用",
"Disable": "停用",
"Remove": "移除",
"Add": "添加",
"Search": "搜索",
"Ferese Settings": "Ferese 设置",
"Close navigation": "关闭导航",
"Show settings navigation": "显示设置导航",
"Reload configuration": "重新加载配置",
"Undo last change": "撤销上次修改",
"A desktop that feels like yours.": "打造适合你的桌面。",
"A place for every screen.": "为每块屏幕安排位置。",
"A sample of active, occupied and empty workspaces.": "预览活动、已占用和空闲工作区。",
"A subtle outline around the active window.": "为活动窗口添加细边框。",
"Accent color": "强调色",
"Accent style": "强调样式",
"Accessibility": "辅助功能",
"Active window opacity": "活动窗口不透明度",
"Add group": "添加分组",
"Add login item": "添加登录启动项",
"Adjust pointer acceleration for connected mice.": "调整已连接鼠标的指针加速度。",
"Adjust pointer acceleration for the touchpad.": "调整触控板的指针加速度。",
"Advanced customization": "高级自定义",
"Animate window changes and shell transitions.": "为窗口变化和 Shell 过渡启用动画。",
"Animation speed": "动画速度",
"Animations and transition speed.": "动画与过渡速度。",
"Apply to": "应用到",
"Apply window opacity and inactive dimming. Turning this off keeps your settings.": "应用窗口透明度与非活动窗口变暗效果,关闭时保留设置。",
"Arrange your panel and change the shell's appearance.": "排列面板并调整 Shell 外观。",
"Arrangement": "排列",
"Auto": "自动",
"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.": "自动模式在电量低于 30% 时使用 60 Hz,连接电源或电量达到 35% 时恢复。应用显示模式时屏幕可能短暂变黑。",
"Automatic appearance": "自动外观",
"Avoid accidental pointer movement.": "避免误触导致指针移动。",
"Back to Panels": "返回面板",
"Background blur": "背景模糊",
"Background opacity for the bar, popovers, menus, notifications and sharing dialog. Text and icons stay opaque.": "面板、弹出层、菜单、通知和共享对话框的背景不透明度;文字和图标保持不透明。",
"Between windows": "窗口间距",
"Blur the lock screen wallpaper. Zero keeps the original image sharp.": "模糊锁屏壁纸。设为零保留原图清晰度。",
"Brightness": "亮度",
"Characters repeated each second while a key is held.": "按住按键时每秒重复的字符数。",
"Choose a fixed appearance or let Auto follow a source.": "使用固定外观,或让自动模式跟随指定来源。",
"Choose an image above, or enter its full path.": "在上方选择图片,或输入完整路径。",
"Choose image…": "选择图片…",
"Choose the finish for the panel, launcher, menus and notifications.": "选择面板、启动器、菜单和通知的材质样式。",
"Clock format": "时钟格式",
"Composition preview": "布局预览",
"Connections are managed by the system. Passwords are not saved in your Ferese config.": "连接由系统管理,密码不会保存在 Ferese 配置中。",
"Corner radius": "圆角半径",
"Current wallpaper": "当前壁纸",
"Custom themes": "自定义主题",
"Dark at": "深色开始时间",
"Dark theme file": "深色主题文件",
"Darken inactive windows. Zero disables dimming.": "降低非活动窗口亮度。设为零关闭变暗效果。",
"Darken the wallpaper behind the clock and sign-in card.": "降低时钟和登录卡片背后的壁纸亮度。",
"Default duration; apps can request another timeout. Pauses while hovered.": "默认显示时长;应用可指定其他时长,悬停时暂停计时。",
"Default wallpapers": "默认壁纸",
"Delay before repeat": "重复延迟",
"Deselect": "取消选择",
"Dim after": "变暗等待时间",
"Dim the locked display after inactivity. Zero disables dimming.": "锁屏闲置一段时间后降低显示亮度,设为零关闭。",
"Disable touchpad with a mouse": "连接鼠标时停用触控板",
"Display mode": "显示模式",
"Display sleep after": "显示器休眠等待时间",
"Display the time above the sign-in card.": "在登录卡片上方显示时间。",
"Display the weekday and date.": "显示星期和日期。",
"Distance needed to navigate. Larger values reduce accidental swipes.": "触发导航所需的滑动距离;较大值可减少误触。",
"Do Not Disturb": "勿扰模式",
"Down": "下移",
"Drag items to reorder. Select an item or group to edit.": "拖动项目排序,选择项目或分组进行编辑。",
"Drop an item here, or add a group.": "将项目拖到这里,或添加分组。",
"Drop items here": "将项目拖到这里",
"Duration of opacity and dimming changes when the active window changes.": "活动窗口改变时,不透明度和亮度变化的过渡时长。",
"Edge margin": "边缘距离",
"Edit panel": "编辑面板",
"Emulate middle click": "模拟中键点击",
"Existing workspaces keep their current layout.": "现有工作区保留当前布局。",
"Fill crops to the screen. Fit keeps the whole image.": "填充会裁剪图片以铺满屏幕;适应会保留完整图片。",
"Focus border": "焦点边框",
"Focus follows pointer": "焦点跟随指针",
"Focus rings and selected controls. Use a hex color.": "焦点边框和选中控件的颜色,使用十六进制色值。",
"Focus transition": "焦点过渡",
"Focus visible windows without raising or scrolling. Click to bring them into view.": "聚焦可见窗口时不置顶或滚动;点击可将窗口移入视野。",
"Fraction of the available workspace.": "占可用工作区的比例。",
"Gradients retain the border color's transparency.": "渐变保留边框颜色的透明度。",
"How long to hold a key before it starts repeating.": "按住按键后开始重复前的等待时间。",
"How the viewport follows the focused column.": "视口跟随焦点列的方式。",
"Ignore touchpad while typing": "打字时忽略触控板",
"Image location": "图片位置",
"Image placement": "图片缩放方式",
"Import…": "导入…",
"Inactive border style": "非活动边框样式",
"Inactive window dimming": "非活动窗口变暗",
"Inactive window opacity": "非活动窗口不透明度",
"Increase contrast": "增强对比度",
"Inset from both edges; stops before extra controls overflow.": "从两侧向内缩进,并避免额外控件溢出。",
"Interface font": "界面字体",
"Keep notifications in history when popups are hidden.": "隐藏弹出通知时仍在历史记录中保留通知。",
"Keep the shortcut guide until you are comfortable with Ferese.": "在熟悉 Ferese 前保留快捷键指南。",
"Keeps icon and text sizes unchanged.": "保持图标和文字大小不变。",
"Key repeat": "按键重复",
"Keyboard & mouse": "键盘与鼠标",
"Keyboard layouts": "键盘布局",
"Keyboard layouts, pointer behavior and touchpad gestures.": "键盘布局、指针行为与触控板手势。",
"Keyboard shortcuts and swipe actions.": "键盘快捷键与滑动操作。",
"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.": "键盘、鼠标和滑动手势共用操作定义。鼠标可使用 MouseLeft、MouseRight、MouseMiddle、MouseBack、MouseForward 或 WheelUp/Down/Left/Right,并组合修饰键。拖动使用 pointer-move 或 pointer-resize。滑动使用 Swipe3Up、Swipe3Down、Swipe3Left 或 Swipe3Right,也支持 4 指和 5 指。toggle-overview 和 none 等操作无需参数。",
"Layout codes, separated by commas. For example: us,ru.": "布局代码,以逗号分隔,例如 us,ru。",
"Layout for new workspaces": "新工作区布局",
"Layout variant": "布局变体",
"Leave empty to use the standard variant.": "留空使用标准变体。",
"Left-handed mouse": "左手鼠标",
"Light at": "浅色开始时间",
"Light theme file": "浅色主题文件",
"Live display information unavailable": "无法获取实时显示信息",
"Local time in HH:MM format.": "本地时间,格式为 HH:MM。",
"Lock screen": "锁屏",
"Login items": "登录启动项",
"Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it.": "登录启动项实时更新。停用或移除会停止当前会话管理的进程,启用会启动进程。",
"Lower temperatures are warmer": "色温越低,颜色越暖",
"Lower values give transitions more time.": "数值越小,过渡时间越长。",
"Make it your own": "个性化设置",
"Managed automatically · no saved display profile": "自动管理 · 未保存显示配置",
"Match appearance": "跟随外观",
"Move or remove this group’s items before removing the group.": "请先移动或移除此分组中的项目,再移除分组。",
"Natural scrolling": "自然滚动",
"New window width": "新窗口宽度",
"Night light is unavailable": "夜光不可用",
"No displays or saved profiles found": "未找到显示器或已保存的配置",
"No matching settings. Try wallpaper, keyboard, or motion.": "未找到匹配的设置。可尝试搜索壁纸、键盘或动画。",
"Opacity of other application windows.": "其他应用窗口的不透明度。",
"Opacity of the active application, including fullscreen windows.": "活动应用窗口的不透明度,包括全屏窗口。",
"Open Ferese Settings": "打开 Ferese 设置",
"Optional KDL overrides. Relative paths start in your config folder.": "可选的 KDL 覆盖文件,相对路径从配置目录开始。",
"Optional overrides for Dark appearance.": "可选的深色外观覆盖文件。",
"Optional overrides for Light appearance.": "可选的浅色外观覆盖文件。",
"Panel & Shell": "面板与 Shell",
"Password": "密码",
"Pause the built-in touchpad while an external mouse is connected.": "连接外部鼠标时暂停使用内置触控板。",
"Placement": "位置",
"Popup timeout": "弹出通知时长",
"Press both mouse buttons together to middle-click.": "同时按下鼠标左右键来模拟中键点击。",
"Press the current workspace's shortcut again to return to the previous workspace on this monitor.": "再次按下当前工作区快捷键,返回本显示器上一个工作区。",
"Preview lock screen": "预览锁屏",
"Program and arguments": "程序与参数",
"Ready when you sign in.": "登录时准备就绪。",
"Reduce motion": "减少动画",
"Reduce transparency": "减少透明效果",
"Refresh displays": "刷新显示器",
"Remove custom panel": "移除自定义面板",
"Remove login item": "移除登录启动项",
"Remove override": "移除覆盖值",
"Reset to style": "恢复样式默认值",
"Reverse the direction of the mouse wheel.": "反转鼠标滚轮方向。",
"Rounded corners for application windows.": "应用窗口的圆角。",
"Rounds the bar, menus, notifications and hover backgrounds. Window corners are separate.": "调整面板、菜单、通知和悬停背景的圆角,窗口圆角单独设置。",
"Sample content": "示例内容",
"Scale wheel scrolling in applications.": "缩放应用中的滚轮滚动量。",
"Scroll behavior": "滚动行为",
"Select an item to adjust how it appears, or a group to edit its placement and spacing.": "选择项目调整外观,选择分组调整位置和间距。",
"Set the scene for your workspace.": "为工作区设置背景。",
"Shared theme file": "共享主题文件",
"Shell appearance": "Shell 外观",
"Shell corner radius": "Shell 圆角半径",
"Shell opacity": "Shell 不透明度",
"Show clock": "显示时钟",
"Show date": "显示日期",
"Show keybinding guide at login": "登录时显示快捷键指南",
"Show popup notifications": "显示弹出通知",
"Shown on all displays": "显示在所有屏幕上",
"Side margins": "两侧边距",
"Silence normal popups; critical notifications can still appear.": "隐藏普通弹出通知,紧急通知仍可显示。",
"Size & spacing": "大小与间距",
"Soften the background behind translucent windows. Adjust the amount in Appearance.": "模糊半透明窗口后的背景,可在外观中调整强度。",
"Softness behind translucent shell surfaces and windows. Zero disables blur.": "半透明 Shell 和窗口背景的模糊强度,设为零关闭模糊。",
"Solid surfaces; background blur is disabled.": "使用不透明表面,并关闭背景模糊。",
"Space around the outside of the layout.": "布局外围的间距。",
"Space between neighboring windows.": "相邻窗口之间的距离。",
"Space between the panel and application windows.": "面板与应用窗口之间的距离。",
"Space from the selected screen edge. Set to zero for a flush panel.": "面板与所选屏幕边缘的距离,设为零贴紧边缘。",
"Stay informed on your terms.": "按你的偏好接收通知。",
"Stronger text and borders.": "增强文字和边框的对比度。",
"Surface style": "表面样式",
"Swap the primary and secondary mouse buttons.": "交换鼠标主键和次键。",
"Swipe distance": "滑动距离",
"Tap the touchpad to click. Updates connected devices immediately.": "轻触触控板实现点击,立即应用到已连接设备。",
"Tap to click": "轻触点击",
"The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items.": "预览是普通窗口,不会锁定会话。自动锁定可在登录启动项中单独配置。",
"Theme overrides": "主题覆盖",
"Toggle back with the same workspace shortcut": "再次按工作区快捷键返回",
"Touchpad speed": "触控板速度",
"Turn off locked displays after inactivity. Input wakes them; zero disables display sleep.": "锁屏闲置后关闭显示器,输入可唤醒,设为零关闭休眠。",
"Up": "上移",
"Use a 12-hour or 24-hour clock.": "使用 12 小时制或 24 小时制。",
"Use immediate changes in place of animations.": "立即切换状态,省略动画。",
"Use shell opacity": "使用 Shell 不透明度",
"Use the name of an installed font family.": "使用已安装的字体族名称。",
"Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens.": "使用外观中的壁纸、Shell 颜色、字体和圆角。下次打开锁屏或预览时生效。",
"Volume": "音量",
"Wallpaper dimming": "壁纸变暗",
"Wallpaper softness": "壁纸模糊",
"Welcome to Ferese": "欢迎使用 Ferese",
"Wi-Fi networks and Bluetooth devices.": "Wi-Fi 网络与蓝牙设备。",
"Window blur": "窗口模糊",
"Window clearance": "窗口预留空间",
"Window focus borders and selected controls. Gradients use the theme colors unless overridden.": "窗口焦点边框与选中控件。渐变默认使用主题颜色。",
"Window focus effects": "窗口焦点效果",
"Window layouts, spacing and focus effects.": "窗口布局、间距与焦点效果。",
"Workspace edges": "工作区边距",
"Workspace style": "工作区样式",
"You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.": "当前使用内置快捷键。可在“全部配置”中添加自定义绑定,重新加载后在此显示。",
"Your desktop, safely put away.": "安全锁定你的桌面。",
"system follows your desktop timezone; or enter an IANA timezone.": "system 跟随桌面时区,也可以输入 IANA 时区名称。",
"Windows": "窗口",
"Motion": "动画",
"Wallpaper": "壁纸",
"Notifications": "通知",
"Displays": "显示器",
"Connections": "连接",
"Shortcuts": "快捷键",
"Make the desktop easier to see and use.": "让桌面更易于看清和操作。",
"Find a setting for your desktop.": "查找桌面设置。",
"Light": "浅色",
"Dark": "深色",
"Schedule": "定时",
"Manual": "手动",
"Default": "默认",
"Custom": "自定义",
"Solid": "纯色",
"Gradient": "渐变",
"None": "无",
"On": "开启",
"Off": "关闭",
"on": "开启",
"off": "关闭",
"Top": "顶部",
"Bottom": "底部",
"Left": "左侧",
"Right": "右侧",
"Center": "居中",
"Start": "起始",
"End": "末尾",
"Fill": "填充",
"Fit": "适应",
"Stretch": "拉伸",
"Tile": "平铺",
"Static": "静态",
"Scrolling": "滚动",
"Tiling": "平铺",
"Floating": "浮动",
"Always": "始终",
"Never": "从不",
"Automatic": "自动",
"Compact": "紧凑",
"Icon": "图标",
"Wide": "宽",
"Dots": "圆点",
"Numbers": "数字",
"Pills": "胶囊",
"Overview": "概览",
"Workspaces": "工作区",
"Focused window": "焦点窗口",
"Media": "媒体",
"Activities": "活动",
"Quick Settings": "快捷设置",
"Night light": "夜光",
"Network": "网络",
"Audio": "音频",
"Recording": "录制",
"Capture status": "捕获状态",
"Battery": "电池",
"Clock": "时钟",
"Overflow": "溢出项",
"Control Center": "控制中心",
"Sound": "声音",
"Calendar": "日历",
"Panel items": "面板项目",
"Screen recording": "屏幕录制",
"Settings": "设置",
"Power": "电源",
"Lock": "锁定",
"Lock session": "锁定会话",
"Log out": "注销",
"Restart": "重启",
"Shut down": "关机",
"Suspend": "挂起",
"Hibernate": "休眠",
"Cancel recording": "取消录制",
"Stop recording": "停止录制",
"Start recording": "开始录制",
"Open calendar": "打开日历",
"Authentication required": "需要身份验证",
"Unlock": "解锁",
"Unlocking…": "正在解锁…",
"Try again": "重试",
"Sign in": "登录",
"Dismiss": "关闭",
"Clear all": "全部清除",
"Connect": "连接",
"Disconnect": "断开连接",
"Connected": "已连接",
"Disconnected": "未连接",
"Connecting…": "正在连接…",
"Forget": "忘记",
"Pair": "配对",
"Remove device": "移除设备",
"Available networks": "可用网络",
"Saved networks": "已保存网络",
"Bluetooth devices": "蓝牙设备",
"Refresh": "刷新",
"Details": "详情",
"Close": "关闭",
"Date & time": "日期与时间",
"Time zone": "时区",
"Mouse": "鼠标",
"Touchpad": "触控板",
"Keyboard": "键盘",
"Pointer speed": "指针速度",
"Scrolling speed": "滚动速度",
"Opacity": "不透明度",
"Blur": "模糊",
"Spacing": "间距",
"Width": "宽度",
"Height": "高度",
"Position": "位置",
"Scale": "缩放",
"Rotation": "旋转",
"Resolution": "分辨率",
"Refresh rate": "刷新率",
"Enabled": "启用",
"Disabled": "停用",
"Priority": "优先级",
"Visible": "可见",
"Remove group": "移除分组",
"Add item": "添加项目",
"Remove item": "移除项目",
"ui": "界面",
"status": "状态与托盘",
"activities": "活动",
"panels": "面板",
"notifications": "通知",
"lock_screen": "锁屏",
"night_light": "夜光",
"idle_inhibit": "阻止空闲",
"animations": "动画",
"layout": "布局",
"workspaces": "工作区",
"input": "输入设备",
"appearance": "外观效果",
"theme": "主题",
"commands": "命令",
"bindings": "快捷键绑定",
"binding_modes": "快捷键模式",
"window_rules": "窗口规则",
"scrolling": "滚动",
"output_profiles": "显示器配置",
"xwayland": "XWayland",
"autostart": "自动启动",
"language": "界面语言",
"tray": "应用托盘",
"enabled": "启用",
"show_passive": "显示被动图标",
"icon_size": "图标大小",
"hidden_items": "隐藏的应用",
"keybinding_guide": "快捷键指南",
"low_battery_threshold": "低电量阈值",
"settings_command": "设置命令",
"focus_follows_mouse": "焦点跟随鼠标",
"xkb_layout": "键盘布局",
"xkb_variant": "布局变体",
"xkb_options": "键盘选项",
"repeat_rate": "按键重复速率",
"repeat_delay_ms": "按键重复延迟(毫秒)",
"touchpad": "触控板",
"mouse": "鼠标",
"natural_scroll": "自然滚动",
"accel_speed": "加速速度",
"accel_profile": "加速模式",
"left_handed": "左手模式",
"middle_emulation": "中键模拟",
"scroll_factor": "滚动倍率",
"drag_threshold": "拖动阈值",
"scroll_cooldown_ms": "滚动冷却时间(毫秒)",
"tap": "轻触点击",
"disable_while_mouse_connected": "连接鼠标时停用",
"disable_while_typing": "打字时停用",
"swipe_threshold": "滑动阈值",
"id": "标识",
"name": "名称",
"kind": "类型",
"style": "样式",
"start": "起始",
"center": "居中",
"end": "末尾",
"groups": "分组",
"items": "项目",
"geometry": "几何",
"morph": "变形面板",
"edge": "屏幕边缘",
"border": "边框",
"background_opacity": "背景不透明度",
"corner_radius": "圆角半径",
"visible": "可见",
"priority": "优先级",
"overflow": "溢出策略",
"representation": "显示形式",
"metadata": "附加数据",
"gap_before": "前置间距",
"percentage": "显示百分比",
"keys": "按键",
"action": "操作",
"argument": "参数",
"match": "匹配条件",
"outputs": "输出",
"position": "位置",
"scale": "缩放",
"transform": "变换",
"mode": "模式",
"file": "文件",
"light": "浅色",
"dark": "深色",
"auto": "自动",
"schedule": "定时",
"material": "材质",
"typography": "排版",
"colors": "颜色",
"font_family": "字体族",
"opacity": "不透明度",
"blur_radius": "模糊半径",
"gap": "间距",
"gaps": "间距",
"width": "宽度",
"height": "高度",
"spacing": "间距",
"size": "大小",
"rules": "规则",
"progress": "进度",
"ignore_parents": "忽略父级",
"title": "标题",
"icon": "图标",
"result": "结果",
"min_duration_ms": "最短时长(毫秒)",
"flash_on_fail": "失败时闪烁",
"dedupe_by": "去重字段",
"object": "对象",
"array": "列表",
"string": "文本",
"number": "数值",
"integer": "整数",
"boolean": "开关",
"null": "未设置",
"January": "一月",
"February": "二月",
"March": "三月",
"April": "四月",
"May": "五月",
"June": "六月",
"July": "七月",
"August": "八月",
"September": "九月",
"October": "十月",
"November": "十一月",
"December": "十二月",
"Mon": "一",
"Tue": "二",
"Wed": "三",
"Thu": "四",
"Fri": "五",
"Sat": "六",
"Sun": "日",
"Filter options": "筛选配置",
"Reset draft value": "重置草稿值",
"Accessible action": "无障碍操作",
"Applications requested that this action be prevented:": "以下应用请求阻止此操作:",
"Attach to screen edge": "吸附屏幕边缘",
"Bluetooth power": "蓝牙开关",
"Clear notifications": "清除通知",
"Collapse panel hub": "收起面板中心",
"Disable this guide in Settings → Shortcuts. It appears at each login until disabled.": "可以在“设置 → 快捷键”中关闭此指南,关闭前会在每次登录时显示。",
"Edit Workspaces": "编辑工作区",
"Esc to exit": "按 Esc 退出",
"Low battery": "电量不足",
"Mute": "静音",
"New notifications will appear here.": "新通知将显示在这里。",
"Night light could not be applied to some displays": "无法在部分显示器上应用夜光",
"No active player": "没有活动播放器",
"No active screen sharing or OBS output": "没有活动的屏幕共享或 OBS 输出",
"No active tasks": "没有进行中的任务",
"No display supports night light": "没有显示器支持夜光",
"No notifications": "没有通知",
"No open windows": "没有打开的窗口",
"Open Settings": "打开设置",
"Outline": "轮廓",
"Play or pause": "播放或暂停",
"Pomodoro": "番茄钟",
"Power Profiles service unavailable": "电源配置服务不可用",
"Preview on hover": "悬停时预览",
"Recent": "最近",
"Service unavailable": "服务不可用",
"Show activity count": "显示活动数量",
"Signal strength": "信号强度",
"Today": "今天",
"You're all caught up": "已查看全部通知",
"Now playing": "正在播放",
"Authenticate": "验证身份",
"Account": "账户",
"Hide details": "隐藏详情",
"Show details": "显示详情",
"Caps Lock is on": "大写锁定已开启",
"Press Enter to unlock": "按回车解锁",
"Preview only · your session is not locked": "仅预览 · 当前会话未锁定",
"Checking password…": "正在验证密码…",
"Password not accepted. Try again.": "密码验证失败,请重试。",
"Changes save automatically": "更改会自动保存",
"Preview config · changes do not update your desktop": "预览配置 · 更改不会应用到当前桌面",
"Saved · desktop updated": "已保存 · 桌面已更新",
"Saved to preview config · desktop unchanged": "已保存到预览配置 · 当前桌面未更改",
"Saved · could not confirm desktop reload": "已保存 · 无法确认桌面是否已重新加载",
"Not saved": "未保存",
"Reloaded from your config": "已重新加载配置",
"Updated from your config": "已从配置更新",
"Config changed externally · Reload when your edits are finished": "配置已被外部修改 · 完成编辑后请重新加载",
"Application icons": "应用图标",
"Refresh applications": "刷新应用列表",
"Panel status icons": "面板状态图标",
"Install the matching Ferese compositor beside Settings.": "请将匹配版本的 Ferese 主程序与设置程序安装在同一目录。"
}
+4 -4
View File
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
use crate::Error;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub max_visible: usize,
@@ -36,14 +36,14 @@ impl Default for Settings {
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[derive(Clone, Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Sources {
pub shell_hook: bool,
pub process_watch: bool,
pub notification_bridge: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Rule {
pub name: String,
@@ -59,7 +59,7 @@ pub struct Rule {
pub flash_on_fail: bool,
pub result: ResultText,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ResultText {
pub ok: String,
+148
View File
@@ -72,6 +72,7 @@ fn field(name: &str, parent: &str) -> String {
}
match name {
"rule" if parent == "activities" => "rules".into(),
"binding" => "bindings".into(),
"binding-mode" => "binding_modes".into(),
"window-rule" => "window_rules".into(),
@@ -99,6 +100,7 @@ fn is_records(key: &str, parent: &str) -> bool {
| ("binding_modes", "bindings")
| ("start" | "center" | "end", "groups")
| ("groups", "items")
| ("activities", "rules")
)
}
@@ -112,6 +114,8 @@ fn is_array(key: &str, parent: &str) -> bool {
| "width_presets"
| "xkb_options"
| "settings_command"
| "hidden_items"
| "ignore_parents"
| "outputs"
)
}
@@ -330,6 +334,20 @@ impl Document {
Some(value)
}
/// Apply a structured editor draft as one transaction. Unchanged subtrees
/// retain their KDL nodes, formatting and comments; no partial draft is saved.
pub fn merge_value(&mut self, desired: &Value) -> Result<(), Error> {
if !desired.is_object() {
return Err(Error("Configuration must be an object".into()));
}
let mut candidate = self.clone();
reconcile(&mut candidate.doc, &[], &self.value, desired)?;
format_document(&mut candidate.doc);
candidate.refresh()?;
*self = candidate;
Ok(())
}
fn refresh(&mut self) -> Result<(), Error> {
self.value = Value::Object(object(&self.doc, "")?);
Ok(())
@@ -535,6 +553,93 @@ impl Document {
}
}
fn reconcile(
doc: &mut KdlDocument,
path: &[String],
old: &Value,
new: &Value,
) -> Result<(), Error> {
if old == new {
return Ok(());
}
if let (Some(before), Some(after)) = (old.as_object(), new.as_object()) {
for key in before.keys().filter(|key| !after.contains_key(*key)) {
let mut child = path.to_vec();
child.push(key.clone());
remove_value(doc, &child)?;
}
for (key, value) in after {
let mut child = path.to_vec();
child.push(key.clone());
reconcile(doc, &child, before.get(key).unwrap_or(&Value::Null), value)?;
}
return Ok(());
}
let refs = path.iter().map(String::as_str).collect::<Vec<_>>();
if new.is_null() {
return remove_value(doc, path);
}
let records = path.last().is_some_and(|key| {
is_records(
key,
path.get(path.len().wrapping_sub(2))
.map_or("", String::as_str),
)
});
// Activity parsing resolves built-in rules even when not authored. Rewrite
// that rule collection together so its intermediate shape is never invalid.
let activity_rules = path == ["activities", "rules"];
if records
&& !activity_rules
&& let (Some(before), Some(after)) = (old.as_array(), new.as_array())
&& before.len() == after.len()
&& before.iter().all(Value::is_object)
&& after.iter().all(Value::is_object)
{
for (index, (before, after)) in before.iter().zip(after).enumerate() {
let mut child = path.to_vec();
child.push(index.to_string());
reconcile(doc, &child, before, after)?;
}
return Ok(());
}
set_in(doc, &refs, "", new.clone())
}
fn remove_value(doc: &mut KdlDocument, path: &[String]) -> Result<(), Error> {
let refs = path.iter().map(String::as_str).collect::<Vec<_>>();
if unset_property(doc, &refs, "")? {
return Ok(());
}
let (name, parents) = refs
.split_last()
.ok_or_else(|| Error("Cannot remove the configuration root".into()))?;
let parent = parents
.iter()
.rev()
.find(|p| p.parse::<usize>().is_err())
.copied()
.unwrap_or("");
let section = section_mut(doc, parents, "")?;
let indices = section
.nodes()
.iter()
.enumerate()
.filter_map(|(index, node)| (field(node.name().value(), parent) == *name).then_some(index))
.collect::<Vec<_>>();
let mut comments = String::new();
for index in indices.into_iter().rev() {
let removed = section.nodes_mut().remove(index);
collect_comments(&removed, &mut comments);
}
if !comments.is_empty() {
let mut format = section.format().cloned().unwrap_or_default();
format.trailing.push_str(&comments);
section.set_format(format);
}
Ok(())
}
fn collect_comments(node: &KdlNode, output: &mut String) {
let mut keep = |text: &str| {
if text.contains("//") || text.contains("/*") {
@@ -917,6 +1022,7 @@ fn node_name(key: &str, parent: &str) -> String {
"panels" => "panel".into(),
"groups" => "group".into(),
"items" => "item".into(),
"rules" if parent == "activities" => "rule".into(),
_ => key.replace('_', "-"),
}
}
@@ -927,6 +1033,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
match value {
Value::Object(map) => {
let positions: &[&str] = match key {
"rules" if parent == "activities" => &["name"],
"bindings" => &["keys", "action", "argument"],
"binding_modes" => &["name"],
"output_profiles" => &["name"],
@@ -947,6 +1054,11 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
}
for (name, value) in map {
if value.is_null()
|| (name == "ignore_parents" && value.as_array().is_some_and(Vec::is_empty))
{
continue;
}
if consumed.contains(&name.as_str()) {
continue;
}
@@ -959,6 +1071,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
}
} else if is_records(key, parent)
&& map.len() <= 4
&& !(key == "rules" && parent == "activities")
&& !value.is_array()
&& !value.is_object()
{
@@ -1007,6 +1120,41 @@ pub fn from_str<T: DeserializeOwned>(source: &str) -> Result<T, Error> {
mod tests {
use super::*;
#[test]
fn structured_activity_rules_use_native_kdl_syntax() {
let mut doc = Document::parse("activities { max-visible 2; }\n").unwrap();
let mut desired = doc.value().clone();
desired["activities"]["rules"][0]["title"] = "Custom build".into();
doc.merge_value(&desired).unwrap();
let reloaded = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reloaded.value()["activities"]["rules"][0]["title"],
"Custom build"
);
assert!(doc.to_string().contains("rule"));
}
#[test]
fn structured_draft_preserves_comments_and_removes_whole_collections() {
let mut doc=Document::parse("// header\ninput { repeat-rate 25; }\n// first\nbinding \"Super+A\" \"none\"\n// second\nbinding \"Super+B\" \"none\"\n").unwrap();
let mut desired = doc.value().clone();
desired["input"]["repeat_rate"] = 30.into();
desired.as_object_mut().unwrap().remove("bindings");
doc.merge_value(&desired).unwrap();
let result = doc.to_string();
assert!(result.contains("// header"));
assert!(result.contains("// first"));
assert!(result.contains("// second"));
let reloaded = Document::parse(&result).unwrap();
assert_eq!(reloaded.get("input.repeat_rate"), Some(&30.into()));
assert!(reloaded.get("bindings").is_none());
}
#[test]
fn structured_draft_rejects_invalid_root_without_mutating_source() {
let mut doc = Document::parse("input { repeat-rate 25; }").unwrap();
let original = doc.to_string();
assert!(doc.merge_value(&serde_json::json!([])).is_err());
assert_eq!(doc.to_string(), original);
}
#[test]
fn named_binding_modes_round_trip_and_keep_nested_bindings_editable() {
let mut document = super::Document::parse(
+2
View File
@@ -3,9 +3,11 @@ pub mod activities;
mod document;
pub mod families;
pub mod file;
pub mod locale;
pub mod night_light;
pub mod notifications;
pub mod presets;
pub mod status;
pub mod theme;
pub use document::*;
+152
View File
@@ -0,0 +1,152 @@
//! Ferese interface language; independent of keyboard layout and system locale.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{
OnceLock,
atomic::{AtomicU8, Ordering},
};
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
)]
pub enum Language {
#[default]
#[serde(rename = "auto")]
Auto,
#[serde(rename = "en", alias = "en-US", alias = "en_US")]
English,
#[serde(rename = "zh-CN", alias = "zh_CN", alias = "zh-Hans", alias = "zh")]
Chinese,
}
impl Language {
#[must_use]
pub const fn key(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::English => "en",
Self::Chinese => "zh-CN",
}
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub language: Language,
}
static LANGUAGE: AtomicU8 = AtomicU8::new(0);
pub fn set(language: Language) {
LANGUAGE.store(
match language {
Language::Auto => 0,
Language::English => 1,
Language::Chinese => 2,
},
Ordering::Relaxed,
);
}
#[must_use]
pub fn configured() -> Language {
match LANGUAGE.load(Ordering::Relaxed) {
1 => Language::English,
2 => Language::Chinese,
_ => Language::Auto,
}
}
#[must_use]
pub fn language_for_locale(locale: &str) -> Language {
if locale.to_ascii_lowercase().starts_with("zh") {
Language::Chinese
} else {
Language::English
}
}
#[must_use]
pub fn resolved() -> Language {
match configured() {
Language::Auto => {
static SYSTEM: OnceLock<Language> = OnceLock::new();
*SYSTEM.get_or_init(|| {
let locale = ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"]
.into_iter()
.find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty()))
.unwrap_or_else(|| "en".into());
language_for_locale(&locale)
})
}
language => language,
}
}
pub fn from_document(document: &crate::Document) -> Language {
document
.get("ui.language")
.cloned()
.and_then(|value| serde_json::from_value(value).ok())
.unwrap_or_default()
}
pub fn load(path: Option<&std::path::Path>) {
let language = path
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|source| crate::Document::parse(&source).ok())
.map(|doc| from_document(&doc))
.unwrap_or_default();
set(language);
}
fn chinese() -> &'static BTreeMap<String, String> {
static CATALOG: OnceLock<BTreeMap<String, String>> = OnceLock::new();
CATALOG.get_or_init(|| {
serde_json::from_str(include_str!("../locales/zh-CN.json"))
.expect("valid built-in Chinese catalog")
})
}
#[must_use]
pub fn in_language<'a>(language: Language, key: &'a str) -> &'a str {
if language == Language::Chinese {
chinese().get(key).map_or(key, String::as_str)
} else {
key
}
}
#[must_use]
pub fn translate(key: &str) -> String {
in_language(resolved(), key).to_owned()
}
#[must_use]
pub fn text(key: &'static str) -> &'static str {
in_language(resolved(), key)
}
#[must_use]
pub fn format(key: &str, arguments: &[(&str, String)]) -> String {
let mut result = translate(key);
for (name, value) in arguments {
result = result.replace(&format!("{{{name}}}"), value);
}
result
}
#[macro_export]
macro_rules! tr {
($key:literal) => {
$crate::locale::text($key)
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locale_choice_does_not_rewrite_identifiers_or_unknown_values() {
assert_eq!(language_for_locale("zh_CN.UTF-8"), Language::Chinese);
assert_eq!(language_for_locale("en_US.UTF-8"), Language::English);
assert_eq!(language_for_locale("C.UTF-8"), Language::English);
assert_eq!(in_language(Language::Chinese, "Appearance"), "外观与设置");
assert_eq!(in_language(Language::English, "Appearance"), "Appearance");
assert_eq!(
in_language(Language::Chinese, "org.example.CustomApp"),
"org.example.CustomApp"
);
assert!(serde_json::from_str::<Language>("\"unsupported\"").is_err());
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct NotificationConfig {
pub show_popups: bool,
+49
View File
@@ -0,0 +1,49 @@
//! Shared shell status/tray configuration, validated before a reload is accepted.
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub keybinding_guide: bool,
pub low_battery_threshold: u8,
pub settings_command: Option<Vec<String>>,
pub tray: Tray,
}
impl Default for Settings {
fn default() -> Self {
Self {
keybinding_guide: true,
low_battery_threshold: 20,
settings_command: Some(vec!["ferese-settings".into()]),
tray: Tray::default(),
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), String> {
if self.low_battery_threshold > 100 {
return Err("low-battery-threshold must be between 0 and 100".into());
}
if !self.tray.icon_size.is_finite() || !(8.0..=48.0).contains(&self.tray.icon_size) {
return Err("tray icon-size must be between 8 and 48 pixels".into());
}
Ok(())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Tray {
pub enabled: bool,
pub show_passive: bool,
pub icon_size: f32,
pub hidden_items: Vec<String>,
}
impl Default for Tray {
fn default() -> Self {
Self {
enabled: true,
show_passive: false,
icon_size: 16.,
hidden_items: vec![],
}
}
}
+7 -5
View File
@@ -12,7 +12,7 @@ pub use ferese_theme_model::*;
pub const TRANSITION_MS: u64 = 250;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
#[serde(default)]
pub struct Selection {
pub family: Option<String>,
@@ -33,7 +33,9 @@ impl Default for Selection {
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum AutoSource {
System,
@@ -47,7 +49,7 @@ pub struct AutoContext {
pub system: Option<Appearance>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
#[serde(default)]
pub struct Schedule {
pub source: AutoSource,
@@ -155,12 +157,12 @@ impl Schedule {
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct CustomTheme {
pub file: PathBuf,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
#[serde(default)]
pub struct Policy {
pub family: Option<String>,
+1
View File
@@ -9,6 +9,7 @@ rust-version.workspace = true
workspace = true
[dependencies]
schemars.workspace = true
ferese-animation = { path = "../ferese-animation" }
ferese-layout = { path = "../ferese-layout" }
serde.workspace = true
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::motion::{Geometry, Motion};
const FADE: Duration = Duration::from_millis(120);
const CONTENT_DELAY: Duration = Duration::from_millis(130);
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Profile {
pub peek_on_hover: bool,
+35 -16
View File
@@ -20,11 +20,13 @@ fn padding() -> [u16; 2] {
[2, 3]
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(transparent)]
pub struct PanelId(pub String);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum Edge {
#[default]
@@ -42,15 +44,17 @@ impl Edge {
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(transparent)]
pub struct GroupId(pub String);
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
#[derive(
Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, schemars::JsonSchema,
)]
#[serde(transparent)]
pub struct ItemId(pub String);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Representation {
Wide,
@@ -58,7 +62,9 @@ pub enum Representation {
Icon,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum OverflowPolicy {
#[default]
@@ -67,7 +73,9 @@ pub enum OverflowPolicy {
Always,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum WorkspaceStyle {
Numbers,
@@ -110,7 +118,7 @@ impl WorkspaceStyle {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ItemKind {
Overview,
@@ -122,6 +130,7 @@ pub enum ItemKind {
Media,
Activities,
QuickSettings,
Tray,
NightLight,
Network,
Audio,
@@ -158,6 +167,7 @@ impl ItemKind {
Self::Media => "Media",
Self::Activities => "Activities",
Self::QuickSettings => "Quick Settings",
Self::Tray => "Application tray",
Self::NightLight => "Night light",
Self::Network => "Network",
Self::Audio => "Audio",
@@ -184,7 +194,7 @@ impl ItemKind {
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize, schemars::JsonSchema)]
pub struct Item {
pub id: ItemId,
#[serde(flatten)]
@@ -241,7 +251,7 @@ impl<'de> Deserialize<'de> for Item {
"percentage is only supported by Battery items",
));
}
#[derive(Deserialize)]
#[derive(Deserialize, schemars::JsonSchema, serde::Serialize)]
struct Fields {
id: ItemId,
#[serde(flatten)]
@@ -325,6 +335,7 @@ impl Item {
match self.kind {
ItemKind::Activities => availability.activities,
ItemKind::Media => availability.media,
ItemKind::Tray => availability.tray,
ItemKind::CaptureStatus => availability.capture,
ItemKind::Network => availability.network,
ItemKind::Audio => availability.audio,
@@ -338,6 +349,7 @@ impl Item {
#[derive(Clone, Copy, Debug, Default)]
pub struct Availability {
pub tray: bool,
pub capture: bool,
pub media: bool,
pub activities: bool,
@@ -348,7 +360,9 @@ pub struct Availability {
pub external_display: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum PanelSurface {
None,
@@ -356,7 +370,9 @@ pub enum PanelSurface {
Solid,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum GroupSurface {
#[default]
@@ -376,7 +392,7 @@ impl GroupSurface {
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Group {
pub id: GroupId,
@@ -402,14 +418,14 @@ impl Group {
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Zone {
pub groups: Vec<Group>,
pub spacing: f32,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Panel {
pub id: PanelId,
@@ -700,6 +716,7 @@ impl Panel {
let controls = vec![
item("media", Media),
item("quick-settings", QuickSettings),
item("tray", Tray),
item("night-light", NightLight),
item("network", Network),
item("audio", Audio),
@@ -820,6 +837,7 @@ mod tests {
"focused-window",
"media",
"quick-settings",
"tray",
"night-light",
"network",
"audio",
@@ -866,6 +884,7 @@ mod tests {
);
assert_eq!(
visible(Availability {
tray: true,
capture: true,
activities: true,
media: true,
@@ -879,7 +898,7 @@ mod tests {
.map(|item| item.id.0.as_str())
.collect::<Vec<_>>()
);
assert_eq!(items(&panel).len(), 14);
assert_eq!(items(&panel).len(), 15);
}
#[test]
+2 -2
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct GeometryIntent {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -51,7 +51,7 @@ impl GeometryIntent {
}
/// Logical dimensions owned by a panel, independent of its color theme or edge.
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct PanelGeometry {
pub height: f32,
+9
View File
@@ -4,6 +4,15 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CornerRadii(pub [f32; 4]);
impl schemars::JsonSchema for CornerRadii {
fn schema_name() -> std::borrow::Cow<'static, str> {
"CornerRadii".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({"anyOf":[{"type":"number","minimum":0},{"type":"array","minItems":4,"maxItems":4,"items":{"type":"number","minimum":0}}]})
}
}
impl CornerRadii {
#[must_use]
pub fn uniform(radius: f32) -> Self {
+3 -1
View File
@@ -2,7 +2,9 @@ use crate::{CornerRadii, Edge};
use ferese_layout::Rect;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum Style {
Floating,
+54 -7
View File
@@ -83,6 +83,22 @@ fn parse_numbers(text: &str) -> Result<Vec<u32>, Box<dyn Error>> {
delegate_noop!(State: ignore pointer_manager::ZwlrVirtualPointerManagerV1);
delegate_noop!(State: ignore virtual_pointer::ZwlrVirtualPointerV1);
fn named_keysym(name: &str) -> Result<u32, String> {
let canonical = match name {
"Ctrl" | "Control" => "Control_L",
"Shift" => "Shift_L",
"Alt" => "Alt_L",
"Super" | "Meta" => "Super_L",
_ => name,
};
let symbol = xkb::keysym_from_name(canonical, xkb::KEYSYM_NO_FLAGS).raw();
if symbol == 0 {
Err(format!("unknown test keysym {name:?}"))
} else {
Ok(symbol)
}
}
fn main() -> Result<(), Box<dyn Error>> {
if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") {
return Err(
@@ -122,16 +138,13 @@ fn main() -> Result<(), Box<dyn Error>> {
sequences.push(vec![character as u32]);
}
}
"--key" => sequences.push(vec![
xkb::keysym_from_name(&args.next().ok_or("missing key")?, xkb::KEYSYM_NO_FLAGS)
.raw(),
]),
"--key" => sequences.push(vec![named_keysym(&args.next().ok_or("missing key")?)?]),
"--chord" => sequences.push(
args.next()
.ok_or("missing chord")?
.split('+')
.map(|name| xkb::keysym_from_name(name, xkb::KEYSYM_NO_FLAGS).raw())
.collect(),
.map(named_keysym)
.collect::<Result<Vec<_>, _>>()?,
),
_ => return Err(format!("unknown test input argument {arg}").into()),
}
@@ -191,7 +204,7 @@ fn main() -> Result<(), Box<dyn Error>> {
xkb::KEYMAP_COMPILE_NO_FLAGS,
)
.ok_or("US test keymap missing")?;
let xkb = xkb::State::new(&keymap);
let mut xkb = xkb::State::new(&keymap);
let text = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1) + "\0";
let mut file = tempfile::tempfile()?;
file.write_all(text.as_bytes())?;
@@ -211,11 +224,25 @@ fn main() -> Result<(), Box<dyn Error>> {
.collect::<Result<Vec<_>, _>>()?;
for key in &codes {
keyboard.key(started.elapsed().as_millis() as u32, *key, 1);
xkb.update_key(xkb::Keycode::new(*key + 8), xkb::KeyDirection::Down);
keyboard.modifiers(
xkb.serialize_mods(xkb::STATE_MODS_DEPRESSED),
xkb.serialize_mods(xkb::STATE_MODS_LATCHED),
xkb.serialize_mods(xkb::STATE_MODS_LOCKED),
xkb.serialize_layout(xkb::STATE_LAYOUT_EFFECTIVE),
);
queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(35));
}
for key in codes.iter().rev() {
keyboard.key(started.elapsed().as_millis() as u32, *key, 0);
xkb.update_key(xkb::Keycode::new(*key + 8), xkb::KeyDirection::Up);
keyboard.modifiers(
xkb.serialize_mods(xkb::STATE_MODS_DEPRESSED),
xkb.serialize_mods(xkb::STATE_MODS_LATCHED),
xkb.serialize_mods(xkb::STATE_MODS_LOCKED),
xkb.serialize_layout(xkb::STATE_LAYOUT_EFFECTIVE),
);
queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(35));
}
@@ -302,3 +329,23 @@ fn main() -> Result<(), Box<dyn Error>> {
println!("nested test input complete");
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn modifier_aliases_are_valid_and_unknown_key_names_are_rejected() {
for (alias, name) in [
("Ctrl", "Control_L"),
("Shift", "Shift_L"),
("Alt", "Alt_L"),
("Super", "Super_L"),
] {
assert_eq!(
super::named_keysym(alias).unwrap(),
super::named_keysym(name).unwrap()
);
assert_ne!(super::named_keysym(alias).unwrap(), 0);
}
assert!(super::named_keysym("not-a-real-key").is_err());
}
}
+1
View File
@@ -9,4 +9,5 @@ rust-version.workspace = true
workspace = true
[dependencies]
schemars.workspace = true
serde.workspace = true
+2 -2
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use crate::{Appearance, Tokens};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Palette {
pub background: String,
pub bar: String,
@@ -25,7 +25,7 @@ impl From<&Tokens> for Palette {
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Family {
pub id: String,
pub name: String,
+23 -17
View File
@@ -2,7 +2,9 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum Appearance {
Light,
@@ -10,7 +12,9 @@ pub enum Appearance {
Dark,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Light,
@@ -19,14 +23,14 @@ pub enum Mode {
Auto,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema)]
#[serde(default)]
pub struct Accessibility {
pub increase_contrast: bool,
pub reduce_transparency: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Colors {
pub surface_base: String,
pub surface_raised: String,
@@ -39,7 +43,7 @@ pub struct Colors {
pub shadow: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Material {
pub style: String,
pub opacity: f64,
@@ -47,7 +51,7 @@ pub struct Material {
pub tint_strength: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Geometry {
pub border_width: f64,
pub focus_ring_width: f64,
@@ -56,50 +60,52 @@ pub struct Geometry {
pub control_gap: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Typography {
pub font_family: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Background {
pub path: Option<PathBuf>,
pub lock_path: Option<PathBuf>,
pub mode: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct BarSurface {
pub background: String,
pub text_primary: String,
pub text_muted: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Surfaces {
pub bar: BarSurface,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct SoftShadow {
pub offset_y: f64,
pub blur: f64,
pub opacity: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Shadows {
pub soft: SoftShadow,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Gradient {
pub from: String,
pub to: String,
pub angle: f64,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[derive(
Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum PaintStyle {
#[default]
@@ -113,7 +119,7 @@ impl PaintStyle {
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Paint {
#[serde(default, skip_serializing_if = "PaintStyle::is_auto")]
pub style: PaintStyle,
@@ -131,7 +137,7 @@ impl Paint {
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Tokens {
pub colors: Colors,
pub material: Material,
@@ -144,7 +150,7 @@ pub struct Tokens {
pub focus_ring: Paint,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct ResolvedTheme {
pub appearance: Appearance,
pub tokens: Tokens,
+18 -3
View File
@@ -7,6 +7,15 @@ use jiff::Zoned;
#[must_use]
pub fn format_bar_time(now: &Zoned) -> String {
if ferese_config::locale::resolved() == ferese_config::locale::Language::Chinese {
return format!(
"{}月{}日, {:02}:{:02}",
now.month(),
now.day(),
now.hour(),
now.minute()
);
}
let months = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec",
];
@@ -93,9 +102,15 @@ fn grid_with_density<'a, M: Clone + 'a>(
let muted = palette.muted.scale_alpha(opacity);
let [previous, today_button, next] = navigation;
let header = row![
crate::text(format!("{} {year}", months[(month - 1) as usize]), font)
.size(if compact { 13 } else { 15 })
.width(Length::Fill),
crate::text(
format!(
"{} {year}",
ferese_config::locale::text(months[(month - 1) as usize])
),
font
)
.size(if compact { 13 } else { 15 })
.width(Length::Fill),
previous,
today_button,
next,