Add live config reload, session locking and managed daemons
Refine focus gradients, shell workspace controls and popup motion. Correct floating stacking and preserve client-chosen dialog sizes. Add lock protocol probes and session integration.
This commit is contained in:
1 parent
b422448d88
commit
672b4e2b60
41 files changed
+2694
-298
No files matched your search
Generated
+10
@@ -1580,6 +1580,7 @@ dependencies = [
|
|||||||
"fontdb",
|
"fontdb",
|
||||||
"image",
|
"image",
|
||||||
"libc",
|
"libc",
|
||||||
|
"notify",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"smithay",
|
"smithay",
|
||||||
@@ -1625,6 +1626,14 @@ dependencies = [
|
|||||||
name = "ferese-layout"
|
name = "ferese-layout"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ferese-lock-probe"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ferese-malformed-client"
|
name = "ferese-malformed-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1645,6 +1654,7 @@ name = "ferese-shell"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ferese-protocols",
|
"ferese-protocols",
|
||||||
|
"iced_accessibility",
|
||||||
"jiff",
|
"jiff",
|
||||||
"libcosmic",
|
"libcosmic",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ members = [
|
|||||||
"tools/ferese-effects-probe",
|
"tools/ferese-effects-probe",
|
||||||
"tools/ferese-malformed-client",
|
"tools/ferese-malformed-client",
|
||||||
"tools/ferese-shell-control-probe",
|
"tools/ferese-shell-control-probe",
|
||||||
|
"tools/ferese-lock-probe",
|
||||||
"tools/ferese-shell-spike",
|
"tools/ferese-shell-spike",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|||||||
@@ -11,6 +11,55 @@ For a versioned installation alongside Plasma in SDDM, see
|
|||||||
[installation and recovery](docs/installation.md). `Super+Shift+E` or
|
[installation and recovery](docs/installation.md). `Super+Shift+E` or
|
||||||
`feresectl exit` logs out immediately; save your work first.
|
`feresectl exit` logs out immediately; save your work first.
|
||||||
|
|
||||||
|
## Live configuration
|
||||||
|
|
||||||
|
Saving `~/.config/ferese/config.toml` (or `$XDG_CONFIG_HOME/ferese/config.toml`)
|
||||||
|
reloads it automatically. Atomic editor saves are supported and bursts are
|
||||||
|
debounced. Invalid edits keep the last working compositor and shell config;
|
||||||
|
the rejection is logged. Idle watching does not reread the file.
|
||||||
|
|
||||||
|
Use `feresectl reload-config` to reload manually and report validation errors.
|
||||||
|
Live sources are limited to 60 KiB for safe delivery over the private protocol.
|
||||||
|
|
||||||
|
Appearance, fonts, wallpaper, animations, keybindings, keyboard layout/repeat,
|
||||||
|
gaps, scrolling focus policy/default widths, and bar geometry reload live.
|
||||||
|
Wallpaper decoding stays off the render thread and keeps the old image until
|
||||||
|
the replacement is ready. Existing windows, widths and layouts are preserved;
|
||||||
|
layout-mode defaults and initial window rules affect new workspaces/windows.
|
||||||
|
Output profiles and touchpad changes apply on reconnect or session restart.
|
||||||
|
The compositor and shell need one initial update/restart to enable this feature.
|
||||||
|
|
||||||
|
## Session services and locking
|
||||||
|
|
||||||
|
`[[autostart]]` entries run after the compositor's public socket is ready:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[autostart]]
|
||||||
|
command = ["awari"]
|
||||||
|
restart = true
|
||||||
|
|
||||||
|
[[autostart]]
|
||||||
|
command = ["swayidle", "-w", "timeout", "600", "ferese-lock", "before-sleep", "ferese-lock", "lock", "ferese-lock"]
|
||||||
|
restart = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Services are session-owned, reaped, and restarted with a five-second retry delay.
|
||||||
|
They receive the correct public Wayland display without private shell privileges.
|
||||||
|
Nested previews skip them unless `nested = true` is explicitly configured.
|
||||||
|
Autostart changes require a session restart; live config edits do not restart services.
|
||||||
|
Run foreground daemons here, not commands that fork themselves into the background.
|
||||||
|
|
||||||
|
Ferese implements `ext-session-lock-v1`. While locked, only lock surfaces over an
|
||||||
|
opaque fallback are rendered, normal input/shortcuts and IPC are blocked, and
|
||||||
|
screencopy is unavailable. DRM confirmation waits for safe page flips on active
|
||||||
|
outputs. A missing/crashed locker leaves the session locked; it is not an unlock.
|
||||||
|
`ferese-lock` runs swaylock/swaylock-effects with optional
|
||||||
|
`~/.config/ferese/swaylock.conf`, waiting for lock confirmation before daemonizing.
|
||||||
|
The compositor authenticates no passwords itself; authentication belongs to the locker.
|
||||||
|
Validate password unlock in a disposable nested session before enabling automatic
|
||||||
|
locking on a primary session. Crash recovery currently requires ending that session
|
||||||
|
from another VT; there is deliberately no unauthenticated IPC unlock.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Install the native dependencies required by Smithay's winit and GLES backends,
|
Install the native dependencies required by Smithay's winit and GLES backends,
|
||||||
@@ -119,6 +168,11 @@ duration_ms = 150 # 0 snaps immediately; reduced motion also snaps
|
|||||||
```
|
```
|
||||||
|
|
||||||
Inactive dimming follows window focus with a smooth, interruption-safe fade.
|
Inactive dimming follows window focus with a smooth, interruption-safe fade.
|
||||||
|
Focus-ring paint (including gradients) and width use the same `duration_ms`,
|
||||||
|
even when dimming is disabled. Focus changes never scale or move the window.
|
||||||
|
Shell buttons fade their hover tint over 120 ms. Both transitions respect
|
||||||
|
`animations.speed`, `animations.enabled`, and `animations.reduced_motion`;
|
||||||
|
click actions remain immediate and settled hover states do not animate.
|
||||||
It keeps window opacity intact, respects animated geometry and rounded corners,
|
It keeps window opacity intact, respects animated geometry and rounded corners,
|
||||||
and leaves shell bars/popovers untouched. Overview temporarily removes dimming.
|
and leaves shell bars/popovers untouched. Overview temporarily removes dimming.
|
||||||
|
|
||||||
@@ -198,6 +252,28 @@ out over 80 ms, with a 64 MiB total snapshot budget and automatic release.
|
|||||||
All monitors share one elapsed-time animation clock, and window content,
|
All monitors share one elapsed-time animation clock, and window content,
|
||||||
clips, borders and shadows use the same physical-pixel edges.
|
clips, borders and shadows use the same physical-pixel edges.
|
||||||
|
|
||||||
|
Window borders and focus rings use solid colors by default. Optional linear
|
||||||
|
gradients can be configured independently:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[theme.focus_ring.gradient]
|
||||||
|
from = "#e5c890"
|
||||||
|
to = "#b98d58"
|
||||||
|
angle = 135.0
|
||||||
|
|
||||||
|
# Optional unfocused-window gradient:
|
||||||
|
[theme.border.gradient]
|
||||||
|
from = "#3e3e43"
|
||||||
|
to = "#242429"
|
||||||
|
angle = 90.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Angles are clockwise in window coordinates: 0 is left-to-right and 90 is
|
||||||
|
top-to-bottom. Both endpoints accept `#RRGGBB` or `#RRGGBBAA`. Remove either
|
||||||
|
gradient section to restore its solid `theme.colors.accent` or `border` color.
|
||||||
|
Gradients share the existing rounded-border shader and add no animation or
|
||||||
|
extra render pass. Configuration changes reload live.
|
||||||
|
|
||||||
Overview has a horizontally scrollable workspace strip with live window
|
Overview has a horizontally scrollable workspace strip with live window
|
||||||
thumbnails and an accent outline on the workspace shown by each output.
|
thumbnails and an accent outline on the workspace shown by each output.
|
||||||
Click a workspace to change the window grid without leaving overview; click
|
Click a workspace to change the window grid without leaving overview; click
|
||||||
@@ -214,7 +290,8 @@ no image reload or full-screen shell buffers. The launched shell receives
|
|||||||
`FERESE_COMPOSITOR_WALLPAPER=1` to disable its redundant wallpaper surfaces.
|
`FERESE_COMPOSITOR_WALLPAPER=1` to disable its redundant wallpaper surfaces.
|
||||||
When no valid image path is configured, the shell's existing fallback remains.
|
When no valid image path is configured, the shell's existing fallback remains.
|
||||||
|
|
||||||
Keyboard and direct-session touchpad settings are applied at startup:
|
Keyboard settings reload live. Direct-session touchpad settings apply on device
|
||||||
|
reconnect or session restart:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[input]
|
[input]
|
||||||
@@ -267,7 +344,8 @@ Unspecified connected outputs remain enabled with their preferred mode, scale
|
|||||||
`1.0`, normal transform, and automatic horizontal placement. Supported
|
`1.0`, normal transform, and automatic horizontal placement. Supported
|
||||||
transforms are `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`,
|
transforms are `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`,
|
||||||
and their rotated flipped variants. Output configuration is applied at startup
|
and their rotated flipped variants. Output configuration is applied at startup
|
||||||
and on hotplug; live configuration reload remains deferred.
|
and on hotplug. Reloaded output profiles take effect on reconnect or session
|
||||||
|
restart; they do not change an already-connected output's mode.
|
||||||
|
|
||||||
On DRM, closing the laptop lid disables internal panels when a usable external
|
On DRM, closing the laptop lid disables internal panels when a usable external
|
||||||
output is available. With no usable external output, the panel stays enabled;
|
output is available. With no usable external output, the panel stays enabled;
|
||||||
@@ -308,6 +386,10 @@ matching rules are applied in declaration order; later rules override only the
|
|||||||
fields they specify. Width or height implies floating placement when `floating`
|
fields they specify. Width or height implies floating placement when `floating`
|
||||||
is omitted:
|
is omitted:
|
||||||
|
|
||||||
|
With `floating = true` and no explicit dimensions, the application chooses its
|
||||||
|
initial size and Ferese centers that size on its workspace's output. Small
|
||||||
|
dialogs are not forced into a workspace-sized container.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[[window_rules]]
|
[[window_rules]]
|
||||||
app_id = "org.example.Editor"
|
app_id = "org.example.Editor"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ ferese-ipc = { path = "../crates/ferese-ipc" }
|
|||||||
ferese-layout = { path = "../crates/ferese-layout" }
|
ferese-layout = { path = "../crates/ferese-layout" }
|
||||||
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
|
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
notify = "8.2.0"
|
||||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
smithay.workspace = true
|
smithay.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ struct DirectOutput {
|
|||||||
damage_tracker: OutputDamageTracker,
|
damage_tracker: OutputDamageTracker,
|
||||||
render_metrics: RenderMetrics,
|
render_metrics: RenderMetrics,
|
||||||
frame_pending: bool,
|
frame_pending: bool,
|
||||||
|
lock_frame_pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OutputSelection {
|
struct OutputSelection {
|
||||||
@@ -369,13 +370,17 @@ fn open_primary_device(
|
|||||||
.insert_source(notifier, move |event, metadata, state| match event {
|
.insert_source(notifier, move |event, metadata, state| match event {
|
||||||
DrmEvent::VBlank(crtc) => {
|
DrmEvent::VBlank(crtc) => {
|
||||||
if let Some(metadata) = metadata {
|
if let Some(metadata) = metadata {
|
||||||
|
let mut retired = false;
|
||||||
let feedback = state
|
let feedback = state
|
||||||
.direct_backend
|
.direct_backend
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.and_then(|backend| backend.devices.get_mut(&node))
|
.and_then(|backend| backend.devices.get_mut(&node))
|
||||||
.and_then(|device| device.outputs.get_mut(&crtc))
|
.and_then(|device| device.outputs.get_mut(&crtc))
|
||||||
.and_then(|output| match output.surface.frame_submitted() {
|
.and_then(|output| match output.surface.frame_submitted() {
|
||||||
Ok(feedback) => feedback,
|
Ok(feedback) => {
|
||||||
|
retired = true;
|
||||||
|
feedback
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame");
|
tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame");
|
||||||
None
|
None
|
||||||
@@ -397,6 +402,21 @@ fn open_primary_device(
|
|||||||
.and_then(|backend| backend.devices.get(&node))
|
.and_then(|backend| backend.devices.get(&node))
|
||||||
.and_then(|device| device.outputs.get(&crtc))
|
.and_then(|device| device.outputs.get(&crtc))
|
||||||
.is_some_and(|output| state.is_focused_output(&output.output));
|
.is_some_and(|output| state.is_focused_output(&output.output));
|
||||||
|
let lock_output = state
|
||||||
|
.direct_backend
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|backend| backend.devices.get_mut(&node))
|
||||||
|
.and_then(|device| device.outputs.get_mut(&crtc))
|
||||||
|
.and_then(|output| {
|
||||||
|
if retired && std::mem::take(&mut output.lock_frame_pending) {
|
||||||
|
Some(output.output.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(output) = lock_output {
|
||||||
|
state.lock_frame_presented(&output);
|
||||||
|
}
|
||||||
if let Some(output) = state
|
if let Some(output) = state
|
||||||
.direct_backend
|
.direct_backend
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -632,6 +652,7 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
|
|||||||
effects,
|
effects,
|
||||||
);
|
);
|
||||||
output.frame_pending = true;
|
output.frame_pending = true;
|
||||||
|
output.lock_frame_pending = state.session_lock.active;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -852,6 +873,10 @@ fn refresh_duration(state: &Ferese, node: DrmNode, crtc: crtc::Handle) -> Durati
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_frame_callbacks(state: &mut Ferese, output: &Output) {
|
fn send_frame_callbacks(state: &mut Ferese, output: &Output) {
|
||||||
|
if state.session_lock.active {
|
||||||
|
state.lock_frame_callbacks(output);
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.space.elements().for_each(|window| {
|
state.space.elements().for_each(|window| {
|
||||||
window.send_frame(
|
window.send_frame(
|
||||||
output,
|
output,
|
||||||
@@ -1099,6 +1124,7 @@ fn create_direct_output(
|
|||||||
damage_tracker,
|
damage_tracker,
|
||||||
render_metrics,
|
render_metrics,
|
||||||
frame_pending: false,
|
frame_pending: false,
|
||||||
|
lock_frame_pending: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+160
-1
@@ -16,6 +16,8 @@ use crate::window_rules::{self, WindowRule, WindowRuleConfig};
|
|||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) autostart: Vec<DaemonConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
animations: AnimationsConfig,
|
animations: AnimationsConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -36,6 +38,26 @@ pub struct Config {
|
|||||||
scrolling: ScrollingConfig,
|
scrolling: ScrollingConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
output_profiles: Vec<OutputProfileConfig>,
|
output_profiles: Vec<OutputProfileConfig>,
|
||||||
|
#[serde(default, rename = "status")]
|
||||||
|
_status: ShellStatusConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub(crate) struct DaemonConfig {
|
||||||
|
pub command: Vec<String>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub restart: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub nested: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate shell-only fields too, before publishing an accepted source.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct ShellStatusConfig {
|
||||||
|
battery_percentage: Option<bool>,
|
||||||
|
low_battery_threshold: Option<u8>,
|
||||||
|
settings_command: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -105,12 +127,62 @@ struct OutputConfig {
|
|||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct RgbaColor(pub [f32; 4]);
|
pub struct RgbaColor(pub [f32; 4]);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct BorderGradient {
|
||||||
|
pub from: RgbaColor,
|
||||||
|
pub to: RgbaColor,
|
||||||
|
pub angle: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct BorderPaintConfig {
|
||||||
|
gradient: Option<BorderGradientConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BorderGradientConfig {
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
#[serde(default)]
|
||||||
|
angle: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BorderPaintConfig {
|
||||||
|
fn settings(&self, name: &str) -> Result<Option<BorderGradient>, ConfigError> {
|
||||||
|
let (from_name, to_name, angle_name) = if name == "focus_ring" {
|
||||||
|
(
|
||||||
|
"focus_ring.gradient.from",
|
||||||
|
"focus_ring.gradient.to",
|
||||||
|
"focus_ring.gradient.angle",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"border.gradient.from",
|
||||||
|
"border.gradient.to",
|
||||||
|
"border.gradient.angle",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.gradient
|
||||||
|
.as_ref()
|
||||||
|
.map(|gradient| {
|
||||||
|
Ok(BorderGradient {
|
||||||
|
from: parse_color(&gradient.from, from_name)?,
|
||||||
|
to: parse_color(&gradient.to, to_name)?,
|
||||||
|
angle: finite_theme_value(gradient.angle, angle_name)?.rem_euclid(360.0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct ThemeSettings {
|
pub struct ThemeSettings {
|
||||||
pub border_width: f64,
|
pub border_width: f64,
|
||||||
pub focus_ring_width: f64,
|
pub focus_ring_width: f64,
|
||||||
pub border_color: RgbaColor,
|
pub border_color: RgbaColor,
|
||||||
pub accent_color: RgbaColor,
|
pub accent_color: RgbaColor,
|
||||||
|
pub border_gradient: Option<BorderGradient>,
|
||||||
|
pub focus_ring_gradient: Option<BorderGradient>,
|
||||||
pub shadow_color: RgbaColor,
|
pub shadow_color: RgbaColor,
|
||||||
pub surface_base_color: RgbaColor,
|
pub surface_base_color: RgbaColor,
|
||||||
pub panel_opacity: f64,
|
pub panel_opacity: f64,
|
||||||
@@ -167,6 +239,10 @@ impl Default for InactiveDimConfig {
|
|||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct ThemeConfig {
|
struct ThemeConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
border: BorderPaintConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
focus_ring: BorderPaintConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
typography: OverviewTypographyConfig,
|
typography: OverviewTypographyConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -184,6 +260,46 @@ struct ThemeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> {
|
||||||
|
toml::from_str(source).map_err(|source| ConfigError::Parse {
|
||||||
|
path: config_path().unwrap_or_default(),
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
|
||||||
|
for daemon in &self.autostart {
|
||||||
|
if daemon
|
||||||
|
.command
|
||||||
|
.first()
|
||||||
|
.is_none_or(|program| program.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidBinding(
|
||||||
|
"autostart command must contain a program".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let input_settings = self.input_settings()?;
|
||||||
|
let bindings = self.bindings(&input_settings)?;
|
||||||
|
Ok(crate::RuntimeConfig {
|
||||||
|
layout_mode: self.layout_mode(),
|
||||||
|
gap_config: self.gap_config()?,
|
||||||
|
input_settings,
|
||||||
|
bindings,
|
||||||
|
window_rules: self.window_rules()?,
|
||||||
|
theme_settings: self.theme_settings()?,
|
||||||
|
default_column_width: self.default_column_width()?,
|
||||||
|
scrolling_focus_strategy: self.scrolling_focus_strategy(),
|
||||||
|
column_width_presets: self.width_presets()?,
|
||||||
|
animations_enabled: self.animations_enabled(),
|
||||||
|
animation_speed: self.animation_speed()?,
|
||||||
|
spring_config: self.spring_config()?,
|
||||||
|
viewport_spring_config: self.viewport_spring_config()?,
|
||||||
|
output_profiles: self.output_profiles()?,
|
||||||
|
wallpaper: self.wallpaper_settings(),
|
||||||
|
overview_font_family: self.overview_font_family(),
|
||||||
|
})
|
||||||
|
}
|
||||||
pub(crate) fn overview_font_family(&self) -> String {
|
pub(crate) fn overview_font_family(&self) -> String {
|
||||||
self.theme
|
self.theme
|
||||||
.typography
|
.typography
|
||||||
@@ -905,6 +1021,8 @@ impl Config {
|
|||||||
focus_ring_width,
|
focus_ring_width,
|
||||||
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
|
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
|
||||||
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
|
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
|
||||||
|
border_gradient: self.theme.border.settings("border")?,
|
||||||
|
focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?,
|
||||||
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
|
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
|
||||||
surface_base_color: parse_color(
|
surface_base_color: parse_color(
|
||||||
&self.theme.colors.surface_base,
|
&self.theme.colors.surface_base,
|
||||||
@@ -1576,7 +1694,7 @@ fn parse_column_width(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_path() -> Option<PathBuf> {
|
pub(crate) fn config_path() -> Option<PathBuf> {
|
||||||
if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
|
if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
|
||||||
return Some(PathBuf::from(directory).join("ferese/config.toml"));
|
return Some(PathBuf::from(directory).join("ferese/config.toml"));
|
||||||
}
|
}
|
||||||
@@ -1724,6 +1842,8 @@ mod tests {
|
|||||||
focus_ring_width: 3.0,
|
focus_ring_width: 3.0,
|
||||||
border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.0,]),
|
border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.0,]),
|
||||||
accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]),
|
accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]),
|
||||||
|
border_gradient: None,
|
||||||
|
focus_ring_gradient: None,
|
||||||
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
|
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
|
||||||
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
|
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
|
||||||
panel_opacity: 0.78,
|
panel_opacity: 0.78,
|
||||||
@@ -1757,6 +1877,45 @@ mod tests {
|
|||||||
assert!(toml::from_str::<Config>("[theme.material]\nstyle = \"glass\"").is_err());
|
assert!(toml::from_str::<Config>("[theme.material]\nstyle = \"glass\"").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn border_gradients_are_optional_and_independent() {
|
||||||
|
let defaults = parse("").theme_settings().unwrap();
|
||||||
|
assert_eq!(defaults.border_gradient, None);
|
||||||
|
assert_eq!(defaults.focus_ring_gradient, None);
|
||||||
|
let configured =
|
||||||
|
parse("[theme.focus_ring.gradient]\nfrom = '#e5c890'\nto = '#b98d5880'\nangle = -45")
|
||||||
|
.theme_settings()
|
||||||
|
.unwrap();
|
||||||
|
let gradient = configured.focus_ring_gradient.unwrap();
|
||||||
|
assert_eq!(configured.border_gradient, None);
|
||||||
|
assert_eq!(gradient.angle, 315.0);
|
||||||
|
assert_eq!(gradient.to.0[3], 128.0 / 255.0);
|
||||||
|
let border = parse("[theme.border.gradient]\nfrom = '#112233'\nto = '#445566'")
|
||||||
|
.theme_settings()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(border.border_gradient.unwrap().angle, 0.0);
|
||||||
|
assert_eq!(border.focus_ring_gradient, None);
|
||||||
|
assert_eq!(configured.border_color, defaults.border_color);
|
||||||
|
assert_eq!(configured.accent_color, defaults.accent_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn border_gradients_reject_invalid_colors_and_nonfinite_angles() {
|
||||||
|
for settings in [
|
||||||
|
"from = 'invalid'\nto = '#445566'",
|
||||||
|
"from = '#112233'\nto = 'invalid'",
|
||||||
|
"from = '#112233'\nto = '#445566'\nangle = nan",
|
||||||
|
"from = '#112233'\nto = '#445566'\nangle = inf",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
parse(&format!("[theme.focus_ring.gradient]\n{settings}"))
|
||||||
|
.theme_settings()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(toml::from_str::<Config>("[theme.border.gradient]\nfrom = '#112233'").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn material_color_comes_from_surface_base() {
|
fn material_color_comes_from_surface_base() {
|
||||||
let theme = parse("[theme.colors]\nsurface_base = \"#000000\"\n[theme.surface.bar]\nbackground = \"#FFFFFF\"")
|
let theme = parse("[theme.colors]\nsurface_base = \"#000000\"\n[theme.surface.bar]\nbackground = \"#FFFFFF\"")
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! Session-owned services. Never grant shell/effects privileges to daemons.
|
||||||
|
use crate::{Ferese, config::DaemonConfig, private_client::ClientCapabilities};
|
||||||
|
use std::{
|
||||||
|
process::Child,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Service {
|
||||||
|
config: DaemonConfig,
|
||||||
|
child: Option<Child>,
|
||||||
|
next_start: Instant,
|
||||||
|
finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Runner(Vec<Service>);
|
||||||
|
|
||||||
|
impl Runner {
|
||||||
|
pub fn new(configs: &[DaemonConfig], nested: bool) -> Self {
|
||||||
|
Self(
|
||||||
|
configs
|
||||||
|
.iter()
|
||||||
|
.filter(|config| !nested || config.nested)
|
||||||
|
.map(|config| Service {
|
||||||
|
config: config.clone(),
|
||||||
|
child: None,
|
||||||
|
next_start: Instant::now(),
|
||||||
|
finished: false,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick(&mut self, state: &mut Ferese) {
|
||||||
|
let now = Instant::now();
|
||||||
|
for service in &mut self.0 {
|
||||||
|
if let Some(child) = &mut service.child {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => {
|
||||||
|
tracing::info!(command = ?service.config.command, %status, "session daemon exited");
|
||||||
|
service.child = None;
|
||||||
|
service.finished = !service.config.restart;
|
||||||
|
service.next_start = now + Duration::from_secs(5);
|
||||||
|
}
|
||||||
|
Ok(None) => continue,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "cannot reap session daemon");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !service.finished && now >= service.next_start {
|
||||||
|
service.child = crate::spawn_client(
|
||||||
|
state,
|
||||||
|
service.config.command.iter().map(Into::into).collect(),
|
||||||
|
ClientCapabilities::default(),
|
||||||
|
);
|
||||||
|
service.next_start = now + Duration::from_secs(5);
|
||||||
|
if service.child.is_none() && !service.config.restart {
|
||||||
|
service.finished = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&mut self) {
|
||||||
|
for service in &mut self.0 {
|
||||||
|
if let Some(mut child) = service.child.take() {
|
||||||
|
crate::terminate_child(&mut child);
|
||||||
|
}
|
||||||
|
service.finished = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Runner {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn previews_do_not_duplicate_session_daemons() {
|
||||||
|
let configs = vec![DaemonConfig {
|
||||||
|
command: vec!["awari".into()],
|
||||||
|
restart: true,
|
||||||
|
nested: false,
|
||||||
|
}];
|
||||||
|
assert!(Runner::new(&configs, true).0.is_empty());
|
||||||
|
let mut direct = Runner::new(&configs, false);
|
||||||
|
assert_eq!(direct.0.len(), 1);
|
||||||
|
direct.stop();
|
||||||
|
assert!(direct.0[0].finished);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,11 +54,43 @@ impl DimAnimation {
|
|||||||
self.current = self.start + (target - self.start) * eased;
|
self.current = self.start + (target - self.start) * eased;
|
||||||
self.current != target
|
self.current != target
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The target changed at this tick, not at the beginning of a potentially
|
||||||
|
/// long idle interval. Do not spend that old elapsed time on a new fade.
|
||||||
|
pub fn advance_visual(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
|
||||||
|
self.advance(
|
||||||
|
target,
|
||||||
|
if self.target != target {
|
||||||
|
Duration::ZERO
|
||||||
|
} else {
|
||||||
|
delta
|
||||||
|
},
|
||||||
|
duration_ms,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn visual_focus_and_dimming_start_together_after_idle_and_reverse() {
|
||||||
|
let mut focus = DimAnimation::new(0.0);
|
||||||
|
let mut dim = DimAnimation::new(0.15);
|
||||||
|
assert!(focus.advance_visual(1.0, Duration::from_secs(30), 150.0));
|
||||||
|
assert!(dim.advance_visual(0.0, Duration::from_secs(30), 150.0));
|
||||||
|
assert_eq!(focus.current, 0.0);
|
||||||
|
assert_eq!(dim.current, 0.15);
|
||||||
|
focus.advance_visual(1.0, Duration::from_millis(75), 150.0);
|
||||||
|
dim.advance_visual(0.0, Duration::from_millis(75), 150.0);
|
||||||
|
assert!((focus.current + dim.current / 0.15 - 1.0).abs() < 1e-9);
|
||||||
|
let before = focus.current;
|
||||||
|
focus.advance_visual(0.0, Duration::from_millis(16), 150.0);
|
||||||
|
assert_eq!(focus.current, before);
|
||||||
|
assert!(!focus.advance_visual(0.0, Duration::from_millis(150), 150.0));
|
||||||
|
assert!(!focus.advance_visual(1.0, Duration::from_secs(30), 0.0));
|
||||||
|
assert_eq!(focus.current, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_unfocused_windows_dim_and_overview_disables_it() {
|
fn only_unfocused_windows_dim_and_overview_disables_it() {
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ struct SurfaceEffectsState {
|
|||||||
generation: AtomicU64,
|
generation: AtomicU64,
|
||||||
role: Mutex<Option<SemanticRole>>,
|
role: Mutex<Option<SemanticRole>>,
|
||||||
regions: Mutex<Option<Vec<[i32; 5]>>>,
|
regions: Mutex<Option<Vec<[i32; 5]>>>,
|
||||||
|
opacity: Mutex<u32>,
|
||||||
|
presentation_supported: AtomicBool,
|
||||||
|
dismissing: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SurfaceEffectsState {
|
impl SurfaceEffectsState {
|
||||||
@@ -74,6 +77,9 @@ impl SurfaceEffectsState {
|
|||||||
generation: AtomicU64::new(0),
|
generation: AtomicU64::new(0),
|
||||||
role: Mutex::new(None),
|
role: Mutex::new(None),
|
||||||
regions: Mutex::new(None),
|
regions: Mutex::new(None),
|
||||||
|
opacity: Mutex::new(1000),
|
||||||
|
presentation_supported: AtomicBool::new(false),
|
||||||
|
dismissing: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +109,7 @@ impl SurfaceEffectsUserData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||||
display.create_global::<Ferese, FereseEffectsManagerV1, _>(2, ());
|
display.create_global::<Ferese, FereseEffectsManagerV1, _>(3, ());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[i32; 5]>> {
|
pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[i32; 5]>> {
|
||||||
@@ -197,7 +203,15 @@ impl Dispatch<FereseEffectsManagerV1, ()> for Ferese {
|
|||||||
"wl_surface already has a Ferese effects object",
|
"wl_surface already has a Ferese effects object",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data_init.init(id, SurfaceEffectsUserData::new(surface));
|
let effects = data_init.init(id, SurfaceEffectsUserData::new(surface.clone()));
|
||||||
|
with_states(&surface, |states| {
|
||||||
|
states
|
||||||
|
.data_map
|
||||||
|
.get::<SurfaceEffectsState>()
|
||||||
|
.unwrap()
|
||||||
|
.presentation_supported
|
||||||
|
.store(effects.version() >= 3, Ordering::Release)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ferese_effects_manager_v1::Request::Destroy => {}
|
ferese_effects_manager_v1::Request::Destroy => {}
|
||||||
@@ -221,6 +235,27 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match request {
|
match request {
|
||||||
|
ferese_surface_effects_v1::Request::SetOpacity { opacity } => {
|
||||||
|
let changed = with_states(&surface, |states| {
|
||||||
|
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if effects.dismissing.load(Ordering::Acquire) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut current = effects.opacity.lock().unwrap();
|
||||||
|
let opacity = opacity.min(1000);
|
||||||
|
if *current == opacity {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*current = opacity;
|
||||||
|
effects.generation.fetch_add(1, Ordering::Release);
|
||||||
|
true
|
||||||
|
});
|
||||||
|
if changed {
|
||||||
|
crate::backends::direct::render_all(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
ferese_surface_effects_v1::Request::SetRole { role } => {
|
ferese_surface_effects_v1::Request::SetRole { role } => {
|
||||||
let role = match role {
|
let role = match role {
|
||||||
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
|
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
|
||||||
@@ -301,11 +336,47 @@ fn detach(surface: &WlSurface) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let role_changed = effects.set_role(None);
|
let role_changed = effects.set_role(None);
|
||||||
|
effects.dismissing.store(false, Ordering::Release);
|
||||||
|
let mut opacity = effects.opacity.lock().unwrap();
|
||||||
|
let opacity_changed = *opacity != 1000;
|
||||||
|
*opacity = 1000;
|
||||||
let was_attached = effects.attached.swap(false, Ordering::AcqRel);
|
let was_attached = effects.attached.swap(false, Ordering::AcqRel);
|
||||||
role_changed || was_attached
|
role_changed || was_attached || opacity_changed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn surface_opacity(surface: &WlSurface) -> f32 {
|
||||||
|
with_states(surface, |states| {
|
||||||
|
states
|
||||||
|
.data_map
|
||||||
|
.get::<SurfaceEffectsState>()
|
||||||
|
.map_or(1.0, |effects| {
|
||||||
|
*effects.opacity.lock().unwrap() as f32 / 1000.0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn begin_surface_dismiss(surface: &WlSurface) -> bool {
|
||||||
|
with_states(surface, |states| {
|
||||||
|
states
|
||||||
|
.data_map
|
||||||
|
.get::<SurfaceEffectsState>()
|
||||||
|
.is_some_and(|effects| {
|
||||||
|
effects.presentation_supported.load(Ordering::Acquire)
|
||||||
|
&& effects.attached.load(Ordering::Acquire)
|
||||||
|
&& !effects.dismissing.swap(true, Ordering::AcqRel)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
|
||||||
|
with_states(surface, |states| {
|
||||||
|
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
|
||||||
|
*effects.opacity.lock().unwrap() = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||||
|
effects.generation.fetch_add(1, Ordering::Release);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ impl XdgActivationHandler for Ferese {
|
|||||||
data: XdgActivationTokenData,
|
data: XdgActivationTokenData,
|
||||||
surface: WlSurface,
|
surface: WlSurface,
|
||||||
) {
|
) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE;
|
let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE;
|
||||||
self.xdg_activation_state.remove_token(&token);
|
self.xdg_activation_state.remove_token(&token);
|
||||||
if !valid {
|
if !valid {
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ impl CompositorHandler for Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn destroyed(&mut self, surface: &WlSurface) {
|
fn destroyed(&mut self, surface: &WlSurface) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
self.session_lock
|
||||||
|
.surfaces
|
||||||
|
.retain(|_, lock| lock.wl_surface() != surface);
|
||||||
|
self.focus_lock_surface();
|
||||||
|
}
|
||||||
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
|
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
|
||||||
crate::backends::direct::render_all(self);
|
crate::backends::direct::render_all(self);
|
||||||
if self.idle_inhibitors.remove(surface).is_some() {
|
if self.idle_inhibitors.remove(surface).is_some() {
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let policy = layer.cached_state();
|
let policy = layer.cached_state();
|
||||||
if policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
|
if !state.session_lock.active
|
||||||
|
&& policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
|
||||||
&& matches!(policy.layer, Layer::Top | Layer::Overlay)
|
&& matches!(policy.layer, Layer::Top | Layer::Overlay)
|
||||||
{
|
{
|
||||||
state
|
state
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ impl Ferese {
|
|||||||
with_damage: bool,
|
with_damage: bool,
|
||||||
) {
|
) {
|
||||||
let mut used = data.used.lock().unwrap();
|
let mut used = data.used.lock().unwrap();
|
||||||
|
if self.session_lock.active {
|
||||||
|
frame.failed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if *used {
|
if *used {
|
||||||
frame.post_error(
|
frame.post_error(
|
||||||
zwlr_screencopy_frame_v1::Error::AlreadyUsed,
|
zwlr_screencopy_frame_v1::Error::AlreadyUsed,
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ impl XdgShellHandler for Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn move_request(&mut self, surface: ToplevelSurface, seat: wl_seat::WlSeat, serial: Serial) {
|
fn move_request(&mut self, surface: ToplevelSurface, seat: wl_seat::WlSeat, serial: Serial) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(seat) = Seat::from_resource(&seat) else {
|
let Some(seat) = Seat::from_resource(&seat) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -163,6 +166,9 @@ impl XdgShellHandler for Ferese {
|
|||||||
serial: Serial,
|
serial: Serial,
|
||||||
edges: smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge,
|
edges: smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge,
|
||||||
) {
|
) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(seat) = Seat::from_resource(&seat) else {
|
let Some(seat) = Seat::from_resource(&seat) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -247,6 +253,9 @@ impl XdgShellHandler for Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) {
|
fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(seat) = Seat::from_resource(&seat) else {
|
let Some(seat) = Seat::from_resource(&seat) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use smithay::reexports::wayland_server::Resource;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use smithay::{
|
use smithay::{
|
||||||
@@ -26,6 +27,21 @@ impl Ferese {
|
|||||||
let seat = self.seat.clone();
|
let seat = self.seat.clone();
|
||||||
self.idle_notifier_state.notify_activity(&seat);
|
self.idle_notifier_state.notify_activity(&seat);
|
||||||
|
|
||||||
|
if self.session_lock.active {
|
||||||
|
// An already-bound IME or drag client may install a grab after the
|
||||||
|
// lock request. Never let that grab receive subsequent lock input.
|
||||||
|
if let Some(keyboard) = seat.get_keyboard() {
|
||||||
|
keyboard.unset_grab(self);
|
||||||
|
}
|
||||||
|
if let Some(pointer) = seat.get_pointer() {
|
||||||
|
pointer.unset_grab(self, SERIAL_COUNTER.next_serial(), 0);
|
||||||
|
}
|
||||||
|
if let Some(touch) = seat.get_touch() {
|
||||||
|
touch.unset_grab(self);
|
||||||
|
}
|
||||||
|
self.focus_lock_surface();
|
||||||
|
}
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
InputEvent::SwitchToggle { event } if event.switch() == Some(Switch::Lid) => {
|
InputEvent::SwitchToggle { event } if event.switch() == Some(Switch::Lid) => {
|
||||||
crate::backends::direct::set_lid_closed(self, event.state() == SwitchState::On);
|
crate::backends::direct::set_lid_closed(self, event.state() == SwitchState::On);
|
||||||
@@ -42,6 +58,9 @@ impl Ferese {
|
|||||||
SERIAL_COUNTER.next_serial(),
|
SERIAL_COUNTER.next_serial(),
|
||||||
Event::time(&event) as u32,
|
Event::time(&event) as u32,
|
||||||
|data, modifiers, keysym| {
|
|data, modifiers, keysym| {
|
||||||
|
if data.session_lock.active {
|
||||||
|
return FilterResult::Forward;
|
||||||
|
}
|
||||||
if state == KeyState::Released && data.intercepted_keys.remove(&keycode) {
|
if state == KeyState::Released && data.intercepted_keys.remove(&keycode) {
|
||||||
return FilterResult::Intercept(());
|
return FilterResult::Intercept(());
|
||||||
}
|
}
|
||||||
@@ -178,6 +197,30 @@ impl Ferese {
|
|||||||
let pointer = self.seat.get_pointer().expect("seat has a pointer");
|
let pointer = self.seat.get_pointer().expect("seat has a pointer");
|
||||||
let serial = SERIAL_COUNTER.next_serial();
|
let serial = SERIAL_COUNTER.next_serial();
|
||||||
|
|
||||||
|
// Native popup_done destroys Iced's window immediately. For
|
||||||
|
// effects-capable shell popups, release input now but defer
|
||||||
|
// popup_done until the compositor's whole-surface fade ends.
|
||||||
|
if event.state() == ButtonState::Pressed
|
||||||
|
&& pointer.is_grabbed()
|
||||||
|
&& let Some(surface) = self.seat.get_keyboard().and_then(|k| k.current_focus())
|
||||||
|
&& let Some(popup) = self.popups.find_popup(&surface)
|
||||||
|
&& !self
|
||||||
|
.surface_under(pointer.current_location())
|
||||||
|
.is_some_and(|(target, _)| surface.id().same_client_as(&target.id()))
|
||||||
|
&& let Ok(root) = smithay::desktop::find_popup_root_surface(&popup)
|
||||||
|
&& crate::effects::begin_surface_dismiss(&surface)
|
||||||
|
{
|
||||||
|
let opacity = crate::effects::surface_opacity(&surface);
|
||||||
|
self.dismissing_popups.push((
|
||||||
|
root,
|
||||||
|
popup,
|
||||||
|
crate::dimming::DimAnimation::new(f64::from(opacity)),
|
||||||
|
));
|
||||||
|
pointer.unset_grab(self, serial, event.time() as u32);
|
||||||
|
self.focus_window_at(pointer.current_location(), serial);
|
||||||
|
crate::backends::direct::render_all(self);
|
||||||
|
}
|
||||||
|
|
||||||
if self.overview.is_active() {
|
if self.overview.is_active() {
|
||||||
let position = pointer.current_location();
|
let position = pointer.current_location();
|
||||||
if self.layer_under(position).is_some() {
|
if self.layer_under(position).is_some() {
|
||||||
@@ -345,6 +388,11 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial) {
|
fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
self.focus_output_at(position);
|
||||||
|
self.focus_lock_surface();
|
||||||
|
return;
|
||||||
|
}
|
||||||
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
|
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
|
||||||
self.focus_output_at(position);
|
self.focus_output_at(position);
|
||||||
|
|
||||||
@@ -390,6 +438,9 @@ impl Ferese {
|
|||||||
pointer: &PointerHandle<Self>,
|
pointer: &PointerHandle<Self>,
|
||||||
position: Point<f64, Logical>,
|
position: Point<f64, Logical>,
|
||||||
) {
|
) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.overview.is_active()
|
if self.overview.is_active()
|
||||||
|| !self.input_settings.focus_follows_mouse
|
|| !self.input_settings.focus_follows_mouse
|
||||||
|| pointer.is_grabbed()
|
|| pointer.is_grabbed()
|
||||||
@@ -436,6 +487,9 @@ impl Ferese {
|
|||||||
pointer: &PointerHandle<Self>,
|
pointer: &PointerHandle<Self>,
|
||||||
requested: Point<f64, Logical>,
|
requested: Point<f64, Logical>,
|
||||||
) -> Point<f64, Logical> {
|
) -> Point<f64, Logical> {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
let current = pointer.current_location();
|
let current = pointer.current_location();
|
||||||
let Some(focused_surface) = pointer.current_focus() else {
|
let Some(focused_surface) = pointer.current_focus() else {
|
||||||
return requested;
|
return requested;
|
||||||
@@ -473,6 +527,9 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn activate_focused_pointer_constraint(&self, pointer: &PointerHandle<Self>) {
|
pub fn activate_focused_pointer_constraint(&self, pointer: &PointerHandle<Self>) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let position = pointer.current_location();
|
let position = pointer.current_location();
|
||||||
let Some((surface, origin)) = self.surface_under(position) else {
|
let Some((surface, origin)) = self.surface_under(position) else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -209,6 +209,13 @@ fn serve_connection(
|
|||||||
|
|
||||||
impl Ferese {
|
impl Ferese {
|
||||||
fn handle_ipc_request(&mut self, request: Request) -> Response {
|
fn handle_ipc_request(&mut self, request: Request) -> Response {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return Response::error(
|
||||||
|
request.id,
|
||||||
|
"session_locked",
|
||||||
|
"IPC unavailable while session is locked",
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Err(error) = validate_request(&request) {
|
if let Err(error) = validate_request(&request) {
|
||||||
return Response::error(request.id, error.code, error.message);
|
return Response::error(request.id, error.code, error.message);
|
||||||
}
|
}
|
||||||
@@ -222,6 +229,9 @@ impl Ferese {
|
|||||||
fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result<Value, CommandError> {
|
fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result<Value, CommandError> {
|
||||||
match command {
|
match command {
|
||||||
"exit" => {} // The IPC worker stops the loop after writing the response.
|
"exit" => {} // The IPC worker stops the loop after writing the response.
|
||||||
|
"reload-config" => self
|
||||||
|
.reload_config()
|
||||||
|
.map_err(|e| CommandError::new("invalid_config", e))?,
|
||||||
"focus" => self.focus_direction(direction_arg(args)?),
|
"focus" => self.focus_direction(direction_arg(args)?),
|
||||||
"move" => self.move_direction(direction_arg(args)?),
|
"move" => self.move_direction(direction_arg(args)?),
|
||||||
"resize" => self.resize_direction(direction_arg(args)?),
|
"resize" => self.resize_direction(direction_arg(args)?),
|
||||||
|
|||||||
+56
-26
@@ -3,6 +3,7 @@
|
|||||||
mod backends;
|
mod backends;
|
||||||
mod config;
|
mod config;
|
||||||
mod cursor;
|
mod cursor;
|
||||||
|
mod daemon;
|
||||||
mod dimming;
|
mod dimming;
|
||||||
mod effects;
|
mod effects;
|
||||||
mod grabs;
|
mod grabs;
|
||||||
@@ -13,7 +14,9 @@ mod metrics;
|
|||||||
mod overview;
|
mod overview;
|
||||||
mod presentation;
|
mod presentation;
|
||||||
mod private_client;
|
mod private_client;
|
||||||
|
mod reload;
|
||||||
mod resize_transaction;
|
mod resize_transaction;
|
||||||
|
mod session_lock;
|
||||||
mod shell_control;
|
mod shell_control;
|
||||||
mod stacking;
|
mod stacking;
|
||||||
mod state;
|
mod state;
|
||||||
@@ -42,11 +45,11 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
|
|
||||||
let launch = LaunchConfig::from_environment()
|
let launch = LaunchConfig::from_environment()
|
||||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
|
||||||
let config = Config::load()?;
|
let initial_source = config::config_path().and_then(|path| std::fs::read_to_string(path).ok());
|
||||||
let input_settings = config.input_settings()?;
|
let config = match initial_source.as_deref() {
|
||||||
let bindings = config.bindings(&input_settings)?;
|
Some(source) => Config::parse_source(source)?,
|
||||||
let window_rules = config.window_rules()?;
|
None => Config::load()?,
|
||||||
let output_profiles = config.output_profiles()?;
|
};
|
||||||
let mut event_loop = EventLoop::try_new()?;
|
let mut event_loop = EventLoop::try_new()?;
|
||||||
let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?;
|
let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?;
|
||||||
event_loop
|
event_loop
|
||||||
@@ -56,29 +59,55 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
state.loop_signal.stop();
|
state.loop_signal.stop();
|
||||||
})?;
|
})?;
|
||||||
let display = Display::new()?;
|
let display = Display::new()?;
|
||||||
let runtime = RuntimeConfig {
|
let runtime = config.runtime_config()?;
|
||||||
layout_mode: config.layout_mode(),
|
|
||||||
gap_config: config.gap_config()?,
|
|
||||||
input_settings,
|
|
||||||
bindings,
|
|
||||||
window_rules,
|
|
||||||
theme_settings: config.theme_settings()?,
|
|
||||||
default_column_width: config.default_column_width()?,
|
|
||||||
scrolling_focus_strategy: config.scrolling_focus_strategy(),
|
|
||||||
column_width_presets: config.width_presets()?,
|
|
||||||
animations_enabled: config.animations_enabled(),
|
|
||||||
animation_speed: config.animation_speed()?,
|
|
||||||
spring_config: config.spring_config()?,
|
|
||||||
viewport_spring_config: config.viewport_spring_config()?,
|
|
||||||
output_profiles,
|
|
||||||
wallpaper: config.wallpaper_settings(),
|
|
||||||
overview_font_family: config.overview_font_family(),
|
|
||||||
};
|
|
||||||
let mut state = Ferese::new(&mut event_loop, display, runtime)?;
|
let mut state = Ferese::new(&mut event_loop, display, runtime)?;
|
||||||
|
state.config_source = initial_source.filter(|source| source.len() <= 60 * 1024);
|
||||||
backends::init(launch.backend, &mut event_loop, &mut state)?;
|
backends::init(launch.backend, &mut event_loop, &mut state)?;
|
||||||
|
let mut monitor =
|
||||||
|
config::config_path().and_then(|path| match reload::ConfigMonitor::new(path) {
|
||||||
|
Ok(monitor) => Some(monitor),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "automatic config watching unavailable; use feresectl reload-config");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
use calloop::timer::{TimeoutAction, Timer};
|
||||||
|
event_loop.handle().insert_source(
|
||||||
|
Timer::from_duration(Duration::from_millis(150)),
|
||||||
|
move |_, _, state: &mut Ferese| {
|
||||||
|
// Decoders finish on a worker even when an idle DRM output has no
|
||||||
|
// pending frame. Wake rendering once, never continuously for wallpaper.
|
||||||
|
if state.wallpaper.poll() {
|
||||||
|
state.backdrop_generation = state.backdrop_generation.wrapping_add(1);
|
||||||
|
backends::direct::render_all(state);
|
||||||
|
}
|
||||||
|
if let Some(result) = monitor
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|monitor| monitor.poll(std::time::Instant::now()))
|
||||||
|
{
|
||||||
|
if let Err(error) = result.and_then(|source| state.reload_config_source(source)) {
|
||||||
|
warn!(%error, "config reload rejected; retaining last working config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TimeoutAction::ToDuration(Duration::from_millis(150))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients");
|
info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients");
|
||||||
let mut child = spawn_client(&mut state, launch.client, launch.client_capabilities);
|
let mut child = spawn_client(&mut state, launch.client, launch.client_capabilities);
|
||||||
|
let runner = std::rc::Rc::new(std::cell::RefCell::new(daemon::Runner::new(
|
||||||
|
&config.autostart,
|
||||||
|
launch.backend == backends::BackendKind::Nested,
|
||||||
|
)));
|
||||||
|
runner.borrow_mut().tick(&mut state);
|
||||||
|
let services = runner.clone();
|
||||||
|
event_loop.handle().insert_source(
|
||||||
|
Timer::from_duration(Duration::from_secs(1)),
|
||||||
|
move |_, _, state: &mut Ferese| {
|
||||||
|
services.borrow_mut().tick(state);
|
||||||
|
TimeoutAction::ToDuration(Duration::from_secs(1))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
let result = event_loop.run(None, &mut state, |state| {
|
let result = event_loop.run(None, &mut state, |state| {
|
||||||
// All input/Wayland callbacks have returned, releasing seat locks.
|
// All input/Wayland callbacks have returned, releasing seat locks.
|
||||||
// Coalesce cursor changes and redraw here, never inside cursor_image.
|
// Coalesce cursor changes and redraw here, never inside cursor_image.
|
||||||
@@ -93,6 +122,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
runner.borrow_mut().stop();
|
||||||
if let Some(child) = &mut child {
|
if let Some(child) = &mut child {
|
||||||
terminate_child(child);
|
terminate_child(child);
|
||||||
}
|
}
|
||||||
@@ -122,9 +152,9 @@ fn spawn_client(
|
|||||||
// Wallpaper decoding and drivers may already have worker threads. Set
|
// Wallpaper decoding and drivers may already have worker threads. Set
|
||||||
// the child's display explicitly instead of mutating the process env.
|
// the child's display explicitly instead of mutating the process env.
|
||||||
command.env("WAYLAND_DISPLAY", &state.socket_name);
|
command.env("WAYLAND_DISPLAY", &state.socket_name);
|
||||||
if state.wallpaper.owns_background() {
|
command.env_remove("WAYLAND_SOCKET");
|
||||||
command.env("FERESE_COMPOSITOR_WALLPAPER", "1");
|
command.env_remove("FERESE_SHELL_CONTROL_SOCKET");
|
||||||
}
|
command.env("FERESE_COMPOSITOR_WALLPAPER", "1");
|
||||||
let private_connection = if capabilities.is_empty() {
|
let private_connection = if capabilities.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OverviewState {
|
impl OverviewState {
|
||||||
|
pub(crate) fn set_font_family(&mut self, family: String) {
|
||||||
|
if self.font_family != family {
|
||||||
|
self.font_family = family;
|
||||||
|
self.font_loaded = false;
|
||||||
|
self.font = None;
|
||||||
|
self.labels.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
pub(crate) fn with_font_family(font_family: String) -> Self {
|
pub(crate) fn with_font_family(font_family: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
font_family,
|
font_family,
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
//! Filesystem events only: idle timer ticks never read/stat the config.
|
||||||
|
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
use std::{
|
||||||
|
io::Read,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::mpsc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn read_source(path: &std::path::Path) -> Result<String, String> {
|
||||||
|
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||||
|
let mut source = String::new();
|
||||||
|
file.take(60 * 1024 + 1)
|
||||||
|
.read_to_string(&mut source)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if source.len() > 60 * 1024 {
|
||||||
|
return Err("configuration exceeds the 60 KiB live-reload limit".into());
|
||||||
|
}
|
||||||
|
Ok(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ConfigMonitor {
|
||||||
|
_watcher: RecommendedWatcher,
|
||||||
|
events: mpsc::Receiver<()>,
|
||||||
|
path: PathBuf,
|
||||||
|
dirty: Option<Instant>,
|
||||||
|
observed: Option<String>,
|
||||||
|
}
|
||||||
|
impl ConfigMonitor {
|
||||||
|
pub(crate) fn new(path: PathBuf) -> notify::Result<Self> {
|
||||||
|
let (sender, events) = mpsc::sync_channel(1);
|
||||||
|
let target = path.clone();
|
||||||
|
let mut watcher =
|
||||||
|
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||||
|
if let Ok(event) = event
|
||||||
|
&& !event.kind.is_access()
|
||||||
|
&& event
|
||||||
|
.paths
|
||||||
|
.iter()
|
||||||
|
.any(|p| p == &target || target.starts_with(p))
|
||||||
|
{
|
||||||
|
let _ = sender.try_send(());
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
// Watch the directory, not the inode: atomic editor saves replace it.
|
||||||
|
let mut directory = path.parent().unwrap_or(std::path::Path::new("."));
|
||||||
|
while !directory.is_dir() {
|
||||||
|
directory = directory.parent().unwrap_or(std::path::Path::new("."));
|
||||||
|
}
|
||||||
|
watcher.watch(
|
||||||
|
directory,
|
||||||
|
if Some(directory) == path.parent() {
|
||||||
|
RecursiveMode::NonRecursive
|
||||||
|
} else {
|
||||||
|
RecursiveMode::Recursive
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let initial_dirty = path.is_file().then(Instant::now);
|
||||||
|
Ok(Self {
|
||||||
|
_watcher: watcher,
|
||||||
|
events,
|
||||||
|
path,
|
||||||
|
dirty: initial_dirty,
|
||||||
|
observed: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(crate) fn poll(&mut self, now: Instant) -> Option<Result<String, String>> {
|
||||||
|
if self.events.try_iter().next().is_some() {
|
||||||
|
self.dirty = Some(now);
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.dirty
|
||||||
|
.is_none_or(|since| now.saturating_duration_since(since) < Duration::from_millis(120))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.dirty = None;
|
||||||
|
let source = match read_source(&self.path) {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(error) => {
|
||||||
|
return Some(Err(format!(
|
||||||
|
"{}: {error}; retaining active config",
|
||||||
|
self.path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if self.observed.as_ref() == Some(&source) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.observed = Some(source.clone());
|
||||||
|
Some(Ok(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::Ferese {
|
||||||
|
pub(crate) fn reload_config_source(&mut self, source: String) -> Result<(), String> {
|
||||||
|
if source.len() > 60 * 1024 {
|
||||||
|
return Err("configuration exceeds the 60 KiB live-reload limit".into());
|
||||||
|
}
|
||||||
|
if self.config_source.as_ref() == Some(&source) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Validate every runtime field before mutating any scene/input state.
|
||||||
|
let config = crate::config::Config::parse_source(&source).map_err(|e| e.to_string())?;
|
||||||
|
let runtime = config.runtime_config().map_err(|e| e.to_string())?;
|
||||||
|
self.apply_runtime_config(runtime)?;
|
||||||
|
self.config_source = Some(source.clone());
|
||||||
|
self.shell_resources.retain(|resource| {
|
||||||
|
if let Ok(shell) = resource.upgrade() {
|
||||||
|
if smithay::reexports::wayland_server::Resource::version(&shell) >= 2 {
|
||||||
|
crate::shell_control::send_shell_config(&shell, &source);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tracing::info!("configuration reloaded live");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub(crate) fn reload_config(&mut self) -> Result<(), String> {
|
||||||
|
let path = crate::config::config_path().ok_or("config directory is unavailable")?;
|
||||||
|
let source = read_source(&path)?;
|
||||||
|
self.reload_config_source(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
struct Directory(PathBuf);
|
||||||
|
impl Directory {
|
||||||
|
fn new() -> Self {
|
||||||
|
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"ferese-reload-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
std::fs::create_dir(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Directory {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn runtime_validation_rejects_bad_edits_before_application() {
|
||||||
|
for source in [
|
||||||
|
"[animations]\nspeed = 0",
|
||||||
|
"[theme.focus_ring.gradient]\nfrom = 'bad'\nto = '#ffffff'",
|
||||||
|
"[status]\nlow_battery_threshold = 'bad'",
|
||||||
|
"[layout]\ninner_gap = -1",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
crate::config::Config::parse_source(source)
|
||||||
|
.and_then(|config| config.runtime_config())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
crate::config::Config::parse_source("[animations]\nspeed = 0.75")
|
||||||
|
.unwrap()
|
||||||
|
.runtime_config()
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn directory_watch_detects_atomic_save_and_does_not_read_during_idle() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
std::fs::write(&path, "[animations]\nspeed = 1").unwrap();
|
||||||
|
let mut monitor = ConfigMonitor::new(path.clone()).unwrap();
|
||||||
|
monitor.dirty = Some(Instant::now() - Duration::from_secs(1));
|
||||||
|
assert!(monitor.poll(Instant::now()).unwrap().is_ok());
|
||||||
|
// With no event, even a deliberately missing path is never read.
|
||||||
|
let original_path = monitor.path.clone();
|
||||||
|
monitor.path = directory.0.join("missing");
|
||||||
|
assert!(monitor.poll(Instant::now()).is_none());
|
||||||
|
monitor.path = original_path;
|
||||||
|
let temporary = directory.0.join("config.toml.new");
|
||||||
|
std::fs::write(&temporary, "[animations]\nspeed = 0.75").unwrap();
|
||||||
|
std::fs::rename(&temporary, &path).unwrap();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(3);
|
||||||
|
loop {
|
||||||
|
if let Some(result) = monitor.poll(Instant::now()) {
|
||||||
|
assert_eq!(result.unwrap(), "[animations]\nspeed = 0.75");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"atomic save notification never settled"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn source_size_limit_is_bounded_and_debounce_coalesces_edits() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let path = directory.0.join("config.toml");
|
||||||
|
std::fs::write(&path, "x".repeat(60 * 1024 + 1)).unwrap();
|
||||||
|
assert!(read_source(&path).is_err());
|
||||||
|
std::fs::write(&path, "[animations]\nspeed = 1").unwrap();
|
||||||
|
let mut monitor = ConfigMonitor::new(path).unwrap();
|
||||||
|
let now = Instant::now();
|
||||||
|
monitor.dirty = Some(now);
|
||||||
|
assert!(monitor.poll(now + Duration::from_millis(119)).is_none());
|
||||||
|
assert!(
|
||||||
|
monitor
|
||||||
|
.poll(now + Duration::from_millis(121))
|
||||||
|
.unwrap()
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use crate::Ferese;
|
||||||
|
use smithay::{
|
||||||
|
output::Output,
|
||||||
|
reexports::{
|
||||||
|
wayland_protocols::ext::session_lock::v1::server::{
|
||||||
|
ext_session_lock_manager_v1::ExtSessionLockManagerV1,
|
||||||
|
ext_session_lock_surface_v1::ExtSessionLockSurfaceV1,
|
||||||
|
ext_session_lock_v1::{self, ExtSessionLockV1},
|
||||||
|
},
|
||||||
|
wayland_server::{
|
||||||
|
Client, DataInit, Dispatch, DisplayHandle, Resource, protocol::wl_output::WlOutput,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
utils::{Logical, Point, SERIAL_COUNTER},
|
||||||
|
wayland::session_lock::{
|
||||||
|
LockSurface, SessionLockHandler, SessionLockManagerState, SessionLockState, SessionLocker,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Lock {
|
||||||
|
pub active: bool,
|
||||||
|
owner: Option<ExtSessionLockV1>,
|
||||||
|
confirmation: Option<SessionLocker>,
|
||||||
|
pub surfaces: HashMap<Output, LockSurface>,
|
||||||
|
pub backgrounds: HashMap<Output, smithay::backend::renderer::element::solid::SolidColorBuffer>,
|
||||||
|
presented: HashSet<Output>,
|
||||||
|
confirmed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ferese {
|
||||||
|
pub(crate) fn lock_surface_under(
|
||||||
|
&self,
|
||||||
|
position: Point<f64, Logical>,
|
||||||
|
) -> Option<(
|
||||||
|
smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
|
||||||
|
Point<f64, Logical>,
|
||||||
|
)> {
|
||||||
|
let output = self.space.output_under(position).next()?;
|
||||||
|
let surface = self.session_lock.surfaces.get(output)?;
|
||||||
|
surface.alive().then(|| {
|
||||||
|
(
|
||||||
|
surface.wl_surface().clone(),
|
||||||
|
self.space.output_geometry(output).unwrap().loc.to_f64(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn focus_lock_surface(&mut self) {
|
||||||
|
let surface = self
|
||||||
|
.focused_output()
|
||||||
|
.and_then(|output| self.session_lock.surfaces.get(output))
|
||||||
|
.or_else(|| {
|
||||||
|
self.session_lock
|
||||||
|
.surfaces
|
||||||
|
.values()
|
||||||
|
.find(|surface| surface.alive())
|
||||||
|
})
|
||||||
|
.filter(|surface| surface.alive())
|
||||||
|
.map(|surface| surface.wl_surface().clone());
|
||||||
|
self.seat
|
||||||
|
.get_keyboard()
|
||||||
|
.unwrap()
|
||||||
|
.set_focus(self, surface, SERIAL_COUNTER.next_serial());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn lock_frame_presented(&mut self, output: &Output) {
|
||||||
|
if !self.session_lock.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.session_lock.presented.insert(output.clone());
|
||||||
|
if self
|
||||||
|
.space
|
||||||
|
.outputs()
|
||||||
|
.all(|output| self.session_lock.presented.contains(output))
|
||||||
|
{
|
||||||
|
if let Some(confirmation) = self.session_lock.confirmation.take() {
|
||||||
|
if confirmation.ext_session_lock().is_alive() {
|
||||||
|
confirmation.lock();
|
||||||
|
self.session_lock.confirmed = true;
|
||||||
|
tracing::info!("session lock confirmed after safe output frames");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn configure_lock_surfaces(&mut self) {
|
||||||
|
for (output, surface) in &self.session_lock.surfaces {
|
||||||
|
if let Some(geometry) = self.space.output_geometry(output) {
|
||||||
|
surface.with_pending_state(|state| {
|
||||||
|
state.size = Some((geometry.size.w as u32, geometry.size.h as u32).into())
|
||||||
|
});
|
||||||
|
surface.send_configure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn lock_frame_callbacks(&self, output: &Output) {
|
||||||
|
if let Some(surface) = self
|
||||||
|
.session_lock
|
||||||
|
.surfaces
|
||||||
|
.get(output)
|
||||||
|
.filter(|surface| surface.alive())
|
||||||
|
{
|
||||||
|
smithay::desktop::utils::send_frames_surface_tree(
|
||||||
|
surface.wl_surface(),
|
||||||
|
output,
|
||||||
|
self.start_time.elapsed(),
|
||||||
|
Some(std::time::Duration::ZERO),
|
||||||
|
|_, _| Some(output.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionLockHandler for Ferese {
|
||||||
|
fn lock_state(&mut self) -> &mut SessionLockManagerState {
|
||||||
|
&mut self.session_lock_state
|
||||||
|
}
|
||||||
|
fn lock(&mut self, confirmation: SessionLocker) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.session_lock.active = true;
|
||||||
|
self.session_lock.owner = Some(confirmation.ext_session_lock().clone());
|
||||||
|
self.session_lock.confirmation = Some(confirmation);
|
||||||
|
for capture in self.pending_screencopies.drain(..) {
|
||||||
|
capture.frame.failed();
|
||||||
|
}
|
||||||
|
self.set_overview_active(false);
|
||||||
|
let serial = SERIAL_COUNTER.next_serial();
|
||||||
|
if let Some(pointer) = self.seat.get_pointer() {
|
||||||
|
pointer.unset_grab(self, serial, 0);
|
||||||
|
pointer.motion(
|
||||||
|
self,
|
||||||
|
None,
|
||||||
|
&smithay::input::pointer::MotionEvent {
|
||||||
|
location: pointer.current_location(),
|
||||||
|
serial,
|
||||||
|
time: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
pointer.frame(self);
|
||||||
|
}
|
||||||
|
if let Some(touch) = self.seat.get_touch() {
|
||||||
|
touch.unset_grab(self);
|
||||||
|
touch.cancel(self);
|
||||||
|
}
|
||||||
|
let keyboard = self.seat.get_keyboard().unwrap();
|
||||||
|
keyboard.unset_grab(self);
|
||||||
|
keyboard.set_focus(self, None, serial);
|
||||||
|
self.intercepted_keys.clear();
|
||||||
|
crate::backends::direct::render_all(self);
|
||||||
|
}
|
||||||
|
fn unlock(&mut self) {
|
||||||
|
self.session_lock = Lock::default();
|
||||||
|
self.restore_keyboard_focus();
|
||||||
|
crate::backends::direct::render_all(self);
|
||||||
|
tracing::info!("session unlocked by lock owner");
|
||||||
|
}
|
||||||
|
fn new_surface(&mut self, surface: LockSurface, output: WlOutput) {
|
||||||
|
if let Some(output) = Output::from_resource(&output) {
|
||||||
|
if let Some(geometry) = self.space.output_geometry(&output) {
|
||||||
|
surface.with_pending_state(|state| {
|
||||||
|
state.size = Some((geometry.size.w as u32, geometry.size.h as u32).into())
|
||||||
|
});
|
||||||
|
self.session_lock.surfaces.insert(output, surface);
|
||||||
|
self.focus_lock_surface();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smithay delegates unlock even after posting InvalidUnlock. Check ownership
|
||||||
|
// and confirmation first so a rejected/unfinished lock cannot unlock the owner.
|
||||||
|
impl Dispatch<ExtSessionLockV1, SessionLockState> for Ferese {
|
||||||
|
fn request(
|
||||||
|
state: &mut Self,
|
||||||
|
client: &Client,
|
||||||
|
lock: &ExtSessionLockV1,
|
||||||
|
request: ext_session_lock_v1::Request,
|
||||||
|
data: &SessionLockState,
|
||||||
|
display: &DisplayHandle,
|
||||||
|
data_init: &mut DataInit<'_, Self>,
|
||||||
|
) {
|
||||||
|
let owner = state.session_lock.owner.as_ref() == Some(lock);
|
||||||
|
if matches!(request, ext_session_lock_v1::Request::UnlockAndDestroy)
|
||||||
|
&& (!owner || !state.session_lock.confirmed)
|
||||||
|
{
|
||||||
|
lock.post_error(
|
||||||
|
ext_session_lock_v1::Error::InvalidUnlock,
|
||||||
|
"lock not confirmed or not the active owner",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if matches!(request, ext_session_lock_v1::Request::GetLockSurface { .. }) && !owner {
|
||||||
|
lock.post_error(
|
||||||
|
ext_session_lock_v1::Error::InvalidUnlock,
|
||||||
|
"not the active lock owner",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
<SessionLockManagerState as Dispatch<ExtSessionLockV1, SessionLockState, Ferese>>::request(
|
||||||
|
state, client, lock, request, data, display, data_init,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
smithay::reexports::wayland_server::delegate_global_dispatch!(Ferese: [ExtSessionLockManagerV1: smithay::wayland::session_lock::SessionLockManagerGlobalData] => SessionLockManagerState);
|
||||||
|
smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockManagerV1: ()] => SessionLockManagerState);
|
||||||
|
smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockSurfaceV1: smithay::wayland::session_lock::ExtLockSurfaceUserData] => SessionLockManagerState);
|
||||||
@@ -14,7 +14,28 @@ use smithay::{
|
|||||||
use crate::{Ferese, private_client::ClientCapabilities, state::ClientState};
|
use crate::{Ferese, private_client::ClientCapabilities, state::ClientState};
|
||||||
|
|
||||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||||
display.create_global::<Ferese, FereseShellManagerV1, _>(1, ());
|
display.create_global::<Ferese, FereseShellManagerV1, _>(2, ());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
|
||||||
|
let mut remaining = source;
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
while !remaining.is_empty() {
|
||||||
|
let mut boundary = remaining.len().min(1024);
|
||||||
|
while !remaining.is_char_boundary(boundary) {
|
||||||
|
boundary -= 1;
|
||||||
|
}
|
||||||
|
chunks.push(&remaining[..boundary]);
|
||||||
|
remaining = &remaining[boundary..];
|
||||||
|
}
|
||||||
|
chunks
|
||||||
|
}
|
||||||
|
pub(crate) fn send_shell_config(shell: &FereseShellV1, source: &str) {
|
||||||
|
shell.config_begin();
|
||||||
|
for chunk in config_chunks(source) {
|
||||||
|
shell.config_chunk(chunk.to_owned());
|
||||||
|
}
|
||||||
|
shell.config_end();
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GlobalDispatch<FereseShellManagerV1, ()> for Ferese {
|
impl GlobalDispatch<FereseShellManagerV1, ()> for Ferese {
|
||||||
@@ -53,6 +74,11 @@ impl Dispatch<FereseShellManagerV1, ()> for Ferese {
|
|||||||
let shell = data_init.init(id, ());
|
let shell = data_init.init(id, ());
|
||||||
|
|
||||||
shell.capabilities(1);
|
shell.capabilities(1);
|
||||||
|
if shell.version() >= 2
|
||||||
|
&& let Some(source) = &state.config_source
|
||||||
|
{
|
||||||
|
send_shell_config(&shell, source);
|
||||||
|
}
|
||||||
shell.overview_state(u32::from(state.overview.is_active()));
|
shell.overview_state(u32::from(state.overview.is_active()));
|
||||||
state.send_shell_snapshot(&shell);
|
state.send_shell_snapshot(&shell);
|
||||||
state.shell_resources.push(shell.downgrade());
|
state.shell_resources.push(shell.downgrade());
|
||||||
@@ -73,6 +99,9 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
|
|||||||
_display: &DisplayHandle,
|
_display: &DisplayHandle,
|
||||||
_data_init: &mut DataInit<'_, Self>,
|
_data_init: &mut DataInit<'_, Self>,
|
||||||
) {
|
) {
|
||||||
|
if state.session_lock.active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
match request {
|
match request {
|
||||||
ferese_shell_v1::Request::ActivateWindow {
|
ferese_shell_v1::Request::ActivateWindow {
|
||||||
window_hi,
|
window_hi,
|
||||||
@@ -371,6 +400,19 @@ fn send_request_failed(shell: &FereseShellV1, request: ferese_shell_v1::FailedRe
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_chunks_are_bounded_and_preserve_unicode() {
|
||||||
|
let source = "abc🌲é".repeat(9000);
|
||||||
|
let chunks = config_chunks(&source);
|
||||||
|
assert!(
|
||||||
|
chunks
|
||||||
|
.iter()
|
||||||
|
.all(|chunk| !chunk.is_empty() && chunk.len() <= 1024)
|
||||||
|
);
|
||||||
|
assert_eq!(chunks.concat(), source);
|
||||||
|
assert!(config_chunks("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn window_ids_round_trip_through_protocol_words() {
|
fn window_ids_round_trip_through_protocol_words() {
|
||||||
for id in [0, 1, u32::MAX as u64, u64::from(u32::MAX) + 1, u64::MAX] {
|
for id in [0, 1, u32::MAX as u64, u64::from(u32::MAX) + 1, u64::MAX] {
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
use ferese_layout::WindowId;
|
use ferese_layout::WindowId;
|
||||||
|
|
||||||
|
pub(crate) fn layer_priority(floating: bool, zooming: bool, fullscreen: bool) -> u8 {
|
||||||
|
if fullscreen {
|
||||||
|
3
|
||||||
|
} else if floating {
|
||||||
|
2
|
||||||
|
} else if zooming {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistent back-to-front order. Geometry updates never change this order.
|
/// Persistent back-to-front order. Geometry updates never change this order.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(crate) struct WindowStack {
|
pub(crate) struct WindowStack {
|
||||||
@@ -34,6 +46,13 @@ impl WindowStack {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn floating_windows_stay_above_tiled_and_maximized_windows() {
|
||||||
|
assert!(layer_priority(true, false, false) > layer_priority(false, true, false));
|
||||||
|
assert!(layer_priority(false, true, false) > layer_priority(false, false, false));
|
||||||
|
assert!(layer_priority(false, false, true) > layer_priority(true, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remapping_neighbours_does_not_change_order() {
|
fn remapping_neighbours_does_not_change_order() {
|
||||||
let mut stack = WindowStack::default();
|
let mut stack = WindowStack::default();
|
||||||
|
|||||||
+215
-10
@@ -107,6 +107,9 @@ impl ClosingAnimation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Ferese {
|
pub struct Ferese {
|
||||||
|
pub(crate) session_lock_state: smithay::wayland::session_lock::SessionLockManagerState,
|
||||||
|
pub(crate) session_lock: crate::session_lock::Lock,
|
||||||
|
pub(crate) config_source: Option<String>,
|
||||||
pub start_time: Instant,
|
pub start_time: Instant,
|
||||||
pub socket_name: OsString,
|
pub socket_name: OsString,
|
||||||
pub display_handle: DisplayHandle,
|
pub display_handle: DisplayHandle,
|
||||||
@@ -125,10 +128,12 @@ pub struct Ferese {
|
|||||||
maximized_windows: HashSet<WindowId>,
|
maximized_windows: HashSet<WindowId>,
|
||||||
maximized_column_widths: HashMap<WindowId, ColumnWidth>,
|
maximized_column_widths: HashMap<WindowId, ColumnWidth>,
|
||||||
window_stack: crate::stacking::WindowStack,
|
window_stack: crate::stacking::WindowStack,
|
||||||
|
natural_floating_pending: HashSet<WindowId>,
|
||||||
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
|
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
|
||||||
pub(crate) window_dims: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
|
pub(crate) window_dims: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
|
||||||
pub(crate) window_resize_fills: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
|
pub(crate) window_resize_fills: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
|
||||||
pub(crate) window_dimming: HashMap<WindowId, crate::dimming::DimAnimation>,
|
pub(crate) window_dimming: HashMap<WindowId, crate::dimming::DimAnimation>,
|
||||||
|
pub(crate) window_focus: HashMap<WindowId, crate::dimming::DimAnimation>,
|
||||||
pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
|
pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
|
||||||
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
|
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
|
||||||
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
|
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
|
||||||
@@ -178,6 +183,11 @@ pub struct Ferese {
|
|||||||
next_output_id: u64,
|
next_output_id: u64,
|
||||||
last_animation_tick: Instant,
|
last_animation_tick: Instant,
|
||||||
pub popups: PopupManager,
|
pub popups: PopupManager,
|
||||||
|
pub(crate) dismissing_popups: Vec<(
|
||||||
|
WlSurface,
|
||||||
|
smithay::desktop::PopupKind,
|
||||||
|
crate::dimming::DimAnimation,
|
||||||
|
)>,
|
||||||
pub seat: Seat<Self>,
|
pub seat: Seat<Self>,
|
||||||
pub alpha_modifier_state: AlphaModifierState,
|
pub alpha_modifier_state: AlphaModifierState,
|
||||||
pub compositor_state: CompositorState,
|
pub compositor_state: CompositorState,
|
||||||
@@ -227,6 +237,65 @@ pub struct RuntimeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Ferese {
|
impl Ferese {
|
||||||
|
pub(crate) fn apply_runtime_config(&mut self, config: RuntimeConfig) -> Result<(), String> {
|
||||||
|
let keyboard_changed = self.input_settings.xkb_layout != config.input_settings.xkb_layout
|
||||||
|
|| self.input_settings.xkb_variant != config.input_settings.xkb_variant
|
||||||
|
|| self.input_settings.xkb_options != config.input_settings.xkb_options;
|
||||||
|
if let Some(keyboard) = self.seat.get_keyboard() {
|
||||||
|
if keyboard_changed {
|
||||||
|
let options = (!config.input_settings.xkb_options.is_empty())
|
||||||
|
.then(|| config.input_settings.xkb_options.join(","));
|
||||||
|
keyboard
|
||||||
|
.set_xkb_config(
|
||||||
|
self,
|
||||||
|
XkbConfig {
|
||||||
|
layout: &config.input_settings.xkb_layout,
|
||||||
|
variant: &config.input_settings.xkb_variant,
|
||||||
|
options,
|
||||||
|
..XkbConfig::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("keymap reload failed: {e}"))?;
|
||||||
|
}
|
||||||
|
keyboard.change_repeat_info(
|
||||||
|
config.input_settings.repeat_rate,
|
||||||
|
config.input_settings.repeat_delay_ms,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.advance_animations(Instant::now());
|
||||||
|
if self.input_settings.touchpad != config.input_settings.touchpad {
|
||||||
|
tracing::warn!(
|
||||||
|
"touchpad changes apply when the device reconnects or the session restarts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.output_profiles != config.output_profiles {
|
||||||
|
tracing::warn!(
|
||||||
|
"output profile changes apply on output reconnect; current modes are preserved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.workspaces.reconfigure_defaults(
|
||||||
|
config.layout_mode,
|
||||||
|
config.default_column_width,
|
||||||
|
config.scrolling_focus_strategy,
|
||||||
|
);
|
||||||
|
self.gap_config = config.gap_config;
|
||||||
|
self.input_settings = config.input_settings;
|
||||||
|
self.bindings = config.bindings;
|
||||||
|
self.window_rules = config.window_rules;
|
||||||
|
self.theme_settings = config.theme_settings;
|
||||||
|
self.column_width_presets = config.column_width_presets;
|
||||||
|
self.animations_enabled = config.animations_enabled;
|
||||||
|
self.animation_speed = config.animation_speed;
|
||||||
|
self.spring_config = config.spring_config;
|
||||||
|
self.viewport_spring_config = config.viewport_spring_config;
|
||||||
|
self.output_profiles = config.output_profiles;
|
||||||
|
self.overview.set_font_family(config.overview_font_family);
|
||||||
|
self.wallpaper.reload(config.wallpaper);
|
||||||
|
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
|
||||||
|
self.relayout();
|
||||||
|
crate::backends::direct::render_all(self);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
pub fn register_output(&mut self, output: &Output, identity: String) {
|
pub fn register_output(&mut self, output: &Output, identity: String) {
|
||||||
let Some(geometry) = self.space.output_geometry(output) else {
|
let Some(geometry) = self.space.output_geometry(output) else {
|
||||||
tracing::error!(output = %output.name(), "cannot register an unmapped output");
|
tracing::error!(output = %output.name(), "cannot register an unmapped output");
|
||||||
@@ -286,6 +355,8 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
self.space.unmap_output(output);
|
self.space.unmap_output(output);
|
||||||
|
self.session_lock.surfaces.remove(output);
|
||||||
|
self.session_lock.backgrounds.remove(output);
|
||||||
|
|
||||||
match self.output_workspaces.disconnect(output_id) {
|
match self.output_workspaces.disconnect(output_id) {
|
||||||
Ok(Some(target)) => {
|
Ok(Some(target)) => {
|
||||||
@@ -406,6 +477,10 @@ impl Ferese {
|
|||||||
let fractional_scale_state = FractionalScaleManagerState::new::<Self>(&display_handle);
|
let fractional_scale_state = FractionalScaleManagerState::new::<Self>(&display_handle);
|
||||||
let idle_inhibit_state = IdleInhibitManagerState::new::<Self>(&display_handle);
|
let idle_inhibit_state = IdleInhibitManagerState::new::<Self>(&display_handle);
|
||||||
let idle_notifier_state = IdleNotifierState::new(&display_handle, event_loop.handle());
|
let idle_notifier_state = IdleNotifierState::new(&display_handle, event_loop.handle());
|
||||||
|
let session_lock_state = smithay::wayland::session_lock::SessionLockManagerState::new::<
|
||||||
|
Self,
|
||||||
|
_,
|
||||||
|
>(&display_handle, |_| true);
|
||||||
let input_method_enabled =
|
let input_method_enabled =
|
||||||
std::env::var_os("FERESE_ENABLE_INPUT_METHOD").is_some_and(|value| value == "1");
|
std::env::var_os("FERESE_ENABLE_INPUT_METHOD").is_some_and(|value| value == "1");
|
||||||
let input_method_manager_state =
|
let input_method_manager_state =
|
||||||
@@ -456,6 +531,9 @@ impl Ferese {
|
|||||||
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
|
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
|
||||||
|
|
||||||
let mut state = Self {
|
let mut state = Self {
|
||||||
|
session_lock_state,
|
||||||
|
session_lock: crate::session_lock::Lock::default(),
|
||||||
|
config_source: None,
|
||||||
start_time,
|
start_time,
|
||||||
socket_name,
|
socket_name,
|
||||||
display_handle,
|
display_handle,
|
||||||
@@ -478,10 +556,12 @@ impl Ferese {
|
|||||||
maximized_windows: HashSet::new(),
|
maximized_windows: HashSet::new(),
|
||||||
maximized_column_widths: HashMap::new(),
|
maximized_column_widths: HashMap::new(),
|
||||||
window_stack: crate::stacking::WindowStack::default(),
|
window_stack: crate::stacking::WindowStack::default(),
|
||||||
|
natural_floating_pending: HashSet::new(),
|
||||||
window_borders: HashMap::new(),
|
window_borders: HashMap::new(),
|
||||||
window_dims: HashMap::new(),
|
window_dims: HashMap::new(),
|
||||||
window_resize_fills: HashMap::new(),
|
window_resize_fills: HashMap::new(),
|
||||||
window_dimming: HashMap::new(),
|
window_dimming: HashMap::new(),
|
||||||
|
window_focus: HashMap::new(),
|
||||||
window_shadows: HashMap::new(),
|
window_shadows: HashMap::new(),
|
||||||
rounded_clip_programs: HashMap::new(),
|
rounded_clip_programs: HashMap::new(),
|
||||||
overview_scrims: HashMap::new(),
|
overview_scrims: HashMap::new(),
|
||||||
@@ -525,6 +605,7 @@ impl Ferese {
|
|||||||
next_output_id: 1,
|
next_output_id: 1,
|
||||||
last_animation_tick: start_time,
|
last_animation_tick: start_time,
|
||||||
popups: PopupManager::default(),
|
popups: PopupManager::default(),
|
||||||
|
dismissing_popups: Vec::new(),
|
||||||
seat,
|
seat,
|
||||||
alpha_modifier_state,
|
alpha_modifier_state,
|
||||||
compositor_state,
|
compositor_state,
|
||||||
@@ -588,6 +669,9 @@ impl Ferese {
|
|||||||
&self,
|
&self,
|
||||||
position: Point<f64, Logical>,
|
position: Point<f64, Logical>,
|
||||||
) -> Option<(WlSurface, Point<f64, Logical>)> {
|
) -> Option<(WlSurface, Point<f64, Logical>)> {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return self.lock_surface_under(position);
|
||||||
|
}
|
||||||
self.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
|
self.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
|
||||||
.map(|(_, surface, origin)| (surface, origin))
|
.map(|(_, surface, origin)| (surface, origin))
|
||||||
.or_else(|| self.window_surface_under(position))
|
.or_else(|| self.window_surface_under(position))
|
||||||
@@ -601,6 +685,9 @@ impl Ferese {
|
|||||||
&self,
|
&self,
|
||||||
position: Point<f64, Logical>,
|
position: Point<f64, Logical>,
|
||||||
) -> Option<(LayerSurface, WlSurface, Point<f64, Logical>)> {
|
) -> Option<(LayerSurface, WlSurface, Point<f64, Logical>)> {
|
||||||
|
if self.session_lock.active {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if let Some(layer) = self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) {
|
if let Some(layer) = self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) {
|
||||||
return Some(layer);
|
return Some(layer);
|
||||||
}
|
}
|
||||||
@@ -739,9 +826,18 @@ impl Ferese {
|
|||||||
let mut windows = self.space.elements().cloned().collect::<Vec<_>>();
|
let mut windows = self.space.elements().cloned().collect::<Vec<_>>();
|
||||||
windows.sort_by_key(|window| {
|
windows.sort_by_key(|window| {
|
||||||
let id = self.window_ids.get(window).copied();
|
let id = self.window_ids.get(window).copied();
|
||||||
let priority = id
|
let geometry = id.and_then(|id| self.window_geometry.get(&id));
|
||||||
.and_then(|id| self.window_geometry.get(&id))
|
let floating = id.is_some_and(|id| {
|
||||||
.is_some_and(|geometry| geometry.is_fullscreen() || geometry.is_zooming());
|
matches!(
|
||||||
|
self.workspaces.placement(id),
|
||||||
|
Some(WindowPlacement::Floating { .. })
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let priority = crate::stacking::layer_priority(
|
||||||
|
floating,
|
||||||
|
geometry.is_some_and(|geometry| geometry.is_zooming()),
|
||||||
|
geometry.is_some_and(|geometry| geometry.is_fullscreen()),
|
||||||
|
);
|
||||||
(
|
(
|
||||||
priority,
|
priority,
|
||||||
id.map_or(usize::MAX, |id| self.window_stack.rank(id)),
|
id.map_or(usize::MAX, |id| self.window_stack.rank(id)),
|
||||||
@@ -891,6 +987,13 @@ impl Ferese {
|
|||||||
Some(WindowPlacement::Floating { .. })
|
Some(WindowPlacement::Floating { .. })
|
||||||
);
|
);
|
||||||
let mut rect = centered_floating_rect(bounds);
|
let mut rect = centered_floating_rect(bounds);
|
||||||
|
if should_float && rule.width.is_none() && rule.height.is_none() {
|
||||||
|
if let Some(size) = client_size(window) {
|
||||||
|
rect = natural_floating_rect(bounds, size);
|
||||||
|
} else {
|
||||||
|
self.natural_floating_pending.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
rect.width = rule.width.unwrap_or(rect.width).min(bounds.width);
|
rect.width = rule.width.unwrap_or(rect.width).min(bounds.width);
|
||||||
rect.height = rule.height.unwrap_or(rect.height).min(bounds.height);
|
rect.height = rule.height.unwrap_or(rect.height).min(bounds.height);
|
||||||
rect.x = bounds.x + (bounds.width - rect.width) / 2.0;
|
rect.x = bounds.x + (bounds.width - rect.width) / 2.0;
|
||||||
@@ -974,9 +1077,11 @@ impl Ferese {
|
|||||||
self.maximized_windows.remove(&id);
|
self.maximized_windows.remove(&id);
|
||||||
self.maximized_column_widths.remove(&id);
|
self.maximized_column_widths.remove(&id);
|
||||||
self.window_stack.remove(id);
|
self.window_stack.remove(id);
|
||||||
|
self.natural_floating_pending.remove(&id);
|
||||||
self.window_borders.remove(&id);
|
self.window_borders.remove(&id);
|
||||||
self.window_dims.remove(&id);
|
self.window_dims.remove(&id);
|
||||||
self.window_dimming.remove(&id);
|
self.window_dimming.remove(&id);
|
||||||
|
self.window_focus.remove(&id);
|
||||||
self.window_shadows.remove(&id);
|
self.window_shadows.remove(&id);
|
||||||
self.closing_windows.remove(&id);
|
self.closing_windows.remove(&id);
|
||||||
self.window_rules_applied.remove(&id);
|
self.window_rules_applied.remove(&id);
|
||||||
@@ -994,6 +1099,9 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn relayout(&mut self) {
|
pub fn relayout(&mut self) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
self.configure_lock_surfaces();
|
||||||
|
}
|
||||||
// Consume idle time before setting new targets; it must not become a
|
// Consume idle time before setting new targets; it must not become a
|
||||||
// large first animation step after a keypress on an idle desktop.
|
// large first animation step after a keypress on an idle desktop.
|
||||||
self.advance_animations(Instant::now());
|
self.advance_animations(Instant::now());
|
||||||
@@ -1214,12 +1322,21 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let visual = geometry.visual.current;
|
let visual = geometry.visual.current;
|
||||||
|
let natural_pending = self.natural_floating_pending.contains(&id)
|
||||||
|
&& is_floating
|
||||||
|
&& !is_fullscreen
|
||||||
|
&& !is_maximized;
|
||||||
|
if natural_pending {
|
||||||
|
requested_size = None;
|
||||||
|
}
|
||||||
let location = (visual.x.round() as i32, visual.y.round() as i32);
|
let location = (visual.x.round() as i32, visual.y.round() as i32);
|
||||||
|
|
||||||
self.space.map_element(window.clone(), location, false);
|
self.space.map_element(window.clone(), location, false);
|
||||||
if let Some(toplevel) = window.toplevel() {
|
if let Some(toplevel) = window.toplevel() {
|
||||||
let state_changed = toplevel.with_pending_state(|state| {
|
let state_changed = toplevel.with_pending_state(|state| {
|
||||||
if let Some(size) = requested_size {
|
if natural_pending {
|
||||||
|
state.size = None;
|
||||||
|
} else if let Some(size) = requested_size {
|
||||||
state.size = Some((size.width, size.height).into());
|
state.size = Some((size.width, size.height).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1258,7 +1375,7 @@ impl Ferese {
|
|||||||
fullscreen_changed || maximized_changed || tiled_changed || decoration_changed
|
fullscreen_changed || maximized_changed || tiled_changed || decoration_changed
|
||||||
});
|
});
|
||||||
|
|
||||||
if requested_size.is_some() || state_changed {
|
if natural_pending || requested_size.is_some() || state_changed {
|
||||||
if let Some(serial) = toplevel.send_pending_configure()
|
if let Some(serial) = toplevel.send_pending_configure()
|
||||||
&& requested_size.is_some()
|
&& requested_size.is_some()
|
||||||
&& had_geometry
|
&& had_geometry
|
||||||
@@ -1327,6 +1444,19 @@ impl Ferese {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut active_animation = false;
|
let mut active_animation = false;
|
||||||
|
self.dismissing_popups.retain_mut(|(root, popup, motion)| {
|
||||||
|
if !smithay::utils::IsAlive::alive(popup.wl_surface()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let duration = if self.animations_enabled { 140.0 } else { 0.0 };
|
||||||
|
let active = motion.advance_visual(0.0, delta, duration);
|
||||||
|
crate::effects::fade_dismissed_surface(popup.wl_surface(), motion.current);
|
||||||
|
active_animation |= active;
|
||||||
|
if !active {
|
||||||
|
let _ = PopupManager::dismiss_popup(root, popup);
|
||||||
|
}
|
||||||
|
active
|
||||||
|
});
|
||||||
|
|
||||||
let dim_settings = self.theme_settings.inactive_dim;
|
let dim_settings = self.theme_settings.inactive_dim;
|
||||||
let duration = if self.animations_enabled {
|
let duration = if self.animations_enabled {
|
||||||
@@ -1335,6 +1465,22 @@ impl Ferese {
|
|||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
let mut dim_changed = false;
|
let mut dim_changed = false;
|
||||||
|
// Include hidden workspace windows: overview can present them too.
|
||||||
|
for id in self.window_ids.values().copied().collect::<Vec<_>>() {
|
||||||
|
let selected = if self.overview.is_active() {
|
||||||
|
self.overview_selected(id)
|
||||||
|
} else {
|
||||||
|
self.focused_window == Some(id)
|
||||||
|
};
|
||||||
|
let target = if selected { 1.0 } else { 0.0 };
|
||||||
|
let focus = self
|
||||||
|
.window_focus
|
||||||
|
.entry(id)
|
||||||
|
.or_insert_with(|| crate::dimming::DimAnimation::new(target));
|
||||||
|
let previous = focus.current;
|
||||||
|
active_animation |= focus.advance_visual(target, delta, duration);
|
||||||
|
dim_changed |= previous != focus.current;
|
||||||
|
}
|
||||||
for (_, id) in &windows {
|
for (_, id) in &windows {
|
||||||
let target = crate::dimming::target(
|
let target = crate::dimming::target(
|
||||||
dim_settings,
|
dim_settings,
|
||||||
@@ -1347,7 +1493,7 @@ impl Ferese {
|
|||||||
.entry(*id)
|
.entry(*id)
|
||||||
.or_insert_with(|| crate::dimming::DimAnimation::new(target));
|
.or_insert_with(|| crate::dimming::DimAnimation::new(target));
|
||||||
let previous = dim.current;
|
let previous = dim.current;
|
||||||
active_animation |= dim.advance(target, delta, duration);
|
active_animation |= dim.advance_visual(target, delta, duration);
|
||||||
dim_changed |= previous != dim.current;
|
dim_changed |= previous != dim.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1504,6 +1650,7 @@ impl Ferese {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(size) = geometry.presentation_size_request(self.start_time.elapsed())
|
if let Some(size) = geometry.presentation_size_request(self.start_time.elapsed())
|
||||||
|
&& !self.natural_floating_pending.contains(&id)
|
||||||
&& let Some(toplevel) = window.toplevel()
|
&& let Some(toplevel) = window.toplevel()
|
||||||
{
|
{
|
||||||
toplevel.with_pending_state(|state| {
|
toplevel.with_pending_state(|state| {
|
||||||
@@ -1625,7 +1772,7 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_client_commit(&mut self, window: &Window) {
|
pub fn record_client_commit(&mut self, window: &Window) {
|
||||||
let Some(id) = self.window_ids.get(window) else {
|
let Some(&id) = self.window_ids.get(window) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
// current_serial is promoted by Smithay on commit, unlike
|
// current_serial is promoted by Smithay on commit, unlike
|
||||||
@@ -1640,15 +1787,32 @@ impl Ferese {
|
|||||||
});
|
});
|
||||||
if self
|
if self
|
||||||
.resize_transactions
|
.resize_transactions
|
||||||
.get(id)
|
.get(&id)
|
||||||
.is_some_and(|transaction| transaction.accepts(committed_serial))
|
.is_some_and(|transaction| transaction.accepts(committed_serial))
|
||||||
{
|
{
|
||||||
self.resize_transactions.remove(id);
|
self.resize_transactions.remove(&id);
|
||||||
}
|
}
|
||||||
let Some(size) = client_size(window) else {
|
let Some(size) = client_size(window) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(geometry) = self.window_geometry.get_mut(id) else {
|
if self.natural_floating_pending.remove(&id)
|
||||||
|
&& matches!(
|
||||||
|
self.workspaces.placement(id),
|
||||||
|
Some(WindowPlacement::Floating { .. })
|
||||||
|
)
|
||||||
|
&& let Some(bounds) = self.floating_bounds_for_window(id)
|
||||||
|
{
|
||||||
|
let rect = natural_floating_rect(bounds, size);
|
||||||
|
if self.workspaces.set_floating_rect(id, rect).is_ok() {
|
||||||
|
// This is the first mapped client buffer, not a user resize.
|
||||||
|
// Do not animate from the temporary placement box.
|
||||||
|
self.window_geometry
|
||||||
|
.insert(id, WindowGeometry::new(rect, Some(size)));
|
||||||
|
self.resize_transactions.remove(&id);
|
||||||
|
self.relayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(geometry) = self.window_geometry.get_mut(&id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2204,6 +2368,10 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn restore_keyboard_focus(&mut self) {
|
pub(crate) fn restore_keyboard_focus(&mut self) {
|
||||||
|
if self.session_lock.active {
|
||||||
|
self.focus_lock_surface();
|
||||||
|
return;
|
||||||
|
}
|
||||||
let surface = self.focused_window.and_then(|focused| {
|
let surface = self.focused_window.and_then(|focused| {
|
||||||
self.window_ids.iter().find_map(|(window, id)| {
|
self.window_ids.iter().find_map(|(window, id)| {
|
||||||
(*id == focused)
|
(*id == focused)
|
||||||
@@ -2226,6 +2394,18 @@ impl Ferese {
|
|||||||
self.output_bounds_for(output)
|
self.output_bounds_for(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn floating_bounds_for_window(&self, window: WindowId) -> Option<Rect> {
|
||||||
|
let output_id = self
|
||||||
|
.workspaces
|
||||||
|
.workspace_for_window(window)
|
||||||
|
.and_then(|workspace| self.output_workspaces.output_for_workspace(workspace));
|
||||||
|
self.output_ids
|
||||||
|
.iter()
|
||||||
|
.find(|(_, id)| Some(**id) == output_id)
|
||||||
|
.and_then(|(output, _)| self.output_bounds_for(output))
|
||||||
|
.or_else(|| self.output_bounds())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn output_bounds_for(&self, output: &Output) -> Option<Rect> {
|
pub(crate) fn output_bounds_for(&self, output: &Output) -> Option<Rect> {
|
||||||
let geometry = self.space.output_geometry(output)?;
|
let geometry = self.space.output_geometry(output)?;
|
||||||
let zone = layer_map_for_output(output).non_exclusive_zone();
|
let zone = layer_map_for_output(output).non_exclusive_zone();
|
||||||
@@ -2339,6 +2519,17 @@ fn centered_floating_rect(bounds: Rect) -> Rect {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn natural_floating_rect(bounds: Rect, size: ClientSize) -> Rect {
|
||||||
|
let width = f64::from(size.width).clamp(1.0, bounds.width.max(1.0));
|
||||||
|
let height = f64::from(size.height).clamp(1.0, bounds.height.max(1.0));
|
||||||
|
Rect::new(
|
||||||
|
bounds.x + (bounds.width - width) / 2.0,
|
||||||
|
bounds.y + (bounds.height - height) / 2.0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn smoothstep(progress: f64) -> f64 {
|
fn smoothstep(progress: f64) -> f64 {
|
||||||
let progress = progress.clamp(0.0, 1.0);
|
let progress = progress.clamp(0.0, 1.0);
|
||||||
|
|
||||||
@@ -2394,6 +2585,20 @@ impl ClientData for ClientState {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn floating_dialog_keeps_its_client_size_not_workspace_proportion() {
|
||||||
|
assert_eq!(
|
||||||
|
natural_floating_rect(
|
||||||
|
Rect::new(100.0, 48.0, 1600.0, 900.0),
|
||||||
|
ClientSize {
|
||||||
|
width: 400,
|
||||||
|
height: 200
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Rect::new(700.0, 398.0, 400.0, 200.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn maximization_respects_layer_exclusion_and_outer_gaps() {
|
fn maximization_respects_layer_exclusion_and_outer_gaps() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use smithay::{
|
|||||||
};
|
};
|
||||||
use std::{collections::HashMap, path::PathBuf, sync::mpsc};
|
use std::{collections::HashMap, path::PathBuf, sync::mpsc};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize)]
|
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
|
||||||
pub(crate) struct WallpaperConfig {
|
pub(crate) struct WallpaperConfig {
|
||||||
pub path: Option<PathBuf>,
|
pub path: Option<PathBuf>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -36,6 +36,9 @@ struct WallpaperTexture {
|
|||||||
id: Id,
|
id: Id,
|
||||||
}
|
}
|
||||||
pub(crate) struct WallpaperState {
|
pub(crate) struct WallpaperState {
|
||||||
|
config: WallpaperConfig,
|
||||||
|
pending: Option<WallpaperConfig>,
|
||||||
|
commit: CommitCounter,
|
||||||
owned: bool,
|
owned: bool,
|
||||||
mode: WallpaperMode,
|
mode: WallpaperMode,
|
||||||
receiver: Option<mpsc::Receiver<Result<image::RgbaImage, String>>>,
|
receiver: Option<mpsc::Receiver<Result<image::RgbaImage, String>>>,
|
||||||
@@ -45,6 +48,7 @@ pub(crate) struct WallpaperState {
|
|||||||
|
|
||||||
impl WallpaperState {
|
impl WallpaperState {
|
||||||
pub fn new(config: WallpaperConfig) -> Self {
|
pub fn new(config: WallpaperConfig) -> Self {
|
||||||
|
let retained = config.clone();
|
||||||
let (sender, receiver) = mpsc::channel();
|
let (sender, receiver) = mpsc::channel();
|
||||||
let owned = config.path.as_ref().is_some_and(|path| path.is_file());
|
let owned = config.path.as_ref().is_some_and(|path| path.is_file());
|
||||||
if owned {
|
if owned {
|
||||||
@@ -64,6 +68,9 @@ impl WallpaperState {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
|
config: retained,
|
||||||
|
pending: None,
|
||||||
|
commit: CommitCounter::default(),
|
||||||
owned,
|
owned,
|
||||||
mode: config.mode,
|
mode: config.mode,
|
||||||
receiver: owned.then_some(receiver),
|
receiver: owned.then_some(receiver),
|
||||||
@@ -71,9 +78,38 @@ impl WallpaperState {
|
|||||||
textures: HashMap::new(),
|
textures: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(test)]
|
||||||
pub fn owns_background(&self) -> bool {
|
pub fn owns_background(&self) -> bool {
|
||||||
self.owned
|
self.owned
|
||||||
}
|
}
|
||||||
|
pub fn reload(&mut self, config: WallpaperConfig) {
|
||||||
|
if self.receiver.is_some() {
|
||||||
|
self.pending = (self.config != config).then_some(config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.config == config {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.config.path == config.path {
|
||||||
|
self.mode = config.mode;
|
||||||
|
self.config = config;
|
||||||
|
self.commit.increment();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if config.path.as_ref().is_some_and(|path| !path.is_file()) {
|
||||||
|
tracing::warn!("new wallpaper is unavailable; retaining previous image");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut replacement = Self::new(config);
|
||||||
|
self.config = replacement.config;
|
||||||
|
self.mode = replacement.mode;
|
||||||
|
self.owned = replacement.owned;
|
||||||
|
self.receiver = replacement.receiver.take();
|
||||||
|
if self.config.path.is_none() {
|
||||||
|
self.pixels = None;
|
||||||
|
self.textures.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn forget_context(&mut self, context: &ErasedContextId) {
|
pub fn forget_context(&mut self, context: &ErasedContextId) {
|
||||||
self.textures.remove(context);
|
self.textures.remove(context);
|
||||||
}
|
}
|
||||||
@@ -84,6 +120,14 @@ impl WallpaperState {
|
|||||||
match receiver.try_recv() {
|
match receiver.try_recv() {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
self.receiver = None;
|
self.receiver = None;
|
||||||
|
if let Some(pending) = self.pending.take() {
|
||||||
|
if pending.path != self.config.path {
|
||||||
|
self.reload(pending);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.config = pending;
|
||||||
|
self.mode = self.config.mode;
|
||||||
|
}
|
||||||
match result {
|
match result {
|
||||||
Ok(pixels) => {
|
Ok(pixels) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -93,6 +137,8 @@ impl WallpaperState {
|
|||||||
"decoded compositor wallpaper once"
|
"decoded compositor wallpaper once"
|
||||||
);
|
);
|
||||||
self.pixels = Some(pixels);
|
self.pixels = Some(pixels);
|
||||||
|
self.textures.clear();
|
||||||
|
self.commit.increment();
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, "wallpaper unavailable; using output clear color")
|
tracing::warn!(%error, "wallpaper unavailable; using output clear color")
|
||||||
@@ -147,7 +193,7 @@ impl WallpaperState {
|
|||||||
);
|
);
|
||||||
Some(NativeTextureElement {
|
Some(NativeTextureElement {
|
||||||
id: cached.id.clone(),
|
id: cached.id.clone(),
|
||||||
commit: CommitCounter::default(),
|
commit: self.commit,
|
||||||
texture: cached.texture.clone(),
|
texture: cached.texture.clone(),
|
||||||
geometry,
|
geometry,
|
||||||
source,
|
source,
|
||||||
@@ -204,6 +250,44 @@ fn image_geometry(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
|
fn live_reload_coalesces_decoders_and_mode_changes_invalidate_damage() {
|
||||||
|
let mut state = WallpaperState::new(WallpaperConfig::default());
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
state.receiver = Some(receiver);
|
||||||
|
state.reload(WallpaperConfig {
|
||||||
|
path: None,
|
||||||
|
mode: WallpaperMode::Fit,
|
||||||
|
});
|
||||||
|
assert!(state.receiver.is_some());
|
||||||
|
let before = state.commit;
|
||||||
|
sender.send(Ok(image::RgbaImage::new(2, 2))).unwrap();
|
||||||
|
assert!(state.poll());
|
||||||
|
assert_eq!(state.mode, WallpaperMode::Fit);
|
||||||
|
assert!(state.pixels.is_some());
|
||||||
|
assert_ne!(state.commit, before);
|
||||||
|
assert!(state.receiver.is_none());
|
||||||
|
let before = state.commit;
|
||||||
|
state.reload(state.config.clone());
|
||||||
|
assert_eq!(state.commit, before);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn reverting_a_pending_reload_keeps_latest_request_and_failed_decode_keeps_pixels() {
|
||||||
|
let mut state = WallpaperState::new(WallpaperConfig::default());
|
||||||
|
state.pixels = Some(image::RgbaImage::new(2, 2));
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
state.receiver = Some(receiver);
|
||||||
|
state.reload(WallpaperConfig {
|
||||||
|
path: None,
|
||||||
|
mode: WallpaperMode::Fit,
|
||||||
|
});
|
||||||
|
state.reload(WallpaperConfig::default());
|
||||||
|
assert!(state.pending.is_none());
|
||||||
|
sender.send(Err("bad image".into())).unwrap();
|
||||||
|
assert!(state.poll());
|
||||||
|
assert!(state.pixels.is_some());
|
||||||
|
assert_eq!(state.mode, WallpaperMode::Fill);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn fill_crops_and_fit_letterboxes_without_reallocating_image() {
|
fn fill_crops_and_fit_letterboxes_without_reallocating_image() {
|
||||||
let (geometry, source) = image_geometry(
|
let (geometry, source) = image_geometry(
|
||||||
(3840, 2160).into(),
|
(3840, 2160).into(),
|
||||||
|
|||||||
+266
-23
@@ -85,6 +85,7 @@ render_elements! {
|
|||||||
Surface=SurfaceRenderElement,
|
Surface=SurfaceRenderElement,
|
||||||
Memory=MemoryRenderElement,
|
Memory=MemoryRenderElement,
|
||||||
Solid=SolidColorRenderElement,
|
Solid=SolidColorRenderElement,
|
||||||
|
LockSurface=WaylandSurfaceRenderElement<GlesRenderer>,
|
||||||
Border=PixelShaderElement,
|
Border=PixelShaderElement,
|
||||||
Blur=BlurRenderElement,
|
Blur=BlurRenderElement,
|
||||||
Effect=PhysicalShaderElement,
|
Effect=PhysicalShaderElement,
|
||||||
@@ -226,13 +227,19 @@ void main() {
|
|||||||
"#;
|
"#;
|
||||||
|
|
||||||
const ROUNDED_BORDER_SHADER: &str = r#"
|
const ROUNDED_BORDER_SHADER: &str = r#"
|
||||||
precision mediump float;
|
precision highp float;
|
||||||
|
|
||||||
uniform float alpha;
|
uniform float alpha;
|
||||||
uniform vec4 clip_rect;
|
uniform vec4 clip_rect;
|
||||||
uniform float radius;
|
uniform float radius;
|
||||||
uniform float border_width;
|
uniform float border_width;
|
||||||
uniform vec4 border_color;
|
uniform vec4 border_color;
|
||||||
|
uniform vec4 border_color_to;
|
||||||
|
uniform vec4 gradient_line;
|
||||||
|
uniform vec4 focus_color;
|
||||||
|
uniform vec4 focus_color_to;
|
||||||
|
uniform vec4 focus_gradient_line;
|
||||||
|
uniform float focus_mix;
|
||||||
varying vec2 v_coords;
|
varying vec2 v_coords;
|
||||||
|
|
||||||
#if defined(DEBUG_FLAGS)
|
#if defined(DEBUG_FLAGS)
|
||||||
@@ -257,8 +264,15 @@ void main() {
|
|||||||
- inner_radius;
|
- inner_radius;
|
||||||
float inner_coverage = 1.0 - smoothstep(-0.5, 0.5, inner_signed_distance);
|
float inner_coverage = 1.0 - smoothstep(-0.5, 0.5, inner_signed_distance);
|
||||||
float coverage = max(outer_coverage - inner_coverage, 0.0);
|
float coverage = max(outer_coverage - inner_coverage, 0.0);
|
||||||
vec4 color = vec4(border_color.rgb * border_color.a, border_color.a)
|
float progress = clamp(dot(gl_FragCoord.xy - gradient_line.xy, gradient_line.zw), 0.0, 1.0);
|
||||||
* coverage * alpha;
|
// Interpolate premultiplied endpoints: a transparent endpoint must not
|
||||||
|
// leak its RGB into the visible border or create a dark halo.
|
||||||
|
vec4 from = vec4(border_color.rgb * border_color.a, border_color.a);
|
||||||
|
vec4 to = vec4(border_color_to.rgb * border_color_to.a, border_color_to.a);
|
||||||
|
float focus_progress = clamp(dot(gl_FragCoord.xy - focus_gradient_line.xy, focus_gradient_line.zw), 0.0, 1.0);
|
||||||
|
vec4 focus_from = vec4(focus_color.rgb * focus_color.a, focus_color.a);
|
||||||
|
vec4 focus_to = vec4(focus_color_to.rgb * focus_color_to.a, focus_color_to.a);
|
||||||
|
vec4 color = mix(mix(from, to, progress), mix(focus_from, focus_to, focus_progress), focus_mix) * coverage * alpha;
|
||||||
|
|
||||||
#if defined(DEBUG_FLAGS)
|
#if defined(DEBUG_FLAGS)
|
||||||
if (tint == 1.0)
|
if (tint == 1.0)
|
||||||
@@ -357,13 +371,14 @@ uniform vec4 visible_rect;
|
|||||||
uniform float material_radius;
|
uniform float material_radius;
|
||||||
uniform vec2 texture_size;
|
uniform vec2 texture_size;
|
||||||
uniform float blur_radius;
|
uniform float blur_radius;
|
||||||
|
uniform float presentation_alpha;
|
||||||
uniform vec4 tint;
|
uniform vec4 tint;
|
||||||
varying vec2 v_coords;
|
varying vec2 v_coords;
|
||||||
void main() {
|
void main() {
|
||||||
vec2 half_size = visible_rect.zw * 0.5;
|
vec2 half_size = visible_rect.zw * 0.5;
|
||||||
vec2 d = abs(gl_FragCoord.xy - visible_rect.xy - half_size) - (half_size - vec2(material_radius));
|
vec2 d = abs(gl_FragCoord.xy - visible_rect.xy - half_size) - (half_size - vec2(material_radius));
|
||||||
float sdf = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - material_radius;
|
float sdf = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - material_radius;
|
||||||
float coverage = alpha * (1.0 - smoothstep(-0.5, 0.5, sdf));
|
float coverage = alpha * presentation_alpha * (1.0 - smoothstep(-0.5, 0.5, sdf));
|
||||||
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
|
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
|
||||||
// Denser Gaussian sampling preserves the same radius and softness without
|
// Denser Gaussian sampling preserves the same radius and softness without
|
||||||
// the visible grid left by widely spaced taps on detailed wallpapers.
|
// the visible grid left by widely spaced taps on detailed wallpapers.
|
||||||
@@ -562,6 +577,7 @@ pub(crate) struct MaterialProgram(GlesPixelProgram);
|
|||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
struct MaterialParameters {
|
struct MaterialParameters {
|
||||||
|
presentation_alpha: f32,
|
||||||
blur: f32,
|
blur: f32,
|
||||||
scene_generation: u64,
|
scene_generation: u64,
|
||||||
sample_geometry: Rectangle<i32, Logical>,
|
sample_geometry: Rectangle<i32, Logical>,
|
||||||
@@ -604,6 +620,12 @@ struct BorderParameters {
|
|||||||
radius: f32,
|
radius: f32,
|
||||||
width: f32,
|
width: f32,
|
||||||
color: [f32; 4],
|
color: [f32; 4],
|
||||||
|
color_to: [f32; 4],
|
||||||
|
gradient_line: [f32; 4],
|
||||||
|
focus_color: [f32; 4],
|
||||||
|
focus_color_to: [f32; 4],
|
||||||
|
focus_gradient_line: [f32; 4],
|
||||||
|
focus_mix: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -902,6 +924,9 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
|||||||
state.loop_signal.stop();
|
state.loop_signal.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if state.session_lock.active {
|
||||||
|
state.lock_frame_presented(&output);
|
||||||
|
}
|
||||||
let elapsed = render_started.elapsed();
|
let elapsed = render_started.elapsed();
|
||||||
if elapsed > refresh.get() {
|
if elapsed > refresh.get() {
|
||||||
missed_deadlines += (elapsed.as_nanos() / refresh.get().as_nanos()) as u64;
|
missed_deadlines += (elapsed.as_nanos() / refresh.get().as_nanos()) as u64;
|
||||||
@@ -946,6 +971,10 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_nested_frame_callbacks(state: &mut Ferese, output: &Output, refresh: Duration) {
|
fn send_nested_frame_callbacks(state: &mut Ferese, output: &Output, refresh: Duration) {
|
||||||
|
if state.session_lock.active {
|
||||||
|
state.lock_frame_callbacks(output);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let time = state.start_time.elapsed();
|
let time = state.start_time.elapsed();
|
||||||
for window in state.space.elements() {
|
for window in state.space.elements() {
|
||||||
window.send_frame(output, time, Some(refresh), |_, _| Some(output.clone()));
|
window.send_frame(output, time, Some(refresh), |_, _| Some(output.clone()));
|
||||||
@@ -985,6 +1014,58 @@ fn output_elements(
|
|||||||
output: &Output,
|
output: &Output,
|
||||||
include_cursor: bool,
|
include_cursor: bool,
|
||||||
) -> Vec<AnimatedWindowRenderElement> {
|
) -> Vec<AnimatedWindowRenderElement> {
|
||||||
|
if state.session_lock.active {
|
||||||
|
state.configure_lock_surfaces();
|
||||||
|
let Some(geometry) = state.space.output_geometry(output) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let scale = output.current_scale().fractional_scale();
|
||||||
|
let background = state
|
||||||
|
.session_lock
|
||||||
|
.backgrounds
|
||||||
|
.entry(output.clone())
|
||||||
|
.or_insert_with(|| {
|
||||||
|
smithay::backend::renderer::element::solid::SolidColorBuffer::new(
|
||||||
|
geometry.size,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
)
|
||||||
|
});
|
||||||
|
background.resize(geometry.size);
|
||||||
|
let mut elements = Vec::new();
|
||||||
|
if let Some(surface) = state
|
||||||
|
.session_lock
|
||||||
|
.surfaces
|
||||||
|
.get(output)
|
||||||
|
.filter(|surface| surface.alive())
|
||||||
|
{
|
||||||
|
elements.extend(
|
||||||
|
render_elements_from_surface_tree::<
|
||||||
|
GlesRenderer,
|
||||||
|
WaylandSurfaceRenderElement<GlesRenderer>,
|
||||||
|
>(
|
||||||
|
renderer,
|
||||||
|
surface.wl_surface(),
|
||||||
|
(0, 0),
|
||||||
|
scale,
|
||||||
|
1.0,
|
||||||
|
RenderElementKind::Unspecified,
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(AnimatedWindowRenderElement::from),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
elements.push(
|
||||||
|
SolidColorRenderElement::from_buffer(
|
||||||
|
background,
|
||||||
|
(0, 0),
|
||||||
|
scale,
|
||||||
|
1.0,
|
||||||
|
RenderElementKind::Unspecified,
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
if state.wallpaper.poll() {
|
if state.wallpaper.poll() {
|
||||||
state.backdrop_generation = state.backdrop_generation.wrapping_add(1);
|
state.backdrop_generation = state.backdrop_generation.wrapping_add(1);
|
||||||
}
|
}
|
||||||
@@ -1082,18 +1163,15 @@ fn output_elements(
|
|||||||
} else {
|
} else {
|
||||||
state.focused_window == Some(id)
|
state.focused_window == Some(id)
|
||||||
};
|
};
|
||||||
let border_width = if focused {
|
let focus = state
|
||||||
state.theme_settings.focus_ring_width
|
.window_focus
|
||||||
} else {
|
.get(&id)
|
||||||
state.theme_settings.border_width
|
.map_or(if focused { 1.0 } else { 0.0 }, |v| v.current);
|
||||||
};
|
let border_width = state.theme_settings.border_width
|
||||||
let border_color = if focused {
|
+ (state.theme_settings.focus_ring_width - state.theme_settings.border_width)
|
||||||
state.theme_settings.accent_color.0
|
* focus;
|
||||||
} else {
|
let border_color = state.theme_settings.border_color.0;
|
||||||
state.theme_settings.border_color.0
|
let gradient = state.theme_settings.border_gradient;
|
||||||
};
|
|
||||||
let border_color =
|
|
||||||
color_with_alpha(border_color, close_alpha * decoration_progress as f32);
|
|
||||||
|
|
||||||
if let Some(border) = window_border_element(
|
if let Some(border) = window_border_element(
|
||||||
state,
|
state,
|
||||||
@@ -1105,6 +1183,9 @@ fn output_elements(
|
|||||||
window_radius,
|
window_radius,
|
||||||
border_width,
|
border_width,
|
||||||
border_color,
|
border_color,
|
||||||
|
gradient,
|
||||||
|
focus as f32,
|
||||||
|
close_alpha * decoration_progress as f32,
|
||||||
output,
|
output,
|
||||||
&programs,
|
&programs,
|
||||||
) {
|
) {
|
||||||
@@ -1263,6 +1344,12 @@ fn overview_chrome_element(
|
|||||||
radius: (radius * scale) as f32,
|
radius: (radius * scale) as f32,
|
||||||
width: (width * scale) as f32,
|
width: (width * scale) as f32,
|
||||||
color,
|
color,
|
||||||
|
color_to: color,
|
||||||
|
gradient_line: [0.0; 4],
|
||||||
|
focus_color: color,
|
||||||
|
focus_color_to: color,
|
||||||
|
focus_gradient_line: [0.0; 4],
|
||||||
|
focus_mix: 0.0,
|
||||||
};
|
};
|
||||||
let program = if key.1 {
|
let program = if key.1 {
|
||||||
rounded_clip_program(state, renderer)?.border
|
rounded_clip_program(state, renderer)?.border
|
||||||
@@ -1472,6 +1559,12 @@ fn rounded_clip_program(
|
|||||||
UniformName::new("radius", UniformType::_1f),
|
UniformName::new("radius", UniformType::_1f),
|
||||||
UniformName::new("border_width", UniformType::_1f),
|
UniformName::new("border_width", UniformType::_1f),
|
||||||
UniformName::new("border_color", UniformType::_4f),
|
UniformName::new("border_color", UniformType::_4f),
|
||||||
|
UniformName::new("border_color_to", UniformType::_4f),
|
||||||
|
UniformName::new("gradient_line", UniformType::_4f),
|
||||||
|
UniformName::new("focus_color", UniformType::_4f),
|
||||||
|
UniformName::new("focus_color_to", UniformType::_4f),
|
||||||
|
UniformName::new("focus_gradient_line", UniformType::_4f),
|
||||||
|
UniformName::new("focus_mix", UniformType::_1f),
|
||||||
];
|
];
|
||||||
let shadow_uniforms = [
|
let shadow_uniforms = [
|
||||||
UniformName::new("shadow_rect", UniformType::_4f),
|
UniformName::new("shadow_rect", UniformType::_4f),
|
||||||
@@ -1516,6 +1609,9 @@ fn window_border_element(
|
|||||||
requested_radius: f64,
|
requested_radius: f64,
|
||||||
requested_width: f64,
|
requested_width: f64,
|
||||||
color: [f32; 4],
|
color: [f32; 4],
|
||||||
|
gradient: Option<crate::config::BorderGradient>,
|
||||||
|
focus_mix: f32,
|
||||||
|
opacity: f32,
|
||||||
output: &Output,
|
output: &Output,
|
||||||
programs: &RoundedClipPrograms,
|
programs: &RoundedClipPrograms,
|
||||||
) -> Option<PhysicalShaderElement> {
|
) -> Option<PhysicalShaderElement> {
|
||||||
@@ -1525,12 +1621,49 @@ fn window_border_element(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (from, to, gradient_line) = match gradient {
|
||||||
|
Some(gradient) => (
|
||||||
|
gradient.from.0,
|
||||||
|
gradient.to.0,
|
||||||
|
border_gradient_line(
|
||||||
|
physical,
|
||||||
|
mode.size,
|
||||||
|
output.current_transform().invert(),
|
||||||
|
gradient.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
None => (color, color, [0.0; 4]),
|
||||||
|
};
|
||||||
|
let (focus_from, focus_to, focus_gradient_line) = match state.theme_settings.focus_ring_gradient
|
||||||
|
{
|
||||||
|
Some(g) => (
|
||||||
|
g.from.0,
|
||||||
|
g.to.0,
|
||||||
|
border_gradient_line(
|
||||||
|
physical,
|
||||||
|
mode.size,
|
||||||
|
output.current_transform().invert(),
|
||||||
|
g.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
None => (
|
||||||
|
state.theme_settings.accent_color.0,
|
||||||
|
state.theme_settings.accent_color.0,
|
||||||
|
[0.0; 4],
|
||||||
|
),
|
||||||
|
};
|
||||||
let parameters = BorderParameters {
|
let parameters = BorderParameters {
|
||||||
geometry,
|
geometry,
|
||||||
clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()),
|
clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()),
|
||||||
radius: scaled_effect_value(requested_radius, geometry, scale),
|
radius: scaled_effect_value(requested_radius, geometry, scale),
|
||||||
width,
|
width,
|
||||||
color,
|
color: color_with_alpha(from, opacity),
|
||||||
|
color_to: color_with_alpha(to, opacity),
|
||||||
|
gradient_line,
|
||||||
|
focus_color: color_with_alpha(focus_from, opacity),
|
||||||
|
focus_color_to: color_with_alpha(focus_to, opacity),
|
||||||
|
focus_gradient_line,
|
||||||
|
focus_mix,
|
||||||
};
|
};
|
||||||
let context = renderer.context_id().erased();
|
let context = renderer.context_id().erased();
|
||||||
let buffers = state.window_borders.entry(id).or_default();
|
let buffers = state.window_borders.entry(id).or_default();
|
||||||
@@ -1573,6 +1706,12 @@ fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'static>> {
|
|||||||
Uniform::new("radius", parameters.radius).into_owned(),
|
Uniform::new("radius", parameters.radius).into_owned(),
|
||||||
Uniform::new("border_width", parameters.width).into_owned(),
|
Uniform::new("border_width", parameters.width).into_owned(),
|
||||||
Uniform::new("border_color", parameters.color).into_owned(),
|
Uniform::new("border_color", parameters.color).into_owned(),
|
||||||
|
Uniform::new("border_color_to", parameters.color_to).into_owned(),
|
||||||
|
Uniform::new("gradient_line", parameters.gradient_line).into_owned(),
|
||||||
|
Uniform::new("focus_color", parameters.focus_color).into_owned(),
|
||||||
|
Uniform::new("focus_color_to", parameters.focus_color_to).into_owned(),
|
||||||
|
Uniform::new("focus_gradient_line", parameters.focus_gradient_line).into_owned(),
|
||||||
|
Uniform::new("focus_mix", parameters.focus_mix).into_owned(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1605,6 +1744,12 @@ fn window_tint_element(
|
|||||||
radius: scaled_effect_value(radius, geometry, scale),
|
radius: scaled_effect_value(radius, geometry, scale),
|
||||||
width: 0.0,
|
width: 0.0,
|
||||||
color,
|
color,
|
||||||
|
color_to: color,
|
||||||
|
gradient_line: [0.0; 4],
|
||||||
|
focus_color: color,
|
||||||
|
focus_color_to: color,
|
||||||
|
focus_gradient_line: [0.0; 4],
|
||||||
|
focus_mix: 0.0,
|
||||||
};
|
};
|
||||||
let uniforms = |p: &BorderParameters| {
|
let uniforms = |p: &BorderParameters| {
|
||||||
vec![
|
vec![
|
||||||
@@ -1921,6 +2066,34 @@ fn framebuffer_clip_rect(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn border_gradient_line(
|
||||||
|
geometry: Rectangle<i32, Physical>,
|
||||||
|
output_size: Size<i32, Physical>,
|
||||||
|
transform: Transform,
|
||||||
|
angle: f64,
|
||||||
|
) -> [f32; 4] {
|
||||||
|
let angle = angle.to_radians();
|
||||||
|
let direction = (angle.cos(), angle.sin());
|
||||||
|
let width = f64::from(geometry.size.w);
|
||||||
|
let height = f64::from(geometry.size.h);
|
||||||
|
let extent = (width * direction.0.abs() + height * direction.1.abs()) * 0.5;
|
||||||
|
let center = geometry.loc.to_f64() + Point::from((width * 0.5, height * 0.5));
|
||||||
|
let offset: Point<f64, Physical> = (direction.0 * extent, direction.1 * extent).into();
|
||||||
|
// Apply the same output-to-framebuffer transform as the rounded clip.
|
||||||
|
// This keeps angles in window coordinates on rotated/flipped monitors.
|
||||||
|
let area = transform.transform_size(output_size).to_f64();
|
||||||
|
let start = transform.transform_point_in(center - offset, &area);
|
||||||
|
let end = transform.transform_point_in(center + offset, &area);
|
||||||
|
let delta = end - start;
|
||||||
|
let length_squared = (delta.x * delta.x + delta.y * delta.y).max(0.000001);
|
||||||
|
[
|
||||||
|
start.x as f32,
|
||||||
|
start.y as f32,
|
||||||
|
(delta.x / length_squared) as f32,
|
||||||
|
(delta.y / length_squared) as f32,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn redraw_output<R, E>(
|
pub(crate) fn redraw_output<R, E>(
|
||||||
renderer: &mut R,
|
renderer: &mut R,
|
||||||
framebuffer: &mut R::Framebuffer<'_>,
|
framebuffer: &mut R::Framebuffer<'_>,
|
||||||
@@ -2087,7 +2260,7 @@ fn append_material_surface(
|
|||||||
surface,
|
surface,
|
||||||
content_origin.to_physical_precise_round(scale),
|
content_origin.to_physical_precise_round(scale),
|
||||||
scale,
|
scale,
|
||||||
1.0,
|
crate::effects::surface_opacity(surface),
|
||||||
RenderElementKind::Unspecified,
|
RenderElementKind::Unspecified,
|
||||||
);
|
);
|
||||||
elements.extend(content.into_iter().filter_map(|element| {
|
elements.extend(content.into_iter().filter_map(|element| {
|
||||||
@@ -2125,6 +2298,7 @@ fn material_element(
|
|||||||
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
|
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
|
||||||
let (role, generation) = crate::effects::surface_role(surface)?;
|
let (role, generation) = crate::effects::surface_role(surface)?;
|
||||||
let mut material = crate::effects::resolve_material(role, state.theme_settings.material_style);
|
let mut material = crate::effects::resolve_material(role, state.theme_settings.material_style);
|
||||||
|
let presentation_alpha = crate::effects::surface_opacity(surface);
|
||||||
if role == crate::effects::SemanticRole::Panel
|
if role == crate::effects::SemanticRole::Panel
|
||||||
&& material.style == crate::config::MaterialStyle::Translucent
|
&& material.style == crate::config::MaterialStyle::Translucent
|
||||||
{
|
{
|
||||||
@@ -2138,7 +2312,11 @@ fn material_element(
|
|||||||
let [offset_y, shadow_blur, shadow_opacity] = material.shadow;
|
let [offset_y, shadow_blur, shadow_opacity] = material.shadow;
|
||||||
let edge_bar =
|
let edge_bar =
|
||||||
role == crate::effects::SemanticRole::Panel && radius == 0.0 && geometry.loc.y == 0;
|
role == crate::effects::SemanticRole::Panel && radius == 0.0 && geometry.loc.y == 0;
|
||||||
let shadow_opacity = if edge_bar { 0.0 } else { shadow_opacity };
|
let shadow_opacity = if edge_bar {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
shadow_opacity * f64::from(presentation_alpha)
|
||||||
|
};
|
||||||
let shadow_geometry = Rectangle::new(
|
let shadow_geometry = Rectangle::new(
|
||||||
(geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(),
|
(geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(),
|
||||||
geometry.size,
|
geometry.size,
|
||||||
@@ -2157,6 +2335,7 @@ fn material_element(
|
|||||||
let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size);
|
let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size);
|
||||||
let sample_physical = sample_geometry.to_physical_precise_round(scale);
|
let sample_physical = sample_geometry.to_physical_precise_round(scale);
|
||||||
let parameters = MaterialParameters {
|
let parameters = MaterialParameters {
|
||||||
|
presentation_alpha,
|
||||||
blur: (blur * scale) as f32,
|
blur: (blur * scale) as f32,
|
||||||
scene_generation: if blur > 0.0 {
|
scene_generation: if blur > 0.0 {
|
||||||
state.backdrop_generation
|
state.backdrop_generation
|
||||||
@@ -2175,9 +2354,14 @@ fn material_element(
|
|||||||
shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32],
|
shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32],
|
||||||
shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur),
|
shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur),
|
||||||
geometry,
|
geometry,
|
||||||
tint: [red, green, blue, material.opacity],
|
tint: [
|
||||||
|
red,
|
||||||
|
green,
|
||||||
|
blue,
|
||||||
|
material.opacity * if blur > 0.0 { 1.0 } else { presentation_alpha },
|
||||||
|
],
|
||||||
generation,
|
generation,
|
||||||
opaque: material.opacity == 1.0 && radius == 0.0,
|
opaque: material.opacity == 1.0 && presentation_alpha == 1.0 && radius == 0.0,
|
||||||
visible_framebuffer: framebuffer_clip_rect(
|
visible_framebuffer: framebuffer_clip_rect(
|
||||||
geometry.to_physical_precise_round(scale),
|
geometry.to_physical_precise_round(scale),
|
||||||
mode.size,
|
mode.size,
|
||||||
@@ -2304,6 +2488,7 @@ fn blur_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option<BlurP
|
|||||||
UniformName::new("material_radius", UniformType::_1f),
|
UniformName::new("material_radius", UniformType::_1f),
|
||||||
UniformName::new("texture_size", UniformType::_2f),
|
UniformName::new("texture_size", UniformType::_2f),
|
||||||
UniformName::new("blur_radius", UniformType::_1f),
|
UniformName::new("blur_radius", UniformType::_1f),
|
||||||
|
UniformName::new("presentation_alpha", UniformType::_1f),
|
||||||
UniformName::new("tint", UniformType::_4f),
|
UniformName::new("tint", UniformType::_4f),
|
||||||
];
|
];
|
||||||
match renderer.compile_custom_texture_shader(BLUR_SHADER, &uniforms) {
|
match renderer.compile_custom_texture_shader(BLUR_SHADER, &uniforms) {
|
||||||
@@ -2332,6 +2517,7 @@ fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
|
|||||||
)
|
)
|
||||||
.into_owned(),
|
.into_owned(),
|
||||||
Uniform::new("blur_radius", p.blur).into_owned(),
|
Uniform::new("blur_radius", p.blur).into_owned(),
|
||||||
|
Uniform::new("presentation_alpha", p.presentation_alpha).into_owned(),
|
||||||
Uniform::new("tint", p.tint).into_owned(),
|
Uniform::new("tint", p.tint).into_owned(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2491,10 +2677,67 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
color_with_alpha, framebuffer_clip_rect, normalized_scale, resize_content_behavior,
|
border_gradient_line, color_with_alpha, framebuffer_clip_rect, normalized_scale,
|
||||||
rounded_visual_rect, scaled_visual_rect, shadow_bounds,
|
resize_content_behavior, rounded_visual_rect, scaled_visual_rect, shadow_bounds,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gradient_direction_survives_every_output_transform() {
|
||||||
|
let mode = (2560, 1600).into();
|
||||||
|
for transform in [
|
||||||
|
Transform::Normal,
|
||||||
|
Transform::_90,
|
||||||
|
Transform::_180,
|
||||||
|
Transform::_270,
|
||||||
|
Transform::Flipped,
|
||||||
|
Transform::Flipped90,
|
||||||
|
Transform::Flipped180,
|
||||||
|
Transform::Flipped270,
|
||||||
|
] {
|
||||||
|
let area = transform.transform_size(mode).to_f64();
|
||||||
|
let rect: Rectangle<i32, Physical> =
|
||||||
|
Rectangle::new((110, 60).into(), (800, 600).into());
|
||||||
|
for (angle, first, last) in [
|
||||||
|
(0.0, (110.0, 360.0), (910.0, 360.0)),
|
||||||
|
(90.0, (510.0, 60.0), (510.0, 660.0)),
|
||||||
|
(45.0, (110.0, 60.0), (910.0, 660.0)),
|
||||||
|
(135.0, (910.0, 60.0), (110.0, 660.0)),
|
||||||
|
] {
|
||||||
|
let line = border_gradient_line(rect, mode, transform, angle);
|
||||||
|
let progress = |point: smithay::utils::Point<f64, Physical>| {
|
||||||
|
let point = transform.transform_point_in(point, &area);
|
||||||
|
(point.x - f64::from(line[0])) * f64::from(line[2])
|
||||||
|
+ (point.y - f64::from(line[1])) * f64::from(line[3])
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
progress(first.into()).abs() < 0.00001,
|
||||||
|
"{transform:?}, {angle}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(progress(last.into()) - 1.0).abs() < 0.00001,
|
||||||
|
"{transform:?}, {angle}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gradient_coordinates_follow_fractional_resize_and_translation() {
|
||||||
|
for scale in [1.0, 1.25, 1.5, 1.8, 2.0] {
|
||||||
|
for width in [500.0, 733.25, 1000.0] {
|
||||||
|
let rect = crate::presentation::physical_rect(
|
||||||
|
ferese_layout::Rect::new(40.25, 60.75, width, 600.5),
|
||||||
|
(0, 0).into(),
|
||||||
|
scale,
|
||||||
|
);
|
||||||
|
let line = border_gradient_line(rect, (3840, 2160).into(), Transform::Normal, 0.0);
|
||||||
|
assert!((f64::from(line[0]) - f64::from(rect.loc.x)).abs() < 0.001);
|
||||||
|
assert!((f64::from(line[2]) * f64::from(rect.size.w) - 1.0).abs() < 0.00001);
|
||||||
|
assert!(line.iter().all(|component| component.is_finite()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct DamageElement {
|
struct DamageElement {
|
||||||
id: Id,
|
id: Id,
|
||||||
|
|||||||
@@ -376,6 +376,23 @@ impl Default for WorkspaceSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceSet {
|
impl WorkspaceSet {
|
||||||
|
/// Update policy without moving windows or replacing existing column widths.
|
||||||
|
pub fn reconfigure_defaults(
|
||||||
|
&mut self,
|
||||||
|
mode: LayoutMode,
|
||||||
|
width: ColumnWidth,
|
||||||
|
strategy: ViewportFocusStrategy,
|
||||||
|
) {
|
||||||
|
self.default_layout_mode = mode;
|
||||||
|
self.default_column_width = width;
|
||||||
|
self.scrolling_focus_strategy = strategy;
|
||||||
|
for workspace in self.workspaces.values_mut() {
|
||||||
|
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
|
||||||
|
layout.set_default_width(width);
|
||||||
|
layout.set_focus_strategy(strategy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn new(
|
pub fn new(
|
||||||
default_layout_mode: LayoutMode,
|
default_layout_mode: LayoutMode,
|
||||||
default_column_width: ColumnWidth,
|
default_column_width: ColumnWidth,
|
||||||
@@ -994,6 +1011,34 @@ fn finite_or_zero(value: f64) -> f64 {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn reconfiguration_preserves_windows_and_current_widths_but_updates_defaults() {
|
||||||
|
let mut workspaces = WorkspaceSet::default();
|
||||||
|
workspaces
|
||||||
|
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
let workspace = workspaces.active_id();
|
||||||
|
workspaces.reconfigure_defaults(
|
||||||
|
LayoutMode::Tree,
|
||||||
|
ColumnWidth::Full,
|
||||||
|
ViewportFocusStrategy::Minimal,
|
||||||
|
);
|
||||||
|
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
|
||||||
|
panic!("existing layout must survive");
|
||||||
|
};
|
||||||
|
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
|
||||||
|
assert_eq!(layout.default_width(), ColumnWidth::Full);
|
||||||
|
assert_eq!(
|
||||||
|
workspaces.workspace_for_window(WindowId(1)),
|
||||||
|
Some(workspace)
|
||||||
|
);
|
||||||
|
workspaces.switch_to_numeric(2).unwrap();
|
||||||
|
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree);
|
||||||
|
assert_eq!(
|
||||||
|
workspaces.workspace_for_window(WindowId(1)),
|
||||||
|
Some(workspace)
|
||||||
|
);
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ impl Default for ScrollingLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ScrollingLayout {
|
impl ScrollingLayout {
|
||||||
|
pub fn set_default_width(&mut self, width: ColumnWidth) {
|
||||||
|
self.default_width = normalized_width(width);
|
||||||
|
}
|
||||||
pub fn with_default_width(default_width: ColumnWidth) -> Self {
|
pub fn with_default_width(default_width: ColumnWidth) -> Self {
|
||||||
Self {
|
Self {
|
||||||
default_width: normalized_width(default_width),
|
default_width: normalized_width(default_width),
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
|
|||||||
}
|
}
|
||||||
"toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout"
|
"toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout"
|
||||||
| "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel"
|
| "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel"
|
||||||
| "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "exit" => {
|
| "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "reload-config"
|
||||||
|
| "exit" => {
|
||||||
if !positional.is_empty() {
|
if !positional.is_empty() {
|
||||||
return Err(format!("{command} does not accept arguments"));
|
return Err(format!("{command} does not accept arguments"));
|
||||||
}
|
}
|
||||||
@@ -78,7 +79,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
fn usage() -> String {
|
||||||
"usage: feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit>\n feresectl <get-focused-window|get-workspaces|get-outputs>".to_owned()
|
"usage: feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit>\n feresectl <get-focused-window|get-workspaces|get-outputs|reload-config>".to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -122,6 +123,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(parse_args(["exit".to_owned(), "extra".to_owned()]).is_err());
|
assert!(parse_args(["exit".to_owned(), "extra".to_owned()]).is_err());
|
||||||
assert!(parse_args(["focus".to_owned()]).is_err());
|
assert!(parse_args(["focus".to_owned()]).is_err());
|
||||||
assert!(parse_args(["reload-config".to_owned()]).is_err());
|
assert!(parse_args(["reload-config".to_owned()]).is_ok());
|
||||||
|
assert!(parse_args(["reload-config".to_owned(), "extra".to_owned()]).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
config=${XDG_CONFIG_HOME:-$HOME/.config}/ferese/swaylock.conf
|
||||||
|
if [[ -f $config ]]; then
|
||||||
|
exec swaylock --config "$config" --daemonize
|
||||||
|
fi
|
||||||
|
exec swaylock --color 0f1014 --daemonize
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
DEALINGS IN THE SOFTWARE.
|
DEALINGS IN THE SOFTWARE.
|
||||||
</copyright>
|
</copyright>
|
||||||
|
|
||||||
<interface name="ferese_effects_manager_v1" version="2">
|
<interface name="ferese_effects_manager_v1" version="3">
|
||||||
<description summary="assign semantic material roles to surfaces">
|
<description summary="assign semantic material roles to surfaces">
|
||||||
This private global is visible only to clients granted the Ferese effects
|
This private global is visible only to clients granted the Ferese effects
|
||||||
capability. Roles describe compositor-owned appearance policy and do not
|
capability. Roles describe compositor-owned appearance policy and do not
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</enum>
|
</enum>
|
||||||
</interface>
|
</interface>
|
||||||
|
|
||||||
<interface name="ferese_surface_effects_v1" version="2">
|
<interface name="ferese_surface_effects_v1" version="3">
|
||||||
<description summary="semantic material role for one surface">
|
<description summary="semantic material role for one surface">
|
||||||
The role affects only compositor rendering. It does not change surface
|
The role affects only compositor rendering. It does not change surface
|
||||||
geometry, input, focus, stacking, or the surface's Wayland role.
|
geometry, input, focus, stacking, or the surface's Wayland role.
|
||||||
@@ -88,5 +88,12 @@
|
|||||||
</description>
|
</description>
|
||||||
<arg name="regions" type="array"/>
|
<arg name="regions" type="array"/>
|
||||||
</request>
|
</request>
|
||||||
|
<request name="set_opacity" since="3">
|
||||||
|
<description summary="fade the complete surface presentation">
|
||||||
|
Opacity from 0 to 1000, including surface content, material and shadow.
|
||||||
|
Values above 1000 are clamped. Input and geometry remain unchanged.
|
||||||
|
</description>
|
||||||
|
<arg name="opacity" type="uint"/>
|
||||||
|
</request>
|
||||||
</interface>
|
</interface>
|
||||||
</protocol>
|
</protocol>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
DEALINGS IN THE SOFTWARE.
|
DEALINGS IN THE SOFTWARE.
|
||||||
</copyright>
|
</copyright>
|
||||||
|
|
||||||
<interface name="ferese_shell_manager_v1" version="1">
|
<interface name="ferese_shell_manager_v1" version="2">
|
||||||
<description summary="create a capability-scoped Ferese shell controller">
|
<description summary="create a capability-scoped Ferese shell controller">
|
||||||
This private global is visible only to clients granted the shell-control
|
This private global is visible only to clients granted the shell-control
|
||||||
capability. It does not assign a Wayland surface role.
|
capability. It does not assign a Wayland surface role.
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<request name="destroy" type="destructor"/>
|
<request name="destroy" type="destructor"/>
|
||||||
</interface>
|
</interface>
|
||||||
|
|
||||||
<interface name="ferese_shell_v1" version="1">
|
<interface name="ferese_shell_v1" version="2">
|
||||||
<description summary="managed-window metadata and shell control">
|
<description summary="managed-window metadata and shell control">
|
||||||
Window, workspace, and output identifiers are unsigned 64-bit values
|
Window, workspace, and output identifiers are unsigned 64-bit values
|
||||||
encoded as high and low 32-bit words. Each snapshot atomically replaces
|
encoded as high and low 32-bit words. Each snapshot atomically replaces
|
||||||
@@ -132,5 +132,16 @@
|
|||||||
<description summary="marks completion of initial protocol setup"/>
|
<description summary="marks completion of initial protocol setup"/>
|
||||||
<arg name="version" type="uint"/>
|
<arg name="version" type="uint"/>
|
||||||
</event>
|
</event>
|
||||||
|
<event name="config_begin" since="2">
|
||||||
|
<description summary="complete validated configuration for live reload">
|
||||||
|
Sent only after the compositor has accepted the complete configuration.
|
||||||
|
Followed by bounded UTF-8 chunks and config_end. Clients apply only the
|
||||||
|
completed source, preserving their current appearance if parsing fails.
|
||||||
|
</description>
|
||||||
|
</event>
|
||||||
|
<event name="config_chunk" since="2">
|
||||||
|
<arg name="source" type="string"/>
|
||||||
|
</event>
|
||||||
|
<event name="config_end" since="2"/>
|
||||||
</interface>
|
</interface>
|
||||||
</protocol>
|
</protocol>
|
||||||
@@ -13,7 +13,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop
|
|||||||
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
|
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
|
||||||
|
|
||||||
# Refuse to overwrite unrelated installations.
|
# Refuse to overwrite unrelated installations.
|
||||||
for name in ferese ferese-shell feresectl ferese-session; do
|
for name in ferese ferese-shell feresectl ferese-session ferese-lock; do
|
||||||
link=/usr/local/bin/$name
|
link=/usr/local/bin/$name
|
||||||
if [[ -e $link || -L $link ]]; then
|
if [[ -e $link || -L $link ]]; then
|
||||||
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || {
|
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || {
|
||||||
@@ -43,6 +43,7 @@ for name in ferese ferese-shell feresectl; do
|
|||||||
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
|
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
|
||||||
done
|
done
|
||||||
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
|
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
|
||||||
|
install -m 0755 -- "$repo_dir/packaging/ferese-lock" "$release_dir/ferese-lock"
|
||||||
install -m 0644 -- "$repo_dir/packaging/config.toml" "$release_dir/config.example.toml"
|
install -m 0644 -- "$repo_dir/packaging/config.toml" "$release_dir/config.example.toml"
|
||||||
|
|
||||||
if [[ -L $install_root/current ]]; then
|
if [[ -L $install_root/current ]]; then
|
||||||
@@ -50,7 +51,7 @@ if [[ -L $install_root/current ]]; then
|
|||||||
fi
|
fi
|
||||||
ln -s -- "releases/$release_id" "$install_root/current-$release_id"
|
ln -s -- "releases/$release_id" "$install_root/current-$release_id"
|
||||||
mv -Tf -- "$install_root/current-$release_id" "$install_root/current"
|
mv -Tf -- "$install_root/current-$release_id" "$install_root/current"
|
||||||
for name in ferese ferese-shell feresectl ferese-session; do
|
for name in ferese ferese-shell feresectl ferese-session ferese-lock; do
|
||||||
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
|
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
|
||||||
done
|
done
|
||||||
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
|
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ jiff.workspace = true
|
|||||||
# Keep GPU rendering available as a fallback/override. The shell defaults to
|
# Keep GPU rendering available as a fallback/override. The shell defaults to
|
||||||
# tiny-skia to avoid a second GPU initialization during desktop startup.
|
# tiny-skia to avoid a second GPU initialization during desktop startup.
|
||||||
libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
|
libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
|
||||||
|
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
wayland-client.workspace = true
|
wayland-client.workspace = true
|
||||||
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub(crate) struct ShellConfig {
|
pub(crate) struct ShellConfig {
|
||||||
|
pub(crate) animations: crate::motion::Settings,
|
||||||
pub(crate) font_family: Option<String>,
|
pub(crate) font_family: Option<String>,
|
||||||
pub(crate) wallpaper: WallpaperConfig,
|
pub(crate) wallpaper: WallpaperConfig,
|
||||||
pub(crate) theme: ShellTheme,
|
pub(crate) theme: ShellTheme,
|
||||||
@@ -111,6 +112,8 @@ pub(crate) enum WallpaperMode {
|
|||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct FereseConfig {
|
struct FereseConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
animations: crate::motion::Settings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
appearance: AppearanceConfig,
|
appearance: AppearanceConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -284,13 +287,24 @@ pub(crate) fn load() -> ShellConfig {
|
|||||||
return ShellConfig::default();
|
return ShellConfig::default();
|
||||||
};
|
};
|
||||||
|
|
||||||
match toml::from_str::<FereseConfig>(&source) {
|
match parse_source(&source) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("ferese-shell: failed to read {}: {error}", path.display());
|
||||||
|
ShellConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, toml::de::Error> {
|
||||||
|
match toml::from_str::<FereseConfig>(source) {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
let mut theme = shell_theme(&config.theme);
|
let mut theme = shell_theme(&config.theme);
|
||||||
theme.material_radius =
|
theme.material_radius =
|
||||||
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0);
|
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0);
|
||||||
|
|
||||||
ShellConfig {
|
Ok(ShellConfig {
|
||||||
|
animations: config.animations,
|
||||||
font_family: config.theme.typography.font_family,
|
font_family: config.theme.typography.font_family,
|
||||||
wallpaper: config.theme.background,
|
wallpaper: config.theme.background,
|
||||||
theme,
|
theme,
|
||||||
@@ -298,12 +312,9 @@ pub(crate) fn load() -> ShellConfig {
|
|||||||
low_battery_threshold: config.status.low_battery_threshold.min(100),
|
low_battery_threshold: config.status.low_battery_threshold.min(100),
|
||||||
..config.status
|
..config.status
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!("ferese-shell: failed to read {}: {error}", path.display());
|
|
||||||
ShellConfig::default()
|
|
||||||
}
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub(crate) struct OutputSnapshot {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct WorkspaceSnapshot {
|
pub(crate) struct WorkspaceSnapshot {
|
||||||
pub(crate) id: u64,
|
pub(crate) id: u64,
|
||||||
|
pub(crate) name: String,
|
||||||
pub(crate) output: Option<u64>,
|
pub(crate) output: Option<u64>,
|
||||||
pub(crate) active: bool,
|
pub(crate) active: bool,
|
||||||
}
|
}
|
||||||
@@ -58,12 +59,14 @@ pub(crate) struct ShellControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct ControlPoll {
|
pub(crate) struct ControlPoll {
|
||||||
|
pub(crate) config: Option<String>,
|
||||||
pub(crate) snapshot: Option<ShellSnapshot>,
|
pub(crate) snapshot: Option<ShellSnapshot>,
|
||||||
pub(crate) overview_active: Option<bool>,
|
pub(crate) overview_active: Option<bool>,
|
||||||
pub(crate) disconnected: bool,
|
pub(crate) disconnected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ControlUpdate {
|
enum ControlUpdate {
|
||||||
|
Config(String),
|
||||||
Snapshot(ShellSnapshot),
|
Snapshot(ShellSnapshot),
|
||||||
OverviewState(bool),
|
OverviewState(bool),
|
||||||
Disconnected,
|
Disconnected,
|
||||||
@@ -74,7 +77,7 @@ impl ShellControl {
|
|||||||
let connection = control_connection()?;
|
let connection = control_connection()?;
|
||||||
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
|
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
|
||||||
let qh = queue.handle();
|
let qh = queue.handle();
|
||||||
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 1..=1, ())?;
|
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 1..=2, ())?;
|
||||||
let shell = manager.get_shell(&qh, ());
|
let shell = manager.get_shell(&qh, ());
|
||||||
let (sender, updates) = mpsc::channel();
|
let (sender, updates) = mpsc::channel();
|
||||||
let mut state = ControlState::new(sender);
|
let mut state = ControlState::new(sender);
|
||||||
@@ -97,6 +100,7 @@ impl ShellControl {
|
|||||||
|
|
||||||
pub(crate) fn poll(&self) -> ControlPoll {
|
pub(crate) fn poll(&self) -> ControlPoll {
|
||||||
let mut poll = ControlPoll {
|
let mut poll = ControlPoll {
|
||||||
|
config: None,
|
||||||
snapshot: None,
|
snapshot: None,
|
||||||
overview_active: None,
|
overview_active: None,
|
||||||
disconnected: false,
|
disconnected: false,
|
||||||
@@ -104,6 +108,7 @@ impl ShellControl {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
match self.updates.try_recv() {
|
match self.updates.try_recv() {
|
||||||
|
Ok(ControlUpdate::Config(source)) => poll.config = Some(source),
|
||||||
Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot),
|
Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot),
|
||||||
Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active),
|
Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active),
|
||||||
Ok(ControlUpdate::Disconnected) | Err(TryRecvError::Disconnected) => {
|
Ok(ControlUpdate::Disconnected) | Err(TryRecvError::Disconnected) => {
|
||||||
@@ -156,6 +161,7 @@ fn control_connection() -> Result<Connection, Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ControlState {
|
struct ControlState {
|
||||||
|
config: Option<String>,
|
||||||
sender: Sender<ControlUpdate>,
|
sender: Sender<ControlUpdate>,
|
||||||
pending: ShellSnapshot,
|
pending: ShellSnapshot,
|
||||||
serial: Option<u32>,
|
serial: Option<u32>,
|
||||||
@@ -165,6 +171,7 @@ impl ControlState {
|
|||||||
fn new(sender: Sender<ControlUpdate>) -> Self {
|
fn new(sender: Sender<ControlUpdate>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sender,
|
sender,
|
||||||
|
config: None,
|
||||||
pending: ShellSnapshot::default(),
|
pending: ShellSnapshot::default(),
|
||||||
serial: None,
|
serial: None,
|
||||||
}
|
}
|
||||||
@@ -193,6 +200,15 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
|||||||
_qh: &QueueHandle<Self>,
|
_qh: &QueueHandle<Self>,
|
||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
|
ferese_shell_v1::Event::ConfigBegin => state.config = Some(String::new()),
|
||||||
|
ferese_shell_v1::Event::ConfigChunk { source } => {
|
||||||
|
append_config_chunk(&mut state.config, &source);
|
||||||
|
}
|
||||||
|
ferese_shell_v1::Event::ConfigEnd => {
|
||||||
|
if let Some(source) = state.config.take() {
|
||||||
|
let _ = state.sender.send(ControlUpdate::Config(source));
|
||||||
|
}
|
||||||
|
}
|
||||||
ferese_shell_v1::Event::SnapshotBegin { serial } => {
|
ferese_shell_v1::Event::SnapshotBegin { serial } => {
|
||||||
state.serial = Some(serial);
|
state.serial = Some(serial);
|
||||||
state.pending = ShellSnapshot::default();
|
state.pending = ShellSnapshot::default();
|
||||||
@@ -215,13 +231,14 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
|||||||
workspace_lo,
|
workspace_lo,
|
||||||
output_hi,
|
output_hi,
|
||||||
output_lo,
|
output_lo,
|
||||||
name: _,
|
name,
|
||||||
active,
|
active,
|
||||||
} => {
|
} => {
|
||||||
let output = join_id(output_hi, output_lo);
|
let output = join_id(output_hi, output_lo);
|
||||||
|
|
||||||
state.pending.workspaces.push(WorkspaceSnapshot {
|
state.pending.workspaces.push(WorkspaceSnapshot {
|
||||||
id: join_id(workspace_hi, workspace_lo),
|
id: join_id(workspace_hi, workspace_lo),
|
||||||
|
name,
|
||||||
output: (output != 0).then_some(output),
|
output: (output != 0).then_some(output),
|
||||||
active: active != 0,
|
active: active != 0,
|
||||||
});
|
});
|
||||||
@@ -276,3 +293,34 @@ fn split_id(id: u64) -> (u32, u32) {
|
|||||||
fn join_id(hi: u32, lo: u32) -> u64 {
|
fn join_id(hi: u32, lo: u32) -> u64 {
|
||||||
(u64::from(hi) << 32) | u64::from(lo)
|
(u64::from(hi) << 32) | u64::from(lo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_config_chunk(config: &mut Option<String>, source: &str) {
|
||||||
|
if let Some(buffer) = config {
|
||||||
|
if buffer.len() + source.len() > 60 * 1024 {
|
||||||
|
*config = None;
|
||||||
|
} else {
|
||||||
|
buffer.push_str(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_transfer_is_bounded_and_requires_begin() {
|
||||||
|
let mut config = None;
|
||||||
|
append_config_chunk(&mut config, "ignored");
|
||||||
|
assert!(config.is_none());
|
||||||
|
config = Some(String::new());
|
||||||
|
append_config_chunk(&mut config, "🌲");
|
||||||
|
append_config_chunk(&mut config, "theme");
|
||||||
|
assert_eq!(config.take().as_deref(), Some("🌲theme"));
|
||||||
|
config = Some("a".repeat(60 * 1024));
|
||||||
|
append_config_chunk(&mut config, "overflow");
|
||||||
|
assert!(config.is_none());
|
||||||
|
append_config_chunk(&mut config, "ignored after overflow");
|
||||||
|
assert!(config.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+208
-161
@@ -19,7 +19,9 @@ use cosmic::iced::event::{self, PlatformSpecific, wayland};
|
|||||||
use cosmic::iced::platform_specific::{
|
use cosmic::iced::platform_specific::{
|
||||||
runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings},
|
runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings},
|
||||||
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer},
|
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer},
|
||||||
shell::commands::layer_surface::{destroy_layer_surface, set_input_zone},
|
shell::commands::layer_surface::{
|
||||||
|
destroy_layer_surface, set_exclusive_zone, set_input_zone, set_margin, set_size,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use cosmic::iced::{
|
use cosmic::iced::{
|
||||||
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window,
|
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window,
|
||||||
@@ -42,12 +44,27 @@ use crate::control::{ShellControl, ShellSnapshot};
|
|||||||
|
|
||||||
const APP_ID: &str = "dev.ferese.Shell";
|
const APP_ID: &str = "dev.ferese.Shell";
|
||||||
|
|
||||||
static SHELL_FONT: std::sync::OnceLock<cosmic::font::Font> = std::sync::OnceLock::new();
|
static SHELL_FONT: std::sync::OnceLock<std::sync::RwLock<cosmic::font::Font>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
fn configured_font(family: Option<&str>) -> cosmic::font::Font {
|
fn configured_font(family: Option<&str>) -> cosmic::font::Font {
|
||||||
family.map_or_else(cosmic::font::default, |family| {
|
family.map_or_else(cosmic::font::default, |family| {
|
||||||
|
// Font names require static storage. Intern repeated families and cap
|
||||||
|
// the cache so live theme edits cannot leak unlimited strings.
|
||||||
|
static FONTS: std::sync::OnceLock<
|
||||||
|
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
|
||||||
|
> = std::sync::OnceLock::new();
|
||||||
|
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
|
||||||
|
if let Some(font) = fonts.get(family) {
|
||||||
|
return *font;
|
||||||
|
}
|
||||||
|
if fonts.len() >= 64 {
|
||||||
|
return cosmic::font::default();
|
||||||
|
}
|
||||||
let family = Box::leak(family.to_owned().into_boxed_str());
|
let family = Box::leak(family.to_owned().into_boxed_str());
|
||||||
cosmic::font::Font::with_name(family)
|
let font = cosmic::font::Font::with_name(family);
|
||||||
|
fonts.insert(family.to_owned(), font);
|
||||||
|
font
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +73,12 @@ fn configured_font(family: Option<&str>) -> cosmic::font::Font {
|
|||||||
fn text<'a>(
|
fn text<'a>(
|
||||||
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
|
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
|
||||||
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
|
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
|
||||||
cosmic::widget::text(content).font(*SHELL_FONT.get_or_init(cosmic::font::default))
|
cosmic::widget::text(content).font(
|
||||||
|
*SHELL_FONT
|
||||||
|
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
|
||||||
|
.read()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -66,11 +88,6 @@ struct BarMetrics {
|
|||||||
icon_size: u16,
|
icon_size: u16,
|
||||||
overview_icon_size: u16,
|
overview_icon_size: u16,
|
||||||
control_height: f32,
|
control_height: f32,
|
||||||
workspace_hit_width: f32,
|
|
||||||
active_marker_width: f32,
|
|
||||||
active_marker_height: f32,
|
|
||||||
dot_marker_size: f32,
|
|
||||||
empty_marker_size: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ShellTheme> for BarMetrics {
|
impl From<ShellTheme> for BarMetrics {
|
||||||
@@ -85,11 +102,6 @@ impl From<ShellTheme> for BarMetrics {
|
|||||||
icon_size: 19,
|
icon_size: 19,
|
||||||
overview_icon_size: 19,
|
overview_icon_size: 19,
|
||||||
control_height: (height - 4.0).max(21.0).min(height),
|
control_height: (height - 4.0).max(21.0).min(height),
|
||||||
workspace_hit_width: 22.0,
|
|
||||||
active_marker_width: 14.0,
|
|
||||||
active_marker_height: 4.0,
|
|
||||||
dot_marker_size: 4.0,
|
|
||||||
empty_marker_size: 3.0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,6 +115,7 @@ fn main() -> cosmic::iced::Result {
|
|||||||
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
|
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
|
||||||
}
|
}
|
||||||
let mut config = config::load();
|
let mut config = config::load();
|
||||||
|
motion::configure(config.animations);
|
||||||
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
|
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
|
||||||
if compositor_wallpaper {
|
if compositor_wallpaper {
|
||||||
// The compositor owns one GPU image, shared across output renderers;
|
// The compositor owns one GPU image, shared across output renderers;
|
||||||
@@ -110,7 +123,7 @@ fn main() -> cosmic::iced::Result {
|
|||||||
config.wallpaper.path = None;
|
config.wallpaper.path = None;
|
||||||
}
|
}
|
||||||
let shell_font = configured_font(config.font_family.as_deref());
|
let shell_font = configured_font(config.font_family.as_deref());
|
||||||
let _ = SHELL_FONT.set(shell_font);
|
let _ = SHELL_FONT.set(std::sync::RwLock::new(shell_font));
|
||||||
// Decode alongside toolkit/GPU initialization, never during a UI draw.
|
// Decode alongside toolkit/GPU initialization, never during a UI draw.
|
||||||
let wallpaper = config.wallpaper.path.clone().map(|path| {
|
let wallpaper = config.wallpaper.path.clone().map(|path| {
|
||||||
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
|
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
|
||||||
@@ -173,7 +186,6 @@ enum Message {
|
|||||||
),
|
),
|
||||||
Tick,
|
Tick,
|
||||||
ActivateWorkspace(u64),
|
ActivateWorkspace(u64),
|
||||||
ActivateWindow(u64),
|
|
||||||
ToggleOverview,
|
ToggleOverview,
|
||||||
StatusTick,
|
StatusTick,
|
||||||
AnimateMenu,
|
AnimateMenu,
|
||||||
@@ -252,7 +264,11 @@ impl cosmic::Application for FereseShell {
|
|||||||
}),
|
}),
|
||||||
cosmic::iced::time::every(Duration::from_millis(500)).map(|_| Message::Tick),
|
cosmic::iced::time::every(Duration::from_millis(500)).map(|_| Message::Tick),
|
||||||
cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::StatusTick),
|
cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::StatusTick),
|
||||||
if self.menu.as_ref().is_some_and(|menu| menu.animating()) {
|
if self
|
||||||
|
.menu
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|menu| menu.animating() || menu.motion.closing())
|
||||||
|
{
|
||||||
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
|
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
|
||||||
} else {
|
} else {
|
||||||
Subscription::none()
|
Subscription::none()
|
||||||
@@ -264,6 +280,9 @@ impl cosmic::Application for FereseShell {
|
|||||||
if let Some(menu) = &self.menu
|
if let Some(menu) = &self.menu
|
||||||
&& let Some(effects) = &menu.effects
|
&& let Some(effects) = &menu.effects
|
||||||
{
|
{
|
||||||
|
if let Err(error) = effects.set_opacity(menu.progress()) {
|
||||||
|
eprintln!("ferese-shell: could not update popup opacity: {error}");
|
||||||
|
}
|
||||||
let regions = menu.regions.lock().unwrap().clone();
|
let regions = menu.regions.lock().unwrap().clone();
|
||||||
if let Err(error) = effects.set_regions(®ions) {
|
if let Err(error) = effects.set_regions(®ions) {
|
||||||
eprintln!("ferese-shell: could not update card materials: {error}");
|
eprintln!("ferese-shell: could not update card materials: {error}");
|
||||||
@@ -295,6 +314,9 @@ impl cosmic::Application for FereseShell {
|
|||||||
eprintln!("ferese-shell: popover material unavailable: {error}")
|
eprintln!("ferese-shell: popover material unavailable: {error}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(effects) = &menu.effects {
|
||||||
|
let _ = effects.set_opacity(menu.progress());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"),
|
Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"),
|
||||||
@@ -310,7 +332,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::AnimateMenu => Task::none(),
|
Message::AnimateMenu => self.animate_menu(),
|
||||||
Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor),
|
Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor),
|
||||||
Message::OpenMenuOn(id, kind, anchor) => {
|
Message::OpenMenuOn(id, kind, anchor) => {
|
||||||
if self.bar_surface_id != id {
|
if self.bar_surface_id != id {
|
||||||
@@ -343,13 +365,14 @@ impl cosmic::Application for FereseShell {
|
|||||||
action,
|
action,
|
||||||
status::Action::Notifications | status::Action::Settings
|
status::Action::Notifications | status::Action::Settings
|
||||||
) {
|
) {
|
||||||
return self.destroy_menu();
|
return self.close_menu();
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::Event(event, id) => self.handle_event(event, id),
|
Message::Event(event, id) => self.handle_event(event, id),
|
||||||
Message::Tick => {
|
Message::Tick => {
|
||||||
self.clock = current_time();
|
self.clock = current_time();
|
||||||
|
let mut reload_task = Task::none();
|
||||||
if let Some(control) = &self.control {
|
if let Some(control) = &self.control {
|
||||||
let poll = control.poll();
|
let poll = control.poll();
|
||||||
|
|
||||||
@@ -359,9 +382,12 @@ impl cosmic::Application for FereseShell {
|
|||||||
if let Some(active) = poll.overview_active {
|
if let Some(active) = poll.overview_active {
|
||||||
self.overview_active = active;
|
self.overview_active = active;
|
||||||
}
|
}
|
||||||
|
if let Some(source) = poll.config {
|
||||||
|
reload_task = self.reload_config(source);
|
||||||
|
}
|
||||||
if let Some(snapshot) = poll.snapshot {
|
if let Some(snapshot) = poll.snapshot {
|
||||||
self.snapshot = snapshot;
|
self.snapshot = snapshot;
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = vec![reload_task];
|
||||||
for entry in &mut self.outputs {
|
for entry in &mut self.outputs {
|
||||||
let output = self
|
let output = self
|
||||||
.snapshot
|
.snapshot
|
||||||
@@ -392,13 +418,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
return Task::batch(tasks);
|
return Task::batch(tasks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Task::none()
|
reload_task
|
||||||
}
|
|
||||||
Message::ActivateWindow(id) => {
|
|
||||||
if let Some(control) = &self.control {
|
|
||||||
control.activate_window(id);
|
|
||||||
}
|
|
||||||
Task::none()
|
|
||||||
}
|
}
|
||||||
Message::ActivateWorkspace(id) => {
|
Message::ActivateWorkspace(id) => {
|
||||||
if let Some(control) = &self.control {
|
if let Some(control) = &self.control {
|
||||||
@@ -432,6 +452,62 @@ impl cosmic::Application for FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FereseShell {
|
impl FereseShell {
|
||||||
|
fn reload_config(&mut self, source: String) -> Task<Message> {
|
||||||
|
let mut config = match config::parse_source(&source) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("ferese-shell: reload rejected; keeping current config: {error}");
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
|
||||||
|
config.wallpaper.path = None;
|
||||||
|
}
|
||||||
|
if self.config.font_family != config.font_family {
|
||||||
|
*SHELL_FONT
|
||||||
|
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
|
||||||
|
.write()
|
||||||
|
.unwrap() = configured_font(config.font_family.as_deref());
|
||||||
|
}
|
||||||
|
motion::configure(config.animations);
|
||||||
|
if self.config.animations != config.animations
|
||||||
|
&& let Some(menu) = &mut self.menu
|
||||||
|
{
|
||||||
|
menu.motion.update_settings(config.animations);
|
||||||
|
}
|
||||||
|
self.status_service
|
||||||
|
.update_settings(config.status.settings_command.clone());
|
||||||
|
let old = self.config.theme;
|
||||||
|
let theme = config.theme;
|
||||||
|
let geometry_changed = old.bar_height != theme.bar_height
|
||||||
|
|| old.bar_margin_top != theme.bar_margin_top
|
||||||
|
|| old.bar_margin_horizontal != theme.bar_margin_horizontal
|
||||||
|
|| old.bar_window_gap != theme.bar_window_gap;
|
||||||
|
self.config = config;
|
||||||
|
if !geometry_changed {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for entry in &self.outputs {
|
||||||
|
tasks.push(set_size(
|
||||||
|
entry.bar,
|
||||||
|
None,
|
||||||
|
Some(theme.bar_height.round() as u32),
|
||||||
|
));
|
||||||
|
tasks.push(set_margin(
|
||||||
|
entry.bar,
|
||||||
|
theme.bar_margin_top,
|
||||||
|
theme.bar_margin_horizontal,
|
||||||
|
0,
|
||||||
|
theme.bar_margin_horizontal,
|
||||||
|
));
|
||||||
|
tasks.push(set_exclusive_zone(
|
||||||
|
entry.bar,
|
||||||
|
(theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Task::batch(tasks)
|
||||||
|
}
|
||||||
fn output_event(
|
fn output_event(
|
||||||
&mut self,
|
&mut self,
|
||||||
event: wayland::OutputEvent,
|
event: wayland::OutputEvent,
|
||||||
@@ -577,13 +653,23 @@ impl FereseShell {
|
|||||||
if self.outputs.iter().any(|entry| entry.bar == id)
|
if self.outputs.iter().any(|entry| entry.bar == id)
|
||||||
|| self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
|
|| self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
|
||||||
{
|
{
|
||||||
|
// Iced emits the first xdg_popup configure as Window::Opened,
|
||||||
|
// not Popup::Configured. This is the popup's ready signal.
|
||||||
|
if let Some(menu) = &mut self.menu
|
||||||
|
&& menu.id == id
|
||||||
|
{
|
||||||
|
menu.motion.begin(Instant::now());
|
||||||
|
if let Some(effects) = &menu.effects {
|
||||||
|
let _ = effects.set_opacity(menu.progress());
|
||||||
|
}
|
||||||
|
}
|
||||||
window::run(id, native_wayland_surface)
|
window::run(id, native_wayland_surface)
|
||||||
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface)))
|
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface)))
|
||||||
}
|
}
|
||||||
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
|
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
|
||||||
..
|
..
|
||||||
}) if self.menu.is_some() => self.destroy_menu(),
|
}) if self.menu.is_some() => self.close_menu(),
|
||||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(
|
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(
|
||||||
event,
|
event,
|
||||||
surface,
|
surface,
|
||||||
@@ -596,6 +682,7 @@ impl FereseShell {
|
|||||||
wayland::PopupEvent::Done => {
|
wayland::PopupEvent::Done => {
|
||||||
self.menu = None;
|
self.menu = None;
|
||||||
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
|
||||||
|
return Task::none();
|
||||||
}
|
}
|
||||||
wayland::PopupEvent::Focused => {
|
wayland::PopupEvent::Focused => {
|
||||||
if menu.effects.is_none() {
|
if menu.effects.is_none() {
|
||||||
@@ -612,6 +699,7 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
wayland::PopupEvent::Configured { .. } => {
|
wayland::PopupEvent::Configured { .. } => {
|
||||||
|
menu.motion.begin(Instant::now());
|
||||||
if menu.effects.is_none() {
|
if menu.effects.is_none() {
|
||||||
match EffectsBinding::attach_role(
|
match EffectsBinding::attach_role(
|
||||||
&surface,
|
&surface,
|
||||||
@@ -627,6 +715,9 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
if let Some(effects) = &menu.effects {
|
||||||
|
let _ = effects.set_opacity(menu.progress());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
@@ -706,25 +797,23 @@ impl FereseShell {
|
|||||||
let shell_theme = self.config.theme.for_bar();
|
let shell_theme = self.config.theme.for_bar();
|
||||||
let bar = BarMetrics::from(shell_theme);
|
let bar = BarMetrics::from(shell_theme);
|
||||||
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
|
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
|
||||||
.spacing(1)
|
.spacing(3)
|
||||||
.align_y(cosmic::iced::Alignment::Center);
|
.align_y(cosmic::iced::Alignment::Center);
|
||||||
|
|
||||||
workspace_row = workspace_row.push(
|
workspace_row = workspace_row.push(motion::button(
|
||||||
button::custom(overview_control(bar, color(shell_theme.text_primary)))
|
button::custom(overview_control(bar, color(shell_theme.text_primary)))
|
||||||
.height(bar.control_height)
|
.height(bar.control_height)
|
||||||
.padding([0, 7])
|
.padding([0, 7])
|
||||||
.class(status_ui::button_style(
|
|
||||||
color(shell_theme.text_primary),
|
|
||||||
self.overview_active,
|
|
||||||
1.0,
|
|
||||||
))
|
|
||||||
.on_press(cosmic::Action::App(Message::ToggleOverview)),
|
.on_press(cosmic::Action::App(Message::ToggleOverview)),
|
||||||
);
|
color(shell_theme.text_primary),
|
||||||
|
self.overview_active,
|
||||||
|
1.0,
|
||||||
|
));
|
||||||
for workspace in self
|
for workspace in self
|
||||||
.snapshot
|
.snapshot
|
||||||
.workspaces
|
.workspaces
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|workspace| self.overview_active && workspace.output == focused_output_id)
|
.filter(|workspace| workspace_on_bar(workspace.output, focused_output_id))
|
||||||
{
|
{
|
||||||
let occupied = self
|
let occupied = self
|
||||||
.snapshot
|
.snapshot
|
||||||
@@ -733,50 +822,35 @@ impl FereseShell {
|
|||||||
.any(|window| window.workspace == workspace.id);
|
.any(|window| window.workspace == workspace.id);
|
||||||
let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme);
|
let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme);
|
||||||
let workspace_button = button::custom(indicator)
|
let workspace_button = button::custom(indicator)
|
||||||
|
.name(format!(
|
||||||
|
"Workspace {}{}",
|
||||||
|
workspace.name,
|
||||||
|
if workspace.active { ", active" } else { "" }
|
||||||
|
))
|
||||||
.height(bar.control_height)
|
.height(bar.control_height)
|
||||||
.padding(0)
|
.padding(0)
|
||||||
.class(status_ui::button_style(
|
|
||||||
color(shell_theme.text_primary),
|
|
||||||
false,
|
|
||||||
1.0,
|
|
||||||
))
|
|
||||||
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
|
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
|
||||||
workspace.id,
|
workspace.id,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
workspace_row = workspace_row.push(workspace_button);
|
let selector = motion::button(
|
||||||
|
workspace_button,
|
||||||
|
color(if workspace.active {
|
||||||
|
shell_theme.accent
|
||||||
|
} else {
|
||||||
|
shell_theme.text_primary
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
// Iced tooltips are overlays inside this bar-height layer surface;
|
||||||
|
// viewport clamping puts them back over the selector. Keep the
|
||||||
|
// accessible button name, without an overlay stealing its target.
|
||||||
|
workspace_row = workspace_row.push(selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
let foreground = color(shell_theme.text_primary);
|
let foreground = color(shell_theme.text_primary);
|
||||||
let mut windows = row::with_capacity(self.snapshot.windows.len())
|
let left = cosmic::iced::widget::scrollable(workspace_row)
|
||||||
.spacing(4)
|
|
||||||
.align_y(cosmic::iced::Alignment::Center);
|
|
||||||
for window in self.snapshot.windows.iter().filter(|window| {
|
|
||||||
focused_output.is_some_and(|output| window.workspace == output.active_workspace)
|
|
||||||
}) {
|
|
||||||
let label = compact_window_label(window);
|
|
||||||
let foreground = color(if window.focused {
|
|
||||||
shell_theme.text_primary
|
|
||||||
} else {
|
|
||||||
shell_theme.text_muted
|
|
||||||
});
|
|
||||||
windows = windows.push(
|
|
||||||
button::custom(
|
|
||||||
container(
|
|
||||||
text(label)
|
|
||||||
.size(bar.text_size)
|
|
||||||
.wrapping(cosmic::iced::core::text::Wrapping::None)
|
|
||||||
.class(theme::Text::Color(foreground)),
|
|
||||||
)
|
|
||||||
.center_y(bar.control_height),
|
|
||||||
)
|
|
||||||
.padding([0, 8])
|
|
||||||
.height(bar.control_height)
|
|
||||||
.class(status_ui::button_style(foreground, window.focused, 1.0))
|
|
||||||
.on_press(cosmic::Action::App(Message::ActivateWindow(window.id))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let windows = cosmic::iced::widget::scrollable(windows)
|
|
||||||
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
||||||
cosmic::iced::widget::scrollable::Scrollbar::default()
|
cosmic::iced::widget::scrollable::Scrollbar::default()
|
||||||
.width(0)
|
.width(0)
|
||||||
@@ -784,9 +858,6 @@ impl FereseShell {
|
|||||||
))
|
))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(bar.control_height);
|
.height(bar.control_height);
|
||||||
let left = row![workspace_row, windows]
|
|
||||||
.spacing(8)
|
|
||||||
.align_y(cosmic::iced::Alignment::Center);
|
|
||||||
let clock = container(
|
let clock = container(
|
||||||
text(&self.clock)
|
text(&self.clock)
|
||||||
.size(bar.text_size)
|
.size(bar.text_size)
|
||||||
@@ -943,70 +1014,46 @@ fn workspace_indicator(
|
|||||||
bar: BarMetrics,
|
bar: BarMetrics,
|
||||||
shell_theme: ShellTheme,
|
shell_theme: ShellTheme,
|
||||||
) -> Element<'static, cosmic::Action<Message>> {
|
) -> Element<'static, cosmic::Action<Message>> {
|
||||||
let (width, height, color, radius) = if active {
|
|
||||||
(
|
|
||||||
bar.active_marker_width,
|
|
||||||
bar.active_marker_height,
|
|
||||||
color(shell_theme.accent),
|
|
||||||
2.0,
|
|
||||||
)
|
|
||||||
} else if occupied {
|
|
||||||
(
|
|
||||||
bar.dot_marker_size,
|
|
||||||
bar.dot_marker_size,
|
|
||||||
color_with_opacity(shell_theme.text_muted, 0.78),
|
|
||||||
3.0,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
bar.empty_marker_size,
|
|
||||||
bar.empty_marker_size,
|
|
||||||
color_with_opacity(shell_theme.text_muted, 0.42),
|
|
||||||
2.0,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let marker = container(text(""))
|
let marker = container(text(""))
|
||||||
.width(width)
|
.width(if active { 18.0 } else { 6.0 })
|
||||||
.height(height)
|
.height(6)
|
||||||
.class(theme::Container::custom(move |_| {
|
.class(theme::Container::custom(move |_| {
|
||||||
workspace_marker_style(color, radius)
|
workspace_selector_style(active, occupied, shell_theme)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
container(marker)
|
container(marker)
|
||||||
.width(bar.workspace_hit_width)
|
.width(28)
|
||||||
.height(bar.control_height)
|
.height(bar.control_height)
|
||||||
.align_x(alignment::Horizontal::Center)
|
.align_x(alignment::Horizontal::Center)
|
||||||
.align_y(alignment::Vertical::Center)
|
.align_y(alignment::Vertical::Center)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_marker_style(color: Color, radius: f32) -> container::Style {
|
fn workspace_on_bar(workspace_output: Option<u64>, bar_output: Option<u64>) -> bool {
|
||||||
container::Style {
|
matches!((workspace_output, bar_output), (Some(workspace), Some(bar)) if workspace == bar)
|
||||||
background: Some(Background::Color(color)),
|
|
||||||
border: Border {
|
|
||||||
radius: radius.into(),
|
|
||||||
..Border::default()
|
|
||||||
},
|
|
||||||
snap: true,
|
|
||||||
..container::Style::default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn display_app_name(window: &control::WindowSnapshot) -> String {
|
fn workspace_selector_style(
|
||||||
let identity = window
|
active: bool,
|
||||||
.app_id
|
occupied: bool,
|
||||||
.rsplit('.')
|
shell_theme: ShellTheme,
|
||||||
.find(|part| !part.is_empty())
|
) -> container::Style {
|
||||||
.unwrap_or(&window.app_id);
|
container::Style {
|
||||||
|
background: if active {
|
||||||
if identity.is_empty() {
|
Some(Background::Color(color(shell_theme.accent)))
|
||||||
window.title.clone()
|
} else if occupied {
|
||||||
} else {
|
Some(Background::Color(color_with_opacity(
|
||||||
let mut characters = identity.chars();
|
shell_theme.text_primary,
|
||||||
characters
|
0.78,
|
||||||
.next()
|
)))
|
||||||
.map(|first| first.to_uppercase().collect::<String>() + characters.as_str())
|
} else {
|
||||||
.unwrap_or_else(|| "Desktop".to_owned())
|
None
|
||||||
|
},
|
||||||
|
border: Border {
|
||||||
|
color: color_with_opacity(shell_theme.text_muted, 0.55),
|
||||||
|
width: if !active && !occupied { 1.0 } else { 0.0 },
|
||||||
|
radius: 3.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,22 +1077,6 @@ fn format_bar_time(now: &Zoned) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compact_window_label(window: &crate::control::WindowSnapshot) -> String {
|
|
||||||
let title = window.title.trim();
|
|
||||||
let label = if title.is_empty() {
|
|
||||||
display_app_name(window)
|
|
||||||
} else {
|
|
||||||
title.to_owned()
|
|
||||||
};
|
|
||||||
let mut characters = label.chars();
|
|
||||||
let prefix: String = characters.by_ref().take(24).collect();
|
|
||||||
if characters.next().is_some() {
|
|
||||||
format!("{prefix}…")
|
|
||||||
} else {
|
|
||||||
prefix
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style {
|
fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style {
|
||||||
cosmic::iced::theme::Style {
|
cosmic::iced::theme::Style {
|
||||||
background_color: Color::TRANSPARENT,
|
background_color: Color::TRANSPARENT,
|
||||||
@@ -1131,6 +1162,7 @@ struct EffectsBinding {
|
|||||||
surface: FereseSurfaceEffectsV1,
|
surface: FereseSurfaceEffectsV1,
|
||||||
_queue: EventQueue<EffectsState>,
|
_queue: EventQueue<EffectsState>,
|
||||||
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>,
|
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>,
|
||||||
|
opacity: std::cell::Cell<Option<u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EffectsBinding {
|
impl EffectsBinding {
|
||||||
@@ -1155,13 +1187,16 @@ impl EffectsBinding {
|
|||||||
let connection = Connection::from_backend(backend);
|
let connection = Connection::from_backend(backend);
|
||||||
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
||||||
let qh = queue.handle();
|
let qh = queue.handle();
|
||||||
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=2, ())?;
|
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=3, ())?;
|
||||||
let effects = manager.get_surface_effects(surface, &qh, ());
|
let effects = manager.get_surface_effects(surface, &qh, ());
|
||||||
|
|
||||||
if let Some(role) = role {
|
if let Some(role) = role {
|
||||||
effects.set_role(role);
|
effects.set_role(role);
|
||||||
} else {
|
} else {
|
||||||
effects.clear_role();
|
effects.clear_role();
|
||||||
|
if effects.version() >= 3 {
|
||||||
|
effects.set_opacity(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
connection.flush()?;
|
connection.flush()?;
|
||||||
|
|
||||||
@@ -1171,6 +1206,7 @@ impl EffectsBinding {
|
|||||||
surface: effects,
|
surface: effects,
|
||||||
_queue: queue,
|
_queue: queue,
|
||||||
regions: Default::default(),
|
regions: Default::default(),
|
||||||
|
opacity: std::cell::Cell::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,6 +1232,7 @@ impl EffectsBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
|
fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
self.set_opacity(1.0)?;
|
||||||
if visible {
|
if visible {
|
||||||
self.surface
|
self.surface
|
||||||
.set_role(ferese_surface_effects_v1::Role::Panel);
|
.set_role(ferese_surface_effects_v1::Role::Panel);
|
||||||
@@ -1206,6 +1243,15 @@ impl EffectsBinding {
|
|||||||
self.connection.flush()?;
|
self.connection.flush()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
fn set_opacity(&self, opacity: f32) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||||
|
if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) {
|
||||||
|
self.surface.set_opacity(opacity);
|
||||||
|
self.connection.flush()?;
|
||||||
|
self.opacity.set(Some(opacity));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EffectsState;
|
struct EffectsState;
|
||||||
@@ -1300,26 +1346,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn window_list_labels_use_titles_and_fall_back_to_the_app_name() {
|
fn workspace_selector_has_distinct_active_paint() {
|
||||||
let snapshot = snapshot_with_fullscreen_window(7);
|
let theme = ShellTheme::default();
|
||||||
let mut window = snapshot.windows[0].clone();
|
let active = workspace_selector_style(true, false, theme);
|
||||||
window.title = " editor.rs — Project ".to_owned();
|
let occupied = workspace_selector_style(false, true, theme);
|
||||||
assert_eq!(compact_window_label(&window), "editor.rs — Project");
|
let inactive = workspace_selector_style(false, false, theme);
|
||||||
window.title = " ".to_owned();
|
assert!(active.background.is_some());
|
||||||
assert_eq!(compact_window_label(&window), display_app_name(&window));
|
assert_eq!(
|
||||||
|
active.background,
|
||||||
|
Some(Background::Color(color(theme.accent)))
|
||||||
|
);
|
||||||
|
assert!(occupied.background.is_some());
|
||||||
|
assert!(inactive.background.is_none());
|
||||||
|
assert_eq!(inactive.border.width, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn window_list_labels_truncate_unicode_without_breaking_characters() {
|
fn workspace_selector_is_output_local() {
|
||||||
let snapshot = snapshot_with_fullscreen_window(7);
|
assert!(workspace_on_bar(Some(1), Some(1)));
|
||||||
let mut window = snapshot.windows[0].clone();
|
assert!(!workspace_on_bar(Some(2), Some(1)));
|
||||||
window.title = "界".repeat(25);
|
assert!(!workspace_on_bar(None, Some(1)));
|
||||||
assert_eq!(
|
assert!(!workspace_on_bar(None, None));
|
||||||
compact_window_label(&window),
|
|
||||||
format!("{}…", "界".repeat(24))
|
|
||||||
);
|
|
||||||
window.title = "界".repeat(24);
|
|
||||||
assert_eq!(compact_window_label(&window), window.title);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -6,6 +6,322 @@ use cosmic::{Element, Theme};
|
|||||||
|
|
||||||
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[i32; 5]>>>;
|
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[i32; 5]>>>;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) struct Settings {
|
||||||
|
enabled: bool,
|
||||||
|
reduced_motion: bool,
|
||||||
|
speed: f64,
|
||||||
|
}
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
reduced_motion: false,
|
||||||
|
speed: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Settings {
|
||||||
|
fn hover_duration(self) -> std::time::Duration {
|
||||||
|
self.duration(120.0)
|
||||||
|
}
|
||||||
|
pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration {
|
||||||
|
if !self.enabled || self.reduced_motion {
|
||||||
|
return std::time::Duration::ZERO;
|
||||||
|
}
|
||||||
|
let speed = if self.speed.is_finite() && self.speed > 0.0 {
|
||||||
|
self.speed.clamp(0.01, 100.0)
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
std::time::Duration::from_secs_f64(milliseconds / 1000.0 / speed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new();
|
||||||
|
pub(crate) fn configure(settings: Settings) {
|
||||||
|
*SETTINGS
|
||||||
|
.get_or_init(|| std::sync::RwLock::new(Settings::default()))
|
||||||
|
.write()
|
||||||
|
.unwrap() = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PopupMotion {
|
||||||
|
start: f32,
|
||||||
|
target: f32,
|
||||||
|
started: Option<std::time::Instant>,
|
||||||
|
settings: Settings,
|
||||||
|
}
|
||||||
|
impl PopupMotion {
|
||||||
|
pub(crate) fn update_settings(&mut self, settings: Settings) {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
self.start = self.progress_at(now);
|
||||||
|
self.settings = settings;
|
||||||
|
self.started = Some(now);
|
||||||
|
}
|
||||||
|
pub(crate) fn new(settings: Settings) -> Self {
|
||||||
|
Self {
|
||||||
|
start: 0.0,
|
||||||
|
target: 1.0,
|
||||||
|
started: None,
|
||||||
|
settings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn begin(&mut self, now: std::time::Instant) {
|
||||||
|
if self.started.is_none() {
|
||||||
|
self.started = Some(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
|
||||||
|
let duration = self
|
||||||
|
.settings
|
||||||
|
.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
|
||||||
|
if duration.is_zero() {
|
||||||
|
return self.target;
|
||||||
|
}
|
||||||
|
let Some(started) = self.started else {
|
||||||
|
return self.start;
|
||||||
|
};
|
||||||
|
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32())
|
||||||
|
.min(1.0);
|
||||||
|
if t == 1.0 {
|
||||||
|
return self.target;
|
||||||
|
}
|
||||||
|
self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t)
|
||||||
|
}
|
||||||
|
pub(crate) fn progress(&self) -> f32 {
|
||||||
|
self.progress_at(std::time::Instant::now())
|
||||||
|
}
|
||||||
|
pub(crate) fn closing(&self) -> bool {
|
||||||
|
self.target == 0.0
|
||||||
|
}
|
||||||
|
pub(crate) fn animating(&self) -> bool {
|
||||||
|
self.progress() != self.target
|
||||||
|
}
|
||||||
|
pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) {
|
||||||
|
self.start = self.progress_at(now);
|
||||||
|
self.target = target;
|
||||||
|
self.started = Some(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animate paint only; the original button retains its layout, hit target,
|
||||||
|
/// keyboard handling, pressed state, and accessibility identity.
|
||||||
|
pub(crate) fn button<'a, M: Clone + 'a>(
|
||||||
|
button: cosmic::widget::button::Button<'a, M>,
|
||||||
|
foreground: cosmic::iced::Color,
|
||||||
|
selected: bool,
|
||||||
|
opacity: f32,
|
||||||
|
) -> Element<'a, M> {
|
||||||
|
let progress = std::rc::Rc::new(std::cell::Cell::new(0.0));
|
||||||
|
let active = progress.clone();
|
||||||
|
let hovered = progress.clone();
|
||||||
|
let paint = move |progress: f32, pressed: bool| cosmic::widget::button::Style {
|
||||||
|
text_color: Some(foreground),
|
||||||
|
icon_color: Some(foreground),
|
||||||
|
border_radius: 6.0.into(),
|
||||||
|
background: Some(cosmic::iced::Background::Color(cosmic::iced::Color {
|
||||||
|
a: if pressed {
|
||||||
|
0.20 * opacity
|
||||||
|
} else {
|
||||||
|
((if selected { 0.14 } else { 0.0 })
|
||||||
|
+ progress * if selected { 0.02 } else { 0.08 })
|
||||||
|
* opacity
|
||||||
|
},
|
||||||
|
..foreground
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let content = button
|
||||||
|
.class(cosmic::theme::Button::Custom {
|
||||||
|
active: Box::new(move |_, _| paint(active.get(), false)),
|
||||||
|
hovered: Box::new(move |_, _| paint(hovered.get(), false)),
|
||||||
|
pressed: Box::new(move |_, _| paint(1.0, true)),
|
||||||
|
disabled: Box::new(move |_| paint(0.0, false)),
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
Element::new(Hover {
|
||||||
|
content,
|
||||||
|
progress,
|
||||||
|
duration: SETTINGS
|
||||||
|
.get()
|
||||||
|
.map_or_else(Settings::default, |settings| *settings.read().unwrap())
|
||||||
|
.hover_duration(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct HoverState {
|
||||||
|
current: f32,
|
||||||
|
start: f32,
|
||||||
|
target: f32,
|
||||||
|
started: Option<std::time::Instant>,
|
||||||
|
}
|
||||||
|
impl HoverState {
|
||||||
|
fn advance(
|
||||||
|
&mut self,
|
||||||
|
target: f32,
|
||||||
|
now: std::time::Instant,
|
||||||
|
duration: std::time::Duration,
|
||||||
|
) -> bool {
|
||||||
|
if self.target != target {
|
||||||
|
self.start = self.current;
|
||||||
|
self.target = target;
|
||||||
|
self.started = Some(now);
|
||||||
|
}
|
||||||
|
let t = if duration.is_zero() {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
self.started.map_or(1.0, |start| {
|
||||||
|
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32())
|
||||||
|
.min(1.0)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
self.current = self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t);
|
||||||
|
if t == 1.0 {
|
||||||
|
self.current = target;
|
||||||
|
self.started = None;
|
||||||
|
}
|
||||||
|
self.current != target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struct Hover<'a, M> {
|
||||||
|
content: Element<'a, M>,
|
||||||
|
progress: std::rc::Rc<std::cell::Cell<f32>>,
|
||||||
|
duration: std::time::Duration,
|
||||||
|
}
|
||||||
|
impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||||
|
fn a11y_nodes(
|
||||||
|
&self,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
tree: &widget::Tree,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
) -> iced_accessibility::A11yTree {
|
||||||
|
self.content
|
||||||
|
.as_widget()
|
||||||
|
.a11y_nodes(layout, &tree.children[0], cursor)
|
||||||
|
}
|
||||||
|
fn tag(&self) -> widget::tree::Tag {
|
||||||
|
widget::tree::Tag::of::<HoverState>()
|
||||||
|
}
|
||||||
|
fn state(&self) -> widget::tree::State {
|
||||||
|
widget::tree::State::new(HoverState::default())
|
||||||
|
}
|
||||||
|
fn children(&self) -> Vec<widget::Tree> {
|
||||||
|
vec![widget::Tree::new(&self.content)]
|
||||||
|
}
|
||||||
|
fn diff(&mut self, tree: &mut widget::Tree) {
|
||||||
|
tree.diff_children(std::slice::from_mut(&mut self.content));
|
||||||
|
}
|
||||||
|
fn size(&self) -> Size<Length> {
|
||||||
|
self.content.as_widget().size()
|
||||||
|
}
|
||||||
|
fn layout(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut widget::Tree,
|
||||||
|
renderer: &cosmic::Renderer,
|
||||||
|
limits: &layout::Limits,
|
||||||
|
) -> layout::Node {
|
||||||
|
self.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.layout(&mut tree.children[0], renderer, limits)
|
||||||
|
}
|
||||||
|
fn update(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut widget::Tree,
|
||||||
|
event: &Event,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
renderer: &cosmic::Renderer,
|
||||||
|
clipboard: &mut dyn Clipboard,
|
||||||
|
shell: &mut Shell<'_, M>,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let hovered = cursor.is_over(layout.bounds()) && cursor.is_over(*viewport);
|
||||||
|
let state = tree.state.downcast_mut::<HoverState>();
|
||||||
|
if state.advance(if hovered { 1.0 } else { 0.0 }, now, self.duration) {
|
||||||
|
shell.request_redraw_at(now + std::time::Duration::from_millis(16));
|
||||||
|
}
|
||||||
|
self.progress.set(state.current);
|
||||||
|
self.content.as_widget_mut().update(
|
||||||
|
&mut tree.children[0],
|
||||||
|
event,
|
||||||
|
layout,
|
||||||
|
cursor,
|
||||||
|
renderer,
|
||||||
|
clipboard,
|
||||||
|
shell,
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
tree: &widget::Tree,
|
||||||
|
renderer: &mut cosmic::Renderer,
|
||||||
|
theme: &Theme,
|
||||||
|
style: &renderer::Style,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
self.progress
|
||||||
|
.set(tree.state.downcast_ref::<HoverState>().current);
|
||||||
|
self.content.as_widget().draw(
|
||||||
|
&tree.children[0],
|
||||||
|
renderer,
|
||||||
|
theme,
|
||||||
|
style,
|
||||||
|
layout,
|
||||||
|
cursor,
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fn mouse_interaction(
|
||||||
|
&self,
|
||||||
|
tree: &widget::Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
renderer: &cosmic::Renderer,
|
||||||
|
) -> mouse::Interaction {
|
||||||
|
self.content.as_widget().mouse_interaction(
|
||||||
|
&tree.children[0],
|
||||||
|
layout,
|
||||||
|
cursor,
|
||||||
|
viewport,
|
||||||
|
renderer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn operate(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut widget::Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
renderer: &cosmic::Renderer,
|
||||||
|
operation: &mut dyn widget::Operation,
|
||||||
|
) {
|
||||||
|
self.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||||
|
}
|
||||||
|
fn overlay<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
tree: &'a mut widget::Tree,
|
||||||
|
layout: Layout<'a>,
|
||||||
|
renderer: &cosmic::Renderer,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
translation: Vector,
|
||||||
|
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
|
||||||
|
self.content.as_widget_mut().overlay(
|
||||||
|
&mut tree.children[0],
|
||||||
|
layout,
|
||||||
|
renderer,
|
||||||
|
viewport,
|
||||||
|
translation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn animated<'a, M: 'a>(
|
pub fn animated<'a, M: 'a>(
|
||||||
content: Element<'a, M>,
|
content: Element<'a, M>,
|
||||||
progress: f32,
|
progress: f32,
|
||||||
@@ -181,6 +497,127 @@ impl widget::Operation for CollectRegions {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
|
fn popup_waits_for_configuration_then_opens_and_closes_faster() {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let mut motion = PopupMotion::new(Settings::default());
|
||||||
|
assert_eq!(
|
||||||
|
motion.progress_at(now + std::time::Duration::from_secs(30)),
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
motion.begin(now);
|
||||||
|
// Later configure/focus notifications must not restart the clock.
|
||||||
|
motion.begin(now + std::time::Duration::from_millis(90));
|
||||||
|
assert!(
|
||||||
|
(motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
motion.progress_at(now + std::time::Duration::from_millis(200)),
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
motion.retarget(0.0, now + std::time::Duration::from_millis(200));
|
||||||
|
assert!(motion.closing());
|
||||||
|
assert!(
|
||||||
|
(motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
motion.progress_at(now + std::time::Duration::from_millis(340)),
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn popup_reversal_and_motion_policy_are_consistent() {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let mut motion = PopupMotion::new(Settings {
|
||||||
|
speed: 0.75,
|
||||||
|
..Settings::default()
|
||||||
|
});
|
||||||
|
motion.begin(now);
|
||||||
|
let reverse = now + std::time::Duration::from_millis(120);
|
||||||
|
let before = motion.progress_at(reverse);
|
||||||
|
motion.retarget(0.0, reverse);
|
||||||
|
assert_eq!(motion.progress_at(reverse), before);
|
||||||
|
let reopen = reverse + std::time::Duration::from_millis(60);
|
||||||
|
let before = motion.progress_at(reopen);
|
||||||
|
motion.retarget(1.0, reopen);
|
||||||
|
assert_eq!(motion.progress_at(reopen), before);
|
||||||
|
assert_eq!(
|
||||||
|
motion.progress_at(reopen + std::time::Duration::from_secs(1)),
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
for settings in [
|
||||||
|
Settings {
|
||||||
|
reduced_motion: true,
|
||||||
|
..Settings::default()
|
||||||
|
},
|
||||||
|
Settings {
|
||||||
|
enabled: false,
|
||||||
|
..Settings::default()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let mut motion = PopupMotion::new(settings);
|
||||||
|
assert_eq!(motion.progress_at(now), 1.0);
|
||||||
|
motion.retarget(0.0, now);
|
||||||
|
assert_eq!(motion.progress_at(now), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn hover_fades_settle_and_idle_does_not_schedule_frames() {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let duration = std::time::Duration::from_millis(120);
|
||||||
|
let mut hover = HoverState::default();
|
||||||
|
assert!(!hover.advance(0.0, now, duration));
|
||||||
|
assert!(hover.advance(1.0, now + std::time::Duration::from_secs(10), duration));
|
||||||
|
assert_eq!(hover.current, 0.0);
|
||||||
|
let start = now + std::time::Duration::from_secs(10);
|
||||||
|
assert!(hover.advance(1.0, start + duration / 2, duration));
|
||||||
|
assert!((hover.current - 0.5).abs() < 0.00001);
|
||||||
|
assert!(!hover.advance(1.0, start + duration, duration));
|
||||||
|
assert_eq!(hover.current, 1.0);
|
||||||
|
assert!(!hover.advance(1.0, start + duration * 10, duration));
|
||||||
|
assert!(hover.advance(0.0, start + duration * 11, duration));
|
||||||
|
assert!(!hover.advance(0.0, start + duration * 12, duration));
|
||||||
|
assert_eq!(hover.current, 0.0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn hover_reversals_preserve_current_paint() {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let duration = std::time::Duration::from_millis(120);
|
||||||
|
let mut hover = HoverState::default();
|
||||||
|
hover.advance(1.0, now, duration);
|
||||||
|
hover.advance(1.0, now + duration / 2, duration);
|
||||||
|
let before = hover.current;
|
||||||
|
assert!(hover.advance(0.0, now + duration / 2, duration));
|
||||||
|
assert_eq!(hover.current, before);
|
||||||
|
hover.advance(0.0, now + duration, duration);
|
||||||
|
assert!(hover.current > 0.0 && hover.current < before);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn hover_obeys_global_motion_policy() {
|
||||||
|
assert_eq!(
|
||||||
|
Settings {
|
||||||
|
speed: 0.75,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.hover_duration(),
|
||||||
|
std::time::Duration::from_millis(160)
|
||||||
|
);
|
||||||
|
for settings in [
|
||||||
|
Settings {
|
||||||
|
enabled: false,
|
||||||
|
..Settings::default()
|
||||||
|
},
|
||||||
|
Settings {
|
||||||
|
reduced_motion: true,
|
||||||
|
..Settings::default()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(settings.hover_duration().is_zero());
|
||||||
|
let mut hover = HoverState::default();
|
||||||
|
assert!(!hover.advance(1.0, std::time::Instant::now(), settings.hover_duration()));
|
||||||
|
assert_eq!(hover.current, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn material_regions_include_cards_but_not_the_parent_or_gaps() {
|
fn material_regions_include_cards_but_not_the_parent_or_gaps() {
|
||||||
let mut collector = CollectRegions(Vec::new());
|
let mut collector = CollectRegions(Vec::new());
|
||||||
let id = widget::Id::new("ferese-blur-card");
|
let id = widget::Id::new("ferese-blur-card");
|
||||||
|
|||||||
@@ -76,12 +76,15 @@ pub struct Update {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
pub struct Service {
|
pub struct Service {
|
||||||
|
settings: Arc<Mutex<Option<Vec<String>>>>,
|
||||||
tx: SyncSender<(u64, Action)>,
|
tx: SyncSender<(u64, Action)>,
|
||||||
rx: Receiver<Update>,
|
rx: Receiver<Update>,
|
||||||
pub generation: u64,
|
pub generation: u64,
|
||||||
}
|
}
|
||||||
impl Service {
|
impl Service {
|
||||||
pub fn start(settings: Option<Vec<String>>) -> Self {
|
pub fn start(settings: Option<Vec<String>>) -> Self {
|
||||||
|
let live_settings = Arc::new(Mutex::new(settings));
|
||||||
|
let settings = live_settings.clone();
|
||||||
let (tx, commands) = mpsc::sync_channel::<(u64, Action)>(64);
|
let (tx, commands) = mpsc::sync_channel::<(u64, Action)>(64);
|
||||||
let (updates, rx) = mpsc::sync_channel(1);
|
let (updates, rx) = mpsc::sync_channel(1);
|
||||||
// Polling and writes have separate workers: a missing D-Bus service must
|
// Polling and writes have separate workers: a missing D-Bus service must
|
||||||
@@ -132,6 +135,7 @@ impl Service {
|
|||||||
state.0 = id;
|
state.0 = id;
|
||||||
state.1 = true;
|
state.1 = true;
|
||||||
}
|
}
|
||||||
|
let settings = settings.lock().unwrap().clone();
|
||||||
let error = execute(&action, settings.as_deref()).err();
|
let error = execute(&action, settings.as_deref()).err();
|
||||||
let mut state = shared.0.lock().unwrap();
|
let mut state = shared.0.lock().unwrap();
|
||||||
state.1 = false;
|
state.1 = false;
|
||||||
@@ -143,6 +147,7 @@ impl Service {
|
|||||||
shared.1.notify_one();
|
shared.1.notify_one();
|
||||||
});
|
});
|
||||||
Self {
|
Self {
|
||||||
|
settings: live_settings,
|
||||||
tx,
|
tx,
|
||||||
rx,
|
rx,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
@@ -156,6 +161,9 @@ impl Service {
|
|||||||
self.generation = generation;
|
self.generation = generation;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
pub fn update_settings(&self, settings: Option<Vec<String>>) {
|
||||||
|
*self.settings.lock().unwrap() = settings;
|
||||||
|
}
|
||||||
pub fn poll(&self) -> Option<Update> {
|
pub fn poll(&self) -> Option<Update> {
|
||||||
self.rx.try_iter().last()
|
self.rx.try_iter().last()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use cosmic::widget::{column, slider};
|
|||||||
use status::{Action, Snapshot};
|
use status::{Action, Snapshot};
|
||||||
|
|
||||||
const DEVICE_LIST_HEIGHT: f32 = 180.0;
|
const DEVICE_LIST_HEIGHT: f32 = 180.0;
|
||||||
const OPEN_MS: f32 = 160.0;
|
|
||||||
|
|
||||||
fn device_list_height(count: usize) -> Option<f32> {
|
fn device_list_height(count: usize) -> Option<f32> {
|
||||||
(count > 4).then_some(DEVICE_LIST_HEIGHT)
|
(count > 4).then_some(DEVICE_LIST_HEIGHT)
|
||||||
@@ -48,30 +47,30 @@ impl Menu {
|
|||||||
pub struct OpenMenu {
|
pub struct OpenMenu {
|
||||||
pub id: window::Id,
|
pub id: window::Id,
|
||||||
pub kind: Menu,
|
pub kind: Menu,
|
||||||
opened: Instant,
|
pub motion: super::motion::PopupMotion,
|
||||||
pub confirm: Option<Action>,
|
pub confirm: Option<Action>,
|
||||||
pub effects: Option<EffectsBinding>,
|
pub effects: Option<EffectsBinding>,
|
||||||
pub regions: super::motion::Regions,
|
pub regions: super::motion::Regions,
|
||||||
}
|
}
|
||||||
impl OpenMenu {
|
impl OpenMenu {
|
||||||
pub fn progress(&self) -> f32 {
|
pub fn progress(&self) -> f32 {
|
||||||
let ease = |t: f32| {
|
self.motion.progress()
|
||||||
let t = t.clamp(0.0, 1.0);
|
|
||||||
1.0 - (1.0 - t).powi(3)
|
|
||||||
};
|
|
||||||
ease(self.opened.elapsed().as_secs_f32() * 1000.0 / OPEN_MS)
|
|
||||||
}
|
}
|
||||||
pub fn animating(&self) -> bool {
|
pub fn animating(&self) -> bool {
|
||||||
self.progress() < 1.0
|
self.motion.animating()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FereseShell {
|
impl FereseShell {
|
||||||
pub fn open_menu(&mut self, kind: Menu, anchor: Rectangle<i32>) -> Task<Message> {
|
pub fn open_menu(&mut self, kind: Menu, anchor: Rectangle<i32>) -> Task<Message> {
|
||||||
if self.menu.as_ref().is_some_and(|menu| menu.kind == kind) {
|
if self.menu.as_ref().is_some_and(|menu| menu.kind == kind) {
|
||||||
// Close atomically. Per-widget alpha cannot fade SVG/text caches
|
if let Some(menu) = &mut self.menu {
|
||||||
// as one surface and leaves bright fragments during the fade.
|
if menu.motion.closing() {
|
||||||
return self.destroy_menu();
|
menu.motion.retarget(1.0, Instant::now());
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.close_menu();
|
||||||
}
|
}
|
||||||
let destroy = self.destroy_menu();
|
let destroy = self.destroy_menu();
|
||||||
if !kind.available(&self.status) {
|
if !kind.available(&self.status) {
|
||||||
@@ -81,7 +80,7 @@ impl FereseShell {
|
|||||||
self.menu = Some(OpenMenu {
|
self.menu = Some(OpenMenu {
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
opened: Instant::now(),
|
motion: super::motion::PopupMotion::new(self.config.animations),
|
||||||
confirm: None,
|
confirm: None,
|
||||||
effects: None,
|
effects: None,
|
||||||
regions: Default::default(),
|
regions: Default::default(),
|
||||||
@@ -124,6 +123,36 @@ impl FereseShell {
|
|||||||
))
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
pub fn close_menu(&mut self) -> Task<Message> {
|
||||||
|
let Some(menu) = &mut self.menu else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
if menu.motion.closing() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if menu
|
||||||
|
.effects
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|effects| effects.surface.version() >= 3)
|
||||||
|
{
|
||||||
|
menu.motion.retarget(0.0, Instant::now());
|
||||||
|
if menu.animating() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.destroy_menu()
|
||||||
|
}
|
||||||
|
pub fn animate_menu(&mut self) -> Task<Message> {
|
||||||
|
if let Some(menu) = &self.menu {
|
||||||
|
if let Some(effects) = &menu.effects {
|
||||||
|
let _ = effects.set_opacity(menu.progress());
|
||||||
|
}
|
||||||
|
if menu.motion.closing() && !menu.animating() {
|
||||||
|
return self.destroy_menu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
pub fn optimistic_status(&mut self, action: &Action) {
|
pub fn optimistic_status(&mut self, action: &Action) {
|
||||||
match action {
|
match action {
|
||||||
Action::Volume(v) => {
|
Action::Volume(v) => {
|
||||||
@@ -221,7 +250,6 @@ impl FereseShell {
|
|||||||
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
|
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
|
||||||
])
|
])
|
||||||
.height(metrics.height)
|
.height(metrics.height)
|
||||||
.class(button_style(foreground, selected, 1.0))
|
|
||||||
.on_press_with_rectangle(move |offset, bounds| {
|
.on_press_with_rectangle(move |offset, bounds| {
|
||||||
cosmic::Action::App(Message::OpenMenu(
|
cosmic::Action::App(Message::OpenMenu(
|
||||||
kind,
|
kind,
|
||||||
@@ -233,7 +261,7 @@ impl FereseShell {
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
});
|
});
|
||||||
controls = controls.push(control);
|
controls = controls.push(motion::button(control, foreground, selected, 1.0));
|
||||||
}
|
}
|
||||||
controls.into()
|
controls.into()
|
||||||
}
|
}
|
||||||
@@ -243,7 +271,8 @@ impl FereseShell {
|
|||||||
return text("").into();
|
return text("").into();
|
||||||
};
|
};
|
||||||
let theme = self.config.theme;
|
let theme = self.config.theme;
|
||||||
let p = menu.progress();
|
// The compositor fades the complete surface, not individual caches.
|
||||||
|
let p = 1.0;
|
||||||
let kind = menu.kind;
|
let kind = menu.kind;
|
||||||
let primary = color_with_opacity(theme.text_primary, p);
|
let primary = color_with_opacity(theme.text_primary, p);
|
||||||
let muted = color_with_opacity(theme.text_muted, p);
|
let muted = color_with_opacity(theme.text_muted, p);
|
||||||
@@ -615,7 +644,7 @@ impl FereseShell {
|
|||||||
}));
|
}));
|
||||||
// Keep the outer surface transparent; only inner cards paint a background.
|
// Keep the outer surface transparent; only inner cards paint a background.
|
||||||
cosmic::widget::autosize::autosize(
|
cosmic::widget::autosize::autosize(
|
||||||
super::motion::animated(panel.into(), p, menu.regions.clone()),
|
super::motion::animated(panel.into(), menu.progress(), menu.regions.clone()),
|
||||||
cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
|
cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
|
||||||
)
|
)
|
||||||
.limits(
|
.limits(
|
||||||
@@ -661,12 +690,15 @@ fn status_summary<'a>(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}));
|
}));
|
||||||
let badge: Element<'_, cosmic::Action<Message>> = if let Some((_, action)) = toggle {
|
let badge: Element<'_, cosmic::Action<Message>> = if let Some((_, action)) = toggle {
|
||||||
button::custom(badge)
|
motion::button(
|
||||||
.padding(0)
|
button::custom(badge)
|
||||||
.name(if enabled { "Turn off" } else { "Turn on" })
|
.padding(0)
|
||||||
.class(button_style(badge_color, false, opacity))
|
.name(if enabled { "Turn off" } else { "Turn on" })
|
||||||
.on_press(cosmic::Action::App(Message::Control(action)))
|
.on_press(cosmic::Action::App(Message::Control(action))),
|
||||||
.into()
|
badge_color,
|
||||||
|
false,
|
||||||
|
opacity,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
badge.into()
|
badge.into()
|
||||||
};
|
};
|
||||||
@@ -800,13 +832,15 @@ fn connection_control<'a>(
|
|||||||
.padding(4)
|
.padding(4)
|
||||||
.into();
|
.into();
|
||||||
};
|
};
|
||||||
container(
|
container(motion::button(
|
||||||
button::custom(content)
|
button::custom(content)
|
||||||
.width(Length::FillPortion(1))
|
.width(Length::FillPortion(1))
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.class(button_style(foreground, false, opacity))
|
|
||||||
.on_press(cosmic::Action::App(Message::Control(action))),
|
.on_press(cosmic::Action::App(Message::Control(action))),
|
||||||
)
|
foreground,
|
||||||
|
false,
|
||||||
|
opacity,
|
||||||
|
))
|
||||||
.id("ferese-blur-card")
|
.id("ferese-blur-card")
|
||||||
.width(Length::FillPortion(1))
|
.width(Length::FillPortion(1))
|
||||||
.into()
|
.into()
|
||||||
@@ -818,11 +852,14 @@ fn menu_button<'a>(
|
|||||||
foreground: Color,
|
foreground: Color,
|
||||||
opacity: f32,
|
opacity: f32,
|
||||||
) -> Element<'a, cosmic::Action<Message>> {
|
) -> Element<'a, cosmic::Action<Message>> {
|
||||||
button::custom(text(label).size(13))
|
motion::button(
|
||||||
.padding([6, 8])
|
button::custom(text(label).size(13))
|
||||||
.class(button_style(foreground, false, opacity))
|
.padding([6, 8])
|
||||||
.on_press(cosmic::Action::App(message))
|
.on_press(cosmic::Action::App(message)),
|
||||||
.into()
|
foreground,
|
||||||
|
false,
|
||||||
|
opacity,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
fn toggle_row<'a>(
|
fn toggle_row<'a>(
|
||||||
label: &'a str,
|
label: &'a str,
|
||||||
@@ -843,24 +880,6 @@ fn toggle_row<'a>(
|
|||||||
.align_y(Alignment::Center)
|
.align_y(Alignment::Center)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
pub(super) fn button_style(foreground: Color, selected: bool, opacity: f32) -> theme::Button {
|
|
||||||
let style = move |strength: f32| button::Style {
|
|
||||||
text_color: Some(foreground),
|
|
||||||
icon_color: Some(foreground),
|
|
||||||
border_radius: 6.0.into(),
|
|
||||||
background: Some(Background::Color(Color {
|
|
||||||
a: strength * opacity,
|
|
||||||
..foreground
|
|
||||||
})),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
theme::Button::Custom {
|
|
||||||
active: Box::new(move |_, _| style(if selected { 0.14 } else { 0.0 })),
|
|
||||||
hovered: Box::new(move |_, _| style(if selected { 0.16 } else { 0.08 })),
|
|
||||||
pressed: Box::new(move |_, _| style(0.20)),
|
|
||||||
disabled: Box::new(move |_| style(0.0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn slider_row(
|
fn slider_row(
|
||||||
source: &'static [u8],
|
source: &'static [u8],
|
||||||
@@ -1071,10 +1090,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn completed_open_stops_requesting_animation_ticks() {
|
fn completed_open_stops_requesting_animation_ticks() {
|
||||||
let past = Instant::now() - Duration::from_secs(1);
|
let past = Instant::now() - Duration::from_secs(1);
|
||||||
|
let mut motion = super::motion::PopupMotion::new(Default::default());
|
||||||
|
motion.begin(past);
|
||||||
let menu = OpenMenu {
|
let menu = OpenMenu {
|
||||||
id: window::Id::unique(),
|
id: window::Id::unique(),
|
||||||
kind: Menu::Network,
|
kind: Menu::Network,
|
||||||
opened: past,
|
motion,
|
||||||
confirm: None,
|
confirm: None,
|
||||||
effects: None,
|
effects: None,
|
||||||
regions: Default::default(),
|
regions: Default::default(),
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "ferese-lock-probe"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wayland-client.workspace = true
|
||||||
|
wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//! Deliberately has no password authentication. Nested test sessions only.
|
||||||
|
use std::{error::Error, time::Duration};
|
||||||
|
use wayland_client::{Connection, Dispatch, QueueHandle, protocol::wl_registry};
|
||||||
|
use wayland_protocols::ext::session_lock::v1::client::{
|
||||||
|
ext_session_lock_manager_v1::ExtSessionLockManagerV1,
|
||||||
|
ext_session_lock_v1::{self, ExtSessionLockV1},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Probe {
|
||||||
|
manager: Option<ExtSessionLockManagerV1>,
|
||||||
|
locked: bool,
|
||||||
|
rejected: bool,
|
||||||
|
}
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, ()> for Probe {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
if let wl_registry::Event::Global {
|
||||||
|
name, interface, ..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
if interface == "ext_session_lock_manager_v1" {
|
||||||
|
state.manager = Some(registry.bind(name, 1, qh, ()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wayland_client::delegate_noop!(Probe: ignore ExtSessionLockManagerV1);
|
||||||
|
impl Dispatch<ExtSessionLockV1, bool> for Probe {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &ExtSessionLockV1,
|
||||||
|
event: ext_session_lock_v1::Event,
|
||||||
|
competing: &bool,
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
ext_session_lock_v1::Event::Locked if !competing => state.locked = true,
|
||||||
|
ext_session_lock_v1::Event::Finished if *competing => state.rejected = true,
|
||||||
|
ext_session_lock_v1::Event::Finished => panic!("lock rejected"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
if std::env::var_os("FERESE_LOCK_TEST").is_none() {
|
||||||
|
return Err(
|
||||||
|
"set FERESE_LOCK_TEST=1; this probe is only for disposable nested sessions".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mode = std::env::args().nth(1).unwrap_or_else(|| "unlock".into());
|
||||||
|
let connection = Connection::connect_to_env()?;
|
||||||
|
let mut queue = connection.new_event_queue();
|
||||||
|
let qh = queue.handle();
|
||||||
|
connection.display().get_registry(&qh, ());
|
||||||
|
let mut state = Probe::default();
|
||||||
|
queue.roundtrip(&mut state)?;
|
||||||
|
let manager = state
|
||||||
|
.manager
|
||||||
|
.clone()
|
||||||
|
.ok_or("session lock protocol unavailable")?;
|
||||||
|
let lock = manager.lock(&qh, false);
|
||||||
|
if mode == "invalid-unlock" {
|
||||||
|
lock.unlock_and_destroy();
|
||||||
|
assert!(
|
||||||
|
queue.roundtrip(&mut state).is_err(),
|
||||||
|
"premature unlock accepted"
|
||||||
|
);
|
||||||
|
println!("PASS premature unlock disconnected");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
while !state.locked {
|
||||||
|
queue.blocking_dispatch(&mut state)?;
|
||||||
|
}
|
||||||
|
println!("PASS locked event received after safe frame");
|
||||||
|
match mode.as_str() {
|
||||||
|
"crash" => return Ok(()),
|
||||||
|
"hold" => std::thread::sleep(Duration::from_secs(10)),
|
||||||
|
"competing" | "attack" => {
|
||||||
|
let other = manager.lock(&qh, true);
|
||||||
|
queue.roundtrip(&mut state)?;
|
||||||
|
assert!(state.rejected, "competing lock not rejected");
|
||||||
|
if mode == "attack" {
|
||||||
|
other.unlock_and_destroy();
|
||||||
|
assert!(
|
||||||
|
queue.roundtrip(&mut state).is_err(),
|
||||||
|
"rejected lock unlocked owner"
|
||||||
|
);
|
||||||
|
println!("PASS rejected owner cannot unlock");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
other.destroy();
|
||||||
|
}
|
||||||
|
"unlock" => {}
|
||||||
|
_ => return Err("use unlock, hold, crash, competing, attack, or invalid-unlock".into()),
|
||||||
|
}
|
||||||
|
lock.unlock_and_destroy();
|
||||||
|
queue.roundtrip(&mut state)?;
|
||||||
|
println!("PASS owner unlocked");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in new issue
Block a user