Fix review findings across compositor and shell
This commit is contained in:
1 parent
5d9673aad3
commit
f7e5efb245
30 files changed
+1222
-181
No files matched your search
Generated
+1
@@ -1934,6 +1934,7 @@ dependencies = [
|
|||||||
"ureq",
|
"ureq",
|
||||||
"url",
|
"url",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
|
"wayland-server",
|
||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub(crate) struct Engine {
|
|||||||
cached_snapshot: Option<Arc<Snapshot>>,
|
cached_snapshot: Option<Arc<Snapshot>>,
|
||||||
publisher: Option<publication::Publisher>,
|
publisher: Option<publication::Publisher>,
|
||||||
watchers: BTreeMap<u64, (u64, SyncSender<Response>)>,
|
watchers: BTreeMap<u64, (u64, SyncSender<Response>)>,
|
||||||
|
locked: bool,
|
||||||
rates: BTreeMap<Owner, (u64, u16)>,
|
rates: BTreeMap<Owner, (u64, u16)>,
|
||||||
windows: BTreeMap<Id, u64>,
|
windows: BTreeMap<Id, u64>,
|
||||||
results: BTreeMap<Id, ferese_config::activities::ResultText>,
|
results: BTreeMap<Id, ferese_config::activities::ResultText>,
|
||||||
@@ -55,6 +56,7 @@ impl Default for Engine {
|
|||||||
.map_err(|error| tracing::warn!(%error, "activity snapshot worker unavailable"))
|
.map_err(|error| tracing::warn!(%error, "activity snapshot worker unavailable"))
|
||||||
.ok(),
|
.ok(),
|
||||||
watchers: BTreeMap::new(),
|
watchers: BTreeMap::new(),
|
||||||
|
locked: false,
|
||||||
rates: BTreeMap::new(),
|
rates: BTreeMap::new(),
|
||||||
results: BTreeMap::new(),
|
results: BTreeMap::new(),
|
||||||
windows: BTreeMap::new(),
|
windows: BTreeMap::new(),
|
||||||
@@ -159,7 +161,20 @@ impl Engine {
|
|||||||
pub fn remove_connection(&mut self, owner: u64) {
|
pub fn remove_connection(&mut self, owner: u64) {
|
||||||
self.watchers.remove(&owner);
|
self.watchers.remove(&owner);
|
||||||
}
|
}
|
||||||
|
pub fn set_locked(&mut self, locked: bool) {
|
||||||
|
self.locked = locked;
|
||||||
|
if locked {
|
||||||
|
if let Some(publisher) = &self.publisher {
|
||||||
|
publisher.revoke();
|
||||||
|
}
|
||||||
|
publication::locked(std::mem::take(&mut self.watchers).into_values().collect());
|
||||||
|
}
|
||||||
|
}
|
||||||
fn deliver(&mut self, recipients: Vec<(u64, SyncSender<Response>)>) {
|
fn deliver(&mut self, recipients: Vec<(u64, SyncSender<Response>)>) {
|
||||||
|
if self.locked {
|
||||||
|
publication::locked(recipients);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if recipients.is_empty() {
|
if recipients.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -174,7 +189,7 @@ impl Engine {
|
|||||||
self.deliver(vec![(request, sender)]);
|
self.deliver(vec![(request, sender)]);
|
||||||
}
|
}
|
||||||
pub fn watch(&mut self, connection: u64, request: u64, since: u64, sender: SyncSender<Response>) {
|
pub fn watch(&mut self, connection: u64, request: u64, since: u64, sender: SyncSender<Response>) {
|
||||||
if since != self.revision {
|
if self.locked || since != self.revision {
|
||||||
self.get(request, sender);
|
self.get(request, sender);
|
||||||
} else if self.watchers.len() < 32 {
|
} else if self.watchers.len() < 32 {
|
||||||
self.watchers.insert(connection, (request, sender));
|
self.watchers.insert(connection, (request, sender));
|
||||||
@@ -492,6 +507,32 @@ mod tests {
|
|||||||
template: Template::Progress,
|
template: Template::Progress,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn session_lock_cancels_pending_and_rejects_new_activity_reads() {
|
||||||
|
let mut engine = Engine::default();
|
||||||
|
let revision = engine.snapshot().revision;
|
||||||
|
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||||
|
engine.watch(1, 42, revision, send);
|
||||||
|
engine.set_locked(true);
|
||||||
|
let reply = receive.recv().unwrap();
|
||||||
|
assert_eq!(reply.id, 42);
|
||||||
|
assert_eq!(reply.error.unwrap().code, "session_locked");
|
||||||
|
assert!(reply.result.is_none());
|
||||||
|
engine.changed();
|
||||||
|
for since in [revision, engine.revision] {
|
||||||
|
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||||
|
engine.watch(2, 43, since, send);
|
||||||
|
assert_eq!(receive.recv().unwrap().error.unwrap().code, "session_locked");
|
||||||
|
}
|
||||||
|
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||||
|
engine.get(44, send);
|
||||||
|
assert_eq!(receive.recv().unwrap().error.unwrap().code, "session_locked");
|
||||||
|
engine.set_locked(false);
|
||||||
|
let (send, receive) = std::sync::mpsc::sync_channel(1);
|
||||||
|
engine.watch(3, 45, revision, send);
|
||||||
|
assert!(receive.recv_timeout(Duration::from_secs(2)).unwrap().result.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejected_updates_do_not_leave_a_snapshot_from_before_lease_expiry() {
|
fn rejected_updates_do_not_leave_a_snapshot_from_before_lease_expiry() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = Engine::default();
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
//! Bounded serialization and delivery outside the compositor event loop.
|
//! Bounded serialization and delivery outside the compositor event loop.
|
||||||
use ferese_ipc::{Response, activities::Snapshot};
|
use ferese_ipc::{Response, activities::Snapshot};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc,
|
Arc, RwLock,
|
||||||
mpsc::{self, SyncSender},
|
mpsc::{self, SyncSender},
|
||||||
};
|
};
|
||||||
|
|
||||||
type Recipient = (u64, SyncSender<Response>);
|
type Recipient = (u64, SyncSender<Response>);
|
||||||
struct Job {
|
struct Job {
|
||||||
|
epoch: u64,
|
||||||
snapshot: Arc<Snapshot>,
|
snapshot: Arc<Snapshot>,
|
||||||
recipients: Vec<Recipient>,
|
recipients: Vec<Recipient>,
|
||||||
}
|
}
|
||||||
pub(super) struct Publisher(SyncSender<Job>);
|
pub(super) struct Publisher(SyncSender<Job>, Arc<RwLock<u64>>);
|
||||||
impl Publisher {
|
impl Publisher {
|
||||||
pub fn new() -> std::io::Result<Self> {
|
pub fn new() -> std::io::Result<Self> {
|
||||||
let (send, receive) = mpsc::sync_channel::<Job>(8);
|
let (send, receive) = mpsc::sync_channel::<Job>(8);
|
||||||
|
let epoch = Arc::new(RwLock::new(0));
|
||||||
|
let worker_epoch = epoch.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("ferese-activity-snapshots".into())
|
.name("ferese-activity-snapshots".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -26,23 +29,47 @@ impl Publisher {
|
|||||||
cached = Some((job.snapshot.clone(), serde_json::json!(job.snapshot.as_ref())));
|
cached = Some((job.snapshot.clone(), serde_json::json!(job.snapshot.as_ref())));
|
||||||
}
|
}
|
||||||
let value = &cached.as_ref().unwrap().1;
|
let value = &cached.as_ref().unwrap().1;
|
||||||
for (id, sender) in job.recipients {
|
// Lock acquisition revokes queued/in-flight serialization.
|
||||||
// Client queues are bounded too. A slow/disconnected client
|
// Hold the guard through delivery to order sends before revocation.
|
||||||
// cannot block serialization or another subscriber.
|
deliver(job.epoch, &worker_epoch, job.recipients, value);
|
||||||
let _ = sender.try_send(Response::success(id, value.clone()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(Self(send))
|
Ok(Self(send, epoch))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke(&self) {
|
||||||
|
let mut epoch = self.1.write().expect("activity publication epoch");
|
||||||
|
*epoch = epoch.wrapping_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send(&self, snapshot: Arc<Snapshot>, recipients: Vec<Recipient>) {
|
pub fn send(&self, snapshot: Arc<Snapshot>, recipients: Vec<Recipient>) {
|
||||||
if let Err(error) = self.0.try_send(Job { snapshot, recipients }) {
|
let epoch = *self.1.read().expect("activity publication epoch");
|
||||||
|
if let Err(error) = self.0.try_send(Job {
|
||||||
|
epoch,
|
||||||
|
snapshot,
|
||||||
|
recipients,
|
||||||
|
}) {
|
||||||
let (mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job)) = error;
|
let (mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job)) = error;
|
||||||
unavailable(job.recipients);
|
unavailable(job.recipients);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn deliver(epoch: u64, current: &RwLock<u64>, recipients: Vec<Recipient>, value: &serde_json::Value) {
|
||||||
|
let current = current.read().expect("activity publication epoch");
|
||||||
|
if epoch != *current {
|
||||||
|
locked(recipients);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (id, sender) in recipients {
|
||||||
|
// A slow/disconnected client cannot block another subscriber.
|
||||||
|
let _ = sender.try_send(Response::success(id, value.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) fn locked(recipients: Vec<Recipient>) {
|
||||||
|
for (id, sender) in recipients {
|
||||||
|
let _ = sender.try_send(Response::error(id, "session_locked", "Session is locked"));
|
||||||
|
}
|
||||||
|
}
|
||||||
pub(super) fn unavailable(recipients: Vec<Recipient>) {
|
pub(super) fn unavailable(recipients: Vec<Recipient>) {
|
||||||
for (id, sender) in recipients {
|
for (id, sender) in recipients {
|
||||||
let _ = sender.try_send(Response::error(
|
let _ = sender.try_send(Response::error(
|
||||||
@@ -58,10 +85,29 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_revokes_serialized_jobs_even_after_unlock() {
|
||||||
|
let (send, _receive) = mpsc::sync_channel(1);
|
||||||
|
let publisher = Publisher(send, Arc::new(RwLock::new(0)));
|
||||||
|
let job_epoch = *publisher.1.read().unwrap();
|
||||||
|
publisher.revoke();
|
||||||
|
let (reply, receive) = mpsc::sync_channel(1);
|
||||||
|
deliver(
|
||||||
|
job_epoch,
|
||||||
|
&publisher.1,
|
||||||
|
vec![(9, reply)],
|
||||||
|
&serde_json::json!({"title": "private"}),
|
||||||
|
);
|
||||||
|
let response = receive.recv().unwrap();
|
||||||
|
assert_eq!(response.id, 9);
|
||||||
|
assert!(response.result.is_none());
|
||||||
|
assert_eq!(response.error.unwrap().code, "session_locked");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn saturated_queue_rejects_without_blocking_and_slow_clients_do_not_block_others() {
|
fn saturated_queue_rejects_without_blocking_and_slow_clients_do_not_block_others() {
|
||||||
let (send, _receive) = mpsc::sync_channel(0);
|
let (send, _receive) = mpsc::sync_channel(0);
|
||||||
let publisher = Publisher(send);
|
let publisher = Publisher(send, Arc::new(RwLock::new(0)));
|
||||||
let (reply, receive) = mpsc::sync_channel(1);
|
let (reply, receive) = mpsc::sync_channel(1);
|
||||||
publisher.send(Arc::new(Snapshot::default()), vec![(7, reply)]);
|
publisher.send(Arc::new(Snapshot::default()), vec![(7, reply)]);
|
||||||
let response = receive.recv_timeout(Duration::from_secs(1)).unwrap();
|
let response = receive.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||||
|
|||||||
@@ -350,8 +350,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
|||||||
.as_mut()
|
.as_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.topology
|
.topology
|
||||||
.devices
|
.observe_devices(&[(primary_path.clone(), topology::device_identity(&primary_path))]);
|
||||||
.insert(primary_path.clone(), topology::DeviceState::known(&primary_path));
|
|
||||||
lid::request_refresh(state, false);
|
lid::request_refresh(state, false);
|
||||||
|
|
||||||
event_loop
|
event_loop
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ use std::path::PathBuf;
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(super) enum Availability {
|
pub(super) enum Availability<Node = DrmNode> {
|
||||||
Ready(DrmNode),
|
Ready(Node),
|
||||||
Degraded(DrmNode),
|
Degraded(Node),
|
||||||
Missing,
|
Missing,
|
||||||
Unavailable,
|
Unavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct DeviceState {
|
pub(super) struct DeviceState<Node = DrmNode> {
|
||||||
pub availability: Availability,
|
pub availability: Availability<Node>,
|
||||||
identity: Option<PathBuf>,
|
identity: Option<PathBuf>,
|
||||||
failures: u32,
|
failures: u32,
|
||||||
retry_at: Option<Instant>,
|
retry_at: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DeviceState {
|
impl<Node> Default for DeviceState<Node> {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
availability: Availability::Missing,
|
availability: Availability::Missing,
|
||||||
@@ -29,19 +29,12 @@ impl Default for DeviceState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn device_identity(path: &Path) -> Option<PathBuf> {
|
pub(super) fn device_identity(path: &Path) -> Option<PathBuf> {
|
||||||
// The parent hardware identity survives cardN renumbering after unplug.
|
// The parent hardware identity survives cardN renumbering after unplug.
|
||||||
std::fs::canonicalize(Path::new("/sys/class/drm").join(path.file_name()?).join("device")).ok()
|
std::fs::canonicalize(Path::new("/sys/class/drm").join(path.file_name()?).join("device")).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceState {
|
impl<Node> DeviceState<Node> {
|
||||||
pub fn known(path: &Path) -> Self {
|
|
||||||
Self {
|
|
||||||
identity: device_identity(path),
|
|
||||||
..Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_path<'a>(&self, original: &Path, observed: &'a [(PathBuf, Option<PathBuf>)]) -> Option<&'a Path> {
|
fn current_path<'a>(&self, original: &Path, observed: &'a [(PathBuf, Option<PathBuf>)]) -> Option<&'a Path> {
|
||||||
observed
|
observed
|
||||||
.iter()
|
.iter()
|
||||||
@@ -58,7 +51,7 @@ impl DeviceState {
|
|||||||
self.retry_at = Some(now + Duration::from_secs(1u64 << self.failures.saturating_sub(1).min(5)));
|
self.retry_at = Some(now + Duration::from_secs(1u64 << self.failures.saturating_sub(1).min(5)));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ready(&mut self, node: DrmNode) {
|
pub fn ready(&mut self, node: Node) {
|
||||||
self.availability = Availability::Ready(node);
|
self.availability = Availability::Ready(node);
|
||||||
self.failures = 0;
|
self.failures = 0;
|
||||||
self.retry_at = None;
|
self.retry_at = None;
|
||||||
@@ -69,12 +62,18 @@ impl DeviceState {
|
|||||||
self.failures = 0;
|
self.failures = 0;
|
||||||
self.retry_at = None;
|
self.retry_at = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn degraded(&mut self, node: Node, now: Instant) {
|
||||||
|
self.failed(now);
|
||||||
|
self.availability = Availability::Degraded(node);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
pub(super) struct Topology<Node = DrmNode> {
|
||||||
pub(super) struct Topology {
|
// Hardware paths are stable keys; use the card path only when sysfs identity
|
||||||
pub devices: HashMap<PathBuf, DeviceState>,
|
// is unavailable. A reused card number must not replace another GPU's slot.
|
||||||
pub invalidated: HashSet<DrmNode>,
|
pub devices: HashMap<PathBuf, DeviceState<Node>>,
|
||||||
|
pub invalidated: HashSet<Node>,
|
||||||
pub reconciling: bool,
|
pub reconciling: bool,
|
||||||
pub dirty: bool,
|
pub dirty: bool,
|
||||||
pub layout_dirty: bool,
|
pub layout_dirty: bool,
|
||||||
@@ -84,7 +83,61 @@ pub(super) struct Topology {
|
|||||||
pub coalescer: crate::output_policy::Debouncer,
|
pub coalescer: crate::output_policy::Debouncer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Topology {
|
impl<Node> Default for Topology<Node> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
devices: HashMap::new(),
|
||||||
|
invalidated: HashSet::new(),
|
||||||
|
reconciling: false,
|
||||||
|
dirty: false,
|
||||||
|
layout_dirty: false,
|
||||||
|
remember_applied: false,
|
||||||
|
retry_timer: None,
|
||||||
|
settle_timer: None,
|
||||||
|
coalescer: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node identity is opaque to reconciliation; tests use integers without opening
|
||||||
|
// DRM devices or depending on which nodes happen to exist in the host's sysfs.
|
||||||
|
impl<Node: Copy + Eq + std::hash::Hash> Topology<Node> {
|
||||||
|
pub fn observe_devices(&mut self, observed: &[(PathBuf, Option<PathBuf>)]) {
|
||||||
|
for (path, identity) in observed {
|
||||||
|
let key = identity.as_ref().unwrap_or(path);
|
||||||
|
let fallback = if identity.is_some() {
|
||||||
|
self.devices.remove(path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if self.devices.contains_key(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Preserve a slot first discovered before its sysfs identity was
|
||||||
|
// available, including its live DRM resources and retry state.
|
||||||
|
let mut slot = fallback.unwrap_or_default();
|
||||||
|
slot.identity = identity.clone();
|
||||||
|
self.devices.insert(key.clone(), slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retire_changed_devices(
|
||||||
|
&mut self,
|
||||||
|
observed: &[(PathBuf, Option<PathBuf>)],
|
||||||
|
node_for_path: impl Fn(&Path) -> Option<Node>,
|
||||||
|
) -> HashSet<Node> {
|
||||||
|
let mut retired = HashSet::new();
|
||||||
|
for (key, slot) in &mut self.devices {
|
||||||
|
if let Availability::Ready(previous) | Availability::Degraded(previous) = slot.availability
|
||||||
|
&& slot.current_path(key, observed).and_then(&node_for_path) != Some(previous)
|
||||||
|
{
|
||||||
|
retired.insert(previous);
|
||||||
|
slot.missing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retired
|
||||||
|
}
|
||||||
|
|
||||||
pub fn request(&mut self, active: bool) -> bool {
|
pub fn request(&mut self, active: bool) -> bool {
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
active && !self.reconciling
|
active && !self.reconciling
|
||||||
@@ -133,8 +186,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
|||||||
if let Some(token) = backend.topology.retry_timer.take() {
|
if let Some(token) = backend.topology.retry_timer.take() {
|
||||||
state.loop_handle.remove(token);
|
state.loop_handle.remove(token);
|
||||||
}
|
}
|
||||||
let mut paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
|
let mut invalidated = std::mem::take(&mut backend.topology.invalidated);
|
||||||
let invalidated = std::mem::take(&mut backend.topology.invalidated);
|
|
||||||
let observed = all_gpus(backend.session.seat()).map(|paths| {
|
let observed = all_gpus(backend.session.seat()).map(|paths| {
|
||||||
paths
|
paths
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -152,19 +204,16 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
|||||||
automatic && super::super::power::low_power(backend.low_power, super::super::power::battery_percent());
|
automatic && super::super::power::low_power(backend.low_power, super::super::power::battery_percent());
|
||||||
|
|
||||||
if let Ok(observed) = &observed {
|
if let Ok(observed) = &observed {
|
||||||
for (path, identity) in observed {
|
backend.topology.observe_devices(observed);
|
||||||
if !backend
|
// Retire every obsolete node before opening any replacement. Otherwise
|
||||||
|
// a later missing slot could remove a new GPU that reused its dev_t.
|
||||||
|
invalidated.extend(
|
||||||
|
backend
|
||||||
.topology
|
.topology
|
||||||
.devices
|
.retire_changed_devices(observed, |path| DrmNode::from_path(path).ok()),
|
||||||
.values()
|
);
|
||||||
.any(|slot| slot.identity == *identity && identity.is_some())
|
|
||||||
&& !backend.topology.devices.contains_key(path)
|
|
||||||
{
|
|
||||||
backend.topology.devices.insert(path.clone(), DeviceState::known(path));
|
|
||||||
paths.push(path.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let mut paths = backend.topology.devices.keys().cloned().collect::<Vec<_>>();
|
||||||
paths.sort();
|
paths.sort();
|
||||||
let mut ready = Vec::new();
|
let mut ready = Vec::new();
|
||||||
state.begin_desktop_transition();
|
state.begin_desktop_transition();
|
||||||
@@ -290,8 +339,8 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
|||||||
}) {
|
}) {
|
||||||
tracing::warn!(%error, "monitor policy validation failed; retaining applied outputs");
|
tracing::warn!(%error, "monitor policy validation failed; retaining applied outputs");
|
||||||
state.direct_backend.as_mut().unwrap().output_error = Some(error);
|
state.direct_backend.as_mut().unwrap().output_error = Some(error);
|
||||||
for (path, _) in &ready {
|
for (path, node) in &ready {
|
||||||
slot_mut(state, path).failed(Instant::now());
|
slot_mut(state, path).degraded(*node, Instant::now());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Activate destinations before disabling former owners, including across GPUs.
|
// Activate destinations before disabling former owners, including across GPUs.
|
||||||
@@ -314,8 +363,7 @@ pub(super) fn reconcile_outputs(state: &mut Ferese, reactivate: bool) {
|
|||||||
entry.ready(*node);
|
entry.ready(*node);
|
||||||
succeeded.push(*node);
|
succeeded.push(*node);
|
||||||
} else {
|
} else {
|
||||||
entry.failed(Instant::now());
|
entry.degraded(*node, Instant::now());
|
||||||
entry.availability = Availability::Degraded(*node);
|
|
||||||
// Device boundaries are not cross-GPU atomic. Compensate already
|
// Device boundaries are not cross-GPU atomic. Compensate already
|
||||||
// applied devices using their last actual hardware settings.
|
// applied devices using their last actual hardware settings.
|
||||||
if !known_good.outputs.is_empty() {
|
if !known_good.outputs.is_empty() {
|
||||||
@@ -482,9 +530,131 @@ pub(super) fn hotplug(state: &mut Ferese) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replacement_gpu_at_the_same_card_path_gets_its_own_slot() {
|
||||||
|
let card = PathBuf::from("/dev/dri/card0");
|
||||||
|
let a = PathBuf::from("/sys/devices/gpu-a");
|
||||||
|
let b = PathBuf::from("/sys/devices/gpu-b");
|
||||||
|
let node = 1;
|
||||||
|
let mut topology = Topology::<u64>::default();
|
||||||
|
topology.observe_devices(&[(card.clone(), Some(a.clone()))]);
|
||||||
|
topology.devices.get_mut(&a).unwrap().ready(node);
|
||||||
|
// A policy failure keeps the device open, so it must retain ownership
|
||||||
|
// of its node until replacement retirement runs.
|
||||||
|
topology.devices.get_mut(&a).unwrap().degraded(node, Instant::now());
|
||||||
|
let observed = [(card.clone(), Some(b.clone()))];
|
||||||
|
topology.observe_devices(&observed);
|
||||||
|
assert_eq!(
|
||||||
|
topology.retire_changed_devices(&observed, |_| Some(node)),
|
||||||
|
HashSet::from([node])
|
||||||
|
);
|
||||||
|
assert_eq!(topology.devices[&a].availability, Availability::Missing);
|
||||||
|
assert_eq!(topology.devices[&a].current_path(&a, &observed), None);
|
||||||
|
assert_eq!(topology.devices[&b].current_path(&b, &observed), Some(card.as_path()));
|
||||||
|
topology.devices.get_mut(&b).unwrap().ready(node);
|
||||||
|
assert!(topology.retire_changed_devices(&observed, |_| Some(node)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renumbered_and_returning_gpus_keep_independent_slots() {
|
||||||
|
let a = PathBuf::from("/sys/devices/gpu-a");
|
||||||
|
let b = PathBuf::from("/sys/devices/gpu-b");
|
||||||
|
let card0 = PathBuf::from("/dev/dri/card0");
|
||||||
|
let card2 = PathBuf::from("/dev/dri/card2");
|
||||||
|
let node0 = 1;
|
||||||
|
let node2 = 2;
|
||||||
|
let mut topology = Topology::<u64>::default();
|
||||||
|
topology.observe_devices(&[(card0.clone(), Some(a.clone()))]);
|
||||||
|
topology.devices.get_mut(&a).unwrap().ready(node0);
|
||||||
|
let observed = [(card0.clone(), Some(b.clone())), (card2.clone(), Some(a.clone()))];
|
||||||
|
for _ in 0..2 {
|
||||||
|
topology.observe_devices(&observed);
|
||||||
|
}
|
||||||
|
assert_eq!(topology.devices.len(), 2);
|
||||||
|
assert_eq!(topology.devices[&a].current_path(&a, &observed), Some(card2.as_path()));
|
||||||
|
assert_eq!(topology.devices[&b].current_path(&b, &observed), Some(card0.as_path()));
|
||||||
|
assert_eq!(
|
||||||
|
topology.retire_changed_devices(&observed, |path| Some(if path == card0 { node0 } else { node2 })),
|
||||||
|
HashSet::from([node0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovering_sysfs_identity_preserves_the_existing_slot() {
|
||||||
|
let card = PathBuf::from("/dev/dri/card0");
|
||||||
|
let identity = PathBuf::from("/sys/devices/gpu");
|
||||||
|
let node = 1;
|
||||||
|
let mut topology = Topology::<u64>::default();
|
||||||
|
topology.observe_devices(&[(card.clone(), None)]);
|
||||||
|
topology.devices.get_mut(&card).unwrap().ready(node);
|
||||||
|
topology.observe_devices(&[(card.clone(), Some(identity.clone()))]);
|
||||||
|
assert_eq!(topology.devices.len(), 1);
|
||||||
|
assert_eq!(topology.devices[&identity].availability, Availability::Ready(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn temporary_identity_loss_reconciles_through_the_card_path() {
|
||||||
|
let card = PathBuf::from("/dev/dri/card0");
|
||||||
|
let identity = PathBuf::from("/sys/devices/gpu");
|
||||||
|
let known = [(card.clone(), Some(identity.clone()))];
|
||||||
|
let unknown = [(card.clone(), None)];
|
||||||
|
let mut topology = Topology::<u64>::default();
|
||||||
|
topology.observe_devices(&known);
|
||||||
|
topology.devices.get_mut(&identity).unwrap().ready(1);
|
||||||
|
|
||||||
|
topology.observe_devices(&unknown);
|
||||||
|
assert_eq!(
|
||||||
|
topology.retire_changed_devices(&unknown, |_| Some(1)),
|
||||||
|
HashSet::from([1])
|
||||||
|
);
|
||||||
|
assert_eq!(topology.devices[&identity].availability, Availability::Missing);
|
||||||
|
assert_eq!(
|
||||||
|
topology.devices[&card].current_path(&card, &unknown),
|
||||||
|
Some(card.as_path())
|
||||||
|
);
|
||||||
|
// Reconciliation reopens the node using its card-path fallback.
|
||||||
|
topology.devices.get_mut(&card).unwrap().ready(1);
|
||||||
|
|
||||||
|
topology.observe_devices(&known);
|
||||||
|
assert_eq!(topology.devices.len(), 1);
|
||||||
|
assert!(!topology.devices.contains_key(&card));
|
||||||
|
assert_eq!(
|
||||||
|
topology.devices[&identity].current_path(&identity, &known),
|
||||||
|
Some(card.as_path())
|
||||||
|
);
|
||||||
|
// The reopened node is still valid and can be reused for this identity.
|
||||||
|
assert!(topology.retire_changed_devices(&known, |_| Some(1)).is_empty());
|
||||||
|
topology.devices.get_mut(&identity).unwrap().ready(1);
|
||||||
|
assert!(topology.retire_changed_devices(&known, |_| Some(1)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swapping_device_numbers_retires_both_before_reopening_either() {
|
||||||
|
let a = PathBuf::from("/sys/devices/gpu-a");
|
||||||
|
let b = PathBuf::from("/sys/devices/gpu-b");
|
||||||
|
let card0 = PathBuf::from("/dev/dri/card0");
|
||||||
|
let card1 = PathBuf::from("/dev/dri/card1");
|
||||||
|
let mut topology = Topology::<u64>::default();
|
||||||
|
topology.observe_devices(&[(card0.clone(), Some(a.clone())), (card1.clone(), Some(b.clone()))]);
|
||||||
|
topology.devices.get_mut(&a).unwrap().ready(0);
|
||||||
|
topology.devices.get_mut(&b).unwrap().ready(1);
|
||||||
|
let swapped = [(card0.clone(), Some(b.clone())), (card1.clone(), Some(a.clone()))];
|
||||||
|
let node_for_path = |path: &Path| Some(u64::from(path == card1));
|
||||||
|
topology.observe_devices(&swapped);
|
||||||
|
assert_eq!(
|
||||||
|
topology.retire_changed_devices(&swapped, node_for_path),
|
||||||
|
HashSet::from([0, 1])
|
||||||
|
);
|
||||||
|
assert_eq!(topology.devices[&a].availability, Availability::Missing);
|
||||||
|
assert_eq!(topology.devices[&b].availability, Availability::Missing);
|
||||||
|
topology.devices.get_mut(&a).unwrap().ready(1);
|
||||||
|
topology.devices.get_mut(&b).unwrap().ready(0);
|
||||||
|
assert!(topology.retire_changed_devices(&swapped, node_for_path).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() {
|
fn inactive_changes_coalesce_and_reconciliation_cannot_reenter() {
|
||||||
let mut topology = Topology::default();
|
let mut topology = Topology::<u64>::default();
|
||||||
assert!(!topology.request(false));
|
assert!(!topology.request(false));
|
||||||
assert!(!topology.request(false));
|
assert!(!topology.request(false));
|
||||||
assert!(topology.dirty);
|
assert!(topology.dirty);
|
||||||
@@ -497,7 +667,7 @@ mod tests {
|
|||||||
fn replug_matches_hardware_even_when_card_numbers_change() {
|
fn replug_matches_hardware_even_when_card_numbers_change() {
|
||||||
let original = Path::new("/dev/dri/card0");
|
let original = Path::new("/dev/dri/card0");
|
||||||
let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0");
|
let identity = PathBuf::from("/sys/devices/pci0000:00/0000:00:02.0");
|
||||||
let mut slot = DeviceState {
|
let mut slot = DeviceState::<u64> {
|
||||||
identity: Some(identity.clone()),
|
identity: Some(identity.clone()),
|
||||||
..DeviceState::default()
|
..DeviceState::default()
|
||||||
};
|
};
|
||||||
@@ -521,9 +691,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn unavailable_devices_retry_independently_with_capped_backoff() {
|
fn unavailable_devices_retry_independently_with_capped_backoff() {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut topology = Topology::default();
|
let mut topology = Topology::<u64>::default();
|
||||||
topology.devices.insert("/dev/dri/card0".into(), DeviceState::default());
|
topology.devices.insert("/dev/dri/card0".into(), DeviceState::default());
|
||||||
let healthy = DrmNode::from_dev_id(libc::makedev(226, 1)).unwrap();
|
let healthy = 1;
|
||||||
let mut second = DeviceState::default();
|
let mut second = DeviceState::default();
|
||||||
second.ready(healthy);
|
second.ready(healthy);
|
||||||
topology.devices.insert("/dev/dri/card1".into(), second);
|
topology.devices.insert("/dev/dri/card1".into(), second);
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn animation_speed(&self) -> Result<f64, ConfigError> {
|
pub fn animation_speed(&self) -> Result<f64, ConfigError> {
|
||||||
positive_animation_value(self.animations.speed, "speed")
|
self.animations
|
||||||
|
.validate_speed()
|
||||||
|
.map_err(|_| ConfigError::AnimationValue {
|
||||||
|
field: "speed",
|
||||||
|
value: self.animations.speed,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
|
pub fn spring_config(&self) -> Result<SpringConfig, ConfigError> {
|
||||||
@@ -60,14 +65,6 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn positive_animation_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
|
|
||||||
if value.is_finite() && value > 0.0 {
|
|
||||||
Ok(value)
|
|
||||||
} else {
|
|
||||||
Err(ConfigError::AnimationValue { field, value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn default_xkb_layout() -> String {
|
pub(super) fn default_xkb_layout() -> String {
|
||||||
"us".to_owned()
|
"us".to_owned()
|
||||||
}
|
}
|
||||||
@@ -151,3 +148,16 @@ impl Default for TouchpadConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
|
pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compositor_speed_uses_shared_motion_bounds() {
|
||||||
|
for speed in [1e-100, 0.009, 101.0, 1e100] {
|
||||||
|
let config = Config::parse_source(&format!("animations {{ speed {speed:e}; }}")).unwrap();
|
||||||
|
assert!(config.animation_speed().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,10 +48,20 @@ pub(crate) struct Engine {
|
|||||||
snapshot: Snapshot,
|
snapshot: Snapshot,
|
||||||
value: Value,
|
value: Value,
|
||||||
waiters: HashMap<u64, (u64, mpsc::SyncSender<Response>)>,
|
waiters: HashMap<u64, (u64, mpsc::SyncSender<Response>)>,
|
||||||
|
locked: bool,
|
||||||
commands: Option<async_channel::Sender<Command>>,
|
commands: Option<async_channel::Sender<Command>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
|
pub(crate) fn set_locked(&mut self, locked: bool) {
|
||||||
|
self.locked = locked;
|
||||||
|
if locked {
|
||||||
|
for (_, (id, response)) in self.waiters.drain() {
|
||||||
|
let _ = response.try_send(Response::error(id, "session_locked", "Session is locked"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn value(&self) -> Value {
|
pub(crate) fn value(&self) -> Value {
|
||||||
if self.value.is_null() {
|
if self.value.is_null() {
|
||||||
serde_json::to_value(&self.snapshot).expect("serializable media snapshot")
|
serde_json::to_value(&self.snapshot).expect("serializable media snapshot")
|
||||||
@@ -75,7 +85,9 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<Response>) {
|
pub(crate) fn watch(&mut self, owner: u64, id: u64, since: u64, response: mpsc::SyncSender<Response>) {
|
||||||
if since != self.snapshot.revision {
|
if self.locked {
|
||||||
|
let _ = response.try_send(Response::error(id, "session_locked", "Session is locked"));
|
||||||
|
} else if since != self.snapshot.revision {
|
||||||
let _ = response.try_send(Response::success(id, self.value()));
|
let _ = response.try_send(Response::success(id, self.value()));
|
||||||
} else {
|
} else {
|
||||||
self.waiters.insert(owner, (id, response));
|
self.waiters.insert(owner, (id, response));
|
||||||
|
|||||||
@@ -114,3 +114,28 @@ fn metadata_and_position_updates_do_not_recompute_idle_policy() {
|
|||||||
));
|
));
|
||||||
assert!(inhibition_changed(std::slice::from_ref(&before), &[]));
|
assert!(inhibition_changed(std::slice::from_ref(&before), &[]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_lock_cancels_pending_and_rejects_new_media_watches() {
|
||||||
|
let mut engine = Engine::default();
|
||||||
|
let (send, receive) = mpsc::sync_channel(1);
|
||||||
|
engine.watch(1, 42, 0, send);
|
||||||
|
engine.set_locked(true);
|
||||||
|
let reply = receive.recv().unwrap();
|
||||||
|
assert_eq!(reply.id, 42);
|
||||||
|
assert!(reply.result.is_none());
|
||||||
|
assert_eq!(reply.error.unwrap().code, "session_locked");
|
||||||
|
engine.update(Snapshot {
|
||||||
|
selected: Some(player("private", Playback::Playing).view),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
for since in [0, engine.snapshot.revision] {
|
||||||
|
let (send, receive) = mpsc::sync_channel(1);
|
||||||
|
engine.watch(2, 43, since, send);
|
||||||
|
assert_eq!(receive.recv().unwrap().error.unwrap().code, "session_locked");
|
||||||
|
}
|
||||||
|
engine.set_locked(false);
|
||||||
|
let (send, receive) = mpsc::sync_channel(1);
|
||||||
|
engine.watch(3, 44, 0, send);
|
||||||
|
assert!(receive.recv().unwrap().result.is_some());
|
||||||
|
}
|
||||||
+12
-1
@@ -1308,7 +1308,7 @@ fn virtual_terminal(symbol: u32, ctrl: bool, alt: bool, direct: bool) -> Option<
|
|||||||
}
|
}
|
||||||
|
|
||||||
(ctrl && alt && (keysyms::KEY_F1..=keysyms::KEY_F12).contains(&symbol))
|
(ctrl && alt && (keysyms::KEY_F1..=keysyms::KEY_F12).contains(&symbol))
|
||||||
.then_some((symbol - keysyms::KEY_F1 + 1) as i32)
|
.then(|| (symbol - keysyms::KEY_F1 + 1) as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emergency_shortcut_escape(symbol: u32, ctrl: bool, alt: bool) -> bool {
|
fn emergency_shortcut_escape(symbol: u32, ctrl: bool, alt: bool) -> bool {
|
||||||
@@ -1394,6 +1394,17 @@ mod tests {
|
|||||||
assert_eq!(virtual_terminal(keysyms::KEY_F7, false, true, true), None);
|
assert_eq!(virtual_terminal(keysyms::KEY_F7, false, true, true), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_keys_never_evaluate_virtual_terminal_arithmetic() {
|
||||||
|
for symbol in [0, keysyms::KEY_a, keysyms::KEY_Escape, keysyms::KEY_F12 + 1] {
|
||||||
|
for ctrl in [false, true] {
|
||||||
|
for alt in [false, true] {
|
||||||
|
assert_eq!(virtual_terminal(symbol, ctrl, alt, true), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn leaves_virtual_terminal_keys_to_nested_clients() {
|
fn leaves_virtual_terminal_keys_to_nested_clients() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -247,7 +247,13 @@ pub(crate) fn init(
|
|||||||
state.activities.get(call.request.id, call.response);
|
state.activities.get(call.request.id, call.response);
|
||||||
}
|
}
|
||||||
} else if call.request.command == "activity-watch" {
|
} else if call.request.command == "activity-watch" {
|
||||||
if let Err(error) = validate_request(&call.request) {
|
if state.session_lock.active() {
|
||||||
|
let _ = call.response.try_send(Response::error(
|
||||||
|
call.request.id,
|
||||||
|
"session_locked",
|
||||||
|
"IPC unavailable while session is locked",
|
||||||
|
));
|
||||||
|
} else if let Err(error) = validate_request(&call.request) {
|
||||||
let _ = call
|
let _ = call
|
||||||
.response
|
.response
|
||||||
.try_send(Response::error(call.request.id, error.code, error.message));
|
.try_send(Response::error(call.request.id, error.code, error.message));
|
||||||
|
|||||||
@@ -274,6 +274,74 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wallpaper_only_reload_applies_path_and_mode_changes() {
|
||||||
|
if !crate::startup_tests::private_runtime("reload::tests::wallpaper_only_reload_applies_path_and_mode_changes")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut events = calloop::EventLoop::try_new().unwrap();
|
||||||
|
let mut state = crate::startup_tests::state(&mut events);
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let image_path = directory.path().join("wallpaper.png");
|
||||||
|
image::RgbaImage::new(2, 2).save(&image_path).unwrap();
|
||||||
|
for mode in ["fit", "fill"] {
|
||||||
|
let source = format!("wallpaper {{ path \"{}\"; mode \"{mode}\"; }}", image_path.display());
|
||||||
|
let (_, runtime, candidate) = crate::config::prepare(&source, directory.path()).unwrap();
|
||||||
|
// Model a settled theme; changing only the wallpaper must still apply.
|
||||||
|
state.theme_engine.live.theme = candidate.theme.clone();
|
||||||
|
state.theme_engine.live.presented = candidate.theme;
|
||||||
|
state.reload_config_source(source.clone()).unwrap();
|
||||||
|
assert_eq!(state.wallpaper.configuration(), &runtime.wallpaper);
|
||||||
|
let revision = state.ipc_events.config_revision;
|
||||||
|
state.reload_config_source(source).unwrap();
|
||||||
|
assert_eq!(state.ipc_events.config_revision, revision);
|
||||||
|
}
|
||||||
|
let candidate = crate::config::prepare("", directory.path()).unwrap().2;
|
||||||
|
state.theme_engine.live.theme = candidate.theme.clone();
|
||||||
|
state.theme_engine.live.presented = candidate.theme.clone();
|
||||||
|
let wallpaper = crate::wallpaper::WallpaperConfig {
|
||||||
|
path: None,
|
||||||
|
..state.wallpaper.configuration().clone()
|
||||||
|
};
|
||||||
|
state.accept_theme(candidate, wallpaper.clone());
|
||||||
|
assert_eq!(state.wallpaper.configuration(), &wallpaper);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_keymap_reload_preserves_other_configuration() {
|
||||||
|
if !crate::startup_tests::private_runtime("reload::tests::rejected_keymap_reload_preserves_other_configuration")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut events = calloop::EventLoop::try_new().unwrap();
|
||||||
|
let mut state = crate::startup_tests::state(&mut events);
|
||||||
|
let before_xwayland = state.xwayland_config.enabled;
|
||||||
|
let before_activities = state.activities.snapshot().revision;
|
||||||
|
let before_layout = state.workspaces.active().layout.mode();
|
||||||
|
let before_input = state.input_settings.clone();
|
||||||
|
let mut runtime = crate::config::Config::default().runtime_config().unwrap();
|
||||||
|
runtime.xwayland.enabled = !before_xwayland;
|
||||||
|
runtime.layout_mode = ferese_core::LayoutMode::Tree;
|
||||||
|
// Exercise application failure after preparation, for example when an
|
||||||
|
// XKB file disappears before the compositor installs the new keymap.
|
||||||
|
runtime.input_settings.xkb_layout = "ferese_missing_keymap_for_reload_test".into();
|
||||||
|
let error = state.apply_runtime_config(runtime, &serde_json::json!({})).unwrap_err();
|
||||||
|
assert!(error.contains("keymap reload failed"), "{error}");
|
||||||
|
assert_eq!(state.xwayland_config.enabled, before_xwayland);
|
||||||
|
assert_eq!(state.activities.snapshot().revision, before_activities);
|
||||||
|
assert_eq!(state.workspaces.active().layout.mode(), before_layout);
|
||||||
|
assert_eq!(state.input_settings, before_input);
|
||||||
|
|
||||||
|
let mut runtime = crate::config::Config::default().runtime_config().unwrap();
|
||||||
|
runtime.xwayland.enabled = !before_xwayland;
|
||||||
|
runtime.layout_mode = ferese_core::LayoutMode::Tree;
|
||||||
|
state.apply_runtime_config(runtime, &serde_json::json!({})).unwrap();
|
||||||
|
assert_eq!(state.xwayland_config.enabled, !before_xwayland);
|
||||||
|
assert_eq!(state.workspaces.active().layout.mode(), ferese_core::LayoutMode::Tree);
|
||||||
|
assert!(state.activities.snapshot().revision > before_activities);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_reload_applies_once_and_rejects_invalid_configuration() {
|
fn worker_reload_applies_once_and_rejects_invalid_configuration() {
|
||||||
const CHILD: &str = "FERESE_WORKER_RELOAD_TEST_CHILD";
|
const CHILD: &str = "FERESE_WORKER_RELOAD_TEST_CHILD";
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ pub(super) fn layer_elements(
|
|||||||
let Some(mode) = output.current_mode() else {
|
let Some(mode) = output.current_mode() else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let output_crop = Rectangle::<i32, Physical>::from_size(mode.size);
|
let output_crop = layer_output_crop(mode.size, output.current_transform());
|
||||||
let layers = {
|
let layers = {
|
||||||
let map = layer_map_for_output(output);
|
let map = layer_map_for_output(output);
|
||||||
|
|
||||||
@@ -457,6 +457,10 @@ pub(super) fn layer_elements(
|
|||||||
elements
|
elements
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn layer_output_crop(size: Size<i32, Physical>, transform: Transform) -> Rectangle<i32, Physical> {
|
||||||
|
Rectangle::from_size(transform.transform_size(size))
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn cursor_elements(
|
pub(super) fn cursor_elements(
|
||||||
state: &Ferese,
|
state: &Ferese,
|
||||||
renderer: &mut GlesRenderer,
|
renderer: &mut GlesRenderer,
|
||||||
@@ -527,3 +531,38 @@ pub(super) fn cursor_elements(
|
|||||||
CursorImageStatus::Hidden => Vec::new(),
|
CursorImageStatus::Hidden => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod layer_crop_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layer_crop_keeps_bottom_edge_content_in_every_output_transform() {
|
||||||
|
use smithay::backend::renderer::element::solid::SolidColorRenderElement;
|
||||||
|
|
||||||
|
let mode = Size::<i32, Physical>::from((1920, 1080));
|
||||||
|
for transform in [
|
||||||
|
Transform::Normal,
|
||||||
|
Transform::_90,
|
||||||
|
Transform::_180,
|
||||||
|
Transform::_270,
|
||||||
|
Transform::Flipped,
|
||||||
|
Transform::Flipped90,
|
||||||
|
Transform::Flipped180,
|
||||||
|
Transform::Flipped270,
|
||||||
|
] {
|
||||||
|
let size = transform.transform_size(mode);
|
||||||
|
let geometry = Rectangle::new((0, size.h - 36).into(), (size.w, 36).into());
|
||||||
|
let panel = SolidColorRenderElement::new(
|
||||||
|
Id::new(),
|
||||||
|
geometry,
|
||||||
|
CommitCounter::default(),
|
||||||
|
[1.0; 4],
|
||||||
|
RenderElementKind::Unspecified,
|
||||||
|
);
|
||||||
|
let crop = CropRenderElement::from_element(panel, 1.0, layer_output_crop(mode, transform))
|
||||||
|
.expect("bottom panel must remain visible");
|
||||||
|
assert_eq!(crop.geometry(1.0.into()), geometry, "{transform:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -434,6 +434,8 @@ impl SessionLockHandler for Ferese {
|
|||||||
// Replacing an orphan never passes through Unlocked. Existing protected
|
// Replacing an orphan never passes through Unlocked. Existing protected
|
||||||
// presentation facts remain valid, but old surfaces must not own input.
|
// presentation facts remain valid, but old surfaces must not own input.
|
||||||
self.session_lock.lifecycle = Lifecycle::Acquiring(confirmation);
|
self.session_lock.lifecycle = Lifecycle::Acquiring(confirmation);
|
||||||
|
self.activities.set_locked(true);
|
||||||
|
self.media_engine.set_locked(true);
|
||||||
self.set_binding_mode("default");
|
self.set_binding_mode("default");
|
||||||
self.capture_epoch = self.capture_epoch.wrapping_add(1);
|
self.capture_epoch = self.capture_epoch.wrapping_add(1);
|
||||||
self.session_lock.surfaces.clear();
|
self.session_lock.surfaces.clear();
|
||||||
@@ -484,6 +486,8 @@ impl SessionLockHandler for Ferese {
|
|||||||
}
|
}
|
||||||
let sleeping = self.session_lock.sleeping;
|
let sleeping = self.session_lock.sleeping;
|
||||||
self.session_lock = Lock::default();
|
self.session_lock = Lock::default();
|
||||||
|
self.activities.set_locked(false);
|
||||||
|
self.media_engine.set_locked(false);
|
||||||
self.refresh_idle_inhibition();
|
self.refresh_idle_inhibition();
|
||||||
if sleeping {
|
if sleeping {
|
||||||
crate::backends::direct::wake_locked_outputs(self);
|
crate::backends::direct::wake_locked_outputs(self);
|
||||||
|
|||||||
@@ -369,6 +369,30 @@ fn locking_exits_binding_modes_and_retains_consumed_release_ownership() {
|
|||||||
assert_eq!(fixture.state.binding_modes.active(), None);
|
assert_eq!(fixture.state.binding_modes.active(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acquiring_lock_revokes_activity_and_media_subscriptions() {
|
||||||
|
let Some(mut fixture) =
|
||||||
|
Fixture::new("session_lock::tests::acquiring_lock_revokes_activity_and_media_subscriptions")
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
fixture.output("pending");
|
||||||
|
let revision = fixture.state.activities.snapshot().revision;
|
||||||
|
let (activities, activity_reply) = std::sync::mpsc::sync_channel(1);
|
||||||
|
let (media, media_reply) = std::sync::mpsc::sync_channel(1);
|
||||||
|
fixture.state.activities.watch(1, 11, revision, activities);
|
||||||
|
let media_revision = fixture.state.media_engine.value()["revision"].as_u64().unwrap();
|
||||||
|
fixture.state.media_engine.watch(2, 22, media_revision, media);
|
||||||
|
let _locker = fixture.acquire();
|
||||||
|
assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Acquiring(_)));
|
||||||
|
for (id, receiver) in [(11, activity_reply), (22, media_reply)] {
|
||||||
|
let response = receiver.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||||
|
assert_eq!(response.id, id);
|
||||||
|
assert!(response.result.is_none());
|
||||||
|
assert_eq!(response.error.unwrap().code, "session_locked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lock_confirms_without_outputs() {
|
fn lock_confirms_without_outputs() {
|
||||||
let Some(mut fixture) = Fixture::new("session_lock::tests::lock_confirms_without_outputs") else {
|
let Some(mut fixture) = Fixture::new("session_lock::tests::lock_confirms_without_outputs") else {
|
||||||
|
|||||||
+45
-34
@@ -878,15 +878,7 @@ impl Ferese {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_none_or(|previous| keys.iter().any(|key| previous.get(*key) != sections.get(*key)))
|
.is_none_or(|previous| keys.iter().any(|key| previous.get(*key) != sections.get(*key)))
|
||||||
};
|
};
|
||||||
// Desired configuration: reloads update it while endpoints stay
|
let activities_changed = changed(&["activities"]);
|
||||||
// session-scoped, so a difference from the running service is reported
|
|
||||||
// as `restart_required` rather than applied behind the user's back.
|
|
||||||
self.xwayland_config = config.xwayland.clone();
|
|
||||||
if changed(&["activities"]) {
|
|
||||||
self.activities
|
|
||||||
.configure(&config.activity_settings, config.activity_matcher.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let layout_changed = changed(&["layout", "scrolling", "workspaces"]);
|
let layout_changed = changed(&["layout", "scrolling", "workspaces"]);
|
||||||
let bindings_changed = changed(&["bindings", "binding_modes", "commands"]);
|
let bindings_changed = changed(&["bindings", "binding_modes", "commands"]);
|
||||||
let input_changed = changed(&["input"]);
|
let input_changed = changed(&["input"]);
|
||||||
@@ -901,6 +893,39 @@ impl Ferese {
|
|||||||
crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?;
|
crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stage layout changes before replacing the keymap: both can fail.
|
||||||
|
// Everything below the successful keymap replacement must be infallible.
|
||||||
|
let workspaces = if layout_changed {
|
||||||
|
let mut workspaces = self.workspaces.clone();
|
||||||
|
let bounds = self
|
||||||
|
.workspaces
|
||||||
|
.iter()
|
||||||
|
.map(|workspace| {
|
||||||
|
let output = self.output_workspaces.output_for_workspace(workspace.id);
|
||||||
|
let rect = self
|
||||||
|
.output_ids
|
||||||
|
.iter()
|
||||||
|
.find(|(_, id)| Some(**id) == output)
|
||||||
|
.and_then(|(output, _)| self.output_bounds_for(output))
|
||||||
|
.or_else(|| self.output_bounds())
|
||||||
|
.unwrap_or(Rect::new(0., 0., 1920., 1080.));
|
||||||
|
(workspace.id, rect)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
workspaces
|
||||||
|
.reconfigure_live(
|
||||||
|
config.layout_mode,
|
||||||
|
config.default_column_width,
|
||||||
|
config.scrolling_focus_strategy,
|
||||||
|
&bounds,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
Some(workspaces)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let keyboard_changed = self.input_settings.xkb_layout != config.input_settings.xkb_layout
|
let keyboard_changed = self.input_settings.xkb_layout != config.input_settings.xkb_layout
|
||||||
|| self.input_settings.xkb_variant != config.input_settings.xkb_variant
|
|| self.input_settings.xkb_variant != config.input_settings.xkb_variant
|
||||||
|| self.input_settings.xkb_options != config.input_settings.xkb_options;
|
|| self.input_settings.xkb_options != config.input_settings.xkb_options;
|
||||||
@@ -940,32 +965,18 @@ impl Ferese {
|
|||||||
|
|
||||||
let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad;
|
let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad;
|
||||||
let outputs_changed = self.output_profiles != config.output_profiles;
|
let outputs_changed = self.output_profiles != config.output_profiles;
|
||||||
if layout_changed {
|
if let Some(workspaces) = workspaces {
|
||||||
let bounds = self
|
self.workspaces = workspaces;
|
||||||
.workspaces
|
|
||||||
.iter()
|
|
||||||
.map(|workspace| {
|
|
||||||
let output = self.output_workspaces.output_for_workspace(workspace.id);
|
|
||||||
let rect = self
|
|
||||||
.output_ids
|
|
||||||
.iter()
|
|
||||||
.find(|(_, id)| Some(**id) == output)
|
|
||||||
.and_then(|(output, _)| self.output_bounds_for(output))
|
|
||||||
.or_else(|| self.output_bounds())
|
|
||||||
.unwrap_or(Rect::new(0., 0., 1920., 1080.));
|
|
||||||
(workspace.id, rect)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
self.workspaces
|
|
||||||
.reconfigure_live(
|
|
||||||
config.layout_mode,
|
|
||||||
config.default_column_width,
|
|
||||||
config.scrolling_focus_strategy,
|
|
||||||
&bounds,
|
|
||||||
)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
}
|
}
|
||||||
|
// Desired configuration: reloads update it while endpoints stay
|
||||||
|
// session-scoped, so a difference from the running service is reported
|
||||||
|
// as `restart_required` rather than applied behind the user's back.
|
||||||
|
self.xwayland_config = config.xwayland.clone();
|
||||||
|
if activities_changed {
|
||||||
|
self.activities
|
||||||
|
.configure(&config.activity_settings, config.activity_matcher.clone());
|
||||||
|
}
|
||||||
|
|
||||||
if layout_changed || self.gap_config != config.gap_config || panel_layout_changed {
|
if layout_changed || self.gap_config != config.gap_config || panel_layout_changed {
|
||||||
// Hidden layouts are resolved independently of presented geometry.
|
// Hidden layouts are resolved independently of presented geometry.
|
||||||
self.workspace_preview_revision = self.workspace_preview_revision.wrapping_add(1);
|
self.workspace_preview_revision = self.workspace_preview_revision.wrapping_add(1);
|
||||||
|
|||||||
@@ -425,7 +425,10 @@ impl Ferese {
|
|||||||
if let Err(error) = self.theme_engine.arm_clock(candidate.next_transition) {
|
if let Err(error) = self.theme_engine.arm_clock(candidate.next_transition) {
|
||||||
tracing::warn!(%error, "cannot arm appearance schedule");
|
tracing::warn!(%error, "cannot arm appearance schedule");
|
||||||
}
|
}
|
||||||
if candidate.theme == self.theme_engine.live.theme && self.theme_engine.pending.is_none() {
|
if candidate.theme == self.theme_engine.live.theme
|
||||||
|
&& self.theme_engine.pending.is_none()
|
||||||
|
&& self.wallpaper.configuration() == &wallpaper
|
||||||
|
{
|
||||||
let changed = self.theme_engine.live.mode != candidate.policy.mode
|
let changed = self.theme_engine.live.mode != candidate.policy.mode
|
||||||
|| self.theme_engine.live.warnings != candidate.warnings
|
|| self.theme_engine.live.warnings != candidate.warnings
|
||||||
|| self.theme_engine.live.error.is_some()
|
|| self.theme_engine.live.error.is_some()
|
||||||
|
|||||||
@@ -273,40 +273,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send `payload` with `MSG_TRUNC`, which makes the kernel deliver a short
|
|
||||||
/// datagram and set `MSG_TRUNC` on the receiver.
|
|
||||||
fn send_truncated_from_child(path: &Path, payload: &[u8]) {
|
|
||||||
// SAFETY: the child performs only sendmsg and _exit, both
|
|
||||||
// async-signal-safe, on freshly created descriptors.
|
|
||||||
unsafe {
|
|
||||||
let pid = libc::fork();
|
|
||||||
assert!(pid >= 0, "fork must succeed");
|
|
||||||
if pid == 0 {
|
|
||||||
let fd = connect_datagram(path);
|
|
||||||
if fd < 0 {
|
|
||||||
libc::_exit(70);
|
|
||||||
}
|
|
||||||
let mut iov = libc::iovec {
|
|
||||||
iov_base: payload.as_ptr().cast_mut().cast(),
|
|
||||||
iov_len: payload.len(),
|
|
||||||
};
|
|
||||||
let mut message: libc::msghdr = std::mem::zeroed();
|
|
||||||
message.msg_iov = std::ptr::from_mut(&mut iov);
|
|
||||||
message.msg_iovlen = 1;
|
|
||||||
// The kernel truncates the payload and reports MSG_TRUNC.
|
|
||||||
message.msg_flags = libc::MSG_TRUNC;
|
|
||||||
let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL);
|
|
||||||
libc::_exit(if sent >= 0 { 0 } else { 71 });
|
|
||||||
}
|
|
||||||
let mut status = 0;
|
|
||||||
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
|
|
||||||
assert!(
|
|
||||||
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
|
|
||||||
"child must send a truncated datagram"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send `READY=1` from a child that also passes a descriptor.
|
/// Send `READY=1` from a child that also passes a descriptor.
|
||||||
///
|
///
|
||||||
/// The child stays alive so the receiver can name its PID, and the extra
|
/// The child stays alive so the receiver can name its PID, and the extra
|
||||||
@@ -377,13 +343,23 @@ mod tests {
|
|||||||
fn a_truncated_notification_is_rejected_even_with_valid_credentials() {
|
fn a_truncated_notification_is_rejected_even_with_valid_credentials() {
|
||||||
let runtime = private_runtime_dir("truncated");
|
let runtime = private_runtime_dir("truncated");
|
||||||
let socket = ReadinessSocket::create(&runtime, 21).unwrap();
|
let socket = ReadinessSocket::create(&runtime, 21).unwrap();
|
||||||
let path = socket.path().to_owned();
|
let sender = UnixDatagram::unbound().unwrap();
|
||||||
|
sender.connect(socket.path()).unwrap();
|
||||||
send_truncated_from_child(&path, b"READY=1");
|
let mut payload = b"READY=1\nSTATUS=".to_vec();
|
||||||
// Any PID is fine here: the point is the flag, not the credential.
|
payload.resize(1024, b'x');
|
||||||
|
// Both the whole message and its received prefix would otherwise pass
|
||||||
|
// the parser. The kernel truncates it to the 512-byte receive buffer.
|
||||||
|
assert_eq!(exact_line(&payload, b"READY=1"), Some(true));
|
||||||
|
assert_eq!(exact_line(&payload[..512], b"READY=1"), Some(true));
|
||||||
|
sender.send(&payload).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
!recv_within(&socket, u32::MAX),
|
!recv_within(&socket, std::process::id()),
|
||||||
"MSG_TRUNC must never satisfy readiness"
|
"an oversized message from the expected PID must be rejected"
|
||||||
|
);
|
||||||
|
sender.send(b"READY=1").unwrap();
|
||||||
|
assert!(
|
||||||
|
recv_within(&socket, std::process::id()),
|
||||||
|
"valid credentials must still be accepted"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,14 +29,21 @@ impl MotionSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate(self) -> Result<(), String> {
|
pub fn validate(self) -> Result<(), String> {
|
||||||
if !self.speed.is_finite() || self.speed <= 0.0 {
|
self.validate_speed()?;
|
||||||
return Err("animation speed must be finite and positive".into());
|
|
||||||
}
|
|
||||||
self.spring_config()?;
|
self.spring_config()?;
|
||||||
self.viewport_config()?;
|
self.viewport_config()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bound both duration division and elapsed-time multiplication.
|
||||||
|
pub fn validate_speed(self) -> Result<f64, String> {
|
||||||
|
if self.speed.is_finite() && (0.01..=100.0).contains(&self.speed) {
|
||||||
|
Ok(self.speed)
|
||||||
|
} else {
|
||||||
|
Err("animation speed must be between 0.01 and 100 (inclusive)".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spring_config(self) -> Result<SpringConfig, String> {
|
pub fn spring_config(self) -> Result<SpringConfig, String> {
|
||||||
self.spring.resolve(240.0)
|
self.spring.resolve(240.0)
|
||||||
}
|
}
|
||||||
@@ -108,6 +115,28 @@ impl SpringSettings {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn speed_validation_rejects_extremes_and_accepts_safe_boundaries() {
|
||||||
|
for speed in [f64::NAN, f64::INFINITY, 0.0, -1.0, 1e-100, 1e100, 0.009, 101.0] {
|
||||||
|
let settings = MotionSettings {
|
||||||
|
speed,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(settings.validate_speed().is_err());
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
|
for speed in [0.01, 0.5, 1.0, 100.0] {
|
||||||
|
let settings = MotionSettings {
|
||||||
|
speed,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(settings.validate_speed().unwrap(), speed);
|
||||||
|
assert!(settings.validate().is_ok());
|
||||||
|
assert!(!settings.duration(120.0).is_zero());
|
||||||
|
assert!(!std::time::Duration::from_secs(60).mul_f64(speed).is_zero());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_are_critical_with_distinct_spatial_and_viewport_responses() {
|
fn defaults_are_critical_with_distinct_spatial_and_viewport_responses() {
|
||||||
let settings = MotionSettings::default();
|
let settings = MotionSettings::default();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use ferese_layout::{
|
|||||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
pub struct WorkspaceId(pub u64);
|
pub struct WorkspaceId(pub u64);
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
pub id: WorkspaceId,
|
pub id: WorkspaceId,
|
||||||
pub layout: WorkspaceLayout,
|
pub layout: WorkspaceLayout,
|
||||||
@@ -382,7 +382,7 @@ impl From<LayoutError> for WorkspaceError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct WorkspaceSet {
|
pub struct WorkspaceSet {
|
||||||
active: WorkspaceId,
|
active: WorkspaceId,
|
||||||
workspaces: HashMap<WorkspaceId, Workspace>,
|
workspaces: HashMap<WorkspaceId, Workspace>,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ The main spring responds more quickly than the viewport spring, allowing a windo
|
|||||||
| Focus emphasis response | 0.8 × main spring response time |
|
| Focus emphasis response | 0.8 × main spring response time |
|
||||||
| Shadow response | 1.2 × main spring response time |
|
| Shadow response | 1.2 × main spring response time |
|
||||||
|
|
||||||
Bounce must be strictly between −1 and 1. Negative values produce overdamping; positive values require `overshoot #true` so the crossing policy permits the requested bounce. Duration and speed must be finite and positive, and the resolved stiffness and damping must remain finite and positive too. If the configuration fails these checks, Ferese keeps the last accepted settings.
|
Bounce must be strictly between −1 and 1. Negative values produce overdamping; positive values require `overshoot #true` so the crossing policy permits the requested bounce. Speed must be between 0.01 and 100, inclusive. Duration and the resolved stiffness and damping must be finite and positive. If the configuration fails these checks, Ferese keeps the last accepted settings.
|
||||||
|
|
||||||
The [Morph panel](panel-configuration.md#morph) uses the same spring solver,
|
The [Morph panel](panel-configuration.md#morph) uses the same spring solver,
|
||||||
with its own response and the shared speed and reduced-motion settings. Content
|
with its own response and the shared speed and reduced-motion settings. Content
|
||||||
|
|||||||
+28
-2
@@ -20,7 +20,7 @@ fn write_plain(writer: &mut impl Write, value: &Value, indent: usize) -> io::Res
|
|||||||
let mut fields = fields.iter().collect::<Vec<_>>();
|
let mut fields = fields.iter().collect::<Vec<_>>();
|
||||||
fields.sort_unstable_by_key(|(key, _)| *key);
|
fields.sort_unstable_by_key(|(key, _)| *key);
|
||||||
for (key, value) in fields {
|
for (key, value) in fields {
|
||||||
write!(writer, "{:indent$}{key}:", "")?;
|
write!(writer, "{:indent$}{}:", "", terminal_text(key))?;
|
||||||
if value.is_object() || value.is_array() {
|
if value.is_object() || value.is_array() {
|
||||||
writeln!(writer)?;
|
writeln!(writer)?;
|
||||||
write_plain(writer, value, indent + 2)?;
|
write_plain(writer, value, indent + 2)?;
|
||||||
@@ -45,11 +45,23 @@ fn write_plain(writer: &mut impl Write, value: &Value, indent: usize) -> io::Res
|
|||||||
fn scalar(value: &Value) -> String {
|
fn scalar(value: &Value) -> String {
|
||||||
match value {
|
match value {
|
||||||
Value::Null => "none".into(),
|
Value::Null => "none".into(),
|
||||||
Value::String(text) => text.clone(),
|
Value::String(text) => terminal_text(text),
|
||||||
value => value.to_string(),
|
value => value.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn terminal_text(text: &str) -> String {
|
||||||
|
let mut escaped = String::with_capacity(text.len());
|
||||||
|
for character in text.chars() {
|
||||||
|
if character.is_control() {
|
||||||
|
escaped.extend(character.escape_default());
|
||||||
|
} else {
|
||||||
|
escaped.push(character);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
escaped
|
||||||
|
}
|
||||||
|
|
||||||
/// Return false when a downstream consumer closes stdout.
|
/// Return false when a downstream consumer closes stdout.
|
||||||
pub fn write_json_line(writer: &mut impl Write, value: &impl serde::Serialize) -> Result<bool, Box<dyn Error>> {
|
pub fn write_json_line(writer: &mut impl Write, value: &impl serde::Serialize) -> Result<bool, Box<dyn Error>> {
|
||||||
let line = serde_json::to_vec(value)?;
|
let line = serde_json::to_vec(value)?;
|
||||||
@@ -68,6 +80,20 @@ pub fn write_json_line(writer: &mut impl Write, value: &impl serde::Serialize) -
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
|
fn plain_output_escapes_terminal_controls_in_keys_and_values() {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
write_plain(
|
||||||
|
&mut bytes,
|
||||||
|
&serde_json::json!({"title\nspoof": "Terminal\u{1b}[2J\r\n\t\u{7}\u{9b} · 日本語"}),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8(bytes).unwrap(),
|
||||||
|
"title\\nspoof: Terminal\\u{1b}[2J\\r\\n\\t\\u{7}\\u{9b} · 日本語\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn plain_output_has_readable_fields_without_json_quotes() {
|
fn plain_output_has_readable_fields_without_json_quotes() {
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
write_plain(
|
write_plain(
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ impl cosmic::Application for App {
|
|||||||
|
|
||||||
fn update(&mut self, message: Message) -> Task<Message> {
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
|
Message::DragWindow => return self.core.drag(None),
|
||||||
Message::ReadRenderScale(id) => {
|
Message::ReadRenderScale(id) => {
|
||||||
return cosmic::iced::window::scale_factor(id)
|
return cosmic::iced::window::scale_factor(id)
|
||||||
.map(|scale| cosmic::Action::App(Message::RenderScale(scale)));
|
.map(|scale| cosmic::Action::App(Message::RenderScale(scale)));
|
||||||
@@ -1087,6 +1088,15 @@ mod tests {
|
|||||||
.0
|
.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_drag_targets_the_main_window_and_handles_a_missing_window() {
|
||||||
|
let mut app = app();
|
||||||
|
app.core.set_main_window_id(None);
|
||||||
|
assert_eq!(app.update(Message::DragWindow).units(), 0);
|
||||||
|
app.core.set_main_window_id(Some(cosmic::iced::window::Id::unique()));
|
||||||
|
assert_eq!(app.update(Message::DragWindow).units(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
fn shortcuts(count: usize) -> App {
|
fn shortcuts(count: usize) -> App {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
app.page = Page::Shortcuts;
|
app.page = Page::Shortcuts;
|
||||||
|
|||||||
@@ -33,3 +33,7 @@ image-codec = { package = "image", version = "0.25.10", default-features = false
|
|||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
ureq = { version = "2.12.1", default-features = false, features = ["tls"] }
|
ureq = { version = "2.12.1", default-features = false, features = ["tls"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
wayland-server.workspace = true
|
||||||
|
ferese-protocols = { path = "../../crates/ferese-protocols", features = ["server"] }
|
||||||
@@ -159,6 +159,7 @@ struct FereseShell {
|
|||||||
overview_active: bool,
|
overview_active: bool,
|
||||||
clock: String,
|
clock: String,
|
||||||
outputs: Vec<OutputSurfaces>,
|
outputs: Vec<OutputSurfaces>,
|
||||||
|
effects_context: Option<std::rc::Rc<EffectsContext>>,
|
||||||
notifications: notifications::Center,
|
notifications: notifications::Center,
|
||||||
notification_surface: Option<notification_ui::NotificationSurface>,
|
notification_surface: Option<notification_ui::NotificationSurface>,
|
||||||
status_service: status::Service,
|
status_service: status::Service,
|
||||||
@@ -348,6 +349,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
overview_active: false,
|
overview_active: false,
|
||||||
clock: clock::current_time(),
|
clock: clock::current_time(),
|
||||||
outputs: Vec::new(),
|
outputs: Vec::new(),
|
||||||
|
effects_context: None,
|
||||||
};
|
};
|
||||||
let wallpaper_task = match wallpaper {
|
let wallpaper_task = match wallpaper {
|
||||||
Some(receiver) => cosmic::task::future(async move {
|
Some(receiver) => cosmic::task::future(async move {
|
||||||
@@ -564,7 +566,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
&& entry.id == id
|
&& entry.id == id
|
||||||
&& entry.effects.is_none()
|
&& entry.effects.is_none()
|
||||||
{
|
{
|
||||||
match EffectsBinding::attach_role(&surface, None, 1.0) {
|
match EffectsBinding::attach_role(&mut self.effects_context, &surface, None, 1.0) {
|
||||||
Ok(binding) => entry.effects = Some(binding),
|
Ok(binding) => entry.effects = Some(binding),
|
||||||
Err(error) => eprintln!("ferese-shell: notification effects unavailable: {error}"),
|
Err(error) => eprintln!("ferese-shell: notification effects unavailable: {error}"),
|
||||||
}
|
}
|
||||||
@@ -573,7 +575,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
if let Some(menu) = &mut self.menu
|
if let Some(menu) = &mut self.menu
|
||||||
&& menu.id == id
|
&& menu.id == id
|
||||||
{
|
{
|
||||||
menu.prepare_surface(&surface);
|
menu.prepare_surface(&mut self.effects_context, &surface);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"),
|
Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"),
|
||||||
@@ -779,7 +781,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
count: self.notifications.unread(),
|
count: self.notifications.unread(),
|
||||||
dnd: value,
|
dnd: value,
|
||||||
});
|
});
|
||||||
return Task::none();
|
return self.sync_hub_activity();
|
||||||
}
|
}
|
||||||
status::Action::Notifications => {
|
status::Action::Notifications => {
|
||||||
return Task::batch([self.close_menu(), self.toggle_notification_history()]);
|
return Task::batch([self.close_menu(), self.toggle_notification_history()]);
|
||||||
@@ -1194,6 +1196,7 @@ mod tests {
|
|||||||
bar_margin_horizontal: 0,
|
bar_margin_horizontal: 0,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
}],
|
}],
|
||||||
|
effects_context: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1210,6 +1213,76 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dnd_refreshes_activity_selection_without_waiting_for_another_event() {
|
||||||
|
use cosmic::Application;
|
||||||
|
use ferese_activities::{Data, Owner, Source, Store, Template, Value};
|
||||||
|
let mut shell = shell_with_measured_panel();
|
||||||
|
shell.config.panels[0].style = ferese_panel::PanelStyle::Morph;
|
||||||
|
shell.config.panels[0].center.groups[0]
|
||||||
|
.items
|
||||||
|
.push(ferese_panel::Item::new(
|
||||||
|
"test-activities",
|
||||||
|
ferese_panel::ItemKind::Activities,
|
||||||
|
));
|
||||||
|
shell.outputs[0]
|
||||||
|
.runtime
|
||||||
|
.reset(&shell.config.panels[0], &shell.snapshot, Some("test"));
|
||||||
|
shell.snapshot.outputs.push(OutputSnapshot {
|
||||||
|
id: 1,
|
||||||
|
name: "test".into(),
|
||||||
|
active_workspace: 1,
|
||||||
|
focused: true,
|
||||||
|
});
|
||||||
|
shell.notifications.ready = true;
|
||||||
|
let mut store = Store::new(1);
|
||||||
|
for n in 1..=2 {
|
||||||
|
store
|
||||||
|
.start(
|
||||||
|
Owner { pid: n, birth: 1 },
|
||||||
|
n.to_string(),
|
||||||
|
Data {
|
||||||
|
source: Source::Publisher,
|
||||||
|
output: Some("test".into()),
|
||||||
|
priority: 100,
|
||||||
|
icon: "hammer".into(),
|
||||||
|
title: format!("Task {n}"),
|
||||||
|
value: Value::Indeterminate,
|
||||||
|
template: Template::Progress,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
shell.activities.snapshot = std::sync::Arc::new(ferese_ipc::activities::Snapshot {
|
||||||
|
activities: store.entries().cloned().collect(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
shell.sync_activity_presentations();
|
||||||
|
let selected = |shell: &FereseShell| {
|
||||||
|
shell.outputs[0]
|
||||||
|
.runtime
|
||||||
|
.hub
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.borrow()
|
||||||
|
.activities
|
||||||
|
.selection
|
||||||
|
.visible
|
||||||
|
.len()
|
||||||
|
};
|
||||||
|
assert_eq!(selected(&shell), 2);
|
||||||
|
drop(shell.update(Message::Control(status::Action::Dnd(true))));
|
||||||
|
assert_eq!(selected(&shell), 1);
|
||||||
|
drop(shell.update(Message::Control(status::Action::Dnd(false))));
|
||||||
|
assert_eq!(selected(&shell), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reload_preserves_the_last_presented_theme_without_a_control_connection() {
|
fn reload_preserves_the_last_presented_theme_without_a_control_connection() {
|
||||||
use cosmic::Application;
|
use cosmic::Application;
|
||||||
|
|||||||
@@ -375,6 +375,23 @@ impl<M> Motion<'_, M> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||||
|
fn a11y_nodes(
|
||||||
|
&self,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
tree: &widget::Tree,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
) -> iced_accessibility::A11yTree {
|
||||||
|
let mut nodes = self.content.as_widget().a11y_nodes(layout, tree, self.cursor(cursor));
|
||||||
|
let offset = self.translation();
|
||||||
|
for node in nodes.root_mut() {
|
||||||
|
translate_accessible_node(node, offset);
|
||||||
|
}
|
||||||
|
for node in nodes.children_mut() {
|
||||||
|
translate_accessible_node(node, offset);
|
||||||
|
}
|
||||||
|
nodes
|
||||||
|
}
|
||||||
|
|
||||||
fn tag(&self) -> widget::tree::Tag {
|
fn tag(&self) -> widget::tree::Tag {
|
||||||
self.content.as_widget().tag()
|
self.content.as_widget().tag()
|
||||||
}
|
}
|
||||||
@@ -479,6 +496,30 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
|||||||
) {
|
) {
|
||||||
self.content.as_widget_mut().operate(tree, layout, renderer, operation);
|
self.content.as_widget_mut().operate(tree, layout, renderer, operation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn overlay<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
tree: &'a mut widget::Tree,
|
||||||
|
layout: Layout<'a>,
|
||||||
|
renderer: &cosmic::Renderer,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
translation: Vector,
|
||||||
|
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
|
||||||
|
let translation = translation + self.translation();
|
||||||
|
self.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.overlay(tree, layout, renderer, viewport, translation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn translate_accessible_node(node: &mut iced_accessibility::A11yNode, offset: Vector) {
|
||||||
|
if let Some(mut bounds) = node.node().bounds() {
|
||||||
|
bounds.x0 += f64::from(offset.x);
|
||||||
|
bounds.x1 += f64::from(offset.x);
|
||||||
|
bounds.y0 += f64::from(offset.y);
|
||||||
|
bounds.y1 += f64::from(offset.y);
|
||||||
|
node.node_mut().set_bounds(bounds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CollectRegions(Vec<Rectangle>);
|
struct CollectRegions(Vec<Rectangle>);
|
||||||
@@ -527,6 +568,139 @@ impl widget::Operation for CollectRegions {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn motion_preserves_accessible_controls_and_translates_tooltip_overlays() {
|
||||||
|
use super::*;
|
||||||
|
use cosmic::iced::advanced::{clipboard, renderer::Headless};
|
||||||
|
use cosmic::iced::{Font, Pixels, Point};
|
||||||
|
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
||||||
|
Font::default(),
|
||||||
|
Pixels(14.),
|
||||||
|
Some("tiny-skia"),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let viewport = Rectangle::with_size(Size::new(400., 300.));
|
||||||
|
for edge in [ferese_panel::Edge::Top, ferese_panel::Edge::Bottom] {
|
||||||
|
let make_content = || {
|
||||||
|
// The toolkit tooltip itself does not expose an accessibility
|
||||||
|
// tree. A sibling button verifies our wrapper's forwarding.
|
||||||
|
cosmic::widget::row::with_capacity(2)
|
||||||
|
.push(cosmic::widget::tooltip(
|
||||||
|
cosmic::widget::Space::new().width(100).height(40),
|
||||||
|
cosmic::widget::text("Tooltip"),
|
||||||
|
cosmic::widget::tooltip::Position::Bottom,
|
||||||
|
))
|
||||||
|
.push(
|
||||||
|
cosmic::widget::button::custom(cosmic::widget::text("Action"))
|
||||||
|
.name("Accessible action")
|
||||||
|
.on_press(())
|
||||||
|
.width(100)
|
||||||
|
.height(40),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut plain: Element<'_, ()> = make_content().into();
|
||||||
|
let mut plain_tree = widget::Tree::new(&plain);
|
||||||
|
let plain_node = plain.as_widget_mut().layout(
|
||||||
|
&mut plain_tree,
|
||||||
|
&renderer,
|
||||||
|
&layout::Limits::new(Size::ZERO, viewport.size()),
|
||||||
|
);
|
||||||
|
let plain_a11y =
|
||||||
|
plain
|
||||||
|
.as_widget()
|
||||||
|
.a11y_nodes(Layout::new(&plain_node), &plain_tree, mouse::Cursor::Unavailable);
|
||||||
|
assert!(!plain_a11y.root().is_empty());
|
||||||
|
let mut wrapped = animated_from_edge(make_content().into(), 0.5, Default::default(), 10., edge);
|
||||||
|
let mut tree = widget::Tree::new(&wrapped);
|
||||||
|
let node =
|
||||||
|
wrapped
|
||||||
|
.as_widget_mut()
|
||||||
|
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()));
|
||||||
|
let offset = if edge == ferese_panel::Edge::Top { -2. } else { 2. };
|
||||||
|
let accessible = wrapped
|
||||||
|
.as_widget()
|
||||||
|
.a11y_nodes(Layout::new(&node), &tree, mouse::Cursor::Unavailable);
|
||||||
|
assert_eq!(accessible.root().len(), plain_a11y.root().len());
|
||||||
|
for (actual, expected) in accessible
|
||||||
|
.root()
|
||||||
|
.iter()
|
||||||
|
.chain(accessible.children())
|
||||||
|
.zip(plain_a11y.root().iter().chain(plain_a11y.children()))
|
||||||
|
{
|
||||||
|
assert_eq!(actual.node().label(), expected.node().label());
|
||||||
|
if let Some(bounds) = expected.node().bounds() {
|
||||||
|
let actual = actual.node().bounds().unwrap();
|
||||||
|
assert_eq!(actual.x0, bounds.x0);
|
||||||
|
assert_eq!(actual.y0, bounds.y0 + offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cursor = mouse::Cursor::Available(Point::new(50., 20.));
|
||||||
|
let event = Event::Mouse(mouse::Event::CursorMoved {
|
||||||
|
position: Point::new(50., 20.),
|
||||||
|
});
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
plain.as_widget_mut().update(
|
||||||
|
&mut plain_tree,
|
||||||
|
&event,
|
||||||
|
Layout::new(&plain_node),
|
||||||
|
cursor,
|
||||||
|
&renderer,
|
||||||
|
&mut clipboard::Null,
|
||||||
|
&mut Shell::new(&mut messages),
|
||||||
|
&viewport,
|
||||||
|
);
|
||||||
|
wrapped.as_widget_mut().update(
|
||||||
|
&mut tree,
|
||||||
|
&event,
|
||||||
|
Layout::new(&node),
|
||||||
|
cursor,
|
||||||
|
&renderer,
|
||||||
|
&mut clipboard::Null,
|
||||||
|
&mut Shell::new(&mut messages),
|
||||||
|
&viewport,
|
||||||
|
);
|
||||||
|
let mut plain_overlay = plain
|
||||||
|
.as_widget_mut()
|
||||||
|
.overlay(
|
||||||
|
&mut plain_tree,
|
||||||
|
Layout::new(&plain_node),
|
||||||
|
&renderer,
|
||||||
|
&viewport,
|
||||||
|
Vector::new(40., 80.),
|
||||||
|
)
|
||||||
|
.expect("tooltip opens");
|
||||||
|
let mut overlay = wrapped
|
||||||
|
.as_widget_mut()
|
||||||
|
.overlay(
|
||||||
|
&mut tree,
|
||||||
|
Layout::new(&node),
|
||||||
|
&renderer,
|
||||||
|
&viewport,
|
||||||
|
Vector::new(40., 80.),
|
||||||
|
)
|
||||||
|
.expect("motion forwards tooltip");
|
||||||
|
let plain_layout = plain_overlay.as_overlay_mut().layout(&renderer, viewport.size());
|
||||||
|
let overlay_layout = overlay.as_overlay_mut().layout(&renderer, viewport.size());
|
||||||
|
fn tooltip_bounds(node: &layout::Node) -> Rectangle {
|
||||||
|
// Tooltip and row each return an overlay group; descend past
|
||||||
|
// full-viewport group nodes to the positioned tooltip root.
|
||||||
|
let mut node = node;
|
||||||
|
while let [child] = node.children() {
|
||||||
|
if node.bounds().position() != cosmic::iced::Point::ORIGIN {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
node = child;
|
||||||
|
}
|
||||||
|
node.bounds()
|
||||||
|
}
|
||||||
|
let plain_bounds = tooltip_bounds(&plain_layout);
|
||||||
|
let bounds = tooltip_bounds(&overlay_layout);
|
||||||
|
assert!(plain_bounds.y > 0.);
|
||||||
|
assert_eq!(bounds.x, plain_bounds.x);
|
||||||
|
assert_eq!(bounds.y, plain_bounds.y + offset as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duration_bounce_spring_finishes_popup_close() {
|
fn duration_bounce_spring_finishes_popup_close() {
|
||||||
let settings = super::Settings {
|
let settings = super::Settings {
|
||||||
|
|||||||
@@ -703,6 +703,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dnd_immediately_removes_and_restores_a_persistent_bridged_notice() {
|
||||||
|
use cosmic::Application;
|
||||||
|
let mut app = crate::tests::shell_with_measured_panel();
|
||||||
|
app.snapshot.outputs.push(crate::control::OutputSnapshot {
|
||||||
|
id: 4,
|
||||||
|
name: "test".into(),
|
||||||
|
active_workspace: 1,
|
||||||
|
focused: true,
|
||||||
|
});
|
||||||
|
let (center, _commands) = fixture();
|
||||||
|
app.notifications = center;
|
||||||
|
app.config.activities.sources.notification_bridge = true;
|
||||||
|
app.notifications.configure_activity_bridge(true);
|
||||||
|
let bar = app.outputs[0].bar;
|
||||||
|
let mut persistent = notice(1);
|
||||||
|
persistent.timeout = None;
|
||||||
|
drop(app.update(crate::Message::NotificationEvent(Event::Notice(persistent))));
|
||||||
|
assert!(app.activity_available(bar));
|
||||||
|
drop(app.update(crate::Message::Control(crate::status::Action::Dnd(true))));
|
||||||
|
assert!(!app.activity_available(bar));
|
||||||
|
assert!(app.notifications.entries[0].live, "DND must not dismiss the notice");
|
||||||
|
drop(app.update(crate::Message::Control(crate::status::Action::Dnd(false))));
|
||||||
|
assert!(app.activity_available(bar));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bridge_creates_an_activity_and_removes_it_on_expiry_and_dismissal() {
|
fn bridge_creates_an_activity_and_removes_it_on_expiry_and_dismissal() {
|
||||||
use cosmic::Application;
|
use cosmic::Application;
|
||||||
|
|||||||
@@ -137,9 +137,13 @@ impl PopoverAnchor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OpenMenu {
|
impl OpenMenu {
|
||||||
pub(super) fn prepare_surface(&mut self, surface: &wl_surface::WlSurface) {
|
pub(super) fn prepare_surface(
|
||||||
|
&mut self,
|
||||||
|
context: &mut Option<std::rc::Rc<EffectsContext>>,
|
||||||
|
surface: &wl_surface::WlSurface,
|
||||||
|
) {
|
||||||
if self.effects.is_none() {
|
if self.effects.is_none() {
|
||||||
match EffectsBinding::attach_role(surface, None, 0.0) {
|
match EffectsBinding::attach_role(context, surface, None, 0.0) {
|
||||||
Ok(binding) => self.effects = Some(binding),
|
Ok(binding) => self.effects = Some(binding),
|
||||||
Err(error) => eprintln!("ferese-shell: popover material unavailable: {error}"),
|
Err(error) => eprintln!("ferese-shell: popover material unavailable: {error}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ use super::{
|
|||||||
wayland, window, wl_output, wl_registry, wl_surface,
|
wayland, window, wl_output, wl_registry, wl_surface,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
pub(super) fn panel_placement(edge: ferese_panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) {
|
pub(super) fn panel_placement(edge: ferese_panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) {
|
||||||
let (anchor, top, bottom) = match edge {
|
let (anchor, top, bottom) = match edge {
|
||||||
ferese_panel::Edge::Top => (Anchor::TOP, margin, 0),
|
ferese_panel::Edge::Top => (Anchor::TOP, margin, 0),
|
||||||
@@ -410,7 +413,7 @@ impl FereseShell {
|
|||||||
return self.sync_notification_surface();
|
return self.sync_notification_surface();
|
||||||
}
|
}
|
||||||
wayland::PopupEvent::Configured { .. } => {
|
wayland::PopupEvent::Configured { .. } => {
|
||||||
menu.prepare_surface(&surface);
|
menu.prepare_surface(&mut self.effects_context, &surface);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -438,7 +441,7 @@ impl FereseShell {
|
|||||||
if let Some(menu) = &mut self.menu
|
if let Some(menu) = &mut self.menu
|
||||||
&& menu.effects.is_none()
|
&& menu.effects.is_none()
|
||||||
{
|
{
|
||||||
menu.prepare_surface(&surface);
|
menu.prepare_surface(&mut self.effects_context, &surface);
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
@@ -457,7 +460,7 @@ impl FereseShell {
|
|||||||
let entry = self.notification_surface.as_mut().unwrap();
|
let entry = self.notification_surface.as_mut().unwrap();
|
||||||
|
|
||||||
if entry.effects.is_none() {
|
if entry.effects.is_none() {
|
||||||
match EffectsBinding::attach_role(&surface, None, 1.0) {
|
match EffectsBinding::attach_role(&mut self.effects_context, &surface, None, 1.0) {
|
||||||
Ok(binding) => entry.effects = Some(binding),
|
Ok(binding) => entry.effects = Some(binding),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("ferese-shell: notification effects unavailable: {error}")
|
eprintln!("ferese-shell: notification effects unavailable: {error}")
|
||||||
@@ -493,9 +496,9 @@ impl FereseShell {
|
|||||||
|
|
||||||
let binding = if islands {
|
let binding = if islands {
|
||||||
// Wait for measured island bounds rather than briefly painting the full bar.
|
// Wait for measured island bounds rather than briefly painting the full bar.
|
||||||
EffectsBinding::attach_role(surface, None, 1.0)
|
EffectsBinding::attach_role(&mut self.effects_context, surface, None, 1.0)
|
||||||
} else {
|
} else {
|
||||||
EffectsBinding::attach(surface, !hidden)
|
EffectsBinding::attach(&mut self.effects_context, surface, !hidden)
|
||||||
};
|
};
|
||||||
|
|
||||||
match binding {
|
match binding {
|
||||||
@@ -585,11 +588,28 @@ fn validate_region_counts(regions: usize, opacities: usize) -> Result<(), std::i
|
|||||||
|
|
||||||
type EffectPresentation = (Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role);
|
type EffectPresentation = (Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role);
|
||||||
|
|
||||||
|
// Keep one registry on the shell connection: wl_registry has no destructor on
|
||||||
|
// the protocol versions we support, so creating one per popup leaks objects.
|
||||||
|
pub(super) struct EffectsContext {
|
||||||
|
connection: Connection,
|
||||||
|
manager: Option<FereseEffectsManagerV1>,
|
||||||
|
_globals: wayland_client::globals::GlobalList,
|
||||||
|
queue: EventQueue<EffectsState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EffectsContext {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(manager) = &self.manager {
|
||||||
|
manager.destroy();
|
||||||
|
}
|
||||||
|
let _ = self.connection.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) struct EffectsBinding {
|
pub(super) struct EffectsBinding {
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
_manager: FereseEffectsManagerV1,
|
_context: std::rc::Rc<EffectsContext>,
|
||||||
pub(super) surface: FereseSurfaceEffectsV1,
|
pub(super) surface: FereseSurfaceEffectsV1,
|
||||||
_queue: EventQueue<EffectsState>,
|
|
||||||
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
|
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
|
||||||
opacity: std::cell::Cell<Option<u32>>,
|
opacity: std::cell::Cell<Option<u32>>,
|
||||||
presentation: std::cell::RefCell<Option<EffectPresentation>>,
|
presentation: std::cell::RefCell<Option<EffectPresentation>>,
|
||||||
@@ -597,8 +617,13 @@ pub(super) struct EffectsBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EffectsBinding {
|
impl EffectsBinding {
|
||||||
pub(super) fn attach(surface: &wl_surface::WlSurface, visible: bool) -> Result<Self, Box<dyn std::error::Error>> {
|
pub(super) fn attach(
|
||||||
|
context: &mut Option<std::rc::Rc<EffectsContext>>,
|
||||||
|
surface: &wl_surface::WlSurface,
|
||||||
|
visible: bool,
|
||||||
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
Self::attach_role(
|
Self::attach_role(
|
||||||
|
context,
|
||||||
surface,
|
surface,
|
||||||
visible.then_some(ferese_surface_effects_v1::Role::Panel),
|
visible.then_some(ferese_surface_effects_v1::Role::Panel),
|
||||||
if visible { 1.0 } else { 0.0 },
|
if visible { 1.0 } else { 0.0 },
|
||||||
@@ -606,6 +631,7 @@ impl EffectsBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn attach_role(
|
pub(super) fn attach_role(
|
||||||
|
context: &mut Option<std::rc::Rc<EffectsContext>>,
|
||||||
surface: &wl_surface::WlSurface,
|
surface: &wl_surface::WlSurface,
|
||||||
role: Option<ferese_surface_effects_v1::Role>,
|
role: Option<ferese_surface_effects_v1::Role>,
|
||||||
opacity: f32,
|
opacity: f32,
|
||||||
@@ -615,30 +641,50 @@ impl EffectsBinding {
|
|||||||
.upgrade()
|
.upgrade()
|
||||||
.ok_or("libcosmic Wayland connection is no longer alive")?;
|
.ok_or("libcosmic Wayland connection is no longer alive")?;
|
||||||
let connection = Connection::from_backend(backend);
|
let connection = Connection::from_backend(backend);
|
||||||
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
// Native handles can create distinct borrowed Backend wrappers around
|
||||||
let qh = queue.handle();
|
// the same wl_display. Backend equality compares wrappers, not displays.
|
||||||
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 6..=6, ())?;
|
if context
|
||||||
let effects = manager.get_surface_effects(surface, &qh, ());
|
.as_ref()
|
||||||
|
.is_none_or(|context| context.connection.backend().display_ptr() != connection.backend().display_ptr())
|
||||||
|
{
|
||||||
|
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
|
||||||
|
// Cache the registry even when this compositor lacks our extension.
|
||||||
|
let manager = globals
|
||||||
|
.bind::<FereseEffectsManagerV1, _, _>(&queue.handle(), 6..=6, ())
|
||||||
|
.ok();
|
||||||
|
*context = Some(std::rc::Rc::new(EffectsContext {
|
||||||
|
connection: connection.clone(),
|
||||||
|
manager,
|
||||||
|
_globals: globals,
|
||||||
|
queue,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let context = context.as_ref().unwrap().clone();
|
||||||
|
let effects = context
|
||||||
|
.manager
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("Ferese effects protocol unavailable")?
|
||||||
|
.get_surface_effects(surface, &context.queue.handle(), ());
|
||||||
|
|
||||||
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||||
effects.set_opacity(opacity);
|
// Own the protocol object before any fallible operation, including flush.
|
||||||
if let Some(role) = role {
|
let binding = Self {
|
||||||
effects.set_role(role);
|
|
||||||
} else {
|
|
||||||
effects.clear_role();
|
|
||||||
}
|
|
||||||
connection.flush()?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
connection,
|
connection,
|
||||||
_manager: manager,
|
_context: context,
|
||||||
surface: effects,
|
surface: effects,
|
||||||
_queue: queue,
|
|
||||||
regions: Default::default(),
|
regions: Default::default(),
|
||||||
opacity: std::cell::Cell::new(Some(opacity)),
|
opacity: std::cell::Cell::new(Some(opacity)),
|
||||||
presentation: Default::default(),
|
presentation: Default::default(),
|
||||||
panel_presentation: Default::default(),
|
panel_presentation: Default::default(),
|
||||||
})
|
};
|
||||||
|
binding.surface.set_opacity(opacity);
|
||||||
|
if let Some(role) = role {
|
||||||
|
binding.surface.set_role(role);
|
||||||
|
} else {
|
||||||
|
binding.surface.clear_role();
|
||||||
|
}
|
||||||
|
binding.connection.flush()?;
|
||||||
|
Ok(binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn set_panel_presentation(
|
pub(super) fn set_panel_presentation(
|
||||||
@@ -744,6 +790,13 @@ impl EffectsBinding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for EffectsBinding {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.surface.destroy();
|
||||||
|
let _ = self.connection.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct EffectsState;
|
struct EffectsState;
|
||||||
|
|
||||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
|
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
|
|
||||||
|
use ferese_protocols::effects::v1::server::{
|
||||||
|
ferese_effects_manager_v1 as manager, ferese_surface_effects_v1 as effects,
|
||||||
|
};
|
||||||
|
use wayland_server::protocol::{wl_compositor, wl_surface};
|
||||||
|
use wayland_server::{Client, DataInit, Dispatch, Display, DisplayHandle, GlobalDispatch, New};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Counts {
|
||||||
|
managers: usize,
|
||||||
|
manager_destroys: usize,
|
||||||
|
surfaces: usize,
|
||||||
|
effect_destroys: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Server(Arc<Mutex<Counts>>);
|
||||||
|
|
||||||
|
impl GlobalDispatch<wl_compositor::WlCompositor, ()> for Server {
|
||||||
|
fn bind(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &DisplayHandle,
|
||||||
|
_: &Client,
|
||||||
|
resource: New<wl_compositor::WlCompositor>,
|
||||||
|
_: &(),
|
||||||
|
init: &mut DataInit<'_, Self>,
|
||||||
|
) {
|
||||||
|
init.init(resource, ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_compositor::WlCompositor, ()> for Server {
|
||||||
|
fn request(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &Client,
|
||||||
|
_: &wl_compositor::WlCompositor,
|
||||||
|
request: wl_compositor::Request,
|
||||||
|
_: &(),
|
||||||
|
_: &DisplayHandle,
|
||||||
|
init: &mut DataInit<'_, Self>,
|
||||||
|
) {
|
||||||
|
if let wl_compositor::Request::CreateSurface { id } = request {
|
||||||
|
init.init(id, ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_surface::WlSurface, ()> for Server {
|
||||||
|
fn request(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &Client,
|
||||||
|
_: &wl_surface::WlSurface,
|
||||||
|
_: wl_surface::Request,
|
||||||
|
_: &(),
|
||||||
|
_: &DisplayHandle,
|
||||||
|
_: &mut DataInit<'_, Self>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlobalDispatch<manager::FereseEffectsManagerV1, ()> for Server {
|
||||||
|
fn bind(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &DisplayHandle,
|
||||||
|
_: &Client,
|
||||||
|
resource: New<manager::FereseEffectsManagerV1>,
|
||||||
|
_: &(),
|
||||||
|
init: &mut DataInit<'_, Self>,
|
||||||
|
) {
|
||||||
|
state.0.lock().unwrap().managers += 1;
|
||||||
|
init.init(resource, ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<manager::FereseEffectsManagerV1, ()> for Server {
|
||||||
|
fn request(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &Client,
|
||||||
|
_: &manager::FereseEffectsManagerV1,
|
||||||
|
request: manager::Request,
|
||||||
|
_: &(),
|
||||||
|
_: &DisplayHandle,
|
||||||
|
init: &mut DataInit<'_, Self>,
|
||||||
|
) {
|
||||||
|
match request {
|
||||||
|
manager::Request::GetSurfaceEffects { id, .. } => {
|
||||||
|
state.0.lock().unwrap().surfaces += 1;
|
||||||
|
init.init(id, ());
|
||||||
|
}
|
||||||
|
manager::Request::Destroy => state.0.lock().unwrap().manager_destroys += 1,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<effects::FereseSurfaceEffectsV1, ()> for Server {
|
||||||
|
fn request(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &Client,
|
||||||
|
_: &effects::FereseSurfaceEffectsV1,
|
||||||
|
request: effects::Request,
|
||||||
|
_: &(),
|
||||||
|
_: &DisplayHandle,
|
||||||
|
_: &mut DataInit<'_, Self>,
|
||||||
|
) {
|
||||||
|
if matches!(request, effects::Request::Destroy) {
|
||||||
|
state.0.lock().unwrap().effect_destroys += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wayland_client::delegate_noop!(super::EffectsState: ignore wayland_client::protocol::wl_compositor::WlCompositor);
|
||||||
|
wayland_client::delegate_noop!(super::EffectsState: ignore wayland_client::protocol::wl_surface::WlSurface);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn popup_effects_share_the_registry_and_destroy_every_protocol_object() {
|
||||||
|
effects_lifecycle(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_effects_extension_reuses_the_registry_across_failed_attachments() {
|
||||||
|
effects_lifecycle(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effects_lifecycle(advertise: bool) {
|
||||||
|
use wayland_client::protocol::wl_compositor::WlCompositor;
|
||||||
|
let counts = Arc::new(Mutex::new(Counts::default()));
|
||||||
|
let worker_counts = counts.clone();
|
||||||
|
let (client, server) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||||
|
let (stop, stopped) = mpsc::channel::<()>();
|
||||||
|
let worker = std::thread::spawn(move || {
|
||||||
|
let mut display = Display::<Server>::new().unwrap();
|
||||||
|
let mut handle = display.handle();
|
||||||
|
handle.create_global::<Server, wl_compositor::WlCompositor, _>(1, ());
|
||||||
|
if advertise {
|
||||||
|
handle.create_global::<Server, manager::FereseEffectsManagerV1, _>(6, ());
|
||||||
|
}
|
||||||
|
handle.insert_client(server, Arc::new(())).unwrap();
|
||||||
|
let mut state = Server(worker_counts);
|
||||||
|
loop {
|
||||||
|
display.dispatch_clients(&mut state).unwrap();
|
||||||
|
display.flush_clients().unwrap();
|
||||||
|
match stopped.recv_timeout(std::time::Duration::from_millis(1)) {
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let connection = wayland_client::Connection::from_socket(client).unwrap();
|
||||||
|
let (globals, queue) = wayland_client::globals::registry_queue_init::<super::EffectsState>(&connection).unwrap();
|
||||||
|
let compositor = globals.bind::<WlCompositor, _, _>(&queue.handle(), 1..=1, ()).unwrap();
|
||||||
|
let mut context = None;
|
||||||
|
let mut first = None;
|
||||||
|
for _ in 0..20 {
|
||||||
|
let surface = compositor.create_surface(&queue.handle(), ());
|
||||||
|
use wayland_client::Proxy;
|
||||||
|
// Match window::run: each native callback lends the same display through
|
||||||
|
// a fresh borrowed backend. Neither wrapper owns the actual display.
|
||||||
|
let borrowed = wayland_client::Connection::from_backend(unsafe {
|
||||||
|
wayland_client::backend::Backend::from_foreign_display(connection.backend().display_ptr())
|
||||||
|
});
|
||||||
|
assert_ne!(borrowed, connection);
|
||||||
|
let id = unsafe {
|
||||||
|
wayland_client::backend::ObjectId::from_ptr(
|
||||||
|
wayland_client::protocol::wl_surface::WlSurface::interface(),
|
||||||
|
surface.id().as_ptr(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.unwrap();
|
||||||
|
let borrowed_surface = wayland_client::protocol::wl_surface::WlSurface::from_id(&borrowed, id).unwrap();
|
||||||
|
let binding = super::EffectsBinding::attach_role(&mut context, &borrowed_surface, None, 1.);
|
||||||
|
assert_eq!(binding.is_ok(), advertise);
|
||||||
|
if let Some(first) = &first {
|
||||||
|
assert!(std::rc::Rc::ptr_eq(first, context.as_ref().unwrap()));
|
||||||
|
} else {
|
||||||
|
first = context.clone();
|
||||||
|
}
|
||||||
|
// Parent surface lifetime does not release the effects protocol object.
|
||||||
|
surface.destroy();
|
||||||
|
drop(binding);
|
||||||
|
}
|
||||||
|
connection.roundtrip().unwrap();
|
||||||
|
{
|
||||||
|
let counts = counts.lock().unwrap();
|
||||||
|
assert_eq!(counts.managers, usize::from(advertise));
|
||||||
|
assert_eq!(counts.surfaces, if advertise { 20 } else { 0 });
|
||||||
|
assert_eq!(counts.effect_destroys, counts.surfaces);
|
||||||
|
assert_eq!(counts.manager_destroys, 0);
|
||||||
|
}
|
||||||
|
drop(first);
|
||||||
|
drop(context);
|
||||||
|
connection.roundtrip().unwrap();
|
||||||
|
assert_eq!(counts.lock().unwrap().manager_destroys, usize::from(advertise));
|
||||||
|
drop(stop);
|
||||||
|
worker.join().unwrap();
|
||||||
|
}
|
||||||
@@ -290,7 +290,7 @@ impl FereseShell {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if entry.primary && entry.effects.is_none() {
|
if entry.primary && entry.effects.is_none() {
|
||||||
match EffectsBinding::attach_role(surface, None, 0.0) {
|
match EffectsBinding::attach_role(&mut self.effects_context, surface, None, 0.0) {
|
||||||
Ok(effects) => entry.effects = Some(effects),
|
Ok(effects) => entry.effects = Some(effects),
|
||||||
Err(error) => eprintln!("ferese-shell: system modal material unavailable: {error}"),
|
Err(error) => eprintln!("ferese-shell: system modal material unavailable: {error}"),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user