From 5062e013dc114fa0205a7bb6c4af1ade2507d1fe Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Sat, 3 Oct 2026 11:42:14 +0300 Subject: [PATCH 1/4] feat(display): preserve frame transparency through composition Keep accepted alpha operations independent of redisplay and project GNU active/inactive opacity, focus redirection and lower-limit semantics into the display host. Apply background alpha to paint layers without fading foreground text, and apply frame/lifecycle opacity once to completed premultiplied pictures. Use complementary weighted composition for Crossfade, ScaleZoom, Parallax and FadeEdges while retaining ordered source-over effects. Convert native output at the final boundary and admit required scratch pictures before presentation, retiring obsolete owners before the resource census and retrying frames that cannot fit. Add focused evaluator, compositor and offscreen regressions with a public selection map and generated-Lisp prerequisites. --- .../src/frame_glyphs.rs | 3 + .../src/glyph_matrix.rs | 10 + .../src/glyph_matrix/tests/mod.rs | 17 + .../src/render_thread/child_frames.rs | 26 + .../frame_compositor/layout_driver.rs | 74 ++ .../src/render_thread/frame_ingest.rs | 44 +- .../src/render_thread/frame_windows.rs | 50 + .../render_thread/frame_windows/tests/mod.rs | 109 ++ .../render_thread/render_pass/chrome/mod.rs | 2 +- .../render_pass/composition_targets.rs | 213 +++- .../composition_targets/tests/mod.rs | 72 ++ .../render_thread/render_pass/full_render.rs | 46 +- .../src/render_thread/render_pass/mod.rs | 95 +- .../src/render_thread/render_pass/present.rs | 1 + .../render_pass/retained_scroll.rs | 67 +- .../render_pass/retained_scroll/tests.rs | 2 +- .../render_pass/retained_static.rs | 12 +- .../src/render_thread/render_pass/scene.rs | 71 +- .../scene/native_boundary_tests.rs | 342 +++++ .../scene/pane_size_boundary_tests.rs | 474 +++++++ .../scene/pinned_lifecycle_tests.rs | 264 ++++ .../render_pass/scene/retained_owner_tests.rs | 577 +++++++++ .../render_thread/render_pass/scene/tests.rs | 1113 +++++++++++++++++ .../src/render_thread/transitions.rs | 51 + .../src/render_thread/window_commands.rs | 28 + .../src/render_thread/window_events.rs | 8 + .../src/thread_comm.rs | 10 + .../src/thread_comm/frame_opacity.rs | 117 ++ .../thread_comm/frame_opacity/tests/mod.rs | 201 +++ .../src/thread_comm/tests/mod.rs | 32 + .../src/display_frame_output.rs | 2 + .../src/display_mock_frame.rs | 3 + crates/neomacs-layout-engine/src/engine.rs | 18 +- .../src/output/builder.rs | 2 + .../src/output/builder/tests/mod.rs | 5 +- .../src/output/frame_state.rs | 5 + .../src/output/install_request.rs | 1 + .../src/renderer/child_frames.rs | 322 ++++- .../src/renderer/content.rs | 58 +- .../src/renderer/draw/mod.rs | 21 +- .../src/renderer/glyphs.rs | 14 +- .../src/renderer/layer_backgrounds.rs | 114 +- .../src/renderer/layer_effects.rs | 13 +- .../src/renderer/layer_text.rs | 12 +- .../src/renderer/layout_pass.rs | 39 +- .../src/renderer/layout_pass/tests/mod.rs | 8 +- .../neomacs-renderer-wgpu/src/renderer/mod.rs | 377 +++--- .../src/renderer/paint/blit.rs | 31 +- .../src/renderer/resources.rs | 13 +- .../src/renderer/transitions.rs | 216 ++-- .../src/shaders/glyph_coverage.wgsl | 9 + .../src/shaders/image.wgsl | 25 + .../tests/offscreen_frame.rs | 4 + .../tests/offscreen_frame/fade_edges_test.rs | 278 ++++ .../tests/offscreen_frame/opacity_test.rs | 743 +++++++++++ crates/neomacs/src/main.rs | 67 + crates/neomacs/src/tests/main_test.rs | 23 + .../emacs_core/display/display_host/mod.rs | 21 + .../src/emacs_core/display/frame/mod.rs | 66 +- .../src/emacs_core/display/window_cmds/mod.rs | 32 + .../display/window_cmds/tests/mod.rs | 100 ++ .../emacs_core/lisp/native/builtins/hooks.rs | 36 +- .../src/emacs_core/lisp/reader/mod.rs | 27 +- .../src/emacs_core/lisp/reader/tests/mod.rs | 148 +++ .../src/emacs_core/runtime/eval/mod.rs | 6 + .../runtime/eval/runtime_projection.rs | 2 + .../runtime/eval/tests/idle_redisplay.rs | 94 ++ crates/neovm-core/src/window/frame_alpha.rs | 67 + .../src/window/frame_alpha/tests/mod.rs | 72 ++ crates/neovm-core/src/window/mod.rs | 23 + docs/testing/transparency-tests.txt | 178 +++ docs/testing/transparency.md | 89 ++ lisp/term/neo-win.el | 2 - 73 files changed, 7041 insertions(+), 476 deletions(-) create mode 100644 crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets/tests/mod.rs create mode 100644 crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs create mode 100644 crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs create mode 100644 crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pinned_lifecycle_tests.rs create mode 100644 crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs create mode 100644 crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs create mode 100644 crates/neomacs-display-runtime/src/thread_comm/frame_opacity.rs create mode 100644 crates/neomacs-display-runtime/src/thread_comm/frame_opacity/tests/mod.rs create mode 100644 crates/neomacs-renderer-wgpu/tests/offscreen_frame/fade_edges_test.rs create mode 100644 crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs create mode 100644 crates/neovm-core/src/window/frame_alpha.rs create mode 100644 crates/neovm-core/src/window/frame_alpha/tests/mod.rs create mode 100644 docs/testing/transparency-tests.txt create mode 100644 docs/testing/transparency.md diff --git a/crates/neomacs-display-protocol/src/frame_glyphs.rs b/crates/neomacs-display-protocol/src/frame_glyphs.rs index 820ad4b69b..d1b5c34509 100644 --- a/crates/neomacs-display-protocol/src/frame_glyphs.rs +++ b/crates/neomacs-display-protocol/src/frame_glyphs.rs @@ -1191,6 +1191,8 @@ pub struct FrameGlyphBuffer { pub outer_border_color: Color, /// Background opacity (1.0 = opaque, 0.0 = transparent) pub background_alpha: f32, + /// GNU whole-frame active/inactive opacity; negative means leave unchanged. + pub frame_alpha: [f32; 2], /// Whether this frame should not accept keyboard focus pub no_accept_focus: bool, @@ -1565,6 +1567,7 @@ impl FrameGlyphBuffer { outer_border_width: 0.0, outer_border_color: Color::BLACK, background_alpha: 1.0, + frame_alpha: [-1.0; 2], no_accept_focus: false, glyphs: Vec::with_capacity(10000), frame_chrome: crate::frame_chrome::FrameChrome::default(), diff --git a/crates/neomacs-display-protocol/src/glyph_matrix.rs b/crates/neomacs-display-protocol/src/glyph_matrix.rs index 3897647f58..3dba579477 100644 --- a/crates/neomacs-display-protocol/src/glyph_matrix.rs +++ b/crates/neomacs-display-protocol/src/glyph_matrix.rs @@ -8,6 +8,10 @@ //! `pixel_width`; GUI backends must use that rather than reconstructing every //! glyph as one frame column. +fn default_frame_alpha() -> [f32; 2] { + [-1.0; 2] +} + use super::effect_config::EffectsConfig; use super::face::{BoxVerticalEdges, FaceAttributes, UnderlineStyle}; use super::frame_chrome::{FrameChrome, FrameChromeContent, PresentationId}; @@ -2024,6 +2028,9 @@ pub struct FrameDisplayState { #[serde(default)] pub outer_border_color: Color, pub background_alpha: f32, + /// GNU whole-frame active/inactive opacity; negative means leave unchanged. + #[serde(default = "default_frame_alpha")] + pub frame_alpha: [f32; 2], pub no_accept_focus: bool, pub window_infos: Vec, pub transition_hints: Vec, @@ -2549,6 +2556,7 @@ impl FrameDisplayState { a: 1.0, }, background_alpha: 1.0, + frame_alpha: [-1.0; 2], no_accept_focus: false, window_infos: Vec::new(), transition_hints: Vec::new(), @@ -2622,6 +2630,7 @@ impl FrameDisplayState { state.outer_border_width = buf.outer_border_width; state.outer_border_color = buf.outer_border_color; state.background_alpha = buf.background_alpha; + state.frame_alpha = buf.frame_alpha; state.no_accept_focus = buf.no_accept_focus; state.faces = buf.faces.clone(); state.font_catalog_generation = buf.font_catalog_generation; @@ -2775,6 +2784,7 @@ impl FrameDisplayState { buf.outer_border_width = self.outer_border_width; buf.outer_border_color = self.outer_border_color; buf.background_alpha = self.background_alpha; + buf.frame_alpha = self.frame_alpha; buf.no_accept_focus = self.no_accept_focus; // Copy faces diff --git a/crates/neomacs-display-protocol/src/glyph_matrix/tests/mod.rs b/crates/neomacs-display-protocol/src/glyph_matrix/tests/mod.rs index b7c07eadd7..fe2c0a0e88 100644 --- a/crates/neomacs-display-protocol/src/glyph_matrix/tests/mod.rs +++ b/crates/neomacs-display-protocol/src/glyph_matrix/tests/mod.rs @@ -18,6 +18,23 @@ fn settled_point( } use super::*; + +#[test] +fn gnu_frame_alpha_survives_display_snapshot_materialization() { + let mut glyphs = FrameGlyphBuffer::with_size(640.0, 480.0); + glyphs.background_alpha = 0.25; + glyphs.frame_alpha = [0.8, -1.0]; + let state = FrameDisplayState::from_frame_glyph_buffer(&glyphs); + assert_eq!(state.background_alpha, 0.25); + assert_eq!(state.frame_alpha, [0.8, -1.0]); + let materialized = state.materialize(); + assert_eq!(materialized.background_alpha, 0.25); + assert_eq!(materialized.frame_alpha, [0.8, -1.0]); + assert_eq!( + FrameDisplayState::new(80, 25, 8.0, 16.0).frame_alpha, + [-1.0; 2] + ); +} use crate::DisplayFrameId; mod row_appearance_test; diff --git a/crates/neomacs-display-runtime/src/render_thread/child_frames.rs b/crates/neomacs-display-runtime/src/render_thread/child_frames.rs index 130049cb19..b9598f89e7 100644 --- a/crates/neomacs-display-runtime/src/render_thread/child_frames.rs +++ b/crates/neomacs-display-runtime/src/render_thread/child_frames.rs @@ -68,6 +68,8 @@ impl EntryAnimation { pub(crate) struct ChildFrameEntry { pub frame_id: u64, pub frame: FrameGlyphBuffer, + /// Last applied native opacity; GNU nil retains this across payloads. + pub applied_frame_alpha: f32, /// Computed absolute position on screen (from parent_x/parent_y) pub abs_x: f32, pub abs_y: f32, @@ -409,6 +411,25 @@ impl ChildFrameManager { before != after } + /// Reclaim time-expired composition ownership before mandatory admission. + /// Sampling expiry is pure: do not advance drift, placement or the submitted + /// interaction projection when a frame might still be refused. + pub fn reclaim_finished_composition(&mut self, sample: FrameSample) -> bool { + let mut changed = self.prune_crossfades(sample); + changed |= self.prune_dying(sample); + for entry in self.frames.values_mut() { + if entry + .animation + .as_ref() + .is_some_and(|animation| animation.motion.sample(sample).finished) + { + entry.animation = None; + changed = true; + } + } + changed + } + /// Drop every crossfade, returning their leases to the pool. The /// device-loss path calls this: the leased textures died with the /// device. @@ -562,11 +583,16 @@ impl ChildFrameManager { "child_frame_lifecycle: render_thread_child_buffer" ); + let applied_frame_alpha = self + .frames + .get(&frame_id.get()) + .map_or(1.0, |entry| entry.applied_frame_alpha); self.frames.insert( frame_id.get(), ChildFrameEntry { frame_id: frame_id.get(), frame: buf, + applied_frame_alpha, abs_x: placed.root_relative().x(), abs_y: placed.root_relative().y(), clip_in_root: placed.clip_in_root(), diff --git a/crates/neomacs-display-runtime/src/render_thread/frame_compositor/layout_driver.rs b/crates/neomacs-display-runtime/src/render_thread/frame_compositor/layout_driver.rs index 136fdc48cb..bc871072ae 100644 --- a/crates/neomacs-display-runtime/src/render_thread/frame_compositor/layout_driver.rs +++ b/crates/neomacs-display-runtime/src/render_thread/frame_compositor/layout_driver.rs @@ -99,6 +99,80 @@ impl LayoutDriver { matches!(self, Self::Animating { .. }) } + /// Borrow-only eligibility for the same pane plan `on_frame` will place. + /// A pending first frame uses the ring's current picture, which becomes + /// previous on rotation; later ticks use only the picture already pinned. + /// Conservatively treat historical pixels as possibly fractional, without + /// allocating for enabled-but-idle motion or zero-weight terminal patches. + pub(in crate::render_thread) fn needs_native_conversion( + &self, + frame: FrameSample, + grid: PixelGrid, + candidate: Option<&SnapshotLease>, + ) -> bool { + let Self::Animating { outgoing, .. } = self else { + return false; + }; + let picture = match outgoing { + OutgoingPicture::Unpinned => candidate, + OutgoingPicture::Pinned(picture) => picture.as_ref(), + }; + if picture.is_none() { + return false; + } + self.planned_sample(frame, grid).is_some_and(|sample| { + sample.pane_blits().iter().any(|pane| { + pane.source == neomacs_renderer_wgpu::PaneSource::Previous + && pane.opacity > 0.0 + && pane.bounds.width > 0.0 + && pane.bounds.height > 0.0 + }) + }) + } + + /// The same borrow-only splice/sample used by eligibility and placement. + pub(in crate::render_thread) fn planned_sample( + &self, + frame: FrameSample, + grid: PixelGrid, + ) -> Option { + let Self::Animating { morph, .. } = self else { + return None; + }; + let spliced = morph.spliced(frame); + let morph = match spliced.as_ref() { + Some(spliced) => spliced, + None if morph.has_pending_retarget() => return None, + None => morph.as_ref(), + }; + Some(morph.sample(frame, grid)) + } + + /// Retire only a terminal source. Keep the CPU morph for acquired-frame + /// settlement, and keep the pin decision so a later tick cannot repin history. + pub(in crate::render_thread) fn reclaim_finished_outgoing( + &mut self, + frame: FrameSample, + grid: PixelGrid, + ) { + if self + .planned_sample(frame, grid) + .is_none_or(|sample| sample.motion.finished()) + { + if let Self::Animating { outgoing, .. } = self { + *outgoing = OutgoingPicture::Pinned(None); + } + } + } + + /// Geometry/device replacement invalidates GPU history, not the CPU morph + /// or its last submitted interaction. Do not repin a picture of the motion. + pub(in crate::render_thread) fn discard_outgoing_picture(&mut self) { + if let Self::Animating { outgoing, .. } = self { + *outgoing = OutgoingPicture::Pinned(None); + } + } + /// Pin the picture this motion fades *from*, if it has not been pinned. /// /// Called by the render pass on every frame of a motion, with the ring's diff --git a/crates/neomacs-display-runtime/src/render_thread/frame_ingest.rs b/crates/neomacs-display-runtime/src/render_thread/frame_ingest.rs index c9dcfb0ffa..cb4a15bb62 100644 --- a/crates/neomacs-display-runtime/src/render_thread/frame_ingest.rs +++ b/crates/neomacs-display-runtime/src/render_thread/frame_ingest.rs @@ -165,6 +165,7 @@ impl RenderApp { /// the instant swap GNU Emacs does. fn lease_child_resize_crossfade( render_policy: &crate::render_thread::render_quality::RenderQualityPolicy, + style: &crate::render_thread::state::ChildFrameStyle, renderer: Option<&mut neomacs_renderer_wgpu::WgpuRenderer>, window_state: &mut crate::render_thread::frame_windows::GuiFrameWindowState, frame_id: u64, @@ -193,33 +194,38 @@ impl RenderApp { return None; } let scale = window_state.scale_factor() as f32; - let phys_w = (old_width * scale).ceil() as u32; - let phys_h = (old_height * scale).ceil() as u32; + let shadow_extent = if style.shadow_enabled { + style.shadow_layers as f32 * style.shadow_offset.max(0.0) + } else { + 0.0 + }; + let phys_w = ((old_width + shadow_extent) * scale).ceil() as u32; + let phys_h = ((old_height + shadow_extent) * scale).ceil() as u32; let Some(size) = neomacs_renderer_wgpu::SnapshotSize::new(phys_w, phys_h) else { return None; }; let renderer = renderer?; let lease = renderer.acquire_snapshot(size).ok()?; - let view = lease.view().clone(); let atlas = window_state.render.compositor.glyph_atlas.as_mut()?; - renderer.render_frame_content( - &view, + let previous_scale = renderer.scale_factor(); + renderer.set_scale_factor(scale); + renderer.capture_child_frame_picture( + &lease, &old_entry.frame, atlas, - phys_w, - phys_h, - 0.0, - 0.0, - false, - None, - 0.0, - None, - None, - 1.0, - 1.0, - [0.0; 2], + style.corner_radius, + style.shadow_enabled, + style.shadow_layers, + style.shadow_offset, + style.shadow_opacity, ); - Some((lease, old_width, old_height, resize_spec)) + renderer.set_scale_factor(previous_scale); + Some(( + lease, + phys_w as f32 / scale, + phys_h as f32 / scale, + resize_spec, + )) } #[cfg(feature = "webview")] @@ -898,6 +904,7 @@ impl RenderApp { let cursor_config = self.cursor_defaults.config_snapshot(); let resize_crossfade = Self::lease_child_resize_crossfade( &self.render_policy, + &self.child_frame_style, self.renderer.as_mut(), window_state, frame_id.get(), @@ -986,6 +993,7 @@ impl RenderApp { let new_presentation = frame.presentation_id; let resize_crossfade = Self::lease_child_resize_crossfade( &self.render_policy, + &self.child_frame_style, self.renderer.as_mut(), ws, frame_id.get(), diff --git a/crates/neomacs-display-runtime/src/render_thread/frame_windows.rs b/crates/neomacs-display-runtime/src/render_thread/frame_windows.rs index a1aadafc42..dde9807be0 100644 --- a/crates/neomacs-display-runtime/src/render_thread/frame_windows.rs +++ b/crates/neomacs-display-runtime/src/render_thread/frame_windows.rs @@ -318,6 +318,9 @@ pub(crate) struct GuiFrameRenderState { /// into the swapchain as the final step. Recreated on resize. pub(super) frame_post_src: Option, pub(super) native_content_src: Option, + pub(super) child_opacity_src: Option, + pub(super) child_resize_src: Option, + pub(super) applied_frame_alpha: f32, /// The current native input-method composition, if any. /// /// `Option` is the active-state invariant: a preedit cannot be "active" @@ -523,6 +526,9 @@ impl GuiFrameRenderState { }, frame_post_src: None, native_content_src: None, + child_opacity_src: None, + child_resize_src: None, + applied_frame_alpha: 1.0, input_method: InputMethodState::default(), cursor: CursorState::new(at), mouse_pos: (0.0, 0.0), @@ -552,6 +558,17 @@ impl GuiFrameRenderState { } pub(super) fn set_surface_state(&mut self, state: SurfaceState) { + if let GuiFramePresentState::Drawable { surface, .. } = self.present_state { + let compatible = matches!(state, SurfaceState::Drawable(next) + if surface.content_surface() == next.content_surface()); + if !compatible { + // Historical pane UVs describe the old content geometry. Keep + // motion/interaction CPU state, but use the established missing- + // history fallback until an acquired frame publishes placement. + self.compositor.layout.discard_outgoing_picture(); + self.mark_dirty(); + } + } self.present_state = match state { SurfaceState::Suspended => GuiFramePresentState::Suspended, SurfaceState::Drawable(surface) => GuiFramePresentState::Drawable { @@ -595,6 +612,38 @@ impl GuiFrameRenderState { Some((point.x(), point.y())) } + /// Project already-applied native controls; scenes never replay setters. + pub(super) fn apply_frame_opacity( + &mut self, + controls: &crate::thread_comm::FrameOpacityState, + ) -> bool { + let mut changed = false; + if let Some(alpha) = controls.applied(self.emacs_frame_id) + && alpha != self.applied_frame_alpha + { + self.applied_frame_alpha = alpha; + changed = true; + } + let mut child_changed = false; + for (&id, entry) in &mut self.compositor.child_frames.frames { + if let Some(alpha) = controls.applied(id) + && alpha != entry.applied_frame_alpha + { + entry.applied_frame_alpha = alpha; + child_changed = true; + } + } + if child_changed { + self.compositor.current_scene_generation = + crate::render_thread::frame_state::next_scene_generation(); + self.compositor.current_row_damage = None; + } + if changed || child_changed { + self.compositor.dirty = true; + } + changed || child_changed + } + // Retained for focused render-state tests; production callers inspect the // retained frame through narrower accessors. #[allow(dead_code)] @@ -2618,6 +2667,7 @@ impl GuiFrameWindowManager { // Composition ring plus every running transition's leased // source picture. clear_frame_transition_textures(&mut render.compositor.transitions); + render.compositor.layout.discard_outgoing_picture(); // Full-frame post shader composition target. render.frame_post_src = None; render.native_content_src = None; diff --git a/crates/neomacs-display-runtime/src/render_thread/frame_windows/tests/mod.rs b/crates/neomacs-display-runtime/src/render_thread/frame_windows/tests/mod.rs index e8613758c7..4b2d00efd0 100644 --- a/crates/neomacs-display-runtime/src/render_thread/frame_windows/tests/mod.rs +++ b/crates/neomacs-display-runtime/src/render_thread/frame_windows/tests/mod.rs @@ -73,6 +73,115 @@ fn option_none_policy_leaves_option_composing_characters() { use neomacs_display_protocol::ModifierPolicy; +#[test] +fn child_focus_opacity_invalidates_retained_scene_without_new_payload() { + let mut render = GuiFrameRenderState::new_without_device( + 0x42, + false, + neomacs_display_protocol::frame_time::observe_platform_now(), + ); + let root = make_frame(0x42, 0); + render.set_current_frame(Some(root), None, Default::default(), Default::default()); + let mut child = FrameGlyphBuffer::with_size(96.0, 64.0); + child.set_frame_identity( + neomacs_display_protocol::DisplayFrameId::new(0x43), + neomacs_display_protocol::DisplayFrameId::new(0x42), + 0.0, + 0.0, + 1, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + child.frame_alpha = [0.8, 0.4]; + let mut controls = crate::thread_comm::FrameOpacityState::default(); + controls.accept(0x42, [-1.0; 2], 0.2); + controls.accept(0x43, [0.8, 0.4], 0.2); + controls.set_redirects(vec![(0x42, Some(0x43))]); + assert!(render.compositor.child_frames.update_frame(child)); + controls.focus(0x42, true); + render.apply_frame_opacity(&controls); + assert_eq!( + render.compositor.child_frames.frames[&0x43].applied_frame_alpha, + 0.8 + ); + let cached_active_generation = render.compositor.current_scene_generation; + controls.focus(0x42, false); + render.apply_frame_opacity(&controls); + assert_eq!( + render.compositor.child_frames.frames[&0x43].applied_frame_alpha, + 0.4 + ); + assert_ne!( + render.compositor.current_scene_generation, + cached_active_generation + ); + let inactive_generation = render.compositor.current_scene_generation; + // The first full repaint and the following compositor-only repaint call + // the same selector, without replacing the evaluator payload. Neither can + // validate a retained texture carrying the old active generation. + controls.focus(0x42, false); + render.apply_frame_opacity(&controls); + controls.focus(0x42, false); + render.apply_frame_opacity(&controls); + assert_eq!( + render.compositor.current_scene_generation, + inactive_generation + ); + assert_ne!( + render.compositor.current_scene_generation, + cached_active_generation + ); + controls.focus(0x42, true); + render.apply_frame_opacity(&controls); + assert_eq!( + render.compositor.child_frames.frames[&0x43].applied_frame_alpha, + 0.8 + ); + assert_ne!( + render.compositor.current_scene_generation, + inactive_generation + ); + controls.accept(0x43, [-1.0; 2], 0.2); + let last = render.compositor.current_scene_generation; + controls.focus(0x42, false); + render.apply_frame_opacity(&controls); + controls.focus(0x42, true); + render.apply_frame_opacity(&controls); + assert_eq!( + render.compositor.child_frames.frames[&0x43].applied_frame_alpha, + 0.8 + ); + assert_eq!(render.compositor.current_scene_generation, last); +} + +#[test] +fn gnu_frame_alpha_focus_and_nil_retains_last_native_opacity() { + let mut render = GuiFrameRenderState::new_without_device( + 0x42, + false, + neomacs_display_protocol::frame_time::observe_platform_now(), + ); + let mut controls = crate::thread_comm::FrameOpacityState::default(); + controls.accept(0x42, [0.8, 0.4], 0.2); + controls.focus(0x42, true); + render.apply_frame_opacity(&controls); + assert_eq!(render.applied_frame_alpha, 0.8); + controls.focus(0x42, false); + render.apply_frame_opacity(&controls); + assert_eq!(render.applied_frame_alpha, 0.4); + controls.accept(0x42, [-1.0; 2], 0.2); + controls.focus(0x42, true); + render.apply_frame_opacity(&controls); + assert_eq!(render.applied_frame_alpha, 0.4); + controls.accept(0x42, [1.0; 2], 0.2); + controls.focus(0x42, false); + render.apply_frame_opacity(&controls); + assert_eq!(render.applied_frame_alpha, 1.0); +} + #[cfg(feature = "neo-term")] #[test] fn terminal_expansion_replacement_is_atomic_and_invalidates_the_scene() { diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/chrome/mod.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/chrome/mod.rs index 3e53cdac09..d7e508cf94 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/chrome/mod.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/chrome/mod.rs @@ -60,7 +60,7 @@ pub(super) fn render_frame_window_overlays_with_toolbar_resources( // this one; moving the call up to them is a change to those two files. super::scene::render_frame_content_overlays( renderer, - native, + native.content_size(), render, surface_view, frame, diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets.rs index 5702999e4c..86a680ec75 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets.rs @@ -6,12 +6,9 @@ //! single place a refused GPU-budget lease is counted and logged, and the //! per-frame census of this window's unpooled full-frame textures. //! -//! Must not: draw into any of them, or decide that a frame needs one. Every -//! function here answers "may I have a texture this size?", and every `None` -//! it returns is a real GPU-pressure state the caller degrades gracefully -//! around — one unshaded or un-transitioned frame, never a dropped one and -//! never a masked failure, which is why refusals are counted rather than -//! swallowed. +//! Must not: draw, consume observations or publish presentation state. Optional +//! effects may degrade on budget pressure; required native/child composition +//! instead returns the typed retry without presenting incomplete pixels. use crate::render_thread::frame_stats; use crate::render_thread::frame_windows::GuiFrameRenderState; @@ -20,14 +17,50 @@ use neomacs_renderer_wgpu::{ WgpuRenderer, }; -/// Overlay chrome needs a real content target; allocation failure must not -/// silently place editor pixels beneath native controls. +/// Only provably opaque native output may bypass encoded-premultiplied +/// conversion. This same pure proof governs early obsolete-owner retirement. +fn native_content_required( + renderer: &WgpuRenderer, + render: &GuiFrameRenderState, + surface: neomacs_display_protocol::DrawableSurface, + plan: &crate::render_thread::render_quality::RenderFeaturePlan, +) -> bool { + surface.content_insets() != Default::default() + || render.applied_frame_alpha != 1.0 + || render + .compositor + .current_frame + .as_ref() + .is_some_and(|frame| { + frame.background_alpha != 1.0 + || render.compositor.layout.needs_native_conversion( + renderer.frame_sample(), + crate::render_thread::frame_compositor::continuity::pane_layout::PixelGrid::new( + surface.device_scale().get() as f64, + ), + render.compositor.transitions.compositions.as_ref() + .map(|ring| ring.current()), + ) + || render.compositor.transitions.needs_native_conversion( + frame, + renderer.frame_sample(), + &render.compositor.pending, + &renderer.effects, + plan.accept_transition_hints, + plan.accept_derived_effects, + ) + }) +} + +/// Native conversion and overlay insets require a charged content target; +/// allocation failure must not silently bypass their final boundary. pub(super) fn native_content_target( renderer: &mut WgpuRenderer, render: &mut GuiFrameRenderState, surface: neomacs_display_protocol::DrawableSurface, + plan: &crate::render_thread::render_quality::RenderFeaturePlan, ) -> Result, super::surface::FrameRenderFailure> { - if surface.content_insets() == Default::default() { + if !native_content_required(renderer, render, surface, plan) { render.native_content_src = None; return Ok(None); } @@ -148,3 +181,165 @@ pub(super) fn report_unpooled_gpu_textures( "full-frame GPU texture accounting" ); } + +/// Native and child targets are admitted before acquisition or any presentation +/// sampling. Keep this order in one production entry point. +pub(super) struct FrameTargets { + pub(super) picture: Option, + pub(super) resize: Option, + pub(super) native: Option, +} + +#[cfg(test)] +pub(super) fn prepare_frame_targets( + renderer: &mut WgpuRenderer, + render: &mut GuiFrameRenderState, + surface: neomacs_display_protocol::DrawableSurface, + plan: &crate::render_thread::render_quality::RenderFeaturePlan, +) -> Result { + prepare_frame_targets_for_scene(renderer, render, surface, plan, true, false) +} + +pub(super) fn prepare_frame_targets_for_scene( + renderer: &mut WgpuRenderer, + render: &mut GuiFrameRenderState, + surface: neomacs_display_protocol::DrawableSurface, + plan: &crate::render_thread::render_quality::RenderFeaturePlan, + compositor_only_hint: bool, + has_gradient: bool, +) -> Result { + let content = surface + .content_surface() + .ok_or(super::surface::FrameRenderFailure::WindowNotReady)?; + let size = + SnapshotSize::new(content.device_width().get(), content.device_height().get()).unwrap(); + let sample = renderer.frame_sample(); + reclaim_finished_child_composition(render, sample); + let grid = crate::render_thread::frame_compositor::continuity::pane_layout::PixelGrid::new( + surface.device_scale().get() as f64, + ); + render.compositor.transitions.reclaim_finished(sample); + render + .compositor + .layout + .reclaim_finished_outgoing(sample, grid); + if !plan.apply_frame_post + || render + .frame_post_src + .as_ref() + .is_some_and(|lease| lease.size() != size) + { + render.frame_post_src = None; + } + let panes = render + .compositor + .layout + .planned_sample(sample, grid) + .map_or_else(Vec::new, |sample| sample.pane_blits()); + if !super::retained_static::is_valid(render, size) + || !super::retained_static::is_eligible(compositor_only_hint, &panes, render) + { + render.compositor.retained_static = None; + } + super::retained_scroll::retire_unusable(renderer, render, has_gradient, surface.device_scale()); + // A dropped unpooled picture must stop charging admission in this retry, + // not in a successful draw which that stale census could prevent. + report_unpooled_gpu_textures(renderer, render); + // Reclaim only ownership proven obsolete, before mandatory child admission. + // No surface, hints, motion sample or interactive projection is consumed. + if !native_content_required(renderer, render, surface, plan) + || render + .native_content_src + .as_ref() + .is_some_and(|lease| lease.size() != size) + { + render.native_content_src = None; + } + let (picture, resize) = prepare_child_targets(renderer, render, size)?; + let native = native_content_target(renderer, render, surface, plan)?; + Ok(FrameTargets { + picture, + resize, + native, + }) +} + +/// Required child pictures are not optional visual decoration. Reserve this +/// before surface acquisition and return the existing retry outcome on pressure. +pub(super) fn mandatory_child_target( + renderer: &mut WgpuRenderer, + required: bool, + size: SnapshotSize, +) -> Result, super::surface::FrameRenderFailure> { + if !required { + return Ok(None); + } + renderer.acquire_snapshot(size).map(Some).map_err(|exceeded| { + frame_stats::count(&frame_stats::FULL_FRAME_TEXTURE_REFUSALS); + tracing::debug!(%exceeded, "mandatory child composition not ready; retrying without present"); + super::surface::FrameRenderFailure::WindowNotReady + }) +} + +#[cfg(test)] +#[path = "composition_targets/tests/mod.rs"] +mod tests; + +/// Preflight child composition before acquiring a surface or advancing any +/// presentation/projection state. Both leases remain local until admitted. +pub(super) fn prepare_child_targets( + renderer: &mut WgpuRenderer, + render: &mut GuiFrameRenderState, + size: SnapshotSize, +) -> Result<(Option, Option), super::surface::FrameRenderFailure> { + reclaim_finished_child_composition(render, renderer.frame_sample()); + let children = &render.compositor.child_frames; + let needs_picture = children.has_dying() + || children.frames.values().any(|entry| { + entry.frame.background_alpha != 1.0 + || entry.applied_frame_alpha != 1.0 + // Dormant policy components are not applied picture opacity. + || entry.animation.is_some() + || entry.crossfade.is_some() + }); + mandatory_child_targets( + renderer, + needs_picture, + children + .frames + .values() + .any(|entry| entry.crossfade.is_some()), + size, + ) +} + +fn reclaim_finished_child_composition( + render: &mut GuiFrameRenderState, + sample: neomacs_display_protocol::frame_time::FrameSample, +) { + if render + .compositor + .child_frames + .reclaim_finished_composition(sample) + { + // Settled state may become retained-static eligible; retire any texture + // of the old animated scene as well as requesting its final repaint. + render.compositor.current_scene_generation = + crate::render_thread::frame_state::next_scene_generation(); + render.compositor.current_row_damage = None; + render.mark_dirty(); + } +} + +/// Both mandatory scratch leases are accepted atomically by the caller. +/// A second refusal releases the first before returning the typed retry. +pub(super) fn mandatory_child_targets( + renderer: &mut WgpuRenderer, + picture: bool, + resize: bool, + size: SnapshotSize, +) -> Result<(Option, Option), super::surface::FrameRenderFailure> { + let picture = mandatory_child_target(renderer, picture, size)?; + let mixed = mandatory_child_target(renderer, resize, size)?; + Ok((picture, mixed)) +} diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets/tests/mod.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets/tests/mod.rs new file mode 100644 index 0000000000..ac19cda8bf --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets/tests/mod.rs @@ -0,0 +1,72 @@ +use super::*; +#[test] +fn mandatory_child_target_refuses_before_present_and_recovers() { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "render_thread::render_pass::composition_targets::tests::mandatory_child_target_refuses_before_present_and_recovers", "--nocapture"]) + .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); + assert!(status.success()); + return; + } + let mut renderer = WgpuRenderer::new(None, 96, 64).expect("real GPU required"); + let size = SnapshotSize::new(96, 64).unwrap(); + let admitted = mandatory_child_target(&mut renderer, true, size) + .unwrap() + .unwrap(); + drop(admitted); + let held = renderer + .acquire_snapshot(SnapshotSize::new(512, 506).unwrap()) + .unwrap(); + assert!(matches!( + mandatory_child_target(&mut renderer, true, size), + Err(super::super::surface::FrameRenderFailure::WindowNotReady) + )); + assert!( + mandatory_child_target(&mut renderer, false, size) + .unwrap() + .is_none() + ); + drop(held); + let recovered = mandatory_child_target(&mut renderer, true, size) + .unwrap() + .unwrap(); + assert_eq!(recovered.size(), size); + drop(recovered); +} + +#[test] +fn mandatory_resize_pair_releases_first_lease_when_second_is_refused() { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "render_thread::render_pass::composition_targets::tests::mandatory_resize_pair_releases_first_lease_when_second_is_refused", "--nocapture"]) + .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); + assert!(status.success()); + return; + } + let mut renderer = WgpuRenderer::new(None, 96, 64).expect("GPU required"); + let size = SnapshotSize::new(96, 64).unwrap(); + let held = renderer + .acquire_snapshot(SnapshotSize::new(512, 496).unwrap()) + .unwrap(); + assert!(matches!( + mandatory_child_targets(&mut renderer, true, true, size), + Err(super::super::surface::FrameRenderFailure::WindowNotReady) + )); + // Capacity admits exactly one scratch. If the failed pair stranded + // its first lease, this retry would also be refused. + let single = mandatory_child_target(&mut renderer, true, size) + .unwrap() + .unwrap(); + drop(single); + drop(held); + let (picture, mixed) = mandatory_child_targets(&mut renderer, true, true, size).unwrap(); + let picture = picture.unwrap(); + let mixed = mixed.unwrap(); + assert_ne!( + picture.id(), + mixed.id(), + "cannot sample from the render attachment" + ); + assert_eq!(picture.size(), size); + assert_eq!(mixed.size(), size); +} diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs index ed1b6ab319..8d43e3949c 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs @@ -72,19 +72,10 @@ pub(super) fn through_composition_ring( // frame. Read straight from the ring instead, it would be the previous // frame *of the motion* — the destination fading into the destination, // which renders as a completely static frame. - let candidate = render - .compositor - .transitions - .previous_composition() - .cloned(); - let previous = render - .compositor - .layout - .pin_outgoing(candidate) - .map(|lease| lease.bind_group().clone()); - renderer.render_pane_layout( - composition.bind_group(), - previous.as_ref(), + place_pane_composition( + renderer, + render, + composition, composition_view, ( native.content_size().0 as f32 / native.scale_factor as f32, @@ -117,6 +108,35 @@ pub(super) fn through_composition_ring( ); } +/// Place and pin a pane picture using the content-local geometry supplied by +/// the native window. Admission and motion sampling remain the caller's work. +pub(super) fn place_pane_composition( + renderer: &mut WgpuRenderer, + render: &mut GuiFrameRenderState, + composition: &SnapshotLease, + destination: &wgpu::TextureView, + logical_size: (f32, f32), + pane_blits: &[PaneBlit], +) { + let candidate = render + .compositor + .transitions + .previous_composition() + .cloned(); + let previous = render + .compositor + .layout + .pin_outgoing(candidate) + .map(|lease| lease.bind_group().clone()); + renderer.render_pane_layout( + composition.bind_group(), + previous.as_ref(), + destination, + logical_size, + pane_blits, + ); +} + /// Draw the frame — content and overlays together — straight onto /// `composition_view`. /// diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs index 316fd26380..54f8f875c6 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs @@ -155,6 +155,66 @@ fn render_frame_window_contents_to_surface( compositor_only_hint: bool, render_policy: &crate::render_thread::render_quality::RenderQualityPolicy, device_lost: &mut crate::render_thread::device_loss::DeviceLossDetector, +) -> Result { + // Reserve interactive child composition before acquiring a swapchain, + // sampling motion, publishing projections or consuming presentation hints. + if !matches!(window_state.lifecycle, FrameLifecycle::Active { .. }) { + return Err(FrameRenderFailure::WindowNotReady); + } + let frame_has_theme_transition = window_state + .render + .pending_theme_change() + .ok_or(FrameRenderFailure::AwaitingContent)?; + let feature_plan = + render_policy.plan_frame(frame_has_theme_transition, renderer.has_frame_post()); + let surface = window_state + .render + .present_mapping() + .ok_or(FrameRenderFailure::AwaitingContent)? + .surface(); + let targets = composition_targets::prepare_frame_targets_for_scene( + renderer, + &mut window_state.render, + surface, + &feature_plan, + compositor_only_hint, + bg_gradient.is_some() || extra_line_spacing != 0.0 || extra_letter_spacing != 0.0, + )?; + window_state.render.child_opacity_src = targets.picture; + window_state.render.child_resize_src = targets.resize; + let result = render_frame_window_contents_reserved( + renderer, + window_state, + bg_gradient, + child_frame_style, + scroll_indicators_enabled, + toolbar, + extra_line_spacing, + extra_letter_spacing, + compositor_only_hint, + feature_plan, + device_lost, + targets.native, + ); + window_state.render.child_opacity_src = None; + window_state.render.child_resize_src = None; + result +} + +#[allow(clippy::too_many_arguments)] +fn render_frame_window_contents_reserved( + renderer: &mut WgpuRenderer, + window_state: &mut GuiFrameWindowState, + bg_gradient: Option<((f32, f32, f32), (f32, f32, f32))>, + child_frame_style: &ChildFrameStyle, + scroll_indicators_enabled: bool, + toolbar: &ToolbarResources, + extra_line_spacing: f32, + extra_letter_spacing: f32, + compositor_only_hint: bool, + feature_plan: crate::render_thread::render_quality::RenderFeaturePlan, + device_lost: &mut crate::render_thread::device_loss::DeviceLossDetector, + native_content: Option, ) -> Result { let render = &mut window_state.render; let native = match &mut window_state.lifecycle { @@ -171,18 +231,7 @@ fn render_frame_window_contents_to_surface( } RenderApp::begin_fps_cpu_span(&mut render.overlays.fps); RenderApp::update_fps_counter(&mut render.overlays.fps, renderer.frame_sample()); - // Read the one bit the offscreen decision needs straight out of the - // retained frame. Missing content has its own typed outcome: the frame - // channel, rather than an expose retry, is responsible for waking it. - // Read it here, before `take_current_frame_for_render` below drains - // the hints. The frame itself is taken once, after the surface is - // acquired — the acquisition has several early-return paths, so - // materializing it earlier was work thrown away outright on any - // lost/outdated/occluded surface. - // Must be read before `take_pending_continuity` below drains it. - let frame_has_theme_transition = render - .pending_theme_change() - .ok_or(FrameRenderFailure::AwaitingContent)?; + let animated_cursor = render.cursor.animated_cursor(); let root_animated_cursor = animated_cursor.filter(|cursor| { cursor.frame_id == DisplayFrameId::new(render.emacs_frame_id) @@ -193,11 +242,8 @@ fn render_frame_window_contents_to_surface( // cursor. The frame's stored cursor geometry is no longer mutated here, // so the materialized frame stays a pure function of the layout snapshot. - let feature_plan = - render_policy.plan_frame(frame_has_theme_transition, renderer.has_frame_post()); - - // Validate and reserve the content target before acquisition and before - // advancing presentation motion. Obscured content is not drawable. + // Targets were admitted without draining the retained frame or continuity. + // Validate geometry before acquisition; obscured content is not drawable. let native_mapping = render .present_mapping() .ok_or(FrameRenderFailure::AwaitingContent)?; @@ -205,8 +251,7 @@ fn render_frame_window_contents_to_surface( .surface() .content_surface() .ok_or(FrameRenderFailure::WindowNotReady)?; - let native_content = - composition_targets::native_content_target(renderer, render, native_mapping.surface())?; + let present_mapping = neomacs_display_protocol::PresentMapping::top_left_clip( content_surface, neomacs_display_protocol::PresentationExtent::new( @@ -374,7 +419,11 @@ fn render_frame_window_contents_to_surface( } render.finish_pointer_paint_render(); if let Some(placement) = native_placement { - renderer.place_native_content(placement, frame.background); + renderer.place_native_content_with_opacity( + placement, + frame.background, + render.applied_frame_alpha, + ); } renderer.set_scale_factor(old_scale_factor); renderer.resize(old_width, old_height); @@ -437,7 +486,11 @@ fn render_frame_window_contents_to_surface( } render.finish_pointer_paint_render(); if let Some(placement) = native_placement { - renderer.place_native_content(placement, frame.background); + renderer.place_native_content_with_opacity( + placement, + frame.background, + render.applied_frame_alpha, + ); } renderer.set_scale_factor(old_scale_factor); renderer.resize(old_width, old_height); diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/present.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/present.rs index 57a3f81324..8b32dd9717 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/present.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/present.rs @@ -42,6 +42,7 @@ impl RenderApp { return SubmissionResult::Skipped; } self.prepare_frame_state_for_render(); + self.refresh_frame_opacity(); let bg_gradient = if self.effects.bg_gradient.enabled { Some(( diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_scroll.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_scroll.rs index 69b84e5e1f..656ae7af9e 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_scroll.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_scroll.rs @@ -192,6 +192,55 @@ fn raster_frame( raster } +fn retention_allowed( + renderer: &WgpuRenderer, + render: &GuiFrameRenderState, + frame: &FrameGlyphBuffer, + has_gradient: bool, +) -> bool { + !has_gradient + && static_body_effects(&renderer.effects) + && !render.compositor.renderer_effects.needs_redraw() + && !super::retained_static::window_has_active_overlays(render) + && render.pointer_selection_for(frame).is_none() + && std::env::var_os("NEOMACS_DISABLE_RETAINED_SCROLL").is_none() +} + +fn coverage_live(cache: &RetainedScroll, frame: &FrameGlyphBuffer) -> bool { + frame.scroll_surfaces.iter().any(|next| { + next.coverage().epoch == cache.paint.surface.coverage().epoch + && next.coverage().content.window_id == cache.paint.surface.coverage().content.window_id + }) +} + +/// Borrow-only retirement: never sample, acknowledge or publish input scroll. +pub(super) fn retire_unusable( + renderer: &WgpuRenderer, + render: &mut GuiFrameRenderState, + has_gradient: bool, + scale: DeviceScale, +) { + let obsolete = render + .compositor + .retained_scroll + .as_ref() + .is_some_and(|cache| { + render + .compositor + .current_frame + .as_ref() + .is_none_or(|frame| { + !coverage_live(cache, frame) + || !retention_allowed(renderer, render, frame, has_gradient) + || cache.paint.scale != scale + || body_background(frame, cache.paint.surface.coverage().viewport).is_none() + }) + }); + if obsolete { + render.compositor.retained_scroll = None; + } +} + /// Prepare before the root draw, so every rejection takes the unchanged full /// glyph path. The pool applies its global memory ceiling; an oversized page /// or refused lease is an ordinary fallback, never an untracked allocation. @@ -209,13 +258,7 @@ pub(super) fn prepare( .compositor .retained_scroll .as_ref() - .is_some_and(|cache| { - !frame.scroll_surfaces.iter().any(|next| { - next.coverage().epoch == cache.paint.surface.coverage().epoch - && next.coverage().content.window_id - == cache.paint.surface.coverage().content.window_id - }) - }) + .is_some_and(|cache| !coverage_live(cache, frame)) { render.compositor.retained_scroll = None; } @@ -224,13 +267,7 @@ pub(super) fn prepare( // Effects which alter body pixels need their own raster dependencies. // Enabled body effects and live overlays conservatively retain the // established full-render path. Disabled parameters are not dependencies. - if has_gradient - || !static_body_effects(&renderer.effects) - || render.compositor.renderer_effects.needs_redraw() - || super::retained_static::window_has_active_overlays(render) - || render.pointer_selection_for(frame).is_some() - || std::env::var_os("NEOMACS_DISABLE_RETAINED_SCROLL").is_some() - { + if !retention_allowed(renderer, render, frame, has_gradient) { render.compositor.retained_scroll = None; return None; } @@ -368,7 +405,7 @@ pub(super) fn prepare( } #[cfg(test)] -mod tests; +pub(super) mod tests; #[cfg(test)] mod geometry_tests { diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_scroll/tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_scroll/tests.rs index a1ae331a35..ac8a43705e 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_scroll/tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_scroll/tests.rs @@ -2,7 +2,7 @@ use super::*; use neomacs_display_protocol::input_progress::InputStream; use neomacs_display_protocol::*; -fn fixture() -> FrameGlyphBuffer { +pub(in crate::render_thread::render_pass) fn fixture() -> FrameGlyphBuffer { fixture_with(|_| {}) } diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_static.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_static.rs index 2303e061e3..a5402b77bf 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_static.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/retained_static.rs @@ -60,6 +60,11 @@ pub(super) fn is_eligible( && std::env::var_os("NEOMACS_DISABLE_RETAINED_STATIC").is_none() } +pub(super) fn is_valid(render: &GuiFrameRenderState, size: SnapshotSize) -> bool { + matches!(&render.compositor.retained_static, + Some(rs) if rs.generation == render.compositor.current_scene_generation && rs.texture.size() == size) +} + /// Compose one frame from the retained cursorless scene: blit it, draw the /// cursor over it, and redraw each filled-box cell. /// @@ -89,10 +94,7 @@ pub(super) fn draw( return; }; let generation = render.compositor.current_scene_generation; - let retained_valid = matches!( - &render.compositor.retained_static, - Some(rs) if rs.generation == generation && rs.texture.size() == native_size - ); + let retained_valid = is_valid(render, native_size); if !retained_valid { ensure_retained_static_texture(renderer, render, native_size); let retained_view = render @@ -297,7 +299,7 @@ pub(super) fn pointer_appearance_allowed(pointer_appearance: &PointerAppearanceS /// /// [`UnpooledTexture::RetainedStaticScene`]: /// neomacs_renderer_wgpu::UnpooledTexture::RetainedStaticScene -fn ensure_retained_static_texture( +pub(super) fn ensure_retained_static_texture( renderer: &WgpuRenderer, render: &mut GuiFrameRenderState, size: SnapshotSize, diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene.rs index 2a226a75b8..a76a8f3d28 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene.rs @@ -17,7 +17,11 @@ use crate::core::types::DisplayFrameId; use crate::render_thread::frame_stats; -use crate::render_thread::frame_windows::{GuiFrameNativeWindowState, GuiFrameRenderState}; +use crate::render_thread::frame_windows::GuiFrameRenderState; + +#[cfg(test)] +#[path = "scene/tests.rs"] +mod tests; use crate::render_thread::state::ChildFrameStyle; use neomacs_renderer_wgpu::{WgpuGlyphAtlas, WgpuRenderer}; @@ -87,7 +91,7 @@ pub(super) fn render_frame_root_glyphs( #[allow(clippy::too_many_arguments)] pub(super) fn render_frame_content_overlays( renderer: &mut WgpuRenderer, - native: &GuiFrameNativeWindowState, + content_size: (u32, u32), render: &mut GuiFrameRenderState, surface_view: &wgpu::TextureView, frame: &crate::core::frame_glyphs::FrameGlyphBuffer, @@ -175,7 +179,7 @@ pub(super) fn render_frame_content_overlays( dying_entry.entry.abs_x, dying_entry.entry.abs_y, clip_in_root, - alpha, + alpha * dying_entry.entry.applied_frame_alpha, offset_y, None, scale, @@ -241,11 +245,9 @@ pub(super) fn render_frame_content_overlays( if finished && child_entry.animation.is_some() { finished_animations.push(child_id); } - // A resize content crossfade layers the previous - // presentation's picture beneath the new frame: the old - // picture fades out as the new one fades in. When the mix - // finishes, the crossfade drops and the next frame draws the - // new payload alone. + // Resize mixes complete unscaled old/new pictures in reserved + // scratch before applying accepted/lifecycle opacity once. At + // completion the next frame uses the new payload alone. let mut crossfade_layer: Option<( &neomacs_renderer_wgpu::SnapshotLease, f32, @@ -285,10 +287,9 @@ pub(super) fn render_frame_content_overlays( drift_drawn.push((child_id, drawn_x, drawn_y, progress.rate)); } } - // The new frame's alpha rides the crossfade's mix when one - // is running, so the old picture genuinely shows through. - let effective_alpha = - alpha * crossfade_layer.as_ref().map_or(1.0, |(_, _, _, mix)| *mix); + // Accepted/lifecycle opacity belongs to the completed resize + // picture, not the independently weighted new content. + let effective_alpha = alpha * child_entry.applied_frame_alpha; if effective_alpha <= 0.0 { continue; } @@ -321,28 +322,6 @@ pub(super) fn render_frame_content_overlays( "child_frame_lifecycle: render_dying_child_frame" ); } else { - // A resize content crossfade layers the previous - // presentation's picture beneath the new frame; the quad - // only draws while the mix is still running. - if let Some((old, old_width, old_height, mix)) = crossfade_layer { - renderer.draw_child_crossfade_quad( - surface_view, - old.bind_group(), - base_x, - base_y, - old_width, - old_height, - native.content_size().0, - native.content_size().1, - 1.0 - mix, - renderer.child_frame_scissor( - clip_in_root, - native.content_size().0, - native.content_size().1, - ), - ); - frame_stats::count(&frame_stats::CHILD_FRAME_CROSSFADE_QUADS); - } tracing::debug!( parent_frame_id = render.emacs_frame_id, frame_id = child_id, @@ -356,15 +335,15 @@ pub(super) fn render_frame_content_overlays( "child_frame_lifecycle: render_child_frame_start" ); } - renderer.render_child_frame( + renderer.render_child_frame_prepared( surface_view, child_frame, base_x, base_y + offset_y, clip_in_root, render.compositor.glyph_atlas.as_mut().unwrap(), - native.content_size().0, - native.content_size().1, + content_size.0, + content_size.1, cursor_visible, animated_cursor.filter(|ac| ac.frame_id == DisplayFrameId::new(child_id)), child_frame_style.corner_radius, @@ -378,6 +357,20 @@ pub(super) fn render_frame_content_overlays( // The scale anchors at the frame's drawn top-left, so the // picture grows outward from the point that anchored it. [base_x, base_y + offset_y], + render.child_opacity_src.as_ref(), + crossfade_layer.map(|(old, old_width, old_height, mix)| { + frame_stats::count(&frame_stats::CHILD_FRAME_CROSSFADE_QUADS); + neomacs_renderer_wgpu::renderer::ChildResizePicture { + old, + old_width, + old_height, + mix, + composition: render + .child_resize_src + .as_ref() + .expect("resize composition was preflighted"), + } + }), ); if !dying { tracing::debug!( @@ -439,8 +432,8 @@ pub(super) fn render_frame_content_overlays( surface_view, frame, render.compositor.glyph_atlas.as_mut().unwrap(), - native.content_size().0, - native.content_size().1, + content_size.0, + content_size.1, scroll_indicators_enabled, ); }); diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs new file mode 100644 index 0000000000..9d7f401930 --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs @@ -0,0 +1,342 @@ +//! Actual production admission, transition dispatch and final native storage. +use super::super::super::composition_targets::{native_content_target, prepare_frame_targets}; +use super::*; +use crate::render_thread::transitions::{detect_frame_transitions, render_frame_transitions}; +use neomacs_display_protocol::frame_glyphs::{ + BufferTransitionTarget, ContentTransitionHint, PresentedWindowRegions, +}; +use neomacs_display_protocol::{ContentTransitionIntent, TransitionEffect}; +use neomacs_renderer_wgpu::renderer::{NativeContentPlacement, RenderTarget}; +pub(super) fn full_plan() -> crate::render_thread::render_quality::RenderFeaturePlan { + crate::render_thread::render_quality::RenderFeaturePlan { + compose_offscreen: true, + accept_transition_hints: true, + accept_derived_effects: true, + accept_cursor_effects: true, + apply_frame_post: false, + } +} + +#[test] +fn obsolete_native_owner_retires_before_required_child_under_same_pressure() { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "render_thread::render_pass::scene::tests::native_boundary::obsolete_native_owner_retires_before_required_child_under_same_pressure", "--nocapture"]) + .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); + assert!(status.success()); + return; + } + let mut renderer = WgpuRenderer::new(None, W, H).expect("GPU required"); + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let mut root = picture(42, 0, W as f32); + root.height = H as f32; + root.set_frame_identity( + DisplayFrameId::new(42), + DisplayFrameId::new(0), + 0.0, + 0.0, + 0, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + root.background = Color::BLACK; + root.background_alpha = 1.0; + render.set_current_frame( + Some(root.clone()), + None, + Default::default(), + Default::default(), + ); + let child = picture(43, 42, 64.0); + assert!(render.compositor.child_frames.update_frame(child.clone())); + let mut sibling = picture(44, 42, 16.0); + sibling.background = Color::BLUE; + sibling.set_frame_identity( + DisplayFrameId::new(44), + DisplayFrameId::new(42), + 56.0, + 8.0, + 2, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + assert!(render.compositor.child_frames.update_frame(sibling.clone())); + seed_pending_layout(&mut render, &root, origin); + let surface = mapping(&root, W, H).surface(); + let mut controls = crate::thread_comm::FrameOpacityState::default(); + controls.accept(42, [0.5; 2], 0.0); + controls.accept(43, [1.0; 2], 0.0); + controls.focus(42, true); + render.apply_frame_opacity(&controls); + let out = target(&renderer); + let view = out.create_view(&Default::default()); + let style = ChildFrameStyle { + corner_radius: 0.0, + shadow_enabled: false, + shadow_layers: 0, + shadow_offset: 0.0, + shadow_opacity: 0.0, + }; + let retained = native_content_target(&mut renderer, &mut render, surface, &full_plan()) + .unwrap() + .unwrap(); + draw_root(&mut renderer, &mut render, retained.view(), &root); + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + retained.view(), + &root, + false, + None, + &style, + false, + ); + renderer.place_native_content_with_opacity( + NativeContentPlacement::new(RenderTarget::new(&view, surface), &retained).unwrap(), + root.background, + render.applied_frame_alpha, + ); + let seeded = px(&pixels(&renderer, &out), 48, 40); + assert!( + seeded + .iter() + .zip([0_u8, 128, 0, 128]) + .all(|(a, b)| a.abs_diff(b) <= 1), + "real fractional-root presentation seeds the retained native owner: {seeded:?}" + ); + let old_id = retained.id(); + drop(retained); // Only the real frame owner remains, not an external holder. + let pressure = renderer + .acquire_snapshot(SnapshotSize::new(512, 496).unwrap()) + .unwrap(); + let interaction = format!("{:?}", render.compositor.interaction); + let current = render.compositor.current_frame.clone(); + let generation = render.compositor.current_scene_generation; + // Both native root and child really need the one available slot: typed retry. + controls.accept(43, [0.5; 2], 0.0); + render.apply_frame_opacity(&controls); + eprintln!( + "native owner control root={} child={} budget={:?} held={:?} native={:?}", + render.applied_frame_alpha, + render.compositor.child_frames.frames[&43].applied_frame_alpha, + renderer.gpu_budget().pooled_bytes(), + pressure.size(), + render.native_content_src.as_ref().map(|p| p.id()) + ); + assert!(matches!( + prepare_frame_targets(&mut renderer, &mut render, surface, &full_plan()), + Err(super::super::super::surface::FrameRenderFailure::WindowNotReady) + )); + assert_eq!(render.native_content_src.as_ref().unwrap().id(), old_id); + controls.accept(42, [1.0; 2], 0.0); + render.apply_frame_opacity(&controls); + for _ in 0..2 { + let before = pixels(&renderer, &out); + let super::super::super::composition_targets::FrameTargets { + picture, + resize, + native, + } = prepare_frame_targets(&mut renderer, &mut render, surface, &full_plan()) + .expect("obsolete native owner must retire BEFORE mandatory child reservation"); + assert!(native.is_none() && render.native_content_src.is_none() && resize.is_none()); + let picture = picture.expect("fractional child must never be omitted"); + assert_eq!( + picture.id(), + old_id, + "reuse the returned owner slot under unchanged pressure" + ); + assert_eq!(render.compositor.current_frame, current); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + assert!( + render.compositor.layout.wants_frames(), + "preflight cannot settle undrawn motion" + ); + assert_eq!(render.compositor.child_frames.frames[&43].frame, child); + assert_eq!(render.compositor.child_frames.frames[&44].frame, sibling); + assert_eq!(pixels(&renderer, &out), before, "preflight cannot draw"); + render.child_opacity_src = Some(picture); + draw_root(&mut renderer, &mut render, &view, &root); + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + &view, + &root, + false, + None, + &style, + false, + ); + let data = pixels(&renderer, &out); + assert_eq!(px(&data, 60, 40), [0, 0, 255, 255], "sibling order"); + assert!( + (186..=189).contains(&px(&data, 48, 40)[1]), + "fractional required child actually drawn" + ); + assert_eq!(px(&data, 2, 60), [0, 0, 0, 255]); + render.child_opacity_src = None; + } + assert!(render.compositor.current_scene_generation >= generation); + drop(pressure); +} + +#[test] +fn dispatched_scale_zoom_strips_use_final_native_conversion_with_opaque_controls() { + let mut renderer = WgpuRenderer::new(None, W, H).expect("real GPU required"); + let size = SnapshotSize::new(W, H).unwrap(); + for (background_alpha, whole_alpha) in [(1.0, 1.0), (0.5, 0.5)] { + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let mut old_frame = FrameGlyphBuffer::with_size(W as f32, H as f32); + old_frame.background = Color::GREEN; + old_frame.background_alpha = background_alpha; + let mut new_frame = old_frame.clone(); + new_frame.background = Color::BLUE; + let surface = mapping(&new_frame, W, H).surface(); + render.set_current_frame( + Some(new_frame.clone()), + None, + Default::default(), + Default::default(), + ); + render.applied_frame_alpha = whole_alpha; + render.compositor.transitions.policy.buffer.enabled = true; + render.compositor.transitions.policy.buffer.duration = Duration::from_secs(1); + render.compositor.transitions.policy.buffer.easing = TransitionEasing::Linear; + render.compositor.transitions.policy.buffer.effect = TransitionEffect::ScaleZoom; + let old = render + .compositor + .transitions + .advance_compositions(&mut renderer, size) + .unwrap(); + draw_root(&mut renderer, &mut render, old.view(), &old_frame); + drop(old); + if background_alpha == 1.0 { + assert!( + native_content_target(&mut renderer, &mut render, surface, &full_plan()) + .unwrap() + .is_none(), + "enabled transition policy and an idle ring must not force a native lease" + ); + } + let region = PresentedWindowRegions { + text_body: neomacs_display_protocol::Rect::new(8.0, 8.0, 80.0, 48.0), + ..Default::default() + } + .buffer_viewport() + .unwrap(); + new_frame + .transition_hints + .push(ContentTransitionHint::BufferReplaced { + target: BufferTransitionTarget::Frame { + regions: vec![region], + }, + intent: ContentTransitionIntent::Replace, + }); + // This pending hint is the real path that begins a transition AFTER native + // preflight. Admission must neither drain it nor ignore its future pixels. + render.compositor.current_frame = Some(new_frame.clone()); + let native = prepare_frame_targets(&mut renderer, &mut render, surface, &full_plan()) + .unwrap() + .native; + assert_eq!( + render + .compositor + .current_frame + .as_ref() + .unwrap() + .transition_hints, + new_frame.transition_hints + ); + let out = target(&renderer); + let view = out.create_view(&Default::default()); + let destination = native.as_ref().map_or(&view, |p| p.view()); + let new = render + .compositor + .transitions + .advance_compositions(&mut renderer, size) + .unwrap(); + draw_root(&mut renderer, &mut render, new.view(), &new_frame); + renderer.set_frame_sample(FrameSample::new(origin, Duration::ZERO)); + detect_frame_transitions( + &mut renderer, + &mut render.compositor.transitions, + &Default::default(), + &mut new_frame, + Default::default(), + &mut render.compositor.dirty, + ); + assert!(render.compositor.transitions.has_active()); + render.compositor.current_frame = Some(new_frame.clone()); + let retained = prepare_frame_targets(&mut renderer, &mut render, surface, &full_plan()) + .unwrap() + .native; + assert_eq!( + retained.as_ref().map(|p| p.id()), + native.as_ref().map(|p| p.id()), + "active transition still requires conversion after hints drain" + ); + drop(retained); + for millis in [250, 750] { + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(millis)), + Duration::ZERO, + )); + renderer + .begin_draw(RenderTarget::new(destination, surface)) + .blit_retained(new.bind_group()); + render_frame_transitions( + &mut renderer, + &mut render.compositor.transitions, + destination, + W, + H, + ); + if let Some(native) = &native { + let placement = + NativeContentPlacement::new(RenderTarget::new(&view, surface), native).unwrap(); + renderer.place_native_content_with_opacity( + placement, + new_frame.background, + whole_alpha, + ); + } + let data = pixels(&renderer, &out); + let strip = px(&data, 9, 24); + let overlap = px(&data, 48, 32); + let outside = px(&data, 2, 2); + eprintln!( + "native ScaleZoom {background_alpha}/{whole_alpha} {millis}: strip {strip:?} overlap {overlap:?} outside {outside:?} conversion={}", + native.is_some() + ); + let weight = 0.75; + let expected_alpha = + (255.0_f32 * background_alpha * whole_alpha * weight).round() as u8; + assert!(strip[3].abs_diff(expected_alpha) <= 2); + let colored = if millis == 250 { strip[1] } else { strip[2] }; + assert!( + colored.abs_diff(expected_alpha) <= 2, + "native encoded-premultiplied strip must store color=alpha, not encode(linear-premultiplied): {strip:?}" + ); + assert!(strip[..3].iter().all(|c| *c <= strip[3].saturating_add(1))); + let t = millis as f32 / 1000.0; + let straight = Color::new(0.0, 1.0 - t, t, 1.0).linear_to_srgb(); + let a = background_alpha * whole_alpha; + assert!(overlap[3].abs_diff((255.0 * a).round() as u8) <= 1); + assert!(overlap[1].abs_diff((255.0 * a * straight.g).round() as u8) <= 2); + assert!(overlap[2].abs_diff((255.0 * a * straight.b).round() as u8) <= 2); + assert!( + outside[2].abs_diff((255.0 * a).round() as u8) <= 1 + && outside[3].abs_diff((255.0 * a).round() as u8) <= 1 + ); + } + } +} diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs new file mode 100644 index 0000000000..2612ba5c25 --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs @@ -0,0 +1,474 @@ +//! Final native pane placement and native-size retirement in production order. +use super::super::super::composition_targets::{native_content_target, prepare_frame_targets}; +use super::super::super::full_render::place_pane_composition; +use super::native_boundary::full_plan; +use super::*; +use crate::render_thread::frame_compositor::continuity::pane_layout::PixelGrid; +use neomacs_display_protocol::types::Rect; +use neomacs_renderer_wgpu::renderer::{NativeContentPlacement, RenderTarget}; + +pub(super) fn pane_frame() -> FrameGlyphBuffer { + let mut root = picture(42, 0, W as f32); + root.height = H as f32; + root.set_frame_identity( + DisplayFrameId::new(42), + DisplayFrameId::new(0), + 0.0, + 0.0, + 0, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + root +} +fn pane_specs(render: &mut GuiFrameRenderState) { + render.compositor.pane_motion = crate::render_thread::render_quality::WindowAnimationSpecs { + resize: linear_second(), + movement: linear_second(), + open: linear_second(), + close: linear_second(), + }; +} +pub(super) fn install_pane_change( + render: &mut GuiFrameRenderState, + origin: neomacs_display_protocol::frame_time::EventTime, + kind: usize, +) -> (FrameGlyphBuffer, FrameGlyphBuffer) { + pane_specs(render); + let single = vec![window(1, Rect::new(0.0, 0.0, W as f32, 48.0))]; + let split = vec![ + window(1, Rect::new(0.0, 0.0, 48.0, 48.0)), + window(2, Rect::new(48.0, 0.0, 48.0, 48.0)), + ]; + let mut before = pane_frame(); + before.background_alpha = 0.5; + before.window_infos = if kind == 0 { + single.clone() + } else { + split.clone() + }; + let mut after = before.clone(); + after.background_alpha = 1.0; // RGB, buffers and window starts are unchanged. + after.window_infos = match kind { + 0 => split, + 1 => single, + _ => vec![ + window(1, Rect::new(0.0, 0.0, 64.0, 48.0)), + window(2, Rect::new(64.0, 0.0, 32.0, 48.0)), + ], + }; + install_layout(render, &before, origin); + install_layout(render, &after, origin); + assert!( + render + .compositor + .current_frame + .as_ref() + .unwrap() + .transition_hints + .is_empty() + ); + assert!(!render.compositor.transitions.has_active()); + (before, after) +} + +#[test] +fn fractional_outgoing_panes_use_final_native_conversion_and_settle_direct() { + let mut renderer = WgpuRenderer::new(None, W, H).expect("real GPU required"); + let size = SnapshotSize::new(W, H).unwrap(); + for kind in 0..3 { + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let (before, after) = install_pane_change(&mut render, origin, kind); + let surface = mapping(&after, W, H).surface(); + let old = render + .compositor + .transitions + .advance_compositions(&mut renderer, size) + .unwrap(); + draw_root(&mut renderer, &mut render, old.view(), &before); + drop(old); // The composition ring, not the test, retains this picture. + let out = target(&renderer); + let view = out.create_view(&Default::default()); + let current = render.compositor.current_frame.clone(); + let interaction = format!("{:?}", render.compositor.interaction); + let mut witnessed_fractional = false; + for millis in [630, 794] { + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(millis)), + Duration::ZERO, + )); + // Pending and active ticks enter the same actual preflight. Neither + // may drain the payload or sample/publish an interactive placement. + let native = prepare_frame_targets(&mut renderer, &mut render, surface, &full_plan()) + .unwrap() + .native; + assert_eq!(render.compositor.current_frame, current); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + assert!(render.compositor.layout.wants_frames()); + let destination = native.as_ref().map_or(&view, |p| p.view()); + let composition = render.sample_pane_layout( + &super::super::super::surface::SurfaceAcquired::for_test(), + renderer.frame_sample(), + PixelGrid::new(1.0), + ); + let new = render + .compositor + .transitions + .advance_compositions(&mut renderer, size) + .unwrap(); + draw_root(&mut renderer, &mut render, new.view(), &after); + place_pane_composition( + &mut renderer, + &mut render, + &new, + destination, + (W as f32, H as f32), + &composition.blits, + ); + if let Some(native) = &native { + renderer.place_native_content_with_opacity( + NativeContentPlacement::new(RenderTarget::new(&view, surface), native).unwrap(), + after.background, + 1.0, + ); + } + let data = pixels(&renderer, &out); + let weight = composition + .blits + .iter() + .find(|pane| { + pane.source == neomacs_renderer_wgpu::PaneSource::Previous + && pane.content_origin == (0.0, 0.0) + }) + .unwrap() + .opacity; + let expected_alpha = (255.0 * (1.0 - 0.5 * weight)).round() as u8; + assert!( + px(&data, 20, 40)[3].abs_diff(expected_alpha) <= 2, + "actual complementary picture weight {weight}" + ); + for x in 2..W - 2 { + let bg = px(&data, x, 40); + witnessed_fractional |= bg[3] < 250; + assert!( + bg[1].abs_diff(bg[3]) <= 2, + "native pane kind{kind} {millis} x{x}: {bg:?}; conversion={}", + native.is_some() + ); + } + assert_eq!( + px(&data, 80, 60), + [0, 255, 0, 255], + "outside panes remains newly opaque" + ); + assert_eq!( + px(&data, 20, 20), + [255; 4], + "complete opaque foreground survives picture interpolation" + ); + eprintln!( + "native panes kind{kind} {millis}: center {:?} blits {:?}", + px(&data, 20, 40), + composition.blits + ); + assert!( + native.is_some(), + "outgoing retained picture requires charged conversion" + ); + drop(new); + } + assert!(witnessed_fractional); + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_secs(2)), + Duration::ZERO, + )); + assert!( + prepare_frame_targets(&mut renderer, &mut render, surface, &full_plan()) + .unwrap() + .native + .is_none(), + "terminal zero-weight patches need no native conversion before settlement" + ); + let settled = render.sample_pane_layout( + &super::super::super::surface::SurfaceAcquired::for_test(), + renderer.frame_sample(), + PixelGrid::new(1.0), + ); + assert!( + settled.projection.is_some(), + "settlement still carries interactive geometry" + ); + assert!(!render.compositor.layout.wants_frames()); + assert!( + native_content_target(&mut renderer, &mut render, surface, &full_plan()) + .unwrap() + .is_none() + ); + draw_root(&mut renderer, &mut render, &view, &after); + assert_eq!(px(&pixels(&renderer, &out), 20, 40), [0, 255, 0, 255]); + } +} + +#[test] +fn pane_conversion_refuses_before_motion_and_recovers_under_held_pressure() { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { + let status=std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact","render_thread::render_pass::scene::tests::pane_size_boundary::pane_conversion_refuses_before_motion_and_recovers_under_held_pressure","--nocapture"]).env("NEOMACS_GPU_BUDGET_MB","1").status().unwrap(); + assert!(status.success()); + return; + } + for active in [false, true] { + let mut renderer = WgpuRenderer::new(None, W, H).expect("real GPU required"); + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let (before, after) = install_pane_change(&mut render, origin, 1); + let surface = mapping(&after, W, H).surface(); + let old = render + .compositor + .transitions + .advance_compositions(&mut renderer, SnapshotSize::new(W, H).unwrap()) + .unwrap(); + draw_root(&mut renderer, &mut render, old.view(), &before); + drop(old); + // Seed the genuinely required native owner while the old background is applied. + render.compositor.current_frame = Some(before.clone()); + let native = native_content_target(&mut renderer, &mut render, surface, &full_plan()) + .unwrap() + .unwrap(); + let id = native.id(); + drop(native); + render.compositor.current_frame = Some(after.clone()); + render.set_current_frame( + Some(after.clone()), + None, + Default::default(), + Default::default(), + ); + if active { + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(250)), + Duration::ZERO, + )); + let native = native_content_target(&mut renderer, &mut render, surface, &full_plan()) + .unwrap() + .unwrap(); + let composition = render.sample_pane_layout( + &super::super::super::surface::SurfaceAcquired::for_test(), + renderer.frame_sample(), + PixelGrid::new(1.0), + ); + let new = render + .compositor + .transitions + .advance_compositions(&mut renderer, SnapshotSize::new(W, H).unwrap()) + .unwrap(); + draw_root(&mut renderer, &mut render, new.view(), &after); + place_pane_composition( + &mut renderer, + &mut render, + &new, + native.view(), + (W as f32, H as f32), + &composition.blits, + ); + drop(new); + drop(native); + } + let child = picture(43, 42, 64.0); + assert!(render.compositor.child_frames.update_frame(child)); + let mut controls = crate::thread_comm::FrameOpacityState::default(); + controls.accept(43, [0.5; 2], 0.0); + render.apply_frame_opacity(&controls); + let pressure = renderer + .acquire_snapshot(SnapshotSize::new(512, if active { 471 } else { 483 }).unwrap()) + .unwrap(); + let current = render.compositor.current_frame.clone(); + let interaction = format!("{:?}", render.compositor.interaction); + for millis in [if active { 500 } else { 0 }, 630] { + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(millis)), + Duration::ZERO, + )); + eprintln!( + "pressure at{millis}: layout={} child={} native={:?} pooled={} unpooled={}", + render.compositor.layout.wants_frames(), + render.compositor.child_frames.frames[&43].applied_frame_alpha, + render.native_content_src.as_ref().map(|p| p.id()), + renderer.gpu_budget().pooled_bytes(), + renderer.gpu_budget().unpooled_bytes() + ); + assert!( + matches!( + prepare_frame_targets(&mut renderer, &mut render, surface, &full_plan()), + Err(super::super::super::surface::FrameRenderFailure::WindowNotReady) + ), + "pane's genuine native owner cannot be retired to admit child" + ); + assert_eq!(render.native_content_src.as_ref().unwrap().id(), id); + assert_eq!(render.compositor.current_frame, current); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + assert!(render.compositor.layout.wants_frames()); + assert!(render.child_opacity_src.is_none() && render.child_resize_src.is_none()); + } + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_secs(2)), + Duration::ZERO, + )); + let targets = prepare_frame_targets(&mut renderer, &mut render, surface, &full_plan()) + .expect( + "terminal pane must not strand obsolete native ownership under unchanged pressure", + ); + assert!(targets.native.is_none()); + assert!(targets.picture.is_some()); + assert!( + render.compositor.layout.wants_frames(), + "preflight must not itself publish settlement" + ); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + let composition = render.sample_pane_layout( + &super::super::super::surface::SurfaceAcquired::for_test(), + renderer.frame_sample(), + PixelGrid::new(1.0), + ); + assert!(composition.projection.is_some()); + assert!(!render.compositor.layout.wants_frames()); + let out = target(&renderer); + let view = out.create_view(&Default::default()); + render.child_opacity_src = targets.picture; + draw_root(&mut renderer, &mut render, &view, &after); + let style = ChildFrameStyle { + corner_radius: 0.0, + shadow_enabled: false, + shadow_layers: 0, + shadow_offset: 0.0, + shadow_opacity: 0.0, + }; + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + &view, + &after, + false, + None, + &style, + false, + ); + assert_eq!( + px(&pixels(&renderer, &out), 48, 40)[3], + 255, + "required child remains rendered over settled opaque root" + ); + render.child_opacity_src = None; + drop(pressure); + } +} + +#[test] +fn wrong_size_required_native_owner_retires_before_child_under_same_pressure() { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { + let status=std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact","render_thread::render_pass::scene::tests::pane_size_boundary::wrong_size_required_native_owner_retires_before_child_under_same_pressure","--nocapture"]).env("NEOMACS_GPU_BUDGET_MB","1").status().unwrap(); + assert!(status.success()); + return; + } + let mut renderer = WgpuRenderer::new(None, W, H).expect("real GPU required"); + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let mut root = pane_frame(); + root.background_alpha = 0.5; + render.set_current_frame( + Some(root.clone()), + None, + Default::default(), + Default::default(), + ); + let old_surface = mapping(&root, 128, H).surface(); + let old = prepare_frame_targets(&mut renderer, &mut render, old_surface, &full_plan()) + .unwrap() + .native + .unwrap(); + draw_root(&mut renderer, &mut render, old.view(), &root); + let old_id = old.id(); + assert_eq!(old.size(), SnapshotSize::new(128, H).unwrap()); + drop(old); + let child = picture(43, 42, 64.0); + render.compositor.child_frames.update_frame(child); + let mut controls = crate::thread_comm::FrameOpacityState::default(); + controls.accept(43, [0.5; 2], 0.0); + render.apply_frame_opacity(&controls); + // Held ownership is pressure 989184 plus the renderer's charged stencil + // 6144: 995328 held + 32768 old + 24576 child exceeds 1048576; + // 995328 held + two 24576 replacement owners fits, continuously. + let pressure = renderer + .acquire_snapshot(SnapshotSize::new(512, 483).unwrap()) + .unwrap(); + let current = render.compositor.current_frame.clone(); + let interaction = format!("{:?}", render.compositor.interaction); + assert!( + matches!( + prepare_frame_targets(&mut renderer, &mut render, old_surface, &full_plan()), + Err(super::super::super::surface::FrameRenderFailure::WindowNotReady) + ), + "correctly sized required root must not be surrendered to child" + ); + assert_eq!(render.native_content_src.as_ref().unwrap().id(), old_id); + let surface = mapping(&root, W, H).surface(); + let targets = prepare_frame_targets(&mut renderer, &mut render, surface, &full_plan()) + .expect("wrong-size native owner must retire BEFORE mandatory child admission"); + assert_eq!( + targets.native.as_ref().unwrap().size(), + SnapshotSize::new(W, H).unwrap() + ); + assert_eq!( + targets.picture.as_ref().unwrap().size(), + SnapshotSize::new(W, H).unwrap() + ); + assert_ne!( + targets.picture.as_ref().unwrap().id(), + targets.native.as_ref().unwrap().id() + ); + assert_eq!(render.compositor.current_frame, current); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + render.child_opacity_src = targets.picture; + let native = targets.native.unwrap(); + draw_root(&mut renderer, &mut render, native.view(), &root); + let style = ChildFrameStyle { + corner_radius: 0.0, + shadow_enabled: false, + shadow_layers: 0, + shadow_offset: 0.0, + shadow_opacity: 0.0, + }; + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + native.view(), + &root, + false, + None, + &style, + false, + ); + let out = target(&renderer); + let view = out.create_view(&Default::default()); + renderer.place_native_content_with_opacity( + NativeContentPlacement::new(RenderTarget::new(&view, surface), &native).unwrap(), + root.background, + 1.0, + ); + let bg = px(&pixels(&renderer, &out), 48, 40); + assert!( + bg[1].abs_diff(bg[3]) <= 2 && (190..=192).contains(&bg[3]), + "root and required child actually drawn after shrink: {bg:?}" + ); + render.child_opacity_src = None; + drop(native); + let retry = prepare_frame_targets(&mut renderer, &mut render, surface, &full_plan()).unwrap(); + assert!(retry.native.is_some() && retry.picture.is_some()); + drop(retry); + drop(pressure); +} diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pinned_lifecycle_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pinned_lifecycle_tests.rs new file mode 100644 index 0000000000..e82605b964 --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pinned_lifecycle_tests.rs @@ -0,0 +1,264 @@ +//! Bounded native-size and isolated two-device pinned-picture lifetimes. +use super::super::super::{composition_targets as targets, full_render::place_pane_composition}; +use super::native_boundary::full_plan; +use super::pane_size_boundary::install_pane_change; +use super::*; +use crate::render_thread::frame_compositor::continuity::pane_layout::PixelGrid; +use crate::render_thread::frame_compositor::layout_driver::{LayoutDriver, OutgoingPicture}; +use crate::render_thread::frame_windows::{ + FrameLifecycle, GuiFrameWindowManager, GuiFrameWindowState, +}; +use neomacs_display_protocol::{DeviceScale, FrameGlyph, SurfaceState}; +use neomacs_renderer_wgpu::renderer::{NativeContentPlacement, RenderTarget}; + +fn pinned( + renderer: &mut WgpuRenderer, + width: u32, +) -> ( + GuiFrameRenderState, + FrameGlyphBuffer, + neomacs_display_protocol::frame_time::EventTime, +) { + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let (mut before, after) = install_pane_change(&mut render, origin, 1); + render.set_surface_state( + SurfaceState::from_device_size(width, H, DeviceScale::new(1.0).unwrap()).unwrap(), + ); + before.width = width as f32; + before.background_alpha = 1.0; + before.glyphs.clear(); + before.glyphs.push(FrameGlyph::Background { + bounds: neomacs_display_protocol::Rect::new(0.0, 0.0, 64.0, H as f32), + color: Color::RED, + }); + before.glyphs.push(FrameGlyph::Background { + bounds: neomacs_display_protocol::Rect::new(64.0, 0.0, width as f32 - 64.0, H as f32), + color: Color::BLUE, + }); + let old = render + .compositor + .transitions + .advance_compositions(renderer, SnapshotSize::new(width, H).unwrap()) + .unwrap(); + renderer.render_frame_glyphs( + old.view(), + &before, + render.compositor.glyph_atlas.as_mut().unwrap(), + mapping(&before, width, H), + false, + None, + None, + None, + None, + None, + ); + drop(old); + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(250)), + Duration::ZERO, + )); + let c = render.sample_pane_layout( + &super::super::super::surface::SurfaceAcquired::for_test(), + renderer.frame_sample(), + PixelGrid::new(1.0), + ); + let new = render + .compositor + .transitions + .advance_compositions(renderer, SnapshotSize::new(width, H).unwrap()) + .unwrap(); + renderer.render_frame_glyphs( + new.view(), + &after, + render.compositor.glyph_atlas.as_mut().unwrap(), + mapping(&after, width, H), + false, + None, + None, + None, + None, + None, + ); + let out = target(renderer); + place_pane_composition( + renderer, + &mut render, + &new, + &out.create_view(&Default::default()), + (width as f32, H as f32), + &c.blits, + ); + drop(new); + assert!(matches!( + &render.compositor.layout, + LayoutDriver::Animating { + outgoing: OutgoingPicture::Pinned(Some(_)), + .. + } + )); + (render, after, origin) +} +fn draw_tick( + renderer: &mut WgpuRenderer, + render: &mut GuiFrameRenderState, + root: &FrameGlyphBuffer, + origin: neomacs_display_protocol::frame_time::EventTime, +) -> Vec { + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(500)), + Duration::ZERO, + )); + let t = targets::prepare_frame_targets( + renderer, + render, + mapping(root, W, H).surface(), + &full_plan(), + ) + .unwrap(); + let c = render.sample_pane_layout( + &super::super::super::surface::SurfaceAcquired::for_test(), + renderer.frame_sample(), + PixelGrid::new(1.0), + ); + eprintln!("lifecycle pane blits {:?}", c.blits); + let new = render + .compositor + .transitions + .advance_compositions(renderer, SnapshotSize::new(W, H).unwrap()) + .unwrap(); + draw_root(renderer, render, new.view(), root); + let out = target(renderer); + let view = out.create_view(&Default::default()); + place_pane_composition( + renderer, + render, + &new, + t.native.as_ref().map_or(&view, |n| n.view()), + (W as f32, H as f32), + &c.blits, + ); + if let Some(n) = &t.native { + renderer.place_native_content_with_opacity( + NativeContentPlacement::new(RenderTarget::new(&view, mapping(root, W, H).surface()), n) + .unwrap(), + root.background, + 1.0, + ); + } + pixels(renderer, &out) +} +#[test] +fn native_resize_retires_incompatible_pinned_picture_not_interaction() { + let mut renderer = WgpuRenderer::new(None, 192, H).expect("real GPU required"); + let (mut render, root, origin) = pinned(&mut renderer, 192); + let interaction = format!("{:?}", render.compositor.interaction); + // Equal geometry is not permission to discard a still-readable live pin. + let id = match &render.compositor.layout { + LayoutDriver::Animating { + outgoing: OutgoingPicture::Pinned(Some(p)), + .. + } => p.id(), + _ => unreachable!(), + }; + render.set_surface_state( + SurfaceState::from_device_size(192, H, DeviceScale::new(1.0).unwrap()).unwrap(), + ); + assert!( + matches!(&render.compositor.layout,LayoutDriver::Animating{outgoing:OutgoingPicture::Pinned(Some(p)),..} if p.id()==id) + ); + renderer.resize(W, H); + render.set_surface_state( + SurfaceState::from_device_size(W, H, DeviceScale::new(1.0).unwrap()).unwrap(), + ); + crate::render_thread::transitions::clear_frame_transition_textures( + &mut render.compositor.transitions, + ); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + let data = draw_tick(&mut renderer, &mut render, &root, origin); + eprintln!( + "resized old-only witness x40 {:?} x56 {:?}", + px(&data, 40, 40), + px(&data, 56, 40) + ); + assert!( + matches!( + &render.compositor.layout, + LayoutDriver::Animating { + outgoing: OutgoingPicture::Pinned(None), + .. + } + ), + "incompatible source must not survive resize or sample old texture with current UVs" + ); + assert_eq!( + px(&data, 40, 40), + [0, 255, 0, 255], + "new-picture fallback must not mis-sample old red/blue stripes" + ); + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_secs(2)), + Duration::ZERO, + )); + assert!( + targets::prepare_frame_targets( + &mut renderer, + &mut render, + mapping(&root, W, H).surface(), + &full_plan() + ) + .unwrap() + .native + .is_none() + ); + assert!( + render + .sample_pane_layout( + &super::super::super::surface::SurfaceAcquired::for_test(), + renderer.frame_sample(), + PixelGrid::new(1.0) + ) + .projection + .is_some() + ); +} +#[test] +fn recreated_device_has_no_old_pane_bindings_and_keeps_cpu_scene() { + let mut a = WgpuRenderer::new(None, W, H).expect("real device A required"); + let (render, root, origin) = pinned(&mut a, W); + let interaction = format!("{:?}", render.compositor.interaction); + let current = render.compositor.current_frame.clone(); + let mut manager = GuiFrameWindowManager::new(); + manager.set_primary_pending(GuiFrameWindowState { + pending_scale_factor: None, + lifecycle: FrameLifecycle::Pending { + width: W, + height: H, + scale_factor: 1.0, + mouse_hidden_for_typing: false, + ime_enabled: false, + last_ime_cursor_area: None, + chrome: Default::default(), + geometry_hints: None, + }, + render, + }); + drop(a); + manager.clear_gpu_resident_state(); + let mut b = WgpuRenderer::new(None, W, H).expect("real device B required"); + let render = &mut manager.primary_window_mut().unwrap().render; + assert_eq!(render.compositor.current_frame, current); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + render.populate_glyph_atlas(b.device(), 1.0); + // On the predecessor this actually binds an A-owned pin to B, and GPU + // validation fails; do not replace this with a source-only assertion. + let data = draw_tick(&mut b, render, &root, origin); + assert!(matches!( + &render.compositor.layout, + LayoutDriver::Animating { + outgoing: OutgoingPicture::Pinned(None), + .. + } + )); + assert_eq!(px(&data, 40, 40), [0, 255, 0, 255]); +} diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs new file mode 100644 index 0000000000..446c39d128 --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs @@ -0,0 +1,577 @@ +//! Real pooled/unpooled ownership through the production mandatory boundary. +use super::super::super::{composition_targets as targets, retained_scroll, retained_static}; +use super::native_boundary::full_plan; +use super::pane_size_boundary::{install_pane_change, pane_frame}; +use super::*; +use crate::render_thread::frame_compositor::continuity::pane_layout::PixelGrid; +use crate::render_thread::frame_compositor::layout_driver::{LayoutDriver, OutgoingPicture}; +use crate::render_thread::transitions::{detect_frame_transitions, render_frame_transitions}; +use neomacs_display_protocol::frame_glyphs::{ + BufferTransitionTarget, ContentTransitionHint, PresentedWindowRegions, +}; +use neomacs_display_protocol::{ContentTransitionIntent, TransitionEffect}; +use neomacs_renderer_wgpu::SnapshotLease; +use neomacs_renderer_wgpu::renderer::{NativeContentPlacement, RenderTarget}; + +fn budget_one(name: &str) -> bool { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() == Ok("1") { + return true; + } + let exact = format!("render_thread::render_pass::scene::tests::retained_owner::{name}"); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &exact, "--nocapture"]) + .env("NEOMACS_GPU_BUDGET_MB", "1") + .status() + .unwrap(); + assert!(status.success()); + false +} + +fn setup() -> (WgpuRenderer, GuiFrameRenderState, FrameGlyphBuffer) { + assert_eq!(std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref(), Ok("1")); + let renderer = WgpuRenderer::new(None, W, H).expect("real Vulkan GPU required"); + let mut render = + GuiFrameRenderState::new(42, renderer.device(), 1.0, false, observe_platform_now()); + let root = pane_frame(); + render.set_current_frame( + Some(root.clone()), + None, + Default::default(), + Default::default(), + ); + (renderer, render, root) +} +fn child(render: &mut GuiFrameRenderState, alpha: f32) { + if !render.compositor.child_frames.frames.contains_key(&43) { + assert!( + render + .compositor + .child_frames + .update_frame(picture(43, 42, 64.0)) + ); + } + let mut controls = crate::thread_comm::FrameOpacityState::default(); + controls.accept(43, [alpha; 2], 0.0); + render.apply_frame_opacity(&controls); + assert_eq!( + render.compositor.child_frames.frames[&43].applied_frame_alpha, + alpha + ); +} +fn hold(renderer: &mut WgpuRenderer, reserve: u64) -> SnapshotLease { + let b = renderer.gpu_budget(); + let free = b.limit_bytes().get() - b.pooled_bytes() - b.unpooled_bytes(); + assert!(free > reserve); + let rows = ((free - reserve) / 1024) as u32; + let lease = renderer + .acquire_snapshot(SnapshotSize::new(256, rows).unwrap()) + .expect("qualified pressure fixture"); + let b = renderer.gpu_budget(); + eprintln!( + "held pressure: pooled={} unpooled={} limit={} pressure={}", + b.pooled_bytes(), + b.unpooled_bytes(), + b.limit_bytes().get(), + 256 * rows * 4 + ); + lease +} +fn refused( + renderer: &mut WgpuRenderer, + render: &mut GuiFrameRenderState, + root: &FrameGlyphBuffer, + plan: &crate::render_thread::render_quality::RenderFeaturePlan, +) { + let current = render.compositor.current_frame.clone(); + let interaction = format!("{:?}", render.compositor.interaction); + let scroll = render + .compositor + .input_scroll + .staged_projection() + .map(|(s, o)| (s.coverage().epoch, o)); + let pending = ( + render.compositor.pending.scrolls.len(), + render.compositor.pending.reflows.len(), + render.compositor.pending.theme.is_some(), + ); + for _ in 0..2 { + assert!( + matches!( + targets::prepare_frame_targets( + renderer, + render, + mapping(root, W, H).surface(), + plan + ), + Err(super::super::super::surface::FrameRenderFailure::WindowNotReady) + ), + "live needed ownership must refuse typed" + ); + assert_eq!(render.compositor.current_frame, current); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + assert_eq!( + render + .compositor + .input_scroll + .staged_projection() + .map(|(s, o)| (s.coverage().epoch, o)), + scroll + ); + assert_eq!( + ( + render.compositor.pending.scrolls.len(), + render.compositor.pending.reflows.len(), + render.compositor.pending.theme.is_some() + ), + pending + ); + } +} +fn admitted( + renderer: &mut WgpuRenderer, + render: &mut GuiFrameRenderState, + root: &FrameGlyphBuffer, + plan: &crate::render_thread::render_quality::RenderFeaturePlan, +) -> targets::FrameTargets { + let current = render.compositor.current_frame.clone(); + let interaction = format!("{:?}", render.compositor.interaction); + let scroll = render + .compositor + .input_scroll + .staged_projection() + .map(|(s, o)| (s.coverage().epoch, o)); + let result = + targets::prepare_frame_targets(renderer, render, mapping(root, W, H).surface(), plan) + .expect( + "obsolete owner must retire before mandatory admission under SAME held pressure", + ); + assert!(result.picture.is_some()); + assert_eq!( + render + .compositor + .input_scroll + .staged_projection() + .map(|(s, o)| (s.coverage().epoch, o)), + scroll + ); + assert_eq!(render.compositor.current_frame, current); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + result +} +fn present( + renderer: &mut WgpuRenderer, + render: &mut GuiFrameRenderState, + root: &FrameGlyphBuffer, + result: targets::FrameTargets, +) { + let out = target(renderer); + let view = out.create_view(&Default::default()); + let destination = result.native.as_ref().map_or(&view, |native| native.view()); + draw_root(renderer, render, destination, root); + render.child_opacity_src = result.picture; + render.child_resize_src = result.resize; + let style = ChildFrameStyle { + corner_radius: 0.0, + shadow_enabled: false, + shadow_layers: 0, + shadow_offset: 0.0, + shadow_opacity: 0.0, + }; + render_frame_content_overlays( + renderer, + (W, H), + render, + destination, + root, + false, + None, + &style, + false, + ); + if let Some(native) = &result.native { + renderer.place_native_content_with_opacity( + NativeContentPlacement::new( + RenderTarget::new(&view, mapping(root, W, H).surface()), + native, + ) + .unwrap(), + root.background, + render.applied_frame_alpha, + ); + } + let p = px(&pixels(renderer, &out), 48, 30); + eprintln!("recovered required-child production pixels {p:?}"); + let expected = if root.background_alpha == 1.0 { + 255_u8 + } else { + 191_u8 + }; + assert!( + p[3].abs_diff(expected) <= 2, + "live required child must really draw: {p:?}" + ); + render.child_opacity_src = None; + render.child_resize_src = None; +} +fn post(shrink: bool) { + let (mut renderer, mut render, root) = setup(); + let old_size = SnapshotSize::new(if shrink { 128 } else { W }, H).unwrap(); + let view = targets::ensure_frame_post_src(&mut renderer, &mut render, old_size).unwrap(); + draw_root(&mut renderer, &mut render, &view, &root); + drop(view); + child(&mut render, 0.5); + let mut plan = full_plan(); + plan.apply_frame_post = true; + let pressure = hold(&mut renderer, u64::from(W * H * 2)); + if !shrink { + refused(&mut renderer, &mut render, &root, &plan); + plan.apply_frame_post = false; + } + let result = admitted(&mut renderer, &mut render, &root, &plan); + assert!(render.frame_post_src.is_none()); + present(&mut renderer, &mut render, &root, result); + drop(pressure); +} +#[test] +fn disabled_post_retires_under_held_pressure() { + if !budget_one("disabled_post_retires_under_held_pressure") { + return; + } + post(false) +} +#[test] +fn wrong_size_post_retires_under_held_pressure() { + if !budget_one("wrong_size_post_retires_under_held_pressure") { + return; + } + post(true) +} + +fn static_scene(shrink: bool) { + let (mut renderer, mut render, root) = setup(); + child(&mut render, 0.5); + retained_static::ensure_retained_static_texture( + &renderer, + &mut render, + SnapshotSize::new(if shrink { 128 } else { W }, H).unwrap(), + ); + let view = render + .compositor + .retained_static + .as_ref() + .unwrap() + .texture + .view() + .clone(); + draw_root(&mut renderer, &mut render, &view, &root); + drop(view); + render + .compositor + .retained_static + .as_mut() + .unwrap() + .generation = render.compositor.current_scene_generation; + targets::report_unpooled_gpu_textures(&mut renderer, &render); + assert!( + renderer.gpu_budget().unpooled_bytes() >= u64::from(if shrink { 128 } else { W } * H * 4) + ); + let pressure = hold(&mut renderer, u64::from(W * H * 2)); + if !shrink { + refused(&mut renderer, &mut render, &root, &full_plan()); + child(&mut render, 0.7); + } + let result = admitted(&mut renderer, &mut render, &root, &full_plan()); + assert!(render.compositor.retained_static.is_none()); + assert_eq!(renderer.gpu_budget().unpooled_bytes(), u64::from(W * H)); // live stencil, not stale census + present(&mut renderer, &mut render, &root, result); + drop(pressure); +} +#[test] +fn stale_static_generation_and_census_retire_under_held_pressure() { + if !budget_one("stale_static_generation_and_census_retire_under_held_pressure") { + return; + } + static_scene(false) +} +#[test] +fn stale_static_extent_and_census_retire_under_held_pressure() { + if !budget_one("stale_static_extent_and_census_retire_under_held_pressure") { + return; + } + static_scene(true) +} + +fn snapshot(fractional: bool) { + let (mut renderer, mut render, mut root) = setup(); + if fractional { + root.background_alpha = 0.5; + render.compositor.current_frame = Some(root.clone()); + } + let origin = observe_platform_now(); + renderer.set_frame_sample(FrameSample::new(origin, Duration::ZERO)); + render.compositor.transitions.policy.buffer.enabled = true; + render.compositor.transitions.policy.buffer.duration = Duration::from_secs(1); + render.compositor.transitions.policy.buffer.effect = TransitionEffect::Crossfade; + let size = SnapshotSize::new(W, H).unwrap(); + for _ in 0..2 { + let pic = render + .compositor + .transitions + .advance_compositions(&mut renderer, size) + .unwrap(); + draw_root(&mut renderer, &mut render, pic.view(), &root); + } + root.transition_hints + .push(ContentTransitionHint::BufferReplaced { + target: BufferTransitionTarget::Frame { + regions: vec![ + PresentedWindowRegions { + text_body: neomacs_display_protocol::Rect::new( + 0.0, 0.0, W as f32, H as f32, + ), + ..Default::default() + } + .buffer_viewport() + .unwrap(), + ], + }, + intent: ContentTransitionIntent::Replace, + }); + detect_frame_transitions( + &mut renderer, + &mut render.compositor.transitions, + &Default::default(), + &mut root, + Default::default(), + &mut render.compositor.dirty, + ); + assert!(render.compositor.transitions.has_active()); + render.compositor.current_frame = Some(root.clone()); + // Age both ring slots away from the transition's unique old picture. + for _ in 0..if fractional { 0 } else { 3 } { + let pic = render + .compositor + .transitions + .advance_compositions(&mut renderer, size) + .unwrap(); + draw_root(&mut renderer, &mut render, pic.view(), &root); + } + let native = targets::native_content_target( + &mut renderer, + &mut render, + mapping(&root, W, H).surface(), + &full_plan(), + ) + .unwrap() + .unwrap(); + render_frame_transitions( + &mut renderer, + &mut render.compositor.transitions, + native.view(), + W, + H, + ); + drop(native); + child(&mut render, 0.5); + let pressure = hold(&mut renderer, u64::from(W * H * 2)); + refused(&mut renderer, &mut render, &root, &full_plan()); + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_secs(2)), + Duration::ZERO, + )); + if fractional { + // Old is still aliased by the ring, so expiry cannot free its bytes. + // A genuinely fractional native owner stays required and refuses. + let id = render.native_content_src.as_ref().unwrap().id(); + refused(&mut renderer, &mut render, &root, &full_plan()); + assert!(!render.compositor.transitions.has_active()); + assert_eq!(render.native_content_src.as_ref().unwrap().id(), id); + } else { + let result = admitted(&mut renderer, &mut render, &root, &full_plan()); + assert!(!render.compositor.transitions.has_active()); + assert!(result.native.is_none()); + present(&mut renderer, &mut render, &root, result); + } + drop(pressure); +} +#[test] +fn expired_snapshot_old_and_native_retire_under_held_pressure() { + if !budget_one("expired_snapshot_old_and_native_retire_under_held_pressure") { + return; + } + snapshot(false) +} +#[test] +fn expired_snapshot_keeps_fractional_native_under_true_pressure() { + if !budget_one("expired_snapshot_keeps_fractional_native_under_true_pressure") { + return; + } + snapshot(true) +} + +#[test] +fn terminal_unique_pane_retires_without_surrendering_fractional_native() { + if !budget_one("terminal_unique_pane_retires_without_surrendering_fractional_native") { + return; + } + let (mut renderer, mut render, _) = setup(); + let origin = observe_platform_now(); + let (before, mut after) = install_pane_change(&mut render, origin, 1); + after.background_alpha = 0.5; + render.compositor.current_frame = Some(after.clone()); + render.set_current_frame( + Some(after.clone()), + None, + Default::default(), + Default::default(), + ); + let size = SnapshotSize::new(W, H).unwrap(); + let old = render + .compositor + .transitions + .advance_compositions(&mut renderer, size) + .unwrap(); + draw_root(&mut renderer, &mut render, old.view(), &before); + drop(old); + let native = targets::native_content_target( + &mut renderer, + &mut render, + mapping(&after, W, H).surface(), + &full_plan(), + ) + .unwrap() + .unwrap(); + let native_id = native.id(); + for millis in [100, 200, 300] { + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(millis)), + Duration::ZERO, + )); + let composition = render.sample_pane_layout( + &super::super::super::surface::SurfaceAcquired::for_test(), + renderer.frame_sample(), + PixelGrid::new(1.0), + ); + let new = render + .compositor + .transitions + .advance_compositions(&mut renderer, size) + .unwrap(); + draw_root(&mut renderer, &mut render, new.view(), &after); + super::super::super::full_render::place_pane_composition( + &mut renderer, + &mut render, + &new, + native.view(), + (W as f32, H as f32), + &composition.blits, + ); + drop(new); + } + let old_id = match &render.compositor.layout { + LayoutDriver::Animating { + outgoing: OutgoingPicture::Pinned(Some(old)), + .. + } => old.id(), + _ => panic!("real production pin required"), + }; + let ring = render.compositor.transitions.compositions.as_ref().unwrap(); + assert_ne!(old_id, ring.current().id()); + assert_ne!(old_id, ring.previous().unwrap().id()); + drop(native); + child(&mut render, 0.5); + let pressure = hold(&mut renderer, u64::from(W * H * 2)); + refused(&mut renderer, &mut render, &after, &full_plan()); + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_secs(2)), + Duration::ZERO, + )); + let result = admitted(&mut renderer, &mut render, &after, &full_plan()); + assert_eq!(result.native.as_ref().unwrap().id(), native_id); + assert!(matches!( + &render.compositor.layout, + LayoutDriver::Animating { + outgoing: OutgoingPicture::Pinned(None), + .. + } + )); + let settled = render.sample_pane_layout( + &super::super::super::surface::SurfaceAcquired::for_test(), + renderer.frame_sample(), + PixelGrid::new(1.0), + ); + assert!(settled.projection.is_some()); + present(&mut renderer, &mut render, &after, result); + drop(pressure); +} + +fn scroll(dead: bool) { + let (mut renderer, mut render, _) = setup(); + let mut root = retained_scroll::tests::fixture(); + root.set_frame_identity( + DisplayFrameId::new(42), + DisplayFrameId::new(0), + 0.0, + 0.0, + 0, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + render.set_current_frame( + Some(root.clone()), + None, + Default::default(), + Default::default(), + ); + let stream = neomacs_display_protocol::input_progress::InputStream::default(); + let delivery = stream.issue().unwrap(); + assert!( + render + .compositor + .input_scroll + .push(&root, 11.0, 11.0, 4.0, delivery.receipt(), None) + ); + render.compositor.input_scroll.paint(&mut root); + let prepared = retained_scroll::prepare( + &mut renderer, + &mut render, + &root, + mapping(&root, W, H), + false, + ) + .expect("actual warmed scroll raster"); + drop(prepared); + render.compositor.current_frame = Some(root.clone()); + child(&mut render, 0.5); + let pressure = hold(&mut renderer, u64::from(W * H * 2)); + refused(&mut renderer, &mut render, &root, &full_plan()); + if dead { + root.scroll_surfaces.clear(); + render.compositor.input_scroll = Default::default(); + } else { + render.overlays.idle_dim.active = true; + } + render.compositor.current_frame = Some(root.clone()); + let result = admitted(&mut renderer, &mut render, &root, &full_plan()); + assert!(render.compositor.retained_scroll.is_none()); + present(&mut renderer, &mut render, &root, result); + drop(pressure); + drop(delivery); +} +#[test] +fn dead_scroll_epoch_retires_under_held_pressure() { + if !budget_one("dead_scroll_epoch_retires_under_held_pressure") { + return; + } + scroll(true) +} +#[test] +fn ineligible_scroll_overlay_retires_under_held_pressure() { + if !budget_one("ineligible_scroll_overlay_retires_under_held_pressure") { + return; + } + scroll(false) +} diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs new file mode 100644 index 0000000000..45fea87227 --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs @@ -0,0 +1,1113 @@ +//! Real GPU regression through the runtime child-resize composition branch. + +#[path = "native_boundary_tests.rs"] +mod native_boundary; +#[path = "pane_size_boundary_tests.rs"] +mod pane_size_boundary; +#[path = "pinned_lifecycle_tests.rs"] +mod pinned_lifecycle; +#[path = "retained_owner_tests.rs"] +mod retained_owner; + +fn target(renderer: &WgpuRenderer) -> wgpu::Texture { + renderer.device().create_texture(&wgpu::TextureDescriptor { + label: Some("runtime opacity regression target"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) +} +fn draw_root( + renderer: &mut WgpuRenderer, + render: &mut GuiFrameRenderState, + view: &wgpu::TextureView, + root: &FrameGlyphBuffer, +) { + renderer.render_frame_glyphs( + view, + root, + render.compositor.glyph_atlas.as_mut().unwrap(), + mapping(root, W, H), + false, + None, + None, + None, + None, + None, + ); +} +fn linear_second() -> MotionSpec { + MotionSpec::Tween(TweenSpec { + duration: MotionDuration::new(Duration::from_secs(1)).unwrap(), + easing: TransitionEasing::Linear, + bezier: None, + }) +} +fn window( + id: i64, + bounds: neomacs_display_protocol::types::Rect, +) -> neomacs_display_protocol::frame_glyphs::WindowInfo { + neomacs_display_protocol::frame_glyphs::WindowInfo { + window_id: DisplayWindowId::new(id), + buffer_id: id as u64 + 100, + window_start: 1, + window_end: 100, + buffer_size: 1000, + buffer_modiff: neomacs_display_protocol::presentation_origin::BufferModiff::new(1), + bounds, + geometry: Default::default(), + line_number_field: None, + mode_line_height: 0.0, + header_line_height: 0.0, + tab_line_height: 0.0, + selected: false, + is_minibuffer: false, + char_height: 16.0, + buffer_name: "scratch".into(), + buffer_file_name: String::new(), + modified: false, + } +} +fn install_layout( + render: &mut GuiFrameRenderState, + root: &FrameGlyphBuffer, + origin: neomacs_display_protocol::frame_time::EventTime, +) { + render.measure_pane_layout(Some(root), origin); + render.compositor.current_frame = Some(root.clone()); + render.begin_presentable_render(); +} +fn seed_pending_layout( + render: &mut GuiFrameRenderState, + root: &FrameGlyphBuffer, + origin: neomacs_display_protocol::frame_time::EventTime, +) { + use neomacs_display_protocol::types::Rect; + render.compositor.pane_motion = crate::render_thread::render_quality::WindowAnimationSpecs { + resize: linear_second(), + movement: linear_second(), + open: linear_second(), + close: linear_second(), + }; + let mut before = root.clone(); + before.window_infos = vec![window(1, Rect::new(0.0, 0.0, W as f32, 48.0))]; + install_layout(render, &before, origin); + let mut after = root.clone(); + after.window_infos = vec![window(1, Rect::new(32.0, 0.0, 64.0, 48.0))]; + install_layout(render, &after, origin); + let composition = render.sample_pane_layout( + &super::super::surface::SurfaceAcquired::for_test(), + FrameSample::new(origin.plus(Duration::from_millis(100)), Duration::ZERO), + crate::render_thread::frame_compositor::continuity::pane_layout::PixelGrid::new(1.0), + ); + render.publish_submitted_projection(composition.projection); +} + +#[test] +fn mixed_policy_child_admission_tracks_applied_highlight_under_held_pressure() { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "render_thread::render_pass::scene::tests::mixed_policy_child_admission_tracks_applied_highlight_under_held_pressure", "--nocapture"]) + .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); + assert!(status.success()); + return; + } + let mut renderer = + WgpuRenderer::new(None, W, H).expect("GPU required for applied-alpha admission"); + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let mut root = picture(42, 0, W as f32); + root.height = H as f32; + root.set_frame_identity( + DisplayFrameId::new(42), + DisplayFrameId::new(0), + 0.0, + 0.0, + 0, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + root.background_alpha = 0.0; + render.set_current_frame( + Some(root.clone()), + None, + Default::default(), + Default::default(), + ); + let mut child = picture(43, 42, 64.0); + child.frame_alpha = [1.0, 0.5]; + assert!(render.compositor.child_frames.update_frame(child.clone())); + // A topmost opaque sibling must remain in its original geometry and z-order. + let mut sibling = picture(44, 42, 16.0); + sibling.background = Color::BLUE; + sibling.set_frame_identity( + DisplayFrameId::new(44), + DisplayFrameId::new(42), + 56.0, + 8.0, + 2, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + assert!(render.compositor.child_frames.update_frame(sibling.clone())); + let mut controls = crate::thread_comm::FrameOpacityState::default(); + controls.accept(42, [1.0; 2], 0.0); + controls.accept(43, [1.0, 0.5], 0.0); + controls.set_redirects(vec![(42, Some(43))]); + controls.focus(42, true); + render.apply_frame_opacity(&controls); + seed_pending_layout(&mut render, &root, origin); + let interaction = format!("{:?}", render.compositor.interaction); + let baseline = format!( + "{:?}", + render.compositor.baseline.as_ref().unwrap().window_infos + ); + let current_frame = render.compositor.current_frame.clone(); + let placement = render.compositor.child_frames.frames[&43] + .frame + .frame_placement; + let target = target(&renderer); + let view = target.create_view(&Default::default()); + let style = ChildFrameStyle { + corner_radius: 0.0, + shadow_enabled: false, + shadow_layers: 0, + shadow_offset: 0.0, + shadow_opacity: 0.0, + }; + let pressure = renderer + .acquire_snapshot(SnapshotSize::new(512, 506).unwrap()) + .unwrap(); + let size = SnapshotSize::new(W, H).unwrap(); + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(500)), + Duration::ZERO, + )); + // The same accepted policy and payload survive loss and regain of effective + // highlight. Neither a replacement scene nor releasing pressure may help. + for focused in [true, false, true] { + controls.focus(42, focused); + render.apply_frame_opacity(&controls); + assert_eq!( + render.compositor.child_frames.frames[&43].applied_frame_alpha, + if focused { 1.0 } else { 0.5 } + ); + let before = pixels(&renderer, &target); + let admission = super::super::composition_targets::prepare_child_targets( + &mut renderer, + &mut render, + size, + ); + assert_eq!(render.compositor.current_frame, current_frame); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + assert_eq!( + format!( + "{:?}", + render.compositor.baseline.as_ref().unwrap().window_infos + ), + baseline + ); + assert!( + render.compositor.layout.wants_frames(), + "admission cannot settle undrawn pane motion" + ); + assert_eq!(render.compositor.child_frames.frames[&43].frame, child); + assert_eq!(render.compositor.child_frames.frames[&44].frame, sibling); + assert_eq!( + render.compositor.child_frames.frames[&43] + .frame + .frame_placement, + placement + ); + if !focused { + assert!(matches!( + admission, + Err(super::super::surface::FrameRenderFailure::WindowNotReady) + )); + assert!(render.child_opacity_src.is_none() && render.child_resize_src.is_none()); + assert_eq!(pixels(&renderer, &target), before); + continue; + } + let (picture, mixed) = admission + .expect("currently opaque mixed-policy child needs no picture under held pressure"); + assert!(picture.is_none() && mixed.is_none()); + draw_root(&mut renderer, &mut render, &view, &root); + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + &view, + &root, + false, + None, + &style, + false, + ); + let data = pixels(&renderer, &target); + assert_eq!(px(&data, 48, 40), [0, 255, 0, 255]); + assert_eq!( + px(&data, 60, 40), + [0, 0, 255, 255], + "topmost sibling remains on top" + ); + assert_eq!( + px(&data, 2, 60), + [0; 4], + "outside children remains untouched" + ); + } + // Nil carries no fractional policy, yet must retain the actually applied .5. + controls.focus(42, false); + controls.accept(43, [-1.0; 2], 0.0); + render.apply_frame_opacity(&controls); + render + .compositor + .child_frames + .frames + .get_mut(&43) + .unwrap() + .frame + .frame_alpha = [-1.0; 2]; + assert_eq!( + render.compositor.child_frames.frames[&43].applied_frame_alpha, + 0.5 + ); + assert!(matches!( + super::super::composition_targets::prepare_child_targets(&mut renderer, &mut render, size), + Err(super::super::surface::FrameRenderFailure::WindowNotReady) + )); + // Current fractional background remains mandatory with whole-frame alpha1. + controls.accept(43, [1.0; 2], 0.0); + render.apply_frame_opacity(&controls); + render + .compositor + .child_frames + .frames + .get_mut(&43) + .unwrap() + .frame + .background_alpha = 0.5; + assert_eq!( + render.compositor.child_frames.frames[&43].applied_frame_alpha, + 1.0 + ); + assert!(matches!( + super::super::composition_targets::prepare_child_targets(&mut renderer, &mut render, size), + Err(super::super::surface::FrameRenderFailure::WindowNotReady) + )); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + drop(pressure); + let (picture, mixed) = + super::super::composition_targets::prepare_child_targets(&mut renderer, &mut render, size) + .unwrap(); + assert!(picture.is_some() && mixed.is_none()); + render.child_opacity_src = picture; + draw_root(&mut renderer, &mut render, &view, &root); + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + &view, + &root, + false, + None, + &style, + false, + ); + let data = pixels(&renderer, &target); + assert!((127..=129).contains(&px(&data, 48, 40)[3])); + assert_eq!(px(&data, 25, 25), [255; 4], "foreground remains opaque"); + assert_eq!(px(&data, 60, 40), [0, 0, 255, 255]); +} + +#[test] +fn expired_child_resize_recovers_under_unchanged_budget_pressure() { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "render_thread::render_pass::scene::tests::expired_child_resize_recovers_under_unchanged_budget_pressure", "--nocapture"]) + .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); + assert!(status.success()); + return; + } + let mut renderer = WgpuRenderer::new(None, W, H).expect("GPU required for expiry regression"); + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let mut root = FrameGlyphBuffer::with_size(W as f32, H as f32); + root.set_frame_identity( + DisplayFrameId::new(42), + DisplayFrameId::new(0), + 0.0, + 0.0, + 0, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + root.background_alpha = 0.0; + render.set_current_frame( + Some(root.clone()), + None, + Default::default(), + Default::default(), + ); + let old_frame = picture(43, 42, 64.0); + let old = renderer + .acquire_snapshot(SnapshotSize::new(64, 48).unwrap()) + .unwrap(); + let old_id = old.id(); + renderer.capture_child_frame_picture( + &old, + &old_frame, + render.compositor.glyph_atlas.as_mut().unwrap(), + 0.0, + false, + 0, + 0.0, + 0.0, + ); + let mut new_frame = picture(43, 42, 80.0); + new_frame.background = Color::BLUE; + new_frame.frame_alpha = [1.0, 0.5]; + assert!(render.compositor.child_frames.update_frame(new_frame)); + let mut controls = crate::thread_comm::FrameOpacityState::default(); + controls.accept(42, [1.0; 2], 0.0); + controls.accept(43, [1.0, 0.5], 0.0); + controls.set_redirects(vec![(42, Some(43))]); + controls.focus(42, true); + render.apply_frame_opacity(&controls); + assert_eq!( + render.compositor.child_frames.frames[&43].applied_frame_alpha, + 1.0 + ); + render.compositor.child_frames.begin_resize_crossfade( + 43, + old, + 64.0, + 48.0, + linear_second(), + origin, + ); + // Seed a genuine nonidentity submitted projection and pending motion. Admission + // may reclaim expired picture ownership, but must not publish undrawn geometry. + seed_pending_layout(&mut render, &root, origin); + let surface_point = neomacs_display_protocol::GeometryPoint::< + neomacs_display_protocol::RootSurfaceSpace, + LogicalPixels, + >::from_px(40.0, 20.0) + .unwrap(); + let mapped = render + .compositor + .interaction + .as_ref() + .unwrap() + .map(surface_point) + .unwrap(); + assert!( + (mapped.x() - 40.0).abs() > 1.0, + "guard must start with nonidentity submitted geometry" + ); + render.compositor.pending.theme = Some( + crate::render_thread::frame_compositor::continuity::theme::ThemeChange { + bounds: neomacs_display_protocol::types::Rect::new(0.0, 0.0, W as f32, H as f32), + }, + ); + let pending_theme = render.compositor.pending.theme; + let current_frame = render.compositor.current_frame.clone(); + assert!( + render + .compositor + .child_frames + .begin_drift(43, 4.0, 4.0, linear_second(), origin) + ); + render + .compositor + .child_frames + .record_drift_drawn(43, 4.0, 4.0, 0.25); + let drift = render.compositor.child_frames.frames[&43] + .drift + .as_ref() + .unwrap() + .last_drawn; + let clip = render.compositor.child_frames.frames[&43].clip_in_root; + let interaction = format!("{:?}", render.compositor.interaction); + let baseline = format!( + "{:?}", + render.compositor.baseline.as_ref().unwrap().window_infos + ); + let placement = render.compositor.child_frames.frames[&43] + .frame + .frame_placement; + let target = target(&renderer); + let view = target.create_view(&Default::default()); + draw_root(&mut renderer, &mut render, &view, &root); + let before = pixels(&renderer, &target); + let pressure = renderer + .acquire_snapshot(SnapshotSize::new(512, 500).unwrap()) + .unwrap(); + let size = SnapshotSize::new(W, H).unwrap(); + for millis in [250, 750, 999] { + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(millis)), + Duration::ZERO, + )); + assert!(matches!( + super::super::composition_targets::prepare_child_targets( + &mut renderer, + &mut render, + size + ), + Err(super::super::surface::FrameRenderFailure::WindowNotReady) + )); + assert!( + render.compositor.child_frames.frames[&43] + .crossfade + .is_some() + ); + assert!(render.child_opacity_src.is_none() && render.child_resize_src.is_none()); + assert_eq!(pixels(&renderer, &target), before); + assert_eq!(render.compositor.current_frame, current_frame); + assert_eq!(render.compositor.pending.theme, pending_theme); + assert_eq!( + render.compositor.child_frames.frames[&43] + .drift + .as_ref() + .unwrap() + .last_drawn, + drift + ); + assert_eq!( + render.compositor.child_frames.frames[&43].clip_in_root, + clip + ); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + } + let generation = render.compositor.current_scene_generation; + let style = ChildFrameStyle { + corner_radius: 0.0, + shadow_enabled: false, + shadow_layers: 0, + shadow_offset: 0.0, + shadow_opacity: 0.0, + }; + for millis in [1000, 1250] { + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(millis)), + Duration::ZERO, + )); + let (picture, mixed) = super::super::composition_targets::prepare_child_targets( + &mut renderer, + &mut render, + size, + ) + .expect("settled opaque child must resume without pressure release or replacement payload"); + assert!(picture.is_none() && mixed.is_none()); + assert!( + render.compositor.child_frames.frames[&43] + .crossfade + .is_none() + ); + assert_ne!(render.compositor.current_scene_generation, generation); + assert_eq!(render.compositor.current_frame, current_frame); + assert_eq!(render.compositor.pending.theme, pending_theme); + assert_eq!( + render.compositor.child_frames.frames[&43].clip_in_root, + clip + ); + if millis == 1000 { + assert_eq!( + render.compositor.child_frames.frames[&43] + .drift + .as_ref() + .unwrap() + .last_drawn, + drift + ); + } + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + assert_eq!( + format!( + "{:?}", + render.compositor.baseline.as_ref().unwrap().window_infos + ), + baseline + ); + assert!( + render.compositor.layout.wants_frames(), + "preflight must not settle unpublished pane motion" + ); + assert_eq!( + render.compositor.child_frames.frames[&43] + .frame + .frame_placement, + placement + ); + draw_root(&mut renderer, &mut render, &view, &root); + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + &view, + &root, + false, + None, + &style, + false, + ); + let data = pixels(&renderer, &target); + assert_eq!(px(&data, 48, 40), [0, 0, 255, 255]); + assert_eq!( + px(&data, 80, 40), + [0, 0, 255, 255], + "settled new-only extent" + ); + assert_eq!(px(&data, 2, 2), [0; 4]); + } + let released = renderer + .acquire_snapshot(SnapshotSize::new(64, 48).unwrap()) + .unwrap(); + assert_eq!( + released.id(), + old_id, + "old lease was returned to the production pool" + ); + drop(released); + let mut controls = crate::thread_comm::FrameOpacityState::default(); + controls.accept(43, [0.5; 2], 0.0); + render.apply_frame_opacity(&controls); + let before = pixels(&renderer, &target); + assert!(matches!( + super::super::composition_targets::prepare_child_targets(&mut renderer, &mut render, size), + Err(super::super::surface::FrameRenderFailure::WindowNotReady) + )); + assert_eq!(pixels(&renderer, &target), before); + assert_eq!(format!("{:?}", render.compositor.interaction), interaction); + drop(pressure); + let (picture, mixed) = + super::super::composition_targets::prepare_child_targets(&mut renderer, &mut render, size) + .unwrap(); + assert!(picture.is_some() && mixed.is_none()); + render.child_opacity_src = picture; + draw_root(&mut renderer, &mut render, &view, &root); + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + &view, + &root, + false, + None, + &style, + false, + ); + assert!((127..=129).contains(&px(&pixels(&renderer, &target), 48, 40)[3])); +} + +use super::*; + +#[test] +fn production_split_delete_placements_keep_background_opacity() { + use crate::render_thread::frame_compositor::continuity::pane_layout::PixelGrid; + use neomacs_display_protocol::types::Rect; + use neomacs_renderer_wgpu::PaneSource; + let mut renderer = + WgpuRenderer::new(None, W, H).expect("GPU required for runtime pane regression"); + let size = SnapshotSize::new(W, H).unwrap(); + let old = renderer.acquire_snapshot(size).unwrap(); + let new = renderer.acquire_snapshot(size).unwrap(); + for splitting in [true, false] { + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + render.compositor.pane_motion = + crate::render_thread::render_quality::WindowAnimationSpecs { + resize: linear_second(), + movement: linear_second(), + open: linear_second(), + close: linear_second(), + }; + let single = vec![window(1, Rect::new(0.0, 0.0, W as f32, 48.0))]; + let split = vec![ + window(1, Rect::new(0.0, 0.0, 48.0, 48.0)), + window(2, Rect::new(48.0, 0.0, 48.0, 48.0)), + ]; + let mut before = picture(42, 0, W as f32); + before.height = H as f32; + before.background_alpha = 0.5; + before.window_infos = if splitting { + single.clone() + } else { + split.clone() + }; + let mut after = before.clone(); + after.background = Color::BLUE; + after.window_infos = if splitting { split } else { single }; + draw_root(&mut renderer, &mut render, old.view(), &before); + draw_root(&mut renderer, &mut render, new.view(), &after); + install_layout(&mut render, &before, origin); + install_layout(&mut render, &after, origin); + let target = target(&renderer); + let view = target.create_view(&Default::default()); + for millis in [0, 500, 1000] { + let sample = + FrameSample::new(origin.plus(Duration::from_millis(millis)), Duration::ZERO); + let composition = render.sample_pane_layout( + &super::super::surface::SurfaceAcquired::for_test(), + sample, + PixelGrid::new(1.0), + ); + assert!(!composition.blits.is_empty()); + if millis < 1000 { + assert!( + composition + .blits + .iter() + .any(|p| p.source == PaneSource::Previous) + ); + } + eprintln!( + "runtime pane split={splitting} {millis}: {:?}", + composition.blits + ); + renderer.render_pane_layout( + new.bind_group(), + Some(old.bind_group()), + &view, + (W as f32, H as f32), + &composition.blits, + ); + let data = pixels(&renderer, &target); + for x in 2..W - 2 { + let bg = px(&data, x, 40); + assert!( + (127..=129).contains(&bg[3]), + "runtime pane split={splitting} {millis} x{x}: {bg:?}" + ); + } + let outside = px(&data, 80, 60); + assert_eq!(outside, [0, 0, 188, 128], "outside actual pane placements"); + assert_eq!( + px(&data, 20, 20), + [255; 4], + "opaque foreground inside surviving pane" + ); + } + } +} + +#[test] +fn expired_child_lifecycle_needs_no_scratch_under_unchanged_pressure() { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { + let status=std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact","render_thread::render_pass::scene::tests::expired_child_lifecycle_needs_no_scratch_under_unchanged_pressure","--nocapture"]) + .env("NEOMACS_GPU_BUDGET_MB","1").status().unwrap(); + assert!(status.success()); + return; + } + for closing in [false, true] { + let mut renderer = + WgpuRenderer::new(None, W, H).expect("GPU required for lifecycle expiry regression"); + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let mut root = FrameGlyphBuffer::with_size(W as f32, H as f32); + root.set_frame_identity( + DisplayFrameId::new(42), + DisplayFrameId::new(0), + 0.0, + 0.0, + 0, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + render.set_current_frame(Some(root), None, Default::default(), Default::default()); + assert!( + render + .compositor + .child_frames + .update_frame(picture(43, 42, 64.0)) + ); + if closing { + assert!(render.compositor.child_frames.retire_frame( + 43, + linear_second(), + origin, + 0.0, + 1.0 + )); + } else { + render.compositor.child_frames.begin_open_animation( + 43, + linear_second(), + origin, + 0.0, + 1.0, + ); + } + let pressure = renderer + .acquire_snapshot(SnapshotSize::new(512, 506).unwrap()) + .unwrap(); + let size = SnapshotSize::new(W, H).unwrap(); + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(999)), + Duration::ZERO, + )); + assert!(matches!( + super::super::composition_targets::prepare_child_targets( + &mut renderer, + &mut render, + size + ), + Err(super::super::surface::FrameRenderFailure::WindowNotReady) + )); + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(1000)), + Duration::ZERO, + )); + let (picture, mixed) = super::super::composition_targets::prepare_child_targets( + &mut renderer, + &mut render, + size, + ) + .unwrap(); + assert!(picture.is_none() && mixed.is_none()); + assert!(!render.compositor.child_frames.has_animation_activity()); + assert_eq!( + render.compositor.child_frames.frames.contains_key(&43), + !closing + ); + drop(pressure); + } +} + +use neomacs_display_protocol::TransitionEasing; +use neomacs_display_protocol::frame_time::{FrameSample, observe_platform_now}; +use neomacs_display_protocol::motion_spec::{MotionDuration, MotionSpec, TweenSpec}; +use neomacs_display_protocol::{ + Color, DeviceScale, DisplayWindowId, FaceId, FrameGlyphBuffer, GeometrySize, GlyphRowRole, + LogicalPixels, PresentMapping, PresentationExtent, StipplePattern, SurfaceState, +}; +use neomacs_renderer_wgpu::SnapshotSize; +use std::time::Duration; +const W: u32 = 96; +const H: u32 = 64; +fn mapping(frame: &FrameGlyphBuffer, w: u32, h: u32) -> PresentMapping { + let SurfaceState::Drawable(surface) = + SurfaceState::from_device_size(w, h, DeviceScale::new(1.0).unwrap()).unwrap() + else { + unreachable!() + }; + PresentMapping::top_left_clip( + surface, + PresentationExtent::new( + frame.presentation_id, + GeometrySize::::from_px(frame.width, frame.height).unwrap(), + ), + ) +} +fn picture(id: u64, parent: u64, width: f32) -> FrameGlyphBuffer { + let mut frame = FrameGlyphBuffer::with_size(width, 48.0); + frame.set_frame_identity( + DisplayFrameId::new(id), + DisplayFrameId::new(parent), + 8.0, + 8.0, + 1, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + frame.background = Color::GREEN; + frame.set_face( + FaceId::new(1), + Color::WHITE, + Some(Color::GREEN), + 400, + false, + 0, + None, + 0, + None, + 0, + None, + ); + frame.faces.get_mut(&FaceId::new(1)).unwrap().stipple = Some(Box::new(StipplePattern { + width: 1, + height: 1, + bits: vec![1], + })); + frame.set_draw_context(DisplayWindowId::new(1), GlyphRowRole::Text, None); + frame.add_stretch(12.0, 12.0, 16.0, 16.0, Color::GREEN, FaceId::new(1), false); + frame +} +fn pixels(renderer: &WgpuRenderer, target: &wgpu::Texture) -> Vec { + let padded = (W * 4).div_ceil(256) * 256; + let buffer = renderer.device().create_buffer(&wgpu::BufferDescriptor { + label: Some("runtime resize readback"), + size: (padded * H) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = renderer + .device() + .create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + ); + renderer.queue().submit([encoder.finish()]); + let slice = buffer.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + renderer + .device() + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(3)), + }) + .unwrap(); + let data = slice.get_mapped_range().unwrap(); + let mut result = Vec::new(); + for row in 0..H { + result.extend_from_slice(&data[(row * padded) as usize..(row * padded + W * 4) as usize]); + } + result +} +fn px(data: &[u8], x: u32, y: u32) -> [u8; 4] { + let i = ((y * W + x) * 4) as usize; + [data[i + 2], data[i + 1], data[i], data[i + 3]] +} +#[test] +fn production_child_resize_applies_complete_picture_opacity_once() { + let mut renderer = + WgpuRenderer::new(None, W, H).expect("GPU required for production resize regression"); + for (background_alpha, radius, lifecycle) in + [(1.0, 0.0, false), (0.5, 8.0, false), (0.5, 8.0, true)] + { + let origin = observe_platform_now(); + let mut render = GuiFrameRenderState::new(42, renderer.device(), 1.0, false, origin); + let mut root = FrameGlyphBuffer::with_size(W as f32, H as f32); + root.set_frame_identity( + DisplayFrameId::new(42), + DisplayFrameId::new(0), + 0.0, + 0.0, + 0, + false, + 0.0, + Color::BLACK, + false, + 1.0, + ); + root.background_alpha = 0.0; + render.set_current_frame( + Some(root.clone()), + None, + Default::default(), + Default::default(), + ); + let mut old_frame = picture(43, 42, 64.0); + old_frame.background_alpha = background_alpha; + let old = renderer + .acquire_snapshot(SnapshotSize::new(64, 48).unwrap()) + .unwrap(); + // Poison the reusable capture lease with opaque pixels first. The same + // complete-picture capture used by frame_ingest must clear uncovered corners. + let mut poison = old_frame.clone(); + poison.background_alpha = 1.0; + renderer.render_frame_glyphs( + old.view(), + &poison, + render.compositor.glyph_atlas.as_mut().unwrap(), + mapping(&poison, 64, 48), + false, + None, + None, + None, + None, + None, + ); + renderer.capture_child_frame_picture( + &old, + &old_frame, + render.compositor.glyph_atlas.as_mut().unwrap(), + radius, + false, + 0, + 0.0, + 0.0, + ); + let mut new_frame = picture(43, 42, 80.0); + new_frame.background = Color::BLUE; + new_frame.background_alpha = background_alpha; + new_frame.frame_alpha = [0.5, 0.25]; + assert!(render.compositor.child_frames.update_frame(new_frame)); + let spec = MotionSpec::Tween(TweenSpec { + duration: MotionDuration::new(Duration::from_secs(1)).unwrap(), + easing: TransitionEasing::Linear, + bezier: None, + }); + render + .compositor + .child_frames + .begin_resize_crossfade(43, old, 64.0, 48.0, spec, origin); + if lifecycle { + render + .compositor + .child_frames + .begin_open_animation(43, spec, origin, 0.0, 1.0); + } + (render.child_opacity_src, render.child_resize_src) = + super::super::composition_targets::mandatory_child_targets( + &mut renderer, + true, + true, + SnapshotSize::new(W, H).unwrap(), + ) + .unwrap(); + let target = renderer.device().create_texture(&wgpu::TextureDescriptor { + label: Some("runtime child resize target"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let style = ChildFrameStyle { + corner_radius: radius, + shadow_enabled: false, + shadow_layers: 0, + shadow_offset: 0.0, + shadow_opacity: 0.0, + }; + let mut controls = crate::thread_comm::FrameOpacityState::default(); + controls.accept(42, [-1.0; 2], 0.0); + controls.accept(43, [0.5, 0.25], 0.0); + controls.set_redirects(vec![(42, Some(43))]); + // Focus changes at the identical intermediate sample must update both pictures, + // not only the freshly ingested foreground. No sleeps or display/input access. + for millis in [0, 250, 500, 750, 1000] { + renderer.set_frame_sample(FrameSample::new( + origin.plus(Duration::from_millis(millis)), + Duration::ZERO, + )); + for accepted in [[0.5, 0.25], [0.75, 0.375]] { + controls.accept(43, accepted, 0.0); + for focused in [true, false] { + controls.focus(42, focused); + render.apply_frame_opacity(&controls); + renderer.render_frame_glyphs( + &view, + &root, + render.compositor.glyph_atlas.as_mut().unwrap(), + mapping(&root, W, H), + false, + None, + None, + None, + None, + None, + ); + render_frame_content_overlays( + &mut renderer, + (W, H), + &mut render, + &view, + &root, + false, + None, + &style, + false, + ); + let data = pixels(&renderer, &target); + let bg = px(&data, 48, 40); + let fg = px(&data, 25, 25); + let mix = millis as f32 / 1000.0; + let alpha = + accepted[if focused { 0 } else { 1 }] * if lifecycle { mix } else { 1.0 }; + let expected = (255.0 * alpha).round() as u8; + eprintln!( + "runtime resize {millis}ms focus {focused}: background {bg:?}, foreground {fg:?}" + ); + assert!( + bg[3].abs_diff((255.0 * alpha * background_alpha).round() as u8) <= 2, + "complete child background alpha: {millis}/{focused}: {bg:?}" + ); + assert!( + fg[3].abs_diff(expected) <= 1, + "complete child foreground alpha: {millis}/{focused}: {fg:?}" + ); + let weighted = Color::new( + 0.0, + alpha * background_alpha * (1.0 - mix), + alpha * background_alpha * mix, + alpha * background_alpha, + ) + .linear_to_srgb(); + assert!( + bg[0] == 0 + && bg[1].abs_diff((255.0 * weighted.g).round() as u8) <= 2 + && bg[2].abs_diff((255.0 * weighted.b).round() as u8) <= 2, + "weighted pictures: {bg:?}" + ); + assert!( + fg[0] == fg[1] && fg[1] == fg[2] && (alpha == 0.0 || fg[0] > 0), + "foreground preserved: {fg:?}" + ); + let new_only = px(&data, 80, 40); + assert!( + new_only[3] + .abs_diff((255.0 * alpha * background_alpha * mix).round() as u8) + <= 2, + "new-only resize extent: {new_only:?}" + ); + assert_eq!(px(&data, 2, 2), [0; 4], "parent outside child is untouched"); + if radius > 0.0 { + assert_eq!(px(&data, 8, 8), [0; 4], "capture clears rounded corner"); + } + } + } + } + } +} diff --git a/crates/neomacs-display-runtime/src/render_thread/transitions.rs b/crates/neomacs-display-runtime/src/render_thread/transitions.rs index c8c36307f1..288627d6dc 100644 --- a/crates/neomacs-display-runtime/src/render_thread/transitions.rs +++ b/crates/neomacs-display-runtime/src/render_thread/transitions.rs @@ -147,6 +147,57 @@ impl TransitionState { !self.active.is_empty() } + /// Read-only preflight for transitions that can introduce fractional pixels. + /// Opaque controls do not certify the retained old picture or transformed + /// coverage. Reuse the real planners without draining hints/continuity or + /// advancing the ring. A current picture becomes previous on advancement. + pub(super) fn needs_native_conversion( + &self, + frame: &FrameGlyphBuffer, + sample: neomacs_display_protocol::frame_time::FrameSample, + pending: &crate::render_thread::frame_compositor::PendingContinuity, + effects: &neomacs_display_protocol::EffectsConfig, + accept_hints: bool, + accept_derived_effects: bool, + ) -> bool { + if self.active.values().any(|transition| { + sample + .presentation_time() + .saturating_since(transition.started) + < transition.plan.duration + }) { + return true; + } + if self.compositions.is_none() { + return false; + } + (accept_hints + && frame + .transition_hints + .iter() + .any(|hint| plan_transition_hint(&self.policy, hint).is_some())) + || pending + .scrolls + .iter() + .any(|scroll| plan_scroll(&self.policy, scroll).is_some()) + || (accept_derived_effects + && pending.theme.is_some() + && effects.theme_transition.enabled) + } + + /// Release finished pictures without consuming observations or publishing placement. + pub(super) fn reclaim_finished( + &mut self, + sample: neomacs_display_protocol::frame_time::FrameSample, + ) { + self.active.retain(|_, transition| { + sample + .presentation_time() + .saturating_since(transition.started) + < transition.plan.duration + }); + } + pub(super) fn active_count(&self) -> usize { self.active.len() } diff --git a/crates/neomacs-display-runtime/src/render_thread/window_commands.rs b/crates/neomacs-display-runtime/src/render_thread/window_commands.rs index 76c86a64c8..72a32a0f6c 100644 --- a/crates/neomacs-display-runtime/src/render_thread/window_commands.rs +++ b/crates/neomacs-display-runtime/src/render_thread/window_commands.rs @@ -7,6 +7,20 @@ use winit::dpi::PhysicalPosition; use winit::window::UserAttentionType; impl RenderApp { + pub(super) fn refresh_frame_opacity(&mut self) { + // Never hold this CPU lock across GPU work or callback-capable code. + let controls = self + .comms + .frame_opacity + .lock() + .unwrap_or_else(|e| e.into_inner()); + for window in self.frame_windows.windows.values_mut() { + if window.render.apply_frame_opacity(&controls) { + window.request_redraw(); + } + } + } + fn remove_pending_child_subtree(&mut self, frame_id: u64) { let mut subtree = std::collections::HashSet::from([frame_id]); loop { @@ -31,6 +45,7 @@ impl RenderApp { pub(super) fn handle_window(&mut self, cmd: WindowCommand) { match cmd { + WindowCommand::RefreshFrameOpacity => self.refresh_frame_opacity(), WindowCommand::ScrollPreview(intent) => { if let Some(state) = self.frame_windows.get_mut(intent.frame) && matches!( @@ -307,6 +322,19 @@ impl RenderApp { let emacs_frame_id = frame.raw_id(); tracing::info!("AdoptPrimaryFrame request: frame_id=0x{:x}", emacs_frame_id); self.frame_windows.adopt_primary_frame_id(emacs_frame_id); + if self + .frame_windows + .get(emacs_frame_id) + .and_then(|state| state.window()) + .is_some_and(|window| window.has_focus()) + { + self.comms + .frame_opacity + .lock() + .unwrap_or_else(|e| e.into_inner()) + .focus(emacs_frame_id, true); + self.refresh_frame_opacity(); + } // The window's scheduling identity moves from the pending id // (0) to the adopted Emacs frame id; retire the old entry so // its deadlines and request token cannot go stale. diff --git a/crates/neomacs-display-runtime/src/render_thread/window_events.rs b/crates/neomacs-display-runtime/src/render_thread/window_events.rs index fb0699d3ac..3242fd0e80 100644 --- a/crates/neomacs-display-runtime/src/render_thread/window_events.rs +++ b/crates/neomacs-display-runtime/src/render_thread/window_events.rs @@ -277,6 +277,14 @@ impl RenderApp { }) .unwrap_or_default() }; + // GNU whole-frame active/inactive alpha follows native focus, + // including a loss with no evaluator content change. + self.comms + .frame_opacity + .lock() + .unwrap_or_else(|e| e.into_inner()) + .focus(emacs_fid, focused); + self.refresh_frame_opacity(); self.comms.send_input(InputEvent::WindowFocus { focused, emacs_frame_id: emacs_fid, diff --git a/crates/neomacs-display-runtime/src/thread_comm.rs b/crates/neomacs-display-runtime/src/thread_comm.rs index bcce516900..4ae61bb468 100644 --- a/crates/neomacs-display-runtime/src/thread_comm.rs +++ b/crates/neomacs-display-runtime/src/thread_comm.rs @@ -10,7 +10,9 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; mod frame_mailbox; +mod frame_opacity; pub use frame_mailbox::{FrameReceiver, FrameSender, QueuedPresentation, SupersededPresentation}; +pub use frame_opacity::FrameOpacityState; use neomacs_display_protocol::{ ImageColorContext, ImageId, ImageLoadToken, ImageMaskPolicy, ImageRealization, ImageRotation, ImageSizeSpec, SelectionOwner, VideoId, @@ -329,6 +331,8 @@ pub enum LifecycleCommand { /// Window and chrome management commands. #[derive(Debug)] pub enum WindowCommand { + /// Wake presentation after a synchronous CPU opacity control operation. + RefreshFrameOpacity, /// Paint an evaluator-resolved viewport while canonical layout is pending. ScrollPreview(neomacs_display_protocol::scroll_coverage::ResolvedScrollIntent), /// Scroll blit pixels within pixel buffer @@ -979,6 +983,7 @@ pub struct ThreadComms { pub capabilities: Arc, pub tooltip_context: Arc, + pub frame_opacity: Arc>, } impl ThreadComms { @@ -997,6 +1002,7 @@ impl ThreadComms { input_rx, capabilities, tooltip_context: Arc::default(), + frame_opacity: Arc::default(), } } @@ -1004,6 +1010,7 @@ impl ThreadComms { pub fn split(self) -> (EmacsComms, RenderComms) { let emacs = EmacsComms { tooltip_context: self.tooltip_context.clone(), + frame_opacity: Arc::clone(&self.frame_opacity), frame_tx: self.frame_tx, cmd_tx: self.cmd_tx, input_rx: self.input_rx, @@ -1013,6 +1020,7 @@ impl ThreadComms { let render = RenderComms { input_stream: Default::default(), tooltip_context: self.tooltip_context, + frame_opacity: self.frame_opacity, frame_rx: self.frame_rx, cmd_rx: self.cmd_rx, input_tx: self.input_tx, @@ -1036,6 +1044,7 @@ pub struct EmacsComms { pub input_rx: Receiver, pub capabilities: Arc, pub tooltip_context: Arc, + pub frame_opacity: Arc>, } /// Render thread communication handle @@ -1046,6 +1055,7 @@ pub struct RenderComms { pub input_tx: Sender, pub capabilities: Arc, pub tooltip_context: Arc, + pub frame_opacity: Arc>, } impl RenderComms { diff --git a/crates/neomacs-display-runtime/src/thread_comm/frame_opacity.rs b/crates/neomacs-display-runtime/src/thread_comm/frame_opacity.rs new file mode 100644 index 0000000000..e5397c3fff --- /dev/null +++ b/crates/neomacs-display-runtime/src/thread_comm/frame_opacity.rs @@ -0,0 +1,117 @@ +//! Synchronous native opacity controls, independent of replaceable scenes. +//! +//! Setters and keyboard focus serialize on this small CPU state. In particular +//! nil replaces the policy but retains the scalar already applied by the prior +//! setter, even when no scene carrying that setter was ever rendered. +use std::collections::HashMap; + +#[derive(Debug)] +struct FrameOpacity { + pair: [f32; 2], + limit: f32, + applied: f32, +} + +#[derive(Debug, Default)] +pub struct FrameOpacityState { + frames: HashMap, + redirects: HashMap>, + native_focus: Option, +} + +impl FrameOpacityState { + pub fn accept(&mut self, frame: u64, pair: [f32; 2], limit: f32) { + let old_highlight = self.highlight(); + let state = self.frames.entry(frame).or_insert(FrameOpacity { + pair: [-1.0; 2], + limit, + applied: 1.0, + }); + state.pair = pair; + state.limit = limit; + self.rehighlight(old_highlight); + self.apply(frame); + } + + /// Publishing the variable does not itself reapply alpha. GNU consults + /// the current scalar on the next setter or focus/highlight operation. + pub fn set_lower_limit(&mut self, limit: f32) { + for state in self.frames.values_mut() { + state.limit = limit; + } + } + + pub fn focus(&mut self, frame: u64, focused: bool) { + let old_highlight = self.highlight(); + if focused { + self.native_focus = Some(frame); + } else if self.native_focus == Some(frame) { + // A late leave from A cannot clear a newer enter on B. + self.native_focus = None; + } + self.rehighlight(old_highlight); + } + + pub fn set_redirects(&mut self, redirects: Vec<(u64, Option)>) { + let old_highlight = self.highlight(); + self.redirects = redirects.into_iter().collect(); + self.rehighlight(old_highlight); + } + + pub fn retire(&mut self, frame: u64) { + let old_highlight = self.highlight(); + self.frames.remove(&frame); + self.redirects.remove(&frame); + if self.native_focus == Some(frame) { + self.native_focus = None; + } + self.rehighlight(old_highlight); + } + + pub fn applied(&self, frame: u64) -> Option { + self.frames.get(&frame).map(|state| state.applied) + } + + fn highlight(&self) -> Option { + // GNU x_frame_rehighlight resolves one hop, falling back to the + // focused frame when its redirect is absent or no longer live. + self.native_focus.map(|frame| { + self.redirects + .get(&frame) + .copied() + .flatten() + .filter(|target| self.frames.contains_key(target)) + .unwrap_or(frame) + }) + } + + fn rehighlight(&mut self, old_highlight: Option) { + let highlight = self.highlight(); + // GNU re-highlights only the old/new participants on a real change. + // No-op redirects and stale focus events must not replay unrelated + // policies under a temporarily bound lower limit. + if old_highlight != highlight { + for frame in old_highlight.into_iter().chain(highlight) { + self.apply(frame); + } + } + } + + fn apply(&mut self, frame: u64) { + let highlight = self.highlight(); + if let Some(state) = self.frames.get_mut(&frame) { + let alpha = state.pair[usize::from(highlight != Some(frame))]; + if alpha >= 0.0 { + state.applied = if (0.0..=1.0).contains(&state.limit) { + alpha.max(state.limit) + } else { + alpha + }; + } + } + } +} + +#[cfg(test)] +#[path = "frame_opacity/tests/mod.rs"] +mod tests; diff --git a/crates/neomacs-display-runtime/src/thread_comm/frame_opacity/tests/mod.rs b/crates/neomacs-display-runtime/src/thread_comm/frame_opacity/tests/mod.rs new file mode 100644 index 0000000000..1976682048 --- /dev/null +++ b/crates/neomacs-display-runtime/src/thread_comm/frame_opacity/tests/mod.rs @@ -0,0 +1,201 @@ +use super::*; + +#[test] +fn setter_under_temporary_limit_only_reapplies_its_target() { + let mut state = FrameOpacityState::default(); + state.accept(1, [0.3; 2], 0.0); + state.accept(2, [0.5; 2], 0.0); + state.focus(1, true); + state.set_lower_limit(0.8); + state.accept(2, [0.5; 2], 0.8); + state.set_lower_limit(0.0); + assert_eq!(state.applied(1), Some(0.3)); + assert_eq!(state.applied(2), Some(0.8)); +} + +#[test] +fn unchanged_highlight_and_stale_focus_do_not_reapply_policy() { + let mut state = FrameOpacityState::default(); + for frame in [1, 2, 3] { + state.accept(frame, [0.7, 0.3], 0.0); + } + state.focus(1, true); + state.set_redirects(vec![(1, Some(2))]); + state.set_lower_limit(0.9); + state.set_redirects(vec![(1, Some(2)), (3, Some(1))]); + state.focus(1, true); + state.focus(3, false); + assert_eq!(state.applied(1), Some(0.3)); + assert_eq!(state.applied(2), Some(0.7)); + assert_eq!(state.applied(3), Some(0.3)); + state.set_redirects(vec![(1, Some(3))]); + assert_eq!(state.applied(1), Some(0.3)); + assert_eq!(state.applied(2), Some(0.9)); + assert_eq!(state.applied(3), Some(0.9)); +} + +#[test] +fn shared_child_reader_projects_redirects_into_applied_native_opacity() { + use neovm_core::emacs_core::{Context, DisplayHost, GuiFrameHostRequest, Value}; + use neovm_core::window::FrameId; + use std::sync::{Arc, Mutex}; + + type AppliedOpacitySnapshot = (Option, Option); + struct Host { + state: Arc>, + snapshots: Arc>>, + owner: Arc>>, + } + impl DisplayHost for Host { + fn realize_gui_frame(&mut self, _request: GuiFrameHostRequest) -> Result<(), String> { + Ok(()) + } + fn resize_gui_frame(&mut self, _request: GuiFrameHostRequest) -> Result<(), String> { + Ok(()) + } + fn set_gui_frame_focus_redirects( + &mut self, + redirects: Vec<(FrameId, Option)>, + ) -> Result<(), String> { + let mut state = self.state.lock().unwrap(); + state.set_redirects( + redirects + .into_iter() + .map(|(id, target)| (id.0, target.map(|id| id.0))) + .collect(), + ); + if let Some((owner, child)) = *self.owner.lock().unwrap() { + self.snapshots + .lock() + .unwrap() + .push((state.applied(owner.0), state.applied(child.0))); + } + Ok(()) + } + } + for abort in [false, true] { + let mut eval = Context::new(); + let buffer = eval.buffer_manager_mut().create_buffer("*scratch*"); + eval.buffer_manager_mut().set_current(buffer); + let native = eval.frame_manager_mut().create_frame("A", 800, 600, buffer); + eval.frame_manager_mut() + .get_mut(native) + .unwrap() + .set_window_system(Some(Value::symbol("neo"))); + let state = Arc::new(Mutex::new(FrameOpacityState::default())); + let snapshots = Arc::new(Mutex::new(Vec::new())); + let owner_ids = Arc::new(Mutex::new(None)); + eval.set_display_host(Box::new(Host { + state: state.clone(), + snapshots: snapshots.clone(), + owner: owner_ids.clone(), + })); + for result in eval.eval_str_each("(setq owner (x-create-frame nil)) (setq child (x-create-frame (list (cons 'parent-frame owner) (cons 'minibuffer (minibuffer-window owner))))) (select-frame child)") { + assert!(result.is_ok(), "{result:?}"); + } + let owner = FrameId(eval.eval_str("owner").unwrap().as_frame_id().unwrap()); + let child = FrameId(eval.eval_str("child").unwrap().as_frame_id().unwrap()); + { + let mut state = state.lock().unwrap(); + state.accept(native.0, [0.7, 0.2], 0.0); + state.accept(owner.0, [0.9, 0.3], 0.0); + state.accept(child.0, [0.8, 0.4], 0.0); + state.focus(native.0, true); + } + *owner_ids.lock().unwrap() = Some((owner, child)); + eval.set_variable("native", Value::make_frame(native.0)); + eval.eval_str("(redirect-frame-focus native child)") + .unwrap(); + let (tx, rx) = crossbeam_channel::unbounded(); + tx.send(neovm_core::keyboard::InputEvent::key_press( + neovm_core::keyboard::KeyEvent::char('\r'), + )) + .unwrap(); + eval.input_rx = Some(rx); + let result = eval.eval_str(&format!( + r#"(progn + (fset 'command-execute (lambda (command &rest ignored) (call-interactively command))) + (setq read-map (make-sparse-keymap)) + (define-key read-map "\r" (lambda () (interactive) (throw 'exit nil))) + (setq minibuffer-setup-hook {}) + (read-from-minibuffer "Prompt: " nil read-map))"#, + if abort { + "(list (lambda () (error \"setup abort\")))" + } else { + "nil" + } + )); + assert_eq!(result.is_err(), abort, "{result:?}"); + let snapshots = snapshots.lock().unwrap(); + assert!( + snapshots.contains(&(Some(0.9), Some(0.4))), + "entry: {snapshots:?}" + ); + assert_eq!(snapshots.last(), Some(&(Some(0.3), Some(0.8))), "unwind"); + } +} + +#[test] +fn accepted_numeric_then_nil_survives_absent_and_replaced_scenes() { + let mut state = FrameOpacityState::default(); + state.focus(1, true); + for frame in [1, 2] { + state.accept(frame, [0.5; 2], 0.2); + state.accept(frame, [-1.0; 2], 0.2); + assert_eq!(state.applied(frame), Some(0.5)); + } + state.focus(1, false); + state.focus(2, true); + assert_eq!(state.applied(1), Some(0.5)); + assert_eq!(state.applied(2), Some(0.5)); +} + +#[test] +fn lower_limit_changes_apply_at_focus_without_replaying_nil() { + let mut state = FrameOpacityState::default(); + state.accept(1, [0.8, 0.3], 0.2); + state.focus(1, true); + state.set_lower_limit(0.6); + assert_eq!(state.applied(1), Some(0.8)); + state.focus(1, false); + assert_eq!(state.applied(1), Some(0.6)); + state.accept(1, [-1.0; 2], 0.6); + state.set_lower_limit(0.9); + state.focus(1, true); + assert_eq!(state.applied(1), Some(0.6)); +} + +#[test] +fn nil_component_retains_last_scalar_not_previous_policy_component() { + let mut state = FrameOpacityState::default(); + state.focus(1, true); + state.accept(1, [0.8, 0.4], 0.2); + state.focus(1, false); + state.accept(1, [-1.0, 0.6], 0.2); + state.focus(1, true); + assert_eq!(state.applied(1), Some(0.6)); + state.accept(1, [-1.0; 2], 0.2); + state.focus(1, false); + assert_eq!(state.applied(1), Some(0.6)); +} + +#[test] +fn redirect_clear_dead_target_and_late_leave_use_effective_highlight() { + let mut state = FrameOpacityState::default(); + for frame in [1, 2, 3] { + state.accept(frame, [0.8, 0.4], 0.2); + } + state.focus(1, true); + assert_eq!((state.applied(1), state.applied(2)), (Some(0.8), Some(0.4))); + state.set_redirects(vec![(1, Some(2))]); + assert_eq!((state.applied(1), state.applied(2)), (Some(0.4), Some(0.8))); + state.set_redirects(vec![(1, None)]); + assert_eq!((state.applied(1), state.applied(2)), (Some(0.8), Some(0.4))); + state.set_redirects(vec![(1, Some(2)), (2, Some(3))]); + assert_eq!(state.applied(2), Some(0.8)); // one hop, not recursive + state.retire(2); + assert_eq!(state.applied(1), Some(0.8)); + state.focus(3, true); + state.focus(1, false); + assert_eq!(state.applied(3), Some(0.8)); +} diff --git a/crates/neomacs-display-runtime/src/thread_comm/tests/mod.rs b/crates/neomacs-display-runtime/src/thread_comm/tests/mod.rs index d133b2660b..4743bb2552 100644 --- a/crates/neomacs-display-runtime/src/thread_comm/tests/mod.rs +++ b/crates/neomacs-display-runtime/src/thread_comm/tests/mod.rs @@ -16,6 +16,38 @@ fn test_image_load(image: u32, attempt: u64) -> ImageLoadToken { ) } +#[test] +fn accepted_opacity_is_independent_of_superseded_visual_mailbox() { + let (emacs, render) = ThreadComms::new().split(); + emacs.frame_opacity.lock().unwrap().accept(1, [0.5; 2], 0.2); + let mut numeric = FrameDisplayState::new(12, 4, 8.0, 16.0); + numeric.frame_alpha = [0.5; 2]; + assert!( + emacs + .frame_tx + .submit(sealed_test_state(numeric)) + .unwrap() + .is_none() + ); + emacs + .frame_opacity + .lock() + .unwrap() + .accept(1, [-1.0; 2], 0.2); + let mut nil = FrameDisplayState::new(12, 4, 8.0, 16.0); + nil.frame_alpha = [-1.0; 2]; + assert!( + emacs + .frame_tx + .submit(sealed_test_state(nil)) + .unwrap() + .is_some() + ); + let latest = render.frame_rx.try_recv().unwrap(); + assert_eq!(latest.frame_alpha, [-1.0; 2]); + assert_eq!(render.frame_opacity.lock().unwrap().applied(1), Some(0.5)); +} + #[test] fn render_capabilities_are_one_shared_evaluator_visible_snapshot() { let (emacs, render) = ThreadComms::new().split(); diff --git a/crates/neomacs-layout-engine/src/display_frame_output.rs b/crates/neomacs-layout-engine/src/display_frame_output.rs index 511031d335..5ed60d1034 100644 --- a/crates/neomacs-layout-engine/src/display_frame_output.rs +++ b/crates/neomacs-layout-engine/src/display_frame_output.rs @@ -62,6 +62,7 @@ pub(crate) struct FrameOutputIdentity { pub(crate) outer_border_width: f32, pub(crate) outer_border_color: Color, pub(crate) background_alpha: f32, + pub(crate) frame_alpha: [f32; 2], pub(crate) no_accept_focus: bool, } @@ -278,6 +279,7 @@ impl<'a> FrameOutputTarget<'a> { identity.outer_border_width, identity.outer_border_color, identity.background_alpha, + identity.frame_alpha, identity.no_accept_focus, ); } diff --git a/crates/neomacs-layout-engine/src/display_mock_frame.rs b/crates/neomacs-layout-engine/src/display_mock_frame.rs index fd08ce2e40..4f63dcb522 100644 --- a/crates/neomacs-layout-engine/src/display_mock_frame.rs +++ b/crates/neomacs-layout-engine/src/display_mock_frame.rs @@ -340,6 +340,7 @@ fn set_mock_frame_identity(builder: &mut DisplayOutputBuilder, identity: FrameOu identity.outer_border_width, identity.outer_border_color, identity.background_alpha, + identity.frame_alpha, identity.no_accept_focus, ); } @@ -368,6 +369,7 @@ pub(crate) fn layout_mock_frame_content( outer_border_width: 0.0, outer_border_color: Color::BLACK, background_alpha: 1.0, + frame_alpha: [-1.0; 2], no_accept_focus: false, }, ); @@ -573,6 +575,7 @@ pub(crate) fn layout_mock_frame_content( outer_border_width: 0.0, outer_border_color: Color::BLACK, background_alpha: 1.0, + frame_alpha: [-1.0; 2], no_accept_focus: false, }, ); diff --git a/crates/neomacs-layout-engine/src/engine.rs b/crates/neomacs-layout-engine/src/engine.rs index 1b4c4c8fe3..10931fe066 100644 --- a/crates/neomacs-layout-engine/src/engine.rs +++ b/crates/neomacs-layout-engine/src/engine.rs @@ -2149,7 +2149,23 @@ impl LayoutEngine { .and_then(neovm_core::face::Color::parse) .map(|color| Color::from_pixel(color.to_pixel())) .unwrap_or(Color::BLACK), - background_alpha: 1.0, + background_alpha: frame.background_alpha, + frame_alpha: { + let mut alpha = frame.frame_alpha; + let limit = neovm_core::window::frame_alpha::lower_limit( + evaluator + .obarray() + .symbol_value("frame-alpha-lower-limit") + .copied() + .unwrap_or(Value::fixnum(20)), + ); + for value in &mut alpha { + if *value >= 0.0 && (0.0..=1.0).contains(&limit) { + *value = value.max(limit); + } + } + alpha + }, no_accept_focus: frame.no_accept_focus, }) } else { diff --git a/crates/neomacs-layout-engine/src/output/builder.rs b/crates/neomacs-layout-engine/src/output/builder.rs index f20cbdc17b..9611650cfc 100644 --- a/crates/neomacs-layout-engine/src/output/builder.rs +++ b/crates/neomacs-layout-engine/src/output/builder.rs @@ -500,6 +500,7 @@ impl DisplayOutputBuilder { outer_border_width: f32, outer_border_color: Color, background_alpha: f32, + frame_alpha: [f32; 2], no_accept_focus: bool, ) { self.install_output_frame_state(OutputFrameStateInstallRequest::Identity( @@ -515,6 +516,7 @@ impl DisplayOutputBuilder { outer_border_width, outer_border_color, background_alpha, + frame_alpha, no_accept_focus, }, )); diff --git a/crates/neomacs-layout-engine/src/output/builder/tests/mod.rs b/crates/neomacs-layout-engine/src/output/builder/tests/mod.rs index 2a001f8706..f11dd6f368 100644 --- a/crates/neomacs-layout-engine/src/output/builder/tests/mod.rs +++ b/crates/neomacs-layout-engine/src/output/builder/tests/mod.rs @@ -278,11 +278,14 @@ fn builder_preserves_negative_child_frame_origin_for_parent_clipping() { Color::BLACK, 0.0, Color::BLACK, - 1.0, + 0.5, + [0.8, 0.4], true, ); let state = builder.finish(80, 24, 1.0, 1.0); + assert_eq!(state.background_alpha, 0.5); + assert_eq!(state.frame_alpha, [0.8, 0.4]); let placement = state.frame_placement; assert_eq!(placement.parent(), Some(DisplayFrameId::new(1))); diff --git a/crates/neomacs-layout-engine/src/output/frame_state.rs b/crates/neomacs-layout-engine/src/output/frame_state.rs index dbea9e27b7..89d0755e53 100644 --- a/crates/neomacs-layout-engine/src/output/frame_state.rs +++ b/crates/neomacs-layout-engine/src/output/frame_state.rs @@ -55,6 +55,7 @@ pub(crate) struct OutputFrameBuildState { outer_border_width: f32, outer_border_color: Color, background_alpha: f32, + frame_alpha: [f32; 2], no_accept_focus: bool, } @@ -99,6 +100,7 @@ impl OutputFrameBuildState { a: 1.0, }, background_alpha: 1.0, + frame_alpha: [-1.0; 2], no_accept_focus: false, } } @@ -200,6 +202,7 @@ impl OutputFrameBuildState { a: 1.0, }; self.background_alpha = 1.0; + self.frame_alpha = [-1.0; 2]; self.no_accept_focus = false; } @@ -311,6 +314,7 @@ impl OutputFrameBuildState { self.outer_border_width = identity.outer_border_width; self.outer_border_color = identity.outer_border_color; self.background_alpha = identity.background_alpha; + self.frame_alpha = identity.frame_alpha; self.no_accept_focus = identity.no_accept_focus; } OutputFrameStateInstallRequest::BackgroundColor(color) => self.background_color = color, @@ -383,6 +387,7 @@ impl OutputFrameBuildState { state.outer_border_width = self.outer_border_width; state.outer_border_color = self.outer_border_color; state.background_alpha = self.background_alpha; + state.frame_alpha = self.frame_alpha; state.no_accept_focus = self.no_accept_focus; } } diff --git a/crates/neomacs-layout-engine/src/output/install_request.rs b/crates/neomacs-layout-engine/src/output/install_request.rs index b021eff4ff..c684ec774e 100644 --- a/crates/neomacs-layout-engine/src/output/install_request.rs +++ b/crates/neomacs-layout-engine/src/output/install_request.rs @@ -115,6 +115,7 @@ pub(crate) struct OutputFrameIdentityInstallRequest { pub(crate) outer_border_width: f32, pub(crate) outer_border_color: Color, pub(crate) background_alpha: f32, + pub(crate) frame_alpha: [f32; 2], pub(crate) no_accept_focus: bool, } diff --git a/crates/neomacs-renderer-wgpu/src/renderer/child_frames.rs b/crates/neomacs-renderer-wgpu/src/renderer/child_frames.rs index 056034fbe1..f7cacf44dd 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/child_frames.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/child_frames.rs @@ -7,6 +7,15 @@ use neomacs_display_protocol::frame_glyphs::FrameGlyphBuffer; use neomacs_display_protocol::types::{AnimatedCursor, Color}; use neomacs_display_protocol::{PointerAppearanceSelection, RootSurfaceRect}; +/// Unscaled old picture and preflighted scratch for a child resize mix. +pub struct ChildResizePicture<'a> { + pub old: &'a super::SnapshotLease, + pub old_width: f32, + pub old_height: f32, + pub mix: f32, + pub composition: &'a super::SnapshotLease, +} + impl WgpuRenderer { /// The scissor rect a child frame's clip resolves to on this surface. /// @@ -65,6 +74,36 @@ impl WgpuRenderer { surface_height: u32, alpha: f32, scissor: Option<(u32, u32, u32, u32)>, + ) { + self.draw_child_picture_quad( + view, + snapshot_bind_group, + x, + y, + width, + height, + surface_width, + surface_height, + alpha, + scissor, + false, + ); + } + + #[allow(clippy::too_many_arguments)] + fn draw_child_picture_quad( + &mut self, + view: &wgpu::TextureView, + snapshot_bind_group: &wgpu::BindGroup, + x: f32, + y: f32, + width: f32, + height: f32, + surface_width: u32, + surface_height: u32, + alpha: f32, + scissor: Option<(u32, u32, u32, u32)>, + additive: bool, ) { let logical_w = surface_width as f32 / self.scale_factor; let logical_h = surface_height as f32 / self.scale_factor; @@ -120,7 +159,11 @@ impl WgpuRenderer { if let Some((sx, sy, sw, sh)) = scissor { pass.set_scissor_rect(sx, sy, sw, sh); } - pass.set_pipeline(&self.pipelines.image); + pass.set_pipeline(if additive { + &self.pipelines.crossfade_add + } else { + &self.pipelines.composition + }); pass.set_bind_group(0, draw.binding(), &[]); pass.set_bind_group(1, snapshot_bind_group, &[]); pass.set_vertex_buffer(0, upload.buffer_slice()); @@ -139,9 +182,9 @@ impl WgpuRenderer { /// /// `alpha` scales the frame's whole picture — background, border, shadow /// and glyphs alike — toward transparent. The composition path passes the - /// interpolated value of a lifecycle animation, or 1.0 for a settled - /// frame; the multiply happens in the shared uniform, so no vertex - /// builder changes shape. + /// interpolated value of a lifecycle animation combined with legacy + /// frame opacity. Non-opaque pictures are isolated before composition, + /// so overlapping background fills never multiply opacity twice. #[allow(clippy::too_many_arguments)] pub fn render_child_frame( &mut self, @@ -164,12 +207,281 @@ impl WgpuRenderer { alpha: f32, scale: f32, pivot: [f32; 2], + ) -> Result<(), super::BudgetExceeded> { + let size = super::SnapshotSize::new(surface_width, surface_height) + .expect("child target dimensions are nonzero"); + let picture = if child.background_alpha != 1.0 || alpha != 1.0 { + Some(self.acquire_snapshot(size)?) + } else { + None + }; + self.render_child_frame_prepared( + view, + child, + offset_x, + offset_y, + clip_in_root, + glyph_atlas, + surface_width, + surface_height, + cursor_visible, + animated_cursor, + corner_radius, + shadow_enabled, + shadow_layers, + shadow_offset, + shadow_opacity, + pointer_selection, + alpha, + scale, + pivot, + picture.as_ref(), + None, + ); + Ok(()) + } + + /// Draw using a mandatory composition lease reserved before acquisition + /// and presentation sampling. All children in one pass may reuse it. + #[allow(clippy::too_many_arguments)] + pub fn render_child_frame_prepared( + &mut self, + view: &wgpu::TextureView, + child: &FrameGlyphBuffer, + offset_x: f32, + offset_y: f32, + clip_in_root: RootSurfaceRect, + glyph_atlas: &mut WgpuGlyphAtlas, + surface_width: u32, + surface_height: u32, + cursor_visible: bool, + animated_cursor: Option, + corner_radius: f32, + shadow_enabled: bool, + shadow_layers: u32, + shadow_offset: f32, + shadow_opacity: f32, + pointer_selection: Option, + alpha: f32, + scale: f32, + pivot: [f32; 2], + picture: Option<&super::SnapshotLease>, + resize: Option>, ) { let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; + if child.background_alpha != 1.0 || alpha != 1.0 || resize.is_some() { + let picture = picture.expect("non-opaque child composition was preflighted"); + self.clear_child_picture(picture.view()); + // Backgrounds replace one another inside the child's own picture; + // text is source-over coverage. Apply lifecycle opacity only once, + // to the completed premultiplied picture, not overlapping fills. + self.render_child_frame_picture( + picture.view(), + child, + offset_x, + offset_y, + clip_in_root, + glyph_atlas, + surface_width, + surface_height, + cursor_visible, + animated_cursor, + corner_radius, + shadow_enabled, + shadow_layers, + shadow_offset, + shadow_opacity, + pointer_selection, + 1.0, + scale, + pivot, + ); + let composed = if let Some(resize) = resize { + let mixed = resize.composition; + self.clear_child_picture(mixed.view()); + let scissor = self.child_frame_scissor(clip_in_root, surface_width, surface_height); + // Both pictures are already premultiplied. Sum their weighted + // RGBA, then source-over the complete result once below. + self.draw_child_picture_quad( + mixed.view(), + resize.old.bind_group(), + offset_x, + offset_y, + resize.old_width * scale, + resize.old_height * scale, + surface_width, + surface_height, + 1.0 - resize.mix, + scissor, + true, + ); + self.draw_child_picture_quad( + mixed.view(), + picture.bind_group(), + 0.0, + 0.0, + surface_width as f32 / self.scale_factor, + surface_height as f32 / self.scale_factor, + surface_width, + surface_height, + resize.mix, + None, + true, + ); + mixed + } else { + picture + }; + self.draw_child_crossfade_quad( + view, + composed.bind_group(), + 0.0, + 0.0, + surface_width as f32 / self.scale_factor, + surface_height as f32 / self.scale_factor, + surface_width, + surface_height, + alpha, + None, + ); + return; + } + self.render_child_frame_picture( + view, + child, + offset_x, + offset_y, + clip_in_root, + glyph_atlas, + surface_width, + surface_height, + cursor_visible, + animated_cursor, + corner_radius, + shadow_enabled, + shadow_layers, + shadow_offset, + shadow_opacity, + pointer_selection, + alpha, + scale, + pivot, + ); + } + + /// Capture the complete unscaled child picture in an already budgeted lease. + /// The caller includes shadow extent in its dimensions; opacity is not baked. + #[allow(clippy::too_many_arguments)] + pub fn capture_child_frame_picture( + &mut self, + picture: &super::SnapshotLease, + child: &FrameGlyphBuffer, + atlas: &mut WgpuGlyphAtlas, + corner_radius: f32, + shadow_enabled: bool, + shadow_layers: u32, + shadow_offset: f32, + shadow_opacity: f32, + ) { + self.clear_child_picture(picture.view()); + atlas.set_current_frame_fonts(child.font_bindings()); + let w = picture.size().width(); + let h = picture.size().height(); + // Only the target-local clip attachment changes. Do not resize or + // reconfigure an installed native surface to the capture dimensions. + let previous_size = ( + self.stencil.texture.view().texture().width(), + self.stencil.texture.view().texture().height(), + ); + if previous_size != (w, h) { + self.install_stencil_targets(w, h); + } + self.render_child_frame_picture( + picture.view(), + child, + 0.0, + 0.0, + RootSurfaceRect::new( + 0.0, + 0.0, + w as f32 / self.scale_factor, + h as f32 / self.scale_factor, + ) + .unwrap(), + atlas, + w, + h, + false, + None, + corner_radius, + shadow_enabled, + shadow_layers, + shadow_offset, + shadow_opacity, + None, + 1.0, + 1.0, + [0.0; 2], + ); + if previous_size != (w, h) { + self.install_stencil_targets(previous_size.0, previous_size.1); + } + } + + fn clear_child_picture(&self, view: &wgpu::TextureView) { + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Child Picture Clear"), + }); + { + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Child Picture Clear"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + } + self.queue.submit([encoder.finish()]); + } + + #[allow(clippy::too_many_arguments)] + fn render_child_frame_picture( + &mut self, + view: &wgpu::TextureView, + child: &FrameGlyphBuffer, + offset_x: f32, + offset_y: f32, + clip_in_root: RootSurfaceRect, + glyph_atlas: &mut WgpuGlyphAtlas, + surface_width: u32, + surface_height: u32, + cursor_visible: bool, + animated_cursor: Option, + corner_radius: f32, + shadow_enabled: bool, + shadow_layers: u32, + shadow_offset: f32, + shadow_opacity: f32, + pointer_selection: Option, + alpha: f32, + scale: f32, + pivot: [f32; 2], + ) { // An identity scale normalizes to (1.0, origin) so the draw-parameters // cache key -- and therefore the immutable uniform snapshot -- is // byte-identical to the pre-scale pipeline for every settled frame. @@ -310,7 +622,7 @@ impl WgpuRenderer { .rounded .upload(&self.device, &self.queue, &bg_verts) { - pass.set_pipeline(&self.pipelines.rounded_rect); + pass.set_pipeline(&self.pipelines.rounded_fill); pass.set_bind_group(0, draw.binding(), &[]); pass.set_vertex_buffer(0, upload.buffer_slice()); pass.draw(0..bg_verts.len() as u32, 0..1); diff --git a/crates/neomacs-renderer-wgpu/src/renderer/content.rs b/crates/neomacs-renderer-wgpu/src/renderer/content.rs index c2ddf2fd69..1c1bf12cae 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/content.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/content.rs @@ -228,6 +228,7 @@ impl WgpuRenderer { // 5. Inline media (images, videos, webkit) // 6. Cursors, borders, scroll bars (on top) let mut bg_vertices: Vec = Vec::new(); + let mut stipple_vertices: Vec = Vec::new(); let mut cursor_bg_vertices: Vec = Vec::new(); let mut cursor_vertices: Vec = Vec::new(); let mut cursor_inverse_video = None; @@ -298,7 +299,7 @@ impl WgpuRenderer { { let fg = frame.resolved_face(face_id).fg; self.add_stipple_paint( - &mut bg_vertices, + &mut stipple_vertices, &fg, pat, paint, @@ -362,7 +363,7 @@ impl WgpuRenderer { { let fg = frame.resolved_face(face_id).fg; self.add_stipple_paint( - &mut bg_vertices, + &mut stipple_vertices, &fg, pat, paint, @@ -613,7 +614,7 @@ impl WgpuRenderer { let mut mask_data: Vec<(AnyAtlasEntry, [CoverageGlyphVertex; 6])> = Vec::new(); let mut subpixel_data: Vec<(AnyAtlasEntry, [CoverageGlyphVertex; 6])> = Vec::new(); let mut color_data: Vec<(AnyAtlasEntry, [GlyphVertex; 6])> = Vec::new(); - let enable_subpixel = glyph_atlas.subpixel_enabled(); + let enable_subpixel = glyph_atlas.subpixel_enabled() && frame.background_alpha == 1.0; let mut text_face_cache: Option<(FaceId, MaterializedFaceData)> = None; for (glyph_index, glyph) in frame.glyphs.iter().enumerate() { @@ -1223,7 +1224,33 @@ impl WgpuRenderer { } else { &self.pipelines.rounded_rect }; - let glyph_pl = if use_stencil { + let background_pl = match (use_stencil, frame.background_alpha == 1.0) { + (true, false) => &self.pipelines.stencil_background_rect, + (false, false) => &self.pipelines.background_rect, + _ => rect_pl, + }; + let background_rounded_pl = match (use_stencil, frame.background_alpha == 1.0) { + (true, false) => &self.pipelines.stencil_background_rounded_rect, + (false, false) => &self.pipelines.background_rounded_rect, + _ => rounded_rect_pl, + }; + for vertex in &mut bg_vertices { + for channel in &mut vertex.color[..3] { + *channel *= frame.background_alpha; + } + } + for vertex in &mut rounded_fill_vertices { + for channel in &mut vertex.color[..3] { + *channel *= frame.background_alpha; + } + } + let glyph_pl = if frame.background_alpha != 1.0 { + if use_stencil { + &self.pipelines.stencil_transparent_glyph + } else { + &self.pipelines.transparent_glyph + } + } else if use_stencil { &self.pipelines.stencil_grayscale_glyph } else { &self.pipelines.grayscale_glyph @@ -1293,18 +1320,37 @@ impl WgpuRenderer { pass.set_stencil_reference(1); } + pass.set_blend_constant(wgpu::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: frame.background_alpha as f64, + }); // --- Draw backgrounds --- if let Some(upload) = self .arenas .rect .upload(&self.device, &self.queue, &bg_vertices) { - pass.set_pipeline(rect_pl); + pass.set_pipeline(background_pl); pass.set_bind_group(0, draw.binding(), &[]); pass.set_vertex_buffer(0, upload.buffer_slice()); pass.draw(0..bg_vertices.len() as u32, 0..1); } + // Stipple 1-bits are foreground, with the same stencil and + // pre-text draw order as root stipples, unaffected by background alpha. + if let Some(upload) = + self.arenas + .rect + .upload(&self.device, &self.queue, &stipple_vertices) + { + pass.set_pipeline(rect_pl); + pass.set_bind_group(0, draw.binding(), &[]); + pass.set_vertex_buffer(0, upload.buffer_slice()); + pass.draw(0..stipple_vertices.len() as u32, 0..1); + } + // --- Draw fringe bitmaps (own column, above backgrounds, below // text — fringes never overlap the text area). --- if let Some(upload) = @@ -1337,7 +1383,7 @@ impl WgpuRenderer { .rounded .upload(&self.device, &self.queue, &rounded_fill_vertices) { - pass.set_pipeline(rounded_rect_pl); + pass.set_pipeline(background_rounded_pl); pass.set_bind_group(0, draw.binding(), &[]); pass.set_vertex_buffer(0, upload.buffer_slice()); pass.draw(0..rounded_fill_vertices.len() as u32, 0..1); diff --git a/crates/neomacs-renderer-wgpu/src/renderer/draw/mod.rs b/crates/neomacs-renderer-wgpu/src/renderer/draw/mod.rs index e70131e619..f34525d15b 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/draw/mod.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/draw/mod.rs @@ -20,7 +20,26 @@ impl WgpuRenderer { placement: super::NativeContentPlacement<'_>, background: neomacs_display_protocol::Color, ) { - let draw = self.begin_draw(placement.target); + self.place_native_content_with_opacity(placement, background, 1.0); + } + + /// Whole-frame opacity is applied once, after composition and postprocessing. + pub fn place_native_content_with_opacity( + &mut self, + placement: super::NativeContentPlacement<'_>, + mut background: neomacs_display_protocol::Color, + opacity: f32, + ) { + background.a *= opacity; + let mut draw = self.begin_draw(placement.target); + let size = draw.target.surface.logical_size(); + draw.parameters = draw.renderer.parameters_for( + [size.width(), size.height()], + 0.0, + opacity, + 1.0, + [0.0; 2], + ); draw.renderer.paint_blit( draw.target, &draw.parameters, diff --git a/crates/neomacs-renderer-wgpu/src/renderer/glyphs.rs b/crates/neomacs-renderer-wgpu/src/renderer/glyphs.rs index a7059bc4ce..5a3c3576b7 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/glyphs.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/glyphs.rs @@ -1637,7 +1637,8 @@ impl WgpuRenderer { }; let box_spans = self.collect_box_spans(¶ms); - let non_overlay_rect_vertices = self.collect_non_overlay_backgrounds(¶ms, &box_spans); + let (non_overlay_rect_vertices, foreground_rect_vertices) = + self.collect_non_overlay_backgrounds(¶ms, &box_spans); let overlay_rect_vertices = self.collect_overlay_backgrounds(¶ms, &box_spans); let chrome = self.collect_chrome_layers(¶ms); @@ -1676,7 +1677,8 @@ impl WgpuRenderer { // Render pass - Clear with frame background color since we rebuild // the entire frame from current_matrix each time (no incremental updates). - let bg = &frame_glyphs.background; + let mut bg = frame_glyphs.background; + bg.a *= frame_glyphs.background_alpha; let load = if load_existing { // Preserve already-composited content (the retained static scene) // and only overwrite within the scissor rect below. @@ -1721,7 +1723,13 @@ impl WgpuRenderer { }; // === Step 1: non-overlay backgrounds === - self.draw_non_overlay_backgrounds(&mut ctx.pass, &non_overlay_rect_vertices); + self.draw_non_overlay_backgrounds( + &mut ctx.pass, + &non_overlay_rect_vertices, + frame_glyphs.background_alpha, + ); + + self.draw_rect_vertex_layer(&mut ctx.pass, &foreground_rect_vertices); // Build shared effect context for all effect functions. // Clone effect config into a local so we can mutably borrow `self` diff --git a/crates/neomacs-renderer-wgpu/src/renderer/layer_backgrounds.rs b/crates/neomacs-renderer-wgpu/src/renderer/layer_backgrounds.rs index 8d75bbb14c..120d52800a 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/layer_backgrounds.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/layer_backgrounds.rs @@ -32,7 +32,7 @@ impl WgpuRenderer { &self, params: &FrameParams<'_>, spans: &BoxSpanSet, - ) -> Vec { + ) -> (Vec, Vec) { let frame_glyphs = params.frame_glyphs; let faces = params.faces; let box_spans = &spans.spans; @@ -42,6 +42,7 @@ impl WgpuRenderer { let logical_h = params.logical_h; // --- Collect non-overlay backgrounds --- let mut non_overlay_rect_vertices: Vec = Vec::new(); + let mut foreground_rect_vertices: Vec = Vec::new(); // Background gradient (rendered behind everything) if let Some((top, bottom)) = background_gradient { @@ -155,7 +156,7 @@ impl WgpuRenderer { if let Some(pat) = face.and_then(|f| f.stipple.as_deref()) { let rf = frame_glyphs.resolved_face(face_id); self.add_stipple_paint( - &mut non_overlay_rect_vertices, + &mut foreground_rect_vertices, &rf.fg, pat, paint, @@ -243,7 +244,7 @@ impl WgpuRenderer { // `highlight-indent-guides` draw their vertical bars. if let Some(pat) = face.and_then(|f| f.stipple.as_deref()) { self.add_stipple_paint( - &mut non_overlay_rect_vertices, + &mut foreground_rect_vertices, &rf.fg, pat, paint, @@ -329,7 +330,7 @@ impl WgpuRenderer { ); if let Some(pat) = face.and_then(|f| f.stipple.as_deref()) { self.add_stipple_paint( - &mut non_overlay_rect_vertices, + &mut foreground_rect_vertices, &rf.fg, pat, paint, @@ -360,7 +361,7 @@ impl WgpuRenderer { }; let face = frame_glyphs.resolved_face(*face_id); self.render_fringe_bitmap( - &mut non_overlay_rect_vertices, + &mut foreground_rect_vertices, *x, *y, *width, @@ -483,7 +484,7 @@ impl WgpuRenderer { guide_color }; self.add_rect( - &mut non_overlay_rect_vertices, + &mut foreground_rect_vertices, col_x, row.y, guide_width, @@ -522,7 +523,7 @@ impl WgpuRenderer { let dot_x = *x + (*width - dot_size) / 2.0; let dot_y = *y + (*ascent - dot_size / 2.0); self.add_rect( - &mut non_overlay_rect_vertices, + &mut foreground_rect_vertices, dot_x, dot_y, dot_size, @@ -537,7 +538,7 @@ impl WgpuRenderer { let arrow_x = *x + 2.0; // Shaft self.add_rect( - &mut non_overlay_rect_vertices, + &mut foreground_rect_vertices, arrow_x, arrow_y, arrow_w, @@ -547,7 +548,7 @@ impl WgpuRenderer { // Arrowhead (small triangle approximated as 2 rects) let tip_x = arrow_x + arrow_w; self.add_rect( - &mut non_overlay_rect_vertices, + &mut foreground_rect_vertices, tip_x - 3.0, arrow_y - 1.5, 3.0, @@ -559,7 +560,7 @@ impl WgpuRenderer { } } - non_overlay_rect_vertices + (non_overlay_rect_vertices, foreground_rect_vertices) } /// Collect the overlay (mode-line/echo area) background layer. @@ -703,9 +704,75 @@ impl WgpuRenderer { &mut self, render_pass: &mut wgpu::RenderPass<'_>, non_overlay_rect_vertices: &[RectVertex], + opacity: f32, ) { - // === Step 1: Draw non-overlay backgrounds === - self.draw_rect_vertex_layer(render_pass, non_overlay_rect_vertices); + self.draw_background_rects(render_pass, non_overlay_rect_vertices, opacity); + } + + pub(super) fn draw_background_rects( + &mut self, + pass: &mut wgpu::RenderPass<'_>, + vertices: &[RectVertex], + opacity: f32, + ) { + if opacity == 1.0 { + self.draw_rect_vertex_layer(pass, vertices); + return; + } + let mut vertices = vertices.to_vec(); + for vertex in &mut vertices { + for channel in &mut vertex.color[..3] { + *channel *= opacity; + } + } + if let Some(upload) = self + .arenas + .rect + .upload(&self.device, &self.queue, &vertices) + { + pass.set_blend_constant(wgpu::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: opacity as f64, + }); + pass.set_pipeline(&self.pipelines.background_rect); + pass.set_vertex_buffer(0, upload.buffer_slice()); + pass.draw(0..vertices.len() as u32, 0..1); + } + } + + pub(super) fn draw_background_rounded_rects( + &mut self, + pass: &mut wgpu::RenderPass<'_>, + vertices: &[RoundedRectVertex], + opacity: f32, + ) { + let mut vertices = vertices.to_vec(); + for vertex in &mut vertices { + for channel in &mut vertex.color[..3] { + *channel *= opacity; + } + } + if let Some(upload) = self + .arenas + .rounded + .upload(&self.device, &self.queue, &vertices) + { + pass.set_blend_constant(wgpu::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: opacity as f64, + }); + pass.set_pipeline(if opacity == 1.0 { + &self.pipelines.rounded_rect + } else { + &self.pipelines.background_rounded_rect + }); + pass.set_vertex_buffer(0, upload.buffer_slice()); + pass.draw(0..vertices.len() as u32, 0..1); + } } /// Draw the overlay background rects and rounded box fills. Runs at the @@ -719,15 +786,8 @@ impl WgpuRenderer { let render_pass = &mut ctx.pass; let faces = ctx.params.faces; let box_spans = &spans.spans; - if let Some(upload) = - self.arenas - .rect - .upload(&self.device, &self.queue, overlay_rect_vertices) - { - render_pass.set_pipeline(&self.pipelines.rect); - render_pass.set_vertex_buffer(0, upload.buffer_slice()); - render_pass.draw(0..overlay_rect_vertices.len() as u32, 0..1); - } + let opacity = ctx.params.frame_glyphs.background_alpha; + self.draw_background_rects(render_pass, overlay_rect_vertices, opacity); // Image textures leave their GNU margin/row-sized box slot uncovered. let mut required_box_fill: Vec = Vec::new(); @@ -741,7 +801,7 @@ impl WgpuRenderer { ); } } - self.draw_rect_vertex_layer(render_pass, &required_box_fill); + self.draw_background_rects(render_pass, &required_box_fill, opacity); // Draw filled rounded rect backgrounds for overlay ROUNDED boxed spans. { @@ -760,15 +820,7 @@ impl WgpuRenderer { ); } } - if let Some(upload) = - self.arenas - .rounded - .upload(&self.device, &self.queue, &overlay_box_fill) - { - render_pass.set_pipeline(&self.pipelines.rounded_rect); - render_pass.set_vertex_buffer(0, upload.buffer_slice()); - render_pass.draw(0..overlay_box_fill.len() as u32, 0..1); - } + self.draw_background_rounded_rects(render_pass, &overlay_box_fill, opacity); } } } diff --git a/crates/neomacs-renderer-wgpu/src/renderer/layer_effects.rs b/crates/neomacs-renderer-wgpu/src/renderer/layer_effects.rs index ec66e3e7ce..c45875b7b0 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/layer_effects.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/layer_effects.rs @@ -100,16 +100,9 @@ impl WgpuRenderer { self.append_rounded_box_fill_geometry(&mut box_fill_vertices, span, face, 0.0, 0.0); } } - self.draw_rect_vertex_layer(render_pass, &required_box_fill_vertices); - if let Some(upload) = - self.arenas - .rounded - .upload(&self.device, &self.queue, &box_fill_vertices) - { - render_pass.set_pipeline(&self.pipelines.rounded_rect); - render_pass.set_vertex_buffer(0, upload.buffer_slice()); - render_pass.draw(0..box_fill_vertices.len() as u32, 0..1); - } + let opacity = ctx.frame_glyphs.background_alpha; + self.draw_background_rects(render_pass, &required_box_fill_vertices, opacity); + self.draw_background_rounded_rects(render_pass, &box_fill_vertices, opacity); } /// Draw the pre-content effect stacks: the core window/cursor effects, diff --git a/crates/neomacs-renderer-wgpu/src/renderer/layer_text.rs b/crates/neomacs-renderer-wgpu/src/renderer/layer_text.rs index fe4c95ea08..d62a2a9cbd 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/layer_text.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/layer_text.rs @@ -121,7 +121,8 @@ impl WgpuRenderer { .map(|cursor| (cursor.window_id.get(), cursor.slot_id.row)); // These effects mutate vertex colors/positions per frame; cached rows // would bake stale values in, so reuse and capture shut off entirely. - let global_effects_active = params.has_line_anims + let global_effects_active = frame_glyphs.background_alpha != 1.0 + || params.has_line_anims || !self.fx.text_fade.active.is_empty() || !self.fx.mode_line_fade.active.is_empty(); let pointer_invalidated_rows = chunks @@ -133,7 +134,8 @@ impl WgpuRenderer { }) .map(|chunk| chunk.key) .collect::>(); - let enable_subpixel = glyph_atlas.subpixel_enabled(); + let enable_subpixel = + glyph_atlas.subpixel_enabled() && frame_glyphs.background_alpha == 1.0; let ctx = row_reuse::ReusePassCtx { damage: params.row_damage, scale_bits: self.scale_factor.to_bits(), @@ -741,7 +743,11 @@ impl WgpuRenderer { // Draw grayscale coverage with the same foreground/background contract as LCD masks. // Batch consecutive glyphs sharing the same atlas page. if !mask_data.is_empty() { - render_pass.set_pipeline(&self.pipelines.grayscale_glyph); + render_pass.set_pipeline(if ctx.params.frame_glyphs.background_alpha == 1.0 { + &self.pipelines.grayscale_glyph + } else { + &self.pipelines.transparent_glyph + }); let all_vertices: Vec = mask_data .iter() diff --git a/crates/neomacs-renderer-wgpu/src/renderer/layout_pass.rs b/crates/neomacs-renderer-wgpu/src/renderer/layout_pass.rs index b43cc3902c..da0808c04c 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/layout_pass.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/layout_pass.rs @@ -57,7 +57,7 @@ pub struct PaneBlit { /// Where in its source picture its content lives. pub content_origin: (f32, f32), pub source: PaneSource, - /// How opaque to draw it, for a pane entering or leaving. + /// The source picture's interpolation weight for a pane entering or leaving. pub opacity: f32, } @@ -82,9 +82,8 @@ impl WgpuRenderer { let corner = |x: f32, y: f32, u: f32, v: f32, a: f32| GlyphVertex { position: [x, y], tex_coords: [u, v], - // Premultiplied by the pipeline's blend, so the alpha alone carries - // a departing pane's fade; there is no separate opacity uniform to - // keep in step with the geometry. + // The copy shader weights premultiplied RGB and alpha together. + // The patch blend applies the complementary destination weight. color: [1.0, 1.0, 1.0, a], }; @@ -189,16 +188,42 @@ impl WgpuRenderer { occlusion_query_set: None, multiview_mask: None, }); - pass.set_pipeline(&self.pipelines.image); + pass.set_pipeline(&self.pipelines.surface_copy); pass.set_bind_group(0, self.frame_parameters().binding(), &[]); pass.set_bind_group(1, source, &[]); pass.set_vertex_buffer(0, upload.buffer_slice()); - pass.draw(0..destination_vertices, 0..1); + pass.draw(0..6, 0..1); + // Entering destination patches also interpolate with the backdrop, + // rather than replacing it with a faded (less opaque) picture. + pass.set_pipeline(&self.pipelines.picture_interpolate); + for start in (6..destination_vertices).step_by(6) { + let weight = vertices[start as usize].color[3] as f64; + pass.set_blend_constant(wgpu::Color { + r: weight, + g: weight, + b: weight, + a: weight, + }); + pass.draw(start..start + 6, 0..1); + } if destination_vertices < vertices.len() as u32 && let Some(previous) = previous { + pass.set_pipeline(&self.pipelines.picture_interpolate); pass.set_bind_group(1, previous, &[]); - pass.draw(destination_vertices..vertices.len() as u32, 0..1); + // Each patch can have a different old-picture weight (held + // strips are 1, reflow patches fade). Interpolate RGBA without + // treating two alternative backgrounds as source-over layers. + for start in (destination_vertices..vertices.len() as u32).step_by(6) { + let weight = vertices[start as usize].color[3] as f64; + pass.set_blend_constant(wgpu::Color { + r: weight, + g: weight, + b: weight, + a: weight, + }); + pass.draw(start..start + 6, 0..1); + } } } self.queue.submit(std::iter::once(encoder.finish())); diff --git a/crates/neomacs-renderer-wgpu/src/renderer/layout_pass/tests/mod.rs b/crates/neomacs-renderer-wgpu/src/renderer/layout_pass/tests/mod.rs index af59e0fbae..d465fd40fa 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/layout_pass/tests/mod.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/layout_pass/tests/mod.rs @@ -185,10 +185,10 @@ fn the_base_is_the_destination_and_the_motion_rides_on_top_of_it() { the fading group are then the same picture and nothing appears to move" ); let destination_draw = source - .find("pass.draw(0..destination_vertices, 0..1);") + .find("pass.draw(0..6, 0..1);") .expect("the destination draw, carrying the base, is still first"); let previous_draw = source - .find("pass.draw(destination_vertices..vertices.len() as u32, 0..1);") + .rfind("pass.draw(start..start + 6, 0..1);") .expect("the previous-sourced draw still exists"); assert!( destination_draw < previous_draw, @@ -222,10 +222,10 @@ fn a_departing_pane_is_drawn_over_the_panes_that_replace_it() { // covered by it on the first frame and never seen. let source = include_str!("../../layout_pass.rs"); let destination_draw = source - .find("pass.draw(0..destination_vertices, 0..1);") + .find("pass.draw(0..6, 0..1);") .expect("the destination draw is still first"); let previous_draw = source - .find("pass.draw(destination_vertices..vertices.len() as u32, 0..1);") + .rfind("pass.draw(start..start + 6, 0..1);") .expect("the departing draw still exists"); assert!(destination_draw < previous_draw); } diff --git a/crates/neomacs-renderer-wgpu/src/renderer/mod.rs b/crates/neomacs-renderer-wgpu/src/renderer/mod.rs index d9d9c40965..2c622d39e0 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/mod.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/mod.rs @@ -18,6 +18,7 @@ use super::webview_cache::WgpuWebViewCache; mod box_tessellation; mod child_frames; +pub use child_frames::ChildResizePicture; mod composition_ring; pub use composition_ring::CompositionRing; mod content; @@ -158,6 +159,28 @@ fn create_bi_planar_video_copy_pipeline( }) } +/// Background fills replace alpha rather than accumulating it over the clear +/// and over one another. Constant alpha supplies the frame opacity; source +/// alpha still carries geometry coverage (including rounded corners). +fn background_blend(premultiplied: bool) -> wgpu::BlendState { + wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: if premultiplied { + wgpu::BlendFactor::One + } else { + wgpu::BlendFactor::SrcAlpha + }, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Constant, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + } +} + /// GPU-accelerated renderer using wgpu. pub struct WgpuRenderer { pub(crate) device: Arc, @@ -348,43 +371,48 @@ impl WgpuRenderer { let target_format = surface_format.unwrap_or(wgpu::TextureFormat::Bgra8UnormSrgb); // Create rect pipeline - let rect_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Rect Pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &rect_shader, - entry_point: Some("vs_main"), - buffers: &[Some(RectVertex::desc())], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &rect_shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: target_format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - cache: None, - multiview_mask: None, - }); + let make_rect_pipeline = |label, blend, depth_stencil| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &rect_shader, + entry_point: Some("vs_main"), + buffers: &[Some(RectVertex::desc())], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &rect_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: target_format, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + cache: None, + multiview_mask: None, + }) + }; + let rect_pipeline = make_rect_pipeline("rect", wgpu::BlendState::ALPHA_BLENDING, None); + let background_rect_pipeline = + make_rect_pipeline("background_rect", background_blend(false), None); // Load rounded rect shader (SDF-based rounded borders) let rounded_rect_shader_source = include_str!("../shaders/rounded_rect.wgsl"); @@ -393,9 +421,9 @@ impl WgpuRenderer { source: wgpu::ShaderSource::Wgsl(rounded_rect_shader_source.into()), }); - let rounded_rect_pipeline = + let make_rounded_pipeline = |label, blend, depth_stencil| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Rounded Rect Pipeline"), + label: Some(label), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &rounded_rect_shader, @@ -408,7 +436,7 @@ impl WgpuRenderer { entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { format: target_format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), + blend: Some(blend), write_mask: wgpu::ColorWrites::ALL, })], compilation_options: Default::default(), @@ -422,7 +450,7 @@ impl WgpuRenderer { unclipped_depth: false, conservative: false, }, - depth_stencil: None, + depth_stencil, multisample: wgpu::MultisampleState { count: 1, mask: !0, @@ -430,7 +458,18 @@ impl WgpuRenderer { }, cache: None, multiview_mask: None, - }); + }) + }; + let rounded_rect_pipeline = + make_rounded_pipeline("rounded_rect", wgpu::BlendState::ALPHA_BLENDING, None); + // SDF fills emit premultiplied RGB; leave border blending unchanged. + let rounded_fill_pipeline = make_rounded_pipeline( + "rounded_fill", + wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, + None, + ); + let background_rounded_rect_pipeline = + make_rounded_pipeline("background_rounded_rect", background_blend(true), None); // Corner mask pipeline: uses the same SDF rounded rect shader but with // a blend mode that multiplies the destination by the source alpha. @@ -568,48 +607,55 @@ impl WgpuRenderer { multiview_mask: None, }); - let coverage_pipeline = - |mask: GlyphCoverageMask, stencil: Option| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Coverage Glyph Pipeline"), - layout: Some(&glyph_pipeline_layout), - vertex: wgpu::VertexState { - module: &coverage_glyph_shader, - entry_point: Some("vs_main"), - buffers: &[Some(CoverageGlyphVertex::desc())], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &coverage_glyph_shader, - entry_point: Some(mask.fragment_entry_point()), - targets: &[Some(wgpu::ColorTargetState { - format: target_format, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), + let coverage_pipeline = |mask: GlyphCoverageMask, + stencil: Option, + transparent: bool| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Coverage Glyph Pipeline"), + layout: Some(&glyph_pipeline_layout), + vertex: wgpu::VertexState { + module: &coverage_glyph_shader, + entry_point: Some("vs_main"), + buffers: &[Some(CoverageGlyphVertex::desc())], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &coverage_glyph_shader, + entry_point: Some(if transparent { + "fs_transparent" + } else { + mask.fragment_entry_point() }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: stencil, - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - cache: None, - multiview_mask: None, - }) - }; - let grayscale_glyph_pipeline = coverage_pipeline(GlyphCoverageMask::Grayscale, None); - let subpixel_glyph_pipeline = coverage_pipeline(GlyphCoverageMask::Subpixel, None); + targets: &[Some(wgpu::ColorTargetState { + format: target_format, + blend: transparent.then_some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: stencil, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + cache: None, + multiview_mask: None, + }) + }; + let grayscale_glyph_pipeline = coverage_pipeline(GlyphCoverageMask::Grayscale, None, false); + let transparent_glyph_pipeline = + coverage_pipeline(GlyphCoverageMask::Grayscale, None, true); + let subpixel_glyph_pipeline = coverage_pipeline(GlyphCoverageMask::Subpixel, None, false); // Create image cache (also creates its bind group layout) let image_cache = ImageCache::new(&device); @@ -655,7 +701,7 @@ impl WgpuRenderer { }); // Create image pipeline (similar to glyph but for RGBA textures) - let make_image_pipeline = |blend| { + let make_image_pipeline = |blend, entry_point| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Image Pipeline"), layout: Some(&image_pipeline_layout), @@ -667,7 +713,7 @@ impl WgpuRenderer { }, fragment: Some(wgpu::FragmentState { module: &image_shader, - entry_point: Some("fs_main"), + entry_point: Some(entry_point), targets: &[Some(wgpu::ColorTargetState { format: target_format, blend, @@ -695,8 +741,51 @@ impl WgpuRenderer { }) }; - let image_pipeline = make_image_pipeline(Some(wgpu::BlendState::ALPHA_BLENDING)); - let surface_copy_pipeline = make_image_pipeline(None); + let image_pipeline = make_image_pipeline(Some(wgpu::BlendState::ALPHA_BLENDING), "fs_main"); + let surface_copy_pipeline = make_image_pipeline(None, "fs_copy"); + let native_copy_pipeline = make_image_pipeline( + None, + if target_format.is_srgb() { + "fs_native" + } else { + "fs_copy" + }, + ); + + let composition_pipeline = make_image_pipeline( + Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + "fs_copy", + ); + // Crossfades are a weighted sum of two complete premultiplied pictures, + // not source-over layers (which would change their background opacity). + let additive_component = wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }; + let crossfade_add_pipeline = make_image_pipeline( + Some(wgpu::BlendState { + color: additive_component, + alpha: additive_component, + }), + "fs_copy", + ); + + // A pane patch replaces a fraction of the already placed destination + // with the same fraction of its old picture. The destination weight + // comes from the pane's fade, never from the old texture's pixel alpha. + let interpolate_component = wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusConstant, + operation: wgpu::BlendOperation::Add, + }; + let picture_interpolate_pipeline = make_image_pipeline( + Some(wgpu::BlendState { + color: interpolate_component, + alpha: interpolate_component, + }), + "fs_copy", + ); #[cfg(feature = "video")] let bi_planar_video_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -795,94 +884,45 @@ impl WgpuRenderer { bias: wgpu::DepthBiasState::default(), }; + let stencil_background_rect_pipeline = make_rect_pipeline( + "Stencil background_rect", + background_blend(false), + Some(stencil_read_state.clone()), + ); + let stencil_background_rounded_rect_pipeline = make_rounded_pipeline( + "Stencil background_rounded_rect", + background_blend(true), + Some(stencil_read_state.clone()), + ); + let stencil_transparent_glyph_pipeline = coverage_pipeline( + GlyphCoverageMask::Grayscale, + Some(stencil_read_state.clone()), + true, + ); // Stencil-read rect pipeline - let stencil_rect_pipeline = - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Stencil Rect Pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &rect_shader, - entry_point: Some("vs_main"), - buffers: &[Some(RectVertex::desc())], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &rect_shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: target_format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: Some(stencil_read_state.clone()), - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - cache: None, - multiview_mask: None, - }); + let stencil_rect_pipeline = make_rect_pipeline( + "Stencil rect", + wgpu::BlendState::ALPHA_BLENDING, + Some(stencil_read_state.clone()), + ); // Stencil-read rounded rect pipeline - let stencil_rounded_rect_pipeline = - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Stencil Rounded Rect Pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &rounded_rect_shader, - entry_point: Some("vs_main"), - buffers: &[Some(RoundedRectVertex::desc())], - compilation_options: Default::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &rounded_rect_shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format: target_format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: Some(stencil_read_state.clone()), - multisample: wgpu::MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - cache: None, - multiview_mask: None, - }); + let stencil_rounded_rect_pipeline = make_rounded_pipeline( + "Stencil rounded_rect", + wgpu::BlendState::ALPHA_BLENDING, + Some(stencil_read_state.clone()), + ); // Stencil-read glyph pipeline let stencil_grayscale_glyph_pipeline = coverage_pipeline( GlyphCoverageMask::Grayscale, Some(stencil_read_state.clone()), + false, ); let stencil_subpixel_glyph_pipeline = coverage_pipeline( GlyphCoverageMask::Subpixel, Some(stencil_read_state.clone()), + false, ); let stencil_image_pipeline = @@ -1063,18 +1103,29 @@ impl WgpuRenderer { surface_format: target_format, pipelines: Pipelines { rect: rect_pipeline, + background_rect: background_rect_pipeline, + background_rounded_rect: background_rounded_rect_pipeline, rounded_rect: rounded_rect_pipeline, + rounded_fill: rounded_fill_pipeline, corner_mask: corner_mask_pipeline, glyph: glyph_pipeline, grayscale_glyph: grayscale_glyph_pipeline, + transparent_glyph: transparent_glyph_pipeline, subpixel_glyph: subpixel_glyph_pipeline, image: image_pipeline, surface_copy: surface_copy_pipeline, + native_copy: native_copy_pipeline, + composition: composition_pipeline, + crossfade_add: crossfade_add_pipeline, + picture_interpolate: picture_interpolate_pipeline, #[cfg(feature = "video")] bi_planar_video: bi_planar_video_pipeline, #[cfg(feature = "video")] bi_planar_video_copy: bi_planar_video_copy_pipeline, opaque_image: opaque_image_pipeline, + stencil_background_rect: stencil_background_rect_pipeline, + stencil_background_rounded_rect: stencil_background_rounded_rect_pipeline, + stencil_transparent_glyph: stencil_transparent_glyph_pipeline, stencil_rect: stencil_rect_pipeline, stencil_rounded_rect: stencil_rounded_rect_pipeline, stencil_subpixel_glyph: stencil_subpixel_glyph_pipeline, diff --git a/crates/neomacs-renderer-wgpu/src/renderer/paint/blit.rs b/crates/neomacs-renderer-wgpu/src/renderer/paint/blit.rs index 5fa617de4c..09a0aa1c65 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/paint/blit.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/paint/blit.rs @@ -35,7 +35,7 @@ impl WgpuRenderer { size.width(), size.height(), wgpu::Color::TRANSPARENT, - &self.pipelines.image, + &self.pipelines.surface_copy, ), BlitPlacement::Region { destination, .. } => ( destination.x, @@ -52,18 +52,37 @@ impl WgpuRenderer { target.surface.device_width().get(), target.surface.device_height().get(), ); + // Native sRGB content stores encoded-premultiplied RGB. + // Clears are supplied in linear space before attachment encoding, + // so perform the same encode -> multiply -> decode as fs_native. + let straight = if self.surface_format.is_srgb() { + bg.linear_to_srgb() + } else { + bg + }; + let premultiplied = neomacs_display_protocol::Color::new( + straight.r * bg.a, + straight.g * bg.a, + straight.b * bg.a, + bg.a, + ); + let clear = if self.surface_format.is_srgb() { + premultiplied.srgb_to_linear() + } else { + premultiplied + }; ( insets.left as f32 / scale, insets.top as f32 / scale, w as f32 / scale, h as f32 / scale, wgpu::Color { - r: (bg.r * bg.a) as f64, - g: (bg.g * bg.a) as f64, - b: (bg.b * bg.a) as f64, - a: bg.a as f64, + r: clear.r as f64, + g: clear.g as f64, + b: clear.b as f64, + a: clear.a as f64, }, - &self.pipelines.surface_copy, + &self.pipelines.native_copy, ) } }; diff --git a/crates/neomacs-renderer-wgpu/src/renderer/resources.rs b/crates/neomacs-renderer-wgpu/src/renderer/resources.rs index 2e7f4cdff0..b1b3bb3a21 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/resources.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/resources.rs @@ -18,18 +18,29 @@ use crate::vertex::{CoverageGlyphVertex, GlyphVertex, RectVertex, RoundedRectVer /// buffer was written (child frame rounded-corner clipping). pub(crate) struct Pipelines { pub(crate) rect: wgpu::RenderPipeline, + pub(crate) background_rect: wgpu::RenderPipeline, + pub(crate) background_rounded_rect: wgpu::RenderPipeline, + pub(crate) transparent_glyph: wgpu::RenderPipeline, pub(crate) rounded_rect: wgpu::RenderPipeline, + pub(crate) rounded_fill: wgpu::RenderPipeline, pub(crate) corner_mask: wgpu::RenderPipeline, pub(crate) glyph: wgpu::RenderPipeline, pub(crate) grayscale_glyph: wgpu::RenderPipeline, pub(crate) subpixel_glyph: wgpu::RenderPipeline, pub(crate) image: wgpu::RenderPipeline, - pub(crate) surface_copy: wgpu::RenderPipeline, + pub(super) surface_copy: wgpu::RenderPipeline, + pub(super) native_copy: wgpu::RenderPipeline, + pub(crate) composition: wgpu::RenderPipeline, + pub(crate) crossfade_add: wgpu::RenderPipeline, + pub(super) picture_interpolate: wgpu::RenderPipeline, #[cfg(feature = "video")] pub(crate) bi_planar_video: wgpu::RenderPipeline, #[cfg(feature = "video")] pub(crate) bi_planar_video_copy: wgpu::RenderPipeline, pub(crate) opaque_image: wgpu::RenderPipeline, + pub(crate) stencil_background_rect: wgpu::RenderPipeline, + pub(crate) stencil_background_rounded_rect: wgpu::RenderPipeline, + pub(crate) stencil_transparent_glyph: wgpu::RenderPipeline, pub(crate) stencil_rect: wgpu::RenderPipeline, pub(crate) stencil_rounded_rect: wgpu::RenderPipeline, pub(crate) stencil_grayscale_glyph: wgpu::RenderPipeline, diff --git a/crates/neomacs-renderer-wgpu/src/renderer/transitions.rs b/crates/neomacs-renderer-wgpu/src/renderer/transitions.rs index 2d7480dc0c..13d159fec6 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/transitions.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/transitions.rs @@ -2,12 +2,21 @@ use super::super::vertex::GlyphVertex; use super::WgpuRenderer; +use neomacs_display_protocol::types::Color; use neomacs_display_protocol::{ AxisMotionTransitionEffect, DirectionlessTransitionEffect, HorizontalTransitionEffect, ResolvedTransitionEffect, TransitionAxis, TransitionDirection, TransitionEasing, TransitionEdge, VerticalPostProcessTransitionEffect, VerticalTransitionEffect, }; +/// Complementary fades combine alternative premultiplied pictures. Other +/// geometries retain source-over ordering (curl, flip, strips and occlusion). +#[derive(Clone, Copy)] +enum TransitionComposition { + SourceOver, + WeightedPictures, +} + fn slide_offsets( axis: TransitionAxis, direction: TransitionDirection, @@ -31,6 +40,62 @@ fn axis_offset(axis: TransitionAxis, amount: f32) -> [f32; 2] { } impl WgpuRenderer { + // A transition replaces the window picture, not a third source-over layer + // over the already-rendered new scene. Clear only its clipped region. + fn clear_transition_region( + &mut self, + view: &wgpu::TextureView, + sx: u32, + sy: u32, + sw: u32, + sh: u32, + surface_width: u32, + surface_height: u32, + ) { + let w = surface_width as f32 / self.scale_factor; + let h = surface_height as f32 / self.scale_factor; + let mut vertices = Vec::new(); + self.add_rect(&mut vertices, 0.0, 0.0, w, h, &Color::BLACK); + let Some(upload) = self + .arenas + .rect + .upload(&self.device, &self.queue, &vertices) + else { + return; + }; + let draw = self.parameters([w, h], 0.0); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Transition Region Clear"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Transition Region Clear"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_scissor_rect(sx, sy, sw, sh); + pass.set_blend_constant(wgpu::Color::TRANSPARENT); + pass.set_pipeline(&self.pipelines.background_rect); + pass.set_bind_group(0, draw.binding(), &[]); + pass.set_vertex_buffer(0, upload.buffer_slice()); + pass.draw(0..vertices.len() as u32, 0..1); + } + self.queue.submit([encoder.finish()]); + } + /// Render a crossfade transition within a scissor region /// Uses the image_pipeline to blend old and new textures pub fn render_crossfade( @@ -163,7 +228,8 @@ impl WgpuRenderer { }); render_pass.set_scissor_rect(sx, sy, sw, sh); - render_pass.set_pipeline(&self.pipelines.image); + // Replace the previous scene within this scissor with (1-t)*old. + render_pass.set_pipeline(&self.pipelines.surface_copy); render_pass.set_bind_group(0, self.frame_parameters().binding(), &[]); // Draw old with fading alpha @@ -173,7 +239,8 @@ impl WgpuRenderer { render_pass.draw(0..6, 0..1); } - // Draw new with increasing alpha + // Add t*new to the weighted old picture, including its alpha. + render_pass.set_pipeline(&self.pipelines.crossfade_add); if let Some(ref upload) = new_upload { render_pass.set_bind_group(1, new_bind_group, &[]); render_pass.set_vertex_buffer(0, upload.buffer_slice()); @@ -223,6 +290,7 @@ impl WgpuRenderer { let uv_right = (bounds.x + bounds.width) / w; let uv_bottom = (bounds.y + bounds.height) / h; + self.clear_transition_region(surface_view, sx, sy, sw, sh, surface_width, surface_height); let (old_offset, new_offset) = slide_offsets(axis, direction, distance, progress); // Build a content-region quad: position covers the content bounds shifted @@ -304,7 +372,7 @@ impl WgpuRenderer { }); render_pass.set_scissor_rect(sx, sy, sw, sh); - render_pass.set_pipeline(&self.pipelines.image); + render_pass.set_pipeline(&self.pipelines.composition); render_pass.set_bind_group(0, self.frame_parameters().binding(), &[]); // Draw old texture sliding out @@ -639,11 +707,17 @@ impl WgpuRenderer { new_bind_group: &wgpu::BindGroup, old_vertices: &[GlyphVertex], new_vertices: &[GlyphVertex], + composition: TransitionComposition, sx: u32, sy: u32, sw: u32, sh: u32, + surface_width: u32, + surface_height: u32, ) { + // Replace the transitioned region, rather than compositing a third copy + // of the already-rendered scene underneath these two snapshots. + self.clear_transition_region(surface_view, sx, sy, sw, sh, surface_width, surface_height); let old_upload = self.create_transition_vb(old_vertices); let new_upload = self.create_transition_vb(new_vertices); @@ -670,7 +744,10 @@ impl WgpuRenderer { multiview_mask: None, }); rp.set_scissor_rect(sx, sy, sw, sh); - rp.set_pipeline(&self.pipelines.image); + rp.set_pipeline(match composition { + TransitionComposition::SourceOver => &self.pipelines.composition, + TransitionComposition::WeightedPictures => &self.pipelines.crossfade_add, + }); rp.set_bind_group(0, self.frame_parameters().binding(), &[]); if let Some(ref upload) = old_upload { @@ -699,91 +776,14 @@ impl WgpuRenderer { surface_width: u32, surface_height: u32, ) { - let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = - match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { - Some(v) => v, - None => return, - }; - let x0 = bounds.x; - let y0 = bounds.y; - let x1 = bounds.x + bounds.width; - let y1 = bounds.y + bounds.height; - let old_a = 1.0 - t; - - let old_verts = [ - GlyphVertex { - position: [x0, y0], - tex_coords: [uv_l, uv_t], - color: [1.0, 1.0, 1.0, old_a], - }, - GlyphVertex { - position: [x1, y0], - tex_coords: [uv_r, uv_t], - color: [1.0, 1.0, 1.0, old_a], - }, - GlyphVertex { - position: [x1, y1], - tex_coords: [uv_r, uv_b], - color: [1.0, 1.0, 1.0, old_a], - }, - GlyphVertex { - position: [x0, y0], - tex_coords: [uv_l, uv_t], - color: [1.0, 1.0, 1.0, old_a], - }, - GlyphVertex { - position: [x1, y1], - tex_coords: [uv_r, uv_b], - color: [1.0, 1.0, 1.0, old_a], - }, - GlyphVertex { - position: [x0, y1], - tex_coords: [uv_l, uv_b], - color: [1.0, 1.0, 1.0, old_a], - }, - ]; - let new_verts = [ - GlyphVertex { - position: [x0, y0], - tex_coords: [uv_l, uv_t], - color: [1.0, 1.0, 1.0, t], - }, - GlyphVertex { - position: [x1, y0], - tex_coords: [uv_r, uv_t], - color: [1.0, 1.0, 1.0, t], - }, - GlyphVertex { - position: [x1, y1], - tex_coords: [uv_r, uv_b], - color: [1.0, 1.0, 1.0, t], - }, - GlyphVertex { - position: [x0, y0], - tex_coords: [uv_l, uv_t], - color: [1.0, 1.0, 1.0, t], - }, - GlyphVertex { - position: [x1, y1], - tex_coords: [uv_r, uv_b], - color: [1.0, 1.0, 1.0, t], - }, - GlyphVertex { - position: [x0, y1], - tex_coords: [uv_l, uv_b], - color: [1.0, 1.0, 1.0, t], - }, - ]; - self.submit_transition_two_quad_pass( + self.render_crossfade( surface_view, old_bind_group, new_bind_group, - &old_verts, - &new_verts, - sx, - sy, - sw, - sh, + t, + bounds, + surface_width, + surface_height, ); } @@ -864,10 +864,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::WeightedPictures, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -959,16 +962,21 @@ impl WgpuRenderer { let new_y_off = dir * (distance - offset); let old_verts = make_strips(old_y_off, true); let new_verts = make_strips(new_y_off, false); + // These are complementary pictures, with intentional edge attenuation, + // not ordered occlusion: add their premultiplied weighted samples. self.submit_transition_two_quad_pass( surface_view, old_bind_group, new_bind_group, &old_verts, &new_verts, + TransitionComposition::WeightedPictures, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1064,10 +1072,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1145,10 +1156,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::WeightedPictures, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1244,10 +1258,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1407,10 +1424,13 @@ impl WgpuRenderer { old_bind_group, &new_verts, &old_verts, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1492,10 +1512,13 @@ impl WgpuRenderer { bind_group, &verts, &empty, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1594,10 +1617,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1684,10 +1710,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1775,10 +1804,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1879,10 +1911,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1969,10 +2004,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -2111,10 +2149,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -2253,10 +2294,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver, sx, sy, sw, sh, + surface_width, + surface_height, ); } } diff --git a/crates/neomacs-renderer-wgpu/src/shaders/glyph_coverage.wgsl b/crates/neomacs-renderer-wgpu/src/shaders/glyph_coverage.wgsl index 0024a061a2..64d8c2f7a4 100644 --- a/crates/neomacs-renderer-wgpu/src/shaders/glyph_coverage.wgsl +++ b/crates/neomacs-renderer-wgpu/src/shaders/glyph_coverage.wgsl @@ -89,6 +89,15 @@ fn fs_subpixel(in: VertexOutput) -> @location(0) vec4 { return composite_coverage(in, textureSample(glyph_texture, glyph_sampler, in.tex_coords).rgb); } +// Transparent backgrounds cannot use opaque LCD glyph patches: their +// precomputed background would become an opaque halo. Grayscale source-over +// leaves the background alpha intact, with fully covered text still opaque. +@fragment +fn fs_transparent(in: VertexOutput) -> @location(0) vec4 { + let coverage = textureSample(glyph_texture, glyph_sampler, in.tex_coords).r; + return vec4(in.fg_color.rgb, coverage * in.fg_color.a * uniforms.content_alpha); +} + @fragment fn fs_grayscale(in: VertexOutput) -> @location(0) vec4 { return composite_coverage(in, vec3(textureSample(glyph_texture, glyph_sampler, in.tex_coords).r)); diff --git a/crates/neomacs-renderer-wgpu/src/shaders/image.wgsl b/crates/neomacs-renderer-wgpu/src/shaders/image.wgsl index cd6cbc128d..9897b9edae 100644 --- a/crates/neomacs-renderer-wgpu/src/shaders/image.wgsl +++ b/crates/neomacs-renderer-wgpu/src/shaders/image.wgsl @@ -62,6 +62,31 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { return vec4(tex_color.rgb * in.color.rgb, tex_color.a * in.color.a * uniforms.content_alpha); } +// Composed editor textures already contain premultiplied RGB. Copy/fade +// their RGB and alpha together; ordinary decoded images remain straight alpha. +@fragment +fn fs_copy(in: VertexOutput) -> @location(0) vec4 { + let tex = textureSample(t_image, s_image, in.tex_coords); + let opacity = in.color.a * uniforms.content_alpha; + return vec4(min(tex.rgb * in.color.rgb, vec3(tex.a)) * opacity, tex.a * opacity); +} + +// Convert linear-premultiplied composition to encoded-premultiplied native RGB. +// The sRGB attachment encodes our output, so undo that encoding before storage. +fn to_srgb(v: vec3) -> vec3 { + return select(1.055 * pow(v, vec3(1.0 / 2.4)) - 0.055, 12.92 * v, v <= vec3(0.0031308)); +} +fn from_srgb(v: vec3) -> vec3 { + return select(pow((v + 0.055) / 1.055, vec3(2.4)), v / 12.92, v <= vec3(0.04045)); +} +@fragment +fn fs_native(in: VertexOutput) -> @location(0) vec4 { + let tex = textureSample(t_image, s_image, in.tex_coords); + let alpha = tex.a * in.color.a * uniforms.content_alpha; + let straight = clamp(tex.rgb / max(tex.a, 0.000001), vec3(0.0), vec3(1.0)); + return vec4(from_srgb(to_srgb(straight) * alpha), alpha); +} + @fragment fn fs_main_opaque(in: VertexOutput) -> @location(0) vec4 { // Sample from texture, force alpha=1.0 (for XRGB/BGRX DMA-BUF textures diff --git a/crates/neomacs-renderer-wgpu/tests/offscreen_frame.rs b/crates/neomacs-renderer-wgpu/tests/offscreen_frame.rs index 86b42a2b30..c6fc7c571a 100644 --- a/crates/neomacs-renderer-wgpu/tests/offscreen_frame.rs +++ b/crates/neomacs-renderer-wgpu/tests/offscreen_frame.rs @@ -10,8 +10,12 @@ //! Skips (passes) cleanly where no GPU adapter is available. use neomacs_display_protocol::face::BoxVerticalEdges; +#[path = "offscreen_frame/fade_edges_test.rs"] +mod fade_edges_test; #[path = "offscreen_frame/menu_test.rs"] mod menu_test; +#[path = "offscreen_frame/opacity_test.rs"] +mod opacity_test; #[path = "offscreen_frame/scroll_texture_test.rs"] mod scroll_texture_test; use neomacs_display_protocol::frame_chrome::PresentationId; diff --git a/crates/neomacs-renderer-wgpu/tests/offscreen_frame/fade_edges_test.rs b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/fade_edges_test.rs new file mode 100644 index 0000000000..561f5cabaa --- /dev/null +++ b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/fade_edges_test.rs @@ -0,0 +1,278 @@ +//! Real dispatch regressions: complementary FadeEdges pictures, not occlusion. +use super::*; +use neomacs_display_protocol::{ + ResolvedTransitionEffect, TransitionDirection, TransitionEasing, TransitionEdge, + VerticalTransitionEffect, types::Rect, +}; +use neomacs_renderer_wgpu::SnapshotSize; + +const HEIGHT: u32 = 112; + +fn harness() -> Harness { + let renderer = + WgpuRenderer::new(None, W, HEIGHT).expect("GPU required for FadeEdges regression"); + let atlas = WgpuGlyphAtlas::new(renderer.device()); + let target = renderer.device().create_texture(&wgpu::TextureDescriptor { + label: Some("fade-edges-target"), + size: wgpu::Extent3d { + width: W, + height: HEIGHT, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + Harness { + renderer, + atlas, + target, + view, + } +} + +fn picture(background: Color, alpha: f32) -> FrameGlyphBuffer { + let mut frame = FrameGlyphBuffer::with_size(W as f32, HEIGHT as f32); + frame.background = background; + frame.background_alpha = alpha; + frame.set_face( + FaceId::new(0), + Color::WHITE, + Some(background), + 400, + false, + 0, + None, + 0, + None, + 0, + None, + ); + frame.faces.get_mut(&FaceId::new(0)).unwrap().stipple = + Some(Box::new(neomacs_display_protocol::StipplePattern { + width: 1, + height: 1, + bits: vec![1], + })); + frame.set_draw_context(DisplayWindowId::new(1), GlyphRowRole::Text, None); + frame.add_stretch(24.0, 16.0, 16.0, 80.0, background, FaceId::new(0), false); + frame +} + +fn render(h: &mut Harness, frame: &FrameGlyphBuffer, view: &wgpu::TextureView) { + h.renderer.render_frame_glyphs( + view, + frame, + &mut h.atlas, + mapping_for(frame, W, HEIGHT), + false, + None, + None, + None, + None, + None, + ); +} + +fn pixels(h: &Harness) -> Vec { + let row = + (W * 4).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let buffer = h.renderer.device().create_buffer(&wgpu::BufferDescriptor { + label: Some("fade-edges-readback"), + size: (row * HEIGHT) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = h + .renderer + .device() + .create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &h.target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row), + rows_per_image: Some(HEIGHT), + }, + }, + wgpu::Extent3d { + width: W, + height: HEIGHT, + depth_or_array_layers: 1, + }, + ); + h.renderer.queue().submit(Some(encoder.finish())); + buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + h.renderer + .device() + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(std::time::Duration::from_secs(3)), + }) + .unwrap(); + let data = buffer.slice(..).get_mapped_range().unwrap(); + data.chunks_exact(row as usize) + .flat_map(|r| r[..(W * 4) as usize].iter().copied()) + .collect() +} + +fn expected(old_weight: f32, new_weight: f32, alpha: f32) -> [u8; 4] { + let color = Color::new( + 0.0, + alpha * old_weight, + alpha * new_weight, + alpha * (old_weight + new_weight), + ) + .linear_to_srgb(); + [color.r, color.g, color.b, color.a].map(|v| (v * 255.0).round() as u8) +} + +fn close(actual: [u8; 4], expected: [u8; 4], context: &str) { + assert!( + actual.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 2), + "{context}: {actual:?} != {expected:?}" + ); +} + +fn outside_unchanged(before: &[u8], after: &[u8]) { + for y in 0..HEIGHT { + for x in 0..W { + if !(8..88).contains(&x) || !(8..104).contains(&y) { + assert_eq!( + px(before, x, y), + px(after, x, y), + "outside scissor ({x},{y})" + ); + } + } + } +} + +// Independent geometric oracle for the edge attenuation at a pixel center: +// 16 six-pixel strips over a 96-pixel viewport; no copied shader or blend pass. +fn edge_weight(y: f32, offset: f32, weight: f32) -> f32 { + let index = ((y - offset) / 6.0).floor(); + if !(0.0..16.0).contains(&index) { + return 0.0; + } + let center = (index * 6.0 + 3.0 + offset).clamp(0.0, 96.0); + let edge = (center.min(96.0 - center) / 14.4).min(1.0); + weight * edge +} + +fn fade_edges(alpha: f32) { + let mut h = harness(); + let size = SnapshotSize::new(W, HEIGHT).unwrap(); + let old = h.renderer.acquire_snapshot(size).unwrap(); + let new = h.renderer.acquire_snapshot(size).unwrap(); + render(&mut h, &picture(Color::GREEN, alpha), old.view()); + render(&mut h, &picture(Color::BLUE, alpha), new.view()); + // Midpoint first makes the reported failure the actual overlapping-picture + // defect, not endpoint attenuation. Both configured scroll directions run. + for direction in [TransitionDirection::Forward, TransitionDirection::Backward] { + for progress in [0.5, 0.0, 0.25, 0.75, 1.0] { + let view = h.view.clone(); + render(&mut h, &picture(Color::rgb(0.3, 0.1, 0.2), 0.75), &view); + let before = pixels(&h); + h.renderer.render_transition_effect( + &h.view, + old.bind_group(), + new.bind_group(), + progress, + 0.0, + &Rect::new(8.0, 8.0, 80.0, 96.0), + ResolvedTransitionEffect::Vertical { + effect: VerticalTransitionEffect::FadeEdges, + direction, + distance: 8.0, + }, + TransitionEasing::Linear, + W, + HEIGHT, + ); + let after = pixels(&h); + let bg = px(&after, 70, 56); + eprintln!( + "FadeEdges {alpha}/{direction:?}/{progress}: background {bg:?}, foreground {:?}, edge {:?}", + px(&after, 30, 56), + px(&after, 70, 12) + ); + close( + bg, + expected(1.0 - progress, progress, alpha), + "complementary interior pictures", + ); + close(px(&after, 30, 56), [255; 4], "opaque stipple foreground"); + let sign = direction.sign(); + for y in [10, 12, 100, 102] { + let local = y as f32 + 0.5 - 8.0; + let old_weight = edge_weight(local, -sign * 8.0 * progress, 1.0 - progress); + let new_weight = edge_weight(local, sign * 8.0 * (1.0 - progress), progress); + close( + px(&after, 70, y), + expected(old_weight, new_weight, alpha), + "edge attenuation", + ); + } + outside_unchanged(&before, &after); + } + } +} + +#[test] +fn dispatched_fade_edges_preserves_fractional_complementary_pictures() { + fade_edges(0.5); +} + +#[test] +fn dispatched_fade_edges_preserves_opaque_complementary_pictures() { + fade_edges(1.0); +} + +#[test] +fn dispatched_page_curl_keeps_ordered_source_over_occlusion() { + let mut h = harness(); + let size = SnapshotSize::new(W, HEIGHT).unwrap(); + let old = h.renderer.acquire_snapshot(size).unwrap(); + let new = h.renderer.acquire_snapshot(size).unwrap(); + for alpha in [0.5, 1.0] { + render(&mut h, &picture(Color::GREEN, alpha), old.view()); + render(&mut h, &picture(Color::BLUE, alpha), new.view()); + let view = h.view.clone(); + render(&mut h, &picture(Color::BLACK, 0.25), &view); + let before = pixels(&h); + h.renderer.render_transition_effect( + &h.view, + old.bind_group(), + new.bind_group(), + 0.0, + 0.0, + &Rect::new(8.0, 8.0, 80.0, 96.0), + ResolvedTransitionEffect::PageCurl { + edge: TransitionEdge::Right, + }, + TransitionEasing::Linear, + W, + HEIGHT, + ); + let after = pixels(&h); + // Old picture covers the new one, not a complementary replacement. + close( + px(&after, 70, 56), + expected(1.0, 1.0 - alpha, alpha), + "PageCurl ordered occlusion", + ); + outside_unchanged(&before, &after); + } +} diff --git a/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs new file mode 100644 index 0000000000..cef079608a --- /dev/null +++ b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs @@ -0,0 +1,743 @@ +//! Pixel regressions for GNU background-only and completed-picture opacity. + +#[test] +fn pane_previous_patches_interpolate_complete_colored_pictures() { + use neomacs_renderer_wgpu::{PaneBlit, PaneSource}; + let mut h = try_harness().expect("GPU required for pane opacity regression"); + let mut old_frame = picture(); + old_frame.background = Color::GREEN; + let mut new_frame = old_frame.clone(); + new_frame.background = Color::BLUE; + let size = SnapshotSize::new(W, H).unwrap(); + let old = h.renderer.acquire_snapshot(size).unwrap(); + let new = h.renderer.acquire_snapshot(size).unwrap(); + render(&mut h, &old_frame, old.view()); + render(&mut h, &new_frame, new.view()); + let mut outside = None; + for weight in [0.0, 0.5, 1.0] { + h.renderer.render_pane_layout( + new.bind_group(), + Some(old.bind_group()), + &h.view, + (W as f32, H as f32), + &[PaneBlit { + bounds: neomacs_display_protocol::types::Rect::new(8.0, 8.0, 80.0, 48.0), + content_origin: (8.0, 8.0), + source: PaneSource::Previous, + opacity: weight, + }], + ); + let pixels = read_back(&h); + let bg = px(&pixels, 70, 45); + eprintln!("pane Previous {weight}: {bg:?}"); + assert!((127..=129).contains(&bg[3]), "pane weight {weight}: {bg:?}"); + let color = Color::new(0.0, 0.5 * weight, 0.5 * (1.0 - weight), 0.5).linear_to_srgb(); + assert!(bg[1].abs_diff((color.g * 255.0).round() as u8) <= 2); + assert!(bg[2].abs_diff((color.b * 255.0).round() as u8) <= 2); + assert!( + opaque_text(&pixels) > 0, + "pane foreground remains opaque at {weight}" + ); + let sample = px(&pixels, 2, 2); + assert_eq!( + sample, + *outside.get_or_insert(sample), + "outside patch unchanged" + ); + } +} + +#[test] +fn dispatched_scale_zoom_preserves_overlap_opacity_and_outside_pixels() { + use neomacs_display_protocol::{ + DirectionlessTransitionEffect, ResolvedTransitionEffect, TransitionEasing, + }; + let mut h = try_harness().expect("GPU required for ScaleZoom regression"); + let size = SnapshotSize::new(W, H).unwrap(); + let old = h.renderer.acquire_snapshot(size).unwrap(); + let new = h.renderer.acquire_snapshot(size).unwrap(); + let bounds = neomacs_display_protocol::types::Rect::new(8.0, 8.0, 80.0, 48.0); + for alpha in [1.0, 0.5] { + let mut frame = picture(); + frame.background = Color::GREEN; + frame.background_alpha = alpha; + // A broad foreground stipple interior remains fully covered after the + // production scale resampling, unlike a one-pixel glyph stem. + frame.faces.get_mut(&FaceId::new(0)).unwrap().stipple = + Some(Box::new(neomacs_display_protocol::StipplePattern { + width: 1, + height: 1, + bits: vec![1], + })); + frame.add_stretch(20.0, 16.0, 16.0, 24.0, Color::BLACK, FaceId::new(0), false); + render(&mut h, &frame, old.view()); + render(&mut h, &frame, new.view()); + for progress in [0.0, 0.5, 1.0] { + let view = h.view.clone(); + render(&mut h, &frame, &view); + let before = read_back(&h); + h.renderer.render_transition_effect( + &h.view, + old.bind_group(), + new.bind_group(), + progress, + 0.0, + &bounds, + ResolvedTransitionEffect::Directionless(DirectionlessTransitionEffect::ScaleZoom), + TransitionEasing::Linear, + W, + H, + ); + let pixels = read_back(&h); + let bg = px(&pixels, 70, 45); + eprintln!("ScaleZoom {alpha}/{progress}: {bg:?}"); + assert!( + bg[3].abs_diff((255.0_f32 * alpha).round() as u8) <= 1, + "ScaleZoom overlap background {alpha}/{progress}: {bg:?}" + ); + assert!( + opaque_text(&pixels) > 0, + "ScaleZoom opaque foreground {alpha}/{progress}" + ); + assert_eq!(px(&pixels, 2, 2), px(&before, 2, 2)); + assert_eq!(px(&pixels, 94, 62), px(&before, 94, 62)); + } + } +} + +use super::*; + +#[test] +fn dispatched_parallax_adds_overlapping_faded_pictures() { + use neomacs_display_protocol::{ + AxisMotionTransitionEffect, ResolvedTransitionEffect, TransitionAxis, TransitionDirection, + TransitionEasing, + }; + let mut h = try_harness().expect("GPU required for shared weighted transition regression"); + let size = SnapshotSize::new(W, H).unwrap(); + let old = h.renderer.acquire_snapshot(size).unwrap(); + let new = h.renderer.acquire_snapshot(size).unwrap(); + for alpha in [1.0, 0.5] { + let mut frame = picture(); + frame.background = Color::GREEN; + frame.background_alpha = alpha; + render(&mut h, &frame, old.view()); + render(&mut h, &frame, new.view()); + let view = h.view.clone(); + render(&mut h, &frame, &view); + h.renderer.render_transition_effect( + &h.view, + old.bind_group(), + new.bind_group(), + 0.5, + 0.0, + &neomacs_display_protocol::types::Rect::new(8.0, 8.0, 80.0, 48.0), + ResolvedTransitionEffect::AxisMotion { + effect: AxisMotionTransitionEffect::Parallax, + axis: TransitionAxis::Horizontal, + direction: TransitionDirection::Forward, + distance: 32.0, + }, + TransitionEasing::Linear, + W, + H, + ); + let bg = px(&read_back(&h), 48, 48); + assert!( + bg[3].abs_diff((255.0_f32 * alpha).round() as u8) <= 1, + "parallax overlap {alpha}: {bg:?}" + ); + } +} + +use neomacs_renderer_wgpu::{ + SnapshotSize, + shader_surface::{SurfaceContract, SurfaceShaderLanguage, compose_surface_wgsl}, +}; + +#[test] +fn native_inset_clear_matches_completed_content_opacity() { + use neomacs_display_protocol::ContentInsets; + use neomacs_renderer_wgpu::renderer::{NativeContentPlacement, RenderTarget}; + let seed = WgpuRenderer::new(None, W, H).expect("GPU required for native inset regression"); + for format in [ + wgpu::TextureFormat::Bgra8UnormSrgb, + wgpu::TextureFormat::Bgra8Unorm, + ] { + let renderer = WgpuRenderer::with_device( + seed.device().clone(), + seed.queue().clone(), + W, + H, + format, + 1.0, + ); + let target = renderer.device().create_texture(&wgpu::TextureDescriptor { + label: Some("native-inset-target"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let atlas = WgpuGlyphAtlas::new(renderer.device()); + let mut h = Harness { + renderer, + target, + view, + atlas, + }; + let surface = mapping_for(&FrameGlyphBuffer::with_size(W as f32, H as f32), W, H) + .surface() + .with_content_insets(ContentInsets::new(4, 8, 4, 4)); + let content = surface.content_surface().unwrap(); + let cw = content.device_width().get(); + let ch = content.device_height().get(); + let source = h + .renderer + .acquire_snapshot(SnapshotSize::new(cw, ch).unwrap()) + .unwrap(); + let mut frame = FrameGlyphBuffer::with_size(cw as f32, ch as f32); + for background in [Color::GREEN, Color::rgb(0.15, 0.45, 0.75)] { + frame.background = background; + h.renderer.render_frame_glyphs( + source.view(), + &frame, + &mut h.atlas, + mapping_for(&frame, cw, ch), + false, + None, + None, + None, + None, + None, + ); + for alpha in [1.0, 0.5, 0.25] { + let placement = + NativeContentPlacement::new(RenderTarget::new(&h.view, surface), &source) + .unwrap(); + h.renderer + .place_native_content_with_opacity(placement, frame.background, alpha); + let pixels = read_back(&h); + let inset = px(&pixels, 2, 2); + let center = px(&pixels, 48, 32); + eprintln!( + "native inset {format:?} opacity {alpha}: inset {inset:?}, content {center:?}" + ); + assert!( + inset.iter().zip(center).all(|(a, b)| a.abs_diff(b) <= 1), + "native inset and content must share storage convention: {format:?}/{alpha}: {inset:?} != {center:?}" + ); + let straight = if format.is_srgb() { + background.linear_to_srgb() + } else { + background + }; + for (got, component) in center.iter().zip([straight.r, straight.g, straight.b, 1.0]) + { + assert!( + got.abs_diff((255.0 * alpha * component).round() as u8) <= 1, + "native premultiplied storage {format:?}/{alpha}: {center:?}" + ); + } + assert!( + center[..3] + .iter() + .all(|c| *c <= center[3].saturating_add(1)), + "RGB must not exceed alpha" + ); + } + } + } +} + +fn picture() -> FrameGlyphBuffer { + let mut frame = FrameGlyphBuffer::with_size(W as f32, H as f32); + frame.background = Color::BLACK; + frame.background_alpha = 0.5; + frame.set_face( + FaceId::new(0), + Color::WHITE, + Some(Color::BLACK), + 400, + false, + 0, + None, + 0, + None, + 0, + None, + ); + frame.set_draw_context(DisplayWindowId::new(1), GlyphRowRole::Text, None); + frame.add_char('M', 20.0, 16.0, 16.0, 24.0, 20.0, false); + frame +} + +fn render(h: &mut Harness, frame: &FrameGlyphBuffer, view: &wgpu::TextureView) { + h.renderer.render_frame_glyphs( + view, + frame, + &mut h.atlas, + mapping_for(frame, W, H), + false, + None, + None, + None, + None, + None, + ); +} + +fn opaque_text(buf: &[u8]) -> usize { + (16..40) + .flat_map(|y| (20..36).map(move |x| (x, y))) + .filter(|&(x, y)| { + let p = px(buf, x, y); + p[0] > 245 && p[1] > 245 && p[2] > 245 && p[3] == 255 + }) + .count() +} + +#[test] +fn background_alpha_keeps_text_opaque_through_crossfade() { + let Some(mut h) = try_harness() else { + eprintln!("SKIP: no GPU adapter"); + return; + }; + let frame = picture(); + let size = SnapshotSize::new(W, H).unwrap(); + let old = h.renderer.acquire_snapshot(size).unwrap(); + let new = h.renderer.acquire_snapshot(size).unwrap(); + render(&mut h, &frame, old.view()); + render(&mut h, &frame, new.view()); + for mix in [0.0, 0.25, 0.5, 0.75, 1.0] { + h.renderer.render_crossfade( + &h.view, + old.bind_group(), + new.bind_group(), + mix, + &neomacs_display_protocol::types::Rect::new(0.0, 0.0, W as f32, H as f32), + W, + H, + ); + let pixels = read_back(&h); + assert!( + (127..=129).contains(&px(&pixels, 80, 50)[3]), + "background opacity must not compound at mix {mix}" + ); + assert!( + opaque_text(&pixels) > 0, + "same foreground stays opaque at mix {mix}" + ); + } +} + +#[test] +fn background_alpha_survives_identity_frame_post() { + let Some(mut h) = try_harness() else { + eprintln!("SKIP: no GPU adapter"); + return; + }; + let frame = picture(); + let src = h + .renderer + .acquire_snapshot(SnapshotSize::new(W, H).unwrap()) + .unwrap(); + render(&mut h, &frame, src.view()); + let source = compose_surface_wgsl( + "fn mainImage(p: vec2) -> vec4 { return textureSample(iChannel0, iChannel0Sampler, vec2(p.x, u.iResolution.y-p.y)/u.iResolution.xy); }", + &[], + SurfaceContract::V1, + ); + h.renderer + .set_frame_post(SurfaceShaderLanguage::Wgsl, &source, &[]) + .unwrap(); + h.renderer + .frame_post_to_view(src.view(), &h.view, W, H, (0.0, 0.0)); + let pixels = read_back(&h); + assert!((127..=129).contains(&px(&pixels, 80, 50)[3])); + assert!(opaque_text(&pixels) > 0); +} + +#[test] +fn child_background_alpha_and_lifecycle_opacity_apply_once() { + let Some(mut h) = try_harness() else { + eprintln!("SKIP: no GPU adapter"); + return; + }; + h.renderer.resize(W, H); + let mut parent = FrameGlyphBuffer::with_size(W as f32, H as f32); + parent.background_alpha = 0.0; + let child = picture(); + for radius in [0.0, 8.0] { + for alpha in [1.0, 0.5] { + h.renderer.render_frame_glyphs( + &h.view, + &parent, + &mut h.atlas, + mapping_for(&parent, W, H), + false, + None, + None, + None, + None, + None, + ); + h.renderer + .render_child_frame( + &h.view, + &child, + 0.0, + 0.0, + neomacs_display_protocol::RootSurfaceRect::new(0.0, 0.0, W as f32, H as f32) + .unwrap(), + &mut h.atlas, + W, + H, + false, + None, + radius, + false, + 0, + 0.0, + 0.0, + None, + alpha, + 1.0, + [0.0; 2], + ) + .expect("child picture admitted"); + let pixels = read_back(&h); + let expected = (128.0_f32 * alpha).round() as u8; + assert!( + px(&pixels, 80, 50)[3].abs_diff(expected) <= 1, + "child background opacity: radius {radius}, alpha {alpha}" + ); + if alpha == 1.0 { + assert!(opaque_text(&pixels) > 0); + } + } + } +} + +#[test] +fn dispatched_crossfade_and_slide_replace_the_previous_picture() { + use neomacs_display_protocol::{ + DirectionlessTransitionEffect, ResolvedTransitionEffect, TransitionAxis, + TransitionDirection, TransitionEasing, + }; + let Some(mut h) = try_harness() else { + eprintln!("SKIP: no GPU adapter"); + return; + }; + let frame = picture(); + let size = SnapshotSize::new(W, H).unwrap(); + let old = h.renderer.acquire_snapshot(size).unwrap(); + let new = h.renderer.acquire_snapshot(size).unwrap(); + render(&mut h, &frame, old.view()); + render(&mut h, &frame, new.view()); + let bounds = neomacs_display_protocol::types::Rect::new(0.0, 0.0, W as f32, H as f32); + for mix in [0.0, 0.25, 0.5, 0.75, 1.0] { + let destination = h.view.clone(); + render(&mut h, &frame, &destination); + h.renderer.render_transition_effect( + &h.view, + old.bind_group(), + new.bind_group(), + mix, + 0.0, + &bounds, + ResolvedTransitionEffect::Directionless(DirectionlessTransitionEffect::Crossfade), + TransitionEasing::Linear, + W, + H, + ); + let pixels = read_back(&h); + assert!((127..=129).contains(&px(&pixels, 80, 50)[3])); + assert!(opaque_text(&pixels) > 0, "dispatched crossfade at {mix}"); + } + for axis in [TransitionAxis::Horizontal, TransitionAxis::Vertical] { + for direction in [TransitionDirection::Forward, TransitionDirection::Backward] { + for mix in [0.0, 0.5, 1.0] { + let destination = h.view.clone(); + render(&mut h, &frame, &destination); + let distance = if axis == TransitionAxis::Horizontal { + W + } else { + H + } as f32; + h.renderer.render_transition_slide( + &h.view, + old.bind_group(), + new.bind_group(), + mix, + axis, + direction, + &bounds, + distance, + W, + H, + ); + let pixels = read_back(&h); + assert!( + (127..=129).contains(&px(&pixels, 80, 50)[3]), + "slide {axis:?} {direction:?} {mix}" + ); + assert!( + pixels + .chunks_exact(4) + .any(|p| p[0] > 245 && p[1] > 245 && p[2] > 245 && p[3] == 255) + ); + } + } + } +} + +#[test] +fn transition_postprocess_preserves_alpha_and_color_modulation() { + use neomacs_display_protocol::{ + ResolvedTransitionEffect, TransitionDirection, TransitionEasing, + VerticalPostProcessTransitionEffect, + }; + let Some(mut h) = try_harness() else { + eprintln!("SKIP: no GPU adapter"); + return; + }; + let mut frame = picture(); + frame.background = Color::new(0.5, 0.5, 0.5, 1.0); + let size = SnapshotSize::new(W, H).unwrap(); + let old = h.renderer.acquire_snapshot(size).unwrap(); + let new = h.renderer.acquire_snapshot(size).unwrap(); + render(&mut h, &frame, old.view()); + render(&mut h, &frame, new.view()); + let destination = h.view.clone(); + render(&mut h, &frame, &destination); + let bounds = neomacs_display_protocol::types::Rect::new(0.0, 0.0, W as f32, H as f32); + h.renderer.render_transition_effect( + &h.view, + old.bind_group(), + new.bind_group(), + 0.0, + 0.0, + &bounds, + ResolvedTransitionEffect::VerticalPostProcess { + effect: VerticalPostProcessTransitionEffect::ColorTemperature, + direction: TransitionDirection::Forward, + distance: H as f32, + }, + TransitionEasing::Linear, + W, + H, + ); + let pixels = read_back(&h); + let bg = px(&pixels, 80, 50); + assert!( + (127..=129).contains(&bg[3]), + "postprocess background: {bg:?}" + ); + assert!( + bg[0] > bg[2], + "temperature tint must survive premultiplied copy: {bg:?}" + ); + assert!( + pixels + .chunks_exact(4) + .any(|p| p[0] > 245 && p[1] > 245 && p[2] > 230 && p[3] == 255) + ); +} + +fn draw_popup(h: &mut Harness, child: &FrameGlyphBuffer, radius: f32, alpha: f32) { + h.renderer + .render_child_frame( + &h.view, + child, + 0.0, + 0.0, + neomacs_display_protocol::RootSurfaceRect::new(0.0, 0.0, W as f32, H as f32).unwrap(), + &mut h.atlas, + W, + H, + false, + None, + radius, + false, + 0, + 0.0, + 0.0, + None, + alpha, + 1.0, + [0.0; 2], + ) + .expect("child picture admitted"); +} + +#[test] +fn colored_uncovered_child_fill_matches_square_and_rounded() { + let mut h = try_harness().expect("GPU adapter required for opacity regression"); + let mut parent = FrameGlyphBuffer::with_size(W as f32, H as f32); + parent.background_alpha = 0.0; + let mut child = parent.clone(); + child.background = Color::GREEN; + child.background_alpha = 0.5; + for alpha in [1.0, 0.5] { + let mut centers = Vec::new(); + for radius in [0.0, 8.0] { + let view = h.view.clone(); + render(&mut h, &parent, &view); + draw_popup(&mut h, &child, radius, alpha); + let pixels = read_back(&h); + let center = px(&pixels, 48, 32); + let expected_green = if alpha == 1.0 { 187 } else { 137 }; + assert!( + center[1].abs_diff(expected_green) <= 1, + "{radius}/{alpha}: {center:?}" + ); + assert!(center[3].abs_diff((128.0_f32 * alpha).round() as u8) <= 1); + if radius > 0.0 { + assert_eq!(px(&pixels, 0, 0), [0; 4], "rounded corner clips fill"); + let edge = px(&pixels, 4, 0); + assert!(edge[3] < center[3], "rounded edge retains coverage"); + assert!(edge[1] > 0, "colored antialiased edge is not lost"); + } + centers.push(center); + } + assert_eq!(centers[0], centers[1]); + } +} + +#[test] +fn child_char_and_stretch_stipple_is_foreground_at_zero_background() { + use neomacs_display_protocol::StipplePattern; + let mut h = try_harness().expect("GPU adapter required for stipple regression"); + let mut parent = FrameGlyphBuffer::with_size(W as f32, H as f32); + parent.background_alpha = 0.0; + for stretch in [false, true] { + for background_alpha in [0.0, 0.5] { + let mut child = parent.clone(); + child.background = Color::BLACK; + child.background_alpha = background_alpha; + child.set_face( + FaceId::new(1), + Color::WHITE, + Some(Color::BLACK), + 400, + false, + 0, + None, + 0, + None, + 0, + None, + ); + child.faces.get_mut(&FaceId::new(1)).unwrap().stipple = + Some(Box::new(StipplePattern { + width: 1, + height: 1, + bits: vec![1], + })); + child.set_draw_context(DisplayWindowId::new(1), GlyphRowRole::Text, None); + if stretch { + child.add_stretch(20.0, 16.0, 20.0, 20.0, Color::BLACK, FaceId::new(1), false); + } else { + child.add_char(' ', 20.0, 16.0, 20.0, 20.0, 16.0, false); + } + for radius in [0.0, 8.0] { + for alpha in [1.0, 0.5] { + let view = h.view.clone(); + render(&mut h, &parent, &view); + draw_popup(&mut h, &child, radius, alpha); + let pixels = read_back(&h); + let p = px(&pixels, 25, 20); + let expected = if alpha == 1.0 { 255 } else { 187 }; + assert!( + p[0].abs_diff(expected) <= 1 && p[1] == p[0] && p[2] == p[0], + "{stretch}/{background_alpha}/{radius}/{alpha}: {p:?}" + ); + assert!(p[3].abs_diff((255.0_f32 * alpha).round() as u8) <= 1); + assert_eq!( + px(&pixels, 10, 10)[3], + (255.0 * background_alpha * alpha).round() as u8 + ); + } + } + } + } +} + +/// Run in the bounded GPU lane with NEOMACS_GPU_BUDGET_MB=1. Real held +/// leases deny the required picture; no opaque substitute is rendered. +#[test] +fn mandatory_child_picture_refusal_is_typed_and_recovers_after_lease_release() { + // Isolate the budget knob in a child test process: never mutate the + // environment of other concurrently running GPU tests. + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "opacity_test::mandatory_child_picture_refusal_is_typed_and_recovers_after_lease_release", "--nocapture"]) + .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); + assert!(status.success()); + return; + } + let mut h = try_harness().expect("real GPU adapter required"); + assert_eq!(h.renderer.gpu_budget().limit_bytes().get(), 1_048_576); + let mut parent = FrameGlyphBuffer::with_size(W as f32, H as f32); + parent.background = Color::BLUE; + let mut child = FrameGlyphBuffer::with_size(W as f32, H as f32); + child.background = Color::GREEN; + child.background_alpha = 0.5; + let draw = |h: &mut Harness, child: &FrameGlyphBuffer| { + h.renderer.render_child_frame( + &h.view, + child, + 0.0, + 0.0, + neomacs_display_protocol::RootSurfaceRect::new(0.0, 0.0, W as f32, H as f32).unwrap(), + &mut h.atlas, + W, + H, + false, + None, + 0.0, + false, + 0, + 0.0, + 0.0, + None, + 1.0, + 1.0, + [0.0; 2], + ) + }; + let view = h.view.clone(); + render(&mut h, &parent, &view); + draw(&mut h, &child).unwrap(); + let normal = read_back(&h); + let held = h + .renderer + .acquire_snapshot(SnapshotSize::new(512, 506).unwrap()) + .unwrap(); + let view = h.view.clone(); + render(&mut h, &parent, &view); + let before = read_back(&h); + assert!( + draw(&mut h, &child).is_err(), + "mandatory refusal must reach caller" + ); + assert_eq!(read_back(&h), before); + child.background_alpha = 1.0; + draw(&mut h, &child).unwrap(); // positive opaque control needs no picture + assert_eq!(px(&read_back(&h), 48, 32), [0, 255, 0, 255]); + drop(held); + child.background_alpha = 0.5; + let view = h.view.clone(); + render(&mut h, &parent, &view); + draw(&mut h, &child).unwrap(); + assert_eq!(read_back(&h), normal); +} diff --git a/crates/neomacs/src/main.rs b/crates/neomacs/src/main.rs index a4a55a42d3..8b2dc210d1 100644 --- a/crates/neomacs/src/main.rs +++ b/crates/neomacs/src/main.rs @@ -1188,6 +1188,7 @@ struct PrimaryWindowDisplayHost { /// Renderer-published effective availability. Requested shader state is /// retained separately so hardware recovery can restore it. render_capabilities: Arc, + frame_opacity: Arc>, /// The exact shader requested by Lisp. This is one transactionally /// updated value so installation state, source, and live uniforms cannot /// drift. It survives temporary quality-policy suppression and device @@ -1873,6 +1874,59 @@ impl DisplayHost for PrimaryWindowDisplayHost { ) } + fn set_gui_frame_alpha_lower_limit(&mut self, limit: f32) { + self.frame_opacity + .lock() + .unwrap_or_else(|e| e.into_inner()) + .set_lower_limit(limit); + } + + fn set_gui_frame_alpha( + &mut self, + frame: FrameId, + alpha: [f32; 2], + limit: f32, + ) -> Result<(), String> { + self.frame_opacity + .lock() + .map_err(|err| err.to_string())? + .accept(frame.0, alpha, limit); + self.send_render_command( + RenderCommand::Window(WindowCommand::RefreshFrameOpacity), + "failed to refresh frame opacity", + ) + } + + fn set_gui_frame_focus_redirects( + &mut self, + redirects: Vec<(FrameId, Option)>, + ) -> Result<(), String> { + self.frame_opacity + .lock() + .map_err(|err| err.to_string())? + .set_redirects( + redirects + .into_iter() + .map(|(frame, target)| (frame.0, target.map(|target| target.0))) + .collect(), + ); + self.send_render_command( + RenderCommand::Window(WindowCommand::RefreshFrameOpacity), + "failed to refresh frame highlight", + ) + } + + fn retire_gui_frame_alpha(&mut self, frame: FrameId) -> Result<(), String> { + self.frame_opacity + .lock() + .map_err(|err| err.to_string())? + .retire(frame.0); + self.send_render_command( + RenderCommand::Window(WindowCommand::RefreshFrameOpacity), + "failed to retire frame opacity", + ) + } + fn set_gui_frame_undecorated( &mut self, _frame_id: neovm_core::window::FrameId, @@ -2955,9 +3009,21 @@ fn adopt_existing_primary_gui_frame(eval: &mut Context) -> Result<(), String> { .get(frame_id) .map(|frame| frame.gui_geometry_hints()) .ok_or_else(|| "selected GUI frame disappeared before adoption".to_string())?; + let alpha = eval + .frame_manager() + .get(frame_id) + .map(|frame| frame.frame_alpha) + .unwrap_or([-1.0; 2]); + let limit = neovm_core::window::frame_alpha::lower_limit( + eval.obarray() + .symbol_value("frame-alpha-lower-limit") + .copied() + .unwrap_or(Value::fixnum(20)), + ); let Some(host) = eval.display_host.as_mut() else { return Ok(()); }; + host.set_gui_frame_alpha(frame_id, alpha, limit)?; host.realize_gui_frame(GuiFrameHostRequest { frame_id, width, @@ -3762,6 +3828,7 @@ fn run_gui_evaluator_worker( resolved_webkits: Mutex::new(HashMap::new()), resolved_surfaces: Mutex::new(ResolvedSurfaceMemo::default()), render_capabilities: Arc::clone(&emacs_comms.capabilities), + frame_opacity: Arc::clone(&emacs_comms.frame_opacity), requested_frame_shader: Mutex::new(None), #[cfg(feature = "neo-term")] terminal_state: TerminalHostState::new(shared_terminals), diff --git a/crates/neomacs/src/tests/main_test.rs b/crates/neomacs/src/tests/main_test.rs index 0fb1c4a51c..79ed823262 100644 --- a/crates/neomacs/src/tests/main_test.rs +++ b/crates/neomacs/src/tests/main_test.rs @@ -2319,6 +2319,7 @@ fn assert_selected_frame_matches_materialized_default_metrics(eval: &Context) { fn opening_gui_frame_adoption_does_not_push_stale_window_size() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2397,6 +2398,7 @@ fn opening_gui_frame_adoption_does_not_push_stale_window_size() { fn opening_gui_frame_adoption_applies_fullscreen_mode() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2453,6 +2455,7 @@ fn opening_gui_frame_adoption_applies_fullscreen_mode() { fn primary_display_host_destroy_gui_frame_routes_primary_and_secondary_windows() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2506,6 +2509,7 @@ fn primary_display_host_destroy_gui_frame_routes_primary_and_secondary_windows() fn primary_display_host_popup_menu_routes_primary_and_secondary_frames() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2590,6 +2594,7 @@ fn primary_image_catalog_lookup_returns_pending_without_waiting_for_render_threa let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let image_metadata = Arc::new(ImageRenderState::default()); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2758,6 +2763,7 @@ fn primary_image_catalog_does_not_block_on_render_command_backpressure() { let worker_cmd_tx = cmd_tx.clone(); let worker = std::thread::spawn(move || { let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2820,6 +2826,7 @@ fn primary_image_catalog_does_not_wait_for_renderer_metadata_lock() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let image_metadata = Arc::new(ImageRenderState::default()); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2886,6 +2893,7 @@ fn primary_image_catalog_does_not_wait_for_renderer_metadata_lock() { fn primary_display_host_expands_tilde_in_image_file_before_render_command() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -2976,6 +2984,7 @@ fn primary_display_host_resolve_image_sync_returns_cached_decode_failure_promptl let (cmd_tx, _cmd_rx) = crossbeam_channel::unbounded(); let image_metadata: SharedImageRenderState = Arc::new(ImageRenderState::default()); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3068,6 +3077,7 @@ fn primary_display_host_resolve_image_sync_returns_cached_decode_failure_promptl fn primary_display_host_request_video_queues_create_once_with_stable_id() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3154,6 +3164,7 @@ fn resolved_video_registry_never_evicts_a_still_referenceable_identity() { fn primary_display_host_request_video_preserves_uri_source() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3203,6 +3214,7 @@ fn primary_display_host_request_video_preserves_uri_source() { fn primary_display_host_routes_one_typed_video_session_lifecycle() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3262,6 +3274,7 @@ fn primary_display_host_routes_one_typed_video_session_lifecycle() { fn primary_display_host_request_webkit_queues_create_and_load_once_with_stable_id() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3316,6 +3329,7 @@ fn primary_display_host_request_webkit_queues_create_and_load_once_with_stable_i fn primary_display_host_preserves_file_navigation_as_a_typed_path() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3360,6 +3374,7 @@ fn primary_display_host_preserves_file_navigation_as_a_typed_path() { fn primary_display_host_xwidget_lifecycle_uses_explicit_xwidget_id() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3434,6 +3449,7 @@ fn bootstrap_gui_frame_adoption_routes_future_resizes_to_primary_window() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); eval.set_display_host(Box::new(PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3502,6 +3518,7 @@ fn primary_window_resize_does_not_wait_for_host_acknowledgement() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let shared = shared_primary_window_size(843, 489); let mut host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3569,6 +3586,7 @@ fn primary_window_resize_does_not_wait_for_host_acknowledgement() { fn primary_window_display_host_forwards_visual_config_to_renderer() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3643,6 +3661,7 @@ fn primary_window_display_host_round_trips_clipboard_requests_through_renderer() reply.send(Ok(SelectionOwner::OtherProcess)).unwrap(); }); let mut host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3715,6 +3734,7 @@ fn redisplay_title_sync_formats_frame_title_format_for_primary_window() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); eval.set_display_host(Box::new(PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -3764,6 +3784,7 @@ fn frame_host_title_formats_the_restored_runtime_system_name() { let _bootstrap = bootstrap_buffers(&mut eval, 843, 489, gui_display()); let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); eval.set_display_host(Box::new(PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -6717,6 +6738,7 @@ fn frame_snapshot_subr_end_to_end_json_and_text() { fn primary_display_host_reports_quality_policy_frame_shader_suppression() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let mut host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), @@ -6798,6 +6820,7 @@ fn primary_display_host_routes_typed_terminal_requests_to_the_renderer() { let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded(); let shared_terminals = new_shared_terminals(); let host = PrimaryWindowDisplayHost { + frame_opacity: Default::default(), resources: Default::default(), system_fonts: Default::default(), tooltip_client: Default::default(), diff --git a/crates/neovm-core/src/emacs_core/display/display_host/mod.rs b/crates/neovm-core/src/emacs_core/display/display_host/mod.rs index 1c3bc3ae5e..c979255506 100644 --- a/crates/neovm-core/src/emacs_core/display/display_host/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/display_host/mod.rs @@ -385,6 +385,27 @@ pub trait DisplayHost { ) -> Result<(), String> { Ok(()) } + /// Apply each accepted legacy alpha operation synchronously, independently + /// of replaceable scene transport. Negative components mean retain. + fn set_gui_frame_alpha( + &mut self, + _frame: crate::window::FrameId, + _alpha: [f32; 2], + _limit: f32, + ) -> Result<(), String> { + Ok(()) + } + fn set_gui_frame_focus_redirects( + &mut self, + _redirects: Vec<(crate::window::FrameId, Option)>, + ) -> Result<(), String> { + Ok(()) + } + fn retire_gui_frame_alpha(&mut self, _frame: crate::window::FrameId) -> Result<(), String> { + Ok(()) + } + fn set_gui_frame_alpha_lower_limit(&mut self, _limit: f32) {} + fn opening_gui_frame_pending(&self) -> bool { false } diff --git a/crates/neovm-core/src/emacs_core/display/frame/mod.rs b/crates/neovm-core/src/emacs_core/display/frame/mod.rs index 6b848a7af6..5863172490 100644 --- a/crates/neovm-core/src/emacs_core/display/frame/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/frame/mod.rs @@ -98,6 +98,45 @@ pub(crate) fn builtin_frame_ancestor_p( )) } +pub(crate) fn sync_gui_frame_focus_redirects(eval: &mut super::eval::Context) -> EvalResult { + let redirects = eval + .frames + .frame_list() + .into_iter() + .filter_map(|id| { + let frame = eval.frames.get(id)?; + frame + .effective_window_system() + .is_some() + .then_some((id, frame.focus_frame.as_frame_id().map(FrameId))) + }) + .collect(); + if let Some(host) = eval.display_host.as_mut() { + host.set_gui_frame_focus_redirects(redirects) + .map_err(|message| signal("error", vec![Value::string(message)]))?; + } + Ok(Value::NIL) +} + +pub(crate) fn sync_gui_frame_alpha(eval: &mut super::eval::Context, frame: FrameId) -> EvalResult { + let alpha = eval + .frames + .get(frame) + .map(|frame| frame.frame_alpha) + .unwrap_or([-1.0; 2]); + let limit = crate::window::frame_alpha::lower_limit( + eval.obarray() + .symbol_value("frame-alpha-lower-limit") + .copied() + .unwrap_or(Value::fixnum(20)), + ); + if let Some(host) = eval.display_host.as_mut() { + host.set_gui_frame_alpha(frame, alpha, limit) + .map_err(|message| signal("error", vec![Value::string(message)]))?; + } + Ok(Value::NIL) +} + /// `(redirect-frame-focus FRAME FOCUS-FRAME)` -> nil. pub(crate) fn builtin_redirect_frame_focus( eval: &mut super::eval::Context, @@ -131,7 +170,7 @@ pub(crate) fn builtin_redirect_frame_focus( .get_mut(fid) .ok_or_else(|| signal("error", vec![Value::string("Frame not found")]))?; frame.focus_frame = focus_frame; - Ok(Value::NIL) + sync_gui_frame_focus_redirects(eval) } /// `(iconify-frame &optional FRAME)` -> nil. @@ -285,6 +324,7 @@ pub(crate) fn builtin_make_frame_invisible( } sync_selected_window_buffer_in_state(&eval.frames, &mut eval.buffers, fallback); eval.sync_keyboard_terminal_owner(); + sync_gui_frame_focus_redirects(eval)?; } } } @@ -1681,6 +1721,30 @@ pub(crate) fn builtin_modify_frame_parameters( } } _ => match FrameParamKey::from_symbol_id(key) { + FrameParamKey::Known( + param @ (FrameParam::Alpha | FrameParam::AlphaBackground), + ) => { + let gui = eval + .frames + .get(fid) + .is_some_and(|frame| frame.effective_window_system().is_some()); + if let Some(frame) = eval.frames.get_mut(fid) { + frame.set_known_parameter(param, pair_cdr); + } + if gui { + if param == FrameParam::Alpha { + crate::window::frame_alpha::pair(pair_cdr)?; + sync_gui_frame_alpha(eval, fid)?; + } else { + crate::window::frame_alpha::component(pair_cdr, 1.0)?; + // Background opacity is carried by layout, not + // the native whole-frame alpha command. Paint + // setters must defeat an otherwise idle skip, + // including changes to unselected children. + eval.invalidate_redisplay(); + } + } + } FrameParamKey::Known(FrameParam::Name) => { if let Some(name) = frame_name_parameter_value(&pair_cdr) { let is_tty = eval diff --git a/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs b/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs index e690f5e37a..05c6745b80 100644 --- a/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs @@ -5061,6 +5061,7 @@ pub(crate) fn select_window( ) }; if frame_changed { + super::frame::sync_gui_frame_focus_redirects(eval)?; eval.sync_keyboard_terminal_owner(); } if selection_changed { @@ -7058,6 +7059,17 @@ pub(crate) fn builtin_x_create_frame( } // GNU gui_display_get_arg resolves frame alist, default-frame-alist, // then the display resource. Keep explicit nil distinct from absence. + let explicit = parse_gui_frame_params(args.first()); + let defaults = eval.eval_symbol_by_id(intern("default-frame-alist")).ok(); + let defaults = parse_gui_frame_params(defaults.as_ref()); + for name in ["alpha", "alpha-background"] { + let key = intern(name); + if !explicit.all.contains_key(&key) + && let Some(value) = defaults.all.get(&key) + { + args[0] = Value::cons(Value::cons(Value::symbol(name), *value), args[0]); + } + } let font_key = intern("font"); if !parse_gui_frame_params(args.first()) .all @@ -7098,6 +7110,12 @@ pub(crate) fn builtin_x_create_frame( args, ); eval.sync_keyboard_terminal_owner(); + if let Ok(value) = &result + && let Some(id) = value.as_frame_id() + { + super::frame::sync_gui_frame_alpha(eval, FrameId(id))?; + super::frame::sync_gui_frame_focus_redirects(eval)?; + } result } @@ -7110,6 +7128,17 @@ pub(crate) fn x_create_frame_impl( expect_args("x-create-frame", &args, 1)?; let parsed = parse_gui_frame_params(args.first()); + for (key, value) in &parsed.all { + match FrameParamKey::from_symbol_id(*key) { + FrameParamKey::Known(FrameParam::Alpha) => { + crate::window::frame_alpha::pair(*value)?; + } + FrameParamKey::Known(FrameParam::AlphaBackground) => { + crate::window::frame_alpha::component(*value, 1.0)?; + } + _ => {} + } + } tracing::debug!( "x_create_frame_impl: display_host_available={} params={:?}", display_host.is_some(), @@ -7551,6 +7580,7 @@ pub(crate) fn delete_frame_owned( sync_selected_window_buffer_in_state(&eval.frames, &mut eval.buffers, replacement); } } + super::frame::sync_gui_frame_focus_redirects(eval)?; match eval.frames.delete_frame(fid) { FrameDeletion::NotFound => { return Err(signal("error", vec![Value::string("Cannot delete frame")])); @@ -7568,6 +7598,8 @@ pub(crate) fn delete_frame_owned( } => {} } if let Some(host) = eval.display_host.as_mut() { + host.retire_gui_frame_alpha(fid) + .map_err(|message| signal("error", vec![Value::string(message)]))?; if was_gui_child_frame { tracing::info!( frame_id = fid.0, diff --git a/crates/neovm-core/src/emacs_core/display/window_cmds/tests/mod.rs b/crates/neovm-core/src/emacs_core/display/window_cmds/tests/mod.rs index 493aedc9a1..b9beab0917 100644 --- a/crates/neovm-core/src/emacs_core/display/window_cmds/tests/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/window_cmds/tests/mod.rs @@ -24,6 +24,90 @@ mod body_geometry_test; mod frame_position_test; mod frame_resize_test; +#[test] +fn alpha_lower_limit_variable_projects_without_replaying_alpha_policy() { + let mut ev = Context::new(); + let host = RecordingDisplayHost::new(); + let limits = host.alpha_limits.clone(); + let operations = host.alpha_operations.clone(); + ev.set_display_host(Box::new(host)); + let results = ev.eval_str_each("(setq frame-alpha-lower-limit 20) (let ((frame-alpha-lower-limit 60)) (setq frame-alpha-lower-limit 70)) (defvaralias 'alpha-limit-alias 'frame-alpha-lower-limit) (set 'alpha-limit-alias 40)"); + assert!(results.iter().all(Result::is_ok), "{results:?}"); + assert_eq!(*limits.borrow(), vec![0.2, 0.6, 0.7, 0.2, 0.2, 0.4]); + assert!(operations.borrow().is_empty()); +} + +#[test] +fn accepted_root_and_child_alpha_operations_survive_numeric_then_nil_without_layout() { + let mut ev = Context::new(); + let buf = ev.buffers.create_buffer("*scratch*"); + ev.buffers.set_current(buf); + let root = ev.frames.create_frame("Root", 800, 600, buf); + ev.frames + .get_mut(root) + .unwrap() + .set_window_system(Some(Value::symbol("neo"))); + let host = RecordingDisplayHost::new(); + let operations = host.alpha_operations.clone(); + ev.set_display_host(Box::new(host)); + let values = ev.eval_str_each("(let* ((root (selected-frame)) (child (x-create-frame (list (cons 'parent-frame root))))) (modify-frame-parameters root '((alpha . 50))) (modify-frame-parameters root '((alpha . nil))) (modify-frame-parameters child '((alpha . 50))) (modify-frame-parameters child '((alpha . nil))) (list (frame-parameter root 'alpha) (frame-parameter child 'alpha)))"); + assert_eq!(format_eval_result(&values[0]), "OK (nil nil)"); + let operations = operations.borrow(); + assert_eq!(operations.len(), 5); // child creation, then both accepted setters per frame + assert_eq!(operations[1].0, root); + assert_eq!(operations[1].1, [0.5; 2]); + assert_eq!(operations[2].1, [-1.0; 2]); + assert_ne!(operations[3].0, root); + assert_eq!(operations[3].1, [0.5; 2]); + assert_eq!(operations[4].1, [-1.0; 2]); +} + +#[test] +fn x_create_frame_resolves_alpha_defaults_and_explicit_nil() { + for (parameter, value, explicit, expected, background) in [ + ("alpha", "50", "nil", [0.5, 0.5], 1.0), + ("alpha", "(80 . 40)", "nil", [0.8, 0.4], 1.0), + ("alpha-background", "50", "nil", [-1.0, -1.0], 0.5), + ("alpha", "50", "'((alpha . nil))", [-1.0, -1.0], 1.0), + ("alpha", "invalid", "'((alpha . nil))", [-1.0, -1.0], 1.0), + ( + "alpha-background", + "invalid", + "'((alpha-background . nil))", + [-1.0, -1.0], + 1.0, + ), + ( + "alpha-background", + "50", + "'((alpha-background . nil))", + [-1.0, -1.0], + 1.0, + ), + ] { + let mut ev = Context::new(); + let buf = ev.buffers.create_buffer("*scratch*"); + ev.buffers.set_current(buf); + ev.frames.create_frame("F1", 800, 600, buf); + ev.set_display_host(Box::new(RecordingDisplayHost::new())); + let src = format!( + "(let ((default-frame-alist '(({parameter} . {value})))) (x-create-frame {explicit}))" + ); + let result = ev.eval_str_each(&src); + let frame_value = result[0].as_ref().expect("valid inherited/default alpha"); + let id = crate::window::FrameId(frame_value.as_frame_id().expect("created frame")); + let frame = ev.frames.get(id).unwrap(); + assert_eq!(frame.frame_alpha, expected, "{src}"); + assert_eq!(frame.background_alpha, background, "{src}"); + } + for parameter in ["alpha", "alpha-background"] { + let out = eval_with_gui_frame(&format!( + "(let ((default-frame-alist '(({parameter} . invalid)))) (condition-case err (x-create-frame nil) (error (car err))))" + )); + assert_eq!(out, vec!["OK wrong-type-argument"], "{parameter}"); + } +} + #[test] fn frame_scale_factor_reads_the_selected_frames_presented_device_scale() { let mut eval = Context::new(); @@ -189,6 +273,8 @@ fn active_minibuffer_window_tracks_live_minibuffer_state() { #[derive(Clone, Default)] struct RecordingDisplayHost { + alpha_operations: Rc>>, + alpha_limits: Rc>>, realized: Rc>>, resized: Rc>>, destroyed_gui_frames: Rc>>, @@ -262,6 +348,20 @@ fn remapped_mono_font_metrics() -> ResolvedFrameFont { } impl DisplayHost for RecordingDisplayHost { + fn set_gui_frame_alpha_lower_limit(&mut self, limit: f32) { + self.alpha_limits.borrow_mut().push(limit); + } + fn set_gui_frame_alpha( + &mut self, + frame: crate::window::FrameId, + pair: [f32; 2], + limit: f32, + ) -> Result<(), String> { + self.alpha_operations + .borrow_mut() + .push((frame, pair, limit)); + Ok(()) + } fn realize_gui_frame(&mut self, request: GuiFrameHostRequest) -> Result<(), String> { self.realized.borrow_mut().push(request); Ok(()) diff --git a/crates/neovm-core/src/emacs_core/lisp/native/builtins/hooks.rs b/crates/neovm-core/src/emacs_core/lisp/native/builtins/hooks.rs index 5ba8601ddb..5302c3e9c3 100644 --- a/crates/neovm-core/src/emacs_core/lisp/native/builtins/hooks.rs +++ b/crates/neovm-core/src/emacs_core/lisp/native/builtins/hooks.rs @@ -106,7 +106,7 @@ fn select_frame_window_for_hook_context( eval: &mut super::eval::Context, frame_id: crate::window::FrameId, window_id: crate::window::WindowId, -) { +) -> EvalResult { let _ = eval.frames.select_frame(frame_id); eval.sync_keyboard_terminal_owner(); if let Some(frame) = eval.frames.get_mut(frame_id) { @@ -115,9 +115,13 @@ fn select_frame_window_for_hook_context( if let Some(buffer_id) = window_buffer_id_in_state(eval, frame_id, window_id) { let _ = eval.switch_current_buffer(buffer_id); } + crate::emacs_core::frame::sync_gui_frame_focus_redirects(eval) } -fn restore_hook_caller_context(eval: &mut super::eval::Context, saved: HookCallerContextState) { +fn restore_hook_caller_context( + eval: &mut super::eval::Context, + saved: HookCallerContextState, +) -> EvalResult { if let Some(frame_id) = saved .selected_frame_id .filter(|frame_id| eval.frames.get(*frame_id).is_some()) @@ -133,6 +137,7 @@ fn restore_hook_caller_context(eval: &mut super::eval::Context, saved: HookCalle if let Some(buffer_id) = saved.current_buffer_id { eval.restore_current_buffer_if_live(buffer_id); } + crate::emacs_core::frame::sync_gui_frame_focus_redirects(eval) } #[derive(Clone, Copy)] @@ -245,7 +250,7 @@ fn run_window_local_hook_values( if !has_local_hook { continue; } - select_frame_window_for_hook_context(eval, frame_id, *window_id); + select_frame_window_for_hook_context(eval, frame_id, *window_id)?; let Some(local_hook_value) = eval .buffers .current_buffer() @@ -263,8 +268,9 @@ fn run_window_local_hook_values( } Ok(Value::NIL) })(); - restore_hook_caller_context(eval, saved); - result + let restore_result = restore_hook_caller_context(eval, saved); + result?; + restore_result } fn run_window_default_hook_value( @@ -289,10 +295,11 @@ fn run_window_default_hook_value( let result = (|| -> EvalResult { let selected_window = eval.frames.get(frame_id).map(|frame| frame.selected_window); if let Some(selected_window) = selected_window { - select_frame_window_for_hook_context(eval, frame_id, selected_window); + select_frame_window_for_hook_context(eval, frame_id, selected_window)?; } else { let _ = eval.frames.select_frame(frame_id); eval.sync_keyboard_terminal_owner(); + crate::emacs_core::frame::sync_gui_frame_focus_redirects(eval)?; } let _ = hook_runtime::safe_run_hook_value( eval, @@ -303,8 +310,9 @@ fn run_window_default_hook_value( )?; Ok(Value::NIL) })(); - restore_hook_caller_context(eval, saved); - result + let restore_result = restore_hook_caller_context(eval, saved); + result?; + restore_result } pub(crate) fn run_redisplay_window_change_hooks(eval: &mut super::eval::Context) -> EvalResult { @@ -1867,6 +1875,7 @@ pub(crate) fn set_window_configuration_with_options( if let Some(frame_id) = frame_to_select { let _ = eval.frames.select_frame(frame_id); } + crate::emacs_core::frame::sync_gui_frame_focus_redirects(eval)?; } // GNU `Fset_window_configuration` marks the frame for redisplay with @@ -1938,7 +1947,7 @@ pub(crate) fn builtin_run_window_configuration_change_hook( eval.push_specpdl_root(global_hook_value); let result = (|| -> EvalResult { - select_frame_window_for_hook_context(eval, frame_id, selected_window); + select_frame_window_for_hook_context(eval, frame_id, selected_window)?; for window_id in &window_ids { let Some(buffer_id) = window_buffer_id_in_state(eval, frame_id, *window_id) else { continue; @@ -1951,7 +1960,7 @@ pub(crate) fn builtin_run_window_configuration_change_hook( if !has_local_hook { continue; } - select_frame_window_for_hook_context(eval, frame_id, *window_id); + select_frame_window_for_hook_context(eval, frame_id, *window_id)?; let Some(local_hook_value) = eval .buffers .current_buffer() @@ -1960,15 +1969,16 @@ pub(crate) fn builtin_run_window_configuration_change_hook( continue; }; let _ = hook_runtime::run_hook_value(eval, hook_sym, local_hook_value, &[], false)?; - select_frame_window_for_hook_context(eval, frame_id, selected_window); + select_frame_window_for_hook_context(eval, frame_id, selected_window)?; } let _ = hook_runtime::run_hook_value(eval, hook_sym, global_hook_value, &[], false)?; Ok(Value::NIL) })(); eval.restore_specpdl_roots(root_scope); - restore_hook_caller_context(eval, saved); - result + let restore_result = restore_hook_caller_context(eval, saved); + result?; + restore_result } pub(crate) fn run_gnu_committed_scroll_functions( diff --git a/crates/neovm-core/src/emacs_core/lisp/reader/mod.rs b/crates/neovm-core/src/emacs_core/lisp/reader/mod.rs index 294ae62463..c5ec39095c 100644 --- a/crates/neovm-core/src/emacs_core/lisp/reader/mod.rs +++ b/crates/neovm-core/src/emacs_core/lisp/reader/mod.rs @@ -943,6 +943,7 @@ pub(crate) fn unwind_minibuffer_session( window_restore: MinibufferWindowRestoreEffect::NoBufferRestored, } }; + let redirect_result = super::frame::sync_gui_frame_focus_redirects(shared); teardown_outcome.window_restore.apply(shared); let inactive_mode_result = teardown_outcome.inactive_mode_result; @@ -953,14 +954,16 @@ pub(crate) fn unwind_minibuffer_session( } // A sizing/inactive-mode transfer skips the remaining read_minibuf_unwind // statements. Separate configuration and minibuffer restoration still run. - let selection_record_result = - if super::eval::gnu_redisplay_hooks_enabled() && inactive_mode_result.is_err() { - Ok(Value::NIL) - } else { - restored_calling_selection - .map(|active| record_restored_calling_window_selection(shared, active)) - .unwrap_or(Ok(Value::NIL)) - }; + // A failed focus-redirect sync likewise skips the selection record. + let selection_record_result = if redirect_result.is_err() + || (super::eval::gnu_redisplay_hooks_enabled() && inactive_mode_result.is_err()) + { + Ok(Value::NIL) + } else { + restored_calling_selection + .map(|active| record_restored_calling_window_selection(shared, active)) + .unwrap_or(Ok(Value::NIL)) + }; shared.obarray.set_symbol_value( "minibuffer-depth", Value::fixnum(shared.minibuffers.depth() as i64), @@ -991,6 +994,7 @@ pub(crate) fn unwind_minibuffer_session( exit_hook_result?; inactive_mode_result?; + redirect_result?; selection_record_result?; Ok(Value::NIL) } @@ -1842,7 +1846,7 @@ impl MinibufferInvocationRestoration { self.windows.record(eval); } - fn select_calling_frame(&self, eval: &mut super::eval::Context) { + fn select_calling_frame(&self, eval: &mut super::eval::Context) -> EvalResult { // GNU `read_minibuf` explicitly reselects the invoking frame after // `unbind_to` has restored the owner/caller configuration stack. The // restore options intentionally keep the then-current selected frame, @@ -1853,6 +1857,7 @@ impl MinibufferInvocationRestoration { minibuffer_redisplay::publish_frame_switch(eval, calling_frame.0); let _ = eval.frames.select_frame(calling_frame.0); } + super::frame::sync_gui_frame_focus_redirects(eval) } } @@ -2260,6 +2265,7 @@ fn finish_read_from_minibuffer_in_vm_runtime_interactive( state: Box::new(session_unwind), }, ); + super::frame::sync_gui_frame_focus_redirects(shared)?; if let Some(active_window_state) = active_window_state { record_active_minibuffer_selection(shared, active_window_state, minibuf_id)?; } @@ -2388,11 +2394,12 @@ fn finish_read_from_minibuffer_in_vm_runtime_interactive( // This is deliberately between the inner lifecycle scope and history: // GNU restores both configurations, then reselects the caller, then // calls `add-to-history` in the restored buffer-local environment. - restoration.select_calling_frame(shared); + let redirect_result = restoration.select_calling_frame(shared); // `with_unwind_scope` roots the tagged result while exit hooks and // window restoration allocate, so string properties cannot retain // otherwise-unreachable Lisp objects through an untraced Rust value. let result_value = lifecycle_result?; + redirect_result?; let result_text = result_value .as_lisp_string() .expect("an accepted minibuffer command must return its contents") diff --git a/crates/neovm-core/src/emacs_core/lisp/reader/tests/mod.rs b/crates/neovm-core/src/emacs_core/lisp/reader/tests/mod.rs index 9e30cf8df5..fc5e98afc2 100644 --- a/crates/neovm-core/src/emacs_core/lisp/reader/tests/mod.rs +++ b/crates/neovm-core/src/emacs_core/lisp/reader/tests/mod.rs @@ -1637,6 +1637,154 @@ fn read_from_minibuffer_restores_calling_frame_after_frame_switch() { ); } +type FocusProjection = Vec<(crate::window::FrameId, Option)>; + +#[derive(Clone, Default)] +struct MinibufferFocusHost { + redirects: Arc>>, +} + +impl DisplayHost for MinibufferFocusHost { + fn realize_gui_frame(&mut self, _request: GuiFrameHostRequest) -> Result<(), String> { + Ok(()) + } + + fn resize_gui_frame(&mut self, _request: GuiFrameHostRequest) -> Result<(), String> { + Ok(()) + } + + fn set_gui_frame_focus_redirects( + &mut self, + redirects: Vec<(crate::window::FrameId, Option)>, + ) -> Result<(), String> { + self.redirects.lock().unwrap().push(redirects); + Ok(()) + } +} + +#[test] +fn shared_child_minibuffer_publishes_focus_on_entry_and_unwind() { + for abort in [false, true] { + let mut ev = Context::new(); + let native = crate::emacs_core::window_cmds::ensure_selected_frame_id(&mut ev); + ev.frames + .get_mut(native) + .unwrap() + .set_window_system(Some(Value::symbol("neo"))); + let host = MinibufferFocusHost::default(); + let projections = host.redirects.clone(); + ev.set_display_host(Box::new(host)); + let setup = ev.eval_str_each( + r#"(setq test-owner (x-create-frame '((alpha . (90 30))))) + (setq test-child (x-create-frame + (list (cons 'parent-frame test-owner) + (cons 'minibuffer (minibuffer-window test-owner)) + '(alpha . (80 40))))) + (select-frame test-child)"#, + ); + // Explicit IDs below avoid relying on frame-list ordering. + assert!(setup.iter().all(Result::is_ok), "{setup:?}"); + let owner = crate::window::FrameId( + ev.obarray + .symbol_value("test-owner") + .unwrap() + .as_frame_id() + .unwrap(), + ); + let child = crate::window::FrameId( + ev.obarray + .symbol_value("test-child") + .unwrap() + .as_frame_id() + .unwrap(), + ); + crate::emacs_core::frame::builtin_redirect_frame_focus( + &mut ev, + vec![Value::make_frame(native.0), Value::make_frame(child.0)], + ) + .unwrap(); + assert_eq!( + ev.frames + .find_window_frame_id(ev.frames.get(child).unwrap().minibuffer_window.unwrap()), + Some(owner) + ); + let (tx, rx) = crossbeam_channel::unbounded(); + tx.send(crate::keyboard::InputEvent::key_press( + crate::keyboard::KeyEvent::char('\r'), + )) + .unwrap(); + ev.input_rx = Some(rx); + // Bare Context has no simple.el command dispatcher. Keep the real + // reader/command loop and substitute only its Lisp dispatch boundary. + ev.eval_str( + "(fset 'command-execute (lambda (command &rest ignored) (call-interactively command)))", + ) + .unwrap(); + let source = "(progn (setq minibuffer-setup-hook nil minibuffer-exit-hook nil) (setq test-read-map (make-sparse-keymap)) (define-key test-read-map \"\\r\" (lambda () (interactive) (throw 'exit nil))))"; + ev.eval_str(source).unwrap(); + let mut entered = false; + let map = *ev.obarray.symbol_value("test-read-map").unwrap(); + let result = finish_read_from_minibuffer_in_eval_with_setup( + &mut ev, + &[Value::string("Prompt: "), Value::NIL, map], + |eval| { + entered = true; + assert_eq!(eval.frames.selected_frame().unwrap().id, owner); + let published = projections.lock().unwrap(); + let highlight = published + .last() + .unwrap() + .iter() + .find(|(id, _)| *id == native) + .unwrap() + .1; + assert_eq!(highlight, Some(owner), "native redirect must precede setup"); + // At the DisplayHost boundary native focus remains A: the projected + // target selects active C and inactive B, not keyboard focus moves. + assert_eq!( + eval.frames.get(owner).unwrap().frame_alpha + [usize::from(highlight != Some(owner))], + 0.9 + ); + assert_eq!( + eval.frames.get(child).unwrap().frame_alpha + [usize::from(highlight != Some(child))], + 0.4 + ); + if abort { + Err(signal("error", vec![Value::string("setup abort")])) + } else { + Ok(Value::NIL) + } + }, + ); + assert!(entered); + assert_eq!(result.is_err(), abort, "{result:?}"); + assert_eq!(ev.frames.selected_frame().unwrap().id, child); + let published = projections.lock().unwrap(); + let highlight = published + .last() + .unwrap() + .iter() + .find(|(id, _)| *id == native) + .unwrap() + .1; + assert_eq!( + highlight, + Some(child), + "native redirect must be restored even on abort" + ); + assert_eq!( + ev.frames.get(owner).unwrap().frame_alpha[usize::from(highlight != Some(owner))], + 0.3 + ); + assert_eq!( + ev.frames.get(child).unwrap().frame_alpha[usize::from(highlight != Some(child))], + 0.8 + ); + } +} + #[test] fn read_from_minibuffer_uses_and_restores_a_separate_minibuffer_owner_frame() { crate::test_utils::init_test_tracing(); diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs b/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs index e842211b86..775daae767 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs @@ -2090,6 +2090,7 @@ fn install_core_eval_symbols(obarray: &mut Obarray, reset_runtime_values: bool) print_symbols_bare_symbol, max_lisp_eval_depth_symbol(), buffer_undo_list_symbol(), + intern("frame-alpha-lower-limit"), ] { obarray.mark_runtime_projected_id(projected); } @@ -4614,6 +4615,11 @@ impl Context { self.symbols_with_pos_enabled = value.is_truthy(); } else if sym_id == self.print_symbols_bare_symbol { self.print_symbols_bare = value.is_truthy(); + } else if sym_id == intern("frame-alpha-lower-limit") { + let limit = crate::window::frame_alpha::lower_limit(value); + if let Some(host) = self.display_host.as_mut() { + host.set_gui_frame_alpha_lower_limit(limit); + } } else if sym_id == max_lisp_eval_depth_symbol() && let Some(depth) = value.as_fixnum() { diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/runtime_projection.rs b/crates/neovm-core/src/emacs_core/runtime/eval/runtime_projection.rs index 94b5d4b561..4cb53a615c 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/runtime_projection.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/runtime_projection.rs @@ -36,6 +36,7 @@ pub(super) fn runtime_projection_mask_for(core_mirrors: &[SymId]) -> Box<[u64]> let mut ids: Vec = core_mirrors.to_vec(); ids.extend([ max_lisp_eval_depth_symbol(), + intern("frame-alpha-lower-limit"), input_decode_map_symbol(), local_function_key_map_symbol(), ]); @@ -95,6 +96,7 @@ impl Context { || resolved == self.symbols_with_pos_enabled_symbol || resolved == self.print_symbols_bare_symbol || resolved == max_lisp_eval_depth_symbol() + || resolved == intern("frame-alpha-lower-limit") || resolved == input_decode_map_symbol() || resolved == local_function_key_map_symbol() || self.is_gc_runtime_setting_symbol(resolved) diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/tests/idle_redisplay.rs b/crates/neovm-core/src/emacs_core/runtime/eval/tests/idle_redisplay.rs index ab6d9c460d..bdb1081d4b 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/tests/idle_redisplay.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/tests/idle_redisplay.rs @@ -150,3 +150,97 @@ fn category_symbol_writes_invalidate_idle_redisplay() { "event metadata is not a layout dependency" ); } + +/// Frame paint state is not part of the idle signature. A background-only +/// setter must therefore schedule layout even without point or input changes. +#[test] +fn background_alpha_repaints_an_idle_gui_frame() { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + crate::emacs_core::xdisp::set_redisplay_idle_skip_for_test(None); + } + } + let _reset = Reset; + crate::emacs_core::xdisp::set_redisplay_idle_skip_for_test(Some(true)); + let (mut eval, layouts) = idle_context(); + let frame = eval.frames.selected_frame().expect("frame").id; + eval.frames + .get_mut(frame) + .unwrap() + .set_window_system(Some(Value::symbol("neo"))); + eval.eval_str("(redisplay t)").unwrap(); + eval.eval_str("(redisplay t)").unwrap(); + assert_eq!(layouts.get(), 1, "the unchanged frame is positively idle"); + eval.eval_str("(condition-case nil (modify-frame-parameters nil '((alpha-background . bad))) (error nil))").unwrap(); + assert_eq!(eval.frames.get(frame).unwrap().background_alpha, 1.0); + assert_eq!( + eval.frames + .get(frame) + .unwrap() + .parameter("alpha-background"), + Some(Value::symbol("bad")) + ); + eval.eval_str("(redisplay t)").unwrap(); + assert_eq!( + layouts.get(), + 1, + "a rejected value does not dirty accepted paint" + ); + eval.eval_str("(modify-frame-parameters nil '((alpha-background . 50)))") + .unwrap(); + assert_eq!(eval.frames.get(frame).unwrap().background_alpha, 0.5); + eval.eval_str("(redisplay t)").unwrap(); + assert_eq!( + layouts.get(), + 2, + "accepted background-only update must repaint" + ); + eval.eval_str("(redisplay t)").unwrap(); + assert_eq!(layouts.get(), 2, "no continuous forced repaint"); + eval.eval_str("(modify-frame-parameters nil '((alpha-background . nil)))") + .unwrap(); + assert_eq!(eval.frames.get(frame).unwrap().background_alpha, 1.0); + eval.eval_str("(redisplay t)").unwrap(); + assert_eq!(layouts.get(), 3, "nil restores opaque paint"); +} + +#[test] +fn background_alpha_repaints_an_unselected_idle_child() { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + crate::emacs_core::xdisp::set_redisplay_idle_skip_for_test(None); + } + } + let _reset = Reset; + crate::emacs_core::xdisp::set_redisplay_idle_skip_for_test(Some(true)); + let (mut eval, layouts) = idle_context(); + let selected = eval.frames.selected_frame().expect("selected").id; + let buffer = eval.buffers.current_buffer_id().unwrap(); + let child = eval.frames.create_frame("idle-child", 200, 160, buffer); + for id in [selected, child] { + eval.frames + .get_mut(id) + .unwrap() + .set_window_system(Some(Value::symbol("neo"))); + } + eval.frames.get_mut(child).unwrap().parent_frame = Value::make_frame(selected.0); + eval.frames.select_frame(selected); + eval.obarray_mut() + .set_symbol_value("idle-alpha-child", Value::make_frame(child.0)); + eval.eval_str("(redisplay t)").unwrap(); + eval.eval_str("(redisplay t)").unwrap(); + assert_eq!(layouts.get(), 1); + eval.eval_str("(modify-frame-parameters idle-alpha-child '((alpha-background . 25)))") + .unwrap(); + assert_eq!( + eval.frames.selected_frame().map(|frame| frame.id), + Some(selected) + ); + assert_eq!(eval.frames.get(child).unwrap().background_alpha, 0.25); + eval.eval_str("(redisplay t)").unwrap(); + assert_eq!(layouts.get(), 2, "unselected child paint must reach layout"); + eval.eval_str("(redisplay t)").unwrap(); + assert_eq!(layouts.get(), 2); +} diff --git a/crates/neovm-core/src/window/frame_alpha.rs b/crates/neovm-core/src/window/frame_alpha.rs new file mode 100644 index 0000000000..6164380464 --- /dev/null +++ b/crates/neovm-core/src/window/frame_alpha.rs @@ -0,0 +1,67 @@ +//! GNU frame opacity decoding. Lisp parameters retain their original values. +use crate::emacs_core::error::{Flow, signal}; +use crate::emacs_core::{Value, ValueKind}; + +/// Decode one GNU opacity component: integers are percentages, floats fractions. +pub fn component(value: Value, nil: f32) -> Result { + if value.is_nil() { + return Ok(nil); + } + let (number, maximum) = match value.kind() { + ValueKind::Fixnum(n) => (n as f64, 100.0), + ValueKind::Float => (value.as_float().unwrap(), 1.0), + _ => { + return Err(signal( + "wrong-type-argument", + vec![Value::symbol("numberp"), value], + )); + } + }; + if !(0.0..=maximum).contains(&number) { + return Err(signal( + "args-out-of-range", + vec![ + if maximum == 100.0 { + Value::fixnum(0) + } else { + Value::make_float(0.0) + }, + if maximum == 100.0 { + Value::fixnum(100) + } else { + Value::make_float(1.0) + }, + ], + )); + } + Ok((number / maximum) as f32) +} + +/// Active/inactive pair, including GNU's nil (leave opacity unchanged) sentinel. +pub fn pair(mut value: Value) -> Result<[f32; 2], Flow> { + let mut result = [-1.0; 2]; + for item in &mut result { + let current = if value.is_cons() { + let car = value.cons_car(); + value = value.cons_cdr(); + car + } else { + value + }; + *item = component(current, -1.0)?; + } + Ok(result) +} + +/// Backend lower limit. Unlike `alpha`, `alpha-background` has no lower limit. +pub fn lower_limit(value: Value) -> f32 { + match value.kind() { + ValueKind::Fixnum(n) => n as f32 / 100.0, + ValueKind::Float => value.as_float().unwrap() as f32, + _ => 1.0, + } +} + +#[cfg(test)] +#[path = "frame_alpha/tests/mod.rs"] +mod tests; diff --git a/crates/neovm-core/src/window/frame_alpha/tests/mod.rs b/crates/neovm-core/src/window/frame_alpha/tests/mod.rs new file mode 100644 index 0000000000..3bc73fb327 --- /dev/null +++ b/crates/neovm-core/src/window/frame_alpha/tests/mod.rs @@ -0,0 +1,72 @@ +use super::*; +#[test] +fn gnu_frame_alpha_components() { + assert_eq!(component(Value::fixnum(1), 1.0).unwrap(), 0.01); + assert_eq!(component(Value::make_float(1.0), 1.0).unwrap(), 1.0); + assert_eq!(component(Value::NIL, 1.0).unwrap(), 1.0); + for v in [ + Value::fixnum(-1), + Value::fixnum(101), + Value::make_float(f64::NAN), + Value::make_float(f64::INFINITY), + Value::symbol("bad"), + ] { + assert!(component(v, 1.0).is_err()); + } + assert!(component(Value::list(vec![Value::fixnum(50)]), 1.0).is_err()); +} +#[test] +fn gnu_frame_alpha_raw_and_accepted_state_are_distinct() { + use crate::buffer::BufferId; + use crate::window::{Frame, FrameId, FrameParam, Rect, Window, WindowId}; + let root = Window::new_leaf(WindowId(1), BufferId(0), Rect::new(0.0, 0.0, 80.0, 60.0)); + let mut frame = Frame::new( + FrameId(1), + Value::string("alpha"), + 1, + 80, + 60, + root, + WindowId(2), + ); + frame.set_known_parameter(FrameParam::AlphaBackground, Value::fixnum(50)); + frame.set_known_parameter(FrameParam::AlphaBackground, Value::fixnum(101)); + assert_eq!(frame.background_alpha, 0.5); + assert_eq!( + frame.known_parameter(FrameParam::AlphaBackground), + Some(Value::fixnum(101)) + ); + frame.set_known_parameter(FrameParam::Alpha, Value::fixnum(80)); + frame.set_known_parameter( + FrameParam::Alpha, + Value::list(vec![Value::fixnum(30), Value::fixnum(101)]), + ); + assert_eq!(frame.frame_alpha, [0.8, 0.8]); + frame.set_known_parameter(FrameParam::AlphaBackground, Value::NIL); + assert_eq!(frame.background_alpha, 1.0); + frame.set_known_parameter(FrameParam::Alpha, Value::NIL); + assert_eq!(frame.frame_alpha, [-1.0; 2]); +} + +#[test] +fn gnu_frame_alpha_pair() { + assert_eq!(pair(Value::fixnum(50)).unwrap(), [0.5, 0.5]); + assert_eq!( + pair(Value::cons(Value::fixnum(80), Value::fixnum(60))).unwrap(), + [0.8, 0.6] + ); + assert_eq!( + pair(Value::list(vec![Value::fixnum(80)])).unwrap(), + [0.8, -1.0] + ); + assert_eq!( + pair(Value::list(vec![ + Value::fixnum(80), + Value::fixnum(60), + Value::symbol("ignored") + ])) + .unwrap(), + [0.8, 0.6] + ); + assert!(pair(Value::list(vec![Value::fixnum(80), Value::fixnum(101)])).is_err()); +} diff --git a/crates/neovm-core/src/window/mod.rs b/crates/neovm-core/src/window/mod.rs index 1f993bf355..a20f184db0 100644 --- a/crates/neovm-core/src/window/mod.rs +++ b/crates/neovm-core/src/window/mod.rs @@ -85,6 +85,8 @@ pub struct WindowId(pub u64); #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct FrameId(pub u64); +pub mod frame_alpha; + /// Whether a logical frame selection may retarget existing focus redirections. /// GNU `do_switch_frame` tracks explicit selections, but not input events. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -4072,6 +4074,10 @@ pub struct Frame { /// active display backend. Wiring the dispatch is tracked as /// audit Phase 6. pub parameters: HashMap, + /// Accepted GUI opacity, independent of the raw Lisp parameter alist. + /// GNU stores a new raw value before its handler can signal an error. + pub background_alpha: f32, + pub frame_alpha: [f32; 2], /// Whether the frame is visible, iconified, or invisible. pub visibility: FrameVisibility, /// Whether the menu / tab / tool bars are actually displayed and therefore @@ -4279,6 +4285,8 @@ impl Frame { params.insert(Value::symbol("minibuffer"), Value::T); params }, + background_alpha: 1.0, + frame_alpha: [-1.0; 2], visibility: FrameVisibility::Visible, // Set true only once an interactive frontend displays this frame. displays_chrome: false, @@ -4603,6 +4611,21 @@ impl Frame { } pub fn set_parameter(&mut self, key: Value, value: Value) -> Option { + // Only successful decoding changes the backend state. The raw value + // remains observable even when the GUI parameter handler later errors. + match key.as_symbol_id().and_then(FrameParam::from_symbol_id) { + Some(FrameParam::Alpha) => { + if let Ok(alpha) = frame_alpha::pair(value) { + self.frame_alpha = alpha; + } + } + Some(FrameParam::AlphaBackground) => { + if let Ok(alpha) = frame_alpha::component(value, 1.0) { + self.background_alpha = alpha; + } + } + _ => {} + } self.parameters.insert(key, value) } diff --git a/docs/testing/transparency-tests.txt b/docs/testing/transparency-tests.txt new file mode 100644 index 0000000000..eaec198ed0 --- /dev/null +++ b/docs/testing/transparency-tests.txt @@ -0,0 +1,178 @@ +cpu neovm-core emacs_core::eval::idle_redisplay_tests::a_forced_idle_redisplay_lays_out_a_change_made_after_the_last_layout +cpu neovm-core emacs_core::eval::idle_redisplay_tests::a_forced_idle_redisplay_lays_out_unless_the_idle_skip_is_on +cpu neovm-core emacs_core::eval::idle_redisplay_tests::an_idle_redisplay_skips_layout_but_runs_pre_redisplay_function +cpu neovm-core emacs_core::eval::idle_redisplay_tests::background_alpha_repaints_an_idle_gui_frame +cpu neovm-core emacs_core::eval::idle_redisplay_tests::background_alpha_repaints_an_unselected_idle_child +cpu neovm-core emacs_core::eval::idle_redisplay_tests::category_symbol_writes_invalidate_idle_redisplay +cpu neovm-core emacs_core::eval::idle_redisplay_tests::window_old_point_follows_its_marker_across_redisplays +cpu neovm-core emacs_core::eval::tests::native_unwind_scope_runs_lower_cleanups_after_a_cleanup_error +cpu neovm-core emacs_core::eval::tests::runtime_projection_mask_matches_the_comparison_chain +cpu neovm-core emacs_core::reader::tests::read_from_minibuffer_mode_error_unwinds_before_session_entry +cpu neovm-core emacs_core::reader::tests::read_from_minibuffer_setup_error_unwinds_the_complete_session +cpu neovm-core emacs_core::reader::tests::read_from_minibuffer_swallows_exit_hook_signals_after_cleanup +cpu neovm-core emacs_core::reader::tests::shared_child_minibuffer_publishes_focus_on_entry_and_unwind +cpu neovm-core emacs_core::window_cmds::tests::accepted_root_and_child_alpha_operations_survive_numeric_then_nil_without_layout +cpu neovm-core emacs_core::window_cmds::tests::alpha_lower_limit_variable_projects_without_replaying_alpha_policy +cpu neovm-core emacs_core::window_cmds::tests::x_create_frame_resolves_alpha_defaults_and_explicit_nil +gpu neomacs-renderer-wgpu fade_edges_test::dispatched_fade_edges_preserves_fractional_complementary_pictures +gpu neomacs-renderer-wgpu fade_edges_test::dispatched_fade_edges_preserves_opaque_complementary_pictures +gpu neomacs-renderer-wgpu fade_edges_test::dispatched_page_curl_keeps_ordered_source_over_occlusion +cpu neomacs-display-protocol glyph_matrix::tests::gnu_frame_alpha_survives_display_snapshot_materialization +gpu neomacs-renderer-wgpu opacity_test::background_alpha_keeps_text_opaque_through_crossfade +gpu neomacs-renderer-wgpu opacity_test::background_alpha_survives_identity_frame_post +gpu neomacs-renderer-wgpu opacity_test::child_background_alpha_and_lifecycle_opacity_apply_once +gpu neomacs-renderer-wgpu opacity_test::child_char_and_stretch_stipple_is_foreground_at_zero_background +gpu neomacs-renderer-wgpu opacity_test::colored_uncovered_child_fill_matches_square_and_rounded +gpu neomacs-renderer-wgpu opacity_test::dispatched_crossfade_and_slide_replace_the_previous_picture +gpu neomacs-renderer-wgpu opacity_test::dispatched_parallax_adds_overlapping_faded_pictures +gpu neomacs-renderer-wgpu opacity_test::dispatched_scale_zoom_preserves_overlap_opacity_and_outside_pixels +gpu neomacs-renderer-wgpu opacity_test::mandatory_child_picture_refusal_is_typed_and_recovers_after_lease_release +gpu neomacs-renderer-wgpu opacity_test::native_inset_clear_matches_completed_content_opacity +gpu neomacs-renderer-wgpu opacity_test::pane_previous_patches_interpolate_complete_colored_pictures +gpu neomacs-renderer-wgpu opacity_test::transition_postprocess_preserves_alpha_and_color_modulation +cpu neomacs-layout-engine output::builder::tests::bidi_restamps_terminals_from_visual_boxed_adjacency +cpu neomacs-layout-engine output::builder::tests::buffer_mouse_face_is_derived_only_from_the_final_authoritative_row +cpu neomacs-layout-engine output::builder::tests::buffer_mouse_face_uses_canonical_margin_pen_and_column +cpu neomacs-layout-engine output::builder::tests::buffer_mouse_face_wrapped_slots_share_one_source_appearance +cpu neomacs-layout-engine output::builder::tests::builder_abandons_only_an_empty_speculative_row_begin +cpu neomacs-layout-engine output::builder::tests::builder_applies_row_begin_lifecycle_to_matrix_row +cpu neomacs-layout-engine output::builder::tests::builder_can_preserve_exact_frame_pixel_size +cpu neomacs-layout-engine output::builder::tests::builder_computes_row_hashes_on_finish +cpu neomacs-layout-engine output::builder::tests::builder_display_row_without_window_is_noop +cpu neomacs-layout-engine output::builder::tests::builder_handles_stretch_glyphs +cpu neomacs-layout-engine output::builder::tests::builder_install_display_row_preserves_row_and_relative_metrics +cpu neomacs-layout-engine output::builder::tests::builder_installs_status_line_row_glyphs_wholesale +cpu neomacs-layout-engine output::builder::tests::builder_keeps_stretch_fixed_while_reordering_rtl_chars +cpu neomacs-layout-engine output::builder::tests::builder_left_margin_chars +cpu neomacs-layout-engine output::builder::tests::builder_normalizes_row_metrics_during_install +cpu neomacs-layout-engine output::builder::tests::builder_preserves_distinct_mode_line_faces_across_sibling_windows +cpu neomacs-layout-engine output::builder::tests::builder_preserves_high_window_id_phys_cursor +cpu neomacs-layout-engine output::builder::tests::builder_preserves_negative_child_frame_origin_for_parent_clipping +cpu neomacs-layout-engine output::builder::tests::builder_preserves_phys_cursor +cpu neomacs-layout-engine output::builder::tests::builder_remaps_phys_cursor_to_visual_bidi_column +cpu neomacs-layout-engine output::builder::tests::builder_reorders_simple_rtl_row +cpu neomacs-layout-engine output::builder::tests::builder_reorders_status_line_rtl_row +cpu neomacs-layout-engine output::builder::tests::builder_reorders_wide_rtl_row +cpu neomacs-layout-engine output::builder::tests::builder_reorders_wide_rtl_row_across_stretch +cpu neomacs-layout-engine output::builder::tests::builder_resets_on_new_frame +cpu neomacs-layout-engine output::builder::tests::builder_set_cursor_at_row +cpu neomacs-layout-engine output::builder::tests::builder_starts_empty +cpu neomacs-layout-engine output::builder::tests::builder_status_line_empty_row_when_no_chars_pushed +cpu neomacs-layout-engine output::builder::tests::builder_stores_row_metrics_as_provided +cpu neomacs-layout-engine output::builder::tests::builder_tracks_multiple_rows +cpu neomacs-layout-engine output::builder::tests::builder_tracks_single_window_single_row +cpu neomacs-layout-engine output::builder::tests::builder_tracks_wide_chars +cpu neomacs-layout-engine output::builder::tests::cluster_continuation_merges_combining_mark +cpu neomacs-layout-engine output::builder::tests::cluster_continuation_merges_zwj_emoji_sequence +cpu neomacs-layout-engine output::builder::tests::cluster_continuation_without_base_falls_back_to_standalone +cpu neomacs-layout-engine output::builder::tests::cluster_tail_detects_lone_regional_indicator_then_pairs_flag +cpu neomacs-layout-engine output::builder::tests::cluster_tail_is_none_at_row_start +cpu neomacs-layout-engine output::builder::tests::complex_run_grows_into_one_composite_with_per_char_padding +cpu neomacs-layout-engine output::builder::tests::cursor_does_not_treat_an_unmapped_string_index_as_a_buffer_position +cpu neomacs-layout-engine output::builder::tests::cursor_on_a_replacement_chooses_the_smallest_string_index_after_bidi_reorder +cpu neomacs-layout-engine output::builder::tests::cursor_resolves_the_exact_occurrence_when_one_string_is_displayed_twice +cpu neomacs-layout-engine output::builder::tests::embedded_rtl_box_run_keeps_neutral_member_inside_visual_terminals +cpu neomacs-layout-engine output::builder::tests::embedded_rtl_box_run_moves_terminals_to_visual_sides +cpu neomacs-layout-engine output::builder::tests::engine_style_loop_clusters_zwj_family_emoji +cpu neomacs-layout-engine output::builder::tests::finalized_cursor_geometry_distinguishes_hscroll_replacement_from_a_prefix +cpu neomacs-layout-engine output::builder::tests::glyph_row_resolved_phys_cursor_preserves_display_string_cursor_slot +cpu neomacs-layout-engine output::builder::tests::lone_rtl_run_stays_in_place_in_ltr_paragraph +cpu neomacs-layout-engine output::builder::tests::long_buffer_mouse_face_publishes_one_region_and_one_source_span +cpu neomacs-layout-engine output::builder::tests::ltr_paragraph_row_is_not_marked_reversed +cpu neomacs-layout-engine output::builder::tests::one_logical_mouse_face_keeps_multiple_face_paint_batches +cpu neomacs-layout-engine output::builder::tests::output_builder_installs_complete_row_metrics_and_cursor +cpu neomacs-layout-engine output::builder::tests::phys_cursor_on_hidden_prefix_resolves_to_first_visible_glyph +cpu neomacs-layout-engine output::builder::tests::phys_cursor_slot_col_accounts_for_line_number_gutter +cpu neomacs-layout-engine output::builder::tests::phys_cursor_slot_resolution_preserves_exact_layout_x_on_empty_gutter_row +cpu neomacs-layout-engine output::builder::tests::resolve_bare_newline_cursor_excludes_positionless_newline_glyph +cpu neomacs-layout-engine output::builder::tests::resolve_cursor_on_blank_gutter_line_lands_past_the_gutter +cpu neomacs-layout-engine output::builder::tests::resolve_cursor_visual_col_is_the_single_resolution_authority +cpu neomacs-layout-engine output::builder::tests::resolve_empty_line_cursor_preserves_gutter_before_line_end_fill +cpu neomacs-layout-engine output::builder::tests::resolve_eol_cursor_excludes_positionless_newline_glyph +cpu neomacs-layout-engine output::builder::tests::resolved_cursor_coordinates_keep_hscroll_output_and_display_columns_distinct +cpu neomacs-layout-engine output::builder::tests::retained_buffer_mouse_face_uses_replayed_row_geometry +cpu neomacs-layout-engine output::builder::tests::rtl_paragraph_row_is_marked_reversed +cpu neomacs-layout-engine output::builder::tests::rtl_text_and_chrome_rows_reorder_identically +cpu neomacs-layout-engine output::builder::tests::set_phys_cursor_leaves_window_cursors_untouched +cpu neomacs-display-runtime render_thread::frame_compositor::layout_driver::tests::a_commit_that_moves_nothing_does_not_cancel_a_motion_in_flight +cpu neomacs-display-runtime render_thread::frame_compositor::layout_driver::tests::a_commit_that_moves_nothing_does_not_restart_a_motion_in_flight +cpu neomacs-display-runtime render_thread::frame_compositor::layout_driver::tests::a_commit_that_moves_nothing_leaves_the_compositor_settled +cpu neomacs-display-runtime render_thread::frame_compositor::layout_driver::tests::a_commit_that_rearranges_the_panes_starts_a_motion +cpu neomacs-display-runtime render_thread::frame_compositor::layout_driver::tests::a_commit_that_wants_the_panes_elsewhere_retargets +cpu neomacs-display-runtime render_thread::frame_compositor::layout_driver::tests::a_frame_with_no_presentation_settles_rather_than_animating_nothing +cpu neomacs-display-runtime render_thread::frame_compositor::layout_driver::tests::a_retarget_onto_where_the_panes_already_are_settles_but_still_names_the_transform +cpu neomacs-display-runtime render_thread::frame_compositor::layout_driver::tests::a_settled_compositor_places_nothing_and_names_no_transform +cpu neomacs-display-runtime render_thread::frame_compositor::layout_driver::tests::only_an_animating_driver_asks_for_frames +cpu neomacs-display-runtime render_thread::frame_compositor::layout_driver::tests::the_last_frame_of_a_motion_still_places_the_panes_and_then_settles +gpu neomacs-display-runtime render_thread::frame_windows::tests::child_focus_opacity_invalidates_retained_scene_without_new_payload +gpu neomacs-display-runtime render_thread::frame_windows::tests::gnu_frame_alpha_focus_and_nil_retains_last_native_opacity +gpu neomacs-display-runtime render_thread::render_pass::composition_targets::tests::mandatory_child_target_refuses_before_present_and_recovers +gpu neomacs-display-runtime render_thread::render_pass::composition_targets::tests::mandatory_resize_pair_releases_first_lease_when_second_is_refused +gpu neomacs-display-runtime render_thread::render_pass::retained_scroll::geometry_tests::raster_extent_preserves_device_phase_and_rejects_oversized_coverage +gpu neomacs-display-runtime render_thread::render_pass::retained_scroll::tests::changed_coverage_pixels_and_font_catalog_rebuild_the_raster +gpu neomacs-display-runtime render_thread::render_pass::retained_scroll::tests::changing_coverage_defers_raster_work_until_paint_is_stable +gpu neomacs-display-runtime render_thread::render_pass::retained_scroll::tests::disabled_effect_parameters_do_not_veto_static_body_rasters +gpu neomacs-display-runtime render_thread::render_pass::retained_scroll::tests::every_enabled_effect_has_an_explicit_static_body_decision +gpu neomacs-display-runtime render_thread::render_pass::retained_scroll::tests::gallery_roundtrip_allows_retained_scroll +gpu neomacs-display-runtime render_thread::render_pass::retained_scroll::tests::retained_scroll_pixels_match_full_render_and_reuse_coverage_during_reversal +gpu neomacs-display-runtime render_thread::render_pass::retained_scroll::tests::scrolling_hover_resolves_projected_slots_and_draws_live_pixels +gpu neomacs-display-runtime render_thread::render_pass::retained_scroll::tests::software_quality_profile_allows_retained_scroll +gpu neomacs-display-runtime render_thread::render_pass::retained_static::tests::a_hovered_or_pressed_pointer_appearance_forces_the_full_render_and_retains_no_cursor_cells +gpu neomacs-display-runtime render_thread::render_pass::retained_static::tests::a_retained_filled_box_cursor_cell_resolves_the_same_local_effect_profile_as_a_full_render +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::expired_child_lifecycle_needs_no_scratch_under_unchanged_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::expired_child_resize_recovers_under_unchanged_budget_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::mixed_policy_child_admission_tracks_applied_highlight_under_held_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::native_boundary::dispatched_scale_zoom_strips_use_final_native_conversion_with_opaque_controls +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::native_boundary::obsolete_native_owner_retires_before_required_child_under_same_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::pane_size_boundary::fractional_outgoing_panes_use_final_native_conversion_and_settle_direct +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::pane_size_boundary::pane_conversion_refuses_before_motion_and_recovers_under_held_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::pane_size_boundary::wrong_size_required_native_owner_retires_before_child_under_same_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::pinned_lifecycle::native_resize_retires_incompatible_pinned_picture_not_interaction +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::pinned_lifecycle::recreated_device_has_no_old_pane_bindings_and_keeps_cpu_scene +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::production_child_resize_applies_complete_picture_opacity_once +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::production_split_delete_placements_keep_background_opacity +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::dead_scroll_epoch_retires_under_held_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::disabled_post_retires_under_held_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::expired_snapshot_keeps_fractional_native_under_true_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::expired_snapshot_old_and_native_retire_under_held_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::ineligible_scroll_overlay_retires_under_held_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::stale_static_extent_and_census_retire_under_held_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::stale_static_generation_and_census_retire_under_held_pressure +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::terminal_unique_pane_retires_without_surrendering_fractional_native +gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::wrong_size_post_retires_under_held_pressure +gpu neomacs-display-runtime render_thread::transitions::tests::default_transition_state_has_expected_policy_defaults +gpu neomacs-display-runtime render_thread::transitions::tests::default_transition_state_starts_without_active_transitions +gpu neomacs-display-runtime render_thread::transitions::tests::frame_buffer_hint_plans_one_synchronized_group_for_each_viewport +gpu neomacs-display-runtime render_thread::transitions::tests::synchronized_transition_plan_rejects_empty_or_inconsistent_region_clocks +gpu neomacs-display-runtime render_thread::transitions::tests::visual_config_is_the_transition_policy_source_of_truth +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::a_departing_pane_is_drawn_over_the_panes_that_replace_it +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::a_departing_pane_reads_from_the_previous_composition +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::a_pane_drawn_away_from_its_content_still_samples_its_content +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::a_pane_with_no_previous_composition_to_read_is_dropped_rather_than_faded_from_nothing +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::a_settled_pane_samples_exactly_the_region_it_covers +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::an_oversized_pane_shows_more_of_the_row_rather_than_a_stretched_copy +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::every_pane_maps_its_own_region_independently +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::nothing_is_submitted_for_a_frame_with_no_size +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::the_base_is_the_destination_and_the_motion_rides_on_top_of_it +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::the_helper_matches_what_the_pass_emits +gpu neomacs-renderer-wgpu renderer::layout_pass::tests::the_pass_draws_the_whole_frame_underneath_the_panes +gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::a_free_slot_nobody_has_wanted_for_a_long_time_stops_being_charged +gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::a_free_slot_of_the_wrong_size_is_evicted_to_make_room_rather_than_refusing_the_lease +gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::a_lease_returns_its_slot_to_the_pool_once_the_last_holder_drops_it +gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::a_second_lease_of_the_same_size_reuses_the_slot_the_first_one_freed +gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::a_slot_someone_still_holds_is_never_evicted_to_satisfy_a_new_lease +gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::a_texture_the_render_thread_owns_outside_the_pool_shrinks_what_the_pool_may_allocate +gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::the_pool_reclaims_before_it_decides_whether_a_lease_fits +gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::the_pool_refuses_a_lease_that_would_cross_the_ceiling_rather_than_allocating_it +gpu neomacs-renderer-wgpu renderer::transitions::tests::slide_offsets_follow_the_typed_axis_and_direction +cpu neomacs-display-runtime thread_comm::frame_opacity::tests::accepted_numeric_then_nil_survives_absent_and_replaced_scenes +cpu neomacs-display-runtime thread_comm::frame_opacity::tests::lower_limit_changes_apply_at_focus_without_replaying_nil +cpu neomacs-display-runtime thread_comm::frame_opacity::tests::nil_component_retains_last_scalar_not_previous_policy_component +cpu neomacs-display-runtime thread_comm::frame_opacity::tests::redirect_clear_dead_target_and_late_leave_use_effective_highlight +cpu neomacs-display-runtime thread_comm::frame_opacity::tests::setter_under_temporary_limit_only_reapplies_its_target +cpu neomacs-display-runtime thread_comm::frame_opacity::tests::shared_child_reader_projects_redirects_into_applied_native_opacity +cpu neomacs-display-runtime thread_comm::frame_opacity::tests::unchanged_highlight_and_stale_focus_do_not_reapply_policy +cpu neomacs-display-runtime thread_comm::tests::accepted_opacity_is_independent_of_superseded_visual_mailbox +cpu neovm-core window::frame_alpha::tests::gnu_frame_alpha_components +cpu neovm-core window::frame_alpha::tests::gnu_frame_alpha_pair +cpu neovm-core window::frame_alpha::tests::gnu_frame_alpha_raw_and_accepted_state_are_distinct diff --git a/docs/testing/transparency.md b/docs/testing/transparency.md new file mode 100644 index 0000000000..bf5cd1507b --- /dev/null +++ b/docs/testing/transparency.md @@ -0,0 +1,89 @@ +# Frame transparency + +`alpha-background` changes frame background paint without fading foreground +text. `alpha` controls the complete frame picture, with active/inactive values, +`frame-alpha-lower-limit`, focus redirection and GNU's numeric-then-nil behavior. +For example: + +```elisp +(modify-frame-parameters nil '((alpha-background . 70))) +(modify-frame-parameters nil '((alpha . (90 60)))) +;; Restore opaque background paint; nil alpha retains the applied frame opacity. +(modify-frame-parameters nil '((alpha-background . nil) (alpha . nil))) +``` + +The compositor keeps completed pictures premultiplied. Background alpha applies +to background layers, and frame/lifecycle opacity applies once to the completed +picture. Complementary transition pictures add their weighted colors and alpha; +ordered effects such as PageCurl still use source-over. Native output converts +at the final boundary, including pane motion and uncovered child fill. + +Fractional child and pane pictures need scratch textures. Before mandatory +admission, the renderer retires owners that can no longer contribute and updates +its external-texture census. If required pictures do not fit the existing budget, +the frame retries without presenting or advancing interaction state. These tests +cover bounded refusal/recovery, resize and a recreated renderer, not an exhaustive +OS/device-loss recovery contract. + +## Focused tests + +Use the build prerequisites in [building.md](../building.md), or the repository's +`nix develop` shell. The bootstrap-dependent CPU cases also need generated Lisp. +On a clean source checkout, prepare the early charsets and compile-only autoloads +with the repository's GNU generators (the files are ignored by Git): + +```sh +awk -f admin/charsets/cp51932.awk < etc/charsets/CP932-2BYTE.map \ + > lisp/international/cp51932.el +gzip -dc admin/charsets/glibc/EUC-JP-MS.gz \ + | awk -f admin/charsets/eucjp-ms.awk > lisp/international/eucjp-ms.el +emacs --batch -Q -l lisp/emacs-lisp/loaddefs-gen.el \ + --eval '(loaddefs-generate "lisp/emacs-lisp" "lisp/transparency-loaddefs.el")' +``` + +The last command uses GNU Emacs to run the checked-in autoload generator; it is +fixture preparation, not a GNU compatibility oracle. It generates +`lisp/emacs-lisp/cl-loaddefs.el`, which runtime bootstrap cleanup reads even when +no bytecode build is requested. Its partial default output has a separate ignored +filename, so it does not replace the complete `loaddefs.el` or the normal +`ldefs-boot.el` fallback. The normal core Cargo build generates +`charscript.el` and `emoji-zwj.el`. A completed `cargo xtask fresh-build --release` +already supplies these prerequisites; do not repeat that full build just for +the focused tests. + +Install the nextest version declared in `.config/nextest.toml`. The selection in +`transparency-tests.txt` lists each package and exact test name. It includes +baseline controls as well as new regressions; it is not the full display or +evaluator suite. + +From the repository root, run the CPU selection: + +```sh +filter=$(python3 -c 'from pathlib import Path; rows=[s.split() for s in Path("docs/testing/transparency-tests.txt").read_text().splitlines()]; print(" | ".join(f"(package(={p}) & test(={n}))" for k,p,n in rows if k == "cpu"))') +cargo nextest run --locked \ + -p neovm-core -p neomacs-display-protocol -p neomacs-display-runtime \ + -p neomacs-layout-engine -p neomacs-renderer-wgpu \ + -E "$filter" --test-threads 1 --no-fail-fast +``` + +For the GPU selection, replace `k == "cpu"` with `k == "gpu"`. A working +wgpu adapter is required. On Linux, a software Vulkan implementation can exercise +the production shaders and render-pass dispatch without a desktop compositor: + +```sh +export WGPU_BACKEND=vulkan +export LP_NUM_THREADS=2 +# If several Vulkan drivers are installed, set VK_DRIVER_FILES to the desired ICD. +``` + +CPU coverage includes accepted opacity operations independent of redisplay, +focus/minibuffer projection, idle repaint and glyph snapshot materialization. +GPU coverage includes opaque foreground/stipple, colored fractional backgrounds, +child lifetime/resize, pane placement, retained-owner admission, native output +conversion and transition dispatch. FadeEdges checks both directions at five +progress values for fractional and opaque pictures, edge attenuation and every +pixel outside the scissor. PageCurl is the ordered-occlusion control. + +The existing display-stack CI selection includes the four display packages; +core cases belong to the existing core suite. Adapter availability, hosted CI, +native window compositors and non-Linux platforms need separate verification. diff --git a/lisp/term/neo-win.el b/lisp/term/neo-win.el index e5f5316306..0b7ffcfe2a 100644 --- a/lisp/term/neo-win.el +++ b/lisp/term/neo-win.el @@ -596,8 +596,6 @@ This sets the `alpha-background' frame parameter, which makes the background transparent while keeping text fully opaque." (interactive "nOpacity (0.0-1.0 or 0-100): ") (let ((f (or frame (selected-frame)))) - (when (and (integerp opacity) (> opacity 1)) - (setq opacity (/ (float opacity) 100.0))) (set-frame-parameter f 'alpha-background opacity))) ;; Menu bar keyboard access (F10) From 169f43d0f6cd21d9d6eedf825066263c887f2439 Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Tue, 6 Oct 2026 09:01:02 +0300 Subject: [PATCH 2/4] fix(display): settle transparency surface, transition and creation edges Sync the native surface state before reading the presentation mapping, so a suspended surface reports WindowNotReady and a drawable one is not rejected through a stale cached mapping. Show the frame background wherever a transition's pictures leave the cleared region uncovered. Source-over effects (card flip, tilt, cylinder roll, cascade, page curl, typewriter and the strip effects) mark the pixels their visible pictures rasterize in a stencil and fill only the unmarked remainder, so covered translucent pixels gain no extra background layer and a fully faded picture leaves its area to the background. The weighted ScaleZoom and Parallax pictures fill the geometric complement of each picture at that picture's weight. Return the realized frame from x-create-frame when the host cannot apply its opacity or focus redirects, logging the failure as GNU x_set_frame_alpha ignores X errors, rather than signaling after the frame already exists. set-window-configuration likewise requests its redisplay before reporting a focus-redirect failure. Cover frame-alpha-lower-limit with GNU x_set_frame_alpha boundary tests: a fixnum is a percentage, a float a fraction, anything else 1.0, and out-of-range values pass through to the consumers' range guard. --- .../render_thread/render_pass/full_render.rs | 1 + .../src/render_thread/render_pass/mod.rs | 32 +- .../scene/native_boundary_tests.rs | 20 +- .../render_pass/scene/retained_owner_tests.rs | 1 + .../render_thread/render_pass/tests/mod.rs | 67 +++++ .../src/render_thread/transitions.rs | 3 + .../neomacs-renderer-wgpu/src/renderer/mod.rs | 47 ++- .../src/renderer/resources.rs | 3 + .../src/renderer/transitions.rs | 281 ++++++++++++++++-- .../src/renderer/transitions/tests/mod.rs | 121 ++++++++ .../tests/offscreen_frame/fade_edges_test.rs | 4 + .../tests/offscreen_frame/opacity_test.rs | 118 +++++++- .../src/emacs_core/display/window_cmds/mod.rs | 23 +- .../display/window_cmds/tests/mod.rs | 64 ++++ .../emacs_core/lisp/native/builtins/hooks.rs | 6 +- .../src/window/frame_alpha/tests/mod.rs | 13 + docs/testing/transparency-tests.txt | 11 + docs/testing/transparency.md | 6 +- 18 files changed, 778 insertions(+), 43 deletions(-) create mode 100644 crates/neomacs-display-runtime/src/render_thread/render_pass/tests/mod.rs diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs index 8d43e3949c..406e44cf64 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/full_render.rs @@ -90,6 +90,7 @@ pub(super) fn through_composition_ring( composition_view, native.content_size().0, native.content_size().1, + frame, ); if render.compositor.transitions.has_active() { render.mark_dirty(); diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs index 54f8f875c6..7befe69e37 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/mod.rs @@ -142,6 +142,24 @@ fn render_frame_window_contents( } } +fn composition_surface( + render: &mut GuiFrameRenderState, + surface_state: neomacs_display_protocol::SurfaceState, +) -> Result { + // Native readiness precedes editor-content and scratch-admission checks. + render.set_surface_state(surface_state); + if matches!( + surface_state, + neomacs_display_protocol::SurfaceState::Suspended + ) { + return Err(FrameRenderFailure::WindowNotReady); + } + render + .present_mapping() + .map(|mapping| mapping.surface()) + .ok_or(FrameRenderFailure::AwaitingContent) +} + #[allow(clippy::too_many_arguments)] fn render_frame_window_contents_to_surface( renderer: &mut WgpuRenderer, @@ -158,20 +176,16 @@ fn render_frame_window_contents_to_surface( ) -> Result { // Reserve interactive child composition before acquiring a swapchain, // sampling motion, publishing projections or consuming presentation hints. - if !matches!(window_state.lifecycle, FrameLifecycle::Active { .. }) { + let FrameLifecycle::Active { native, .. } = &window_state.lifecycle else { return Err(FrameRenderFailure::WindowNotReady); - } + }; + let surface = composition_surface(&mut window_state.render, native.surface_state())?; let frame_has_theme_transition = window_state .render .pending_theme_change() .ok_or(FrameRenderFailure::AwaitingContent)?; let feature_plan = render_policy.plan_frame(frame_has_theme_transition, renderer.has_frame_post()); - let surface = window_state - .render - .present_mapping() - .ok_or(FrameRenderFailure::AwaitingContent)? - .surface(); let targets = composition_targets::prepare_frame_targets_for_scene( renderer, &mut window_state.render, @@ -500,3 +514,7 @@ fn render_frame_window_contents_reserved( projection: pane_projection, }) } + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs index 9d7f401930..dde5235694 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs @@ -299,6 +299,7 @@ fn dispatched_scale_zoom_strips_use_final_native_conversion_with_opaque_controls destination, W, H, + &new_frame, ); if let Some(native) = &native { let placement = @@ -317,15 +318,18 @@ fn dispatched_scale_zoom_strips_use_final_native_conversion_with_opaque_controls "native ScaleZoom {background_alpha}/{whole_alpha} {millis}: strip {strip:?} overlap {overlap:?} outside {outside:?} conversion={}", native.is_some() ); - let weight = 0.75; - let expected_alpha = - (255.0_f32 * background_alpha * whole_alpha * weight).round() as u8; + // Missing geometric weight is the current (blue) background, + // not a transparent strip or a third layer under both snapshots. + let a = background_alpha * whole_alpha; + let expected_alpha = (255.0_f32 * a).round() as u8; assert!(strip[3].abs_diff(expected_alpha) <= 2); - let colored = if millis == 250 { strip[1] } else { strip[2] }; - assert!( - colored.abs_diff(expected_alpha) <= 2, - "native encoded-premultiplied strip must store color=alpha, not encode(linear-premultiplied): {strip:?}" - ); + let strip_color = if millis == 250 { + Color::new(0.0, 0.75, 0.25, 1.0).linear_to_srgb() + } else { + Color::BLUE + }; + assert!(strip[1].abs_diff((255.0 * a * strip_color.g).round() as u8) <= 2); + assert!(strip[2].abs_diff((255.0 * a * strip_color.b).round() as u8) <= 2); assert!(strip[..3].iter().all(|c| *c <= strip[3].saturating_add(1))); let t = millis as f32 / 1000.0; let straight = Color::new(0.0, 1.0 - t, t, 1.0).linear_to_srgb(); diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs index 446c39d128..7846c4065d 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs @@ -370,6 +370,7 @@ fn snapshot(fractional: bool) { native.view(), W, H, + &root, ); drop(native); child(&mut render, 0.5); diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/tests/mod.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/tests/mod.rs new file mode 100644 index 0000000000..fd99f5d62e --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/tests/mod.rs @@ -0,0 +1,67 @@ +use super::*; +use neomacs_display_protocol::{DeviceScale, SurfaceState}; + +fn drawable() -> SurfaceState { + SurfaceState::from_device_size(800, 600, DeviceScale::new(1.0).unwrap()).unwrap() +} + +fn render_with_frame() -> GuiFrameRenderState { + let mut render = GuiFrameRenderState::new_without_device( + 42, + false, + neomacs_display_protocol::frame_time::observe_platform_now(), + ); + render.set_current_frame( + Some(crate::core::frame_glyphs::FrameGlyphBuffer::with_size( + 800.0, 600.0, + )), + None, + Default::default(), + Default::default(), + ); + render +} + +#[test] +fn suspended_native_surface_is_window_not_ready_with_or_without_content() { + let mut render = render_with_frame(); + render.set_surface_state(SurfaceState::Suspended); + assert_eq!( + composition_surface(&mut render, SurfaceState::Suspended), + Err(FrameRenderFailure::WindowNotReady) + ); + assert!(render.present_mapping().is_none()); + let mut empty = GuiFrameRenderState::new_without_device( + 42, + false, + neomacs_display_protocol::frame_time::observe_platform_now(), + ); + assert_eq!( + composition_surface(&mut empty, SurfaceState::Suspended), + Err(FrameRenderFailure::WindowNotReady) + ); +} + +#[test] +fn drawable_native_surface_refreshes_suspended_cached_mapping() { + let mut render = render_with_frame(); + render.set_surface_state(SurfaceState::Suspended); + let SurfaceState::Drawable(surface) = drawable() else { + unreachable!() + }; + assert_eq!(composition_surface(&mut render, drawable()), Ok(surface)); + assert!(render.present_mapping().is_some()); +} + +#[test] +fn drawable_surface_without_editor_content_is_still_awaiting_content() { + let mut render = GuiFrameRenderState::new_without_device( + 42, + false, + neomacs_display_protocol::frame_time::observe_platform_now(), + ); + assert_eq!( + composition_surface(&mut render, drawable()), + Err(FrameRenderFailure::AwaitingContent) + ); +} diff --git a/crates/neomacs-display-runtime/src/render_thread/transitions.rs b/crates/neomacs-display-runtime/src/render_thread/transitions.rs index 288627d6dc..2f6509b798 100644 --- a/crates/neomacs-display-runtime/src/render_thread/transitions.rs +++ b/crates/neomacs-display-runtime/src/render_thread/transitions.rs @@ -521,6 +521,7 @@ pub(super) fn render_frame_transitions( surface_view: &wgpu::TextureView, width: u32, height: u32, + frame: &FrameGlyphBuffer, ) { let now = renderer.frame_sample().presentation_time(); let Some(compositions) = transitions.compositions.as_ref() else { @@ -546,6 +547,8 @@ pub(super) fn render_frame_transitions( transition.plan.easing, width, height, + frame.background, + frame.background_alpha, ); } diff --git a/crates/neomacs-renderer-wgpu/src/renderer/mod.rs b/crates/neomacs-renderer-wgpu/src/renderer/mod.rs index 2c622d39e0..7e66aa9d40 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/mod.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/mod.rs @@ -701,7 +701,7 @@ impl WgpuRenderer { }); // Create image pipeline (similar to glyph but for RGBA textures) - let make_image_pipeline = |blend, entry_point| { + let make_image_pipeline_with = |blend, entry_point, depth_stencil| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Image Pipeline"), layout: Some(&image_pipeline_layout), @@ -730,7 +730,7 @@ impl WgpuRenderer { unclipped_depth: false, conservative: false, }, - depth_stencil: None, + depth_stencil, multisample: wgpu::MultisampleState { count: 1, mask: !0, @@ -740,6 +740,8 @@ impl WgpuRenderer { multiview_mask: None, }) }; + let make_image_pipeline = + |blend, entry_point| make_image_pipeline_with(blend, entry_point, None); let image_pipeline = make_image_pipeline(Some(wgpu::BlendState::ALPHA_BLENDING), "fs_main"); let surface_copy_pipeline = make_image_pipeline(None, "fs_copy"); @@ -763,6 +765,14 @@ impl WgpuRenderer { dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add, }; + let transition_background_add_pipeline = make_rect_pipeline( + "Transition Background Complement", + wgpu::BlendState { + color: additive_component, + alpha: additive_component, + }, + None, + ); let crossfade_add_pipeline = make_image_pipeline( Some(wgpu::BlendState { color: additive_component, @@ -884,6 +894,36 @@ impl WgpuRenderer { bias: wgpu::DepthBiasState::default(), }; + // Transition coverage: every rasterized picture fragment marks its + // pixel, then the frame background fills only unmarked pixels. + let transition_mark_face = wgpu::StencilFaceState { + compare: wgpu::CompareFunction::Always, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::Keep, + pass_op: wgpu::StencilOperation::Replace, + }; + let transition_composition_marked_pipeline = make_image_pipeline_with( + Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + "fs_copy", + Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Stencil8, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: wgpu::StencilState { + front: transition_mark_face, + back: transition_mark_face, + read_mask: 0xFF, + write_mask: 0xFF, + }, + bias: wgpu::DepthBiasState::default(), + }), + ); + let transition_uncovered_background_pipeline = make_rect_pipeline( + "Transition Uncovered Background", + wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, + Some(stencil_read_state.clone()), + ); + let stencil_background_rect_pipeline = make_rect_pipeline( "Stencil background_rect", background_blend(false), @@ -1104,6 +1144,9 @@ impl WgpuRenderer { pipelines: Pipelines { rect: rect_pipeline, background_rect: background_rect_pipeline, + transition_background_add: transition_background_add_pipeline, + transition_composition_marked: transition_composition_marked_pipeline, + transition_uncovered_background: transition_uncovered_background_pipeline, background_rounded_rect: background_rounded_rect_pipeline, rounded_rect: rounded_rect_pipeline, rounded_fill: rounded_fill_pipeline, diff --git a/crates/neomacs-renderer-wgpu/src/renderer/resources.rs b/crates/neomacs-renderer-wgpu/src/renderer/resources.rs index b1b3bb3a21..e7225d4bc0 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/resources.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/resources.rs @@ -19,6 +19,9 @@ use crate::vertex::{CoverageGlyphVertex, GlyphVertex, RectVertex, RoundedRectVer pub(crate) struct Pipelines { pub(crate) rect: wgpu::RenderPipeline, pub(crate) background_rect: wgpu::RenderPipeline, + pub(crate) transition_background_add: wgpu::RenderPipeline, + pub(crate) transition_composition_marked: wgpu::RenderPipeline, + pub(crate) transition_uncovered_background: wgpu::RenderPipeline, pub(crate) background_rounded_rect: wgpu::RenderPipeline, pub(crate) transparent_glyph: wgpu::RenderPipeline, pub(crate) rounded_rect: wgpu::RenderPipeline, diff --git a/crates/neomacs-renderer-wgpu/src/renderer/transitions.rs b/crates/neomacs-renderer-wgpu/src/renderer/transitions.rs index 13d159fec6..71b15098f6 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/transitions.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/transitions.rs @@ -1,6 +1,6 @@ //! Transitions methods for WgpuRenderer. -use super::super::vertex::GlyphVertex; +use super::super::vertex::{GlyphVertex, RectVertex}; use super::WgpuRenderer; use neomacs_display_protocol::types::Color; use neomacs_display_protocol::{ @@ -9,11 +9,35 @@ use neomacs_display_protocol::{ TransitionEdge, VerticalPostProcessTransitionEffect, VerticalTransitionEffect, }; +/// The frame background a transition shows where its pictures leave the +/// cleared region uncovered. +#[derive(Clone, Copy, Debug)] +struct TransitionBackground { + color: Color, + alpha: f32, +} + +impl TransitionBackground { + fn new(color: Color, background_alpha: f32) -> Self { + Self { + color, + alpha: (color.a * background_alpha).clamp(0.0, 1.0), + } + } + + /// Premultiplied linear color at `weight` of the background opacity. + fn premultiplied(self, weight: f32) -> [f32; 4] { + let a = self.alpha * weight; + [self.color.r * a, self.color.g * a, self.color.b * a, a] + } +} + /// Complementary fades combine alternative premultiplied pictures. Other -/// geometries retain source-over ordering (curl, flip, strips and occlusion). +/// geometries retain source-over ordering (curl, flip, strips and occlusion); +/// their uncovered geometry shows the frame background. #[derive(Clone, Copy)] enum TransitionComposition { - SourceOver, + SourceOver(TransitionBackground), WeightedPictures, } @@ -39,7 +63,99 @@ fn axis_offset(axis: TransitionAxis, amount: f32) -> [f32; 2] { } } +/// Complete only the geometric complement of contracted rectangular pictures. +/// Each ring carries that picture's weight, so overlapping rings add just like +/// the snapshots. Never fill underneath covered pixels or infer missing weight +/// from pixel alpha: transparent content and FadeEdges attenuation are real. +fn transition_background_vertices( + bounds: &neomacs_display_protocol::types::Rect, + quads: &[&[GlyphVertex]], + background: TransitionBackground, +) -> Vec { + let mut vertices = Vec::new(); + let right = bounds.x + bounds.width; + let bottom = bounds.y + bounds.height; + for quad in quads { + // These callers supply one axis-aligned six-vertex quad per picture. + let [first, _, opposite, _, _, _] = *quad else { + continue; + }; + let [left, top] = first.position; + let [r, b] = opposite.position; + let color = background.premultiplied(first.color[3]); + for [x0, y0, x1, y1] in [ + [bounds.x, bounds.y, right, top], + [bounds.x, b, right, bottom], + [bounds.x, top, left, b], + [r, top, right, b], + ] { + if x1 <= x0 || y1 <= y0 { + continue; + } + for position in [[x0, y0], [x1, y0], [x1, y1], [x0, y0], [x1, y1], [x0, y1]] { + vertices.push(RectVertex { position, color }); + } + } + } + vertices +} + impl WgpuRenderer { + #[allow(clippy::too_many_arguments)] + fn fill_transition_quad_gaps( + &mut self, + view: &wgpu::TextureView, + bounds: &neomacs_display_protocol::types::Rect, + quads: &[&[GlyphVertex]], + background: TransitionBackground, + surface_width: u32, + surface_height: u32, + ) { + let Some((sx, sy, sw, sh, w, h, ..)) = + self.transition_scissor_and_uv(bounds, surface_width, surface_height) + else { + return; + }; + let vertices = transition_background_vertices(bounds, quads, background); + let Some(upload) = self + .arenas + .rect + .upload(&self.device, &self.queue, &vertices) + else { + return; + }; + let draw = self.parameters([w, h], 0.0); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Transition Background Complement"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Transition Background Complement"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_scissor_rect(sx, sy, sw, sh); + pass.set_pipeline(&self.pipelines.transition_background_add); + pass.set_bind_group(0, draw.binding(), &[]); + pass.set_vertex_buffer(0, upload.buffer_slice()); + pass.draw(0..vertices.len() as u32, 0..1); + } + self.queue.submit([encoder.finish()]); + } + // A transition replaces the window picture, not a third source-over layer // over the already-rendered new scene. Clear only its clipped region. fn clear_transition_region( @@ -410,8 +526,11 @@ impl WgpuRenderer { easing: TransitionEasing, surface_width: u32, surface_height: u32, + background: Color, + background_alpha: f32, ) { let eased_t = easing.apply(raw_t); + let background = TransitionBackground::new(background, background_alpha); match effect { ResolvedTransitionEffect::Directionless(DirectionlessTransitionEffect::Crossfade) => { @@ -434,6 +553,7 @@ impl WgpuRenderer { bounds, surface_width, surface_height, + background, ); } ResolvedTransitionEffect::AxisMotion { @@ -472,6 +592,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ); } ResolvedTransitionEffect::CardFlip { axis } => { @@ -484,6 +605,7 @@ impl WgpuRenderer { bounds, surface_width, surface_height, + background, ); } ResolvedTransitionEffect::PageCurl { edge } => { @@ -496,6 +618,7 @@ impl WgpuRenderer { bounds, surface_width, surface_height, + background, ); } ResolvedTransitionEffect::Vertical { @@ -514,6 +637,7 @@ impl WgpuRenderer { effect, surface_width, surface_height, + background, ), ResolvedTransitionEffect::VerticalPostProcess { effect, @@ -531,6 +655,7 @@ impl WgpuRenderer { effect, surface_width, surface_height, + background, ), ResolvedTransitionEffect::Horizontal { effect: HorizontalTransitionEffect::TypewriterReveal, @@ -545,6 +670,7 @@ impl WgpuRenderer { bounds, surface_width, surface_height, + background, ); } } @@ -564,6 +690,7 @@ impl WgpuRenderer { effect: VerticalTransitionEffect, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { match effect { VerticalTransitionEffect::FadeEdges => self.render_transition_fade_edges( @@ -588,6 +715,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::Tilt => self.render_transition_tilt( surface_view, @@ -599,6 +727,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::CylinderRoll => self.render_transition_cylinder_roll( surface_view, @@ -610,6 +739,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::Wobbly => self.render_transition_wobbly( surface_view, @@ -622,6 +752,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::Wave => self.render_transition_wave( surface_view, @@ -634,6 +765,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::PerLineSpring => self.render_transition_per_line_spring( surface_view, @@ -646,6 +778,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::Liquid => self.render_transition_liquid( surface_view, @@ -658,6 +791,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), } } @@ -700,6 +834,13 @@ impl WgpuRenderer { } /// Helper: submit a two-quad transition pass (old + new textures). + /// + /// A source-over transition marks the pixels its pictures rasterize in the + /// stencil and then fills only the unmarked remainder with the frame + /// background, so flipped, rolled, tilted or displaced geometry never + /// leaves the cleared region transparent and covered translucent pixels + /// never gain a third background layer. + #[allow(clippy::too_many_arguments)] fn submit_transition_two_quad_pass( &mut self, surface_view: &wgpu::TextureView, @@ -718,8 +859,50 @@ impl WgpuRenderer { // Replace the transitioned region, rather than compositing a third copy // of the already-rendered scene underneath these two snapshots. self.clear_transition_region(surface_view, sx, sy, sw, sh, surface_width, surface_height); - let old_upload = self.create_transition_vb(old_vertices); - let new_upload = self.create_transition_vb(new_vertices); + let background = match composition { + TransitionComposition::SourceOver(background) => Some(background), + TransitionComposition::WeightedPictures => None, + }; + // A fully faded picture quad draws nothing, so it must not mark its + // pixels covered either (an old picture faded out under a reveal). + let visible = |vertices: &[GlyphVertex]| -> Vec { + vertices + .chunks(6) + .filter(|quad| background.is_none() || quad.iter().any(|v| v.color[3] > 0.0)) + .flatten() + .copied() + .collect() + }; + let old_vertices = visible(old_vertices); + let new_vertices = visible(new_vertices); + let old_upload = self.create_transition_vb(&old_vertices); + let new_upload = self.create_transition_vb(&new_vertices); + let w = surface_width as f32 / self.scale_factor; + let h = surface_height as f32 / self.scale_factor; + let background_fill = background.and_then(|background| { + let color = background.premultiplied(1.0); + let vertices: Vec = + [[0.0, 0.0], [w, 0.0], [w, h], [0.0, 0.0], [w, h], [0.0, h]] + .into_iter() + .map(|position| RectVertex { position, color }) + .collect(); + let upload = self + .arenas + .rect + .upload(&self.device, &self.queue, &vertices)?; + Some((upload, vertices.len() as u32, self.parameters([w, h], 0.0))) + }); + // The coverage stencil must match the color target, which may be a + // composition picture rather than the installed surface. + let previous_stencil = ( + self.stencil.texture.view().texture().width(), + self.stencil.texture.view().texture().height(), + ); + let resize_stencil = + background_fill.is_some() && previous_stencil != (surface_width, surface_height); + if resize_stencil { + self.install_stencil_targets(surface_width, surface_height); + } let mut encoder = self .device @@ -738,16 +921,31 @@ impl WgpuRenderer { }, depth_slice: None, })], - depth_stencil_attachment: None, + depth_stencil_attachment: background_fill.as_ref().map(|_| { + wgpu::RenderPassDepthStencilAttachment { + view: self.stencil.texture.view(), + depth_ops: None, + stencil_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0), + store: wgpu::StoreOp::Store, + }), + } + }), timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); rp.set_scissor_rect(sx, sy, sw, sh); - rp.set_pipeline(match composition { - TransitionComposition::SourceOver => &self.pipelines.composition, - TransitionComposition::WeightedPictures => &self.pipelines.crossfade_add, - }); + if background_fill.is_some() { + // Every rasterized picture fragment marks its pixel covered. + rp.set_pipeline(&self.pipelines.transition_composition_marked); + rp.set_stencil_reference(1); + } else { + rp.set_pipeline(match composition { + TransitionComposition::SourceOver(_) => &self.pipelines.composition, + TransitionComposition::WeightedPictures => &self.pipelines.crossfade_add, + }); + } rp.set_bind_group(0, self.frame_parameters().binding(), &[]); if let Some(ref upload) = old_upload { @@ -761,8 +959,19 @@ impl WgpuRenderer { rp.set_vertex_buffer(0, upload.buffer_slice()); rp.draw(0..new_vertices.len() as u32, 0..1); } + + if let Some((upload, count, draw)) = &background_fill { + rp.set_pipeline(&self.pipelines.transition_uncovered_background); + rp.set_stencil_reference(0); + rp.set_bind_group(0, draw.binding(), &[]); + rp.set_vertex_buffer(0, upload.buffer_slice()); + rp.draw(0..*count, 0..1); + } } self.queue.submit(std::iter::once(encoder.finish())); + if resize_stencil { + self.install_stencil_targets(previous_stencil.0, previous_stencil.1); + } } /// Crossfade: alpha blend old → new within content bounds. @@ -797,6 +1006,7 @@ impl WgpuRenderer { bounds: &neomacs_display_protocol::types::Rect, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { @@ -872,6 +1082,14 @@ impl WgpuRenderer { surface_width, surface_height, ); + self.fill_transition_quad_gaps( + surface_view, + bounds, + &[&old_verts, &new_verts], + background, + surface_width, + surface_height, + ); } /// FadeEdges: slide with soft fade at viewport top/bottom edges. @@ -993,6 +1211,7 @@ impl WgpuRenderer { distance: f32, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { @@ -1072,7 +1291,7 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, @@ -1095,6 +1314,7 @@ impl WgpuRenderer { distance: f32, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { @@ -1164,6 +1384,15 @@ impl WgpuRenderer { surface_width, surface_height, ); + // Each offset picture misses its own weight in the band it vacates. + self.fill_transition_quad_gaps( + surface_view, + bounds, + &[&old_verts, &new_verts], + background, + surface_width, + surface_height, + ); } /// Tilt: subtle perspective tilt during scroll. @@ -1178,6 +1407,7 @@ impl WgpuRenderer { distance: f32, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { @@ -1258,7 +1488,7 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, @@ -1279,6 +1509,7 @@ impl WgpuRenderer { bounds: &neomacs_display_protocol::types::Rect, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { use super::deform::page_curl_transform; let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = @@ -1424,7 +1655,7 @@ impl WgpuRenderer { old_bind_group, &new_verts, &old_verts, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, @@ -1445,6 +1676,7 @@ impl WgpuRenderer { bounds: &neomacs_display_protocol::types::Rect, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { @@ -1512,7 +1744,7 @@ impl WgpuRenderer { bind_group, &verts, &empty, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, @@ -1534,6 +1766,7 @@ impl WgpuRenderer { distance: f32, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { @@ -1617,7 +1850,7 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, @@ -1640,6 +1873,7 @@ impl WgpuRenderer { distance: f32, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { use super::deform::wobbly_deform; let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = @@ -1710,7 +1944,7 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, @@ -1733,6 +1967,7 @@ impl WgpuRenderer { distance: f32, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { @@ -1804,7 +2039,7 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, @@ -1827,6 +2062,7 @@ impl WgpuRenderer { distance: f32, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { @@ -1911,7 +2147,7 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, @@ -1934,6 +2170,7 @@ impl WgpuRenderer { distance: f32, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { use super::deform::liquid_deform; let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = @@ -2004,7 +2241,7 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, @@ -2031,6 +2268,7 @@ impl WgpuRenderer { effect: VerticalPostProcessTransitionEffect, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { @@ -2149,7 +2387,7 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, @@ -2170,6 +2408,7 @@ impl WgpuRenderer { bounds: &neomacs_display_protocol::types::Rect, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { let (sx, sy, sw, sh, _w, _h, uv_l, uv_t, uv_r, uv_b) = match self.transition_scissor_and_uv(bounds, surface_width, surface_height) { @@ -2294,7 +2533,7 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, - TransitionComposition::SourceOver, + TransitionComposition::SourceOver(background), sx, sy, sw, diff --git a/crates/neomacs-renderer-wgpu/src/renderer/transitions/tests/mod.rs b/crates/neomacs-renderer-wgpu/src/renderer/transitions/tests/mod.rs index fb83610db8..c0a6d01c2d 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/transitions/tests/mod.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/transitions/tests/mod.rs @@ -21,3 +21,124 @@ fn slide_offsets_follow_the_typed_axis_and_direction() { assert_eq!(old, [0.0, 25.0]); assert_eq!(new, [0.0, -75.0]); } + +fn quad(left: f32, top: f32, right: f32, bottom: f32, weight: f32) -> [GlyphVertex; 6] { + [ + [left, top], + [right, top], + [right, bottom], + [left, top], + [right, bottom], + [left, bottom], + ] + .map(|position| GlyphVertex { + position, + tex_coords: [0.0; 2], + color: [1.0, 1.0, 1.0, weight], + }) +} + +fn background_sample(vertices: &[RectVertex], x: f32, y: f32) -> [f32; 4] { + let mut color = [0.0; 4]; + for q in vertices.chunks_exact(6) { + if x >= q[0].position[0] + && x < q[2].position[0] + && y >= q[0].position[1] + && y < q[2].position[1] + { + for (sum, channel) in color.iter_mut().zip(q[0].color) { + *sum += channel; + } + } + } + color +} + +#[test] +fn geometric_background_complements_preserve_weight_and_fractional_alpha() { + let bounds = neomacs_display_protocol::types::Rect::new(8.0, 8.0, 100.0, 100.0); + let old = quad(9.0, 9.0, 107.0, 107.0, 0.75); + let new = quad(11.0, 11.0, 105.0, 105.0, 0.25); + for alpha in [0.0, 0.5, 1.0] { + let background = Color::new(0.2, 0.4, 0.6, 0.5); + let a = alpha * background.a; + let vertices = transition_background_vertices( + &bounds, + &[&old, &new], + TransitionBackground::new(background, alpha), + ); + for (x, y, missing) in [ + (8.5, 58.0, 1.0), + (10.0, 58.0, 0.25), + (58.0, 58.0, 0.0), + (58.0, 8.5, 1.0), + (58.0, 10.0, 0.25), + (2.0, 2.0, 0.0), + ] { + let actual = background_sample(&vertices, x, y); + let expected = [ + 0.2 * a * missing, + 0.4 * a * missing, + 0.6 * a * missing, + a * missing, + ]; + for (v, e) in actual.into_iter().zip(expected) { + assert!((v - e).abs() < 0.00001); + } + } + } +} + +#[test] +fn card_flip_complement_never_fills_beneath_the_picture() { + let bounds = neomacs_display_protocol::types::Rect::new(8.0, 8.0, 100.0, 100.0); + for picture in [ + quad(57.0, 8.0, 59.0, 108.0, 1.0), + quad(8.0, 57.0, 108.0, 59.0, 1.0), + ] { + let vertices = transition_background_vertices( + &bounds, + &[&picture], + TransitionBackground::new(Color::BLACK, 0.5), + ); + assert_eq!(background_sample(&vertices, 8.5, 8.5), [0.0, 0.0, 0.0, 0.5]); + assert_eq!(background_sample(&vertices, 58.0, 58.0), [0.0; 4]); + assert_eq!(background_sample(&vertices, 2.0, 2.0), [0.0; 4]); + } +} + +#[test] +fn full_size_endpoints_need_no_weighted_background() { + let bounds = neomacs_display_protocol::types::Rect::new(8.0, 8.0, 100.0, 100.0); + let full = quad(8.0, 8.0, 108.0, 108.0, 1.0); + let invisible = quad(12.0, 12.0, 104.0, 104.0, 0.0); + let vertices = transition_background_vertices( + &bounds, + &[&full, &invisible], + TransitionBackground::new(Color::BLACK, 1.0), + ); + assert!(vertices.iter().all(|v| v.color == [0.0; 4])); +} + +#[test] +fn parallax_complements_fill_each_vacated_band_by_its_picture_weight() { + let bounds = neomacs_display_protocol::types::Rect::new(8.0, 8.0, 100.0, 100.0); + // Old picture moved up by 30 (weight 0.75), new still 70 below (0.25). + let old = quad(8.0, -22.0, 108.0, 78.0, 0.75); + let new = quad(8.0, 78.0, 108.0, 178.0, 0.25); + let vertices = transition_background_vertices( + &bounds, + &[&old, &new], + TransitionBackground::new(Color::BLACK, 1.0), + ); + // Each pixel's picture weights plus its background weight sum to one. + assert_eq!( + background_sample(&vertices, 58.0, 50.0), + [0.0, 0.0, 0.0, 0.25] + ); + assert_eq!( + background_sample(&vertices, 58.0, 90.0), + [0.0, 0.0, 0.0, 0.75] + ); + assert_eq!(background_sample(&vertices, 2.0, 50.0), [0.0; 4]); +} diff --git a/crates/neomacs-renderer-wgpu/tests/offscreen_frame/fade_edges_test.rs b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/fade_edges_test.rs index 561f5cabaa..db53c8bd70 100644 --- a/crates/neomacs-renderer-wgpu/tests/offscreen_frame/fade_edges_test.rs +++ b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/fade_edges_test.rs @@ -200,6 +200,8 @@ fn fade_edges(alpha: f32) { TransitionEasing::Linear, W, HEIGHT, + Color::BLUE, + alpha, ); let after = pixels(&h); let bg = px(&after, 70, 56); @@ -265,6 +267,8 @@ fn dispatched_page_curl_keeps_ordered_source_over_occlusion() { TransitionEasing::Linear, W, HEIGHT, + Color::BLUE, + alpha, ); let after = pixels(&h); // Old picture covers the new one, not a complementary replacement. diff --git a/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs index cef079608a..522d5d4dc4 100644 --- a/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs +++ b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs @@ -72,7 +72,7 @@ fn dispatched_scale_zoom_preserves_overlap_opacity_and_outside_pixels() { frame.add_stretch(20.0, 16.0, 16.0, 24.0, Color::BLACK, FaceId::new(0), false); render(&mut h, &frame, old.view()); render(&mut h, &frame, new.view()); - for progress in [0.0, 0.5, 1.0] { + for progress in [0.0, 0.25, 0.5, 0.75, 1.0] { let view = h.view.clone(); render(&mut h, &frame, &view); let before = read_back(&h); @@ -87,6 +87,8 @@ fn dispatched_scale_zoom_preserves_overlap_opacity_and_outside_pixels() { TransitionEasing::Linear, W, H, + frame.background, + frame.background_alpha, ); let pixels = read_back(&h); let bg = px(&pixels, 70, 45); @@ -95,6 +97,13 @@ fn dispatched_scale_zoom_preserves_overlap_opacity_and_outside_pixels() { bg[3].abs_diff((255.0_f32 * alpha).round() as u8) <= 1, "ScaleZoom overlap background {alpha}/{progress}: {bg:?}" ); + for x in [8, 9] { + assert!( + px(&pixels, x, 32)[3].abs_diff((255.0_f32 * alpha).round() as u8) <= 1, + "ScaleZoom geometric complement {alpha}/{progress}: {:?}", + px(&pixels, x, 32) + ); + } assert!( opaque_text(&pixels) > 0, "ScaleZoom opaque foreground {alpha}/{progress}" @@ -141,6 +150,8 @@ fn dispatched_parallax_adds_overlapping_faded_pictures() { TransitionEasing::Linear, W, H, + frame.background, + frame.background_alpha, ); let bg = px(&read_back(&h), 48, 48); assert!( @@ -458,6 +469,8 @@ fn dispatched_crossfade_and_slide_replace_the_previous_picture() { TransitionEasing::Linear, W, H, + frame.background, + frame.background_alpha, ); let pixels = read_back(&h); assert!((127..=129).contains(&px(&pixels, 80, 50)[3])); @@ -535,6 +548,8 @@ fn transition_postprocess_preserves_alpha_and_color_modulation() { TransitionEasing::Linear, W, H, + frame.background, + frame.background_alpha, ); let pixels = read_back(&h); let bg = px(&pixels, 80, 50); @@ -741,3 +756,104 @@ fn mandatory_child_picture_refusal_is_typed_and_recovers_after_lease_release() { draw(&mut h, &child).unwrap(); assert_eq!(read_back(&h), normal); } + +#[test] +fn source_over_transitions_fill_uncovered_geometry_with_the_background() { + use neomacs_display_protocol::{ + HorizontalTransitionEffect, ResolvedTransitionEffect, TransitionAxis, TransitionDirection, + TransitionEasing, VerticalTransitionEffect, + }; + let mut h = try_harness().expect("GPU required for transition coverage regression"); + let size = SnapshotSize::new(W, H).unwrap(); + let old = h.renderer.acquire_snapshot(size).unwrap(); + let new = h.renderer.acquire_snapshot(size).unwrap(); + let bounds = neomacs_display_protocol::types::Rect::new(8.0, 8.0, 80.0, 48.0); + let effects = [ + // Mid-flip the picture spans only x in roughly 36..60. + ( + ResolvedTransitionEffect::CardFlip { + axis: TransitionAxis::Horizontal, + }, + 0.4, + ), + // Halfway through a scroll twice the region's height, both tilted + // pictures sit wholly outside it. + ( + ResolvedTransitionEffect::Vertical { + effect: VerticalTransitionEffect::Tilt, + direction: TransitionDirection::Forward, + distance: 96.0, + }, + 0.5, + ), + // Past halfway the old picture has faded out entirely while the + // last lines have not begun their reveal. + ( + ResolvedTransitionEffect::Horizontal { + effect: HorizontalTransitionEffect::TypewriterReveal, + direction: TransitionDirection::Forward, + }, + 0.6, + ), + ]; + for alpha in [1.0, 0.5] { + let mut frame = picture(); + frame.background = Color::GREEN; + frame.background_alpha = alpha; + render(&mut h, &frame, old.view()); + render(&mut h, &frame, new.view()); + let expected = (255.0_f32 * alpha).round() as u8; + for (effect, progress) in effects { + let view = h.view.clone(); + render(&mut h, &frame, &view); + let before = read_back(&h); + h.renderer.render_transition_effect( + &h.view, + old.bind_group(), + new.bind_group(), + progress, + 0.0, + &bounds, + effect, + TransitionEasing::Linear, + W, + H, + frame.background, + frame.background_alpha, + ); + let pixels = read_back(&h); + if matches!(effect, ResolvedTransitionEffect::Horizontal { .. }) { + let p = px(&pixels, 86, 54); + assert!( + p[3].abs_diff(expected) <= 1, + "unrevealed typewriter line at alpha {alpha}: {p:?}" + ); + continue; + } + // No pixel of the transitioned region is left more transparent + // than the frame background, wherever the pictures moved. + let thinnest = (8..56) + .flat_map(|y| (8..88).map(move |x| (x, y))) + .min_by_key(|&(x, y)| px(&pixels, x, y)[3]) + .unwrap(); + let p = px(&pixels, thinnest.0, thinnest.1); + eprintln!("{effect:?} {alpha}: thinnest {thinnest:?} {p:?}"); + assert!( + p[3] >= expected.saturating_sub(1), + "{effect:?} uncovered pixel {thinnest:?} at alpha {alpha}: {p:?}" + ); + if matches!(effect, ResolvedTransitionEffect::CardFlip { .. }) { + // Mid-flip the corners are background only, at exactly its alpha. + for (x, y) in [(9, 9), (9, 32), (86, 54)] { + let p = px(&pixels, x, y); + assert!( + p[3].abs_diff(expected) <= 1, + "CardFlip uncovered pixel ({x},{y}) at alpha {alpha}: {p:?}" + ); + } + } + assert_eq!(px(&pixels, 2, 2), px(&before, 2, 2)); + assert_eq!(px(&pixels, 94, 62), px(&before, 94, 62)); + } + } +} diff --git a/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs b/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs index 05c6745b80..2424135282 100644 --- a/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/window_cmds/mod.rs @@ -7113,8 +7113,27 @@ pub(crate) fn builtin_x_create_frame( if let Ok(value) = &result && let Some(id) = value.as_frame_id() { - super::frame::sync_gui_frame_alpha(eval, FrameId(id))?; - super::frame::sync_gui_frame_focus_redirects(eval)?; + // The frame is already live and realized. Like GNU x_set_frame_alpha, + // which ignores X errors, a host opacity failure must not turn into a + // signal that hides the new frame from its caller. + for (what, sync) in [ + ( + "alpha", + super::frame::sync_gui_frame_alpha(eval, FrameId(id)), + ), + ( + "focus redirects", + super::frame::sync_gui_frame_focus_redirects(eval), + ), + ] { + if let Err(err) = sync { + tracing::warn!( + frame_id = id, + ?err, + "x-create-frame: failed to sync GUI frame {what}" + ); + } + } } result } diff --git a/crates/neovm-core/src/emacs_core/display/window_cmds/tests/mod.rs b/crates/neovm-core/src/emacs_core/display/window_cmds/tests/mod.rs index b9beab0917..80a2cdc5ec 100644 --- a/crates/neovm-core/src/emacs_core/display/window_cmds/tests/mod.rs +++ b/crates/neovm-core/src/emacs_core/display/window_cmds/tests/mod.rs @@ -62,6 +62,56 @@ fn accepted_root_and_child_alpha_operations_survive_numeric_then_nil_without_lay assert_eq!(operations[4].1, [-1.0; 2]); } +#[test] +fn x_create_frame_returns_the_realized_frame_when_alpha_sync_fails() { + let mut ev = Context::new(); + let buf = ev.buffers.create_buffer("*scratch*"); + ev.buffers.set_current(buf); + ev.frames.create_frame("F1", 800, 600, buf); + let host = RecordingDisplayHost { + fail_alpha_sync: true, + ..RecordingDisplayHost::new() + }; + let realized = host.realized.clone(); + let operations = host.alpha_operations.clone(); + ev.set_display_host(Box::new(host)); + let result = ev.eval_str_each( + "(let ((frame (x-create-frame '((alpha . 50))))) (list (and (framep frame) t) (and (frame-live-p frame) t) (and (memq frame (frame-list)) t)))", + ); + assert_eq!(format_eval_result(&result[0]), "OK (t t t)"); + assert_eq!(realized.borrow().len(), 1); + assert_eq!(operations.borrow().len(), 1); +} + +#[test] +fn focus_redirect_sync_failure_keeps_frame_creation_and_restoration_effects() { + let mut ev = Context::new(); + let buf = ev.buffers.create_buffer("*scratch*"); + ev.buffers.set_current(buf); + ev.frames.create_frame("F1", 800, 600, buf); + let host = RecordingDisplayHost::new(); + let fail_focus_sync = host.fail_focus_sync.clone(); + fail_focus_sync.set(true); + ev.set_display_host(Box::new(host)); + let created = ev.eval_str_each( + "(setq neomacs--test-frame (x-create-frame nil)) (and (frame-live-p neomacs--test-frame) t)", + ); + assert_eq!(format_eval_result(&created[1]), "OK t"); + + let saved = ev.eval_str_each("(setq neomacs--test-config (current-window-configuration))"); + assert!(saved[0].is_ok()); + let before = ev.menu_bar_rebuild_generation(); + let restored = ev.eval_str_each( + "(condition-case err (set-window-configuration neomacs--test-config) (error (car (cdr err))))", + ); + assert_eq!( + format_eval_result(&restored[0]), + "OK \"focus channel closed\"" + ); + // The accepted restoration still requests its redisplay before signaling. + assert_ne!(ev.menu_bar_rebuild_generation(), before); +} + #[test] fn x_create_frame_resolves_alpha_defaults_and_explicit_nil() { for (parameter, value, explicit, expected, background) in [ @@ -274,6 +324,8 @@ fn active_minibuffer_window_tracks_live_minibuffer_state() { #[derive(Clone, Default)] struct RecordingDisplayHost { alpha_operations: Rc>>, + fail_alpha_sync: bool, + fail_focus_sync: Rc>, alpha_limits: Rc>>, realized: Rc>>, resized: Rc>>, @@ -360,6 +412,18 @@ impl DisplayHost for RecordingDisplayHost { self.alpha_operations .borrow_mut() .push((frame, pair, limit)); + if self.fail_alpha_sync { + return Err("opacity channel closed".into()); + } + Ok(()) + } + fn set_gui_frame_focus_redirects( + &mut self, + _redirects: Vec<(crate::window::FrameId, Option)>, + ) -> Result<(), String> { + if self.fail_focus_sync.get() { + return Err("focus channel closed".into()); + } Ok(()) } fn realize_gui_frame(&mut self, request: GuiFrameHostRequest) -> Result<(), String> { diff --git a/crates/neovm-core/src/emacs_core/lisp/native/builtins/hooks.rs b/crates/neovm-core/src/emacs_core/lisp/native/builtins/hooks.rs index 5302c3e9c3..b9f3017714 100644 --- a/crates/neovm-core/src/emacs_core/lisp/native/builtins/hooks.rs +++ b/crates/neovm-core/src/emacs_core/lisp/native/builtins/hooks.rs @@ -1703,6 +1703,7 @@ pub(crate) fn set_window_configuration_with_options( )); }; + let mut focus_redirect_result = Ok(Value::NIL); let snapshot = WINDOW_CONFIGURATION_SNAPSHOTS.with(|slot| { slot.borrow() .get(&serial) @@ -1875,7 +1876,9 @@ pub(crate) fn set_window_configuration_with_options( if let Some(frame_id) = frame_to_select { let _ = eval.frames.select_frame(frame_id); } - crate::emacs_core::frame::sync_gui_frame_focus_redirects(eval)?; + // The restored configuration is accepted; a host that cannot apply + // its focus redirects must not cancel the redisplay request below. + focus_redirect_result = crate::emacs_core::frame::sync_gui_frame_focus_redirects(eval); } // GNU `Fset_window_configuration` marks the frame for redisplay with @@ -1889,6 +1892,7 @@ pub(crate) fn set_window_configuration_with_options( // owned by `redisplay_with_force`, where the live-vs-recorded frame state // is compared exactly once per natural redisplay cycle. eval.request_menu_bar_rebuild(super::eval::MenuBarRebuildReason::WindowsOrBuffersChanged); + focus_redirect_result?; Ok(Value::T) } diff --git a/crates/neovm-core/src/window/frame_alpha/tests/mod.rs b/crates/neovm-core/src/window/frame_alpha/tests/mod.rs index 3bc73fb327..fe55b73099 100644 --- a/crates/neovm-core/src/window/frame_alpha/tests/mod.rs +++ b/crates/neovm-core/src/window/frame_alpha/tests/mod.rs @@ -70,3 +70,16 @@ fn gnu_frame_alpha_pair() { ); assert!(pair(Value::list(vec![Value::fixnum(80), Value::fixnum(101)])).is_err()); } +#[test] +fn gnu_frame_alpha_lower_limit_keeps_x_set_frame_alpha_values() { + // GNU x_set_frame_alpha: a fixnum is a percentage and a float a fraction; + // anything else leaves alpha_min at 1.0 (fully opaque). Out-of-range + // values pass through: alpha_min only applies when it is at most 1.0. + assert_eq!(lower_limit(Value::fixnum(20)), 0.2); + assert_eq!(lower_limit(Value::make_float(0.3)), 0.3); + assert_eq!(lower_limit(Value::NIL), 1.0); + assert_eq!(lower_limit(Value::symbol("bad")), 1.0); + assert_eq!(lower_limit(Value::fixnum(500)), 5.0); + assert_eq!(lower_limit(Value::fixnum(-10)), -0.1); + assert!(lower_limit(Value::make_float(f64::NAN)).is_nan()); +} diff --git a/docs/testing/transparency-tests.txt b/docs/testing/transparency-tests.txt index eaec198ed0..76737eb333 100644 --- a/docs/testing/transparency-tests.txt +++ b/docs/testing/transparency-tests.txt @@ -13,7 +13,9 @@ cpu neovm-core emacs_core::reader::tests::read_from_minibuffer_swallows_exit_hoo cpu neovm-core emacs_core::reader::tests::shared_child_minibuffer_publishes_focus_on_entry_and_unwind cpu neovm-core emacs_core::window_cmds::tests::accepted_root_and_child_alpha_operations_survive_numeric_then_nil_without_layout cpu neovm-core emacs_core::window_cmds::tests::alpha_lower_limit_variable_projects_without_replaying_alpha_policy +cpu neovm-core emacs_core::window_cmds::tests::focus_redirect_sync_failure_keeps_frame_creation_and_restoration_effects cpu neovm-core emacs_core::window_cmds::tests::x_create_frame_resolves_alpha_defaults_and_explicit_nil +cpu neovm-core emacs_core::window_cmds::tests::x_create_frame_returns_the_realized_frame_when_alpha_sync_fails gpu neomacs-renderer-wgpu fade_edges_test::dispatched_fade_edges_preserves_fractional_complementary_pictures gpu neomacs-renderer-wgpu fade_edges_test::dispatched_fade_edges_preserves_opaque_complementary_pictures gpu neomacs-renderer-wgpu fade_edges_test::dispatched_page_curl_keeps_ordered_source_over_occlusion @@ -29,6 +31,7 @@ gpu neomacs-renderer-wgpu opacity_test::dispatched_scale_zoom_preserves_overlap_ gpu neomacs-renderer-wgpu opacity_test::mandatory_child_picture_refusal_is_typed_and_recovers_after_lease_release gpu neomacs-renderer-wgpu opacity_test::native_inset_clear_matches_completed_content_opacity gpu neomacs-renderer-wgpu opacity_test::pane_previous_patches_interpolate_complete_colored_pictures +gpu neomacs-renderer-wgpu opacity_test::source_over_transitions_fill_uncovered_geometry_with_the_background gpu neomacs-renderer-wgpu opacity_test::transition_postprocess_preserves_alpha_and_color_modulation cpu neomacs-layout-engine output::builder::tests::bidi_restamps_terminals_from_visual_boxed_adjacency cpu neomacs-layout-engine output::builder::tests::buffer_mouse_face_is_derived_only_from_the_final_authoritative_row @@ -140,6 +143,9 @@ gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_o gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::stale_static_generation_and_census_retire_under_held_pressure gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::terminal_unique_pane_retires_without_surrendering_fractional_native gpu neomacs-display-runtime render_thread::render_pass::scene::tests::retained_owner::wrong_size_post_retires_under_held_pressure +gpu neomacs-display-runtime render_thread::render_pass::tests::drawable_native_surface_refreshes_suspended_cached_mapping +gpu neomacs-display-runtime render_thread::render_pass::tests::drawable_surface_without_editor_content_is_still_awaiting_content +gpu neomacs-display-runtime render_thread::render_pass::tests::suspended_native_surface_is_window_not_ready_with_or_without_content gpu neomacs-display-runtime render_thread::transitions::tests::default_transition_state_has_expected_policy_defaults gpu neomacs-display-runtime render_thread::transitions::tests::default_transition_state_starts_without_active_transitions gpu neomacs-display-runtime render_thread::transitions::tests::frame_buffer_hint_plans_one_synchronized_group_for_each_viewport @@ -165,6 +171,10 @@ gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::a_texture_the_render_t gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::the_pool_reclaims_before_it_decides_whether_a_lease_fits gpu neomacs-renderer-wgpu renderer::snapshot_pool::tests::the_pool_refuses_a_lease_that_would_cross_the_ceiling_rather_than_allocating_it gpu neomacs-renderer-wgpu renderer::transitions::tests::slide_offsets_follow_the_typed_axis_and_direction +gpu neomacs-renderer-wgpu renderer::transitions::tests::card_flip_complement_never_fills_beneath_the_picture +gpu neomacs-renderer-wgpu renderer::transitions::tests::full_size_endpoints_need_no_weighted_background +gpu neomacs-renderer-wgpu renderer::transitions::tests::geometric_background_complements_preserve_weight_and_fractional_alpha +gpu neomacs-renderer-wgpu renderer::transitions::tests::parallax_complements_fill_each_vacated_band_by_its_picture_weight cpu neomacs-display-runtime thread_comm::frame_opacity::tests::accepted_numeric_then_nil_survives_absent_and_replaced_scenes cpu neomacs-display-runtime thread_comm::frame_opacity::tests::lower_limit_changes_apply_at_focus_without_replaying_nil cpu neomacs-display-runtime thread_comm::frame_opacity::tests::nil_component_retains_last_scalar_not_previous_policy_component @@ -174,5 +184,6 @@ cpu neomacs-display-runtime thread_comm::frame_opacity::tests::shared_child_read cpu neomacs-display-runtime thread_comm::frame_opacity::tests::unchanged_highlight_and_stale_focus_do_not_reapply_policy cpu neomacs-display-runtime thread_comm::tests::accepted_opacity_is_independent_of_superseded_visual_mailbox cpu neovm-core window::frame_alpha::tests::gnu_frame_alpha_components +cpu neovm-core window::frame_alpha::tests::gnu_frame_alpha_lower_limit_keeps_x_set_frame_alpha_values cpu neovm-core window::frame_alpha::tests::gnu_frame_alpha_pair cpu neovm-core window::frame_alpha::tests::gnu_frame_alpha_raw_and_accepted_state_are_distinct diff --git a/docs/testing/transparency.md b/docs/testing/transparency.md index bf5cd1507b..642a97a516 100644 --- a/docs/testing/transparency.md +++ b/docs/testing/transparency.md @@ -15,7 +15,11 @@ For example: The compositor keeps completed pictures premultiplied. Background alpha applies to background layers, and frame/lifecycle opacity applies once to the completed picture. Complementary transition pictures add their weighted colors and alpha; -ordered effects such as PageCurl still use source-over. Native output converts +ordered effects such as PageCurl still use source-over. Where a transition's +pictures leave part of its region uncovered, the frame background shows there +at its own alpha. Source-over effects that fade a picture partway (Cascade, +CylinderRoll, TypewriterReveal) still show that picture's covered pixels at +its partial weight. Native output converts at the final boundary, including pane motion and uncovered child fill. Fractional child and pane pictures need scratch textures. Before mandatory From 6833ab02e0da5a7a07342f10079116b7d62fc795 Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Wed, 7 Oct 2026 12:21:54 +0300 Subject: [PATCH 3/4] test(display): require subprocess guards to run exactly one test libtest exits successfully when --exact matches nothing, so a renamed GPU-budget test passed without running its body. Share one guard per crate that also checks the child reported exactly one passed test. --- .../composition_targets/tests/mod.rs | 17 +++--- .../scene/native_boundary_tests.rs | 8 ++- .../scene/pane_size_boundary_tests.rs | 12 ++--- .../render_pass/scene/retained_owner_tests.rs | 14 ++--- .../render_thread/render_pass/scene/tests.rs | 25 ++++----- .../render_thread/render_pass/tests/mod.rs | 53 +++++++++++++++++++ .../tests/offscreen_frame/opacity_test.rs | 42 +++++++++++++-- 7 files changed, 119 insertions(+), 52 deletions(-) diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets/tests/mod.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets/tests/mod.rs index ac19cda8bf..d678d435e5 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets/tests/mod.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets/tests/mod.rs @@ -1,11 +1,10 @@ +use super::super::tests::in_gpu_budget_child; use super::*; #[test] fn mandatory_child_target_refuses_before_present_and_recovers() { - if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { - let status = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "render_thread::render_pass::composition_targets::tests::mandatory_child_target_refuses_before_present_and_recovers", "--nocapture"]) - .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); - assert!(status.success()); + if !in_gpu_budget_child( + "render_thread::render_pass::composition_targets::tests::mandatory_child_target_refuses_before_present_and_recovers", + ) { return; } let mut renderer = WgpuRenderer::new(None, 96, 64).expect("real GPU required"); @@ -36,11 +35,9 @@ fn mandatory_child_target_refuses_before_present_and_recovers() { #[test] fn mandatory_resize_pair_releases_first_lease_when_second_is_refused() { - if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { - let status = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "render_thread::render_pass::composition_targets::tests::mandatory_resize_pair_releases_first_lease_when_second_is_refused", "--nocapture"]) - .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); - assert!(status.success()); + if !in_gpu_budget_child( + "render_thread::render_pass::composition_targets::tests::mandatory_resize_pair_releases_first_lease_when_second_is_refused", + ) { return; } let mut renderer = WgpuRenderer::new(None, 96, 64).expect("GPU required"); diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs index dde5235694..52233f28ad 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs @@ -19,11 +19,9 @@ pub(super) fn full_plan() -> crate::render_thread::render_quality::RenderFeature #[test] fn obsolete_native_owner_retires_before_required_child_under_same_pressure() { - if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { - let status = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "render_thread::render_pass::scene::tests::native_boundary::obsolete_native_owner_retires_before_required_child_under_same_pressure", "--nocapture"]) - .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); - assert!(status.success()); + if !in_gpu_budget_child( + "render_thread::render_pass::scene::tests::native_boundary::obsolete_native_owner_retires_before_required_child_under_same_pressure", + ) { return; } let mut renderer = WgpuRenderer::new(None, W, H).expect("GPU required"); diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs index 2612ba5c25..fc3a388f42 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/pane_size_boundary_tests.rs @@ -215,9 +215,9 @@ fn fractional_outgoing_panes_use_final_native_conversion_and_settle_direct() { #[test] fn pane_conversion_refuses_before_motion_and_recovers_under_held_pressure() { - if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { - let status=std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact","render_thread::render_pass::scene::tests::pane_size_boundary::pane_conversion_refuses_before_motion_and_recovers_under_held_pressure","--nocapture"]).env("NEOMACS_GPU_BUDGET_MB","1").status().unwrap(); - assert!(status.success()); + if !in_gpu_budget_child( + "render_thread::render_pass::scene::tests::pane_size_boundary::pane_conversion_refuses_before_motion_and_recovers_under_held_pressure", + ) { return; } for active in [false, true] { @@ -369,9 +369,9 @@ fn pane_conversion_refuses_before_motion_and_recovers_under_held_pressure() { #[test] fn wrong_size_required_native_owner_retires_before_child_under_same_pressure() { - if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { - let status=std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact","render_thread::render_pass::scene::tests::pane_size_boundary::wrong_size_required_native_owner_retires_before_child_under_same_pressure","--nocapture"]).env("NEOMACS_GPU_BUDGET_MB","1").status().unwrap(); - assert!(status.success()); + if !in_gpu_budget_child( + "render_thread::render_pass::scene::tests::pane_size_boundary::wrong_size_required_native_owner_retires_before_child_under_same_pressure", + ) { return; } let mut renderer = WgpuRenderer::new(None, W, H).expect("real GPU required"); diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs index 7846c4065d..a72ce8ca74 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs @@ -14,17 +14,9 @@ use neomacs_renderer_wgpu::SnapshotLease; use neomacs_renderer_wgpu::renderer::{NativeContentPlacement, RenderTarget}; fn budget_one(name: &str) -> bool { - if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() == Ok("1") { - return true; - } - let exact = format!("render_thread::render_pass::scene::tests::retained_owner::{name}"); - let status = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", &exact, "--nocapture"]) - .env("NEOMACS_GPU_BUDGET_MB", "1") - .status() - .unwrap(); - assert!(status.success()); - false + in_gpu_budget_child(&format!( + "render_thread::render_pass::scene::tests::retained_owner::{name}" + )) } fn setup() -> (WgpuRenderer, GuiFrameRenderState, FrameGlyphBuffer) { diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs index 45fea87227..1bce252049 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs @@ -113,11 +113,9 @@ fn seed_pending_layout( #[test] fn mixed_policy_child_admission_tracks_applied_highlight_under_held_pressure() { - if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { - let status = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "render_thread::render_pass::scene::tests::mixed_policy_child_admission_tracks_applied_highlight_under_held_pressure", "--nocapture"]) - .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); - assert!(status.success()); + if !in_gpu_budget_child( + "render_thread::render_pass::scene::tests::mixed_policy_child_admission_tracks_applied_highlight_under_held_pressure", + ) { return; } let mut renderer = @@ -336,11 +334,9 @@ fn mixed_policy_child_admission_tracks_applied_highlight_under_held_pressure() { #[test] fn expired_child_resize_recovers_under_unchanged_budget_pressure() { - if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { - let status = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "render_thread::render_pass::scene::tests::expired_child_resize_recovers_under_unchanged_budget_pressure", "--nocapture"]) - .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); - assert!(status.success()); + if !in_gpu_budget_child( + "render_thread::render_pass::scene::tests::expired_child_resize_recovers_under_unchanged_budget_pressure", + ) { return; } let mut renderer = WgpuRenderer::new(None, W, H).expect("GPU required for expiry regression"); @@ -711,11 +707,9 @@ fn production_split_delete_placements_keep_background_opacity() { #[test] fn expired_child_lifecycle_needs_no_scratch_under_unchanged_pressure() { - if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { - let status=std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact","render_thread::render_pass::scene::tests::expired_child_lifecycle_needs_no_scratch_under_unchanged_pressure","--nocapture"]) - .env("NEOMACS_GPU_BUDGET_MB","1").status().unwrap(); - assert!(status.success()); + if !in_gpu_budget_child( + "render_thread::render_pass::scene::tests::expired_child_lifecycle_needs_no_scratch_under_unchanged_pressure", + ) { return; } for closing in [false, true] { @@ -796,6 +790,7 @@ fn expired_child_lifecycle_needs_no_scratch_under_unchanged_pressure() { } } +use super::super::tests::in_gpu_budget_child; use neomacs_display_protocol::TransitionEasing; use neomacs_display_protocol::frame_time::{FrameSample, observe_platform_now}; use neomacs_display_protocol::motion_spec::{MotionDuration, MotionSpec, TweenSpec}; diff --git a/crates/neomacs-display-runtime/src/render_thread/render_pass/tests/mod.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/tests/mod.rs index fd99f5d62e..2265d94136 100644 --- a/crates/neomacs-display-runtime/src/render_thread/render_pass/tests/mod.rs +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/tests/mod.rs @@ -65,3 +65,56 @@ fn drawable_surface_without_editor_content_is_still_awaiting_content() { Err(FrameRenderFailure::AwaitingContent) ); } + +/// Run the test named `exact` in a child test process under a 1 MiB GPU +/// budget, so the budget never leaks into concurrently running tests. +/// Returns true inside that child, where the caller runs its body. In the +/// parent it requires the child to have run exactly one test and passed: +/// libtest exits successfully when `--exact` matches nothing. +pub(super) fn in_gpu_budget_child(exact: &str) -> bool { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() == Ok("1") { + return true; + } + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", exact, "--nocapture"]) + .env("NEOMACS_GPU_BUDGET_MB", "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success() && ran_one_passing_test(&stdout), + "budget child for {exact} did not run exactly one passing test\n{stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ); + false +} + +/// Whether libtest output reports exactly one passed and no failed test. +fn ran_one_passing_test(stdout: &str) -> bool { + let mut results = stdout + .lines() + .filter_map(|line| line.strip_prefix("test result: ok. ")); + let (Some(counts), None) = (results.next(), results.next()) else { + return false; + }; + let count = |label: &str| { + counts + .split("; ") + .find_map(|part| part.strip_suffix(label)?.parse::().ok()) + }; + count(" passed") == Some(1) && count(" failed") == Some(0) +} + +#[test] +fn budget_child_guard_rejects_runs_that_match_no_test() { + let summary = |passed, filtered| { + format!( + "running {passed} test\n\ntest result: ok. {passed} passed; 0 failed; \ + 0 ignored; 0 measured; {filtered} filtered out; finished in 0.01s\n" + ) + }; + assert!(ran_one_passing_test(&summary(1, 41))); + assert!(!ran_one_passing_test(&summary(0, 42))); + assert!(!ran_one_passing_test(&summary(2, 40))); + assert!(!ran_one_passing_test("")); +} diff --git a/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs index 522d5d4dc4..809dc9941f 100644 --- a/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs +++ b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs @@ -687,17 +687,49 @@ fn child_char_and_stretch_stipple_is_foreground_at_zero_background() { } } +/// Run the test named `exact` in a child test process under a 1 MiB GPU +/// budget. Returns true inside that child, where the caller runs its body. +/// In the parent it requires the child to have run exactly one test and +/// passed: libtest exits successfully when `--exact` matches nothing. +fn in_gpu_budget_child(exact: &str) -> bool { + if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() == Ok("1") { + return true; + } + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", exact, "--nocapture"]) + .env("NEOMACS_GPU_BUDGET_MB", "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + let mut results = stdout + .lines() + .filter_map(|line| line.strip_prefix("test result: ok. ")); + let counts = match (results.next(), results.next()) { + (Some(counts), None) => counts, + _ => "", + }; + let count = |label: &str| { + counts + .split("; ") + .find_map(|part| part.strip_suffix(label)?.parse::().ok()) + }; + assert!( + out.status.success() && count(" passed") == Some(1) && count(" failed") == Some(0), + "budget child for {exact} did not run exactly one passing test\n{stdout}\n{}", + String::from_utf8_lossy(&out.stderr) + ); + false +} + /// Run in the bounded GPU lane with NEOMACS_GPU_BUDGET_MB=1. Real held /// leases deny the required picture; no opaque substitute is rendered. #[test] fn mandatory_child_picture_refusal_is_typed_and_recovers_after_lease_release() { // Isolate the budget knob in a child test process: never mutate the // environment of other concurrently running GPU tests. - if std::env::var("NEOMACS_GPU_BUDGET_MB").as_deref() != Ok("1") { - let status = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "opacity_test::mandatory_child_picture_refusal_is_typed_and_recovers_after_lease_release", "--nocapture"]) - .env("NEOMACS_GPU_BUDGET_MB", "1").status().unwrap(); - assert!(status.success()); + if !in_gpu_budget_child( + "opacity_test::mandatory_child_picture_refusal_is_typed_and_recovers_after_lease_release", + ) { return; } let mut h = try_harness().expect("real GPU adapter required"); From 816c7fc39f75a48dd5463438152fa09f0974e10c Mon Sep 17 00:00:00 2001 From: Thanos Apollo Date: Wed, 7 Oct 2026 12:21:55 +0300 Subject: [PATCH 4/4] perf(eval): cache the frame-alpha-lower-limit symbol Every projected variable write re-interned the name. Use a cached symbol id, as max-lisp-eval-depth already does. --- crates/neovm-core/src/emacs_core/runtime/eval/mod.rs | 5 +++-- .../src/emacs_core/runtime/eval/runtime_projection.rs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs b/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs index 775daae767..8210108a86 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/mod.rs @@ -1997,6 +1997,7 @@ cached_symbol_id!(closure_symbol, "closure"); cached_symbol_id!(declare_symbol, "declare"); cached_symbol_id!(macro_symbol, "macro"); cached_symbol_id!(max_lisp_eval_depth_symbol, "max-lisp-eval-depth"); +cached_symbol_id!(frame_alpha_lower_limit_symbol, "frame-alpha-lower-limit"); cached_symbol_id!(byte_code_literal_symbol, "byte-code-literal"); cached_symbol_id!(byte_code_symbol, "byte-code"); cached_symbol_id!(input_decode_map_symbol, "input-decode-map"); @@ -2090,7 +2091,7 @@ fn install_core_eval_symbols(obarray: &mut Obarray, reset_runtime_values: bool) print_symbols_bare_symbol, max_lisp_eval_depth_symbol(), buffer_undo_list_symbol(), - intern("frame-alpha-lower-limit"), + frame_alpha_lower_limit_symbol(), ] { obarray.mark_runtime_projected_id(projected); } @@ -4615,7 +4616,7 @@ impl Context { self.symbols_with_pos_enabled = value.is_truthy(); } else if sym_id == self.print_symbols_bare_symbol { self.print_symbols_bare = value.is_truthy(); - } else if sym_id == intern("frame-alpha-lower-limit") { + } else if sym_id == frame_alpha_lower_limit_symbol() { let limit = crate::window::frame_alpha::lower_limit(value); if let Some(host) = self.display_host.as_mut() { host.set_gui_frame_alpha_lower_limit(limit); diff --git a/crates/neovm-core/src/emacs_core/runtime/eval/runtime_projection.rs b/crates/neovm-core/src/emacs_core/runtime/eval/runtime_projection.rs index 4cb53a615c..673d3005ee 100644 --- a/crates/neovm-core/src/emacs_core/runtime/eval/runtime_projection.rs +++ b/crates/neovm-core/src/emacs_core/runtime/eval/runtime_projection.rs @@ -36,7 +36,7 @@ pub(super) fn runtime_projection_mask_for(core_mirrors: &[SymId]) -> Box<[u64]> let mut ids: Vec = core_mirrors.to_vec(); ids.extend([ max_lisp_eval_depth_symbol(), - intern("frame-alpha-lower-limit"), + frame_alpha_lower_limit_symbol(), input_decode_map_symbol(), local_function_key_map_symbol(), ]); @@ -96,7 +96,7 @@ impl Context { || resolved == self.symbols_with_pos_enabled_symbol || resolved == self.print_symbols_bare_symbol || resolved == max_lisp_eval_depth_symbol() - || resolved == intern("frame-alpha-lower-limit") + || resolved == frame_alpha_lower_limit_symbol() || resolved == input_decode_map_symbol() || resolved == local_function_key_map_symbol() || self.is_gc_runtime_setting_symbol(resolved)