Files
galaxy/crates/onboarding/src/slides/layout.rs
T

381 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<dyn Element>` containing the responsive layout
pub fn static_left(
left: impl Fn() -> Box<dyn Element>,
right: impl FnOnce() -> Box<dyn Element>,
) -> Box<dyn Element> {
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 sides 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<dyn Element>,
panel_size: Option<Vector2F>,
top: f32,
origin: Option<Point>,
}
impl CenterFractionTopWrapper {
fn new(h_ratio: f32, inv_aspect: f32, frac: f32, inner: Box<dyn Element>) -> 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<Vector2F> {
self.panel_size
}
fn origin(&self) -> Option<Point> {
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<dyn Element> {
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<dyn Element> = 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<dyn Element> = 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()
}