use galaxyui_core::assets::asset_cache::AssetSource; use galaxyui_core::elements::{ Align, CacheOption, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Empty, Expanded, Flex, Image, MainAxisSize, ParentElement, Point, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Stack, }; use galaxyui_core::event::DispatchedEvent; use galaxyui_core::{ AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, SizeConstraint, }; use pathfinder_geometry::vector::{vec2f, Vector2F}; // Onboarding images live under `app/assets/async/` so they are excluded from the WASM // binary (RustEmbed excludes `async/**` on wasm targets). They are still bundled normally // on native builds. Unlike other `async/` assets these are NOT wired up with // `bundled_or_fetched_asset!`, so they cannot be fetched remotely on web. We can't use // that macro here because it resolves paths relative to CARGO_MANIFEST_DIR (i.e. // `crates/onboarding/`), but the assets live under `app/assets/`. Onboarding is not // shown on web, so this is fine. // TODO(APP-3934): support the macro outside the app crate pub const ONBOARDING_BG_PATH: &str = "async/png/onboarding/onboarding_bg.png"; const LEFT_COLUMN_WIDTH: f32 = 580.; const LEFT_COLUMN_CONTENT_MAX_WIDTH: f32 = 800.; const MIN_RIGHT_COLUMN_WIDTH: f32 = 540.; /// The minimum window width at which the two-column layout is shown (left content + right panel). /// Below this width, `static_left` collapses to a single left-only column. pub const TWO_COLUMN_MIN_WIDTH: f32 = LEFT_COLUMN_WIDTH + MIN_RIGHT_COLUMN_WIDTH; /// Creates a two-column layout with a fixed-width left column and flexible right column. /// /// The left column's *content* is constrained to [`LEFT_COLUMN_CONTENT_MAX_WIDTH`] and is always /// horizontally centered within the left panel. /// /// If the available width is too narrow for the right column to have at least /// [`MIN_RIGHT_COLUMN_WIDTH`], we instead render only the left column and center it. /// /// # Arguments /// * `left` - Builder for the element to display in the left column ([`LEFT_COLUMN_WIDTH`] px) /// * `right` - Builder for the element to display in the right column (flexible width) /// /// # Returns /// A `Box` containing the responsive layout pub fn static_left( left: impl Fn() -> Box, right: impl FnOnce() -> Box, ) -> Box { let max_width_for_two_columns = LEFT_COLUMN_WIDTH + MIN_RIGHT_COLUMN_WIDTH; let left_constrained = || { ConstrainedBox::new(left()) .with_max_width(LEFT_COLUMN_CONTENT_MAX_WIDTH) .finish() }; // Narrow layout: show only the left section, centered. // Use Align instead of a max-sized Flex so we can safely center even when the incoming // height constraint is unbounded. let left_only = Align::new(left_constrained()).finish(); // Default layout: fixed-width left + flexible right. // Use Align instead of a max-sized Flex so we can safely center even when the incoming // height constraint is unbounded. let left_centered = Align::new(left_constrained()).finish(); let left_fixed_width = Container::new( ConstrainedBox::new(left_centered) .with_width(LEFT_COLUMN_WIDTH) .finish(), ) .finish(); let right_flexible = Shrinkable::new(1., Container::new(right()).finish()).finish(); let two_column_layout = Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(left_fixed_width) .with_child(right_flexible) .finish(), ) .finish(); SizeConstraintSwitch::new( two_column_layout, vec![( SizeConstraintCondition::WidthLessThan(max_width_for_two_columns), left_only, )], ) .finish() } /// How horizontal space around the foreground image is applied. #[derive(Clone, Copy)] pub enum HPadding { /// Symmetric fixed padding on both sides (in pixels). Used for the default layout. Fixed(f32), /// Symmetric padding on both sides scales with panel width. /// Value is a fraction of panel width per side (e.g. `39. / 640.`). ProportionalBoth(f32), /// Left padding scales with panel width; image fills the rest to the right edge. /// Value is a fraction of panel width (e.g. `39. / 640.`). ProportionalLeft(f32), /// Right padding scales with panel width; image fills the rest to the left edge. /// `ratio` is a fraction of panel width (e.g. `69. / 640.`). /// `left_offset` is a fixed pixel adjustment to the image's left edge; use a negative /// value to shift left when the asset has built-in visual padding on that side. ProportionalRight { ratio: f32, left_offset: f32 }, } /// How the vertical top offset for the foreground image is determined. #[derive(Clone, Copy)] pub enum TopMode { /// Fixed fraction of panel height: `top = panel_height × ratio`. Ratio(f32), /// Dynamic: centers a specific fraction of the image's rendered height in the panel. /// /// For `cover()` width-limited: `image_height = panel_width × (1 − 2×h_ratio) × inv_aspect` /// `top = (panel_height × 0.5 − image_height × frac).max(0.)` /// /// Adapts to both panel dimensions so the chosen image fraction stays /// centered even when the panel size changes or the image overflows at the bottom. CenterFraction { /// Each side’s proportion of panel width used for horizontal spacing. h_ratio: f32, /// `natural_height / natural_width` of the image asset. inv_aspect: f32, /// The fraction of image height to center in the panel (e.g. `0.25`). frac: f32, }, } /// How the foreground image is fitted within its slot. #[derive(Clone, Copy)] pub enum ForegroundFit { /// `cover().top_aligned()`: image fills the slot width; overflows at the bottom. CoverTopAligned, /// `contain().top_aligned()`: full image visible, centered horizontally, pinned to top. ContainTopAligned, /// `contain().right_aligned()`: full image visible, right edge flush, space on the left. ContainRightAligned, } /// Sizing and positioning for a foreground image on the onboarding right panel. /// /// `top_mode` controls vertical positioning; `h_padding` controls horizontal padding. #[derive(Clone, Copy)] pub struct ForegroundLayout { /// Vertical positioning mode. pub top_mode: TopMode, /// Horizontal padding mode. pub h_padding: HPadding, /// Image fit and alignment within its slot. pub fit: ForegroundFit, } /// Layout for welcome/intention/theme-picker slides. /// Proportional symmetric horizontal padding; cover() fills the slot width. /// Dynamic top: centers the 25%-from-top image point in the panel, adapting to panel size. pub const FOREGROUND_LAYOUT_DEFAULT: ForegroundLayout = ForegroundLayout { top_mode: TopMode::CenterFraction { h_ratio: 39. / 640., inv_aspect: 612. / 561., frac: 0.25, }, h_padding: HPadding::ProportionalBoth(39. / 640.), fit: ForegroundFit::CoverTopAligned, }; /// Layout for customize slides. /// Proportional left padding; image fills to the right edge via cover. /// We cover because the most important part is the top left of the image. pub const FOREGROUND_LAYOUT_WIDE: ForegroundLayout = ForegroundLayout { top_mode: TopMode::Ratio(118. / 800.), h_padding: HPadding::ProportionalLeft(39. / 640.), fit: ForegroundFit::CoverTopAligned, }; /// Layout for code-review customize images. /// Proportional right padding; portrait image fills to the left edge and overflows bottom. /// We cover because the most important part is the top right of the image. pub const FOREGROUND_LAYOUT_CODE_REVIEW: ForegroundLayout = ForegroundLayout { top_mode: TopMode::Ratio(118. / 800.), h_padding: HPadding::ProportionalRight { ratio: 69. / 640., left_offset: -16., }, fit: ForegroundFit::CoverTopAligned, }; /// Layout for third-party slides. /// No horizontal padding; contain() shows the full image; right-aligned so any /// leftover horizontal space appears on the left. /// We contain because the most important part is the bottom of the image which needs to be visible. pub const FOREGROUND_LAYOUT_THIRD_PARTY: ForegroundLayout = ForegroundLayout { top_mode: TopMode::Ratio(118. / 800.), h_padding: HPadding::Fixed(0.), fit: ForegroundFit::ContainRightAligned, }; /// Wraps an image slot with a dynamic top offset that keeps a chosen fraction /// of the image height centered in the panel. /// /// The image slot is given the remaining panel height after the computed top offset. /// The slot's paint overflows downward (visible up to the panel `Clipped` boundary). struct CenterFractionTopWrapper { h_ratio: f32, inv_aspect: f32, frac: f32, inner: Box, panel_size: Option, top: f32, origin: Option, } impl CenterFractionTopWrapper { fn new(h_ratio: f32, inv_aspect: f32, frac: f32, inner: Box) -> Self { Self { h_ratio, inv_aspect, frac, inner, panel_size: None, top: 0., origin: None, } } } impl Element for CenterFractionTopWrapper { fn layout( &mut self, constraint: SizeConstraint, ctx: &mut LayoutContext, app: &AppContext, ) -> Vector2F { let panel = constraint.max; self.panel_size = Some(panel); // image_height for cover() width-limited: slot_width × inv_aspect let slot_w = panel.x() * (1. - 2. * self.h_ratio); let image_h = slot_w * self.inv_aspect; self.top = (panel.y() * 0.5 - image_h * self.frac).max(0.); let slot_h = (panel.y() - self.top).max(1.); let slot = vec2f(panel.x(), slot_h); self.inner.layout(SizeConstraint::new(slot, slot), ctx, app); panel } fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) { self.inner.after_layout(ctx, app); } fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) { self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index())); self.inner.paint(origin + vec2f(0., self.top), ctx, app); } fn dispatch_event( &mut self, _event: &DispatchedEvent, _ctx: &mut EventContext, _app: &AppContext, ) -> bool { false } fn size(&self) -> Option { self.panel_size } fn origin(&self) -> Option { self.origin } } /// Wraps a slide's foreground visual with the shared onboarding background image. /// /// For `TopMode::Ratio`: uses a `Flex::column` with a proportional `Expanded` top spacer. /// For `TopMode::CenterFraction`: uses `CenterFractionTopWrapper` which computes the /// top offset dynamically at layout time based on the actual panel dimensions. pub fn onboarding_right_panel_with_bg( path: &'static str, layout: ForegroundLayout, ) -> Box { let background = Image::new( AssetSource::Bundled { path: ONBOARDING_BG_PATH, }, CacheOption::Original, ) .stretch() .finish(); let image = match layout.fit { ForegroundFit::CoverTopAligned => { Image::new(AssetSource::Bundled { path }, CacheOption::Original) .cover() .top_aligned() .finish() } ForegroundFit::ContainTopAligned => { Image::new(AssetSource::Bundled { path }, CacheOption::Original) .contain() .top_aligned() .finish() } ForegroundFit::ContainRightAligned => { Image::new(AssetSource::Bundled { path }, CacheOption::Original) .contain() .right_aligned() .finish() } }; // Apply horizontal padding: fixed pixels (DEFAULT) or proportional to panel width. let image_slot: Box = match layout.h_padding { HPadding::Fixed(px) => Container::new(image) .with_padding_left(px) .with_padding_right(px) .finish(), HPadding::ProportionalBoth(ratio) => Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new())))) .with_child(Box::new(Expanded::new(1. - 2. * ratio, image))) .with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new())))) .finish(), HPadding::ProportionalLeft(ratio) => Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new())))) .with_child(Box::new(Expanded::new(1. - ratio, image))) .finish(), HPadding::ProportionalRight { ratio, left_offset } => { let adjusted = if left_offset != 0. { Container::new(image) .with_padding_left(left_offset) .finish() } else { image }; Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(Box::new(Expanded::new(1. - ratio, adjusted))) .with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new())))) .finish() } }; // Build the foreground with either a fixed or dynamic top offset. let foreground: Box = match layout.top_mode { TopMode::Ratio(ratio) => { let remaining = 1. - ratio; Flex::column() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new())))) .with_child(Box::new(Expanded::new(remaining, image_slot))) .finish() } TopMode::CenterFraction { h_ratio, inv_aspect, frac, } => Box::new(CenterFractionTopWrapper::new( h_ratio, inv_aspect, frac, image_slot, )), }; let mut stack = Stack::new(); stack.extend(Some(background)); stack.extend(Some(foreground)); Clipped::new(stack.finish()).finish() }