diff --git a/src/toolbar_gtk/css.rs b/src/toolbar_gtk/css.rs index 49c61838a..25fbc2cf7 100644 --- a/src/toolbar_gtk/css.rs +++ b/src/toolbar_gtk/css.rs @@ -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 {{ @@ -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, @@ -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, @@ -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, @@ -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::*; @@ -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), @@ -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 = emitted_colors() .into_iter() .map(|(_, value)| value) @@ -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 {{" diff --git a/src/toolbar_gtk/view/capture_suppression/native_popovers.rs b/src/toolbar_gtk/view/capture_suppression/native_popovers.rs index 05cc795dc..2e88d72ea 100644 --- a/src/toolbar_gtk/view/capture_suppression/native_popovers.rs +++ b/src/toolbar_gtk/view/capture_suppression/native_popovers.rs @@ -232,22 +232,63 @@ fn rearm_proof(epoch: &Cell) { 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: >k4::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>, + original_child_size: RefCell>, + original_scroll_layout: RefCell>, }, } impl NativePopoverContent { fn new(popover: >k4::Popover) -> Self { if let Ok(menu) = popover.clone().downcast::() { + 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), }; } @@ -290,6 +331,8 @@ impl NativePopoverContent { surface, proof_model, original_model, + original_child_size, + original_scroll_layout, } = self else { return; @@ -303,6 +346,27 @@ impl NativePopoverContent { }; let proof_model_object = proof_model.clone().upcast::(); 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::() { + 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)); } @@ -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: >k4::Popover) { @@ -330,6 +397,8 @@ impl NativePopoverContent { surface, proof_model, original_model, + original_child_size, + original_scroll_layout, } => { let Ok(menu) = popover.clone().downcast::() else { return; @@ -337,10 +406,19 @@ impl NativePopoverContent { let proof_model_object = proof_model.clone().upcast::(); 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(); + } } } } diff --git a/src/toolbar_gtk/view/mod.rs b/src/toolbar_gtk/view/mod.rs index 169a32262..b37930bf4 100644 --- a/src/toolbar_gtk/view/mod.rs +++ b/src/toolbar_gtk/view/mod.rs @@ -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 @@ -271,6 +273,28 @@ impl CaptureSurfaceContent { } } +fn set_capture_transparent_class(widget: &impl IsA, 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, @@ -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, feedback: FeedbackSender, @@ -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, @@ -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; } } diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index bda44f9aa..e63815b3a 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -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 { @@ -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, @@ -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) } diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 1854e6d2e..ef518d085 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -2,6 +2,7 @@ mod builtin_contract; mod builtin_style_nodes; +mod capture_render; mod expectations; mod gtk_contract; mod gtk_level_widgets; diff --git a/src/toolbar_gtk/view/top_bar/tests/capture_render.rs b/src/toolbar_gtk/view/top_bar/tests/capture_render.rs new file mode 100644 index 000000000..e11631835 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/capture_render.rs @@ -0,0 +1,202 @@ +//! Private-display pixel tests. Each case has a fresh GTK process so failures +//! in native popup proof cannot hide window, wrapper, or menu regressions. + +mod lifecycle; +mod popups; +mod subprocess; +mod support; + +use super::super::{FeedbackSender, TopBar}; +use crate::toolbar_gtk::view::install_capture_css_provider; +use gtk4::prelude::*; +use subprocess::{CHILD_ENV, Outcome}; +use support::*; + +#[test] +fn capture_roots_override_user_css_and_restore_rendered_pixels() { + if std::env::var_os("WAYSCRIBER_REQUIRE_GTK_TESTS").is_none() + || std::env::var("WAYLAND_DISPLAY").as_deref() != Ok("wayscriber-widget-tests") + { + return; + } + + if std::env::var_os(CHILD_ENV).is_none() { + let name = concat!( + module_path!(), + "::capture_roots_override_user_css_and_restore_rendered_pixels" + ); + let name = name.strip_prefix("wayscriber::").unwrap(); + subprocess::run_cases(name); + return; + } + + gtk4::init().expect("private GTK display"); + // Production GTK defaults allow animations. A 400ms CSS transition must + // not outlive the first acknowledged transparent frame. + gtk4::Settings::default() + .unwrap() + .set_gtk_enable_animations(true); + gtk4::Settings::default() + .unwrap() + .set_gtk_font_name(Some("Sans 11")); + gtk4::Settings::default() + .unwrap() + .set_gtk_xft_dpi(96 * 1024); + let display = gtk4::gdk::Display::default().unwrap(); + let application = gtk4::CssProvider::new(); + application.load_from_string(&crate::toolbar_gtk::css::stylesheet(1.0)); + gtk4::style_context_add_provider_for_display( + &display, + &application, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + let capture = install_capture_css_provider(1.0); + let user = gtk4::CssProvider::new(); + let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let observed = errors.clone(); + user.connect_parsing_error(move |_, _, error| observed.borrow_mut().push(error.to_string())); + gtk4::style_context_add_provider_for_display( + &display, + &user, + gtk4::STYLE_PROVIDER_PRIORITY_USER, + ); + + let case = std::env::var(CHILD_ENV).unwrap(); + eprintln!( + "capture case: {case}, renderer: {}", + std::env::var("GSK_RENDERER").unwrap() + ); + let renderer = std::env::var("GSK_RENDERER").unwrap(); + let mut outcome = Outcome::Passed; + match case.as_str() { + "renderer-probe" => { + let window = gtk4::Window::builder() + .default_width(32) + .default_height(32) + .build(); + window.present(); + wait_for_frame(&window); + let renderer = window.renderer().unwrap(); + eprintln!("optional Vulkan probe selected {}", renderer.type_().name()); + if renderer.type_().name() != "GskVulkanRenderer" { + outcome = Outcome::VulkanUnavailable; + } else { + assert_renderer(&window); + } + window.destroy(); + } + case if case.starts_with("menu") => popups::menu(&user, case), + "lifecycle" => lifecycle::apply_paths(&user), + case if case.starts_with("popup-") => popups::popup(&user, case), + case => roots(&user, case), + } + assert!( + errors.borrow().is_empty(), + "user CSS fixture parse errors: {:?}", + errors.borrow() + ); + for provider in [&user, &capture, &application] { + gtk4::style_context_remove_provider_for_display(&display, provider); + } + + subprocess::complete(&renderer, &case, &outcome); + if outcome == Outcome::VulkanUnavailable { + std::process::exit(77); + } +} + +fn roots(user: >k4::CssProvider, case: &str) { + let (tx, _) = std::sync::mpsc::channel(); + let top = TopBar::new_for_test(FeedbackSender::new(tx)); + top.root.set_size_request(160, 64); + top.window.set_decorated(false); + let selector = match case { + "window" | "transition" | "animation" | "border-image" => "window", + "wrapper" | "wrapper-minimum" => "overlay.wayscriber-capture-surface-content", + "picture" | "proof-geometry" => "picture", + "universal" => "*", + _ => unreachable!(), + }; + let transition = if case == "transition" { + "transition: background-color 400ms linear, box-shadow 400ms linear;" + } else { + "" + }; + let image = if case == "border-image" { + "border-color: transparent; border-image-source: linear-gradient(90deg, #11e811, #11e811); border-image-slice: 1;" + } else { + "" + }; + let metrics = if case == "wrapper-minimum" { + "min-width: 360px; min-height: 120px; padding: 5px; margin: 2px;" + } else if case == "proof-geometry" { + "border-width: 9px; padding: 8px; margin: 3px;" + } else { + "" + }; + user.load_from_string( + &(painted_css(selector, "0 2px 6px #58c0e8") + + &format!("{selector} {{ {transition} {image} {metrics} }}")), + ); + if case == "animation" { + user.load_from_string(&(painted_css("window", "none") + r#" + @keyframes capture-fixture { + from { background-color: #23252e; } + to { background-color: #e86b18; } + } + window { background-image: none; animation: capture-fixture 400ms linear infinite alternate; } + "#)); + } + top.window.present(); + wait_for_frame(&top.window); + assert_renderer(&top.window); + if case == "animation" { + assert_fixture(&pixels(&top.window)); + assert_animated(&top.window); + let size = native_size(&top.window); + for generation in 1..=2 { + top.set_capture_transparent(true); + prove(generation, vec![top.capture_target()]); + assert_clear(&top.window, "active keyframe animation"); + assert_eq!(native_size(&top.window), size); + top.set_capture_transparent(false); + wait_for_frame(&top.window); + assert_fixture(&pixels(&top.window)); + assert_animated(&top.window); + } + top.window.destroy(); + return; + } + // The proof Picture is normally invisible. Reveal it without capture CSS + // to prove this fixture paints it before testing the guarded presentation. + if matches!(case, "picture" | "proof-geometry" | "universal") { + top.capture_surface.set_transparent(true); + top.capture_surface + .widget() + .remove_css_class(crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS); + wait_for_frame(&top.window); + assert_fixture(&pixels(&top.capture_surface.proof)); + top.capture_surface.set_transparent(false); + wait_for_frame(&top.window); + } + let normal = settled_pixels(&top.window); + if !matches!(case, "picture" | "proof-geometry") { + assert_fixture(&normal); + } + let size = native_size(&top.window); + + for generation in 1..=2 { + top.set_capture_transparent(true); + prove(generation, vec![top.capture_target()]); + // No transition-duration settling occurs after the proof waiter. + assert_clear(&top.window, case); + assert_clear(top.capture_surface.widget(), "proof wrapper"); + assert!(top.capture_surface.proof_visible()); + assert_eq!(native_size(&top.window), size); + + top.set_capture_transparent(false); + wait_for_frame(&top.window); + assert_restored(&top.window, &normal, case); + } + top.window.destroy(); +} diff --git a/src/toolbar_gtk/view/top_bar/tests/capture_render/lifecycle.rs b/src/toolbar_gtk/view/top_bar/tests/capture_render/lifecycle.rs new file mode 100644 index 000000000..9d7443e67 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/capture_render/lifecycle.rs @@ -0,0 +1,124 @@ +use super::{FeedbackSender, TopBar, support::*}; +use crate::toolbar_gtk::GtkToolbarUpdate; +use crate::ui::toolbar::{ToolbarBindingHints, ToolbarSnapshot}; +use gtk4::prelude::*; + +pub(super) fn apply_paths(user: >k4::CssProvider) { + user.load_from_string(&painted_css("window", "0 2px 6px #58c0e8")); + let state = crate::input::state::test_support::make_test_input_state(); + let mut update = GtkToolbarUpdate { + snapshot: ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ), + top_visible: false, + top_offset: (0.0, 0.0), + top_offset_seq: 0, + top_base_x: 0.0, + output_name: None, + rebind_modifier: Default::default(), + rebind_modifier_active: false, + modal_engaged: false, + drag_preview: None, + capture_suppressed: false, + capture_suppression_generation: None, + }; + let (tx, _) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + top.window.set_decorated(false); + + hidden_capture_and_cancel(&mut top, &mut update); + let (normal, size) = visible_capture_and_restore(&mut top, &mut update); + idle_hide_and_reveal(&mut top, &mut update, &normal, size); + + update.top_visible = false; + assert!(!top.apply(&update)); + assert!(!top.window.is_visible()); + + super::super::widget_support::detach_test_popovers(&mut top); + top.window.destroy(); +} + +fn hidden_capture_and_cancel(top: &mut TopBar, update: &mut GtkToolbarUpdate) { + assert!(!top.apply(update)); + + update.capture_suppressed = true; + update.capture_suppression_generation = Some(1); + assert!(!top.apply(update)); + assert!( + !top.window.is_visible(), + "capture cannot map hidden toolbar" + ); + + update.capture_suppressed = false; + update.capture_suppression_generation = None; + assert!(!top.apply(update)); + assert!( + !top.window.is_visible(), + "cancel keeps hidden toolbar hidden" + ); +} + +fn visible_capture_and_restore( + top: &mut TopBar, + update: &mut GtkToolbarUpdate, +) -> (Vec<[u8; 4]>, (i32, i32)) { + update.top_visible = true; + assert!(top.apply(update)); + wait_for_frame(&top.window); + assert!(top.apply(update)); + wait_for_frame(&top.window); + + // Weston may assign initial keyboard focus to the first slider after the + // map callback. Capture releases that focus; compare an unfocused normal + // toolbar so its Cairo focus ring is not part of the styling fixture. + gtk4::prelude::GtkWindowExt::set_focus(&top.window, gtk4::Widget::NONE); + assert!(gtk4::prelude::GtkWindowExt::focus(&top.window).is_none()); + + assert_renderer(&top.window); + let normal = settled_pixels(&top.window); + assert_fixture(&normal); + let size = native_size(&top.window); + + for generation in 2..=3 { + update.capture_suppressed = true; + update.capture_suppression_generation = Some(generation); + assert!(top.apply(update)); + assert!(top.drag_blocked.get()); + assert!(top.root.is_child_visible()); + assert_eq!(top.root.opacity(), 0.0); + + prove(generation, vec![top.capture_target()]); + assert_clear(&top.window, "apply capture"); + assert_eq!(native_size(&top.window), size); + + update.capture_suppressed = false; + update.capture_suppression_generation = None; + assert!(top.apply(update)); + assert!(!top.drag_blocked.get()); + assert!(top.root.is_child_visible()); + assert_eq!(top.root.opacity(), 1.0); + wait_for_frame(&top.window); + assert_restored(&top.window, &normal, "apply cancel/complete"); + } + + (normal, size) +} + +fn idle_hide_and_reveal( + top: &mut TopBar, + update: &mut GtkToolbarUpdate, + normal: &[[u8; 4]], + size: (i32, i32), +) { + update.snapshot.top_fade = 0.0; + assert!(top.apply(update)); + wait_for_frame(&top.window); + assert_clear(&top.window, "apply idle-hidden strip"); + assert_eq!(native_size(&top.window), size); + + update.snapshot.top_fade = 1.0; + assert!(top.apply(update)); + wait_for_frame(&top.window); + assert_restored(&top.window, normal, "apply idle reveal"); +} diff --git a/src/toolbar_gtk/view/top_bar/tests/capture_render/popups.rs b/src/toolbar_gtk/view/top_bar/tests/capture_render/popups.rs new file mode 100644 index 000000000..c8ca01393 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/capture_render/popups.rs @@ -0,0 +1,239 @@ +use super::support::*; +use crate::toolbar_gtk::view::top_bar::popovers::set_popover_capture_transparent; +use crate::toolbar_gtk::view::{ + CaptureProofTarget, CaptureSurfaceContent, capture_suppression::TooltipCapture, +}; +use gtk4::prelude::*; + +fn anchor() -> (gtk4::Window, gtk4::Button) { + let anchor = gtk4::Button::with_label("Popup anchor"); + let window = gtk4::Window::builder() + .default_width(320) + .default_height(200) + .decorated(false) + .child(&anchor) + .build(); + window.add_css_class("wayscriber-toolbar"); + window.present(); + wait_for_frame(&window); + assert_renderer(&window); + (window, anchor) +} + +pub(super) fn popup(user: >k4::CssProvider, case: &str) { + let values = crate::ui::theme::css::GtkStylesheetValues::new(1.0); + let shadow = match case { + "popup-none" => "none".to_string(), + "popup-small" => "0 1px 2px #58c0e8".to_string(), + "popup-default" => format!( + "0 {}px {}px #58c0e8", + values.shadow_offset_y, values.shadow_blur + ), + "popup-large" => "0 8px 24px #58c0e8".to_string(), + _ => unreachable!(), + }; + user.load_from_string(&painted_css( + "popover, popover > contents, popover > arrow", + "none", + )); + let (window, anchor) = anchor(); + let content = CaptureSurfaceContent::new(>k4::Label::new(Some("Popup content"))); + let popup = gtk4::Popover::new(); + popup.set_autohide(false); + popup.set_child(Some(content.widget())); + popup.set_parent(&anchor); + popup.popup(); + wait_for_frame(&popup); + let size = native_size(&popup); + + // Shadow overrides are deliberately ignored in normal and capture modes. + // Assert the policy before capture too, including smaller/absent shadows: + // changing extents at capture time would withdraw this native surface. + user.load_from_string( + &(painted_css("popover, popover > contents, popover > arrow", "none") + + &format!("popover > contents {{ box-shadow: {shadow}; }}")), + ); + wait_for_frame(&popup); + assert_eq!( + native_size(&popup), + size, + "stable application shadow policy" + ); + let normal = settled_pixels(&popup); + assert_fixture(&normal); + + for generation in 1..=2 { + set_popover_capture_transparent(&popup, &content, true, false); + prove( + generation, + vec![CaptureProofTarget::new_withdrawable( + "pixel-popup", + &popup, + &content, + )], + ); + assert_clear(&popup, case); + assert_eq!(native_size(&popup), size); + assert!(content.proof_visible()); + set_popover_capture_transparent(&popup, &content, false, true); + wait_for_frame(&popup); + assert_restored(&popup, &normal, case); + assert_eq!(native_size(&popup), size); + } + popup.popdown(); + popup.unparent(); + window.destroy(); +} + +pub(super) fn menu(user: >k4::CssProvider, case: &str) { + let painted = !matches!(case, "menu-default" | "menu-long"); + let long = case == "menu-long"; + user.load_from_string(&menu_css(case)); + + let (window, anchor) = anchor(); + let menu = menu_widget(&anchor, case == "menu-decorated", long); + let original_child = menu.child(); + let original_parent = original_child.as_ref().unwrap().parent(); + assert!( + original_parent.is_some(), + "GTK owns the original menu child" + ); + let scrolled = original_child + .as_ref() + .unwrap() + .downcast_ref::() + .unwrap(); + + menu.popup(); + wait_for_frame(&menu); + let original_request = original_child.as_ref().unwrap().size_request(); + if long { + assert_menu_scrollable(scrolled); + } + let original_max_content_height = scrolled.max_content_height(); + let original_policy = scrolled.policy(); + let original_propagation = ( + scrolled.propagates_natural_width(), + scrolled.propagates_natural_height(), + ); + + let normal = settled_pixels(&menu); + let size = native_size(&menu); + assert!( + normal.iter().any(|pixel| pixel[3] != 0), + "visible menu fixture" + ); + if painted { + assert_fixture(&normal); + } + let original_model = menu.menu_model(); + + let capture = TooltipCapture::new(); + capture.set_suppressed(true); + capture.install_tree(window.upcast_ref()); + let targets = capture.capture_popover_targets(); + assert_eq!(targets.len(), 1); + prove(1, targets); + assert_clear(&menu, "GTK-owned menu intermediaries and proof"); + assert_eq!(native_size(&menu), size, "menu native size during proof"); + assert_eq!(menu.child(), original_child); + assert_eq!(original_child.as_ref().unwrap().parent(), original_parent); + + capture.set_suppressed(false); + wait_for_frame(&menu); + assert_restored(&menu, &normal, "GTK-owned menu"); + assert_eq!(menu.menu_model(), original_model); + assert_eq!(original_child.as_ref().unwrap().parent(), original_parent); + assert_eq!(menu.child(), original_child); + assert_eq!( + original_child.as_ref().unwrap().size_request(), + original_request + ); + assert_eq!(native_size(&menu), size, "restored menu native size"); + assert_eq!(scrolled.max_content_height(), original_max_content_height); + assert_eq!(scrolled.policy(), original_policy); + assert_eq!( + ( + scrolled.propagates_natural_width(), + scrolled.propagates_natural_height() + ), + original_propagation + ); + + if long { + // Compare the initial scene before exercising restored menu scrolling; + // mid-scroll restoration is outside this fixture. + assert_menu_scrolls(&menu, scrolled); + assert_eq!(native_size(&menu), size); + } + + menu.popdown(); + menu.unparent(); + window.destroy(); +} + +fn menu_css(case: &str) -> String { + match case { + "menu-default" | "menu-long" => String::new(), + "menu" | "menu-decorated" => { + // Preserve the adversarial fixtures that paint every GTK-owned + // intermediary and explicitly zero the scrollbar's theme metrics. + let mut css = painted_css("popover, popover *", "none") + + "popover scrollbar, popover scrollbar * { min-width: 0; min-height: 0; padding: 0; margin: 0; }"; + if case == "menu-decorated" { + css += "popover scrolledwindow { padding: 4px; margin: 2px; }"; + } + css + } + _ => unreachable!("unknown menu fixture: {case}"), + } +} + +fn menu_widget(anchor: >k4::Button, decorated: bool, long: bool) -> gtk4::PopoverMenu { + let model = gtk4::gio::Menu::new(); + if long { + for item in 0..60 { + model.append(Some(&format!("Copy item {item}")), Some("clipboard.copy")); + } + } else { + model.append(Some("Copy"), Some("clipboard.copy")); + } + let menu = gtk4::PopoverMenu::from_model(Some(&model)); + menu.set_autohide(false); + menu.set_parent(anchor); + if long { + // Bound the initial popup so compositor placement need not shrink it. + menu.child() + .unwrap() + .downcast_ref::() + .unwrap() + .set_max_content_height(240); + } + if decorated { + let child = menu.child().unwrap(); + child.set_margin_start(3); + child.set_margin_end(5); + child.set_margin_top(4); + child.set_margin_bottom(6); + child.set_size_request(180, 100); + } + + menu +} + +fn assert_menu_scrollable(scrolled: >k4::ScrolledWindow) { + let adjustment = scrolled.vadjustment(); + assert!(adjustment.page_size() > 0.0, "allocated GTK viewport"); + assert!( + adjustment.upper() > adjustment.page_size(), + "60-item fixture must overflow the allocated GTK viewport" + ); +} + +fn assert_menu_scrolls(menu: >k4::PopoverMenu, scrolled: >k4::ScrolledWindow) { + assert_menu_scrollable(scrolled); + let adjustment = scrolled.vadjustment(); + adjustment.set_value((adjustment.upper() - adjustment.page_size()) / 2.0); + assert!(adjustment.value() > 0.0, "long menu must actually scroll"); + wait_for_frame(menu); +} diff --git a/src/toolbar_gtk/view/top_bar/tests/capture_render/subprocess.rs b/src/toolbar_gtk/view/top_bar/tests/capture_render/subprocess.rs new file mode 100644 index 000000000..e20b65422 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/capture_render/subprocess.rs @@ -0,0 +1,160 @@ +use std::process::{Command, Output}; + +pub(super) const CHILD_ENV: &str = "WAYSCRIBER_GTK_CAPTURE_RENDER_CHILD"; +const COMPLETION_PREFIX: &str = "WAYSCRIBER_CAPTURE_RENDER_COMPLETED "; + +#[derive(Debug, PartialEq)] +pub(super) enum Outcome { + Passed, + VulkanUnavailable, +} + +pub(super) fn complete(renderer: &str, case: &str, outcome: &Outcome) { + eprintln!("{}", completion_marker(renderer, case, outcome)); +} + +fn completion_marker(renderer: &str, case: &str, outcome: &Outcome) -> String { + let outcome = match outcome { + Outcome::Passed => "passed", + Outcome::VulkanUnavailable => "unavailable", + }; + format!("{COMPLETION_PREFIX}renderer={renderer} case={case} outcome={outcome}") +} + +fn child(name: &str, renderer: &str, case: &str) -> Command { + let mut command = Command::new(std::env::current_exe().expect("test binary")); + command + .args([name, "--exact", "--test-threads=1", "--nocapture"]) + .env(CHILD_ENV, case) + .env("GSK_RENDERER", renderer) + .env("LIBGL_ALWAYS_SOFTWARE", "1") + .env("G_DEBUG", "fatal-criticals"); + command +} + +fn validate(output: &Output, renderer: &str, case: &str) -> Result { + let outcome = match output.status.code() { + Some(0) => Outcome::Passed, + Some(77) if renderer == "vulkan" && case == "renderer-probe" => Outcome::VulkanUnavailable, + _ => return Err(format!("child exited with {}", output.status)), + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let markers: Vec<_> = stdout + .lines() + .chain(stderr.lines()) + .filter(|line| line.starts_with(COMPLETION_PREFIX)) + .collect(); + let expected = completion_marker(renderer, case, &outcome); + if markers != [expected.as_str()] { + return Err(format!( + "expected exactly {expected:?} after assertions and cleanup; observed {markers:?}" + )); + } + + Ok(outcome) +} + +fn run(name: &str, renderer: &str, case: &str) -> Result { + let output = child(name, renderer, case) + .output() + .map_err(|error| format!("could not run child: {error}"))?; + // Forward GTK diagnostics and the child's assertion/cleanup completion even + // on success. A stale exact filter exits zero without that completion. + eprint!("{}", String::from_utf8_lossy(&output.stdout)); + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + validate(&output, renderer, case) +} + +pub(super) fn run_cases(name: &str) { + let mut failures = Vec::new(); + let mut renderers = vec!["cairo", "gl"]; + match run(name, "vulkan", "renderer-probe") { + Ok(Outcome::Passed) => renderers.push("vulkan"), + Ok(Outcome::VulkanUnavailable) => { + eprintln!("SKIPPED: optional Vulkan capture pixels; GTK selected another renderer"); + } + Err(error) => failures.push(format!("vulkan/renderer-probe: {error}")), + } + + for renderer in renderers { + for case in [ + "window", + "wrapper", + "wrapper-minimum", + "picture", + "proof-geometry", + "universal", + "transition", + "animation", + "border-image", + "popup-none", + "popup-small", + "popup-default", + "popup-large", + "menu", + "menu-decorated", + "menu-default", + "menu-long", + "lifecycle", + ] { + if let Err(error) = run(name, renderer, case) { + failures.push(format!("{renderer}/{case}: {error}")); + } + } + } + assert!(failures.is_empty(), "capture render failures: {failures:?}"); + eprintln!("EXECUTED: GTK capture root pixel assertions (Cairo and GL)"); +} + +#[test] +fn zero_test_child_cannot_complete_a_case_or_renderer_probe() { + let name = concat!(module_path!(), "::__nonexistent_capture_render_child_test"); + let name = name.strip_prefix("wayscriber::").unwrap(); + let output = child(name, "cairo", "window") + .output() + .expect("run zero-test negative control"); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("running 0 tests")); + assert!(validate(&output, "cairo", "window").is_err()); + assert!(validate(&output, "vulkan", "renderer-probe").is_err()); +} + +#[test] +fn completion_requires_matching_case_renderer_outcome_and_exit_status() { + use std::os::unix::process::ExitStatusExt; + + let mut output = Output { + status: std::process::ExitStatus::from_raw(0), + stdout: Vec::new(), + stderr: Vec::new(), + }; + output.stderr = completion_marker("cairo", "window", &Outcome::Passed).into_bytes(); + assert_eq!(validate(&output, "cairo", "window"), Ok(Outcome::Passed)); + assert!(validate(&output, "gl", "window").is_err()); + assert!(validate(&output, "cairo", "wrapper").is_err()); + output.stdout = output.stderr.clone(); + assert!(validate(&output, "cairo", "window").is_err()); + output.stdout.clear(); + output.status = std::process::ExitStatus::from_raw(1 << 8); + assert!(validate(&output, "cairo", "window").is_err()); + + output.status = std::process::ExitStatus::from_raw(77 << 8); + output.stderr = + completion_marker("vulkan", "renderer-probe", &Outcome::VulkanUnavailable).into_bytes(); + assert_eq!( + validate(&output, "vulkan", "renderer-probe"), + Ok(Outcome::VulkanUnavailable) + ); + assert!(validate(&output, "vulkan", "window").is_err()); + assert!(validate(&output, "gl", "renderer-probe").is_err()); + output.stderr = + completion_marker("gl", "renderer-probe", &Outcome::VulkanUnavailable).into_bytes(); + assert!(validate(&output, "vulkan", "renderer-probe").is_err()); + output.stderr = completion_marker("vulkan", "window", &Outcome::VulkanUnavailable).into_bytes(); + assert!(validate(&output, "vulkan", "renderer-probe").is_err()); + output.stderr = completion_marker("vulkan", "renderer-probe", &Outcome::Passed).into_bytes(); + assert!(validate(&output, "vulkan", "renderer-probe").is_err()); + output.stderr.clear(); + assert!(validate(&output, "vulkan", "renderer-probe").is_err()); +} diff --git a/src/toolbar_gtk/view/top_bar/tests/capture_render/support.rs b/src/toolbar_gtk/view/top_bar/tests/capture_render/support.rs new file mode 100644 index 000000000..ff1bbe21c --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/capture_render/support.rs @@ -0,0 +1,181 @@ +use crate::toolbar_gtk::view::{ + CaptureProofTarget, capture_suppression::wait_for_presented_transparency, + widget_native_is_mapped, +}; +use gtk4::prelude::*; +use std::time::{Duration, Instant}; + +pub(super) fn prove(generation: u64, targets: Vec) { + gtk4::glib::MainContext::default() + .block_on(wait_for_presented_transparency(generation, targets)) + .expect("transparent native buffer presentation"); +} + +pub(super) fn native_size(widget: &impl IsA) -> (i32, i32) { + let surface = widget.as_ref().native().unwrap().surface().unwrap(); + assert!(surface.is_mapped(), "native surface remains mapped"); + (surface.width(), surface.height()) +} + +pub(super) fn assert_renderer(window: >k4::Window) { + let renderer = window.renderer().unwrap(); + match std::env::var("GSK_RENDERER").unwrap().as_str() { + "cairo" => assert!(renderer.is::()), + "gl" => assert!(renderer.is::()), + "vulkan" => assert_eq!(renderer.type_().name(), "GskVulkanRenderer"), + _ => unreachable!(), + } +} + +pub(super) fn painted_css(selector: &str, shadow: &str) -> String { + format!( + "{selector} {{ background-color: #23252e; background-image: linear-gradient(90deg, #23252e, #e86b18); border: 3px solid #11e811; outline: 2px solid #c72bd9; box-shadow: {shadow}; }}" + ) +} + +pub(super) fn assert_fixture(pixels: &[[u8; 4]]) { + assert!( + pixels + .iter() + .filter(|pixel| **pixel == [17, 232, 17, 255]) + .count() + > 10, + "opaque green user border must override application CSS" + ); +} + +pub(super) fn assert_animated(widget: &impl IsA) { + let initial = pixels(widget); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + wait_for_frame(widget); + if pixels(widget) != initial { + return; + } + assert!( + Instant::now() < deadline, + "user keyframe animation did not advance" + ); + } +} + +pub(super) fn assert_clear(widget: &impl IsA, label: &str) { + let pixels = pixels(widget); + let painted = pixels.iter().filter(|pixel| pixel[3] != 0).count(); + assert_eq!(painted, 0, "{label} leaked pixels, including surface edges"); +} + +pub(super) fn assert_restored(widget: &impl IsA, expected: &[[u8; 4]], label: &str) { + // Removing the guard restores the user's transitions too. Only normal + // restoration may settle; capture pixels are checked immediately after + // the production presentation waiter, without an extra frame or delay. + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let actual = pixels(widget); + let changed = actual.iter().zip(expected).filter(|(a, b)| a != b).count(); + if actual.len() == expected.len() && changed == 0 { + return; + } + assert!( + Instant::now() < deadline, + "{label} failed to restore {changed} user CSS pixels and bounds {} vs {}", + actual.len(), + expected.len() + ); + wait_for_frame(widget); + } +} + +pub(super) fn settled_pixels(widget: &impl IsA) -> Vec<[u8; 4]> { + let deadline = Instant::now() + Duration::from_secs(2); + let mut previous = pixels(widget); + let mut stable = 0; + loop { + wait_for_frame(widget); + let current = pixels(widget); + if current == previous { + stable += 1; + } else { + stable = 0; + } + if stable == 3 { + return current; + } + assert!(Instant::now() < deadline, "normal fixture did not settle"); + previous = current; + } +} + +pub(super) fn pixels(widget: &impl IsA) -> Vec<[u8; 4]> { + let widget = widget.as_ref(); + let deadline = Instant::now() + Duration::from_secs(3); + let node = loop { + let snapshot = gtk4::Snapshot::new(); + gtk4::WidgetPaintable::new(Some(widget)).snapshot( + &snapshot, + f64::from(widget.width()), + f64::from(widget.height()), + ); + if let Some(node) = snapshot.to_node() { + break node; + } + + // A newly mapped popup shares the parent's clock. Its first parent + // paint can precede its own initial snapshot, especially with GL. + assert!(Instant::now() < deadline, "nonempty native render node"); + wait_for_frame(widget); + }; + // WidgetPaintable clips to the allocation. The margin checks transparent + // render bounds only; native popup geometry is asserted separately. + let viewport = gtk4::graphene::Rect::new( + -16.0, + -16.0, + widget.width() as f32 + 32.0, + widget.height() as f32 + 32.0, + ); + let texture = widget + .native() + .unwrap() + .renderer() + .unwrap() + .render_texture(&node, Some(&viewport)); + let stride = texture.width() as usize * 4; + let mut bytes = vec![0; stride * texture.height() as usize]; + texture.download(&mut bytes, stride); + + let (pixels, remainder) = bytes.as_chunks::<4>(); + assert!(remainder.is_empty(), "complete RGBA texture pixels"); + pixels.to_vec() +} + +pub(super) fn wait_for_frame(widget: &impl IsA) { + let context = gtk4::glib::MainContext::default(); + let deadline = Instant::now() + Duration::from_secs(3); + let clock = loop { + while context.pending() { + context.iteration(false); + } + if let Some(clock) = widget.as_ref().frame_clock() + && widget_native_is_mapped(widget.as_ref()) + && widget.as_ref().width() > 0 + && widget.as_ref().height() > 0 + { + break clock; + } + assert!(Instant::now() < deadline, "GTK widget did not map"); + std::thread::sleep(Duration::from_millis(2)); + }; + let painted = std::rc::Rc::new(std::cell::Cell::new(false)); + let observed = painted.clone(); + let handler = clock.connect_after_paint(move |_| observed.set(true)); + widget.as_ref().queue_draw(); + clock.request_phase(gtk4::gdk::FrameClockPhase::PAINT); + while !painted.get() { + while context.pending() { + context.iteration(false); + } + assert!(Instant::now() < deadline, "GTK did not paint"); + std::thread::sleep(Duration::from_millis(2)); + } + clock.disconnect(handler); +} diff --git a/tools/csharp-tests/GtkGateParityTests.cs b/tools/csharp-tests/GtkGateParityTests.cs index 689cf2873..c5c0efc5a 100644 --- a/tools/csharp-tests/GtkGateParityTests.cs +++ b/tools/csharp-tests/GtkGateParityTests.cs @@ -9,6 +9,7 @@ public sealed class GtkGateParityTests private const string PopupMarker = "EXECUTED: GTK native popup presentation regression"; private const string MenuMarker = "EXECUTED: GTK native menu presentation assertions"; private const string PenFeelMarker = "EXECUTED: GTK Pen feel fractional-scale render assertions"; + private const string CaptureRootMarker = "EXECUTED: GTK capture root pixel assertions (Cairo and GL)"; private const string ConfiguratorMarker = "EXECUTED: configurator GTK language rebuild assertions"; private const string FixtureSocket = "WAYSCRIBER_FIXTURE_SOCKET"; private const string FixtureOutput = "WAYSCRIBER_FIXTURE_OUTPUT"; @@ -21,6 +22,7 @@ public sealed class GtkGateParityTests [InlineData( PopupMarker, ExitCodes.Success )] [InlineData( MenuMarker, ExitCodes.Success )] [InlineData( PenFeelMarker, ExitCodes.Success )] + [InlineData( CaptureRootMarker, ExitCodes.Success )] [InlineData( ConfiguratorMarker, ExitCodes.Success )] [InlineData( null, ExitCodes.Failure )] public async Task BothGatesRequireNativeAssertionsAndCleanUpTheirPrivateDisplay( string? omittedMarker, int childExitCode ) @@ -76,6 +78,7 @@ public GtkGateFixture( string? omittedMarker, int childExitCode ) PopupMarker, MenuMarker, PenFeelMarker, + CaptureRootMarker, ConfiguratorMarker, ]; File.WriteAllLines( output, markers.Where( marker => marker != omittedMarker ) ); diff --git a/tools/csharp/Commands/DevelopmentCommands.cs b/tools/csharp/Commands/DevelopmentCommands.cs index ebff726d5..1458f5304 100644 --- a/tools/csharp/Commands/DevelopmentCommands.cs +++ b/tools/csharp/Commands/DevelopmentCommands.cs @@ -246,6 +246,7 @@ private static async Task GtkWidgets( ToolContext context, string[] args ) var combinedOutput = result.StandardOutput + result.StandardError; foreach ( var marker in new[] { "EXECUTED: GTK focus and slider assertions", "EXECUTED: GTK widget contract assertions", "EXECUTED: GTK native popup presentation regression", "EXECUTED: GTK native menu presentation assertions", + "EXECUTED: GTK capture root pixel assertions (Cairo and GL)", "EXECUTED: GTK Pen feel fractional-scale render assertions" } ) { if ( !combinedOutput.Contains( marker, StringComparison.Ordinal ) ) diff --git a/tools/test-gtk-widgets.sh b/tools/test-gtk-widgets.sh index 57b90925c..7e586848f 100755 --- a/tools/test-gtk-widgets.sh +++ b/tools/test-gtk-widgets.sh @@ -39,6 +39,7 @@ grep -Fq 'EXECUTED: GTK widget contract assertions' "$GTK_TEST_RUNTIME/tests.log grep -Fq 'EXECUTED: GTK Pen feel fractional-scale render assertions' "$GTK_TEST_RUNTIME/tests.log" grep -Fq 'EXECUTED: GTK native popup presentation regression' "$GTK_TEST_RUNTIME/tests.log" grep -Fq 'EXECUTED: GTK native menu presentation assertions' "$GTK_TEST_RUNTIME/tests.log" +grep -Fq 'EXECUTED: GTK capture root pixel assertions (Cairo and GL)' "$GTK_TEST_RUNTIME/tests.log" dbus-run-session -- cargo test --locked -p wayscriber-configurator --all-features \ app::component::tests:: -- --test-threads=1 --nocapture 2>&1 | tee "$GTK_TEST_RUNTIME/configurator-tests.log" grep -Fq 'EXECUTED: configurator GTK language rebuild assertions' "$GTK_TEST_RUNTIME/configurator-tests.log"