Add live config reload, session locking and managed daemons

Refine focus gradients, shell workspace controls and popup motion. Correct floating stacking and preserve client-chosen dialog sizes. Add lock protocol probes and session integration.
This commit is contained in:
Samuel Onoja committed 2026-09-26 19:03:37 +01:00
1 parent b422448d88
commit 672b4e2b60
41 files changed
+2694 -298

No files matched your search

+437
View File
@@ -6,6 +6,322 @@ use cosmic::{Element, Theme};
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[i32; 5]>>>;
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
#[serde(default)]
pub(crate) struct Settings {
enabled: bool,
reduced_motion: bool,
speed: f64,
}
impl Default for Settings {
fn default() -> Self {
Self {
enabled: true,
reduced_motion: false,
speed: 1.0,
}
}
}
impl Settings {
fn hover_duration(self) -> std::time::Duration {
self.duration(120.0)
}
pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration {
if !self.enabled || self.reduced_motion {
return std::time::Duration::ZERO;
}
let speed = if self.speed.is_finite() && self.speed > 0.0 {
self.speed.clamp(0.01, 100.0)
} else {
1.0
};
std::time::Duration::from_secs_f64(milliseconds / 1000.0 / speed)
}
}
static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new();
pub(crate) fn configure(settings: Settings) {
*SETTINGS
.get_or_init(|| std::sync::RwLock::new(Settings::default()))
.write()
.unwrap() = settings;
}
pub(crate) struct PopupMotion {
start: f32,
target: f32,
started: Option<std::time::Instant>,
settings: Settings,
}
impl PopupMotion {
pub(crate) fn update_settings(&mut self, settings: Settings) {
let now = std::time::Instant::now();
self.start = self.progress_at(now);
self.settings = settings;
self.started = Some(now);
}
pub(crate) fn new(settings: Settings) -> Self {
Self {
start: 0.0,
target: 1.0,
started: None,
settings,
}
}
pub(crate) fn begin(&mut self, now: std::time::Instant) {
if self.started.is_none() {
self.started = Some(now);
}
}
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
let duration = self
.settings
.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
if duration.is_zero() {
return self.target;
}
let Some(started) = self.started else {
return self.start;
};
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32())
.min(1.0);
if t == 1.0 {
return self.target;
}
self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t)
}
pub(crate) fn progress(&self) -> f32 {
self.progress_at(std::time::Instant::now())
}
pub(crate) fn closing(&self) -> bool {
self.target == 0.0
}
pub(crate) fn animating(&self) -> bool {
self.progress() != self.target
}
pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) {
self.start = self.progress_at(now);
self.target = target;
self.started = Some(now);
}
}
/// Animate paint only; the original button retains its layout, hit target,
/// keyboard handling, pressed state, and accessibility identity.
pub(crate) fn button<'a, M: Clone + 'a>(
button: cosmic::widget::button::Button<'a, M>,
foreground: cosmic::iced::Color,
selected: bool,
opacity: f32,
) -> Element<'a, M> {
let progress = std::rc::Rc::new(std::cell::Cell::new(0.0));
let active = progress.clone();
let hovered = progress.clone();
let paint = move |progress: f32, pressed: bool| cosmic::widget::button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: 6.0.into(),
background: Some(cosmic::iced::Background::Color(cosmic::iced::Color {
a: if pressed {
0.20 * opacity
} else {
((if selected { 0.14 } else { 0.0 })
+ progress * if selected { 0.02 } else { 0.08 })
* opacity
},
..foreground
})),
..Default::default()
};
let content = button
.class(cosmic::theme::Button::Custom {
active: Box::new(move |_, _| paint(active.get(), false)),
hovered: Box::new(move |_, _| paint(hovered.get(), false)),
pressed: Box::new(move |_, _| paint(1.0, true)),
disabled: Box::new(move |_| paint(0.0, false)),
})
.into();
Element::new(Hover {
content,
progress,
duration: SETTINGS
.get()
.map_or_else(Settings::default, |settings| *settings.read().unwrap())
.hover_duration(),
})
}
#[derive(Default)]
struct HoverState {
current: f32,
start: f32,
target: f32,
started: Option<std::time::Instant>,
}
impl HoverState {
fn advance(
&mut self,
target: f32,
now: std::time::Instant,
duration: std::time::Duration,
) -> bool {
if self.target != target {
self.start = self.current;
self.target = target;
self.started = Some(now);
}
let t = if duration.is_zero() {
1.0
} else {
self.started.map_or(1.0, |start| {
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32())
.min(1.0)
})
};
self.current = self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t);
if t == 1.0 {
self.current = target;
self.started = None;
}
self.current != target
}
}
struct Hover<'a, M> {
content: Element<'a, M>,
progress: std::rc::Rc<std::cell::Cell<f32>>,
duration: std::time::Duration,
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
fn a11y_nodes(
&self,
layout: Layout<'_>,
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<HoverState>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(HoverState::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)]
}
fn diff(&mut self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &cosmic::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
let now = std::time::Instant::now();
let hovered = cursor.is_over(layout.bounds()) && cursor.is_over(*viewport);
let state = tree.state.downcast_mut::<HoverState>();
if state.advance(if hovered { 1.0 } else { 0.0 }, now, self.duration) {
shell.request_redraw_at(now + std::time::Duration::from_millis(16));
}
self.progress.set(state.current);
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.progress
.set(tree.state.downcast_ref::<HoverState>().current);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], 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>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
pub fn animated<'a, M: 'a>(
content: Element<'a, M>,
progress: f32,
@@ -181,6 +497,127 @@ impl widget::Operation for CollectRegions {
mod tests {
use super::*;
#[test]
fn popup_waits_for_configuration_then_opens_and_closes_faster() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings::default());
assert_eq!(
motion.progress_at(now + std::time::Duration::from_secs(30)),
0.0
);
motion.begin(now);
// Later configure/focus notifications must not restart the clock.
motion.begin(now + std::time::Duration::from_millis(90));
assert!(
(motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001
);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_millis(200)),
1.0
);
motion.retarget(0.0, now + std::time::Duration::from_millis(200));
assert!(motion.closing());
assert!(
(motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001
);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_millis(340)),
0.0
);
}
#[test]
fn popup_reversal_and_motion_policy_are_consistent() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings {
speed: 0.75,
..Settings::default()
});
motion.begin(now);
let reverse = now + std::time::Duration::from_millis(120);
let before = motion.progress_at(reverse);
motion.retarget(0.0, reverse);
assert_eq!(motion.progress_at(reverse), before);
let reopen = reverse + std::time::Duration::from_millis(60);
let before = motion.progress_at(reopen);
motion.retarget(1.0, reopen);
assert_eq!(motion.progress_at(reopen), before);
assert_eq!(
motion.progress_at(reopen + std::time::Duration::from_secs(1)),
1.0
);
for settings in [
Settings {
reduced_motion: true,
..Settings::default()
},
Settings {
enabled: false,
..Settings::default()
},
] {
let mut motion = PopupMotion::new(settings);
assert_eq!(motion.progress_at(now), 1.0);
motion.retarget(0.0, now);
assert_eq!(motion.progress_at(now), 0.0);
}
}
#[test]
fn hover_fades_settle_and_idle_does_not_schedule_frames() {
let now = std::time::Instant::now();
let duration = std::time::Duration::from_millis(120);
let mut hover = HoverState::default();
assert!(!hover.advance(0.0, now, duration));
assert!(hover.advance(1.0, now + std::time::Duration::from_secs(10), duration));
assert_eq!(hover.current, 0.0);
let start = now + std::time::Duration::from_secs(10);
assert!(hover.advance(1.0, start + duration / 2, duration));
assert!((hover.current - 0.5).abs() < 0.00001);
assert!(!hover.advance(1.0, start + duration, duration));
assert_eq!(hover.current, 1.0);
assert!(!hover.advance(1.0, start + duration * 10, duration));
assert!(hover.advance(0.0, start + duration * 11, duration));
assert!(!hover.advance(0.0, start + duration * 12, duration));
assert_eq!(hover.current, 0.0);
}
#[test]
fn hover_reversals_preserve_current_paint() {
let now = std::time::Instant::now();
let duration = std::time::Duration::from_millis(120);
let mut hover = HoverState::default();
hover.advance(1.0, now, duration);
hover.advance(1.0, now + duration / 2, duration);
let before = hover.current;
assert!(hover.advance(0.0, now + duration / 2, duration));
assert_eq!(hover.current, before);
hover.advance(0.0, now + duration, duration);
assert!(hover.current > 0.0 && hover.current < before);
}
#[test]
fn hover_obeys_global_motion_policy() {
assert_eq!(
Settings {
speed: 0.75,
..Settings::default()
}
.hover_duration(),
std::time::Duration::from_millis(160)
);
for settings in [
Settings {
enabled: false,
..Settings::default()
},
Settings {
reduced_motion: true,
..Settings::default()
},
] {
assert!(settings.hover_duration().is_zero());
let mut hover = HoverState::default();
assert!(!hover.advance(1.0, std::time::Instant::now(), settings.hover_duration()));
assert_eq!(hover.current, 1.0);
}
}
#[test]
fn material_regions_include_cards_but_not_the_parent_or_gaps() {
let mut collector = CollectRegions(Vec::new());
let id = widget::Id::new("ferese-blur-card");