diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf0e69..e7a2141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Added +- **`Viewport::local_viewport()`** — resolve the child's viewport-relative (`vw` / `vh` / `vmin`) and fluid `Length`s against the viewport's own rect instead of the root layout viewport the sub-canvas inherits. For fixed-size floating mini-UIs (a phone-shaped panel pinned to a corner of a desktop-wide surface) whose content is calibrated against the panel rect; scroll-like clips should keep the default inheritance. +- **`ListItem::height( impl Into )` / `ListItem::font_size( impl Into )`** — override the theme row height (floored at the label's rendered height so text never clips) and the primary-label font size, mirroring the `Toggle` / `Radio` `height()` builders, so dense menus can trade the touch-target generosity for row density. + - **Per-canvas pixel density** — `Canvas::set_density` pins a canvas (and the sub-canvases derived from it) to its own density factor for `Length::dp` resolution, overriding the process `set_density` global; `Canvas::density` reads the effective value. New `Canvas::resolve_geom` / `Canvas::resolve_font` resolve an explicit `Length` in geometry / font space with the canvas' density — widgets now route caller-supplied lengths through them, so a `dp` override follows the canvas it draws on. The hook for surfaces on outputs whose DPI differs from the process-wide one (an overlay on a second monitor, an embedder with several `UiSurface`s). - **`Length::resolve_with_density`** — `Length::resolve` with an explicit density for `LengthBase::Dp`, instead of the process `density()`. - **`docs/backends.md`** — the canonical software/GLES capability matrix: what renders identically, what degrades gracefully on software (gradients, shadows, backdrop blur, bottom fade) and what is GPU-only, replacing per-method rustdoc archaeology. Linked from README, onboarding and the architecture known-gaps list. diff --git a/docs/architecture.md b/docs/architecture.md index 490ac73..4eb28f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -197,7 +197,7 @@ Every size in a widget tree is a `Length`, resolved to concrete pixels at layout Stock widgets do not hard-code either strategy. Each carries a design pixel per dimension (e.g. `button` height 48, font 16) and resolves it through the process-wide `WidgetScaling` mode: `Length::widget(n)` returns `fluid(n)` under `WidgetScaling::Fluid` (the default) or `dp(n)` under `WidgetScaling::Physical`. `set_widget_scaling(mode)` flips it once for the whole app. An explicit `Length` on an individual widget (`button.height(...)`, `text_edit.height(...)`, `font_size(...)`) bypasses the mode entirely — the mode only decides the meaning of the *default* design pixels, never an override the app wrote on purpose. -Both `density()` and `widget_scaling()` are process globals read during layout; set them at startup (or, for density, whenever the surface moves to an output with a different DPI). Because they are global, ltk's own test suite serialises the tests that touch them. Density is also overridable **per canvas**: `Canvas::set_density` pins a canvas (and every sub-canvas derived from it) to its own factor, and all canvas-routed resolution — `geom_px` / `font_px` for stock-widget design pixels, `Canvas::resolve_geom` / `resolve_font` for explicit `Length` values — uses the local density when one is pinned and the process global otherwise. This is the hook for a surface sitting on an output whose DPI differs from the one the global was derived from (an overlay on a second monitor, an embedder with several `UiSurface`s). +Both `density()` and `widget_scaling()` are process globals read during layout; set them at startup (or, for density, whenever the surface moves to an output with a different DPI). Because they are global, ltk's own test suite serialises the tests that touch them. Density is also overridable **per canvas**: `Canvas::set_density` pins a canvas (and every sub-canvas derived from it) to its own factor, and all canvas-routed resolution — `geom_px` / `font_px` for stock-widget design pixels, `Canvas::resolve_geom` / `resolve_font` for explicit `Length` values — uses the local density when one is pinned and the process global otherwise. This is the hook for a surface sitting on an output whose DPI differs from the one the global was derived from (an overlay on a second monitor, an embedder with several `UiSurface`s). The layout viewport follows the same inheritance model: sub-canvases (scroll and viewport clips, GLES clip layers) inherit the root surface's layout viewport so fluid and viewport-relative values resolve identically inside and outside a clip, and `Viewport::local_viewport()` opts a clip out of it — its child then resolves against the clip's own rect, for fixed-size floating mini-UIs whose content must not scale with the host surface. `Length` adapts *sizes* to the orientation; to adapt the *structure* of a layout (a row of panels in landscape, the same panels stacked in portrait), branch the view on `ltk::orientation()`. The runtime records the main surface's physical dimensions on every configure (also readable as `ltk::viewport_size()`) and rebuilds the view after each resize, so a `match ltk::orientation() { Landscape => row()…, Portrait => column()… }` follows the window live. The portrait/landscape rule matches `Length::orient` (a square surface counts as portrait). `examples/clip_path.rs` shows the pattern. diff --git a/docs/cookbook.md b/docs/cookbook.md index 0049075..2460bda 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -145,6 +145,8 @@ backend renders a hard edge. If your shell must look identical on both backends, branch on `ltk::is_software_render()` and skip the fade when the software path is active. +If the panel is a fixed-size pill on a much larger surface (a phone-shaped quick-settings card pinned to a corner of a desktop monitor), add `.local_viewport()` to the `viewport` so `vw` / `vmin` and fluid sizes inside resolve against the pill rect instead of the whole surface — without it the panel's content scales with the monitor and overflows the pill. + **See also**: [`Viewport`](./widgets.md#viewport), [`OverlaySpec`](../src/app.rs). diff --git a/docs/widgets.md b/docs/widgets.md index 834d0b2..236a7eb 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -175,6 +175,8 @@ When both are set the text sits to the left of the icon. its content (theme default 16 px) — lower it when the enclosing view's own padding already provides the margin. +`height( len )` overrides the theme row height (56 design px, 68 with a subtitle) and `font_size( len )` the primary-label size (16 design px; subtitle and trailing keep their theme sizes), both `impl Into`. Use them together where a dense list — a context menu, a compact picker — should trade the touch-target generosity of a settings list for row density; the resolved height is floored at the label's rendered height so the text never clips however aggressive the override. + **See also**: [`pressable`](#pressable) for free-form tappable rows, [`scroll`](#scroll) to wrap a list of items in a scrollable container. @@ -578,6 +580,8 @@ viewport( panel_view ) # } ``` +By default the clip's sub-canvas inherits the root layout viewport, so viewport-relative (`vw` / `vh` / `vmin`) and fluid `Length`s inside resolve exactly as they would outside the clip — the right behaviour for scroll-like clipping. `local_viewport()` opts out: the child resolves those `Length`s against the viewport's own rect instead. Use it for a fixed-size floating mini-UI — a phone-shaped panel pinned to a corner of a desktop-wide surface — whose content is calibrated against the panel rect and must not scale with the surface hosting it. + **See also**: the slide-in panel recipe in [`docs/cookbook.md`](./cookbook.md#slide-in-panel). diff --git a/src/draw/layout.rs b/src/draw/layout.rs index 749cf63..7d39d21 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -355,6 +355,7 @@ pub( crate ) fn layout_and_draw( let vw = ( rect.width.ceil() as u32 ).max( 1 ); let vh = ( rect.height.ceil() as u32 ).max( 1 ); let mut sub = canvas.sub_canvas( vw, vh ); + if v.local_viewport { sub.set_local_layout_viewport(); } sub.clear(); let child_rect = Rect { x: 0.0, y: 0.0, width: rect.width, height: effective_h }; diff --git a/src/render/mod.rs b/src/render/mod.rs index 19dcfa1..1cbdcf4 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -296,6 +296,22 @@ impl Canvas } } + /// Pin this canvas' layout viewport to its own pixel size, opting a + /// sub-canvas out of the root-viewport inheritance: viewport-relative + /// and fluid [`Length`]s drawn on it resolve against the canvas rect + /// itself. For fixed-size floating mini-UIs (a phone-shaped panel on + /// a desktop surface) whose content must not scale with the surface + /// hosting them. + pub( crate ) fn set_local_layout_viewport( &mut self ) + { + let ( w, h ) = self.size(); + match self + { + Canvas::Software( c ) => c.layout_viewport = Some( ( w as f32, h as f32 ) ), + Canvas::Gles( c ) => c.layout_viewport = Some( ( w as f32, h as f32 ) ), + } + } + /// Resolve an explicit [`Length`] in **geometry** space: against /// [`Self::viewport_layout`], with this canvas' [`Self::density`]. /// Widgets resolve caller-supplied geometry lengths through this so @@ -974,6 +990,21 @@ mod viewport_tests assert_eq!( sub.density(), 3.0 ); } + #[ test ] + fn sub_canvas_local_layout_viewport_overrides_inheritance() + { + let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); + + let c = Canvas::new( 1920, 1080 ); + let mut sub = c.sub_canvas( 396, 644 ); + assert_eq!( sub.viewport_layout(), ( 1920.0, 1080.0 ) ); + sub.set_local_layout_viewport(); + assert_eq!( sub.viewport_layout(), ( 396.0, 644.0 ) ); + // Nested sub-canvases keep propagating the pinned local viewport. + let nested = sub.sub_canvas( 200, 200 ); + assert_eq!( nested.viewport_layout(), ( 396.0, 644.0 ) ); + } + #[ test ] fn font_px_is_constant_physical_in_physical_mode() { diff --git a/src/widget/list_item/mod.rs b/src/widget/list_item/mod.rs index 6629199..134d875 100644 --- a/src/widget/list_item/mod.rs +++ b/src/widget/list_item/mod.rs @@ -67,6 +67,10 @@ pub struct ListItem /// Optional override of the horizontal content inset. `None` /// falls back to `theme::PAD_H`. pub( crate ) pad_h: Option, + /// Optional override of the theme row height. + pub( crate ) height: Option, + /// Optional override of the primary-label font size. + pub( crate ) font_size: Option, } impl ListItem @@ -86,6 +90,8 @@ impl ListItem selected: false, icon: None, pad_h: None, + height: None, + font_size: None, } } @@ -143,6 +149,26 @@ impl ListItem self } + /// Override the preferred row height (default: the theme row + /// height, `theme::HEIGHT` / `theme::HEIGHT_SUB` design px). + /// Accepts any [`Length`] so dense menus can trade the touch-target + /// generosity for row density. The resolved value is floored at the + /// label's rendered height so the text never clips. + pub fn height( mut self, h: impl Into ) -> Self + { + self.height = Some( h.into() ); + self + } + + /// Override the primary-label font size (default: `theme::LABEL_SIZE` + /// design px, resolved through the widget-scaling mode). Subtitle and + /// trailing text keep their theme sizes. + pub fn font_size( mut self, s: impl Into ) -> Self + { + self.font_size = Some( s.into() ); + self + } + /// Set the message emitted when the row is tapped. pub fn on_press( mut self, msg: Msg ) -> Self { @@ -157,13 +183,30 @@ impl ListItem self } + /// Effective primary-label font size on `canvas`, honouring the + /// [`Self::font_size`] override. + fn label_px( &self, canvas: &Canvas ) -> f32 + { + match self.font_size + { + Some( l ) => canvas.resolve_font( l ), + None => canvas.font_px( theme::LABEL_SIZE ), + } + } + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) { - let h = if self.subtitle.is_some() + let h = match self.height { - canvas.geom_px( theme::HEIGHT_SUB ) - } else { - canvas.geom_px( theme::HEIGHT ) + Some( l ) => + { + // Floor at the label's physical height so the row can be + // squeezed but the text never clips. + let label_phys = self.label_px( canvas ) * canvas.dpi_scale(); + canvas.resolve_geom( l ).max( label_phys + 4.0 ) + } + None if self.subtitle.is_some() => canvas.geom_px( theme::HEIGHT_SUB ), + None => canvas.geom_px( theme::HEIGHT ), }; ( max_width, h ) } @@ -206,7 +249,7 @@ impl ListItem canvas.stroke_rect( rect, theme::focus_color(), theme::FOCUS_W, theme::RADIUS ); } - let label_size = canvas.font_px( theme::LABEL_SIZE ); + let label_size = self.label_px( canvas ); let pad_h = self.pad_h .map( |l| canvas.resolve_geom( l ) ) .unwrap_or_else( || canvas.geom_px( theme::PAD_H ) ); @@ -288,6 +331,8 @@ impl ListItem selected: self.selected, icon: self.icon, pad_h: self.pad_h, + height: self.height, + font_size: self.font_size, } } } diff --git a/src/widget/radio/mod.rs b/src/widget/radio/mod.rs index 809c4fb..c51c516 100644 --- a/src/widget/radio/mod.rs +++ b/src/widget/radio/mod.rs @@ -84,7 +84,7 @@ impl Radio } /// Override the preferred height (default: the theme row height, - /// [`theme::HEIGHT`] design px). Accepts any [`Length`] so dense + /// `theme::HEIGHT` design px). Accepts any [`Length`] so dense /// layouts can tie the row height to the viewport. The resolved /// value is floored at the outer circle size so the ring never /// clips; the circle keeps its theme size and stays centred. diff --git a/src/widget/toggle/mod.rs b/src/widget/toggle/mod.rs index 5e17be3..d69d3c0 100644 --- a/src/widget/toggle/mod.rs +++ b/src/widget/toggle/mod.rs @@ -88,7 +88,7 @@ impl Toggle } /// Override the preferred height (default: the theme row height, - /// [`theme::HEIGHT`] design px). Accepts any [`Length`] so dense + /// `theme::HEIGHT` design px). Accepts any [`Length`] so dense /// layouts can tie the row height to the viewport. The resolved /// value is floored at the track height so the pill never clips; /// the track keeps its theme size and stays vertically centred. diff --git a/src/widget/viewport/mod.rs b/src/widget/viewport/mod.rs index 47b9393..489eff8 100644 --- a/src/widget/viewport/mod.rs +++ b/src/widget/viewport/mod.rs @@ -30,13 +30,17 @@ pub struct Viewport /// the leading edge of the animation does not knife-cut against the layer /// below it. pub( crate ) fade_bottom: f32, + /// Resolve the child's viewport-relative and fluid `Length`s against + /// this viewport's own rect instead of the inherited root layout + /// viewport. See [`Self::local_viewport`]. + pub( crate ) local_viewport: bool, } impl Viewport { pub fn new( child: impl Into> ) -> Self { - Self { child: Box::new( child.into() ), width: None, height: None, fade_bottom: 0.0 } + Self { child: Box::new( child.into() ), width: None, height: None, fade_bottom: 0.0, local_viewport: false } } /// Set a fixed viewport width in logical pixels. Mirrors @@ -66,6 +70,20 @@ impl Viewport self } + /// Resolve the child's viewport-relative (`vw` / `vh` / `vmin`) and + /// fluid `Length`s against this viewport's own rect instead of the + /// root surface the sub-canvas normally inherits. For a fixed-size + /// floating mini-UI (e.g. a phone-shaped panel pinned to a corner of + /// a desktop-wide surface) whose content is calibrated against the + /// panel rect and must not scale with the host surface. Scroll-like + /// clips should keep the default inheritance so content renders at + /// the same size inside and outside the clip. + pub fn local_viewport( mut self ) -> Self + { + self.local_viewport = true; + self + } + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) { let inner_w = self.width.unwrap_or( max_width ); @@ -83,10 +101,11 @@ impl Viewport { Viewport { - child: Box::new( self.child.map_arc( f ) ), - width: self.width, - height: self.height, - fade_bottom: self.fade_bottom, + child: Box::new( self.child.map_arc( f ) ), + width: self.width, + height: self.height, + fade_bottom: self.fade_bottom, + local_viewport: self.local_viewport, } } }