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
@@ -15,6 +15,7 @@ ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||
ferese-layout = { path = "../crates/ferese-layout" }
|
||||
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
|
||||
libc.workspace = true
|
||||
notify = "8.2.0"
|
||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
smithay.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -134,6 +134,7 @@ struct DirectOutput {
|
||||
damage_tracker: OutputDamageTracker,
|
||||
render_metrics: RenderMetrics,
|
||||
frame_pending: bool,
|
||||
lock_frame_pending: bool,
|
||||
}
|
||||
|
||||
struct OutputSelection {
|
||||
@@ -369,13 +370,17 @@ fn open_primary_device(
|
||||
.insert_source(notifier, move |event, metadata, state| match event {
|
||||
DrmEvent::VBlank(crtc) => {
|
||||
if let Some(metadata) = metadata {
|
||||
let mut retired = false;
|
||||
let feedback = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.and_then(|backend| backend.devices.get_mut(&node))
|
||||
.and_then(|device| device.outputs.get_mut(&crtc))
|
||||
.and_then(|output| match output.surface.frame_submitted() {
|
||||
Ok(feedback) => feedback,
|
||||
Ok(feedback) => {
|
||||
retired = true;
|
||||
feedback
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame");
|
||||
None
|
||||
@@ -397,6 +402,21 @@ fn open_primary_device(
|
||||
.and_then(|backend| backend.devices.get(&node))
|
||||
.and_then(|device| device.outputs.get(&crtc))
|
||||
.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
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
@@ -632,6 +652,7 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
|
||||
effects,
|
||||
);
|
||||
output.frame_pending = true;
|
||||
output.lock_frame_pending = state.session_lock.active;
|
||||
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) {
|
||||
if state.session_lock.active {
|
||||
state.lock_frame_callbacks(output);
|
||||
return;
|
||||
}
|
||||
state.space.elements().for_each(|window| {
|
||||
window.send_frame(
|
||||
output,
|
||||
@@ -1099,6 +1124,7 @@ fn create_direct_output(
|
||||
damage_tracker,
|
||||
render_metrics,
|
||||
frame_pending: false,
|
||||
lock_frame_pending: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+160
-1
@@ -16,6 +16,8 @@ use crate::window_rules::{self, WindowRule, WindowRuleConfig};
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub(crate) autostart: Vec<DaemonConfig>,
|
||||
#[serde(default)]
|
||||
animations: AnimationsConfig,
|
||||
#[serde(default)]
|
||||
@@ -36,6 +38,26 @@ pub struct Config {
|
||||
scrolling: ScrollingConfig,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -105,12 +127,62 @@ struct OutputConfig {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
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)]
|
||||
pub struct ThemeSettings {
|
||||
pub border_width: f64,
|
||||
pub focus_ring_width: f64,
|
||||
pub border_color: RgbaColor,
|
||||
pub accent_color: RgbaColor,
|
||||
pub border_gradient: Option<BorderGradient>,
|
||||
pub focus_ring_gradient: Option<BorderGradient>,
|
||||
pub shadow_color: RgbaColor,
|
||||
pub surface_base_color: RgbaColor,
|
||||
pub panel_opacity: f64,
|
||||
@@ -167,6 +239,10 @@ impl Default for InactiveDimConfig {
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ThemeConfig {
|
||||
#[serde(default)]
|
||||
border: BorderPaintConfig,
|
||||
#[serde(default)]
|
||||
focus_ring: BorderPaintConfig,
|
||||
#[serde(default)]
|
||||
typography: OverviewTypographyConfig,
|
||||
#[serde(default)]
|
||||
@@ -184,6 +260,46 @@ struct ThemeConfig {
|
||||
}
|
||||
|
||||
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 {
|
||||
self.theme
|
||||
.typography
|
||||
@@ -905,6 +1021,8 @@ impl Config {
|
||||
focus_ring_width,
|
||||
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
|
||||
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")?,
|
||||
surface_base_color: parse_color(
|
||||
&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()) {
|
||||
return Some(PathBuf::from(directory).join("ferese/config.toml"));
|
||||
}
|
||||
@@ -1724,6 +1842,8 @@ mod tests {
|
||||
focus_ring_width: 3.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]),
|
||||
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]),
|
||||
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
|
||||
panel_opacity: 0.78,
|
||||
@@ -1757,6 +1877,45 @@ mod tests {
|
||||
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]
|
||||
fn material_color_comes_from_surface_base() {
|
||||
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 != 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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn only_unfocused_windows_dim_and_overview_disables_it() {
|
||||
|
||||
@@ -65,6 +65,9 @@ struct SurfaceEffectsState {
|
||||
generation: AtomicU64,
|
||||
role: Mutex<Option<SemanticRole>>,
|
||||
regions: Mutex<Option<Vec<[i32; 5]>>>,
|
||||
opacity: Mutex<u32>,
|
||||
presentation_supported: AtomicBool,
|
||||
dismissing: AtomicBool,
|
||||
}
|
||||
|
||||
impl SurfaceEffectsState {
|
||||
@@ -74,6 +77,9 @@ impl SurfaceEffectsState {
|
||||
generation: AtomicU64::new(0),
|
||||
role: 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) {
|
||||
display.create_global::<Ferese, FereseEffectsManagerV1, _>(2, ());
|
||||
display.create_global::<Ferese, FereseEffectsManagerV1, _>(3, ());
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
} 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 => {}
|
||||
@@ -221,6 +235,27 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
||||
};
|
||||
|
||||
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 } => {
|
||||
let role = match role {
|
||||
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
|
||||
@@ -301,11 +336,47 @@ fn detach(surface: &WlSurface) -> bool {
|
||||
return false;
|
||||
};
|
||||
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);
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -27,6 +27,9 @@ impl XdgActivationHandler for Ferese {
|
||||
data: XdgActivationTokenData,
|
||||
surface: WlSurface,
|
||||
) {
|
||||
if self.session_lock.active {
|
||||
return;
|
||||
}
|
||||
let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE;
|
||||
self.xdg_activation_state.remove_token(&token);
|
||||
if !valid {
|
||||
|
||||
@@ -90,6 +90,12 @@ impl CompositorHandler for Ferese {
|
||||
}
|
||||
|
||||
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);
|
||||
crate::backends::direct::render_all(self);
|
||||
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();
|
||||
if policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
|
||||
if !state.session_lock.active
|
||||
&& policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
|
||||
&& matches!(policy.layer, Layer::Top | Layer::Overlay)
|
||||
{
|
||||
state
|
||||
|
||||
@@ -137,6 +137,10 @@ impl Ferese {
|
||||
with_damage: bool,
|
||||
) {
|
||||
let mut used = data.used.lock().unwrap();
|
||||
if self.session_lock.active {
|
||||
frame.failed();
|
||||
return;
|
||||
}
|
||||
if *used {
|
||||
frame.post_error(
|
||||
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) {
|
||||
if self.session_lock.active {
|
||||
return;
|
||||
}
|
||||
let Some(seat) = Seat::from_resource(&seat) else {
|
||||
return;
|
||||
};
|
||||
@@ -163,6 +166,9 @@ impl XdgShellHandler for Ferese {
|
||||
serial: Serial,
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -247,6 +253,9 @@ impl XdgShellHandler for Ferese {
|
||||
}
|
||||
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
use std::process::Command;
|
||||
|
||||
use smithay::{
|
||||
@@ -26,6 +27,21 @@ impl Ferese {
|
||||
let seat = self.seat.clone();
|
||||
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 {
|
||||
InputEvent::SwitchToggle { event } if event.switch() == Some(Switch::Lid) => {
|
||||
crate::backends::direct::set_lid_closed(self, event.state() == SwitchState::On);
|
||||
@@ -42,6 +58,9 @@ impl Ferese {
|
||||
SERIAL_COUNTER.next_serial(),
|
||||
Event::time(&event) as u32,
|
||||
|data, modifiers, keysym| {
|
||||
if data.session_lock.active {
|
||||
return FilterResult::Forward;
|
||||
}
|
||||
if state == KeyState::Released && data.intercepted_keys.remove(&keycode) {
|
||||
return FilterResult::Intercept(());
|
||||
}
|
||||
@@ -178,6 +197,30 @@ impl Ferese {
|
||||
let pointer = self.seat.get_pointer().expect("seat has a pointer");
|
||||
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() {
|
||||
let position = pointer.current_location();
|
||||
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) {
|
||||
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");
|
||||
self.focus_output_at(position);
|
||||
|
||||
@@ -390,6 +438,9 @@ impl Ferese {
|
||||
pointer: &PointerHandle<Self>,
|
||||
position: Point<f64, Logical>,
|
||||
) {
|
||||
if self.session_lock.active {
|
||||
return;
|
||||
}
|
||||
if self.overview.is_active()
|
||||
|| !self.input_settings.focus_follows_mouse
|
||||
|| pointer.is_grabbed()
|
||||
@@ -436,6 +487,9 @@ impl Ferese {
|
||||
pointer: &PointerHandle<Self>,
|
||||
requested: Point<f64, Logical>,
|
||||
) -> Point<f64, Logical> {
|
||||
if self.session_lock.active {
|
||||
return requested;
|
||||
}
|
||||
let current = pointer.current_location();
|
||||
let Some(focused_surface) = pointer.current_focus() else {
|
||||
return requested;
|
||||
@@ -473,6 +527,9 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub fn activate_focused_pointer_constraint(&self, pointer: &PointerHandle<Self>) {
|
||||
if self.session_lock.active {
|
||||
return;
|
||||
}
|
||||
let position = pointer.current_location();
|
||||
let Some((surface, origin)) = self.surface_under(position) else {
|
||||
return;
|
||||
|
||||
@@ -209,6 +209,13 @@ fn serve_connection(
|
||||
|
||||
impl Ferese {
|
||||
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) {
|
||||
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> {
|
||||
match command {
|
||||
"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)?),
|
||||
"move" => self.move_direction(direction_arg(args)?),
|
||||
"resize" => self.resize_direction(direction_arg(args)?),
|
||||
|
||||
+56
-26
@@ -3,6 +3,7 @@
|
||||
mod backends;
|
||||
mod config;
|
||||
mod cursor;
|
||||
mod daemon;
|
||||
mod dimming;
|
||||
mod effects;
|
||||
mod grabs;
|
||||
@@ -13,7 +14,9 @@ mod metrics;
|
||||
mod overview;
|
||||
mod presentation;
|
||||
mod private_client;
|
||||
mod reload;
|
||||
mod resize_transaction;
|
||||
mod session_lock;
|
||||
mod shell_control;
|
||||
mod stacking;
|
||||
mod state;
|
||||
@@ -42,11 +45,11 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
let launch = LaunchConfig::from_environment()
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
|
||||
let config = Config::load()?;
|
||||
let input_settings = config.input_settings()?;
|
||||
let bindings = config.bindings(&input_settings)?;
|
||||
let window_rules = config.window_rules()?;
|
||||
let output_profiles = config.output_profiles()?;
|
||||
let initial_source = config::config_path().and_then(|path| std::fs::read_to_string(path).ok());
|
||||
let config = match initial_source.as_deref() {
|
||||
Some(source) => Config::parse_source(source)?,
|
||||
None => Config::load()?,
|
||||
};
|
||||
let mut event_loop = EventLoop::try_new()?;
|
||||
let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?;
|
||||
event_loop
|
||||
@@ -56,29 +59,55 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
state.loop_signal.stop();
|
||||
})?;
|
||||
let display = Display::new()?;
|
||||
let runtime = RuntimeConfig {
|
||||
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 runtime = config.runtime_config()?;
|
||||
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)?;
|
||||
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");
|
||||
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| {
|
||||
// All input/Wayland callbacks have returned, releasing seat locks.
|
||||
// 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 {
|
||||
terminate_child(child);
|
||||
}
|
||||
@@ -122,9 +152,9 @@ fn spawn_client(
|
||||
// Wallpaper decoding and drivers may already have worker threads. Set
|
||||
// the child's display explicitly instead of mutating the process env.
|
||||
command.env("WAYLAND_DISPLAY", &state.socket_name);
|
||||
if state.wallpaper.owns_background() {
|
||||
command.env("FERESE_COMPOSITOR_WALLPAPER", "1");
|
||||
}
|
||||
command.env_remove("WAYLAND_SOCKET");
|
||||
command.env_remove("FERESE_SHELL_CONTROL_SOCKET");
|
||||
command.env("FERESE_COMPOSITOR_WALLPAPER", "1");
|
||||
let private_connection = if capabilities.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -103,6 +103,14 @@ fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 {
|
||||
}
|
||||
|
||||
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 {
|
||||
Self {
|
||||
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};
|
||||
|
||||
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 {
|
||||
@@ -53,6 +74,11 @@ impl Dispatch<FereseShellManagerV1, ()> for Ferese {
|
||||
let shell = data_init.init(id, ());
|
||||
|
||||
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()));
|
||||
state.send_shell_snapshot(&shell);
|
||||
state.shell_resources.push(shell.downgrade());
|
||||
@@ -73,6 +99,9 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
|
||||
_display: &DisplayHandle,
|
||||
_data_init: &mut DataInit<'_, Self>,
|
||||
) {
|
||||
if state.session_lock.active {
|
||||
return;
|
||||
}
|
||||
match request {
|
||||
ferese_shell_v1::Request::ActivateWindow {
|
||||
window_hi,
|
||||
@@ -371,6 +400,19 @@ fn send_request_failed(shell: &FereseShellV1, request: ferese_shell_v1::FailedRe
|
||||
mod tests {
|
||||
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]
|
||||
fn window_ids_round_trip_through_protocol_words() {
|
||||
for id in [0, 1, u32::MAX as u64, u64::from(u32::MAX) + 1, u64::MAX] {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct WindowStack {
|
||||
@@ -34,6 +46,13 @@ impl WindowStack {
|
||||
mod tests {
|
||||
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]
|
||||
fn remapping_neighbours_does_not_change_order() {
|
||||
let mut stack = WindowStack::default();
|
||||
|
||||
+215
-10
@@ -107,6 +107,9 @@ impl ClosingAnimation {
|
||||
}
|
||||
|
||||
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 socket_name: OsString,
|
||||
pub display_handle: DisplayHandle,
|
||||
@@ -125,10 +128,12 @@ pub struct Ferese {
|
||||
maximized_windows: HashSet<WindowId>,
|
||||
maximized_column_widths: HashMap<WindowId, ColumnWidth>,
|
||||
window_stack: crate::stacking::WindowStack,
|
||||
natural_floating_pending: HashSet<WindowId>,
|
||||
pub(crate) window_borders: 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_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) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
|
||||
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
|
||||
@@ -178,6 +183,11 @@ pub struct Ferese {
|
||||
next_output_id: u64,
|
||||
last_animation_tick: Instant,
|
||||
pub popups: PopupManager,
|
||||
pub(crate) dismissing_popups: Vec<(
|
||||
WlSurface,
|
||||
smithay::desktop::PopupKind,
|
||||
crate::dimming::DimAnimation,
|
||||
)>,
|
||||
pub seat: Seat<Self>,
|
||||
pub alpha_modifier_state: AlphaModifierState,
|
||||
pub compositor_state: CompositorState,
|
||||
@@ -227,6 +237,65 @@ pub struct RuntimeConfig {
|
||||
}
|
||||
|
||||
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) {
|
||||
let Some(geometry) = self.space.output_geometry(output) else {
|
||||
tracing::error!(output = %output.name(), "cannot register an unmapped output");
|
||||
@@ -286,6 +355,8 @@ impl Ferese {
|
||||
}
|
||||
});
|
||||
self.space.unmap_output(output);
|
||||
self.session_lock.surfaces.remove(output);
|
||||
self.session_lock.backgrounds.remove(output);
|
||||
|
||||
match self.output_workspaces.disconnect(output_id) {
|
||||
Ok(Some(target)) => {
|
||||
@@ -406,6 +477,10 @@ impl Ferese {
|
||||
let fractional_scale_state = FractionalScaleManagerState::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 session_lock_state = smithay::wayland::session_lock::SessionLockManagerState::new::<
|
||||
Self,
|
||||
_,
|
||||
>(&display_handle, |_| true);
|
||||
let input_method_enabled =
|
||||
std::env::var_os("FERESE_ENABLE_INPUT_METHOD").is_some_and(|value| value == "1");
|
||||
let input_method_manager_state =
|
||||
@@ -456,6 +531,9 @@ impl Ferese {
|
||||
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
|
||||
|
||||
let mut state = Self {
|
||||
session_lock_state,
|
||||
session_lock: crate::session_lock::Lock::default(),
|
||||
config_source: None,
|
||||
start_time,
|
||||
socket_name,
|
||||
display_handle,
|
||||
@@ -478,10 +556,12 @@ impl Ferese {
|
||||
maximized_windows: HashSet::new(),
|
||||
maximized_column_widths: HashMap::new(),
|
||||
window_stack: crate::stacking::WindowStack::default(),
|
||||
natural_floating_pending: HashSet::new(),
|
||||
window_borders: HashMap::new(),
|
||||
window_dims: HashMap::new(),
|
||||
window_resize_fills: HashMap::new(),
|
||||
window_dimming: HashMap::new(),
|
||||
window_focus: HashMap::new(),
|
||||
window_shadows: HashMap::new(),
|
||||
rounded_clip_programs: HashMap::new(),
|
||||
overview_scrims: HashMap::new(),
|
||||
@@ -525,6 +605,7 @@ impl Ferese {
|
||||
next_output_id: 1,
|
||||
last_animation_tick: start_time,
|
||||
popups: PopupManager::default(),
|
||||
dismissing_popups: Vec::new(),
|
||||
seat,
|
||||
alpha_modifier_state,
|
||||
compositor_state,
|
||||
@@ -588,6 +669,9 @@ impl Ferese {
|
||||
&self,
|
||||
position: 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])
|
||||
.map(|(_, surface, origin)| (surface, origin))
|
||||
.or_else(|| self.window_surface_under(position))
|
||||
@@ -601,6 +685,9 @@ impl Ferese {
|
||||
&self,
|
||||
position: 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]) {
|
||||
return Some(layer);
|
||||
}
|
||||
@@ -739,9 +826,18 @@ impl Ferese {
|
||||
let mut windows = self.space.elements().cloned().collect::<Vec<_>>();
|
||||
windows.sort_by_key(|window| {
|
||||
let id = self.window_ids.get(window).copied();
|
||||
let priority = id
|
||||
.and_then(|id| self.window_geometry.get(&id))
|
||||
.is_some_and(|geometry| geometry.is_fullscreen() || geometry.is_zooming());
|
||||
let geometry = id.and_then(|id| self.window_geometry.get(&id));
|
||||
let floating = id.is_some_and(|id| {
|
||||
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,
|
||||
id.map_or(usize::MAX, |id| self.window_stack.rank(id)),
|
||||
@@ -891,6 +987,13 @@ impl Ferese {
|
||||
Some(WindowPlacement::Floating { .. })
|
||||
);
|
||||
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.height = rule.height.unwrap_or(rect.height).min(bounds.height);
|
||||
rect.x = bounds.x + (bounds.width - rect.width) / 2.0;
|
||||
@@ -974,9 +1077,11 @@ impl Ferese {
|
||||
self.maximized_windows.remove(&id);
|
||||
self.maximized_column_widths.remove(&id);
|
||||
self.window_stack.remove(id);
|
||||
self.natural_floating_pending.remove(&id);
|
||||
self.window_borders.remove(&id);
|
||||
self.window_dims.remove(&id);
|
||||
self.window_dimming.remove(&id);
|
||||
self.window_focus.remove(&id);
|
||||
self.window_shadows.remove(&id);
|
||||
self.closing_windows.remove(&id);
|
||||
self.window_rules_applied.remove(&id);
|
||||
@@ -994,6 +1099,9 @@ impl Ferese {
|
||||
}
|
||||
|
||||
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
|
||||
// large first animation step after a keypress on an idle desktop.
|
||||
self.advance_animations(Instant::now());
|
||||
@@ -1214,12 +1322,21 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
self.space.map_element(window.clone(), location, false);
|
||||
if let Some(toplevel) = window.toplevel() {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -1258,7 +1375,7 @@ impl Ferese {
|
||||
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()
|
||||
&& requested_size.is_some()
|
||||
&& had_geometry
|
||||
@@ -1327,6 +1444,19 @@ impl Ferese {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
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 duration = if self.animations_enabled {
|
||||
@@ -1335,6 +1465,22 @@ impl Ferese {
|
||||
0.0
|
||||
};
|
||||
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 {
|
||||
let target = crate::dimming::target(
|
||||
dim_settings,
|
||||
@@ -1347,7 +1493,7 @@ impl Ferese {
|
||||
.entry(*id)
|
||||
.or_insert_with(|| crate::dimming::DimAnimation::new(target));
|
||||
let previous = dim.current;
|
||||
active_animation |= dim.advance(target, delta, duration);
|
||||
active_animation |= dim.advance_visual(target, delta, duration);
|
||||
dim_changed |= previous != dim.current;
|
||||
}
|
||||
|
||||
@@ -1504,6 +1650,7 @@ impl Ferese {
|
||||
);
|
||||
}
|
||||
if let Some(size) = geometry.presentation_size_request(self.start_time.elapsed())
|
||||
&& !self.natural_floating_pending.contains(&id)
|
||||
&& let Some(toplevel) = window.toplevel()
|
||||
{
|
||||
toplevel.with_pending_state(|state| {
|
||||
@@ -1625,7 +1772,7 @@ impl Ferese {
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
// current_serial is promoted by Smithay on commit, unlike
|
||||
@@ -1640,15 +1787,32 @@ impl Ferese {
|
||||
});
|
||||
if self
|
||||
.resize_transactions
|
||||
.get(id)
|
||||
.get(&id)
|
||||
.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 {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -2204,6 +2368,10 @@ impl Ferese {
|
||||
}
|
||||
|
||||
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| {
|
||||
self.window_ids.iter().find_map(|(window, id)| {
|
||||
(*id == focused)
|
||||
@@ -2226,6 +2394,18 @@ impl Ferese {
|
||||
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> {
|
||||
let geometry = self.space.output_geometry(output)?;
|
||||
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 {
|
||||
let progress = progress.clamp(0.0, 1.0);
|
||||
|
||||
@@ -2394,6 +2585,20 @@ impl ClientData for ClientState {
|
||||
mod tests {
|
||||
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]
|
||||
fn maximization_respects_layer_exclusion_and_outer_gaps() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -17,7 +17,7 @@ use smithay::{
|
||||
};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::mpsc};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
|
||||
pub(crate) struct WallpaperConfig {
|
||||
pub path: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
@@ -36,6 +36,9 @@ struct WallpaperTexture {
|
||||
id: Id,
|
||||
}
|
||||
pub(crate) struct WallpaperState {
|
||||
config: WallpaperConfig,
|
||||
pending: Option<WallpaperConfig>,
|
||||
commit: CommitCounter,
|
||||
owned: bool,
|
||||
mode: WallpaperMode,
|
||||
receiver: Option<mpsc::Receiver<Result<image::RgbaImage, String>>>,
|
||||
@@ -45,6 +48,7 @@ pub(crate) struct WallpaperState {
|
||||
|
||||
impl WallpaperState {
|
||||
pub fn new(config: WallpaperConfig) -> Self {
|
||||
let retained = config.clone();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let owned = config.path.as_ref().is_some_and(|path| path.is_file());
|
||||
if owned {
|
||||
@@ -64,6 +68,9 @@ impl WallpaperState {
|
||||
});
|
||||
}
|
||||
Self {
|
||||
config: retained,
|
||||
pending: None,
|
||||
commit: CommitCounter::default(),
|
||||
owned,
|
||||
mode: config.mode,
|
||||
receiver: owned.then_some(receiver),
|
||||
@@ -71,9 +78,38 @@ impl WallpaperState {
|
||||
textures: HashMap::new(),
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn owns_background(&self) -> bool {
|
||||
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) {
|
||||
self.textures.remove(context);
|
||||
}
|
||||
@@ -84,6 +120,14 @@ impl WallpaperState {
|
||||
match receiver.try_recv() {
|
||||
Ok(result) => {
|
||||
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 {
|
||||
Ok(pixels) => {
|
||||
tracing::info!(
|
||||
@@ -93,6 +137,8 @@ impl WallpaperState {
|
||||
"decoded compositor wallpaper once"
|
||||
);
|
||||
self.pixels = Some(pixels);
|
||||
self.textures.clear();
|
||||
self.commit.increment();
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "wallpaper unavailable; using output clear color")
|
||||
@@ -147,7 +193,7 @@ impl WallpaperState {
|
||||
);
|
||||
Some(NativeTextureElement {
|
||||
id: cached.id.clone(),
|
||||
commit: CommitCounter::default(),
|
||||
commit: self.commit,
|
||||
texture: cached.texture.clone(),
|
||||
geometry,
|
||||
source,
|
||||
@@ -204,6 +250,44 @@ fn image_geometry(
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[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() {
|
||||
let (geometry, source) = image_geometry(
|
||||
(3840, 2160).into(),
|
||||
|
||||
+266
-23
@@ -85,6 +85,7 @@ render_elements! {
|
||||
Surface=SurfaceRenderElement,
|
||||
Memory=MemoryRenderElement,
|
||||
Solid=SolidColorRenderElement,
|
||||
LockSurface=WaylandSurfaceRenderElement<GlesRenderer>,
|
||||
Border=PixelShaderElement,
|
||||
Blur=BlurRenderElement,
|
||||
Effect=PhysicalShaderElement,
|
||||
@@ -226,13 +227,19 @@ void main() {
|
||||
"#;
|
||||
|
||||
const ROUNDED_BORDER_SHADER: &str = r#"
|
||||
precision mediump float;
|
||||
precision highp float;
|
||||
|
||||
uniform float alpha;
|
||||
uniform vec4 clip_rect;
|
||||
uniform float radius;
|
||||
uniform float border_width;
|
||||
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;
|
||||
|
||||
#if defined(DEBUG_FLAGS)
|
||||
@@ -257,8 +264,15 @@ void main() {
|
||||
- inner_radius;
|
||||
float inner_coverage = 1.0 - smoothstep(-0.5, 0.5, inner_signed_distance);
|
||||
float coverage = max(outer_coverage - inner_coverage, 0.0);
|
||||
vec4 color = vec4(border_color.rgb * border_color.a, border_color.a)
|
||||
* coverage * alpha;
|
||||
float progress = clamp(dot(gl_FragCoord.xy - gradient_line.xy, gradient_line.zw), 0.0, 1.0);
|
||||
// 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 (tint == 1.0)
|
||||
@@ -357,13 +371,14 @@ uniform vec4 visible_rect;
|
||||
uniform float material_radius;
|
||||
uniform vec2 texture_size;
|
||||
uniform float blur_radius;
|
||||
uniform float presentation_alpha;
|
||||
uniform vec4 tint;
|
||||
varying vec2 v_coords;
|
||||
void main() {
|
||||
vec2 half_size = visible_rect.zw * 0.5;
|
||||
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 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; }
|
||||
// Denser Gaussian sampling preserves the same radius and softness without
|
||||
// the visible grid left by widely spaced taps on detailed wallpapers.
|
||||
@@ -562,6 +577,7 @@ pub(crate) struct MaterialProgram(GlesPixelProgram);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct MaterialParameters {
|
||||
presentation_alpha: f32,
|
||||
blur: f32,
|
||||
scene_generation: u64,
|
||||
sample_geometry: Rectangle<i32, Logical>,
|
||||
@@ -604,6 +620,12 @@ struct BorderParameters {
|
||||
radius: f32,
|
||||
width: f32,
|
||||
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)]
|
||||
@@ -902,6 +924,9 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
state.loop_signal.stop();
|
||||
return;
|
||||
}
|
||||
if state.session_lock.active {
|
||||
state.lock_frame_presented(&output);
|
||||
}
|
||||
let elapsed = render_started.elapsed();
|
||||
if elapsed > refresh.get() {
|
||||
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) {
|
||||
if state.session_lock.active {
|
||||
state.lock_frame_callbacks(output);
|
||||
return;
|
||||
}
|
||||
let time = state.start_time.elapsed();
|
||||
for window in state.space.elements() {
|
||||
window.send_frame(output, time, Some(refresh), |_, _| Some(output.clone()));
|
||||
@@ -985,6 +1014,58 @@ fn output_elements(
|
||||
output: &Output,
|
||||
include_cursor: bool,
|
||||
) -> 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() {
|
||||
state.backdrop_generation = state.backdrop_generation.wrapping_add(1);
|
||||
}
|
||||
@@ -1082,18 +1163,15 @@ fn output_elements(
|
||||
} else {
|
||||
state.focused_window == Some(id)
|
||||
};
|
||||
let border_width = if focused {
|
||||
state.theme_settings.focus_ring_width
|
||||
} else {
|
||||
state.theme_settings.border_width
|
||||
};
|
||||
let border_color = if focused {
|
||||
state.theme_settings.accent_color.0
|
||||
} else {
|
||||
state.theme_settings.border_color.0
|
||||
};
|
||||
let border_color =
|
||||
color_with_alpha(border_color, close_alpha * decoration_progress as f32);
|
||||
let focus = state
|
||||
.window_focus
|
||||
.get(&id)
|
||||
.map_or(if focused { 1.0 } else { 0.0 }, |v| v.current);
|
||||
let border_width = state.theme_settings.border_width
|
||||
+ (state.theme_settings.focus_ring_width - state.theme_settings.border_width)
|
||||
* focus;
|
||||
let border_color = state.theme_settings.border_color.0;
|
||||
let gradient = state.theme_settings.border_gradient;
|
||||
|
||||
if let Some(border) = window_border_element(
|
||||
state,
|
||||
@@ -1105,6 +1183,9 @@ fn output_elements(
|
||||
window_radius,
|
||||
border_width,
|
||||
border_color,
|
||||
gradient,
|
||||
focus as f32,
|
||||
close_alpha * decoration_progress as f32,
|
||||
output,
|
||||
&programs,
|
||||
) {
|
||||
@@ -1263,6 +1344,12 @@ fn overview_chrome_element(
|
||||
radius: (radius * scale) as f32,
|
||||
width: (width * scale) as f32,
|
||||
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 {
|
||||
rounded_clip_program(state, renderer)?.border
|
||||
@@ -1472,6 +1559,12 @@ fn rounded_clip_program(
|
||||
UniformName::new("radius", UniformType::_1f),
|
||||
UniformName::new("border_width", UniformType::_1f),
|
||||
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 = [
|
||||
UniformName::new("shadow_rect", UniformType::_4f),
|
||||
@@ -1516,6 +1609,9 @@ fn window_border_element(
|
||||
requested_radius: f64,
|
||||
requested_width: f64,
|
||||
color: [f32; 4],
|
||||
gradient: Option<crate::config::BorderGradient>,
|
||||
focus_mix: f32,
|
||||
opacity: f32,
|
||||
output: &Output,
|
||||
programs: &RoundedClipPrograms,
|
||||
) -> Option<PhysicalShaderElement> {
|
||||
@@ -1525,12 +1621,49 @@ fn window_border_element(
|
||||
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 {
|
||||
geometry,
|
||||
clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()),
|
||||
radius: scaled_effect_value(requested_radius, geometry, scale),
|
||||
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 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("border_width", parameters.width).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),
|
||||
width: 0.0,
|
||||
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| {
|
||||
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>(
|
||||
renderer: &mut R,
|
||||
framebuffer: &mut R::Framebuffer<'_>,
|
||||
@@ -2087,7 +2260,7 @@ fn append_material_surface(
|
||||
surface,
|
||||
content_origin.to_physical_precise_round(scale),
|
||||
scale,
|
||||
1.0,
|
||||
crate::effects::surface_opacity(surface),
|
||||
RenderElementKind::Unspecified,
|
||||
);
|
||||
elements.extend(content.into_iter().filter_map(|element| {
|
||||
@@ -2125,6 +2298,7 @@ fn material_element(
|
||||
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
|
||||
let (role, generation) = crate::effects::surface_role(surface)?;
|
||||
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
|
||||
&& material.style == crate::config::MaterialStyle::Translucent
|
||||
{
|
||||
@@ -2138,7 +2312,11 @@ fn material_element(
|
||||
let [offset_y, shadow_blur, shadow_opacity] = material.shadow;
|
||||
let edge_bar =
|
||||
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(
|
||||
(geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(),
|
||||
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_physical = sample_geometry.to_physical_precise_round(scale);
|
||||
let parameters = MaterialParameters {
|
||||
presentation_alpha,
|
||||
blur: (blur * scale) as f32,
|
||||
scene_generation: if blur > 0.0 {
|
||||
state.backdrop_generation
|
||||
@@ -2175,9 +2354,14 @@ fn material_element(
|
||||
shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32],
|
||||
shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur),
|
||||
geometry,
|
||||
tint: [red, green, blue, material.opacity],
|
||||
tint: [
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
material.opacity * if blur > 0.0 { 1.0 } else { presentation_alpha },
|
||||
],
|
||||
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(
|
||||
geometry.to_physical_precise_round(scale),
|
||||
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("texture_size", UniformType::_2f),
|
||||
UniformName::new("blur_radius", UniformType::_1f),
|
||||
UniformName::new("presentation_alpha", UniformType::_1f),
|
||||
UniformName::new("tint", UniformType::_4f),
|
||||
];
|
||||
match renderer.compile_custom_texture_shader(BLUR_SHADER, &uniforms) {
|
||||
@@ -2332,6 +2517,7 @@ fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
|
||||
)
|
||||
.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(),
|
||||
]
|
||||
}
|
||||
@@ -2491,10 +2677,67 @@ mod tests {
|
||||
};
|
||||
|
||||
use super::{
|
||||
color_with_alpha, framebuffer_clip_rect, normalized_scale, resize_content_behavior,
|
||||
rounded_visual_rect, scaled_visual_rect, shadow_bounds,
|
||||
border_gradient_line, color_with_alpha, framebuffer_clip_rect, normalized_scale,
|
||||
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)]
|
||||
struct DamageElement {
|
||||
id: Id,
|
||||
|
||||
Reference in new issue
Block a user