Compare commits

..
10 Commits
Author SHA1 Message Date
Samuel Onoja 0d7203f84b Migrate DRM outputs to Smithay DrmCompositor 2026-10-01 19:31:54 +01:00
Samuel Onoja cd19691f52 fix wl-copy..copy on select bug 2026-10-01 18:29:56 +01:00
Samuel Onoja 1441875096 Unify per-monitor workspace numbering and lifecycle policy 2026-10-01 18:24:12 +01:00
Samuel Onoja 07bda5a21c Refactor window ownership and immutable frame sampling 2026-10-01 18:03:01 +01:00
Samuel Onoja c0010ccbb6 Remove design documents and their handbook references 2026-10-01 17:51:54 +01:00
Samuel Onoja 3d0c85c70a Wait for DRM presentation before releasing frame callbacks
Cancel callback-only timers when a frame is submitted and defer callback delivery until retirement. Cover a delayed timer and missed presentation deadline in the scheduler tests.

Clean up shader argument borrows and shell region decoding to pass Clippy.
2026-10-01 17:32:17 +01:00
Samuel Onoja a40b899b8c Schedule DRM frames against per-output presentation deadlines 2026-10-01 17:22:34 +01:00
Samuel Onoja 9666e4ff6c Align corner clipping, antialiasing, and fractional material regions 2026-10-01 16:27:31 +01:00
Samuel Onoja f110ec80e1 Unify circular corner geometry and shader masks 2026-10-01 16:08:54 +01:00
Samuel Onoja 0162870cca Add focus history and Alt-Tab window switching 2026-10-01 16:00:23 +01:00
85 changed files with 4386 additions and 1786 deletions

No files matched your search

+3
View File
@@ -62,6 +62,9 @@ Shortcuts** whenever you want to change a shortcut or gesture.
| --- | --- | | --- | --- |
| Super + Enter | Open a terminal | | Super + Enter | Open a terminal |
| Super + H / J / K / L | Focus left / down / up / right | | Super + H / J / K / L | Focus left / down / up / right |
| Super + Backspace | Return to the last focused window |
| Super + Escape | Toggle between the last two visited workspaces on this monitor |
| Alt + Tab / Alt + Shift + Tab | Preview recent windows forward / backward; release Alt to select, Escape to cancel |
| Super + F | Maximize the window | | Super + F | Maximize the window |
| Super + Shift + F | Toggle fullscreen | | Super + Shift + F | Toggle fullscreen |
| Super + R | Cycle column width | | Super + R | Cycle column width |
+318 -116
View File
@@ -1,24 +1,31 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::error::Error; use std::error::Error;
use std::io; use std::io;
use std::os::fd::AsFd;
use std::path::Path; use std::path::Path;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
mod capture;
mod planes;
mod scheduling;
use scheduling::*;
use smithay::backend::allocator::Fourcc; use smithay::backend::allocator::Fourcc;
use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice}; use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice};
use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface, NodeType}; use smithay::backend::drm::compositor::{DrmCompositor, PrimaryPlaneElement};
use smithay::backend::drm::exporter::gbm::GbmFramebufferExporter;
use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, NodeType};
use smithay::backend::egl::{EGLContext, EGLDisplay}; use smithay::backend::egl::{EGLContext, EGLDisplay};
use smithay::backend::input::InputEvent; use smithay::backend::input::InputEvent;
use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface}; use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface};
use smithay::backend::renderer::damage::OutputDamageTracker; use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
use smithay::backend::renderer::gles::GlesRenderer; use smithay::backend::renderer::utils::CommitCounter;
use smithay::backend::renderer::{Bind, Frame, ImportDma, ImportMemWl, Renderer}; use smithay::backend::renderer::{ImportDma, ImportMemWl, Renderer};
use smithay::backend::session::libseat::LibSeatSession; use smithay::backend::session::libseat::LibSeatSession;
use smithay::backend::session::{Event as SessionEvent, Session}; use smithay::backend::session::{Event as SessionEvent, Session};
use smithay::backend::udev::{UdevBackend, UdevEvent, primary_gpu}; use smithay::backend::udev::{UdevBackend, UdevEvent, primary_gpu};
use smithay::desktop::layer_map_for_output; use smithay::desktop::layer_map_for_output;
use smithay::desktop::utils::OutputPresentationFeedback; use smithay::desktop::utils::OutputPresentationFeedback;
use smithay::output::{Mode as OutputMode, Output, PhysicalProperties, Scale, Subpixel}; use smithay::output::{Mode as OutputMode, Output, OutputModeSource, PhysicalProperties, Scale, Subpixel};
use smithay::reexports::calloop::timer::{TimeoutAction, Timer}; use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::calloop::{EventLoop, RegistrationToken}; use smithay::reexports::calloop::{EventLoop, RegistrationToken};
use smithay::reexports::drm::control::{ use smithay::reexports::drm::control::{
@@ -35,7 +42,7 @@ use smithay::wayland::presentation::Refresh;
use crate::Ferese; use crate::Ferese;
use crate::config::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform}; use crate::config::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
use crate::metrics::RenderMetrics; use crate::metrics::RenderMetrics;
use crate::render::{animated_window_elements, cursorless_window_elements, frame_effect_metrics, redraw_output}; use crate::render::{frame_effect_metrics, sampled_output_elements};
pub struct DirectBackendState { pub struct DirectBackendState {
pub session: LibSeatSession, pub session: LibSeatSession,
@@ -145,10 +152,18 @@ struct PresentationClock {
missed_deadlines: u64, missed_deadlines: u64,
} }
type OutputCompositor = DrmCompositor<
GbmAllocator<DrmDeviceFd>,
GbmFramebufferExporter<DrmDeviceFd>,
OutputPresentationFeedback,
DrmDeviceFd,
>;
struct DirectDevice { struct DirectDevice {
drm: DrmDevice, drm: DrmDevice,
gbm: GbmDevice<DrmDeviceFd>, gbm: GbmDevice<DrmDeviceFd>,
renderer: GlesRenderer, renderer: GlesRenderer,
render_node: DrmNode,
outputs: HashMap<crtc::Handle, DirectOutput>, outputs: HashMap<crtc::Handle, DirectOutput>,
} }
@@ -159,12 +174,14 @@ struct DirectOutput {
settings: OutputSettings, settings: OutputSettings,
output: Output, output: Output,
global: GlobalId, global: GlobalId,
surface: GbmBufferedSurface<GbmAllocator<DrmDeviceFd>, OutputPresentationFeedback>, surface: OutputCompositor,
damage_tracker: OutputDamageTracker, primary_commit: Option<CommitCounter>,
capture_texture: Option<GlesTexture>,
render_metrics: RenderMetrics, render_metrics: RenderMetrics,
frame_pending: bool, frame_pending: bool,
scheduler: crate::frame_scheduler::FrameScheduler,
render_timer: Option<RegistrationToken>,
power_off: bool, power_off: bool,
callback_clock: crate::presentation::CallbackClock,
callback_timer: Option<RegistrationToken>, callback_timer: Option<RegistrationToken>,
lock_frame_pending: bool, lock_frame_pending: bool,
} }
@@ -211,6 +228,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
animation_timer: None, animation_timer: None,
animation_active: false, animation_active: false,
}); });
open_primary_device(event_loop, state, &primary_path)?; open_primary_device(event_loop, state, &primary_path)?;
event_loop event_loop
.handle() .handle()
@@ -228,17 +246,20 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
backend.input_devices.push(device); backend.input_devices.push(device);
} }
} }
InputEvent::DeviceRemoved { device } => { InputEvent::DeviceRemoved { device } => {
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.input_devices.retain(|candidate| candidate != &device); backend.input_devices.retain(|candidate| candidate != &device);
} }
} }
event => state.process_input_event(event), event => state.process_input_event(event),
})?; })?;
event_loop event_loop
.handle() .handle()
.insert_source(notifier, move |event, _, state| match event { .insert_source(notifier, move |event, _, state| match event {
SessionEvent::PauseSession => { SessionEvent::PauseSession => {
let handle = state.loop_handle.clone();
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.active = false; backend.active = false;
backend backend
@@ -248,28 +269,43 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
backend.devices.values_mut().for_each(|device| { backend.devices.values_mut().for_each(|device| {
device.drm.pause(); device.drm.pause();
device.outputs.values_mut().for_each(|output| { device.outputs.values_mut().for_each(|output| {
cancel_output_timers(&handle, output);
output.surface.reset_buffers(); output.surface.reset_buffers();
output.damage_tracker = OutputDamageTracker::from_output(&output.output); output.primary_commit = None;
output.capture_texture = None;
output.frame_pending = false; output.frame_pending = false;
output.power_off = false; output.power_off = false;
}); });
}); });
} }
libinput_context.suspend(); libinput_context.suspend();
tracing::info!("direct session paused"); tracing::info!("direct session paused");
} }
SessionEvent::ActivateSession => { SessionEvent::ActivateSession => {
if libinput_context.resume().is_err() { if libinput_context.resume().is_err() {
tracing::error!("failed to resume libinput"); tracing::error!("failed to resume libinput");
} }
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.active = true; backend.active = true;
for device in backend.devices.values_mut() { for device in backend.devices.values_mut() {
if let Err(error) = device.drm.activate(true) { if let Err(error) = device.drm.activate(true) {
tracing::error!(%error, "failed to reactivate DRM device"); tracing::error!(%error, "failed to reactivate DRM device");
continue;
}
for output in device.outputs.values_mut() {
// A suspended page flip may never arrive. Clear the old
// pending frame before accepting a fresh submission.
if let Err(error) = output.surface.clear().and_then(|()| output.surface.reset_state()) {
tracing::error!(%error, "failed to reset DRM output after activation");
}
} }
} }
} }
update_power_policy(state); update_power_policy(state);
state.reset_animation_clock(); state.reset_animation_clock();
tracing::info!("direct session activated"); tracing::info!("direct session activated");
@@ -287,11 +323,13 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
tracing::info!(?device_id, ?path, "additional DRM device discovered"); tracing::info!(?device_id, ?path, "additional DRM device discovered");
} }
} }
UdevEvent::Changed { device_id } => { UdevEvent::Changed { device_id } => {
if let Some(node) = direct_node_for_device(state, device_id) { if let Some(node) = direct_node_for_device(state, device_id) {
rescan_device(state, node); rescan_device(state, node);
} }
} }
UdevEvent::Removed { device_id } => { UdevEvent::Removed { device_id } => {
if let Some(node) = direct_node_for_device(state, device_id) { if let Some(node) = direct_node_for_device(state, device_id) {
remove_device(state, node); remove_device(state, node);
@@ -447,6 +485,7 @@ fn open_primary_device(
) )
.into()); .into());
} }
state state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -481,14 +520,16 @@ fn open_primary_device(
.and_then(|device| device.outputs.get_mut(&crtc)) .and_then(|device| device.outputs.get_mut(&crtc))
.and_then(|output| match output.surface.frame_submitted() { .and_then(|output| match output.surface.frame_submitted() {
Ok(feedback) => { Ok(feedback) => {
retired = true; retired = feedback.is_some();
feedback feedback
} }
Err(error) => { Err(error) => {
tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame"); tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame");
None None
} }
}); });
if let (Some(mut feedback), DrmEventTime::Monotonic(time)) = (feedback, metadata.time) { if let (Some(mut feedback), DrmEventTime::Monotonic(time)) = (feedback, metadata.time) {
feedback.presented( feedback.presented(
Time::<Monotonic>::from(time), Time::<Monotonic>::from(time),
@@ -497,6 +538,7 @@ fn open_primary_device(
Kind::Vsync | Kind::HwClock | Kind::HwCompletion, Kind::Vsync | Kind::HwClock | Kind::HwCompletion,
); );
} }
let lock_output = state let lock_output = state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -509,34 +551,59 @@ fn open_primary_device(
None None
} }
}); });
if let Some(output) = lock_output { if let Some(output) = lock_output {
state.lock_frame_presented(&output); state.lock_frame_presented(&output);
} }
if let Some(output) = state if let Some(output) = state
.direct_backend .direct_backend
.as_mut() .as_mut()
.and_then(|backend| backend.devices.get_mut(&node)) .and_then(|backend| backend.devices.get_mut(&node))
.and_then(|device| device.outputs.get_mut(&crtc)) .and_then(|device| device.outputs.get_mut(&crtc))
&& retired
{ {
output.frame_pending = false; output.frame_pending = false;
if let DrmEventTime::Monotonic(timestamp) = metadata.time {
let now = monotonic_now();
if timestamp <= now + refresh_duration_from_mode(output.mode) {
if let Some(plan) = output.scheduler.presented(timestamp)
&& std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some()
{
tracing::debug!(target: "ferese::render", output = %output.output.name(),
target_us = plan.present_at.as_micros(), actual_us = timestamp.as_micros(),
request_to_present_us = timestamp.saturating_sub(plan.requested_at).as_micros(),
missed_deadlines = output.scheduler.missed_deadlines,
"DRM presentation schedule");
}
} else {
output.scheduler.retire_unknown();
}
} else {
output.scheduler.retire_unknown();
}
} }
state.record_drm_presentation(node, crtc, metadata.time, metadata.sequence); state.record_drm_presentation(node, crtc, metadata.time, metadata.sequence);
tracing::trace!(?node, ?crtc, sequence = metadata.sequence, "page flip"); tracing::trace!(?node, ?crtc, sequence = metadata.sequence, "page flip");
let active = state.direct_backend.as_ref().is_some_and(|backend| backend.active); let active = state.direct_backend.as_ref().is_some_and(|backend| backend.active);
if active { if active && retired {
let was_animating = state schedule_frame_callbacks(state, node, crtc, monotonic_now());
let identity = state
.direct_backend .direct_backend
.as_ref() .as_ref()
.is_some_and(|backend| backend.animation_active); .and_then(|backend| backend.devices.get(&node))
let animating = state.advance_animations(Instant::now()); .and_then(|device| device.outputs.get(&crtc))
if animating || was_animating { .map(|output| output.output.clone());
render_outputs(state, animating); if identity.is_some_and(|output| state.output_has_animations(&output)) {
request_frame(state, node, crtc);
} else { } else {
render_output(state, node, crtc); arm_frame(state, node, crtc);
} }
} }
} }
} }
DrmEvent::Error(error) => { DrmEvent::Error(error) => {
tracing::error!(?node, %error, "DRM event error"); tracing::error!(?node, %error, "DRM event error");
} }
@@ -553,6 +620,7 @@ fn open_primary_device(
drm, drm,
gbm, gbm,
renderer, renderer,
render_node,
outputs, outputs,
}, },
); );
@@ -566,16 +634,23 @@ pub(crate) fn sleep_locked_outputs(state: &mut Ferese) {
if !state.session_lock.active || !state.session_lock.sleeping { if !state.session_lock.active || !state.session_lock.sleeping {
return; return;
} }
let Some(backend) = state.direct_backend.as_mut() else { let Some(backend) = state.direct_backend.as_mut() else {
return; return;
}; };
for device in backend.devices.values_mut().filter(|device| device.drm.is_active()) { for device in backend.devices.values_mut().filter(|device| device.drm.is_active()) {
for output in device.outputs.values_mut() { for output in device.outputs.values_mut() {
if output.power_off || output.frame_pending { if output.power_off || output.frame_pending {
continue; continue;
} }
match output.surface.surface().clear() {
Ok(()) => output.power_off = true, match output.surface.clear() {
Ok(()) => {
cancel_output_timers(&state.loop_handle, output);
output.power_off = true;
}
Err(error) => { Err(error) => {
tracing::warn!(%error, output = %output.output.name(), "could not sleep locked display") tracing::warn!(%error, output = %output.output.name(), "could not sleep locked display")
} }
@@ -609,6 +684,7 @@ fn render_outputs(state: &mut Ferese, animating: bool) {
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_active = animating && !state.session_lock.sleeping; backend.animation_active = animating && !state.session_lock.sleeping;
} }
arm_animation_timer(state); arm_animation_timer(state);
let outputs = state let outputs = state
.direct_backend .direct_backend
@@ -623,7 +699,7 @@ fn render_outputs(state: &mut Ferese, animating: bool) {
.unwrap_or_default(); .unwrap_or_default();
for (node, crtc) in outputs { for (node, crtc) in outputs {
render_output(state, node, crtc); request_frame(state, node, crtc);
} }
} }
@@ -697,8 +773,7 @@ fn lid_hides_panel(closed: bool, external_available: bool) -> bool {
closed && external_available closed && external_available
} }
fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, plan: crate::frame_scheduler::FramePlan) {
schedule_frame_callbacks(state, node, crtc);
if state.session_lock.active && state.session_lock.sleeping { if state.session_lock.active && state.session_lock.sleeping {
sleep_locked_outputs(state); sleep_locked_outputs(state);
let asleep = state let asleep = state
@@ -711,11 +786,13 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
return; return;
} }
} }
let missed_deadlines = state let missed_deadlines = state
.direct_backend .direct_backend
.as_ref() .as_ref()
.and_then(|backend| backend.presentation.get(&(node, crtc))) .and_then(|backend| backend.devices.get(&node))
.map_or(0, |clock| clock.missed_deadlines); .and_then(|device| device.outputs.get(&crtc))
.map_or(0, |output| output.scheduler.missed_deadlines);
let Some(mut device) = state let Some(mut device) = state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -723,10 +800,12 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
else { else {
return; return;
}; };
let Some(mut output) = device.outputs.remove(&crtc) else { let Some(mut output) = device.outputs.remove(&crtc) else {
restore_device(state, node, device); restore_device(state, node, device);
return; return;
}; };
if !device.drm.is_active() || output.frame_pending { if !device.drm.is_active() || output.frame_pending {
device.outputs.insert(crtc, output); device.outputs.insert(crtc, output);
restore_device(state, node, device); restore_device(state, node, device);
@@ -734,73 +813,112 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
} }
let render_started = Instant::now(); let render_started = Instant::now();
let rendered = (|| -> Result<bool, Box<dyn Error>> { let now = monotonic_now();
state.process_dmabuf_imports(&mut device.renderer); let horizon = output
let (mut buffer, age) = output.surface.next_buffer()?; .scheduler
let elements = animated_window_elements(state, &mut device.renderer, &output.output); .forecast_time(now, plan)
let effects = frame_effect_metrics(&elements, output.output.current_scale().fractional_scale()); .map_or(Duration::ZERO, |target| target.saturating_sub(now));
let mut framebuffer = device.renderer.bind(&mut buffer)?; let frame = state.sample_frame(&output.output, horizon);
let result = output.damage_tracker.render_output( let rendered = {
&mut device.renderer, (|| -> Result<bool, Box<dyn Error>> {
&mut framebuffer, state.process_dmabuf_imports(&mut device.renderer, Some(device.render_node));
usize::from(age), let elements = sampled_output_elements(state, &mut device.renderer, &output.output, true, &frame);
&elements, let effects = frame_effect_metrics(&elements, output.output.current_scale().fractional_scale());
[0.035, 0.04, 0.055, 1.0], let flags = planes::frame_flags(
)?; state.output_has_fullscreen_for_frame(&output.output, frame.overview.is_presenting()),
let cursorless_capture = state.has_pending_screencopy(&output.output, false); state.output_has_animations(&output.output),
let captured_with_cursor = state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, true); state.session_lock.active,
);
let result =
output
.surface
.render_frame(&mut device.renderer, &elements, [0.035, 0.04, 0.055, 1.0], flags)?;
if cursorless_capture { if result.needs_sync()
let cursorless_elements = cursorless_window_elements(state, &mut device.renderer, &output.output); && let PrimaryPlaneElement::Swapchain(primary) = &result.primary_element
redraw_output( {
primary.sync.wait()?;
}
capture::capture_output(
state,
&mut device.renderer, &mut device.renderer,
&mut framebuffer, &mut output.capture_texture,
&output.output, &output.output,
&cursorless_elements, &frame,
)?; )?;
state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, false);
redraw_output(&mut device.renderer, &mut framebuffer, &output.output, &elements)?; if result.is_empty {
} else if captured_with_cursor { output
let _ = device .render_metrics
.renderer .record_no_damage(render_started.elapsed(), missed_deadlines);
.render( return Ok(false);
&mut framebuffer, }
output.output.current_mode().expect("output has a mode").size,
output.output.current_transform(), // Count GPU composition damage; direct scanout and cursor-only
)? // updates do not redraw the primary swapchain.
.finish()?; let (damage, primary_commit) = match &result.primary_element {
} PrimaryPlaneElement::Swapchain(primary) => {
let Some(damage) = result.damage.cloned() else { let damage = primary.damage.damage_since(output.primary_commit);
let damage = damage.map_or_else(
|| {
vec![smithay::utils::Rectangle::from_size(
output.output.current_mode().unwrap().size,
)]
},
|damage| {
damage
.iter()
.map(|rect| {
smithay::utils::Rectangle::new(
(rect.loc.x, rect.loc.y).into(),
(rect.size.w, rect.size.h).into(),
)
})
.collect()
},
);
(damage, Some(primary.damage.current_commit()))
}
PrimaryPlaneElement::Element(_) => (Vec::new(), None),
};
let presentation = crate::presentation::take_output_feedback(
state,
&output.output,
&result.states,
Kind::Vsync | Kind::HwClock | Kind::HwCompletion,
);
drop(result);
output.surface.queue_frame(presentation)?;
output.primary_commit = primary_commit;
output output
.render_metrics .render_metrics
.record_no_damage(render_started.elapsed(), missed_deadlines); .record_frame(render_started.elapsed(), &damage, missed_deadlines, effects);
return Ok(false); output.frame_pending = true;
}; output.lock_frame_pending = state.session_lock.active;
Ok(true)
})()
};
let presentation = crate::presentation::take_output_feedback( let submitted = rendered.as_ref().is_ok_and(|submitted| *submitted);
state, record_frame_schedule(&mut output, plan, render_started.elapsed(), submitted);
&output.output,
&result.states, if submitted && let Some(token) = output.callback_timer.take() {
Kind::Vsync | Kind::HwClock | Kind::HwCompletion, state.loop_handle.remove(token);
); }
output
.surface
.queue_buffer(Some(result.sync), Some(damage.clone()), presentation)?;
output
.render_metrics
.record_frame(render_started.elapsed(), &damage, missed_deadlines, effects);
output.frame_pending = true;
output.lock_frame_pending = state.session_lock.active;
Ok(true)
})();
match rendered { match rendered {
Ok(true) => tracing::trace!(?node, ?crtc, "queued DRM frame"), Ok(true) => tracing::trace!(?node, ?crtc, "queued DRM frame"),
Ok(false) => (), Ok(false) => (),
Err(error) => tracing::error!(?node, ?crtc, %error, "failed to render DRM frame"), Err(error) => tracing::error!(?node, ?crtc, %error, "failed to render DRM frame"),
} }
device.outputs.insert(crtc, output); device.outputs.insert(crtc, output);
restore_device(state, node, device); restore_device(state, node, device);
if !submitted {
schedule_frame_callbacks(state, node, crtc, plan.present_at);
}
} }
fn restore_device(state: &mut Ferese, node: DrmNode, device: DirectDevice) { fn restore_device(state: &mut Ferese, node: DrmNode, device: DirectDevice) {
@@ -840,11 +958,13 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
return; return;
} }
}; };
let lid_closed = state.direct_backend.as_ref().is_some_and(|backend| backend.lid_closed); let lid_closed = state.direct_backend.as_ref().is_some_and(|backend| backend.lid_closed);
apply_lid_policy(&mut scan, lid_closed); apply_lid_policy(&mut scan, lid_closed);
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.connected_outputs = scan.connected_outputs; backend.connected_outputs = scan.connected_outputs;
} }
let mut selections = scan let mut selections = scan
.selections .selections
.into_iter() .into_iter()
@@ -866,10 +986,12 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
} else { } else {
Ok(()) Ok(())
}; };
match result { match result {
Ok(()) => { Ok(()) => {
let old_geometry = state.space.output_geometry(&output.output); let old_geometry = state.space.output_geometry(&output.output);
output.mode = selection.mode; output.mode = selection.mode;
cancel_output_timers(&state.loop_handle, output);
output.settings = selection.settings.clone(); output.settings = selection.settings.clone();
let position = selection.settings.position.unwrap_or_else(|| { let position = selection.settings.position.unwrap_or_else(|| {
state state
@@ -878,6 +1000,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
.map(|g| [g.loc.x, g.loc.y]) .map(|g| [g.loc.x, g.loc.y])
.unwrap_or([0, 0]) .unwrap_or([0, 0])
}); });
output.output.change_current_state( output.output.change_current_state(
Some(OutputMode::from(output.mode)), Some(OutputMode::from(output.mode)),
Some(output_transform(output.settings.transform)), Some(output_transform(output.settings.transform)),
@@ -910,20 +1033,26 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
); );
} }
} }
output.damage_tracker = OutputDamageTracker::from_output(&output.output);
output.surface.reset_buffer_ages();
output.primary_commit = None;
output.capture_texture = None;
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
let clock = backend.presentation.entry((node, crtc)).or_default(); let clock = backend.presentation.entry((node, crtc)).or_default();
clock.set_refresh(OutputMode::from(output.mode).refresh); clock.set_refresh(OutputMode::from(output.mode).refresh);
clock.reset_timing(); clock.reset_timing();
} }
} }
Err(error) => { Err(error) => {
tracing::warn!(?crtc, %error, "display change failed; retaining current output") tracing::warn!(?crtc, %error, "display change failed; retaining current output")
} }
} }
selections.remove(&crtc); selections.remove(&crtc);
continue; continue;
} }
let unchanged = device.outputs.get(&crtc).is_some_and(|output| { let unchanged = device.outputs.get(&crtc).is_some_and(|output| {
selections.get(&crtc).is_some_and(|selection| { selections.get(&crtc).is_some_and(|selection| {
selection.connector.handle() == output.connector selection.connector.handle() == output.connector
@@ -931,6 +1060,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
&& selection.settings == output.settings && selection.settings == output.settings
}) })
}); });
if unchanged { if unchanged {
selections.remove(&crtc); selections.remove(&crtc);
continue; continue;
@@ -944,12 +1074,14 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
continue; continue;
} }
if let Some(output) = device.outputs.remove(&crtc) { if let Some(mut output) = device.outputs.remove(&crtc) {
cancel_output_timers(&state.loop_handle, &mut output);
state.display_handle.disable_global::<Ferese>(output.global); state.display_handle.disable_global::<Ferese>(output.global);
state.unregister_output(&output.output); state.unregister_output(&output.output);
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.presentation.remove(&(node, crtc)); backend.presentation.remove(&(node, crtc));
} }
tracing::info!(?node, ?crtc, "removed DRM output"); tracing::info!(?node, ?crtc, "removed DRM output");
} }
} }
@@ -969,6 +1101,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
.set_refresh(OutputMode::from(mode).refresh); .set_refresh(OutputMode::from(mode).refresh);
device.outputs.insert(crtc, output); device.outputs.insert(crtc, output);
} }
Err(error) => tracing::error!(?node, ?crtc, %error, "failed to add DRM output"), Err(error) => tracing::error!(?node, ?crtc, %error, "failed to add DRM output"),
} }
} }
@@ -997,9 +1130,12 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
info.enabled = true; info.enabled = true;
info.current_mode = Some(connected_mode_info(output.mode)); info.current_mode = Some(connected_mode_info(output.mode));
} }
continue; continue;
} }
if let Some(output) = device.outputs.remove(&crtc) {
if let Some(mut output) = device.outputs.remove(&crtc) {
cancel_output_timers(&state.loop_handle, &mut output);
state.display_handle.disable_global::<Ferese>(output.global); state.display_handle.disable_global::<Ferese>(output.global);
state.unregister_output(&output.output); state.unregister_output(&output.output);
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
@@ -1007,6 +1143,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
} }
} }
} }
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
for output in device.outputs.values() { for output in device.outputs.values() {
if let Some(info) = backend if let Some(info) = backend
@@ -1023,6 +1160,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
} }
} }
} }
restore_device(state, node, device); restore_device(state, node, device);
state.restore_output_focus(); state.restore_output_focus();
state.relayout(); state.relayout();
@@ -1040,17 +1178,20 @@ fn remove_device(state: &mut Ferese, node: DrmNode) {
let context = device.renderer.context_id().erased(); let context = device.renderer.context_id().erased();
state.wallpaper.forget_context(&context); state.wallpaper.forget_context(&context);
state.resize_snapshots.retain(|_, snapshot| snapshot.context != context); state.render.forget_context(&context);
for (crtc, output) in device.outputs { for (crtc, mut output) in device.outputs {
cancel_output_timers(&state.loop_handle, &mut output);
state.display_handle.disable_global::<Ferese>(output.global); state.display_handle.disable_global::<Ferese>(output.global);
state.unregister_output(&output.output); state.unregister_output(&output.output);
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.presentation.remove(&(node, crtc)); backend.presentation.remove(&(node, crtc));
} }
} }
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.connected_outputs.clear(); backend.connected_outputs.clear();
} }
tracing::info!(?node, "removed DRM device"); tracing::info!(?node, "removed DRM device");
} }
@@ -1066,6 +1207,10 @@ fn refresh_duration(state: &Ferese, node: DrmNode, crtc: crtc::Handle) -> Durati
.unwrap_or_else(|| Duration::from_millis(16)) .unwrap_or_else(|| Duration::from_millis(16))
} }
fn refresh_duration_from_mode(mode: DrmMode) -> Duration {
Duration::from_nanos(1_000_000_000_000 / OutputMode::from(mode).refresh.max(1) as u64)
}
fn animation_interval(state: &Ferese) -> Duration { fn animation_interval(state: &Ferese) -> Duration {
state state
.direct_backend .direct_backend
@@ -1088,6 +1233,7 @@ fn arm_animation_timer(state: &mut Ferese) {
if !needed { if !needed {
return; return;
} }
let deadline = state.animation_fallback_deadline(animation_interval(state)); let deadline = state.animation_fallback_deadline(animation_interval(state));
match state match state
.loop_handle .loop_handle
@@ -1101,7 +1247,30 @@ fn arm_animation_timer(state: &mut Ferese) {
if Instant::now() < deadline { if Instant::now() < deadline {
return TimeoutAction::ToInstant(deadline); return TimeoutAction::ToInstant(deadline);
} }
render_all(state);
let animating = state.advance_animations(Instant::now());
if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_active = animating;
}
let outputs = state
.direct_backend
.as_ref()
.into_iter()
.flat_map(|backend| backend.devices.iter())
.flat_map(|(node, device)| {
device
.outputs
.iter()
.map(|(crtc, output)| (*node, *crtc, output.output.clone()))
})
.collect::<Vec<_>>();
for (node, crtc, output) in outputs {
if state.output_has_animations(&output) {
request_frame(state, node, crtc);
}
}
if state if state
.direct_backend .direct_backend
.as_ref() .as_ref()
@@ -1110,9 +1279,11 @@ fn arm_animation_timer(state: &mut Ferese) {
return TimeoutAction::ToInstant(state.animation_fallback_deadline(animation_interval(state))); return TimeoutAction::ToInstant(state.animation_fallback_deadline(animation_interval(state)));
} }
} }
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_timer = None; backend.animation_timer = None;
} }
TimeoutAction::Drop TimeoutAction::Drop
}) { }) {
Ok(token) => state.direct_backend.as_mut().unwrap().animation_timer = Some(token), Ok(token) => state.direct_backend.as_mut().unwrap().animation_timer = Some(token),
@@ -1120,12 +1291,13 @@ fn arm_animation_timer(state: &mut Ferese) {
} }
} }
fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) { fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, target: Duration) {
if state.session_lock.sleeping { if state.session_lock.sleeping {
return; return;
} }
let now = Instant::now(); let now = Instant::now();
let interval = refresh_duration(state, node, crtc); let timestamp = monotonic_now();
let Some(output) = state let Some(output) = state
.direct_backend .direct_backend
.as_ref() .as_ref()
@@ -1136,15 +1308,22 @@ fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl
else { else {
return; return;
}; };
if output.callback_timer.is_some() {
if output.callback_timer.is_some() || output.frame_pending {
return; return;
} }
let identity = output.output.clone(); let identity = output.output.clone();
let deadline = output.callback_clock.deadline(now, interval); let Some(callback_at) = output.scheduler.callback_deadline(timestamp, target) else {
if deadline <= now { return;
};
if callback_at <= timestamp {
deliver_frame_callbacks(state, node, crtc, &identity); deliver_frame_callbacks(state, node, crtc, &identity);
return; return;
} }
let deadline = now + callback_at.saturating_sub(timestamp);
match state match state
.loop_handle .loop_handle
.insert_source(Timer::from_deadline(deadline), move |_, _, state| { .insert_source(Timer::from_deadline(deadline), move |_, _, state| {
@@ -1161,6 +1340,7 @@ fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl
output.callback_timer = Some(token); output.callback_timer = Some(token);
} }
} }
Err(error) => tracing::warn!(%error, "could not schedule DRM frame callbacks"), Err(error) => tracing::warn!(%error, "could not schedule DRM frame callbacks"),
} }
} }
@@ -1169,9 +1349,11 @@ fn deliver_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle
let Some(backend) = state.direct_backend.as_mut() else { let Some(backend) = state.direct_backend.as_mut() else {
return; return;
}; };
let Some(device) = backend.devices.get_mut(&node) else { let Some(device) = backend.devices.get_mut(&node) else {
return; return;
}; };
let Some(output) = device let Some(output) = device
.outputs .outputs
.get_mut(&crtc) .get_mut(&crtc)
@@ -1179,11 +1361,23 @@ fn deliver_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle
else { else {
return; return;
}; };
output.callback_timer = None; output.callback_timer = None;
if !backend.active || !device.drm.is_active() || state.session_lock.sleeping { if !backend.active || !device.drm.is_active() || state.session_lock.sleeping || output.frame_pending {
return; return;
} }
output.callback_clock.sent(Instant::now());
let timestamp = monotonic_now();
let Some(next) = output.scheduler.callback_deadline(timestamp, timestamp) else {
return;
};
if next > timestamp {
schedule_frame_callbacks(state, node, crtc, next);
return;
}
output.scheduler.callback_sent(timestamp);
send_frame_callbacks(state, identity); send_frame_callbacks(state, identity);
} }
@@ -1197,7 +1391,8 @@ fn send_frame_callbacks(state: &mut Ferese, output: &Output) {
.elements() .elements()
.filter(|window| { .filter(|window| {
state state
.window_ids .windows
.ids()
.get(*window) .get(*window)
.is_some_and(|id| state.window_belongs_to_output(*id, output)) .is_some_and(|id| state.window_belongs_to_output(*id, output))
}) })
@@ -1413,16 +1608,30 @@ fn create_direct_output(
let identity = connector_identity(drm, &connector); let identity = connector_identity(drm, &connector);
let drm_surface = drm.create_surface(crtc, mode, &[connector.handle()])?; let drm_surface = drm.create_surface(crtc, mode, &[connector.handle()])?;
let allocator = GbmAllocator::new(gbm.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT); let allocator = GbmAllocator::new(gbm.clone(), GbmBufferFlags::RENDERING | GbmBufferFlags::SCANOUT);
let surface = GbmBufferedSurface::new( let node = DrmNode::from_file(gbm.as_fd())?;
let render_node = node
.node_with_type(NodeType::Render)
.and_then(Result::ok)
.unwrap_or(node);
let mut surface = DrmCompositor::new(
OutputModeSource::Static {
size: OutputMode::from(mode).size,
scale: settings.scale.into(),
transform: output_transform(settings.transform),
},
drm_surface, drm_surface,
None,
allocator, allocator,
&[Fourcc::Argb8888, Fourcc::Abgr8888], GbmFramebufferExporter::new(gbm.clone(), Some(render_node)),
[Fourcc::Argb8888, Fourcc::Abgr8888],
renderer.dmabuf_formats(), renderer.dmabuf_formats(),
drm.cursor_size(),
Some(gbm.clone()),
)?; )?;
// Publish only after DRM/GBM creation succeeds; failed activation must not // Publish only after DRM/GBM creation succeeds; failed activation must not
// leave a phantom output/workspace that could receive evacuated windows. // leave a phantom output/workspace that could receive evacuated windows.
let (output, global) = create_output(state, &connector, mode, identity, &settings); let (output, global) = create_output(state, &connector, mode, identity, &settings);
let damage_tracker = OutputDamageTracker::from_output(&output); surface.set_output_mode_source((&output).into());
let render_metrics = RenderMetrics::from_environment(output.name()); let render_metrics = RenderMetrics::from_environment(output.name());
tracing::info!(?crtc, connector = %connector, "initialized DRM output"); tracing::info!(?crtc, connector = %connector, "initialized DRM output");
@@ -1434,11 +1643,13 @@ fn create_direct_output(
output, output,
global, global,
surface, surface,
damage_tracker, primary_commit: None,
capture_texture: None,
render_metrics, render_metrics,
frame_pending: false, frame_pending: false,
power_off: false, power_off: false,
callback_clock: Default::default(), scheduler: crate::frame_scheduler::FrameScheduler::new(refresh_duration_from_mode(mode)),
render_timer: None,
callback_timer: None, callback_timer: None,
lock_frame_pending: false, lock_frame_pending: false,
}) })
@@ -1540,9 +1751,15 @@ impl DirectBackendState {
time: DrmEventTime, time: DrmEventTime,
sequence: u32, sequence: u32,
) -> Option<Duration> { ) -> Option<Duration> {
self.presentation let missed = self
.get_mut(&(node, crtc)) .devices
.and_then(|clock| clock.record(time, sequence)) .get(&node)
.and_then(|device| device.outputs.get(&crtc))
.map_or(0, |output| output.scheduler.missed_deadlines);
self.presentation.get_mut(&(node, crtc)).and_then(|clock| {
clock.missed_deadlines = missed;
clock.record(time, sequence)
})
} }
} }
@@ -1566,21 +1783,6 @@ impl PresentationClock {
self.last_presentation = Some(time); self.last_presentation = Some(time);
self.presented_frames = self.presented_frames.saturating_add(1); self.presented_frames = self.presented_frames.saturating_add(1);
if let (Some(delta), Some(refresh)) = (delta, self.refresh_interval)
&& delta > refresh + refresh / 2
{
let elapsed_frames = (delta.as_nanos() / refresh.as_nanos()).max(1) as u64;
let missed = elapsed_frames.saturating_sub(1);
self.missed_deadlines = self.missed_deadlines.saturating_add(missed);
tracing::warn!(
sequence,
?delta,
?refresh,
missed,
total = self.missed_deadlines,
"missed DRM presentation deadline"
);
}
delta delta
} }
} }
@@ -1639,14 +1841,14 @@ mod tests {
} }
#[test] #[test]
fn presentation_clock_counts_missed_deadlines() { fn presentation_clock_does_not_count_idle_gaps_as_missed_deadlines() {
let mut clock = PresentationClock::default(); let mut clock = PresentationClock::default();
clock.set_refresh(60_000); clock.set_refresh(60_000);
clock.record(DrmEventTime::Monotonic(Duration::ZERO), 1); clock.record(DrmEventTime::Monotonic(Duration::ZERO), 1);
clock.record(DrmEventTime::Monotonic(Duration::from_millis(50)), 2); clock.record(DrmEventTime::Monotonic(Duration::from_millis(50)), 2);
assert_eq!(clock.missed_deadlines, 2); assert_eq!(clock.missed_deadlines, 0);
} }
#[test] #[test]
+153
View File
@@ -0,0 +1,153 @@
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::element::RenderElement;
use smithay::backend::renderer::gles::{GlesError, GlesRenderer, GlesTarget, GlesTexture};
use smithay::backend::renderer::{Bind, Offscreen, Texture};
use smithay::output::Output;
use crate::Ferese;
use crate::render::{redraw_output, sampled_output_elements};
use crate::state::FrameScene;
pub(super) fn capture_output(
state: &mut Ferese,
renderer: &mut GlesRenderer,
texture: &mut Option<GlesTexture>,
output: &Output,
scene: &FrameScene,
) -> Result<(), GlesError> {
for include_cursor in [true, false] {
if !state.has_pending_screencopy(output, include_cursor) {
continue;
}
// Sample the same scene into an independent target: the displayed
// primary buffer may omit both a scanned-out client and hardware cursor.
let elements = sampled_output_elements(state, renderer, output, include_cursor, scene);
let target = render_capture(renderer, texture, output, &elements)?;
state.process_screencopies(renderer, &target, output, include_cursor);
}
Ok(())
}
fn render_capture<'a, E: RenderElement<GlesRenderer>>(
renderer: &mut GlesRenderer,
texture: &'a mut Option<GlesTexture>,
output: &Output,
elements: &[E],
) -> Result<GlesTarget<'a>, GlesError> {
let size = output.current_mode().expect("output has a mode").size;
if texture
.as_ref()
.is_none_or(|texture| texture.size() != (size.w, size.h).into())
{
*texture = Some(Offscreen::<GlesTexture>::create_buffer(
renderer,
Fourcc::Abgr8888,
(size.w, size.h).into(),
)?);
}
// Rebinding for each capture restores GL state after readback.
let mut target = renderer.bind(texture.as_mut().expect("capture target allocated"))?;
redraw_output(renderer, &mut target, output, elements)?;
Ok(target)
}
#[cfg(test)]
mod tests {
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
use smithay::backend::renderer::element::{Id, Kind};
use smithay::backend::renderer::utils::CommitCounter;
use smithay::backend::renderer::{Color32F, ExportMem};
use smithay::output::{Mode, PhysicalProperties, Subpixel};
use smithay::utils::{Physical, Rectangle, Transform};
use super::*;
#[test]
#[ignore = "requires an EGL rendering device"]
fn capture_recomposes_cursor_without_touching_the_display_target() {
let device = EGLDevice::enumerate().unwrap().last().expect("an EGL device");
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let output = Output::new(
"capture".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (32, 24).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
None,
None,
);
let background = SolidColorRenderElement::new(
Id::new(),
Rectangle::<i32, Physical>::from_size((32, 24).into()),
CommitCounter::default(),
Color32F::new(1.0, 0.0, 0.0, 1.0),
Kind::Unspecified,
);
let cursor = SolidColorRenderElement::new(
Id::new(),
Rectangle::new((0, 0).into(), (4, 4).into()),
CommitCounter::default(),
Color32F::new(0.0, 1.0, 0.0, 1.0),
Kind::Cursor,
);
let mut displayed = None;
let mut capture = None;
{
let _ = render_capture(
&mut renderer,
&mut displayed,
&output,
std::slice::from_ref(&background),
)
.unwrap();
}
for (elements, expected) in [
(vec![cursor, background.clone()], [0, 255, 0, 255]),
(vec![background], [255, 0, 0, 255]),
] {
let target = render_capture(&mut renderer, &mut capture, &output, &elements).unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size((32, 24).into()), Fourcc::Abgr8888)
.unwrap();
let pixels = renderer.map_texture(&mapping).unwrap();
assert_eq!(&pixels[..4], &expected);
assert_eq!(&pixels[(12 * 32 + 16) * 4..(12 * 32 + 16) * 4 + 4], &[255, 0, 0, 255]);
drop(target);
let target = renderer.bind(displayed.as_mut().unwrap()).unwrap();
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size((32, 24).into()), Fourcc::Abgr8888)
.unwrap();
assert_eq!(&renderer.map_texture(&mapping).unwrap()[..4], &[255, 0, 0, 255]);
}
output.change_current_state(
Some(Mode {
size: (48, 16).into(),
refresh: 60_000,
}),
Some(Transform::_90),
None,
None,
);
let _ = render_capture::<SolidColorRenderElement>(&mut renderer, &mut capture, &output, &[]).unwrap();
assert_eq!(capture.as_ref().unwrap().size(), (48, 16).into());
}
}
+39
View File
@@ -0,0 +1,39 @@
use smithay::backend::drm::compositor::FrameFlags;
pub(super) fn frame_flags(fullscreen: bool, animating: bool, locked: bool) -> FrameFlags {
let mut flags = FrameFlags::ALLOW_CURSOR_PLANE_SCANOUT;
if fullscreen && !animating && !locked {
flags |= FrameFlags::ALLOW_PRIMARY_PLANE_SCANOUT;
}
// Overlay planes stay disabled until their effects and capture behavior
// have been validated on hardware independently of fullscreen scanout.
flags
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_settled_unlocked_fullscreen_frames_allow_primary_scanout() {
for fullscreen in [false, true] {
for animating in [false, true] {
for locked in [false, true] {
let flags = frame_flags(fullscreen, animating, locked);
assert_eq!(
flags.contains(FrameFlags::ALLOW_PRIMARY_PLANE_SCANOUT),
fullscreen && !animating && !locked,
);
assert!(flags.contains(FrameFlags::ALLOW_CURSOR_PLANE_SCANOUT));
assert!(!flags.intersects(
FrameFlags::ALLOW_PRIMARY_PLANE_SCANOUT_ANY
| FrameFlags::ALLOW_OVERLAY_PLANE_SCANOUT
| FrameFlags::SKIP_CURSOR_ONLY_UPDATES
));
}
}
}
}
}
@@ -0,0 +1,141 @@
use super::*;
use crate::frame_scheduler::FramePlan;
pub(super) fn monotonic_now() -> Duration {
smithay::utils::Clock::<Monotonic>::new().now().into()
}
pub(super) fn cancel_output_timers(
handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
output: &mut DirectOutput,
) {
for token in [output.render_timer.take(), output.callback_timer.take()]
.into_iter()
.flatten()
{
handle.remove(token);
}
output.scheduler.reset(Duration::from_nanos(
1_000_000_000_000 / OutputMode::from(output.mode).refresh.max(1) as u64,
));
}
pub(super) fn request_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
if state.session_lock.sleeping {
sleep_locked_outputs(state);
return;
}
let now = monotonic_now();
let Some(output) = state
.direct_backend
.as_mut()
.filter(|backend| backend.active)
.and_then(|backend| backend.devices.get_mut(&node))
.filter(|device| device.drm.is_active())
.and_then(|device| device.outputs.get_mut(&crtc))
else {
return;
};
if output.power_off {
return;
}
output.scheduler.request(now);
arm_frame(state, node, crtc);
}
pub(super) fn arm_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
let now = monotonic_now();
let Some(output) = state
.direct_backend
.as_mut()
.filter(|backend| backend.active)
.and_then(|backend| backend.devices.get_mut(&node))
.filter(|device| device.drm.is_active())
.and_then(|device| device.outputs.get_mut(&crtc))
else {
return;
};
if output.render_timer.is_some() || output.frame_pending || output.power_off || state.session_lock.sleeping {
return;
}
let Some(plan) = output.scheduler.plan(now) else {
return;
};
let identity = output.output.clone();
// Even an immediate repaint is queued. Bursts of input and surface commits
// in the same event-loop dispatch are folded into one scene build.
let deadline = Instant::now() + plan.render_at.saturating_sub(now);
match state
.loop_handle
.insert_source(Timer::from_deadline(deadline), move |_, _, state| {
dispatch_frame(state, node, crtc, &identity);
TimeoutAction::Drop
}) {
Ok(token) => {
if let Some(output) = state
.direct_backend
.as_mut()
.and_then(|backend| backend.devices.get_mut(&node))
.and_then(|device| device.outputs.get_mut(&crtc))
{
output.render_timer = Some(token);
}
}
Err(error) => tracing::warn!(%error, "could not schedule DRM frame"),
}
}
fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identity: &Output) {
let Some(output) = state
.direct_backend
.as_mut()
.filter(|backend| backend.active)
.and_then(|backend| backend.devices.get_mut(&node))
.filter(|device| device.drm.is_active())
.and_then(|device| device.outputs.get_mut(&crtc))
.filter(|output| &output.output == identity)
else {
return;
};
output.render_timer = None;
if output.frame_pending || output.power_off || state.session_lock.sleeping {
return;
}
let Some(plan) = output.scheduler.begin() else {
return;
};
let animating = state.advance_animations(Instant::now());
if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_active = animating;
}
render_output(state, node, crtc, plan);
if state.output_has_animations(identity) {
request_frame(state, node, crtc);
}
arm_animation_timer(state);
}
pub(super) fn record_frame_schedule(output: &mut DirectOutput, plan: FramePlan, cost: Duration, submitted: bool) {
output.scheduler.rendered(plan, monotonic_now(), cost, submitted);
if std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some() {
let started = monotonic_now().saturating_sub(cost);
tracing::debug!(target: "ferese::render", output = %output.output.name(),
render_target_us = plan.render_at.as_micros(), presentation_target_us = plan.present_at.as_micros(),
target_phase_known = plan.phase_known,
render_started_us = started.as_micros(), timer_lateness_us = started.saturating_sub(plan.render_at).as_micros(),
render_us = cost.as_micros(), submitted, "DRM frame schedule");
}
}
+40 -3
View File
@@ -438,6 +438,43 @@ mod tests {
assert!(Config::parse_source("workspaces {\n auto-back-and-forth \"yes\"\n}\n").is_err()); assert!(Config::parse_source("workspaces {\n auto-back-and-forth \"yes\"\n}\n").is_err());
} }
#[test]
fn focus_history_actions_are_bindable_and_have_defaults() {
let defaults = Config::default().runtime_config().unwrap();
for action in [
BindingAction::FocusLastWindow,
BindingAction::FocusMru(false),
BindingAction::FocusMru(true),
] {
assert!(defaults.bindings.iter().any(|binding| binding.action == action));
}
for (name, action) in [
("focus-last-window", BindingAction::FocusLastWindow),
("focus-mru-next", BindingAction::FocusMru(false)),
("focus-mru-previous", BindingAction::FocusMru(true)),
] {
assert_eq!(parse_action(name, None, &HashMap::new()).unwrap(), action);
assert!(parse_action(name, Some("left"), &HashMap::new()).is_err());
}
let bindings = &defaults.bindings;
assert!(bindings.iter().any(|binding| binding.matches(
23_u32.into(),
&[keysyms::KEY_Tab],
false,
false,
true,
false
) && binding.action == BindingAction::FocusMru(false)));
assert!(bindings.iter().any(|binding| binding.matches(
23_u32.into(),
&[keysyms::KEY_Tab],
false,
false,
true,
true
) && binding.action == BindingAction::FocusMru(true)));
}
#[test] #[test]
fn packaged_and_custom_kdl_pass_runtime_validation() { fn packaged_and_custom_kdl_pass_runtime_validation() {
Config::parse_source(include_str!("../../packaging/config.kdl")) Config::parse_source(include_str!("../../packaging/config.kdl"))
@@ -888,7 +925,7 @@ mod tests {
let input = config.input_settings().unwrap(); let input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap(); let bindings = config.bindings(&input).unwrap();
assert_eq!(bindings.len(), 50); assert_eq!(bindings.len(), 53);
for (shift, action) in [ for (shift, action) in [
(false, BindingAction::ToggleMaximized), (false, BindingAction::ToggleMaximized),
(true, BindingAction::ToggleFullscreen), (true, BindingAction::ToggleFullscreen),
@@ -931,14 +968,14 @@ mod tests {
let input = replaced.input_settings().unwrap(); let input = replaced.input_settings().unwrap();
let bindings = replaced.bindings(&input).unwrap(); let bindings = replaced.bindings(&input).unwrap();
assert_eq!(bindings.len(), 50); assert_eq!(bindings.len(), 53);
assert!(bindings.iter().any(|binding| { assert!(bindings.iter().any(|binding| {
binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()]) binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()])
})); }));
let input = unbound.input_settings().unwrap(); let input = unbound.input_settings().unwrap();
let bindings = unbound.bindings(&input).unwrap(); let bindings = unbound.bindings(&input).unwrap();
assert_eq!(bindings.len(), 49); assert_eq!(bindings.len(), 52);
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close)); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
} }
+20
View File
@@ -267,6 +267,14 @@ pub(super) fn parse_action(
no_argument()?; no_argument()?;
Ok(BindingAction::WorkspaceBackAndForth) Ok(BindingAction::WorkspaceBackAndForth)
} }
"focus-last-window" => {
no_argument()?;
Ok(BindingAction::FocusLastWindow)
}
"focus-mru-next" | "focus-mru-previous" => {
no_argument()?;
Ok(BindingAction::FocusMru(action == "focus-mru-previous"))
}
"spawn" => { "spawn" => {
let command = required_argument()?; let command = required_argument()?;
let argv = commands let argv = commands
@@ -369,6 +377,9 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
binding("Super+Tab", "toggle-overview", None), binding("Super+Tab", "toggle-overview", None),
binding("Super+F1", "toggle-keybinding-guide", None), binding("Super+F1", "toggle-keybinding-guide", None),
binding("Super+Escape", "workspace-back-and-forth", None), binding("Super+Escape", "workspace-back-and-forth", None),
binding("Super+BackSpace", "focus-last-window", None),
binding("Alt+Tab", "focus-mru-next", None),
binding("Alt+Shift+Tab", "focus-mru-previous", None),
binding("Super+Shift+E", "exit", None), binding("Super+Shift+E", "exit", None),
]; ];
@@ -433,6 +444,8 @@ pub enum BindingAction {
Close, Close,
Exit, Exit,
Focus(ferese_layout::Direction), Focus(ferese_layout::Direction),
FocusLastWindow,
FocusMru(bool),
Move(ferese_layout::Direction), Move(ferese_layout::Direction),
Resize(ferese_layout::Direction), Resize(ferese_layout::Direction),
SwitchWorkspace(u8), SwitchWorkspace(u8),
@@ -534,6 +547,13 @@ impl Binding {
BindingAction::Close => "Close window".into(), BindingAction::Close => "Close window".into(),
BindingAction::Exit => "Log out (with confirmation)".into(), BindingAction::Exit => "Log out (with confirmation)".into(),
BindingAction::Focus(direction) => format!("Focus window {direction:?}"), BindingAction::Focus(direction) => format!("Focus window {direction:?}"),
BindingAction::FocusLastWindow => "Return to last focused window".into(),
BindingAction::FocusMru(reverse) => if *reverse {
"Previous window in focus history"
} else {
"Next window in focus history"
}
.into(),
BindingAction::Move(direction) => format!("Move window {direction:?}"), BindingAction::Move(direction) => format!("Move window {direction:?}"),
BindingAction::Resize(direction) => format!("Resize window {direction:?}"), BindingAction::Resize(direction) => format!("Resize window {direction:?}"),
BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"), BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"),
+12 -1
View File
@@ -18,7 +18,7 @@ pub(crate) fn target(
} }
/// A bounded, interruption-safe opacity transition; it never overshoots. /// A bounded, interruption-safe opacity transition; it never overshoots.
#[derive(Debug)] #[derive(Clone, Debug)]
pub(crate) struct DimAnimation { pub(crate) struct DimAnimation {
pub current: f64, pub current: f64,
start: f64, start: f64,
@@ -27,6 +27,14 @@ pub(crate) struct DimAnimation {
} }
impl DimAnimation { impl DimAnimation {
pub(crate) fn is_animating(&self) -> bool {
self.current != self.target
}
pub(crate) fn predict(&mut self, delta: Duration, duration_ms: f64) {
self.advance(self.target, delta, duration_ms);
}
pub fn new(value: f64) -> Self { pub fn new(value: f64) -> Self {
Self { Self {
current: value, current: value,
@@ -42,16 +50,19 @@ impl DimAnimation {
self.target = target; self.target = target;
self.elapsed = 0.0; self.elapsed = 0.0;
} }
if duration_ms == 0.0 { if duration_ms == 0.0 {
self.current = target; self.current = target;
return false; return false;
} }
self.elapsed += delta.as_secs_f64() * 1000.0; self.elapsed += delta.as_secs_f64() * 1000.0;
let progress = (self.elapsed / duration_ms).min(1.0); let progress = (self.elapsed / duration_ms).min(1.0);
if progress == 1.0 { if progress == 1.0 {
self.current = target; self.current = target;
return false; return false;
} }
let eased = progress * progress * (3.0 - 2.0 * progress); let eased = progress * progress * (3.0 - 2.0 * progress);
self.current = self.start + (target - self.start) * eased; self.current = self.start + (target - self.start) * eased;
self.current != target self.current != target
+25 -12
View File
@@ -56,7 +56,7 @@ struct SurfaceEffectsState {
attached: AtomicBool, attached: AtomicBool,
generation: AtomicU64, generation: AtomicU64,
role: Mutex<Option<SemanticRole>>, role: Mutex<Option<SemanticRole>>,
regions: Mutex<Option<Vec<[i32; 5]>>>, regions: Mutex<Option<Vec<[f64; 5]>>>,
opacity: Mutex<u32>, opacity: Mutex<u32>,
presentation_supported: AtomicBool, presentation_supported: AtomicBool,
dismissing: AtomicBool, dismissing: AtomicBool,
@@ -183,7 +183,7 @@ impl Dispatch<FereseSurfaceMaterialV1, SurfaceEffectsUserData> for Ferese {
} }
} }
pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[i32; 5]>> { pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[f64; 5]>> {
with_states(surface, |states| { with_states(surface, |states| {
states states
.data_map .data_map
@@ -195,7 +195,7 @@ pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[i32; 5]>> {
}) })
} }
fn decode_regions(bytes: &[u8]) -> Option<Vec<[i32; 5]>> { fn decode_regions(bytes: &[u8]) -> Option<Vec<[f64; 5]>> {
if !bytes.len().is_multiple_of(20) || bytes.len() > 32 * 20 { if !bytes.len().is_multiple_of(20) || bytes.len() > 32 * 20 {
return None; return None;
} }
@@ -204,9 +204,12 @@ fn decode_regions(bytes: &[u8]) -> Option<Vec<[i32; 5]>> {
.0 .0
.iter() .iter()
.map(|tuple| { .map(|tuple| {
let values: [i32; 5] = std::array::from_fn(|index| i32::from_ne_bytes(tuple.as_chunks::<4>().0[index])); let values: [f64; 5] = std::array::from_fn(|i| f64::from(f32::from_ne_bytes(tuple.as_chunks::<4>().0[i])));
(values[2] > 0 && values[3] > 0 && values[4] >= 0 && values.iter().all(|v| v.abs_diff(0) <= 32768)) (values[2] > 0.0
.then_some(values) && values[3] > 0.0
&& values[4] >= 0.0
&& values.iter().all(|v| v.is_finite() && v.abs() <= 32768.0))
.then_some(values)
}) })
.collect() .collect()
} }
@@ -439,15 +442,25 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn rounded_regions_are_bounded_and_validated() { fn fractional_regions_validate_all_wire_values() {
let region = [12_i32, 8, 120, 48, 11]; let region = [0.25f32, -0.75, 100.5, 40.25, 13.5];
let bytes: Vec<_> = region.into_iter().flat_map(i32::to_ne_bytes).collect(); let encode = |r: [f32; 5]| r.into_iter().flat_map(f32::to_ne_bytes).collect::<Vec<_>>();
assert_eq!(decode_regions(&bytes), Some(vec![region])); let bytes = encode(region);
assert_eq!(decode_regions(&bytes), Some(vec![region.map(f64::from)]));
assert_eq!(decode_regions(&[]), Some(vec![])); assert_eq!(decode_regions(&[]), Some(vec![]));
assert!(decode_regions(&bytes[..19]).is_none()); assert!(decode_regions(&bytes[..19]).is_none());
assert!(decode_regions(&bytes.repeat(33)).is_none()); assert!(decode_regions(&bytes.repeat(33)).is_none());
for invalid in [[0_i32, 0, 0, 48, 11], [0, 0, 120, 48, -1], [i32::MIN, 0, 120, 48, 11]] { for index in 0..5 {
assert!(decode_regions(&invalid.into_iter().flat_map(i32::to_ne_bytes).collect::<Vec<_>>()).is_none()); for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 32769.0] {
let mut bad = region;
bad[index] = invalid;
assert!(decode_regions(&encode(bad)).is_none());
}
}
for (index, value) in [(2, 0.0), (3, -1.0), (4, -0.25)] {
let mut bad = region;
bad[index] = value;
assert!(decode_regions(&encode(bad)).is_none());
} }
} }
+402
View File
@@ -0,0 +1,402 @@
//! Fixed-refresh scheduling policy, independent of DRM and the event loop.
use std::time::Duration;
const MIN_RENDER: Duration = Duration::from_millis(2);
const TIMER_MARGIN: Duration = Duration::from_millis(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct FramePlan {
pub render_at: Duration,
pub present_at: Duration,
pub requested_at: Duration,
pub phase_known: bool,
}
#[derive(Debug)]
pub(crate) struct FrameScheduler {
interval: Duration,
last_presented: Option<Duration>,
last_target: Option<Duration>,
requested_at: Option<Duration>,
scheduled: Option<FramePlan>,
submitted: Option<FramePlan>,
last_callback: Option<Duration>,
render_estimate: Duration,
render_variance: Duration,
last_sample: Option<Duration>,
extra_margin: Duration,
pub missed_deadlines: u64,
}
impl FrameScheduler {
pub fn new(interval: Duration) -> Self {
Self {
interval: interval.max(Duration::from_micros(1)),
last_presented: None,
last_target: None,
requested_at: None,
scheduled: None,
submitted: None,
last_callback: None,
render_estimate: MIN_RENDER,
render_variance: Duration::ZERO,
last_sample: None,
extra_margin: Duration::ZERO,
missed_deadlines: 0,
}
}
pub fn reset(&mut self, interval: Duration) {
let missed = self.missed_deadlines;
*self = Self::new(interval);
self.missed_deadlines = missed;
}
pub fn request(&mut self, now: Duration) {
self.requested_at.get_or_insert(now);
}
fn budget(&self, now: Duration) -> Duration {
let idle = self
.last_presented
.is_none_or(|last| now.saturating_sub(last) >= self.interval * 100);
if idle {
// A cold GPU is less predictable. Start immediately on idle wakeup.
self.interval
} else {
(self.render_estimate + self.render_variance * 2 + TIMER_MARGIN + self.extra_margin).min(self.interval)
}
}
fn phase_at_or_after(&self, time: Duration) -> Duration {
let Some(last) = self.last_presented else {
return time;
};
let count = time
.saturating_sub(last)
.as_nanos()
.div_ceil(self.interval.as_nanos())
.max(1);
last + self.interval.mul_f64(count as f64)
}
pub fn plan(&mut self, now: Duration) -> Option<FramePlan> {
if self.submitted.is_some() {
return None;
}
if let Some(plan) = self.scheduled {
return Some(plan);
}
let requested_at = self.requested_at?;
if self.last_presented.is_some_and(|last| last > now + self.interval) {
self.last_presented = None;
self.last_target = None;
}
let budget = self.budget(now);
let idle = self
.last_presented
.is_none_or(|last| now.saturating_sub(last) >= self.interval * 100);
// With one in-flight GBM frame, submitting immediately can hit the
// nearest vblank even when the conservative budget is larger than
// the remaining time. Do not deliberately skip that presentation.
let next = if self.last_presented.is_some() {
now + Duration::from_nanos(1)
} else {
now + self.interval
};
let earliest = next.max(self.last_target.map_or(next, |last| last + self.interval));
let present_at = self.phase_at_or_after(earliest);
let plan = FramePlan {
render_at: if idle {
now
} else {
present_at.saturating_sub(budget).max(now)
},
present_at,
requested_at,
phase_known: self.last_presented.is_some(),
};
self.scheduled = Some(plan);
Some(plan)
}
pub fn begin(&mut self) -> Option<FramePlan> {
let plan = self.scheduled.take()?;
self.requested_at = None;
self.last_target = Some(plan.present_at);
Some(plan)
}
pub fn rendered(&mut self, plan: FramePlan, now: Duration, cost: Duration, submitted: bool) {
if submitted {
self.submitted = Some(plan);
let elapsed = self
.last_sample
.map_or(Duration::from_millis(500), |last| now.saturating_sub(last));
self.last_sample = Some(now);
let sample = cost.max(MIN_RENDER);
let ratio = (elapsed.as_secs_f64() / 0.5).clamp(0.01, 1.0);
let deviation = sample.saturating_sub(self.render_estimate);
let variance_ratio = (elapsed.as_secs_f64() / 6.0).clamp(0.001, 0.1);
self.render_variance = self.render_variance.mul_f64(1.0 - variance_ratio).max(deviation);
self.render_estimate = self.render_estimate.mul_f64(1.0 - ratio) + sample.mul_f64(ratio);
}
}
pub fn presented(&mut self, timestamp: Duration) -> Option<FramePlan> {
// Regressive or implausibly early events must not shift the phase backward.
if self.last_presented.is_some_and(|last| timestamp < last) {
self.last_presented = None;
self.last_target = None;
return self.submitted.take();
}
self.last_presented = Some(timestamp);
let plan = self.submitted.take()?;
// The first submission has only an estimated phase. Retiring it on
// the actual refresh must not carry that estimate into the next frame.
self.last_target = Some(timestamp);
let late = timestamp.saturating_sub(plan.present_at);
if plan.phase_known && late > self.interval / 2 {
let missed = (late.as_nanos() + self.interval.as_nanos() / 2) / self.interval.as_nanos();
self.missed_deadlines = self.missed_deadlines.saturating_add(missed as u64);
// CPU submission timings may omit asynchronous GPU work. Increase
// the margin promptly after a real miss; relax it slowly afterward.
self.extra_margin = (self.extra_margin + TIMER_MARGIN).min(self.interval / 2);
} else {
self.extra_margin = self.extra_margin.mul_f64(0.98);
}
Some(plan)
}
pub fn retire_unknown(&mut self) {
self.submitted = None;
self.last_presented = None;
self.last_target = None;
}
pub fn forecast_time(&self, now: Duration, plan: FramePlan) -> Option<Duration> {
// Before the first real page flip there is no reliable refresh phase.
// A late timer still samples the next physical presentation, while
// deadline metrics retain the original scheduled target.
plan.phase_known.then(|| {
plan.present_at
.max(self.phase_at_or_after(now + Duration::from_nanos(1)))
})
}
pub fn callback_deadline(&self, now: Duration, target: Duration) -> Option<Duration> {
// A timer from a callback-only update must yield to a later submission.
// Its page flip will release callbacks using the observed refresh phase.
if self.submitted.is_some() {
return None;
}
let tolerance = Duration::from_micros(500).min(self.interval / 8);
if target <= now
&& self
.last_callback
.is_none_or(|last| last + self.interval <= now + tolerance)
{
return Some(now);
}
let earliest = self
.last_callback
.map_or(now, |last| last + self.interval)
.max(target)
.max(now);
Some(self.phase_at_or_after(earliest))
}
pub fn callback_sent(&mut self, now: Duration) {
// Record the refresh slot, not delayed event-loop dispatch time.
// Otherwise a callback delivered a few microseconds late can make
// the next refresh look too early and halve a client's frame rate.
self.last_callback = Some(self.last_presented.map_or(now, |last| {
let tolerance = Duration::from_micros(500).min(self.interval / 8);
let slots = (now.saturating_sub(last) + tolerance).as_nanos() / self.interval.as_nanos();
last + self.interval.mul_f64(slots as f64)
}));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ms(value: u64) -> Duration {
Duration::from_millis(value)
}
#[test]
fn requests_coalesce_without_postponing_the_frame_and_wait_for_retirement() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
assert_eq!((plan.render_at, plan.present_at), (ms(107), ms(110)));
scheduler.request(ms(102));
assert_eq!(scheduler.plan(ms(102)), Some(plan));
assert_eq!(scheduler.begin(), Some(plan));
scheduler.rendered(plan, ms(108), ms(1), true);
scheduler.request(ms(109));
assert_eq!(scheduler.plan(ms(109)), None);
scheduler.presented(ms(110));
assert_eq!(scheduler.plan(ms(110)).unwrap().present_at, ms(120));
}
#[test]
fn idle_wakeup_starts_early_and_is_not_a_missed_deadline() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(10_100));
let plan = scheduler.plan(ms(10_100)).unwrap();
assert_eq!(plan.render_at, ms(10_100));
scheduler.begin();
scheduler.rendered(plan, ms(10_101), ms(1), true);
scheduler.presented(ms(10_110));
assert_eq!(scheduler.missed_deadlines, 0);
}
#[test]
fn misses_measure_the_submitted_target_and_expand_the_budget() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(108), ms(1), true);
assert_eq!(scheduler.presented(ms(130)), Some(plan));
assert_eq!(scheduler.missed_deadlines, 2);
assert_eq!(scheduler.budget(ms(131)), ms(4));
assert_eq!(scheduler.presented(ms(129)), None);
}
#[test]
fn first_retirement_aligns_the_next_frame_to_the_observed_refresh() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.request(ms(100));
let plan = scheduler.plan(ms(100)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(102), ms(2), true);
scheduler.presented(ms(103));
scheduler.request(ms(104));
assert_eq!(scheduler.plan(ms(104)).unwrap().present_at, ms(113));
}
#[test]
fn no_damage_attempts_and_callbacks_do_not_spin_within_a_refresh() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(108), ms(1), false);
scheduler.callback_sent(ms(110));
scheduler.request(ms(108));
assert_eq!(scheduler.plan(ms(108)).unwrap().present_at, ms(120));
assert_eq!(scheduler.callback_deadline(ms(111), ms(110)).unwrap(), ms(120));
}
#[test]
fn callback_only_timer_waits_for_a_later_submission_even_when_it_misses_its_target() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let unchanged = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(unchanged, ms(108), ms(1), false);
let callback_at = scheduler.callback_deadline(ms(108), unchanged.present_at).unwrap();
assert_eq!(callback_at, ms(110));
scheduler.request(ms(109));
let changed = scheduler.plan(ms(109)).unwrap();
scheduler.begin();
scheduler.rendered(changed, ms(118), ms(1), true);
// The old timer is dispatched late, after a new frame was queued.
// Neither that timer nor the predicted refresh can release callbacks.
assert_eq!(scheduler.callback_deadline(ms(118), callback_at), None);
assert_eq!(scheduler.callback_deadline(ms(120), changed.present_at), None);
assert_eq!(scheduler.callback_deadline(ms(129), callback_at), None);
scheduler.presented(ms(130));
assert_eq!(scheduler.callback_deadline(ms(130), ms(130)), Some(ms(130)));
scheduler.callback_sent(ms(130));
assert_eq!(scheduler.callback_deadline(ms(130), callback_at), Some(ms(140)));
}
#[test]
fn outputs_keep_independent_phases_and_reset_cancels_obsolete_work() {
let mut fast = FrameScheduler::new(ms(4));
let mut slow = FrameScheduler::new(ms(16));
for scheduler in [&mut fast, &mut slow] {
scheduler.presented(ms(100));
scheduler.request(ms(101));
}
assert_eq!(fast.plan(ms(101)).unwrap().present_at, ms(104));
assert_eq!(slow.plan(ms(101)).unwrap().present_at, ms(116));
fast.reset(ms(8));
assert_eq!(fast.plan(ms(102)), None);
}
#[test]
fn callback_dispatch_jitter_does_not_skip_a_refresh_or_duplicate_a_callback() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.callback_sent(ms(100) + Duration::from_micros(80));
scheduler.presented(ms(110));
let now = ms(110) + Duration::from_micros(10);
assert_eq!(scheduler.callback_deadline(now, now).unwrap(), now);
scheduler.callback_sent(now);
assert_eq!(scheduler.callback_deadline(now, now).unwrap(), ms(120));
}
#[test]
fn unknown_retirement_and_regressive_timestamps_do_not_strand_pending_work() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(101));
let plan = scheduler.plan(ms(101)).unwrap();
scheduler.begin();
scheduler.rendered(plan, ms(108), ms(1), true);
scheduler.request(ms(109));
assert_eq!(scheduler.presented(ms(90)), Some(plan));
assert!(scheduler.plan(ms(110)).is_some());
scheduler.retire_unknown();
assert!(scheduler.plan(ms(111)).is_some());
}
#[test]
fn cold_gpu_and_late_dispatch_do_not_predict_a_past_or_deliberately_skipped_frame() {
let mut scheduler = FrameScheduler::new(ms(10));
scheduler.presented(ms(100));
scheduler.request(ms(10_101));
let plan = scheduler.plan(ms(10_101)).unwrap();
assert_eq!(plan.render_at, ms(10_101));
assert_eq!(plan.present_at, ms(10_110));
assert_eq!(scheduler.forecast_time(ms(10_111), plan), Some(ms(10_120)));
let mut unknown = FrameScheduler::new(ms(10));
unknown.request(ms(100));
let plan = unknown.plan(ms(100)).unwrap();
assert_eq!(unknown.forecast_time(ms(101), plan), None);
unknown.begin();
unknown.rendered(plan, ms(102), ms(2), true);
unknown.presented(ms(130));
assert_eq!(
unknown.missed_deadlines, 0,
"unknown phase is not a measurable missed deadline"
);
}
}
+5 -6
View File
@@ -159,10 +159,9 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
self.last_size = constrained_size(self.initial_rect.size, delta, self.edges, minimum, maximum); self.last_size = constrained_size(self.initial_rect.size, delta, self.edges, minimum, maximum);
let rect = resized_rect(self.initial_rect, self.last_size, self.edges); let rect = resized_rect(self.initial_rect, self.last_size, self.edges);
data.set_floating_window_geometry(&self.window, rect.loc, rect.size); data.set_floating_window_geometry(&self.window, rect.loc, rect.size);
if let Some(id) = data.window_ids.get(&self.window).copied() { if let Some(id) = data.windows.ids().get(&self.window).copied() {
data.floating_resize_anchors.insert( data.windows.update(id, |w| {
id, w.resize_anchor = Some((
(
self.edges.left(), self.edges.left(),
self.edges.top(), self.edges.top(),
ferese_layout::Rect::new( ferese_layout::Rect::new(
@@ -171,8 +170,8 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
initial.size.w as f64, initial.size.w as f64,
initial.size.h as f64, initial.size.h as f64,
), ),
), ))
); });
} }
surface.with_pending_state(|state| { surface.with_pending_state(|state| {
+1 -1
View File
@@ -43,7 +43,7 @@ impl XdgActivationHandler for Ferese {
return; return;
}; };
self.focused_window = self.window_ids.get(&window).copied(); self.focused_window = self.windows.ids().get(&window).copied();
if let Some(focused) = self.focused_window if let Some(focused) = self.focused_window
&& let Err(error) = self.workspaces.focus_window(focused) && let Err(error) = self.workspaces.focus_window(focused)
{ {
+1 -1
View File
@@ -81,7 +81,7 @@ impl CompositorHandler for Ferese {
if surface == &root { if surface == &root {
if crate::state::window_has_buffer(&window) { if crate::state::window_has_buffer(&window) {
xdg_shell::apply_initial_window_rules(self, &window); xdg_shell::apply_initial_window_rules(self, &window);
} else if self.window_ids.contains_key(&window) { } else if self.windows.ids().contains_key(&window) {
self.remove_tiled_window(&window); self.remove_tiled_window(&window);
self.space.map_element(window.clone(), (0, 0), false); self.space.map_element(window.clone(), (0, 0), false);
self.restore_keyboard_focus(); self.restore_keyboard_focus();
+26 -1
View File
@@ -59,6 +59,16 @@ impl SeatHandler for Ferese {
} }
fn focus_changed(&mut self, seat: &Seat<Self>, focused: Option<&WlSurface>) { fn focus_changed(&mut self, seat: &Seat<Self>, focused: Option<&WlSurface>) {
if let Some(id) = focused.and_then(|surface| {
self.windows.ids().iter().find_map(|(window, id)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface)
.then_some(*id)
})
}) {
self.focus_history.record(id);
}
if let Some(inhibitor) = self.active_shortcuts_inhibitor.take() if let Some(inhibitor) = self.active_shortcuts_inhibitor.take()
&& inhibitor.is_active() && inhibitor.is_active()
{ {
@@ -142,7 +152,7 @@ impl Ferese {
{ {
root = parent; root = parent;
} }
let window = self.window_ids.iter().find_map(|(window, id)| { let window = self.windows.ids().iter().find_map(|(window, id)| {
window window
.toplevel() .toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == &root) .is_some_and(|toplevel| toplevel.wl_surface() == &root)
@@ -251,6 +261,18 @@ impl PrimarySelectionHandler for Ferese {
} }
} }
impl smithay::wayland::selection::wlr_data_control::DataControlHandler for Ferese {
fn data_control_state(&self) -> &smithay::wayland::selection::wlr_data_control::DataControlState {
&self.wlr_data_control_state
}
}
impl smithay::wayland::selection::ext_data_control::DataControlHandler for Ferese {
fn data_control_state(&self) -> &smithay::wayland::selection::ext_data_control::DataControlState {
&self.ext_data_control_state
}
}
impl XdgForeignHandler for Ferese { impl XdgForeignHandler for Ferese {
fn xdg_foreign_state(&mut self) -> &mut XdgForeignState { fn xdg_foreign_state(&mut self) -> &mut XdgForeignState {
&mut self.xdg_foreign_state &mut self.xdg_foreign_state
@@ -285,3 +307,6 @@ smithay::delegate_xdg_decoration!(Ferese);
smithay::delegate_xdg_foreign!(Ferese); smithay::delegate_xdg_foreign!(Ferese);
smithay::delegate_xdg_shell!(Ferese); smithay::delegate_xdg_shell!(Ferese);
smithay::delegate_xdg_toplevel_icon!(Ferese); smithay::delegate_xdg_toplevel_icon!(Ferese);
smithay::delegate_data_control!(Ferese);
smithay::delegate_ext_data_control!(Ferese);
+3 -2
View File
@@ -186,7 +186,8 @@ impl Ferese {
return None; return None;
} }
let window = self let window = self
.window_ids .windows
.ids()
.iter() .iter()
.find(|(_, candidate)| **candidate == id)? .find(|(_, candidate)| **candidate == id)?
.0 .0
@@ -283,7 +284,7 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da
}; };
if state.session_lock.active if state.session_lock.active
|| !super::screencopy::capture_allowed() || !super::screencopy::capture_allowed()
|| !state.window_ids.values().any(|id| *id == snapshot.id) || !state.windows.ids().values().any(|id| *id == snapshot.id)
{ {
resource.failed(); resource.failed();
return; return;
+14 -11
View File
@@ -36,7 +36,7 @@ impl XdgShellHandler for Ferese {
let Some(parent) = surface.parent() else { let Some(parent) = surface.parent() else {
return; return;
}; };
let parent = self.window_ids.iter().find_map(|(window, id)| { let parent = self.windows.ids().iter().find_map(|(window, id)| {
window window
.toplevel() .toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == &parent) .is_some_and(|toplevel| toplevel.wl_surface() == &parent)
@@ -58,9 +58,12 @@ impl XdgShellHandler for Ferese {
} }
fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
// Hidden workspace windows are managed but unmapped from Space.
let Some(window) = self let Some(window) = self
.space .windows
.elements() .ids()
.keys()
.chain(self.space.elements())
.find(|window| { .find(|window| {
window window
.toplevel() .toplevel()
@@ -183,7 +186,7 @@ impl XdgShellHandler for Ferese {
} }
fn maximize_request(&mut self, surface: ToplevelSurface) { fn maximize_request(&mut self, surface: ToplevelSurface) {
if let Some(id) = self.window_ids.iter().find_map(|(window, id)| { if let Some(id) = self.windows.ids().iter().find_map(|(window, id)| {
window window
.toplevel() .toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface()) .is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
@@ -196,7 +199,7 @@ impl XdgShellHandler for Ferese {
} }
fn unmaximize_request(&mut self, surface: ToplevelSurface) { fn unmaximize_request(&mut self, surface: ToplevelSurface) {
if let Some(id) = self.window_ids.iter().find_map(|(window, id)| { if let Some(id) = self.windows.ids().iter().find_map(|(window, id)| {
window window
.toplevel() .toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface()) .is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
@@ -213,7 +216,7 @@ impl XdgShellHandler for Ferese {
surface: ToplevelSurface, surface: ToplevelSurface,
_output: Option<smithay::reexports::wayland_server::protocol::wl_output::WlOutput>, _output: Option<smithay::reexports::wayland_server::protocol::wl_output::WlOutput>,
) { ) {
if let Some(window) = self.window_ids.iter().find_map(|(window, id)| { if let Some(window) = self.windows.ids().iter().find_map(|(window, id)| {
window window
.toplevel() .toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface()) .is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
@@ -226,7 +229,7 @@ impl XdgShellHandler for Ferese {
} }
fn unfullscreen_request(&mut self, surface: ToplevelSurface) { fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
if let Some(window) = self.window_ids.iter().find_map(|(window, id)| { if let Some(window) = self.windows.ids().iter().find_map(|(window, id)| {
window window
.toplevel() .toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface()) .is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
@@ -378,7 +381,7 @@ pub fn apply_initial_window_rules(state: &mut Ferese, window: &Window) {
) )
}); });
let initial = !state.window_ids.contains_key(window); let initial = !state.windows.ids().contains_key(window);
let requested = initial.then(|| { let requested = initial.then(|| {
toplevel.with_pending_state(|pending| { toplevel.with_pending_state(|pending| {
( (
@@ -389,7 +392,7 @@ pub fn apply_initial_window_rules(state: &mut Ferese, window: &Window) {
}); });
if initial { if initial {
let parent = toplevel.parent().and_then(|parent| { let parent = toplevel.parent().and_then(|parent| {
state.window_ids.iter().find_map(|(candidate, id)| { state.windows.ids().iter().find_map(|(candidate, id)| {
candidate candidate
.toplevel() .toplevel()
.is_some_and(|surface| surface.wl_surface() == &parent) .is_some_and(|surface| surface.wl_surface() == &parent)
@@ -400,7 +403,7 @@ pub fn apply_initial_window_rules(state: &mut Ferese, window: &Window) {
} }
state.apply_initial_window_rules(window, app_id.as_deref(), title.as_deref(), transient); state.apply_initial_window_rules(window, app_id.as_deref(), title.as_deref(), transient);
if let Some((maximized, fullscreen)) = requested if let Some((maximized, fullscreen)) = requested
&& let Some(id) = state.window_ids.get(window).copied() && let Some(id) = state.windows.ids().get(window).copied()
{ {
if maximized { if maximized {
state.set_window_maximized(id, true); state.set_window_maximized(id, true);
@@ -422,7 +425,7 @@ impl Ferese {
.find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root)); .find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root));
let geometry = (|| { let geometry = (|| {
if let Some(window) = window { if let Some(window) = window {
let id = *self.window_ids.get(window)?; let id = *self.windows.ids().get(window)?;
let output = self let output = self
.space .space
.outputs() .outputs()
+44 -33
View File
@@ -43,7 +43,7 @@ impl Ferese {
let Some(local) = surface_tree_position(root, surface, geometry.loc) else { let Some(local) = surface_tree_position(root, surface, geometry.loc) else {
continue; continue;
}; };
let id = self.window_ids.get(window)?; let id = self.windows.ids().get(window)?;
let presented = self.presented_window_rect(*id)?; let presented = self.presented_window_rect(*id)?;
let (scale_x, scale_y) = self.visual_scale_for_window(window)?; let (scale_x, scale_y) = self.visual_scale_for_window(window)?;
let point = local.to_f64() + hint - geometry.loc.to_f64(); let point = local.to_f64() + hint - geometry.loc.to_f64();
@@ -275,6 +275,13 @@ impl Ferese {
SERIAL_COUNTER.next_serial(), SERIAL_COUNTER.next_serial(),
event.time_msec(), event.time_msec(),
|data, modifiers, keysym| { |data, modifiers, keysym| {
if data.focus_cycle.is_some() {
if data.session_lock.active || data.input_capture.captures(1) {
data.cancel_focus_cycle();
} else if !modifiers.alt {
data.finish_focus_cycle();
}
}
if state == KeyState::Released { if state == KeyState::Released {
data.portal_shortcuts.release(keycode, Event::time(&event)); data.portal_shortcuts.release(keycode, Event::time(&event));
if data.intercepted_keys.remove(&keycode) { if data.intercepted_keys.remove(&keycode) {
@@ -285,6 +292,13 @@ impl Ferese {
return FilterResult::Forward; return FilterResult::Forward;
} }
let symbol = keysym.modified_sym().raw(); let symbol = keysym.modified_sym().raw();
if data.focus_cycle.is_some() && symbol == keysyms::KEY_Escape {
if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode);
data.cancel_focus_cycle();
}
return FilterResult::Intercept(());
}
if data.input_capture.active() && emergency_shortcut_escape(symbol, modifiers.ctrl, modifiers.alt) { if data.input_capture.active() && emergency_shortcut_escape(symbol, modifiers.ctrl, modifiers.alt) {
if state == KeyState::Pressed { if state == KeyState::Pressed {
data.input_capture.disable_all(); data.input_capture.disable_all();
@@ -362,6 +376,18 @@ impl Ferese {
) )
.then(|| binding.action.clone()) .then(|| binding.action.clone())
}); });
if data.focus_cycle.is_some()
&& state == KeyState::Pressed
&& !matches!(action, Some(BindingAction::FocusMru(_)))
&& !matches!(symbol,
keysyms::KEY_Alt_L | keysyms::KEY_Alt_R
| keysyms::KEY_Shift_L | keysyms::KEY_Shift_R
| keysyms::KEY_Control_L | keysyms::KEY_Control_R
| keysyms::KEY_Super_L | keysyms::KEY_Super_R)
{
data.intercepted_keys.insert(keycode);
return FilterResult::Intercept(());
}
let Some(action) = action else { let Some(action) = action else {
if state == KeyState::Pressed if state == KeyState::Pressed
&& data.portal_shortcuts.press( && data.portal_shortcuts.press(
@@ -379,7 +405,11 @@ impl Ferese {
if state == KeyState::Pressed { if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode); data.intercepted_keys.insert(keycode);
data.execute_binding(action); if let BindingAction::FocusMru(reverse) = action {
data.cycle_focus(reverse, modifiers.alt);
} else {
data.execute_binding(action);
}
} }
FilterResult::Intercept(()) FilterResult::Intercept(())
@@ -511,7 +541,7 @@ impl Ferese {
return; return;
} }
if let Some(window) = self.window_under_visual(position) if let Some(window) = self.window_under_visual(position)
&& let Some(id) = self.window_ids.get(&window).copied() && let Some(id) = self.windows.ids().get(&window).copied()
{ {
self.select_overview_window(id); self.select_overview_window(id);
} else { } else {
@@ -751,39 +781,18 @@ impl Ferese {
} }
} }
pub(crate) fn relative_workspace_target(&mut self, next: bool) -> Option<ferese_core::WorkspaceId> { pub(crate) fn relative_workspace_target(&self, next: bool) -> Option<ferese_core::WorkspaceId> {
let output = self.output_workspaces.focused_output()?; let output = self.output_workspaces.focused_output()?;
let current = self.output_workspaces.active_workspace(output)?; let current = self.output_workspaces.active_workspace(output)?;
let mut candidates = self.workspaces.ordered(); let candidates = self.output_workspaces.ordered_workspaces(&self.workspaces, output);
candidates.retain(|workspace| { let index = candidates.iter().position(|id| *id == current)?;
self.output_workspaces
.output_for_workspace(workspace.id)
.is_none_or(|owner| owner == output)
});
let index = candidates.iter().position(|workspace| workspace.id == current)?;
let neighbor = if next { let neighbor = if next {
index.checked_add(1) index.checked_add(1)?
} else { } else {
index.checked_sub(1) index.checked_sub(1)?
}; };
if let Some(workspace) = neighbor.and_then(|index| candidates.get(index)) {
return Some(workspace.id); candidates.get(neighbor).copied()
}
if next
&& self.workspaces.workspace(current).is_some_and(|workspace| {
workspace.layout.window_ids().next().is_some() || !workspace.floating.is_empty()
})
{
let number = self
.workspaces
.iter()
.filter_map(|workspace| workspace.name.parse::<u32>().ok())
.max()
.unwrap_or(1)
.checked_add(1)?;
return self.workspaces.ensure_numeric(number).ok();
}
None
} }
fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) { fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) {
@@ -806,7 +815,7 @@ impl Ferese {
} }
if let Some(window) = self.window_under_visual(position) { if let Some(window) = self.window_under_visual(position) {
let focused = self.window_ids.get(&window).copied(); let focused = self.windows.ids().get(&window).copied();
if let Some(focused) = focused { if let Some(focused) = focused {
let result = if raise { let result = if raise {
self.workspaces.focus_window(focused) self.workspaces.focus_window(focused)
@@ -874,7 +883,7 @@ impl Ferese {
let Some(window) = self.window_under_visual(position) else { let Some(window) = self.window_under_visual(position) else {
return; return;
}; };
if self.window_ids.get(&window).copied() == self.focused_window { if self.windows.ids().get(&window).copied() == self.focused_window {
return; return;
} }
@@ -998,6 +1007,8 @@ impl Ferese {
BindingAction::Close => self.close_focused_window(), BindingAction::Close => self.close_focused_window(),
BindingAction::Exit => self.request_logout_confirmation(), BindingAction::Exit => self.request_logout_confirmation(),
BindingAction::Focus(direction) => self.focus_direction(direction), BindingAction::Focus(direction) => self.focus_direction(direction),
BindingAction::FocusLastWindow => self.focus_last_window(),
BindingAction::FocusMru(reverse) => self.cycle_focus(reverse, false),
BindingAction::Move(direction) => self.move_direction(direction), BindingAction::Move(direction) => self.move_direction(direction),
BindingAction::Resize(direction) => self.resize_direction(direction), BindingAction::Resize(direction) => self.resize_direction(direction),
BindingAction::SwitchRelativeWorkspace(next) => self.switch_relative_workspace(next, None), BindingAction::SwitchRelativeWorkspace(next) => self.switch_relative_workspace(next, None),
+20 -9
View File
@@ -647,6 +647,9 @@ impl Ferese {
.reload_config() .reload_config()
.map_err(|e| CommandError::new("invalid_config", e))?, .map_err(|e| CommandError::new("invalid_config", e))?,
"focus" => self.focus_direction(direction_arg(args)?), "focus" => self.focus_direction(direction_arg(args)?),
"focus-last-window" => self.focus_last_window(),
"focus-mru-next" => self.cycle_focus(false, false),
"focus-mru-previous" => self.cycle_focus(true, false),
"move" => self.move_direction(direction_arg(args)?), "move" => self.move_direction(direction_arg(args)?),
"resize" => self.resize_direction(direction_arg(args)?), "resize" => self.resize_direction(direction_arg(args)?),
"workspace" => self.switch_workspace(workspace_arg(args)?), "workspace" => self.switch_workspace(workspace_arg(args)?),
@@ -824,7 +827,8 @@ impl Ferese {
return; return;
}; };
let Some(window) = self let Some(window) = self
.window_ids .windows
.ids()
.iter() .iter()
.find(|(_, candidate)| **candidate == id) .find(|(_, candidate)| **candidate == id)
.map(|(window, _)| window.clone()) .map(|(window, _)| window.clone())
@@ -933,7 +937,8 @@ impl Ferese {
.and_then(|client| client.get_credentials(&self.display_handle).ok()) .and_then(|client| client.get_credentials(&self.display_handle).ok())
.is_some_and(|credentials| credentials.pid as u32 == pid) .is_some_and(|credentials| credentials.pid as u32 == pid)
}; };
self.window_ids self.windows
.ids()
.keys() .keys()
.filter_map(|window| window.toplevel()) .filter_map(|window| window.toplevel())
.any(|toplevel| belongs_to(toplevel.wl_surface())) .any(|toplevel| belongs_to(toplevel.wl_surface()))
@@ -948,7 +953,8 @@ impl Ferese {
use smithay::wayland::compositor::with_states; use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::XdgToplevelSurfaceData; use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
let mut windows = self let mut windows = self
.window_ids .windows
.ids()
.iter() .iter()
.filter_map(|(window, id)| { .filter_map(|(window, id)| {
let toplevel = window.toplevel()?; let toplevel = window.toplevel()?;
@@ -981,20 +987,25 @@ impl Ferese {
} }
fn workspaces_json(&self) -> Value { fn workspaces_json(&self) -> Value {
let active = self.workspaces.active_id();
let workspaces = self let workspaces = self
.workspaces .output_workspaces
.ordered() .workspace_views(&self.workspaces)
.into_iter() .into_iter()
.map(|workspace| { .map(|view| {
let workspace = self.workspaces.workspace(view.id).expect("workspace view exists");
let mode = match workspace.layout.mode() { let mode = match workspace.layout.mode() {
LayoutMode::Scrolling => "scrolling", LayoutMode::Scrolling => "scrolling",
LayoutMode::Tree => "tree", LayoutMode::Tree => "tree",
}; };
json!({ json!({
"id": workspace.id.0, "id": workspace.id.0,
"name": workspace.name, "name": view.index.to_string(),
"active": workspace.id == active, "index": view.index,
"output": view.output.0,
"window_count": view.window_count,
"visible": view.visible,
"focused": view.focused,
"active": view.focused,
"layout": mode, "layout": mode,
"focused_window": workspace.last_focused.map(|window| window.0), "focused_window": workspace.last_focused.map(|window| window.0),
"fullscreen_window": workspace.fullscreen.map(|window| window.0), "fullscreen_window": workspace.fullscreen.map(|window| window.0),
+4
View File
@@ -7,6 +7,7 @@ mod daemon;
mod dimming; mod dimming;
mod effects; mod effects;
mod floating; mod floating;
mod frame_scheduler;
mod gestures; mod gestures;
mod grabs; mod grabs;
mod handlers; mod handlers;
@@ -116,6 +117,9 @@ fn main() -> Result<(), Box<dyn Error>> {
state.backdrop_generation = state.backdrop_generation.wrapping_add(1); state.backdrop_generation = state.backdrop_generation.wrapping_add(1);
} }
// All input/Wayland callbacks have returned, releasing seat locks. // All input/Wayland callbacks have returned, releasing seat locks.
if state.focus_cycle.is_some() && (state.session_lock.active || state.input_capture.captures(1)) {
state.cancel_focus_cycle();
}
// Coalesce cursor changes and redraw here, never inside cursor_image. // Coalesce cursor changes and redraw here, never inside cursor_image.
if state.input_capture.restore_focus { if state.input_capture.restore_focus {
state.restore_input_capture_focus(); state.restore_input_capture_focus();
+268 -102
View File
@@ -89,12 +89,9 @@ type OverviewStateLabel =
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct OverviewState { pub(crate) struct OverviewState {
active: bool, motion: OverviewMotion,
selected: Option<WindowId>, selected: Option<WindowId>,
selection_workspace: Option<WorkspaceId>, selection_workspace: Option<WorkspaceId>,
presentations: HashMap<WindowId, AnimatedRect>,
opacity: AnimatedValue,
exit_transition: Option<(f64, f64)>,
strip_offsets: HashMap<OutputId, usize>, strip_offsets: HashMap<OutputId, usize>,
font_family: String, font_family: String,
font_requests: Option<FontRequests>, font_requests: Option<FontRequests>,
@@ -102,15 +99,20 @@ pub(crate) struct OverviewState {
labels: OverviewStateLabel, labels: OverviewStateLabel,
} }
#[derive(Clone, Debug)]
pub(crate) struct OverviewMotion {
active: bool,
presentations: HashMap<WindowId, AnimatedRect>,
opacity: AnimatedValue,
exit_transition: Option<(f64, f64)>,
}
impl Default for OverviewState { impl Default for OverviewState {
fn default() -> Self { fn default() -> Self {
Self { Self {
active: false, motion: OverviewMotion::default(),
selected: None, selected: None,
selection_workspace: None, selection_workspace: None,
presentations: HashMap::new(),
opacity: AnimatedValue::new(0.0),
exit_transition: None,
strip_offsets: HashMap::new(), strip_offsets: HashMap::new(),
font_family: "sans-serif".into(), font_family: "sans-serif".into(),
font_requests: None, font_requests: None,
@@ -123,6 +125,7 @@ impl Default for OverviewState {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct WorkspaceCard { pub(crate) struct WorkspaceCard {
pub workspace: WorkspaceId, pub workspace: WorkspaceId,
pub index: u32,
pub rect: Rect, pub rect: Rect,
pub selected: bool, pub selected: bool,
pub windows: Vec<(WindowId, Rect)>, pub windows: Vec<(WindowId, Rect)>,
@@ -160,6 +163,20 @@ fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 {
} }
impl OverviewState { impl OverviewState {
pub(crate) fn is_animating(&self, spring: SpringConfig) -> bool {
self.motion.is_animating(spring)
}
pub(crate) fn sample(&self, delta: Duration, spring: SpringConfig) -> OverviewMotion {
let mut motion = self.motion.clone();
if !delta.is_zero() {
motion.advance(delta, spring, true);
}
motion
}
pub(crate) fn set_font_family(&mut self, family: String) { pub(crate) fn set_font_family(&mut self, family: String) {
if self.font_family != family { if self.font_family != family {
self.font_family = family; self.font_family = family;
@@ -241,19 +258,20 @@ impl OverviewState {
} }
pub(crate) fn is_active(&self) -> bool { pub(crate) fn is_active(&self) -> bool {
self.active self.motion.active
} }
pub(crate) fn is_presenting(&self) -> bool { pub(crate) fn is_presenting(&self) -> bool {
self.active || !self.presentations.is_empty() || self.opacity.current > 0.001 self.motion.is_presenting()
} }
#[cfg(test)]
pub(crate) fn opacity(&self) -> f32 { pub(crate) fn opacity(&self) -> f32 {
self.opacity.current.clamp(0.0, 1.0) as f32 self.motion.opacity()
} }
fn select_workspace_windows(&mut self, windows: &[WindowId], preferred: Option<WindowId>) { fn select_workspace_windows(&mut self, windows: &[WindowId], preferred: Option<WindowId>) {
let visible = |id: &WindowId| windows.contains(id) && self.presentations.contains_key(id); let visible = |id: &WindowId| windows.contains(id) && self.motion.presentations.contains_key(id);
self.selected = self self.selected = self
.selected .selected
.filter(visible) .filter(visible)
@@ -265,16 +283,30 @@ impl OverviewState {
self.selected self.selected
} }
pub(crate) fn has_window_preview(&self, id: WindowId) -> bool {
self.motion.presentations.contains_key(&id)
}
pub(crate) fn select_window(&mut self, id: WindowId) -> bool {
if !self.motion.presentations.contains_key(&id) {
return false;
}
self.selection_workspace = None;
self.selected = Some(id);
true
}
pub(crate) fn select_direction(&mut self, direction: Direction) -> bool { pub(crate) fn select_direction(&mut self, direction: Direction) -> bool {
let Some(current) = self let Some(current) = self
.selected .selected
.and_then(|id| self.presentations.get(&id)) .and_then(|id| self.motion.presentations.get(&id))
.map(|presentation| presentation.current) .map(|presentation| presentation.current)
else { else {
return false; return false;
}; };
let current_center = rect_center(current); let current_center = rect_center(current);
let next = self let next = self
.motion
.presentations .presentations
.iter() .iter()
.filter(|(id, _)| Some(**id) != self.selected) .filter(|(id, _)| Some(**id) != self.selected)
@@ -294,17 +326,7 @@ impl OverviewState {
} }
pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect { pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect {
let preview = self.presentations.get(&id).map_or(normal, |p| p.current); self.motion.presented_rect(id, normal)
if let Some((elapsed, _)) = self.exit_transition {
let t = 1.0 - (1.0 - (elapsed / EXIT_DURATION).clamp(0.0, 1.0)).powi(3);
return Rect::new(
preview.x + (normal.x - preview.x) * t,
preview.y + (normal.y - preview.y) * t,
preview.width + (normal.width - preview.width) * t,
preview.height + (normal.height - preview.height) * t,
);
}
preview
} }
fn enter( fn enter(
@@ -314,23 +336,23 @@ impl OverviewState {
animations_enabled: bool, animations_enabled: bool,
) { ) {
// Reverse a dismissal from the currently visible geometry, not the old grid. // Reverse a dismissal from the currently visible geometry, not the old grid.
if self.exit_transition.is_some() { if self.motion.exit_transition.is_some() {
let visible: Vec<_> = targets let visible: Vec<_> = targets
.iter() .iter()
.map(|(id, (normal, _))| (*id, self.presented_rect(*id, *normal))) .map(|(id, (normal, _))| (*id, self.presented_rect(*id, *normal)))
.collect(); .collect();
for (id, rect) in visible { for (id, rect) in visible {
if let Some(presentation) = self.presentations.get_mut(&id) { if let Some(presentation) = self.motion.presentations.get_mut(&id) {
*presentation = AnimatedRect::new(rect); *presentation = AnimatedRect::new(rect);
} }
} }
} }
self.exit_transition = None; self.motion.exit_transition = None;
self.selection_workspace = None; self.selection_workspace = None;
self.active = true; self.motion.active = true;
self.opacity.retarget_preserving_motion(1.0); self.motion.opacity.retarget_preserving_motion(1.0);
if !animations_enabled { if !animations_enabled {
self.opacity.snap(); self.motion.opacity.snap();
} }
self.strip_offsets.clear(); self.strip_offsets.clear();
self.selected = selected self.selected = selected
@@ -340,10 +362,11 @@ impl OverviewState {
} }
fn retarget(&mut self, targets: HashMap<WindowId, (Rect, Rect)>, animations_enabled: bool) { fn retarget(&mut self, targets: HashMap<WindowId, (Rect, Rect)>, animations_enabled: bool) {
self.presentations.retain(|id, _| targets.contains_key(id)); self.motion.presentations.retain(|id, _| targets.contains_key(id));
for (id, (normal, target)) in targets { for (id, (normal, target)) in targets {
let presentation = self let presentation = self
.motion
.presentations .presentations
.entry(id) .entry(id)
.or_insert_with(|| AnimatedRect::new(normal)); .or_insert_with(|| AnimatedRect::new(normal));
@@ -353,20 +376,23 @@ impl OverviewState {
presentation.snap(); presentation.snap();
} }
} }
if self.selected.is_none_or(|id| !self.presentations.contains_key(&id)) { if self
self.selected = self.presentations.keys().copied().min_by_key(|id| id.0); .selected
.is_none_or(|id| !self.motion.presentations.contains_key(&id))
{
self.selected = self.motion.presentations.keys().copied().min_by_key(|id| id.0);
} }
} }
fn exit(&mut self, normal: HashMap<WindowId, Rect>, animations_enabled: bool) { fn exit(&mut self, normal: HashMap<WindowId, Rect>, animations_enabled: bool) {
self.active = false; self.motion.active = false;
self.exit_transition = animations_enabled.then_some((0.0, self.opacity.current)); self.motion.exit_transition = animations_enabled.then_some((0.0, self.motion.opacity.current));
self.opacity.retarget_preserving_motion(0.0); self.motion.opacity.retarget_preserving_motion(0.0);
if !animations_enabled { if !animations_enabled {
self.opacity.snap(); self.motion.opacity.snap();
} }
self.selected = None; self.selected = None;
self.presentations.retain(|id, presentation| { self.motion.presentations.retain(|id, presentation| {
let Some(target) = normal.get(id).copied() else { let Some(target) = normal.get(id).copied() else {
return false; return false;
}; };
@@ -379,58 +405,12 @@ impl OverviewState {
}); });
if !animations_enabled { if !animations_enabled {
self.presentations.clear(); self.motion.presentations.clear();
} }
} }
pub(crate) fn advance(&mut self, delta: Duration, spring: SpringConfig, animations_enabled: bool) -> bool { pub(crate) fn advance(&mut self, delta: Duration, spring: SpringConfig, animations_enabled: bool) -> bool {
if let Some((elapsed, initial_opacity)) = self.exit_transition { self.motion.advance(delta, spring, animations_enabled)
let elapsed = if animations_enabled {
elapsed + delta.as_secs_f64()
} else {
EXIT_DURATION
};
let remaining = (1.0 - elapsed / EXIT_DURATION).clamp(0.0, 1.0);
self.opacity.current = initial_opacity * remaining.powi(3);
if elapsed >= EXIT_DURATION {
self.exit_transition = None;
self.presentations.clear();
self.opacity.snap();
return false;
}
self.exit_transition = Some((elapsed, initial_opacity));
return true;
}
let mut active_animation = false;
for presentation in self.presentations.values_mut() {
if animations_enabled {
active_animation |= presentation.advance(delta, spring);
} else {
presentation.snap();
}
}
if !self.active && !active_animation {
self.presentations.clear();
}
if animations_enabled {
// Geometry's 0.1-pixel tolerance is far too coarse for opacity:
// it would abruptly drop the last ten percent of the fade.
active_animation |= self.opacity.advance(
delta,
SpringConfig {
position_tolerance: 0.001,
velocity_tolerance: 0.005,
..spring
},
);
} else {
self.opacity.snap();
}
active_animation
} }
} }
@@ -441,7 +421,7 @@ impl Ferese {
} }
let selected = self let selected = self
.window_under_visual(point) .window_under_visual(point)
.and_then(|w| self.window_ids.get(&w).copied()); .and_then(|w| self.windows.ids().get(&w).copied());
if selected.is_some() && self.overview.selected != selected { if selected.is_some() && self.overview.selected != selected {
self.overview.selection_workspace = None; self.overview.selection_workspace = None;
@@ -480,15 +460,17 @@ impl Ferese {
pub(crate) fn overview_workspace_label( pub(crate) fn overview_workspace_label(
&mut self, &mut self,
workspace: WorkspaceId, index: u32,
scale: f64, scale: f64,
) -> Option<(MemoryRenderBuffer, smithay::utils::Size<i32, smithay::utils::Buffer>)> { ) -> Option<(MemoryRenderBuffer, smithay::utils::Size<i32, smithay::utils::Buffer>)> {
let name = self.workspaces.workspace(workspace)?.name.clone();
self.overview self.overview
.label(&name, scale, self.theme_settings.text_primary_color.0) .label(&index.to_string(), scale, self.theme_settings.text_primary_color.0)
} }
pub(crate) fn set_overview_active(&mut self, active: bool) { pub(crate) fn set_overview_active(&mut self, active: bool) {
if !active {
self.focus_cycle = None;
}
if self.overview.is_active() == active { if self.overview.is_active() == active {
return; return;
} }
@@ -528,7 +510,7 @@ impl Ferese {
} }
pub(crate) fn select_overview_window(&mut self, id: WindowId) -> bool { pub(crate) fn select_overview_window(&mut self, id: WindowId) -> bool {
if !self.overview.is_active() || !self.overview.presentations.contains_key(&id) { if !self.overview.is_active() || !self.overview.motion.presentations.contains_key(&id) {
return false; return false;
} }
@@ -547,6 +529,13 @@ impl Ferese {
let targets = self.overview_targets(); let targets = self.overview_targets();
self.overview.retarget(targets, self.animations_enabled()); self.overview.retarget(targets, self.animations_enabled());
if let Some(cycle) = self.focus_cycle.as_mut() {
let available = self.overview.motion.presentations.keys().copied().collect::<Vec<_>>();
if let Some(id) = cycle.reconcile(&available) {
self.overview.select_window(id);
}
return;
}
if self.overview.selection_workspace.is_some() { if self.overview.selection_workspace.is_some() {
let id = self.workspaces.active_id(); let id = self.workspaces.active_id();
self.overview.selection_workspace = Some(id); self.overview.selection_workspace = Some(id);
@@ -566,7 +555,7 @@ impl Ferese {
} }
pub(crate) fn presented_window_rect(&self, id: WindowId) -> Option<Rect> { pub(crate) fn presented_window_rect(&self, id: WindowId) -> Option<Rect> {
let normal = self.window_geometry.get(&id)?.visual.current; let normal = self.windows.geometry(&id)?.visual.current;
let mut presented = self.overview.presented_rect(id, normal); let mut presented = self.overview.presented_rect(id, normal);
if !self.overview.is_presenting() { if !self.overview.is_presenting() {
let (x, y) = self.workspace_slide_offset(id); let (x, y) = self.workspace_slide_offset(id);
@@ -577,7 +566,7 @@ impl Ferese {
} }
pub(crate) fn inverse_presented_window_point(&self, id: WindowId, x: f64, y: f64) -> Option<(f64, f64)> { pub(crate) fn inverse_presented_window_point(&self, id: WindowId, x: f64, y: f64) -> Option<(f64, f64)> {
let geometry = self.window_geometry.get(&id)?; let geometry = self.windows.geometry(&id)?;
if !self.overview.is_presenting() { if !self.overview.is_presenting() {
let presented = self.presented_window_rect(id)?; let presented = self.presented_window_rect(id)?;
return Some((x - presented.x, y - presented.y)); return Some((x - presented.x, y - presented.y));
@@ -600,6 +589,14 @@ impl Ferese {
} }
pub(crate) fn overview_workspace_cards(&self, output: &Output) -> Vec<WorkspaceCard> { pub(crate) fn overview_workspace_cards(&self, output: &Output) -> Vec<WorkspaceCard> {
self.overview_workspace_cards_for_frame(output, None)
}
pub(crate) fn overview_workspace_cards_for_frame(
&self,
output: &Output,
frame: Option<&crate::state::FrameScene>,
) -> Vec<WorkspaceCard> {
let Some(bounds) = self.output_bounds_for(output) else { let Some(bounds) = self.output_bounds_for(output) else {
return Vec::new(); return Vec::new();
}; };
@@ -609,7 +606,12 @@ impl Ferese {
let selected = self.output_workspaces.active_workspace(output_id); let selected = self.output_workspaces.active_workspace(output_id);
let strip = workspace_strip(bounds); let strip = workspace_strip(bounds);
let capacity = strip_capacity(strip); let capacity = strip_capacity(strip);
let workspaces = self.workspaces.ordered(); let workspaces = self
.output_workspaces
.workspace_views(&self.workspaces)
.into_iter()
.filter(|view| view.output == output_id)
.collect::<Vec<_>>();
let selected_index = workspaces let selected_index = workspaces
.iter() .iter()
.position(|workspace| Some(workspace.id) == selected) .position(|workspace| Some(workspace.id) == selected)
@@ -632,7 +634,9 @@ impl Ferese {
.skip(offset) .skip(offset)
.take(capacity) .take(capacity)
.enumerate() .enumerate()
.map(|(index, workspace)| { .map(|(index, view)| {
let workspace = self.workspaces.workspace(view.id).expect("workspace view exists");
let rect = Rect::new( let rect = Rect::new(
start_x + index as f64 * (card_width + WORKSPACE_CARD_GAP), start_x + index as f64 * (card_width + WORKSPACE_CARD_GAP),
strip.y + 12.0, strip.y + 12.0,
@@ -645,7 +649,11 @@ impl Ferese {
.chain(workspace.floating.iter().copied()) .chain(workspace.floating.iter().copied())
.take(4) .take(4)
.filter_map(|id| { .filter_map(|id| {
let source = self.window_geometry.get(&id)?.visual.current; let source = frame
.and_then(|frame| frame.windows.get(&id).map(|window| &window.geometry))
.or_else(|| self.windows.geometry(&id))?
.visual
.current;
Some((id, source)) Some((id, source))
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -658,6 +666,7 @@ impl Ferese {
let windows = preview_layout(preview_bounds, &windows, 4.0, 4.0).into_iter().collect(); let windows = preview_layout(preview_bounds, &windows, 4.0, 4.0).into_iter().collect();
WorkspaceCard { WorkspaceCard {
workspace: workspace.id, workspace: workspace.id,
index: view.index,
rect, rect,
selected: Some(workspace.id) == selected, selected: Some(workspace.id) == selected,
windows, windows,
@@ -720,10 +729,10 @@ impl Ferese {
}; };
let capacity = strip_capacity(workspace_strip(bounds)); let capacity = strip_capacity(workspace_strip(bounds));
let cards = self.overview_workspace_cards(&output); let cards = self.overview_workspace_cards(&output);
let ordered = self.workspaces.ordered(); let ordered = self.output_workspaces.ordered_workspaces(&self.workspaces, output_id);
let current = cards let current = cards
.first() .first()
.and_then(|card| ordered.iter().position(|workspace| workspace.id == card.workspace)) .and_then(|card| ordered.iter().position(|id| *id == card.workspace))
.unwrap_or(0); .unwrap_or(0);
let next = if delta > 0.0 { let next = if delta > 0.0 {
current.saturating_add(1) current.saturating_add(1)
@@ -752,6 +761,22 @@ impl Ferese {
let Some(bounds) = self.output_bounds_for(output) else { let Some(bounds) = self.output_bounds_for(output) else {
continue; continue;
}; };
if let Some(cycle) = &self.focus_cycle {
let windows = cycle
.windows()
.iter()
.filter_map(|id| {
if self.focus_preview_output(*id) != Some(output_id) {
return None;
}
Some((*id, self.windows.geometry(id)?.visual.current))
})
.collect::<Vec<_>>();
for (id, target) in overview_layout(window_area(bounds), &windows) {
targets.insert(id, (self.windows.geometry(&id).unwrap().visual.current, target));
}
continue;
}
let Some(workspace) = self.workspaces.workspace(workspace) else { let Some(workspace) = self.workspaces.workspace(workspace) else {
continue; continue;
}; };
@@ -760,13 +785,13 @@ impl Ferese {
.window_ids() .window_ids()
.chain(workspace.floating.iter().copied()) .chain(workspace.floating.iter().copied())
.filter_map(|id| { .filter_map(|id| {
let normal = self.window_geometry.get(&id)?.visual.current; let normal = self.windows.geometry(&id)?.visual.current;
Some((id, normal)) Some((id, normal))
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for (id, target) in overview_layout(window_area(bounds), &windows) { for (id, target) in overview_layout(window_area(bounds), &windows) {
let normal = self.window_geometry[&id].visual.current; let normal = self.windows.geometry(&id).unwrap().visual.current;
targets.insert(id, (normal, target)); targets.insert(id, (normal, target));
} }
} }
@@ -775,8 +800,8 @@ impl Ferese {
} }
fn normal_window_rects(&self) -> HashMap<WindowId, Rect> { fn normal_window_rects(&self) -> HashMap<WindowId, Rect> {
self.window_geometry self.windows
.iter() .geometries()
.map(|(id, geometry)| (*id, geometry.visual.current)) .map(|(id, geometry)| (*id, geometry.visual.current))
.collect() .collect()
} }
@@ -973,6 +998,36 @@ mod tests {
assert!(!overview.is_presenting()); assert!(!overview.is_presenting());
} }
#[test]
fn sampling_overview_exit_does_not_finish_the_live_transition() {
let mut overview = OverviewState::default();
let id = WindowId(1);
let normal = Rect::new(0., 0., 800., 600.);
let thumbnail = Rect::new(100., 100., 200., 150.);
overview.enter(HashMap::from([(id, (normal, thumbnail))]), Some(id), false);
overview.exit(HashMap::from([(id, normal)]), true);
overview.advance(Duration::from_millis(170), SpringConfig::default(), true);
let before = overview.presented_rect(id, normal);
let opacity = overview.opacity();
let finished = overview.sample(Duration::from_millis(16), SpringConfig::default());
let earlier = overview.sample(Duration::from_millis(4), SpringConfig::default());
assert!(!finished.is_presenting());
assert_eq!(finished.presented_rect(id, normal), normal);
assert!(earlier.is_presenting());
assert!(earlier.opacity() < opacity);
assert!(overview.is_presenting());
assert_eq!(overview.presented_rect(id, normal), before);
assert_eq!(overview.opacity(), opacity);
assert_eq!(
overview
.sample(Duration::ZERO, SpringConfig::default())
.presented_rect(id, normal),
before
);
}
#[test] #[test]
fn reversing_overview_preserves_current_opacity() { fn reversing_overview_preserves_current_opacity() {
let mut overview = OverviewState::default(); let mut overview = OverviewState::default();
@@ -1053,6 +1108,25 @@ mod tests {
assert!((preview.width / preview.height - source.width / source.height).abs() < 0.001); assert!((preview.width / preview.height - source.width / source.height).abs() < 0.001);
} }
#[test]
fn mru_selection_can_cross_workspace_selection_and_rejects_missing_previews() {
let first = WindowId(1);
let second = WindowId(2);
let normal = Rect::new(0.0, 0.0, 800.0, 600.0);
let mut overview = OverviewState::default();
overview.enter(
HashMap::from([(first, (normal, normal)), (second, (normal, normal))]),
Some(first),
false,
);
overview.selection_workspace = Some(WorkspaceId(1));
assert!(overview.select_window(second));
assert_eq!(overview.selected(), Some(second));
assert_eq!(overview.selection_workspace, None);
assert!(!overview.select_window(WindowId(99)));
assert_eq!(overview.selected(), Some(second));
}
#[test] #[test]
fn exiting_overview_tracks_a_moving_layout_and_finishes_in_180ms() { fn exiting_overview_tracks_a_moving_layout_and_finishes_in_180ms() {
let id = WindowId(1); let id = WindowId(1);
@@ -1143,3 +1217,95 @@ mod tests {
assert!(!overview.select_direction(Direction::Right)); assert!(!overview.select_direction(Direction::Right));
} }
} }
impl Default for OverviewMotion {
fn default() -> Self {
Self {
active: false,
presentations: HashMap::new(),
opacity: AnimatedValue::new(0.0),
exit_transition: None,
}
}
}
impl OverviewMotion {
pub(crate) fn is_animating(&self, spring: SpringConfig) -> bool {
self.exit_transition.is_some()
|| (self.opacity.current - self.opacity.target).abs() > 0.001
|| self.opacity.velocity.abs() > 0.005
|| self.presentations.values().any(|rect| !rect.is_settled(spring))
}
pub(crate) fn is_presenting(&self) -> bool {
self.active || !self.presentations.is_empty() || self.opacity.current > 0.001
}
pub(crate) fn opacity(&self) -> f32 {
self.opacity.current.clamp(0.0, 1.0) as f32
}
pub(crate) fn presented_rect(&self, id: WindowId, normal: Rect) -> Rect {
let preview = self.presentations.get(&id).map_or(normal, |p| p.current);
if let Some((elapsed, _)) = self.exit_transition {
let t = 1.0 - (1.0 - (elapsed / EXIT_DURATION).clamp(0.0, 1.0)).powi(3);
return Rect::new(
preview.x + (normal.x - preview.x) * t,
preview.y + (normal.y - preview.y) * t,
preview.width + (normal.width - preview.width) * t,
preview.height + (normal.height - preview.height) * t,
);
}
preview
}
pub(crate) fn advance(&mut self, delta: Duration, spring: SpringConfig, animations_enabled: bool) -> bool {
if let Some((elapsed, initial_opacity)) = self.exit_transition {
let elapsed = if animations_enabled {
elapsed + delta.as_secs_f64()
} else {
EXIT_DURATION
};
let remaining = (1.0 - elapsed / EXIT_DURATION).clamp(0.0, 1.0);
self.opacity.current = initial_opacity * remaining.powi(3);
if elapsed >= EXIT_DURATION {
self.exit_transition = None;
self.presentations.clear();
self.opacity.snap();
return false;
}
self.exit_transition = Some((elapsed, initial_opacity));
return true;
}
let mut active_animation = false;
for presentation in self.presentations.values_mut() {
if animations_enabled {
active_animation |= presentation.advance(delta, spring);
} else {
presentation.snap();
}
}
if !self.active && !active_animation {
self.presentations.clear();
}
if animations_enabled {
// Geometry's 0.1-pixel tolerance is far too coarse for opacity:
// it would abruptly drop the last ten percent of the fade.
active_animation |= self.opacity.advance(
delta,
SpringConfig {
position_tolerance: 0.001,
velocity_tolerance: 0.005,
..spring
},
);
} else {
self.opacity.snap();
}
active_animation
}
}
+101 -32
View File
@@ -36,7 +36,8 @@ pub(crate) fn take_output_feedback(
for window in state.space.elements().filter(|window| { for window in state.space.elements().filter(|window| {
state state
.window_ids .windows
.ids()
.get(*window) .get(*window)
.is_some_and(|id| state.window_belongs_to_output(*id, output)) .is_some_and(|id| state.window_belongs_to_output(*id, output))
}) { }) {
@@ -59,21 +60,6 @@ fn rendered_feedback_output(output: &Output, id: Id, rendered: &RenderElementSta
.then(|| output.clone()) .then(|| output.clone())
} }
#[derive(Default)]
pub(crate) struct CallbackClock {
last_sent: Option<Instant>,
}
impl CallbackClock {
pub(crate) fn deadline(&self, now: Instant, interval: Duration) -> Instant {
self.last_sent.map_or(now, |last| (last + interval).max(now))
}
pub(crate) fn sent(&mut self, now: Instant) {
self.last_sent = Some(now);
}
}
pub(crate) fn frame_delta(last: &mut Instant, now: Instant) -> Duration { pub(crate) fn frame_delta(last: &mut Instant, now: Instant) -> Duration {
let delta = now.saturating_duration_since(*last); let delta = now.saturating_duration_since(*last);
*last = (*last).max(now); *last = (*last).max(now);
@@ -96,6 +82,41 @@ pub(crate) fn physical_rect(
) )
} }
/// A single physical outline shared by content and all of its decorations.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct RoundedRect {
pub rect: Rectangle<i32, Physical>,
pub radius: f32,
}
impl RoundedRect {
pub(crate) fn new(rect: ferese_layout::Rect, origin: Point<i32, Logical>, scale: f64, radius: f64) -> Self {
let rect = physical_rect(rect, origin, scale);
Self {
radius: clamp_radius(radius * scale, rect.size),
rect,
}
}
pub(crate) fn from_logical(rect: Rectangle<i32, Logical>, scale: f64, radius: f64) -> Self {
Self::new(
ferese_layout::Rect::new(
rect.loc.x as f64,
rect.loc.y as f64,
rect.size.w as f64,
rect.size.h as f64,
),
(0, 0).into(),
scale,
radius,
)
}
}
pub(crate) fn clamp_radius(requested: f64, size: smithay::utils::Size<i32, Physical>) -> f32 {
requested.min(f64::from(size.w.min(size.h)) / 2.0).max(0.0) as f32
}
pub(crate) fn handoff_alpha(elapsed: Duration) -> f32 { pub(crate) fn handoff_alpha(elapsed: Duration) -> f32 {
let progress = (elapsed.as_secs_f64() / HANDOFF.as_secs_f64()).clamp(0.0, 1.0); let progress = (elapsed.as_secs_f64() / HANDOFF.as_secs_f64()).clamp(0.0, 1.0);
(1.0 - progress * progress * (3.0 - 2.0 * progress)) as f32 (1.0 - progress * progress * (3.0 - 2.0 * progress)) as f32
@@ -259,6 +280,51 @@ impl RenderElement<GlesRenderer> for NativeTextureElement {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn rounded_outline_clamps_after_physical_snapping() {
// Logical clamping would give 1.125; the snapped 3px rect allows radius 1.5.
let outline = RoundedRect::new(ferese_layout::Rect::new(0.2, 0.2, 1.5, 4.0), (0, 0).into(), 1.5, 20.0);
assert_eq!(outline.rect, Rectangle::new((0, 0).into(), (3, 6).into()));
assert_eq!(outline.radius, 1.5);
for scale in [1.0, 1.25, 1.5, 2.0] {
let rect = ferese_layout::Rect::new(-20.4, -10.2, 8.8, 6.4);
let outline = RoundedRect::new(rect, (-10, -5).into(), scale, 100.0);
assert_eq!(outline.rect, physical_rect(rect, (-10, -5).into(), scale));
assert_eq!(
outline.radius,
outline.rect.size.w.min(outline.rect.size.h) as f32 / 2.0
);
}
}
#[test]
fn physical_radius_preserves_fractions_and_handles_zero() {
let size = (11, 7).into();
assert_eq!(clamp_radius(100.0, size), 3.5);
assert_eq!(clamp_radius(2.25, size), 2.25);
assert_eq!(clamp_radius(0.0, size), 0.0);
assert_eq!(clamp_radius(-1.0, size), 0.0);
assert_eq!(clamp_radius(2.0, (0, 10).into()), 0.0);
let outline = RoundedRect::new(ferese_layout::Rect::new(0.0, 0.0, 10.0, 10.0), (0, 0).into(), 1.5, 1.5);
assert_eq!(outline.radius, 2.25);
}
#[test]
fn material_and_window_outline_agree_for_the_same_rect() {
let logical = Rectangle::new((-3, 7).into(), (11, 9).into());
for scale in [1.0, 1.25, 1.5, 2.0] {
assert_eq!(
RoundedRect::from_logical(logical, scale, 100.0),
RoundedRect::new(
ferese_layout::Rect::new(-3.0, 7.0, 11.0, 9.0),
(0, 0).into(),
scale,
100.0
)
);
}
}
use super::*; use super::*;
#[test] #[test]
@@ -362,37 +428,40 @@ mod tests {
#[test] #[test]
fn no_damage_requests_are_paced_without_buffer_submission() { fn no_damage_requests_are_paced_without_buffer_submission() {
let start = Instant::now(); let start = Duration::ZERO;
let refresh = Duration::from_millis(16); let refresh = Duration::from_millis(16);
let mut clock = CallbackClock::default(); let mut clock = crate::frame_scheduler::FrameScheduler::new(refresh);
assert_eq!(clock.deadline(start, refresh), start); assert_eq!(clock.callback_deadline(start, start).unwrap(), start);
clock.sent(start); clock.callback_sent(start);
for millisecond in 1..16 { for millisecond in 1..16 {
assert_eq!( assert_eq!(
clock.deadline(start + Duration::from_millis(millisecond), refresh), clock
.callback_deadline(start + Duration::from_millis(millisecond), start)
.unwrap(),
start + refresh start + refresh
); );
} }
let late = start + Duration::from_millis(50); let late = start + Duration::from_millis(50);
assert_eq!(clock.deadline(late, refresh), late); assert_eq!(clock.callback_deadline(late, late).unwrap(), late);
clock.sent(late); clock.callback_sent(late);
assert_eq!(clock.deadline(late, refresh), late + refresh); assert_eq!(clock.callback_deadline(late, late).unwrap(), late + refresh);
} }
#[test] #[test]
fn independent_output_callback_clocks_do_not_sum_their_rates() { fn independent_output_callback_clocks_do_not_sum_their_rates() {
let start = Instant::now(); let start = Duration::ZERO;
let mut slow = CallbackClock::default(); let mut slow = crate::frame_scheduler::FrameScheduler::new(Duration::from_millis(16));
let mut fast = CallbackClock::default(); let mut fast = crate::frame_scheduler::FrameScheduler::new(Duration::from_millis(8));
slow.sent(start); slow.callback_sent(start);
fast.sent(start); fast.callback_sent(start);
fast.sent(start + Duration::from_millis(8)); fast.callback_sent(start + Duration::from_millis(8));
assert_eq!( assert_eq!(
slow.deadline(start + Duration::from_millis(8), Duration::from_millis(16)), slow.callback_deadline(start + Duration::from_millis(8), start).unwrap(),
start + Duration::from_millis(16) start + Duration::from_millis(16)
); );
assert_eq!( assert_eq!(
fast.deadline(start + Duration::from_millis(8), Duration::from_millis(8)), fast.callback_deadline(start + Duration::from_millis(8), start).unwrap(),
start + Duration::from_millis(16) start + Duration::from_millis(16)
); );
} }
+83 -63
View File
@@ -1,8 +1,15 @@
use super::*; use super::*;
pub(super) fn rounded_clip_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option<RoundedClipPrograms> { pub(super) fn rounded_clip_program(
resources: &mut RenderResources,
renderer: &mut GlesRenderer,
) -> Option<RoundedClipPrograms> {
let context = renderer.context_id().erased(); let context = renderer.context_id().erased();
if let Some(program) = state.rounded_clip_programs.get(&context) { if let Some(program) = resources
.contexts
.get(&context)
.and_then(|programs| programs.rounded.as_ref())
{
return Some(program.clone()); return Some(program.clone());
} }
@@ -29,22 +36,35 @@ pub(super) fn rounded_clip_program(state: &mut Ferese, renderer: &mut GlesRender
UniformName::new("opacity", UniformType::_1f), UniformName::new("opacity", UniformType::_1f),
UniformName::new("shadow_color", UniformType::_4f), UniformName::new("shadow_color", UniformType::_4f),
]; ];
let texture = renderer.compile_custom_texture_shader(ROUNDED_TEXTURE_SHADER, &texture_uniforms); let texture = renderer.compile_custom_texture_shader(corner_shader(ROUNDED_TEXTURE_SHADER), &texture_uniforms);
let border = renderer.compile_custom_pixel_shader(ROUNDED_BORDER_SHADER, &border_uniforms); let border = renderer.compile_custom_pixel_shader(corner_shader(ROUNDED_BORDER_SHADER), &border_uniforms);
let shadow = renderer.compile_custom_pixel_shader(WINDOW_SHADOW_SHADER, &shadow_uniforms); let shadow = renderer.compile_custom_pixel_shader(corner_shader(WINDOW_SHADOW_SHADER), &shadow_uniforms);
let compiled = texture.and_then(|texture| border.and_then(|border| shadow.map(|shadow| (texture, border, shadow)))); let solid = renderer.compile_custom_pixel_shader(
corner_shader(ROUNDED_SOLID_SHADER),
&[
UniformName::new("clip_rect", UniformType::_4f),
UniformName::new("radius", UniformType::_1f),
UniformName::new("color", UniformType::_4f),
],
);
let compiled = texture.and_then(|texture| {
border.and_then(|border| shadow.and_then(|shadow| solid.map(|solid| (texture, border, shadow, solid))))
});
match compiled { match compiled {
Ok((texture, border, shadow)) => { Ok((texture, border, shadow, solid)) => {
let programs = RoundedClipPrograms { let programs = RoundedClipPrograms {
texture, texture,
solid,
border, border,
shadow, shadow,
}; };
state.rounded_clip_programs.insert(context, programs.clone()); resources.contexts.entry(context).or_default().rounded = Some(programs.clone());
Some(programs) Some(programs)
} }
Err(error) => { Err(error) => {
tracing::error!(%error, "failed to compile rounded-window shader"); if !std::mem::replace(&mut resources.contexts.entry(context).or_default().rounded_warned, true) {
tracing::warn!(%error, "rounded-window shader unavailable; falling back to unrounded rendering");
}
None None
} }
} }
@@ -52,13 +72,13 @@ pub(super) fn rounded_clip_program(state: &mut Ferese, renderer: &mut GlesRender
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(super) fn window_border_element( pub(super) fn window_border_element(
state: &mut Ferese, resources: &mut RenderResources,
theme: &crate::config::ThemeSettings,
renderer: &GlesRenderer, renderer: &GlesRenderer,
id: ferese_layout::WindowId, id: ferese_layout::WindowId,
geometry: Rectangle<i32, Logical>, geometry: Rectangle<i32, Logical>,
physical: Rectangle<i32, Physical>, corners: RoundedRect,
scale: f64, scale: f64,
requested_radius: f64,
requested_width: f64, requested_width: f64,
color: [f32; 4], color: [f32; 4],
gradient: Option<crate::config::BorderGradient>, gradient: Option<crate::config::BorderGradient>,
@@ -68,7 +88,7 @@ pub(super) fn window_border_element(
programs: &RoundedClipPrograms, programs: &RoundedClipPrograms,
) -> Option<PhysicalShaderElement> { ) -> Option<PhysicalShaderElement> {
let mode = output.current_mode()?; let mode = output.current_mode()?;
let width = scaled_effect_value(requested_width, geometry, scale); let width = clamp_radius(requested_width * scale, corners.rect.size);
if width == 0.0 { if width == 0.0 {
return None; return None;
} }
@@ -77,26 +97,27 @@ pub(super) fn window_border_element(
Some(gradient) => ( Some(gradient) => (
gradient.from.0, gradient.from.0,
gradient.to.0, gradient.to.0,
border_gradient_line(physical, mode.size, output.current_transform().invert(), gradient.angle), border_gradient_line(
corners.rect,
mode.size,
output.current_transform().invert(),
gradient.angle,
),
), ),
None => (color, color, [0.0; 4]), None => (color, color, [0.0; 4]),
}; };
let (focus_from, focus_to, focus_gradient_line) = match state.theme_settings.focus_ring_gradient { let (focus_from, focus_to, focus_gradient_line) = match theme.focus_ring_gradient {
Some(g) => ( Some(g) => (
g.from.0, g.from.0,
g.to.0, g.to.0,
border_gradient_line(physical, mode.size, output.current_transform().invert(), g.angle), border_gradient_line(corners.rect, mode.size, output.current_transform().invert(), g.angle),
),
None => (
state.theme_settings.accent_color.0,
state.theme_settings.accent_color.0,
[0.0; 4],
), ),
None => (theme.accent_color.0, theme.accent_color.0, [0.0; 4]),
}; };
let parameters = BorderParameters { let parameters = BorderParameters {
geometry, geometry,
clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), clip_rect: framebuffer_clip_rect(corners.rect, mode.size, output.current_transform().invert()),
radius: scaled_effect_value(requested_radius, geometry, scale), radius: corners.radius,
width, width,
color: color_with_alpha(from, opacity), color: color_with_alpha(from, opacity),
color_to: color_with_alpha(to, opacity), color_to: color_with_alpha(to, opacity),
@@ -107,7 +128,7 @@ pub(super) fn window_border_element(
focus_mix, focus_mix,
}; };
let context = renderer.context_id().erased(); let context = renderer.context_id().erased();
let buffers = state.window_borders.entry(id).or_default(); let buffers = &mut resources.windows.entry(id).or_default().borders;
if !buffers.contexts.contains_key(&context) { if !buffers.contexts.contains_key(&context) {
let element = PixelShaderElement::new( let element = PixelShaderElement::new(
programs.border.clone(), programs.border.clone(),
@@ -137,7 +158,7 @@ pub(super) fn window_border_element(
Some(PhysicalShaderElement { Some(PhysicalShaderElement {
inner: cached.element.clone(), inner: cached.element.clone(),
geometry: physical, geometry: corners.rect,
}) })
} }
@@ -158,31 +179,29 @@ pub(super) fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'sta
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(super) fn window_tint_element( pub(super) fn window_tint_element(
state: &mut Ferese, resources: &mut RenderResources,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
id: ferese_layout::WindowId, id: ferese_layout::WindowId,
geometry: Rectangle<i32, Logical>, geometry: Rectangle<i32, Logical>,
physical: Rectangle<i32, Physical>, corners: RoundedRect,
scale: f64,
radius: f64,
color: [f32; 4], color: [f32; 4],
resize_fill: bool, resize_fill: bool,
output: &Output, output: &Output,
) -> Option<PhysicalShaderElement> { ) -> Option<PhysicalShaderElement> {
if color[3] <= 0.0 { if color[3] <= 0.0 {
if resize_fill { if resize_fill {
state.window_resize_fills.remove(&id); resources.clear_tint(id, true);
} else { } else {
state.window_dims.remove(&id); resources.clear_tint(id, false);
} }
return None; return None;
} }
let mode = output.current_mode()?; let mode = output.current_mode()?;
let program = material_program(state, renderer)?; let program = material_program(resources, renderer)?;
let parameters = BorderParameters { let parameters = BorderParameters {
geometry, geometry,
clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), clip_rect: framebuffer_clip_rect(corners.rect, mode.size, output.current_transform().invert()),
radius: scaled_effect_value(radius, geometry, scale), radius: corners.radius,
width: 0.0, width: 0.0,
color, color,
color_to: color, color_to: color,
@@ -204,9 +223,9 @@ pub(super) fn window_tint_element(
}; };
let context = renderer.context_id().erased(); let context = renderer.context_id().erased();
let buffers = if resize_fill { let buffers = if resize_fill {
state.window_resize_fills.entry(id).or_default() &mut resources.windows.entry(id).or_default().resize_fill
} else { } else {
state.window_dims.entry(id).or_default() &mut resources.windows.entry(id).or_default().dim
}; };
let cached = buffers.contexts.entry(context).or_insert_with(|| CachedBorder { let cached = buffers.contexts.entry(context).or_insert_with(|| CachedBorder {
element: PixelShaderElement::new( element: PixelShaderElement::new(
@@ -230,19 +249,18 @@ pub(super) fn window_tint_element(
} }
Some(PhysicalShaderElement { Some(PhysicalShaderElement {
inner: cached.element.clone(), inner: cached.element.clone(),
geometry: physical, geometry: corners.rect,
}) })
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(super) fn window_shadow_element( pub(super) fn window_shadow_element(
state: &mut Ferese, resources: &mut RenderResources,
renderer: &GlesRenderer, renderer: &GlesRenderer,
id: ferese_layout::WindowId, id: ferese_layout::WindowId,
geometry: Rectangle<i32, Logical>, geometry: Rectangle<i32, Logical>,
physical: Rectangle<i32, Physical>, corners: RoundedRect,
scale: f64, scale: f64,
requested_radius: f64,
offset_y: f64, offset_y: f64,
blur: f64, blur: f64,
opacity: f64, opacity: f64,
@@ -256,20 +274,24 @@ pub(super) fn window_shadow_element(
} }
let shadow_geometry = Rectangle::new( let shadow_geometry = Rectangle::new(
(physical.loc.x, physical.loc.y + (offset_y * scale).round() as i32).into(), (
physical.size, corners.rect.loc.x,
corners.rect.loc.y + (offset_y * scale).round() as i32,
)
.into(),
corners.rect.size,
); );
let bounds = shadow_bounds(geometry, offset_y, blur); let bounds = shadow_bounds(geometry, offset_y, blur);
let parameters = ShadowParameters { let parameters = ShadowParameters {
blur: (blur * scale) as f32, blur: (blur * scale) as f32,
bounds, bounds,
shadow_rect: framebuffer_clip_rect(shadow_geometry, mode.size, output.current_transform().invert()), shadow_rect: framebuffer_clip_rect(shadow_geometry, mode.size, output.current_transform().invert()),
radius: scaled_effect_value(requested_radius, geometry, scale), radius: corners.radius,
opacity: opacity as f32, opacity: opacity as f32,
color, color,
}; };
let context = renderer.context_id().erased(); let context = renderer.context_id().erased();
let buffers = state.window_shadows.entry(id).or_default(); let buffers = &mut resources.windows.entry(id).or_default().shadow;
if !buffers.contexts.contains_key(&context) { if !buffers.contexts.contains_key(&context) {
let element = PixelShaderElement::new( let element = PixelShaderElement::new(
programs.shadow.clone(), programs.shadow.clone(),
@@ -327,25 +349,16 @@ pub(super) fn shadow_bounds(geometry: Rectangle<i32, Logical>, offset_y: f64, bl
) )
} }
pub(super) fn scaled_effect_value(requested: f64, geometry: Rectangle<i32, Logical>, scale: f64) -> f32 {
requested
.min(f64::from(geometry.size.w.min(geometry.size.h)) / 2.0)
.max(0.0) as f32
* scale as f32
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub(super) fn rounded_window_elements( pub(super) fn rounded_window_elements(
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
window: &smithay::desktop::Window, window: &smithay::desktop::Window,
constrain: Rectangle<i32, Logical>, corners: RoundedRect,
physical_constrain: Rectangle<i32, Physical>,
scale: f64, scale: f64,
requested_radius: f64,
alpha: f32, alpha: f32,
clip_changed: bool, clip_changed: bool,
output: &Output, output: &Output,
program: GlesTexProgram, programs: RoundedClipPrograms,
behavior: ConstrainScaleBehavior, behavior: ConstrainScaleBehavior,
) -> Vec<AnimatedWindowRenderElement> { ) -> Vec<AnimatedWindowRenderElement> {
let Some(toplevel) = window.toplevel() else { let Some(toplevel) = window.toplevel() else {
@@ -355,11 +368,12 @@ pub(super) fn rounded_window_elements(
return Vec::new(); return Vec::new();
}; };
let physical_constrain = corners.rect;
let geometry = window.geometry(); let geometry = window.geometry();
let reference = geometry.to_physical_precise_round(scale); let reference = geometry.to_physical_precise_round(scale);
let location = physical_constrain.loc - geometry.loc.to_physical_precise_round(scale); let location = physical_constrain.loc - geometry.loc.to_physical_precise_round(scale);
let clip = framebuffer_clip_rect(physical_constrain, mode.size, output.current_transform().invert()); let clip = framebuffer_clip_rect(physical_constrain, mode.size, output.current_transform().invert());
let radius = scaled_effect_value(requested_radius, constrain, scale); let radius = corners.radius;
let surface = toplevel.wl_surface(); let surface = toplevel.wl_surface();
let mut content = PopupManager::popups_for_surface(surface) let mut content = PopupManager::popups_for_surface(surface)
@@ -389,14 +403,20 @@ pub(super) fn rounded_window_elements(
) )
.into_iter() .into_iter()
.map(|inner| { .map(|inner| {
RoundedSurfaceRenderElement { if radius == 0.0 {
inner, // Rectangular clipping is already enforced by the outer crop
program: program.clone(), // element. Keep the raw surface available for fullscreen scanout.
clip_rect: clip, WindowContentRenderElement::from(inner)
radius, } else {
clip_changed, RoundedSurfaceRenderElement {
inner,
programs: programs.clone(),
clip_rect: clip,
radius,
clip_changed,
}
.into()
} }
.into()
}), }),
); );
+95 -41
View File
@@ -20,23 +20,16 @@ pub(super) fn append_material_surface(
}; };
let regions = crate::effects::surface_regions(surface); let regions = crate::effects::surface_regions(surface);
let targets: Vec<_> = match &regions { let targets: Vec<_> = match &regions {
None => vec![(geometry, radius as f32)], None => vec![(geometry, RoundedRect::from_logical(geometry, scale, radius))],
Some(regions) => regions Some(regions) => regions
.iter() .iter()
.filter_map(|r| { .filter_map(|r| material_region_geometry(*r, geometry, content_origin, scale))
Rectangle::new(
(content_origin.x + r[0], content_origin.y + r[1]).into(),
(r[2], r[3]).into(),
)
.intersection(geometry)
.map(|rect| (rect, r[4] as f32))
})
.collect(), .collect(),
}; };
let materials: Vec<_> = targets let materials: Vec<_> = targets
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(index, (rect, radius))| { .filter_map(|(index, (rect, corners))| {
material_element( material_element(
state, state,
renderer, renderer,
@@ -44,7 +37,7 @@ pub(super) fn append_material_surface(
surface, surface,
MaterialSurface { MaterialSurface {
geometry: *rect, geometry: *rect,
radius: *radius, corners: *corners,
index, index,
capture_geometry: geometry, capture_geometry: geometry,
alpha: 1.0, alpha: 1.0,
@@ -52,21 +45,18 @@ pub(super) fn append_material_surface(
) )
}) })
.collect(); .collect();
if let Some(buffers) = state.material_buffers.get_mut(surface) { if let Some(buffers) = state.render.surfaces.get_mut(surface) {
buffers.contexts.retain(|(_, index), _| *index < targets.len()); buffers.contexts.retain(|(_, index), _| *index < targets.len());
} }
let material = (!materials.is_empty() && regions.is_none()).then_some(()); let material = (!materials.is_empty() && regions.is_none()).then_some(());
let clip = material.as_ref().and_then(|_| { let clip = material.as_ref().and_then(|_| {
let corners = targets.first()?.1;
let mode = output.current_mode()?; let mode = output.current_mode()?;
let program = rounded_clip_program(state, renderer)?; let program = rounded_clip_program(&mut state.render, renderer)?;
Some(( Some((
program.texture, program,
framebuffer_clip_rect( framebuffer_clip_rect(corners.rect, mode.size, output.current_transform().invert()),
geometry.to_physical_precise_round(scale), corners.radius,
mode.size,
output.current_transform().invert(),
),
scaled_effect_value(radius, geometry, scale),
)) ))
}); });
// Smithay element lists are front to back: content, material, shadow. // Smithay element lists are front to back: content, material, shadow.
@@ -83,7 +73,7 @@ pub(super) fn append_material_surface(
let element = if let Some((program, clip_rect, radius)) = &clip { let element = if let Some((program, clip_rect, radius)) = &clip {
WindowContentRenderElement::Rounded(RoundedSurfaceRenderElement { WindowContentRenderElement::Rounded(RoundedSurfaceRenderElement {
inner: element, inner: element,
program: program.clone(), programs: program.clone(),
clip_rect: *clip_rect, clip_rect: *clip_rect,
radius: *radius, radius: *radius,
clip_changed: false, clip_changed: false,
@@ -101,9 +91,30 @@ pub(super) fn append_material_surface(
} }
} }
/// Preserve the surface-local fractional edges until the shared physical snap.
fn material_region_geometry(
region: [f64; 5],
geometry: Rectangle<i32, Logical>,
origin: Point<i32, Logical>,
scale: f64,
) -> Option<(Rectangle<i32, Logical>, RoundedRect)> {
let rect = Rectangle::<f64, Logical>::new(
(f64::from(origin.x) + region[0], f64::from(origin.y) + region[1]).into(),
(region[2], region[3]).into(),
)
.intersection(geometry.to_f64())?;
let corners = RoundedRect::new(
ferese_layout::Rect::new(rect.loc.x, rect.loc.y, rect.size.w, rect.size.h),
(0, 0).into(),
scale,
region[4],
);
Some((rect.to_i32_up(), corners))
}
pub(super) struct MaterialSurface { pub(super) struct MaterialSurface {
pub geometry: Rectangle<i32, Logical>, pub geometry: Rectangle<i32, Logical>,
pub radius: f32, pub corners: RoundedRect,
pub index: usize, pub index: usize,
pub capture_geometry: Rectangle<i32, Logical>, pub capture_geometry: Rectangle<i32, Logical>,
pub alpha: f32, pub alpha: f32,
@@ -118,7 +129,7 @@ pub(super) fn material_element(
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { ) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
let MaterialSurface { let MaterialSurface {
geometry, geometry,
radius, corners,
index, index,
capture_geometry, capture_geometry,
alpha, alpha,
@@ -139,7 +150,7 @@ pub(super) fn material_element(
state.theme_settings.surface_base_color state.theme_settings.surface_base_color
}; };
let [red, green, blue, _] = background.0; let [red, green, blue, _] = background.0;
let radius = radius.min(geometry.size.w.min(geometry.size.h) as f32 * 0.5); let radius = corners.radius;
let [offset_y, shadow_blur, shadow_opacity] = material.shadow; 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 edge_bar = role == crate::effects::SemanticRole::Panel && radius == 0.0 && geometry.loc.y == 0;
let shadow_opacity = if edge_bar { let shadow_opacity = if edge_bar {
@@ -148,8 +159,12 @@ pub(super) fn material_element(
shadow_opacity * (state.theme_settings.shadow_opacity / 0.2) * f64::from(presentation_alpha) shadow_opacity * (state.theme_settings.shadow_opacity / 0.2) * f64::from(presentation_alpha)
}; };
let shadow_geometry = Rectangle::new( let shadow_geometry = Rectangle::new(
(geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(), (
geometry.size, corners.rect.loc.x,
corners.rect.loc.y + (offset_y * scale).round() as i32,
)
.into(),
corners.rect.size,
); );
let background_opacity = material.opacity; let background_opacity = material.opacity;
let blur = material_blur_radius(material.style, material.opacity, state.theme_settings.backdrop_blur); let blur = material_blur_radius(material.style, material.opacity, state.theme_settings.backdrop_blur);
@@ -169,22 +184,24 @@ pub(super) fn material_element(
sample_geometry, sample_geometry,
sample_physical, sample_physical,
sample_framebuffer: framebuffer_clip_rect(sample_physical, mode.size, transform), sample_framebuffer: framebuffer_clip_rect(sample_physical, mode.size, transform),
radius: radius * scale as f32, radius,
shadow_rect: framebuffer_clip_rect(shadow_geometry.to_physical_precise_round(scale), mode.size, transform), shadow_rect: framebuffer_clip_rect(shadow_geometry, mode.size, transform),
shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32], shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32],
shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur), shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur),
geometry, geometry,
tint: [red, green, blue, material.opacity * presentation_alpha], tint: [red, green, blue, material.opacity * presentation_alpha],
generation, generation,
opaque: material.opacity == 1.0 && presentation_alpha == 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, transform), visible_framebuffer: framebuffer_clip_rect(corners.rect, mode.size, transform),
}; };
let context = renderer.context_id().erased(); let context = renderer.context_id().erased();
let program = material_program(state, renderer)?; let program = material_program(&mut state.render, renderer)?;
let blur_program = (blur > 0.0).then(|| blur_program(state, renderer)).flatten(); let blur_program = (blur > 0.0)
.then(|| blur_program(&mut state.render, renderer))
.flatten();
let capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer); let capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer);
let capture_size = Size::from((capture_rect[2], capture_rect[3])); let capture_size = Size::from((capture_rect[2], capture_rect[3]));
let buffers = state.material_buffers.entry(surface.clone()).or_default(); let buffers = state.render.surfaces.entry(surface.clone()).or_default();
let capture = if let Some(blur_program) = blur_program { let capture = if let Some(blur_program) = blur_program {
if buffers if buffers
.captures .captures
@@ -281,7 +298,14 @@ pub(super) fn material_element(
cached.parameters = parameters; cached.parameters = parameters;
} }
let background = match &cached.element { let background = match &cached.element {
MaterialElement::Fill(element) => element.clone().into(), // Keep the shader's opaque regions when its canvas already has the
// shared edges. Animated modal geometry may need a physical override.
MaterialElement::Fill(element) if element.geometry(scale.into()) == corners.rect => element.clone().into(),
MaterialElement::Fill(element) => PhysicalShaderElement {
inner: element.clone(),
geometry: corners.rect,
}
.into(),
MaterialElement::Blur(element) => element.clone().into(), MaterialElement::Blur(element) => element.clone().into(),
}; };
Some((background, cached.shadow.clone().into())) Some((background, cached.shadow.clone().into()))
@@ -295,15 +319,19 @@ pub(super) fn material_blur_radius(style: crate::config::MaterialStyle, opacity:
} }
} }
pub(super) fn blur_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option<BlurProgram> { pub(super) fn blur_program(resources: &mut RenderResources, renderer: &mut GlesRenderer) -> Option<BlurProgram> {
let context = renderer.context_id().erased(); let context = renderer.context_id().erased();
if let Some(program) = state.blur_programs.get(&context) { if let Some(program) = resources
.contexts
.get(&context)
.and_then(|programs| programs.blur.as_ref())
{
return Some(program.clone()); return Some(program.clone());
} }
match renderer.compile_custom_texture_shader(BLUR_SHADER, &blur_uniform_names()) { match renderer.compile_custom_texture_shader(corner_shader(BLUR_SHADER), &blur_uniform_names()) {
Ok(program) => { Ok(program) => {
let program = BlurProgram(program); let program = BlurProgram(program);
state.blur_programs.insert(context, program.clone()); resources.contexts.entry(context).or_default().blur = Some(program.clone());
Some(program) Some(program)
} }
Err(error) => { Err(error) => {
@@ -358,9 +386,16 @@ pub(super) fn expanded_blur_region(
) )
} }
pub(super) fn material_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option<MaterialProgram> { pub(super) fn material_program(
resources: &mut RenderResources,
renderer: &mut GlesRenderer,
) -> Option<MaterialProgram> {
let context = renderer.context_id().erased(); let context = renderer.context_id().erased();
if let Some(program) = state.material_programs.get(&context) { if let Some(program) = resources
.contexts
.get(&context)
.and_then(|programs| programs.material.as_ref())
{
return Some(program.clone()); return Some(program.clone());
} }
@@ -372,10 +407,10 @@ pub(super) fn material_program(state: &mut Ferese, renderer: &mut GlesRenderer)
UniformName::new("shadow_rect", UniformType::_4f), UniformName::new("shadow_rect", UniformType::_4f),
UniformName::new("shadow_values", UniformType::_2f), UniformName::new("shadow_values", UniformType::_2f),
]; ];
match renderer.compile_custom_pixel_shader(MATERIAL_SHADER, &uniforms) { match renderer.compile_custom_pixel_shader(corner_shader(MATERIAL_SHADER), &uniforms) {
Ok(program) => { Ok(program) => {
let program = MaterialProgram(program); let program = MaterialProgram(program);
state.material_programs.insert(context, program.clone()); resources.contexts.entry(context).or_default().material = Some(program.clone());
Some(program) Some(program)
} }
Err(error) => { Err(error) => {
@@ -412,3 +447,22 @@ pub(super) fn blur_damage(
DamageSet::from_slice(&[Rectangle::from_size((size.w, size.h).into())]) DamageSet::from_slice(&[Rectangle::from_size((size.w, size.h).into())])
} }
} }
#[cfg(test)]
mod region_geometry_tests {
use super::*;
#[test]
fn fractional_regions_snap_once_in_output_pixels() {
let bounds = Rectangle::from_size((200, 100).into());
let (_, corners) = material_region_geometry([0.4, 0.4, 100.4, 40.4, 14.0], bounds, (0, 0).into(), 1.5).unwrap();
assert_eq!(corners.rect, Rectangle::new((1, 1).into(), (150, 60).into()));
assert_eq!(corners.radius, 21.0);
// The old logical rounding would put this at physical (0, 0).
let (_, clipped) =
material_region_geometry([-0.4, -0.4, 100.4, 40.4, 100.0], bounds, (0, 0).into(), 1.5).unwrap();
assert_eq!(clipped.rect, Rectangle::from_size((150, 60).into()));
assert_eq!(clipped.radius, 30.0);
assert!(material_region_geometry([300.0, 0.0, 10.0, 10.0, 2.0], bounds, (0, 0).into(), 1.5).is_none());
}
}
+115 -9
View File
@@ -2,8 +2,8 @@ mod capture;
mod decorations; mod decorations;
mod materials; mod materials;
mod overview; mod overview;
mod resources;
mod scene; mod scene;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -13,9 +13,10 @@ pub(crate) use capture::{capture_resize_snapshot, capture_window_buffer, capture
use decorations::*; use decorations::*;
use materials::*; use materials::*;
use overview::*; use overview::*;
pub(crate) use resources::RenderResources;
use scene::cursor_elements; use scene::cursor_elements;
pub(crate) use scene::{ pub(crate) use scene::{
animated_window_elements, cursorless_window_elements, frame_effect_metrics, layer_surfaces, output_elements, animated_window_elements, frame_effect_metrics, layer_surfaces, output_elements, sampled_output_elements,
}; };
use smithay::backend::allocator::Fourcc; use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement;
@@ -48,7 +49,7 @@ use smithay::wayland::shell::wlr_layer::Layer;
use crate::Ferese; use crate::Ferese;
use crate::metrics::FrameEffectMetrics; use crate::metrics::FrameEffectMetrics;
use crate::presentation::{NativeTextureElement, PhysicalShaderElement, physical_rect}; use crate::presentation::{NativeTextureElement, PhysicalShaderElement, RoundedRect, clamp_radius, physical_rect};
type SurfaceRenderElement = type SurfaceRenderElement =
CropRenderElement<RelocateRenderElement<RescaleRenderElement<WaylandSurfaceRenderElement<GlesRenderer>>>>; CropRenderElement<RelocateRenderElement<RescaleRenderElement<WaylandSurfaceRenderElement<GlesRenderer>>>>;
@@ -96,6 +97,8 @@ render_elements! {
const ROUNDED_TEXTURE_SHADER: &str = include_str!("shaders/rounded_texture_shader.frag"); const ROUNDED_TEXTURE_SHADER: &str = include_str!("shaders/rounded_texture_shader.frag");
const ROUNDED_SOLID_SHADER: &str = include_str!("shaders/rounded_solid_shader.frag");
const ROUNDED_BORDER_SHADER: &str = include_str!("shaders/rounded_border_shader.frag"); const ROUNDED_BORDER_SHADER: &str = include_str!("shaders/rounded_border_shader.frag");
const WINDOW_SHADOW_SHADER: &str = include_str!("shaders/window_shadow_shader.frag"); const WINDOW_SHADOW_SHADER: &str = include_str!("shaders/window_shadow_shader.frag");
@@ -106,6 +109,10 @@ const MATERIAL_SHADER: &str = include_str!("shaders/material_shader.frag");
const BLUR_SHADER: &str = include_str!("shaders/blur_shader.frag"); const BLUR_SHADER: &str = include_str!("shaders/blur_shader.frag");
fn corner_shader(source: &str) -> String {
source.replace("//_CORNERS_", include_str!("shaders/corners.glsl"))
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct BlurProgram(GlesTexProgram); pub(crate) struct BlurProgram(GlesTexProgram);
@@ -276,6 +283,7 @@ impl RenderElement<GlesRenderer> for BlurRenderElement {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct RoundedClipPrograms { pub(crate) struct RoundedClipPrograms {
solid: GlesPixelProgram,
texture: GlesTexProgram, texture: GlesTexProgram,
border: GlesPixelProgram, border: GlesPixelProgram,
shadow: GlesPixelProgram, shadow: GlesPixelProgram,
@@ -386,7 +394,7 @@ pub(crate) struct WindowShadowBuffers {
#[derive(Debug)] #[derive(Debug)]
struct RoundedSurfaceRenderElement { struct RoundedSurfaceRenderElement {
inner: WaylandSurfaceRenderElement<GlesRenderer>, inner: WaylandSurfaceRenderElement<GlesRenderer>,
program: GlesTexProgram, programs: RoundedClipPrograms,
clip_rect: [f32; 4], clip_rect: [f32; 4],
radius: f32, radius: f32,
clip_changed: bool, clip_changed: bool,
@@ -455,18 +463,31 @@ impl RenderElement<GlesRenderer> for RoundedSurfaceRenderElement {
opaque_regions, opaque_regions,
self.transform(), self.transform(),
self.alpha(), self.alpha(),
Some(&self.program), Some(&self.programs.texture),
&[ &[
Uniform::new("clip_rect", self.clip_rect), Uniform::new("clip_rect", self.clip_rect),
Uniform::new("radius", self.radius), Uniform::new("radius", self.radius),
], ],
), ),
WaylandSurfaceTexture::SolidColor(_) => self.inner.draw(frame, src, dst, damage, opaque_regions), WaylandSurfaceTexture::SolidColor(color) => frame.render_pixel_shader_to(
&self.programs.solid,
src,
dst,
(dst.size.w, dst.size.h).into(),
Some(damage),
self.alpha(),
&[
Uniform::new("clip_rect", self.clip_rect),
Uniform::new("radius", self.radius),
Uniform::new("color", color.components()),
],
),
} }
} }
fn underlying_storage(&self, renderer: &mut GlesRenderer) -> Option<UnderlyingStorage<'_>> { fn underlying_storage(&self, _renderer: &mut GlesRenderer) -> Option<UnderlyingStorage<'_>> {
self.inner.underlying_storage(renderer) // Storage is deliberately hidden: plane scanout would bypass clipping.
None
} }
} }
@@ -510,6 +531,91 @@ mod tests {
scaled_visual_rect, shadow_bounds, scaled_visual_rect, shadow_bounds,
}; };
#[test]
#[ignore = "requires an EGL rendering device"]
fn solid_corner_mask_preserves_premultiplied_color() {
use smithay::backend::allocator::Fourcc;
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture, Uniform, UniformName, UniformType};
use smithay::backend::renderer::{Bind, Color32F, ExportMem, Frame, Offscreen, Renderer};
let device = EGLDevice::enumerate().unwrap().last().expect("an EGL device");
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let program = renderer
.compile_custom_pixel_shader(
super::corner_shader(super::ROUNDED_SOLID_SHADER),
&[
UniformName::new("clip_rect", UniformType::_4f),
UniformName::new("radius", UniformType::_1f),
UniformName::new("color", UniformType::_4f),
],
)
.unwrap();
let size = (32, 32).into();
let mut texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, size).unwrap();
let rect = Rectangle::<i32, Physical>::from_size((32, 32).into());
for (radius, corner_alpha, inset) in [(12.0f32, 0u8, 0.0f32), (0.0, 64, 0.0), (0.0, 48, 0.25)] {
let mut target = renderer.bind(&mut texture).unwrap();
{
let mut frame = renderer
.render(&mut target, (32, 32).into(), Transform::Normal)
.unwrap();
frame.clear(Color32F::TRANSPARENT, &[rect]).unwrap();
frame
.render_pixel_shader_to(
&program,
Rectangle::from_size(size.to_f64()),
rect,
size,
Some(&[rect]),
0.5,
&[
Uniform::new("clip_rect", [inset, inset, 32.0 - 2.0 * inset, 32.0 - 2.0 * inset]),
Uniform::new("radius", radius),
Uniform::new("color", [0.5f32, 0.0, 0.0, 0.5]),
],
)
.unwrap();
let _ = frame.finish().unwrap();
}
let mapping = renderer
.copy_framebuffer(&target, Rectangle::from_size(size), Fourcc::Abgr8888)
.unwrap();
let pixels = renderer.map_texture(&mapping).unwrap();
let center = (16 * 32 + 16) * 4;
assert!(pixels[center].abs_diff(64) <= 1);
assert!(pixels[center + 3].abs_diff(64) <= 1);
assert!(pixels[3].abs_diff(corner_alpha) <= 1);
}
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn shared_corner_shaders_compile() {
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
use smithay::backend::renderer::gles::GlesRenderer;
let device = EGLDevice::enumerate().unwrap().last().expect("an EGL device");
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
for source in [super::ROUNDED_TEXTURE_SHADER, super::BLUR_SHADER] {
renderer
.compile_custom_texture_shader(super::corner_shader(source), &[])
.unwrap();
}
for source in [
super::ROUNDED_SOLID_SHADER,
super::ROUNDED_BORDER_SHADER,
super::WINDOW_SHADOW_SHADER,
super::MATERIAL_SHADER,
] {
renderer
.compile_custom_pixel_shader(super::corner_shader(source), &[])
.unwrap();
}
}
#[test] #[test]
#[ignore = "requires an EGL rendering device"] #[ignore = "requires an EGL rendering device"]
fn translucent_blur_does_not_leak_the_sharp_backdrop() { fn translucent_blur_does_not_leak_the_sharp_backdrop() {
@@ -523,7 +629,7 @@ mod tests {
let context = EGLContext::new(&display).unwrap(); let context = EGLContext::new(&display).unwrap();
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() }; let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let program = renderer let program = renderer
.compile_custom_texture_shader(super::BLUR_SHADER, &super::blur_uniform_names()) .compile_custom_texture_shader(super::corner_shader(super::BLUR_SHADER), &super::blur_uniform_names())
.unwrap(); .unwrap();
let size = (32, 32).into(); let size = (32, 32).into();
let mut pixels = vec![128u8; 32 * 32 * 4]; let mut pixels = vec![128u8; 32 * 32 * 4];
+19 -18
View File
@@ -15,12 +15,12 @@ pub(super) fn overview_chrome_element(
) -> Option<PhysicalShaderElement> { ) -> Option<PhysicalShaderElement> {
let output_id = state.output_id(output)?; let output_id = state.output_id(output)?;
let geometry = rounded_visual_rect(rect, output_geometry.loc); let geometry = rounded_visual_rect(rect, output_geometry.loc);
let physical = physical_rect(rect, output_geometry.loc, scale); let corners = RoundedRect::new(rect, output_geometry.loc, scale, radius);
let mode = output.current_mode()?; let mode = output.current_mode()?;
let parameters = BorderParameters { let parameters = BorderParameters {
geometry, geometry,
clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), clip_rect: framebuffer_clip_rect(corners.rect, mode.size, output.current_transform().invert()),
radius: (radius * scale) as f32, radius: corners.radius,
width: (width * scale) as f32, width: (width * scale) as f32,
color, color,
color_to: color, color_to: color,
@@ -31,9 +31,9 @@ pub(super) fn overview_chrome_element(
focus_mix: 0.0, focus_mix: 0.0,
}; };
let program = if key.1 == OverviewChromePart::Outline { let program = if key.1 == OverviewChromePart::Outline {
rounded_clip_program(state, renderer)?.border rounded_clip_program(&mut state.render, renderer)?.border
} else { } else {
material_program(state, renderer)?.0 material_program(&mut state.render, renderer)?.0
}; };
let uniforms = |p: &BorderParameters| { let uniforms = |p: &BorderParameters| {
if key.1 == OverviewChromePart::Outline { if key.1 == OverviewChromePart::Outline {
@@ -51,7 +51,8 @@ pub(super) fn overview_chrome_element(
}; };
let context = renderer.context_id().erased(); let context = renderer.context_id().erased();
let chrome = &mut state let chrome = &mut state
.overview_scrims .render
.outputs
.entry(output_id) .entry(output_id)
.or_insert_with(|| OverviewScrim { chrome: HashMap::new() }) .or_insert_with(|| OverviewScrim { chrome: HashMap::new() })
.chrome; .chrome;
@@ -79,7 +80,7 @@ pub(super) fn overview_chrome_element(
} }
Some(PhysicalShaderElement { Some(PhysicalShaderElement {
inner: cached.element.clone(), inner: cached.element.clone(),
geometry: physical, geometry: corners.rect,
}) })
} }
@@ -89,15 +90,16 @@ pub(super) fn overview_strip_elements(
output: &Output, output: &Output,
output_geometry: Rectangle<i32, Logical>, output_geometry: Rectangle<i32, Logical>,
scale: f64, scale: f64,
frame: &crate::state::FrameScene,
) -> Vec<AnimatedWindowRenderElement> { ) -> Vec<AnimatedWindowRenderElement> {
let alpha = state.overview.opacity(); let alpha = frame.overview.opacity();
if alpha <= 0.001 { if alpha <= 0.001 {
return Vec::new(); return Vec::new();
} }
let Some(bounds) = state.output_bounds_for(output) else { let Some(bounds) = state.output_bounds_for(output) else {
return Vec::new(); return Vec::new();
}; };
let cards = state.overview_workspace_cards(output); let cards = state.overview_workspace_cards_for_frame(output, Some(frame));
let strip = crate::overview::workspace_strip(bounds); let strip = crate::overview::workspace_strip(bounds);
let panel = match (cards.first(), cards.last()) { let panel = match (cards.first(), cards.last()) {
(Some(first), Some(last)) => ferese_layout::Rect::new( (Some(first), Some(last)) => ferese_layout::Rect::new(
@@ -116,16 +118,17 @@ pub(super) fn overview_strip_elements(
) )
.opacity; .opacity;
let mut elements = Vec::new(); let mut elements = Vec::new();
let programs = rounded_clip_program(state, renderer); let programs = rounded_clip_program(&mut state.render, renderer);
// Resolve IDs once per strip, rather than scanning all managed windows // Resolve IDs once per strip, rather than scanning all managed windows
// separately for every miniature. Own the handles so rendering can mutate state. // separately for every miniature. Own the handles so rendering can mutate state.
let windows_by_id: std::collections::HashMap<_, _> = state let windows_by_id: std::collections::HashMap<_, _> = state
.window_ids .windows
.ids()
.iter() .iter()
.map(|(window, id)| (*id, window.clone())) .map(|(window, id)| (*id, window.clone()))
.collect(); .collect();
if let Some(output_id) = state.output_id(output) if let Some(output_id) = state.output_id(output)
&& let Some(cache) = state.overview_scrims.get_mut(&output_id) && let Some(cache) = state.render.outputs.get_mut(&output_id)
{ {
cache.chrome.retain(|(id, part), _| match part { cache.chrome.retain(|(id, part), _| match part {
OverviewChromePart::Caption => windows_by_id.keys().any(|window| window.0 == *id), OverviewChromePart::Caption => windows_by_id.keys().any(|window| window.0 == *id),
@@ -139,7 +142,7 @@ pub(super) fn overview_strip_elements(
if !state.window_belongs_to_output(*id, output) { if !state.window_belongs_to_output(*id, output) {
continue; continue;
} }
let Some(rect) = state.presented_window_rect(*id) else { let Some(rect) = frame.windows.get(id).map(|sample| sample.rect) else {
continue; continue;
}; };
let Some((buffer, size)) = state.overview_window_label(window, scale, rect.width) else { let Some((buffer, size)) = state.overview_window_label(window, scale, rect.width) else {
@@ -223,20 +226,18 @@ pub(super) fn overview_strip_elements(
elements.extend(rounded_window_elements( elements.extend(rounded_window_elements(
renderer, renderer,
window, window,
rounded_visual_rect(*rect, output_geometry.loc), RoundedRect::new(*rect, output_geometry.loc, scale, 4.0),
physical_rect(*rect, output_geometry.loc, scale),
scale, scale,
4.0,
alpha, alpha,
true, true,
output, output,
programs.texture.clone(), programs.clone(),
ConstrainScaleBehavior::Stretch, ConstrainScaleBehavior::Stretch,
)); ));
} }
} }
} }
if let Some((buffer, size)) = state.overview_workspace_label(card.workspace, scale) { if let Some((buffer, size)) = state.overview_workspace_label(card.index, scale) {
let location = Point::<i32, Physical>::from(( let location = Point::<i32, Physical>::from((
((card.rect.x - f64::from(output_geometry.loc.x) + card.rect.width * 0.5) * scale).round() as i32 ((card.rect.x - f64::from(output_geometry.loc.x) + card.rect.width * 0.5) * scale).round() as i32
- size.w / 2, - size.w / 2,
+176
View File
@@ -0,0 +1,176 @@
use ferese_core::OutputId;
use ferese_layout::WindowId;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use super::*;
/// Resource ownership follows the lifetime of a window, output, surface or GL
/// context. Desktop policy and animation values do not live in these caches.
#[derive(Default)]
pub(crate) struct RenderResources {
pub(super) windows: HashMap<WindowId, WindowBuffers>,
pub(super) contexts: HashMap<ErasedContextId, ContextPrograms>,
pub(super) outputs: HashMap<OutputId, OverviewScrim>,
pub(super) surfaces: HashMap<WlSurface, MaterialBuffers>,
}
#[derive(Default)]
pub(super) struct WindowBuffers {
pub borders: WindowBorderBuffers,
pub dim: WindowBorderBuffers,
pub resize_fill: WindowBorderBuffers,
pub shadow: WindowShadowBuffers,
snapshot: Option<ResizeSnapshot>,
}
#[derive(Default)]
pub(super) struct ContextPrograms {
pub rounded: Option<RoundedClipPrograms>,
pub rounded_warned: bool,
pub material: Option<MaterialProgram>,
pub blur: Option<BlurProgram>,
}
impl RenderResources {
pub fn remove_window(&mut self, id: WindowId) {
self.windows.remove(&id);
}
pub fn remove_output(&mut self, id: OutputId) {
self.outputs.remove(&id);
}
pub fn snapshot(&self, id: &WindowId) -> Option<&ResizeSnapshot> {
self.windows.get(id)?.snapshot.as_ref()
}
pub fn snapshots(&self) -> impl Iterator<Item = &ResizeSnapshot> {
self.windows.values().filter_map(|window| window.snapshot.as_ref())
}
pub fn set_snapshot(&mut self, id: WindowId, snapshot: ResizeSnapshot) {
self.windows.entry(id).or_default().snapshot = Some(snapshot);
}
pub fn clear_snapshot(&mut self, id: &WindowId) {
if let Some(window) = self.windows.get_mut(id) {
window.snapshot = None;
}
}
pub fn retain_snapshots(&mut self, mut keep: impl FnMut(&WindowId, &mut ResizeSnapshot) -> bool) {
for (id, window) in &mut self.windows {
if let Some(snapshot) = &mut window.snapshot
&& !keep(id, snapshot)
{
window.snapshot = None;
}
}
}
pub(super) fn clear_tint(&mut self, id: WindowId, resize_fill: bool) {
let Some(window) = self.windows.get_mut(&id) else {
return;
};
if resize_fill {
window.resize_fill.contexts.clear();
} else {
window.dim.contexts.clear();
}
}
pub fn forget_context(&mut self, context: &ErasedContextId) {
self.contexts.remove(context);
for window in self.windows.values_mut() {
window.borders.contexts.remove(context);
window.dim.contexts.remove(context);
window.resize_fill.contexts.remove(context);
window.shadow.contexts.remove(context);
if window
.snapshot
.as_ref()
.is_some_and(|snapshot| &snapshot.context == context)
{
window.snapshot = None;
}
}
for output in self.outputs.values_mut() {
for contexts in output.chrome.values_mut() {
contexts.remove(context);
}
}
for surface in self.surfaces.values_mut() {
surface.contexts.retain(|(id, _), _| id != context);
surface.captures.remove(context);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_removal_releases_all_its_caches_without_removing_other_windows() {
let mut resources = RenderResources::default();
resources.windows.insert(WindowId(1), WindowBuffers::default());
resources.windows.insert(WindowId(2), WindowBuffers::default());
resources.remove_window(WindowId(1));
resources.clear_snapshot(&WindowId(1));
resources.clear_tint(WindowId(1), true);
assert!(!resources.windows.contains_key(&WindowId(1)));
assert!(resources.windows.contains_key(&WindowId(2)));
}
#[test]
#[ignore = "requires an EGL rendering device"]
fn context_removal_evicts_programs_and_snapshots_without_touching_another_gpu() {
use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay};
let device = EGLDevice::enumerate().unwrap().last().expect("an EGL device");
let display = unsafe { EGLDisplay::new(device).unwrap() };
let mut first = unsafe { GlesRenderer::new(EGLContext::new(&display).unwrap()).unwrap() };
let mut second = unsafe { GlesRenderer::new(EGLContext::new(&display).unwrap()).unwrap() };
let first_id = first.context_id().erased();
let second_id = second.context_id().erased();
let mut resources = RenderResources::default();
for (id, renderer) in [(WindowId(1), &mut first), (WindowId(2), &mut second)] {
rounded_clip_program(&mut resources, renderer).unwrap();
material_program(&mut resources, renderer).unwrap();
blur_program(&mut resources, renderer).unwrap();
let texture = Offscreen::<GlesTexture>::create_buffer(renderer, Fourcc::Abgr8888, (16, 16).into()).unwrap();
resources.set_snapshot(
id,
ResizeSnapshot {
texture,
context: renderer.context_id().erased(),
id: Id::new(),
commit: CommitCounter::default(),
elapsed: Duration::ZERO,
last_tick: None,
scale: 1.0,
},
);
}
resources.forget_context(&first_id);
assert!(!resources.contexts.contains_key(&first_id));
assert!(resources.contexts.contains_key(&second_id));
assert!(resources.snapshot(&WindowId(1)).is_none());
assert!(resources.snapshot(&WindowId(2)).is_some());
assert_eq!(resources.snapshots().count(), 1);
// Reconnecting a context can build fresh resources after eviction.
rounded_clip_program(&mut resources, &mut first).unwrap();
assert!(resources.contexts.contains_key(&first_id));
}
}
+53 -71
View File
@@ -8,14 +8,6 @@ pub(crate) fn animated_window_elements(
output_elements(state, renderer, output, true) output_elements(state, renderer, output, true)
} }
pub(crate) fn cursorless_window_elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
) -> Vec<AnimatedWindowRenderElement> {
output_elements(state, renderer, output, false)
}
pub(crate) fn frame_effect_metrics(_elements: &[AnimatedWindowRenderElement], _scale: f64) -> FrameEffectMetrics { pub(crate) fn frame_effect_metrics(_elements: &[AnimatedWindowRenderElement], _scale: f64) -> FrameEffectMetrics {
FrameEffectMetrics FrameEffectMetrics
} }
@@ -25,9 +17,20 @@ pub(crate) fn output_elements(
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
output: &Output, output: &Output,
include_cursor: bool, include_cursor: bool,
) -> Vec<AnimatedWindowRenderElement> {
let frame = state.sample_frame(output, Duration::ZERO);
sampled_output_elements(state, renderer, output, include_cursor, &frame)
}
pub(crate) fn sampled_output_elements(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
include_cursor: bool,
frame: &crate::state::FrameScene,
) -> Vec<AnimatedWindowRenderElement> { ) -> Vec<AnimatedWindowRenderElement> {
if state.session_lock.active { if state.session_lock.active {
state.configure_lock_surfaces();
let Some(geometry) = state.space.output_geometry(output) else { let Some(geometry) = state.space.output_geometry(output) else {
return Vec::new(); return Vec::new();
}; };
@@ -83,29 +86,36 @@ pub(crate) fn output_elements(
return Vec::new(); return Vec::new();
}; };
let scale = output.current_scale().fractional_scale(); let scale = output.current_scale().fractional_scale();
let rounded_clip_program = rounded_clip_program(state, renderer); let rounded_clip_program = rounded_clip_program(&mut state.render, renderer);
let mut elements = if include_cursor { let mut elements = if include_cursor {
cursor_elements(state, renderer, output_geometry, scale) cursor_elements(state, renderer, output_geometry, scale)
} else { } else {
Vec::new() Vec::new()
}; };
let upper_layers: &[Layer] = if state.output_has_fullscreen(output) { let upper_layers: &[Layer] = if state.output_has_fullscreen_for_frame(output, frame.overview.is_presenting()) {
&[Layer::Overlay] &[Layer::Overlay]
} else { } else {
&[Layer::Overlay, Layer::Top] &[Layer::Overlay, Layer::Top]
}; };
elements.extend(layer_elements(state, renderer, output, upper_layers)); elements.extend(layer_elements(state, renderer, output, upper_layers));
if state.overview.is_presenting() { if frame.overview.is_presenting() {
elements.extend(overview_strip_elements(state, renderer, output, output_geometry, scale)); elements.extend(overview_strip_elements(
state,
renderer,
output,
output_geometry,
scale,
frame,
));
} }
let mut candidates = if state.overview.is_active() { let mut candidates = if state.overview.is_active() {
state.window_ids.keys().cloned().collect::<Vec<_>>() state.windows.ids().keys().cloned().collect::<Vec<_>>()
} else { } else {
state.space.elements().rev().cloned().collect::<Vec<_>>() state.space.elements().rev().cloned().collect::<Vec<_>>()
}; };
if state.overview.is_active() { if state.overview.is_active() {
candidates.sort_by_key(|window| std::cmp::Reverse(state.window_ids.get(window).map_or(0, |id| id.0))); candidates.sort_by_key(|window| std::cmp::Reverse(state.windows.ids().get(window).map_or(0, |id| id.0)));
} }
let windows = candidates let windows = candidates
.iter() .iter()
@@ -115,20 +125,17 @@ pub(crate) fn output_elements(
if !state.window_content_ready(window) { if !state.window_content_ready(window) {
return None; return None;
} }
let id = *state.window_ids.get(window)?; let id = *state.windows.ids().get(window)?;
// Scrolling columns may sit outside their monitor's rectangle. // Scrolling columns may sit outside their monitor's rectangle.
// They must not reappear on a neighboring output just because // They must not reappear on a neighboring output just because
// their global animated coordinates overlap it. // their global animated coordinates overlap it.
if !state.window_belongs_to_output(id, output) { if !state.window_belongs_to_output(id, output) {
return None; return None;
} }
let visual = state.presented_window_rect(id)?; let sample = frame.windows.get(&id)?;
let (close_scale, close_alpha) = state.closing_visual(id); let visual = scaled_visual_rect(sample.rect, sample.close_scale);
let visual = scaled_visual_rect(visual, close_scale); let close_alpha = sample.close_alpha;
let decoration_progress = state let decoration_progress = sample.geometry.decorations.clamp(0.0, 1.0);
.window_geometry
.get(&id)
.map_or(1.0, |geometry| geometry.decorations.clamp(0.0, 1.0));
Some((window.clone(), id, visual, decoration_progress, close_alpha)) Some((window.clone(), id, visual, decoration_progress, close_alpha))
}) })
@@ -136,7 +143,6 @@ pub(crate) fn output_elements(
for (window, id, visual, decoration_progress, close_alpha) in windows { for (window, id, visual, decoration_progress, close_alpha) in windows {
let constrain = rounded_visual_rect(visual, output_geometry.loc); let constrain = rounded_visual_rect(visual, output_geometry.loc);
let pixels = physical_rect(visual, output_geometry.loc, scale);
let material_surface = window let material_surface = window
.toplevel() .toplevel()
.map(|toplevel| toplevel.wl_surface()) .map(|toplevel| toplevel.wl_surface())
@@ -149,19 +155,19 @@ pub(crate) fn output_elements(
} else { } else {
state.theme_settings.window_radius state.theme_settings.window_radius
} * decoration_progress; } * decoration_progress;
let corners = RoundedRect::new(visual, output_geometry.loc, scale, window_radius);
let pixels = corners.rect;
// Only overview/close intentionally scale the complete application. // Only overview/close intentionally scale the complete application.
let scale_content = state.overview.is_presenting() || state.closing_visual(id).0 != 1.0; let scale_content = frame.overview.is_presenting() || frame.windows[&id].close_scale != 1.0;
let behavior = resize_content_behavior(scale_content); let behavior = resize_content_behavior(scale_content);
let dim = state.window_dimming.get(&id).map_or(0.0, |dim| dim.current); let dim = frame.windows[&id].dim;
if let Some(overlay) = window_tint_element( if let Some(overlay) = window_tint_element(
state, &mut state.render,
renderer, renderer,
id, id,
constrain, constrain,
pixels, corners,
scale,
window_radius,
[0.0, 0.0, 0.0, dim as f32 * close_alpha], [0.0, 0.0, 0.0, dim as f32 * close_alpha],
false, false,
output, output,
@@ -176,28 +182,20 @@ pub(crate) fn output_elements(
let shadow_blur = state.theme_settings.shadow_blur; let shadow_blur = state.theme_settings.shadow_blur;
let shadow_opacity = state.theme_settings.shadow_opacity; let shadow_opacity = state.theme_settings.shadow_opacity;
let shadow_color = state.theme_settings.shadow_color.0; let shadow_color = state.theme_settings.shadow_color.0;
let focused = if state.overview.is_active() { let focus = frame.windows[&id].focus;
state.overview_selected(id)
} else {
state.focused_window == Some(id)
};
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 let border_width = state.theme_settings.border_width
+ (state.theme_settings.focus_ring_width - state.theme_settings.border_width) * focus; + (state.theme_settings.focus_ring_width - state.theme_settings.border_width) * focus;
let border_color = state.theme_settings.border_color.0; let border_color = state.theme_settings.border_color.0;
let gradient = state.theme_settings.border_gradient; let gradient = state.theme_settings.border_gradient;
if let Some(border) = window_border_element( if let Some(border) = window_border_element(
state, &mut state.render,
&state.theme_settings,
renderer, renderer,
id, id,
constrain, constrain,
pixels, corners,
scale, scale,
window_radius,
border_width, border_width,
border_color, border_color,
gradient, gradient,
@@ -209,13 +207,12 @@ pub(crate) fn output_elements(
elements.push(border.into()); elements.push(border.into());
} }
let shadow = window_shadow_element( let shadow = window_shadow_element(
state, &mut state.render,
renderer, renderer,
id, id,
constrain, constrain,
pixels, corners,
scale, scale,
window_radius,
shadow_offset_y, shadow_offset_y,
shadow_blur, shadow_blur,
shadow_opacity * f64::from(close_alpha) * decoration_progress, shadow_opacity * f64::from(close_alpha) * decoration_progress,
@@ -224,7 +221,7 @@ pub(crate) fn output_elements(
&programs, &programs,
); );
if !scale_content if !scale_content
&& let Some(snapshot) = state.resize_snapshots.get(&id) && let Some(snapshot) = state.render.snapshot(&id)
&& snapshot.context == renderer.context_id().erased() && snapshot.context == renderer.context_id().erased()
&& (snapshot.scale - scale).abs() < 0.001 && (snapshot.scale - scale).abs() < 0.001
{ {
@@ -250,8 +247,7 @@ pub(crate) fn output_elements(
program: Some(programs.texture.clone()), program: Some(programs.texture.clone()),
uniforms: vec![ uniforms: vec![
Uniform::new("clip_rect", clip).into_owned(), Uniform::new("clip_rect", clip).into_owned(),
Uniform::new("radius", scaled_effect_value(window_radius, constrain, scale)) Uniform::new("radius", corners.radius).into_owned(),
.into_owned(),
], ],
} }
.into(), .into(),
@@ -261,17 +257,12 @@ pub(crate) fn output_elements(
elements.extend(rounded_window_elements( elements.extend(rounded_window_elements(
renderer, renderer,
&window, &window,
constrain, corners,
pixels,
scale, scale,
window_radius,
close_alpha, close_alpha,
state frame.windows[&id].geometry.presentation_changed,
.window_geometry
.get(&id)
.is_some_and(|geometry| geometry.presentation_changed),
output, output,
programs.texture.clone(), programs.clone(),
behavior, behavior,
)); ));
let source = window.geometry().size; let source = window.geometry().size;
@@ -281,24 +272,15 @@ pub(crate) fn output_elements(
{ {
let mut color = state.theme_settings.surface_base_color.0; let mut color = state.theme_settings.surface_base_color.0;
color[3] = close_alpha; color[3] = close_alpha;
if let Some(fill) = window_tint_element( if let Some(fill) =
state, window_tint_element(&mut state.render, renderer, id, constrain, corners, color, true, output)
renderer, {
id,
constrain,
pixels,
scale,
window_radius,
color,
true,
output,
) {
// Front-to-back: fill uncovered strips behind the native // Front-to-back: fill uncovered strips behind the native
// content instead of stretching it or exposing wallpaper. // content instead of stretching it or exposing wallpaper.
elements.push(fill.into()); elements.push(fill.into());
} }
} else { } else {
state.window_resize_fills.remove(&id); state.render.clear_tint(id, true);
} }
if let Some(surface) = material_surface if let Some(surface) = material_surface
&& let Some((background, _)) = material_element( && let Some((background, _)) = material_element(
@@ -308,7 +290,7 @@ pub(crate) fn output_elements(
surface, surface,
MaterialSurface { MaterialSurface {
geometry: constrain, geometry: constrain,
radius: window_radius as f32, corners,
index: 0, index: 0,
capture_geometry: constrain, capture_geometry: constrain,
alpha: close_alpha, alpha: close_alpha,
@@ -387,7 +369,7 @@ pub(super) fn layer_elements(
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
state.material_buffers.retain(|surface, _| surface.is_alive()); state.render.surfaces.retain(|surface, _| surface.is_alive());
let mut elements = Vec::new(); let mut elements = Vec::new();
for (geometry, layer) in layers { for (geometry, layer) in layers {
@@ -19,11 +19,11 @@ uniform float presentation_alpha;
uniform float background_opacity; uniform float background_opacity;
uniform vec4 tint; uniform vec4 tint;
varying vec2 v_coords; varying vec2 v_coords;
//_CORNERS_
void main() { void main() {
vec2 half_size = visible_rect.zw * 0.5; float sdf = rounded_rect_distance(gl_FragCoord.xy, visible_rect, material_radius);
vec2 d = abs(gl_FragCoord.xy - visible_rect.xy - half_size) - (half_size - vec2(material_radius)); float coverage = alpha * presentation_alpha * edge_coverage(sdf);
float sdf = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - material_radius;
float coverage = alpha * presentation_alpha * (1.0 - smoothstep(-0.5, 0.5, sdf));
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; } if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size; vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size;
vec4 color = vec4(0.0); vec4 color = vec4(0.0);
@@ -0,0 +1,11 @@
// Coordinates and radius are physical pixels. Rust clamps the outer radius.
float rounded_rect_distance(vec2 point, vec4 rect, float radius) {
vec2 half_size = rect.zw * 0.5;
vec2 d = abs(point - rect.xy - half_size) - (half_size - vec2(radius));
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - radius;
}
float edge_coverage(float distance) {
// Match Iced/WGPU quad edges: a one-physical-pixel linear ramp.
return clamp(0.5 - distance, 0.0, 1.0);
}
@@ -9,17 +9,13 @@ uniform vec4 shadow_rect;
uniform vec2 shadow_values; uniform vec2 shadow_values;
varying vec2 v_coords; varying vec2 v_coords;
float rounded_distance(vec2 point, vec4 rect) { //_CORNERS_
vec2 half_size = rect.zw * 0.5;
float radius = min(material_radius, min(half_size.x, half_size.y));
vec2 d = abs(point - rect.xy - half_size) - (half_size - vec2(radius));
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - radius;
}
void main() { void main() {
float sdf = rounded_distance(gl_FragCoord.xy, visible_rect); float sdf = rounded_rect_distance(gl_FragCoord.xy, visible_rect, material_radius);
float coverage = 1.0 - smoothstep(-0.5, 0.5, sdf); float coverage = edge_coverage(sdf);
if (paint_mode > 0.5) { if (paint_mode > 0.5) {
float distance = max(rounded_distance(gl_FragCoord.xy, shadow_rect), 0.0); float distance = max(rounded_rect_distance(gl_FragCoord.xy, shadow_rect, material_radius), 0.0);
float sigma = max(shadow_values.x * 0.5, 0.5); float sigma = max(shadow_values.x * 0.5, 0.5);
float opacity = exp(-0.5 * distance * distance / (sigma * sigma)) float opacity = exp(-0.5 * distance * distance / (sigma * sigma))
* shadow_values.y * (1.0 - coverage) * alpha; * shadow_values.y * (1.0 - coverage) * alpha;
@@ -18,23 +18,16 @@ varying vec2 v_coords;
uniform float tint; uniform float tint;
#endif #endif
void main() { //_CORNERS_
vec2 point = gl_FragCoord.xy - clip_rect.xy;
vec2 half_size = clip_rect.zw * 0.5;
vec2 distance = abs(point - half_size) - (half_size - vec2(radius));
float signed_distance = length(max(distance, 0.0))
+ min(max(distance.x, distance.y), 0.0)
- radius;
float outer_coverage = 1.0 - smoothstep(-0.5, 0.5, signed_distance);
vec2 inner_half_size = max(half_size - vec2(border_width), vec2(0.0)); void main() {
float signed_distance = rounded_rect_distance(gl_FragCoord.xy, clip_rect, radius);
float outer_coverage = edge_coverage(signed_distance);
vec2 inner_size = max(clip_rect.zw - vec2(2.0 * border_width), vec2(0.0));
vec4 inner_rect = vec4(clip_rect.xy + (clip_rect.zw - inner_size) * 0.5, inner_size);
float inner_radius = max(radius - border_width, 0.0); float inner_radius = max(radius - border_width, 0.0);
vec2 inner_distance = abs(point - half_size) float inner_coverage = edge_coverage(rounded_rect_distance(gl_FragCoord.xy, inner_rect, inner_radius));
- (inner_half_size - vec2(inner_radius));
float inner_signed_distance = length(max(inner_distance, 0.0))
+ min(max(inner_distance.x, inner_distance.y), 0.0)
- inner_radius;
float inner_coverage = 1.0 - smoothstep(-0.5, 0.5, inner_signed_distance);
float coverage = max(outer_coverage - inner_coverage, 0.0); float coverage = max(outer_coverage - inner_coverage, 0.0);
float progress = clamp(dot(gl_FragCoord.xy - gradient_line.xy, gradient_line.zw), 0.0, 1.0); 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 // Interpolate premultiplied endpoints: a transparent endpoint must not
@@ -0,0 +1,13 @@
precision highp float;
uniform float alpha;
uniform vec4 clip_rect;
uniform float radius;
uniform vec4 color;
//_CORNERS_
void main() {
float distance = rounded_rect_distance(gl_FragCoord.xy, clip_rect, radius);
// Single-pixel buffer colors are already premultiplied.
gl_FragColor = color * alpha * edge_coverage(distance);
}
@@ -22,6 +22,8 @@ varying vec2 v_coords;
uniform float tint; uniform float tint;
#endif #endif
//_CORNERS_
void main() { void main() {
vec4 color = texture2D(tex, v_coords); vec4 color = texture2D(tex, v_coords);
@@ -31,13 +33,8 @@ void main() {
color = color * alpha; color = color * alpha;
#endif #endif
vec2 point = gl_FragCoord.xy - clip_rect.xy; float signed_distance = rounded_rect_distance(gl_FragCoord.xy, clip_rect, radius);
vec2 half_size = clip_rect.zw * 0.5; float coverage = edge_coverage(signed_distance);
vec2 distance = abs(point - half_size) - (half_size - vec2(radius));
float signed_distance = length(max(distance, 0.0))
+ min(max(distance.x, distance.y), 0.0)
- radius;
float coverage = 1.0 - smoothstep(-0.5, 0.5, signed_distance);
color *= coverage; color *= coverage;
#if defined(DEBUG_FLAGS) #if defined(DEBUG_FLAGS)
@@ -13,13 +13,10 @@ varying vec2 v_coords;
uniform float tint; uniform float tint;
#endif #endif
//_CORNERS_
void main() { void main() {
vec2 point = gl_FragCoord.xy - shadow_rect.xy; float signed_distance = rounded_rect_distance(gl_FragCoord.xy, shadow_rect, radius);
vec2 half_size = shadow_rect.zw * 0.5;
vec2 distance = abs(point - half_size) - (half_size - vec2(radius));
float signed_distance = length(max(distance, 0.0))
+ min(max(distance.x, distance.y), 0.0)
- radius;
float sigma = max(blur * 0.5, 0.5); float sigma = max(blur * 0.5, 0.5);
float normalized_distance = max(signed_distance, 0.0) / sigma; float normalized_distance = max(signed_distance, 0.0) / sigma;
float coverage = exp(-0.5 * normalized_distance * normalized_distance); float coverage = exp(-0.5 * normalized_distance * normalized_distance);
+28 -18
View File
@@ -318,27 +318,25 @@ impl Ferese {
} }
fn workspace_snapshots(&self) -> Vec<WorkspaceSnapshot> { fn workspace_snapshots(&self) -> Vec<WorkspaceSnapshot> {
self.workspaces self.output_workspaces
.ordered() .workspace_views(&self.workspaces)
.into_iter() .into_iter()
.map(|workspace| { .map(|view| WorkspaceSnapshot {
let output = self.output_workspaces.output_for_workspace(workspace.id); id: view.id,
let active = output: Some(view.output),
output.is_some_and(|output| self.output_workspaces.active_workspace(output) == Some(workspace.id)); name: view.index.to_string(),
index: view.index,
WorkspaceSnapshot { window_count: view.window_count as u32,
id: workspace.id, visible: view.visible,
output, focused: view.focused,
name: workspace.name.clone(),
active,
}
}) })
.collect() .collect()
} }
fn managed_window_snapshots(&self) -> Vec<ManagedWindowSnapshot> { fn managed_window_snapshots(&self) -> Vec<ManagedWindowSnapshot> {
let mut snapshots = self let mut snapshots = self
.window_ids .windows
.ids()
.iter() .iter()
.filter_map(|(window, id)| { .filter_map(|(window, id)| {
let workspace = self.workspaces.workspace_for_window(*id)?.0; let workspace = self.workspaces.workspace_for_window(*id)?.0;
@@ -455,7 +453,10 @@ impl ShellSnapshot {
output_hi, output_hi,
output_lo, output_lo,
workspace.name.clone(), workspace.name.clone(),
u32::from(workspace.active), workspace.index,
workspace.window_count,
u32::from(workspace.visible),
u32::from(workspace.focused),
); );
} }
for snapshot in &self.windows { for snapshot in &self.windows {
@@ -498,7 +499,10 @@ struct WorkspaceSnapshot {
id: WorkspaceId, id: WorkspaceId,
output: Option<OutputId>, output: Option<OutputId>,
name: String, name: String,
active: bool, index: u32,
window_count: u32,
visible: bool,
focused: bool,
} }
fn split_id(id: u64) -> (u32, u32) { fn split_id(id: u64) -> (u32, u32) {
@@ -558,7 +562,10 @@ mod tests {
id: WorkspaceId(1), id: WorkspaceId(1),
output: Some(OutputId(1)), output: Some(OutputId(1)),
name: "1".into(), name: "1".into(),
active: true, index: 1,
window_count: 1,
visible: true,
focused: true,
}], }],
windows: vec![ManagedWindowSnapshot { windows: vec![ManagedWindowSnapshot {
id: WindowId(1), id: WindowId(1),
@@ -574,7 +581,10 @@ mod tests {
|s| s.outputs[0].active_workspace = WorkspaceId(2), |s| s.outputs[0].active_workspace = WorkspaceId(2),
|s| s.outputs.clear(), |s| s.outputs.clear(),
|s| s.workspaces[0].output = None, |s| s.workspaces[0].output = None,
|s| s.workspaces[0].active = false, |s| s.workspaces[0].visible = false,
|s| s.workspaces[0].focused = false,
|s| s.workspaces[0].index = 2,
|s| s.workspaces[0].window_count = 0,
|s| s.workspaces[0].name = "renamed".into(), |s| s.workspaces[0].name = "renamed".into(),
|s| s.windows[0].workspace = 2, |s| s.windows[0].workspace = 2,
|s| s.windows[0].title = "new title".into(), |s| s.windows[0].title = "new title".into(),
+39 -64
View File
@@ -1,12 +1,15 @@
mod animation; mod animation;
mod floating; mod floating;
mod focus;
mod hit_testing; mod hit_testing;
mod layout; mod layout;
mod navigation; mod navigation;
mod outputs; mod outputs;
mod prediction;
pub(crate) use prediction::FrameScene;
mod window_registry;
mod windows; mod windows;
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::error::Error; use std::error::Error;
use std::ffi::OsString; use std::ffi::OsString;
@@ -26,7 +29,7 @@ use ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1;
use smithay::backend::allocator::dmabuf::Dmabuf; use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::drm::{DrmEventTime, DrmNode}; use smithay::backend::drm::{DrmEventTime, DrmNode};
use smithay::backend::input::Keycode; use smithay::backend::input::Keycode;
use smithay::backend::renderer::{ErasedContextId, ImportDma}; use smithay::backend::renderer::ImportDma;
use smithay::desktop::space::SpaceElement; use smithay::desktop::space::SpaceElement;
use smithay::desktop::utils::send_frames_surface_tree; use smithay::desktop::utils::send_frames_surface_tree;
use smithay::desktop::{LayerSurface, PopupKind, PopupManager, Space, Window, WindowSurfaceType, layer_map_for_output}; use smithay::desktop::{LayerSurface, PopupKind, PopupManager, Space, Window, WindowSurfaceType, layer_map_for_output};
@@ -57,6 +60,7 @@ use smithay::wayland::presentation::PresentationState;
use smithay::wayland::relative_pointer::RelativePointerManagerState; use smithay::wayland::relative_pointer::RelativePointerManagerState;
use smithay::wayland::selection::data_device::DataDeviceState; use smithay::wayland::selection::data_device::DataDeviceState;
use smithay::wayland::selection::primary_selection::PrimarySelectionState; use smithay::wayland::selection::primary_selection::PrimarySelectionState;
use smithay::wayland::selection::{ext_data_control, wlr_data_control};
use smithay::wayland::session_lock::SessionLockManagerState; use smithay::wayland::session_lock::SessionLockManagerState;
use smithay::wayland::shell::wlr_layer::{Layer, WlrLayerShellState}; use smithay::wayland::shell::wlr_layer::{Layer, WlrLayerShellState};
use smithay::wayland::shell::xdg::decoration::XdgDecorationState; use smithay::wayland::shell::xdg::decoration::XdgDecorationState;
@@ -69,6 +73,7 @@ use smithay::wayland::viewporter::ViewporterState;
use smithay::wayland::xdg_activation::XdgActivationState; use smithay::wayland::xdg_activation::XdgActivationState;
use smithay::wayland::xdg_foreign::XdgForeignState; use smithay::wayland::xdg_foreign::XdgForeignState;
use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconManager; use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconManager;
use window_registry::WindowRegistry;
use crate::backends::direct::DirectBackendState; use crate::backends::direct::DirectBackendState;
use crate::config::{Binding, DaemonConfig, InputSettings, OutputProfile, ThemeSettings}; use crate::config::{Binding, DaemonConfig, InputSettings, OutputProfile, ThemeSettings};
@@ -84,11 +89,6 @@ use crate::ipc::IpcSocketGuard;
use crate::overview::OverviewState; use crate::overview::OverviewState;
use crate::portal_session::PortalSession; use crate::portal_session::PortalSession;
use crate::portal_shortcuts::PortalShortcuts; use crate::portal_shortcuts::PortalShortcuts;
use crate::render::{
BlurProgram, MaterialBuffers, MaterialProgram, OverviewScrim, ResizeSnapshot, RoundedClipPrograms,
WindowBorderBuffers, WindowShadowBuffers,
};
use crate::resize_transaction::ResizeTransaction;
use crate::session_lock::{IdleSettings, Lock}; use crate::session_lock::{IdleSettings, Lock};
use crate::shell_control::ShellSnapshot; use crate::shell_control::ShellSnapshot;
use crate::stacking::WindowStack; use crate::stacking::WindowStack;
@@ -168,12 +168,14 @@ impl FocusSwipe {
} }
} }
#[derive(Clone)]
struct WorkspaceSlideItem { struct WorkspaceSlideItem {
workspace: WorkspaceId, workspace: WorkspaceId,
start: SlideOffset, start: SlideOffset,
target: SlideOffset, target: SlideOffset,
} }
#[derive(Clone)]
struct WorkspaceSlide { struct WorkspaceSlide {
items: Vec<WorkspaceSlideItem>, items: Vec<WorkspaceSlideItem>,
elapsed: Duration, elapsed: Duration,
@@ -322,54 +324,32 @@ pub struct Ferese {
pub output_workspaces: OutputWorkspaceMap, pub output_workspaces: OutputWorkspaceMap,
pub(crate) output_ids: HashMap<Output, OutputId>, pub(crate) output_ids: HashMap<Output, OutputId>,
output_identity_ids: HashMap<String, OutputId>, output_identity_ids: HashMap<String, OutputId>,
pub window_ids: HashMap<Window, WindowId>, pub(crate) windows: WindowRegistry<Window>,
pub window_geometry: HashMap<WindowId, WindowGeometry>, pub(crate) render: crate::render::RenderResources,
resize_transactions: HashMap<WindowId, ResizeTransaction>,
pub(crate) resize_snapshots: HashMap<WindowId, ResizeSnapshot>,
pub(crate) nested_backend: Option<NestedBackend>, pub(crate) nested_backend: Option<NestedBackend>,
pub(crate) wallpaper: WallpaperState, pub(crate) wallpaper: WallpaperState,
maximized_windows: HashSet<WindowId>,
maximized_column_widths: HashMap<WindowId, ColumnWidth>,
window_stack: WindowStack, window_stack: WindowStack,
floating_above_fullscreen: HashMap<WindowId, WindowId>, floating_above_fullscreen: HashMap<WindowId, WindowId>,
natural_floating_pending: HashSet<WindowId>,
floating_memory: crate::floating::Memory, floating_memory: crate::floating::Memory,
floating_save_worker: crate::floating::SaveWorker, floating_save_worker: crate::floating::SaveWorker,
floating_window_memory: HashMap<WindowId, crate::floating::Remembered>,
floating_placement_anchors: HashMap<WindowId, Rect>,
floating_cascade: crate::floating::Cascade, floating_cascade: crate::floating::Cascade,
pub(crate) floating_resize_anchors: HashMap<WindowId, (bool, bool, Rect)>,
pub(crate) window_borders: HashMap<WindowId, WindowBorderBuffers>,
pub(crate) window_dims: HashMap<WindowId, WindowBorderBuffers>,
pub(crate) window_resize_fills: HashMap<WindowId, WindowBorderBuffers>,
pub(crate) window_dimming: HashMap<WindowId, DimAnimation>,
pub(crate) window_focus: HashMap<WindowId, DimAnimation>,
pub(crate) window_shadows: HashMap<WindowId, WindowShadowBuffers>,
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, RoundedClipPrograms>,
pub(crate) overview_scrims: HashMap<OutputId, OverviewScrim>,
pub(crate) material_programs: HashMap<ErasedContextId, MaterialProgram>,
pub(crate) material_buffers: HashMap<WlSurface, MaterialBuffers>,
pub(crate) portal_shortcuts: PortalShortcuts, pub(crate) portal_shortcuts: PortalShortcuts,
pub(crate) pending_logout: Option<u32>, pub(crate) pending_logout: Option<u32>,
pub(crate) logout_query: Option<u32>, pub(crate) logout_query: Option<u32>,
pub(crate) logout_owner: Option<ObjectId>, pub(crate) logout_owner: Option<ObjectId>,
pub(crate) blur_programs: HashMap<ErasedContextId, BlurProgram>,
pub(crate) backdrop_generation: u64, pub(crate) backdrop_generation: u64,
closing_windows: HashMap<WindowId, ClosingAnimation>,
viewport_animations: HashMap<WorkspaceId, AnimatedValue>, viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
focus_swipe: Option<FocusSwipe>, focus_swipe: Option<FocusSwipe>,
workspace_slides: HashMap<OutputId, WorkspaceSlide>, workspace_slides: HashMap<OutputId, WorkspaceSlide>,
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
viewport_coupled_widths: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
pending_column_width_cycles: HashSet<WindowId>,
pub focused_window: Option<WindowId>, pub focused_window: Option<WindowId>,
pub(crate) focus_history: ferese_core::FocusHistory,
pub(crate) focus_cycle: Option<ferese_core::FocusCycle>,
column_width_presets: Vec<ColumnWidth>, column_width_presets: Vec<ColumnWidth>,
gap_config: GapConfig, gap_config: GapConfig,
pub(crate) input_settings: InputSettings, pub(crate) input_settings: InputSettings,
pub(crate) bindings: Vec<Binding>, pub(crate) bindings: Vec<Binding>,
workspace_auto_back_and_forth: bool, workspace_auto_back_and_forth: bool,
window_rules: Vec<WindowRule>, window_rules: Vec<WindowRule>,
window_rules_applied: HashSet<WindowId>,
pub(crate) theme_settings: ThemeSettings, pub(crate) theme_settings: ThemeSettings,
pub(crate) inactive_dim: crate::config::InactiveDimSettings, pub(crate) inactive_dim: crate::config::InactiveDimSettings,
animations_enabled: bool, animations_enabled: bool,
@@ -404,7 +384,6 @@ pub struct Ferese {
pub(crate) shell_snapshot_serial: u32, pub(crate) shell_snapshot_serial: u32,
pub(crate) last_shell_snapshot: Option<ShellSnapshot>, pub(crate) last_shell_snapshot: Option<ShellSnapshot>,
pub(crate) overview: crate::overview::OverviewState, pub(crate) overview: crate::overview::OverviewState,
next_window_id: u64,
next_output_id: u64, next_output_id: u64,
last_animation_tick: Instant, last_animation_tick: Instant,
pub popups: PopupManager, pub popups: PopupManager,
@@ -425,6 +404,8 @@ pub struct Ferese {
pub pointer_constraints_state: PointerConstraintsState, pub pointer_constraints_state: PointerConstraintsState,
pub presentation_state: PresentationState, pub presentation_state: PresentationState,
pub primary_selection_state: PrimarySelectionState, pub primary_selection_state: PrimarySelectionState,
pub wlr_data_control_state: wlr_data_control::DataControlState,
pub ext_data_control_state: ext_data_control::DataControlState,
pub relative_pointer_state: RelativePointerManagerState, pub relative_pointer_state: RelativePointerManagerState,
pub seat_state: SeatState<Self>, pub seat_state: SeatState<Self>,
pub shm_state: ShmState, pub shm_state: ShmState,
@@ -501,6 +482,17 @@ impl Ferese {
let presentation_state = PresentationState::new::<Self>(&display_handle, libc::CLOCK_MONOTONIC as u32); let presentation_state = PresentationState::new::<Self>(&display_handle, libc::CLOCK_MONOTONIC as u32);
let data_device_state = DataDeviceState::new::<Self>(&display_handle); let data_device_state = DataDeviceState::new::<Self>(&display_handle);
let primary_selection_state = PrimarySelectionState::new::<Self>(&display_handle); let primary_selection_state = PrimarySelectionState::new::<Self>(&display_handle);
// Clipboard tools need direct selection access. Without data control,
// wl-copy maps a temporary window to obtain keyboard focus.
let wlr_data_control_state =
wlr_data_control::DataControlState::new::<Self, _>(&display_handle, Some(&primary_selection_state), |_| {
true
});
let ext_data_control_state =
ext_data_control::DataControlState::new::<Self, _>(&display_handle, Some(&primary_selection_state), |_| {
true
});
let relative_pointer_state = RelativePointerManagerState::new::<Self>(&display_handle); let relative_pointer_state = RelativePointerManagerState::new::<Self>(&display_handle);
let viewporter_state = ViewporterState::new::<Self>(&display_handle); let viewporter_state = ViewporterState::new::<Self>(&display_handle);
let layer_shell_state = WlrLayerShellState::new::<Self>(&display_handle); let layer_shell_state = WlrLayerShellState::new::<Self>(&display_handle);
@@ -552,56 +544,34 @@ impl Ferese {
output_workspaces: OutputWorkspaceMap::default(), output_workspaces: OutputWorkspaceMap::default(),
output_ids: HashMap::new(), output_ids: HashMap::new(),
output_identity_ids: HashMap::new(), output_identity_ids: HashMap::new(),
window_ids: HashMap::new(), windows: WindowRegistry::default(),
window_geometry: HashMap::new(), render: Default::default(),
resize_transactions: HashMap::new(),
resize_snapshots: HashMap::new(),
nested_backend: None, nested_backend: None,
wallpaper: WallpaperState::with_wakeup(config.wallpaper, Some(event_loop.get_signal())), wallpaper: WallpaperState::with_wakeup(config.wallpaper, Some(event_loop.get_signal())),
maximized_windows: HashSet::new(),
maximized_column_widths: HashMap::new(),
window_stack: WindowStack::default(), window_stack: WindowStack::default(),
floating_above_fullscreen: HashMap::new(), floating_above_fullscreen: HashMap::new(),
natural_floating_pending: HashSet::new(),
floating_memory: crate::floating::Memory::path() floating_memory: crate::floating::Memory::path()
.map(|p| crate::floating::Memory::load(&p)) .map(|p| crate::floating::Memory::load(&p))
.unwrap_or_default(), .unwrap_or_default(),
floating_save_worker: Default::default(), floating_save_worker: Default::default(),
floating_window_memory: HashMap::new(),
floating_placement_anchors: HashMap::new(),
floating_cascade: Default::default(), floating_cascade: Default::default(),
floating_resize_anchors: HashMap::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(),
material_programs: HashMap::new(),
material_buffers: HashMap::new(),
portal_shortcuts: PortalShortcuts::default(), portal_shortcuts: PortalShortcuts::default(),
pending_logout: None, pending_logout: None,
logout_query: None, logout_query: None,
logout_owner: None, logout_owner: None,
blur_programs: HashMap::new(),
backdrop_generation: 0, backdrop_generation: 0,
closing_windows: HashMap::new(),
viewport_animations: HashMap::new(), viewport_animations: HashMap::new(),
focus_swipe: None, focus_swipe: None,
workspace_slides: HashMap::new(), workspace_slides: HashMap::new(),
scrolling_world_x: HashMap::new(),
viewport_coupled_widths: HashMap::new(),
pending_column_width_cycles: HashSet::new(),
focused_window: None, focused_window: None,
focus_history: Default::default(),
focus_cycle: None,
column_width_presets: config.column_width_presets, column_width_presets: config.column_width_presets,
gap_config: config.gap_config, gap_config: config.gap_config,
input_settings: config.input_settings, input_settings: config.input_settings,
bindings: config.bindings, bindings: config.bindings,
workspace_auto_back_and_forth: config.workspace_auto_back_and_forth, workspace_auto_back_and_forth: config.workspace_auto_back_and_forth,
window_rules: config.window_rules, window_rules: config.window_rules,
window_rules_applied: HashSet::new(),
theme_settings: config.theme_settings, theme_settings: config.theme_settings,
inactive_dim: config.inactive_dim, inactive_dim: config.inactive_dim,
animations_enabled: config.animations_enabled, animations_enabled: config.animations_enabled,
@@ -631,7 +601,6 @@ impl Ferese {
shell_snapshot_serial: 0, shell_snapshot_serial: 0,
last_shell_snapshot: None, last_shell_snapshot: None,
overview: OverviewState::with_font_family(config.overview_font_family), overview: OverviewState::with_font_family(config.overview_font_family),
next_window_id: 1,
next_output_id: 1, next_output_id: 1,
last_animation_tick: start_time, last_animation_tick: start_time,
popups: PopupManager::default(), popups: PopupManager::default(),
@@ -652,6 +621,8 @@ impl Ferese {
pointer_constraints_state, pointer_constraints_state,
presentation_state, presentation_state,
primary_selection_state, primary_selection_state,
wlr_data_control_state,
ext_data_control_state,
relative_pointer_state, relative_pointer_state,
seat_state, seat_state,
shm_state, shm_state,
@@ -778,12 +749,16 @@ impl Ferese {
self.pending_dmabuf_imports.push((dmabuf, notifier)); self.pending_dmabuf_imports.push((dmabuf, notifier));
} }
pub(crate) fn process_dmabuf_imports<R>(&mut self, renderer: &mut R) pub(crate) fn process_dmabuf_imports<R>(&mut self, renderer: &mut R, node: Option<DrmNode>)
where where
R: ImportDma, R: ImportDma,
{ {
for (dmabuf, notifier) in self.pending_dmabuf_imports.drain(..) { for (dmabuf, notifier) in self.pending_dmabuf_imports.drain(..) {
if renderer.import_dmabuf(&dmabuf, None).is_ok() { if renderer.import_dmabuf(&dmabuf, None).is_ok() {
if let Some(node) = node {
dmabuf.set_node(node);
}
if let Err(error) = notifier.successful::<Self>() { if let Err(error) = notifier.successful::<Self>() {
tracing::debug!(?error, "dma-buf client disappeared before import completed"); tracing::debug!(?error, "dma-buf client disappeared before import completed");
} }
@@ -1061,7 +1036,7 @@ mod tests {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
let first = workspaces.active_id(); let first = workspaces.active_id();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
let second = workspaces.ensure_numeric(2).unwrap(); let second = workspaces.create_workspace();
workspaces.activate(second).unwrap(); workspaces.activate(second).unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(3), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(3), Axis::Horizontal, 0.5).unwrap();
@@ -1072,7 +1047,7 @@ mod tests {
.connect(OutputId(1), OutputGeometry::new(0, 0, 1920, 1080), first) .connect(OutputId(1), OutputGeometry::new(0, 0, 1920, 1080), first)
.unwrap(); .unwrap();
let owner = if assignment == Some(OutputId(2)) { let owner = if assignment == Some(OutputId(2)) {
let third = workspaces.ensure_numeric(3).unwrap(); let third = workspaces.create_workspace();
outputs outputs
.connect(OutputId(2), OutputGeometry::new(1920, 0, 1920, 1080), third) .connect(OutputId(2), OutputGeometry::new(1920, 0, 1920, 1080), third)
.unwrap(); .unwrap();
+73 -41
View File
@@ -29,6 +29,7 @@ impl Ferese {
{ {
self.focus_swipe = None; self.focus_swipe = None;
} }
let visible_workspaces = if self.viewport_animations.is_empty() { let visible_workspaces = if self.viewport_animations.is_empty() {
HashSet::new() HashSet::new()
} else { } else {
@@ -38,10 +39,11 @@ impl Ferese {
.filter_map(|output| self.output_workspaces.active_workspace(*output)) .filter_map(|output| self.output_workspaces.active_workspace(*output))
.collect::<HashSet<_>>() .collect::<HashSet<_>>()
}; };
let windows = self let windows = self
.space .space
.elements() .elements()
.filter_map(|window| self.window_ids.get(window).copied().map(|id| (window.clone(), id))) .filter_map(|window| self.windows.ids().get(window).copied().map(|id| (window.clone(), id)))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut active_animation = false; let mut active_animation = false;
let mut completed_slides = Vec::new(); let mut completed_slides = Vec::new();
@@ -49,16 +51,19 @@ impl Ferese {
if slide.held_progress.is_some() { if slide.held_progress.is_some() {
continue; continue;
} }
if slide.advance(delta) { if slide.advance(delta) {
active_animation = true; active_animation = true;
} else { } else {
completed_slides.push(*output); completed_slides.push(*output);
} }
} }
self.dismissing_popups.retain_mut(|(root, popup, motion)| { self.dismissing_popups.retain_mut(|(root, popup, motion)| {
if !smithay::utils::IsAlive::alive(popup.wl_surface()) { if !smithay::utils::IsAlive::alive(popup.wl_surface()) {
return false; return false;
} }
let duration = if self.animations_enabled { 140.0 } else { 0.0 }; let duration = if self.animations_enabled { 140.0 } else { 0.0 };
let active = motion.advance_visual(0.0, delta, duration); let active = motion.advance_visual(0.0, delta, duration);
crate::effects::fade_dismissed_surface(popup.wl_surface(), motion.current); crate::effects::fade_dismissed_surface(popup.wl_surface(), motion.current);
@@ -75,69 +80,72 @@ impl Ferese {
} else { } else {
0.0 0.0
}; };
let mut dim_changed = false; let mut dim_changed = false;
// Include hidden workspace windows: overview can present them too. // Include hidden workspace windows: overview can present them too.
// Read selection once so these immutable fields can be borrowed alongside // Read selection once so these immutable fields can be borrowed alongside
// window_focus without allocating a temporary window-ID vector. // each record without allocating a temporary window-ID vector.
let selected_window = if self.overview.is_active() { let selected_window = if self.overview.is_active() {
self.overview.selected() self.overview.selected()
} else { } else {
self.focused_window self.focused_window
}; };
for id in self.window_ids.values().copied() {
for (&id, record) in self.windows.records_mut() {
let selected = selected_window == Some(id); let selected = selected_window == Some(id);
let target = if selected { 1.0 } else { 0.0 }; let target = if selected { 1.0 } else { 0.0 };
let focus = self let focus = record
.window_focus .focus
.entry(id) .get_or_insert_with(|| crate::dimming::DimAnimation::new(target));
.or_insert_with(|| crate::dimming::DimAnimation::new(target));
let previous = focus.current; let previous = focus.current;
active_animation |= focus.advance_visual(target, delta, duration); active_animation |= focus.advance_visual(target, delta, duration);
dim_changed |= previous != focus.current; dim_changed |= previous != focus.current;
} }
for (_, id) in &windows { for (_, id) in &windows {
let target = crate::dimming::target(dim_settings, self.focused_window, *id, self.overview.is_presenting()); let target = crate::dimming::target(dim_settings, self.focused_window, *id, self.overview.is_presenting());
let dim = self let Some(record) = self.windows.record_mut(*id) else {
.window_dimming continue;
.entry(*id) };
.or_insert_with(|| crate::dimming::DimAnimation::new(target));
let dim = record
.dimming
.get_or_insert_with(|| crate::dimming::DimAnimation::new(target));
let previous = dim.current; let previous = dim.current;
active_animation |= dim.advance_visual(target, delta, duration); active_animation |= dim.advance_visual(target, delta, duration);
dim_changed |= previous != dim.current; dim_changed |= previous != dim.current;
} }
let mut ready_to_close = Vec::new(); let mut ready_to_close = Vec::new();
for (id, animation) in &mut self.closing_windows { for (id, record) in self.windows.records_mut() {
let Some(animation) = &mut record.closing else { continue };
if animation.advance(delta, self.animations_enabled) { if animation.advance(delta, self.animations_enabled) {
ready_to_close.push(*id); ready_to_close.push(*id);
} else if !animation.close_sent { } else if !animation.close_sent {
active_animation = true; active_animation = true;
} }
} }
for id in ready_to_close { for id in ready_to_close {
self.send_window_close(id); self.send_window_close(id);
} }
let now = self.start_time.elapsed(); let now = self.start_time.elapsed();
self.resize_transactions.retain(|id, transaction| { self.windows.expire_transactions(now);
if transaction.expired(now) {
tracing::warn!(?id, "resize presentation deadline reached");
false
} else {
true
}
});
let blocked_workspaces = self let blocked_workspaces = self
.resize_transactions .windows
.keys() .resizing()
.filter_map(|id| self.workspaces.workspace_for_window(*id)) .filter_map(|id| self.workspaces.workspace_for_window(*id))
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
let animations_enabled = self.animations_enabled; let animations_enabled = self.animations_enabled;
let animation_speed = self.animation_speed; let animation_speed = self.animation_speed;
self.resize_snapshots.retain(|id, snapshot| { self.render.retain_snapshots(|id, snapshot| {
if !animations_enabled { if !animations_enabled {
return false; return false;
} }
let waiting_for_client = self let waiting_for_client = self
.workspaces .workspaces
.workspace_for_window(*id) .workspace_for_window(*id)
@@ -145,7 +153,7 @@ impl Ferese {
// A shrinking client's destination buffer arrives before the // A shrinking client's destination buffer arrives before the
// animated bounds reach it. Keep the old native pixels covering // animated bounds reach it. Keep the old native pixels covering
// that strip rather than fading them into the neutral resize fill. // that strip rather than fading them into the neutral resize fill.
let uncovered = self.window_geometry.get(id).is_some_and(|geometry| { let uncovered = self.windows.geometry(id).is_some_and(|geometry| {
geometry.client.committed_size.is_some_and(|size| { geometry.client.committed_size.is_some_and(|size| {
crate::presentation::resize_needs_old_frame( crate::presentation::resize_needs_old_frame(
geometry.visual.current, geometry.visual.current,
@@ -166,21 +174,24 @@ impl Ferese {
if !blocked { if !blocked {
snapshot.commit.increment(); snapshot.commit.increment();
} }
if !active { if !active {
tracing::debug!(?id, bytes = snapshot.bytes(), "released resize handoff snapshot"); tracing::debug!(?id, bytes = snapshot.bytes(), "released resize handoff snapshot");
} }
active active
}); });
active_animation |= !self.resize_snapshots.is_empty(); active_animation |= self.render.snapshots().next().is_some();
// Keep scheduling frames while waiting, so the deadline cannot stall. // Keep scheduling frames while waiting, so the deadline cannot stall.
active_animation |= !blocked_workspaces.is_empty(); active_animation |= !blocked_workspaces.is_empty();
for (workspace, viewport) in &mut self.viewport_animations { for (workspace, viewport) in &mut self.viewport_animations {
if !visible_workspaces.contains(workspace) { if !visible_workspaces.contains(workspace) {
continue; continue;
} }
if blocked_workspaces.contains(workspace) { if blocked_workspaces.contains(workspace) {
continue; continue;
} }
if let Some(swipe) = self.focus_swipe.as_ref().filter(|swipe| swipe.workspace == *workspace) { if let Some(swipe) = self.focus_swipe.as_ref().filter(|swipe| swipe.workspace == *workspace) {
viewport.current = swipe.position(); viewport.current = swipe.position();
viewport.velocity = 0.0; viewport.velocity = 0.0;
@@ -200,12 +211,19 @@ impl Ferese {
{ {
continue; continue;
} }
let Some(geometry) = self.window_geometry.get_mut(&id) else {
let Some(record) = self.windows.record_mut(id) else {
continue; continue;
}; };
let natural_pending = record.natural_floating_pending;
let Some(geometry) = record.geometry.as_mut() else {
continue;
};
let zooming = geometry.is_zooming(); let zooming = geometry.is_zooming();
let coupled_target = self.viewport_coupled_widths.get(&id).map(|(_, width)| width.target); let coupled_target = record.coupled_width.as_ref().map(|(_, width)| width.target);
if coupled_target.is_some() { if coupled_target.is_some() {
geometry.visual.target.width = geometry.visual.current.width; geometry.visual.target.width = geometry.visual.current.width;
geometry.visual.velocity.width = 0.0; geometry.visual.velocity.width = 0.0;
@@ -214,7 +232,8 @@ impl Ferese {
if let Some(target) = coupled_target { if let Some(target) = coupled_target {
geometry.visual.target.width = target; geometry.visual.target.width = target;
} }
if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id)
if let Some((workspace, world_x)) = record.world_x.as_mut()
&& let Some(viewport) = self.viewport_animations.get(workspace) && let Some(viewport) = self.viewport_animations.get(workspace)
{ {
if zooming { if zooming {
@@ -225,12 +244,14 @@ impl Ferese {
} else { } else {
world_x.snap(); world_x.snap();
} }
if !zooming { if !zooming {
geometry.visual.current.x = world_x.current - viewport.current; geometry.visual.current.x = world_x.current - viewport.current;
geometry.visual.velocity.x = world_x.velocity - viewport.velocity; geometry.visual.velocity.x = world_x.velocity - viewport.velocity;
} }
} }
if let Some((_, width)) = self.viewport_coupled_widths.get_mut(&id) {
if let Some((_, width)) = record.coupled_width.as_mut() {
let width_active = if self.animations_enabled { let width_active = if self.animations_enabled {
width.advance(delta, self.viewport_spring_config) width.advance(delta, self.viewport_spring_config)
} else { } else {
@@ -244,6 +265,7 @@ impl Ferese {
settled_coupled_widths.push(id); settled_coupled_widths.push(id);
} }
} }
if let Some(size) = geometry.client.expire_wait(now) { if let Some(size) = geometry.client.expire_wait(now) {
tracing::warn!( tracing::warn!(
?id, ?id,
@@ -253,8 +275,9 @@ impl Ferese {
"client did not commit the final configured size within 500 ms" "client did not commit the final configured size within 500 ms"
); );
} }
if let Some(size) = geometry.presentation_size_request(now) if let Some(size) = geometry.presentation_size_request(now)
&& !self.natural_floating_pending.contains(&id) && !natural_pending
&& let Some(toplevel) = window.toplevel() && let Some(toplevel) = window.toplevel()
{ {
toplevel.with_pending_state(|state| { toplevel.with_pending_state(|state| {
@@ -262,20 +285,25 @@ impl Ferese {
}); });
toplevel.send_pending_configure(); toplevel.send_pending_configure();
} }
let visual = geometry.visual.current; let visual = geometry.visual.current;
self.space self.space
.map_element(window, (visual.x.round() as i32, visual.y.round() as i32), false); .map_element(window, (visual.x.round() as i32, visual.y.round() as i32), false);
} }
for id in settled_coupled_widths { for id in settled_coupled_widths {
self.viewport_coupled_widths.remove(&id); self.windows.update(id, |record| record.coupled_width = None);
} }
if !completed_slides.is_empty() { if !completed_slides.is_empty() {
for output in completed_slides { for output in completed_slides {
self.workspace_slides.remove(&output); self.workspace_slides.remove(&output);
} }
let visible = self.visible_workspace_ids(); let visible = self.visible_workspace_ids();
let visible_windows = self let visible_windows = self
.window_ids .windows
.ids()
.values() .values()
.copied() .copied()
.filter(|id| { .filter(|id| {
@@ -285,7 +313,7 @@ impl Ferese {
}) })
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
self.unmap_invisible_windows(&visible_windows); self.unmap_invisible_windows(&visible_windows);
self.prune_empty_workspaces(&visible); self.reconcile_workspaces(&visible);
self.send_shell_snapshots(); self.send_shell_snapshots();
active_animation = true; active_animation = true;
} }
@@ -308,19 +336,15 @@ impl Ferese {
scaled_animation_duration(duration, self.animations_enabled, self.animation_speed) scaled_animation_duration(duration, self.animations_enabled, self.animation_speed)
} }
pub(crate) fn closing_visual(&self, id: WindowId) -> (f64, f32) { pub(crate) fn workspace_slide_offset(&self, window: WindowId) -> (f64, f64) {
let Some(animation) = self.closing_windows.get(&id) else { self.workspace_slide_offset_at(window, Duration::ZERO)
return (1.0, 1.0);
};
let eased = smoothstep(animation.progress);
(1.0 - eased * 0.02, (1.0 - eased) as f32)
} }
pub(crate) fn workspace_slide_offset(&self, window: WindowId) -> (f64, f64) { pub(super) fn workspace_slide_offset_at(&self, window: WindowId, delta: Duration) -> (f64, f64) {
let Some(workspace) = self.workspaces.workspace_for_window(window) else { let Some(workspace) = self.workspaces.workspace_for_window(window) else {
return (0.0, 0.0); return (0.0, 0.0);
}; };
let Some(output_id) = self let Some(output_id) = self
.workspace_slides .workspace_slides
.iter() .iter()
@@ -329,9 +353,11 @@ impl Ferese {
else { else {
return (0.0, 0.0); return (0.0, 0.0);
}; };
let Some(slide) = self.workspace_slides.get(&output_id) else { let Some(slide) = self.workspace_slides.get(&output_id) else {
return (0.0, 0.0); return (0.0, 0.0);
}; };
let Some(size) = self let Some(size) = self
.output_ids .output_ids
.iter() .iter()
@@ -341,6 +367,12 @@ impl Ferese {
else { else {
return (0.0, 0.0); return (0.0, 0.0);
}; };
let mut slide = slide.clone();
if slide.held_progress.is_none() && !delta.is_zero() {
slide.advance(delta);
}
slide.offset(workspace, f64::from(size.w), f64::from(size.h)) slide.offset(workspace, f64::from(size.w), f64::from(size.h))
} }
+40 -25
View File
@@ -18,7 +18,8 @@ impl Ferese {
None => return, None => return,
}; };
if let Some(handle) = self if let Some(handle) = self
.window_ids .windows
.ids()
.iter() .iter()
.find_map(|(handle, id)| (*id == window).then_some(handle.clone())) .find_map(|(handle, id)| (*id == window).then_some(handle.clone()))
{ {
@@ -75,7 +76,7 @@ impl Ferese {
.workspace_for_window(window) .workspace_for_window(window)
.and_then(|workspace| self.workspaces.workspace(workspace)) .and_then(|workspace| self.workspaces.workspace(workspace))
.is_some_and(|workspace| workspace.fullscreen == Some(window)); .is_some_and(|workspace| workspace.fullscreen == Some(window));
let enabled = fullscreen || !self.maximized_windows.contains(&window); let enabled = fullscreen || !self.windows.record(window).is_some_and(|w| w.maximized);
self.set_window_maximized(window, enabled); self.set_window_maximized(window, enabled);
} }
@@ -85,9 +86,9 @@ impl Ferese {
} }
if enabled { if enabled {
let _ = self.workspaces.set_fullscreen(window, false); let _ = self.workspaces.set_fullscreen(window, false);
self.maximized_windows.insert(window); self.windows.update(window, |w| w.maximized = true);
} else { } else {
self.maximized_windows.remove(&window); self.windows.update(window, |w| w.maximized = false);
} }
if self.workspaces.placement(window) == Some(WindowPlacement::Tiled) { if self.workspaces.placement(window) == Some(WindowPlacement::Tiled) {
let workspace_id = self.workspaces.workspace_for_window(window).unwrap(); let workspace_id = self.workspaces.workspace_for_window(window).unwrap();
@@ -96,10 +97,16 @@ impl Ferese {
{ {
if enabled { if enabled {
if let Some(column) = layout.columns().iter().find(|column| column.windows.contains(&window)) { if let Some(column) = layout.columns().iter().find(|column| column.windows.contains(&window)) {
self.maximized_column_widths.entry(window).or_insert(column.width); self.windows.update(window, |w| {
w.maximized_column_width.get_or_insert(column.width);
});
} }
let _ = layout.set_column_width(window, ColumnWidth::Proportion(1.0)); let _ = layout.set_column_width(window, ColumnWidth::Proportion(1.0));
} else if let Some(width) = self.maximized_column_widths.remove(&window) { } else if let Some(width) = self
.windows
.update(window, |w| w.maximized_column_width.take())
.flatten()
{
let _ = layout.set_column_width(window, width); let _ = layout.set_column_width(window, width);
} }
} }
@@ -108,7 +115,11 @@ impl Ferese {
} }
pub(crate) fn output_has_fullscreen(&self, output: &Output) -> bool { pub(crate) fn output_has_fullscreen(&self, output: &Output) -> bool {
!self.overview.is_presenting() self.output_has_fullscreen_for_frame(output, self.overview.is_presenting())
}
pub(crate) fn output_has_fullscreen_for_frame(&self, output: &Output, overview: bool) -> bool {
!overview
&& self && self
.output_id(output) .output_id(output)
.and_then(|id| self.output_workspaces.active_workspace(id)) .and_then(|id| self.output_workspaces.active_workspace(id))
@@ -127,7 +138,8 @@ impl Ferese {
} }
pub fn is_floating_window(&self, window: &Window) -> bool { pub fn is_floating_window(&self, window: &Window) -> bool {
self.window_ids self.windows
.ids()
.get(window) .get(window)
.and_then(|id| self.workspaces.placement(*id)) .and_then(|id| self.workspaces.placement(*id))
.is_some_and(|placement| matches!(placement, WindowPlacement::Floating { .. })) .is_some_and(|placement| matches!(placement, WindowPlacement::Floating { .. }))
@@ -139,10 +151,10 @@ impl Ferese {
location: Point<i32, Logical>, location: Point<i32, Logical>,
size: Size<i32, Logical>, size: Size<i32, Logical>,
) { ) {
let Some(id) = self.window_ids.get(window).copied() else { let Some(id) = self.windows.ids().get(window).copied() else {
return; return;
}; };
self.floating_resize_anchors.remove(&id); self.windows.update(id, |w| w.resize_anchor.take()).flatten();
let rect = Rect::new( let rect = Rect::new(
location.x as f64, location.x as f64,
location.y as f64, location.y as f64,
@@ -157,12 +169,15 @@ impl Ferese {
// Direct manipulation follows the pointer, including while the client is // Direct manipulation follows the pointer, including while the client is
// still drawing its next buffer. Rendering and hit testing share this rect. // still drawing its next buffer. Rendering and hit testing share this rect.
self.resize_transactions.remove(&id); self.windows.clear_transaction(&id);
self.resize_snapshots.remove(&id); self.render.clear_snapshot(&id);
let geometry = self let Some(record) = self.windows.record_mut(id) else {
.window_geometry return;
.entry(id) };
.or_insert_with(|| WindowGeometry::new(rect, client_size(window))); let geometry = record
.geometry
.get_or_insert_with(|| WindowGeometry::new(rect, client_size(window)));
if let Some(size) = geometry.follow_pointer(rect, self.start_time.elapsed()) if let Some(size) = geometry.follow_pointer(rect, self.start_time.elapsed())
&& let Some(toplevel) = window.toplevel() && let Some(toplevel) = window.toplevel()
{ {
@@ -171,8 +186,8 @@ impl Ferese {
}); });
toplevel.send_pending_configure(); toplevel.send_pending_configure();
} }
self.scrolling_world_x.remove(&id); self.windows.update(id, |record| record.world_x = None);
self.viewport_coupled_widths.remove(&id); self.windows.update(id, |record| record.coupled_width = None);
self.space.map_element(window.clone(), location, false); self.space.map_element(window.clone(), location, false);
self.sync_window_stacking(); self.sync_window_stacking();
// Move/resize/cancel callbacks run with Smithay's pointer mutex held. // Move/resize/cancel callbacks run with Smithay's pointer mutex held.
@@ -212,7 +227,7 @@ impl Ferese {
self.space self.space
.elements() .elements()
.filter_map(|window| { .filter_map(|window| {
let id = *self.window_ids.get(window)?; let id = *self.windows.ids().get(window)?;
if Some(id) == exclude { if Some(id) == exclude {
return None; return None;
} }
@@ -255,7 +270,7 @@ impl Ferese {
let parent_rect = parent let parent_rect = parent
.as_ref() .as_ref()
.and_then(|surface| { .and_then(|surface| {
self.window_ids.iter().find_map(|(window, id)| { self.windows.ids().iter().find_map(|(window, id)| {
window window
.toplevel() .toplevel()
.is_some_and(|top| top.wl_surface() == surface) .is_some_and(|top| top.wl_surface() == surface)
@@ -266,7 +281,7 @@ impl Ferese {
.and_then(|rect| intersection(rect, work)); .and_then(|rect| intersection(rect, work));
let dialog = crate::window_rules::is_native_dialog(app.as_deref()); let dialog = crate::window_rules::is_native_dialog(app.as_deref());
let remembered = id let remembered = id
.and_then(|id| self.floating_window_memory.get(&id)) .and_then(|id| self.windows.record(id).and_then(|w| w.floating_memory.as_ref()))
.or_else(|| { .or_else(|| {
if parent.is_none() { if parent.is_none() {
app.as_deref().and_then(|app| self.floating_memory.get(app)) app.as_deref().and_then(|app| self.floating_memory.get(app))
@@ -317,7 +332,7 @@ impl Ferese {
.map(|(_, r)| r) .map(|(_, r)| r)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let anchor = id let anchor = id
.and_then(|id| self.floating_placement_anchors.get(&id).copied()) .and_then(|id| self.windows.record(id).and_then(|w| w.placement_anchor))
.or_else(|| { .or_else(|| {
self.focused_window self.focused_window
.filter(|focused| Some(*focused) != id) .filter(|focused| Some(*focused) != id)
@@ -337,7 +352,7 @@ impl Ferese {
} }
pub(crate) fn remember_floating(&mut self, window: &Window) { pub(crate) fn remember_floating(&mut self, window: &Window) {
let Some(id) = self.window_ids.get(window).copied() else { let Some(id) = self.windows.ids().get(window).copied() else {
return; return;
}; };
let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id) else { let Some(WindowPlacement::Floating { rect }) = self.workspaces.placement(id) else {
@@ -360,7 +375,7 @@ impl Ferese {
return; return;
}; };
let entry = crate::floating::Remembered::new(output, rect, work); let entry = crate::floating::Remembered::new(output, rect, work);
self.floating_window_memory.insert(id, entry.clone()); self.windows.update(id, |w| w.floating_memory = Some(entry.clone()));
if let Some(app) = app { if let Some(app) = app {
self.floating_memory.save(app, entry); self.floating_memory.save(app, entry);
} }
@@ -377,7 +392,7 @@ impl Ferese {
window: &Window, window: &Window,
raw: Rect, raw: Rect,
) -> (Vec<crate::floating::SnapLine>, Vec<crate::floating::SnapLine>) { ) -> (Vec<crate::floating::SnapLine>, Vec<crate::floating::SnapLine>) {
let id = self.window_ids.get(window).copied(); let id = self.windows.ids().get(window).copied();
let work = self let work = self
.floating_outputs() .floating_outputs()
.into_iter() .into_iter()
+101
View File
@@ -0,0 +1,101 @@
use super::*;
impl Ferese {
pub(super) fn focus_candidates(&self) -> Vec<WindowId> {
self.windows
.ids()
.iter()
.filter_map(|(window, id)| {
self.workspaces.workspace_for_window(*id)?;
(window.toplevel().is_some()
&& self.window_content_ready(window)
&& self.windows.geometry(id).is_some()
&& self.output_workspaces.focused_output().is_some()
&& !self.windows.record(*id).is_some_and(|record| record.closing.is_some()))
.then_some(*id)
})
.collect()
}
pub(crate) fn focus_preview_output(&self, id: WindowId) -> Option<OutputId> {
if self.windows.record(id).is_some_and(|record| record.closing.is_some()) {
return None;
}
self.workspaces
.workspace_for_window(id)
.and_then(|workspace| self.output_workspaces.output_for_workspace(workspace))
.or_else(|| self.output_workspaces.focused_output())
}
pub(crate) fn focus_last_window(&mut self) {
if self.session_lock.active || self.input_capture.captures(1) {
return;
}
self.cancel_focus_cycle();
let target = self
.focus_history
.candidates(self.focused_window, self.focus_candidates())
.into_iter()
.find(|id| Some(*id) != self.focused_window);
if let Some(id) = target {
self.activate_managed_window(id);
}
}
pub(crate) fn cycle_focus(&mut self, reverse: bool, preview: bool) {
if self.session_lock.active || self.input_capture.captures(1) {
return;
}
let available = self.focus_candidates();
if !preview {
self.cancel_focus_cycle();
let order = self.focus_history.candidates(self.focused_window, available.clone());
let mut cycle = ferese_core::FocusCycle::new(order, self.focused_window);
if let Some(id) = cycle.advance(reverse, &available) {
self.activate_managed_window(id);
}
return;
}
if self.focus_cycle.is_none() {
let order = self.focus_history.candidates(self.focused_window, available.clone());
if order.len() < 2 {
return;
}
self.focus_cycle = Some(ferese_core::FocusCycle::new(order, self.focused_window));
if self.overview.is_active() {
self.retarget_overview();
} else {
self.set_overview_active(true);
}
}
if let Some(id) = self
.focus_cycle
.as_mut()
.and_then(|cycle| cycle.advance(reverse, &available))
{
self.overview.select_window(id);
crate::backends::direct::render_all(self);
} else {
self.cancel_focus_cycle();
}
}
pub(crate) fn finish_focus_cycle(&mut self) {
let Some(mut cycle) = self.focus_cycle.take() else {
return;
};
if !self.session_lock.active
&& !self.input_capture.captures(1)
&& let Some(id) = cycle.reconcile(&self.focus_candidates())
{
self.activate_managed_window(id);
}
self.set_overview_active(false);
}
pub(crate) fn cancel_focus_cycle(&mut self) {
if self.focus_cycle.take().is_some() {
self.set_overview_active(false);
}
}
}
+14 -10
View File
@@ -41,7 +41,7 @@ impl Ferese {
let workspace = self.workspace_under_pointer(position)?; let workspace = self.workspace_under_pointer(position)?;
self.space.elements().rev().find_map(|window| { self.space.elements().rev().find_map(|window| {
let id = self.window_ids.get(window)?; let id = self.windows.ids().get(window)?;
if self.workspaces.workspace_for_window(*id) != Some(workspace) { if self.workspaces.workspace_for_window(*id) != Some(workspace) {
return None; return None;
} }
@@ -71,7 +71,7 @@ impl Ferese {
} }
pub(super) fn window_fills_visual_bounds(&self, id: WindowId, workspace: WorkspaceId) -> bool { pub(super) fn window_fills_visual_bounds(&self, id: WindowId, workspace: WorkspaceId) -> bool {
self.maximized_windows.contains(&id) self.windows.record(id).is_some_and(|w| w.maximized)
|| self || self
.workspaces .workspaces
.workspace(workspace) .workspace(workspace)
@@ -137,7 +137,7 @@ impl Ferese {
return None; return None;
} }
let id = self.window_ids.get(window)?; let id = self.windows.ids().get(window)?;
if self.workspaces.workspace_for_window(*id) != Some(workspace) { if self.workspaces.workspace_for_window(*id) != Some(workspace) {
return None; return None;
} }
@@ -166,9 +166,9 @@ impl Ferese {
}; };
if overview_active { if overview_active {
let mut candidates = self.window_ids.keys().cloned().collect::<Vec<_>>(); let mut candidates = self.windows.ids().keys().cloned().collect::<Vec<_>>();
// Match Overview's front-to-back render order during overlapping motion. // Match Overview's front-to-back render order during overlapping motion.
candidates.sort_by_key(|window| std::cmp::Reverse(self.window_ids.get(window).map_or(0, |id| id.0))); candidates.sort_by_key(|window| std::cmp::Reverse(self.windows.ids().get(window).map_or(0, |id| id.0)));
candidates.iter().find_map(hit) candidates.iter().find_map(hit)
} else { } else {
self.space.elements().rev().find_map(hit) self.space.elements().rev().find_map(hit)
@@ -184,6 +184,9 @@ impl Ferese {
let Some(output_id) = self.output_id(output) else { let Some(output_id) = self.output_id(output) else {
return false; return false;
}; };
if self.focus_cycle.is_some() && self.overview.is_active() {
return self.overview.has_window_preview(window) && self.focus_preview_output(window) == Some(output_id);
}
let workspace = self.workspaces.workspace_for_window(window); let workspace = self.workspaces.workspace_for_window(window);
workspace == self.output_workspaces.active_workspace(output_id) workspace == self.output_workspaces.active_workspace(output_id)
|| self || self
@@ -196,8 +199,8 @@ impl Ferese {
if !self.overview.is_presenting() { if !self.overview.is_presenting() {
return Some((1.0, 1.0)); return Some((1.0, 1.0));
} }
let id = self.window_ids.get(window)?; let id = self.windows.ids().get(window)?;
let geometry = self.window_geometry.get(id)?; let geometry = self.windows.geometry(id)?;
let source = geometry.client.committed_size?; let source = geometry.client.committed_size?;
let presented = self.presented_window_rect(*id)?; let presented = self.presented_window_rect(*id)?;
@@ -212,7 +215,7 @@ impl Ferese {
} }
pub(crate) fn visual_rect_for_window(&self, window: &Window) -> Option<Rectangle<i32, Logical>> { pub(crate) fn visual_rect_for_window(&self, window: &Window) -> Option<Rectangle<i32, Logical>> {
let id = self.window_ids.get(window)?; let id = self.windows.ids().get(window)?;
let rect = self.presented_window_rect(*id)?; let rect = self.presented_window_rect(*id)?;
let size = ClientSize::from_rect(rect); let size = ClientSize::from_rect(rect);
Some(Rectangle::new( Some(Rectangle::new(
@@ -224,9 +227,10 @@ impl Ferese {
pub(crate) fn window_content_ready(&self, window: &Window) -> bool { pub(crate) fn window_content_ready(&self, window: &Window) -> bool {
window_has_buffer(window) window_has_buffer(window)
&& self && self
.window_ids .windows
.ids()
.get(window) .get(window)
.and_then(|id| self.window_geometry.get(id)) .and_then(|id| self.windows.geometry(id))
.is_some_and(|geometry| geometry.client.committed_size.is_some()) .is_some_and(|geometry| geometry.client.committed_size.is_some())
} }
} }
+70 -39
View File
@@ -15,27 +15,36 @@ impl Ferese {
visible visible
} }
pub(super) fn prune_empty_workspaces(&mut self, visible: &HashSet<WorkspaceId>) { pub(super) fn reconcile_workspaces(&mut self, visible: &HashSet<WorkspaceId>) {
let mut protected = visible.clone(); for workspace in self
protected.extend(self.output_workspaces.history_workspaces()); .output_workspaces
for workspace in self.workspaces.prune_empty(&protected) { .reconcile_workspaces(&mut self.workspaces, visible)
self.output_workspaces.forget_workspace(workspace); {
self.viewport_animations.remove(&workspace); self.viewport_animations.remove(&workspace);
} }
} }
pub(super) fn unmap_invisible_windows(&mut self, visible: &HashSet<WindowId>) -> bool { pub(super) fn unmap_invisible_windows(&mut self, visible: &HashSet<WindowId>) -> bool {
let mut changed = false; let mut changed = false;
for (window, id) in &self.window_ids { let windows = self
if visible.contains(id) { .windows
.ids()
.iter()
.map(|(window, id)| (window.clone(), *id))
.collect::<Vec<_>>();
for (window, id) in windows {
if visible.contains(&id) {
continue; continue;
} }
let was_mapped = self.space.element_location(window).is_some();
let was_mapped = self.space.element_location(&window).is_some();
changed |= was_mapped; changed |= was_mapped;
if was_mapped && let Some(geometry) = self.window_geometry.get_mut(id) { if was_mapped && let Some(geometry) = self.windows.geometry_mut(&id) {
geometry.settle_presentation(); geometry.settle_presentation();
} }
self.space.unmap_elem(window);
self.space.unmap_elem(&window);
} }
changed changed
} }
@@ -43,7 +52,7 @@ impl Ferese {
pub fn relayout(&mut self) { pub fn relayout(&mut self) {
self.refresh_input_capture_zones(); self.refresh_input_capture_zones();
let visible_workspaces = self.visible_workspace_ids(); let visible_workspaces = self.visible_workspace_ids();
self.prune_empty_workspaces(&visible_workspaces); self.reconcile_workspaces(&visible_workspaces);
if self.session_lock.active { if self.session_lock.active {
self.configure_lock_surfaces(); self.configure_lock_surfaces();
} }
@@ -51,8 +60,8 @@ impl Ferese {
// large first animation step after a keypress on an idle desktop. // large first animation step after a keypress on an idle desktop.
self.advance_animations(Instant::now()); self.advance_animations(Instant::now());
self.arrange_layers(); self.arrange_layers();
let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x); let mut previous_scrolling_world_x = self.windows.take_world_positions();
let pending_column_width_cycles = std::mem::take(&mut self.pending_column_width_cycles); let pending_column_width_cycles = self.windows.take_column_width_requests();
let outputs = self.space.outputs().cloned().collect::<Vec<_>>(); let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
let constraints = self.window_constraints(); let constraints = self.window_constraints();
let mut visible = HashSet::new(); let mut visible = HashSet::new();
@@ -63,6 +72,7 @@ impl Ferese {
let Some(output_id) = self.output_ids.get(&output).copied() else { let Some(output_id) = self.output_ids.get(&output).copied() else {
continue; continue;
}; };
let Some(active_workspace) = self.output_workspaces.active_workspace(output_id) else { let Some(active_workspace) = self.output_workspaces.active_workspace(output_id) else {
continue; continue;
}; };
@@ -80,13 +90,16 @@ impl Ferese {
let Some(bounds) = self.output_bounds_for(&output) else { let Some(bounds) = self.output_bounds_for(&output) else {
continue; continue;
}; };
let fullscreen_bounds = self.full_output_bounds_for(&output).unwrap_or(bounds); let fullscreen_bounds = self.full_output_bounds_for(&output).unwrap_or(bounds);
for layer in layer_map_for_output(&output).layers() { for layer in layer_map_for_output(&output).layers() {
crate::handlers::set_surface_tree_output(layer.wl_surface(), &output); crate::handlers::set_surface_tree_output(layer.wl_surface(), &output);
} }
let Some(workspace) = self.workspaces.workspace(workspace_id) else { let Some(workspace) = self.workspaces.workspace(workspace_id) else {
continue; continue;
}; };
let workspace_fullscreen = workspace.fullscreen; let workspace_fullscreen = workspace.fullscreen;
let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_)); let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_));
let workspace_focus = workspace.last_focused; let workspace_focus = workspace.last_focused;
@@ -97,6 +110,7 @@ impl Ferese {
let Some(workspace) = self.workspaces.workspace_mut(workspace_id) else { let Some(workspace) = self.workspaces.workspace_mut(workspace_id) else {
continue; continue;
}; };
let layout = let layout =
match workspace match workspace
.layout .layout
@@ -134,15 +148,17 @@ impl Ferese {
); );
} }
for (window, id) in &self.window_ids { for (window, id) in self.windows.ids() {
if self.workspaces.workspace_for_window(*id) != Some(workspace_id) { if self.workspaces.workspace_for_window(*id) != Some(workspace_id) {
continue; continue;
} }
if let Some(toplevel) = window.toplevel() { if let Some(toplevel) = window.toplevel() {
crate::handlers::set_surface_tree_output(toplevel.wl_surface(), &output); crate::handlers::set_surface_tree_output(toplevel.wl_surface(), &output);
} }
let is_maximized = self.maximized_windows.contains(id) && workspace_fullscreen != Some(*id); let is_maximized =
self.windows.record(*id).is_some_and(|w| w.maximized) && workspace_fullscreen != Some(*id);
let rect = if workspace_fullscreen == Some(*id) { let rect = if workspace_fullscreen == Some(*id) {
fullscreen_bounds fullscreen_bounds
} else if is_maximized && !is_scrolling_layout { } else if is_maximized && !is_scrolling_layout {
@@ -166,6 +182,7 @@ impl Ferese {
None => continue, None => continue,
} }
}; };
let is_fullscreen = workspace_fullscreen == Some(*id); let is_fullscreen = workspace_fullscreen == Some(*id);
let is_floating = matches!(self.workspaces.placement(*id), Some(WindowPlacement::Floating { .. })); let is_floating = matches!(self.workspaces.placement(*id), Some(WindowPlacement::Floating { .. }));
@@ -195,19 +212,25 @@ impl Ferese {
let now = self.start_time.elapsed(); let now = self.start_time.elapsed();
let mut scrolling_world_x = HashMap::new();
placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id)); placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id));
for (window, id, rect, is_fullscreen, is_maximized, is_floating, scrolling, couple_width) in placements { for (window, id, rect, is_fullscreen, is_maximized, is_floating, scrolling, couple_width) in placements {
let was_mapped = self.space.element_location(&window).is_some(); let was_mapped = self.space.element_location(&window).is_some();
let Some(record) = self.windows.record_mut(id) else {
continue;
};
// A viewport-coupled width must never override fullscreen/floating geometry. // A viewport-coupled width must never override fullscreen/floating geometry.
if scrolling.is_none() { if scrolling.is_none() {
self.viewport_coupled_widths.remove(&id); record.coupled_width = None;
} }
let (geometry, had_geometry) = match self.window_geometry.entry(id) {
Entry::Occupied(entry) => (entry.into_mut(), true), let natural_pending = record.natural_floating_pending && is_floating && !is_fullscreen && !is_maximized;
Entry::Vacant(entry) => (entry.insert(WindowGeometry::new(rect, client_size(&window))), false), let had_geometry = record.geometry.is_some();
}; let geometry = record
.geometry
.get_or_insert_with(|| WindowGeometry::new(rect, client_size(&window)));
let mode = if is_fullscreen { let mode = if is_fullscreen {
PresentationMode::Fullscreen PresentationMode::Fullscreen
} else if is_maximized { } else if is_maximized {
@@ -220,13 +243,16 @@ impl Ferese {
if !was_mapped && (had_geometry || is_fullscreen) { if !was_mapped && (had_geometry || is_fullscreen) {
geometry.settle_presentation(); geometry.settle_presentation();
} }
if !self.animations_enabled { if !self.animations_enabled {
geometry.advance(Duration::ZERO, self.spring_config, false); geometry.advance(Duration::ZERO, self.spring_config, false);
requested_size = geometry.presentation_size_request(now).or(requested_size); requested_size = geometry.presentation_size_request(now).or(requested_size);
} }
if geometry.is_zooming() { if geometry.is_zooming() {
self.viewport_coupled_widths.remove(&id); record.coupled_width = None;
} }
if let Some((workspace, world_x, viewport_x)) = scrolling { if let Some((workspace, world_x, viewport_x)) = scrolling {
let restored_world_x = let restored_world_x =
restored_scrolling_world_x(had_geometry, geometry.visual.current.x, viewport_x, world_x); restored_scrolling_world_x(had_geometry, geometry.visual.current.x, viewport_x, world_x);
@@ -239,23 +265,23 @@ impl Ferese {
if !self.animations_enabled { if !self.animations_enabled {
animated_world_x.snap(); animated_world_x.snap();
} }
if !geometry.is_zooming() { if !geometry.is_zooming() {
geometry.visual.current.x = animated_world_x.current - viewport_x; geometry.visual.current.x = animated_world_x.current - viewport_x;
geometry.visual.velocity.x = animated_world_x.velocity; geometry.visual.velocity.x = animated_world_x.velocity;
} }
scrolling_world_x.insert(id, (workspace, animated_world_x)); record.world_x = Some((workspace, animated_world_x));
let coupled = !geometry.is_zooming() let coupled = !geometry.is_zooming()
&& (couple_width && (couple_width
|| self || record
.viewport_coupled_widths .coupled_width
.get(&id) .as_ref()
.is_some_and(|(previous_workspace, _)| *previous_workspace == workspace)); .is_some_and(|(previous_workspace, _)| *previous_workspace == workspace));
if coupled { if coupled {
let width = self let width = record
.viewport_coupled_widths .coupled_width
.entry(id) .get_or_insert_with(|| (workspace, AnimatedValue::new(geometry.visual.current.width)));
.or_insert_with(|| (workspace, AnimatedValue::new(geometry.visual.current.width)));
if width.0 != workspace { if width.0 != workspace {
*width = (workspace, AnimatedValue::new(geometry.visual.current.width)); *width = (workspace, AnimatedValue::new(geometry.visual.current.width));
} }
@@ -266,15 +292,15 @@ impl Ferese {
geometry.visual.current.width = width.1.current; geometry.visual.current.width = width.1.current;
geometry.visual.velocity.width = width.1.velocity; geometry.visual.velocity.width = width.1.velocity;
} else if pending_column_width_cycles.contains(&id) { } else if pending_column_width_cycles.contains(&id) {
self.viewport_coupled_widths.remove(&id); record.coupled_width = None;
} }
} }
let visual = geometry.visual.current; let visual = geometry.visual.current;
let natural_pending =
self.natural_floating_pending.contains(&id) && is_floating && !is_fullscreen && !is_maximized;
if natural_pending { if natural_pending {
requested_size = None; requested_size = None;
} }
let location = (visual.x.round() as i32, visual.y.round() as i32); let location = (visual.x.round() as i32, visual.y.round() as i32);
self.space.map_element(window.clone(), location, false); self.space.map_element(window.clone(), location, false);
@@ -297,6 +323,7 @@ impl Ferese {
} else { } else {
state.states.unset(xdg_toplevel::State::Maximized) state.states.unset(xdg_toplevel::State::Maximized)
}; };
let tiled = !is_floating && !is_fullscreen; let tiled = !is_floating && !is_fullscreen;
let tiled_changed = if tiled { let tiled_changed = if tiled {
state.states.set(xdg_toplevel::State::TiledLeft) state.states.set(xdg_toplevel::State::TiledLeft)
@@ -319,7 +346,7 @@ impl Ferese {
&& had_geometry && had_geometry
&& self.animations_enabled && self.animations_enabled
{ {
self.resize_transactions.insert( self.windows.set_transaction(
id, id,
crate::resize_transaction::ResizeTransaction::new(serial, now) crate::resize_transaction::ResizeTransaction::new(serial, now)
.with_source_geometry(window.geometry()), .with_source_geometry(window.geometry()),
@@ -327,10 +354,11 @@ impl Ferese {
} }
} }
} }
self.scrolling_world_x = scrolling_world_x;
if layout_changed { if layout_changed {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1); self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
} }
self.sync_window_stacking(); self.sync_window_stacking();
self.retarget_overview(); self.retarget_overview();
self.send_shell_snapshots(); self.send_shell_snapshots();
@@ -339,9 +367,10 @@ impl Ferese {
} }
pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) { pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) {
if let Some(id) = self.window_ids.get(window) { if let Some(id) = self.windows.ids().get(window) {
self.window_stack.raise(*id); self.window_stack.raise(*id);
} }
self.space.raise_element(window, activate); self.space.raise_element(window, activate);
self.sync_window_stacking(); self.sync_window_stacking();
} }
@@ -349,8 +378,8 @@ impl Ferese {
pub(crate) fn sync_window_stacking(&mut self) { pub(crate) fn sync_window_stacking(&mut self) {
let mut windows = self.space.elements().cloned().collect::<Vec<_>>(); let mut windows = self.space.elements().cloned().collect::<Vec<_>>();
windows.sort_by_key(|window| { windows.sort_by_key(|window| {
let id = self.window_ids.get(window).copied(); let id = self.windows.ids().get(window).copied();
let geometry = id.and_then(|id| self.window_geometry.get(&id)); let geometry = id.and_then(|id| self.windows.geometry(&id));
let floating = let floating =
id.is_some_and(|id| matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. }))); id.is_some_and(|id| matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. })));
let priority = crate::stacking::layer_priority( let priority = crate::stacking::layer_priority(
@@ -372,6 +401,7 @@ impl Ferese {
if stacking_order_settled(self.space.elements(), &windows, |window| window.z_index()) { if stacking_order_settled(self.space.elements(), &windows, |window| window.z_index()) {
return; return;
} }
for window in windows { for window in windows {
self.space.raise_element(&window, false); self.space.raise_element(&window, false);
} }
@@ -387,7 +417,8 @@ impl Ferese {
} }
pub(super) fn window_constraints(&self) -> HashMap<WindowId, SizeConstraints> { pub(super) fn window_constraints(&self) -> HashMap<WindowId, SizeConstraints> {
self.window_ids self.windows
.ids()
.iter() .iter()
.filter_map(|(window, id)| { .filter_map(|(window, id)| {
let toplevel = window.toplevel()?; let toplevel = window.toplevel()?;
+20 -24
View File
@@ -140,7 +140,8 @@ impl Ferese {
tracing::warn!(%error, ?current, ?next, "failed to slide swipe focus"); tracing::warn!(%error, ?current, ?next, "failed to slide swipe focus");
} }
let Some(window) = self let Some(window) = self
.window_ids .windows
.ids()
.iter() .iter()
.find_map(|(window, id)| (*id == next).then(|| window.clone())) .find_map(|(window, id)| (*id == next).then(|| window.clone()))
else { else {
@@ -277,7 +278,7 @@ impl Ferese {
match self.workspaces.cycle_column_width(window, &self.column_width_presets) { match self.workspaces.cycle_column_width(window, &self.column_width_presets) {
Ok(true) => { Ok(true) => {
self.pending_column_width_cycles.insert(window); self.windows.update(window, |w| w.column_width_pending = true);
self.relayout(); self.relayout();
} }
Ok(false) => {} Ok(false) => {}
@@ -306,7 +307,8 @@ impl Ferese {
pub(crate) fn activate_managed_window(&mut self, id: WindowId) -> bool { pub(crate) fn activate_managed_window(&mut self, id: WindowId) -> bool {
let Some(window) = self let Some(window) = self
.window_ids .windows
.ids()
.iter() .iter()
.find_map(|(window, window_id)| (*window_id == id).then(|| window.clone())) .find_map(|(window, window_id)| (*window_id == id).then(|| window.clone()))
else { else {
@@ -392,16 +394,13 @@ impl Ferese {
slide_direction: Option<SwipeDirection>, slide_direction: Option<SwipeDirection>,
auto_back_and_forth: bool, auto_back_and_forth: bool,
) { ) {
let workspace = match self.workspaces.ensure_numeric(index) {
Ok(workspace) => workspace,
Err(error) => {
tracing::error!(%error, index, "failed to switch workspace");
return;
}
};
let Some(output) = self.output_workspaces.focused_output() else { let Some(output) = self.output_workspaces.focused_output() else {
return; return;
}; };
let Some(workspace) = self.output_workspaces.workspace_at(&self.workspaces, output, index) else {
return;
};
let previous = self.output_workspaces.active_workspace(output); let previous = self.output_workspaces.active_workspace(output);
let owner = match self let owner = match self
.output_workspaces .output_workspaces
@@ -524,26 +523,23 @@ impl Ferese {
let Some(window) = self.focused_window else { let Some(window) = self.focused_window else {
return; return;
}; };
let Some(output) = self.output_workspaces.focused_output() else {
return;
};
let Some(destination) = self.output_workspaces.workspace_at(&self.workspaces, output, index) else {
return;
};
let axis = self let axis = self
.output_bounds() .output_bounds()
.and_then(|bounds| { .and_then(|bounds| {
let workspace = self.workspaces.ensure_numeric(index).ok()?; let target = self.workspaces.workspace(destination)?;
let target = self.workspaces.workspace(workspace)?;
target.layout.automatic_axis(target.last_focused, bounds).ok() target.layout.automatic_axis(target.last_focused, bounds).ok()
}) })
.unwrap_or(Axis::Horizontal); .unwrap_or(Axis::Horizontal);
let destination = match self.workspaces.move_window_to_numeric(window, index, axis, 0.5) { if let Err(error) = self.workspaces.move_window_to_workspace(window, destination, axis, 0.5) {
Ok(destination) => destination, tracing::error!(%error, ?window, index, "failed to move window to workspace");
Err(error) => { return;
tracing::error!(%error, ?window, index, "failed to move window to workspace");
return;
}
};
if let Some(output) = self.output_workspaces.focused_output()
&& let Err(error) = self.output_workspaces.assign_workspace(output, destination)
{
tracing::error!(%error, ?destination, "failed to assign destination workspace");
} }
self.focused_window = self.workspaces.active().last_focused; self.focused_window = self.workspaces.active().last_focused;
@@ -560,7 +556,7 @@ impl Ferese {
return; return;
} }
let surface = self.focused_window.and_then(|focused| { let surface = self.focused_window.and_then(|focused| {
self.window_ids.iter().find_map(|(window, id)| { self.windows.ids().iter().find_map(|(window, id)| {
(*id == focused) (*id == focused)
.then(|| window.toplevel()) .then(|| window.toplevel())
.flatten() .flatten()
+14 -15
View File
@@ -15,25 +15,23 @@ impl Ferese {
self.next_output_id = self.next_output_id.saturating_add(1); self.next_output_id = self.next_output_id.saturating_add(1);
id id
}); });
let fallback_workspace = (1..) let fallback_workspace = self
.find_map(|index| { .workspaces
let workspace = self.workspaces.ensure_numeric(index).ok()?; .iter()
self.output_workspaces .filter(|workspace| self.output_workspaces.output_for_workspace(workspace.id).is_none())
.output_for_workspace(workspace) .map(|workspace| workspace.id)
.is_none() .min_by_key(|id| id.0)
.then_some(workspace) .unwrap_or_else(|| self.workspaces.create_workspace());
})
.expect("numeric workspaces are inexhaustible");
let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h); let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h);
let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace); let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace);
match registration { match registration {
Ok(workspace) => { Ok(_) => {
self.output_ids.insert(output.clone(), output_id); self.output_ids.insert(output.clone(), output_id);
if self.output_workspaces.focused_output() == Some(output_id) { self.restore_output_focus();
self.activate_output_workspace(output_id, workspace); let visible = self.visible_workspace_ids();
} self.reconcile_workspaces(&visible);
self.send_shell_snapshots(); self.send_shell_snapshots();
} }
Err(error) => { Err(error) => {
@@ -48,7 +46,7 @@ impl Ferese {
}; };
self.workspace_slides.remove(&output_id); self.workspace_slides.remove(&output_id);
self.overview_scrims.remove(&output_id); self.render.remove_output(output_id);
self.pending_screencopies.retain(|capture| { self.pending_screencopies.retain(|capture| {
if capture.output == *output { if capture.output == *output {
capture.fail(); capture.fail();
@@ -82,7 +80,8 @@ impl Ferese {
pub(crate) fn reposition_output_floats(&mut self, output: OutputId, old: Rect, new: Rect) { pub(crate) fn reposition_output_floats(&mut self, output: OutputId, old: Rect, new: Rect) {
let floats = self let floats = self
.window_ids .windows
.ids()
.values() .values()
.filter_map(|id| { .filter_map(|id| {
let workspace = self.workspaces.workspace_for_window(*id)?; let workspace = self.workspaces.workspace_for_window(*id)?;
+335
View File
@@ -0,0 +1,335 @@
use super::*;
/// Output-local presentation values. Sampling never changes authoritative
/// geometry, configure barriers, animation clocks, or another output's view.
pub(crate) struct FrameScene {
pub windows: HashMap<WindowId, WindowFrame>,
pub overview: crate::overview::OverviewMotion,
}
pub(crate) struct WindowFrame {
pub geometry: WindowGeometry,
pub rect: Rect,
pub close_scale: f64,
pub close_alpha: f32,
pub focus: f64,
pub dim: f64,
}
impl Ferese {
pub(crate) fn output_has_animations(&self, output: &Output) -> bool {
if self.overview.is_animating(self.spring_config) || !self.dismissing_popups.is_empty() {
return true;
}
if self
.output_id(output)
.and_then(|id| self.workspace_slides.get(&id))
.is_some_and(|slide| slide.held_progress.is_none() && slide.elapsed < WORKSPACE_SLIDE_DURATION)
{
return true;
}
self.windows.records().any(|(id, record)| {
let Some(mut geometry) = record.geometry else {
return false;
};
if !self.window_belongs_to_output(*id, output) {
return false;
}
if record.resize.is_some()
|| self.render.snapshot(id).is_some()
|| record.closing.as_ref().is_some_and(|close| !close.close_sent)
|| record.focus.as_ref().is_some_and(|focus| focus.is_animating())
|| record.dimming.as_ref().is_some_and(|dim| dim.is_animating())
{
return true;
}
if geometry.advance(Duration::ZERO, self.spring_config, self.animations_enabled) {
return true;
}
let Some(workspace) = self.workspaces.workspace_for_window(*id) else {
return false;
};
self.viewport_animations.get(&workspace).is_some_and(|viewport| {
let mut viewport = *viewport;
let held = self
.focus_swipe
.as_ref()
.is_some_and(|swipe| swipe.workspace == workspace);
!held && viewport.advance(Duration::ZERO, self.viewport_spring_config)
}) || record.coupled_width.as_ref().is_some_and(|(_, width)| {
let mut width = *width;
width.advance(Duration::ZERO, self.viewport_spring_config)
})
})
}
pub(crate) fn sample_frame(&self, output: &Output, horizon: Duration) -> FrameScene {
let delta = if self.animations_enabled && self.output_has_animations(output) {
horizon.min(Duration::from_millis(100)).mul_f64(self.animation_speed)
} else {
Duration::ZERO
};
let overview = self.overview.sample(delta, self.spring_config);
let blocked = self
.windows
.resizing()
.filter_map(|id| self.workspaces.workspace_for_window(*id))
.collect::<HashSet<_>>();
let mut windows = HashMap::new();
for (&id, record) in self.windows.records() {
let Some(original) = record.geometry else { continue };
if !self.window_belongs_to_output(id, output) {
continue;
}
let workspace = self.workspaces.workspace_for_window(id);
let world = record.world_x.as_ref().and_then(|(workspace, world)| {
let viewport = self.viewport_animations.get(workspace)?;
let held = self
.focus_swipe
.as_ref()
.is_some_and(|swipe| swipe.workspace == *workspace);
Some((*world, *viewport, held))
});
let width = record.coupled_width.as_ref().map(|(_, width)| *width);
let geometry = if delta.is_zero() || workspace.is_some_and(|workspace| blocked.contains(&workspace)) {
original
} else {
predict_geometry(
original,
delta,
self.spring_config,
self.viewport_spring_config,
world,
width,
)
};
let mut rect = overview.presented_rect(id, geometry.visual.current);
if !overview.is_presenting() {
let (x, y) = self.workspace_slide_offset_at(id, delta);
rect.x += x;
rect.y += y;
}
let mut close = record.closing.as_ref().copied().unwrap_or_default();
if record.closing.is_some() && !delta.is_zero() {
close.advance(delta, true);
}
let eased = smoothstep(close.progress);
let focused = if self.overview.is_active() {
self.overview_selected(id)
} else {
self.focused_window == Some(id)
};
let sample_dim = |motion: Option<&DimAnimation>, fallback| {
let Some(mut motion) = motion.cloned() else {
return fallback;
};
if !delta.is_zero() {
motion.predict(delta, self.inactive_dim.duration_ms);
}
motion.current
};
windows.insert(
id,
WindowFrame {
geometry,
rect,
close_scale: 1.0 - eased * 0.02,
close_alpha: (1.0 - eased) as f32,
focus: sample_dim(record.focus.as_ref(), if focused { 1.0 } else { 0.0 }),
dim: sample_dim(record.dimming.as_ref(), 0.0),
},
);
}
FrameScene { windows, overview }
}
}
fn predict_geometry(
original: WindowGeometry,
delta: Duration,
spring: SpringConfig,
viewport_spring: SpringConfig,
world: Option<(AnimatedValue, AnimatedValue, bool)>,
width: Option<AnimatedValue>,
) -> WindowGeometry {
let mut predicted = original;
let zooming = predicted.is_zooming();
if width.is_some() {
predicted.visual.target.width = predicted.visual.current.width;
predicted.visual.velocity.width = 0.0;
}
predicted.advance(delta, spring, true);
predicted.visual.target.width = original.visual.target.width;
if let Some((mut world, mut viewport, held)) = world
&& !zooming
{
world.advance(delta, spring);
if !held {
viewport.advance(delta, viewport_spring);
}
predicted.visual.current.x = world.current - viewport.current;
predicted.visual.velocity.x = world.velocity - viewport.velocity;
}
if let Some(mut width) = width {
width.advance(delta, viewport_spring);
predicted.visual.current.width = width.current;
predicted.visual.velocity.width = width.velocity;
}
predicted
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"]
fn frame_sampling_preserves_compositor_state_between_output_deadlines() {
let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap());
assert!(
runtime.starts_with(std::env::temp_dir()),
"use a disposable runtime directory"
);
let mut event_loop = EventLoop::try_new().unwrap();
let display = Display::new().unwrap();
let config = crate::config::Config::default().runtime_config().unwrap();
let mut state = Ferese::new(&mut event_loop, display, config).unwrap();
let output = Output::new(
"forecast-test".into(),
smithay::output::PhysicalProperties {
size: (0, 0).into(),
subpixel: smithay::output::Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(smithay::output::Mode {
size: (1920, 1080).into(),
refresh: 60_000,
}),
Some(smithay::utils::Transform::Normal),
None,
Some((0, 0).into()),
);
state.space.map_output(&output, (0, 0));
state.register_output(&output, "forecast-test".into());
let id = WindowId(1);
state.workspaces.insert_window(id, Axis::Horizontal, 0.5).unwrap();
let mut geometry = WindowGeometry::new(Rect::new(0., 0., 400., 300.), None);
geometry.visual.set_target(Rect::new(600., 0., 400., 300.));
// This test isolates frame sampling from the protocol client fixture.
state.windows.records.insert(
id,
super::super::window_registry::WindowRecord {
geometry: Some(geometry),
..Default::default()
},
);
let tick = state.last_animation_tick;
for horizon in [
Duration::from_millis(16),
Duration::from_millis(4),
Duration::from_millis(8),
] {
{
let forecast = state.sample_frame(&output, horizon);
assert!(forecast.windows[&id].geometry.visual.current.x > geometry.visual.current.x);
}
assert_eq!(*state.windows.geometry(&id).unwrap(), geometry);
assert_eq!(state.last_animation_tick, tick);
}
// A prediction must neither advance nor expire a client resize barrier.
state.windows.set_transaction(
id,
crate::resize_transaction::ResizeTransaction::new(9.into(), Duration::ZERO),
);
let blocked = state.sample_frame(&output, Duration::from_millis(16));
assert_eq!(blocked.windows[&id].geometry, geometry);
assert!(state.windows.transaction(&id).is_some());
state.windows.clear_transaction(&id);
state.animations_enabled = false;
let forecast = state.sample_frame(&output, Duration::from_millis(16));
assert_eq!(forecast.windows[&id].geometry, geometry);
}
#[test]
fn mixed_refresh_forecasts_do_not_change_authoritative_geometry_or_client_state() {
let mut geometry = WindowGeometry::new(Rect::new(0., 0., 400., 300.), None);
geometry.visual.set_target(Rect::new(600., 0., 500., 300.));
let original = geometry;
let sample = |delta| {
predict_geometry(
geometry,
delta,
SpringConfig::default(),
SpringConfig::default(),
None,
None,
)
};
let fast = sample(Duration::from_nanos(1_000_000_000 / 240));
let slow = sample(Duration::from_nanos(1_000_000_000 / 60));
assert!(fast.visual.current.x > 0.);
assert!(slow.visual.current.x > fast.visual.current.x);
assert_eq!(sample(Duration::from_nanos(1_000_000_000 / 240)), fast);
assert_eq!(geometry, original);
assert_eq!(slow.client, original.client);
assert_eq!(slow.logical, original.logical);
}
#[test]
fn scrolling_coordinates_and_coupled_width_use_the_same_forecast_time() {
let geometry = WindowGeometry::new(Rect::new(500., 0., 400., 300.), None);
let mut world = AnimatedValue::new(500.);
world.set_target(600.);
let mut viewport = AnimatedValue::new(0.);
viewport.set_target(200.);
let mut width = AnimatedValue::new(400.);
width.set_target(500.);
let spring = SpringConfig::default();
let delta = Duration::from_millis(10);
let predicted = predict_geometry(
geometry,
delta,
spring,
spring,
Some((world, viewport, false)),
Some(width),
);
world.advance(delta, spring);
viewport.advance(delta, spring);
width.advance(delta, spring);
assert_eq!(predicted.visual.current.x, world.current - viewport.current);
assert_eq!(predicted.visual.current.width, width.current);
assert_eq!(predicted.visual.target, geometry.visual.target);
}
}
+253
View File
@@ -0,0 +1,253 @@
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use ferese_animation::{AnimatedValue, WindowGeometry};
use ferese_core::WorkspaceId;
use ferese_layout::{ColumnWidth, Rect, WindowId};
use crate::resize_transaction::ResizeTransaction;
/// Metadata with exactly the lifetime of a managed window. Layouts and focus
/// history reference its ID; they do not own or duplicate this metadata.
#[derive(Default)]
pub(crate) struct WindowRecord {
pub geometry: Option<WindowGeometry>,
pub resize: Option<ResizeTransaction>,
pub focus: Option<crate::dimming::DimAnimation>,
pub dimming: Option<crate::dimming::DimAnimation>,
pub(super) closing: Option<super::ClosingAnimation>,
pub world_x: Option<(WorkspaceId, AnimatedValue)>,
pub coupled_width: Option<(WorkspaceId, AnimatedValue)>,
pub maximized: bool,
pub maximized_column_width: Option<ColumnWidth>,
pub natural_floating_pending: bool,
pub floating_memory: Option<crate::floating::Remembered>,
pub placement_anchor: Option<Rect>,
pub resize_anchor: Option<(bool, bool, Rect)>,
pub column_width_pending: bool,
pub rules_applied: bool,
}
/// The handle index is read-only outside this owner. Registering and removing a
/// handle always updates its record as well. IDs are never reused on remapping.
pub(crate) struct WindowRegistry<W> {
ids: HashMap<W, WindowId>,
pub(super) records: HashMap<WindowId, WindowRecord>,
next_id: u64,
}
impl<W> Default for WindowRegistry<W> {
fn default() -> Self {
Self {
ids: HashMap::new(),
records: HashMap::new(),
next_id: 1,
}
}
}
impl<W: Eq + Hash> WindowRegistry<W> {
pub fn allocate_id(&mut self) -> WindowId {
let id = WindowId(self.next_id);
self.next_id = self.next_id.checked_add(1).expect("window IDs exhausted");
id
}
pub fn register(&mut self, window: W, id: WindowId) {
assert!(!self.ids.contains_key(&window), "window already registered");
assert!(!self.records.contains_key(&id), "window ID already registered");
self.ids.insert(window, id);
self.records.insert(id, WindowRecord::default());
}
pub fn ids(&self) -> &HashMap<W, WindowId> {
&self.ids
}
pub fn record(&self, id: WindowId) -> Option<&WindowRecord> {
self.records.get(&id)
}
pub fn record_mut(&mut self, id: WindowId) -> Option<&mut WindowRecord> {
self.records.get_mut(&id)
}
pub fn records_mut(&mut self) -> impl Iterator<Item = (&WindowId, &mut WindowRecord)> {
self.records.iter_mut()
}
pub fn records(&self) -> impl Iterator<Item = (&WindowId, &WindowRecord)> {
self.records.iter()
}
pub fn take_world_positions(&mut self) -> HashMap<WindowId, (WorkspaceId, AnimatedValue)> {
self.records
.iter_mut()
.filter_map(|(id, record)| Some((*id, record.world_x.take()?)))
.collect()
}
pub fn geometry(&self, id: &WindowId) -> Option<&WindowGeometry> {
self.records.get(id)?.geometry.as_ref()
}
pub fn geometry_mut(&mut self, id: &WindowId) -> Option<&mut WindowGeometry> {
self.records.get_mut(id)?.geometry.as_mut()
}
pub fn geometries(&self) -> impl Iterator<Item = (&WindowId, &WindowGeometry)> {
self.records
.iter()
.filter_map(|(id, record)| Some((id, record.geometry.as_ref()?)))
}
pub fn set_geometry(&mut self, id: WindowId, geometry: WindowGeometry) {
self.update(id, |record| record.geometry = Some(geometry));
}
pub fn transaction(&self, id: &WindowId) -> Option<&ResizeTransaction> {
self.records.get(id)?.resize.as_ref()
}
pub fn set_transaction(&mut self, id: WindowId, transaction: ResizeTransaction) {
self.update(id, |record| record.resize = Some(transaction));
}
pub fn clear_transaction(&mut self, id: &WindowId) {
self.update(*id, |record| record.resize = None);
}
pub fn resizing(&self) -> impl Iterator<Item = &WindowId> {
self.records
.iter()
.filter_map(|(id, record)| record.resize.is_some().then_some(id))
}
pub fn expire_transactions(&mut self, now: std::time::Duration) {
for (id, record) in &mut self.records {
if record.resize.is_some_and(|transaction| transaction.expired(now)) {
tracing::warn!(?id, "resize presentation deadline reached");
record.resize = None;
}
}
}
pub fn update<R>(&mut self, id: WindowId, update: impl FnOnce(&mut WindowRecord) -> R) -> Option<R> {
self.records.get_mut(&id).map(update)
}
pub fn remove(&mut self, window: &W) -> Option<WindowId> {
let id = self.ids.remove(window)?;
self.records.remove(&id);
Some(id)
}
pub fn take_column_width_requests(&mut self) -> HashSet<WindowId> {
self.records
.iter_mut()
.filter_map(|(id, record)| std::mem::take(&mut record.column_width_pending).then_some(*id))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unmap_drops_metadata_and_late_updates_cannot_resurrect_it() {
let mut windows = WindowRegistry::default();
let id = windows.allocate_id();
windows.register("surface", id);
windows.update(id, |record| {
record.maximized = true;
record.resize_anchor = Some((true, false, Rect::new(0., 0., 800., 600.)));
record.column_width_pending = true;
record.rules_applied = true;
record.focus = Some(crate::dimming::DimAnimation::new(1.0));
record.closing = Some(super::super::ClosingAnimation::default());
record.world_x = Some((WorkspaceId(1), AnimatedValue::new(50.0)));
record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(800.0)));
});
let geometry = WindowGeometry::new(Rect::new(0., 0., 800., 600.), None);
let resize = ResizeTransaction::new(12.into(), std::time::Duration::ZERO);
windows.set_geometry(id, geometry);
windows.set_transaction(id, resize);
assert_eq!(windows.remove(&"surface"), Some(id));
assert_eq!(windows.remove(&"surface"), None);
assert!(windows.record(id).is_none());
assert!(windows.update(id, |record| record.maximized = true).is_none());
windows.set_geometry(id, geometry);
windows.set_transaction(id, resize);
assert!(windows.geometry(&id).is_none());
assert!(windows.transaction(&id).is_none());
assert_eq!(windows.resizing().count(), 0);
assert!(windows.ids().is_empty());
assert!(windows.take_column_width_requests().is_empty());
}
#[test]
fn remap_has_new_identity_and_does_not_inherit_old_requests() {
let mut windows = WindowRegistry::default();
let old = windows.allocate_id();
windows.register("surface", old);
windows.update(old, |record| record.rules_applied = true);
windows.remove(&"surface");
let new = windows.allocate_id();
windows.register("surface", new);
assert_ne!(old, new);
assert!(
windows
.update(old, |record| record.column_width_pending = true)
.is_none()
);
assert!(!windows.record(new).unwrap().rules_applied);
assert!(windows.take_column_width_requests().is_empty());
}
#[test]
fn pending_width_changes_are_consumed_once_and_survive_other_window_removal() {
let mut windows = WindowRegistry::default();
let first = windows.allocate_id();
let second = windows.allocate_id();
windows.register("first", first);
windows.register("second", second);
windows.update(first, |record| record.column_width_pending = true);
windows.update(second, |record| record.column_width_pending = true);
windows.remove(&"first");
assert_eq!(windows.take_column_width_requests(), HashSet::from([second]));
assert!(windows.take_column_width_requests().is_empty());
assert!(windows.record(second).is_some());
}
#[test]
fn expired_resize_releases_only_its_barrier_and_preserves_geometry() {
use std::time::Duration;
let mut windows = WindowRegistry::default();
let first = windows.allocate_id();
let second = windows.allocate_id();
windows.register("first", first);
windows.register("second", second);
let geometry = WindowGeometry::new(Rect::new(0., 0., 800., 600.), None);
windows.set_geometry(first, geometry);
windows.set_transaction(first, ResizeTransaction::new(1.into(), Duration::ZERO));
windows.set_transaction(second, ResizeTransaction::new(2.into(), Duration::from_millis(100)));
windows.expire_transactions(Duration::from_millis(300));
assert_eq!(windows.geometry(&first), Some(&geometry));
assert!(windows.transaction(&first).is_none());
assert!(windows.transaction(&second).unwrap().accepts(Some(2.into())));
assert_eq!(windows.resizing().copied().collect::<Vec<_>>(), vec![second]);
}
}
+106 -72
View File
@@ -24,6 +24,7 @@ impl Ferese {
{ {
return; return;
} }
let outputs = self.floating_outputs(); let outputs = self.floating_outputs();
let Some((remembered, work)) = app_id let Some((remembered, work)) = app_id
.as_deref() .as_deref()
@@ -65,13 +66,16 @@ impl Ferese {
self.add_tiled_window(window); self.add_tiled_window(window);
return; return;
} }
if let Some(parent) = parent { if let Some(parent) = parent {
self.add_transient_window(window, parent); self.add_transient_window(window, parent);
return; return;
} }
if let Some(pointer) = self.seat.get_pointer() { if let Some(pointer) = self.seat.get_pointer() {
self.focus_output_at(pointer.current_location()); self.focus_output_at(pointer.current_location());
} }
let remembered_work = app_id let remembered_work = app_id
.filter(|app| !crate::window_rules::is_native_dialog(Some(app))) .filter(|app| !crate::window_rules::is_native_dialog(Some(app)))
.and_then(|app| self.floating_memory.get(app)) .and_then(|app| self.floating_memory.get(app))
@@ -86,12 +90,13 @@ impl Ferese {
if let Some(work) = remembered_work { if let Some(work) = remembered_work {
self.focus_output_at((work.x + work.width / 2.0, work.y + work.height / 2.0).into()); self.focus_output_at((work.x + work.width / 2.0, work.y + work.height / 2.0).into());
} }
let Some(bounds) = self.output_bounds() else { let Some(bounds) = self.output_bounds() else {
self.add_tiled_window(window); self.add_tiled_window(window);
return; return;
}; };
let id = WindowId(self.next_window_id);
self.next_window_id += 1; let id = self.windows.allocate_id();
let size_rect = client_size(&window) let size_rect = client_size(&window)
.map(|size| natural_floating_rect(bounds, size)) .map(|size| natural_floating_rect(bounds, size))
.unwrap_or_else(|| centered_floating_rect(bounds)); .unwrap_or_else(|| centered_floating_rect(bounds));
@@ -100,7 +105,6 @@ impl Ferese {
.focused_window .focused_window
.and_then(|focused| self.presented_window_rect(focused)) .and_then(|focused| self.presented_window_rect(focused))
.unwrap_or(bounds); .unwrap_or(bounds);
self.floating_placement_anchors.insert(id, anchor);
let fullscreen = self.workspaces.active().fullscreen; let fullscreen = self.workspaces.active().fullscreen;
let focus = true; let focus = true;
if let Err(error) = self if let Err(error) = self
@@ -110,25 +114,30 @@ impl Ferese {
tracing::error!(%error, ?id, "failed to insert rule-placed floating window"); tracing::error!(%error, ?id, "failed to insert rule-placed floating window");
return; return;
} }
self.windows.register(window.clone(), id);
self.windows.update(id, |w| w.placement_anchor = Some(anchor));
if rule.width.is_none() && rule.height.is_none() && client_size(&window).is_none() && remembered_work.is_none() if rule.width.is_none() && rule.height.is_none() && client_size(&window).is_none() && remembered_work.is_none()
{ {
self.natural_floating_pending.insert(id); self.windows.update(id, |w| w.natural_floating_pending = true);
} }
self.window_ids.insert(window.clone(), id);
self.window_stack.insert(id); self.window_stack.insert(id);
if let Some(fullscreen) = fullscreen { if let Some(fullscreen) = fullscreen {
self.floating_above_fullscreen.insert(id, fullscreen); self.floating_above_fullscreen.insert(id, fullscreen);
} }
if focus { if focus {
self.focused_window = Some(id); self.focused_window = Some(id);
} }
self.space.map_element(window, (0, 0), focus); self.space.map_element(window, (0, 0), focus);
// apply_initial_window_rules performs the first relayout/configure. // apply_initial_window_rules performs the first relayout/configure.
} }
pub fn add_tiled_window(&mut self, window: Window) { pub fn add_tiled_window(&mut self, window: Window) {
let id = WindowId(self.next_window_id); let id = self.windows.allocate_id();
self.next_window_id += 1;
let focus_new_window = self.workspaces.active().fullscreen.is_none(); let focus_new_window = self.workspaces.active().fullscreen.is_none();
let axis = self let axis = self
@@ -147,11 +156,12 @@ impl Ferese {
return; return;
} }
self.window_ids.insert(window.clone(), id); self.windows.register(window.clone(), id);
self.window_stack.insert(id); self.window_stack.insert(id);
if focus_new_window { if focus_new_window {
self.focused_window = Some(id); self.focused_window = Some(id);
} }
self.space.map_element(window, (0, 0), focus_new_window); self.space.map_element(window, (0, 0), focus_new_window);
self.relayout(); self.relayout();
@@ -165,18 +175,19 @@ impl Ferese {
self.add_tiled_window(window); self.add_tiled_window(window);
return; return;
}; };
let Some(bounds) = self.floating_bounds_for_window(parent) else { let Some(bounds) = self.floating_bounds_for_window(parent) else {
self.add_tiled_window(window); self.add_tiled_window(window);
return; return;
}; };
let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds); let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds);
let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds); let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds);
let size_rect = client_size(&window) let size_rect = client_size(&window)
.map(|s| natural_floating_rect(bounds, s)) .map(|s| natural_floating_rect(bounds, s))
.unwrap_or_else(|| centered_transient_rect(visible_parent)); .unwrap_or_else(|| centered_transient_rect(visible_parent));
let rect = self.place_floating(&window, None, bounds, (size_rect.width, size_rect.height), None); let rect = self.place_floating(&window, None, bounds, (size_rect.width, size_rect.height), None);
let id = WindowId(self.next_window_id); let id = self.windows.allocate_id();
self.next_window_id += 1;
let focus = workspace == self.workspaces.active_id() let focus = workspace == self.workspaces.active_id()
&& (self.focused_window == Some(parent) || self.workspaces.active().fullscreen == Some(parent)); && (self.focused_window == Some(parent) || self.workspaces.active().fullscreen == Some(parent));
@@ -185,11 +196,12 @@ impl Ferese {
return; return;
} }
self.window_ids.insert(window.clone(), id); self.windows.register(window.clone(), id);
self.window_stack.insert(id); self.window_stack.insert(id);
if client_size(&window).is_none() { if client_size(&window).is_none() {
self.natural_floating_pending.insert(id); self.windows.update(id, |w| w.natural_floating_pending = true);
} }
if self if self
.workspaces .workspaces
.workspace(workspace) .workspace(workspace)
@@ -197,9 +209,11 @@ impl Ferese {
{ {
self.floating_above_fullscreen.insert(id, parent); self.floating_above_fullscreen.insert(id, parent);
} }
if focus { if focus {
self.focused_window = Some(id); self.focused_window = Some(id);
} }
self.space.map_element(window, (0, 0), focus); self.space.map_element(window, (0, 0), focus);
self.relayout(); self.relayout();
if focus { if focus {
@@ -214,10 +228,15 @@ impl Ferese {
title: Option<&str>, title: Option<&str>,
transient: bool, transient: bool,
) { ) {
let Some(id) = self.window_ids.get(window).copied() else { let Some(id) = self.windows.ids().get(window).copied() else {
return; return;
}; };
if !self.window_rules_applied.insert(id) {
if !self
.windows
.update(id, |w| !std::mem::replace(&mut w.rules_applied, true))
.unwrap_or(false)
{
return; return;
} }
@@ -226,11 +245,12 @@ impl Ferese {
} }
pub(super) fn reapply_window_rules(&mut self, old_rules: &[WindowRule]) { pub(super) fn reapply_window_rules(&mut self, old_rules: &[WindowRule]) {
let windows = self.window_ids.keys().cloned().collect::<Vec<_>>(); let windows = self.windows.ids().keys().cloned().collect::<Vec<_>>();
for window in windows { for window in windows {
let Some(toplevel) = window.toplevel() else { let Some(toplevel) = window.toplevel() else {
continue; continue;
}; };
let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| { let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| {
let attributes = states.data_map.get::<XdgToplevelSurfaceData>().unwrap().lock().unwrap(); let attributes = states.data_map.get::<XdgToplevelSurfaceData>().unwrap().lock().unwrap();
( (
@@ -248,15 +268,18 @@ impl Ferese {
} }
pub(super) fn apply_window_rule_result(&mut self, window: &Window, rule: crate::window_rules::WindowRuleResult) { pub(super) fn apply_window_rule_result(&mut self, window: &Window, rule: crate::window_rules::WindowRuleResult) {
let Some(id) = self.window_ids.get(window).copied() else { let Some(id) = self.windows.ids().get(window).copied() else {
return; return;
}; };
let Some(bounds) = self.floating_bounds_for_window(id) else { let Some(bounds) = self.floating_bounds_for_window(id) else {
return; return;
}; };
if rule == Default::default() { if rule == Default::default() {
return; return;
} }
let axis = self let axis = self
.workspaces .workspaces
.active() .active()
@@ -264,11 +287,17 @@ impl Ferese {
.automatic_axis(self.focused_window, bounds) .automatic_axis(self.focused_window, bounds)
.unwrap_or(Axis::Horizontal); .unwrap_or(Axis::Horizontal);
if let Some(workspace) = rule.workspace if let Some(index) = rule.workspace
&& let Err(error) = self.workspaces.move_window_to_numeric(id, workspace, axis, 0.5) && let Some(output) = self
.workspaces
.workspace_for_window(id)
.and_then(|workspace| self.output_workspaces.output_for_workspace(workspace))
&& let Some(workspace) = self.output_workspaces.workspace_at(&self.workspaces, output, index)
&& let Err(error) = self.workspaces.move_window_to_workspace(id, workspace, axis, 0.5)
{ {
tracing::warn!(%error, ?id, workspace, "failed to apply window workspace rule"); tracing::warn!(%error, ?id, ?workspace, "failed to apply window workspace rule");
} }
let bounds = self.floating_bounds_for_window(id).unwrap_or(bounds); let bounds = self.floating_bounds_for_window(id).unwrap_or(bounds);
let should_float = rule let should_float = rule
@@ -287,7 +316,7 @@ impl Ferese {
.and_then(|entry| entry.restore(&self.floating_outputs())) .and_then(|entry| entry.restore(&self.floating_outputs()))
.is_none() .is_none()
{ {
self.natural_floating_pending.insert(id); self.windows.update(id, |w| w.natural_floating_pending = true);
} }
} }
rect.width = rule.width.unwrap_or(rect.width).min(bounds.width); rect.width = rule.width.unwrap_or(rect.width).min(bounds.width);
@@ -309,6 +338,7 @@ impl Ferese {
} else { } else {
Ok(()) Ok(())
}; };
if let Err(error) = result { if let Err(error) = result {
tracing::warn!(%error, ?id, "failed to apply floating window rule"); tracing::warn!(%error, ?id, "failed to apply floating window rule");
} }
@@ -331,16 +361,19 @@ impl Ferese {
} }
pub fn make_window_transient(&mut self, window: &Window, parent: WindowId) { pub fn make_window_transient(&mut self, window: &Window, parent: WindowId) {
let Some(id) = self.window_ids.get(window).copied() else { let Some(id) = self.windows.ids().get(window).copied() else {
return; return;
}; };
if self.workspaces.workspace_for_window(id) != self.workspaces.workspace_for_window(parent) { if self.workspaces.workspace_for_window(id) != self.workspaces.workspace_for_window(parent) {
tracing::warn!(?id, ?parent, "ignored transient parent on another workspace"); tracing::warn!(?id, ?parent, "ignored transient parent on another workspace");
return; return;
} }
let Some(bounds) = self.floating_bounds_for_window(parent) else { let Some(bounds) = self.floating_bounds_for_window(parent) else {
return; return;
}; };
let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds); let parent_rect = self.presented_window_rect(parent).unwrap_or(bounds);
let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds); let visible_parent = crate::floating::intersection(parent_rect, bounds).unwrap_or(bounds);
let size_rect = client_size(window) let size_rect = client_size(window)
@@ -369,6 +402,7 @@ impl Ferese {
{ {
self.floating_above_fullscreen.insert(id, parent); self.floating_above_fullscreen.insert(id, parent);
} }
if self.focused_window == Some(parent) { if self.focused_window == Some(parent) {
if let Err(error) = self.workspaces.focus_window(id) { if let Err(error) = self.workspaces.focus_window(id) {
tracing::warn!(%error, ?id, "failed to focus transient window"); tracing::warn!(%error, ?id, "failed to focus transient window");
@@ -385,34 +419,18 @@ impl Ferese {
pub fn remove_tiled_window(&mut self, window: &Window) { pub fn remove_tiled_window(&mut self, window: &Window) {
// Uncommitted toplevels have not entered a workspace yet. // Uncommitted toplevels have not entered a workspace yet.
self.space.unmap_elem(window); self.space.unmap_elem(window);
let Some(id) = self.window_ids.remove(window) else { let Some(id) = self.windows.remove(window) else {
return; return;
}; };
self.window_geometry.remove(&id);
self.resize_transactions.remove(&id); self.focus_history.remove(id);
self.resize_snapshots.remove(&id); self.render.remove_window(id);
self.window_resize_fills.remove(&id);
self.maximized_windows.remove(&id);
self.maximized_column_widths.remove(&id);
self.window_stack.remove(id); self.window_stack.remove(id);
self.floating_above_fullscreen.remove(&id); self.floating_above_fullscreen.remove(&id);
self.natural_floating_pending.remove(&id);
self.floating_window_memory.remove(&id);
self.floating_placement_anchors.remove(&id);
self.floating_resize_anchors.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);
self.scrolling_world_x.remove(&id);
self.viewport_coupled_widths.remove(&id);
self.pending_column_width_cycles.remove(&id);
if let Err(error) = self.workspaces.remove_window(id) { if let Err(error) = self.workspaces.remove_window(id) {
tracing::error!(%error, ?id, "failed to remove window from layout"); tracing::error!(%error, ?id, "failed to remove window from layout");
} }
if self.focused_window == Some(id) { if self.focused_window == Some(id) {
self.focused_window = self.workspaces.active().last_focused; self.focused_window = self.workspaces.active().last_focused;
} }
@@ -422,7 +440,8 @@ impl Ferese {
pub(crate) fn capture_resize_before_commit(&mut self, surface: &WlSurface) { pub(crate) fn capture_resize_before_commit(&mut self, surface: &WlSurface) {
let Some((window, id)) = self let Some((window, id)) = self
.window_ids .windows
.ids()
.iter() .iter()
.find(|(window, _)| { .find(|(window, _)| {
window window
@@ -433,9 +452,11 @@ impl Ferese {
else { else {
return; return;
}; };
let Some(transaction) = self.resize_transactions.get(&id) else {
let Some(transaction) = self.windows.transaction(&id) else {
return; return;
}; };
let serial = with_states(surface, |states| { let serial = with_states(surface, |states| {
states states
.data_map .data_map
@@ -445,6 +466,7 @@ impl Ferese {
if !transaction.accepts(serial) { if !transaction.accepts(serial) {
return; return;
} }
let Some(output) = self let Some(output) = self
.space .space
.outputs() .outputs()
@@ -453,10 +475,11 @@ impl Ferese {
else { else {
return; return;
}; };
let source_geometry = transaction.source_geometry().unwrap_or_else(|| window.geometry()); let source_geometry = transaction.source_geometry().unwrap_or_else(|| window.geometry());
let scale = output.current_scale().fractional_scale(); let scale = output.current_scale().fractional_scale();
let source_size = source_geometry.size.to_physical_precise_round(scale); let source_size = source_geometry.size.to_physical_precise_round(scale);
if self.resize_snapshots.get(&id).is_some_and(|snapshot| { if self.render.snapshot(&id).is_some_and(|snapshot| {
crate::presentation::snapshot_covers_source( crate::presentation::snapshot_covers_source(
smithay::backend::renderer::Texture::size(&snapshot.texture), smithay::backend::renderer::Texture::size(&snapshot.texture),
source_size, source_size,
@@ -466,7 +489,8 @@ impl Ferese {
}) { }) {
return; return;
} }
let used: usize = self.resize_snapshots.values().map(|snapshot| snapshot.bytes()).sum();
let used: usize = self.render.snapshots().map(|snapshot| snapshot.bytes()).sum();
let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used); let remaining = crate::presentation::SNAPSHOT_BUDGET.saturating_sub(used);
let result = if let Some(backend) = &self.nested_backend { let result = if let Some(backend) = &self.nested_backend {
// Commit dispatch does not run inside the Winit event callback; // Commit dispatch does not run inside the Winit event callback;
@@ -489,7 +513,7 @@ impl Ferese {
match result { match result {
Ok(Some(snapshot)) => { Ok(Some(snapshot)) => {
tracing::debug!(?id, bytes = snapshot.bytes(), "captured native resize handoff"); tracing::debug!(?id, bytes = snapshot.bytes(), "captured native resize handoff");
self.resize_snapshots.insert(id, snapshot); self.render.set_snapshot(id, snapshot);
} }
Ok(None) => {} Ok(None) => {}
Err(error) => { Err(error) => {
@@ -499,7 +523,7 @@ impl Ferese {
} }
pub fn record_client_commit(&mut self, window: &Window) { pub fn record_client_commit(&mut self, window: &Window) {
let Some(&id) = self.window_ids.get(window) else { let Some(&id) = self.windows.ids().get(window) else {
return; return;
}; };
// current_serial is promoted by Smithay on commit, unlike // current_serial is promoted by Smithay on commit, unlike
@@ -513,28 +537,30 @@ impl Ferese {
}) })
}); });
if self if self
.resize_transactions .windows
.get(&id) .transaction(&id)
.is_some_and(|transaction| transaction.accepts(committed_serial)) .is_some_and(|transaction| transaction.accepts(committed_serial))
{ {
self.resize_transactions.remove(&id); self.windows.clear_transaction(&id);
} }
let Some(size) = client_size(window) else { let Some(size) = client_size(window) else {
return; return;
}; };
let fullscreen = self let fullscreen = self
.workspaces .workspaces
.workspace_for_window(id) .workspace_for_window(id)
.and_then(|workspace| self.workspaces.workspace(workspace)) .and_then(|workspace| self.workspaces.workspace(workspace))
.is_some_and(|workspace| workspace.fullscreen == Some(id)); .is_some_and(|workspace| workspace.fullscreen == Some(id));
let normal_geometry = !fullscreen let normal_geometry = !fullscreen
&& !self.maximized_windows.contains(&id) && !self.windows.record(id).is_some_and(|w| w.maximized)
&& self && self.windows.geometry(&id).is_none_or(|geometry| !geometry.is_zooming());
.window_geometry let initial_floating = self
.get(&id) .windows
.is_none_or(|geometry| !geometry.is_zooming()); .update(id, |w| std::mem::take(&mut w.natural_floating_pending))
let initial_floating = .unwrap_or(false)
self.natural_floating_pending.remove(&id) || self.floating_placement_anchors.contains_key(&id); || self.windows.record(id).is_some_and(|w| w.placement_anchor.is_some());
if initial_floating if initial_floating
&& normal_geometry && normal_geometry
&& matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. })) && matches!(self.workspaces.placement(id), Some(WindowPlacement::Floating { .. }))
@@ -547,12 +573,12 @@ impl Ferese {
(f64::from(size.width), f64::from(size.height)), (f64::from(size.width), f64::from(size.height)),
Some((Some(f64::from(size.width)), Some(f64::from(size.height)))), Some((Some(f64::from(size.width)), Some(f64::from(size.height)))),
); );
self.floating_placement_anchors.remove(&id); self.windows.update(id, |w| w.placement_anchor = None);
if self.workspaces.set_floating_rect(id, rect).is_ok() { if self.workspaces.set_floating_rect(id, rect).is_ok() {
// This is the first mapped client buffer, not a user resize. // This is the first mapped client buffer, not a user resize.
// Do not animate from the temporary placement box. // Do not animate from the temporary placement box.
self.window_geometry.insert(id, WindowGeometry::new(rect, Some(size))); self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size)));
self.resize_transactions.remove(&id); self.windows.clear_transaction(&id);
self.relayout(); self.relayout();
} }
} }
@@ -560,7 +586,7 @@ impl Ferese {
// terminal cell grids, or a dialog changing its contents). Its committed // terminal cell grids, or a dialog changing its contents). Its committed
// window geometry is authoritative once the latest configure is committed. // window geometry is authoritative once the latest configure is committed.
// Never let an old buffer undo a newer resize or a fullscreen transition. // Never let an old buffer undo a newer resize or a fullscreen transition.
self.floating_placement_anchors.remove(&id); self.windows.update(id, |w| w.placement_anchor = None);
let settled_configure = window.toplevel().is_some_and(|toplevel| { let settled_configure = window.toplevel().is_some_and(|toplevel| {
with_states(toplevel.wl_surface(), |states| { with_states(toplevel.wl_surface(), |states| {
states.data_map.get::<XdgToplevelSurfaceData>().is_some_and(|data| { states.data_map.get::<XdgToplevelSurfaceData>().is_some_and(|data| {
@@ -580,22 +606,25 @@ impl Ferese {
&& ClientSize::from_rect(rect) != size && ClientSize::from_rect(rect) != size
{ {
let mut rect = Rect::new(rect.x, rect.y, f64::from(size.width), f64::from(size.height)); let mut rect = Rect::new(rect.x, rect.y, f64::from(size.width), f64::from(size.height));
if let Some((left, top, anchor)) = self.floating_resize_anchors.get(&id) { if let Some((left, top, anchor)) = self.windows.record(id).and_then(|w| w.resize_anchor.as_ref()) {
rect = crate::floating::anchored_size(*anchor, (rect.width, rect.height), *left, *top); rect = crate::floating::anchored_size(*anchor, (rect.width, rect.height), *left, *top);
} else if initial_floating && let Some(work) = self.floating_bounds_for_window(id) { } else if initial_floating && let Some(work) = self.floating_bounds_for_window(id) {
rect = crate::floating::clamp_to_work(rect, work); rect = crate::floating::clamp_to_work(rect, work);
} }
if self.workspaces.set_floating_rect(id, rect).is_ok() { if self.workspaces.set_floating_rect(id, rect).is_ok() {
self.window_geometry.insert(id, WindowGeometry::new(rect, Some(size))); self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size)));
self.resize_transactions.remove(&id); self.windows.clear_transaction(&id);
self.resize_snapshots.remove(&id); self.render.clear_snapshot(&id);
self.relayout(); self.relayout();
} }
} }
if settled_configure && self.floating_resize_anchors.remove(&id).is_some() {
if settled_configure && self.windows.update(id, |w| w.resize_anchor.take()).flatten().is_some() {
self.remember_floating(window); self.remember_floating(window);
} }
let Some(geometry) = self.window_geometry.get_mut(&id) else {
let Some(geometry) = self.windows.geometry_mut(&id) else {
return; return;
}; };
@@ -613,17 +642,21 @@ impl Ferese {
return; return;
} }
self.closing_windows.entry(focused).or_default(); self.windows.update(focused, |record| {
record.closing.get_or_insert_default();
});
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
} }
pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool { pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool {
if !self.window_ids.values().any(|window_id| *window_id == id) { if !self.windows.ids().values().any(|window_id| *window_id == id) {
return false; return false;
} }
if self.animations_enabled { if self.animations_enabled {
self.closing_windows.entry(id).or_default(); self.windows.update(id, |record| {
record.closing.get_or_insert_default();
});
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
} else { } else {
self.send_window_close(id); self.send_window_close(id);
@@ -634,7 +667,8 @@ impl Ferese {
pub(super) fn send_window_close(&self, id: WindowId) { pub(super) fn send_window_close(&self, id: WindowId) {
let Some(toplevel) = self let Some(toplevel) = self
.window_ids .windows
.ids()
.iter() .iter()
.find_map(|(window, window_id)| (*window_id == id).then(|| window.toplevel()).flatten()) .find_map(|(window, window_id)| (*window_id == id).then(|| window.toplevel()).flatten())
else { else {
+3 -2
View File
@@ -125,7 +125,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
let rendered = (|| -> DamageRenderResult { let rendered = (|| -> DamageRenderResult {
{ {
let (renderer, mut framebuffer) = backend.bind()?; let (renderer, mut framebuffer) = backend.bind()?;
state.process_dmabuf_imports(renderer); state.process_dmabuf_imports(renderer, None);
let elements = animated_window_elements(state, renderer, &output); let elements = animated_window_elements(state, renderer, &output);
let effects = frame_effect_metrics(&elements, output.current_scale().fractional_scale()); let effects = frame_effect_metrics(&elements, output.current_scale().fractional_scale());
let result = damage_tracker.render_output( let result = damage_tracker.render_output(
@@ -229,7 +229,8 @@ fn send_nested_frame_callbacks(state: &mut Ferese, output: &Output, refresh: Dur
let time = state.start_time.elapsed(); let time = state.start_time.elapsed();
for window in state.space.elements().filter(|window| { for window in state.space.elements().filter(|window| {
state state
.window_ids .windows
.ids()
.get(*window) .get(*window)
.is_some_and(|id| state.window_belongs_to_output(*id, output)) .is_some_and(|id| state.window_belongs_to_output(*id, output))
}) { }) {
+16
View File
@@ -1297,6 +1297,22 @@ pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn resolved_shell_radius_preserves_fractional_values() {
for (requested, expected) in [(0.0, 0.0), (0.4, 0.4), (13.4, 13.4), (13.5, 13.5)] {
let document = crate::Document::parse(&format!(
"theme {{ geometry {{ shell-radius {requested}; window-radius 7.25; }} }}"
))
.unwrap();
let result = super::resolve(&document, std::path::Path::new("/tmp"), jiff::Timestamp::now(), |_| {
Err("unexpected theme file".into())
})
.unwrap();
assert_eq!(result.theme.tokens.geometry.shell_radius, expected);
assert_eq!(result.theme.tokens.geometry.window_radius, 7.25);
}
}
use super::*; use super::*;
fn candidate(source: &str) -> Result<Candidate, String> { fn candidate(source: &str) -> Result<Candidate, String> {
+144
View File
@@ -0,0 +1,144 @@
use ferese_layout::WindowId;
/// Most recent focus first. Layer surfaces and temporary selections do not enter this list.
#[derive(Debug, Default)]
pub struct FocusHistory {
windows: Vec<WindowId>,
}
impl FocusHistory {
pub fn record(&mut self, window: WindowId) {
if self.windows.first() == Some(&window) {
return;
}
self.remove(window);
self.windows.insert(0, window);
}
pub fn remove(&mut self, window: WindowId) {
self.windows.retain(|id| *id != window);
}
pub fn candidates(&self, current: Option<WindowId>, mut available: Vec<WindowId>) -> Vec<WindowId> {
available.sort_unstable_by_key(|id| id.0);
available.dedup();
let mut ordered = Vec::with_capacity(available.len());
for id in current
.into_iter()
.chain(self.windows.iter().copied())
.chain(available.iter().copied())
{
if available.contains(&id) && !ordered.contains(&id) {
ordered.push(id);
}
}
ordered
}
}
/// A frozen traversal order: previewing does not reorder focus history.
#[derive(Debug)]
pub struct FocusCycle {
windows: Vec<WindowId>,
selected: Option<WindowId>,
}
impl FocusCycle {
pub fn new(windows: Vec<WindowId>, current: Option<WindowId>) -> Self {
let selected = current.filter(|id| windows.contains(id));
Self { windows, selected }
}
pub fn windows(&self) -> &[WindowId] {
&self.windows
}
pub fn reconcile(&mut self, available: &[WindowId]) -> Option<WindowId> {
if self.selected.is_some_and(|id| available.contains(&id)) {
self.selected
} else {
self.advance(false, available)
}
}
pub fn advance(&mut self, reverse: bool, available: &[WindowId]) -> Option<WindowId> {
// Keep the old index when the selected window disappears so forward
// traversal selects its successor rather than skipping over it.
let old = self
.selected
.and_then(|id| self.windows.iter().position(|candidate| *candidate == id));
let before = old.map(|index| self.windows[..index].iter().filter(|id| available.contains(id)).count());
let survives = self.selected.is_some_and(|id| available.contains(&id));
self.windows.retain(|id| available.contains(id));
if self.windows.is_empty() {
self.selected = None;
return None;
}
let len = self.windows.len();
let index = match (before, reverse, survives) {
(Some(index), true, _) => (index + len - 1) % len,
(Some(index), false, true) => (index + 1) % len,
(Some(index), false, false) => index % len,
(None, true, _) => len - 1,
(None, false, _) => 0,
};
self.selected = Some(self.windows[index]);
self.selected
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn history_is_unique_filters_unavailable_and_last_focus_toggles() {
let mut history = FocusHistory::default();
for id in [1, 2, 3, 3] {
history.record(WindowId(id));
}
let available = vec![WindowId(1), WindowId(2), WindowId(3), WindowId(4)];
assert_eq!(
history.candidates(Some(WindowId(3)), available.clone()),
[3, 2, 1, 4].map(WindowId)
);
history.record(WindowId(2));
assert_eq!(history.candidates(Some(WindowId(2)), available)[1], WindowId(3));
history.remove(WindowId(3));
assert_eq!(
history.candidates(None, vec![WindowId(1), WindowId(4)]),
[1, 4].map(WindowId)
);
}
#[test]
fn cycle_freezes_order_wraps_reverses_and_skips_closed_windows() {
let available = [3, 2, 1].map(WindowId);
let mut cycle = FocusCycle::new(available.to_vec(), Some(WindowId(3)));
assert_eq!(cycle.advance(false, &available), Some(WindowId(2)));
assert_eq!(cycle.advance(false, &available), Some(WindowId(1)));
assert_eq!(cycle.advance(false, &available), Some(WindowId(3)));
assert_eq!(cycle.advance(true, &available), Some(WindowId(1)));
assert_eq!(cycle.advance(false, &[WindowId(3), WindowId(2)]), Some(WindowId(3)));
assert_eq!(cycle.advance(false, &[]), None);
}
#[test]
fn missing_current_focus_starts_at_most_recent_and_new_windows_do_not_join_cycle() {
let mut cycle = FocusCycle::new(vec![WindowId(2), WindowId(1)], None);
assert_eq!(
cycle.advance(false, &[WindowId(3), WindowId(2), WindowId(1)]),
Some(WindowId(2))
);
assert_eq!(cycle.advance(true, &[WindowId(2)]), Some(WindowId(2)));
}
#[test]
fn closing_the_preview_selects_its_successor_before_commit() {
let available = [3, 2, 1].map(WindowId);
let mut cycle = FocusCycle::new(available.to_vec(), Some(WindowId(3)));
assert_eq!(cycle.advance(false, &available), Some(WindowId(2)));
assert_eq!(cycle.reconcile(&[WindowId(3), WindowId(1)]), Some(WindowId(1)));
assert_eq!(cycle.reconcile(&[WindowId(3), WindowId(1)]), Some(WindowId(1)));
}
}
+4 -1
View File
@@ -1,10 +1,13 @@
pub mod desktop; pub mod desktop;
mod focus;
pub mod notifications; pub mod notifications;
mod output; mod output;
mod workspace; mod workspace;
mod workspace_policy;
pub use focus::{FocusCycle, FocusHistory};
pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch}; pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
pub use workspace::{ pub use workspace::{
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet, LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet,
workspace_order_key,
}; };
pub use workspace_policy::WorkspaceView;
+29 -31
View File
@@ -107,10 +107,6 @@ impl OutputWorkspaceMap {
self.outputs.get(&output).and_then(|state| state.previous) self.outputs.get(&output).and_then(|state| state.previous)
} }
pub fn history_workspaces(&self) -> impl Iterator<Item = WorkspaceId> + '_ {
self.outputs.values().filter_map(|state| state.previous)
}
pub fn output_for_workspace(&self, workspace: WorkspaceId) -> Option<OutputId> { pub fn output_for_workspace(&self, workspace: WorkspaceId) -> Option<OutputId> {
self.assignments.get(&workspace).copied() self.assignments.get(&workspace).copied()
} }
@@ -171,6 +167,19 @@ impl OutputWorkspaceMap {
let revision_matches = self.revision(evacuated.workspace) == evacuated.revision; let revision_matches = self.revision(evacuated.workspace) == evacuated.revision;
if assignment_matches && revision_matches { if assignment_matches && revision_matches {
// Cleanup may have removed every other workspace while this
// monitor was disconnected. Never strand the donor output;
// the reconnecting monitor can use its fresh fallback.
if evacuated
.target
.and_then(|target| self.outputs.get(&target))
.is_some_and(|state| {
state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace)
})
{
continue;
}
if let Some(target) = evacuated.target if let Some(target) = evacuated.target
&& let Some(target_state) = self.outputs.get_mut(&target) && let Some(target_state) = self.outputs.get_mut(&target)
{ {
@@ -413,6 +422,22 @@ impl OutputWorkspaceMap {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn reconnect_cannot_reclaim_the_donor_monitors_only_workspace() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
outputs.focus_output(OutputId(2)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
assert!(outputs.forget_workspace(WorkspaceId(1)));
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(3)).unwrap();
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(2)));
assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(3)));
assert!(outputs.validate());
}
#[test] #[test]
fn forgetting_an_inactive_workspace_prevents_hotplug_resurrection() { fn forgetting_an_inactive_workspace_prevents_hotplug_resurrection() {
let mut outputs = super::OutputWorkspaceMap::default(); let mut outputs = super::OutputWorkspaceMap::default();
@@ -530,33 +555,6 @@ mod tests {
); );
} }
#[test]
fn empty_previous_workspace_survives_pruning_until_history_moves_on() {
let mut workspaces = crate::WorkspaceSet::default();
let first = workspaces.active_id();
let second = workspaces.ensure_numeric(2).unwrap();
let third = workspaces.ensure_numeric(3).unwrap();
let mut outputs = OutputWorkspaceMap::default();
let display = OutputId(1);
outputs.connect(display, geometry(0), first).unwrap();
outputs.switch_workspace(display, second).unwrap();
workspaces.activate(second).unwrap();
let protected = outputs.history_workspaces().chain([second]).collect();
let removed = workspaces.prune_empty(&protected);
assert!(removed.contains(&third));
assert!(workspaces.workspace(first).is_some());
let third = workspaces.ensure_numeric(3).unwrap();
outputs.switch_workspace(display, third).unwrap();
workspaces.activate(third).unwrap();
let protected = outputs.history_workspaces().chain([third]).collect();
for workspace in workspaces.prune_empty(&protected) {
outputs.forget_workspace(workspace);
}
assert!(workspaces.workspace(first).is_none());
assert!(workspaces.workspace(second).is_some());
assert_eq!(outputs.previous_workspace(display), Some(second));
}
#[test] #[test]
fn new_outputs_receive_distinct_workspaces() { fn new_outputs_receive_distinct_workspaces() {
let mut outputs = OutputWorkspaceMap::default(); let mut outputs = OutputWorkspaceMap::default();
+74 -139
View File
@@ -10,14 +10,9 @@ use ferese_layout::{
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct WorkspaceId(pub u64); pub struct WorkspaceId(pub u64);
pub fn workspace_order_key(name: &str, id: WorkspaceId) -> (u64, u64) {
(name.parse::<u64>().unwrap_or(u64::MAX), id.0)
}
#[derive(Debug)] #[derive(Debug)]
pub struct Workspace { pub struct Workspace {
pub id: WorkspaceId, pub id: WorkspaceId,
pub name: String,
pub layout: WorkspaceLayout, pub layout: WorkspaceLayout,
pub floating: Vec<WindowId>, pub floating: Vec<WindowId>,
pub last_focused: Option<WindowId>, pub last_focused: Option<WindowId>,
@@ -339,7 +334,6 @@ impl From<LayoutError> for WorkspaceError {
pub struct WorkspaceSet { pub struct WorkspaceSet {
active: WorkspaceId, active: WorkspaceId,
workspaces: HashMap<WorkspaceId, Workspace>, workspaces: HashMap<WorkspaceId, Workspace>,
names: HashMap<String, WorkspaceId>,
window_workspaces: HashMap<WindowId, WorkspaceId>, window_workspaces: HashMap<WindowId, WorkspaceId>,
placements: HashMap<WindowId, WindowPlacement>, placements: HashMap<WindowId, WindowPlacement>,
default_layout_mode: LayoutMode, default_layout_mode: LayoutMode,
@@ -436,19 +430,16 @@ impl WorkspaceSet {
let active = WorkspaceId(1); let active = WorkspaceId(1);
let workspace = Workspace { let workspace = Workspace {
id: active, id: active,
name: "1".to_owned(),
layout: WorkspaceLayout::new(default_layout_mode, default_column_width, scrolling_focus_strategy), layout: WorkspaceLayout::new(default_layout_mode, default_column_width, scrolling_focus_strategy),
floating: Vec::new(), floating: Vec::new(),
last_focused: None, last_focused: None,
fullscreen: None, fullscreen: None,
}; };
let workspaces = HashMap::from([(active, workspace)]); let workspaces = HashMap::from([(active, workspace)]);
let names = HashMap::from([("1".to_owned(), active)]);
Self { Self {
active, active,
workspaces, workspaces,
names,
window_workspaces: HashMap::new(), window_workspaces: HashMap::new(),
placements: HashMap::new(), placements: HashMap::new(),
default_layout_mode, default_layout_mode,
@@ -500,36 +491,6 @@ impl WorkspaceSet {
self.workspaces.values() self.workspaces.values()
} }
pub fn ordered(&self) -> Vec<&Workspace> {
let mut workspaces = self.iter().collect::<Vec<_>>();
workspaces.sort_unstable_by_key(|workspace| workspace_order_key(&workspace.name, workspace.id));
workspaces
}
pub fn prune_empty(&mut self, protected: &HashSet<WorkspaceId>) -> Vec<WorkspaceId> {
let mut empty = self
.workspaces
.values()
.filter(|workspace| workspace.layout.window_ids().next().is_none() && workspace.floating.is_empty())
.map(|workspace| workspace.id)
.collect::<Vec<_>>();
empty.sort_by_key(|id| id.0);
let visible_empty = empty.iter().any(|id| *id == self.active || protected.contains(id));
let spare = if visible_empty { None } else { empty.first().copied() };
let mut removed = Vec::new();
for id in empty {
if id == self.active || protected.contains(&id) || Some(id) == spare {
continue;
}
if let Some(workspace) = self.workspaces.remove(&id) {
self.names.remove(&workspace.name);
removed.push(id);
}
}
debug_assert!(self.validate().is_ok());
removed
}
pub fn workspace_for_window(&self, window: WindowId) -> Option<WorkspaceId> { pub fn workspace_for_window(&self, window: WindowId) -> Option<WorkspaceId> {
self.window_workspaces.get(&window).copied() self.window_workspaces.get(&window).copied()
} }
@@ -680,25 +641,39 @@ impl WorkspaceSet {
Ok(changed) Ok(changed)
} }
pub fn ensure_numeric(&mut self, index: u32) -> Result<WorkspaceId, WorkspaceError> { pub fn remove_empty(&mut self, id: WorkspaceId) -> bool {
if id == self.active || !self.workspace(id).is_some_and(Workspace::is_empty) {
return false;
}
self.workspaces.remove(&id);
true
}
#[cfg(test)]
pub(crate) fn fixture_workspace(&mut self, index: u32) -> Result<WorkspaceId, WorkspaceError> {
if index == 0 { if index == 0 {
return Err(WorkspaceError::InvalidNumericName(index)); return Err(WorkspaceError::InvalidNumericName(index));
} }
let name = index.to_string(); while self.next_id <= u64::from(index) {
self.create_workspace();
if let Some(id) = self.names.get(&name) {
return Ok(*id);
} }
let id = WorkspaceId(u64::from(index));
self.workspace(id)
.map(|_| id)
.ok_or(WorkspaceError::UnknownWorkspace(id))
}
/// Creates an identity; display positions are assigned by the output owner.
pub fn create_workspace(&mut self) -> WorkspaceId {
let id = WorkspaceId(self.next_id); let id = WorkspaceId(self.next_id);
self.next_id += 1; self.next_id = self.next_id.checked_add(1).expect("workspace IDs exhausted");
self.names.insert(name.clone(), id);
self.workspaces.insert( self.workspaces.insert(
id, id,
Workspace { Workspace {
id, id,
name,
layout: WorkspaceLayout::new( layout: WorkspaceLayout::new(
self.default_layout_mode, self.default_layout_mode,
self.default_column_width, self.default_column_width,
@@ -711,11 +686,12 @@ impl WorkspaceSet {
); );
debug_assert!(self.validate().is_ok()); debug_assert!(self.validate().is_ok());
Ok(id) id
} }
pub fn switch_to_numeric(&mut self, index: u32) -> Result<Option<WindowId>, WorkspaceError> { #[cfg(test)]
let workspace = self.ensure_numeric(index)?; fn switch_to_fixture(&mut self, index: u32) -> Result<Option<WindowId>, WorkspaceError> {
let workspace = self.fixture_workspace(index)?;
self.activate(workspace) self.activate(workspace)
} }
@@ -798,17 +774,32 @@ impl WorkspaceSet {
Ok(()) Ok(())
} }
pub fn move_window_to_numeric( #[cfg(test)]
fn move_window_to_fixture(
&mut self, &mut self,
window: WindowId, window: WindowId,
index: u32, index: u32,
axis: Axis, axis: Axis,
ratio: f64, ratio: f64,
) -> Result<WorkspaceId, WorkspaceError> { ) -> Result<WorkspaceId, WorkspaceError> {
let destination = self.fixture_workspace(index)?;
self.move_window_to_workspace(window, destination, axis, ratio)
}
pub fn move_window_to_workspace(
&mut self,
window: WindowId,
destination_id: WorkspaceId,
axis: Axis,
ratio: f64,
) -> Result<WorkspaceId, WorkspaceError> {
if self.workspace(destination_id).is_none() {
return Err(WorkspaceError::UnknownWorkspace(destination_id));
}
let source_id = self let source_id = self
.workspace_for_window(window) .workspace_for_window(window)
.ok_or(LayoutError::UnknownWindow(window))?; .ok_or(LayoutError::UnknownWindow(window))?;
let destination_id = self.ensure_numeric(index)?;
if source_id == destination_id { if source_id == destination_id {
return Ok(destination_id); return Ok(destination_id);
@@ -943,14 +934,11 @@ impl WorkspaceSet {
if !self.workspaces.contains_key(&self.active) { if !self.workspaces.contains_key(&self.active) {
return Err(WorkspaceError::InvalidState("active workspace is missing")); return Err(WorkspaceError::InvalidState("active workspace is missing"));
} }
if self.names.len() != self.workspaces.len() {
return Err(WorkspaceError::InvalidState("workspace name index is inconsistent"));
}
let mut seen_windows = HashSet::new(); let mut seen_windows = HashSet::new();
for (id, workspace) in &self.workspaces { for (id, workspace) in &self.workspaces {
if workspace.id != *id || self.names.get(&workspace.name) != Some(id) { if workspace.id != *id {
return Err(WorkspaceError::InvalidState("workspace index is inconsistent")); return Err(WorkspaceError::InvalidState("workspace index is inconsistent"));
} }
@@ -1033,70 +1021,6 @@ fn finite_or_zero(value: f64) -> f64 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn numeric_order_survives_pruning_and_recreation() {
let mut set = super::WorkspaceSet::default();
let original_first = set.active_id();
let second = set.ensure_numeric(2).unwrap();
set.activate(second).unwrap();
assert_eq!(set.prune_empty(&std::collections::HashSet::new()), vec![original_first]);
let tenth = set.ensure_numeric(10).unwrap();
let first = set.ensure_numeric(1).unwrap();
assert_ne!(first, original_first);
assert!(first.0 > tenth.0);
assert_eq!(
set.ordered()
.into_iter()
.map(|workspace| (workspace.name.as_str(), workspace.id))
.collect::<Vec<_>>(),
vec![("1", first), ("2", second), ("10", tenth)]
);
set.activate(first).unwrap();
assert_eq!(set.active().name, "1");
assert!(set.validate().is_ok());
}
#[test]
fn pruning_keeps_windows_and_one_empty_workspace() {
let mut set = super::WorkspaceSet::default();
let first = set.active_id();
set.insert_window(super::WindowId(1), super::Axis::Horizontal, 0.5)
.unwrap();
let floating = set.ensure_numeric(2).unwrap();
set.insert_floating_window(
super::WindowId(2),
floating,
super::Rect::new(0., 0., 100., 100.),
false,
)
.unwrap();
let spare = set.ensure_numeric(3).unwrap();
let excess = set.ensure_numeric(4).unwrap();
assert_eq!(set.prune_empty(&std::collections::HashSet::new()), vec![excess]);
assert!(set.workspace(first).is_some());
assert!(set.workspace(floating).is_some());
assert!(set.workspace(spare).is_some());
set.activate(spare).unwrap();
set.remove_window(super::WindowId(1)).unwrap();
assert_eq!(set.prune_empty(&std::collections::HashSet::new()), vec![first]);
assert!(set.validate().is_ok());
assert_ne!(set.ensure_numeric(1).unwrap(), first);
}
#[test]
fn pruning_never_removes_another_displays_active_workspace() {
let mut set = super::WorkspaceSet::default();
let first = set.active_id();
let second = set.ensure_numeric(2).unwrap();
let third = set.ensure_numeric(3).unwrap();
assert_eq!(
set.prune_empty(&std::collections::HashSet::from([first, second])),
vec![third]
);
assert!(set.workspace(second).is_some());
}
#[test] #[test]
fn live_policy_updates_existing_layouts_and_default_widths_not_custom_widths() { fn live_policy_updates_existing_layouts_and_default_widths_not_custom_widths() {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
@@ -1149,21 +1073,22 @@ mod tests {
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5)); assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
assert_eq!(layout.default_width(), ColumnWidth::Full); assert_eq!(layout.default_width(), ColumnWidth::Full);
assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace)); assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace));
workspaces.switch_to_numeric(2).unwrap(); workspaces.switch_to_fixture(2).unwrap();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree); assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree);
assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace)); assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace));
} }
use super::*; use super::*;
#[test] #[test]
fn numeric_workspaces_are_created_lazily_with_stable_ids() { fn workspace_creation_uses_fresh_ids() {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
let first = workspaces.active_id(); let first = workspaces.active_id();
let ninth = workspaces.ensure_numeric(9).unwrap(); let second = workspaces.create_workspace();
assert!(workspaces.remove_empty(second));
assert_eq!(workspaces.ensure_numeric(1), Ok(first)); let third = workspaces.create_workspace();
assert_eq!(workspaces.ensure_numeric(9), Ok(ninth)); assert_ne!(first, third);
assert_ne!(first, ninth); assert_ne!(second, third);
assert!(workspaces.validate().is_ok()); assert!(workspaces.validate().is_ok());
} }
@@ -1184,7 +1109,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(first.geometry[&WindowId(1)], Rect::new(4.0, 4.0, 992.0, 792.0)); assert_eq!(first.geometry[&WindowId(1)], Rect::new(4.0, 4.0, 992.0, 792.0));
workspaces.switch_to_numeric(2).unwrap(); workspaces.switch_to_fixture(2).unwrap();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling); assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
} }
@@ -1193,11 +1118,11 @@ mod tests {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
assert_eq!(workspaces.switch_to_numeric(2).unwrap(), None); assert_eq!(workspaces.switch_to_fixture(2).unwrap(), None);
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
assert_eq!(workspaces.switch_to_numeric(1).unwrap(), Some(WindowId(1))); assert_eq!(workspaces.switch_to_fixture(1).unwrap(), Some(WindowId(1)));
assert_eq!(workspaces.switch_to_numeric(2).unwrap(), Some(WindowId(2))); assert_eq!(workspaces.switch_to_fixture(2).unwrap(), Some(WindowId(2)));
assert!(workspaces.validate().is_ok()); assert!(workspaces.validate().is_ok());
} }
@@ -1208,7 +1133,7 @@ mod tests {
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
let first = workspaces.active_id(); let first = workspaces.active_id();
let second = workspaces let second = workspaces
.move_window_to_numeric(WindowId(2), 2, Axis::Horizontal, 0.5) .move_window_to_fixture(WindowId(2), 2, Axis::Horizontal, 0.5)
.unwrap(); .unwrap();
assert_eq!(workspaces.active_id(), first); assert_eq!(workspaces.active_id(), first);
@@ -1247,7 +1172,7 @@ mod tests {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces workspaces
.move_window_to_numeric(WindowId(1), 2, Axis::Horizontal, 0.5) .move_window_to_fixture(WindowId(1), 2, Axis::Horizontal, 0.5)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1402,7 +1327,7 @@ mod tests {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
let parent_workspace = workspaces.active_id(); let parent_workspace = workspaces.active_id();
workspaces.switch_to_numeric(2).unwrap(); workspaces.switch_to_fixture(2).unwrap();
workspaces workspaces
.insert_floating_window( .insert_floating_window(
WindowId(2), WindowId(2),
@@ -1463,7 +1388,7 @@ mod tests {
assert_eq!(workspaces.active().last_focused, Some(WindowId(2))); assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
assert!(workspaces.focus_window(WindowId(2)).is_ok()); assert!(workspaces.focus_window(WindowId(2)).is_ok());
workspaces.switch_to_numeric(2).unwrap(); workspaces.switch_to_fixture(2).unwrap();
assert_eq!(workspaces.activate(workspace).unwrap(), Some(WindowId(2))); assert_eq!(workspaces.activate(workspace).unwrap(), Some(WindowId(2)));
assert_eq!(workspaces.active().fullscreen, Some(WindowId(1))); assert_eq!(workspaces.active().fullscreen, Some(WindowId(1)));
assert!(workspaces.validate().is_ok()); assert!(workspaces.validate().is_ok());
@@ -1474,13 +1399,13 @@ mod tests {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces.toggle_fullscreen(WindowId(1)).unwrap(); workspaces.toggle_fullscreen(WindowId(1)).unwrap();
workspaces.switch_to_numeric(2).unwrap(); workspaces.switch_to_fixture(2).unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces workspaces
.move_window_to_numeric(WindowId(2), 1, Axis::Horizontal, 0.5) .move_window_to_fixture(WindowId(2), 1, Axis::Horizontal, 0.5)
.unwrap(); .unwrap();
assert_eq!(workspaces.switch_to_numeric(1).unwrap(), Some(WindowId(1))); assert_eq!(workspaces.switch_to_fixture(1).unwrap(), Some(WindowId(1)));
assert_eq!(workspaces.active().last_focused, Some(WindowId(1))); assert_eq!(workspaces.active().last_focused, Some(WindowId(1)));
assert_eq!( assert_eq!(
workspaces.focus_window(WindowId(2)), workspaces.focus_window(WindowId(2)),
@@ -1586,13 +1511,13 @@ mod tests {
} }
2 => { 2 => {
workspaces workspaces
.switch_to_numeric((random.rotate_left(11) % 4 + 1) as u32) .switch_to_fixture((random.rotate_left(11) % 4 + 1) as u32)
.unwrap(); .unwrap();
} }
3 if !windows.is_empty() => { 3 if !windows.is_empty() => {
let window = windows[random as usize % windows.len()]; let window = windows[random as usize % windows.len()];
workspaces workspaces
.move_window_to_numeric( .move_window_to_fixture(
window, window,
(random.rotate_left(17) % 4 + 1) as u32, (random.rotate_left(17) % 4 + 1) as u32,
random_axis(random), random_axis(random),
@@ -1651,3 +1576,13 @@ mod tests {
) )
} }
} }
impl Workspace {
pub fn window_count(&self) -> usize {
self.layout.window_ids().count() + self.floating.len()
}
pub fn is_empty(&self) -> bool {
self.layout.window_ids().next().is_none() && self.floating.is_empty()
}
}
+352
View File
@@ -0,0 +1,352 @@
use std::collections::HashSet;
use crate::{OutputId, OutputWorkspaceMap, WorkspaceId, WorkspaceSet};
/// Shared presentation model. Identity never changes when positions compact.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkspaceView {
pub id: WorkspaceId,
pub output: OutputId,
pub index: u32,
pub window_count: usize,
pub visible: bool,
pub focused: bool,
}
impl OutputWorkspaceMap {
pub fn workspace_views(&self, workspaces: &WorkspaceSet) -> Vec<WorkspaceView> {
let mut outputs = self.connected_outputs().collect::<Vec<_>>();
outputs.sort_unstable();
outputs
.into_iter()
.flat_map(|output| {
self.ordered_workspaces(workspaces, output)
.into_iter()
.enumerate()
.map(move |(index, id)| {
let visible = self.active_workspace(output) == Some(id);
WorkspaceView {
id,
output,
index: index as u32 + 1,
window_count: workspaces.workspace(id).unwrap().window_count(),
visible,
focused: visible && self.focused_output() == Some(output),
}
})
})
.collect()
}
pub fn ordered_workspaces(&self, workspaces: &WorkspaceSet, output: OutputId) -> Vec<WorkspaceId> {
let mut ids = workspaces
.iter()
.filter(|workspace| self.output_for_workspace(workspace.id) == Some(output))
.map(|workspace| workspace.id)
.collect::<Vec<_>>();
ids.sort_unstable_by_key(|id| id.0);
ids
}
/// Out-of-range numeric selection resolves to the trailing spare. Looking
/// up a gesture target never creates workspaces or changes history.
pub fn workspace_at(&self, workspaces: &WorkspaceSet, output: OutputId, index: u32) -> Option<WorkspaceId> {
let index = index.checked_sub(1)? as usize;
let ids = self.ordered_workspaces(workspaces, output);
ids.get(index).or_else(|| ids.last()).copied()
}
/// Reconcile after membership, selection, output, or transition changes.
/// History is a reference, not a reason to keep an empty workspace alive.
pub fn reconcile_workspaces(
&mut self,
workspaces: &mut WorkspaceSet,
transitions: &HashSet<WorkspaceId>,
) -> Vec<WorkspaceId> {
if let Some(active) = self.focused_output().and_then(|output| self.active_workspace(output)) {
workspaces.activate(active).expect("output's active workspace exists");
}
let mut protected = transitions.clone();
let outputs = self.connected_outputs().collect::<Vec<_>>();
for output in outputs {
let ordered = self.ordered_workspaces(workspaces, output);
if let Some(active) = self.active_workspace(output) {
protected.insert(active);
}
let spare = ordered
.last()
.copied()
.filter(|id| workspaces.workspace(*id).is_some_and(|w| w.is_empty()))
.unwrap_or_else(|| {
let id = workspaces.create_workspace();
self.assign_workspace(output, id).expect("connected output");
id
});
protected.insert(spare);
}
let removed = workspaces
.iter()
.filter(|workspace| {
workspace.is_empty() && !protected.contains(&workspace.id) && workspace.id != workspaces.active_id()
})
.map(|workspace| workspace.id)
.collect::<Vec<_>>();
for id in &removed {
self.forget_workspace(*id);
assert!(workspaces.remove_empty(*id));
}
removed
}
}
#[cfg(test)]
mod tests {
use ferese_layout::{Axis, Rect, WindowId};
use super::*;
use crate::OutputGeometry;
fn setup() -> (WorkspaceSet, OutputWorkspaceMap, OutputId) {
let set = WorkspaceSet::default();
let mut outputs = OutputWorkspaceMap::default();
let output = OutputId(1);
outputs
.connect(output, OutputGeometry::new(0, 0, 1000, 800), set.active_id())
.unwrap();
(set, outputs, output)
}
fn reconcile(set: &mut WorkspaceSet, outputs: &mut OutputWorkspaceMap) {
outputs.reconcile_workspaces(set, &HashSet::new());
assert!(set.validate().is_ok());
for output in outputs.connected_outputs() {
let ids = outputs.ordered_workspaces(set, output);
assert!(set.workspace(*ids.last().unwrap()).unwrap().is_empty());
let empties = ids.iter().filter(|id| set.workspace(**id).unwrap().is_empty()).count();
let active_middle_empty = outputs
.active_workspace(output)
.is_some_and(|id| Some(&id) != ids.last() && set.workspace(id).unwrap().is_empty());
assert_eq!(empties, 1 + usize::from(active_middle_empty));
}
}
#[test]
fn occupied_workspaces_get_one_spare_and_positions_compact_without_changing_ids() {
let (mut set, mut outputs, output) = setup();
let first = set.active_id();
reconcile(&mut set, &mut outputs);
assert_eq!(set.iter().count(), 1);
set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
reconcile(&mut set, &mut outputs);
let second = outputs.workspace_at(&set, output, 2).unwrap();
outputs.switch_workspace(output, second).unwrap();
reconcile(&mut set, &mut outputs);
set.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
reconcile(&mut set, &mut outputs);
let spare = outputs.workspace_at(&set, output, 3).unwrap();
set.remove_window(WindowId(1)).unwrap();
reconcile(&mut set, &mut outputs);
assert!(set.workspace(first).is_none());
assert_eq!(outputs.previous_workspace(output), None);
assert_eq!(outputs.workspace_at(&set, output, 1), Some(second));
assert_eq!(outputs.workspace_at(&set, output, 99), Some(spare));
assert_eq!(outputs.workspace_at(&set, output, 0), None);
assert_eq!(
outputs
.workspace_views(&set)
.iter()
.map(|view| view.index)
.collect::<Vec<_>>(),
vec![1, 2]
);
}
#[test]
fn selected_empty_survives_until_leaving_but_history_does_not_pin_it() {
let (mut set, mut outputs, output) = setup();
let first = set.active_id();
set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
reconcile(&mut set, &mut outputs);
let spare = outputs.workspace_at(&set, output, 2).unwrap();
set.remove_window(WindowId(1)).unwrap();
reconcile(&mut set, &mut outputs);
assert!(set.workspace(first).is_some());
outputs.switch_workspace(output, spare).unwrap();
reconcile(&mut set, &mut outputs);
assert_eq!(outputs.ordered_workspaces(&set, output), vec![spare]);
assert_eq!(outputs.previous_workspace(output), None);
}
#[test]
fn transitions_temporarily_protect_empty_workspaces_and_cancelled_lookup_has_no_effect() {
let (mut set, mut outputs, output) = setup();
let first = set.active_id();
set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
reconcile(&mut set, &mut outputs);
let spare = outputs.workspace_at(&set, output, 2).unwrap();
let before = outputs.workspace_views(&set);
for _ in 0..10 {
assert_eq!(outputs.workspace_at(&set, output, 2), Some(spare));
}
assert_eq!(outputs.workspace_views(&set), before);
assert_eq!(outputs.previous_workspace(output), None);
outputs.switch_workspace(output, spare).unwrap();
set.remove_window(WindowId(1)).unwrap();
outputs.reconcile_workspaces(&mut set, &HashSet::from([first]));
assert!(set.workspace(first).is_some());
reconcile(&mut set, &mut outputs);
assert!(set.workspace(first).is_none());
}
#[test]
fn floating_windows_count_as_occupied_and_last_close_preserves_active_identity() {
let (mut set, mut outputs, output) = setup();
let first = set.active_id();
set.insert_floating_window(WindowId(1), first, Rect::new(0., 0., 100., 100.), true)
.unwrap();
reconcile(&mut set, &mut outputs);
assert_eq!(outputs.workspace_views(&set)[0].window_count, 1);
assert_eq!(set.iter().count(), 2);
set.remove_window(WindowId(1)).unwrap();
reconcile(&mut set, &mut outputs);
assert_eq!(outputs.active_workspace(output), Some(first));
let spare = outputs.workspace_at(&set, output, 2).unwrap();
outputs.switch_workspace(output, spare).unwrap();
reconcile(&mut set, &mut outputs);
assert_eq!(set.iter().count(), 1);
}
#[test]
fn monitors_have_independent_positions_and_reconnect_restores_occupied_workspaces() {
let (mut set, mut outputs, left) = setup();
let first = set.active_id();
set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
reconcile(&mut set, &mut outputs);
let right = OutputId(2);
let second = set.create_workspace();
outputs
.connect(right, OutputGeometry::new(1000, 0, 1000, 800), second)
.unwrap();
outputs.focus_output(right).unwrap();
reconcile(&mut set, &mut outputs);
set.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
reconcile(&mut set, &mut outputs);
assert_eq!(outputs.workspace_at(&set, left, 1), Some(first));
assert_eq!(outputs.workspace_at(&set, right, 1), Some(second));
assert_eq!(
outputs.workspace_views(&set).iter().filter(|view| view.visible).count(),
2
);
assert_eq!(
outputs.workspace_views(&set).iter().filter(|view| view.focused).count(),
1
);
outputs.disconnect(right).unwrap();
reconcile(&mut set, &mut outputs);
let fallback = set.create_workspace();
outputs
.connect(right, OutputGeometry::new(1000, 0, 1000, 800), fallback)
.unwrap();
reconcile(&mut set, &mut outputs);
assert_eq!(outputs.output_for_workspace(first), Some(left));
assert_eq!(outputs.output_for_workspace(second), Some(right));
assert!(set.workspace(fallback).is_none());
assert_eq!(outputs.workspace_at(&set, right, 1), Some(second));
}
}
#[cfg(test)]
mod sequences {
use ferese_layout::{Axis, WindowId};
use super::*;
use crate::OutputGeometry;
#[test]
fn repeated_switch_move_close_and_hotplug_keep_workspace_invariants() {
let mut set = WorkspaceSet::default();
let mut outputs = OutputWorkspaceMap::default();
outputs
.connect(OutputId(1), OutputGeometry::new(0, 0, 1000, 800), set.active_id())
.unwrap();
let mut next_window = 1;
let mut seed = 0x1234_5678_u64;
for step in 0..500 {
outputs.reconcile_workspaces(&mut set, &HashSet::new());
let output = outputs.focused_output().unwrap();
let windows = set
.iter()
.flat_map(|workspace| workspace.layout.window_ids())
.collect::<Vec<_>>();
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
match seed % 5 {
0 => {
set.insert_window(WindowId(next_window), Axis::Horizontal, 0.5).unwrap();
next_window += 1;
}
1 if !windows.is_empty() => {
set.remove_window(windows[seed as usize % windows.len()]).unwrap();
}
2 => {
let index = (seed % 9 + 1) as u32;
let target = outputs.workspace_at(&set, output, index).unwrap();
outputs.switch_workspace(output, target).unwrap();
}
3 if !windows.is_empty() => {
let target = outputs.workspace_at(&set, output, u32::MAX).unwrap();
set.move_window_to_workspace(windows[seed as usize % windows.len()], target, Axis::Horizontal, 0.5)
.unwrap();
}
_ => {
if outputs.active_workspace(OutputId(2)).is_some() {
outputs.disconnect(OutputId(2)).unwrap();
} else {
let fallback = set.create_workspace();
outputs
.connect(OutputId(2), OutputGeometry::new(1000, 0, 1000, 800), fallback)
.unwrap();
outputs.focus_output(OutputId(2)).unwrap();
}
}
}
outputs.reconcile_workspaces(&mut set, &HashSet::new());
assert!(set.validate().is_ok(), "step {step}");
let views = outputs.workspace_views(&set);
assert_eq!(views.iter().filter(|view| view.focused).count(), 1);
assert_eq!(views.len(), set.iter().count());
for output in outputs.connected_outputs() {
let rows = views.iter().filter(|view| view.output == output).collect::<Vec<_>>();
assert_eq!(rows.last().unwrap().window_count, 0);
for (index, row) in rows.iter().enumerate() {
assert_eq!(row.index, index as u32 + 1);
if row.window_count == 0 && index + 1 != rows.len() {
assert!(row.visible, "abandoned empty workspace at step {step}");
}
}
if let Some(previous) = outputs.previous_workspace(output) {
assert!(rows.iter().any(|row| row.id == previous));
}
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
/// Radius of a concentric inset, in the same units as the outer radius.
pub fn inner_radius(outer: f32, padding: f32) -> f32 {
(outer - padding).max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inset_corners_preserve_the_center_and_saturate() {
assert_eq!(inner_radius(14.0, 4.0), 10.0);
assert_eq!(inner_radius(7.5, 2.25), 5.25);
assert_eq!(inner_radius(14.0, 0.0), 14.0);
assert_eq!(inner_radius(14.0, 14.0), 0.0);
assert_eq!(inner_radius(14.0, 20.0), 0.0);
assert_eq!(inner_radius(0.0, 4.0), 0.0);
}
}
+2
View File
@@ -4,6 +4,7 @@ pub mod calendar;
mod contrast; mod contrast;
pub mod controls; pub mod controls;
pub mod gallery; pub mod gallery;
mod geometry;
pub mod icons; pub mod icons;
pub mod material; pub mod material;
pub mod menus; pub mod menus;
@@ -14,6 +15,7 @@ mod typography;
pub use button::accent_button; pub use button::accent_button;
pub use contrast::{accent_color, accent_pair, apply, composite, contrast, foreground, luminance}; pub use contrast::{accent_color, accent_pair, apply, composite, contrast, foreground, luminance};
pub use geometry::inner_radius;
pub use palette::{Palette, mix, parse_color, surface_shade}; pub use palette::{Palette, mix, parse_color, surface_shade};
pub use presets::{PRESETS, Preset}; pub use presets::{PRESETS, Preset};
pub use typography::{font, text}; pub use typography::{font, text};
+8 -3
View File
@@ -78,20 +78,25 @@ pub fn separator<'a, M: 'a>(foreground: Color, opacity: f32) -> Element<'a, M> {
pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -> Element<'a, M> { pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -> Element<'a, M> {
let (track, thumb) = crate::controls::switch_colors(palette, enabled, false); let (track, thumb) = crate::controls::switch_colors(palette, enabled, false);
let track_radius = 10.;
let padding = 2.;
let knob = container(cosmic::widget::Space::new().width(16).height(16)) let knob = container(cosmic::widget::Space::new().width(16).height(16))
.width(16) .width(16)
.height(16) .height(16)
.class(crate::controls::surface(Color { a: opacity, ..thumb }, 8.)); .class(crate::controls::surface(
Color { a: opacity, ..thumb },
crate::inner_radius(track_radius, padding),
));
container(knob) container(knob)
.width(36) .width(36)
.height(20) .height(20)
.padding(2) .padding(padding)
.align_x(if enabled { .align_x(if enabled {
cosmic::iced::alignment::Horizontal::Right cosmic::iced::alignment::Horizontal::Right
} else { } else {
cosmic::iced::alignment::Horizontal::Left cosmic::iced::alignment::Horizontal::Left
}) })
.class(crate::controls::surface(track.scale_alpha(opacity), 10.)) .class(crate::controls::surface(track.scale_alpha(opacity), track_radius))
.into() .into()
} }
-6
View File
@@ -12,8 +12,6 @@
- [Shortcuts and gestures](shortcuts.md): keyboard navigation, Super-drag, resize, and window grouping. - [Shortcuts and gestures](shortcuts.md): keyboard navigation, Super-drag, resize, and window grouping.
- [Desktop widgets](desktop-widgets.md): clocks and sticky notes.
- [Notifications](notifications.md): popups, history, and Do Not Disturb.
- [Lock screen](locking.md): appearance, automatic locking, and display sleep. - [Lock screen](locking.md): appearance, automatic locking, and display sleep.
- [Screen sharing and recording](screen-sharing.md): choosing sources and stopping a session. - [Screen sharing and recording](screen-sharing.md): choosing sources and stopping a session.
- [Desktop portals](portals.md): application integration and supported services. - [Desktop portals](portals.md): application integration and supported services.
@@ -21,10 +19,6 @@
## Develop Ferese ## Develop Ferese
- [Development](development.md): builds, tests, and portal checks. - [Development](development.md): builds, tests, and portal checks.
- [Compositor architecture](ferese-window-manager-spec.md).
- [Shell behavior](ferese-shell-design.md).
- [Theme components](ferese-theme-ui-spec.md).
- [Animation checks](animation-validation.md).
- [Nested resize checks](nested-resize-testing.md). - [Nested resize checks](nested-resize-testing.md).
- [Client soak testing](native-soak-testing.md). - [Client soak testing](native-soak-testing.md).
- [Performance measurements](performance-baseline.md). - [Performance measurements](performance-baseline.md).
-26
View File
@@ -1,26 +0,0 @@
# Animation regression checks
Run `cargo test -p ferese-animation -p ferese --offline` for transition,
resize requests, reduced motion, stacking, and refresh-rate regression tests.
For visual validation, use a disposable Ferese session with three tiled windows
containing text and at least one floating window. Test both the nested and DRM
backends. Record the session at its native refresh rate when comparing builds;
single screenshots cannot establish smoothness.
| Scenario | Expected result |
| --- | --- |
| Super+F on the middle column, then exit | Neighbours remain behind the expanding window. The shrinking window stays above them until the transition finishes. |
| Toggle Super+F repeatedly at 40–80 ms intervals | Every reversal starts at the presented frame. Corners, border and shadow follow the same zoom progress. The final state matches the last input. |
| Cycle width, then immediately Super+F | The old column-width spring cannot override maximized width. |
| Exit maximize or fullscreen and immediately resize or scroll | The current frame is retained when the target changes; scrolling resumes without a position jump. |
| Drag a floating window; resize from its top-left corner | The window follows the pointer directly. The opposite edge stays anchored. Delayed client commits do not move or raise it. |
| Zoom responsive and slow-redrawing clients | Each zoom requests the destination size once. Client redraws do not generate intermediate resize requests or restart the motion. |
| Zoom the leftmost and rightmost columns in and out | Each outer edge moves monotonically toward its destination; neither side trembles or reverses near the screen edge. |
| Disable borders and shadows, then zoom | Rounded corner pixels repaint through the final frame; no corner remnants remain. |
| Repeat on 60 Hz and 144 Hz outputs; move focus between them | Similar elapsed-time motion with no double advancement. Check frame timing on actual DRM hardware. |
| Enable reduced motion | Geometry and decorations reach their final values together. |
Zoom requests the destination client size once and scales the available buffer
along the transition. This avoids repeated content reflow during motion. It is
not a buffer snapshot or a guarantee of perfectly sharp intermediate text.
+36 -21
View File
@@ -6,8 +6,7 @@ which includes app and media shortcuts, appearance, widgets, and optional startu
and display settings. Its browser/editor shortcuts need Firefox/Zed; brightness and display settings. Its browser/editor shortcuts need Firefox/Zed; brightness
keys need `brightnessctl` and audio keys need `wpctl`. keys need `brightnessctl` and audio keys need `wpctl`.
This reference covers layouts, appearance, input, and desktop behavior. This reference covers layouts, appearance, input, and desktop behavior.
See [Desktop widgets](desktop-widgets.md), [Notifications](notifications.md), See [Desktop portals](portals.md) for application integration options.
and [Desktop portals](portals.md) for their additional options.
## KDL syntax ## KDL syntax
@@ -135,8 +134,7 @@ Choose Light, Dark, or Auto in Settings or Control Center. Auto follows local
07:00 and 19:00 boundaries unless you change its schedule. 07:00 and 19:00 boundaries unless you change its schedule.
Selections store preset names. Partial KDL files can override shared colors or Selections store preset names. Partial KDL files can override shared colors or
one appearance. See [Themes](theme-engine.md) for files, accessibility options, one appearance.
and live previews.
| Section / key | Type | Default | Meaning | | Section / key | Type | Default | Meaning |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -145,7 +143,7 @@ and live previews.
| `theme.split` | boolean | `false` | Use separate light/dark families; retains both selections when disabled | | `theme.split` | boolean | `false` | Use separate light/dark families; retains both selections when disabled |
| `theme.light.family`, `theme.dark.family` | family id | `"ferese-blue"` | Stored selections used when split is enabled | | `theme.light.family`, `theme.dark.family` | family id | `"ferese-blue"` | Stored selections used when split is enabled |
| `theme.custom-themes.<id>.file` | path | unset | Imported KDL family with explicit light and/or dark sections | | `theme.custom-themes.<id>.file` | path | unset | Imported KDL family with explicit light and/or dark sections |
| `theme.light.preset` | preset name | `"ferese-blue-light"` | Light palette; see the [preset pairs](theme-engine.md#preset-pairs) | | `theme.light.preset` | preset name | `"ferese-blue-light"` | Light palette |
| `theme.dark.preset` | preset name | `"ferese-blue"` | Dark palette | | `theme.dark.preset` | preset name | `"ferese-blue"` | Dark palette |
| `theme.file` | path | unset | Shared partial KDL theme | | `theme.file` | path | unset | Shared partial KDL theme |
| `theme.light.file`, `theme.dark.file` | path | unset | Partial KDL theme for one appearance | | `theme.light.file`, `theme.dark.file` | path | unset | Partial KDL theme for one appearance |
@@ -183,7 +181,7 @@ Filled controls choose a contrasting text color automatically.
| `border-width` | number ≥ 0 | `1` | Window border thickness | | `border-width` | number ≥ 0 | `1` | Window border thickness |
| `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness | | `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness |
| `window-radius` | number ≥ 0 | `14` | Managed-window corners, independent of the shell | | `window-radius` | number ≥ 0 | `14` | Managed-window corners, independent of the shell |
| `shell-radius` | number ≥ 0 | `14` | All shell surfaces, cards, widgets and interaction backgrounds; 0 makes them square | | `shell-radius` | number ≥ 0 | `14` | All shell surfaces, cards, widgets and interaction backgrounds; fractional radii are preserved; 0 makes them square |
| `top-bar-height` | number > 0 | `28` | Menu-bar height | | `top-bar-height` | number > 0 | `28` | Menu-bar height |
| `top-bar-margin-top` | integer ≥ 0 | `0` | Space above bar | | `top-bar-margin-top` | integer ≥ 0 | `0` | Space above bar |
| `top-bar-window-gap` | integer ≥ 0 | `0` | Clearance below bar | | `top-bar-window-gap` | integer ≥ 0 | `0` | Clearance below bar |
@@ -223,18 +221,20 @@ theme {
## Workspaces ## Workspaces
A single-output session starts with workspace 1. Numbered workspace shortcuts create Each monitor has an independent list of workspaces, numbered from 1. Its bar,
workspaces on demand; switching forward past the last workspace also creates the overview strip, and numeric shortcuts use that same order. Numbers change when
next one when the current workspace contains windows. Empty workspaces are empty workspaces are removed; internal workspace IDs stay stable.
cleaned up automatically, keeping at most one spare empty workspace in addition to
visible workspaces and each monitor's previous workspace. Additional displays
receive their own workspace.
`Super+Escape` returns to the previous workspace on the focused monitor. Repeat One empty workspace is always available at the end of each monitor's list.
it to switch between the two most recently visited workspaces. Switching to a Opening or moving a window there creates another spare. An empty workspace stays
workspace on another monitor updates that monitor's history. History is cleared while displayed or participating in a swipe, then disappears after you leave it.
for a disconnected monitor; it does not jump to a workspace reclaimed by another Selecting a number beyond the current list selects the trailing empty workspace.
monitor on reconnect. With no previous workspace, the action does nothing. Window-rule workspace numbers also refer to positions on the window's monitor.
`Super+Escape` returns to the previous surviving workspace on the focused monitor.
History does not keep abandoned empty workspaces alive. With no previous workspace,
the action does nothing. Disconnecting a monitor clears its history; occupied
workspaces move to another monitor and can return when it reconnects.
Enable **Settings → Windows → Toggle back with the same workspace shortcut**, or: Enable **Settings → Windows → Toggle back with the same workspace shortcut**, or:
@@ -250,6 +250,22 @@ commands and overview selection still select the requested workspace directly.
Use `feresectl workspace-back-and-forth` for an explicit toggle, or bind Use `feresectl workspace-back-and-forth` for an explicit toggle, or bind
`workspace-back-and-forth` to another key or gesture. The action takes no argument. `workspace-back-and-forth` to another key or gesture. The action takes no argument.
`Super+BackSpace` focuses the last focused window, including windows on other
workspaces or monitors. Repeating it toggles between the two most recently
focused windows. Layer surfaces such as launchers do not replace this history.
`Alt+Tab` previews windows in focus order using overview. Keep Alt held and press
Tab to advance, or Shift+Tab to go backward. Release Alt to focus the selection;
Escape cancels without changing focus. The order stays fixed until selection is
committed. Closed windows are skipped and newly opened windows join the next
cycle. Preview transitions follow the configured animation speed and reduced
motion settings.
Bind `focus-last-window`, `focus-mru-next`, or `focus-mru-previous` to customize
these shortcuts. MRU bindings with Alt held preview until Alt is released;
bindings without Alt, gestures, and the corresponding `feresectl` commands
switch immediately. All three actions take no arguments.
## Input ## Input
| `input` key | Type | Default | Meaning | | `input` key | Type | Default | Meaning |
@@ -331,6 +347,8 @@ invalid. A disabled binding must omit `action` and `argument`.
| `workspace`, `move-to-workspace` | Workspace number string, 1–255 | | `workspace`, `move-to-workspace` | Workspace number string, 1–255 |
| `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor | | `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor |
| `workspace-back-and-forth` | None; return to the previous workspace on this monitor | | `workspace-back-and-forth` | None; return to the previous workspace on this monitor |
| `focus-last-window` | None; return to the last focused window |
| `focus-mru-next`, `focus-mru-previous` | None; cycle windows in focus order |
| `none` | None; ignore this trigger | | `none` | None; ignore this trigger |
| `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None | | `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None |
@@ -347,6 +365,7 @@ Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center;
Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen; Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen;
Super+M layout; Super+Shift+Space floating; Super+Tab overview; Super+M layout; Super+Shift+Space floating; Super+Tab overview;
Super+Escape previous visited workspace; Super+Escape previous visited workspace;
Super+BackSpace last focused window; Alt+Tab/Alt+Shift+Tab MRU switching;
Super+Shift+S area screenshot; Print Screen whole-screen screenshot; Super+Shift+S area screenshot; Print Screen whole-screen screenshot;
Super+Shift+E logout confirmation. Super+Shift+E logout confirmation.
@@ -548,7 +567,3 @@ override shell rounding.
Power actions use a centered confirmation. **Super+Shift+E** and Power actions use a centered confirmation. **Super+Shift+E** and
`feresectl request-logout` ask to log out. `feresectl exit` ends the session `feresectl request-logout` ask to log out. `feresectl exit` ends the session
immediately. immediately.
## Notifications
See [Notifications](notifications.md) for popups, Do Not Disturb, and timeout options.
-118
View File
@@ -1,118 +0,0 @@
# Desktop widgets
Clock and sticky-note widgets sit above the wallpaper and behind windows.
Drag the clock or a note's title to move it. Release to save, or press Escape to
cancel. Widgets stop at each other's edges on the same display.
Enable it in Settings → Desktop widgets or in your Ferese config:
```kdl
desktop-widgets {
clock enabled=#true anchor="top_left" margin-x=64 margin-y=80 {
style "pixel" // or "minimal"
outputs // every output; or outputs "DP-2"
width 440
height 320
font-family "" // style default; or "Comfortaa"
bold #false
time-size 128.0
date-size 18.0
time-format "%-I:%M %p" // use "%H:%M" for 24-hour time
date-format "%a, %b %-d"
time-zone "" // system zone; or "Africa/Lagos"
show-date #true
lowercase #true
color "" // theme primary text
date-color "" // theme muted text
opacity 0.9
alignment "center" // left, center, right
gap 4.0
padding 12.0
background "" // transparent; or "#10101080"
radius 16.0
}
}
```
Positions: `top_left`, `top_center`, `top_right`, `center_left`, `center`,
`center_right`, `bottom_left`, `bottom_center`, `bottom_right`.
Margins affect anchored axes only: a centered horizontal position ignores
`margin-x`; a centered vertical position ignores `margin-y`.
All geometry and text sizes use logical pixels and follow the output's scale.
Increase the widget width/height for longer formats or larger text.
Choose **Pixel** for stacked hours and minutes with alternating theme colors,
or **Minimal** for a plain time/date layout. Pixel zero-pads hours and hides am/pm.
Formats containing seconds or extra text use a single line. Both styles shrink
time text to fit. Select a different font in Settings, or set `font-family`.
Changes apply live. Colors and fonts follow the theme unless overridden.
Formatting uses [strftime-compatible formats](https://docs.rs/jiff/latest/jiff/fmt/strtime/index.html).
Use `%H:%M` for 24-hour time or `%-I:%M %p` for 12-hour time.
## Clock limits
Clock and note corners follow the shared shell radius. Their `radius` fields
are accepted for compatibility but do not override it.
All clock keys are optional. The example enables the clock (disabled by default);
the other values illustrate defaults or their empty-string equivalents.
`outputs` is an array of strings; `enabled`, `bold`, `show-date`, `lowercase` are
booleans; `width`, `height`, and margins are integers. Other sizes/opacity are numbers.
| Fields | Accepted range |
| --- | --- |
| `width`, `height` | 64–1600, 32–800 |
| `margin-x`, `margin-y` | 0–8192 |
| `time-size`, `date-size` | 8–240, 8–96 |
| `opacity` | 0–1 |
| `gap`, `padding`, `radius` | 0–64, 0–64, 0–128 |
| `font-family`, `time-format`, `date-format` | At most 128 bytes each |
| `outputs` | At most 32 exact output names, each 1–128 bytes |
Formats must be valid; `time-zone` must be empty or a recognized IANA name.
Colors are empty (inherit), `#RRGGBB` or `#RRGGBBAA`; all numeric values must be finite.
## Sticky notes
Settings → Desktop widgets → Add note creates a card. Drag its title to move it;
click Edit for its multiline editor, then Done to finish. Text autosaves after
500 ms without typing. Escape cancels a drag or finishes editing. Position saves
on release; dragging changes the anchor to `top_left` and saves pixel margins.
Title, size and style are also editable in Settings. Notes persist in the config.
Choose a note’s font in Settings or set `font-family "Comfortaa"` in its `note`
node. Without an override, notes use the interface font.
These are plain-text notes, not Markdown or HTML.
```kdl
desktop-widgets {
note id="today" title="Today" text="Review the pull request\nTake a break\n" anchor="top_right" margin-x=48 margin-y=80
}
```
Repeat the `note` node for additional notes; IDs must be unique. Cards stay behind windows
and take keyboard focus only when clicked. Set `interactive #false` for a fully
click-through card. Long text wraps; increase height if needed.
| Key | Type | Default / accepted values |
| --- | --- | --- |
| `id` | string | `"note"`; unique, 1–64 ASCII letters/digits/`-`/`_` |
| `enabled` | boolean | `true` |
| `interactive` | boolean | `true`; drag/edit controls; `false` is click-through |
| `title` | string | `"Note"`; empty hides it; maximum 256 bytes |
| `text` | string | `""`; multiline plain text, maximum 16 KiB |
| `outputs` | string array | `[]` means all; at most 32 exact names, each 1–128 bytes |
| `anchor` | string | `"top_right"`; same nine positions as clock |
| `margin-x`, `margin-y` | integer | `48`, `80`; 0–8192; anchored edges only |
| `width`, `height` | integer | `320`, `240`; 120–1200, 80–1200 |
| `font-family` | string | omitted/empty follows desktop font; maximum 128 bytes |
| `text-size`, `title-size` | number | `16`, `18`; 8–96 |
| `color` | string | omitted/empty/`"theme"` follows primary text; or hex color |
| `background` | string | omitted/`"theme"` follows surface base; empty is transparent; or hex color |
| `opacity` | number | `0.9`; 0–1, multiplies text/background alpha |
| `padding`, `gap` | number | `20`, `8`; 0–64 |
| `radius` | number | `16`; 0–128 |
| `alignment` | string | `"left"`; `left`, `center`, `right` |
At most 32 notes; the entire live config is limited to 60 KiB.
-4
View File
@@ -14,14 +14,10 @@ Keep another desktop available for direct-session recovery.
## Test guides ## Test guides
- [Animation checks](animation-validation.md): transitions, interruption, and hit testing.
- [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness. - [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness.
- [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth. - [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth.
- [Performance measurements](performance-baseline.md): sampling and historical results. - [Performance measurements](performance-baseline.md): sampling and historical results.
The [compositor](ferese-window-manager-spec.md), [shell](ferese-shell-design.md),
and [theme](ferese-theme-ui-spec.md) references describe the main code boundaries.
## Portal checks ## Portal checks
Run backend unit tests and compare D-Bus contracts against installed portal XML: Run backend unit tests and compare D-Bus contracts against installed portal XML:
-65
View File
@@ -1,65 +0,0 @@
# Shell behavior
This is a maintainer reference. User options are documented in
[Configuration](configuration.md). Common UI belongs in
[ferese-theme](../crates/ferese-theme/README.md).
## Bar and popovers
The shell creates a bar for each output. Workspace buttons use compositor state
and appear in numeric order. The active window title occupies the center when
space allows. Date and time form one calendar button. Right-side controls open
sound, battery, notifications, recording, and Control Center popovers.
A popover is anchored to its control and constrained to the same output.
Opening another primary popover replaces the current one. Popovers reserve no
workspace space; the bar reserves its configured height and margins.
True fullscreen hides the bar without changing the preserved tiled layout.
Use real service availability for controls. Handle missing services and failed
actions without blocking the UI. Settings opens as a separate application.
## Overview
The compositor presents window previews; the shell supplies labels and workspace
controls. Do not continuously copy windows into the shell process.
Clicking a workspace background selects that workspace and keeps overview open
so its windows can be selected. Clicking a window activates that exact window.
Directional keys change selection, Enter activates it, and Escape dismisses.
Activation must use the selected window ID, not the window focused before overview.
## Focus and dialogs
The bar and notification popups do not normally take keyboard focus. Interactive
shell surfaces restore the previous valid app focus when dismissed.
Power actions and logout use a centered system confirmation, including blockers
when present. Input outside the confirmation must not reach applications.
Authentication, screen-sharing, and recording dialogs are separate themed windows.
Use one visible frame, content-sized height, and an output-sized height limit.
Scrollable lists must leave headings and action buttons accessible.
Attach dialog material to the content card using the shared theme helper.
Match blur regions to the displayed card throughout opening and resize; do not
paint an opaque window behind a translucent card.
## Notifications and widgets
The shell provides the standard notification service. Normal popups respect
Do Not Disturb; critical messages bypass it. History is held in memory and clears
when the shell exits. See [Notifications](notifications.md).
Clock and note widgets sit behind application windows, reserve no workspace space,
and save positions after dragging. Only interactive notes take keyboard focus.
See [Desktop widgets](desktop-widgets.md).
## Lifecycle
Shell failure must not destroy managed windows or workspace state. Reconstruct
bars and controls from compositor state after reconnecting, and clear dead shell
focus. Notification history is not reconstructed from disk.
Keep slow service queries, image loading, and filesystem work away from UI event
handling. Stop animation work once the UI settles. Validate popover placement,
fullscreen transitions, dialog dismissal, and shell restart in a nested session.
-83
View File
@@ -1,83 +0,0 @@
# Theme components
Ferese's native apps use [ferese-theme](../crates/ferese-theme/README.md) for
colors, fonts, controls, menus, icons, calendar elements, and dialog materials.
Use [Configuration → Appearance](configuration.md#appearance) for supported keys
and defaults.
## Shared appearance
The six theme families are Ferese Blue, Catppuccin, Gruvbox, Rosé Pine, Tokyo
Night, and Everforest. Each has a paired light and dark palette. Ferese Blue uses the
logo accent, `#3D7BE6`. Presets supply colors and materials; explicit wallpaper,
typography, geometry, and material overrides remain in place.
The interface font applies to shell text and native dialogs. Clock and note
widgets can override it. Managed-window corners and shell corners are separate
settings; small controls cap their corners to fit their size.
## Materials
Two material styles are supported:
| Style | Background |
| --- | --- |
| `solid` | Opaque theme surface |
| `translucent` | Theme tint over blurred background content |
Bars, popovers, notifications, and themed dialogs share `theme.material.opacity`
and `blur-radius`. Text and icons stay opaque. An opacity of zero hides the
material; blur radius zero disables blur. Solid mode stays opaque.
```kdl
theme {
colors {
accent "#3D7BE6"
}
material {
style "translucent"
opacity 0.78
blur-radius 12
}
geometry {
shell-radius 14
window-radius 14
}
}
```
The compositor owns blur and tint. `material::ModalMaterial` attaches native
dialogs to that rendering path. Keep the attachment alive with the window and
make the attached client background transparent. Outside Ferese, retain a client
background so the dialog remains usable.
## Components
| Module | Use |
| --- | --- |
| `palette` | Resolve configuration colors and build the native toolkit theme |
| `controls` | Surfaces, navigation, text buttons, and input styles |
| `menus` | Rows, headings, sections, separators, switches, and sliders |
| `gallery` | Split theme previews, selected badges and accessible radio navigation |
| `calendar` | Date/time labels, clock digits, and calendar grids |
| `icons` | Shared SVG assets, sizing, and tint |
| `typography` | Shared font selection and text |
| `contrast` | Readable labels on filled controls |
| `material` | Attach modal cards to compositor materials |
Applications provide data, layout, actions, and animation progress. Extend shared
components when a style is needed in multiple apps. Avoid hardcoded app-specific
fonts, foreground colors, or opacity values.
## UI checks
- Check text and icons in all twelve light and dark variants.
- Check rest, hover, pressed, focused, selected, and disabled controls.
- Keep accent-button labels readable after typing or changing focus.
- Keep controls usable with solid materials and blur disabled.
- Align material regions with the visible card at every output scale.
- Give dialogs one frame; cap their height and scroll their content, preserving actions.
- Respect reduced motion and stop requesting animation frames after settling.
Theme changes affect appearance; they do not change focus, workspace ownership,
input permissions, or authentication policy.
-109
View File
@@ -1,109 +0,0 @@
# Compositor architecture
This is a maintainer reference. For supported configuration and shortcuts, use
[Configuration](configuration.md); for builds and checks, use [Development](development.md).
## Processes and crates
| Component | Responsibility |
| --- | --- |
| `ferese` | Wayland clients, windows, workspaces, input, outputs, and rendering |
| `ferese-shell` | Bar, popovers, notifications, widgets, and system confirmations |
| `ferese-settings` | Configuration editor |
| `feresectl` | Command-line control |
| `ferese-lock` | Lock-screen UI and PAM authentication |
| `ferese-polkit-agent` | System authentication prompts |
| `xdg-desktop-portal-ferese` | Desktop portal requests and PipeWire streams |
| `ferese-record` | Video encoding and saving |
The compositor uses Smithay and calloop. The direct backend renders through
DRM/KMS; the nested backend renders into a window on another Wayland desktop.
Native UIs use libcosmic. Layout, animation, configuration, IPC, protocols, and
[theme components](../crates/ferese-theme/README.md) live in separate crates.
## State and ownership
The event loop owns compositor state and Wayland objects. Background workers
return results through bounded channels; results must be checked against current
window, output, and request identities before application.
Keep these relationships valid after a state change:
- Each window belongs to one workspace and appears once in its layout or floating list.
- Each attached workspace belongs to one output; each output has one active workspace.
- A window's output follows workspace ownership.
- Fullscreen preserves the underlying layout or floating placement.
- Focus points to a live window or authorized shell surface.
- Workspace focus history is distinct from current keyboard focus.
Monitor removal migrates workspaces to a remaining output. Returning monitors
restore eligible workspaces; explicit user moves take priority. Selecting a
workspace visible on another monitor focuses that monitor.
## Layout and presentation
Scrolling layouts arrange columns horizontally. Windows within a column divide
its height. A workspace owns its viewport; presentation subtracts that viewport
from window positions. Tree layouts use nested splits and stacks. Layout changes
preserve window membership and floating placement.
Keep layout targets, animated rectangles, and committed client sizes separate.
A layout transition requests the destination client size rather than sending
one configure per animation frame. Available content is scaled and clipped until
the matching client buffer arrives. Hit testing uses the same presented geometry.
Interrupted animations continue from their current position.
Input and layout use logical pixels. Rendering converts them using the output's
scale and transform. Application popups use their parent's output and presented
position when calculating bounds.
## Rendering and frame delivery
Render damage from client commits, animations, effects, cursor motion, and output
changes. Movement damages both old and new bounds, including shadows and blur.
Output-specific callbacks follow window ownership. Presentation feedback is
collected only for surfaces the renderer actually displayed on that output.
An unchanged frame still permits paced client callbacks without submitting a
new display buffer.
Animations use elapsed time across outputs, with a timer while transitions remain
active. An idle focused monitor must not stall another monitor's animation.
The compositor renders blur from content behind the requesting surface. Material
regions and client content must share coordinates during placement, scaling,
and animation. See [Theme components](ferese-theme-ui-spec.md).
## Input
Use milliseconds for keyboard, pointer, touch, and gesture protocol events;
relative-motion timestamps retain microseconds. Keep keyboard focus, pointer
focus, and window stacking separate. Hover focus does not scroll or raise windows.
Pointer grabs and constraints must end cleanly when their surface disappears.
A locked pointer's position hint is stored while locked, then resolved against
current surface geometry and clamped to an output when the lock is released.
## IPC and private protocols
User control uses `$XDG_RUNTIME_DIR/ferese/control.sock`, restricted to the
session user. The parent directory is private and peers are checked by UID.
IPC frames are length-prefixed JSON with bounded message sizes, workers, and queues.
See `feresectl --help` for commands.
Private Wayland capabilities grant shell control or material effects to trusted
components. Public layer-shell access alone grants neither. Trusted connections
and descriptors must not leak to launched applications. Capture paths check
session policy and reject capture while locked.
Session locking must protect every output, including hotplug, and remain secure
if the lock UI crashes. [Portal integration](portals.md) documents application
consent and the currently supported services.
## Review and testing
Check output ownership, presented geometry, focus restoration, request lifetimes,
and bounded work whenever changing a protocol or rendering path. Test both
backends; a nested result does not establish hardware behavior.
Use the [animation](animation-validation.md), [resize](nested-resize-testing.md),
and [soak](native-soak-testing.md) checks alongside the workspace tests.
-47
View File
@@ -1,47 +0,0 @@
# Notifications
Ferese shows compact notification popups and a history panel opened from the bar.
Their colors, font, corners, and background follow the shell theme.
## Popups and history
Up to three popups are visible at once. Hovering pauses a popup's timer.
History keeps the latest 100 notifications and clears when the shell exits.
App action buttons work when provided by the sender. Transient messages leave
history when closed.
Critical notifications bypass Do Not Disturb and remain visible unless the app
provides a timeout. Other apps can also request a timeout or no timeout.
## Settings
**Settings → Notifications** controls popup visibility, the default timeout, and
the Do Not Disturb preference. The bar toggle changes Do Not Disturb for the
current session; Settings saves it for future sessions. Hidden popups still enter
history.
```kdl
notifications {
show-popups #true
do-not-disturb #false
timeout-ms 6000
}
```
The default timeout accepts 1000–30000 milliseconds.
## Send a test notification
```sh
notify-send --app-name=Ferese --icon=dialog-information "Hello" "Your desktop notification"
```
## Troubleshooting
Only one notification server can run on the session bus. The session launcher
stops `swaync.service` before starting the shell. When launching the shell manually,
stop any existing notification server first. If another server owns the interface,
Ferese logs that native notifications are unavailable.
Ferese supports plain text, app icons, actions, replacement, and urgency. Rich
markup, embedded images, sounds, and saved notification history are not supported.
+21
View File
@@ -21,6 +21,27 @@ frames. Keep build work and unrelated activity outside the sampling interval.
CPU percentages below use one core as 100%. Short samples are observations, CPU percentages below use one core as 100%. Short samples are observations,
not guarantees or hardware-session benchmarks. not guarantees or hardware-session benchmarks.
## Direct frame timing
The DRM backend schedules each output against its own predicted presentation
time. Render cost and a safety margin determine when compositing starts. Idle
outputs wake on demand; callback-only updates are paced to the output refresh
cycle without submitting unchanged buffers. Animation forecasts are restored
before processing input or rendering another output.
For a direct session, enable `FERESE_TRACE_PERFORMANCE=1` and
`RUST_LOG=ferese::render=debug` before starting Ferese. The timing records include
the planned render start, actual render start, timer lateness, presentation
target, DRM presentation timestamp, and request-to-presentation duration.
Missed deadlines compare submitted frames with their targets; idle gaps do not
count. Request-to-presentation starts at the compositor's redraw request, so it
does not include device input latency, client rendering before the request, or
the display's pixel response. Render cost measures CPU preparation and queueing;
asynchronous GPU work is covered conservatively by feedback-driven margins.
Check sustained animation, idle wakeup, callback-only clients, and mixed-refresh
outputs separately. Nested sessions do not exercise the DRM scheduler.
## Historical results ## Historical results
On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale, On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale,
-150
View File
@@ -1,150 +0,0 @@
# Themes
The compositor resolves theme files and publishes a versioned snapshot. Native
apps consume that snapshot; Settings edits references and requests isolated previews.
## Preset pairs
| Family | Dark preset | Light preset |
| --- | --- | --- |
| Ferese Blue (default) | `ferese-blue` | `ferese-blue-light` |
| Catppuccin | `catppuccin-mocha` | `catppuccin-latte` |
| Gruvbox | `gruvbox` | `gruvbox-light` |
| Rosé Pine | `rose-pine-moon` | `rose-pine-dawn` |
| Tokyo Night | `tokyo-night` | `tokyo-night-day` |
| Everforest | `everforest-dark` | `everforest-light` |
Each variant supplies its own surfaces, tint strength, opacity, blur, borders,
and shadows. Shared material overrides apply to both appearances; appearance
specific overrides take precedence. [Palette sources and adjustments](theme-palette-sources.md)
record the upstream colors and contrast tuning.
## Configuration
```kdl
theme {
mode "auto"
family "catppuccin"
split #false
file "themes/custom_theme.kdl"
light {
preset "ferese-blue-light"
file "themes/custom_theme_light.kdl"
material { opacity 0.94; tint-strength 1; }
}
dark { preset "ferese-blue"; file "themes/custom_theme_dark.kdl"; }
schedule { source "schedule"; timezone "system"; light-at "07:00"; dark-at "19:00"; }
accessibility { increase-contrast #false; reduce-transparency #false; }
accent "#3D7BE6"
}
```
Paths are relative to the configuration directory. Partial theme files contain
`theme { colors { accent "#3D7BE6"; } }`, or the token sections directly.
Merge order is preset, shared file, appearance file, shared inline tokens,
appearance inline tokens, then accent and accessibility adjustments. Unknown tokens warn; invalid known values reject
an entire update and retain the last working snapshot.
## Snapshot
`ResolvedTheme` contains effective appearance and typed colors, surfaces, material,
geometry, typography, wallpaper, border paints, and shadow tokens. Base, raised,
and application backgrounds are separate colors. Selected mode,
accessibility inputs, requested accent, adjusted accent, warnings, schema version,
and revision accompany it. Consumers do not infer appearance from brightness.
Material tokens differ by appearance. Light translucent surfaces use full tint strength and
0.94 default opacity to stay pale over dark wallpapers; dark surfaces use a softer
0.5 tint. Shared material overrides apply to both unless the appearance overrides
them. Reduce Transparency resolves to solid
material with zero blur. Increase Contrast adjusts text, borders, and translucent
surface tint for 7:1 text contrast, and enables native high-contrast controls.
Accents are adjusted against surfaces independently for each appearance, then
on-accent text is selected for readability.
## Scheduling
Auto selects the most recent light or dark boundary in the configured timezone.
The interval is circular, so light may start after dark. Equal boundaries are
invalid. Repeated hours use their first occurrence; skipped hours shift forward
by the timezone gap. The next transition arms a realtime timer. Resume, timezone
replacement, and realtime clock jumps trigger recalculation without polling.
## Transitions
Prepare the incoming wallpaper before starting a 250 ms eased transition. Decode
or upload failures and a bounded readiness timeout retain the old wallpaper and
allow the theme change. Reduced motion and accessibility changes apply immediately.
Transitions preserve their exact resolved endpoints. Changes within one appearance
keep the resolved material settings instead of adding temporary tint corrections.
Surface, tint, border, shadow, and accent colors can transition. Fonts and geometry
switch atomically. Foregrounds remain readable across dark and light backdrops. Near the neutral
midpoint, the transition temporarily strengthens material tint instead of fading
text through the background color. Tests sample primary, muted,
and on-accent text throughout the transition. Wallpaper changes use prepared
images rather than a placeholder.
Publish effective appearance at transition start. The portal emits changed
`color-scheme`, `accent-color`, `contrast`, and `reduced-motion` values then.
## Commands
| Command | Action |
| --- | --- |
| `feresectl theme get` | Read the current mode, effective appearance, and colors |
| `feresectl theme status` | Read warnings and the last reload error |
| `feresectl theme subscribe` | Stream changes as JSON; no polling |
| `feresectl theme mode light` | Save Light mode; also accepts `dark` and `auto` |
| `feresectl theme preview PATH` | Resolve a candidate config without applying it |
Settings shows six paired previews, with Light on the left and Dark on the right.
Auto uses a switch in the page header. The adjacent button shows its source and,
when enabled, the effective appearance. It opens source and schedule controls
below the gallery, which can be edited before enabling Auto. Enabling Auto
immediately follows the chosen source; disabling it keeps the current appearance.
Manual appearance controls are in Control Center. Selecting a family
applies the matching variant live and offers Undo for five
seconds. Hovering does not change the desktop. Arrow keys navigate the gallery.
Enable **Use different themes for light and dark** for two independent galleries.
Disabling it uses the active family and retains the stored pair. A missing
variant uses Ferese Blue for that appearance and displays a fallback note.
**Follow system** reads `org.gnome.desktop.interface color-scheme` and monitors
changes. `default` and `prefer-light` mean Light; `prefer-dark` means Dark. If the
setting is unavailable, Ferese uses Dark and displays a note. This source does
not read Ferese's own appearance portal, avoiding a feedback loop. Sunrise/sunset
is deferred.
The gallery’s Import button adds a family to the gallery without selecting it. Give each
variant an explicit section; omitted tokens inherit Ferese Blue for that
appearance. For example:
```kdl
theme {
name "My theme"
light { colors { accent "#8F5300"; }; }
dark { colors { accent "#CBA6F7"; }; }
}
```
Settings stores the file reference under `theme.custom-themes.<id>.file`.
The compositor validates it and publishes its palettes alongside built-ins.
Omit a variant to make a single-variant family. Token-only partial files remain
supported as shared/per-appearance overrides; imports require explicit variants
so appearance is never guessed from brightness. Imported and override files
reload after a short debounce, including saves that replace the file. Invalid
updates retain the last working theme and produce one desktop notification.
High contrast, Reduce Transparency and Reduce Motion are in Accessibility.
Material, accent, font and geometry controls stay visible below the gallery. Theme
overrides are under Advanced customization, in one file-picker row with a
Shared/Light/Dark selector; each stored
reference is retained when selecting another scope. Settings and shell switches
share the same palette. Switch and slider thumbs stay circular regardless of
shell corner radius.
When an older config first adopts mode or preset selection, Ferese moves its
inline palette into the dark selection and preserves comments. Shared wallpaper,
font, and geometry settings continue to apply to both appearances. For a wallpaper
pair, set `background { path "..."; }` inside each appearance or its theme file.
-33
View File
@@ -1,33 +0,0 @@
# Theme palette sources
Ferese maps upstream palettes to desktop roles. Materials are authored for Ferese;
text and accents have the adjustments below to meet desktop contrast requirements.
| Family | Source revision | License |
| --- | --- | --- |
| catppuccin | [07d02aa110ef](https://github.com/catppuccin/palette/blob/07d02aa110ef9eb7e7427afca5c73ba9cf7f8ebd/palette.json) | MIT |
| gruvbox | [ef8864bb42bf](https://github.com/morhetz/gruvbox/blob/ef8864bb42bf244f0295d1c5a403b27e3d139695/colors/gruvbox.vim) | MIT/X11 |
| rose-pine | [ff483051a47e](https://github.com/rose-pine/neovim/blob/ff483051a47e27d84bdef47703538df1ed9f4a47/lua/rose-pine/palette.lua) | MIT |
| tokyo-night | [cdc07ac78467](https://github.com/folke/tokyonight.nvim/blob/cdc07ac78467a233fd62c493de29a17e0cf2b2b6/extras/kitty/tokyonight_day.conf) | Apache-2.0; palette exports marked MIT |
| everforest | [85a86eb62409](https://github.com/sainnhe/everforest/blob/85a86eb62409e3ec88713bff3d1b9d7374e112e4/autoload/everforest.vim) | MIT |
Tokyo Night Night also uses its [official export](https://github.com/folke/tokyonight.nvim/blob/cdc07ac78467a233fd62c493de29a17e0cf2b2b6/extras/kitty/tokyonight_night.conf).
## Color adjustments
| Variant | Token | Upstream | Ferese |
| --- | --- | --- | --- |
| rose-pine-moon | muted | `#908CAA` | `#918DAA` |
| rose-pine-dawn | muted | `#797593` | `#716D89` |
| tokyo-night-day | text | `#3760BF` | `#3358B0` |
| tokyo-night-day | muted | `#6172B0` | `#4D5A8C` |
| tokyo-night-day | accent | `#2E7DE9` | `#2C76DD` |
| everforest-light | muted | `#829181` | `#677367` |
| everforest-light | accent | `#8DA101` | `#849601` |
Catppuccin Latte uses upstream subtext1 for muted labels. Gruvbox uses readable
foreground shades rather than its comment gray. Rosé Pine uses its subtle role
for secondary labels and its love accent in Dawn. Everforest uses the Hard pair.
Tokyo Night uses the upstream Night and Day exports.
License texts are bundled in [assets/themes/licenses](../assets/themes/licenses)
and copied into each installed release. Older saved Monochrome, Dracula, Ayu Light,
and Monokai selections remain readable, but are absent from the gallery.
+9 -2
View File
@@ -159,7 +159,10 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
| "reload-config" | "reload-config"
| "exit" | "exit"
| "request-logout" | "request-logout"
| "workspace-back-and-forth" => { | "workspace-back-and-forth"
| "focus-last-window"
| "focus-mru-next"
| "focus-mru-previous" => {
if !positional.is_empty() { if !positional.is_empty() {
return Err(format!("{command} does not accept arguments")); return Err(format!("{command} does not accept arguments"));
} }
@@ -188,7 +191,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
} }
fn usage() -> String { fn usage() -> String {
"usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned() "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
} }
#[cfg(test)] #[cfg(test)]
@@ -231,6 +234,10 @@ mod tests {
("workspace-back-and-forth".into(), json!({})) ("workspace-back-and-forth".into(), json!({}))
); );
assert!(parse_args(["workspace-back-and-forth".into(), "2".into()]).is_err()); assert!(parse_args(["workspace-back-and-forth".into(), "2".into()]).is_err());
for command in ["focus-last-window", "focus-mru-next", "focus-mru-previous"] {
assert_eq!(parse_args([command.into()]).unwrap(), (command.into(), json!({})));
assert!(parse_args([command.into(), "2".into()]).is_err());
}
} }
#[test] #[test]
+5 -2
View File
@@ -81,8 +81,11 @@
<request name="set_regions" since="2"> <request name="set_regions" since="2">
<description summary="restrict material to rounded surface-local rectangles"> <description summary="restrict material to rounded surface-local rectangles">
An array of native-endian signed 32-bit tuples (x, y, width, height, An array of native-endian IEEE 754 binary32 tuples (x, y, width, height,
radius), measured in logical pixels. At most 32 rectangles are allowed. radius) in logical pixels. All values must be finite and have magnitude
at most 32768; width and height must be positive and radius nonnegative.
At most 32 rectangles are allowed. Invalid arrays are ignored.
Coordinates are rounded only after output scaling.
An empty array disables material painting without clearing the role. An empty array disables material painting without clearing the role.
Surfaces which never send this request retain full-surface materials. Surfaces which never send this request retain full-surface materials.
</description> </description>
+4 -1
View File
@@ -110,7 +110,10 @@
<arg name="output_hi" type="uint" summary="zero when the workspace has no output"/> <arg name="output_hi" type="uint" summary="zero when the workspace has no output"/>
<arg name="output_lo" type="uint" summary="zero when the workspace has no output"/> <arg name="output_lo" type="uint" summary="zero when the workspace has no output"/>
<arg name="name" type="string"/> <arg name="name" type="string"/>
<arg name="active" type="uint" summary="one when visible on its output"/> <arg name="index" type="uint" summary="one-based position on this output"/>
<arg name="window_count" type="uint" summary="managed windows, including floating windows"/>
<arg name="visible" type="uint" summary="one when displayed on its output"/>
<arg name="focused" type="uint" summary="one when visible on the focused output"/>
</event> </event>
<event name="snapshot_end"> <event name="snapshot_end">
-4
View File
@@ -17,11 +17,7 @@ PAGES = [
('why-ferese', 'Why Ferese', ROOT / 'docs/why-ferese.md'), ('why-ferese', 'Why Ferese', ROOT / 'docs/why-ferese.md'),
('installation', 'Installation', ROOT / 'docs/installation.md'), ('installation', 'Installation', ROOT / 'docs/installation.md'),
('configuration', 'Configuration', ROOT / 'docs/configuration.md'), ('configuration', 'Configuration', ROOT / 'docs/configuration.md'),
('theme-engine', 'Themes', ROOT / 'docs/theme-engine.md'),
('theme-palette-sources', 'Theme palette sources', ROOT / 'docs/theme-palette-sources.md'),
('shortcuts', 'Shortcuts and gestures', ROOT / 'docs/shortcuts.md'), ('shortcuts', 'Shortcuts and gestures', ROOT / 'docs/shortcuts.md'),
('desktop-widgets', 'Desktop widgets', ROOT / 'docs/desktop-widgets.md'),
('notifications', 'Notifications', ROOT / 'docs/notifications.md'),
('locking', 'Lock screen', ROOT / 'docs/locking.md'), ('locking', 'Lock screen', ROOT / 'docs/locking.md'),
('screen-sharing', 'Screen sharing', ROOT / 'docs/screen-sharing.md'), ('screen-sharing', 'Screen sharing', ROOT / 'docs/screen-sharing.md'),
('portals', 'Desktop portals', ROOT / 'docs/portals.md'), ('portals', 'Desktop portals', ROOT / 'docs/portals.md'),
@@ -0,0 +1,114 @@
"""Focus history against real windows on a disposable nested compositor.
FERESE_TEST_FOCUS_HISTORY=1 python3 scripts/tests/test_focus_history_isolated.py
Requires built debug ferese/feresectl, foot, dbus-daemon, and a Wayland host.
"""
import json
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import time
import unittest
@unittest.skipUnless(os.environ.get("FERESE_TEST_FOCUS_HISTORY") == "1", "nested focus test is opt-in")
class FocusHistoryTest(unittest.TestCase):
def test_last_focus_crosses_workspaces_and_skips_closed_windows(self):
repo = Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory(prefix="ferese-focus-test-") as temporary:
root = Path(temporary)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese"
config.mkdir(parents=True)
(config / "config.kdl").write_text("animations { enabled #false; }\n")
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime),
XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"))
env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
children = []
def launch(command, **kwargs):
child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs)
children.append(child)
return child
def command(*args):
return json.loads(subprocess.check_output(
[str(repo / "target/debug/feresectl"), *args], env=env, timeout=5
))
def wait_for(predicate):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
result = predicate()
if result:
return result
time.sleep(.025)
self.fail("Timed out waiting for compositor state: " + (root / "compositor.log").read_text())
def focused():
result = command("get-focused-window")
return result["id"] if result else None
with (root / "compositor.log").open("w") as log:
try:
bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"],
stdout=subprocess.PIPE, text=True)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log)
wait_for(lambda: (runtime / "ferese/control.sock").is_socket())
command("focus-last-window")
self.assertIsNone(focused())
env["WAYLAND_DISPLAY"] = str(next(
path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")
))
windows = []
for index in range(3):
app = f"ferese.test.focus.{index}"
child = launch(["foot", "--app-id", app, "sleep", "120"], stdout=log, stderr=log)
window = wait_for(lambda: next((w for w in command("get-windows")
if w["app_id"] == app and w["width"] > 0), None))
wait_for(lambda: focused() == window["id"])
windows.append((child, window["id"]))
if index == 1:
command("workspace", "2")
first, second, third = [window for _, window in windows]
command("focus-last-window")
self.assertEqual(focused(), second)
self.assertEqual(next(w["name"] for w in command("get-workspaces") if w["active"]), "1")
command("focus-last-window")
self.assertEqual(focused(), third)
self.assertEqual(next(w["name"] for w in command("get-workspaces") if w["active"]), "2")
command("focus-mru-previous")
self.assertEqual(focused(), first, "reverse traversal reaches least recent window")
command("focus-mru-next")
self.assertEqual(focused(), third)
windows[1][0].terminate()
windows[1][0].wait(timeout=3)
wait_for(lambda: not any(w["id"] == second for w in command("get-windows")))
command("focus-last-window")
self.assertEqual(focused(), first, "closed windows must not consume a focus action")
command("reload-config")
command("focus-last-window")
self.assertEqual(focused(), third, "reload must preserve focus history")
finally:
for child in reversed(children):
if child.poll() is None:
os.killpg(child.pid, signal.SIGTERM)
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait(timeout=3)
if child.stdout is not None:
child.stdout.close()
if __name__ == "__main__":
unittest.main()
@@ -86,6 +86,7 @@ class UnmappedToplevelTest(unittest.TestCase):
text=True, text=True,
) )
visible_ids = []
for phase, visible in [ for phase, visible in [
("initial-empty", False), ("initial-empty", False),
("visible", True), ("visible", True),
@@ -110,9 +111,35 @@ class UnmappedToplevelTest(unittest.TestCase):
if time.monotonic() >= deadline: if time.monotonic() >= deadline:
self.fail(f"{phase}: unexpected focused window {focused}") self.fail(f"{phase}: unexpected focused window {focused}")
time.sleep(0.05) time.sleep(0.05)
if visible:
visible_ids.append(focused["id"])
if phase == "visible":
# Leave a configure/resize outstanding when the buffer is detached.
subprocess.run([str(ctl_binary), "toggle-maximized"], env=env,
capture_output=True, check=True)
client.stdin.write("\n") client.stdin.write("\n")
client.stdin.flush() client.stdin.flush()
self.assertNotEqual(visible_ids[0], visible_ids[1], "remapping must allocate a fresh window identity")
self.assertEqual(client.wait(timeout=5), 0) self.assertEqual(client.wait(timeout=5), 0)
deadline = time.monotonic() + 3
while True:
remaining = json.loads(subprocess.check_output(
[str(ctl_binary), "get-windows"], env=env, text=True
))
if not remaining:
break
if time.monotonic() >= deadline:
self.fail(f"destroyed window still managed: {remaining}")
time.sleep(0.05)
subprocess.run([str(ctl_binary), "focus-last-window"], env=env,
capture_output=True, check=True)
focused = json.loads(subprocess.check_output(
[str(ctl_binary), "get-focused-window"], env=env, text=True
))
self.assertIsNone(focused, "focus history must not restore a destroyed window")
finally: finally:
if client and client.poll() is None: if client and client.poll() is None:
client.terminate() client.terminate()
+10 -1
View File
@@ -35,10 +35,19 @@ class WindowCapture(unittest.TestCase):
config = root / "config/ferese/config.kdl" config = root / "config/ferese/config.kdl"
config.parent.mkdir(parents=True) config.parent.mkdir(parents=True)
config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n') config.write_text('window-rule app-id="ferese.test.window-capture" floating=#true\n')
# Remembered geometry deliberately places both clients together;
# ordinary placement now avoids overlap. Keep host state isolated.
state = root / "state/ferese"
state.mkdir(parents=True)
(state / "floating.json").write_text(json.dumps({
"ferese.test.window-capture": {
"output": "ferese-winit", "fractions": [.1, .1, .4, .4],
},
}))
display = Path(os.environ["WAYLAND_DISPLAY"]) display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute(): if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1") env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), WAYLAND_DISPLAY=str(display), FERESE_ENABLE_SCREENCOPY="1")
env.pop("WAYLAND_SOCKET", None) env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None) env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log = (root / "compositor.log").open("w") log = (root / "compositor.log").open("w")
@@ -1,7 +1,7 @@
"""Workspace history against a temporary nested compositor. """Workspace history against a temporary nested compositor.
FERESE_TEST_WORKSPACE_HISTORY=1 python3 scripts/tests/test_workspace_history_isolated.py FERESE_TEST_WORKSPACE_HISTORY=1 python3 scripts/tests/test_workspace_history_isolated.py
Requires target/debug/ferese, feresectl, dbus-daemon, and a Wayland host. Requires target/debug/ferese, feresectl, foot, dbus-daemon, and a Wayland host.
""" """
import json import json
import os import os
@@ -15,7 +15,7 @@ import unittest
@unittest.skipUnless(os.environ.get("FERESE_TEST_WORKSPACE_HISTORY") == "1", "nested workspace test is opt-in") @unittest.skipUnless(os.environ.get("FERESE_TEST_WORKSPACE_HISTORY") == "1", "nested workspace test is opt-in")
class WorkspaceHistoryTest(unittest.TestCase): class WorkspaceHistoryTest(unittest.TestCase):
def test_empty_workspace_history_and_explicit_ipc_selection(self): def test_dynamic_positions_spare_cleanup_and_history(self):
repo = Path(__file__).resolve().parents[2] repo = Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory(prefix="ferese-workspace-test-") as temporary: with tempfile.TemporaryDirectory(prefix="ferese-workspace-test-") as temporary:
root = Path(temporary) root = Path(temporary)
@@ -59,28 +59,73 @@ class WorkspaceHistoryTest(unittest.TestCase):
if compositor.poll() is not None or time.monotonic() >= deadline: if compositor.poll() is not None or time.monotonic() >= deadline:
self.fail((root / "compositor.log").read_text()) self.fail((root / "compositor.log").read_text())
time.sleep(.025) time.sleep(.025)
self.assertEqual(active(), "1") def wait_for(predicate):
command("workspace-back-and-forth") deadline = time.monotonic() + 10
self.assertEqual(active(), "1", "no history is a no-op") while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(.025)
self.fail("Timed out: " + (root / "compositor.log").read_text())
def workspaces():
return command("get-workspaces")
initial = workspaces()[0]["id"]
self.assertEqual(len(workspaces()), 1)
command("workspace", "9")
self.assertEqual(workspaces()[0]["id"], initial)
self.assertEqual(len(workspaces()), 1, "numeric lookup must not create gaps")
env["WAYLAND_DISPLAY"] = str(next(
path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")
))
first = launch(["foot", "--app-id", "ferese.test.workspace.first", "sleep", "120"],
stdout=log, stderr=log)
wait_for(lambda: len(workspaces()) == 2 and workspaces()[0]["window_count"] == 1)
command("workspace", "2") command("workspace", "2")
self.assertEqual(active(), "2") second_id = next(w["id"] for w in workspaces() if w["focused"])
self.assertEqual({w["name"] for w in command("get-workspaces")}, {"1", "2"}) second = launch(["foot", "--app-id", "ferese.test.workspace.second", "sleep", "120"],
stdout=log, stderr=log)
wait_for(lambda: len(workspaces()) == 3 and workspaces()[1]["window_count"] == 1)
for expected in ["1", "2", "1", "2"]: for expected in ["1", "2", "1", "2"]:
command("workspace-back-and-forth") command("workspace-back-and-forth")
self.assertEqual(active(), expected) self.assertEqual(active(), expected)
command("workspace", "3")
self.assertEqual({w["name"] for w in command("get-workspaces")}, {"2", "3"}, os.killpg(first.pid, signal.SIGTERM)
"old empty history is pruned") first.wait(timeout=3)
wait_for(lambda: len(workspaces()) == 2)
self.assertEqual(workspaces()[0]["id"], second_id, "compaction preserves identity")
self.assertEqual([w["index"] for w in workspaces()], [1, 2])
command("workspace-back-and-forth")
self.assertEqual(active(), "1", "removed history cannot resurrect an empty workspace")
spare_id = workspaces()[1]["id"]
command("workspace", "99")
self.assertEqual(active(), "2")
self.assertEqual(next(w["id"] for w in workspaces() if w["focused"]), spare_id)
command("workspace-back-and-forth")
self.assertEqual(active(), "1")
replacement = source.with_suffix(".new") replacement = source.with_suffix(".new")
replacement.write_text("animations { enabled #false; }\nworkspaces { auto-back-and-forth #true; }\n") replacement.write_text("animations { enabled #false; }\nworkspaces { auto-back-and-forth #true; }\n")
replacement.replace(source) replacement.replace(source)
command("reload-config") command("reload-config")
command("workspace", "3") command("workspace", "1")
self.assertEqual(active(), "3", "literal IPC selection must not auto-toggle") self.assertEqual(active(), "1", "explicit IPC selection must not auto-toggle")
command("workspace-back-and-forth") command("workspace-back-and-forth")
self.assertEqual(active(), "2", "reload must preserve history") self.assertEqual(active(), "2")
command("workspace-back-and-forth") command("workspace-back-and-forth")
self.assertEqual(active(), "3") self.assertEqual(active(), "1")
os.killpg(second.pid, signal.SIGTERM)
second.wait(timeout=3)
wait_for(lambda: all(w["window_count"] == 0 for w in workspaces()))
command("workspace", "2")
wait_for(lambda: len(workspaces()) == 1)
self.assertEqual(workspaces()[0]["id"], spare_id)
self.assertEqual(workspaces()[0]["name"], "1")
self.assertTrue(workspaces()[0]["visible"])
self.assertTrue(workspaces()[0]["focused"])
finally: finally:
for child in reversed(children): for child in reversed(children):
if child.poll() is None: if child.poll() is None:
+14 -9
View File
@@ -38,20 +38,19 @@ impl FereseShell {
.spacing(1) .spacing(1)
.align_y(cosmic::iced::Alignment::Center); .align_y(cosmic::iced::Alignment::Center);
for workspace in &self.snapshot.workspaces { for workspace in self
.snapshot
.workspaces_for_output(focused_output.map(|output| output.id))
{
let active = workspace_active_on_bar(workspace.id, focused_output); let active = workspace_active_on_bar(workspace.id, focused_output);
let owner = workspace let owner = workspace
.output .output
.and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id)); .and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id));
let occupied = self let occupied = workspace.window_count > 0;
.snapshot
.windows
.iter()
.any(|window| window.workspace == workspace.id);
let indicator = workspace_indicator( let indicator = workspace_indicator(
&workspace.name, &workspace.name,
active, active,
workspace.active && !active, workspace.visible && !active,
occupied, occupied,
bar, bar,
shell_theme, shell_theme,
@@ -59,8 +58,14 @@ impl FereseShell {
let workspace_button = button::custom(indicator) let workspace_button = button::custom(indicator)
.name(format!( .name(format!(
"Workspace {}{}{}", "Workspace {}{}{}",
workspace.name, workspace.index,
if active { ", active" } else { "" }, if workspace.focused {
", focused"
} else if active {
", visible"
} else {
""
},
owner.map_or(String::new(), |output| format!(", on {}", output.name)) owner.map_or(String::new(), |output| format!(", on {}", output.name))
)) ))
.height(bar.control_height) .height(bar.control_height)
+58 -6
View File
@@ -19,6 +19,14 @@ pub(crate) struct ShellSnapshot {
pub(crate) windows: Vec<WindowSnapshot>, pub(crate) windows: Vec<WindowSnapshot>,
} }
impl ShellSnapshot {
pub(crate) fn workspaces_for_output(&self, output: Option<u64>) -> impl Iterator<Item = &WorkspaceSnapshot> {
self.workspaces
.iter()
.filter(move |workspace| output.is_some() && workspace.output == output)
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct OutputSnapshot { pub(crate) struct OutputSnapshot {
pub(crate) id: u64, pub(crate) id: u64,
@@ -32,7 +40,10 @@ pub(crate) struct WorkspaceSnapshot {
pub(crate) id: u64, pub(crate) id: u64,
pub(crate) name: String, pub(crate) name: String,
pub(crate) output: Option<u64>, pub(crate) output: Option<u64>,
pub(crate) active: bool, pub(crate) index: u32,
pub(crate) window_count: u32,
pub(crate) visible: bool,
pub(crate) focused: bool,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -249,7 +260,10 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
output_hi, output_hi,
output_lo, output_lo,
name, name,
active, index,
window_count,
visible,
focused,
} => { } => {
let output = join_id(output_hi, output_lo); let output = join_id(output_hi, output_lo);
@@ -257,7 +271,10 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
id: join_id(workspace_hi, workspace_lo), id: join_id(workspace_hi, workspace_lo),
name, name,
output: (output != 0).then_some(output), output: (output != 0).then_some(output),
active: active != 0, index,
window_count,
visible: visible != 0,
focused: focused != 0,
}); });
} }
ferese_shell_v1::Event::Window { ferese_shell_v1::Event::Window {
@@ -286,9 +303,6 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
}); });
} }
ferese_shell_v1::Event::SnapshotEnd { serial } if state.serial.take() == Some(serial) => { ferese_shell_v1::Event::SnapshotEnd { serial } if state.serial.take() == Some(serial) => {
state.pending.workspaces.sort_unstable_by_key(|workspace| {
ferese_core::workspace_order_key(&workspace.name, ferese_core::WorkspaceId(workspace.id))
});
let _ = state.sender.send(ControlUpdate::Snapshot(state.pending.clone())); let _ = state.sender.send(ControlUpdate::Snapshot(state.pending.clone()));
} }
ferese_shell_v1::Event::LogoutRequested { serial, output_name } => { ferese_shell_v1::Event::LogoutRequested { serial, output_name } => {
@@ -348,3 +362,41 @@ mod tests {
assert!(config.is_none()); assert!(config.is_none());
} }
} }
#[cfg(test)]
mod workspace_tests {
use super::*;
#[test]
fn each_bar_uses_its_own_order_and_authoritative_occupancy() {
let workspace = |id, output, index, window_count| WorkspaceSnapshot {
id,
output: Some(output),
index,
name: index.to_string(),
window_count,
visible: index == 1,
focused: output == 1 && index == 1,
};
let snapshot = ShellSnapshot {
workspaces: vec![workspace(7, 1, 1, 2), workspace(9, 1, 2, 0), workspace(3, 2, 1, 0)],
..Default::default()
};
let left = snapshot.workspaces_for_output(Some(1)).collect::<Vec<_>>();
assert_eq!(
left.iter().map(|workspace| workspace.id).collect::<Vec<_>>(),
vec![7, 9]
);
assert_eq!(left[0].window_count, 2);
assert_eq!(
snapshot
.workspaces_for_output(Some(2))
.map(|workspace| workspace.id)
.collect::<Vec<_>>(),
vec![3]
);
assert_eq!(snapshot.workspaces_for_output(None).count(), 0);
assert_eq!(snapshot.workspaces_for_output(Some(99)).count(), 0);
}
}
+3 -2
View File
@@ -361,12 +361,13 @@ impl FereseShell {
&& editor.id == id && editor.id == id
{ {
let id = id.to_owned(); let id = id.to_owned();
let editor_radius = ferese_theme::inner_radius(self.config.theme.material_radius, note.padding);
body = body.push( body = body.push(
cosmic::widget::TextEditor::new(&editor.content) cosmic::widget::TextEditor::new(&editor.content)
.style(|theme, status| { .style(move |theme, status| {
use cosmic::iced::widget::text_editor::Catalog; use cosmic::iced::widget::text_editor::Catalog;
let mut style = theme.style(&<cosmic::Theme as Catalog>::default(), status); let mut style = theme.style(&<cosmic::Theme as Catalog>::default(), status);
style.border.radius = motion::radius(f32::MAX).into(); style.border.radius = editor_radius.into();
style style
}) })
.height(Length::Fill) .height(Length::Fill)
+17 -8
View File
@@ -3,7 +3,7 @@ use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, la
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector}; use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme}; use cosmic::{Element, Theme};
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[i32; 5]>>>; pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[f32; 5]>>>;
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
#[serde(default)] #[serde(default)]
@@ -463,18 +463,18 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
struct CollectRegions(Vec<Rectangle>); struct CollectRegions(Vec<Rectangle>);
impl CollectRegions { impl CollectRegions {
fn into_regions(self, translation: Vector, radius: f32) -> Vec<[i32; 5]> { fn into_regions(self, translation: Vector, radius: f32) -> Vec<[f32; 5]> {
self.0 self.0
.into_iter() .into_iter()
.take(32) .take(32)
.filter(|r| r.width > 0.0 && r.height > 0.0) .filter(|r| r.width > 0.0 && r.height > 0.0)
.map(|r| { .map(|r| {
[ [
(r.x + translation.x).round() as i32, r.x + translation.x,
(r.y + translation.y).round() as i32, r.y + translation.y,
r.width.round() as i32, r.width,
r.height.round() as i32, r.height,
radius.round().max(0.0) as i32, radius.max(0.0),
] ]
}) })
.collect() .collect()
@@ -508,6 +508,15 @@ impl widget::Operation for CollectRegions {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn material_regions_preserve_fractional_layout_and_translation() {
let collector = CollectRegions(vec![Rectangle::new((0.25, 2.5).into(), (100.25, 40.5).into())]);
assert_eq!(
collector.into_regions(Vector::new(0.125, -0.25), 13.5),
vec![[0.375, 2.25, 100.25, 40.5, 13.5]]
);
}
#[test] #[test]
fn centered_modal_material_tracks_surface_position_and_animation() { fn centered_modal_material_tracks_surface_position_and_animation() {
let mut collector = CollectRegions(Vec::new()); let mut collector = CollectRegions(Vec::new());
@@ -515,7 +524,7 @@ mod tests {
widget::Operation::container(&mut collector, Some(&widget::Id::new("ferese-blur-card")), card); widget::Operation::container(&mut collector, Some(&widget::Id::new("ferese-blur-card")), card);
assert_eq!( assert_eq!(
collector.into_regions(Vector::new(0.0, -2.0), 14.0), collector.into_regions(Vector::new(0.0, -2.0), 14.0),
vec![[380, 418, 440, 220, 14]] vec![[380., 418., 440., 220., 14.]]
); );
} }
@@ -467,6 +467,7 @@ impl FereseShell {
width: if history { 0.0 } else { 1.0 }, width: if history { 0.0 } else { 1.0 },
radius: palette.material_radius.into(), radius: palette.material_radius.into(),
}, },
snap: true,
..Default::default() ..Default::default()
})); }));
let card: Element<'_, cosmic::Action<Message>> = card.into(); let card: Element<'_, cosmic::Action<Message>> = card.into();
@@ -738,6 +739,7 @@ impl FereseShell {
width: 1.0, width: 1.0,
radius: palette.material_radius.into(), radius: palette.material_radius.into(),
}, },
snap: true,
..Default::default() ..Default::default()
})) }))
.into(); .into();
+1
View File
@@ -222,6 +222,7 @@ impl FereseShell {
width: 1.0, width: 1.0,
radius: theme.material_radius.into(), radius: theme.material_radius.into(),
}, },
snap: true,
..Default::default() ..Default::default()
})); }));
+31 -15
View File
@@ -433,12 +433,19 @@ pub(super) fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Sty
} }
} }
fn encode_regions(regions: &[[f32; 5]]) -> Vec<u8> {
regions
.iter()
.flat_map(|region| region.iter().flat_map(|value| value.to_ne_bytes()))
.collect()
}
pub(super) struct EffectsBinding { pub(super) struct EffectsBinding {
connection: Connection, connection: Connection,
_manager: FereseEffectsManagerV1, _manager: FereseEffectsManagerV1,
pub(super) surface: FereseSurfaceEffectsV1, pub(super) surface: FereseSurfaceEffectsV1,
_queue: EventQueue<EffectsState>, _queue: EventQueue<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>, regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
opacity: std::cell::Cell<Option<u32>>, opacity: std::cell::Cell<Option<u32>>,
} }
@@ -463,13 +470,11 @@ impl EffectsBinding {
let connection = Connection::from_backend(backend); let connection = Connection::from_backend(backend);
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?; let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
let qh = queue.handle(); let qh = queue.handle();
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=3, ())?; let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 3..=3, ())?;
let effects = manager.get_surface_effects(surface, &qh, ()); let effects = manager.get_surface_effects(surface, &qh, ());
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
if effects.version() >= 3 { effects.set_opacity(opacity);
effects.set_opacity(opacity);
}
if let Some(role) = role { if let Some(role) = role {
effects.set_role(role); effects.set_role(role);
} else { } else {
@@ -487,24 +492,19 @@ impl EffectsBinding {
}) })
} }
pub(super) fn set_regions(&self, regions: &[[i32; 5]]) -> Result<(), Box<dyn std::error::Error>> { pub(super) fn set_regions(&self, regions: &[[f32; 5]]) -> Result<(), Box<dyn std::error::Error>> {
self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover) self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover)
} }
pub(super) fn set_material_regions( pub(super) fn set_material_regions(
&self, &self,
regions: &[[i32; 5]], regions: &[[f32; 5]],
role: ferese_surface_effects_v1::Role, role: ferese_surface_effects_v1::Role,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if self.surface.version() < 2 || self.regions.borrow().as_deref() == Some(regions) { if self.regions.borrow().as_deref() == Some(regions) {
return Ok(()); return Ok(());
} }
self.surface.set_regions( self.surface.set_regions(encode_regions(regions));
regions
.iter()
.flat_map(|r| r.iter().flat_map(|v| v.to_ne_bytes()))
.collect(),
);
if regions.is_empty() { if regions.is_empty() {
self.surface.clear_role(); self.surface.clear_role();
} else { } else {
@@ -529,7 +529,7 @@ impl EffectsBinding {
pub(super) fn set_opacity(&self, opacity: f32) -> Result<(), Box<dyn std::error::Error>> { pub(super) fn set_opacity(&self, opacity: f32) -> Result<(), Box<dyn std::error::Error>> {
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) { if self.opacity.get() != Some(opacity) {
self.surface.set_opacity(opacity); self.surface.set_opacity(opacity);
self.connection.flush()?; self.connection.flush()?;
self.opacity.set(Some(opacity)); self.opacity.set(Some(opacity));
@@ -554,3 +554,19 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
delegate_noop!(EffectsState: ignore FereseEffectsManagerV1); delegate_noop!(EffectsState: ignore FereseEffectsManagerV1);
delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1); delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
#[cfg(test)]
mod region_encoding_tests {
#[test]
fn fractional_region_encoding_preserves_values() {
let regions = [[0.25, -0.75, 100.5, 40.25, 14.0]];
let bytes = super::encode_regions(&regions);
let decoded: Vec<_> = bytes
.as_chunks::<4>()
.0
.iter()
.map(|&v| f32::from_ne_bytes(v))
.collect();
assert_eq!(decoded, regions[0]);
}
}
+1
View File
@@ -541,6 +541,7 @@ impl FereseShell {
width: 1.0, width: 1.0,
radius: theme.material_radius.into(), radius: theme.material_radius.into(),
}, },
snap: true,
..Default::default() ..Default::default()
})) }))
.into() .into()
+1 -3
View File
@@ -10,7 +10,7 @@ use
## Get started ## Get started
1. [Install Ferese](installation.md), or try a [nested preview](installation.md#preview-and-logs). 1. [Install Ferese](installation.md), or try a [nested preview](installation.md#preview-and-logs).
2. Choose Light, Dark, or Auto in **Control Center**. Open **Settings** for [theme files and presets](theme-engine.md). 2. Choose Light, Dark, or Auto in **Control Center**. Open **Settings** for theme files and presets.
3. Use the [configuration reference](configuration.md) for shortcuts, gestures, window rules, and displays. 3. Use the [configuration reference](configuration.md) for shortcuts, gestures, window rules, and displays.
## Default shortcuts ## Default shortcuts
@@ -40,8 +40,6 @@ group windows, resize them and move between workspaces.
## Desktop guides ## Desktop guides
- [Desktop widgets](desktop-widgets.md): clocks and sticky notes.
- [Notifications](notifications.md): history and Do Not Disturb.
- [Lock screen](locking.md): appearance, automatic locking, and display sleep. - [Lock screen](locking.md): appearance, automatic locking, and display sleep.
- [Screen sharing and recording](screen-sharing.md): share an app or display, or save a video. - [Screen sharing and recording](screen-sharing.md): share an app or display, or save a video.
- [Desktop portals](portals.md): integration with applications. - [Desktop portals](portals.md): integration with applications.