Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 76 additions & 32 deletions src/toolbar_gtk/css.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,23 +287,6 @@ window.wayscriber-toolbar {{
padding: {pad_popover}px;
box-shadow: 0 {shadow_offset_y}px {shadow_blur}px {popover_shadow};
}}
/* A popover is its own native surface. Its contents and arrow CSS nodes paint
outside the application-provided child, so capture suppression must clear
their pixels too. Keep border and shadow dimensions unchanged to avoid a
resize while the transparent replacement buffer is being acknowledged. */
.wayscriber-toolbar popover.{capture_transparent_class},
.wayscriber-toolbar popover.{capture_transparent_class} > arrow {{
background: transparent;
border-color: transparent;
box-shadow: none;
outline-color: transparent;
}}
.wayscriber-toolbar popover.{capture_transparent_class} > contents {{
background: transparent;
border-color: transparent;
box-shadow: 0 {shadow_offset_y}px {shadow_blur}px {popover_shadow_clear};
outline-color: transparent;
}}

/* ===== Popover / strip shared chrome =================================== */
.wayscriber-toolbar .section-title {{
Expand Down Expand Up @@ -358,16 +341,6 @@ tooltip {{
padding: {spacing_xs}px {spacing_std}px;
font-size: {font_tooltip}px;
}}
/* GtkTooltipWindow is a private GtkNative with its own popup surface. Its
native CSS node paints outside the application-provided custom widget, so
suppress the chrome without unmapping the popup. */
tooltip.{capture_transparent_class} {{
background: transparent;
border-color: transparent;
box-shadow: none;
outline-color: transparent;
color: transparent;
}}
"#,
panel = v.panel,
panel_border = v.panel_border,
Expand Down Expand Up @@ -402,7 +375,6 @@ tooltip.{capture_transparent_class} {{
badge_bg = v.badge_bg,
badge_border = v.badge_border,
popover_shadow = v.popover_shadow,
popover_shadow_clear = v.popover_shadow_clear,
section_title = v.section_title,
field_bg = v.field_bg,
field_border = v.field_border,
Expand All @@ -411,7 +383,6 @@ tooltip.{capture_transparent_class} {{
track_hover = v.track_hover,
meter_bar_h = crate::ui::theme::css::scaled_px(8.0, scale),
meter_bar_radius = crate::ui::theme::css::scaled_px(4.0, scale),
capture_transparent_class = CAPTURE_TRANSPARENT_CLASS,
radius_panel = v.radius_panel,
radius_button = v.radius_button,
radius_sm = v.radius_sm,
Expand Down Expand Up @@ -449,6 +420,79 @@ tooltip.{capture_transparent_class} {{
)
}

/// Capture is required to clear every painted native root, including user CSS
/// that overrides the ordinary application stylesheet. Install this separately
/// above user priority. Popup shadow geometry is stable even outside capture:
/// GTK may withdraw a native popup when its shadow extents change, losing the
/// mapped surface whose transparent presentation the capture barrier requires.
/// User shadow overrides are intentionally replaced by the application shadow;
/// other user decoration returns when the capture class is removed.
pub(super) fn capture_stylesheet(scale: f64) -> String {
let v = GtkStylesheetValues::new(scale);
format!(
r#"
.wayscriber-toolbar popover > contents {{
box-shadow: 0 {shadow_offset_y}px {shadow_blur}px {popover_shadow};
}}
window.wayscriber-toolbar.{class},
overlay.wayscriber-capture-surface-content.{class},
overlay.wayscriber-capture-surface-content.{class} > picture,
popover.{class},
popover.{class} *,
tooltip.{class},
tooltip.{class} * {{
transition: none;
animation: none;
background: transparent;
border-color: transparent;
border-image-source: none;
box-shadow: none;
outline-color: transparent;
}}
/* The overlay proof does not participate in measurement. Keep its texture
area nonempty even when user CSS gives every picture a border or padding. */
overlay.wayscriber-capture-surface-content.{class} > picture {{
border-width: 0;
padding: 0;
margin: 0;
}}
/* This empty widget exists only in GTK's temporary menu proof model. Its
decoration must not enlarge the menu's custom slot or consume its texture. */
overlay.wayscriber-capture-menu-proof.{class} {{
min-width: 1px;
min-height: 1px;
border-width: 0;
padding: 0;
margin: 0;
}}
popover.{class} .wayscriber-capture-menu-slot {{
min-width: 0;
min-height: 0;
border-width: 0;
padding: 0;
margin: 0;
}}
/* Preserve the popup's border widths, padding and shadow extents while its
replacement buffer is being acknowledged. Its native CSS nodes paint
outside the child that CaptureSurfaceContent hides. */
popover.{class} > contents {{
background: transparent;
border-color: transparent;
box-shadow: 0 {shadow_offset_y}px {shadow_blur}px {popover_shadow_clear};
outline-color: transparent;
}}
tooltip.{class} {{
color: transparent;
}}
"#,
class = CAPTURE_TRANSPARENT_CLASS,
shadow_offset_y = v.shadow_offset_y,
shadow_blur = v.shadow_blur,
popover_shadow = v.popover_shadow,
popover_shadow_clear = v.popover_shadow_clear,
)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -597,7 +641,7 @@ mod tests {

#[test]
fn every_theme_token_color_appears_in_the_stylesheet() {
let css = stylesheet(1.0);
let css = stylesheet(1.0) + &capture_stylesheet(1.0);
for (token, value) in emitted_colors() {
assert!(
css.contains(&value),
Expand All @@ -608,7 +652,7 @@ mod tests {

#[test]
fn stylesheet_has_no_color_literal_outside_the_theme_tokens() {
let css = stylesheet(1.0);
let css = stylesheet(1.0) + &capture_stylesheet(1.0);
let known: std::collections::HashSet<String> = emitted_colors()
.into_iter()
.map(|(_, value)| value)
Expand All @@ -629,7 +673,7 @@ mod tests {

#[test]
fn capture_suppression_clears_private_native_popup_chrome() {
let css = stylesheet(1.0);
let css = capture_stylesheet(1.0);
assert!(css.contains(&format!("tooltip.{CAPTURE_TRANSPARENT_CLASS} {{")));
assert!(css.contains(&format!(
"popover.{CAPTURE_TRANSPARENT_CLASS} > contents {{"
Expand Down
80 changes: 79 additions & 1 deletion src/toolbar_gtk/view/capture_suppression/native_popovers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,22 +232,63 @@ fn rearm_proof(epoch: &Cell<u64>) {
epoch.set(epoch.get().wrapping_add(1));
}

struct MenuScrollLayout {
scrolled: gtk4::ScrolledWindow,
propagate_width: bool,
propagate_height: bool,
policy: (gtk4::PolicyType, gtk4::PolicyType),
}

impl MenuScrollLayout {
fn hold(scrolled: &gtk4::ScrolledWindow) -> Self {
let layout = Self {
scrolled: scrolled.clone(),
propagate_width: scrolled.propagates_natural_width(),
propagate_height: scrolled.propagates_natural_height(),
policy: scrolled.policy(),
};
// The temporary model contains only the proof. Its natural size and
// scrollbar minimum lengths must not inflate the saved allocation.
scrolled.set_propagate_natural_width(false);
scrolled.set_propagate_natural_height(false);
scrolled.set_policy(gtk4::PolicyType::External, gtk4::PolicyType::External);

layout
}

fn restore(self) {
self.scrolled.set_policy(self.policy.0, self.policy.1);
self.scrolled
.set_propagate_natural_width(self.propagate_width);
self.scrolled
.set_propagate_natural_height(self.propagate_height);
}
}

enum NativePopoverContent {
Wrapped(CaptureSurfaceContent),
Menu {
surface: CaptureSurfaceContent,
proof_model: gtk4::gio::Menu,
original_model: RefCell<Option<gtk4::gio::MenuModel>>,
original_child_size: RefCell<Option<(gtk4::Widget, i32, i32)>>,
original_scroll_layout: RefCell<Option<MenuScrollLayout>>,
},
}

impl NativePopoverContent {
fn new(popover: &gtk4::Popover) -> Self {
if let Ok(menu) = popover.clone().downcast::<gtk4::PopoverMenu>() {
let surface = CaptureSurfaceContent::empty();
surface
.widget()
.add_css_class("wayscriber-capture-menu-proof");
return Self::Menu {
surface: CaptureSurfaceContent::empty(),
surface,
proof_model: capture_menu_proof_model(),
original_model: RefCell::new(menu.menu_model()),
original_child_size: RefCell::new(None),
original_scroll_layout: RefCell::new(None),
};
}

Expand Down Expand Up @@ -290,6 +331,8 @@ impl NativePopoverContent {
surface,
proof_model,
original_model,
original_child_size,
original_scroll_layout,
} = self
else {
return;
Expand All @@ -303,6 +346,27 @@ impl NativePopoverContent {
};
let proof_model_object = proof_model.clone().upcast::<gtk4::gio::MenuModel>();
if menu.menu_model().as_ref() != Some(&proof_model_object) {
// Keep the class-owned child's allocation while replacing its
// model. The proof slot must not resize the native popup, and the
// child itself remains owned and parented by GtkPopoverMenu.
if original_child_size.borrow().is_none()
&& let Some(child) = menu.child()
{
let (width, height) = child.size_request();
// GTK's public outer-allocation getters include CSS margins.
// compute_bounds returns the border box and would shrink a
// CSS-margined child when used as its minimum request.
#[allow(deprecated)]
let allocation = (child.allocated_width(), child.allocated_height());
// The temporary model's natural requisition must not grow
// beyond that allocation. Restore normal model propagation
// together with its original minimum request afterward.
if let Some(scrolled) = child.downcast_ref::<gtk4::ScrolledWindow>() {
original_scroll_layout.replace(Some(MenuScrollLayout::hold(scrolled)));
}
child.set_size_request(allocation.0, allocation.1);
original_child_size.replace(Some((child, width, height)));
}
original_model.replace(menu.menu_model());
menu.set_menu_model(Some(proof_model));
}
Expand All @@ -311,6 +375,9 @@ impl NativePopoverContent {
{
log::error!("could not install GTK menu capture proof widget");
}
if let Some(slot) = surface.widget().parent() {
slot.add_css_class("wayscriber-capture-menu-slot");
}
}

fn restore(&self, popover: &gtk4::Popover) {
Expand All @@ -330,17 +397,28 @@ impl NativePopoverContent {
surface,
proof_model,
original_model,
original_child_size,
original_scroll_layout,
} => {
let Ok(menu) = popover.clone().downcast::<gtk4::PopoverMenu>() else {
return;
};
let proof_model_object = proof_model.clone().upcast::<gtk4::gio::MenuModel>();
if menu.menu_model().as_ref() == Some(&proof_model_object) {
if surface.widget().parent().is_some() {
if let Some(slot) = surface.widget().parent() {
slot.remove_css_class("wayscriber-capture-menu-slot");
}
menu.remove_child(surface.widget());
}
menu.set_menu_model(original_model.borrow().as_ref());
}
if let Some((child, width, height)) = original_child_size.take() {
child.set_size_request(width, height);
}
if let Some(layout) = original_scroll_layout.take() {
layout.restore();
}
}
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/toolbar_gtk/view/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ impl CaptureSurfaceContent {
}

fn set_transparent(&self, transparent: bool) {
set_capture_transparent_class(&self.root, transparent);

let opacity = if transparent { 0.0 } else { 1.0 };
if let Some(content) = self.root.child()
&& (content.opacity() - opacity).abs() > f64::EPSILON
Expand Down Expand Up @@ -271,6 +273,28 @@ impl CaptureSurfaceContent {
}
}

fn set_capture_transparent_class(widget: &impl IsA<gtk4::Widget>, transparent: bool) {
if transparent {
widget.add_css_class(super::css::CAPTURE_TRANSPARENT_CLASS);
} else {
widget.remove_css_class(super::css::CAPTURE_TRANSPARENT_CLASS);
}
}

fn install_capture_css_provider(scale: f64) -> gtk4::CssProvider {
let provider = gtk4::CssProvider::new();
provider.load_from_string(&super::css::capture_stylesheet(scale));
if let Some(display) = gtk4::gdk::Display::default() {
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_USER + 1,
);
}

provider
}

#[derive(Clone)]
struct CaptureProofTarget {
name: &'static str,
Expand Down Expand Up @@ -447,6 +471,7 @@ pub(super) struct Windows {
top: top_bar::TopBar,
tooltip_capture: capture_suppression::TooltipCapture,
css_provider: gtk4::CssProvider,
capture_css_provider: gtk4::CssProvider,
css_scale_milli: i64,
pinned_output: Option<String>,
feedback: FeedbackSender,
Expand All @@ -468,6 +493,7 @@ impl Windows {
top: top_bar::TopBar::new(feedback.clone()),
tooltip_capture: capture_suppression::TooltipCapture::new(),
css_provider,
capture_css_provider: install_capture_css_provider(1.0),
css_scale_milli: 1000,
pinned_output: None,
feedback,
Expand Down Expand Up @@ -611,6 +637,8 @@ impl Windows {
if milli != self.css_scale_milli {
self.css_provider
.load_from_string(&super::css::stylesheet(scale));
self.capture_css_provider
.load_from_string(&super::css::capture_stylesheet(scale));
self.css_scale_milli = milli;
}
}
Expand Down
11 changes: 7 additions & 4 deletions src/toolbar_gtk/view/top_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,8 +560,7 @@ impl TopBar {
} else {
self.hide_popovers_for_window_hide();
}
self.capture_surface
.set_transparent(presentation.capture_transparent);
self.set_capture_transparent(presentation.capture_transparent);
super::set_surface_input_enabled(&self.window, false);
self.window.set_visible(false);
if let Some(generation) = update.capture_suppression_generation {
Expand Down Expand Up @@ -620,8 +619,7 @@ impl TopBar {
}
self.window.set_visible(true);
let presentation = presentation.with_idle_hidden(snapshot.top_strip_hidden());
self.capture_surface
.set_transparent(presentation.capture_transparent);
self.set_capture_transparent(presentation.capture_transparent);
super::set_visual_hidden(
&self.window,
&self.root,
Expand All @@ -646,6 +644,11 @@ impl TopBar {
true
}

fn set_capture_transparent(&self, transparent: bool) {
super::set_capture_transparent_class(&self.window, transparent);
self.capture_surface.set_transparent(transparent);
}

pub(in crate::toolbar_gtk::view) fn capture_target(&self) -> CaptureProofTarget {
CaptureProofTarget::new("top", &self.window, &self.capture_surface)
}
Expand Down
Loading
Loading