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:
Samuel Onoja authored and GitHub committed 2026-10-12 01:31:34 +01:00
1 parent 42c3b290dd
commit e22f0ebef9
131 files changed
+12562 -1634

No files matched your search

+20 -5
View File
@@ -32,7 +32,7 @@ use crate::window_rules::{WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
panels: Option<Vec<ferese_config::panel::Panel>>,
panels: Option<Vec<ferese_panel::Panel>>,
#[serde(default)]
notifications: ferese_config::notifications::NotificationConfig,
#[serde(default)]
@@ -114,7 +114,7 @@ impl Config {
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
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.xwayland.validate().map_err(ConfigError::Binding)?;
@@ -136,6 +136,11 @@ impl Config {
bindings,
window_rules: self.window_rules()?,
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
.panels
.as_ref()
@@ -458,12 +463,17 @@ mod tests {
#[test]
fn panel_surfaces_are_validated_before_live_publication() {
for surface in ["none", "inset", "island"] {
for style in ferese_panel::PanelStyle::AVAILABLE {
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();
}
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 { background islands; }").is_err());
assert!(Config::parse_source("status { bar-layout islands; }").is_err());
@@ -817,6 +827,7 @@ mod tests {
focus_ring_gradient: None,
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_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,]),
text_primary_color: RgbaColor([244.0 / 255.0, 247.0 / 255.0, 251.0 / 255.0, 1.0]),
shell_opacity: 0.78,
@@ -906,11 +917,15 @@ mod tests {
#[test]
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()
.unwrap();
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.surface_raised_color,
RgbaColor([49. / 255., 65. / 255., 89. / 255., 1.])
);
}
#[test]
+9
View File
@@ -35,6 +35,7 @@ impl Config {
focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?,
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
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(
self.theme
.surface
@@ -141,6 +142,10 @@ pub(super) fn default_surface_base_color() -> String {
"#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 {
"#3D7BE6".to_owned()
}
@@ -215,6 +220,7 @@ pub struct ThemeSettings {
pub focus_ring_gradient: Option<BorderGradient>,
pub shadow_color: RgbaColor,
pub surface_base_color: RgbaColor,
pub surface_raised_color: RgbaColor,
pub bar_background_color: RgbaColor,
pub text_primary_color: RgbaColor,
pub shell_opacity: f64,
@@ -355,6 +361,8 @@ pub(super) struct ThemeColorsConfig {
pub(super) text_primary: String,
#[serde(default = "default_surface_base_color")]
pub(super) surface_base: String,
#[serde(default = "default_surface_raised_color")]
pub(super) surface_raised: String,
#[serde(default = "default_border_color")]
pub(super) border: String,
#[serde(default = "default_accent_color")]
@@ -367,6 +375,7 @@ impl Default for ThemeColorsConfig {
fn default() -> Self {
Self {
surface_base: default_surface_base_color(),
surface_raised: default_surface_raised_color(),
text_primary: default_text_primary_color(),
border: default_border_color(),
accent: default_accent_color(),
-1
View File
@@ -1,4 +1,3 @@
//! Session-owned services. Never grant shell/effects privileges to daemons.
use std::cell::RefCell;
use std::collections::HashMap;
use std::process::Child;
+150 -2
View File
@@ -51,8 +51,15 @@ pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle, opacity
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)]
struct PendingPresentation {
panel: Option<PanelPresentation>,
role: SemanticRole,
regions: Vec<[f64; 5]>,
opacity: u32,
@@ -67,6 +74,7 @@ struct SurfaceEffectsState {
regions: Mutex<Option<Vec<[f64; 5]>>>,
opacity: Mutex<u32>,
pending: Mutex<Option<PendingPresentation>>,
panel: Mutex<Option<PanelPresentation>>,
region_opacities: Mutex<Vec<u32>>,
presentation_supported: AtomicBool,
dismissing: AtomicBool,
@@ -81,6 +89,7 @@ impl SurfaceEffectsState {
regions: Mutex::new(None),
opacity: Mutex::new(1000),
pending: Mutex::new(None),
panel: Mutex::new(None),
region_opacities: Mutex::new(Vec::new()),
presentation_supported: AtomicBool::new(false),
dismissing: AtomicBool::new(false),
@@ -92,6 +101,7 @@ impl SurfaceEffectsState {
return;
};
*self.role.lock().unwrap() = Some(pending.role);
*self.panel.lock().unwrap() = pending.panel;
*self.regions.lock().unwrap() = Some(pending.regions);
*self.region_opacities.lock().unwrap() = pending.region_opacities;
if !self.dismissing.load(Ordering::Acquire) {
@@ -101,6 +111,7 @@ impl SurfaceEffectsState {
}
fn set_role(&self, role: Option<SemanticRole>) -> bool {
self.pending.lock().unwrap().take();
let mut current = self.role.lock().unwrap();
if *current != role {
*current = role;
@@ -126,7 +137,7 @@ impl SurfaceEffectsUserData {
}
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, ());
}
@@ -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> {
with_states(surface, |states| {
states
@@ -361,6 +410,31 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
};
match request {
ferese_surface_effects_v1::Request::SetPanelPresentation {
regions,
shapes,
fusion,
opacity,
} => {
let (Some(regions), Some(panel)) = (decode_regions(&regions), 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 {
role,
regions,
@@ -377,6 +451,7 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
with_states(&surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
*effects.pending.lock().unwrap() = Some(PendingPresentation {
panel: None,
role,
regions,
opacity: opacity.min(1000),
@@ -510,6 +585,7 @@ fn detach(surface: &WlSurface) -> bool {
let role_changed = effects.set_role(None);
effects.region_opacities.lock().unwrap().clear();
effects.pending.lock().unwrap().take();
effects.panel.lock().unwrap().take();
effects.dismissing.store(false, Ordering::Release);
let mut opacity = effects.opacity.lock().unwrap();
let opacity_changed = *opacity != 1000;
@@ -551,10 +627,82 @@ pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
mod tests {
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]
fn presentation_waits_for_buffer_commit_and_keeps_dismissal_ownership() {
let state = SurfaceEffectsState::new();
let queue = |opacity| PendingPresentation {
panel: None,
role: SemanticRole::Popover,
regions: vec![[1.25, 2.5, 100.0, 80.0, 12.0]],
opacity,
@@ -618,7 +766,7 @@ mod tests {
#[test]
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);
let region = [0.0f32, 0., 12., 36., 4.];
let bytes: Vec<_> = region.into_iter().flat_map(f32::to_ne_bytes).collect();
+1
View File
@@ -183,6 +183,7 @@ pub(super) fn grouped_elements(
output,
surface,
MaterialSurface {
shape_origin: constrain.loc,
geometry: constrain,
corners,
index: 0,
+102 -5
View File
@@ -286,6 +286,105 @@ fn rasterize(renderer: &mut GlesRenderer, program: &GlesPixelProgram, uniforms:
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]
#[ignore = "requires an EGL rendering device"]
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 resources = RenderResources::default();
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] {
let radii = ferese_config::panel::CornerRadii([14., 8., 12., 20.])
.at_edge(edge, true)
.0;
for edge in [ferese_panel::Edge::Top, ferese_panel::Edge::Bottom] {
let radii = ferese_panel::CornerRadii([14., 8., 12., 20.]).at_edge(edge, true).0;
let pixels = rasterize(
&mut renderer,
&material.0,
@@ -428,7 +525,7 @@ fn docked_panel_fill_covers_the_entire_attached_edge() {
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)
} else {
(35, 0)
+169 -14
View File
@@ -46,6 +46,7 @@ pub(super) fn append_material_surface(
output,
surface,
MaterialSurface {
shape_origin: content_origin,
geometry: *rect,
corners: *corners,
index,
@@ -148,6 +149,7 @@ fn framebuffer_corner_radii(radii: [f32; 4], transform: Transform) -> [f32; 4] {
}
pub(super) struct MaterialSurface {
pub shape_origin: Point<i32, Logical>,
pub geometry: Rectangle<i32, Logical>,
pub corners: RoundedRect,
pub index: usize,
@@ -177,6 +179,7 @@ fn resolve_surface_material(
role: crate::effects::SemanticRole,
settings: crate::config::ThemeSettings,
panel_opacity: Option<f32>,
panel_style: ferese_panel::PanelStyle,
) -> crate::effects::ResolvedMaterial {
let mut material = crate::effects::resolve_material(role, settings.material_style, settings.shell_opacity as f32);
if role == crate::effects::SemanticRole::Panel {
@@ -185,6 +188,9 @@ fn resolve_surface_material(
panel_opacity,
settings.reduce_transparency,
);
if panel_style == ferese_panel::PanelStyle::Morph {
material.shadow = [0.; 3];
}
}
material
}
@@ -198,13 +204,19 @@ pub(super) fn material_element_with_role(
(role, generation, opacity): (crate::effects::SemanticRole, u64, f32),
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
let MaterialSurface {
shape_origin,
geometry,
corners,
index,
capture_geometry,
alpha,
} = 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 mode = output.current_mode()?;
let scale = output.current_scale().fractional_scale();
@@ -214,6 +226,12 @@ pub(super) fn material_element_with_role(
} else {
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 logical_radii = if role == crate::effects::SemanticRole::Panel {
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_bottom =
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_edge(ferese_config::panel::Edge::Bottom, touches_bottom)
.at_edge(ferese_panel::Edge::Bottom, touches_bottom)
.0;
let radius = radii.into_iter().fold(0., f32::max);
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 sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size);
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 {
panel,
corner_radii,
presentation_alpha,
background_opacity,
@@ -278,7 +302,7 @@ pub(super) fn material_element_with_role(
geometry,
tint: [red, green, blue, material.opacity * presentation_alpha],
generation,
opaque: material.opacity == 1.0 && presentation_alpha == 1.0 && radius == 0.0,
opaque,
visible_framebuffer: framebuffer_clip_rect(corners.rect, mode.size, transform),
};
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.corner_radii != parameters.corner_radii
|| cached.parameters.visible_framebuffer != parameters.visible_framebuffer
|| cached.parameters.panel != parameters.panel
{
cached.shadow.resize(parameters.shadow_bounds, None);
cached.shadow.update_uniforms(decoration_uniforms(&parameters, 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("material_radii", UniformType::_4f),
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("background_opacity", UniformType::_1f),
UniformName::new("tint", UniformType::_4f),
]
];
names.extend(panel_uniform_names());
names
}
pub(super) fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
vec![
let mut uniforms = vec![
Uniform::new("visible_rect", p.visible_framebuffer),
Uniform::new("material_radii", p.corner_radii),
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("background_opacity", p.background_opacity),
Uniform::new("tint", p.tint),
]
];
uniforms.extend(panel_uniforms(p));
uniforms
}
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());
}
let uniforms = [
let mut uniforms = vec![
UniformName::new("visible_rect", UniformType::_4f),
UniformName::new("material_radii", 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_values", UniformType::_2f),
];
uniforms.extend(panel_uniform_names());
match renderer.compile_custom_pixel_shader(corner_shader_for(MATERIAL_SHADER, shape), &uniforms) {
Ok(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>> {
vec![
let mut uniforms = vec![
Uniform::new("paint_mode", paint_mode),
Uniform::new("shadow_rect", parameters.shadow_rect),
Uniform::new("shadow_values", parameters.shadow_values),
Uniform::new("visible_rect", parameters.visible_framebuffer),
Uniform::new("material_radii", parameters.corner_radii),
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) = &parameters.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(
size: Size<i32, Physical>,
current: CommitCounter,
@@ -562,17 +686,18 @@ mod region_geometry_tests {
#[test]
fn reduce_transparency_wins_over_continuous_and_island_panel_materials() {
use crate::effects::SemanticRole;
for surface in ["solid", "none"] {
for surface in ["flat", "floating", "morph"] {
for opacity in [0., 0.4, 1.] {
for reduce in [false, true] {
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 material = resolve_surface_material(
SemanticRole::Panel,
runtime.theme_settings,
runtime.panel_background_opacity,
runtime.panel_style,
);
assert_eq!(material.opacity, if reduce { 1. } else { opacity });
assert_eq!(material.opacity, candidate.theme.panel_opacity(Some(opacity)));
@@ -587,13 +712,43 @@ mod region_geometry_tests {
if reduce {
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);
}
}
}
}
#[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]
fn framebuffer_transforms_preserve_each_logical_corner() {
let radii = [1., 2., 3., 4.];
+8
View File
@@ -136,6 +136,7 @@ fn corner_shader_for(source: &str, shape: CornerShape) -> String {
.replace("precision mediump float;", "precision highp float;"),
}
.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 {
@@ -338,8 +339,15 @@ pub(crate) struct OverviewScrim {
#[derive(Clone, Debug)]
pub(crate) struct MaterialProgram(GlesPixelProgram);
#[derive(Clone, Debug, PartialEq)]
struct PanelMatrices {
shapes: Vec<[f32; 9]>,
fusion: f32,
}
#[derive(Clone, Debug, PartialEq)]
struct MaterialParameters {
panel: Option<PanelMatrices>,
corner_radii: [f32; 4],
presentation_alpha: f32,
background_opacity: f32,
+1
View File
@@ -328,6 +328,7 @@ fn scene_elements(
output,
surface,
MaterialSurface {
shape_origin: constrain.loc,
geometry: constrain,
corners,
index: 0,
@@ -21,9 +21,10 @@ uniform vec4 tint;
varying vec2 v_coords;
//_CORNERS_
//_MATERIAL_CORNERS_
//_PANEL_MASK_
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);
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size;
@@ -11,15 +11,16 @@ varying vec2 v_coords;
//_CORNERS_
//_MATERIAL_CORNERS_
//_PANEL_MASK_
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);
if (paint_mode > 0.5) {
// The shadow pass is multiplied by (1 - coverage). Fully covered
// pixels need neither a second contour solve nor a Gaussian sample.
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 opacity = exp(-0.5 * distance * distance / (sigma * sigma))
* 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);
}
+108 -2
View File
@@ -10,7 +10,7 @@ use crate::private_client::ClientCapabilities;
use crate::state::ClientState;
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> {
@@ -120,6 +120,18 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
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 } => {
let snapshot = state.portal_session.snapshot();
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 {
if snapshot.workspace_preview_revision != self.workspace_preview_revision {
return false;
}
let mut outputs = self.output_workspaces.connected_outputs();
for previous in &snapshot.outputs {
if outputs.next() != Some(previous.id)
|| self.output_names.get(&previous.id) != Some(&previous.name)
|| 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
{
return false;
@@ -377,6 +398,7 @@ impl Ferese {
|| previous.app_id != app_id
|| previous.title != title
|| previous.state != state
|| previous.source_geometry != self.shell_window_geometry(id)
{
return false;
}
@@ -387,6 +409,7 @@ impl Ferese {
fn shell_snapshot(&self) -> ShellSnapshot {
ShellSnapshot {
workspace_preview_revision: self.workspace_preview_revision,
outputs: self.output_snapshots(),
workspaces: self.workspace_snapshots(),
windows: self.managed_window_snapshots(),
@@ -416,6 +439,7 @@ impl Ferese {
name: output.name(),
active_workspace: self.output_workspaces.active_workspace(*id)?,
focused: focused == Some(*id),
bounds: self.output_bounds_for(output),
})
})
.collect::<Vec<_>>();
@@ -445,6 +469,9 @@ impl Ferese {
self.windows.window(id)?.toplevel()?;
let mut state = ferese_shell_v1::WindowState::empty();
if record.maximized {
state |= ferese_shell_v1::WindowState::Maximized;
}
if self.focused_window == Some(id) {
state |= ferese_shell_v1::WindowState::Focused;
}
@@ -467,7 +494,13 @@ impl Ferese {
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> {
let previews = self.workspace_preview_rects();
self.windows
.ordered_ids()
.filter_map(|id| self.shell_window_data(id))
@@ -477,6 +510,8 @@ impl Ferese {
app_id: app_id.to_owned(),
title: title.to_owned(),
state,
source_geometry: self.shell_window_geometry(id),
geometry: previews.get(&id).copied().or_else(|| self.shell_window_geometry(id)),
})
.collect()
}
@@ -518,6 +553,7 @@ enum ShellWindowAction {
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ShellSnapshot {
workspace_preview_revision: u64,
outputs: Vec<OutputSnapshot>,
workspaces: Vec<WorkspaceSnapshot>,
windows: Vec<ManagedWindowSnapshot>,
@@ -568,6 +604,11 @@ impl ShellSnapshot {
snapshot.title.clone(),
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);
}
@@ -580,10 +621,13 @@ struct ManagedWindowSnapshot {
app_id: String,
title: String,
state: ferese_shell_v1::WindowState,
geometry: Option<ferese_layout::Rect>,
source_geometry: Option<ferese_layout::Rect>,
}
#[derive(Clone, Debug, PartialEq)]
struct OutputSnapshot {
bounds: Option<ferese_layout::Rect>,
id: OutputId,
name: String,
active_workspace: WorkspaceId,
@@ -661,17 +705,22 @@ mod tests {
state.space.map_output(&output, (0, 0));
state.register_output(&output, "snapshot-test".into());
let original = state.shell_snapshot();
let initial_cache = state.preview_cache_stats();
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_eq!(state.preview_cache_stats(), initial_cache);
state
.workspaces
.insert_window(WindowId(99), ferese_layout::Axis::Horizontal, 0.5)
.unwrap();
assert!(!state.shell_snapshot_matches(&original));
let occupied = state.shell_snapshot();
assert_eq!(state.preview_cache_stats().0, initial_cache.0 + 1);
assert!(state.shell_snapshot_matches(&occupied));
let output_id = state.output_ids[&output];
let next = state.workspaces.create_workspace();
@@ -682,8 +731,60 @@ mod tests {
assert!(!state.shell_snapshot_matches(&assigned));
let selected = state.shell_snapshot();
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);
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]
@@ -708,7 +809,9 @@ mod tests {
#[test]
fn snapshot_deduplication_preserves_shell_visible_changes() {
let original = ShellSnapshot {
workspace_preview_revision: 0,
outputs: vec![OutputSnapshot {
bounds: None,
id: OutputId(1),
name: "display".into(),
active_workspace: WorkspaceId(1),
@@ -729,10 +832,13 @@ mod tests {
app_id: "terminal".into(),
title: "shell".into(),
state: ferese_shell_v1::WindowState::Focused,
geometry: None,
source_geometry: None,
}],
};
assert_eq!(original, original.clone());
let changes: &[fn(&mut ShellSnapshot)] = &[
|s| s.workspace_preview_revision += 1,
|s| s.outputs[0].focused = false,
|s| s.outputs[0].active_workspace = WorkspaceId(2),
|s| s.outputs.clear(),
+17 -3
View File
@@ -464,8 +464,9 @@ pub struct Ferese {
workspace_auto_back_and_forth: bool,
pub(crate) window_rules: Vec<WindowRule>,
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_style: ferese_panel::PanelStyle,
pub(crate) focus_effect: crate::config::FocusEffectSettings,
animations_enabled: bool,
animation_speed: f64,
@@ -505,6 +506,8 @@ pub struct Ferese {
pub(crate) shell_resources: Vec<Weak<FereseShellV1>>,
pub(crate) shell_snapshot_serial: u32,
pub(crate) last_shell_snapshot: Option<ShellSnapshot>,
pub(crate) workspace_preview_revision: u64,
workspace_previews: std::cell::RefCell<layout::PreviewCache>,
pub(crate) overview: OverviewState,
next_output_id: u64,
last_animation_tick: Instant,
@@ -557,8 +560,9 @@ pub struct RuntimeConfig {
pub workspace_auto_back_and_forth: bool,
pub window_rules: Vec<WindowRule>,
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_style: ferese_panel::PanelStyle,
pub focus_effect: crate::config::FocusEffectSettings,
pub default_column_width: ColumnWidth,
pub scrolling_focus_strategy: ViewportFocusStrategy,
@@ -746,6 +750,7 @@ impl Ferese {
theme_settings: config.theme_settings,
panel_corner_radius: config.panel_corner_radius,
panel_background_opacity: config.panel_background_opacity,
panel_style: config.panel_style,
focus_effect: config.focus_effect,
animations_enabled: config.animations_enabled,
animation_speed: config.animation_speed,
@@ -780,6 +785,8 @@ impl Ferese {
shell_resources: Vec::new(),
shell_snapshot_serial: 0,
last_shell_snapshot: None,
workspace_preview_revision: 0,
workspace_previews: Default::default(),
overview: OverviewState::with_font_family(config.overview_font_family),
next_output_id: 1,
last_animation_tick: start_time,
@@ -849,7 +856,9 @@ impl Ferese {
let input_changed = changed(&["input"]);
let daemons_changed = changed(&["autostart"]);
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"]);
if self.output_profiles != config.output_profiles {
@@ -921,6 +930,10 @@ impl Ferese {
)
.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.input_settings = config.input_settings;
if bindings_changed {
@@ -936,6 +949,7 @@ impl Ferese {
self.theme_settings = config.theme_settings;
self.panel_corner_radius = config.panel_corner_radius;
self.panel_background_opacity = config.panel_background_opacity;
self.panel_style = config.panel_style;
self.focus_effect = config.focus_effect;
self.column_width_presets = config.column_width_presets;
self.animations_enabled = config.animations_enabled;
+207 -12
View File
@@ -1,6 +1,98 @@
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 {
/// 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> {
let mut visible = self
.output_workspaces
@@ -228,6 +320,8 @@ impl Ferese {
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 is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_));
let workspace_focus = workspace.last_focused;
@@ -239,17 +333,18 @@ impl Ferese {
continue;
};
let layout =
match workspace
.layout
.resolve_geometry_with_constraints(bounds, self.gap_config, &constraints, focused)
{
Ok(layout) => layout,
Err(error) => {
tracing::error!(%error, ?workspace_id, "failed to compute tiled geometry");
continue;
}
};
let layout = match workspace.layout.resolve_geometry_with_constraints(
layout_bounds,
self.gap_config,
&constraints,
focused,
) {
Ok(layout) => layout,
Err(error) => {
tracing::error!(%error, ?workspace_id, "failed to compute tiled geometry");
continue;
}
};
let viewport_target = workspace.layout.viewport_x();
let viewport_motion = viewport_target.map(|target| {
let viewport = self
@@ -288,7 +383,7 @@ impl Ferese {
let rect = if workspace_fullscreen == Some(*id) {
fullscreen_bounds
} 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 {
match placement {
Some(WindowPlacement::Tiled) => {
@@ -597,6 +692,7 @@ impl Ferese {
pub(super) fn tiled_layout(&mut self, bounds: Rect) -> Result<LayoutResult, ferese_layout::LayoutError> {
let constraints = self.window_constraints();
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(
bounds,
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> {
self.windows
.ids()
+45 -1
View File
@@ -52,8 +52,9 @@ impl Ferese {
{
return;
}
let layout_bounds = self.workspace_layout_bounds(bounds, &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()
{
return;
@@ -338,6 +339,7 @@ impl Ferese {
let Some(bounds) = self.output_bounds() else {
return;
};
let bounds = self.workspace_layout_bounds(bounds, &self.workspaces.active().layout);
let constraints = self.window_constraints();
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) {
let Some(window) = self.focused_window else {
return;