Compare commits

...
10 Commits
Author SHA1 Message Date
aria 993bc0f88f Integrate native Rime and stabilize input, output, and rendering
- Add optional modifier-only Super binding whose tap is cancelled by
  other input, focus changes, grabs, locks, and session teardown.
- Ship the native ferese-rime user service, Rime defaults, installer
  inventory, package dependencies, session environment, and docs.
- Vendor Smithay 8a27023 with the SHM texture-cache format fix and add
  GPU regressions for format changes and resize snapshots.
- Release reused CRTC owners before staging output modes, then restore
  changed outputs before former owners when a transaction fails.
- Wait for resize-capture GPU completion, expand filtered surface
  damage, and preserve IME popup origin under fractional scaling.
- Mark weekends like holidays in both calendar layouts.
2026-10-30 03:13:43 +09:00
aria a2be421974 Add searchable input controls and display profile editing 2026-10-28 08:19:05 +09:00
aria 78ce1ed590 Use application artwork in the running window tray 2026-10-28 07:33:19 +09:00
aria 81b99709bb perf(settings): coalesce refreshes and cache resize previews 2026-10-28 06:56:03 +09:00
aria 6671960d5e Avoid cold theme preview rasterization and load settings schema asynchronously 2026-10-28 06:28:06 +09:00
aria 326465952a Consolidate desktop controls with editable holidays and a running-window tray 2026-10-28 06:02:46 +09:00
aria 9908dde9f8 feat(settings): refine density, group detailed options and add edge maximize gestures 2026-10-28 04:49:27 +09:00
aria d7e122711a docs: summarize Graphite visual specification in Chinese 2026-10-28 04:14:36 +09:00
aria 1e34ec47ad feat(ui): refine Graphite visuals and center navigation content 2026-10-28 04:09:10 +09:00
aria cc3b6f903f feat(settings): add grouped configuration hub, languages and tray controls 2026-10-28 03:37:22 +09:00
515 changed files with 144647 additions and 1135 deletions

No files matched your search

Generated
+204 -1
View File
@@ -114,6 +114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"const-random",
"getrandom 0.3.4", "getrandom 0.3.4",
"once_cell", "once_cell",
"version_check", "version_check",
@@ -792,6 +793,15 @@ dependencies = [
"wayland-client", "wayland-client",
] ]
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.7" version = "1.4.7"
@@ -998,6 +1008,19 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "compact_str"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"static_assertions",
"zmij",
]
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -1019,6 +1042,26 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]] [[package]]
name = "constant_time_eq" name = "constant_time_eq"
version = "0.4.2" version = "0.4.2"
@@ -1696,6 +1739,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]] [[package]]
name = "either" name = "either"
version = "1.18.0" version = "1.18.0"
@@ -1828,6 +1877,7 @@ dependencies = [
"libc", "libc",
"memmap2 0.9.11", "memmap2 0.9.11",
"notify 8.2.0", "notify 8.2.0",
"schemars",
"serde", "serde",
"serde_json", "serde_json",
"smithay", "smithay",
@@ -1842,6 +1892,7 @@ dependencies = [
name = "ferese-activities" name = "ferese-activities"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"schemars",
"serde", "serde",
] ]
@@ -1850,6 +1901,7 @@ name = "ferese-animation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ferese-layout", "ferese-layout",
"schemars",
"serde", "serde",
] ]
@@ -1863,6 +1915,8 @@ dependencies = [
"jiff", "jiff",
"kdl", "kdl",
"regex", "regex",
"rhai",
"schemars",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@@ -1917,6 +1971,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"ferese-animation", "ferese-animation",
"ferese-layout", "ferese-layout",
"schemars",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -1991,6 +2046,7 @@ dependencies = [
"shlex 1.3.0", "shlex 1.3.0",
"tempfile", "tempfile",
"tokio", "tokio",
"wayland-client",
"zbus", "zbus",
"zeroize", "zeroize",
] ]
@@ -2017,6 +2073,7 @@ dependencies = [
"fff-search", "fff-search",
"freedesktop-desktop-entry", "freedesktop-desktop-entry",
"iced_accessibility", "iced_accessibility",
"iced_winit",
"image", "image",
"jiff", "jiff",
"libc", "libc",
@@ -2026,6 +2083,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"system-tray",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
@@ -2068,6 +2126,7 @@ dependencies = [
name = "ferese-theme-model" name = "ferese-theme-model"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"schemars",
"serde", "serde",
] ]
@@ -4330,6 +4389,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
dependencies = [
"spin",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -4685,6 +4753,9 @@ name = "once_cell"
version = "1.21.4" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "once_cell_polyfill" name = "once_cell_polyfill"
@@ -5436,6 +5507,26 @@ dependencies = [
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
[[package]]
name = "ref-cast"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.13.1" version = "1.13.1"
@@ -5533,6 +5624,36 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "rhai"
version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3925b784c0bee6542637e425ed7511a63257bf11b7ecf51ffd845e2d24d9163"
dependencies = [
"ahash",
"bitflags 2.13.2",
"compact_str",
"no-std-compat",
"num-traits",
"once_cell",
"rhai_codegen",
"smallvec",
"smartstring",
"thin-vec",
"web-time",
]
[[package]]
name = "rhai_codegen"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d9661edb4a4b8145b017582d634c5eb88ff1a5284a5aa701f83a28f0657a826"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -5717,6 +5838,31 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schemars"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 3.0.6",
]
[[package]] [[package]]
name = "scoped-tls" name = "scoped-tls"
version = "1.0.1" version = "1.0.1"
@@ -5784,6 +5930,17 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.151" version = "1.0.151"
@@ -5949,10 +6106,20 @@ version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]] [[package]]
name = "smithay" name = "smithay"
version = "0.7.0" version = "0.7.0"
source = "git+https://github.com/Smithay/smithay?rev=8a270232f1bfc416c781273c98a3f52a812a0bba#8a270232f1bfc416c781273c98a3f52a812a0bba"
dependencies = [ dependencies = [
"appendlist", "appendlist",
"bitflags 2.13.2", "bitflags 2.13.2",
@@ -6112,6 +6279,12 @@ dependencies = [
"x11rb", "x11rb",
] ]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]] [[package]]
name = "spirv" name = "spirv"
version = "0.3.0+sdk-1.3.268.0" version = "0.3.0+sdk-1.3.268.0"
@@ -6251,6 +6424,21 @@ dependencies = [
"version-compare", "version-compare",
] ]
[[package]]
name = "system-tray"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab761caca0fb6cb7c57e09df241eb26ea8cdc63df88f1badca4818494f74d55f"
dependencies = [
"cfg-if",
"futures-lite",
"serde",
"thiserror 2.0.21",
"tokio",
"tracing",
"zbus",
]
[[package]] [[package]]
name = "taffy" name = "taffy"
version = "0.9.2" version = "0.9.2"
@@ -6291,6 +6479,12 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "thin-vec"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -6370,6 +6564,15 @@ dependencies = [
"time-core", "time-core",
] ]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]] [[package]]
name = "tiny-skia" name = "tiny-skia"
version = "0.11.4" version = "0.11.4"
+5
View File
@@ -21,6 +21,7 @@ members = [
"portal", "portal",
] ]
resolver = "3" resolver = "3"
exclude = ["vendor/smithay"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
@@ -42,6 +43,7 @@ undocumented_unsafe_blocks = "deny"
wildcard_imports = "deny" wildcard_imports = "deny"
[workspace.dependencies] [workspace.dependencies]
schemars = { version = "1.2.3", features = ["preserve_order"] }
ferese-shape = { git = "https://github.com/ferese-wm/ferese-shape", tag = "v0.1.0" } ferese-shape = { git = "https://github.com/ferese-wm/ferese-shape", tag = "v0.1.0" }
bitflags = "2.9.4" bitflags = "2.9.4"
calloop = { version = "0.14.5", features = ["signals"] } calloop = { version = "0.14.5", features = ["signals"] }
@@ -71,3 +73,6 @@ wayland-client = "0.31.15"
wayland-scanner = "0.31.11" wayland-scanner = "0.31.11"
wayland-server = "0.31.14" wayland-server = "0.31.14"
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] } wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
[patch."https://github.com/Smithay/smithay"]
smithay = { path = "vendor/smithay" }
+20
View File
@@ -71,6 +71,26 @@ inside your existing Wayland desktop.
## Configure ## Configure
Open **Control Center → Settings** or edit `~/.config/ferese/config.kdl`. Open **Control Center → Settings** or edit `~/.config/ferese/config.kdl`.
The [visual direction notes](docs/development/visual-direction.md) document the
Graphite dark/light family and the responsive settings layout.
This fork adds a grouped **Appearance** settings hub. **General → Language**
supports the system language, 简体中文 and English and saves the choice as
`ui { language "zh-CN"; }` (`auto` and `en` are also supported). It changes
Ferese's interface independently of keyboard layouts and input methods.
**All options** exposes the compositor's complete configuration schema, including
nested lists, maps and optional sections. Confirm text with Enter and apply the
whole draft; validation, conflict detection and backups use the normal Settings
save path. Basic settings continue to save immediately.
**Tray icons** controls panel status icons and StatusNotifierItem application
icons. Applications can be hidden individually, including after they exit. Tray
preferences live under `status { tray { enabled #true; show-passive #false;
icon-size 16; hidden-items "application-id"; }; }`. Existing custom panels can
add an **Application tray** item in **Panel & Shell**. The compositor, Shell and
Settings must be built and installed together so the schema and editor match.
The [configuration reference](https://github.com/ferese-wm/ferese/wiki/Configuration) The [configuration reference](https://github.com/ferese-wm/ferese/wiki/Configuration)
and [example config](packaging/config.kdl) cover appearance, layouts, displays, and [example config](packaging/config.kdl) cover appearance, layouts, displays,
window rules and startup apps. window rules and startup apps.
+1
View File
@@ -9,6 +9,7 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
ferese-activities = { path = "../crates/ferese-activities" } ferese-activities = { path = "../crates/ferese-activities" }
ferese-panel = { path = "../crates/ferese-panel" } ferese-panel = { path = "../crates/ferese-panel" }
ferese-shape.workspace = true ferese-shape.workspace = true
+3 -1
View File
@@ -31,7 +31,7 @@ use smithay::backend::drm::compositor::{DrmCompositor, FrameFlags, PrimaryPlaneE
use smithay::backend::drm::exporter::gbm::GbmFramebufferExporter; use smithay::backend::drm::exporter::gbm::GbmFramebufferExporter;
use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, NodeType}; use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, NodeType};
use smithay::backend::egl::EGLDisplay; use smithay::backend::egl::EGLDisplay;
use smithay::backend::input::InputEvent; use smithay::backend::input::{Device as _, InputEvent};
use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface}; use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface};
use smithay::backend::renderer::element::{RenderElement, UnderlyingStorage}; use smithay::backend::renderer::element::{RenderElement, UnderlyingStorage};
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture}; use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
@@ -425,6 +425,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
} }
InputEvent::DeviceRemoved { device } => { InputEvent::DeviceRemoved { device } => {
state.super_tap.device_removed(&device.id());
state.cancel_window_interaction(); state.cancel_window_interaction();
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend backend
@@ -439,6 +440,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
.handle() .handle()
.insert_source(notifier, move |event, _, state| match event { .insert_source(notifier, move |event, _, state| match event {
SessionEvent::PauseSession => { SessionEvent::PauseSession => {
state.super_tap.reset();
state.cancel_window_interaction(); state.cancel_window_interaction();
state.set_binding_mode("default"); state.set_binding_mode("default");
let handle = state.loop_handle.clone(); let handle = state.loop_handle.clone();
+14 -3
View File
@@ -450,8 +450,8 @@ impl crate::output_transaction::HardwareTransaction for DeviceTransaction<'_> {
} }
} }
/// All allocations/tests precede commits. Logical publication waits for the /// A complete device test precedes application. Release former connector owners
/// final usable inventory; devices are independently applied and restored. /// before per-surface tests; publish only after the final inventory is usable.
pub(super) fn apply_device( pub(super) fn apply_device(
state: &mut Ferese, state: &mut Ferese,
node: DrmNode, node: DrmNode,
@@ -483,6 +483,17 @@ pub(super) fn apply_device(
.filter(|crtc| !selections.contains_key(crtc)) .filter(|crtc| !selections.contains_key(crtc))
.copied() .copied()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// A connector replacement can reuse the same CRTC, so it is not a removal
// from the keyed inventory. Its old routing/resources must still be released
// before staging any other output's new mode (for example HDMI -> DP).
let mut releases = removals.clone();
releases.extend(selections.iter().filter_map(|(crtc, selection)| {
device
.outputs
.get(crtc)
.and_then(|output| (output.connector != selection.connector.handle()).then_some(*crtc))
}));
releases.sort_by_key(|crtc| u32::from(*crtc));
let hardware_changes = changed let hardware_changes = changed
.iter() .iter()
.filter(|crtc| { .filter(|crtc| {
@@ -500,7 +511,7 @@ pub(super) fn apply_device(
staged: HashMap::new(), staged: HashMap::new(),
replaced: HashSet::new(), replaced: HashSet::new(),
}; };
let result = crate::output_transaction::apply(&mut driver, &hardware_changes, &removals); let result = crate::output_transaction::apply(&mut driver, &hardware_changes, &releases);
let mut staged = std::mem::take(&mut driver.staged); let mut staged = std::mem::take(&mut driver.staged);
if let Err(failure) = result { if let Err(failure) = result {
for crtc in failure.unavailable { for crtc in failure.unavailable {
+31 -20
View File
@@ -36,8 +36,10 @@ pub(crate) use tab_indicator::TabIndicatorSettings;
use crate::window_rules; use crate::window_rules;
use crate::window_rules::{WindowRule, WindowRuleConfig}; use crate::window_rules::{WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub struct Config { pub struct Config {
#[serde(default)]
ui: ferese_config::locale::Settings,
#[serde(default)] #[serde(default)]
activities: ferese_config::activities::Settings, activities: ferese_config::activities::Settings,
#[serde(default)] #[serde(default)]
@@ -79,10 +81,10 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub(crate) xwayland: XwaylandConfig, pub(crate) xwayland: XwaylandConfig,
#[serde(default, rename = "status")] #[serde(default, rename = "status")]
_status: ShellStatusConfig, _status: ferese_config::status::Settings,
} }
#[derive(Clone, Debug, Deserialize, PartialEq)] #[derive(Clone, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)]
pub(crate) struct DaemonConfig { pub(crate) struct DaemonConfig {
pub command: Vec<String>, pub command: Vec<String>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
@@ -93,16 +95,6 @@ pub(crate) struct DaemonConfig {
pub nested: bool, pub nested: bool,
} }
// Validate shell-only fields too, before publishing an accepted source.
#[derive(Debug, Default, Deserialize)]
#[allow(dead_code)]
#[serde(deny_unknown_fields)]
struct ShellStatusConfig {
keybinding_guide: Option<bool>,
low_battery_threshold: Option<u8>,
settings_command: Option<Vec<String>>,
}
impl Config { impl Config {
#[cfg(test)] #[cfg(test)]
pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> { pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> {
@@ -129,6 +121,7 @@ impl Config {
} }
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> { pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
self._status.validate().map_err(ConfigError::Binding)?;
if let Some(panels) = &self.panels { if let Some(panels) = &self.panels {
ferese_panel::validate(panels).map_err(ConfigError::Binding)?; ferese_panel::validate(panels).map_err(ConfigError::Binding)?;
} }
@@ -212,13 +205,13 @@ impl Config {
} }
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
struct WorkspacesConfig { struct WorkspacesConfig {
#[serde(default)] #[serde(default)]
auto_back_and_forth: bool, auto_back_and_forth: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
struct LayoutConfig { struct LayoutConfig {
mode: Option<LayoutModeValue>, mode: Option<LayoutModeValue>,
#[serde(default = "default_inner_gap")] #[serde(default = "default_inner_gap")]
@@ -240,14 +233,14 @@ impl Default for LayoutConfig {
} }
} }
#[derive(Clone, Copy, Debug, Deserialize)] #[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
enum LayoutModeValue { enum LayoutModeValue {
Scrolling, Scrolling,
Tree, Tree,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
struct ScrollingConfig { struct ScrollingConfig {
#[serde(default)] #[serde(default)]
tab_indicator: tab_indicator::TabIndicatorConfig, tab_indicator: tab_indicator::TabIndicatorConfig,
@@ -258,14 +251,14 @@ struct ScrollingConfig {
width_presets: Vec<ColumnWidthValue>, width_presets: Vec<ColumnWidthValue>,
} }
#[derive(Clone, Copy, Debug, Deserialize)] #[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
enum ColumnDisplayValue { enum ColumnDisplayValue {
Stacked, Stacked,
Tabbed, Tabbed,
} }
#[derive(Clone, Copy, Debug, Deserialize)] #[derive(Clone, Copy, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum FocusStrategyValue { enum FocusStrategyValue {
Minimal, Minimal,
@@ -273,7 +266,7 @@ enum FocusStrategyValue {
Paged, Paged,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(untagged)] #[serde(untagged)]
enum ColumnWidthValue { enum ColumnWidthValue {
Proportion(f64), Proportion(f64),
@@ -428,6 +421,24 @@ pub(crate) fn config_path() -> Option<PathBuf> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn configuration_schema_exposes_effective_panel_defaults_and_typed_values() {
let exported = crate::configuration_schema();
assert_eq!(exported["version"], 1);
assert_eq!(
exported["defaults"]["panels"],
serde_json::to_value(
Config::default()
.panels
.unwrap_or_else(|| vec![ferese_panel::Panel::default()])
)
.unwrap()
);
let config: Config = serde_json::from_value(exported["defaults"].clone()).unwrap();
config.runtime_config().unwrap();
assert!(exported["schema"]["properties"].get("ui").is_some());
assert!(exported["schema"]["properties"].get("status").is_some());
}
#[test] #[test]
fn geometry_rejects_removed_and_misspelled_keys_in_both_consumption_paths() { fn geometry_rejects_removed_and_misspelled_keys_in_both_consumption_paths() {
for key in [ for key in [
+16 -14
View File
@@ -202,14 +202,14 @@ pub struct BorderGradient {
pub angle: f64, pub angle: f64,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct BorderPaintConfig { pub(super) struct BorderPaintConfig {
#[serde(default)] #[serde(default)]
pub(super) style: ferese_config::theme::PaintStyle, pub(super) style: ferese_config::theme::PaintStyle,
pub(super) gradient: Option<BorderGradientConfig>, pub(super) gradient: Option<BorderGradientConfig>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct BorderGradientConfig { pub(super) struct BorderGradientConfig {
pub(super) from: String, pub(super) from: String,
pub(super) to: String, pub(super) to: String,
@@ -278,7 +278,9 @@ pub struct ThemeSettings {
pub reduce_transparency: bool, pub reduce_transparency: bool,
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[derive(
Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum MaterialStyle { pub enum MaterialStyle {
Translucent, Translucent,
@@ -286,7 +288,7 @@ pub enum MaterialStyle {
Solid, Solid,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub(super) struct AppearanceConfig { pub(super) struct AppearanceConfig {
#[serde(default)] #[serde(default)]
@@ -303,7 +305,7 @@ pub struct FocusEffectSettings {
pub duration_ms: f64, pub duration_ms: f64,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub(super) struct FocusEffectConfig { pub(super) struct FocusEffectConfig {
pub(super) enabled: bool, pub(super) enabled: bool,
@@ -327,7 +329,7 @@ impl Default for FocusEffectConfig {
} }
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeConfig { pub(super) struct ThemeConfig {
#[serde(default)] #[serde(default)]
pub(super) border: BorderPaintConfig, pub(super) border: BorderPaintConfig,
@@ -349,23 +351,23 @@ pub(super) struct ThemeConfig {
pub(super) surface: ThemeSurfaceConfig, pub(super) surface: ThemeSurfaceConfig,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeSurfaceConfig { pub(super) struct ThemeSurfaceConfig {
#[serde(default)] #[serde(default)]
pub(super) bar: ThemeBarConfig, pub(super) bar: ThemeBarConfig,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeBarConfig { pub(super) struct ThemeBarConfig {
pub(super) background: Option<String>, pub(super) background: Option<String>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct OverviewTypographyConfig { pub(super) struct OverviewTypographyConfig {
pub(super) font_family: Option<String>, pub(super) font_family: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeMaterialConfig { pub(super) struct ThemeMaterialConfig {
#[serde(default = "default_shell_opacity")] #[serde(default = "default_shell_opacity")]
pub(super) opacity: f64, pub(super) opacity: f64,
@@ -400,7 +402,7 @@ fn default_backdrop_blur() -> f64 {
12.0 12.0
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeColorsConfig { pub(super) struct ThemeColorsConfig {
#[serde(default = "default_text_primary_color")] #[serde(default = "default_text_primary_color")]
pub(super) text_primary: String, pub(super) text_primary: String,
@@ -429,13 +431,13 @@ impl Default for ThemeColorsConfig {
} }
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct ThemeShadowConfig { pub(super) struct ThemeShadowConfig {
#[serde(default)] #[serde(default)]
pub(super) soft: SoftShadowConfig, pub(super) soft: SoftShadowConfig,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct SoftShadowConfig { pub(super) struct SoftShadowConfig {
#[serde(default = "default_shadow_offset_y")] #[serde(default = "default_shadow_offset_y")]
pub(super) offset_y: f64, pub(super) offset_y: f64,
@@ -455,7 +457,7 @@ impl Default for SoftShadowConfig {
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub(super) struct ThemeGeometryConfig { pub(super) struct ThemeGeometryConfig {
#[serde(default, rename = "control_gap")] #[serde(default, rename = "control_gap")]
+39 -4
View File
@@ -123,6 +123,7 @@ pub(super) fn parse_binding(
BindingMatch::Keysym => BindingTrigger::Keysym(identity.key), BindingMatch::Keysym => BindingTrigger::Keysym(identity.key),
BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)), BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)),
BindingMatch::Pointer => BindingTrigger::Pointer(PointerTrigger::from_key(identity.key)), BindingMatch::Pointer => BindingTrigger::Pointer(PointerTrigger::from_key(identity.key)),
BindingMatch::SuperTap => BindingTrigger::SuperTap,
BindingMatch::Swipe => BindingTrigger::Swipe { BindingMatch::Swipe => BindingTrigger::Swipe {
fingers: identity.key / 4, fingers: identity.key / 4,
direction: [ direction: [
@@ -154,6 +155,21 @@ pub(super) fn binding_identity(
configured: &BindingConfig, configured: &BindingConfig,
keymap: &xkb::Keymap, keymap: &xkb::Keymap,
) -> Result<BindingIdentity, ConfigError> { ) -> Result<BindingIdentity, ConfigError> {
if matches!(
configured.keys.trim().to_ascii_lowercase().as_str(),
"super" | "logo" | "mod4"
) {
if configured.match_mode != BindingMatch::Keysym {
return Err(ConfigError::Binding(
"Super tap cannot use physical key matching".into(),
));
}
return Ok(BindingIdentity {
modifiers: BindingModifiers::default(),
match_mode: BindingMatch::SuperTap,
key: 0,
});
}
let (modifiers, key) = parse_chord(&configured.keys)?; let (modifiers, key) = parse_chord(&configured.keys)?;
if let Some(trigger) = PointerTrigger::parse(&key)? { if let Some(trigger) = PointerTrigger::parse(&key)? {
if configured.match_mode != BindingMatch::Keysym { if configured.match_mode != BindingMatch::Keysym {
@@ -192,7 +208,7 @@ pub(super) fn binding_identity(
configured.keys configured.keys
)) ))
})?, })?,
BindingMatch::Swipe | BindingMatch::Pointer => { BindingMatch::Swipe | BindingMatch::Pointer | BindingMatch::SuperTap => {
return Err(ConfigError::Binding( return Err(ConfigError::Binding(
"gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(), "gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(),
)); ));
@@ -211,6 +227,7 @@ pub(super) fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity
BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key), BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key),
BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()), BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()),
BindingTrigger::Pointer(trigger) => (BindingMatch::Pointer, trigger.key()), BindingTrigger::Pointer(trigger) => (BindingMatch::Pointer, trigger.key()),
BindingTrigger::SuperTap => (BindingMatch::SuperTap, 0),
BindingTrigger::Swipe { fingers, direction } => { BindingTrigger::Swipe { fingers, direction } => {
(BindingMatch::Swipe, fingers * 4 + direction as u32) (BindingMatch::Swipe, fingers * 4 + direction as u32)
} }
@@ -574,6 +591,7 @@ pub(super) enum BindingTrigger {
Keysym(u32), Keysym(u32),
Physical(Keycode), Physical(Keycode),
Pointer(PointerTrigger), Pointer(PointerTrigger),
SuperTap,
Swipe { Swipe {
fingers: u32, fingers: u32,
direction: crate::gestures::SwipeDirection, direction: crate::gestures::SwipeDirection,
@@ -679,6 +697,7 @@ impl Binding {
}) })
} }
(BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right, (BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right,
(BindingTrigger::SuperTap, BindingTrigger::SuperTap) => true,
_ => false, _ => false,
} }
} }
@@ -702,7 +721,9 @@ impl Binding {
&& match self.trigger { && match self.trigger {
BindingTrigger::Keysym(expected) => keysyms.contains(&expected), BindingTrigger::Keysym(expected) => keysyms.contains(&expected),
BindingTrigger::Physical(expected) => expected == keycode, BindingTrigger::Physical(expected) => expected == keycode,
BindingTrigger::Swipe { .. } | BindingTrigger::Pointer(_) => false, BindingTrigger::Swipe { .. }
| BindingTrigger::Pointer(_)
| BindingTrigger::SuperTap => false,
} }
} }
@@ -797,6 +818,7 @@ impl Binding {
} }
} }
parts.push(match self.trigger { parts.push(match self.trigger {
BindingTrigger::SuperTap => "Super (tap)".into(),
BindingTrigger::Pointer(trigger) => trigger.label(), BindingTrigger::Pointer(trigger) => trigger.label(),
BindingTrigger::Keysym(symbol) => { BindingTrigger::Keysym(symbol) => {
let name = xkb::keysym_get_name(xkb::Keysym::new(symbol)); let name = xkb::keysym_get_name(xkb::Keysym::new(symbol));
@@ -832,7 +854,18 @@ impl Binding {
} }
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)] #[derive(
Clone,
Copy,
Debug,
Default,
Deserialize,
Eq,
Hash,
PartialEq,
schemars::JsonSchema,
serde::Serialize,
)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub(super) enum BindingMatch { pub(super) enum BindingMatch {
#[default] #[default]
@@ -841,9 +874,11 @@ pub(super) enum BindingMatch {
Swipe, Swipe,
#[serde(skip)] #[serde(skip)]
Pointer, Pointer,
#[serde(skip)]
SuperTap,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct BindingConfig { pub(super) struct BindingConfig {
pub(super) keys: String, pub(super) keys: String,
#[serde(default, rename = "match")] #[serde(default, rename = "match")]
+14 -1
View File
@@ -27,7 +27,10 @@ impl From<Vec<Binding>> for BindingSet {
if matches!( if matches!(
identity.match_mode, identity.match_mode,
BindingMatch::Keysym | BindingMatch::Physical | BindingMatch::Pointer BindingMatch::Keysym
| BindingMatch::Physical
| BindingMatch::Pointer
| BindingMatch::SuperTap
) { ) {
index.entry(identity).or_insert(position); index.entry(identity).or_insert(position);
} }
@@ -46,6 +49,16 @@ impl std::ops::Deref for BindingSet {
} }
impl BindingSet { impl BindingSet {
pub(crate) fn super_tap(&self) -> Option<&Binding> {
self.index
.get(&BindingIdentity {
modifiers: BindingModifiers::default(),
match_mode: BindingMatch::SuperTap,
key: 0,
})
.map(|index| &self.ordered[*index])
}
pub(crate) fn binding_for_pointer( pub(crate) fn binding_for_pointer(
&self, &self,
trigger: super::PointerTrigger, trigger: super::PointerTrigger,
+1 -1
View File
@@ -6,7 +6,7 @@ use super::{
#[cfg(test)] #[cfg(test)]
use ferese_layout::Direction; use ferese_layout::Direction;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub(crate) struct BindingModeConfig { pub(crate) struct BindingModeConfig {
name: String, name: String,
+6 -4
View File
@@ -141,7 +141,7 @@ fn default_swipe_threshold() -> u16 {
80 80
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct InputConfig { pub(super) struct InputConfig {
#[serde(default)] #[serde(default)]
pub(super) focus_follows_mouse: bool, pub(super) focus_follows_mouse: bool,
@@ -176,7 +176,7 @@ impl Default for InputConfig {
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct TouchpadConfig { pub(super) struct TouchpadConfig {
#[serde(default)] #[serde(default)]
pub(super) accel_speed: f64, pub(super) accel_speed: f64,
@@ -257,14 +257,16 @@ mod tests {
} }
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[derive(
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum AccelerationProfile { pub enum AccelerationProfile {
Adaptive, Adaptive,
Flat, Flat,
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] #[derive(Clone, Copy, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct MouseSettings { pub struct MouseSettings {
pub natural_scroll: bool, pub natural_scroll: bool,
+11 -5
View File
@@ -191,7 +191,9 @@ pub struct OutputModeRequest {
pub refresh_millihertz: Option<u32>, pub refresh_millihertz: Option<u32>,
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[derive(
Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum OutputTransform { pub enum OutputTransform {
#[default] #[default]
@@ -211,7 +213,9 @@ pub enum OutputTransform {
Flipped270, Flipped270,
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)] #[derive(
Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum LidPolicy { pub enum LidPolicy {
#[default] #[default]
@@ -223,7 +227,9 @@ const fn default_confirm_timeout() -> u32 {
15 15
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq)] #[derive(
Clone, Copy, Debug, Default, Deserialize, serde::Serialize, PartialEq, Eq, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum OutputLayout { pub enum OutputLayout {
InternalOnly, InternalOnly,
@@ -233,7 +239,7 @@ pub enum OutputLayout {
Mirror, Mirror,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct OutputProfileConfig { pub(super) struct OutputProfileConfig {
pub(super) name: String, pub(super) name: String,
#[serde(default)] #[serde(default)]
@@ -248,7 +254,7 @@ pub(super) struct OutputProfileConfig {
pub(super) outputs: Vec<OutputConfig>, pub(super) outputs: Vec<OutputConfig>,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(super) struct OutputConfig { pub(super) struct OutputConfig {
#[serde(default)] #[serde(default)]
pub(super) auto_refresh: bool, pub(super) auto_refresh: bool,
+1 -1
View File
@@ -1,6 +1,6 @@
use super::{Config, ConfigError, Deserialize, parse_color}; use super::{Config, ConfigError, Deserialize, parse_color};
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub(super) struct TabIndicatorConfig { pub(super) struct TabIndicatorConfig {
width: f64, width: f64,
+5 -3
View File
@@ -2,7 +2,9 @@ use std::path::PathBuf;
use serde::Deserialize; use serde::Deserialize;
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] #[derive(
Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub(crate) enum XwaylandStartup { pub(crate) enum XwaylandStartup {
#[default] #[default]
@@ -10,7 +12,7 @@ pub(crate) enum XwaylandStartup {
Eager, Eager,
} }
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub(crate) struct XwaylandConfig { pub(crate) struct XwaylandConfig {
pub enabled: bool, pub enabled: bool,
@@ -79,7 +81,7 @@ mod tests {
#[test] #[test]
fn startup_mode_accepts_kebab_case() { fn startup_mode_accepts_kebab_case() {
#[derive(Deserialize)] #[derive(Deserialize, schemars::JsonSchema, serde::Serialize)]
struct Wrapper { struct Wrapper {
startup: XwaylandStartup, startup: XwaylandStartup,
} }
+40 -4
View File
@@ -1,14 +1,17 @@
use smithay::backend::allocator::dmabuf::Dmabuf; use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::renderer::utils::on_commit_buffer_handler; use smithay::backend::renderer::utils::on_commit_buffer_handler;
use smithay::reexports::wayland_server::Client; use smithay::reexports::calloop::Interest;
use smithay::reexports::wayland_server::protocol::wl_buffer; use smithay::reexports::wayland_server::protocol::wl_buffer;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::reexports::wayland_server::{Client, Resource};
use smithay::wayland::buffer::BufferHandler; use smithay::wayland::buffer::BufferHandler;
use smithay::wayland::compositor::{ use smithay::wayland::compositor::{
CompositorClientState, CompositorHandler, CompositorState, get_parent, get_role, BufferAssignment, CompositorClientState, CompositorHandler, CompositorState, SurfaceAttributes,
is_sync_subsurface, add_blocker, add_pre_commit_hook, get_parent, get_role, is_sync_subsurface, with_states,
};
use smithay::wayland::dmabuf::{
DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier, get_dmabuf,
}; };
use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
use smithay::wayland::shm::{ShmHandler, ShmState}; use smithay::wayland::shm::{ShmHandler, ShmState};
use super::{layer_shell, xdg_shell}; use super::{layer_shell, xdg_shell};
@@ -29,6 +32,39 @@ impl CompositorHandler for Ferese {
fn new_surface(&mut self, surface: &WlSurface) { fn new_surface(&mut self, surface: &WlSurface) {
self.update_surface_preferences(surface); self.update_surface_preferences(surface);
// A commit is not proof that the GPU has finished writing its dma-buf.
// Keep the previous surface state renderable until the acquire fence
// signals, including during resize snapshots and subsurface updates.
add_pre_commit_hook::<Self, _>(surface, |state, _dh, surface| {
let dmabuf = with_states(surface, |states| {
states
.cached_state
.get::<SurfaceAttributes>()
.pending()
.buffer
.as_ref()
.and_then(|assignment| match assignment {
BufferAssignment::NewBuffer(buffer) => get_dmabuf(buffer).ok().cloned(),
_ => None,
})
});
let Some((dmabuf, client)) = dmabuf.zip(surface.client()) else {
return;
};
if let Ok((blocker, source)) = dmabuf.generate_blocker(Interest::READ) {
let inserted = state.loop_handle.insert_source(source, move |_, _, state| {
let dh = state.display_handle.clone();
state
.client_compositor_state(&client)
.blocker_cleared(state, &dh);
Ok(())
});
match inserted {
Ok(_) => add_blocker(surface, blocker),
Err(error) => tracing::warn!(%error, "could not monitor surface GPU readiness"),
}
}
});
} }
fn new_subsurface(&mut self, surface: &WlSurface, _parent: &WlSurface) { fn new_subsurface(&mut self, surface: &WlSurface, _parent: &WlSurface) {
+2
View File
@@ -81,6 +81,7 @@ impl SeatHandler for Ferese {
} }
fn focus_changed(&mut self, seat: &Seat<Self>, focused: Option<&WlSurface>) { fn focus_changed(&mut self, seat: &Seat<Self>, focused: Option<&WlSurface>) {
self.super_tap.cancel();
self.pending_layer_focus = None; self.pending_layer_focus = None;
// Smithay has already sent enter. last_enter uses a separate mutex; // Smithay has already sent enter. last_enter uses a separate mutex;
// querying current_focus here would reenter the held keyboard lock. // querying current_focus here would reenter the held keyboard lock.
@@ -157,6 +158,7 @@ impl KeyboardShortcutsInhibitHandler for Ferese {
.and_then(|keyboard| keyboard.current_focus()); .and_then(|keyboard| keyboard.current_focus());
if focused.as_ref() == Some(inhibitor.wl_surface()) { if focused.as_ref() == Some(inhibitor.wl_surface()) {
self.super_tap.cancel();
inhibitor.activate(); inhibitor.activate();
self.active_shortcuts_inhibitor = Some(inhibitor); self.active_shortcuts_inhibitor = Some(inhibitor);
} }
+5 -1
View File
@@ -19,6 +19,7 @@ impl VirtualKeyboardHandler for Ferese {
_ => None, _ => None,
}; };
if let Some(device) = device { if let Some(device) = device {
self.super_tap.cancel();
keyboard.release_source(self, device.source()); keyboard.release_source(self, device.source());
} }
return; return;
@@ -78,7 +79,10 @@ impl VirtualKeyboardHandler for Ferese {
context.set_modifier_mask(mods_depressed, mods_latched, mods_locked, group) context.set_modifier_mask(mods_depressed, mods_latched, mods_locked, group)
}); });
} }
InputEvent::DeviceRemoved { device } => keyboard.release_source(self, device.source()), InputEvent::DeviceRemoved { device } => {
self.super_tap.cancel();
keyboard.release_source(self, device.source());
}
_ => (), _ => (),
} }
} }
+6 -2
View File
@@ -11,7 +11,9 @@ use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
use crate::Ferese; use crate::Ferese;
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[derive(
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub(crate) struct Settings { pub(crate) struct Settings {
pub fullscreen_playback: bool, pub fullscreen_playback: bool,
@@ -25,7 +27,9 @@ impl Default for Settings {
} }
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[derive(
Clone, Copy, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum Mode { pub enum Mode {
None, None,
+32
View File
@@ -1,6 +1,7 @@
mod window; mod window;
pub(crate) use window::WindowInteraction; pub(crate) use window::WindowInteraction;
mod pointer; mod pointer;
pub(crate) mod super_tap;
use ferese_layout::Direction; use ferese_layout::Direction;
pub(crate) use pointer::ScrollBindings; pub(crate) use pointer::ScrollBindings;
use smithay::backend::input::{ use smithay::backend::input::{
@@ -150,6 +151,16 @@ impl Ferese {
event: InputEvent<I>, event: InputEvent<I>,
source: KeyboardSource, source: KeyboardSource,
) { ) {
match &event {
InputEvent::Keyboard { event } => self.super_tap.observe_key(source, event.key_code()),
InputEvent::PointerButton { event } => self.super_tap.pointer_button(
event.device().id(),
event.button_code(),
event.state(),
),
InputEvent::DeviceRemoved { device } => self.super_tap.device_removed(&device.id()),
_ => self.super_tap.cancel(),
}
if self.input_capture.active() { if self.input_capture.active() {
self.set_binding_mode("default"); self.set_binding_mode("default");
} }
@@ -320,6 +331,10 @@ impl Ferese {
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard"); let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
let keycode = event.key_code(); let keycode = event.key_code();
let state = event.state(); let state = event.state();
let keys_were_clear = keyboard.pressed_keys().is_empty();
let tap_focus = keyboard.current_focus().map(|surface| surface.id());
let tap_allowed = self.super_tap_allowed(&keyboard);
let mut tap_action = None;
let was_captured = self.input_capture.active(); let was_captured = self.input_capture.active();
if self.input_capture.captures(1) { if self.input_capture.captures(1) {
@@ -349,6 +364,18 @@ impl Ferese {
serial, serial,
event.time(), event.time(),
|data, modifiers, keysym| { |data, modifiers, keysym| {
if state == KeyState::Released {
if data.super_tap.release(source, keycode, tap_allowed, tap_focus.clone()) {
tap_action = data.binding_modes.bindings(&data.bindings)
.super_tap().map(|binding| binding.action.clone());
}
} else if keys_were_clear && tap_allowed
&& !modifiers.ctrl && !modifiers.alt && !modifiers.shift
&& keysym.raw_syms().iter().any(|symbol| matches!(symbol.raw(), keysyms::KEY_Super_L | keysyms::KEY_Super_R))
&& data.binding_modes.bindings(&data.bindings).super_tap().is_some()
{
data.super_tap.arm(source, keycode, tap_focus.clone());
}
if data.focus_cycle.is_some() { if data.focus_cycle.is_some() {
if data.session_lock.active() || data.input_capture.captures(1) { if data.session_lock.active() || data.input_capture.captures(1) {
data.cancel_focus_cycle(); data.cancel_focus_cycle();
@@ -530,6 +557,11 @@ impl Ferese {
FilterResult::Intercept(()) FilterResult::Intercept(())
}, },
); );
// Finish forwarding the original release to its owner before
// changing focus or opening a layer surface for the action.
if let Some(action) = tap_action {
self.execute_binding(action);
}
if state == KeyState::Pressed && intercepted.is_none() && client_grab { if state == KeyState::Pressed && intercepted.is_none() && client_grab {
self.record_activation_input(serial, interaction_focus); self.record_activation_input(serial, interaction_focus);
} }
+88
View File
@@ -0,0 +1,88 @@
//! A modifier tap is armed only by an otherwise isolated Super press. It never
//! owns the keyboard event: normal clients, IMEs and Super chords keep theirs.
use std::collections::HashSet;
use smithay::backend::input::ButtonState;
use smithay::input::keyboard::{KeyboardHandle, KeyboardSource, Keycode};
use smithay::reexports::wayland_server::backend::ObjectId;
use smithay::wayland::input_method::InputMethodKeyboardGrab;
use smithay::wayland::keyboard_shortcuts_inhibit::KeyboardShortcutsInhibitorSeat;
#[derive(Default)]
pub(crate) struct SuperTap {
candidate: Option<(KeyboardSource, Keycode, Option<ObjectId>)>,
pointer_buttons: HashSet<(String, u32)>,
}
impl SuperTap {
pub(crate) fn cancel(&mut self) {
self.candidate = None;
}
pub(crate) fn reset(&mut self) {
self.cancel();
self.pointer_buttons.clear();
}
pub(crate) fn device_removed(&mut self, device: &str) {
self.cancel();
self.pointer_buttons.retain(|(owner, _)| owner != device);
}
pub(super) fn pointer_button(&mut self, device: String, button: u32, state: ButtonState) {
self.cancel();
if state == ButtonState::Pressed {
self.pointer_buttons.insert((device, button));
} else {
self.pointer_buttons.remove(&(device, button));
}
}
// Observe before Smithay deduplicates a second input source's identical
// keycode. That second source must invalidate an isolated tap as well.
pub(super) fn observe_key(&mut self, source: KeyboardSource, key: Keycode) {
if self
.candidate
.as_ref()
.is_some_and(|(owner, held, _)| *owner != source || *held != key)
{
self.cancel();
}
}
pub(super) fn arm(&mut self, source: KeyboardSource, key: Keycode, focus: Option<ObjectId>) {
self.candidate = Some((source, key, focus));
}
pub(super) fn release(
&mut self,
source: KeyboardSource,
key: Keycode,
allowed: bool,
focus: Option<ObjectId>,
) -> bool {
self.candidate.take().is_some_and(|(owner, held, initial)| {
allowed && owner == source && held == key && focus == initial
})
}
}
impl crate::Ferese {
pub(super) fn super_tap_allowed(&self, keyboard: &KeyboardHandle<Self>) -> bool {
!self.session_lock.active()
&& !self.input_capture.active()
&& !self.seat.keyboard_shortcuts_inhibited()
&& self.active_shortcuts_inhibitor.is_none()
&& self.pointer_operation.is_none()
&& self.super_tap.pointer_buttons.is_empty()
&& self.seat.get_pointer().is_none_or(|pointer| !pointer.is_grabbed())
// A native Rime grab is part of normal text input. Popup/client
// grabs still retain their exclusive keyboard interaction.
&& keyboard.with_grab(|_, grab| grab.is::<InputMethodKeyboardGrab>()).unwrap_or(true)
}
}
#[cfg(test)]
mod security_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,181 @@
use crate::Ferese;
use crate::input::binding_mode_tests::key;
use crate::state::{dispatch_test_client as dispatch, test_client_request as request, test_window};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_protocols::ext::session_lock::v1::server::{
ext_session_lock_manager_v1::ExtSessionLockManagerV1, ext_session_lock_v1::ExtSessionLockV1,
};
use smithay::reexports::wayland_protocols::wp::keyboard_shortcuts_inhibit::zv1::server::zwp_keyboard_shortcuts_inhibit_manager_v1::ZwpKeyboardShortcutsInhibitManagerV1;
use smithay::reexports::wayland_server::{Client, Resource};
use smithay::utils::SERIAL_COUNTER;
use smithay::wayland::keyboard_shortcuts_inhibit::KeyboardShortcutsInhibitorSeat;
use std::io::Read;
use std::os::unix::net::UnixStream;
const SOURCE: &str = "binding Super binding-mode tapped\nbinding-mode tapped { binding a none; }\n";
fn fixture() -> (Ferese, EventLoop<'static, Ferese>) {
let (mut state, events, _) = crate::input::layer_focus_tests::fixture();
state.reload_config_source(SOURCE.into()).unwrap();
(state, events)
}
fn bind_seat(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, wire: &mut UnixStream) {
request(wire, 1, 1, &[7], None);
dispatch(events, state);
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes);
wire.set_nonblocking(false).unwrap();
let mut offset = 0;
let mut seat_global = None;
while offset < bytes.len() {
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
if object == 7 && header as u16 == 0 {
let length =
u32::from_ne_bytes(bytes[offset + 12..offset + 16].try_into().unwrap()) as usize;
if &bytes[offset + 16..offset + 16 + length] == b"wl_seat\0" {
seat_global = Some(u32::from_ne_bytes(
bytes[offset + 8..offset + 12].try_into().unwrap(),
));
}
}
offset += (header >> 16) as usize;
}
let mut args = vec![seat_global.unwrap(), 8];
args.extend(
b"wl_seat\0"
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
);
args.extend([7, 8]);
request(wire, 7, 0, &args, None);
dispatch(events, state);
}
fn create_manager<R: Resource + 'static>(state: &mut Ferese, client: &Client) -> R
where
Ferese: smithay::reexports::wayland_server::Dispatch<R, smithay::wayland::GlobalData>,
{
client
.create_resource::<R, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap()
}
#[test]
fn inhibitor_protocol_cancels_a_held_tap_even_if_destroyed_before_release() {
if !crate::startup_tests::private_runtime(
"input::super_tap::security_tests::inhibitor_protocol_cancels_a_held_tap_even_if_destroyed_before_release",
) {
return;
}
let (mut state, mut events) = fixture();
let (window, mut wire) = test_window(&mut state, &mut events, 0xff102030);
let surface = window.toplevel().unwrap().wl_surface().clone();
let client = surface.client().unwrap();
let keyboard = state.seat.get_keyboard().unwrap();
keyboard.set_focus(
&mut state,
Some(surface.clone()),
SERIAL_COUNTER.next_serial(),
);
bind_seat(&mut state, &mut events, &mut wire);
let manager = create_manager::<ZwpKeyboardShortcutsInhibitManagerV1>(&mut state, &client);
key(&mut state, 133, true);
assert!(state.super_tap.candidate.is_some());
let opcode = ZwpKeyboardShortcutsInhibitManagerV1::interface()
.requests
.iter()
.position(|request| request.name == "inhibit_shortcuts")
.unwrap() as u32;
request(
&mut wire,
manager.id().protocol_id(),
opcode,
&[9, surface.id().protocol_id(), 8],
None,
);
dispatch(&mut events, &mut state);
assert!(state.seat.keyboard_shortcuts_inhibited());
assert!(
state.super_tap.candidate.is_none(),
"activation must cancel immediately"
);
request(&mut wire, 9, 0, &[], None);
dispatch(&mut events, &mut state);
assert!(!state.seat.keyboard_shortcuts_inhibited());
key(&mut state, 133, false);
assert_eq!(state.binding_modes.active(), None);
key(&mut state, 133, true);
key(&mut state, 133, false);
assert_eq!(state.binding_modes.active(), Some("tapped"));
}
#[test]
fn session_lock_protocol_cancels_pending_tap_before_further_keyboard_input() {
if !crate::startup_tests::private_runtime(
"input::super_tap::security_tests::session_lock_protocol_cancels_pending_tap_before_further_keyboard_input",
) {
return;
}
let (mut state, mut events) = fixture();
let (window, mut wire) = test_window(&mut state, &mut events, 0xff102030);
let surface = window.toplevel().unwrap().wl_surface().clone();
let client = surface.client().unwrap();
state.seat.get_keyboard().unwrap().set_focus(
&mut state,
Some(surface),
SERIAL_COUNTER.next_serial(),
);
let manager = create_manager::<ExtSessionLockManagerV1>(&mut state, &client);
key(&mut state, 133, true);
assert!(state.super_tap.candidate.is_some());
let opcode = ExtSessionLockManagerV1::interface()
.requests
.iter()
.position(|request| request.name == "lock")
.unwrap() as u32;
request(&mut wire, manager.id().protocol_id(), opcode, &[7], None);
dispatch(&mut events, &mut state);
assert!(state.session_lock.active());
assert!(
client
.object_from_protocol_id::<ExtSessionLockV1>(&state.display_handle, 7)
.is_ok()
);
assert!(state.super_tap.candidate.is_none());
key(&mut state, 133, false);
key(&mut state, 133, true);
key(&mut state, 133, false);
assert_eq!(state.binding_modes.active(), None);
}
#[test]
fn focus_departure_and_return_do_not_restore_a_held_tap() {
if !crate::startup_tests::private_runtime(
"input::super_tap::security_tests::focus_departure_and_return_do_not_restore_a_held_tap",
) {
return;
}
let (mut state, mut events) = fixture();
let (window, _wire) = test_window(&mut state, &mut events, 0xff102030);
let surface = window.toplevel().unwrap().wl_surface().clone();
let keyboard = state.seat.get_keyboard().unwrap();
keyboard.set_focus(
&mut state,
Some(surface.clone()),
SERIAL_COUNTER.next_serial(),
);
key(&mut state, 133, true);
assert!(state.super_tap.candidate.is_some());
keyboard.set_focus(&mut state, None, SERIAL_COUNTER.next_serial());
keyboard.set_focus(&mut state, Some(surface), SERIAL_COUNTER.next_serial());
assert!(state.super_tap.candidate.is_none());
key(&mut state, 133, false);
assert_eq!(state.binding_modes.active(), None);
}
+508
View File
@@ -0,0 +1,508 @@
use super::*;
use crate::Ferese;
use crate::config::{BindingAction, Config};
use crate::input::binding_mode_tests::key;
use crate::input::{Event, InputBackend, InputEvent, PointerButtonEvent, PointerMotionEvent};
use ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1;
use smithay::backend::input::{
Axis, AxisRelativeDirection, AxisSource, InputTime, KeyState, PointerAxisEvent, UnusedEvent,
};
use smithay::backend::winit::WinitVirtualDevice;
use smithay::input::keyboard::{GrabStartData, KeyboardGrab, KeyboardInnerHandle, ModifiersState};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_server::{Resource, protocol::wl_surface::WlSurface};
use smithay::utils::{SERIAL_COUNTER, Serial};
use std::io::Read;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
macro_rules! private {
($test:ident) => {
if !crate::startup_tests::private_runtime(concat!(
"input::super_tap::tests::",
stringify!($test)
)) {
return;
}
};
}
const SOURCE: &str = "binding Super binding-mode tapped\nbinding-mode tapped { binding a none; }\nbinding Super+Space binding-mode chord\nbinding-mode chord { binding a none; }\n";
fn fixture(source: &str) -> (Ferese, EventLoop<'static, Ferese>) {
let (mut state, events, _) = crate::input::layer_focus_tests::fixture();
state.reload_config_source(source.into()).unwrap();
(state, events)
}
fn tap(state: &mut Ferese, code: u32) {
key(state, code, true);
key(state, code, false);
}
fn tapped(state: &Ferese) -> bool {
state.binding_modes.active() == Some("tapped")
}
#[test]
fn parsing_indexes_taps_separately_and_preserves_space_chord() {
let config = Config::parse_source("binding Super toggle-launcher\n").unwrap();
let bindings: crate::config::BindingSet = config
.bindings(&config.input_settings().unwrap())
.unwrap()
.into();
assert!(matches!(
bindings.super_tap().unwrap().action,
BindingAction::ToggleLauncher
));
assert!(
bindings
.binding_for_key(
Keycode::new(133),
&[smithay::input::keyboard::keysyms::KEY_Super_L],
true,
false,
false,
false
)
.is_none()
);
assert!(matches!(
bindings
.binding_for_key(
Keycode::new(65),
&[smithay::input::keyboard::keysyms::KEY_space],
true,
false,
false,
false
)
.unwrap()
.action,
BindingAction::ToggleLauncher
));
let guide = bindings.super_tap().unwrap().guide_entry(None).unwrap();
assert_eq!(guide["keys"], "Super (tap)");
for source in [
"binding Super toggle-launcher\nbinding Logo none\n",
"binding Super match=physical action=toggle-launcher\n",
"binding Ctrl toggle-launcher\n",
] {
let config = Config::parse_source(source).unwrap();
assert!(
config.bindings(&config.input_settings().unwrap()).is_err(),
"{source}"
);
}
let disabled = Config::parse_source("binding Super disabled=#true\n").unwrap();
let bindings: crate::config::BindingSet = disabled
.bindings(&disabled.input_settings().unwrap())
.unwrap()
.into();
assert!(bindings.super_tap().is_none());
}
#[test]
fn left_right_taps_execute_on_release_and_repeat_does_not_rearm() {
private!(left_right_taps_execute_on_release_and_repeat_does_not_rearm);
let (mut state, _events) = fixture(SOURCE);
for code in [133, 134] {
key(&mut state, code, true);
assert!(!tapped(&state));
key(&mut state, code, true); // backend repeat, not another isolated tap
assert!(!tapped(&state));
key(&mut state, code, false);
assert!(tapped(&state));
state.set_binding_mode("default");
key(&mut state, code, false); // duplicate release
assert!(!tapped(&state));
assert!(state.seat.get_keyboard().unwrap().pressed_keys().is_empty());
assert!(
state.intercepted_keys.is_empty(),
"tap must not own either half of Super"
);
}
}
#[test]
fn existing_chords_held_keys_and_both_super_keys_never_become_taps() {
private!(existing_chords_held_keys_and_both_super_keys_never_become_taps);
let (mut state, _events) = fixture(SOURCE);
for sequence in [
vec![(133, true), (38, true), (38, false), (133, false)],
vec![(38, true), (133, true), (38, false), (133, false)],
vec![(133, true), (134, true), (133, false), (134, false)],
vec![(134, true), (133, true), (134, false), (133, false)],
vec![(37, true), (133, true), (37, false), (133, false)],
vec![(133, true), (50, true), (50, false), (133, false)],
] {
for (code, down) in sequence {
key(&mut state, code, down);
}
assert!(!tapped(&state));
assert!(state.seat.get_keyboard().unwrap().pressed_keys().is_empty());
}
key(&mut state, 133, true);
tap(&mut state, 65); // existing Super+Space, action is deliberately distinct
assert_eq!(state.binding_modes.active(), Some("chord"));
key(&mut state, 133, false);
assert_eq!(state.binding_modes.active(), Some("chord"));
}
#[derive(Debug)]
struct PointerInput;
impl InputBackend for PointerInput {
type Device = WinitVirtualDevice;
type KeyboardKeyEvent = UnusedEvent;
type PointerButtonEvent = Button;
type PointerMotionEvent = Motion;
type PointerAxisEvent = Scroll;
type PointerMotionAbsoluteEvent = UnusedEvent;
type GestureSwipeBeginEvent = UnusedEvent;
type GestureSwipeUpdateEvent = UnusedEvent;
type GestureSwipeEndEvent = UnusedEvent;
type GesturePinchBeginEvent = UnusedEvent;
type GesturePinchUpdateEvent = UnusedEvent;
type GesturePinchEndEvent = UnusedEvent;
type GestureHoldBeginEvent = UnusedEvent;
type GestureHoldEndEvent = UnusedEvent;
type TouchDownEvent = UnusedEvent;
type TouchUpEvent = UnusedEvent;
type TouchMotionEvent = UnusedEvent;
type TouchCancelEvent = UnusedEvent;
type TouchFrameEvent = UnusedEvent;
type TabletToolAxisEvent = UnusedEvent;
type TabletToolProximityEvent = UnusedEvent;
type TabletToolTipEvent = UnusedEvent;
type TabletToolButtonEvent = UnusedEvent;
type SwitchToggleEvent = UnusedEvent;
type SpecialEvent = UnusedEvent;
}
#[derive(Debug)]
struct Button(ButtonState);
#[derive(Debug)]
struct Motion;
#[derive(Debug)]
struct Scroll;
macro_rules! pointer_event {
($event:ty) => {
impl Event<PointerInput> for $event {
fn time(&self) -> InputTime {
InputTime::from_millis(1)
}
fn device(&self) -> WinitVirtualDevice {
WinitVirtualDevice
}
}
};
}
pointer_event!(Button);
pointer_event!(Motion);
pointer_event!(Scroll);
impl PointerButtonEvent<PointerInput> for Button {
fn button_code(&self) -> u32 {
0x111
}
fn state(&self) -> ButtonState {
self.0
}
}
impl PointerMotionEvent<PointerInput> for Motion {
fn delta_x(&self) -> f64 {
1.
}
fn delta_y(&self) -> f64 {
0.
}
fn delta_x_unaccel(&self) -> f64 {
1.
}
fn delta_y_unaccel(&self) -> f64 {
0.
}
}
impl PointerAxisEvent<PointerInput> for Scroll {
fn amount(&self, axis: Axis) -> Option<f64> {
(axis == Axis::Vertical).then_some(15.)
}
fn amount_v120(&self, axis: Axis) -> Option<f64> {
(axis == Axis::Vertical).then_some(120.)
}
fn source(&self) -> AxisSource {
AxisSource::Wheel
}
fn relative_direction(&self, _: Axis) -> AxisRelativeDirection {
AxisRelativeDirection::Identical
}
}
fn button(state: &mut Ferese, down: bool) {
state.process_input_event::<PointerInput>(InputEvent::PointerButton {
event: Button(if down {
ButtonState::Pressed
} else {
ButtonState::Released
}),
});
}
#[test]
fn mouse_motion_buttons_scroll_and_already_held_button_cancel_taps() {
private!(mouse_motion_buttons_scroll_and_already_held_button_cancel_taps);
let (mut state, _events) = fixture(SOURCE);
key(&mut state, 133, true);
button(&mut state, true);
button(&mut state, false);
key(&mut state, 133, false);
assert!(!tapped(&state));
button(&mut state, true);
tap(&mut state, 133);
assert!(!tapped(&state));
button(&mut state, false);
key(&mut state, 133, true);
state.process_input_event::<PointerInput>(InputEvent::PointerMotion { event: Motion });
key(&mut state, 133, false);
assert!(!tapped(&state));
key(&mut state, 133, true);
state.process_input_event::<PointerInput>(InputEvent::PointerAxis { event: Scroll });
key(&mut state, 133, false);
assert!(!tapped(&state));
tap(&mut state, 133);
assert!(
tapped(&state),
"fresh isolated tap must recover after mouse use"
);
}
#[test]
fn reload_focus_loss_and_second_source_do_not_resurrect_pending_taps() {
private!(reload_focus_loss_and_second_source_do_not_resurrect_pending_taps);
let (mut state, _events) = fixture(SOURCE);
key(&mut state, 133, true);
state.reload_config_source(SOURCE.into()).unwrap();
// An unchanged config need not replace bindings; an actual input/binding
// edit must invalidate the press even if its action stays the same.
state
.reload_config_source(format!("{SOURCE}\nbinding b none\n"))
.unwrap();
key(&mut state, 133, false);
assert!(!tapped(&state));
key(&mut state, 133, true);
state.release_nested_keyboard();
key(&mut state, 133, false);
assert!(!tapped(&state));
let auxiliary = KeyboardSource::new_auxiliary();
state
.super_tap
.arm(KeyboardSource::MAIN, Keycode::new(133), None);
state.super_tap.observe_key(auxiliary, Keycode::new(133));
assert!(
!state
.super_tap
.release(KeyboardSource::MAIN, Keycode::new(133), true, None)
);
tap(&mut state, 134);
assert!(tapped(&state));
}
struct ClientGrab(GrabStartData<Ferese>);
impl KeyboardGrab<Ferese> for ClientGrab {
fn input(
&mut self,
data: &mut Ferese,
handle: &mut KeyboardInnerHandle<'_, Ferese>,
key: Keycode,
state: KeyState,
mods: Option<ModifiersState>,
serial: Serial,
time: InputTime,
) {
handle.input(data, key, state, mods, serial, time);
}
fn set_focus(
&mut self,
data: &mut Ferese,
handle: &mut KeyboardInnerHandle<'_, Ferese>,
focus: Option<WlSurface>,
serial: Serial,
) {
handle.set_focus(data, focus, serial);
}
fn start_data(&self) -> &GrabStartData<Ferese> {
&self.0
}
fn unset(&mut self, _: &mut Ferese) {}
}
#[test]
fn native_ime_grab_allows_tap_but_client_grab_keeps_keyboard() {
private!(native_ime_grab_allows_tap_but_client_grab_keeps_keyboard);
let (mut state, mut _events) = fixture(SOURCE);
let keyboard = state.seat.get_keyboard().unwrap();
// Use a real input-method grab and its own virtual keyboard, as Rime
// does, rather than an empty grab whose protocol resources do not exist.
use smithay::reexports::wayland_protocols_misc::zwp_input_method_v2::server::{
zwp_input_method_manager_v2::ZwpInputMethodManagerV2,
zwp_input_method_v2::ZwpInputMethodV2,
};
use smithay::reexports::wayland_protocols_misc::zwp_virtual_keyboard_v1::server::zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1;
use smithay::wayland::input_method::InputMethodSeat;
use std::os::fd::AsRawFd;
let (server, mut wire) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(crate::state::ClientState::default()))
.unwrap();
let request = crate::state::test_client_request;
request(&mut wire, 1, 1, &[2], None); // display.get_registry
crate::state::dispatch_test_client(&mut _events, &mut state);
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes);
wire.set_nonblocking(false).unwrap();
let mut offset = 0;
let mut seat_global = None;
while offset < bytes.len() {
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
if object == 2 && header as u16 == 0 {
let length =
u32::from_ne_bytes(bytes[offset + 12..offset + 16].try_into().unwrap()) as usize;
if &bytes[offset + 16..offset + 16 + length] == b"wl_seat\0" {
seat_global = Some(u32::from_ne_bytes(
bytes[offset + 8..offset + 12].try_into().unwrap(),
));
}
}
offset += (header >> 16) as usize;
}
let mut bind = vec![seat_global.unwrap(), 8];
bind.extend(
b"wl_seat\0"
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
);
bind.extend([7, 3]);
request(&mut wire, 2, 0, &bind, None);
crate::state::dispatch_test_client(&mut _events, &mut state);
let manager = client
.create_resource::<ZwpInputMethodManagerV2, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
let vk_manager = client
.create_resource::<ZwpVirtualKeyboardManagerV1, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
request(&mut wire, manager.id().protocol_id(), 0, &[3, 4], None);
crate::state::dispatch_test_client(&mut _events, &mut state);
let grab_opcode = ZwpInputMethodV2::interface()
.requests
.iter()
.position(|request| request.name == "grab_keyboard")
.unwrap() as u32;
request(&mut wire, 4, grab_opcode, &[5], None);
request(&mut wire, vk_manager.id().protocol_id(), 0, &[3, 6], None);
let fd = tempfile::tempfile().unwrap();
request(&mut wire, 6, 0, &[0, 0], Some(fd.as_raw_fd())); // no_keymap, retain seat mapping
crate::state::dispatch_test_client(&mut _events, &mut state);
assert_eq!(
state.seat.input_method().keyboard_grab_client(),
Some(client)
);
key(&mut state, 133, true);
assert!(state.super_tap.candidate.is_some());
request(&mut wire, 6, 1, &[1, 125, 1], None); // trusted Rime echo of physical Super press
crate::state::dispatch_test_client(&mut _events, &mut state);
assert!(
state.super_tap.candidate.is_some(),
"trusted IME echo must bypass auxiliary tap cancellation"
);
assert!(!tapped(&state));
key(&mut state, 133, false);
assert!(tapped(&state));
request(&mut wire, 6, 1, &[2, 125, 0], None); // trusted release echo after the physical tap
crate::state::dispatch_test_client(&mut _events, &mut state);
assert!(tapped(&state));
assert!(keyboard.pressed_keys().is_empty());
state.set_binding_mode("default");
keyboard.unset_grab(&mut state);
keyboard.set_grab(
&mut state,
ClientGrab(GrabStartData { focus: None }),
SERIAL_COUNTER.next_serial(),
);
tap(&mut state, 133);
assert!(!tapped(&state));
keyboard.unset_grab(&mut state);
tap(&mut state, 133);
assert!(tapped(&state));
}
fn launcher_events(state: &mut Ferese, wire: &mut UnixStream, shell: &FereseShellV1) -> usize {
state.display_handle.flush_clients().unwrap();
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes);
let opcode = FereseShellV1::interface()
.events
.iter()
.position(|e| e.name == "toggle_launcher")
.unwrap() as u16;
let (mut offset, mut count) = (0, 0);
while offset < bytes.len() {
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
if object == shell.id().protocol_id() && header as u16 == opcode {
count += 1;
}
offset += (header >> 16) as usize;
}
count
}
#[test]
fn actual_launcher_protocol_is_sent_once_after_release_and_space_still_works() {
private!(actual_launcher_protocol_is_sent_once_after_release_and_space_still_works);
let (mut state, _events) = fixture("binding Super toggle-launcher\n");
let (server, mut wire) = UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, Arc::new(crate::state::ClientState::default()))
.unwrap();
let shell = client
.create_resource::<FereseShellV1, crate::shell_control::ShellData, Ferese>(
&state.display_handle,
10,
Default::default(),
)
.unwrap();
state.shell_resources.push(shell.downgrade());
for code in [133, 134] {
key(&mut state, code, true);
assert_eq!(launcher_events(&mut state, &mut wire, &shell), 0);
key(&mut state, code, false);
assert_eq!(launcher_events(&mut state, &mut wire, &shell), 1);
}
key(&mut state, 133, true);
tap(&mut state, 65);
key(&mut state, 133, false);
assert_eq!(
launcher_events(&mut state, &mut wire, &shell),
1,
"Space chord must not also produce a tap"
);
}
#[test]
fn lost_pointer_devices_and_session_reset_do_not_leave_taps_disabled() {
let mut tap = SuperTap::default();
tap.pointer_button("mouse-a".into(), 0x110, ButtonState::Pressed);
tap.pointer_button("mouse-b".into(), 0x110, ButtonState::Pressed);
tap.device_removed("mouse-a");
assert_eq!(tap.pointer_buttons.len(), 1);
tap.device_removed("mouse-b");
assert!(tap.pointer_buttons.is_empty());
tap.pointer_button("mouse-a".into(), 0x111, ButtonState::Pressed);
tap.arm(KeyboardSource::MAIN, Keycode::new(133), None);
tap.reset();
assert!(tap.pointer_buttons.is_empty());
assert!(tap.candidate.is_none());
}
+16
View File
@@ -65,12 +65,28 @@ use tracing::{info, warn};
use crate::backends::LaunchConfig; use crate::backends::LaunchConfig;
fn configuration_schema() -> serde_json::Value {
let mut schema =
serde_json::to_value(schemars::schema_for!(config::Config)).expect("serializable schema");
let mut defaults =
serde_json::to_value(config::Config::default()).expect("serializable defaults");
// None means the implicit default panel, not an empty desktop. Present its
// effective value so the complete editor can modify it directly.
defaults["panels"] = serde_json::json!([ferese_panel::Panel::default()]);
schema["properties"]["panels"]["default"] = defaults["panels"].clone();
serde_json::json!({"version":1,"schema":schema,"defaults":defaults})
}
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
init_logging(); init_logging();
// Settings validates a candidate before atomically replacing the user's file. // Settings validates a candidate before atomically replacing the user's file.
// This mode never opens a display, input device, or compositor socket. // This mode never opens a display, input device, or compositor socket.
let args = std::env::args_os().skip(1).collect::<Vec<_>>(); let args = std::env::args_os().skip(1).collect::<Vec<_>>();
if args == ["--config-schema"] || args == ["--config-schema", "--backend=schema-query"] {
println!("{}", configuration_schema());
return Ok(());
}
if args.first().is_some_and(|arg| arg == "--check-config") { if args.first().is_some_and(|arg| arg == "--check-config") {
if args.len() != 2 { if args.len() != 2 {
return Err("usage: ferese --check-config PATH".into()); return Err("usage: ferese --check-config PATH".into());
+160 -8
View File
@@ -17,30 +17,39 @@ pub(crate) struct Failure<K> {
/// Caller must validate the *complete* device plan first. No logical publication /// Caller must validate the *complete* device plan first. No logical publication
/// is permitted until success. Unavailable rollback results must be retired. /// is permitted until success. Unavailable rollback results must be retired.
/// `releases` includes removed outputs and old connector owners whose CRTC is
/// reused by a change. Release those resources before per-surface staging tests.
pub(crate) fn apply<T: HardwareTransaction>( pub(crate) fn apply<T: HardwareTransaction>(
driver: &mut T, driver: &mut T,
changes: &[T::Key], changes: &[T::Key],
removals: &[T::Key], releases: &[T::Key],
) -> Result<(), Failure<T::Key>> { ) -> Result<(), Failure<T::Key>> {
let mut touched = Vec::new(); let mut touched_changes = Vec::new();
let mut touched_releases = Vec::new();
let result = (|| { let result = (|| {
for key in releases {
touched_releases.push(*key);
driver.disable(*key)?;
}
for key in changes { for key in changes {
touched.push(*key); touched_changes.push(*key);
driver.stage(*key)?; driver.stage(*key)?;
} }
for key in changes { for key in changes {
driver.commit(*key)?; driver.commit(*key)?;
} }
for key in removals {
touched.push(*key);
driver.disable(*key)?;
}
Ok::<_, String>(()) Ok::<_, String>(())
})(); })();
if let Err(error) = result { if let Err(error) = result {
driver.abort_staged(); driver.abort_staged();
let unavailable = touched // Drop new surfaces first, then restore surviving changed outputs before
// re-enabling former owners. Restoring a released CRTC too early can
// exceed the very resource budget that required its release.
let unavailable = touched_changes
.into_iter() .into_iter()
.rev()
.filter(|key| !touched_releases.contains(key))
.chain(touched_releases.iter().rev().copied())
.filter(|key| driver.restore(*key).is_err()) .filter(|key| driver.restore(*key).is_err())
.collect(); .collect();
return Err(Failure { error, unavailable }); return Err(Failure { error, unavailable });
@@ -48,6 +57,149 @@ pub(crate) fn apply<T: HardwareTransaction>(
Ok(()) Ok(())
} }
#[cfg(test)]
mod resource_tests {
use super::*;
use std::collections::BTreeMap;
/// Per-CRTC staging probes see the current hardware plus that one proposed
/// mode. The final plan fits, but an old HDMI route consumes the budget that
/// a surviving DP output's higher refresh needs. CRTC 589 is then reused by
/// another connector, so it appears in both changes and pre-releases.
struct LimitedHardware {
active: BTreeMap<u16, u8>,
previous: BTreeMap<u16, u8>,
desired: BTreeMap<u16, u8>,
events: Vec<(&'static str, u16)>,
fail: Option<(&'static str, u16)>,
restore_fails: Option<u16>,
}
impl LimitedHardware {
fn new() -> Self {
let previous = BTreeMap::from([(205, 0), (397, 1), (589, 2)]);
Self {
active: previous.clone(),
previous,
desired: BTreeMap::from([(205, 0), (397, 2), (589, 1)]),
events: vec![],
fail: None,
restore_fails: None,
}
}
fn fits(&self, key: u16, cost: u8) -> Result<(), String> {
let mut proposed = self.active.clone();
proposed.insert(key, cost);
if proposed.values().copied().sum::<u8>() > 3 {
Err("intermediate topology exceeds device resources".into())
} else {
Ok(())
}
}
fn event(&mut self, operation: &'static str, key: u16) -> Result<(), String> {
self.events.push((operation, key));
if self.fail == Some((operation, key)) {
Err(format!("injected {operation} failure on {key}"))
} else {
Ok(())
}
}
}
impl HardwareTransaction for LimitedHardware {
type Key = u16;
fn stage(&mut self, key: u16) -> Result<(), String> {
self.fits(key, self.desired[&key])?;
self.event("stage", key)
}
fn commit(&mut self, key: u16) -> Result<(), String> {
self.fits(key, self.desired[&key])?;
self.active.insert(key, self.desired[&key]);
// A failed operation can have already changed hardware.
self.event("commit", key)
}
fn disable(&mut self, key: u16) -> Result<(), String> {
self.active.remove(&key);
self.event("disable", key)
}
fn abort_staged(&mut self) {
self.events.push(("abort", 0));
self.active.retain(|key, _| self.previous.contains_key(key));
}
fn restore(&mut self, key: u16) -> Result<(), String> {
self.events.push(("restore", key));
if self.restore_fails == Some(key) {
self.active.remove(&key);
return Err("previous output unavailable".into());
}
if let Some(cost) = self.previous.get(&key).copied() {
self.fits(key, cost)?;
self.active.insert(key, cost);
} else {
self.active.remove(&key);
}
Ok(())
}
}
#[test]
fn reused_crtc_releases_its_old_connector_before_other_modes_are_tested() {
let mut hardware = LimitedHardware::new();
assert_eq!(hardware.desired.values().copied().sum::<u8>(), 3);
apply(&mut hardware, &[397, 589], &[589]).unwrap();
assert_eq!(hardware.active, hardware.desired);
assert_eq!(hardware.events[0], ("disable", 589));
assert!(hardware.events.iter().all(|(_, key)| *key != 205));
}
#[test]
fn rollback_reverts_new_modes_before_restoring_released_connector_owners() {
for failure in [("stage", 589), ("commit", 397), ("commit", 589)] {
let mut hardware = LimitedHardware::new();
hardware.fail = Some(failure);
let result = apply(&mut hardware, &[397, 589], &[589]).unwrap_err();
assert!(result.error.starts_with("injected"), "{}", result.error);
assert!(result.unavailable.is_empty(), "{result:?}");
assert_eq!(hardware.active, hardware.previous);
assert_eq!(
&hardware.events[hardware.events.len() - 3..],
&[("abort", 0), ("restore", 397), ("restore", 589)]
);
}
}
#[test]
fn partially_failed_release_restores_the_old_output_without_staging_changes() {
let mut hardware = LimitedHardware::new();
hardware.fail = Some(("disable", 589));
let result = apply(&mut hardware, &[397, 589], &[589]).unwrap_err();
assert_eq!(result.error, "injected disable failure on 589");
assert!(result.unavailable.is_empty());
assert_eq!(hardware.active, hardware.previous);
assert_eq!(
hardware.events,
[("disable", 589), ("abort", 0), ("restore", 589)]
);
}
#[test]
fn failed_rollback_reports_lost_output_and_still_restores_the_released_owner() {
let mut hardware = LimitedHardware::new();
hardware.fail = Some(("commit", 589));
hardware.restore_fails = Some(397);
let result = apply(&mut hardware, &[397, 589], &[589]).unwrap_err();
assert_eq!(result.unavailable, [397]);
assert_eq!(hardware.active, BTreeMap::from([(205, 0), (589, 2)]));
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+182
View File
@@ -0,0 +1,182 @@
//! Compare damaged swapchain frames with an independently repainted scene.
use super::*;
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::element::solid::SolidColorBuffer;
use smithay::utils::user_data::UserDataMap;
fn blur(
renderer: &mut GlesRenderer,
program: &BlurProgram,
scale: f64,
size: Size<i32, Physical>,
transform: Transform,
) -> BlurRenderElement {
let sample = Rectangle::<i32, Logical>::new((0, 10).into(), (145, 80).into());
let visible = Rectangle::<i32, Logical>::new((10, 20).into(), (120, 55).into());
let sample_physical = sample.to_physical_precise_round(scale);
let framebuffer = framebuffer_clip_rect(sample_physical, size, transform.invert());
let capture = framebuffer_capture_rect(framebuffer);
let texture = renderer
.create_buffer(Fourcc::Abgr8888, (capture[2], capture[3]).into())
.unwrap();
BlurRenderElement::new(
texture,
program.0.clone(),
&MaterialParameters {
panel: None,
corner_radii: [0.; 4],
presentation_alpha: 1.,
background_opacity: 0.,
blur: (8. * scale) as f32,
sample_geometry: sample,
sample_physical,
sample_framebuffer: framebuffer,
shadow_rect: [0.; 4],
shadow_values: [0.; 2],
shadow_bounds: visible,
geometry: visible,
tint: [0.; 4],
generation: 0,
opaque: false,
visible_framebuffer: framebuffer_clip_rect(
visible.to_physical_precise_round(scale),
size,
transform.invert(),
),
},
Arc::new(AtomicBool::new(true)),
)
}
fn pixels(renderer: &mut GlesRenderer, texture: &mut GlesTexture) -> Vec<u8> {
let size = texture.size();
let target = renderer.bind(texture).unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size(size), Fourcc::Abgr8888)
.unwrap();
renderer.map_texture(&mapping).unwrap().to_vec()
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn blur_behind_moving_opaque_window_matches_full_frames_at_buffer_age_three() {
let mut renderer = {
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
let software = std::env::var_os("FERESE_TEST_EGL_SOFTWARE").is_some();
let device = EGLDevice::enumerate()
.unwrap()
.find(|device| device.is_software() == software)
.unwrap();
// SAFETY: The enumerated device remains owned by the EGL display/context.
let display = unsafe { EGLDisplay::new(device).unwrap() };
// SAFETY: This test owns the renderer's fresh context exclusively.
unsafe { GlesRenderer::new(EGLContext::new(&display).unwrap()).unwrap() }
};
let mut resources = RenderResources::default();
let program = blur_program(&mut resources, &mut renderer).unwrap();
for scale in [1., 1.5, 2.] {
for transform in [Transform::Normal, Transform::Flipped180] {
let size: Size<i32, Physical> = ((160. * scale) as i32, (100. * scale) as i32).into();
let mut tracker = OutputDamageTracker::new(size, scale, transform);
let mut targets = (0..3)
.map(|_| {
renderer
.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into())
.unwrap()
})
.collect::<Vec<GlesTexture>>();
let mut reference: GlesTexture = renderer
.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into())
.unwrap();
let effect = blur(&mut renderer, &program, scale, size, transform);
let mut background = SolidColorBuffer::new((160, 100), [0.1, 0.7, 0.2, 1.]);
let foreground = SolidColorBuffer::new((60, 90), [0.2, 0.1, 0.8, 1.]);
for frame_index in 0..9 {
if frame_index % 3 == 0 {
background.set_color([0.1, 0.3 + frame_index as f32 * 0.05, 0.2, 1.]);
}
let solid = || {
SolidColorRenderElement::from_buffer(
&background,
(0, 0),
scale,
1.,
RenderElementKind::Unspecified,
)
};
let front = SolidColorRenderElement::from_buffer(
&foreground,
(((100 - frame_index * 3) as f64 * scale) as i32, 0),
scale,
1.,
RenderElementKind::Unspecified,
);
let elements: Vec<AnimatedWindowRenderElement> =
vec![front.clone().into(), effect.clone().into(), solid().into()];
backdrop::update(&elements, scale);
let slot = frame_index as usize % 3;
{
let mut target = renderer.bind(&mut targets[slot]).unwrap();
tracker
.render_output(
&mut renderer,
&mut target,
if frame_index < 3 { 0 } else { 3 },
&elements,
Color32F::TRANSPARENT,
)
.unwrap()
.sync
.wait()
.unwrap();
}
// Paint every layer without opaque culling or damage reuse.
// A separate capture texture prevents the oracle from repairing
// the incremental renderer's cached copy as a side effect.
let reference_blur = blur(&mut renderer, &program, scale, size, transform);
let full_elements: Vec<AnimatedWindowRenderElement> =
vec![front.into(), reference_blur.into(), solid().into()];
{
let mut target = renderer.bind(&mut reference).unwrap();
let mut frame = renderer
.render(&mut target, size, transform.invert())
.unwrap();
let full = Rectangle::from_size(size);
frame.clear(Color32F::TRANSPARENT, &[full]).unwrap();
for element in full_elements.iter().rev() {
let cache = UserDataMap::default();
let rect = element.geometry(scale.into());
if element.is_framebuffer_effect() {
element
.capture_framebuffer(&mut frame, element.src(), rect, &cache)
.unwrap();
}
element
.draw(
&mut frame,
element.src(),
rect,
&[Rectangle::from_size(rect.size)],
&[],
element.is_framebuffer_effect().then_some(&cache),
)
.unwrap();
}
frame.finish().unwrap().wait().unwrap();
}
let actual = pixels(&mut renderer, &mut targets[slot]);
let expected = pixels(&mut renderer, &mut reference);
let differing = actual
.iter()
.zip(&expected)
.filter(|(a, b)| a.abs_diff(**b) > 1)
.count();
assert_eq!(
differing, 0,
"scale={scale} transform={transform:?} frame={frame_index}"
);
}
}
}
}
+11 -2
View File
@@ -18,7 +18,7 @@ pub(crate) fn capture_resize_snapshot(
) -> Result<Option<ResizeSnapshot>, GlesError> { ) -> Result<Option<ResizeSnapshot>, GlesError> {
// Called before on_commit_buffer_handler: renderer surface state still // Called before on_commit_buffer_handler: renderer surface state still
// owns the previous buffer during an actual resize handoff. // owns the previous buffer during an actual resize handoff.
render_window_texture(renderer, window, geometry, scale, remaining, None) render_window_texture(renderer, window, geometry, scale, remaining, None, true)
} }
fn render_window_texture( fn render_window_texture(
@@ -28,6 +28,7 @@ fn render_window_texture(
scale: f64, scale: f64,
remaining: usize, remaining: usize,
reusable: Option<GlesTexture>, reusable: Option<GlesTexture>,
before_source_release: bool,
) -> Result<Option<ResizeSnapshot>, GlesError> { ) -> Result<Option<ResizeSnapshot>, GlesError> {
let Some(toplevel) = window.toplevel() else { let Some(toplevel) = window.toplevel() else {
return Ok(None); return Ok(None);
@@ -68,7 +69,13 @@ fn render_window_texture(
let damage = Rectangle::from_size(size); let damage = Rectangle::from_size(size);
frame.clear(Color32F::new(0.0, 0.0, 0.0, 0.0), &[damage])?; frame.clear(Color32F::new(0.0, 0.0, 0.0, 0.0), &[damage])?;
draw_render_elements(&mut frame, scale, &content, &[damage])?; draw_render_elements(&mut frame, scale, &content, &[damage])?;
let _ = frame.finish()?; let copied = frame.finish()?;
if before_source_release {
// This function runs before the old wl_buffer is replaced. The
// client may immediately reuse it after release, so the detached
// animation image must be complete before that release is possible.
copied.wait().map_err(|_| GlesError::SyncInterrupted)?;
}
} }
Ok(Some(ResizeSnapshot { Ok(Some(ResizeSnapshot {
texture, texture,
@@ -118,6 +125,7 @@ pub(crate) fn capture_window_frame(
scale, scale,
128 * 1024 * 1024, 128 * 1024 * 1024,
reusable, reusable,
false,
) )
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or("Window has no capturable content")?; .ok_or("Window has no capturable content")?;
@@ -422,6 +430,7 @@ pub(crate) fn capture_stream_window(
info.scale, info.scale,
128 * 1024 * 1024, 128 * 1024 * 1024,
reusable, reusable,
false,
) )
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.ok_or("Window has no capturable content")?; .ok_or("Window has no capturable content")?;
+138
View File
@@ -0,0 +1,138 @@
//! Real input-method protocol resources and SHM bounds; no host GUI is used.
use super::layer_popup_geometry;
use crate::Ferese;
use crate::state::{dispatch_test_client as dispatch, test_client_request as request, test_window};
use smithay::desktop::PopupKind;
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_protocols_misc::zwp_input_method_v2::server::zwp_input_method_manager_v2::ZwpInputMethodManagerV2;
use smithay::reexports::wayland_server::protocol::{
wl_compositor::WlCompositor, wl_shm::WlShm, wl_surface::WlSurface,
};
use smithay::reexports::wayland_server::Resource;
use smithay::utils::{Physical, Point, Rectangle};
use smithay::wayland::input_method::PopupParent;
use smithay::wayland::text_input::TextInputSeat;
use std::io::{Read, Write};
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
fn bind_seat(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, wire: &mut UnixStream) {
request(wire, 1, 1, &[7], None);
dispatch(events, state);
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes);
wire.set_nonblocking(false).unwrap();
let mut offset = 0;
let mut seat = None;
while offset < bytes.len() {
let object = u32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap());
let header = u32::from_ne_bytes(bytes[offset + 4..offset + 8].try_into().unwrap());
if object == 7 && header as u16 == 0 {
let length =
u32::from_ne_bytes(bytes[offset + 12..offset + 16].try_into().unwrap()) as usize;
if &bytes[offset + 16..offset + 16 + length] == b"wl_seat\0" {
seat = Some(u32::from_ne_bytes(
bytes[offset + 8..offset + 12].try_into().unwrap(),
));
}
}
offset += (header >> 16) as usize;
}
let mut args = vec![seat.unwrap(), 8];
args.extend(
b"wl_seat\0"
.chunks_exact(4)
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
);
args.extend([7, 8]);
request(wire, 7, 0, &args, None);
dispatch(events, state);
}
#[test]
fn input_method_popup_preserves_layer_origin_and_uses_own_surface_bounds() {
if !crate::startup_tests::private_runtime(
"render::scene::layer_popup_tests::input_method_popup_preserves_layer_origin_and_uses_own_surface_bounds",
) {
return;
}
let mut events = EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
let (window, mut wire) = test_window(&mut state, &mut events, 0xff102030);
let parent = window.toplevel().unwrap().wl_surface().clone();
let client = parent.client().unwrap();
state.seat.text_input().set_focus(Some(parent.clone()));
bind_seat(&mut state, &mut events, &mut wire);
let manager = client
.create_resource::<ZwpInputMethodManagerV2, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
let compositor = client
.create_resource::<WlCompositor, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
6,
smithay::wayland::GlobalData,
)
.unwrap();
let shm = client
.create_resource::<WlShm, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
request(&mut wire, manager.id().protocol_id(), 0, &[8, 9], None);
request(&mut wire, compositor.id().protocol_id(), 0, &[10], None);
request(&mut wire, 9, 4, &[11, 10], None); // get_input_popup_surface
let mut file = tempfile::tempfile().unwrap();
file.write_all(&0xff204060u32.to_ne_bytes().repeat(364 * 80))
.unwrap();
request(
&mut wire,
shm.id().protocol_id(),
0,
&[12, 364 * 80 * 4],
Some(file.as_raw_fd()),
);
request(&mut wire, 12, 0, &[13, 0, 364, 80, 364 * 4, 0], None);
request(&mut wire, 10, 1, &[13, 0, 0], None);
request(&mut wire, 10, 6, &[], None);
dispatch(&mut events, &mut state);
let surface = client
.object_from_protocol_id::<WlSurface>(&state.display_handle, 10)
.unwrap();
let Some(PopupKind::InputMethod(mut popup)) = state.popups.find_popup(&surface) else {
panic!("input-method popup must be tracked");
};
// Parent geometry is global, but the layer renderer receives output-local
// coordinates. Cover a primary output and a secondary output left/above it.
for output_origin in [(0, 0), (-1920, -300)] {
let layer_origin = Point::from((360, 241));
let global_parent =
Rectangle::new(layer_origin + Point::from(output_origin), (560, 70).into());
popup.set_parent(Some(PopupParent {
surface: parent.clone(),
location: global_parent,
}));
popup.set_location((148, 55).into());
let kind = PopupKind::InputMethod(popup.clone());
assert_eq!(kind.geometry(), global_parent);
let (bounds, content_origin) = layer_popup_geometry(layer_origin, popup.location(), &kind);
assert_eq!(content_origin, Point::from((508, 296)));
assert_eq!(
bounds,
Rectangle::new((508, 296).into(), (364, 80).into()),
"candidate bounds must come from its own buffer, not its parent"
);
for scale in [1.0, 1.5, 2.0] {
let pixels: Rectangle<i32, Physical> = bounds.to_physical_precise_round(scale);
assert_eq!(pixels.loc, content_origin.to_physical_precise_round(scale));
assert_eq!(pixels.size.h, (80.0 * scale) as i32);
}
}
}
+128 -24
View File
@@ -1,4 +1,6 @@
mod backdrop; mod backdrop;
#[cfg(test)]
mod backdrop_gpu_tests;
mod capture; mod capture;
mod closing; mod closing;
mod window_blur; mod window_blur;
@@ -272,9 +274,25 @@ impl Element for BlurRenderElement {
fn kind(&self) -> RenderElementKind { fn kind(&self) -> RenderElementKind {
RenderElementKind::Unspecified RenderElementKind::Unspecified
} }
fn is_framebuffer_effect(&self) -> bool {
// The complete sampling region must be repainted before capture,
// including pixels hidden by opaque elements above this blur.
true
}
} }
impl RenderElement<GlesRenderer> for BlurRenderElement { impl RenderElement<GlesRenderer> for BlurRenderElement {
fn capture_framebuffer(
&self,
frame: &mut GlesFrame<'_, '_>,
_src: Rectangle<f64, Buffer>,
_dst: Rectangle<i32, Physical>,
_cache: &smithay::utils::user_data::UserDataMap,
) -> Result<(), GlesError> {
self.capture(frame)
}
fn draw( fn draw(
&self, &self,
frame: &mut GlesFrame<'_, '_>, frame: &mut GlesFrame<'_, '_>,
@@ -282,14 +300,37 @@ impl RenderElement<GlesRenderer> for BlurRenderElement {
dst: Rectangle<i32, Physical>, dst: Rectangle<i32, Physical>,
damage: &[Rectangle<i32, Physical>], damage: &[Rectangle<i32, Physical>],
opaque_regions: &[Rectangle<i32, Physical>], opaque_regions: &[Rectangle<i32, Physical>],
_cache: Option<&smithay::utils::user_data::UserDataMap>, cache: Option<&smithay::utils::user_data::UserDataMap>,
) -> Result<(), GlesError> { ) -> Result<(), GlesError> {
// Simple full-frame renderers do not implement the effect callback.
// Damage-tracked outputs must use its callback, never the dirty flag:
// the tracker knows when occlusion or buffer restoration needs a copy.
if cache.is_none() && self.capture_dirty.load(Ordering::Relaxed) {
self.capture(frame)?;
}
frame.render_texture_from_to(
&self.texture,
src,
dst,
damage,
opaque_regions,
Transform::Normal,
1.0,
Some(&self.program),
&self.uniforms,
)
}
fn underlying_storage(&self, _renderer: &mut GlesRenderer) -> Option<UnderlyingStorage<'_>> {
None
}
}
impl BlurRenderElement {
fn capture(&self, frame: &mut GlesFrame<'_, '_>) -> Result<(), GlesError> {
let texture = self.texture.tex_id(); let texture = self.texture.tex_id();
let [x, y, width, height] = self.capture_rect; let [x, y, width, height] = self.capture_rect;
// Buffer-age restoration and unrelated partial damage must reuse the {
// clean captured scene. Recapturing a partly repainted framebuffer
// would feed previous blurred pixels back into the blur.
if self.capture_dirty.load(Ordering::Relaxed) {
// SAFETY: with_context makes this frame context current. GL writes into live local // SAFETY: with_context makes this frame context current. GL writes into live local
// scalars, and the capture texture belongs to this renderer with the allocated // scalars, and the capture texture belongs to this renderer with the allocated
// framebuffer bounds. // framebuffer bounds.
@@ -341,22 +382,7 @@ impl RenderElement<GlesRenderer> for BlurRenderElement {
} }
self.capture_dirty.store(false, Ordering::Relaxed); self.capture_dirty.store(false, Ordering::Relaxed);
} }
Ok(())
frame.render_texture_from_to(
&self.texture,
src,
dst,
damage,
opaque_regions,
Transform::Normal,
1.0,
Some(&self.program),
&self.uniforms,
)
}
fn underlying_storage(&self, _renderer: &mut GlesRenderer) -> Option<UnderlyingStorage<'_>> {
None
} }
} }
@@ -538,6 +564,47 @@ fn clipped_surface_damage(
} }
} }
fn sampled_surface_damage(
size: Size<i32, Physical>,
source: Size<f64, Buffer>,
transform: Transform,
clip_changed: bool,
client_damage: impl FnOnce() -> DamageSet<i32, Physical>,
) -> DamageSet<i32, Physical> {
clipped_surface_damage(size, clip_changed, || {
// Linear filtering also changes destination samples just outside the
// changed texels. Smithay maps their rectangle to physical coordinates,
// but does not include this half-texel sampling footprint. Expanding in
// physical pixels prevents stale fringes on fractional-scale redraws.
let source = transform.transform_size(source);
let footprint = |pixels: i32, texels: f64| {
if texels > 0.0 {
(f64::from(pixels) / texels * 0.5).ceil() as i32
} else {
0
}
};
let x = footprint(size.w, source.w);
let y = footprint(size.h, source.h);
let bounds = Rectangle::from_size(size);
client_damage()
.into_iter()
.filter(|rect| !rect.is_empty())
.filter_map(|rect| {
Rectangle::new(
(rect.loc.x.saturating_sub(x), rect.loc.y.saturating_sub(y)).into(),
(
rect.size.w.saturating_add(x.saturating_mul(2)),
rect.size.h.saturating_add(y.saturating_mul(2)),
)
.into(),
)
.intersection(bounds)
})
.collect()
})
}
impl Element for RoundedSurfaceRenderElement { impl Element for RoundedSurfaceRenderElement {
fn id(&self) -> &Id { fn id(&self) -> &Id {
self.inner.id() self.inner.id()
@@ -564,9 +631,13 @@ impl Element for RoundedSurfaceRenderElement {
scale: RenderScale<f64>, scale: RenderScale<f64>,
commit: Option<CommitCounter>, commit: Option<CommitCounter>,
) -> DamageSet<i32, Physical> { ) -> DamageSet<i32, Physical> {
clipped_surface_damage(self.inner.geometry(scale).size, self.clip_changed, || { sampled_surface_damage(
self.inner.damage_since(scale, commit) self.inner.geometry(scale).size,
}) self.inner.src().size,
self.inner.transform(),
self.clip_changed,
|| self.inner.damage_since(scale, commit),
)
} }
fn opaque_regions(&self, scale: RenderScale<f64>) -> OpaqueRegions<i32, Physical> { fn opaque_regions(&self, scale: RenderScale<f64>) -> OpaqueRegions<i32, Physical> {
@@ -688,6 +759,39 @@ mod tests {
shadow_bounds, shadow_bounds,
}; };
#[test]
fn filtered_surface_damage_stays_local_and_preserves_full_clip_changes() {
use smithay::backend::renderer::utils::DamageSet;
let size = (120, 80).into();
let source = (80.0, 40.0).into();
for (transform, x_padding) in [(Transform::Normal, 1), (Transform::_90, 2)] {
let damage = super::sampled_surface_damage(size, source, transform, false, || {
DamageSet::from_slice(&[
Rectangle::new((0, 0).into(), (4, 3).into()),
Rectangle::new((118, 78).into(), (2, 2).into()),
])
});
assert_eq!(
damage.iter().copied().collect::<Vec<_>>(),
vec![
Rectangle::new((0, 0).into(), (4 + x_padding, 4).into()),
Rectangle::new((118 - x_padding, 77).into(), (2 + x_padding, 3).into(),),
],
);
assert!(
super::sampled_surface_damage(size, source, transform, false, DamageSet::default)
.is_empty()
);
let full = super::sampled_surface_damage(size, source, transform, true, || {
panic!("a changed clip must keep full damage without consulting the client")
});
assert_eq!(
full.iter().copied().collect::<Vec<_>>(),
vec![Rectangle::from_size(size)]
);
}
}
#[test] #[test]
#[ignore = "requires an EGL rendering device"] #[ignore = "requires an EGL rendering device"]
fn solid_corner_mask_preserves_premultiplied_color() { fn solid_corner_mask_preserves_premultiplied_color() {
+26 -3
View File
@@ -556,9 +556,7 @@ pub(super) fn layer_elements(
let mut elements = Vec::new(); let mut elements = Vec::new();
for (geometry, layer) in layers { for (geometry, layer) in layers {
for (popup, offset) in PopupManager::popups_for_surface(layer.wl_surface()) { for (popup, offset) in PopupManager::popups_for_surface(layer.wl_surface()) {
let mut bounds = popup.geometry(); let (bounds, content_origin) = layer_popup_geometry(geometry.loc, offset, &popup);
let content_origin = geometry.loc + offset - bounds.loc;
bounds.loc = geometry.loc + offset;
append_material_surface( append_material_surface(
state, state,
renderer, renderer,
@@ -587,6 +585,27 @@ pub(super) fn layer_elements(
elements elements
} }
fn layer_popup_geometry(
layer_origin: Point<i32, Logical>,
offset: Point<i32, Logical>,
popup: &smithay::desktop::PopupKind,
) -> (Rectangle<i32, Logical>, Point<i32, Logical>) {
if matches!(popup, smithay::desktop::PopupKind::InputMethod(_)) {
// IME geometry describes its parent in global coordinates, unlike
// xdg window geometry. Its location is already parent-relative, and
// its candidate surface can extend beyond the parent's bounds.
let origin = layer_origin + offset;
return (
smithay::desktop::utils::bbox_from_surface_tree(popup.wl_surface(), origin),
origin,
);
}
let mut bounds = popup.geometry();
let origin = layer_origin + offset - bounds.loc;
bounds.loc = layer_origin + offset;
(bounds, origin)
}
fn layer_output_crop(size: Size<i32, Physical>, transform: Transform) -> Rectangle<i32, Physical> { fn layer_output_crop(size: Size<i32, Physical>, transform: Transform) -> Rectangle<i32, Physical> {
Rectangle::from_size(transform.transform_size(size)) Rectangle::from_size(transform.transform_size(size))
} }
@@ -675,6 +694,10 @@ pub(super) fn cursor_elements(
} }
} }
#[cfg(test)]
#[path = "layer_popup_tests.rs"]
mod layer_popup_tests;
#[cfg(test)] #[cfg(test)]
mod layer_crop_tests { mod layer_crop_tests {
use super::*; use super::*;
+2 -1
View File
@@ -17,7 +17,7 @@ use smithay::wayland::session_lock::{
use crate::Ferese; use crate::Ferese;
#[derive(Clone, Copy, Debug, serde::Deserialize)] #[derive(Clone, Copy, Debug, serde::Deserialize, schemars::JsonSchema, serde::Serialize)]
#[serde(default)] #[serde(default)]
pub(crate) struct IdleSettings { pub(crate) struct IdleSettings {
pub dim_after_seconds: u64, pub dim_after_seconds: u64,
@@ -452,6 +452,7 @@ impl SessionLockHandler for Ferese {
} }
fn lock(&mut self, confirmation: SessionLocker) { fn lock(&mut self, confirmation: SessionLocker) {
self.super_tap.cancel();
self.dismiss_launcher(); self.dismiss_launcher();
if !self.session_lock.lifecycle.can_acquire() { if !self.session_lock.lifecycle.can_acquire() {
return; return;
+3
View File
@@ -505,6 +505,7 @@ pub struct Ferese {
pub(crate) binding_modes: crate::config::BindingModes, pub(crate) binding_modes: crate::config::BindingModes,
pub(crate) binding_mode_pressed: HashSet<Keycode>, pub(crate) binding_mode_pressed: HashSet<Keycode>,
pub(crate) binding_mode_repeats: HashMap<Keycode, crate::config::Binding>, pub(crate) binding_mode_repeats: HashMap<Keycode, crate::config::Binding>,
pub(crate) super_tap: crate::input::super_tap::SuperTap,
workspace_auto_back_and_forth: bool, workspace_auto_back_and_forth: bool,
pub(crate) window_rules: Vec<WindowRule>, pub(crate) window_rules: Vec<WindowRule>,
pub(crate) theme_settings: ThemeSettings, pub(crate) theme_settings: ThemeSettings,
@@ -847,6 +848,7 @@ impl Ferese {
binding_modes: config.binding_modes, binding_modes: config.binding_modes,
binding_mode_pressed: HashSet::new(), binding_mode_pressed: HashSet::new(),
binding_mode_repeats: HashMap::new(), binding_mode_repeats: HashMap::new(),
super_tap: Default::default(),
workspace_auto_back_and_forth: config.workspace_auto_back_and_forth, workspace_auto_back_and_forth: config.workspace_auto_back_and_forth,
window_rules: config.window_rules, window_rules: config.window_rules,
theme_settings: config.theme_settings, theme_settings: config.theme_settings,
@@ -1073,6 +1075,7 @@ impl Ferese {
self.gap_config = config.gap_config; self.gap_config = config.gap_config;
self.input_settings = config.input_settings; self.input_settings = config.input_settings;
if bindings_changed || input_changed { if bindings_changed || input_changed {
self.super_tap.cancel();
self.binding_mode_repeats.clear(); self.binding_mode_repeats.clear();
self.bindings = config.bindings.into(); self.bindings = config.bindings.into();
self.binding_modes = config.binding_modes; self.binding_modes = config.binding_modes;
@@ -1,4 +1,9 @@
//! Private protocol clients and offscreen EGL; no host desktop or DRM required. //! Private protocol clients and offscreen EGL; no host desktop or DRM required.
#[path = "resize_scene_gpu_tests.rs"]
mod resize_scene_gpu_tests;
#[path = "partial_shm_gpu_tests.rs"]
mod partial_shm_gpu_tests;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::fd::{AsRawFd, RawFd}; use std::os::fd::{AsRawFd, RawFd};
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
@@ -0,0 +1,461 @@
//! Streaming SHM updates compared with independent full imports and full repaint.
use super::*;
use std::borrow::Cow;
use std::fs::File;
use std::os::unix::fs::FileExt;
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::element::{Element, RenderElement};
use smithay::backend::renderer::{Color32F, Frame, Renderer};
use smithay::utils::{Buffer, Physical, Size};
const WIDTH: u32 = 193;
const HEIGHT: u32 = 137;
const STRIDE: u32 = (WIDTH + 7) * 4;
const OFFSET: u32 = 64;
struct ReusableShm {
file: File,
buffer: u32,
version: usize,
format: u32,
}
fn buffer_pixels(pixels: &[u8], format: u32, switching_formats: bool) -> Cow<'_, [u8]> {
if !switching_formats {
return Cow::Borrowed(pixels);
}
let mut encoded = pixels.to_vec();
for pixel in encoded.chunks_exact_mut(4) {
if format == 1 {
// XRGB's unused high byte may legally be zero.
pixel[3] = 0;
} else {
// A valid premultiplied translucent ARGB surface makes a stale
// opaque shader variant visibly differ in RGB as well as alpha.
for channel in &mut pixel[..3] {
*channel = (u16::from(*channel) * 128 / 255) as u8;
}
pixel[3] = 128;
}
}
Cow::Owned(encoded)
}
fn write_region(file: &File, pixels: &[u8], rect: Rectangle<i32, Buffer>) {
for y in rect.loc.y..rect.loc.y + rect.size.h {
let source = (y as u32 * WIDTH + rect.loc.x as u32) as usize * 4;
let target = u64::from(OFFSET + y as u32 * STRIDE + rect.loc.x as u32 * 4);
file.write_all_at(&pixels[source..source + rect.size.w as usize * 4], target)
.unwrap();
}
}
fn pixels(renderer: &mut GlesRenderer, texture: &mut GlesTexture) -> Vec<u8> {
let size = smithay::backend::renderer::Texture::size(texture);
let target = renderer.bind(texture).unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size(size), Fourcc::Abgr8888)
.unwrap();
renderer.map_texture(&mapping).unwrap().to_vec()
}
#[test]
#[ignore = "requires a private Wayland socket and an EGL rendering device"]
fn reused_shm_partial_damage_matches_fresh_full_uploads_at_fractional_scale() {
const TEST: &str = "state::capture_privacy::tests::partial_shm_gpu_tests::reused_shm_partial_damage_matches_fresh_full_uploads_at_fractional_scale";
if !crate::startup_tests::private_runtime(TEST) {
return;
}
run_shm_scenarios(&[(1, None), (3, None)]);
}
#[test]
#[ignore = "requires a private Wayland socket and an EGL rendering device"]
fn same_sized_argb_xrgb_switches_match_fresh_full_uploads() {
const TEST: &str = "state::capture_privacy::tests::partial_shm_gpu_tests::same_sized_argb_xrgb_switches_match_fresh_full_uploads";
if !crate::startup_tests::private_runtime(TEST) {
return;
}
run_shm_scenarios(&[(2, Some(0)), (2, Some(1))]);
}
fn run_shm_scenarios(scenarios: &[(usize, Option<u32>)]) {
let software = std::env::var_os("FERESE_TEST_EGL_SOFTWARE").is_some();
let device = EGLDevice::enumerate()
.unwrap()
.find(|device| device.is_software() == software)
.expect("requested EGL device");
eprintln!(
"partial SHM EGL software={software}, render node={:?}",
device.render_device_path()
);
// SAFETY: The display owns the enumerated device; each renderer below owns
// a fresh, unshared context used only by this test thread.
let display = unsafe { EGLDisplay::new(device).unwrap() };
let mut renderer = unsafe { GlesRenderer::new(EGLContext::new(&display).unwrap()).unwrap() };
let scale = 1.5;
let physical: Size<i32, Physical> = (480, 360).into();
let mut mismatching_frames = 0;
for &(buffer_count, first_format) in scenarios {
let switching_formats = first_format.is_some();
let mut events = EventLoop::try_new().unwrap();
let config = crate::config::Config::parse_source(
"animations { enabled #false; }\nlayout { inner-gap 4; outer-gap 4; }\nwindow-rule transient=#false floating=#true\n",
)
.unwrap()
.runtime_config()
.unwrap();
let mut state = Ferese::new(&mut events, Display::new().unwrap(), config).unwrap();
state.animation_test_time = Some(Duration::ZERO);
state.focus_effect.active_opacity = 1.0;
state.focus_effect.inactive_opacity = 1.0;
state.focus_effect.inactive_dim = 0.0;
state.theme_settings.window_radius = 12.0;
let output = Output::new(
"partial-shm".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: physical,
refresh: 60_000,
}),
Some(Transform::Normal),
Some(Scale::Fractional(scale)),
None,
);
state.space.map_output(&output, (0, 0));
state.register_output(&output, "partial-shm".into());
let (window, mut wire) = window(&mut state, &mut events, 0xff203c5c);
let id = state.windows.ids()[&window];
state.focused_window = Some(id);
state.set_floating_window_geometry(
&window,
(56, 64).into(),
(WIDTH as i32, HEIGHT as i32).into(),
);
state.display_handle.flush_clients().unwrap();
ack_configure(&mut wire, 3);
let client = window.toplevel().unwrap().wl_surface().client().unwrap();
let shm = client
.create_resource::<WlShm, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
let compositor = client
.create_resource::<WlCompositor, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
6,
smithay::wayland::GlobalData,
)
.unwrap();
let mut raster = 0xff203c5cu32
.to_ne_bytes()
.repeat((WIDTH * HEIGHT) as usize);
let full: Rectangle<i32, Buffer> =
Rectangle::from_size((WIDTH as i32, HEIGHT as i32).into());
let mut buffers = Vec::new();
for slot in 0..buffer_count {
let format = first_format.map_or(0, |first| first ^ (slot as u32 % 2));
let file = tempfile::tempfile().unwrap();
let len = OFFSET + STRIDE * HEIGHT;
file.set_len(u64::from(len)).unwrap();
// Nontrivial offset/stride/padding exercise pixel-store state too.
file.write_all_at(&vec![0xa5; len as usize], 0).unwrap();
write_region(
&file,
&buffer_pixels(&raster, format, switching_formats),
full,
);
let pool = 7 + slot as u32 * 2;
let buffer = pool + 1;
request(
&mut wire,
shm.id().protocol_id(),
0,
&[pool, len],
Some(file.as_raw_fd()),
);
request(
&mut wire,
pool,
0,
&[buffer, OFFSET, WIDTH, HEIGHT, STRIDE, format],
None,
);
buffers.push(ReusableShm {
file,
buffer,
version: 0,
format,
});
}
let region = 7 + buffer_count as u32 * 2;
request(&mut wire, compositor.id().protocol_id(), 1, &[region], None);
if switching_formats {
// ARGB is genuinely translucent in this scenario; XRGB receives
// automatic opacity from its declared SHM format.
request(&mut wire, 2, 4, &[0], None);
} else {
request(&mut wire, region, 1, &[0, 0, WIDTH, HEIGHT], None);
request(&mut wire, 2, 4, &[region], None);
}
request(&mut wire, region, 0, &[], None);
request(&mut wire, 3, 3, &[0, 0, WIDTH, HEIGHT], None);
request(&mut wire, 2, 1, &[buffers[0].buffer, 0, 0], None);
request(&mut wire, 2, 9, &[0, 0, WIDTH, HEIGHT], None);
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
state.windows.update(id, |record| record.opening = None);
state.animations_enabled = true;
state.advance_animations_at(Duration::from_secs(1), Duration::from_secs(1));
let fixed_geometry = state.windows.geometry(&id).unwrap().visual.current;
assert_eq!(window.geometry().size, (WIDTH as i32, HEIGHT as i32).into());
let mut tracker = OutputDamageTracker::from_output(&output);
let mut targets: Vec<GlesTexture> = (0..3)
.map(|_| {
renderer
.create_buffer(Fourcc::Abgr8888, (physical.w, physical.h).into())
.unwrap()
})
.collect();
let mut history = Vec::new();
let mut total_changed_bytes = 0;
let mut partial_frames = 0;
for frame_index in 0..24 {
let mut changed = None;
if frame_index > 0 {
let rect = if switching_formats {
full
} else {
Rectangle::<i32, Buffer>::new(
(
(frame_index * 37 % (WIDTH as usize - 29)) as i32,
(frame_index * 23 % (HEIGHT as usize - 13)) as i32,
)
.into(),
(29, 13).into(),
)
};
let color = 0xff000000u32
| (((frame_index * 41) % 220 + 20) as u32) << 16
| (((frame_index * 67) % 220 + 20) as u32) << 8
| (((frame_index * 97) % 220 + 20) as u32);
for y in rect.loc.y..rect.loc.y + rect.size.h {
for x in rect.loc.x..rect.loc.x + rect.size.w {
let at = (y as u32 * WIDTH + x as u32) as usize * 4;
raster[at..at + 4].copy_from_slice(&color.to_ne_bytes());
}
}
history.push(rect);
let buffer = &mut buffers[frame_index % buffer_count];
let encoded = buffer_pixels(&raster, buffer.format, switching_formats);
// A recycled client buffer catches up only the rectangles changed
// since its last use. Surface damage describes this frame's delta
// from the preceding surface commit, independent of buffer age.
for pending in &history[buffer.version..] {
write_region(&buffer.file, &encoded, *pending);
total_changed_bytes += pending.size.w * pending.size.h * 4;
}
buffer.version = history.len();
request(&mut wire, 2, 1, &[buffer.buffer, 0, 0], None);
request(
&mut wire,
2,
9,
&[
rect.loc.x as u32,
rect.loc.y as u32,
rect.size.w as u32,
rect.size.h as u32,
],
None,
);
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
changed = Some((rect, color));
partial_frames += 1;
}
state.advance_animations_at(
Duration::from_millis(16),
Duration::from_millis(1016 + frame_index as u64 * 16),
);
let scene = state.sample_frame(&output, Duration::ZERO);
assert_eq!(
scene.windows[&id].presentation.bounds.current, fixed_geometry,
"streaming moved the window"
);
assert_eq!(scene.windows[&id].presentation.alpha(), 1.0);
let elements = crate::render::sampled_output_elements(
&mut state,
&mut renderer,
&output,
false,
&scene,
);
assert!(
elements
.iter()
.all(|element| !element.is_framebuffer_effect())
);
let slot = frame_index % 3;
{
let mut target = renderer.bind(&mut targets[slot]).unwrap();
tracker
.render_output(
&mut renderer,
&mut target,
if frame_index < 3 { 0 } else { 3 },
&elements,
Color32F::new(0.035, 0.04, 0.055, 1.0),
)
.unwrap()
.sync
.wait()
.unwrap();
}
let actual = pixels(&mut renderer, &mut targets[slot]);
let cached_format = smithay::backend::renderer::utils::with_renderer_surface_state(
window.toplevel().unwrap().wl_surface(),
|surface| {
surface
.texture::<GlesTexture>(renderer.context_id())
.and_then(smithay::backend::renderer::Texture::format)
},
)
.flatten();
// A fresh, unshared context cannot reuse the display path's imported
// SHM texture. Smithay creates a new texture with a complete TexImage2D
// upload regardless of partial damage, then this oracle repaints every
// layer without damage history or opaque-region culling.
let mut oracle =
unsafe { GlesRenderer::new(EGLContext::new(&display).unwrap()).unwrap() };
let oracle_context = oracle.context_id().erased();
let mut reference: GlesTexture = oracle
.create_buffer(Fourcc::Abgr8888, (physical.w, physical.h).into())
.unwrap();
let full_elements = crate::render::sampled_output_elements(
&mut state,
&mut oracle,
&output,
false,
&scene,
);
let fresh_format = smithay::backend::renderer::utils::with_renderer_surface_state(
window.toplevel().unwrap().wl_surface(),
|surface| {
surface
.texture::<GlesTexture>(oracle.context_id())
.and_then(smithay::backend::renderer::Texture::format)
},
)
.flatten();
{
let mut target = oracle.bind(&mut reference).unwrap();
let mut frame = oracle
.render(&mut target, physical, Transform::Normal)
.unwrap();
frame
.clear(
Color32F::new(0.035, 0.04, 0.055, 1.0),
&[Rectangle::from_size(physical)],
)
.unwrap();
for element in full_elements.iter().rev() {
let rect = element.geometry(scale.into());
element
.draw(
&mut frame,
element.src(),
rect,
&[Rectangle::from_size(rect.size)],
&[],
None,
)
.unwrap();
}
frame.finish().unwrap().wait().unwrap();
}
let expected = pixels(&mut oracle, &mut reference);
let differing = actual
.iter()
.zip(&expected)
.filter(|(a, b)| a.abs_diff(**b) > 1)
.count();
if differing > 0 {
mismatching_frames += 1;
let differences = actual
.chunks_exact(4)
.zip(expected.chunks_exact(4))
.enumerate()
.filter(|(_, (a, b))| a.iter().zip(*b).any(|(x, y)| x.abs_diff(*y) > 1))
.map(|(at, (a, b))| {
(
(at % physical.w as usize, at / physical.w as usize),
a.to_vec(),
b.to_vec(),
)
})
.collect::<Vec<_>>();
let bounds = differences.iter().fold(
(usize::MAX, usize::MAX, 0, 0),
|(l, t, r, b), ((x, y), _, _)| (l.min(*x), t.min(*y), r.max(*x), b.max(*y)),
);
eprintln!(
"reused client buffers={buffer_count}, partial frame={frame_index}, different channels={differing}, pixel bounds={bounds:?}, samples={:?}",
&differences[..differences.len().min(8)]
);
if switching_formats {
let rgb_differing = actual
.chunks_exact(4)
.zip(expected.chunks_exact(4))
.filter(|(a, b)| {
a[..3].iter().zip(&b[..3]).any(|(x, y)| x.abs_diff(*y) > 1)
})
.count();
eprintln!(
"same-size SHM first_format={first_format:?} attached_format={} cached_texture_format={cached_format:?} fresh_texture_format={fresh_format:?} RGB-mismatching-pixels={rgb_differing}",
buffers[frame_index % buffer_count].format
);
}
}
if !switching_formats && let Some((rect, color)) = changed {
let x = ((fixed_geometry.x + f64::from(rect.loc.x) + f64::from(rect.size.w) / 2.)
* scale) as usize;
let y = ((fixed_geometry.y + f64::from(rect.loc.y) + f64::from(rect.size.h) / 2.)
* scale) as usize;
let at = (y * physical.w as usize + x) * 4;
let cpu_rgba = [(color >> 16) as u8, (color >> 8) as u8, color as u8, 255];
assert_eq!(
&actual[at..at + 4],
&cpu_rgba,
"partial upload missed CPU raster at frame={frame_index}"
);
}
state.render.forget_context(&oracle_context);
}
assert_eq!(partial_frames, 23);
if !switching_formats {
assert!(total_changed_bytes < (WIDTH * HEIGHT * 4 * partial_frames as u32) as i32);
}
eprintln!(
"stable scale={scale}, reused wl_buffers={buffer_count}: 24 age-3 frames, {partial_frames} partial commits, {total_changed_bytes} locally written SHM bytes, 24 fresh-context full-upload oracle frames"
);
}
assert_eq!(
mismatching_frames, 0,
"partial SHM frames differ from complete fresh uploads"
);
}
@@ -0,0 +1,341 @@
//! Exercise the display scene's damage history, independently of capture repainting.
use super::*;
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::element::{Element, RenderElement};
use smithay::backend::renderer::{Color32F, Frame, Renderer, Texture};
use smithay::utils::{Physical, Size};
fn commit_raster(
state: &mut Ferese,
events: &mut EventLoop<'static, Ferese>,
window: &Window,
wire: &mut UnixStream,
object: &mut u32,
size: (u32, u32),
phase: u32,
) {
let client = window.toplevel().unwrap().wl_surface().client().unwrap();
let shm = client
.create_resource::<WlShm, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
let compositor = client
.create_resource::<WlCompositor, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
6,
smithay::wayland::GlobalData,
)
.unwrap();
let (width, height) = size;
// A high-contrast grid catches stale/cropped pixels, unlike a solid window.
let mut raster = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height {
for x in 0..width {
let color = if (x / 13 + y / 11 + phase) % 2 == 0 {
0xff26b976_u32
} else {
0xffd44381_u32
};
raster.extend_from_slice(&color.to_ne_bytes());
}
}
let mut file = tempfile::tempfile().unwrap();
file.write_all(&raster).unwrap();
request(
wire,
shm.id().protocol_id(),
0,
&[*object, width * height * 4],
Some(file.as_raw_fd()),
);
request(
wire,
*object,
0,
&[*object + 1, 0, width, height, width * 4, 0],
None,
);
request(wire, 3, 3, &[0, 0, width, height], None);
request(wire, 2, 1, &[*object + 1, 0, 0], None);
// Real opaque desktop clients declare an opaque region. Without it the
// damage tracker cannot exercise the rounded element's occlusion path.
request(wire, compositor.id().protocol_id(), 1, &[*object + 2], None);
request(wire, *object + 2, 1, &[0, 0, width, height], None);
request(wire, 2, 4, &[*object + 2], None);
request(wire, *object + 2, 0, &[], None);
request(wire, 2, 2, &[0, 0, width, height], None);
request(wire, 2, 6, &[], None);
*object += 3;
dispatch(events, state);
}
fn read_pixels(renderer: &mut GlesRenderer, texture: &mut GlesTexture) -> Vec<u8> {
let size = texture.size();
let target = renderer.bind(texture).unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size(size), Fourcc::Abgr8888)
.unwrap();
renderer.map_texture(&mapping).unwrap().to_vec()
}
#[test]
#[ignore = "requires a private Wayland socket and an EGL rendering device"]
fn floating_maximize_handoff_matches_full_repaint_at_buffer_age_three() {
const TEST: &str = "state::capture_privacy::tests::resize_scene_gpu_tests::floating_maximize_handoff_matches_full_repaint_at_buffer_age_three";
if !crate::startup_tests::private_runtime(TEST) {
return;
}
let software = std::env::var_os("FERESE_TEST_EGL_SOFTWARE").is_some();
let device = EGLDevice::enumerate()
.unwrap()
.find(|device| device.is_software() == software)
.expect("requested EGL rendering device");
eprintln!(
"resize scene EGL software={software}, render node={:?}",
device.render_device_path()
);
// SAFETY: This test owns the enumerated device and the fresh EGL context.
let display = unsafe { EGLDisplay::new(device).unwrap() };
// SAFETY: GL work remains on this renderer's owning test thread.
let mut renderer = unsafe { GlesRenderer::new(EGLContext::new(&display).unwrap()).unwrap() };
for scale in [1.0, 1.5] {
let mut events = EventLoop::try_new().unwrap();
let config = crate::config::Config::parse_source(
"animations { enabled #false; }\nlayout { inner-gap 4; outer-gap 4; }\nwindow-rule transient=#false floating=#true\n",
)
.unwrap()
.runtime_config()
.unwrap();
let mut state = Ferese::new(&mut events, Display::new().unwrap(), config).unwrap();
state.animation_test_time = Some(Duration::ZERO);
state.focus_effect.active_opacity = 1.0;
state.focus_effect.inactive_opacity = 1.0;
state.focus_effect.inactive_dim = 0.0;
state.theme_settings.window_radius = 12.0;
let output = Output::new(
"resize-age-three".into(),
PhysicalProperties {
serial_number: String::new(),
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
let physical: Size<i32, Physical> = ((320.0 * scale) as i32, (240.0 * scale) as i32).into();
output.change_current_state(
Some(Mode {
size: physical,
refresh: 60_000,
}),
Some(Transform::Normal),
Some(Scale::Fractional(scale)),
None,
);
state.space.map_output(&output, (0, 0));
state.register_output(&output, "resize-age-three".into());
let (window, mut wire) = window(&mut state, &mut events, 0xff26b976);
let id = state.windows.ids()[&window];
state.focused_window = Some(id);
state.set_floating_window_geometry(&window, (106, 92).into(), (128, 80).into());
state.display_handle.flush_clients().unwrap();
ack_configure(&mut wire, 3);
let mut object = 7;
commit_raster(
&mut state,
&mut events,
&window,
&mut wire,
&mut object,
(128, 80),
0,
);
state.windows.update(id, |record| record.opening = None);
state.animations_enabled = true;
let mut tracker = OutputDamageTracker::from_output(&output);
let mut targets: Vec<GlesTexture> = (0..3)
.map(|_| {
renderer
.create_buffer(Fourcc::Abgr8888, (physical.w, physical.h).into())
.unwrap()
})
.collect();
let mut reference: GlesTexture = renderer
.create_buffer(Fourcc::Abgr8888, (physical.w, physical.h).into())
.unwrap();
let mut frame_index = 0;
let mut now = Duration::ZERO;
let mut snapshot_frames = 0;
let mut fill_frames = 0;
let mut moving_frames = 0;
let mut repaint_frames = 0;
let mut native_draw_frames = 0;
let mut opaque_frames = 0;
// Delayed commits exercise both old-frame cropping on shrink and neutral
// fill on grow. Repeating in both directions also reuses every target.
for (cycle, delay) in [3, 1, 5, 0].into_iter().enumerate() {
state.set_window_maximized(id, cycle % 2 == 0);
state.display_handle.flush_clients().unwrap();
let (_, configured) = ack_configure_with_size(&mut wire, 3, Some(4));
let requested = configured.expect("maximize/restore configure");
for tick in 0..28 {
if tick == delay {
// The offscreen fixture has no DirectBackend. Invoke the
// same capture-before-release operation used by that backend.
let snapshot = crate::render::capture_resize_snapshot(
&mut renderer,
&window,
window.geometry(),
scale,
32 * 1024 * 1024,
)
.unwrap()
.expect("old client pixels");
state.render.set_snapshot(id, snapshot);
commit_raster(
&mut state,
&mut events,
&window,
&mut wire,
&mut object,
requested,
cycle as u32 + 1,
);
repaint_frames += 1;
} else if tick > delay && tick % 2 == 0 {
// IDE repainting alternates with unchanged client buffers,
// so both client damage and compositor-only motion matter.
commit_raster(
&mut state,
&mut events,
&window,
&mut wire,
&mut object,
requested,
cycle as u32 + tick as u32,
);
repaint_frames += 1;
}
now += Duration::from_millis(16);
state.advance_animations_at(Duration::from_millis(16), now);
let scene = state.sample_frame(&output, Duration::from_millis(5));
let sample = &scene.windows[&id];
assert_eq!(sample.presentation.alpha(), 1.0, "ordinary opaque window");
if sample.geometry.visual.current != sample.geometry.visual.target {
moving_frames += 1;
}
snapshot_frames += usize::from(state.render.snapshot(&id).is_some());
let source = window.geometry().size;
let visual = sample.presentation.bounds.current;
fill_frames += usize::from(
f64::from(source.w) < visual.width || f64::from(source.h) < visual.height,
);
let elements = crate::render::sampled_output_elements(
&mut state,
&mut renderer,
&output,
false,
&scene,
);
native_draw_frames +=
usize::from(state.render.snapshot(&id).is_some_and(|snapshot| {
elements.iter().any(|element| element.id() == &snapshot.id)
}));
opaque_frames += usize::from(
elements
.iter()
.any(|element| !element.opaque_regions(scale.into()).is_empty()),
);
assert!(
elements
.iter()
.all(|element| !element.is_framebuffer_effect()),
"this regression must cover opaque resize without backdrop blur"
);
let slot = frame_index % 3;
{
let mut target = renderer.bind(&mut targets[slot]).unwrap();
tracker
.render_output(
&mut renderer,
&mut target,
if frame_index < 3 { 0 } else { 3 },
&elements,
Color32F::new(0.035, 0.04, 0.055, 1.0),
)
.unwrap()
.sync
.wait()
.unwrap();
}
// Independent oracle: no damage history, no opaque-region
// culling, and every layer painted back-to-front into a clean FBO.
{
let mut target = renderer.bind(&mut reference).unwrap();
let mut frame = renderer
.render(&mut target, physical, Transform::Normal)
.unwrap();
frame
.clear(
Color32F::new(0.035, 0.04, 0.055, 1.0),
&[Rectangle::from_size(physical)],
)
.unwrap();
for element in elements.iter().rev() {
let rect = element.geometry(scale.into());
element
.draw(
&mut frame,
element.src(),
rect,
&[Rectangle::from_size(rect.size)],
&[],
None,
)
.unwrap();
}
frame.finish().unwrap().wait().unwrap();
}
let actual = read_pixels(&mut renderer, &mut targets[slot]);
let expected = read_pixels(&mut renderer, &mut reference);
let differing = actual
.iter()
.zip(&expected)
.filter(|(a, b)| a.abs_diff(**b) > 1)
.count();
assert_eq!(
differing, 0,
"scale={scale} cycle={cycle} tick={tick} frame={frame_index} source={source:?} visual={visual:?}"
);
frame_index += 1;
}
}
assert!(snapshot_frames > 20, "resize handoff was not exercised");
assert!(
fill_frames > 0,
"growing native content never needed resize fill"
);
eprintln!(
"opaque resize scale={scale}: {frame_index} age-3 frames, {snapshot_frames} snapshot frames, {native_draw_frames} snapshot draw frames, {fill_frames} fill frames, {moving_frames} moving frames, {repaint_frames} client repaint frames, {opaque_frames} opaque frames"
);
assert!(moving_frames > 20, "maximize/restore animation did not run");
assert!(
repaint_frames > 40,
"client repainting did not overlap motion"
);
assert!(
native_draw_frames > 20,
"retained snapshots were not in the display scene"
);
assert!(
opaque_frames > 20,
"opaque-region culling was not exercised"
);
}
}
+4 -2
View File
@@ -27,7 +27,7 @@ use std::sync::Arc;
const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5); const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5);
#[derive(Clone, Debug, Deserialize, PartialEq)] #[derive(Clone, Debug, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize)]
pub(crate) struct WallpaperConfig { pub(crate) struct WallpaperConfig {
#[serde(default = "default_wallpaper_path")] #[serde(default = "default_wallpaper_path")]
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
@@ -48,7 +48,9 @@ impl Default for WallpaperConfig {
} }
} }
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] #[derive(
Clone, Copy, Debug, Default, Deserialize, PartialEq, schemars::JsonSchema, serde::Serialize,
)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub(crate) enum WallpaperMode { pub(crate) enum WallpaperMode {
#[default] #[default]
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, schemars::JsonSchema, serde::Serialize)]
pub(crate) struct WindowRuleConfig { pub(crate) struct WindowRuleConfig {
pub app_id: Option<String>, pub app_id: Option<String>,
pub title: Option<String>, pub title: Option<String>,
+1
View File
@@ -271,6 +271,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
impl Ferese { impl Ferese {
pub(crate) fn release_nested_keyboard(&mut self) { pub(crate) fn release_nested_keyboard(&mut self) {
self.super_tap.reset();
// Host shortcuts can consume releases while this window is unfocused. // Host shortcuts can consume releases while this window is unfocused.
// Release only the physical/nested source, including modifiers, without // Release only the physical/nested source, including modifiers, without
// executing compositor shortcuts. Auxiliary keyboards retain ownership. // executing compositor shortcuts. Auxiliary keyboards retain ownership.
+1 -1
View File
@@ -1778,7 +1778,7 @@ mod manager_loop {
state state
.reload_config_source( .reload_config_source(
"xwayland { enabled #true; startup \"on-demand\"; path \"".to_owned() "xwayland { enabled #true; startup \"on-demand\"; path \"".to_owned()
+ &wrapper(&directory, &[]).display().to_string() + wrapper(&directory, &[]).display().to_string().as_str()
+ "\"; }", + "\"; }",
) )
.expect("the reload must be accepted"); .expect("the reload must be accepted");
+1
View File
@@ -9,4 +9,5 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
serde.workspace = true serde.workspace = true
+4 -4
View File
@@ -3,7 +3,7 @@
use crate::Time; use crate::Time;
use serde::{Deserialize, Serialize}; 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)] #[serde(default, deny_unknown_fields)]
pub struct Settings { pub struct Settings {
pub work_minutes: u16, pub work_minutes: u16,
@@ -46,7 +46,7 @@ impl Settings {
}) * 60_000 }) * 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")] #[serde(rename_all = "kebab-case")]
pub enum Phase { pub enum Phase {
Work, 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")] #[serde(rename_all = "kebab-case")]
pub enum Status { pub enum Status {
Running, Running,
@@ -71,7 +71,7 @@ pub enum Status {
Ready, Ready,
Finished, Finished,
} }
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
pub struct Session { pub struct Session {
pub settings: Settings, pub settings: Settings,
pub phase: Phase, 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_TEXT: usize = 256;
pub const MAX_LEASE_MS: u64 = 300_000; 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)] #[serde(deny_unknown_fields)]
pub struct Owner { pub struct Owner {
pub pid: u32, pub pid: u32,
pub birth: u64, 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)] #[serde(deny_unknown_fields)]
pub struct Id { pub struct Id {
pub owner: Owner, pub owner: Owner,
@@ -23,7 +27,7 @@ pub struct Id {
pub session: u64, pub session: u64,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde( #[serde(
tag = "kind", tag = "kind",
content = "value", content = "value",
@@ -52,7 +56,7 @@ impl Value {
} }
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde( #[serde(
tag = "kind", tag = "kind",
content = "data", content = "data",
@@ -66,7 +70,7 @@ pub enum Template {
List { rows: Vec<String> }, 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")] #[serde(rename_all = "kebab-case")]
pub enum Source { pub enum Source {
Publisher, Publisher,
@@ -79,7 +83,7 @@ pub enum Source {
Process, Process,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum State { pub enum State {
Running, Running,
@@ -88,7 +92,7 @@ pub enum State {
Cancelled, Cancelled,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Data { pub struct Data {
pub source: Source, 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)] #[serde(default, deny_unknown_fields)]
pub struct Limits { pub struct Limits {
pub max_visible: usize, 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 struct Activity {
pub id: Id, pub id: Id,
pub data: Data, pub data: Data,
+1
View File
@@ -9,5 +9,6 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
ferese-layout = { path = "../ferese-layout" } ferese-layout = { path = "../ferese-layout" }
serde.workspace = true serde.workspace = true
+13 -2
View File
@@ -1,7 +1,9 @@
use crate::{CrossingPolicy, SpringConfig}; use crate::{CrossingPolicy, SpringConfig};
/// One configuration contract for compositor and out-of-process shell motion. /// 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)] #[serde(default, deny_unknown_fields)]
pub struct MotionSettings { pub struct MotionSettings {
pub enabled: bool, 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)] #[serde(default, deny_unknown_fields)]
pub struct SpringSettings { pub struct SpringSettings {
pub duration_ms: Option<f64>, pub duration_ms: Option<f64>,
+2
View File
@@ -9,11 +9,13 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
ferese-activities = { path = "../ferese-activities" } ferese-activities = { path = "../ferese-activities" }
regex = "1.12" regex = "1.12"
ferese-panel = { path = "../ferese-panel" } ferese-panel = { path = "../ferese-panel" }
ferese-theme-model = { path = "../ferese-theme-model" } ferese-theme-model = { path = "../ferese-theme-model" }
jiff.workspace = true jiff.workspace = true
rhai = { version = "1.27", features = ["sync"] }
kdl = "6.7.1" kdl = "6.7.1"
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
+15
View File
@@ -0,0 +1,15 @@
# Japanese holiday function and official reference
Source: Cabinet Office, [国民の祝日について](https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html).
- CSV: https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv
- Retrieved: 2026-10-28
- Original SHA-256: `cec37a743c96995cdb9cb52b685c9003634682a9b0e1a640a6b9b96881fe964a`
- Coverage: 1955-01-01 through 2027-11-23
- Conversion: CP932 to UTF-8; dates normalized to YYYY-MM-DD; header removed.
`japanese-holidays.rhai` is the editable, built-in runtime data source. It calculates 1955–2099 Japanese holidays, including historical rules and one-off exceptions. Unpublished years are estimates; equinox holidays become official after publication in the government gazette. See [NAOJ](https://www.nao.ac.jp/faq/a0301.html).
The CSV is an independent regression reference, including substitute holidays and holidays between national holidays. The full function output is checked against every date and name for 1955–2027. To refresh the reference, download the official CSV, apply the same conversion, update exceptions in the function as needed, and rerun calendar tests.
Source terms: [Cabinet Office website terms](https://www.cao.go.jp/notice/rule.html).
@@ -0,0 +1,82 @@
// 日本祝日计算函数。返回 YYYY-MM-DD → 节假日名称的映射。
// weekday(date) 的周日为 0;date(year, month, day) 与 add_days(date, n) 生成日期。
// 规则适用 1955–2099 年;未公布年份的春秋分日为近似计算。
fn monday(year, month, nth) {
let first = date(year, month, 1);
1 + (8 - weekday(first)) % 7 + 7 * (nth - 1)
}
fn japanese_holidays(year) {
let h = #{};
if year < 1955 || year > 2099 { return h; }
h[date(year, 1, 1)] = "元日";
h[date(year, 1, if year >= 2000 { monday(year, 1, 2) } else { 15 })] = "成人の日";
if year >= 1967 { h[date(year, 2, 11)] = "建国記念の日"; }
if year >= 2020 { h[date(year, 2, 23)] = "天皇誕生日"; }
if year >= 1989 && year <= 2018 { h[date(year, 12, 23)] = "天皇誕生日"; }
h[date(year, 4, 29)] = if year >= 2007 { "昭和の日" }
else if year >= 1989 { "みどりの日" } else { "天皇誕生日" };
// 春分・秋分近似式。官报公布前的将来日期仅为推算。
let spring_base = if year < 1980 { 20.8357 } else { 20.8431 };
let autumn_base = if year < 1980 { 23.2588 } else { 23.2488 };
let leap_base = if year < 1980 { 1983 } else { 1980 };
let shift = 0.242194 * (year - 1980) - (year - leap_base) / 4;
h[date(year, 3, floor(spring_base + shift))] = "春分の日";
h[date(year, 9, floor(autumn_base + shift))] = "秋分の日";
h[date(year, 5, 3)] = "憲法記念日";
if year >= 2007 { h[date(year, 5, 4)] = "みどりの日"; }
h[date(year, 5, 5)] = "こどもの日";
h[date(year, 11, 3)] = "文化の日";
h[date(year, 11, 23)] = "勤労感謝の日";
if year >= 1966 {
h[date(year, 9, if year >= 2003 { monday(year, 9, 3) } else { 15 })] = "敬老の日";
let sport = if year == 2020 { date(year, 7, 24) }
else if year == 2021 { date(year, 7, 23) }
else { date(year, 10, if year >= 2000 { monday(year, 10, 2) } else { 10 }) };
h[sport] = if year >= 2020 { "スポーツの日" }
else if year == 2019 { "体育の日(スポーツの日)" } else { "体育の日" };
}
if year >= 1996 {
let sea = if year == 2020 { date(year, 7, 23) }
else if year == 2021 { date(year, 7, 22) }
else { date(year, 7, if year >= 2003 { monday(year, 7, 3) } else { 20 }) };
h[sea] = "海の日";
}
if year >= 2016 {
h[date(year, 8, if year == 2020 { 10 } else if year == 2021 { 8 } else { 11 })] = "山の日";
}
// 即位・成婚等の特例。
if year == 1959 { h[date(year, 4, 10)] = "結婚の儀"; }
if year == 1989 { h[date(year, 2, 24)] = "大喪の礼"; }
if year == 1990 { h[date(year, 11, 12)] = "即位礼正殿の儀"; }
if year == 1993 { h[date(year, 6, 9)] = "結婚の儀"; }
if year == 2019 {
h[date(year, 5, 1)] = "休日(祝日扱い)";
h[date(year, 10, 22)] = "休日(祝日扱い)";
}
let national = h;
let first = date(year, 1, 1);
// 祝日に挟まれた日:1986 年から適用、日曜日は対象外。
if year >= 1986 {
for day in 0..days_in_year(year) {
let d = add_days(first, day);
if weekday(d) != 0 && !h.contains(d)
&& national.contains(add_days(d, -1)) && national.contains(add_days(d, 1)) {
h[d] = "休日";
}
}
}
// 振替休日:1973 年導入、2007 年以降は次の祝日でない日まで繰り越す。
for d in national.keys() {
if d >= "1973-04-12" && weekday(d) == 0 {
let substitute = add_days(d, 1);
if year >= 2007 {
while h.contains(substitute) { substitute = add_days(substitute, 1); }
}
if !h.contains(substitute) { h[substitute] = "休日"; }
}
}
h
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
use crate::Error; use crate::Error;
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Settings { pub struct Settings {
pub max_visible: usize, 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)] #[serde(default, deny_unknown_fields)]
pub struct Sources { pub struct Sources {
pub shell_hook: bool, pub shell_hook: bool,
pub process_watch: bool, pub process_watch: bool,
pub notification_bridge: bool, pub notification_bridge: bool,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Rule { pub struct Rule {
pub name: String, pub name: String,
@@ -59,7 +59,7 @@ pub struct Rule {
pub flash_on_fail: bool, pub flash_on_fail: bool,
pub result: ResultText, pub result: ResultText,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ResultText { pub struct ResultText {
pub ok: String, pub ok: String,
+202
View File
@@ -0,0 +1,202 @@
//! Editable Japanese holiday calculations and user-authored calendar dates.
pub use crate::calendar_rules::{DEFAULT_JAPANESE_HOLIDAY_FUNCTION, japanese_holidays};
use jiff::civil::Date;
use serde::{Deserialize, Serialize};
#[cfg(test)]
use std::{collections::BTreeMap, sync::OnceLock};
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub show_adjacent_days: bool,
pub custom_holidays: Vec<Holiday>,
pub japanese_holiday_function: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
show_adjacent_days: true,
custom_holidays: Vec::new(),
japanese_holiday_function: DEFAULT_JAPANESE_HOLIDAY_FUNCTION.into(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Holiday {
/// YYYY-MM-DD for one date, or MM-DD for an annual holiday.
pub date: String,
pub name: String,
}
impl Holiday {
pub fn validate(&self) -> Result<(), String> {
let valid = match self.date.len() {
10 => self.date.parse::<Date>().is_ok(),
5 => format!("2000-{}", self.date).parse::<Date>().is_ok(),
_ => false,
};
if !valid {
return Err("Holiday date must be YYYY-MM-DD or MM-DD.".into());
}
if self.name.trim().is_empty() || self.name.chars().count() > 80 {
return Err("Holiday name must contain 1–80 characters.".into());
}
Ok(())
}
fn matches(&self, date: Date) -> bool {
self.date == date.to_string()
|| self.date == format!("{:02}-{:02}", date.month(), date.day())
}
}
impl Settings {
pub fn validate(&self) -> Result<(), String> {
for holiday in &self.custom_holidays {
holiday.validate()?;
}
validate_japanese_holiday_function(&self.japanese_holiday_function)
}
/// Custom names are combined with official names, preserving both on collisions.
#[must_use]
pub fn holiday_name(&self, date: Date) -> Option<String> {
let mut names: Vec<String> =
japanese_holidays(&self.japanese_holiday_function, date.year())
.ok()
.and_then(|days| days.get(&date).cloned())
.into_iter()
.collect();
for holiday in &self.custom_holidays {
if holiday.matches(date) && !names.contains(&holiday.name) {
names.push(holiday.name.clone());
}
}
(!names.is_empty()).then(|| names.join(" · "))
}
}
#[must_use]
#[cfg(test)]
fn official_japanese_holiday(date: Date) -> Option<&'static str> {
static HOLIDAYS: OnceLock<BTreeMap<&'static str, &'static str>> = OnceLock::new();
HOLIDAYS
.get_or_init(|| {
include_str!("../data/jp-holidays.csv")
.lines()
.filter_map(|line| line.split_once(','))
.collect()
})
.get(date.to_string().as_str())
.copied()
}
pub fn validate_japanese_holiday_function(source: &str) -> Result<(), String> {
crate::calendar_rules::validate(source)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_dates_round_trip_through_editable_kdl_records() {
let mut doc =
crate::Document::parse("status { calendar { show-adjacent-days #false; }; }\n")
.unwrap();
doc.add(
"status.calendar.custom_holidays",
vec![
("date".into(), "12-24".into()),
("name".into(), "个人假日".into()),
],
)
.unwrap();
let source = doc.to_string();
let mut doc = crate::Document::parse(&source).unwrap();
let status: crate::status::Settings =
serde_json::from_value(doc.get("status").unwrap().clone()).unwrap();
status.validate().unwrap();
assert!(!status.calendar.show_adjacent_days);
assert_eq!(
status
.calendar
.holiday_name("2027-12-24".parse().unwrap())
.as_deref(),
Some("个人假日")
);
doc.set(
"status.calendar.custom_holidays.0.date",
"2026-02-30".into(),
)
.unwrap();
let status: crate::status::Settings =
serde_json::from_value(doc.get("status").unwrap().clone()).unwrap();
assert!(status.validate().is_err());
doc.remove("status.calendar.custom_holidays", 0).unwrap();
assert!(
doc.get("status.calendar.custom_holidays")
.is_none_or(|v| v.as_array().is_some_and(|v| v.is_empty()))
);
}
#[test]
fn official_dates_include_substitutes_bridges_and_olympic_exceptions() {
for (date, expected) in [
("2026-05-06", Some("休日")),
("2026-09-22", Some("休日")),
("2027-03-22", Some("休日")),
("2021-07-22", Some("海の日")),
("2021-07-19", None),
("2026-10-28", None),
("2028-01-01", None),
] {
assert_eq!(official_japanese_holiday(date.parse().unwrap()), expected);
}
let dates: Vec<_> = include_str!("../data/jp-holidays.csv")
.lines()
.map(|s| s.split_once(',').unwrap().0)
.collect();
assert!(dates.windows(2).all(|pair| pair[0] < pair[1]));
assert!(dates.iter().all(|date| date.parse::<Date>().is_ok()));
assert_eq!(dates.iter().filter(|d| d.starts_with("2026-")).count(), 18);
assert_eq!(dates.iter().filter(|d| d.starts_with("2027-")).count(), 17);
}
#[test]
fn custom_holidays_validate_and_repeat_without_inventing_leap_days() {
let mut settings = Settings::default();
assert!(settings.show_adjacent_days);
settings.custom_holidays = vec![
Holiday {
date: "02-29".into(),
name: "Leap day".into(),
},
Holiday {
date: "2026-11-03".into(),
name: "My holiday".into(),
},
];
assert!(settings.validate().is_ok());
assert_eq!(
settings
.holiday_name("2024-02-29".parse().unwrap())
.as_deref(),
Some("Leap day")
);
assert_eq!(
settings
.holiday_name("2026-11-03".parse().unwrap())
.as_deref(),
Some("文化の日 · My holiday")
);
for date in ["2026-02-29", "13-01", "2026-1-1", "02-30"] {
assert!(
Holiday {
date: date.into(),
name: "Test".into()
}
.validate()
.is_err()
);
}
}
}
+206
View File
@@ -0,0 +1,206 @@
use jiff::civil::Date;
use rhai::{AST, Engine, EvalAltResult, INT, ImmutableString, Scope};
use std::{
collections::BTreeMap,
sync::{Arc, Mutex, OnceLock},
};
pub const DEFAULT_JAPANESE_HOLIDAY_FUNCTION: &str = include_str!("../data/japanese-holidays.rhai");
pub type Holidays = Arc<BTreeMap<Date, String>>;
fn evaluation_error(error: &EvalAltResult) -> String {
match error.unwrap_inner() {
EvalAltResult::ErrorTooManyOperations(_) => {
"Holiday function exceeded its operation limit.".into()
}
EvalAltResult::ErrorStackOverflow(_) => {
"Holiday function exceeded its call depth limit.".into()
}
_ => error.to_string(),
}
}
fn script_date(value: &str) -> Result<Date, Box<EvalAltResult>> {
value
.parse::<Date>()
.map_err(|_| format!("Invalid calendar date: {value}").into())
}
fn engine() -> Engine {
let mut engine = Engine::new();
engine.set_module_resolver(rhai::module_resolvers::DummyModuleResolver::new());
for symbol in ["import", "export", "eval", "print", "debug"] {
engine.disable_symbol(symbol);
}
engine
.set_max_operations(100_000)
.set_max_call_levels(16)
.set_max_expr_depths(32, 32)
.set_max_variables(64)
.set_max_functions(16)
.set_max_string_size(1024)
.set_max_array_size(400)
.set_max_map_size(400);
engine.on_print(|_| {}).on_debug(|_, _, _| {});
engine.register_fn(
"date",
|year: INT, month: INT, day: INT| -> Result<String, Box<EvalAltResult>> {
script_date(&format!("{year:04}-{month:02}-{day:02}")).map(|d| d.to_string())
},
);
engine.register_fn(
"weekday",
|date: ImmutableString| -> Result<INT, Box<EvalAltResult>> {
script_date(&date).map(|d| INT::from(d.weekday().since(jiff::civil::Weekday::Sunday)))
},
);
engine.register_fn(
"add_days",
|date: ImmutableString, days: INT| -> Result<String, Box<EvalAltResult>> {
if !(-3660..=3660).contains(&days) {
return Err("Calendar date offset must be within ten years.".into());
}
script_date(&date)?
.checked_add(jiff::Span::new().days(days))
.map(|d| d.to_string())
.map_err(|error| error.to_string().into())
},
);
engine.register_fn("days_in_year", |year: INT| -> INT {
if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
366
} else {
365
}
});
// Converting float -> integer is explicit, keeping the editable equinox formula readable.
engine.register_fn("floor", |value: rhai::FLOAT| -> INT {
value.floor() as INT
});
engine
}
struct Compiled {
source: String,
engine: Engine,
ast: Result<AST, String>,
years: BTreeMap<i16, Result<Holidays, String>>,
}
/// Bounded, offline evaluation, compiled once per source and cached per visible year.
/// Scripts receive only civil-date helpers; imports, eval and host I/O are unavailable.
pub fn japanese_holidays(source: &str, year: i16) -> Result<Holidays, String> {
if source.len() > 16_384 {
return Err("Holiday function exceeds 16 KiB.".into());
}
static CACHE: OnceLock<Mutex<Option<Compiled>>> = OnceLock::new();
let mut cache = CACHE
.get_or_init(|| Mutex::new(None))
.lock()
.map_err(|_| "Holiday function cache is unavailable.")?;
if cache.as_ref().is_none_or(|cached| cached.source != source) {
let engine = engine();
let ast = engine
.compile(source)
.map_err(|error| format!("Holiday function: {error}"));
*cache = Some(Compiled {
source: source.into(),
engine,
ast,
years: BTreeMap::new(),
});
}
let cache = cache.as_mut().unwrap();
if let Some(result) = cache.years.get(&year) {
return result.clone();
}
let result = (|| {
let ast = cache.ast.as_ref().map_err(Clone::clone)?;
let result = cache
.engine
.call_fn::<rhai::Map>(
&mut Scope::new(),
ast,
"japanese_holidays",
(INT::from(year),),
)
.map_err(|error| evaluation_error(&error))?;
let mut holidays = BTreeMap::new();
for (date, name) in result {
let date = date
.parse::<Date>()
.map_err(|_| "Holiday function must return YYYY-MM-DD keys.")?;
if date.year() != year {
return Err("Holiday function returned a date outside the requested year.".into());
}
let name = name
.try_cast::<ImmutableString>()
.ok_or("Holiday function must return text names.")?;
if name.trim().is_empty() || name.chars().count() > 80 {
return Err("Holiday name must contain 1–80 characters.".into());
}
holidays.insert(date, name.to_string());
}
Ok(Arc::new(holidays))
})();
if cache.years.len() >= 8 {
cache.years.clear();
}
cache.years.insert(year, result.clone());
result
}
pub fn validate(source: &str) -> Result<(), String> {
let year = jiff::Zoned::now().year();
for year in [year - 1, year, year + 1] {
japanese_holidays(source, year)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_function_matches_every_official_date_and_name_through_2027() {
let mut official: BTreeMap<i16, BTreeMap<Date, String>> = BTreeMap::new();
for line in include_str!("../data/jp-holidays.csv").lines() {
let (date, name) = line.split_once(',').unwrap();
let date = date.parse::<Date>().unwrap();
official
.entry(date.year())
.or_default()
.insert(date, name.into());
}
for (year, expected) in official {
let actual = japanese_holidays(DEFAULT_JAPANESE_HOLIDAY_FUNCTION, year).unwrap();
assert_eq!(*actual, expected, "official reference mismatch in {year}");
}
}
#[test]
fn edited_function_changes_dates_and_invalid_or_unbounded_code_is_rejected() {
let source =
r#"fn japanese_holidays(year) { let h=#{}; h[date(year,10,28)]="测试假日"; h }"#;
let result = japanese_holidays(source, 2026).unwrap();
assert_eq!(
result
.get(&"2026-10-28".parse::<Date>().unwrap())
.map(String::as_str),
Some("测试假日")
);
for source in [
"fn japanese_holidays(year) { loop {} }",
"import \"/etc/passwd\" as x; fn japanese_holidays(year) { #{} }",
"fn japanese_holidays(year) { eval(\"1+1\"); #{} }",
"fn japanese_holidays(year) { #{invalid: 3} }",
"fn japanese_holidays(year) { #{\"2026-02-30\":\"Bad date\"} }",
"fn japanese_holidays(year) { #{\"2025-01-01\":\"Wrong year\"} }",
] {
assert!(
japanese_holidays(source, 2026).is_err(),
"accepted invalid source {source}"
);
}
assert!(japanese_holidays(&" ".repeat(16_385), 2026).is_err());
}
}
+212
View File
@@ -72,6 +72,7 @@ fn field(name: &str, parent: &str) -> String {
} }
match name { match name {
"rule" if parent == "activities" => "rules".into(),
"binding" => "bindings".into(), "binding" => "bindings".into(),
"binding-mode" => "binding_modes".into(), "binding-mode" => "binding_modes".into(),
"window-rule" => "window_rules".into(), "window-rule" => "window_rules".into(),
@@ -99,6 +100,8 @@ fn is_records(key: &str, parent: &str) -> bool {
| ("binding_modes", "bindings") | ("binding_modes", "bindings")
| ("start" | "center" | "end", "groups") | ("start" | "center" | "end", "groups")
| ("groups", "items") | ("groups", "items")
| ("activities", "rules")
| ("calendar", "custom_holidays")
) )
} }
@@ -112,6 +115,8 @@ fn is_array(key: &str, parent: &str) -> bool {
| "width_presets" | "width_presets"
| "xkb_options" | "xkb_options"
| "settings_command" | "settings_command"
| "hidden_items"
| "ignore_parents"
| "outputs" | "outputs"
) )
} }
@@ -208,6 +213,7 @@ pub(crate) fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Valu
} }
let positional: &[&str] = match key { let positional: &[&str] = match key {
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"custom_holidays" => &["date", "name"],
"binding_modes" => &["name"], "binding_modes" => &["name"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"outputs" => &["match"], "outputs" => &["match"],
@@ -330,6 +336,50 @@ impl Document {
Some(value) 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(())
}
/// 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(())
@@ -535,6 +585,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) { fn collect_comments(node: &KdlNode, output: &mut String) {
let mut keep = |text: &str| { let mut keep = |text: &str| {
if text.contains("//") || text.contains("/*") { if text.contains("//") || text.contains("/*") {
@@ -813,6 +950,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) ->
if rest.len() == 1 { if rest.len() == 1 {
let positions: &[&str] = match key { let positions: &[&str] = match key {
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"custom_holidays" => &["date", "name"],
"binding_modes" => &["name"], "binding_modes" => &["name"],
"outputs" => &["match"], "outputs" => &["match"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
@@ -917,6 +1055,7 @@ fn node_name(key: &str, parent: &str) -> String {
"panels" => "panel".into(), "panels" => "panel".into(),
"groups" => "group".into(), "groups" => "group".into(),
"items" => "item".into(), "items" => "item".into(),
"rules" if parent == "activities" => "rule".into(),
_ => key.replace('_', "-"), _ => key.replace('_', "-"),
} }
} }
@@ -927,7 +1066,9 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
match value { match value {
Value::Object(map) => { Value::Object(map) => {
let positions: &[&str] = match key { let positions: &[&str] = match key {
"rules" if parent == "activities" => &["name"],
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"custom_holidays" => &["date", "name"],
"binding_modes" => &["name"], "binding_modes" => &["name"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"outputs" => &["match"], "outputs" => &["match"],
@@ -947,6 +1088,11 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
} }
for (name, value) in map { 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()) { if consumed.contains(&name.as_str()) {
continue; continue;
} }
@@ -959,6 +1105,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
} }
} else if is_records(key, parent) } else if is_records(key, parent)
&& map.len() <= 4 && map.len() <= 4
&& !(key == "rules" && parent == "activities")
&& !value.is_array() && !value.is_array()
&& !value.is_object() && !value.is_object()
{ {
@@ -1007,6 +1154,71 @@ 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]
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] #[test]
fn named_binding_modes_round_trip_and_keep_nested_bindings_editable() { fn named_binding_modes_round_trip_and_keep_nested_bindings_editable() {
let mut document = super::Document::parse( let mut document = super::Document::parse(
+2 -1
View File
@@ -1,13 +1,14 @@
use crate::theme::Appearance; use crate::theme::Appearance;
pub use ferese_theme_model::families::{Family, Palette}; pub use ferese_theme_model::families::{Family, Palette};
pub const BUILTINS: [(&str, &str, &str, &str); 6] = [ pub const BUILTINS: [(&str, &str, &str, &str); 7] = [
( (
"ferese-blue", "ferese-blue",
"Ferese Blue", "Ferese Blue",
"ferese-blue-light", "ferese-blue-light",
"ferese-blue", "ferese-blue",
), ),
("graphite", "Graphite", "graphite-light", "graphite"),
( (
"catppuccin", "catppuccin",
"Catppuccin", "Catppuccin",
+4
View File
@@ -1,11 +1,15 @@
//! KDL documents, validation and persisted configuration values. //! KDL documents, validation and persisted configuration values.
pub mod activities; pub mod activities;
pub mod calendar;
mod calendar_rules;
mod document; mod document;
pub mod families; pub mod families;
pub mod file; pub mod file;
pub mod locale;
pub mod night_light; pub mod night_light;
pub mod notifications; pub mod notifications;
pub mod presets; pub mod presets;
pub mod status;
pub mod theme; pub mod theme;
pub use document::*; 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}; 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)] #[serde(default, deny_unknown_fields)]
pub struct Settings { pub struct Settings {
pub enabled: bool, pub enabled: bool,
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Deserialize, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct NotificationConfig { pub struct NotificationConfig {
pub show_popups: bool, pub show_popups: bool,
+37 -1
View File
@@ -19,7 +19,7 @@ pub struct Preset {
pub shadow_opacity: f64, pub shadow_opacity: f64,
} }
pub const PRESETS: [Preset; 12] = [ pub const PRESETS: [Preset; 14] = [
Preset { Preset {
appearance: Appearance::Dark, appearance: Appearance::Dark,
id: "ferese-blue", id: "ferese-blue",
@@ -38,6 +38,24 @@ pub const PRESETS: [Preset; 12] = [
tint_strength: 0.5, tint_strength: 0.5,
shadow_opacity: 0.2, shadow_opacity: 0.2,
}, },
Preset {
appearance: Appearance::Dark,
id: "graphite",
name: "Graphite",
description: "Quiet graphite",
base: "#101113",
raised: "#1B1D21",
application_background: "#141518",
text: "#E7E9ED",
muted: "#9A9FA9",
accent: "#8EABD8",
border: "#FFFFFF16",
gradient_end: "#8EABD8",
opacity: 0.96,
blur_radius: 8.0,
tint_strength: 0.06,
shadow_opacity: 0.12,
},
Preset { Preset {
appearance: Appearance::Dark, appearance: Appearance::Dark,
id: "catppuccin-mocha", id: "catppuccin-mocha",
@@ -146,6 +164,24 @@ pub const PRESETS: [Preset; 12] = [
tint_strength: 1.0, tint_strength: 1.0,
shadow_opacity: 0.1, shadow_opacity: 0.1,
}, },
Preset {
appearance: Appearance::Light,
id: "graphite-light",
name: "Graphite",
description: "Quiet paper",
base: "#F7F8FA",
raised: "#FFFFFF",
application_background: "#F1F3F5",
text: "#23262C",
muted: "#626A76",
accent: "#3F6DAA",
border: "#00000018",
gradient_end: "#3F6DAA",
opacity: 0.98,
blur_radius: 4.0,
tint_strength: 0.02,
shadow_opacity: 0.08,
},
Preset { Preset {
appearance: Appearance::Light, appearance: Appearance::Light,
id: "catppuccin-latte", id: "catppuccin-latte",
+52
View File
@@ -0,0 +1,52 @@
//! 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,
pub calendar: crate::calendar::Settings,
}
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(),
calendar: Default::default(),
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), String> {
self.calendar.validate()?;
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![],
}
}
}
+10 -8
View File
@@ -12,7 +12,7 @@ pub use ferese_theme_model::*;
pub const TRANSITION_MS: u64 = 250; pub const TRANSITION_MS: u64 = 250;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
#[serde(default)] #[serde(default)]
pub struct Selection { pub struct Selection {
pub family: Option<String>, 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")] #[serde(rename_all = "kebab-case")]
pub enum AutoSource { pub enum AutoSource {
System, System,
@@ -47,7 +49,7 @@ pub struct AutoContext {
pub system: Option<Appearance>, pub system: Option<Appearance>,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
#[serde(default)] #[serde(default)]
pub struct Schedule { pub struct Schedule {
pub source: AutoSource, 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 struct CustomTheme {
pub file: PathBuf, pub file: PathBuf,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
#[serde(default)] #[serde(default)]
pub struct Policy { pub struct Policy {
pub family: Option<String>, pub family: Option<String>,
@@ -1356,8 +1358,8 @@ mod tests {
.iter() .iter()
.filter(|p| p.appearance == Appearance::Light) .filter(|p| p.appearance == Appearance::Light)
.collect(); .collect();
assert_eq!(dark.len(), 6); assert_eq!(dark.len(), crate::families::BUILTINS.len());
assert_eq!(light.len(), 6); assert_eq!(light.len(), crate::families::BUILTINS.len());
for (dark, light) in dark.iter().zip(light) { for (dark, light) in dark.iter().zip(light) {
assert_eq!(dark.name, light.name); assert_eq!(dark.name, light.name);
assert_ne!(dark.base, light.base); assert_ne!(dark.base, light.base);
@@ -1441,7 +1443,7 @@ mod tests {
); );
assert_eq!(resolved.policy.light.family.as_deref(), Some("gruvbox")); assert_eq!(resolved.policy.light.family.as_deref(), Some("gruvbox"));
assert_eq!(resolved.policy.dark.family.as_deref(), Some("catppuccin")); assert_eq!(resolved.policy.dark.family.as_deref(), Some("catppuccin"));
assert_eq!(resolved.families.len(), 6); assert_eq!(resolved.families.len(), crate::families::BUILTINS.len());
} }
} }
let resolved = candidate(r#"theme { mode "light"; family "catppuccin"; split #true; light { family "gruvbox"; }; dark { family "everforest"; }; }"#).unwrap(); let resolved = candidate(r#"theme { mode "light"; family "catppuccin"; split #true; light { family "gruvbox"; }; dark { family "everforest"; }; }"#).unwrap();
+1
View File
@@ -9,6 +9,7 @@ rust-version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
schemars.workspace = true
ferese-animation = { path = "../ferese-animation" } ferese-animation = { path = "../ferese-animation" }
ferese-layout = { path = "../ferese-layout" } ferese-layout = { path = "../ferese-layout" }
serde.workspace = true serde.workspace = true
+4 -4
View File
@@ -8,7 +8,7 @@ use crate::motion::{Geometry, Motion};
const FADE: Duration = Duration::from_millis(120); const FADE: Duration = Duration::from_millis(120);
const CONTENT_DELAY: Duration = Duration::from_millis(130); 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)] #[serde(default, deny_unknown_fields)]
pub struct Profile { pub struct Profile {
pub peek_on_hover: bool, pub peek_on_hover: bool,
@@ -40,7 +40,7 @@ impl Default for Profile {
expanded_width: 340., expanded_width: 340.,
workspaces_height: 176., workspaces_height: 176.,
media_height: 170., media_height: 170.,
calendar_height: 230., calendar_height: 650.,
notifications_height: 176., notifications_height: 176.,
controls_height: 248., controls_height: 248.,
expanded_radius: 26., expanded_radius: 26.,
@@ -415,7 +415,7 @@ mod tests {
assert!(!hub.active()); assert!(!hub.active());
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]) hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.])
.unwrap(); .unwrap();
assert_eq!(hub.target_size(), [340., 230.]); assert_eq!(hub.target_size(), [340., 600.]); // clipped to the output
hub.advance(Duration::from_millis(120), false); hub.advance(Duration::from_millis(120), false);
assert_eq!(hub.sample().opacity, 0.); assert_eq!(hub.sample().opacity, 0.);
assert_eq!(hub.sample().previous_opacity, 0.); assert_eq!(hub.sample().previous_opacity, 0.);
@@ -469,7 +469,7 @@ mod tests {
} }
let overshoot = (peak - 340.) / (340. - 150.); let overshoot = (peak - 340.) / (340. - 150.);
assert!((0.04..0.05).contains(&overshoot)); assert!((0.04..0.05).contains(&overshoot));
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 340., 230.)); assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 340., 600.));
assert!(!hub.active()); assert!(!hub.active());
} }
+194 -30
View File
@@ -20,11 +20,13 @@ fn padding() -> [u16; 2] {
[2, 3] [2, 3]
} }
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(transparent)] #[serde(transparent)]
pub struct PanelId(pub String); 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")] #[serde(rename_all = "lowercase")]
pub enum Edge { pub enum Edge {
#[default] #[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)] #[serde(transparent)]
pub struct GroupId(pub String); 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)] #[serde(transparent)]
pub struct ItemId(pub String); 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")] #[serde(rename_all = "kebab-case")]
pub enum Representation { pub enum Representation {
Wide, Wide,
@@ -58,7 +62,9 @@ pub enum Representation {
Icon, 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")] #[serde(rename_all = "kebab-case")]
pub enum OverflowPolicy { pub enum OverflowPolicy {
#[default] #[default]
@@ -67,7 +73,9 @@ pub enum OverflowPolicy {
Always, 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")] #[serde(rename_all = "kebab-case")]
pub enum WorkspaceStyle { pub enum WorkspaceStyle {
Numbers, 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")] #[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ItemKind { pub enum ItemKind {
Overview, Overview,
@@ -119,9 +127,11 @@ pub enum ItemKind {
style: WorkspaceStyle, style: WorkspaceStyle,
}, },
FocusedWindow, FocusedWindow,
WindowTray,
Media, Media,
Activities, Activities,
QuickSettings, QuickSettings,
Tray,
NightLight, NightLight,
Network, Network,
Audio, Audio,
@@ -143,6 +153,7 @@ impl ItemKind {
pub fn minimum_width(self) -> Option<f32> { pub fn minimum_width(self) -> Option<f32> {
match self { match self {
Self::FocusedWindow => Some(48.), Self::FocusedWindow => Some(48.),
Self::WindowTray => Some(30.),
Self::Workspaces { .. } => Some(24.), Self::Workspaces { .. } => Some(24.),
Self::Media => Some(112.), Self::Media => Some(112.),
_ => None, _ => None,
@@ -155,9 +166,11 @@ impl ItemKind {
Self::Overview => "Overview", Self::Overview => "Overview",
Self::Workspaces { .. } => "Workspaces", Self::Workspaces { .. } => "Workspaces",
Self::FocusedWindow => "Focused window", Self::FocusedWindow => "Focused window",
Self::WindowTray => "Window tray",
Self::Media => "Media", Self::Media => "Media",
Self::Activities => "Activities", Self::Activities => "Activities",
Self::QuickSettings => "Quick Settings", Self::QuickSettings => "Quick Settings",
Self::Tray => "Application tray",
Self::NightLight => "Night light", Self::NightLight => "Night light",
Self::Network => "Network", Self::Network => "Network",
Self::Audio => "Audio", Self::Audio => "Audio",
@@ -178,13 +191,13 @@ impl ItemKind {
&[Wide, Compact, Icon] &[Wide, Compact, Icon]
} }
Self::Battery { percentage: true } => &[Wide, Icon], Self::Battery { percentage: true } => &[Wide, Icon],
Self::Workspaces { .. } | Self::FocusedWindow => &[Wide], Self::Workspaces { .. } | Self::FocusedWindow | Self::WindowTray => &[Wide],
_ => &[Icon], _ => &[Icon],
} }
} }
} }
#[derive(Clone, Debug, PartialEq, Serialize)] #[derive(Clone, Debug, PartialEq, Serialize, schemars::JsonSchema)]
pub struct Item { pub struct Item {
pub id: ItemId, pub id: ItemId,
#[serde(flatten)] #[serde(flatten)]
@@ -241,7 +254,7 @@ impl<'de> Deserialize<'de> for Item {
"percentage is only supported by Battery items", "percentage is only supported by Battery items",
)); ));
} }
#[derive(Deserialize)] #[derive(Deserialize, schemars::JsonSchema, serde::Serialize)]
struct Fields { struct Fields {
id: ItemId, id: ItemId,
#[serde(flatten)] #[serde(flatten)]
@@ -301,7 +314,10 @@ impl Item {
metadata: BTreeMap::new(), metadata: BTreeMap::new(),
overflow: if matches!( overflow: if matches!(
kind, kind,
ItemKind::Overview | ItemKind::Workspaces { .. } | ItemKind::FocusedWindow ItemKind::Overview
| ItemKind::Workspaces { .. }
| ItemKind::FocusedWindow
| ItemKind::WindowTray
) { ) {
OverflowPolicy::Never OverflowPolicy::Never
} else { } else {
@@ -325,6 +341,7 @@ impl Item {
match self.kind { match self.kind {
ItemKind::Activities => availability.activities, ItemKind::Activities => availability.activities,
ItemKind::Media => availability.media, ItemKind::Media => availability.media,
ItemKind::Tray => availability.tray,
ItemKind::CaptureStatus => availability.capture, ItemKind::CaptureStatus => availability.capture,
ItemKind::Network => availability.network, ItemKind::Network => availability.network,
ItemKind::Audio => availability.audio, ItemKind::Audio => availability.audio,
@@ -338,6 +355,7 @@ impl Item {
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
pub struct Availability { pub struct Availability {
pub tray: bool,
pub capture: bool, pub capture: bool,
pub media: bool, pub media: bool,
pub activities: bool, pub activities: bool,
@@ -348,7 +366,9 @@ pub struct Availability {
pub external_display: bool, 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")] #[serde(rename_all = "kebab-case")]
pub enum PanelSurface { pub enum PanelSurface {
None, None,
@@ -356,7 +376,9 @@ pub enum PanelSurface {
Solid, 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")] #[serde(rename_all = "kebab-case")]
pub enum GroupSurface { pub enum GroupSurface {
#[default] #[default]
@@ -376,7 +398,7 @@ impl GroupSurface {
} }
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Group { pub struct Group {
pub id: GroupId, pub id: GroupId,
@@ -402,14 +424,14 @@ impl Group {
} }
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Zone { pub struct Zone {
pub groups: Vec<Group>, pub groups: Vec<Group>,
pub spacing: f32, pub spacing: f32,
} }
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Panel { pub struct Panel {
pub id: PanelId, pub id: PanelId,
@@ -682,6 +704,84 @@ impl Panel {
.find(|item| &item.id == id) .find(|item| &item.id == id)
} }
/// Project older panel files onto the consolidated time/control entry without
/// rewriting their source or changing surviving item identities.
pub fn consolidate_clock_controls(&mut self) {
let kinds: Vec<_> = [&self.start, &self.center, &self.end]
.into_iter()
.flat_map(|z| &z.groups)
.flat_map(|g| &g.items)
.map(|i| i.kind)
.collect();
// Only migrate legacy presets. A deliberately removed or hidden tray in
// a current composition must stay removed or hidden.
if kinds.iter().any(|k| {
matches!(
k,
ItemKind::QuickSettings | ItemKind::Network | ItemKind::NightLight
)
}) && !kinds.contains(&ItemKind::WindowTray)
{
let mut suffix = 0;
let mut id = ItemId("window-tray".into());
while self.item(&id).is_some() {
suffix += 1;
id = ItemId(format!("window-tray-{suffix}"));
}
if let Some(group) = self.end.groups.first_mut() {
group
.items
.insert(0, Item::new(&id.0, ItemKind::WindowTray));
}
}
let has_clock = [&self.start, &self.center, &self.end]
.into_iter()
.flat_map(|z| &z.groups)
.flat_map(|g| &g.items)
.any(|i| i.kind == ItemKind::Clock);
let mut clock_exists = has_clock;
for zone in [&mut self.start, &mut self.center, &mut self.end] {
for group in &mut zone.groups {
group.items.retain_mut(|item| match item.kind {
ItemKind::QuickSettings if !clock_exists => {
item.kind = ItemKind::Clock;
clock_exists = true;
true
}
ItemKind::QuickSettings | ItemKind::Network | ItemKind::NightLight => false,
_ => true,
});
}
}
if !clock_exists {
return;
}
let mut notices = Vec::new();
for zone in [&mut self.start, &mut self.center, &mut self.end] {
for group in &mut zone.groups {
group.items.retain(|item| {
if item.kind == ItemKind::Notifications {
notices.push(item.clone());
false
} else {
true
}
});
}
}
for zone in [&mut self.start, &mut self.center, &mut self.end] {
for group in &mut zone.groups {
if let Some(index) = group.items.iter().position(|i| i.kind == ItemKind::Clock) {
for item in &mut notices {
item.gap_before = Some(0.);
}
group.items.splice(index + 1..index + 1, notices);
return;
}
}
}
}
/// Build a stable initial composition; presets are an editor convenience. /// Build a stable initial composition; presets are an editor convenience.
#[must_use] #[must_use]
pub fn from_preset(preset: PanelStyle) -> Self { pub fn from_preset(preset: PanelStyle) -> Self {
@@ -698,17 +798,17 @@ impl Panel {
); );
let title = item("focused-window", FocusedWindow); let title = item("focused-window", FocusedWindow);
let controls = vec![ let controls = vec![
item("window-tray", WindowTray),
item("media", Media), item("media", Media),
item("quick-settings", QuickSettings), item("tray", Tray),
item("night-light", NightLight),
item("network", Network),
item("audio", Audio), item("audio", Audio),
item("recording", Recording), item("recording", Recording),
item("capture-status", CaptureStatus), item("capture-status", CaptureStatus),
item("notifications", Notifications),
item("battery", Battery { percentage: true }), item("battery", Battery { percentage: true }),
]; ];
let clock = item("clock", Clock); let clock = item("clock", Clock);
let mut notifications = item("notifications", Notifications);
notifications.gap_before = Some(0.);
let display = item("display-mode", DisplayMode); let display = item("display-mode", DisplayMode);
let (start, center, end) = match preset { let (start, center, end) = match preset {
@@ -720,13 +820,13 @@ impl Panel {
Group::new("title", vec![title], [0, 0], padding), Group::new("title", vec![title], [0, 0], padding),
vec![ vec![
Group::new("status", controls, [2, 3], padding), Group::new("status", controls, [2, 3], padding),
Group::new("time", vec![clock], [2, 3], padding), Group::new("time", vec![clock, notifications], [2, 3], padding),
Group::new("display", vec![display], [0, 0], padding), Group::new("display", vec![display], [0, 0], padding),
], ],
), ),
_ => { _ => {
let mut controls = controls; let mut controls = controls;
controls.extend([clock, display]); controls.extend([clock, notifications, display]);
( (
vec![Group::new( vec![Group::new(
"navigation", "navigation",
@@ -818,16 +918,15 @@ mod tests {
"overview", "overview",
"workspaces", "workspaces",
"focused-window", "focused-window",
"window-tray",
"media", "media",
"quick-settings", "tray",
"night-light",
"network",
"audio", "audio",
"recording", "recording",
"capture-status", "capture-status",
"notifications",
"battery", "battery",
"clock", "clock",
"notifications",
"display-mode", "display-mode",
]; ];
for panel in [&continuous, &islands] { for panel in [&continuous, &islands] {
@@ -858,14 +957,14 @@ mod tests {
"overview", "overview",
"workspaces", "workspaces",
"focused-window", "focused-window",
"quick-settings", "window-tray",
"night-light",
"recording", "recording",
"clock" "clock"
] ]
); );
assert_eq!( assert_eq!(
visible(Availability { visible(Availability {
tray: true,
capture: true, capture: true,
activities: true, activities: true,
media: true, media: true,
@@ -879,7 +978,72 @@ mod tests {
.map(|item| item.id.0.as_str()) .map(|item| item.id.0.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
); );
assert_eq!(items(&panel).len(), 14); assert_eq!(items(&panel).len(), 13);
}
#[test]
fn legacy_window_tray_is_seeded_once_without_resurrecting_current_removed_items() {
let mut panel = Panel::default();
panel.end.groups[0]
.items
.retain(|i| i.kind != ItemKind::WindowTray);
panel.consolidate_clock_controls();
assert!(!items(&panel).iter().any(|i| i.kind == ItemKind::WindowTray));
panel.end.groups[0]
.items
.push(Item::new("old-controls", ItemKind::QuickSettings));
// The instance name can already belong to a different authored item.
panel.start.groups[0].items[0].id = ItemId("window-tray".into());
panel.consolidate_clock_controls();
let tray = items(&panel)
.into_iter()
.find(|i| i.kind == ItemKind::WindowTray)
.unwrap();
assert_eq!(tray.id.0, "window-tray-1");
validate(std::slice::from_ref(&panel)).unwrap();
let once = panel.clone();
panel.consolidate_clock_controls();
assert_eq!(panel, once);
}
#[test]
fn legacy_controls_consolidate_beside_the_clock_and_preserve_notification_state() {
let mut panel = Panel::default();
let mut notice = panel.end.groups[1].items.pop().unwrap();
notice.visible = false;
notice.metadata.insert("custom".into(), "retained".into());
panel.start.groups[0].items.extend([
Item::new("old-network", ItemKind::Network),
Item::new("old-controls", ItemKind::QuickSettings),
notice.clone(),
]);
panel.consolidate_clock_controls();
let all = items(&panel);
assert!(!all.iter().any(|i| matches!(
i.kind,
ItemKind::Network | ItemKind::QuickSettings | ItemKind::NightLight
)));
let clock = all.iter().position(|i| i.kind == ItemKind::Clock).unwrap();
let moved = all[clock + 1];
assert_eq!(moved.id, notice.id);
assert!(!moved.visible);
assert_eq!(moved.metadata, notice.metadata);
let once = panel.clone();
panel.consolidate_clock_controls();
assert_eq!(panel, once);
for zone in [&mut panel.start, &mut panel.center, &mut panel.end] {
for group in &mut zone.groups {
group.items.retain(|i| i.kind != ItemKind::Clock);
}
}
panel.end.groups[0]
.items
.push(Item::new("controls-only", ItemKind::QuickSettings));
panel.consolidate_clock_controls();
assert_eq!(
panel.item(&ItemId("controls-only".into())).unwrap().kind,
ItemKind::Clock
);
} }
#[test] #[test]
+2 -2
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; 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)] #[serde(default, deny_unknown_fields)]
pub struct GeometryIntent { pub struct GeometryIntent {
#[serde(skip_serializing_if = "Option::is_none")] #[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. /// 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)] #[serde(default, deny_unknown_fields)]
pub struct PanelGeometry { pub struct PanelGeometry {
pub height: f32, pub height: f32,
+2 -2
View File
@@ -840,14 +840,14 @@ mod tests {
.groups .groups
.iter_mut() .iter_mut()
.flat_map(|group| &mut group.items) .flat_map(|group| &mut group.items)
.find(|item| item.id.0 == "network") .find(|item| item.id.0 == "audio")
.unwrap() .unwrap()
.overflow = OverflowPolicy::Always; .overflow = OverflowPolicy::Always;
let mut measured = measurements(&panel); let mut measured = measurements(&panel);
measured.retain(|item| item.id.0 != "media"); measured.retain(|item| item.id.0 != "media");
let resolution = resolve(&panel, &measured, 2000., 32.); let resolution = resolve(&panel, &measured, 2000., 32.);
assert!(!resolution.items.contains_key(&ItemId("media".into()))); assert!(!resolution.items.contains_key(&ItemId("media".into())));
assert_eq!(resolution.overflow, [ItemId("network".into())]); assert_eq!(resolution.overflow, [ItemId("audio".into())]);
} }
#[test] #[test]
fn priority_controls_displacement_and_expansion_restores_requested_sizes() { fn priority_controls_displacement_and_expansion_restores_requested_sizes() {
+9
View File
@@ -4,6 +4,15 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct CornerRadii(pub [f32; 4]); 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 { impl CornerRadii {
#[must_use] #[must_use]
pub fn uniform(radius: f32) -> Self { pub fn uniform(radius: f32) -> Self {
+3 -1
View File
@@ -2,7 +2,9 @@ use crate::{CornerRadii, Edge};
use ferese_layout::Rect; use ferese_layout::Rect;
use serde::{Deserialize, Serialize}; 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")] #[serde(rename_all = "kebab-case")]
pub enum Style { pub enum Style {
Floating, Floating,
+86 -12
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 pointer_manager::ZwlrVirtualPointerManagerV1);
delegate_noop!(State: ignore virtual_pointer::ZwlrVirtualPointerV1); 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>> { fn main() -> Result<(), Box<dyn Error>> {
if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") { if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") {
return Err( return Err(
@@ -91,6 +107,7 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
let mut sequences: Vec<Vec<u32>> = Vec::new(); let mut sequences: Vec<Vec<u32>> = Vec::new();
let mut clicks = Vec::new(); let mut clicks = Vec::new();
let mut hovers = Vec::new();
let mut drag = None; let mut drag = None;
let mut extent = (1280, 800); let mut extent = (1280, 800);
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
@@ -103,12 +120,19 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
extent = (v[0], v[1]); extent = (v[0], v[1]);
} }
"--click" => { "--hover" => {
let v = parse_numbers(&args.next().ok_or("missing hover position")?)?;
if v.len() != 2 {
return Err("invalid hover".into());
}
hovers.push((v[0], v[1]));
}
"--click" | "--double-click" => {
let v = parse_numbers(&args.next().ok_or("missing click position")?)?; let v = parse_numbers(&args.next().ok_or("missing click position")?)?;
if v.len() != 2 { if v.len() != 2 {
return Err("invalid click".into()); return Err("invalid click".into());
} }
clicks.push((v[0], v[1])); clicks.push((v[0], v[1], if arg == "--double-click" { 2 } else { 1 }));
} }
"--drag" => { "--drag" => {
let v = parse_numbers(&args.next().ok_or("missing drag positions")?)?; let v = parse_numbers(&args.next().ok_or("missing drag positions")?)?;
@@ -122,16 +146,13 @@ fn main() -> Result<(), Box<dyn Error>> {
sequences.push(vec![character as u32]); sequences.push(vec![character as u32]);
} }
} }
"--key" => sequences.push(vec![ "--key" => sequences.push(vec![named_keysym(&args.next().ok_or("missing key")?)?]),
xkb::keysym_from_name(&args.next().ok_or("missing key")?, xkb::KEYSYM_NO_FLAGS)
.raw(),
]),
"--chord" => sequences.push( "--chord" => sequences.push(
args.next() args.next()
.ok_or("missing chord")? .ok_or("missing chord")?
.split('+') .split('+')
.map(|name| xkb::keysym_from_name(name, xkb::KEYSYM_NO_FLAGS).raw()) .map(named_keysym)
.collect(), .collect::<Result<Vec<_>, _>>()?,
), ),
_ => return Err(format!("unknown test input argument {arg}").into()), _ => return Err(format!("unknown test input argument {arg}").into()),
} }
@@ -191,7 +212,7 @@ fn main() -> Result<(), Box<dyn Error>> {
xkb::KEYMAP_COMPILE_NO_FLAGS, xkb::KEYMAP_COMPILE_NO_FLAGS,
) )
.ok_or("US test keymap missing")?; .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 text = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1) + "\0";
let mut file = tempfile::tempfile()?; let mut file = tempfile::tempfile()?;
file.write_all(text.as_bytes())?; file.write_all(text.as_bytes())?;
@@ -211,22 +232,48 @@ fn main() -> Result<(), Box<dyn Error>> {
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
for key in &codes { for key in &codes {
keyboard.key(started.elapsed().as_millis() as u32, *key, 1); 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)?; queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(35)); std::thread::sleep(Duration::from_millis(35));
} }
for key in codes.iter().rev() { for key in codes.iter().rev() {
keyboard.key(started.elapsed().as_millis() as u32, *key, 0); 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)?; queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(35)); std::thread::sleep(Duration::from_millis(35));
} }
} }
if !clicks.is_empty() || drag.is_some() { if !clicks.is_empty() || !hovers.is_empty() || drag.is_some() {
let pointer = state let pointer = state
.pointer_manager .pointer_manager
.as_ref() .as_ref()
.ok_or("test pointer is not enabled on this compositor")? .ok_or("test pointer is not enabled on this compositor")?
.create_virtual_pointer(state.seat.as_ref(), &qh, ()); .create_virtual_pointer(state.seat.as_ref(), &qh, ());
for (x, y) in clicks { for (x, y) in hovers {
pointer.motion_absolute(
started.elapsed().as_millis() as u32,
x,
y,
extent.0,
extent.1,
);
pointer.frame();
queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(120));
}
for (x, y, count) in clicks {
pointer.motion_absolute( pointer.motion_absolute(
started.elapsed().as_millis() as u32, started.elapsed().as_millis() as u32,
x, x,
@@ -237,6 +284,7 @@ fn main() -> Result<(), Box<dyn Error>> {
pointer.frame(); pointer.frame();
queue.roundtrip(&mut state)?; queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(80)); std::thread::sleep(Duration::from_millis(80));
for index in 0..count {
pointer.button( pointer.button(
started.elapsed().as_millis() as u32, started.elapsed().as_millis() as u32,
0x110, 0x110,
@@ -252,7 +300,13 @@ fn main() -> Result<(), Box<dyn Error>> {
); );
pointer.frame(); pointer.frame();
queue.roundtrip(&mut state)?; queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(150)); // Consecutive clicks must fit Iced's 300 ms double-click window.
std::thread::sleep(Duration::from_millis(if index + 1 < count {
80
} else {
150
}));
}
} }
if let Some(v) = drag { if let Some(v) = drag {
pointer.motion_absolute( pointer.motion_absolute(
@@ -302,3 +356,23 @@ fn main() -> Result<(), Box<dyn Error>> {
println!("nested test input complete"); println!("nested test input complete");
Ok(()) 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 workspace = true
[dependencies] [dependencies]
schemars.workspace = true
serde.workspace = true serde.workspace = true
+2 -2
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use crate::{Appearance, Tokens}; use crate::{Appearance, Tokens};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Palette { pub struct Palette {
pub background: String, pub background: String,
pub bar: 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 struct Family {
pub id: String, pub id: String,
pub name: String, pub name: String,
+23 -17
View File
@@ -2,7 +2,9 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; 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")] #[serde(rename_all = "lowercase")]
pub enum Appearance { pub enum Appearance {
Light, Light,
@@ -10,7 +12,9 @@ pub enum Appearance {
Dark, 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")] #[serde(rename_all = "lowercase")]
pub enum Mode { pub enum Mode {
Light, Light,
@@ -19,14 +23,14 @@ pub enum Mode {
Auto, Auto,
} }
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq, schemars::JsonSchema)]
#[serde(default)] #[serde(default)]
pub struct Accessibility { pub struct Accessibility {
pub increase_contrast: bool, pub increase_contrast: bool,
pub reduce_transparency: bool, pub reduce_transparency: bool,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Colors { pub struct Colors {
pub surface_base: String, pub surface_base: String,
pub surface_raised: String, pub surface_raised: String,
@@ -39,7 +43,7 @@ pub struct Colors {
pub shadow: String, pub shadow: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Material { pub struct Material {
pub style: String, pub style: String,
pub opacity: f64, pub opacity: f64,
@@ -47,7 +51,7 @@ pub struct Material {
pub tint_strength: f64, pub tint_strength: f64,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Geometry { pub struct Geometry {
pub border_width: f64, pub border_width: f64,
pub focus_ring_width: f64, pub focus_ring_width: f64,
@@ -56,50 +60,52 @@ pub struct Geometry {
pub control_gap: f64, pub control_gap: f64,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Typography { pub struct Typography {
pub font_family: String, pub font_family: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Background { pub struct Background {
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
pub lock_path: Option<PathBuf>, pub lock_path: Option<PathBuf>,
pub mode: String, pub mode: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct BarSurface { pub struct BarSurface {
pub background: String, pub background: String,
pub text_primary: String, pub text_primary: String,
pub text_muted: String, pub text_muted: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Surfaces { pub struct Surfaces {
pub bar: BarSurface, pub bar: BarSurface,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct SoftShadow { pub struct SoftShadow {
pub offset_y: f64, pub offset_y: f64,
pub blur: f64, pub blur: f64,
pub opacity: f64, pub opacity: f64,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Shadows { pub struct Shadows {
pub soft: SoftShadow, pub soft: SoftShadow,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct Gradient { pub struct Gradient {
pub from: String, pub from: String,
pub to: String, pub to: String,
pub angle: f64, 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")] #[serde(rename_all = "lowercase")]
pub enum PaintStyle { pub enum PaintStyle {
#[default] #[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 { pub struct Paint {
#[serde(default, skip_serializing_if = "PaintStyle::is_auto")] #[serde(default, skip_serializing_if = "PaintStyle::is_auto")]
pub style: PaintStyle, 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 struct Tokens {
pub colors: Colors, pub colors: Colors,
pub material: Material, pub material: Material,
@@ -144,7 +150,7 @@ pub struct Tokens {
pub focus_ring: Paint, pub focus_ring: Paint,
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, schemars::JsonSchema)]
pub struct ResolvedTheme { pub struct ResolvedTheme {
pub appearance: Appearance, pub appearance: Appearance,
pub tokens: Tokens, pub tokens: Tokens,
+198 -27
View File
@@ -7,6 +7,15 @@ use jiff::Zoned;
#[must_use] #[must_use]
pub fn format_bar_time(now: &Zoned) -> String { 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 = [ let months = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec", "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec",
]; ];
@@ -27,7 +36,7 @@ pub fn month(today: jiff::civil::Date, offset: i32) -> (jiff::civil::Date, usize
let year = month_index.div_euclid(12) as i16; let year = month_index.div_euclid(12) as i16;
let month = (month_index.rem_euclid(12) + 1) as i8; let month = (month_index.rem_euclid(12) + 1) as i8;
let first = jiff::civil::Date::new(year, month, 1).expect("calendar month is in range"); let first = jiff::civil::Date::new(year, month, 1).expect("calendar month is in range");
let start = usize::try_from(first.weekday().since(jiff::civil::Weekday::Monday)).unwrap_or(0); let start = usize::try_from(first.weekday().since(jiff::civil::Weekday::Sunday)).unwrap_or(0);
let days = usize::try_from(first.days_in_month()).unwrap_or(31); let days = usize::try_from(first.days_in_month()).unwrap_or(31);
(first, start, days) (first, start, days)
} }
@@ -40,8 +49,11 @@ pub fn grid<'a, M: Clone + 'a>(
font: cosmic::font::Font, font: cosmic::font::Font,
opacity: f32, opacity: f32,
navigation: [Element<'a, M>; 3], navigation: [Element<'a, M>; 3],
settings: &ferese_config::calendar::Settings,
) -> Element<'a, M> { ) -> Element<'a, M> {
grid_with_density(today, offset, palette, font, opacity, navigation, false) grid_with_density(
today, offset, palette, font, opacity, navigation, false, settings,
)
} }
#[must_use] #[must_use]
@@ -52,8 +64,11 @@ pub fn compact_grid<'a, M: Clone + 'a>(
font: cosmic::font::Font, font: cosmic::font::Font,
opacity: f32, opacity: f32,
navigation: [Element<'a, M>; 3], navigation: [Element<'a, M>; 3],
settings: &ferese_config::calendar::Settings,
) -> Element<'a, M> { ) -> Element<'a, M> {
grid_with_density(today, offset, palette, font, opacity, navigation, true) grid_with_density(
today, offset, palette, font, opacity, navigation, true, settings,
)
} }
fn grid_with_density<'a, M: Clone + 'a>( fn grid_with_density<'a, M: Clone + 'a>(
@@ -64,13 +79,10 @@ fn grid_with_density<'a, M: Clone + 'a>(
opacity: f32, opacity: f32,
navigation: [Element<'a, M>; 3], navigation: [Element<'a, M>; 3],
compact: bool, compact: bool,
settings: &ferese_config::calendar::Settings,
) -> Element<'a, M> { ) -> Element<'a, M> {
let cell_width = if compact { let cell_width = Length::Fill;
Length::Fill let cell_height = if compact { 24. } else { 28. };
} else {
Length::Fixed(30.)
};
let cell_height = if compact { 20. } else { 25. };
let spacing = if compact { 2. } else { 3. }; let spacing = if compact { 2. } else { 3. };
let months = [ let months = [
"January", "January",
@@ -91,9 +103,21 @@ fn grid_with_density<'a, M: Clone + 'a>(
let month = first.month(); let month = first.month();
let primary = palette.text.scale_alpha(opacity); let primary = palette.text.scale_alpha(opacity);
let muted = palette.muted.scale_alpha(opacity); let muted = palette.muted.scale_alpha(opacity);
let holiday_color = if palette.appearance == ferese_config::theme::Appearance::Light {
cosmic::iced::Color::from_rgb8(184, 38, 49)
} else {
cosmic::iced::Color::from_rgb8(244, 110, 120)
}
.scale_alpha(opacity);
let [previous, today_button, next] = navigation; let [previous, today_button, next] = navigation;
let header = row![ let header = row![
crate::text(format!("{} {year}", months[(month - 1) as usize]), font) crate::text(
format!(
"{} {year}",
ferese_config::locale::text(months[(month - 1) as usize])
),
font
)
.size(if compact { 13 } else { 15 }) .size(if compact { 13 } else { 15 })
.width(Length::Fill), .width(Length::Fill),
previous, previous,
@@ -115,12 +139,19 @@ fn grid_with_density<'a, M: Clone + 'a>(
} else { } else {
Length::Shrink Length::Shrink
}); });
for label in ["M", "T", "W", "T", "F", "S", "S"] { for (weekday, label) in weekday_labels(ferese_config::locale::resolved())
.into_iter()
.enumerate()
{
weekdays = weekdays.push( weekdays = weekdays.push(
container( container(
crate::text(label, font) crate::text(label, font)
.size(if compact { 9 } else { 11 }) .size(if compact { 9 } else { 11 })
.class(theme::Text::Color(muted)), .class(theme::Text::Color(if matches!(weekday, 0 | 6) {
holiday_color
} else {
muted
})),
) )
.width(cell_width) .width(cell_width)
.center_x(cell_width), .center_x(cell_width),
@@ -135,19 +166,40 @@ fn grid_with_density<'a, M: Clone + 'a>(
}); });
for weekday in 0..7 { for weekday in 0..7 {
let cell = week * 7 + weekday; let cell = week * 7 + weekday;
let day = cell let date = first
.checked_sub(start) .checked_add(jiff::Span::new().days(cell as i64 - start as i64))
.map(|day| day + 1) .ok();
.filter(|day| *day <= days); let in_month = date.is_some_and(|date| date.month() == month && date.year() == year);
let is_today = let visible = in_month || settings.show_adjacent_days;
year == today.year() && month == today.month() && day == Some(today.day() as usize); let date = date.filter(|_| visible);
let label = day.map_or(String::new(), |day| day.to_string()); let is_today = date == Some(today);
dates = dates.push( let holiday = date.and_then(|date| settings.holiday_name(date));
container( let emphasized = date.is_some() && (holiday.is_some() || matches!(weekday, 0 | 6));
let foreground = if emphasized {
holiday_color.scale_alpha(if in_month { 1. } else { 0.65 })
} else if is_today || in_month {
primary
} else {
muted.scale_alpha(0.6)
};
let label = date.map_or(String::new(), |date| date.day().to_string());
let font = cosmic::iced::Font {
weight: if emphasized {
cosmic::iced::font::Weight::Bold
} else {
cosmic::iced::font::Weight::Normal
},
..font
};
let day: Element<'a, M> = container(
crate::text(label, font) crate::text(label, font)
.size(12) .size(12)
.class(theme::Text::Color(if is_today { primary } else { muted })), .class(theme::Text::Color(foreground)),
) )
.id(cosmic::iced::advanced::widget::Id::new(date.map_or_else(
|| format!("calendar-empty-{cell}"),
|date| format!("calendar-date-{date}"),
)))
.width(cell_width) .width(cell_width)
.height(cell_height) .height(cell_height)
.center_x(cell_width) .center_x(cell_width)
@@ -161,16 +213,135 @@ fn grid_with_density<'a, M: Clone + 'a>(
..Default::default() ..Default::default()
}, },
..Default::default() ..Default::default()
})), }))
); .into();
dates = dates.push(if let Some(name) = holiday {
cosmic::widget::tooltip(
day,
crate::text(name, font).size(11),
cosmic::widget::tooltip::Position::Top,
)
.into()
} else {
day
});
} }
grid = grid.push(dates); grid = grid.push(dates);
} }
if let Err(error) =
ferese_config::calendar::japanese_holidays(&settings.japanese_holiday_function, year)
{
grid = grid.push(
crate::text(
ferese_config::locale::format(
"Holiday function error: {error}",
&[("error", error)],
),
font,
)
.size(10)
.class(theme::Text::Color(muted)),
);
}
grid.into() grid.into()
} }
/// Sunday is always the first column; labels follow Ferese's interface language.
#[must_use]
pub fn weekday_labels(language: ferese_config::locale::Language) -> [&'static str; 7] {
if language == ferese_config::locale::Language::Chinese {
["日", "一", "二", "三", "四", "五", "六"]
} else {
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn sunday_grid_localizes_weekdays_and_can_hide_adjacent_dates_without_reflow() {
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::advanced::widget::{Id, Operation, Tree};
use cosmic::iced::advanced::{Layout, layout};
use cosmic::iced::{Font, Pixels, Rectangle, Size};
use ferese_config::locale::Language;
assert_eq!(
super::weekday_labels(Language::Chinese),
["日", "一", "二", "三", "四", "五", "六"]
);
assert_eq!(
super::weekday_labels(Language::English),
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
);
#[derive(Default)]
struct Dates {
first: Option<Rectangle>,
sunday: Option<Rectangle>,
holiday: Option<Rectangle>,
}
impl Operation for Dates {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
if id == Some(&Id::new("calendar-date-2026-10-01")) {
self.first = Some(bounds);
}
if id == Some(&Id::new("calendar-date-2026-09-27")) {
self.sunday = Some(bounds);
}
if id == Some(&Id::new("calendar-date-2026-10-12")) {
self.holiday = Some(bounds);
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let mut first = None;
for adjacent in [true, false] {
let settings = ferese_config::calendar::Settings {
show_adjacent_days: adjacent,
..Default::default()
};
let mut view: cosmic::Element<'_, ()> = super::grid(
"2026-10-28".parse().unwrap(),
0,
crate::Palette::from_resolved(&ferese_config::theme::default_theme()),
Font::default(),
1.,
std::array::from_fn(|_| cosmic::widget::Space::new().width(20).height(20).into()),
&settings,
);
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(330., 600.)),
);
let mut dates = Dates::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut dates);
assert_eq!(dates.sunday.is_some(), adjacent);
assert!(dates.holiday.is_some());
let current = dates.first.unwrap();
if let Some(first) = first {
assert_eq!(current, first);
} else {
first = Some(current);
}
if let Some(sunday) = dates.sunday {
assert!(sunday.x < current.x && (sunday.y - current.y).abs() < 0.5);
}
}
}
#[test] #[test]
fn clock_uses_lowercase_date_and_twelve_hour_time() { fn clock_uses_lowercase_date_and_twelve_hour_time() {
for (stamp, expected) in [ for (stamp, expected) in [
@@ -195,14 +366,14 @@ mod tests {
fn calendar_aligns_leap_months_and_year_boundaries() { fn calendar_aligns_leap_months_and_year_boundaries() {
let february = jiff::civil::Date::new(2024, 2, 15).unwrap(); let february = jiff::civil::Date::new(2024, 2, 15).unwrap();
let (first, start, days) = super::month(february, 0); let (first, start, days) = super::month(february, 0);
assert_eq!((first.year(), first.month(), start, days), (2024, 2, 3, 29)); assert_eq!((first.year(), first.month(), start, days), (2024, 2, 4, 29));
let (first, start, days) = super::month(february, -2); let (first, start, days) = super::month(february, -2);
assert_eq!( assert_eq!(
(first.year(), first.month(), start, days), (first.year(), first.month(), start, days),
(2023, 12, 4, 31) (2023, 12, 5, 31)
); );
let june = jiff::civil::Date::new(2025, 6, 1).unwrap(); let june = jiff::civil::Date::new(2025, 6, 1).unwrap();
let (_, start, days) = super::month(june, 0); let (_, start, days) = super::month(june, 0);
assert_eq!((start, days), (6, 30)); assert_eq!((start, days), (0, 30));
} }
} }
+125 -24
View File
@@ -116,14 +116,24 @@ pub fn settings_input(p: Palette) -> theme::TextInput {
let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance { let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance {
shape: Some(BorderShape::Continuous), shape: Some(BorderShape::Continuous),
outline: None, outline: None,
background: mix(p.card, p.text, if hovered { 0.06 } else { 0.035 }).into(), background: mix(p.background, p.card, if hovered { 0.55 } else { 0.35 }).into(),
border_radius: p.radius.min(7.).into(), border_radius: p.radius.min(7.).into(),
border_width: 1., border_width: 1.,
border_offset: None, border_offset: None,
border_color: if focused { border_color: if focused {
p.accent p.accent
} else { } else {
mix(p.card, p.text, if hovered { 0.26 } else { 0.18 }) mix(
p.card,
p.text,
if p.high_contrast {
0.5
} else if hovered {
0.20
} else {
0.10
},
)
}, },
icon_color: Some(p.muted), icon_color: Some(p.muted),
text_color: Some(p.text), text_color: Some(p.text),
@@ -166,25 +176,52 @@ fn styled_button(
opacity: f32, opacity: f32,
focus_visible: bool, focus_visible: bool,
) -> theme::Button { ) -> theme::Button {
let style = move |hover: bool, focused: bool| { let style = move |hover: bool, focused: bool, pressed: bool| {
let background = if selected { let resting = if navigation {
mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 })
} else if hover {
if navigation {
mix(p.card, crate::surface_shade(p.sidebar), 0.06)
} else {
mix(p.card, p.text, 0.08)
}
} else if navigation {
p.sidebar p.sidebar
} else { } else {
mix(p.card, p.text, 0.035) mix(p.background, p.card, 0.5)
}; };
let (background, on) = if selected { let selected_strength = if navigation { 0.09 } else { 0.10 };
crate::accent_pair(background, p.text) let background = if selected && navigation {
mix(
resting,
p.text,
if pressed {
0.16
} else if hover {
0.13
} else { } else {
(background, p.text) selected_strength
},
)
} else if selected {
mix(
resting,
p.accent,
if pressed {
0.20
} else if hover {
0.16
} else {
selected_strength
},
)
} else if hover {
mix(resting, p.text, if pressed { 0.09 } else { 0.055 })
} else {
resting
}; };
let mut on = crate::foreground(background, p.text);
if p.high_contrast && crate::contrast(background, on) < 7. {
on = if crate::contrast(background, Color::BLACK)
>= crate::contrast(background, Color::WHITE)
{
Color::BLACK
} else {
Color::WHITE
};
}
let mut background = background; let mut background = background;
background.a *= if hover && opacity < 1.0 { background.a *= if hover && opacity < 1.0 {
@@ -193,9 +230,15 @@ fn styled_button(
opacity opacity
}; };
let fill = if selected { let fill = if selected && !navigation {
p.selected_background( p.selected_background(
if hover { 0.24 } else { 0.17 }, if pressed {
0.20
} else if hover {
0.16
} else {
0.10
},
background.a, background.a,
on, on,
background, background,
@@ -211,11 +254,23 @@ fn styled_button(
text_color: Some(on), text_color: Some(on),
icon_color: Some(on), icon_color: Some(on),
border_radius: p.radius.min(9.).into(), border_radius: p.radius.min(9.).into(),
border_width: if selected || !navigation { 1. } else { 0. }, border_width: if !navigation || (selected && p.high_contrast) {
1.
} else {
0.
},
border_color: if selected { border_color: if selected {
if p.high_contrast {
p.accent p.accent
} else {
mix(background, p.accent, 0.40)
}
} else if !navigation { } else if !navigation {
mix(p.card, p.text, 0.18) mix(
background,
p.text,
if p.high_contrast { 0.50 } else { 0.10 },
)
} else { } else {
Color::TRANSPARENT Color::TRANSPARENT
}, },
@@ -227,10 +282,17 @@ fn styled_button(
}; };
theme::Button::Custom { theme::Button::Custom {
active: Box::new(move |focused, _| style(false, focused)), active: Box::new(move |focused, _| style(false, focused, false)),
hovered: Box::new(move |focused, _| style(true, focused)), hovered: Box::new(move |focused, _| style(true, focused, false)),
pressed: Box::new(move |focused, _| style(true, focused)), pressed: Box::new(move |focused, _| style(true, focused, true)),
disabled: Box::new(move |_| style(false, false)), disabled: Box::new(move |_| {
let mut disabled = style(false, false, false);
if !selected {
disabled.text_color = Some(p.muted);
disabled.icon_color = Some(p.muted);
}
disabled
}),
} }
} }
@@ -433,6 +495,45 @@ pub fn filled_button(fill: Color, foreground: Color, radius: f32, opacity: f32)
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn navigation_selection_is_quiet_but_keyboard_focus_and_press_are_explicit() {
use cosmic::widget::button::Catalog;
for preset in ferese_config::presets::PRESETS {
let mut resolved = ferese_config::theme::default_theme();
resolved.appearance = preset.appearance;
resolved.tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap();
for high_contrast in [false, true] {
resolved.accessibility.increase_contrast = high_contrast;
let palette = Palette::from_resolved(&resolved);
let native = palette.native_theme();
for selected in [false, true] {
let class = navigation_style(palette, selected);
let resting = native.active(false, selected, &class);
let focus = native.active(true, selected, &class);
assert_eq!(resting.outline_width, 0.);
assert_eq!(focus.outline_width, 2.);
assert_eq!(
resting.border_width,
if selected && high_contrast { 1. } else { 0. }
);
let Some(Background::Color(fill)) = resting.background else {
panic!("navigation should not be a gradient")
};
assert!(
crate::contrast(fill, resting.text_color.unwrap())
>= if high_contrast { 7. } else { 4.5 },
"{}",
preset.id
);
assert_ne!(
native.hovered(false, selected, &class).background,
native.pressed(false, selected, &class).background
);
}
}
}
}
#[test] #[test]
fn hiding_modal_focus_preserves_button_borders_and_materials() { fn hiding_modal_focus_preserves_button_borders_and_materials() {
use cosmic::widget::button::Catalog; use cosmic::widget::button::Catalog;
+5 -13
View File
@@ -6,6 +6,7 @@ use cosmic::{Element, Renderer, Theme};
use ferese_theme_model::Appearance; use ferese_theme_model::Appearance;
use ferese_theme_model::families::{Family, Palette}; use ferese_theme_model::families::{Family, Palette};
use iced_accessibility::{A11yTree, accesskit}; use iced_accessibility::{A11yTree, accesskit};
mod preview;
pub struct TileOptions { pub struct TileOptions {
pub variant: Option<Appearance>, pub variant: Option<Appearance>,
@@ -56,9 +57,9 @@ pub fn tile<M: Clone + 'static>(
caption = caption.push( caption = caption.push(
crate::text( crate::text(
if family.light.is_none() { if family.light.is_none() {
"Dark only" ferese_config::tr!("Dark only")
} else { } else {
"Light only" ferese_config::tr!("Light only")
}, },
font, font,
) )
@@ -66,23 +67,14 @@ pub fn tile<M: Clone + 'static>(
.class(cosmic::theme::Text::Color(label_color)), .class(cosmic::theme::Text::Color(label_color)),
); );
} }
let family_preview = family.clone();
let caption: Element<'static, M> = container(caption) let caption: Element<'static, M> = container(caption)
.padding(8) .padding(8)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.align_y(cosmic::iced::alignment::Vertical::Bottom) .align_y(cosmic::iced::alignment::Vertical::Bottom)
.into(); .into();
let image = cosmic::widget::responsive(move |size| { let image = preview::view(family, variant, active, selected);
let height = size.width * 126. / 200.; let content = cosmic::iced::widget::stack([image, caption]);
// Match the button's logical radius when scaling the cached SVG artwork.
let radius = 10. * 200. / size.width.max(1.);
preview_with_radius(&family_preview, variant, active, selected, radius)
.height(Length::Fixed(height))
.into()
})
.height(Length::Shrink);
let content = cosmic::iced::widget::stack([image.into(), caption]);
let tile = button::custom(content) let tile = button::custom(content)
.id(id.clone()) .id(id.clone())
.name(format!("{}, {}", family.name, family.availability())) .name(format!("{}, {}", family.name, family.availability()))
+334
View File
@@ -0,0 +1,334 @@
//! Draw the small desktop directly. Resizing and selection never create a new
//! SVG, parse a document, or rasterize a family-specific texture on the UI thread.
use cosmic::iced::advanced::{Layout, Widget, layout, mouse, renderer, svg, widget::Tree};
use cosmic::iced::{Border, Color, Length, Rectangle, Size};
use cosmic::{Element, Renderer, Theme};
use ferese_theme_model::{
Appearance,
families::{Family, Palette},
};
const CHECK_SVG: &str = concat!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">"#,
r#"<path d="M1 6l3 3 6-6" fill="none" stroke="white" stroke-width="2" "#,
r#"stroke-linecap="round" stroke-linejoin="round"/></svg>"#,
);
#[derive(Clone, Copy)]
struct Colors {
background: Color,
bar: Color,
muted: Color,
surface: Color,
text: Color,
accent: Color,
}
impl From<&Palette> for Colors {
fn from(p: &Palette) -> Self {
let color = |s: &str| crate::parse_color(s).expect("validated theme preview color");
Self {
background: color(&p.background),
bar: color(&p.bar),
muted: color(&p.muted),
surface: color(&p.surface),
text: color(&p.text),
accent: color(&p.accent),
}
}
}
pub(super) fn view<M: 'static>(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
) -> Element<'static, M> {
Element::new(Preview {
light: family.light.as_ref().map(Colors::from),
dark: family.dark.as_ref().map(Colors::from),
variant,
active,
selected,
})
}
struct Preview {
light: Option<Colors>,
dark: Option<Colors>,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
}
fn rect(renderer: &mut Renderer, bounds: Rectangle, color: Color, radius: f32) {
use renderer::Renderer as _;
renderer.fill_quad(
renderer::Quad {
bounds,
border: Border {
radius: radius.into(),
..Default::default()
},
..Default::default()
},
color,
);
}
impl Preview {
fn desktop(
&self,
renderer: &mut Renderer,
bounds: Rectangle,
colors: Option<Colors>,
appearance: Appearance,
) {
let scale = bounds.width / 200.;
let at = |x, y, width, height| Rectangle {
x: bounds.x + x * scale,
y: bounds.y + y * scale,
width: width * scale,
height: height * scale,
};
let background = colors.map_or(Color::from_rgb8(217, 221, 226), |p| p.background);
use renderer::Renderer as _;
renderer.fill_quad(
renderer::Quad {
bounds,
border: Border {
radius: 10.into(),
shape: cosmic::iced::border::Shape::Continuous,
..Default::default()
},
..Default::default()
},
background,
);
let Some(p) = colors else {
// The surrounding caption identifies the unavailable appearance.
// Inset the pattern so the outer corners remain transparent.
for y in (8..120).step_by(8) {
for x in (8..192).step_by(8) {
if (x + y) % 16 == 0 {
rect(
renderer,
at(x as f32, y as f32, 8., 8.),
Color::from_rgb8(196, 201, 209),
0.,
);
}
}
}
return;
};
renderer.fill_quad(
renderer::Quad {
bounds: at(8., 8., 184., 14.),
border: Border {
radius: (4. * scale).into(),
width: scale,
color: p.muted.scale_alpha(0.18),
..Default::default()
},
..Default::default()
},
p.bar,
);
for (x, y, width) in [(15., 15., 10.), (170., 15., 15.)] {
rect(renderer, at(x, y - 1., width, 2.), p.muted, scale);
}
rect(renderer, at(24., 35., 152., 77.), p.surface, 7. * scale);
rect(
renderer,
at(24., 50.5, 152., 1.),
p.muted.scale_alpha(0.22),
0.,
);
for (x, y, width) in [
(35., 44., 34.),
(36., 63., 105.),
(36., 73., 90.),
(36., 83., 115.),
] {
rect(renderer, at(x, y - 1., width, 2.), p.text, scale);
}
rect(renderer, at(76., 92., 48., 10.), p.accent, 3. * scale);
if self.active == Some(appearance) {
let (x, width) = if self.variant.is_some() {
(8., 184.)
} else if appearance == Appearance::Light {
(8., 84.)
} else {
(108., 84.)
};
rect(renderer, at(x, 120., width, 3.), p.accent, 1.5 * scale);
}
}
}
impl<M> Widget<M, Theme, Renderer> for Preview {
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Shrink)
}
fn layout(&mut self, _: &mut Tree, _: &Renderer, limits: &layout::Limits) -> layout::Node {
let width = limits.max().width;
let width = if width.is_finite() { width } else { 200. };
layout::Node::new(limits.resolve(
Length::Fill,
Length::Shrink,
Size::new(width, width * 126. / 200.),
))
}
fn draw(
&self,
_: &Tree,
renderer: &mut Renderer,
_: &Theme,
_: &renderer::Style,
layout: Layout<'_>,
_: mouse::Cursor,
viewport: &Rectangle,
) {
use renderer::Renderer as _;
let bounds = layout.bounds();
let Some(clip) = bounds.intersection(viewport) else {
return;
};
for (appearance, colors) in [
(Appearance::Light, self.light),
(Appearance::Dark, self.dark),
] {
if self.variant.is_some_and(|variant| variant != appearance) {
continue;
}
let half = if self.variant.is_some() {
bounds
} else {
Rectangle {
x: bounds.x
+ if appearance == Appearance::Dark {
bounds.width / 2.
} else {
0.
},
width: bounds.width / 2.,
..bounds
}
};
if let Some(clip) = half.intersection(&clip) {
renderer.with_layer(clip, |renderer| {
self.desktop(renderer, bounds, colors, appearance)
});
}
}
if self.selected {
// A new layer preserves painter order after the two clipped halves;
// root-layer quads would otherwise be batched behind those layers.
renderer.with_layer(clip, |renderer| {
let p = match self.active.unwrap_or(Appearance::Dark) {
Appearance::Light => self.light,
Appearance::Dark => self.dark,
}
.or(self.dark)
.or(self.light)
.expect("theme family has an appearance");
let scale = bounds.width / 200.;
let badge = Rectangle {
x: bounds.x + 173. * scale,
y: bounds.y + 6. * scale,
width: 20. * scale,
height: 20. * scale,
};
rect(renderer, badge, p.accent, 10. * scale);
static CHECK: std::sync::OnceLock<svg::Handle> = std::sync::OnceLock::new();
let check = CHECK.get_or_init(|| svg::Handle::from_memory(CHECK_SVG.as_bytes()));
let fg = crate::foreground(p.accent, Color::WHITE);
use svg::Renderer as _;
renderer.draw_svg(
svg::Svg::new(check.clone()).color(fg),
Rectangle {
x: badge.x + 4. * scale,
y: badge.y + 4. * scale,
width: 12. * scale,
height: 12. * scale,
},
clip,
);
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::renderer::{Headless, Renderer as _};
use cosmic::iced::{Font, Pixels};
#[test]
fn paired_native_preview_preserves_palettes_and_transparent_corners() {
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let mut renderer = runtime
.block_on(Renderer::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let family = ferese_config::families::builtins().remove(1);
for variant in [None, Some(Appearance::Light), Some(Appearance::Dark)] {
let mut view: Element<'_, ()> = view(&family, variant, Some(Appearance::Dark), true);
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(400., 252.)),
);
let bounds = node.bounds();
renderer.reset(bounds);
view.as_widget().draw(
&tree,
&mut renderer,
&Theme::default(),
&renderer::Style {
text_color: Color::WHITE,
icon_color: Color::WHITE,
scale_factor: 1.,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
let pixels =
Headless::screenshot(&mut renderer, Size::new(400, 252), 1., Color::TRANSPARENT);
assert_eq!(pixels[3], 0, "outer corner must remain transparent");
let accent = crate::parse_color(&family.dark.as_ref().unwrap().accent).unwrap();
let badge = &pixels[(32 * 400 + 350) * 4..(32 * 400 + 350) * 4 + 4];
for (actual, expected) in badge.iter().zip([accent.r, accent.g, accent.b, accent.a]) {
assert!(
(f32::from(*actual) - expected * 255.).abs() < 2.,
"selected badge must remain above both preview halves: {variant:?} {badge:?}"
);
}
for (x, appearance) in [
(20, variant.unwrap_or(Appearance::Light)),
(380, variant.unwrap_or(Appearance::Dark)),
] {
let expected =
crate::parse_color(&family.palette(appearance).unwrap().background).unwrap();
let actual = &pixels[(60 * 400 + x) * 4..(60 * 400 + x) * 4 + 4];
for (actual, expected) in actual
.iter()
.zip([expected.r, expected.g, expected.b, expected.a])
{
assert!(
(f32::from(*actual) - expected * 255.).abs() < 2.,
"{variant:?}: {actual:?} {expected:?}"
);
}
}
}
}
}
+19
View File
@@ -18,6 +18,25 @@ Keys use XKB keysyms (`Enter`, letters, `[`/`]`) or physical XKB names (`AD06`).
A binding replaces the same normalized chord/match mode. Duplicate bindings are A binding replaces the same normalized chord/match mode. Duplicate bindings are
invalid. A disabled binding must omit `action` and `argument`. invalid. A disabled binding must omit `action` and `argument`.
A modifier-only `Super` binding is optional:
```kdl
binding "Super" action="toggle-launcher"
```
Either Super key triggers this action when released after an isolated press.
The original press and release still reach the focused application. Other keys,
both Super keys together, pointer movement, clicks, scrolling, gestures and touch
cancel the tap; a mouse button already held also prevents it. Existing Super
chords, including Super+Space, keep their actions. Key repeat does not retrigger
the tap. Locking, shortcut inhibition, client keyboard grabs, focus changes and
device/session teardown cancel pending taps. Native input-method grabs remain
compatible. `Logo` and `Mod4` name the same tap, so they cannot be configured as
separate bindings. Physical matching is not supported for this trigger.
Use `binding "Super" disabled=#true` to remove a configured tap. Without a tap
binding, Super keeps its existing modifier behavior.
| Actions | Argument | | Actions | Argument |
| --- | --- | | --- | --- |
| `spawn` | Name in `commands` | | `spawn` | Name in `commands` |
+6 -4
View File
@@ -1,5 +1,7 @@
# Input # Input
Settings separates **Keyboard**, **Mouse** and **Touchpad**. In Keyboard, search installed XKB layouts and variants by their localized name or code; each layout has its own variant. Add, remove or reorder layouts without editing comma-separated configuration strings. The layout-switching menu changes only `grp:*` options and preserves other XKB options. Catalog loading runs in the background and uses `XKB_CONFIG_ROOT/rules/evdev.lst` when set, otherwise `/usr/share/X11/xkb/rules/evdev.lst`.
| `input` key | Type | Default | Meaning | | `input` key | Type | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `focus-follows-mouse` | boolean | `false` | Focus visible windows under the pointer without raising them or scrolling; click or use keyboard focus to reveal a window | | `focus-follows-mouse` | boolean | `false` | Focus visible windows under the pointer without raising them or scrolling; click or use keyboard focus to reveal a window |
@@ -25,7 +27,7 @@ The touchpad keys `tap`, `natural-scroll` and `disable-while-typing` default to
`true`. Set `disable-while-mouse-connected #true` to pause a built-in touchpad `true`. Set `disable-while-mouse-connected #true` to pause a built-in touchpad
while an external mouse is connected. It defaults to `false` and uses libinput's while an external mouse is connected. It defaults to `false` and uses libinput's
external-mouse policy on supported touchpads, including when a mouse is plugged external-mouse policy on supported touchpads, including when a mouse is plugged
in or removed during the session. The toggle is also in **Settings → Keyboard & mouse**. in or removed during the session. The toggle is also in **Settings → Touchpad**.
These device settings apply to DRM sessions; nested previews use the host's These device settings apply to DRM sessions; nested previews use the host's
physical input settings. physical input settings.
@@ -67,8 +69,8 @@ by other monitors and stop at the first/last workspace. Short or cancelled navig
swipes slide back; diagonal swipes do not navigate. Navigation pauses while locked, during swipes slide back; diagonal swipes do not navigate. Navigation pauses while locked, during
window grabs, or when an application inhibits shortcuts. window grabs, or when an application inhibits shortcuts.
Assign swipes in **Settings → Keyboard & mouse → Shortcuts** using the same actions and arguments as Assign swipes in **Settings → Shortcuts** using the same actions and arguments as
keyboard bindings. Choose a "Customize swipe" button to add an override. The keyboard bindings. Use **Customize gestures** from Touchpad, then choose a gesture and action in the shortcut editor. The
gesture keys are `Swipe3Up`, `Swipe3Down`, `Swipe3Left`, and `Swipe3Right`; 4 or 5 gesture keys are `Swipe3Up`, `Swipe3Down`, `Swipe3Left`, and `Swipe3Right`; 4 or 5
fingers are also supported. Changes reload live: fingers are also supported. Changes reload live:
@@ -85,7 +87,7 @@ keyboard modifiers or physical key matching. The trigger is automatically
recognized from its name. Gesture navigation uses native touchpad events in a recognized from its name. Gesture navigation uses native touchpad events in a
hardware session. hardware session.
Adjust recognition distance in **Settings → Keyboard & mouse**, or with Adjust recognition distance in **Settings → Touchpad**, or with
`swipe-threshold 80` under `input.touchpad` (integer 16–1000 logical pixels). `swipe-threshold 80` under `input.touchpad` (integer 16–1000 logical pixels).
## Mouse bindings ## Mouse bindings
+4
View File
@@ -1,5 +1,9 @@
# Displays # Displays
**Settings → Displays** shows the current arrangement, live resolution, refresh rate, scale and connection status. Save the current desktop as a named profile, rename or remove profiles, and configure resolution, refresh policy, scale, rotation/reflection and logical pixel position for each output. Unique hardware identities are preferred over connector names; identical monitors fall back to their current connectors. The arrangement diagram selects outputs but does not drag them.
Hardware changes use the compositor confirmation timeout. **Keep changes** accepts them; **Revert changes** restores the previous runtime arrangement and the display configuration captured before the first edit. **Restore previous display configuration** remains available for the source configuration after an automatic runtime rollback. Other settings and their comments are preserved. Nested sessions show the host-managed output and save configuration only; test physical modes and positioning in a standalone session.
## Color temperature ## Color temperature
Night light warms the display using its hardware gamma ramp. It is off by default. Night light warms the display using its hardware gamma ramp. It is off by default.
+1
View File
@@ -9,6 +9,7 @@ to `0` disables every path that can read the screen, including the built-in
screenshot command, not just the Wayland protocol. A session launched by hand screenshot command, not just the Wayland protocol. A session launched by hand
rather than through `ferese-session` needs `FERESE_ENABLE_SCREENCOPY=1` in its rather than through `ferese-session` needs `FERESE_ENABLE_SCREENCOPY=1` in its
environment for screenshots to work. environment for screenshots to work.
Settings checks the actual Wayland registry before offering shortcut recording. With shortcut inhibition disabled, use its modifier buttons and searchable key menu. Enabling the launch-time flag requires starting a new Ferese session.
Text input is available by default; Ctrl+Alt+Escape releases an active shortcut Text input is available by default; Ctrl+Alt+Escape releases an active shortcut
inhibitor. Use `feresectl --help` for runtime control commands. inhibitor. Use `feresectl --help` for runtime control commands.
+1 -1
View File
@@ -2,7 +2,7 @@
| `status` key | Type | Default | Meaning | | `status` key | Type | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `keybinding-guide` | boolean | `true` | Show the active shortcut guide at login until disabled in Settings → Keyboard & mouse → Shortcuts. | | `keybinding-guide` | boolean | `true` | Show the active shortcut guide at login until disabled in Settings → Shortcuts. |
| `low-battery-threshold` | integer 0–100 | `20` | Warning-color threshold | | `low-battery-threshold` | integer 0–100 | `20` | Warning-color threshold |
| `settings-command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action | | `settings-command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action |
+19
View File
@@ -37,6 +37,25 @@ librime 仍是 C++ 引擎,通过一个由安装头文件检查 ABI 的小型 C
## 构建与启动 ## 构建与启动
### 本机日常会话安装
安装清单现包含 `ferese-rime`,Arch 包声明 `librime` 和 `rime-luna-pinyin`
依赖。公共方案与词库仍由系统包维护于 `/usr/share/rime-data`,不复制个人词库。
直接 DRM 会话默认启用原生输入法,并仅在 Ferese 会话内覆盖继承的 IBus Qt 环境。
GTK 不强制指定输入模块,由实际的原生 Wayland/X11 后端自行选择;
会话激活环境清空旧的 `GTK_IM_MODULE`,避免 XWayland 应用加载 GTK3 Wayland 模块时崩溃。
这些变量也导入该会话的 D-Bus/systemd 激活环境。嵌套预览保留原有的显式输入法启动方式。
会话在 `$XDG_DATA_HOME/ferese/rime`(默认 `~/.local/share/ferese/rime`)建立独立用户目录,
仅在 `default.custom.yaml` 不存在时安装默认简体拼音和每页九个候选的配置。
不读取、迁移或覆盖 IBus/Fcitx 的个人词库,也保留该目录内已有的用户定制。
Rime 由会话固定版本的 systemd 用户临时服务运行,异常后重启并启用十秒输入循环 watchdog;
服务名称由合成器控制套接字稳定生成,Shell 硬崩溃恢复时仅替换该会话的旧输入法实例。
会话结束时停止,输入法失败保留桌面。可用 `journalctl --user -u 'ferese-rime-session@*'`
查看日志。`FERESE_ENABLE_INPUT_METHOD=0` 可在登录前显式停用此集成。
以下预览与硬件验证记录早于此安装集成,不能代替新安装会话的验收。
需要仓库的构建依赖,以及 `librime`、`xkbcommon` 和 Wayland 开发文件。 需要仓库的构建依赖,以及 `librime`、`xkbcommon` 和 Wayland 开发文件。
验证环境使用系统 librime 1.17.0、GTK4 4.24.1、RTX 4090、NVIDIA 615.78.08。 验证环境使用系统 librime 1.17.0、GTK4 4.24.1、RTX 4090、NVIDIA 615.78.08。
Rust 工具链由仓库固定为 1.95.0。 Rust 工具链由仓库固定为 1.95.0。
+428
View File
@@ -0,0 +1,428 @@
# Ferese Graphite 视觉方案
- 文档版本:1.7
- 整理日期:2026-10-28
- 视觉基线:`1e34ec4`、`9908dde`、`3264659`、`6671960`、`81b9970`;本版增加搜索选择、快捷键编辑及显示器配置流程。
- 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。
## 1. 视觉目标
以中性表面、细边界和清晰排版建立界面层次,让配置内容和操作状态成为视觉重点。
1. **中性底色**:使用石墨灰与纸白两种外观,通过相邻表面的明度差区分区域。
2. **少量强调色**:将颜色集中在选中、键盘焦点、开关与状态提示上。
3. **统一节奏**:统一页边距、分组间距、图标尺寸和导航行高。
4. **文字分层**:用字号、字重和次级文字颜色区分标题、说明与辅助信息。
5. **克制装饰**:降低光晕、玻璃染色和阴影的存在感,保留必要的层次提示。
6. **状态清晰**:分别表达悬停、按下、选中、键盘焦点和禁用状态。
7. **紧凑但舒展**:减少重复说明、强制换行和无用途的空白;将空间留给设置行、卡片之间的适度间距。
## 2. 参考视频分析
参考文件:`ir9g5tra15711hQD.mp4`,时长约 9.78 秒,2168 × 2160,60 fps。
视频展示的是界面重设计过程。可提取其配色、布局和排版原则;剪辑中的变化速度不能用于确定真实交互的延迟、缓动曲线或动画时长。
| 视频阶段 | 可观察的变化 | 在 Ferese 中的落地 |
| --- | --- | --- |
| 0–3 秒 | 调整导航、卡片与标题间距,统一对齐关系 | 建立主要间距尺度,统一导航行高和页面边距 |
| 3–5 秒 | 紫色光晕和高饱和背景逐渐收敛为中性表面 | 新增 Graphite 深浅主题,降低染色与阴影强度 |
| 5–7 秒 | 换用细线图标、缩小品牌标识、简化选中态 | 使用小型轮廓图标、中性选中底色与细指示条 |
| 7–9.78 秒 | 通过排版与内容形成层级,颜色集中在有意义的信息上 | 保留 MiSans,统一标题字重、说明文字和状态色使用方式 |
## 3. 主题与配色
主题族 ID 为 `graphite`。深色预设 ID 为 `graphite`,浅色预设 ID 为 `graphite-light`;通常选择主题族后,通过外观模式切换深浅。
### 3.1 基础色板
下表是预设源值。实际绘制颜色经过共享主题解析与对比度校正,组件应消费解析后的主题令牌,避免各自复制一份色值。
| 角色 | 深色 | 浅色 |
| --- | --- | --- |
| 基础表面 / 侧栏 | `#101113` | `#F7F8FA` |
| 抬升表面 | `#1B1D21` | `#FFFFFF` |
| 应用背景 | `#141518` | `#F1F3F5` |
| 主要文字 | `#E7E9ED` | `#23262C` |
| 次级文字 | `#9A9FA9` | `#626A76` |
| 强调色 | `#8EABD8` | `#3F6DAA` |
| 边界基色,含透明度 | `#FFFFFF16` | `#00000018` |
边界与控件底色会结合当前背景和文字颜色派生。普通分区采用细边界与轻微明度差;高对比度模式加强边界与文字校正。
### 3.2 材质参数
| 参数 | 深色 | 浅色 |
| --- | ---: | ---: |
| 材质不透明度 | 0.96 | 0.98 |
| 模糊半径 | 8 | 4 |
| 染色强度 | 0.06 | 0.02 |
| 阴影不透明度 | 0.12 | 0.08 |
这些数值由主题和组件共同解释。设置应用的正文表面保持清楚稳定;Shell 等支持材质的组件使用对应材质令牌。
导航选中背景采用中性色。用户明确选择的渐变主题,继续通过现有主题机制作用于相关控件和窗口焦点。
## 4. 字体与图标
### 4.1 字体层级
界面使用 **MiSans**;Rime 候选优先使用 **MiSans / MiSans TC**。
| 角色 | 逻辑字号 | 字重 / 用法 |
| --- | ---: | --- |
| 页面标题 | 16 px | Medium,比分组标题大 2 px |
| 分组标题,含侧栏和详细页 | 14 px | Bold |
| 总览卡片标题 | 14 px | Regular |
| 侧栏导航、普通字段名称 | 13 px | Regular |
| 字段补充说明与辅助提示 | 10.5 px | Regular、次级色,与 13 px 字段名称明确区分 |
| 卡片说明、代码编辑器 | 11 px | Regular |
| 保存状态 | 11 px | Regular、次级色 |
字段名称与短说明在同一段内自然排版,仅在可用宽度不足时换行;说明使用次级色区分,不另起固定的一行。页面标题下不重复展示泛化副标题。卡片高度跟随实际内容,适应中英文;细则与控件文字均使用标准字重。
### 4.2 图标
- 品牌标识:24 px。
- 页面与导航轮廓图标:19 px。
- 小型操作图标、卡片箭头:16 px。
- 普通图标使用主要或次级文字色,选中导航图标使用强调色。
- 图标与同行文字垂直居中;卡片箭头固定在行的末端。
## 5. 间距与形状
本文的 px 均指逻辑像素,截图中的物理像素会随显示缩放变化。
以 **4 px** 为主要间距基准;文本行间距、细边界和字形光学校正允许采用更细的数值。
| 项目 | 规格 |
| --- | --- |
| 页面上 / 下边距 | 16 / 12 px |
| 页面左右边距 | 20 px |
| 总览及详细页分组间距 | 20 px |
| 分组标题与设置容器间距 | 10 px |
| 同组卡片间距 | 16 px |
| 卡片内部边距 | 16 px |
| 卡片图标与文字间距 | 12 px |
| 卡片标题与说明间距 | 4 px |
| 设置行内边距 | 上下 12 px、左右 14 px |
| 设置行标签与控件间距 | 12 px |
| 同组设置行间距 | 4 px |
| 侧栏宽度 | 208 px |
| 折叠侧栏抽屉宽度 | 244 px |
| 侧栏内边距 | 上下 24 px、左右 12 px |
| 导航按钮高度 | 36 px |
| 导航按钮左右内边距 | 8 px |
| 导航行内元素间距 | 8 px |
| 选中指示条 | 2 × 14 px |
| 普通输入框圆角上限 | 7 px |
| 设置按钮圆角上限 | 5 px |
| 按钮组圆角上限 | 6 px |
| 按钮组内边距 | 5 px |
| 选项按钮内边距 | 上下 6 px、左右 10 px;页内标签为 8 / 14 px |
| 导航滚动条 | 宽 4 px,与选项间隔 8 px,单独占位 |
| 分区容器圆角上限 | 10 px |
| 常规边界 | 1 px |
| 键盘焦点轮廓 | 2 px |
圆角上限受现有主题圆角设置约束,方角等用户偏好继续生效。
## 6. 设置总览与自适应布局
Appearance 作为全部设置的入口,内容按实际功能分组:
| 分组 | 入口 |
| --- | --- |
| 快速访问 | 常规、全部配置 |
| 桌面 | 主题、壁纸、面板与 Shell、托盘图标 |
| 交互 | 窗口、键盘、鼠标、触控板、快捷键、辅助功能 |
| 系统 | 显示器、连接、通知、日历、锁屏、登录启动项 |
总览使用等宽卡片,文字区域占用弹性空间,箭头在卡片末端对齐。
- 总览**可用内容宽度 ≥ 680 逻辑像素**:双列。
- 总览**可用内容宽度 < 680 逻辑像素**:单列。
- **窗口宽度 < 960 逻辑像素**:侧栏收起,通过按钮打开导航抽屉。
- 页面内容框最大宽度为 1280 px,包含左右各 20 px 的留白;更宽窗口居中显示内容框。
- 卡片高度由内容决定,说明换行后保持完整显示。
- 超出视口的内容通过滚动访问,保存状态与撤销、重新加载操作位于页脚。
卡片列数依据正文可用宽度计算,侧栏折叠依据窗口宽度计算,二者使用独立阈值。
### 6.1 详细页分组
设置进入详细页后,继续按功能组织,分组标题采用 14 px 粗体,设置名称与细则采用标准字重。分组随实际字段出现,避免空组。
| 详细页 | 主要分组 |
| --- | --- |
| 常规 | 界面 |
| 日历 | 日期显示、日本祝日、自定义节假日 |
| 窗口 | 工作区与布局、间距与圆角、焦点与窗口效果 |
| 键盘 | 键盘布局与变体、布局切换、按键重复 |
| 鼠标 | 鼠标行为、滚轮与指针 |
| 触控板 | 触控板行为、指针与手势 |
| 面板与 Shell | 面板布局与项目、Shell 圆角与材质,分为两个子页 |
| 显示器 | 当前排列、配置档、输出设置 |
| 主题 | 配色与强调色、表面与透明度、字体与排版;自动外观按需展开 |
| 辅助功能 | 视觉辅助、动画 |
| 通知 | 通知行为、通知时长 |
| 锁屏 | 时钟与日期、锁屏背景、显示器电源,按支持的配置出现 |
| 托盘图标 | 应用托盘、应用图标、面板状态图标 |
| 快捷键 | 快捷键指南、自定义快捷键 |
连接和面板编辑器保留各自的功能分区;登录启动项按应用组织。窗口效果预览放入对应分组,高度收至 84 px,避免独占页首的大块空间。
### 6.2 搜索选择与快速绑定
- 键盘布局、变体与布局切换组合键使用可搜索菜单,显示本地化名称及 XKB 代码。变体列表随所属布局更新;多布局新增、删除和上移时保持布局与变体一一对应,不要求填写逗号分隔字符串。
- 菜单高度随过滤结果收缩,超出可用视口后滚动,避免固定高度产生大片空白。菜单与输入框使用现有石墨灰配色、MiSans 和 5 px 圆角。
- 快捷键独立入口提供新增、编辑、删除、搜索及当前快捷键指南。编辑时先选择组合键,再搜索动作,并按动作展示方向、工作区、模式或程序参数。
- 录入前检测会话是否提供快捷键屏蔽协议;只有收到屏蔽确认后才提示按键。Esc、失焦、离开页面或超时结束录入。不支持的会话直接提供修饰键按钮和按键菜单,避免误触原有快捷键。
- 手势从触控板页进入同一快捷键编辑器。自定义组合键覆盖会话级绑定;编辑过程中明确保留物理位置匹配与字符匹配的区别。
### 6.3 显示器与面板结构
- 显示器页先呈现当前输出排列,再提供配置档和所选输出设置。排列图支持负坐标、旋转与不同缩放,点击显示器进行选择;图示表达当前状态,数值设置表达所选配置档。
- 当前布局可保存为配置档;提供重命名、删除与启用操作。输出按硬件身份匹配,同型号身份重复时使用当前接口,避免将接口名固定解释为物理位置。
- 分辨率与刷新率继续采用硬件提供的选项;缩放、旋转/反射、逻辑像素位置在同一输出分组中调整。至少保留一个已连接输出,禁用开关以草稿和实时状态共同判断。
- 硬件变更保留确认和自动回退。主动回退时仅还原显示器配置片段;自动回退后仍可恢复原配置文件,避免重载时再次套用错误布局。嵌套预览明确提示物理输出由宿主管理。
- 面板子页管理几何、布局、项目与拖动编排;Shell 子页集中圆角、材质与不透明度,并链接托盘、日历和通知。移除无字段的空分组。
### 6.4 留白与窗口操作
- 双击页面顶部、底部、左右边缘及超宽窗口外侧的留白,切换最大化与还原;标题区域和侧栏未被控件占用的区域同样支持。
- 在上述区域移动指针超过拖动阈值后开始拖窗,单击不立即拖动。
- 设置内容与控件遵循自身交互,双击字段标签不会触发最大化。
- 窄窗口导航抽屉通过点击右侧空白或按 Esc 关闭;移除独立的“关闭导航”按钮。
## 7. 组件状态与对齐
| 状态 | 视觉表达 |
| --- | --- |
| 普通 | 中性表面、细边界、正常文字对比度 |
| 悬停 | 轻微改变底色明度,保持几何位置稳定 |
| 按下 | 比悬停更明确的明度或色彩反馈 |
| 选中 | 中性选中底色,配合小面积强调色;导航显示左侧指示条 |
| 键盘焦点 | 明确的 2 px 焦点轮廓,与选中态分别表达 |
| 禁用 | 使用次级文字与图标颜色,保持状态可辨认 |
| 高对比度 | 加强边界,并校正文字颜色以保持可读性 |
### 7.1 导航垂直居中
导航需要同时满足两层对齐:
1. 图标、文字与指示条在内容行内部垂直居中。
2. **整个内容行在 36 px 按钮及悬停区域内垂直居中。**
原生自定义按钮会从上内边距放置子内容,因此仅设置行内居中,会使整行仍处于按钮上方。实现中增加填满按钮高度的居中容器,确保普通、悬停、选中和焦点状态使用同一条中心线。
### 7.2 Rime 候选界面
- 候选横向排列,使用 MiSans 字体。
- 编号、中英文候选字按实际字形边界进行垂直居中。
- 背景、边界与选中态通过共享主题保持一致。
- 保留数字键选字以及数字行 `-`、`=` / `+` 翻页的既有行为。
### 7.3 导航、按钮组与本地化
- 导航滚动条嵌入独立通道,绘制区域和点击区域均不覆盖导航项。直接打开靠下的页面、切换页面及打开抽屉时,将当前选项完整滚入视口。
- 按钮组与内部按钮使用保留少量圆角的矩形。组背景采用轻微表面差,扩大内边距,避免形成明显的厚重外框。
- 标准设置名称、说明、枚举、面板项目、连接操作和提示通过统一语言目录显示。配置键、动作标识、应用名、SSID 和用户内容保留原值。
### 7.4 时间入口与通知标记
- 时间右侧紧邻通知标记,二者保持紧凑间距。标记为 **3 × 13 px**、圆角 **1.5 px** 的竖条;点击区域固定宽 **11 px**。
- 有通知时竖条为白色,无通知时透明,状态变化不改变布局宽度。点击仍可打开通知历史。
- 移除独立控制中心、Wi-Fi 和夜光快捷图标。旧面板配置在显示时合并时间相关入口,保留其余项目的 ID、可见性和自定义内容。
- 点击时间打开组合组件:上方日历,下方控制中心。控制中心保留外观、网络、蓝牙、音量、亮度与电源管理,移除夜光和勿扰开关。
- 音量区显示当前输出设备、百分比、音量滑块与明确标注的静音开关;设备暂不可用时显示简短状态。
- 电源区按设备能力显示节能、均衡、性能模式,以及重启、关机、挂起操作。重启、关机、挂起先打开确认弹窗;取消保持当前会话。
- 常规组合弹层宽 360 px,高度随内容变化并受可用空间限制;内容过长时可滚动。Morph 样式同步提供组合内容。
### 7.5 活动窗口区
- 活动窗口区位于面板右侧、系统托盘区左边,与工作区显示空间独立。排列所有已打开窗口,每个窗口一个应用图标,关闭窗口即移除;不保留已关闭应用的固定图标。
- 点击图标切换到对应窗口,支持同一应用的多个窗口和跨工作区切换。以启动顺序保持稳定位置,突出当前焦点窗口。
- 使用应用桌面入口声明的图标,保留 SVG / PNG 的原始配色;兼容 StartupWMClass 匹配、绝对图标路径及应用 ID 图标查找。没有可用图标时使用通用窗口图形。图标查询在后台执行,按运行中的应用缓存。
- 图标 18 px,按钮宽 30 px,间隔 2 px;悬停显示窗口标题,标题为空时使用应用标识。窗口过多时在分配宽度内横向滚动,默认最大宽 360 px。
- 面板编辑器可添加、隐藏、移动“活动窗口”项目;旧预设在合并时间入口时补齐该项目,新配置中主动移除后不会自动恢复。
### 7.6 日历与祝日函数
- 每周从周日开始;星期标题随 Ferese 语言变化,中文为“日、一、二、三、四、五、六”,英文为 Sun 至 Sat。
- 日本祝日和自定义节假日的日期使用红色粗体,悬停可查看名称;当前日期底色与节假日文字样式分别表达。
- 默认显示相邻月份的日期,在“日历 → 日期显示”中可关闭。开关不改变本月日期的列对齐。
- “日历 → 日本祝日”右侧仅提供代码图标作为函数编辑入口。点击展开 280 px 高的多行编辑器;未应用代码在收起、再次打开后继续保留。
- 编辑器提供“应用函数”和“恢复内置函数”。恢复只替换编辑内容,仍需应用;语法或日期结果错误时保留草稿和诊断,当前日历继续使用已保存函数。
- 内置源为 [japanese-holidays.rhai](../../crates/ferese-config/data/japanese-holidays.rhai),入口 `japanese_holidays(year)` 返回 `YYYY-MM-DD → 名称` 映射。辅助函数为 `date(year, month, day)`、`weekday(date)`(周日为 0)、`add_days(date, n)`、`days_in_year(year)` 和 `floor(value)`。
- 规则包含历史制度调整、振替休日、夹在祝日间的休日、即位等特例,以及 2020 / 2021 年奥运调整。适用范围为 1955–2099 年;已与内阁府公布的 1955–2027 年全部 1067 条日期和名称核对一致。未公布年份按规则推算,春秋分日以官报公布为准。
- 解释器离线运行,禁用导入与动态求值,并限制代码长度、运算量、调用深度和容器大小。编译结果和年度结果缓存,函数校验在后台执行。
- 自定义节假日支持 `YYYY-MM-DD` 单次日期和 `MM-DD` 每年重复;同一天可同时显示日本祝日与个人节假日名称。
来源:[内阁府祝日资料](https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html)、[国立天文台春秋分日说明](https://www.nao.ac.jp/faq/a0301.html)、[Rhai 运算限制](https://rhai.rs/book/safety/max-operations.html)。官方快照作为回归校验资料,日历运行时使用可编辑函数。
### 7.7 内容载入与绘制
- 主题预览由原生矩形、圆角和线条绘制,保留浅深配对、当前外观指示和选中标记;布局阶段不再生成与窗口尺寸相关的 SVG。主题与快捷键列表继续按可见行挂载。
- “全部配置”在首次进入或搜索时异步查询 schema,同一请求在完成前只执行一次。成功结果以不可变快照缓存;失败可重试;查询返回时使用最新配置并保留搜索内容。
- 显示器、连接、托盘应用与壁纸继续使用各自的后台读取或缩略图任务。新增页面应先显示内容结构和载入状态,将耗时采集放到后台,避免在视图构建、布局或绘制回调里执行子进程和文件解析。
- 显示器查询合并进行中请求,成功或失败返回后 1 秒内的自动访问复用结果,保留原有 2 秒轮询。托盘应用查询使用 2 秒短时缓存和 3 秒总超时;失败保留上次成功列表。手动刷新绕过缓存有效期,但不会叠加进行中请求。
- 面板预览在配置解析时保留规范化数据,仅在面板字段编辑后更新,避免每帧重复反序列化。启动时的主题快照在初始化中复用;手动重新载入配置使用后台任务,结果不会覆盖请求之后的新编辑。
- 窗口布局示意图使用缓存矢量几何,保留原有渐变、曲线、颜色、描边和比例;布局参数或主题改变时更新缓存。最大化与还原保持同一示意图尺寸时复用几何。
- 设置控件保留稳定 ID,使异步数据刷新、可见行挂载和页面切换保持键盘焦点与未提交输入。
## 8. 动效原则
保留现有动画与“减少动画”配置。动效服务于状态变化与空间关系,悬停和按下反馈保持简洁。
参考视频没有提供可验证的交互时长或缓动参数,因此本轮视觉方案没有新增动画时长或缓动曲线。窗口操作按 6.2 节执行。
## 9. 配置示例
```kdl
theme {
family "graphite"
mode "dark"
typography {
font-family "MiSans"
}
}
```
将 `mode` 改为 `"light"` 可使用同一主题族的浅色外观。现有其他主题仍可从“外观与设置 → 主题”切换。
```kdl
status {
calendar {
show-adjacent-days #true
custom-holidays "12-24" "个人假日"
}
}
```
祝日函数通过设置编辑,保存在 `status.calendar.japanese_holiday_function`;未覆盖时使用内置函数。
## 10. 验收与实现索引
### 验收要点
- 深浅外观均能区分背景、分区、文字和操作状态。
- 鼠标悬停与按下不会导致文字或图标跳动。
- 选中态与键盘焦点分别清楚可辨。
- 导航文字、图标和指示条相对完整按钮区域居中。
- 窄窗口单列、宽窗口双列,所有设置入口均可到达。
- 中文与英文换行后,卡片内容完整且互不重叠。
- 页面标题比分组标题大 2 px;分组为粗体,设置名称和细则为标准字体。
- 详细页按功能分组,短说明自然流动,不保留重复副标题和大型装饰留白。
- 双击边缘留白能最大化和还原,设置控件不会误触发;导航抽屉保留外侧点击和 Esc 关闭。
- 原生 Rime 候选保留横向排列、选字、翻页与主题跟随。
### 已完成的验证
本版密度与交互修订:
| 验证项 | 结果 |
| --- | --- |
| 设置测试 | 140 项通过,11 项可选检查默认跳过 |
| 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 |
| 配置测试 | 52 项单元测试及 11 项集成测试通过 |
| 共享主题 / 面板 / Shell | 34 / 69 / 256 项通过 |
| 布局检查 | 覆盖 480–1800 px 窗口宽度 |
| 原生预览 | 已检查深浅外观及 631 px 窄窗口 |
| 导航居中 | 全部 19 项;包含独立滚动条通道检查 |
| Rime | 原生输入、候选、翻页、主题变化与进程恢复通过 |
| 边缘双击 | 原生预览内 1272 px 最大化与 631 px 还原双向切换通过,配置内容不变 |
| 导航抽屉 | 已移除关闭按钮,外侧点击和 Esc 关闭均检查通过 |
| 时间控制面板 | 原生预览显示真实音频输出、音量与静音控件、电源模式;关机确认后取消,会话保持运行 |
| 活动窗口区 | 位于右侧托盘左边;三个窗口点击、相邻图标切换、跨工作区激活、关闭移除与重新打开均通过 |
| 应用图标 | 本机解析 SOUP、Strata、QQ、Teams 的 SVG / PNG;原生预览中 SOUP 显示现有品牌图标。仅重载 Shell,SOUP 和设置的进程、窗口及焦点保留 |
| 祝日函数 | 原生编辑并应用函数可改变日历;无限循环被拒绝且保存配置不变,恢复内置函数后配置校验通过 |
| 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 |
| 原生主题预览 | 检查配对颜色、透明外角和选中标记图层顺序;保留比例与键盘导航 |
| 后台 schema | 将查询人为延迟 2 秒,窗口提前显示载入状态;结果完成后显示选项,查询只执行一次;覆盖失败重试与最新配置接续 |
| 布局与变体菜单 | 原生搜索 `de` 与 `nodead`,选择德语及无死键变体,写回配置并经 compositor 校验通过 |
| 快捷键编辑 | 原生录入 `Super+Q`、中文搜索动作并保存停用覆盖;窗口保持运行。Esc 取消后窗口快捷键恢复;480 px 编辑器可用 |
| 录入能力检测 | 分别检查关闭与启用快捷键屏蔽协议的隔离会话;前者展示菜单选择,后者收到屏蔽确认后录入 |
| 显示配置 | 原生创建、校验、重命名与恢复配置档通过;恢复后保留键盘和快捷键设置。身份重复与禁用保护由模型测试覆盖 |
| 面板与 Shell | 原生检查分开的面板/Shell 子页,统一分组标题、页脚和面板样式翻译 |
| 本轮分类渲染 | WGPU、2 倍缩放下渲染 21 页共 378 帧,覆盖 631 / 1272 px;未变更实际显示器 |
滚动渲染专项记录沿用前版 `9908dde`;边缘双击与窄窗口抽屉本轮再次检查通过。本版原生检查使用独立嵌套预览,保留原预览中的 SOUP 和设置进程;没有重新执行关机、挂起或切换实际桌面会话。本轮没有切换主机显示器、默认应用关联或 GNOME 输入源。真实多屏模式切换、确认超时和驱动应用仍需在独立硬件会话复核;嵌套预览与模型测试不能代替这些检查。
### 性能采样
2026-10-28 在本机同一隔离嵌套会话对比 `3264659` 与本次修订,均为调试构建。原生设置窗口为 1272 × 760 逻辑像素、2 倍缩放;每个程序首次进入主题页一次,再切换三次。原生指标为页面切换至首个重绘回调的时间,不等同于显示器扫描呈现延迟。
| 指标 | 原实现 | 优化后 |
| --- | ---: | ---: |
| 原生主题页首次重绘 | 243.3 ms | 15.1 ms |
| 原生主题页后续切换中位数 | 14.0 ms | 10.8 ms |
| WGPU 主题滚动绘制与读回 P95 | 163.9 ms | 3.75 ms |
滚动采样使用相同的 1000 × 780 逻辑视口、1.25 倍缩放及 48 帧往返滚动,跳过最初 8 帧。软件回退的主题绘制 P95 仍约 304 ms,本轮未进一步调整其共有绘制成本。把 schema 查询人为延迟 2 秒时,约 765 ms 已显示载入界面,随后补齐选项;请求仅执行一次。
性能、原生界面与后台载入记录位于 `.artifacts/settings-performance-20261028/`,与此前验收资料分开保存。另保留主题键盘选取、配置写入与恢复检查,以及配对颜色、透明外角和选中标记的像素回归。
#### 全部分类与最大化 / 还原
随后以 `6671960` 为基线,检查全部 18 个页面(包含动效、快捷键子页)。同一配置、WGPU、2 倍缩放,每页测量首次构建、稳定帧、10 次往返尺寸变化及 4 次切换,共 324 帧;尺寸为 1272 × 760 与 631 × 568 逻辑像素。连接、托盘和三屏数据使用固定模型,异步硬件查询另由请求合并测试和原生预览检查覆盖。
| 指标(中位数,调试构建) | 基线 | 优化后 |
| --- | ---: | ---: |
| 窗口页尺寸变化:绘制与 GPU 读回 | 28.52 ms | 8.35 ms |
| 窗口页尺寸变化:构建、布局、绘制与读回总计 | 49.64 ms | 29.32 ms |
| 面板页稳定帧:构建与布局 | 2.20 ms | 1.66 ms |
| 面板页重复切换:构建与布局 | 2.15 ms | 1.47 ms |
其余分类重复切换的构建与布局中位数约 0.69–1.63 ms,没有发现同类 SVG 渲染尖峰。完整尺寸变化仍需重新排版文字,各页约 6–21 ms;首次进程的字体和渲染器初始化成本仍存在。这些读回指标用于实现对照,不代表物理屏幕的呈现延迟,也不表示每个页面都获得同等加速。
54 张相同尺寸的页面图像对照中,52 张逐像素一致;窗口页的两张宽窗口图仅有矢量替换差异,平均每通道误差约 0.071 / 255。独立示意图对照的平均每通道误差约 0.023 / 255,涵盖描边缩放和 SVG 渐变坐标的保持。请求合并、失败重试、预览缓存失效和后台重新载入期间的新输入保护均有回归检查。
独立嵌套会话中,18 页各切换两次,切换到首个重绘回调为 3.0–19.8 ms,两轮中位数分别为 7.9 / 7.4 ms。窗口页原生最大化 / 还原各测 12 次:新尺寸内容提交的中位数为基线 37.1 ms、优化后 35.2 ms,包含 IPC 轮询误差,未显示明显的原生提交延迟改善。视觉动画结束约 413 ms,保持原有动效;它与客户端绘制耗时分别记录,不以缩短动效替代渲染优化。
手动重新载入另用隔离配置和仅测试进程使用的读取拦截器人为延迟 2 秒:基线的读取线程就是界面主线程,新版的读取发生在工作线程。约 0.88 秒时,新版已经切换并显示主题页,基线仍等待读取。两版测试实例随后关闭,拦截器不用于保留的开发预览。预览运行程序的 SHA-256 与本次构建一致,检查前后配置的 SHA-256 一致。
本轮记录位于 `.artifacts/settings-audit-20261028/`,包含逐帧数据、前后图像、原生分类与窗口尺寸检查;这些资料只保留在本机。
应用图标修订记录位于 `.artifacts/application-icons-20261028/`。SOUP 桌面入口未声明图标,本机将已有 SOUP 品牌 SVG 注册在其应用 ID 下;图标资产的来源与校验值随记录保存。Ferese 使用通用应用图标解析规则。
### 实现位置
| 内容 | 文件 |
| --- | --- |
| 主题色与材质预设 | `crates/ferese-config/src/presets.rs` |
| 主题族注册 | `crates/ferese-config/src/families.rs` |
| 共享按钮、导航、输入框状态 | `crates/ferese-theme/src/controls.rs` |
| 页面结构与导航居中 | `shell/ferese-settings/src/pages.rs` |
| 详细页字段分组 | `shell/ferese-settings/src/schema.rs` |
| 行内说明与标题字重 | `shell/ferese-settings/src/form_controls.rs` |
| 日历设置与代码编辑器 | `shell/ferese-settings/src/calendar_controls.rs` |
| 日历日期和星期显示 | `crates/ferese-theme/src/calendar.rs` |
| 原生主题预览绘制 | `crates/ferese-theme/src/gallery/preview.rs` |
| 按需 schema 载入与缓存 | `shell/ferese-settings/src/all_options.rs` |
| 数据刷新合并与有效期 | `shell/ferese-settings/src/refresh.rs` |
| 规范化面板预览缓存 | `shell/ferese-settings/src/store.rs` |
| 窗口示意图缓存绘制 | `shell/ferese-settings/src/window_preview.rs` |
| 全部分类与尺寸性能采样 | `shell/ferese-settings/src/performance_tests.rs` |
| 祝日函数执行与校验 | `crates/ferese-config/src/calendar_rules.rs` |
| 时间弹层与控制中心 | `shell/ferese-shell/src/status_ui/` |
| 活动窗口与焦点切换 | `shell/ferese-shell/src/window_tray.rs` |
| 应用图标来源与通用兜底 | `shell/ferese-shell/src/application_icons.rs` |
| 总览卡片与双列布局 | `shell/ferese-settings/src/all_options.rs` |
| 分区表面与图标 | `shell/ferese-settings/src/visuals.rs` |
| 布局及对齐回归检查 | `shell/ferese-settings/src/scroll_tests.rs` |
前版记录保存在 `.artifacts/visual-reference-20261028/` 与 `.artifacts/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` |
+10 -1
View File
@@ -39,7 +39,8 @@ sudo pacman -S --needed python base-devel git curl cmake pkgconf wayland libxkbc
libinput systemd seatd mesa libdrm fontconfig freetype2 expat dbus \ libinput systemd seatd mesa libdrm fontconfig freetype2 expat dbus \
desktop-file-utils foot pam clang pipewire libpipewire wireplumber polkit \ desktop-file-utils foot pam clang pipewire libpipewire wireplumber polkit \
xdg-desktop-portal xdg-desktop-portal-gtk \ xdg-desktop-portal xdg-desktop-portal-gtk \
gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire pipewire-pulse slurp gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire pipewire-pulse slurp \
librime rime-luna-pinyin
``` ```
### Debian and Ubuntu ### Debian and Ubuntu
@@ -61,6 +62,14 @@ other distributions; check the [Smithay
dependencies](https://github.com/Smithay/smithay#system-dependencies) for equivalent dependencies](https://github.com/Smithay/smithay#system-dependencies) for equivalent
libraries. libraries.
This local fork also builds the native `ferese-rime` service. Outside Arch Linux,
install librime and its development headers plus the Rime Prelude, Luna Pinyin
and Stroke shared schemas under `/usr/share/rime-data` before building and logging
in. The Fedora and Debian/Ubuntu commands above describe the upstream desktop
dependencies and do not yet include these fork-specific Rime packages. Ferese
keeps its user dictionary separate from IBus and Fcitx; see the
[native Rime notes](development/rime-poc.md#本机日常会话安装).
### X11 support (optional) ### X11 support (optional)
To run X11-only applications, Ferese launches To run X11-only applications, Ferese launches
+14 -1
View File
@@ -1,11 +1,17 @@
# Shortcuts and gestures # Shortcuts and gestures
`Super` is usually the Windows key. These are the built-in defaults. Open `Super` is usually the Windows key. These are the built-in defaults. Open
**Settings → Keyboard & mouse → Shortcuts** to change bindings and gestures or disable the login **Settings → Shortcuts** to change bindings and gestures or disable the login
guide. Your saved bindings can override these defaults. guide. Your saved bindings can override these defaults.
The [example config](../packaging/config.kdl) adds the app, media and arrow-key The [example config](../packaging/config.kdl) adds the app, media and arrow-key
shortcuts listed below. shortcuts listed below.
## Edit bindings in Settings
Choose **Add shortcut**, record the combination, then search for an action. Recording temporarily inhibits Ferese shortcuts after the compositor confirms the inhibition; Escape, focus loss, leaving the page or the 15-second timeout releases it. A key menu with modifier buttons is also available for combinations reserved by a host desktop. Mouse buttons, wheel directions and three-, four- or five-finger swipes use the same editor.
The editor provides action-specific arguments and an application command field. Commands are parsed into an argument vector; no shell is invoked. Adding an existing custom combination updates that binding; editing another binding into a duplicate reports a conflict. Changes apply globally. A physical-key binding retains its matching mode when only its action changes; recording or choosing a new key switches to key-label matching.
## Launcher ## Launcher
**Super + Space** opens the launcher. Type to search, press **Tab** to accept **Super + Space** opens the launcher. Type to search, press **Tab** to accept
@@ -18,6 +24,13 @@ current workspace. Power off, restart and logout also require **Alt + Enter**.
Run `feresectl launcher toggle` to open or close it from a script. Run `feresectl launcher toggle` to open or close it from a script.
To also toggle the launcher by pressing and releasing Super alone, add
`binding "Super" action="toggle-launcher"` to your configuration. The action runs
on release; using another key, the pointer or a held mouse button cancels it, so
Super+Space and Super+mouse combinations continue to work. See
[modifier-only bindings](configuration/bindings.md) for input ownership and
cancellation behavior.
## Move and resize with the mouse ## Move and resize with the mouse
| Action | Result | | Action | Result |
+2 -1
View File
@@ -7,7 +7,8 @@ url='https://github.com/ferese-wm/ferese'
license=('MIT' 'Apache-2.0' 'BSD-2-Clause' 'BSD-3-Clause' 'BSL-1.0' 'CC0-1.0' 'CDLA-Permissive-2.0' 'ISC' 'MPL-2.0' 'Unicode-3.0' 'Unlicense' 'Zlib' 'OFL-1.1' 'Apache-2.0 WITH LLVM-exception') license=('MIT' 'Apache-2.0' 'BSD-2-Clause' 'BSD-3-Clause' 'BSL-1.0' 'CC0-1.0' 'CDLA-Permissive-2.0' 'ISC' 'MPL-2.0' 'Unicode-3.0' 'Unlicense' 'Zlib' 'OFL-1.1' 'Apache-2.0 WITH LLVM-exception')
depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm' depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm'
'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire' 'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire'
'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk' 'glib2') 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk' 'glib2'
'librime' 'rime-luna-pinyin')
makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'desktop-file-utils') makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'desktop-file-utils')
checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth')
optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection' optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection'
+2 -1
View File
@@ -7,7 +7,8 @@ url='https://github.com/ferese-wm/ferese'
license=('MIT' 'Apache-2.0' 'BSD-2-Clause' 'BSD-3-Clause' 'BSL-1.0' 'CC0-1.0' 'CDLA-Permissive-2.0' 'ISC' 'MPL-2.0' 'Unicode-3.0' 'Unlicense' 'Zlib' 'OFL-1.1' 'Apache-2.0 WITH LLVM-exception') license=('MIT' 'Apache-2.0' 'BSD-2-Clause' 'BSD-3-Clause' 'BSL-1.0' 'CC0-1.0' 'CDLA-Permissive-2.0' 'ISC' 'MPL-2.0' 'Unicode-3.0' 'Unlicense' 'Zlib' 'OFL-1.1' 'Apache-2.0 WITH LLVM-exception')
depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm' depends=('wayland' 'libxkbcommon' 'libinput' 'systemd-libs' 'seatd' 'mesa' 'libdrm'
'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire' 'fontconfig' 'freetype2' 'expat' 'dbus' 'python' 'bash' 'util-linux' 'systemd' 'pam' 'polkit' 'foot' 'pipewire'
'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk' 'glib2') 'wireplumber' 'libpipewire' 'gstreamer' 'xdg-desktop-portal' 'xdg-desktop-portal-gtk' 'glib2'
'librime' 'rime-luna-pinyin')
makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'git' 'desktop-file-utils') makedepends=('cargo' 'cmake' 'clang' 'pkgconf' 'python' 'git' 'desktop-file-utils')
checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth') checkdepends=('dbus' 'vulkan-swrast' 'xorg-xauth')
optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection' optdepends=('brightnessctl: brightness controls' 'slurp: screenshot and recording region selection'
+12
View File
@@ -33,6 +33,18 @@ unset WAYLAND_SOCKET
if [[ $backend == drm ]]; then if [[ $backend == drm ]]; then
unset DISPLAY WAYLAND_DISPLAY unset DISPLAY WAYLAND_DISPLAY
export FERESE_SESSION_IMPORT_ENV=1 export FERESE_SESSION_IMPORT_ENV=1
# This fork owns its native input-method service. Keep GNOME's IBus settings
# outside this session, including apps launched later through activation.
export FERESE_ENABLE_INPUT_METHOD=${FERESE_ENABLE_INPUT_METHOD:-1}
if [[ $FERESE_ENABLE_INPUT_METHOD == 1 ]]; then
# GTK selects its IM module from the actual GDK backend. Forcing its
# Wayland module crashes GTK3 consumers that initialize an X11 display.
unset GTK_IM_MODULE
export QT_IM_MODULE=wayland QT_IM_MODULES=wayland
export IBUS_ADDRESS=
unset SDL_IM_MODULE
export FERESE_RIME_CONTROL=${FERESE_RIME_CONTROL:-$XDG_RUNTIME_DIR/ferese/rime-control.sock}
fi
else else
export FERESE_SESSION_IMPORT_ENV=0 export FERESE_SESSION_IMPORT_ENV=0
fi fi
+66
View File
@@ -4,6 +4,7 @@ session_target_started=0
shell_pid= shell_pid=
agent_pid= agent_pid=
autostart_pid= autostart_pid=
rime_unit=
manage_session=0 manage_session=0
if [[ ${FERESE_SESSION_MODE:-desktop} == desktop && ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then if [[ ${FERESE_SESSION_MODE:-desktop} == desktop && ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then
manage_session=1 manage_session=1
@@ -19,6 +20,9 @@ cleanup() {
kill "$agent_pid" 2>/dev/null || true kill "$agent_pid" 2>/dev/null || true
wait "$agent_pid" 2>/dev/null || true wait "$agent_pid" 2>/dev/null || true
fi fi
if [[ -n $rime_unit ]]; then
timeout --kill-after=1s 6s systemctl --user stop "$rime_unit" || true
fi
if ((session_target_started)); then if ((session_target_started)); then
systemctl --user stop ferese-session.target || true systemctl --user stop ferese-session.target || true
fi fi
@@ -43,6 +47,10 @@ if ((manage_session)); then
XDG_SESSION_DESKTOP XDG_SESSION_DESKTOP
XDG_SESSION_TYPE XDG_SESSION_TYPE
) )
if [[ ${FERESE_ENABLE_INPUT_METHOD:-0} == 1 ]]; then
activation_vars+=("GTK_IM_MODULE=" QT_IM_MODULE QT_IM_MODULES IBUS_ADDRESS FERESE_RIME_CONTROL
"SDL_IM_MODULE=" "XMODIFIERS=${XMODIFIERS:-}")
fi
if command -v dbus-update-activation-environment >/dev/null; then if command -v dbus-update-activation-environment >/dev/null; then
if ! env WAYLAND_DISPLAY="$public_display" \ if ! env WAYLAND_DISPLAY="$public_display" \
dbus-update-activation-environment --systemd "${activation_vars[@]}"; then dbus-update-activation-environment --systemd "${activation_vars[@]}"; then
@@ -70,6 +78,59 @@ if ((manage_session)); then
fi fi
fi fi
release_dir=$(dirname -- "$(readlink -f -- "$0")") release_dir=$(dirname -- "$(readlink -f -- "$0")")
# Start the native input method on the public socket, without shell privileges.
# Pin the executable to this release and let systemd recover both crashes and a
# stalled input loop. A failed IME must leave the compositor and shell usable.
start_rime() {
local user_data=${FERESE_RIME_USER_DATA:-${XDG_DATA_HOME:-$HOME/.local/share}/ferese/rime}
local shared_data=${FERESE_RIME_SHARED_DATA:-/usr/share/rime-data}
local identity=${FERESE_SOCKET:-${XDG_RUNTIME_DIR:-}/$public_display}
local name variable load_state deadline
local -a environment=()
name=$(systemd-escape [email protected] "$identity") || return 1
[[ -x $release_dir/ferese-rime ]] || return 1
[[ -r $shared_data/luna_pinyin_simp.schema.yaml ]] || {
echo 'Ferese: install the Rime Luna Pinyin schemas before starting native input.' >&2
return 1
}
mkdir -p -- "$user_data" || return 1
# noclobber preserves existing customization, including a concurrent setup.
if [[ ! -e $user_data/default.custom.yaml && ! -L $user_data/default.custom.yaml ]]; then
(set -o noclobber; cat -- "$release_dir/rime/default.custom.yaml" > "$user_data/default.custom.yaml") || return 1
fi
# SIGKILL cannot run the previous helper's trap. The compositor control socket
# survives helper recovery, so replace only its old IME instance. Bound all
# manager calls and wait for the transient unit to be collected before reuse.
load_state=$(timeout --kill-after=1s 2s systemctl --user show "$name" --property=LoadState --value) || return 1
if [[ $load_state != not-found ]]; then
timeout --kill-after=1s 6s systemctl --user stop "$name" || return 1
deadline=$((SECONDS + 3))
while [[ $load_state != not-found ]]; do
((SECONDS < deadline)) || return 1
load_state=$(timeout --kill-after=1s 1s systemctl --user show "$name" --property=LoadState --value) || return 1
if [[ $load_state != not-found ]]; then sleep .05; fi
done
fi
for variable in HOME PATH LANG XDG_RUNTIME_DIR XDG_CONFIG_HOME XDG_CACHE_HOME XDG_DATA_HOME XDG_STATE_HOME \
DBUS_SESSION_BUS_ADDRESS XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE \
FERESE_SOCKET FERESE_RIME_CONTROL; do
if [[ -v $variable ]]; then environment+=("--setenv=$variable=${!variable}"); fi
done
if timeout --kill-after=1s 6s systemd-run --user --collect --quiet --no-block --unit="$name" --service-type=notify \
--property=Restart=on-failure --property=RestartSec=1 \
--property=StartLimitIntervalSec=60 --property=StartLimitBurst=5 \
--property=WatchdogSec=10 --property=WatchdogSignal=SIGKILL \
--property=TimeoutStartSec=45 --property=TimeoutStopSec=3 \
--property=BindsTo=ferese-session.target --property=PartOf=ferese-session.target \
"${environment[@]}" \
/usr/bin/env -u WAYLAND_SOCKET -u FERESE_SHELL_CONTROL_SOCKET -u FERESE_PRIVATE_X11_COOKIE \
WAYLAND_DISPLAY="$public_display" "$release_dir/ferese-rime" \
--user-data "$user_data" --shared-data "$shared_data"; then
rime_unit=$name
else
return 1
fi
}
# Agent failures must not take down a healthy shell. The compositor supervises # Agent failures must not take down a healthy shell. The compositor supervises
# this helper (and creates fresh private connections) when the shell itself exits. # this helper (and creates fresh private connections) when the shell itself exits.
supervise_agent() { supervise_agent() {
@@ -96,6 +157,11 @@ supervise_agent &
agent_pid=$! agent_pid=$!
"$release_dir/ferese-shell" & "$release_dir/ferese-shell" &
shell_pid=$! shell_pid=$!
if ((manage_session && session_target_started)) && [[ ${FERESE_ENABLE_INPUT_METHOD:-0} == 1 ]]; then
if ! start_rime; then
echo 'Ferese: native Rime could not start; the desktop remains available.' >&2
fi
fi
if ((manage_session)) && [[ ${FERESE_CLIENT_RESTART:-0} != 1 && -x $release_dir/feresectl ]]; then if ((manage_session)) && [[ ${FERESE_CLIENT_RESTART:-0} != 1 && -x $release_dir/feresectl ]]; then
env WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \ env WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \
"$release_dir/feresectl" autostart & "$release_dir/feresectl" autostart &
+6
View File
@@ -0,0 +1,6 @@
# First-run Ferese defaults; existing user customization is never overwritten.
patch:
schema_list:
- schema: luna_pinyin_simp
- schema: luna_pinyin
menu/page_size: 9
+2 -2
View File
@@ -169,11 +169,11 @@ def validate_bundle(bundle, *, installed=False):
actual = {str(path.relative_to(bundle)) for path in bundle.rglob('*') if path.is_file()} actual = {str(path.relative_to(bundle)) for path in bundle.rglob('*') if path.is_file()}
required = expected.keys() required = expected.keys()
if installed: if installed:
# Installed releases may predate the appearance wallpapers and license. # Installed releases may predate the appearance assets or native Rime.
# New bundles must include them; upgrades and rollback verify the older # New bundles must include them; upgrades and rollback verify the older
# release's recorded files without requiring these later additions. # release's recorded files without requiring these later additions.
required = required - {'wallpapers/ferese-wallpaper-dark.png', 'wallpapers/ferese-wallpaper-light.png', required = required - {'wallpapers/ferese-wallpaper-dark.png', 'wallpapers/ferese-wallpaper-light.png',
'licenses/Ferese-LICENSE.txt'} 'licenses/Ferese-LICENSE.txt', 'ferese-rime', 'rime/default.custom.yaml'}
required = {target for target in required if not target.startswith('activities/')} required = {target for target in required if not target.startswith('activities/')}
if not required <= seen or actual != seen | {'manifest.json'}: if not required <= seen or actual != seen | {'manifest.json'}:
+12
View File
@@ -6,6 +6,7 @@
"feresectl", "feresectl",
"ferese-lock", "ferese-lock",
"ferese-polkit-agent", "ferese-polkit-agent",
"ferese-rime",
"xdg-desktop-portal-ferese" "xdg-desktop-portal-ferese"
], ],
"files": [ "files": [
@@ -50,6 +51,17 @@
"mode": "0755", "mode": "0755",
"link": true "link": true
}, },
{
"source": "target/release/ferese-rime",
"target": "ferese-rime",
"mode": "0755",
"link": true
},
{
"source": "packaging/rime/default.custom.yaml",
"target": "rime/default.custom.yaml",
"mode": "0644"
},
{ {
"source": "target/release/xdg-desktop-portal-ferese", "source": "target/release/xdg-desktop-portal-ferese",
"target": "xdg-desktop-portal-ferese", "target": "xdg-desktop-portal-ferese",
+17 -5
View File
@@ -35,16 +35,24 @@ def check_tag(tag, release_version):
def licenses(destination, metadata): def licenses(destination, metadata):
destination.mkdir(parents=True, exist_ok=True) destination.mkdir(parents=True, exist_ok=True)
report = [] report = []
workspace_members = set(metadata.get('workspace_members', []))
workspace_root = Path(metadata.get('workspace_root', ROOT))
for package in sorted(metadata['packages'], key=lambda p: (p['name'], p['version'])): for package in sorted(metadata['packages'], key=lambda p: (p['name'], p['version'])):
if package['source'] is None: if package.get('id') in workspace_members:
continue continue
directory = Path(package['manifest_path']).parent directory = Path(package['manifest_path']).parent
source_hash = hashlib.sha256(package['source'].encode()).hexdigest()[:8] source = package['source']
if source is None:
# A vendored/path dependency is third-party code even though Cargo
# gives it no registry/Git source. Keep notices reproducible across
# checkout locations without exporting absolute manifest paths.
source = 'path+' + Path(os.path.relpath(directory, workspace_root)).as_posix()
source_hash = hashlib.sha256(source.encode()).hexdigest()[:8]
name = f"{package['name']}-{package['version']}-{source_hash}" name = f"{package['name']}-{package['version']}-{source_hash}"
files = set() files = set()
search = [directory] search = [directory]
if package['source'].startswith('git+'): if source.startswith('git+'):
for ancestor in directory.parents: for ancestor in directory.parents:
search.append(ancestor) search.append(ancestor)
if (ancestor / '.git').exists(): if (ancestor / '.git').exists():
@@ -66,7 +74,7 @@ def licenses(destination, metadata):
shutil.copyfile(path, target) shutil.copyfile(path, target)
report.append({'name': package['name'], 'version': package['version'], report.append({'name': package['name'], 'version': package['version'],
'source': package['source'], 'license': package['license'], 'source': source, 'license': package['license'],
'notice_directory': name}) 'notice_directory': name})
(destination / 'dependencies.json').write_text(json.dumps(report, indent=2) + '\n') (destination / 'dependencies.json').write_text(json.dumps(report, indent=2) + '\n')
@@ -113,7 +121,11 @@ def stage(destdir, binary_dir, pam):
planned.extend([(ROOT / f'packaging/pam.d/ferese-lock.{pam}', 'etc/pam.d/ferese-lock', 0o644), planned.extend([(ROOT / f'packaging/pam.d/ferese-lock.{pam}', 'etc/pam.d/ferese-lock', 0o644),
(ROOT / 'LICENSE', 'usr/share/licenses/ferese/LICENSE', 0o644), (ROOT / 'LICENSE', 'usr/share/licenses/ferese/LICENSE', 0o644),
(ROOT / 'README.md', 'usr/share/doc/ferese/README.md', 0o644)]) (ROOT / 'README.md', 'usr/share/doc/ferese/README.md', 0o644),
(ROOT / 'vendor/smithay/UPSTREAM.md',
'usr/share/doc/ferese/vendor/smithay/UPSTREAM.md', 0o644),
(ROOT / 'vendor/smithay/patches/shm-texture-cache-format.patch',
'usr/share/doc/ferese/vendor/smithay/shm-texture-cache-format.patch', 0o644)])
# Validate the complete inventory before writing any part of the package. # Validate the complete inventory before writing any part of the package.
for source, _, _ in planned: for source, _, _ in planned:
if not source.is_file(): if not source.is_file():
+10 -2
View File
@@ -83,8 +83,14 @@ def environment():
"IBUS_ADDRESS": "", "FERESE_SESSION_IMPORT_ENV": "0", "FERESE_ENABLE_INPUT_METHOD": "1", "IBUS_ADDRESS": "", "FERESE_SESSION_IMPORT_ENV": "0", "FERESE_ENABLE_INPUT_METHOD": "1",
"FERESE_RIME_CONTROL": str(RUNTIME / "rime-control.sock"), "FERESE_RIME_CONTROL": str(RUNTIME / "rime-control.sock"),
"FERESE_ENABLE_TEST_POINTER": "1", "FERESE_ENABLE_TEST_POINTER": "1",
"FERESE_ENABLE_SCREENCOPY": "1", "XDG_DATA_DIRS": f"{Path.home()}/.local/share:/usr/local/share:/usr/share"} "FERESE_ENABLE_SCREENCOPY": "1", "PATH": f"{ROOT}/target/debug:" + os.environ.get("PATH", "/usr/bin"), "XDG_DATA_DIRS": f"{Path.home()}/.local/share:/usr/local/share:/usr/share"}
values.update({f"XDG_{kind.upper()}_HOME": str(DATA / "profile" / kind) for kind in ("config", "data", "cache", "state")}) values.update({f"XDG_{kind.upper()}_HOME": str(DATA / "profile" / kind) for kind in ("config", "data", "cache", "state")})
# Keep Wayland, D-Bus and input methods private while allowing the nested
# shell to inspect the same audio server as the real desktop session.
host_runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
if (host_runtime / "pipewire-0").exists():
values["PIPEWIRE_RUNTIME_DIR"] = str(host_runtime)
values["TMPDIR"] = str(DATA / "tmp")
return values return values
@@ -124,6 +130,7 @@ def start():
return return
DATA.mkdir(mode=0o700,parents=True,exist_ok=True) DATA.mkdir(mode=0o700,parents=True,exist_ok=True)
DATA.chmod(0o700) DATA.chmod(0o700)
(DATA / "tmp").mkdir(mode=0o700, exist_ok=True)
if not (ROOT / "target/debug/ferese-rime").exists(): if not (ROOT / "target/debug/ferese-rime").exists():
raise RuntimeError("Build ferese, feresectl and ferese-rime first") raise RuntimeError("Build ferese, feresectl and ferese-rime first")
for kind in ("config/ferese", "data", "cache", "state"): for kind in ("config/ferese", "data", "cache", "state"):
@@ -131,6 +138,7 @@ def start():
RUNTIME.mkdir(mode=0o700, exist_ok=True) RUNTIME.mkdir(mode=0o700, exist_ok=True)
if RUNTIME.stat().st_uid != os.getuid() or RUNTIME.stat().st_mode & 0o077: if RUNTIME.stat().st_uid != os.getuid() or RUNTIME.stat().st_mode & 0o077:
raise RuntimeError("Unexpected private runtime owner or permissions") raise RuntimeError("Unexpected private runtime owner or permissions")
if not (DATA / "profile/config/ferese/config.kdl").exists():
(DATA / "profile/config/ferese/config.kdl").write_text(''' (DATA / "profile/config/ferese/config.kdl").write_text('''
xwayland { enabled #false; } xwayland { enabled #false; }
status { keybinding-guide #false; } status { keybinding-guide #false; }
@@ -148,7 +156,7 @@ theme { typography { font-family "MiSans"; }; }
raise RuntimeError("Parent Wayland display is missing") raise RuntimeError("Parent Wayland display is missing")
env = {**environment(), "WAYLAND_DISPLAY": str(host_display), "RUST_LOG": "info"} env = {**environment(), "WAYLAND_DISPLAY": str(host_display), "RUST_LOG": "info"}
start_unit(MAIN, ["/usr/bin/dbus-run-session", "--", str(ROOT / "target/debug/ferese"), "--backend", "nested", start_unit(MAIN, ["/usr/bin/dbus-run-session", "--", str(ROOT / "target/debug/ferese"), "--backend", "nested",
"--supervise-client", "--grant-effects", "--grant-shell-control", "--", "/usr/bin/ferese-shell"], env, "3G") "--supervise-client", "--grant-effects", "--grant-shell-control", "--", str(ROOT / "target/debug/ferese-shell")], env, "3G")
c = wait_for(context, "Development compositor did not publish its private connection") c = wait_for(context, "Development compositor did not publish its private connection")
(DATA / "context.json").write_text(json.dumps(c, indent=2) + "\n") (DATA / "context.json").write_text(json.dumps(c, indent=2) + "\n")
start_ime() start_ime()
+29
View File
@@ -0,0 +1,29 @@
// Private-session StatusNotifierItem fixture. Never sends application messages.
const {Gio,GLib}=imports.gi;
const output=ARGV[0];
const xml=`<node><interface name="org.kde.StatusNotifierItem">
<property name="Category" type="s" access="read"/><property name="Id" type="s" access="read"/>
<property name="Title" type="s" access="read"/><property name="Status" type="s" access="read"/>
<property name="WindowId" type="i" access="read"/><property name="IconName" type="s" access="read"/>
<property name="IconPixmap" type="a(iiay)" access="read"/><property name="IconThemePath" type="s" access="read"/>
<property name="OverlayIconName" type="s" access="read"/><property name="OverlayIconPixmap" type="a(iiay)" access="read"/>
<property name="AttentionIconName" type="s" access="read"/><property name="AttentionIconPixmap" type="a(iiay)" access="read"/>
<property name="AttentionMovieName" type="s" access="read"/><property name="ToolTip" type="(sa(iiay)ss)" access="read"/>
<property name="ItemIsMenu" type="b" access="read"/><property name="Menu" type="o" access="read"/>
<method name="Activate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="SecondaryActivate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="ContextMenu"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="Scroll"><arg type="i" direction="in"/><arg type="s" direction="in"/></method>
<signal name="NewIcon"/><signal name="NewStatus"><arg type="s"/></signal>
</interface></node>`;
let activated=0;
const pixels=Array.from({length:16*16*4},(_,i)=>[255,19,231,173][i%4]);
const item={Category:'ApplicationStatus',Id:'ferese-tray-fixture',Title:'Ferese tray fixture',Status:'Active',WindowId:0,
IconName:'',IconPixmap:[[16,16,pixels]],IconThemePath:'',OverlayIconName:'',OverlayIconPixmap:[],AttentionIconName:'',AttentionIconPixmap:[],AttentionMovieName:'',ToolTip:['',[],'Ferese tray fixture','Test icon'],ItemIsMenu:false,Menu:'/NO_DBUSMENU',
Activate(){GLib.file_set_contents(output,JSON.stringify({activated:++activated}));},SecondaryActivate(){},ContextMenu(){},Scroll(){}};
const loop=new GLib.MainLoop(null,false);
let exported;
Gio.bus_own_name(Gio.BusType.SESSION,'dev.ferese.TrayFixture',Gio.BusNameOwnerFlags.NONE,
connection=>{exported=Gio.DBusExportedObject.wrapJSObject(xml,item);exported.export(connection,'/StatusNotifierItem');},
(connection,name)=>{connection.call('org.kde.StatusNotifierWatcher','/StatusNotifierWatcher','org.kde.StatusNotifierWatcher','RegisterStatusNotifierItem',new GLib.Variant('(s)',[name]),null,Gio.DBusCallFlags.NONE,5000,null,(c,r)=>{try{c.call_finish(r);GLib.file_set_contents(output,JSON.stringify({activated,registered:true}));}catch(e){printerr(e.message);loop.quit();}});},()=>loop.quit());
loop.run();
@@ -0,0 +1,116 @@
"""Real shell + native Rime test on a private Xvfb/nested Wayland/bus.
No host GUI input or application profile is connected to this fixture.
FERESE_TEST_LAUNCHER_IME=1 python3 scripts/tests/test_launcher_rime_isolated.py
Build the release compositor/shell/Rime/feresectl and debug ferese-rime-probe first.
Synthetic screenshots and protocol traces stay under .artifacts/.
"""
from pathlib import Path
import hashlib,json,os,re,secrets,signal,shutil,subprocess,tempfile,time,unittest
REPO=Path(__file__).resolve().parents[2]
BASE=REPO/'.artifacts/launcher-rime-isolated'
COMPOSITOR=REPO/os.environ.get('FERESE_TEST_BINARY','target/release/ferese')
PROBE=REPO/os.environ.get('FERESE_TEST_PROBE','target/debug/ferese-rime-probe')
CTL=REPO/os.environ.get('FERESE_TEST_CTL','target/release/feresectl')
RIME=REPO/os.environ.get('FERESE_TEST_RIME_BINARY','target/release/ferese-rime')
def wait(fn,label,timeout=20):
end=time.monotonic()+timeout
while time.monotonic()<end:
value=fn()
if value:return value
time.sleep(.05)
raise RuntimeError('Timed out: '+label)
def run_case(label,shell,xdisplay,xauth):
case=BASE/label;case.mkdir(mode=0o700,exist_ok=True)
runtime=Path(tempfile.mkdtemp(prefix='flime-',dir='/run/user/1000'))
for name in ['config/ferese','data','state','cache','home','rime','tmp']:(case/name).mkdir(mode=0o700,parents=True,exist_ok=True)
(case/'config/ferese/config.kdl').write_text('xwayland { enabled #false; }\nanimations { enabled #false; }\nstatus { keybinding-guide #false; }\n')
(case/'rime/default.custom.yaml').write_text('patch:\n schema_list:\n - schema: luna_pinyin_simp\n')
conf=runtime/'bus.conf';conf.write_text('<busconfig><type>session</type><listen>unix:path='+str(runtime/'bus')+'</listen><policy context="default"><allow send_destination="*"/><allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
env={k:v for k,v in os.environ.items() if k not in ['WAYLAND_DISPLAY','WAYLAND_SOCKET','FERESE_SOCKET','FERESE_SHELL_CONTROL_SOCKET','DBUS_SESSION_BUS_ADDRESS','GTK_IM_MODULE','QT_IM_MODULE','IBUS_ADDRESS','XMODIFIERS']}
env.update(HOME=str(case/'home'),XDG_RUNTIME_DIR=str(runtime),XDG_CONFIG_HOME=str(case/'config'),XDG_DATA_HOME=str(case/'data'),XDG_CACHE_HOME=str(case/'cache'),XDG_STATE_HOME=str(case/'state'),XDG_DATA_DIRS='/usr/local/share:/usr/share',DISPLAY=xdisplay,XAUTHORITY=str(xauth),TMPDIR=str(case/'tmp'),FERESE_ENABLE_INPUT_METHOD='1',FERESE_ENABLE_SCREENCOPY='1',FERESE_SESSION_IMPORT_ENV='0',FERESE_RIME_CONTROL=str(runtime/'rime.sock'),LIBGL_ALWAYS_SOFTWARE='1',__EGL_VENDOR_LIBRARY_FILENAMES='/usr/share/glvnd/egl_vendor.d/50_mesa.json',WINIT_UNIX_BACKEND='x11',ICED_BACKEND='tiny-skia',PATH='/usr/bin',RUST_LOG='ferese=info')
children=[];log=(case/'session.log').open('w')
try:
bus=subprocess.Popen(['dbus-daemon','--config-file='+str(conf),'--nofork','--print-address=1'],env=env,stdout=subprocess.PIPE,stderr=log,text=True,start_new_session=True);children.append(bus)
env['DBUS_SESSION_BUS_ADDRESS']=bus.stdout.readline().strip();assert env['DBUS_SESSION_BUS_ADDRESS'].startswith('unix:')
comp=subprocess.Popen([str(COMPOSITOR),'--backend','nested','--supervise-client','--grant-effects','--grant-shell-control','--','/usr/bin/env','WAYLAND_DEBUG=client',str(shell)],env=env,stdout=log,stderr=log,start_new_session=True);children.append(comp)
def find_socket():
candidates=list(runtime.glob('ferese/instances/*/control.sock'))
return next((p for p in candidates if p.is_socket()),None)
sock=wait(find_socket,'private IPC')
def ctl(*args):return subprocess.check_output([str(CTL),'--socket',str(sock),*map(str,args)],env=env,text=True)
outputs=json.loads(ctl('-j','outputs'));assert len(outputs)==1 and outputs[0]['name']=='ferese-winit',outputs
wayland=wait(lambda:next((p for p in runtime.glob('wayland-*') if p.is_socket()),None),'private Wayland')
child_env=env|{'WAYLAND_DISPLAY':str(wayland),'FERESE_SOCKET':str(sock)}
ime=subprocess.Popen([str(RIME),'--user-data',str(case/'rime'),'--shared-data','/usr/share/rime-data'],env=child_env,stdout=log,stderr=log,start_new_session=True);children.append(ime)
wait(lambda:(runtime/'rime.sock').is_socket(),'native Rime')
def status():return json.loads(subprocess.check_output([str(RIME),'--status'],env=child_env,text=True))['state']
wait(lambda: 'ferese_shell_manager_v1' in (case/'session.log').read_text(), 'real shell control binding')
time.sleep(.5);ctl('launcher','toggle')
wait(lambda:status()['active'],'launcher text-input enable')
subprocess.run([str(RIME),'--mode','rime'],env=child_env,capture_output=True,text=True,check=True)
def cursor_x():
return [int(x) for x in re.findall(r'zwp_text_input_v3[@#]\d+\.set_cursor_rectangle\((-?\d+),', (case/'session.log').read_text())]
initial=wait(lambda:cursor_x(),'initial caret')[-1]
testenv=child_env|{'FERESE_TEST_INPUT':'1'}
def probe(*args):
p=subprocess.run([str(PROBE),*args],env=testenv,capture_output=True,text=True,timeout=10)
assert p.returncode==0,p.stderr
probe('--keys','nihao');time.sleep(.3)
(case/'preedit.png').write_bytes(subprocess.check_output([str(CTL),'--socket',str(sock),'screenshot'],env=env))
probe('--keys',' ');time.sleep(.6)
(case/'commit.png').write_bytes(subprocess.check_output([str(CTL),'--socket',str(sock),'screenshot'],env=env))
trace=(case/'session.log').read_text();xs=cursor_x()
result={'label':label,'shell':str(shell),'shellSha256':hashlib.sha256(shell.read_bytes()).hexdigest(),'compositorPid':comp.pid,'singlePrivateOutputVerified':True,'nativeRimePid':ime.pid,'launcherImeActive':status()['active'],'committedSyntheticHelloSeenOnProtocol':bool(re.search(r'commit_string\("你好"\)',trace)),'initialCaretX':initial,'caretXs':xs,'committedTextMovedCaret':bool(xs and xs[-1]>initial+8),'privateHeadlessX11Parent':xdisplay,'hostGuiUsed':False,'userProfilesUsed':False,'messagesSent':False}
(case/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n');print(json.dumps(result,ensure_ascii=False))
probe('--chord','Control_L+a','--key','BackSpace','--key','Escape')
return result
finally:
for child in reversed(children):
if child.poll() is None:
os.killpg(child.pid,signal.SIGTERM)
try:child.wait(timeout=4)
except subprocess.TimeoutExpired:os.killpg(child.pid,signal.SIGKILL);child.wait()
log.close()
# Keep synthetic schema/screenshots/logs as evidence; remove dead private sockets.
import shutil
shutil.rmtree(runtime)
@unittest.skipUnless(os.environ.get('FERESE_TEST_LAUNCHER_IME') == '1', 'opt-in isolated launcher/Rime test')
class LauncherRimeTest(unittest.TestCase):
def test_preedit_and_commit_reach_launcher(self):
for tool in ['Xvfb','xauth','dbus-daemon']:
if shutil.which(tool) is None:self.skipTest('missing fixture tool: '+tool)
shell=REPO/os.environ.get('FERESE_TEST_SHELL','target/release/ferese-shell')
for binary in [COMPOSITOR,PROBE,CTL,RIME,shell]:
if not binary.is_file():self.skipTest('build required binary: '+str(binary))
BASE.mkdir(mode=0o700,parents=True,exist_ok=True)
auth=BASE/'fixture.Xauthority';cookie=secrets.token_hex(16);xvfb=None
with (BASE/'xvfb.log').open('w') as log:
try:
for number in range(190,230):
if Path(f'/tmp/.X{number}-lock').exists() or Path(f'/tmp/.X11-unix/X{number}').exists():continue
display=':'+str(number)
subprocess.run(['xauth','-f',str(auth),'add',display,'MIT-MAGIC-COOKIE-1',cookie],capture_output=True,check=True)
auth.chmod(0o600)
xvfb=subprocess.Popen(['Xvfb',display,'-screen','0','1600x1000x24','-nolisten','tcp','-auth',str(auth)],stdout=log,stderr=log,start_new_session=True)
try:wait(lambda:Path(f'/tmp/.X11-unix/X{number}').is_socket(),'Xvfb startup',5)
except RuntimeError:
if xvfb.poll() is None:os.killpg(xvfb.pid,signal.SIGTERM);xvfb.wait()
xvfb=None;continue
break
self.assertIsNotNone(xvfb,'No private Xvfb display available')
result=run_case('current',shell.resolve(),display,auth)
self.assertTrue(result['singlePrivateOutputVerified'])
self.assertTrue(result['committedSyntheticHelloSeenOnProtocol'])
self.assertTrue(result['committedTextMovedCaret'], 'Wayland commit arrived but did not update launcher text/caret')
finally:
if xvfb is not None and xvfb.poll() is None:os.killpg(xvfb.pid,signal.SIGTERM);xvfb.wait(timeout=5)
auth.unlink(missing_ok=True)
if __name__=='__main__':unittest.main()
+38
View File
@@ -76,6 +76,14 @@ class PackageTests(unittest.TestCase):
self.assertTrue((dest / 'usr/share/ferese/wallpapers/ferese.png').is_file()) self.assertTrue((dest / 'usr/share/ferese/wallpapers/ferese.png').is_file())
self.assertEqual((dest / 'usr/libexec/ferese/ferese-session').stat().st_mode & 0o777, 0o755) self.assertEqual((dest / 'usr/libexec/ferese/ferese-session').stat().st_mode & 0o777, 0o755)
self.assertEqual((dest / 'usr/libexec/ferese').stat().st_mode & 0o777, 0o755) self.assertEqual((dest / 'usr/libexec/ferese').stat().st_mode & 0o777, 0o755)
self.assertTrue((dest / 'usr/libexec/ferese/ferese-rime').is_file())
self.assertIn('schema: luna_pinyin_simp',
(dest / 'usr/libexec/ferese/rime/default.custom.yaml').read_text())
provenance = dest / 'usr/share/doc/ferese/vendor/smithay'
for name in ('UPSTREAM.md', 'shm-texture-cache-format.patch'):
origin = PACKAGE.ROOT / 'vendor/smithay' / (
name if name == 'UPSTREAM.md' else 'patches/' + name)
self.assertEqual((provenance / name).read_bytes(), origin.read_bytes())
def test_same_version_from_different_sources_keeps_both_licenses(self): def test_same_version_from_different_sources_keeps_both_licenses(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -107,6 +115,36 @@ class PackageTests(unittest.TestCase):
self.assertEqual(report[0]['license'], 'MIT') self.assertEqual(report[0]['license'], 'MIT')
self.assertEqual(report[0]['source'], 'registry+test') self.assertEqual(report[0]['source'], 'registry+test')
def test_vendored_notices_exclude_workspace_members_and_are_relocatable(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
reports = []
for checkout in ('first-checkout', 'second-checkout'):
workspace = root / checkout / 'ferese'
packages = []
for name, folder, license_text in (
('ferese', workspace / 'compositor', 'workspace notice'),
('smithay', workspace / 'vendor/smithay', 'Smithay MIT notice'),
('external', workspace.parent / 'shared/external', 'external notice')):
folder.mkdir(parents=True)
(folder / 'LICENSE.txt').write_text(license_text)
packages.append({'id': f'path+file://{folder}#{name}@0.7.0',
'name': name, 'version': '0.7.0', 'source': None,
'manifest_path': str(folder / 'Cargo.toml'),
'license': 'MIT', 'license_file': None})
destination = workspace / 'notices'
PACKAGE.licenses(destination, {'packages': packages,
'workspace_members': [packages[0]['id']], 'workspace_root': str(workspace)})
report = json.loads((destination / 'dependencies.json').read_text())
self.assertEqual({p['name']: p['source'] for p in report}, {
'smithay': 'path+vendor/smithay', 'external': 'path+../shared/external'})
notices = {p['name']: (destination / p['notice_directory'] / 'LICENSE.txt').read_text()
for p in report}
self.assertEqual(notices, {'smithay': 'Smithay MIT notice', 'external': 'external notice'})
self.assertNotIn(str(root), (destination / 'dependencies.json').read_text())
reports.append(report)
self.assertEqual(reports[0], reports[1])
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+12
View File
@@ -141,6 +141,18 @@ m.main()
self.assertEqual(os.readlink(self.base / 'current'), 'releases/first') self.assertEqual(os.readlink(self.base / 'current'), 'releases/first')
self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first') self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first')
def test_upgrade_and_rollback_accept_installed_release_before_native_rime(self):
self.install()
self.without_files(self.base / 'releases/first', {'ferese-rime', 'rime/default.custom.yaml'})
# Older releases did not publish the native input command either.
(self.root / 'usr/local/bin/ferese-rime').unlink()
self.install(1)
self.assertTrue((self.root / 'usr/local/bin/ferese-rime').is_symlink())
self.assertTrue((self.base / 'releases/second/ferese-rime').is_file())
self.run_command('rollback')
self.assertFalse((self.root / 'usr/local/bin/ferese-rime').exists())
self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first')
def test_upgrade_and_rollback_accept_installed_release_before_appearance_wallpapers(self): def test_upgrade_and_rollback_accept_installed_release_before_appearance_wallpapers(self):
self.install() self.install()
legacy = self.base / 'releases/first' legacy = self.base / 'releases/first'
+166 -4
View File
@@ -13,19 +13,46 @@ SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-session-shell'
class SessionStartupTest(unittest.TestCase): class SessionStartupTest(unittest.TestCase):
def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False, def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False,
display=None, xauthority=None, extra_env=None, programs_override=None): display=None, xauthority=None, extra_env=None, programs_override=None,
user_customization=None, restart_after_kill=False):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
helper = root / 'ferese-session-shell' helper = root / 'ferese-session-shell'
shutil.copyfile(SCRIPT, helper) shutil.copyfile(SCRIPT, helper)
(root / 'rime').mkdir()
shutil.copyfile(SCRIPT.parent / 'rime/default.custom.yaml', root / 'rime/default.custom.yaml')
shared = root / 'rime-shared'
shared.mkdir()
(shared / 'luna_pinyin_simp.schema.yaml').write_text('fixture')
units = root / 'units'
units.mkdir()
foreign_unit = units / '[email protected]'
foreign_unit.write_text('foreign service')
if user_customization is not None:
user_rime = root / 'data/ferese/rime'
user_rime.mkdir(parents=True)
(user_rime / 'default.custom.yaml').write_text(user_customization)
programs = { programs = {
'dbus-update-activation-environment': ( 'dbus-update-activation-environment': (
'printf "import:%s\\n" "$WAYLAND_DISPLAY" >> "$TEST_ROOT/events"\n' 'printf "import:%s\\n" "$WAYLAND_DISPLAY" >> "$TEST_ROOT/events"\n'
'printf "vars:%s\\n" "$*" >> "$TEST_ROOT/events"\n'), 'printf "vars:%s\\n" "$*" >> "$TEST_ROOT/events"\n'),
'dbus-send': 'echo reload-bus >> "$TEST_ROOT/events"\n', 'dbus-send': 'echo reload-bus >> "$TEST_ROOT/events"\n',
'systemctl': 'echo "$*" >> "$TEST_ROOT/events"\nif [[ $* == "--user start ferese-session.target" && $TEST_FAIL == 1 ]]; then exit 1; fi\n', 'systemctl': (
'echo "$*" >> "$TEST_ROOT/events"\n'
'if [[ $* == "--user start ferese-session.target" && $TEST_FAIL == 1 ]]; then exit 1; fi\n'
'if [[ ${2:-} == show && ${3:-} == ferese-rime-session@* ]]; then\n'
' if [[ -f $TEST_ROOT/units/$3 ]]; then echo loaded; else echo not-found; fi\n'
'elif [[ ${2:-} == stop && ${3:-} == ferese-rime-session@* ]]; then\n'
' rm -f -- "$TEST_ROOT/units/$3"\nfi\n'),
'ferese-polkit-agent': 'exec sleep 30\n', 'ferese-polkit-agent': 'exec sleep 30\n',
'ferese-shell': 'printf "shell:%s\\n" "$WAYLAND_SOCKET" >> "$TEST_ROOT/events"\nif [[ $TEST_WAIT == 1 ]]; then exec sleep 30; fi\nexit "$TEST_STATUS"\n', 'ferese-shell': 'printf "shell:%s\\n" "$WAYLAND_SOCKET" >> "$TEST_ROOT/events"\nif [[ $TEST_WAIT == 1 ]]; then exec sleep 30; fi\nexit "$TEST_STATUS"\n',
'ferese-rime': 'exit 0\n',
'systemd-run': (
'for arg; do if [[ $arg == --unit=* ]]; then unit=${arg#--unit=}; fi; done\n'
'(set -o noclobber; echo running > "$TEST_ROOT/units/$unit") || exit 1\n'
'printf "rime-arg:%s\\n" "$@" >> "$TEST_ROOT/events"\n'
'printf "rime-config:%s\\n" "$(cat "$XDG_DATA_HOME/ferese/rime/default.custom.yaml")" >> "$TEST_ROOT/events"\n'
'touch "$TEST_ROOT/rime-started"\n'),
} }
programs.update(programs_override or {}) programs.update(programs_override or {})
for name, body in programs.items(): for name, body in programs.items():
@@ -36,7 +63,10 @@ class SessionStartupTest(unittest.TestCase):
FERESE_SESSION_MODE='desktop', FERESE_SESSION_MODE='desktop',
FERESE_SESSION_IMPORT_ENV=str(int(direct)), WAYLAND_DISPLAY='private-display', FERESE_SESSION_IMPORT_ENV=str(int(direct)), WAYLAND_DISPLAY='private-display',
FERESE_PUBLIC_WAYLAND_DISPLAY='public-display', WAYLAND_SOCKET='77', FERESE_PUBLIC_WAYLAND_DISPLAY='public-display', WAYLAND_SOCKET='77',
TEST_FAIL=str(int(fail_target)), TEST_STATUS=str(shell_status), TEST_WAIT=str(int(terminate))) FERESE_SOCKET=str(root / 'control.sock'),
XDG_DATA_HOME=str(root / 'data'), FERESE_RIME_SHARED_DATA=str(shared),
TEST_FAIL=str(int(fail_target)), TEST_STATUS=str(shell_status),
TEST_WAIT=str(int(terminate or restart_after_kill)))
env.pop('DISPLAY', None) env.pop('DISPLAY', None)
env.pop('XAUTHORITY', None) env.pop('XAUTHORITY', None)
if display is not None: if display is not None:
@@ -44,7 +74,25 @@ class SessionStartupTest(unittest.TestCase):
if xauthority is not None: if xauthority is not None:
env['XAUTHORITY'] = xauthority env['XAUTHORITY'] = xauthority
env.update(extra_env or {}) env.update(extra_env or {})
child = subprocess.Popen(['bash', str(helper)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) child = subprocess.Popen(['bash', str(helper)], env=env, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, start_new_session=True)
if restart_after_kill:
deadline = time.monotonic() + 3
while not (root / 'rime-started').exists():
if time.monotonic() > deadline:
os.killpg(child.pid, signal.SIGKILL)
self.fail('Native input unit did not start')
time.sleep(.01)
child.kill()
child.wait(timeout=2)
# The helper cannot run cleanup after SIGKILL. Dispose of fake
# shell/agent processes, while its fake systemd service survives.
os.killpg(child.pid, signal.SIGKILL)
child.communicate(timeout=2)
self.assertEqual(len(list(units.glob('*.service'))), 2)
env.update(TEST_WAIT='0', FERESE_CLIENT_RESTART='1')
child = subprocess.Popen(['bash', str(helper)], env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if terminate: if terminate:
deadline = time.monotonic() + 3 deadline = time.monotonic() + 3
while not (root / 'events').exists() or 'shell:77' not in (root / 'events').read_text(): while not (root / 'events').exists() or 'shell:77' not in (root / 'events').read_text():
@@ -54,6 +102,7 @@ class SessionStartupTest(unittest.TestCase):
time.sleep(.01) time.sleep(.01)
child.send_signal(signal.SIGTERM) child.send_signal(signal.SIGTERM)
child.communicate(timeout=4) child.communicate(timeout=4)
self.assertEqual(foreign_unit.read_text(), 'foreign service')
return child.returncode, (root / 'events').read_text().splitlines() return child.returncode, (root / 'events').read_text().splitlines()
@staticmethod @staticmethod
@@ -78,6 +127,78 @@ class SessionStartupTest(unittest.TestCase):
self.assertEqual(status, 0) self.assertEqual(status, 0)
self.assertIn('FERESE_SOCKET=/run/user/1000/ferese/control.sock', self.imported_vars(events)) self.assertIn('FERESE_SOCKET=/run/user/1000/ferese/control.sock', self.imported_vars(events))
def test_native_rime_is_release_pinned_supervised_and_stopped_with_its_session(self):
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'})
self.assertEqual(status, 0)
args = [event.removeprefix('rime-arg:') for event in events if event.startswith('rime-arg:')]
self.assertIn('--service-type=notify', args)
for setting in ('Restart=on-failure', 'WatchdogSec=10', 'StartLimitBurst=5',
'BindsTo=ferese-session.target'):
self.assertIn('--property=' + setting, args)
self.assertIn('WAYLAND_DISPLAY=public-display', args)
self.assertIn('WAYLAND_SOCKET', args)
self.assertIn('FERESE_SHELL_CONTROL_SOCKET', args)
executable = next(arg for arg in args if arg.endswith('/ferese-rime'))
self.assertNotEqual(executable, '/usr/bin/ferese-rime')
unit = next(arg.removeprefix('--unit=') for arg in args if arg.startswith('--unit='))
self.assertIn('--user stop ' + unit, events)
self.assertEqual(events[-1], '--user stop ferese-session.target')
self.assertIn(' - schema: luna_pinyin_simp', events)
def test_native_rime_preserves_existing_user_customization(self):
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'},
user_customization='my-custom-dictionary: preserved\n')
self.assertEqual(status, 0)
self.assertIn('rime-config:my-custom-dictionary: preserved', events)
def test_hard_killed_helper_replaces_only_its_existing_native_rime_unit(self):
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'},
restart_after_kill=True)
self.assertEqual(status, 0)
starts = [event.removeprefix('rime-arg:--unit=') for event in events
if event.startswith('rime-arg:--unit=')]
self.assertEqual(len(starts), 2)
self.assertEqual(starts[0], starts[1])
stopped = '--user stop ' + starts[0]
start_events = [index for index, event in enumerate(events) if event == 'rime-arg:--unit=' + starts[0]]
self.assertIn(stopped, events[start_events[0] + 1:start_events[1]])
self.assertNotIn('--user stop [email protected]', events)
def test_unresponsive_old_rime_unit_blocks_replacement_without_stopping_the_desktop(self):
# Model a timed-out manager request. The helper must not create a second
# input service while it cannot confirm the state of the first one.
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'},
programs_override={'systemctl': (
'echo "$*" >> "$TEST_ROOT/events"\n'
'if [[ ${2:-} == show ]]; then exit 124; fi\n')})
self.assertEqual(status, 0)
self.assertIn('shell:77', events)
self.assertFalse(any(event.startswith('rime-arg:') for event in events))
def test_failed_rime_start_leaves_the_desktop_running(self):
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'},
programs_override={'systemd-run': 'exit 7\n'})
self.assertEqual(status, 0)
self.assertIn('shell:77', events)
self.assertFalse(any(event.startswith('--user stop ferese-rime-') for event in events))
def test_nested_session_never_starts_native_rime(self):
status, events = self.run_session(direct=False, extra_env={'FERESE_ENABLE_INPUT_METHOD': '1'})
self.assertEqual(status, 0)
self.assertEqual(events, ['shell:77'])
def test_native_input_environment_is_imported_for_activated_applications(self):
status, events = self.run_session(extra_env={'FERESE_ENABLE_INPUT_METHOD': '1',
'GTK_IM_MODULE': '', 'QT_IM_MODULE': 'wayland', 'QT_IM_MODULES': 'wayland',
'IBUS_ADDRESS': '', 'XMODIFIERS': '@im=ibus', 'FERESE_RIME_CONTROL': '/private/rime.sock'})
self.assertEqual(status, 0)
imported = self.imported_vars(events)
for name in ('QT_IM_MODULE', 'QT_IM_MODULES', 'IBUS_ADDRESS', 'FERESE_RIME_CONTROL'):
self.assertIn(name, imported)
self.assertIn('GTK_IM_MODULE=', imported)
self.assertIn('SDL_IM_MODULE=', imported)
self.assertIn('XMODIFIERS=@im=ibus', imported)
def test_a_managed_x11_endpoint_is_imported_for_dbus_activators(self): def test_a_managed_x11_endpoint_is_imported_for_dbus_activators(self):
status, events = self.run_session(display=':7', xauthority='/run/user/1000/ferese-xauth-abcd') status, events = self.run_session(display=':7', xauthority='/run/user/1000/ferese-xauth-abcd')
self.assertEqual(status, 0) self.assertEqual(status, 0)
@@ -161,5 +282,46 @@ class SessionStartupTest(unittest.TestCase):
self.assertEqual(events.count('autostart'), int(restarted == '0')) self.assertEqual(events.count('autostart'), int(restarted == '0'))
class SessionLauncherTest(unittest.TestCase):
def launch(self, nested=False, enabled=None):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
launcher = root / 'ferese-session'
shutil.copyfile(SCRIPT.parent / 'ferese-session', launcher)
for name, body in {'ferese': '/usr/bin/env > "$TEST_ROOT/environment"\n',
'systemctl': 'exit 0\n'}.items():
path = root / name
path.write_text('#!/bin/bash\n' + body)
path.chmod(0o755)
env = dict(os.environ, PATH=f'{root}:{os.environ["PATH"]}', TEST_ROOT=directory,
HOME=directory, XDG_RUNTIME_DIR=directory, DBUS_SESSION_BUS_ADDRESS='fixture',
XDG_STATE_HOME=str(root / 'state'), GTK_IM_MODULE='ibus', QT_IM_MODULE='ibus',
QT_IM_MODULES='wayland;ibus', IBUS_ADDRESS='old-ibus', XMODIFIERS='@im=ibus', SDL_IM_MODULE='ibus')
env.pop('FERESE_ENABLE_INPUT_METHOD', None)
if enabled is not None:
env['FERESE_ENABLE_INPUT_METHOD'] = enabled
subprocess.run(['bash', str(launcher), *(['--nested'] if nested else [])],
env=env, capture_output=True, check=True)
return dict(line.split('=', 1) for line in (root / 'environment').read_text().splitlines())
def test_drm_session_overrides_inherited_ibus_only_for_native_input(self):
env = self.launch()
self.assertEqual(env['FERESE_ENABLE_INPUT_METHOD'], '1')
self.assertNotIn('GTK_IM_MODULE', env)
for variable in ('QT_IM_MODULE', 'QT_IM_MODULES'):
self.assertEqual(env[variable], 'wayland')
self.assertEqual(env['IBUS_ADDRESS'], '')
self.assertEqual(env['XMODIFIERS'], '@im=ibus')
self.assertNotIn('SDL_IM_MODULE', env)
self.assertTrue(env['FERESE_RIME_CONTROL'].endswith('/ferese/rime-control.sock'))
def test_nested_and_explicit_opt_out_preserve_input_configuration(self):
for options in ({'nested': True}, {'enabled': '0'}):
with self.subTest(options=options):
env = self.launch(**options)
self.assertEqual(env['QT_IM_MODULE'], 'ibus')
self.assertNotEqual(env.get('FERESE_ENABLE_INPUT_METHOD'), '1')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+9 -4
View File
@@ -30,6 +30,8 @@ fn main() -> iced::Result {
ferese_config::config_path() ferese_config::config_path()
}; };
ferese_config::locale::load(config.as_deref());
if !args.is_empty() && !preview && args != ["--foreground"] { if !args.is_empty() && !preview && args != ["--foreground"] {
eprintln!("Usage: ferese-lock [--preview | --foreground]"); eprintln!("Usage: ferese-lock [--preview | --foreground]");
std::process::exit(2); std::process::exit(2);
@@ -430,7 +432,7 @@ impl Locker {
let margin = if size.width < 640. { 24. } else { 36. }; let margin = if size.width < 640. { 24. } else { 36. };
let top = (size.height * 0.09).max(24.); let top = (size.height * 0.09).max(24.);
let a = &self.appearance; let a = &self.appearance;
let mut input = widget::text_input("Password", self.password.as_str()) let mut input = widget::text_input(ferese_config::tr!("Password"), self.password.as_str())
.password() .password()
.padding([4, 10]) .padding([4, 10])
.size(14) .size(14)
@@ -494,9 +496,9 @@ impl Locker {
input, input,
self.label( self.label(
if self.caps_lock { if self.caps_lock {
"Caps Lock is on" ferese_config::tr!("Caps Lock is on")
} else { } else {
self.status ferese_config::locale::text(self.status)
}, },
12 12
) )
@@ -542,7 +544,10 @@ impl Locker {
container(identity).center_x(Length::Fill), container(identity).center_x(Length::Fill),
widget::Space::new().height(Length::Fill), widget::Space::new().height(Length::Fill),
row![ row![
row![ferese_symbol(), self.label("Ferese", 13)] row![
ferese_symbol(),
self.label(ferese_config::tr!("Ferese"), 13)
]
.spacing(5) .spacing(5)
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
widget::Space::new().width(Length::Fill), widget::Space::new().width(Length::Fill),
Loaded 100 of 515 files, more files were not shown because too many files have changed in this diff. Show more