Fix compositor output routing and pointer hints; format imports

This commit is contained in:
Samuel Onoja committed 2026-09-30 06:39:58 +01:00
1 parent 88e4f3c135
commit 46509f880a
108 files changed
+3331 -8020

No files matched your search

+100 -268
View File
@@ -1,57 +1,41 @@
use std::{
collections::{HashMap, HashSet},
error::Error,
io,
path::Path,
time::{Duration, Instant},
};
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::io;
use std::path::Path;
use std::time::{Duration, Instant};
use smithay::{
backend::{
allocator::{
Fourcc,
gbm::{GbmAllocator, GbmBufferFlags, GbmDevice},
},
drm::{
DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface, NodeType,
},
egl::{EGLContext, EGLDisplay},
input::InputEvent,
libinput::{LibinputInputBackend, LibinputSessionInterface},
renderer::{
Bind, Frame, ImportDma, ImportMemWl, Renderer, damage::OutputDamageTracker,
gles::GlesRenderer,
},
session::{Event as SessionEvent, Session, libseat::LibSeatSession},
udev::{UdevBackend, UdevEvent, primary_gpu},
},
desktop::{layer_map_for_output, utils::OutputPresentationFeedback},
output::{Mode as OutputMode, Output, PhysicalProperties, Scale, Subpixel},
reexports::{
calloop::{
EventLoop, RegistrationToken,
timer::{TimeoutAction, Timer},
},
drm::control::{
use smithay::backend::allocator::Fourcc;
use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice};
use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, GbmBufferedSurface, NodeType};
use smithay::backend::egl::{EGLContext, EGLDisplay};
use smithay::backend::input::InputEvent;
use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface};
use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::backend::renderer::{Bind, Frame, ImportDma, ImportMemWl, Renderer};
use smithay::backend::session::libseat::LibSeatSession;
use smithay::backend::session::{Event as SessionEvent, Session};
use smithay::backend::udev::{UdevBackend, UdevEvent, primary_gpu};
use smithay::desktop::layer_map_for_output;
use smithay::desktop::utils::OutputPresentationFeedback;
use smithay::output::{Mode as OutputMode, Output, PhysicalProperties, Scale, Subpixel};
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::calloop::{EventLoop, RegistrationToken};
use smithay::reexports::drm::control::{
Device as ControlDevice, Mode as DrmMode, ModeTypeFlags, connector, crtc, property,
},
input::{Device as LibinputDevice, Libinput},
rustix::fs::OFlags,
wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind,
wayland_server::backend::GlobalId,
},
utils::{DeviceFd, Monotonic, Time, Transform},
wayland::{dmabuf::DmabufFeedbackBuilder, presentation::Refresh},
};
use smithay::reexports::input::{Device as LibinputDevice, Libinput};
use smithay::reexports::rustix::fs::OFlags;
use smithay::reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind;
use smithay::reexports::wayland_server::backend::GlobalId;
use smithay::utils::{DeviceFd, Monotonic, Time, Transform};
use smithay::wayland::dmabuf::DmabufFeedbackBuilder;
use smithay::wayland::presentation::Refresh;
use crate::{
Ferese,
config::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform},
metrics::RenderMetrics,
winit::{
animated_window_elements, cursorless_window_elements, frame_effect_metrics, redraw_output,
},
};
use crate::Ferese;
use crate::config::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
use crate::metrics::RenderMetrics;
use crate::winit::{animated_window_elements, cursorless_window_elements, frame_effect_metrics, redraw_output};
pub struct DirectBackendState {
pub session: LibSeatSession,
@@ -72,14 +56,12 @@ impl DirectBackendState {
geometry: smithay::utils::Rectangle<i32, smithay::utils::Logical>,
output: &Output,
remaining: usize,
) -> Result<Option<crate::winit::ResizeSnapshot>, smithay::backend::renderer::gles::GlesError>
{
let Some(device) = self.devices.values_mut().find(|device| {
device
.outputs
.values()
.any(|candidate| &candidate.output == output)
}) else {
) -> Result<Option<crate::winit::ResizeSnapshot>, smithay::backend::renderer::gles::GlesError> {
let Some(device) = self
.devices
.values_mut()
.find(|device| device.outputs.values().any(|candidate| &candidate.output == output))
else {
return Ok(None);
};
crate::winit::capture_resize_snapshot(
@@ -100,12 +82,7 @@ impl DirectBackendState {
let device = self
.devices
.values_mut()
.find(|device| {
device
.outputs
.values()
.any(|candidate| &candidate.output == output)
})
.find(|device| device.outputs.values().any(|candidate| &candidate.output == output))
.ok_or("Window output is unavailable")?;
crate::winit::capture_window_buffer(
&mut device.renderer,
@@ -124,12 +101,7 @@ impl DirectBackendState {
let device = self
.devices
.values_mut()
.find(|device| {
device
.outputs
.values()
.any(|candidate| &candidate.output == output)
})
.find(|device| device.outputs.values().any(|candidate| &candidate.output == output))
.ok_or("Window output is unavailable")?;
crate::winit::capture_window_frame(
&mut device.renderer,
@@ -212,12 +184,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
let seat_name = session.seat();
let udev_backend = UdevBackend::new(&seat_name)?;
let primary_path = primary_gpu(&seat_name)?
.or_else(|| {
udev_backend
.device_list()
.next()
.map(|(_, path)| path.to_owned())
})
.or_else(|| udev_backend.device_list().next().map(|(_, path)| path.to_owned()))
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no DRM device found"))?;
let mut libinput_context =
Libinput::new_with_udev::<LibinputSessionInterface<LibSeatSession>>(session.clone().into());
@@ -254,9 +221,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
}
InputEvent::DeviceRemoved { device } => {
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),
@@ -275,8 +240,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
device.drm.pause();
device.outputs.values_mut().for_each(|output| {
output.surface.reset_buffers();
output.damage_tracker =
OutputDamageTracker::from_output(&output.output);
output.damage_tracker = OutputDamageTracker::from_output(&output.output);
output.frame_pending = false;
output.power_off = false;
});
@@ -335,20 +299,12 @@ fn configure_libinput_device(state: &Ferese, device: &mut LibinputDevice) {
if device.config_tap_finger_count() > 0
&& let Err(error) = device.config_tap_set_enabled(settings.tap)
{
tracing::warn!(
device = device.name(),
?error,
"failed to configure tap-to-click"
);
tracing::warn!(device = device.name(), ?error, "failed to configure tap-to-click");
}
if device.config_scroll_has_natural_scroll()
&& let Err(error) = device.config_scroll_set_natural_scroll_enabled(settings.natural_scroll)
{
tracing::warn!(
device = device.name(),
?error,
"failed to configure natural scrolling"
);
tracing::warn!(device = device.name(), ?error, "failed to configure natural scrolling");
}
if device.config_dwt_is_available()
&& let Err(error) = device.config_dwt_set_enabled(settings.disable_while_typing)
@@ -383,10 +339,7 @@ pub(crate) fn reload_outputs(state: &mut Ferese) {
}
}
pub(crate) fn validate_live_outputs(
state: &Ferese,
profiles: &[OutputProfile],
) -> Result<(), String> {
pub(crate) fn validate_live_outputs(state: &Ferese, profiles: &[OutputProfile]) -> Result<(), String> {
let Some(backend) = state.direct_backend.as_ref() else {
return Ok(());
};
@@ -414,10 +367,7 @@ fn open_primary_device(
.session
.clone();
let node = DrmNode::from_path(path)?;
let fd = session.open(
path,
OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOCTTY | OFlags::NONBLOCK,
)?;
let fd = session.open(path, OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOCTTY | OFlags::NONBLOCK)?;
let fd = DrmDeviceFd::new(DeviceFd::from(fd));
let (mut drm, notifier) = DrmDevice::new(fd.clone(), true)?;
let gbm = GbmDevice::new(fd)?;
@@ -443,10 +393,7 @@ fn open_primary_device(
let mut scan = select_outputs(&drm, &state.output_profiles)?;
apply_lid_policy(
&mut scan,
state
.direct_backend
.as_ref()
.is_some_and(|backend| backend.lid_closed),
state.direct_backend.as_ref().is_some_and(|backend| backend.lid_closed),
);
if scan.selections.is_empty() {
return Err(io::Error::new(
@@ -468,9 +415,7 @@ fn open_primary_device(
mode,
settings,
} = selection;
let output = create_direct_output(
state, &mut drm, &gbm, &renderer, connector, crtc, mode, &settings,
)?;
let output = create_direct_output(state, &mut drm, &gbm, &renderer, connector, crtc, mode, &settings)?;
state
.direct_backend
.as_mut()
@@ -503,9 +448,7 @@ fn open_primary_device(
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(
Time::<Monotonic>::from(time),
Refresh::fixed(refresh_duration(state, node, crtc)),
@@ -538,10 +481,7 @@ fn open_primary_device(
}
state.record_drm_presentation(node, crtc, metadata.time, metadata.sequence);
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 {
let was_animating = state
.direct_backend
@@ -588,11 +528,7 @@ pub(crate) fn sleep_locked_outputs(state: &mut Ferese) {
let Some(backend) = state.direct_backend.as_mut() else {
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() {
if output.power_off || output.frame_pending {
continue;
@@ -621,11 +557,7 @@ pub(crate) fn wake_locked_outputs(state: &mut Ferese) {
}
pub fn render_all(state: &mut Ferese) {
if state
.direct_backend
.as_ref()
.is_none_or(|backend| !backend.active)
{
if state.direct_backend.as_ref().is_none_or(|backend| !backend.active) {
return;
}
let animating = state.advance_animations(Instant::now());
@@ -692,9 +624,7 @@ fn initial_lid_closed() -> bool {
fn internal_connector(interface: connector::Interface) -> bool {
matches!(
interface,
connector::Interface::EmbeddedDisplayPort
| connector::Interface::LVDS
| connector::Interface::DSI
connector::Interface::EmbeddedDisplayPort | connector::Interface::LVDS | connector::Interface::DSI
)
}
@@ -767,8 +697,7 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
state.process_dmabuf_imports(&mut device.renderer);
let (mut buffer, age) = output.surface.next_buffer()?;
let elements = animated_window_elements(state, &mut device.renderer, &output.output);
let effects =
frame_effect_metrics(&elements, output.output.current_scale().fractional_scale());
let effects = frame_effect_metrics(&elements, output.output.current_scale().fractional_scale());
let mut framebuffer = device.renderer.bind(&mut buffer)?;
let result = output.damage_tracker.render_output(
&mut device.renderer,
@@ -778,12 +707,10 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
[0.035, 0.04, 0.055, 1.0],
)?;
let cursorless_capture = state.has_pending_screencopy(&output.output, false);
let captured_with_cursor =
state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, true);
let captured_with_cursor = state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, true);
if cursorless_capture {
let cursorless_elements =
cursorless_window_elements(state, &mut device.renderer, &output.output);
let cursorless_elements = cursorless_window_elements(state, &mut device.renderer, &output.output);
redraw_output(
&mut device.renderer,
&mut framebuffer,
@@ -791,22 +718,13 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
&cursorless_elements,
)?;
state.process_screencopies(&mut device.renderer, &framebuffer, &output.output, false);
redraw_output(
&mut device.renderer,
&mut framebuffer,
&output.output,
&elements,
)?;
redraw_output(&mut device.renderer, &mut framebuffer, &output.output, &elements)?;
} else if captured_with_cursor {
let _ = device
.renderer
.render(
&mut framebuffer,
output
.output
.current_mode()
.expect("output has a mode")
.size,
output.output.current_mode().expect("output has a mode").size,
output.output.current_transform(),
)?
.finish()?;
@@ -818,34 +736,18 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
return Ok(false);
};
let mut presentation = OutputPresentationFeedback::new(&output.output);
state.space.elements().for_each(|window| {
window.take_presentation_feedback(
&mut presentation,
|_, _| Some(output.output.clone()),
|_, _| Kind::Vsync | Kind::HwClock | Kind::HwCompletion,
let presentation = crate::presentation::take_output_feedback(
state,
&output.output,
&result.states,
Kind::Vsync | Kind::HwClock | Kind::HwCompletion,
);
});
let layers = layer_map_for_output(&output.output)
.layers()
.cloned()
.collect::<Vec<_>>();
layers.iter().for_each(|layer| {
layer.take_presentation_feedback(
&mut presentation,
|_, _| Some(output.output.clone()),
|_, _| Kind::Vsync | Kind::HwClock | Kind::HwCompletion,
);
});
output
.surface
.queue_buffer(Some(result.sync), Some(damage.clone()), presentation)?;
output.render_metrics.record_frame(
render_started.elapsed(),
&damage,
missed_deadlines,
effects,
);
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)
@@ -893,10 +795,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
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);
if let Some(backend) = state.direct_backend.as_mut() {
backend.connected_outputs = scan.connected_outputs;
@@ -940,9 +839,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
Some(Scale::Fractional(output.settings.scale)),
Some((position[0], position[1]).into()),
);
state
.space
.map_output(&output.output, (position[0], position[1]));
state.space.map_output(&output.output, (position[0], position[1]));
if let Some(id) = state.output_id(&output.output)
&& let Some(g) = state.space.output_geometry(&output.output)
{
@@ -1045,10 +942,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
}
for crtc in deferred_removals {
let keep_internal = device
.outputs
.get(&crtc)
.is_some_and(|output| output.internal)
let keep_internal = device.outputs.get(&crtc).is_some_and(|output| output.internal)
&& lid_closed
&& state.direct_backend.as_ref().is_some_and(|backend| {
device.outputs.get(&crtc).is_some_and(|output| {
@@ -1060,10 +954,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
})
&& !device.outputs.values().any(|output| !output.internal);
if keep_internal {
tracing::warn!(
?crtc,
"keeping laptop panel enabled: external output activation failed"
);
tracing::warn!(?crtc, "keeping laptop panel enabled: external output activation failed");
if let Some(output) = device.outputs.get(&crtc)
&& let Some(backend) = state.direct_backend.as_mut()
&& let Some(info) = backend
@@ -1116,9 +1007,7 @@ fn remove_device(state: &mut Ferese, node: DrmNode) {
let context = device.renderer.context_id().erased();
state.wallpaper.forget_context(&context);
state
.resize_snapshots
.retain(|_, snapshot| snapshot.context != context);
state.resize_snapshots.retain(|_, snapshot| snapshot.context != context);
for (crtc, output) in device.outputs {
state.display_handle.disable_global::<Ferese>(output.global);
state.unregister_output(&output.output);
@@ -1170,9 +1059,10 @@ fn arm_animation_timer(state: &mut Ferese) {
match state
.loop_handle
.insert_source(Timer::from_deadline(deadline), |_, _, state| {
let active = state.direct_backend.as_ref().is_some_and(|backend| {
backend.active && backend.animation_active && !state.session_lock.sleeping
});
let active = state
.direct_backend
.as_ref()
.is_some_and(|backend| backend.active && backend.animation_active && !state.session_lock.sleeping);
if active {
let deadline = state.animation_fallback_deadline(animation_interval(state));
if Instant::now() < deadline {
@@ -1184,9 +1074,7 @@ fn arm_animation_timer(state: &mut Ferese) {
.as_ref()
.is_some_and(|backend| backend.animation_active)
{
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() {
@@ -1244,12 +1132,7 @@ fn schedule_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl
}
}
fn deliver_frame_callbacks(
state: &mut Ferese,
node: DrmNode,
crtc: crtc::Handle,
identity: &Output,
) {
fn deliver_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identity: &Output) {
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
@@ -1286,24 +1169,15 @@ fn send_frame_callbacks(state: &mut Ferese, output: &Output) {
.is_some_and(|id| state.window_belongs_to_output(*id, output))
})
.for_each(|window| {
window.send_frame(
output,
state.start_time.elapsed(),
Some(Duration::ZERO),
|_, _| Some(output.clone()),
);
window.send_frame(output, state.start_time.elapsed(), Some(Duration::ZERO), |_, _| {
Some(output.clone())
});
let layers = layer_map_for_output(output)
.layers()
.cloned()
.collect::<Vec<_>>();
});
let layers = layer_map_for_output(output).layers().cloned().collect::<Vec<_>>();
layers.iter().for_each(|layer| {
layer.send_frame(
output,
state.start_time.elapsed(),
Some(Duration::ZERO),
|_, _| Some(output.clone()),
);
layer.send_frame(output, state.start_time.elapsed(), Some(Duration::ZERO), |_, _| {
Some(output.clone())
});
});
state.send_cursor_frame(output);
state.space.refresh();
@@ -1365,12 +1239,7 @@ fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile]) -> io::Result<Out
profile: active_profile.map(|profile| profile.name.clone()),
physical_size: connector.size(),
current_mode: selected_mode.map(connected_mode_info),
available_modes: connector
.modes()
.iter()
.copied()
.map(connected_mode_info)
.collect(),
available_modes: connector.modes().iter().copied().map(connected_mode_info).collect(),
scale: settings.scale,
transform: settings.transform,
configured_position: settings.position,
@@ -1438,10 +1307,7 @@ fn default_output_settings(matcher: String) -> OutputSettings {
}
}
fn select_mode(
connector: &connector::Info,
requested: Option<OutputModeRequest>,
) -> Option<DrmMode> {
fn select_mode(connector: &connector::Info, requested: Option<OutputModeRequest>) -> Option<DrmMode> {
let fallback = || {
connector
.modes()
@@ -1460,24 +1326,11 @@ fn select_mode(
.filter(|mode| mode.size() == (requested.width, requested.height));
let selected = if let Some(refresh) = requested.refresh_millihertz {
matching
.min_by_key(|mode| {
OutputMode::from(**mode)
.refresh
.unsigned_abs()
.abs_diff(refresh)
})
.filter(|mode| {
OutputMode::from(**mode)
.refresh
.unsigned_abs()
.abs_diff(refresh)
<= 1_000
})
.min_by_key(|mode| OutputMode::from(**mode).refresh.unsigned_abs().abs_diff(refresh))
.filter(|mode| OutputMode::from(**mode).refresh.unsigned_abs().abs_diff(refresh) <= 1_000)
.copied()
} else {
matching
.max_by_key(|mode| OutputMode::from(**mode).refresh)
.copied()
matching.max_by_key(|mode| OutputMode::from(**mode).refresh).copied()
};
if selected.is_none() {
tracing::warn!(
@@ -1504,10 +1357,7 @@ fn create_direct_output(
) -> Result<DirectOutput, Box<dyn Error>> {
let identity = connector_identity(drm, &connector);
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(
drm_surface,
allocator,
@@ -1602,10 +1452,7 @@ fn output_transform(transform: OutputTransform) -> Transform {
}
fn connector_identity(drm: &DrmDevice, connector: &connector::Info) -> String {
let edid = drm
.get_properties(connector.handle())
.ok()
.and_then(|properties| {
let edid = drm.get_properties(connector.handle()).ok().and_then(|properties| {
properties.iter().find_map(|(handle, raw)| {
let info = drm.get_property(*handle).ok()?;
if info.name().to_bytes() != b"EDID" {
@@ -1654,23 +1501,16 @@ impl PresentationClock {
fn set_refresh(&mut self, refresh_millihertz: i32) {
if refresh_millihertz > 0 {
self.refresh_interval = Some(Duration::from_nanos(
1_000_000_000_000_u64 / refresh_millihertz as u64,
));
self.refresh_interval = Some(Duration::from_nanos(1_000_000_000_000_u64 / refresh_millihertz as u64));
}
}
fn record(&mut self, time: DrmEventTime, sequence: u32) -> Option<Duration> {
let DrmEventTime::Monotonic(time) = time else {
tracing::warn!(
sequence,
"DRM driver reported a realtime page-flip timestamp"
);
tracing::warn!(sequence, "DRM driver reported a realtime page-flip timestamp");
return None;
};
let delta = self
.last_presentation
.map(|previous| time.saturating_sub(previous));
let delta = self.last_presentation.map(|previous| time.saturating_sub(previous));
self.last_presentation = Some(time);
self.presented_frames = self.presented_frames.saturating_add(1);
@@ -1709,9 +1549,7 @@ mod tests {
#[test]
fn panel_detection_uses_connector_type_not_monitor_name() {
assert!(internal_connector(
connector::Interface::EmbeddedDisplayPort
));
assert!(internal_connector(connector::Interface::EmbeddedDisplayPort));
assert!(internal_connector(connector::Interface::LVDS));
assert!(internal_connector(connector::Interface::DSI));
assert!(!internal_connector(connector::Interface::HDMIA));
@@ -1744,10 +1582,7 @@ mod tests {
fn presentation_clock_rejects_realtime_timestamps() {
let mut clock = PresentationClock::default();
assert_eq!(
clock.record(DrmEventTime::Realtime(SystemTime::now()), 1),
None
);
assert_eq!(clock.record(DrmEventTime::Realtime(SystemTime::now()), 1), None);
assert_eq!(clock.presented_frames, 0);
}
@@ -1770,10 +1605,7 @@ mod tests {
clock.reset_timing();
assert_eq!(
clock.record(DrmEventTime::Monotonic(Duration::from_secs(10)), 2),
None
);
assert_eq!(clock.record(DrmEventTime::Monotonic(Duration::from_secs(10)), 2), None);
assert_eq!(clock.presented_frames, 2);
assert_eq!(clock.missed_deadlines, 0);
}
+10 -33
View File
@@ -4,7 +4,8 @@ use std::ffi::OsString;
use smithay::reexports::calloop::EventLoop;
use crate::{Ferese, private_client::ClientCapabilities};
use crate::Ferese;
use crate::private_client::ClientCapabilities;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BackendKind {
@@ -21,16 +22,12 @@ pub struct LaunchConfig {
impl LaunchConfig {
pub fn from_environment() -> Result<Self, String> {
let graphical_session =
std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some();
let graphical_session = std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some();
Self::parse(std::env::args_os().skip(1), graphical_session)
}
fn parse(
args: impl IntoIterator<Item = OsString>,
graphical_session: bool,
) -> Result<Self, String> {
fn parse(args: impl IntoIterator<Item = OsString>, graphical_session: bool) -> Result<Self, String> {
let mut args = args.into_iter().peekable();
let mut requested_backend = None;
let mut client = Vec::new();
@@ -99,10 +96,7 @@ fn parse_backend(value: &OsString) -> Result<Option<BackendKind>, String> {
Some("auto") => Ok(None),
Some("nested") => Ok(Some(BackendKind::Nested)),
Some("drm") => Ok(Some(BackendKind::Drm)),
_ => Err(format!(
"unknown backend {:?}; expected auto, nested, or drm",
value
)),
_ => Err(format!("unknown backend {:?}; expected auto, nested, or drm", value)),
}
}
@@ -161,42 +155,25 @@ mod tests {
.unwrap();
assert_eq!(config.client, [OsString::from("ferese-effects-probe")]);
assert!(
config
.client_capabilities
.contains(ClientCapabilities::EFFECTS)
);
assert!(config.client_capabilities.contains(ClientCapabilities::EFFECTS));
}
#[test]
fn explicitly_grants_shell_control_to_the_launched_private_client() {
let config = LaunchConfig::parse(
[
OsString::from("--grant-shell-control"),
OsString::from("ferese-shell"),
],
[OsString::from("--grant-shell-control"), OsString::from("ferese-shell")],
true,
)
.unwrap();
assert_eq!(config.client, [OsString::from("ferese-shell")]);
assert!(
config
.client_capabilities
.contains(ClientCapabilities::SHELL_CONTROL)
);
assert!(
!config
.client_capabilities
.contains(ClientCapabilities::EFFECTS)
);
assert!(config.client_capabilities.contains(ClientCapabilities::SHELL_CONTROL));
assert!(!config.client_capabilities.contains(ClientCapabilities::EFFECTS));
}
#[test]
fn rejects_an_unknown_backend() {
let error =
LaunchConfig::parse([OsString::from("--backend"), OsString::from("other")], true)
.unwrap_err();
let error = LaunchConfig::parse([OsString::from("--backend"), OsString::from("other")], true).unwrap_err();
assert!(error.contains("unknown backend"));
}
+107 -306
View File
@@ -1,9 +1,7 @@
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::fmt;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::{fmt, fs, io};
use ferese_animation::SpringConfig;
use ferese_core::LayoutMode;
@@ -11,7 +9,8 @@ use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy};
use serde::Deserialize;
use smithay::input::keyboard::{Keycode, keysyms, xkb};
use crate::window_rules::{self, WindowRule, WindowRuleConfig};
use crate::window_rules;
use crate::window_rules::{WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)]
pub struct Config {
@@ -163,11 +162,7 @@ impl BorderPaintConfig {
"focus_ring.gradient.angle",
)
} else {
(
"border.gradient.from",
"border.gradient.to",
"border.gradient.angle",
)
("border.gradient.from", "border.gradient.to", "border.gradient.angle")
};
self.gradient
.as_ref()
@@ -274,18 +269,10 @@ impl Config {
}
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
self.notifications
.validate()
.map_err(ConfigError::InvalidBinding)?;
self.desktop_widgets
.validate()
.map_err(ConfigError::InvalidBinding)?;
self.notifications.validate().map_err(ConfigError::InvalidBinding)?;
self.desktop_widgets.validate().map_err(ConfigError::InvalidBinding)?;
for daemon in &self.autostart {
if daemon
.command
.first()
.is_none_or(|program| program.trim().is_empty())
{
if daemon.command.first().is_none_or(|program| program.trim().is_empty()) {
return Err(ConfigError::InvalidBinding(
"autostart command must contain a program".into(),
));
@@ -510,15 +497,10 @@ impl Binding {
let symbol = parse_keysym(&key)?;
if (modifiers.ctrl
&& modifiers.alt
&& matches!(
symbol,
keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12
))
&& matches!(symbol, keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12))
|| (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol)
{
return Err(ConfigError::InvalidBinding(
"Reserved system shortcut".into(),
));
return Err(ConfigError::InvalidBinding("Reserved system shortcut".into()));
}
Ok(Self {
modifiers,
@@ -534,34 +516,19 @@ impl Binding {
match (&self.trigger, &other.trigger) {
(BindingTrigger::Keysym(left), BindingTrigger::Keysym(right)) => left == right,
(BindingTrigger::Keysym(symbol), BindingTrigger::Physical(code))
| (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => {
(0..map.num_layouts_for_key(*code)).any(|layout| {
| (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => (0..map.num_layouts_for_key(*code))
.any(|layout| {
map.key_get_syms_by_level(*code, layout, 0)
.iter()
.any(|key| key.raw() == *symbol)
})
}
}),
(BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right,
_ => false,
}
}
pub fn matches(
&self,
keycode: Keycode,
keysyms: &[u32],
logo: bool,
ctrl: bool,
alt: bool,
shift: bool,
) -> bool {
self.modifiers
== BindingModifiers {
logo,
ctrl,
alt,
shift,
}
pub fn matches(&self, keycode: Keycode, keysyms: &[u32], logo: bool, ctrl: bool, alt: bool, shift: bool) -> bool {
self.modifiers == BindingModifiers { logo, ctrl, alt, shift }
&& match self.trigger {
BindingTrigger::Keysym(expected) => keysyms.contains(&expected),
BindingTrigger::Physical(expected) => expected == keycode,
@@ -572,11 +539,7 @@ impl Binding {
pub(crate) fn guide_entry(&self, keymap: Option<&xkb::Keymap>) -> Option<serde_json::Value> {
let description = match &self.action {
BindingAction::None => return None,
BindingAction::Spawn(argv)
if argv
.first()
.is_some_and(|program| program == "ferese-screenshot") =>
{
BindingAction::Spawn(argv) if argv.first().is_some_and(|program| program == "ferese-screenshot") => {
if argv.iter().any(|arg| arg == "--full") {
"Capture whole screen"
} else {
@@ -598,12 +561,9 @@ impl Binding {
BindingAction::Resize(direction) => format!("Resize window {direction:?}"),
BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"),
BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"),
BindingAction::SwitchRelativeWorkspace(next) => if *next {
"Next workspace"
} else {
"Previous workspace"
BindingAction::SwitchRelativeWorkspace(next) => {
if *next { "Next workspace" } else { "Previous workspace" }.into()
}
.into(),
BindingAction::ToggleFullscreen => "Toggle fullscreen".into(),
BindingAction::ToggleMaximized => "Toggle full-width window".into(),
BindingAction::ToggleLayout => "Switch workspace layout".into(),
@@ -651,11 +611,7 @@ impl Binding {
matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count)
}
pub(crate) fn matches_swipe(
&self,
count: u32,
target: crate::gestures::SwipeDirection,
) -> bool {
pub(crate) fn matches_swipe(&self, count: u32, target: crate::gestures::SwipeDirection) -> bool {
matches!(self.trigger, BindingTrigger::Swipe { fingers, direction } if fingers == count && direction == target)
}
}
@@ -1022,11 +978,7 @@ impl Config {
}
pub fn scrolling_focus_strategy(&self) -> ViewportFocusStrategy {
match self
.scrolling
.focus_strategy
.unwrap_or(FocusStrategyValue::Minimal)
{
match self.scrolling.focus_strategy.unwrap_or(FocusStrategyValue::Minimal) {
FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal,
FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center,
FocusStrategyValue::Paged => ViewportFocusStrategy::Paged,
@@ -1078,10 +1030,7 @@ impl Config {
pub fn bindings(&self, input: &InputSettings) -> Result<Vec<Binding>, ConfigError> {
let mut commands = HashMap::from([
("terminal".to_owned(), vec!["foot".to_owned()]),
(
"screenshot".to_owned(),
vec!["ferese-screenshot".to_owned()],
),
("screenshot".to_owned(), vec!["ferese-screenshot".to_owned()]),
(
"screenshot-full".to_owned(),
vec!["ferese-screenshot".to_owned(), "--full".to_owned()],
@@ -1197,19 +1146,13 @@ impl Config {
}
pub fn theme_settings(&self) -> Result<ThemeSettings, ConfigError> {
let border_width =
nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?;
let focus_ring_width = nonnegative_theme_value(
self.theme.geometry.focus_ring_width,
"geometry.focus_ring_width",
)?;
let window_radius =
nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?;
let shadow_offset_y =
finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?;
let border_width = nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?;
let focus_ring_width =
nonnegative_theme_value(self.theme.geometry.focus_ring_width, "geometry.focus_ring_width")?;
let window_radius = nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?;
let shadow_offset_y = finite_theme_value(self.theme.shadow.soft.offset_y, "shadow.soft.offset_y")?;
let shadow_blur = nonnegative_theme_value(self.theme.shadow.soft.blur, "shadow.soft.blur")?;
let shadow_opacity =
unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?;
let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?;
let material_radius = nonnegative_theme_value(
self.theme
@@ -1224,25 +1167,16 @@ impl Config {
border_width,
focus_ring_width,
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
text_primary_color: parse_color(
&self.theme.colors.text_primary,
"colors.text_primary",
)?,
text_primary_color: parse_color(&self.theme.colors.text_primary, "colors.text_primary")?,
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
border_gradient: self.theme.border.settings("border")?,
focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?,
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
surface_base_color: parse_color(
&self.theme.colors.surface_base,
"colors.surface_base",
)?,
surface_base_color: parse_color(&self.theme.colors.surface_base, "colors.surface_base")?,
shell_opacity: unit_theme_value(self.theme.material.opacity, "material.opacity")?,
inactive_dim: InactiveDimSettings {
enabled: self.appearance.inactive_dim.enabled,
amount: unit_theme_value(
self.appearance.inactive_dim.amount,
"appearance.inactive_dim.amount",
)?,
amount: unit_theme_value(self.appearance.inactive_dim.amount, "appearance.inactive_dim.amount")?,
duration_ms: nonnegative_theme_value(
self.appearance.inactive_dim.duration_ms,
"appearance.inactive_dim.duration_ms",
@@ -1253,11 +1187,7 @@ impl Config {
shadow_blur,
shadow_opacity,
material_style: self.theme.material.style,
backdrop_blur: nonnegative_theme_value(
self.theme.material.blur_radius,
"material.blur_radius",
)?
.min(32.0),
backdrop_blur: nonnegative_theme_value(self.theme.material.blur_radius, "material.blur_radius")?.min(32.0),
material_radius,
panel_radius: material_radius,
})
@@ -1273,8 +1203,7 @@ impl Config {
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
let mass = positive_animation_value(self.animations.spring.mass, "spring.mass")?;
let stiffness =
positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?;
let stiffness = positive_animation_value(self.animations.spring.stiffness, "spring.stiffness")?;
let damping = self.animations.spring.damping;
if !damping.is_finite() || damping < 0.0 {
return Err(ConfigError::InvalidAnimationValue {
@@ -1292,12 +1221,9 @@ impl Config {
}
pub fn viewport_spring_config(&self) -> Result<SpringConfig, ConfigError> {
let mass =
positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?;
let stiffness = positive_animation_value(
self.animations.viewport_spring.stiffness,
"viewport_spring.stiffness",
)?;
let mass = positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?;
let stiffness =
positive_animation_value(self.animations.viewport_spring.stiffness, "viewport_spring.stiffness")?;
let damping_ratio = positive_animation_value(
self.animations.viewport_spring.damping_ratio,
"viewport_spring.damping_ratio",
@@ -1322,9 +1248,7 @@ struct BindingIdentity {
fn validate_commands(commands: &HashMap<String, Vec<String>>) -> Result<(), ConfigError> {
for (name, argv) in commands {
if name.trim().is_empty() {
return Err(ConfigError::InvalidBinding(
"command names cannot be empty".to_owned(),
));
return Err(ConfigError::InvalidBinding("command names cannot be empty".to_owned()));
}
if argv.is_empty() || argv[0].is_empty() {
return Err(ConfigError::InvalidBinding(format!(
@@ -1365,9 +1289,10 @@ fn parse_binding(
}
let identity = binding_identity(configured, keymap)?;
let action = configured.action.as_deref().ok_or_else(|| {
ConfigError::InvalidBinding(format!("binding {:?} has no action", configured.keys))
})?;
let action = configured
.action
.as_deref()
.ok_or_else(|| ConfigError::InvalidBinding(format!("binding {:?} has no action", configured.keys)))?;
let action = parse_action(action, configured.argument.as_deref(), commands)?;
let trigger = match identity.match_mode {
BindingMatch::Keysym => BindingTrigger::Keysym(identity.key),
@@ -1390,15 +1315,10 @@ fn parse_binding(
})
}
fn binding_identity(
configured: &BindingConfig,
keymap: &xkb::Keymap,
) -> Result<BindingIdentity, ConfigError> {
fn binding_identity(configured: &BindingConfig, keymap: &xkb::Keymap) -> Result<BindingIdentity, ConfigError> {
let (modifiers, key) = parse_chord(&configured.keys)?;
if let Some((fingers, direction)) = parse_swipe(&key) {
if configured.match_mode == BindingMatch::Physical
|| modifiers != BindingModifiers::default()
{
if configured.match_mode == BindingMatch::Physical || modifiers != BindingModifiers::default() {
return Err(ConfigError::InvalidBinding(
"swipes cannot use keyboard modifiers or physical key matching".into(),
));
@@ -1438,9 +1358,7 @@ fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity {
let (match_mode, key) = match binding.trigger {
BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key),
BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()),
BindingTrigger::Swipe { fingers, direction } => {
(BindingMatch::Swipe, fingers * 4 + direction as u32)
}
BindingTrigger::Swipe { fingers, direction } => (BindingMatch::Swipe, fingers * 4 + direction as u32),
};
BindingIdentity {
@@ -1456,9 +1374,7 @@ fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), ConfigError> {
for component in chord.split('+').map(str::trim) {
if component.is_empty() {
return Err(ConfigError::InvalidBinding(format!(
"invalid key chord {chord:?}"
)));
return Err(ConfigError::InvalidBinding(format!("invalid key chord {chord:?}")));
}
let slot = match component.to_ascii_lowercase().as_str() {
@@ -1470,9 +1386,7 @@ fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), ConfigError> {
};
if let Some(slot) = slot {
if *slot {
return Err(ConfigError::InvalidBinding(format!(
"duplicate modifier in {chord:?}"
)));
return Err(ConfigError::InvalidBinding(format!("duplicate modifier in {chord:?}")));
}
*slot = true;
} else if key.replace(component.to_owned()).is_some() {
@@ -1482,9 +1396,7 @@ fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), ConfigError> {
}
}
let key = key.ok_or_else(|| {
ConfigError::InvalidBinding(format!("key chord {chord:?} does not contain a key"))
})?;
let key = key.ok_or_else(|| ConfigError::InvalidBinding(format!("key chord {chord:?} does not contain a key")))?;
Ok((modifiers, key))
}
@@ -1520,9 +1432,7 @@ fn parse_keysym(name: &str) -> Result<u32, ConfigError> {
symbol = xkb::keysym_from_name(&normalized, xkb::KEYSYM_CASE_INSENSITIVE);
}
if symbol.raw() == keysyms::KEY_NoSymbol {
Err(ConfigError::InvalidBinding(format!(
"unknown keysym {name:?}"
)))
Err(ConfigError::InvalidBinding(format!("unknown keysym {name:?}")))
} else {
Ok(symbol.raw())
}
@@ -1542,11 +1452,8 @@ fn parse_action(
Ok(())
}
};
let required_argument = || {
argument.ok_or_else(|| {
ConfigError::InvalidBinding(format!("action {action:?} requires an argument"))
})
};
let required_argument =
|| argument.ok_or_else(|| ConfigError::InvalidBinding(format!("action {action:?} requires an argument")));
match action {
"none" => {
@@ -1555,15 +1462,13 @@ fn parse_action(
}
"workspace-next" | "workspace-previous" => {
no_argument()?;
Ok(BindingAction::SwitchRelativeWorkspace(
action == "workspace-next",
))
Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next"))
}
"spawn" => {
let command = required_argument()?;
let argv = commands.get(command).ok_or_else(|| {
ConfigError::InvalidBinding(format!("unknown command {command:?}"))
})?;
let argv = commands
.get(command)
.ok_or_else(|| ConfigError::InvalidBinding(format!("unknown command {command:?}")))?;
Ok(BindingAction::Spawn(argv.clone()))
}
"close" => {
@@ -1576,15 +1481,9 @@ fn parse_action(
}
"focus" => Ok(BindingAction::Focus(parse_direction(required_argument()?)?)),
"move" => Ok(BindingAction::Move(parse_direction(required_argument()?)?)),
"resize" => Ok(BindingAction::Resize(
parse_direction(required_argument()?)?,
)),
"workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace(
required_argument()?,
)?)),
"move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(
required_argument()?,
)?)),
"resize" => Ok(BindingAction::Resize(parse_direction(required_argument()?)?)),
"workspace" => Ok(BindingAction::SwitchWorkspace(parse_workspace(required_argument()?)?)),
"move-to-workspace" => Ok(BindingAction::MoveToWorkspace(parse_workspace(required_argument()?)?)),
"toggle-maximized" => {
no_argument()?;
Ok(BindingAction::ToggleMaximized)
@@ -1621,9 +1520,7 @@ fn parse_action(
no_argument()?;
Ok(BindingAction::ToggleOverview)
}
_ => Err(ConfigError::InvalidBinding(format!(
"unknown action {action:?}"
))),
_ => Err(ConfigError::InvalidBinding(format!("unknown action {action:?}"))),
}
}
@@ -1633,18 +1530,14 @@ fn parse_direction(argument: &str) -> Result<Direction, ConfigError> {
"right" => Ok(Direction::Right),
"up" => Ok(Direction::Up),
"down" => Ok(Direction::Down),
_ => Err(ConfigError::InvalidBinding(format!(
"invalid direction {argument:?}"
))),
_ => Err(ConfigError::InvalidBinding(format!("invalid direction {argument:?}"))),
}
}
fn parse_workspace(argument: &str) -> Result<u8, ConfigError> {
match argument.parse() {
Ok(workspace) if workspace > 0 => Ok(workspace),
_ => Err(ConfigError::InvalidBinding(format!(
"invalid workspace {argument:?}"
))),
_ => Err(ConfigError::InvalidBinding(format!("invalid workspace {argument:?}"))),
}
}
@@ -1672,25 +1565,13 @@ fn default_bindings() -> Vec<BindingConfig> {
for (key, direction) in [("H", "left"), ("J", "down"), ("K", "up"), ("L", "right")] {
bindings.push(binding("Super+".to_owned() + key, "focus", Some(direction)));
bindings.push(binding(
"Super+Shift+".to_owned() + key,
"move",
Some(direction),
));
bindings.push(binding(
"Super+Ctrl+".to_owned() + key,
"resize",
Some(direction),
));
bindings.push(binding("Super+Shift+".to_owned() + key, "move", Some(direction)));
bindings.push(binding("Super+Ctrl+".to_owned() + key, "resize", Some(direction)));
}
for workspace in 1..=9 {
let workspace = workspace.to_string();
bindings.push(binding(
format!("Super+{workspace}"),
"workspace",
Some(&workspace),
));
bindings.push(binding(format!("Super+{workspace}"), "workspace", Some(&workspace)));
bindings.push(binding(
format!("Super+Shift+{workspace}"),
"move-to-workspace",
@@ -1701,11 +1582,7 @@ fn default_bindings() -> Vec<BindingConfig> {
bindings
}
fn binding(
keys: impl Into<String>,
action: impl Into<String>,
argument: Option<&str>,
) -> BindingConfig {
fn binding(keys: impl Into<String>, action: impl Into<String>, argument: Option<&str>) -> BindingConfig {
BindingConfig {
keys: keys.into(),
match_mode: BindingMatch::Keysym,
@@ -1909,9 +1786,7 @@ fn parse_output_mode(value: &str) -> Result<OutputModeRequest, String> {
let (size, refresh) = value
.trim()
.split_once('@')
.map_or((value.trim(), None), |(size, refresh)| {
(size, Some(refresh))
});
.map_or((value.trim(), None), |(size, refresh)| (size, Some(refresh)));
let (width, height) = size
.split_once('x')
.ok_or_else(|| format!("mode {value:?} must use WIDTHxHEIGHT or WIDTHxHEIGHT@REFRESH"))?;
@@ -1944,17 +1819,10 @@ fn parse_output_mode(value: &str) -> Result<OutputModeRequest, String> {
})
}
fn parse_column_width(
value: &ColumnWidthValue,
field: &'static str,
) -> Result<ColumnWidth, ConfigError> {
fn parse_column_width(value: &ColumnWidthValue, field: &'static str) -> Result<ColumnWidth, ConfigError> {
match value {
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => {
Ok(ColumnWidth::Proportion(*value))
}
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => {
Ok(ColumnWidth::Full)
}
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => Ok(ColumnWidth::Proportion(*value)),
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => Ok(ColumnWidth::Full),
value => Err(ConfigError::InvalidColumnWidth {
field,
value: match value {
@@ -1982,15 +1850,9 @@ mod tests {
let source = "input {\n touchpad {\n swipe-threshold 96\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\noutput-profile name=\"desk\" {\n output match=\"DP-1\" scale=1.5 {\n position 0 0\n }\n}\ndesktop-widgets {\n clock {\n enabled #true\n outputs \"DP-1\"\n }\n}\nautostart {\n command \"program\" \"argument with space\"\n}\n";
let config = Config::parse_source(source).unwrap();
config.runtime_config().unwrap();
assert_eq!(
config.input_settings().unwrap().touchpad.swipe_threshold,
96
);
assert_eq!(config.input_settings().unwrap().touchpad.swipe_threshold, 96);
assert_eq!(config.desktop_widgets.clock.outputs, ["DP-1"]);
assert_eq!(
config.output_profiles().unwrap()[0].outputs[0].position,
Some([0, 0])
);
assert_eq!(config.output_profiles().unwrap()[0].outputs[0].position, Some([0, 0]));
}
#[test]
@@ -2026,10 +1888,7 @@ mod tests {
let config = parse("");
assert_eq!(config.layout_mode(), LayoutMode::Scrolling);
assert_eq!(
config.default_column_width().unwrap(),
ColumnWidth::Proportion(0.5)
);
assert_eq!(config.default_column_width().unwrap(), ColumnWidth::Proportion(0.5));
assert_eq!(config.gap_config().unwrap(), GapConfig::default());
}
@@ -2039,10 +1898,7 @@ mod tests {
let numeric = parse("scrolling {\n default-column-width 1.0\n}\n");
assert_eq!(full.default_column_width().unwrap(), ColumnWidth::Full);
assert_eq!(
numeric.default_column_width().unwrap(),
ColumnWidth::Proportion(1.0)
);
assert_eq!(numeric.default_column_width().unwrap(), ColumnWidth::Proportion(1.0));
}
#[test]
@@ -2066,19 +1922,10 @@ mod tests {
let minimal = parse("");
let centered = parse("scrolling {\n focus-strategy \"center_on_focus\"\n}\n");
let paged = parse("scrolling {\n focus-strategy \"paged\"\n}\n");
assert_eq!(
paged.scrolling_focus_strategy(),
ViewportFocusStrategy::Paged
);
assert_eq!(paged.scrolling_focus_strategy(), ViewportFocusStrategy::Paged);
assert_eq!(
minimal.scrolling_focus_strategy(),
ViewportFocusStrategy::Minimal
);
assert_eq!(
centered.scrolling_focus_strategy(),
ViewportFocusStrategy::Center
);
assert_eq!(minimal.scrolling_focus_strategy(), ViewportFocusStrategy::Minimal);
assert_eq!(centered.scrolling_focus_strategy(), ViewportFocusStrategy::Center);
}
#[test]
@@ -2120,8 +1967,7 @@ mod tests {
#[test]
fn parses_and_validates_layout_gaps() {
let configured =
parse("layout {\n inner-gap 6.0\n outer-gap 14.0\n smart-gaps #true\n}\n");
let configured = parse("layout {\n inner-gap 6.0\n outer-gap 14.0\n smart-gaps #true\n}\n");
let invalid = parse("layout {\n outer-gap -1.0\n}\n");
assert_eq!(
@@ -2159,11 +2005,9 @@ mod tests {
"theme {\n colors {\n border \"#11223344\"\n accent \"#AABBCC\"\n shadow \"#01020380\"\n }\n geometry {\n border-width 1.5\n focus-ring-width 3.0\n window-radius 12.0\n }\n shadow {\n soft {\n offset-y -2.0\n blur 24.0\n opacity 0.4\n }\n }\n material {\n style \"translucent\"\n }\n}\n",
);
let invalid_color = parse("theme {\n colors {\n accent \"blue\"\n }\n}\n");
let invalid_radius =
parse("theme {\n geometry {\n window-radius -1.0\n }\n}\n");
let invalid_opacity = parse(
"theme {\n shadow {\n soft {\n opacity 1.1\n }\n }\n}\n",
);
let invalid_radius = parse("theme {\n geometry {\n window-radius -1.0\n }\n}\n");
let invalid_opacity =
parse("theme {\n shadow {\n soft {\n opacity 1.1\n }\n }\n}\n");
assert_eq!(
configured.theme_settings().unwrap(),
@@ -2197,24 +2041,15 @@ mod tests {
assert!(invalid_radius.theme_settings().is_err());
assert!(invalid_opacity.theme_settings().is_err());
assert!(
ferese_config::from_str::<Config>(
"theme {\n material {\n style \"mist\"\n }\n}\n"
)
.is_err()
ferese_config::from_str::<Config>("theme {\n material {\n style \"mist\"\n }\n}\n").is_err()
);
}
#[test]
fn materials_default_to_solid_and_reject_removed_style() {
assert_eq!(
parse("").theme_settings().unwrap().material_style,
MaterialStyle::Solid
);
assert_eq!(parse("").theme_settings().unwrap().material_style, MaterialStyle::Solid);
assert!(
ferese_config::from_str::<Config>(
"theme {\n material {\n style \"glass\"\n }\n}\n"
)
.is_err()
ferese_config::from_str::<Config>("theme {\n material {\n style \"glass\"\n }\n}\n").is_err()
);
}
@@ -2249,14 +2084,17 @@ mod tests {
"from \"#112233\"\nto \"#445566\"\nangle #inf\n",
] {
assert!(
Config::parse_source(&format!(
"theme {{ focus-ring {{ gradient {{\n{settings}\n}} }} }}"
))
Config::parse_source(&format!("theme {{ focus-ring {{ gradient {{\n{settings}\n}} }} }}"))
.and_then(|config| config.theme_settings())
.is_err()
);
}
assert!(ferese_config::from_str::<Config>("theme {\n border {\n gradient {\n from \"#112233\"\n }\n }\n}\n").is_err());
assert!(
ferese_config::from_str::<Config>(
"theme {\n border {\n gradient {\n from \"#112233\"\n }\n }\n}\n"
)
.is_err()
);
}
#[test]
@@ -2336,9 +2174,7 @@ mod tests {
("duration_ms", "#inf"),
] {
assert!(
Config::parse_source(&format!(
"appearance {{ inactive-dim {{ {key} {value}; }} }}"
))
Config::parse_source(&format!("appearance {{ inactive-dim {{ {key} {value}; }} }}"))
.and_then(|config| config.theme_settings())
.is_err()
);
@@ -2409,9 +2245,7 @@ mod tests {
);
for threshold in [0, 15, 1001] {
assert!(
parse(&format!(
"input {{ touchpad {{ swipe-threshold {threshold}; }} }}"
))
parse(&format!("input {{ touchpad {{ swipe-threshold {threshold}; }} }}"))
.input_settings()
.is_err()
);
@@ -2435,9 +2269,8 @@ mod tests {
#[test]
fn guide_uses_effective_bindings_including_overrides_and_unbindings() {
let config = parse(
"binding keys=\"Super+Q\" action=\"none\"\nbinding keys=\"Super+Tab\" action=\"toggle-floating\"\n",
);
let config =
parse("binding keys=\"Super+Q\" action=\"none\"\nbinding keys=\"Super+Tab\" action=\"toggle-floating\"\n");
let input = config.input_settings().unwrap();
let map = physical_keymap(&input).unwrap();
let entries = config
@@ -2446,13 +2279,12 @@ mod tests {
.iter()
.filter_map(|binding| binding.guide_entry(Some(&map)))
.collect::<Vec<_>>();
assert!(!entries.iter().any(|entry| entry["description"] == "Close window"));
assert!(
!entries
entries
.iter()
.any(|entry| entry["description"] == "Close window")
.any(|entry| entry["keys"] == "Super + Tab" && entry["description"] == "Toggle floating window")
);
assert!(entries.iter().any(|entry| entry["keys"] == "Super + Tab"
&& entry["description"] == "Toggle floating window"));
assert!(entries.iter().any(|entry| entry["keys"] == "Super + Enter"));
}
@@ -2472,11 +2304,7 @@ mod tests {
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_f)
&& binding.action == action));
}
assert!(
bindings
.iter()
.any(|binding| binding.action == BindingAction::Exit)
);
assert!(bindings.iter().any(|binding| binding.action == BindingAction::Exit));
assert!(bindings.iter().any(|binding| {
binding.modifiers.logo
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return)
@@ -2491,11 +2319,7 @@ mod tests {
assert!(bindings.iter().any(|binding| {
binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Print)
&& binding.modifiers == BindingModifiers::default()
&& binding.action
== BindingAction::Spawn(vec![
"ferese-screenshot".to_owned(),
"--full".to_owned(),
])
&& binding.action == BindingAction::Spawn(vec!["ferese-screenshot".to_owned(), "--full".to_owned()])
}));
assert!(bindings.iter().any(|binding| {
binding.modifiers.logo
@@ -2515,29 +2339,18 @@ mod tests {
let bindings = replaced.bindings(&input).unwrap();
assert_eq!(bindings.len(), 48);
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 bindings = unbound.bindings(&input).unwrap();
assert_eq!(bindings.len(), 47);
assert!(
!bindings
.iter()
.any(|binding| binding.action == BindingAction::Close)
);
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
}
#[test]
fn accepts_physical_xkb_key_names() {
let config = parse(
"binding keys=\"Super+AD06\" match=\"physical\" action=\"focus\" argument=\"left\"\n",
);
let config = parse("binding keys=\"Super+AD06\" match=\"physical\" action=\"focus\" argument=\"left\"\n");
let input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap();
@@ -2549,13 +2362,9 @@ mod tests {
#[test]
fn rejects_duplicate_or_invalid_user_bindings() {
let duplicate = parse(
"binding keys=\"Super+Q\" action=\"close\"\nbinding keys=\"logo+q\" action=\"close\"\n",
);
let missing_command =
parse("binding keys=\"Super+Enter\" action=\"spawn\" argument=\"missing\"\n");
let invalid_argument =
parse("binding keys=\"Super+Q\" action=\"close\" argument=\"left\"\n");
let duplicate = parse("binding keys=\"Super+Q\" action=\"close\"\nbinding keys=\"logo+q\" action=\"close\"\n");
let missing_command = parse("binding keys=\"Super+Enter\" action=\"spawn\" argument=\"missing\"\n");
let invalid_argument = parse("binding keys=\"Super+Q\" action=\"close\" argument=\"left\"\n");
for config in [duplicate, missing_command, invalid_argument] {
let input = config.input_settings().unwrap();
@@ -2569,12 +2378,7 @@ mod tests {
"window-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 fullscreen=#false\n",
);
let rules = config.window_rules().unwrap();
let result = window_rules::resolve(
&rules,
Some("org.example.editor.desktop"),
Some("Document"),
false,
);
let result = window_rules::resolve(&rules, Some("org.example.editor.desktop"), Some("Document"), false);
assert_eq!(result.workspace, Some(3));
assert_eq!(result.floating, Some(true));
@@ -2640,13 +2444,10 @@ mod tests {
#[test]
fn rejects_invalid_output_profiles() {
let invalid_scale =
parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" scale=0.0\n}\n");
let invalid_mode =
parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" mode=\"native\"\n}\n");
let duplicate = parse(
"output-profile name=\"bad\" {\n output match=\"eDP-1\"\n output match=\"eDP-1\"\n}\n",
);
let invalid_scale = parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" scale=0.0\n}\n");
let invalid_mode = parse("output-profile name=\"bad\" {\n output match=\"eDP-1\" mode=\"native\"\n}\n");
let duplicate =
parse("output-profile name=\"bad\" {\n output match=\"eDP-1\"\n output match=\"eDP-1\"\n}\n");
assert!(invalid_scale.output_profiles().is_err());
assert!(invalid_mode.output_profiles().is_err());
+13 -30
View File
@@ -1,11 +1,12 @@
use std::{fs, sync::OnceLock};
use std::fs;
use std::sync::OnceLock;
use smithay::{
backend::{allocator::Fourcc, renderer::element::memory::MemoryRenderBuffer},
input::pointer::CursorIcon,
utils::{Buffer, Point, Size, Transform},
};
use xcursor::{CursorTheme, parser::Image};
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::element::memory::MemoryRenderBuffer;
use smithay::input::pointer::CursorIcon;
use smithay::utils::{Buffer, Point, Size, Transform};
use xcursor::CursorTheme;
use xcursor::parser::Image;
const DEFAULT_CURSOR_SIZE: u32 = 24;
@@ -58,9 +59,7 @@ fn load_image(theme: &CursorTheme, name: &str, requested_size: u32) -> Option<Im
let path = theme.load_icon(name)?;
let bytes = fs::read(path).ok()?;
xcursor::parser::parse_xcursor(&bytes)?
.into_iter()
.min_by_key(|image| {
xcursor::parser::parse_xcursor(&bytes)?.into_iter().min_by_key(|image| {
(
image.size.abs_diff(requested_size),
image.width.abs_diff(requested_size),
@@ -70,14 +69,7 @@ fn load_image(theme: &CursorTheme, name: &str, requested_size: u32) -> Option<Im
fn cursor_from_image(image: Image) -> NamedCursor {
let size = Size::<i32, Buffer>::from((image.width as i32, image.height as i32));
let buffer = MemoryRenderBuffer::from_slice(
&image.pixels_rgba,
Fourcc::Abgr8888,
size,
1,
Transform::Normal,
None,
);
let buffer = MemoryRenderBuffer::from_slice(&image.pixels_rgba, Fourcc::Abgr8888, size, 1, Transform::Normal, None);
NamedCursor {
buffer,
@@ -122,18 +114,9 @@ mod tests {
#[test]
fn supplies_legacy_fallback_names_for_common_cursors() {
assert_eq!(
cursor_names(CursorIcon::Default),
["default", "left_ptr", "arrow"]
);
assert_eq!(
cursor_names(CursorIcon::Pointer),
["pointer", "hand2", "left_ptr"]
);
assert_eq!(
cursor_names(CursorIcon::Text),
["text", "xterm", "left_ptr"]
);
assert_eq!(cursor_names(CursorIcon::Default), ["default", "left_ptr", "arrow"]);
assert_eq!(cursor_names(CursorIcon::Pointer), ["pointer", "hand2", "left_ptr"]);
assert_eq!(cursor_names(CursorIcon::Text), ["text", "xterm", "left_ptr"]);
}
#[test]
+10 -24
View File
@@ -1,9 +1,10 @@
//! Session-owned services. Never grant shell/effects privileges to daemons.
use crate::{Ferese, config::DaemonConfig, private_client::ClientCapabilities};
use std::{
process::Child,
time::{Duration, Instant},
};
use std::process::Child;
use std::time::{Duration, Instant};
use crate::Ferese;
use crate::config::DaemonConfig;
use crate::private_client::ClientCapabilities;
struct Service {
config: DaemonConfig,
@@ -17,15 +18,8 @@ pub(crate) struct Runner(Vec<Service>);
impl Runner {
pub fn reconcile(&mut self, configs: &[DaemonConfig], nested: bool) {
let mut previous = std::mem::take(&mut self.0);
for config in configs
.iter()
.filter(|c| c.enabled && (!nested || c.nested))
{
if self
.0
.iter()
.any(|service| service.config.command == config.command)
{
for config in configs.iter().filter(|c| c.enabled && (!nested || c.nested)) {
if self.0.iter().any(|service| service.config.command == config.command) {
continue;
}
if let Some(index) = previous
@@ -132,12 +126,7 @@ mod tests {
nested: false,
};
let mut runner = Runner::new(&[config.clone()], false);
runner.0[0].child = Some(
std::process::Command::new("/bin/sleep")
.arg("30")
.spawn()
.unwrap(),
);
runner.0[0].child = Some(std::process::Command::new("/bin/sleep").arg("30").spawn().unwrap());
let pid = runner.0[0].child.as_ref().unwrap().id();
config.restart = true;
runner.reconcile(&[config.clone()], false);
@@ -150,10 +139,7 @@ mod tests {
assert!(runner.0.is_empty());
let deadline = Instant::now() + Duration::from_secs(2);
while unsafe { libc::kill(pid as i32, 0) } == 0 {
assert!(
Instant::now() < deadline,
"removed service was not stopped/reaped"
);
assert!(Instant::now() < deadline, "removed service was not stopped/reaped");
std::thread::sleep(Duration::from_millis(10));
}
}
+5 -7
View File
@@ -1,7 +1,9 @@
use crate::config::InactiveDimSettings;
use ferese_layout::WindowId;
use std::time::Duration;
use ferese_layout::WindowId;
use crate::config::InactiveDimSettings;
pub(crate) fn target(
settings: InactiveDimSettings,
focused: Option<WindowId>,
@@ -60,11 +62,7 @@ impl DimAnimation {
pub fn advance_visual(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
self.advance(
target,
if self.target != target {
Duration::ZERO
} else {
delta
},
if self.target != target { Duration::ZERO } else { delta },
duration_ms,
)
}
+29 -72
View File
@@ -1,27 +1,23 @@
use std::sync::{
Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use ferese_protocols::effects::v1::server::{
ferese_effects_manager_v1::{self, FereseEffectsManagerV1},
ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1},
};
use ferese_protocols::material::v1::server::{
ferese_material_manager_v1::{self, FereseMaterialManagerV1},
ferese_surface_material_v1::{self, FereseSurfaceMaterialV1},
};
use smithay::{
reexports::wayland_server::{
use ferese_protocols::effects::v1::server::ferese_effects_manager_v1::FereseEffectsManagerV1;
use ferese_protocols::effects::v1::server::ferese_surface_effects_v1::FereseSurfaceEffectsV1;
use ferese_protocols::effects::v1::server::{ferese_effects_manager_v1, ferese_surface_effects_v1};
use ferese_protocols::material::v1::server::ferese_material_manager_v1::FereseMaterialManagerV1;
use ferese_protocols::material::v1::server::ferese_surface_material_v1::FereseSurfaceMaterialV1;
use ferese_protocols::material::v1::server::{ferese_material_manager_v1, ferese_surface_material_v1};
use smithay::reexports::wayland_server::backend::ClientId;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::reexports::wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, WEnum, Weak,
backend::ClientId, protocol::wl_surface::WlSurface,
},
wayland::compositor::with_states,
};
use smithay::wayland::compositor::with_states;
use crate::{
Ferese, config::MaterialStyle, private_client::ClientCapabilities, state::ClientState,
};
use crate::Ferese;
use crate::config::MaterialStyle;
use crate::private_client::ClientCapabilities;
use crate::state::ClientState;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SemanticRole {
@@ -41,16 +37,10 @@ pub(crate) struct ResolvedMaterial {
pub shadow: [f64; 3],
}
pub(crate) fn resolve_material(
role: SemanticRole,
style: MaterialStyle,
opacity: f32,
) -> ResolvedMaterial {
pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle, opacity: f32) -> ResolvedMaterial {
let shadow = match role {
SemanticRole::Panel => [1.0, 5.0, 0.04],
SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => {
[2.0, 8.0, 0.07]
}
SemanticRole::PanelElevated | SemanticRole::Popover | SemanticRole::Menu => [2.0, 8.0, 0.07],
SemanticRole::Hud => [1.0, 4.0, 0.04],
SemanticRole::Notification | SemanticRole::Modal => [3.0, 10.0, 0.09],
};
@@ -58,11 +48,7 @@ pub(crate) fn resolve_material(
MaterialStyle::Solid => 1.0,
MaterialStyle::Translucent => opacity.clamp(0.0, 1.0),
};
ResolvedMaterial {
style,
opacity,
shadow,
}
ResolvedMaterial { style, opacity, shadow }
}
#[derive(Debug)]
@@ -145,9 +131,7 @@ impl Dispatch<FereseMaterialManagerV1, ()> for Ferese {
match request {
ferese_material_manager_v1::Request::GetSurfaceMaterial { id, surface } => {
let attached = with_states(&surface, |states| {
states
.data_map
.insert_if_missing_threadsafe(SurfaceEffectsState::new);
states.data_map.insert_if_missing_threadsafe(SurfaceEffectsState::new);
let effects = states.data_map.get::<SurfaceEffectsState>().unwrap();
effects.attached.swap(true, Ordering::AcqRel)
});
@@ -220,12 +204,8 @@ fn decode_regions(bytes: &[u8]) -> Option<Vec<[i32; 5]>> {
.0
.iter()
.map(|tuple| {
let values: [i32; 5] =
std::array::from_fn(|index| i32::from_ne_bytes(tuple.as_chunks::<4>().0[index]));
(values[2] > 0
&& values[3] > 0
&& values[4] >= 0
&& values.iter().all(|v| v.abs_diff(0) <= 32768))
let values: [i32; 5] = std::array::from_fn(|index| i32::from_ne_bytes(tuple.as_chunks::<4>().0[index]));
(values[2] > 0 && values[3] > 0 && values[4] >= 0 && values.iter().all(|v| v.abs_diff(0) <= 32768))
.then_some(values)
})
.collect()
@@ -273,9 +253,7 @@ impl Dispatch<FereseEffectsManagerV1, ()> for Ferese {
match request {
ferese_effects_manager_v1::Request::GetSurfaceEffects { id, surface } => {
let already_attached = with_states(&surface, |states| {
states
.data_map
.insert_if_missing_threadsafe(SurfaceEffectsState::new);
states.data_map.insert_if_missing_threadsafe(SurfaceEffectsState::new);
states
.data_map
.get::<SurfaceEffectsState>()
@@ -346,14 +324,10 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
ferese_surface_effects_v1::Request::SetRole { role } => {
let role = match role {
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
WEnum::Value(ferese_surface_effects_v1::Role::PanelElevated) => {
SemanticRole::PanelElevated
}
WEnum::Value(ferese_surface_effects_v1::Role::PanelElevated) => SemanticRole::PanelElevated,
WEnum::Value(ferese_surface_effects_v1::Role::Popover) => SemanticRole::Popover,
WEnum::Value(ferese_surface_effects_v1::Role::Menu) => SemanticRole::Menu,
WEnum::Value(ferese_surface_effects_v1::Role::Notification) => {
SemanticRole::Notification
}
WEnum::Value(ferese_surface_effects_v1::Role::Notification) => SemanticRole::Notification,
WEnum::Value(ferese_surface_effects_v1::Role::Hud) => SemanticRole::Hud,
WEnum::Value(ferese_surface_effects_v1::Role::Modal) => SemanticRole::Modal,
WEnum::Unknown(_) | WEnum::Value(_) => return,
@@ -437,18 +411,13 @@ pub(crate) fn surface_opacity(surface: &WlSurface) -> f32 {
states
.data_map
.get::<SurfaceEffectsState>()
.map_or(1.0, |effects| {
*effects.opacity.lock().unwrap() as f32 / 1000.0
})
.map_or(1.0, |effects| *effects.opacity.lock().unwrap() as f32 / 1000.0)
})
}
pub(crate) fn begin_surface_dismiss(surface: &WlSurface) -> bool {
with_states(surface, |states| {
states
.data_map
.get::<SurfaceEffectsState>()
.is_some_and(|effects| {
states.data_map.get::<SurfaceEffectsState>().is_some_and(|effects| {
effects.presentation_supported.load(Ordering::Acquire)
&& effects.attached.load(Ordering::Acquire)
&& !effects.dismissing.swap(true, Ordering::AcqRel)
@@ -477,20 +446,8 @@ mod tests {
assert_eq!(decode_regions(&[]), Some(vec![]));
assert!(decode_regions(&bytes[..19]).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],
] {
assert!(
decode_regions(
&invalid
.into_iter()
.flat_map(i32::to_ne_bytes)
.collect::<Vec<_>>()
)
.is_none()
);
for invalid in [[0_i32, 0, 0, 48, 11], [0, 0, 120, 48, -1], [i32::MIN, 0, 120, 48, 11]] {
assert!(decode_regions(&invalid.into_iter().flat_map(i32::to_ne_bytes).collect::<Vec<_>>()).is_none());
}
}
+9 -22
View File
@@ -1,14 +1,11 @@
use smithay::{
desktop::Window,
input::pointer::{
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent,
GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent,
GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData, MotionEvent, PointerGrab,
PointerInnerHandle, RelativeMotionEvent,
},
reexports::wayland_server::protocol::wl_surface::WlSurface,
utils::{Logical, Point, Size},
use smithay::desktop::Window;
use smithay::input::pointer::{
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent,
GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData,
MotionEvent, PointerGrab, PointerInnerHandle, RelativeMotionEvent,
};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Size};
use crate::Ferese;
@@ -44,12 +41,7 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
handle.relative_motion(data, focus, event);
}
fn button(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &ButtonEvent,
) {
fn button(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, event: &ButtonEvent) {
handle.button(data, event);
if handle.current_pressed().is_empty() {
self.finished = true;
@@ -57,12 +49,7 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
}
}
fn axis(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
frame: AxisFrame,
) {
fn axis(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, frame: AxisFrame) {
handle.axis(data, frame);
}
+23 -69
View File
@@ -1,20 +1,16 @@
use std::cell::RefCell;
use smithay::{
desktop::Window,
input::pointer::{
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent,
GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent,
GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData, MotionEvent, PointerGrab,
PointerInnerHandle, RelativeMotionEvent,
},
reexports::{
wayland_protocols::xdg::shell::server::xdg_toplevel,
wayland_server::protocol::wl_surface::WlSurface,
},
utils::{Logical, Point, Rectangle, Size},
wayland::{compositor::with_states, shell::xdg::SurfaceCachedState},
use smithay::desktop::Window;
use smithay::input::pointer::{
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent,
GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent, GestureSwipeUpdateEvent, GrabStartData,
MotionEvent, PointerGrab, PointerInnerHandle, RelativeMotionEvent,
};
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Rectangle, Size};
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::SurfaceCachedState;
use crate::Ferese;
@@ -31,9 +27,7 @@ impl ResizeEdge {
fn left(self) -> bool {
matches!(
self.0,
xdg_toplevel::ResizeEdge::Left
| xdg_toplevel::ResizeEdge::TopLeft
| xdg_toplevel::ResizeEdge::BottomLeft
xdg_toplevel::ResizeEdge::Left | xdg_toplevel::ResizeEdge::TopLeft | xdg_toplevel::ResizeEdge::BottomLeft
)
}
@@ -49,9 +43,7 @@ impl ResizeEdge {
fn top(self) -> bool {
matches!(
self.0,
xdg_toplevel::ResizeEdge::Top
| xdg_toplevel::ResizeEdge::TopLeft
| xdg_toplevel::ResizeEdge::TopRight
xdg_toplevel::ResizeEdge::Top | xdg_toplevel::ResizeEdge::TopLeft | xdg_toplevel::ResizeEdge::TopRight
)
}
@@ -82,16 +74,8 @@ impl ResizeSurfaceGrab {
initial_rect: Rectangle<i32, Logical>,
) -> Self {
ResizeState::with(
window
.toplevel()
.expect("managed window has a toplevel")
.wl_surface(),
|state| {
*state = ResizeState::Resizing {
edges,
initial_rect,
}
},
window.toplevel().expect("managed window has a toplevel").wl_surface(),
|state| *state = ResizeState::Resizing { edges, initial_rect },
);
Self {
start_data,
@@ -114,17 +98,13 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
) {
handle.motion(data, None, event);
let delta = event.location - self.start_data.location;
let surface = self
.window
.toplevel()
.expect("managed window has a toplevel");
let surface = self.window.toplevel().expect("managed window has a toplevel");
let (minimum, maximum) = with_states(surface.wl_surface(), |states| {
let mut cached = states.cached_state.get::<SurfaceCachedState>();
let state = cached.current();
(state.min_size, state.max_size)
});
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);
data.set_floating_window_geometry(&self.window, rect.loc, rect.size);
@@ -145,20 +125,12 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
handle.relative_motion(data, focus, event);
}
fn button(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &ButtonEvent,
) {
fn button(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, event: &ButtonEvent) {
handle.button(data, event);
if handle.current_pressed().is_empty() {
self.finished = true;
handle.unset_grab(self, data, event.serial, event.time, true);
let surface = self
.window
.toplevel()
.expect("managed window has a toplevel");
let surface = self.window.toplevel().expect("managed window has a toplevel");
surface.with_pending_state(|state| {
state.states.unset(xdg_toplevel::State::Resizing);
state.size = Some(self.last_size);
@@ -173,12 +145,7 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
}
}
fn axis(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
frame: AxisFrame,
) {
fn axis(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>, frame: AxisFrame) {
handle.axis(data, frame);
}
@@ -267,21 +234,14 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
return;
}
let surface = self
.window
.toplevel()
.expect("managed window has a toplevel");
let surface = self.window.toplevel().expect("managed window has a toplevel");
surface.with_pending_state(|state| {
state.states.unset(xdg_toplevel::State::Resizing);
state.size = Some(self.initial_rect.size);
});
surface.send_pending_configure();
ResizeState::with(surface.wl_surface(), |state| *state = ResizeState::Idle);
data.set_floating_window_geometry(
&self.window,
self.initial_rect.loc,
self.initial_rect.size,
);
data.set_floating_window_geometry(&self.window, self.initial_rect.loc, self.initial_rect.size);
}
}
@@ -316,14 +276,8 @@ impl ResizeState {
fn commit(&mut self) -> Option<(ResizeEdge, Rectangle<i32, Logical>)> {
match *self {
Self::Idle => None,
Self::Resizing {
edges,
initial_rect,
} => Some((edges, initial_rect)),
Self::WaitingForFinalCommit {
edges,
initial_rect,
} => {
Self::Resizing { edges, initial_rect } => Some((edges, initial_rect)),
Self::WaitingForFinalCommit { edges, initial_rect } => {
*self = Self::Idle;
Some((edges, initial_rect))
}
+4 -11
View File
@@ -1,11 +1,9 @@
use std::time::Duration;
use smithay::{
reexports::wayland_server::protocol::wl_surface::WlSurface,
utils::SERIAL_COUNTER,
wayland::xdg_activation::{
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::SERIAL_COUNTER;
use smithay::wayland::xdg_activation::{
XdgActivationHandler, XdgActivationState, XdgActivationToken, XdgActivationTokenData,
},
};
use crate::Ferese;
@@ -21,12 +19,7 @@ impl XdgActivationHandler for Ferese {
data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE
}
fn request_activation(
&mut self,
token: XdgActivationToken,
data: XdgActivationTokenData,
surface: WlSurface,
) {
fn request_activation(&mut self, token: XdgActivationToken, data: XdgActivationTokenData, surface: WlSurface) {
if self.session_lock.active || self.input_capture.captures(1) {
return;
}
+17 -30
View File
@@ -1,28 +1,23 @@
use smithay::{
backend::{allocator::dmabuf::Dmabuf, renderer::utils::on_commit_buffer_handler},
reexports::wayland_server::{
Client,
protocol::{wl_buffer, wl_surface::WlSurface},
},
wayland::{
buffer::BufferHandler,
compositor::{
CompositorClientState, CompositorHandler, CompositorState, get_parent,
is_sync_subsurface,
},
dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier},
shm::{ShmHandler, ShmState},
},
use smithay::backend::allocator::dmabuf::Dmabuf;
use smithay::backend::renderer::utils::on_commit_buffer_handler;
use smithay::reexports::wayland_server::Client;
use smithay::reexports::wayland_server::protocol::wl_buffer;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::wayland::buffer::BufferHandler;
use smithay::wayland::compositor::{
CompositorClientState, CompositorHandler, CompositorState, get_parent, is_sync_subsurface,
};
use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
use smithay::wayland::shm::{ShmHandler, ShmState};
use super::{layer_shell, xdg_shell};
use crate::{Ferese, state::ClientState};
use crate::Ferese;
use crate::state::ClientState;
fn layer_affects_backdrop(layer: smithay::wayland::shell::wlr_layer::Layer) -> bool {
matches!(
layer,
smithay::wayland::shell::wlr_layer::Layer::Background
| smithay::wayland::shell::wlr_layer::Layer::Bottom
smithay::wayland::shell::wlr_layer::Layer::Background | smithay::wayland::shell::wlr_layer::Layer::Bottom
)
}
@@ -68,11 +63,7 @@ impl CompositorHandler for Ferese {
let window = {
self.space
.elements()
.find(|window| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == &root)
})
.find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root))
.cloned()
};
@@ -124,12 +115,7 @@ impl DmabufHandler for Ferese {
&mut self.dmabuf_state
}
fn dmabuf_imported(
&mut self,
_global: &DmabufGlobal,
dmabuf: Dmabuf,
notifier: ImportNotifier,
) {
fn dmabuf_imported(&mut self, _global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) {
self.queue_dmabuf_import(dmabuf, notifier);
crate::backends::direct::render_all(self);
}
@@ -143,8 +129,9 @@ impl ShmHandler for Ferese {
#[cfg(test)]
mod tests {
use super::*;
use smithay::wayland::shell::wlr_layer::Layer;
use super::*;
#[test]
fn panel_commits_do_not_invalidate_their_own_backdrop() {
assert!(!layer_affects_backdrop(Layer::Top));
+6 -14
View File
@@ -1,18 +1,13 @@
use smithay::{
desktop::{PopupKind, WindowSurfaceType, layer_map_for_output},
reexports::wayland_server::protocol::wl_surface::WlSurface,
utils::{Logical, Rectangle},
wayland::input_method::{InputMethodHandler, PopupSurface},
};
use smithay::desktop::{PopupKind, WindowSurfaceType, layer_map_for_output};
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Rectangle};
use smithay::wayland::input_method::{InputMethodHandler, PopupSurface};
use crate::Ferese;
impl InputMethodHandler for Ferese {
fn new_popup(&mut self, surface: PopupSurface) {
if let Err(error) = self
.popups
.track_popup(PopupKind::InputMethod(surface.clone()))
{
if let Err(error) = self.popups.track_popup(PopupKind::InputMethod(surface.clone())) {
tracing::warn!(?error, "failed to track input method popup");
}
crate::backends::direct::render_all(self);
@@ -38,10 +33,7 @@ impl InputMethodHandler for Ferese {
return geometry;
}
let Some(layer) = self
.space
.layer_for_surface(parent, WindowSurfaceType::TOPLEVEL)
else {
let Some(layer) = self.space.layer_for_surface(parent, WindowSurfaceType::TOPLEVEL) else {
return Rectangle::default();
};
+11 -36
View File
@@ -1,16 +1,11 @@
use smithay::{
desktop::{
LayerSurface as DesktopLayerSurface, PopupKind, WindowSurfaceType, layer_map_for_output,
},
output::Output,
reexports::wayland_server::protocol::{wl_output::WlOutput, wl_surface::WlSurface},
wayland::shell::{
wlr_layer::{
use smithay::desktop::{LayerSurface as DesktopLayerSurface, PopupKind, WindowSurfaceType, layer_map_for_output};
use smithay::output::Output;
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::wayland::shell::wlr_layer::{
KeyboardInteractivity, Layer, LayerSurface, WlrLayerShellHandler, WlrLayerShellState,
},
xdg::PopupSurface,
},
};
use smithay::wayland::shell::xdg::PopupSurface;
use crate::Ferese;
@@ -19,13 +14,7 @@ impl WlrLayerShellHandler for Ferese {
&mut self.layer_shell_state
}
fn new_layer_surface(
&mut self,
surface: LayerSurface,
output: Option<WlOutput>,
_layer: Layer,
namespace: String,
) {
fn new_layer_surface(&mut self, surface: LayerSurface, output: Option<WlOutput>, _layer: Layer, namespace: String) {
let output = output
.as_ref()
.and_then(Output::from_resource)
@@ -42,10 +31,7 @@ impl WlrLayerShellHandler for Ferese {
}
tracing::debug!(%namespace, output = %output.name(), "mapped layer surface");
super::set_surface_tree_scale(
layer.wl_surface(),
output.current_scale().fractional_scale(),
);
super::set_surface_tree_scale(layer.wl_surface(), output.current_scale().fractional_scale());
self.relayout();
crate::backends::direct::render_all(self);
}
@@ -89,10 +75,7 @@ impl WlrLayerShellHandler for Ferese {
}
pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
let Some(layer) = state
.space
.layer_for_surface(surface, WindowSurfaceType::TOPLEVEL)
else {
let Some(layer) = state.space.layer_for_surface(surface, WindowSurfaceType::TOPLEVEL) else {
return;
};
let outputs = state.space.outputs().cloned().collect::<Vec<_>>();
@@ -112,11 +95,7 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
&& policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
&& matches!(policy.layer, Layer::Top | Layer::Overlay)
{
state
.seat
.get_keyboard()
.expect("seat has a keyboard")
.set_focus(
state.seat.get_keyboard().expect("seat has a keyboard").set_focus(
state,
Some(layer.wl_surface().clone()),
smithay::utils::SERIAL_COUNTER.next_serial(),
@@ -136,10 +115,6 @@ fn layer_has_keyboard_focus(state: &Ferese, layer: &DesktopLayerSurface) -> bool
.seat
.get_keyboard()
.and_then(|keyboard| keyboard.current_focus())
.and_then(|surface| {
state
.space
.layer_for_surface(&surface, WindowSurfaceType::ALL)
})
.and_then(|surface| state.space.layer_for_surface(&surface, WindowSurfaceType::ALL))
.is_some_and(|focused| focused == *layer)
}
+38 -57
View File
@@ -8,42 +8,31 @@ pub(crate) mod screenshot_worker;
pub(crate) mod window_capture;
mod xdg_shell;
use smithay::{
desktop::{PopupManager, layer_map_for_output},
input::{
Seat, SeatHandler, SeatState,
pointer::{CursorImageStatus, PointerHandle},
},
reexports::wayland_server::{Resource, protocol::wl_surface::WlSurface},
utils::Point,
wayland::{
compositor::{TraversalAction, with_states, with_surface_tree_downward},
fractional_scale::{FractionalScaleHandler, with_fractional_scale},
idle_inhibit::IdleInhibitHandler,
idle_notify::{IdleNotifierHandler, IdleNotifierState},
keyboard_shortcuts_inhibit::{
KeyboardShortcutsInhibitHandler, KeyboardShortcutsInhibitState,
KeyboardShortcutsInhibitor, KeyboardShortcutsInhibitorSeat,
},
output::OutputHandler,
pointer_constraints::{
PointerConstraint, PointerConstraintsHandler, with_pointer_constraint,
},
selection::{
SelectionHandler,
data_device::{
ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler,
set_data_device_focus,
},
primary_selection::{
PrimarySelectionHandler, PrimarySelectionState, set_primary_focus,
},
},
tablet_manager::TabletSeatHandler,
xdg_foreign::{XdgForeignHandler, XdgForeignState},
xdg_toplevel_icon::XdgToplevelIconHandler,
},
use smithay::desktop::{PopupManager, layer_map_for_output};
use smithay::input::pointer::{CursorImageStatus, PointerHandle};
use smithay::input::{Seat, SeatHandler, SeatState};
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::wayland::compositor::{TraversalAction, with_states, with_surface_tree_downward};
use smithay::wayland::fractional_scale::{FractionalScaleHandler, with_fractional_scale};
use smithay::wayland::idle_inhibit::IdleInhibitHandler;
use smithay::wayland::idle_notify::{IdleNotifierHandler, IdleNotifierState};
use smithay::wayland::keyboard_shortcuts_inhibit::{
KeyboardShortcutsInhibitHandler, KeyboardShortcutsInhibitState, KeyboardShortcutsInhibitor,
KeyboardShortcutsInhibitorSeat,
};
use smithay::wayland::output::OutputHandler;
use smithay::wayland::pointer_constraints::{PointerConstraint, PointerConstraintsHandler, with_pointer_constraint};
use smithay::wayland::selection::SelectionHandler;
use smithay::wayland::selection::data_device::{
ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, set_data_device_focus,
};
use smithay::wayland::selection::primary_selection::{
PrimarySelectionHandler, PrimarySelectionState, set_primary_focus,
};
use smithay::wayland::tablet_manager::TabletSeatHandler;
use smithay::wayland::xdg_foreign::{XdgForeignHandler, XdgForeignState};
use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconHandler;
use crate::Ferese;
@@ -75,9 +64,7 @@ impl SeatHandler for Ferese {
{
inhibitor.inactivate();
}
if let Some(inhibitor) =
focused.and_then(|surface| seat.keyboard_shortcuts_inhibitor_for_surface(surface))
{
if let Some(inhibitor) = focused.and_then(|surface| seat.keyboard_shortcuts_inhibitor_for_surface(surface)) {
inhibitor.activate();
self.active_shortcuts_inhibitor = Some(inhibitor);
}
@@ -137,10 +124,7 @@ impl KeyboardShortcutsInhibitHandler for Ferese {
}
fn new_inhibitor(&mut self, inhibitor: KeyboardShortcutsInhibitor) {
let focused = self
.seat
.get_keyboard()
.and_then(|keyboard| keyboard.current_focus());
let focused = self.seat.get_keyboard().and_then(|keyboard| keyboard.current_focus());
if focused.as_ref() == Some(inhibitor.wl_surface()) {
inhibitor.activate();
@@ -199,7 +183,14 @@ pub(crate) fn set_surface_tree_scale(surface: &WlSurface, scale: f64) {
}
impl PointerConstraintsHandler for Ferese {
fn new_constraint(&mut self, _surface: &WlSurface, pointer: &PointerHandle<Self>) {
fn new_constraint(&mut self, surface: &WlSurface, pointer: &PointerHandle<Self>) {
if self
.locked_pointer_hint
.as_ref()
.is_some_and(|hint| hint.surface == *surface)
{
self.locked_pointer_hint = None;
}
self.activate_focused_pointer_constraint(pointer);
}
@@ -218,20 +209,10 @@ impl PointerConstraintsHandler for Ferese {
return;
}
let Some((focused_surface, origin)) = self.surface_under(pointer.current_location()) else {
return;
};
if focused_surface == *surface {
let current = pointer.current_location();
let (scale_x, scale_y) = self
.window_under_visual(current)
.and_then(|window| self.visual_scale_for_window(&window))
.unwrap_or((1.0, 1.0));
let current_surface_location = current - origin;
let offset = location - current_surface_location;
pointer.set_location(current + Point::from((offset.x * scale_x, offset.y * scale_y)));
}
self.locked_pointer_hint = Some(crate::input::LockedPointerHint {
surface: surface.clone(),
local: location,
});
}
}
+27 -51
View File
@@ -1,27 +1,21 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use super::screenshot::{CaptureBuffer, PartOutcome, PartSender};
use smithay::{
backend::{
allocator::Fourcc,
renderer::{ExportMem, TextureMapping},
},
output::Output,
reexports::{
wayland_protocols_wlr::screencopy::v1::server::{
zwlr_screencopy_frame_v1::{self, ZwlrScreencopyFrameV1},
zwlr_screencopy_manager_v1::{self, ZwlrScreencopyManagerV1},
},
wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
protocol::{wl_buffer::WlBuffer, wl_shm},
},
},
utils::{Buffer, Logical, Rectangle, Size},
wayland::shm::with_buffer_contents_mut,
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::{ExportMem, TextureMapping};
use smithay::output::Output;
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1;
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1;
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::{
zwlr_screencopy_frame_v1, zwlr_screencopy_manager_v1,
};
use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
use smithay::reexports::wayland_server::protocol::wl_shm;
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource};
use smithay::utils::{Buffer, Logical, Rectangle, Size};
use smithay::wayland::shm::with_buffer_contents_mut;
use super::screenshot::{CaptureBuffer, PartOutcome, PartSender};
use crate::Ferese;
const BYTES_PER_PIXEL: usize = 4;
@@ -150,8 +144,7 @@ impl PendingScreencopy {
pub(crate) fn capture_allowed() -> bool {
static ALLOWED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ALLOWED
.get_or_init(|| capture_opted_in(std::env::var_os("FERESE_ENABLE_SCREENCOPY").as_deref()))
*ALLOWED.get_or_init(|| capture_opted_in(std::env::var_os("FERESE_ENABLE_SCREENCOPY").as_deref()))
}
fn capture_opted_in(value: Option<&std::ffi::OsStr>) -> bool {
@@ -312,9 +305,7 @@ impl Ferese {
let captures = std::mem::take(&mut self.pending_screencopies);
for capture in captures {
if &capture.output != output
|| !cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)
{
if &capture.output != output || !cursor_overlay_matches(capture.overlay_cursor, overlay_cursor) {
remaining.push(capture);
continue;
}
@@ -325,8 +316,7 @@ impl Ferese {
}
framebuffer_binding_changed = true;
if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err()
{
if complete_capture(renderer, framebuffer, &capture, self.start_time.elapsed()).is_err() {
capture.fail();
}
}
@@ -336,10 +326,9 @@ impl Ferese {
}
pub(crate) fn has_pending_screencopy(&self, output: &Output, overlay_cursor: bool) -> bool {
self.pending_screencopies.iter().any(|capture| {
capture.output == *output
&& cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)
})
self.pending_screencopies
.iter()
.any(|capture| capture.output == *output && cursor_overlay_matches(capture.overlay_cursor, overlay_cursor))
}
}
@@ -404,10 +393,7 @@ fn create_frame(
}
}
fn capture_region(
output: &Output,
requested: Option<Rectangle<i32, Logical>>,
) -> Option<Rectangle<i32, Buffer>> {
fn capture_region(output: &Output, requested: Option<Rectangle<i32, Logical>>) -> Option<Rectangle<i32, Buffer>> {
let mode = output.current_mode()?;
let scale = output.current_scale().fractional_scale();
let transform = output.current_transform();
@@ -434,9 +420,7 @@ pub(crate) fn capture_region_for_geometry(
) -> Option<Rectangle<i32, Buffer>> {
let logical_size = output_logical_size(mode_size, scale, transform);
let output_region = Rectangle::from_size(logical_size);
let logical_region = requested
.unwrap_or(output_region)
.intersection(output_region)?;
let logical_region = requested.unwrap_or(output_region).intersection(output_region)?;
if logical_region.size.w <= 0 || logical_region.size.h <= 0 {
return None;
@@ -488,9 +472,7 @@ where
tracing::warn!(?error, "failed to map output framebuffer copy");
})?;
let expected = capture.region.size;
let stride = (expected.w as usize)
.checked_mul(BYTES_PER_PIXEL)
.ok_or(())?;
let stride = (expected.w as usize).checked_mul(BYTES_PER_PIXEL).ok_or(())?;
let bytes = stride.checked_mul(expected.h as usize).ok_or(())?;
if source.len() < bytes {
return Err(());
@@ -548,20 +530,17 @@ where
frame.flags(zwlr_screencopy_frame_v1::Flags::empty());
let seconds = timestamp.as_secs();
frame.ready(
(seconds >> 32) as u32,
seconds as u32,
timestamp.subsec_nanos(),
);
frame.ready((seconds >> 32) as u32, seconds as u32, timestamp.subsec_nanos());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use smithay::utils::{Physical, Transform};
use super::*;
#[test]
fn capture_is_off_unless_the_opt_in_is_exactly_one() {
use std::ffi::OsStr;
@@ -580,8 +559,7 @@ mod tests {
let mode = Size::<i32, Physical>::from((1_920, 1_080));
let requested = Rectangle::new((-10, 20).into(), (110, 50).into());
let region =
capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested)).unwrap();
let region = capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested)).unwrap();
assert_eq!(region, Rectangle::new((0, 40).into(), (200, 100).into()));
}
@@ -591,9 +569,7 @@ mod tests {
let mode = Size::<i32, Physical>::from((1_920, 1_080));
let requested = Rectangle::new((1_000, 600).into(), (100, 100).into());
assert!(
capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested),).is_none()
);
assert!(capture_region_for_geometry(mode, 2.0, Transform::Normal, Some(requested),).is_none());
}
#[test]
+56 -212
View File
@@ -36,9 +36,7 @@ const MAX_CANVAS_BYTES: usize = 128 * 1024 * 1024;
impl Canvas {
fn new(width: u32, height: u32) -> Option<Self> {
let length = (width as usize)
.checked_mul(height as usize)?
.checked_mul(4)?;
let length = (width as usize).checked_mul(height as usize)?.checked_mul(4)?;
if width == 0 || height == 0 || length > MAX_CANVAS_BYTES {
return None;
}
@@ -81,11 +79,7 @@ impl Canvas {
}
}
fn scale_and_convert(
frame: &OutputFrame,
target_width: usize,
target_height: usize,
) -> Option<Vec<u8>> {
fn scale_and_convert(frame: &OutputFrame, target_width: usize, target_height: usize) -> Option<Vec<u8>> {
let (upright_width, upright_height) = upright_size(frame);
if upright_width <= 0 || upright_height <= 0 || target_width == 0 || target_height == 0 {
return None;
@@ -97,13 +91,8 @@ fn scale_and_convert(
if upright_width as usize == target_width && upright_height as usize == target_height {
for row in 0..target_height {
for column in 0..target_width {
let (x, y) = upright_source_pixel(
column as i32,
row as i32,
frame.width,
frame.height,
frame.transform,
);
let (x, y) =
upright_source_pixel(column as i32, row as i32, frame.width, frame.height, frame.transform);
let from = (y as usize)
.checked_mul(frame.stride)?
.checked_add(x as usize * BYTES_PER_PIXEL)?;
@@ -154,13 +143,7 @@ fn scale_and_convert(
Some(out)
}
fn upright_source_pixel(
x: i32,
y: i32,
width: i32,
height: i32,
transform: Transform,
) -> (i32, i32) {
fn upright_source_pixel(x: i32, y: i32, width: i32, height: i32, transform: Transform) -> (i32, i32) {
match transform {
Transform::_90 => (y, height - 1 - x),
Transform::_180 => (width - 1 - x, height - 1 - y),
@@ -186,10 +169,7 @@ pub(crate) fn compose(frames: &[OutputFrame]) -> Result<Canvas, String> {
}
let (left, top) = logical_origin(frames).ok_or("Screenshot region is empty")?;
let scale = frames
.iter()
.map(|frame| frame.scale)
.fold(0.0_f64, f64::max);
let scale = frames.iter().map(|frame| frame.scale).fold(0.0_f64, f64::max);
if !(scale.is_finite() && scale > 0.0) {
return Err("Screenshot outputs have an invalid scale".into());
}
@@ -216,22 +196,15 @@ pub(crate) fn compose(frames: &[OutputFrame]) -> Result<Canvas, String> {
if width <= 0 || height <= 0 {
return Err("Screenshot region is empty".into());
}
let mut canvas = Canvas::new(width as u32, height as u32)
.ok_or("Screenshot image is too large to encode")?;
let mut canvas = Canvas::new(width as u32, height as u32).ok_or("Screenshot image is too large to encode")?;
for (frame, (x0, y0, target_width, target_height)) in frames.iter().zip(placement) {
if target_width == 0 || target_height == 0 {
continue;
}
let scaled = scale_and_convert(frame, target_width as usize, target_height as usize)
.ok_or("Invalid capture size")?;
canvas.blit(
&scaled,
target_width as usize,
target_height as usize,
x0,
y0,
);
let scaled =
scale_and_convert(frame, target_width as usize, target_height as usize).ok_or("Invalid capture size")?;
canvas.blit(&scaled, target_width as usize, target_height as usize, x0, y0);
}
Ok(canvas)
@@ -292,12 +265,7 @@ fn logical_origin(frames: &[OutputFrame]) -> Option<(i32, i32)> {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Geometry {
All,
Region {
x: i32,
y: i32,
width: i32,
height: i32,
},
Region { x: i32, y: i32, width: i32, height: i32 },
}
impl Geometry {
@@ -307,16 +275,9 @@ impl Geometry {
if width <= 0 || height <= 0 {
return Err("Screenshot region must have a positive size".into());
}
x.checked_add(width)
.ok_or("screenshot geometry overflows")?;
y.checked_add(height)
.ok_or("screenshot geometry overflows")?;
Ok(Geometry::Region {
x,
y,
width,
height,
})
x.checked_add(width).ok_or("screenshot geometry overflows")?;
y.checked_add(height).ok_or("screenshot geometry overflows")?;
Ok(Geometry::Region { x, y, width, height })
}
}
@@ -394,10 +355,7 @@ struct Request {
pub(crate) enum Action {
None,
Encode {
request: u64,
frames: Vec<OutputFrame>,
},
Encode { request: u64, frames: Vec<OutputFrame> },
// The path was already sent to the caller; it must not be cleaned up.
Delivered(PathBuf),
// The request is gone, so this file is ours to remove.
@@ -461,10 +419,7 @@ impl Coordinator {
response_id,
reply,
state: RequestState::Collecting,
parts: specs
.into_iter()
.map(|spec| (spec, PartState::Pending))
.collect(),
parts: specs.into_iter().map(|spec| (spec, PartState::Pending)).collect(),
received: 0,
deadline: Instant::now() + REQUEST_TIMEOUT,
},
@@ -485,12 +440,7 @@ impl Coordinator {
)
}
pub(crate) fn on_part(
&mut self,
request: u64,
part: usize,
result: Result<CaptureBuffer, String>,
) -> Action {
pub(crate) fn on_part(&mut self, request: u64, part: usize, result: Result<CaptureBuffer, String>) -> Action {
let Some(entry) = self.requests.get_mut(&request) else {
return Action::None;
};
@@ -595,10 +545,7 @@ impl Coordinator {
.map(|(id, _)| *id)
.collect();
for id in &expired {
self.reject_inner(
*id,
"Screenshot timed out: an output was not redrawn in time",
);
self.reject_inner(*id, "Screenshot timed out: an output was not redrawn in time");
}
expired
}
@@ -682,10 +629,7 @@ impl PlannedPart {
// Turns a requested logical region into the per-output reads that cover it.
// The logical-to-buffer conversion is shared with the screencopy path so both
// agree on rounding at fractional scales.
pub(crate) fn plan(
geometry: &Geometry,
outputs: &[OutputLayout],
) -> Result<Vec<PlannedPart>, String> {
pub(crate) fn plan(geometry: &Geometry, outputs: &[OutputLayout]) -> Result<Vec<PlannedPart>, String> {
if outputs.is_empty() {
return Err("No outputs are enabled".into());
}
@@ -696,8 +640,7 @@ pub(crate) fn plan(
{
return Err("Screenshot region must have a positive size".into());
}
let logical_sizes: Vec<Size<i32, Logical>> =
outputs.iter().map(OutputLayout::logical_size).collect();
let logical_sizes: Vec<Size<i32, Logical>> = outputs.iter().map(OutputLayout::logical_size).collect();
let requested = match geometry {
Geometry::All => {
@@ -709,10 +652,8 @@ pub(crate) fn plan(
};
union = Some(match union {
Some(current) => {
let top_left = Point::<i32, Logical>::new(
current.loc.x.min(rect.loc.x),
current.loc.y.min(rect.loc.y),
);
let top_left =
Point::<i32, Logical>::new(current.loc.x.min(rect.loc.x), current.loc.y.min(rect.loc.y));
let bottom_right = Point::<i32, Logical>::new(
(current.loc.x + current.size.w).max(rect.loc.x + rect.size.w),
(current.loc.y + current.size.h).max(rect.loc.y + rect.size.h),
@@ -724,12 +665,7 @@ pub(crate) fn plan(
}
union.expect("outputs is not empty")
}
Geometry::Region {
x,
y,
width,
height,
} => Rectangle {
Geometry::Region { x, y, width, height } => Rectangle {
loc: (*x, *y).into(),
size: (*width, *height).into(),
},
@@ -757,11 +693,7 @@ pub(crate) fn plan(
}
// capture_region_for_geometry works in output-local logical space.
let local = Rectangle {
loc: (
clipped.loc.x - output.location.x,
clipped.loc.y - output.location.y,
)
.into(),
loc: (clipped.loc.x - output.location.x, clipped.loc.y - output.location.y).into(),
size: clipped.size,
};
// The clip above already intersected against this same logical rect, so
@@ -808,34 +740,19 @@ mod planning {
}
fn region(x: i32, y: i32, width: i32, height: i32) -> Geometry {
Geometry::Region {
x,
y,
width,
height,
}
Geometry::Region { x, y, width, height }
}
#[test]
fn a_crop_crossing_a_seam_splits_into_one_part_per_output() {
let outputs = [
output(0, 0, 1920, 1080, 1.0),
output(1920, 0, 1920, 1080, 1.0),
];
let outputs = [output(0, 0, 1920, 1080, 1.0), output(1920, 0, 1920, 1080, 1.0)];
let parts = plan(&region(1800, 0, 200, 100), &outputs).unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].index, 0);
assert_eq!((parts[0].logical.loc.x, parts[0].logical.size.w), (1800, 120));
assert_eq!(
(parts[0].logical.loc.x, parts[0].logical.size.w),
(1800, 120)
);
assert_eq!(
(
parts[1].index,
parts[1].logical.loc.x,
parts[1].logical.size.w
),
(parts[1].index, parts[1].logical.loc.x, parts[1].logical.size.w),
(1, 1920, 80)
);
// The clipped widths must add back up to the request.
@@ -868,14 +785,8 @@ mod planning {
let outputs = [output(0, 0, 800, 600, 1.0), output(800, 0, 1600, 1200, 1.5)];
let parts = plan(&region(600, 0, 400, 300), &outputs).unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(
(parts[0].logical.loc.x, parts[0].logical.size.w),
(600, 200)
);
assert_eq!(
(parts[1].logical.loc.x, parts[1].logical.size.w),
(800, 200)
);
assert_eq!((parts[0].logical.loc.x, parts[0].logical.size.w), (600, 200));
assert_eq!((parts[1].logical.loc.x, parts[1].logical.size.w), (800, 200));
// The 1.5 output reads back at 1.5x while still reporting logical 200.
let spec = parts[1].spec(&outputs[1]);
assert_eq!(spec.logical_width, 200);
@@ -885,10 +796,7 @@ mod planning {
#[test]
fn all_geometry_unions_every_output() {
let outputs = [
output(0, 0, 1920, 1080, 1.0),
output(-1280, -400, 1280, 800, 1.0),
];
let outputs = [output(0, 0, 1920, 1080, 1.0), output(-1280, -400, 1280, 800, 1.0)];
let parts = plan(&Geometry::All, &outputs).unwrap();
assert_eq!(parts.len(), 2);
// Neither output is moved into a normalised origin at plan time.
@@ -899,10 +807,7 @@ mod planning {
#[test]
fn a_crop_misses_outputs_that_do_not_intersect() {
let outputs = [
output(0, 0, 1920, 1080, 1.0),
output(1920, 0, 1920, 1080, 1.0),
];
let outputs = [output(0, 0, 1920, 1080, 1.0), output(1920, 0, 1920, 1080, 1.0)];
let parts = plan(&region(4000, 0, 100, 100), &outputs);
assert!(parts.is_err(), "a region off the desktop is reported");
}
@@ -912,17 +817,11 @@ mod planning {
// The second output starts exactly where the region ends. It contributes
// nothing, so the capture must still succeed with just the first output
// rather than failing on a region it does not overlap.
let outputs = [
output(0, 0, 1920, 1080, 1.0),
output(1920, 0, 1920, 1080, 1.0),
];
let outputs = [output(0, 0, 1920, 1080, 1.0), output(1920, 0, 1920, 1080, 1.0)];
let parts = plan(&region(100, 100, 1820, 980), &outputs).expect("captures the overlap");
assert_eq!(parts.len(), 1, "only the overlapping output is read");
assert_eq!(parts[0].index, 0);
assert_eq!(
(parts[0].logical.size.w, parts[0].logical.size.h),
(1820, 980)
);
assert_eq!((parts[0].logical.size.w, parts[0].logical.size.h), (1820, 980));
}
#[test]
@@ -932,16 +831,10 @@ mod planning {
// A 90-degree transform turns the 1920x1080 mode into a 1080x1920
// logical area, and the readback follows the same rotation.
let parts = plan(&region(0, 0, 1080, 400), std::slice::from_ref(&rotated)).unwrap();
assert_eq!(
(parts[0].logical.size.w, parts[0].logical.size.h),
(1080, 400)
);
assert_eq!((parts[0].logical.size.w, parts[0].logical.size.h), (1080, 400));
let spec = parts[0].spec(&rotated);
assert_eq!(spec.transform, Transform::_90);
assert!(
spec.buffer_height > spec.buffer_width,
"buffer axes are swapped"
);
assert!(spec.buffer_height > spec.buffer_width, "buffer axes are swapped");
}
#[test]
@@ -981,10 +874,12 @@ mod planning {
#[cfg(test)]
mod completion {
use std::sync::mpsc::sync_channel;
use smithay::reexports::calloop::channel::channel as loop_channel;
use super::*;
use crate::handlers::screencopy::CaptureSink;
use smithay::reexports::calloop::channel::channel as loop_channel;
use std::sync::mpsc::sync_channel;
fn spec(width: i32, height: i32) -> PartSpec {
PartSpec {
@@ -1021,10 +916,7 @@ mod completion {
coordinator
.admit(42, reply.clone(), (0..parts).map(|_| spec(2, 2)).collect())
.expect("admitted");
Self {
coordinator,
received,
}
Self { coordinator, received }
}
fn response(&self) -> Option<Response> {
@@ -1041,21 +933,13 @@ mod completion {
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))),
Action::None
));
assert!(
harness.coordinator.is_live(id),
"still collecting after part 0"
);
assert!(harness.coordinator.is_live(id), "still collecting after part 0");
assert!(matches!(
harness
.coordinator
.on_part(id, 1, Err("readback failed".into())),
harness.coordinator.on_part(id, 1, Err("readback failed".into())),
Action::None
));
assert!(
!harness.coordinator.is_live(id),
"one failed part ends the request"
);
assert!(!harness.coordinator.is_live(id), "one failed part ends the request");
let response = harness.response().expect("caller is always answered");
assert_eq!(response.id, 42);
@@ -1070,9 +954,7 @@ mod completion {
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
// Output 1 is removed from the layout, so its pending capture is failed.
harness
.coordinator
.on_part(id, 1, Err("output was removed".into()));
harness.coordinator.on_part(id, 1, Err("output was removed".into()));
assert!(!harness.coordinator.is_live(id));
assert!(harness.response().unwrap().error.is_some());
@@ -1142,9 +1024,7 @@ mod completion {
// Unlocking does not resurrect the request; the file the worker
// eventually produces is ours to delete.
assert!(!harness.coordinator.is_live(id));
let action = harness
.coordinator
.on_encoded(id, Ok(PathBuf::from("/tmp/late.png")));
let action = harness.coordinator.on_encoded(id, Ok(PathBuf::from("/tmp/late.png")));
assert!(matches!(action, Action::DiscardFile(ref path) if path.ends_with("late.png")));
assert!(harness.response().is_none(), "no late success is delivered");
}
@@ -1155,9 +1035,7 @@ mod completion {
let id = harness.coordinator.next_id;
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
harness
.coordinator
.on_encoded(id, Ok(PathBuf::from("/tmp/shot.png")));
harness.coordinator.on_encoded(id, Ok(PathBuf::from("/tmp/shot.png")));
let response = harness.response().expect("success is answered");
assert_eq!(response.id, 42);
assert!(response.error.is_none());
@@ -1165,9 +1043,7 @@ mod completion {
// A duplicate completion for the same request has nowhere to go, and
// its file is an orphan rather than a second delivery.
let action = harness
.coordinator
.on_encoded(id, Ok(PathBuf::from("/tmp/again.png")));
let action = harness.coordinator.on_encoded(id, Ok(PathBuf::from("/tmp/again.png")));
assert!(matches!(action, Action::DiscardFile(_)));
// A delivered path is reported separately so the caller cannot
@@ -1190,9 +1066,7 @@ mod completion {
let id = harness.coordinator.next_id;
harness.coordinator.on_part(id, 0, Ok(buffer(2, 2)));
harness
.coordinator
.on_encoded(id, Err("encoding failed".into()));
harness.coordinator.on_encoded(id, Err("encoding failed".into()));
let response = harness.response().expect("failure is answered");
assert_eq!(response.error.unwrap().message, "encoding failed");
assert!(!harness.coordinator.is_live(id));
@@ -1205,23 +1079,13 @@ mod completion {
// client would never be answered.
let mut harness = Harness::new(2);
let id = harness.coordinator.next_id;
harness
.coordinator
.requests
.get_mut(&id)
.expect("tracked")
.deadline = Instant::now() - Duration::from_secs(1);
harness.coordinator.requests.get_mut(&id).expect("tracked").deadline = Instant::now() - Duration::from_secs(1);
assert_eq!(harness.coordinator.expire(), vec![id]);
let response = harness.response().expect("the caller is answered");
assert_eq!(response.error.as_ref().unwrap().code, "screenshot_failed");
assert!(
response
.error
.as_ref()
.unwrap()
.message
.contains("timed out"),
response.error.as_ref().unwrap().message.contains("timed out"),
"the failure explains itself: {:?}",
response.error.as_ref().unwrap().message
);
@@ -1302,11 +1166,7 @@ mod completion {
// buffer that was handed off is never replaced.
assert!(!sink.publish(buffer(64, 64)));
let PartOutcome {
request,
part,
result,
} = receiver.try_recv().expect("published once");
let PartOutcome { request, part, result } = receiver.try_recv().expect("published once");
assert_eq!((request, part), (7, 1));
assert_eq!(result.unwrap().width, 2);
assert!(receiver.try_recv().is_err(), "no second buffer was sent");
@@ -1321,11 +1181,7 @@ mod completion {
// failure has already been reported.
assert!(!sink.publish(buffer(2, 2)));
let PartOutcome {
request,
part,
result,
} = receiver.try_recv().expect("answered");
let PartOutcome { request, part, result } = receiver.try_recv().expect("answered");
assert_eq!((request, part), (3, 0));
assert!(result.is_err());
assert!(receiver.try_recv().is_err());
@@ -1715,11 +1571,7 @@ mod tests {
// 9 logical at scale 1.5.
assert_eq!((canvas.width, canvas.height), (14, 5));
for column in 0..canvas.width as usize {
assert_eq!(
canvas.pixels[column * 4 + 3],
255,
"column {column} has a gap"
);
assert_eq!(canvas.pixels[column * 4 + 3], 255, "column {column} has a gap");
}
}
@@ -1751,11 +1603,7 @@ mod tests {
let canvas = compose(&[left, right]).unwrap();
assert_eq!(canvas.width, 5);
assert_eq!(
&canvas.pixels[2 * 4..3 * 4],
&[0, 0, 0, 0],
"gap is transparent"
);
assert_eq!(&canvas.pixels[2 * 4..3 * 4], &[0, 0, 0, 0], "gap is transparent");
assert_eq!(canvas.pixels[3], 255);
assert_eq!(canvas.pixels[3 * 4 + 3], 255);
}
@@ -1784,11 +1632,7 @@ mod tests {
#[test]
fn geometry_tolerates_surrounding_whitespace_from_slurp() {
for raw in [
"100,200 800x600\n",
" 100,200 800x600 ",
"100,200 800x600\r\n",
] {
for raw in ["100,200 800x600\n", " 100,200 800x600 ", "100,200 800x600\r\n"] {
assert_eq!(
parse_geometry(raw).unwrap(),
Geometry::Region {
+9 -24
View File
@@ -1,10 +1,8 @@
use std::fs;
use std::io;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::thread;
use std::time::{Duration, SystemTime};
use std::{fs, io, thread};
use smithay::reexports::calloop::channel;
@@ -125,13 +123,7 @@ fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
image::codecs::png::CompressionType::Fast,
image::codecs::png::FilterType::Adaptive,
);
image::ImageEncoder::write_image(
encoder,
image.as_raw(),
width,
height,
image::ExtendedColorType::Rgba8,
)
image::ImageEncoder::write_image(encoder, image.as_raw(), width, height, image::ExtendedColorType::Rgba8)
.map_err(|error| format!("Could not encode screenshot: {error}"))?;
tracing::debug!(encoded = out.len(), "encoded screenshot");
Ok(out)
@@ -161,8 +153,8 @@ fn write_private_file(png: &[u8]) -> Result<PathBuf, String> {
}
fn private_directory() -> Result<PathBuf, String> {
let runtime = std::env::var_os("XDG_RUNTIME_DIR")
.ok_or("XDG_RUNTIME_DIR is not set, so screenshots cannot be staged")?;
let runtime =
std::env::var_os("XDG_RUNTIME_DIR").ok_or("XDG_RUNTIME_DIR is not set, so screenshots cannot be staged")?;
let root = PathBuf::from(runtime);
let directory = root.join("ferese-screenshots");
match fs::symlink_metadata(&directory) {
@@ -177,9 +169,7 @@ fn private_directory() -> Result<PathBuf, String> {
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
fs::create_dir(&directory)
.and_then(|()| {
fs::set_permissions(&directory, fs::Permissions::from_mode(DIR_MODE))
})
.and_then(|()| fs::set_permissions(&directory, fs::Permissions::from_mode(DIR_MODE)))
.map_err(|error| format!("Could not create screenshot directory: {error}"))?;
}
Err(error) => return Err(format!("Could not inspect screenshot directory: {error}")),
@@ -189,9 +179,10 @@ fn private_directory() -> Result<PathBuf, String> {
#[cfg(test)]
mod tests {
use super::*;
use smithay::utils::Transform;
use super::*;
fn frame(width: i32, height: i32) -> OutputFrame {
let stride = width as usize * 4;
let mut pixels = vec![0u8; stride * height as usize];
@@ -237,10 +228,7 @@ mod tests {
let path = write_private_file(&png).expect("staged");
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, FILE_MODE,
"staged screenshots are not group/world readable"
);
assert_eq!(mode, FILE_MODE, "staged screenshots are not group/world readable");
fs::remove_file(&path).unwrap();
}
@@ -254,10 +242,7 @@ mod tests {
fs::write(&other, b"not a screenshot").unwrap();
sweep_stale_files();
assert!(
fresh.exists(),
"a freshly staged screenshot is still in use"
);
assert!(fresh.exists(), "a freshly staged screenshot is still in use");
assert!(other.exists(), "unrelated files are left alone");
fs::remove_file(&fresh).unwrap();
+23 -62
View File
@@ -1,27 +1,19 @@
use std::{
os::unix::fs::MetadataExt,
sync::{Arc, Mutex},
};
use std::os::unix::fs::MetadataExt;
use std::sync::{Arc, Mutex};
use ferese_layout::WindowId;
use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1::{
self as manager, FereseWindowCaptureManagerV1,
};
use smithay::{
reexports::{
wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1::{
self as frame, ZwlrScreencopyFrameV1,
},
wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
protocol::{wl_buffer::WlBuffer, wl_shm},
},
},
utils::Rectangle,
wayland::shm::with_buffer_contents_mut,
};
use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1 as manager;
use ferese_protocols::window_capture::v1::server::ferese_window_capture_manager_v1::FereseWindowCaptureManagerV1;
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1 as frame;
use smithay::reexports::wayland_protocols_wlr::screencopy::v1::server::zwlr_screencopy_frame_v1::ZwlrScreencopyFrameV1;
use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
use smithay::reexports::wayland_server::protocol::wl_shm;
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource};
use smithay::utils::Rectangle;
use smithay::wayland::shm::with_buffer_contents_mut;
use super::{screencopy::FrameData, screenshot::CaptureBuffer};
use super::screencopy::FrameData;
use super::screenshot::CaptureBuffer;
use crate::Ferese;
const MAX_BYTES: usize = 128 * 1024 * 1024;
@@ -42,10 +34,7 @@ struct Permit {
impl Permit {
fn reserve(budget: &Arc<Mutex<Budget>>, bytes: usize) -> Option<Self> {
let mut available = budget.lock().unwrap();
if bytes == 0
|| bytes > MAX_BYTES.saturating_sub(available.bytes)
|| available.frames >= MAX_FRAMES
{
if bytes == 0 || bytes > MAX_BYTES.saturating_sub(available.bytes) || available.frames >= MAX_FRAMES {
return None;
}
available.bytes += bytes;
@@ -79,11 +68,7 @@ pub(crate) struct Global {
pub(crate) fn is_portal(stream: &std::os::unix::net::UnixStream) -> bool {
use std::os::fd::AsRawFd;
let mut credentials = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
let mut length = std::mem::size_of_val(&credentials) as libc::socklen_t;
// SAFETY: the connected socket is live and both out pointers cover their
// initialized stack allocations. SO_PEERCRED identifies the connecting process.
@@ -101,29 +86,19 @@ pub(crate) fn is_portal(stream: &std::os::unix::net::UnixStream) -> bool {
}
let expected = std::env::current_exe()
.ok()
.and_then(|exe| {
exe.parent()
.map(|dir| dir.join("xdg-desktop-portal-ferese"))
})
.and_then(|exe| exe.parent().map(|dir| dir.join("xdg-desktop-portal-ferese")))
.and_then(|exe| std::fs::metadata(exe).ok());
let actual = std::fs::metadata(format!("/proc/{}/exe", credentials.pid)).ok();
matches!((expected, actual), (Some(expected), Some(actual)) if expected.dev() == actual.dev() && expected.ino() == actual.ino())
}
pub(super) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>(
1,
Global {
budget: Arc::default(),
},
);
display.create_global::<Ferese, FereseWindowCaptureManagerV1, Global>(1, Global { budget: Arc::default() });
}
impl GlobalDispatch<FereseWindowCaptureManagerV1, Global> for Ferese {
fn can_view(client: Client, _: &Global) -> bool {
client
.get_data::<crate::state::ClientState>()
.is_some_and(|state| {
client.get_data::<crate::state::ClientState>().is_some_and(|state| {
state
.capabilities
.contains(crate::private_client::ClientCapabilities::WINDOW_CAPTURE)
@@ -226,8 +201,7 @@ impl Ferese {
.clone();
let geometry = window.geometry();
let scale = output.current_scale().fractional_scale();
let size: smithay::utils::Size<i32, smithay::utils::Physical> =
geometry.size.to_physical_precise_round(scale);
let size: smithay::utils::Size<i32, smithay::utils::Physical> = geometry.size.to_physical_precise_round(scale);
let bytes = usize::try_from(size.w)
.ok()?
.checked_mul(usize::try_from(size.h).ok()?)?
@@ -247,8 +221,7 @@ impl Ferese {
}
let (x, y) = self.inverse_presented_window_point(id, location.x, location.y)?;
let surface_point = smithay::utils::Point::from((x, y)) + geometry.loc.to_f64();
let (surface, _) = window
.surface_under(surface_point, smithay::desktop::WindowSurfaceType::ALL)?;
let (surface, _) = window.surface_under(surface_point, smithay::desktop::WindowSurfaceType::ALL)?;
if pointer.current_focus().as_ref() != Some(&surface) {
return None;
}
@@ -298,18 +271,10 @@ impl Ferese {
}
}
pub(super) fn copy_snapshot(
state: &Ferese,
resource: &ZwlrScreencopyFrameV1,
data: &FrameData,
buffer: &WlBuffer,
) {
pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, data: &FrameData, buffer: &WlBuffer) {
let mut used = data.used.lock().unwrap();
if *used {
resource.post_error(
frame::Error::AlreadyUsed,
"window capture frame has already been used",
);
resource.post_error(frame::Error::AlreadyUsed, "window capture frame has already been used");
return;
}
*used = true;
@@ -342,11 +307,7 @@ pub(super) fn copy_snapshot(
// SAFETY: Smithay owns the writable mapping, validated format/stride and
// checked offset cover the complete snapshot; the source is separately owned.
unsafe {
std::ptr::copy_nonoverlapping(
snapshot.pixels.pixels.as_ptr(),
destination.add(offset),
bytes,
);
std::ptr::copy_nonoverlapping(snapshot.pixels.pixels.as_ptr(), destination.add(offset), bytes);
}
true
})
+83 -72
View File
@@ -1,35 +1,23 @@
use smithay::{
desktop::{
PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, Space, Window,
WindowSurfaceType, find_popup_root_surface, get_popup_toplevel_coords,
},
input::{
Seat,
pointer::{Focus, GrabStartData},
},
reexports::wayland_protocols::xdg::{
decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode,
shell::server::xdg_toplevel::State as ToplevelState,
},
reexports::wayland_server::{
Resource,
protocol::{wl_seat, wl_surface::WlSurface},
},
utils::Serial,
wayland::{
compositor::with_states,
shell::xdg::decoration::XdgDecorationHandler,
shell::xdg::{
PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState,
XdgToplevelSurfaceData,
},
},
use smithay::desktop::{
PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, Space, Window, WindowSurfaceType,
find_popup_root_surface, get_popup_toplevel_coords,
};
use smithay::input::Seat;
use smithay::input::pointer::{Focus, GrabStartData};
use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as ToplevelState;
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_seat;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Rectangle, Serial};
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
use smithay::wayland::shell::xdg::{
PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState, XdgToplevelSurfaceData,
};
use crate::{
Ferese,
grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab, handle_resize_commit},
};
use crate::Ferese;
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab, handle_resize_commit};
impl XdgShellHandler for Ferese {
fn xdg_shell_state(&mut self) -> &mut XdgShellState {
@@ -93,12 +81,7 @@ impl XdgShellHandler for Ferese {
}
}
fn reposition_request(
&mut self,
surface: PopupSurface,
positioner: PositionerState,
token: u32,
) {
fn reposition_request(&mut self, surface: PopupSurface, positioner: PositionerState, token: u32) {
surface.with_pending_state(|state| {
state.geometry = positioner.get_geometry();
state.positioner = positioner;
@@ -273,12 +256,7 @@ impl XdgShellHandler for Ferese {
};
if let Some(pointer) = seat.get_pointer() {
pointer.set_grab(
self,
PopupPointerGrab::new(&popup_grab),
serial,
Focus::Keep,
);
pointer.set_grab(self, PopupPointerGrab::new(&popup_grab), serial, Focus::Keep);
}
if let Some(keyboard) = seat.get_keyboard() {
keyboard.set_grab(self, PopupKeyboardGrab::new(&popup_grab), serial);
@@ -290,21 +268,14 @@ impl XdgShellHandler for Ferese {
}
}
fn check_grab(
seat: &Seat<Ferese>,
surface: &WlSurface,
serial: Serial,
) -> Option<GrabStartData<Ferese>> {
fn check_grab(seat: &Seat<Ferese>, surface: &WlSurface, serial: Serial) -> Option<GrabStartData<Ferese>> {
let pointer = seat.get_pointer()?;
if !pointer.has_grab(serial) {
return None;
}
let start_data = pointer.grab_start_data()?;
let (focus, _) = start_data.focus.as_ref()?;
focus
.id()
.same_client_as(&surface.id())
.then_some(start_data)
focus.id().same_client_as(&surface.id()).then_some(start_data)
}
impl XdgDecorationHandler for Ferese {
@@ -381,9 +352,7 @@ pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, surfa
if let Some(PopupKind::Xdg(popup)) = popups.find_popup(surface)
&& !popup.is_initial_configure_sent()
{
popup
.send_configure()
.expect("initial popup configure is valid");
popup.send_configure().expect("initial popup configure is valid");
}
}
@@ -444,40 +413,82 @@ impl Ferese {
let Ok(root) = find_popup_root_surface(&PopupKind::Xdg(popup.clone())) else {
return;
};
let window = self.space.elements().find(|window| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == &root)
});
let window = self
.space
.elements()
.find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root));
let geometry = (|| {
if let Some(window) = window {
let output = self.space.outputs().next()?;
let output_geometry = self.space.output_geometry(output)?;
let window_geometry = self.space.element_geometry(window)?;
Some((output_geometry, window_geometry.loc))
} else {
let layer = self
let id = *self.window_ids.get(window)?;
let output = self
.space
.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL)?;
.outputs()
.find(|output| self.window_belongs_to_output(id, output))?;
let output_geometry = self.space.output_geometry(output)?;
let presented = self.presented_window_rect(id)?;
let location = (presented.x.round() as i32, presented.y.round() as i32).into();
Some((output_geometry, location))
} else {
let layer = self.space.layer_for_surface(&root, WindowSurfaceType::TOPLEVEL)?;
self.space.outputs().find_map(|output| {
let output_geometry = self.space.output_geometry(output)?;
let layer_geometry =
smithay::desktop::layer_map_for_output(output).layer_geometry(&layer)?;
let layer_geometry = smithay::desktop::layer_map_for_output(output).layer_geometry(&layer)?;
Some((output_geometry, output_geometry.loc + layer_geometry.loc))
})
}
})();
let Some((mut target, root_location)) = geometry else {
let Some((target, root_location)) = geometry else {
return;
};
target.loc -= get_popup_toplevel_coords(&PopupKind::Xdg(popup.clone()));
target.loc -= root_location;
let target = popup_constraint_target(
target,
root_location,
get_popup_toplevel_coords(&PopupKind::Xdg(popup.clone())),
);
popup.with_pending_state(|state| {
state.geometry = state.positioner.get_unconstrained_geometry(target);
});
}
}
fn popup_constraint_target(
mut output: Rectangle<i32, Logical>,
root_location: Point<i32, Logical>,
parent_offset: Point<i32, Logical>,
) -> Rectangle<i32, Logical> {
output.loc -= root_location + parent_offset;
output
}
#[cfg(test)]
mod tests {
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_positioner;
use super::*;
#[test]
fn popup_uses_presented_parent_coordinates_on_an_offset_monitor() {
let output = Rectangle::new((1920, -200).into(), (1920, 1080).into());
let root = Point::from((2200, -100));
let parent = Point::from((40, 30));
let target = popup_constraint_target(output, root, parent);
assert_eq!(target.loc, Point::from((-320, -130)));
let positioner = PositionerState {
rect_size: (320, 180).into(),
anchor_rect: Rectangle::new((1850, 900).into(), (1, 1).into()),
anchor_edges: xdg_positioner::Anchor::BottomRight,
gravity: xdg_positioner::Gravity::BottomRight,
constraint_adjustment: xdg_positioner::ConstraintAdjustment::SlideX
| xdg_positioner::ConstraintAdjustment::SlideY,
..Default::default()
};
let popup = positioner.get_unconstrained_geometry(target);
assert_eq!(popup.loc, Point::from((1280, 770)));
let global = Rectangle::new(root + parent + popup.loc, popup.size);
assert_eq!(global.intersection(output), Some(global));
}
}
+180 -99
View File
@@ -1,53 +1,122 @@
use smithay::reexports::wayland_server::Resource;
use std::process::Command;
use ferese_layout::Direction;
use smithay::{
backend::input::{
AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, GestureBeginEvent,
GestureEndEvent, GestureSwipeUpdateEvent as _, InputBackend, InputEvent, KeyState,
KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, Switch,
SwitchState, SwitchToggleEvent, TouchEvent,
},
input::{
keyboard::{FilterResult, keysyms},
pointer::{
AxisFrame, ButtonEvent, Focus, GestureSwipeBeginEvent, GestureSwipeEndEvent,
GestureSwipeUpdateEvent, GrabStartData, MotionEvent, PointerHandle,
RelativeMotionEvent,
},
touch::{DownEvent, MotionEvent as TouchMotionEvent, UpEvent},
},
reexports::{
wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge as XdgResizeEdge,
wayland_server::protocol::wl_surface::WlSurface,
},
utils::{Logical, Point, SERIAL_COUNTER, Serial},
wayland::{
keyboard_shortcuts_inhibit::KeyboardShortcutsInhibitorSeat,
pointer_constraints::{PointerConstraint, with_pointer_constraint},
},
use smithay::backend::input::{
AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, GestureBeginEvent, GestureEndEvent,
GestureSwipeUpdateEvent as _, InputBackend, InputEvent, KeyState, KeyboardKeyEvent, PointerAxisEvent,
PointerButtonEvent, PointerMotionEvent, Switch, SwitchState, SwitchToggleEvent, TouchEvent,
};
use smithay::input::keyboard::{FilterResult, keysyms};
use smithay::input::pointer::{
AxisFrame, ButtonEvent, Focus, GestureSwipeBeginEvent, GestureSwipeEndEvent, GestureSwipeUpdateEvent,
GrabStartData, MotionEvent, PointerHandle, RelativeMotionEvent,
};
use smithay::input::touch::{DownEvent, MotionEvent as TouchMotionEvent, UpEvent};
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge as XdgResizeEdge;
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, SERIAL_COUNTER, Serial};
use smithay::wayland::keyboard_shortcuts_inhibit::KeyboardShortcutsInhibitorSeat;
use smithay::wayland::pointer_constraints::{PointerConstraint, with_pointer_constraint};
use crate::{
Ferese,
config::BindingAction,
gestures::SwipeDirection,
grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab},
};
use crate::Ferese;
use crate::config::BindingAction;
use crate::gestures::SwipeDirection;
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab};
const BTN_LEFT: u32 = 0x110;
const BTN_RIGHT: u32 = 0x111;
pub(crate) struct LockedPointerHint {
pub(crate) surface: WlSurface,
pub(crate) local: Point<f64, Logical>,
}
impl Ferese {
pub(crate) fn pointer_hint_position(
&self,
surface: &WlSurface,
hint: Point<f64, Logical>,
) -> Option<Point<f64, Logical>> {
for window in self.space.elements() {
let root = window.toplevel()?.wl_surface();
let geometry = window.geometry();
let Some(local) = surface_tree_position(root, surface, geometry.loc) else {
continue;
};
let id = self.window_ids.get(window)?;
let presented = self.presented_window_rect(*id)?;
let (scale_x, scale_y) = self.visual_scale_for_window(window)?;
let point = local.to_f64() + hint - geometry.loc.to_f64();
return Some(Point::from((
presented.x + point.x * scale_x,
presented.y + point.y * scale_y,
)));
}
for output in self.space.outputs() {
let output_geometry = self.space.output_geometry(output)?;
let layers = smithay::desktop::layer_map_for_output(output);
for layer in layers.layers() {
let Some(local) = surface_tree_position(layer.wl_surface(), surface, (0, 0).into()) else {
continue;
};
let geometry = layers.layer_geometry(layer)?;
return Some((output_geometry.loc + geometry.loc + local).to_f64() + hint);
}
}
None
}
pub(crate) fn apply_unlocked_pointer_hint(&mut self) {
let Some(hint) = self.locked_pointer_hint.take() else {
return;
};
if !hint.surface.is_alive() || self.session_lock.active || self.input_capture.active() {
return;
}
let Some(pointer) = self.seat.get_pointer() else {
return;
};
let locked = with_pointer_constraint(&hint.surface, &pointer, |constraint| {
constraint.is_some_and(|constraint| {
constraint.is_active() && matches!(&*constraint, PointerConstraint::Locked(_))
})
});
if locked {
self.locked_pointer_hint = Some(hint);
return;
}
if pointer.current_focus().is_some_and(|surface| {
with_pointer_constraint(&surface, &pointer, |constraint| {
constraint.is_some_and(|constraint| constraint.is_active())
})
}) {
return;
}
let Some(global) = self.pointer_hint_position(&hint.surface, hint.local) else {
return;
};
let location = self.clamp_pointer_position(global);
let focus = self.surface_under(location);
pointer.motion(
self,
focus,
&MotionEvent {
location,
serial: SERIAL_COUNTER.next_serial(),
time: self.last_pointer_time,
},
);
pointer.frame(self);
self.cursor_redraw_pending = true;
}
pub fn process_input_event<I: InputBackend>(&mut self, event: InputEvent<I>) {
let seat = self.seat.clone();
self.idle_notifier_state.notify_activity(&seat);
if !matches!(
&event,
InputEvent::DeviceAdded { .. }
| InputEvent::DeviceRemoved { .. }
| InputEvent::SwitchToggle { .. }
InputEvent::DeviceAdded { .. } | InputEvent::DeviceRemoved { .. } | InputEvent::SwitchToggle { .. }
) {
self.lock_input_activity();
}
@@ -117,9 +186,7 @@ impl Ferese {
if self.swipe.active() {
self.swipe.update(event.delta_x(), event.delta_y());
} else {
seat.get_pointer()
.expect("seat has a pointer")
.gesture_swipe_update(
seat.get_pointer().expect("seat has a pointer").gesture_swipe_update(
self,
&GestureSwipeUpdateEvent {
time: event.time_msec(),
@@ -140,9 +207,7 @@ impl Ferese {
.map(|binding| binding.action.clone())
{
match action {
BindingAction::Focus(
direction @ (Direction::Left | Direction::Right),
) => {
BindingAction::Focus(direction @ (Direction::Left | Direction::Right)) => {
self.focus_direction_from_swipe(direction);
}
BindingAction::SwitchRelativeWorkspace(next) => {
@@ -152,9 +217,7 @@ impl Ferese {
}
}
} else {
seat.get_pointer()
.expect("seat has a pointer")
.gesture_swipe_end(
seat.get_pointer().expect("seat has a pointer").gesture_swipe_end(
self,
&GestureSwipeEndEvent {
serial: SERIAL_COUNTER.next_serial(),
@@ -301,6 +364,7 @@ impl Ferese {
}
}
InputEvent::PointerMotionAbsolute { event, .. } => {
self.last_pointer_time = event.time_msec();
let Some(position) = self.absolute_event_position(&event) else {
return;
};
@@ -325,6 +389,7 @@ impl Ferese {
crate::backends::direct::render_all(self);
}
InputEvent::PointerMotion { event, .. } => {
self.last_pointer_time = event.time_msec();
let pointer = self.seat.get_pointer().expect("seat has a pointer");
let position = pointer.current_location();
let focus = self.surface_under(position);
@@ -346,8 +411,7 @@ impl Ferese {
focus,
&RelativeMotionEvent {
delta: (delta.x / scale_x, delta.y / scale_y).into(),
delta_unaccel: (delta_unaccel.x / scale_x, delta_unaccel.y / scale_y)
.into(),
delta_unaccel: (delta_unaccel.x / scale_x, delta_unaccel.y / scale_y).into(),
utime: event.time(),
},
);
@@ -369,6 +433,7 @@ impl Ferese {
crate::backends::direct::render_all(self);
}
InputEvent::PointerButton { event, .. } => {
self.last_pointer_time = event.time_msec();
if self.input_capture.active() {
if self.input_capture.captures(2) {
self.input_capture.push(serde_json::json!({"type":"button", "button":event.button_code(), "pressed":event.state() == ButtonState::Pressed}));
@@ -392,11 +457,8 @@ impl Ferese {
&& crate::effects::begin_surface_dismiss(&surface)
{
let opacity = crate::effects::surface_opacity(&surface);
self.dismissing_popups.push((
root,
popup,
crate::dimming::DimAnimation::new(f64::from(opacity)),
));
self.dismissing_popups
.push((root, popup, crate::dimming::DimAnimation::new(f64::from(opacity))));
pointer.unset_grab(self, serial, event.time_msec());
self.focus_window_at(pointer.current_location(), serial, true);
crate::backends::direct::render_all(self);
@@ -466,13 +528,14 @@ impl Ferese {
pointer.frame(self);
}
InputEvent::PointerAxis { event, .. } => {
self.last_pointer_time = event.time_msec();
let source = event.source();
let horizontal = event.amount(Axis::Horizontal).unwrap_or_else(|| {
event.amount_v120(Axis::Horizontal).unwrap_or(0.0) * 15.0 / 120.0
});
let vertical = event.amount(Axis::Vertical).unwrap_or_else(|| {
event.amount_v120(Axis::Vertical).unwrap_or(0.0) * 15.0 / 120.0
});
let horizontal = event
.amount(Axis::Horizontal)
.unwrap_or_else(|| event.amount_v120(Axis::Horizontal).unwrap_or(0.0) * 15.0 / 120.0);
let vertical = event
.amount(Axis::Vertical)
.unwrap_or_else(|| event.amount_v120(Axis::Vertical).unwrap_or(0.0) * 15.0 / 120.0);
if self.input_capture.active() {
if self.input_capture.captures(2) {
self.input_capture.push(serde_json::json!({"type":"scroll", "x":horizontal, "y":vertical, "v120_x":event.amount_v120(Axis::Horizontal), "v120_y":event.amount_v120(Axis::Vertical), "stop_x":source == AxisSource::Finger && event.amount(Axis::Horizontal) == Some(0.0), "stop_y":source == AxisSource::Finger && event.amount(Axis::Vertical) == Some(0.0)}));
@@ -482,11 +545,7 @@ impl Ferese {
let pointer = self.seat.get_pointer().expect("seat has a pointer");
if self.scroll_overview_strip(
pointer.current_location(),
if horizontal != 0.0 {
horizontal
} else {
vertical
},
if horizontal != 0.0 { horizontal } else { vertical },
) {
return;
}
@@ -577,12 +636,7 @@ impl Ferese {
}
}
fn start_floating_pointer_grab(
&mut self,
pointer: &PointerHandle<Self>,
button: u32,
serial: Serial,
) -> bool {
fn start_floating_pointer_grab(&mut self, pointer: &PointerHandle<Self>, button: u32, serial: Serial) -> bool {
if self.session_lock.active
|| pointer.is_grabbed()
|| !matches!(button, BTN_LEFT | BTN_RIGHT)
@@ -629,10 +683,8 @@ impl Ferese {
Focus::Clear,
);
} else {
let horizontal_right =
location.x >= f64::from(rect.loc.x) + f64::from(rect.size.w) / 2.0;
let vertical_bottom =
location.y >= f64::from(rect.loc.y) + f64::from(rect.size.h) / 2.0;
let horizontal_right = location.x >= f64::from(rect.loc.x) + f64::from(rect.size.w) / 2.0;
let vertical_bottom = location.y >= f64::from(rect.loc.y) + f64::from(rect.size.h) / 2.0;
let edge = match (horizontal_right, vertical_bottom) {
(false, false) => XdgResizeEdge::TopLeft,
(true, false) => XdgResizeEdge::TopRight,
@@ -655,9 +707,7 @@ impl Ferese {
I: InputBackend,
E: AbsolutePositionEvent<I>,
{
let output = self
.focused_output()
.or_else(|| self.space.outputs().next())?;
let output = self.focused_output().or_else(|| self.space.outputs().next())?;
let geometry = self.space.output_geometry(output)?;
Some(event.position_transformed(geometry.size) + geometry.loc.to_f64())
@@ -666,14 +716,8 @@ impl Ferese {
fn swipe_navigation_blocked(&self) -> bool {
self.session_lock.active
|| self.active_shortcuts_inhibitor.is_some()
|| self
.seat
.get_pointer()
.is_some_and(|pointer| pointer.is_grabbed())
|| self
.seat
.get_keyboard()
.is_some_and(|keyboard| keyboard.is_grabbed())
|| self.seat.get_pointer().is_some_and(|pointer| pointer.is_grabbed())
|| self.seat.get_keyboard().is_some_and(|keyboard| keyboard.is_grabbed())
}
fn switch_relative_workspace(&mut self, next: bool, slide: Option<SwipeDirection>) {
@@ -689,10 +733,7 @@ impl Ferese {
.output_for_workspace(workspace.id)
.is_none_or(|owner| owner == output)
});
let Some(index) = candidates
.iter()
.position(|workspace| workspace.id == current)
else {
let Some(index) = candidates.iter().position(|workspace| workspace.id == current) else {
return;
};
let index = if next {
@@ -799,11 +840,7 @@ impl Ferese {
}
}
fn focus_window_under_pointer(
&mut self,
pointer: &PointerHandle<Self>,
position: Point<f64, Logical>,
) {
fn focus_window_under_pointer(&mut self, pointer: &PointerHandle<Self>, position: Point<f64, Logical>) {
if self.session_lock.active {
return;
}
@@ -813,10 +850,7 @@ impl Ferese {
}
// Keep the explicitly selected window focused until Overview's exit
// animation settles. Moving previews must not steal focus on pointer jitter.
if self.overview.is_presenting()
|| !self.input_settings.focus_follows_mouse
|| pointer.is_grabbed()
{
if self.overview.is_presenting() || !self.input_settings.focus_follows_mouse || pointer.is_grabbed() {
return;
}
if self.layer_under(position).is_some() {
@@ -952,9 +986,7 @@ impl Ferese {
BindingAction::Focus(direction) => self.focus_direction(direction),
BindingAction::Move(direction) => self.move_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),
BindingAction::SwitchWorkspace(workspace) => {
self.switch_workspace(u32::from(workspace));
}
@@ -974,6 +1006,55 @@ impl Ferese {
}
}
fn surface_tree_position(
root: &WlSurface,
target: &WlSurface,
geometry_origin: Point<i32, Logical>,
) -> Option<Point<i32, Logical>> {
use smithay::desktop::PopupManager;
surface_tree_offset(root, target).or_else(|| {
PopupManager::popups_for_surface(root).find_map(|(popup, offset)| {
surface_tree_offset(popup.wl_surface(), target)
.map(|local| geometry_origin + offset - popup.geometry().loc + local)
})
})
}
fn surface_tree_offset(root: &WlSurface, target: &WlSurface) -> Option<Point<i32, Logical>> {
use std::cell::Cell;
use smithay::backend::renderer::utils::RendererSurfaceStateUserData;
use smithay::wayland::compositor::{TraversalAction, with_surface_tree_downward};
let found = Cell::new(None);
with_surface_tree_downward(
root,
Point::<i32, Logical>::default(),
|_, states, location| {
states
.data_map
.get::<RendererSurfaceStateUserData>()
.and_then(|data| data.lock().unwrap().view())
.map_or(TraversalAction::SkipChildren, |view| {
TraversalAction::DoChildren(*location + view.offset)
})
},
|surface, states, location| {
if surface == target
&& let Some(view) = states
.data_map
.get::<RendererSurfaceStateUserData>()
.and_then(|data| data.lock().unwrap().view())
{
found.set(Some(*location + view.offset));
}
},
|_, _, _| found.get().is_none(),
);
found.get()
}
fn virtual_terminal(symbol: u32, ctrl: bool, alt: bool, direct: bool) -> Option<i32> {
if !direct {
return None;
+20 -81
View File
@@ -1,7 +1,5 @@
use std::{
collections::{HashMap, VecDeque},
sync::mpsc::SyncSender,
};
use std::collections::{HashMap, VecDeque};
use std::sync::mpsc::SyncSender;
use ferese_ipc::Response;
use serde::Deserialize;
@@ -91,24 +89,14 @@ fn edge(barrier: &Barrier, zones: &[Zone]) -> Option<Edge> {
}
match found {
Edge::Top | Edge::Bottom => {
let outside = y1
+ if matches!(found, Edge::Top) {
-0.5
} else {
0.5
};
let outside = y1 + if matches!(found, Edge::Top) { -0.5 } else { 0.5 };
outside >= f64::from(other.y)
&& outside < other.bottom()
&& x1 < other.right()
&& x2 + 1.0 > f64::from(other.x)
}
Edge::Left | Edge::Right => {
let outside = x1
+ if matches!(found, Edge::Left) {
-0.5
} else {
0.5
};
let outside = x1 + if matches!(found, Edge::Left) { -0.5 } else { 0.5 };
outside >= f64::from(other.x)
&& outside < other.right()
&& y1 < other.bottom()
@@ -186,10 +174,7 @@ impl InputCapture {
if self.sessions.len() >= MAX_SESSIONS || capabilities == 0 || capabilities & !3 != 0 {
return Err("Unsupported capabilities or input capture session limit reached".into());
}
self.next_session = self
.next_session
.checked_add(1)
.ok_or("Input capture IDs exhausted")?;
self.next_session = self.next_session.checked_add(1).ok_or("Input capture IDs exhausted")?;
let id = self.next_session;
self.sessions.insert(
id,
@@ -208,24 +193,15 @@ impl InputCapture {
pub(crate) fn zones(&self, id: u64) -> Result<Value, String> {
self.session(id)?;
Ok(
json!({"zone_set":self.zone_set, "zones":self.zones.iter().map(|zone|
(zone.width, zone.height, zone.x, zone.y)).collect::<Vec<_>>()}),
)
Ok(json!({"zone_set":self.zone_set, "zones":self.zones.iter().map(|zone|
(zone.width, zone.height, zone.x, zone.y)).collect::<Vec<_>>()}))
}
fn session(&self, id: u64) -> Result<&Session, String> {
self.sessions
.get(&id)
.ok_or("Unknown input capture session".into())
self.sessions.get(&id).ok_or("Unknown input capture session".into())
}
pub(crate) fn set_barriers(
&mut self,
id: u64,
zone_set: u32,
barriers: Vec<Barrier>,
) -> Result<Vec<u32>, String> {
pub(crate) fn set_barriers(&mut self, id: u64, zone_set: u32, barriers: Vec<Barrier>) -> Result<Vec<u32>, String> {
self.session(id)?;
if barriers.len() > MAX_BARRIERS {
return Err("Too many pointer barriers".into());
@@ -250,10 +226,7 @@ impl InputCapture {
}
pub(crate) fn enable(&mut self, id: u64) -> Result<(), String> {
let session = self
.sessions
.get_mut(&id)
.ok_or("Unknown input capture session")?;
let session = self.sessions.get_mut(&id).ok_or("Unknown input capture session")?;
if session.closed {
return Err("Input capture session is closed".into());
}
@@ -279,11 +252,7 @@ impl InputCapture {
if self.active() {
return true;
}
if !allowed
|| ![from.0, from.1, to.0, to.1]
.iter()
.all(|value| value.is_finite())
{
if !allowed || ![from.0, from.1, to.0, to.1].iter().all(|value| value.is_finite()) {
return false;
}
let hit = self
@@ -368,10 +337,7 @@ impl InputCapture {
self.restore_focus = true;
let session = self.sessions.get_mut(&id).unwrap();
let activation = session.activation.take();
self.enqueue(
id,
json!({"type":"deactivated", "activation_id":activation}),
);
self.enqueue(id, json!({"type":"deactivated", "activation_id":activation}));
self.flush();
}
Ok(())
@@ -396,18 +362,10 @@ impl InputCapture {
}
pub(crate) fn valid_position(&self, point: (f64, f64)) -> bool {
point.0.is_finite()
&& point.1.is_finite()
&& self.zones.iter().any(|zone| zone.contains(point))
point.0.is_finite() && point.1.is_finite() && self.zones.iter().any(|zone| zone.contains(point))
}
pub(crate) fn watch(
&mut self,
owner: u64,
request: u64,
id: u64,
response: SyncSender<Response>,
) {
pub(crate) fn watch(&mut self, owner: u64, request: u64, id: u64, response: SyncSender<Response>) {
if !self.sessions.contains_key(&id)
|| self.waiters.len() >= MAX_SESSIONS
|| self.waiters.values().any(|(_, token, _)| *token == id)
@@ -491,10 +449,7 @@ mod tests {
[3840, 0, 3840, 1079],
[0, 0, 0, 1079],
] {
assert!(
edge(&Barrier { id: 1, position }, &zones).is_some(),
"{position:?}"
);
assert!(edge(&Barrier { id: 1, position }, &zones).is_some(), "{position:?}");
}
for position in [
[1920, 0, 1920, 1079],
@@ -503,10 +458,7 @@ mod tests {
[50, 50, 50, 200],
[0, 5, 0, 4],
] {
assert!(
edge(&Barrier { id: 1, position }, &zones).is_none(),
"{position:?}"
);
assert!(edge(&Barrier { id: 1, position }, &zones).is_none(), "{position:?}");
}
let barrier = Barrier {
id: 1,
@@ -595,11 +547,7 @@ mod tests {
capture.enable(id).unwrap();
assert!(capture.motion((3839.0, 50.0), (3900.0, 50.0), true));
let activation = capture.sessions[&id].activation.unwrap();
assert!(
capture
.release(id, Some(activation.wrapping_add(1)))
.is_err()
);
assert!(capture.release(id, Some(activation.wrapping_add(1))).is_err());
capture.release(id, Some(activation)).unwrap();
assert!(capture.sessions[&id].enabled);
capture.update_zones(vec![zones()[0]]);
@@ -667,19 +615,10 @@ impl crate::Ferese {
&& !self.overview.is_presenting()
&& !pointer.is_grabbed()
&& !self.swipe.active()
&& self
.seat
.get_keyboard()
.is_none_or(|keyboard| !keyboard.is_grabbed())
&& self
.seat
.get_touch()
.is_none_or(|touch| !touch.is_grabbed())
&& self.seat.get_keyboard().is_none_or(|keyboard| !keyboard.is_grabbed())
&& self.seat.get_touch().is_none_or(|touch| !touch.is_grabbed())
&& !self.seat.keyboard_shortcuts_inhibited();
if !self
.input_capture
.motion((from.x, from.y), (to.x, to.y), allowed)
{
if !self.input_capture.motion((from.x, from.y), (to.x, to.y), allowed) {
return false;
}
let delta = if absolute {
+74 -188
View File
@@ -1,32 +1,26 @@
use std::env;
use std::fs;
use std::io;
use std::os::fd::AsRawFd;
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{
Arc,
mpsc::{SyncSender, sync_channel},
};
use std::thread;
use std::sync::mpsc::{SyncSender, sync_channel};
use std::time::Duration;
use std::{env, fs, io, thread};
use ferese_core::LayoutMode;
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
use ferese_layout::Direction;
use serde_json::{Value, json};
use smithay::output::Output;
use smithay::reexports::calloop::{EventLoop, LoopSignal, channel, timer};
use smithay::utils::Transform;
use crate::handlers::screencopy;
use crate::handlers::screenshot::{
Action, Geometry, OutputLayout, PartOutcome, PartSender, parse_geometry, plan,
};
use crate::handlers::screenshot_worker::{self, Encoded, Job, Worker};
use crate::{Ferese, config::OutputTransform};
use smithay::output::Output;
use crate::Ferese;
use crate::config::OutputTransform;
use crate::handlers::screenshot::{Action, Geometry, OutputLayout, PartOutcome, PartSender, parse_geometry, plan};
use crate::handlers::screenshot_worker::{Encoded, Job, Worker};
use crate::handlers::{screencopy, screenshot_worker};
const REQUEST_QUEUE_CAPACITY: usize = 128;
const MAX_CONNECTIONS: usize = 64;
@@ -90,18 +84,14 @@ pub(crate) struct IpcSocketGuard {
impl Drop for IpcSocketGuard {
fn drop(&mut self) {
if self.path.symlink_metadata().is_ok_and(|metadata| {
metadata.file_type().is_socket()
&& metadata.dev() == self.device
&& metadata.ino() == self.inode
metadata.file_type().is_socket() && metadata.dev() == self.device && metadata.ino() == self.inode
}) {
let _ = fs::remove_file(&self.path);
}
}
}
pub(crate) fn init(
event_loop: &mut EventLoop<'static, Ferese>,
) -> Result<ScreenshotInit, Box<dyn std::error::Error>> {
pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<ScreenshotInit, Box<dyn std::error::Error>> {
let path = socket_path()?;
prepare_parent(&path)?;
let listener = bind_listener(&path)?;
@@ -115,9 +105,7 @@ pub(crate) fn init(
let (sender, receiver): (channel::SyncSender<IpcEvent>, channel::Channel<IpcEvent>) =
channel::sync_channel(REQUEST_QUEUE_CAPACITY);
event_loop
.handle()
.insert_source(receiver, |event, _, state| {
event_loop.handle().insert_source(receiver, |event, _, state| {
if let channel::Event::Msg(event) = event {
let call = match event {
IpcEvent::Call(call) => call,
@@ -141,11 +129,9 @@ pub(crate) fn init(
));
} else if call.request.command == "input-capture-watch" {
if let Err(error) = validate_request(&call.request) {
let _ = call.response.try_send(Response::error(
call.request.id,
error.code,
error.message,
));
let _ = call
.response
.try_send(Response::error(call.request.id, error.code, error.message));
} else if let Some(id) = call.request.args["session"].as_u64() {
state
.input_capture
@@ -159,18 +145,13 @@ pub(crate) fn init(
}
} else if call.request.command == "session-watch" {
if let Err(error) = validate_request(&call.request) {
let _ = call.response.send(Response::error(
call.request.id,
error.code,
error.message,
));
let _ = call
.response
.send(Response::error(call.request.id, error.code, error.message));
} else if let Some(since) = call.request.args["since"].as_u64() {
state.portal_session.watch(
call.owner,
call.request.id,
since,
call.response,
);
state
.portal_session
.watch(call.owner, call.request.id, since, call.response);
} else {
let _ = call.response.send(Response::error(
call.request.id,
@@ -194,18 +175,14 @@ pub(crate) fn init(
// Screenshot readbacks publish parts here, so the loop wakes as soon as a
// readback lands, whichever backend produced it.
let (parts, part_events) = channel::channel::<PartOutcome>();
event_loop
.handle()
.insert_source(part_events, |event, _, state| {
event_loop.handle().insert_source(part_events, |event, _, state| {
if let channel::Event::Msg(outcome) = event {
state.on_screenshot_part(outcome);
}
})?;
let (encoded, encoded_events) = channel::sync_channel::<Encoded>(RESULT_QUEUE_CAPACITY);
event_loop
.handle()
.insert_source(encoded_events, |event, _, state| {
event_loop.handle().insert_source(encoded_events, |event, _, state| {
if let channel::Event::Msg(result) = event {
state.on_screenshot_encoded(result);
}
@@ -231,9 +208,9 @@ pub(crate) fn init(
// Readbacks are published by the render path, so a request whose outputs
// are not redrawn would otherwise wait forever. This re-arms to the exact
// remaining time whenever a request is outstanding, and idles otherwise.
event_loop.handle().insert_source(
timer::Timer::from_duration(DEADLINE_IDLE),
|_, _, state| {
event_loop
.handle()
.insert_source(timer::Timer::from_duration(DEADLINE_IDLE), |_, _, state| {
// Abandoned requests have already been answered, so their
// readbacks are simply dropped: they are not failed, because
// that would publish into a request that no longer exists.
@@ -244,8 +221,7 @@ pub(crate) fn init(
}
let next = state.screenshot.next_deadline().unwrap_or(DEADLINE_IDLE);
timer::TimeoutAction::ToDuration(next)
},
)?;
})?;
tracing::info!(path = %path.display(), "Ferese IPC is accepting connections");
@@ -265,11 +241,7 @@ pub(crate) struct ScreenshotInit {
pub(crate) _guard: IpcSocketGuard,
}
fn accept_connections(
listener: UnixListener,
sender: channel::SyncSender<IpcEvent>,
signal: LoopSignal,
) {
fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal) {
let active_connections = Arc::new(AtomicUsize::new(0));
let connection_ids = AtomicU64::new(1);
@@ -296,10 +268,7 @@ fn accept_connections(
}
let Some(permit) = try_acquire_connection(&active_connections) else {
tracing::warn!(
limit = MAX_CONNECTIONS,
"rejected IPC connection at worker limit"
);
tracing::warn!(limit = MAX_CONNECTIONS, "rejected IPC connection at worker limit");
continue;
};
let sender = sender.clone();
@@ -329,17 +298,10 @@ fn try_acquire_connection(active: &Arc<AtomicUsize>) -> Option<ConnectionPermit>
}
}
Some(ConnectionPermit {
active: active.clone(),
})
Some(ConnectionPermit { active: active.clone() })
}
fn serve_connection(
mut stream: UnixStream,
sender: channel::SyncSender<IpcEvent>,
signal: LoopSignal,
owner: u64,
) {
fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent>, signal: LoopSignal, owner: u64) {
let native_portal = crate::handlers::window_capture::is_portal(&stream);
let _connection = IpcConnection {
owner,
@@ -351,9 +313,7 @@ fn serve_connection(
Err(ferese_ipc::FrameError::Io(error))
if matches!(
error.kind(),
io::ErrorKind::UnexpectedEof
| io::ErrorKind::ConnectionReset
| io::ErrorKind::BrokenPipe
io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe
) =>
{
return;
@@ -365,8 +325,7 @@ fn serve_connection(
};
let (response, receiver) = sync_channel(1);
let exit_requested = request.command == "exit";
let screenshot_requested =
matches!(request.command.as_str(), "screenshot" | "screenshot-window");
let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window");
let call = IpcEvent::Call(IpcCall {
native_portal,
owner,
@@ -432,10 +391,7 @@ fn u32_arg(args: &Value, key: &str) -> Result<u32, CommandError> {
fn discard_undelivered_screenshot(response: &Response) {
if response.error.is_none()
&& let Some(path) = response
.result
.as_ref()
.and_then(|result| result["path"].as_str())
&& let Some(path) = response.result.as_ref().and_then(|result| result["path"].as_str())
&& let Err(error) = fs::remove_file(path)
&& error.kind() != io::ErrorKind::NotFound
{
@@ -459,11 +415,7 @@ impl Ferese {
| "cancel-session-end"
)
{
return Response::error(
request.id,
"session_locked",
"IPC unavailable while session is locked",
);
return Response::error(request.id, "session_locked", "IPC unavailable while session is locked");
}
if let Err(error) = validate_request(&request) {
return Response::error(request.id, error.code, error.message);
@@ -475,12 +427,7 @@ impl Ferese {
}
}
fn dispatch_ipc_command(
&mut self,
owner: u64,
command: &str,
args: &Value,
) -> Result<Value, CommandError> {
fn dispatch_ipc_command(&mut self, owner: u64, command: &str, args: &Value) -> Result<Value, CommandError> {
match command {
"portal-inhibit" => {
let inhibition = serde_json::from_value(args.clone())
@@ -522,9 +469,9 @@ impl Ferese {
.validate_end(
u32_arg(args, "token")?,
u32_arg(args, "inhibitor-revision")?,
args["force"].as_bool().ok_or_else(|| {
CommandError::new("invalid_argument", "Missing force confirmation")
})?,
args["force"]
.as_bool()
.ok_or_else(|| CommandError::new("invalid_argument", "Missing force confirmation"))?,
)
.map_err(|error| CommandError::new("inhibited", error))?;
return Ok(json!({}));
@@ -534,17 +481,17 @@ impl Ferese {
.commit_end(
u32_arg(args, "token")?,
u32_arg(args, "inhibitor-revision")?,
args["force"].as_bool().ok_or_else(|| {
CommandError::new("invalid_argument", "Missing force confirmation")
})?,
args["force"]
.as_bool()
.ok_or_else(|| CommandError::new("invalid_argument", "Missing force confirmation"))?,
)
.map_err(|error| CommandError::new("inhibited", error))?;
return Ok(json!({}));
}
"logind-session-ending" => {
let ending = args["ending"].as_bool().ok_or_else(|| {
CommandError::new("invalid_argument", "Missing shutdown state")
})?;
let ending = args["ending"]
.as_bool()
.ok_or_else(|| CommandError::new("invalid_argument", "Missing shutdown state"))?;
self.portal_session.set_phase(if ending { 3 } else { 1 });
return Ok(json!({}));
}
@@ -573,8 +520,7 @@ impl Ferese {
let xkb = state.xkb().lock().unwrap();
// SAFETY: the ref-counted keymap does not escape the locked Xkb;
// only its owned string is retained for the EIS keyboard.
unsafe { xkb.keymap() }
.get_as_string(smithay::input::keyboard::xkb::KEYMAP_FORMAT_TEXT_V1)
unsafe { xkb.keymap() }.get_as_string(smithay::input::keyboard::xkb::KEYMAP_FORMAT_TEXT_V1)
});
return Ok(json!({"session":id, "keymap":keymap}));
}
@@ -583,9 +529,9 @@ impl Ferese {
return self
.input_capture
.zones(
args["session"].as_u64().ok_or_else(|| {
CommandError::new("invalid_capture", "Missing session")
})?,
args["session"]
.as_u64()
.ok_or_else(|| CommandError::new("invalid_capture", "Missing session"))?,
)
.map_err(|e| CommandError::new("invalid_capture", e));
}
@@ -642,12 +588,8 @@ impl Ferese {
let result = if command == "input-capture-disable" {
self.input_capture.disable(id)
} else {
self.input_capture.release(
id,
args["activation_id"]
.as_u64()
.and_then(|id| u32::try_from(id).ok()),
)
self.input_capture
.release(id, args["activation_id"].as_u64().and_then(|id| u32::try_from(id).ok()))
};
result.map_err(|e| CommandError::new("invalid_capture", e))?;
if active && !self.input_capture.active() {
@@ -696,9 +638,7 @@ impl Ferese {
.get("pid")
.and_then(Value::as_u64)
.and_then(|pid| u32::try_from(pid).ok())
.ok_or_else(|| {
CommandError::new("invalid_argument", "pid must be an unsigned process ID")
})?;
.ok_or_else(|| CommandError::new("invalid_argument", "pid must be an unsigned process ID"))?;
return Ok(json!(self.has_client_surfaces(pid)));
}
"get-workspaces" => return Ok(self.workspaces_json()),
@@ -783,10 +723,7 @@ impl Ferese {
return;
}
};
let specs = planned
.iter()
.map(|part| part.spec(&layouts[part.index]))
.collect();
let specs = planned.iter().map(|part| part.spec(&layouts[part.index])).collect();
// admit stores the reply only on success, so a clone survives the
// rejection path and every caller is answered exactly once.
@@ -794,8 +731,7 @@ impl Ferese {
let id = match self.screenshot.admit(request.id, response, specs) {
Ok(id) => id,
Err(message) => {
let _ =
fallback.try_send(Response::error(request.id, "screenshot_rejected", message));
let _ = fallback.try_send(Response::error(request.id, "screenshot_rejected", message));
return;
}
};
@@ -819,11 +755,7 @@ impl Ferese {
fn start_window_screenshot(&mut self, request: Request, response: SyncSender<Response>) {
let reject = |message: String| {
let _ = response.try_send(Response::error(
request.id,
"window_capture_failed",
message,
));
let _ = response.try_send(Response::error(request.id, "window_capture_failed", message));
};
if self.session_lock.active || !screencopy::capture_allowed() {
reject("Screen capture is unavailable".into());
@@ -877,19 +809,13 @@ impl Ferese {
let capture = match self.screenshot.admit(request.id, response, vec![spec]) {
Ok(capture) => capture,
Err(error) => {
let _ =
fallback.try_send(Response::error(request.id, "screenshot_rejected", error));
let _ = fallback.try_send(Response::error(request.id, "screenshot_rejected", error));
return;
}
};
let result = if let Some(backend) = &self.nested_backend {
match backend.try_borrow_mut() {
Ok(mut backend) => crate::winit::capture_window_buffer(
backend.renderer(),
&window,
geometry,
scale,
),
Ok(mut backend) => crate::winit::capture_window_buffer(backend.renderer(), &window, geometry, scale),
Err(_) => Err("Window renderer is busy".into()),
}
} else if let Some(backend) = &mut self.direct_backend {
@@ -905,9 +831,7 @@ impl Ferese {
}
pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) {
let action = self
.screenshot
.on_part(outcome.request, outcome.part, outcome.result);
let action = self.screenshot.on_part(outcome.request, outcome.part, outcome.result);
self.apply_screenshot_action(action);
}
@@ -925,8 +849,7 @@ impl Ferese {
.as_ref()
.is_some_and(|worker| worker.submit(Job { request, frames }).is_ok());
if !submitted {
self.screenshot
.reject(request, "Screenshot encoding queue is full");
self.screenshot.reject(request, "Screenshot encoding queue is full");
}
}
Action::Delivered(path) => {
@@ -955,8 +878,7 @@ impl Ferese {
fn has_client_surfaces(&self, pid: u32) -> bool {
use smithay::reexports::wayland_server::Resource;
let belongs_to =
|surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface| {
let belongs_to = |surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface| {
surface
.client()
.and_then(|client| client.get_credentials(&self.display_handle).ok())
@@ -974,19 +896,15 @@ impl Ferese {
}
fn windows_json(&self) -> Value {
use smithay::wayland::{compositor::with_states, shell::xdg::XdgToplevelSurfaceData};
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
let mut windows = self
.window_ids
.iter()
.filter_map(|(window, id)| {
let toplevel = window.toplevel()?;
let (app_id, title) = 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();
(
attributes.app_id.clone().unwrap_or_default(),
attributes.title.clone().unwrap_or_default(),
@@ -1044,10 +962,7 @@ impl Ferese {
.connected_outputs
.iter()
.map(|info| {
let mapped = self
.space
.outputs()
.find(|output| output.name() == info.connector);
let mapped = self.space.outputs().find(|output| output.name() == info.connector);
let id = mapped.and_then(|output| self.output_id(output));
let geometry = mapped.and_then(|output| self.space.output_geometry(output));
let workspace = id.and_then(|id| self.output_workspaces.active_workspace(id));
@@ -1105,9 +1020,7 @@ impl Ferese {
})
})
.collect::<Vec<_>>();
outputs.sort_by(|left, right| {
left["connector"].as_str().cmp(&right["connector"].as_str())
});
outputs.sort_by(|left, right| left["connector"].as_str().cmp(&right["connector"].as_str()));
return Value::Array(outputs);
}
@@ -1163,9 +1076,7 @@ fn geometry_arg(args: &Value) -> Result<Geometry, String> {
match args.get("geometry") {
None | Some(Value::Null) => Ok(Geometry::All),
Some(value) => {
let text = value
.as_str()
.ok_or("geometry must be a string like \"x,y WxH\"")?;
let text = value.as_str().ok_or("geometry must be a string like \"x,y WxH\"")?;
parse_geometry(text)
}
}
@@ -1179,10 +1090,7 @@ fn validate_request(request: &Request) -> Result<(), CommandError> {
));
}
if request.kind != "command" {
return Err(CommandError::new(
"invalid_type",
"expected type \"command\"",
));
return Err(CommandError::new("invalid_type", "expected type \"command\""));
}
Ok(())
@@ -1247,12 +1155,7 @@ fn workspace_arg(args: &Value) -> Result<u32, CommandError> {
.and_then(Value::as_u64)
.and_then(|index| u32::try_from(index).ok())
.filter(|index| *index > 0)
.ok_or_else(|| {
CommandError::new(
"invalid_argument",
"index must be a positive 32-bit integer",
)
})
.ok_or_else(|| CommandError::new("invalid_argument", "index must be a positive 32-bit integer"))
}
fn socket_path() -> Result<PathBuf, io::Error> {
@@ -1264,9 +1167,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
}
fn prepare_parent(socket: &Path) -> Result<(), io::Error> {
let parent = socket
.parent()
.expect("the Ferese control socket always has a parent");
let parent = socket.parent().expect("the Ferese control socket always has a parent");
match fs::symlink_metadata(parent) {
Ok(metadata) => {
if !metadata.is_dir() || metadata.file_type().is_symlink() {
@@ -1311,11 +1212,7 @@ fn bind_listener(path: &Path) -> Result<UnixListener, io::Error> {
}
fn peer_uid(stream: &UnixStream) -> Result<u32, io::Error> {
let mut credentials = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut credentials = libc::ucred { pid: 0, uid: 0, gid: 0 };
let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: `credentials` and `length` point to initialized, correctly sized
@@ -1361,10 +1258,7 @@ mod tests {
#[test]
fn parses_direction_arguments() {
assert_eq!(
direction_arg(&json!({ "direction": "left" })).unwrap(),
Direction::Left
);
assert_eq!(direction_arg(&json!({ "direction": "left" })).unwrap(), Direction::Left);
assert!(direction_arg(&json!({ "direction": "diagonal" })).is_err());
}
@@ -1402,9 +1296,7 @@ mod tests {
let worker = thread::spawn(move || serve_connection(server, sender, signal, 1));
let invalid = b"not-json";
client
.write_all(&(invalid.len() as u32).to_be_bytes())
.unwrap();
client.write_all(&(invalid.len() as u32).to_be_bytes()).unwrap();
client.write_all(invalid).unwrap();
client.shutdown(Shutdown::Write).unwrap();
client
@@ -1451,10 +1343,7 @@ mod tests {
let socket = root.join("control.sock");
let first = bind_listener(&socket).unwrap();
assert_eq!(
bind_listener(&socket).unwrap_err().kind(),
io::ErrorKind::AddrInUse
);
assert_eq!(bind_listener(&socket).unwrap_err().kind(), io::ErrorKind::AddrInUse);
drop(first);
let replacement = bind_listener(&socket).unwrap();
@@ -1470,10 +1359,7 @@ mod tests {
let socket = root.join("control.sock");
fs::write(&socket, b"keep").unwrap();
assert_eq!(
bind_listener(&socket).unwrap_err().kind(),
io::ErrorKind::AddrInUse
);
assert_eq!(bind_listener(&socket).unwrap_err().kind(), io::ErrorKind::AddrInUse);
assert_eq!(fs::read(&socket).unwrap(), b"keep");
fs::remove_dir_all(root).unwrap();
}
+20 -28
View File
@@ -28,16 +28,14 @@ mod wallpaper;
mod window_rules;
mod winit;
use std::{
error::Error,
io,
process::{Child, Command},
thread,
time::Duration,
};
use std::error::Error;
use std::process::{Child, Command};
use std::time::Duration;
use std::{io, thread};
use calloop::signals::{Signal, Signals};
use smithay::reexports::{calloop::EventLoop, wayland_server::Display};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_server::Display;
pub use state::{Ferese, RuntimeConfig};
use tracing::{info, warn};
@@ -60,8 +58,8 @@ fn main() -> Result<(), Box<dyn Error>> {
return Ok(());
}
let launch = LaunchConfig::from_environment()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let launch =
LaunchConfig::from_environment().map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let initial_source = config::config_path().and_then(|path| std::fs::read_to_string(path).ok());
let config = match initial_source.as_deref() {
Some(source) => Config::parse_source(source)?,
@@ -81,15 +79,15 @@ fn main() -> Result<(), Box<dyn Error>> {
state.config_source = initial_source.filter(|source| source.len() <= 60 * 1024);
overview::init_font_loader(&mut event_loop, &mut state)?;
backends::init(launch.backend, &mut event_loop, &mut state)?;
let mut monitor = config::config_path().and_then(
|path| match reload::ConfigMonitor::with_wakeup(path, Some(event_loop.get_signal())) {
let mut monitor = config::config_path().and_then(|path| {
match reload::ConfigMonitor::with_wakeup(path, Some(event_loop.get_signal())) {
Ok(monitor) => Some(monitor),
Err(error) => {
warn!(%error, "automatic config watching unavailable; use feresectl reload-config");
None
}
},
);
}
});
use calloop::timer::{TimeoutAction, Timer};
info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients");
let mut child = spawn_client(&mut state, launch.client, launch.client_capabilities);
@@ -141,9 +139,9 @@ fn main() -> Result<(), Box<dyn Error>> {
if state.input_capture.restore_focus {
state.restore_input_capture_focus();
}
state.apply_unlocked_pointer_hint();
let wallpaper_retry = state.wallpaper.take_retry_wakeup();
if std::mem::take(&mut state.cursor_redraw_pending) || wallpaper_changed || wallpaper_retry
{
if std::mem::take(&mut state.cursor_redraw_pending) || wallpaper_changed || wallpaper_retry {
backends::direct::render_all(state);
}
if let Err(error) = state
@@ -242,12 +240,11 @@ fn terminate_child(child: &mut Child) {
#[cfg(test)]
mod startup_tests {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use super::*;
use std::{
io::{Read, Write},
os::unix::net::UnixStream,
sync::Arc,
};
#[test]
fn deferred_cursor_redraw_runs_after_input_lock_is_released() {
@@ -295,15 +292,10 @@ mod startup_tests {
}
display.dispatch_clients(&mut ()).unwrap();
display.handle().flush_clients().unwrap();
client
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
client.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
let mut reply = [0u8; 12];
client.read_exact(&mut reply).unwrap();
assert_eq!(u32::from_ne_bytes(reply[0..4].try_into().unwrap()), 2);
assert_eq!(
u32::from_ne_bytes(reply[4..8].try_into().unwrap()) & 0xffff,
0
);
assert_eq!(u32::from_ne_bytes(reply[4..8].try_into().unwrap()) & 0xffff, 0);
}
}
+7 -22
View File
@@ -1,7 +1,5 @@
use std::{
collections::VecDeque,
time::{Duration, Instant},
};
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use smithay::utils::{Physical, Rectangle};
@@ -94,14 +92,8 @@ impl RenderMetrics {
.unwrap_or(0);
self.frame_times.make_contiguous().sort_unstable();
let percentile = |percent: usize| {
let index = (self.frame_times.len() * percent)
.div_ceil(100)
.saturating_sub(1);
self.frame_times
.get(index)
.copied()
.unwrap_or_default()
.as_micros()
let index = (self.frame_times.len() * percent).div_ceil(100).saturating_sub(1);
self.frame_times.get(index).copied().unwrap_or_default().as_micros()
};
tracing::info!(
target: "ferese::render",
@@ -131,8 +123,7 @@ impl RenderMetrics {
}
fn rectangle_area(rectangle: &Rectangle<i32, Physical>) -> u64 {
u64::try_from(rectangle.size.w.max(0)).unwrap_or(0)
* u64::try_from(rectangle.size.h.max(0)).unwrap_or(0)
u64::try_from(rectangle.size.w.max(0)).unwrap_or(0) * u64::try_from(rectangle.size.h.max(0)).unwrap_or(0)
}
#[cfg(test)]
@@ -211,14 +202,8 @@ mod tests {
for micros in 0..600 {
metrics.record_frame(Duration::from_micros(micros), &[], 0, FrameEffectMetrics);
}
assert_eq!(
metrics.frame_times.front(),
Some(&Duration::from_micros(88))
);
assert_eq!(
metrics.frame_times.back(),
Some(&Duration::from_micros(599))
);
assert_eq!(metrics.frame_times.front(), Some(&Duration::from_micros(88)));
assert_eq!(metrics.frame_times.back(), Some(&Duration::from_micros(599)));
assert!(
metrics
.frame_times
+44 -149
View File
@@ -1,23 +1,17 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex, mpsc},
time::Duration,
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
use ab_glyph::{Font, FontArc, FontVec, ScaleFont};
use ferese_animation::{AnimatedRect, AnimatedValue, SpringConfig};
use ferese_core::{OutputId, WorkspaceId};
use ferese_layout::{Direction, Rect, WindowId};
use smithay::{
output::Output,
utils::{Logical, Point},
};
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::element::memory::MemoryRenderBuffer;
use smithay::output::Output;
use smithay::utils::{Logical, Point, Transform};
use crate::Ferese;
use ab_glyph::{Font, FontArc, FontVec, ScaleFont};
use smithay::{
backend::{allocator::Fourcc, renderer::element::memory::MemoryRenderBuffer},
utils::Transform,
};
const OVERVIEW_MARGIN: f64 = 48.0;
const OVERVIEW_GAP: f64 = 40.0;
@@ -46,9 +40,7 @@ pub(crate) fn init_font_loader(
) -> Result<(), Box<dyn std::error::Error>> {
use smithay::reexports::calloop::channel;
let (results, source) = channel::sync_channel::<(String, Option<FontArc>)>(1);
event_loop
.handle()
.insert_source(source, |event, _, state| {
event_loop.handle().insert_source(source, |event, _, state| {
if let channel::Event::Msg((family, font)) = event
&& state.overview.complete_font_load(&family, font)
&& state.overview.is_presenting()
@@ -92,13 +84,8 @@ pub(crate) fn init_font_loader(
Ok(())
}
type OverviewStateLabel = HashMap<
(String, u64, [u32; 4]),
(
MemoryRenderBuffer,
smithay::utils::Size<i32, smithay::utils::Buffer>,
),
>;
type OverviewStateLabel =
HashMap<(String, u64, [u32; 4]), (MemoryRenderBuffer, smithay::utils::Size<i32, smithay::utils::Buffer>)>;
#[derive(Debug)]
pub(crate) struct OverviewState {
@@ -154,19 +141,11 @@ pub(crate) fn workspace_strip(bounds: Rect) -> Rect {
fn window_area(bounds: Rect) -> Rect {
let strip = workspace_strip(bounds);
let top = strip.y + strip.height + 16.0;
Rect::new(
bounds.x,
top,
bounds.width,
(bounds.y + bounds.height - top).max(1.0),
)
Rect::new(bounds.x, top, bounds.width, (bounds.y + bounds.height - top).max(1.0))
}
fn contains(rect: Rect, point: Point<f64, Logical>) -> bool {
point.x >= rect.x
&& point.x < rect.x + rect.width
&& point.y >= rect.y
&& point.y < rect.y + rect.height
point.x >= rect.x && point.x < rect.x + rect.width && point.y >= rect.y && point.y < rect.y + rect.height
}
fn strip_capacity(strip: Rect) -> usize {
@@ -212,10 +191,7 @@ impl OverviewState {
text: &str,
scale: f64,
color: [f32; 4],
) -> Option<(
MemoryRenderBuffer,
smithay::utils::Size<i32, smithay::utils::Buffer>,
)> {
) -> Option<(MemoryRenderBuffer, smithay::utils::Size<i32, smithay::utils::Buffer>)> {
let key = (text.to_owned(), scale.to_bits(), color.map(f32::to_bits));
if let Some(buffer) = self.labels.get(&key) {
return Some(buffer.clone());
@@ -234,10 +210,7 @@ impl OverviewState {
let mut pen = 2.0;
for ch in text.chars() {
let id = font.glyph_id(ch);
let glyph = id.with_scale_and_position(
scaled.scale(),
ab_glyph::point(pen, scaled.ascent() + 1.0),
);
let glyph = id.with_scale_and_position(scaled.scale(), ab_glyph::point(pen, scaled.ascent() + 1.0));
pen += scaled.h_advance(id);
if let Some(outline) = font.outline_glyph(glyph) {
let bounds = outline.px_bounds();
@@ -257,14 +230,8 @@ impl OverviewState {
});
}
}
let buffer = MemoryRenderBuffer::from_slice(
&pixels,
Fourcc::Abgr8888,
(width, height),
1,
Transform::Normal,
None,
);
let buffer =
MemoryRenderBuffer::from_slice(&pixels, Fourcc::Abgr8888, (width, height), 1, Transform::Normal, None);
if self.labels.len() >= 128 {
self.labels.clear();
}
@@ -291,13 +258,7 @@ impl OverviewState {
.selected
.filter(visible)
.or_else(|| preferred.filter(visible))
.or_else(|| {
windows
.iter()
.copied()
.filter(visible)
.min_by_key(|id| id.0)
});
.or_else(|| windows.iter().copied().filter(visible).min_by_key(|id| id.0));
}
pub(crate) fn selected(&self) -> Option<WindowId> {
@@ -319,13 +280,9 @@ impl OverviewState {
.filter(|(id, _)| Some(**id) != self.selected)
.filter_map(|(id, presentation)| {
let center = rect_center(presentation.current);
directional_distance(direction, current_center, center)
.map(|distance| (*id, distance))
})
.min_by(|(left_id, left), (right_id, right)| {
left.total_cmp(right)
.then_with(|| left_id.0.cmp(&right_id.0))
directional_distance(direction, current_center, center).map(|distance| (*id, distance))
})
.min_by(|(left_id, left), (right_id, right)| left.total_cmp(right).then_with(|| left_id.0.cmp(&right_id.0)))
.map(|(id, _)| id);
let Some(next) = next else {
return false;
@@ -396,10 +353,7 @@ impl OverviewState {
presentation.snap();
}
}
if self
.selected
.is_none_or(|id| !self.presentations.contains_key(&id))
{
if self.selected.is_none_or(|id| !self.presentations.contains_key(&id)) {
self.selected = self.presentations.keys().copied().min_by_key(|id| id.0);
}
}
@@ -429,12 +383,7 @@ impl OverviewState {
}
}
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 {
let elapsed = if animations_enabled {
elapsed + delta.as_secs_f64()
@@ -506,18 +455,12 @@ impl Ferese {
window: &smithay::desktop::Window,
scale: f64,
width: f64,
) -> Option<(
MemoryRenderBuffer,
smithay::utils::Size<i32, smithay::utils::Buffer>,
)> {
use smithay::wayland::{compositor::with_states, shell::xdg::XdgToplevelSurfaceData};
) -> Option<(MemoryRenderBuffer, smithay::utils::Size<i32, smithay::utils::Buffer>)> {
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
let surface = window.toplevel()?.wl_surface();
let title = with_states(surface, |states| {
let data = states
.data_map
.get::<XdgToplevelSurfaceData>()?
.lock()
.ok()?;
let data = states.data_map.get::<XdgToplevelSurfaceData>()?.lock().ok()?;
data.title
.clone()
.filter(|title| !title.trim().is_empty())
@@ -539,10 +482,7 @@ impl Ferese {
&mut self,
workspace: WorkspaceId,
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
.label(&name, scale, self.theme_settings.text_primary_color.0)
@@ -621,8 +561,7 @@ impl Ferese {
.collect::<Vec<_>>()
})
.unwrap_or_default();
self.overview
.select_workspace_windows(&windows, self.focused_window);
self.overview.select_workspace_windows(&windows, self.focused_window);
}
}
@@ -637,12 +576,7 @@ impl Ferese {
Some(presented)
}
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)?;
if !self.overview.is_presenting() {
let presented = self.presented_window_rect(id)?;
@@ -651,11 +585,7 @@ impl Ferese {
let source = geometry.client.committed_size?;
let presented = self.overview.presented_rect(id, geometry.visual.current);
if source.width <= 0
|| source.height <= 0
|| presented.width <= 0.0
|| presented.height <= 0.0
{
if source.width <= 0 || source.height <= 0 || presented.width <= 0.0 || presented.height <= 0.0 {
return None;
}
@@ -692,8 +622,7 @@ impl Ferese {
.copied()
.unwrap_or_else(|| selected_index.saturating_sub(capacity - 1))
.min(max_offset);
let card_width =
((strip.width - 24.0 - WORKSPACE_CARD_GAP * capacity.saturating_sub(1) as f64)
let card_width = ((strip.width - 24.0 - WORKSPACE_CARD_GAP * capacity.saturating_sub(1) as f64)
/ capacity as f64)
.clamp(1.0, 160.0);
let visible_count = workspaces.len().saturating_sub(offset).min(capacity);
@@ -726,9 +655,7 @@ impl Ferese {
(rect.width - 12.0).max(1.0),
(rect.height - 26.0).max(1.0),
);
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 {
workspace: workspace.id,
rect,
@@ -796,11 +723,7 @@ impl Ferese {
let ordered = self.workspaces.ordered();
let current = cards
.first()
.and_then(|card| {
ordered
.iter()
.position(|workspace| workspace.id == card.workspace)
})
.and_then(|card| ordered.iter().position(|workspace| workspace.id == card.workspace))
.unwrap_or(0);
let next = if delta > 0.0 {
current.saturating_add(1)
@@ -863,12 +786,7 @@ fn overview_layout(bounds: Rect, windows: &[(WindowId, Rect)]) -> HashMap<Window
preview_layout(bounds, windows, OVERVIEW_MARGIN, OVERVIEW_GAP)
}
fn preview_layout(
bounds: Rect,
windows: &[(WindowId, Rect)],
margin: f64,
gap: f64,
) -> HashMap<WindowId, Rect> {
fn preview_layout(bounds: Rect, windows: &[(WindowId, Rect)], margin: f64, gap: f64) -> HashMap<WindowId, Rect> {
if windows.is_empty() {
return HashMap::new();
}
@@ -881,10 +799,8 @@ fn preview_layout(
let cell_size = |columns: usize| {
let rows = windows.len().div_ceil(columns);
(
((content_width - gap * columns.saturating_sub(1) as f64) / columns as f64)
.clamp(1.0, 480.0),
((content_height - gap * rows.saturating_sub(1) as f64) / rows as f64)
.clamp(1.0, 420.0),
((content_width - gap * columns.saturating_sub(1) as f64) / columns as f64).clamp(1.0, 480.0),
((content_height - gap * rows.saturating_sub(1) as f64) / rows as f64).clamp(1.0, 420.0),
)
};
let score = |columns: usize| {
@@ -926,10 +842,8 @@ fn preview_layout(
let width = (source.width * scale).max(1.0);
let height = (source.height * scale).max(1.0);
let row_count = (windows.len() - row * columns).min(columns);
let row_width =
cell_width * row_count as f64 + gap * row_count.saturating_sub(1) as f64;
let cell_x =
bounds.x + (bounds.width - row_width) * 0.5 + column as f64 * (cell_width + gap);
let row_width = cell_width * row_count as f64 + gap * row_count.saturating_sub(1) as f64;
let cell_x = bounds.x + (bounds.width - row_width) * 0.5 + column as f64 * (cell_width + gap);
let cell_y = origin_y + row as f64 * (cell_height + gap);
let target = Rect::new(
cell_x + (cell_width - width) / 2.0,
@@ -947,11 +861,7 @@ fn rect_center(rect: Rect) -> (f64, f64) {
(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0)
}
fn directional_distance(
direction: Direction,
current: (f64, f64),
candidate: (f64, f64),
) -> Option<f64> {
fn directional_distance(direction: Direction, current: (f64, f64), candidate: (f64, f64)) -> Option<f64> {
let delta_x = candidate.0 - current.0;
let delta_y = candidate.1 - current.1;
let (primary, secondary) = match direction {
@@ -1001,10 +911,7 @@ mod tests {
requests.request(family);
}
receiver.try_recv().unwrap();
assert!(matches!(
receiver.try_recv(),
Err(mpsc::TryRecvError::Empty)
));
assert!(matches!(receiver.try_recv(), Err(mpsc::TryRecvError::Empty)));
assert_eq!(*requests.latest.lock().unwrap(), "third");
drop(requests);
assert!(matches!(receiver.recv(), Err(mpsc::RecvError)));
@@ -1024,9 +931,7 @@ mod tests {
let strip = Rect::new(origin + 24.0, 64.0, 1552.0, 132.0);
for count in [1, 3, 8] {
let start = centered_row_start(strip, count, 160.0);
let end = start
+ count as f64 * 160.0
+ count.saturating_sub(1) as f64 * WORKSPACE_CARD_GAP;
let end = start + count as f64 * 160.0 + count.saturating_sub(1) as f64 * WORKSPACE_CARD_GAP;
assert!((start - strip.x - (strip.x + strip.width - end)).abs() < 0.001);
}
}
@@ -1043,11 +948,7 @@ mod tests {
assert!(strip.height < bounds.height / 2.0);
assert!(area.y > strip.y + strip.height);
let windows = [(WindowId(1), Rect::new(0.0, 0.0, 800.0, 900.0))];
assert!(
overview_layout(area, &windows)
.values()
.all(|rect| rect.y >= area.y)
);
assert!(overview_layout(area, &windows).values().all(|rect| rect.y >= area.y));
}
}
@@ -1146,10 +1047,7 @@ mod tests {
#[test]
fn overview_grid_preserves_preview_aspect_ratio() {
let source = Rect::new(0.0, 0.0, 1200.0, 800.0);
let layout = overview_layout(
Rect::new(0.0, 0.0, 1920.0, 1080.0),
&[(WindowId(1), source)],
);
let layout = overview_layout(Rect::new(0.0, 0.0, 1920.0, 1080.0), &[(WindowId(1), source)]);
let preview = layout[&WindowId(1)];
assert!((preview.width / preview.height - source.width / source.height).abs() < 0.001);
@@ -1235,10 +1133,7 @@ mod tests {
let mut overview = OverviewState::default();
overview.enter(
HashMap::from([
(left, (left_rect, left_rect)),
(right, (right_rect, right_rect)),
]),
HashMap::from([(left, (left_rect, left_rect)), (right, (right_rect, right_rect))]),
Some(left),
false,
);
+31 -89
View File
@@ -1,12 +1,11 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::mpsc::SyncSender;
use std::time::{Duration, Instant};
use ferese_ipc::Response;
use serde::Deserialize;
use serde_json::{Value, json};
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use std::{
collections::{HashMap, HashSet, VecDeque},
sync::mpsc::SyncSender,
time::{Duration, Instant},
};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -104,31 +103,21 @@ impl PortalSession {
pub(crate) fn remove(&mut self, owner: u64) {
self.waiters.remove(&owner);
if self.phase == 2
&& self
.query
.as_ref()
.is_some_and(|query| query.owner == Some(owner))
{
if self.phase == 2 && self.query.as_ref().is_some_and(|query| query.owner == Some(owner)) {
self.set_phase(1);
}
if self.inhibitors.remove(&owner).is_some() {
self.inhibitor_revision = self.inhibitor_revision.wrapping_add(1);
}
let monitor_removed = self.monitors.remove(&owner);
let acknowledged = self
.query
.as_mut()
.is_some_and(|query| query.pending.remove(&owner));
let acknowledged = self.query.as_mut().is_some_and(|query| query.pending.remove(&owner));
if monitor_removed || acknowledged {
self.changed();
}
}
fn idle_inhibited(&self) -> bool {
self.inhibitors
.values()
.any(|inhibitor| inhibitor.flags & 8 != 0)
self.inhibitors.values().any(|inhibitor| inhibitor.flags & 8 != 0)
}
pub(crate) fn begin_query(&mut self) -> u32 {
@@ -152,11 +141,7 @@ impl PortalSession {
}
pub(crate) fn cancel_query(&mut self, token: u32) {
if self
.query
.as_ref()
.is_some_and(|query| query.token == token)
{
if self.query.as_ref().is_some_and(|query| query.token == token) {
self.set_phase(1);
}
}
@@ -169,42 +154,19 @@ impl PortalSession {
.is_some_and(|query| query.pending.is_empty() || Instant::now() >= query.deadline)
}
pub(crate) fn validate_end(
&self,
token: u32,
inhibitor_revision: u32,
force: bool,
) -> Result<(), String> {
if !self.query_ready()
|| !self
.query
.as_ref()
.is_some_and(|query| query.token == token)
{
return Err(
"Session-ending confirmation expired or applications are still responding".into(),
);
pub(crate) fn validate_end(&self, token: u32, inhibitor_revision: u32, force: bool) -> Result<(), String> {
if !self.query_ready() || !self.query.as_ref().is_some_and(|query| query.token == token) {
return Err("Session-ending confirmation expired or applications are still responding".into());
}
if self.inhibitor_revision != inhibitor_revision
|| (!force
&& self
.inhibitors
.values()
.any(|inhibitor| inhibitor.flags & 1 != 0))
|| (!force && self.inhibitors.values().any(|inhibitor| inhibitor.flags & 1 != 0))
{
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
return Err("Applications changed their inhibitors; review the confirmation again".into());
}
Ok(())
}
pub(crate) fn commit_end(
&mut self,
token: u32,
inhibitor_revision: u32,
force: bool,
) -> Result<(), String> {
pub(crate) fn commit_end(&mut self, token: u32, inhibitor_revision: u32, force: bool) -> Result<(), String> {
self.validate_end(token, inhibitor_revision, force)?;
self.set_phase(3);
Ok(())
@@ -247,13 +209,7 @@ impl PortalSession {
}
}
pub(crate) fn watch(
&mut self,
owner: u64,
id: u64,
since: u64,
response: SyncSender<Response>,
) {
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: SyncSender<Response>) {
if since != self.revision {
let _ = response.try_send(Response::success(id, self.snapshot()));
} else {
@@ -262,12 +218,13 @@ impl PortalSession {
}
pub(crate) fn snapshot(&self) -> Value {
let inhibitors = self.inhibitors.values().map(|inhibitor| json!({"flags": inhibitor.flags, "app": inhibitor.app, "reason": inhibitor.reason})).collect::<Vec<_>>();
let inhibitors = self
.inhibitors
.values()
.map(|inhibitor| json!({"flags": inhibitor.flags, "app": inhibitor.app, "reason": inhibitor.reason}))
.collect::<Vec<_>>();
let remaining = self.query.as_ref().map_or(0, |query| {
query
.deadline
.saturating_duration_since(Instant::now())
.as_millis() as u64
query.deadline.saturating_duration_since(Instant::now()).as_millis() as u64
});
json!({"screensaver-active": self.locked, "session-state": self.phase, "revision": self.revision,
"inhibitors": inhibitors, "inhibitor-revision": self.inhibitor_revision,
@@ -278,24 +235,22 @@ impl PortalSession {
impl crate::Ferese {
pub(crate) fn refresh_idle_inhibition(&mut self) {
self.idle_notifier_state.set_is_inhibited(
!self.idle_inhibitors.is_empty() || self.portal_session.idle_inhibited(),
);
self.idle_notifier_state
.set_is_inhibited(!self.idle_inhibitors.is_empty() || self.portal_session.idle_inhibited());
}
pub(crate) fn end_portal_session(&mut self) {
let token = self.portal_session.query.as_ref().map(|query| query.token);
let result = self.loop_handle.insert_source(
Timer::from_duration(Duration::from_millis(200)),
move |_, _, state| {
let result =
self.loop_handle
.insert_source(Timer::from_duration(Duration::from_millis(200)), move |_, _, state| {
if state.portal_session.phase == 3
&& state.portal_session.query.as_ref().map(|query| query.token) == token
{
state.loop_signal.stop();
}
TimeoutAction::Drop
},
);
});
if let Err(error) = result {
tracing::error!(%error, "failed to schedule session ending");
self.loop_signal.stop();
@@ -354,20 +309,10 @@ mod tests {
let approved = session.inhibitor_revision;
session.register(1, inhibition(1)).unwrap();
assert!(session.commit_end(token, approved, true).is_err());
assert!(
session
.commit_end(token, session.inhibitor_revision, false)
.is_err()
);
session
.commit_end(token, session.inhibitor_revision, true)
.unwrap();
assert!(session.commit_end(token, session.inhibitor_revision, false).is_err());
session.commit_end(token, session.inhibitor_revision, true).unwrap();
assert_eq!(session.snapshot()["session-state"], 3);
assert!(
session
.commit_end(token, session.inhibitor_revision, true)
.is_err()
);
assert!(session.commit_end(token, session.inhibitor_revision, true).is_err());
}
#[test]
@@ -410,10 +355,7 @@ mod tests {
let response = receiver.try_recv().unwrap();
assert_eq!(response.result.unwrap()["screensaver-active"], true);
session.set_locked(false);
assert_eq!(
session.snapshot()["transitions"].as_array().unwrap().len(),
2
);
assert_eq!(session.snapshot()["transitions"].as_array().unwrap().len(), 2);
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
session.watch(1, 11, session.revision, sender);
session.remove(1);
+17 -45
View File
@@ -1,7 +1,5 @@
use std::{
collections::{HashMap, VecDeque},
time::Instant,
};
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
use serde::Deserialize;
use serde_json::{Value, json};
@@ -28,9 +26,7 @@ struct Registration {
impl Registration {
fn timestamp(&self) -> u64 {
self.latest
.0
.saturating_add(self.latest.1.elapsed().as_millis() as u64)
self.latest.0.saturating_add(self.latest.1.elapsed().as_millis() as u64)
}
fn release_all(&mut self) {
@@ -57,11 +53,7 @@ impl PortalShortcuts {
return Err("Shortcut limit reached".into());
}
let keymap = crate::config::physical_keymap(input).map_err(|error| error.to_string())?;
if self
.0
.get(&owner)
.is_some_and(|registration| registration.closed)
{
if self.0.get(&owner).is_some_and(|registration| registration.closed) {
return Err("Shortcut session has ended".into());
}
let mut parsed: Vec<(Shortcut, Binding)> = Vec::new();
@@ -70,17 +62,11 @@ impl PortalShortcuts {
.trigger
.split('+')
.any(|part| part.trim().eq_ignore_ascii_case("num"));
if shortcut.id.is_empty()
|| shortcut.id.len() > 256
|| shortcut.id.chars().any(char::is_control)
{
if shortcut.id.is_empty() || shortcut.id.len() > 256 || shortcut.id.chars().any(char::is_control) {
return Err("Invalid shortcut identifier".into());
}
let binding =
Binding::portal_trigger(&shortcut.trigger).map_err(|error| error.to_string())?;
if configured
.iter()
.any(|other| binding.conflicts(other, &keymap))
let binding = Binding::portal_trigger(&shortcut.trigger).map_err(|error| error.to_string())?;
if configured.iter().any(|other| binding.conflicts(other, &keymap))
|| parsed
.iter()
.any(|(other, key)| other.id == shortcut.id || binding.conflicts(key, &keymap))
@@ -120,9 +106,9 @@ impl PortalShortcuts {
return;
};
for registration in self.0.values_mut() {
registration.shortcuts.retain(|(_, binding)| {
!configured.iter().any(|key| binding.conflicts(key, &keymap))
});
registration
.shortcuts
.retain(|(_, binding)| !configured.iter().any(|key| binding.conflicts(key, &keymap)));
let active_ids = registration
.shortcuts
.iter()
@@ -158,13 +144,7 @@ impl PortalShortcuts {
})
}
pub(crate) fn press(
&mut self,
key: Keycode,
symbols: &[u32],
modifiers: &ModifiersState,
timestamp: u64,
) -> bool {
pub(crate) fn press(&mut self, key: Keycode, symbols: &[u32], modifiers: &ModifiersState, timestamp: u64) -> bool {
for registration in self.0.values_mut() {
let Some((shortcut, _)) = registration.shortcuts.iter().find(|(shortcut, binding)| {
(!shortcut.num_required || modifiers.num_lock)
@@ -212,9 +192,10 @@ impl PortalShortcuts {
#[cfg(test)]
mod tests {
use super::*;
use smithay::input::keyboard::keysyms;
use super::*;
fn shortcut(id: &str, trigger: &str) -> Shortcut {
Shortcut {
id: id.into(),
@@ -244,10 +225,7 @@ mod tests {
.register(1, vec![shortcut("one", "Super+q")], &configured, &input)
.is_err()
);
assert_eq!(
shortcuts.poll(1, false)["shortcuts"][0]["trigger"],
"Ctrl+Alt+a"
);
assert_eq!(shortcuts.poll(1, false)["shortcuts"][0]["trigger"], "Ctrl+Alt+a");
shortcuts.remove(1);
assert_eq!(shortcuts.poll(1, false)["closed"], true);
shortcuts
@@ -296,12 +274,7 @@ mod tests {
.register(1, vec![shortcut("one", "Ctrl+Alt+a")], &[], &input)
.unwrap();
shortcuts.reconcile(&[Binding::portal_trigger("Ctrl+Alt+a").unwrap()], &input);
assert!(
shortcuts.poll(1, false)["shortcuts"]
.as_array()
.unwrap()
.is_empty()
);
assert!(shortcuts.poll(1, false)["shortcuts"].as_array().unwrap().is_empty());
assert!(Binding::portal_trigger("Ctrl+Alt+Escape").is_err());
assert!(Binding::portal_trigger("Ctrl+Alt+F3").is_err());
}
@@ -332,9 +305,8 @@ mod tests {
#[test]
fn physical_conflicts_include_secondary_layouts_and_num_modifier_is_honored() {
let config = crate::config::Config::parse_source(
"binding keys=\"Ctrl+AD01\" match=\"physical\" action=\"none\"\n",
)
let config =
crate::config::Config::parse_source("binding keys=\"Ctrl+AD01\" match=\"physical\" action=\"none\"\n")
.unwrap();
let mut input = config.input_settings().unwrap();
input.xkb_layout = "us,fr".into();
+110 -54
View File
@@ -2,21 +2,63 @@
//! logical pixels. Content, clips and decorations share those exact edges.
use std::time::{Duration, Instant};
use smithay::{
backend::renderer::{
element::{Element, Id, Kind, RenderElement},
gles::{
GlesError, GlesFrame, GlesRenderer, GlesTexProgram, GlesTexture, Uniform,
element::PixelShaderElement,
},
utils::{CommitCounter, DamageSet},
},
utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Transform},
use smithay::backend::renderer::element::{
Element, Id, Kind, RenderElement, RenderElementPresentationState, RenderElementStates,
};
use smithay::backend::renderer::gles::element::PixelShaderElement;
use smithay::backend::renderer::gles::{GlesError, GlesFrame, GlesRenderer, GlesTexProgram, GlesTexture, Uniform};
use smithay::backend::renderer::utils::{CommitCounter, DamageSet};
use smithay::desktop::utils::OutputPresentationFeedback;
use smithay::output::Output;
use smithay::reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Transform};
use smithay::wayland::compositor::SurfaceData;
pub(crate) const HANDOFF: Duration = Duration::from_millis(80);
pub(crate) const SNAPSHOT_BUDGET: usize = 64 * 1024 * 1024;
pub(crate) fn take_output_feedback(
state: &crate::Ferese,
output: &Output,
rendered: &RenderElementStates,
flags: wp_presentation_feedback::Kind,
) -> OutputPresentationFeedback {
use smithay::desktop::layer_map_for_output;
use smithay::desktop::utils::{OutputPresentationFeedback, surface_presentation_feedback_flags_from_states};
let mut feedback = OutputPresentationFeedback::new(output);
let visible_output =
|surface: &WlSurface, _: &SurfaceData| rendered_feedback_output(output, surface.into(), rendered);
let surface_flags = |surface: &WlSurface, _: &SurfaceData| {
flags | surface_presentation_feedback_flags_from_states(surface, rendered)
};
for window in state.space.elements().filter(|window| {
state
.window_ids
.get(*window)
.is_some_and(|id| state.window_belongs_to_output(*id, output))
}) {
window.take_presentation_feedback(&mut feedback, visible_output, surface_flags);
}
for layer in layer_map_for_output(output).layers() {
layer.take_presentation_feedback(&mut feedback, visible_output, surface_flags);
}
feedback
}
fn rendered_feedback_output(output: &Output, id: Id, rendered: &RenderElementStates) -> Option<Output> {
rendered
.element_render_state(id)
.is_some_and(|state| {
state.visible_area > 0 && state.presentation_state != RenderElementPresentationState::Skipped
})
.then(|| output.clone())
}
#[derive(Default)]
pub(crate) struct CallbackClock {
last_sent: Option<Instant>,
@@ -24,8 +66,7 @@ pub(crate) struct CallbackClock {
impl CallbackClock {
pub(crate) fn deadline(&self, now: Instant, interval: Duration) -> Instant {
self.last_sent
.map_or(now, |last| (last + interval).max(now))
self.last_sent.map_or(now, |last| (last + interval).max(now))
}
pub(crate) fn sent(&mut self, now: Instant) {
@@ -48,6 +89,7 @@ pub(crate) fn physical_rect(
let top = ((rect.y - f64::from(origin.y)) * scale).round() as i32;
let right = ((rect.x + rect.width - f64::from(origin.x)) * scale).round() as i32;
let bottom = ((rect.y + rect.height - f64::from(origin.y)) * scale).round() as i32;
Rectangle::new(
(left, top).into(),
((right - left).max(1), (bottom - top).max(1)).into(),
@@ -122,11 +164,7 @@ impl Element for PhysicalShaderElement {
self.geometry
}
fn damage_since(
&self,
_: Scale<f64>,
commit: Option<CommitCounter>,
) -> DamageSet<i32, Physical> {
fn damage_since(&self, _: Scale<f64>, commit: Option<CommitCounter>) -> DamageSet<i32, Physical> {
if commit == Some(self.current_commit()) {
DamageSet::default()
} else {
@@ -187,11 +225,7 @@ impl Element for NativeTextureElement {
Kind::Unspecified
}
fn damage_since(
&self,
_: Scale<f64>,
commit: Option<CommitCounter>,
) -> DamageSet<i32, Physical> {
fn damage_since(&self, _: Scale<f64>, commit: Option<CommitCounter>) -> DamageSet<i32, Physical> {
if commit == Some(self.commit) {
DamageSet::default()
} else {
@@ -227,26 +261,59 @@ impl RenderElement<GlesRenderer> for NativeTextureElement {
mod tests {
use super::*;
#[test]
fn presentation_feedback_excludes_missing_and_occluded_surfaces() {
use smithay::backend::renderer::element::RenderElementState;
use smithay::output::{PhysicalProperties, Subpixel};
let output = Output::new(
"owner".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
let id = Id::new();
let mut states = RenderElementStates::default();
assert!(rendered_feedback_output(&output, id.clone(), &states).is_none());
for state in [
RenderElementState {
visible_area: 0,
presentation_state: RenderElementPresentationState::Skipped,
},
RenderElementState {
visible_area: 0,
presentation_state: RenderElementPresentationState::Rendering { reason: None },
},
] {
states.states.insert(id.clone(), state);
assert!(rendered_feedback_output(&output, id.clone(), &states).is_none());
}
for presentation_state in [
RenderElementPresentationState::Rendering { reason: None },
RenderElementPresentationState::ZeroCopy,
] {
states.states.insert(
id.clone(),
RenderElementState {
visible_area: 32,
presentation_state,
},
);
assert_eq!(
rendered_feedback_output(&output, id.clone(), &states),
Some(output.clone())
);
}
}
#[test]
fn reversing_a_grow_replaces_the_smaller_snapshot() {
assert!(!snapshot_covers_source(
(500, 600).into(),
(1000, 600).into(),
1.0,
1.0
));
assert!(snapshot_covers_source(
(1000, 600).into(),
(500, 600).into(),
1.0,
1.0
));
assert!(!snapshot_covers_source(
(1000, 600).into(),
(500, 600).into(),
1.0,
1.8
));
assert!(!snapshot_covers_source((500, 600).into(), (1000, 600).into(), 1.0, 1.0));
assert!(snapshot_covers_source((1000, 600).into(), (500, 600).into(), 1.0, 1.0));
assert!(!snapshot_covers_source((1000, 600).into(), (500, 600).into(), 1.0, 1.8));
}
#[test]
@@ -347,25 +414,14 @@ mod tests {
fn fractional_motion_does_not_quantize_to_logical_pixels() {
let rect = ferese_layout::Rect::new(0.3, 0.3, 100.2, 80.2);
assert_eq!(physical_rect(rect, (0, 0).into(), 1.8).loc, (1, 1).into());
assert_eq!(
physical_rect(rect, (0, 0).into(), 1.8).size,
(180, 144).into()
);
assert_eq!(physical_rect(rect, (0, 0).into(), 1.8).size, (180, 144).into());
}
#[test]
fn adjacent_frames_share_edges_at_all_output_scales() {
for scale in [1.0, 1.25, 1.5, 1.8, 2.0] {
let left = physical_rect(
ferese_layout::Rect::new(13.3, 0.0, 500.4, 800.0),
(0, 0).into(),
scale,
);
let right = physical_rect(
ferese_layout::Rect::new(513.7, 0.0, 500.4, 800.0),
(0, 0).into(),
scale,
);
let left = physical_rect(ferese_layout::Rect::new(13.3, 0.0, 500.4, 800.0), (0, 0).into(), scale);
let right = physical_rect(ferese_layout::Rect::new(513.7, 0.0, 500.4, 800.0), (0, 0).into(), scale);
assert_eq!(left.loc.x + left.size.w, right.loc.x);
}
}
+12 -20
View File
@@ -1,14 +1,12 @@
use std::{
io,
os::{
fd::AsRawFd,
unix::{net::UnixStream, process::CommandExt},
},
process::Command,
sync::Arc,
};
use std::io;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
use std::os::unix::process::CommandExt;
use std::process::Command;
use std::sync::Arc;
use crate::{Ferese, state::ClientState};
use crate::Ferese;
use crate::state::ClientState;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct ClientCapabilities(u8);
@@ -36,8 +34,8 @@ pub(crate) fn prepare_command(
command: &mut Command,
capabilities: ClientCapabilities,
) -> io::Result<Vec<UnixStream>> {
let split_shell_control = capabilities.contains(ClientCapabilities::EFFECTS)
&& capabilities.contains(ClientCapabilities::SHELL_CONTROL);
let split_shell_control =
capabilities.contains(ClientCapabilities::EFFECTS) && capabilities.contains(ClientCapabilities::SHELL_CONTROL);
let primary_capabilities = if split_shell_control {
ClientCapabilities::EFFECTS
} else {
@@ -56,10 +54,7 @@ pub(crate) fn prepare_command(
if split_shell_control {
let control = insert_private_client(state, ClientCapabilities::SHELL_CONTROL)?;
command.env(
"FERESE_SHELL_CONTROL_SOCKET",
control.as_raw_fd().to_string(),
);
command.env("FERESE_SHELL_CONTROL_SOCKET", control.as_raw_fd().to_string());
clients.push(control);
}
@@ -81,10 +76,7 @@ pub(crate) fn prepare_command(
Ok(clients)
}
fn insert_private_client(
state: &mut Ferese,
capabilities: ClientCapabilities,
) -> io::Result<UnixStream> {
fn insert_private_client(state: &mut Ferese, capabilities: ClientCapabilities) -> io::Result<UnixStream> {
let (server, client) = UnixStream::pair()?;
state.display_handle.insert_client(
+10 -26
View File
@@ -1,11 +1,10 @@
//! Filesystem events wake the loop; only pending edits need a debounce timer.
use std::io::Read;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use std::{
io::Read,
path::PathBuf,
sync::mpsc,
time::{Duration, Instant},
};
fn read_source(path: &std::path::Path) -> Result<String, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
@@ -40,14 +39,10 @@ impl ConfigMonitor {
let (sender, events) = mpsc::sync_channel(1);
let target = path.clone();
let event_wakeup = wakeup.clone();
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event
&& !event.kind.is_access()
&& event
.paths
.iter()
.any(|p| p == &target || target.starts_with(p))
&& event.paths.iter().any(|p| p == &target || target.starts_with(p))
{
let _ = sender.try_send(());
if let Some(wakeup) = &event_wakeup {
@@ -224,10 +219,7 @@ mod tests {
assert_eq!(result.unwrap(), "animations {\n speed 0.75\n}\n");
break;
}
assert!(
Instant::now() < deadline,
"atomic save notification never settled"
);
assert!(Instant::now() < deadline, "atomic save notification never settled");
std::thread::sleep(Duration::from_millis(10));
}
}
@@ -242,17 +234,9 @@ mod tests {
let mut monitor = ConfigMonitor::new(path).unwrap();
let now = Instant::now();
monitor.dirty = Some(now);
assert_eq!(
monitor.next_deadline(),
Some(now + Duration::from_millis(120))
);
assert_eq!(monitor.next_deadline(), Some(now + Duration::from_millis(120)));
assert!(monitor.poll(now + Duration::from_millis(119)).is_none());
assert!(
monitor
.poll(now + Duration::from_millis(121))
.unwrap()
.is_ok()
);
assert!(monitor.poll(now + Duration::from_millis(121)).unwrap().is_ok());
assert_eq!(monitor.next_deadline(), None);
}
}
+1 -2
View File
@@ -72,8 +72,7 @@ mod tests {
#[test]
fn shrinking_commit_retains_the_previous_full_frame_geometry() {
let source = Rectangle::new((4, 8).into(), (1200, 800).into());
let transaction =
ResizeTransaction::new(1.into(), Duration::ZERO).with_source_geometry(source);
let transaction = ResizeTransaction::new(1.into(), Duration::ZERO).with_source_geometry(source);
assert_eq!(transaction.source_geometry(), Some(source));
}
}
+35 -81
View File
@@ -1,30 +1,22 @@
use crate::Ferese;
use smithay::reexports::calloop::{
RegistrationToken,
timer::{TimeoutAction, Timer},
};
use smithay::{
output::Output,
reexports::{
wayland_protocols::ext::session_lock::v1::server::{
ext_session_lock_manager_v1::ExtSessionLockManagerV1,
ext_session_lock_surface_v1::ExtSessionLockSurfaceV1,
ext_session_lock_v1::{self, ExtSessionLockV1},
},
wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, Resource, protocol::wl_output::WlOutput,
},
},
utils::{Logical, Point, SERIAL_COUNTER},
wayland::session_lock::{
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use smithay::output::Output;
use smithay::reexports::calloop::RegistrationToken;
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::wayland_protocols::ext::session_lock::v1::server::ext_session_lock_manager_v1::ExtSessionLockManagerV1;
use smithay::reexports::wayland_protocols::ext::session_lock::v1::server::ext_session_lock_surface_v1::ExtSessionLockSurfaceV1;
use smithay::reexports::wayland_protocols::ext::session_lock::v1::server::ext_session_lock_v1;
use smithay::reexports::wayland_protocols::ext::session_lock::v1::server::ext_session_lock_v1::ExtSessionLockV1;
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, Resource};
use smithay::utils::{Logical, Point, SERIAL_COUNTER};
use smithay::wayland::session_lock::{
LockSurface, SessionLockHandler, SessionLockManagerState, SessionLockState, SessionLocker,
},
};
use std::{
collections::{HashMap, HashSet},
time::{Duration, Instant},
};
use crate::Ferese;
#[derive(Clone, Copy, Debug, serde::Deserialize)]
#[serde(default)]
pub(crate) struct IdleSettings {
@@ -54,10 +46,7 @@ impl IdleSettings {
fn next_poll(self, elapsed: Duration, animate: bool) -> Duration {
let dim_at = Duration::from_secs(self.dim_after_seconds);
if animate
&& self.dim_after_seconds != 0
&& elapsed >= dim_at
&& elapsed < dim_at + Duration::from_millis(500)
if animate && self.dim_after_seconds != 0 && elapsed >= dim_at && elapsed < dim_at + Duration::from_millis(500)
{
return Duration::from_millis(16);
}
@@ -72,8 +61,7 @@ impl IdleSettings {
}
fn appearance(self, elapsed: Duration) -> (f32, bool) {
let sleeping = self.sleep_after_seconds != 0
&& elapsed >= Duration::from_secs(self.sleep_after_seconds);
let sleeping = self.sleep_after_seconds != 0 && elapsed >= Duration::from_secs(self.sleep_after_seconds);
let dim = if self.dim_after_seconds == 0 {
0.0
} else {
@@ -99,17 +87,13 @@ pub(crate) struct Lock {
idle_since: Option<Instant>,
pub(crate) idle_opacity: f32,
pub(crate) sleeping: bool,
pub(crate) idle_overlays:
HashMap<Output, smithay::backend::renderer::element::solid::SolidColorBuffer>,
pub(crate) idle_overlays: HashMap<Output, smithay::backend::renderer::element::solid::SolidColorBuffer>,
idle_timer: Option<RegistrationToken>,
}
impl Lock {
fn ready_for_idle<'a>(&self, outputs: impl Iterator<Item = &'a Output>) -> bool {
self.active
&& outputs
.into_iter()
.all(|output| self.presented.contains(output))
self.active && outputs.into_iter().all(|output| self.presented.contains(output))
}
pub(crate) fn output_added(&mut self, output: &Output) {
@@ -146,14 +130,11 @@ impl Ferese {
return;
}
let since = self.session_lock.idle_since.get_or_insert(now);
let (mut opacity, sleeping) = self
.lock_idle
.appearance(now.saturating_duration_since(*since));
let (mut opacity, sleeping) = self.lock_idle.appearance(now.saturating_duration_since(*since));
if !self.animations_enabled() && opacity > 0.0 && !sleeping {
opacity = 0.65;
}
let changed =
opacity != self.session_lock.idle_opacity || sleeping != self.session_lock.sleeping;
let changed = opacity != self.session_lock.idle_opacity || sleeping != self.session_lock.sleeping;
let was_sleeping = self.session_lock.sleeping;
self.session_lock.idle_opacity = opacity;
self.session_lock.sleeping = sleeping;
@@ -190,12 +171,7 @@ impl Ferese {
let surface = self
.focused_output()
.and_then(|output| self.session_lock.surfaces.get(output))
.or_else(|| {
self.session_lock
.surfaces
.values()
.find(|surface| surface.alive())
})
.or_else(|| self.session_lock.surfaces.values().find(|surface| surface.alive()))
.filter(|surface| surface.alive())
.map(|surface| surface.wl_surface().clone());
self.seat
@@ -234,12 +210,7 @@ impl Ferese {
}
pub(crate) fn lock_frame_callbacks(&self, output: &Output) {
if let Some(surface) = self
.session_lock
.surfaces
.get(output)
.filter(|surface| surface.alive())
{
if let Some(surface) = self.session_lock.surfaces.get(output).filter(|surface| surface.alive()) {
smithay::desktop::utils::send_frames_surface_tree(
surface.wl_surface(),
output,
@@ -263,9 +234,9 @@ impl SessionLockHandler for Ferese {
self.cancel_logout_confirmation();
self.session_lock.active = true;
self.session_lock.idle_since = Some(Instant::now());
match self.loop_handle.insert_source(
Timer::from_duration(Duration::from_secs(1)),
|_, _, state| {
match self
.loop_handle
.insert_source(Timer::from_duration(Duration::from_secs(1)), |_, _, state| {
if !state.session_lock.active {
return TimeoutAction::Drop;
}
@@ -274,13 +245,8 @@ impl SessionLockHandler for Ferese {
.session_lock
.idle_since
.map_or(Duration::ZERO, |since| since.elapsed());
TimeoutAction::ToDuration(
state
.lock_idle
.next_poll(elapsed, state.animations_enabled()),
)
},
) {
TimeoutAction::ToDuration(state.lock_idle.next_poll(elapsed, state.animations_enabled()))
}) {
Ok(token) => self.session_lock.idle_timer = Some(token),
Err(error) => tracing::warn!(%error, "could not schedule lock screen inactivity"),
}
@@ -295,10 +261,7 @@ impl SessionLockHandler for Ferese {
// already encoding has no pending capture left, so it is terminated
// here and its staged file is discarded when the worker reports back.
for request in self.screenshot.terminate_all() {
tracing::debug!(
request,
"cancelled an in-flight screenshot for the session lock"
);
tracing::debug!(request, "cancelled an in-flight screenshot for the session lock");
}
self.set_overview_active(false);
let serial = SERIAL_COUNTER.next_serial();
@@ -345,9 +308,8 @@ impl SessionLockHandler for Ferese {
if let Some(output) = Output::from_resource(&output)
&& let Some(geometry) = self.space.output_geometry(&output)
{
surface.with_pending_state(|state| {
state.size = Some((geometry.size.w as u32, geometry.size.h as u32).into())
});
surface
.with_pending_state(|state| state.size = Some((geometry.size.w as u32, geometry.size.h as u32).into()));
self.session_lock.surfaces.insert(output, surface);
self.focus_lock_surface();
}
@@ -377,10 +339,7 @@ impl Dispatch<ExtSessionLockV1, SessionLockState> for Ferese {
return;
}
if matches!(request, ext_session_lock_v1::Request::GetLockSurface { .. }) && !owner {
lock.post_error(
ext_session_lock_v1::Error::InvalidUnlock,
"not the active lock owner",
);
lock.post_error(ext_session_lock_v1::Error::InvalidUnlock, "not the active lock owner");
return;
}
<SessionLockManagerState as Dispatch<ExtSessionLockV1, SessionLockState, Ferese>>::request(
@@ -413,10 +372,7 @@ mod tests {
policy.next_poll(Duration::from_secs(30), true),
Duration::from_millis(16)
);
assert_eq!(
policy.next_poll(Duration::from_secs(30), false),
Duration::from_secs(1)
);
assert_eq!(policy.next_poll(Duration::from_secs(30), false), Duration::from_secs(1));
}
#[test]
@@ -498,9 +454,7 @@ mod tests {
.validate()
.is_err()
);
assert!(
crate::config::Config::parse_source("lock-screen { dim-after-seconds -1; }").is_err()
);
assert!(crate::config::Config::parse_source("lock-screen { dim-after-seconds -1; }").is_err());
assert!(
crate::config::Config::parse_source("lock-screen { sleep-after-seconds 90000; }")
.unwrap()
+30 -92
View File
@@ -1,17 +1,15 @@
use ferese_core::{OutputId, WorkspaceId};
use ferese_layout::WindowId;
use ferese_protocols::shell::v1::server::{
ferese_shell_manager_v1::{self, FereseShellManagerV1},
ferese_shell_v1::{self, FereseShellV1},
};
use smithay::{
reexports::wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
},
wayland::{compositor::with_states, shell::xdg::XdgToplevelSurfaceData},
};
use ferese_protocols::shell::v1::server::ferese_shell_manager_v1::FereseShellManagerV1;
use ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1;
use ferese_protocols::shell::v1::server::{ferese_shell_manager_v1, ferese_shell_v1};
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource};
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::XdgToplevelSurfaceData;
use crate::{Ferese, private_client::ClientCapabilities, state::ClientState};
use crate::Ferese;
use crate::private_client::ClientCapabilities;
use crate::state::ClientState;
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseShellManagerV1, _>(4, ());
@@ -52,11 +50,9 @@ impl GlobalDispatch<FereseShellManagerV1, ()> for Ferese {
}
fn can_view(client: Client, _global_data: &()) -> bool {
client.get_data::<ClientState>().is_some_and(|state| {
state
.capabilities
.contains(ClientCapabilities::SHELL_CONTROL)
})
client
.get_data::<ClientState>()
.is_some_and(|state| state.capabilities.contains(ClientCapabilities::SHELL_CONTROL))
}
}
@@ -104,19 +100,13 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
return;
}
match request {
ferese_shell_v1::Request::ActivateWindow {
window_hi,
window_lo,
} => state.handle_shell_window_request(
ferese_shell_v1::Request::ActivateWindow { window_hi, window_lo } => state.handle_shell_window_request(
shell,
ferese_shell_v1::FailedRequest::ActivateWindow,
join_id(window_hi, window_lo),
ShellWindowAction::Activate,
),
ferese_shell_v1::Request::CloseWindow {
window_hi,
window_lo,
} => state.handle_shell_window_request(
ferese_shell_v1::Request::CloseWindow { window_hi, window_lo } => state.handle_shell_window_request(
shell,
ferese_shell_v1::FailedRequest::CloseWindow,
join_id(window_hi, window_lo),
@@ -129,11 +119,7 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
let workspace = WorkspaceId(join_u64(workspace_hi, workspace_lo));
if !state.activate_managed_workspace(workspace) {
send_request_failed(
shell,
ferese_shell_v1::FailedRequest::ActivateWorkspace,
workspace.0,
);
send_request_failed(shell, ferese_shell_v1::FailedRequest::ActivateWorkspace, workspace.0);
}
}
ferese_shell_v1::Request::ConfirmLogout { serial } => {
@@ -149,36 +135,21 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
force,
} => {
if force <= 1 {
state.confirm_portal_logout(
shell,
serial,
query_token,
inhibitor_revision,
force == 1,
);
state.confirm_portal_logout(shell, serial, query_token, inhibitor_revision, force == 1);
}
}
ferese_shell_v1::Request::CancelLogout { serial } => {
if state.logout_owner.as_ref() == Some(&shell.id())
&& state.pending_logout == Some(serial)
{
if state.logout_owner.as_ref() == Some(&shell.id()) && state.pending_logout == Some(serial) {
state.cancel_logout_confirmation();
}
}
ferese_shell_v1::Request::EnterOverview => state.set_overview_active(true),
ferese_shell_v1::Request::ExitOverview => state.set_overview_active(false),
ferese_shell_v1::Request::SelectOverviewWindow {
window_hi,
window_lo,
} => {
ferese_shell_v1::Request::SelectOverviewWindow { window_hi, window_lo } => {
let id = join_id(window_hi, window_lo);
if !state.select_overview_window(id) {
send_request_failed(
shell,
ferese_shell_v1::FailedRequest::SelectOverviewWindow,
id.0,
);
send_request_failed(shell, ferese_shell_v1::FailedRequest::SelectOverviewWindow, id.0);
}
}
ferese_shell_v1::Request::Destroy => {
@@ -202,12 +173,7 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
}
}
fn logout_confirmation_matches(
pending: Option<u32>,
query: Option<u32>,
serial: u32,
token: u32,
) -> bool {
fn logout_confirmation_matches(pending: Option<u32>, query: Option<u32>, serial: u32, token: u32) -> bool {
pending == Some(serial) && query == Some(token)
}
@@ -221,14 +187,7 @@ fn consume_logout_confirmation(pending: &mut Option<u32>, serial: u32) -> bool {
}
impl Ferese {
fn confirm_portal_logout(
&mut self,
shell: &FereseShellV1,
serial: u32,
token: u32,
revision: u32,
force: bool,
) {
fn confirm_portal_logout(&mut self, shell: &FereseShellV1, serial: u32, token: u32, revision: u32, force: bool) {
if self.logout_owner.as_ref() != Some(&shell.id())
|| !logout_confirmation_matches(self.pending_logout, self.logout_query, serial, token)
{
@@ -269,9 +228,7 @@ impl Ferese {
self.logout_query = Some(self.portal_session.begin_query());
self.pending_logout = Some(serial);
self.logout_owner = Some(shell.id());
let output_name = self
.focused_output()
.map_or_else(String::new, |output| output.name());
let output_name = self.focused_output().map_or_else(String::new, |output| output.name());
shell.logout_requested(serial, output_name);
}
@@ -280,11 +237,7 @@ impl Ferese {
self.portal_session.cancel_query(token);
}
if let Some(serial) = self.pending_logout.take() {
for shell in self
.shell_resources
.iter()
.filter_map(|shell| shell.upgrade().ok())
{
for shell in self.shell_resources.iter().filter_map(|shell| shell.upgrade().ok()) {
if self.logout_owner.as_ref() == Some(&shell.id()) && shell.version() >= 3 {
shell.logout_cancelled(serial);
}
@@ -294,8 +247,7 @@ impl Ferese {
}
pub(crate) fn send_shell_snapshots(&mut self) {
self.shell_resources
.retain(|resource| resource.upgrade().is_ok());
self.shell_resources.retain(|resource| resource.upgrade().is_ok());
if self.shell_resources.is_empty() {
self.last_shell_snapshot = None;
return;
@@ -325,8 +277,7 @@ impl Ferese {
// New subscribers always receive a complete snapshot. Do not update the
// broadcast cache here: existing subscribers may still need this state.
self.shell_snapshot_serial = self.shell_snapshot_serial.wrapping_add(1);
self.shell_snapshot()
.send(shell, self.shell_snapshot_serial);
self.shell_snapshot().send(shell, self.shell_snapshot_serial);
}
fn output_snapshots(&self) -> Vec<OutputSnapshot> {
@@ -354,9 +305,8 @@ impl Ferese {
.into_iter()
.map(|workspace| {
let output = self.output_workspaces.output_for_workspace(workspace.id);
let active = output.is_some_and(|output| {
self.output_workspaces.active_workspace(output) == Some(workspace.id)
});
let active =
output.is_some_and(|output| self.output_workspaces.active_workspace(output) == Some(workspace.id));
WorkspaceSnapshot {
id: workspace.id,
@@ -479,10 +429,7 @@ impl ShellSnapshot {
}
for workspace in &self.workspaces {
let (workspace_hi, workspace_lo) = split_id(workspace.id.0);
let (output_hi, output_lo) = workspace
.output
.map(|output| split_id(output.0))
.unwrap_or_default();
let (output_hi, output_lo) = workspace.output.map(|output| split_id(output.0)).unwrap_or_default();
shell.workspace(
workspace_hi,
@@ -564,12 +511,7 @@ mod tests {
#[test]
fn logout_serial_cannot_authorize_a_different_session_query() {
assert!(super::logout_confirmation_matches(Some(42), Some(9), 42, 9));
assert!(!super::logout_confirmation_matches(
Some(42),
Some(9),
42,
10
));
assert!(!super::logout_confirmation_matches(Some(42), Some(9), 42, 10));
assert!(!super::logout_confirmation_matches(None, Some(9), 42, 9));
}
@@ -633,11 +575,7 @@ mod tests {
fn config_chunks_are_bounded_and_preserve_unicode() {
let source = "abc🌲é".repeat(9000);
let chunks = config_chunks(&source);
assert!(
chunks
.iter()
.all(|chunk| !chunk.is_empty() && chunk.len() <= 1024)
);
assert!(chunks.iter().all(|chunk| !chunk.is_empty() && chunk.len() <= 1024));
assert_eq!(chunks.concat(), source);
assert!(config_chunks("").is_empty());
}
+7 -19
View File
@@ -1,12 +1,8 @@
use ferese_layout::WindowId;
use std::collections::HashMap;
pub(crate) fn layer_priority(
floating: bool,
zooming: bool,
fullscreen: bool,
above_fullscreen: bool,
) -> u8 {
use ferese_layout::WindowId;
pub(crate) fn layer_priority(floating: bool, zooming: bool, fullscreen: bool, above_fullscreen: bool) -> u8 {
if above_fullscreen {
4
} else if fullscreen {
@@ -97,18 +93,10 @@ mod tests {
#[test]
fn floating_windows_stay_above_tiled_and_maximized_windows() {
assert!(
layer_priority(true, false, false, false) > layer_priority(false, true, false, false)
);
assert!(
layer_priority(false, true, false, false) > layer_priority(false, false, false, false)
);
assert!(
layer_priority(false, false, true, false) > layer_priority(true, false, false, false)
);
assert!(
layer_priority(true, false, false, true) > layer_priority(false, false, true, false)
);
assert!(layer_priority(true, false, false, false) > layer_priority(false, true, false, false));
assert!(layer_priority(false, true, false, false) > layer_priority(false, false, false, false));
assert!(layer_priority(false, false, true, false) > layer_priority(true, false, false, false));
assert!(layer_priority(true, false, false, true) > layer_priority(false, false, true, false));
}
#[test]
+253 -503
View File
File diff suppressed because it is too large. Load diff
+36 -84
View File
@@ -1,28 +1,22 @@
//! One decoded image and one texture per GPU context, not double-buffered
//! full-screen UI surfaces per monitor. Resize changes sampling only.
use crate::presentation::NativeTextureElement;
use std::cell::Cell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use serde::Deserialize;
use smithay::{
backend::{
allocator::Fourcc,
renderer::{
ErasedContextId, ImportMem, Renderer,
element::Id,
gles::{GlesRenderer, GlesTexture},
utils::CommitCounter,
},
},
output::Output,
utils::{Buffer, Physical, Rectangle, Size},
};
use std::{
cell::Cell,
collections::HashMap,
path::PathBuf,
rc::Rc,
sync::mpsc,
time::{Duration, Instant},
};
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::element::Id;
use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture};
use smithay::backend::renderer::utils::CommitCounter;
use smithay::backend::renderer::{ErasedContextId, ImportMem, Renderer};
use smithay::output::Output;
use smithay::utils::{Buffer, Physical, Rectangle, Size};
use crate::presentation::NativeTextureElement;
const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5);
@@ -80,10 +74,7 @@ impl WallpaperState {
Self::with_wakeup(config, None)
}
pub fn with_wakeup(
config: WallpaperConfig,
wakeup: Option<smithay::reexports::calloop::LoopSignal>,
) -> Self {
pub fn with_wakeup(config: WallpaperConfig, wakeup: Option<smithay::reexports::calloop::LoopSignal>) -> Self {
let retained = config.clone();
let (sender, receiver) = mpsc::channel();
let owned = config.path.as_ref().is_some_and(|path| path.is_file());
@@ -92,8 +83,7 @@ impl WallpaperState {
let wakeup = wakeup.clone();
std::thread::spawn(move || {
let load = || -> Result<image::RgbaImage, String> {
let (width, height) =
image::image_dimensions(&path).map_err(|e| e.to_string())?;
let (width, height) = image::image_dimensions(&path).map_err(|e| e.to_string())?;
if u64::from(width) * u64::from(height) * 4 > 256 * 1024 * 1024 {
return Err("wallpaper exceeds the 256 MiB decode limit".into());
}
@@ -164,9 +154,7 @@ impl WallpaperState {
}
fn upload_ready(&self, context: &ErasedContextId, now: Instant) -> bool {
self.upload_retries
.get(context)
.is_none_or(|retry| now >= *retry)
self.upload_retries.get(context).is_none_or(|retry| now >= *retry)
}
pub(crate) fn take_retry_wakeup(&self) -> bool {
@@ -250,11 +238,7 @@ impl WallpaperState {
}
}
pub fn element(
&mut self,
renderer: &mut GlesRenderer,
output: &Output,
) -> Option<NativeTextureElement> {
pub fn element(&mut self, renderer: &mut GlesRenderer, output: &Output) -> Option<NativeTextureElement> {
let pixels = self.pixels.as_ref()?;
let context = renderer.context_id().erased();
if !self.textures.contains_key(&context) {
@@ -265,32 +249,21 @@ impl WallpaperState {
match renderer.import_memory(pixels.as_raw(), Fourcc::Abgr8888, size, false) {
Ok(texture) => {
self.upload_retries.remove(&context);
tracing::debug!(
bytes = pixels.as_raw().len(),
"uploaded shared wallpaper texture"
);
self.textures.insert(
context.clone(),
WallpaperTexture {
texture,
id: Id::new(),
},
);
tracing::debug!(bytes = pixels.as_raw().len(), "uploaded shared wallpaper texture");
self.textures
.insert(context.clone(), WallpaperTexture { texture, id: Id::new() });
}
Err(error) => {
// Avoid retrying a large failed allocation every frame, but
// allow recovery from temporary GPU memory pressure.
self.upload_retries
.insert(context, Instant::now() + UPLOAD_RETRY_DELAY);
self.upload_retries.insert(context, Instant::now() + UPLOAD_RETRY_DELAY);
tracing::debug!(%error, "wallpaper texture import failed");
return None;
}
}
}
let cached = self.textures.get(&context)?;
let output_size = output
.current_transform()
.transform_size(output.current_mode()?.size);
let output_size = output.current_transform().transform_size(output.current_mode()?.size);
let (geometry, source) = image_geometry(
(pixels.width() as i32, pixels.height() as i32).into(),
output_size,
@@ -324,11 +297,7 @@ fn image_geometry(
(
Rectangle::from_size(output),
Rectangle::new(
(
(f64::from(image.w) - width) / 2.0,
(f64::from(image.h) - height) / 2.0,
)
.into(),
((f64::from(image.w) - width) / 2.0, (f64::from(image.h) - height) / 2.0).into(),
(width, height).into(),
),
)
@@ -341,10 +310,7 @@ fn image_geometry(
)
.into();
(
Rectangle::new(
((output.w - size.w) / 2, (output.h - size.h) / 2).into(),
size,
),
Rectangle::new(((output.w - size.w) / 2, (output.h - size.h) / 2).into(), size),
Rectangle::from_size(image.to_f64()),
)
}
@@ -373,9 +339,7 @@ mod tests {
state.arm_retry_timer(&event_loop.handle(), now).unwrap();
assert!(state.retry_timer_pending.get());
assert!(!state.take_retry_wakeup());
event_loop
.dispatch(Some(Duration::from_secs(1)), &mut ())
.unwrap();
event_loop.dispatch(Some(Duration::from_secs(1)), &mut ()).unwrap();
assert!(!state.retry_timer_pending.get());
assert!(
state.take_retry_wakeup(),
@@ -383,10 +347,7 @@ mod tests {
);
assert!(!state.take_retry_wakeup());
let after = Instant::now();
assert_eq!(
state.next_retry_deadline(after),
Some(after + UPLOAD_RETRY_DELAY)
);
assert_eq!(state.next_retry_deadline(after), Some(after + UPLOAD_RETRY_DELAY));
state.forget_context(&context);
assert_eq!(state.next_retry_deadline(after), None);
state.retry_wakeup.set(true);
@@ -398,7 +359,8 @@ mod tests {
#[test]
fn decoder_wakes_an_idle_event_loop() {
use smithay::reexports::calloop::{EventLoop, timer::Timer};
use smithay::reexports::calloop::EventLoop;
use smithay::reexports::calloop::timer::Timer;
let mut event_loop = EventLoop::<WallpaperState>::try_new().unwrap();
event_loop
.handle()
@@ -407,8 +369,7 @@ mod tests {
})
.unwrap();
let signal = event_loop.get_signal();
let mut state =
WallpaperState::with_wakeup(WallpaperConfig::default(), Some(signal.clone()));
let mut state = WallpaperState::with_wakeup(WallpaperConfig::default(), Some(signal.clone()));
event_loop
.run(None, &mut state, |state| {
if state.poll() {
@@ -429,18 +390,14 @@ mod tests {
mode: WallpaperMode::Fill,
});
let now = Instant::now();
state
.upload_retries
.insert(context.clone(), now + UPLOAD_RETRY_DELAY);
state.upload_retries.insert(context.clone(), now + UPLOAD_RETRY_DELAY);
assert!(!state.upload_ready(&context, now));
assert!(state.upload_ready(&other, now));
assert!(state.upload_ready(&context, now + UPLOAD_RETRY_DELAY));
state.forget_context(&context);
assert!(state.upload_ready(&context, now));
state
.upload_retries
.insert(context.clone(), now + UPLOAD_RETRY_DELAY);
state.upload_retries.insert(context.clone(), now + UPLOAD_RETRY_DELAY);
let (sender, receiver) = mpsc::channel();
state.receiver = Some(receiver);
sender.send(Ok(image::RgbaImage::new(2, 2))).unwrap();
@@ -489,16 +446,11 @@ mod tests {
#[test]
fn fill_crops_and_fit_letterboxes_without_reallocating_image() {
let (geometry, source) = image_geometry(
(3840, 2160).into(),
(1600, 1000).into(),
WallpaperMode::Fill,
);
let (geometry, source) = image_geometry((3840, 2160).into(), (1600, 1000).into(), WallpaperMode::Fill);
assert_eq!(geometry.size, (1600, 1000).into());
assert_eq!(source.size.h, 2160.0);
assert!(source.loc.x > 0.0);
let (geometry, source) =
image_geometry((3840, 2160).into(), (1600, 1000).into(), WallpaperMode::Fit);
let (geometry, source) = image_geometry((3840, 2160).into(), (1600, 1000).into(), WallpaperMode::Fit);
assert_eq!(geometry.size, (1600, 900).into());
assert_eq!(geometry.loc, (0, 50).into());
assert_eq!(source.size, (3840.0, 2160.0).into());
+8 -32
View File
@@ -63,12 +63,7 @@ pub fn validate(configured: &[WindowRuleConfig]) -> Result<Vec<WindowRule>, Stri
.collect()
}
pub fn resolve(
rules: &[WindowRule],
app_id: Option<&str>,
title: Option<&str>,
transient: bool,
) -> WindowRuleResult {
pub fn resolve(rules: &[WindowRule], app_id: Option<&str>, title: Option<&str>, transient: bool) -> WindowRuleResult {
let app_id = app_id.map(normalize_app_id);
let mut result = WindowRuleResult::default();
if matches!(
@@ -88,10 +83,7 @@ pub fn resolve(
.app_id
.as_deref()
.is_none_or(|expected| app_id.as_deref() == Some(expected));
let title_matches = rule
.title
.as_deref()
.is_none_or(|expected| title == Some(expected));
let title_matches = rule.title.as_deref().is_none_or(|expected| title == Some(expected));
let transient_matches = rule.transient.is_none_or(|expected| expected == transient);
if !app_id_matches || !title_matches || !transient_matches {
continue;
@@ -109,14 +101,10 @@ pub fn resolve(
fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result<WindowRule, String> {
if rule.app_id.is_none() && rule.title.is_none() && rule.transient.is_none() {
return Err(format!(
"window_rules[{index}] must match app_id, title, or transient"
));
return Err(format!("window_rules[{index}] must match app_id, title, or transient"));
}
if rule.workspace == Some(0) {
return Err(format!(
"window_rules[{index}].workspace must be greater than zero"
));
return Err(format!("window_rules[{index}].workspace must be greater than zero"));
}
let app_id = rule
@@ -157,11 +145,7 @@ fn nonempty_matcher(value: &str) -> Result<&str, &'static str> {
}
}
fn positive_dimension(
value: Option<f64>,
index: usize,
field: &'static str,
) -> Result<Option<f64>, String> {
fn positive_dimension(value: Option<f64>, index: usize, field: &'static str) -> Result<Option<f64>, String> {
if value.is_none_or(|value| value.is_finite() && value > 0.0) {
Ok(value)
} else {
@@ -193,9 +177,7 @@ mod tests {
assert_eq!(removed.floating, Some(false));
assert_eq!(removed.fullscreen, Some(false));
assert_eq!(
super::live_result(old, Default::default(), true)
.unwrap()
.floating,
super::live_result(old, Default::default(), true).unwrap().floating,
Some(true)
);
let new = super::WindowRuleResult {
@@ -234,15 +216,9 @@ mod tests {
..config(app_id)
}])
.unwrap();
assert_eq!(
resolve(&rules, Some(app_id), None, false).floating,
Some(false)
);
assert_eq!(resolve(&rules, Some(app_id), None, false).floating, Some(false));
}
assert_eq!(
resolve(&[], Some("org.example.Editor"), None, false).floating,
None
);
assert_eq!(resolve(&[], Some("org.example.Editor"), None, false).floating, None);
}
#[test]
+182 -475
View File
File diff suppressed because it is too large. Load diff
+24 -114
View File
@@ -108,13 +108,7 @@ impl AnimatedValue {
for _ in 0..steps {
let previous_error = self.current - self.target;
advance_value(
&mut self.current,
self.target,
&mut self.velocity,
step,
config,
);
advance_value(&mut self.current, self.target, &mut self.velocity, step, config);
let current_error = self.current - self.target;
if previous_error != 0.0 && previous_error.signum() != current_error.signum() {
self.snap();
@@ -163,20 +157,8 @@ impl AnimatedRect {
let step = seconds / steps as f64;
for _ in 0..steps {
advance_value(
&mut self.current.x,
self.target.x,
&mut self.velocity.x,
step,
config,
);
advance_value(
&mut self.current.y,
self.target.y,
&mut self.velocity.y,
step,
config,
);
advance_value(&mut self.current.x, self.target.x, &mut self.velocity.x, step, config);
advance_value(&mut self.current.y, self.target.y, &mut self.velocity.y, step, config);
advance_value(
&mut self.current.width,
self.target.width,
@@ -321,12 +303,7 @@ impl WindowGeometry {
self.client.request_size(size, now).then_some(size)
}
pub fn set_presentation_target(
&mut self,
rect: Rect,
fullscreen: bool,
now: Duration,
) -> Option<ClientSize> {
pub fn set_presentation_target(&mut self, rect: Rect, fullscreen: bool, now: Duration) -> Option<ClientSize> {
self.set_presentation_mode(
rect,
if fullscreen {
@@ -338,12 +315,7 @@ impl WindowGeometry {
)
}
pub fn set_presentation_mode(
&mut self,
rect: Rect,
mode: PresentationMode,
now: Duration,
) -> Option<ClientSize> {
pub fn set_presentation_mode(&mut self, rect: Rect, mode: PresentationMode, now: Duration) -> Option<ClientSize> {
let rect = normalized_rect(rect);
if self.mode != mode || (self.zoom.is_some() && self.logical != rect) {
let mut progress = AnimatedValue::new(0.0);
@@ -359,14 +331,9 @@ impl WindowGeometry {
let speed = [velocity.x, velocity.y, velocity.width, velocity.height];
let squared_length = distance.iter().map(|value| value * value).sum::<f64>();
if squared_length > 0.001 {
let projected =
distance.iter().zip(speed).map(|(d, v)| d * v).sum::<f64>() / squared_length;
let projected = distance.iter().zip(speed).map(|(d, v)| d * v).sum::<f64>() / squared_length;
// Keep forward momentum; soften a reversal without jumping position.
progress.velocity = if projected < 0.0 {
projected * 0.35
} else {
projected
};
progress.velocity = if projected < 0.0 { projected * 0.35 } else { projected };
}
self.zoom = Some(ZoomTransition {
from: self.visual.current,
@@ -412,24 +379,14 @@ impl WindowGeometry {
requested_size
}
pub fn advance(
&mut self,
delta: Duration,
config: SpringConfig,
animations_enabled: bool,
) -> bool {
pub fn advance(&mut self, delta: Duration, config: SpringConfig, animations_enabled: bool) -> bool {
let previous = (self.visual.current, self.decorations);
let active = self.advance_presentation(delta, config, animations_enabled);
self.presentation_changed = previous != (self.visual.current, self.decorations);
active
}
fn advance_presentation(
&mut self,
delta: Duration,
config: SpringConfig,
animations_enabled: bool,
) -> bool {
fn advance_presentation(&mut self, delta: Duration, config: SpringConfig, animations_enabled: bool) -> bool {
if !animations_enabled {
self.visual.snap();
self.decorations = if self.is_fullscreen() { 0.0 } else { 1.0 };
@@ -506,15 +463,8 @@ impl WindowGeometry {
}
}
fn advance_value(
current: &mut f64,
target: f64,
velocity: &mut f64,
delta: f64,
config: SpringConfig,
) {
let acceleration =
(-config.stiffness * (*current - target) - config.damping * *velocity) / config.mass;
fn advance_value(current: &mut f64, target: f64, velocity: &mut f64, delta: f64, config: SpringConfig) {
let acceleration = (-config.stiffness * (*current - target) - config.damping * *velocity) / config.mass;
*velocity += acceleration * delta;
let previous_error = *current - target;
*current += *velocity * delta;
@@ -641,17 +591,10 @@ mod tests {
assert_eq!(geometry.visual.current, rect);
assert_eq!(
geometry.decorations,
if mode == PresentationMode::Fullscreen {
0.0
} else {
1.0
}
if mode == PresentationMode::Fullscreen { 0.0 } else { 1.0 }
);
assert!(!geometry.is_zooming());
assert_eq!(
geometry.is_fullscreen(),
mode == PresentationMode::Fullscreen
);
assert_eq!(geometry.is_fullscreen(), mode == PresentationMode::Fullscreen);
}
}
@@ -678,9 +621,7 @@ mod tests {
fn zoom_finishes_without_a_visible_final_edge_jump() {
let tiled = Rect::new(12.0, 12.0, 942.0, 1056.0);
let fullscreen = Rect::new(0.0, 0.0, 1920.0, 1080.0);
for (start, target, fullscreen_target) in
[(tiled, fullscreen, true), (fullscreen, tiled, false)]
{
for (start, target, fullscreen_target) in [(tiled, fullscreen, true), (fullscreen, tiled, false)] {
let mut geometry = WindowGeometry::new(start, None);
if !fullscreen_target {
geometry.set_presentation_target(start, true, Duration::ZERO);
@@ -690,11 +631,7 @@ mod tests {
let mut finished = false;
for _ in 0..1000 {
let before = geometry.visual.current;
if !geometry.advance(
Duration::from_secs_f64(1.0 / 144.0),
SpringConfig::default(),
true,
) {
if !geometry.advance(Duration::from_secs_f64(1.0 / 144.0), SpringConfig::default(), true) {
let right_before = before.x + before.width;
let right_after = target.x + target.width;
assert!(
@@ -721,9 +658,7 @@ mod tests {
let progress = 1.0 - geometry.decorations;
let visual = geometry.visual.current;
assert!((visual.x - (start.x + (end.x - start.x) * progress)).abs() < 1e-9);
assert!(
(visual.width - (start.width + (end.width - start.width) * progress)).abs() < 1e-9
);
assert!((visual.width - (start.width + (end.width - start.width) * progress)).abs() < 1e-9);
}
}
@@ -734,11 +669,7 @@ mod tests {
let mut geometry = WindowGeometry::new(tiled, None);
for enabled in [true, false, true, false] {
let before = (geometry.visual.current, geometry.decorations);
geometry.set_presentation_target(
if enabled { fullscreen } else { tiled },
enabled,
Duration::ZERO,
);
geometry.set_presentation_target(if enabled { fullscreen } else { tiled }, enabled, Duration::ZERO);
assert_eq!((geometry.visual.current, geometry.decorations), before);
geometry.advance(Duration::from_millis(48), SpringConfig::default(), true);
}
@@ -782,24 +713,15 @@ mod tests {
Some(ClientSize::from_rect(middle))
);
geometry.advance(Duration::from_millis(64), SpringConfig::default(), true);
assert_eq!(geometry.presentation_size_request(Duration::from_millis(64)), None);
assert_eq!(
geometry.presentation_size_request(Duration::from_millis(64)),
None
);
assert_eq!(
geometry.set_presentation_mode(
end,
PresentationMode::Normal,
Duration::from_millis(64)
),
geometry.set_presentation_mode(end, PresentationMode::Normal, Duration::from_millis(64)),
Some(ClientSize::from_rect(end))
);
let mut requests = Vec::new();
for frame in 1..240 {
geometry.advance(Duration::from_millis(8), SpringConfig::default(), true);
if let Some(size) =
geometry.presentation_size_request(Duration::from_millis(64 + frame * 8))
{
if let Some(size) = geometry.presentation_size_request(Duration::from_millis(64 + frame * 8)) {
assert_eq!(geometry.visual.current.width, end.width);
requests.push(size);
}
@@ -844,13 +766,8 @@ mod tests {
true,
);
let current = geometry.visual.current;
let edges = |rect: Rect| {
[rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]
};
for ((before, after), end) in edges(previous)
.into_iter()
.zip(edges(current))
.zip(edges(target))
let edges = |rect: Rect| [rect.x, rect.y, rect.x + rect.width, rect.y + rect.height];
for ((before, after), end) in edges(previous).into_iter().zip(edges(current)).zip(edges(target))
{
assert!((after - end).abs() <= (before - end).abs() + 1e-9);
}
@@ -1004,11 +921,7 @@ mod tests {
let mut geometry = WindowGeometry::new(rect, None);
assert!(geometry.set_logical_target(rect, Duration::ZERO).is_some());
assert!(
geometry
.set_logical_target(rect, Duration::from_millis(16))
.is_none()
);
assert!(geometry.set_logical_target(rect, Duration::from_millis(16)).is_none());
}
#[test]
@@ -1067,10 +980,7 @@ mod tests {
assert!(sixty.current.width <= target.width);
}
for _ in 0..288 {
one_forty_four.advance(
Duration::from_secs_f64(1.0 / 144.0),
SpringConfig::default(),
);
one_forty_four.advance(Duration::from_secs_f64(1.0 / 144.0), SpringConfig::default());
}
assert!(sixty.is_settled(SpringConfig::default()));
+36 -119
View File
@@ -1,7 +1,9 @@
use std::fmt;
use std::path::PathBuf;
use kdl::{KdlDocument, KdlEntry, KdlNode, KdlValue};
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use std::{fmt, path::PathBuf};
pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78;
@@ -36,14 +38,9 @@ pub fn default_wallpaper() -> &'static str {
.ok()
.and_then(|path| path.parent().map(|dir| dir.join("wallpapers/ferese.png")))
.filter(|path| path.is_file())
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/wallpapers/ferese.png")
});
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/wallpapers/ferese.png"));
path.canonicalize()
.unwrap_or(path)
.to_string_lossy()
.into_owned()
path.canonicalize().unwrap_or(path).to_string_lossy().into_owned()
})
}
@@ -65,10 +62,8 @@ fn field(name: &str, parent: &str) -> String {
fn is_records(key: &str, parent: &str) -> bool {
matches!(
(parent, key),
(
"",
"bindings" | "window_rules" | "output_profiles" | "autostart"
) | ("output_profiles", "outputs")
("", "bindings" | "window_rules" | "output_profiles" | "autostart")
| ("output_profiles", "outputs")
| ("desktop_widgets", "notes")
)
}
@@ -77,12 +72,7 @@ fn is_array(key: &str, parent: &str) -> bool {
parent == "commands"
|| matches!(
key,
"command"
| "position"
| "width_presets"
| "xkb_options"
| "settings_command"
| "outputs"
"command" | "position" | "width_presets" | "xkb_options" | "settings_command" | "outputs"
)
}
@@ -91,9 +81,7 @@ fn scalar(value: &KdlValue) -> Result<Value, Error> {
KdlValue::String(s) => Value::String(s.clone()),
KdlValue::Bool(v) => Value::Bool(*v),
KdlValue::Null => {
return Err(Error(
"Omit optional settings instead of using #null".into(),
));
return Err(Error("Omit optional settings instead of using #null".into()));
}
KdlValue::Integer(n) => {
if let Ok(n) = i64::try_from(*n) {
@@ -124,9 +112,7 @@ fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error>
let key = field(node.name().value(), parent);
let value = node_value(node, &key, parent)?;
if is_records(&key, parent) {
let records = result
.entry(key)
.or_insert_with(|| Value::Array(Vec::new()));
let records = result.entry(key).or_insert_with(|| Value::Array(Vec::new()));
records.as_array_mut().unwrap().push(value);
} else {
insert(&mut result, key, value)?;
@@ -138,9 +124,7 @@ fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error>
fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
if node.ty().is_some() || node.entries().iter().any(|e| e.ty().is_some()) {
return Err(Error(
"Type annotations are not supported in configuration".into(),
));
return Err(Error("Type annotations are not supported in configuration".into()));
}
let args = node
@@ -156,11 +140,7 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
.collect::<Vec<_>>();
if is_records(key, parent) {
let mut map = node
.children()
.map(|d| object(d, key))
.transpose()?
.unwrap_or_default();
let mut map = node.children().map(|d| object(d, key)).transpose()?.unwrap_or_default();
for (name, entry) in props {
insert(&mut map, name, scalar(entry.value())?)?;
}
@@ -195,11 +175,7 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
node.name()
)));
}
let mut map = node
.children()
.map(|d| object(d, key))
.transpose()?
.unwrap_or_default();
let mut map = node.children().map(|d| object(d, key)).transpose()?.unwrap_or_default();
for (name, entry) in props {
insert(&mut map, name, scalar(entry.value())?)?;
}
@@ -224,9 +200,7 @@ pub struct Document {
impl Document {
pub fn parse(source: &str) -> Result<Self, Error> {
let doc = source
.parse::<KdlDocument>()
.map_err(|e| Error(format!("{e:?}")))?;
let doc = source.parse::<KdlDocument>().map_err(|e| Error(format!("{e:?}")))?;
let value = Value::Object(object(&doc, "")?);
Ok(Self { doc, value })
@@ -267,10 +241,7 @@ impl Document {
pub fn add(&mut self, path: &str, fields: Vec<(String, Value)>) -> Result<(), Error> {
let mut candidate = self.clone();
let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p));
let parent = parents
.rsplit('.')
.find(|s| s.parse::<usize>().is_err())
.unwrap_or("");
let parent = parents.rsplit('.').find(|s| s.parse::<usize>().is_err()).unwrap_or("");
if !is_records(last, parent) {
return Err(Error("Expected a list of records".into()));
@@ -278,18 +249,12 @@ impl Document {
let doc = section_mut(
&mut candidate.doc,
&parents
.split('.')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>(),
&parents.split('.').filter(|s| !s.is_empty()).collect::<Vec<_>>(),
"",
)?;
doc.nodes_mut().push(value_node(
last,
&Value::Object(fields.into_iter().collect()),
parent,
)?);
doc.nodes_mut()
.push(value_node(last, &Value::Object(fields.into_iter().collect()), parent)?);
format_document(&mut candidate.doc);
candidate.refresh()?;
*self = candidate;
@@ -302,19 +267,14 @@ impl Document {
let (last, parents) = path.rsplit_once('.').map_or((path, ""), |(p, l)| (l, p));
let doc = section_mut(
&mut candidate.doc,
&parents
.split('.')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>(),
&parents.split('.').filter(|s| !s.is_empty()).collect::<Vec<_>>(),
"",
)?;
let i = doc
.nodes()
.iter()
.enumerate()
.filter(|(_, n)| {
field(n.name().value(), parents.rsplit('.').next().unwrap_or("")) == last
})
.filter(|(_, n)| field(n.name().value(), parents.rsplit('.').next().unwrap_or("")) == last)
.nth(index)
.map(|(i, _)| i)
.ok_or_else(|| Error("This item no longer exists. Reload settings.".into()))?;
@@ -346,11 +306,7 @@ type NodeFormat = (Vec<Option<kdl::KdlEntryFormat>>, Option<String>);
fn collect_node_formats(doc: &KdlDocument, formats: &mut Vec<NodeFormat>) {
for node in doc.nodes() {
let entries = node
.entries()
.iter()
.map(|entry| entry.format().cloned())
.collect();
let entries = node.entries().iter().map(|entry| entry.format().cloned()).collect();
let terminator = node
.format()
.map(|format| format.terminator.clone())
@@ -391,11 +347,7 @@ fn format_document(doc: &mut KdlDocument) {
}
fn node_index(doc: &mut KdlDocument, key: &str, parent: &str) -> usize {
if let Some(i) = doc
.nodes()
.iter()
.position(|n| field(n.name().value(), parent) == key)
{
if let Some(i) = doc.nodes().iter().position(|n| field(n.name().value(), parent) == key) {
return i;
}
@@ -403,11 +355,7 @@ fn node_index(doc: &mut KdlDocument, key: &str, parent: &str) -> usize {
doc.nodes().len() - 1
}
fn section_mut<'a>(
doc: &'a mut KdlDocument,
parts: &[&str],
parent: &str,
) -> Result<&'a mut KdlDocument, Error> {
fn section_mut<'a>(doc: &'a mut KdlDocument, parts: &[&str], parent: &str) -> Result<&'a mut KdlDocument, Error> {
if parts.is_empty() {
return Ok(doc);
}
@@ -500,9 +448,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) ->
}
}
if key == "autostart"
&& rest[0] == "command"
&& node.children().is_none_or(|d| d.get("command").is_none())
if key == "autostart" && rest[0] == "command" && node.children().is_none_or(|d| d.get("command").is_none())
{
node.entries_mut().retain(|e| e.name().is_some());
for value in value
@@ -613,15 +559,9 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
{
node.entries_mut().push(KdlEntry::new(kdl_value(value)?));
}
} else if is_records(key, parent)
&& map.len() <= 4
&& !value.is_array()
&& !value.is_object()
{
node.entries_mut().push(KdlEntry::new_prop(
name.replace('_', "-"),
kdl_value(value)?,
));
} else if is_records(key, parent) && map.len() <= 4 && !value.is_array() && !value.is_object() {
node.entries_mut()
.push(KdlEntry::new_prop(name.replace('_', "-"), kdl_value(value)?));
} else {
append(&mut child, name, value, key)?;
}
@@ -643,10 +583,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
fn append(doc: &mut KdlDocument, key: &str, value: &Value, parent: &str) -> Result<(), Error> {
if is_records(key, parent) {
for record in value
.as_array()
.ok_or_else(|| Error("Expected records".into()))?
{
for record in value.as_array().ok_or_else(|| Error("Expected records".into()))? {
doc.nodes_mut().push(value_node(key, record, parent)?);
}
} else {
@@ -677,10 +614,7 @@ desktop-widgets {
}
"#;
let mut doc = Document::parse(source).unwrap();
assert_eq!(
doc.get("commands.my_command").unwrap(),
&serde_json::json!(["program"])
);
assert_eq!(doc.get("commands.my_command").unwrap(), &serde_json::json!(["program"]));
assert_eq!(
doc.get("desktop_widgets.clock.outputs").unwrap(),
&serde_json::json!(["DP-1"])
@@ -691,8 +625,7 @@ desktop-widgets {
);
doc.set("commands.new_command", serde_json::json!(["two", "words"]))
.unwrap();
doc.set("desktop_widgets.clock.outputs", serde_json::json!([]))
.unwrap();
doc.set("desktop_widgets.clock.outputs", serde_json::json!([])).unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("commands.new_command").unwrap(),
@@ -706,8 +639,7 @@ desktop-widgets {
#[test]
fn inline_comments_and_failed_edits_leave_document_intact() {
let mut doc =
Document::parse("// header\nanimations {\n speed 0.75 // keep inline\n}\n").unwrap();
let mut doc = Document::parse("// header\nanimations {\n speed 0.75 // keep inline\n}\n").unwrap();
doc.set("animations.speed", 0.8.into()).unwrap();
assert!(doc.to_string().contains("// keep inline"));
assert_eq!(
@@ -750,15 +682,11 @@ autostart "program" "two words" enabled=#false
assert_eq!(doc.get("bindings.0.action").unwrap(), "toggle-overview");
doc.set("bindings.0.action", "none".into()).unwrap();
doc.set("input.repeat_rate", 25.into()).unwrap();
doc.set("output_profiles.0.outputs.0.scale", 2.0.into())
.unwrap();
doc.set("output_profiles.0.outputs.0.scale", 2.0.into()).unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(reparsed.get("bindings.0.action").unwrap(), "none");
assert_eq!(reparsed.get("input.repeat_rate").unwrap(), 25);
assert_eq!(
reparsed.get("output_profiles.0.outputs.0.scale").unwrap(),
2.0
);
assert_eq!(reparsed.get("output_profiles.0.outputs.0.scale").unwrap(), 2.0);
}
#[test]
@@ -786,28 +714,17 @@ desktop-widgets {
}
"#;
let mut doc = Document::parse(source).unwrap();
doc.set("desktop_widgets.notes.0.text", "one\ntwo".into())
.unwrap();
doc.set("desktop_widgets.notes.0.text", "one\ntwo".into()).unwrap();
assert!(doc.to_string().contains("// personal comment"));
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("commands.screenshot-full").unwrap(),
&serde_json::json!(["ferese-screenshot", "--full"])
);
assert_eq!(
reparsed.get("desktop_widgets.notes.0.text").unwrap(),
"one\ntwo"
);
assert_eq!(reparsed.get("desktop_widgets.notes.0.text").unwrap(), "one\ntwo");
doc.add("desktop_widgets.notes", vec![("id".into(), "b".into())])
.unwrap();
assert_eq!(
doc.get("desktop_widgets.notes")
.unwrap()
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(doc.get("desktop_widgets.notes").unwrap().as_array().unwrap().len(), 2);
doc.remove("desktop_widgets.notes", 0).unwrap();
assert_eq!(doc.get("desktop_widgets.notes.0.id").unwrap(), "b");
}
+14 -29
View File
@@ -81,9 +81,7 @@ impl Default for StickyNote {
impl StickyNote {
pub fn on_output(&self, name: Option<&str>) -> bool {
self.enabled
&& (self.outputs.is_empty()
|| name.is_some_and(|name| self.outputs.iter().any(|s| s == name)))
self.enabled && (self.outputs.is_empty() || name.is_some_and(|name| self.outputs.iter().any(|s| s == name)))
}
/// Text/style changes repaint in place, never remap the desktop card.
pub fn same_surface(&self, other: &Self) -> bool {
@@ -99,12 +97,7 @@ impl StickyNote {
}
pub fn validate(&self) -> Result<(), String> {
let invalid = |field: &str| {
Err(format!(
"desktop_widgets.notes[{}].{field} is invalid",
self.id
))
};
let invalid = |field: &str| Err(format!("desktop_widgets.notes[{}].{field} is invalid", self.id));
if self.id.is_empty()
|| self.id.len() > 64
|| !self
@@ -138,9 +131,9 @@ impl StickyNote {
for color in [&self.color, &self.background].into_iter().flatten() {
if !color.is_empty()
&& color != "theme"
&& !color.strip_prefix('#').is_some_and(|hex| {
matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit())
})
&& !color
.strip_prefix('#')
.is_some_and(|hex| matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit()))
{
return invalid("color/background");
}
@@ -253,8 +246,7 @@ impl Default for Clock {
impl Clock {
pub fn on_output(&self, name: Option<&str>) -> bool {
self.enabled
&& (self.outputs.is_empty()
|| name.is_some_and(|name| self.outputs.iter().any(|output| output == name)))
&& (self.outputs.is_empty() || name.is_some_and(|name| self.outputs.iter().any(|output| output == name)))
}
pub fn labels(&self, now: &jiff::Zoned) -> Result<(String, String), String> {
@@ -264,10 +256,8 @@ impl Clock {
}
_ => now.clone(),
};
let time =
jiff::fmt::strtime::format(&self.time_format, &zoned).map_err(|e| e.to_string())?;
let date =
jiff::fmt::strtime::format(&self.date_format, &zoned).map_err(|e| e.to_string())?;
let time = jiff::fmt::strtime::format(&self.time_format, &zoned).map_err(|e| e.to_string())?;
let date = jiff::fmt::strtime::format(&self.date_format, &zoned).map_err(|e| e.to_string())?;
Ok(if self.lowercase {
(time.to_lowercase(), date.to_lowercase())
} else {
@@ -295,16 +285,14 @@ impl Clock {
return invalid(name);
}
}
for color in [&self.color, &self.date_color, &self.background]
.into_iter()
.flatten()
{
for color in [&self.color, &self.date_color, &self.background].into_iter().flatten() {
if color.is_empty() {
continue;
}
if !color.strip_prefix('#').is_some_and(|hex| {
matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit())
}) {
if !color
.strip_prefix('#')
.is_some_and(|hex| matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit()))
{
return invalid("color");
}
}
@@ -414,9 +402,6 @@ mod tests {
date_format: "%-d %b".into(),
..Clock::default()
};
assert_eq!(
clock.labels(&now).unwrap(),
("10:05 pm".into(), "26 sep".into())
);
assert_eq!(clock.labels(&now).unwrap(), ("10:05 pm".into(), "26 sep".into()));
}
}
+2 -2
View File
@@ -5,6 +5,6 @@ mod workspace;
pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
pub use workspace::{
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout,
WorkspaceSet, workspace_order_key,
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet,
workspace_order_key,
};
+45 -158
View File
@@ -1,8 +1,6 @@
use std::{
collections::{HashMap, HashSet},
error::Error,
fmt,
};
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::fmt;
use crate::WorkspaceId;
@@ -19,12 +17,7 @@ pub struct OutputGeometry {
impl OutputGeometry {
pub const fn new(x: i32, y: i32, width: i32, height: i32) -> Self {
Self {
x,
y,
width,
height,
}
Self { x, y, width, height }
}
fn center(self) -> (i64, i64) {
@@ -39,10 +32,7 @@ impl OutputGeometry {
pub enum OutputError {
AlreadyConnected(OutputId),
UnknownOutput(OutputId),
WorkspaceAlreadyAssigned {
workspace: WorkspaceId,
output: OutputId,
},
WorkspaceAlreadyAssigned { workspace: WorkspaceId, output: OutputId },
}
impl fmt::Display for OutputError {
@@ -51,10 +41,7 @@ impl fmt::Display for OutputError {
Self::AlreadyConnected(output) => write!(formatter, "output {output:?} is connected"),
Self::UnknownOutput(output) => write!(formatter, "unknown output {output:?}"),
Self::WorkspaceAlreadyAssigned { workspace, output } => {
write!(
formatter,
"workspace {workspace:?} is assigned to {output:?}"
)
write!(formatter, "workspace {workspace:?} is assigned to {output:?}")
}
}
}
@@ -111,11 +98,7 @@ impl OutputWorkspaceMap {
}
pub fn forget_workspace(&mut self, workspace: WorkspaceId) -> bool {
if self
.outputs
.values()
.any(|output| output.active == workspace)
{
if self.outputs.values().any(|output| output.active == workspace) {
return false;
}
if let Some(owner) = self.assignments.remove(&workspace)
@@ -125,9 +108,7 @@ impl OutputWorkspaceMap {
}
self.revisions.remove(&workspace);
self.evacuations.retain(|_, record| {
record
.workspaces
.retain(|entry| entry.workspace != workspace);
record.workspaces.retain(|entry| entry.workspace != workspace);
if record.active == workspace {
if let Some(entry) = record.workspaces.first() {
record.active = entry.workspace;
@@ -165,8 +146,7 @@ impl OutputWorkspaceMap {
let mut reclaimed = Vec::new();
if let Some(record) = &record {
for evacuated in &record.workspaces {
let assignment_matches =
self.assignments.get(&evacuated.workspace).copied() == evacuated.target;
let assignment_matches = self.assignments.get(&evacuated.workspace).copied() == evacuated.target;
let revision_matches = self.revision(evacuated.workspace) == evacuated.revision;
if assignment_matches && revision_matches {
@@ -193,12 +173,7 @@ impl OutputWorkspaceMap {
let active = record
.as_ref()
.and_then(|record| reclaimed.contains(&record.active).then_some(record.active))
.or_else(|| {
reclaimed
.iter()
.copied()
.min_by_key(|workspace| workspace.0)
});
.or_else(|| reclaimed.iter().copied().min_by_key(|workspace| workspace.0));
let active = match active {
Some(active) => active,
None => {
@@ -229,10 +204,7 @@ impl OutputWorkspaceMap {
}
pub fn disconnect(&mut self, output: OutputId) -> Result<Option<OutputId>, OutputError> {
let removed = self
.outputs
.remove(&output)
.ok_or(OutputError::UnknownOutput(output))?;
let removed = self.outputs.remove(&output).ok_or(OutputError::UnknownOutput(output))?;
let target = self.migration_target(output, removed.geometry);
let removed_had_focus = self.focused == Some(output);
let mut evacuated = removed.workspaces.into_iter().collect::<Vec<_>>();
@@ -309,10 +281,7 @@ impl OutputWorkspaceMap {
}
let reassigned = self.assignments.insert(workspace, output) != Some(output);
let state = self
.outputs
.get_mut(&output)
.expect("output was checked above");
let state = self.outputs.get_mut(&output).expect("output was checked above");
state.workspaces.insert(workspace);
state.active = workspace;
self.focused = Some(output);
@@ -324,11 +293,7 @@ impl OutputWorkspaceMap {
Ok(WorkspaceSwitch::Activated(output))
}
pub fn assign_workspace(
&mut self,
output: OutputId,
workspace: WorkspaceId,
) -> Result<OutputId, OutputError> {
pub fn assign_workspace(&mut self, output: OutputId, workspace: WorkspaceId) -> Result<OutputId, OutputError> {
if !self.outputs.contains_key(&output) {
return Err(OutputError::UnknownOutput(output));
}
@@ -348,11 +313,7 @@ impl OutputWorkspaceMap {
Ok(output)
}
fn migration_target(
&self,
removed: OutputId,
removed_geometry: OutputGeometry,
) -> Option<OutputId> {
fn migration_target(&self, removed: OutputId, removed_geometry: OutputGeometry) -> Option<OutputId> {
if let Some(focused) = self.focused
&& focused != removed
&& self.outputs.contains_key(&focused)
@@ -382,10 +343,7 @@ impl OutputWorkspaceMap {
}
fn validate(&self) -> bool {
if self
.focused
.is_some_and(|focused| !self.outputs.contains_key(&focused))
{
if self.focused.is_some_and(|focused| !self.outputs.contains_key(&focused)) {
return false;
}
@@ -423,9 +381,7 @@ mod tests {
assert!(outputs.forget_workspace(first));
assert_eq!(outputs.output_for_workspace(first), None);
outputs.disconnect(display).unwrap();
outputs
.connect(display, geometry, super::WorkspaceId(3))
.unwrap();
outputs.connect(display, geometry, super::WorkspaceId(3)).unwrap();
assert_eq!(outputs.active_workspace(display), Some(second));
assert_eq!(outputs.output_for_workspace(first), None);
assert!(outputs.validate());
@@ -462,47 +418,28 @@ mod tests {
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)),
Ok(WorkspaceId(2))
);
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(OutputId(1))
);
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(2))
);
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
}
#[test]
fn removal_prefers_a_remaining_focused_output_without_replacing_its_workspace() {
let mut outputs = OutputWorkspaceMap::default();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs.focus_output(OutputId(1)).unwrap();
assert_eq!(outputs.disconnect(OutputId(2)), Ok(Some(OutputId(1))));
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(1))
);
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
}
#[test]
fn removing_the_focused_output_activates_its_workspace_on_the_nearest_output() {
let mut outputs = OutputWorkspaceMap::default();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs
.connect(OutputId(3), geometry(3_840), WorkspaceId(3))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs.connect(OutputId(3), geometry(3_840), WorkspaceId(3)).unwrap();
outputs.focus_output(OutputId(2)).unwrap();
assert_eq!(outputs.disconnect(OutputId(2)), Ok(Some(OutputId(1))));
@@ -513,12 +450,8 @@ mod tests {
#[test]
fn reconnect_reclaims_automatically_evacuated_workspaces() {
let mut outputs = OutputWorkspaceMap::default();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs.focus_output(OutputId(1)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
@@ -526,29 +459,20 @@ mod tests {
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)),
Ok(WorkspaceId(2))
);
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(2))
);
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(2)));
}
#[test]
fn reconnect_restores_the_migration_targets_remaining_workspace() {
let mut outputs = OutputWorkspaceMap::default();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs.focus_output(OutputId(2)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(2)));
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(3))
.unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)).unwrap();
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(2)));
@@ -557,37 +481,24 @@ mod tests {
#[test]
fn using_an_evacuated_workspace_does_not_prevent_automatic_return() {
let mut outputs = OutputWorkspaceMap::default();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
outputs.focus_output(OutputId(1)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
outputs
.switch_workspace(OutputId(1), WorkspaceId(2))
.unwrap();
outputs.switch_workspace(OutputId(1), WorkspaceId(2)).unwrap();
assert_eq!(
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)),
Ok(WorkspaceId(2))
);
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(2))
);
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
}
#[test]
fn repeated_lid_and_monitor_handoffs_restore_both_workspace_owners() {
let mut outputs = OutputWorkspaceMap::default();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1920), WorkspaceId(2))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
for removed in [OutputId(1), OutputId(2), OutputId(1), OutputId(2)] {
let remaining = if removed == OutputId(1) {
OutputId(2)
@@ -596,14 +507,8 @@ mod tests {
};
outputs.focus_output(removed).unwrap();
outputs.disconnect(removed).unwrap();
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(remaining)
);
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(remaining)
);
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(remaining));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(remaining));
// Switching between handed-off workspaces is normal use, not an
// explicit change of their home output.
outputs.switch_workspace(remaining, WorkspaceId(1)).unwrap();
@@ -615,14 +520,8 @@ mod tests {
WorkspaceId(3),
)
.unwrap();
assert_eq!(
outputs.output_for_workspace(WorkspaceId(1)),
Some(OutputId(1))
);
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(2))
);
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(2)));
}
@@ -631,12 +530,8 @@ mod tests {
#[test]
fn selecting_a_workspace_owned_by_another_output_focuses_that_output() {
let mut outputs = OutputWorkspaceMap::default();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs
.connect(OutputId(2), geometry(1_920), WorkspaceId(2))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)).unwrap();
assert_eq!(
outputs.switch_workspace(OutputId(1), WorkspaceId(2)),
@@ -648,18 +543,10 @@ mod tests {
#[test]
fn assigning_an_unused_workspace_does_not_change_the_active_workspace() {
let mut outputs = OutputWorkspaceMap::default();
outputs
.connect(OutputId(1), geometry(0), WorkspaceId(1))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
assert_eq!(
outputs.assign_workspace(OutputId(1), WorkspaceId(2)),
Ok(OutputId(1))
);
assert_eq!(outputs.assign_workspace(OutputId(1), WorkspaceId(2)), Ok(OutputId(1)));
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(
outputs.output_for_workspace(WorkspaceId(2)),
Some(OutputId(1))
);
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
}
}
+90 -334
View File
@@ -3,8 +3,8 @@ use std::error::Error;
use std::fmt;
use ferese_layout::{
Axis, ColumnWidth, Direction, GapConfig, LayoutError, LayoutResult, LayoutTree, Rect,
ScrollingLayout, SizeConstraints, ViewportFocusStrategy, WindowId,
Axis, ColumnWidth, Direction, GapConfig, LayoutError, LayoutResult, LayoutTree, Rect, ScrollingLayout,
SizeConstraints, ViewportFocusStrategy, WindowId,
};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -44,11 +44,7 @@ impl Default for WorkspaceLayout {
}
impl WorkspaceLayout {
pub fn new(
mode: LayoutMode,
default_column_width: ColumnWidth,
focus_strategy: ViewportFocusStrategy,
) -> Self {
pub fn new(mode: LayoutMode, default_column_width: ColumnWidth, focus_strategy: ViewportFocusStrategy) -> Self {
match mode {
LayoutMode::Scrolling => {
let mut layout = ScrollingLayout::with_default_width(default_column_width);
@@ -161,17 +157,11 @@ impl WorkspaceLayout {
constraints,
focused.filter(|window| layout.contains(*window)),
),
Self::Tree(layout) => {
layout.geometry_with_constraints(bounds, gaps, constraints, focused)
}
Self::Tree(layout) => layout.geometry_with_constraints(bounds, gaps, constraints, focused),
}
}
pub fn automatic_axis(
&self,
focused: Option<WindowId>,
bounds: Rect,
) -> Result<Axis, LayoutError> {
pub fn automatic_axis(&self, focused: Option<WindowId>, bounds: Rect) -> Result<Axis, LayoutError> {
match self {
Self::Scrolling(_) => Ok(Axis::Horizontal),
Self::Tree(layout) => layout.automatic_axis(focused, bounds),
@@ -190,24 +180,14 @@ impl WorkspaceLayout {
}
}
pub fn move_window(
&mut self,
window: WindowId,
direction: Direction,
bounds: Rect,
) -> Result<bool, LayoutError> {
pub fn move_window(&mut self, window: WindowId, direction: Direction, bounds: Rect) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => layout.move_window(window, direction),
Self::Tree(layout) => layout.move_window(window, direction, bounds),
}
}
pub fn resize_window(
&mut self,
window: WindowId,
direction: Direction,
amount: f64,
) -> Result<bool, LayoutError> {
pub fn resize_window(&mut self, window: WindowId, direction: Direction, amount: f64) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => layout.resize_window(window, direction, amount),
Self::Tree(layout) => layout.resize_window(window, direction, amount),
@@ -229,11 +209,7 @@ impl WorkspaceLayout {
}
}
pub fn cycle_column_width(
&mut self,
window: WindowId,
presets: &[ColumnWidth],
) -> Result<bool, LayoutError> {
pub fn cycle_column_width(&mut self, window: WindowId, presets: &[ColumnWidth]) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => layout.cycle_column_width(window, presets),
Self::Tree(_) => Ok(false),
@@ -280,11 +256,7 @@ impl WorkspaceLayout {
for column in columns {
for (row, window) in column.windows.into_iter().enumerate() {
let axis = if row == 0 {
Axis::Horizontal
} else {
Axis::Vertical
};
let axis = if row == 0 { Axis::Horizontal } else { Axis::Vertical };
tree.insert(window, previous, axis, 0.5)?;
previous = Some(window);
}
@@ -444,12 +416,7 @@ impl WorkspaceSet {
}
/// Update policy without moving windows or replacing existing column widths.
pub fn reconfigure_defaults(
&mut self,
mode: LayoutMode,
width: ColumnWidth,
strategy: ViewportFocusStrategy,
) {
pub fn reconfigure_defaults(&mut self, mode: LayoutMode, width: ColumnWidth, strategy: ViewportFocusStrategy) {
self.default_layout_mode = mode;
self.default_column_width = width;
self.scrolling_focus_strategy = strategy;
@@ -470,11 +437,7 @@ impl WorkspaceSet {
let workspace = Workspace {
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(),
last_focused: None,
fullscreen: None,
@@ -520,12 +483,8 @@ impl WorkspaceSet {
debug_assert!(self.validate().is_ok());
let focused = self.active().last_focused;
let visible_floating = focused.filter(|window| {
matches!(
self.placement(*window),
Some(WindowPlacement::Floating { .. })
)
});
let visible_floating =
focused.filter(|window| matches!(self.placement(*window), Some(WindowPlacement::Floating { .. })));
Ok(visible_floating.or(self.active().fullscreen).or(focused))
}
@@ -543,8 +502,7 @@ impl WorkspaceSet {
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.sort_unstable_by_key(|workspace| workspace_order_key(&workspace.name, workspace.id));
workspaces
}
@@ -552,20 +510,12 @@ impl WorkspaceSet {
let mut empty = self
.workspaces
.values()
.filter(|workspace| {
workspace.layout.window_ids().next().is_none() && workspace.floating.is_empty()
})
.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 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 {
@@ -588,32 +538,20 @@ impl WorkspaceSet {
self.placements.get(&window).copied()
}
pub fn set_active_layout_mode(
&mut self,
mode: LayoutMode,
bounds: Rect,
) -> Result<bool, WorkspaceError> {
pub fn set_active_layout_mode(&mut self, mode: LayoutMode, bounds: Rect) -> Result<bool, WorkspaceError> {
let default_column_width = self.default_column_width;
let focus_strategy = self.scrolling_focus_strategy;
let workspace = self.active_mut();
let focused = tiled_focus(workspace);
let changed = workspace.layout.set_mode(
mode,
bounds,
focused,
default_column_width,
focus_strategy,
)?;
let changed = workspace
.layout
.set_mode(mode, bounds, focused, default_column_width, focus_strategy)?;
debug_assert!(self.validate().is_ok());
Ok(changed)
}
pub fn set_floating_rect(
&mut self,
window: WindowId,
rect: Rect,
) -> Result<(), WorkspaceError> {
pub fn set_floating_rect(&mut self, window: WindowId, rect: Rect) -> Result<(), WorkspaceError> {
let placement = self
.placements
.get_mut(&window)
@@ -639,35 +577,23 @@ impl WorkspaceSet {
self.focus_window_with_reveal(window, false)
}
fn focus_window_with_reveal(
&mut self,
window: WindowId,
reveal: bool,
) -> Result<(), WorkspaceError> {
fn focus_window_with_reveal(&mut self, window: WindowId, reveal: bool) -> Result<(), WorkspaceError> {
if self.workspace_for_window(window) != Some(self.active) {
return Err(WorkspaceError::InvalidState(
"focused window is not on the active workspace",
));
}
if self.active().fullscreen.is_some_and(|fullscreen| {
fullscreen != window
&& !matches!(
self.placement(window),
Some(WindowPlacement::Floating { .. })
)
fullscreen != window && !matches!(self.placement(window), Some(WindowPlacement::Floating { .. }))
}) {
return Err(WorkspaceError::InvalidState(
"focused window is hidden by fullscreen",
));
return Err(WorkspaceError::InvalidState("focused window is hidden by fullscreen"));
}
if self.placement(window) == Some(WindowPlacement::Tiled) {
if reveal {
self.active_mut().layout.activate_window(window)?;
} else {
self.active_mut()
.layout
.activate_window_without_reveal(window)?;
self.active_mut().layout.activate_window_without_reveal(window)?;
}
}
self.active_mut().last_focused = Some(window);
@@ -676,11 +602,7 @@ impl WorkspaceSet {
Ok(())
}
pub fn stack_window(
&mut self,
window: WindowId,
target: WindowId,
) -> Result<(), WorkspaceError> {
pub fn stack_window(&mut self, window: WindowId, target: WindowId) -> Result<(), WorkspaceError> {
let workspace = self
.workspace_for_window(window)
.ok_or(LayoutError::UnknownWindow(window))?;
@@ -721,11 +643,7 @@ impl WorkspaceSet {
Ok(changed)
}
pub fn cycle_column_width(
&mut self,
window: WindowId,
presets: &[ColumnWidth],
) -> Result<bool, WorkspaceError> {
pub fn cycle_column_width(&mut self, window: WindowId, presets: &[ColumnWidth]) -> Result<bool, WorkspaceError> {
if self.workspace_for_window(window) != Some(self.active)
|| self.placement(window) != Some(WindowPlacement::Tiled)
{
@@ -734,10 +652,7 @@ impl WorkspaceSet {
));
}
let changed = self
.active_mut()
.layout
.cycle_column_width(window, presets)?;
let changed = self.active_mut().layout.cycle_column_width(window, presets)?;
debug_assert!(self.validate().is_ok());
Ok(changed)
}
@@ -804,12 +719,7 @@ impl WorkspaceSet {
self.activate(workspace)
}
pub fn insert_window(
&mut self,
window: WindowId,
axis: Axis,
ratio: f64,
) -> Result<(), WorkspaceError> {
pub fn insert_window(&mut self, window: WindowId, axis: Axis, ratio: f64) -> Result<(), WorkspaceError> {
if self.window_workspaces.contains_key(&window) {
return Err(WorkspaceError::DuplicateWindow(window));
}
@@ -1004,11 +914,7 @@ impl WorkspaceSet {
Ok(enabled)
}
pub fn set_fullscreen(
&mut self,
window: WindowId,
enabled: bool,
) -> Result<bool, WorkspaceError> {
pub fn set_fullscreen(&mut self, window: WindowId, enabled: bool) -> Result<bool, WorkspaceError> {
let workspace_id = self
.workspace_for_window(window)
.ok_or(LayoutError::UnknownWindow(window))?;
@@ -1038,18 +944,14 @@ impl WorkspaceSet {
return Err(WorkspaceError::InvalidState("active workspace is missing"));
}
if self.names.len() != self.workspaces.len() {
return Err(WorkspaceError::InvalidState(
"workspace name index is inconsistent",
));
return Err(WorkspaceError::InvalidState("workspace name index is inconsistent"));
}
let mut seen_windows = HashSet::new();
for (id, workspace) in &self.workspaces {
if workspace.id != *id || self.names.get(&workspace.name) != Some(id) {
return Err(WorkspaceError::InvalidState(
"workspace index is inconsistent",
));
return Err(WorkspaceError::InvalidState("workspace index is inconsistent"));
}
workspace.layout.validate()?;
@@ -1059,9 +961,7 @@ impl WorkspaceSet {
|| self.window_workspaces.get(&window) != Some(id)
|| self.placements.get(&window) != Some(&WindowPlacement::Tiled)
{
return Err(WorkspaceError::InvalidState(
"window ownership is inconsistent",
));
return Err(WorkspaceError::InvalidState("window ownership is inconsistent"));
}
}
@@ -1071,10 +971,7 @@ impl WorkspaceSet {
if !seen_floating.insert(*window)
|| !seen_windows.insert(*window)
|| self.window_workspaces.get(window) != Some(id)
|| !matches!(
self.placements.get(window),
Some(WindowPlacement::Floating { .. })
)
|| !matches!(self.placements.get(window), Some(WindowPlacement::Floating { .. }))
{
return Err(WorkspaceError::InvalidState(
"floating window ownership is inconsistent",
@@ -1086,9 +983,7 @@ impl WorkspaceSet {
.last_focused
.is_some_and(|window| self.window_workspaces.get(&window) != Some(id))
{
return Err(WorkspaceError::InvalidState(
"workspace focus is inconsistent",
));
return Err(WorkspaceError::InvalidState("workspace focus is inconsistent"));
}
if workspace
@@ -1101,12 +996,8 @@ impl WorkspaceSet {
}
}
if seen_windows.len() != self.window_workspaces.len()
|| self.placements.len() != self.window_workspaces.len()
{
return Err(WorkspaceError::InvalidState(
"window index retains stale entries",
));
if seen_windows.len() != self.window_workspaces.len() || self.placements.len() != self.window_workspaces.len() {
return Err(WorkspaceError::InvalidState("window index retains stale entries"));
}
Ok(())
@@ -1114,13 +1005,10 @@ impl WorkspaceSet {
}
fn first_window(workspace: &Workspace) -> Option<WindowId> {
workspace.layout.preferred_window().or_else(|| {
workspace
.floating
.iter()
.copied()
.min_by_key(|window| window.0)
})
.layout
.preferred_window()
.or_else(|| workspace.floating.iter().copied().min_by_key(|window| window.0))
}
fn tiled_focus(workspace: &Workspace) -> Option<WindowId> {
@@ -1151,10 +1039,7 @@ mod tests {
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]
);
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();
@@ -1188,19 +1073,13 @@ mod tests {
.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_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_eq!(set.prune_empty(&std::collections::HashSet::new()), vec![first]);
assert!(set.validate().is_ok());
assert_ne!(set.ensure_numeric(1).unwrap(), first);
}
@@ -1222,9 +1101,7 @@ mod tests {
fn live_policy_updates_existing_layouts_and_default_widths_not_custom_widths() {
let mut workspaces = WorkspaceSet::default();
for id in 1..=3 {
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
}
let WorkspaceLayout::Scrolling(layout) = &mut workspaces.active_mut().layout else {
panic!()
@@ -1263,30 +1140,18 @@ mod tests {
#[test]
fn reconfiguration_preserves_windows_and_current_widths_but_updates_defaults() {
let mut workspaces = WorkspaceSet::default();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
let workspace = workspaces.active_id();
workspaces.reconfigure_defaults(
LayoutMode::Tree,
ColumnWidth::Full,
ViewportFocusStrategy::Minimal,
);
workspaces.reconfigure_defaults(LayoutMode::Tree, ColumnWidth::Full, ViewportFocusStrategy::Minimal);
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!("existing layout must survive");
};
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
assert_eq!(layout.default_width(), ColumnWidth::Full);
assert_eq!(
workspaces.workspace_for_window(WindowId(1)),
Some(workspace)
);
assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace));
workspaces.switch_to_numeric(2).unwrap();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree);
assert_eq!(
workspaces.workspace_for_window(WindowId(1)),
Some(workspace)
);
assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace));
}
use super::*;
@@ -1304,14 +1169,9 @@ mod tests {
#[test]
fn configured_layout_defaults_apply_to_new_workspaces() {
let mut workspaces = WorkspaceSet::new(
LayoutMode::Scrolling,
ColumnWidth::Full,
ViewportFocusStrategy::Minimal,
);
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
let mut workspaces =
WorkspaceSet::new(LayoutMode::Scrolling, ColumnWidth::Full, ViewportFocusStrategy::Minimal);
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
let first = workspaces
.active_mut()
.layout
@@ -1323,10 +1183,7 @@ mod tests {
)
.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();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
}
@@ -1334,14 +1191,10 @@ mod tests {
#[test]
fn switching_restores_workspace_focus() {
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);
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_numeric(2).unwrap(), Some(WindowId(2)));
@@ -1351,12 +1204,8 @@ mod tests {
#[test]
fn moving_a_window_updates_membership_without_switching() {
let mut workspaces = WorkspaceSet::default();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
let first = workspaces.active_id();
let second = workspaces
.move_window_to_numeric(WindowId(2), 2, Axis::Horizontal, 0.5)
@@ -1364,32 +1213,16 @@ mod tests {
assert_eq!(workspaces.active_id(), first);
assert_eq!(workspaces.workspace_for_window(WindowId(2)), Some(second));
assert!(
!workspaces
.workspace(first)
.unwrap()
.layout
.contains(WindowId(2))
);
assert!(
workspaces
.workspace(second)
.unwrap()
.layout
.contains(WindowId(2))
);
assert!(!workspaces.workspace(first).unwrap().layout.contains(WindowId(2)));
assert!(workspaces.workspace(second).unwrap().layout.contains(WindowId(2)));
assert!(workspaces.validate().is_ok());
}
#[test]
fn removing_focus_chooses_a_deterministic_remaining_window() {
let mut workspaces = WorkspaceSet::default();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces.remove_window(WindowId(1)).unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
@@ -1400,9 +1233,7 @@ mod tests {
fn closing_a_scrolling_column_focuses_its_adjacent_column() {
let mut workspaces = WorkspaceSet::default();
for id in 1..=4 {
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
}
workspaces.focus_window(WindowId(3)).unwrap();
@@ -1414,9 +1245,7 @@ mod tests {
#[test]
fn focus_rejects_a_window_on_an_inactive_workspace() {
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
.move_window_to_numeric(WindowId(1), 2, Axis::Horizontal, 0.5)
.unwrap();
@@ -1433,9 +1262,7 @@ mod tests {
#[test]
fn floating_toggle_removes_and_restores_tiled_membership() {
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 rect = Rect::new(10.0, 20.0, 640.0, 480.0);
assert_eq!(
@@ -1461,21 +1288,14 @@ mod tests {
#[test]
fn floating_geometry_updates_are_authoritative_and_validated() {
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.set_floating_rect(WindowId(1), Rect::default()),
Err(WorkspaceError::InvalidState("window is not floating"))
);
workspaces
.toggle_floating(
WindowId(1),
Rect::new(10.0, 20.0, 640.0, 480.0),
Axis::Horizontal,
0.5,
)
.toggle_floating(WindowId(1), Rect::new(10.0, 20.0, 640.0, 480.0), Axis::Horizontal, 0.5)
.unwrap();
workspaces
.set_floating_rect(WindowId(1), Rect::new(30.0, 40.0, f64::NAN, -2.0))
@@ -1497,15 +1317,9 @@ mod tests {
ViewportFocusStrategy::Center,
ViewportFocusStrategy::Paged,
] {
let mut workspaces = WorkspaceSet::new(
LayoutMode::Scrolling,
ColumnWidth::Proportion(0.5),
strategy,
);
let mut workspaces = WorkspaceSet::new(LayoutMode::Scrolling, ColumnWidth::Proportion(0.5), strategy);
for id in 1..=3 {
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1000.0, 800.0);
let gaps = GapConfig {
@@ -1555,20 +1369,13 @@ mod tests {
ViewportFocusStrategy::Paged,
);
for id in 1..=4 {
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1000.0, 800.0);
let before = workspaces
.active_mut()
.layout
.geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
Some(WindowId(4)),
)
.geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(4)))
.unwrap();
let scroll = workspaces.active().layout.viewport_x();
workspaces
@@ -1582,12 +1389,7 @@ mod tests {
let after = workspaces
.active_mut()
.layout
.geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
Some(WindowId(5)),
)
.geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(5)))
.unwrap();
assert_eq!(before.geometry, after.geometry);
assert_eq!(scroll, workspaces.active().layout.viewport_x());
@@ -1598,9 +1400,7 @@ mod tests {
#[test]
fn floating_window_can_follow_its_parent_workspace_without_focus() {
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();
workspaces.switch_to_numeric(2).unwrap();
workspaces
@@ -1612,10 +1412,7 @@ mod tests {
)
.unwrap();
assert_eq!(
workspaces.workspace_for_window(WindowId(2)),
Some(parent_workspace)
);
assert_eq!(workspaces.workspace_for_window(WindowId(2)), Some(parent_workspace));
assert_eq!(
workspaces.workspace(parent_workspace).unwrap().last_focused,
Some(WindowId(1))
@@ -1626,9 +1423,7 @@ mod tests {
#[test]
fn fullscreen_preserves_underlying_placement() {
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 before = workspaces.placement(WindowId(1));
assert!(workspaces.toggle_fullscreen(WindowId(1)).unwrap());
@@ -1646,13 +1441,9 @@ mod tests {
#[test]
fn mapping_under_fullscreen_does_not_change_focus() {
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
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(1)));
assert_eq!(workspaces.active().fullscreen, Some(WindowId(1)));
@@ -1664,17 +1455,10 @@ mod tests {
fn floating_dialog_can_focus_above_fullscreen() {
let mut workspaces = WorkspaceSet::default();
let workspace = workspaces.active_id();
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
.insert_floating_window(
WindowId(2),
workspace,
Rect::new(100.0, 100.0, 500.0, 400.0),
true,
)
.insert_floating_window(WindowId(2), workspace, Rect::new(100.0, 100.0, 500.0, 400.0), true)
.unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
@@ -1688,14 +1472,10 @@ mod tests {
#[test]
fn switching_to_fullscreen_restores_the_visible_window() {
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.switch_to_numeric(2).unwrap();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces
.move_window_to_numeric(WindowId(2), 1, Axis::Horizontal, 0.5)
.unwrap();
@@ -1704,9 +1484,7 @@ mod tests {
assert_eq!(workspaces.active().last_focused, Some(WindowId(1)));
assert_eq!(
workspaces.focus_window(WindowId(2)),
Err(WorkspaceError::InvalidState(
"focused window is hidden by fullscreen"
))
Err(WorkspaceError::InvalidState("focused window is hidden by fullscreen"))
);
assert!(workspaces.validate().is_ok());
}
@@ -1714,12 +1492,8 @@ mod tests {
#[test]
fn grouping_tiled_windows_preserves_workspace_membership() {
let mut workspaces = WorkspaceSet::default();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
workspaces
.insert_window(WindowId(2), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces.stack_window(WindowId(2), WindowId(1)).unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
@@ -1740,26 +1514,16 @@ mod tests {
fn layout_mode_conversion_preserves_membership_and_focus() {
let mut workspaces = WorkspaceSet::default();
for id in 1..=4 {
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
}
workspaces.focus_window(WindowId(2)).unwrap();
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
assert!(
workspaces
.set_active_layout_mode(LayoutMode::Tree, bounds)
.unwrap()
);
assert!(workspaces.set_active_layout_mode(LayoutMode::Tree, bounds).unwrap());
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree);
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
assert_eq!(
workspaces
.active()
.layout
.window_ids()
.collect::<HashSet<_>>(),
workspaces.active().layout.window_ids().collect::<HashSet<_>>(),
HashSet::from([WindowId(1), WindowId(2), WindowId(3), WindowId(4)])
);
@@ -1777,19 +1541,14 @@ mod tests {
fn scrolling_column_commands_preserve_workspace_state() {
let mut workspaces = WorkspaceSet::default();
for id in 1..=3 {
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
workspaces.insert_window(WindowId(id), Axis::Horizontal, 0.5).unwrap();
}
workspaces.stack_window(WindowId(3), WindowId(2)).unwrap();
assert!(workspaces.extract_window(WindowId(3)).unwrap());
assert!(
workspaces
.cycle_column_width(
WindowId(3),
&[ColumnWidth::Proportion(0.5), ColumnWidth::Full],
)
.cycle_column_width(WindowId(3), &[ColumnWidth::Proportion(0.5), ColumnWidth::Full],)
.unwrap()
);
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
@@ -1817,9 +1576,7 @@ mod tests {
0 if windows.len() < 48 => {
let window = WindowId(next_window);
next_window += 1;
workspaces
.insert_window(window, random_axis(random), 0.5)
.unwrap();
workspaces.insert_window(window, random_axis(random), 0.5).unwrap();
windows.push(window);
}
1 if !windows.is_empty() => {
@@ -1866,8 +1623,7 @@ mod tests {
})
.collect::<Vec<_>>();
if let Some(window) = candidates.get(random as usize % candidates.len().max(1))
{
if let Some(window) = candidates.get(random as usize % candidates.len().max(1)) {
workspaces.focus_window(*window).unwrap();
}
}
+3 -6
View File
@@ -1,6 +1,6 @@
use std::error::Error;
use std::fmt;
use std::io::{self, Read, Write};
use std::io::{Read, Write};
use std::{fmt, io};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -69,10 +69,7 @@ impl fmt::Display for FrameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => error.fmt(formatter),
Self::Oversized(size) => write!(
formatter,
"IPC payload is {size} bytes; maximum is {MAX_PAYLOAD_SIZE}"
),
Self::Oversized(size) => write!(formatter, "IPC payload is {size} bytes; maximum is {MAX_PAYLOAD_SIZE}"),
Self::InvalidJson(error) => write!(formatter, "invalid IPC JSON: {error}"),
}
}
+56 -202
View File
@@ -54,12 +54,7 @@ pub struct Rect {
impl Rect {
pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
Self {
x,
y,
width,
height,
}
Self { x, y, width, height }
}
fn center(self) -> (f64, f64) {
@@ -365,11 +360,7 @@ impl LayoutTree {
Ok(geometry)
}
pub fn geometry_with_gaps(
&self,
bounds: Rect,
gaps: GapConfig,
) -> Result<HashMap<WindowId, Rect>, LayoutError> {
pub fn geometry_with_gaps(&self, bounds: Rect, gaps: GapConfig) -> Result<HashMap<WindowId, Rect>, LayoutError> {
let mut geometry = self.geometry(bounds)?;
apply_gaps(&mut geometry, bounds, gaps);
@@ -434,11 +425,7 @@ impl LayoutTree {
Ok(result)
}
pub fn automatic_axis(
&self,
focused: Option<WindowId>,
bounds: Rect,
) -> Result<Axis, LayoutError> {
pub fn automatic_axis(&self, focused: Option<WindowId>, bounds: Rect) -> Result<Axis, LayoutError> {
let Some(root) = self.root else {
return Ok(Axis::Horizontal);
};
@@ -516,12 +503,7 @@ impl LayoutTree {
.map(|(candidate, _, _, _)| candidate))
}
pub fn move_window(
&mut self,
window: WindowId,
direction: Direction,
bounds: Rect,
) -> Result<bool, LayoutError> {
pub fn move_window(&mut self, window: WindowId, direction: Direction, bounds: Rect) -> Result<bool, LayoutError> {
let Some(neighbor) = self.directional_neighbor(window, direction, bounds)? else {
return Ok(false);
};
@@ -545,12 +527,7 @@ impl LayoutTree {
Ok(true)
}
pub fn resize_window(
&mut self,
window: WindowId,
direction: Direction,
amount: f64,
) -> Result<bool, LayoutError> {
pub fn resize_window(&mut self, window: WindowId, direction: Direction, amount: f64) -> Result<bool, LayoutError> {
let mut child = self
.windows
.get(&window)
@@ -651,9 +628,7 @@ impl LayoutTree {
match self.nodes.get(&node) {
Some(Node::Window(_)) => Ok(node),
Some(Node::Split { first, .. }) => self.first_window(*first),
Some(Node::Stack {
children, active, ..
}) => children
Some(Node::Stack { children, active, .. }) => children
.get(*active)
.copied()
.ok_or(LayoutError::InvalidTree("stack active index is invalid"))
@@ -662,12 +637,7 @@ impl LayoutTree {
}
}
fn replace_child(
&mut self,
parent: NodeId,
old: NodeId,
new: NodeId,
) -> Result<(), LayoutError> {
fn replace_child(&mut self, parent: NodeId, old: NodeId, new: NodeId) -> Result<(), LayoutError> {
match self.nodes.get_mut(&parent) {
Some(Node::Split { first, .. }) if *first == old => *first = new,
Some(Node::Split { second, .. }) if *second == old => *second = new,
@@ -684,12 +654,7 @@ impl LayoutTree {
Ok(())
}
fn collapse_parent(
&mut self,
parent: NodeId,
leaf: NodeId,
sibling: NodeId,
) -> Result<(), LayoutError> {
fn collapse_parent(&mut self, parent: NodeId, leaf: NodeId, sibling: NodeId) -> Result<(), LayoutError> {
let window = match self.nodes.get(&leaf) {
Some(Node::Window(window)) => *window,
_ => return Err(LayoutError::InvalidTree("removed leaf is not a window")),
@@ -786,21 +751,11 @@ impl LayoutTree {
} else {
(available - second_required).max(0.0)
};
let adjusted_ratio = if available > 0.0 {
first_extent / available
} else {
0.5
};
let adjusted_ratio = if available > 0.0 { first_extent / available } else { 0.5 };
let (first_bounds, second_bounds) = split_rect(bounds, *axis, adjusted_ratio);
self.layout_node_constrained(*first, first_bounds, constraints, focused, geometry)?;
self.layout_node_constrained(
*second,
second_bounds,
constraints,
focused,
geometry,
)?;
self.layout_node_constrained(*second, second_bounds, constraints, focused, geometry)?;
}
Some(Node::Stack { children, active }) => {
let child = children
@@ -826,10 +781,7 @@ impl LayoutTree {
Ok((constraint.min_width, constraint.min_height))
}
Some(Node::Split {
axis,
first,
second,
..
axis, first, second, ..
}) => {
let first = self.minimum_size(*first, constraints)?;
let second = self.minimum_size(*second, constraints)?;
@@ -860,9 +812,7 @@ impl LayoutTree {
Some(Node::Split { first, second, .. }) => {
self.contains_window(*first, window) || self.contains_window(*second, window)
}
Some(Node::Stack { children, .. }) => children
.iter()
.any(|child| self.contains_window(*child, window)),
Some(Node::Stack { children, .. }) => children.iter().any(|child| self.contains_window(*child, window)),
None => false,
}
}
@@ -887,15 +837,9 @@ impl LayoutTree {
}
}
Some(Node::Split {
ratio,
first,
second,
..
ratio, first, second, ..
}) => {
if first == second
|| !ratio.is_finite()
|| !(MIN_SPLIT_RATIO..=MAX_SPLIT_RATIO).contains(ratio)
{
if first == second || !ratio.is_finite() || !(MIN_SPLIT_RATIO..=MAX_SPLIT_RATIO).contains(ratio) {
return Err(LayoutError::InvalidTree("split is invalid"));
}
@@ -931,11 +875,7 @@ fn valid_gap(gap: f64) -> f64 {
}
fn valid_resize_amount(amount: f64) -> f64 {
if amount.is_finite() {
amount.abs()
} else {
0.0
}
if amount.is_finite() { amount.abs() } else { 0.0 }
}
fn normalized_constraints(constraints: SizeConstraints) -> SizeConstraints {
@@ -1029,12 +969,7 @@ fn nearly_equal(left: f64, right: f64) -> bool {
(left - right).abs() < f64::EPSILON * left.abs().max(right.abs()).max(1.0)
}
fn intervals_overlap(
first_start: f64,
first_size: f64,
second_start: f64,
second_size: f64,
) -> bool {
fn intervals_overlap(first_start: f64, first_size: f64, second_start: f64, second_size: f64) -> bool {
first_start < second_start + second_size && second_start < first_start + first_size
}
@@ -1043,24 +978,14 @@ fn split_rect(bounds: Rect, axis: Axis, ratio: f64) -> (Rect, Rect) {
Axis::Horizontal => {
let boundary = bounds.x + bounds.width * ratio;
let first = Rect::new(bounds.x, bounds.y, boundary - bounds.x, bounds.height);
let second = Rect::new(
boundary,
bounds.y,
bounds.x + bounds.width - boundary,
bounds.height,
);
let second = Rect::new(boundary, bounds.y, bounds.x + bounds.width - boundary, bounds.height);
(first, second)
}
Axis::Vertical => {
let boundary = bounds.y + bounds.height * ratio;
let first = Rect::new(bounds.x, bounds.y, bounds.width, boundary - bounds.y);
let second = Rect::new(
bounds.x,
boundary,
bounds.width,
bounds.y + bounds.height - boundary,
);
let second = Rect::new(bounds.x, boundary, bounds.width, bounds.y + bounds.height - boundary);
(first, second)
}
@@ -1074,43 +999,32 @@ mod tests {
#[test]
fn insertion_splits_the_focused_leaf() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(1)), Axis::Vertical, 0.5)
.unwrap();
assert!(tree.validate().is_ok());
assert_eq!(
tree.geometry(Rect::new(0.0, 0.0, 100.0, 80.0))
.unwrap()
.len(),
3
);
assert_eq!(tree.geometry(Rect::new(0.0, 0.0, 100.0, 80.0)).unwrap().len(), 3);
}
#[test]
fn removal_collapses_the_parent_split() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.remove(WindowId(1)).unwrap();
assert!(tree.validate().is_ok());
assert_eq!(
tree.node(tree.root().unwrap()),
Some(&Node::Window(WindowId(2)))
);
assert_eq!(tree.node(tree.root().unwrap()), Some(&Node::Window(WindowId(2))));
}
#[test]
fn shared_split_boundary_is_exact() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 1.0 / 3.0)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 1.0 / 3.0).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 1.0 / 3.0)
.unwrap();
@@ -1125,8 +1039,7 @@ mod tests {
#[test]
fn rejects_duplicate_and_unknown_windows() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
assert_eq!(
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5),
@@ -1147,28 +1060,20 @@ mod tests {
#[test]
fn automatic_axis_follows_focused_tile_shape() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
let wide = Rect::new(0.0, 0.0, 1_920.0, 1_080.0);
assert_eq!(
tree.automatic_axis(Some(WindowId(1)), wide).unwrap(),
Axis::Horizontal
);
assert_eq!(tree.automatic_axis(Some(WindowId(1)), wide).unwrap(), Axis::Horizontal);
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
assert_eq!(
tree.automatic_axis(Some(WindowId(1)), wide).unwrap(),
Axis::Vertical
);
assert_eq!(tree.automatic_axis(Some(WindowId(1)), wide).unwrap(), Axis::Vertical);
}
#[test]
fn gaps_preserve_outer_and_shared_spacing() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
@@ -1186,8 +1091,7 @@ mod tests {
#[test]
fn smart_gaps_remove_outer_gap_for_one_window() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
let bounds = Rect::new(0.0, 0.0, 100.0, 80.0);
let geometry = tree
@@ -1206,8 +1110,7 @@ mod tests {
#[test]
fn default_gaps_keep_outer_spacing_for_one_window() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
let geometry = tree
.geometry_with_gaps(Rect::new(0.0, 0.0, 100.0, 80.0), GapConfig::default())
@@ -1219,8 +1122,7 @@ mod tests {
#[test]
fn directional_neighbor_prefers_primary_distance() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(2)), Axis::Vertical, 0.5)
@@ -1233,13 +1135,11 @@ mod tests {
Some(WindowId(2))
);
assert_eq!(
tree.directional_neighbor(WindowId(2), Direction::Down, bounds)
.unwrap(),
tree.directional_neighbor(WindowId(2), Direction::Down, bounds).unwrap(),
Some(WindowId(3))
);
assert_eq!(
tree.directional_neighbor(WindowId(1), Direction::Left, bounds)
.unwrap(),
tree.directional_neighbor(WindowId(1), Direction::Left, bounds).unwrap(),
None
);
}
@@ -1247,16 +1147,12 @@ mod tests {
#[test]
fn directional_move_swaps_window_leaves() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
let bounds = Rect::new(0.0, 0.0, 100.0, 80.0);
assert!(
tree.move_window(WindowId(1), Direction::Right, bounds)
.unwrap()
);
assert!(tree.move_window(WindowId(1), Direction::Right, bounds).unwrap());
let geometry = tree.geometry(bounds).unwrap();
assert_eq!(geometry[&WindowId(2)].x, 0.0);
@@ -1267,16 +1163,11 @@ mod tests {
#[test]
fn directional_move_at_boundary_is_a_noop() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
assert!(
!tree
.move_window(
WindowId(1),
Direction::Left,
Rect::new(0.0, 0.0, 100.0, 80.0),
)
.move_window(WindowId(1), Direction::Left, Rect::new(0.0, 0.0, 100.0, 80.0),)
.unwrap()
);
}
@@ -1284,18 +1175,14 @@ mod tests {
#[test]
fn directional_resize_changes_the_nearest_matching_split() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
let bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
assert!(
tree.resize_window(WindowId(1), Direction::Right, 0.1)
.unwrap()
);
assert!(tree.resize_window(WindowId(1), Direction::Right, 0.1).unwrap());
let geometry = tree.geometry(bounds).unwrap();
assert_eq!(geometry[&WindowId(1)].width, 30.0);
@@ -1307,23 +1194,15 @@ mod tests {
#[test]
fn directional_resize_walks_to_an_ancestor_and_clamps() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(1)), Axis::Vertical, 0.5)
.unwrap();
let bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
assert!(
tree.resize_window(WindowId(3), Direction::Right, 1.0)
.unwrap()
);
assert!(
!tree
.resize_window(WindowId(3), Direction::Right, 1.0)
.unwrap()
);
assert!(tree.resize_window(WindowId(3), Direction::Right, 1.0).unwrap());
assert!(!tree.resize_window(WindowId(3), Direction::Right, 1.0).unwrap());
let geometry = tree.geometry(bounds).unwrap();
assert_eq!(geometry[&WindowId(2)].x, 95.0);
@@ -1333,26 +1212,16 @@ mod tests {
#[test]
fn directional_resize_at_an_outer_edge_is_a_noop() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
assert!(
!tree
.resize_window(WindowId(1), Direction::Left, 0.05)
.unwrap()
);
assert!(
!tree
.resize_window(WindowId(1), Direction::Right, f64::NAN)
.unwrap()
);
assert!(!tree.resize_window(WindowId(1), Direction::Left, 0.05).unwrap());
assert!(!tree.resize_window(WindowId(1), Direction::Right, f64::NAN).unwrap());
}
#[test]
fn stacking_windows_shows_only_the_active_child() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.stack_window(WindowId(2), WindowId(1)).unwrap();
@@ -1366,8 +1235,7 @@ mod tests {
#[test]
fn activating_a_hidden_stack_child_changes_visible_geometry() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.stack_window(WindowId(2), WindowId(1)).unwrap();
@@ -1382,8 +1250,7 @@ mod tests {
#[test]
fn removing_from_a_stack_collapses_or_preserves_cardinality() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
tree.stack_window(WindowId(2), WindowId(1)).unwrap();
@@ -1393,10 +1260,7 @@ mod tests {
tree.remove(WindowId(3)).unwrap();
tree.remove(WindowId(2)).unwrap();
assert_eq!(
tree.node(tree.root().unwrap()),
Some(&Node::Window(WindowId(1)))
);
assert_eq!(tree.node(tree.root().unwrap()), Some(&Node::Window(WindowId(1))));
assert!(tree.validate().is_ok());
}
@@ -1409,9 +1273,7 @@ mod tests {
let bounds = Rect::new(0.0, 0.0, 1_920.0, 1_080.0);
for _ in 0..2_000 {
random = random
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
random = random.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let operation = random % 6;
match operation {
@@ -1425,8 +1287,7 @@ mod tests {
Axis::Vertical
};
tree.insert(window, focused, axis, ratio_from_random(random))
.unwrap();
tree.insert(window, focused, axis, ratio_from_random(random)).unwrap();
windows.push(window);
}
1 if !windows.is_empty() => {
@@ -1453,8 +1314,7 @@ mod tests {
}
5 if !windows.is_empty() => {
let window = windows[random as usize % windows.len()];
tree.resize_window(window, random_direction(random), 0.03)
.unwrap();
tree.resize_window(window, random_direction(random), 0.03).unwrap();
}
_ => {}
}
@@ -1466,8 +1326,7 @@ mod tests {
#[test]
fn constraints_adjust_split_boundaries_when_space_is_available() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.2)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.2).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.2)
.unwrap();
let constraints = HashMap::from([
@@ -1507,8 +1366,7 @@ mod tests {
#[test]
fn focused_window_wins_an_impossible_minimum_constraint_conflict() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
let constraints = HashMap::from([
@@ -1550,8 +1408,7 @@ mod tests {
#[test]
fn maximum_constraints_clip_without_overlapping_neighbors() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
let constraints = HashMap::from([(
WindowId(1),
SizeConstraints {
@@ -1569,10 +1426,7 @@ mod tests {
)
.unwrap();
assert_eq!(
result.geometry[&WindowId(1)],
Rect::new(4.0, 4.0, 60.0, 40.0)
);
assert_eq!(result.geometry[&WindowId(1)], Rect::new(4.0, 4.0, 60.0, 40.0));
assert_eq!(result.warnings.len(), 2);
}
+68 -240
View File
@@ -1,8 +1,8 @@
use std::collections::{HashMap, HashSet};
use crate::{
ConstraintKind, ConstraintWarning, Direction, GapConfig, LayoutError, LayoutResult, Rect,
SizeConstraints, WindowId, normalized_constraints, record_minimum_warnings,
ConstraintKind, ConstraintWarning, Direction, GapConfig, LayoutError, LayoutResult, Rect, SizeConstraints,
WindowId, normalized_constraints, record_minimum_warnings,
};
const DEFAULT_WIDTH: f64 = 0.5;
@@ -145,16 +145,10 @@ impl ScrollingLayout {
}
pub fn window_ids(&self) -> impl Iterator<Item = WindowId> + '_ {
self.columns
.iter()
.flat_map(|column| column.windows.iter().copied())
self.columns.iter().flat_map(|column| column.windows.iter().copied())
}
pub fn insert(
&mut self,
window: WindowId,
focused: Option<WindowId>,
) -> Result<(), LayoutError> {
pub fn insert(&mut self, window: WindowId, focused: Option<WindowId>) -> Result<(), LayoutError> {
if self.contains(window) {
return Err(LayoutError::DuplicateWindow(window));
}
@@ -163,8 +157,7 @@ impl ScrollingLayout {
.and_then(|focused| self.window_location(focused))
.map(|(column, _)| column + 1)
.unwrap_or(self.columns.len());
self.columns
.insert(index, Column::new(window, self.default_width));
self.columns.insert(index, Column::new(window, self.default_width));
self.active_column = Some(index);
self.reveal_pending = (self.columns.len() > 1).then_some(window);
@@ -173,12 +166,9 @@ impl ScrollingLayout {
}
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (column_index, window_index) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let (column_index, window_index) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let column = &mut self.columns[column_index];
let removed_active_column =
self.active_column == Some(column_index) && column.windows.len() == 1;
let removed_active_column = self.active_column == Some(column_index) && column.windows.len() == 1;
column.windows.remove(window_index);
column.heights.remove(window_index);
@@ -206,9 +196,7 @@ impl ScrollingLayout {
}
pub fn focus(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (column, index) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let (column, index) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let changed_column = self.active_column != Some(column);
self.active_column = Some(column);
self.columns[column].active = index;
@@ -221,9 +209,7 @@ impl ScrollingLayout {
/// Hover changes the active window without requesting viewport movement.
/// Keep any earlier explicit reveal attached to its original window.
pub fn focus_without_reveal(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (column, index) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let (column, index) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
self.active_column = Some(column);
self.columns[column].active = index;
Ok(())
@@ -237,11 +223,7 @@ impl ScrollingLayout {
Ok(())
}
pub fn slide_focus_from(
&mut self,
previous: WindowId,
focused: WindowId,
) -> Result<(), LayoutError> {
pub fn slide_focus_from(&mut self, previous: WindowId, focused: WindowId) -> Result<(), LayoutError> {
self.window_location(previous)
.ok_or(LayoutError::UnknownWindow(previous))?;
self.focus_without_reveal(focused)?;
@@ -250,16 +232,11 @@ impl ScrollingLayout {
Ok(())
}
pub fn move_into_column(
&mut self,
window: WindowId,
target: WindowId,
) -> Result<(), LayoutError> {
pub fn move_into_column(&mut self, window: WindowId, target: WindowId) -> Result<(), LayoutError> {
if window == target {
return Ok(());
}
self.window_location(target)
.ok_or(LayoutError::UnknownWindow(target))?;
self.window_location(target).ok_or(LayoutError::UnknownWindow(target))?;
self.remove(window)?;
let (target_column, target_index) = self
.window_location(target)
@@ -278,9 +255,7 @@ impl ScrollingLayout {
}
pub fn extract_to_column(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (source, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let (source, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
if self.columns[source].windows.len() == 1 {
self.active_column = Some(source);
return Ok(());
@@ -288,8 +263,7 @@ impl ScrollingLayout {
self.remove(window)?;
let insertion = (source + 1).min(self.columns.len());
self.columns
.insert(insertion, Column::new(window, self.default_width));
self.columns.insert(insertion, Column::new(window, self.default_width));
self.active_column = Some(insertion);
self.reveal_pending = self.active_window();
@@ -297,32 +271,16 @@ impl ScrollingLayout {
Ok(())
}
pub fn set_column_width(
&mut self,
window: WindowId,
width: ColumnWidth,
) -> Result<(), LayoutError> {
let (column, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
pub fn set_column_width(&mut self, window: WindowId, width: ColumnWidth) -> Result<(), LayoutError> {
let (column, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
self.columns[column].width = normalized_width(width);
self.reveal_pending = self.active_window();
Ok(())
}
pub fn cycle_column_width(
&mut self,
window: WindowId,
presets: &[ColumnWidth],
) -> Result<bool, LayoutError> {
let (column, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let presets = presets
.iter()
.copied()
.map(normalized_width)
.collect::<Vec<_>>();
pub fn cycle_column_width(&mut self, window: WindowId, presets: &[ColumnWidth]) -> Result<bool, LayoutError> {
let (column, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let presets = presets.iter().copied().map(normalized_width).collect::<Vec<_>>();
if presets.is_empty() {
return Ok(false);
}
@@ -365,35 +323,22 @@ impl ScrollingLayout {
window: WindowId,
direction: Direction,
) -> Result<Option<WindowId>, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let (column, row) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let neighbor = match direction {
Direction::Up => row
.checked_sub(1)
.map(|row| self.columns[column].windows[row]),
Direction::Up => row.checked_sub(1).map(|row| self.columns[column].windows[row]),
Direction::Down => self.columns[column].windows.get(row + 1).copied(),
Direction::Left => column.checked_sub(1).map(|column| {
let column = &self.columns[column];
column.windows[column.active]
}),
Direction::Right => self
.columns
.get(column + 1)
.map(|column| column.windows[column.active]),
Direction::Right => self.columns.get(column + 1).map(|column| column.windows[column.active]),
};
Ok(neighbor)
}
pub fn move_window(
&mut self,
window: WindowId,
direction: Direction,
) -> Result<bool, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
pub fn move_window(&mut self, window: WindowId, direction: Direction) -> Result<bool, LayoutError> {
let (column, row) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
match direction {
Direction::Up if row > 0 => {
@@ -407,9 +352,7 @@ impl ScrollingLayout {
self.columns[column].active = row + 1;
}
Direction::Left if column > 0 => self.move_horizontally(window, column - 1)?,
Direction::Right if column + 1 < self.columns.len() => {
self.move_horizontally(window, column + 1)?
}
Direction::Right if column + 1 < self.columns.len() => self.move_horizontally(window, column + 1)?,
_ => return Ok(false),
}
@@ -417,20 +360,9 @@ impl ScrollingLayout {
Ok(true)
}
pub fn resize_window(
&mut self,
window: WindowId,
direction: Direction,
amount: f64,
) -> Result<bool, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let amount = if amount.is_finite() {
amount.abs()
} else {
0.0
};
pub fn resize_window(&mut self, window: WindowId, direction: Direction, amount: f64) -> Result<bool, LayoutError> {
let (column, row) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let amount = if amount.is_finite() { amount.abs() } else { 0.0 };
if amount == 0.0 {
return Ok(false);
}
@@ -441,18 +373,12 @@ impl ScrollingLayout {
ColumnWidth::Proportion(value) => value,
ColumnWidth::Fixed(_) | ColumnWidth::Full => DEFAULT_WIDTH,
};
let adjustment = if direction == Direction::Right {
amount
} else {
-amount
};
let adjustment = if direction == Direction::Right { amount } else { -amount };
let resized = (current + adjustment).clamp(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH);
self.columns[column].width = ColumnWidth::Proportion(resized);
resized != current
}
Direction::Up if row > 0 => {
resize_rows(&mut self.columns[column], row, row - 1, amount)
}
Direction::Up if row > 0 => resize_rows(&mut self.columns[column], row, row - 1, amount),
Direction::Down if row + 1 < self.columns[column].windows.len() => {
resize_rows(&mut self.columns[column], row, row + 1, amount)
}
@@ -496,9 +422,7 @@ impl ScrollingLayout {
for column in &self.columns {
let requested = match normalized_width(column.width) {
ColumnWidth::Proportion(proportion) => {
((viewport_width + inner) * proportion - inner).max(1.0)
}
ColumnWidth::Proportion(proportion) => ((viewport_width + inner) * proportion - inner).max(1.0),
ColumnWidth::Fixed(width) => width,
ColumnWidth::Full => viewport_width,
};
@@ -526,18 +450,14 @@ impl ScrollingLayout {
self.reveal_pending = None;
self.swipe_focus_pending = None;
} else if let Some((previous, focused)) = self.swipe_focus_pending.take() {
if let (Some((from, _)), Some((to, _))) = (
self.window_location(previous),
self.window_location(focused),
) {
if let (Some((from, _)), Some((to, _))) = (self.window_location(previous), self.window_location(focused)) {
let last_end = column_positions
.last()
.map(|(start, width)| start + width)
.unwrap_or(0.0);
let max_offset = (last_end - viewport_width).max(0.0);
self.viewport_x = (self.viewport_x + column_positions[to].0
- column_positions[from].0)
.clamp(0.0, max_offset);
self.viewport_x =
(self.viewport_x + column_positions[to].0 - column_positions[from].0).clamp(0.0, max_offset);
}
self.reveal_pending = None;
} else {
@@ -554,9 +474,7 @@ impl ScrollingLayout {
let mut result = LayoutResult::default();
for (column_index, column) in self.columns.iter().enumerate() {
let (column_x, column_width) = column_positions[column_index];
let available_height = (viewport_height
- inner * (column.windows.len().saturating_sub(1) as f64))
.max(1.0);
let available_height = (viewport_height - inner * (column.windows.len().saturating_sub(1) as f64)).max(1.0);
let mut y = bounds.y + outer;
for (window_index, window) in column.windows.iter().enumerate() {
@@ -615,12 +533,7 @@ impl ScrollingLayout {
}
}
fn paged_viewport(
&self,
positions: &[(f64, f64)],
viewport_width: f64,
active: Option<usize>,
) -> Option<f64> {
fn paged_viewport(&self, positions: &[(f64, f64)], viewport_width: f64, active: Option<usize>) -> Option<f64> {
if self.focus_strategy != ViewportFocusStrategy::Paged {
return None;
}
@@ -671,14 +584,8 @@ impl ScrollingLayout {
}
}
fn move_horizontally(
&mut self,
window: WindowId,
destination: usize,
) -> Result<(), LayoutError> {
let (source, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
fn move_horizontally(&mut self, window: WindowId, destination: usize) -> Result<(), LayoutError> {
let (source, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
if self.columns[source].windows.len() == 1 {
self.columns.swap(source, destination);
self.active_column = Some(destination);
@@ -686,15 +593,10 @@ impl ScrollingLayout {
return Ok(());
}
let insertion = if destination < source {
source
} else {
source + 1
};
let insertion = if destination < source { source } else { source + 1 };
self.remove(window)?;
let insertion = insertion.min(self.columns.len());
self.columns
.insert(insertion, Column::new(window, self.default_width));
self.columns.insert(insertion, Column::new(window, self.default_width));
self.active_column = Some(insertion);
self.reveal_pending = self.active_window();
Ok(())
@@ -711,31 +613,20 @@ impl ScrollingLayout {
fn validate_scalars(&self) -> Result<(), LayoutError> {
if !self.viewport_x.is_finite() {
return Err(LayoutError::InvalidTree(
"scrolling viewport offset is invalid",
));
return Err(LayoutError::InvalidTree("scrolling viewport offset is invalid"));
}
if normalized_width(self.default_width) != self.default_width {
return Err(LayoutError::InvalidTree(
"scrolling default column width is invalid",
));
return Err(LayoutError::InvalidTree("scrolling default column width is invalid"));
}
if self.columns.is_empty() {
return if self.active_column.is_none() {
Ok(())
} else {
Err(LayoutError::InvalidTree(
"empty scrolling layout has an active column",
))
Err(LayoutError::InvalidTree("empty scrolling layout has an active column"))
};
}
if self
.active_column
.is_none_or(|active| active >= self.columns.len())
{
return Err(LayoutError::InvalidTree(
"scrolling active column is invalid",
));
if self.active_column.is_none_or(|active| active >= self.columns.len()) {
return Err(LayoutError::InvalidTree("scrolling active column is invalid"));
}
Ok(())
}
@@ -746,24 +637,15 @@ impl ScrollingLayout {
let mut windows = HashSet::new();
for column in &self.columns {
if normalized_width(column.width) != column.width {
return Err(LayoutError::InvalidTree(
"scrolling column width is invalid",
));
return Err(LayoutError::InvalidTree("scrolling column width is invalid"));
}
if column.windows.is_empty() {
return Err(LayoutError::InvalidTree("scrolling column is empty"));
}
if column.active >= column.windows.len() || column.heights.len() != column.windows.len()
{
return Err(LayoutError::InvalidTree(
"scrolling column metadata is invalid",
));
if column.active >= column.windows.len() || column.heights.len() != column.windows.len() {
return Err(LayoutError::InvalidTree("scrolling column metadata is invalid"));
}
if !column
.heights
.iter()
.all(|height| height.is_finite() && *height > 0.0)
{
if !column.heights.iter().all(|height| height.is_finite() && *height > 0.0) {
return Err(LayoutError::InvalidTree("scrolling height is invalid"));
}
for window in &column.windows {
@@ -790,9 +672,7 @@ fn resize_rows(column: &mut Column, row: usize, neighbor: usize, amount: f64) ->
fn normalized_width(width: ColumnWidth) -> ColumnWidth {
match width {
ColumnWidth::Proportion(value) if value.is_finite() && value > 0.0 => {
ColumnWidth::Proportion(value)
}
ColumnWidth::Proportion(value) if value.is_finite() && value > 0.0 => ColumnWidth::Proportion(value),
ColumnWidth::Fixed(value) if value.is_finite() && value > 0.0 => ColumnWidth::Fixed(value),
ColumnWidth::Full => ColumnWidth::Full,
_ => ColumnWidth::default(),
@@ -800,11 +680,7 @@ fn normalized_width(width: ColumnWidth) -> ColumnWidth {
}
fn finite_nonnegative(value: f64) -> f64 {
if value.is_finite() {
value.max(0.0)
} else {
0.0
}
if value.is_finite() { value.max(0.0) } else { 0.0 }
}
fn apply_maximums(
@@ -866,12 +742,7 @@ mod tests {
layout.insert(window(2), Some(window(1))).unwrap();
assert_eq!(layout.default_width(), ColumnWidth::Full);
assert!(
layout
.columns()
.iter()
.all(|column| column.width == ColumnWidth::Full)
);
assert!(layout.columns().iter().all(|column| column.width == ColumnWidth::Full));
}
#[test]
@@ -920,9 +791,7 @@ mod tests {
fn focus_reveal_scrolls_only_as_far_as_needed() {
let mut layout = ScrollingLayout::default();
for id in 1..=4 {
layout
.insert(window(id), Some(window(id.saturating_sub(1))))
.unwrap();
layout.insert(window(id), Some(window(id.saturating_sub(1)))).unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
let gaps = GapConfig {
@@ -961,14 +830,8 @@ mod tests {
assert_eq!(layout.viewport_x(), 0.0);
assert_eq!(first.geometry, second.geometry);
assert_eq!(
second.geometry[&window(1)],
Rect::new(10.0, 10.0, 485.0, 780.0)
);
assert_eq!(
second.geometry[&window(2)],
Rect::new(505.0, 10.0, 485.0, 780.0)
);
assert_eq!(second.geometry[&window(1)], Rect::new(10.0, 10.0, 485.0, 780.0));
assert_eq!(second.geometry[&window(2)], Rect::new(505.0, 10.0, 485.0, 780.0));
}
#[test]
@@ -1037,13 +900,9 @@ mod tests {
.unwrap();
assert_eq!(layout.viewport_x(), 0.0);
for (previous, focused, expected) in
[(1, 2, 495.0), (2, 3, 990.0), (3, 2, 495.0), (2, 1, 0.0)]
{
for (previous, focused, expected) in [(1, 2, 495.0), (2, 3, 990.0), (3, 2, 495.0), (2, 1, 0.0)] {
layout.focus_and_reveal(window(focused)).unwrap();
layout
.slide_focus_from(window(previous), window(focused))
.unwrap();
layout.slide_focus_from(window(previous), window(focused)).unwrap();
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(focused)))
.unwrap();
@@ -1071,9 +930,7 @@ mod tests {
#[test]
fn paged_focus_preserves_explicit_center_until_focus_changes() {
let (mut layout, bounds, gaps) = paged_layout(4);
layout
.center_window(window(2), bounds, gaps, &HashMap::new())
.unwrap();
layout.center_window(window(2), bounds, gaps, &HashMap::new()).unwrap();
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
@@ -1188,18 +1045,14 @@ mod tests {
};
for id in 1..=3 {
layout
.insert(window(id), (id > 1).then(|| window(id - 1)))
.unwrap();
layout.insert(window(id), (id > 1).then(|| window(id - 1))).unwrap();
layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(id)))
.unwrap();
}
let before = layout.viewport_x();
layout
.resize_window(window(3), Direction::Right, 0.5)
.unwrap();
layout.resize_window(window(3), Direction::Right, 0.5).unwrap();
layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(3)))
.unwrap();
@@ -1345,18 +1198,10 @@ mod tests {
layout.insert(window(2), Some(window(1))).unwrap();
layout.move_into_column(window(2), window(1)).unwrap();
assert!(
layout
.resize_window(window(1), Direction::Right, 0.1)
.unwrap()
);
assert!(layout.resize_window(window(1), Direction::Right, 0.1).unwrap());
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.6));
assert!(
layout
.resize_window(window(1), Direction::Down, 0.1)
.unwrap()
);
assert!(layout.resize_window(window(1), Direction::Down, 0.1).unwrap());
assert_eq!(layout.columns()[0].heights, vec![0.6, 0.4]);
}
@@ -1371,10 +1216,7 @@ mod tests {
];
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
assert_eq!(
layout.columns()[0].width,
ColumnWidth::Proportion(2.0 / 3.0)
);
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(2.0 / 3.0));
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
assert_eq!(layout.columns()[0].width, ColumnWidth::Full);
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
@@ -1459,10 +1301,7 @@ mod tests {
let mut layout = ScrollingLayout::default();
for id in 1_u64..=4 {
layout
.insert(
window(id),
id.checked_sub(1).filter(|id| *id > 0).map(window),
)
.insert(window(id), id.checked_sub(1).filter(|id| *id > 0).map(window))
.unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
@@ -1475,11 +1314,7 @@ mod tests {
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4)))
.unwrap();
assert!(
layout
.center_window(window(2), bounds, gaps, &HashMap::new())
.unwrap()
);
assert!(layout.center_window(window(2), bounds, gaps, &HashMap::new()).unwrap());
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
@@ -1518,9 +1353,7 @@ mod tests {
if first == second {
second = (second + 1) % windows.len();
}
layout
.move_into_column(windows[first], windows[second])
.unwrap();
layout.move_into_column(windows[first], windows[second]).unwrap();
}
3 if !windows.is_empty() => {
let index = random as usize % windows.len();
@@ -1534,9 +1367,7 @@ mod tests {
5 if !windows.is_empty() => {
let index = random as usize % windows.len();
let direction = random_direction(random.rotate_left(23));
layout
.resize_window(windows[index], direction, 0.05)
.unwrap();
layout.resize_window(windows[index], direction, 0.05).unwrap();
}
6 if !windows.is_empty() => {
let index = random as usize % windows.len();
@@ -1546,10 +1377,7 @@ mod tests {
}
layout.validate().unwrap();
assert_eq!(
layout.window_ids().collect::<HashSet<_>>().len(),
windows.len()
);
assert_eq!(layout.window_ids().collect::<HashSet<_>>().len(), windows.len());
}
}
+4 -12
View File
@@ -195,15 +195,11 @@ pub mod window_capture {
use wayland_client::protocol::__interfaces::*;
use wayland_protocols_wlr::screencopy::v1::client::__interfaces::*;
wayland_scanner::generate_interfaces!(
"../../protocols/ferese-window-capture-v1.xml"
);
wayland_scanner::generate_interfaces!("../../protocols/ferese-window-capture-v1.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!(
"../../protocols/ferese-window-capture-v1.xml"
);
wayland_scanner::generate_client_code!("../../protocols/ferese-window-capture-v1.xml");
}
#[cfg(feature = "server")]
@@ -216,15 +212,11 @@ pub mod window_capture {
use wayland_protocols_wlr::screencopy::v1::server::__interfaces::*;
use wayland_server::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!(
"../../protocols/ferese-window-capture-v1.xml"
);
wayland_scanner::generate_interfaces!("../../protocols/ferese-window-capture-v1.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_server_code!(
"../../protocols/ferese-window-capture-v1.xml"
);
wayland_scanner::generate_server_code!("../../protocols/ferese-window-capture-v1.xml");
}
}
}
+5 -10
View File
@@ -1,9 +1,8 @@
use cosmic::iced::{Background, Color};
use cosmic::theme;
use cosmic::widget::button;
use crate::{composite, foreground};
use cosmic::{
iced::{Background, Color},
theme,
widget::button,
};
#[derive(Clone, Copy)]
pub(crate) enum State {
@@ -13,11 +12,7 @@ pub(crate) enum State {
Disabled,
}
pub(crate) fn accent_button_style(
theme: &cosmic::Theme,
state: State,
focused: bool,
) -> button::Style {
pub(crate) fn accent_button_style(theme: &cosmic::Theme, state: State, focused: bool) -> button::Style {
let native = theme.cosmic();
let component = &native.accent_button;
let fill: Color = match state {
+19 -52
View File
@@ -1,10 +1,7 @@
//! Date/time labels and a calendar grid; callers own navigation and popup behavior.
use cosmic::{
Element,
iced::{Alignment, Background, Border, Length},
theme,
widget::{column, container, row},
};
use cosmic::iced::{Alignment, Background, Border, Length};
use cosmic::widget::{column, container, row};
use cosmic::{Element, theme};
use jiff::Zoned;
pub fn format_bar_time(now: &Zoned) -> String {
@@ -41,10 +38,7 @@ pub fn stacked_digits(label: &str) -> Option<[String; 2]> {
let (hour, minute) = time.split_once(':')?;
if !(1..=2).contains(&hour.len())
|| minute.len() != 2
|| !hour
.bytes()
.chain(minute.bytes())
.all(|digit| digit.is_ascii_digit())
|| !hour.bytes().chain(minute.bytes()).all(|digit| digit.is_ascii_digit())
{
return None;
}
@@ -104,11 +98,7 @@ pub fn grid<'a, M: Clone + 'a>(
let mut weekdays = row::with_capacity(7).spacing(3);
for label in ["M", "T", "W", "T", "F", "S", "S"] {
weekdays = weekdays.push(
container(
crate::text(label, font)
.size(11)
.class(theme::Text::Color(muted)),
)
container(crate::text(label, font).size(11).class(theme::Text::Color(muted)))
.width(30)
.center_x(30),
);
@@ -118,26 +108,21 @@ pub fn grid<'a, M: Clone + 'a>(
let mut dates = row::with_capacity(7).spacing(3);
for weekday in 0..7 {
let cell = week * 7 + weekday;
let day = cell
.checked_sub(start)
.map(|day| day + 1)
.filter(|day| *day <= days);
let is_today =
year == today.year() && month == today.month() && day == Some(today.day() as usize);
let day = cell.checked_sub(start).map(|day| day + 1).filter(|day| *day <= days);
let is_today = year == today.year() && month == today.month() && day == Some(today.day() as usize);
let label = day.map_or(String::new(), |day| day.to_string());
dates = dates.push(
container(
crate::text(label, font)
.size(12)
.class(theme::Text::Color(if is_today { primary } else { muted })),
)
container(crate::text(label, font).size(12).class(theme::Text::Color(if is_today {
primary
} else {
muted
})))
.width(30)
.height(25)
.center_x(30)
.center_y(25)
.class(theme::Container::custom(move |_| container::Style {
background: is_today
.then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))),
background: is_today.then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))),
border: Border {
radius: palette.radius.min(12.).into(),
..Default::default()
@@ -155,14 +140,8 @@ pub fn grid<'a, M: Clone + 'a>(
mod tests {
#[test]
fn pixel_clock_stacks_only_hour_and_minute_formats() {
assert_eq!(
super::stacked_digits("2:45 pm"),
Some(["02".into(), "45".into()])
);
assert_eq!(
super::stacked_digits("14:45"),
Some(["14".into(), "45".into()])
);
assert_eq!(super::stacked_digits("2:45 pm"), Some(["02".into(), "45".into()]));
assert_eq!(super::stacked_digits("14:45"), Some(["14".into(), "45".into()]));
assert!(super::stacked_digits("14:45:30").is_none());
assert!(super::stacked_digits("Today 14:45").is_none());
assert!(super::stacked_digits("14:45 pm extra").is_none());
@@ -171,18 +150,9 @@ mod tests {
#[test]
fn clock_uses_lowercase_date_and_twelve_hour_time() {
for (stamp, expected) in [
(
"2026-09-28T21:32:00+01:00[Africa/Lagos]",
"28 sept, 9:32 pm",
),
(
"2026-09-28T00:05:00+01:00[Africa/Lagos]",
"28 sept, 12:05 am",
),
(
"2026-09-28T12:00:00+01:00[Africa/Lagos]",
"28 sept, 12:00 pm",
),
("2026-09-28T21:32:00+01:00[Africa/Lagos]", "28 sept, 9:32 pm"),
("2026-09-28T00:05:00+01:00[Africa/Lagos]", "28 sept, 12:05 am"),
("2026-09-28T12:00:00+01:00[Africa/Lagos]", "28 sept, 12:00 pm"),
] {
assert_eq!(super::format_bar_time(&stamp.parse().unwrap()), expected);
}
@@ -194,10 +164,7 @@ mod tests {
let (first, start, days) = super::month(february, 0);
assert_eq!((first.year(), first.month(), start, days), (2024, 2, 3, 29));
let (first, start, days) = super::month(february, -2);
assert_eq!(
(first.year(), first.month(), start, days),
(2023, 12, 4, 31)
);
assert_eq!((first.year(), first.month(), start, days), (2023, 12, 4, 31));
let june = jiff::civil::Date::new(2025, 6, 1).unwrap();
let (_, start, days) = super::month(june, 0);
assert_eq!((start, days), (6, 30));
+11 -42
View File
@@ -76,27 +76,12 @@ pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
let surface: Color = native.primary(false).base.into();
for component in [&mut native.accent, &mut native.accent_button] {
let (base, on) = accent_pair(composite(component.base.into(), surface), preferred);
component.hover = rgba(readable_fill(
composite(component.hover.into(), surface),
base,
on,
));
component.pressed = rgba(readable_fill(
composite(component.pressed.into(), surface),
base,
on,
));
component.selected = rgba(readable_fill(
composite(component.selected.into(), surface),
base,
on,
));
component.hover = rgba(readable_fill(composite(component.hover.into(), surface), base, on));
component.pressed = rgba(readable_fill(composite(component.pressed.into(), surface), base, on));
component.selected = rgba(readable_fill(composite(component.selected.into(), surface), base, on));
component.base = rgba(base);
component.on = rgba(on);
component.selected_text = rgba(foreground(
composite(component.selected.into(), surface),
on,
));
component.selected_text = rgba(foreground(composite(component.selected.into(), surface), on));
component.disabled = rgba(composite(Color { a: 0.5, ..base }, surface));
component.on_disabled = rgba(foreground(composite(Color { a: 0.5, ..base }, surface), on));
}
@@ -125,9 +110,10 @@ fn rgba(c: Color) -> cosmic::cosmic_theme::palette::Srgba {
#[cfg(test)]
mod tests {
use cosmic::iced::Background;
use super::*;
use crate::button::{State, accent_button_style};
use cosmic::iced::Background;
#[test]
fn reference_contrast_values_use_linear_srgb() {
assert!((contrast(Color::BLACK, Color::WHITE) - 21.).abs() < 0.00001);
@@ -139,10 +125,7 @@ mod tests {
let preferred = Color::from_rgb8(240, 242, 246);
assert_eq!(foreground(Color::BLACK, preferred), preferred);
assert_eq!(foreground(Color::WHITE, preferred), Color::BLACK);
assert_eq!(
foreground(Color::from_rgb8(61, 123, 230), preferred),
Color::BLACK
);
assert_eq!(foreground(Color::from_rgb8(61, 123, 230), preferred), Color::BLACK);
}
#[test]
@@ -170,12 +153,7 @@ mod tests {
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
for state in [State::Active, State::Hovered, State::Pressed, State::Disabled] {
let actual = accent_button_style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
assert_eq!(actual.icon_color, Some(preferred));
@@ -223,20 +201,11 @@ mod tests {
for fill in [component.base, component.hover, component.pressed] {
assert!(contrast(fill.into(), component.on.into()) >= 4.5);
}
assert!(
contrast(component.selected.into(), component.selected_text.into()) >= 4.5
);
assert!(
contrast(component.disabled.into(), component.on_disabled.into()) >= 4.5
);
assert!(contrast(component.selected.into(), component.selected_text.into()) >= 4.5);
assert!(contrast(component.disabled.into(), component.on_disabled.into()) >= 4.5);
}
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
for state in [State::Active, State::Hovered, State::Pressed, State::Disabled] {
let style = accent_button_style(&theme, state, false);
let Some(Background::Color(fill)) = style.background else {
panic!("No fill")
+8 -24
View File
@@ -1,9 +1,8 @@
use cosmic::iced::{Background, Border, Color, Vector};
use cosmic::widget::{button, container};
use cosmic::{theme, widget};
use crate::{Palette, mix};
use cosmic::{
iced::{Background, Border, Color, Vector},
theme,
widget::{self, button, container},
};
pub fn surface(background: Color, radius: f32) -> theme::Container<'static> {
theme::Container::custom(move |_| surface_appearance(background, radius))
@@ -85,11 +84,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
icon_color: Some(on),
border_radius: p.radius.min(9.).into(),
border_width: if selected { 1. } else { 0. },
border_color: if selected {
p.accent
} else {
Color::TRANSPARENT
},
border_color: if selected { p.accent } else { Color::TRANSPARENT },
outline_width: 0.,
outline_color: Color::TRANSPARENT,
overlay: None,
@@ -136,11 +131,7 @@ pub fn authentication_input(palette: Palette) -> theme::TextInput {
}
}
pub fn lock_input(
radius: f32,
accent: cosmic::iced::Color,
surface: cosmic::iced::Color,
) -> theme::TextInput {
pub fn lock_input(radius: f32, accent: cosmic::iced::Color, surface: cosmic::iced::Color) -> theme::TextInput {
let appearance = move |focused: bool| widget::text_input::Appearance {
background: cosmic::iced::Color::from_rgba(
(surface.r + 1.) * 0.5,
@@ -190,9 +181,7 @@ pub fn shell_button(
a: if pressed {
0.20 * opacity
} else {
((if selected { 0.14 } else { 0.0 })
+ progress * if selected { 0.02 } else { 0.08 })
* opacity
((if selected { 0.14 } else { 0.0 }) + progress * if selected { 0.02 } else { 0.08 }) * opacity
},
..foreground
})),
@@ -211,12 +200,7 @@ pub fn surface_appearance(background: Color, radius: f32) -> container::Style {
}
}
pub fn notification_button(
foreground: Color,
hover: Color,
radius: f32,
filled: bool,
) -> theme::Button {
pub fn notification_button(foreground: Color, hover: Color, radius: f32, filled: bool) -> theme::Button {
let style = move |active: bool| button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
+7 -13
View File
@@ -1,9 +1,7 @@
//! Canonical SVG assets, sizing and semantic tint rules for native Ferese UIs.
use cosmic::{
iced::{Color, ContentFit, Length},
theme,
widget::icon,
};
use cosmic::iced::{Color, ContentFit, Length};
use cosmic::theme;
use cosmic::widget::icon;
pub const ARROW: &[u8] = include_bytes!("../assets/icons/arrow.svg");
pub const BATTERY: &[u8] = include_bytes!("../assets/icons/battery.svg");
@@ -18,8 +16,7 @@ pub const BATTERY_75: &[u8] = include_bytes!("../assets/icons/status/battery-75.
pub const BATTERY_CHARGING: &[u8] = include_bytes!("../assets/icons/status/battery-charging.svg");
pub const BATTERY_EMPTY: &[u8] = include_bytes!("../assets/icons/status/battery-empty.svg");
pub const BATTERY_FULL: &[u8] = include_bytes!("../assets/icons/status/battery-full.svg");
pub const BLUETOOTH_CONNECTED: &[u8] =
include_bytes!("../assets/icons/status/bluetooth-connected.svg");
pub const BLUETOOTH_CONNECTED: &[u8] = include_bytes!("../assets/icons/status/bluetooth-connected.svg");
pub const BLUETOOTH_OFF: &[u8] = include_bytes!("../assets/icons/status/bluetooth-off.svg");
pub const BLUETOOTH_ON: &[u8] = include_bytes!("../assets/icons/status/bluetooth-on.svg");
pub const BRIGHTNESS: &[u8] = include_bytes!("../assets/icons/status/brightness.svg");
@@ -67,10 +64,7 @@ pub fn tinted(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon
}
pub fn symbolic(source: &'static [u8], size: u16) -> icon::Icon {
icon::from_svg_bytes(source)
.symbolic(true)
.icon()
.size(size)
icon::from_svg_bytes(source).symbolic(true).icon().size(size)
}
pub fn accented(source: &'static [u8], size: u16, foreground: Color, accent: Color) -> icon::Icon {
@@ -88,8 +82,8 @@ pub fn accented(source: &'static [u8], size: u16, foreground: Color, accent: Col
.size(size)
.width(Length::Fixed(f32::from(size) * aspect))
.content_fit(ContentFit::Contain)
.class(theme::Svg::custom(move |_| {
cosmic::iced::widget::svg::Style { color: None }
.class(theme::Svg::custom(move |_| cosmic::iced::widget::svg::Style {
color: None,
}))
}
+9 -17
View File
@@ -1,14 +1,10 @@
use std::sync::Arc;
use ferese_protocols::material::v1::client::{
ferese_material_manager_v1::FereseMaterialManagerV1,
ferese_surface_material_v1::FereseSurfaceMaterialV1,
};
use wayland_client::{
Connection, Dispatch, Proxy, QueueHandle, delegate_noop,
globals::{GlobalListContents, registry_queue_init},
protocol::{wl_registry, wl_surface},
};
use ferese_protocols::material::v1::client::ferese_material_manager_v1::FereseMaterialManagerV1;
use ferese_protocols::material::v1::client::ferese_surface_material_v1::FereseSurfaceMaterialV1;
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::{wl_registry, wl_surface};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
/// Keeps a compositor-owned dialog material attached for the window lifetime.
#[derive(Clone, Debug)]
@@ -37,9 +33,8 @@ impl ModalMaterial {
return Err("Dialog materials require Wayland".into());
};
// Iced lends live native handles; this backend borrows its connection.
let backend = unsafe {
wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast())
};
let backend =
unsafe { wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) };
let connection = Connection::from_backend(backend);
let id = unsafe {
wayland_client::backend::ObjectId::from_ptr(
@@ -48,8 +43,7 @@ impl ModalMaterial {
)
}
.map_err(|error| error.to_string())?;
let surface =
wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
let (globals, mut queue) =
registry_queue_init::<MaterialState>(&connection).map_err(|error| error.to_string())?;
let qh = queue.handle();
@@ -63,9 +57,7 @@ impl ModalMaterial {
material,
};
let mut state = MaterialState::default();
queue
.roundtrip(&mut state)
.map_err(|error| error.to_string())?;
queue.roundtrip(&mut state).map_err(|error| error.to_string())?;
if !state.ready {
return Err("Dialog material attachment was not acknowledged".into());
}
+8 -29
View File
@@ -1,9 +1,7 @@
//! Shared menu layouts and paint; callers own actions and animation timing.
use cosmic::{
Element,
iced::{Alignment, Color, Length},
widget::{button, container, row as iced_row},
};
use cosmic::Element;
use cosmic::iced::{Alignment, Color, Length};
use cosmic::widget::{button, container, row as iced_row};
pub fn button<'a, M: Clone + 'a>(
label: impl Into<std::borrow::Cow<'a, str>> + 'a,
@@ -20,10 +18,7 @@ pub fn row<'a, M: 'a>(
trailing: Element<'a, M>,
font: cosmic::font::Font,
) -> Element<'a, M> {
iced_row![
crate::text(label, font).width(Length::Fill).size(14),
trailing
]
iced_row![crate::text(label, font).width(Length::Fill).size(14), trailing]
.align_y(Alignment::Center)
.into()
}
@@ -38,12 +33,7 @@ pub fn heading<'a, M: 'a>(
.align_y(Alignment::Center)
}
pub fn badge<'a, M: 'a>(
content: Element<'a, M>,
accent: Color,
opacity: f32,
radius: f32,
) -> Element<'a, M> {
pub fn badge<'a, M: 'a>(content: Element<'a, M>, accent: Color, opacity: f32, radius: f32) -> Element<'a, M> {
container(content)
.width(36)
.height(36)
@@ -56,12 +46,7 @@ pub fn badge<'a, M: 'a>(
.into()
}
pub fn section<'a, M: 'a>(
content: Element<'a, M>,
foreground: Color,
opacity: f32,
radius: f32,
) -> Element<'a, M> {
pub fn section<'a, M: 'a>(content: Element<'a, M>, foreground: Color, opacity: f32, radius: f32) -> Element<'a, M> {
container(content)
.padding(10)
.width(Length::Fill)
@@ -87,10 +72,7 @@ pub fn section_label<'a>(
pub fn separator<'a, M: 'a>(foreground: Color, opacity: f32) -> Element<'a, M> {
container(cosmic::widget::Space::new().width(Length::Fill).height(1))
.class(crate::controls::surface(
foreground.scale_alpha(0.12 * opacity),
0.,
))
.class(crate::controls::surface(foreground.scale_alpha(0.12 * opacity), 0.))
.into()
}
@@ -112,10 +94,7 @@ pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -
.width(16)
.height(16)
.class(crate::controls::surface(
Color {
a: opacity,
..thumb
},
Color { a: opacity, ..thumb },
palette.radius.min(8.),
));
container(knob)
+3 -9
View File
@@ -102,11 +102,7 @@ pub fn parse_color(value: &str) -> Option<Color> {
}
pub fn mix(a: Color, b: Color, t: f32) -> Color {
Color::from_rgb(
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t,
)
Color::from_rgb(a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t)
}
pub fn surface_shade(base: Color) -> Color {
@@ -179,10 +175,8 @@ mod tests {
("translucent", 2., 1.),
("translucent", -1., 0.),
] {
let document = Document::parse(&format!(
"theme {{ material {{ style {style}; opacity {opacity}; }} }}"
))
.unwrap();
let document =
Document::parse(&format!("theme {{ material {{ style {style}; opacity {opacity}; }} }}")).unwrap();
assert_eq!(material_opacity(Some(&document)), expected);
}
}
+2 -4
View File
@@ -1,7 +1,5 @@
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
pub fn font(family: Option<&str>) -> cosmic::font::Font {
let Some(family) = family else {
+11 -24
View File
@@ -1,11 +1,9 @@
use std::{
collections::BTreeMap,
env, fs,
io::Read,
os::unix::process::CommandExt,
path::{Path, PathBuf},
process::{Command, Stdio},
};
use std::collections::BTreeMap;
use std::io::Read;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::{env, fs};
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
for key in ["WAYLAND_SOCKET", "FERESE_SHELL_CONTROL_SOCKET"] {
@@ -35,20 +33,14 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
if system_directories.is_empty() {
system_directories.push(PathBuf::from("/etc/xdg"));
}
let directories = std::iter::once(home)
.chain(system_directories)
.collect::<Vec<_>>();
let directories = std::iter::once(home).chain(system_directories).collect::<Vec<_>>();
let mut entries = BTreeMap::new();
for directory in directories {
let Ok(files) = fs::read_dir(directory.join("autostart")) else {
continue;
};
for file in files.flatten() {
if file
.path()
.extension()
.is_some_and(|extension| extension == "desktop")
{
if file.path().extension().is_some_and(|extension| extension == "desktop") {
entries.entry(file.file_name()).or_insert(file.path());
}
}
@@ -108,9 +100,7 @@ fn read_entry(path: &Path) -> Option<BTreeMap<String, String>> {
return None;
}
let mut source = String::new();
file.take(1024 * 1024 + 1)
.read_to_string(&mut source)
.ok()?;
file.take(1024 * 1024 + 1).read_to_string(&mut source).ok()?;
if source.len() > 1024 * 1024 {
return None;
}
@@ -152,15 +142,12 @@ fn enabled(entry: &BTreeMap<String, String>, desktop: &str) -> bool {
fn available(program: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
let executable = |path: &Path| {
fs::metadata(path)
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
};
if program.contains('/') {
executable(Path::new(program))
} else {
env::var_os("PATH").is_some_and(|paths| {
env::split_paths(&paths).any(|path| executable(&path.join(program)))
})
env::var_os("PATH").is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
}
}
+8 -17
View File
@@ -1,11 +1,10 @@
mod autostart;
use std::env;
use std::error::Error;
use std::fs;
use std::io::{self, Write};
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::{env, fs, io};
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
use serde_json::{Value, json};
@@ -101,10 +100,9 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
));
}
},
"toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout"
| "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel"
| "close" | "get-focused-window" | "get-windows" | "get-workspaces" | "get-outputs"
| "reload-config" | "exit" | "request-logout" => {
"toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout" | "toggle-overview"
| "cycle-column-width" | "center-column" | "consume" | "expel" | "close" | "get-focused-window"
| "get-windows" | "get-workspaces" | "get-outputs" | "reload-config" | "exit" | "request-logout" => {
if !positional.is_empty() {
return Err(format!("{command} does not accept arguments"));
}
@@ -152,15 +150,11 @@ mod tests {
#[test]
fn parses_direction_and_workspace_commands() {
assert_eq!(
parse_args(["focus".to_owned(), "left".to_owned()])
.unwrap()
.1,
parse_args(["focus".to_owned(), "left".to_owned()]).unwrap().1,
json!({ "direction": "left" })
);
assert_eq!(
parse_args(["workspace".to_owned(), "7".to_owned()])
.unwrap()
.1,
parse_args(["workspace".to_owned(), "7".to_owned()]).unwrap().1,
json!({ "index": 7 })
);
assert_eq!(
@@ -203,10 +197,7 @@ mod tests {
#[test]
fn rejects_unknown_or_missing_arguments() {
assert_eq!(
parse_args(["exit".to_owned()]).unwrap(),
("exit".to_owned(), json!({}))
);
assert_eq!(parse_args(["exit".to_owned()]).unwrap(), ("exit".to_owned(), json!({})));
assert!(parse_args(["exit".to_owned(), "extra".to_owned()]).is_err());
assert!(parse_args(["focus".to_owned()]).is_err());
assert!(parse_args(["reload-config".to_owned()]).is_ok());
+55 -151
View File
@@ -1,26 +1,22 @@
//! ScreenCast portal backend. Only the portal frontend may create sessions;
//! saved monitor consent is restored only through the frontend permission store.
use crate::{
capture::{Capture, Source},
picker::Prompt,
stream::Ready,
};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::{collections::HashMap, process::Stdio, time::Duration};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::{Child, Command},
sync::{Mutex, Notify},
};
use zbus::{
Connection,
message::Header,
object_server::SignalEmitter,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
use std::collections::HashMap;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{Mutex, Notify};
use zbus::Connection;
use zbus::message::Header;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
use crate::capture::{Capture, Source};
use crate::picker::Prompt;
use crate::stream::Ready;
pub(crate) type Options = HashMap<String, OwnedValue>;
pub(crate) static CONFIG_TRANSACTION: std::sync::Mutex<()> = std::sync::Mutex::new(());
@@ -105,18 +101,12 @@ impl RecorderControl {
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
let sender = header
.sender()
.ok_or_else(|| error("Missing recorder caller"))?;
let sender = header.sender().ok_or_else(|| error("Missing recorder caller"))?;
if !owns_session(sender.as_str(), session_handle.as_str()) {
return Err(zbus::fdo::Error::AccessDenied(
"Not your recording session".into(),
));
return Err(zbus::fdo::Error::AccessDenied("Not your recording session".into()));
}
let dbus = zbus::fdo::DBusProxy::new(connection).await?;
let pid = dbus
.get_connection_unix_process_id(sender.clone().into())
.await?;
let pid = dbus.get_connection_unix_process_id(sender.clone().into()).await?;
if !shell_recorder(pid) {
return Err(zbus::fdo::Error::AccessDenied(
"The shell must own the recording controls".into(),
@@ -155,18 +145,14 @@ fn shell_recorder(pid: u32) -> bool {
let parent = std::fs::read_to_string(format!("/proc/{pid}/status"))
.ok()
.and_then(|status| {
status.lines().find_map(|line| {
line.strip_prefix("PPid:")
.and_then(|s| s.trim().parse::<u32>().ok())
})
status
.lines()
.find_map(|line| line.strip_prefix("PPid:").and_then(|s| s.trim().parse::<u32>().ok()))
});
parent.is_some_and(|pid| matches_binary(pid, "ferese-shell") == Some(true))
}
pub(crate) async fn authorize(
connection: &Connection,
header: &Header<'_>,
) -> zbus::fdo::Result<String> {
pub(crate) async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result<String> {
let sender = header
.sender()
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing caller".into()))?;
@@ -176,9 +162,7 @@ pub(crate) async fn authorize(
.await
.map_err(|_| zbus::fdo::Error::AccessDenied("Desktop portal is unavailable".into()))?;
if sender.as_str() != owner.as_str() {
return Err(zbus::fdo::Error::AccessDenied(
"Use the desktop portal".into(),
));
return Err(zbus::fdo::Error::AccessDenied("Use the desktop portal".into()));
}
Ok(sender.to_string())
@@ -227,10 +211,7 @@ impl Backend {
let conn = connection.clone();
let path = path.to_owned();
tokio::spawn(async move {
let _ = conn
.object_server()
.remove::<SessionObject, _>(path.as_str())
.await;
let _ = conn.object_server().remove::<SessionObject, _>(path.as_str()).await;
});
}
}
@@ -319,21 +300,14 @@ impl Backend {
) -> zbus::fdo::Result<(u32, Options)> {
let owner = authorize(connection, &header).await?;
let settings = source_options(&options).and_then(|(multiple, cursor, types)| {
Ok((
multiple,
cursor,
types,
crate::restore::persist_mode(&options)?,
))
Ok((multiple, cursor, types, crate::restore::persist_mode(&options)?))
});
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?;
if session.owner != owner
|| session.app != app_id
|| !matches!(session.phase, Phase::Created | Phase::Selected)
if session.owner != owner || session.app != app_id || !matches!(session.phase, Phase::Created | Phase::Selected)
{
return Err(error("Invalid session state"));
}
@@ -369,10 +343,7 @@ impl Backend {
) -> zbus::fdo::Result<(u32, Options)> {
let owner = authorize(connection, &header).await?;
if !handle
.as_str()
.starts_with("/org/freedesktop/portal/desktop/request/")
{
if !handle.as_str().starts_with("/org/freedesktop/portal/desktop/request/") {
return Err(error("Invalid request path"));
}
@@ -435,10 +406,7 @@ impl Backend {
result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into())))
}
};
let _ = connection
.object_server()
.remove::<Request, _>(handle)
.await;
let _ = connection.object_server().remove::<Request, _>(handle).await;
match result {
Ok((children, streams, mut reply)) if !cancel.stopped.load(Ordering::SeqCst) => {
@@ -455,8 +423,7 @@ impl Backend {
});
reply.insert(
"streams".into(),
OwnedValue::try_from(Value::from(streams))
.map_err(|e| error(&e.to_string()))?,
OwnedValue::try_from(Value::from(streams)).map_err(|e| error(&e.to_string()))?,
);
Ok((0, reply))
@@ -593,8 +560,7 @@ async fn start_streams(
let rememberable = sources
.iter()
.filter(|source| {
crate::restore::Restore::create(app, cursor, std::slice::from_ref(*source), &outputs, 1)
.is_some()
crate::restore::Restore::create(app, cursor, std::slice::from_ref(*source), &outputs, 1).is_some()
})
.map(|source| source.name.clone())
.collect::<Vec<_>>();
@@ -602,10 +568,7 @@ async fn start_streams(
let selected = restore.resolve(app, cursor, multiple, &sources, &outputs)?;
let selected = validate_selection(
&sources,
&selected
.iter()
.map(|source| source.name.clone())
.collect::<Vec<_>>(),
&selected.iter().map(|source| source.name.clone()).collect::<Vec<_>>(),
multiple,
)
.ok()?;
@@ -707,15 +670,11 @@ async fn start_streams(
.map_err(|e| e.to_string())?;
let mut line = String::new();
let output = child.stdout.take().unwrap();
tokio::time::timeout(
Duration::from_secs(10),
BufReader::new(output).read_line(&mut line),
)
tokio::time::timeout(Duration::from_secs(10), BufReader::new(output).read_line(&mut line))
.await
.map_err(|_| "PipeWire stream startup timed out")?
.map_err(|e| e.to_string())?;
let ready: Ready =
serde_json::from_str(&line).map_err(|_| "PipeWire stream could not start")?;
let ready: Ready = serde_json::from_str(&line).map_err(|_| "PipeWire stream could not start")?;
if ready.node == u32::MAX {
return Err("Invalid PipeWire node".into());
@@ -734,10 +693,7 @@ async fn start_streams(
return Err("A selected display changed during stream startup".into());
}
if children
.iter_mut()
.any(|child| !matches!(child.try_wait(), Ok(None)))
{
if children.iter_mut().any(|child| !matches!(child.try_wait(), Ok(None))) {
return Err("A selected stream ended during startup".into());
}
@@ -751,11 +707,10 @@ async fn start_streams(
.find(|source| source.name == name)
.ok_or("Unknown window stream")?;
let id = source.window_id().ok_or("Invalid selected window")?;
if !final_windows.as_array().is_some_and(|windows| {
windows
.iter()
.any(|window| window["id"].as_u64() == Some(id))
}) {
if !final_windows
.as_array()
.is_some_and(|windows| windows.iter().any(|window| window["id"].as_u64() == Some(id)))
{
return Err("A selected window closed during startup".to_owned());
}
window_metadata(source, &ready)
@@ -773,9 +728,7 @@ fn window_metadata(source: &Source, ready: &Ready) -> Result<Options, String> {
if source.window_id().is_none() || ready.width == 0 || ready.height == 0 {
return Err("Invalid window stream".into());
}
let (width, height) = ready
.logical_size
.ok_or("Missing captured window geometry")?;
let (width, height) = ready.logical_size.ok_or("Missing captured window geometry")?;
let size = (
i32::try_from(width).map_err(|_| "Invalid window width")?,
i32::try_from(height).map_err(|_| "Invalid window height")?,
@@ -787,25 +740,15 @@ fn window_metadata(source: &Source, ready: &Ready) -> Result<Options, String> {
("source_type".into(), 2u32.into()),
(
"size".into(),
Value::from(size)
.try_to_owned()
.map_err(|e| e.to_string())?,
Value::from(size).try_to_owned().map_err(|e| e.to_string())?,
),
]))
}
fn monitor_metadata(
outputs: &serde_json::Value,
name: &str,
ready: &Ready,
) -> Result<Options, String> {
fn monitor_metadata(outputs: &serde_json::Value, name: &str, ready: &Ready) -> Result<Options, String> {
let output = outputs
.as_array()
.and_then(|outputs| {
outputs
.iter()
.find(|output| output["name"].as_str() == Some(name))
})
.and_then(|outputs| outputs.iter().find(|output| output["name"].as_str() == Some(name)))
.ok_or("Selected monitor is unavailable")?;
let integer = |key: &str| {
output[key]
@@ -851,23 +794,13 @@ fn monitor_metadata(
),
(
"size".into(),
Value::from(size)
.try_to_owned()
.map_err(|error| error.to_string())?,
Value::from(size).try_to_owned().map_err(|error| error.to_string())?,
),
]))
}
fn validate_selection(
sources: &[Source],
names: &[String],
multiple: bool,
) -> Result<Vec<Source>, String> {
if names.is_empty()
|| names.len() > 8
|| names.len() > sources.len()
|| (!multiple && names.len() != 1)
{
fn validate_selection(sources: &[Source], names: &[String], multiple: bool) -> Result<Vec<Source>, String> {
if names.is_empty() || names.len() > 8 || names.len() > sources.len() || (!multiple && names.len() != 1) {
return Err("Invalid display selection".into());
}
@@ -890,11 +823,7 @@ fn validate_selection(
async fn supervise(mut children: Vec<Child>, cancel: Arc<Cancel>) {
loop {
if cancel.stopped.load(Ordering::SeqCst)
|| children
.iter_mut()
.any(|c| !matches!(c.try_wait(), Ok(None)))
{
if cancel.stopped.load(Ordering::SeqCst) || children.iter_mut().any(|c| !matches!(c.try_wait(), Ok(None))) {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -928,10 +857,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
.serve_at(PATH, shortcuts.clone())?
.serve_at(PATH, inhibit.clone())?
.serve_at(PATH, input_capture.clone())?
.serve_at(
"/org/ferese/ScreenRecorder",
RecorderControl(backend.clone()),
)?
.serve_at("/org/ferese/ScreenRecorder", RecorderControl(backend.clone()))?
.build()
.await?;
@@ -955,9 +881,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
requests.revoke_stale(owner.as_deref()).await;
shortcuts.revoke_stale(&connection, owner.as_deref()).await;
inhibit.revoke_stale(&connection, owner.as_deref()).await;
input_capture
.revoke_stale(&connection, owner.as_deref())
.await;
input_capture.revoke_stale(&connection, owner.as_deref()).await;
let stale: Vec<_> = backend
.sessions
@@ -1057,10 +981,7 @@ mod tests {
scale: 1,
})
.collect::<Vec<_>>();
let names = sources
.iter()
.map(|source| source.name.clone())
.collect::<Vec<_>>();
let names = sources.iter().map(|source| source.name.clone()).collect::<Vec<_>>();
assert!(validate_selection(&sources, &names[..8], true).is_ok());
assert!(validate_selection(&sources, &names, true).is_err());
assert!(validate_selection(&sources, &names[..2], false).is_err());
@@ -1117,10 +1038,7 @@ mod tests {
scale: 1,
})
.collect::<Vec<_>>();
let names = sources
.iter()
.map(|source| source.name.clone())
.collect::<Vec<_>>();
let names = sources.iter().map(|source| source.name.clone()).collect::<Vec<_>>();
assert_eq!(validate_selection(&sources, &names, true).unwrap().len(), 6);
assert!(validate_selection(&sources, &names, false).is_err());
}
@@ -1137,22 +1055,8 @@ mod tests {
scale: 1,
};
assert!(validate_selection(std::slice::from_ref(&source), &[], false).is_err());
assert!(
validate_selection(std::slice::from_ref(&source), &["other".into()], false).is_err()
);
assert!(
validate_selection(
std::slice::from_ref(&source),
&["test".into(), "test".into()],
true
)
.is_err()
);
assert_eq!(
validate_selection(&[source], &["test".into()], false)
.unwrap()
.len(),
1
);
assert!(validate_selection(std::slice::from_ref(&source), &["other".into()], false).is_err());
assert!(validate_selection(std::slice::from_ref(&source), &["test".into(), "test".into()], true).is_err());
assert_eq!(validate_selection(&[source], &["test".into()], false).unwrap().len(), 1);
}
}
+7 -16
View File
@@ -1,5 +1,8 @@
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value as Json;
use std::{os::unix::net::UnixStream, sync::Arc, time::Duration};
#[derive(Clone)]
pub(crate) struct Bridge(Arc<BridgeInner>);
@@ -41,17 +44,9 @@ impl Bridge {
async fn request(&self, command: &'static str, args: Json, wait: bool) -> Result<Json, String> {
let bridge = self.clone();
tokio::task::spawn_blocking(move || {
let mut stream = bridge
.0
.stream
.lock()
.map_err(|_| "Compositor connection failed")?;
let mut stream = bridge.0.stream.lock().map_err(|_| "Compositor connection failed")?;
stream
.set_read_timeout(if wait {
None
} else {
Some(Duration::from_secs(2))
})
.set_read_timeout(if wait { None } else { Some(Duration::from_secs(2)) })
.map_err(|error| error.to_string())?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
@@ -73,11 +68,7 @@ impl Bridge {
}
pub(crate) async fn capture_watch(&self, session: u64) -> Result<Json, String> {
self.request(
"input-capture-watch",
serde_json::json!({"session":session}),
true,
)
self.request("input-capture-watch", serde_json::json!({"session":session}), true)
.await
}
+28 -88
View File
@@ -1,20 +1,15 @@
//! Bounded, cancellable Wayland capture. No screenshots or external capture tools.
use std::fs::File;
use std::os::fd::{AsFd, AsRawFd, FromRawFd};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use ferese_protocols::window_capture::v1::client::ferese_window_capture_manager_v1 as window_manager;
use memmap2::MmapMut;
use serde::{Deserialize, Serialize};
use std::{
fs::File,
os::fd::{AsFd, AsRawFd, FromRawFd},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use wayland_client::{
Connection, Dispatch, EventQueue, QueueHandle, WEnum,
protocol::{wl_buffer, wl_output, wl_registry, wl_shm, wl_shm_pool},
};
use wayland_client::protocol::{wl_buffer, wl_output, wl_registry, wl_shm, wl_shm_pool};
use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, WEnum};
use wayland_protocols_wlr::screencopy::v1::client::{
zwlr_screencopy_frame_v1 as frame, zwlr_screencopy_manager_v1 as manager,
};
@@ -104,9 +99,7 @@ impl Capture {
// uninterruptible roundtrip on a stalled compositor.
this.sync(stop)?;
this.sync(stop)?;
if (this.state.manager.is_none() && this.state.window_manager.is_none())
|| this.state.shm.is_none()
{
if (this.state.manager.is_none() && this.state.window_manager.is_none()) || this.state.shm.is_none() {
return Err("Ferese screen capture is unavailable in this session".into());
}
Ok(this)
@@ -114,9 +107,7 @@ impl Capture {
fn sync(&mut self, stop: &AtomicBool) -> Result<(), String> {
let done = Arc::new(AtomicBool::new(false));
self.connection
.display()
.sync(&self.queue.handle(), done.clone());
self.connection.display().sync(&self.queue.handle(), done.clone());
let deadline = Instant::now() + Duration::from_secs(5);
while !done.load(Ordering::Relaxed) {
self.dispatch(stop, deadline)?;
@@ -168,34 +159,19 @@ impl Capture {
.collect()
}
pub fn frame(
&mut self,
name: &str,
cursor: bool,
stop: &AtomicBool,
mut pixels: Vec<u8>,
) -> Result<Frame, String> {
pub fn frame(&mut self, name: &str, cursor: bool, stop: &AtomicBool, mut pixels: Vec<u8>) -> Result<Frame, String> {
self.state.result = None;
self.state.flipped = false;
self.state.logical_size = None;
self.state.busy = false;
let (frame, transform) = if let Some(id) = name
.strip_prefix("window:")
.and_then(|id| id.parse::<u64>().ok())
{
let (frame, transform) = if let Some(id) = name.strip_prefix("window:").and_then(|id| id.parse::<u64>().ok()) {
let manager = self
.state
.window_manager
.as_ref()
.ok_or("Window capture is unavailable")?;
(
manager.capture_window(
(id >> 32) as u32,
id as u32,
cursor as u32,
&self.queue.handle(),
(),
),
manager.capture_window((id >> 32) as u32, id as u32, cursor as u32, &self.queue.handle(), ()),
wl_output::Transform::Normal,
)
} else {
@@ -205,18 +181,11 @@ impl Capture {
.iter()
.find(|o| o.source.name == name)
.ok_or("Shared monitor disconnected")?;
if self
.selected_output
.is_some_and(|selected| selected != output.global)
{
if self.selected_output.is_some_and(|selected| selected != output.global) {
return Err("Shared monitor was replaced".into());
}
self.selected_output = Some(output.global);
let manager = self
.state
.manager
.as_ref()
.ok_or("Monitor capture is unavailable")?;
let manager = self.state.manager.as_ref().ok_or("Monitor capture is unavailable")?;
(
manager.capture_output(cursor as i32, &output.proxy, &self.queue.handle(), ()),
output.transform,
@@ -234,11 +203,7 @@ impl Capture {
frame.destroy();
self.state.result.take().unwrap()?;
let buffer = self
.state
.buffer
.as_ref()
.ok_or("Capture supplied no buffer")?;
let buffer = self.state.buffer.as_ref().ok_or("Capture supplied no buffer")?;
let (width, height) = copy_oriented_pixels(
&buffer.map,
buffer.width,
@@ -283,9 +248,7 @@ impl Capture {
if ready > 0 {
read.read().map_err(|e| e.to_string())?;
} else if ready < 0
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
{
} else if ready < 0 && std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted {
return Err(std::io::Error::last_os_error().to_string());
}
}
@@ -327,13 +290,9 @@ impl Dispatch<wl_registry::WlRegistry, ()> for State {
scale: 1,
},
}),
"ferese_window_capture_manager_v1" => {
state.window_manager = Some(registry.bind(name, 1, qh, ()))
}
"ferese_window_capture_manager_v1" => state.window_manager = Some(registry.bind(name, 1, qh, ())),
"wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())),
"zwlr_screencopy_manager_v1" => {
state.manager = Some(registry.bind(name, version.min(3), qh, ()))
}
"zwlr_screencopy_manager_v1" => state.manager = Some(registry.bind(name, version.min(3), qh, ())),
_ => (),
},
wl_registry::Event::GlobalRemove { name } => state.outputs.retain(|o| o.global != name),
@@ -358,9 +317,7 @@ impl Dispatch<wl_output::WlOutput, u32> for State {
match event {
wl_output::Event::Name { name } => output.source.name = name,
wl_output::Event::Description { description } => output.source.label = description,
wl_output::Event::Geometry {
x, y, transform, ..
} => {
wl_output::Event::Geometry { x, y, transform, .. } => {
if let WEnum::Value(transform) = transform {
output.transform = transform;
}
@@ -412,9 +369,7 @@ impl Dispatch<frame::ZwlrScreencopyFrameV1, ()> for State {
return Ok(());
}
// SAFETY: fixed NUL-terminated name and a newly owned memfd.
let fd = unsafe {
libc::memfd_create(c"ferese-screencast".as_ptr(), libc::MFD_CLOEXEC)
};
let fd = unsafe { libc::memfd_create(c"ferese-screencast".as_ptr(), libc::MFD_CLOEXEC) };
if fd < 0 {
return Err(std::io::Error::last_os_error().to_string());
@@ -424,20 +379,13 @@ impl Dispatch<frame::ZwlrScreencopyFrameV1, ()> for State {
file.set_len(bytes as u64).map_err(|e| e.to_string())?;
// SAFETY: the file has the checked mapping length and lives with the mapping.
let map = unsafe { MmapMut::map_mut(&file) }.map_err(|e| e.to_string())?;
let pool = state
.shm
.as_ref()
.ok_or("Missing shared memory global")?
.create_pool(file.as_fd(), bytes as i32, qh, ());
let proxy = pool.create_buffer(
0,
width as i32,
height as i32,
stride as i32,
format,
let pool = state.shm.as_ref().ok_or("Missing shared memory global")?.create_pool(
file.as_fd(),
bytes as i32,
qh,
(),
);
let proxy = pool.create_buffer(0, width as i32, height as i32, stride as i32, format, qh, ());
pool.destroy();
frame.copy(&proxy);
@@ -540,9 +488,7 @@ fn copy_oriented_pixels(
fn buffer_size(width: u32, height: u32, stride: u32) -> Result<usize, String> {
let row = width.checked_mul(4).ok_or("Capture dimensions overflow")?;
let bytes = stride
.checked_mul(height)
.ok_or("Capture dimensions overflow")?;
let bytes = stride.checked_mul(height).ok_or("Capture dimensions overflow")?;
if width == 0 || height == 0 || stride < row || bytes > 128 * 1024 * 1024 {
return Err("Unsupported capture dimensions".into());
@@ -578,9 +524,7 @@ impl Dispatch<window_manager::FereseWindowCaptureManagerV1, ()> for State {
_: &QueueHandle<Self>,
) {
match event {
window_manager::Event::Geometry { width, height, .. } => {
state.logical_size = Some((width, height))
}
window_manager::Event::Geometry { width, height, .. } => state.logical_size = Some((width, height)),
window_manager::Event::Busy { .. } => state.busy = true,
_ => (),
}
@@ -609,11 +553,7 @@ mod tests {
for (transform, size, expected) in cases {
for y_invert in [false, true] {
let mut source = Vec::new();
let rows = if y_invert {
[b"def", b"abc"]
} else {
[b"abc", b"def"]
};
let rows = if y_invert { [b"def", b"abc"] } else { [b"abc", b"def"] };
for row in rows {
for &value in row {
source.extend_from_slice(&pixel(value));
+11 -23
View File
@@ -1,14 +1,14 @@
use cosmic::{
Element,
app::{Core, Settings, Task},
iced::{Alignment, Length},
widget::{button, column, container, image, row, scrollable, text_input},
};
use ferese_theme::{Palette, accent_button, controls, material::ModalMaterial, text};
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::sync::Arc;
use cosmic::Element;
use cosmic::app::{Core, Settings, Task};
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{button, column, container, image, row, scrollable, text_input};
use ferese_theme::material::ModalMaterial;
use ferese_theme::{Palette, accent_button, controls, text};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct Prompt {
pub title: String,
@@ -30,15 +30,7 @@ pub(crate) struct ShortcutField {
#[derive(Clone, Debug)]
enum Message {
Opened(cosmic::iced::window::Id),
Attached(
Result<
(
Option<Arc<crate::parent::Parent>>,
Result<ModalMaterial, String>,
),
String,
>,
),
Attached(Result<(Option<Arc<crate::parent::Parent>>, Result<ModalMaterial, String>), String>),
Accept,
Cancel,
Shortcut(usize, String),
@@ -56,9 +48,7 @@ struct Consent {
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new();
std::io::stdin()
.take(256 * 1024 + 1)
.read_to_string(&mut input)?;
std::io::stdin().take(256 * 1024 + 1).read_to_string(&mut input)?;
if input.len() > 256 * 1024 {
return Err("Consent request is too large".into());
@@ -124,9 +114,7 @@ impl cosmic::Application for Consent {
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
Some(Message::Opened(id))
}
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => Some(Message::Opened(id)),
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
..
+55 -125
View File
@@ -1,22 +1,21 @@
use crate::{
backend::{Cancel, Options, Request, authorize},
consent::Prompt,
};
use std::{
collections::HashMap,
io::Read,
os::unix::{fs::PermissionsExt, net::UnixStream},
path::{Path, PathBuf},
process::Stdio,
sync::{Arc, atomic::Ordering},
time::Duration,
};
use tokio::{io::AsyncWriteExt, sync::Mutex};
use zbus::{
Connection,
message::Header,
zvariant::{OwnedObjectPath, Value},
};
use std::collections::HashMap;
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use zbus::Connection;
use zbus::message::Header;
use zbus::zvariant::{OwnedObjectPath, Value};
use crate::backend::{Cancel, Options, Request, authorize};
use crate::consent::Prompt;
const PATH: &str = "/org/freedesktop/portal/desktop";
const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024;
@@ -56,9 +55,7 @@ impl Requests {
}
let mut pending = self.0.lock().await;
if pending.len() >= self.1 || pending.contains_key(handle.as_str()) {
return Err(zbus::fdo::Error::LimitsExceeded(
"Request limit reached".into(),
));
return Err(zbus::fdo::Error::LimitsExceeded("Request limit reached".into()));
}
let cancel = Arc::new(Cancel::default());
if !connection
@@ -72,9 +69,7 @@ impl Requests {
)
.await?
{
return Err(zbus::fdo::Error::InvalidArgs(
"Request already exists".into(),
));
return Err(zbus::fdo::Error::InvalidArgs("Request already exists".into()));
}
pending.insert(handle.to_string(), (owner, cancel.clone()));
Ok(cancel)
@@ -82,10 +77,7 @@ impl Requests {
pub(crate) async fn end(&self, connection: &Connection, handle: &OwnedObjectPath) {
self.0.lock().await.remove(handle.as_str());
let _ = connection
.object_server()
.remove::<Request, _>(handle)
.await;
let _ = connection.object_server().remove::<Request, _>(handle).await;
}
}
@@ -231,10 +223,7 @@ pub(crate) async fn consent(
}
}
pub(crate) async fn ipc(
command: &'static str,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
pub(crate) async fn ipc(command: &'static str, args: serde_json::Value) -> Result<serde_json::Value, String> {
tokio::task::spawn_blocking(move || ipc_sync(command, args))
.await
.map_err(|error| error.to_string())?
@@ -260,8 +249,7 @@ fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result<serde_json
args,
};
ferese_ipc::write_frame(&mut stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response = ferese_ipc::read_frame(&mut stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
@@ -313,10 +301,7 @@ async fn capture(geometry: Option<String>) -> Result<Vec<u8>, String> {
capture_command("screenshot", serde_json::json!({"geometry": geometry})).await
}
async fn capture_command(
command: &'static str,
args: serde_json::Value,
) -> Result<Vec<u8>, String> {
async fn capture_command(command: &'static str, args: serde_json::Value) -> Result<Vec<u8>, String> {
tokio::task::spawn_blocking(move || {
let result = ipc_sync(command, args)?;
let path = result["path"].as_str().ok_or("Missing screenshot path")?;
@@ -462,20 +447,14 @@ fn window_sources(windows: &serde_json::Value) -> Result<Vec<crate::capture::Sou
Ok(sources)
}
pub(crate) fn sharing_window_sources(
windows: &serde_json::Value,
) -> Result<Vec<crate::capture::Source>, String> {
pub(crate) fn sharing_window_sources(windows: &serde_json::Value) -> Result<Vec<crate::capture::Source>, String> {
let windows = windows.as_array().ok_or("Invalid window list")?;
let eligible = windows
.iter()
.filter(|window| {
window["workspace"].as_u64().is_some()
&& window["capture_width"]
.as_i64()
.is_some_and(|width| width > 0)
&& window["capture_height"]
.as_i64()
.is_some_and(|height| height > 0)
&& window["capture_width"].as_i64().is_some_and(|width| width > 0)
&& window["capture_height"].as_i64().is_some_and(|height| height > 0)
&& window["app_id"].as_str() != Some("dev.ferese.ScreenShare")
})
.take(128)
@@ -530,8 +509,7 @@ async fn select_window(app: &str, parent: &str) -> Result<Option<u64>, String> {
if !output.status.success() {
return Ok(None);
}
let names: Vec<String> =
serde_json::from_slice(&output.stdout).map_err(|_| "Invalid window selection")?;
let names: Vec<String> = serde_json::from_slice(&output.stdout).map_err(|_| "Invalid window selection")?;
if names.len() != 1 || !prompt.sources.iter().any(|source| source.name == names[0]) {
return Err("Unknown selected window".into());
}
@@ -562,9 +540,7 @@ async fn pick_color(app: &str, parent: &str) -> Result<Option<Options>, String>
let color = png_color(&bytes)?;
Ok(Some(HashMap::from([(
"color".into(),
Value::from(color)
.try_to_owned()
.map_err(|error| error.to_string())?,
Value::from(color).try_to_owned().map_err(|error| error.to_string())?,
)])))
}
@@ -577,9 +553,7 @@ fn png_color(bytes: &[u8]) -> Result<(f64, f64, f64), String> {
.filter(|size| *size <= 16 * 1024)
.ok_or("Unexpected pixel dimensions")?;
let mut pixels = vec![0; size];
let info = reader
.next_frame(&mut pixels)
.map_err(|error| error.to_string())?;
let info = reader.next_frame(&mut pixels).map_err(|error| error.to_string())?;
if info.width == 0 || info.height == 0 {
return Err("Empty image".into());
}
@@ -599,8 +573,7 @@ fn private_directory(name: &str) -> Result<PathBuf, String> {
.join("ferese/portal")
.join(name);
std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))
.map_err(|error| error.to_string())?;
std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).map_err(|error| error.to_string())?;
Ok(directory)
}
@@ -668,11 +641,7 @@ fn prepare_wallpaper(input: &Path, cancel: &Cancel) -> Result<tempfile::NamedTem
.custom_flags(libc::O_NONBLOCK)
.open(input)
.map_err(|error| error.to_string())?;
if !input
.metadata()
.map_err(|error| error.to_string())?
.is_file()
{
if !input.metadata().map_err(|error| error.to_string())?.is_file() {
return Err("Wallpaper must be a regular file".into());
}
let directory = private_directory("wallpapers")?;
@@ -695,8 +664,7 @@ fn prepare_wallpaper(input: &Path, cancel: &Cancel) -> Result<tempfile::NamedTem
if size > MAX_IMAGE_BYTES {
return Err("Wallpaper is too large".into());
}
std::io::Write::write_all(&mut file, &buffer[..bytes])
.map_err(|error| error.to_string())?;
std::io::Write::write_all(&mut file, &buffer[..bytes]).map_err(|error| error.to_string())?;
}
if size == 0 {
return Err("Wallpaper is empty".into());
@@ -705,9 +673,7 @@ fn prepare_wallpaper(input: &Path, cancel: &Cancel) -> Result<tempfile::NamedTem
.map_err(|error| error.to_string())?
.with_guessed_format()
.map_err(|error| error.to_string())?;
let (width, height) = reader
.into_dimensions()
.map_err(|error| error.to_string())?;
let (width, height) = reader.into_dimensions().map_err(|error| error.to_string())?;
if width == 0
|| height == 0
|| width > 16384
@@ -741,8 +707,7 @@ fn edit_wallpaper(path: &Path, image: &Path, target: &str) -> Result<(), String>
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(error.to_string()),
};
let mut document =
ferese_config::Document::parse(&source).map_err(|error| error.to_string())?;
let mut document = ferese_config::Document::parse(&source).map_err(|error| error.to_string())?;
if target == "background" && document.get("theme.background.lock_path").is_none() {
let old = document
.get("theme.background.path")
@@ -755,10 +720,7 @@ fn edit_wallpaper(path: &Path, image: &Path, target: &str) -> Result<(), String>
}
if target != "lockscreen" {
document
.set(
"theme.background.path",
image.to_string_lossy().into_owned().into(),
)
.set("theme.background.path", image.to_string_lossy().into_owned().into())
.map_err(|error| error.to_string())?;
}
if target != "background" {
@@ -772,11 +734,8 @@ fn edit_wallpaper(path: &Path, image: &Path, target: &str) -> Result<(), String>
let parent = path.parent().ok_or("Invalid config path")?;
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|error| error.to_string())?;
std::io::Write::write_all(&mut temp, document.to_string().as_bytes())
.map_err(|error| error.to_string())?;
temp.as_file()
.sync_all()
.map_err(|error| error.to_string())?;
std::io::Write::write_all(&mut temp, document.to_string().as_bytes()).map_err(|error| error.to_string())?;
temp.as_file().sync_all().map_err(|error| error.to_string())?;
if std::fs::read_to_string(&path).unwrap_or_default() != source {
return Err("Configuration changed; please try again".into());
}
@@ -806,11 +765,7 @@ mod tests {
fn local_uris_preserve_special_characters_and_reject_network_paths() {
let path = Path::new("/tmp/wallpaper #1_日本.png");
assert_eq!(local_path(&file_uri(path).unwrap()).unwrap(), path);
for uri in [
"https://example.com/a.png",
"file://example.com/a.png",
"not a URI",
] {
for uri in ["https://example.com/a.png", "file://example.com/a.png", "not a URI"] {
assert!(local_path(uri).is_err());
}
}
@@ -862,46 +817,25 @@ mod tests {
fn wallpaper_targets_preserve_independent_paths_and_other_settings() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.kdl");
std::fs::write(&path, "// Keep my settings\ntheme { background { path \"/old.png\"; }; }; commands { terminal \"foot\"; }\n").unwrap();
std::fs::write(
&path,
"// Keep my settings\ntheme { background { path \"/old.png\"; }; }; commands { terminal \"foot\"; }\n",
)
.unwrap();
edit_wallpaper(&path, Path::new("/desktop.png"), "background").unwrap();
let document =
ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
document.get("theme.background.path").unwrap(),
"/desktop.png"
);
assert_eq!(
document.get("theme.background.lock_path").unwrap(),
"/old.png"
);
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("Keep my settings")
);
let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(document.get("theme.background.path").unwrap(), "/desktop.png");
assert_eq!(document.get("theme.background.lock_path").unwrap(), "/old.png");
assert!(std::fs::read_to_string(&path).unwrap().contains("Keep my settings"));
edit_wallpaper(&path, Path::new("/lock.png"), "lockscreen").unwrap();
let document =
ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
document.get("theme.background.path").unwrap(),
"/desktop.png"
);
assert_eq!(
document.get("theme.background.lock_path").unwrap(),
"/lock.png"
);
let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(document.get("theme.background.path").unwrap(), "/desktop.png");
assert_eq!(document.get("theme.background.lock_path").unwrap(), "/lock.png");
edit_wallpaper(&path, Path::new("/both.png"), "both").unwrap();
let document =
ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let document = ferese_config::Document::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(document.get("theme.background.path").unwrap(), "/both.png");
assert_eq!(
document.get("theme.background.lock_path").unwrap(),
"/both.png"
);
assert_eq!(
document.get("commands.terminal").unwrap(),
&serde_json::json!(["foot"])
);
assert_eq!(document.get("theme.background.lock_path").unwrap(), "/both.png");
assert_eq!(document.get("commands.terminal").unwrap(), &serde_json::json!(["foot"]));
}
#[test]
@@ -912,10 +846,6 @@ mod tests {
std::fs::write(&path, [0u8; 32]).unwrap();
let cancel = Cancel::default();
cancel.stop();
assert!(
prepare_wallpaper(&path, &cancel)
.unwrap_err()
.contains("cancelled")
);
assert!(prepare_wallpaper(&path, &cancel).unwrap_err().contains("cancelled"));
}
}
+38 -92
View File
@@ -1,23 +1,15 @@
use std::{
fs::File,
io::Write,
os::{
fd::{AsFd, OwnedFd},
unix::net::UnixStream,
},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use std::fs::File;
use std::io::Write;
use std::os::fd::{AsFd, OwnedFd};
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use calloop::{EventLoop, LoopSignal, PostAction, channel};
use reis::{
calloop::{EisRequestSource, EisRequestSourceEvent},
eis,
enumflags2::BitFlags,
request::{Connection, Device, DeviceCapability, EisRequest},
};
use reis::calloop::{EisRequestSource, EisRequestSourceEvent};
use reis::eis;
use reis::enumflags2::BitFlags;
use reis::request::{Connection, Device, DeviceCapability, EisRequest};
use serde_json::Value;
use crate::backend::Cancel;
@@ -29,11 +21,7 @@ pub(crate) struct Worker {
}
impl Worker {
pub(crate) fn start(
capabilities: u32,
keymap: &str,
cancel: Arc<Cancel>,
) -> Result<(Self, OwnedFd), String> {
pub(crate) fn start(capabilities: u32, keymap: &str, cancel: Arc<Cancel>) -> Result<(Self, OwnedFd), String> {
if keymap.is_empty() || keymap.len() > 256 * 1024 {
return Err("Invalid captured keyboard map".into());
}
@@ -59,8 +47,7 @@ impl Worker {
let _finish = Finish(cancel);
let run = || {
let context = eis::Context::new(server).map_err(|e| e.to_string())?;
let mut event_loop =
EventLoop::<State>::try_new().map_err(|e| e.to_string())?;
let mut event_loop = EventLoop::<State>::try_new().map_err(|e| e.to_string())?;
let signal = event_loop.get_signal();
let (sender, receiver) = channel::sync_channel(4);
event_loop
@@ -72,9 +59,7 @@ impl Worker {
.map_err(|e| e.to_string())?;
event_loop
.handle()
.insert_source(
EisRequestSource::new(context, 1),
|event, connection, state| {
.insert_source(EisRequestSource::new(context, 1), |event, connection, state| {
let action = match event {
Ok(event) => state.request(connection, event),
Err(_) => {
@@ -86,8 +71,7 @@ impl Worker {
state.signal.stop();
}
Ok(action)
},
)
})
.map_err(|e| e.to_string())?;
let mut state = State {
signal: signal.clone(),
@@ -107,8 +91,7 @@ impl Worker {
.map_err(|_| "Input transport startup cancelled".to_owned())?;
let _ = event_loop.run(None, &mut state, |_| {});
if let Some(connection) = state.connection.take() {
connection
.disconnected(eis::connection::DisconnectReason::Disconnected, None);
connection.disconnected(eis::connection::DisconnectReason::Disconnected, None);
}
state.ready.store(false, Ordering::SeqCst);
Ok::<(), String>(())
@@ -121,14 +104,7 @@ impl Worker {
let (sender, signal) = started
.recv_timeout(std::time::Duration::from_secs(2))
.map_err(|_| "Input transport startup timed out".to_owned())??;
Ok((
Self {
sender,
signal,
ready,
},
client.into(),
))
Ok((Self { sender, signal, ready }, client.into()))
}
pub(crate) fn send(&self, events: Vec<Value>) -> Result<(), String> {
@@ -180,18 +156,14 @@ impl State {
capabilities |= DeviceCapability::Keyboard;
}
if self.capabilities & 2 != 0 {
capabilities |= DeviceCapability::Pointer
| DeviceCapability::Button
| DeviceCapability::Scroll;
capabilities |= DeviceCapability::Pointer | DeviceCapability::Button | DeviceCapability::Scroll;
}
let _ = connection.add_seat(Some("Ferese input capture"), capabilities);
self.connection = Some(connection.clone());
}
EisRequestSourceEvent::Request(EisRequest::Bind(request)) => {
let keyboard = request.capabilities.contains(DeviceCapability::Keyboard)
&& self.capabilities & 1 != 0;
let pointer = request.capabilities.contains(DeviceCapability::Pointer)
&& self.capabilities & 2 != 0;
let keyboard = request.capabilities.contains(DeviceCapability::Keyboard) && self.capabilities & 1 != 0;
let pointer = request.capabilities.contains(DeviceCapability::Pointer) && self.capabilities & 2 != 0;
if !keyboard && let Some(device) = self.keyboard.take() {
device.remove();
self.keyboard_ready = false;
@@ -207,11 +179,7 @@ impl State {
DeviceCapability::Keyboard.into(),
|device| {
if let Some(keyboard) = device.interface::<eis::Keyboard>() {
keyboard.keymap(
eis::keyboard::KeymapType::Xkb,
self.keymap_size,
self.keymap.as_fd(),
);
keyboard.keymap(eis::keyboard::KeymapType::Xkb, self.keymap_size, self.keymap.as_fd());
}
},
);
@@ -221,15 +189,11 @@ impl State {
}
if pointer && self.pointer.is_none() {
let caps = request.capabilities
& (DeviceCapability::Pointer
| DeviceCapability::Button
| DeviceCapability::Scroll);
let device = request.seat.add_device(
Some("Ferese pointer"),
eis::device::DeviceType::Virtual,
caps,
|_| {},
);
& (DeviceCapability::Pointer | DeviceCapability::Button | DeviceCapability::Scroll);
let device =
request
.seat
.add_device(Some("Ferese pointer"), eis::device::DeviceType::Virtual, caps, |_| {});
self.pointer_ready = device.device().version() < 3;
device.resumed();
self.pointer = Some(device);
@@ -263,14 +227,11 @@ impl State {
return PostAction::Remove;
}
self.ready.store(
(self.keyboard.is_none() || self.keyboard_ready)
&& (self.pointer.is_none() || self.pointer_ready),
(self.keyboard.is_none() || self.keyboard_ready) && (self.pointer.is_none() || self.pointer_ready),
Ordering::SeqCst,
);
}
EisRequestSourceEvent::Request(
EisRequest::Disconnect | EisRequest::DeviceClosed(_),
) => {
EisRequestSourceEvent::Request(EisRequest::Disconnect | EisRequest::DeviceClosed(_)) => {
self.signal.stop();
return PostAction::Remove;
}
@@ -307,12 +268,7 @@ impl State {
if let Some(device) = &self.keyboard
&& let Some(keyboard) = device.interface::<eis::Keyboard>()
{
for key in event["keys"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
{
for key in event["keys"].as_array().into_iter().flatten().filter_map(Value::as_u64) {
keyboard.key(key as u32, eis::keyboard::KeyState::Press);
}
device.frame(now());
@@ -392,10 +348,7 @@ impl State {
}
fn now() -> u64 {
let mut time = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
let mut time = libc::timespec { tv_sec: 0, tv_nsec: 0 };
// SAFETY: the initialized output pointer covers a timespec allocation.
if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut time) } == 0 {
time.tv_sec as u64 * 1_000_000 + time.tv_nsec as u64 / 1_000
@@ -406,12 +359,12 @@ fn now() -> u64 {
#[cfg(test)]
mod tests {
use super::*;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::json;
use std::{
process::{Command, Stdio},
time::{Duration, Instant},
};
use super::*;
#[test]
#[ignore = "requires Python and the system libei receiver library"]
@@ -419,8 +372,8 @@ mod tests {
let cancel = Arc::new(Cancel::default());
let map = "xkb_keymap { xkb_keycodes { include \"evdev+aliases(qwerty)\" }; xkb_types { include \"complete\" }; xkb_compatibility { include \"complete\" }; xkb_symbols { include \"pc+us+inet(evdev)\" }; };";
let (worker, fd) = Worker::start(3, map, cancel.clone()).unwrap();
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../scripts/tests/fixtures/eis-receiver.py");
let fixture =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../scripts/tests/fixtures/eis-receiver.py");
let mut client = Command::new("python3")
.arg(fixture)
.stdin(Stdio::from(fd))
@@ -435,10 +388,7 @@ mod tests {
if !worker.ready.load(Ordering::SeqCst) {
let _ = client.kill();
let output = client.wait_with_output().unwrap();
panic!(
"Receiver did not bind: {}",
String::from_utf8_lossy(&output.stderr)
);
panic!("Receiver did not bind: {}", String::from_utf8_lossy(&output.stderr));
}
worker
.send(vec![
@@ -453,11 +403,7 @@ mod tests {
])
.unwrap();
let output = client.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).contains("\"keymap\": true"));
let deadline = Instant::now() + Duration::from_secs(2);
while !cancel.stopped.load(Ordering::SeqCst) && Instant::now() < deadline {
+73 -91
View File
@@ -1,21 +1,17 @@
use std::{
collections::HashMap,
sync::{Arc, atomic::Ordering},
time::Duration,
};
use tokio::sync::{Mutex, Notify};
use zbus::{
Connection,
message::Header,
object_server::SignalEmitter,
zvariant::{OwnedFd, OwnedObjectPath, Value},
};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use crate::{
backend::{Cancel, Options, authorize},
bridge::Bridge,
desktop::Requests,
};
use tokio::sync::{Mutex, Notify};
use zbus::Connection;
use zbus::message::Header;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::{OwnedFd, OwnedObjectPath, Value};
use crate::backend::{Cancel, Options, authorize};
use crate::bridge::Bridge;
use crate::desktop::Requests;
const PATH: &str = "/org/freedesktop/portal/desktop";
@@ -68,10 +64,7 @@ impl Inhibit {
if let Ok(emitter) = SignalEmitter::new(connection, path) {
let _ = MonitorObject::closed(&emitter).await;
}
let _ = connection
.object_server()
.remove::<MonitorObject, _>(path)
.await;
let _ = connection.object_server().remove::<MonitorObject, _>(path).await;
self.changed.notify_one();
}
}
@@ -114,9 +107,7 @@ impl Inhibit {
since = state["revision"].as_u64();
for (path, monitor) in monitors {
if !state["monitor-owners"].as_array().is_some_and(|owners| {
owners
.iter()
.any(|owner| owner.as_u64() == Some(monitor.native_owner))
owners.iter().any(|owner| owner.as_u64() == Some(monitor.native_owner))
}) {
self.end(&connection, &path).await;
continue;
@@ -128,8 +119,7 @@ impl Inhibit {
let transitions = match *last {
None => vec![state.clone()],
Some((_, _, revision, _)) => {
let events =
state["transitions"].as_array().cloned().unwrap_or_default();
let events = state["transitions"].as_array().cloned().unwrap_or_default();
if events
.first()
.and_then(|event| event["revision"].as_u64())
@@ -141,11 +131,7 @@ impl Inhibit {
}
events
.into_iter()
.filter(|event| {
event["revision"]
.as_u64()
.is_some_and(|next| next > revision)
})
.filter(|event| event["revision"].as_u64().is_some_and(|next| next > revision))
.collect()
}
};
@@ -154,19 +140,13 @@ impl Inhibit {
let phase = event["session-state"].as_u64().unwrap_or(1) as u32;
let revision = event["revision"].as_u64().unwrap_or(0);
let token = event["query-token"].as_u64().unwrap_or(0) as u32;
let changed = last.as_ref().is_none_or(|old| {
old.0 != locked || old.1 != phase || (phase == 2 && old.3 != token)
});
let changed = last
.as_ref()
.is_none_or(|old| old.0 != locked || old.1 != phase || (phase == 2 && old.3 != token));
if changed {
let values = HashMap::from([
(
"screensaver-active".into(),
Value::from(locked).try_into().unwrap(),
),
(
"session-state".into(),
Value::from(phase).try_into().unwrap(),
),
("screensaver-active".into(), Value::from(locked).try_into().unwrap()),
("session-state".into(), Value::from(phase).try_into().unwrap()),
]);
if let Ok(emitter) = SignalEmitter::new(&connection, PATH) {
if Self::state_changed(
@@ -203,9 +183,7 @@ impl Inhibit {
use futures_util::StreamExt;
loop {
let result: Result<(), String> = async {
let connection = Connection::system()
.await
.map_err(|error| error.to_string())?;
let connection = Connection::system().await.map_err(|error| error.to_string())?;
let proxy = zbus::Proxy::new(
&connection,
"org.freedesktop.login1",
@@ -219,16 +197,10 @@ impl Inhibit {
.await
.map_err(|error| error.to_string())?;
while let Some(message) = signals.next().await {
let (ending,): (bool,) = message
.body()
.deserialize()
.map_err(|error| error.to_string())?;
let (ending,): (bool,) = message.body().deserialize().map_err(|error| error.to_string())?;
if let Ok(bridge) = Bridge::connect() {
let _ = bridge
.call(
"logind-session-ending",
serde_json::json!({"ending": ending}),
)
.call("logind-session-ending", serde_json::json!({"ending": ending}))
.await;
bridge.close();
}
@@ -246,9 +218,7 @@ impl Inhibit {
fn flags_what(flags: u32) -> zbus::fdo::Result<String> {
if flags == 0 || flags & !15 != 0 {
return Err(zbus::fdo::Error::InvalidArgs(
"Invalid inhibition flags".into(),
));
return Err(zbus::fdo::Error::InvalidArgs("Invalid inhibition flags".into()));
}
let mut what = Vec::new();
if flags & 1 != 0 {
@@ -268,9 +238,7 @@ async fn login_inhibitor(what: &str, app: &str, reason: &str) -> Result<Option<O
return Ok(None);
}
tokio::time::timeout(Duration::from_secs(5), async {
let connection = Connection::system()
.await
.map_err(|error| error.to_string())?;
let connection = Connection::system().await.map_err(|error| error.to_string())?;
let proxy = zbus::Proxy::new(
&connection,
"org.freedesktop.login1",
@@ -309,13 +277,8 @@ impl Inhibit {
.transpose()
.map_err(|_| zbus::fdo::Error::InvalidArgs("Invalid inhibition reason".into()))?
.unwrap_or_else(|| "Application requested session inhibition".into());
if app_id.len() > 512
|| reason.len() > 1024
|| app_id.chars().chain(reason.chars()).any(char::is_control)
{
return Err(zbus::fdo::Error::InvalidArgs(
"Invalid application or reason".into(),
));
if app_id.len() > 512 || reason.len() > 1024 || app_id.chars().chain(reason.chars()).any(char::is_control) {
return Err(zbus::fdo::Error::InvalidArgs("Invalid application or reason".into()));
}
let cancel = self.requests.begin(connection, &header, &handle).await?;
let bridge = match Bridge::connect() {
@@ -351,9 +314,7 @@ impl Inhibit {
bridge.close();
self.requests.end(connection, &handle).await;
Err(zbus::fdo::Error::Failed(
result
.err()
.unwrap_or_else(|| "Inhibition was cancelled".into()),
result.err().unwrap_or_else(|| "Inhibition was cancelled".into()),
))
}
}
@@ -369,15 +330,11 @@ impl Inhibit {
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<u32> {
let owner = authorize(connection, &header).await?;
if !session_handle
.as_str()
.starts_with(&format!("{PATH}/session/"))
if !session_handle.as_str().starts_with(&format!("{PATH}/session/"))
|| session_handle.as_str().len() > 512
|| app_id.len() > 512
{
return Err(zbus::fdo::Error::InvalidArgs(
"Invalid session monitor".into(),
));
return Err(zbus::fdo::Error::InvalidArgs("Invalid session monitor".into()));
}
let cancel = self.requests.begin(connection, &header, &handle).await?;
let mut monitors = self.monitors.lock().await;
@@ -394,30 +351,59 @@ impl Inhibit {
_ = cancel.wait() => Err("Monitor creation was cancelled".into()),
result = bridge.call("portal-monitor-register", serde_json::json!({})) => result,
};
let native = match result { Ok(native) => native, Err(error) => { bridge.close(); return Err(zbus::fdo::Error::Failed(error)); } };
let native = match result {
Ok(native) => native,
Err(error) => {
bridge.close();
return Err(zbus::fdo::Error::Failed(error));
}
};
let Some(native_owner) = native["monitor-owner"].as_u64() else {
bridge.close();
return Err(zbus::fdo::Error::Failed("Missing monitor owner".into()));
};
let exported = connection.object_server().at(&session_handle, MonitorObject {
backend: self.clone(), path: session_handle.to_string(), owner: owner.clone(), cancel: cancel.clone(),
}).await;
let exported = connection
.object_server()
.at(
&session_handle,
MonitorObject {
backend: self.clone(),
path: session_handle.to_string(),
owner: owner.clone(),
cancel: cancel.clone(),
},
)
.await;
match exported {
Ok(true) if !cancel.stopped.load(Ordering::SeqCst) => {
monitors.insert(session_handle.to_string(), Arc::new(Monitor {
owner, cancel: cancel.clone(), last: Mutex::new(None), bridge,
monitors.insert(
session_handle.to_string(),
Arc::new(Monitor {
owner,
cancel: cancel.clone(),
last: Mutex::new(None),
bridge,
native_owner,
}));
}),
);
self.changed.notify_one();
Ok(0)
}
result => {
bridge.close();
if matches!(result, Ok(true)) { let _ = connection.object_server().remove::<MonitorObject, _>(&session_handle).await; }
result.map(|_| if cancel.stopped.load(Ordering::SeqCst) { 1 } else { 2 }).map_err(Into::into)
if matches!(result, Ok(true)) {
let _ = connection
.object_server()
.remove::<MonitorObject, _>(&session_handle)
.await;
}
result
.map(|_| if cancel.stopped.load(Ordering::SeqCst) { 1 } else { 2 })
.map_err(Into::into)
}
}
}.await;
}
.await;
attempt
};
drop(monitors);
@@ -442,9 +428,7 @@ impl Inhibit {
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Not your session monitor".into()))?;
let last = monitor.last.lock().await;
if monitor.cancel.stopped.load(Ordering::SeqCst) {
return Err(zbus::fdo::Error::AccessDenied(
"Session monitor closed".into(),
));
return Err(zbus::fdo::Error::AccessDenied("Session monitor closed".into()));
}
if let Some((_, 2, _, token)) = *last {
monitor
@@ -484,9 +468,7 @@ impl MonitorObject {
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
if authorize(connection, &header).await? != self.owner {
return Err(zbus::fdo::Error::AccessDenied(
"Not your session monitor".into(),
));
return Err(zbus::fdo::Error::AccessDenied("Not your session monitor".into()));
}
self.cancel.stop();
self.backend.end(connection, &self.path).await;
+42 -103
View File
@@ -1,24 +1,19 @@
use std::{
collections::HashMap,
sync::{Arc, atomic::Ordering},
time::Duration,
};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
use serde_json::json;
use tokio::sync::Mutex;
use zbus::{
Connection,
message::Header,
object_server::SignalEmitter,
zvariant::{OwnedFd, OwnedObjectPath, Value},
};
use zbus::Connection;
use zbus::message::Header;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::{OwnedFd, OwnedObjectPath, Value};
use crate::{
backend::{Cancel, Options, authorize},
bridge::Bridge,
desktop::Requests,
eis::Worker,
};
use crate::backend::{Cancel, Options, authorize};
use crate::bridge::Bridge;
use crate::desktop::Requests;
use crate::eis::Worker;
const PATH: &str = "/org/freedesktop/portal/desktop";
@@ -92,10 +87,7 @@ impl InputCapture {
let connection = connection.clone();
let path = path.to_owned();
tokio::spawn(async move {
let _ = connection
.object_server()
.remove::<SessionObject, _>(path)
.await;
let _ = connection.object_server().remove::<SessionObject, _>(path).await;
});
}
}
@@ -106,9 +98,7 @@ impl InputCapture {
.lock()
.await
.iter()
.filter_map(|(path, session)| {
(Some(session.owner.as_str()) != owner).then_some(path.clone())
})
.filter_map(|(path, session)| (Some(session.owner.as_str()) != owner).then_some(path.clone()))
.collect::<Vec<_>>();
for path in stale {
self.end(connection, &path).await;
@@ -152,10 +142,7 @@ impl InputCapture {
if session.cancel.stopped.load(Ordering::SeqCst) {
break;
}
let transport = state
.worker
.as_ref()
.map(|worker| worker.send(events.clone()));
let transport = state.worker.as_ref().map(|worker| worker.send(events.clone()));
if transport.is_some_and(|result| result.is_err()) {
break;
}
@@ -179,10 +166,8 @@ impl InputCapture {
.as_array()
.filter(|position| position.len() == 2)
{
if let Ok(position) = Value::from((
position[0].as_f64().unwrap_or(0.0),
position[1].as_f64().unwrap_or(0.0),
))
if let Ok(position) =
Value::from((position[0].as_f64().unwrap_or(0.0), position[1].as_f64().unwrap_or(0.0)))
.try_to_owned()
{
options.insert("cursor_position".into(), position);
@@ -201,12 +186,8 @@ impl InputCapture {
)
.await
}
Some("disabled") => {
Self::disabled(&emitter, path.clone(), Options::new()).await
}
Some("zones") => {
Self::zones_changed(&emitter, path.clone(), Options::new()).await
}
Some("disabled") => Self::disabled(&emitter, path.clone(), Options::new()).await,
Some("zones") => Self::zones_changed(&emitter, path.clone(), Options::new()).await,
Some("closed") => {
closed = true;
Ok(())
@@ -238,8 +219,7 @@ fn capabilities(options: &Options) -> zbus::fdo::Result<u32> {
let requested = options
.get("capabilities")
.ok_or_else(|| invalid("Missing requested capabilities"))?;
let requested =
u32::try_from(requested).map_err(|_| invalid("Invalid requested capabilities"))?;
let requested = u32::try_from(requested).map_err(|_| invalid("Invalid requested capabilities"))?;
if requested == 0 || requested & 3 == 0 {
return Err(invalid("No supported capabilities requested"));
}
@@ -267,9 +247,7 @@ impl InputCapture {
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<Options> {
let owner = authorize(connection, &header).await?;
if !session_handle
.as_str()
.starts_with(&format!("{PATH}/session/"))
if !session_handle.as_str().starts_with(&format!("{PATH}/session/"))
|| session_handle.as_str().len() > 512
|| app_id.len() > 512
{
@@ -365,9 +343,7 @@ impl InputCapture {
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<(u32, Options)> {
let session = self
.session(connection, &header, &session_handle, &app_id)
.await?;
let session = self.session(connection, &header, &session_handle, &app_id).await?;
let caps = capabilities(&options)?;
crate::restore::persist_mode(&options)?;
let cancel = self.requests.begin(connection, &header, &handle).await?;
@@ -406,18 +382,11 @@ impl InputCapture {
let reply = bridge
.call("input-capture-register", json!({"capabilities":caps}))
.await?;
let id = reply["session"]
.as_u64()
.ok_or("Missing capture session ID")?;
let keymap = reply["keymap"]
.as_str()
.ok_or("Missing keyboard map")?
.to_owned();
let id = reply["session"].as_u64().ok_or("Missing capture session ID")?;
let keymap = reply["keymap"].as_str().ok_or("Missing keyboard map")?.to_owned();
let watch = Bridge::connect()?;
let mut state = session.state.lock().await;
if cancel.stopped.load(Ordering::SeqCst)
|| session.cancel.stopped.load(Ordering::SeqCst)
{
if cancel.stopped.load(Ordering::SeqCst) || session.cancel.stopped.load(Ordering::SeqCst) {
bridge.close();
return Err("Input capture approval was cancelled".to_owned());
}
@@ -473,9 +442,7 @@ impl InputCapture {
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<(u32, Options)> {
let session = self
.session(connection, &header, &session_handle, &app_id)
.await?;
let session = self.session(connection, &header, &session_handle, &app_id).await?;
let (bridge, id) = Self::native(&session).await?;
let cancel = self.requests.begin(connection, &header, &handle).await?;
let result = tokio::select! {
@@ -485,23 +452,18 @@ impl InputCapture {
result = bridge.call("input-capture-zones", json!({"session":id})) => Some(result),
};
self.requests.end(connection, &handle).await;
if cancel.stopped.load(Ordering::SeqCst)
|| session.cancel.stopped.load(Ordering::SeqCst)
|| result.is_none()
{
if cancel.stopped.load(Ordering::SeqCst) || session.cancel.stopped.load(Ordering::SeqCst) || result.is_none() {
return Ok((1, Options::new()));
}
let result = result.unwrap().map_err(failed)?;
let zones: Vec<(u32, u32, i32, i32)> = serde_json::from_value(result["zones"].clone())
.map_err(|_| invalid("Invalid native capture zones"))?;
let zones: Vec<(u32, u32, i32, i32)> =
serde_json::from_value(result["zones"].clone()).map_err(|_| invalid("Invalid native capture zones"))?;
Ok((
0,
Options::from([
(
"zones".into(),
Value::from(zones)
.try_to_owned()
.map_err(|e| failed(e.to_string()))?,
Value::from(zones).try_to_owned().map_err(|e| failed(e.to_string()))?,
),
(
"zone_set".into(),
@@ -523,9 +485,7 @@ impl InputCapture {
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<(u32, Options)> {
let session = self
.session(connection, &header, &session_handle, &app_id)
.await?;
let session = self.session(connection, &header, &session_handle, &app_id).await?;
let (bridge, id) = Self::native(&session).await?;
if barriers.len() > 64 {
return Err(invalid("Too many pointer barriers"));
@@ -533,20 +493,15 @@ impl InputCapture {
let barriers = barriers
.into_iter()
.map(|barrier| {
let id = u32::try_from(
barrier
.get("barrier_id")
.ok_or_else(|| invalid("Missing barrier ID"))?,
)
let id = u32::try_from(barrier.get("barrier_id").ok_or_else(|| invalid("Missing barrier ID"))?)
.map_err(|_| invalid("Invalid barrier ID"))?;
let position = barrier
.get("position")
.ok_or_else(|| invalid("Missing barrier position"))?
.try_clone()
.map_err(|_| invalid("Invalid position"))?;
let position: (i32, i32, i32, i32) = position
.try_into()
.map_err(|_| invalid("Invalid barrier position"))?;
let position: (i32, i32, i32, i32) =
position.try_into().map_err(|_| invalid("Invalid barrier position"))?;
Ok(json!({"id":id,"position":[position.0,position.1,position.2,position.3]}))
})
.collect::<zbus::fdo::Result<Vec<_>>>()?;
@@ -558,10 +513,7 @@ impl InputCapture {
result = bridge.call("input-capture-barriers", json!({"session":id,"zone_set":zone_set,"barriers":barriers})) => Some(result),
};
self.requests.end(connection, &handle).await;
if cancel.stopped.load(Ordering::SeqCst)
|| session.cancel.stopped.load(Ordering::SeqCst)
|| result.is_none()
{
if cancel.stopped.load(Ordering::SeqCst) || session.cancel.stopped.load(Ordering::SeqCst) || result.is_none() {
self.end(connection, session_handle.as_str()).await;
return Ok((1, Options::new()));
}
@@ -588,9 +540,7 @@ impl InputCapture {
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<OwnedFd> {
let session = self
.session(connection, &header, &session_handle, &app_id)
.await?;
let session = self.session(connection, &header, &session_handle, &app_id).await?;
Self::native(&session).await?;
let mut state = session.state.lock().await;
if state.worker.is_some() {
@@ -599,8 +549,7 @@ impl InputCapture {
let capabilities = state.capabilities;
let keymap = state.keymap.clone();
let cancel = session.cancel.clone();
let (worker, fd) =
tokio::task::spawn_blocking(move || Worker::start(capabilities, &keymap, cancel))
let (worker, fd) = tokio::task::spawn_blocking(move || Worker::start(capabilities, &keymap, cancel))
.await
.map_err(|e| failed(e.to_string()))?
.map_err(failed)?;
@@ -616,9 +565,7 @@ impl InputCapture {
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<(u32, Options)> {
let session = self
.session(connection, &header, &session_handle, &app_id)
.await?;
let session = self.session(connection, &header, &session_handle, &app_id).await?;
let (bridge, id) = Self::native(&session).await?;
let ready = session
.state
@@ -630,9 +577,7 @@ impl InputCapture {
.ok_or_else(|| invalid("Connect to EIS before enabling input capture"))?;
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while !ready.load(Ordering::SeqCst) {
if session.cancel.stopped.load(Ordering::SeqCst)
|| tokio::time::Instant::now() >= deadline
{
if session.cancel.stopped.load(Ordering::SeqCst) || tokio::time::Instant::now() >= deadline {
return Err(invalid("EIS receiver has not bound an input device"));
}
tokio::time::sleep(Duration::from_millis(10)).await;
@@ -652,9 +597,7 @@ impl InputCapture {
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<(u32, Options)> {
let session = self
.session(connection, &header, &session_handle, &app_id)
.await?;
let session = self.session(connection, &header, &session_handle, &app_id).await?;
let (bridge, id) = Self::native(&session).await?;
bridge
.call("input-capture-disable", json!({"session":id}))
@@ -671,9 +614,7 @@ impl InputCapture {
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<(u32, Options)> {
let session = self
.session(connection, &header, &session_handle, &app_id)
.await?;
let session = self.session(connection, &header, &session_handle, &app_id).await?;
let (bridge, id) = Self::native(&session).await?;
let activation = options
.get("activation_id")
@@ -770,9 +711,7 @@ mod tests {
fn requested_capabilities_are_required_and_supported_subset_is_returned() {
assert!(capabilities(&Options::new()).is_err());
for requested in [0u32, 4, 8] {
assert!(
capabilities(&Options::from([("capabilities".into(), requested.into())])).is_err()
);
assert!(capabilities(&Options::from([("capabilities".into(), requested.into())])).is_err());
}
for (requested, supported) in [(1u32, 1), (2, 2), (3, 3), (7, 3), (9, 1)] {
assert_eq!(
+14 -14
View File
@@ -1,6 +1,13 @@
use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration};
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use zbus::{Connection, message::Header, object_server::SignalEmitter, zvariant::Value};
use zbus::Connection;
use zbus::message::Header;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::Value;
const PATH: &str = "/org/freedesktop/portal/desktop";
const KEYS: [&str; 7] = [
@@ -55,8 +62,7 @@ impl Lockdown {
connection: &Connection,
header: Option<Header<'_>>,
) -> zbus::fdo::Result<()> {
let header =
header.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing policy caller".into()))?;
let header = header.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing policy caller".into()))?;
crate::backend::authorize(connection, &header).await?;
let mut current = self.0.write().await;
store(index, value).map_err(zbus::fdo::Error::Failed)?;
@@ -209,8 +215,7 @@ fn store(index: usize, value: bool) -> Result<(), String> {
Err(error) => return Err(error.to_string()),
_ => return Err("Configuration is too large".into()),
};
let mut document =
ferese_config::Document::parse(&source).map_err(|error| error.to_string())?;
let mut document = ferese_config::Document::parse(&source).map_err(|error| error.to_string())?;
if values(&document).is_none() {
return Err("Invalid lockdown policy".into());
}
@@ -223,11 +228,8 @@ fn store(index: usize, value: bool) -> Result<(), String> {
let directory = path.parent().ok_or("Invalid config path")?;
std::fs::create_dir_all(directory).map_err(|error| error.to_string())?;
let mut file = tempfile::NamedTempFile::new_in(directory).map_err(|error| error.to_string())?;
std::io::Write::write_all(&mut file, document.to_string().as_bytes())
.map_err(|error| error.to_string())?;
file.as_file()
.sync_all()
.map_err(|error| error.to_string())?;
std::io::Write::write_all(&mut file, document.to_string().as_bytes()).map_err(|error| error.to_string())?;
file.as_file().sync_all().map_err(|error| error.to_string())?;
if std::fs::read_to_string(&path).unwrap_or_default() != source {
return Err("Configuration changed; please try again".into());
}
@@ -250,9 +252,7 @@ mod tests {
let values = values(&enabled).unwrap();
assert!(values[1] && values[4]);
assert!(!values[0]);
let invalid =
ferese_config::Document::parse("portals { lockdown { disable-camera \"yes\"; }; }")
.unwrap();
let invalid = ferese_config::Document::parse("portals { lockdown { disable-camera \"yes\"; }; }").unwrap();
assert!(super::values(&invalid).is_none());
for source in ["portals #false", "portals { lockdown \"bad\"; }"] {
let invalid = ferese_config::Document::parse(source).unwrap();
+4 -8
View File
@@ -19,18 +19,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
match args.as_slice() {
[flag, name, cursor] if flag == "--stream" => {
stream::run(name.clone(), cursor == "embedded", None)
}
[flag, name, cursor] if flag == "--stream" => stream::run(name.clone(), cursor == "embedded", None),
[flag, id, cursor] if flag == "--stream-window" => {
let id: u64 = id.parse()?;
stream::run(format!("window:{id}"), cursor == "embedded", None)
}
[flag, name, cursor, generation] if flag == "--stream" => stream::run(
name.clone(),
cursor == "embedded",
Some(generation.parse()?),
),
[flag, name, cursor, generation] if flag == "--stream" => {
stream::run(name.clone(), cursor == "embedded", Some(generation.parse()?))
}
[flag] if flag == "--sources" => {
let capture = capture::Capture::connect(&std::sync::atomic::AtomicBool::new(false))?;
println!("{}", serde_json::to_string(&capture.sources())?);
+12 -23
View File
@@ -1,12 +1,9 @@
use wayland_client::{
Connection, Dispatch, Proxy, QueueHandle, delegate_noop,
globals::{GlobalListContents, registry_queue_init},
protocol::{wl_registry, wl_surface},
};
use wayland_protocols::xdg::foreign::zv2::client::{
zxdg_imported_v2::{self, ZxdgImportedV2},
zxdg_importer_v2::ZxdgImporterV2,
};
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::{wl_registry, wl_surface};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
use wayland_protocols::xdg::foreign::zv2::client::zxdg_imported_v2;
use wayland_protocols::xdg::foreign::zv2::client::zxdg_imported_v2::ZxdgImportedV2;
use wayland_protocols::xdg::foreign::zv2::client::zxdg_importer_v2::ZxdgImporterV2;
#[derive(Debug)]
pub(crate) struct Parent {
@@ -16,10 +13,7 @@ pub(crate) struct Parent {
}
impl Parent {
pub(crate) fn attach(
window: &dyn cosmic::iced::window::Window,
parent: &str,
) -> Result<Option<Self>, String> {
pub(crate) fn attach(window: &dyn cosmic::iced::window::Window, parent: &str) -> Result<Option<Self>, String> {
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
if parent.is_empty() {
return Ok(None);
@@ -36,9 +30,8 @@ impl Parent {
return Err("Portal parenting requires Wayland".into());
};
// The window lends live handles; the borrowed backend never owns the display.
let backend = unsafe {
wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast())
};
let backend =
unsafe { wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) };
let connection = Connection::from_backend(backend);
let id = unsafe {
wayland_client::backend::ObjectId::from_ptr(
@@ -47,19 +40,15 @@ impl Parent {
)
}
.map_err(|error| error.to_string())?;
let surface =
wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
let (globals, mut queue) =
registry_queue_init::<State>(&connection).map_err(|error| error.to_string())?;
let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
let (globals, mut queue) = registry_queue_init::<State>(&connection).map_err(|error| error.to_string())?;
let qh = queue.handle();
let importer = globals
.bind::<ZxdgImporterV2, _, _>(&qh, 1..=1, ())
.map_err(|error| error.to_string())?;
let imported = importer.import_toplevel(handle.into(), &qh, ());
let mut state = State::default();
queue
.roundtrip(&mut state)
.map_err(|error| error.to_string())?;
queue.roundtrip(&mut state).map_err(|error| error.to_string())?;
if state.invalid {
imported.destroy();
importer.destroy();
+23 -65
View File
@@ -1,20 +1,16 @@
use crate::{
backend::{Options, authorize},
desktop::{Requests, consent, ipc, reply},
};
use std::{
collections::{HashMap, HashSet},
path::Path,
sync::Arc,
time::Duration,
};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use zbus::{
Connection,
message::Header,
object_server::SignalEmitter,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
use zbus::Connection;
use zbus::message::Header;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
use crate::backend::{Options, authorize};
use crate::desktop::{Requests, consent, ipc, reply};
const PATH: &str = "/org/freedesktop/portal/desktop";
@@ -56,19 +52,12 @@ fn usb_label(info: &Options, id: &str) -> String {
.and_then(|value| HashMap::<String, OwnedValue>::try_from(value).ok())
.unwrap_or_default();
let property = |keys: &[&str]| {
keys.iter().find_map(|key| {
properties
.get(*key)
.and_then(|value| <&str>::try_from(value).ok())
})
keys.iter()
.find_map(|key| properties.get(*key).and_then(|value| <&str>::try_from(value).ok()))
};
let vendor = property(&["ID_VENDOR_FROM_DATABASE", "ID_VENDOR", "ID_VENDOR_ID"]);
let model = property(&["ID_MODEL_FROM_DATABASE", "ID_MODEL", "ID_MODEL_ID"]);
let label = [vendor, model]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
let label = [vendor, model].into_iter().flatten().collect::<Vec<_>>().join(" ");
let label = if label.is_empty() { id } else { &label };
label
.chars()
@@ -102,27 +91,11 @@ async fn usb_consent(
description.push_str(&format!(
"\n{} — {}",
label,
if writable {
"read and write"
} else {
"read only"
}
));
selected.push((
id,
HashMap::from([("writable".to_owned(), OwnedValue::from(writable))]),
if writable { "read and write" } else { "read only" }
));
selected.push((id, HashMap::from([("writable".to_owned(), OwnedValue::from(writable))])));
}
if !consent(
app,
parent,
"USB device access",
&description,
"Allow access",
None,
)
.await?
{
if !consent(app, parent, "USB device access", &description, "Allow access", None).await? {
return Ok(None);
}
Ok(Some(HashMap::from([(
@@ -212,8 +185,7 @@ impl Background {
let directory = dirs::config_dir()
.ok_or_else(|| zbus::fdo::Error::Failed("Missing config directory".into()))?
.join("autostart");
set_autostart(&directory, &app_id, enable, &commandline, flags)
.map_err(zbus::fdo::Error::InvalidArgs)
set_autostart(&directory, &app_id, enable, &commandline, flags).map_err(zbus::fdo::Error::InvalidArgs)
}
#[zbus(signal)]
@@ -254,13 +226,7 @@ fn app_states(windows: &serde_json::Value) -> HashMap<String, u32> {
apps
}
fn set_autostart(
directory: &Path,
app: &str,
enable: bool,
argv: &[String],
flags: u32,
) -> Result<bool, String> {
fn set_autostart(directory: &Path, app: &str, enable: bool, argv: &[String], flags: u32) -> Result<bool, String> {
if app.is_empty()
|| app.len() > 255
|| !app
@@ -287,9 +253,7 @@ fn set_autostart(
}
return Ok(true);
}
if argv.len() > 128
|| (argv.first().is_none_or(|argument| argument.is_empty()) && flags & 1 == 0)
{
if argv.len() > 128 || (argv.first().is_none_or(|argument| argument.is_empty()) && flags & 1 == 0) {
return Err("Invalid autostart command".into());
}
let exec = argv
@@ -377,15 +341,9 @@ mod tests {
"ID_VENDOR_FROM_DATABASE".to_owned(),
Value::from("Example Vendor").try_to_owned().unwrap(),
),
(
"ID_MODEL".to_owned(),
Value::from("Gamepad").try_to_owned().unwrap(),
),
("ID_MODEL".to_owned(), Value::from("Gamepad").try_to_owned().unwrap()),
]);
let info = HashMap::from([(
"properties".into(),
Value::from(properties).try_to_owned().unwrap(),
)]);
let info = HashMap::from([("properties".into(), Value::from(properties).try_to_owned().unwrap())]);
assert_eq!(usb_label(&info, "opaque-id"), "Example Vendor Gamepad");
assert_eq!(usb_label(&Options::new(), "fallback-id"), "fallback-id");
}
+27 -63
View File
@@ -1,13 +1,13 @@
use crate::capture::Source;
use cosmic::{
ApplicationExt, Element,
app::{Core, Settings, Task},
iced::{Alignment, Length},
widget::{button, column, container, row, scrollable},
};
use std::io::{Read, Write};
use cosmic::app::{Core, Settings, Task};
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{button, column, container, row, scrollable};
use cosmic::{ApplicationExt, Element};
use ferese_theme::icons::symbolic as glyph;
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use crate::capture::Source;
#[derive(Clone, Serialize, Deserialize)]
pub struct Prompt {
@@ -82,9 +82,7 @@ struct Picker {
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new();
std::io::stdin()
.take(256 * 1024 + 1)
.read_to_string(&mut input)?;
std::io::stdin().take(256 * 1024 + 1).read_to_string(&mut input)?;
if input.len() > 256 * 1024 {
return Err("Picker request is too large".into());
}
@@ -95,8 +93,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
}
let (theme, font, background, palette) = appearance();
let height =
180. + prompt.source_list_height() + if prompt.persist_mode > 0 { 64. } else { 0. };
let height = 180. + prompt.source_list_height() + if prompt.persist_mode > 0 { 64. } else { 0. };
cosmic::app::run::<Picker>(
Settings::default()
.size(cosmic::iced::Size::new(400., height))
@@ -114,12 +111,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
impl cosmic::Application for Picker {
type Executor = cosmic::executor::Default;
type Flags = (
Prompt,
cosmic::iced::Color,
ferese_theme::Palette,
cosmic::font::Font,
);
type Flags = (Prompt, cosmic::iced::Color, ferese_theme::Palette, cosmic::font::Font);
type Message = Message;
const APP_ID: &'static str = "dev.ferese.ScreenShare";
@@ -131,10 +123,7 @@ impl cosmic::Application for Picker {
&mut self.core
}
fn init(
mut core: Core,
(prompt, background, palette, font): Self::Flags,
) -> (Self, Task<Message>) {
fn init(mut core: Core, (prompt, background, palette, font): Self::Flags) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
core.window.border_padding = Some(0);
core.window.content_container = false;
@@ -171,9 +160,7 @@ impl cosmic::Application for Picker {
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
Some(Message::WindowOpened(id))
}
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => Some(Message::WindowOpened(id)),
_ => None,
})
}
@@ -183,12 +170,8 @@ impl cosmic::Application for Picker {
Message::WindowOpened(id) => {
let parent = self.prompt.parent.clone();
return cosmic::iced::window::run(id, move |window| {
let parent =
crate::parent::Parent::attach(window, &parent)?.map(std::sync::Arc::new);
Ok((
parent,
ferese_theme::material::ModalMaterial::attach(window),
))
let parent = crate::parent::Parent::attach(window, &parent)?.map(std::sync::Arc::new);
Ok((parent, ferese_theme::material::ModalMaterial::attach(window)))
})
.map(|result| cosmic::Action::App(Message::Attached(result)));
}
@@ -216,11 +199,7 @@ impl cosmic::Application for Picker {
}
Message::Remember if self.can_remember() => self.remember = !self.remember,
Message::Share if !self.selected.is_empty() => {
let names: Vec<_> = self
.selected
.iter()
.map(|i| &self.prompt.sources[*i].name)
.collect();
let names: Vec<_> = self.selected.iter().map(|i| &self.prompt.sources[*i].name).collect();
if self.prompt.window_capture {
println!("{}", serde_json::to_string(&names).unwrap());
} else {
@@ -288,26 +267,20 @@ impl cosmic::Application for Picker {
for (index, source) in self.prompt.sources.iter().enumerate() {
let is_window = self.prompt.window_capture || source.window_id().is_some();
if !self.prompt.window_capture && previous_kind != Some(is_window) {
sources = sources.push(
self.text(if is_window { "Windows" } else { "Displays" })
.size(12),
);
sources = sources.push(self.text(if is_window { "Windows" } else { "Displays" }).size(12));
previous_kind = Some(is_window);
}
let selected = self.selected.contains(&index);
let mut entry = row![
glyph(ferese_theme::icons::DISPLAY, 24),
column![
self.text(
if self.prompt.window_capture || source.window_id().is_some() {
self.text(if self.prompt.window_capture || source.window_id().is_some() {
&source.label
} else {
&source.name
}
)
})
.size(14),
self.text(format!("{} × {}", source.width, source.height))
.size(12)
self.text(format!("{} × {}", source.width, source.height)).size(12)
]
.spacing(3)
.width(Length::Fill),
@@ -329,8 +302,7 @@ impl cosmic::Application for Picker {
.on_press(Message::Select(index)),
);
}
content = content
.push(scrollable(sources).height(Length::Fixed(self.prompt.source_list_height())));
content = content.push(scrollable(sources).height(Length::Fixed(self.prompt.source_list_height())));
content = content.push(
self.text(if self.prompt.window_capture {
"Only the selected window will be captured."
@@ -354,21 +326,14 @@ impl cosmic::Application for Picker {
ferese_theme::menus::switch(self.remember, self.palette, 1.),
self.font,
))
.class(ferese_theme::controls::navigation_style(
self.palette,
false,
))
.class(ferese_theme::controls::navigation_style(self.palette, false))
.width(Length::Fill)
.on_press_maybe(self.can_remember().then_some(Message::Remember)),
);
}
let mut share = button::custom(
row![
self.text(if self.prompt.window_capture {
"Capture"
} else {
"Share"
}),
self.text(if self.prompt.window_capture { "Capture" } else { "Share" }),
glyph(ferese_theme::icons::ARROW, 16)
]
.spacing(8)
@@ -396,11 +361,10 @@ impl Picker {
fn can_remember(&self) -> bool {
!self.selected.is_empty()
&& self.selected.len() <= 16
&& self.selected.iter().all(|index| {
self.prompt
.rememberable
.contains(&self.prompt.sources[*index].name)
})
&& self
.selected
.iter()
.all(|index| self.prompt.rememberable.contains(&self.prompt.sources[*index].name))
}
fn text<'a>(
+3 -5
View File
@@ -3,11 +3,9 @@
mod recorder;
fn main() {
if let Err(error) = tokio::runtime::Runtime::new().and_then(|runtime| {
runtime
.block_on(recorder::run())
.map_err(std::io::Error::other)
}) {
if let Err(error) = tokio::runtime::Runtime::new()
.and_then(|runtime| runtime.block_on(recorder::run()).map_err(std::io::Error::other))
{
recorder::event("error", Some(&error.to_string()), None);
std::process::exit(1);
}
+33 -95
View File
@@ -1,22 +1,19 @@
use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::fd::AsRawFd;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use gstreamer::{self as gst, prelude::*};
use std::{
collections::HashMap,
fs::{File, OpenOptions},
io::{Read, Write},
os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use gstreamer as gst;
use gstreamer::prelude::*;
use tokio::sync::watch;
use zbus::{
Connection, Proxy,
zvariant::{OwnedFd, OwnedObjectPath, OwnedValue, Value},
};
use zbus::zvariant::{OwnedFd, OwnedObjectPath, OwnedValue, Value};
use zbus::{Connection, Proxy};
type Options = HashMap<String, OwnedValue>;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
@@ -38,30 +35,13 @@ async fn request<B: serde::Serialize + zbus::zvariant::DynamicType>(
body: &B,
stop: &mut watch::Receiver<bool>,
) -> Result<Option<Options>, String> {
let sender = connection
.unique_name()
.ok_or("No D-Bus identity")?
.as_str()[1..]
.replace('.', "_");
let sender = connection.unique_name().ok_or("No D-Bus identity")?.as_str()[1..].replace('.', "_");
let path = format!("{DESKTOP_PATH}/request/{sender}/{token}");
let request = Proxy::new(
connection,
DESKTOP,
path.as_str(),
"org.freedesktop.portal.Request",
)
let request = Proxy::new(connection, DESKTOP, path.as_str(), "org.freedesktop.portal.Request")
.await
.map_err(|e| e.to_string())?;
let mut responses = request
.receive_signal("Response")
.await
.map_err(|e| e.to_string())?;
let portal = Proxy::new(
connection,
DESKTOP,
DESKTOP_PATH,
"org.freedesktop.portal.ScreenCast",
)
let mut responses = request.receive_signal("Response").await.map_err(|e| e.to_string())?;
let portal = Proxy::new(connection, DESKTOP, DESKTOP_PATH, "org.freedesktop.portal.ScreenCast")
.await
.map_err(|e| e.to_string())?;
let returned: OwnedObjectPath = portal
@@ -90,14 +70,7 @@ async fn request<B: serde::Serialize + zbus::zvariant::DynamicType>(
pub async fn run() -> Result<(), String> {
gst::init().map_err(|e| e.to_string())?;
for factory in [
"pipewiresrc",
"queue",
"videoconvert",
"vp8enc",
"webmmux",
"fdsink",
] {
for factory in ["pipewiresrc", "queue", "videoconvert", "vp8enc", "webmmux", "fdsink"] {
if gst::ElementFactory::find(factory).is_none() {
return Err(format!(
"Recording needs the GStreamer {factory} plugin. See the recording installation guide."
@@ -127,13 +100,10 @@ pub async fn run() -> Result<(), String> {
event("cancelled", None, None);
return Ok(());
};
let handle = created
.get("session_handle")
.ok_or("Portal returned no session")?;
let handle = created.get("session_handle").ok_or("Portal returned no session")?;
// The public portal historically returns this path as a D-Bus string.
let handle = OwnedObjectPath::try_from(
String::try_from(handle.try_clone().map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?,
String::try_from(handle.try_clone().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
// Older/foreign backends keep their normal sharing indicator. Only Ferese
@@ -146,17 +116,10 @@ pub async fn run() -> Result<(), String> {
"org.ferese.ScreenRecorder",
)
.await?;
control
.call::<_, _, ()>("UseBarControls", &(handle.clone(),))
.await
control.call::<_, _, ()>("UseBarControls", &(handle.clone(),)).await
})
.await;
let session = Proxy::new(
&connection,
DESKTOP,
handle.as_str(),
"org.freedesktop.portal.Session",
)
let session = Proxy::new(&connection, DESKTOP, handle.as_str(), "org.freedesktop.portal.Session")
.await
.map_err(|e| e.to_string())?;
let result = record_session(&connection, &session, &handle, &token, &output, &mut stop).await;
@@ -176,10 +139,7 @@ async fn record_session(
output: &Path,
stop: &mut watch::Receiver<bool>,
) -> Result<Option<PathBuf>, String> {
let mut closed = session
.receive_signal("Closed")
.await
.map_err(|e| e.to_string())?;
let mut closed = session.receive_signal("Closed").await.map_err(|e| e.to_string())?;
if request(
connection,
"SelectSources",
@@ -205,11 +165,7 @@ async fn record_session(
connection,
"Start",
&token(2),
&(
handle,
"",
HashMap::from([("handle_token", option_string(token(2)))]),
),
&(handle, "", HashMap::from([("handle_token", option_string(token(2)))])),
stop,
)
.await?
@@ -226,12 +182,7 @@ async fn record_session(
if streams.len() != 1 {
return Err("Recording requires exactly one display".into());
}
let portal = Proxy::new(
connection,
DESKTOP,
DESKTOP_PATH,
"org.freedesktop.portal.ScreenCast",
)
let portal = Proxy::new(connection, DESKTOP, DESKTOP_PATH, "org.freedesktop.portal.ScreenCast")
.await
.map_err(|e| e.to_string())?;
let remote: OwnedFd = portal
@@ -278,8 +229,7 @@ fn output_directory() -> Result<PathBuf, String> {
}
}
fn recording_file(directory: &Path) -> Result<(File, PathBuf, PathBuf), String> {
std::fs::create_dir_all(directory)
.map_err(|e| format!("Cannot create recording folder: {e}"))?;
std::fs::create_dir_all(directory).map_err(|e| format!("Cannot create recording folder: {e}"))?;
let name = format!(
"Recording-{}-{}",
jiff::Zoned::now().strftime("%Y-%m-%d_%H-%M-%S"),
@@ -338,24 +288,19 @@ fn encode(source: gst::Element, output: &Path, stop: Arc<AtomicBool>) -> Result<
pipeline
.add_many([&source, &queue, &convert, &encoder, &mux, &sink])
.map_err(|e| e.to_string())?;
gst::Element::link_many([&source, &queue, &convert, &encoder, &mux, &sink])
.map_err(|e| e.to_string())?;
gst::Element::link_many([&source, &queue, &convert, &encoder, &mux, &sink]).map_err(|e| e.to_string())?;
let announced = Arc::new(AtomicBool::new(false));
let notice = announced.clone();
source
.static_pad("src")
.ok_or("Capture has no video output")?
.add_probe(gst::PadProbeType::BUFFER, move |_, info| {
if info.buffer().is_some_and(|b| b.size() > 0)
&& !notice.swap(true, Ordering::Relaxed)
{
if info.buffer().is_some_and(|b| b.size() > 0) && !notice.swap(true, Ordering::Relaxed) {
event("recording", None, None);
}
gst::PadProbeReturn::Ok
});
pipeline
.set_state(gst::State::Playing)
.map_err(|e| e.to_string())?;
pipeline.set_state(gst::State::Playing).map_err(|e| e.to_string())?;
let bus = pipeline.bus().ok_or("Encoder has no message bus")?;
let mut finishing = None;
let started = Instant::now();
@@ -404,8 +349,7 @@ fn encode(source: gst::Element, output: &Path, stop: Arc<AtomicBool>) -> Result<
result.map_err(|e| format!("{e}. Incomplete file: {}", partial.display()))?;
file.sync_all()
.map_err(|e| format!("Cannot finish saving {}: {e}", partial.display()))?;
std::fs::rename(&partial, &destination)
.map_err(|e| format!("Cannot finish saving {}: {e}", partial.display()))?;
std::fs::rename(&partial, &destination).map_err(|e| format!("Cannot finish saving {}: {e}", partial.display()))?;
Ok(destination)
}
@@ -419,10 +363,7 @@ mod tests {
let (_, second, _) = recording_file(directory.path()).unwrap();
assert_ne!(first, second);
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(first).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(std::fs::metadata(first).unwrap().permissions().mode() & 0o777, 0o600);
}
#[test]
#[ignore = "requires an explicitly provided temporary PipeWire test node"]
@@ -493,10 +434,7 @@ mod tests {
"Invalid saved duration: {duration:?}"
);
assert!(
matches!(
message.as_ref().map(|m| m.view()),
Some(gst::MessageView::Eos(_))
),
matches!(message.as_ref().map(|m| m.view()), Some(gst::MessageView::Eos(_))),
"Saved video did not decode: {message:?}"
);
}
+23 -58
View File
@@ -1,8 +1,10 @@
use crate::{backend::Options, capture::Source};
use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
use zbus::zvariant::{OwnedValue, Value};
use crate::backend::Options;
use crate::capture::Source;
const VENDOR: &str = "Ferese";
const VERSION: u32 = 1;
const MAX_DATA: usize = 16 * 1024;
@@ -31,9 +33,7 @@ pub(crate) fn persist_mode(options: &Options) -> zbus::fdo::Result<u32> {
.map_err(|_| zbus::fdo::Error::InvalidArgs("Invalid persistence option".into()))?
.unwrap_or(0);
if mode > 2 {
return Err(zbus::fdo::Error::InvalidArgs(
"Unsupported persistence mode".into(),
));
return Err(zbus::fdo::Error::InvalidArgs("Unsupported persistence mode".into()));
}
Ok(mode)
}
@@ -43,9 +43,7 @@ fn identity<'a>(outputs: &'a Json, name: &str) -> Option<&'a str> {
(output["name"].as_str() == Some(name) && output["enabled"] == true)
.then(|| output["identity"].as_str())
.flatten()
.filter(|identity| {
!identity.is_empty() && identity.len() <= 512 && identity.starts_with("drm-edid:")
})
.filter(|identity| !identity.is_empty() && identity.len() <= 512 && identity.starts_with("drm-edid:"))
})
}
@@ -54,14 +52,15 @@ pub(crate) fn selection_unchanged(sources: &[Source], before: &Json, after: &Jso
outputs
.as_array()
.and_then(|outputs| {
outputs.iter().find(|output| {
output["name"].as_str() == Some(name) && output["enabled"] == true
})
outputs
.iter()
.find(|output| output["name"].as_str() == Some(name) && output["enabled"] == true)
})
.map(|output| (output["identity"].clone(), output["id"].clone()))
};
sources.iter().all(
|source| match (find(before, &source.name), find(after, &source.name)) {
sources
.iter()
.all(|source| match (find(before, &source.name), find(after, &source.name)) {
(Some((identity, id)), Some((current_identity, current_id))) => {
if identity.is_null() {
id.as_u64().is_some() && id == current_id && current_identity.is_null()
@@ -70,8 +69,7 @@ pub(crate) fn selection_unchanged(sources: &[Source], before: &Json, after: &Jso
}
}
_ => false,
},
)
})
}
impl Restore {
@@ -125,29 +123,13 @@ impl Restore {
{
return None;
}
selected.push(
sources
.iter()
.find(|source| source.name == monitor.name)?
.clone(),
);
selected.push(sources.iter().find(|source| source.name == monitor.name)?.clone());
}
Some(selected)
}
pub(crate) fn create(
app: &str,
cursor: bool,
sources: &[Source],
outputs: &Json,
mode: u32,
) -> Option<Self> {
if app.is_empty()
|| app.len() > 512
|| sources.is_empty()
|| sources.len() > 16
|| !(1..=2).contains(&mode)
{
pub(crate) fn create(app: &str, cursor: bool, sources: &[Source], outputs: &Json, mode: u32) -> Option<Self> {
if app.is_empty() || app.len() > 512 || sources.is_empty() || sources.len() > 16 || !(1..=2).contains(&mode) {
return None;
}
let monitors = sources
@@ -196,8 +178,7 @@ mod tests {
#[test]
fn restore_is_bound_to_app_capture_options_and_display_identity() {
let outputs =
serde_json::json!([{"name":"DP-1", "identity":"drm-edid:123", "enabled":true}]);
let outputs = serde_json::json!([{"name":"DP-1", "identity":"drm-edid:123", "enabled":true}]);
let sources = [source()];
let saved = Restore::create("org.test.App", true, &sources, &outputs, 2).unwrap();
let options = Options::from([("restore_data".into(), saved.value().unwrap())]);
@@ -217,20 +198,14 @@ mod tests {
.resolve("org.test.App", false, false, &sources, &outputs)
.is_none()
);
assert!(
restored
.resolve("org.test.App", true, false, &[], &outputs)
.is_none()
);
let changed =
serde_json::json!([{"name":"DP-1", "identity":"drm-edid:456", "enabled":true}]);
assert!(restored.resolve("org.test.App", true, false, &[], &outputs).is_none());
let changed = serde_json::json!([{"name":"DP-1", "identity":"drm-edid:456", "enabled":true}]);
assert!(
restored
.resolve("org.test.App", true, false, &sources, &changed)
.is_none()
);
let disabled =
serde_json::json!([{"name":"DP-1", "identity":"drm-edid:123", "enabled":false}]);
let disabled = serde_json::json!([{"name":"DP-1", "identity":"drm-edid:123", "enabled":false}]);
assert!(
restored
.resolve("org.test.App", true, false, &sources, &disabled)
@@ -240,14 +215,9 @@ mod tests {
#[test]
fn transient_permission_cannot_be_upgraded_without_new_consent() {
let outputs =
serde_json::json!([{"name":"DP-1", "identity":"drm-edid:123", "enabled":true}]);
let outputs = serde_json::json!([{"name":"DP-1", "identity":"drm-edid:123", "enabled":true}]);
let saved = Restore::create("org.test.App", false, &[source()], &outputs, 1).unwrap();
let restored = Restore::read(&Options::from([(
"restore_data".into(),
saved.value().unwrap(),
)]))
.unwrap();
let restored = Restore::read(&Options::from([("restore_data".into(), saved.value().unwrap())])).unwrap();
assert_eq!(restored.effective_mode(2), 1);
assert_eq!(restored.effective_mode(0), 0);
let weak = serde_json::json!([{"name":"DP-1", "identity":"drm:DP-1", "enabled":true}]);
@@ -259,8 +229,7 @@ mod tests {
fn anonymous_nested_selection_has_lifetime_checks_without_saved_grants() {
let outputs = serde_json::json!([{"name":"DP-1", "id":1, "identity":null, "enabled":true}]);
assert!(selection_unchanged(&[source()], &outputs, &outputs));
let replacement =
serde_json::json!([{"name":"DP-1", "id":2, "identity":null, "enabled":true}]);
let replacement = serde_json::json!([{"name":"DP-1", "id":2, "identity":null, "enabled":true}]);
assert!(!selection_unchanged(&[source()], &outputs, &replacement));
assert!(Restore::create("org.test.App", false, &[source()], &outputs, 2).is_none());
}
@@ -288,11 +257,7 @@ mod tests {
.is_none()
);
saved.monitors[1] = saved.monitors[0].clone();
assert!(
saved
.resolve("org.test.App", false, true, &sources, &outputs)
.is_none()
);
assert!(saved.resolve("org.test.App", false, true, &sources, &outputs).is_none());
}
#[test]
+13 -17
View File
@@ -1,12 +1,13 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use ferese_config::Document;
use ferese_theme::Palette;
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::RwLock;
use zbus::{
Connection,
object_server::SignalEmitter,
zvariant::{OwnedValue, Value},
};
use zbus::Connection;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::{OwnedValue, Value};
const APPEARANCE: &str = "org.freedesktop.appearance";
const PATH: &str = "/org/freedesktop/portal/desktop";
@@ -50,10 +51,7 @@ impl Appearance {
fn values(self) -> HashMap<String, OwnedValue> {
HashMap::from([
("color-scheme".into(), self.scheme.into()),
(
"accent-color".into(),
Value::from(self.accent).try_to_owned().unwrap(),
),
("accent-color".into(), Value::from(self.accent).try_to_owned().unwrap()),
("contrast".into(), 0u32.into()),
("reduced-motion".into(), self.reduced_motion.into()),
])
@@ -156,12 +154,7 @@ mod tests {
#[test]
fn namespaces_support_only_trailing_section_wildcards() {
for filter in [
"",
"org.freedesktop.appearance",
"org.freedesktop.*",
"org.*",
] {
for filter in ["", "org.freedesktop.appearance", "org.freedesktop.*", "org.*"] {
assert!(matches_namespace(&[filter.into()], APPEARANCE));
}
for filter in [
@@ -177,7 +170,10 @@ mod tests {
#[test]
fn reports_ferese_theme_and_reduced_motion() {
let doc = Document::parse("theme { colors { surface-base \"#ffffff\"; accent \"#ff8000\"; }; }; animations { enabled #false; }").unwrap();
let doc = Document::parse(
"theme { colors { surface-base \"#ffffff\"; accent \"#ff8000\"; }; }; animations { enabled #false; }",
)
.unwrap();
let appearance = Appearance::from_document(Some(&doc));
assert_eq!(appearance.scheme, 2);
assert_eq!(appearance.reduced_motion, 1);
+31 -76
View File
@@ -1,20 +1,21 @@
use std::{collections::HashMap, io::Read, process::Stdio, sync::Arc, time::Duration};
use std::collections::HashMap;
use std::io::Read;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use serde_json::json;
use tokio::{io::AsyncWriteExt, sync::Mutex};
use zbus::{
Connection,
message::Header,
object_server::SignalEmitter,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use zbus::Connection;
use zbus::message::Header;
use zbus::object_server::SignalEmitter;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
use crate::{
backend::{Cancel, Options, authorize},
bridge::Bridge,
consent::{Prompt, ShortcutField},
desktop::Requests,
};
use crate::backend::{Cancel, Options, authorize};
use crate::bridge::Bridge;
use crate::consent::{Prompt, ShortcutField};
use crate::desktop::Requests;
const PATH: &str = "/org/freedesktop/portal/desktop";
type Shortcuts = Vec<(String, Options)>;
@@ -88,10 +89,7 @@ impl GlobalShortcuts {
if let Ok(emitter) = SignalEmitter::new(connection, path) {
let _ = SessionObject::closed(&emitter).await;
}
let _ = connection
.object_server()
.remove::<SessionObject, _>(path)
.await;
let _ = connection.object_server().remove::<SessionObject, _>(path).await;
}
}
@@ -134,25 +132,14 @@ impl GlobalShortcuts {
.filter(|field| !field.trigger.trim().is_empty())
.map(|field| json!({"id":field.id,"trigger":field.trigger.trim()}))
.collect::<Vec<_>>();
match bridge
.call("portal-shortcuts-register", json!(registration))
.await
{
match bridge.call("portal-shortcuts-register", json!(registration)).await {
Ok(_) => {
let mut state = session.state.lock().await;
if session
.cancel
.stopped
.load(std::sync::atomic::Ordering::SeqCst)
if session.cancel.stopped.load(std::sync::atomic::Ordering::SeqCst)
|| cancel.stopped.load(std::sync::atomic::Ordering::SeqCst)
{
if first
|| session
.cancel
.stopped
.load(std::sync::atomic::Ordering::SeqCst)
{
if first || session.cancel.stopped.load(std::sync::atomic::Ordering::SeqCst) {
bridge.close();
} else {
let previous = previous
@@ -161,11 +148,7 @@ impl GlobalShortcuts {
.map(|field| json!({"id":field.id,"trigger":field.trigger}))
.collect::<Vec<_>>();
if bridge
.call("portal-shortcuts-register", json!(previous))
.await
.is_err()
{
if bridge.call("portal-shortcuts-register", json!(previous)).await.is_err() {
bridge.close();
}
}
@@ -202,13 +185,7 @@ impl GlobalShortcuts {
}
}
async fn watch(
&self,
connection: Connection,
path: OwnedObjectPath,
session: Arc<Session>,
bridge: Bridge,
) {
async fn watch(&self, connection: Connection, path: OwnedObjectPath, session: Arc<Session>, bridge: Bridge) {
let emitter = SignalEmitter::new(&connection, PATH).unwrap();
loop {
tokio::select! {
@@ -221,11 +198,7 @@ impl GlobalShortcuts {
_ => break,
};
let mut state = session.state.lock().await;
if session
.cancel
.stopped
.load(std::sync::atomic::Ordering::SeqCst)
{
if session.cancel.stopped.load(std::sync::atomic::Ordering::SeqCst) {
break;
}
if let Some(active) = value["shortcuts"]
@@ -234,16 +207,13 @@ impl GlobalShortcuts {
{
let mut changed = false;
for field in &mut state.fields {
if !field.trigger.is_empty()
&& !active.iter().any(|item| item["id"] == field.id)
{
if !field.trigger.is_empty() && !active.iter().any(|item| item["id"] == field.id) {
field.trigger.clear();
changed = true;
}
}
if changed {
let _ = Self::shortcuts_changed(&emitter, path.clone(), results(&state.fields))
.await;
let _ = Self::shortcuts_changed(&emitter, path.clone(), results(&state.fields)).await;
}
}
for event in value["events"].as_array().into_iter().flatten() {
@@ -252,12 +222,9 @@ impl GlobalShortcuts {
};
let timestamp = event["timestamp"].as_u64().unwrap_or(0);
if event["active"] == true {
let _ = Self::activated(&emitter, path.clone(), id, timestamp, Options::new())
.await;
let _ = Self::activated(&emitter, path.clone(), id, timestamp, Options::new()).await;
} else {
let _ =
Self::deactivated(&emitter, path.clone(), id, timestamp, Options::new())
.await;
let _ = Self::deactivated(&emitter, path.clone(), id, timestamp, Options::new()).await;
}
}
}
@@ -282,15 +249,11 @@ impl GlobalShortcuts {
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<(u32, Options)> {
let owner = authorize(connection, &header).await?;
if !session_handle
.as_str()
.starts_with(&format!("{PATH}/session/"))
if !session_handle.as_str().starts_with(&format!("{PATH}/session/"))
|| session_handle.as_str().len() > 512
|| app_id.len() > 512
{
return Err(zbus::fdo::Error::InvalidArgs(
"Invalid shortcut session".into(),
));
return Err(zbus::fdo::Error::InvalidArgs("Invalid shortcut session".into()));
}
let cancel = self.requests.begin(connection, &header, &handle).await?;
let mut sessions = self.sessions.lock().await;
@@ -500,9 +463,7 @@ impl SessionObject {
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
if header.sender().map(|sender| sender.as_str()) != Some(self.owner.as_str()) {
return Err(zbus::fdo::Error::AccessDenied(
"Not your shortcut session".into(),
));
return Err(zbus::fdo::Error::AccessDenied("Not your shortcut session".into()));
}
self.backend.end(connection, &self.path).await;
Ok(())
@@ -547,10 +508,7 @@ fn fields(shortcuts: Shortcuts) -> Result<Vec<ShortcutField>, String> {
Ok(fields)
}
fn validate_selection(
requested: &[ShortcutField],
selected: &[ShortcutField],
) -> Result<(), String> {
fn validate_selection(requested: &[ShortcutField], selected: &[ShortcutField]) -> Result<(), String> {
if selected.len() != requested.len()
|| selected.iter().zip(requested).any(|(chosen, original)| {
chosen.id != original.id
@@ -590,10 +548,7 @@ fn reply(response: Result<Option<Shortcuts>, String>) -> (u32, Options) {
match response {
Ok(Some(shortcuts)) => (
0,
HashMap::from([(
"shortcuts".into(),
Value::from(shortcuts).try_into().unwrap(),
)]),
HashMap::from([("shortcuts".into(), Value::from(shortcuts).try_into().unwrap())]),
),
Ok(None) => (1, Options::new()),
Err(error) => {
+21 -46
View File
@@ -1,17 +1,17 @@
//! PipeWire runs outside the compositor. Keep one frame, drop old frames under
//! backpressure, and destroy the node when the portal's control pipe closes.
use crate::capture::{Capture, Frame};
use pipewire::{self as pw, properties::properties, spa};
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use pipewire as pw;
use pipewire::properties::properties;
use pipewire::spa;
use serde::{Deserialize, Serialize};
use spa::pod::Pod;
use std::{
io::{Read, Write},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use crate::capture::{Capture, Frame};
#[derive(Debug, Serialize, Deserialize)]
pub struct Ready {
@@ -70,10 +70,7 @@ impl<'a> OutputBuffer<'a> {
)
.cast::<spa::sys::spa_meta_header>();
if let Some(header) = header.as_mut() {
let mut now = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
let mut now = libc::timespec { tv_sec: 0, tv_nsec: 0 };
header.pts = if libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) == 0 {
now.tv_sec * 1_000_000_000 + now.tv_nsec
} else {
@@ -104,10 +101,7 @@ fn int_property(key: u32, value: i32) -> spa::pod::Property {
}
}
fn pod(object: spa::pod::Object) -> Vec<u8> {
spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(object),
)
spa::pod::serialize::PodSerializer::serialize(std::io::Cursor::new(Vec::new()), &spa::pod::Value::Object(object))
.unwrap()
.0
.into_inner()
@@ -145,11 +139,7 @@ fn video_format(width: u32, height: u32) -> Vec<u8> {
))
}
pub fn run(
name: String,
cursor: bool,
generation: Option<u32>,
) -> Result<(), Box<dyn std::error::Error>> {
pub fn run(name: String, cursor: bool, generation: Option<u32>) -> Result<(), Box<dyn std::error::Error>> {
let stop = Arc::new(AtomicBool::new(false));
let pipe_stop = stop.clone();
std::thread::spawn(move || {
@@ -213,14 +203,10 @@ pub fn run(
.add_local_listener_with_user_data(data)
.state_changed(move |stream, data, _, state| {
data.fresh.store(false, Ordering::SeqCst);
data.streaming.store(
matches!(state, pw::stream::StreamState::Streaming),
Ordering::Relaxed,
);
data.streaming
.store(matches!(state, pw::stream::StreamState::Streaming), Ordering::Relaxed);
match state {
pw::stream::StreamState::Paused | pw::stream::StreamState::Streaming
if !data.announced =>
{
pw::stream::StreamState::Paused | pw::stream::StreamState::Streaming if !data.announced => {
let node = stream.node_id();
if node != u32::MAX {
data.announced = true;
@@ -257,8 +243,7 @@ pub fn run(
|| format.size().width == 0
|| format.size().height == 0
|| (!resizable && (format.size().width, format.size().height) != (width, height))
|| u64::from(format.size().width) * u64::from(format.size().height) * 4
> 128 * 1024 * 1024
|| u64::from(format.size().width) * u64::from(format.size().height) * 4 > 128 * 1024 * 1024
|| format.format() != spa::param::video::VideoFormat::BGRx
{
data.stop.store(true, Ordering::Relaxed);
@@ -271,10 +256,7 @@ pub fn run(
spa::param::ParamType::Buffers,
int_property(spa::sys::SPA_PARAM_BUFFERS_buffers, 4),
int_property(spa::sys::SPA_PARAM_BUFFERS_blocks, 1),
int_property(
spa::sys::SPA_PARAM_BUFFERS_size,
(width * height * 4) as i32
),
int_property(spa::sys::SPA_PARAM_BUFFERS_size, (width * height * 4) as i32),
int_property(spa::sys::SPA_PARAM_BUFFERS_stride, (width * 4) as i32),
int_property(spa::sys::SPA_PARAM_BUFFERS_align, 16)
));
@@ -292,10 +274,7 @@ pub fn run(
)
));
if stream
.update_params(&mut [
Pod::from_bytes(&buffers).unwrap(),
Pod::from_bytes(&header).unwrap(),
])
.update_params(&mut [Pod::from_bytes(&buffers).unwrap(), Pod::from_bytes(&header).unwrap()])
.is_err()
{
data.stop.store(true, Ordering::Relaxed);
@@ -358,9 +337,7 @@ pub fn run(
while !capture_stop.load(Ordering::Relaxed) {
let start = Instant::now();
if capture_streaming.load(Ordering::Relaxed)
|| checked.elapsed() >= Duration::from_secs(1)
{
if capture_streaming.load(Ordering::Relaxed) || checked.elapsed() >= Duration::from_secs(1) {
checked = Instant::now();
match capture.frame(&name, cursor, &capture_stop, std::mem::take(&mut spare)) {
Ok(frame) if resizable || (frame.width == width && frame.height == height) => {
@@ -407,9 +384,7 @@ pub fn run(
} else {
Duration::from_millis(10)
};
mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(timeout));
mainloop.loop_().iterate(pw::loop_::Timeout::Finite(timeout));
if streaming.load(Ordering::Relaxed) && Instant::now() >= next_frame {
let _ = stream.trigger_process();
next_frame = Instant::now() + interval;
+4
View File
@@ -0,0 +1,4 @@
max_width = 120
imports_granularity = "Module"
group_imports = "StdExternalCrate"
reorder_imports = true
+7 -14
View File
@@ -1,4 +1,5 @@
use cosmic::{iced::Color, widget::image};
use cosmic::iced::Color;
use cosmic::widget::image;
use ferese_config::Document;
#[derive(Clone)]
@@ -89,11 +90,7 @@ impl Appearance {
}
pub fn clock_format(&self) -> &'static str {
if self.twelve_hour {
"%I:%M %p"
} else {
"%H:%M"
}
if self.twelve_hour { "%I:%M %p" } else { "%H:%M" }
}
pub fn theme(&self) -> cosmic::Theme {
@@ -112,14 +109,12 @@ impl Appearance {
}
fn account_picture(user: &str) -> Option<image::Handle> {
let mut candidates =
vec![std::path::PathBuf::from("/var/lib/AccountsService/icons").join(user)];
let mut candidates = vec![std::path::PathBuf::from("/var/lib/AccountsService/icons").join(user)];
if let Some(home) = std::env::var_os("HOME") {
candidates.push(std::path::PathBuf::from(home).join(".face"));
}
candidates.into_iter().find_map(|path| {
let pixels =
cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path)).ok()?;
let pixels = cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path)).ok()?;
let side = pixels.width().min(pixels.height());
if side == 0 {
return None;
@@ -132,13 +127,11 @@ fn account_picture(user: &str) -> Option<image::Handle> {
side,
)
.to_image();
let mut pixels =
::image::imageops::resize(&square, 256, 256, ::image::imageops::FilterType::Lanczos3);
let mut pixels = ::image::imageops::resize(&square, 256, 256, ::image::imageops::FilterType::Lanczos3);
// Clip the decoded thumbnail, so the avatar stays circular even on
// renderers that do not support rounded image clipping.
for (x, y, pixel) in pixels.enumerate_pixels_mut() {
let distance =
((x as f32 + 0.5 - 128.).powi(2) + (y as f32 + 0.5 - 128.).powi(2)).sqrt();
let distance = ((x as f32 + 0.5 - 128.).powi(2) + (y as f32 + 0.5 - 128.).powi(2)).sqrt();
let coverage = (128. - distance).clamp(0., 1.);
pixel[3] = (pixel[3] as f32 * coverage).round() as u8;
}
+7 -19
View File
@@ -1,5 +1,6 @@
//! PAM stays on a worker thread. No password is placed in argv, logs or IPC.
use std::ffi::{CStr, CString, c_char, c_int, c_void};
use zeroize::Zeroizing;
#[repr(C)]
@@ -14,8 +15,7 @@ struct Response {
}
#[repr(C)]
struct Conversation {
callback:
unsafe extern "C" fn(c_int, *mut *const Message, *mut *mut Response, *mut c_void) -> c_int,
callback: unsafe extern "C" fn(c_int, *mut *const Message, *mut *mut Response, *mut c_void) -> c_int,
data: *mut c_void,
}
struct Credentials {
@@ -39,8 +39,7 @@ unsafe extern "C" fn converse(
unsafe {
*out = std::ptr::null_mut();
let credentials = &mut *(data as *mut Credentials);
let responses =
libc::calloc(count as usize, std::mem::size_of::<Response>()) as *mut Response;
let responses = libc::calloc(count as usize, std::mem::size_of::<Response>()) as *mut Response;
if responses.is_null() {
return CONV_ERR;
}
@@ -51,10 +50,8 @@ unsafe extern "C" fn converse(
} else {
match (*msg).style {
1 => {
credentials.password_prompts =
credentials.password_prompts.saturating_add(1);
(credentials.password_prompts == 1)
.then_some(credentials.password.as_slice())
credentials.password_prompts = credentials.password_prompts.saturating_add(1);
(credentials.password_prompts == 1).then_some(credentials.password.as_slice())
} // Never send the password as a second-factor response.
2 => Some(credentials.user.as_bytes_with_nul()), // PAM_PROMPT_ECHO_ON
3 | 4 => Some(&[][..]), // PAM_ERROR_MSG / PAM_TEXT_INFO
@@ -65,11 +62,7 @@ unsafe extern "C" fn converse(
if !bytes.is_empty() {
let allocation = libc::malloc(bytes.len()) as *mut c_char;
if !allocation.is_null() {
std::ptr::copy_nonoverlapping(
bytes.as_ptr(),
allocation.cast(),
bytes.len(),
);
std::ptr::copy_nonoverlapping(bytes.as_ptr(), allocation.cast(), bytes.len());
(*responses.add(i)).text = allocation;
continue;
}
@@ -139,12 +132,7 @@ fn authenticate_with(user: &str, password: &str, config: Option<&CStr>) -> bool
let Ok(library) = libloading::Library::new("libpam.so.0") else {
return false;
};
type Start = unsafe extern "C" fn(
*const c_char,
*const c_char,
*const Conversation,
*mut *mut c_void,
) -> c_int;
type Start = unsafe extern "C" fn(*const c_char, *const c_char, *const Conversation, *mut *mut c_void) -> c_int;
type StartConf = unsafe extern "C" fn(
*const c_char,
*const c_char,
+32 -73
View File
@@ -4,22 +4,15 @@ mod appearance;
mod auth;
mod runtime;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use cosmic::app::{Core, Settings, Task};
use cosmic::iced::event::{PlatformSpecific, wayland};
use cosmic::iced::platform_specific::shell::commands::session_lock;
use cosmic::{
Element,
app::{Core, Settings, Task},
iced::{
self, Event, Length, Subscription,
event::{self, PlatformSpecific, wayland},
window,
},
theme,
widget::{self, column, container, image, row},
};
use std::{
collections::HashMap,
time::{Duration, Instant},
};
use cosmic::iced::{Event, Length, Subscription, event, window};
use cosmic::widget::{column, container, image, row};
use cosmic::{Element, iced, theme, widget};
use wayland_client::protocol::wl_output::WlOutput;
use zeroize::{Zeroize, Zeroizing};
@@ -31,8 +24,7 @@ fn main() -> iced::Result {
);
return Ok(());
}
let preview = args == ["--preview"]
|| (args.len() == 3 && args[0] == "--preview" && args[1] == "--config");
let preview = args == ["--preview"] || (args.len() == 3 && args[0] == "--preview" && args[1] == "--config");
let config = if preview && args.len() == 3 {
Some(std::path::PathBuf::from(&args[2]))
} else {
@@ -201,9 +193,7 @@ impl cosmic::Application for Locker {
wayland::Event::Output(..) | wayland::Event::SessionLock(..),
))
| Event::Window(window::Event::Opened { .. })
| Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => {
Some(Message::Event(Box::new(event)))
}
| Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => Some(Message::Event(Box::new(event))),
_ => None,
}),
iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick),
@@ -212,9 +202,7 @@ impl cosmic::Application for Locker {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Event(box Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
modifiers,
))) => {
Message::Event(box Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers))) => {
self.caps_lock = modifiers.contains(iced::keyboard::Modifiers::CAPS_LOCK);
}
Message::Tick => {
@@ -222,8 +210,7 @@ impl cosmic::Application for Locker {
self.clock = now.strftime(self.appearance.clock_format()).to_string();
self.date = now.strftime("%A, %B %-d").to_string();
if matches!(self.state, AuthState::Rejected(at) if at.elapsed() >= Duration::from_secs(2))
{
if matches!(self.state, AuthState::Rejected(at) if at.elapsed() >= Duration::from_secs(2)) {
self.state = AuthState::Ready;
}
}
@@ -242,10 +229,7 @@ impl cosmic::Application for Locker {
return focus();
}
if !self.confirmation.confirmed()
|| !self.state.may_submit()
|| self.password.is_empty()
{
if !self.confirmation.confirmed() || !self.state.may_submit() || self.password.is_empty() {
return Task::none();
}
@@ -269,9 +253,10 @@ impl cosmic::Application for Locker {
self.status = "Password not accepted. Try again.";
return focus();
}
Message::Event(box Event::PlatformSpecific(PlatformSpecific::Wayland(
wayland::Event::Output(event, output),
))) if !self.preview => {
Message::Event(box Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output(
event,
output,
)))) if !self.preview => {
if matches!(event, wayland::OutputEvent::Removed) {
if let Some(id) = self.outputs.remove(&output) {
return session_lock::destroy_lock_surface(id);
@@ -284,9 +269,9 @@ impl cosmic::Application for Locker {
}
}
}
Message::Event(box Event::PlatformSpecific(PlatformSpecific::Wayland(
wayland::Event::SessionLock(event),
))) if !self.preview => match event {
Message::Event(box Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::SessionLock(
event,
)))) if !self.preview => match event {
wayland::SessionLockEvent::Locked => {
if self.confirmation.observe() {
use std::io::Write;
@@ -296,9 +281,11 @@ impl cosmic::Application for Locker {
}
if !self.started {
self.started = true;
return Task::batch(self.outputs.iter().map(|(output, id)| {
session_lock::get_lock_surface(*id, output.clone())
}));
return Task::batch(
self.outputs
.iter()
.map(|(output, id)| session_lock::get_lock_surface(*id, output.clone())),
);
}
}
wayland::SessionLockEvent::Focused(..) => return focus(),
@@ -353,14 +340,7 @@ impl Locker {
fn screen_content(&self, size: iced::Size) -> Element<'_, Message> {
let avatar_size = (size.height * 0.08).clamp(56., 88.);
let clock_size = (size.height * 0.165)
.min(
size.width
* if self.appearance.twelve_hour {
0.075
} else {
0.12
},
)
.min(size.width * if self.appearance.twelve_hour { 0.075 } else { 0.12 })
.clamp(40., 180.) as u16;
let margin = if size.width < 640. { 24. } else { 36. };
let top = (size.height * 0.09).max(24.);
@@ -368,9 +348,7 @@ impl Locker {
let mut input = widget::text_input("Password", self.password.as_str())
.password()
.padding([6, 12])
.style(ferese_theme::controls::lock_input(
a.radius, a.accent, a.panel,
))
.style(ferese_theme::controls::lock_input(a.radius, a.accent, a.panel))
.font(a.font)
.id(widget::Id::new("password"));
@@ -422,14 +400,7 @@ impl Locker {
self.label(&self.user, 16).class(iced::Color::WHITE),
widget::Space::new().height(6),
input,
self.label(
if self.caps_lock {
"Caps Lock is on"
} else {
self.status
},
12
)
self.label(if self.caps_lock { "Caps Lock is on" } else { self.status }, 12)
.class(iced::Color::WHITE.scale_alpha(0.68)),
]
.spacing(12)
@@ -453,17 +424,10 @@ impl Locker {
}
if a.show_date {
clock = clock.push(
self.label(&self.date, 18)
.class(iced::Color::WHITE.scale_alpha(0.72)),
);
clock = clock.push(self.label(&self.date, 18).class(iced::Color::WHITE.scale_alpha(0.72)));
}
let clock_height = if a.show_clock {
clock_size as f32 * 1.3
} else {
0.
} + if a.show_date { 24. } else { 0. };
let clock_height = if a.show_clock { clock_size as f32 * 1.3 } else { 0. } + if a.show_date { 24. } else { 0. };
let gap = (size.height * 0.44 - top - clock_height).max(24.);
let foreground = container(
column![
@@ -497,14 +461,12 @@ impl Locker {
})
.width(Length::Fill)
.height(Length::Fill)
.class(theme::Container::custom(move |_| {
widget::container::Style {
.class(theme::Container::custom(move |_| widget::container::Style {
text_color: Some(iced::Color::WHITE.scale_alpha(0.65)),
background: Some(iced::Background::Color(iced::Color::from_rgba(
0.025, 0.035, 0.06, a.dim,
))),
..Default::default()
}
}));
let background: Element<'_, Message> = image(a.wallpaper.clone())
.width(Length::Fill)
@@ -524,10 +486,7 @@ fn line_icon(path: &str, size: u16) -> widget::icon::Icon {
fn ferese_symbol() -> widget::icon::Icon {
widget::icon(
widget::icon::from_svg_bytes(
include_bytes!("../../../packaging/icons/ferese.svg").as_slice(),
)
.symbolic(true),
widget::icon::from_svg_bytes(include_bytes!("../../../packaging/icons/ferese.svg").as_slice()).symbolic(true),
)
.size(30)
}
+9 -14
View File
@@ -1,15 +1,12 @@
//! Startup handshake: the invoking command exits successfully only after the
//! compositor's confirmation. The UI child remains alive for authentication.
use std::{
io::{BufRead, BufReader},
process::{Command, Stdio},
time::Duration,
};
use wayland_client::{
Connection, Dispatch, QueueHandle,
globals::{GlobalListContents, registry_queue_init},
protocol::wl_registry,
};
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::time::Duration;
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry;
use wayland_client::{Connection, Dispatch, QueueHandle};
struct Registry;
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
fn event(
@@ -23,10 +20,8 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
}
}
pub fn check_protocol() -> Result<(), String> {
let connection =
Connection::connect_to_env().map_err(|e| format!("Wayland connection: {e}"))?;
let (globals, _) = registry_queue_init::<Registry>(&connection)
.map_err(|e| format!("Wayland registry: {e}"))?;
let connection = Connection::connect_to_env().map_err(|e| format!("Wayland connection: {e}"))?;
let (globals, _) = registry_queue_init::<Registry>(&connection).map_err(|e| format!("Wayland registry: {e}"))?;
if globals
.contents()
.clone_list()
+11 -27
View File
@@ -1,14 +1,11 @@
mod prompt;
mod session;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
use zbus::zvariant::{OwnedValue, Value};
const AGENT_PATH: &str = "/dev/ferese/PolkitAgent";
@@ -45,9 +42,8 @@ impl Agent {
cookie: String,
identities: Vec<Identity>,
) -> zbus::fdo::Result<()> {
let uid = choose_identity(&identities).ok_or_else(|| {
zbus::fdo::Error::Failed("No supported authentication identity".into())
})?;
let uid = choose_identity(&identities)
.ok_or_else(|| zbus::fdo::Error::Failed("No supported authentication identity".into()))?;
let cancelled = Arc::new(AtomicBool::new(false));
{
@@ -60,9 +56,7 @@ impl Agent {
let result = tokio::task::spawn_blocking({
let cookie = cookie.clone();
move || {
session::authenticate(uid, message.chars().take(240).collect(), cookie, cancelled)
}
move || session::authenticate(uid, message.chars().take(240).collect(), cookie, cancelled)
})
.await
.map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?;
@@ -116,10 +110,7 @@ fn session_subject() -> Result<(String, HashMap<String, Value<'static>>), String
.map_err(|error| error.to_string())?;
let mut details = HashMap::new();
details.insert("pid".to_owned(), Value::from(std::process::id()));
details.insert(
"uid".to_owned(),
Value::from(unsafe { libc::geteuid() } as i32),
);
details.insert("uid".to_owned(), Value::from(unsafe { libc::geteuid() } as i32));
details.insert("start-time".to_owned(), Value::from(started));
Ok(("unix-process".into(), details))
}
@@ -130,15 +121,11 @@ async fn run_agent() -> Result<(), Box<dyn std::error::Error>> {
.serve_at(AGENT_PATH, Agent::default())?
.build()
.await?;
let authority =
zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY_INTERFACE).await?;
let authority = zbus::Proxy::new(&connection, AUTHORITY, AUTHORITY_PATH, AUTHORITY_INTERFACE).await?;
let locale = std::env::var("LANG").unwrap_or_default();
authority
.call::<_, _, ()>(
"RegisterAuthenticationAgent",
&(subject, locale, AGENT_PATH),
)
.call::<_, _, ()>("RegisterAuthenticationAgent", &(subject, locale, AGENT_PATH))
.await?;
std::future::pending::<()>().await;
@@ -174,9 +161,6 @@ mod tests {
HashMap::from([("uid".to_owned(), OwnedValue::from(uid))]),
)
};
assert_eq!(
choose_identity(&[identity(0), identity(current)]),
Some(current)
);
assert_eq!(choose_identity(&[identity(0), identity(current)]), Some(current));
}
}
+21 -52
View File
@@ -1,19 +1,16 @@
use crate::PromptEvent;
use cosmic::{
Element,
app::{Core, Settings, Task},
iced::{Alignment, Color, Length, Size, Subscription, window},
theme,
widget::{self, button, column, container, row},
};
use std::{
collections::VecDeque,
io::{BufRead, BufReader, Write},
sync::{Arc, Mutex},
time::Duration,
};
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Write};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use cosmic::app::{Core, Settings, Task};
use cosmic::iced::{Alignment, Color, Length, Size, Subscription, window};
use cosmic::widget::{button, column, container, row};
use cosmic::{Element, theme, widget};
use zeroize::{Zeroize, Zeroizing};
use crate::PromptEvent;
#[derive(Clone)]
struct Appearance {
surface: Color,
@@ -103,9 +100,7 @@ fn content_height(description: &str, status: Option<&str>) -> f32 {
.lines()
.map(|line| line.chars().count().div_ceil(48).max(1))
.sum();
let status_height = status.map_or(0, |message| {
14 + 18 * message.chars().count().div_ceil(48).clamp(1, 3)
});
let status_height = status.map_or(0, |message| 14 + 18 * message.chars().count().div_ceil(48).clamp(1, 3));
(214 + 18 * lines.saturating_sub(1) + status_height).min(360) as f32
}
@@ -163,10 +158,7 @@ fn focus() -> Task<Message> {
impl Prompt {
fn resize_to_content(&mut self) -> Task<Message> {
let height = content_height(
&self.description,
self.error.as_deref().or(self.info.as_deref()),
);
let height = content_height(&self.description, self.error.as_deref().or(self.info.as_deref()));
if (height - self.height).abs() < 1.0 {
return Task::none();
}
@@ -180,12 +172,7 @@ impl Prompt {
impl cosmic::Application for Prompt {
type Executor = cosmic::executor::Default;
type Flags = (
Arc<Mutex<VecDeque<PromptEvent>>>,
String,
String,
Appearance,
);
type Flags = (Arc<Mutex<VecDeque<PromptEvent>>>, String, String, Appearance);
type Message = Message;
const APP_ID: &'static str = "dev.ferese.Authentication";
@@ -198,21 +185,14 @@ impl cosmic::Application for Prompt {
}
fn style(&self) -> Option<cosmic::iced::theme::Style> {
Some(
self.appearance
.palette()
.application_style(if self.material.is_some() {
Some(self.appearance.palette().application_style(if self.material.is_some() {
cosmic::iced::Color::TRANSPARENT
} else {
self.appearance.surface
}),
)
}))
}
fn init(
mut core: Core,
(incoming, description, user, appearance): Self::Flags,
) -> (Self, Task<Message>) {
fn init(mut core: Core, (incoming, description, user, appearance): Self::Flags) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
core.window.border_padding = Some(0);
core.window.content_container = false;
@@ -257,10 +237,7 @@ impl cosmic::Application for Prompt {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::WindowOpened(id) => {
return cosmic::iced::window::run(
id,
ferese_theme::material::ModalMaterial::attach,
)
return cosmic::iced::window::run(id, ferese_theme::material::ModalMaterial::attach)
.map(|result| cosmic::Action::App(Message::MaterialAttached(result)));
}
Message::MaterialAttached(result) => {
@@ -323,9 +300,7 @@ impl cosmic::Application for Prompt {
.id(widget::Id::new("auth-response"))
.font(palette.font)
.padding([7, 12])
.style(ferese_theme::controls::authentication_input(
palette.palette(),
));
.style(ferese_theme::controls::authentication_input(palette.palette()));
if !self.echo {
input = input.password();
}
@@ -347,10 +322,7 @@ impl cosmic::Application for Prompt {
.height(40)
.center_x(40)
.center_y(40)
.class(ferese_theme::controls::surface(
palette.accent.scale_alpha(0.13),
12.,
)),
.class(ferese_theme::controls::surface(palette.accent.scale_alpha(0.13), 12.,)),
column![
ferese_theme::text("Authentication required", palette.font).size(18),
ferese_theme::text(format!("Confirm as {}", self.user), palette.font)
@@ -384,10 +356,7 @@ impl cosmic::Application for Prompt {
body = body.push(
row![
button::custom(ferese_theme::text("Cancel", palette.font).size(13))
.class(ferese_theme::controls::button_style(
palette.palette(),
false
))
.class(ferese_theme::controls::button_style(palette.palette(), false))
.padding([8, 14])
.on_press(Message::Cancel),
action
+24 -58
View File
@@ -1,19 +1,17 @@
use crate::PromptEvent;
use std::ffi::{CStr, CString, c_char, c_int, c_void};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{Arc, mpsc};
use std::time::Duration;
use glib::gobject_ffi;
use libloading::Library;
use std::{
ffi::{CStr, CString, c_char, c_int, c_void},
io::{BufRead, BufReader, BufWriter, Write},
process::{Child, ChildStdin, Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Sender},
},
time::Duration,
};
use zeroize::Zeroize;
use crate::PromptEvent;
enum SessionEvent {
Request(String, bool),
Info(String),
@@ -38,21 +36,15 @@ impl Api {
unsafe {
let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?;
let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?;
let user_new = *polkit
.get(b"polkit_unix_user_new\0")
.map_err(|e| e.to_string())?;
let session_new = *agent
.get(b"polkit_agent_session_new\0")
.map_err(|e| e.to_string())?;
let user_new = *polkit.get(b"polkit_unix_user_new\0").map_err(|e| e.to_string())?;
let session_new = *agent.get(b"polkit_agent_session_new\0").map_err(|e| e.to_string())?;
let initiate = *agent
.get(b"polkit_agent_session_initiate\0")
.map_err(|e| e.to_string())?;
let response = *agent
.get(b"polkit_agent_session_response\0")
.map_err(|e| e.to_string())?;
let cancel = *agent
.get(b"polkit_agent_session_cancel\0")
.map_err(|e| e.to_string())?;
let cancel = *agent.get(b"polkit_agent_session_cancel\0").map_err(|e| e.to_string())?;
Ok(Self {
_agent: agent,
@@ -72,9 +64,7 @@ pub fn check() -> Result<(), String> {
}
fn username(uid: u32) -> String {
let output = Command::new("getent")
.args(["passwd", &uid.to_string()])
.output();
let output = Command::new("getent").args(["passwd", &uid.to_string()]).output();
output
.ok()
.filter(|output| output.status.success())
@@ -122,32 +112,21 @@ fn prompt_process(
Ok((child, writer, rx))
}
unsafe extern "C" fn request(
_session: Object,
prompt: *const c_char,
echo: c_int,
data: *mut c_void,
) {
unsafe extern "C" fn request(_session: Object, prompt: *const c_char, echo: c_int, data: *mut c_void) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let prompt = unsafe { CStr::from_ptr(prompt) }
.to_string_lossy()
.into_owned();
let prompt = unsafe { CStr::from_ptr(prompt) }.to_string_lossy().into_owned();
let _ = tx.send(SessionEvent::Request(prompt, echo != 0));
}
unsafe extern "C" fn info(_session: Object, text: *const c_char, data: *mut c_void) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let text = unsafe { CStr::from_ptr(text) }
.to_string_lossy()
.into_owned();
let text = unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned();
let _ = tx.send(SessionEvent::Info(text));
}
unsafe extern "C" fn error(_session: Object, text: *const c_char, data: *mut c_void) {
let tx = unsafe { &*(data as *const Sender<SessionEvent>) };
let text = unsafe { CStr::from_ptr(text) }
.to_string_lossy()
.into_owned();
let text = unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned();
let _ = tx.send(SessionEvent::Error(text));
}
@@ -156,12 +135,7 @@ unsafe extern "C" fn completed(_session: Object, success: c_int, data: *mut c_vo
let _ = tx.send(SessionEvent::Completed(success != 0));
}
unsafe fn connect(
session: Object,
name: &'static CStr,
callback: gobject_ffi::GCallback,
data: *mut c_void,
) {
unsafe fn connect(session: Object, name: &'static CStr, callback: gobject_ffi::GCallback, data: *mut c_void) {
unsafe {
gobject_ffi::g_signal_connect_data(
session.cast(),
@@ -215,7 +189,7 @@ fn run_session(
unsafe extern "C" fn(Object, *const c_char, *mut c_void),
unsafe extern "C" fn(),
>(
info as unsafe extern "C" fn(Object, *const c_char, *mut c_void),
info as unsafe extern "C" fn(Object, *const c_char, *mut c_void)
)),
signal_data,
);
@@ -226,7 +200,7 @@ fn run_session(
unsafe extern "C" fn(Object, *const c_char, *mut c_void),
unsafe extern "C" fn(),
>(
error as unsafe extern "C" fn(Object, *const c_char, *mut c_void),
error as unsafe extern "C" fn(Object, *const c_char, *mut c_void)
)),
signal_data,
);
@@ -237,7 +211,7 @@ fn run_session(
unsafe extern "C" fn(Object, c_int, *mut c_void),
unsafe extern "C" fn(),
>(
completed as unsafe extern "C" fn(Object, c_int, *mut c_void),
completed as unsafe extern "C" fn(Object, c_int, *mut c_void)
)),
signal_data,
);
@@ -276,10 +250,7 @@ fn run_session(
_ => {}
}
}
if !stopped
&& (cancelled.load(Ordering::Acquire)
|| child.try_wait().map_err(|e| e.to_string())?.is_some())
{
if !stopped && (cancelled.load(Ordering::Acquire) || child.try_wait().map_err(|e| e.to_string())?.is_some()) {
unsafe { (api.cancel)(session) };
stopped = true;
}
@@ -293,12 +264,7 @@ fn run_session(
Ok(outcome.filter(|_| !stopped))
}
pub fn authenticate(
uid: u32,
message: String,
cookie: String,
cancelled: Arc<AtomicBool>,
) -> Result<bool, String> {
pub fn authenticate(uid: u32, message: String, cookie: String, cancelled: Arc<AtomicBool>) -> Result<bool, String> {
let api = Api::load()?;
let cookie = CString::new(cookie).map_err(|_| "Invalid authentication cookie")?;
let context = glib::MainContext::new();
+2 -1
View File
@@ -1,4 +1,5 @@
use std::{process::Command, sync::OnceLock};
use std::process::Command;
use std::sync::OnceLock;
pub fn families() -> &'static [String] {
static FAMILIES: OnceLock<Vec<String>> = OnceLock::new();
+94 -163
View File
@@ -4,22 +4,20 @@ mod store;
mod visuals;
mod watch;
use cosmic::{
ApplicationExt, Element,
app::{Core, Settings, Task},
iced::{Alignment, Length},
widget::{self, button, column, container, row, scrollable, slider, text_input, toggler},
};
use std::collections::HashMap;
use std::path::PathBuf;
use cosmic::app::{Core, Settings, Task};
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{button, column, container, row, scrollable, slider, text_input, toggler};
use cosmic::{ApplicationExt, Element, widget};
use schema::{Field, Kind, Page};
use std::{collections::HashMap, path::PathBuf};
use store::{Edit, Snapshot, set};
fn main() -> cosmic::iced::Result {
let mut args = std::env::args_os().skip(1);
let path = match args.next() {
Some(arg) if arg == "--config" => {
PathBuf::from(args.next().expect("--config requires a path"))
}
Some(arg) if arg == "--config" => PathBuf::from(args.next().expect("--config requires a path")),
Some(_) => {
eprintln!("Usage: ferese-settings [--config PATH]");
return Ok(());
@@ -195,9 +193,7 @@ impl cosmic::Application for App {
return self.change(set(&format!("desktop_widgets.notes.{index}.text"), value));
}
}
Message::AddNote
if !self.saving && self.draft.records("desktop_widgets.notes") < 32 =>
{
Message::AddNote if !self.saving && self.draft.records("desktop_widgets.notes") < 32 => {
let id = format!(
"note-{}",
std::time::SystemTime::now()
@@ -223,9 +219,7 @@ impl cosmic::Application for App {
}
Message::DragWindow => return self.core.drag(None),
Message::ExternalConfig(result) => match result {
Ok(snapshot)
if snapshot.source == self.current.source
|| snapshot.source == self.draft.source => {}
Ok(snapshot) if snapshot.source == self.current.source || snapshot.source == self.draft.source => {}
Ok(snapshot) => {
if self.saving
|| self.note_editors.values().any(|editor| editor.dirty)
@@ -233,9 +227,7 @@ impl cosmic::Application for App {
|| !self.ranges.is_empty()
|| !self.pending.is_empty()
{
self.status =
"Config changed externally · Reload when your edits are finished"
.into();
self.status = "Config changed externally · Reload when your edits are finished".into();
} else {
self.current = snapshot.clone();
self.draft = snapshot;
@@ -307,9 +299,7 @@ impl cosmic::Application for App {
match shlex::split(&value).filter(|v| !v.is_empty()) {
Some(args) => set(
&field.path,
args.into_iter()
.map(serde_json::Value::from)
.collect::<Vec<_>>(),
args.into_iter().map(serde_json::Value::from).collect::<Vec<_>>(),
),
None => {
self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into());
@@ -348,11 +338,7 @@ impl cosmic::Application for App {
}
.into();
return Task::batch([
self.update_theme(),
self.flush(),
self.load_thumbnail(),
]);
return Task::batch([self.update_theme(), self.flush(), self.load_thumbnail()]);
}
Err(error) => {
self.error = Some(error);
@@ -410,13 +396,20 @@ impl cosmic::Application for App {
"--file-filter=All files | *",
])
.output()
.map_err(|error| format!("Could not open the file chooser: {error}. Install zenity or enter an image path."))
.map_err(|error| {
format!(
"Could not open the file chooser: {error}. Install zenity or enter an image path."
)
})
.and_then(|output| {
if output.status.code() == Some(1) {
return Ok(None); // User cancelled.
}
if !output.status.success() {
return Err(format!("File chooser failed: {}", String::from_utf8_lossy(&output.stderr).trim()));
return Err(format!(
"File chooser failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let path = String::from_utf8(output.stdout)
.map_err(|_| "The image path is not valid UTF-8.".to_owned())?;
@@ -458,8 +451,7 @@ impl cosmic::Application for App {
return self.load_thumbnail();
}
Message::NewCommand(value) => self.new_command = value,
Message::AddCommand if !self.saving => {
match shlex::split(&self.new_command).filter(|a| !a.is_empty()) {
Message::AddCommand if !self.saving => match shlex::split(&self.new_command).filter(|a| !a.is_empty()) {
Some(args) => {
self.new_command.clear();
return self.change(Edit::Add(
@@ -467,22 +459,15 @@ impl cosmic::Application for App {
vec![
(
"command".into(),
args.into_iter()
.map(serde_json::Value::from)
.collect::<Vec<_>>()
.into(),
args.into_iter().map(serde_json::Value::from).collect::<Vec<_>>().into(),
),
("enabled".into(), true.into()),
("restart".into(), true.into()),
],
));
}
None => {
self.error =
Some("Enter a program and arguments with balanced quotes.".into())
}
}
}
None => self.error = Some("Enter a program and arguments with balanced quotes.".into()),
},
Message::AddSwipe(keys) if !self.saving => {
let (action, argument) = match keys {
"Swipe3Up" => ("workspace-next", None),
@@ -490,10 +475,7 @@ impl cosmic::Application for App {
"Swipe3Left" => ("focus", Some("right")),
_ => ("focus", Some("left")),
};
let mut fields = vec![
("keys".into(), keys.into()),
("action".into(), action.into()),
];
let mut fields = vec![("keys".into(), keys.into()), ("action".into(), action.into())];
if let Some(argument) = argument {
fields.push(("argument".into(), argument.into()));
@@ -596,8 +578,7 @@ impl cosmic::Application for App {
if !self.search.is_empty() {
if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) {
body = body
.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
}
for page in Page::ALL.into_iter().filter(|p| p.matches(&self.search)) {
body = body.push(
@@ -682,8 +663,7 @@ impl cosmic::Application for App {
.unwrap_or("Choose an image to preview your wallpaper.")
}));
}
body =
body.push(button::standard("Choose image…").on_press(Message::PickWallpaper));
body = body.push(button::standard("Choose image…").on_press(Message::PickWallpaper));
}
let fields = schema::fields(self.page);
@@ -692,30 +672,21 @@ impl cosmic::Application for App {
for field in fields {
group = group.push(self.field(field));
}
body = body.push(
container(group)
.padding(8)
.class(visuals::surface(palette.card, 14.)),
);
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
match self.page {
Page::LockScreen => {
body = body.push(self.note("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens."));
body = body.push(
button::standard("Preview lock screen").on_press(Message::PreviewLock),
);
body = body.push(button::standard("Preview lock screen").on_press(Message::PreviewLock));
body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items."));
}
Page::Desktop => {
let can_change_list =
!self.saving && !self.note_editors.values().any(|e| e.dirty);
let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty);
body = body.push(self.label("Sticky notes", 16.));
body = body.push(self.note("Edit here; notes save after you pause typing. Desktop cards stay behind windows and are click-through."));
for index in 0..self.draft.records("desktop_widgets.notes") {
let id = self
.draft
.string(&format!("desktop_widgets.notes.{index}.id"), "note");
let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note");
let mut group = column([]).spacing(8);
for field in schema::note_fields(index) {
@@ -729,23 +700,13 @@ impl cosmic::Application for App {
.height(160)
.font(self.font)
.size(14.)
.on_action(move |action| {
Message::NoteAction(id.clone(), action)
}),
.on_action(move |action| Message::NoteAction(id.clone(), action)),
);
}
group =
group.push(button::destructive("Remove note").on_press_maybe(
can_change_list.then_some(Message::Remove(
"desktop_widgets.notes".into(),
index,
)),
group = group.push(button::destructive("Remove note").on_press_maybe(
can_change_list.then_some(Message::Remove("desktop_widgets.notes".into(), index)),
));
body = body.push(
container(group)
.padding(12)
.class(visuals::surface(palette.card, 14.)),
);
body = body.push(container(group).padding(12).class(visuals::surface(palette.card, 14.)));
}
body = body.push(
button::standard("Add note").on_press_maybe(
@@ -783,19 +744,12 @@ impl cosmic::Application for App {
Kind::Toggle(true),
)))
.push(
container(
button::destructive("Remove item").on_press_maybe(
(!self.saving)
.then_some(Message::Remove("autostart".into(), index)),
),
)
container(button::destructive("Remove item").on_press_maybe(
(!self.saving).then_some(Message::Remove("autostart".into(), index)),
))
.padding(12),
);
body = body.push(
container(group)
.padding(8)
.class(visuals::surface(palette.card, 14.)),
);
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
body = body.push(
row([])
@@ -804,12 +758,9 @@ impl cosmic::Application for App {
text_input("Program and arguments", self.new_command.clone())
.on_input(Message::NewCommand),
)
.push(
button::standard("Add login item").on_press_maybe(
(!self.saving && !self.new_command.trim().is_empty())
.then_some(Message::AddCommand),
),
),
.push(button::standard("Add login item").on_press_maybe(
(!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand),
)),
);
}
Page::Shortcuts => {
@@ -828,10 +779,10 @@ impl cosmic::Application for App {
});
if !exists {
gestures =
gestures.push(button::standard(label).on_press_maybe(
(!self.saving).then_some(Message::AddSwipe(keys)),
));
gestures = gestures.push(
button::standard(label)
.on_press_maybe((!self.saving).then_some(Message::AddSwipe(keys))),
);
}
}
@@ -859,11 +810,7 @@ impl cosmic::Application for App {
"Command name, direction, or action argument.",
"",
)));
body = body.push(
container(group)
.padding(8)
.class(visuals::surface(palette.card, 14.)),
);
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
if self.draft.records("bindings") == 0 {
body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload."));
@@ -873,29 +820,44 @@ impl cosmic::Application for App {
body = body.push(self.note("Display profiles update connected outputs live. Keep your config open for adding profiles or changing display positions."));
for profile in 0..self.draft.records("output_profiles") {
let prefix = format!("output_profiles.{profile}");
body = body.push(
self.label(
self.draft
.string(&format!("{prefix}.name"), "Display profile"),
17.,
),
);
body =
body.push(self.label(self.draft.string(&format!("{prefix}.name"), "Display profile"), 17.));
for output in 0..self.draft.records(&format!("{prefix}.outputs")) {
let prefix = format!("{prefix}.outputs.{output}");
let group = column([]).spacing(1)
.push(self.field(schema::text(format!("{prefix}.match"), "Display", "Output name or matching pattern.", "")))
.push(self.field(schema::text(format!("{prefix}.mode"), "Resolution", "For example: 2560x1440@60. Leave unchanged to retain automatic selection.", "")))
.push(self.field(schema::range(format!("{prefix}.scale"), "Scale", "Logical size of text and controls.", 1., 0.75, 3., 0.25, "×", false)));
body = body.push(
container(group)
.padding(8)
.class(visuals::surface(palette.card, 14.)),
);
let group = column([])
.spacing(1)
.push(self.field(schema::text(
format!("{prefix}.match"),
"Display",
"Output name or matching pattern.",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.mode"),
"Resolution",
"For example: 2560x1440@60. Leave unchanged to retain automatic selection.",
"",
)))
.push(self.field(schema::range(
format!("{prefix}.scale"),
"Scale",
"Logical size of text and controls.",
1.,
0.75,
3.,
0.25,
"×",
false,
)));
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
}
if self.draft.records("output_profiles") == 0 {
body = body.push(self.note("No saved profiles. Ferese currently configures connected displays automatically."));
body =
body.push(self.note(
"No saved profiles. Ferese currently configures connected displays automatically.",
));
}
}
_ => {}
@@ -947,14 +909,7 @@ impl cosmic::Application for App {
},
palette.muted,
),
self.label(
if self.saving {
"Saving…"
} else {
&self.status
},
11.,
),
self.label(if self.saving { "Saving…" } else { &self.status }, 11.),
widget::tooltip::Position::Top,
))
.push(widget::Space::new().width(Length::Fill))
@@ -986,12 +941,7 @@ impl cosmic::Application for App {
.push(sidebar)
.push(
container(
container(
column([])
.push(content.height(Length::Fill))
.push(footer)
.spacing(6),
)
container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6))
.padding(cosmic::iced::Padding {
top: 20.,
right: 20.,
@@ -1022,11 +972,8 @@ impl App {
}
fn note_index(&self, id: &str) -> Option<usize> {
(0..self.draft.records("desktop_widgets.notes")).find(|index| {
self.draft
.string(&format!("desktop_widgets.notes.{index}.id"), "note")
== id
})
(0..self.draft.records("desktop_widgets.notes"))
.find(|index| self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note") == id)
}
fn sync_notes(&mut self) {
@@ -1036,10 +983,7 @@ impl App {
let prefix = format!("desktop_widgets.notes.{index}");
let id = self.draft.string(&format!("{prefix}.id"), "note");
let text = self.draft.string(&format!("{prefix}.text"), "");
let editor = self
.note_editors
.entry(id.clone())
.or_insert_with(|| NoteEditor {
let editor = self.note_editors.entry(id.clone()).or_insert_with(|| NoteEditor {
content: widget::text_editor::Content::with_text(&text),
revision: 0,
dirty: false,
@@ -1121,9 +1065,7 @@ impl App {
fn field(&self, field: Field) -> Element<'static, Message> {
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
let mut labels = column([])
.spacing(3)
.push(self.label(field.label.clone(), 13.));
let mut labels = column([]).spacing(3).push(self.label(field.label.clone(), 13.));
if !field.description.is_empty() {
labels = labels.push(
self.label(field.description.clone(), 11.)
@@ -1205,9 +1147,7 @@ impl App {
.align_y(Alignment::Center)
.spacing(14)
.push(
slider(min..=max, value, move |value| {
Message::Range(field.clone(), value)
})
slider(min..=max, value, move |value| Message::Range(field.clone(), value))
.step(step)
.on_release(Message::Release(release))
.width(145),
@@ -1254,10 +1194,7 @@ impl App {
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(
container(widget::Space::new().width(24).height(24))
.class(visuals::surface(swatch, 6.)),
)
.push(container(widget::Space::new().width(24).height(24)).class(visuals::surface(swatch, 6.)))
.push(input)
.into()
} else {
@@ -1342,8 +1279,9 @@ impl App {
#[cfg(test)]
mod tests {
use super::*;
use cosmic::Application;
use super::*;
fn app() -> App {
App::init(
Core::default(),
@@ -1361,10 +1299,7 @@ mod tests {
app.draft
.edit(&Edit::Add(
"desktop_widgets.notes".into(),
vec![
("id".into(), "test".into()),
("text".into(), "first\nsecond".into()),
],
vec![("id".into(), "test".into()), ("text".into(), "first\nsecond".into())],
))
.unwrap();
app.sync_notes();
@@ -1377,10 +1312,7 @@ mod tests {
let _ = app.update(Message::SaveNote("test".into(), 2));
assert!(app.saving);
assert!(!app.note_editors["test"].dirty);
assert_eq!(
app.draft.string("desktop_widgets.notes.0.text", ""),
"first\nsecond"
);
assert_eq!(app.draft.string("desktop_widgets.notes.0.text", ""), "first\nsecond");
}
#[test]
@@ -1424,8 +1356,7 @@ mod tests {
let _ = app.update(Message::ExternalConfig(Ok(external.clone())));
assert_eq!(app.current.source, external.source);
assert_eq!(app.draft.number("animations.speed", 0.), 0.5);
app.inputs
.insert("theme.colors.accent".into(), "#ffffff".into());
app.inputs.insert("theme.colors.accent".into(), "#ffffff".into());
let newer = Snapshot::parse("animations {\n speed 0.8\n}\n".into()).unwrap();
let _ = app.update(Message::ExternalConfig(Ok(newer)));
assert_eq!(app.current.source, external.source);
+4 -27
View File
@@ -129,12 +129,7 @@ pub struct Field {
}
impl Field {
pub fn new(
path: impl Into<String>,
label: impl Into<String>,
description: impl Into<String>,
kind: Kind,
) -> Self {
pub fn new(path: impl Into<String>, label: impl Into<String>, description: impl Into<String>, kind: Kind) -> Self {
Self {
path: path.into(),
label: label.into(),
@@ -180,15 +175,7 @@ pub fn text(
description: impl Into<String>,
default: &'static str,
) -> Field {
Field::new(
path,
label,
description,
Kind::Text {
default,
argv: false,
},
)
Field::new(path, label, description, Kind::Text { default, argv: false })
}
fn choice(
@@ -400,18 +387,8 @@ pub fn note_fields(index: usize) -> Vec<Field> {
"Drag the title and edit directly. Off makes the card click-through.",
Kind::Toggle(true),
),
Field::new(
format!("{prefix}.enabled"),
"Show note",
"",
Kind::Toggle(true),
),
text(
format!("{prefix}.title"),
"Title",
"Empty hides the title.",
"Note",
),
Field::new(format!("{prefix}.enabled"), "Show note", "", Kind::Toggle(true)),
text(format!("{prefix}.title"), "Title", "Empty hides the title.", "Note"),
text(
format!("{prefix}.anchor"),
"Position",
+24 -61
View File
@@ -1,12 +1,11 @@
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use ferese_config::Document;
use serde_json::Value;
use std::{
fs,
io::{Read, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
pub const LIMIT: usize = 60 * 1024;
@@ -45,10 +44,7 @@ impl Snapshot {
}
pub fn string(&self, path: &str, fallback: &str) -> String {
self.item(path)
.and_then(Value::as_str)
.unwrap_or(fallback)
.to_owned()
self.item(path).and_then(Value::as_str).unwrap_or(fallback).to_owned()
}
pub fn number(&self, path: &str, fallback: f64) -> f64 {
@@ -60,9 +56,7 @@ impl Snapshot {
}
pub fn records(&self, path: &str) -> usize {
self.item(path)
.and_then(Value::as_array)
.map_or(0, |a| a.len())
self.item(path).and_then(Value::as_array).map_or(0, |a| a.len())
}
pub fn argv(&self, path: &str) -> String {
@@ -172,9 +166,7 @@ fn save_with(
let snapshot = Snapshot::parse(desired.to_owned())?;
let unchanged = || -> Result<(), String> {
if Snapshot::read(path)?.source != expected {
return Err(
"Your config changed in another app. Reload settings before saving.".into(),
);
return Err("Your config changed in another app. Reload settings before saving.".into());
}
Ok(())
};
@@ -188,17 +180,13 @@ fn save_with(
.set_permissions(metadata.permissions())
.map_err(|e| e.to_string())?;
}
staged
.write_all(desired.as_bytes())
.map_err(|e| e.to_string())?;
staged.write_all(desired.as_bytes()).map_err(|e| e.to_string())?;
staged.as_file().sync_all().map_err(|e| e.to_string())?;
validate(staged.path())?;
unchanged()?;
// Keep the last working source, including comments and unknown keys.
let mut backup = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
backup
.write_all(expected.as_bytes())
.map_err(|e| e.to_string())?;
backup.write_all(expected.as_bytes()).map_err(|e| e.to_string())?;
backup.as_file().sync_all().map_err(|e| e.to_string())?;
backup
.persist(path.with_extension("kdl.settings-backup"))
@@ -212,8 +200,7 @@ pub fn reload_running() -> bool {
let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") else {
return false;
};
let Ok(mut socket) =
std::os::unix::net::UnixStream::connect(PathBuf::from(runtime).join("ferese/control.sock"))
let Ok(mut socket) = std::os::unix::net::UnixStream::connect(PathBuf::from(runtime).join("ferese/control.sock"))
else {
return false;
};
@@ -293,17 +280,11 @@ window-rule app-id="mine" floating=#true
#[test]
fn nested_array_edits_and_login_items_roundtrip() {
let mut snapshot = Snapshot::parse(
"output-profile name=\"desk\" {\n output match=\"HDMI-A-1\" scale=1.5\n}\n".into(),
)
let mut snapshot =
Snapshot::parse("output-profile name=\"desk\" {\n output match=\"HDMI-A-1\" scale=1.5\n}\n".into())
.unwrap();
snapshot
.edit(&set("output_profiles.0.outputs.0.scale", 1.75))
.unwrap();
assert_eq!(
snapshot.number("output_profiles.0.outputs.0.scale", 1.),
1.75
);
snapshot.edit(&set("output_profiles.0.outputs.0.scale", 1.75)).unwrap();
assert_eq!(snapshot.number("output_profiles.0.outputs.0.scale", 1.), 1.75);
snapshot
.edit(&Edit::Add(
"autostart".into(),
@@ -331,28 +312,18 @@ window-rule app-id="mine" floating=#true
#[test]
fn nested_note_lists_keep_existing_clock_settings() {
let mut snapshot = Snapshot::parse(
"desktop-widgets {\n clock {\n enabled #true\n }\n}\n".into(),
)
.unwrap();
let mut snapshot =
Snapshot::parse("desktop-widgets {\n clock {\n enabled #true\n }\n}\n".into()).unwrap();
snapshot
.edit(&Edit::Add(
"desktop_widgets.notes".into(),
vec![
("id".into(), "first".into()),
("text".into(), "one\ntwo".into()),
],
vec![("id".into(), "first".into()), ("text".into(), "one\ntwo".into())],
))
.unwrap();
assert_eq!(snapshot.records("desktop_widgets.notes"), 1);
assert!(snapshot.boolean("desktop_widgets.clock.enabled", false));
assert_eq!(
snapshot.string("desktop_widgets.notes.0.text", ""),
"one\ntwo"
);
snapshot
.edit(&Edit::Remove("desktop_widgets.notes".into(), 0))
.unwrap();
assert_eq!(snapshot.string("desktop_widgets.notes.0.text", ""), "one\ntwo");
snapshot.edit(&Edit::Remove("desktop_widgets.notes".into(), 0)).unwrap();
assert_eq!(snapshot.records("desktop_widgets.notes"), 0);
}
@@ -364,11 +335,7 @@ window-rule app-id="mine" floating=#true
let path = directory.path().join("config.kdl");
let validate = |snapshot: &Snapshot| {
fs::write(&path, &snapshot.source).unwrap();
let result = Command::new(&binary)
.arg("--check-config")
.arg(&path)
.output()
.unwrap();
let result = Command::new(&binary).arg("--check-config").arg(&path).output().unwrap();
assert!(
result.status.success(),
"{}\n{}",
@@ -386,9 +353,7 @@ window-rule app-id="mine" floating=#true
let values: Vec<Value> = match field.kind {
Kind::Font => vec!["".into(), "sans-serif".into()],
Kind::Toggle(_) => vec![true.into(), false.into()],
Kind::Range {
min, max, integer, ..
} => {
Kind::Range { min, max, integer, .. } => {
if integer {
vec![(min as i64).into(), (max as i64).into()]
} else {
@@ -396,9 +361,7 @@ window-rule app-id="mine" floating=#true
}
}
Kind::Text { default, .. } => vec![default.into()],
Kind::Choice { choices, .. } => {
choices.iter().map(|(v, _)| (*v).into()).collect()
}
Kind::Choice { choices, .. } => choices.iter().map(|(v, _)| (*v).into()).collect(),
};
for value in values {
let mut snapshot = Snapshot::parse(String::new()).unwrap();
+18 -53
View File
@@ -1,19 +1,13 @@
use crate::{
Message,
schema::Page,
store::{Edit, Snapshot, set},
};
use cosmic::{
Element,
iced::{Color, Length},
widget::icon as svg_icon,
};
pub use ferese_theme::controls::{
button_style, navigation_style, settings_input as input_style, surface,
};
use cosmic::Element;
use cosmic::iced::{Color, Length};
use cosmic::widget::icon as svg_icon;
pub use ferese_theme::controls::{button_style, navigation_style, settings_input as input_style, surface};
pub use ferese_theme::{Palette, mix, surface_shade};
use crate::Message;
use crate::schema::Page;
use crate::store::{Edit, Snapshot, set};
pub fn color(value: &str, fallback: Color) -> Color {
ferese_theme::parse_color(value).unwrap_or(fallback)
}
@@ -32,9 +26,7 @@ pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
}
pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
ferese_theme::font(Some(
&snapshot.string("theme.typography.font_family", "Inter"),
))
ferese_theme::font(Some(&snapshot.string("theme.typography.font_family", "Inter")))
}
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
@@ -91,20 +83,14 @@ pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
("theme.surface.bar.text_primary", preset.text, "#F0F3FA"),
("theme.surface.bar.text_muted", preset.muted, "#AAB4C7"),
("theme.focus_ring.gradient.from", preset.accent, "#3D7BE6"),
(
"theme.focus_ring.gradient.to",
preset.gradient_end,
"#3D7BE6",
),
("theme.focus_ring.gradient.to", preset.gradient_end, "#3D7BE6"),
]
.into_iter()
.all(|(path, expected, fallback)| {
if index == 0 && snapshot.item(path).is_none() {
return true;
}
snapshot
.string(path, fallback)
.eq_ignore_ascii_case(expected)
snapshot.string(path, fallback).eq_ignore_ascii_case(expected)
}) && snapshot.number("theme.focus_ring.gradient.angle", 0.) == 0.
}
@@ -164,19 +150,14 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
let accent = hex(p.accent);
let muted = hex(p.muted);
let gap = snapshot.number("layout.inner_gap", 8.).clamp(0., 32.);
let radius = snapshot
.number("theme.geometry.window_radius", 14.)
.clamp(0., 28.);
let radius = snapshot.number("theme.geometry.window_radius", 14.).clamp(0., 28.);
let bar_y = snapshot.number("theme.geometry.top_bar_margin_top", 0.) * 0.5 + 12.;
let bar_margin = snapshot.number("theme.geometry.top_bar_margin_horizontal", 0.) * 0.5 + 14.;
let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65;
let bar_radius = snapshot.number("theme.geometry.shell_radius", 14.) * 0.65;
let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" {
snapshot
.number(
"theme.material.opacity",
ferese_config::DEFAULT_MATERIAL_OPACITY,
)
.number("theme.material.opacity", ferese_config::DEFAULT_MATERIAL_OPACITY)
.clamp(0., 1.)
} else {
1.
@@ -235,16 +216,8 @@ mod tests {
let snapshot = configured_preset(index);
let palette = Palette::from_document(Some(&snapshot.doc));
for background in [palette.background, palette.sidebar, palette.card] {
assert!(
contrast(palette.text, background) >= 4.5,
"{} text",
preset.name
);
assert!(
contrast(palette.muted, background) >= 4.5,
"{} muted text",
preset.name
);
assert!(contrast(palette.text, background) >= 4.5, "{} text", preset.name);
assert!(contrast(palette.muted, background) >= 4.5, "{} muted text", preset.name);
}
for other in PRESETS.iter().skip(index + 1) {
assert_ne!(preset.accent, other.accent);
@@ -263,17 +236,12 @@ mod tests {
.unwrap();
assert!(!preset_selected(&snapshot, index));
let mut snapshot = configured_preset(index);
snapshot
.edit(&set("theme.focus_ring.gradient.to", "#123456"))
.unwrap();
snapshot.edit(&set("theme.focus_ring.gradient.to", "#123456")).unwrap();
assert!(!preset_selected(&snapshot, index));
}
assert!(preset_selected(&Snapshot::parse(String::new()).unwrap(), 0));
assert!(preset(usize::MAX).is_empty());
assert!(!preset_selected(
&Snapshot::parse(String::new()).unwrap(),
usize::MAX
));
assert!(!preset_selected(&Snapshot::parse(String::new()).unwrap(), usize::MAX));
}
#[test]
@@ -283,10 +251,7 @@ mod tests {
for (index, item) in PRESETS.iter().enumerate() {
let snapshot = configured_preset(index);
assert_eq!(
native_theme(Some(&snapshot)).cosmic().is_dark,
item.name != "Ayu Light"
);
assert_eq!(native_theme(Some(&snapshot)).cosmic().is_dark, item.name != "Ayu Light");
}
}
}
+12 -18
View File
@@ -1,8 +1,12 @@
//! Watch the parent directory so atomic editor saves keep working.
use crate::{Message, store::Snapshot};
use std::path::PathBuf;
use std::time::Duration;
use cosmic::iced::futures::{SinkExt, Stream};
use notify::{RecursiveMode, Watcher};
use std::{path::PathBuf, time::Duration};
use crate::Message;
use crate::store::Snapshot;
pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
let path = path.clone();
@@ -21,9 +25,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
let mut watcher = match watcher {
Ok(watcher) => watcher,
Err(error) => {
let _ = output
.send(Message::ExternalConfig(Err(error.to_string())))
.await;
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
return;
}
};
@@ -32,9 +34,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
path.parent().unwrap_or(std::path::Path::new(".")),
RecursiveMode::NonRecursive,
) {
let _ = output
.send(Message::ExternalConfig(Err(error.to_string())))
.await;
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
return;
}
@@ -44,11 +44,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
let snapshot = tokio::task::spawn_blocking(move || Snapshot::read(&file))
.await
.unwrap_or_else(|e| Err(e.to_string()));
if output
.send(Message::ExternalConfig(snapshot))
.await
.is_err()
{
if output.send(Message::ExternalConfig(snapshot)).await.is_err() {
return;
}
if events.recv().await.is_none() {
@@ -64,9 +60,10 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::futures::{StreamExt, pin_mut};
use super::*;
#[test]
fn watches_atomic_replacement_and_retains_subscription_after_bad_kdl() {
tokio::runtime::Builder::new_current_thread()
@@ -79,10 +76,7 @@ mod tests {
std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap();
let stream = changes(&path);
pin_mut!(stream);
assert!(matches!(
stream.next().await,
Some(Message::ExternalConfig(Ok(_)))
));
assert!(matches!(stream.next().await, Some(Message::ExternalConfig(Ok(_)))));
for source in ["bad [", "animations {\n speed 0.75\n}\n"] {
let temporary = directory.path().join("replacement");
std::fs::write(&temporary, source).unwrap();
+19 -38
View File
@@ -1,9 +1,8 @@
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use std::{
os::unix::net::UnixStream,
path::PathBuf,
time::{Duration, Instant},
};
pub(super) fn call(command: &str) -> Result<Value, String> {
request(command, json!({}))
@@ -33,10 +32,7 @@ impl SessionConnection {
}
fn request(&self, command: &str, args: Value) -> Result<Value, String> {
let mut stream = self
.stream
.lock()
.map_err(|_| "Compositor connection failed")?;
let mut stream = self.stream.lock().map_err(|_| "Compositor connection failed")?;
let request = ferese_ipc::Request {
version: ferese_ipc::VERSION,
id: 1,
@@ -45,8 +41,7 @@ impl SessionConnection {
args,
};
ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response =
ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
let response: ferese_ipc::Response = ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?;
if let Some(error) = response.error {
return Err(error.message);
}
@@ -89,24 +84,21 @@ impl Approval {
}
pub fn prepare(&self) -> Result<(), String> {
if matches!(self.mode, Mode::Shutdown | Mode::Suspend)
&& login_inhibitors(self.mode)? != self.external
{
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
if matches!(self.mode, Mode::Shutdown | Mode::Suspend) && login_inhibitors(self.mode)? != self.external {
return Err("Applications changed their inhibitors; review the confirmation again".into());
}
if self.mode == Mode::Shutdown {
self.connection.as_ref().ok_or("Missing session-ending lease")?.request(
self.connection
.as_ref()
.ok_or("Missing session-ending lease")?
.request(
"validate-session-end",
json!({"token": self.token, "inhibitor-revision": self.revision, "force": self.force()}),
)?;
} else if self.mode == Mode::Suspend {
let state = call("get-session-state")?;
if state["inhibitor-revision"].as_u64() != Some(u64::from(self.revision)) {
return Err(
"Applications changed their inhibitors; review the confirmation again".into(),
);
return Err("Applications changed their inhibitors; review the confirmation again".into());
}
}
Ok(())
@@ -129,19 +121,12 @@ fn login_inhibitors(mode: Mode) -> Result<Vec<String>, String> {
"org.freedesktop.login1.Manager",
)
.map_err(|error| error.to_string())?;
let inhibitors: Vec<(String, String, String, String, u32, u32)> = proxy
.call("ListInhibitors", &())
.map_err(|error| error.to_string())?;
let what = if mode == Mode::Suspend {
"sleep"
} else {
"shutdown"
};
let inhibitors: Vec<(String, String, String, String, u32, u32)> =
proxy.call("ListInhibitors", &()).map_err(|error| error.to_string())?;
let what = if mode == Mode::Suspend { "sleep" } else { "shutdown" };
let mut reasons = inhibitors
.into_iter()
.filter(|(flags, _, _, inhibition, _, _)| {
inhibition == "block" && flags.split(':').any(|flag| flag == what)
})
.filter(|(flags, _, _, inhibition, _, _)| inhibition == "block" && flags.split(':').any(|flag| flag == what))
.map(|(_, app, reason, _, _, _)| format!("{app}: {reason}"))
.collect::<Vec<_>>();
reasons.sort();
@@ -181,9 +166,7 @@ pub(super) fn inhibitors(mode: Mode) -> Result<Approval, String> {
}
}
let flags = if mode == Mode::Suspend { 4 } else { 1 };
let entries = state["inhibitors"]
.as_array()
.ok_or("Invalid inhibitor list")?;
let entries = state["inhibitors"].as_array().ok_or("Invalid inhibitor list")?;
let mut reasons = entries
.iter()
.filter(|entry| entry["flags"].as_u64().unwrap_or(0) & flags != 0)
@@ -192,9 +175,7 @@ pub(super) fn inhibitors(mode: Mode) -> Result<Approval, String> {
.as_str()
.filter(|app| !app.is_empty())
.unwrap_or("Application");
let reason = entry["reason"]
.as_str()
.unwrap_or("Requested session inhibition");
let reason = entry["reason"].as_str().unwrap_or("Requested session inhibition");
format!("{app}: {reason}")
})
.collect::<Vec<_>>();
+11 -34
View File
@@ -93,8 +93,7 @@ impl Default for ShellTheme {
impl ShellTheme {
pub(crate) fn palette(self) -> ferese_theme::Palette {
let color =
|[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
let color = |[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
let surface = color(self.surface_base);
ferese_theme::Palette {
background: surface,
@@ -330,14 +329,8 @@ pub(crate) fn load() -> ShellConfig {
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
match ferese_config::from_str::<FereseConfig>(source) {
Ok(config) => {
config
.notifications
.validate()
.map_err(ferese_config::Error::from)?;
config
.desktop_widgets
.validate()
.map_err(ferese_config::Error::from)?;
config.notifications.validate().map_err(ferese_config::Error::from)?;
config.desktop_widgets.validate().map_err(ferese_config::Error::from)?;
let mut theme = shell_theme(&config.theme);
theme.material_radius = nonnegative_or(
config
@@ -387,19 +380,15 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
ShellTheme {
material_radius: defaults.material_radius,
bar_background: {
let mut background =
parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
let mut background = parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
background[3] = alpha;
background
},
bar_text_primary: parse_color(&theme.surface.bar.text_primary)
.unwrap_or(defaults.bar_text_primary),
bar_text_muted: parse_color(&theme.surface.bar.text_muted)
.unwrap_or(defaults.bar_text_muted),
bar_text_primary: parse_color(&theme.surface.bar.text_primary).unwrap_or(defaults.bar_text_primary),
bar_text_muted: parse_color(&theme.surface.bar.text_muted).unwrap_or(defaults.bar_text_muted),
surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base),
surface_popover: {
let mut color =
parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
color[3] = alpha;
color
},
@@ -424,8 +413,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap),
shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y),
shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur),
shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity)
.clamp(0.0, 1.0),
shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity).clamp(0.0, 1.0),
}
}
@@ -556,16 +544,8 @@ mod tests {
.clock;
assert!(clock.enabled);
assert_eq!(clock.anchor, ferese_core::desktop::Anchor::BottomRight);
assert!(
super::parse_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n")
.is_err()
);
assert!(
super::parse_source(
"desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n"
)
.is_err()
);
assert!(super::parse_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n").is_err());
assert!(super::parse_source("desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n").is_err());
}
use super::*;
@@ -603,10 +583,7 @@ theme {
config.theme.typography.font_family.as_deref(),
Some("JetBrainsMono Nerd Font")
);
assert_eq!(
config.theme.background.path,
Some(PathBuf::from("/tmp/wallpaper.png"))
);
assert_eq!(config.theme.background.path, Some(PathBuf::from("/tmp/wallpaper.png")));
assert_eq!(config.theme.background.mode, WallpaperMode::Fit);
}
+20 -42
View File
@@ -1,22 +1,16 @@
use std::{
error::Error,
os::{
fd::{FromRawFd, RawFd},
unix::net::UnixStream,
},
sync::mpsc::{self, Receiver, Sender, TryRecvError},
thread,
};
use std::error::Error;
use std::os::fd::{FromRawFd, RawFd};
use std::os::unix::net::UnixStream;
use std::sync::mpsc;
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::thread;
use ferese_protocols::shell::v1::client::{
ferese_shell_manager_v1::FereseShellManagerV1,
ferese_shell_v1::{self, FereseShellV1},
};
use wayland_client::{
Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop,
globals::{GlobalListContents, registry_queue_init},
protocol::wl_registry,
};
use ferese_protocols::shell::v1::client::ferese_shell_manager_v1::FereseShellManagerV1;
use ferese_protocols::shell::v1::client::ferese_shell_v1;
use ferese_protocols::shell::v1::client::ferese_shell_v1::FereseShellV1;
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry;
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop};
#[derive(Clone, Debug, Default)]
pub(crate) struct ShellSnapshot {
@@ -116,11 +110,7 @@ impl ShellControl {
match self.updates.try_recv() {
Ok(ControlUpdate::Logout(serial, output)) => poll.logout = Some((serial, output)),
Ok(ControlUpdate::LogoutCancelled(serial)) => {
if poll
.logout
.as_ref()
.is_some_and(|(pending, _)| *pending == serial)
{
if poll.logout.as_ref().is_some_and(|(pending, _)| *pending == serial) {
poll.logout = None;
}
poll.logout_cancelled.push(serial);
@@ -174,8 +164,8 @@ impl ShellControl {
}
fn control_connection() -> Result<Connection, Box<dyn Error>> {
let raw_fd = std::env::var_os("FERESE_SHELL_CONTROL_SOCKET")
.ok_or("Ferese did not provide a shell-control connection")?;
let raw_fd =
std::env::var_os("FERESE_SHELL_CONTROL_SOCKET").ok_or("Ferese did not provide a shell-control connection")?;
let fd = raw_fd
.to_str()
.ok_or("FERESE_SHELL_CONTROL_SOCKET is not valid UTF-8")?
@@ -299,26 +289,14 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
fullscreen,
});
}
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),
)
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,
} => {
let _ = state
.sender
.send(ControlUpdate::Logout(serial, output_name));
ferese_shell_v1::Event::LogoutRequested { serial, output_name } => {
let _ = state.sender.send(ControlUpdate::Logout(serial, output_name));
}
ferese_shell_v1::Event::LogoutCancelled { serial } => {
let _ = state.sender.send(ControlUpdate::LogoutCancelled(serial));
+1 -2
View File
@@ -6,8 +6,7 @@ pub(super) struct Entry {
pub(super) fn load() -> Result<Vec<Entry>, String> {
let entries: Vec<Entry> =
serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?)
.map_err(|error| error.to_string())?;
serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?).map_err(|error| error.to_string())?;
if entries.len() > 256
|| entries
.iter()
File diff suppressed because it is too large. Load diff
+36 -108
View File
@@ -1,6 +1,5 @@
//! Draw-only popup transform; layout stays stable while its presentation animates.
use cosmic::iced::advanced::Renderer as _;
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme};
@@ -42,8 +41,7 @@ impl Settings {
}
}
static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new();
static SHELL_RADIUS: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(14.0_f32.to_bits());
static SHELL_RADIUS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(14.0_f32.to_bits());
/// All shell shapes share one radius, capped to fit smaller controls.
pub(crate) fn radius(limit: f32) -> f32 {
@@ -97,17 +95,14 @@ impl PopupMotion {
}
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
let duration = self
.settings
.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
let duration = self.settings.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
if duration.is_zero() {
return self.target;
}
let Some(started) = self.started else {
return self.start;
};
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32())
.min(1.0);
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32()).min(1.0);
if t == 1.0 {
return self.target;
}
@@ -145,14 +140,7 @@ pub(crate) fn button<'a, M: Clone + 'a>(
let active = progress.clone();
let hovered = progress.clone();
let paint = move |progress: f32, pressed: bool| {
ferese_theme::controls::shell_button(
foreground,
selected,
opacity,
radius(14.),
progress,
pressed,
)
ferese_theme::controls::shell_button(foreground, selected, opacity, radius(14.), progress, pressed)
};
let content = button
.class(cosmic::theme::Button::Custom {
@@ -181,12 +169,7 @@ struct HoverState {
}
impl HoverState {
fn advance(
&mut self,
target: f32,
now: std::time::Instant,
duration: std::time::Duration,
) -> bool {
fn advance(&mut self, target: f32, now: std::time::Instant, duration: std::time::Duration) -> bool {
if self.target != target {
self.start = self.current;
self.target = target;
@@ -196,8 +179,7 @@ impl HoverState {
1.0
} else {
self.started.map_or(1.0, |start| {
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32())
.min(1.0)
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32()).min(1.0)
})
};
self.current = self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t);
@@ -222,9 +204,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
self.content.as_widget().a11y_nodes(layout, &tree.children[0], cursor)
}
fn tag(&self) -> widget::tree::Tag {
@@ -298,17 +278,10 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.progress
.set(tree.state.downcast_ref::<HoverState>().current);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
self.progress.set(tree.state.downcast_ref::<HoverState>().current);
self.content
.as_widget()
.draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport);
}
fn mouse_interaction(
@@ -319,13 +292,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
self.content
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
fn operate(
@@ -348,22 +317,13 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
self.content
.as_widget_mut()
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
}
}
pub fn animated<'a, M: 'a>(
content: Element<'a, M>,
progress: f32,
regions: Regions,
radius: f32,
) -> Element<'a, M> {
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> {
Element::new(Motion {
content,
progress,
@@ -445,9 +405,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
}
*self.regions.lock().unwrap() = collector.into_regions(self.translation(), self.radius);
let cursor = self.cursor(cursor);
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
self.content
.as_widget_mut()
.update(tree, event, layout, cursor, renderer, clipboard, shell, viewport);
}
fn draw(
@@ -463,15 +423,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
let v = self.translation();
let transform = Transformation::translate(v.x, v.y);
renderer.with_transformation(transform, |renderer| {
self.content.as_widget().draw(
tree,
renderer,
theme,
style,
layout,
self.cursor(cursor),
viewport,
)
self.content
.as_widget()
.draw(tree, renderer, theme, style, layout, self.cursor(cursor), viewport)
});
}
@@ -483,13 +437,9 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
tree,
layout,
self.cursor(cursor),
viewport,
renderer,
)
self.content
.as_widget()
.mouse_interaction(tree, layout, self.cursor(cursor), viewport, renderer)
}
fn operate(
@@ -499,9 +449,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
self.content.as_widget_mut().operate(tree, layout, renderer, operation);
}
}
@@ -557,11 +505,7 @@ mod tests {
fn centered_modal_material_tracks_surface_position_and_animation() {
let mut collector = CollectRegions(Vec::new());
let card = Rectangle::new((380.0, 420.0).into(), (440.0, 220.0).into());
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!(
collector.into_regions(Vector::new(0.0, -2.0), 14.0),
vec![[380, 418, 440, 220, 14]]
@@ -572,29 +516,16 @@ mod tests {
fn popup_waits_for_configuration_then_opens_and_closes_faster() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings::default());
assert_eq!(
motion.progress_at(now + std::time::Duration::from_secs(30)),
0.0
);
assert_eq!(motion.progress_at(now + std::time::Duration::from_secs(30)), 0.0);
motion.begin(now);
// Later configure/focus notifications must not restart the clock.
motion.begin(now + std::time::Duration::from_millis(90));
assert!(
(motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001
);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_millis(200)),
1.0
);
assert!((motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001);
assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(200)), 1.0);
motion.retarget(0.0, now + std::time::Duration::from_millis(200));
assert!(motion.closing());
assert!(
(motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001
);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_millis(340)),
0.0
);
assert!((motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001);
assert_eq!(motion.progress_at(now + std::time::Duration::from_millis(340)), 0.0);
}
#[test]
@@ -613,10 +544,7 @@ mod tests {
let before = motion.progress_at(reopen);
motion.retarget(1.0, reopen);
assert_eq!(motion.progress_at(reopen), before);
assert_eq!(
motion.progress_at(reopen + std::time::Duration::from_secs(1)),
1.0
);
assert_eq!(motion.progress_at(reopen + std::time::Duration::from_secs(1)), 1.0);
for settings in [
Settings {
reduced_motion: true,
+6 -10
View File
@@ -1,5 +1,7 @@
//! Atomic, conflict-checked note edits; always called from a worker thread.
use std::{fs, io::Write, path::Path};
use std::fs;
use std::io::Write;
use std::path::Path;
#[derive(Clone, Debug)]
pub enum Edit {
@@ -63,13 +65,10 @@ pub fn save(path: &Path, edits: &[Edit]) -> Result<(), String> {
file.as_file()
.set_permissions(fs::metadata(path).map_err(|e| e.to_string())?.permissions())
.map_err(|e| e.to_string())?;
file.write_all(updated.as_bytes())
.map_err(|e| e.to_string())?;
file.write_all(updated.as_bytes()).map_err(|e| e.to_string())?;
file.as_file().sync_all().map_err(|e| e.to_string())?;
if fs::read_to_string(path).map_err(|e| e.to_string())? != source {
return Err(
"Config changed while saving; your note remains open. Edit again to retry.".into(),
);
return Err("Config changed while saving; your note remains open. Edit again to retry.".into());
}
file.persist(path).map_err(|e| e.to_string())?;
fs::File::open(parent)
@@ -131,10 +130,7 @@ desktop-widgets {
let source = fs::read_to_string(&path).unwrap();
assert!(source.contains("// keep this"));
assert!(source.contains("#123456"));
let notes = crate::config::parse_source(&source)
.unwrap()
.desktop_widgets
.notes;
let notes = crate::config::parse_source(&source).unwrap().desktop_widgets.notes;
assert_eq!(notes[0].text, "one\ntwo");
assert_eq!((notes[0].margin_x, notes[0].margin_y), (50, 60));
assert_eq!(notes[0].anchor, ferese_core::desktop::Anchor::TopLeft);
+33 -88
View File
@@ -1,15 +1,12 @@
use super::*;
use cosmic::iced::widget::scrollable::{Direction, Scrollbar};
use cosmic::widget::{column, scrollable};
use super::*;
const POPUP_WIDTH: u32 = 368;
fn popup_height(notice: &notifications::Notice, count: usize) -> u32 {
let body = if notice.body.is_empty() && count == 1 {
0
} else {
22
};
let body = if notice.body.is_empty() && count == 1 { 0 } else { 22 };
let actions = if notice.live && notice.actions.iter().any(|(key, _)| key != "default") {
39
} else {
@@ -33,18 +30,13 @@ fn history_height(center: &notifications::Center) -> u32 {
} else {
1
};
popup_height(notice, count)
+ wrapped_lines.saturating_sub(1) as u32 * 17
+ if count > 1 { 12 } else { 0 }
popup_height(notice, count) + wrapped_lines.saturating_sub(1) as u32 * 17 + if count > 1 { 12 } else { 0 }
};
let cards: u32 = groups
.iter()
.map(|group| {
if group.len() > 1 && center.expanded_apps.contains(&group[0].app) {
32 + group
.iter()
.map(|notice| card_height(notice, 1))
.sum::<u32>()
32 + group.iter().map(|notice| card_height(notice, 1)).sum::<u32>()
+ group.len().saturating_sub(1) as u32 * 8
} else {
card_height(group[0], group.len())
@@ -188,9 +180,10 @@ impl FereseShell {
.outputs
.iter()
.find(|entry| {
self.snapshot.outputs.iter().any(|output| {
output.focused && Some(output.name.as_str()) == entry.name.as_deref()
})
self.snapshot
.outputs
.iter()
.any(|output| output.focused && Some(output.name.as_str()) == entry.name.as_deref())
})
.or_else(|| self.outputs.first())
.map(|entry| entry.output.clone());
@@ -213,9 +206,7 @@ impl FereseShell {
.notifications
.popup_groups()
.iter()
.map(|(notice, _, count)| {
popup_height(notice, *count) + if *count > 1 { 12 } else { 0 }
})
.map(|(notice, _, count)| popup_height(notice, *count) + if *count > 1 { 12 } else { 0 })
.sum::<u32>()
+ count.saturating_sub(1) as u32 * 8
+ 8;
@@ -299,8 +290,7 @@ impl FereseShell {
let id = notice.id;
let mut bold = *SHELL_FONT.get().unwrap().read().unwrap();
bold.weight = cosmic::iced::font::Weight::Bold;
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty()
{
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() {
accented_icon(ferese_theme::icons::FERESE, 18, accent, accent)
} else if notice.icon.starts_with('/') {
icon::icon(icon::from_path(notice.icon.clone().into())).size(18)
@@ -308,8 +298,7 @@ impl FereseShell {
icon::from_name(notice.icon.as_str()).size(18).icon()
}
.opacity(opacity);
let icon_well =
container(app_icon)
let icon_well = container(app_icon)
.center_x(30)
.center_y(30)
.class(theme::Container::custom(move |_| container::Style {
@@ -329,8 +318,7 @@ impl FereseShell {
palette.surface_popover
}),
);
let (badge_fill, badge_text) =
ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
let (badge_fill, badge_text) = ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
let badge = container(
text(count.to_string())
.size(10)
@@ -375,12 +363,7 @@ impl FereseShell {
.align_y(alignment::Vertical::Center)
.push(icon)
.push(
container(
text(app)
.size(11)
.font(bold)
.class(theme::Text::Color(muted)),
)
container(text(app).size(11).font(bold).class(theme::Text::Color(muted)))
.width(Length::Fill)
.height(16)
.clip(true),
@@ -446,13 +429,13 @@ impl FereseShell {
let mut actions = row([]);
for (index, (key, label)) in available.into_iter().enumerate() {
if index > 0 {
actions = actions.push(
container(cosmic::iced::widget::Space::new().width(1).height(38))
.class(theme::Container::custom(move |_| container::Style {
actions =
actions.push(container(cosmic::iced::widget::Space::new().width(1).height(38)).class(
theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(divider)),
..Default::default()
})),
);
}),
));
}
actions = actions.push(card_button(
container(
@@ -472,17 +455,12 @@ impl FereseShell {
}
face = face
.push(
container(
cosmic::iced::widget::Space::new()
.width(Length::Fill)
.height(1),
)
.class(theme::Container::custom(move |_| {
container::Style {
container(cosmic::iced::widget::Space::new().width(Length::Fill).height(1)).class(
theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(divider)),
..Default::default()
}
})),
}),
),
)
.push(actions);
}
@@ -518,11 +496,7 @@ impl FereseShell {
// bottom edges, after the card's measured height.
let back = move |inset: f32| {
container(
container(
cosmic::iced::widget::Space::new()
.width(Length::Fill)
.height(6),
)
container(cosmic::iced::widget::Space::new().width(Length::Fill).height(6))
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color {
@@ -552,10 +526,7 @@ impl FereseShell {
Message::ToggleNotificationHistory
}));
} else if notice.live && notice.actions.iter().any(|(key, _)| key == "default") {
area = area.on_press(cosmic::Action::App(Message::InvokeNotification(
id,
"default".into(),
)));
area = area.on_press(cosmic::Action::App(Message::InvokeNotification(id, "default".into())));
}
area.into()
}
@@ -589,11 +560,7 @@ impl FereseShell {
} else {
format!(
"{count} {} · {} {}",
if count == 1 {
"notification"
} else {
"notifications"
},
if count == 1 { "notification" } else { "notifications" },
groups.len(),
if groups.len() == 1 { "app" } else { "apps" }
)
@@ -619,11 +586,7 @@ impl FereseShell {
well,
28,
));
let dnd_color = if self.notifications.dnd {
accent
} else {
muted
};
let dnd_color = if self.notifications.dnd { accent } else { muted };
let dnd_icon = if self.notifications.dnd {
ferese_theme::icons::NOTIFICATIONS_OFF
} else {
@@ -674,8 +637,7 @@ impl FereseShell {
self.notifications.dnd,
1.0,
));
let controls =
container(dnd)
let controls = container(dnd)
.padding(10)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
@@ -692,12 +654,7 @@ impl FereseShell {
.spacing(10)
.align_x(alignment::Horizontal::Center)
.push(
container(accented_icon(
ferese_theme::icons::NOTIFICATIONS,
26,
muted,
accent,
))
container(accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent))
.center_x(56)
.center_y(56)
.class(theme::Container::custom(move |_| container::Style {
@@ -720,11 +677,7 @@ impl FereseShell {
.size(11)
.class(theme::Text::Color(muted)),
);
content = content.push(
container(empty)
.center_x(Length::Fill)
.center_y(Length::Fill),
);
content = content.push(container(empty).center_x(Length::Fill).center_y(Length::Fill));
} else {
content = content.push(
row([])
@@ -786,12 +739,7 @@ impl FereseShell {
}
entries = entries.push(cards);
} else {
entries = entries.push(self.notification_card(
latest,
opacity,
true,
group.len(),
));
entries = entries.push(self.notification_card(latest, opacity, true, group.len()));
}
}
content = content.push(
@@ -806,8 +754,7 @@ impl FereseShell {
.height(history_height(&self.notifications) as f32)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: (!compositor_material)
.then_some(Background::Color(color(palette.surface_popover))),
background: (!compositor_material).then_some(Background::Color(color(palette.surface_popover))),
border: Border {
color: color_with_opacity(palette.text_muted, 0.18 * opacity),
width: 1.0,
@@ -819,9 +766,7 @@ impl FereseShell {
motion::animated(
panel,
progress,
surface
.map(|surface| surface.regions.clone())
.unwrap_or_default(),
surface.map(|surface| surface.regions.clone()).unwrap_or_default(),
palette.material_radius,
)
} else {
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more