Add panel-wide styles and the Morph hub (#26)
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.
This commit is contained in:
1 parent
42c3b290dd
commit
e22f0ebef9
131 files changed
+12562
-1634
No files matched your search
@@ -1,4 +1,3 @@
|
||||
//! Input estimation and destination selection; all post-release motion uses springs.
|
||||
use std::{collections::VecDeque, time::Duration};
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//! Exact physical spring motion. Presentation policy is deliberately separate.
|
||||
use super::SpringConfig;
|
||||
|
||||
/// Solve m y'' + c y' + k y = 0, with y = current - target.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//! Deterministic elapsed-time traces and the frozen pre-analytic oracle.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-panel = { path = "../ferese-panel" }
|
||||
ferese-theme-model = { path = "../ferese-theme-model" }
|
||||
jiff.workspace = true
|
||||
kdl = "6.7.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod families;
|
||||
pub mod file;
|
||||
pub mod notifications;
|
||||
pub mod panel;
|
||||
pub use ferese_panel::panel;
|
||||
pub mod presets;
|
||||
pub mod theme;
|
||||
|
||||
@@ -14,17 +14,7 @@ use serde_json::{Map, Value};
|
||||
|
||||
pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PanelPreset {
|
||||
#[default]
|
||||
Continuous,
|
||||
Islands,
|
||||
}
|
||||
|
||||
pub const fn default_island_padding() -> f32 {
|
||||
4.0
|
||||
}
|
||||
pub use ferese_panel::{PanelStyle, default_island_padding};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error(String);
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Logical dimensions owned by a panel, independent of its color theme or edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PanelGeometry {
|
||||
pub height: f32,
|
||||
pub edge_margin: i32,
|
||||
pub side_margins: i32,
|
||||
pub window_clearance: i32,
|
||||
pub inner_padding: f32,
|
||||
}
|
||||
|
||||
impl Default for PanelGeometry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
height: 28.,
|
||||
edge_margin: 0,
|
||||
side_margins: 0,
|
||||
window_clearance: 0,
|
||||
inner_padding: 12.,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelGeometry {
|
||||
pub fn validate(self) -> Result<(), String> {
|
||||
if !self.height.is_finite()
|
||||
|| !(24. ..=128.).contains(&self.height)
|
||||
|| !self.inner_padding.is_finite()
|
||||
|| !(0. ..=64.).contains(&self.inner_padding)
|
||||
|| [self.edge_margin, self.side_margins, self.window_clearance]
|
||||
.into_iter()
|
||||
.any(|value| !(0..=4096).contains(&value))
|
||||
{
|
||||
return Err("panel height must be 24..128, inner padding 0..64, and margins/clearance 0..4096".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn invalid_geometry_rejects_nonfinite_dimensions_and_negative_clearances() {
|
||||
for geometry in [
|
||||
PanelGeometry {
|
||||
height: f32::NAN,
|
||||
..Default::default()
|
||||
},
|
||||
PanelGeometry {
|
||||
edge_margin: -1,
|
||||
..Default::default()
|
||||
},
|
||||
PanelGeometry {
|
||||
height: 12.,
|
||||
..Default::default()
|
||||
},
|
||||
PanelGeometry {
|
||||
inner_padding: f32::INFINITY,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(geometry.validate().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -784,7 +784,13 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
|
||||
}
|
||||
}
|
||||
if accessibility.increase_contrast {
|
||||
t.colors.border = hex(readable(rgba(&t.colors.border).unwrap(), surface, 3.));
|
||||
t.colors.border = hex(readable_across(rgba(&t.colors.border).unwrap(), &backgrounds, 3.));
|
||||
if backgrounds
|
||||
.iter()
|
||||
.any(|background| contrast(rgba(&t.colors.border).unwrap(), *background) < 3.)
|
||||
{
|
||||
warnings.push("Border cannot reach 3:1 contrast on all configured surfaces".into());
|
||||
}
|
||||
resolve_material_contrast(t, minimum);
|
||||
}
|
||||
if accessibility.reduce_transparency {
|
||||
@@ -900,7 +906,14 @@ pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
|
||||
)?;
|
||||
let mut value = token_value(&base)?;
|
||||
merge(&mut value, layer, "", false)?;
|
||||
let tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
let mut tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if layer.get("colors").and_then(|colors| colors.get("border")).is_none() {
|
||||
// A new family without an authored border follows its own foreground,
|
||||
// rather than inheriting the unrelated Ferese Blue border.
|
||||
let mut border = rgba(&tokens.colors.text_primary)?;
|
||||
border[3] = 0.10;
|
||||
tokens.colors.border = hex(border);
|
||||
}
|
||||
validate(&tokens)?;
|
||||
warnings.extend(authored_warnings(&tokens));
|
||||
variants[index] = Some(tokens);
|
||||
@@ -1159,6 +1172,7 @@ mod tests {
|
||||
Appearance::Dark => format!("theme {{ mode \"dark\"; dark {{ preset \"{}\"; }}; }}", entry.id),
|
||||
};
|
||||
let resolved = candidate(&source).unwrap().theme;
|
||||
assert_eq!(resolved.tokens.colors.border, tokens.colors.border);
|
||||
assert_eq!(resolved.tokens.colors.surface_raised, tokens.colors.surface_raised);
|
||||
assert_eq!(
|
||||
resolved.tokens.colors.application_background,
|
||||
@@ -1305,6 +1319,61 @@ mod tests {
|
||||
assert!(import_family("custom-test", "colors { accent \"#FFFFFF\"; }").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_borders_follow_the_foreground_only_when_not_authored() {
|
||||
let (_, (light, dark), _) = import_family(
|
||||
"custom-border",
|
||||
r##"theme {
|
||||
light { colors { text-primary "#213040"; }; }
|
||||
dark { colors { text-primary "#F3E2D1"; }; }
|
||||
}"##,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(light.unwrap().colors.border, "#2130401A");
|
||||
assert_eq!(dark.unwrap().colors.border, "#F3E2D11A");
|
||||
|
||||
for border in ["#12345600", "#12345680", "#123456FF"] {
|
||||
let (_, (_, dark), _) = import_family(
|
||||
"custom-border",
|
||||
&format!(r##"theme {{ dark {{ colors {{ border "{border}"; }}; }}; }}"##),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(dark.unwrap().colors.border, border);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_contrast_borders_cover_each_bundled_surface() {
|
||||
for preset in &crate::presets::PRESETS {
|
||||
let mode = if preset.appearance == Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
let theme = candidate(&format!(
|
||||
r#"theme {{ mode "{mode}"; {mode} {{ preset "{}"; }}; accessibility {{ increase-contrast #true; }}; }}"#,
|
||||
preset.id,
|
||||
))
|
||||
.unwrap()
|
||||
.theme;
|
||||
let colors = &theme.tokens.colors;
|
||||
let border = rgba(&colors.border).unwrap();
|
||||
assert_eq!(border[3], 1.);
|
||||
for surface in [
|
||||
&colors.surface_base,
|
||||
&colors.surface_raised,
|
||||
&colors.application_background,
|
||||
&theme.tokens.surface.bar.background,
|
||||
] {
|
||||
assert!(
|
||||
contrast(border, rgba(surface).unwrap()) >= 3.,
|
||||
"{} on {surface}",
|
||||
preset.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_auto_is_context_driven_and_has_no_polling_deadline() {
|
||||
let document = Document::parse(r#"theme { mode "auto"; schedule { source "system"; }; }"#).unwrap();
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
use ferese_config::PanelStyle;
|
||||
use ferese_config::panel::*;
|
||||
|
||||
#[test]
|
||||
fn removed_panel_surface_fields_are_rejected() {
|
||||
for source in [
|
||||
"surface solid;",
|
||||
"group-surface island;",
|
||||
"background islands;",
|
||||
"style continuous;",
|
||||
"style islands;",
|
||||
] {
|
||||
let document = ferese_config::Document::parse(&format!("panel main {{ {source} }}")).unwrap();
|
||||
assert!(
|
||||
serde_json::from_value::<Vec<Panel>>(document.get("panels").unwrap().clone()).is_err(),
|
||||
"{source}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_surfaces_round_trip_without_group_overrides() {
|
||||
let source = "// keep\npanel main { style floating; start { group nav { item overview kind=overview; }; }; center { group title { item title kind=focused-window; }; }; end { group media { item media kind=media; }; }; }";
|
||||
let mut doc = ferese_config::Document::parse(source).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
let panel = &panels[0];
|
||||
assert_eq!(panel.group_surface(), GroupSurface::Island);
|
||||
assert_eq!(panel.preset(), Some(PanelStyle::Floating));
|
||||
doc.set("panels", serde_json::to_value(&panels).unwrap()).unwrap();
|
||||
let restored = ferese_config::Document::parse(&doc.to_string()).unwrap();
|
||||
let restored: Vec<Panel> = serde_json::from_value(restored.get("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(restored, panels);
|
||||
assert!(doc.to_string().contains("// keep"));
|
||||
let mut preset = panel.clone();
|
||||
let groups = [&panel.start, &panel.center, &panel.end].map(|zone| zone.groups.clone());
|
||||
preset.apply_preset(PanelStyle::Flat);
|
||||
assert_eq!(preset.preset(), Some(PanelStyle::Flat));
|
||||
assert_eq!(
|
||||
[&preset.start, &preset.center, &preset.end].map(|zone| zone.groups.clone()),
|
||||
groups
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_surface_options_are_rejected_instead_of_inherited_or_ignored() {
|
||||
for surface in ["none", "inset", "island", "default", "unset"] {
|
||||
let doc = ferese_config::Document::parse(&format!(
|
||||
"panel main {{ start {{ group nav {{ surface {surface}; item clock kind=clock; }} }} }}"
|
||||
))
|
||||
.unwrap();
|
||||
let error = serde_json::from_value::<Vec<Panel>>(doc.get("panels").unwrap().clone())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("unknown field `surface`"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_round_trips_kdl_and_edits_keep_ids_order_and_comments() {
|
||||
let source = r#"// panel comment
|
||||
panel "main" {
|
||||
style "floating"
|
||||
end {
|
||||
group "status" {
|
||||
padding 2 3
|
||||
// network comment
|
||||
item "network" kind="network" overflow="always"
|
||||
item "local-clock" kind="clock"
|
||||
item "second-clock" kind="clock" representation="compact"
|
||||
}
|
||||
}
|
||||
}
|
||||
animations { speed 0.8; }
|
||||
"#;
|
||||
let mut doc = ferese_config::Document::parse(source).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
validate(&panels).unwrap();
|
||||
assert_eq!(panels[0].end.groups[0].padding, [2, 3]);
|
||||
assert!(panels[0].background_opacity.is_none());
|
||||
assert_eq!(panels[0].end.groups[0].items[0].overflow, OverflowPolicy::Always);
|
||||
doc.set("panels.0.end.groups.0.items.0.overflow", "never".into())
|
||||
.unwrap();
|
||||
let path = "panels.0.end.groups.0.items";
|
||||
let mut items = doc.get(path).unwrap().as_array().unwrap().clone();
|
||||
items.swap(0, 2);
|
||||
doc.set(path, items.into()).unwrap();
|
||||
let formatted = doc.to_string();
|
||||
assert!(formatted.contains("// panel comment"));
|
||||
assert!(formatted.contains("// network comment"));
|
||||
let parsed = ferese_config::Document::parse(&formatted).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(parsed.get("panels").unwrap().clone()).unwrap();
|
||||
validate(&panels).unwrap();
|
||||
assert_eq!(
|
||||
panels[0].end.groups[0]
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.id.0.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["second-clock", "local-clock", "network"]
|
||||
);
|
||||
assert_eq!(
|
||||
panels[0].item(&ItemId("network".into())).unwrap().overflow,
|
||||
OverflowPolicy::Never
|
||||
);
|
||||
assert_eq!(parsed.get("animations.speed").unwrap(), &serde_json::json!(0.8));
|
||||
let value = serde_json::to_value(&panels).unwrap();
|
||||
doc.set("panels", value.clone()).unwrap();
|
||||
let round_trip = ferese_config::Document::parse(&doc.to_string()).unwrap();
|
||||
let restored: Vec<Panel> = serde_json::from_value(round_trip.get("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(restored, panels);
|
||||
doc.set(path, Vec::<serde_json::Value>::new().into()).unwrap();
|
||||
assert!(doc.to_string().contains("// network comment"));
|
||||
let restored: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
assert!(restored[0].end.groups[0].items.is_empty());
|
||||
assert_eq!(restored[0].end.spacing, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_border_defaults_off_and_round_trips_an_explicit_enabled_value() {
|
||||
let mut doc =
|
||||
ferese_config::Document::parse("panel main { end { group status { item clock kind=clock; }; }; }\n").unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
assert!(!panels[0].border);
|
||||
doc.set("panels.0.border", true.into()).unwrap();
|
||||
let doc = ferese_config::Document::parse(&doc.to_string()).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
assert!(panels[0].border);
|
||||
assert_eq!(panels[0].end.groups[0].items[0].id.0, "clock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_background_opacity_inherits_until_overridden_and_validates_bounds() {
|
||||
let doc = ferese_config::Document::parse("panel main { end { group status { }; }; }").unwrap();
|
||||
let mut panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(panels[0].background_opacity, None);
|
||||
assert!(
|
||||
serde_json::to_value(&panels[0]).unwrap()["end"]["groups"][0]
|
||||
.get("background_opacity")
|
||||
.is_none()
|
||||
);
|
||||
for value in [0., 0.5, 1.] {
|
||||
panels[0].background_opacity = Some(value);
|
||||
validate(&panels).unwrap();
|
||||
}
|
||||
for value in [-0.1, 1.1, f32::NAN, f32::INFINITY] {
|
||||
panels[0].background_opacity = Some(value);
|
||||
assert!(validate(&panels).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_edge_defaults_to_top_and_round_trips_both_positions() {
|
||||
for (source, expected) in [
|
||||
("", Edge::Top),
|
||||
("edge top;", Edge::Top),
|
||||
("edge bottom;", Edge::Bottom),
|
||||
] {
|
||||
let document = ferese_config::Document::parse(&format!("panel main {{ {source} }}")).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(document.get("panels").unwrap().clone()).unwrap();
|
||||
validate(&panels).unwrap();
|
||||
assert_eq!(panels[0].edge, expected);
|
||||
let restored: Vec<Panel> = serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap();
|
||||
assert_eq!(restored[0].edge, expected);
|
||||
let mut moved = panels[0].clone();
|
||||
moved.edge = if expected == Edge::Top { Edge::Bottom } else { Edge::Top };
|
||||
assert!(!moved.same_composition(&panels[0]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_duplicate_ids_and_unsupported_surface_configuration() {
|
||||
let mut panel = Panel::default();
|
||||
validate(std::slice::from_ref(&panel)).unwrap();
|
||||
panel.end.groups[0].items[1].id = panel.start.groups[0].items[0].id.clone();
|
||||
assert!(validate(std::slice::from_ref(&panel)).is_err());
|
||||
panel.end.groups[0].items[1].id = ItemId("_overflow".into());
|
||||
assert!(validate(std::slice::from_ref(&panel)).is_err());
|
||||
assert!(validate(&[]).is_err());
|
||||
assert!(validate(&[panel.clone(), panel]).is_err());
|
||||
assert!(ferese_config::from_str::<serde_json::Value>("panel \"main\" { edge \"bottom\"; }").is_ok());
|
||||
let doc = ferese_config::Document::parse("panel \"main\" { edge \"left\"; }").unwrap();
|
||||
assert!(serde_json::from_value::<Vec<Panel>>(doc.get("panels").unwrap().clone()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn workspace_styles_round_trip_and_only_apply_to_workspaces() {
|
||||
for style in WorkspaceStyle::ALL {
|
||||
let source = format!(
|
||||
"panel main {{ start {{ group nav {{ item spaces kind=\"workspaces\" style=\"{}\"; }} }} }}",
|
||||
style.key()
|
||||
);
|
||||
let document = ferese_config::Document::parse(&source).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(document.get("panels").unwrap().clone()).unwrap();
|
||||
validate(&panels).unwrap();
|
||||
assert_eq!(panels[0].start.groups[0].items[0].kind, ItemKind::Workspaces { style });
|
||||
let mut wrong_kind = serde_json::to_value(&panels[0].start.groups[0].items[0]).unwrap();
|
||||
wrong_kind["kind"] = "clock".into();
|
||||
assert!(serde_json::from_value::<Item>(wrong_kind).is_err());
|
||||
let restored: Vec<Panel> = serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap();
|
||||
assert_eq!(restored, panels);
|
||||
}
|
||||
let mut item = serde_json::json!({"id": "spaces", "kind": "workspaces"});
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Item>(item.clone()).unwrap().kind,
|
||||
ItemKind::Workspaces {
|
||||
style: WorkspaceStyle::Dots
|
||||
}
|
||||
);
|
||||
item["style"] = "unsupported".into();
|
||||
assert!(serde_json::from_value::<Item>(item).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_options_are_strict_and_metadata_is_explicit() {
|
||||
for key in ["ovreflow", "visibile", "custom_note"] {
|
||||
let mut value = serde_json::json!({"id":"network", "kind":"network"});
|
||||
value[key] = "always".into();
|
||||
let error = serde_json::from_value::<Item>(value).unwrap_err().to_string();
|
||||
assert!(error.contains(key) && error.contains("unknown field"), "{error}");
|
||||
}
|
||||
let source = "panel main { start { group nav { item network kind=\"network\" { metadata { custom-note \"keep\"; overflow \"authored note\"; }; }; }; }; }";
|
||||
let panel: Vec<Panel> = serde_json::from_value(
|
||||
ferese_config::Document::parse(source)
|
||||
.unwrap()
|
||||
.get("panels")
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let item = &panel[0].start.groups[0].items[0];
|
||||
assert_eq!(item.metadata["custom_note"], "keep");
|
||||
assert_eq!(item.overflow, OverflowPolicy::Auto);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Item>(serde_json::to_value(item).unwrap()).unwrap(),
|
||||
*item
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<Item>(serde_json::json!({"id":"network", "kind":"network", "metadata":"wrong"}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ pub enum LayoutMode {
|
||||
Tree,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum WorkspaceLayout {
|
||||
Scrolling(ScrollingLayout),
|
||||
Tree(LayoutTree),
|
||||
|
||||
@@ -139,7 +139,7 @@ impl fmt::Display for LayoutError {
|
||||
|
||||
impl Error for LayoutError {}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LayoutTree {
|
||||
root: Option<NodeId>,
|
||||
nodes: HashMap<NodeId, Node>,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "ferese-panel"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-animation = { path = "../ferese-animation" }
|
||||
ferese-layout = { path = "../ferese-layout" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,147 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::PanelStyle;
|
||||
|
||||
pub const HYSTERESIS: Duration = Duration::from_millis(150);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct WindowState {
|
||||
pub maximized: bool,
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Presentation {
|
||||
Floating,
|
||||
Flat,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl Presentation {
|
||||
pub fn style(self) -> Option<PanelStyle> {
|
||||
match self {
|
||||
Self::Floating => Some(PanelStyle::Floating),
|
||||
Self::Flat => Some(PanelStyle::Flat),
|
||||
Self::Hidden => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WindowState> for Presentation {
|
||||
fn from(state: WindowState) -> Self {
|
||||
if state.fullscreen {
|
||||
Self::Hidden
|
||||
} else if state.maximized {
|
||||
Self::Flat
|
||||
} else {
|
||||
Self::Floating
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One instance belongs to one output. A deadline exists only while a new
|
||||
/// ordinary window state remains stable; fullscreen takes effect immediately.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Adaptive {
|
||||
current: Presentation,
|
||||
pending: Option<(Presentation, Duration)>,
|
||||
}
|
||||
|
||||
impl Adaptive {
|
||||
pub fn new(state: WindowState) -> Self {
|
||||
Self {
|
||||
current: state.into(),
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current(&self) -> Presentation {
|
||||
self.current
|
||||
}
|
||||
|
||||
pub fn observe(&mut self, state: WindowState, now: Duration) -> bool {
|
||||
let desired = Presentation::from(state);
|
||||
if desired == self.current {
|
||||
self.pending = None;
|
||||
return false;
|
||||
}
|
||||
if desired == Presentation::Hidden || self.current == Presentation::Hidden {
|
||||
self.current = desired;
|
||||
self.pending = None;
|
||||
return true;
|
||||
}
|
||||
match self.pending {
|
||||
Some((target, since)) if target == desired && now >= since => {
|
||||
if now - since >= HYSTERESIS {
|
||||
self.current = target;
|
||||
self.pending = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.pending = Some((desired, now));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deadline(&self) -> Option<Duration> {
|
||||
self.pending.map(|(_, since)| since.saturating_add(HYSTERESIS))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(ms: u64) -> Duration {
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
fn maximized() -> WindowState {
|
||||
WindowState {
|
||||
maximized: true,
|
||||
fullscreen: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_flapping_cancels_the_pending_style() {
|
||||
let mut state = Adaptive::new(WindowState::default());
|
||||
assert!(!state.observe(maximized(), at(0)));
|
||||
assert!(!state.observe(WindowState::default(), at(149)));
|
||||
assert_eq!(state.deadline(), None);
|
||||
assert!(!state.observe(maximized(), at(150)));
|
||||
assert!(!state.observe(maximized(), at(299)));
|
||||
assert!(state.observe(maximized(), at(300)));
|
||||
assert_eq!(state.current(), Presentation::Flat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_cancels_pending_work_and_restores_without_delay() {
|
||||
let mut state = Adaptive::new(WindowState::default());
|
||||
state.observe(maximized(), at(0));
|
||||
assert!(state.observe(
|
||||
WindowState {
|
||||
fullscreen: true,
|
||||
..maximized()
|
||||
},
|
||||
at(1)
|
||||
));
|
||||
assert_eq!(state.current().style(), None);
|
||||
assert_eq!(state.deadline(), None);
|
||||
assert!(state.observe(maximized(), at(2)));
|
||||
assert_eq!(state.current().style(), Some(PanelStyle::Flat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_change_independently() {
|
||||
let mut left = Adaptive::new(WindowState::default());
|
||||
let right = Adaptive::new(WindowState::default());
|
||||
left.observe(maximized(), at(0));
|
||||
left.observe(maximized(), at(150));
|
||||
assert_eq!(left.current(), Presentation::Flat);
|
||||
assert_eq!(right.current(), Presentation::Floating);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub mod adaptive;
|
||||
pub mod morph;
|
||||
pub mod motion;
|
||||
pub mod panel;
|
||||
pub use panel::*;
|
||||
pub mod outline;
|
||||
pub mod scene;
|
||||
pub mod shape;
|
||||
|
||||
pub use shape::Style as PanelStyle;
|
||||
|
||||
pub const fn default_island_padding() -> f32 {
|
||||
4.0
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ferese_layout::Rect;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::motion::{Geometry, Motion};
|
||||
|
||||
const FADE: Duration = Duration::from_millis(120);
|
||||
const CONTENT_DELAY: Duration = Duration::from_millis(130);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Profile {
|
||||
pub peek_on_hover: bool,
|
||||
pub attached: bool,
|
||||
pub idle_width: f64,
|
||||
pub peek_width: f64,
|
||||
pub peek_height: f64,
|
||||
pub toast_width: f64,
|
||||
pub toast_height: f64,
|
||||
pub expanded_width: f64,
|
||||
pub workspaces_height: f64,
|
||||
pub media_height: f64,
|
||||
pub calendar_height: f64,
|
||||
pub notifications_height: f64,
|
||||
pub controls_height: f64,
|
||||
pub expanded_radius: f64,
|
||||
}
|
||||
|
||||
impl Default for Profile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
peek_on_hover: true,
|
||||
attached: false,
|
||||
idle_width: 150.,
|
||||
peek_width: 260.,
|
||||
peek_height: 44.,
|
||||
toast_width: 320.,
|
||||
toast_height: 48.,
|
||||
expanded_width: 340.,
|
||||
workspaces_height: 176.,
|
||||
media_height: 170.,
|
||||
calendar_height: 230.,
|
||||
notifications_height: 176.,
|
||||
controls_height: 248.,
|
||||
expanded_radius: 26.,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn validate(self) -> Result<(), &'static str> {
|
||||
let sizes = [
|
||||
self.idle_width,
|
||||
self.peek_width,
|
||||
self.peek_height,
|
||||
self.toast_width,
|
||||
self.toast_height,
|
||||
self.expanded_width,
|
||||
self.workspaces_height,
|
||||
self.media_height,
|
||||
self.calendar_height,
|
||||
self.notifications_height,
|
||||
self.controls_height,
|
||||
];
|
||||
if sizes
|
||||
.iter()
|
||||
.any(|value| !value.is_finite() || !(24. ..=1024.).contains(value))
|
||||
|| !self.expanded_radius.is_finite()
|
||||
|| !(0. ..=128.).contains(&self.expanded_radius)
|
||||
{
|
||||
Err("hub dimensions must be 24..1024 and its expanded radius 0..128")
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(self, mode: Mode, collapsed_height: f64) -> [f64; 2] {
|
||||
match mode {
|
||||
Mode::Idle => [self.idle_width, collapsed_height],
|
||||
Mode::Peek => [self.peek_width, self.peek_height],
|
||||
Mode::Toast => [self.toast_width, self.toast_height],
|
||||
Mode::Expanded(content) => [
|
||||
self.expanded_width,
|
||||
match content {
|
||||
Content::Workspaces => self.workspaces_height,
|
||||
Content::Media => self.media_height,
|
||||
Content::Calendar => self.calendar_height,
|
||||
Content::Notifications => self.notifications_height,
|
||||
Content::Controls => self.controls_height,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Content {
|
||||
Workspaces,
|
||||
Media,
|
||||
Calendar,
|
||||
Notifications,
|
||||
Controls,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
#[default]
|
||||
Idle,
|
||||
Peek,
|
||||
Toast,
|
||||
Expanded(Content),
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn expanded(self) -> bool {
|
||||
matches!(self, Self::Expanded(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Sample {
|
||||
pub geometry: Geometry,
|
||||
pub previous: Mode,
|
||||
pub previous_opacity: f32,
|
||||
pub current: Mode,
|
||||
pub opacity: f32,
|
||||
}
|
||||
|
||||
/// The exclusive zone is deliberately absent. Expansion changes the painted
|
||||
/// island and its hit target; the caller retains the collapsed reservation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Hub {
|
||||
profile: Profile,
|
||||
pins: Option<[f64; 4]>,
|
||||
height: f64,
|
||||
motion: Motion,
|
||||
mode: Mode,
|
||||
previous: Mode,
|
||||
previous_opacity: f32,
|
||||
initial_opacity: f32,
|
||||
elapsed: Duration,
|
||||
available: [f64; 2],
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
pub fn new(profile: Profile, collapsed_height: f64) -> Result<Self, &'static str> {
|
||||
profile.validate()?;
|
||||
if !collapsed_height.is_finite() || !(24. ..=128.).contains(&collapsed_height) {
|
||||
return Err("hub height must be 24..128");
|
||||
}
|
||||
let size = profile.size(Mode::Idle, collapsed_height);
|
||||
Ok(Self {
|
||||
profile,
|
||||
pins: None,
|
||||
height: collapsed_height,
|
||||
motion: Motion::with_spring(
|
||||
Geometry::rounded(Rect::new(0., 0., size[0], size[1]), size[1] / 2.),
|
||||
ferese_animation::SpringConfig {
|
||||
stiffness: 300.,
|
||||
damping: 2. * 0.7 * 300_f64.sqrt(),
|
||||
position_tolerance: 0.05,
|
||||
velocity_tolerance: 0.05,
|
||||
crossing: ferese_animation::CrossingPolicy::AllowOvershoot,
|
||||
..Default::default()
|
||||
},
|
||||
)?,
|
||||
mode: Mode::Idle,
|
||||
previous: Mode::Idle,
|
||||
previous_opacity: 0.,
|
||||
initial_opacity: 1.,
|
||||
elapsed: FADE,
|
||||
available: [f64::MAX; 2],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pin_radii(&mut self, pins: Option<[f64; 4]>) -> Result<(), &'static str> {
|
||||
if pins.is_some_and(|pins| {
|
||||
pins.iter()
|
||||
.any(|value| !value.is_finite() || !(0. ..=128.).contains(value))
|
||||
}) {
|
||||
return Err("hub radius pins must be finite values between 0 and 128");
|
||||
}
|
||||
self.pins = pins;
|
||||
let mut target = self.motion.target();
|
||||
target.radii = pins.unwrap_or(
|
||||
[if self.mode.expanded() {
|
||||
self.profile.expanded_radius
|
||||
} else {
|
||||
target.bounds.height / 2.
|
||||
}; 4],
|
||||
);
|
||||
self.motion.retarget(target)
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> Mode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
pub fn retarget_peek(&mut self, width: f64, available: [f64; 2]) -> Result<(), &'static str> {
|
||||
if !width.is_finite() || !(24. ..=1024.).contains(&width) {
|
||||
return Err("peek width must be 24..1024");
|
||||
}
|
||||
let configured = self.profile.peek_width;
|
||||
self.profile.peek_width = width;
|
||||
let result = self.retarget(Mode::Peek, available);
|
||||
self.profile.peek_width = configured;
|
||||
result
|
||||
}
|
||||
|
||||
pub fn retarget_idle(&mut self, width: f64, available: [f64; 2]) -> Result<(), &'static str> {
|
||||
if !width.is_finite() || !(24. ..=1024.).contains(&width) {
|
||||
return Err("idle width must be 24..1024");
|
||||
}
|
||||
let configured = self.profile.idle_width;
|
||||
self.profile.idle_width = width;
|
||||
let result = self.retarget(Mode::Idle, available);
|
||||
self.profile.idle_width = configured;
|
||||
result
|
||||
}
|
||||
|
||||
pub fn retarget(&mut self, mode: Mode, available: [f64; 2]) -> Result<(), &'static str> {
|
||||
if available.iter().any(|value| !value.is_finite() || *value <= 0.) {
|
||||
return Err("hub requires a finite positive available area");
|
||||
}
|
||||
let [width, height] = self.profile.size(mode, self.height);
|
||||
let width = width.min(available[0]);
|
||||
let height = height.min(available[1]);
|
||||
let radius = if mode.expanded() {
|
||||
self.profile.expanded_radius
|
||||
} else {
|
||||
height / 2.
|
||||
};
|
||||
let mut geometry = Geometry::rounded(Rect::new(0., 0., width, height), radius);
|
||||
geometry.radii = self.pins.unwrap_or(geometry.radii);
|
||||
if mode == self.mode {
|
||||
self.available = available;
|
||||
return self.motion.retarget(geometry);
|
||||
}
|
||||
let sample = self.sample();
|
||||
self.available = available;
|
||||
if sample.previous_opacity > sample.opacity {
|
||||
self.previous = sample.previous;
|
||||
self.previous_opacity = sample.previous_opacity;
|
||||
} else {
|
||||
self.previous = sample.current;
|
||||
self.previous_opacity = sample.opacity;
|
||||
}
|
||||
self.initial_opacity = if mode == sample.current {
|
||||
sample.opacity
|
||||
} else if mode == sample.previous {
|
||||
sample.previous_opacity
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
if self.previous == mode {
|
||||
self.previous_opacity = 0.;
|
||||
}
|
||||
self.mode = mode;
|
||||
self.elapsed = Duration::ZERO;
|
||||
self.motion.retarget(geometry)
|
||||
}
|
||||
|
||||
/// Fit the expanded shell to measured content without restarting its content fade.
|
||||
pub fn fit_content_height(&mut self, height: f64) -> Result<(), &'static str> {
|
||||
if !height.is_finite() || height <= 0. {
|
||||
return Err("content height must be finite and positive");
|
||||
}
|
||||
if self.mode.expanded() {
|
||||
let mut target = self.motion.target();
|
||||
target.bounds.height = height
|
||||
.max(24.)
|
||||
.min(self.profile.size(self.mode, self.height)[1])
|
||||
.min(self.available[1]);
|
||||
self.motion.retarget(target)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace content inside an already settled shape without restarting its springs.
|
||||
pub fn replace_content(&mut self) {
|
||||
self.previous = self.mode;
|
||||
self.previous_opacity = self.sample().opacity;
|
||||
self.initial_opacity = 0.;
|
||||
self.elapsed = Duration::ZERO;
|
||||
}
|
||||
|
||||
pub fn advance(&mut self, elapsed: Duration, reduced_motion: bool) -> bool {
|
||||
self.elapsed = self.elapsed.saturating_add(elapsed);
|
||||
self.motion.advance(elapsed, reduced_motion);
|
||||
if reduced_motion {
|
||||
self.previous_opacity = 0.;
|
||||
self.initial_opacity = 1.;
|
||||
}
|
||||
self.active()
|
||||
}
|
||||
|
||||
pub fn active(&self) -> bool {
|
||||
self.motion.active() || self.sample().previous_opacity > 0. || self.sample().opacity < 1.
|
||||
}
|
||||
|
||||
pub fn sample(&self) -> Sample {
|
||||
let fade = |elapsed: Duration| (elapsed.as_secs_f32() / FADE.as_secs_f32()).clamp(0., 1.);
|
||||
let opacity =
|
||||
self.initial_opacity + (1. - self.initial_opacity) * fade(self.elapsed.saturating_sub(CONTENT_DELAY));
|
||||
let mut geometry = self.motion.current();
|
||||
geometry.bounds.width = geometry.bounds.width.clamp(0., self.available[0]);
|
||||
geometry.bounds.height = geometry.bounds.height.clamp(0., self.available[1]);
|
||||
geometry.radii = geometry.radii.map(|radius| radius.max(0.));
|
||||
Sample {
|
||||
geometry,
|
||||
previous: self.previous,
|
||||
previous_opacity: self.previous_opacity * (1. - fade(self.elapsed)),
|
||||
current: self.mode,
|
||||
opacity,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allocation_size(&self) -> [f64; 2] {
|
||||
let maximum = self.motion.maximum_size();
|
||||
std::array::from_fn(|i| maximum[i].min(self.available[i]))
|
||||
}
|
||||
|
||||
pub fn target_size(&self) -> [f64; 2] {
|
||||
let target = self.motion.target().bounds;
|
||||
[target.width, target.height]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_width_changes_preserve_content_and_motion() {
|
||||
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
|
||||
hub.retarget_idle(270., [800., 600.]).unwrap();
|
||||
hub.advance(Duration::from_millis(60), false);
|
||||
let before = hub.sample();
|
||||
assert_eq!(before.opacity, 1.);
|
||||
hub.retarget_idle(300., [800., 600.]).unwrap();
|
||||
assert_eq!(hub.sample(), before);
|
||||
assert_eq!(hub.target_size(), [300., 34.]);
|
||||
hub.advance(Duration::from_secs(2), false);
|
||||
assert_eq!(hub.sample().geometry.bounds.width, 300.);
|
||||
assert_eq!(hub.sample().opacity, 1.);
|
||||
hub.retarget_peek(360., [320., 600.]).unwrap();
|
||||
assert_eq!(hub.target_size(), [320., 44.]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_height_preserves_fade_and_motion_and_caps_long_content() {
|
||||
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
|
||||
hub.retarget(Mode::Expanded(Content::Controls), [800., 600.]).unwrap();
|
||||
hub.advance(Duration::from_millis(180), false);
|
||||
let before = hub.sample();
|
||||
hub.fit_content_height(148.).unwrap();
|
||||
assert_eq!(hub.target_size(), [340., 148.]);
|
||||
assert_eq!(hub.sample().geometry, before.geometry);
|
||||
assert_eq!(hub.sample().opacity, before.opacity);
|
||||
hub.advance(Duration::from_secs(2), false);
|
||||
assert_eq!(hub.sample().geometry.bounds.height, 148.);
|
||||
assert_eq!(hub.sample().opacity, 1.);
|
||||
hub.fit_content_height(900.).unwrap();
|
||||
assert_eq!(hub.target_size(), [340., Profile::default().controls_height]);
|
||||
hub.retarget(Mode::Idle, [800., 600.]).unwrap();
|
||||
hub.fit_content_height(100.).unwrap();
|
||||
assert_eq!(hub.target_size(), [150., 34.]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_content_swap_fades_without_restarting_the_geometry() {
|
||||
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
|
||||
hub.retarget(Mode::Expanded(Content::Controls), [800., 600.]).unwrap();
|
||||
hub.advance(Duration::from_secs(2), false);
|
||||
hub.advance(Duration::from_secs(1), false);
|
||||
let geometry = hub.sample().geometry;
|
||||
hub.replace_content();
|
||||
assert_eq!(hub.sample().geometry, geometry);
|
||||
assert_eq!(hub.sample().opacity, 0.);
|
||||
assert_eq!(hub.sample().previous_opacity, 1.);
|
||||
hub.advance(Duration::from_millis(60), false);
|
||||
assert_eq!(hub.sample().opacity, 0.);
|
||||
assert!((hub.sample().previous_opacity - 0.5).abs() < 0.001);
|
||||
hub.advance(Duration::from_millis(130), false);
|
||||
assert!((hub.sample().opacity - 0.5).abs() < 0.001);
|
||||
assert_eq!(hub.sample().previous_opacity, 0.);
|
||||
hub.advance(Duration::from_millis(60), false);
|
||||
assert!(!hub.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_swap_uses_a_fixed_delay_instead_of_waiting_for_spring_settlement() {
|
||||
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
|
||||
assert!(!hub.active());
|
||||
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap();
|
||||
assert_eq!(hub.target_size(), [340., 230.]);
|
||||
hub.advance(Duration::from_millis(120), false);
|
||||
assert_eq!(hub.sample().opacity, 0.);
|
||||
assert_eq!(hub.sample().previous_opacity, 0.);
|
||||
hub.advance(Duration::from_millis(70), false);
|
||||
assert!((hub.sample().opacity - 0.5).abs() < 0.001);
|
||||
hub.advance(Duration::from_millis(60), false);
|
||||
assert_eq!(hub.sample().opacity, 1.);
|
||||
assert!(hub.active(), "content finishes while the shape is still settling");
|
||||
for _ in 0..50 {
|
||||
hub.advance(Duration::from_millis(16), false);
|
||||
}
|
||||
assert_eq!(hub.sample().opacity, 1.);
|
||||
assert_eq!(hub.sample().previous_opacity, 0.);
|
||||
assert!(!hub.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interrupted_content_delay_never_reveals_the_cancelled_panel() {
|
||||
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
|
||||
hub.retarget(Mode::Expanded(Content::Media), [800., 600.]).unwrap();
|
||||
hub.advance(Duration::from_millis(80), false);
|
||||
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap();
|
||||
for _ in 0..130 {
|
||||
hub.advance(Duration::from_millis(8), false);
|
||||
let sample = hub.sample();
|
||||
assert_eq!(sample.current, Mode::Expanded(Content::Calendar));
|
||||
assert_eq!(sample.previous, Mode::Idle);
|
||||
}
|
||||
assert!(!hub.active());
|
||||
assert_eq!(hub.sample().opacity, 1.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_overshoots_then_settles_within_its_reserved_surface() {
|
||||
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
|
||||
hub.retarget(Mode::Expanded(Content::Calendar), [800., 600.]).unwrap();
|
||||
let allocation = hub.allocation_size();
|
||||
let mut peak = 340_f64;
|
||||
for _ in 0..200 {
|
||||
hub.advance(Duration::from_millis(8), false);
|
||||
let sample = hub.sample();
|
||||
peak = peak.max(sample.geometry.bounds.width);
|
||||
assert!(sample.geometry.bounds.width <= allocation[0] + 1e-9);
|
||||
assert!(sample.geometry.bounds.height <= allocation[1] + 1e-9);
|
||||
}
|
||||
let overshoot = (peak - 340.) / (340. - 150.);
|
||||
assert!((0.04..0.05).contains(&overshoot));
|
||||
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 340., 230.));
|
||||
assert!(!hub.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overshoot_is_clipped_to_small_outputs_and_reduced_motion_removes_all_fades() {
|
||||
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
|
||||
hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.]).unwrap();
|
||||
for _ in 0..60 {
|
||||
hub.advance(Duration::from_millis(8), false);
|
||||
let bounds = hub.sample().geometry.bounds;
|
||||
assert!(bounds.width <= 240. && bounds.height <= 180.);
|
||||
}
|
||||
hub.retarget(Mode::Idle, [240., 180.]).unwrap();
|
||||
hub.advance(Duration::ZERO, true);
|
||||
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 150., 34.));
|
||||
assert_eq!(hub.sample().previous_opacity, 0.);
|
||||
assert_eq!(hub.sample().opacity, 1.);
|
||||
assert!(!hub.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_expansion_keeps_the_presented_shape() {
|
||||
let mut hub = Hub::new(Profile::default(), 34.).unwrap();
|
||||
hub.retarget(Mode::Expanded(Content::Media), [800., 600.]).unwrap();
|
||||
hub.advance(Duration::from_millis(40), false);
|
||||
let before = hub.sample().geometry;
|
||||
hub.retarget(Mode::Idle, [800., 600.]).unwrap();
|
||||
assert_eq!(hub.sample().geometry, before);
|
||||
for _ in 0..50 {
|
||||
hub.advance(Duration::from_millis(16), false);
|
||||
}
|
||||
assert_eq!(hub.target_size(), [150., 34.]);
|
||||
assert!(!hub.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_outputs_and_reduced_motion_keep_the_hub_bounded() {
|
||||
let mut hub = Hub::new(Profile::default(), 30.).unwrap();
|
||||
hub.retarget(Mode::Expanded(Content::Calendar), [240., 180.]).unwrap();
|
||||
hub.advance(Duration::ZERO, true);
|
||||
assert_eq!(hub.sample().geometry.bounds, Rect::new(0., 0., 240., 180.));
|
||||
assert_eq!(hub.sample().opacity, 1.);
|
||||
assert!(!hub.active());
|
||||
assert!(hub.retarget(Mode::Idle, [f64::NAN, 180.]).is_err());
|
||||
assert_eq!(hub.mode(), Mode::Expanded(Content::Calendar));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ferese_animation::{AnimatedValue, CrossingPolicy, SpringConfig};
|
||||
use ferese_layout::Rect;
|
||||
|
||||
/// Unsnapped logical geometry. Pixel snapping and radius normalization belong
|
||||
/// to scene derivation, so neither changes the spring's trajectory.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Geometry {
|
||||
pub bounds: Rect,
|
||||
pub radii: [f64; 4],
|
||||
pub margin: [f64; 4],
|
||||
pub thickness: [f64; 4],
|
||||
pub gap: f64,
|
||||
pub fusion: f64,
|
||||
}
|
||||
|
||||
const COUNT: usize = 18;
|
||||
|
||||
impl Geometry {
|
||||
pub fn rounded(bounds: Rect, radius: f64) -> Self {
|
||||
Self {
|
||||
bounds,
|
||||
radii: [radius; 4],
|
||||
margin: [0.; 4],
|
||||
thickness: [0.; 4],
|
||||
gap: 0.,
|
||||
fusion: 0.,
|
||||
}
|
||||
}
|
||||
|
||||
fn values(self) -> [f64; COUNT] {
|
||||
let mut values = [0.; COUNT];
|
||||
values[..4].copy_from_slice(&[self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height]);
|
||||
values[4..8].copy_from_slice(&self.radii);
|
||||
values[8..12].copy_from_slice(&self.margin);
|
||||
values[12..16].copy_from_slice(&self.thickness);
|
||||
values[16] = self.gap;
|
||||
values[17] = self.fusion;
|
||||
values
|
||||
}
|
||||
|
||||
fn from_values(values: [f64; COUNT]) -> Self {
|
||||
Self {
|
||||
bounds: Rect::new(values[0], values[1], values[2], values[3]),
|
||||
radii: values[4..8].try_into().unwrap(),
|
||||
margin: values[8..12].try_into().unwrap(),
|
||||
thickness: values[12..16].try_into().unwrap(),
|
||||
gap: values[16],
|
||||
fusion: values[17],
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(self) -> Result<Self, &'static str> {
|
||||
let values = self.values();
|
||||
if values.iter().any(|value| !value.is_finite()) || values[2..].iter().any(|value| *value < 0.) {
|
||||
Err("panel animation geometry must be finite with nonnegative extents")
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Motion {
|
||||
values: [AnimatedValue; COUNT],
|
||||
spring: SpringConfig,
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
pub fn new(geometry: Geometry) -> Result<Self, &'static str> {
|
||||
Self::with_spring(
|
||||
geometry,
|
||||
SpringConfig {
|
||||
stiffness: 6400.,
|
||||
damping: 160.,
|
||||
position_tolerance: 0.05,
|
||||
velocity_tolerance: 0.1,
|
||||
crossing: CrossingPolicy::NoCrossing,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn with_spring(geometry: Geometry, spring: SpringConfig) -> Result<Self, &'static str> {
|
||||
Ok(Self {
|
||||
values: geometry.validate()?.values().map(AnimatedValue::new),
|
||||
spring,
|
||||
})
|
||||
}
|
||||
|
||||
/// Largest future dimensions, including the momentum carried through a retarget.
|
||||
pub(crate) fn maximum_size(&self) -> [f64; 2] {
|
||||
let omega2 = self.spring.stiffness / self.spring.mass;
|
||||
let decay = self.spring.damping / (2. * self.spring.mass);
|
||||
[self.values[2], self.values[3]].map(|value| {
|
||||
let displacement = value.current - value.target;
|
||||
let mut maximum = value.current.max(value.target);
|
||||
if omega2 > decay * decay {
|
||||
let frequency = (omega2 - decay * decay).sqrt();
|
||||
let sine = (value.velocity + decay * displacement) / frequency;
|
||||
let derivative_sine = -(decay * value.velocity + omega2 * displacement) / frequency;
|
||||
let first = (-value.velocity)
|
||||
.atan2(derivative_sine)
|
||||
.rem_euclid(std::f64::consts::PI);
|
||||
// Extrema alternate; later peaks decay, so the first two suffice.
|
||||
for phase in [first, first + std::f64::consts::PI] {
|
||||
let t = phase / frequency;
|
||||
let position =
|
||||
value.target + (-decay * t).exp() * (displacement * phase.cos() + sine * phase.sin());
|
||||
maximum = maximum.max(position);
|
||||
}
|
||||
} else {
|
||||
// Total spring energy bounds displacement for any nonnegative damping.
|
||||
maximum = maximum.max(
|
||||
value.target + (displacement * displacement + value.velocity * value.velocity / omega2).sqrt(),
|
||||
);
|
||||
}
|
||||
maximum
|
||||
})
|
||||
}
|
||||
|
||||
pub fn current(&self) -> Geometry {
|
||||
Geometry::from_values(self.values.map(|value| value.current))
|
||||
}
|
||||
|
||||
pub fn target(&self) -> Geometry {
|
||||
Geometry::from_values(self.values.map(|value| value.target))
|
||||
}
|
||||
|
||||
pub fn retarget(&mut self, geometry: Geometry) -> Result<(), &'static str> {
|
||||
let target = geometry.validate()?.values();
|
||||
for (value, target) in self.values.iter_mut().zip(target) {
|
||||
value.retarget_preserving_motion(target);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn active(&self) -> bool {
|
||||
self.values.iter().any(AnimatedValue::is_animating)
|
||||
}
|
||||
|
||||
/// Returns whether another frame is needed. Reduced motion also clears
|
||||
/// residual velocity, including when enabled halfway through an animation.
|
||||
pub fn advance(&mut self, elapsed: Duration, reduced_motion: bool) -> bool {
|
||||
let mut active = false;
|
||||
for value in &mut self.values {
|
||||
if reduced_motion {
|
||||
value.snap();
|
||||
} else {
|
||||
active |= value.advance(elapsed, self.spring);
|
||||
}
|
||||
}
|
||||
active
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn collapsed() -> Geometry {
|
||||
Geometry::rounded(Rect::new(0., 0., 150., 34.), 17.)
|
||||
}
|
||||
fn expanded() -> Geometry {
|
||||
Geometry::rounded(Rect::new(-95., 0., 340., 230.), 26.)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interruption_keeps_the_current_geometry_and_velocity() {
|
||||
let mut motion = Motion::new(collapsed()).unwrap();
|
||||
motion.retarget(expanded()).unwrap();
|
||||
motion.advance(Duration::from_millis(16), false);
|
||||
let before = motion.current();
|
||||
let velocity = motion.values.map(|value| value.velocity);
|
||||
motion.retarget(collapsed()).unwrap();
|
||||
assert_eq!(motion.current(), before);
|
||||
assert_eq!(motion.values.map(|value| value.velocity), velocity);
|
||||
for _ in 0..30 {
|
||||
motion.advance(Duration::from_millis(16), false);
|
||||
}
|
||||
assert_eq!(motion.current(), collapsed());
|
||||
assert!(!motion.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_share_one_trajectory_and_stop_requesting_frames() {
|
||||
let mut motion = Motion::new(collapsed()).unwrap();
|
||||
motion.retarget(expanded()).unwrap();
|
||||
for _ in 0..12 {
|
||||
motion.advance(Duration::from_millis(16), false);
|
||||
let geometry = motion.current();
|
||||
let width_progress = (geometry.bounds.width - 150.) / 190.;
|
||||
let height_progress = (geometry.bounds.height - 34.) / 196.;
|
||||
let radius_progress = (geometry.radii[0] - 17.) / 9.;
|
||||
// A dimension may snap at its final subpixel tolerance first.
|
||||
assert!((width_progress - height_progress).abs() < 0.0005);
|
||||
assert!((width_progress - radius_progress).abs() < 0.0005);
|
||||
}
|
||||
for _ in 0..12 {
|
||||
motion.advance(Duration::from_millis(16), false);
|
||||
}
|
||||
assert_eq!(motion.current(), expanded());
|
||||
assert!(!motion.advance(Duration::from_secs(1), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduced_motion_snaps_an_inflight_animation() {
|
||||
let mut motion = Motion::new(collapsed()).unwrap();
|
||||
motion.retarget(expanded()).unwrap();
|
||||
motion.advance(Duration::from_millis(16), false);
|
||||
assert!(!motion.advance(Duration::ZERO, true));
|
||||
assert_eq!(motion.current(), expanded());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underdamped_reversals_preserve_velocity_and_fit_the_predicted_extrema() {
|
||||
let mut motion = Motion::with_spring(
|
||||
collapsed(),
|
||||
SpringConfig {
|
||||
stiffness: 300.,
|
||||
damping: 2. * 0.7 * 300_f64.sqrt(),
|
||||
crossing: CrossingPolicy::AllowOvershoot,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for turn in 0..60 {
|
||||
let before = motion.current();
|
||||
let velocity = motion.values.map(|value| value.velocity);
|
||||
motion
|
||||
.retarget(if turn % 2 == 0 { expanded() } else { collapsed() })
|
||||
.unwrap();
|
||||
assert_eq!(motion.current(), before);
|
||||
assert_eq!(motion.values.map(|value| value.velocity), velocity);
|
||||
let bound = motion.maximum_size();
|
||||
let mut prediction = motion.clone();
|
||||
for _ in 0..200 {
|
||||
prediction.advance(Duration::from_millis(8), false);
|
||||
let geometry = prediction.current();
|
||||
assert!(geometry.bounds.width <= bound[0] + 1e-9);
|
||||
assert!(geometry.bounds.height <= bound[1] + 1e-9);
|
||||
}
|
||||
motion.advance(Duration::from_millis(16 + turn * 7), false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_retarget_leaves_motion_untouched() {
|
||||
let mut motion = Motion::new(collapsed()).unwrap();
|
||||
let mut invalid = expanded();
|
||||
invalid.bounds.height = f64::NAN;
|
||||
assert!(motion.retarget(invalid).is_err());
|
||||
assert_eq!(motion.target(), collapsed());
|
||||
assert!(!motion.active());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
use crate::scene::{Primitive, Scene, smooth_min};
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
|
||||
pub type Contour = Vec<[f64; 2]>;
|
||||
|
||||
fn arc(points: &mut Contour, center: [f64; 2], radius: f64, start: f64, tolerance: f64) {
|
||||
let segments = ((FRAC_PI_2 / (8. * tolerance / radius.max(tolerance)).sqrt()).ceil() as usize).clamp(1, 128);
|
||||
for i in 0..=segments {
|
||||
let angle = start + FRAC_PI_2 * i as f64 / segments as f64;
|
||||
points.push([center[0] + radius * angle.cos(), center[1] + radius * angle.sin()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn clockwise(points: &mut Contour, cutout: bool) {
|
||||
let area = points
|
||||
.iter()
|
||||
.zip(points.iter().cycle().skip(1))
|
||||
.take(points.len())
|
||||
.map(|(a, b)| a[0] * b[1] - b[0] * a[1])
|
||||
.sum::<f64>();
|
||||
if (area < 0.) != cutout {
|
||||
points.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
fn primitive_outline(primitive: Primitive, tolerance: f64) -> Contour {
|
||||
let b = primitive.bounds;
|
||||
let (l, t, r, d) = (b.x, b.y, b.x + b.width, b.y + b.height);
|
||||
let mut points = Vec::new();
|
||||
if primitive.operation >= 2 {
|
||||
let radius = b.width.min(b.height);
|
||||
let (center, start, remaining) = match primitive.operation {
|
||||
2 => ([l, d], -FRAC_PI_2, [[r, d], [r, t], [l, t]]),
|
||||
3 => ([r, d], PI, [[r, t], [l, t], [l, d]]),
|
||||
4 => ([r, t], FRAC_PI_2, [[l, t], [l, d], [r, d]]),
|
||||
_ => ([l, t], 0., [[l, d], [r, d], [r, t]]),
|
||||
};
|
||||
arc(&mut points, center, radius, start, tolerance);
|
||||
points.extend(remaining);
|
||||
} else {
|
||||
let [tl, tr, br, bl] = primitive.radii.0.map(f64::from);
|
||||
for (center, radius, start) in [
|
||||
([l + tl, t + tl], tl, PI),
|
||||
([r - tr, t + tr], tr, PI + FRAC_PI_2),
|
||||
([r - br, d - br], br, 0.),
|
||||
([l + bl, d - bl], bl, FRAC_PI_2),
|
||||
] {
|
||||
arc(&mut points, center, radius, start, tolerance);
|
||||
}
|
||||
}
|
||||
clockwise(&mut points, primitive.operation == 1);
|
||||
points
|
||||
}
|
||||
|
||||
/// Paths for vector backends. Additive contours share one nonzero fill, so
|
||||
/// touching groups and fillets have no separately anti-aliased seam.
|
||||
pub fn contours(scene: &Scene, tolerance: f64) -> Result<Vec<Contour>, &'static str> {
|
||||
if !tolerance.is_finite() || !(0.01..=1.).contains(&tolerance) {
|
||||
return Err("outline tolerance must be between 0.01 and 1 logical pixels");
|
||||
}
|
||||
let mut result: Vec<_> = scene
|
||||
.paint
|
||||
.iter()
|
||||
.filter(|p| scene.fusion == 0. || p.blend_zone == 0)
|
||||
.map(|p| primitive_outline(*p, tolerance))
|
||||
.collect();
|
||||
if scene.fusion > 0. {
|
||||
for zone in 1..=3 {
|
||||
let shapes: Vec<_> = scene.paint.iter().filter(|p| p.blend_zone == zone).copied().collect();
|
||||
if shapes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Fused rows share their vertical baseline. Each component is
|
||||
// vertically convex; solve its upper and lower boundary, rather
|
||||
// than sampling every pixel of a full-output bitmap.
|
||||
let top = shapes.iter().map(|p| p.bounds.y).fold(f64::INFINITY, f64::min);
|
||||
let bottom = shapes
|
||||
.iter()
|
||||
.map(|p| p.bounds.y + p.bounds.height)
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let mid = (top + bottom) * 0.5;
|
||||
let left = shapes.iter().map(|p| p.bounds.x).fold(f64::INFINITY, f64::min) - scene.fusion;
|
||||
let right = shapes
|
||||
.iter()
|
||||
.map(|p| p.bounds.x + p.bounds.width)
|
||||
.fold(f64::NEG_INFINITY, f64::max)
|
||||
+ scene.fusion;
|
||||
let distance = |x, y| {
|
||||
shapes
|
||||
.iter()
|
||||
.map(|p| p.distance([x, y]))
|
||||
.fold(f64::INFINITY, |a, b| smooth_min(a, b, scene.fusion))
|
||||
};
|
||||
let step = tolerance * 2.;
|
||||
let count = ((right - left) / step).ceil() as usize;
|
||||
if count > 1_000_000 {
|
||||
return Err("fused outline exceeds sampling capacity");
|
||||
}
|
||||
let mut upper = Vec::new();
|
||||
let mut lower = Vec::new();
|
||||
let finish = |upper: &mut Contour, lower: &mut Contour, result: &mut Vec<Contour>| {
|
||||
if !upper.is_empty() {
|
||||
upper.extend(lower.drain(..).rev());
|
||||
clockwise(upper, false);
|
||||
result.push(std::mem::take(upper));
|
||||
}
|
||||
};
|
||||
for i in 0..=count {
|
||||
let x = left + (right - left) * i as f64 / count.max(1) as f64;
|
||||
if distance(x, mid) >= 0. {
|
||||
finish(&mut upper, &mut lower, &mut result);
|
||||
continue;
|
||||
}
|
||||
let root = |outside: f64| {
|
||||
let mut a = outside;
|
||||
let mut b = mid;
|
||||
for _ in 0..16 {
|
||||
let y = (a + b) * 0.5;
|
||||
if distance(x, y) < 0. {
|
||||
b = y;
|
||||
} else {
|
||||
a = y;
|
||||
}
|
||||
}
|
||||
(a + b) * 0.5
|
||||
};
|
||||
upper.push([x, root(top - scene.fusion)]);
|
||||
lower.push([x, root(bottom + scene.fusion)]);
|
||||
}
|
||||
finish(&mut upper, &mut lower, &mut result);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Open border paths for screen-attached notches. Split shared straight edges
|
||||
/// before cancelling them, so the body and its fillets have one continuous rim.
|
||||
pub fn notch_border(scene: &Scene, tolerance: f64, edge: crate::Edge) -> Result<Vec<Contour>, &'static str> {
|
||||
let contours = contours(scene, tolerance)?;
|
||||
let vertices: Vec<_> = contours.iter().flatten().copied().collect();
|
||||
let screen_edge = vertices.iter().map(|p| p[1]).fold(
|
||||
if edge == crate::Edge::Top {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
f64::NEG_INFINITY
|
||||
},
|
||||
if edge == crate::Edge::Top { f64::min } else { f64::max },
|
||||
);
|
||||
let close = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
|
||||
let mut segments: Vec<([f64; 2], [f64; 2])> = Vec::new();
|
||||
for contour in &contours {
|
||||
for (&a, &b) in contour.iter().zip(contour.iter().cycle().skip(1)).take(contour.len()) {
|
||||
if close(a, b) || ((a[1] - screen_edge).abs() < 1e-6 && (b[1] - screen_edge).abs() < 1e-6) {
|
||||
continue;
|
||||
}
|
||||
let delta = [b[0] - a[0], b[1] - a[1]];
|
||||
let length_squared = delta[0] * delta[0] + delta[1] * delta[1];
|
||||
let mut cuts = vec![(0., a), (1., b)];
|
||||
for &point in &vertices {
|
||||
let offset = [point[0] - a[0], point[1] - a[1]];
|
||||
let t = (offset[0] * delta[0] + offset[1] * delta[1]) / length_squared;
|
||||
if t > 0. && t < 1. && (offset[0] * delta[1] - offset[1] * delta[0]).abs() < 1e-6 {
|
||||
cuts.push((t, point));
|
||||
}
|
||||
}
|
||||
cuts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
cuts.dedup_by(|a, b| close(a.1, b.1));
|
||||
for pair in cuts.windows(2) {
|
||||
let segment = (pair[0].1, pair[1].1);
|
||||
if let Some(index) = segments
|
||||
.iter()
|
||||
.position(|&(a, b)| close(a, segment.1) && close(b, segment.0))
|
||||
{
|
||||
segments.swap_remove(index);
|
||||
} else {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
while let Some((a, b)) = segments.pop() {
|
||||
let mut path = vec![a, b];
|
||||
while let Some(index) = segments.iter().position(|&(a, _)| close(a, *path.last().unwrap())) {
|
||||
path.push(segments.swap_remove(index).1);
|
||||
}
|
||||
while let Some(index) = segments.iter().position(|&(_, b)| close(b, path[0])) {
|
||||
path.insert(0, segments.swap_remove(index).0);
|
||||
}
|
||||
paths.push(path);
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scene::{GroupBounds, Parameters, derive};
|
||||
use crate::shape::{PixelGrid, Style, ZonePosition};
|
||||
use ferese_layout::Rect;
|
||||
|
||||
fn winding(contours: &[Contour], point: [f64; 2]) -> i32 {
|
||||
let mut winding = 0;
|
||||
for points in contours {
|
||||
for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) {
|
||||
let cross = (b[0] - a[0]) * (point[1] - a[1]) - (point[0] - a[0]) * (b[1] - a[1]);
|
||||
if a[1] <= point[1] && b[1] > point[1] && cross > 0. {
|
||||
winding += 1;
|
||||
}
|
||||
if a[1] > point[1] && b[1] <= point[1] && cross < 0. {
|
||||
winding -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
winding
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notch_rim_connects_both_fillets_without_shared_or_screen_edges() {
|
||||
for scale in [1., 1.25, 1.5, 2.] {
|
||||
for edge in [crate::Edge::Top, crate::Edge::Bottom] {
|
||||
let scene = derive(
|
||||
Parameters {
|
||||
style: Style::Notch,
|
||||
edge,
|
||||
height: 44.,
|
||||
fillet: 12.,
|
||||
..Default::default()
|
||||
},
|
||||
Rect::new(0., 0., 340., 44.),
|
||||
&[GroupBounds {
|
||||
zone: ZonePosition::Center,
|
||||
bounds: Rect::new(70.3, 0., 160.4, 44.),
|
||||
}],
|
||||
PixelGrid::new(scale).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let paths = notch_border(&scene, 0.1 / scale, edge).unwrap();
|
||||
assert_eq!(paths.len(), 1, "{edge:?} at {scale}");
|
||||
let path = &paths[0];
|
||||
let screen_y = if edge == crate::Edge::Top { 0. } else { 44. };
|
||||
assert!((path.first().unwrap()[1] - screen_y).abs() < 1e-6);
|
||||
assert!((path.last().unwrap()[1] - screen_y).abs() < 1e-6);
|
||||
let body = scene.paint.iter().find(|p| p.operation == 0).unwrap().bounds;
|
||||
assert!(path.iter().any(|p| p[0] < body.x));
|
||||
assert!(path.iter().any(|p| p[0] > body.x + body.width));
|
||||
for segment in path.windows(2) {
|
||||
let [a, b] = [segment[0], segment[1]];
|
||||
assert!((a[1] - screen_y).abs() > 1e-6 || (b[1] - screen_y).abs() > 1e-6);
|
||||
let mid = [(a[0] + b[0]) / 2., (a[1] + b[1]) / 2.];
|
||||
assert!(scene.distance(mid).abs() < 0.12 / scale, "off outline: {mid:?}");
|
||||
// A shared body/fillet seam has fill on both sides, unlike a visible rim.
|
||||
let delta = [b[0] - a[0], b[1] - a[1]];
|
||||
let length = delta[0].hypot(delta[1]);
|
||||
let normal = [-delta[1] / length * 0.25, delta[0] / length * 0.25];
|
||||
assert!(
|
||||
scene.distance([mid[0] + normal[0], mid[1] + normal[1]]) > 0.
|
||||
|| scene.distance([mid[0] - normal[0], mid[1] - normal[1]]) > 0.
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_morph_rim_follows_animated_body_at_fractional_scales() {
|
||||
for scale in [1., 1.25, 1.5, 2.] {
|
||||
for edge in [crate::Edge::Top, crate::Edge::Bottom] {
|
||||
for (width, height, radius) in [(150., 34., 17.), (218.3, 41.7, 20.85), (340., 230., 26.)] {
|
||||
let scene = derive(
|
||||
Parameters {
|
||||
style: Style::Morph,
|
||||
attached: true,
|
||||
edge,
|
||||
fillet: 12.,
|
||||
radius: Some(crate::shape::AnchoredRadii([radius; 4])),
|
||||
..Default::default()
|
||||
},
|
||||
Rect::new(30.3, 0., width, height),
|
||||
&[],
|
||||
PixelGrid::new(scale).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let body = scene.paint[0];
|
||||
assert_eq!(scene.paint.len(), 3);
|
||||
assert_eq!(scene.input.len(), 1, "decorative shoulders must not take pointer input");
|
||||
let screen_y = if edge == crate::Edge::Top {
|
||||
body.bounds.y
|
||||
} else {
|
||||
body.bounds.y + body.bounds.height
|
||||
};
|
||||
let paths = notch_border(&scene, 0.1 / scale, edge).unwrap();
|
||||
assert_eq!(
|
||||
paths.len(),
|
||||
1,
|
||||
"one continuous rim: {scale}, {edge:?}, {width}, {height}"
|
||||
);
|
||||
let path = &paths[0];
|
||||
assert!((path.first().unwrap()[1] - screen_y).abs() < 1e-6);
|
||||
assert!((path.last().unwrap()[1] - screen_y).abs() < 1e-6);
|
||||
assert!(path.iter().any(|p| p[0] < body.bounds.x));
|
||||
assert!(path.iter().any(|p| p[0] > body.bounds.x + body.bounds.width));
|
||||
for segment in path.windows(2) {
|
||||
let [a, b] = [segment[0], segment[1]];
|
||||
assert!(
|
||||
(a[1] - screen_y).abs() > 1e-6 || (b[1] - screen_y).abs() > 1e-6,
|
||||
"no stroke on the screen edge"
|
||||
);
|
||||
let mid = [(a[0] + b[0]) / 2., (a[1] + b[1]) / 2.];
|
||||
assert!(scene.distance(mid).abs() < 0.12 / scale);
|
||||
}
|
||||
let contours = contours(&scene, 0.1 / scale).unwrap();
|
||||
for x in 0..((width + 60.) as usize) {
|
||||
for y in 0..(height as usize) {
|
||||
let p = [x as f64 + 0.37, y as f64 + 0.37];
|
||||
if scene.distance(p).abs() > 0.15 / scale {
|
||||
assert_eq!(
|
||||
winding(&contours, p) != 0,
|
||||
scene.distance(p) < 0.,
|
||||
"native and fallback must agree"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_union_and_cutout_match_implicit_shapes() {
|
||||
let groups = [
|
||||
GroupBounds {
|
||||
zone: ZonePosition::Start,
|
||||
bounds: Rect::new(4., 0., 20., 16.),
|
||||
},
|
||||
GroupBounds {
|
||||
zone: ZonePosition::Start,
|
||||
bounds: Rect::new(28., 0., 20., 16.),
|
||||
},
|
||||
GroupBounds {
|
||||
zone: ZonePosition::Center,
|
||||
bounds: Rect::new(52., 0., 20., 16.),
|
||||
},
|
||||
];
|
||||
for style in [
|
||||
Style::Floating,
|
||||
Style::Capsules,
|
||||
Style::Notch,
|
||||
Style::Flat,
|
||||
Style::Frame,
|
||||
Style::CornerWrap,
|
||||
Style::Fused,
|
||||
] {
|
||||
let scene = derive(
|
||||
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()
|
||||
},
|
||||
Rect::new(0., 0., 96., 64.),
|
||||
&groups,
|
||||
PixelGrid::new(1.25).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let contours = contours(&scene, 0.1).unwrap();
|
||||
for y in 0..64 {
|
||||
for x in 0..96 {
|
||||
let point = [x as f64 + 0.5, y as f64 + 0.5];
|
||||
let distance = scene.distance(point);
|
||||
if distance.abs() > 0.3 {
|
||||
assert_eq!(
|
||||
winding(&contours, point) != 0,
|
||||
distance < 0.,
|
||||
"{style:?} at {point:?}, {distance}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Panel composition, independent of widgets, services, and Wayland surfaces.
|
||||
pub(crate) mod geometry;
|
||||
pub mod layout;
|
||||
pub use geometry::PanelGeometry;
|
||||
pub use geometry::{GeometryIntent, PanelGeometry};
|
||||
mod radius;
|
||||
use crate::PanelPreset;
|
||||
use crate::PanelStyle;
|
||||
pub use radius::CornerRadii;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
@@ -349,8 +348,6 @@ pub struct Group {
|
||||
pub id: GroupId,
|
||||
#[serde(default)]
|
||||
pub items: Vec<Item>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub surface: Option<GroupSurface>,
|
||||
#[serde(default = "spacing")]
|
||||
pub spacing: f32,
|
||||
#[serde(default = "padding")]
|
||||
@@ -364,7 +361,6 @@ impl Group {
|
||||
Self {
|
||||
id: GroupId(id.into()),
|
||||
items,
|
||||
surface: None,
|
||||
spacing: 1.0,
|
||||
padding,
|
||||
island_padding,
|
||||
@@ -386,11 +382,11 @@ pub struct Panel {
|
||||
#[serde(default)]
|
||||
pub edge: Edge,
|
||||
#[serde(default)]
|
||||
pub geometry: PanelGeometry,
|
||||
pub geometry: GeometryIntent,
|
||||
#[serde(default)]
|
||||
pub surface: PanelSurface,
|
||||
#[serde(default)]
|
||||
pub group_surface: GroupSurface,
|
||||
pub style: PanelStyle,
|
||||
#[serde(default, skip_serializing_if = "default_morph")]
|
||||
pub morph: crate::morph::Profile,
|
||||
#[serde(default)]
|
||||
pub border: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -414,6 +410,10 @@ impl Default for Zone {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_morph(profile: &crate::morph::Profile) -> bool {
|
||||
*profile == crate::morph::Profile::default()
|
||||
}
|
||||
|
||||
pub fn validate(panels: &[Panel]) -> Result<(), String> {
|
||||
if panels.len() != 1 {
|
||||
return Err("configure exactly one panel; multiple panels are not supported yet".into());
|
||||
@@ -428,7 +428,11 @@ pub fn validate(panels: &[Panel]) -> Result<(), String> {
|
||||
};
|
||||
let mut panel_ids = HashSet::new();
|
||||
for panel in panels {
|
||||
if !PanelStyle::AVAILABLE.contains(&panel.style) {
|
||||
return Err(format!("panel style {} is not available yet", panel.style.key()));
|
||||
}
|
||||
panel.geometry.validate()?;
|
||||
panel.morph.validate()?;
|
||||
if panel.group_count() > MAX_GROUPS {
|
||||
return Err(format!(
|
||||
"a panel supports at most {MAX_GROUPS} groups, reserving one region for overflow"
|
||||
@@ -437,7 +441,7 @@ pub fn validate(panels: &[Panel]) -> Result<(), String> {
|
||||
if let Some(opacity) = panel.background_opacity
|
||||
&& (!opacity.is_finite() || !(0.0..=1.0).contains(&opacity))
|
||||
{
|
||||
return Err("panel group background opacity must be between 0 and 1".into());
|
||||
return Err("panel background opacity must be between 0 and 1".into());
|
||||
}
|
||||
if let Some(radius) = panel.corner_radius {
|
||||
radius.validate()?;
|
||||
@@ -486,40 +490,69 @@ pub fn validate(panels: &[Panel]) -> Result<(), String> {
|
||||
|
||||
impl Default for Panel {
|
||||
fn default() -> Self {
|
||||
Self::from_preset(PanelPreset::Continuous)
|
||||
Self::from_preset(PanelStyle::Flat)
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub fn resolved_surface(&self, group: &Group) -> GroupSurface {
|
||||
group.surface.unwrap_or(self.group_surface)
|
||||
pub fn preset(&self) -> Option<PanelStyle> {
|
||||
Some(self.style)
|
||||
}
|
||||
|
||||
pub fn preset(&self) -> Option<PanelPreset> {
|
||||
if [&self.start, &self.center, &self.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.any(|group| group.surface.is_some_and(|surface| surface != self.group_surface))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match (self.surface, self.group_surface) {
|
||||
(PanelSurface::Solid, GroupSurface::None) => Some(PanelPreset::Continuous),
|
||||
(PanelSurface::None, GroupSurface::Island) => Some(PanelPreset::Islands),
|
||||
_ => None,
|
||||
pub fn apply_preset(&mut self, style: PanelStyle) {
|
||||
self.style = style;
|
||||
}
|
||||
|
||||
pub fn surface(&self) -> PanelSurface {
|
||||
if matches!(self.style, PanelStyle::Flat | PanelStyle::Frame) {
|
||||
PanelSurface::Solid
|
||||
} else {
|
||||
PanelSurface::None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_preset(&mut self, preset: PanelPreset) {
|
||||
(self.surface, self.group_surface) = match preset {
|
||||
PanelPreset::Continuous => (PanelSurface::Solid, GroupSurface::None),
|
||||
PanelPreset::Islands => (PanelSurface::None, GroupSurface::Island),
|
||||
};
|
||||
for group in [&mut self.start, &mut self.center, &mut self.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &mut zone.groups)
|
||||
{
|
||||
group.surface = None;
|
||||
pub fn group_surface(&self) -> GroupSurface {
|
||||
if self.surface() == PanelSurface::Solid {
|
||||
GroupSurface::None
|
||||
} else {
|
||||
GroupSurface::Island
|
||||
}
|
||||
}
|
||||
|
||||
pub fn zone_spacing(&self, zone: &Zone) -> f32 {
|
||||
if self.style.connected() { 0. } else { zone.spacing }
|
||||
}
|
||||
|
||||
pub fn shape_parameters(&self) -> crate::scene::Parameters {
|
||||
crate::scene::Parameters {
|
||||
style: self.style,
|
||||
attached: self.style == PanelStyle::Morph && self.morph.attached,
|
||||
fillet: if self.style == PanelStyle::Morph {
|
||||
12.
|
||||
} else {
|
||||
crate::scene::Parameters::default().fillet
|
||||
},
|
||||
edge: self.edge,
|
||||
height: self.resolved_geometry().height.into(),
|
||||
radius: self.corner_radius.map(|r| crate::shape::AnchoredRadii(r.0)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolved_geometry(&self) -> PanelGeometry {
|
||||
let mut geometry = self.geometry.resolve(self.style);
|
||||
if self.style == PanelStyle::Morph && self.morph.attached {
|
||||
geometry.edge_margin = 0;
|
||||
}
|
||||
geometry
|
||||
}
|
||||
|
||||
pub fn exclusive_zone(&self, hidden: bool) -> i32 {
|
||||
if hidden {
|
||||
0
|
||||
} else {
|
||||
let geometry = self.resolved_geometry();
|
||||
(geometry.height.round() as i32).saturating_add(geometry.window_clearance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,13 +574,14 @@ impl Panel {
|
||||
&& left.spacing == right.spacing
|
||||
&& left.padding == right.padding
|
||||
&& left.island_padding == right.island_padding
|
||||
&& (self.resolved_surface(left) == GroupSurface::Island)
|
||||
== (other.resolved_surface(right) == GroupSurface::Island)
|
||||
&& (self.group_surface() == GroupSurface::Island)
|
||||
== (other.group_surface() == GroupSurface::Island)
|
||||
})
|
||||
};
|
||||
self.id == other.id
|
||||
&& self.edge == other.edge
|
||||
&& self.surface == other.surface
|
||||
&& self.style == other.style
|
||||
&& self.morph == other.morph
|
||||
&& same_zone(&self.start, &other.start)
|
||||
&& same_zone(&self.center, &other.center)
|
||||
&& same_zone(&self.end, &other.end)
|
||||
@@ -571,7 +605,6 @@ impl Panel {
|
||||
overflow: OverflowPolicy::Never,
|
||||
priority: 100,
|
||||
}],
|
||||
surface: None,
|
||||
spacing: 0.0,
|
||||
padding: [2, 3],
|
||||
island_padding: self
|
||||
@@ -591,7 +624,7 @@ impl Panel {
|
||||
}
|
||||
|
||||
/// Build a stable initial composition; presets are an editor convenience.
|
||||
pub fn from_preset(preset: PanelPreset) -> Self {
|
||||
pub fn from_preset(preset: PanelStyle) -> Self {
|
||||
use ItemKind::*;
|
||||
|
||||
let item = Item::new;
|
||||
@@ -617,7 +650,7 @@ impl Panel {
|
||||
let display = item("display-mode", DisplayMode);
|
||||
|
||||
let (start, center, end) = match preset {
|
||||
PanelPreset::Continuous => (
|
||||
PanelStyle::Flat => (
|
||||
vec![
|
||||
Group::new("overview", vec![overview], [0, 0], padding),
|
||||
Group::new("workspaces", vec![workspaces], [2, 3], padding),
|
||||
@@ -629,7 +662,7 @@ impl Panel {
|
||||
Group::new("display", vec![display], [0, 0], padding),
|
||||
],
|
||||
),
|
||||
PanelPreset::Islands => {
|
||||
_ => {
|
||||
let mut controls = controls;
|
||||
controls.extend([clock, display]);
|
||||
(
|
||||
@@ -643,23 +676,15 @@ impl Panel {
|
||||
Self {
|
||||
id: PanelId("main".into()),
|
||||
edge: Edge::Top,
|
||||
geometry: PanelGeometry::default(),
|
||||
surface: if preset == PanelPreset::Continuous {
|
||||
PanelSurface::Solid
|
||||
} else {
|
||||
PanelSurface::None
|
||||
},
|
||||
group_surface: if preset == PanelPreset::Continuous {
|
||||
GroupSurface::None
|
||||
} else {
|
||||
GroupSurface::Island
|
||||
},
|
||||
geometry: GeometryIntent::default(),
|
||||
style: preset,
|
||||
morph: Default::default(),
|
||||
border: false,
|
||||
background_opacity: Option::None,
|
||||
corner_radius: Option::None,
|
||||
start: Zone {
|
||||
groups: start,
|
||||
spacing: if preset == PanelPreset::Islands { 8.0 } else { 3.0 },
|
||||
spacing: if preset == PanelStyle::Floating { 8.0 } else { 3.0 },
|
||||
},
|
||||
center: Zone {
|
||||
groups: vec![center],
|
||||
@@ -677,9 +702,27 @@ impl Panel {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fullscreen_releases_the_entire_reservation_without_discarding_pins() {
|
||||
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||
panel.geometry.height = Some(36.);
|
||||
panel.geometry.window_clearance = Some(4);
|
||||
assert_eq!(panel.exclusive_zone(false), 40);
|
||||
assert_eq!(panel.exclusive_zone(true), 0);
|
||||
assert_eq!(panel.geometry.window_clearance, Some(4));
|
||||
}
|
||||
|
||||
fn items(panel: &Panel) -> Vec<&Item> {
|
||||
[&panel.start, &panel.center, &panel.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_capacity_reserves_overflow_even_for_empty_groups() {
|
||||
for background in [PanelPreset::Continuous, PanelPreset::Islands] {
|
||||
for background in [PanelStyle::Flat, PanelStyle::Floating] {
|
||||
let mut panel = Panel::from_preset(background);
|
||||
panel.start.groups.clear();
|
||||
panel.center.groups.clear();
|
||||
@@ -696,46 +739,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_surfaces_round_trip_and_overrides_are_preserved() {
|
||||
let source = "// keep\npanel main { surface none; group-surface island; start { group nav { item overview kind=overview; }; }; center { group title { surface none; item title kind=focused-window; }; }; end { group media { surface inset; item media kind=media; }; }; }";
|
||||
let mut doc = crate::Document::parse(source).unwrap();
|
||||
let panel: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
let panel = &panel[0];
|
||||
assert_eq!(panel.resolved_surface(&panel.start.groups[0]), GroupSurface::Island);
|
||||
assert_eq!(panel.resolved_surface(&panel.center.groups[0]), GroupSurface::None);
|
||||
assert_eq!(panel.resolved_surface(&panel.end.groups[0]), GroupSurface::Inset);
|
||||
assert_eq!(panel.preset(), None);
|
||||
doc.set("panels", serde_json::to_value(vec![panel]).unwrap()).unwrap();
|
||||
let restored = crate::Document::parse(&doc.to_string()).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(restored.get("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(&panels[0], panel);
|
||||
assert!(doc.to_string().contains("// keep"));
|
||||
let mut preset = panel.clone();
|
||||
let ids: Vec<_> = items(panel).iter().map(|item| item.id.clone()).collect();
|
||||
preset.apply_preset(PanelPreset::Continuous);
|
||||
assert_eq!(preset.preset(), Some(PanelPreset::Continuous));
|
||||
assert_eq!(
|
||||
items(&preset).iter().map(|item| item.id.clone()).collect::<Vec<_>>(),
|
||||
ids
|
||||
);
|
||||
assert!(preset.center.groups[0].surface.is_none());
|
||||
preset.apply_preset(PanelPreset::Islands);
|
||||
assert_eq!(preset.preset(), Some(PanelPreset::Islands));
|
||||
}
|
||||
|
||||
fn items(panel: &Panel) -> Vec<&Item> {
|
||||
[&panel.start, &panel.center, &panel.end]
|
||||
.into_iter()
|
||||
.flat_map(|zone| &zone.groups)
|
||||
.flat_map(|group| &group.items)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_preserve_order_and_ids_across_background_changes() {
|
||||
let continuous = Panel::default();
|
||||
let islands = Panel::from_preset(PanelPreset::Islands);
|
||||
let islands = Panel::from_preset(PanelStyle::Floating);
|
||||
let expected = [
|
||||
"overview",
|
||||
"workspaces",
|
||||
@@ -799,7 +806,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn settings_map_to_instances_and_group_presentation() {
|
||||
let mut panel = Panel::from_preset(PanelPreset::Islands);
|
||||
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||
panel.center.groups[0].items[0].visible = false;
|
||||
let battery = panel.end.groups[0]
|
||||
.items
|
||||
@@ -835,162 +842,6 @@ mod tests {
|
||||
assert_eq!(first.kind, second.kind);
|
||||
assert_ne!(first.id, second.id);
|
||||
}
|
||||
#[test]
|
||||
fn composition_round_trips_kdl_and_edits_keep_ids_order_and_comments() {
|
||||
let source = r#"// panel comment
|
||||
panel "main" {
|
||||
surface "none"
|
||||
group-surface "island"
|
||||
end {
|
||||
group "status" {
|
||||
surface "island"
|
||||
padding 2 3
|
||||
// network comment
|
||||
item "network" kind="network" overflow="always"
|
||||
item "local-clock" kind="clock"
|
||||
item "second-clock" kind="clock" representation="compact"
|
||||
}
|
||||
}
|
||||
}
|
||||
animations { speed 0.8; }
|
||||
"#;
|
||||
let mut doc = crate::Document::parse(source).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
validate(&panels).unwrap();
|
||||
assert_eq!(panels[0].end.groups[0].padding, [2, 3]);
|
||||
assert!(panels[0].background_opacity.is_none());
|
||||
assert_eq!(panels[0].end.groups[0].items[0].overflow, OverflowPolicy::Always);
|
||||
doc.set("panels.0.end.groups.0.items.0.overflow", "never".into())
|
||||
.unwrap();
|
||||
let path = "panels.0.end.groups.0.items";
|
||||
let mut items = doc.get(path).unwrap().as_array().unwrap().clone();
|
||||
items.swap(0, 2);
|
||||
doc.set(path, items.into()).unwrap();
|
||||
let formatted = doc.to_string();
|
||||
assert!(formatted.contains("// panel comment"));
|
||||
assert!(formatted.contains("// network comment"));
|
||||
let parsed = crate::Document::parse(&formatted).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(parsed.get("panels").unwrap().clone()).unwrap();
|
||||
validate(&panels).unwrap();
|
||||
assert_eq!(
|
||||
panels[0].end.groups[0]
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.id.0.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["second-clock", "local-clock", "network"]
|
||||
);
|
||||
assert_eq!(
|
||||
panels[0].item(&ItemId("network".into())).unwrap().overflow,
|
||||
OverflowPolicy::Never
|
||||
);
|
||||
assert_eq!(parsed.get("animations.speed").unwrap(), &serde_json::json!(0.8));
|
||||
let value = serde_json::to_value(&panels).unwrap();
|
||||
doc.set("panels", value.clone()).unwrap();
|
||||
let round_trip = crate::Document::parse(&doc.to_string()).unwrap();
|
||||
let restored: Vec<Panel> = serde_json::from_value(round_trip.get("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(restored, panels);
|
||||
doc.set(path, Vec::<serde_json::Value>::new().into()).unwrap();
|
||||
assert!(doc.to_string().contains("// network comment"));
|
||||
let restored: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
assert!(restored[0].end.groups[0].items.is_empty());
|
||||
assert_eq!(restored[0].end.spacing, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_border_defaults_off_and_round_trips_an_explicit_enabled_value() {
|
||||
let mut doc =
|
||||
crate::Document::parse("panel main { end { group status { item clock kind=clock; }; }; }\n").unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
assert!(!panels[0].border);
|
||||
doc.set("panels.0.border", true.into()).unwrap();
|
||||
let doc = crate::Document::parse(&doc.to_string()).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
assert!(panels[0].border);
|
||||
assert_eq!(panels[0].end.groups[0].items[0].id.0, "clock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_background_opacity_inherits_until_overridden_and_validates_bounds() {
|
||||
let doc = crate::Document::parse("panel main { end { group status { surface inset; }; }; }").unwrap();
|
||||
let mut panels: Vec<Panel> = serde_json::from_value(doc.get("panels").unwrap().clone()).unwrap();
|
||||
assert_eq!(panels[0].background_opacity, None);
|
||||
assert!(
|
||||
serde_json::to_value(&panels[0]).unwrap()["end"]["groups"][0]
|
||||
.get("background_opacity")
|
||||
.is_none()
|
||||
);
|
||||
for value in [0., 0.5, 1.] {
|
||||
panels[0].background_opacity = Some(value);
|
||||
validate(&panels).unwrap();
|
||||
}
|
||||
for value in [-0.1, 1.1, f32::NAN, f32::INFINITY] {
|
||||
panels[0].background_opacity = Some(value);
|
||||
assert!(validate(&panels).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_edge_defaults_to_top_and_round_trips_both_positions() {
|
||||
for (source, expected) in [
|
||||
("", Edge::Top),
|
||||
("edge top;", Edge::Top),
|
||||
("edge bottom;", Edge::Bottom),
|
||||
] {
|
||||
let document = crate::Document::parse(&format!("panel main {{ {source} }}")).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(document.get("panels").unwrap().clone()).unwrap();
|
||||
validate(&panels).unwrap();
|
||||
assert_eq!(panels[0].edge, expected);
|
||||
let restored: Vec<Panel> = serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap();
|
||||
assert_eq!(restored[0].edge, expected);
|
||||
let mut moved = panels[0].clone();
|
||||
moved.edge = if expected == Edge::Top { Edge::Bottom } else { Edge::Top };
|
||||
assert!(!moved.same_composition(&panels[0]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_duplicate_ids_and_unsupported_surface_configuration() {
|
||||
let mut panel = Panel::default();
|
||||
validate(std::slice::from_ref(&panel)).unwrap();
|
||||
panel.end.groups[0].items[1].id = panel.start.groups[0].items[0].id.clone();
|
||||
assert!(validate(std::slice::from_ref(&panel)).is_err());
|
||||
panel.end.groups[0].items[1].id = ItemId("_overflow".into());
|
||||
assert!(validate(std::slice::from_ref(&panel)).is_err());
|
||||
assert!(validate(&[]).is_err());
|
||||
assert!(validate(&[panel.clone(), panel]).is_err());
|
||||
assert!(crate::from_str::<serde_json::Value>("panel \"main\" { edge \"bottom\"; }").is_ok());
|
||||
let doc = crate::Document::parse("panel \"main\" { edge \"left\"; }").unwrap();
|
||||
assert!(serde_json::from_value::<Vec<Panel>>(doc.get("panels").unwrap().clone()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn workspace_styles_round_trip_and_only_apply_to_workspaces() {
|
||||
for style in WorkspaceStyle::ALL {
|
||||
let source = format!(
|
||||
"panel main {{ start {{ group nav {{ item spaces kind=\"workspaces\" style=\"{}\"; }} }} }}",
|
||||
style.key()
|
||||
);
|
||||
let document = crate::Document::parse(&source).unwrap();
|
||||
let panels: Vec<Panel> = serde_json::from_value(document.get("panels").unwrap().clone()).unwrap();
|
||||
validate(&panels).unwrap();
|
||||
assert_eq!(panels[0].start.groups[0].items[0].kind, ItemKind::Workspaces { style });
|
||||
let mut wrong_kind = serde_json::to_value(&panels[0].start.groups[0].items[0]).unwrap();
|
||||
wrong_kind["kind"] = "clock".into();
|
||||
assert!(serde_json::from_value::<Item>(wrong_kind).is_err());
|
||||
let restored: Vec<Panel> = serde_json::from_value(serde_json::to_value(&panels).unwrap()).unwrap();
|
||||
assert_eq!(restored, panels);
|
||||
}
|
||||
let mut item = serde_json::json!({"id": "spaces", "kind": "workspaces"});
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Item>(item.clone()).unwrap().kind,
|
||||
ItemKind::Workspaces {
|
||||
style: WorkspaceStyle::Dots
|
||||
}
|
||||
);
|
||||
item["style"] = "unsupported".into();
|
||||
assert!(serde_json::from_value::<Item>(item).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_item_fields_are_rejected() {
|
||||
for value in [
|
||||
@@ -1001,38 +852,15 @@ animations { speed 0.8; }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_options_are_strict_and_metadata_is_explicit() {
|
||||
for key in ["ovreflow", "visibile", "custom_note"] {
|
||||
let mut value = serde_json::json!({"id":"network", "kind":"network"});
|
||||
value[key] = "always".into();
|
||||
let error = serde_json::from_value::<Item>(value).unwrap_err().to_string();
|
||||
assert!(error.contains(key) && error.contains("unknown field"), "{error}");
|
||||
}
|
||||
let source = "panel main { start { group nav { item network kind=\"network\" { metadata { custom-note \"keep\"; overflow \"authored note\"; }; }; }; }; }";
|
||||
let panel: Vec<Panel> =
|
||||
serde_json::from_value(crate::Document::parse(source).unwrap().get("panels").unwrap().clone()).unwrap();
|
||||
let item = &panel[0].start.groups[0].items[0];
|
||||
assert_eq!(item.metadata["custom_note"], "keep");
|
||||
assert_eq!(item.overflow, OverflowPolicy::Auto);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Item>(serde_json::to_value(item).unwrap()).unwrap(),
|
||||
*item
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<Item>(serde_json::json!({"id":"network", "kind":"network", "metadata":"wrong"}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn surface_changes_only_replace_composition_when_item_geometry_changes() {
|
||||
let panel = Panel::default();
|
||||
let mut changed = panel.clone();
|
||||
changed.group_surface = GroupSurface::Inset;
|
||||
changed.style = PanelStyle::Flat;
|
||||
assert!(panel.same_composition(&changed));
|
||||
changed.end.groups[0].surface = Some(GroupSurface::Island);
|
||||
changed.style = PanelStyle::Floating;
|
||||
assert!(!panel.same_composition(&changed));
|
||||
changed.end.groups[0].surface = Some(GroupSurface::None);
|
||||
changed.style = PanelStyle::Flat;
|
||||
assert!(panel.same_composition(&changed));
|
||||
changed.end.groups[0].padding[1] += 1;
|
||||
assert!(!panel.same_composition(&changed));
|
||||
@@ -0,0 +1,138 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct GeometryIntent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub height: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub edge_margin: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub side_margins: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub window_clearance: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inner_padding: Option<f32>,
|
||||
}
|
||||
|
||||
impl GeometryIntent {
|
||||
pub fn resolve(self, style: crate::PanelStyle) -> PanelGeometry {
|
||||
use crate::PanelStyle::*;
|
||||
let flush = matches!(style, Notch | Flat | Frame | CornerWrap);
|
||||
PanelGeometry {
|
||||
height: self.height.unwrap_or(match style {
|
||||
Frame => 40.,
|
||||
Morph => 34.,
|
||||
_ => 36.,
|
||||
}),
|
||||
edge_margin: if flush { 0 } else { self.edge_margin.unwrap_or(8) },
|
||||
side_margins: if flush { 0 } else { self.side_margins.unwrap_or(8) },
|
||||
window_clearance: self.window_clearance.unwrap_or(0),
|
||||
inner_padding: self.inner_padding.unwrap_or(12.),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(self) -> Result<(), String> {
|
||||
// Validate pins even when a flush style temporarily overrides margins.
|
||||
let mut geometry = self.resolve(crate::PanelStyle::Floating);
|
||||
geometry.edge_margin = self.edge_margin.unwrap_or(0);
|
||||
geometry.side_margins = self.side_margins.unwrap_or(0);
|
||||
geometry.validate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical dimensions owned by a panel, independent of its color theme or edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PanelGeometry {
|
||||
pub height: f32,
|
||||
pub edge_margin: i32,
|
||||
pub side_margins: i32,
|
||||
pub window_clearance: i32,
|
||||
pub inner_padding: f32,
|
||||
}
|
||||
|
||||
impl Default for PanelGeometry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
height: 36.,
|
||||
edge_margin: 0,
|
||||
side_margins: 0,
|
||||
window_clearance: 0,
|
||||
inner_padding: 12.,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PanelGeometry {
|
||||
pub fn validate(self) -> Result<(), String> {
|
||||
if !self.height.is_finite()
|
||||
|| !(24. ..=128.).contains(&self.height)
|
||||
|| !self.inner_padding.is_finite()
|
||||
|| !(0. ..=64.).contains(&self.inner_padding)
|
||||
|| [self.edge_margin, self.side_margins, self.window_clearance]
|
||||
.into_iter()
|
||||
.any(|value| !(0..=4096).contains(&value))
|
||||
{
|
||||
return Err("panel height must be 24..128, inner padding 0..64, and margins/clearance 0..4096".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn style_defaults_and_user_pins_have_separate_ownership() {
|
||||
use crate::PanelStyle::*;
|
||||
let intent = GeometryIntent::default();
|
||||
assert_eq!(intent.resolve(Floating).edge_margin, 8);
|
||||
assert_eq!(intent.resolve(Flat).edge_margin, 0);
|
||||
assert_eq!(intent.resolve(Morph).height, 34.);
|
||||
let pins = GeometryIntent {
|
||||
height: Some(32.),
|
||||
edge_margin: Some(6),
|
||||
side_margins: Some(18),
|
||||
..intent
|
||||
};
|
||||
assert_eq!(pins.resolve(Flat).height, 32.);
|
||||
assert_eq!(pins.resolve(Flat).side_margins, 0);
|
||||
assert_eq!(pins.resolve(Floating).side_margins, 18);
|
||||
assert_eq!(pins.resolve(Notch).edge_margin, 0);
|
||||
assert!(serde_json::to_value(intent).unwrap().as_object().unwrap().is_empty());
|
||||
assert!(
|
||||
GeometryIntent {
|
||||
edge_margin: Some(-1),
|
||||
..intent
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_geometry_rejects_nonfinite_dimensions_and_negative_clearances() {
|
||||
for geometry in [
|
||||
PanelGeometry {
|
||||
height: f32::NAN,
|
||||
..Default::default()
|
||||
},
|
||||
PanelGeometry {
|
||||
edge_margin: -1,
|
||||
..Default::default()
|
||||
},
|
||||
PanelGeometry {
|
||||
height: 12.,
|
||||
..Default::default()
|
||||
},
|
||||
PanelGeometry {
|
||||
inner_padding: f32::INFINITY,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(geometry.validate().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-19
@@ -1,4 +1,3 @@
|
||||
//! Measured panel allocation. This module has no frame timing or service ownership.
|
||||
use super::{ItemId, OverflowPolicy, Panel, Representation, Zone};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -47,7 +46,7 @@ fn zone_width(panel: &Panel, zone: &Zone, items: &BTreeMap<ItemId, Placement>) -
|
||||
}
|
||||
if occupied {
|
||||
let padding = 2.0 * f32::from(group.padding[1]);
|
||||
let island = if panel.resolved_surface(group) == super::GroupSurface::Island {
|
||||
let island = if panel.group_surface() == super::GroupSurface::Island {
|
||||
2.0 * group.island_padding
|
||||
} else {
|
||||
0.0
|
||||
@@ -56,7 +55,7 @@ fn zone_width(panel: &Panel, zone: &Zone, items: &BTreeMap<ItemId, Placement>) -
|
||||
group_count += 1;
|
||||
}
|
||||
}
|
||||
width + zone.spacing * group_count.saturating_sub(1) as f32
|
||||
width + panel.zone_spacing(zone) * group_count.saturating_sub(1) as f32
|
||||
}
|
||||
|
||||
impl Resolution {
|
||||
@@ -69,7 +68,7 @@ impl Resolution {
|
||||
if !self.overflow.is_empty() {
|
||||
self.zone_widths[2] += overflow_width
|
||||
+ if self.zone_widths[2] > 0.0 {
|
||||
panel.end.spacing
|
||||
panel.zone_spacing(&panel.end)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -297,7 +296,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn zone_width_ignores_empty_groups_and_gaps_before_hidden_items() {
|
||||
use super::super::{Group, GroupSurface, Item, ItemKind};
|
||||
use super::super::{Group, Item, ItemKind};
|
||||
let mut panel = Panel::default();
|
||||
let mut first = Item::new("first", ItemKind::Overview);
|
||||
first.gap_before = Some(99.);
|
||||
@@ -305,7 +304,7 @@ mod tests {
|
||||
let mut last = Item::new("last", ItemKind::Clock);
|
||||
last.gap_before = Some(7.);
|
||||
let mut group = Group::new("content", vec![first, hidden, last], [0, 3], 5.);
|
||||
group.surface = Some(GroupSurface::Island);
|
||||
panel.style = crate::PanelStyle::Floating;
|
||||
group.island_padding = 4.;
|
||||
let empty = Group::new("empty", vec![Item::new("missing", ItemKind::Media)], [0, 99], 99.);
|
||||
let trailing = Group::new(
|
||||
@@ -341,7 +340,7 @@ mod tests {
|
||||
),
|
||||
]
|
||||
.into();
|
||||
assert_eq!(zone_width(&panel, &panel.start, &items), 96.);
|
||||
assert_eq!(zone_width(&panel, &panel.start, &items), 106.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -583,7 +582,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn narrow_panels_adapt_deterministically_and_keep_every_item_reachable() {
|
||||
for background in [crate::PanelPreset::Continuous, crate::PanelPreset::Islands] {
|
||||
for background in [crate::PanelStyle::Flat, crate::PanelStyle::Floating] {
|
||||
let panel = Panel::from_preset(background);
|
||||
let measured = measurements(&panel);
|
||||
for width in [2400., 1200., 800., 400., 160., 80., 32.] {
|
||||
@@ -603,7 +602,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn decoration_and_borders_do_not_change_background_arrangement_or_allocation() {
|
||||
for background in [crate::PanelPreset::Continuous, crate::PanelPreset::Islands] {
|
||||
for background in [crate::PanelStyle::Flat, crate::PanelStyle::Floating] {
|
||||
let mut panel = Panel::from_preset(background);
|
||||
let measurements = measurements(&panel);
|
||||
let plain = resolve(&panel, &measurements, 1000., 32.);
|
||||
@@ -717,20 +716,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn only_island_groups_reserve_island_padding() {
|
||||
let mut panel = Panel::from_preset(crate::PanelPreset::Islands);
|
||||
fn panel_surface_policy_controls_padding_for_every_group() {
|
||||
let mut panel = Panel::from_preset(crate::PanelStyle::Floating);
|
||||
let measured = measurements(&panel);
|
||||
let initial = resolve(&panel, &measured, 2400., 32.);
|
||||
let title_padding = 2. * panel.center.groups[0].island_padding;
|
||||
panel.center.groups[0].surface = Some(super::super::GroupSurface::None);
|
||||
panel.style = crate::PanelStyle::Flat;
|
||||
let plain = resolve(&panel, &measured, 2400., 32.);
|
||||
assert_eq!(plain.zone_widths[1], initial.zone_widths[1] - title_padding);
|
||||
assert_eq!(plain.zone_widths[0], initial.zone_widths[0]);
|
||||
assert_eq!(plain.zone_widths[2], initial.zone_widths[2]);
|
||||
panel.center.groups[0].surface = Some(super::super::GroupSurface::Inset);
|
||||
for (i, zone) in [&panel.start, &panel.center, &panel.end].into_iter().enumerate() {
|
||||
let padding: f32 = zone.groups.iter().map(|group| 2. * group.island_padding).sum();
|
||||
assert_eq!(plain.zone_widths[i], initial.zone_widths[i] - padding);
|
||||
}
|
||||
panel.style = crate::PanelStyle::Flat;
|
||||
assert_eq!(resolve(&panel, &measured, 2400., 32.), plain);
|
||||
// A parent panel fill cannot change a group's allocation policy.
|
||||
panel.surface = super::super::PanelSurface::Solid;
|
||||
panel.style = crate::PanelStyle::Flat;
|
||||
assert_eq!(resolve(&panel, &measured, 2400., 32.), plain);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,25 @@ impl CornerRadii {
|
||||
let limit = width.min(height).max(0.) / 2.;
|
||||
Self(self.0.map(|value| value.min(limit)))
|
||||
}
|
||||
|
||||
/// Scale all corners together when their sums exceed an adjacent edge.
|
||||
/// Unlike the symmetric round-box clamp, this permits a large free-side
|
||||
/// corner when the corner on the anchored side is square.
|
||||
pub fn normalized(self, width: f32, height: f32) -> Self {
|
||||
let [tl, tr, br, bl] = self.0;
|
||||
let mut factor = 1.0_f64;
|
||||
for (length, sum) in [
|
||||
(width, f64::from(tl) + f64::from(tr)),
|
||||
(width, f64::from(bl) + f64::from(br)),
|
||||
(height, f64::from(tl) + f64::from(bl)),
|
||||
(height, f64::from(tr) + f64::from(br)),
|
||||
] {
|
||||
if sum > 0. {
|
||||
factor = factor.min(f64::from(length.max(0.)) / sum);
|
||||
}
|
||||
}
|
||||
Self(self.0.map(|radius| (f64::from(radius) * factor) as f32))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for CornerRadii {
|
||||
@@ -0,0 +1,716 @@
|
||||
use crate::shape::{AnchoredRadii, PixelGrid, RoundedSurface, Style, ZonePosition};
|
||||
use crate::{CornerRadii, Edge};
|
||||
use ferese_layout::Rect;
|
||||
|
||||
pub const MAX_PRIMITIVES: usize = 32;
|
||||
|
||||
/// Additive rounded rectangles, one optional cutout, and concave corner patches.
|
||||
/// Patch codes name the circle's center: bottom-left, bottom-right, top-right,
|
||||
/// top-left. A patch is its rectangle minus that circle, rather than a hot area.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Primitive {
|
||||
pub bounds: Rect,
|
||||
pub radii: CornerRadii,
|
||||
pub operation: u8,
|
||||
pub blend_zone: u8,
|
||||
}
|
||||
|
||||
impl Primitive {
|
||||
pub fn packet(self) -> [f32; 10] {
|
||||
let Rect { x, y, width, height } = self.bounds;
|
||||
let [tl, tr, br, bl] = self.radii.0;
|
||||
[
|
||||
x as f32,
|
||||
y as f32,
|
||||
width as f32,
|
||||
height as f32,
|
||||
tl,
|
||||
tr,
|
||||
br,
|
||||
bl,
|
||||
self.operation as f32,
|
||||
self.blend_zone as f32,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn from_packet(values: [f32; 10]) -> Result<Self, &'static str> {
|
||||
if !values.into_iter().all(|v| v.is_finite() && v.abs() <= 32768.)
|
||||
|| values[2] <= 0.
|
||||
|| values[3] <= 0.
|
||||
|| values[4..8].iter().any(|v| *v < 0.)
|
||||
|| values[8].fract() != 0.
|
||||
|| !(0. ..=5.).contains(&values[8])
|
||||
|| values[9].fract() != 0.
|
||||
|| !(0. ..=3.).contains(&values[9])
|
||||
{
|
||||
return Err("invalid panel shape primitive");
|
||||
}
|
||||
Ok(Self {
|
||||
bounds: Rect::new(values[0].into(), values[1].into(), values[2].into(), values[3].into()),
|
||||
radii: CornerRadii([values[4], values[5], values[6], values[7]]).normalized(values[2], values[3]),
|
||||
operation: values[8] as u8,
|
||||
blend_zone: values[9] as u8,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn distance(self, point: [f64; 2]) -> f64 {
|
||||
let rect = rectangle_distance(point, self.bounds);
|
||||
if self.operation >= 2 {
|
||||
let b = self.bounds;
|
||||
let center = match self.operation {
|
||||
2 => [b.x, b.y + b.height],
|
||||
3 => [b.x + b.width, b.y + b.height],
|
||||
4 => [b.x + b.width, b.y],
|
||||
_ => [b.x, b.y],
|
||||
};
|
||||
return rect.max(b.width.min(b.height) - (point[0] - center[0]).hypot(point[1] - center[1]));
|
||||
}
|
||||
let b = self.bounds;
|
||||
let (x, y) = (point[0] - b.x, point[1] - b.y);
|
||||
let mut distance = rect;
|
||||
for (r, dx, dy) in [
|
||||
(self.radii.0[0], x, y),
|
||||
(self.radii.0[1], b.width - x, y),
|
||||
(self.radii.0[2], b.width - x, b.height - y),
|
||||
(self.radii.0[3], x, b.height - y),
|
||||
] {
|
||||
let r = f64::from(r);
|
||||
if r > 0. && dx < r && dy < r {
|
||||
distance = distance.max((dx - r).hypot(dy - r) - r);
|
||||
}
|
||||
}
|
||||
distance
|
||||
}
|
||||
}
|
||||
|
||||
fn rectangle_distance(point: [f64; 2], b: Rect) -> f64 {
|
||||
let x = (point[0] - b.x - b.width / 2.).abs() - b.width / 2.;
|
||||
let y = (point[1] - b.y - b.height / 2.).abs() - b.height / 2.;
|
||||
x.max(0.).hypot(y.max(0.)) + x.max(y).min(0.)
|
||||
}
|
||||
|
||||
pub fn smooth_min(a: f64, b: f64, strength: f64) -> f64 {
|
||||
if strength <= 0. {
|
||||
return a.min(b);
|
||||
}
|
||||
let h = ((strength - (a - b).abs()) / strength).max(0.);
|
||||
a.min(b) - h * h * strength / 4.
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Scene {
|
||||
pub paint: Vec<Primitive>,
|
||||
pub input: Vec<RoundedSurface>,
|
||||
/// Bounded drawing/capture regions. Frame uses strips, not the output area.
|
||||
pub damage: Vec<Rect>,
|
||||
pub dividers: Vec<[f64; 4]>,
|
||||
pub fusion: f64,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
pub fn distance(&self, point: [f64; 2]) -> f64 {
|
||||
let mut zones = [f64::INFINITY; 4];
|
||||
let mut cutout = f64::INFINITY;
|
||||
for primitive in &self.paint {
|
||||
let distance = primitive.distance(point);
|
||||
if primitive.operation == 1 {
|
||||
cutout = cutout.min(distance);
|
||||
} else {
|
||||
let zone = usize::from(primitive.blend_zone);
|
||||
zones[zone] = if zone > 0 {
|
||||
smooth_min(zones[zone], distance, self.fusion)
|
||||
} else {
|
||||
zones[zone].min(distance)
|
||||
};
|
||||
}
|
||||
}
|
||||
zones.into_iter().fold(f64::INFINITY, f64::min).max(-cutout)
|
||||
}
|
||||
|
||||
pub fn contains(&self, point: [f64; 2]) -> bool {
|
||||
self.input.iter().any(|shape| shape.contains(point))
|
||||
}
|
||||
|
||||
pub fn packets(&self) -> Vec<[f32; 10]> {
|
||||
self.paint.iter().map(|p| p.packet()).collect()
|
||||
}
|
||||
|
||||
/// Horizontal runs at physical-pixel centers. Corners and animated gaps
|
||||
/// stay click-through; adjacent equal runs are coalesced into one rectangle.
|
||||
pub fn input_rectangles(&self, scale: f64) -> Vec<[i32; 4]> {
|
||||
if !scale.is_finite() || scale <= 0. {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut result: Vec<[i32; 4]> = Vec::new();
|
||||
for shape in &self.input {
|
||||
let b = shape.bounds();
|
||||
let left = (b.x * scale).floor() as i32;
|
||||
let right = ((b.x + b.width) * scale).ceil() as i32;
|
||||
let top = (b.y * scale).floor() as i32;
|
||||
let bottom = ((b.y + b.height) * scale).ceil() as i32;
|
||||
for y in top..bottom {
|
||||
let mut first = left;
|
||||
while first < right && !shape.contains([(f64::from(first) + 0.5) / scale, (f64::from(y) + 0.5) / scale])
|
||||
{
|
||||
first += 1;
|
||||
}
|
||||
let mut last = right;
|
||||
while last > first && !shape.contains([(f64::from(last) - 0.5) / scale, (f64::from(y) + 0.5) / scale]) {
|
||||
last -= 1;
|
||||
}
|
||||
if first == last {
|
||||
continue;
|
||||
}
|
||||
if let Some(previous) = result.last_mut()
|
||||
&& previous[0] == first
|
||||
&& previous[2] == last - first
|
||||
&& previous[1] + previous[3] == y
|
||||
{
|
||||
previous[3] += 1;
|
||||
} else {
|
||||
result.push([first, y, last - first, 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GroupBounds {
|
||||
pub zone: ZonePosition,
|
||||
pub bounds: Rect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Parameters {
|
||||
pub style: Style,
|
||||
pub attached: bool,
|
||||
pub edge: Edge,
|
||||
pub height: f64,
|
||||
pub gap: f64,
|
||||
pub radius: Option<AnchoredRadii>,
|
||||
pub frame_insets: [f64; 4],
|
||||
pub frame_radius: f64,
|
||||
pub wrap_size: [f64; 2],
|
||||
pub wrap_width: f64,
|
||||
pub fillet: f64,
|
||||
}
|
||||
|
||||
impl Default for Parameters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
style: Style::Flat,
|
||||
attached: false,
|
||||
edge: Edge::Top,
|
||||
height: 36.,
|
||||
gap: 8.,
|
||||
radius: None,
|
||||
frame_insets: [40., 8., 8., 8.],
|
||||
frame_radius: 14.,
|
||||
wrap_size: [560., 380.],
|
||||
wrap_width: 36.,
|
||||
fillet: 10.,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add(
|
||||
scene: &mut Scene,
|
||||
grid: PixelGrid,
|
||||
rect: Rect,
|
||||
radii: CornerRadii,
|
||||
input: bool,
|
||||
zone: u8,
|
||||
) -> Result<(), &'static str> {
|
||||
let rounded = RoundedSurface::new(rect, radii, grid)?;
|
||||
if rounded.bounds().width == 0. || rounded.bounds().height == 0. {
|
||||
return Ok(());
|
||||
}
|
||||
scene.paint.push(Primitive {
|
||||
bounds: rounded.bounds(),
|
||||
radii: rounded.radii(),
|
||||
operation: 0,
|
||||
blend_zone: zone,
|
||||
});
|
||||
if input {
|
||||
scene.input.push(rounded);
|
||||
}
|
||||
scene.damage.push(rounded.bounds());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn derive(
|
||||
parameters: Parameters,
|
||||
bounds: Rect,
|
||||
groups: &[GroupBounds],
|
||||
grid: PixelGrid,
|
||||
) -> Result<Scene, &'static str> {
|
||||
let bounds = grid.rect(bounds)?;
|
||||
if bounds.width == 0. || bounds.height == 0. {
|
||||
return Ok(Scene::default());
|
||||
}
|
||||
if ![
|
||||
parameters.height,
|
||||
parameters.gap,
|
||||
parameters.frame_radius,
|
||||
parameters.wrap_width,
|
||||
parameters.fillet,
|
||||
]
|
||||
.into_iter()
|
||||
.chain(parameters.frame_insets)
|
||||
.chain(parameters.wrap_size)
|
||||
.all(|v| v.is_finite() && (0. ..=32768.).contains(&v))
|
||||
|| parameters
|
||||
.radius
|
||||
.is_some_and(|r| r.screen_space(parameters.edge).validate().is_err())
|
||||
{
|
||||
return Err("invalid panel shape parameters");
|
||||
}
|
||||
let mut scene = Scene::default();
|
||||
match parameters.style {
|
||||
Style::Flat => add(&mut scene, grid, bounds, CornerRadii::uniform(0.), true, 0)?,
|
||||
Style::Frame => {
|
||||
add(&mut scene, grid, bounds, CornerRadii::uniform(0.), false, 0)?;
|
||||
let [top, right, bottom, left] = parameters.frame_insets;
|
||||
let left = left.min(bounds.width / 3.);
|
||||
let right = right.min(bounds.width / 3.);
|
||||
let top = top.min(bounds.height / 3.);
|
||||
let bottom = bottom.min(bounds.height / 3.);
|
||||
let inner = grid.rect(Rect::new(
|
||||
bounds.x + left,
|
||||
bounds.y + top,
|
||||
bounds.width - left - right,
|
||||
bounds.height - top - bottom,
|
||||
))?;
|
||||
let r = parameters.frame_radius as f32;
|
||||
let radii = CornerRadii([
|
||||
if left > 0.5 && top > 0.5 { r } else { 0. },
|
||||
if right > 0.5 && top > 0.5 { r } else { 0. },
|
||||
if right > 0.5 && bottom > 0.5 { r } else { 0. },
|
||||
if left > 0.5 && bottom > 0.5 { r } else { 0. },
|
||||
])
|
||||
.normalized(inner.width as f32, inner.height as f32);
|
||||
scene.paint.push(Primitive {
|
||||
bounds: inner,
|
||||
radii,
|
||||
operation: 1,
|
||||
blend_zone: 0,
|
||||
});
|
||||
scene.input.push(RoundedSurface::new(
|
||||
Rect::new(bounds.x, bounds.y, bounds.width, top),
|
||||
CornerRadii::uniform(0.),
|
||||
grid,
|
||||
)?);
|
||||
let radius = f64::from(radii.max());
|
||||
scene.damage = vec![
|
||||
Rect::new(bounds.x, bounds.y, bounds.width, top + radius),
|
||||
Rect::new(
|
||||
bounds.x,
|
||||
bounds.y + bounds.height - bottom - radius,
|
||||
bounds.width,
|
||||
bottom + radius,
|
||||
),
|
||||
Rect::new(
|
||||
bounds.x,
|
||||
bounds.y + top + radius,
|
||||
left + radius,
|
||||
(bounds.height - top - bottom - 2. * radius).max(0.),
|
||||
),
|
||||
Rect::new(
|
||||
bounds.x + bounds.width - right - radius,
|
||||
bounds.y + top + radius,
|
||||
right + radius,
|
||||
(bounds.height - top - bottom - 2. * radius).max(0.),
|
||||
),
|
||||
];
|
||||
}
|
||||
Style::CornerWrap => {
|
||||
let h = parameters.height.min(bounds.height);
|
||||
let w = parameters.wrap_width.min(bounds.width);
|
||||
let horizontal = Rect::new(bounds.x, bounds.y, parameters.wrap_size[0].min(bounds.width), h);
|
||||
let vertical = Rect::new(bounds.x, bounds.y, w, parameters.wrap_size[1].min(bounds.height));
|
||||
add(
|
||||
&mut scene,
|
||||
grid,
|
||||
horizontal,
|
||||
AnchoredRadii([0., 0., 14., 0.]).screen_space(Edge::Top),
|
||||
true,
|
||||
0,
|
||||
)?;
|
||||
add(&mut scene, grid, vertical, CornerRadii([0., 0., 14., 0.]), true, 0)?;
|
||||
// Capture the union once: overlapping translucent captures darken the elbow.
|
||||
scene.damage = vec![
|
||||
horizontal,
|
||||
Rect::new(vertical.x, vertical.y + h, w, (vertical.height - h).max(0.)),
|
||||
];
|
||||
let r = parameters
|
||||
.fillet
|
||||
.min((horizontal.width - w).max(0.))
|
||||
.min((vertical.height - h).max(0.));
|
||||
if r > 0. {
|
||||
let patch = grid.rect(Rect::new(bounds.x + w, bounds.y + h, r, r))?;
|
||||
scene.damage.push(patch);
|
||||
scene.paint.push(Primitive {
|
||||
bounds: patch,
|
||||
radii: CornerRadii::uniform(0.),
|
||||
operation: 4,
|
||||
blend_zone: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
Style::Morph | Style::WindowHug => {
|
||||
let attached = parameters.style == Style::Morph && parameters.attached;
|
||||
let radii = parameters
|
||||
.radius
|
||||
.map_or(CornerRadii::uniform((bounds.height / 2.) as f32), |r| {
|
||||
r.screen_space(parameters.edge)
|
||||
})
|
||||
.at_edge(parameters.edge, attached);
|
||||
add(&mut scene, grid, bounds, radii, true, 0)?;
|
||||
if attached {
|
||||
let r = grid.snap(parameters.fillet.min(bounds.height));
|
||||
if r > 0. {
|
||||
let (y, left, right) = if parameters.edge == Edge::Top {
|
||||
(bounds.y, 2, 3)
|
||||
} else {
|
||||
(bounds.y + bounds.height - r, 5, 4)
|
||||
};
|
||||
for (x, operation) in [(bounds.x - r, left), (bounds.x + bounds.width, right)] {
|
||||
let patch = grid.rect(Rect::new(x, y, r, r))?;
|
||||
scene.paint.push(Primitive {
|
||||
bounds: patch,
|
||||
radii: CornerRadii::uniform(0.),
|
||||
operation,
|
||||
blend_zone: 0,
|
||||
});
|
||||
scene.damage.push(patch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
style => {
|
||||
for (zone_index, zone) in [ZonePosition::Start, ZonePosition::Center, ZonePosition::End]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let occupied: Vec<_> = groups
|
||||
.iter()
|
||||
.filter(|g| g.zone == zone && g.bounds.width > 0. && g.bounds.height > 0.)
|
||||
.collect();
|
||||
if occupied.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if style.connected() {
|
||||
let x = occupied.iter().map(|g| g.bounds.x).fold(f64::INFINITY, f64::min);
|
||||
let end = occupied
|
||||
.iter()
|
||||
.map(|g| g.bounds.x + g.bounds.width)
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let (x, end) = if style == Style::Notch {
|
||||
match zone {
|
||||
ZonePosition::Start => (bounds.x, end),
|
||||
ZonePosition::End => (x, bounds.x + bounds.width),
|
||||
ZonePosition::Center => (x, end),
|
||||
}
|
||||
} else {
|
||||
(x, end)
|
||||
};
|
||||
let body = Rect::new(x, bounds.y, end - x, bounds.height);
|
||||
let radii = parameters.radius.map_or_else(
|
||||
|| {
|
||||
crate::shape::effective_radii(
|
||||
style,
|
||||
zone,
|
||||
parameters.edge,
|
||||
[body.width as f32, body.height as f32],
|
||||
0,
|
||||
1,
|
||||
)
|
||||
},
|
||||
|r| {
|
||||
Ok(r.screen_space(parameters.edge)
|
||||
.normalized(body.width as f32, body.height as f32))
|
||||
},
|
||||
)?;
|
||||
add(&mut scene, grid, body, radii, true, 0)?;
|
||||
for group in occupied.iter().take(occupied.len() - 1) {
|
||||
let seam = grid.snap(group.bounds.x + group.bounds.width);
|
||||
scene
|
||||
.dividers
|
||||
.push([seam, bounds.y + 8., seam, bounds.y + bounds.height - 8.]);
|
||||
}
|
||||
if style == Style::Notch && zone == ZonePosition::Center {
|
||||
let r = parameters
|
||||
.fillet
|
||||
.min(bounds.height)
|
||||
.min((x - bounds.x).max(0.))
|
||||
.min((bounds.x + bounds.width - end).max(0.));
|
||||
if r > 0. {
|
||||
let (y, left, right) = if parameters.edge == Edge::Top {
|
||||
(bounds.y, 2, 3)
|
||||
} else {
|
||||
(bounds.y + bounds.height - r, 5, 4)
|
||||
};
|
||||
for (x, operation) in [(x - r, left), (end, right)] {
|
||||
scene.paint.push(Primitive {
|
||||
bounds: grid.rect(Rect::new(x, y, r, r))?,
|
||||
radii: CornerRadii::uniform(0.),
|
||||
operation,
|
||||
blend_zone: 0,
|
||||
});
|
||||
scene.damage.push(Rect::new(x, y, r, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for group in occupied {
|
||||
let body = Rect::new(group.bounds.x, bounds.y, group.bounds.width, bounds.height);
|
||||
let radii = parameters
|
||||
.radius
|
||||
.map_or(CornerRadii::uniform((bounds.height / 2.) as f32), |r| {
|
||||
r.screen_space(parameters.edge)
|
||||
});
|
||||
add(
|
||||
&mut scene,
|
||||
grid,
|
||||
body,
|
||||
radii,
|
||||
true,
|
||||
if style == Style::Fused {
|
||||
(zone_index + 1) as u8
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Polynomial smooth-min only bridges when k > 2*gap. A strength
|
||||
// of 0.8*gap cannot form a neck with this kernel.
|
||||
if style == Style::Fused {
|
||||
scene.fusion = parameters.gap * 2.4;
|
||||
scene.damage.clear();
|
||||
for zone in 1..=3 {
|
||||
let mut shapes = scene.paint.iter().filter(|p| p.blend_zone == zone);
|
||||
if let Some(first) = shapes.next() {
|
||||
let mut region = first.bounds;
|
||||
for shape in shapes {
|
||||
let right = (region.x + region.width).max(shape.bounds.x + shape.bounds.width);
|
||||
region.x = region.x.min(shape.bounds.x);
|
||||
region.width = right - region.x;
|
||||
}
|
||||
scene.damage.push(region);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if parameters.edge == Edge::Bottom && matches!(parameters.style, Style::Frame | Style::CornerWrap) {
|
||||
let mirror = |mut r: Rect| {
|
||||
r.y = 2. * bounds.y + bounds.height - r.y - r.height;
|
||||
r
|
||||
};
|
||||
for primitive in &mut scene.paint {
|
||||
primitive.bounds = mirror(primitive.bounds);
|
||||
let [tl, tr, br, bl] = primitive.radii.0;
|
||||
primitive.radii = CornerRadii([bl, br, tr, tl]);
|
||||
primitive.operation = match primitive.operation {
|
||||
2 => 5,
|
||||
3 => 4,
|
||||
4 => 3,
|
||||
5 => 2,
|
||||
op => op,
|
||||
};
|
||||
}
|
||||
for input in &mut scene.input {
|
||||
let [tl, tr, br, bl] = input.radii().0;
|
||||
*input = RoundedSurface::new(mirror(input.bounds()), CornerRadii([bl, br, tr, tl]), grid)?;
|
||||
}
|
||||
for damage in &mut scene.damage {
|
||||
*damage = mirror(*damage);
|
||||
}
|
||||
}
|
||||
if scene.paint.len() > MAX_PRIMITIVES {
|
||||
return Err("panel shape exceeds renderer primitive capacity");
|
||||
}
|
||||
scene.damage.retain(|r| r.width > 0. && r.height > 0.);
|
||||
Ok(scene)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn groups() -> [GroupBounds; 3] {
|
||||
[
|
||||
GroupBounds {
|
||||
zone: ZonePosition::Start,
|
||||
bounds: Rect::new(8., 0., 100., 36.),
|
||||
},
|
||||
GroupBounds {
|
||||
zone: ZonePosition::Start,
|
||||
bounds: Rect::new(116., 0., 80., 36.),
|
||||
},
|
||||
GroupBounds {
|
||||
zone: ZonePosition::Center,
|
||||
bounds: Rect::new(350., 0., 100., 36.),
|
||||
},
|
||||
]
|
||||
}
|
||||
fn scene(style: Style) -> Scene {
|
||||
derive(
|
||||
Parameters {
|
||||
style,
|
||||
..Default::default()
|
||||
},
|
||||
Rect::new(0., 0., 800., 36.),
|
||||
&groups(),
|
||||
PixelGrid::new(1.25).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn floating_gaps_are_unpainted_and_click_through() {
|
||||
let scene = scene(Style::Floating);
|
||||
assert_eq!(scene.paint.len(), 3);
|
||||
assert!(scene.distance([112., 18.]) > 0. && !scene.contains([112., 18.]));
|
||||
assert!(scene.contains([50., 18.]));
|
||||
}
|
||||
#[test]
|
||||
fn capsules_use_one_outline_per_occupied_zone() {
|
||||
let scene = scene(Style::Capsules);
|
||||
assert_eq!(scene.paint.len(), 2);
|
||||
assert_eq!(scene.dividers.len(), 1);
|
||||
assert!(scene.distance([112., 18.]) < 0.);
|
||||
}
|
||||
#[test]
|
||||
fn notch_fillets_are_painted_but_never_interactive() {
|
||||
let scene = scene(Style::Notch);
|
||||
assert!(scene.distance([349., 1.]) < 0.);
|
||||
assert!(!scene.contains([349., 1.]));
|
||||
assert!(scene.distance([340.5, 9.5]) > 0.);
|
||||
assert_eq!(scene.paint[0].bounds.x, 0.);
|
||||
}
|
||||
#[test]
|
||||
fn fused_neighbors_grow_a_neck_without_capturing_the_gap() {
|
||||
let scene = scene(Style::Fused);
|
||||
assert!(scene.distance([112., 18.]) < 0.);
|
||||
assert!(!scene.contains([112., 18.]));
|
||||
assert!(scene.distance([250., 18.]) > 0.);
|
||||
}
|
||||
#[test]
|
||||
fn fused_capture_includes_the_neck() {
|
||||
let scene = scene(Style::Fused);
|
||||
assert!(
|
||||
scene
|
||||
.damage
|
||||
.iter()
|
||||
.any(|r| r.x <= 112. && r.x + r.width > 112. && r.y <= 18. && r.y + r.height > 18.)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_wrap_capture_is_disjoint_and_the_workspace_arm_is_interactive() {
|
||||
let scene = derive(
|
||||
Parameters {
|
||||
style: Style::CornerWrap,
|
||||
..Default::default()
|
||||
},
|
||||
Rect::new(0., 0., 800., 600.),
|
||||
&[],
|
||||
PixelGrid::new(1.25).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(scene.contains([18., 200.]));
|
||||
assert!(!scene.contains([38., 38.])); // the decorative elbow is not input
|
||||
for (i, a) in scene.damage.iter().enumerate() {
|
||||
for b in &scene.damage[i + 1..] {
|
||||
assert!(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y);
|
||||
}
|
||||
}
|
||||
for x in (0..800).step_by(3) {
|
||||
for y in (0..600).step_by(3) {
|
||||
let point = [x as f64 + 0.5, y as f64 + 0.5];
|
||||
if scene.distance(point) < 0. {
|
||||
assert!(scene.damage.iter().any(|r| point[0] >= r.x
|
||||
&& point[0] < r.x + r.width
|
||||
&& point[1] >= r.y
|
||||
&& point[1] < r.y + r.height));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_has_a_cutout_and_bounded_damage() {
|
||||
let scene = derive(
|
||||
Parameters {
|
||||
style: Style::Frame,
|
||||
..Default::default()
|
||||
},
|
||||
Rect::new(0., 0., 1600., 1000.),
|
||||
&[],
|
||||
PixelGrid::new(1.5).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(scene.distance([800., 20.]) < 0. && scene.distance([800., 500.]) > 0.);
|
||||
assert!(scene.contains([800., 20.]) && !scene.contains([2., 500.]));
|
||||
assert!(scene.damage.iter().map(|r| r.width * r.height).sum::<f64>() < 1600. * 1000. / 5.);
|
||||
}
|
||||
#[test]
|
||||
fn primitive_packets_reject_malformed_geometry() {
|
||||
let mut packet = scene(Style::Floating).packets()[0];
|
||||
for (index, value) in [(0, f32::NAN), (2, 0.), (4, -1.), (8, 2.5), (8, 6.), (9, 4.)] {
|
||||
let previous = packet[index];
|
||||
packet[index] = value;
|
||||
assert!(Primitive::from_packet(packet).is_err());
|
||||
packet[index] = previous;
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn physical_input_runs_exclude_corners_and_coalesce() {
|
||||
let scene = scene(Style::Floating);
|
||||
let rects = scene.input_rectangles(1.25);
|
||||
assert!(rects.len() < 3 * 45);
|
||||
assert!(
|
||||
!rects
|
||||
.iter()
|
||||
.any(|r| r[0] <= 140 && 140 < r[0] + r[2] && r[1] <= 22 && 22 < r[1] + r[3])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn attached_styles_mirror_the_whole_outline_at_the_bottom() {
|
||||
for style in [Style::Notch, Style::Frame, Style::CornerWrap] {
|
||||
let bounds = Rect::new(0., 0., 800., if style == Style::Notch { 36. } else { 600. });
|
||||
let grid = PixelGrid::new(1.).unwrap();
|
||||
let top = derive(
|
||||
Parameters {
|
||||
style,
|
||||
..Default::default()
|
||||
},
|
||||
bounds,
|
||||
&groups(),
|
||||
grid,
|
||||
)
|
||||
.unwrap();
|
||||
let bottom = derive(
|
||||
Parameters {
|
||||
style,
|
||||
edge: Edge::Bottom,
|
||||
..Default::default()
|
||||
},
|
||||
bounds,
|
||||
&groups(),
|
||||
grid,
|
||||
)
|
||||
.unwrap();
|
||||
for x in (0..800).step_by(13) {
|
||||
for y in (0..bounds.height as usize).step_by(11) {
|
||||
let a = top.distance([x as f64 + 0.5, y as f64 + 0.5]);
|
||||
let b = bottom.distance([x as f64 + 0.5, bounds.height - y as f64 - 0.5]);
|
||||
assert!((a - b).abs() < 1e-6, "{style:?}: {x},{y}: {a} != {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
use crate::{CornerRadii, Edge};
|
||||
use ferese_layout::Rect;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Style {
|
||||
Floating,
|
||||
Capsules,
|
||||
Notch,
|
||||
#[default]
|
||||
Flat,
|
||||
Frame,
|
||||
CornerWrap,
|
||||
Fused,
|
||||
Morph,
|
||||
Adaptive,
|
||||
WindowHug,
|
||||
}
|
||||
|
||||
impl Style {
|
||||
/// Styles with a complete shell surface contract.
|
||||
pub const AVAILABLE: [Self; 7] = [
|
||||
Self::Floating,
|
||||
Self::Capsules,
|
||||
Self::Notch,
|
||||
Self::Flat,
|
||||
Self::Fused,
|
||||
Self::Adaptive,
|
||||
Self::Morph,
|
||||
];
|
||||
|
||||
pub const ALL: [Self; 10] = [
|
||||
Self::Floating,
|
||||
Self::Capsules,
|
||||
Self::Notch,
|
||||
Self::Flat,
|
||||
Self::Adaptive,
|
||||
Self::Morph,
|
||||
Self::Fused,
|
||||
Self::Frame,
|
||||
Self::CornerWrap,
|
||||
Self::WindowHug,
|
||||
];
|
||||
|
||||
pub const fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Floating => "floating",
|
||||
Self::Capsules => "capsules",
|
||||
Self::Notch => "notch",
|
||||
Self::Flat => "flat",
|
||||
Self::Frame => "frame",
|
||||
Self::CornerWrap => "corner-wrap",
|
||||
Self::Fused => "fused",
|
||||
Self::Morph => "morph",
|
||||
Self::Adaptive => "adaptive",
|
||||
Self::WindowHug => "window-hug",
|
||||
}
|
||||
}
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Floating => "Floating",
|
||||
Self::Capsules => "Capsules",
|
||||
Self::Notch => "Notch",
|
||||
Self::Flat => "Edge to edge",
|
||||
Self::Frame => "Frame",
|
||||
Self::CornerWrap => "Corner wrap",
|
||||
Self::Fused => "Fused islands",
|
||||
Self::Morph => "Morph",
|
||||
Self::Adaptive => "Adaptive",
|
||||
Self::WindowHug => "Window hug",
|
||||
}
|
||||
}
|
||||
pub const fn connected(self) -> bool {
|
||||
matches!(self, Self::Capsules | Self::Notch)
|
||||
}
|
||||
pub const fn detached(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Floating | Self::Capsules | Self::Fused | Self::Morph | Self::Adaptive | Self::WindowHug
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ZonePosition {
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
/// Clockwise from the left corner on the anchored edge. The same values are
|
||||
/// used for top and bottom panels; only conversion to screen space differs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnchoredRadii(pub [f32; 4]);
|
||||
|
||||
impl AnchoredRadii {
|
||||
pub fn screen_space(self, edge: Edge) -> CornerRadii {
|
||||
let [flat_left, flat_right, free_right, free_left] = self.0;
|
||||
CornerRadii(match edge {
|
||||
Edge::Top => [flat_left, flat_right, free_right, free_left],
|
||||
Edge::Bottom => [free_left, free_right, flat_right, flat_left],
|
||||
})
|
||||
}
|
||||
|
||||
/// Membership refers to the occupied groups after availability and
|
||||
/// overflow have been resolved, never to the stored group count.
|
||||
pub fn joined(mut self, index: usize, count: usize) -> Result<Self, &'static str> {
|
||||
if index >= count {
|
||||
return Err("a joined group must belong to a nonempty zone");
|
||||
}
|
||||
if index > 0 {
|
||||
self.0[0] = 0.;
|
||||
self.0[3] = 0.;
|
||||
}
|
||||
if index + 1 < count {
|
||||
self.0[1] = 0.;
|
||||
self.0[2] = 0.;
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_radii(
|
||||
style: Style,
|
||||
zone: ZonePosition,
|
||||
edge: Edge,
|
||||
size: [f32; 2],
|
||||
index: usize,
|
||||
count: usize,
|
||||
) -> Result<CornerRadii, &'static str> {
|
||||
if !size.into_iter().all(|value| value.is_finite() && value >= 0.) {
|
||||
return Err("panel dimensions must be finite and nonnegative");
|
||||
}
|
||||
if index >= count {
|
||||
return Err("a group must belong to a nonempty zone");
|
||||
}
|
||||
let [width, height] = size;
|
||||
let base = AnchoredRadii(match style {
|
||||
Style::Floating | Style::Capsules | Style::Fused | Style::Morph | Style::Adaptive | Style::WindowHug => {
|
||||
[height / 2.; 4]
|
||||
}
|
||||
Style::Notch => match zone {
|
||||
ZonePosition::Start => [0., 0., 14., 0.],
|
||||
ZonePosition::Center => [0., 0., 20., 20.],
|
||||
ZonePosition::End => [0., 0., 0., 14.],
|
||||
},
|
||||
Style::Flat | Style::Frame | Style::CornerWrap => [0.; 4],
|
||||
});
|
||||
let radii = if matches!(style, Style::Capsules | Style::Notch) {
|
||||
base.joined(index, count)?
|
||||
} else {
|
||||
base
|
||||
};
|
||||
Ok(radii.screen_space(edge).normalized(width, height))
|
||||
}
|
||||
|
||||
/// Snap rendered boundaries, without quantizing the stored animation state.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PixelGrid {
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
impl PixelGrid {
|
||||
pub fn new(scale: f64) -> Result<Self, &'static str> {
|
||||
if !scale.is_finite() || scale <= 0. {
|
||||
return Err("output scale must be finite and positive");
|
||||
}
|
||||
Ok(Self { scale })
|
||||
}
|
||||
|
||||
pub fn snap(self, position: f64) -> f64 {
|
||||
(position * self.scale).round() / self.scale
|
||||
}
|
||||
|
||||
pub fn rect(self, bounds: Rect) -> Result<Rect, &'static str> {
|
||||
if ![bounds.x, bounds.y, bounds.width, bounds.height]
|
||||
.into_iter()
|
||||
.all(f64::is_finite)
|
||||
|| bounds.width < 0.
|
||||
|| bounds.height < 0.
|
||||
{
|
||||
return Err("surface bounds must be finite with nonnegative dimensions");
|
||||
}
|
||||
let left = self.snap(bounds.x);
|
||||
let top = self.snap(bounds.y);
|
||||
let right = self.snap(bounds.x + bounds.width);
|
||||
let bottom = self.snap(bounds.y + bounds.height);
|
||||
if ![left, top, right, bottom].into_iter().all(f64::is_finite) {
|
||||
return Err("surface bounds exceed the physical pixel grid");
|
||||
}
|
||||
Ok(Rect::new(left, top, (right - left).max(0.), (bottom - top).max(0.)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact circular-corner hit testing, including asymmetric radii larger than
|
||||
/// half the surface height. Decorative fillets and shadows are separate shapes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RoundedSurface {
|
||||
bounds: Rect,
|
||||
radii: CornerRadii,
|
||||
}
|
||||
|
||||
impl RoundedSurface {
|
||||
pub fn new(bounds: Rect, radii: CornerRadii, grid: PixelGrid) -> Result<Self, &'static str> {
|
||||
if radii.validate().is_err() {
|
||||
return Err("surface radii must be finite and nonnegative");
|
||||
}
|
||||
let bounds = grid.rect(bounds)?;
|
||||
if bounds.width > f64::from(f32::MAX) || bounds.height > f64::from(f32::MAX) {
|
||||
return Err("surface dimensions exceed the radius coordinate range");
|
||||
}
|
||||
let radii = radii.normalized(bounds.width as f32, bounds.height as f32);
|
||||
Ok(Self { bounds, radii })
|
||||
}
|
||||
|
||||
pub fn bounds(self) -> Rect {
|
||||
self.bounds
|
||||
}
|
||||
|
||||
pub fn radii(self) -> CornerRadii {
|
||||
self.radii
|
||||
}
|
||||
|
||||
/// Right and bottom boundaries are excluded so a shared seam belongs to
|
||||
/// exactly one surface, rather than both neighbors.
|
||||
pub fn contains(self, point: [f64; 2]) -> bool {
|
||||
let [x, y] = [point[0] - self.bounds.x, point[1] - self.bounds.y];
|
||||
let (width, height) = (self.bounds.width, self.bounds.height);
|
||||
if !(x >= 0. && y >= 0. && x < width && y < height) {
|
||||
return false;
|
||||
}
|
||||
for (radius, dx, dy) in [
|
||||
(self.radii.0[0], x, y),
|
||||
(self.radii.0[1], width - x, y),
|
||||
(self.radii.0[2], width - x, height - y),
|
||||
(self.radii.0[3], x, height - y),
|
||||
] {
|
||||
let radius = f64::from(radius);
|
||||
if dx < radius && dy < radius && (dx - radius).hypot(dy - radius) > radius {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hiding_the_last_group_rounds_the_new_end() {
|
||||
let radius = |index, count| {
|
||||
effective_radii(
|
||||
Style::Capsules,
|
||||
ZonePosition::Start,
|
||||
Edge::Top,
|
||||
[100., 36.],
|
||||
index,
|
||||
count,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(radius(1, 3).0, [0.; 4]);
|
||||
assert_eq!(radius(1, 2).0, [0., 18., 18., 0.]);
|
||||
assert_eq!(radius(0, 1).0, [18.; 4]);
|
||||
assert_eq!(radius(0, 2).0, [18., 0., 0., 18.]);
|
||||
assert!(effective_radii(Style::Capsules, ZonePosition::Start, Edge::Top, [100., 36.], 0, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_flip_mirrors_all_styles_and_zone_positions() {
|
||||
for style in [Style::Floating, Style::Capsules, Style::Notch, Style::Flat] {
|
||||
for zone in [ZonePosition::Start, ZonePosition::Center, ZonePosition::End] {
|
||||
for index in 0..3 {
|
||||
let top = effective_radii(style, zone, Edge::Top, [100., 28.], index, 3)
|
||||
.unwrap()
|
||||
.0;
|
||||
let bottom = effective_radii(style, zone, Edge::Bottom, [100., 28.], index, 3)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(bottom, [top[3], top[2], top[1], top[0]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asymmetric_corners_preserve_notch_shape_and_scale_proportionally() {
|
||||
let notch = effective_radii(Style::Notch, ZonePosition::Center, Edge::Top, [100., 28.], 0, 1).unwrap();
|
||||
assert_eq!(notch.0, [0., 0., 20., 20.]);
|
||||
assert_eq!(notch.normalized(30., 28.).0, [0., 0., 15., 15.]);
|
||||
assert_eq!(
|
||||
CornerRadii([10., 20., 40., 30.]).normalized(35., 25.).0,
|
||||
[25. / 6., 25. / 3., 50. / 3., 12.5]
|
||||
);
|
||||
assert_eq!(CornerRadii::uniform(f32::MAX).normalized(40., 28.).0, [14.; 4]);
|
||||
assert_eq!(notch.normalized(0., 28.).0, [0.; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_boundaries_snap_without_seams_at_fractional_scales() {
|
||||
for scale in [1., 1.25, 1.5, 2.] {
|
||||
let grid = PixelGrid::new(scale).unwrap();
|
||||
for origin in [-1800.3, -0.4, 0., 23.3] {
|
||||
let left = grid.rect(Rect::new(origin, 0., 33.3, 36.)).unwrap();
|
||||
let right = grid.rect(Rect::new(origin + 33.3, 0., 42.7, 36.)).unwrap();
|
||||
assert!((left.x + left.width - right.x).abs() < 1e-9);
|
||||
for boundary in [left.x, left.x + left.width, right.x + right.width] {
|
||||
assert!((boundary * scale - (boundary * scale).round()).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_gaps_and_round_corners_do_not_capture_input() {
|
||||
let grid = PixelGrid::new(1.25).unwrap();
|
||||
let left = RoundedSurface::new(Rect::new(8., 8., 100., 36.), CornerRadii::uniform(18.), grid).unwrap();
|
||||
let right = RoundedSurface::new(Rect::new(116., 8., 100., 36.), CornerRadii::uniform(18.), grid).unwrap();
|
||||
for point in [[0., 0.], [8., 8.], [112., 26.], [216., 26.], [f64::NAN, 26.]] {
|
||||
assert!(!left.contains(point) && !right.contains(point), "{point:?}");
|
||||
}
|
||||
assert!(left.contains([30., 26.]));
|
||||
assert!(right.contains([150., 26.]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asymmetric_hit_testing_matches_a_vertically_mirrored_surface() {
|
||||
let grid = PixelGrid::new(1.).unwrap();
|
||||
let bounds = Rect::new(0., 0., 100., 28.);
|
||||
let top = RoundedSurface::new(bounds, AnchoredRadii([0., 0., 20., 20.]).screen_space(Edge::Top), grid).unwrap();
|
||||
let bottom = RoundedSurface::new(
|
||||
bounds,
|
||||
AnchoredRadii([0., 0., 20., 20.]).screen_space(Edge::Bottom),
|
||||
grid,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(top.contains([1., 1.]));
|
||||
assert!(!top.contains([1., 27.]));
|
||||
for x in 0..100 {
|
||||
for y in 0..28 {
|
||||
assert_eq!(
|
||||
top.contains([x as f64 + 0.5, y as f64 + 0.5]),
|
||||
bottom.contains([x as f64 + 0.5, 27.5 - y as f64])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connected_seam_has_exactly_one_input_owner() {
|
||||
let grid = PixelGrid::new(1.5).unwrap();
|
||||
let left = RoundedSurface::new(Rect::new(0., 0., 30., 36.), CornerRadii([18., 0., 0., 18.]), grid).unwrap();
|
||||
let right = RoundedSurface::new(Rect::new(30., 0., 40., 36.), CornerRadii([0., 18., 18., 0.]), grid).unwrap();
|
||||
for y in [0., 18., 35.9] {
|
||||
assert!(!left.contains([30., y]));
|
||||
assert!(right.contains([30., y]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_runtime_geometry_is_rejected() {
|
||||
for scale in [0., -1., f64::NAN, f64::INFINITY] {
|
||||
assert!(PixelGrid::new(scale).is_err());
|
||||
}
|
||||
let grid = PixelGrid::new(1.).unwrap();
|
||||
for bounds in [
|
||||
Rect::new(0., 0., -1., 28.),
|
||||
Rect::new(f64::NAN, 0., 10., 28.),
|
||||
Rect::new(f64::MAX, 0., f64::MAX, 28.),
|
||||
] {
|
||||
assert!(grid.rect(bounds).is_err());
|
||||
}
|
||||
assert!(RoundedSurface::new(Rect::new(0., 0., 10., 28.), CornerRadii::uniform(-1.), grid).is_err());
|
||||
assert!(effective_radii(Style::Floating, ZonePosition::Start, Edge::Top, [100., f32::NAN], 0, 1).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
//! Generated bindings for Ferese Wayland protocols.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// Semantic surface effects protocol.
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
//! Theme subscriptions and window-scoped material bindings for Ferese clients.
|
||||
//! Applications own subscription cancellation and keep bindings within window lifetimes.
|
||||
pub mod material;
|
||||
pub mod service;
|
||||
@@ -1,4 +1,3 @@
|
||||
//! Resolved theme values and pure color operations. No configuration or runtime access.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
mod color;
|
||||
|
||||
@@ -6,6 +6,8 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ferese-layout = { path = "../ferese-layout" }
|
||||
ferese-panel = { path = "../ferese-panel" }
|
||||
jiff.workspace = true
|
||||
ferese-theme-model = { path = "../ferese-theme-model" }
|
||||
ferese-config = { path = "../ferese-config" }
|
||||
|
||||
@@ -38,6 +38,32 @@ pub fn grid<'a, M: Clone + 'a>(
|
||||
opacity: f32,
|
||||
navigation: [Element<'a, M>; 3],
|
||||
) -> Element<'a, M> {
|
||||
grid_with_density(today, offset, palette, font, opacity, navigation, false)
|
||||
}
|
||||
|
||||
pub fn compact_grid<'a, M: Clone + 'a>(
|
||||
today: jiff::civil::Date,
|
||||
offset: i32,
|
||||
palette: crate::Palette,
|
||||
font: cosmic::font::Font,
|
||||
opacity: f32,
|
||||
navigation: [Element<'a, M>; 3],
|
||||
) -> Element<'a, M> {
|
||||
grid_with_density(today, offset, palette, font, opacity, navigation, true)
|
||||
}
|
||||
|
||||
fn grid_with_density<'a, M: Clone + 'a>(
|
||||
today: jiff::civil::Date,
|
||||
offset: i32,
|
||||
palette: crate::Palette,
|
||||
font: cosmic::font::Font,
|
||||
opacity: f32,
|
||||
navigation: [Element<'a, M>; 3],
|
||||
compact: bool,
|
||||
) -> Element<'a, M> {
|
||||
let cell_width = if compact { Length::Fill } else { Length::Fixed(30.) };
|
||||
let cell_height = if compact { 20. } else { 25. };
|
||||
let spacing = if compact { 2. } else { 3. };
|
||||
let months = [
|
||||
"January",
|
||||
"February",
|
||||
@@ -60,7 +86,7 @@ pub fn grid<'a, M: Clone + 'a>(
|
||||
let [previous, today_button, next] = navigation;
|
||||
let header = row![
|
||||
crate::text(format!("{} {year}", months[(month - 1) as usize]), font)
|
||||
.size(15)
|
||||
.size(if compact { 13 } else { 15 })
|
||||
.width(Length::Fill),
|
||||
previous,
|
||||
today_button,
|
||||
@@ -68,18 +94,31 @@ pub fn grid<'a, M: Clone + 'a>(
|
||||
]
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(2);
|
||||
let mut grid = column::with_capacity(8).spacing(3).push(header);
|
||||
let mut weekdays = row::with_capacity(7).spacing(3);
|
||||
let mut grid = column::with_capacity(8)
|
||||
.spacing(spacing)
|
||||
.width(if compact { Length::Fill } else { Length::Shrink })
|
||||
.push(header);
|
||||
let mut weekdays =
|
||||
row::with_capacity(7)
|
||||
.spacing(spacing)
|
||||
.width(if compact { Length::Fill } else { Length::Shrink });
|
||||
for label in ["M", "T", "W", "T", "F", "S", "S"] {
|
||||
weekdays = weekdays.push(
|
||||
container(crate::text(label, font).size(11).class(theme::Text::Color(muted)))
|
||||
.width(30)
|
||||
.center_x(30),
|
||||
container(
|
||||
crate::text(label, font)
|
||||
.size(if compact { 9 } else { 11 })
|
||||
.class(theme::Text::Color(muted)),
|
||||
)
|
||||
.width(cell_width)
|
||||
.center_x(cell_width),
|
||||
);
|
||||
}
|
||||
grid = grid.push(weekdays);
|
||||
for week in 0..(start + days).div_ceil(7) {
|
||||
let mut dates = row::with_capacity(7).spacing(3);
|
||||
let mut dates =
|
||||
row::with_capacity(7)
|
||||
.spacing(spacing)
|
||||
.width(if compact { Length::Fill } else { Length::Shrink });
|
||||
for weekday in 0..7 {
|
||||
let cell = week * 7 + weekday;
|
||||
let day = cell.checked_sub(start).map(|day| day + 1).filter(|day| *day <= days);
|
||||
@@ -91,10 +130,10 @@ pub fn grid<'a, M: Clone + 'a>(
|
||||
} else {
|
||||
muted
|
||||
})))
|
||||
.width(30)
|
||||
.height(25)
|
||||
.center_x(30)
|
||||
.center_y(25)
|
||||
.width(cell_width)
|
||||
.height(cell_height)
|
||||
.center_x(cell_width)
|
||||
.center_y(cell_height)
|
||||
.class(theme::Container::custom(move |_| container::Style {
|
||||
background: is_today.then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))),
|
||||
border: Border {
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod icons;
|
||||
pub mod menus;
|
||||
mod palette;
|
||||
pub mod panel;
|
||||
pub mod panel_surface;
|
||||
mod typography;
|
||||
pub mod workspaces;
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
|
||||
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
|
||||
use cosmic::{Element, Theme};
|
||||
use ferese_config::panel::layout::{Measurement, Resolution, resolve_with_gap};
|
||||
use ferese_config::panel::{ItemId, Panel, Representation};
|
||||
use ferese_panel::layout::{Measurement, Resolution, resolve_with_gap};
|
||||
use ferese_panel::{ItemId, Panel, Representation};
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Group outlines never paint a second fill over the panel background.
|
||||
@@ -250,8 +250,8 @@ mod tests {
|
||||
use cosmic::iced::advanced::{clipboard, renderer::Headless};
|
||||
use cosmic::iced::{Font, Pixels, Point};
|
||||
use cosmic::widget::{button, text};
|
||||
use ferese_config::panel::OverflowPolicy;
|
||||
use ferese_config::panel::layout::Placement;
|
||||
use ferese_panel::OverflowPolicy;
|
||||
use ferese_panel::layout::Placement;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Probe {
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
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 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::borrow::Cow;
|
||||
use cosmic::iced::{Alignment, Color, Length};
|
||||
use cosmic::widget::{Space, container, row};
|
||||
use cosmic::{Element, theme};
|
||||
use ferese_config::panel::WorkspaceStyle;
|
||||
use ferese_panel::WorkspaceStyle;
|
||||
|
||||
use crate::Palette;
|
||||
|
||||
|
||||
Reference in new issue
Block a user