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
Generated
+17
@@ -1745,6 +1745,7 @@ dependencies = [
|
|||||||
"ferese-core",
|
"ferese-core",
|
||||||
"ferese-ipc",
|
"ferese-ipc",
|
||||||
"ferese-layout",
|
"ferese-layout",
|
||||||
|
"ferese-panel",
|
||||||
"ferese-protocols",
|
"ferese-protocols",
|
||||||
"ferese-shape",
|
"ferese-shape",
|
||||||
"fontdb",
|
"fontdb",
|
||||||
@@ -1776,6 +1777,7 @@ dependencies = [
|
|||||||
name = "ferese-config"
|
name = "ferese-config"
|
||||||
version = "0.1.0-beta"
|
version = "0.1.0-beta"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ferese-panel",
|
||||||
"ferese-theme-model",
|
"ferese-theme-model",
|
||||||
"jiff",
|
"jiff",
|
||||||
"kdl",
|
"kdl",
|
||||||
@@ -1826,6 +1828,16 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ferese-panel"
|
||||||
|
version = "0.1.0-beta"
|
||||||
|
dependencies = [
|
||||||
|
"ferese-animation",
|
||||||
|
"ferese-layout",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ferese-polkit-agent"
|
name = "ferese-polkit-agent"
|
||||||
version = "0.1.0-beta"
|
version = "0.1.0-beta"
|
||||||
@@ -1863,6 +1875,7 @@ version = "0.1.0-beta"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ferese-config",
|
"ferese-config",
|
||||||
"ferese-ipc",
|
"ferese-ipc",
|
||||||
|
"ferese-panel",
|
||||||
"ferese-theme",
|
"ferese-theme",
|
||||||
"ferese-theme-client",
|
"ferese-theme-client",
|
||||||
"iced_accessibility",
|
"iced_accessibility",
|
||||||
@@ -1890,6 +1903,8 @@ dependencies = [
|
|||||||
"ferese-animation",
|
"ferese-animation",
|
||||||
"ferese-config",
|
"ferese-config",
|
||||||
"ferese-ipc",
|
"ferese-ipc",
|
||||||
|
"ferese-layout",
|
||||||
|
"ferese-panel",
|
||||||
"ferese-protocols",
|
"ferese-protocols",
|
||||||
"ferese-theme",
|
"ferese-theme",
|
||||||
"ferese-theme-client",
|
"ferese-theme-client",
|
||||||
@@ -1915,6 +1930,8 @@ name = "ferese-theme"
|
|||||||
version = "0.1.0-beta"
|
version = "0.1.0-beta"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ferese-config",
|
"ferese-config",
|
||||||
|
"ferese-layout",
|
||||||
|
"ferese-panel",
|
||||||
"ferese-theme-model",
|
"ferese-theme-model",
|
||||||
"iced_accessibility",
|
"iced_accessibility",
|
||||||
"jiff",
|
"jiff",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ members = [
|
|||||||
"crates/ferese-theme-model",
|
"crates/ferese-theme-model",
|
||||||
"crates/ferese-ipc",
|
"crates/ferese-ipc",
|
||||||
"crates/ferese-layout",
|
"crates/ferese-layout",
|
||||||
|
"crates/ferese-panel",
|
||||||
"crates/ferese-protocols",
|
"crates/ferese-protocols",
|
||||||
"shell/ferese-shell",
|
"shell/ferese-shell",
|
||||||
"shell/ferese-settings",
|
"shell/ferese-settings",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
Ferese combines a window manager and desktop shell with one configuration.
|
Ferese combines a window manager and desktop shell with one configuration.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
[View more screenshots](docs/screenshots.md)
|
[View more screenshots](docs/screenshots.md)
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ Ferese combines a window manager and desktop shell with one configuration.
|
|||||||
|
|
||||||
- Scrolling columns, window groups, tree tiling and floating windows with saved size and position
|
- Scrolling columns, window groups, tree tiling and floating windows with saved size and position
|
||||||
- Integrated shell with notifications, media controls, Settings and Control Center
|
- Integrated shell with notifications, media controls, Settings and Control Center
|
||||||
- [Custom panels](docs/configuration.md#panel-composition) with editable items, groups, surfaces, and top or bottom placement
|
- [Custom panels](docs/panel-configuration.md) with seven styles, editable groups and items, and top or bottom placement
|
||||||
- Workspace overview and recent-window switching
|
- Workspace overview and recent-window switching
|
||||||
- [Continuum animations](docs/configuration.md#animations): interruptible springs, gesture momentum and shared motion controls
|
- [Continuum animations](docs/configuration.md#animations): interruptible springs, gesture momentum and shared motion controls
|
||||||
- Live wallpapers and blur, custom KDL themes, and Light/Dark/Auto modes
|
- Live wallpapers and blur, custom KDL themes, and Light/Dark/Auto modes
|
||||||
@@ -76,7 +76,8 @@ guide](docs/installation.md) also walks you through previews, updates and recove
|
|||||||
## Configure
|
## Configure
|
||||||
|
|
||||||
Configure Ferese through **Control Center → Settings** or `~/.config/ferese/config.kdl`.
|
Configure Ferese through **Control Center → Settings** or `~/.config/ferese/config.kdl`.
|
||||||
Changes apply live. Invalid edits leave the last working configuration in place.
|
Changes apply live, and if an edit is invalid, the last working configuration stays
|
||||||
|
in place.
|
||||||
|
|
||||||
The [configuration reference](docs/configuration.md) and [example
|
The [configuration reference](docs/configuration.md) and [example
|
||||||
config](packaging/config.kdl) cover appearance, window layouts,
|
config](packaging/config.kdl) cover appearance, window layouts,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ferese-panel = { path = "../crates/ferese-panel" }
|
||||||
ferese-shape.workspace = true
|
ferese-shape.workspace = true
|
||||||
ferese-config = { path = "../crates/ferese-config" }
|
ferese-config = { path = "../crates/ferese-config" }
|
||||||
ab_glyph = "0.2.32"
|
ab_glyph = "0.2.32"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ use crate::window_rules::{WindowRule, WindowRuleConfig};
|
|||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
panels: Option<Vec<ferese_config::panel::Panel>>,
|
panels: Option<Vec<ferese_panel::Panel>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
notifications: ferese_config::notifications::NotificationConfig,
|
notifications: ferese_config::notifications::NotificationConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -114,7 +114,7 @@ impl Config {
|
|||||||
|
|
||||||
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
|
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
|
||||||
if let Some(panels) = &self.panels {
|
if let Some(panels) = &self.panels {
|
||||||
ferese_config::panel::validate(panels).map_err(ConfigError::Binding)?;
|
ferese_panel::validate(panels).map_err(ConfigError::Binding)?;
|
||||||
}
|
}
|
||||||
self.notifications.validate().map_err(ConfigError::Binding)?;
|
self.notifications.validate().map_err(ConfigError::Binding)?;
|
||||||
self.xwayland.validate().map_err(ConfigError::Binding)?;
|
self.xwayland.validate().map_err(ConfigError::Binding)?;
|
||||||
@@ -136,6 +136,11 @@ impl Config {
|
|||||||
bindings,
|
bindings,
|
||||||
window_rules: self.window_rules()?,
|
window_rules: self.window_rules()?,
|
||||||
theme_settings: self.theme_settings()?,
|
theme_settings: self.theme_settings()?,
|
||||||
|
panel_style: self
|
||||||
|
.panels
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|panels| panels.first())
|
||||||
|
.map_or(ferese_panel::PanelStyle::Flat, |panel| panel.style),
|
||||||
panel_corner_radius: self
|
panel_corner_radius: self
|
||||||
.panels
|
.panels
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -458,12 +463,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn panel_surfaces_are_validated_before_live_publication() {
|
fn panel_surfaces_are_validated_before_live_publication() {
|
||||||
for surface in ["none", "inset", "island"] {
|
for style in ferese_panel::PanelStyle::AVAILABLE {
|
||||||
let source = format!(
|
let source = format!(
|
||||||
"panel main {{ surface solid; group-surface {surface}; end {{ group status {{ surface {surface}; }}; }}; }}"
|
"panel main {{ style {}; end {{ group status {{ item clock kind=clock; }}; }}; }}",
|
||||||
|
style.key()
|
||||||
);
|
);
|
||||||
Config::parse_source(&source).unwrap().runtime_config().unwrap();
|
Config::parse_source(&source).unwrap().runtime_config().unwrap();
|
||||||
}
|
}
|
||||||
|
for surface in ["none", "inset", "island"] {
|
||||||
|
let invalid = format!("panel main {{ end {{ group status {{ surface {surface}; }}; }}; }}");
|
||||||
|
assert!(Config::parse_source(&invalid).is_err());
|
||||||
|
}
|
||||||
assert!(Config::parse_source("panel main { group-surface invalid; }").is_err());
|
assert!(Config::parse_source("panel main { group-surface invalid; }").is_err());
|
||||||
assert!(Config::parse_source("panel main { background islands; }").is_err());
|
assert!(Config::parse_source("panel main { background islands; }").is_err());
|
||||||
assert!(Config::parse_source("status { bar-layout islands; }").is_err());
|
assert!(Config::parse_source("status { bar-layout islands; }").is_err());
|
||||||
@@ -817,6 +827,7 @@ mod tests {
|
|||||||
focus_ring_gradient: None,
|
focus_ring_gradient: None,
|
||||||
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
|
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
|
||||||
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
|
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
|
||||||
|
surface_raised_color: RgbaColor([30.0 / 255.0, 37.0 / 255.0, 48.0 / 255.0, 1.0]),
|
||||||
bar_background_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
|
bar_background_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
|
||||||
text_primary_color: RgbaColor([244.0 / 255.0, 247.0 / 255.0, 251.0 / 255.0, 1.0]),
|
text_primary_color: RgbaColor([244.0 / 255.0, 247.0 / 255.0, 251.0 / 255.0, 1.0]),
|
||||||
shell_opacity: 0.78,
|
shell_opacity: 0.78,
|
||||||
@@ -906,11 +917,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn material_color_comes_from_surface_base() {
|
fn material_color_comes_from_surface_base() {
|
||||||
let theme = parse("theme {\n colors {\n surface-base \"#000000\"\n }\n surface {\n bar {\n background \"#FFFFFF\"\n }\n }\n}\n")
|
let theme = parse("theme {\n colors {\n surface-base \"#000000\"\n surface-raised \"#314159\"\n }\n surface {\n bar {\n background \"#FFFFFF\"\n }\n }\n}\n")
|
||||||
.theme_settings()
|
.theme_settings()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(theme.surface_base_color, RgbaColor([0.0, 0.0, 0.0, 1.0]));
|
assert_eq!(theme.surface_base_color, RgbaColor([0.0, 0.0, 0.0, 1.0]));
|
||||||
assert_eq!(theme.bar_background_color, RgbaColor([1.0, 1.0, 1.0, 1.0]));
|
assert_eq!(theme.bar_background_color, RgbaColor([1.0, 1.0, 1.0, 1.0]));
|
||||||
|
assert_eq!(
|
||||||
|
theme.surface_raised_color,
|
||||||
|
RgbaColor([49. / 255., 65. / 255., 89. / 255., 1.])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ impl Config {
|
|||||||
focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?,
|
focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?,
|
||||||
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
|
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
|
||||||
surface_base_color: parse_color(&self.theme.colors.surface_base, "colors.surface_base")?,
|
surface_base_color: parse_color(&self.theme.colors.surface_base, "colors.surface_base")?,
|
||||||
|
surface_raised_color: parse_color(&self.theme.colors.surface_raised, "colors.surface_raised")?,
|
||||||
bar_background_color: parse_color(
|
bar_background_color: parse_color(
|
||||||
self.theme
|
self.theme
|
||||||
.surface
|
.surface
|
||||||
@@ -141,6 +142,10 @@ pub(super) fn default_surface_base_color() -> String {
|
|||||||
"#111821".to_owned()
|
"#111821".to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn default_surface_raised_color() -> String {
|
||||||
|
ferese_config::theme::default_tokens().colors.surface_raised
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn default_accent_color() -> String {
|
pub(super) fn default_accent_color() -> String {
|
||||||
"#3D7BE6".to_owned()
|
"#3D7BE6".to_owned()
|
||||||
}
|
}
|
||||||
@@ -215,6 +220,7 @@ pub struct ThemeSettings {
|
|||||||
pub focus_ring_gradient: Option<BorderGradient>,
|
pub focus_ring_gradient: Option<BorderGradient>,
|
||||||
pub shadow_color: RgbaColor,
|
pub shadow_color: RgbaColor,
|
||||||
pub surface_base_color: RgbaColor,
|
pub surface_base_color: RgbaColor,
|
||||||
|
pub surface_raised_color: RgbaColor,
|
||||||
pub bar_background_color: RgbaColor,
|
pub bar_background_color: RgbaColor,
|
||||||
pub text_primary_color: RgbaColor,
|
pub text_primary_color: RgbaColor,
|
||||||
pub shell_opacity: f64,
|
pub shell_opacity: f64,
|
||||||
@@ -355,6 +361,8 @@ pub(super) struct ThemeColorsConfig {
|
|||||||
pub(super) text_primary: String,
|
pub(super) text_primary: String,
|
||||||
#[serde(default = "default_surface_base_color")]
|
#[serde(default = "default_surface_base_color")]
|
||||||
pub(super) surface_base: String,
|
pub(super) surface_base: String,
|
||||||
|
#[serde(default = "default_surface_raised_color")]
|
||||||
|
pub(super) surface_raised: String,
|
||||||
#[serde(default = "default_border_color")]
|
#[serde(default = "default_border_color")]
|
||||||
pub(super) border: String,
|
pub(super) border: String,
|
||||||
#[serde(default = "default_accent_color")]
|
#[serde(default = "default_accent_color")]
|
||||||
@@ -367,6 +375,7 @@ impl Default for ThemeColorsConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
surface_base: default_surface_base_color(),
|
surface_base: default_surface_base_color(),
|
||||||
|
surface_raised: default_surface_raised_color(),
|
||||||
text_primary: default_text_primary_color(),
|
text_primary: default_text_primary_color(),
|
||||||
border: default_border_color(),
|
border: default_border_color(),
|
||||||
accent: default_accent_color(),
|
accent: default_accent_color(),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Session-owned services. Never grant shell/effects privileges to daemons.
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::process::Child;
|
use std::process::Child;
|
||||||
|
|||||||
+150
-2
@@ -51,8 +51,15 @@ pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle, opacity
|
|||||||
ResolvedMaterial { style, opacity, shadow }
|
ResolvedMaterial { style, opacity, shadow }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) struct PanelPresentation {
|
||||||
|
pub shapes: std::sync::Arc<[ferese_panel::scene::Primitive]>,
|
||||||
|
pub fusion: f64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct PendingPresentation {
|
struct PendingPresentation {
|
||||||
|
panel: Option<PanelPresentation>,
|
||||||
role: SemanticRole,
|
role: SemanticRole,
|
||||||
regions: Vec<[f64; 5]>,
|
regions: Vec<[f64; 5]>,
|
||||||
opacity: u32,
|
opacity: u32,
|
||||||
@@ -67,6 +74,7 @@ struct SurfaceEffectsState {
|
|||||||
regions: Mutex<Option<Vec<[f64; 5]>>>,
|
regions: Mutex<Option<Vec<[f64; 5]>>>,
|
||||||
opacity: Mutex<u32>,
|
opacity: Mutex<u32>,
|
||||||
pending: Mutex<Option<PendingPresentation>>,
|
pending: Mutex<Option<PendingPresentation>>,
|
||||||
|
panel: Mutex<Option<PanelPresentation>>,
|
||||||
region_opacities: Mutex<Vec<u32>>,
|
region_opacities: Mutex<Vec<u32>>,
|
||||||
presentation_supported: AtomicBool,
|
presentation_supported: AtomicBool,
|
||||||
dismissing: AtomicBool,
|
dismissing: AtomicBool,
|
||||||
@@ -81,6 +89,7 @@ impl SurfaceEffectsState {
|
|||||||
regions: Mutex::new(None),
|
regions: Mutex::new(None),
|
||||||
opacity: Mutex::new(1000),
|
opacity: Mutex::new(1000),
|
||||||
pending: Mutex::new(None),
|
pending: Mutex::new(None),
|
||||||
|
panel: Mutex::new(None),
|
||||||
region_opacities: Mutex::new(Vec::new()),
|
region_opacities: Mutex::new(Vec::new()),
|
||||||
presentation_supported: AtomicBool::new(false),
|
presentation_supported: AtomicBool::new(false),
|
||||||
dismissing: AtomicBool::new(false),
|
dismissing: AtomicBool::new(false),
|
||||||
@@ -92,6 +101,7 @@ impl SurfaceEffectsState {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
*self.role.lock().unwrap() = Some(pending.role);
|
*self.role.lock().unwrap() = Some(pending.role);
|
||||||
|
*self.panel.lock().unwrap() = pending.panel;
|
||||||
*self.regions.lock().unwrap() = Some(pending.regions);
|
*self.regions.lock().unwrap() = Some(pending.regions);
|
||||||
*self.region_opacities.lock().unwrap() = pending.region_opacities;
|
*self.region_opacities.lock().unwrap() = pending.region_opacities;
|
||||||
if !self.dismissing.load(Ordering::Acquire) {
|
if !self.dismissing.load(Ordering::Acquire) {
|
||||||
@@ -101,6 +111,7 @@ impl SurfaceEffectsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_role(&self, role: Option<SemanticRole>) -> bool {
|
fn set_role(&self, role: Option<SemanticRole>) -> bool {
|
||||||
|
self.pending.lock().unwrap().take();
|
||||||
let mut current = self.role.lock().unwrap();
|
let mut current = self.role.lock().unwrap();
|
||||||
if *current != role {
|
if *current != role {
|
||||||
*current = role;
|
*current = role;
|
||||||
@@ -126,7 +137,7 @@ impl SurfaceEffectsUserData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||||
display.create_global::<Ferese, FereseEffectsManagerV1, _>(5, ());
|
display.create_global::<Ferese, FereseEffectsManagerV1, _>(6, ());
|
||||||
display.create_global::<Ferese, FereseMaterialManagerV1, _>(1, ());
|
display.create_global::<Ferese, FereseMaterialManagerV1, _>(1, ());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +239,44 @@ pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[f64; 5]>> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn surface_panel_presentation(surface: &WlSurface) -> Option<PanelPresentation> {
|
||||||
|
with_states(surface, |states| {
|
||||||
|
states
|
||||||
|
.data_map
|
||||||
|
.get::<SurfaceEffectsState>()?
|
||||||
|
.panel
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_panel_shapes(bytes: &[u8], fusion: f64) -> Option<PanelPresentation> {
|
||||||
|
if !bytes.len().is_multiple_of(40)
|
||||||
|
|| bytes.len() > ferese_panel::scene::MAX_PRIMITIVES * 40
|
||||||
|
|| !fusion.is_finite()
|
||||||
|
|| !(0. ..=256.).contains(&fusion)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let shapes: Vec<_> = bytes
|
||||||
|
.as_chunks::<40>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|tuple| {
|
||||||
|
let values = std::array::from_fn(|i| f32::from_ne_bytes(tuple.as_chunks::<4>().0[i]));
|
||||||
|
ferese_panel::scene::Primitive::from_packet(values).ok()
|
||||||
|
})
|
||||||
|
.collect::<Option<_>>()?;
|
||||||
|
if shapes.iter().filter(|p| p.operation == 1).count() > 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PanelPresentation {
|
||||||
|
shapes: shapes.into(),
|
||||||
|
fusion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn surface_region_opacities(surface: &WlSurface) -> Vec<u32> {
|
pub(crate) fn surface_region_opacities(surface: &WlSurface) -> Vec<u32> {
|
||||||
with_states(surface, |states| {
|
with_states(surface, |states| {
|
||||||
states
|
states
|
||||||
@@ -361,6 +410,31 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match request {
|
match request {
|
||||||
|
ferese_surface_effects_v1::Request::SetPanelPresentation {
|
||||||
|
regions,
|
||||||
|
shapes,
|
||||||
|
fusion,
|
||||||
|
opacity,
|
||||||
|
} => {
|
||||||
|
let (Some(regions), Some(panel)) = (decode_regions(®ions), decode_panel_shapes(&shapes, fusion))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if panel.shapes.is_empty() != regions.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
with_states(&surface, |states| {
|
||||||
|
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
|
||||||
|
*effects.pending.lock().unwrap() = Some(PendingPresentation {
|
||||||
|
panel: Some(panel),
|
||||||
|
role: SemanticRole::Panel,
|
||||||
|
region_opacities: vec![1000; regions.len()],
|
||||||
|
regions,
|
||||||
|
opacity: opacity.min(1000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
ferese_surface_effects_v1::Request::SetPresentation {
|
ferese_surface_effects_v1::Request::SetPresentation {
|
||||||
role,
|
role,
|
||||||
regions,
|
regions,
|
||||||
@@ -377,6 +451,7 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
|
|||||||
with_states(&surface, |states| {
|
with_states(&surface, |states| {
|
||||||
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
|
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
|
||||||
*effects.pending.lock().unwrap() = Some(PendingPresentation {
|
*effects.pending.lock().unwrap() = Some(PendingPresentation {
|
||||||
|
panel: None,
|
||||||
role,
|
role,
|
||||||
regions,
|
regions,
|
||||||
opacity: opacity.min(1000),
|
opacity: opacity.min(1000),
|
||||||
@@ -510,6 +585,7 @@ fn detach(surface: &WlSurface) -> bool {
|
|||||||
let role_changed = effects.set_role(None);
|
let role_changed = effects.set_role(None);
|
||||||
effects.region_opacities.lock().unwrap().clear();
|
effects.region_opacities.lock().unwrap().clear();
|
||||||
effects.pending.lock().unwrap().take();
|
effects.pending.lock().unwrap().take();
|
||||||
|
effects.panel.lock().unwrap().take();
|
||||||
effects.dismissing.store(false, Ordering::Release);
|
effects.dismissing.store(false, Ordering::Release);
|
||||||
let mut opacity = effects.opacity.lock().unwrap();
|
let mut opacity = effects.opacity.lock().unwrap();
|
||||||
let opacity_changed = *opacity != 1000;
|
let opacity_changed = *opacity != 1000;
|
||||||
@@ -551,10 +627,82 @@ pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn shape_bytes(operation: f32) -> Vec<u8> {
|
||||||
|
[1.25f32, 2.5, 100., 36., 0., 0., 18., 18., operation, 0.]
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(f32::to_ne_bytes)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_shapes_reject_malformed_packets_and_duplicate_cutouts() {
|
||||||
|
let bytes = shape_bytes(0.);
|
||||||
|
let decoded = decode_panel_shapes(&bytes, 12.).unwrap();
|
||||||
|
assert_eq!(decoded.shapes.len(), 1);
|
||||||
|
assert_eq!(decoded.shapes[0].bounds.x, 1.25);
|
||||||
|
assert_eq!(decoded.fusion, 12.);
|
||||||
|
assert!(decode_panel_shapes(&[], 0.).unwrap().shapes.is_empty());
|
||||||
|
assert!(decode_panel_shapes(&bytes[..39], 0.).is_none());
|
||||||
|
assert!(decode_panel_shapes(&bytes.repeat(33), 0.).is_none());
|
||||||
|
assert!(decode_panel_shapes(&shape_bytes(1.).repeat(2), 0.).is_none());
|
||||||
|
for invalid in [f64::NAN, f64::INFINITY, -1., 257.] {
|
||||||
|
assert!(decode_panel_shapes(&bytes, invalid).is_none());
|
||||||
|
}
|
||||||
|
for invalid in [f32::NAN, 0.5, 6.] {
|
||||||
|
assert!(decode_panel_shapes(&shape_bytes(invalid), 0.).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_shape_and_capture_regions_commit_together_and_clear_for_popovers() {
|
||||||
|
let state = SurfaceEffectsState::new();
|
||||||
|
*state.pending.lock().unwrap() = Some(PendingPresentation {
|
||||||
|
panel: decode_panel_shapes(&shape_bytes(0.), 12.),
|
||||||
|
role: SemanticRole::Panel,
|
||||||
|
regions: vec![[1.25, 2.5, 100., 36., 0.]],
|
||||||
|
opacity: 1000,
|
||||||
|
region_opacities: vec![1000],
|
||||||
|
});
|
||||||
|
assert!(state.panel.lock().unwrap().is_none());
|
||||||
|
assert!(state.regions.lock().unwrap().is_none());
|
||||||
|
state.commit_presentation();
|
||||||
|
assert_eq!(state.panel.lock().unwrap().as_ref().unwrap().fusion, 12.);
|
||||||
|
assert_eq!(state.regions.lock().unwrap().as_ref().unwrap().len(), 1);
|
||||||
|
*state.pending.lock().unwrap() = Some(PendingPresentation {
|
||||||
|
panel: None,
|
||||||
|
role: SemanticRole::Popover,
|
||||||
|
regions: vec![[0., 0., 200., 100., 14.]],
|
||||||
|
opacity: 800,
|
||||||
|
region_opacities: vec![1000],
|
||||||
|
});
|
||||||
|
state.commit_presentation();
|
||||||
|
assert!(state.panel.lock().unwrap().is_none());
|
||||||
|
assert_eq!(*state.role.lock().unwrap(), Some(SemanticRole::Popover));
|
||||||
|
assert_eq!(*state.opacity.lock().unwrap(), 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hiding_a_panel_cancels_its_queued_presentation() {
|
||||||
|
let state = SurfaceEffectsState::new();
|
||||||
|
state.set_role(Some(SemanticRole::Panel));
|
||||||
|
*state.pending.lock().unwrap() = Some(PendingPresentation {
|
||||||
|
panel: decode_panel_shapes(&shape_bytes(0.), 0.),
|
||||||
|
role: SemanticRole::Panel,
|
||||||
|
regions: vec![[0., 0., 100., 36., 0.]],
|
||||||
|
opacity: 1000,
|
||||||
|
region_opacities: vec![1000],
|
||||||
|
});
|
||||||
|
state.set_role(None);
|
||||||
|
state.commit_presentation();
|
||||||
|
assert_eq!(*state.role.lock().unwrap(), None);
|
||||||
|
assert!(state.panel.lock().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn presentation_waits_for_buffer_commit_and_keeps_dismissal_ownership() {
|
fn presentation_waits_for_buffer_commit_and_keeps_dismissal_ownership() {
|
||||||
let state = SurfaceEffectsState::new();
|
let state = SurfaceEffectsState::new();
|
||||||
let queue = |opacity| PendingPresentation {
|
let queue = |opacity| PendingPresentation {
|
||||||
|
panel: None,
|
||||||
role: SemanticRole::Popover,
|
role: SemanticRole::Popover,
|
||||||
regions: vec![[1.25, 2.5, 100.0, 80.0, 12.0]],
|
regions: vec![[1.25, 2.5, 100.0, 80.0, 12.0]],
|
||||||
opacity,
|
opacity,
|
||||||
@@ -618,7 +766,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn maximum_panel_groups_and_overflow_fit_one_effects_publication() {
|
fn maximum_panel_groups_and_overflow_fit_one_effects_publication() {
|
||||||
let count = ferese_config::panel::MAX_GROUPS + 1;
|
let count = ferese_panel::MAX_GROUPS + 1;
|
||||||
assert_eq!(count, ferese_protocols::effects::v1::MAX_REGIONS);
|
assert_eq!(count, ferese_protocols::effects::v1::MAX_REGIONS);
|
||||||
let region = [0.0f32, 0., 12., 36., 4.];
|
let region = [0.0f32, 0., 12., 36., 4.];
|
||||||
let bytes: Vec<_> = region.into_iter().flat_map(f32::to_ne_bytes).collect();
|
let bytes: Vec<_> = region.into_iter().flat_map(f32::to_ne_bytes).collect();
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ pub(super) fn grouped_elements(
|
|||||||
output,
|
output,
|
||||||
surface,
|
surface,
|
||||||
MaterialSurface {
|
MaterialSurface {
|
||||||
|
shape_origin: constrain.loc,
|
||||||
geometry: constrain,
|
geometry: constrain,
|
||||||
corners,
|
corners,
|
||||||
index: 0,
|
index: 0,
|
||||||
|
|||||||
@@ -286,6 +286,105 @@ fn rasterize(renderer: &mut GlesRenderer, program: &GlesPixelProgram, uniforms:
|
|||||||
renderer.map_texture(&mapping).unwrap().to_vec()
|
renderer.map_texture(&mapping).unwrap().to_vec()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires an EGL rendering device"]
|
||||||
|
fn panel_shader_matches_core_outlines_and_resets_for_popovers() {
|
||||||
|
use ferese_panel::scene::{GroupBounds, Parameters, Scene, derive};
|
||||||
|
use ferese_panel::shape::{PixelGrid, Style, ZonePosition};
|
||||||
|
use smithay::backend::renderer::gles::UniformValue;
|
||||||
|
let mut renderer = renderer();
|
||||||
|
let mut resources = RenderResources::default();
|
||||||
|
let program = material_program_for_corners(&mut resources, &mut renderer, CornerShape::Circular).unwrap();
|
||||||
|
let bounds = ferese_layout::Rect::new(0., 0., 64., 64.);
|
||||||
|
let groups = [
|
||||||
|
GroupBounds {
|
||||||
|
zone: ZonePosition::Start,
|
||||||
|
bounds: ferese_layout::Rect::new(3., 0., 18., 16.),
|
||||||
|
},
|
||||||
|
GroupBounds {
|
||||||
|
zone: ZonePosition::Start,
|
||||||
|
bounds: ferese_layout::Rect::new(25., 0., 18., 16.),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut scenes = Vec::new();
|
||||||
|
for style in [
|
||||||
|
Style::Floating,
|
||||||
|
Style::Capsules,
|
||||||
|
Style::Notch,
|
||||||
|
Style::Fused,
|
||||||
|
Style::Morph,
|
||||||
|
Style::Flat,
|
||||||
|
Style::Frame,
|
||||||
|
Style::CornerWrap,
|
||||||
|
] {
|
||||||
|
for edge in [ferese_panel::Edge::Top, ferese_panel::Edge::Bottom] {
|
||||||
|
for scale in [1., 1.25, 1.5] {
|
||||||
|
scenes.push(
|
||||||
|
derive(
|
||||||
|
Parameters {
|
||||||
|
style,
|
||||||
|
edge,
|
||||||
|
height: 16.,
|
||||||
|
gap: 4.,
|
||||||
|
frame_insets: [16., 4., 4., 4.],
|
||||||
|
frame_radius: 10.,
|
||||||
|
wrap_size: [40., 42.],
|
||||||
|
wrap_width: 8.,
|
||||||
|
fillet: 6.,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
bounds,
|
||||||
|
&groups,
|
||||||
|
PixelGrid::new(scale).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for scene in scenes.iter().chain(std::iter::once(&Scene::default())) {
|
||||||
|
let matrices = scene
|
||||||
|
.packets()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| [p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7], p[8] * 4. + p[9]])
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut uniforms = vec![
|
||||||
|
Uniform::new("visible_rect", [0.0f32, 0., 64., 64.]),
|
||||||
|
Uniform::new("material_radii", [12.0f32; 4]),
|
||||||
|
Uniform::new("tint", [1.0f32; 4]),
|
||||||
|
Uniform::new("paint_mode", 0.0f32),
|
||||||
|
Uniform::new("panel_count", matrices.len() as f32),
|
||||||
|
Uniform::new("panel_fusion", scene.fusion as f32),
|
||||||
|
];
|
||||||
|
if !matrices.is_empty() {
|
||||||
|
uniforms.push(Uniform::new(
|
||||||
|
"panel_shapes[0]",
|
||||||
|
UniformValue::Matrix3x3 {
|
||||||
|
matrices,
|
||||||
|
transpose: false,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let pixels = rasterize(&mut renderer, &program.0, &uniforms);
|
||||||
|
for y in 0..64 {
|
||||||
|
for x in 0..64 {
|
||||||
|
let point = [x as f64 + 0.5, y as f64 + 0.5];
|
||||||
|
let distance = if scene.paint.is_empty() {
|
||||||
|
reference_distance(point, [0., 0., 64., 64.], 12., CornerShape::Circular)
|
||||||
|
} else {
|
||||||
|
scene.distance(point)
|
||||||
|
};
|
||||||
|
let expected = (coverage(distance) * 255.).round() as u8;
|
||||||
|
let actual = pixels[(y * 64 + x) * 4 + 3];
|
||||||
|
assert!(
|
||||||
|
actual.abs_diff(expected) <= 2,
|
||||||
|
"panel mask mismatch at ({x},{y}): {actual} != {expected}, {scene:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "requires an EGL rendering device"]
|
#[ignore = "requires an EGL rendering device"]
|
||||||
fn apple_window_fill_and_inset_border_match_path_reference_at_fractional_scales() {
|
fn apple_window_fill_and_inset_border_match_path_reference_at_fractional_scales() {
|
||||||
@@ -412,10 +511,8 @@ fn docked_panel_fill_covers_the_entire_attached_edge() {
|
|||||||
let mut renderer = renderer();
|
let mut renderer = renderer();
|
||||||
let mut resources = RenderResources::default();
|
let mut resources = RenderResources::default();
|
||||||
let material = material_program_for_corners(&mut resources, &mut renderer, CornerShape::Circular).unwrap();
|
let material = material_program_for_corners(&mut resources, &mut renderer, CornerShape::Circular).unwrap();
|
||||||
for edge in [ferese_config::panel::Edge::Top, ferese_config::panel::Edge::Bottom] {
|
for edge in [ferese_panel::Edge::Top, ferese_panel::Edge::Bottom] {
|
||||||
let radii = ferese_config::panel::CornerRadii([14., 8., 12., 20.])
|
let radii = ferese_panel::CornerRadii([14., 8., 12., 20.]).at_edge(edge, true).0;
|
||||||
.at_edge(edge, true)
|
|
||||||
.0;
|
|
||||||
let pixels = rasterize(
|
let pixels = rasterize(
|
||||||
&mut renderer,
|
&mut renderer,
|
||||||
&material.0,
|
&material.0,
|
||||||
@@ -428,7 +525,7 @@ fn docked_panel_fill_covers_the_entire_attached_edge() {
|
|||||||
Uniform::new("shadow_values", [8.0f32, 1.0]),
|
Uniform::new("shadow_values", [8.0f32, 1.0]),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let (attached_row, free_row) = if edge == ferese_config::panel::Edge::Top {
|
let (attached_row, free_row) = if edge == ferese_panel::Edge::Top {
|
||||||
(0, 35)
|
(0, 35)
|
||||||
} else {
|
} else {
|
||||||
(35, 0)
|
(35, 0)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub(super) fn append_material_surface(
|
|||||||
output,
|
output,
|
||||||
surface,
|
surface,
|
||||||
MaterialSurface {
|
MaterialSurface {
|
||||||
|
shape_origin: content_origin,
|
||||||
geometry: *rect,
|
geometry: *rect,
|
||||||
corners: *corners,
|
corners: *corners,
|
||||||
index,
|
index,
|
||||||
@@ -148,6 +149,7 @@ fn framebuffer_corner_radii(radii: [f32; 4], transform: Transform) -> [f32; 4] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct MaterialSurface {
|
pub(super) struct MaterialSurface {
|
||||||
|
pub shape_origin: Point<i32, Logical>,
|
||||||
pub geometry: Rectangle<i32, Logical>,
|
pub geometry: Rectangle<i32, Logical>,
|
||||||
pub corners: RoundedRect,
|
pub corners: RoundedRect,
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
@@ -177,6 +179,7 @@ fn resolve_surface_material(
|
|||||||
role: crate::effects::SemanticRole,
|
role: crate::effects::SemanticRole,
|
||||||
settings: crate::config::ThemeSettings,
|
settings: crate::config::ThemeSettings,
|
||||||
panel_opacity: Option<f32>,
|
panel_opacity: Option<f32>,
|
||||||
|
panel_style: ferese_panel::PanelStyle,
|
||||||
) -> crate::effects::ResolvedMaterial {
|
) -> crate::effects::ResolvedMaterial {
|
||||||
let mut material = crate::effects::resolve_material(role, settings.material_style, settings.shell_opacity as f32);
|
let mut material = crate::effects::resolve_material(role, settings.material_style, settings.shell_opacity as f32);
|
||||||
if role == crate::effects::SemanticRole::Panel {
|
if role == crate::effects::SemanticRole::Panel {
|
||||||
@@ -185,6 +188,9 @@ fn resolve_surface_material(
|
|||||||
panel_opacity,
|
panel_opacity,
|
||||||
settings.reduce_transparency,
|
settings.reduce_transparency,
|
||||||
);
|
);
|
||||||
|
if panel_style == ferese_panel::PanelStyle::Morph {
|
||||||
|
material.shadow = [0.; 3];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
material
|
material
|
||||||
}
|
}
|
||||||
@@ -198,13 +204,19 @@ pub(super) fn material_element_with_role(
|
|||||||
(role, generation, opacity): (crate::effects::SemanticRole, u64, f32),
|
(role, generation, opacity): (crate::effects::SemanticRole, u64, f32),
|
||||||
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
|
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
|
||||||
let MaterialSurface {
|
let MaterialSurface {
|
||||||
|
shape_origin,
|
||||||
geometry,
|
geometry,
|
||||||
corners,
|
corners,
|
||||||
index,
|
index,
|
||||||
capture_geometry,
|
capture_geometry,
|
||||||
alpha,
|
alpha,
|
||||||
} = surface_geometry;
|
} = surface_geometry;
|
||||||
let material = resolve_surface_material(role, state.theme_settings, state.panel_background_opacity);
|
let material = resolve_surface_material(
|
||||||
|
role,
|
||||||
|
state.theme_settings,
|
||||||
|
state.panel_background_opacity,
|
||||||
|
state.panel_style,
|
||||||
|
);
|
||||||
let presentation_alpha = opacity * alpha;
|
let presentation_alpha = opacity * alpha;
|
||||||
let mode = output.current_mode()?;
|
let mode = output.current_mode()?;
|
||||||
let scale = output.current_scale().fractional_scale();
|
let scale = output.current_scale().fractional_scale();
|
||||||
@@ -214,6 +226,12 @@ pub(super) fn material_element_with_role(
|
|||||||
} else {
|
} else {
|
||||||
state.theme_settings.surface_base_color
|
state.theme_settings.surface_base_color
|
||||||
};
|
};
|
||||||
|
let morph = role == crate::effects::SemanticRole::Panel && state.panel_style == ferese_panel::PanelStyle::Morph;
|
||||||
|
let background = if morph {
|
||||||
|
state.theme_settings.surface_raised_color
|
||||||
|
} else {
|
||||||
|
background
|
||||||
|
};
|
||||||
let [red, green, blue, _] = background.0;
|
let [red, green, blue, _] = background.0;
|
||||||
let logical_radii = if role == crate::effects::SemanticRole::Panel {
|
let logical_radii = if role == crate::effects::SemanticRole::Panel {
|
||||||
state.panel_corner_radius
|
state.panel_corner_radius
|
||||||
@@ -234,9 +252,9 @@ pub(super) fn material_element_with_role(
|
|||||||
let touches_top = role == crate::effects::SemanticRole::Panel && geometry.loc.y == 0;
|
let touches_top = role == crate::effects::SemanticRole::Panel && geometry.loc.y == 0;
|
||||||
let touches_bottom =
|
let touches_bottom =
|
||||||
role == crate::effects::SemanticRole::Panel && geometry.loc.y + geometry.size.h >= output_size.h;
|
role == crate::effects::SemanticRole::Panel && geometry.loc.y + geometry.size.h >= output_size.h;
|
||||||
let radii = ferese_config::panel::CornerRadii(radii)
|
let radii = ferese_panel::CornerRadii(radii)
|
||||||
.at_top_edge(touches_top)
|
.at_top_edge(touches_top)
|
||||||
.at_edge(ferese_config::panel::Edge::Bottom, touches_bottom)
|
.at_edge(ferese_panel::Edge::Bottom, touches_bottom)
|
||||||
.0;
|
.0;
|
||||||
let radius = radii.into_iter().fold(0., f32::max);
|
let radius = radii.into_iter().fold(0., f32::max);
|
||||||
let corner_radii = framebuffer_corner_radii(radii, transform);
|
let corner_radii = framebuffer_corner_radii(radii, transform);
|
||||||
@@ -264,7 +282,13 @@ pub(super) fn material_element_with_role(
|
|||||||
let blur = material_blur_radius(material.style, material.opacity, state.theme_settings.backdrop_blur);
|
let blur = material_blur_radius(material.style, material.opacity, state.theme_settings.backdrop_blur);
|
||||||
let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size);
|
let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size);
|
||||||
let sample_physical = sample_geometry.to_physical_precise_round(scale);
|
let sample_physical = sample_geometry.to_physical_precise_round(scale);
|
||||||
|
let panel = (role == crate::effects::SemanticRole::Panel)
|
||||||
|
.then(|| crate::effects::surface_panel_presentation(surface))
|
||||||
|
.flatten()
|
||||||
|
.map(|presentation| panel_matrices(&presentation, shape_origin, scale, mode.size, transform));
|
||||||
|
let opaque = panel.is_none() && material.opacity == 1.0 && presentation_alpha == 1.0 && radius == 0.0;
|
||||||
let mut parameters = MaterialParameters {
|
let mut parameters = MaterialParameters {
|
||||||
|
panel,
|
||||||
corner_radii,
|
corner_radii,
|
||||||
presentation_alpha,
|
presentation_alpha,
|
||||||
background_opacity,
|
background_opacity,
|
||||||
@@ -278,7 +302,7 @@ pub(super) fn material_element_with_role(
|
|||||||
geometry,
|
geometry,
|
||||||
tint: [red, green, blue, material.opacity * presentation_alpha],
|
tint: [red, green, blue, material.opacity * presentation_alpha],
|
||||||
generation,
|
generation,
|
||||||
opaque: material.opacity == 1.0 && presentation_alpha == 1.0 && radius == 0.0,
|
opaque,
|
||||||
visible_framebuffer: framebuffer_clip_rect(corners.rect, mode.size, transform),
|
visible_framebuffer: framebuffer_clip_rect(corners.rect, mode.size, transform),
|
||||||
};
|
};
|
||||||
let context = renderer.context_id().erased();
|
let context = renderer.context_id().erased();
|
||||||
@@ -377,6 +401,7 @@ pub(super) fn material_element_with_role(
|
|||||||
|| cached.parameters.shadow_bounds != parameters.shadow_bounds
|
|| cached.parameters.shadow_bounds != parameters.shadow_bounds
|
||||||
|| cached.parameters.corner_radii != parameters.corner_radii
|
|| cached.parameters.corner_radii != parameters.corner_radii
|
||||||
|| cached.parameters.visible_framebuffer != parameters.visible_framebuffer
|
|| cached.parameters.visible_framebuffer != parameters.visible_framebuffer
|
||||||
|
|| cached.parameters.panel != parameters.panel
|
||||||
{
|
{
|
||||||
cached.shadow.resize(parameters.shadow_bounds, None);
|
cached.shadow.resize(parameters.shadow_bounds, None);
|
||||||
cached.shadow.update_uniforms(decoration_uniforms(¶meters, 2.0));
|
cached.shadow.update_uniforms(decoration_uniforms(¶meters, 2.0));
|
||||||
@@ -437,8 +462,8 @@ pub(super) fn blur_program(resources: &mut RenderResources, renderer: &mut GlesR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn blur_uniform_names() -> [UniformName<'static>; 8] {
|
pub(super) fn blur_uniform_names() -> Vec<UniformName<'static>> {
|
||||||
[
|
let mut names = vec![
|
||||||
UniformName::new("visible_rect", UniformType::_4f),
|
UniformName::new("visible_rect", UniformType::_4f),
|
||||||
UniformName::new("material_radii", UniformType::_4f),
|
UniformName::new("material_radii", UniformType::_4f),
|
||||||
UniformName::new("texture_size", UniformType::_2f),
|
UniformName::new("texture_size", UniformType::_2f),
|
||||||
@@ -447,11 +472,13 @@ pub(super) fn blur_uniform_names() -> [UniformName<'static>; 8] {
|
|||||||
UniformName::new("presentation_alpha", UniformType::_1f),
|
UniformName::new("presentation_alpha", UniformType::_1f),
|
||||||
UniformName::new("background_opacity", UniformType::_1f),
|
UniformName::new("background_opacity", UniformType::_1f),
|
||||||
UniformName::new("tint", UniformType::_4f),
|
UniformName::new("tint", UniformType::_4f),
|
||||||
]
|
];
|
||||||
|
names.extend(panel_uniform_names());
|
||||||
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
|
pub(super) fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
|
||||||
vec![
|
let mut uniforms = vec![
|
||||||
Uniform::new("visible_rect", p.visible_framebuffer),
|
Uniform::new("visible_rect", p.visible_framebuffer),
|
||||||
Uniform::new("material_radii", p.corner_radii),
|
Uniform::new("material_radii", p.corner_radii),
|
||||||
Uniform::new("texture_size", [p.sample_framebuffer[2], p.sample_framebuffer[3]]),
|
Uniform::new("texture_size", [p.sample_framebuffer[2], p.sample_framebuffer[3]]),
|
||||||
@@ -460,7 +487,9 @@ pub(super) fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
|
|||||||
Uniform::new("presentation_alpha", p.presentation_alpha),
|
Uniform::new("presentation_alpha", p.presentation_alpha),
|
||||||
Uniform::new("background_opacity", p.background_opacity),
|
Uniform::new("background_opacity", p.background_opacity),
|
||||||
Uniform::new("tint", p.tint),
|
Uniform::new("tint", p.tint),
|
||||||
]
|
];
|
||||||
|
uniforms.extend(panel_uniforms(p));
|
||||||
|
uniforms
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] {
|
pub(super) fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] {
|
||||||
@@ -502,7 +531,7 @@ pub(super) fn material_program_for_corners(
|
|||||||
return Some(program.clone());
|
return Some(program.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let uniforms = [
|
let mut uniforms = vec![
|
||||||
UniformName::new("visible_rect", UniformType::_4f),
|
UniformName::new("visible_rect", UniformType::_4f),
|
||||||
UniformName::new("material_radii", UniformType::_4f),
|
UniformName::new("material_radii", UniformType::_4f),
|
||||||
UniformName::new("tint", UniformType::_4f),
|
UniformName::new("tint", UniformType::_4f),
|
||||||
@@ -510,6 +539,7 @@ pub(super) fn material_program_for_corners(
|
|||||||
UniformName::new("shadow_rect", UniformType::_4f),
|
UniformName::new("shadow_rect", UniformType::_4f),
|
||||||
UniformName::new("shadow_values", UniformType::_2f),
|
UniformName::new("shadow_values", UniformType::_2f),
|
||||||
];
|
];
|
||||||
|
uniforms.extend(panel_uniform_names());
|
||||||
match renderer.compile_custom_pixel_shader(corner_shader_for(MATERIAL_SHADER, shape), &uniforms) {
|
match renderer.compile_custom_pixel_shader(corner_shader_for(MATERIAL_SHADER, shape), &uniforms) {
|
||||||
Ok(program) => {
|
Ok(program) => {
|
||||||
let program = MaterialProgram(program);
|
let program = MaterialProgram(program);
|
||||||
@@ -532,16 +562,110 @@ pub(super) fn material_uniforms(parameters: &MaterialParameters) -> Vec<Uniform<
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn decoration_uniforms(parameters: &MaterialParameters, paint_mode: f32) -> Vec<Uniform<'static>> {
|
pub(super) fn decoration_uniforms(parameters: &MaterialParameters, paint_mode: f32) -> Vec<Uniform<'static>> {
|
||||||
vec![
|
let mut uniforms = vec![
|
||||||
Uniform::new("paint_mode", paint_mode),
|
Uniform::new("paint_mode", paint_mode),
|
||||||
Uniform::new("shadow_rect", parameters.shadow_rect),
|
Uniform::new("shadow_rect", parameters.shadow_rect),
|
||||||
Uniform::new("shadow_values", parameters.shadow_values),
|
Uniform::new("shadow_values", parameters.shadow_values),
|
||||||
Uniform::new("visible_rect", parameters.visible_framebuffer),
|
Uniform::new("visible_rect", parameters.visible_framebuffer),
|
||||||
Uniform::new("material_radii", parameters.corner_radii),
|
Uniform::new("material_radii", parameters.corner_radii),
|
||||||
Uniform::new("tint", parameters.tint),
|
Uniform::new("tint", parameters.tint),
|
||||||
|
];
|
||||||
|
uniforms.extend(panel_uniforms(parameters));
|
||||||
|
uniforms
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panel_uniform_names() -> [UniformName<'static>; 3] {
|
||||||
|
[
|
||||||
|
UniformName::new("panel_count", UniformType::_1f),
|
||||||
|
UniformName::new("panel_fusion", UniformType::_1f),
|
||||||
|
UniformName::new("panel_shapes[0]", UniformType::Matrix3x3),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn panel_uniforms(parameters: &MaterialParameters) -> Vec<Uniform<'static>> {
|
||||||
|
use smithay::backend::renderer::gles::UniformValue;
|
||||||
|
let mut uniforms = vec![
|
||||||
|
Uniform::new(
|
||||||
|
"panel_count",
|
||||||
|
parameters.panel.as_ref().map_or(0., |p| p.shapes.len() as f32),
|
||||||
|
),
|
||||||
|
Uniform::new("panel_fusion", parameters.panel.as_ref().map_or(0., |p| p.fusion)),
|
||||||
|
];
|
||||||
|
if let Some(panel) = ¶meters.panel
|
||||||
|
&& !panel.shapes.is_empty()
|
||||||
|
{
|
||||||
|
uniforms.push(Uniform::new(
|
||||||
|
"panel_shapes[0]",
|
||||||
|
UniformValue::Matrix3x3 {
|
||||||
|
matrices: panel.shapes.clone(),
|
||||||
|
transpose: false,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
uniforms
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panel_matrices(
|
||||||
|
presentation: &crate::effects::PanelPresentation,
|
||||||
|
origin: Point<i32, Logical>,
|
||||||
|
scale: f64,
|
||||||
|
size: Size<i32, Physical>,
|
||||||
|
transform: Transform,
|
||||||
|
) -> PanelMatrices {
|
||||||
|
let permutation = framebuffer_corner_radii([0., 1., 2., 3.], transform);
|
||||||
|
let shapes = presentation
|
||||||
|
.shapes
|
||||||
|
.iter()
|
||||||
|
.map(|primitive| {
|
||||||
|
let b = primitive.bounds;
|
||||||
|
let left = ((b.x + f64::from(origin.x)) * scale).round() as i32;
|
||||||
|
let top = ((b.y + f64::from(origin.y)) * scale).round() as i32;
|
||||||
|
let right = ((b.x + b.width + f64::from(origin.x)) * scale).round() as i32;
|
||||||
|
let bottom = ((b.y + b.height + f64::from(origin.y)) * scale).round() as i32;
|
||||||
|
let physical = Rectangle::new((left, top).into(), (right - left, bottom - top).into());
|
||||||
|
let rect = framebuffer_clip_rect(physical, size, transform);
|
||||||
|
let radii = primitive.radii.0.map(|r| (f64::from(r) * scale) as f32);
|
||||||
|
let radii = framebuffer_corner_radii(
|
||||||
|
ferese_panel::CornerRadii(radii)
|
||||||
|
.normalized(physical.size.w as f32, physical.size.h as f32)
|
||||||
|
.0,
|
||||||
|
transform,
|
||||||
|
);
|
||||||
|
let operation = if primitive.operation >= 2 {
|
||||||
|
let old_corner = match primitive.operation {
|
||||||
|
2 => 3,
|
||||||
|
3 => 2,
|
||||||
|
4 => 1,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
match permutation.iter().position(|i| *i == old_corner as f32).unwrap() {
|
||||||
|
0 => 5,
|
||||||
|
1 => 4,
|
||||||
|
2 => 3,
|
||||||
|
_ => 2,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
primitive.operation
|
||||||
|
};
|
||||||
|
[
|
||||||
|
rect[0],
|
||||||
|
rect[1],
|
||||||
|
rect[2],
|
||||||
|
rect[3],
|
||||||
|
radii[0],
|
||||||
|
radii[1],
|
||||||
|
radii[2],
|
||||||
|
radii[3],
|
||||||
|
f32::from(operation * 4 + primitive.blend_zone),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
PanelMatrices {
|
||||||
|
shapes,
|
||||||
|
fusion: (presentation.fusion * scale) as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn blur_damage(
|
pub(super) fn blur_damage(
|
||||||
size: Size<i32, Physical>,
|
size: Size<i32, Physical>,
|
||||||
current: CommitCounter,
|
current: CommitCounter,
|
||||||
@@ -562,17 +686,18 @@ mod region_geometry_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn reduce_transparency_wins_over_continuous_and_island_panel_materials() {
|
fn reduce_transparency_wins_over_continuous_and_island_panel_materials() {
|
||||||
use crate::effects::SemanticRole;
|
use crate::effects::SemanticRole;
|
||||||
for surface in ["solid", "none"] {
|
for surface in ["flat", "floating", "morph"] {
|
||||||
for opacity in [0., 0.4, 1.] {
|
for opacity in [0., 0.4, 1.] {
|
||||||
for reduce in [false, true] {
|
for reduce in [false, true] {
|
||||||
let source = format!(
|
let source = format!(
|
||||||
"theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ surface {surface}; group-surface island; background-opacity {opacity}; }}"
|
"theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ style {surface}; background-opacity {opacity}; }}"
|
||||||
);
|
);
|
||||||
let (_, runtime, candidate) = crate::theme::prepare(&source, std::path::Path::new("/tmp")).unwrap();
|
let (_, runtime, candidate) = crate::theme::prepare(&source, std::path::Path::new("/tmp")).unwrap();
|
||||||
let material = resolve_surface_material(
|
let material = resolve_surface_material(
|
||||||
SemanticRole::Panel,
|
SemanticRole::Panel,
|
||||||
runtime.theme_settings,
|
runtime.theme_settings,
|
||||||
runtime.panel_background_opacity,
|
runtime.panel_background_opacity,
|
||||||
|
runtime.panel_style,
|
||||||
);
|
);
|
||||||
assert_eq!(material.opacity, if reduce { 1. } else { opacity });
|
assert_eq!(material.opacity, if reduce { 1. } else { opacity });
|
||||||
assert_eq!(material.opacity, candidate.theme.panel_opacity(Some(opacity)));
|
assert_eq!(material.opacity, candidate.theme.panel_opacity(Some(opacity)));
|
||||||
@@ -587,13 +712,43 @@ mod region_geometry_tests {
|
|||||||
if reduce {
|
if reduce {
|
||||||
assert_eq!(runtime.theme_settings.backdrop_blur, 0.);
|
assert_eq!(runtime.theme_settings.backdrop_blur, 0.);
|
||||||
}
|
}
|
||||||
let popover = resolve_surface_material(SemanticRole::Popover, runtime.theme_settings, Some(0.));
|
let popover = resolve_surface_material(
|
||||||
|
SemanticRole::Popover,
|
||||||
|
runtime.theme_settings,
|
||||||
|
Some(0.),
|
||||||
|
runtime.panel_style,
|
||||||
|
);
|
||||||
assert!((popover.opacity - if reduce { 1. } else { 0.7 }).abs() < 0.001);
|
assert!((popover.opacity - if reduce { 1. } else { 0.7 }).abs() < 0.001);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn morph_inherits_translucency_and_blur_without_adding_a_shadow() {
|
||||||
|
use crate::effects::SemanticRole;
|
||||||
|
for reduce in [false, true] {
|
||||||
|
let source = format!(
|
||||||
|
"theme {{ material {{ style translucent; opacity 0.6; blur-radius 20; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ style morph; }}"
|
||||||
|
);
|
||||||
|
let (_, runtime, candidate) = crate::theme::prepare(&source, std::path::Path::new("/tmp")).unwrap();
|
||||||
|
let material =
|
||||||
|
resolve_surface_material(SemanticRole::Panel, runtime.theme_settings, None, runtime.panel_style);
|
||||||
|
assert_eq!(material.shadow, [0.; 3]);
|
||||||
|
assert_eq!(material.opacity, candidate.theme.panel_opacity(None));
|
||||||
|
assert_eq!(
|
||||||
|
material.style,
|
||||||
|
if reduce {
|
||||||
|
crate::config::MaterialStyle::Solid
|
||||||
|
} else {
|
||||||
|
crate::config::MaterialStyle::Translucent
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let blur = material_blur_radius(material.style, material.opacity, runtime.theme_settings.backdrop_blur);
|
||||||
|
assert_eq!(blur > 0., !reduce);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn framebuffer_transforms_preserve_each_logical_corner() {
|
fn framebuffer_transforms_preserve_each_logical_corner() {
|
||||||
let radii = [1., 2., 3., 4.];
|
let radii = [1., 2., 3., 4.];
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ fn corner_shader_for(source: &str, shape: CornerShape) -> String {
|
|||||||
.replace("precision mediump float;", "precision highp float;"),
|
.replace("precision mediump float;", "precision highp float;"),
|
||||||
}
|
}
|
||||||
.replace("//_MATERIAL_CORNERS_", include_str!("shaders/material_corners.glsl"))
|
.replace("//_MATERIAL_CORNERS_", include_str!("shaders/material_corners.glsl"))
|
||||||
|
.replace("//_PANEL_MASK_", include_str!("shaders/panel_mask.glsl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn window_corner_shape(_window: &smithay::desktop::Window) -> CornerShape {
|
pub(crate) fn window_corner_shape(_window: &smithay::desktop::Window) -> CornerShape {
|
||||||
@@ -338,8 +339,15 @@ pub(crate) struct OverviewScrim {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct MaterialProgram(GlesPixelProgram);
|
pub(crate) struct MaterialProgram(GlesPixelProgram);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
struct PanelMatrices {
|
||||||
|
shapes: Vec<[f32; 9]>,
|
||||||
|
fusion: f32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
struct MaterialParameters {
|
struct MaterialParameters {
|
||||||
|
panel: Option<PanelMatrices>,
|
||||||
corner_radii: [f32; 4],
|
corner_radii: [f32; 4],
|
||||||
presentation_alpha: f32,
|
presentation_alpha: f32,
|
||||||
background_opacity: f32,
|
background_opacity: f32,
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ fn scene_elements(
|
|||||||
output,
|
output,
|
||||||
surface,
|
surface,
|
||||||
MaterialSurface {
|
MaterialSurface {
|
||||||
|
shape_origin: constrain.loc,
|
||||||
geometry: constrain,
|
geometry: constrain,
|
||||||
corners,
|
corners,
|
||||||
index: 0,
|
index: 0,
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ uniform vec4 tint;
|
|||||||
varying vec2 v_coords;
|
varying vec2 v_coords;
|
||||||
//_CORNERS_
|
//_CORNERS_
|
||||||
//_MATERIAL_CORNERS_
|
//_MATERIAL_CORNERS_
|
||||||
|
//_PANEL_MASK_
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
float sdf = rounded_rect_corners_distance(gl_FragCoord.xy, visible_rect, material_radii);
|
float sdf = material_distance(gl_FragCoord.xy, visible_rect, material_radii);
|
||||||
float coverage = alpha * presentation_alpha * edge_coverage(sdf);
|
float coverage = alpha * presentation_alpha * edge_coverage(sdf);
|
||||||
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
|
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
|
||||||
vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size;
|
vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size;
|
||||||
|
|||||||
@@ -11,15 +11,16 @@ varying vec2 v_coords;
|
|||||||
|
|
||||||
//_CORNERS_
|
//_CORNERS_
|
||||||
//_MATERIAL_CORNERS_
|
//_MATERIAL_CORNERS_
|
||||||
|
//_PANEL_MASK_
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
float sdf = rounded_rect_corners_distance(gl_FragCoord.xy, visible_rect, material_radii);
|
float sdf = material_distance(gl_FragCoord.xy, visible_rect, material_radii);
|
||||||
float coverage = edge_coverage(sdf);
|
float coverage = edge_coverage(sdf);
|
||||||
if (paint_mode > 0.5) {
|
if (paint_mode > 0.5) {
|
||||||
// The shadow pass is multiplied by (1 - coverage). Fully covered
|
// The shadow pass is multiplied by (1 - coverage). Fully covered
|
||||||
// pixels need neither a second contour solve nor a Gaussian sample.
|
// pixels need neither a second contour solve nor a Gaussian sample.
|
||||||
if (coverage >= 1.0) { gl_FragColor = vec4(0.0); return; }
|
if (coverage >= 1.0) { gl_FragColor = vec4(0.0); return; }
|
||||||
float distance = max(rounded_rect_corners_distance(gl_FragCoord.xy, shadow_rect, material_radii), 0.0);
|
float distance = max(material_distance(gl_FragCoord.xy - (shadow_rect.xy - visible_rect.xy), visible_rect, material_radii), 0.0);
|
||||||
float sigma = max(shadow_values.x * 0.5, 0.5);
|
float sigma = max(shadow_values.x * 0.5, 0.5);
|
||||||
float opacity = exp(-0.5 * distance * distance / (sigma * sigma))
|
float opacity = exp(-0.5 * distance * distance / (sigma * sigma))
|
||||||
* shadow_values.y * (1.0 - coverage) * alpha;
|
* shadow_values.y * (1.0 - coverage) * alpha;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// The same signed-distance mask is used by tint, blur and shadow. Shapes are
|
||||||
|
// bounded surface-local geometry transformed into framebuffer coordinates.
|
||||||
|
uniform float panel_count;
|
||||||
|
uniform float panel_fusion;
|
||||||
|
uniform mat3 panel_shapes[32];
|
||||||
|
|
||||||
|
float panel_box_distance(vec2 p, vec4 b) {
|
||||||
|
vec2 q = abs(p - b.xy - b.zw * 0.5) - b.zw * 0.5;
|
||||||
|
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
|
||||||
|
}
|
||||||
|
float panel_round_distance(vec2 p, vec4 b, vec4 r) {
|
||||||
|
vec2 q = p - b.xy;
|
||||||
|
float d = panel_box_distance(p, b);
|
||||||
|
vec2 a = q;
|
||||||
|
if (r.x > 0.0 && a.x < r.x && a.y < r.x) d = max(d, length(a - r.x) - r.x);
|
||||||
|
a = vec2(b.z - q.x, q.y);
|
||||||
|
if (r.y > 0.0 && a.x < r.y && a.y < r.y) d = max(d, length(a - r.y) - r.y);
|
||||||
|
a = b.zw - q;
|
||||||
|
if (r.z > 0.0 && a.x < r.z && a.y < r.z) d = max(d, length(a - r.z) - r.z);
|
||||||
|
a = vec2(q.x, b.w - q.y);
|
||||||
|
if (r.w > 0.0 && a.x < r.w && a.y < r.w) d = max(d, length(a - r.w) - r.w);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
float panel_smin(float a, float b) {
|
||||||
|
if (panel_fusion <= 0.0) return min(a, b);
|
||||||
|
float h = max(panel_fusion - abs(a - b), 0.0) / panel_fusion;
|
||||||
|
return min(a, b) - h * h * panel_fusion * 0.25;
|
||||||
|
}
|
||||||
|
float panel_distance(vec2 p) {
|
||||||
|
vec4 unions = vec4(1.0e8);
|
||||||
|
float cutout = 1.0e8;
|
||||||
|
for (int i = 0; i < 32; i++) {
|
||||||
|
if (float(i) >= panel_count) break;
|
||||||
|
mat3 m = panel_shapes[i];
|
||||||
|
vec4 b = vec4(m[0].xyz, m[1].x);
|
||||||
|
vec4 r = vec4(m[1].yz, m[2].xy);
|
||||||
|
float op = floor(m[2].z / 4.0);
|
||||||
|
float zone = mod(m[2].z, 4.0);
|
||||||
|
float d = panel_round_distance(p, b, r);
|
||||||
|
if (op > 1.5) {
|
||||||
|
vec2 c = b.xy;
|
||||||
|
if (op < 2.5) c.y += b.w;
|
||||||
|
else if (op < 3.5) c += b.zw;
|
||||||
|
else if (op < 4.5) c.x += b.z;
|
||||||
|
d = max(panel_box_distance(p, b), min(b.z, b.w) - length(p - c));
|
||||||
|
}
|
||||||
|
if (op > 0.5 && op < 1.5) cutout = min(cutout, d);
|
||||||
|
else if (zone < 0.5) unions.x = min(unions.x, d);
|
||||||
|
else if (zone < 1.5) unions.y = panel_smin(unions.y, d);
|
||||||
|
else if (zone < 2.5) unions.z = panel_smin(unions.z, d);
|
||||||
|
else unions.w = panel_smin(unions.w, d);
|
||||||
|
}
|
||||||
|
return max(min(min(unions.x, unions.y), min(unions.z, unions.w)), -cutout);
|
||||||
|
}
|
||||||
|
float material_distance(vec2 p, vec4 b, vec4 r) {
|
||||||
|
if (panel_count > 0.5) return panel_distance(p);
|
||||||
|
return rounded_rect_corners_distance(p, b, r);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ use crate::private_client::ClientCapabilities;
|
|||||||
use crate::state::ClientState;
|
use crate::state::ClientState;
|
||||||
|
|
||||||
pub(crate) fn init_global(display: &DisplayHandle) {
|
pub(crate) fn init_global(display: &DisplayHandle) {
|
||||||
display.create_global::<Ferese, FereseShellManagerV1, _>(6, ());
|
display.create_global::<Ferese, FereseShellManagerV1, _>(8, ());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
|
pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
|
||||||
@@ -120,6 +120,18 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
|
|||||||
send_request_failed(shell, ferese_shell_v1::FailedRequest::ActivateWorkspace, workspace.0);
|
send_request_failed(shell, ferese_shell_v1::FailedRequest::ActivateWorkspace, workspace.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ferese_shell_v1::Request::MoveWindowToWorkspace {
|
||||||
|
window_hi,
|
||||||
|
window_lo,
|
||||||
|
workspace_hi,
|
||||||
|
workspace_lo,
|
||||||
|
} => {
|
||||||
|
let window = join_id(window_hi, window_lo);
|
||||||
|
let workspace = WorkspaceId(join_u64(workspace_hi, workspace_lo));
|
||||||
|
if !state.move_managed_window_to_workspace(window, workspace) {
|
||||||
|
send_request_failed(shell, ferese_shell_v1::FailedRequest::MoveWindowToWorkspace, window.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
ferese_shell_v1::Request::ConfirmLogout { serial } => {
|
ferese_shell_v1::Request::ConfirmLogout { serial } => {
|
||||||
let snapshot = state.portal_session.snapshot();
|
let snapshot = state.portal_session.snapshot();
|
||||||
let token = snapshot["query-token"].as_u64().unwrap_or(0) as u32;
|
let token = snapshot["query-token"].as_u64().unwrap_or(0) as u32;
|
||||||
@@ -327,12 +339,21 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn shell_snapshot_matches(&self, snapshot: &ShellSnapshot) -> bool {
|
fn shell_snapshot_matches(&self, snapshot: &ShellSnapshot) -> bool {
|
||||||
|
if snapshot.workspace_preview_revision != self.workspace_preview_revision {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let mut outputs = self.output_workspaces.connected_outputs();
|
let mut outputs = self.output_workspaces.connected_outputs();
|
||||||
|
|
||||||
for previous in &snapshot.outputs {
|
for previous in &snapshot.outputs {
|
||||||
if outputs.next() != Some(previous.id)
|
if outputs.next() != Some(previous.id)
|
||||||
|| self.output_names.get(&previous.id) != Some(&previous.name)
|
|| self.output_names.get(&previous.id) != Some(&previous.name)
|
||||||
|| self.output_workspaces.active_workspace(previous.id) != Some(previous.active_workspace)
|
|| self.output_workspaces.active_workspace(previous.id) != Some(previous.active_workspace)
|
||||||
|
|| self
|
||||||
|
.output_ids
|
||||||
|
.iter()
|
||||||
|
.find(|(_, id)| **id == previous.id)
|
||||||
|
.and_then(|(output, _)| self.output_bounds_for(output))
|
||||||
|
!= previous.bounds
|
||||||
|| (self.output_workspaces.focused_output() == Some(previous.id)) != previous.focused
|
|| (self.output_workspaces.focused_output() == Some(previous.id)) != previous.focused
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -377,6 +398,7 @@ impl Ferese {
|
|||||||
|| previous.app_id != app_id
|
|| previous.app_id != app_id
|
||||||
|| previous.title != title
|
|| previous.title != title
|
||||||
|| previous.state != state
|
|| previous.state != state
|
||||||
|
|| previous.source_geometry != self.shell_window_geometry(id)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -387,6 +409,7 @@ impl Ferese {
|
|||||||
|
|
||||||
fn shell_snapshot(&self) -> ShellSnapshot {
|
fn shell_snapshot(&self) -> ShellSnapshot {
|
||||||
ShellSnapshot {
|
ShellSnapshot {
|
||||||
|
workspace_preview_revision: self.workspace_preview_revision,
|
||||||
outputs: self.output_snapshots(),
|
outputs: self.output_snapshots(),
|
||||||
workspaces: self.workspace_snapshots(),
|
workspaces: self.workspace_snapshots(),
|
||||||
windows: self.managed_window_snapshots(),
|
windows: self.managed_window_snapshots(),
|
||||||
@@ -416,6 +439,7 @@ impl Ferese {
|
|||||||
name: output.name(),
|
name: output.name(),
|
||||||
active_workspace: self.output_workspaces.active_workspace(*id)?,
|
active_workspace: self.output_workspaces.active_workspace(*id)?,
|
||||||
focused: focused == Some(*id),
|
focused: focused == Some(*id),
|
||||||
|
bounds: self.output_bounds_for(output),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -445,6 +469,9 @@ impl Ferese {
|
|||||||
self.windows.window(id)?.toplevel()?;
|
self.windows.window(id)?.toplevel()?;
|
||||||
let mut state = ferese_shell_v1::WindowState::empty();
|
let mut state = ferese_shell_v1::WindowState::empty();
|
||||||
|
|
||||||
|
if record.maximized {
|
||||||
|
state |= ferese_shell_v1::WindowState::Maximized;
|
||||||
|
}
|
||||||
if self.focused_window == Some(id) {
|
if self.focused_window == Some(id) {
|
||||||
state |= ferese_shell_v1::WindowState::Focused;
|
state |= ferese_shell_v1::WindowState::Focused;
|
||||||
}
|
}
|
||||||
@@ -467,7 +494,13 @@ impl Ferese {
|
|||||||
Some((id, workspace.0, &record.app_id, &record.title, state))
|
Some((id, workspace.0, &record.app_id, &record.title, state))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shell_window_geometry(&self, id: WindowId) -> Option<ferese_layout::Rect> {
|
||||||
|
let rect = self.windows.geometry(&id)?.visual.target;
|
||||||
|
(rect.width > 0. && rect.height > 0.).then_some(rect)
|
||||||
|
}
|
||||||
|
|
||||||
fn managed_window_snapshots(&self) -> Vec<ManagedWindowSnapshot> {
|
fn managed_window_snapshots(&self) -> Vec<ManagedWindowSnapshot> {
|
||||||
|
let previews = self.workspace_preview_rects();
|
||||||
self.windows
|
self.windows
|
||||||
.ordered_ids()
|
.ordered_ids()
|
||||||
.filter_map(|id| self.shell_window_data(id))
|
.filter_map(|id| self.shell_window_data(id))
|
||||||
@@ -477,6 +510,8 @@ impl Ferese {
|
|||||||
app_id: app_id.to_owned(),
|
app_id: app_id.to_owned(),
|
||||||
title: title.to_owned(),
|
title: title.to_owned(),
|
||||||
state,
|
state,
|
||||||
|
source_geometry: self.shell_window_geometry(id),
|
||||||
|
geometry: previews.get(&id).copied().or_else(|| self.shell_window_geometry(id)),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -518,6 +553,7 @@ enum ShellWindowAction {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub(crate) struct ShellSnapshot {
|
pub(crate) struct ShellSnapshot {
|
||||||
|
workspace_preview_revision: u64,
|
||||||
outputs: Vec<OutputSnapshot>,
|
outputs: Vec<OutputSnapshot>,
|
||||||
workspaces: Vec<WorkspaceSnapshot>,
|
workspaces: Vec<WorkspaceSnapshot>,
|
||||||
windows: Vec<ManagedWindowSnapshot>,
|
windows: Vec<ManagedWindowSnapshot>,
|
||||||
@@ -568,6 +604,11 @@ impl ShellSnapshot {
|
|||||||
snapshot.title.clone(),
|
snapshot.title.clone(),
|
||||||
snapshot.state,
|
snapshot.state,
|
||||||
);
|
);
|
||||||
|
if shell.version() >= 8
|
||||||
|
&& let Some(rect) = snapshot.geometry
|
||||||
|
{
|
||||||
|
shell.window_geometry(window_hi, window_lo, rect.x, rect.y, rect.width, rect.height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
shell.snapshot_end(serial);
|
shell.snapshot_end(serial);
|
||||||
}
|
}
|
||||||
@@ -580,10 +621,13 @@ struct ManagedWindowSnapshot {
|
|||||||
app_id: String,
|
app_id: String,
|
||||||
title: String,
|
title: String,
|
||||||
state: ferese_shell_v1::WindowState,
|
state: ferese_shell_v1::WindowState,
|
||||||
|
geometry: Option<ferese_layout::Rect>,
|
||||||
|
source_geometry: Option<ferese_layout::Rect>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
struct OutputSnapshot {
|
struct OutputSnapshot {
|
||||||
|
bounds: Option<ferese_layout::Rect>,
|
||||||
id: OutputId,
|
id: OutputId,
|
||||||
name: String,
|
name: String,
|
||||||
active_workspace: WorkspaceId,
|
active_workspace: WorkspaceId,
|
||||||
@@ -661,17 +705,22 @@ mod tests {
|
|||||||
state.space.map_output(&output, (0, 0));
|
state.space.map_output(&output, (0, 0));
|
||||||
state.register_output(&output, "snapshot-test".into());
|
state.register_output(&output, "snapshot-test".into());
|
||||||
let original = state.shell_snapshot();
|
let original = state.shell_snapshot();
|
||||||
|
let initial_cache = state.preview_cache_stats();
|
||||||
|
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
|
// Building a snapshot for metadata changes must reuse layout geometry.
|
||||||
|
assert_eq!(state.shell_snapshot(), original);
|
||||||
assert!(state.shell_snapshot_matches(&original));
|
assert!(state.shell_snapshot_matches(&original));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert_eq!(state.preview_cache_stats(), initial_cache);
|
||||||
state
|
state
|
||||||
.workspaces
|
.workspaces
|
||||||
.insert_window(WindowId(99), ferese_layout::Axis::Horizontal, 0.5)
|
.insert_window(WindowId(99), ferese_layout::Axis::Horizontal, 0.5)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!state.shell_snapshot_matches(&original));
|
assert!(!state.shell_snapshot_matches(&original));
|
||||||
let occupied = state.shell_snapshot();
|
let occupied = state.shell_snapshot();
|
||||||
|
assert_eq!(state.preview_cache_stats().0, initial_cache.0 + 1);
|
||||||
assert!(state.shell_snapshot_matches(&occupied));
|
assert!(state.shell_snapshot_matches(&occupied));
|
||||||
let output_id = state.output_ids[&output];
|
let output_id = state.output_ids[&output];
|
||||||
let next = state.workspaces.create_workspace();
|
let next = state.workspaces.create_workspace();
|
||||||
@@ -682,8 +731,60 @@ mod tests {
|
|||||||
assert!(!state.shell_snapshot_matches(&assigned));
|
assert!(!state.shell_snapshot_matches(&assigned));
|
||||||
let selected = state.shell_snapshot();
|
let selected = state.shell_snapshot();
|
||||||
assert!(state.shell_snapshot_matches(&selected));
|
assert!(state.shell_snapshot_matches(&selected));
|
||||||
|
assert_eq!(state.output_workspaces.active_workspace(output_id), Some(next));
|
||||||
|
assert_eq!(state.workspaces.workspace(next).unwrap().layout.window_ids().count(), 0);
|
||||||
|
let hidden_before = state.workspace_preview_rects()[&WindowId(99)];
|
||||||
|
let before = state.preview_cache_stats();
|
||||||
|
state.output_names.insert(output_id, "Renamed".into());
|
||||||
|
state.shell_snapshot();
|
||||||
|
assert_eq!(state.preview_cache_stats(), before, "names do not affect previews");
|
||||||
|
state.output_names.insert(output_id, "snapshot-test".into());
|
||||||
|
// The second workspace is empty; modifying its layout should not solve the hidden one.
|
||||||
|
state
|
||||||
|
.workspaces
|
||||||
|
.workspace_mut(next)
|
||||||
|
.unwrap()
|
||||||
|
.layout
|
||||||
|
.insert(WindowId(100), None, ferese_layout::Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
state.workspace_preview_rects();
|
||||||
|
assert_eq!(state.preview_cache_stats().0, before.0 + 1);
|
||||||
|
state
|
||||||
|
.workspaces
|
||||||
|
.workspace_mut(next)
|
||||||
|
.unwrap()
|
||||||
|
.layout
|
||||||
|
.remove(WindowId(100))
|
||||||
|
.unwrap();
|
||||||
|
state.workspace_preview_rects();
|
||||||
|
let selected = state.shell_snapshot();
|
||||||
|
state
|
||||||
|
.reload_config_source("layout { inner-gap 30; outer-gap 40; smart-gaps #false; }".into())
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!state.shell_snapshot_matches(&selected),
|
||||||
|
"hidden previews must invalidate even with no visible window geometry to change"
|
||||||
|
);
|
||||||
|
assert_ne!(state.workspace_preview_rects()[&WindowId(99)], hidden_before);
|
||||||
|
let refreshed = state.shell_snapshot();
|
||||||
|
assert!(state.shell_snapshot_matches(&refreshed));
|
||||||
|
state
|
||||||
|
.reload_config_source(
|
||||||
|
"layout { inner-gap 30; outer-gap 40; smart-gaps #false; }; input { repeat-rate 35; }".into(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
state.workspace_preview_revision, refreshed.workspace_preview_revision,
|
||||||
|
"unrelated settings do not invalidate layouts"
|
||||||
|
);
|
||||||
state.unregister_output(&output);
|
state.unregister_output(&output);
|
||||||
assert!(!state.shell_snapshot_matches(&selected));
|
assert!(!state.shell_snapshot_matches(&refreshed));
|
||||||
|
state.workspace_preview_rects();
|
||||||
|
assert_eq!(
|
||||||
|
state.preview_cache_stats().1,
|
||||||
|
0,
|
||||||
|
"removed outputs release cached previews"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -708,7 +809,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn snapshot_deduplication_preserves_shell_visible_changes() {
|
fn snapshot_deduplication_preserves_shell_visible_changes() {
|
||||||
let original = ShellSnapshot {
|
let original = ShellSnapshot {
|
||||||
|
workspace_preview_revision: 0,
|
||||||
outputs: vec![OutputSnapshot {
|
outputs: vec![OutputSnapshot {
|
||||||
|
bounds: None,
|
||||||
id: OutputId(1),
|
id: OutputId(1),
|
||||||
name: "display".into(),
|
name: "display".into(),
|
||||||
active_workspace: WorkspaceId(1),
|
active_workspace: WorkspaceId(1),
|
||||||
@@ -729,10 +832,13 @@ mod tests {
|
|||||||
app_id: "terminal".into(),
|
app_id: "terminal".into(),
|
||||||
title: "shell".into(),
|
title: "shell".into(),
|
||||||
state: ferese_shell_v1::WindowState::Focused,
|
state: ferese_shell_v1::WindowState::Focused,
|
||||||
|
geometry: None,
|
||||||
|
source_geometry: None,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
assert_eq!(original, original.clone());
|
assert_eq!(original, original.clone());
|
||||||
let changes: &[fn(&mut ShellSnapshot)] = &[
|
let changes: &[fn(&mut ShellSnapshot)] = &[
|
||||||
|
|s| s.workspace_preview_revision += 1,
|
||||||
|s| s.outputs[0].focused = false,
|
|s| s.outputs[0].focused = false,
|
||||||
|s| s.outputs[0].active_workspace = WorkspaceId(2),
|
|s| s.outputs[0].active_workspace = WorkspaceId(2),
|
||||||
|s| s.outputs.clear(),
|
|s| s.outputs.clear(),
|
||||||
|
|||||||
+17
-3
@@ -464,8 +464,9 @@ pub struct Ferese {
|
|||||||
workspace_auto_back_and_forth: bool,
|
workspace_auto_back_and_forth: bool,
|
||||||
pub(crate) window_rules: Vec<WindowRule>,
|
pub(crate) window_rules: Vec<WindowRule>,
|
||||||
pub(crate) theme_settings: ThemeSettings,
|
pub(crate) theme_settings: ThemeSettings,
|
||||||
pub(crate) panel_corner_radius: Option<ferese_config::panel::CornerRadii>,
|
pub(crate) panel_corner_radius: Option<ferese_panel::CornerRadii>,
|
||||||
pub(crate) panel_background_opacity: Option<f32>,
|
pub(crate) panel_background_opacity: Option<f32>,
|
||||||
|
pub(crate) panel_style: ferese_panel::PanelStyle,
|
||||||
pub(crate) focus_effect: crate::config::FocusEffectSettings,
|
pub(crate) focus_effect: crate::config::FocusEffectSettings,
|
||||||
animations_enabled: bool,
|
animations_enabled: bool,
|
||||||
animation_speed: f64,
|
animation_speed: f64,
|
||||||
@@ -505,6 +506,8 @@ pub struct Ferese {
|
|||||||
pub(crate) shell_resources: Vec<Weak<FereseShellV1>>,
|
pub(crate) shell_resources: Vec<Weak<FereseShellV1>>,
|
||||||
pub(crate) shell_snapshot_serial: u32,
|
pub(crate) shell_snapshot_serial: u32,
|
||||||
pub(crate) last_shell_snapshot: Option<ShellSnapshot>,
|
pub(crate) last_shell_snapshot: Option<ShellSnapshot>,
|
||||||
|
pub(crate) workspace_preview_revision: u64,
|
||||||
|
workspace_previews: std::cell::RefCell<layout::PreviewCache>,
|
||||||
pub(crate) overview: OverviewState,
|
pub(crate) overview: OverviewState,
|
||||||
next_output_id: u64,
|
next_output_id: u64,
|
||||||
last_animation_tick: Instant,
|
last_animation_tick: Instant,
|
||||||
@@ -557,8 +560,9 @@ pub struct RuntimeConfig {
|
|||||||
pub workspace_auto_back_and_forth: bool,
|
pub workspace_auto_back_and_forth: bool,
|
||||||
pub window_rules: Vec<WindowRule>,
|
pub window_rules: Vec<WindowRule>,
|
||||||
pub theme_settings: ThemeSettings,
|
pub theme_settings: ThemeSettings,
|
||||||
pub(crate) panel_corner_radius: Option<ferese_config::panel::CornerRadii>,
|
pub(crate) panel_corner_radius: Option<ferese_panel::CornerRadii>,
|
||||||
pub(crate) panel_background_opacity: Option<f32>,
|
pub(crate) panel_background_opacity: Option<f32>,
|
||||||
|
pub(crate) panel_style: ferese_panel::PanelStyle,
|
||||||
pub focus_effect: crate::config::FocusEffectSettings,
|
pub focus_effect: crate::config::FocusEffectSettings,
|
||||||
pub default_column_width: ColumnWidth,
|
pub default_column_width: ColumnWidth,
|
||||||
pub scrolling_focus_strategy: ViewportFocusStrategy,
|
pub scrolling_focus_strategy: ViewportFocusStrategy,
|
||||||
@@ -746,6 +750,7 @@ impl Ferese {
|
|||||||
theme_settings: config.theme_settings,
|
theme_settings: config.theme_settings,
|
||||||
panel_corner_radius: config.panel_corner_radius,
|
panel_corner_radius: config.panel_corner_radius,
|
||||||
panel_background_opacity: config.panel_background_opacity,
|
panel_background_opacity: config.panel_background_opacity,
|
||||||
|
panel_style: config.panel_style,
|
||||||
focus_effect: config.focus_effect,
|
focus_effect: config.focus_effect,
|
||||||
animations_enabled: config.animations_enabled,
|
animations_enabled: config.animations_enabled,
|
||||||
animation_speed: config.animation_speed,
|
animation_speed: config.animation_speed,
|
||||||
@@ -780,6 +785,8 @@ impl Ferese {
|
|||||||
shell_resources: Vec::new(),
|
shell_resources: Vec::new(),
|
||||||
shell_snapshot_serial: 0,
|
shell_snapshot_serial: 0,
|
||||||
last_shell_snapshot: None,
|
last_shell_snapshot: None,
|
||||||
|
workspace_preview_revision: 0,
|
||||||
|
workspace_previews: Default::default(),
|
||||||
overview: OverviewState::with_font_family(config.overview_font_family),
|
overview: OverviewState::with_font_family(config.overview_font_family),
|
||||||
next_output_id: 1,
|
next_output_id: 1,
|
||||||
last_animation_tick: start_time,
|
last_animation_tick: start_time,
|
||||||
@@ -849,7 +856,9 @@ impl Ferese {
|
|||||||
let input_changed = changed(&["input"]);
|
let input_changed = changed(&["input"]);
|
||||||
let daemons_changed = changed(&["autostart"]);
|
let daemons_changed = changed(&["autostart"]);
|
||||||
let lock_changed = changed(&["lock_screen"]);
|
let lock_changed = changed(&["lock_screen"]);
|
||||||
let non_output_scene_changed = layout_changed || changed(&["animations", "appearance", "window_rules"]);
|
let panel_layout_changed = self.panel_style != config.panel_style;
|
||||||
|
let non_output_scene_changed =
|
||||||
|
layout_changed || panel_layout_changed || changed(&["animations", "appearance", "window_rules"]);
|
||||||
let scene_changed = non_output_scene_changed || changed(&["output_profiles"]);
|
let scene_changed = non_output_scene_changed || changed(&["output_profiles"]);
|
||||||
|
|
||||||
if self.output_profiles != config.output_profiles {
|
if self.output_profiles != config.output_profiles {
|
||||||
@@ -921,6 +930,10 @@ impl Ferese {
|
|||||||
)
|
)
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
}
|
}
|
||||||
|
if layout_changed || self.gap_config != config.gap_config || panel_layout_changed {
|
||||||
|
// Hidden layouts are resolved independently of presented geometry.
|
||||||
|
self.workspace_preview_revision = self.workspace_preview_revision.wrapping_add(1);
|
||||||
|
}
|
||||||
self.gap_config = config.gap_config;
|
self.gap_config = config.gap_config;
|
||||||
self.input_settings = config.input_settings;
|
self.input_settings = config.input_settings;
|
||||||
if bindings_changed {
|
if bindings_changed {
|
||||||
@@ -936,6 +949,7 @@ impl Ferese {
|
|||||||
self.theme_settings = config.theme_settings;
|
self.theme_settings = config.theme_settings;
|
||||||
self.panel_corner_radius = config.panel_corner_radius;
|
self.panel_corner_radius = config.panel_corner_radius;
|
||||||
self.panel_background_opacity = config.panel_background_opacity;
|
self.panel_background_opacity = config.panel_background_opacity;
|
||||||
|
self.panel_style = config.panel_style;
|
||||||
self.focus_effect = config.focus_effect;
|
self.focus_effect = config.focus_effect;
|
||||||
self.column_width_presets = config.column_width_presets;
|
self.column_width_presets = config.column_width_presets;
|
||||||
self.animations_enabled = config.animations_enabled;
|
self.animations_enabled = config.animations_enabled;
|
||||||
|
|||||||
+207
-12
@@ -1,6 +1,98 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct PreviewCache {
|
||||||
|
entries: HashMap<WorkspaceId, Preview>,
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) resolutions: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Preview {
|
||||||
|
layout: WorkspaceLayout,
|
||||||
|
input: PreviewInput,
|
||||||
|
rectangles: HashMap<WindowId, Rect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
struct PreviewInput {
|
||||||
|
bounds: Rect,
|
||||||
|
maximized_bounds: Rect,
|
||||||
|
fullscreen_bounds: Rect,
|
||||||
|
gaps: GapConfig,
|
||||||
|
focused: Option<WindowId>,
|
||||||
|
fullscreen: Option<WindowId>,
|
||||||
|
windows: Vec<PreviewWindow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq)]
|
||||||
|
struct PreviewWindow {
|
||||||
|
id: WindowId,
|
||||||
|
constraints: SizeConstraints,
|
||||||
|
floating: Option<Rect>,
|
||||||
|
maximized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl Ferese {
|
impl Ferese {
|
||||||
|
/// Morph owns the gap on its reserved edge. Keep the layout's outer gap
|
||||||
|
/// on the other three edges, and keep floating work-area bounds unchanged.
|
||||||
|
pub(super) fn morph_layout_bounds(&self, mut bounds: Rect, outer: f64) -> Rect {
|
||||||
|
use smithay::wayland::shell::wlr_layer::{Anchor, ExclusiveZone};
|
||||||
|
if self.panel_style != ferese_panel::PanelStyle::Morph || outer <= 0. || !outer.is_finite() {
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
let Some(output) = self
|
||||||
|
.space
|
||||||
|
.outputs()
|
||||||
|
.find(|output| self.output_bounds_for(output) == Some(bounds))
|
||||||
|
else {
|
||||||
|
return bounds;
|
||||||
|
};
|
||||||
|
let Some(output_geometry) = self.space.output_geometry(output) else {
|
||||||
|
return bounds;
|
||||||
|
};
|
||||||
|
let map = layer_map_for_output(output);
|
||||||
|
for layer in map.layers().filter(|layer| layer.namespace() == "ferese-shell-top-bar") {
|
||||||
|
let policy = layer.cached_state();
|
||||||
|
let ExclusiveZone::Exclusive(amount) = policy.exclusive_zone else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if amount == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(geometry) = map.layer_geometry(layer) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let top = f64::from(output_geometry.loc.y + geometry.loc.y);
|
||||||
|
let bottom = top + f64::from(geometry.size.h);
|
||||||
|
// Only remove a gap if Morph actually borders the usable area.
|
||||||
|
// Another reserved bar on this edge must retain its own spacing.
|
||||||
|
if policy.anchor.contains(Anchor::TOP)
|
||||||
|
&& !policy.anchor.contains(Anchor::BOTTOM)
|
||||||
|
&& (bounds.y - (top + f64::from(amount))).abs() < 0.01
|
||||||
|
{
|
||||||
|
bounds.y -= outer;
|
||||||
|
bounds.height += outer;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if policy.anchor.contains(Anchor::BOTTOM)
|
||||||
|
&& !policy.anchor.contains(Anchor::TOP)
|
||||||
|
&& (bounds.y + bounds.height - (bottom - f64::from(amount))).abs() < 0.01
|
||||||
|
{
|
||||||
|
bounds.height += outer;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn workspace_layout_bounds(&self, bounds: Rect, layout: &WorkspaceLayout) -> Rect {
|
||||||
|
let outer = if self.gap_config.smart && layout.window_ids().count() == 1 {
|
||||||
|
0.
|
||||||
|
} else {
|
||||||
|
self.gap_config.outer
|
||||||
|
};
|
||||||
|
self.morph_layout_bounds(bounds, outer)
|
||||||
|
}
|
||||||
pub(super) fn visible_workspace_ids(&self) -> HashSet<WorkspaceId> {
|
pub(super) fn visible_workspace_ids(&self) -> HashSet<WorkspaceId> {
|
||||||
let mut visible = self
|
let mut visible = self
|
||||||
.output_workspaces
|
.output_workspaces
|
||||||
@@ -228,6 +320,8 @@ impl Ferese {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let layout_bounds = self.workspace_layout_bounds(bounds, &workspace.layout);
|
||||||
|
let maximized_bounds = self.morph_layout_bounds(bounds, self.gap_config.outer);
|
||||||
let workspace_fullscreen = workspace.fullscreen;
|
let workspace_fullscreen = workspace.fullscreen;
|
||||||
let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_));
|
let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_));
|
||||||
let workspace_focus = workspace.last_focused;
|
let workspace_focus = workspace.last_focused;
|
||||||
@@ -239,17 +333,18 @@ impl Ferese {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let layout =
|
let layout = match workspace.layout.resolve_geometry_with_constraints(
|
||||||
match workspace
|
layout_bounds,
|
||||||
.layout
|
self.gap_config,
|
||||||
.resolve_geometry_with_constraints(bounds, self.gap_config, &constraints, focused)
|
&constraints,
|
||||||
{
|
focused,
|
||||||
Ok(layout) => layout,
|
) {
|
||||||
Err(error) => {
|
Ok(layout) => layout,
|
||||||
tracing::error!(%error, ?workspace_id, "failed to compute tiled geometry");
|
Err(error) => {
|
||||||
continue;
|
tracing::error!(%error, ?workspace_id, "failed to compute tiled geometry");
|
||||||
}
|
continue;
|
||||||
};
|
}
|
||||||
|
};
|
||||||
let viewport_target = workspace.layout.viewport_x();
|
let viewport_target = workspace.layout.viewport_x();
|
||||||
let viewport_motion = viewport_target.map(|target| {
|
let viewport_motion = viewport_target.map(|target| {
|
||||||
let viewport = self
|
let viewport = self
|
||||||
@@ -288,7 +383,7 @@ impl Ferese {
|
|||||||
let rect = if workspace_fullscreen == Some(*id) {
|
let rect = if workspace_fullscreen == Some(*id) {
|
||||||
fullscreen_bounds
|
fullscreen_bounds
|
||||||
} else if is_maximized && (!is_scrolling_layout || is_floating) {
|
} else if is_maximized && (!is_scrolling_layout || is_floating) {
|
||||||
maximized_rect(bounds, self.gap_config.outer)
|
maximized_rect(maximized_bounds, self.gap_config.outer)
|
||||||
} else {
|
} else {
|
||||||
match placement {
|
match placement {
|
||||||
Some(WindowPlacement::Tiled) => {
|
Some(WindowPlacement::Tiled) => {
|
||||||
@@ -597,6 +692,7 @@ impl Ferese {
|
|||||||
pub(super) fn tiled_layout(&mut self, bounds: Rect) -> Result<LayoutResult, ferese_layout::LayoutError> {
|
pub(super) fn tiled_layout(&mut self, bounds: Rect) -> Result<LayoutResult, ferese_layout::LayoutError> {
|
||||||
let constraints = self.window_constraints();
|
let constraints = self.window_constraints();
|
||||||
let focused = self.focused_window;
|
let focused = self.focused_window;
|
||||||
|
let bounds = self.workspace_layout_bounds(bounds, &self.workspaces.active().layout);
|
||||||
self.workspaces.active_mut().layout.resolve_geometry_with_constraints(
|
self.workspaces.active_mut().layout.resolve_geometry_with_constraints(
|
||||||
bounds,
|
bounds,
|
||||||
self.gap_config,
|
self.gap_config,
|
||||||
@@ -605,6 +701,105 @@ impl Ferese {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn preview_cache_stats(&self) -> (usize, usize) {
|
||||||
|
let cache = self.workspace_previews.borrow();
|
||||||
|
(cache.resolutions, cache.entries.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn workspace_preview_rects(&self) -> HashMap<WindowId, Rect> {
|
||||||
|
let constraints = self.window_constraints();
|
||||||
|
let mut cache = self.workspace_previews.borrow_mut();
|
||||||
|
let mut live = HashSet::new();
|
||||||
|
let mut previews = HashMap::new();
|
||||||
|
for view in self.output_workspaces.workspace_views_iter(&self.workspaces) {
|
||||||
|
let Some(workspace) = self.workspaces.workspace(view.id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(output) = self
|
||||||
|
.output_ids
|
||||||
|
.iter()
|
||||||
|
.find(|(_, id)| **id == view.output)
|
||||||
|
.map(|(o, _)| o)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(bounds) = self.output_bounds_for(output) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
live.insert(view.id);
|
||||||
|
let mut windows: Vec<_> = workspace
|
||||||
|
.layout
|
||||||
|
.window_ids()
|
||||||
|
.map(|id| (id, false))
|
||||||
|
.chain(workspace.floating.iter().map(|&id| (id, true)))
|
||||||
|
.map(|(id, floating)| PreviewWindow {
|
||||||
|
id,
|
||||||
|
constraints: constraints.get(&id).copied().unwrap_or_default(),
|
||||||
|
floating: if floating {
|
||||||
|
match self.workspaces.placement(id) {
|
||||||
|
Some(ferese_core::WindowPlacement::Floating { rect }) => Some(rect),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
maximized: self.windows.record(id).is_some_and(|w| w.maximized),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
windows.sort_by_key(|w| w.id.0);
|
||||||
|
let input = PreviewInput {
|
||||||
|
bounds: self.workspace_layout_bounds(bounds, &workspace.layout),
|
||||||
|
maximized_bounds: self.morph_layout_bounds(bounds, self.gap_config.outer),
|
||||||
|
fullscreen_bounds: self.full_output_bounds_for(output).unwrap_or(bounds),
|
||||||
|
gaps: self.gap_config,
|
||||||
|
focused: workspace.last_focused,
|
||||||
|
fullscreen: workspace.fullscreen,
|
||||||
|
windows,
|
||||||
|
};
|
||||||
|
if !cache
|
||||||
|
.entries
|
||||||
|
.get(&view.id)
|
||||||
|
.is_some_and(|cached| cached.input == input && cached.layout == workspace.layout)
|
||||||
|
{
|
||||||
|
// Only clone and solve the changed workspace. Titles, urgency and
|
||||||
|
// other shell metadata never invalidate geometry.
|
||||||
|
let mut layout = workspace.layout.clone();
|
||||||
|
let mut rectangles = layout
|
||||||
|
.resolve_geometry_with_constraints(input.bounds, input.gaps, &constraints, input.focused)
|
||||||
|
.map(|resolved| resolved.geometry)
|
||||||
|
.unwrap_or_default();
|
||||||
|
for window in &input.windows {
|
||||||
|
if let Some(rect) = window.floating {
|
||||||
|
rectangles.insert(window.id, constrained_floating_rect(rect, window.constraints));
|
||||||
|
}
|
||||||
|
if input.fullscreen == Some(window.id) {
|
||||||
|
rectangles.insert(window.id, input.fullscreen_bounds);
|
||||||
|
} else if window.maximized
|
||||||
|
&& (window.floating.is_some() || !matches!(workspace.layout, WorkspaceLayout::Scrolling(_)))
|
||||||
|
{
|
||||||
|
rectangles.insert(window.id, maximized_rect(input.maximized_bounds, input.gaps.outer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.entries.insert(
|
||||||
|
view.id,
|
||||||
|
Preview {
|
||||||
|
layout: workspace.layout.clone(),
|
||||||
|
input,
|
||||||
|
rectangles,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
cache.resolutions += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previews.extend(cache.entries[&view.id].rectangles.iter().map(|(&id, &rect)| (id, rect)));
|
||||||
|
}
|
||||||
|
cache.entries.retain(|id, _| live.contains(id));
|
||||||
|
previews
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn window_constraints(&self) -> HashMap<WindowId, SizeConstraints> {
|
pub(super) fn window_constraints(&self) -> HashMap<WindowId, SizeConstraints> {
|
||||||
self.windows
|
self.windows
|
||||||
.ids()
|
.ids()
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ impl Ferese {
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let layout_bounds = self.workspace_layout_bounds(bounds, &candidate);
|
||||||
if candidate
|
if candidate
|
||||||
.resolve_geometry_with_constraints(bounds, self.gap_config, &self.window_constraints(), Some(to))
|
.resolve_geometry_with_constraints(layout_bounds, self.gap_config, &self.window_constraints(), Some(to))
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -338,6 +339,7 @@ impl Ferese {
|
|||||||
let Some(bounds) = self.output_bounds() else {
|
let Some(bounds) = self.output_bounds() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let bounds = self.workspace_layout_bounds(bounds, &self.workspaces.active().layout);
|
||||||
let constraints = self.window_constraints();
|
let constraints = self.window_constraints();
|
||||||
|
|
||||||
match self
|
match self
|
||||||
@@ -614,6 +616,48 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn move_managed_window_to_workspace(&mut self, window: WindowId, destination: WorkspaceId) -> bool {
|
||||||
|
if self.session_lock.active() || self.windows.window(window).and_then(|w| w.toplevel()).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(source) = self.workspaces.workspace_for_window(window) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(output_id) = self.output_workspaces.output_for_workspace(destination) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if self.output_workspaces.output_for_workspace(source) != Some(output_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if source == destination {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let bounds = self
|
||||||
|
.output_ids
|
||||||
|
.iter()
|
||||||
|
.find(|(_, id)| **id == output_id)
|
||||||
|
.and_then(|(output, _)| self.output_bounds_for(output));
|
||||||
|
let axis = bounds
|
||||||
|
.and_then(|bounds| {
|
||||||
|
let workspace = self.workspaces.workspace(destination)?;
|
||||||
|
workspace.layout.automatic_axis(workspace.last_focused, bounds).ok()
|
||||||
|
})
|
||||||
|
.unwrap_or(Axis::Horizontal);
|
||||||
|
if self
|
||||||
|
.workspaces
|
||||||
|
.move_window_to_workspace(window, destination, axis, 0.5)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.focused_window == Some(window) {
|
||||||
|
self.focused_window = self.workspaces.active().last_focused;
|
||||||
|
}
|
||||||
|
self.relayout();
|
||||||
|
// The shell keeps keyboard focus while a drag updates the overview.
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn move_focused_to_workspace(&mut self, index: u32) {
|
pub fn move_focused_to_workspace(&mut self, index: u32) {
|
||||||
let Some(window) = self.focused_window else {
|
let Some(window) = self.focused_window else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Input estimation and destination selection; all post-release motion uses springs.
|
|
||||||
use std::{collections::VecDeque, time::Duration};
|
use std::{collections::VecDeque, time::Duration};
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
#[derive(Default, Debug)]
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Exact physical spring motion. Presentation policy is deliberately separate.
|
|
||||||
use super::SpringConfig;
|
use super::SpringConfig;
|
||||||
|
|
||||||
/// Solve m y'' + c y' + k y = 0, with y = current - target.
|
/// 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::*;
|
use super::*;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ferese-panel = { path = "../ferese-panel" }
|
||||||
ferese-theme-model = { path = "../ferese-theme-model" }
|
ferese-theme-model = { path = "../ferese-theme-model" }
|
||||||
jiff.workspace = true
|
jiff.workspace = true
|
||||||
kdl = "6.7.1"
|
kdl = "6.7.1"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
pub mod families;
|
pub mod families;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod panel;
|
pub use ferese_panel::panel;
|
||||||
pub mod presets;
|
pub mod presets;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
|
||||||
@@ -14,17 +14,7 @@ use serde_json::{Map, Value};
|
|||||||
|
|
||||||
pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78;
|
pub const DEFAULT_MATERIAL_OPACITY: f64 = 0.78;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
pub use ferese_panel::{PanelStyle, default_island_padding};
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum PanelPreset {
|
|
||||||
#[default]
|
|
||||||
Continuous,
|
|
||||||
Islands,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn default_island_padding() -> f32 {
|
|
||||||
4.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Error(String);
|
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 {
|
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);
|
resolve_material_contrast(t, minimum);
|
||||||
}
|
}
|
||||||
if accessibility.reduce_transparency {
|
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)?;
|
let mut value = token_value(&base)?;
|
||||||
merge(&mut value, layer, "", false)?;
|
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)?;
|
validate(&tokens)?;
|
||||||
warnings.extend(authored_warnings(&tokens));
|
warnings.extend(authored_warnings(&tokens));
|
||||||
variants[index] = Some(tokens);
|
variants[index] = Some(tokens);
|
||||||
@@ -1159,6 +1172,7 @@ mod tests {
|
|||||||
Appearance::Dark => format!("theme {{ mode \"dark\"; dark {{ preset \"{}\"; }}; }}", entry.id),
|
Appearance::Dark => format!("theme {{ mode \"dark\"; dark {{ preset \"{}\"; }}; }}", entry.id),
|
||||||
};
|
};
|
||||||
let resolved = candidate(&source).unwrap().theme;
|
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.surface_raised, tokens.colors.surface_raised);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolved.tokens.colors.application_background,
|
resolved.tokens.colors.application_background,
|
||||||
@@ -1305,6 +1319,61 @@ mod tests {
|
|||||||
assert!(import_family("custom-test", "colors { accent \"#FFFFFF\"; }").is_err());
|
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]
|
#[test]
|
||||||
fn system_auto_is_context_driven_and_has_no_polling_deadline() {
|
fn system_auto_is_context_driven_and_has_no_polling_deadline() {
|
||||||
let document = Document::parse(r#"theme { mode "auto"; schedule { source "system"; }; }"#).unwrap();
|
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,
|
Tree,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub enum WorkspaceLayout {
|
pub enum WorkspaceLayout {
|
||||||
Scrolling(ScrollingLayout),
|
Scrolling(ScrollingLayout),
|
||||||
Tree(LayoutTree),
|
Tree(LayoutTree),
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ impl fmt::Display for LayoutError {
|
|||||||
|
|
||||||
impl Error for LayoutError {}
|
impl Error for LayoutError {}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
pub struct LayoutTree {
|
pub struct LayoutTree {
|
||||||
root: Option<NodeId>,
|
root: Option<NodeId>,
|
||||||
nodes: HashMap<NodeId, Node>,
|
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(crate) mod geometry;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub use geometry::PanelGeometry;
|
pub use geometry::{GeometryIntent, PanelGeometry};
|
||||||
mod radius;
|
mod radius;
|
||||||
use crate::PanelPreset;
|
use crate::PanelStyle;
|
||||||
pub use radius::CornerRadii;
|
pub use radius::CornerRadii;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
@@ -349,8 +348,6 @@ pub struct Group {
|
|||||||
pub id: GroupId,
|
pub id: GroupId,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub items: Vec<Item>,
|
pub items: Vec<Item>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub surface: Option<GroupSurface>,
|
|
||||||
#[serde(default = "spacing")]
|
#[serde(default = "spacing")]
|
||||||
pub spacing: f32,
|
pub spacing: f32,
|
||||||
#[serde(default = "padding")]
|
#[serde(default = "padding")]
|
||||||
@@ -364,7 +361,6 @@ impl Group {
|
|||||||
Self {
|
Self {
|
||||||
id: GroupId(id.into()),
|
id: GroupId(id.into()),
|
||||||
items,
|
items,
|
||||||
surface: None,
|
|
||||||
spacing: 1.0,
|
spacing: 1.0,
|
||||||
padding,
|
padding,
|
||||||
island_padding,
|
island_padding,
|
||||||
@@ -386,11 +382,11 @@ pub struct Panel {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub edge: Edge,
|
pub edge: Edge,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub geometry: PanelGeometry,
|
pub geometry: GeometryIntent,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub surface: PanelSurface,
|
pub style: PanelStyle,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "default_morph")]
|
||||||
pub group_surface: GroupSurface,
|
pub morph: crate::morph::Profile,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub border: bool,
|
pub border: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[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> {
|
pub fn validate(panels: &[Panel]) -> Result<(), String> {
|
||||||
if panels.len() != 1 {
|
if panels.len() != 1 {
|
||||||
return Err("configure exactly one panel; multiple panels are not supported yet".into());
|
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();
|
let mut panel_ids = HashSet::new();
|
||||||
for panel in panels {
|
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.geometry.validate()?;
|
||||||
|
panel.morph.validate()?;
|
||||||
if panel.group_count() > MAX_GROUPS {
|
if panel.group_count() > MAX_GROUPS {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"a panel supports at most {MAX_GROUPS} groups, reserving one region for overflow"
|
"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
|
if let Some(opacity) = panel.background_opacity
|
||||||
&& (!opacity.is_finite() || !(0.0..=1.0).contains(&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 {
|
if let Some(radius) = panel.corner_radius {
|
||||||
radius.validate()?;
|
radius.validate()?;
|
||||||
@@ -486,40 +490,69 @@ pub fn validate(panels: &[Panel]) -> Result<(), String> {
|
|||||||
|
|
||||||
impl Default for Panel {
|
impl Default for Panel {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::from_preset(PanelPreset::Continuous)
|
Self::from_preset(PanelStyle::Flat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Panel {
|
impl Panel {
|
||||||
pub fn resolved_surface(&self, group: &Group) -> GroupSurface {
|
pub fn preset(&self) -> Option<PanelStyle> {
|
||||||
group.surface.unwrap_or(self.group_surface)
|
Some(self.style)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn preset(&self) -> Option<PanelPreset> {
|
pub fn apply_preset(&mut self, style: PanelStyle) {
|
||||||
if [&self.start, &self.center, &self.end]
|
self.style = style;
|
||||||
.into_iter()
|
}
|
||||||
.flat_map(|zone| &zone.groups)
|
|
||||||
.any(|group| group.surface.is_some_and(|surface| surface != self.group_surface))
|
pub fn surface(&self) -> PanelSurface {
|
||||||
{
|
if matches!(self.style, PanelStyle::Flat | PanelStyle::Frame) {
|
||||||
return None;
|
PanelSurface::Solid
|
||||||
}
|
} else {
|
||||||
match (self.surface, self.group_surface) {
|
PanelSurface::None
|
||||||
(PanelSurface::Solid, GroupSurface::None) => Some(PanelPreset::Continuous),
|
|
||||||
(PanelSurface::None, GroupSurface::Island) => Some(PanelPreset::Islands),
|
|
||||||
_ => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_preset(&mut self, preset: PanelPreset) {
|
pub fn group_surface(&self) -> GroupSurface {
|
||||||
(self.surface, self.group_surface) = match preset {
|
if self.surface() == PanelSurface::Solid {
|
||||||
PanelPreset::Continuous => (PanelSurface::Solid, GroupSurface::None),
|
GroupSurface::None
|
||||||
PanelPreset::Islands => (PanelSurface::None, GroupSurface::Island),
|
} else {
|
||||||
};
|
GroupSurface::Island
|
||||||
for group in [&mut self.start, &mut self.center, &mut self.end]
|
}
|
||||||
.into_iter()
|
}
|
||||||
.flat_map(|zone| &mut zone.groups)
|
|
||||||
{
|
pub fn zone_spacing(&self, zone: &Zone) -> f32 {
|
||||||
group.surface = None;
|
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.spacing == right.spacing
|
||||||
&& left.padding == right.padding
|
&& left.padding == right.padding
|
||||||
&& left.island_padding == right.island_padding
|
&& left.island_padding == right.island_padding
|
||||||
&& (self.resolved_surface(left) == GroupSurface::Island)
|
&& (self.group_surface() == GroupSurface::Island)
|
||||||
== (other.resolved_surface(right) == GroupSurface::Island)
|
== (other.group_surface() == GroupSurface::Island)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
self.id == other.id
|
self.id == other.id
|
||||||
&& self.edge == other.edge
|
&& 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.start, &other.start)
|
||||||
&& same_zone(&self.center, &other.center)
|
&& same_zone(&self.center, &other.center)
|
||||||
&& same_zone(&self.end, &other.end)
|
&& same_zone(&self.end, &other.end)
|
||||||
@@ -571,7 +605,6 @@ impl Panel {
|
|||||||
overflow: OverflowPolicy::Never,
|
overflow: OverflowPolicy::Never,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
}],
|
}],
|
||||||
surface: None,
|
|
||||||
spacing: 0.0,
|
spacing: 0.0,
|
||||||
padding: [2, 3],
|
padding: [2, 3],
|
||||||
island_padding: self
|
island_padding: self
|
||||||
@@ -591,7 +624,7 @@ impl Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a stable initial composition; presets are an editor convenience.
|
/// 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::*;
|
use ItemKind::*;
|
||||||
|
|
||||||
let item = Item::new;
|
let item = Item::new;
|
||||||
@@ -617,7 +650,7 @@ impl Panel {
|
|||||||
let display = item("display-mode", DisplayMode);
|
let display = item("display-mode", DisplayMode);
|
||||||
|
|
||||||
let (start, center, end) = match preset {
|
let (start, center, end) = match preset {
|
||||||
PanelPreset::Continuous => (
|
PanelStyle::Flat => (
|
||||||
vec![
|
vec![
|
||||||
Group::new("overview", vec![overview], [0, 0], padding),
|
Group::new("overview", vec![overview], [0, 0], padding),
|
||||||
Group::new("workspaces", vec![workspaces], [2, 3], padding),
|
Group::new("workspaces", vec![workspaces], [2, 3], padding),
|
||||||
@@ -629,7 +662,7 @@ impl Panel {
|
|||||||
Group::new("display", vec![display], [0, 0], padding),
|
Group::new("display", vec![display], [0, 0], padding),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
PanelPreset::Islands => {
|
_ => {
|
||||||
let mut controls = controls;
|
let mut controls = controls;
|
||||||
controls.extend([clock, display]);
|
controls.extend([clock, display]);
|
||||||
(
|
(
|
||||||
@@ -643,23 +676,15 @@ impl Panel {
|
|||||||
Self {
|
Self {
|
||||||
id: PanelId("main".into()),
|
id: PanelId("main".into()),
|
||||||
edge: Edge::Top,
|
edge: Edge::Top,
|
||||||
geometry: PanelGeometry::default(),
|
geometry: GeometryIntent::default(),
|
||||||
surface: if preset == PanelPreset::Continuous {
|
style: preset,
|
||||||
PanelSurface::Solid
|
morph: Default::default(),
|
||||||
} else {
|
|
||||||
PanelSurface::None
|
|
||||||
},
|
|
||||||
group_surface: if preset == PanelPreset::Continuous {
|
|
||||||
GroupSurface::None
|
|
||||||
} else {
|
|
||||||
GroupSurface::Island
|
|
||||||
},
|
|
||||||
border: false,
|
border: false,
|
||||||
background_opacity: Option::None,
|
background_opacity: Option::None,
|
||||||
corner_radius: Option::None,
|
corner_radius: Option::None,
|
||||||
start: Zone {
|
start: Zone {
|
||||||
groups: start,
|
groups: start,
|
||||||
spacing: if preset == PanelPreset::Islands { 8.0 } else { 3.0 },
|
spacing: if preset == PanelStyle::Floating { 8.0 } else { 3.0 },
|
||||||
},
|
},
|
||||||
center: Zone {
|
center: Zone {
|
||||||
groups: vec![center],
|
groups: vec![center],
|
||||||
@@ -677,9 +702,27 @@ impl Panel {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn group_capacity_reserves_overflow_even_for_empty_groups() {
|
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);
|
let mut panel = Panel::from_preset(background);
|
||||||
panel.start.groups.clear();
|
panel.start.groups.clear();
|
||||||
panel.center.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]
|
#[test]
|
||||||
fn defaults_preserve_order_and_ids_across_background_changes() {
|
fn defaults_preserve_order_and_ids_across_background_changes() {
|
||||||
let continuous = Panel::default();
|
let continuous = Panel::default();
|
||||||
let islands = Panel::from_preset(PanelPreset::Islands);
|
let islands = Panel::from_preset(PanelStyle::Floating);
|
||||||
let expected = [
|
let expected = [
|
||||||
"overview",
|
"overview",
|
||||||
"workspaces",
|
"workspaces",
|
||||||
@@ -799,7 +806,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn settings_map_to_instances_and_group_presentation() {
|
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;
|
panel.center.groups[0].items[0].visible = false;
|
||||||
let battery = panel.end.groups[0]
|
let battery = panel.end.groups[0]
|
||||||
.items
|
.items
|
||||||
@@ -835,162 +842,6 @@ mod tests {
|
|||||||
assert_eq!(first.kind, second.kind);
|
assert_eq!(first.kind, second.kind);
|
||||||
assert_ne!(first.id, second.id);
|
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]
|
#[test]
|
||||||
fn removed_item_fields_are_rejected() {
|
fn removed_item_fields_are_rejected() {
|
||||||
for value in [
|
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]
|
#[test]
|
||||||
fn surface_changes_only_replace_composition_when_item_geometry_changes() {
|
fn surface_changes_only_replace_composition_when_item_geometry_changes() {
|
||||||
let panel = Panel::default();
|
let panel = Panel::default();
|
||||||
let mut changed = panel.clone();
|
let mut changed = panel.clone();
|
||||||
changed.group_surface = GroupSurface::Inset;
|
changed.style = PanelStyle::Flat;
|
||||||
assert!(panel.same_composition(&changed));
|
assert!(panel.same_composition(&changed));
|
||||||
changed.end.groups[0].surface = Some(GroupSurface::Island);
|
changed.style = PanelStyle::Floating;
|
||||||
assert!(!panel.same_composition(&changed));
|
assert!(!panel.same_composition(&changed));
|
||||||
changed.end.groups[0].surface = Some(GroupSurface::None);
|
changed.style = PanelStyle::Flat;
|
||||||
assert!(panel.same_composition(&changed));
|
assert!(panel.same_composition(&changed));
|
||||||
changed.end.groups[0].padding[1] += 1;
|
changed.end.groups[0].padding[1] += 1;
|
||||||
assert!(!panel.same_composition(&changed));
|
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 super::{ItemId, OverflowPolicy, Panel, Representation, Zone};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ fn zone_width(panel: &Panel, zone: &Zone, items: &BTreeMap<ItemId, Placement>) -
|
|||||||
}
|
}
|
||||||
if occupied {
|
if occupied {
|
||||||
let padding = 2.0 * f32::from(group.padding[1]);
|
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
|
2.0 * group.island_padding
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
@@ -56,7 +55,7 @@ fn zone_width(panel: &Panel, zone: &Zone, items: &BTreeMap<ItemId, Placement>) -
|
|||||||
group_count += 1;
|
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 {
|
impl Resolution {
|
||||||
@@ -69,7 +68,7 @@ impl Resolution {
|
|||||||
if !self.overflow.is_empty() {
|
if !self.overflow.is_empty() {
|
||||||
self.zone_widths[2] += overflow_width
|
self.zone_widths[2] += overflow_width
|
||||||
+ if self.zone_widths[2] > 0.0 {
|
+ if self.zone_widths[2] > 0.0 {
|
||||||
panel.end.spacing
|
panel.zone_spacing(&panel.end)
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
@@ -297,7 +296,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zone_width_ignores_empty_groups_and_gaps_before_hidden_items() {
|
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 panel = Panel::default();
|
||||||
let mut first = Item::new("first", ItemKind::Overview);
|
let mut first = Item::new("first", ItemKind::Overview);
|
||||||
first.gap_before = Some(99.);
|
first.gap_before = Some(99.);
|
||||||
@@ -305,7 +304,7 @@ mod tests {
|
|||||||
let mut last = Item::new("last", ItemKind::Clock);
|
let mut last = Item::new("last", ItemKind::Clock);
|
||||||
last.gap_before = Some(7.);
|
last.gap_before = Some(7.);
|
||||||
let mut group = Group::new("content", vec![first, hidden, last], [0, 3], 5.);
|
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.;
|
group.island_padding = 4.;
|
||||||
let empty = Group::new("empty", vec![Item::new("missing", ItemKind::Media)], [0, 99], 99.);
|
let empty = Group::new("empty", vec![Item::new("missing", ItemKind::Media)], [0, 99], 99.);
|
||||||
let trailing = Group::new(
|
let trailing = Group::new(
|
||||||
@@ -341,7 +340,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
.into();
|
.into();
|
||||||
assert_eq!(zone_width(&panel, &panel.start, &items), 96.);
|
assert_eq!(zone_width(&panel, &panel.start, &items), 106.);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -583,7 +582,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn narrow_panels_adapt_deterministically_and_keep_every_item_reachable() {
|
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 panel = Panel::from_preset(background);
|
||||||
let measured = measurements(&panel);
|
let measured = measurements(&panel);
|
||||||
for width in [2400., 1200., 800., 400., 160., 80., 32.] {
|
for width in [2400., 1200., 800., 400., 160., 80., 32.] {
|
||||||
@@ -603,7 +602,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decoration_and_borders_do_not_change_background_arrangement_or_allocation() {
|
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 mut panel = Panel::from_preset(background);
|
||||||
let measurements = measurements(&panel);
|
let measurements = measurements(&panel);
|
||||||
let plain = resolve(&panel, &measurements, 1000., 32.);
|
let plain = resolve(&panel, &measurements, 1000., 32.);
|
||||||
@@ -717,20 +716,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn only_island_groups_reserve_island_padding() {
|
fn panel_surface_policy_controls_padding_for_every_group() {
|
||||||
let mut panel = Panel::from_preset(crate::PanelPreset::Islands);
|
let mut panel = Panel::from_preset(crate::PanelStyle::Floating);
|
||||||
let measured = measurements(&panel);
|
let measured = measurements(&panel);
|
||||||
let initial = resolve(&panel, &measured, 2400., 32.);
|
let initial = resolve(&panel, &measured, 2400., 32.);
|
||||||
let title_padding = 2. * panel.center.groups[0].island_padding;
|
panel.style = crate::PanelStyle::Flat;
|
||||||
panel.center.groups[0].surface = Some(super::super::GroupSurface::None);
|
|
||||||
let plain = resolve(&panel, &measured, 2400., 32.);
|
let plain = resolve(&panel, &measured, 2400., 32.);
|
||||||
assert_eq!(plain.zone_widths[1], initial.zone_widths[1] - title_padding);
|
for (i, zone) in [&panel.start, &panel.center, &panel.end].into_iter().enumerate() {
|
||||||
assert_eq!(plain.zone_widths[0], initial.zone_widths[0]);
|
let padding: f32 = zone.groups.iter().map(|group| 2. * group.island_padding).sum();
|
||||||
assert_eq!(plain.zone_widths[2], initial.zone_widths[2]);
|
assert_eq!(plain.zone_widths[i], initial.zone_widths[i] - padding);
|
||||||
panel.center.groups[0].surface = Some(super::super::GroupSurface::Inset);
|
}
|
||||||
|
panel.style = crate::PanelStyle::Flat;
|
||||||
assert_eq!(resolve(&panel, &measured, 2400., 32.), plain);
|
assert_eq!(resolve(&panel, &measured, 2400., 32.), plain);
|
||||||
// A parent panel fill cannot change a group's allocation policy.
|
panel.style = crate::PanelStyle::Flat;
|
||||||
panel.surface = super::super::PanelSurface::Solid;
|
|
||||||
assert_eq!(resolve(&panel, &measured, 2400., 32.), plain);
|
assert_eq!(resolve(&panel, &measured, 2400., 32.), plain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,25 @@ impl CornerRadii {
|
|||||||
let limit = width.min(height).max(0.) / 2.;
|
let limit = width.min(height).max(0.) / 2.;
|
||||||
Self(self.0.map(|value| value.min(limit)))
|
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 {
|
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)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
/// Semantic surface effects protocol.
|
/// 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 material;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Resolved theme values and pure color operations. No configuration or runtime access.
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
mod color;
|
mod color;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ license.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ferese-layout = { path = "../ferese-layout" }
|
||||||
|
ferese-panel = { path = "../ferese-panel" }
|
||||||
jiff.workspace = true
|
jiff.workspace = true
|
||||||
ferese-theme-model = { path = "../ferese-theme-model" }
|
ferese-theme-model = { path = "../ferese-theme-model" }
|
||||||
ferese-config = { path = "../ferese-config" }
|
ferese-config = { path = "../ferese-config" }
|
||||||
|
|||||||
@@ -38,6 +38,32 @@ pub fn grid<'a, M: Clone + 'a>(
|
|||||||
opacity: f32,
|
opacity: f32,
|
||||||
navigation: [Element<'a, M>; 3],
|
navigation: [Element<'a, M>; 3],
|
||||||
) -> Element<'a, M> {
|
) -> 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 = [
|
let months = [
|
||||||
"January",
|
"January",
|
||||||
"February",
|
"February",
|
||||||
@@ -60,7 +86,7 @@ pub fn grid<'a, M: Clone + 'a>(
|
|||||||
let [previous, today_button, next] = navigation;
|
let [previous, today_button, next] = navigation;
|
||||||
let header = row![
|
let header = row![
|
||||||
crate::text(format!("{} {year}", months[(month - 1) as usize]), font)
|
crate::text(format!("{} {year}", months[(month - 1) as usize]), font)
|
||||||
.size(15)
|
.size(if compact { 13 } else { 15 })
|
||||||
.width(Length::Fill),
|
.width(Length::Fill),
|
||||||
previous,
|
previous,
|
||||||
today_button,
|
today_button,
|
||||||
@@ -68,18 +94,31 @@ pub fn grid<'a, M: Clone + 'a>(
|
|||||||
]
|
]
|
||||||
.align_y(Alignment::Center)
|
.align_y(Alignment::Center)
|
||||||
.spacing(2);
|
.spacing(2);
|
||||||
let mut grid = column::with_capacity(8).spacing(3).push(header);
|
let mut grid = column::with_capacity(8)
|
||||||
let mut weekdays = row::with_capacity(7).spacing(3);
|
.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"] {
|
for label in ["M", "T", "W", "T", "F", "S", "S"] {
|
||||||
weekdays = weekdays.push(
|
weekdays = weekdays.push(
|
||||||
container(crate::text(label, font).size(11).class(theme::Text::Color(muted)))
|
container(
|
||||||
.width(30)
|
crate::text(label, font)
|
||||||
.center_x(30),
|
.size(if compact { 9 } else { 11 })
|
||||||
|
.class(theme::Text::Color(muted)),
|
||||||
|
)
|
||||||
|
.width(cell_width)
|
||||||
|
.center_x(cell_width),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
grid = grid.push(weekdays);
|
grid = grid.push(weekdays);
|
||||||
for week in 0..(start + days).div_ceil(7) {
|
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 {
|
for weekday in 0..7 {
|
||||||
let cell = week * 7 + weekday;
|
let cell = week * 7 + weekday;
|
||||||
let day = cell.checked_sub(start).map(|day| day + 1).filter(|day| *day <= days);
|
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 {
|
} else {
|
||||||
muted
|
muted
|
||||||
})))
|
})))
|
||||||
.width(30)
|
.width(cell_width)
|
||||||
.height(25)
|
.height(cell_height)
|
||||||
.center_x(30)
|
.center_x(cell_width)
|
||||||
.center_y(25)
|
.center_y(cell_height)
|
||||||
.class(theme::Container::custom(move |_| container::Style {
|
.class(theme::Container::custom(move |_| container::Style {
|
||||||
background: is_today.then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))),
|
background: is_today.then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))),
|
||||||
border: Border {
|
border: Border {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod icons;
|
|||||||
pub mod menus;
|
pub mod menus;
|
||||||
mod palette;
|
mod palette;
|
||||||
pub mod panel;
|
pub mod panel;
|
||||||
|
pub mod panel_surface;
|
||||||
mod typography;
|
mod typography;
|
||||||
pub mod workspaces;
|
pub mod workspaces;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
|
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
|
||||||
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
|
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
|
||||||
use cosmic::{Element, Theme};
|
use cosmic::{Element, Theme};
|
||||||
use ferese_config::panel::layout::{Measurement, Resolution, resolve_with_gap};
|
use ferese_panel::layout::{Measurement, Resolution, resolve_with_gap};
|
||||||
use ferese_config::panel::{ItemId, Panel, Representation};
|
use ferese_panel::{ItemId, Panel, Representation};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
/// Group outlines never paint a second fill over the panel background.
|
/// 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::advanced::{clipboard, renderer::Headless};
|
||||||
use cosmic::iced::{Font, Pixels, Point};
|
use cosmic::iced::{Font, Pixels, Point};
|
||||||
use cosmic::widget::{button, text};
|
use cosmic::widget::{button, text};
|
||||||
use ferese_config::panel::OverflowPolicy;
|
use ferese_panel::OverflowPolicy;
|
||||||
use ferese_config::panel::layout::Placement;
|
use ferese_panel::layout::Placement;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
enum Probe {
|
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::iced::{Alignment, Color, Length};
|
||||||
use cosmic::widget::{Space, container, row};
|
use cosmic::widget::{Space, container, row};
|
||||||
use cosmic::{Element, theme};
|
use cosmic::{Element, theme};
|
||||||
use ferese_config::panel::WorkspaceStyle;
|
use ferese_panel::WorkspaceStyle;
|
||||||
|
|
||||||
use crate::Palette;
|
use crate::Palette;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- [Installation](installation.md): install dependencies, log in, preview changes, update and recover a session.
|
- [Installation](installation.md): install dependencies, log in, preview changes, update and recover a session.
|
||||||
- [Configuration](configuration.md): configure layouts, input, shortcuts and displays in KDL.
|
- [Configuration](configuration.md): configure layouts, input, shortcuts and displays in KDL.
|
||||||
|
- [Panel configuration](panel-configuration.md): choose a panel style, arrange groups and items, and configure the Morph hub.
|
||||||
- [Why Ferese](why-ferese.md): window layouts and a shared theme for the desktop.
|
- [Why Ferese](why-ferese.md): window layouts and a shared theme for the desktop.
|
||||||
- [Screenshots](screenshots.md): see the themes, overview and shell controls.
|
- [Screenshots](screenshots.md): see the themes, overview and shell controls.
|
||||||
|
|
||||||
@@ -21,5 +22,4 @@
|
|||||||
- [Development](development.md): build Ferese, run tests and check portal contracts.
|
- [Development](development.md): build Ferese, run tests and check portal contracts.
|
||||||
- [Nested resize checks](nested-resize-testing.md).
|
- [Nested resize checks](nested-resize-testing.md).
|
||||||
- [Client soak testing](native-soak-testing.md).
|
- [Client soak testing](native-soak-testing.md).
|
||||||
- [Performance measurements](performance-baseline.md).
|
|
||||||
- [Website development](../site/README.md).
|
- [Website development](../site/README.md).
|
||||||
@@ -69,6 +69,12 @@ The main spring responds more quickly than the viewport spring, allowing a windo
|
|||||||
|
|
||||||
Bounce must be strictly between −1 and 1. Negative values produce overdamping; positive values require `overshoot #true` so the crossing policy permits the requested bounce. Duration and speed must be finite and positive, and the resolved stiffness and damping must remain finite and positive too. If the configuration fails these checks, Ferese keeps the last accepted settings.
|
Bounce must be strictly between −1 and 1. Negative values produce overdamping; positive values require `overshoot #true` so the crossing policy permits the requested bounce. Duration and speed must be finite and positive, and the resolved stiffness and damping must remain finite and positive too. If the configuration fails these checks, Ferese keeps the last accepted settings.
|
||||||
|
|
||||||
|
The [Morph panel](panel-configuration.md#morph) uses the same spring solver,
|
||||||
|
with its own response and the shared speed and reduced-motion settings. Content
|
||||||
|
is laid out at the destination size while the shape expands around it, and
|
||||||
|
fades handle changes between views. The space kept clear of windows stays
|
||||||
|
unchanged throughout; regular status popups continue to use the main shell spring.
|
||||||
|
|
||||||
The global speed setting scales elapsed spring time, so 0.5 takes twice as long and 2.0 takes half as long. It applies to both compositor and Ferese shell motion. Setting `reduced-motion #true` takes precedence over enabled animations and applies the destination directly. Neither setting controls animations that applications draw inside their own windows.
|
The global speed setting scales elapsed spring time, so 0.5 takes twice as long and 2.0 takes half as long. It applies to both compositor and Ferese shell motion. Setting `reduced-motion #true` takes precedence over enabled animations and applies the destination directly. Neither setting controls animations that applications draw inside their own windows.
|
||||||
|
|
||||||
## Passing a gesture’s velocity into the release
|
## Passing a gesture’s velocity into the release
|
||||||
@@ -107,7 +113,7 @@ These slides use the viewport spring through [workspace navigation](../composito
|
|||||||
|
|
||||||
An application’s surface lifetime does not always match the duration of its visible exit. Window entrance starts when content is available, but on unmap the live surface can disappear before a closing animation would finish. Ferese retains content for that exit, moving toward 98% of the current bounds while opacity, emphasis, and shadow fade to zero.
|
An application’s surface lifetime does not always match the duration of its visible exit. Window entrance starts when content is available, but on unmap the live surface can disappear before a closing animation would finish. Ferese retains content for that exit, moving toward 98% of the current bounds while opacity, emphasis, and shadow fade to zero.
|
||||||
|
|
||||||
Closing during resize also retains the resize snapshots and their handoffs. Their progress must continue during close so the old buffer does not remain frozen over the exiting image. Retaining a picture is only part of the work; the presentation state that determines how it blends with the newer content has to continue advancing too.
|
Closing during resize also retains the resize snapshots and their handoffs. Their progress must continue during close so the old buffer does not remain frozen over the exiting image. The presentation state must keep advancing so the retained image can blend with the newer content.
|
||||||
|
|
||||||
Shell popups keep their surface until the closing motion settles, then destroy it. If you reopen the same panel during close, Ferese retargets that existing motion. Switching from Battery to Wi-Fi is different: the surface stays, but the new panel gets its own entrance. This behavior lives in [panel lifecycle handling](../shell/ferese-shell/src/status_ui/lifecycle.rs) and avoids a destroy-then-create gap between panels.
|
Shell popups keep their surface until the closing motion settles, then destroy it. If you reopen the same panel during close, Ferese retargets that existing motion. Switching from Battery to Wi-Fi is different: the surface stays, but the new panel gets its own entrance. This behavior lives in [panel lifecycle handling](../shell/ferese-shell/src/status_ui/lifecycle.rs) and avoids a destroy-then-create gap between panels.
|
||||||
|
|
||||||
|
|||||||
+58
-176
@@ -40,15 +40,16 @@ output-profile "docked" {
|
|||||||
```
|
```
|
||||||
|
|
||||||
Lists use repeated `panel`, `group`, `item`, `binding`, `window-rule`,
|
Lists use repeated `panel`, `group`, `item`, `binding`, `window-rule`,
|
||||||
`output-profile`, `output`, `note`, and `autostart` nodes. Bindings take their keys,
|
`output-profile`, `output`, and `autostart` nodes. Bindings take their keys,
|
||||||
action, and argument positionally; profiles, outputs, and notes take a positional
|
action, and argument positionally; profiles and outputs take a positional
|
||||||
name, match, or ID. Panels, groups, and items use positional IDs, while autostart
|
name or match. Panels, groups, and items use positional IDs, while autostart
|
||||||
commands use positional arguments. Other fields can be properties or child nodes,
|
commands use positional arguments. Other fields can be properties or child nodes,
|
||||||
but duplicate fields are rejected. The dotted paths in the reference tables
|
but duplicate fields are rejected. The dotted paths in the reference tables
|
||||||
below describe nested sections.
|
below describe nested sections.
|
||||||
|
|
||||||
Settings preserves comments and custom fields when editing, while normalizing
|
Settings preserves comments and untouched configuration content when editing,
|
||||||
indentation.
|
while normalizing indentation. Panel item annotations belong in an explicit
|
||||||
|
`metadata` section; unrecognized built-in options are rejected.
|
||||||
|
|
||||||
The Settings sidebar groups pages under Desktop, Interaction, and System, with
|
The Settings sidebar groups pages under Desktop, Interaction, and System, with
|
||||||
Motion under Windows and Shortcuts under Keyboard & mouse. Search includes both
|
Motion under Windows and Shortcuts under Keyboard & mouse. Search includes both
|
||||||
@@ -57,10 +58,10 @@ views and opens the matching setting directly.
|
|||||||
## Saving and validation
|
## Saving and validation
|
||||||
|
|
||||||
Settings saves text when you press Enter or leave the field, and sliders when
|
Settings saves text when you press Enter or leave the field, and sliders when
|
||||||
you release them. Undo restores the previous save. Reload reads external edits
|
you release them, keeping `config.kdl.settings-backup` before each save. Use
|
||||||
without merging unfinished drafts. Wallpaper browsing needs `zenity`, but you
|
Undo to restore the previous save, or Reload to read external edits without
|
||||||
can also enter a path directly.
|
merging unfinished drafts. To browse for a wallpaper, install `zenity`; you can
|
||||||
Settings keeps `config.kdl.settings-backup` before saving.
|
also enter its path directly.
|
||||||
|
|
||||||
File edits reload automatically, including atomic editor saves, so changes to
|
File edits reload automatically, including atomic editor saves, so changes to
|
||||||
appearance, wallpaper, motion, input, bindings, rules, layouts, displays,
|
appearance, wallpaper, motion, input, bindings, rules, layouts, displays,
|
||||||
@@ -93,10 +94,10 @@ cancels pending pairing. Passwords are sent to NetworkManager, not saved in KDL;
|
|||||||
pairing keys are managed by BlueZ. These controls affect the system even when
|
pairing keys are managed by BlueZ. These controls affect the system even when
|
||||||
Settings is opened with a separate preview config.
|
Settings is opened with a separate preview config.
|
||||||
|
|
||||||
Defaults below are built-in defaults, not your personal or packaged overrides.
|
The tables below give built-in defaults; your configuration or the packaged
|
||||||
Dimensions are logical pixels unless noted; numbers must be finite. Optional
|
example may override them. Dimensions use logical pixels unless noted, and
|
||||||
values are omitted, not `null`. Colors use `#RRGGBB` or `#RRGGBBAA`.
|
numbers must be finite. Omit optional values rather than writing `null`, use
|
||||||
Live config is limited to 60 KiB.
|
`#RRGGBB` or `#RRGGBBAA` for colors, and keep the live config within 60 KiB.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -186,6 +187,10 @@ immediate. Reduced motion takes precedence over `enabled #true`; setting
|
|||||||
`speed 0` is invalid. These settings control Ferese's animations, not animations
|
`speed 0` is invalid. These settings control Ferese's animations, not animations
|
||||||
inside other applications.
|
inside other applications.
|
||||||
|
|
||||||
|
Morph animates its shape with its own spring and fades between views, while
|
||||||
|
following the shared speed and reduced-motion policy; see the
|
||||||
|
[panel guide](panel-configuration.md#morph) for its states and size controls.
|
||||||
|
|
||||||
Shell animations sample motion on each surface's redraw, paced by its Wayland
|
Shell animations sample motion on each surface's redraw, paced by its Wayland
|
||||||
frame callbacks. They request animation frames only while moving.
|
frame callbacks. They request animation frames only while moving.
|
||||||
Menus, modals, notifications and hover fades use this path instead of a fixed
|
Menus, modals, notifications and hover fades use this path instead of a fixed
|
||||||
@@ -326,6 +331,7 @@ is set.
|
|||||||
| `theme.colors` key | Default | Meaning |
|
| `theme.colors` key | Default | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `surface-base` | `"#111821"` | Material/card base color |
|
| `surface-base` | `"#111821"` | Material/card base color |
|
||||||
|
| `surface-raised` | `"#1E2530"` | Raised surfaces, including the Morph hub |
|
||||||
| `text-primary` | `"#F4F7FB"` | Main text |
|
| `text-primary` | `"#F4F7FB"` | Main text |
|
||||||
| `text-muted` | `"#8793A2"` | Secondary text |
|
| `text-muted` | `"#8793A2"` | Secondary text |
|
||||||
| `accent` | `"#3D7BE6"` | Active controls and solid focus ring |
|
| `accent` | `"#3D7BE6"` | Active controls and solid focus ring |
|
||||||
@@ -339,14 +345,14 @@ Filled controls choose a contrasting text color automatically.
|
|||||||
| `border-width` | number ≥ 0 | `1` | Window border thickness |
|
| `border-width` | number ≥ 0 | `1` | Window border thickness |
|
||||||
| `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness |
|
| `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness |
|
||||||
| `window-radius` | number ≥ 0 | `14` | Managed-window corners, independent of the shell |
|
| `window-radius` | number ≥ 0 | `14` | Managed-window corners, independent of the shell |
|
||||||
| `shell-radius` | number ≥ 0 | `14` | All shell surfaces, cards and interaction backgrounds; fractional radii are preserved; 0 makes them square |
|
| `shell-radius` | number ≥ 0 | `14` | Shell surfaces and controls, apart from panel shapes; fractional radii are preserved; 0 makes them square |
|
||||||
| `control-gap` | number ≥ 0 | `12` | Right-side control spacing |
|
| `control-gap` | number ≥ 0 | `12` | Shared control spacing token; panel spacing belongs to its groups |
|
||||||
|
|
||||||
| Section / key | Type | Default | Meaning |
|
| Section / key | Type | Default | Meaning |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `theme.surface.bar.background` | color | `"#1C202EF2"` | Shell fallback background; compositor materials use `surface-base` |
|
| `theme.surface.bar.background` | color | `"#111821"` | Regular panel background color; Morph uses `surface-raised` |
|
||||||
| `theme.surface.bar.text-primary` | color | `"#F0F3FA"` | Bar text/icons |
|
| `theme.surface.bar.text-primary` | color | `"#F4F7FB"` | Panel text/icons |
|
||||||
| `theme.surface.bar.text-muted` | color | `"#AAB4C7"` | Inactive bar foreground |
|
| `theme.surface.bar.text-muted` | color | `"#8793A2"` | Inactive panel foreground |
|
||||||
| `theme.shadow.soft.offset-y` | number | `4` | Window shadow vertical offset |
|
| `theme.shadow.soft.offset-y` | number | `4` | Window shadow vertical offset |
|
||||||
| `theme.shadow.soft.blur` | number ≥ 0 | `18` | Window shadow softness |
|
| `theme.shadow.soft.blur` | number ≥ 0 | `18` | Window shadow softness |
|
||||||
| `theme.shadow.soft.opacity` | number 0–1 | `0.20` | Window shadow strength |
|
| `theme.shadow.soft.opacity` | number 0–1 | `0.20` | Window shadow strength |
|
||||||
@@ -808,116 +814,43 @@ intentionally turning off the last working one.
|
|||||||
|
|
||||||
## Panel composition
|
## Panel composition
|
||||||
|
|
||||||
Ferese shows one panel on each output, at the top by default. You can use the
|
Ferese uses one panel configuration across all outputs, with each output laying
|
||||||
default arrangement or define a `panel` block with its own geometry, surface
|
out its own workspace controls and focused-window title. Open **Settings →
|
||||||
defaults, ordered groups, and item settings.
|
Panel & Shell** to choose a style and position, then **Edit panel** to arrange
|
||||||
|
items in Start, Center and End groups. The composition preview stays visible
|
||||||
|
while you edit, and changes save automatically.
|
||||||
|
|
||||||
In Settings → **Panel & Shell**, **Panels** contains the position, background,
|
Choose style, opacity and corners for the whole panel; groups control layout and
|
||||||
size, spacing, and corner-radius controls. Choose **Edit panel** to open
|
spacing. The available styles are Floating, Capsules, Notch, Edge to edge,
|
||||||
**Arrange**, where you can organize groups and items while the composition preview
|
Fused islands, Adaptive and Morph. Capsules and Fused islands join neighboring
|
||||||
stays visible above the independently scrolling editor and inspector.
|
**groups within a zone**, so a zone with one group remains a single island.
|
||||||
**Back to Panels** returns to the panel controls.
|
Adaptive follows window state independently on each output, while Morph combines
|
||||||
|
the panel into a centered hub with expandable workspace, media and status views.
|
||||||
|
|
||||||
Use **Position** to choose Top or Bottom, or set `edge "top"` or `edge "bottom"`
|
Most panels default to 36 logical pixels high, Morph to 34, and detached styles
|
||||||
inside the KDL panel block. Menus and notifications open toward the desktop, with
|
use 8-pixel margins unless your configuration overrides them. The packaged
|
||||||
window clearance and **Edge margin** following that screen edge. Panel dimensions
|
configuration includes its own geometry overrides. For the complete options and
|
||||||
live in a separate `geometry` block, so appearance changes do not resize the
|
examples, see the [panel configuration guide](panel-configuration.md).
|
||||||
panel. Its fields are `height` (24–128), `edge-margin`, `side-margins`, and
|
|
||||||
`window-clearance` (0–4096), plus `inner-padding` (0–64); their defaults are 28, 0,
|
|
||||||
0, 0, and 12 logical pixels respectively.
|
|
||||||
|
|
||||||
Each group has an **Add item** menu. Select a control to open its inspector, where
|
|
||||||
you can change visibility, overflow, preferred size, and placement, or remove the
|
|
||||||
item. To reorder it, use the **Up / Down** arrow buttons or drag its handle; dragging
|
|
||||||
also lets you move it to another group, and dropping into an empty zone creates a
|
|
||||||
group. Hidden controls remain in the editor with faded icons and labels. These
|
|
||||||
changes use the existing save and Undo controls, and `ferese-settings --page bar`
|
|
||||||
opens the page directly.
|
|
||||||
|
|
||||||
Use **Add group** in Start, Center, or End to create an empty group, then select
|
|
||||||
its name to change placement, surface, spacing, and padding. Arrow buttons reorder
|
|
||||||
groups within a zone; before removing a group, move or remove its controls.
|
|
||||||
|
|
||||||
The **Background** presets set the panel fill and default group surface together:
|
|
||||||
Continuous uses `solid` for the panel and `none` for groups, while Islands uses
|
|
||||||
`none` for the panel and `island` for groups. Applying either preset clears group
|
|
||||||
surface overrides while keeping your arrangement intact.
|
|
||||||
|
|
||||||
Use **Default group surface** to choose what groups inherit, or select Default,
|
|
||||||
None, Inset, or Island in an individual group's inspector. None adds no fill,
|
|
||||||
Inset wraps the padded content in a rounded surface, and Island adds a full-height
|
|
||||||
surface with island padding. Groups on a filled panel use a subtle local tint,
|
|
||||||
allowing arrangements such as a continuous panel with one media island or
|
|
||||||
navigation and status islands around an undecorated title. With mixed settings,
|
|
||||||
neither preset button is selected.
|
|
||||||
|
|
||||||
**Group borders** controls the outlines of all groups and is off by default,
|
|
||||||
while opacity and corner radii apply across the panel.
|
|
||||||
|
|
||||||
**Background opacity** overrides shell opacity for the panel background in either
|
|
||||||
arrangement, without fading controls or changing popups. **Use shell opacity**
|
|
||||||
restores inheritance.
|
|
||||||
|
|
||||||
Choose **Customize corners** to override the radius for the whole panel,
|
|
||||||
including its groups. The four sliders control top left, top right, bottom right,
|
|
||||||
and bottom left, saving each change when you release the slider.
|
|
||||||
|
|
||||||
In KDL, pixel values follow CSS shorthand: one value sets all corners; two set
|
|
||||||
top-left/bottom-right and top-right/bottom-left; three set
|
|
||||||
top-left, top-right/bottom-left, and bottom-right; four run clockwise from
|
|
||||||
top-left. For example, `4px 8px 12px 16px` gives each corner a different radius.
|
|
||||||
Values must be finite and nonnegative; percentages and elliptical `/` syntax are
|
|
||||||
not supported. Radii are clamped to half the shorter surface dimension.
|
|
||||||
|
|
||||||
At zero edge margin, panel backgrounds reach the selected screen edge with square
|
|
||||||
corners where they touch it, without changing the saved radii. Islands preserve
|
|
||||||
the gaps between groups, while Continuous fills the whole panel.
|
|
||||||
|
|
||||||
**Side margins** moves the left and right ends inward together over a range of
|
|
||||||
0–4096 px. The shell limits the requested inset for each output using measured
|
|
||||||
content widths: controls may compact, but the inset stops before causing
|
|
||||||
additional overflow. As content grows or the output gets smaller, the applied
|
|
||||||
inset shrinks to make room. If the controls cannot fit even at zero margin, the
|
|
||||||
panel uses its normal overflow behavior.
|
|
||||||
|
|
||||||
The panel follows the shell radius unless you set an override, which stays in
|
|
||||||
place when the shell radius changes. **Use shell radius** clears that override
|
|
||||||
without changing the arrangement; **Remove custom panel** restores both the
|
|
||||||
default arrangement and its inherited radius.
|
|
||||||
|
|
||||||
Dragging changes the drop hint until release, then saves the complete move once.
|
|
||||||
Escape, focus loss, and release outside a drop target cancel without saving.
|
|
||||||
|
|
||||||
The composition preview follows your draft and adapts to the Settings window's
|
|
||||||
width using sample content with every service available. Select a preview control
|
|
||||||
to edit that instance, or use the chevron to reveal overflow items. Browsing and
|
|
||||||
selection do not save anything: the first edit saves the composition and the
|
|
||||||
change together. The preview shares the shell's sizing resolver, while live
|
|
||||||
service state and exact vertical styling can differ.
|
|
||||||
|
|
||||||
```kdl
|
```kdl
|
||||||
panel "main" {
|
panel "main" {
|
||||||
surface "none"
|
edge "top"
|
||||||
group-surface "island"
|
style "floating"
|
||||||
geometry { height 30; edge-margin 6; side-margins 18; window-clearance 4; }
|
geometry { height 36; edge-margin 8; window-clearance 4; }
|
||||||
border #false
|
|
||||||
background-opacity 0.85
|
|
||||||
start {
|
start {
|
||||||
group "navigation" {
|
group "navigation" {
|
||||||
item "overview" kind="overview" overflow="never"
|
item "overview" kind="overview" overflow="never"
|
||||||
item "workspaces" kind="workspaces" overflow="never"
|
item "workspaces" kind="workspaces" style="dots" overflow="never"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
center {
|
center {
|
||||||
group "title" {
|
group "title" {
|
||||||
surface "none"
|
|
||||||
item "title" kind="focused-window" overflow="never"
|
item "title" kind="focused-window" overflow="never"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end {
|
end {
|
||||||
group "status" {
|
group "status" {
|
||||||
item "network" kind="network" overflow="always"
|
item "quick-settings" kind="quick-settings"
|
||||||
item "audio" kind="audio"
|
|
||||||
item "battery" kind="battery" percentage=#true
|
item "battery" kind="battery" percentage=#true
|
||||||
item "clock" kind="clock" representation="compact"
|
item "clock" kind="clock" representation="compact"
|
||||||
}
|
}
|
||||||
@@ -925,64 +858,11 @@ panel "main" {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Start, center, and end contain ordered groups of item instances. Their IDs must
|
If you omit the panel block, Ferese uses its default composition; if you write
|
||||||
be unique within the panel, contain 1–128 ASCII letters/digits or `-_.:@`, and not
|
one, its groups and items define the contents, and empty zones stay empty. Only
|
||||||
start with `_`. Exactly one panel can be configured, without per-output overrides.
|
one panel block is accepted, and group and item IDs must be unique within it.
|
||||||
|
Use `edge "bottom"` to move the same arrangement to the bottom, with popups
|
||||||
Built-in kinds are `overview`, `workspaces`, `focused-window`, `media`,
|
opening toward the desktop and edge-relative corners mirroring vertically.
|
||||||
`quick-settings`, `network`, `audio`, `recording`, `notifications`, `battery`,
|
|
||||||
`clock`, and `display-mode`. Availability still follows the associated service
|
|
||||||
or hardware. Duplicate kinds are allowed when their IDs differ; clock instances
|
|
||||||
currently share the same clock format and timezone.
|
|
||||||
|
|
||||||
| Scope / key | Values / default | Meaning |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Panel `surface` | `solid` (default), `none` | Full panel fill or transparent space between decorated groups |
|
|
||||||
| Panel `group-surface` | `none` (default), `inset`, `island` | Inherited group surface |
|
|
||||||
| Group `surface` | omit to inherit, `none`, `inset`, `island` | Override surface independently of other groups |
|
|
||||||
| Panel `geometry` | height 28, edge/side margins 0, window clearance 0, inner padding 12 | Dimensions independent of the theme |
|
|
||||||
| Panel `border` | boolean; default `false` | Show group outlines |
|
|
||||||
| Panel `corner-radius` | optional px string, e.g. `"4px 8px"` | Override shell radius for all panel surfaces |
|
|
||||||
| Zone `spacing` | 0–64; default 8 | Gap between groups |
|
|
||||||
| Panel `background-opacity` | optional 0–1; inherits shell opacity | Override panel background opacity in either arrangement |
|
|
||||||
| Groups per panel | at most 31 | Reserves one of the 32 material regions for overflow |
|
|
||||||
| Group `spacing` | 0–64; default 1 | Gap between items |
|
|
||||||
| Group `padding` | two integers 0–32; default `2 3` | Vertical and horizontal inner padding |
|
|
||||||
| Group `island-padding` | 0–32; default 4 | Additional horizontal padding for islands |
|
|
||||||
| Item `visible` | boolean; default `true` | Allow the item when available |
|
|
||||||
| Item `overflow` | `auto` (default), `never`, `always` | Move when needed, keep visible, or place in overflow |
|
|
||||||
| Item `priority` | integer 0–100; default 50 | Lower priorities yield space first |
|
|
||||||
| Item `representation` | omit, `wide`, `compact`, `icon` | Preferred supported form; smaller forms may be used |
|
|
||||||
| Item `gap-before` | 0–64; unset | Override the preceding gap within a group |
|
|
||||||
| Item `metadata` | optional section | Custom data preserved by the editor; no shell behavior |
|
|
||||||
| Workspaces `style` | `numbers`, `dots` (default), `tabs`, `window-stacks`, `app-icons` | Workspace indicators; choose in the Workspaces item inspector |
|
|
||||||
| Battery `percentage` | boolean; default `true` | Include percentage in its full form |
|
|
||||||
| Focused window `visible` | boolean; default `true` | Include the title |
|
|
||||||
|
|
||||||
Media and clock support all three representations. Battery with a percentage
|
|
||||||
supports `wide` and `icon`; other controls keep their existing form. Unsupported
|
|
||||||
preferences use the kind's normal form. The title can shrink and ellipsize;
|
|
||||||
workspace controls scroll within their allocation. The panel itself does not scroll.
|
|
||||||
|
|
||||||
Workspace indicators keep the same width as their state changes. Dots use a pill
|
|
||||||
for the active workspace, a filled dot for an occupied workspace, and an outline
|
|
||||||
for an empty one. Tabs show workspace names, window stacks show up to three
|
|
||||||
window outlines, and app icons show up to two distinct running applications.
|
|
||||||
Hover for the workspace name and window count. Active transitions follow the
|
|
||||||
animation and reduced-motion settings. Explicitly set `style="numbers"`
|
|
||||||
to retain numbered indicators.
|
|
||||||
|
|
||||||
Item-specific settings belong to their kind: Workspaces uses `style`, Battery
|
|
||||||
uses `percentage`, and Focused window uses the shared `visible` flag. Unrecognized
|
|
||||||
options, including misspellings, are rejected, so custom data belongs in an
|
|
||||||
explicit `metadata` section such as
|
|
||||||
`item "clock" kind="clock" { metadata { note "Desk clock"; }; }`.
|
|
||||||
|
|
||||||
The center stays screen-centered. When controls cannot fit, lower-priority items
|
|
||||||
adapt first, with definition order breaking ties. The overflow chevron appears
|
|
||||||
before the end controls and opens displaced items through their usual menus.
|
|
||||||
`never` moves to overflow only when the mandatory controls cannot fit. A surface
|
|
||||||
narrower than the chevron's padded minimum clips that control.
|
|
||||||
|
|
||||||
## Status controls
|
## Status controls
|
||||||
|
|
||||||
@@ -992,15 +872,16 @@ narrower than the chevron's padded minimum clips that control.
|
|||||||
| `low-battery-threshold` | integer 0–100 | `20` | Warning-color threshold |
|
| `low-battery-threshold` | integer 0–100 | `20` | Warning-color threshold |
|
||||||
| `settings-command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action |
|
| `settings-command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action |
|
||||||
|
|
||||||
Configure panel surfaces in **Panel & Shell** or in the `panel` block above.
|
Configure the panel in **Panel & Shell** or its KDL block, as described in the
|
||||||
Transparent gaps pass clicks through. Group islands use panel opacity and corner
|
[panel guide](panel-configuration.md). The panel's appearance applies to every
|
||||||
radii; changing their surface leaves modal transparency and window clearance
|
group, while popup and notification backgrounds follow the shared shell theme.
|
||||||
unchanged. Island padding belongs to each group and applies only to islands.
|
Transparent gaps between islands pass clicks through to the windows beneath.
|
||||||
|
|
||||||
### Now Playing
|
### Now Playing
|
||||||
|
|
||||||
The media control on the right shows artwork, the track title and a play/pause
|
The Media item can show artwork, the track title and a play/pause button wherever
|
||||||
button. Click the artwork or title to open playback controls for MPRIS players.
|
you place it in the panel. Click the artwork or title to open playback controls
|
||||||
|
for MPRIS players; Morph presents playback in its Music view.
|
||||||
Scroll over the control to change the player's volume, or
|
Scroll over the control to change the player's volume, or
|
||||||
middle-click to raise its window when supported. The menu has previous/next
|
middle-click to raise its window when supported. The menu has previous/next
|
||||||
buttons, a seek slider when supported, and a player chooser. It uses the same
|
buttons, a seek slider when supported, and a player chooser. It uses the same
|
||||||
@@ -1140,7 +1021,8 @@ and blur masks. Small controls cap the radius to fit their size; circles and pil
|
|||||||
keep circular outlines. The measured profile has small tangent and curvature
|
keep circular outlines. The measured profile has small tangent and curvature
|
||||||
discontinuities at its internal segment joins. Window rounding remains under
|
discontinuities at its internal segment joins. Window rounding remains under
|
||||||
Settings → Windows. Shell rounding comes from `theme.geometry.shell-radius`,
|
Settings → Windows. Shell rounding comes from `theme.geometry.shell-radius`,
|
||||||
which defaults to 14 px, with an optional corner override for each panel.
|
which defaults to 14 px. Panel corners follow their selected style and height,
|
||||||
|
with optional overrides in the [panel configuration](panel-configuration.md#background-outline-and-corners).
|
||||||
|
|
||||||
## X11 support (xwayland-satellite)
|
## X11 support (xwayland-satellite)
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -19,11 +19,11 @@ Run CI manually when a change needs it. Ordinary pushes and pull requests do not
|
|||||||
start build runners, and website deployment is manual. Release tags and pull
|
start build runners, and website deployment is manual. Release tags and pull
|
||||||
requests labeled `release` run the release checks and package builds.
|
requests labeled `release` run the release checks and package builds.
|
||||||
|
|
||||||
CI checks formatting, lints and tests across the whole workspace, including the
|
The workspace workflow checks formatting, lints and tests on Ubuntu, including
|
||||||
shell, settings, portal and CLI. The Fedora workspace job also builds desktop
|
the shell, Settings, portal and CLI. It builds the desktop binaries, runs the
|
||||||
binaries and runs installer, session-recovery and private portal-contract checks. The
|
Python integration suite and checks offscreen capture with software Mesa, then
|
||||||
compositor job runs offscreen capture regressions with software Mesa. Opt-in
|
uses those binaries for isolated panel and XWayland checks on a headless Wayland
|
||||||
nested and hardware checks remain separate.
|
host. Hardware checks remain separate.
|
||||||
|
|
||||||
## Test guides
|
## Test guides
|
||||||
|
|
||||||
@@ -32,7 +32,6 @@ nested and hardware checks remain separate.
|
|||||||
- [Output management tests](output-management-testing.md): profiles, hotplug, mirroring, lid handling and failed DRM commits.
|
- [Output management tests](output-management-testing.md): profiles, hotplug, mirroring, lid handling and failed DRM commits.
|
||||||
- [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness.
|
- [Nested resize checks](nested-resize-testing.md): wallpaper and bar responsiveness.
|
||||||
- [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth.
|
- [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth.
|
||||||
- [Performance measurements](performance-baseline.md): sampling and historical results.
|
|
||||||
|
|
||||||
## Portal checks
|
## Portal checks
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
+10
-10
@@ -61,22 +61,23 @@ libraries.
|
|||||||
|
|
||||||
To run X11-only applications, Ferese launches
|
To run X11-only applications, Ferese launches
|
||||||
[xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite),
|
[xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite),
|
||||||
which in turn needs an `Xwayland` binary. Both are runtime dependencies, not
|
which in turn needs an `Xwayland` binary. Both are runtime dependencies, so
|
||||||
build dependencies, and neither is needed to build Ferese.
|
neither is needed to build Ferese.
|
||||||
|
|
||||||
- `Xwayland` ships with the X.Org server packages: `xorg-x11-server-Xwayland`
|
- `Xwayland` ships with the X.Org server packages: `xorg-x11-server-Xwayland`
|
||||||
on Fedora and Debian/Ubuntu, `xorg-xwayland` on Arch.
|
on Fedora and Debian/Ubuntu, `xorg-xwayland` on Arch.
|
||||||
- `xwayland-satellite` is not packaged by most distributions. Build or install
|
- `xwayland-satellite` is not packaged by most distributions. Build or install
|
||||||
it from its upstream releases **with its `systemd` feature enabled**
|
it from its upstream releases **with its `systemd` feature enabled**
|
||||||
(`cargo build --release --features systemd`). That feature is what sends the
|
(`cargo build --release --features systemd`), which sends the `READY=1`
|
||||||
`READY=1` readiness notification Ferese waits for. Upstream's default feature
|
notification Ferese waits for. Upstream's default feature set is empty, so a
|
||||||
set is empty, so a default build never sends it, Ferese's fixed 10-second
|
default build never sends that notification: even if the process remains alive,
|
||||||
startup budget expires, and the service reports a startup failure instead of
|
Ferese's fixed 10-second startup budget expires and the service reports a
|
||||||
a display. A spawned process that is still alive is not readiness.
|
startup failure instead of a display.
|
||||||
|
|
||||||
Both the compositor's default and the packaged configuration ship with X11
|
Both the compositor's default and the packaged configuration ship with X11
|
||||||
enabled, so a session with the two pieces above needs no configuration change.
|
enabled, so a session with the two pieces above needs no configuration change.
|
||||||
If you maintain your own `config.kdl`, the block to keep is
|
If you maintain your own `config.kdl`, keep this block in
|
||||||
|
`~/.config/ferese/config.kdl`:
|
||||||
|
|
||||||
```kdl
|
```kdl
|
||||||
xwayland {
|
xwayland {
|
||||||
@@ -86,8 +87,7 @@ xwayland {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
in `~/.config/ferese/config.kdl` and starting a new session, then check
|
Start a new session, then check `feresectl xwayland status`. See
|
||||||
`feresectl xwayland status`. See
|
|
||||||
[X11 support](configuration.md#x11-support-xwayland-satellite) for the full key
|
[X11 support](configuration.md#x11-support-xwayland-satellite) for the full key
|
||||||
list, diagnostics, and known limitations.
|
list, diagnostics, and known limitations.
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ this at fractional scaling too.
|
|||||||
## Compare renderers
|
## Compare renderers
|
||||||
|
|
||||||
Repeat the check with `ICED_BACKEND=wgpu` and `ICED_BACKEND=tiny-skia` to compare
|
Repeat the check with `ICED_BACKEND=wgpu` and `ICED_BACKEND=tiny-skia` to compare
|
||||||
renderers. The shell and Settings prefer software rendering with GPU fallback,
|
renderers. The shell prefers tiny-skia with a wgpu fallback for its small
|
||||||
which keeps Settings labels visible inside scrolling content but can be slower
|
surfaces, while Settings prefers wgpu with a tiny-skia fallback for larger
|
||||||
when resampling wallpaper. Setting `ICED_BACKEND` explicitly overrides that
|
scrolling pages. An explicit `ICED_BACKEND` selects the requested backend, and
|
||||||
preference.
|
the shell's automatic preference is not passed to Settings when it launches it.
|
||||||
|
|
||||||
Follow the trace from host resize through layer configure, client acknowledgement,
|
Follow the trace from host resize through layer configure, client acknowledgement,
|
||||||
new buffer commit and compositor rendering. A quick acknowledgement does not mean
|
new buffer commit and compositor rendering. A quick acknowledgement does not mean
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
# Panel configuration
|
||||||
|
|
||||||
|
Use **Settings → Panel & Shell** to change the panel's style, position and
|
||||||
|
contents, or edit the `panel` block in `~/.config/ferese/config.kdl`. Settings
|
||||||
|
writes to this file, so you can arrange controls visually and then adjust their
|
||||||
|
values in KDL. Changes apply live, and an invalid edit leaves the last working
|
||||||
|
configuration active.
|
||||||
|
|
||||||
|
Ferese uses one panel configuration across all outputs. Each output lays out
|
||||||
|
its own panel using the space and services available there; workspace controls
|
||||||
|
and the focused-window title belong to that output. If you omit the `panel`
|
||||||
|
block, Ferese supplies its default composition. Once you write one, its groups
|
||||||
|
and items define the composition, including any zones you leave empty.
|
||||||
|
|
||||||
|
## Arrange the panel in Settings
|
||||||
|
|
||||||
|
The composition preview stays above the editor while you change the panel.
|
||||||
|
It uses sample content, so it can show a battery, media player or external
|
||||||
|
display control even when that service is unavailable in your session. Its
|
||||||
|
layout adapts to the width of the Settings window using the same allocation
|
||||||
|
rules as the shell.
|
||||||
|
|
||||||
|
Choose a **Style** from the compact dropdown, then use **Position** to place the
|
||||||
|
panel at the top or bottom. The appearance controls set its background opacity,
|
||||||
|
outline and corners, while **Size & spacing** controls its height and distance
|
||||||
|
from windows and screen edges. A reset button beside a dimension clears that
|
||||||
|
override; **Reset to style** clears geometry and corner overrides together.
|
||||||
|
|
||||||
|
Choose **Edit panel** to open Arrange, where **Add group** creates a group in
|
||||||
|
Start, Center or End. Use its **Add item** menu to fill it, then select an item
|
||||||
|
to change its placement, visibility, overflow, preferred size or options
|
||||||
|
specific to that control. Select the group itself to change its placement,
|
||||||
|
item spacing and padding; before removing it, move or remove its items.
|
||||||
|
|
||||||
|
Use the drag handles to move items within or between groups, or the arrow
|
||||||
|
buttons to reorder them. A drop hint shows where the item will go, and dropping
|
||||||
|
it into an empty zone creates a group there. The move saves on release;
|
||||||
|
Escape, focus loss or a release outside a valid target cancels it.
|
||||||
|
|
||||||
|
Other controls save automatically, with sliders saving when you release them.
|
||||||
|
**Undo** restores the previous save, **Reload configuration** reads external
|
||||||
|
edits, and **Back to Panels** returns to the appearance controls.
|
||||||
|
|
||||||
|
**Remove custom panel** restores the built-in composition, style and geometry;
|
||||||
|
Undo brings back the composition you removed.
|
||||||
|
|
||||||
|
You can open this page directly with `ferese-settings --page bar`.
|
||||||
|
|
||||||
|
## Choose a style
|
||||||
|
|
||||||
|
Choose the style for the whole panel; its groups share that appearance and
|
||||||
|
control only arrangement and spacing.
|
||||||
|
|
||||||
|
| Settings name | KDL value | Appearance and behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Floating | `floating` | Separate rounded islands for occupied groups, with transparent gaps between them |
|
||||||
|
| Capsules | `capsules` | One rounded capsule per occupied zone, with dividers between its groups |
|
||||||
|
| Notch | `notch` | Groups attached to the screen edge, with curved shoulders around the centered group |
|
||||||
|
| Edge to edge | `flat` | A continuous full-width background with square outer corners |
|
||||||
|
| Fused islands | `fused` | Rounded groups with connecting necks where neighboring groups are close enough |
|
||||||
|
| Adaptive | `adaptive` | Floating with ordinary windows, edge to edge when maximized, and hidden in fullscreen |
|
||||||
|
| Morph | `morph` | One centered hub that expands into workspace, media and status controls |
|
||||||
|
|
||||||
|
Capsules and Fused islands join groups **within the same zone**, so a zone with
|
||||||
|
one group appears as a single pill in either style. To see the joins, put
|
||||||
|
Overview and Workspaces in separate Start groups, or separate the status controls
|
||||||
|
and Clock into two End groups. With Capsules, those groups touch and have a
|
||||||
|
divider between them; Fused islands keeps their configured spacing and blends
|
||||||
|
nearby shapes, while Floating leaves the gaps open.
|
||||||
|
|
||||||
|
When a group has no visible items, its background disappears, and the remaining
|
||||||
|
shapes adjust as items become unavailable or move into overflow. Clicks in
|
||||||
|
transparent gaps reach the windows underneath. If the active workspace contains
|
||||||
|
a fullscreen window, the panel hides on that output and releases its reserved
|
||||||
|
space.
|
||||||
|
|
||||||
|
Changing the style preserves your composition and saved appearance overrides.
|
||||||
|
Adaptive evaluates each output independently, with a 150 ms delay between
|
||||||
|
ordinary and maximized presentations to avoid switching back and forth during
|
||||||
|
brief state changes. Entering or leaving fullscreen takes effect immediately.
|
||||||
|
|
||||||
|
## Position, size and spacing
|
||||||
|
|
||||||
|
Set `edge "top"` or `edge "bottom"` on the panel. Its controls keep the same
|
||||||
|
order, while edge-relative corners and attached shapes mirror vertically.
|
||||||
|
Popups and notifications open toward the desktop.
|
||||||
|
|
||||||
|
Dimensions use logical pixels, so output scaling applies afterward. The
|
||||||
|
`geometry` block contains optional overrides; omitting a value lets the selected
|
||||||
|
style supply it.
|
||||||
|
|
||||||
|
| `geometry` key | Default | Accepted values | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `height` | 36; Morph uses 34 | Number 24–128 | Panel height, or Morph's collapsed height |
|
||||||
|
| `edge-margin` | 8 for detached styles; 0 for Notch and Edge to edge | Integer 0–4096 | Distance from the selected screen edge |
|
||||||
|
| `side-margins` | 8 for detached styles; 0 for Notch and Edge to edge | Integer 0–4096 | Requested inset at both ends of the panel |
|
||||||
|
| `window-clearance` | 0 | Integer 0–4096 | Additional reserved space between the panel and windows |
|
||||||
|
| `inner-padding` | 12 | Number 0–64 | Space inside the row before its first and after its last group |
|
||||||
|
|
||||||
|
Notch and Edge to edge use zero edge and side margins even if you have saved
|
||||||
|
overrides for them. Those values remain in the file and apply again when you
|
||||||
|
choose a detached style. Attached Morph also uses zero edge margin, while its
|
||||||
|
saved floating margin returns when attachment is disabled.
|
||||||
|
|
||||||
|
Ferese uses the requested side margins only while the content fits. It measures
|
||||||
|
the controls on each output and reduces the inset when the output narrows or
|
||||||
|
more content appears, so the margins do not cause additional overflow. Settings
|
||||||
|
limits the slider to the available display and preview measurements; KDL accepts
|
||||||
|
the wider range shown above.
|
||||||
|
|
||||||
|
Morph reserves space for its collapsed height plus window clearance. Its
|
||||||
|
expanded views overlay windows, so increasing their height does not push windows
|
||||||
|
down. At the panel edge, Morph accounts for the layout's outer gap rather than
|
||||||
|
adding the two gaps together.
|
||||||
|
|
||||||
|
## Background, outline and corners
|
||||||
|
|
||||||
|
`background-opacity` accepts a number from 0 to 1 and affects the panel
|
||||||
|
background without fading its text or icons. Omit it to inherit shell opacity,
|
||||||
|
or use the reset control beside **Background opacity** in Settings. It does not
|
||||||
|
change the opacity of popups, notifications or application windows.
|
||||||
|
|
||||||
|
The panel uses the shared material settings under `theme.material`, including
|
||||||
|
translucency and backdrop blur. Morph uses the theme's `surface-raised` color
|
||||||
|
and suppresses the material shadow. **Reduce Transparency** takes precedence
|
||||||
|
over a panel opacity override, making the background opaque and removing blur.
|
||||||
|
See [Appearance](configuration.md#appearance) for the shared theme controls.
|
||||||
|
|
||||||
|
Set `border #true` to draw the panel outline. Its color comes from
|
||||||
|
`theme.colors.border`, and Capsules also uses that color for its dividers.
|
||||||
|
Leave it at the default `#false` for no outline.
|
||||||
|
|
||||||
|
Automatic corners follow the style and panel height. Choose **Custom** to pin
|
||||||
|
them, or return to **Automatic** to remove the override. The four controls are
|
||||||
|
**Edge left**, **Edge right**, **Outer left** and **Outer right**, so they retain
|
||||||
|
their meaning when you move the panel to the bottom.
|
||||||
|
|
||||||
|
In KDL, `corner-radius` is a pixel string using CSS-style shorthand. One value
|
||||||
|
sets every corner; two set opposite pairs; three set the first corner, the
|
||||||
|
second/fourth pair and the third; four give each corner its own value. The
|
||||||
|
stored order is edge-left, edge-right, outer-right, outer-left:
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
corner-radius "4px 8px 12px 16px"
|
||||||
|
```
|
||||||
|
|
||||||
|
On a top panel, that order runs clockwise from top-left. On a bottom panel,
|
||||||
|
the same values mirror vertically. Values must be finite and nonnegative, with
|
||||||
|
`px` units except for a bare `0`; percentages and elliptical `/` syntax are
|
||||||
|
not supported. Ferese scales radii to fit when adjacent corners would overlap,
|
||||||
|
and Edge to edge keeps its outer corners square. Corner pins stay saved when
|
||||||
|
you switch styles.
|
||||||
|
|
||||||
|
## Groups and items
|
||||||
|
|
||||||
|
Place groups in Start on the left, Center in the middle or End on the right,
|
||||||
|
then arrange items within them. Center stays centered on the panel even when
|
||||||
|
the two sides have different widths. Each group and item has an ID, so Settings
|
||||||
|
keeps track of the one you are editing when you move it.
|
||||||
|
|
||||||
|
Panel IDs, group IDs and item IDs contain 1–128 ASCII letters or digits, or
|
||||||
|
`-_.:@`, and cannot start with `_`. Group IDs must be unique across the panel,
|
||||||
|
as must item IDs; different instances of the same item kind are allowed when
|
||||||
|
their IDs differ. A panel supports up to 31 authored groups.
|
||||||
|
|
||||||
|
| Scope / key | Default | Accepted values | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Zone `spacing` | 8 | Number 0–64 | Gap between groups; Capsules and Notch use zero |
|
||||||
|
| Group `spacing` | 1 | Number 0–64 | Gap between items |
|
||||||
|
| Group `padding` | `2 3` | Two integers 0–32 | Vertical padding followed by horizontal padding |
|
||||||
|
| Group `island-padding` | 4 | Number 0–32 | Extra horizontal padding for island styles |
|
||||||
|
| Item `visible` | `#true` | Boolean | Show the item when its service or hardware is available |
|
||||||
|
| Item `overflow` | `auto` | `auto`, `never`, `always` | Overflow when needed, keep visible, or always use overflow |
|
||||||
|
| Item `priority` | 50 | Integer 0–100 | Lower values yield space first |
|
||||||
|
| Item `representation` | Preferred supported form | `wide`, `compact`, `icon`, or omit | Starting form before adapting to available space |
|
||||||
|
| Item `gap-before` | Unset | Number 0–64 | Override the gap before this item within its group |
|
||||||
|
| Item `metadata` | Empty | Nested section | Custom data preserved by Settings, without shell behavior |
|
||||||
|
|
||||||
|
Horizontal padding and island padding add together for island styles. Group
|
||||||
|
padding changes the space around that group's controls; it does not give the
|
||||||
|
group a separate height, style or set of corners.
|
||||||
|
`gap-before` has no effect on the first visible item in a group.
|
||||||
|
|
||||||
|
### Item catalog
|
||||||
|
|
||||||
|
The Add item catalog contains these twelve kinds. Service-dependent controls
|
||||||
|
appear when their service or hardware is available, and a focused-window item
|
||||||
|
disappears when that output has no window title to show.
|
||||||
|
|
||||||
|
| Settings name | `kind` | Item-specific options |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Overview | `overview` | Opens the workspace overview |
|
||||||
|
| Workspaces | `workspaces` | `style`: `numbers`, `dots`, `tabs`, `window-stacks`, `app-icons`; default `dots` |
|
||||||
|
| Focused window | `focused-window` | Use the shared `visible` field to show or hide the title |
|
||||||
|
| Media | `media` | Full, Compact and Icon representations |
|
||||||
|
| Quick Settings | `quick-settings` | Opens Control Center |
|
||||||
|
| Network | `network` | Opens Wi-Fi controls |
|
||||||
|
| Audio | `audio` | Opens audio controls |
|
||||||
|
| Recording | `recording` | Opens recording controls or stops the active recording |
|
||||||
|
| Notifications | `notifications` | Opens notifications |
|
||||||
|
| Battery | `battery` | `percentage`: boolean, default `#true` |
|
||||||
|
| Clock | `clock` | Full, Compact and Icon representations |
|
||||||
|
| Display mode | `display-mode` | Appears when an external display is connected |
|
||||||
|
|
||||||
|
Workspace styles change the regular panel's indicators. Numbers shows workspace
|
||||||
|
numbers and Tabs shows their names, while Dots uses a pill for the active
|
||||||
|
workspace. Window stacks uses
|
||||||
|
small window outlines, and App icons shows up to two distinct running apps.
|
||||||
|
Hovering an indicator shows its workspace name and window count. Clock
|
||||||
|
instances share the shell's clock format and timezone.
|
||||||
|
|
||||||
|
Unknown options are rejected, so put custom annotations inside `metadata`:
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
item "desk-clock" kind="clock" {
|
||||||
|
metadata { note "Clock beside the audio control"; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adaptation and overflow
|
||||||
|
|
||||||
|
In the inspector, **Full**, **Compact** and **Icon** correspond to `wide`,
|
||||||
|
`compact` and `icon`. Media and Clock support all three; Battery with percentage
|
||||||
|
supports Full and Icon. Workspaces and Focused window have one form that can
|
||||||
|
shrink, while other controls use icons. An unsupported preference falls back
|
||||||
|
to the item's normal form.
|
||||||
|
|
||||||
|
When space runs short, lower-priority items yield first, with their order in the
|
||||||
|
composition breaking ties. Flexible content can shrink, and items can switch
|
||||||
|
to smaller supported forms; overflow keeps the remaining controls accessible
|
||||||
|
from the chevron. **Keep visible** maps to `never`, **When needed** to `auto`,
|
||||||
|
and **Always** to `always`. Even `never` can move into overflow if the mandatory
|
||||||
|
controls cannot fit at their minimum sizes.
|
||||||
|
|
||||||
|
The overflow chevron appears before the End controls, and selecting an entry
|
||||||
|
opens that item's usual controls. The panel itself does not scroll; workspace
|
||||||
|
indicators may scroll within the space they receive, while long window titles
|
||||||
|
end with an ellipsis.
|
||||||
|
|
||||||
|
## Morph
|
||||||
|
|
||||||
|
Morph combines the panel into one centered hub. Alongside a compact workspace
|
||||||
|
strip, it shows artwork, the track title and an equalizer during playback.
|
||||||
|
Otherwise it shows the focused-window title, falling back to the date when
|
||||||
|
there is no title. Recording and low-battery states take priority over ordinary
|
||||||
|
content. Hovering can expand a preview, and clicking opens the hub's Workspaces,
|
||||||
|
Music, Calendar,
|
||||||
|
Notifications or Controls view. Escape or the close button collapses it.
|
||||||
|
|
||||||
|
The Workspaces view shows miniature window layouts. Click a tile to switch
|
||||||
|
workspace, use Tab and the arrow keys to navigate it, or drag a miniature window
|
||||||
|
to another tile on the same output. Scrolling over the collapsed hub changes
|
||||||
|
workspace, with a brief workspace preview after a switch.
|
||||||
|
|
||||||
|
Morph keeps your saved composition for when you switch back to a regular panel.
|
||||||
|
The hub uses its own layouts and compact workspace dots, so Start/Center/End
|
||||||
|
positions, group padding and the regular workspace indicator style do not apply
|
||||||
|
inside it. Item settings still determine which configured controls appear in
|
||||||
|
its Controls view and shortcuts.
|
||||||
|
|
||||||
|
Turn on **Attach to screen edge** to join the hub to the chosen edge with curved
|
||||||
|
shoulders, or set `morph { attached #true; }`. **Preview on hover** maps to
|
||||||
|
`peek-on-hover`; disabling it leaves click-to-open behavior available. These
|
||||||
|
two switches appear in Settings when Morph is selected, while the size values
|
||||||
|
below can be adjusted in KDL.
|
||||||
|
|
||||||
|
| `morph` key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `attached` | `#false` | Join the hub to the selected screen edge |
|
||||||
|
| `peek-on-hover` | `#true` | Show a preview when the pointer enters |
|
||||||
|
| `idle-width` | 150 | Collapsed content width budget |
|
||||||
|
| `peek-width` | 260 | Hover preview width budget |
|
||||||
|
| `peek-height` | 44 | Hover preview height |
|
||||||
|
| `toast-width` | 320 | Notification toast width |
|
||||||
|
| `toast-height` | 48 | Notification toast height |
|
||||||
|
| `expanded-width` | 340 | Width of expanded views |
|
||||||
|
| `workspaces-height` | 176 | Workspace view height |
|
||||||
|
| `media-height` | 170 | Music view height |
|
||||||
|
| `calendar-height` | 230 | Calendar view height |
|
||||||
|
| `notifications-height` | 176 | Notifications view height |
|
||||||
|
| `controls-height` | 248 | Maximum Controls view height before scrolling |
|
||||||
|
| `expanded-radius` | 26 | Default corners for expanded views |
|
||||||
|
|
||||||
|
All width and height values must be finite numbers from 24 to 1024, and
|
||||||
|
`expanded-radius` accepts 0–128. The collapsed hub and workspace feedback fit
|
||||||
|
their content, Controls may open at a shorter height, and all views stay within
|
||||||
|
the available output area. A configured width therefore allows room for content
|
||||||
|
without forcing empty space around it.
|
||||||
|
|
||||||
|
For ordinary media and date previews, the maximum width is `peek-width` clamped
|
||||||
|
to 200–280 pixels. Activity previews choose their own width, and workspace
|
||||||
|
feedback can be narrower. If you have pinned the panel's corners, those values
|
||||||
|
override `expanded-radius`.
|
||||||
|
|
||||||
|
Morph follows animation speed and reduced motion. As it expands, the area that
|
||||||
|
accepts clicks follows its shape while the space kept clear of windows stays
|
||||||
|
unchanged; if you interrupt a transition, it continues from the shape already
|
||||||
|
on screen.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Joined groups with a centered title
|
||||||
|
|
||||||
|
This composition puts Overview and Workspaces in separate Start groups so
|
||||||
|
Capsules can join them with a divider. The title stays centered, and the two
|
||||||
|
End groups keep status controls together while separating the clock.
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
panel "main" {
|
||||||
|
edge "top"
|
||||||
|
style "capsules"
|
||||||
|
border #true
|
||||||
|
background-opacity 0.85
|
||||||
|
geometry {
|
||||||
|
height 36
|
||||||
|
edge-margin 8
|
||||||
|
side-margins 8
|
||||||
|
window-clearance 4
|
||||||
|
}
|
||||||
|
start {
|
||||||
|
group "overview" {
|
||||||
|
item "overview" kind="overview" overflow="never" priority=100
|
||||||
|
}
|
||||||
|
group "navigation" {
|
||||||
|
item "workspaces" kind="workspaces" style="dots" overflow="never" priority=100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
center {
|
||||||
|
group "title" {
|
||||||
|
padding 0 0
|
||||||
|
item "title" kind="focused-window" overflow="never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end {
|
||||||
|
group "status" {
|
||||||
|
item "media" kind="media" representation="wide"
|
||||||
|
item "quick-settings" kind="quick-settings"
|
||||||
|
item "network" kind="network"
|
||||||
|
item "audio" kind="audio"
|
||||||
|
item "recording" kind="recording"
|
||||||
|
item "notifications" kind="notifications"
|
||||||
|
item "battery" kind="battery" percentage=#true
|
||||||
|
}
|
||||||
|
group "time" {
|
||||||
|
item "clock" kind="clock" representation="compact"
|
||||||
|
item "display" kind="display-mode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change only `style` to `floating` or `fused` to compare the same composition
|
||||||
|
with separate or blended islands, or use `adaptive` to let window state choose
|
||||||
|
between Floating and Edge to edge.
|
||||||
|
|
||||||
|
### An attached Morph hub
|
||||||
|
|
||||||
|
Keep the groups from the previous example, set `style "morph"`, and add this
|
||||||
|
section inside the same panel block. Changing `edge` to `bottom` mirrors the
|
||||||
|
attached shape without changing the profile.
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
morph {
|
||||||
|
attached #true
|
||||||
|
peek-on-hover #true
|
||||||
|
expanded-width 360
|
||||||
|
media-height 180
|
||||||
|
controls-height 248
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To return to a floating hub, set `attached #false`; its saved edge margin then
|
||||||
|
applies again. The profile stays in the file when you choose another style, so
|
||||||
|
you can return to Morph without re-entering these values.
|
||||||
|
|
||||||
|
## Validate a file
|
||||||
|
|
||||||
|
Save your edit and run `ferese --check-config ~/.config/ferese/config.kdl` to
|
||||||
|
validate it without applying changes. The file accepts one `panel` block;
|
||||||
|
unknown fields, duplicate IDs, unavailable styles and out-of-range dimensions
|
||||||
|
are errors. See [Saving and validation](configuration.md#saving-and-validation)
|
||||||
|
for how Settings and live reloads handle a failed validation.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
# Screen sharing and recording
|
# Screen sharing and recording
|
||||||
|
|
||||||
Share a monitor or window from a Wayland-compatible recording or conferencing app,
|
Share a monitor or window from a Wayland-compatible recording or conferencing app,
|
||||||
or save a video using the top bar. [Installation](installation.md) includes the
|
or save a video using the panel's Recording control. The
|
||||||
portal and PipeWire dependencies.
|
[installation guide](installation.md) covers the portal and PipeWire dependencies.
|
||||||
|
|
||||||
## Record from the top bar
|
## Record from the panel
|
||||||
|
|
||||||
1. Click the recording icon.
|
1. Click the Recording item in the panel, or open Controls → Recording in Morph.
|
||||||
2. Choose a display and an audio source: **Off**, **System sound**, **Microphone**,
|
2. Choose a display and an audio source: **Off**, **System sound**, **Microphone**,
|
||||||
or **Both**. Audio is off by default; the other choices use
|
or **Both**. Audio is off by default; the other choices use
|
||||||
your default output or input device from audio settings.
|
your default output or input device from audio settings.
|
||||||
@@ -38,9 +38,9 @@ The picker follows your shell colors, font, corners, opacity, and blur.
|
|||||||
## Stop sharing
|
## Stop sharing
|
||||||
|
|
||||||
Click **Stop sharing** or close the sharing window. For the built-in recorder,
|
Click **Stop sharing** or close the sharing window. For the built-in recorder,
|
||||||
use the stop button in the top bar. Sharing also ends when the app closes or
|
use the panel's stop button, which Morph also shows while recording. Sharing
|
||||||
Ferese locks. If you disconnect a shared monitor or change its capture size,
|
also ends when the app closes or Ferese locks. If you disconnect a shared monitor
|
||||||
its stream ends and you need to start sharing again.
|
or change its capture size, its stream ends and you need to start sharing again.
|
||||||
|
|
||||||
## Remember permissions
|
## Remember permissions
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -34,7 +34,8 @@ Six themes, each with paired light and dark colours.
|
|||||||
|
|
||||||
## Panel & Shell
|
## Panel & Shell
|
||||||
|
|
||||||
Panel composition, position and surface settings.
|
The composition preview and panel controls in Settings. See the
|
||||||
|
[panel configuration guide](panel-configuration.md) for the current styles and options.
|
||||||
|
|
||||||
[](images/screenshots/ferese-panel-settings.webp)
|
[](images/screenshots/ferese-panel-settings.webp)
|
||||||
|
|
||||||
|
|||||||
+10
-12
@@ -1,16 +1,16 @@
|
|||||||
# Why Ferese
|
# Why Ferese
|
||||||
|
|
||||||
When you use a window manager, arranging windows is only part of using the desktop. You still need to connect to Wi-Fi, change an audio device, adjust a display, read notifications and lock the screen. Those tasks involve different components, but you encounter them as parts of the same environment. When their settings and behaviour are maintained separately, making them fit together becomes another job.
|
Alongside a window manager, you need Wi-Fi, audio and display controls, notifications and a lock screen. Choosing separate desktop components remains useful when you want that independence, though keeping them consistent becomes another job when their settings and behaviour are maintained separately.
|
||||||
|
|
||||||
Ferese brings window management and desktop controls into one project. It includes a Wayland compositor, a desktop shell, Settings, Control Center and a lock screen, with configuration and theming shared across its components. Building them together makes it possible to decide how a desktop change should work across those components, rather than leaving each one to interpret it independently.
|
Ferese includes a Wayland compositor, desktop shell, Settings, Control Center and a lock screen with shared configuration and theming. It keeps window management configurable while defining how a desktop change applies across those components.
|
||||||
|
|
||||||
## Settings that apply beyond one component
|
## Settings that apply beyond one component
|
||||||
|
|
||||||
Changing an accent colour should affect the places where Ferese uses that colour. Changing motion speed should affect both window transitions and shell transitions. These are desktop preferences, even though the compositor and shell run in separate processes. Ferese’s shared theme and motion settings give those processes a common definition to work from.
|
An accent-colour change should reach the places where Ferese uses it, just as a motion-speed change should affect both window and shell transitions. The compositor and shell run in separate processes, but their shared theme and motion settings give them the same values to work from.
|
||||||
|
|
||||||
Settings and `~/.config/ferese/config.kdl` are two ways to edit the same configuration. You can use the graphical interface for a display change, then open the file to adjust a binding or window rule. Settings preserves comments and custom fields when saving. File changes reload automatically, and invalid edits leave the last working configuration active. This lets the graphical controls remain useful without making the configuration file a secondary or unsupported way to manage the desktop.
|
Settings and `~/.config/ferese/config.kdl` edit the same configuration, so you can change a display in Settings, then open the file to adjust a binding or window rule. Settings preserves comments and untouched configuration content when saving, and file changes reload automatically. If an edit is invalid, the last working configuration stays active.
|
||||||
|
|
||||||
The shared settings have a defined scope. Ferese can coordinate the surfaces it draws and the transitions it manages; it does not control every animation an application draws inside its own window. That distinction matters when describing a consistent desktop: consistency between Ferese’s components should not be confused with taking over application behaviour.
|
Ferese can coordinate the surfaces it draws and the transitions it manages; it does not control every animation an application draws inside its own window.
|
||||||
|
|
||||||
[](images/screenshots/ferese-appearance.webp)
|
[](images/screenshots/ferese-appearance.webp)
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ A scrolling layout is useful when several windows need more space than can comfo
|
|||||||
|
|
||||||
Ferese supports both, with the layout selected per workspace. Floating windows are also available and remember their size and placement. A workspace can therefore use scrolling columns for a sequence of wide windows, while another uses tree tiling for a smaller set that needs to be viewed together.
|
Ferese supports both, with the layout selected per workspace. Floating windows are also available and remember their size and placement. A workspace can therefore use scrolling columns for a sequence of wide windows, while another uses tree tiling for a smaller set that needs to be viewed together.
|
||||||
|
|
||||||
Even within scrolling, moving focus does not always need to move the desktop. Ferese’s minimal focus strategy leaves a fully visible column where it is and scrolls only far enough to reveal a hidden edge. Other strategies centre the focused column or arrange columns into viewport-sized pages. These choices affect how much the desktop moves while you work, so they belong in the layout’s behaviour rather than being treated as visual decoration.
|
Even within scrolling, moving focus does not always need to move the desktop. Ferese’s minimal focus strategy leaves a fully visible column where it is and scrolls only far enough to reveal a hidden edge. Other strategies centre the focused column or arrange columns into viewport-sized pages.
|
||||||
|
|
||||||
[](images/screenshots/ferese-overview.webp)
|
[](images/screenshots/ferese-overview.webp)
|
||||||
|
|
||||||
@@ -28,12 +28,10 @@ Even within scrolling, moving focus does not always need to move the desktop. Fe
|
|||||||
|
|
||||||
An animation often starts before you have finished deciding what to do. You might focus another window while the viewport is moving, reverse a fullscreen transition, or reopen a panel before it has finished closing. The desktop needs to handle that next input using the state already on screen.
|
An animation often starts before you have finished deciding what to do. You might focus another window while the viewport is moving, reverse a fullscreen transition, or reopen a panel before it has finished closing. The desktop needs to handle that next input using the state already on screen.
|
||||||
|
|
||||||
Continuum, Ferese’s shared motion system, keeps a moving value’s current position, velocity and destination. Changing the destination can preserve the existing movement instead of restarting from rest. Direct manipulation follows a different rule: a dragged window follows the pointer directly, while a released gesture hands its velocity to a spring. The distinction keeps the animation from adding a trailing movement between your hand and the window.
|
Continuum, Ferese’s shared motion system, keeps a moving value’s current position, velocity and destination. Changing the destination can preserve the existing movement instead of restarting from rest. A dragged window follows the pointer directly so it does not trail your hand; when you release a gesture, its velocity passes to a spring.
|
||||||
|
|
||||||
This coordination extends beyond position. A window’s content, clipping and border need to follow the same presented bounds. When the application closes, its live surface may disappear before the visual exit finishes, so Ferese retains an image for that transition. When a shell panel closes, its content and compositor-drawn material need to leave together. Otherwise, the text can disappear while its background or blur remains. These are practical reasons for designing the motion system alongside the compositor and shell.
|
A window’s content, clipping and border need to follow the same presented bounds. When the application closes, its live surface may disappear before the visual exit finishes, so Ferese retains an image for that transition. Shell content and its compositor-drawn material also need to leave together, or the text can disappear while its background or blur remains.
|
||||||
|
|
||||||
## Taking responsibility for the whole interaction
|
## Testing the whole interaction
|
||||||
|
|
||||||
Including more of the desktop also means taking responsibility for more of its behaviour. A common theme does not resolve a late application buffer, and a correct spring equation does not make a missed frame arrive on time. Resize coordination, output timing and interrupted transitions still need testing, including measurements on the hardware where the desktop runs.
|
A shared theme does not resolve a late application buffer, and a correct spring equation does not make a missed frame arrive on time. Resize coordination, output timing and interrupted transitions still need testing and measurements on the hardware where the desktop runs.
|
||||||
|
|
||||||
Ferese’s reason for existing is to work on those interactions together while keeping window management configurable. Choosing separate desktop components remains useful when that independence is what you want. Ferese takes responsibility for the components it includes, so changing the layout, adjusting the appearance or using an everyday desktop control does not also require maintaining the connections between them.
|
|
||||||
@@ -147,7 +147,7 @@ status {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Panel composition is explicit; visibility and item options belong to each item.
|
// Panel composition is explicit; visibility and item options belong to each item.
|
||||||
// Background opacity and corner radius inherit from the shell unless overridden here.
|
// Opacity follows the shell; corners follow the panel style unless overridden here.
|
||||||
panel "main" {
|
panel "main" {
|
||||||
geometry {
|
geometry {
|
||||||
height 30
|
height 30
|
||||||
@@ -156,8 +156,7 @@ panel "main" {
|
|||||||
window-clearance 4
|
window-clearance 4
|
||||||
}
|
}
|
||||||
edge "top"
|
edge "top"
|
||||||
surface "solid"
|
style "flat"
|
||||||
group-surface "none"
|
|
||||||
border #false
|
border #false
|
||||||
start {
|
start {
|
||||||
spacing 3
|
spacing 3
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! ScreenCast portal backend. Only the portal frontend may create sessions;
|
|
||||||
//! saved monitor consent is restored only through the frontend permission store.
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Bounded, cancellable Wayland capture. No screenshots or external capture tools.
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::os::fd::{AsFd, AsRawFd, FromRawFd};
|
use std::os::fd::{AsFd, AsRawFd, FromRawFd};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! Native recording client. The portal retains capture authorization; encoding
|
|
||||||
//! and file I/O live in this separate process, never in the shell render loop.
|
|
||||||
mod recorder;
|
mod recorder;
|
||||||
mod recording;
|
mod recording;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Map an approved logical recording region into the upright video stream.
|
|
||||||
use crate::recording::Crop;
|
use crate::recording::Crop;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! PipeWire runs outside the compositor. Keep one frame, drop old frames under
|
|
||||||
//! backpressure, and destroy the node when the portal's control pipe closes.
|
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
DEALINGS IN THE SOFTWARE.
|
DEALINGS IN THE SOFTWARE.
|
||||||
</copyright>
|
</copyright>
|
||||||
|
|
||||||
<interface name="ferese_effects_manager_v1" version="5">
|
<interface name="ferese_effects_manager_v1" version="6">
|
||||||
<description summary="assign semantic material roles to surfaces">
|
<description summary="assign semantic material roles to surfaces">
|
||||||
This private global is visible only to clients granted the Ferese effects
|
This private global is visible only to clients granted the Ferese effects
|
||||||
capability. Roles describe compositor-owned appearance policy and do not
|
capability. Roles describe compositor-owned appearance policy and do not
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</enum>
|
</enum>
|
||||||
</interface>
|
</interface>
|
||||||
|
|
||||||
<interface name="ferese_surface_effects_v1" version="5">
|
<interface name="ferese_surface_effects_v1" version="6">
|
||||||
<description summary="semantic material role for one surface">
|
<description summary="semantic material role for one surface">
|
||||||
The role affects only compositor rendering. It does not change surface
|
The role affects only compositor rendering. It does not change surface
|
||||||
geometry, input, focus, stacking, or the surface's Wayland role.
|
geometry, input, focus, stacking, or the surface's Wayland role.
|
||||||
@@ -121,5 +121,24 @@
|
|||||||
<arg name="opacity" type="uint"/>
|
<arg name="opacity" type="uint"/>
|
||||||
<arg name="region_opacities" type="array"/>
|
<arg name="region_opacities" type="array"/>
|
||||||
</request>
|
</request>
|
||||||
|
<request name="set_panel_presentation" since="6">
|
||||||
|
<description summary="commit a bounded panel shape with its content">
|
||||||
|
Panel-only material presentation. Regions are bounded capture/damage
|
||||||
|
rectangles in the set_regions format. Shapes are native-endian binary32
|
||||||
|
tuples (x,y,width,height,top-left,top-right,bottom-right,bottom-left,
|
||||||
|
operation,blend-zone). Operations: 0 rounded union, 1 rounded cutout,
|
||||||
|
2..5 rectangular concave patches whose circle centers are bottom-left,
|
||||||
|
bottom-right, top-right, top-left. Blend zones 1..3 fuse independently;
|
||||||
|
zone 0 never fuses. At most 32 primitives and one cutout are accepted.
|
||||||
|
All coordinates must be finite and bounded by 32768 logical pixels.
|
||||||
|
Invalid requests leave pending state unchanged. Empty shapes require
|
||||||
|
empty regions. Input remains controlled by wl_surface.set_input_region.
|
||||||
|
This presentation becomes visible atomically with the next surface commit.
|
||||||
|
</description>
|
||||||
|
<arg name="regions" type="array"/>
|
||||||
|
<arg name="shapes" type="array"/>
|
||||||
|
<arg name="fusion" type="fixed" summary="polynomial smooth-min strength, 0..256 logical pixels"/>
|
||||||
|
<arg name="opacity" type="uint"/>
|
||||||
|
</request>
|
||||||
</interface>
|
</interface>
|
||||||
</protocol>
|
</protocol>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
DEALINGS IN THE SOFTWARE.
|
DEALINGS IN THE SOFTWARE.
|
||||||
</copyright>
|
</copyright>
|
||||||
|
|
||||||
<interface name="ferese_shell_manager_v1" version="6">
|
<interface name="ferese_shell_manager_v1" version="8">
|
||||||
<description summary="create a capability-scoped Ferese shell controller">
|
<description summary="create a capability-scoped Ferese shell controller">
|
||||||
This private global is visible only to clients granted the shell-control
|
This private global is visible only to clients granted the shell-control
|
||||||
capability. It does not assign a Wayland surface role.
|
capability. It does not assign a Wayland surface role.
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<request name="destroy" type="destructor"/>
|
<request name="destroy" type="destructor"/>
|
||||||
</interface>
|
</interface>
|
||||||
|
|
||||||
<interface name="ferese_shell_v1" version="6">
|
<interface name="ferese_shell_v1" version="8">
|
||||||
<description summary="managed-window metadata and shell control">
|
<description summary="managed-window metadata and shell control">
|
||||||
Window, workspace, and output identifiers are unsigned 64-bit values
|
Window, workspace, and output identifiers are unsigned 64-bit values
|
||||||
encoded as high and low 32-bit words. Each snapshot atomically replaces
|
encoded as high and low 32-bit words. Each snapshot atomically replaces
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
<entry name="urgent" value="2"/>
|
<entry name="urgent" value="2"/>
|
||||||
<entry name="fullscreen" value="4"/>
|
<entry name="fullscreen" value="4"/>
|
||||||
<entry name="floating" value="8"/>
|
<entry name="floating" value="8"/>
|
||||||
|
<entry name="maximized" value="16" since="7"/>
|
||||||
</enum>
|
</enum>
|
||||||
|
|
||||||
<enum name="failed_request">
|
<enum name="failed_request">
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
<entry name="close_window" value="1"/>
|
<entry name="close_window" value="1"/>
|
||||||
<entry name="select_overview_window" value="2"/>
|
<entry name="select_overview_window" value="2"/>
|
||||||
<entry name="activate_workspace" value="3"/>
|
<entry name="activate_workspace" value="3"/>
|
||||||
|
<entry name="move_window_to_workspace" value="4" since="8"/>
|
||||||
</enum>
|
</enum>
|
||||||
|
|
||||||
<event name="snapshot_begin">
|
<event name="snapshot_begin">
|
||||||
@@ -181,5 +183,27 @@
|
|||||||
disabled monitors and mirror targets without a logical wl_output.
|
disabled monitors and mirror targets without a logical wl_output.
|
||||||
</description>
|
</description>
|
||||||
</event>
|
</event>
|
||||||
|
<event name="window_geometry" since="8">
|
||||||
|
<description summary="window layout rectangle for workspace previews">
|
||||||
|
Follows its window event inside a snapshot. Coordinates are compositor
|
||||||
|
logical coordinates at the layout target, independent of animations.
|
||||||
|
Width and height are positive. An omitted event means unavailable geometry.
|
||||||
|
</description>
|
||||||
|
<arg name="window_hi" type="uint"/>
|
||||||
|
<arg name="window_lo" type="uint"/>
|
||||||
|
<arg name="x" type="fixed"/>
|
||||||
|
<arg name="y" type="fixed"/>
|
||||||
|
<arg name="width" type="fixed"/>
|
||||||
|
<arg name="height" type="fixed"/>
|
||||||
|
</event>
|
||||||
|
<request name="move_window_to_workspace" since="8">
|
||||||
|
<description summary="move an identified window without switching workspaces">
|
||||||
|
Both IDs must exist and belong to the same output. A stale ID is rejected.
|
||||||
|
</description>
|
||||||
|
<arg name="window_hi" type="uint"/>
|
||||||
|
<arg name="window_lo" type="uint"/>
|
||||||
|
<arg name="workspace_hi" type="uint"/>
|
||||||
|
<arg name="workspace_lo" type="uint"/>
|
||||||
|
</request>
|
||||||
</interface>
|
</interface>
|
||||||
</protocol>
|
</protocol>
|
||||||
@@ -18,6 +18,7 @@ PAGES = [
|
|||||||
('installation', 'Installation', ROOT / 'docs/installation.md'),
|
('installation', 'Installation', ROOT / 'docs/installation.md'),
|
||||||
('packaging', 'Packages and releases', ROOT / 'docs/packaging.md'),
|
('packaging', 'Packages and releases', ROOT / 'docs/packaging.md'),
|
||||||
('configuration', 'Configuration', ROOT / 'docs/configuration.md'),
|
('configuration', 'Configuration', ROOT / 'docs/configuration.md'),
|
||||||
|
('panel-configuration', 'Panel configuration', ROOT / 'docs/panel-configuration.md'),
|
||||||
('animation-model', 'Animation model', ROOT / 'docs/animation-model.md'),
|
('animation-model', 'Animation model', ROOT / 'docs/animation-model.md'),
|
||||||
('shortcuts', 'Shortcuts and gestures', ROOT / 'docs/shortcuts.md'),
|
('shortcuts', 'Shortcuts and gestures', ROOT / 'docs/shortcuts.md'),
|
||||||
('locking', 'Lock screen', ROOT / 'docs/locking.md'),
|
('locking', 'Lock screen', ROOT / 'docs/locking.md'),
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ def main():
|
|||||||
raise RuntimeError((logs / "weston.log").read_text())
|
raise RuntimeError((logs / "weston.log").read_text())
|
||||||
time.sleep(.05)
|
time.sleep(.05)
|
||||||
cases = [
|
cases = [
|
||||||
("shell-continuous", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "continuous"}),
|
("shell-flat", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "flat"}),
|
||||||
("shell-islands", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "islands"}),
|
("shell-floating", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "floating"}),
|
||||||
]
|
]
|
||||||
if args.suite == "desktop":
|
if args.suite == "desktop":
|
||||||
cases.insert(0, ("lock", "lock", {"FERESE_TEST_LOCK": "1", "FERESE_TEST_BIN_DIR": "target/debug"}))
|
cases.insert(0, ("lock", "lock", {"FERESE_TEST_LOCK": "1", "FERESE_TEST_BIN_DIR": "target/debug"}))
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run the current shell and Settings in a private nested desktop."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def request(endpoint, command, args=None):
|
||||||
|
data = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args or {}}).encode()
|
||||||
|
with socket.socket(socket.AF_UNIX) as connection:
|
||||||
|
connection.settimeout(2)
|
||||||
|
connection.connect(str(endpoint))
|
||||||
|
connection.sendall(struct.pack(">I", len(data)) + data)
|
||||||
|
def receive(count):
|
||||||
|
result = bytearray()
|
||||||
|
while len(result) < count:
|
||||||
|
part = connection.recv(count - len(result))
|
||||||
|
if not part:
|
||||||
|
raise ConnectionError("Preview compositor disconnected")
|
||||||
|
result.extend(part)
|
||||||
|
return bytes(result)
|
||||||
|
length = struct.unpack(">I", receive(4))[0]
|
||||||
|
if length > 4 * 1024 * 1024:
|
||||||
|
raise ValueError("Oversized preview response")
|
||||||
|
result = json.loads(receive(length))
|
||||||
|
if "error" in result:
|
||||||
|
raise RuntimeError(result["error"])
|
||||||
|
return result["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--no-build", action="store_true", help="Use existing binaries")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Use debug builds instead of optimized release builds")
|
||||||
|
parser.add_argument("--style", default="floating", help="Initial panel style")
|
||||||
|
args = parser.parse_args()
|
||||||
|
profile = "debug" if args.debug else "release"
|
||||||
|
if not args.no_build:
|
||||||
|
command = ["cargo", "build", "-p", "ferese", "-p", "ferese-shell", "-p", "ferese-settings"]
|
||||||
|
if not args.debug:
|
||||||
|
command.append("--release")
|
||||||
|
subprocess.run(command, cwd=REPO, check=True)
|
||||||
|
binaries = {name: REPO / "target" / profile / name for name in ("ferese", "ferese-shell", "ferese-settings")}
|
||||||
|
if any(not binary.is_file() for binary in binaries.values()):
|
||||||
|
parser.error("Build the compositor, shell and Settings first")
|
||||||
|
display = Path(os.environ.get("WAYLAND_DISPLAY", ""))
|
||||||
|
if not os.environ.get("WAYLAND_DISPLAY"):
|
||||||
|
parser.error("Run this preview inside a Wayland session")
|
||||||
|
if not display.is_absolute():
|
||||||
|
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
|
||||||
|
children = []
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ferese-panel-preview-") as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
# Keep sibling validation and apps opened from the shell on this build,
|
||||||
|
# even if Cargo replaces target/release while the preview is running.
|
||||||
|
preview_bin = root / "bin"
|
||||||
|
preview_bin.mkdir()
|
||||||
|
for name, binary in binaries.items():
|
||||||
|
shutil.copy2(binary, preview_bin / name)
|
||||||
|
for name in ("ferese-record", "ferese-lock", "ferese-launcher", "feresectl"):
|
||||||
|
binary = REPO / "target" / profile / name
|
||||||
|
if binary.is_file():
|
||||||
|
shutil.copy2(binary, preview_bin / name)
|
||||||
|
binaries = {name: preview_bin / name for name in binaries}
|
||||||
|
runtime = root / "runtime"
|
||||||
|
runtime.mkdir(mode=0o700)
|
||||||
|
config = root / "config/ferese"
|
||||||
|
config.mkdir(parents=True)
|
||||||
|
# Keep the complete composition; an authored panel with no groups is empty.
|
||||||
|
source = (REPO / "packaging/config.kdl").read_text().replace('style "flat"', 'style ' + json.dumps(args.style), 1)
|
||||||
|
if args.style == "morph":
|
||||||
|
source = source.replace('border #false', 'border #true', 1)
|
||||||
|
source = re.sub(r'keybinding-guide\s+#true', 'keybinding-guide #false', source)
|
||||||
|
config_path = config / "config.kdl"
|
||||||
|
config_path.write_text(source)
|
||||||
|
subprocess.run([str(binaries["ferese"]), "--check-config", str(config_path)], check=True)
|
||||||
|
env = {key: value for key, value in os.environ.items() if not key.startswith("FERESE_")}
|
||||||
|
env["PATH"] = str(preview_bin) + os.pathsep + env.get("PATH", os.defpath)
|
||||||
|
env.update(XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display), XDG_CONFIG_HOME=str(root / "config"),
|
||||||
|
XDG_STATE_HOME=str(root / "state"), XDG_CACHE_HOME=str(root / "cache"),
|
||||||
|
XDG_DATA_HOME=str(root / "data"), FERESE_ENABLE_SCREENCOPY="1",
|
||||||
|
FERESE_COMPOSITOR_WALLPAPER="1", WINIT_UNIX_BACKEND="wayland", ICED_BACKEND="wgpu", RUST_LOG="ferese=info")
|
||||||
|
env.pop("WAYLAND_SOCKET", None)
|
||||||
|
bus_config = root / "bus.conf"
|
||||||
|
bus_config.write_text('<busconfig><type>session</type><listen>unix:tmpdir=/tmp</listen>'
|
||||||
|
'<policy context="default"><allow send_destination="*"/>'
|
||||||
|
'<allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
|
||||||
|
launcher = root / "launch.sh"
|
||||||
|
launcher.write_text('#!/bin/bash\nunset DISPLAY\nexport WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY}"\n'
|
||||||
|
'env -u WAYLAND_SOCKET -u FERESE_SHELL_CONTROL_SOCKET -u DISPLAY '
|
||||||
|
'WINIT_UNIX_BACKEND=wayland '
|
||||||
|
+ shlex.quote(str(binaries["ferese-settings"])) + ' --page bar &\nexec '
|
||||||
|
+ shlex.quote(str(binaries["ferese-shell"])) + '\n')
|
||||||
|
launcher.chmod(0o700)
|
||||||
|
log_path = root / "session.log"
|
||||||
|
def stop(_signal, _frame):
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
signal.signal(signal.SIGTERM, stop)
|
||||||
|
try:
|
||||||
|
with log_path.open("w") as log:
|
||||||
|
bus = subprocess.Popen(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"],
|
||||||
|
env=env, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True)
|
||||||
|
children.append(bus)
|
||||||
|
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
|
||||||
|
compositor = subprocess.Popen([str(binaries["ferese"]), "--backend", "nested", "--grant-effects",
|
||||||
|
"--grant-shell-control", "--", str(launcher)],
|
||||||
|
env=env, stdout=log, stderr=log, start_new_session=True)
|
||||||
|
children.append(compositor)
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
readiness = "Waiting for the compositor socket"
|
||||||
|
while True:
|
||||||
|
endpoints = list(runtime.glob("ferese/instances/*/control.sock"))
|
||||||
|
trace = log_path.read_text()
|
||||||
|
child = re.search(r'spawned Wayland client.*pid=(\d+)', trace)
|
||||||
|
if compositor.poll() is not None or time.monotonic() >= deadline:
|
||||||
|
raise RuntimeError("Preview did not create its shell and Settings: " + readiness + "\n" + trace[-8000:])
|
||||||
|
if endpoints and child:
|
||||||
|
try:
|
||||||
|
panel = request(endpoints[0], "has-client-surfaces", {"pid": int(child.group(1))})
|
||||||
|
# The private socketpair has the compositor's peer credentials.
|
||||||
|
# Wallpaper is disabled, so this client can only own shell surfaces.
|
||||||
|
panel = panel or request(endpoints[0], "has-client-surfaces", {"pid": compositor.pid})
|
||||||
|
windows = request(endpoints[0], "get-windows")
|
||||||
|
settings = any(window["mapped"] and window["app_id"] == "dev.ferese.Settings"
|
||||||
|
for window in windows)
|
||||||
|
readiness = f"shell surfaces={panel}; windows={windows}"
|
||||||
|
if panel and settings:
|
||||||
|
break
|
||||||
|
except (OSError, ConnectionError):
|
||||||
|
pass
|
||||||
|
time.sleep(.05)
|
||||||
|
print(f"Panel preview ready ({profile} build). Changes apply only inside this window.\nConfig: {config_path}\nLog: {log_path}", flush=True)
|
||||||
|
compositor.wait()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
for child in reversed(children):
|
||||||
|
try:
|
||||||
|
os.killpg(child.pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
child.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
os.killpg(child.pid, signal.SIGKILL)
|
||||||
|
child.wait(timeout=5)
|
||||||
|
if child.stdout is not None:
|
||||||
|
child.stdout.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Exercise real shell frame-driven motion on a private nested desktop and bus.
|
"""Exercise real shell frame-driven motion on a private nested desktop and bus.
|
||||||
|
|
||||||
FERESE_TEST_SHELL_MOTION=1 python3 scripts/tests/test_shell_motion_isolated.py
|
FERESE_TEST_SHELL_MOTION=1 python3 scripts/tests/test_shell_motion_isolated.py
|
||||||
Set FERESE_TEST_BAR_LAYOUT=islands to also test island masks and live switching.
|
Set FERESE_TEST_BAR_LAYOUT=floating to also test island masks and live switching.
|
||||||
Uses release binaries by default. Requires a Wayland host, dbus-daemon and gdbus.
|
Uses release binaries by default. Requires a Wayland host, dbus-daemon and gdbus.
|
||||||
Protocol traces contain only this test's shell traffic, never the host desktop.
|
Protocol traces contain only this test's shell traffic, never the host desktop.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from session_socket import ipc_environment, ipc_socket
|
from session_socket import ipc_environment, ipc_socket
|
||||||
@@ -18,10 +19,9 @@ import unittest
|
|||||||
|
|
||||||
|
|
||||||
def panel_config(preset):
|
def panel_config(preset):
|
||||||
surface, group_surface = ("solid", "none") if preset == "continuous" else ("none", "island")
|
style = preset
|
||||||
return f'''panel main {{
|
return f'''panel main {{
|
||||||
surface "{surface}"
|
style "{style}"
|
||||||
group-surface "{group_surface}"
|
|
||||||
start {{ group navigation {{ island-padding 12; item overview kind=overview; item spaces kind=workspaces; }}; }}
|
start {{ group navigation {{ island-padding 12; item overview kind=overview; item spaces kind=workspaces; }}; }}
|
||||||
center {{ group title {{ island-padding 12; item title kind=focused-window; }}; }}
|
center {{ group title {{ island-padding 12; item title kind=focused-window; }}; }}
|
||||||
end {{ group status {{ island-padding 12; item media kind=media; item quick kind=quick-settings; item network kind=network; item audio kind=audio; item recording kind=recording; item notifications kind=notifications; item battery kind=battery; item clock kind=clock; item display kind=display-mode; }}; }}
|
end {{ group status {{ island-padding 12; item media kind=media; item quick kind=quick-settings; item network kind=network; item audio kind=audio; item recording kind=recording; item notifications kind=notifications; item battery kind=battery; item clock kind=clock; item display kind=display-mode; }}; }}
|
||||||
@@ -43,8 +43,8 @@ class ShellMotionTest(unittest.TestCase):
|
|||||||
runtime.mkdir(mode=0o700)
|
runtime.mkdir(mode=0o700)
|
||||||
config = root / "config/ferese"
|
config = root / "config/ferese"
|
||||||
config.mkdir(parents=True)
|
config.mkdir(parents=True)
|
||||||
bar_layout = os.environ.get("FERESE_TEST_BAR_LAYOUT", "continuous")
|
bar_layout = os.environ.get("FERESE_TEST_BAR_LAYOUT", "flat")
|
||||||
self.assertIn(bar_layout, ("continuous", "islands"))
|
self.assertIn(bar_layout, ("flat", "floating"))
|
||||||
config_file = config / "config.kdl"
|
config_file = config / "config.kdl"
|
||||||
config_file.write_text(
|
config_file.write_text(
|
||||||
'animations { speed 0.5; }\n'
|
'animations { speed 0.5; }\n'
|
||||||
@@ -122,9 +122,18 @@ class ShellMotionTest(unittest.TestCase):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
updates = re.findall(
|
updates = re.findall(
|
||||||
r'ferese_surface_effects_v1[@#]' + effects[-1] + r'\.set_presentation\(\d+, array\[(\d+)\],', trace)
|
r'ferese_surface_effects_v1[@#]' + effects[-1] + r'\.set_panel_presentation\(array\[(\d+)\],', trace)
|
||||||
return int(updates[-1]) // 20 if updates else None
|
return int(updates[-1]) // 20 if updates else None
|
||||||
|
|
||||||
|
def bar_size(surface):
|
||||||
|
identity, start = surface
|
||||||
|
trace = log_path.read_text()[start:]
|
||||||
|
match = re.search(r'get_layer_surface\(new id zwlr_layer_surface_v1[@#](\d+), wl_surface[@#]' + identity + r',', trace)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
updates = re.findall(r'zwlr_layer_surface_v1[@#]' + match.group(1) + r'\.set_size\((\d+), (\d+)\)', trace)
|
||||||
|
return tuple(map(int, updates[-1])) if updates else None
|
||||||
|
|
||||||
def bar_input_rectangles(surface):
|
def bar_input_rectangles(surface):
|
||||||
identity, start = surface
|
identity, start = surface
|
||||||
trace = log_path.read_text()[start:]
|
trace = log_path.read_text()[start:]
|
||||||
@@ -165,9 +174,10 @@ class ShellMotionTest(unittest.TestCase):
|
|||||||
wait_for(lambda: (ipc_socket(runtime)).is_socket())
|
wait_for(lambda: (ipc_socket(runtime)).is_socket())
|
||||||
wait_for(lambda: dbus("GetServerInformation", check=False).returncode == 0)
|
wait_for(lambda: dbus("GetServerInformation", check=False).returncode == 0)
|
||||||
bar = wait_for(lambda: layer_surface("ferese-shell-top-bar"))
|
bar = wait_for(lambda: layer_surface("ferese-shell-top-bar"))
|
||||||
if bar_layout == "islands":
|
if bar_layout == "floating":
|
||||||
wait_for(lambda: bar_region_count(bar) == 2)
|
wait_for(lambda: bar_region_count(bar) == 2)
|
||||||
original_input = wait_for(lambda: bar_input_rectangles(bar))
|
original_input = wait_for(lambda: bar_input_rectangles(bar))
|
||||||
|
original_start_width = max(x + width for x, y, width, height in original_input if x < 300) - min(x for x, y, width, height in original_input if x < 300)
|
||||||
time.sleep(.5)
|
time.sleep(.5)
|
||||||
reply = dbus("Notify", "Ferese motion test", "0", "", "Frame cadence", "Synthetic test notification", "[]", "{}", "0").stdout
|
reply = dbus("Notify", "Ferese motion test", "0", "", "Frame cadence", "Synthetic test notification", "[]", "{}", "0").stdout
|
||||||
notice = re.search(r'uint32 (\d+)', reply).group(1)
|
notice = re.search(r'uint32 (\d+)', reply).group(1)
|
||||||
@@ -251,26 +261,84 @@ class ShellMotionTest(unittest.TestCase):
|
|||||||
wait_for(lambda: destroyed(remaining[0]))
|
wait_for(lambda: destroyed(remaining[0]))
|
||||||
print("Modal: rapid toggles released all native surfaces", flush=True)
|
print("Modal: rapid toggles released all native surfaces", flush=True)
|
||||||
|
|
||||||
if bar_layout == "islands":
|
if bar_layout == "floating":
|
||||||
for layout_name, expected_count in [("continuous", 1), ("islands", None)]:
|
for layout_name, expected_count in [("flat", 1), ("floating", None)]:
|
||||||
config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(layout_name))
|
config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(layout_name))
|
||||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||||
wait_for(lambda: bar_region_count(bar) == (expected_count or 2))
|
wait_for(lambda: bar_region_count(bar) == (expected_count or 2))
|
||||||
|
|
||||||
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
|
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
|
||||||
self.assertNotIn("panicked at", log_path.read_text())
|
self.assertNotIn("panicked at", log_path.read_text())
|
||||||
print("Bar: separate material regions; continuous/islands live switches passed", flush=True)
|
print("Bar: separate material regions; flat/floating live switches passed", flush=True)
|
||||||
|
|
||||||
for padding in [4, 0]:
|
for padding in [4, 0]:
|
||||||
source = config_file.read_text()
|
source = config_file.read_text()
|
||||||
config_file.write_text(re.sub(
|
config_file.write_text(re.sub(
|
||||||
r'island-padding \d+', f'island-padding {padding}', source))
|
r'island-padding \d+', f'island-padding {padding}', source))
|
||||||
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||||
expected_width = original_input[0][2] - 2 * (12 - padding)
|
expected_width = original_start_width - 2 * (12 - padding)
|
||||||
wait_for(lambda: len(bar_input_rectangles(bar)) == 2
|
def start_width():
|
||||||
and bar_input_rectangles(bar)[0][2] == expected_width)
|
rectangles = [(x, width) for x, y, width, height in bar_input_rectangles(bar) if x < 300]
|
||||||
|
return max((x + width for x, width in rectangles), default=0) - min((x for x, width in rectangles), default=0)
|
||||||
|
wait_for(lambda: start_width() == expected_width)
|
||||||
|
|
||||||
print("Bar: live padding reductions updated the input regions", flush=True)
|
print("Bar: live padding reductions updated the input regions", flush=True)
|
||||||
|
if settings_binary := os.environ.get("FERESE_TEST_SETTINGS"):
|
||||||
|
child_display = next(path for path in runtime.glob("wayland-*") if path.is_socket())
|
||||||
|
settings_env = dict(ipc_environment(env), WAYLAND_DISPLAY=str(child_display), WINIT_UNIX_BACKEND="wayland", ICED_BACKEND="wgpu")
|
||||||
|
settings_env.pop("WAYLAND_SOCKET", None)
|
||||||
|
settings_env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
|
||||||
|
settings = subprocess.Popen([str(repo / settings_binary), "--page", "bar"], env=settings_env,
|
||||||
|
start_new_session=True, stdout=log, stderr=log)
|
||||||
|
children.append(settings)
|
||||||
|
def windows():
|
||||||
|
response = subprocess.run([str(ctl), "--json", "windows"], env=ipc_environment(env),
|
||||||
|
text=True, capture_output=True, check=True, timeout=5)
|
||||||
|
return json.loads(response.stdout)
|
||||||
|
wait_for(lambda: any(window["mapped"] and window["app_id"] == "dev.ferese.Settings" for window in windows()))
|
||||||
|
config_file.write_text('status { keybinding-guide #false; }\n' + panel_config("floating").replace('style "floating"', 'style "adaptive"'))
|
||||||
|
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||||
|
wait_for(lambda: bar_region_count(bar) == 3)
|
||||||
|
for command, count in [("toggle-maximized", 1), ("toggle-maximized", 3)]:
|
||||||
|
subprocess.run([str(ctl), command], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||||
|
wait_for(lambda: bar_region_count(bar) == count)
|
||||||
|
subprocess.run([str(ctl), "toggle-fullscreen"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||||
|
wait_for(lambda: not bar_input_rectangles(bar))
|
||||||
|
subprocess.run([str(ctl), "toggle-fullscreen"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||||
|
wait_for(lambda: bar_input_rectangles(bar))
|
||||||
|
settings.terminate()
|
||||||
|
settings.wait(timeout=5)
|
||||||
|
wait_for(lambda: not windows())
|
||||||
|
print("Adaptive: maximization changed the real panel; fullscreen released input and restored it", flush=True)
|
||||||
|
|
||||||
|
if bar_layout == "floating":
|
||||||
|
for style, regions in [("floating", 2), ("capsules", 2), ("notch", 2), ("flat", 1), ("fused", 2), ("adaptive", 2)]:
|
||||||
|
config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\n' + panel_config(style))
|
||||||
|
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||||
|
wait_for(lambda: bar_region_count(bar) == regions)
|
||||||
|
self.assertTrue(bar_input_rectangles(bar), style + " lost its native input")
|
||||||
|
config_file.write_text('animations { speed 0.5; }\nstatus { keybinding-guide #false; }\nnotifications { timeout-ms 1200; }\n' + panel_config("morph"))
|
||||||
|
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||||
|
wait_for(lambda: bar_size(bar) == (150, 34) and bar_region_count(bar) == 1)
|
||||||
|
reply = dbus("Notify", "Morph test", "0", "", "Hub notification", "Bounded native surface", "[]", "{}", "0").stdout
|
||||||
|
notice = re.search(r'uint32 (\d+)', reply).group(1)
|
||||||
|
wait_for(lambda: bar_size(bar) == (320, 48))
|
||||||
|
# Removing a notice must collapse its hub immediately; an old timeout cannot reopen it.
|
||||||
|
dbus("CloseNotification", notice)
|
||||||
|
wait_for(lambda: bar_size(bar) == (150, 34))
|
||||||
|
time.sleep(.25)
|
||||||
|
before = commits(bar)
|
||||||
|
trace_start = len(log_path.read_text())
|
||||||
|
time.sleep(.5)
|
||||||
|
# Clock/service updates can cause a short render burst; settled geometry must not animate.
|
||||||
|
self.assertNotIn(".set_panel_presentation(", log_path.read_text()[trace_start:])
|
||||||
|
self.assertLessEqual(commits(bar) - before, 4, "settled Morph keeps scheduling frames")
|
||||||
|
self.assertEqual(bar_region_count(bar), 1)
|
||||||
|
config_file.write_text('status { keybinding-guide #false; }\n' + panel_config("floating"))
|
||||||
|
subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True, timeout=5)
|
||||||
|
wait_for(lambda: bar_region_count(bar) == 2)
|
||||||
|
print("Styles: native masks survived every live switch; Morph grew, closed and stopped frames", flush=True)
|
||||||
|
|
||||||
saved = config_file.read_text()
|
saved = config_file.read_text()
|
||||||
config_file.write_text('panel main { surface "invalid-surface"; }\n')
|
config_file.write_text('panel main { surface "invalid-surface"; }\n')
|
||||||
rejected = subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env),
|
rejected = subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env),
|
||||||
@@ -293,6 +361,8 @@ class ShellMotionTest(unittest.TestCase):
|
|||||||
self.assertNotIn("panicked at", log_path.read_text())
|
self.assertNotIn("panicked at", log_path.read_text())
|
||||||
print("Reload: invalid config preserved the panel; repaired config reopened and released a modal", flush=True)
|
print("Reload: invalid config preserved the panel; repaired config reopened and released a modal", flush=True)
|
||||||
finally:
|
finally:
|
||||||
|
if destination := os.environ.get("FERESE_TEST_LOG"):
|
||||||
|
shutil.copyfile(log_path, destination)
|
||||||
for child in reversed(children):
|
for child in reversed(children):
|
||||||
if child.poll() is None:
|
if child.poll() is None:
|
||||||
os.killpg(child.pid, signal.SIGTERM)
|
os.killpg(child.pid, signal.SIGTERM)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! PAM stays on a worker thread. No password is placed in argv, logs or IPC.
|
|
||||||
use std::ffi::{CStr, CString, c_char, c_int, c_void};
|
use std::ffi::{CStr, CString, c_char, c_int, c_void};
|
||||||
|
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! Startup handshake: the invoking command exits successfully only after the
|
|
||||||
//! compositor's confirmation. The UI child remains alive for authentication.
|
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ferese-panel = { path = "../../crates/ferese-panel" }
|
||||||
ferese-theme-client = { path = "../../crates/ferese-theme-client" }
|
ferese-theme-client = { path = "../../crates/ferese-theme-client" }
|
||||||
ferese-theme = { path = "../../crates/ferese-theme" }
|
ferese-theme = { path = "../../crates/ferese-theme" }
|
||||||
ferese-config = { path = "../../crates/ferese-config" }
|
ferese-config = { path = "../../crates/ferese-config" }
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ enum InitialPage {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
enum Message {
|
enum Message {
|
||||||
|
ReadRenderScale(cosmic::iced::window::Id),
|
||||||
|
RenderScale(f32),
|
||||||
Connections(connections::Input),
|
Connections(connections::Input),
|
||||||
ConnectionsReady(Result<std::sync::Arc<connections::Client>, String>),
|
ConnectionsReady(Result<std::sync::Arc<connections::Client>, String>),
|
||||||
ConnectionsRefreshed(Box<connections::Snapshot>),
|
ConnectionsRefreshed(Box<connections::Snapshot>),
|
||||||
@@ -114,9 +116,9 @@ enum Message {
|
|||||||
Change(Edit),
|
Change(Edit),
|
||||||
PanelClearSelection,
|
PanelClearSelection,
|
||||||
PanelPage(panel_controls::PanelPage),
|
PanelPage(panel_controls::PanelPage),
|
||||||
PanelSelect(ferese_config::panel::ItemId),
|
PanelSelect(ferese_panel::ItemId),
|
||||||
PanelGroupSelect(ferese_config::panel::GroupId),
|
PanelGroupSelect(ferese_panel::GroupId),
|
||||||
PanelPreviewResolved(ferese_config::panel::layout::Resolution),
|
PanelPreviewResolved(ferese_panel::layout::Resolution),
|
||||||
PanelPreviewOverflow,
|
PanelPreviewOverflow,
|
||||||
PanelEdit(panel_edit::Action),
|
PanelEdit(panel_edit::Action),
|
||||||
Draft(String, String),
|
Draft(String, String),
|
||||||
@@ -159,6 +161,7 @@ struct App {
|
|||||||
display_selection: Option<String>,
|
display_selection: Option<String>,
|
||||||
display_error: Option<String>,
|
display_error: Option<String>,
|
||||||
core: Core,
|
core: Core,
|
||||||
|
render_scale: f64,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
current: Snapshot,
|
current: Snapshot,
|
||||||
draft: Snapshot,
|
draft: Snapshot,
|
||||||
@@ -187,10 +190,10 @@ struct App {
|
|||||||
undo_revision: u64,
|
undo_revision: u64,
|
||||||
auto_details: bool,
|
auto_details: bool,
|
||||||
advanced_theme: bool,
|
advanced_theme: bool,
|
||||||
panel_selection: Option<ferese_config::panel::ItemId>,
|
panel_selection: Option<ferese_panel::ItemId>,
|
||||||
panel_page: panel_controls::PanelPage,
|
panel_page: panel_controls::PanelPage,
|
||||||
panel_group_selection: Option<ferese_config::panel::GroupId>,
|
panel_group_selection: Option<ferese_panel::GroupId>,
|
||||||
panel_preview_resolution: ferese_config::panel::layout::Resolution,
|
panel_preview_resolution: ferese_panel::layout::Resolution,
|
||||||
panel_preview_overflow: bool,
|
panel_preview_overflow: bool,
|
||||||
theme_file_target: usize,
|
theme_file_target: usize,
|
||||||
wallpaper: wallpaper_controls::State,
|
wallpaper: wallpaper_controls::State,
|
||||||
@@ -263,6 +266,7 @@ impl cosmic::Application for App {
|
|||||||
display_selection: None,
|
display_selection: None,
|
||||||
display_error: None,
|
display_error: None,
|
||||||
core,
|
core,
|
||||||
|
render_scale: 1.,
|
||||||
path,
|
path,
|
||||||
draft: current.clone(),
|
draft: current.clone(),
|
||||||
current,
|
current,
|
||||||
@@ -326,6 +330,13 @@ impl cosmic::Application for App {
|
|||||||
|
|
||||||
fn update(&mut self, message: Message) -> Task<Message> {
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
|
Message::ReadRenderScale(id) => {
|
||||||
|
return cosmic::iced::window::scale_factor(id)
|
||||||
|
.map(|scale| cosmic::Action::App(Message::RenderScale(scale)));
|
||||||
|
}
|
||||||
|
Message::RenderScale(scale) if scale.is_finite() && scale > 0. && scale <= 16. => {
|
||||||
|
self.render_scale = f64::from(scale);
|
||||||
|
}
|
||||||
Message::Connections(input) => return self.connections_input(input),
|
Message::Connections(input) => return self.connections_input(input),
|
||||||
Message::ConnectionsReady(result) => return self.connections_ready(result),
|
Message::ConnectionsReady(result) => return self.connections_ready(result),
|
||||||
Message::ConnectionsRefreshed(snapshot) => self.connections_refreshed(*snapshot),
|
Message::ConnectionsRefreshed(snapshot) => self.connections_refreshed(*snapshot),
|
||||||
@@ -889,6 +900,21 @@ impl cosmic::Application for App {
|
|||||||
|
|
||||||
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
|
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
|
||||||
cosmic::iced::Subscription::batch([
|
cosmic::iced::Subscription::batch([
|
||||||
|
cosmic::iced::event::listen_with(|event, _, id| match event {
|
||||||
|
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
|
||||||
|
Some(Message::ReadRenderScale(id))
|
||||||
|
}
|
||||||
|
cosmic::iced::Event::Window(cosmic::iced::window::Event::Rescaled(scale)) => {
|
||||||
|
Some(Message::RenderScale(scale))
|
||||||
|
}
|
||||||
|
cosmic::iced::Event::PlatformSpecific(cosmic::iced::event::PlatformSpecific::Wayland(
|
||||||
|
cosmic::iced::event::wayland::Event::Output(
|
||||||
|
cosmic::iced::event::wayland::OutputEvent::InfoUpdate(_),
|
||||||
|
_,
|
||||||
|
),
|
||||||
|
)) => Some(Message::ReadRenderScale(id)),
|
||||||
|
_ => None,
|
||||||
|
}),
|
||||||
if self.sidebar_open {
|
if self.sidebar_open {
|
||||||
cosmic::iced::event::listen_with(|event, _, _| match event {
|
cosmic::iced::event::listen_with(|event, _, _| match event {
|
||||||
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||||
@@ -1102,7 +1128,7 @@ mod tests {
|
|||||||
let _ = app.update(Message::Range(field.clone(), 23.));
|
let _ = app.update(Message::Range(field.clone(), 23.));
|
||||||
assert_eq!(app.draft.source, before);
|
assert_eq!(app.draft.source, before);
|
||||||
let _ = app.update(Message::Release(field));
|
let _ = app.update(Message::Release(field));
|
||||||
let radii: ferese_config::panel::CornerRadii =
|
let radii: ferese_panel::CornerRadii =
|
||||||
serde_json::from_value(app.draft.item("panels.0.corner_radius").unwrap().clone()).unwrap();
|
serde_json::from_value(app.draft.item("panels.0.corner_radius").unwrap().clone()).unwrap();
|
||||||
assert_eq!(radii.0, [4., 8., 23., 16.]);
|
assert_eq!(radii.0, [4., 8., 23., 16.]);
|
||||||
assert!(app.ranges.is_empty());
|
assert!(app.ranges.is_empty());
|
||||||
@@ -1111,7 +1137,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pending_panel_moves_save_both_groups_together_and_keep_the_selected_instance() {
|
fn pending_panel_moves_save_both_groups_together_and_keep_the_selected_instance() {
|
||||||
use ferese_config::panel::{GroupId, ItemId};
|
use ferese_panel::{GroupId, ItemId};
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
let mut initial = Snapshot::parse(String::new()).unwrap();
|
let mut initial = Snapshot::parse(String::new()).unwrap();
|
||||||
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
||||||
@@ -1139,13 +1165,13 @@ mod tests {
|
|||||||
assert!(app.saving, "the complete queued draft is saved once");
|
assert!(app.saving, "the complete queued draft is saved once");
|
||||||
assert!(app.pending.is_empty());
|
assert!(app.pending.is_empty());
|
||||||
assert_eq!(app.draft.doc.value(), &expected);
|
assert_eq!(app.draft.doc.value(), &expected);
|
||||||
let panels: Vec<ferese_config::panel::Panel> =
|
let panels: Vec<ferese_panel::Panel> =
|
||||||
serde_json::from_value(app.draft.item("panels").unwrap().clone()).unwrap();
|
serde_json::from_value(app.draft.item("panels").unwrap().clone()).unwrap();
|
||||||
ferese_config::panel::validate(&panels).unwrap();
|
ferese_panel::validate(&panels).unwrap();
|
||||||
assert_eq!(panels[0].start.groups[1].items[1].id, id);
|
assert_eq!(panels[0].start.groups[1].items[1].id, id);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
panels[0].start.groups[1].items[1].overflow,
|
panels[0].start.groups[1].items[1].overflow,
|
||||||
ferese_config::panel::OverflowPolicy::Always
|
ferese_panel::OverflowPolicy::Always
|
||||||
);
|
);
|
||||||
assert!(!panels[0].end.groups[0].items.iter().any(|item| item.id == id));
|
assert!(!panels[0].end.groups[0].items.iter().any(|item| item.id == id));
|
||||||
}
|
}
|
||||||
@@ -1181,7 +1207,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
app.panel_page = panel_controls::PanelPage::Arrange;
|
app.panel_page = panel_controls::PanelPage::Arrange;
|
||||||
let collapsed = nodes(&app);
|
let collapsed = nodes(&app);
|
||||||
let id = ferese_config::panel::ItemId("clock".into());
|
let id = ferese_panel::ItemId("clock".into());
|
||||||
let _ = app.update(Message::PanelSelect(id.clone()));
|
let _ = app.update(Message::PanelSelect(id.clone()));
|
||||||
let expanded = nodes(&app);
|
let expanded = nodes(&app);
|
||||||
assert!(expanded > collapsed);
|
assert!(expanded > collapsed);
|
||||||
@@ -1193,7 +1219,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pending_group_moves_and_style_changes_save_one_coherent_draft() {
|
fn pending_group_moves_and_style_changes_save_one_coherent_draft() {
|
||||||
use ferese_config::panel::GroupId;
|
use ferese_panel::GroupId;
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
let mut initial = Snapshot::parse(String::new()).unwrap();
|
let mut initial = Snapshot::parse(String::new()).unwrap();
|
||||||
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
||||||
@@ -1225,7 +1251,7 @@ mod tests {
|
|||||||
fn selecting_a_generated_preview_item_does_not_save_the_composition() {
|
fn selecting_a_generated_preview_item_does_not_save_the_composition() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
app.saving = true;
|
app.saving = true;
|
||||||
let id = ferese_config::panel::ItemId("clock".into());
|
let id = ferese_panel::ItemId("clock".into());
|
||||||
let _ = app.update(Message::PanelSelect(id.clone()));
|
let _ = app.update(Message::PanelSelect(id.clone()));
|
||||||
assert_eq!(app.panel_selection, Some(id));
|
assert_eq!(app.panel_selection, Some(id));
|
||||||
assert!(app.draft.item("panels").is_none());
|
assert!(app.draft.item("panels").is_none());
|
||||||
@@ -1244,19 +1270,17 @@ mod tests {
|
|||||||
assert!(app.draft.item("panels").is_none());
|
assert!(app.draft.item("panels").is_none());
|
||||||
}
|
}
|
||||||
app.saving = true;
|
app.saving = true;
|
||||||
let _ = app.update(Message::PanelSelect(ferese_config::panel::ItemId("clock".into())));
|
let _ = app.update(Message::PanelSelect(ferese_panel::ItemId("clock".into())));
|
||||||
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
|
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
|
||||||
assert!(app.panel_group_selection.is_none());
|
assert!(app.panel_group_selection.is_none());
|
||||||
let _ = app.update(Message::PanelGroupSelect(ferese_config::panel::GroupId(
|
let _ = app.update(Message::PanelGroupSelect(ferese_panel::GroupId("status".into())));
|
||||||
"status".into(),
|
|
||||||
)));
|
|
||||||
assert!(app.panel_selection.is_none());
|
assert!(app.panel_selection.is_none());
|
||||||
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
|
assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dropped_items_follow_queued_group_moves_and_are_saved_once() {
|
fn dropped_items_follow_queued_group_moves_and_are_saved_once() {
|
||||||
use ferese_config::panel::{GroupId, ItemId};
|
use ferese_panel::{GroupId, ItemId};
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
let mut initial = Snapshot::parse(String::new()).unwrap();
|
let mut initial = Snapshot::parse(String::new()).unwrap();
|
||||||
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
initial.edit(&panel_controls::initialize(&initial).unwrap()).unwrap();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use ferese_config::panel::{Edge, ItemKind, Panel};
|
use ferese_panel::{Edge, ItemKind, Panel};
|
||||||
|
|
||||||
use crate::panel_edit::{Action, Destination, Zone};
|
use crate::panel_edit::{Action, Destination, Zone};
|
||||||
use crate::{App, Element, Message, schema::Page, store, widget};
|
use crate::{App, Element, Message, schema::Page, store, widget};
|
||||||
@@ -12,7 +12,7 @@ pub(super) enum PanelPage {
|
|||||||
|
|
||||||
pub(super) fn initialize(_snapshot: &store::Snapshot) -> Result<store::Edit, String> {
|
pub(super) fn initialize(_snapshot: &store::Snapshot) -> Result<store::Edit, String> {
|
||||||
let panel = Panel::default();
|
let panel = Panel::default();
|
||||||
ferese_config::panel::validate(std::slice::from_ref(&panel))?;
|
ferese_panel::validate(std::slice::from_ref(&panel))?;
|
||||||
Ok(store::set(
|
Ok(store::set(
|
||||||
"panels",
|
"panels",
|
||||||
serde_json::to_value(vec![panel]).map_err(|error| error.to_string())?,
|
serde_json::to_value(vec![panel]).map_err(|error| error.to_string())?,
|
||||||
@@ -26,10 +26,18 @@ impl std::fmt::Display for CatalogItem {
|
|||||||
formatter.write_str(self.0.label())
|
formatter.write_str(self.0.label())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
struct StyleChoice(ferese_panel::PanelStyle);
|
||||||
|
impl std::fmt::Display for StyleChoice {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str(self.0.label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const CATALOG: [CatalogItem; 12] = [
|
const CATALOG: [CatalogItem; 12] = [
|
||||||
CatalogItem(ItemKind::Overview),
|
CatalogItem(ItemKind::Overview),
|
||||||
CatalogItem(ItemKind::Workspaces {
|
CatalogItem(ItemKind::Workspaces {
|
||||||
style: ferese_config::panel::WorkspaceStyle::Dots,
|
style: ferese_panel::WorkspaceStyle::Dots,
|
||||||
}),
|
}),
|
||||||
CatalogItem(ItemKind::FocusedWindow),
|
CatalogItem(ItemKind::FocusedWindow),
|
||||||
CatalogItem(ItemKind::Media),
|
CatalogItem(ItemKind::Media),
|
||||||
@@ -104,11 +112,6 @@ impl App {
|
|||||||
.class(crate::visuals::surface(palette.card, 14.)),
|
.class(crate::visuals::surface(palette.card, 14.)),
|
||||||
);
|
);
|
||||||
let preset = self.preview_panel().ok().and_then(|panel| panel.preset());
|
let preset = self.preview_panel().ok().and_then(|panel| panel.preset());
|
||||||
let preset_key = match preset {
|
|
||||||
Some(ferese_config::PanelPreset::Continuous) => "continuous",
|
|
||||||
Some(ferese_config::PanelPreset::Islands) => "islands",
|
|
||||||
None => "custom",
|
|
||||||
};
|
|
||||||
let position = widget::row([Edge::Top, Edge::Bottom].into_iter().map(|position| {
|
let position = widget::row([Edge::Top, Edge::Bottom].into_iter().map(|position| {
|
||||||
let label = if position == Edge::Top { "Top" } else { "Bottom" };
|
let label = if position == Edge::Top { "Top" } else { "Bottom" };
|
||||||
widget::button::custom(self.label(label, 12.))
|
widget::button::custom(self.label(label, 12.))
|
||||||
@@ -134,53 +137,20 @@ impl App {
|
|||||||
.padding(16)
|
.padding(16)
|
||||||
.class(crate::visuals::surface(palette.card, 14.)),
|
.class(crate::visuals::surface(palette.card, 14.)),
|
||||||
);
|
);
|
||||||
let appearance = widget::column([])
|
let mut appearance = widget::column([])
|
||||||
.spacing(16)
|
.spacing(16)
|
||||||
.push(self.panel_heading("Appearance", 15.))
|
.push(self.panel_heading("Appearance", 15.))
|
||||||
.push(self.panel_appearance_row(
|
.push(self.panel_style_choices(preset.unwrap_or_default()))
|
||||||
"Background",
|
|
||||||
"Apply a preset, then customize each group.",
|
|
||||||
self.panel_surface_choices(
|
|
||||||
preset_key,
|
|
||||||
&[("continuous", "Continuous"), ("islands", "Islands")],
|
|
||||||
move |key| {
|
|
||||||
Message::PanelEdit(Action::ApplyPreset(if key == "islands" {
|
|
||||||
ferese_config::PanelPreset::Islands
|
|
||||||
} else {
|
|
||||||
ferese_config::PanelPreset::Continuous
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.push(
|
|
||||||
self.panel_appearance_row(
|
|
||||||
"Default group surface",
|
|
||||||
"Groups can override this in their inspector.",
|
|
||||||
self.panel_surface_choices(
|
|
||||||
self.preview_panel()
|
|
||||||
.map_or(ferese_config::panel::GroupSurface::None, |panel| panel.group_surface)
|
|
||||||
.key(),
|
|
||||||
&[("none", "None"), ("inset", "Inset"), ("island", "Island")],
|
|
||||||
|key| {
|
|
||||||
Message::PanelEdit(Action::SetDefaultSurface(match key {
|
|
||||||
"inset" => ferese_config::panel::GroupSurface::Inset,
|
|
||||||
"island" => ferese_config::panel::GroupSurface::Island,
|
|
||||||
_ => ferese_config::panel::GroupSurface::None,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.push(self.panel_divider())
|
.push(self.panel_divider())
|
||||||
.push(self.panel_opacity_controls())
|
.push(self.panel_opacity_controls())
|
||||||
.push(self.panel_divider())
|
.push(self.panel_divider())
|
||||||
.push(
|
.push(
|
||||||
self.panel_appearance_row(
|
self.panel_appearance_row(
|
||||||
"Group borders",
|
"Outline",
|
||||||
"Outline each group of items.",
|
"Add a fine line around the panel shape.",
|
||||||
widget::container(
|
widget::container(
|
||||||
ferese_theme::controls::switch(self.draft.boolean("panels.0.border", false), palette)
|
ferese_theme::controls::switch(self.draft.boolean("panels.0.border", false), palette)
|
||||||
.name("Group borders")
|
.name("Outline")
|
||||||
.on_press(Message::PanelEdit(Action::SetBorder(
|
.on_press(Message::PanelEdit(Action::SetBorder(
|
||||||
!self.draft.boolean("panels.0.border", false),
|
!self.draft.boolean("panels.0.border", false),
|
||||||
))),
|
))),
|
||||||
@@ -190,7 +160,44 @@ impl App {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.push(self.panel_divider())
|
.push(self.panel_divider())
|
||||||
.push(self.panel_radius_controls());
|
.push(if preset == Some(ferese_panel::PanelStyle::Flat) {
|
||||||
|
widget::Space::new().height(0).into()
|
||||||
|
} else {
|
||||||
|
self.panel_radius_controls()
|
||||||
|
});
|
||||||
|
if preset == Some(ferese_panel::PanelStyle::Morph) {
|
||||||
|
let enabled = self.draft.boolean("panels.0.morph.peek_on_hover", true);
|
||||||
|
let attached = self.draft.boolean("panels.0.morph.attached", false);
|
||||||
|
appearance = appearance
|
||||||
|
.push(self.panel_divider())
|
||||||
|
.push(
|
||||||
|
self.panel_appearance_row(
|
||||||
|
"Attach to screen edge",
|
||||||
|
"Join the island to the screen edge with curved shoulders.",
|
||||||
|
widget::container(
|
||||||
|
ferese_theme::controls::switch(attached, palette)
|
||||||
|
.name("Attach to screen edge")
|
||||||
|
.on_press(Message::PanelEdit(Action::SetMorphAttached(!attached))),
|
||||||
|
)
|
||||||
|
.align_right(cosmic::iced::Length::Fill)
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.push(self.panel_divider())
|
||||||
|
.push(
|
||||||
|
self.panel_appearance_row(
|
||||||
|
"Preview on hover",
|
||||||
|
"Expand the island when you hover over it.",
|
||||||
|
widget::container(
|
||||||
|
ferese_theme::controls::switch(enabled, palette)
|
||||||
|
.name("Preview on hover")
|
||||||
|
.on_press(Message::PanelEdit(Action::SetPeekOnHover(!enabled))),
|
||||||
|
)
|
||||||
|
.align_right(cosmic::iced::Length::Fill)
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
rows = rows.push(
|
rows = rows.push(
|
||||||
widget::container(appearance)
|
widget::container(appearance)
|
||||||
.id("panel-appearance-settings")
|
.id("panel-appearance-settings")
|
||||||
@@ -198,21 +205,70 @@ impl App {
|
|||||||
.width(cosmic::iced::Length::Fill)
|
.width(cosmic::iced::Length::Fill)
|
||||||
.class(crate::visuals::surface(palette.card, 14.)),
|
.class(crate::visuals::surface(palette.card, 14.)),
|
||||||
);
|
);
|
||||||
let mut settings = widget::column([])
|
let pins = self.preview_panel().is_ok_and(|panel| {
|
||||||
.spacing(4)
|
panel.geometry != ferese_panel::GeometryIntent::default() || panel.corner_radius.is_some()
|
||||||
.push(widget::container(self.panel_heading("Size & spacing", 15.)).padding([4, 10]));
|
});
|
||||||
|
let mut settings = widget::column([]).spacing(4).push(
|
||||||
|
widget::container(
|
||||||
|
widget::row![
|
||||||
|
self.panel_heading("Size & spacing", 15.)
|
||||||
|
.width(cosmic::iced::Length::Fill),
|
||||||
|
self.settings_button(
|
||||||
|
"Reset to style",
|
||||||
|
"M3 12a9 9 0 1 0 3-6 M3 3v6h6",
|
||||||
|
pins.then_some(Message::PanelEdit(Action::ResetShape)),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
.align_y(cosmic::iced::Alignment::Center),
|
||||||
|
)
|
||||||
|
.padding([4, 10]),
|
||||||
|
);
|
||||||
for field in crate::schema::fields(Page::Bar)
|
for field in crate::schema::fields(Page::Bar)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|field| field.path.starts_with("panels.0.geometry."))
|
.filter(|field| field.path.starts_with("panels.0.geometry."))
|
||||||
|
.filter(|field| {
|
||||||
|
if preset == Some(ferese_panel::PanelStyle::Morph)
|
||||||
|
&& self.draft.boolean("panels.0.morph.attached", false)
|
||||||
|
&& field.path.ends_with(".edge_margin")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
!matches!(
|
||||||
|
preset.unwrap_or_default(),
|
||||||
|
ferese_panel::PanelStyle::Flat | ferese_panel::PanelStyle::Notch
|
||||||
|
) || !matches!(field.path.rsplit('.').next(), Some("edge_margin" | "side_margins"))
|
||||||
|
})
|
||||||
{
|
{
|
||||||
let mut field = field;
|
let mut field = field;
|
||||||
|
if preset == Some(ferese_panel::PanelStyle::Morph) {
|
||||||
|
if matches!(field.path.rsplit('.').next(), Some("inner_padding" | "side_margins")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if field.path.ends_with(".height") {
|
||||||
|
field.label = "Collapsed height".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
if let crate::schema::Kind::Range { default, .. } = &mut field.kind {
|
if let crate::schema::Kind::Range { default, .. } = &mut field.kind {
|
||||||
let geometry =
|
let geometry = serde_json::to_value(
|
||||||
serde_json::to_value(self.preview_panel().map(|panel| panel.geometry).unwrap_or_default())
|
self.preview_panel()
|
||||||
.unwrap();
|
.map(|panel| panel.resolved_geometry())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
*default = geometry[field.path.rsplit('.').next().unwrap()].as_f64().unwrap();
|
*default = geometry[field.path.rsplit('.').next().unwrap()].as_f64().unwrap();
|
||||||
}
|
}
|
||||||
settings = settings.push(self.field(field));
|
let pinned = self.draft.item(&field.path).is_some();
|
||||||
|
let reset = pinned.then_some(Message::PanelEdit(Action::ClearGeometry(field.path.clone())));
|
||||||
|
let reset_label = format!("Reset {}", field.label);
|
||||||
|
settings = settings.push(
|
||||||
|
widget::row![
|
||||||
|
self.field(field),
|
||||||
|
self.settings_icon_button(&reset_label, "M3 12a9 9 0 1 0 3-6 M3 3v6h6", reset)
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(cosmic::iced::Alignment::Center),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
rows = rows.push(
|
rows = rows.push(
|
||||||
widget::container(settings)
|
widget::container(settings)
|
||||||
@@ -241,10 +297,11 @@ impl App {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let panel = self.preview_panel().ok()?;
|
let panel = self.preview_panel().ok()?;
|
||||||
Some(
|
Some(self.panel_preview_resolution.side_margin(
|
||||||
self.panel_preview_resolution
|
4096,
|
||||||
.side_margin(4096, display.logical_width?, panel.geometry.inner_padding),
|
display.logical_width?,
|
||||||
)
|
panel.resolved_geometry().inner_padding,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn panel_appearance_row(
|
fn panel_appearance_row(
|
||||||
@@ -331,13 +388,16 @@ impl App {
|
|||||||
fn panel_radius_controls(&self) -> Element<'static, Message> {
|
fn panel_radius_controls(&self) -> Element<'static, Message> {
|
||||||
use cosmic::iced::{Alignment, Length};
|
use cosmic::iced::{Alignment, Length};
|
||||||
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
let shell_radius = self.resolved.presented.tokens.geometry.shell_radius as f32;
|
let shell_radius = self
|
||||||
|
.preview_panel()
|
||||||
|
.map(|panel| panel.resolved_geometry().height * 0.5)
|
||||||
|
.unwrap_or(18.);
|
||||||
let custom = self
|
let custom = self
|
||||||
.draft
|
.draft
|
||||||
.item("panels.0.corner_radius")
|
.item("panels.0.corner_radius")
|
||||||
.and_then(|value| serde_json::from_value::<ferese_config::panel::CornerRadii>(value.clone()).ok());
|
.and_then(|value| serde_json::from_value::<ferese_panel::CornerRadii>(value.clone()).ok());
|
||||||
let radii = custom.unwrap_or_else(|| ferese_config::panel::CornerRadii::uniform(shell_radius));
|
let radii = custom.unwrap_or_else(|| ferese_panel::CornerRadii::uniform(shell_radius));
|
||||||
let modes = widget::row([("Shell default", false), ("Custom", true)].map(|(label, selected)| {
|
let modes = widget::row([("Automatic", false), ("Custom", true)].map(|(label, selected)| {
|
||||||
widget::button::custom(self.label(label, 12.).align_x(Alignment::Center))
|
widget::button::custom(self.label(label, 12.).align_x(Alignment::Center))
|
||||||
.name(format!("Corner radius: {label}"))
|
.name(format!("Corner radius: {label}"))
|
||||||
.padding([8, 12])
|
.padding([8, 12])
|
||||||
@@ -359,10 +419,10 @@ impl App {
|
|||||||
let mut corners = Vec::new();
|
let mut corners = Vec::new();
|
||||||
// Match the physical corners: top row first, then bottom-left / bottom-right.
|
// Match the physical corners: top row first, then bottom-left / bottom-right.
|
||||||
for (index, label, icon) in [
|
for (index, label, icon) in [
|
||||||
(0, "Top left", "M5 19V9a4 4 0 0 1 4-4h10"),
|
(0, "Edge left", "M5 19V9a4 4 0 0 1 4-4h10"),
|
||||||
(1, "Top right", "M5 5h10a4 4 0 0 1 4 4v10"),
|
(1, "Edge right", "M5 5h10a4 4 0 0 1 4 4v10"),
|
||||||
(3, "Bottom left", "M5 5v10a4 4 0 0 0 4 4h10"),
|
(3, "Outer left", "M5 5v10a4 4 0 0 0 4 4h10"),
|
||||||
(2, "Bottom right", "M5 19h10a4 4 0 0 0 4-4V5"),
|
(2, "Outer right", "M5 19h10a4 4 0 0 0 4-4V5"),
|
||||||
] {
|
] {
|
||||||
let value = radii.0[index];
|
let value = radii.0[index];
|
||||||
let tint = if custom.is_some() { palette.text } else { palette.muted };
|
let tint = if custom.is_some() { palette.text } else { palette.muted };
|
||||||
@@ -406,10 +466,21 @@ impl App {
|
|||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if custom.is_none() {
|
||||||
|
return self.panel_appearance_row(
|
||||||
|
"Corner radius",
|
||||||
|
"Corners follow the style and panel height.",
|
||||||
|
modes.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
let bottom_corners = corners.split_off(2);
|
let bottom_corners = corners.split_off(2);
|
||||||
let mut content = widget::column([])
|
let content = widget::column([])
|
||||||
.spacing(12)
|
.spacing(12)
|
||||||
.push(self.panel_appearance_row("Corner radius", "Set the rounding of each corner.", modes.into()))
|
.push(self.panel_appearance_row(
|
||||||
|
"Corner radius",
|
||||||
|
"Pins stay in place when you change styles. Top and bottom panels mirror them.",
|
||||||
|
modes.into(),
|
||||||
|
))
|
||||||
.push(
|
.push(
|
||||||
widget::column([])
|
widget::column([])
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
@@ -426,14 +497,7 @@ impl App {
|
|||||||
.width(Length::Fill),
|
.width(Length::Fill),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if self.preview_panel().is_ok_and(|panel| panel.geometry.edge_margin == 0) {
|
|
||||||
let bottom = self.preview_panel().is_ok_and(|panel| panel.edge == Edge::Bottom);
|
|
||||||
content = content.push(self.note(if bottom {
|
|
||||||
"Bottom corners stay square while the panel touches the bottom edge."
|
|
||||||
} else {
|
|
||||||
"Top corners stay square while the panel touches the top edge."
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
content.into()
|
content.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,51 +600,45 @@ impl App {
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn panel_surface_choices(
|
fn panel_style_choices(&self, selected: ferese_panel::PanelStyle) -> Element<'static, Message> {
|
||||||
&self,
|
use cosmic::iced::Length;
|
||||||
selected: &str,
|
use ferese_panel::PanelStyle;
|
||||||
options: &'static [(&'static str, &'static str)],
|
|
||||||
message: impl Fn(&'static str) -> Message,
|
|
||||||
) -> Element<'static, Message> {
|
|
||||||
use cosmic::iced::{Alignment, Length};
|
|
||||||
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
let options = widget::row(options.iter().map(|&(key, label)| {
|
let description = match selected {
|
||||||
widget::button::custom(
|
PanelStyle::Floating => "Separate floating groups",
|
||||||
widget::row([])
|
PanelStyle::Capsules => "Connected groups with rounded ends",
|
||||||
.spacing(8)
|
PanelStyle::Notch => "Attached to the screen edge",
|
||||||
.align_y(Alignment::Center)
|
PanelStyle::Flat => "A continuous edge-to-edge panel",
|
||||||
.push(crate::visuals::action_icon(
|
PanelStyle::Fused => "Groups blend where they meet",
|
||||||
match key {
|
PanelStyle::Adaptive => "Follows the active window state",
|
||||||
"continuous" => "M3 7h18v10H3z",
|
PanelStyle::Morph => "A clock hub with expandable controls",
|
||||||
"none" => "M4 4l16 16 M4 20L20 4",
|
_ => "",
|
||||||
"inset" => "M3 5h18v14H3z M7 9h10v6H7z",
|
};
|
||||||
_ => "M2 7h5v10H2z M10 7h4v10h-4z M17 7h5v10h-5z",
|
self.panel_appearance_row(
|
||||||
},
|
"Style",
|
||||||
palette.muted,
|
description,
|
||||||
))
|
crate::visuals::panel_select(
|
||||||
.push(self.label(label, 12.)),
|
cosmic::iced::widget::pick_list(
|
||||||
|
PanelStyle::AVAILABLE.map(StyleChoice).to_vec(),
|
||||||
|
Some(StyleChoice(selected)),
|
||||||
|
|style| Message::PanelEdit(Action::ApplyPreset(style.0)),
|
||||||
|
)
|
||||||
|
.font(self.font)
|
||||||
|
.text_size(12)
|
||||||
|
.padding([6, 10])
|
||||||
|
.width(Length::Fill),
|
||||||
|
palette,
|
||||||
)
|
)
|
||||||
.name(format!("Background: {label}"))
|
.into(),
|
||||||
.padding([8, 10])
|
)
|
||||||
.width(Length::Fill)
|
|
||||||
.class(crate::visuals::panel_button(palette, selected == key))
|
|
||||||
.on_press(message(key))
|
|
||||||
.into()
|
|
||||||
}))
|
|
||||||
.spacing(2);
|
|
||||||
widget::container(options)
|
|
||||||
.padding(3)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.class(crate::visuals::surface(palette.sidebar, 9.))
|
|
||||||
.into()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn panel_editor(&self, panel: &Panel) -> Element<'static, Message> {
|
fn panel_editor(&self, panel: &Panel) -> Element<'static, Message> {
|
||||||
use cosmic::iced::{Alignment, Length};
|
use cosmic::iced::{Alignment, Length};
|
||||||
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
let palette = crate::visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
let mut zones = widget::column([]).spacing(18);
|
let mut zones = widget::column([]).spacing(18);
|
||||||
if panel.group_count() == ferese_config::panel::MAX_GROUPS {
|
if panel.group_count() == ferese_panel::MAX_GROUPS {
|
||||||
zones = zones.push(self.label(format!("{}-group limit reached", ferese_config::panel::MAX_GROUPS), 12.));
|
zones = zones.push(self.label(format!("{}-group limit reached", ferese_panel::MAX_GROUPS), 12.));
|
||||||
}
|
}
|
||||||
for placement in Zone::ALL {
|
for placement in Zone::ALL {
|
||||||
let zone = placement.definition(panel);
|
let zone = placement.definition(panel);
|
||||||
@@ -592,7 +650,7 @@ impl App {
|
|||||||
self.settings_button(
|
self.settings_button(
|
||||||
"Add group",
|
"Add group",
|
||||||
"M12 5v14 M5 12h14",
|
"M12 5v14 M5 12h14",
|
||||||
(panel.group_count() < ferese_config::panel::MAX_GROUPS)
|
(panel.group_count() < ferese_panel::MAX_GROUPS)
|
||||||
.then_some(Message::PanelEdit(Action::AddGroup(placement))),
|
.then_some(Message::PanelEdit(Action::AddGroup(placement))),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
@@ -787,8 +845,8 @@ mod tests {
|
|||||||
modes: Vec::new(),
|
modes: Vec::new(),
|
||||||
}];
|
}];
|
||||||
app.panel_preview_resolution.items.insert(
|
app.panel_preview_resolution.items.insert(
|
||||||
ferese_config::panel::ItemId("clock".into()),
|
ferese_panel::ItemId("clock".into()),
|
||||||
ferese_config::panel::layout::Placement::Overflow,
|
ferese_panel::layout::Placement::Overflow,
|
||||||
);
|
);
|
||||||
app.panel_preview_resolution.minimum_width = 600.;
|
app.panel_preview_resolution.minimum_width = 600.;
|
||||||
assert_eq!(app.panel_side_margin_limit(), Some(408));
|
assert_eq!(app.panel_side_margin_limit(), Some(408));
|
||||||
@@ -806,8 +864,8 @@ mod tests {
|
|||||||
snapshot.edit(&initialize(&snapshot).unwrap()).unwrap();
|
snapshot.edit(&initialize(&snapshot).unwrap()).unwrap();
|
||||||
let snapshot = store::Snapshot::parse(snapshot.source).unwrap();
|
let snapshot = store::Snapshot::parse(snapshot.source).unwrap();
|
||||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||||
assert_eq!(panels[0].preset(), Some(ferese_config::PanelPreset::Continuous));
|
assert_eq!(panels[0].preset(), Some(ferese_panel::PanelStyle::Flat));
|
||||||
assert_eq!(panels[0].geometry, ferese_config::panel::PanelGeometry::default());
|
assert_eq!(panels[0].resolved_geometry(), ferese_panel::PanelGeometry::default());
|
||||||
assert!(panels[0].center.groups[0].items[0].visible);
|
assert!(panels[0].center.groups[0].items[0].visible);
|
||||||
assert_eq!(snapshot.number("animations.speed", 1.), 0.8);
|
assert_eq!(snapshot.number("animations.speed", 1.), 0.8);
|
||||||
assert!(snapshot.source.contains("// keep"));
|
assert!(snapshot.source.contains("// keep"));
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
//! Local item dragging. Pointer motion changes only the drop hint; release saves once.
|
|
||||||
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
|
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
|
||||||
use cosmic::iced::{Border, Color, Event, Length, Point, Rectangle, Size, Vector, keyboard};
|
use cosmic::iced::{Border, Color, Event, Length, Point, Rectangle, Size, Vector, keyboard};
|
||||||
use cosmic::{Element, Renderer, Theme};
|
use cosmic::{Element, Renderer, Theme};
|
||||||
use ferese_config::panel::{GroupId, ItemId, Panel};
|
use ferese_panel::{GroupId, ItemId, Panel};
|
||||||
|
|
||||||
use crate::Message;
|
use crate::Message;
|
||||||
use crate::panel_edit::{Action, Destination, Zone};
|
use crate::panel_edit::{Action, Destination, Zone};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use ferese_config::panel::{GroupId, Item, ItemId, ItemKind, Panel};
|
use ferese_panel::{GroupId, Item, ItemId, ItemKind, Panel};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::store::{Edit, Snapshot, set};
|
use crate::store::{Edit, Snapshot, set};
|
||||||
@@ -26,14 +26,16 @@ impl std::fmt::Display for Destination {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(super) enum Action {
|
pub(super) enum Action {
|
||||||
ApplyPreset(ferese_config::PanelPreset),
|
ApplyPreset(ferese_panel::PanelStyle),
|
||||||
SetDefaultSurface(ferese_config::panel::GroupSurface),
|
ResetShape,
|
||||||
ClearGroupSurface(GroupId),
|
ClearGeometry(String),
|
||||||
SetEdge(ferese_config::panel::Edge),
|
SetEdge(ferese_panel::Edge),
|
||||||
SetGeometry(String, Value),
|
SetGeometry(String, Value),
|
||||||
SetRadius(String),
|
SetRadius(String),
|
||||||
SetCorner(usize, f32),
|
SetCorner(usize, f32),
|
||||||
SetBorder(bool),
|
SetBorder(bool),
|
||||||
|
SetPeekOnHover(bool),
|
||||||
|
SetMorphAttached(bool),
|
||||||
SetOpacity(f32),
|
SetOpacity(f32),
|
||||||
ClearOpacity,
|
ClearOpacity,
|
||||||
ClearRadius,
|
ClearRadius,
|
||||||
@@ -71,7 +73,7 @@ impl Zone {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn definition(self, panel: &Panel) -> &ferese_config::panel::Zone {
|
pub fn definition(self, panel: &Panel) -> &ferese_panel::Zone {
|
||||||
match self {
|
match self {
|
||||||
Self::Start => &panel.start,
|
Self::Start => &panel.start,
|
||||||
Self::Center => &panel.center,
|
Self::Center => &panel.center,
|
||||||
@@ -168,30 +170,29 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
|||||||
}
|
}
|
||||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").ok_or("Customize items first.")?.clone())
|
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").ok_or("Customize items first.")?.clone())
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
ferese_config::panel::validate(&panels)?;
|
ferese_panel::validate(&panels)?;
|
||||||
let panel = &panels[0];
|
let panel = &panels[0];
|
||||||
let earlier = matches!(&action, Action::Earlier(_));
|
let earlier = matches!(&action, Action::Earlier(_));
|
||||||
let earlier_group = matches!(&action, Action::EarlierGroup(_));
|
let earlier_group = matches!(&action, Action::EarlierGroup(_));
|
||||||
let edits = match action {
|
let edits = match action {
|
||||||
Action::ApplyPreset(preset) => {
|
Action::ApplyPreset(style) => vec![set("panels.0.style", style.key())],
|
||||||
let mut edited = panel.clone();
|
Action::ResetShape => vec![
|
||||||
edited.apply_preset(preset);
|
Edit::Unset("panels.0.geometry".into()),
|
||||||
let mut edits = vec![
|
Edit::Unset("panels.0.corner_radius".into()),
|
||||||
set("panels.0.surface", serde_json::to_value(edited.surface).unwrap()),
|
],
|
||||||
set(
|
Action::ClearGeometry(path) => {
|
||||||
"panels.0.group_surface",
|
if !matches!(
|
||||||
serde_json::to_value(edited.group_surface).unwrap(),
|
path.as_str(),
|
||||||
),
|
"panels.0.geometry.height"
|
||||||
];
|
| "panels.0.geometry.edge_margin"
|
||||||
for group in Zone::ALL.into_iter().flat_map(|zone| &zone.definition(panel).groups) {
|
| "panels.0.geometry.side_margins"
|
||||||
if group.surface.is_some() {
|
| "panels.0.geometry.window_clearance"
|
||||||
edits.push(Edit::Unset(format!("{}.surface", group_record_path(panel, &group.id)?)));
|
| "panels.0.geometry.inner_padding"
|
||||||
}
|
) {
|
||||||
|
return Err("Unknown panel geometry setting.".into());
|
||||||
}
|
}
|
||||||
edits
|
vec![Edit::Unset(path)]
|
||||||
}
|
}
|
||||||
Action::SetDefaultSurface(surface) => vec![set("panels.0.group_surface", surface.key())],
|
|
||||||
Action::ClearGroupSurface(id) => vec![Edit::Unset(format!("{}.surface", group_record_path(panel, &id)?))],
|
|
||||||
Action::SetEdge(edge) => vec![set("panels.0.edge", edge.key())],
|
Action::SetEdge(edge) => vec![set("panels.0.edge", edge.key())],
|
||||||
Action::SetGeometry(field, value) => {
|
Action::SetGeometry(field, value) => {
|
||||||
if !matches!(
|
if !matches!(
|
||||||
@@ -204,6 +205,8 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
|||||||
}
|
}
|
||||||
Action::SetOpacity(value) => vec![set("panels.0.background_opacity", value)],
|
Action::SetOpacity(value) => vec![set("panels.0.background_opacity", value)],
|
||||||
Action::ClearOpacity => vec![Edit::Unset("panels.0.background_opacity".into())],
|
Action::ClearOpacity => vec![Edit::Unset("panels.0.background_opacity".into())],
|
||||||
|
Action::SetMorphAttached(enabled) => vec![set("panels.0.morph.attached", enabled)],
|
||||||
|
Action::SetPeekOnHover(enabled) => vec![set("panels.0.morph.peek_on_hover", enabled)],
|
||||||
Action::SetBorder(enabled) => vec![set("panels.0.border", enabled)],
|
Action::SetBorder(enabled) => vec![set("panels.0.border", enabled)],
|
||||||
Action::SetCorner(index, value) => {
|
Action::SetCorner(index, value) => {
|
||||||
let mut radii = panel.corner_radius.ok_or("Customize corners first.")?;
|
let mut radii = panel.corner_radius.ok_or("Customize corners first.")?;
|
||||||
@@ -212,7 +215,7 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
|||||||
vec![set("panels.0.corner_radius", radii.to_string())]
|
vec![set("panels.0.corner_radius", radii.to_string())]
|
||||||
}
|
}
|
||||||
Action::SetRadius(value) => {
|
Action::SetRadius(value) => {
|
||||||
let radii: ferese_config::panel::CornerRadii = value.parse()?;
|
let radii: ferese_panel::CornerRadii = value.parse()?;
|
||||||
vec![set("panels.0.corner_radius", radii.to_string())]
|
vec![set("panels.0.corner_radius", radii.to_string())]
|
||||||
}
|
}
|
||||||
Action::ClearRadius => vec![Edit::Unset("panels.0.corner_radius".into())],
|
Action::ClearRadius => vec![Edit::Unset("panels.0.corner_radius".into())],
|
||||||
@@ -407,7 +410,7 @@ pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result<Vec<Edit>, Str
|
|||||||
}
|
}
|
||||||
_ => (field, value),
|
_ => (field, value),
|
||||||
};
|
};
|
||||||
if !matches!(field.as_str(), "spacing" | "padding" | "island_padding" | "surface") {
|
if !matches!(field.as_str(), "spacing" | "padding" | "island_padding") {
|
||||||
return Err("Unknown panel group setting.".into());
|
return Err("Unknown panel group setting.".into());
|
||||||
}
|
}
|
||||||
vec![set(&format!("{}.{field}", group_record_path(panel, &id)?), value)]
|
vec![set(&format!("{}.{field}", group_record_path(panel, &id)?), value)]
|
||||||
@@ -434,9 +437,13 @@ mod tests {
|
|||||||
snapshot.edit(&edit).unwrap();
|
snapshot.edit(&edit).unwrap();
|
||||||
}
|
}
|
||||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||||
assert_eq!(panels[0].geometry.height, 28.);
|
assert_eq!(panels[0].resolved_geometry().height, 36.);
|
||||||
assert_eq!(panels[0].geometry.edge_margin, 0);
|
assert_eq!(panels[0].resolved_geometry().edge_margin, 0);
|
||||||
assert_eq!(panels[0].geometry.side_margins, 18);
|
assert_eq!(panels[0].geometry.side_margins, Some(18));
|
||||||
|
assert_eq!(panels[0].resolved_geometry().side_margins, 0);
|
||||||
|
let mut floating = panels[0].clone();
|
||||||
|
floating.apply_preset(ferese_panel::PanelStyle::Floating);
|
||||||
|
assert_eq!(floating.resolved_geometry().side_margins, 18);
|
||||||
assert!(snapshot.source.contains("// keep"));
|
assert!(snapshot.source.contains("// keep"));
|
||||||
assert!(plan(&snapshot, Action::SetGeometry("height".into(), 12.into())).is_err());
|
assert!(plan(&snapshot, Action::SetGeometry("height".into(), 12.into())).is_err());
|
||||||
}
|
}
|
||||||
@@ -444,7 +451,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn group_capacity_is_checked_before_saving_and_removal_reopens_a_slot() {
|
fn group_capacity_is_checked_before_saving_and_removal_reopens_a_slot() {
|
||||||
let mut snapshot = Snapshot::parse("panel main {}".into()).unwrap();
|
let mut snapshot = Snapshot::parse("panel main {}".into()).unwrap();
|
||||||
for _ in 0..ferese_config::panel::MAX_GROUPS {
|
for _ in 0..ferese_panel::MAX_GROUPS {
|
||||||
apply(&mut snapshot, Action::AddGroup(Zone::End));
|
apply(&mut snapshot, Action::AddGroup(Zone::End));
|
||||||
}
|
}
|
||||||
let source = snapshot.source.clone();
|
let source = snapshot.source.clone();
|
||||||
@@ -497,6 +504,42 @@ animations { speed 0.8; }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn morph_attachment_restores_floating_margin_and_preserves_clearance() {
|
||||||
|
let mut snapshot =
|
||||||
|
Snapshot::parse("panel main { style morph; geometry { edge-margin 9; window-clearance 3; }; }".into())
|
||||||
|
.unwrap();
|
||||||
|
let before = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||||
|
apply(&mut snapshot, Action::SetMorphAttached(true));
|
||||||
|
let attached = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||||
|
assert_eq!(attached[0].resolved_geometry().edge_margin, 0);
|
||||||
|
assert_eq!(attached[0].resolved_geometry().window_clearance, 3);
|
||||||
|
assert_eq!(attached[0].exclusive_zone(false), before[0].exclusive_zone(false));
|
||||||
|
assert!(attached[0].shape_parameters().attached);
|
||||||
|
apply(&mut snapshot, Action::SetMorphAttached(false));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap(),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hover_preference_round_trips_without_changing_shape_or_items() {
|
||||||
|
let mut snapshot = Snapshot::parse("panel main { style morph; morph { idle-width 180; }; }".into()).unwrap();
|
||||||
|
let before = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||||
|
apply(&mut snapshot, Action::SetPeekOnHover(false));
|
||||||
|
let after = serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||||
|
assert!(!after[0].morph.peek_on_hover);
|
||||||
|
let mut expected = before.clone();
|
||||||
|
expected[0].morph.peek_on_hover = false;
|
||||||
|
assert_eq!(after, expected);
|
||||||
|
apply(&mut snapshot, Action::SetPeekOnHover(true));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap(),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_group_style_and_opacity_edit_only_the_panel_and_can_restore_inheritance() {
|
fn shared_group_style_and_opacity_edit_only_the_panel_and_can_restore_inheritance() {
|
||||||
let mut snapshot = Snapshot::parse(String::new()).unwrap();
|
let mut snapshot = Snapshot::parse(String::new()).unwrap();
|
||||||
@@ -541,7 +584,7 @@ animations { speed 0.8; }
|
|||||||
apply(&mut snapshot, Action::SetRadius("4px 8px 12px 16px".into()));
|
apply(&mut snapshot, Action::SetRadius("4px 8px 12px 16px".into()));
|
||||||
apply(&mut snapshot, Action::SetCorner(0, 9.));
|
apply(&mut snapshot, Action::SetCorner(0, 9.));
|
||||||
apply(&mut snapshot, Action::SetCorner(3, 21.));
|
apply(&mut snapshot, Action::SetCorner(3, 21.));
|
||||||
let radii: ferese_config::panel::CornerRadii =
|
let radii: ferese_panel::CornerRadii =
|
||||||
serde_json::from_value(snapshot.item("panels.0.corner_radius").unwrap().clone()).unwrap();
|
serde_json::from_value(snapshot.item("panels.0.corner_radius").unwrap().clone()).unwrap();
|
||||||
assert_eq!(radii.0, [9., 8., 12., 21.]);
|
assert_eq!(radii.0, [9., 8., 12., 21.]);
|
||||||
let previous = snapshot.source.clone();
|
let previous = snapshot.source.clone();
|
||||||
@@ -585,11 +628,11 @@ animations { speed 0.8; }
|
|||||||
#[test]
|
#[test]
|
||||||
fn changing_edge_materializes_preferences_and_round_trips_without_moving_items() {
|
fn changing_edge_materializes_preferences_and_round_trips_without_moving_items() {
|
||||||
let mut snapshot = Snapshot::parse("".into()).unwrap();
|
let mut snapshot = Snapshot::parse("".into()).unwrap();
|
||||||
apply(&mut snapshot, Action::SetEdge(ferese_config::panel::Edge::Bottom));
|
apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Bottom));
|
||||||
let before = snapshot.item("panels.0.start").unwrap().clone();
|
let before = snapshot.item("panels.0.start").unwrap().clone();
|
||||||
assert_eq!(snapshot.string("panels.0.edge", ""), "bottom");
|
assert_eq!(snapshot.string("panels.0.edge", ""), "bottom");
|
||||||
assert!(snapshot.boolean("panels.0.end.groups.0.items.6.percentage", false));
|
assert!(snapshot.boolean("panels.0.end.groups.0.items.6.percentage", false));
|
||||||
apply(&mut snapshot, Action::SetEdge(ferese_config::panel::Edge::Top));
|
apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Top));
|
||||||
assert_eq!(snapshot.string("panels.0.edge", ""), "top");
|
assert_eq!(snapshot.string("panels.0.edge", ""), "top");
|
||||||
assert_eq!(snapshot.item("panels.0.start"), Some(&before));
|
assert_eq!(snapshot.item("panels.0.start"), Some(&before));
|
||||||
Snapshot::parse(snapshot.source).unwrap();
|
Snapshot::parse(snapshot.source).unwrap();
|
||||||
@@ -809,7 +852,7 @@ animations { speed 0.8; }
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_zones_can_gain_unique_groups_and_nonempty_groups_cannot_be_removed() {
|
fn empty_zones_can_gain_unique_groups_and_nonempty_groups_cannot_be_removed() {
|
||||||
let mut snapshot = Snapshot::parse("panel \"main\" { surface none; group-surface island; }".into()).unwrap();
|
let mut snapshot = Snapshot::parse("panel \"main\" { style floating; }".into()).unwrap();
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
apply(&mut snapshot, Action::AddGroup(Zone::End));
|
apply(&mut snapshot, Action::AddGroup(Zone::End));
|
||||||
}
|
}
|
||||||
@@ -934,7 +977,7 @@ animations { speed 0.8; }
|
|||||||
&mut snapshot,
|
&mut snapshot,
|
||||||
Action::Set(ItemId("battery".into()), "percentage".into(), false.into()),
|
Action::Set(ItemId("battery".into()), "percentage".into(), false.into()),
|
||||||
);
|
);
|
||||||
for style in ferese_config::panel::WorkspaceStyle::ALL {
|
for style in ferese_panel::WorkspaceStyle::ALL {
|
||||||
apply(
|
apply(
|
||||||
&mut snapshot,
|
&mut snapshot,
|
||||||
Action::Set(ItemId("workspaces".into()), "style".into(), style.key().into()),
|
Action::Set(ItemId("workspaces".into()), "style".into(), style.key().into()),
|
||||||
@@ -961,7 +1004,7 @@ animations { speed 0.8; }
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
panel.item(&ItemId("workspaces".into())).unwrap().kind,
|
panel.item(&ItemId("workspaces".into())).unwrap().kind,
|
||||||
ItemKind::Workspaces {
|
ItemKind::Workspaces {
|
||||||
style: ferese_config::panel::WorkspaceStyle::AppIcons
|
style: ferese_panel::WorkspaceStyle::AppIcons
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -982,38 +1025,43 @@ animations { speed 0.8; }
|
|||||||
assert!(panels[0].item(&ItemId("title".into())).unwrap().visible);
|
assert!(panels[0].item(&ItemId("title".into())).unwrap().visible);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn group_surface_edits_and_presets_preserve_composition_and_comments() {
|
fn resetting_one_geometry_pin_preserves_composition_and_other_pins() {
|
||||||
let mut snapshot = Snapshot::parse("// keep\npanel main { surface none; group-surface island; center { group title surface=none { item title kind=focused-window; }; }; end { group media { item media kind=media; }; }; }".into()).unwrap();
|
let mut snapshot = Snapshot::parse("// keep\npanel main { style floating; geometry { height 32; side-margins 18; }; corner-radius \"8px 9px 10px 11px\"; end { group status { item clock kind=clock; }; }; }".into()).unwrap();
|
||||||
let items = snapshot.item("panels.0.center.groups.0.items").unwrap().clone();
|
let groups = snapshot.item("panels.0.end.groups").unwrap().clone();
|
||||||
let media = GroupId("media".into());
|
|
||||||
apply(
|
apply(
|
||||||
&mut snapshot,
|
&mut snapshot,
|
||||||
Action::SetGroup(media.clone(), "surface".into(), "inset".into()),
|
Action::ClearGeometry("panels.0.geometry.side_margins".into()),
|
||||||
);
|
);
|
||||||
assert_eq!(snapshot.string("panels.0.end.groups.0.surface", ""), "inset");
|
assert_eq!(snapshot.item("panels.0.geometry.side_margins"), None);
|
||||||
apply(&mut snapshot, Action::ClearGroupSurface(media));
|
assert_eq!(snapshot.item("panels.0.geometry.height"), Some(&serde_json::json!(32)));
|
||||||
assert!(snapshot.item("panels.0.end.groups.0.surface").is_none());
|
assert!(snapshot.item("panels.0.corner_radius").is_some());
|
||||||
apply(
|
apply(&mut snapshot, Action::ResetShape);
|
||||||
&mut snapshot,
|
assert_eq!(snapshot.item("panels.0.geometry"), None);
|
||||||
Action::ApplyPreset(ferese_config::PanelPreset::Continuous),
|
assert_eq!(snapshot.item("panels.0.corner_radius"), None);
|
||||||
);
|
assert_eq!(snapshot.item("panels.0.end.groups"), Some(&groups));
|
||||||
assert_eq!(snapshot.string("panels.0.surface", ""), "solid");
|
|
||||||
assert!(snapshot.item("panels.0.center.groups.0.surface").is_none());
|
|
||||||
assert_eq!(snapshot.item("panels.0.center.groups.0.items"), Some(&items));
|
|
||||||
assert!(snapshot.source.contains("// keep"));
|
assert!(snapshot.source.contains("// keep"));
|
||||||
apply(
|
}
|
||||||
&mut snapshot,
|
|
||||||
Action::SetGroup(GroupId("title".into()), "surface".into(), "none".into()),
|
#[test]
|
||||||
);
|
fn panel_presets_preserve_group_layout_items_and_comments() {
|
||||||
apply(&mut snapshot, Action::ApplyPreset(ferese_config::PanelPreset::Islands));
|
let mut snapshot = Snapshot::parse("// keep\npanel main { style floating; center { group title { padding 2 7; item title kind=focused-window; }; }; end { group media { item media kind=media; }; }; }".into()).unwrap();
|
||||||
|
let groups = snapshot.item("panels.0.center.groups").unwrap().clone();
|
||||||
|
apply(&mut snapshot, Action::ApplyPreset(ferese_panel::PanelStyle::Flat));
|
||||||
|
assert_eq!(snapshot.string("panels.0.style", ""), "flat");
|
||||||
|
assert_eq!(snapshot.item("panels.0.center.groups"), Some(&groups));
|
||||||
|
assert!(snapshot.source.contains("// keep"));
|
||||||
|
apply(&mut snapshot, Action::ApplyPreset(ferese_panel::PanelStyle::Floating));
|
||||||
|
assert_eq!(snapshot.item("panels.0.center.groups"), Some(&groups));
|
||||||
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
|
||||||
assert_eq!(panels[0].preset(), Some(ferese_config::PanelPreset::Islands));
|
assert_eq!(panels[0].preset(), Some(ferese_panel::PanelStyle::Floating));
|
||||||
assert!(
|
for surface in ["none", "inset", "island"] {
|
||||||
plan(
|
assert!(
|
||||||
&snapshot,
|
plan(
|
||||||
Action::SetGroup(GroupId("title".into()), "surface".into(), "unsupported".into())
|
&snapshot,
|
||||||
)
|
Action::SetGroup(GroupId("title".into()), "surface".into(), surface.into())
|
||||||
.is_err()
|
)
|
||||||
);
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::panel_edit::{Action, Destination, Zone};
|
use crate::panel_edit::{Action, Destination, Zone};
|
||||||
use crate::{App, Element, Field, Kind, Message, widget};
|
use crate::{App, Element, Field, Kind, Message, widget};
|
||||||
use cosmic::iced::{Alignment, Length};
|
use cosmic::iced::{Alignment, Length};
|
||||||
use ferese_config::panel::{Group, Item, ItemKind, Panel, Representation};
|
use ferese_panel::{Group, Item, ItemKind, Panel, Representation};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
struct Choice(&'static str, &'static str);
|
struct Choice(&'static str, &'static str);
|
||||||
@@ -146,7 +146,7 @@ impl App {
|
|||||||
.spacing(6)
|
.spacing(6)
|
||||||
.push(self.label("Workspace style", 13.))
|
.push(self.label("Workspace style", 13.))
|
||||||
.push(self.note("A sample of active, occupied and empty workspaces."));
|
.push(self.note("A sample of active, occupied and empty workspaces."));
|
||||||
for style in ferese_config::panel::WorkspaceStyle::ALL {
|
for style in ferese_panel::WorkspaceStyle::ALL {
|
||||||
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 22.);
|
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 22.);
|
||||||
choices = choices.push(
|
choices = choices.push(
|
||||||
widget::button::custom(
|
widget::button::custom(
|
||||||
@@ -192,30 +192,6 @@ impl App {
|
|||||||
palette,
|
palette,
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
let surface_choices = [
|
|
||||||
Choice("inherit", "Default"),
|
|
||||||
Choice("none", "None"),
|
|
||||||
Choice("inset", "Inset"),
|
|
||||||
Choice("island", "Island"),
|
|
||||||
];
|
|
||||||
let selected_surface = group.surface.map_or(surface_choices[0], |surface| {
|
|
||||||
*surface_choices.iter().find(|choice| choice.0 == surface.key()).unwrap()
|
|
||||||
});
|
|
||||||
let surface_id = group.id.clone();
|
|
||||||
rows = rows.push(self.label("Surface", 13.)).push(crate::visuals::panel_select(
|
|
||||||
cosmic::iced::widget::pick_list(surface_choices.to_vec(), Some(selected_surface), move |choice| {
|
|
||||||
Message::PanelEdit(if choice.0 == "inherit" {
|
|
||||||
Action::ClearGroupSurface(surface_id.clone())
|
|
||||||
} else {
|
|
||||||
Action::SetGroup(surface_id.clone(), "surface".into(), choice.0.into())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.font(self.font)
|
|
||||||
.text_size(13)
|
|
||||||
.padding([8, 10])
|
|
||||||
.width(Length::Fill),
|
|
||||||
palette,
|
|
||||||
));
|
|
||||||
rows = rows.push(self.panel_divider());
|
rows = rows.push(self.panel_divider());
|
||||||
let mut spacing = widget::column([]).spacing(12);
|
let mut spacing = widget::column([]).spacing(12);
|
||||||
for (name, label, value, maximum) in [
|
for (name, label, value, maximum) in [
|
||||||
@@ -229,7 +205,7 @@ impl App {
|
|||||||
),
|
),
|
||||||
("island_padding", "Island padding", group.island_padding, 32.),
|
("island_padding", "Island padding", group.island_padding, 32.),
|
||||||
] {
|
] {
|
||||||
if panel.resolved_surface(group) != ferese_config::panel::GroupSurface::Island && name == "island_padding" {
|
if panel.group_surface() != ferese_panel::GroupSurface::Island && name == "island_padding" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let change = |value: f32| {
|
let change = |value: f32| {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use cosmic::iced::{Alignment, Length};
|
use cosmic::iced::{Alignment, Length};
|
||||||
use cosmic::widget::{button, column, container, row};
|
use cosmic::widget::{button, column, container, row};
|
||||||
use ferese_config::panel::layout::{Placement, Resolution};
|
use ferese_panel::layout::{Placement, Resolution};
|
||||||
use ferese_config::panel::{Availability, Group, Item, ItemId, ItemKind, Panel, Representation, Zone};
|
use ferese_panel::{Availability, Group, Item, ItemId, ItemKind, Panel, Representation, Zone};
|
||||||
use ferese_theme::panel::{Sample, Spacing, frame};
|
use ferese_theme::panel::{Sample, Spacing, frame};
|
||||||
|
|
||||||
use crate::{App, Element, Message, panel_controls, visuals};
|
use crate::{App, Element, Message, panel_controls, visuals};
|
||||||
@@ -36,12 +36,12 @@ impl App {
|
|||||||
.push(
|
.push(
|
||||||
container(self.panel_preview_frame(panel.clone()))
|
container(self.panel_preview_frame(panel.clone()))
|
||||||
.padding(cosmic::iced::Padding {
|
.padding(cosmic::iced::Padding {
|
||||||
top: if panel.edge == ferese_config::panel::Edge::Top {
|
top: if panel.edge == ferese_panel::Edge::Top {
|
||||||
10.
|
10.
|
||||||
} else {
|
} else {
|
||||||
26.
|
26.
|
||||||
},
|
},
|
||||||
bottom: if panel.edge == ferese_config::panel::Edge::Top {
|
bottom: if panel.edge == ferese_panel::Edge::Top {
|
||||||
26.
|
26.
|
||||||
} else {
|
} else {
|
||||||
10.
|
10.
|
||||||
@@ -98,6 +98,47 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn panel_preview_frame(&self, panel: Panel) -> Element<'_, Message> {
|
fn panel_preview_frame(&self, panel: Panel) -> Element<'_, Message> {
|
||||||
|
if panel.style == ferese_panel::PanelStyle::Morph {
|
||||||
|
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
|
let height = panel.resolved_geometry().height;
|
||||||
|
let hub = container(self.label("● 10:09", 13.))
|
||||||
|
.id("morph-preview-body")
|
||||||
|
.center_x(panel.morph.idle_width as f32)
|
||||||
|
.center_y(height);
|
||||||
|
let hub = ferese_theme::panel_surface::frame(
|
||||||
|
container(hub)
|
||||||
|
.padding([0, if panel.morph.attached { 12 } else { 0 }])
|
||||||
|
.into(),
|
||||||
|
ferese_theme::panel_surface::Options {
|
||||||
|
parameters: panel.shape_parameters(),
|
||||||
|
bounds: Some(cosmic::iced::advanced::widget::Id::new("morph-preview-body")),
|
||||||
|
groups: Vec::new(),
|
||||||
|
scale: self.render_scale,
|
||||||
|
high_contrast: palette.high_contrast,
|
||||||
|
background: cosmic::iced::Color {
|
||||||
|
a: if self.resolved.presented.accessibility.reduce_transparency {
|
||||||
|
1.
|
||||||
|
} else {
|
||||||
|
panel.background_opacity.unwrap_or(palette.card.a)
|
||||||
|
},
|
||||||
|
..palette.card
|
||||||
|
},
|
||||||
|
border: if panel.border {
|
||||||
|
visuals::color(
|
||||||
|
&self.resolved.presented.tokens.colors.border,
|
||||||
|
cosmic::iced::Color::TRANSPARENT,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cosmic::iced::Color::TRANSPARENT
|
||||||
|
},
|
||||||
|
divider: cosmic::iced::Color::TRANSPARENT,
|
||||||
|
native: false,
|
||||||
|
},
|
||||||
|
|_| {},
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
return container(hub).center_x(Length::Fill).height(height).into();
|
||||||
|
}
|
||||||
let mut samples = Vec::new();
|
let mut samples = Vec::new();
|
||||||
for zone in [&panel.center, &panel.start, &panel.end] {
|
for zone in [&panel.center, &panel.start, &panel.end] {
|
||||||
for item in zone
|
for item in zone
|
||||||
@@ -128,24 +169,42 @@ impl App {
|
|||||||
view: self.preview_control(&overflow.items[0], Representation::Icon, None),
|
view: self.preview_control(&overflow.items[0], Representation::Icon, None),
|
||||||
});
|
});
|
||||||
let overflow_width = 2. * f32::from(overflow.padding[1])
|
let overflow_width = 2. * f32::from(overflow.padding[1])
|
||||||
+ if panel.resolved_surface(&overflow) == ferese_config::panel::GroupSurface::Island {
|
+ if panel.group_surface() == ferese_panel::GroupSurface::Island {
|
||||||
2. * overflow.island_padding
|
2. * overflow.island_padding
|
||||||
} else {
|
} else {
|
||||||
0.
|
0.
|
||||||
};
|
};
|
||||||
let continuous = panel.surface == ferese_config::panel::PanelSurface::Solid;
|
let parameters = panel.shape_parameters();
|
||||||
let corners = panel
|
let groups = [
|
||||||
.resolved_radius(self.resolved.presented.tokens.geometry.shell_radius as f32)
|
(ferese_panel::shape::ZonePosition::Start, &panel.start),
|
||||||
.at_edge(panel.edge, panel.geometry.edge_margin == 0)
|
(ferese_panel::shape::ZonePosition::Center, &panel.center),
|
||||||
.0;
|
(ferese_panel::shape::ZonePosition::End, &panel.end),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|(position, zone)| {
|
||||||
|
zone.groups.iter().map(move |group| {
|
||||||
|
(
|
||||||
|
cosmic::iced::advanced::widget::Id::new(format!("preview-group:{}", group.id.0)),
|
||||||
|
position,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.chain(std::iter::once((
|
||||||
|
cosmic::iced::advanced::widget::Id::new("preview-group:_overflow"),
|
||||||
|
ferese_panel::shape::ZonePosition::End,
|
||||||
|
)))
|
||||||
|
.collect();
|
||||||
let opacity = self.resolved.presented.panel_opacity(panel.background_opacity);
|
let opacity = self.resolved.presented.panel_opacity(panel.background_opacity);
|
||||||
|
let height = panel.resolved_geometry().height;
|
||||||
|
let padding = panel.resolved_geometry().inner_padding;
|
||||||
|
let border = panel.border;
|
||||||
let measured_panel = panel.clone();
|
let measured_panel = panel.clone();
|
||||||
let content = frame(
|
let content = frame(
|
||||||
std::borrow::Cow::Owned(measured_panel),
|
std::borrow::Cow::Owned(measured_panel),
|
||||||
samples,
|
samples,
|
||||||
Spacing {
|
Spacing {
|
||||||
overflow_decoration: overflow_width,
|
overflow_decoration: overflow_width,
|
||||||
zone_gap: 32.,
|
zone_gap: 8.,
|
||||||
},
|
},
|
||||||
self.panel_preview_resolution.clone(),
|
self.panel_preview_resolution.clone(),
|
||||||
move |resolution| {
|
move |resolution| {
|
||||||
@@ -172,7 +231,7 @@ impl App {
|
|||||||
let has_end = panel.end.groups.iter().flat_map(|group| &group.items)
|
let has_end = panel.end.groups.iter().flat_map(|group| &group.items)
|
||||||
.any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.));
|
.any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.));
|
||||||
end = row([])
|
end = row([])
|
||||||
.spacing(if has_end { panel.end.spacing } else { 0. })
|
.spacing(if has_end { panel.zone_spacing(&panel.end) } else { 0. })
|
||||||
.align_y(Alignment::Center)
|
.align_y(Alignment::Center)
|
||||||
.push(trigger)
|
.push(trigger)
|
||||||
.push(end)
|
.push(end)
|
||||||
@@ -193,29 +252,44 @@ impl App {
|
|||||||
Message::PanelPreviewResolved,
|
Message::PanelPreviewResolved,
|
||||||
);
|
);
|
||||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
container(content)
|
ferese_theme::panel_surface::frame(
|
||||||
.width(Length::Fill)
|
container(content)
|
||||||
.height(48)
|
.width(Length::Fill)
|
||||||
.clip(true)
|
.height(height)
|
||||||
.class(cosmic::theme::Container::custom(move |_| {
|
.padding([0., padding])
|
||||||
let mut style = ferese_theme::controls::surface_appearance(
|
.clip(true)
|
||||||
cosmic::iced::Color {
|
.into(),
|
||||||
a: opacity,
|
ferese_theme::panel_surface::Options {
|
||||||
..palette.card
|
parameters,
|
||||||
},
|
bounds: None,
|
||||||
0.,
|
groups,
|
||||||
);
|
scale: self.render_scale,
|
||||||
style.border.radius = corners.into();
|
high_contrast: palette.high_contrast,
|
||||||
if !continuous {
|
background: cosmic::iced::Color {
|
||||||
style.background = None;
|
a: opacity,
|
||||||
}
|
..palette.card
|
||||||
style
|
},
|
||||||
}))
|
border: if border {
|
||||||
.into()
|
visuals::color(
|
||||||
|
&self.resolved.presented.tokens.colors.border,
|
||||||
|
cosmic::iced::Color::TRANSPARENT,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cosmic::iced::Color::TRANSPARENT
|
||||||
|
},
|
||||||
|
divider: visuals::color(
|
||||||
|
&self.resolved.presented.tokens.colors.border,
|
||||||
|
cosmic::iced::Color::TRANSPARENT,
|
||||||
|
),
|
||||||
|
native: false,
|
||||||
|
},
|
||||||
|
|_| {},
|
||||||
|
None,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn preview_zone(&self, panel: &Panel, zone: &Zone, resolution: &Resolution) -> Element<'static, Message> {
|
fn preview_zone(&self, panel: &Panel, zone: &Zone, resolution: &Resolution) -> Element<'static, Message> {
|
||||||
let mut groups = row([]).spacing(zone.spacing).align_y(Alignment::Center);
|
let mut groups = row([]).spacing(panel.zone_spacing(zone)).align_y(Alignment::Center);
|
||||||
for group in &zone.groups {
|
for group in &zone.groups {
|
||||||
let mut controls = row([]).align_y(Alignment::Center);
|
let mut controls = row([]).align_y(Alignment::Center);
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
@@ -246,44 +320,22 @@ impl App {
|
|||||||
group: &Group,
|
group: &Group,
|
||||||
controls: Element<'static, Message>,
|
controls: Element<'static, Message>,
|
||||||
) -> Element<'static, Message> {
|
) -> Element<'static, Message> {
|
||||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
let horizontal = f32::from(group.padding[1])
|
||||||
let surface = panel.resolved_surface(group);
|
+ if panel.group_surface() == ferese_panel::GroupSurface::Island {
|
||||||
let radius = panel
|
group.island_padding
|
||||||
.resolved_radius(self.resolved.presented.tokens.geometry.shell_radius as f32)
|
|
||||||
.at_edge(
|
|
||||||
panel.edge,
|
|
||||||
surface == ferese_config::panel::GroupSurface::Island && panel.geometry.edge_margin == 0,
|
|
||||||
)
|
|
||||||
.0;
|
|
||||||
let opacity = self.resolved.presented.panel_opacity(panel.background_opacity);
|
|
||||||
let border = panel.border;
|
|
||||||
let content: Element<'static, Message> = container(controls)
|
|
||||||
.padding(group.padding)
|
|
||||||
.class(cosmic::theme::Container::custom(move |_| {
|
|
||||||
ferese_theme::panel::group_border(palette.muted.scale_alpha(0.4), radius, border)
|
|
||||||
}))
|
|
||||||
.into();
|
|
||||||
if surface == ferese_config::panel::GroupSurface::None {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
let parent_solid = panel.surface == ferese_config::panel::PanelSurface::Solid;
|
|
||||||
container(content)
|
|
||||||
.padding(if surface == ferese_config::panel::GroupSurface::Island {
|
|
||||||
[2., group.island_padding]
|
|
||||||
} else {
|
} else {
|
||||||
[0., 0.]
|
0.
|
||||||
|
};
|
||||||
|
container(controls)
|
||||||
|
.id(format!("preview-group:{}", group.id.0))
|
||||||
|
.padding(cosmic::iced::Padding {
|
||||||
|
top: group.padding[0].into(),
|
||||||
|
bottom: group.padding[0].into(),
|
||||||
|
left: horizontal,
|
||||||
|
right: horizontal,
|
||||||
})
|
})
|
||||||
.class(cosmic::theme::Container::custom(move |_| {
|
.height(panel.resolved_geometry().height)
|
||||||
let mut style = ferese_theme::controls::surface_appearance(
|
.align_y(Alignment::Center)
|
||||||
cosmic::iced::Color {
|
|
||||||
a: if parent_solid { opacity * 0.08 } else { opacity },
|
|
||||||
..if parent_solid { palette.text } else { palette.card }
|
|
||||||
},
|
|
||||||
0.,
|
|
||||||
);
|
|
||||||
style.border.radius = radius.into();
|
|
||||||
style
|
|
||||||
}))
|
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,13 +346,14 @@ impl App {
|
|||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
) -> Element<'static, Message> {
|
) -> Element<'static, Message> {
|
||||||
use ferese_theme::icons;
|
use ferese_theme::icons;
|
||||||
|
let control_height = (self.draft.number("panels.0.geometry.height", 36.) as f32 - 4.).max(20.);
|
||||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
if let ItemKind::Workspaces { style } = item.kind {
|
if let ItemKind::Workspaces { style } = item.kind {
|
||||||
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.);
|
let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.);
|
||||||
let control = button::custom(container(sample).id(format!("preview-icon:{}", item.id.0)))
|
let control = button::custom(container(sample).id(format!("preview-icon:{}", item.id.0)))
|
||||||
.name("Edit Workspaces")
|
.name("Edit Workspaces")
|
||||||
.padding([4, 2])
|
.padding([0, 2])
|
||||||
.height(28)
|
.height(control_height)
|
||||||
.on_press(Message::PanelSelect(item.id.clone()))
|
.on_press(Message::PanelSelect(item.id.clone()))
|
||||||
.class(preview_button(palette, self.panel_selection.as_ref() == Some(&item.id)));
|
.class(preview_button(palette, self.panel_selection.as_ref() == Some(&item.id)));
|
||||||
return container(control)
|
return container(control)
|
||||||
@@ -353,8 +406,8 @@ impl App {
|
|||||||
};
|
};
|
||||||
let control: Element<'static, Message> = button::custom(content)
|
let control: Element<'static, Message> = button::custom(content)
|
||||||
.name(format!("Edit {}", item.id.0))
|
.name(format!("Edit {}", item.id.0))
|
||||||
.padding([4, 7])
|
.padding([0, 7])
|
||||||
.height(28)
|
.height(control_height)
|
||||||
.on_press(action)
|
.on_press(action)
|
||||||
.class(preview_button(
|
.class(preview_button(
|
||||||
palette,
|
palette,
|
||||||
@@ -587,11 +640,8 @@ mod tests {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.0;
|
.0;
|
||||||
for background in [
|
for background in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating] {
|
||||||
ferese_config::PanelPreset::Continuous,
|
let mut panel = Panel::from_preset(ferese_panel::PanelStyle::Floating);
|
||||||
ferese_config::PanelPreset::Islands,
|
|
||||||
] {
|
|
||||||
let mut panel = Panel::from_preset(ferese_config::PanelPreset::Islands);
|
|
||||||
panel.apply_preset(background);
|
panel.apply_preset(background);
|
||||||
// Match a three-group layout with media in navigation and a larger status group.
|
// Match a three-group layout with media in navigation and a larger status group.
|
||||||
let media = panel.end.groups[0].items.remove(0);
|
let media = panel.end.groups[0].items.remove(0);
|
||||||
@@ -648,11 +698,7 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
side.x - center.x - center.width
|
side.x - center.x - center.width
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(gap >= 7.9, "{background:?} at {width}: {} crowds title ({gap}px)", id.0);
|
||||||
gap >= 31.9,
|
|
||||||
"{background:?} at {width}: {} crowds title ({gap}px)",
|
|
||||||
id.0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,10 +726,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.0;
|
.0;
|
||||||
for background in [
|
for background in [ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating] {
|
||||||
ferese_config::PanelPreset::Continuous,
|
|
||||||
ferese_config::PanelPreset::Islands,
|
|
||||||
] {
|
|
||||||
let panel = Panel::from_preset(background);
|
let panel = Panel::from_preset(background);
|
||||||
let ids: Vec<_> = [&panel.start, &panel.center, &panel.end]
|
let ids: Vec<_> = [&panel.start, &panel.center, &panel.end]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ pub fn fields(page: Page) -> Vec<Field> {
|
|||||||
"Height",
|
"Height",
|
||||||
"Keeps icon and text sizes unchanged.",
|
"Keeps icon and text sizes unchanged.",
|
||||||
RangeSpec {
|
RangeSpec {
|
||||||
default: 28.0,
|
default: 36.0,
|
||||||
min: 24.0,
|
min: 24.0,
|
||||||
max: 48.0,
|
max: 48.0,
|
||||||
step: 1.0,
|
step: 1.0,
|
||||||
|
|||||||
@@ -348,7 +348,6 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
|||||||
| "Shown on all displays"
|
| "Shown on all displays"
|
||||||
| "Position"
|
| "Position"
|
||||||
| "Background"
|
| "Background"
|
||||||
| "Default group surface"
|
|
||||||
| "Background opacity"
|
| "Background opacity"
|
||||||
| "Group borders"
|
| "Group borders"
|
||||||
| "Corner radius"
|
| "Corner radius"
|
||||||
@@ -363,6 +362,8 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
|||||||
| "Add item"
|
| "Add item"
|
||||||
| "Overview"
|
| "Overview"
|
||||||
| "Workspaces"
|
| "Workspaces"
|
||||||
|
| "Morph"
|
||||||
|
| "Collapsed height"
|
||||||
| "Battery"
|
| "Battery"
|
||||||
| "Placement"
|
| "Placement"
|
||||||
| "Show item"
|
| "Show item"
|
||||||
@@ -409,8 +410,8 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
|||||||
))
|
))
|
||||||
.expect("GPU panel rendering requires a hardware or software WGPU adapter");
|
.expect("GPU panel rendering requires a hardware or software WGPU adapter");
|
||||||
for width in [740., 1100.] {
|
for width in [740., 1100.] {
|
||||||
for scale in [1., 1.75] {
|
for scale in [1., 1.25, 1.5, 1.75] {
|
||||||
for selection in ["panels", "items", "battery", "status"] {
|
for selection in ["panels", "morph", "items", "battery", "status"] {
|
||||||
let mut app = App::init(
|
let mut app = App::init(
|
||||||
Core::default(),
|
Core::default(),
|
||||||
(
|
(
|
||||||
@@ -421,14 +422,24 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
|
|||||||
)
|
)
|
||||||
.0;
|
.0;
|
||||||
app.font = font;
|
app.font = font;
|
||||||
app.panel_page = if selection == "panels" {
|
app.render_scale = f64::from(scale);
|
||||||
|
if selection == "morph" {
|
||||||
|
for edit in crate::panel_edit::plan(
|
||||||
|
&app.draft,
|
||||||
|
crate::panel_edit::Action::ApplyPreset(ferese_panel::PanelStyle::Morph),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
app.draft.edit(&edit).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.panel_page = if matches!(selection, "panels" | "morph") {
|
||||||
panel_controls::PanelPage::Panels
|
panel_controls::PanelPage::Panels
|
||||||
} else {
|
} else {
|
||||||
panel_controls::PanelPage::Arrange
|
panel_controls::PanelPage::Arrange
|
||||||
};
|
};
|
||||||
app.panel_selection = (selection == "battery").then(|| ferese_config::panel::ItemId("battery".into()));
|
app.panel_selection = (selection == "battery").then(|| ferese_panel::ItemId("battery".into()));
|
||||||
app.panel_group_selection =
|
app.panel_group_selection = (selection == "status").then(|| ferese_panel::GroupId("status".into()));
|
||||||
(selection == "status").then(|| ferese_config::panel::GroupId("status".into()));
|
|
||||||
let bounds = Rectangle::with_size(Size::new(width, 650.));
|
let bounds = Rectangle::with_size(Size::new(width, 650.));
|
||||||
let physical = Size::new((width * scale) as u32, (650. * scale) as u32);
|
let physical = Size::new((width * scale) as u32, (650. * scale) as u32);
|
||||||
let theme = app.native_palette.native_theme();
|
let theme = app.native_palette.native_theme();
|
||||||
@@ -565,14 +576,15 @@ fn render_panel_review_states() {
|
|||||||
app.native_palette = visuals::Palette::from_resolved(&app.resolved.presented);
|
app.native_palette = visuals::Palette::from_resolved(&app.resolved.presented);
|
||||||
for (name, tab, item, group) in [
|
for (name, tab, item, group) in [
|
||||||
("panels", panel_controls::PanelPage::Panels, None, None),
|
("panels", panel_controls::PanelPage::Panels, None, None),
|
||||||
("mixed-surfaces", panel_controls::PanelPage::Panels, None, None),
|
("floating-groups", panel_controls::PanelPage::Panels, None, None),
|
||||||
(
|
(
|
||||||
"surface-inspector",
|
"group-spacing-inspector",
|
||||||
panel_controls::PanelPage::Arrange,
|
panel_controls::PanelPage::Arrange,
|
||||||
None,
|
None,
|
||||||
Some("title"),
|
Some("title"),
|
||||||
),
|
),
|
||||||
("corners", panel_controls::PanelPage::Panels, None, None),
|
("corners", panel_controls::PanelPage::Panels, None, None),
|
||||||
|
("morph", panel_controls::PanelPage::Panels, None, None),
|
||||||
("preview-hover", panel_controls::PanelPage::Panels, None, None),
|
("preview-hover", panel_controls::PanelPage::Panels, None, None),
|
||||||
("navigation", panel_controls::PanelPage::Arrange, None, None),
|
("navigation", panel_controls::PanelPage::Arrange, None, None),
|
||||||
("shortcuts", panel_controls::PanelPage::Panels, None, None),
|
("shortcuts", panel_controls::PanelPage::Panels, None, None),
|
||||||
@@ -603,15 +615,23 @@ fn render_panel_review_states() {
|
|||||||
_ => Page::Bar,
|
_ => Page::Bar,
|
||||||
};
|
};
|
||||||
app.draft = Snapshot::parse(String::new()).unwrap();
|
app.draft = Snapshot::parse(String::new()).unwrap();
|
||||||
if matches!(name, "mixed-surfaces" | "surface-inspector") {
|
if matches!(name, "floating-groups" | "group-spacing-inspector") {
|
||||||
let mut panel = ferese_config::panel::Panel::from_preset(ferese_config::PanelPreset::Islands);
|
let mut panel = ferese_panel::Panel::from_preset(ferese_panel::PanelStyle::Floating);
|
||||||
panel.center.groups[0].surface = Some(ferese_config::panel::GroupSurface::None);
|
panel.geometry.edge_margin = Some(6);
|
||||||
panel.end.groups[0].surface = Some(ferese_config::panel::GroupSurface::Inset);
|
|
||||||
panel.geometry.edge_margin = 6;
|
|
||||||
app.draft
|
app.draft
|
||||||
.edit(&crate::store::set("panels", serde_json::to_value(vec![panel]).unwrap()))
|
.edit(&crate::store::set("panels", serde_json::to_value(vec![panel]).unwrap()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
if name == "morph" {
|
||||||
|
for edit in crate::panel_edit::plan(
|
||||||
|
&app.draft,
|
||||||
|
crate::panel_edit::Action::ApplyPreset(ferese_panel::PanelStyle::Morph),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
app.draft.edit(&edit).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
if name == "corners" {
|
if name == "corners" {
|
||||||
for edit in crate::panel_edit::plan(
|
for edit in crate::panel_edit::plan(
|
||||||
&app.draft,
|
&app.draft,
|
||||||
@@ -625,8 +645,8 @@ fn render_panel_review_states() {
|
|||||||
}
|
}
|
||||||
app.panel_page = tab;
|
app.panel_page = tab;
|
||||||
app.sidebar_open = name == "navigation";
|
app.sidebar_open = name == "navigation";
|
||||||
app.panel_selection = item.map(|id| ferese_config::panel::ItemId(id.into()));
|
app.panel_selection = item.map(|id| ferese_panel::ItemId(id.into()));
|
||||||
app.panel_group_selection = group.map(|id| ferese_config::panel::GroupId(id.into()));
|
app.panel_group_selection = group.map(|id| ferese_panel::GroupId(id.into()));
|
||||||
for (width, height) in [(1100, 860), (740, 650)] {
|
for (width, height) in [(1100, 860), (740, 650)] {
|
||||||
let bounds = Rectangle::with_size(Size::new(width as f32, height as f32));
|
let bounds = Rectangle::with_size(Size::new(width as f32, height as f32));
|
||||||
let theme = app.native_palette.native_theme();
|
let theme = app.native_palette.native_theme();
|
||||||
@@ -763,10 +783,8 @@ fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
|
|||||||
let mut initial = None;
|
let mut initial = None;
|
||||||
for selection in [
|
for selection in [
|
||||||
None,
|
None,
|
||||||
Some(Message::PanelSelect(ferese_config::panel::ItemId("battery".into()))),
|
Some(Message::PanelSelect(ferese_panel::ItemId("battery".into()))),
|
||||||
Some(Message::PanelGroupSelect(ferese_config::panel::GroupId(
|
Some(Message::PanelGroupSelect(ferese_panel::GroupId("status".into()))),
|
||||||
"status".into(),
|
|
||||||
))),
|
|
||||||
Some(Message::PanelClearSelection),
|
Some(Message::PanelClearSelection),
|
||||||
] {
|
] {
|
||||||
if let Some(selection) = selection {
|
if let Some(selection) = selection {
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ impl Snapshot {
|
|||||||
}
|
}
|
||||||
let doc = Document::parse(&source).map_err(|e| e.to_string())?;
|
let doc = Document::parse(&source).map_err(|e| e.to_string())?;
|
||||||
if let Some(value) = doc.get("panels") {
|
if let Some(value) = doc.get("panels") {
|
||||||
let panels: Vec<ferese_config::panel::Panel> =
|
let panels: Vec<ferese_panel::Panel> =
|
||||||
serde_json::from_value(value.clone()).map_err(|error| error.to_string())?;
|
serde_json::from_value(value.clone()).map_err(|error| error.to_string())?;
|
||||||
ferese_config::panel::validate(&panels)?;
|
ferese_panel::validate(&panels)?;
|
||||||
}
|
}
|
||||||
Ok(Self { source, doc })
|
Ok(Self { source, doc })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Watch the parent directory so atomic editor saves keep working.
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ license.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ferese-layout = { path = "../../crates/ferese-layout" }
|
||||||
|
ferese-panel = { path = "../../crates/ferese-panel" }
|
||||||
ferese-animation = { path = "../../crates/ferese-animation" }
|
ferese-animation = { path = "../../crates/ferese-animation" }
|
||||||
ferese-theme-client = { path = "../../crates/ferese-theme-client" }
|
ferese-theme-client = { path = "../../crates/ferese-theme-client" }
|
||||||
ferese-ipc = { path = "../../crates/ferese-ipc" }
|
ferese-ipc = { path = "../../crates/ferese-ipc" }
|
||||||
|
|||||||
+178
-180
@@ -1,11 +1,13 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
use super::Background;
|
||||||
use super::{
|
use super::{
|
||||||
Background, BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment,
|
BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment, bar_icon, button,
|
||||||
bar_icon, button, color, container, control, motion, row, status_ui, text, theme, window,
|
color, container, control, motion, row, status_ui, theme, window,
|
||||||
};
|
};
|
||||||
use crate::panel::{Availability, Item, ItemKind, Panel, Zone};
|
use crate::panel::{Availability, Item, ItemKind, Panel, Zone};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use ferese_config::PanelPreset;
|
use ferese_panel::PanelStyle;
|
||||||
use ferese_config::panel::{GroupSurface, PanelSurface};
|
use ferese_panel::{GroupSurface, PanelSurface};
|
||||||
|
|
||||||
use ferese_theme::panel as adaptive;
|
use ferese_theme::panel as adaptive;
|
||||||
pub(super) mod presentation;
|
pub(super) mod presentation;
|
||||||
@@ -37,16 +39,17 @@ impl FereseShell {
|
|||||||
|
|
||||||
pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
|
||||||
if self.bar_hidden(id) {
|
if self.bar_hidden(id) {
|
||||||
return container(text("")).width(Length::Fill).height(Length::Fill).into();
|
return container(self.render.text(""))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
let panel = self.panel_for_bar(id);
|
||||||
|
if panel.style == ferese_panel::PanelStyle::Morph {
|
||||||
|
return self.view_hub(id);
|
||||||
}
|
}
|
||||||
let panel = &self.config.panels[0];
|
|
||||||
let mut shell_theme = self.config.theme.for_bar();
|
let mut shell_theme = self.config.theme.for_bar();
|
||||||
shell_theme.bar_background[3] = (shell_theme.panel_opacity(panel.background_opacity) * 255.).round() as u8;
|
shell_theme.bar_background[3] = (shell_theme.panel_opacity(panel.background_opacity) * 255.).round() as u8;
|
||||||
let corners = panel
|
|
||||||
.resolved_radius(shell_theme.bar_radius)
|
|
||||||
.at_edge(panel.edge, panel.geometry.edge_margin == 0);
|
|
||||||
let mode = panel.surface;
|
|
||||||
let islands = mode == PanelSurface::None;
|
|
||||||
let output = self.outputs.iter().find(|output| output.bar == id);
|
let output = self.outputs.iter().find(|output| output.bar == id);
|
||||||
let effects = output.and_then(|output| output.effects.as_ref());
|
let effects = output.and_then(|output| output.effects.as_ref());
|
||||||
let compositor_material = effects.is_some();
|
let compositor_material = effects.is_some();
|
||||||
@@ -74,7 +77,7 @@ impl FereseShell {
|
|||||||
id,
|
id,
|
||||||
item,
|
item,
|
||||||
None,
|
None,
|
||||||
panel.resolved_surface(group) == GroupSurface::Island,
|
panel.group_surface() == GroupSurface::Island,
|
||||||
representation,
|
representation,
|
||||||
)
|
)
|
||||||
.0;
|
.0;
|
||||||
@@ -103,25 +106,34 @@ impl FereseShell {
|
|||||||
id,
|
id,
|
||||||
&overflow_group.items[0],
|
&overflow_group.items[0],
|
||||||
None,
|
None,
|
||||||
panel.resolved_surface(&overflow_group) == GroupSurface::Island,
|
panel.group_surface() == GroupSurface::Island,
|
||||||
Representation::Icon,
|
Representation::Icon,
|
||||||
)
|
)
|
||||||
.0,
|
.0,
|
||||||
});
|
});
|
||||||
// Measure the control itself; reserve group decoration separately.
|
// Measure the control itself; reserve group decoration separately.
|
||||||
let overflow_width = 2.0 * f32::from(overflow_group.padding[1])
|
let overflow_width = 2.0 * f32::from(overflow_group.padding[1])
|
||||||
+ if panel.resolved_surface(&overflow_group) == GroupSurface::Island {
|
+ if panel.group_surface() == GroupSurface::Island {
|
||||||
2.0 * overflow_group.island_padding
|
2.0 * overflow_group.island_padding
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
let materials = [&panel.start, &panel.center, &panel.end]
|
let groups = [
|
||||||
.into_iter()
|
(ferese_panel::shape::ZonePosition::Start, &panel.start),
|
||||||
.flat_map(|zone| &zone.groups)
|
(ferese_panel::shape::ZonePosition::Center, &panel.center),
|
||||||
.chain(std::iter::once(&overflow_group))
|
(ferese_panel::shape::ZonePosition::End, &panel.end),
|
||||||
.filter(|group| islands && panel.resolved_surface(group) != GroupSurface::None)
|
]
|
||||||
.map(|group| (group_material_id(panel, group), 1.))
|
.into_iter()
|
||||||
.collect();
|
.flat_map(|(position, zone)| {
|
||||||
|
zone.groups
|
||||||
|
.iter()
|
||||||
|
.map(move |group| (group_material_id(panel, group), position))
|
||||||
|
})
|
||||||
|
.chain(std::iter::once((
|
||||||
|
group_material_id(panel, &overflow_group),
|
||||||
|
ferese_panel::shape::ZonePosition::End,
|
||||||
|
)))
|
||||||
|
.collect();
|
||||||
let content = adaptive::frame(
|
let content = adaptive::frame(
|
||||||
std::borrow::Cow::Borrowed(panel),
|
std::borrow::Cow::Borrowed(panel),
|
||||||
samples,
|
samples,
|
||||||
@@ -183,7 +195,7 @@ impl FereseShell {
|
|||||||
.any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0));
|
.any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0));
|
||||||
if end_has_items {
|
if end_has_items {
|
||||||
row![trigger, end]
|
row![trigger, end]
|
||||||
.spacing(panel.end.spacing)
|
.spacing(panel.zone_spacing(&panel.end))
|
||||||
.align_y(cosmic::iced::Alignment::Center)
|
.align_y(cosmic::iced::Alignment::Center)
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
@@ -204,18 +216,46 @@ impl FereseShell {
|
|||||||
let content = container(content)
|
let content = container(content)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.padding([0, panel.geometry.inner_padding.round() as u16])
|
.padding([0, panel.resolved_geometry().inner_padding.round() as u16])
|
||||||
.class(theme::Container::custom(move |_| {
|
|
||||||
let mut style = bar_style(shell_theme, compositor_material, islands);
|
|
||||||
style.border.radius = corners.0.into();
|
|
||||||
style
|
|
||||||
}))
|
|
||||||
.into();
|
.into();
|
||||||
presentation::frame(
|
let content = ferese_theme::panel_surface::frame(
|
||||||
|
content,
|
||||||
|
ferese_theme::panel_surface::Options {
|
||||||
|
parameters: panel.shape_parameters(),
|
||||||
|
bounds: None,
|
||||||
|
groups,
|
||||||
|
scale: output.map_or(1., |entry| entry.scale),
|
||||||
|
high_contrast: shell_theme.high_contrast,
|
||||||
|
background: color(shell_theme.bar_background),
|
||||||
|
border: if panel.border {
|
||||||
|
color(shell_theme.border)
|
||||||
|
} else {
|
||||||
|
Color::TRANSPARENT
|
||||||
|
},
|
||||||
|
divider: color(shell_theme.border),
|
||||||
|
native: compositor_material,
|
||||||
|
},
|
||||||
|
move |scene| {
|
||||||
|
if let Some(effects) = effects
|
||||||
|
&& let Err(error) = effects.set_panel_presentation(scene)
|
||||||
|
{
|
||||||
|
eprintln!("ferese-shell: could not update panel material: {error}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(Box::new(move |scene| {
|
||||||
|
cosmic::Action::App(Message::PanelSurfaceChanged(id, Box::new(scene.clone())))
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
self.with_workspace_hover(id, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn with_workspace_hover<'a>(
|
||||||
|
&'a self,
|
||||||
|
id: window::Id,
|
||||||
|
content: Element<'a, cosmic::Action<Message>>,
|
||||||
|
) -> Element<'a, cosmic::Action<Message>> {
|
||||||
|
presentation::hover(
|
||||||
content,
|
content,
|
||||||
mode,
|
|
||||||
corners.max(),
|
|
||||||
materials,
|
|
||||||
self.workspace_ui
|
self.workspace_ui
|
||||||
.hover_target(id)
|
.hover_target(id)
|
||||||
.map(|(workspace, target)| presentation::HoverTarget {
|
.map(|(workspace, target)| presentation::HoverTarget {
|
||||||
@@ -224,19 +264,6 @@ impl FereseShell {
|
|||||||
cosmic::Action::App(Message::HoverWorkspace(id, workspace, target.clone(), false))
|
cosmic::Action::App(Message::HoverWorkspace(id, workspace, target.clone(), false))
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
move |regions, opacities| {
|
|
||||||
if let Some(effects) = effects
|
|
||||||
&& let Err(error) = effects.set_presentation(
|
|
||||||
regions,
|
|
||||||
1.,
|
|
||||||
opacities.iter().copied(),
|
|
||||||
super::ferese_surface_effects_v1::Role::Panel,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
eprintln!("ferese-shell: could not update bar material: {error}");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
move |regions| cosmic::Action::App(Message::BarRegionsChanged(id, regions)),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +276,7 @@ impl FereseShell {
|
|||||||
representation: Representation,
|
representation: Representation,
|
||||||
) -> (Element<'_, cosmic::Action<Message>>, bool) {
|
) -> (Element<'_, cosmic::Action<Message>>, bool) {
|
||||||
let shell_theme = self.config.theme.for_bar();
|
let shell_theme = self.config.theme.for_bar();
|
||||||
let bar = BarMetrics::from(self.config.panels[0].geometry);
|
let bar = BarMetrics::from(self.panel_for_bar(id).resolved_geometry());
|
||||||
let control_height = if islands {
|
let control_height = if islands {
|
||||||
bar.group_item_height
|
bar.group_item_height
|
||||||
} else {
|
} else {
|
||||||
@@ -264,6 +291,7 @@ impl FereseShell {
|
|||||||
});
|
});
|
||||||
let element = match item.kind {
|
let element = match item.kind {
|
||||||
ItemKind::Overview => motion::button(
|
ItemKind::Overview => motion::button(
|
||||||
|
self.render,
|
||||||
button::custom(overview_control(
|
button::custom(overview_control(
|
||||||
BarMetrics { control_height, ..bar },
|
BarMetrics { control_height, ..bar },
|
||||||
color(shell_theme.accent),
|
color(shell_theme.accent),
|
||||||
@@ -276,7 +304,7 @@ impl FereseShell {
|
|||||||
1.0,
|
1.0,
|
||||||
),
|
),
|
||||||
ItemKind::Workspaces { style } => {
|
ItemKind::Workspaces { style } => {
|
||||||
let workspaces = self.view_workspace_item(id, style, &item.id.0);
|
let workspaces = self.view_workspace_item(id, style, &item.id.0, 1.);
|
||||||
if let Some(width) = width {
|
if let Some(width) = width {
|
||||||
cosmic::iced::widget::scrollable(workspaces)
|
cosmic::iced::widget::scrollable(workspaces)
|
||||||
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
|
||||||
@@ -296,7 +324,9 @@ impl FereseShell {
|
|||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
let element: Element<'_, cosmic::Action<Message>> = text(title)
|
let element: Element<'_, cosmic::Action<Message>> = self
|
||||||
|
.render
|
||||||
|
.text(title)
|
||||||
.size(bar.text_size)
|
.size(bar.text_size)
|
||||||
.width(width.map_or(Length::Shrink, Length::Fixed))
|
.width(width.map_or(Length::Shrink, Length::Fixed))
|
||||||
.height(control_height)
|
.height(control_height)
|
||||||
@@ -332,19 +362,22 @@ impl FereseShell {
|
|||||||
.align_y(cosmic::iced::Alignment::Center);
|
.align_y(cosmic::iced::Alignment::Center);
|
||||||
if representation == Representation::Wide && !date.is_empty() {
|
if representation == Representation::Wide && !date.is_empty() {
|
||||||
content = content.push(
|
content = content.push(
|
||||||
text(date)
|
self.render
|
||||||
|
.text(date)
|
||||||
.size(12)
|
.size(12)
|
||||||
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
||||||
.class(theme::Text::Color(foreground)),
|
.class(theme::Text::Color(foreground)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let content = content.push(
|
let content = content.push(
|
||||||
text(time)
|
self.render
|
||||||
|
.text(time)
|
||||||
.size(bar.text_size)
|
.size(bar.text_size)
|
||||||
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
||||||
.class(theme::Text::Color(foreground)),
|
.class(theme::Text::Color(foreground)),
|
||||||
);
|
);
|
||||||
motion::button(
|
motion::button(
|
||||||
|
self.render,
|
||||||
button::custom(container(content).center_y(bar.group_item_height))
|
button::custom(container(content).center_y(bar.group_item_height))
|
||||||
.padding([0, 4])
|
.padding([0, 4])
|
||||||
.height(bar.group_item_height)
|
.height(bar.group_item_height)
|
||||||
@@ -366,6 +399,7 @@ impl FereseShell {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
ItemKind::DisplayMode => motion::button(
|
ItemKind::DisplayMode => motion::button(
|
||||||
|
self.render,
|
||||||
button::custom(bar_content(
|
button::custom(bar_content(
|
||||||
bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground),
|
bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground),
|
||||||
control_height,
|
control_height,
|
||||||
@@ -414,7 +448,7 @@ impl FereseShell {
|
|||||||
let Some(output) = self.outputs.iter().find(|output| output.bar == menu.anchor.parent) else {
|
let Some(output) = self.outputs.iter().find(|output| output.bar == menu.anchor.parent) else {
|
||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
let panel = &self.config.panels[0];
|
let panel = self.panel_for_bar(output.bar);
|
||||||
let Some(resolution) = &output.panel_resolution else {
|
let Some(resolution) = &output.panel_resolution else {
|
||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
@@ -445,20 +479,25 @@ impl FereseShell {
|
|||||||
) => cosmic::Action::App(Message::PanelAction(Box::new(message))),
|
) => cosmic::Action::App(Message::PanelAction(Box::new(message))),
|
||||||
other => other,
|
other => other,
|
||||||
});
|
});
|
||||||
rows = rows.push(cosmic::widget::column![text(item.kind.label()).size(12), element].spacing(4));
|
rows = rows.push(cosmic::widget::column![self.render.text(item.kind.label()).size(12), element].spacing(4));
|
||||||
}
|
}
|
||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_workspace_item(
|
pub(super) fn view_workspace_item(
|
||||||
&self,
|
&self,
|
||||||
id: window::Id,
|
id: window::Id,
|
||||||
style: ferese_config::panel::WorkspaceStyle,
|
style: ferese_panel::WorkspaceStyle,
|
||||||
instance: &str,
|
instance: &str,
|
||||||
|
opacity: f32,
|
||||||
) -> Element<'_, cosmic::Action<Message>> {
|
) -> Element<'_, cosmic::Action<Message>> {
|
||||||
let focused_output = self.output_for_bar(id);
|
let focused_output = self.output_for_bar(id);
|
||||||
let shell_theme = self.config.theme.for_bar();
|
let shell_theme = self.config.theme.for_bar();
|
||||||
let bar = BarMetrics::from(self.config.panels[0].geometry);
|
let mut palette = shell_theme.palette();
|
||||||
|
palette.text.a *= opacity;
|
||||||
|
palette.muted.a *= opacity;
|
||||||
|
palette.accent.a *= opacity;
|
||||||
|
let bar = BarMetrics::from(self.panel_for_bar(id).resolved_geometry());
|
||||||
let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len())
|
let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len())
|
||||||
.spacing(1)
|
.spacing(1)
|
||||||
.align_y(cosmic::iced::Alignment::Center);
|
.align_y(cosmic::iced::Alignment::Center);
|
||||||
@@ -471,7 +510,7 @@ impl FereseShell {
|
|||||||
.output
|
.output
|
||||||
.and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id));
|
.and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id));
|
||||||
let transition = self.workspace_ui.transition(workspace.id, active);
|
let transition = self.workspace_ui.transition(workspace.id, active);
|
||||||
let apps = if style == ferese_config::panel::WorkspaceStyle::AppIcons {
|
let apps = if style == ferese_panel::WorkspaceStyle::AppIcons {
|
||||||
self.workspace_ui.apps(workspace.id, &self.snapshot)
|
self.workspace_ui.apps(workspace.id, &self.snapshot)
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -490,12 +529,17 @@ impl FereseShell {
|
|||||||
ferese_theme::workspaces::indicator(
|
ferese_theme::workspaces::indicator(
|
||||||
style,
|
style,
|
||||||
details.clone(),
|
details.clone(),
|
||||||
shell_theme.palette(),
|
palette,
|
||||||
super::shell_font(),
|
self.render.font,
|
||||||
bar.group_item_height,
|
bar.group_item_height,
|
||||||
transition.progress(now),
|
transition.progress(now),
|
||||||
apps.iter()
|
apps.iter()
|
||||||
.map(|handle| cosmic::widget::icon::icon(handle.clone()).size(16).into())
|
.map(|handle| {
|
||||||
|
cosmic::widget::icon::icon(handle.clone())
|
||||||
|
.size(16)
|
||||||
|
.opacity(opacity)
|
||||||
|
.into()
|
||||||
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -514,6 +558,7 @@ impl FereseShell {
|
|||||||
.on_press(cosmic::Action::App(Message::ActivateWorkspace(workspace.id)));
|
.on_press(cosmic::Action::App(Message::ActivateWorkspace(workspace.id)));
|
||||||
|
|
||||||
let selector = motion::button(
|
let selector = motion::button(
|
||||||
|
self.render,
|
||||||
workspace_button,
|
workspace_button,
|
||||||
color(if active {
|
color(if active {
|
||||||
shell_theme.accent
|
shell_theme.accent
|
||||||
@@ -521,7 +566,7 @@ impl FereseShell {
|
|||||||
shell_theme.text_primary
|
shell_theme.text_primary
|
||||||
}),
|
}),
|
||||||
false,
|
false,
|
||||||
1.0,
|
opacity,
|
||||||
);
|
);
|
||||||
let target =
|
let target =
|
||||||
cosmic::iced::advanced::widget::Id::new(format!("workspace:{id:?}:{instance}:{}", workspace.id));
|
cosmic::iced::advanced::widget::Id::new(format!("workspace:{id:?}:{instance}:{}", workspace.id));
|
||||||
@@ -562,20 +607,19 @@ fn view_zone<'a>(
|
|||||||
panel: &Panel,
|
panel: &Panel,
|
||||||
zone: &Zone,
|
zone: &Zone,
|
||||||
availability: Availability,
|
availability: Availability,
|
||||||
shell_theme: ShellTheme,
|
_shell_theme: ShellTheme,
|
||||||
compositor_material: bool,
|
_compositor_material: bool,
|
||||||
visible: impl Fn(&Item) -> bool,
|
visible: impl Fn(&Item) -> bool,
|
||||||
render: impl Fn(&Item, GroupSurface) -> (Element<'a, cosmic::Action<Message>>, bool),
|
render: impl Fn(&Item, GroupSurface) -> (Element<'a, cosmic::Action<Message>>, bool),
|
||||||
) -> Element<'a, cosmic::Action<Message>> {
|
) -> Element<'a, cosmic::Action<Message>> {
|
||||||
let bar = BarMetrics::from(panel.geometry);
|
let bar = BarMetrics::from(panel.resolved_geometry());
|
||||||
let mut groups = row::with_capacity(zone.groups.len())
|
let mut groups = row::with_capacity(zone.groups.len())
|
||||||
.spacing(zone.spacing)
|
.spacing(panel.zone_spacing(zone))
|
||||||
.align_y(cosmic::iced::Alignment::Center);
|
.align_y(cosmic::iced::Alignment::Center);
|
||||||
for group in &zone.groups {
|
for group in &zone.groups {
|
||||||
let surface = panel.resolved_surface(group);
|
let surface = panel.group_surface();
|
||||||
let mut controls = row::with_capacity(group.items.len()).align_y(cosmic::iced::Alignment::Center);
|
let mut controls = row::with_capacity(group.items.len()).align_y(cosmic::iced::Alignment::Center);
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
let mut has_content = false;
|
|
||||||
for item in group
|
for item in group
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -585,8 +629,7 @@ fn view_zone<'a>(
|
|||||||
controls =
|
controls =
|
||||||
controls.push(cosmic::iced::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing)));
|
controls.push(cosmic::iced::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing)));
|
||||||
}
|
}
|
||||||
let (element, content) = render(item, surface);
|
let (element, _) = render(item, surface);
|
||||||
has_content |= content;
|
|
||||||
let element: Element<'_, cosmic::Action<Message>> = if matches!(item.kind, ItemKind::FocusedWindow) {
|
let element: Element<'_, cosmic::Action<Message>> = if matches!(item.kind, ItemKind::FocusedWindow) {
|
||||||
element
|
element
|
||||||
} else {
|
} else {
|
||||||
@@ -598,57 +641,26 @@ fn view_zone<'a>(
|
|||||||
if count == 0 {
|
if count == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let opacity = shell_theme.panel_opacity(panel.background_opacity);
|
let horizontal = f32::from(group.padding[1])
|
||||||
let corners = panel
|
+ if panel.group_surface() == GroupSurface::Island {
|
||||||
.resolved_radius(shell_theme.bar_radius)
|
group.island_padding
|
||||||
.at_edge(
|
} else {
|
||||||
panel.edge,
|
0.
|
||||||
surface == GroupSurface::Island && panel.geometry.edge_margin == 0,
|
};
|
||||||
)
|
let content = container(controls)
|
||||||
.0;
|
.padding(cosmic::iced::Padding {
|
||||||
let border = panel.border;
|
top: f32::from(group.padding[0]),
|
||||||
let content: Element<'_, cosmic::Action<Message>> = container(controls)
|
bottom: f32::from(group.padding[0]),
|
||||||
.padding(group.padding)
|
left: horizontal,
|
||||||
.height(bar.group_height)
|
right: horizontal,
|
||||||
|
})
|
||||||
|
.height(bar.height)
|
||||||
.align_y(alignment::Vertical::Center)
|
.align_y(alignment::Vertical::Center)
|
||||||
.class(theme::Container::custom(move |_| {
|
.id(format!("panel:{}:group:{}", panel.id.0, group.id.0));
|
||||||
ferese_theme::panel::group_border(color(shell_theme.border), corners, border && has_content)
|
let content: Element<'_, cosmic::Action<Message>> = container(content)
|
||||||
}))
|
.id(group_material_id(panel, group))
|
||||||
|
.height(bar.height)
|
||||||
.into();
|
.into();
|
||||||
let content = container(content)
|
|
||||||
.id(format!("panel:{}:group:{}", panel.id.0, group.id.0))
|
|
||||||
.into();
|
|
||||||
let panel_surface = panel.surface;
|
|
||||||
let material = compositor_material && panel.surface == PanelSurface::None;
|
|
||||||
let content = match surface {
|
|
||||||
GroupSurface::None => content,
|
|
||||||
GroupSurface::Inset => container(content)
|
|
||||||
.id(group_material_id(panel, group))
|
|
||||||
.class(theme::Container::custom(move |_| {
|
|
||||||
group_surface_style(shell_theme, panel_surface, material, opacity, corners)
|
|
||||||
}))
|
|
||||||
.into(),
|
|
||||||
GroupSurface::Island => {
|
|
||||||
let content = island(
|
|
||||||
content,
|
|
||||||
shell_theme,
|
|
||||||
panel.geometry,
|
|
||||||
group.island_padding,
|
|
||||||
has_content,
|
|
||||||
material || panel.surface == PanelSurface::Solid,
|
|
||||||
Some((group_material_id(panel, group), opacity, corners)),
|
|
||||||
);
|
|
||||||
if panel.surface == PanelSurface::Solid {
|
|
||||||
container(content)
|
|
||||||
.class(theme::Container::custom(move |_| {
|
|
||||||
group_surface_style(shell_theme, PanelSurface::Solid, false, opacity, corners)
|
|
||||||
}))
|
|
||||||
.into()
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
groups = groups.push(content);
|
groups = groups.push(content);
|
||||||
}
|
}
|
||||||
groups.into()
|
groups.into()
|
||||||
@@ -725,6 +737,7 @@ pub(super) fn workspace_active_on_bar(workspace: u64, output: Option<&control::O
|
|||||||
output.is_some_and(|output| output.active_workspace == workspace)
|
output.is_some_and(|output| output.active_workspace == workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool, islands: bool) -> container::Style {
|
pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool, islands: bool) -> container::Style {
|
||||||
let mut style = if islands {
|
let mut style = if islands {
|
||||||
container::Style::default()
|
container::Style::default()
|
||||||
@@ -740,6 +753,7 @@ pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool, islands: b
|
|||||||
style
|
style
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(super) fn island<'a>(
|
pub(super) fn island<'a>(
|
||||||
content: Element<'a, cosmic::Action<Message>>,
|
content: Element<'a, cosmic::Action<Message>>,
|
||||||
theme: ShellTheme,
|
theme: ShellTheme,
|
||||||
@@ -754,7 +768,7 @@ pub(super) fn island<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let bar = BarMetrics::from(geometry);
|
let bar = BarMetrics::from(geometry);
|
||||||
let fallback = ferese_config::panel::CornerRadii::uniform(theme.bar_radius)
|
let fallback = ferese_panel::CornerRadii::uniform(theme.bar_radius)
|
||||||
.at_top_edge(geometry.edge_margin == 0)
|
.at_top_edge(geometry.edge_margin == 0)
|
||||||
.0;
|
.0;
|
||||||
let (id, opacity, corners) =
|
let (id, opacity, corners) =
|
||||||
@@ -780,26 +794,6 @@ pub(super) fn island<'a>(
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn group_surface_style(
|
|
||||||
theme: ShellTheme,
|
|
||||||
parent: PanelSurface,
|
|
||||||
material: bool,
|
|
||||||
opacity: f32,
|
|
||||||
corners: [f32; 4],
|
|
||||||
) -> cosmic::widget::container::Style {
|
|
||||||
let mut style = bar_style(theme, material, false);
|
|
||||||
style.border.radius = corners.into();
|
|
||||||
if parent == PanelSurface::Solid {
|
|
||||||
style.background = Some(Background::Color(Color {
|
|
||||||
a: opacity * 0.08,
|
|
||||||
..color(theme.text_primary)
|
|
||||||
}));
|
|
||||||
} else if let Some(Background::Color(fill)) = style.background {
|
|
||||||
style.background = Some(Background::Color(Color { a: opacity, ..fill }));
|
|
||||||
}
|
|
||||||
style
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn input_region(
|
pub(super) fn input_region(
|
||||||
mode: PanelSurface,
|
mode: PanelSurface,
|
||||||
hidden: bool,
|
hidden: bool,
|
||||||
@@ -964,7 +958,7 @@ mod island_tests {
|
|||||||
shell.clock = "10 oct, 4:35 pm".into();
|
shell.clock = "10 oct, 4:35 pm".into();
|
||||||
let item = Item::new("clock", ItemKind::Clock);
|
let item = Item::new("clock", ItemKind::Clock);
|
||||||
for height in [28., 31., 48.] {
|
for height in [28., 31., 48.] {
|
||||||
shell.config.panels[0].geometry.height = height;
|
shell.config.panels[0].geometry.height = Some(height);
|
||||||
for representation in [Representation::Wide, Representation::Compact] {
|
for representation in [Representation::Wide, Representation::Compact] {
|
||||||
let mut view = shell
|
let mut view = shell
|
||||||
.view_panel_item(shell.outputs[0].bar, &item, None, true, representation)
|
.view_panel_item(shell.outputs[0].bar, &item, None, true, representation)
|
||||||
@@ -1015,7 +1009,7 @@ mod island_tests {
|
|||||||
availability,
|
availability,
|
||||||
theme,
|
theme,
|
||||||
true,
|
true,
|
||||||
|_| true,
|
|item| title_content || !matches!(item.kind, ItemKind::FocusedWindow),
|
||||||
|item, _| {
|
|item, _| {
|
||||||
let width = match item.kind {
|
let width = match item.kind {
|
||||||
ItemKind::Overview => 28.,
|
ItemKind::Overview => 28.,
|
||||||
@@ -1036,14 +1030,14 @@ mod island_tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let mut view: Element<'_, cosmic::Action<Message>> = container(view)
|
let mut view: Element<'_, cosmic::Action<Message>> = container(view)
|
||||||
.height(panel.geometry.height)
|
.height(panel.resolved_geometry().height)
|
||||||
.align_y(alignment::Vertical::Center)
|
.align_y(alignment::Vertical::Center)
|
||||||
.into();
|
.into();
|
||||||
let mut tree = widget::Tree::new(&view);
|
let mut tree = widget::Tree::new(&view);
|
||||||
let node = view.as_widget_mut().layout(
|
let node = view.as_widget_mut().layout(
|
||||||
&mut tree,
|
&mut tree,
|
||||||
&renderer,
|
&renderer,
|
||||||
&layout::Limits::new(Size::ZERO, Size::new(1200., panel.geometry.height)),
|
&layout::Limits::new(Size::ZERO, Size::new(1200., panel.resolved_geometry().height)),
|
||||||
);
|
);
|
||||||
let mut bounds = Bounds {
|
let mut bounds = Bounds {
|
||||||
group_ids: zone
|
group_ids: zone
|
||||||
@@ -1076,19 +1070,19 @@ mod island_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn islands_cover_the_full_panel_height_independently_of_group_decoration() {
|
fn islands_cover_the_full_panel_height_independently_of_group_decoration() {
|
||||||
let mut panel = Panel::from_preset(PanelPreset::Islands);
|
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||||
for margin in [0, 6] {
|
for margin in [0, 6] {
|
||||||
let theme = ShellTheme::default();
|
let theme = ShellTheme::default();
|
||||||
panel.geometry.height = 36.;
|
panel.geometry.height = Some(36.);
|
||||||
panel.geometry.edge_margin = margin;
|
panel.geometry.edge_margin = Some(margin);
|
||||||
for border in [false, true] {
|
for border in [false, true] {
|
||||||
panel.border = border;
|
panel.border = border;
|
||||||
let bounds = measure_zone_with_theme(&panel, &panel.start, Availability::default(), true, theme);
|
let bounds = measure_zone_with_theme(&panel, &panel.start, Availability::default(), true, theme);
|
||||||
assert_eq!(bounds.islands[0].y, 0.);
|
assert_eq!(bounds.islands[0].y, 0.);
|
||||||
assert_eq!(bounds.islands[0].height, 36.);
|
assert_eq!(bounds.islands[0].height, 36.);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bounds.groups[0].height, 32.,
|
bounds.groups[0].height, 36.,
|
||||||
"decoration must not give the inner content a second full-height background"
|
"group layout follows the same baseline as its shared surface"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1097,7 +1091,7 @@ mod island_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn composition_preserves_default_group_gaps_and_material_geometry() {
|
fn composition_preserves_default_group_gaps_and_material_geometry() {
|
||||||
for padding in [0., 4., 13.5] {
|
for padding in [0., 4., 13.5] {
|
||||||
for background in [PanelPreset::Continuous, PanelPreset::Islands] {
|
for background in [PanelStyle::Flat, PanelStyle::Floating] {
|
||||||
let mut panel = Panel::from_preset(background);
|
let mut panel = Panel::from_preset(background);
|
||||||
for group in [&mut panel.start, &mut panel.center, &mut panel.end]
|
for group in [&mut panel.start, &mut panel.center, &mut panel.end]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1110,14 +1104,17 @@ mod island_tests {
|
|||||||
start.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
|
start.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
|
||||||
["overview", "workspaces"]
|
["overview", "workspaces"]
|
||||||
);
|
);
|
||||||
let islands = background == PanelPreset::Islands;
|
let islands = background == PanelStyle::Floating;
|
||||||
assert_eq!(start.items[0].1.x, if islands { padding + 3. } else { 0. });
|
assert_eq!(start.items[0].1.x, if islands { padding + 3. } else { 0. });
|
||||||
assert_eq!(start.items[1].1.x - start.items[0].1.x, if islands { 29. } else { 34. });
|
assert_eq!(start.items[1].1.x - start.items[0].1.x, if islands { 29. } else { 34. });
|
||||||
assert_eq!(start.islands.len(), usize::from(islands));
|
assert_eq!(start.islands.len(), panel.start.groups.len());
|
||||||
if islands {
|
if islands {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
start.islands[0],
|
start.islands[0],
|
||||||
Rectangle::new((0., 0.).into(), (107. + 2. * padding, 28.).into())
|
Rectangle::new(
|
||||||
|
(0., 0.).into(),
|
||||||
|
(107. + 2. * padding, panel.resolved_geometry().height).into()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let end = measure_zone(&panel, &panel.end, Availability::default(), true);
|
let end = measure_zone(&panel, &panel.end, Availability::default(), true);
|
||||||
@@ -1130,19 +1127,22 @@ mod island_tests {
|
|||||||
if islands {
|
if islands {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
end.islands[0],
|
end.islands[0],
|
||||||
Rectangle::new((0., 0.).into(), (108. + 2. * padding, 28.).into())
|
Rectangle::new(
|
||||||
|
(0., 0.).into(),
|
||||||
|
(108. + 2. * padding, panel.resolved_geometry().height).into()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let center = measure_zone(&panel, &panel.center, Availability::default(), true);
|
let center = measure_zone(&panel, &panel.center, Availability::default(), true);
|
||||||
assert_eq!(center.islands.len(), usize::from(islands));
|
assert_eq!(center.islands.len(), panel.center.groups.len());
|
||||||
if islands {
|
if islands {
|
||||||
assert_eq!(center.islands[0].width, 80. + 2. * padding);
|
assert_eq!(center.islands[0].width, 80. + 2. * padding);
|
||||||
}
|
}
|
||||||
let empty_title = measure_zone(&panel, &panel.center, Availability::default(), false);
|
let empty_title = measure_zone(&panel, &panel.center, Availability::default(), false);
|
||||||
assert!(empty_title.islands.is_empty());
|
assert!(empty_title.islands.is_empty());
|
||||||
assert_eq!(
|
assert!(
|
||||||
empty_title.items[0].1.width, 80.,
|
empty_title.items.is_empty(),
|
||||||
"keep the current empty title reservation"
|
"an unavailable title leaves no group behind"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1150,7 +1150,7 @@ mod island_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn group_renderer_obeys_order_and_instance_identity_and_skips_unavailable_groups() {
|
fn group_renderer_obeys_order_and_instance_identity_and_skips_unavailable_groups() {
|
||||||
let mut panel = Panel::from_preset(PanelPreset::Islands);
|
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||||
let group = &mut panel.end.groups[0];
|
let group = &mut panel.end.groups[0];
|
||||||
let mut clock = group
|
let mut clock = group
|
||||||
.items
|
.items
|
||||||
@@ -1206,7 +1206,7 @@ mod island_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn default_groups_have_no_fill_or_outline_in_either_arrangement() {
|
fn default_groups_have_no_fill_or_outline_in_either_arrangement() {
|
||||||
let theme = ShellTheme::default();
|
let theme = ShellTheme::default();
|
||||||
for background in [PanelPreset::Continuous, PanelPreset::Islands] {
|
for background in [PanelStyle::Flat, PanelStyle::Floating] {
|
||||||
let panel = Panel::from_preset(background);
|
let panel = Panel::from_preset(background);
|
||||||
let group = ferese_theme::panel::group_border(color(theme.border), theme.bar_radius, panel.border);
|
let group = ferese_theme::panel::group_border(color(theme.border), theme.bar_radius, panel.border);
|
||||||
assert!(group.background.is_none());
|
assert!(group.background.is_none());
|
||||||
@@ -1239,27 +1239,25 @@ mod island_tests {
|
|||||||
assert_eq!(inner.border.width, 1.);
|
assert_eq!(inner.border.width, 1.);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn mixed_group_surfaces_use_independent_heights_and_padding() {
|
fn panel_surface_policy_applies_to_every_occupied_group() {
|
||||||
let mut panel = Panel::from_preset(PanelPreset::Islands);
|
let mut panel = Panel::from_preset(PanelStyle::Floating);
|
||||||
panel.geometry.height = 36.;
|
panel.geometry.height = Some(36.);
|
||||||
panel.center.groups[0].surface = Some(GroupSurface::None);
|
for zone in [&panel.start, &panel.center, &panel.end] {
|
||||||
panel.end.groups[0].surface = Some(GroupSurface::Inset);
|
let measured = measure_zone(&panel, zone, Availability::default(), true);
|
||||||
let navigation = measure_zone(&panel, &panel.start, Availability::default(), true);
|
assert_eq!(measured.islands.len(), measured.groups.len());
|
||||||
let title = measure_zone(&panel, &panel.center, Availability::default(), true);
|
for (island, group) in measured.islands.iter().zip(&measured.groups) {
|
||||||
let status = measure_zone(&panel, &panel.end, Availability::default(), true);
|
assert_eq!(island.height, 36.);
|
||||||
assert_eq!(navigation.islands.len(), 1);
|
assert_eq!(island.width, group.width);
|
||||||
assert_eq!(navigation.islands[0].height, 36.);
|
}
|
||||||
assert!(title.islands.is_empty());
|
}
|
||||||
assert_eq!(title.items.len(), 1);
|
panel.apply_preset(PanelStyle::Flat);
|
||||||
assert_eq!(status.islands.len(), 1);
|
for zone in [&panel.start, &panel.center, &panel.end] {
|
||||||
assert_eq!(status.islands[0].height, BarMetrics::from(panel.geometry).group_height);
|
let measured = measure_zone(&panel, zone, Availability::default(), true);
|
||||||
assert_eq!(status.islands[0].width, status.groups[0].width);
|
assert_eq!(
|
||||||
assert!(navigation.islands[0].width > navigation.groups[0].width);
|
measured.islands.len(),
|
||||||
// The same media island can sit on a continuous panel.
|
measured.groups.len(),
|
||||||
panel.surface = PanelSurface::Solid;
|
"changing the shared shape preserves layout bounds"
|
||||||
panel.group_surface = GroupSurface::None;
|
);
|
||||||
panel.end.groups[0].surface = Some(GroupSurface::Island);
|
}
|
||||||
let status = measure_zone(&panel, &panel.end, Availability::default(), true);
|
|
||||||
assert_eq!(status.islands[0].height, 36.);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
|
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
|
||||||
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
|
use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
|
||||||
use cosmic::{Element, Theme};
|
use cosmic::{Element, Theme};
|
||||||
use ferese_config::panel::PanelSurface;
|
use ferese_panel::PanelSurface;
|
||||||
|
|
||||||
pub(crate) fn island_id() -> widget::Id {
|
pub(crate) fn island_id() -> widget::Id {
|
||||||
static ID: std::sync::OnceLock<widget::Id> = std::sync::OnceLock::new();
|
static ID: std::sync::OnceLock<widget::Id> = std::sync::OnceLock::new();
|
||||||
@@ -13,6 +13,7 @@ pub(crate) fn input_id() -> widget::Id {
|
|||||||
ID.get_or_init(widget::Id::unique).clone()
|
ID.get_or_init(widget::Id::unique).clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(super) fn frame<'a, M: 'a>(
|
pub(super) fn frame<'a, M: 'a>(
|
||||||
content: Element<'a, M>,
|
content: Element<'a, M>,
|
||||||
mode: PanelSurface,
|
mode: PanelSurface,
|
||||||
@@ -29,7 +30,19 @@ pub(super) fn frame<'a, M: 'a>(
|
|||||||
materials,
|
materials,
|
||||||
hover,
|
hover,
|
||||||
present: Box::new(present),
|
present: Box::new(present),
|
||||||
changed: Box::new(changed),
|
changed: Some(Box::new(changed)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn hover<'a, M: 'a>(content: Element<'a, M>, hover: Option<HoverTarget<'a, M>>) -> Element<'a, M> {
|
||||||
|
Element::new(Presentation {
|
||||||
|
content,
|
||||||
|
mode: PanelSurface::None,
|
||||||
|
radius: 0.,
|
||||||
|
materials: Vec::new(),
|
||||||
|
hover,
|
||||||
|
present: Box::new(|_, _| {}),
|
||||||
|
changed: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +79,8 @@ impl widget::Operation for HoverBounds<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Changed<'a, M> = Box<dyn Fn(Vec<[f32; 5]>) -> M + 'a>;
|
||||||
|
|
||||||
type Present<'a> = Box<dyn Fn(&[[f32; 5]], &[f32]) + 'a>;
|
type Present<'a> = Box<dyn Fn(&[[f32; 5]], &[f32]) + 'a>;
|
||||||
|
|
||||||
struct Presentation<'a, M> {
|
struct Presentation<'a, M> {
|
||||||
@@ -75,7 +90,7 @@ struct Presentation<'a, M> {
|
|||||||
materials: Vec<(widget::Id, f32)>,
|
materials: Vec<(widget::Id, f32)>,
|
||||||
hover: Option<HoverTarget<'a, M>>,
|
hover: Option<HoverTarget<'a, M>>,
|
||||||
present: Present<'a>,
|
present: Present<'a>,
|
||||||
changed: Box<dyn Fn(Vec<[f32; 5]>) -> M + 'a>,
|
changed: Option<Changed<'a, M>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
||||||
@@ -166,7 +181,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
|||||||
shell,
|
shell,
|
||||||
viewport,
|
viewport,
|
||||||
);
|
);
|
||||||
if !matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) {
|
if self.changed.is_none() || !matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +218,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Presentation<'_, M> {
|
|||||||
state.mode = Some(self.mode);
|
state.mode = Some(self.mode);
|
||||||
state.current.clone_from(&collector.regions);
|
state.current.clone_from(&collector.regions);
|
||||||
state.input.clone_from(&collector.input);
|
state.input.clone_from(&collector.input);
|
||||||
shell.publish((self.changed)(if self.mode == PanelSurface::Solid {
|
shell.publish((self.changed.as_ref().unwrap())(if self.mode == PanelSurface::Solid {
|
||||||
collector.regions.clone()
|
collector.regions.clone()
|
||||||
} else {
|
} else {
|
||||||
collector.input.clone()
|
collector.input.clone()
|
||||||
@@ -482,25 +497,20 @@ mod tests {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
let islands = row![section(100), section(200),].spacing(60);
|
let islands = row![section(100), section(200),].spacing(60);
|
||||||
let content = container(islands)
|
let content: Element<'_, cosmic::Action<crate::Message>> = container(islands)
|
||||||
.padding([0, 12])
|
.padding([0, 12])
|
||||||
.width(600)
|
.width(600)
|
||||||
.height(28)
|
.height(28)
|
||||||
.align_y(cosmic::iced::alignment::Vertical::Center)
|
.align_y(cosmic::iced::alignment::Vertical::Center)
|
||||||
.into();
|
.into();
|
||||||
let mut view = frame(
|
let mut view = frame(
|
||||||
content,
|
content.map(|_| Vec::<[f32; 5]>::new()),
|
||||||
mode,
|
mode,
|
||||||
14.,
|
14.,
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
|_, _| {},
|
|_, _| {},
|
||||||
|regions| {
|
|regions| regions,
|
||||||
cosmic::Action::App(crate::Message::BarRegionsChanged(
|
|
||||||
cosmic::iced::window::Id::unique(),
|
|
||||||
regions,
|
|
||||||
))
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
|
let tree = tree.get_or_insert_with(|| widget::Tree::new(&view));
|
||||||
tree.diff(&mut view);
|
tree.diff(&mut view);
|
||||||
@@ -531,9 +541,7 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
vec![[0., 0., 600., 28., 14.]]
|
vec![[0., 0., 600., 28., 14.]]
|
||||||
};
|
};
|
||||||
let cosmic::Action::App(crate::Message::BarRegionsChanged(_, regions)) = &messages[0] else {
|
let regions = &messages[0];
|
||||||
panic!("expected measured bar regions");
|
|
||||||
};
|
|
||||||
assert_eq!(*regions, expected);
|
assert_eq!(*regions, expected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Visible clock labels, driven by realtime boundaries and timezone changes.
|
|
||||||
use std::ffi::{CString, OsString};
|
use std::ffi::{CString, OsString};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ pub(crate) struct ShellTheme {
|
|||||||
pub(crate) bar_text_primary: [u8; 4],
|
pub(crate) bar_text_primary: [u8; 4],
|
||||||
pub(crate) bar_text_muted: [u8; 4],
|
pub(crate) bar_text_muted: [u8; 4],
|
||||||
pub(crate) surface_base: [u8; 4],
|
pub(crate) surface_base: [u8; 4],
|
||||||
|
pub(crate) surface_raised: [u8; 4],
|
||||||
pub(crate) surface_popover: [u8; 4],
|
pub(crate) surface_popover: [u8; 4],
|
||||||
pub(crate) text_primary: [u8; 4],
|
pub(crate) text_primary: [u8; 4],
|
||||||
pub(crate) text_muted: [u8; 4],
|
pub(crate) text_muted: [u8; 4],
|
||||||
@@ -86,13 +87,15 @@ impl Default for ShellTheme {
|
|||||||
bar_text_primary: [240, 243, 250, 255],
|
bar_text_primary: [240, 243, 250, 255],
|
||||||
bar_text_muted: [170, 180, 199, 255],
|
bar_text_muted: [170, 180, 199, 255],
|
||||||
surface_base: [17, 24, 33, 255],
|
surface_base: [17, 24, 33, 255],
|
||||||
|
surface_raised: parse_color(&ferese_config::theme::default_tokens().colors.surface_raised)
|
||||||
|
.expect("valid theme surface color"),
|
||||||
surface_popover: [17, 24, 33, 255],
|
surface_popover: [17, 24, 33, 255],
|
||||||
text_primary: [244, 247, 251, 255],
|
text_primary: [244, 247, 251, 255],
|
||||||
text_muted: [135, 147, 162, 255],
|
text_muted: [135, 147, 162, 255],
|
||||||
accent: [61, 123, 230, 255],
|
accent: [61, 123, 230, 255],
|
||||||
accent_gradient: None,
|
accent_gradient: None,
|
||||||
on_accent: [244, 247, 251, 255],
|
on_accent: [244, 247, 251, 255],
|
||||||
border: [255, 255, 255, 24],
|
border: parse_color(&default_border()).expect("valid theme border color"),
|
||||||
shadow: [0, 0, 0, 85],
|
shadow: [0, 0, 0, 85],
|
||||||
bar_radius: 14.0,
|
bar_radius: 14.0,
|
||||||
material_radius: 14.0,
|
material_radius: 14.0,
|
||||||
@@ -249,6 +252,8 @@ struct TypographyConfig {
|
|||||||
struct ThemeColorsConfig {
|
struct ThemeColorsConfig {
|
||||||
#[serde(default = "default_surface_base")]
|
#[serde(default = "default_surface_base")]
|
||||||
surface_base: String,
|
surface_base: String,
|
||||||
|
#[serde(default = "default_surface_raised")]
|
||||||
|
surface_raised: String,
|
||||||
#[serde(default = "default_text_primary")]
|
#[serde(default = "default_text_primary")]
|
||||||
text_primary: String,
|
text_primary: String,
|
||||||
#[serde(default = "default_text_muted")]
|
#[serde(default = "default_text_muted")]
|
||||||
@@ -267,6 +272,7 @@ impl Default for ThemeColorsConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
surface_base: default_surface_base(),
|
surface_base: default_surface_base(),
|
||||||
|
surface_raised: default_surface_raised(),
|
||||||
text_primary: default_text_primary(),
|
text_primary: default_text_primary(),
|
||||||
text_muted: default_text_muted(),
|
text_muted: default_text_muted(),
|
||||||
accent: default_accent(),
|
accent: default_accent(),
|
||||||
@@ -371,7 +377,7 @@ fn parse_document(
|
|||||||
{
|
{
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
if let Some(panels) = &config.panels {
|
if let Some(panels) = &config.panels {
|
||||||
ferese_config::panel::validate(panels).map_err(ferese_config::Error::from)?;
|
ferese_panel::validate(panels).map_err(ferese_config::Error::from)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
config.animations.validate().map_err(ferese_config::Error::from)?;
|
config.animations.validate().map_err(ferese_config::Error::from)?;
|
||||||
@@ -447,6 +453,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
|
|||||||
bar_text_primary: parse_color(&theme.surface.bar.text_primary).unwrap_or(defaults.bar_text_primary),
|
bar_text_primary: parse_color(&theme.surface.bar.text_primary).unwrap_or(defaults.bar_text_primary),
|
||||||
bar_text_muted: parse_color(&theme.surface.bar.text_muted).unwrap_or(defaults.bar_text_muted),
|
bar_text_muted: parse_color(&theme.surface.bar.text_muted).unwrap_or(defaults.bar_text_muted),
|
||||||
surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base),
|
surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base),
|
||||||
|
surface_raised: parse_color(&theme.colors.surface_raised).unwrap_or(defaults.surface_raised),
|
||||||
surface_popover: {
|
surface_popover: {
|
||||||
let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
|
let mut color = parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
|
||||||
color[3] = alpha;
|
color[3] = alpha;
|
||||||
@@ -501,6 +508,10 @@ fn default_surface_base() -> String {
|
|||||||
"#111821".to_owned()
|
"#111821".to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_surface_raised() -> String {
|
||||||
|
ferese_config::theme::default_tokens().colors.surface_raised
|
||||||
|
}
|
||||||
|
|
||||||
fn default_text_primary() -> String {
|
fn default_text_primary() -> String {
|
||||||
"#F4F7FB".to_owned()
|
"#F4F7FB".to_owned()
|
||||||
}
|
}
|
||||||
@@ -514,7 +525,7 @@ fn default_accent() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_border() -> String {
|
fn default_border() -> String {
|
||||||
"#FFFFFF18".to_owned()
|
ferese_config::theme::default_tokens().colors.border
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_shadow() -> String {
|
fn default_shadow() -> String {
|
||||||
@@ -547,6 +558,25 @@ pub(crate) fn config_path() -> Option<PathBuf> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn morph_profile_uses_the_current_strict_schema() {
|
||||||
|
let config = parse_test_source(
|
||||||
|
"panel main { style morph; morph { idle-width 180; media-height 184; peek-on-hover #false; }; }\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(config.panels[0].morph.idle_width, 180.);
|
||||||
|
assert_eq!(config.panels[0].morph.media_height, 184.);
|
||||||
|
assert!(!config.panels[0].morph.peek_on_hover);
|
||||||
|
assert!(
|
||||||
|
parse_test_source("panel main { style morph; }\n").unwrap().panels[0]
|
||||||
|
.morph
|
||||||
|
.peek_on_hover
|
||||||
|
);
|
||||||
|
assert!(parse_test_source("panel main { style morph; morph { peek-on-hover 1; }; }\n").is_err());
|
||||||
|
assert!(parse_test_source("panel main { style morph; morph { idle-wdth 180; }; }\n").is_err());
|
||||||
|
assert!(parse_test_source("panel main { style morph; morph { idle-width -1; }; }\n").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn removed_geometry_is_rejected_before_a_runtime_snapshot_can_hide_it() {
|
fn removed_geometry_is_rejected_before_a_runtime_snapshot_can_hide_it() {
|
||||||
for key in [
|
for key in [
|
||||||
@@ -583,11 +613,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reduce_transparency_overrides_panel_opacity_for_both_surfaces() {
|
fn reduce_transparency_overrides_panel_opacity_for_both_surfaces() {
|
||||||
for surface in ["solid", "none"] {
|
for surface in ["flat", "floating"] {
|
||||||
for opacity in [0., 0.4, 1.] {
|
for opacity in [0., 0.4, 1.] {
|
||||||
for reduce in [false, true] {
|
for reduce in [false, true] {
|
||||||
let source = format!(
|
let source = format!(
|
||||||
"theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ surface {surface}; group-surface island; background-opacity {opacity}; }}"
|
"theme {{ material {{ style translucent; opacity 0.7; }}; accessibility {{ reduce-transparency #{reduce}; }}; }}; panel main {{ style {surface}; background-opacity {opacity}; }}"
|
||||||
);
|
);
|
||||||
let document = ferese_config::Document::parse(&source).unwrap();
|
let document = ferese_config::Document::parse(&source).unwrap();
|
||||||
let resolved = ferese_config::theme::resolve(
|
let resolved = ferese_config::theme::resolve(
|
||||||
@@ -649,13 +679,13 @@ mod tests {
|
|||||||
use crate::panel::{GroupSurface, ItemKind, PanelSurface};
|
use crate::panel::{GroupSurface, ItemKind, PanelSurface};
|
||||||
let defaults = parse_test_source("").unwrap();
|
let defaults = parse_test_source("").unwrap();
|
||||||
assert_eq!(ShellConfig::default().panels, defaults.panels);
|
assert_eq!(ShellConfig::default().panels, defaults.panels);
|
||||||
let source = "panel main { surface none; group-surface island; geometry { height 36; edge-margin 6; }; center { group title { surface none; item title kind=focused-window visible=#false; }; }; end { group status { island-padding 9.5; item battery kind=battery percentage=#false; }; }; }";
|
let source = "panel main { style floating; geometry { height 36; edge-margin 6; }; center { group title { item title kind=focused-window visible=#false; }; }; end { group status { island-padding 9.5; item battery kind=battery percentage=#false; }; }; }";
|
||||||
let current = parse_test_source(source).unwrap();
|
let current = parse_test_source(source).unwrap();
|
||||||
let panel = ¤t.panels[0];
|
let panel = ¤t.panels[0];
|
||||||
assert_eq!(panel.surface, PanelSurface::None);
|
assert_eq!(panel.surface(), PanelSurface::None);
|
||||||
assert_eq!(panel.group_surface, GroupSurface::Island);
|
assert_eq!(panel.group_surface(), GroupSurface::Island);
|
||||||
assert_eq!(panel.geometry.height, 36.);
|
assert_eq!(panel.resolved_geometry().height, 36.);
|
||||||
assert_eq!(panel.geometry.edge_margin, 6);
|
assert_eq!(panel.resolved_geometry().edge_margin, 6);
|
||||||
assert_eq!(panel.end.groups[0].island_padding, 9.5);
|
assert_eq!(panel.end.groups[0].island_padding, 9.5);
|
||||||
assert!(!panel.center.groups[0].items[0].visible);
|
assert!(!panel.center.groups[0].items[0].visible);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -667,10 +697,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn authored_composition_rejects_invalid_reload_data() {
|
fn authored_composition_rejects_invalid_reload_data() {
|
||||||
let source = r#"panel "custom" { surface "none"; group-surface "island"; end { group "clocks" { item "one" kind="clock"; item "two" kind="clock"; }; }; }"#;
|
let source = r#"panel "custom" { style "floating"; end { group "clocks" { item "one" kind="clock"; item "two" kind="clock"; }; }; }"#;
|
||||||
let config = parse_test_source(source).unwrap();
|
let config = parse_test_source(source).unwrap();
|
||||||
assert_eq!(config.panels[0].id.0, "custom");
|
assert_eq!(config.panels[0].id.0, "custom");
|
||||||
assert_eq!(config.panels[0].surface, crate::panel::PanelSurface::None);
|
assert_eq!(config.panels[0].surface(), crate::panel::PanelSurface::None);
|
||||||
assert_eq!(config.panels[0].end.groups[0].items.len(), 2);
|
assert_eq!(config.panels[0].end.groups[0].items.len(), 2);
|
||||||
assert!(parse_test_source(&source.replace("item \"two\"", "item \"one\"")).is_err());
|
assert!(parse_test_source(&source.replace("item \"two\"", "item \"one\"")).is_err());
|
||||||
assert!(parse_test_source("panel one; panel two;").is_err());
|
assert!(parse_test_source("panel one; panel two;").is_err());
|
||||||
@@ -680,7 +710,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn group_surfaces_do_not_change_modal_opacity() {
|
fn group_surfaces_do_not_change_modal_opacity() {
|
||||||
let normal = parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap();
|
let normal = parse_test_source("theme { material { style translucent; opacity 0.7; }; }").unwrap();
|
||||||
let islands = parse_test_source("panel main { surface none; group-surface island; }\ntheme { material { style translucent; opacity 0.7; }; }").unwrap();
|
let islands = parse_test_source(
|
||||||
|
"panel main { style floating; }\ntheme { material { style translucent; opacity 0.7; }; }",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(islands.theme.surface_popover, normal.theme.surface_popover);
|
assert_eq!(islands.theme.surface_popover, normal.theme.surface_popover);
|
||||||
assert_eq!(islands.theme.surface_base, normal.theme.surface_base);
|
assert_eq!(islands.theme.surface_base, normal.theme.surface_base);
|
||||||
assert_eq!(islands.theme.bar_background, normal.theme.bar_background);
|
assert_eq!(islands.theme.bar_background, normal.theme.bar_background);
|
||||||
@@ -696,7 +729,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let config = parse_test_source(&source).unwrap();
|
let config = parse_test_source(&source).unwrap();
|
||||||
assert_eq!(config.panels[0].end.groups[0].island_padding, padding);
|
assert_eq!(config.panels[0].end.groups[0].island_padding, padding);
|
||||||
assert_eq!(config.panels[0].geometry.inner_padding, 12.);
|
assert_eq!(config.panels[0].resolved_geometry().inner_padding, 12.);
|
||||||
}
|
}
|
||||||
for value in ["-1", "32.5", "\"small\""] {
|
for value in ["-1", "32.5", "\"small\""] {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -788,7 +821,7 @@ theme {
|
|||||||
assert_eq!(config.theme.background.mode, WallpaperMode::Fill);
|
assert_eq!(config.theme.background.mode, WallpaperMode::Fill);
|
||||||
let theme = shell_theme(&config.theme);
|
let theme = shell_theme(&config.theme);
|
||||||
assert_eq!(crate::panel::PanelGeometry::default().edge_margin, 0);
|
assert_eq!(crate::panel::PanelGeometry::default().edge_margin, 0);
|
||||||
assert_eq!(crate::panel::PanelGeometry::default().height, 28.0);
|
assert_eq!(crate::panel::PanelGeometry::default().height, 36.0);
|
||||||
assert_eq!(crate::panel::PanelGeometry::default().side_margins, 0);
|
assert_eq!(crate::panel::PanelGeometry::default().side_margins, 0);
|
||||||
assert_eq!(theme.bar_radius, 14.0);
|
assert_eq!(theme.bar_radius, 14.0);
|
||||||
assert_eq!(theme.bar_background[3], 255);
|
assert_eq!(theme.bar_background[3], 255);
|
||||||
@@ -800,6 +833,7 @@ theme {
|
|||||||
fn bar_palette_can_be_changed_without_changing_popovers() {
|
fn bar_palette_can_be_changed_without_changing_popovers() {
|
||||||
let config: FereseConfig = ferese_config::from_str(
|
let config: FereseConfig = ferese_config::from_str(
|
||||||
r##"theme {
|
r##"theme {
|
||||||
|
colors { surface-raised "#314159"; }
|
||||||
surface {
|
surface {
|
||||||
bar {
|
bar {
|
||||||
background "#EAECEEDD"
|
background "#EAECEEDD"
|
||||||
@@ -813,6 +847,7 @@ theme {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let theme = shell_theme(&config.theme);
|
let theme = shell_theme(&config.theme);
|
||||||
assert_eq!(theme.bar_background, [234, 236, 238, 255]);
|
assert_eq!(theme.bar_background, [234, 236, 238, 255]);
|
||||||
|
assert_eq!(theme.surface_raised, [49, 65, 89, 255]);
|
||||||
assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]);
|
assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]);
|
||||||
assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]);
|
assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]);
|
||||||
assert_eq!(theme.text_primary, ShellTheme::default().text_primary);
|
assert_eq!(theme.text_primary, ShellTheme::default().text_primary);
|
||||||
|
|||||||
@@ -48,15 +48,15 @@ pub(crate) struct WorkspaceSnapshot {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct WindowSnapshot {
|
pub(crate) struct WindowSnapshot {
|
||||||
#[cfg_attr(not(test), expect(dead_code, reason = "Retained for window-targeted shell views."))]
|
|
||||||
pub(crate) id: u64,
|
pub(crate) id: u64,
|
||||||
pub(crate) workspace: u64,
|
pub(crate) workspace: u64,
|
||||||
|
pub(crate) geometry: Option<[f64; 4]>,
|
||||||
pub(crate) app_id: String,
|
pub(crate) app_id: String,
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
pub(crate) focused: bool,
|
pub(crate) focused: bool,
|
||||||
#[cfg_attr(not(test), expect(dead_code, reason = "The current bar does not display urgency."))]
|
|
||||||
pub(crate) urgent: bool,
|
pub(crate) urgent: bool,
|
||||||
pub(crate) fullscreen: bool,
|
pub(crate) fullscreen: bool,
|
||||||
|
pub(crate) maximized: bool,
|
||||||
#[cfg_attr(
|
#[cfg_attr(
|
||||||
not(test),
|
not(test),
|
||||||
expect(dead_code, reason = "The current bar does not display window placement.")
|
expect(dead_code, reason = "The current bar does not display window placement.")
|
||||||
@@ -136,7 +136,7 @@ impl ShellControl {
|
|||||||
let connection = control_connection()?;
|
let connection = control_connection()?;
|
||||||
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
|
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
|
||||||
let qh = queue.handle();
|
let qh = queue.handle();
|
||||||
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 4..=6, ())?;
|
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 7..=8, ())?;
|
||||||
let shell = manager.get_shell(&qh, ());
|
let shell = manager.get_shell(&qh, ());
|
||||||
let (sender, updates) = mpsc::channel();
|
let (sender, updates) = mpsc::channel();
|
||||||
let wake = Wake(Arc::new(tokio::sync::Notify::new()));
|
let wake = Wake(Arc::new(tokio::sync::Notify::new()));
|
||||||
@@ -194,6 +194,16 @@ impl ShellControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn move_window_to_workspace(&self, window: u64, workspace: u64) {
|
||||||
|
if self.shell.version() < 8 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (hi, lo) = split_id(window);
|
||||||
|
let (workspace_hi, workspace_lo) = split_id(workspace);
|
||||||
|
self.shell.move_window_to_workspace(hi, lo, workspace_hi, workspace_lo);
|
||||||
|
let _ = self.connection.flush();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn activate_workspace(&self, id: u64) {
|
pub(crate) fn activate_workspace(&self, id: u64) {
|
||||||
let (hi, lo) = split_id(id);
|
let (hi, lo) = split_id(id);
|
||||||
|
|
||||||
@@ -375,14 +385,36 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
|
|||||||
state.pending.windows.push(WindowSnapshot {
|
state.pending.windows.push(WindowSnapshot {
|
||||||
id: join_id(window_hi, window_lo),
|
id: join_id(window_hi, window_lo),
|
||||||
workspace: join_id(workspace_hi, workspace_lo),
|
workspace: join_id(workspace_hi, workspace_lo),
|
||||||
|
geometry: None,
|
||||||
app_id,
|
app_id,
|
||||||
title,
|
title,
|
||||||
focused: flags.contains(ferese_shell_v1::WindowState::Focused),
|
focused: flags.contains(ferese_shell_v1::WindowState::Focused),
|
||||||
urgent: flags.contains(ferese_shell_v1::WindowState::Urgent),
|
urgent: flags.contains(ferese_shell_v1::WindowState::Urgent),
|
||||||
fullscreen: flags.contains(ferese_shell_v1::WindowState::Fullscreen),
|
fullscreen: flags.contains(ferese_shell_v1::WindowState::Fullscreen),
|
||||||
|
maximized: flags.contains(ferese_shell_v1::WindowState::Maximized),
|
||||||
floating: flags.contains(ferese_shell_v1::WindowState::Floating),
|
floating: flags.contains(ferese_shell_v1::WindowState::Floating),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
ferese_shell_v1::Event::WindowGeometry {
|
||||||
|
window_hi,
|
||||||
|
window_lo,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
} => {
|
||||||
|
if [x, y, width, height].iter().all(|n| n.is_finite())
|
||||||
|
&& width > 0.
|
||||||
|
&& height > 0.
|
||||||
|
&& let Some(window) = state
|
||||||
|
.pending
|
||||||
|
.windows
|
||||||
|
.last_mut()
|
||||||
|
.filter(|window| window.id == join_id(window_hi, window_lo))
|
||||||
|
{
|
||||||
|
window.geometry = Some([x, y, width, height]);
|
||||||
|
}
|
||||||
|
}
|
||||||
ferese_shell_v1::Event::Capabilities { .. } => {
|
ferese_shell_v1::Event::Capabilities { .. } => {
|
||||||
let _ = state.sender.send(ControlUpdate::MonitorsChanged);
|
let _ = state.sender.send(ControlUpdate::MonitorsChanged);
|
||||||
}
|
}
|
||||||
@@ -564,6 +596,110 @@ mod tests {
|
|||||||
assert_ne!(tiled.id, floating.id);
|
assert_ne!(tiled.id, floating.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn geometry_is_attached_to_its_window_and_published_only_with_the_complete_snapshot() {
|
||||||
|
let (control, mut state, queue, _peer) = control_fixture();
|
||||||
|
dispatch_event(
|
||||||
|
&control,
|
||||||
|
&mut state,
|
||||||
|
&queue,
|
||||||
|
ferese_shell_v1::Event::SnapshotBegin { serial: 8 },
|
||||||
|
);
|
||||||
|
let id = 0x1234_5678_9abc_def0;
|
||||||
|
dispatch_event(&control, &mut state, &queue, window_event(id, 0));
|
||||||
|
let (window_hi, window_lo) = split_id(id);
|
||||||
|
dispatch_event(
|
||||||
|
&control,
|
||||||
|
&mut state,
|
||||||
|
&queue,
|
||||||
|
ferese_shell_v1::Event::WindowGeometry {
|
||||||
|
window_hi,
|
||||||
|
window_lo,
|
||||||
|
x: -640.,
|
||||||
|
y: 20.,
|
||||||
|
width: 620.,
|
||||||
|
height: 480.,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Invalid geometry must not replace a valid rectangle.
|
||||||
|
dispatch_event(
|
||||||
|
&control,
|
||||||
|
&mut state,
|
||||||
|
&queue,
|
||||||
|
ferese_shell_v1::Event::WindowGeometry {
|
||||||
|
window_hi,
|
||||||
|
window_lo,
|
||||||
|
x: 0.,
|
||||||
|
y: 0.,
|
||||||
|
width: -1.,
|
||||||
|
height: 480.,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(control.poll().snapshot.is_none());
|
||||||
|
dispatch_event(
|
||||||
|
&control,
|
||||||
|
&mut state,
|
||||||
|
&queue,
|
||||||
|
ferese_shell_v1::Event::SnapshotEnd { serial: 8 },
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
control.poll().snapshot.unwrap().windows[0].geometry,
|
||||||
|
Some([-640., 20., 620., 480.])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn geometry_stream_uses_only_the_preceding_window() {
|
||||||
|
let (control, mut state, queue, _peer) = control_fixture();
|
||||||
|
dispatch_event(
|
||||||
|
&control,
|
||||||
|
&mut state,
|
||||||
|
&queue,
|
||||||
|
ferese_shell_v1::Event::SnapshotBegin { serial: 9 },
|
||||||
|
);
|
||||||
|
for id in 1..=512 {
|
||||||
|
dispatch_event(&control, &mut state, &queue, window_event(id, 0));
|
||||||
|
let (window_hi, window_lo) = split_id(id);
|
||||||
|
dispatch_event(
|
||||||
|
&control,
|
||||||
|
&mut state,
|
||||||
|
&queue,
|
||||||
|
ferese_shell_v1::Event::WindowGeometry {
|
||||||
|
window_hi,
|
||||||
|
window_lo,
|
||||||
|
x: id as f64,
|
||||||
|
y: 0.,
|
||||||
|
width: 50.,
|
||||||
|
height: 40.,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A malformed late event must not overwrite an earlier window.
|
||||||
|
dispatch_event(
|
||||||
|
&control,
|
||||||
|
&mut state,
|
||||||
|
&queue,
|
||||||
|
ferese_shell_v1::Event::WindowGeometry {
|
||||||
|
window_hi: 0,
|
||||||
|
window_lo: 1,
|
||||||
|
x: -1.,
|
||||||
|
y: 0.,
|
||||||
|
width: 50.,
|
||||||
|
height: 40.,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
dispatch_event(
|
||||||
|
&control,
|
||||||
|
&mut state,
|
||||||
|
&queue,
|
||||||
|
ferese_shell_v1::Event::SnapshotEnd { serial: 9 },
|
||||||
|
);
|
||||||
|
let snapshot = control.poll().snapshot.unwrap();
|
||||||
|
for window in snapshot.windows {
|
||||||
|
assert_eq!(window.geometry, Some([window.id as f64, 0., 50., 40.]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn known_window_flags_survive_unknown_future_bits() {
|
fn known_window_flags_survive_unknown_future_bits() {
|
||||||
use ferese_shell_v1::WindowState as Flags;
|
use ferese_shell_v1::WindowState as Flags;
|
||||||
@@ -575,14 +711,15 @@ mod tests {
|
|||||||
wayland_client::WEnum::Unknown(_)
|
wayland_client::WEnum::Unknown(_)
|
||||||
));
|
));
|
||||||
let cases = [
|
let cases = [
|
||||||
(known.bits(), [true, true, true, true]),
|
(known.bits(), [true, true, true, true, false]),
|
||||||
(known.bits() | future, [true, true, true, true]),
|
(known.bits() | future, [true, true, true, true, false]),
|
||||||
(Flags::Focused.bits() | future, [true, false, false, false]),
|
(Flags::Focused.bits() | future, [true, false, false, false, false]),
|
||||||
(Flags::Urgent.bits() | future, [false, true, false, false]),
|
(Flags::Urgent.bits() | future, [false, true, false, false, false]),
|
||||||
(Flags::Fullscreen.bits() | future, [false, false, true, false]),
|
(Flags::Fullscreen.bits() | future, [false, false, true, false, false]),
|
||||||
(Flags::Floating.bits() | future, [false, false, false, true]),
|
(Flags::Floating.bits() | future, [false, false, false, true, false]),
|
||||||
(future, [false; 4]),
|
(Flags::Maximized.bits() | future, [false, false, false, false, true]),
|
||||||
(0, [false; 4]),
|
(future, [false; 5]),
|
||||||
|
(0, [false; 5]),
|
||||||
];
|
];
|
||||||
dispatch_event(
|
dispatch_event(
|
||||||
&control,
|
&control,
|
||||||
@@ -602,7 +739,13 @@ mod tests {
|
|||||||
let snapshot = control.poll().snapshot.unwrap();
|
let snapshot = control.poll().snapshot.unwrap();
|
||||||
for (window, (_, expected)) in snapshot.windows.iter().zip(cases) {
|
for (window, (_, expected)) in snapshot.windows.iter().zip(cases) {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
[window.focused, window.urgent, window.fullscreen, window.floating],
|
[
|
||||||
|
window.focused,
|
||||||
|
window.urgent,
|
||||||
|
window.fullscreen,
|
||||||
|
window.floating,
|
||||||
|
window.maximized
|
||||||
|
],
|
||||||
expected
|
expected
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ impl FereseShell {
|
|||||||
let focus_visible = self.system_modal.as_ref().is_some_and(|modal| modal.focus_visible);
|
let focus_visible = self.system_modal.as_ref().is_some_and(|modal| modal.focus_visible);
|
||||||
let pending = model.pending();
|
let pending = model.pending();
|
||||||
let mut header = row![
|
let mut header = row![
|
||||||
text("Display mode").size(17),
|
self.render.text("Display mode").size(17),
|
||||||
cosmic::iced::widget::Space::new().width(Length::Fill)
|
cosmic::iced::widget::Space::new().width(Length::Fill)
|
||||||
]
|
]
|
||||||
.align_y(cosmic::iced::Alignment::Center)
|
.align_y(cosmic::iced::Alignment::Center)
|
||||||
@@ -401,7 +401,8 @@ impl FereseShell {
|
|||||||
.and_then(|inventory| inventory.profile.as_deref())
|
.and_then(|inventory| inventory.profile.as_deref())
|
||||||
{
|
{
|
||||||
header = header.push(
|
header = header.push(
|
||||||
text(format!("{profile} profile"))
|
self.render
|
||||||
|
.text(format!("{profile} profile"))
|
||||||
.size(12)
|
.size(12)
|
||||||
.class(theme::Text::Color(palette.muted)),
|
.class(theme::Text::Color(palette.muted)),
|
||||||
);
|
);
|
||||||
@@ -417,10 +418,12 @@ impl FereseShell {
|
|||||||
.is_some_and(|inventory| inventory.available(mode));
|
.is_some_and(|inventory| inventory.available(mode));
|
||||||
let card = cosmic::widget::column![
|
let card = cosmic::widget::column![
|
||||||
ferese_theme::icons::tinted(mode.icon(), 40, palette.text),
|
ferese_theme::icons::tinted(mode.icon(), 40, palette.text),
|
||||||
text(mode.label())
|
self.render
|
||||||
|
.text(mode.label())
|
||||||
.size(13)
|
.size(13)
|
||||||
.wrapping(cosmic::iced::widget::text::Wrapping::None),
|
.wrapping(cosmic::iced::widget::text::Wrapping::None),
|
||||||
text((index + 1).to_string())
|
self.render
|
||||||
|
.text((index + 1).to_string())
|
||||||
.size(11)
|
.size(11)
|
||||||
.class(theme::Text::Color(palette.muted)),
|
.class(theme::Text::Color(palette.muted)),
|
||||||
]
|
]
|
||||||
@@ -449,7 +452,10 @@ impl FereseShell {
|
|||||||
displays = displays.push(
|
displays = displays.push(
|
||||||
row![
|
row![
|
||||||
ferese_theme::icons::tinted(ferese_theme::icons::DISPLAY, 16, palette.muted),
|
ferese_theme::icons::tinted(ferese_theme::icons::DISPLAY, 16, palette.muted),
|
||||||
text(&display.label).size(12).class(theme::Text::Color(palette.muted))
|
self.render
|
||||||
|
.text(&display.label)
|
||||||
|
.size(12)
|
||||||
|
.class(theme::Text::Color(palette.muted))
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(cosmic::iced::Alignment::Center),
|
.align_y(cosmic::iced::Alignment::Center),
|
||||||
@@ -460,7 +466,7 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(error) = &model.error {
|
if let Some(error) = &model.error {
|
||||||
rows = rows.push(text(error).size(12));
|
rows = rows.push(self.render.text(error).size(12));
|
||||||
}
|
}
|
||||||
|
|
||||||
let hint = if model.busy {
|
let hint = if model.busy {
|
||||||
@@ -491,14 +497,14 @@ impl FereseShell {
|
|||||||
.inventory
|
.inventory
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|inventory| inventory.available(model.selected)));
|
.is_some_and(|inventory| inventory.available(model.selected)));
|
||||||
rows.push(text(hint).size(12).class(theme::Text::Color(palette.muted)))
|
rows.push(self.render.text(hint).size(12).class(theme::Text::Color(palette.muted)))
|
||||||
.push(
|
.push(
|
||||||
container(
|
container(
|
||||||
row![
|
row![
|
||||||
cosmic::iced::widget::Space::new().width(Length::Fill),
|
cosmic::iced::widget::Space::new().width(Length::Fill),
|
||||||
ferese_theme::controls::text_button(
|
ferese_theme::controls::text_button(
|
||||||
if pending { "Revert" } else { "Cancel" },
|
if pending { "Revert" } else { "Cancel" },
|
||||||
shell_font(),
|
self.render.font,
|
||||||
palette,
|
palette,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
@@ -512,7 +518,7 @@ impl FereseShell {
|
|||||||
.on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))),
|
.on_press_maybe((!model.busy).then_some(cosmic::Action::App(cancel))),
|
||||||
ferese_theme::controls::text_button(
|
ferese_theme::controls::text_button(
|
||||||
if pending { "Keep" } else { "Apply" },
|
if pending { "Keep" } else { "Apply" },
|
||||||
shell_font(),
|
self.render.font,
|
||||||
palette,
|
palette,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|||||||
+162
-67
@@ -15,9 +15,12 @@ mod motion;
|
|||||||
mod notification_ui;
|
mod notification_ui;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod panel;
|
mod panel;
|
||||||
|
mod panel_hub;
|
||||||
mod panel_layout;
|
mod panel_layout;
|
||||||
|
mod panel_runtime;
|
||||||
mod recording;
|
mod recording;
|
||||||
mod renderer;
|
mod renderer;
|
||||||
|
mod rendering;
|
||||||
mod status;
|
mod status;
|
||||||
mod status_ui;
|
mod status_ui;
|
||||||
mod system_modal;
|
mod system_modal;
|
||||||
@@ -57,25 +60,10 @@ const APP_ID: &str = "dev.ferese.Shell";
|
|||||||
// that compatibility path gated; current backends use Window::Opened and
|
// that compatibility path gated; current backends use Window::Opened and
|
||||||
// window::run to retrieve handles without updates at the display refresh rate.
|
// window::run to retrieve handles without updates at the display refresh rate.
|
||||||
static EFFECT_FRAME_PENDING: AtomicBool = AtomicBool::new(true);
|
static EFFECT_FRAME_PENDING: AtomicBool = AtomicBool::new(true);
|
||||||
static SHELL_FONT: std::sync::OnceLock<std::sync::RwLock<cosmic::font::Font>> = std::sync::OnceLock::new();
|
|
||||||
|
|
||||||
fn configured_font(family: Option<&str>) -> cosmic::font::Font {
|
fn configured_font(family: Option<&str>) -> cosmic::font::Font {
|
||||||
ferese_theme::font(family)
|
ferese_theme::font(family)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shell_font() -> cosmic::font::Font {
|
|
||||||
*SHELL_FONT
|
|
||||||
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
|
|
||||||
.read()
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn text<'a>(
|
|
||||||
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
|
|
||||||
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
|
|
||||||
ferese_theme::text(content, shell_font())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct BarMetrics {
|
struct BarMetrics {
|
||||||
height: f32,
|
height: f32,
|
||||||
@@ -83,6 +71,7 @@ struct BarMetrics {
|
|||||||
icon_size: u16,
|
icon_size: u16,
|
||||||
overview_icon_size: u16,
|
overview_icon_size: u16,
|
||||||
control_height: f32,
|
control_height: f32,
|
||||||
|
#[cfg(test)]
|
||||||
group_height: f32,
|
group_height: f32,
|
||||||
group_item_height: f32,
|
group_item_height: f32,
|
||||||
}
|
}
|
||||||
@@ -103,6 +92,7 @@ impl From<panel::PanelGeometry> for BarMetrics {
|
|||||||
icon_size: 20,
|
icon_size: 20,
|
||||||
overview_icon_size: 20,
|
overview_icon_size: 20,
|
||||||
control_height,
|
control_height,
|
||||||
|
#[cfg(test)]
|
||||||
group_height,
|
group_height,
|
||||||
group_item_height: (group_height - 4.0).max(0.0),
|
group_item_height: (group_height - 4.0).max(0.0),
|
||||||
}
|
}
|
||||||
@@ -116,7 +106,6 @@ fn main() -> cosmic::iced::Result {
|
|||||||
});
|
});
|
||||||
renderer::configure_shell();
|
renderer::configure_shell();
|
||||||
|
|
||||||
motion::configure(config.animations, config.theme.material_radius);
|
|
||||||
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
|
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
|
||||||
if compositor_wallpaper {
|
if compositor_wallpaper {
|
||||||
// The compositor owns one GPU image, shared across output renderers;
|
// The compositor owns one GPU image, shared across output renderers;
|
||||||
@@ -124,7 +113,6 @@ fn main() -> cosmic::iced::Result {
|
|||||||
config.wallpaper.path = None;
|
config.wallpaper.path = None;
|
||||||
}
|
}
|
||||||
let shell_font = configured_font(config.font_family.as_deref());
|
let shell_font = configured_font(config.font_family.as_deref());
|
||||||
let _ = SHELL_FONT.set(std::sync::RwLock::new(shell_font));
|
|
||||||
// Decode alongside toolkit/GPU initialization, never during a UI draw.
|
// Decode alongside toolkit/GPU initialization, never during a UI draw.
|
||||||
let wallpaper = config.wallpaper.path.clone().map(|path| {
|
let wallpaper = config.wallpaper.path.clone().map(|path| {
|
||||||
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
|
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
|
||||||
@@ -153,6 +141,7 @@ type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver<Result<im
|
|||||||
struct FereseShell {
|
struct FereseShell {
|
||||||
core: Core,
|
core: Core,
|
||||||
config: ShellConfig,
|
config: ShellConfig,
|
||||||
|
render: rendering::Rendering,
|
||||||
wallpaper: Option<image::Handle>,
|
wallpaper: Option<image::Handle>,
|
||||||
control: Option<ShellControl>,
|
control: Option<ShellControl>,
|
||||||
snapshot: ShellSnapshot,
|
snapshot: ShellSnapshot,
|
||||||
@@ -179,6 +168,8 @@ struct FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct OutputSurfaces {
|
struct OutputSurfaces {
|
||||||
|
runtime: panel_runtime::Runtime,
|
||||||
|
scale: f64,
|
||||||
output: wl_output::WlOutput,
|
output: wl_output::WlOutput,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
bar: window::Id,
|
bar: window::Id,
|
||||||
@@ -193,7 +184,20 @@ struct OutputSurfaces {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
enum Message {
|
enum Message {
|
||||||
BarRegionsChanged(window::Id, Vec<[f32; 5]>),
|
HubOpen(window::Id, status_ui::Menu),
|
||||||
|
HubClose(window::Id),
|
||||||
|
HubWorkspaceScroll(window::Id, cosmic::iced::mouse::ScrollDelta),
|
||||||
|
HubOverview(window::Id),
|
||||||
|
HubWorkspaceHover(window::Id, Option<u64>),
|
||||||
|
HubWorkspaceSelect(window::Id, u64),
|
||||||
|
HubWorkspaceMove(window::Id, u64, u64),
|
||||||
|
HubWorkspaceFocus(window::Id, Option<u64>, Option<cosmic::iced::keyboard::key::Named>),
|
||||||
|
HubToastExpired(window::Id, Instant),
|
||||||
|
HubWorkspaceFeedbackExpired(window::Id, Instant),
|
||||||
|
HubIgnore,
|
||||||
|
PanelScale(window::Id, f32),
|
||||||
|
AdaptiveDeadline(window::Id, u64),
|
||||||
|
PanelSurfaceChanged(window::Id, Box<ferese_panel::scene::Scene>),
|
||||||
PanelResolved(window::Id, panel_layout::Resolution),
|
PanelResolved(window::Id, panel_layout::Resolution),
|
||||||
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
||||||
ThemeMode(ferese_config::theme::Mode),
|
ThemeMode(ferese_config::theme::Mode),
|
||||||
@@ -279,7 +283,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) {
|
fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) {
|
||||||
let app = Self {
|
let app = Self {
|
||||||
core,
|
core,
|
||||||
notifications: notifications::Center::new(config.notifications.clone()),
|
notifications: notifications::Center::new(config.notifications.clone(), config.animations),
|
||||||
notification_surface: None,
|
notification_surface: None,
|
||||||
status_service: status::Service::start(config.status.settings_command.clone()),
|
status_service: status::Service::start(config.status.settings_command.clone()),
|
||||||
status: status::Snapshot::default(),
|
status: status::Snapshot::default(),
|
||||||
@@ -295,6 +299,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
guide_load: Default::default(),
|
guide_load: Default::default(),
|
||||||
guide_attempts: 0,
|
guide_attempts: 0,
|
||||||
pending_power: None,
|
pending_power: None,
|
||||||
|
render: rendering::Rendering::new(&config),
|
||||||
config,
|
config,
|
||||||
wallpaper: None,
|
wallpaper: None,
|
||||||
control: ShellControl::connect()
|
control: ShellControl::connect()
|
||||||
@@ -339,8 +344,12 @@ impl cosmic::Application for FereseShell {
|
|||||||
.load(Ordering::Relaxed)
|
.load(Ordering::Relaxed)
|
||||||
.then_some(Message::Event(event, id)),
|
.then_some(Message::Event(event, id)),
|
||||||
Event::Keyboard(_)
|
Event::Keyboard(_)
|
||||||
| Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_) | cosmic::iced::mouse::Event::CursorLeft)
|
| Event::Mouse(
|
||||||
| Event::Window(window::Event::Opened { .. } | window::Event::Closed)
|
cosmic::iced::mouse::Event::ButtonPressed(_)
|
||||||
|
| cosmic::iced::mouse::Event::CursorLeft
|
||||||
|
| cosmic::iced::mouse::Event::CursorEntered,
|
||||||
|
)
|
||||||
|
| Event::Window(window::Event::Opened { .. } | window::Event::Closed | window::Event::Rescaled(_))
|
||||||
| Event::PlatformSpecific(PlatformSpecific::Wayland(
|
| Event::PlatformSpecific(PlatformSpecific::Wayland(
|
||||||
wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..),
|
wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..),
|
||||||
)) => Some(Message::Event(event, id)),
|
)) => Some(Message::Event(event, id)),
|
||||||
@@ -358,6 +367,17 @@ impl cosmic::Application for FereseShell {
|
|||||||
|
|
||||||
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
|
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
|
||||||
match message {
|
match message {
|
||||||
|
Message::HubOpen(id, kind) => self.open_hub(id, kind),
|
||||||
|
Message::HubClose(id) => self.close_hub(id),
|
||||||
|
Message::HubWorkspaceScroll(id, delta) => self.scroll_hub_workspaces(id, delta),
|
||||||
|
Message::HubOverview(id) => self.open_hub_overview(id),
|
||||||
|
Message::HubWorkspaceHover(id, workspace) => self.hub_workspace_hover(id, workspace),
|
||||||
|
Message::HubWorkspaceSelect(id, workspace) => self.hub_workspace_select(id, workspace),
|
||||||
|
Message::HubWorkspaceMove(id, window, workspace) => self.hub_workspace_move(id, window, workspace),
|
||||||
|
Message::HubWorkspaceFocus(id, focused, key) => self.hub_workspace_focus(id, focused, key),
|
||||||
|
Message::HubToastExpired(id, revision) => self.hub_toast_expired(id, revision),
|
||||||
|
Message::HubWorkspaceFeedbackExpired(id, revision) => self.hub_workspace_feedback_expired(id, revision),
|
||||||
|
Message::HubIgnore => Task::none(),
|
||||||
Message::PanelResolved(id, resolution) => {
|
Message::PanelResolved(id, resolution) => {
|
||||||
let allocation_changed = self
|
let allocation_changed = self
|
||||||
.outputs
|
.outputs
|
||||||
@@ -390,7 +410,12 @@ impl cosmic::Application for FereseShell {
|
|||||||
Task::batch([margin, tooltip])
|
Task::batch([margin, tooltip])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::BarRegionsChanged(id, regions) => {
|
Message::PanelSurfaceChanged(id, scene) => {
|
||||||
|
let regions = scene
|
||||||
|
.input_rectangles(1.)
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32, 0.])
|
||||||
|
.collect();
|
||||||
let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) else {
|
let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
@@ -398,7 +423,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
output.bar_regions = regions;
|
output.bar_regions = regions;
|
||||||
set_input_zone(
|
set_input_zone(
|
||||||
id,
|
id,
|
||||||
bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions),
|
bar::input_region(ferese_panel::PanelSurface::None, output.hidden, &output.bar_regions),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,7 +540,7 @@ impl cosmic::Application for FereseShell {
|
|||||||
}
|
}
|
||||||
Message::StopRecording => {
|
Message::StopRecording => {
|
||||||
self.recorder.stop();
|
self.recorder.stop();
|
||||||
Task::none()
|
self.sync_hub_activity()
|
||||||
}
|
}
|
||||||
Message::StatusUpdated(update) => {
|
Message::StatusUpdated(update) => {
|
||||||
if update.generation >= self.status_service.generation {
|
if update.generation >= self.status_service.generation {
|
||||||
@@ -530,9 +555,14 @@ impl cosmic::Application for FereseShell {
|
|||||||
dnd: self.notifications.dnd,
|
dnd: self.notifications.dnd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.sync_notification_surface()
|
Task::batch([self.sync_hub_activity(), self.sync_notification_surface()])
|
||||||
}
|
}
|
||||||
Message::NotificationEvent(event) => {
|
Message::NotificationEvent(event) => {
|
||||||
|
let notice = if let notifications::Event::Notice(notice) = &event {
|
||||||
|
Some(notice.id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
self.notifications.handle_event(event);
|
self.notifications.handle_event(event);
|
||||||
if self.notifications.ready {
|
if self.notifications.ready {
|
||||||
self.status.notifications = Some(status::Notifications {
|
self.status.notifications = Some(status::Notifications {
|
||||||
@@ -540,11 +570,12 @@ impl cosmic::Application for FereseShell {
|
|||||||
dnd: self.notifications.dnd,
|
dnd: self.notifications.dnd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.sync_notification_surface()
|
let hub = notice.map_or_else(Task::none, |notice| self.hub_notice(notice));
|
||||||
|
Task::batch([hub, self.sync_hub_notices(), self.sync_notification_surface()])
|
||||||
}
|
}
|
||||||
Message::NotificationTick => {
|
Message::NotificationTick => {
|
||||||
self.notifications.tick();
|
self.notifications.tick();
|
||||||
self.sync_notification_surface()
|
Task::batch([self.sync_hub_notices(), self.sync_notification_surface()])
|
||||||
}
|
}
|
||||||
Message::DismissNotification(id) => {
|
Message::DismissNotification(id) => {
|
||||||
self.notifications.close(id, 2);
|
self.notifications.close(id, 2);
|
||||||
@@ -715,10 +746,22 @@ impl cosmic::Application for FereseShell {
|
|||||||
}
|
}
|
||||||
Message::RecorderEvent(event) => {
|
Message::RecorderEvent(event) => {
|
||||||
self.recorder.handle(event);
|
self.recorder.handle(event);
|
||||||
Task::none()
|
self.sync_hub_activity()
|
||||||
}
|
}
|
||||||
Message::RecorderElapsed => Task::none(),
|
Message::RecorderElapsed => Task::none(),
|
||||||
|
Message::PanelScale(id, scale) => {
|
||||||
|
if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id)
|
||||||
|
&& scale.is_finite()
|
||||||
|
&& scale > 0.
|
||||||
|
&& scale <= 16.
|
||||||
|
{
|
||||||
|
entry.scale = f64::from(scale);
|
||||||
|
}
|
||||||
|
self.sync_hub_workspaces()
|
||||||
|
}
|
||||||
|
Message::AdaptiveDeadline(id, ticket) => self.adaptive_deadline(id, ticket),
|
||||||
Message::ControlReady => {
|
Message::ControlReady => {
|
||||||
|
let overview_was_active = self.overview_active;
|
||||||
let mut reload_task = Task::none();
|
let mut reload_task = Task::none();
|
||||||
if let Some(control) = &self.control {
|
if let Some(control) = &self.control {
|
||||||
let poll = control.poll();
|
let poll = control.poll();
|
||||||
@@ -740,9 +783,15 @@ impl cosmic::Application for FereseShell {
|
|||||||
|
|
||||||
if let Some(snapshot) = poll.snapshot {
|
if let Some(snapshot) = poll.snapshot {
|
||||||
self.snapshot = snapshot;
|
self.snapshot = snapshot;
|
||||||
let mut tasks = vec![reload_task, self.update_workspace_ui()];
|
let feedback = self.sync_hub_workspace_feedback(overview_was_active || self.overview_active);
|
||||||
|
let mut tasks = vec![
|
||||||
|
reload_task,
|
||||||
|
self.update_workspace_ui(),
|
||||||
|
feedback,
|
||||||
|
self.refresh_adaptive_panels(),
|
||||||
|
];
|
||||||
let mut visibility_changed = false;
|
let mut visibility_changed = false;
|
||||||
|
let mut suspended_hubs = Vec::new();
|
||||||
for entry in &mut self.outputs {
|
for entry in &mut self.outputs {
|
||||||
let output = self
|
let output = self
|
||||||
.snapshot
|
.snapshot
|
||||||
@@ -756,7 +805,14 @@ impl cosmic::Application for FereseShell {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.push(set_exclusive_zone(
|
||||||
|
entry.bar,
|
||||||
|
entry.runtime.panel(&self.config.panels[0]).exclusive_zone(hidden),
|
||||||
|
));
|
||||||
entry.hidden = hidden;
|
entry.hidden = hidden;
|
||||||
|
if hidden && entry.runtime.hub.is_some() {
|
||||||
|
suspended_hubs.push(entry.bar);
|
||||||
|
}
|
||||||
visibility_changed = true;
|
visibility_changed = true;
|
||||||
if let Some(effects) = &entry.effects
|
if let Some(effects) = &entry.effects
|
||||||
&& let Err(error) = effects.set_visible(!hidden)
|
&& let Err(error) = effects.set_visible(!hidden)
|
||||||
@@ -765,9 +821,12 @@ impl cosmic::Application for FereseShell {
|
|||||||
}
|
}
|
||||||
tasks.push(set_input_zone(
|
tasks.push(set_input_zone(
|
||||||
entry.bar,
|
entry.bar,
|
||||||
bar::input_region(self.config.panels[0].surface, hidden, &entry.bar_regions),
|
bar::input_region(ferese_panel::PanelSurface::None, hidden, &entry.bar_regions),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
for id in suspended_hubs {
|
||||||
|
tasks.push(self.suspend_hub(id));
|
||||||
|
}
|
||||||
if self
|
if self
|
||||||
.menu
|
.menu
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -826,11 +885,11 @@ impl cosmic::Application for FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self) -> Element<'_, Self::Message> {
|
fn view(&self) -> Element<'_, Self::Message> {
|
||||||
text("").into()
|
self.render.text("").into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> {
|
fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> {
|
||||||
text("").into()
|
self.render.text("").into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,13 +913,7 @@ impl FereseShell {
|
|||||||
config.wallpaper.path = None;
|
config.wallpaper.path = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.config.font_family != config.font_family {
|
self.render = rendering::Rendering::new(&config);
|
||||||
*SHELL_FONT
|
|
||||||
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
|
|
||||||
.write()
|
|
||||||
.unwrap() = configured_font(config.font_family.as_deref());
|
|
||||||
}
|
|
||||||
motion::configure(config.animations, config.theme.material_radius);
|
|
||||||
|
|
||||||
if self.config.animations != config.animations {
|
if self.config.animations != config.animations {
|
||||||
self.notifications.update_motion_settings(config.animations);
|
self.notifications.update_motion_settings(config.animations);
|
||||||
@@ -877,14 +930,25 @@ impl FereseShell {
|
|||||||
let composition_changed = !self.config.panels[0].same_composition(&config.panels[0]);
|
let composition_changed = !self.config.panels[0].same_composition(&config.panels[0]);
|
||||||
let edge_changed = self.config.panels[0].edge != config.panels[0].edge;
|
let edge_changed = self.config.panels[0].edge != config.panels[0].edge;
|
||||||
let old = self.config.theme;
|
let old = self.config.theme;
|
||||||
let old_geometry = self.config.panels[0].geometry;
|
let old_geometry = self.config.panels[0].resolved_geometry();
|
||||||
let theme = config.theme;
|
let theme = config.theme;
|
||||||
let geometry = config.panels[0].geometry;
|
let geometry = config.panels[0].resolved_geometry();
|
||||||
let geometry_changed = edge_changed || old_geometry != geometry;
|
let geometry_changed = edge_changed || old_geometry != geometry;
|
||||||
self.notifications.configure(config.notifications.clone());
|
self.notifications.configure(config.notifications.clone());
|
||||||
self.config = config;
|
self.config = config;
|
||||||
|
if composition_changed || geometry_changed {
|
||||||
|
for entry in &mut self.outputs {
|
||||||
|
entry
|
||||||
|
.runtime
|
||||||
|
.reset(&self.config.panels[0], &self.snapshot, entry.name.as_deref());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for entry in &mut self.outputs {
|
||||||
|
entry.runtime.update_appearance(&self.config.panels[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
let workspace_task = self.update_workspace_ui();
|
let workspace_task = self.update_workspace_ui();
|
||||||
let mut tasks = vec![workspace_task];
|
let mut tasks = vec![workspace_task, self.sync_hub_activity()];
|
||||||
|
|
||||||
if composition_changed || geometry_changed {
|
if composition_changed || geometry_changed {
|
||||||
tasks.push(self.destroy_menu());
|
tasks.push(self.destroy_menu());
|
||||||
@@ -893,7 +957,7 @@ impl FereseShell {
|
|||||||
// publishes its replacement through PanelResolved.
|
// publishes its replacement through PanelResolved.
|
||||||
tasks.push(set_input_zone(
|
tasks.push(set_input_zone(
|
||||||
output.bar,
|
output.bar,
|
||||||
bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions),
|
bar::input_region(ferese_panel::PanelSurface::None, output.hidden, &output.bar_regions),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,21 +973,40 @@ impl FereseShell {
|
|||||||
tasks.push(cosmic::command::set_theme(theme.palette().native_theme()));
|
tasks.push(cosmic::command::set_theme(theme.palette().native_theme()));
|
||||||
}
|
}
|
||||||
|
|
||||||
for entry in self.outputs.iter().filter(|_| geometry_changed) {
|
for entry in self.outputs.iter().filter(|_| geometry_changed || composition_changed) {
|
||||||
|
let panel = entry.runtime.panel(&self.config.panels[0]);
|
||||||
|
let geometry = panel.resolved_geometry();
|
||||||
let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
|
let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
|
||||||
tasks.push(set_anchor(entry.bar, anchor | Anchor::LEFT | Anchor::RIGHT));
|
let hub_size = entry.runtime.hub.as_ref().map(|hub| hub.borrow().allocation);
|
||||||
tasks.push(set_size(entry.bar, None, Some(geometry.height.round() as u32)));
|
tasks.push(set_anchor(
|
||||||
tasks.push(set_exclusive_zone(
|
|
||||||
entry.bar,
|
entry.bar,
|
||||||
(geometry.height.round() as i32).saturating_add(geometry.window_clearance),
|
if hub_size.is_some() {
|
||||||
|
anchor
|
||||||
|
} else {
|
||||||
|
anchor | Anchor::LEFT | Anchor::RIGHT
|
||||||
|
},
|
||||||
));
|
));
|
||||||
|
let (width, height) = hub_size.map_or((None, Some(geometry.height.round() as u32)), |size| {
|
||||||
|
(Some(size[0]), Some(size[1]))
|
||||||
|
});
|
||||||
|
tasks.push(set_size(entry.bar, width, height));
|
||||||
|
tasks.push(
|
||||||
|
cosmic::iced::platform_specific::shell::commands::layer_surface::set_keyboard_interactivity(
|
||||||
|
entry.bar,
|
||||||
|
KeyboardInteractivity::None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
tasks.push(set_exclusive_zone(entry.bar, panel.exclusive_zone(entry.hidden)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let bars: Vec<_> = self.outputs.iter().map(|entry| entry.bar).collect();
|
let bars: Vec<_> = self.outputs.iter().map(|entry| entry.bar).collect();
|
||||||
for bar in bars {
|
for bar in bars {
|
||||||
tasks.push(self.update_panel_margin(bar, edge_changed || old_geometry.edge_margin != geometry.edge_margin));
|
tasks.push(self.update_panel_margin(
|
||||||
|
bar,
|
||||||
|
composition_changed || edge_changed || old_geometry.edge_margin != geometry.edge_margin,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if geometry_changed {
|
if geometry_changed || composition_changed {
|
||||||
tasks.push(self.dismiss_workspace_tooltip());
|
tasks.push(self.dismiss_workspace_tooltip());
|
||||||
if let Some(surface) = self.notification_surface.take() {
|
if let Some(surface) = self.notification_surface.take() {
|
||||||
tasks.push(destroy_layer_surface(surface.id));
|
tasks.push(destroy_layer_surface(surface.id));
|
||||||
@@ -934,20 +1017,26 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update_panel_margin(&mut self, bar: window::Id, force: bool) -> Task<Message> {
|
fn update_panel_margin(&mut self, bar: window::Id, force: bool) -> Task<Message> {
|
||||||
let geometry = self.config.panels[0].geometry;
|
let panel = self.panel_for_bar(bar);
|
||||||
|
let is_hub = panel.style == ferese_panel::PanelStyle::Morph;
|
||||||
|
let geometry = panel.resolved_geometry();
|
||||||
let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == bar) else {
|
let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == bar) else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
let margin = entry.size.zip(entry.panel_resolution.as_ref()).map_or(
|
let margin = if is_hub {
|
||||||
entry.bar_margin_horizontal,
|
0
|
||||||
|((width, _), resolution)| {
|
} else {
|
||||||
resolution.side_margin(
|
entry.size.zip(entry.panel_resolution.as_ref()).map_or(
|
||||||
geometry.side_margins,
|
entry.bar_margin_horizontal,
|
||||||
width,
|
|((width, _), resolution)| {
|
||||||
f32::from(geometry.inner_padding.round() as u16),
|
resolution.side_margin(
|
||||||
)
|
geometry.side_margins,
|
||||||
},
|
width,
|
||||||
);
|
f32::from(geometry.inner_padding.round() as u16),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
if entry.bar_margin_horizontal == margin && !force {
|
if entry.bar_margin_horizontal == margin && !force {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
@@ -982,7 +1071,7 @@ mod tests {
|
|||||||
let output = wl_output::WlOutput::inert(connection.backend().downgrade());
|
let output = wl_output::WlOutput::inert(connection.backend().downgrade());
|
||||||
FereseShell {
|
FereseShell {
|
||||||
core: Core::default(),
|
core: Core::default(),
|
||||||
notifications: notifications::Center::new(config.notifications.clone()),
|
notifications: notifications::Center::new(config.notifications.clone(), config.animations),
|
||||||
notification_surface: None,
|
notification_surface: None,
|
||||||
status_service: status::Service::start(None),
|
status_service: status::Service::start(None),
|
||||||
status: Default::default(),
|
status: Default::default(),
|
||||||
@@ -998,6 +1087,7 @@ mod tests {
|
|||||||
guide_load: Default::default(),
|
guide_load: Default::default(),
|
||||||
guide_attempts: 0,
|
guide_attempts: 0,
|
||||||
pending_power: None,
|
pending_power: None,
|
||||||
|
render: rendering::Rendering::new(&config),
|
||||||
config,
|
config,
|
||||||
wallpaper: None,
|
wallpaper: None,
|
||||||
control: None,
|
control: None,
|
||||||
@@ -1006,6 +1096,8 @@ mod tests {
|
|||||||
overview_active: false,
|
overview_active: false,
|
||||||
clock: String::new(),
|
clock: String::new(),
|
||||||
outputs: vec![OutputSurfaces {
|
outputs: vec![OutputSurfaces {
|
||||||
|
runtime: panel_runtime::Runtime::new(&panel::Panel::default(), &ShellSnapshot::default(), None),
|
||||||
|
scale: 1.,
|
||||||
output,
|
output,
|
||||||
name: Some("test".into()),
|
name: Some("test".into()),
|
||||||
bar: window::Id::unique(),
|
bar: window::Id::unique(),
|
||||||
@@ -1025,7 +1117,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn panel_placement_uses_only_the_selected_edge_for_anchor_and_margin() {
|
fn panel_placement_uses_only_the_selected_edge_for_anchor_and_margin() {
|
||||||
use ferese_config::panel::Edge;
|
use ferese_panel::Edge;
|
||||||
for gap in [0, 12] {
|
for gap in [0, 12] {
|
||||||
let (anchor, margin) = surfaces::panel_placement(Edge::Top, gap, 32);
|
let (anchor, margin) = surfaces::panel_placement(Edge::Top, gap, 32);
|
||||||
assert_eq!(anchor, Anchor::TOP);
|
assert_eq!(anchor, Anchor::TOP);
|
||||||
@@ -1039,7 +1131,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cosmetic_reloads_preserve_measured_margins_and_the_open_popover() {
|
fn cosmetic_reloads_preserve_measured_margins_and_the_open_popover() {
|
||||||
let mut shell = shell_with_measured_panel();
|
let mut shell = shell_with_measured_panel();
|
||||||
shell.config.panels[0].geometry.side_margins = 240;
|
shell.config.panels[0].style = ferese_panel::PanelStyle::Floating;
|
||||||
|
shell.config.panels[0].geometry.side_margins = Some(240);
|
||||||
drop(shell.update_panel_margin(shell.outputs[0].bar, false));
|
drop(shell.update_panel_margin(shell.outputs[0].bar, false));
|
||||||
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240);
|
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240);
|
||||||
let popup = window::Id::unique();
|
let popup = window::Id::unique();
|
||||||
@@ -1061,7 +1154,6 @@ mod tests {
|
|||||||
("background_opacity", serde_json::json!(0.5)),
|
("background_opacity", serde_json::json!(0.5)),
|
||||||
("corner_radius", serde_json::json!("4px 8px")),
|
("corner_radius", serde_json::json!("4px 8px")),
|
||||||
("border", serde_json::json!(true)),
|
("border", serde_json::json!(true)),
|
||||||
("group_surface", serde_json::json!("inset")),
|
|
||||||
] {
|
] {
|
||||||
let edit = field;
|
let edit = field;
|
||||||
let mut document = ferese_config::Document::parse("").unwrap();
|
let mut document = ferese_config::Document::parse("").unwrap();
|
||||||
@@ -1205,7 +1297,8 @@ mod tests {
|
|||||||
fn an_empty_measured_panel_is_not_an_unmeasured_panel() {
|
fn an_empty_measured_panel_is_not_an_unmeasured_panel() {
|
||||||
let mut shell = shell_with_measured_panel();
|
let mut shell = shell_with_measured_panel();
|
||||||
let bar = shell.outputs[0].bar;
|
let bar = shell.outputs[0].bar;
|
||||||
shell.config.panels[0].geometry.side_margins = 240;
|
shell.config.panels[0].style = ferese_panel::PanelStyle::Floating;
|
||||||
|
shell.config.panels[0].geometry.side_margins = Some(240);
|
||||||
shell.outputs[0].panel_resolution = None;
|
shell.outputs[0].panel_resolution = None;
|
||||||
drop(shell.update_panel_margin(bar, false));
|
drop(shell.update_panel_margin(bar, false));
|
||||||
assert_eq!(shell.outputs[0].bar_margin_horizontal, 0);
|
assert_eq!(shell.outputs[0].bar_margin_horizontal, 0);
|
||||||
@@ -1365,7 +1458,9 @@ mod tests {
|
|||||||
focused: true,
|
focused: true,
|
||||||
urgent: false,
|
urgent: false,
|
||||||
fullscreen: true,
|
fullscreen: true,
|
||||||
|
maximized: false,
|
||||||
floating: false,
|
floating: false,
|
||||||
|
geometry: None,
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,10 +138,17 @@ impl FereseShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn media_tick_subscription(&self) -> Subscription<()> {
|
pub fn media_tick_subscription(&self) -> Subscription<()> {
|
||||||
let visible = self
|
let hub_visible = self.outputs.iter().any(|entry| {
|
||||||
.menu
|
!entry.hidden
|
||||||
.as_ref()
|
&& entry.runtime.hub.as_ref().is_some_and(|hub| {
|
||||||
.is_some_and(|menu| menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing())
|
hub.borrow().hub.mode() == ferese_panel::morph::Mode::Expanded(ferese_panel::morph::Content::Media)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let visible = (hub_visible
|
||||||
|
|| self
|
||||||
|
.menu
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|menu| menu.kind == crate::status_ui::Menu::Media && !menu.motion.closing()))
|
||||||
&& self.outputs.iter().any(|output| !output.hidden);
|
&& self.outputs.iter().any(|output| !output.hidden);
|
||||||
let on_battery = self
|
let on_battery = self
|
||||||
.status
|
.status
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mod frames;
|
mod frames;
|
||||||
pub(crate) use frames::frame_driven;
|
pub(crate) use frames::{ContentMeasurement, frame_driven, geometry_driven};
|
||||||
|
|
||||||
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
|
use cosmic::iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget, layout, mouse, renderer, widget};
|
||||||
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
|
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
|
||||||
@@ -10,29 +10,6 @@ pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[f32; 5]>>>;
|
|||||||
pub(crate) use ferese_animation::MotionSettings as Settings;
|
pub(crate) use ferese_animation::MotionSettings as Settings;
|
||||||
use ferese_animation::{AnimatedValue, SpringConfig};
|
use ferese_animation::{AnimatedValue, SpringConfig};
|
||||||
|
|
||||||
static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new();
|
|
||||||
static SHELL_RADIUS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(14.0_f32.to_bits());
|
|
||||||
|
|
||||||
/// All shell shapes share one radius, capped to fit smaller controls.
|
|
||||||
pub(crate) fn radius(limit: f32) -> f32 {
|
|
||||||
f32::from_bits(SHELL_RADIUS.load(std::sync::atomic::Ordering::Relaxed)).min(limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn configure(settings: Settings, radius: f32) {
|
|
||||||
SHELL_RADIUS.store(radius.to_bits(), std::sync::atomic::Ordering::Relaxed);
|
|
||||||
*SETTINGS
|
|
||||||
.get_or_init(|| std::sync::RwLock::new(Settings::default()))
|
|
||||||
.write()
|
|
||||||
.unwrap() = settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn settings() -> Settings {
|
|
||||||
*SETTINGS
|
|
||||||
.get_or_init(|| std::sync::RwLock::new(Settings::default()))
|
|
||||||
.read()
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct PopupMotion {
|
pub(crate) struct PopupMotion {
|
||||||
value: AnimatedValue,
|
value: AnimatedValue,
|
||||||
started: Option<std::time::Instant>,
|
started: Option<std::time::Instant>,
|
||||||
@@ -129,18 +106,20 @@ impl PopupMotion {
|
|||||||
/// Animate paint only; the original button retains its layout, hit target,
|
/// Animate paint only; the original button retains its layout, hit target,
|
||||||
/// keyboard handling, pressed state, and accessibility identity.
|
/// keyboard handling, pressed state, and accessibility identity.
|
||||||
pub(crate) fn button<'a, M: Clone + 'a>(
|
pub(crate) fn button<'a, M: Clone + 'a>(
|
||||||
|
render: crate::rendering::Rendering,
|
||||||
button: cosmic::widget::button::Button<'a, M>,
|
button: cosmic::widget::button::Button<'a, M>,
|
||||||
foreground: cosmic::iced::Color,
|
foreground: cosmic::iced::Color,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
opacity: f32,
|
opacity: f32,
|
||||||
) -> Element<'a, M> {
|
) -> Element<'a, M> {
|
||||||
let paint = move |progress: f32, pressed: bool| {
|
let paint = move |progress: f32, pressed: bool| {
|
||||||
ferese_theme::controls::shell_button(foreground, selected, opacity, radius(14.), progress, pressed)
|
ferese_theme::controls::shell_button(foreground, selected, opacity, render.radius(14.), progress, pressed)
|
||||||
};
|
};
|
||||||
painted_button(button, paint)
|
painted_button(render, button, paint)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn circular_button<'a, M: Clone + 'a>(
|
pub(crate) fn circular_button<'a, M: Clone + 'a>(
|
||||||
|
render: crate::rendering::Rendering,
|
||||||
button: cosmic::widget::button::Button<'a, M>,
|
button: cosmic::widget::button::Button<'a, M>,
|
||||||
foreground: cosmic::iced::Color,
|
foreground: cosmic::iced::Color,
|
||||||
fill: Option<cosmic::iced::Color>,
|
fill: Option<cosmic::iced::Color>,
|
||||||
@@ -163,10 +142,11 @@ pub(crate) fn circular_button<'a, M: Clone + 'a>(
|
|||||||
|
|
||||||
style
|
style
|
||||||
};
|
};
|
||||||
painted_button(button, paint)
|
painted_button(render, button, paint)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn painted_button<'a, M: Clone + 'a>(
|
fn painted_button<'a, M: Clone + 'a>(
|
||||||
|
render: crate::rendering::Rendering,
|
||||||
button: cosmic::widget::button::Button<'a, M>,
|
button: cosmic::widget::button::Button<'a, M>,
|
||||||
paint: impl Fn(f32, bool) -> cosmic::widget::button::Style + Copy + 'static,
|
paint: impl Fn(f32, bool) -> cosmic::widget::button::Style + Copy + 'static,
|
||||||
) -> Element<'a, M> {
|
) -> Element<'a, M> {
|
||||||
@@ -184,10 +164,7 @@ fn painted_button<'a, M: Clone + 'a>(
|
|||||||
Element::new(Hover {
|
Element::new(Hover {
|
||||||
content,
|
content,
|
||||||
progress,
|
progress,
|
||||||
duration: SETTINGS
|
duration: render.motion.duration(120.0),
|
||||||
.get()
|
|
||||||
.map_or_else(Settings::default, |settings| *settings.read().unwrap())
|
|
||||||
.duration(120.0),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +335,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> {
|
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32, regions: Regions, radius: f32) -> Element<'a, M> {
|
||||||
animated_from_edge(content, progress, regions, radius, ferese_config::panel::Edge::Top)
|
animated_from_edge(content, progress, regions, radius, ferese_panel::Edge::Top)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn animated_from_edge<'a, M: 'a>(
|
pub fn animated_from_edge<'a, M: 'a>(
|
||||||
@@ -366,18 +343,14 @@ pub fn animated_from_edge<'a, M: 'a>(
|
|||||||
progress: f32,
|
progress: f32,
|
||||||
regions: Regions,
|
regions: Regions,
|
||||||
radius: f32,
|
radius: f32,
|
||||||
edge: ferese_config::panel::Edge,
|
edge: ferese_panel::Edge,
|
||||||
) -> Element<'a, M> {
|
) -> Element<'a, M> {
|
||||||
Element::new(Motion {
|
Element::new(Motion {
|
||||||
content,
|
content,
|
||||||
progress,
|
progress,
|
||||||
regions,
|
regions,
|
||||||
radius,
|
radius,
|
||||||
direction: if edge == ferese_config::panel::Edge::Top {
|
direction: if edge == ferese_panel::Edge::Top { -1. } else { 1. },
|
||||||
-1.
|
|
||||||
} else {
|
|
||||||
1.
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,50 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub(crate) struct ContentMeasurement<'a, M> {
|
||||||
|
pub content: Element<'a, M>,
|
||||||
|
pub width: f32,
|
||||||
|
pub measured: Box<dyn Fn(Size) + 'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn geometry_driven<'a, M: 'a>(
|
||||||
|
revision: Option<Instant>,
|
||||||
|
build: impl Fn(Instant) -> Element<'a, M> + 'a,
|
||||||
|
active: impl Fn(Instant) -> bool + 'a,
|
||||||
|
after_frame: impl Fn(Instant) + 'a,
|
||||||
|
completion: impl Fn(Instant) -> Option<M> + 'a,
|
||||||
|
measurement: Option<ContentMeasurement<'a, M>>,
|
||||||
|
) -> Element<'a, M> {
|
||||||
|
driven(revision, build, active, after_frame, completion, true, measurement)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn frame_driven<'a, M: 'a>(
|
pub(crate) fn frame_driven<'a, M: 'a>(
|
||||||
revision: Option<Instant>,
|
revision: Option<Instant>,
|
||||||
build: impl Fn(Instant) -> Element<'a, M> + 'a,
|
build: impl Fn(Instant) -> Element<'a, M> + 'a,
|
||||||
active: impl Fn(Instant) -> bool + 'a,
|
active: impl Fn(Instant) -> bool + 'a,
|
||||||
after_frame: impl Fn(Instant) + 'a,
|
after_frame: impl Fn(Instant) + 'a,
|
||||||
completion: impl Fn(Instant) -> Option<M> + 'a,
|
completion: impl Fn(Instant) -> Option<M> + 'a,
|
||||||
|
) -> Element<'a, M> {
|
||||||
|
driven(revision, build, active, after_frame, completion, false, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn driven<'a, M: 'a>(
|
||||||
|
revision: Option<Instant>,
|
||||||
|
build: impl Fn(Instant) -> Element<'a, M> + 'a,
|
||||||
|
active: impl Fn(Instant) -> bool + 'a,
|
||||||
|
after_frame: impl Fn(Instant) + 'a,
|
||||||
|
completion: impl Fn(Instant) -> Option<M> + 'a,
|
||||||
|
geometry_changes: bool,
|
||||||
|
measurement: Option<ContentMeasurement<'a, M>>,
|
||||||
) -> Element<'a, M> {
|
) -> Element<'a, M> {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let content = build(now);
|
let content = build(now);
|
||||||
let content_active = active(now);
|
let content_active = active(now);
|
||||||
Element::new(FrameDriven {
|
Element::new(FrameDriven {
|
||||||
content,
|
content,
|
||||||
|
measurement,
|
||||||
|
measured: false,
|
||||||
|
geometry_changes,
|
||||||
content_active,
|
content_active,
|
||||||
revision,
|
revision,
|
||||||
build: Box::new(build),
|
build: Box::new(build),
|
||||||
@@ -29,6 +61,8 @@ struct FrameState {
|
|||||||
revision: Option<Instant>,
|
revision: Option<Instant>,
|
||||||
last_frame: Option<Instant>,
|
last_frame: Option<Instant>,
|
||||||
completed: bool,
|
completed: bool,
|
||||||
|
pending: bool,
|
||||||
|
limits: Option<layout::Limits>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FrameState {
|
impl FrameState {
|
||||||
@@ -42,6 +76,7 @@ impl FrameState {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.last_frame = Some(at);
|
self.last_frame = Some(at);
|
||||||
|
self.pending = true;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +91,10 @@ impl FrameState {
|
|||||||
|
|
||||||
struct FrameDriven<'a, M> {
|
struct FrameDriven<'a, M> {
|
||||||
content: Element<'a, M>,
|
content: Element<'a, M>,
|
||||||
|
measurement: Option<ContentMeasurement<'a, M>>,
|
||||||
|
measured: bool,
|
||||||
content_active: bool,
|
content_active: bool,
|
||||||
|
geometry_changes: bool,
|
||||||
revision: Option<Instant>,
|
revision: Option<Instant>,
|
||||||
build: Box<dyn Fn(Instant) -> Element<'a, M> + 'a>,
|
build: Box<dyn Fn(Instant) -> Element<'a, M> + 'a>,
|
||||||
active: Box<dyn Fn(Instant) -> bool + 'a>,
|
active: Box<dyn Fn(Instant) -> bool + 'a>,
|
||||||
@@ -72,10 +110,20 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
|||||||
widget::tree::State::new(FrameState::default())
|
widget::tree::State::new(FrameState::default())
|
||||||
}
|
}
|
||||||
fn children(&self) -> Vec<widget::Tree> {
|
fn children(&self) -> Vec<widget::Tree> {
|
||||||
vec![widget::Tree::new(&self.content)]
|
std::iter::once(widget::Tree::new(&self.content))
|
||||||
|
.chain(self.measurement.iter().map(|m| widget::Tree::new(&m.content)))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
fn diff(&mut self, tree: &mut widget::Tree) {
|
fn diff(&mut self, tree: &mut widget::Tree) {
|
||||||
tree.diff_children(std::slice::from_mut(&mut self.content));
|
tree.children.truncate(1 + usize::from(self.measurement.is_some()));
|
||||||
|
tree.children[0].diff(&mut self.content);
|
||||||
|
if let Some(measurement) = &mut self.measurement {
|
||||||
|
if tree.children.len() == 1 {
|
||||||
|
tree.children.push(widget::Tree::new(&measurement.content));
|
||||||
|
} else {
|
||||||
|
tree.children[1].diff(&mut measurement.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn size(&self) -> Size<Length> {
|
fn size(&self) -> Size<Length> {
|
||||||
self.content.as_widget().size()
|
self.content.as_widget().size()
|
||||||
@@ -86,6 +134,23 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
|||||||
renderer: &cosmic::Renderer,
|
renderer: &cosmic::Renderer,
|
||||||
limits: &layout::Limits,
|
limits: &layout::Limits,
|
||||||
) -> layout::Node {
|
) -> layout::Node {
|
||||||
|
tree.state.downcast_mut::<FrameState>().limits = Some(*limits);
|
||||||
|
if !self.measured {
|
||||||
|
if let Some(measurement) = &mut self.measurement {
|
||||||
|
// Measure at the final width once, so spring overshoot never changes wrapping or its target.
|
||||||
|
let probe_limits = layout::Limits::new(Size::ZERO, Size::new(measurement.width, f32::INFINITY));
|
||||||
|
let node = measurement
|
||||||
|
.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.layout(&mut tree.children[1], renderer, &probe_limits);
|
||||||
|
(measurement.measured)(node.size());
|
||||||
|
let now = Instant::now();
|
||||||
|
self.content = (self.build)(now);
|
||||||
|
self.content_active = (self.active)(now);
|
||||||
|
tree.children[0].diff(&mut self.content);
|
||||||
|
}
|
||||||
|
self.measured = true;
|
||||||
|
}
|
||||||
self.content
|
self.content
|
||||||
.as_widget_mut()
|
.as_widget_mut()
|
||||||
.layout(&mut tree.children[0], renderer, limits)
|
.layout(&mut tree.children[0], renderer, limits)
|
||||||
@@ -117,39 +182,57 @@ impl<'a, M: 'a> Widget<M, Theme, cosmic::Renderer> for FrameDriven<'a, M> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let mut updated_geometry = false;
|
||||||
|
let mut fresh_layout = None;
|
||||||
if let Some(at) = redraw {
|
if let Some(at) = redraw {
|
||||||
// Only paint parameters depend on time. Preserve the child tree's
|
// Preserve focus and scroll state while sampling the current frame.
|
||||||
// focus/hover/scroll state and its existing layout.
|
|
||||||
let active = (self.active)(at);
|
let active = (self.active)(at);
|
||||||
// Include the final settled sample, then reuse the tree on hover
|
// Include the final settled sample, then reuse the tree on hover
|
||||||
// and other redraws. Application updates build fresh content.
|
// and other redraws. Application updates build fresh content.
|
||||||
if active || self.content_active {
|
if active || self.content_active {
|
||||||
self.content = (self.build)(at);
|
self.content = (self.build)(at);
|
||||||
tree.children[0].diff(&mut self.content);
|
tree.children[0].diff(&mut self.content);
|
||||||
|
if self.geometry_changes {
|
||||||
|
self.content_active = (self.active)(at);
|
||||||
|
let limits = tree
|
||||||
|
.state
|
||||||
|
.downcast_ref::<FrameState>()
|
||||||
|
.limits
|
||||||
|
.expect("laid-out animation");
|
||||||
|
fresh_layout = Some(
|
||||||
|
self.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.layout(&mut tree.children[0], renderer, &limits)
|
||||||
|
.move_to(layout.position()),
|
||||||
|
);
|
||||||
|
updated_geometry = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.content_active = active;
|
self.content_active = (self.active)(at);
|
||||||
}
|
}
|
||||||
|
// Iced revalidates the base layout without replaying the event. Publish
|
||||||
|
// the measured shape and run the child against this frame's layout now.
|
||||||
self.content.as_widget_mut().update(
|
self.content.as_widget_mut().update(
|
||||||
&mut tree.children[0],
|
&mut tree.children[0],
|
||||||
event,
|
event,
|
||||||
layout,
|
fresh_layout.as_ref().map_or(layout, Layout::new),
|
||||||
cursor,
|
cursor,
|
||||||
renderer,
|
renderer,
|
||||||
clipboard,
|
clipboard,
|
||||||
shell,
|
shell,
|
||||||
viewport,
|
viewport,
|
||||||
);
|
);
|
||||||
|
if updated_geometry {
|
||||||
|
shell.invalidate_layout();
|
||||||
|
}
|
||||||
if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
|
if let Event::Window(cosmic::iced::window::Event::RedrawRequested(at)) = event {
|
||||||
let active = (self.active)(*at);
|
let active = (self.active)(*at);
|
||||||
if active {
|
if active {
|
||||||
shell.request_redraw();
|
shell.request_redraw();
|
||||||
}
|
}
|
||||||
// Iced can replay the same redraw after a child invalidates layout.
|
// Complete only after a valid layout, including the replay of this frame.
|
||||||
// Keep its NextFrame request, but sample/publish only once.
|
if std::mem::take(&mut tree.state.downcast_mut::<FrameState>().pending) {
|
||||||
if redraw.is_some() {
|
|
||||||
(self.after_frame)(*at);
|
(self.after_frame)(*at);
|
||||||
}
|
|
||||||
if redraw.is_some() {
|
|
||||||
let completion = (self.completion)(*at);
|
let completion = (self.completion)(*at);
|
||||||
if tree.state.downcast_mut::<FrameState>().complete(completion.is_none())
|
if tree.state.downcast_mut::<FrameState>().complete(completion.is_none())
|
||||||
&& let Some(message) = completion
|
&& let Some(message) = completion
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ fn blend(base: Color, foreground: Color, amount: f32) -> Color {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn icon_well<'a>(
|
fn icon_well<'a>(
|
||||||
|
render: crate::rendering::Rendering,
|
||||||
content: impl Into<Element<'a, cosmic::Action<Message>>>,
|
content: impl Into<Element<'a, cosmic::Action<Message>>>,
|
||||||
foreground: Color,
|
foreground: Color,
|
||||||
extent: u16,
|
extent: u16,
|
||||||
@@ -36,7 +37,7 @@ fn icon_well<'a>(
|
|||||||
.center_y(extent)
|
.center_y(extent)
|
||||||
.class(ferese_theme::controls::surface(
|
.class(ferese_theme::controls::surface(
|
||||||
foreground.scale_alpha(0.07),
|
foreground.scale_alpha(0.07),
|
||||||
motion::radius(f32::from(extent) / 2.0),
|
render.radius(f32::from(extent) / 2.0),
|
||||||
))
|
))
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,7 @@ fn age_label(age: Duration) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn card_button<'a>(
|
fn card_button<'a>(
|
||||||
|
render: crate::rendering::Rendering,
|
||||||
content: impl Into<Element<'a, cosmic::Action<Message>>>,
|
content: impl Into<Element<'a, cosmic::Action<Message>>>,
|
||||||
message: Message,
|
message: Message,
|
||||||
foreground: Color,
|
foreground: Color,
|
||||||
@@ -65,7 +67,7 @@ fn card_button<'a>(
|
|||||||
.class(ferese_theme::controls::notification_button(
|
.class(ferese_theme::controls::notification_button(
|
||||||
foreground,
|
foreground,
|
||||||
foreground.scale_alpha(0.08),
|
foreground.scale_alpha(0.08),
|
||||||
if round { motion::radius(11.) } else { 0. },
|
if round { render.radius(11.) } else { 0. },
|
||||||
round,
|
round,
|
||||||
))
|
))
|
||||||
.into()
|
.into()
|
||||||
@@ -89,6 +91,7 @@ fn card_surface<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn icon_control<'a>(
|
fn icon_control<'a>(
|
||||||
|
render: crate::rendering::Rendering,
|
||||||
source: &'static [u8],
|
source: &'static [u8],
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
message: Message,
|
message: Message,
|
||||||
@@ -97,6 +100,7 @@ fn icon_control<'a>(
|
|||||||
extent: u16,
|
extent: u16,
|
||||||
) -> Element<'a, cosmic::Action<Message>> {
|
) -> Element<'a, cosmic::Action<Message>> {
|
||||||
let control = card_button(
|
let control = card_button(
|
||||||
|
render,
|
||||||
container(bar_icon(source, 14, foreground))
|
container(bar_icon(source, 14, foreground))
|
||||||
.center_x(extent)
|
.center_x(extent)
|
||||||
.center_y(extent),
|
.center_y(extent),
|
||||||
@@ -107,14 +111,14 @@ fn icon_control<'a>(
|
|||||||
);
|
);
|
||||||
cosmic::widget::tooltip(
|
cosmic::widget::tooltip(
|
||||||
control,
|
control,
|
||||||
text(label).size(11).class(theme::Text::Color(foreground)),
|
render.text(label).size(11).class(theme::Text::Color(foreground)),
|
||||||
cosmic::widget::tooltip::Position::Left,
|
cosmic::widget::tooltip::Position::Left,
|
||||||
)
|
)
|
||||||
.class(theme::Container::custom(move |_| container::Style {
|
.class(theme::Container::custom(move |_| container::Style {
|
||||||
background: Some(Background::Color(background)),
|
background: Some(Background::Color(background)),
|
||||||
border: Border {
|
border: Border {
|
||||||
shape: BorderShape::Continuous,
|
shape: BorderShape::Continuous,
|
||||||
radius: motion::radius(12.0).into(),
|
radius: render.radius(12.0).into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
text_color: Some(foreground),
|
text_color: Some(foreground),
|
||||||
@@ -145,13 +149,30 @@ impl FereseShell {
|
|||||||
})
|
})
|
||||||
.and_then(|output| output.size)
|
.and_then(|output| output.size)
|
||||||
.map_or(HISTORY_MAX_HEIGHT, |(_, height)| {
|
.map_or(HISTORY_MAX_HEIGHT, |(_, height)| {
|
||||||
let geometry = self.config.panels[0].geometry;
|
let geometry = self
|
||||||
|
.menu
|
||||||
|
.as_ref()
|
||||||
|
.map(|menu| self.panel_for_bar(menu.anchor.parent))
|
||||||
|
.unwrap_or(&self.config.panels[0])
|
||||||
|
.resolved_geometry();
|
||||||
(height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0).max(1.0)
|
(height as f32 - geometry.edge_margin as f32 - geometry.height - 8.0 - 12.0).max(1.0)
|
||||||
})
|
})
|
||||||
.min(HISTORY_MAX_HEIGHT)
|
.min(HISTORY_MAX_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn toggle_notification_history(&mut self) -> Task<Message> {
|
pub(super) fn toggle_notification_history(&mut self) -> Task<Message> {
|
||||||
|
if let Some(parent) = self.active_bar()
|
||||||
|
&& self.hub_mode(parent).is_some()
|
||||||
|
{
|
||||||
|
return if self.hub_mode(parent)
|
||||||
|
== Some(ferese_panel::morph::Mode::Expanded(
|
||||||
|
ferese_panel::morph::Content::Notifications,
|
||||||
|
)) {
|
||||||
|
self.close_hub(parent)
|
||||||
|
} else {
|
||||||
|
self.open_hub(parent, status_ui::Menu::Notifications)
|
||||||
|
};
|
||||||
|
}
|
||||||
if self.notifications.history_open {
|
if self.notifications.history_open {
|
||||||
return self.close_menu();
|
return self.close_menu();
|
||||||
}
|
}
|
||||||
@@ -174,7 +195,7 @@ impl FereseShell {
|
|||||||
x: (width - 48).max(0),
|
x: (width - 48).max(0),
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 24,
|
width: 24,
|
||||||
height: self.config.panels[0].geometry.height.round() as i32,
|
height: self.config.panels[0].resolved_geometry().height.round() as i32,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
self.open_menu(status_ui::Menu::Notifications, anchor)
|
self.open_menu(status_ui::Menu::Notifications, anchor)
|
||||||
@@ -195,7 +216,10 @@ impl FereseShell {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let count = self.notifications.popup_groups().len();
|
let count = self.notifications.popup_groups().len();
|
||||||
let wanted = self.notifications.ready && !self.notifications.history_open && count > 0;
|
let wanted = self.config.panels[0].style != ferese_panel::PanelStyle::Morph
|
||||||
|
&& self.notifications.ready
|
||||||
|
&& !self.notifications.history_open
|
||||||
|
&& count > 0;
|
||||||
let selected = self
|
let selected = self
|
||||||
.outputs
|
.outputs
|
||||||
.iter()
|
.iter()
|
||||||
@@ -236,7 +260,13 @@ impl FereseShell {
|
|||||||
.find(|entry| entry.output == output)
|
.find(|entry| entry.output == output)
|
||||||
.and_then(|entry| entry.size)
|
.and_then(|entry| entry.size)
|
||||||
.map_or(1080, |size| size.1);
|
.map_or(1080, |size| size.1);
|
||||||
let top = self.config.panels[0].geometry.height + self.config.panels[0].geometry.edge_margin as f32 + 12.0;
|
let geometry = self
|
||||||
|
.outputs
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.output == output)
|
||||||
|
.map(|entry| entry.runtime.panel(&self.config.panels[0]).resolved_geometry())
|
||||||
|
.unwrap_or_else(|| self.config.panels[0].resolved_geometry());
|
||||||
|
let top = geometry.height + geometry.edge_margin as f32 + 12.0;
|
||||||
let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32);
|
let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32);
|
||||||
if let Some(surface) = &mut self.notification_surface {
|
if let Some(surface) = &mut self.notification_surface {
|
||||||
if surface.height != height {
|
if surface.height != height {
|
||||||
@@ -306,7 +336,7 @@ impl FereseShell {
|
|||||||
let divider = tint(palette.border);
|
let divider = tint(palette.border);
|
||||||
let well = blend(base, foreground, 0.07);
|
let well = blend(base, foreground, 0.07);
|
||||||
let id = notice.id;
|
let id = notice.id;
|
||||||
let mut bold = *SHELL_FONT.get().unwrap().read().unwrap();
|
let mut bold = self.render.font;
|
||||||
bold.weight = cosmic::iced::font::Weight::Bold;
|
bold.weight = cosmic::iced::font::Weight::Bold;
|
||||||
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() {
|
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() {
|
||||||
accented_icon(ferese_theme::icons::FERESE, 18, accent, accent)
|
accented_icon(ferese_theme::icons::FERESE, 18, accent, accent)
|
||||||
@@ -316,7 +346,7 @@ impl FereseShell {
|
|||||||
icon::from_name(notice.icon.as_str()).size(18).icon()
|
icon::from_name(notice.icon.as_str()).size(18).icon()
|
||||||
}
|
}
|
||||||
.opacity(opacity);
|
.opacity(opacity);
|
||||||
let icon_well = icon_well(app_icon, foreground, 30);
|
let icon_well = icon_well(self.render, app_icon, foreground, 30);
|
||||||
let icon: Element<'_, cosmic::Action<Message>> = if count > 1 {
|
let icon: Element<'_, cosmic::Action<Message>> = if count > 1 {
|
||||||
let badge_fill = ferese_theme::composite(
|
let badge_fill = ferese_theme::composite(
|
||||||
color(palette.accent),
|
color(palette.accent),
|
||||||
@@ -328,7 +358,8 @@ impl FereseShell {
|
|||||||
);
|
);
|
||||||
let (badge_fill, badge_text) = ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
|
let (badge_fill, badge_text) = ferese_theme::accent_pair(badge_fill, color(palette.text_primary));
|
||||||
let badge = container(
|
let badge = container(
|
||||||
text(count.to_string())
|
self.render
|
||||||
|
.text(count.to_string())
|
||||||
.size(10)
|
.size(10)
|
||||||
.font(bold)
|
.font(bold)
|
||||||
.class(theme::Text::Color(badge_text.scale_alpha(opacity))),
|
.class(theme::Text::Color(badge_text.scale_alpha(opacity))),
|
||||||
@@ -339,7 +370,7 @@ impl FereseShell {
|
|||||||
background: Some(Background::Color(badge_fill.scale_alpha(opacity))),
|
background: Some(Background::Color(badge_fill.scale_alpha(opacity))),
|
||||||
border: Border {
|
border: Border {
|
||||||
shape: BorderShape::Continuous,
|
shape: BorderShape::Continuous,
|
||||||
radius: motion::radius(8.0).into(),
|
radius: self.render.radius(8.0).into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -372,17 +403,25 @@ impl FereseShell {
|
|||||||
.align_y(alignment::Vertical::Center)
|
.align_y(alignment::Vertical::Center)
|
||||||
.push(icon)
|
.push(icon)
|
||||||
.push(
|
.push(
|
||||||
container(text(app).size(11).font(bold).class(theme::Text::Color(muted)))
|
container(
|
||||||
.width(Length::Fill)
|
self.render
|
||||||
.height(16)
|
.text(app)
|
||||||
.clip(true),
|
.size(11)
|
||||||
|
.font(bold)
|
||||||
|
.class(theme::Text::Color(muted)),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(16)
|
||||||
|
.clip(true),
|
||||||
)
|
)
|
||||||
.push(
|
.push(
|
||||||
text(age_label(notice.received_at.elapsed()))
|
self.render
|
||||||
|
.text(age_label(notice.received_at.elapsed()))
|
||||||
.size(11)
|
.size(11)
|
||||||
.class(theme::Text::Color(muted)),
|
.class(theme::Text::Color(muted)),
|
||||||
)
|
)
|
||||||
.push(icon_control(
|
.push(icon_control(
|
||||||
|
self.render,
|
||||||
ferese_theme::icons::CLOSE,
|
ferese_theme::icons::CLOSE,
|
||||||
"Dismiss notification",
|
"Dismiss notification",
|
||||||
if history && count > 1 {
|
if history && count > 1 {
|
||||||
@@ -398,7 +437,8 @@ impl FereseShell {
|
|||||||
));
|
));
|
||||||
let mut content = column([]).spacing(4).push(header).push(
|
let mut content = column([]).spacing(4).push(header).push(
|
||||||
container(
|
container(
|
||||||
text(notice.title.clone())
|
self.render
|
||||||
|
.text(notice.title.clone())
|
||||||
.size(14)
|
.size(14)
|
||||||
.font(bold)
|
.font(bold)
|
||||||
.class(theme::Text::Color(foreground)),
|
.class(theme::Text::Color(foreground)),
|
||||||
@@ -417,7 +457,7 @@ impl FereseShell {
|
|||||||
};
|
};
|
||||||
if !body.is_empty() {
|
if !body.is_empty() {
|
||||||
content = content.push(
|
content = content.push(
|
||||||
container(text(body).size(13).class(theme::Text::Color(muted)))
|
container(self.render.text(body).size(13).class(theme::Text::Color(muted)))
|
||||||
.height(if history && count == 1 {
|
.height(if history && count == 1 {
|
||||||
Length::Shrink
|
Length::Shrink
|
||||||
} else {
|
} else {
|
||||||
@@ -447,8 +487,10 @@ impl FereseShell {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
actions = actions.push(card_button(
|
actions = actions.push(card_button(
|
||||||
|
self.render,
|
||||||
container(
|
container(
|
||||||
text(label.clone())
|
self.render
|
||||||
|
.text(label.clone())
|
||||||
.font(bold)
|
.font(bold)
|
||||||
.size(13)
|
.size(13)
|
||||||
.class(theme::Text::Color(accent)),
|
.class(theme::Text::Color(accent)),
|
||||||
@@ -511,7 +553,7 @@ impl FereseShell {
|
|||||||
})),
|
})),
|
||||||
border: Border {
|
border: Border {
|
||||||
shape: BorderShape::Continuous,
|
shape: BorderShape::Continuous,
|
||||||
radius: [0.0, 0.0, motion::radius(6.0), motion::radius(6.0)].into(),
|
radius: [0.0, 0.0, self.render.radius(6.0), self.render.radius(6.0)].into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -579,7 +621,7 @@ impl FereseShell {
|
|||||||
let base = color(palette.surface_base);
|
let base = color(palette.surface_base);
|
||||||
let compositor_material = surface.is_some_and(|menu| menu.effects.is_some());
|
let compositor_material = surface.is_some_and(|menu| menu.effects.is_some());
|
||||||
let well = blend(base, primary, 0.07);
|
let well = blend(base, primary, 0.07);
|
||||||
let mut bold = *SHELL_FONT.get().unwrap().read().unwrap();
|
let mut bold = self.render.font;
|
||||||
bold.weight = cosmic::iced::font::Weight::Bold;
|
bold.weight = cosmic::iced::font::Weight::Bold;
|
||||||
let groups = self.notifications.history_groups();
|
let groups = self.notifications.history_groups();
|
||||||
let count = self.notifications.entries.len();
|
let count = self.notifications.entries.len();
|
||||||
@@ -596,17 +638,19 @@ impl FereseShell {
|
|||||||
let heading = column([])
|
let heading = column([])
|
||||||
.spacing(3)
|
.spacing(3)
|
||||||
.push(
|
.push(
|
||||||
text("Notifications")
|
self.render
|
||||||
|
.text("Notifications")
|
||||||
.size(16)
|
.size(16)
|
||||||
.font(bold)
|
.font(bold)
|
||||||
.class(theme::Text::Color(primary)),
|
.class(theme::Text::Color(primary)),
|
||||||
)
|
)
|
||||||
.push(text(subtitle).size(11).class(theme::Text::Color(muted)));
|
.push(self.render.text(subtitle).size(11).class(theme::Text::Color(muted)));
|
||||||
let header = row([])
|
let header = row([])
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.align_y(alignment::Vertical::Center)
|
.align_y(alignment::Vertical::Center)
|
||||||
.push(container(heading).width(Length::Fill))
|
.push(container(heading).width(Length::Fill))
|
||||||
.push(icon_control(
|
.push(icon_control(
|
||||||
|
self.render,
|
||||||
ferese_theme::icons::CLOSE,
|
ferese_theme::icons::CLOSE,
|
||||||
"Close notification center",
|
"Close notification center",
|
||||||
Message::ToggleNotificationHistory,
|
Message::ToggleNotificationHistory,
|
||||||
@@ -629,27 +673,31 @@ impl FereseShell {
|
|||||||
column([])
|
column([])
|
||||||
.spacing(2)
|
.spacing(2)
|
||||||
.push(
|
.push(
|
||||||
text("Do Not Disturb")
|
self.render
|
||||||
|
.text("Do Not Disturb")
|
||||||
.size(12)
|
.size(12)
|
||||||
.font(bold)
|
.font(bold)
|
||||||
.class(theme::Text::Color(primary)),
|
.class(theme::Text::Color(primary)),
|
||||||
)
|
)
|
||||||
.push(
|
.push(
|
||||||
text(if self.notifications.dnd {
|
self.render
|
||||||
"Popups paused"
|
.text(if self.notifications.dnd {
|
||||||
} else {
|
"Popups paused"
|
||||||
"Popups enabled"
|
} else {
|
||||||
})
|
"Popups enabled"
|
||||||
.size(11)
|
})
|
||||||
.class(theme::Text::Color(muted)),
|
.size(11)
|
||||||
|
.class(theme::Text::Color(muted)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.width(Length::Fill),
|
.width(Length::Fill),
|
||||||
)
|
)
|
||||||
.push(motion::button(
|
.push(motion::button(
|
||||||
|
self.render,
|
||||||
button::custom(
|
button::custom(
|
||||||
container(
|
container(
|
||||||
text(if self.notifications.dnd { "On" } else { "Off" })
|
self.render
|
||||||
|
.text(if self.notifications.dnd { "On" } else { "Off" })
|
||||||
.size(12)
|
.size(12)
|
||||||
.font(bold)
|
.font(bold)
|
||||||
.class(theme::Text::Color(dnd_color)),
|
.class(theme::Text::Color(dnd_color)),
|
||||||
@@ -672,18 +720,21 @@ impl FereseShell {
|
|||||||
.spacing(10)
|
.spacing(10)
|
||||||
.align_x(alignment::Horizontal::Center)
|
.align_x(alignment::Horizontal::Center)
|
||||||
.push(icon_well(
|
.push(icon_well(
|
||||||
|
self.render,
|
||||||
accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent),
|
accented_icon(ferese_theme::icons::NOTIFICATIONS, 26, muted, accent),
|
||||||
primary,
|
primary,
|
||||||
56,
|
56,
|
||||||
))
|
))
|
||||||
.push(
|
.push(
|
||||||
text("You're all caught up")
|
self.render
|
||||||
|
.text("You're all caught up")
|
||||||
.size(15)
|
.size(15)
|
||||||
.font(bold)
|
.font(bold)
|
||||||
.class(theme::Text::Color(primary)),
|
.class(theme::Text::Color(primary)),
|
||||||
)
|
)
|
||||||
.push(
|
.push(
|
||||||
text("New notifications will appear here.")
|
self.render
|
||||||
|
.text("New notifications will appear here.")
|
||||||
.size(11)
|
.size(11)
|
||||||
.class(theme::Text::Color(muted)),
|
.class(theme::Text::Color(muted)),
|
||||||
);
|
);
|
||||||
@@ -693,13 +744,15 @@ impl FereseShell {
|
|||||||
row([])
|
row([])
|
||||||
.align_y(alignment::Vertical::Center)
|
.align_y(alignment::Vertical::Center)
|
||||||
.push(
|
.push(
|
||||||
text("Recent")
|
self.render
|
||||||
|
.text("Recent")
|
||||||
.size(11)
|
.size(11)
|
||||||
.font(bold)
|
.font(bold)
|
||||||
.class(theme::Text::Color(muted))
|
.class(theme::Text::Color(muted))
|
||||||
.width(Length::Fill),
|
.width(Length::Fill),
|
||||||
)
|
)
|
||||||
.push(icon_control(
|
.push(icon_control(
|
||||||
|
self.render,
|
||||||
ferese_theme::icons::TRASH,
|
ferese_theme::icons::TRASH,
|
||||||
"Clear all notifications",
|
"Clear all notifications",
|
||||||
Message::ClearNotifications,
|
Message::ClearNotifications,
|
||||||
@@ -721,13 +774,15 @@ impl FereseShell {
|
|||||||
.spacing(6)
|
.spacing(6)
|
||||||
.align_y(alignment::Vertical::Center)
|
.align_y(alignment::Vertical::Center)
|
||||||
.push(
|
.push(
|
||||||
text(format!("{} · {}", app, group.len()))
|
self.render
|
||||||
|
.text(format!("{} · {}", app, group.len()))
|
||||||
.size(11)
|
.size(11)
|
||||||
.font(bold)
|
.font(bold)
|
||||||
.class(theme::Text::Color(muted))
|
.class(theme::Text::Color(muted))
|
||||||
.width(Length::Fill),
|
.width(Length::Fill),
|
||||||
)
|
)
|
||||||
.push(icon_control(
|
.push(icon_control(
|
||||||
|
self.render,
|
||||||
ferese_theme::icons::CHEVRON_UP,
|
ferese_theme::icons::CHEVRON_UP,
|
||||||
"Collapse group",
|
"Collapse group",
|
||||||
Message::ToggleNotificationGroup(latest.app.clone()),
|
Message::ToggleNotificationGroup(latest.app.clone()),
|
||||||
@@ -736,6 +791,7 @@ impl FereseShell {
|
|||||||
24,
|
24,
|
||||||
))
|
))
|
||||||
.push(icon_control(
|
.push(icon_control(
|
||||||
|
self.render,
|
||||||
ferese_theme::icons::TRASH,
|
ferese_theme::icons::TRASH,
|
||||||
"Clear group",
|
"Clear group",
|
||||||
Message::RemoveNotificationGroup(latest.app.clone()),
|
Message::RemoveNotificationGroup(latest.app.clone()),
|
||||||
@@ -834,7 +890,12 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
for foreground in [Color::BLACK, Color::WHITE] {
|
for foreground in [Color::BLACK, Color::WHITE] {
|
||||||
for extent in [30, 56] {
|
for extent in [30, 56] {
|
||||||
let inset = icon_well(cosmic::widget::Space::new(), foreground, extent);
|
let inset = icon_well(
|
||||||
|
crate::rendering::Rendering::new(&crate::config::ShellConfig::default()),
|
||||||
|
cosmic::widget::Space::new(),
|
||||||
|
foreground,
|
||||||
|
extent,
|
||||||
|
);
|
||||||
let controls = ferese_theme::menus::section(
|
let controls = ferese_theme::menus::section(
|
||||||
cosmic::widget::Space::new().width(100).height(40).into(),
|
cosmic::widget::Space::new().width(100).height(40).into(),
|
||||||
foreground,
|
foreground,
|
||||||
@@ -899,6 +960,7 @@ mod tests {
|
|||||||
for history in [false, true] {
|
for history in [false, true] {
|
||||||
for scale in [1.0, 1.25, 1.5] {
|
for scale in [1.0, 1.25, 1.5] {
|
||||||
let action = card_button(
|
let action = card_button(
|
||||||
|
crate::rendering::Rendering::new(&crate::config::ShellConfig::default()),
|
||||||
cosmic::widget::Space::new().height(38),
|
cosmic::widget::Space::new().height(38),
|
||||||
Message::InvokeNotification(1, "action".into()),
|
Message::InvokeNotification(1, "action".into()),
|
||||||
Color::WHITE,
|
Color::WHITE,
|
||||||
|
|||||||
@@ -279,10 +279,11 @@ pub struct Center {
|
|||||||
pub entries: VecDeque<Notice>,
|
pub entries: VecDeque<Notice>,
|
||||||
toasts: VecDeque<Toast>,
|
toasts: VecDeque<Toast>,
|
||||||
last_tick: Instant,
|
last_tick: Instant,
|
||||||
|
motion: super::motion::Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Center {
|
impl Center {
|
||||||
pub fn new(config: ferese_config::notifications::NotificationConfig) -> Self {
|
pub fn new(config: ferese_config::notifications::NotificationConfig, motion: super::motion::Settings) -> Self {
|
||||||
Self {
|
Self {
|
||||||
service: Service::start(&config),
|
service: Service::start(&config),
|
||||||
ready: false,
|
ready: false,
|
||||||
@@ -294,6 +295,7 @@ impl Center {
|
|||||||
entries: VecDeque::new(),
|
entries: VecDeque::new(),
|
||||||
toasts: VecDeque::new(),
|
toasts: VecDeque::new(),
|
||||||
last_tick: Instant::now(),
|
last_tick: Instant::now(),
|
||||||
|
motion,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +323,7 @@ impl Center {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_motion_settings(&mut self, settings: super::motion::Settings) {
|
pub fn update_motion_settings(&mut self, settings: super::motion::Settings) {
|
||||||
|
self.motion = settings;
|
||||||
for toast in &mut self.toasts {
|
for toast in &mut self.toasts {
|
||||||
toast.motion.update_settings(settings);
|
toast.motion.update_settings(settings);
|
||||||
}
|
}
|
||||||
@@ -518,7 +521,7 @@ impl Center {
|
|||||||
self.toasts.push_back(Toast {
|
self.toasts.push_back(Toast {
|
||||||
id: notice.id,
|
id: notice.id,
|
||||||
motion: {
|
motion: {
|
||||||
let mut motion = super::motion::PopupMotion::new(super::motion::settings());
|
let mut motion = super::motion::PopupMotion::new(self.motion);
|
||||||
motion.begin(Instant::now());
|
motion.begin(Instant::now());
|
||||||
motion
|
motion
|
||||||
},
|
},
|
||||||
@@ -641,6 +644,7 @@ mod tests {
|
|||||||
let (commands, receiver) = mpsc::sync_channel(256);
|
let (commands, receiver) = mpsc::sync_channel(256);
|
||||||
let center = Center {
|
let center = Center {
|
||||||
config: NotificationConfig::default(),
|
config: NotificationConfig::default(),
|
||||||
|
motion: Default::default(),
|
||||||
service: Service {
|
service: Service {
|
||||||
timeout: Arc::new(std::sync::atomic::AtomicU32::new(6000)),
|
timeout: Arc::new(std::sync::atomic::AtomicU32::new(6000)),
|
||||||
events: Events(Arc::new(tokio::sync::Mutex::new(events))),
|
events: Events(Arc::new(tokio::sync::Mutex::new(events))),
|
||||||
|
|||||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user