Files
feresaria/crates/ferese-theme/src/panel_surface.rs
T

743 lines
27 KiB
Rust

use cosmic::iced::advanced::Renderer as _;
use cosmic::iced::advanced::graphics::geometry::{Cache, Path, Renderer as _, Stroke};
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Color, Event, Length, Point, Rectangle, Size, Vector};
use cosmic::{Element, Theme};
use ferese_panel::scene::{GroupBounds, Parameters, Scene};
use ferese_panel::shape::{PixelGrid, ZonePosition};
pub struct Options {
pub parameters: Parameters,
pub bounds: Option<widget::Id>,
pub groups: Vec<(widget::Id, ZonePosition)>,
pub scale: f64,
pub high_contrast: bool,
pub background: Color,
pub border: Color,
pub divider: Color,
pub native: bool,
}
pub fn frame<'a, M: 'a>(
content: Element<'a, M>,
options: Options,
present: impl Fn(&Scene) + 'a,
changed: Option<Changed<'a, M>>,
) -> Element<'a, M> {
Element::new(Surface {
content,
options,
present: Box::new(present),
changed,
})
}
type Present<'a> = Box<dyn Fn(&Scene) + 'a>;
type Changed<'a, M> = Box<dyn Fn(&Scene) -> M + 'a>;
struct Surface<'a, M> {
content: Element<'a, M>,
options: Options,
present: Present<'a>,
changed: Option<Changed<'a, M>>,
}
#[derive(Default)]
struct State {
scene: Scene,
cache: Cache<cosmic::Renderer>,
colors: Option<(Color, Color, Color, bool, u64, bool)>,
published: Option<Scene>,
}
struct Collect<'a> {
targets: &'a [(widget::Id, ZonePosition)],
origin: Point,
groups: Vec<GroupBounds>,
bounds_id: Option<&'a widget::Id>,
bounds: Option<ferese_layout::Rect>,
}
impl widget::Operation for Collect<'_> {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if self.bounds_id.is_some() && id == self.bounds_id {
self.bounds = Some(ferese_layout::Rect::new(
(bounds.x - self.origin.x).into(),
(bounds.y - self.origin.y).into(),
bounds.width.into(),
bounds.height.into(),
));
}
if let Some((_, zone)) = self.targets.iter().find(|(target, _)| Some(target) == id) {
self.groups.push(GroupBounds {
zone: *zone,
bounds: ferese_layout::Rect::new(
(bounds.x - self.origin.x).into(),
(bounds.y - self.origin.y).into(),
bounds.width.into(),
bounds.height.into(),
),
});
}
}
}
impl<M> Surface<'_, M> {
fn measure(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::Renderer,
) {
let mut collector = Collect {
targets: &self.options.groups,
origin: layout.bounds().position(),
groups: Vec::new(),
bounds_id: self.options.bounds.as_ref(),
bounds: None,
};
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
&mut collector,
);
let size = layout.bounds().size();
let scene = PixelGrid::new(self.options.scale)
.and_then(|grid| {
ferese_panel::scene::derive(
self.options.parameters,
collector.bounds.unwrap_or_else(|| {
ferese_layout::Rect::new(0., 0., size.width.into(), size.height.into())
}),
&collector.groups,
grid,
)
})
.unwrap_or_default();
let state = tree.state.downcast_mut::<State>();
let colors = (
self.options.background,
self.options.border,
self.options.divider,
self.options.native,
self.options.scale.to_bits(),
self.options.high_contrast,
);
if state.scene != scene || state.colors != Some(colors) {
state.scene = scene;
state.colors = Some(colors);
state.cache.clear();
}
}
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Surface<'_, M> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::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 {
let node = self
.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits);
self.measure(tree, Layout::new(&node), renderer);
node
}
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,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
if matches!(
event,
Event::Window(cosmic::iced::window::Event::RedrawRequested(_))
) && !shell.is_layout_invalid()
{
self.measure(tree, layout, renderer);
(self.present)(&tree.state.downcast_ref::<State>().scene);
let state = tree.state.downcast_mut::<State>();
if state.published.as_ref() != Some(&state.scene) {
if let Some(changed) = &self.changed {
shell.publish(changed(&state.scene));
}
state.published = Some(state.scene.clone());
}
}
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let state = tree.state.downcast_ref::<State>();
let bounds = layout.bounds();
if !self.options.native
|| self.options.border.a > 0.
|| (!state.scene.dividers.is_empty() && self.options.divider.a > 0.)
{
let geometry = state.cache.draw(renderer, bounds.size(), |frame| {
let scale = if self.options.scale.is_finite() && self.options.scale > 0. {
self.options.scale
} else {
1.
};
let hairline = 1. / scale as f32;
let tolerance = (0.2 / scale).clamp(0.01, 1.);
if let Ok(contours) = ferese_panel::outline::contours(&state.scene, tolerance) {
let path = Path::new(|builder| {
for contour in contours {
if let Some((first, rest)) = contour.split_first() {
builder.move_to(Point::new(first[0] as f32, first[1] as f32));
for point in rest {
builder.line_to(Point::new(point[0] as f32, point[1] as f32));
}
builder.close();
}
}
});
if !self.options.native {
frame.fill(&path, self.options.background);
}
if self.options.border.a > 0. {
let width = if self.options.high_contrast {
hairline.max(1.)
} else {
hairline
};
if self.options.parameters.style == ferese_panel::PanelStyle::Flat {
let y = match self.options.parameters.edge {
ferese_panel::Edge::Top => bounds.height - width / 2.,
ferese_panel::Edge::Bottom => width / 2.,
};
frame.stroke(
&Path::line(Point::new(0., y), Point::new(bounds.width, y)),
Stroke::default()
.with_color(self.options.border)
.with_width(width),
);
} else if self.options.parameters.style == ferese_panel::PanelStyle::Notch
|| (self.options.parameters.style == ferese_panel::PanelStyle::Morph
&& self.options.parameters.attached)
{
if let Ok(contours) = ferese_panel::outline::notch_border(
&state.scene,
tolerance,
self.options.parameters.edge,
) {
let border = Path::new(|builder| {
for contour in contours {
if let Some((first, rest)) = contour.split_first() {
builder.move_to(Point::new(
first[0] as f32,
first[1] as f32,
));
for point in rest {
builder.line_to(Point::new(
point[0] as f32,
point[1] as f32,
));
}
}
}
});
frame.stroke(
&border,
Stroke::default()
.with_color(self.options.border)
.with_width(width),
);
}
} else {
frame.stroke(
&path,
Stroke::default()
.with_color(self.options.border)
.with_width(width),
);
}
}
}
for [x1, y1, x2, y2] in &state.scene.dividers {
frame.stroke(
&Path::line(
Point::new(*x1 as f32, *y1 as f32),
Point::new(*x2 as f32, *y2 as f32),
),
Stroke::default()
.with_color(self.options.divider)
.with_width(hairline),
);
}
});
renderer.with_layer(*viewport, |renderer| {
renderer.with_translation(Vector::new(bounds.x, bounds.y), |renderer| {
renderer.draw_geometry(geometry)
})
});
}
// Keep content after cached vector geometry in the renderer's layer order.
// Parent-layer text can otherwise be batched underneath the background.
renderer.with_layer(*viewport, |renderer| {
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,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::{Font, Pixels};
use ferese_panel::shape::Style;
#[test]
fn shared_surface_publishes_layout_changes_and_leaves_native_fill_to_the_compositor() {
use cosmic::iced::advanced::clipboard;
use std::cell::RefCell;
let mut renderer = cosmic::iced::futures::executor::block_on(
<cosmic::Renderer as Headless>::new(Font::default(), Pixels(14.), Some("tiny-skia")),
)
.unwrap();
let ids = [widget::Id::unique(), widget::Id::unique()];
let mut tree = None;
let viewport = Rectangle::with_size(Size::new(200., 32.));
for (style, width, native) in [
(Style::Floating, 40., false),
(Style::Floating, 40., true),
(Style::Floating, 60., true),
(Style::Flat, 60., true),
] {
let content = cosmic::widget::row::with_children([
cosmic::widget::container(cosmic::widget::Space::new().width(width).height(32))
.id(ids[0].clone())
.into(),
cosmic::widget::container(cosmic::widget::Space::new().width(40).height(32))
.id(ids[1].clone())
.into(),
])
.spacing(20);
let published = RefCell::new(Vec::new());
let mut view = frame(
cosmic::widget::container(content)
.width(200)
.height(32)
.into(),
Options {
parameters: Parameters {
style,
height: 32.,
..Default::default()
},
bounds: None,
groups: vec![
(ids[0].clone(), ZonePosition::Start),
(ids[1].clone(), ZonePosition::End),
],
scale: 1.25,
high_contrast: false,
background: Color::WHITE,
border: Color::TRANSPARENT,
divider: Color::TRANSPARENT,
native,
},
|scene| published.borrow_mut().push(scene.clone()),
Some(Box::new(Clone::clone)),
);
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
tree.diff(&mut view);
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
let mut messages = Vec::new();
for _ in 0..3 {
view.as_widget_mut().update(
tree,
&Event::Window(cosmic::iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
Layout::new(&node),
mouse::Cursor::Unavailable,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
}
assert_eq!(published.borrow().len(), 3);
assert_eq!(
messages.len(),
usize::from(!(style == Style::Floating && width == 40. && native))
);
let scene = published.borrow()[0].clone();
assert_eq!(
scene.input.len(),
if style == Style::Floating { 2 } else { 1 }
);
if style == Style::Floating {
assert!(
scene.distance([width as f64 + 10., 16.]) > 0.,
"the gap stays empty"
);
}
renderer.reset(viewport);
view.as_widget().draw(
tree,
&mut renderer,
&Theme::default(),
&renderer::Style {
text_color: Color::WHITE,
icon_color: Color::WHITE,
scale_factor: 1.25,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&viewport,
);
let pixels = renderer.screenshot(Size::new(250, 40), 1.25, Color::TRANSPARENT);
assert_eq!(pixels.chunks_exact(4).any(|pixel| pixel[3] > 0), !native);
}
}
#[test]
fn border_coverage_tracks_physical_scale_and_high_contrast() {
let mut renderer = cosmic::iced::futures::executor::block_on(
<cosmic::Renderer as Headless>::new(Font::default(), Pixels(14.), Some("tiny-skia")),
)
.unwrap();
for scale in [0.75, 1., 1.25, 1.5, 2.] {
for high_contrast in [false, true] {
let mut view: Element<'_, ()> = frame(
cosmic::widget::Space::new().width(64).height(32).into(),
Options {
parameters: Parameters {
style: Style::Flat,
edge: ferese_panel::Edge::Bottom,
..Default::default()
},
bounds: None,
groups: Vec::new(),
scale,
high_contrast,
background: Color::TRANSPARENT,
border: Color::WHITE,
divider: Color::TRANSPARENT,
native: true,
},
|_| {},
None,
);
let mut tree = widget::Tree::new(&view);
let bounds = Rectangle::with_size(Size::new(64., 32.));
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
renderer.reset(bounds);
view.as_widget().draw(
&tree,
&mut renderer,
&Theme::default(),
&renderer::Style {
text_color: Color::WHITE,
icon_color: Color::WHITE,
scale_factor: scale,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
let size = Size::new((64. * scale) as u32, (32. * scale) as u32);
let pixels = renderer.screenshot(size, scale as f32, Color::TRANSPARENT);
let coverage: f64 = (0..size.height)
.map(|y| {
f64::from(pixels[((y * size.width + size.width / 2) * 4 + 3) as usize])
/ 255.
})
.sum();
let expected = if high_contrast { scale.max(1.) } else { 1. };
assert!(
(coverage - expected).abs() < 0.03,
"scale {scale}, high contrast {high_contrast}: {coverage} physical pixels, expected {expected}"
);
}
}
}
#[test]
fn the_surface_background_keeps_parent_layer_text_visible() {
let mut renderer = cosmic::iced::futures::executor::block_on(
<cosmic::Renderer as Headless>::new(Font::default(), Pixels(14.), Some("tiny-skia")),
)
.unwrap();
for scale in [1., 1.25, 1.5] {
let content = cosmic::widget::container(
cosmic::widget::text("10:09").class(cosmic::theme::Text::Color(Color::WHITE)),
)
.center_x(150)
.center_y(34);
let mut view: Element<'_, ()> = frame(
content.into(),
Options {
parameters: Parameters {
style: Style::Morph,
height: 34.,
..Default::default()
},
bounds: None,
groups: Vec::new(),
scale,
high_contrast: false,
background: Color::BLACK,
border: Color::TRANSPARENT,
divider: Color::TRANSPARENT,
native: false,
},
|_| {},
None,
);
let mut tree = widget::Tree::new(&view);
let viewport = Rectangle::with_size(Size::new(150., 34.));
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
renderer.reset(viewport);
view.as_widget().draw(
&tree,
&mut renderer,
&Theme::default(),
&renderer::Style {
text_color: Color::WHITE,
icon_color: Color::WHITE,
scale_factor: scale,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&viewport,
);
let pixels = renderer.screenshot(
Size::new((150. * scale) as u32, (34. * scale).ceil() as u32),
scale as f32,
Color::BLACK,
);
let ink = pixels
.chunks_exact(4)
.filter(|pixel| pixel[0] > 128)
.count();
assert!(
ink > 25,
"clock text was covered by the surface background at scale {scale}"
);
}
}
#[test]
fn shared_surface_draws_core_outlines_at_fractional_scales() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some("tiny-skia"),
))
.unwrap();
let theme = Theme::default();
for style in [
Style::Floating,
Style::Capsules,
Style::Notch,
Style::Flat,
Style::Frame,
Style::CornerWrap,
Style::Fused,
] {
for scale in [1., 1.25, 1.5] {
let a = widget::Id::unique();
let b = widget::Id::unique();
let content = cosmic::widget::container(cosmic::widget::row![
cosmic::widget::Space::new().width(4),
cosmic::widget::container(cosmic::widget::Space::new().width(20).height(16))
.id(a.clone()),
cosmic::widget::Space::new().width(4),
cosmic::widget::container(cosmic::widget::Space::new().width(20).height(16))
.id(b.clone()),
])
.width(96)
.height(64)
.into();
let mut view: Element<'_, ()> = frame(
content,
Options {
parameters: Parameters {
style,
height: 16.,
gap: 4.,
frame_insets: [16., 4., 4., 4.],
frame_radius: 8.,
wrap_size: [40., 42.],
wrap_width: 8.,
fillet: 6.,
..Default::default()
},
bounds: None,
groups: vec![(a, ZonePosition::Start), (b, ZonePosition::Start)],
scale,
high_contrast: false,
background: Color::WHITE,
border: Color::TRANSPARENT,
divider: Color::TRANSPARENT,
native: false,
},
|_| {},
None,
);
let mut tree = widget::Tree::new(&view);
let bounds = Rectangle::new(Point::ORIGIN, Size::new(96., 64.));
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
renderer.reset(bounds);
view.as_widget().draw(
&tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: Color::WHITE,
icon_color: Color::WHITE,
scale_factor: scale,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
let size = Size::new((96. * scale) as u32, (64. * scale) as u32);
let pixels = Headless::screenshot(&mut renderer, size, scale as f32, Color::BLACK);
let scene = &tree.state.downcast_ref::<State>().scene;
assert!(!scene.paint.is_empty());
for y in 0..size.height {
for x in 0..size.width {
let point = [(f64::from(x) + 0.5) / scale, (f64::from(y) + 0.5) / scale];
let distance = scene.distance(point);
if distance.abs() > 1. / scale {
let ink = pixels[((y * size.width + x) * 4) as usize] > 128;
assert_eq!(ink, distance < 0., "{style:?} scale {scale} at {point:?}");
}
}
}
}
}
}
}