diff --git a/crates/slag-core/src/runtime.rs b/crates/slag-core/src/runtime.rs index 51e9329..e639a45 100644 --- a/crates/slag-core/src/runtime.rs +++ b/crates/slag-core/src/runtime.rs @@ -257,6 +257,38 @@ mod tests { assert_eq!(out.last(), Some(&SteelVal::IntV(22))); } + /// `#:fit 'contain` must survive the whole trip: Steel keyword plist -> + /// element hash -> decode. A quoted symbol is what packs actually pass, + /// and a silent decode miss turns every video back into a cover-crop. + #[test] + fn video_fit_contain_decodes_from_a_real_symbol() { + let rt = runtime(bundled_only()); + let out = rt + .run(&format!( + r#"(require "{UI_MODULE}") + (list (video "k" #:fit 'contain) + (video "k" #:fit (and #t 'contain)) + (video "k" #:fit (and #f 'contain)) + (video "k"))"# + )) + .expect("video build failed"); + let SteelVal::ListV(videos) = out.last().expect("no output") else { + panic!("expected a list of videos"); + }; + let contains: Vec = videos + .iter() + .map(|v| match crate::decode::decode(v).root.kind { + crate::element::Kind::Video { contain, .. } => contain, + other => panic!("expected a video, got {other:?}"), + }) + .collect(); + assert_eq!( + contains, + vec![true, true, false, false], + "literal and (and ...) symbols must decode; falsy/absent stay cover" + ); + } + /// A component loaded under a prefix must expose its own `render`, which is /// what lets every component share one engine. #[test] diff --git a/crates/slag-gpui/src/materialize.rs b/crates/slag-gpui/src/materialize.rs index 92aded0..759e700 100644 --- a/crates/slag-gpui/src/materialize.rs +++ b/crates/slag-gpui/src/materialize.rs @@ -21,9 +21,9 @@ use std::collections::HashMap; use std::sync::Arc; use gpui::{ - div, img, px, AnyElement, Div, Entity, FontFeatures, Hsla, InteractiveElement, IntoElement, - ObjectFit, ParentElement, RenderImage, ScrollHandle, SharedString, - StatefulInteractiveElement, Styled, StyledImage, WeakEntity, Window, + canvas, div, img, px, AnyElement, Corners, Div, Entity, FontFeatures, Hsla, InteractiveElement, + IntoElement, ObjectFit, ParentElement, RenderImage, ScrollHandle, SharedString, + StatefulInteractiveElement, Styled, WeakEntity, Window, }; use gpui_component::{ alert::Alert, @@ -989,9 +989,16 @@ fn build_key_capture( } /// Build a video surface: the last-presented frame (see `ensure_videos`) -/// cover-fitted into the styled box, or an empty panel before the first frame. -/// The frame is looked up in the component-owned map, not fetched from the app, -/// so one render pass presents one consistent frame per key. +/// fitted into the styled box (`#:fit 'contain` letterboxes, the default +/// cover-crops), or an empty panel before the first frame. The frame is looked +/// up in the component-owned map, not fetched from the app, so one render pass +/// presents one consistent frame per key. +/// +/// Painted through `canvas`, not gpui's `img`: `img` writes the image's aspect +/// ratio into its own layout style, which overrides `size_full` in taffy — the +/// element lays out at full width times aspect height, overflows the box, and +/// the clip eats the bottom of every frame whose aspect is narrower than the +/// box, regardless of the requested object fit. #[inline(never)] fn build_video( el: &Element, @@ -1007,7 +1014,19 @@ fn build_video( }; let boxed = apply_style(div(), &el.style, theme).overflow_hidden(); let boxed = match ctx.videos.get(key) { - Some(frame) => boxed.child(img(frame.clone()).size_full().object_fit(fit)), + Some(frame) => { + let frame = frame.clone(); + boxed.child( + canvas( + |_, _, _| (), + move |bounds, _, window, _| { + let target = fit.get_bounds(bounds, frame.size(0)); + let _ = window.paint_image(target, Corners::default(), frame, 0, false); + }, + ) + .size_full(), + ) + } None => boxed.bg(theme.secondary), }; finish_interactive(boxed, el, ctx)