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:
Samuel Onoja committed 2026-09-26 19:03:37 +01:00
1 parent b422448d88
commit 672b4e2b60
41 files changed
+2694 -298

No files matched your search

+27 -1
View File
@@ -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
View File
@@ -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\"")
+97
View File
@@ -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);
}
}
+32
View File
@@ -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() {
+74 -3
View File
@@ -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::*;
+3
View File
@@ -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 {
+6
View File
@@ -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() {
+2 -1
View File
@@ -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
+4
View File
@@ -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,
+9
View File
@@ -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;
};
+57
View File
@@ -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;
+10
View File
@@ -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
View File
@@ -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 {
+8
View File
@@ -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,
+217
View File
@@ -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()
);
}
}
+211
View File
@@ -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);
+43 -1
View File
@@ -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] {
+19
View File
@@ -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
View File
@@ -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!(
+86 -2
View File
@@ -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
View File
@@ -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,