Panel appearance now applies to the whole panel, with seven styles: Floating, Capsules, Notch, Edge to edge, Fused islands, Adaptive and Morph. - Share composition, geometry, and motion through the renderer-independent `ferese-panel` crate, including native materials, input regions, and Settings previews. - Add the Morph hub with media, calendar, notifications, controls, workspace previews, hover feedback, and attached mode. Keep recording and low-battery status visible and respect window clearance. - Cache workspace preview geometry, draw thumbnails directly, centralize Morph transitions, and keep rendering settings local to each shell instance. - Put Record Screen in the Morph header and show the display control only when an external monitor is connected. Update panel documentation, the desktop screenshot, and native check style names. Local validation included 660 compositor tests, 191 shell tests, isolated preview-cache checks, GPU Morph captures at 1×/1.25×/1.5×, formatting, and Clippy with warnings denied. The final header change passed all 23 Morph tests. Release workflow jobs are skipped for this PR.
415 lines
15 KiB
Rust
415 lines
15 KiB
Rust
//! Surface-local animation sampling. Iced's Wayland backend gates NextFrame
|
|
//! redraws on that surface's frame callback; no animation timer is needed.
|
|
use super::*;
|
|
use std::time::Instant;
|
|
|
|
pub(crate) struct ContentMeasurement<'a, M> {
|
|
pub content: Element<'a, M>,
|
|
pub width: f32,
|
|
pub measured: Box<dyn Fn(Size) + 'a>,
|
|
}
|
|
|
|
pub(crate) fn geometry_driven<'a, M: 'a>(
|
|
revision: Option<Instant>,
|
|
build: impl Fn(Instant) -> Element<'a, M> + 'a,
|
|
active: impl Fn(Instant) -> bool + 'a,
|
|
after_frame: impl Fn(Instant) + 'a,
|
|
completion: impl Fn(Instant) -> Option<M> + 'a,
|
|
measurement: Option<ContentMeasurement<'a, M>>,
|
|
) -> Element<'a, M> {
|
|
driven(revision, build, active, after_frame, completion, true, measurement)
|
|
}
|
|
|
|
pub(crate) fn frame_driven<'a, M: 'a>(
|
|
revision: Option<Instant>,
|
|
build: impl Fn(Instant) -> Element<'a, M> + 'a,
|
|
active: impl Fn(Instant) -> bool + 'a,
|
|
after_frame: impl Fn(Instant) + 'a,
|
|
completion: impl Fn(Instant) -> Option<M> + 'a,
|
|
) -> Element<'a, M> {
|
|
driven(revision, build, active, after_frame, completion, false, None)
|
|
}
|
|
|
|
fn driven<'a, M: 'a>(
|
|
revision: Option<Instant>,
|
|
build: impl Fn(Instant) -> Element<'a, M> + 'a,
|
|
active: impl Fn(Instant) -> bool + 'a,
|
|
after_frame: impl Fn(Instant) + 'a,
|
|
completion: impl Fn(Instant) -> Option<M> + 'a,
|
|
geometry_changes: bool,
|
|
measurement: Option<ContentMeasurement<'a, M>>,
|
|
) -> Element<'a, M> {
|
|
let now = Instant::now();
|
|
let content = build(now);
|
|
let content_active = active(now);
|
|
Element::new(FrameDriven {
|
|
content,
|
|
measurement,
|
|
measured: false,
|
|
geometry_changes,
|
|
content_active,
|
|
revision,
|
|
build: Box::new(build),
|
|
active: Box::new(active),
|
|
after_frame: Box::new(after_frame),
|
|
completion: Box::new(completion),
|
|
})
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FrameState {
|
|
revision: Option<Instant>,
|
|
last_frame: Option<Instant>,
|
|
completed: bool,
|
|
pending: bool,
|
|
limits: Option<layout::Limits>,
|
|
}
|
|
|
|
impl FrameState {
|
|
fn begin(&mut self, at: Instant, revision: Option<Instant>) -> bool {
|
|
if self.revision != revision {
|
|
self.revision = revision;
|
|
self.last_frame = None;
|
|
self.completed = false;
|
|
}
|
|
if self.last_frame == Some(at) {
|
|
return false;
|
|
}
|
|
self.last_frame = Some(at);
|
|
self.pending = true;
|
|
true
|
|
}
|
|
|
|
fn complete(&mut self, active: bool) -> bool {
|
|
if active {
|
|
self.completed = false;
|
|
return false;
|
|
}
|
|
!std::mem::replace(&mut self.completed, true)
|
|
}
|
|
}
|
|
|
|
struct FrameDriven<'a, M> {
|
|
content: Element<'a, M>,
|
|
measurement: Option<ContentMeasurement<'a, M>>,
|
|
measured: bool,
|
|
content_active: bool,
|
|
geometry_changes: bool,
|
|
revision: Option<Instant>,
|
|
build: Box<dyn Fn(Instant) -> Element<'a, M> + 'a>,
|
|
active: Box<dyn Fn(Instant) -> bool + 'a>,
|
|
after_frame: Box<dyn Fn(Instant) + 'a>,
|
|
completion: Box<dyn Fn(Instant) -> Option<M> + 'a>,
|
|
}
|
|
|
|
impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
|
fn tag(&self) -> widget::tree::Tag {
|
|
widget::tree::Tag::of::<FrameState>()
|
|
}
|
|
fn state(&self) -> widget::tree::State {
|
|
widget::tree::State::new(FrameState::default())
|
|
}
|
|
fn children(&self) -> Vec<widget::Tree> {
|
|
std::iter::once(widget::Tree::new(&self.content))
|
|
.chain(self.measurement.iter().map(|m| widget::Tree::new(&m.content)))
|
|
.collect()
|
|
}
|
|
fn diff(&mut self, tree: &mut widget::Tree) {
|
|
tree.children.truncate(1 + usize::from(self.measurement.is_some()));
|
|
tree.children[0].diff(&mut self.content);
|
|
if let Some(measurement) = &mut self.measurement {
|
|
if tree.children.len() == 1 {
|
|
tree.children.push(widget::Tree::new(&measurement.content));
|
|
} else {
|
|
tree.children[1].diff(&mut measurement.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 {
|
|
tree.state.downcast_mut::<FrameState>().limits = Some(*limits);
|
|
if !self.measured {
|
|
if let Some(measurement) = &mut self.measurement {
|
|
// Measure at the final width once, so spring overshoot never changes wrapping or its target.
|
|
let probe_limits = layout::Limits::new(Size::ZERO, Size::new(measurement.width, f32::INFINITY));
|
|
let node = measurement
|
|
.content
|
|
.as_widget_mut()
|
|
.layout(&mut tree.children[1], renderer, &probe_limits);
|
|
(measurement.measured)(node.size());
|
|
let now = Instant::now();
|
|
self.content = (self.build)(now);
|
|
self.content_active = (self.active)(now);
|
|
tree.children[0].diff(&mut self.content);
|
|
}
|
|
self.measured = true;
|
|
}
|
|
self.content
|
|
.as_widget_mut()
|
|
.layout(&mut tree.children[0], renderer, limits)
|
|
}
|
|
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 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 redraw = if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
|
|
tree.state
|
|
.downcast_mut::<FrameState>()
|
|
.begin(*at, self.revision)
|
|
.then_some(*at)
|
|
} else {
|
|
None
|
|
};
|
|
let mut updated_geometry = false;
|
|
let mut fresh_layout = None;
|
|
if let Some(at) = redraw {
|
|
// Preserve focus and scroll state while sampling the current frame.
|
|
let active = (self.active)(at);
|
|
// Include the final settled sample, then reuse the tree on hover
|
|
// and other redraws. Application updates build fresh content.
|
|
if active || self.content_active {
|
|
self.content = (self.build)(at);
|
|
tree.children[0].diff(&mut self.content);
|
|
if self.geometry_changes {
|
|
self.content_active = (self.active)(at);
|
|
let limits = tree
|
|
.state
|
|
.downcast_ref::<FrameState>()
|
|
.limits
|
|
.expect("laid-out animation");
|
|
fresh_layout = Some(
|
|
self.content
|
|
.as_widget_mut()
|
|
.layout(&mut tree.children[0], renderer, &limits)
|
|
.move_to(layout.position()),
|
|
);
|
|
updated_geometry = true;
|
|
}
|
|
}
|
|
self.content_active = (self.active)(at);
|
|
}
|
|
// Iced revalidates the base layout without replaying the event. Publish
|
|
// the measured shape and run the child against this frame's layout now.
|
|
self.content.as_widget_mut().update(
|
|
&mut tree.children[0],
|
|
event,
|
|
fresh_layout.as_ref().map_or(layout, Layout::new),
|
|
cursor,
|
|
renderer,
|
|
clipboard,
|
|
shell,
|
|
viewport,
|
|
);
|
|
if updated_geometry {
|
|
shell.invalidate_layout();
|
|
}
|
|
if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
|
|
let active = (self.active)(*at);
|
|
if active {
|
|
shell.request_redraw();
|
|
}
|
|
// Complete only after a valid layout, including the replay of this frame.
|
|
if std::mem::take(&mut tree.state.downcast_mut::<FrameState>().pending) {
|
|
(self.after_frame)(*at);
|
|
let completion = (self.completion)(*at);
|
|
if tree.state.downcast_mut::<FrameState>().complete(completion.is_none())
|
|
&& let Some(message) = completion
|
|
{
|
|
shell.publish(message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fn draw(
|
|
&self,
|
|
tree: &widget::Tree,
|
|
renderer: &mut cosmic::Renderer,
|
|
theme: &Theme,
|
|
style: &renderer::Style,
|
|
layout: Layout<'_>,
|
|
cursor: mouse::Cursor,
|
|
viewport: &Rectangle,
|
|
) {
|
|
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<'b>(
|
|
&'b mut self,
|
|
tree: &'b mut widget::Tree,
|
|
layout: Layout<'b>,
|
|
renderer: &cosmic::Renderer,
|
|
viewport: &Rectangle,
|
|
translation: Vector,
|
|
) -> Option<cosmic::iced::advanced::overlay::Element<'b, M, Theme, cosmic::Renderer>> {
|
|
self.content
|
|
.as_widget_mut()
|
|
.overlay(&mut tree.children[0], layout, renderer, viewport, translation)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use cosmic::iced::advanced::renderer::Headless;
|
|
use cosmic::iced::window::{Event as WindowEvent, RedrawRequest};
|
|
use std::{cell::RefCell, time::Duration};
|
|
|
|
#[test]
|
|
fn callbacks_sample_every_refresh_without_timers_and_stop_when_settled() {
|
|
let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
|
cosmic::iced::Font::default(),
|
|
cosmic::iced::Pixels(14.0),
|
|
Some("tiny-skia"),
|
|
))
|
|
.unwrap();
|
|
for hz in [60, 120, 144, 240] {
|
|
let start = Instant::now();
|
|
let mut motion = PopupMotion::new(Settings::default());
|
|
motion.begin(start);
|
|
let samples = RefCell::new(Vec::new());
|
|
let mut element = frame_driven(
|
|
motion.revision(),
|
|
|at| {
|
|
samples.borrow_mut().push(motion.progress_at(at));
|
|
cosmic::widget::Space::new().width(20.0).height(20.0).into()
|
|
},
|
|
|at| motion.frame_active(at),
|
|
|_| {},
|
|
|at| (!motion.frame_active(at)).then_some(()),
|
|
);
|
|
let mut tree = widget::Tree::new(&element);
|
|
let node = element.as_widget_mut().layout(
|
|
&mut tree,
|
|
&renderer,
|
|
&layout::Limits::new(Size::ZERO, Size::new(100.0, 100.0)),
|
|
);
|
|
let mut messages = Vec::new();
|
|
let mut finished = false;
|
|
let mut clipboard = cosmic::iced::advanced::clipboard::Null;
|
|
samples.borrow_mut().clear();
|
|
for frame in 0..=hz * 2 {
|
|
let at = start + Duration::from_secs_f64(frame as f64 / hz as f64);
|
|
let event = Event::Window(WindowEvent::RedrawRequested(at));
|
|
let active = motion.frame_active(at);
|
|
for _ in 0..2 {
|
|
// Iced may replay a redraw after a layout/update pass.
|
|
let mut shell = Shell::new(&mut messages);
|
|
element.as_widget_mut().update(
|
|
&mut tree,
|
|
&event,
|
|
Layout::new(&node),
|
|
mouse::Cursor::Unavailable,
|
|
&renderer,
|
|
&mut clipboard,
|
|
&mut shell,
|
|
&Rectangle::new((0.0, 0.0).into(), (100.0, 100.0).into()),
|
|
);
|
|
assert_eq!(
|
|
shell.redraw_request(),
|
|
if active {
|
|
RedrawRequest::NextFrame
|
|
} else {
|
|
RedrawRequest::Wait
|
|
}
|
|
);
|
|
}
|
|
assert_eq!(samples.borrow().len(), frame as usize + 1);
|
|
if !active {
|
|
finished = true;
|
|
break;
|
|
}
|
|
assert!(
|
|
messages.is_empty(),
|
|
"motion should not publish application updates per frame"
|
|
);
|
|
}
|
|
assert!(finished);
|
|
assert_eq!(messages.len(), 1, "only one completion update");
|
|
let settled_builds = samples.borrow().len();
|
|
// A later unrelated redraw must not restart motion or cleanup.
|
|
let mut shell = Shell::new(&mut messages);
|
|
element.as_widget_mut().update(
|
|
&mut tree,
|
|
&Event::Window(WindowEvent::RedrawRequested(start + Duration::from_secs(3))),
|
|
Layout::new(&node),
|
|
mouse::Cursor::Unavailable,
|
|
&renderer,
|
|
&mut clipboard,
|
|
&mut shell,
|
|
&Rectangle::new((0.0, 0.0).into(), (100.0, 100.0).into()),
|
|
);
|
|
assert_eq!(shell.redraw_request(), RedrawRequest::Wait);
|
|
assert_eq!(messages.len(), 1);
|
|
assert_eq!(samples.borrow().len(), settled_builds, "settled redraw rebuilt content");
|
|
let values = samples.borrow();
|
|
assert!(values.windows(2).all(|pair| pair[1] >= pair[0]));
|
|
assert_eq!(values.last(), Some(&1.0));
|
|
assert!(values.len() > (hz / 4) as usize, "high refresh callbacks were skipped");
|
|
}
|
|
// Keep this mutable: the real render path shares the same renderer.
|
|
renderer.reset(Rectangle::default());
|
|
}
|
|
|
|
#[test]
|
|
fn new_transition_can_complete_after_previous_one_settled() {
|
|
let start = Instant::now();
|
|
let mut state = FrameState::default();
|
|
assert!(state.begin(start, Some(start)));
|
|
assert!(state.complete(false));
|
|
assert!(!state.begin(start, Some(start)));
|
|
assert!(!state.complete(false));
|
|
let next = start + Duration::from_millis(10);
|
|
assert!(state.begin(next, Some(next)));
|
|
assert!(!state.complete(true));
|
|
assert!(state.begin(next + Duration::from_millis(400), Some(next)));
|
|
assert!(state.complete(false));
|
|
assert!(!state.complete(false));
|
|
}
|
|
}
|