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..d678d435e5 --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/composition_targets/tests/mod.rs @@ -0,0 +1,69 @@ +use super::super::tests::in_gpu_budget_child; +use super::*; +#[test] +fn mandatory_child_target_refuses_before_present_and_recovers() { + 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"); + 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 !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"); + 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..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 @@ -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, @@ -99,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(); @@ -117,6 +109,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..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, @@ -155,6 +173,62 @@ 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. + 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 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 +245,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 +256,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 +265,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 +433,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 +500,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); @@ -447,3 +514,7 @@ fn render_frame_window_contents_to_surface( projection: pane_projection, }) } + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; 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..52233f28ad --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/native_boundary_tests.rs @@ -0,0 +1,344 @@ +//! 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 !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"); + 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, + &new_frame, + ); + 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() + ); + // 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 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(); + 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..fc3a388f42 --- /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 !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] { + 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 !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"); + 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..a72ce8ca74 --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/retained_owner_tests.rs @@ -0,0 +1,570 @@ +//! 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 { + in_gpu_budget_child(&format!( + "render_thread::render_pass::scene::tests::retained_owner::{name}" + )) +} + +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, + &root, + ); + 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..1bce252049 --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/scene/tests.rs @@ -0,0 +1,1108 @@ +//! 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 !in_gpu_budget_child( + "render_thread::render_pass::scene::tests::mixed_policy_child_admission_tracks_applied_highlight_under_held_pressure", + ) { + 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 !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"); + 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 !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] { + 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 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}; +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/render_pass/tests/mod.rs b/crates/neomacs-display-runtime/src/render_thread/render_pass/tests/mod.rs new file mode 100644 index 0000000000..2265d94136 --- /dev/null +++ b/crates/neomacs-display-runtime/src/render_thread/render_pass/tests/mod.rs @@ -0,0 +1,120 @@ +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) + ); +} + +/// 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-display-runtime/src/render_thread/transitions.rs b/crates/neomacs-display-runtime/src/render_thread/transitions.rs index c8c36307f1..2f6509b798 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() } @@ -470,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 { @@ -495,6 +547,8 @@ pub(super) fn render_frame_transitions( transition.plan.easing, width, height, + frame.background, + frame.background_alpha, ); } 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..7e66aa9d40 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_with = |blend, entry_point, depth_stencil| { 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, @@ -684,7 +730,7 @@ impl WgpuRenderer { unclipped_depth: false, conservative: false, }, - depth_stencil: None, + depth_stencil, multisample: wgpu::MultisampleState { count: 1, mask: !0, @@ -694,9 +740,62 @@ 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"); + 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 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, + alpha: additive_component, + }), + "fs_copy", + ); - let image_pipeline = make_image_pipeline(Some(wgpu::BlendState::ALPHA_BLENDING)); - let surface_copy_pipeline = make_image_pipeline(None); + // 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 +894,75 @@ impl WgpuRenderer { bias: wgpu::DepthBiasState::default(), }; - // 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, + // 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, }, - cache: None, - multiview_mask: None, - }); + 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), + 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 = 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 +1143,32 @@ impl WgpuRenderer { surface_format: target_format, 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, 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..e7225d4bc0 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/resources.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/resources.rs @@ -18,18 +18,32 @@ 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) 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, + 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..71b15098f6 100644 --- a/crates/neomacs-renderer-wgpu/src/renderer/transitions.rs +++ b/crates/neomacs-renderer-wgpu/src/renderer/transitions.rs @@ -1,13 +1,46 @@ //! 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::{ AxisMotionTransitionEffect, DirectionlessTransitionEffect, HorizontalTransitionEffect, ResolvedTransitionEffect, TransitionAxis, TransitionDirection, TransitionEasing, 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); +/// their uncovered geometry shows the frame background. +#[derive(Clone, Copy)] +enum TransitionComposition { + SourceOver(TransitionBackground), + WeightedPictures, +} + fn slide_offsets( axis: TransitionAxis, direction: TransitionDirection, @@ -30,7 +63,155 @@ 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( + &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 +344,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 +355,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 +406,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 +488,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 @@ -342,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) => { @@ -366,6 +553,7 @@ impl WgpuRenderer { bounds, surface_width, surface_height, + background, ); } ResolvedTransitionEffect::AxisMotion { @@ -404,6 +592,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ); } ResolvedTransitionEffect::CardFlip { axis } => { @@ -416,6 +605,7 @@ impl WgpuRenderer { bounds, surface_width, surface_height, + background, ); } ResolvedTransitionEffect::PageCurl { edge } => { @@ -428,6 +618,7 @@ impl WgpuRenderer { bounds, surface_width, surface_height, + background, ); } ResolvedTransitionEffect::Vertical { @@ -446,6 +637,7 @@ impl WgpuRenderer { effect, surface_width, surface_height, + background, ), ResolvedTransitionEffect::VerticalPostProcess { effect, @@ -463,6 +655,7 @@ impl WgpuRenderer { effect, surface_width, surface_height, + background, ), ResolvedTransitionEffect::Horizontal { effect: HorizontalTransitionEffect::TypewriterReveal, @@ -477,6 +670,7 @@ impl WgpuRenderer { bounds, surface_width, surface_height, + background, ); } } @@ -496,6 +690,7 @@ impl WgpuRenderer { effect: VerticalTransitionEffect, surface_width: u32, surface_height: u32, + background: TransitionBackground, ) { match effect { VerticalTransitionEffect::FadeEdges => self.render_transition_fade_edges( @@ -520,6 +715,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::Tilt => self.render_transition_tilt( surface_view, @@ -531,6 +727,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::CylinderRoll => self.render_transition_cylinder_roll( surface_view, @@ -542,6 +739,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::Wobbly => self.render_transition_wobbly( surface_view, @@ -554,6 +752,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::Wave => self.render_transition_wave( surface_view, @@ -566,6 +765,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::PerLineSpring => self.render_transition_per_line_spring( surface_view, @@ -578,6 +778,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), VerticalTransitionEffect::Liquid => self.render_transition_liquid( surface_view, @@ -590,6 +791,7 @@ impl WgpuRenderer { distance, surface_width, surface_height, + background, ), } } @@ -632,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, @@ -639,13 +848,61 @@ 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, ) { - let old_upload = self.create_transition_vb(old_vertices); - let new_upload = self.create_transition_vb(new_vertices); + // 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 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 @@ -664,13 +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(&self.pipelines.image); + 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 { @@ -684,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. @@ -699,91 +985,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, ); } @@ -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) { @@ -864,10 +1074,21 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::WeightedPictures, sx, sy, sw, sh, + surface_width, + surface_height, + ); + self.fill_transition_quad_gaps( + surface_view, + bounds, + &[&old_verts, &new_verts], + background, + surface_width, + surface_height, ); } @@ -959,16 +1180,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, ); } @@ -985,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) { @@ -1064,10 +1291,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1084,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) { @@ -1145,10 +1376,22 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::WeightedPictures, sx, sy, sw, sh, + 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, ); } @@ -1164,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) { @@ -1244,10 +1488,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1262,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) = @@ -1407,10 +1655,13 @@ impl WgpuRenderer { old_bind_group, &new_verts, &old_verts, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1425,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) { @@ -1492,10 +1744,13 @@ impl WgpuRenderer { bind_group, &verts, &empty, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1511,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) { @@ -1594,10 +1850,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1614,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) = @@ -1684,10 +1944,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1704,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) { @@ -1775,10 +2039,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1795,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) { @@ -1879,10 +2147,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1899,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) = @@ -1969,10 +2241,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -1993,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) { @@ -2111,10 +2387,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } @@ -2129,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) { @@ -2253,10 +2533,13 @@ impl WgpuRenderer { new_bind_group, &old_verts, &new_verts, + TransitionComposition::SourceOver(background), sx, sy, sw, sh, + surface_width, + surface_height, ); } } 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/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..db53c8bd70 --- /dev/null +++ b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/fade_edges_test.rs @@ -0,0 +1,282 @@ +//! 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, + Color::BLUE, + alpha, + ); + 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, + Color::BLUE, + alpha, + ); + 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..809dc9941f --- /dev/null +++ b/crates/neomacs-renderer-wgpu/tests/offscreen_frame/opacity_test.rs @@ -0,0 +1,891 @@ +//! 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.25, 0.5, 0.75, 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, + frame.background, + frame.background_alpha, + ); + 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:?}" + ); + 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}" + ); + 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, + frame.background, + frame.background_alpha, + ); + 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, + frame.background, + frame.background_alpha, + ); + 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, + frame.background, + frame.background_alpha, + ); + 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 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 !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"); + 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); +} + +#[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/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..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 @@ -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,31 @@ 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() + { + // 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 } @@ -7110,6 +7147,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 +7599,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 +7617,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..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 @@ -24,6 +24,140 @@ 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_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 [ + ("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 +323,10 @@ 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>>, destroyed_gui_frames: Rc>>, @@ -262,6 +400,32 @@ 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)); + 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> { 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..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 @@ -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 { @@ -1695,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) @@ -1867,6 +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); } + // 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 @@ -1880,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) } @@ -1938,7 +1951,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 +1964,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 +1973,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..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,6 +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(), + frame_alpha_lower_limit_symbol(), ] { obarray.mark_runtime_projected_id(projected); } @@ -4614,6 +4616,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 == 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); + } } 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..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,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(), + frame_alpha_lower_limit_symbol(), 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 == frame_alpha_lower_limit_symbol() || 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..fe55b73099 --- /dev/null +++ b/crates/neovm-core/src/window/frame_alpha/tests/mod.rs @@ -0,0 +1,85 @@ +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()); +} +#[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/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..76737eb333 --- /dev/null +++ b/docs/testing/transparency-tests.txt @@ -0,0 +1,189 @@ +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::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 +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::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 +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::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 +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 +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 +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_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 new file mode 100644 index 0000000000..642a97a516 --- /dev/null +++ b/docs/testing/transparency.md @@ -0,0 +1,93 @@ +# 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. 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 +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)