ltk: responsive padding/spacing and scrolling, expanded theme palette, and bundled Adwaita cursors
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

A mixed pass over the default theme and the layout/input core, plus the toolkit's own cursor set. Grouped by area below.
== Responsive sizing ==
Add `Length::dp( px )` — a "design pixel". It interprets `px` against a configurable reference vmin (default 412 px, the eydos mobile reference width) and returns `Vmin( px / reference * 100 ).clamp( px * 0.7, px * 1.5 )`, so a value authored against a mock-up scales with the surface without collapsing on tiny screens or ballooning on a 4K desktop. The reference is process-global, set via `set_design_reference()` and read via `design_reference()` (stored as f32 bits in an AtomicU32); both are re-exported from `lib.rs`.
Make container and grid insets relative. `Container`'s four padding fields become `Length` instead of `f32`; every setter (`padding`, `padding_h`, `padding_v`, `padding_top`/`right`/`bottom`/`left`) now takes `impl Into<Length>`, so existing `f32` call sites keep compiling via the `From<f32>` shim. The values are resolved against the viewport in `Container::preferred_size` and in the container draw path (`draw/layout.rs`). `WrapGrid`'s `spacing_x`, `spacing_y` and `padding` get the same treatment, with a `resolved( canvas )` helper funnelling the per-frame resolution and `grid()` seeding `Length::px` defaults. Container tests now compare against `Length::px( … )`.
== Scrolling ==
`Scroll::preferred_size` is now axis-aware. A horizontal-only scroll reports its child's natural height rather than claiming all remaining vertical space, so it no longer steals Y from its siblings when it sits inside a `Column`; vertical and both-axis scrolls keep the spacer-like `( max_width, 0.0 )`. `Column`'s space-distribution correspondingly treats a `Scroll` as a vertical space-claimer only when its axis allows Y.
Disambiguate nested scroll viewports by direction. On press the gesture state now collects every scroll viewport under the point (`scroll_candidates`, innermost first) instead of committing to one; on the first 8 px of motion it locks onto the candidate whose axis matches the dominant direction (`scroll_locked`), so a horizontal scroller nested inside a vertical list no longer grabs the wrong axis. The pointer scroll hit test is aligned to the same innermost-first ordering.
== Theme palette ==
`themes/default/theme.json` gains named colours (green / green-deep, yellow, orange / orange-deep, pink / pink-soft, sky-deep, error / error-soft, neutral-tertiary) and new semantic slots in both light and dark modes: `danger`, `text-tertiary`, `accept`, `chip` / `chip-active` / `chip-active-fg`, and `avatar-1` … `avatar-9`.
== Cursors ==
Bundle GNOME's Adwaita cursor theme — the cursors GNOME Shell uses — into `themes/default/cursors/` so a Wayland compositor can draw consistent, complete pointers for ltk applications without the toolkit rasterising cursors itself and without depending on adwaita-icon-theme being installed on the target. The cursors are copied verbatim in XCursor binary format: 35 image files, one per CSS/freedesktop cursor name (default, text, pointer, *-resize, …), plus 27 customary X11 alias symlinks (arrow → default, hand2 → pointer, …); a sibling `cursor.theme` makes the tree a valid XCursor theme. The existing `ltk-theme-default.install` copies `themes/default` recursively, so the directory ships with no packaging change. Applications keep declaring a `CursorShape` per widget over `wp_cursor_shape_v1`; the compositor resolves it against the active theme's `cursors/` directory by name, and the set covers all 34 `CursorShape` variants.
Document the set in `themes/default/cursors/README.md` (what it is, the XCursor layout, the full shape list, how the compositor consumes it, guidance for forks) and `themes/default/cursors/LICENSE.md` (attribution and licence options, modelled on the icons catalogue LICENSE). `lib.rs` lists the cursors in its third-party-assets section.
Close out licensing in `debian/copyright`: a `Files: themes/default/cursors/*` paragraph records the upstream dual offer (CC-BY-SA-3.0 or LGPL-3, and CC-BY-SA-4.0 for the newer assets) attributed to the GNOME Project, with standalone CC-BY-SA-3.0, CC-BY-SA-4.0 and LGPL-3 paragraphs (summary-plus-canonical-URL for the CC licences, matching the existing CC-BY-4.0 entry; LGPL-3 referencing /usr/share/common-licenses/LGPL-3). The files are unmodified from upstream, so there is nothing to declare under the ShareAlike "indicate if changes were made" clause.
Add `tests/cursor_assets.rs`: every `CursorShape` name resolves to a valid XCursor file (Xcur magic, following symlinks), `cursor.theme` is present, no entry is a dangling symlink, and the expected-name list stays in sync with the enum's 34 variants.
This commit is contained in:
2026-05-28 23:11:14 +02:00
parent 3d8523533c
commit 9ca3b60f3a
78 changed files with 570 additions and 102 deletions

58
debian/copyright vendored
View File

@@ -62,6 +62,27 @@ Comment:
sibling `Sora-LICENSE.txt`. Upstream:
https://github.com/sora-xor/sora-font
Files: themes/default/cursors/*
Copyright: 2002-2014 The GNOME Project and the Adwaita icon theme authors
License: CC-BY-SA-3.0 or LGPL-3, and CC-BY-SA-4.0
Comment:
Pointer cursors under themes/default/cursors/ are GNOME's *Adwaita*
cursor theme — the cursors GNOME Shell ships — bundled verbatim from
the adwaita-icon-theme distribution so the default ltk theme is
self-contained (it does not depend on adwaita-icon-theme being
installed). The files are in XCursor binary format, one per CSS cursor
name (default, text, pointer, *-resize, …) plus the customary X11 alias
symlinks (arrow → default, hand2 → pointer, …); the sibling
cursor.theme makes the tree a valid XCursor theme. They are unmodified
from upstream. A Wayland compositor draws them in response to
wp_cursor_shape_v1, so ltk applications get them without bundling their
own. Upstream offers the artwork under the disjunction in the License
field; redistributing under any one of the options satisfies the
licence. Attribution shorthand: "Cursors by the GNOME Project". The
full functional and licence notes live in
themes/default/cursors/README.md and themes/default/cursors/LICENSE.md.
Upstream: https://gitlab.gnome.org/GNOME/adwaita-icon-theme
License: LGPL-2.1-only
This library is free software; you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as published by
@@ -196,3 +217,40 @@ License: CC-BY-4.0
The licence text above is a summary; the canonical legal text at the URL
is what governs use, distribution and modification of the Licensed
Material referenced under `Files: themes/default/icons/catalogue/*`.
License: CC-BY-SA-3.0
Creative Commons Attribution-ShareAlike 3.0 (CC BY-SA 3.0).
.
You are free to share and adapt the material for any purpose, even
commercially, provided you give appropriate credit, provide a link to
the licence, indicate if changes were made, and distribute your
contributions under the same licence as the original (ShareAlike).
.
The canonical legal text is at:
https://creativecommons.org/licenses/by-sa/3.0/legalcode
.
A human-readable summary (which is not a substitute for the licence) is
available at:
https://creativecommons.org/licenses/by-sa/3.0/
.
The summary above governs the Adwaita cursor files referenced under
`Files: themes/default/cursors/*` when distributed under the 3.0 option.
License: CC-BY-SA-4.0
Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0).
.
You are free to share and adapt the material for any purpose, even
commercially, under the same Attribution and ShareAlike terms as the
CC BY-SA 3.0 paragraph above.
.
The canonical legal text is at:
https://creativecommons.org/licenses/by-sa/4.0/legalcode
.
A human-readable summary (which is not a substitute for the licence) is
available at:
https://creativecommons.org/licenses/by-sa/4.0/
License: LGPL-3
The Adwaita cursors are alternatively available under the GNU Lesser
General Public License version 3. On Debian systems the complete text
of the LGPL version 3 is in `/usr/share/common-licenses/LGPL-3`.

View File

@@ -195,12 +195,18 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
{
canvas.stroke_rect( rect, color, width, c.corners );
}
let vp = canvas.viewport_logical();
let em = crate::types::Length::EM_BASE_DEFAULT;
let pad_l = c.pad_left.resolve( vp, em );
let pad_r = c.pad_right.resolve( vp, em );
let pad_t = c.pad_top.resolve( vp, em );
let pad_b = c.pad_bottom.resolve( vp, em );
let inner = crate::types::Rect
{
x: rect.x + c.pad_left,
y: rect.y + c.pad_top,
width: ( rect.width - c.pad_left - c.pad_right ).max( 0.0 ),
height: ( rect.height - c.pad_top - c.pad_bottom ).max( 0.0 ),
x: rect.x + pad_l,
y: rect.y + pad_t,
width: ( rect.width - pad_l - pad_r ).max( 0.0 ),
height: ( rect.height - pad_t - pad_b ).max( 0.0 ),
};
let result = layout_and_draw::<Msg>( c.child.as_ref(), canvas, inner, ctx, flat_idx );

View File

@@ -68,6 +68,10 @@ pub struct GestureState<Msg: Clone>
/// Index of the Scroll viewport that owns the current gesture, if
/// the press landed inside one.
pub scrolling_widget: Option<( usize, crate::widget::scroll::ScrollAxis )>,
/// Scroll viewports containing the press, innermost first.
pub scroll_candidates: Vec<( usize, crate::widget::scroll::ScrollAxis )>,
/// `true` once the first 8 px of motion has pinned `scrolling_widget` to a definite axis.
pub scroll_locked: bool,
/// Scroll-viewport drag exceeded the 8 px start tolerance — the
/// release will be consumed as a scroll instead of a tap.
pub scroll_drag_started: bool,
@@ -136,6 +140,8 @@ impl<Msg: Clone> GestureState<Msg>
start: None,
pressed_idx: None,
scrolling_widget: None,
scroll_candidates: Vec::new(),
scroll_locked: false,
scroll_drag_started: false,
horizontal_drag_started: false,
vertical_drag_started: false,
@@ -175,9 +181,12 @@ impl<Msg: Clone> GestureState<Msg>
}).unwrap_or( ( None, None ) );
self.start = Some( pos );
self.scrolling_widget = scroll_rects.iter().rev()
.find( |( r, _, _ )| r.contains( pos ) )
.map( |( _, idx, ax )| ( *idx, *ax ) );
self.scroll_candidates = scroll_rects.iter()
.filter( |( r, _, _ )| r.contains( pos ) )
.map( |( _, idx, ax )| ( *idx, *ax ) )
.collect();
self.scrolling_widget = self.scroll_candidates.first().copied();
self.scroll_locked = false;
self.scroll_drag_started = false;
self.horizontal_drag_started = false;
self.vertical_drag_started = false;
@@ -289,19 +298,30 @@ impl<Msg: Clone> GestureState<Msg>
return MoveOutcome::Idle;
}
// Scroll viewport drag: mutate offset in place and advance the
// gesture origin so the next delta is frame-to-frame, not
// press-to-now (otherwise the first 8 px trip the threshold
// and the entire scroll gets absorbed into one delta). Both
// axes are routed independently; an axis the viewport does not
// allow is just ignored (delta still consumed by the gesture,
// so the swipe handler does not fight the scroll for it).
if let Some( ( scroll_idx, axis ) ) = self.scrolling_widget
if self.scrolling_widget.is_some()
{
if let Some( start ) = self.start
{
let dx = pos.x - start.x;
let dy = pos.y - start.y;
if !self.scroll_locked
{
if dx.abs() <= 8.0 && dy.abs() <= 8.0
{
return MoveOutcome::Idle;
}
let prefer_x = dx.abs() > dy.abs();
if let Some( c ) = self.scroll_candidates.iter()
.find( |( _, ax )| if prefer_x { ax.allows_x() } else { ax.allows_y() } )
.copied()
{
self.scrolling_widget = Some( c );
}
self.scroll_locked = true;
}
let ( scroll_idx, axis ) = self.scrolling_widget.unwrap();
let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); }
if axis.allows_y() { entry.1 = ( entry.1 - dy ).max( 0.0 ); }

View File

@@ -33,7 +33,7 @@ impl<A: App> AppData<A>
let scroll_hit =
{
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
ss.scroll_rects.iter()
.find( |( r, _, _ )| r.contains( pos ) )
.map( |( _, idx, ax )| ( *idx, *ax ) )
};

View File

@@ -224,13 +224,11 @@ impl<Msg: Clone> Column<Msg>
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.resolved_height( canvas ).is_none() => s.weight,
Element::Scroll( _ ) => 1,
Element::Scroll( s ) if s.axis.allows_y() => 1,
_ => 0,
} )
.sum();
@@ -238,7 +236,7 @@ impl<Msg: Clone> Column<Msg>
let fixed_h: f32 = self.children.iter()
.map( |c|
{
if matches!( c, Element::Scroll( _ ) )
if matches!( c, Element::Scroll( s ) if s.axis.allows_y() )
{
0.0
} else if let Element::Spacer( s ) = c {
@@ -282,7 +280,7 @@ impl<Msg: Clone> Column<Msg>
};
( inner_w, h )
},
Element::Scroll( _ ) =>
Element::Scroll( s ) if s.axis.allows_y() =>
{
let h = if total_weight > 0
{

View File

@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::Rect;
use crate::types::{ Length, Rect };
use crate::widget::Element;
/// A grid layout that wraps children into rows of a fixed column count.
@@ -35,12 +35,12 @@ pub struct WrapGrid<Msg: Clone>
pub children: Vec<Element<Msg>>,
/// Number of columns per row.
pub columns: usize,
/// Horizontal gap between cells (pixels).
pub spacing_x: f32,
/// Vertical gap between rows (pixels).
pub spacing_y: f32,
/// Padding on all sides (pixels).
pub padding: f32,
/// Horizontal gap between cells.
pub spacing_x: Length,
/// Vertical gap between rows.
pub spacing_y: Length,
/// Padding on all sides.
pub padding: Length,
/// When `true`, a partial last row is centred horizontally within
/// the grid's content rect instead of being left-aligned.
pub centre_last_row: bool,
@@ -56,31 +56,32 @@ impl<Msg: Clone> WrapGrid<Msg>
}
/// Set both horizontal and vertical gap between cells (default 8.0).
pub fn spacing( mut self, s: f32 ) -> Self
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
{
let s = s.into();
self.spacing_x = s;
self.spacing_y = s;
self
}
/// Set only the horizontal gap between cells; leaves vertical spacing untouched.
pub fn spacing_x( mut self, s: f32 ) -> Self
pub fn spacing_x( mut self, s: impl Into<Length> ) -> Self
{
self.spacing_x = s;
self.spacing_x = s.into();
self
}
/// Set only the vertical gap between rows; leaves horizontal spacing untouched.
pub fn spacing_y( mut self, s: f32 ) -> Self
pub fn spacing_y( mut self, s: impl Into<Length> ) -> Self
{
self.spacing_y = s;
self.spacing_y = s.into();
self
}
/// Set the padding on all sides (default 0.0).
pub fn padding( mut self, p: f32 ) -> Self
pub fn padding( mut self, p: impl Into<Length> ) -> Self
{
self.padding = p;
self.padding = p.into();
self
}
@@ -92,6 +93,17 @@ impl<Msg: Clone> WrapGrid<Msg>
self
}
fn resolved( &self, canvas: &Canvas ) -> ( f32, f32, f32 )
{
let vp = canvas.viewport_logical();
let em = Length::EM_BASE_DEFAULT;
(
self.spacing_x.resolve( vp, em ),
self.spacing_y.resolve( vp, em ),
self.padding.resolve( vp, em ),
)
}
/// Compute the preferred size given an available width.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
@@ -99,12 +111,13 @@ impl<Msg: Clone> WrapGrid<Msg>
{
return ( max_width, 0.0 );
}
let ( sx, sy, pad ) = self.resolved( canvas );
let cols = self.columns;
let inner_w = (max_width - self.padding * 2.0).max( 0.0 );
let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let inner_w = (max_width - pad * 2.0).max( 0.0 );
let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let row_count = (self.children.len() + cols - 1) / cols;
let mut total_h = self.padding * 2.0;
let mut total_h = pad * 2.0;
for row in 0..row_count
{
let start = row * cols;
@@ -114,7 +127,7 @@ impl<Msg: Clone> WrapGrid<Msg>
.map( |c| c.preferred_size( cell_w, canvas ).1 )
.fold( 0.0_f32, f32::max );
total_h += row_h;
if row + 1 < row_count { total_h += self.spacing_y; }
if row + 1 < row_count { total_h += sy; }
}
( max_width, total_h )
}
@@ -126,11 +139,12 @@ impl<Msg: Clone> WrapGrid<Msg>
{
return Vec::new();
}
let ( sx, sy, pad ) = self.resolved( canvas );
let cols = self.columns;
let inner_w = (rect.width - self.padding * 2.0).max( 0.0 );
let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let x0 = rect.x + self.padding;
let mut y = rect.y + self.padding;
let inner_w = (rect.width - pad * 2.0).max( 0.0 );
let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let x0 = rect.x + pad;
let mut y = rect.y + pad;
let row_count = (self.children.len() + cols - 1) / cols;
let mut out = Vec::with_capacity( self.children.len() );
@@ -148,16 +162,16 @@ impl<Msg: Clone> WrapGrid<Msg>
let row_offset = if self.centre_last_row && items_in_row < cols
{
let missing = (cols - items_in_row) as f32;
missing * (cell_w + self.spacing_x) / 2.0
missing * (cell_w + sx) / 2.0
} else { 0.0 };
for col in 0..items_in_row
{
let x = x0 + row_offset + col as f32 * (cell_w + self.spacing_x);
let x = x0 + row_offset + col as f32 * (cell_w + sx);
let crect = Rect { x, y, width: cell_w, height: row_h };
out.push( ( crect, start + col ) );
}
y += row_h + self.spacing_y;
y += row_h + sy;
}
out
}
@@ -396,9 +410,9 @@ pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
{
children: Vec::new(),
columns,
spacing_x: 8.0,
spacing_y: 8.0,
padding: 0.0,
spacing_x: Length::px( 8.0 ),
spacing_y: Length::px( 8.0 ),
padding: Length::px( 0.0 ),
centre_last_row: false,
}
}

View File

@@ -159,6 +159,16 @@
//! - **Sora Regular** (`src/theme/fallback/Sora-Regular.otf`) — the
//! embedded font fallback, [SIL OFL 1.1](https://scripts.sil.org/OFL),
//! © The Sora Project Authors, Jonathan Barnbrook, Julián Moncada.
//! - **Pointer cursors** under `themes/default/cursors/` — GNOME's
//! *Adwaita* cursor theme (the cursors GNOME Shell ships), bundled
//! verbatim, © the GNOME Project. Offered upstream under
//! [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) *or*
//! [LGPL 3](https://www.gnu.org/licenses/lgpl-3.0.html), and
//! [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) for
//! the newer assets; any one option satisfies the licence. See
//! `themes/default/cursors/README.md` (what the set is and how it is
//! used) and `themes/default/cursors/LICENSE.md` (attribution).
//! Upstream: <https://gitlab.gnome.org/GNOME/adwaita-icon-theme>.
//!
//! The remaining artwork in the default theme (wallpapers, lockscreens,
//! launcher logo, brand-mark variants, per-application icons) is
@@ -230,6 +240,7 @@ pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
pub use render::is_software_render;
pub use wallpaper::{ WallpaperBundle, ImageData };
pub use types::{ Color, Corners, CursorShape, Length, LengthBase, Point, Rect, Size, WidgetId };
pub use types::{ design_reference, set_design_reference };
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 };

View File

@@ -24,6 +24,8 @@
//! `ltk::Rect`, …) so application code rarely needs the `ltk::types::`
//! prefix.
use std::sync::atomic::{ AtomicU32, Ordering };
/// An RGBA color with floating-point channels in the range `[0.0, 1.0]`.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct Color
@@ -524,6 +526,17 @@ impl Length
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 ) ) }
/// "Design pixel": `px` interpreted at the reference vmin set via
/// [`set_design_reference`] (defaults to 412 px — the eydos mobile
/// reference width). The result is a `Vmin` value clamped to
/// `[px * 0.7, px * 1.5]`, so the layout scales with the screen
/// without collapsing on tiny surfaces or ballooning on 4K.
pub fn dp( px: f32 ) -> Self
{
let r = design_reference();
Length::vmin( px / r * 100.0 ).clamp( px * 0.7, px * 1.5 )
}
/// 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
@@ -573,6 +586,23 @@ impl Length
}
}
static DESIGN_REFERENCE_BITS: AtomicU32 = AtomicU32::new( 412.0_f32.to_bits() );
/// Set the reference vmin width that [`Length::dp`] interprets `px` against.
/// Call once at startup (e.g. before [`crate::run`]) to align the design
/// scale to the surface mock-up the app was designed for.
pub fn set_design_reference( reference_vmin: f32 )
{
DESIGN_REFERENCE_BITS.store( reference_vmin.to_bits(), Ordering::Relaxed );
}
/// Current value used by [`Length::dp`] — the px width at which `dp(n)`
/// resolves to `n` logical pixels.
pub fn design_reference() -> f32
{
f32::from_bits( DESIGN_REFERENCE_BITS.load( Ordering::Relaxed ) )
}
impl From<f32> for Length
{
fn from( v: f32 ) -> Self { Length::px( v ) }

View File

@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::theme::Paint;
use crate::types::{ Color, Corners };
use crate::types::{ Color, Corners, Length };
use crate::render::Canvas;
use super::Element;
@@ -72,15 +72,16 @@ pub struct Container<Msg: Clone>
/// left edge, …) without hitting the renderer with an offset
/// trick.
pub corners: Corners,
/// Padding on the top edge in logical px — gap between the
/// container's top boundary and its child.
pub pad_top: f32,
/// Padding on the right edge in logical px.
pub pad_right: f32,
/// Padding on the bottom edge in logical px.
pub pad_bottom: f32,
/// Padding on the left edge in logical px.
pub pad_left: f32,
/// Padding on the top edge — gap between the container's top boundary
/// and its child. Stored as a [`Length`] so it can scale with the
/// viewport via [`Length::dp`] / [`Length::vmin`].
pub pad_top: Length,
/// Padding on the right edge.
pub pad_right: Length,
/// Padding on the bottom edge.
pub pad_bottom: Length,
/// Padding on the left edge.
pub pad_left: Length,
pub opacity: f32,
/// Optional `( color, width_px )` border stroke painted around the
/// container's rounded rectangle, after the fill / surface and
@@ -109,10 +110,10 @@ impl<Msg: Clone> Container<Msg>
background: None,
surface: None,
corners: Corners::ZERO,
pad_top: 0.0,
pad_right: 0.0,
pad_bottom: 0.0,
pad_left: 0.0,
pad_top: Length::px( 0.0 ),
pad_right: Length::px( 0.0 ),
pad_bottom: Length::px( 0.0 ),
pad_left: Length::px( 0.0 ),
opacity: 1.0,
border: None,
max_width: None,
@@ -197,8 +198,9 @@ impl<Msg: Clone> Container<Msg>
/// edges, so calling this first and then a per-edge setter is the
/// idiomatic way to express "uniform padding except for one
/// edge".
pub fn padding( mut self, p: f32 ) -> Self
pub fn padding( mut self, p: impl Into<Length> ) -> Self
{
let p = p.into();
self.pad_top = p;
self.pad_right = p;
self.pad_bottom = p;
@@ -207,16 +209,18 @@ impl<Msg: Clone> Container<Msg>
}
/// Set horizontal padding (left + right each).
pub fn padding_h( mut self, p: f32 ) -> Self
pub fn padding_h( mut self, p: impl Into<Length> ) -> Self
{
let p = p.into();
self.pad_left = p;
self.pad_right = p;
self
}
/// Set vertical padding (top + bottom each).
pub fn padding_v( mut self, p: f32 ) -> Self
pub fn padding_v( mut self, p: impl Into<Length> ) -> Self
{
let p = p.into();
self.pad_top = p;
self.pad_bottom = p;
self
@@ -225,30 +229,30 @@ impl<Msg: Clone> Container<Msg>
/// Set the top edge padding only. Pairs with
/// [`padding_bottom`](Self::padding_bottom) for asymmetric
/// vertical insets.
pub fn padding_top( mut self, p: f32 ) -> Self
pub fn padding_top( mut self, p: impl Into<Length> ) -> Self
{
self.pad_top = p;
self.pad_top = p.into();
self
}
/// Set the right edge padding only.
pub fn padding_right( mut self, p: f32 ) -> Self
pub fn padding_right( mut self, p: impl Into<Length> ) -> Self
{
self.pad_right = p;
self.pad_right = p.into();
self
}
/// Set the bottom edge padding only.
pub fn padding_bottom( mut self, p: f32 ) -> Self
pub fn padding_bottom( mut self, p: impl Into<Length> ) -> Self
{
self.pad_bottom = p;
self.pad_bottom = p.into();
self
}
/// Set the left edge padding only.
pub fn padding_left( mut self, p: f32 ) -> Self
pub fn padding_left( mut self, p: impl Into<Length> ) -> Self
{
self.pad_left = p;
self.pad_left = p.into();
self
}
@@ -271,9 +275,15 @@ impl<Msg: Clone> Container<Msg>
/// Return the preferred `(width, height)` accounting for padding.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let vp = canvas.viewport_logical();
let em = Length::EM_BASE_DEFAULT;
let pad_l = self.pad_left.resolve( vp, em );
let pad_r = self.pad_right.resolve( vp, em );
let pad_t = self.pad_top.resolve( vp, em );
let pad_b = self.pad_bottom.resolve( vp, em );
let avail = self.max_width.map( |m| max_width.min( m ) ).unwrap_or( max_width );
let pad_x = self.pad_left + self.pad_right;
let pad_y = self.pad_top + self.pad_bottom;
let pad_x = pad_l + pad_r;
let pad_y = pad_t + pad_b;
let inner_w = ( avail - pad_x ).max( 0.0 );
let ( cw, ch ) = self.child.preferred_size( inner_w, canvas );
( cw + pad_x, ch + pad_y )

View File

@@ -3,6 +3,7 @@
use super::*;
use crate::layout::spacer::spacer;
use crate::types::Length;
#[ test ]
fn default_no_background()
@@ -15,30 +16,30 @@ fn default_no_background()
fn padding_sets_all_four_sides()
{
let c = container::<()>( spacer() ).padding( 10.0 );
assert_eq!( c.pad_top, 10.0 );
assert_eq!( c.pad_right, 10.0 );
assert_eq!( c.pad_bottom, 10.0 );
assert_eq!( c.pad_left, 10.0 );
assert_eq!( c.pad_top, Length::px( 10.0 ) );
assert_eq!( c.pad_right, Length::px( 10.0 ) );
assert_eq!( c.pad_bottom, Length::px( 10.0 ) );
assert_eq!( c.pad_left, Length::px( 10.0 ) );
}
#[ test ]
fn padding_h_only_touches_left_and_right()
{
let c = container::<()>( spacer() ).padding_h( 8.0 );
assert_eq!( c.pad_left, 8.0 );
assert_eq!( c.pad_right, 8.0 );
assert_eq!( c.pad_top, 0.0 );
assert_eq!( c.pad_bottom, 0.0 );
assert_eq!( c.pad_left, Length::px( 8.0 ) );
assert_eq!( c.pad_right, Length::px( 8.0 ) );
assert_eq!( c.pad_top, Length::px( 0.0 ) );
assert_eq!( c.pad_bottom, Length::px( 0.0 ) );
}
#[ test ]
fn padding_v_only_touches_top_and_bottom()
{
let c = container::<()>( spacer() ).padding_v( 6.0 );
assert_eq!( c.pad_top, 6.0 );
assert_eq!( c.pad_bottom, 6.0 );
assert_eq!( c.pad_left, 0.0 );
assert_eq!( c.pad_right, 0.0 );
assert_eq!( c.pad_top, Length::px( 6.0 ) );
assert_eq!( c.pad_bottom, Length::px( 6.0 ) );
assert_eq!( c.pad_left, Length::px( 0.0 ) );
assert_eq!( c.pad_right, Length::px( 0.0 ) );
}
#[ test ]
@@ -48,10 +49,10 @@ fn per_edge_overrides_uniform_padding()
let c = container::<()>( spacer() )
.padding( 12.0 )
.padding_bottom( 22.0 );
assert_eq!( c.pad_top, 12.0 );
assert_eq!( c.pad_right, 12.0 );
assert_eq!( c.pad_bottom, 22.0 );
assert_eq!( c.pad_left, 12.0 );
assert_eq!( c.pad_top, Length::px( 12.0 ) );
assert_eq!( c.pad_right, Length::px( 12.0 ) );
assert_eq!( c.pad_bottom, Length::px( 22.0 ) );
assert_eq!( c.pad_left, Length::px( 12.0 ) );
}
#[ test ]

View File

@@ -93,13 +93,27 @@ impl<Msg: Clone> Scroll<Msg>
self
}
/// Returns `(max_width, 0.0)` — the Scroll node claims all remaining space in
/// the parent layout, exactly like a [`Spacer`](crate::layout::spacer::Spacer).
/// The actual viewport size is determined at render time from the rect the
/// parent assigns.
pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
/// Preferred size — axis-aware.
///
/// - **Vertical or both**: returns `(max_width, 0.0)`. The Scroll node
/// claims all remaining space in the parent layout, exactly like a
/// [`Spacer`](crate::layout::spacer::Spacer). The actual viewport size
/// is determined at render time from the rect the parent assigns.
/// - **Horizontal-only**: claims `max_width` but reports the child's
/// natural height. A horizontal scroll has an intrinsic height (the
/// row of items it clips) and must not steal Y space from its siblings
/// when it sits inside a [`Column`](crate::layout::column::Column).
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
( max_width, 0.0 )
match self.axis
{
ScrollAxis::Horizontal =>
{
let ( _, h ) = self.child.preferred_size( max_width, canvas );
( max_width, h )
}
ScrollAxis::Vertical | ScrollAxis::Both => ( max_width, 0.0 ),
}
}
/// No-op — rendering is handled entirely by `layout_and_draw` in `draw.rs`.

105
tests/cursor_assets.rs Normal file
View File

@@ -0,0 +1,105 @@
use std::io::Read;
use std::path::{ Path, PathBuf };
/// Every shape `ltk::CursorShape` can request, by its CSS / freedesktop
/// cursor name. The default theme must ship an XCursor file (or alias
/// symlink) for each so the compositor never has to fall back. Keep in
/// sync with the `CursorShape` enum in `src/types.rs`.
const CURSOR_NAMES: &[ &str ] =
&[
"default", "context-menu", "help", "pointer", "progress", "wait",
"cell", "crosshair", "text", "vertical-text", "alias", "copy", "move",
"no-drop", "not-allowed", "grab", "grabbing", "e-resize", "n-resize",
"ne-resize", "nw-resize", "s-resize", "se-resize", "sw-resize",
"w-resize", "ew-resize", "ns-resize", "nesw-resize", "nwse-resize",
"col-resize", "row-resize", "all-scroll", "zoom-in", "zoom-out",
];
fn theme_dir() -> PathBuf
{
PathBuf::from( env!( "CARGO_MANIFEST_DIR" ) ).join( "themes/default" )
}
fn cursors_dir() -> PathBuf
{
theme_dir().join( "cursors" )
}
/// True if `path` (following symlinks) is a binary XCursor file, i.e. it
/// opens and starts with the four magic bytes `Xcur`.
fn is_xcursor( path: &Path ) -> bool
{
let mut file = match std::fs::File::open( path )
{
Ok( f ) => f,
Err( _ ) => return false,
};
let mut magic = [ 0u8; 4 ];
file.read_exact( &mut magic ).is_ok() && &magic == b"Xcur"
}
#[ test ]
fn cursor_theme_manifest_present()
{
let manifest = theme_dir().join( "cursor.theme" );
let body = std::fs::read_to_string( &manifest )
.unwrap_or_else( |e| panic!( "reading {}: {e}", manifest.display() ) );
assert!
(
body.contains( "[Icon Theme]" ),
"cursor.theme is missing the [Icon Theme] header",
);
}
#[ test ]
fn every_cursor_shape_has_a_valid_xcursor_file()
{
let dir = cursors_dir();
let missing: Vec<&str> = CURSOR_NAMES
.iter()
.copied()
.filter( |name| !is_xcursor( &dir.join( name ) ) )
.collect();
assert!
(
missing.is_empty(),
"default theme has no valid XCursor file for: {missing:?}",
);
}
/// Packaging (`dh_install`) can in principle drop or break the alias
/// symlinks; a dangling link would make a cursor name unresolvable at
/// runtime. Every entry must resolve to an existing file.
#[ test ]
fn no_dangling_entries()
{
let dir = cursors_dir();
let entries = std::fs::read_dir( &dir )
.unwrap_or_else( |e| panic!( "reading {}: {e}", dir.display() ) );
let mut dangling = Vec::new();
for entry in entries
{
let path = entry.expect( "dir entry" ).path();
// `metadata` follows symlinks, so a broken link is an Err here.
if std::fs::metadata( &path ).is_err()
{
dangling.push( path );
}
}
assert!( dangling.is_empty(), "dangling cursor entries: {dangling:?}" );
}
/// Guard against the name list above drifting from the enum it mirrors.
#[ test ]
fn cursor_name_list_matches_shape_count()
{
assert_eq!
(
CURSOR_NAMES.len(),
34,
"CURSOR_NAMES is out of sync with ltk::CursorShape (34 variants)",
);
}

View File

@@ -0,0 +1,3 @@
[Icon Theme]
Name=Default
Comment=Liberux default cursor theme

View File

@@ -0,0 +1,57 @@
# Cursor theme licence
The pointer cursors in this directory are GNOME's **Adwaita** cursor
theme — the cursors GNOME Shell ships — bundled verbatim into the ltk
default theme. They are distributed by upstream under a choice of
licences:
- **Author:** the GNOME Project (Adwaita icon theme authors, 20022014)
- **Source:** <https://gitlab.gnome.org/GNOME/adwaita-icon-theme>
- **Licence:** **CC BY-SA 3.0 _or_ LGPL 3**, and CC BY-SA 4.0 for the
newer assets — see below.
The licence is a disjunction: redistributing the cursors under **any
one** of the offered options satisfies it. ltk passes the choice
through unchanged.
- CC BY-SA 3.0 — <https://creativecommons.org/licenses/by-sa/3.0/>
- CC BY-SA 4.0 — <https://creativecommons.org/licenses/by-sa/4.0/>
- LGPL 3 — <https://www.gnu.org/licenses/lgpl-3.0.html>
## Required attribution
When redistributing this directory (or a binary that embeds these
cursor files), credit must be given to the GNOME Project and a link to
the chosen licence must be reachable. The wording is flexible — any of
the following satisfies the obligation:
> Cursors by the [GNOME Project](https://www.gnome.org), Adwaita cursor
> theme, CC BY-SA 3.0 / LGPL 3.
> Pointer cursors: © the GNOME Project, Adwaita, distributed under
> CC BY-SA 3.0 (https://creativecommons.org/licenses/by-sa/3.0/).
Upstream notes that, for attribution, "using 'GNOME Project' is enough".
The full Debian-style attribution lives in the top-level
[`debian/copyright`](../../../debian/copyright) of the `ltk` source
tree under the `Files: themes/default/cursors/*` paragraph.
## Modifications
The cursor files in this directory are **unmodified** copies of the
upstream Adwaita XCursor files (the alias symlinks are part of the
upstream theme). No content changes have been made, so there is nothing
to declare under the ShareAlike "indicate if changes were made" clause.
Anyone needing a pristine copy can also fetch it from the upstream
`adwaita-icon-theme` release.
## How licensing applies to the rest of the theme
The rest of the default theme is licensed separately: branding artwork,
app icons and the unknown-app fallback are original Liberux Labs work
under `LGPL-2.1-only`, and the catalogue glyphs under
`themes/default/icons/catalogue/` are Streamline's under CC BY 4.0 (see
that directory's `LICENSE.md`). Forks should preserve the split:
re-license your own artwork freely, but the contents of this `cursors/`
directory remain the GNOME Project's under the terms above.

View File

@@ -0,0 +1,73 @@
# Default theme cursors
This directory holds the pointer cursors for the ltk **default** theme.
They are GNOME's **Adwaita** cursors (what GNOME Shell uses), bundled
verbatim so the theme is self-contained and does not depend on
`adwaita-icon-theme` being installed on the target system.
Licence and attribution are documented separately in
[`LICENSE.md`](./LICENSE.md).
## Layout
Standard XCursor theme layout:
```
themes/default/
├── cursor.theme # XCursor theme manifest (Name=Default)
└── cursors/
├── default # one binary XCursor file per CSS cursor name
├── text
├── pointer
├── ew-resize
├── …
├── arrow -> default # customary X11 alias symlinks
└── hand2 -> pointer
```
- Each regular file is a binary XCursor image set (magic `Xcur`),
carrying one or more nominal sizes and, for the busy cursors, several
animation frames.
- The file **name is the CSS / freedesktop cursor name** (`default`,
`text`, `pointer`, `not-allowed`, `ns-resize`, …), which is exactly
the name a `wp_cursor_shape_v1` shape resolves to. The alias symlinks
(`arrow`, `hand2`, `bottom_left_corner`, …) are the legacy X11 names;
they are not required by the cursor-shape protocol but are kept so the
tree also works as a plain XCursor theme (e.g. for XWayland).
## Cursor set
The theme provides a file for every shape ltk can request — the 34
variants of [`ltk::CursorShape`](../../../src/types.rs), which mirror
`cursor_icon::CursorIcon` 1:1:
`default`, `context-menu`, `help`, `pointer`, `progress`, `wait`,
`cell`, `crosshair`, `text`, `vertical-text`, `alias`, `copy`, `move`,
`no-drop`, `not-allowed`, `grab`, `grabbing`, `e-resize`, `n-resize`,
`ne-resize`, `nw-resize`, `s-resize`, `se-resize`, `sw-resize`,
`w-resize`, `ew-resize`, `ns-resize`, `nesw-resize`, `nwse-resize`,
`col-resize`, `row-resize`, `all-scroll`, `zoom-in`, `zoom-out`.
`wait` and `progress` are animated (multiple frames with per-frame
delays). The `tests/cursor_assets.rs` integration test enforces that
every name above resolves to a valid XCursor file.
## How they are used
ltk does **not** rasterise cursors itself; as a Wayland client it
declares a shape per widget through `wp_cursor_shape_v1` and the
compositor draws it. The Liberux compositor (forge) resolves the
requested shape against the active theme's `cursors/` directory by CSS
name, picks the image whose nominal size is closest to `24px × output
scale`, and uploads it at the cursor's hotspot — so these files are what
the user actually sees under forge. A compositor that does not advertise
`wp_cursor_shape_v1`, or that uses a different cursor theme, will ignore
this directory.
## Forking / overriding
To ship different cursors in a derived theme, drop replacement XCursor
files (same CSS names) into the fork's `cursors/` directory. Keep
`cursor.theme` so the tree stays a valid XCursor theme, and update the
`Files: themes/default/cursors/*` paragraph in `debian/copyright` plus
this directory's `LICENSE.md` to match the new source and licence.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
default

View File

@@ -0,0 +1 @@
nwse-resize

View File

@@ -0,0 +1 @@
sw-resize

View File

@@ -0,0 +1 @@
se-resize

View File

@@ -0,0 +1 @@
s-resize

BIN
themes/default/cursors/cell Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
themes/default/cursors/copy Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
crosshair

View File

@@ -0,0 +1 @@
crosshair

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
crosshair

View File

@@ -0,0 +1 @@
default

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
nesw-resize

View File

@@ -0,0 +1 @@
all-resize

BIN
themes/default/cursors/grab Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
grab

View File

@@ -0,0 +1 @@
pointer

BIN
themes/default/cursors/help Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
default

View File

@@ -0,0 +1 @@
w-resize

1
themes/default/cursors/move Symbolic link
View File

@@ -0,0 +1 @@
default

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
help

View File

@@ -0,0 +1 @@
e-resize

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
ew-resize

View File

@@ -0,0 +1 @@
ns-resize

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
crosshair

BIN
themes/default/cursors/text Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
default

View File

@@ -0,0 +1 @@
nw-resize

View File

@@ -0,0 +1 @@
ne-resize

View File

@@ -0,0 +1 @@
n-resize

Binary file not shown.

Binary file not shown.

BIN
themes/default/cursors/wait Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
wait

View File

@@ -0,0 +1 @@
text

Binary file not shown.

Binary file not shown.

View File

@@ -37,7 +37,18 @@
"glass-elev": "#212121",
"ink": "#000000",
"cyan-deep": "#0091AD",
"teal-fill-end": "#00687C"
"teal-fill-end": "#00687C",
"green": "#00D344",
"green-deep": "#00B41A",
"yellow": "#FFD400",
"orange": "#FF8156",
"orange-deep": "#BD6403",
"pink": "#EF15FA",
"pink-soft": "#FFD7FF",
"sky-deep": "#1E8FD9",
"error": "#F00013",
"error-soft": "#FF6B6B",
"neutral-tertiary": "#807E88"
},
"gradients": {
@@ -111,6 +122,21 @@
"accent": { "type": "color", "value": "@cyan", "meta": { "semantic": "palette/accent" } },
"divider": { "type": "color", "value": "@navy/14", "meta": { "semantic": "palette/divider" } },
"icon": { "type": "color", "value": "@navy", "meta": { "semantic": "palette/icon" } },
"danger": { "type": "color", "value": "@error", "meta": { "semantic": "palette/danger" } },
"text-tertiary": { "type": "color", "value": "@neutral-tertiary", "meta": { "semantic": "palette/text_tertiary" } },
"accept": { "type": "color", "value": "@green-deep", "meta": { "semantic": "status/accept" } },
"chip": { "type": "color", "value": "@off-white", "meta": { "semantic": "palette/chip" } },
"chip-active": { "type": "color", "value": "@navy", "meta": { "semantic": "palette/chip_active" } },
"chip-active-fg": { "type": "color", "value": "@white", "meta": { "semantic": "palette/chip_active_fg" } },
"avatar-1": { "type": "color", "value": "@sky", "meta": { "semantic": "avatar/1" } },
"avatar-2": { "type": "color", "value": "@green", "meta": { "semantic": "avatar/2" } },
"avatar-3": { "type": "color", "value": "@orange", "meta": { "semantic": "avatar/3" } },
"avatar-4": { "type": "color", "value": "@pink", "meta": { "semantic": "avatar/4" } },
"avatar-5": { "type": "color", "value": "@yellow", "meta": { "semantic": "avatar/5" } },
"avatar-6": { "type": "color", "value": "@sky-deep", "meta": { "semantic": "avatar/6" } },
"avatar-7": { "type": "color", "value": "@cyan", "meta": { "semantic": "avatar/7" } },
"avatar-8": { "type": "color", "value": "@green-deep", "meta": { "semantic": "avatar/8" } },
"avatar-9": { "type": "color", "value": "@orange-deep", "meta": { "semantic": "avatar/9" } },
"shadows-glass": {
"type": "shadows",
@@ -225,6 +251,21 @@
"accent": { "type": "color", "value": "@cyan", "meta": { "semantic": "palette/accent" } },
"divider": { "type": "color", "value": "@white/14", "meta": { "semantic": "palette/divider" } },
"icon": { "type": "color", "value": "@white", "meta": { "semantic": "palette/icon" } },
"danger": { "type": "color", "value": "@error-soft", "meta": { "semantic": "palette/danger" } },
"text-tertiary": { "type": "color", "value": "@white/80", "meta": { "semantic": "palette/text_tertiary" } },
"accept": { "type": "color", "value": "@green", "meta": { "semantic": "status/accept" } },
"chip": { "type": "color", "value": "@white/14", "meta": { "semantic": "palette/chip" } },
"chip-active": { "type": "color", "value": "@cyan", "meta": { "semantic": "palette/chip_active" } },
"chip-active-fg": { "type": "color", "value": "@navy", "meta": { "semantic": "palette/chip_active_fg" } },
"avatar-1": { "type": "color", "value": "@sky", "meta": { "semantic": "avatar/1" } },
"avatar-2": { "type": "color", "value": "@green", "meta": { "semantic": "avatar/2" } },
"avatar-3": { "type": "color", "value": "@orange", "meta": { "semantic": "avatar/3" } },
"avatar-4": { "type": "color", "value": "@pink", "meta": { "semantic": "avatar/4" } },
"avatar-5": { "type": "color", "value": "@yellow", "meta": { "semantic": "avatar/5" } },
"avatar-6": { "type": "color", "value": "@sky-deep", "meta": { "semantic": "avatar/6" } },
"avatar-7": { "type": "color", "value": "@cyan", "meta": { "semantic": "avatar/7" } },
"avatar-8": { "type": "color", "value": "@green-deep", "meta": { "semantic": "avatar/8" } },
"avatar-9": { "type": "color", "value": "@orange-deep", "meta": { "semantic": "avatar/9" } },
"shadows-glass": {
"type": "shadows",