From 24f4d2703a672a4cc894ab0be45bdbd99e89e04d Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Sun, 24 May 2026 00:12:50 +0200 Subject: [PATCH] =?UTF-8?q?ltk:=20introduce=20viewport-relative=20`Length`?= =?UTF-8?q?=20so=20any=20size,=20padding,=20spacing=20or=20font=20height?= =?UTF-8?q?=20can=20scale=20with=20the=20surface=20instead=20of=20being=20?= =?UTF-8?q?frozen=20at=20a=20px=20constant,=20fix=20`text::preferred=5Fsiz?= =?UTF-8?q?e`=20to=20honour=20the=20font-declared=20line=20gap,=20and=20ad?= =?UTF-8?q?d=20a=20responsive=20typographic=20scale=20The=20motivating=20b?= =?UTF-8?q?ug=20was=20a=20lockscreen=20in=20a=20downstream=20app=20(eydos-?= =?UTF-8?q?loginmanager)=20where=20the=20clock=20at=2087=20px=20overlapped?= =?UTF-8?q?=20the=20date=20at=2024=20px=20on=20a=20Pinephone=20but=20not?= =?UTF-8?q?=20on=20a=20winit=20dev=20screen.=20The=20root=20cause=20split?= =?UTF-8?q?=20in=20two:=20the=20layout=20was=20wired=20with=20a=20single?= =?UTF-8?q?=20`f32`=20spacing=20constant=20that=20worked=20at=20the=20dev?= =?UTF-8?q?=20resolution=20and=20broke=20at=20the=20smaller=20one,=20and?= =?UTF-8?q?=20`text::Text::preferred=5Fsize`=20was=20returning=20`ascent?= =?UTF-8?q?=20-=20descent`=20for=20the=20line=20height=20=E2=80=94=20fontd?= =?UTF-8?q?ue's=20terminology=20for=20"the=20minimum=20bounding=20box=20of?= =?UTF-8?q?=20an=20unaccented=20line",=20which=20deliberately=20drops=20th?= =?UTF-8?q?e=20`line=5Fgap`=20that=20every=20typographic=20renderer=20(Pan?= =?UTF-8?q?go,=20CoreText,=20DirectWrite)=20reserves=20between=20adjacent?= =?UTF-8?q?=20rows.=20At=20Sora's=20200/em=20line=20gap,=20an=2087=20px=20?= =?UTF-8?q?row=20was=20visually=2017=20px=20taller=20than=20the=20rect=20t?= =?UTF-8?q?he=20column=20allocated=20for=20it;=20stacked=20tight=20against?= =?UTF-8?q?=20the=20row=20above,=20the=20descenders=20bled=20into=20the=20?= =?UTF-8?q?row=20below.=20This=20commit=20fixes=20both=20halves=20at=20the?= =?UTF-8?q?=20toolkit=20level=20so=20every=20consumer=20benefits=20without?= =?UTF-8?q?=20bolting=20on=20a=20per-screen=20`Sizing`=20helper=20in=20the?= =?UTF-8?q?ir=20own=20view=20code.=20`types::Length`=20(with=20the=20`Leng?= =?UTF-8?q?thBase`=20enum=20behind=20it)=20is=20the=20new=20currency=20for?= =?UTF-8?q?=20any=20"how=20big"=20or=20"how=20far=20apart"=20parameter.=20?= =?UTF-8?q?Six=20variants=20=E2=80=94=20`Px`,=20`Vw`,=20`Vh`,=20`Vmin`,=20?= =?UTF-8?q?`Vmax`,=20`Em`=20=E2=80=94=20cover=20the=20cases=20a=20real=20U?= =?UTF-8?q?I=20hits:=20absolute=20pixels=20for=20fixed-chrome=20decisions,?= =?UTF-8?q?=20viewport-relative=20percentages=20for=20sizes=20that=20have?= =?UTF-8?q?=20to=20survive=20a=20portrait/landscape=20rotation,=20and=20ro?= =?UTF-8?q?ot-font-size=20multiples=20for=20typographic=20hierarchy.=20Opt?= =?UTF-8?q?ional=20`min=5Fpx`=20/=20`max=5Fpx`=20bounds=20attach=20to=20th?= =?UTF-8?q?e=20same=20`Length`=20value=20via=20`.clamp(=20lo,=20hi=20)`=20?= =?UTF-8?q?(both=20ends),=20`.at=5Fleast(=20lo=20)`=20and=20`.at=5Fmost(?= =?UTF-8?q?=20hi=20)`=20(one-sided);=20the=20names=20are=20intentionally?= =?UTF-8?q?=20divergent=20from=20`f32::min`/`f32::max`=20to=20avoid=20bein?= =?UTF-8?q?g=20read=20with=20the=20opposite=20semantics=20(`x.min(24)`=20i?= =?UTF-8?q?n=20std=20means=20"the=20smaller=20of=20x=20and=2024",=20which?= =?UTF-8?q?=20is=20the=20inverse=20of=20what=20a=20min=20bound=20expresses?= =?UTF-8?q?).=20The=20bounds=20are=20stored=20as=20raw=20`f32`=20rather=20?= =?UTF-8?q?than=20nested=20`Length`=20values,=20which=20keeps=20`Length`?= =?UTF-8?q?=20`Copy`=20and=20avoids=20a=20`Box`=20allocation=20per=20widge?= =?UTF-8?q?t=20per=20frame=20=E2=80=94=20the=20bounded-by-relative=20case?= =?UTF-8?q?=20(`Vmin(20).clamp(Vmin(10),=20Vmin(40))`)=20is=20rare=20enoug?= =?UTF-8?q?h=20that=20the=20trade=20is=20the=20right=20one.=20`From`,?= =?UTF-8?q?=20`From`=20and=20`From`=20are=20implemented=20so=20e?= =?UTF-8?q?very=20legacy=20`.size(=2024.0=20)`=20/=20`.padding(=208.0=20)`?= =?UTF-8?q?=20/=20`.spacing(=204.0=20)`=20call=20keeps=20compiling=20uncha?= =?UTF-8?q?nged;=20the=20migration=20is=20opt-in=20per=20call=20site.=20Th?= =?UTF-8?q?e=20`EM=5FBASE=5FDEFAULT=20=3D=2016.0`=20constant=20matches=20`?= =?UTF-8?q?theme::typography::BODY`=20so=20`Length::em(=202.0=20)`=20resol?= =?UTF-8?q?ves=20consistently=20with=20the=20body-text=20default;=20a=20fu?= =?UTF-8?q?ture=20change=20can=20thread=20a=20theme-supplied=20em=20base?= =?UTF-8?q?=20through=20without=20breaking=20the=20resolver=20shape.=20The?= =?UTF-8?q?=20resolver=20=E2=80=94=20`Length::resolve(=20viewport:=20(=20f?= =?UTF-8?q?32,=20f32=20),=20em=5Fbase:=20f32=20)=20->=20f32`=20=E2=80=94?= =?UTF-8?q?=20runs=20at=20layout=20time=20against=20a=20viewport=20supplie?= =?UTF-8?q?d=20by=20the=20renderer.=20`Canvas::viewport=5Flogical()`=20is?= =?UTF-8?q?=20the=20new=20helper=20that=20exposes=20that=20viewport:=20it?= =?UTF-8?q?=20divides=20the=20canvas's=20physical=20size=20by=20`dpi=5Fsca?= =?UTF-8?q?le`=20and=20falls=20back=20to=20physical=20size=20when=20`dpi?= =?UTF-8?q?=5Fscale=20<=3D=200.0`,=20guarding=20the=20misconfigured-canvas?= =?UTF-8?q?=20path=20so=20a=20Vmin=20call=20doesn't=20poison=20every=20dow?= =?UTF-8?q?nstream=20measurement=20with=20`NaN`=20or=20`inf`.=20The=20view?= =?UTF-8?q?port=20is=20in=20**logical**=20pixels=20=E2=80=94=20matching=20?= =?UTF-8?q?what=20every=20wayland=20`xdg=5Ftoplevel.configure`=20event=20a?= =?UTF-8?q?lready=20hands=20the=20client=20=E2=80=94=20so=20`Length::vmin(?= =?UTF-8?q?=2018.0=20)`=20on=20a=20360=C3=97720-logical=20Librem=205=20por?= =?UTF-8?q?trait=20surface=20resolves=20to=2064.8=20px=20and=20the=20same?= =?UTF-8?q?=20expression=20on=20a=201600=C3=97900=20dev=20screen=20resolve?= =?UTF-8?q?s=20to=20162=20px,=20automatically.=20Every=20widget=20setter?= =?UTF-8?q?=20that=20took=20an=20`f32`=20size,=20padding,=20spacing,=20max?= =?UTF-8?q?-width,=20or=20fixed=20dimension=20now=20takes=20`impl=20Into`=20and=20stores=20the=20value=20as=20`Length`:=20-=20`wi?= =?UTF-8?q?dget::text::Text::size(=20impl=20Into=20)`;=20the=20`si?= =?UTF-8?q?ze`=20field=20is=20now=20`Length`.=20`Text::resolved=5Fsize(=20?= =?UTF-8?q?&Canvas=20)`=20is=20the=20internal=20accessor=20that=20every=20?= =?UTF-8?q?measurement=20/=20drawing=20path=20routes=20through,=20so=20the?= =?UTF-8?q?=20field=20can=20stay=20`Length`=20without=20churning=20the=20c?= =?UTF-8?q?all=20sites.=20`preferred=5Fsize`=20and=20`draw`=20now=20read?= =?UTF-8?q?=20`new=5Fline=5Fsize=20=3D=20ascent=20-=20descent=20+=20line?= =?UTF-8?q?=5Fgap`=20from=20fontdue's=20`LineMetrics`=20(the=20fix=20for?= =?UTF-8?q?=20the=20original=20bug)=20=E2=80=94=20the=20baseline=20placeme?= =?UTF-8?q?nt=20is=20unchanged,=20only=20the=20row=20height=20grows=20by?= =?UTF-8?q?=20the=20font's=20declared=20leading,=20which=20is=20what=20eve?= =?UTF-8?q?ry=20stacked=20layout=20was=20implicitly=20relying=20on.=20-=20?= =?UTF-8?q?`layout::Spacer::height(=20impl=20Into=20)`=20/=20`.wid?= =?UTF-8?q?th(=20impl=20Into=20)`;=20`fixed=5Fheight`=20/=20`fixed?= =?UTF-8?q?=5Fwidth`=20are=20now=20`Option`.=20New=20`resolved=5Fh?= =?UTF-8?q?eight(=20&Canvas=20)`=20/=20`resolved=5Fwidth(=20&Canvas=20)`?= =?UTF-8?q?=20helpers=20replace=20the=20direct=20`s.fixed=5Fheight.unwrap?= =?UTF-8?q?=5For(=200.0=20)`=20reads=20in=20`layout::column`,=20`layout::r?= =?UTF-8?q?ow`=20and=20`layout::stack`.=20`Spacer::preferred=5Fsize`=20gro?= =?UTF-8?q?ws=20a=20`&Canvas`=20parameter=20for=20the=20same=20reason;=20`?= =?UTF-8?q?Element::preferred=5Fsize`=20passes=20the=20canvas=20through.?= =?UTF-8?q?=20-=20`layout::Column::spacing`=20/=20`.padding`=20/=20`.max?= =?UTF-8?q?=5Fwidth`,=20`layout::Row::spacing`=20/=20`.padding`=20?= =?UTF-8?q?=E2=80=94=20all=20take=20`impl=20Into`=20and=20store=20?= =?UTF-8?q?`Length`.=20Internal=20`resolved=5Fspacing(=20&Canvas=20)`,=20`?= =?UTF-8?q?resolved=5Fpadding(=20&Canvas=20)`,=20`resolved=5Fmax=5Fwidth(?= =?UTF-8?q?=20&Canvas=20)`=20helpers=20funnel=20every=20read,=20so=20the?= =?UTF-8?q?=20layout=20code=20paths=20stay=20readable.=20The=20column's=20?= =?UTF-8?q?`inner=5Fw`=20private=20helper=20picks=20up=20a=20`&Canvas`=20a?= =?UTF-8?q?rgument;=20the=20test=20that=20used=20it=20directly=20is=20upda?= =?UTF-8?q?ted.=20`theme::typography`=20keeps=20its=20historic=20`f32`=20c?= =?UTF-8?q?onstants=20(`H0`=E2=80=A6`BODY=5FXS`,=20plus=20`LINE=5FHEIGHT`)?= =?UTF-8?q?=20so=20the=20migration=20is=20gradual,=20and=20adds=20a=20para?= =?UTF-8?q?llel=20responsive=20scale=20exposed=20as=20functions=20returnin?= =?UTF-8?q?g=20`Length`:=20`h0()`,=20`h1()`,=20`h2()`,=20`h3()`,=20`body()?= =?UTF-8?q?`,=20`body=5Fs()`,=20`body=5Fxs()`.=20Each=20is=20a=20`Length::?= =?UTF-8?q?vmin(=20pct=20).clamp(=20min=5Fpx,=20max=5Fpx=20)`=20whose=20pe?= =?UTF-8?q?rcentage=20is=20calibrated=20against=20a=201000-px=20smaller=20?= =?UTF-8?q?side=20reproducing=20the=20legacy=20px=20constant=20exactly,=20?= =?UTF-8?q?and=20whose=20px=20clamps=20protect=20both=20ends=20of=20the=20?= =?UTF-8?q?spectrum=20=E2=80=94=20a=20360-px=20Pinephone=20hits=20the=20lo?= =?UTF-8?q?wer=20clamp=20on=20the=20larger=20headings,=20a=204K=20desktop?= =?UTF-8?q?=20hits=20the=20upper=20one.=20The=20tests=20in=20`theme::typog?= =?UTF-8?q?raphy`=20exercise=20all=20three=20regimes=20(narrow=20phone,=20?= =?UTF-8?q?calibration=20point,=20large=20display)=20so=20future=20drift?= =?UTF-8?q?=20in=20the=20percentages=20or=20clamps=20is=20caught=20immedia?= =?UTF-8?q?tely.=20`Canvas::viewport=5Flogical`=20is=20the=20only=20render?= =?UTF-8?q?-surface=20API=20touched.=20None=20of=20the=20existing=20per-fr?= =?UTF-8?q?ame=20paths=20(`draw=5Ftext`,=20`measure=5Ftext`,=20`font=5Flin?= =?UTF-8?q?e=5Fmetrics`)=20change=20shape,=20so=20backends=20and=20externa?= =?UTF-8?q?l=20embedders=20aren't=20disturbed.=20The=20`dpi=5Fscale`=20acc?= =?UTF-8?q?essor=20already=20existed;=20this=20commit=20only=20adds=20the?= =?UTF-8?q?=20convenience=20that=20ratios=20it=20against=20the=20surface?= =?UTF-8?q?=20size=20to=20return=20the=20unit=20layout=20actually=20wants.?= =?UTF-8?q?=20Test=20coverage=20rounds=20out=20the=20addition=20rather=20t?= =?UTF-8?q?han=20just=20smoke-testing=20the=20happy=20path:=2022=20new=20t?= =?UTF-8?q?ests,=20broken=20down=20as=20`types::length=5Ftests`=20(7=20?= =?UTF-8?q?=E2=80=94=20every=20variant,=20clamp=20with=20relative=20value,?= =?UTF-8?q?=20clamp=20with=20swapped=20bounds,=20`From`),=20`render::?= =?UTF-8?q?viewport=5Ftests`=20(3=20=E2=80=94=20scale=201,=20scale=202,=20?= =?UTF-8?q?scale=200=20fallback),=20`theme::typography::tests`=20(3=20?= =?UTF-8?q?=E2=80=94=20phone-clamped,=20calibrated,=204K-clamped),=20`layo?= =?UTF-8?q?ut::spacer::tests`=20(4=20=E2=80=94=20px=20height,=20vmin=20hei?= =?UTF-8?q?ght,=20vw=20width,=20flex=20spacer=20reports=20`None`),=20`layo?= =?UTF-8?q?ut::column::tests`=20(3=20new=20=E2=80=94=20vmin=20spacing=20ac?= =?UTF-8?q?cumulates,=20vmin=20padding,=20vmin=20max-width=20caps=20inner-?= =?UTF-8?q?w),=20`layout::row::tests`=20(2=20new=20=E2=80=94=20vmin=20padd?= =?UTF-8?q?ing,=20vmin=20spacing=20produces=20correct=20visible=20gap=20be?= =?UTF-8?q?tween=20non-flex=20children=20regardless=20of=20the=20row's=20c?= =?UTF-8?q?entering=20anchor),=20and=20`widget::text::tests`=20(3=20update?= =?UTF-8?q?d/new=20=E2=80=94=20defaults=20compare=20against=20`Length::px(?= =?UTF-8?q?16.0)`,=20`.size(=20f32=20)`=20and=20`.size(=20Length=20)`=20bo?= =?UTF-8?q?th=20verified).=20The=20existing=20integration=20test=20in=20`t?= =?UTF-8?q?ests/layout=5Fstack=5Fspacer.rs`=20is=20updated=20to=20call=20`?= =?UTF-8?q?Spacer::preferred=5Fsize(=20&canvas=20)`=20and=20compare=20`fix?= =?UTF-8?q?ed=5Fheight`=20/=20`fixed=5Fwidth`=20against=20`Some(=20Length:?= =?UTF-8?q?:px(=20n=20)=20)`.=20Documentation=20is=20updated=20end-to-end?= =?UTF-8?q?=20so=20the=20new=20API=20is=20discoverable=20from=20`cargo=20d?= =?UTF-8?q?oc`=20without=20grepping=20the=20source:=20`lib.rs`=20gets=20a?= =?UTF-8?q?=20new=20entry=20for=20`Length`=20under=20the=20**Types**=20sec?= =?UTF-8?q?tion=20and=20a=20new=20**Designing=20for=20multiple=20resolutio?= =?UTF-8?q?ns**=20section=20that=20lists=20the=20three=20patterns=20(relat?= =?UTF-8?q?ive=20`Length`=20for=20sizing,=20responsive=20typography=20for?= =?UTF-8?q?=20hierarchy,=20`view()`-level=20branching=20on=20surface=20dim?= =?UTF-8?q?ensions=20only=20when=20the=20structure=20itself=20must=20chang?= =?UTF-8?q?e).=20`Canvas::viewport=5Flogical`=20ships=20with=20a=20runnabl?= =?UTF-8?q?e=20`assert=5Feq!`=20example=20covering=20the=20scale-2=20case.?= =?UTF-8?q?=20The=20module-level=20docstrings=20for=20`Spacer`,=20`Column`?= =?UTF-8?q?=20and=20`Row`=20now=20show=20both=20an=20`f32`=20example=20(le?= =?UTF-8?q?gacy,=20still=20valid)=20and=20a=20`Length::vmin(=20...=20).cla?= =?UTF-8?q?mp(=20...=20)`=20example=20for=20the=20responsive=20variant=20?= =?UTF-8?q?=E2=80=94=20`cargo=20doc`=20renders=20both=20side=20by=20side?= =?UTF-8?q?=20so=20the=20upgrade=20path=20is=20obvious.=20Out=20of=20scope?= =?UTF-8?q?=20for=20this=20commit,=20deliberate:=20`WrapGrid::spacing=5Fx`?= =?UTF-8?q?=20/=20`spacing=5Fy`=20/=20`padding`,=20`widget::text=5Fedit::T?= =?UTF-8?q?extEdit::font=5Fsize`,=20and=20`widget::image::Image::size`=20s?= =?UTF-8?q?till=20take=20`f32`.=20None=20of=20them=20are=20on=20a=20critic?= =?UTF-8?q?al=20responsive=20path=20right=20now,=20the=20`From`=20shi?= =?UTF-8?q?m=20means=20migrating=20later=20is=20a=20one-line=20setter=20si?= =?UTF-8?q?gnature=20change=20per=20widget,=20and=20keeping=20this=20commi?= =?UTF-8?q?t=20focused=20on=20the=20widgets=20the=20lockscreen=20actually?= =?UTF-8?q?=20uses=20keeps=20the=20diff=20reviewable.=20The=20line-gap=20f?= =?UTF-8?q?ix=20in=20`text::preferred=5Fsize`=20already=20benefits=20`Text?= =?UTF-8?q?Edit`=20indirectly=20because=20its=20caret/row=20math=20reads?= =?UTF-8?q?=20from=20the=20same=20metrics=20helpers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layout/column.rs | 156 ++++++++++++++++++------ src/layout/row.rs | 129 +++++++++++++++----- src/layout/spacer.rs | 130 ++++++++++++++++---- src/layout/stack.rs | 2 +- src/lib.rs | 29 ++++- src/render/mod.rs | 56 +++++++++ src/theme/typography.rs | 67 +++++++++++ src/types.rs | 223 +++++++++++++++++++++++++++++++++++ src/widget/element.rs | 2 +- src/widget/text/mod.rs | 63 ++++++---- src/widget/text/tests.rs | 11 +- tests/layout_stack_spacer.rs | 15 ++- 12 files changed, 764 insertions(+), 119 deletions(-) diff --git a/src/layout/column.rs b/src/layout/column.rs index b21ab2c..aec5bf2 100644 --- a/src/layout/column.rs +++ b/src/layout/column.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use crate::types::Rect; +use crate::types::{ Length, Rect }; use crate::render::Canvas; use crate::widget::Element; @@ -23,14 +23,36 @@ use crate::widget::Element; /// .into() /// # } /// ``` +/// +/// `padding`, `spacing` and `max_width` all accept any +/// [`crate::Length`], so a responsive layout reads as: +/// +/// ```rust,no_run +/// # use ltk::{ button, column, text, Length, Element }; +/// # #[ derive( Clone ) ] enum Msg { Ok } +/// # fn _ex() -> Element { +/// column() +/// // Padding is 3 % of the viewport's smaller side, clamped to 16..48 px. +/// .padding( Length::vmin( 3.0 ).clamp( 16.0, 48.0 ) ) +/// .spacing( Length::vmin( 1.5 ).at_least( 8.0 ) ) +/// .max_width( Length::vw( 60.0 ).at_most( 720.0 ) ) +/// .push( text( "Responsive" ) ) +/// .push( button( "OK" ).on_press( Msg::Ok ) ) +/// .into() +/// # } +/// ``` pub struct Column { pub children: Vec>, - pub spacing: f32, - pub padding: f32, + /// Vertical gap between children. Stored as [`Length`] so a `Vmin(2.0)` + /// or `Em(0.5)` gap scales with the viewport instead of freezing at a + /// px constant. + pub spacing: Length, + /// Padding on all sides. Same [`Length`] semantics as `spacing`. + pub padding: Length, pub align_center_x: bool, pub center_y: bool, - pub max_width: Option, + pub max_width: Option, pub fit_content: bool, } @@ -41,8 +63,8 @@ impl Column Self { children: Vec::new(), - spacing: 8.0, - padding: 16.0, + spacing: Length::px( 8.0 ), + padding: Length::px( 16.0 ), align_center_x: true, center_y: false, max_width: None, @@ -57,17 +79,20 @@ impl Column self } - /// Set the vertical gap between children in pixels. Default: `8.0`. - pub fn spacing( mut self, s: f32 ) -> Self + /// Set the vertical gap between children. Default: `8.0` px. Accepts + /// any [`Length`] — pass an `f32` for the px case, or a relative + /// value like `Length::vmin( 2.0 )` to scale with the viewport. + pub fn spacing( mut self, s: impl Into ) -> Self { - self.spacing = s; + self.spacing = s.into(); self } - /// Set the padding (all sides) in pixels. Default: `16.0`. - pub fn padding( mut self, p: f32 ) -> Self + /// Set the padding (all sides). Default: `16.0` px. Accepts any + /// [`Length`]. + pub fn padding( mut self, p: impl Into ) -> Self { - self.padding = p; + self.padding = p.into(); self } @@ -85,14 +110,33 @@ impl Column self } - /// Limit the content width in pixels. The column still reports `max_width` as - /// its preferred width so the parent allocates the full available rect. - pub fn max_width( mut self, w: f32 ) -> Self + /// Limit the content width. Accepts any [`Length`]. The column still + /// reports the parent's `max_width` as its preferred width so the + /// parent allocates the full available rect. + pub fn max_width( mut self, w: impl Into ) -> Self { - self.max_width = Some( w ); + self.max_width = Some( w.into() ); self } + #[ inline ] + fn resolved_spacing( &self, canvas: &Canvas ) -> f32 + { + self.spacing.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + } + + #[ inline ] + fn resolved_padding( &self, canvas: &Canvas ) -> f32 + { + self.padding.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + } + + #[ inline ] + fn resolved_max_width( &self, canvas: &Canvas ) -> Option + { + self.max_width.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) + } + /// Report the intrinsic content width as preferred width instead of filling /// the available `max_width`. Use this when the column represents a card /// or widget meant to sit side-by-side with other children inside a @@ -108,10 +152,10 @@ impl Column self } - fn inner_w( &self, available: f32 ) -> f32 + fn inner_w( &self, available: f32, canvas: &Canvas ) -> f32 { - let w = available - self.padding * 2.0; - self.max_width.map( |m| w.min( m ) ).unwrap_or( w ) + let w = available - self.resolved_padding( canvas ) * 2.0; + self.resolved_max_width( canvas ).map( |m| w.min( m ) ).unwrap_or( w ) } fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32 @@ -120,18 +164,19 @@ impl Column self.children.iter() .map( |c| match c { - Element::Spacer( s ) => s.fixed_height.unwrap_or( 0.0 ), + Element::Spacer( s ) => s.resolved_height( canvas ).unwrap_or( 0.0 ), other => other.preferred_size( inner_w, canvas ).1, } ) .sum::() - + self.spacing * (self.children.len().saturating_sub( 1 )) as f32 + + self.resolved_spacing( canvas ) * ( self.children.len().saturating_sub( 1 ) ) as f32 } /// Return the preferred `(width, height)` given available `max_width`. pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) { - let inner_w = self.inner_w( max_width ); - let total_h = self.content_h( inner_w, canvas ) + self.padding * 2.0; + let inner_w = self.inner_w( max_width, canvas ); + let pad = self.resolved_padding( canvas ); + let total_h = self.content_h( inner_w, canvas ) + pad * 2.0; let w = if self.fit_content { @@ -162,7 +207,7 @@ impl Column other => other.preferred_size( inner_w, canvas ).0, } ) .fold( 0.0_f32, f32::max ); - ( content_w + self.padding * 2.0 ).min( max_width ) + ( content_w + pad * 2.0 ).min( max_width ) } else { max_width }; @@ -175,14 +220,16 @@ impl Column /// Layout children within rect and return (rect, child_index) pairs. pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)> { - let inner_w = self.inner_w( rect.width ); + let inner_w = self.inner_w( rect.width, canvas ); + let pad = self.resolved_padding( canvas ); + let spacing = self.resolved_spacing( canvas ); // Flexible spacers and Scroll widgets claim remaining vertical space. // Fixed-height spacers behave like normal fixed-size children. let total_weight: u32 = self.children.iter() .map( |c| match c { - Element::Spacer( s ) if s.fixed_height.is_none() => s.weight, + Element::Spacer( s ) if s.resolved_height( canvas ).is_none() => s.weight, Element::Scroll( _ ) => 1, _ => 0, } ) @@ -195,23 +242,23 @@ impl Column { 0.0 } else if let Element::Spacer( s ) = c { - s.fixed_height.unwrap_or( 0.0 ) + s.resolved_height( canvas ).unwrap_or( 0.0 ) } else { c.preferred_size( inner_w, canvas ).1 } } ) .sum::() - + self.spacing * (self.children.len().saturating_sub( 1 )) as f32; + + spacing * ( self.children.len().saturating_sub( 1 ) ) as f32; - let avail_h = rect.height - self.padding * 2.0; - let avail_spare = (avail_h - fixed_h).max( 0.0 ); + let avail_h = rect.height - pad * 2.0; + let avail_spare = ( avail_h - fixed_h ).max( 0.0 ); // `center_y` only applies when there are no spacers. let start_y = if total_weight == 0 && self.center_y { - rect.y + self.padding + avail_spare / 2.0 + rect.y + pad + avail_spare / 2.0 } else { - rect.y + self.padding + rect.y + pad }; let start_x = rect.x + (rect.width - inner_w) / 2.0; @@ -224,7 +271,7 @@ impl Column { Element::Spacer( s ) => { - let h = if let Some( fixed ) = s.fixed_height + let h = if let Some( fixed ) = s.resolved_height( canvas ) { fixed } else if total_weight > 0 @@ -254,7 +301,7 @@ impl Column start_x }; result.push( ( Rect { x, y, width: w, height: h }, i ) ); - y += h + self.spacing; + y += h + spacing; } result } @@ -330,16 +377,18 @@ mod tests #[ test ] fn inner_w_respects_padding_and_max_width() { + let canvas = make_canvas(); let col = column::<()>().padding( 20.0 ).max_width( 100.0 ); // available = 200, minus padding*2 = 160, capped at max_width = 100 - assert_eq!( col.inner_w( 200.0 ), 100.0 ); + assert_eq!( col.inner_w( 200.0, &canvas ), 100.0 ); } #[ test ] fn inner_w_without_max_width_subtracts_padding() { + let canvas = make_canvas(); let col = column::<()>().padding( 10.0 ); - assert_eq!( col.inner_w( 200.0 ), 180.0 ); + assert_eq!( col.inner_w( 200.0, &canvas ), 180.0 ); } #[ test ] @@ -356,4 +405,39 @@ mod tests let ( _, h ) = col.preferred_size( 100.0, &canvas ); assert_eq!( h, 16.0 ); } + + #[ test ] + fn vmin_spacing_resolves_against_canvas_viewport() + { + // Canvas is 800x600 → vmin = 600. 5 % of 600 = 30 px per gap. + // Three zero-height spacers → two gaps → 60 px total. + let canvas = make_canvas(); + let col = column::<()>() + .padding( 0.0 ) + .spacing( Length::vmin( 5.0 ) ) + .push( crate::spacer() ) + .push( crate::spacer() ) + .push( crate::spacer() ); + let ( _, h ) = col.preferred_size( 100.0, &canvas ); + assert_eq!( h, 60.0 ); + } + + #[ test ] + fn vmin_padding_doubles_around_content() + { + // 4 % of 600 = 24 px padding on each side → 48 px on an empty column. + let canvas = make_canvas(); + let col = column::<()>().padding( Length::vmin( 4.0 ) ); + let ( _, h ) = col.preferred_size( 100.0, &canvas ); + assert_eq!( h, 48.0 ); + } + + #[ test ] + fn vmin_max_width_caps_inner_w() + { + // 20 % of 600 = 120 px max-width. + let canvas = make_canvas(); + let col = column::<()>().padding( 0.0 ).max_width( Length::vmin( 20.0 ) ); + assert_eq!( col.inner_w( 200.0, &canvas ), 120.0 ); + } } diff --git a/src/layout/row.rs b/src/layout/row.rs index 273da49..ee77327 100644 --- a/src/layout/row.rs +++ b/src/layout/row.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use crate::types::Rect; +use crate::types::{ Length, Rect }; use crate::render::Canvas; use crate::widget::Element; @@ -23,11 +23,29 @@ use crate::widget::Element; /// .into() /// # } /// ``` +/// +/// `spacing` and `padding` accept any [`crate::Length`]: +/// +/// ```rust,no_run +/// # use std::sync::Arc; +/// # use ltk::{ icon_button, row, Length, Element }; +/// # #[ derive( Clone ) ] enum Msg { A, B } +/// # fn _ex( a_rgba: Arc>, b_rgba: Arc>, w: u32, h: u32 ) -> Element { +/// row() +/// // 2 % of the viewport's smaller side, never below 8 px. +/// .spacing( Length::vmin( 2.0 ).at_least( 8.0 ) ) +/// .push( icon_button( a_rgba, w, h ).on_press( Msg::A ) ) +/// .push( icon_button( b_rgba, w, h ).on_press( Msg::B ) ) +/// .into() +/// # } +/// ``` pub struct Row { pub children: Vec>, - pub spacing: f32, - pub padding: f32, + /// Horizontal gap between children. [`Length`]; default `8.0` px. + pub spacing: Length, + /// Padding on all sides. [`Length`]; default `0.0` px. + pub padding: Length, pub align_right: bool, } @@ -35,7 +53,13 @@ impl Row { pub fn new() -> Self { - Self { children: Vec::new(), spacing: 8.0, padding: 0.0, align_right: false } + Self + { + children: Vec::new(), + spacing: Length::px( 8.0 ), + padding: Length::px( 0.0 ), + align_right: false, + } } /// Append a child widget or layout. @@ -45,20 +69,34 @@ impl Row self } - /// Set the horizontal gap between children in pixels. Default: `8.0`. - pub fn spacing( mut self, s: f32 ) -> Self + /// Set the horizontal gap between children. Default: `8.0` px. Accepts + /// any [`Length`] so the gap can scale with the viewport. + pub fn spacing( mut self, s: impl Into ) -> Self { - self.spacing = s; + self.spacing = s.into(); self } - /// Set the padding (all sides) in pixels. Default: `0.0`. - pub fn padding( mut self, p: f32 ) -> Self + /// Set the padding (all sides). Default: `0.0` px. Accepts any + /// [`Length`]. + pub fn padding( mut self, p: impl Into ) -> Self { - self.padding = p; + self.padding = p.into(); self } + #[ inline ] + fn resolved_spacing( &self, canvas: &Canvas ) -> f32 + { + self.spacing.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + } + + #[ inline ] + fn resolved_padding( &self, canvas: &Canvas ) -> f32 + { + self.padding.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + } + /// Push the content block to the right edge of the available width. pub fn align_right( mut self ) -> Self { @@ -69,16 +107,18 @@ impl Row /// Return the preferred `(width, height)` given available `max_width`. pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) { + let pad = self.resolved_padding( canvas ); + let spacing = self.resolved_spacing( canvas ); // Width contribution of every fixed (non-flex, non-flex-spacer) child. // Used to compute the residual width that wrap-style children will // actually render in, so their reported height matches the layout. - let inner_w = ( max_width - self.padding * 2.0 ).max( 0.0 ); - let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32; + let inner_w = ( max_width - pad * 2.0 ).max( 0.0 ); + let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32; let fixed_w: f32 = self.children.iter() .filter( |c| match c { Element::Flex( _ ) => false, - Element::Spacer( s ) => s.fixed_width.is_some(), + Element::Spacer( s ) => s.resolved_width( canvas ).is_some(), _ => true, } ) .map( |c| c.preferred_size( max_width, canvas ).0 ) @@ -103,7 +143,7 @@ impl Row let has_flex = self.children.iter().any( |c| match c { Element::Flex( _ ) => true, - Element::Spacer( s ) => s.fixed_width.is_none(), + Element::Spacer( s ) => s.resolved_width( canvas ).is_none(), _ => false, } ); @@ -115,11 +155,11 @@ impl Row .map( |c| c.preferred_size( max_width, canvas ).0 ) .sum::() + gaps - + self.padding * 2.0; + + pad * 2.0; total_w.min( max_width ) }; - ( w, max_h + self.padding * 2.0 ) + ( w, max_h + pad * 2.0 ) } pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {} @@ -134,7 +174,9 @@ impl Row /// [`Row::align_right`]). pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)> { - let inner_h = rect.height - self.padding * 2.0; + let pad = self.resolved_padding( canvas ); + let spacing = self.resolved_spacing( canvas ); + let inner_h = rect.height - pad * 2.0; // Spacers and `Flex` wrappers report 0 width here; their real width // comes from the flex distribution below. @@ -142,7 +184,7 @@ impl Row .map( |c| c.preferred_size( rect.width, canvas ) ) .collect(); - let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32; + let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32; let fixed_w: f32 = self.children.iter().zip( sizes.iter() ) .filter( |( c, _ )| match c { @@ -151,7 +193,7 @@ impl Row // `Spacer::width(...)`-pinned spacers, contributes to the // fixed-width tally. Element::Flex( _ ) => false, - Element::Spacer( s ) => s.fixed_width.is_some(), + Element::Spacer( s ) => s.resolved_width( canvas ).is_some(), _ => true, } ) .map( |( _, ( w, _ ) )| *w ) @@ -159,13 +201,13 @@ impl Row let total_weight: u32 = self.children.iter() .filter_map( |c| match c { - Element::Spacer( s ) if s.fixed_width.is_none() => Some( s.weight ), + Element::Spacer( s ) if s.resolved_width( canvas ).is_none() => Some( s.weight ), Element::Flex( f ) => Some( f.weight ), _ => None, } ) .sum(); - let inner_w = ( rect.width - self.padding * 2.0 ).max( 0.0 ); + let inner_w = ( rect.width - pad * 2.0 ).max( 0.0 ); let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 ); let has_spacers = total_weight > 0; @@ -173,15 +215,15 @@ impl Row { // Spacers and `Flex` wrappers claim the leftover; the cluster // sits flush to the left edge of the inner rect. - ( rect.x + self.padding, leftover / total_weight as f32 ) + ( rect.x + pad, leftover / total_weight as f32 ) } else if self.align_right { - ( rect.x + rect.width - (fixed_w + gaps) - self.padding, 0.0 ) + ( rect.x + rect.width - ( fixed_w + gaps ) - pad, 0.0 ) } else { - ( rect.x + (rect.width - fixed_w - gaps) / 2.0, 0.0 ) + ( rect.x + ( rect.width - fixed_w - gaps ) / 2.0, 0.0 ) }; let mut x = start_x; @@ -190,7 +232,7 @@ impl Row { let width = match child { - Element::Spacer( s ) => match s.fixed_width + Element::Spacer( s ) => match s.resolved_width( canvas ) { Some( fw ) => fw, None => flex_unit * s.weight as f32, @@ -198,9 +240,9 @@ impl Row Element::Flex( f ) => flex_unit * f.weight as f32, _ => w, }; - let y = rect.y + self.padding + (inner_h - h) / 2.0; + let y = rect.y + pad + ( inner_h - h ) / 2.0; result.push( ( Rect { x, y, width, height: h }, i ) ); - x += width + self.spacing; + x += width + spacing; } result } @@ -296,4 +338,37 @@ mod tests let rect = Rect { x: 0., y: 0., width: 400., height: 48. }; assert!( r.layout( rect, &canvas ).is_empty() ); } + + #[ test ] + fn vmin_padding_doubles_around_content() + { + // 800x600 canvas → vmin = 600. 4 % = 24 px each side → 48 px height. + let canvas = make_canvas(); + let r = row::<()>().padding( Length::vmin( 4.0 ) ); + let ( _, h ) = r.preferred_size( 500.0, &canvas ); + assert_eq!( h, 48.0 ); + } + + #[ test ] + fn vmin_spacing_pins_visible_layout_gap() + { + // Two fixed-width spacers separated by a vmin spacing of 5 %. + // Canvas vmin = 600 → 30 px between the inner edges. With both + // spacers 10 px wide, the second one's `x` minus the first one's + // `x + width` must equal the gap regardless of where the row chose + // to anchor the cluster (centered, since there are no flex spacers). + let canvas = make_canvas(); + let r = row::<()>() + .padding( 0.0 ) + .spacing( Length::vmin( 5.0 ) ) + .push( crate::spacer().width( 10.0 ) ) + .push( crate::spacer().width( 10.0 ) ); + let rect = Rect { x: 0., y: 0., width: 200., height: 48. }; + let placed = r.layout( rect, &canvas ); + assert_eq!( placed.len(), 2 ); + let ( first_rect, _ ) = placed[ 0 ]; + let ( second_rect, _ ) = placed[ 1 ]; + let gap = second_rect.x - ( first_rect.x + first_rect.width ); + assert!( ( gap - 30.0 ).abs() < 1e-3, "expected ~30 px gap, got {gap}" ); + } } diff --git a/src/layout/spacer.rs b/src/layout/spacer.rs index 4c682ba..ad7b825 100644 --- a/src/layout/spacer.rs +++ b/src/layout/spacer.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. +use crate::render::Canvas; +use crate::types::Length; use crate::widget::Element; /// A flexible, invisible spacer that expands to fill available space. @@ -44,7 +46,23 @@ use crate::widget::Element; /// # fn _ex() -> Element { /// column() /// .push( text( "Header" ) ) -/// .push( spacer().height( 20.0 ) ) // Exactly 20px gap +/// .push( spacer().height( 20.0 ) ) // Exactly 20 px gap +/// .push( text( "Content" ) ) +/// .into() +/// # } +/// ``` +/// +/// `.height(...)` and `.width(...)` also accept any [`crate::Length`], so the +/// gap can scale with the surface instead of being frozen at a px constant: +/// +/// ```rust,no_run +/// # use ltk::{ column, spacer, text, Length, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex() -> Element { +/// column() +/// .push( text( "Header" ) ) +/// // 6 % of the surface's smaller side, never below 16 px or above 64 px. +/// .push( spacer().height( Length::vmin( 6.0 ).clamp( 16.0, 64.0 ) ) ) /// .push( text( "Content" ) ) /// .into() /// # } @@ -53,10 +71,14 @@ pub struct Spacer { /// Relative weight of this spacer (default 1). pub weight: u32, - /// Fixed height in pixels (overrides flexible behavior in a column). - pub fixed_height: Option, - /// Fixed width in pixels (overrides flexible behavior in a row). - pub fixed_width: Option, + /// Fixed height (overrides flexible behavior in a column). Accepts any + /// [`Length`] — pass an `f32`/`i32`/`u32` for the px case (kept for + /// backward compatibility with existing call sites), or + /// `Length::vmin( … )` etc. for viewport-relative gaps. + pub fixed_height: Option, + /// Fixed width (overrides flexible behavior in a row). Same length-type + /// semantics as [`Self::fixed_height`]. + pub fixed_width: Option, } impl Spacer @@ -68,34 +90,50 @@ impl Spacer self } - /// Set a fixed height for this spacer in pixels. - /// When set, the spacer will occupy exactly this much vertical space - /// instead of expanding flexibly. - pub fn height( mut self, h: f32 ) -> Self + /// Set a fixed height for this spacer. Accepts any [`Length`]: a bare + /// `24.0_f32` is treated as `Length::px( 24.0 )` for source-level + /// backwards compatibility; a `Length::vmin( 6.0 )` makes the gap + /// scale with the surface's smaller dimension. + pub fn height( mut self, h: impl Into ) -> Self { - self.fixed_height = Some( h ); + self.fixed_height = Some( h.into() ); self } - /// Set a fixed width for this spacer in pixels. - /// When set, the spacer occupies exactly this much horizontal space - /// inside a [`Row`](crate::layout::row::Row) instead of expanding - /// flexibly. Mirrors [`Self::height`] for the horizontal axis — useful - /// to reserve a precise visual margin while a sibling - /// [`Flex`](crate::Flex) claims the remaining width. - pub fn width( mut self, w: f32 ) -> Self + /// Set a fixed width for this spacer. Mirrors [`Self::height`] for the + /// horizontal axis. + pub fn width( mut self, w: impl Into ) -> Self { - self.fixed_width = Some( w ); + self.fixed_width = Some( w.into() ); self } - /// Returns `( fixed_width, fixed_height )`, falling back to `0.0` on the - /// axes that were not pinned. The parent layout distributes leftover - /// along its main axis among the still-flexible spacers and `Flex` - /// wrappers, weighted by `weight`. - pub fn preferred_size( &self ) -> (f32, f32) + /// Returns `( fixed_width, fixed_height )` resolved against the + /// current canvas viewport, falling back to `0.0` on axes that were + /// not pinned. The parent layout distributes leftover along its main + /// axis among the still-flexible spacers and `Flex` wrappers, + /// weighted by `weight`. + pub fn preferred_size( &self, canvas: &Canvas ) -> ( f32, f32 ) { - ( self.fixed_width.unwrap_or( 0.0 ), self.fixed_height.unwrap_or( 0.0 ) ) + let vp = canvas.viewport_logical(); + let em = Length::EM_BASE_DEFAULT; + ( + self.fixed_width .map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ), + self.fixed_height.map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ), + ) + } + + /// Resolved fixed height in logical pixels, or `None` when the + /// spacer is flex. Cheaper than [`Self::preferred_size`] when the + /// layout only needs the main-axis size for one orientation. + pub fn resolved_height( &self, canvas: &Canvas ) -> Option + { + self.fixed_height.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) + } + + pub fn resolved_width( &self, canvas: &Canvas ) -> Option + { + self.fixed_width.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) } /// No-op — spacers are invisible. @@ -141,3 +179,47 @@ pub fn spacer() -> Spacer { Spacer { weight: 1, fixed_height: None, fixed_width: None } } + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::render::Canvas; + + fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } + + #[ test ] + fn height_accepts_f32_as_pixels() + { + let s = spacer().height( 24.0 ); + let canvas = make_canvas(); + assert_eq!( s.resolved_height( &canvas ), Some( 24.0 ) ); + } + + #[ test ] + fn height_accepts_length_and_resolves_against_viewport() + { + // 10 % of the smaller side (= 600) = 60 px. + let s = spacer().height( Length::vmin( 10.0 ) ); + let canvas = make_canvas(); + assert_eq!( s.resolved_height( &canvas ), Some( 60.0 ) ); + } + + #[ test ] + fn width_accepts_length_and_resolves_against_viewport() + { + let s = spacer().width( Length::vw( 25.0 ) ); + let canvas = make_canvas(); + // 25 % of 800 = 200. + assert_eq!( s.resolved_width( &canvas ), Some( 200.0 ) ); + } + + #[ test ] + fn flex_spacer_has_no_resolved_dimensions() + { + let s = spacer().weight( 3 ); + let canvas = make_canvas(); + assert_eq!( s.resolved_height( &canvas ), None ); + assert_eq!( s.resolved_width( &canvas ), None ); + } +} diff --git a/src/layout/stack.rs b/src/layout/stack.rs index c7b13b4..b7eb404 100644 --- a/src/layout/stack.rs +++ b/src/layout/stack.rs @@ -128,7 +128,7 @@ impl Stack let content_w = self.children.iter() .map( |( c, _, _, _, _, _ )| match c { - Element::Spacer( s ) => s.fixed_width.unwrap_or( 0.0 ), + Element::Spacer( s ) => s.resolved_width( canvas ).unwrap_or( 0.0 ), Element::Separator( _ ) => 0.0, Element::Scroll( _ ) => 0.0, Element::ProgressBar( _ ) => 0.0, diff --git a/src/lib.rs b/src/lib.rs index fd6d3b1..5b1b2c5 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -107,9 +107,36 @@ //! Geometry and primitive values that flow through every builder: //! //! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`]. +//! - [`Length`] — a size/distance that may be absolute pixels +//! ([`LengthBase::Px`]) or relative to the surface viewport +//! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] / +//! [`LengthBase::Vmax`]) or to the root font size ([`LengthBase::Em`]). +//! Every setter that takes a size, padding, spacing or font height +//! now accepts `impl Into`, so legacy `.size( 24.0 )` keeps +//! working while new code can write `.size( Length::vmin( 4.0 ) +//! .clamp( 16.0, 32.0 ) )` for a typeface that scales with the screen. //! //! See [`types`] for the full module with `//!` description. //! +//! ## Designing for multiple resolutions +//! +//! ltk supports three approaches to making a layout adapt to the screen, +//! ordered from most to least common: +//! +//! 1. **Relative [`Length`] values** for font sizes, padding, spacing and +//! spacer height/width. Default to `Length::vmin( pct ).clamp( lo, hi )`: +//! the percentage tracks the surface's smaller side (so portrait and +//! landscape behave coherently), and the px clamps protect both ends +//! of the spectrum from breaking the design. +//! 2. **Responsive typographic scale** via [`theme::typography::h0`]… +//! [`theme::typography::body_xs`] — pre-calibrated [`Length`] values +//! coherent with the default Sora-based theme. Calibrated so a +//! 1000-px smaller side reproduces the legacy px constants exactly. +//! 3. **`view()`-level branching on `surface_width` / `surface_height`** +//! when the layout structure itself must change (e.g. sidebar → +//! bottom-tabs below a breakpoint). Keep this for genuine restructuring, +//! not for sizing — `Length` covers sizing. +//! //! ## Runtime-free embedding //! //! Use [`core::UiSurface`] when you need ltk's layout, drawing and @@ -202,7 +229,7 @@ pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as th pub use gles_render::{ BorrowedGlesTexture, GlesVersion }; pub use render::is_software_render; pub use wallpaper::{ WallpaperBundle, ImageData }; -pub use types::{ Color, Corners, CursorShape, Point, Rect, Size, WidgetId }; +pub use types::{ Color, Corners, CursorShape, Length, LengthBase, Point, Rect, Size, WidgetId }; pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container }; pub use widget::button::ButtonVariant; pub use widget::slider::{ Slider, slider, SliderAxis }; diff --git a/src/render/mod.rs b/src/render/mod.rs index d96ff63..77e57b4 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -199,6 +199,31 @@ impl Canvas } } + /// `(width, height)` of the surface in **logical** pixels (physical + /// size divided by `dpi_scale`). This is the right viewport for + /// resolving [`crate::Length`] values, which are themselves in + /// logical units. Falls back to physical size if `dpi_scale` is + /// 0 or negative so a misconfigured canvas still returns a usable + /// non-zero viewport instead of `NaN`/`inf`. + /// + /// ```rust,no_run + /// # use ltk::core::Canvas; + /// let mut c = Canvas::new( 720, 1440 ); + /// c.set_dpi_scale( 2.0 ); + /// assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) ); + /// ``` + pub fn viewport_logical( &self ) -> ( f32, f32 ) + { + let ( pw, ph ) = self.size(); + let scale = self.dpi_scale(); + if scale > 0.0 + { + ( pw as f32 / scale, ph as f32 / scale ) + } else { + ( pw as f32, ph as f32 ) + } + } + /// Borrow the GLES texture backing this canvas, when the canvas /// is GPU-backed. pub fn borrowed_gles_texture( &self ) -> Option @@ -642,3 +667,34 @@ impl Canvas } } } + +#[ cfg( test ) ] +mod viewport_tests +{ + use super::Canvas; + + #[ test ] + fn viewport_logical_at_scale_one_matches_physical() + { + let c = Canvas::new( 800, 600 ); + assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) ); + } + + #[ test ] + fn viewport_logical_divides_by_dpi_scale() + { + let mut c = Canvas::new( 720, 1440 ); + c.set_dpi_scale( 2.0 ); + assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) ); + } + + #[ test ] + fn viewport_logical_falls_back_to_physical_when_scale_is_zero() + { + // Guard the misconfigured-scale path: a divide-by-zero would + // poison every `Length::Vmin`/`Vw`/`Vh` resolution downstream. + let mut c = Canvas::new( 800, 600 ); + c.set_dpi_scale( 0.0 ); + assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) ); + } +} diff --git a/src/theme/typography.rs b/src/theme/typography.rs index 30532af..f786a27 100644 --- a/src/theme/typography.rs +++ b/src/theme/typography.rs @@ -6,6 +6,17 @@ //! Designed around the **Sora** typeface (Google Fonts). If Sora is not //! installed on the system, ltk falls back to Liberation Sans / DejaVu Sans; //! glyph metrics will differ slightly but the scale still reads correctly. +//! +//! Two scales coexist: the historic **px** constants (`H0` through +//! `BODY_XS`) for code that wants a frozen pixel size, and the new +//! **responsive** scale ([`h0`], [`h1`], …, [`body_xs`]) that returns +//! viewport-relative [`crate::Length`] values clamped to the same px +//! range that used to be the constant. Call sites can mix freely: +//! `.size( typography::H2 )` still resolves to `Length::px( 24.0 )` via +//! `From`, while `.size( typography::h2() )` scales with the +//! surface's smaller dimension. + +use crate::types::Length; pub const H0: f32 = 50.0; pub const H1: f32 = 34.0; @@ -18,3 +29,59 @@ pub const BODY_XS: f32 = 12.0; /// Interlineado (line-height) multiplier recommended by the kit. Apply as /// `size * LINE_HEIGHT` when laying out multi-line text blocks. pub const LINE_HEIGHT: f32 = 1.5; + +// ─── Responsive scale ──────────────────────────────────────────────────────── +// +// The percentages are calibrated so the px clamps match the legacy constants +// at a 1000 px logical smaller side (a typical landscape tablet). On a Librem 5 +// portrait (~720 px) headings round down sensibly; on a 4K desktop the upper +// clamp kicks in before display titles get absurd. + +pub fn h0() -> Length { Length::vmin( 5.0 ).clamp( 32.0, 80.0 ) } +pub fn h1() -> Length { Length::vmin( 3.4 ).clamp( 24.0, 56.0 ) } +pub fn h2() -> Length { Length::vmin( 2.4 ).clamp( 18.0, 40.0 ) } +pub fn h3() -> Length { Length::vmin( 2.0 ).clamp( 16.0, 32.0 ) } +pub fn body() -> Length { Length::vmin( 1.6 ).clamp( 14.0, 22.0 ) } +pub fn body_s() -> Length { Length::vmin( 1.4 ).clamp( 12.0, 18.0 ) } +pub fn body_xs() -> Length { Length::vmin( 1.2 ).clamp( 11.0, 15.0 ) } + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + /// 360-px-wide portrait phone — Vmin ratio under the clamp's lower bound, + /// so every scale snaps to its `min_px`. + #[ test ] + fn responsive_scale_pins_to_min_on_narrow_phones() + { + let vp = ( 360.0, 720.0 ); + let em = Length::EM_BASE_DEFAULT; + assert_eq!( h0().resolve( vp, em ), 32.0 ); + assert_eq!( body().resolve( vp, em ), 14.0 ); + assert_eq!( body_xs().resolve( vp, em ), 11.0 ); + } + + /// 1000-px smaller side — the calibration point. Numbers should be + /// "around" the legacy px constants without exceeding the upper clamp. + #[ test ] + fn responsive_scale_centers_around_legacy_px_constants() + { + let vp = ( 1000.0, 1000.0 ); + let em = Length::EM_BASE_DEFAULT; + assert_eq!( h0().resolve( vp, em ), 50.0 ); + assert_eq!( h2().resolve( vp, em ), 24.0 ); + assert_eq!( body().resolve( vp, em ), 16.0 ); + } + + /// 4K-class smaller side — every scale should saturate to its upper + /// clamp instead of growing absurdly. + #[ test ] + fn responsive_scale_pins_to_max_on_large_displays() + { + let vp = ( 2160.0, 3840.0 ); + let em = Length::EM_BASE_DEFAULT; + assert_eq!( h0().resolve( vp, em ), 80.0 ); + assert_eq!( body().resolve( vp, em ), 22.0 ); + } +} diff --git a/src/types.rs b/src/types.rs index 5475a84..823fa94 100644 --- a/src/types.rs +++ b/src/types.rs @@ -430,3 +430,226 @@ mod tests assert_eq!( r, e ); } } + +// ─── Length ────────────────────────────────────────────────────────────────── + +/// One of the pure relative-or-absolute modes a [`Length`] can carry. +/// Split out so [`Length`] itself can stay `Copy` while still supporting +/// optional clamp bounds — the recursive `Clamp` variant of the original +/// sketch would have forced a `Box` allocation, which on a widget tree +/// that builds these values per frame is the wrong trade. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub enum LengthBase +{ + /// Absolute, in logical pixels. + Px( f32 ), + /// Percentage of the viewport's width (`Vw(10.0)` == 10 % of width). + Vw( f32 ), + /// Percentage of the viewport's height. + Vh( f32 ), + /// Percentage of the viewport's **smaller** dimension. The right + /// default for typography and gutters that must survive a + /// portrait/landscape rotation without growing absurd. + Vmin( f32 ), + /// Percentage of the viewport's **larger** dimension. + Vmax( f32 ), + /// Multiple of the root font size (typographic hierarchy: a heading + /// of `Em(2.0)` is twice the body size, regardless of viewport). + Em( f32 ), +} + +impl LengthBase +{ + fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32 + { + let ( vw, vh ) = viewport; + match self + { + LengthBase::Px( v ) => *v, + LengthBase::Vw( pct ) => vw * pct / 100.0, + LengthBase::Vh( pct ) => vh * pct / 100.0, + LengthBase::Vmin( pct ) => vw.min( vh ) * pct / 100.0, + LengthBase::Vmax( pct ) => vw.max( vh ) * pct / 100.0, + LengthBase::Em( mul ) => em_base * mul, + } + } +} + +/// A size or distance value that may be expressed in absolute pixels or +/// relative to the rendering surface. Every widget API that used to take +/// `f32` for a size, padding, spacing or font height now takes +/// `impl Into`, so existing call sites keep compiling unchanged +/// while new code can switch to viewport-relative units for layouts that +/// must scale across screen sizes (portrait phone, landscape tablet, +/// 4K desktop) without per-target tweaks. +/// +/// Resolution requires a viewport — passed in as `(width, height)` in +/// **logical** pixels — and an `em_base` (the body-text font size that +/// `Em` is a multiple of). All resolution funnels through +/// [`Length::resolve`], so widgets can stay backend-agnostic. +/// +/// Construct directly via the [`LengthBase`] variants +/// (`Length::vmin( 18.0 )`, `Length::px( 24.0 )`, …) or implicitly from +/// `f32`/`i32`/`u32` for the px case so legacy `.size( 24.0 )` style +/// keeps compiling unchanged. Optionally chain `.clamp( min_px, max_px )` +/// to bound a relative value into a safe range. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub struct Length +{ + pub base: LengthBase, + /// Lower bound in absolute logical px. `None` means unbounded. + pub min_px: Option, + /// Upper bound in absolute logical px. `None` means unbounded. + pub max_px: Option, +} + +impl Length +{ + /// Default font-size that [`LengthBase::Em`] is a multiple of. Matches + /// the `typography::BODY` constant of the default theme. + pub const EM_BASE_DEFAULT: f32 = 16.0; + + pub const fn from_base( base: LengthBase ) -> Self + { + Self { base, min_px: None, max_px: None } + } + + /// Shorthand constructors. `Length::vmin( 18.0 )` reads better than + /// `Length::from_base( LengthBase::Vmin( 18.0 ) )` at every call site + /// and the brevity matters when these appear in tight view code. + pub const fn px( v: f32 ) -> Self { Self::from_base( LengthBase::Px( v ) ) } + pub const fn vw( v: f32 ) -> Self { Self::from_base( LengthBase::Vw( v ) ) } + pub const fn vh( v: f32 ) -> Self { Self::from_base( LengthBase::Vh( v ) ) } + pub const fn vmin( v: f32 ) -> Self { Self::from_base( LengthBase::Vmin( v ) ) } + pub const fn vmax( v: f32 ) -> Self { Self::from_base( LengthBase::Vmax( v ) ) } + pub const fn em( v: f32 ) -> Self { Self::from_base( LengthBase::Em( v ) ) } + + /// Resolve to a concrete logical-pixel value given a viewport and an + /// `em_base` (the root font size that `Em` is a fraction of). + pub fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32 + { + let raw = self.base.resolve( viewport, em_base ); + let lo = self.min_px; + let hi = self.max_px; + // If both bounds present, normalise their order so swapped args + // don't produce NaN out of f32::clamp. + let ( lo, hi ) = match ( lo, hi ) + { + ( Some( a ), Some( b ) ) if a > b => ( Some( b ), Some( a ) ), + other => other, + }; + let v = match lo { Some( a ) => raw.max( a ), None => raw }; + match hi { Some( b ) => v.min( b ), None => v } + } + + /// Cap the resolved value to `[min_px, max_px]`. Bounds are + /// absolute px because the typical use is "this Vmin should never + /// shrink past readable nor balloon past comfortable"; bounding + /// a relative value with another relative value is rare enough to + /// not justify boxing the type. If you swap min/max the resolver + /// tolerates it instead of panicking. + pub fn clamp( mut self, min_px: f32, max_px: f32 ) -> Length + { + self.min_px = Some( min_px ); + self.max_px = Some( max_px ); + self + } + + /// One-sided bound: never resolve below `min_px`. Named `at_least` + /// (rather than `min`) to avoid clashing visually with `f32::min`, + /// which has the opposite semantics ("return the smaller of two"). + pub fn at_least( mut self, min_px: f32 ) -> Length + { + self.min_px = Some( min_px ); + self + } + + /// One-sided bound: never resolve above `max_px`. Counterpart to + /// [`Self::at_least`]. + pub fn at_most( mut self, max_px: f32 ) -> Length + { + self.max_px = Some( max_px ); + self + } +} + +impl From for Length +{ + fn from( v: f32 ) -> Self { Length::px( v ) } +} + +impl From for Length +{ + fn from( v: i32 ) -> Self { Length::px( v as f32 ) } +} + +impl From for Length +{ + fn from( v: u32 ) -> Self { Length::px( v as f32 ) } +} + +impl From for Length +{ + fn from( base: LengthBase ) -> Self { Length::from_base( base ) } +} + +#[ cfg( test ) ] +mod length_tests +{ + use super::Length; + + #[ test ] + fn px_is_passthrough() + { + assert_eq!( Length::px( 42.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 42.0 ); + } + + #[ test ] + fn vw_vh_are_percent_of_viewport() + { + assert_eq!( Length::vw( 50.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 400.0 ); + assert_eq!( Length::vh( 25.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 150.0 ); + } + + #[ test ] + fn vmin_picks_smaller_side() + { + assert_eq!( Length::vmin( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 60.0 ); + assert_eq!( Length::vmin( 10.0 ).resolve( ( 600.0, 800.0 ), 16.0 ), 60.0 ); + } + + #[ test ] + fn vmax_picks_larger_side() + { + assert_eq!( Length::vmax( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 80.0 ); + } + + #[ test ] + fn em_uses_em_base() + { + assert_eq!( Length::em( 2.0 ).resolve( ( 800.0, 600.0 ), 18.0 ), 36.0 ); + } + + #[ test ] + fn clamp_bounds_relative_value() + { + // 50 % of the smaller side (= 300) capped to [100, 200] → 200. + let l = Length::vmin( 50.0 ).clamp( 100.0, 200.0 ); + assert_eq!( l.resolve( ( 800.0, 600.0 ), 16.0 ), 200.0 ); + + // 1 % of the smaller side (= 6) lifted to the min of 50. + let l2 = Length::vmin( 1.0 ).clamp( 50.0, 200.0 ); + assert_eq!( l2.resolve( ( 800.0, 600.0 ), 16.0 ), 50.0 ); + + // Caller swapped min/max — resolver tolerates without panic. + let l3 = Length::vmin( 50.0 ).clamp( 200.0, 100.0 ); + assert_eq!( l3.resolve( ( 800.0, 600.0 ), 16.0 ), 200.0 ); + } + + #[ test ] + fn f32_converts_to_px() + { + let l: Length = 24.0_f32.into(); + assert_eq!( l.base, super::LengthBase::Px( 24.0 ) ); + } +} diff --git a/src/widget/element.rs b/src/widget/element.rs index 9317cbf..7c834f1 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -56,7 +56,7 @@ impl Element Element::Row( r ) => r.preferred_size( max_width, canvas ), Element::Stack( s ) => s.preferred_size( max_width, canvas ), Element::Text( t ) => t.preferred_size( max_width, canvas ), - Element::Spacer( s ) => s.preferred_size(), + Element::Spacer( s ) => s.preferred_size( canvas ), Element::Scroll( s ) => s.preferred_size( max_width, canvas ), Element::Viewport( v ) => v.preferred_size( max_width, canvas ), Element::WrapGrid( g ) => g.preferred_size( max_width, canvas ), diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 271f46d..c416a89 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use fontdue::Font; use crate::theme::FontStyle; -use crate::types::{ Color, Rect }; +use crate::types::{ Color, Length, Rect }; use crate::render::Canvas; use super::Element; @@ -24,7 +24,10 @@ pub enum TextAlign pub struct Text { pub content: String, - pub size: f32, + /// Font size as a [`Length`]. Resolved against the surface's logical + /// viewport at layout time, so a `Length::Vmin( 5.0 )` heading scales + /// with the screen instead of being frozen at a px constant. + pub size: Length, pub color: Color, pub align: TextAlign, pub wrap: bool, @@ -47,7 +50,7 @@ impl Text Self { content: content.into(), - size: 16.0, + size: Length::px( 16.0 ), color: Color::WHITE, align: TextAlign::Left, wrap: false, @@ -56,6 +59,15 @@ impl Text } } + /// Resolve the font size against the canvas viewport. Internal + /// helper: every method that needs an `f32` size for a `fontdue` + /// call routes through this so the field can stay a `Length`. + #[ inline ] + fn resolved_size( &self, canvas: &Canvas ) -> f32 + { + self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + } + pub fn no_truncate( mut self ) -> Self { self.truncate = false; @@ -81,9 +93,9 @@ impl Text self } - pub fn size( mut self, s: f32 ) -> Self + pub fn size( mut self, s: impl Into ) -> Self { - self.size = s; + self.size = s.into(); self } @@ -126,41 +138,49 @@ impl Text fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc> ) -> f32 { + let size = self.resolved_size( canvas ); match font { - Some( f ) => canvas.measure_text_with_font( text, self.size, f ), - None => canvas.measure_text( text, self.size ), + Some( f ) => canvas.measure_text_with_font( text, size, f ), + None => canvas.measure_text( text, size ), } } fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc> ) -> f32 { + let size = self.resolved_size( canvas ); match font { - Some( f ) => f.metrics( ch, self.size * canvas.dpi_scale() ).advance_width, - None => canvas.font_metrics( ch, self.size ).advance_width, + Some( f ) => f.metrics( ch, size * canvas.dpi_scale() ).advance_width, + None => canvas.font_metrics( ch, size ).advance_width, } } fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc> ) { + let size = self.resolved_size( canvas ); match font { - Some( f ) => canvas.draw_text_with_font( text, x, y, self.size, self.color, f ), - None => canvas.draw_text( text, x, y, self.size, self.color ), + Some( f ) => canvas.draw_text_with_font( text, x, y, size, self.color, f ), + None => canvas.draw_text( text, x, y, size, self.color ), } } pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) { - let line_h = canvas.font_line_metrics( self.size ) - .map( |m| m.ascent - m.descent ) - .unwrap_or( self.size ); + let size = self.resolved_size( canvas ); + // `new_line_size = ascent - descent + line_gap` is the standard + // typographic line height; the previous `ascent - descent` discarded + // the font-declared leading and at large sizes left adjacent rows + // visibly overlapping when stacked tight in a column. + let line_h = canvas.font_line_metrics( size ) + .map( |m| m.new_line_size ) + .unwrap_or( size ); let font = self.resolve_font( canvas ); if self.wrap { - let lines = wrap_lines( &self.content, self.size, max_width, canvas, font.as_ref() ); + let lines = wrap_lines( &self.content, size, max_width, canvas, font.as_ref() ); let h = line_h * lines.len().max( 1 ) as f32; ( max_width, h ) } @@ -173,17 +193,18 @@ impl Text pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool ) { - let ascent = canvas.font_line_metrics( self.size ) + let size = self.resolved_size( canvas ); + let ascent = canvas.font_line_metrics( size ) .map( |m| m.ascent ) - .unwrap_or( self.size * 0.8 ); - let line_h = canvas.font_line_metrics( self.size ) - .map( |m| m.ascent - m.descent ) - .unwrap_or( self.size ); + .unwrap_or( size * 0.8 ); + let line_h = canvas.font_line_metrics( size ) + .map( |m| m.new_line_size ) + .unwrap_or( size ); let font = self.resolve_font( canvas ); if self.wrap { - let lines = wrap_lines( &self.content, self.size, rect.width, canvas, font.as_ref() ); + let lines = wrap_lines( &self.content, size, rect.width, canvas, font.as_ref() ); for ( i, line ) in lines.iter().enumerate() { let line_w = self.measure( line, canvas, font.as_ref() ); diff --git a/src/widget/text/tests.rs b/src/widget/text/tests.rs index 88fe232..489df37 100644 --- a/src/widget/text/tests.rs +++ b/src/widget/text/tests.rs @@ -11,7 +11,7 @@ fn new_uses_documented_defaults() { let t = Text::new( "hello" ); assert_eq!( t.content, "hello" ); - assert_eq!( t.size, 16.0 ); + assert_eq!( t.size, crate::Length::px( 16.0 ) ); assert_eq!( t.color, Color::WHITE ); assert_eq!( t.align, TextAlign::Left ); } @@ -20,7 +20,14 @@ fn new_uses_documented_defaults() fn size_builder_overrides_default() { let t = Text::new( "" ).size( 32.0 ); - assert_eq!( t.size, 32.0 ); + assert_eq!( t.size, crate::Length::px( 32.0 ) ); +} + +#[ test ] +fn size_builder_accepts_length() +{ + let t = Text::new( "" ).size( crate::Length::vmin( 5.0 ) ); + assert_eq!( t.size.base, crate::LengthBase::Vmin( 5.0 ) ); } #[ test ] diff --git a/tests/layout_stack_spacer.rs b/tests/layout_stack_spacer.rs index b0706ee..a4eff53 100644 --- a/tests/layout_stack_spacer.rs +++ b/tests/layout_stack_spacer.rs @@ -2,7 +2,7 @@ // the way `Spacer` distributes leftover space inside a `Column`. use ltk::core::Canvas; -use ltk::{ stack, spacer, HAlign, VAlign, Rect }; +use ltk::{ stack, spacer, HAlign, Length, VAlign, Rect }; fn make_canvas() -> Canvas { @@ -181,29 +181,32 @@ fn stack_empty_preferred_size_height_is_zero() #[ test ] fn spacer_default_has_weight_one_and_no_fixed_size() { + let canvas = make_canvas(); let s = spacer(); assert_eq!( s.weight, 1 ); assert!( s.fixed_height.is_none() ); assert!( s.fixed_width.is_none() ); - assert_eq!( s.preferred_size(), ( 0.0, 0.0 ) ); + assert_eq!( s.preferred_size( &canvas ), ( 0.0, 0.0 ) ); } #[ test ] fn spacer_height_pins_vertical_axis_only() { + let canvas = make_canvas(); let s = spacer().height( 24.0 ); - assert_eq!( s.fixed_height, Some( 24.0 ) ); + assert_eq!( s.fixed_height, Some( Length::px( 24.0 ) ) ); assert!( s.fixed_width.is_none() ); - assert_eq!( s.preferred_size(), ( 0.0, 24.0 ) ); + assert_eq!( s.preferred_size( &canvas ), ( 0.0, 24.0 ) ); } #[ test ] fn spacer_width_pins_horizontal_axis_only() { + let canvas = make_canvas(); let s = spacer().width( 12.0 ); - assert_eq!( s.fixed_width, Some( 12.0 ) ); + assert_eq!( s.fixed_width, Some( Length::px( 12.0 ) ) ); assert!( s.fixed_height.is_none() ); - assert_eq!( s.preferred_size(), ( 12.0, 0.0 ) ); + assert_eq!( s.preferred_size( &canvas ), ( 12.0, 0.0 ) ); } #[ test ]