TabStrip, the open documents of an application as a browser or a terminal shows them, with tabs that grow, shrink and can be dragged, an overview of cards for a phone, a widget anchored under another one and list items in colours of their own
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

Trust and prompt each drew their own strip of tabs, their own phone bar with a counter and their own grid of cards, and neither animated anything nor let a tab move. `tab_strip` does all of it once, in colours the application chooses, so the same widget serves a black terminal and a browser that follows the theme. Two things the menus of those applications needed come with it: a way to place a menu under the button that opens it, and list items that are not drawn in the theme's colours.
The widget. `tab_strip( tabs )` takes `TabItem`s, made with `tab( key, label )`, where the key stays the same while the tab is open, plus `.image( ImageData )` for a page's icon, `.symbol( name )` for a theme icon drawn in the tab's ink, and `.closable( bool )`. The builder sets the `selected` tab, `on_select`, `on_close`, `on_overview`, the buttons before the tabs (`leading`), right after the last one (`after_tabs`) and at the far right (`trailing`), the phone bar's `mobile_center`, the `TabStripColors`, the `TabLayout`, `reorderable` and the `TabMotion`. `TabStripColors` sets the strip's background and ink and the fill, edge and ink of the active tab and of the others; the default follows the theme.
On a desktop. `[leading] [tabs…][after_tabs]  clear  [trailing]`. The tabs share the width between 90 widget pixels and a fifth of the screen's width and scroll sideways when even the minimum does not fit, and 7.5 % of the width between the leading and trailing buttons is always left clear, also while they scroll. A horizontal scroll measures its child with no width limit, so the tabs inside it cannot see how much room there is; two new elements solve that. `TabRow`, outside the scroll, works the tab width out of its own rect, less the clear part and the buttons after the tabs, and hands it to the `TabMotion`; `TabCells`, inside the scroll, places the tabs, the gaps of closing ones and the buttons where the motion says. Each tab is clipped by a `viewport`, so it can grow from nothing and shrink away.
On a phone. `TabLayout::Mobile` is a bar with the leading buttons, the active tab's icon and title or the application's `mobile_center`, a counter that sends `on_overview` and the trailing buttons. `tab_overview( tabs )` is the grid of cards it opens, with a `title`, a `back` message and header `action`s: `build` gives the whole page, header and scrolling cards, and `cards` the grid alone for an application that lays its page out itself. The cards are placed by a third new element, `TabCards`, every one as tall as the tallest, so a dragged card can follow the finger.
The motion. `TabMotion` is the only state, kept by the application, handed to every view and reported from `App::is_animating`; it is an `Arc<Mutex>` because `view` only gets `&self`, and clones share it. A tab that opens grows from nothing while the others narrow, and one that closes leaves a gap that shrinks away while the others keep their width for a second, so the close button of the tab that slides in lands under the pointer; another close starts the second again. Changes take 180 ms, ease-out. The first layout and a resize alone place the tabs without moving them.
Reordering. With `reorderable( true )` each tab, and each card of the overview, carries its select message as `on_drag_start`, so it is dragged after 24 px of mouse motion or a long press, and dragging a tab selects it. The drag reaches the application, which forwards `App::on_drag_move` to `TabMotion::drag_move` and answers `App::on_drop` with the `( from, to )` that `TabMotion::drag_end` reports. The motion remembers where it drew every tab: the first move takes the tab under it and returns `false` when there is none, the tab then follows the pointer within the tabs and is drawn on top, and the others slide out of its way as its centre passes theirs, or as it enters another cell of the overview. On release it slides into its place. A strip and its overview are never shown at once and may share one motion.
Anchored. `AnchoredOverlay`, which only the combo used, is public as `anchored( child, id )`: it draws its child under the widget built with that `WidgetId`, as it was laid out on the previous frame. `align` lines it up with the anchor's left edge, its right edge or its middle, `gap` sets the space under it, and the child is now kept within the rect its parent gives it, which also keeps a combo's list inside the window.
List items and icons. `ListItem::ink( label, secondary )` draws the label, the subtitle and the trailing text in colours of the application's own and tints hover and press with the label's, for rows on a surface that is not the theme's. A button's icon is now fitted into its square without being stretched: a wide icon such as `actions/remove`, a 25 by 4 minus, was drawn as a block as tall as it was wide.
Documentation, example and tests. `docs/widgets.md` describes `tab_strip` and `anchored` and the new `ink`. `cargo run --example tab_strip` opens three documents that can be added, closed and dragged, on a desktop strip or a phone bar with its overview. The tests cover the share of the width, its limits and the clear part, the buttons after the tabs, the drawing order of a dragged tab, the builder's defaults, the overview's rows, and in `motion.rs` the growth, the gap, the wait and its restart, a resize, and dragging a tab and a card; `anchored` and the icon fit have tests of their own.
Not done. A strip that scrolls does not scroll to the active tab or follow a tab dragged towards its edge, and the overview does not scroll while a card is dragged to the edge of the screen.
This commit is contained in:
2026-10-15 08:22:41 +02:00
parent 99b195ea13
commit ce98c7b82c
22 changed files with 2143 additions and 17 deletions
+53 -2
View File
@@ -24,7 +24,7 @@ patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md).
- [Clipping wrappers](#clipping-wrappers)
- [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex) · [`carousel`](#carousel)
- [Overlays and feedback](#overlays-and-feedback)
- [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`dialog`](#dialog)
- [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`anchored`](#anchored) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`tab_strip`](#tab_strip) · [`dialog`](#dialog)
- [Pickers](#pickers)
- [`date_picker`](#date_picker) · [`time_picker`](#time_picker) · [`color_picker`](#color_picker)
- [Layouts](#layouts)
@@ -201,6 +201,8 @@ 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<Length>`. 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.
`ink( label, secondary )` draws the label in `label` and the subtitle and trailing text in `secondary`, and tints hover and press with `label`, in place of the theme's colours: for rows on a surface of the app's own, such as a menu in a fixed colour.
**See also**: [`pressable`](#pressable) for free-form tappable rows,
[`scroll`](#scroll) to wrap a list of items in a scrollable container.
@@ -798,6 +800,27 @@ overlay wiring.
**See also**: [`toast`](#toast) for transient self-dismissing
notifications.
---
### `anchored`
Draws its child right under another widget, the one built with the matching `.id( WidgetId )`, as that widget was laid out on the previous frame: a menu under the button that opens it, inside the window. `align` lines the child up with the anchor's left edge (`HAlign::Start`, the default), its right edge (`HAlign::End`) or its middle (`HAlign::Center`), and the child is kept within the rect its parent gives it; `gap` is the space under the anchor, in physical pixels. Until the anchor has been drawn once the child fills its parent's rect.
**When**: a menu or a panel that drops from a button of a bar, laid over the page in a [`stack`](#stack), usually over a scrim that closes it. For a popup that may leave the window, return an `OverlaySpec` with `anchor_widget_id` from `App::overlays` instead.
```rust,no_run
# use ltk::{ anchored, stack, Element, HAlign, WidgetId };
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( page: Element<Msg>, menu: Element<Msg> ) -> Element<Msg> {
stack()
.push( page )
.push( anchored( menu, WidgetId( "menu-button" ) ).align( HAlign::End ).gap( 4.0 ) )
.into()
# }
```
**See also**: [`tooltip`](#tooltip) and [`combo`](#combo), which anchor overlays to a widget the same way.
### `combo`
Select / dropdown with a popup list — single- or multi-select (`multi_select( true )` adds selection chips), with optional type-to-filter (`searchable( true )`). The widget is a stateless projection over an app-owned `ComboState`, and the app places **two** pieces: the trigger (`Combo::trigger()`) goes in the view tree like any widget, and the open popup is either layered into the same surface via `Combo::popup()` inside a [`stack`](#stack), or returned as a real xdg-popup from `App::overlays` via `Combo::overlay()`.
@@ -855,7 +878,7 @@ tabs::<Msg, _, _>( [ "Profile", "Network", "Privacy" ] )
# }
```
**See also**: [`notebook`](#notebook) for tabs that own their pages.
**See also**: [`notebook`](#notebook) for tabs that own their pages, [`tab_strip`](#tab_strip) for the open documents of a browser or a terminal.
### `notebook`
@@ -880,6 +903,34 @@ notebook::<Msg>()
**See also**: [`tabs`](#tabs) for a bare tab bar without page
ownership.
### `tab_strip`
The open documents of an app, as a browser or a terminal shows them. On a desktop, a strip of tabs with their icon, title and close button, sharing the width between a minimum and a fifth of the screen, scrolling sideways when even the minimum does not fit, and always leaving 7.5 % of the width clear; buttons go before the tabs (`leading`), right after the last one (`after_tabs`) and at the far right (`trailing`). On a phone (`TabLayout::Mobile`), a bar with the active tab, or the app's own `mobile_center`, and a counter that sends `on_overview`; `tab_overview` is the grid of cards it opens, as a whole page with `build` or as the cards alone with `cards`. `TabStripColors` sets every colour, the theme's by default.
Tabs grow from nothing as they open and the others narrow; a closed tab leaves a gap that shrinks away while the others keep their width for a second, so the close button that slides in lands under the pointer. The app keeps a `TabMotion`, hands it to every view and reports `TabMotion::is_animating` from `App::is_animating`.
With `reorderable( true )` the desktop strip's tabs, and the overview's cards, can be dragged into another order, the others sliding out of the way; the overview takes the strip's `TabMotion` too: the app forwards `App::on_drag_move` to `TabMotion::drag_move` and answers `App::on_drop` with the move `TabMotion::drag_end` reports, as `( from, to )`.
**When**: a browser's tabs, a terminal's sessions, an editor's files: documents the user opens and closes, each with a key that stays the same while it is open.
```rust,no_run
# use ltk::{ tab, tab_strip, Element, TabLayout, TabMotion };
# #[ derive( Clone ) ] enum Msg { Select( usize ), Close( usize ), Overview, NewTab }
# fn _ex( titles: &[ ( u64, String ) ], active: usize, motion: &TabMotion, plus: Element<Msg> ) -> Element<Msg> {
tab_strip( titles.iter().map( |( key, title )| tab( *key, title.clone() ).symbol( "features/console" ) ) )
.selected( active )
.on_select( Msg::Select )
.on_close( Msg::Close )
.on_overview( Msg::Overview )
.after_tabs( plus )
.layout( TabLayout::Desktop )
.motion( motion )
.into()
# }
```
**See also**: [`tabs`](#tabs) for a segmented selector between fixed sections.
### `dialog`
Modal or non-modal centered confirmation card with a built-in scrim,
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `cargo run --example tab_strip`
//!
//! Demonstrates the `tab_strip()` widget. On the desktop layout tabs grow as they open, leave a shrinking gap as they close while
//! the others wait a second, and can be dragged into another order; `+` after the last tab opens one. The button at the
//! left switches to the phone layout, whose counter opens the `tab_overview()` grid, whose cards can be dragged too. The app keeps a `TabMotion`, reports it
//! from `is_animating` and forwards the drag to it.
//!
//! Esc quits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example needs a running Wayland compositor.
use ltk::{
App, ButtonVariant, Element, Keysym, Length, TabLayout, TabMotion,
button, column, container, flex, tab, tab_overview, tab_strip, text,
};
#[ derive( Clone ) ]
enum Message
{
Select( usize ),
Close( usize ),
Move( usize, usize ),
New,
Overview,
Back,
ToggleLayout,
}
struct TabStripApp
{
tabs: Vec<u64>,
active: usize,
next: u64,
layout: TabLayout,
overview: bool,
motion: TabMotion,
}
impl TabStripApp
{
fn new() -> Self
{
Self { tabs: vec![ 1, 2, 3 ], active: 0, next: 4, layout: TabLayout::Desktop, overview: false, motion: TabMotion::new() }
}
fn items( &self ) -> impl Iterator<Item = ltk::TabItem> + '_
{
self.tabs.iter().map( |&key| tab( key, format!( "Document {key}" ) ).symbol( "archives/document" ) )
}
fn switch_button( &self ) -> Element<Message>
{
let label = match self.layout
{
TabLayout::Desktop => "Phone",
TabLayout::Mobile => "Desktop",
};
button( label ).variant( ButtonVariant::Tertiary ).on_press( Message::ToggleLayout ).into()
}
fn new_button() -> Element<Message>
{
button( "+" ).variant( ButtonVariant::Tertiary ).width( Length::widget( 40.0 ) ).on_press( Message::New ).into()
}
fn page( &self ) -> Element<Message>
{
let title = self.tabs.get( self.active ).map_or_else( || "No tabs".to_string(), |key| format!( "Document {key}" ) );
container( text( title ).size( 28.0 ) ).padding( 24.0 ).into()
}
}
impl App for TabStripApp
{
type Message = Message;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.tab_strip" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Message>
{
if self.overview
{
return tab_overview( self.items() )
.selected( self.active )
.on_select( Message::Select )
.on_close( Message::Close )
.reorderable( true )
.motion( &self.motion )
.title( "Tabs" )
.back( Message::Back )
.action( Self::new_button() )
.into();
}
let bar = tab_strip( self.items() )
.selected( self.active )
.on_select( Message::Select )
.on_close( Message::Close )
.on_overview( Message::Overview )
.reorderable( true )
.leading( self.switch_button() )
.after_tabs( Self::new_button() )
.trailing( button( "Menu" ).variant( ButtonVariant::Tertiary ) )
.layout( self.layout )
.motion( &self.motion );
column().padding( 0.0 ).spacing( 0.0 ).push( bar ).push( flex( self.page() ) ).into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::Select( idx ) =>
{
self.active = idx.min( self.tabs.len().saturating_sub( 1 ) );
self.overview = false;
}
Message::Close( idx ) =>
{
if idx >= self.tabs.len() { return; }
self.tabs.remove( idx );
if idx < self.active || self.active >= self.tabs.len() { self.active = self.active.saturating_sub( 1 ); }
}
Message::Move( from, to ) =>
{
let active = self.tabs.get( self.active ).copied();
let key = self.tabs.remove( from );
self.tabs.insert( to, key );
self.active = active.and_then( |key| self.tabs.iter().position( |&k| k == key ) ).unwrap_or( to );
}
Message::New =>
{
self.tabs.push( self.next );
self.next += 1;
self.active = self.tabs.len() - 1;
self.overview = false;
}
Message::Overview => self.overview = true,
Message::Back => self.overview = false,
Message::ToggleLayout =>
{
self.layout = match self.layout
{
TabLayout::Desktop => TabLayout::Mobile,
TabLayout::Mobile => TabLayout::Desktop,
};
}
}
}
fn is_animating( &self ) -> bool { self.motion.is_animating() }
fn on_drag_move( &mut self, x: f32, y: f32 ) { self.motion.drag_move( x, y ); }
fn on_drop( &mut self, _x: f32, _y: f32 ) -> Option<Message>
{
self.motion.drag_end().map( |( from, to )| Message::Move( from, to ) )
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
if keysym == Keysym::Escape { std::process::exit( 0 ); }
None
}
}
fn main()
{
ltk::run( TabStripApp::new() );
}
+26 -1
View File
@@ -94,6 +94,31 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
}
idx
}
Element::TabRow( r ) =>
{
let inner = r.arrange( rect, canvas );
layout_and_draw::<Msg>( r.child.as_ref(), canvas, inner, ctx, flat_idx )
}
Element::TabCells( cells ) =>
{
let child_rects = cells.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( cells.child( child_i ), canvas, child_rect, ctx, idx );
}
idx
}
Element::TabCards( cards ) =>
{
let child_rects = cards.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( cards.child( child_i ), canvas, child_rect, ctx, idx );
}
idx
}
Element::Flex( f ) =>
{
// `Flex` is invisible chrome: it claimed leftover width up at
@@ -122,7 +147,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
let ( w, h ) = a.child.preferred_size( rect.width, canvas );
Rect
{
x: anchor.x,
x: a.left( anchor, w, rect ),
y: anchor.y + anchor.height + a.gap,
width: w,
height: h,
+2
View File
@@ -379,6 +379,8 @@ pub use widget::flex::{ Flex, flex };
pub use widget::combo::{ Combo, ComboState, combo };
pub use widget::spinner::{ Spinner, spinner };
pub use widget::tab_bar::{ TabBar, tabs };
pub use widget::anchored_overlay::{ anchored, AnchoredOverlay };
pub use widget::tab_strip::{ TabItem, TabLayout, TabMotion, TabOverview, TabStrip, TabStripColors, tab, tab_overview, tab_strip };
pub use widget::toast::{ Toast, toast };
pub use widget::tooltip::{ Tooltip, tooltip };
pub use widget::notebook::{ Notebook, NotebookPage, notebook };
+38
View File
@@ -20,6 +20,7 @@
//! root in a `view()`, means the full surface, giving a sane modal
//! fallback until the next frame fixes the position.
use crate::layout::stack::HAlign;
use crate::types::{ Rect, WidgetId };
use super::Element;
@@ -38,6 +39,8 @@ pub struct AnchoredOverlay<Msg: Clone>
/// Vertical pixel gap between the bottom of the anchor and the top
/// of the child.
pub( crate ) gap: f32,
/// Which of the anchor's edges the child lines up with: `Start` its left one, `End` its right one, `Center` its middle.
pub( crate ) align: HAlign,
}
impl<Msg: Clone> AnchoredOverlay<Msg>
@@ -53,9 +56,36 @@ impl<Msg: Clone> AnchoredOverlay<Msg>
child: Box::new( child.into() ),
anchor_id,
gap,
align: HAlign::Start,
}
}
/// The vertical space, in physical pixels, between the anchor's bottom edge and the child's top edge. Default: `0`.
pub fn gap( mut self, gap: f32 ) -> Self
{
self.gap = gap;
self
}
/// Line the child up with the anchor's left edge (`Start`, the default), its right edge (`End`) or its middle (`Center`).
pub fn align( mut self, align: HAlign ) -> Self
{
self.align = align;
self
}
/// The child's left edge, `width` wide, against an anchor at `anchor`, kept within `bounds`.
pub( crate ) fn left( &self, anchor: Rect, width: f32, bounds: Rect ) -> f32
{
let x = match self.align
{
HAlign::End => anchor.x + anchor.width - width,
HAlign::Center => anchor.x + ( anchor.width - width ) / 2.0,
_ => anchor.x,
};
x.min( bounds.x + bounds.width - width ).max( bounds.x )
}
/// Compute the draw rect for the child, given the anchor rect (when
/// available) and the parent-supplied fallback.
///
@@ -88,6 +118,7 @@ impl<Msg: Clone> AnchoredOverlay<Msg>
child: Box::new( self.child.map_arc( f ) ),
anchor_id: self.anchor_id,
gap: self.gap,
align: self.align,
}
}
}
@@ -99,3 +130,10 @@ impl<Msg: Clone + 'static> From<AnchoredOverlay<Msg>> for Element<Msg>
Element::AnchoredOverlay( a )
}
}
/// Draw `child` right under the widget whose `.id( … )` is `anchor`, as it was laid out on the previous frame: a menu under the
/// button that opens it. Until the anchor has been drawn the child fills the rect its parent gives it.
pub fn anchored<Msg: Clone>( child: impl Into<Element<Msg>>, anchor: WidgetId ) -> AnchoredOverlay<Msg>
{
AnchoredOverlay::new( child, anchor, 0.0 )
}
+13
View File
@@ -22,3 +22,16 @@ fn resolve_rect_falls_back_when_anchor_missing()
let r = AnchoredOverlay::<()>::resolve_rect( None, 8.0, fallback );
assert_eq!( r, fallback );
}
#[ test ]
fn the_child_lines_up_with_the_edge_asked_and_stays_inside()
{
let anchor = Rect { x: 500.0, y: 0.0, width: 40.0, height: 40.0 };
let bounds = Rect { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
let overlay = |align| anchored::<()>( crate::layout::spacer::spacer(), WidgetId( "menu" ) ).align( align );
assert_eq!( overlay( HAlign::Start ).left( anchor, 200.0, bounds ), 500.0 );
assert_eq!( overlay( HAlign::End ).left( anchor, 200.0, bounds ), 340.0 );
assert_eq!( overlay( HAlign::Center ).left( anchor, 200.0, bounds ), 420.0 );
assert_eq!( overlay( HAlign::Start ).left( anchor, 400.0, bounds ), 400.0 );
assert_eq!( overlay( HAlign::End ).left( Rect { x: 10.0, ..anchor }, 200.0, bounds ), 0.0 );
}
+13 -9
View File
@@ -505,14 +505,8 @@ impl<Msg: Clone> Button<Msg>
{
self.paint_frame( canvas, rect, focused );
let s = self.resolved_icon_size( canvas ).min( rect.width ).min( rect.height );
let dest = Rect
{
x: rect.x + ( rect.width - s ) / 2.0,
y: rect.y + ( rect.height - s ) / 2.0,
width: s,
height: s,
};
canvas.draw_image_data( rgba, img_w, img_h, dest, 1.0 );
let area = Rect { x: rect.x + ( rect.width - s ) / 2.0, y: rect.y + ( rect.height - s ) / 2.0, width: s, height: s };
canvas.draw_image_data( rgba, img_w, img_h, fit( img_w, img_h, area ), 1.0 );
return;
}
@@ -545,7 +539,7 @@ impl<Msg: Clone> Button<Msg>
let ring = circle.expand( theme::FOCUS_W + 1.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, r + theme::FOCUS_W + 1.0 );
}
canvas.draw_image_data( rgba, img_w, img_h, rect, 1.0 );
canvas.draw_image_data( rgba, img_w, img_h, fit( img_w, img_h, rect ), 1.0 );
}
/// Wrap this button in an [`Element`].
@@ -580,3 +574,13 @@ impl<Msg: Clone> Button<Msg>
}
}
}
/// An `img_w` × `img_h` image as big as fits in `area` without stretching, centred in it.
pub( crate ) fn fit( img_w: u32, img_h: u32, area: Rect ) -> Rect
{
if img_w == 0 || img_h == 0 { return area; }
let scale = ( area.width / img_w as f32 ).min( area.height / img_h as f32 );
let width = img_w as f32 * scale;
let height = img_h as f32 * scale;
Rect { x: area.x + ( area.width - width ) / 2.0, y: area.y + ( area.height - height ) / 2.0, width, height }
}
+12
View File
@@ -183,3 +183,15 @@ fn width_builder_pins_button_width()
let canvas = Canvas::new( 400, 800 );
assert_eq!( b.preferred_size( 1000.0, &canvas ).0, 40.0 );
}
#[ test ]
fn a_wide_icon_keeps_its_proportions_in_a_square()
{
let area = Rect { x: 10.0, y: 10.0, width: 20.0, height: 20.0 };
let dest = fit( 48, 6, area );
let near = |a: f32, b: f32| ( a - b ).abs() < 1e-4;
assert!( near( dest.x, 10.0 ) && near( dest.width, 20.0 ) );
assert!( near( dest.y, 18.75 ) && near( dest.height, 2.5 ) );
let square = fit( 24, 24, area );
assert!( near( square.x, 10.0 ) && near( square.y, 10.0 ) && near( square.width, 20.0 ) && near( square.height, 20.0 ) );
}
+13 -1
View File
@@ -7,7 +7,7 @@ use crate::render::Canvas;
use super::{
anchored_overlay, button, carousel, checkbox, container, external, flex,
image, list_item, pressable, progress_bar, radio, rich_text, scroll, separator,
pattern_grid, slider, spinner, text, text_edit, toggle, viewport, vslider, window_button,
pattern_grid, slider, spinner, tab_strip, text, text_edit, toggle, viewport, vslider, window_button,
};
use super::handlers::WidgetHandlers;
use super::MapFn;
@@ -43,6 +43,9 @@ pub enum Element<Msg: Clone>
Spinner( spinner::Spinner ),
External( external::External ),
Carousel( carousel::Carousel<Msg> ),
TabRow( tab_strip::TabRow<Msg> ),
TabCells( tab_strip::TabCells<Msg> ),
TabCards( tab_strip::TabCards<Msg> ),
}
impl<Msg: Clone> Element<Msg>
@@ -80,6 +83,9 @@ impl<Msg: Clone> Element<Msg>
Element::Spinner( s ) => s.preferred_size( max_width, canvas ),
Element::External( e ) => e.preferred_size( max_width ),
Element::Carousel( c ) => c.preferred_size( max_width, canvas ),
Element::TabRow( r ) => r.preferred_size( max_width, canvas ),
Element::TabCells( c ) => c.preferred_size( max_width, canvas ),
Element::TabCards( c ) => c.preferred_size( max_width, canvas ),
}
}
@@ -126,6 +132,9 @@ impl<Msg: Clone> Element<Msg>
Element::Spinner( s ) => s.draw( canvas, rect ),
Element::External( e ) => e.draw( canvas, rect ),
Element::Carousel( _ ) => {}
Element::TabRow( _ ) => {}
Element::TabCells( _ ) => {}
Element::TabCards( _ ) => {}
}
}
@@ -444,6 +453,9 @@ impl<Msg: Clone + 'static> Element<Msg>
Element::Spinner( s ) => Element::Spinner( s ),
Element::External( e ) => Element::External( e ),
Element::Carousel( c ) => Element::Carousel( c.map_msg( f ) ),
Element::TabRow( r ) => Element::TabRow( r.map_msg( f ) ),
Element::TabCells( c ) => Element::TabCells( c.map_msg( f ) ),
Element::TabCards( c ) => Element::TabCards( c.map_msg( f ) ),
}
}
}
+25 -4
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use crate::types::{ Length, Rect, WidgetId };
use crate::types::{ Color, Length, Rect, WidgetId };
use crate::render::Canvas;
use super::{ elide, Element };
@@ -71,6 +71,8 @@ pub struct ListItem<Msg: Clone>
pub( crate ) height: Option<Length>,
/// Optional override of the primary-label font size.
pub( crate ) font_size: Option<Length>,
/// Colours of the label and of the subtitle and trailing text, for a row on a surface of its own.
pub( crate ) ink: Option<( Color, Color )>,
}
impl<Msg: Clone> ListItem<Msg>
@@ -92,9 +94,18 @@ impl<Msg: Clone> ListItem<Msg>
pad_h: None,
height: None,
font_size: None,
ink: None,
}
}
/// Draw the label in `label`, the subtitle and trailing text in `secondary`, and tint hover and press with `label`, in
/// place of the theme's colours: for rows on a surface that is not the theme's.
pub fn ink( mut self, label: Color, secondary: Color ) -> Self
{
self.ink = Some( ( label, secondary ) );
self
}
/// Attach a leading icon. Pass the decoded RGBA buffer alongside
/// the image's native width and height; the draw path scales it
/// down to `theme::ICON_SIZE` on the same row baseline as the
@@ -233,15 +244,24 @@ impl<Msg: Clone> ListItem<Msg>
}
else
{
let tint = |bg: Color| match self.ink
{
Some( ( label, _ ) ) => Color { a: bg.a, ..label },
None => bg,
};
if pressed
{
canvas.fill_rect( rect, theme::press_bg(), theme::RADIUS );
canvas.fill_rect( rect, tint( theme::press_bg() ), theme::RADIUS );
}
else if hovered
{
canvas.fill_rect( rect, theme::hover_bg(), theme::RADIUS );
canvas.fill_rect( rect, tint( theme::hover_bg() ), theme::RADIUS );
}
match self.ink
{
Some( ( label, secondary ) ) => ( label, secondary, secondary ),
None => ( theme::label_color(), theme::subtitle_color(), theme::trailing_color() ),
}
( theme::label_color(), theme::subtitle_color(), theme::trailing_color() )
};
if focused
@@ -353,6 +373,7 @@ impl<Msg: Clone> ListItem<Msg>
pad_h: self.pad_h,
height: self.height,
font_size: self.font_size,
ink: self.ink,
}
}
}
+1
View File
@@ -83,6 +83,7 @@ pub mod combo;
pub mod anchored_overlay;
pub mod spinner;
pub mod tab_bar;
pub mod tab_strip;
pub mod toast;
pub mod tooltip;
pub mod notebook;
+81
View File
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The overview's grid of cards, placed by the [`TabMotion`] so a dragged card follows the finger and the others make way.
use crate::render::Canvas;
use crate::types::{ Length, Rect };
use crate::widget::Element;
use super::motion::TabMotion;
pub struct TabCards<Msg: Clone>
{
pub( crate ) cards: Vec<( u64, Element<Msg> )>,
pub( crate ) columns: usize,
pub( crate ) spacing: Length,
pub( crate ) motion: TabMotion,
}
impl<Msg: Clone> TabCards<Msg>
{
pub( crate ) fn child( &self, idx: usize ) -> &Element<Msg>
{
&self.cards[ idx ].1
}
fn columns( &self ) -> usize
{
self.columns.max( 1 )
}
/// Every card as big as the tallest.
fn cell( &self, width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let gap = canvas.resolve_geom( self.spacing );
let columns = self.columns() as f32;
let width = ( ( width - gap * ( columns - 1.0 ) ) / columns ).max( 0.0 );
let height = self.cards.iter().map( |( _, card )| card.preferred_size( width, canvas ).1 ).fold( 0.0_f32, f32::max );
( width, height )
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let gap = canvas.resolve_geom( self.spacing );
let height = self.cell( max_width, canvas ).1;
let rows = self.cards.len().div_ceil( self.columns() ) as f32;
( max_width, rows * height + ( rows - 1.0 ).max( 0.0 ) * gap )
}
/// The cards in rows, the dragged one last so it is drawn on top.
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<( Rect, usize )>
{
let gap = canvas.resolve_geom( self.spacing );
let keys = self.cards.iter().map( |( key, _ )| *key ).collect::<Vec<_>>();
self.motion.lay_cards( &keys, rect, self.columns(), self.cell( rect.width, canvas ), gap ).into_iter()
.filter_map( |laid| keys.iter().position( |&key| key == laid.key ).map( |idx| ( laid.rect, idx ) ) )
.collect()
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> TabCards<U>
where
U: Clone + 'static,
Msg: 'static,
{
TabCards
{
cards: self.cards.into_iter().map( |( key, card )| ( key, card.map_arc( f ) ) ).collect(),
columns: self.columns,
spacing: self.spacing,
motion: self.motion,
}
}
}
impl<Msg: Clone> From<TabCards<Msg>> for Element<Msg>
{
fn from( cards: TabCards<Msg> ) -> Self
{
Element::TabCards( cards )
}
}
+81
View File
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::layout::row::row;
use crate::types::{ Corners, Length };
use crate::widget::flex::flex;
use crate::widget::pressable::pressable;
use crate::widget::scroll::scroll;
use crate::widget::viewport::viewport;
use crate::widget::{ container, text, Element };
use super::row::{ TabCells, TabRow };
use super::theme::{ EDGE_WIDTH, GAP, ICON_SIZE, LABEL_SIZE, TAB_RADIUS };
use super::{ parts, IndexMsg, TabItem, TabStrip, TabStripColors };
/// `[leading] [tabs…][after tabs] clear [trailing]`, the tabs scrolling sideways when they do not fit.
pub( super ) fn build<Msg: Clone + 'static>( strip: TabStrip<Msg> ) -> Element<Msg>
{
let TabStrip { tabs, selected, on_select, on_close, reorderable, leading, after, trailing, colors, motion, .. } = strip;
let keys = tabs.iter().map( |tab| tab.key ).collect();
let handles = tabs.into_iter().enumerate()
.map( |( idx, tab )| ( tab.key, handle( tab, idx, idx == selected, reorderable, &colors, on_select.as_ref(), on_close.as_ref() ) ) )
.collect();
let cells = TabCells { tabs: handles, after, motion: motion.clone() };
let tab_row = TabRow { child: Box::new( scroll( cells ).horizontal().into() ), keys, motion };
let mut bar = row().spacing( Length::widget( GAP ) );
for button in leading
{
bar = bar.push( button );
}
bar = bar.push( flex( tab_row ) );
for button in trailing
{
bar = bar.push( button );
}
container( bar )
.background( colors.strip )
.padding_h( Length::widget( 6.0 ) )
.padding_top( Length::widget( 4.0 ) )
.into()
}
/// Clipped to its slot, so it can grow from nothing and shrink away.
fn handle<Msg: Clone + 'static>(
tab: TabItem,
idx: usize,
active: bool,
draggable: bool,
colors: &TabStripColors,
on_select: Option<&IndexMsg<Msg>>,
on_close: Option<&IndexMsg<Msg>>,
) -> Element<Msg>
{
let ( fill, edge, ink ) = colors.of( active );
let weight = if active { 600 } else { 400 };
let mut body = row().spacing( Length::widget( 8.0 ) );
if let Some( icon ) = parts::icon( &tab, Length::widget( ICON_SIZE ), ink )
{
body = body.push( icon );
}
body = body.push( flex( text( tab.label ).size( LABEL_SIZE ).weight( weight ).color( ink ) ) );
if let Some( close ) = on_close.filter( |_| tab.closable )
{
body = body.push( parts::close( close( idx ), ink ) );
}
let face = container( body )
.background( fill )
.border( edge, EDGE_WIDTH )
.radius( Corners::top( TAB_RADIUS ) )
.padding_left( Length::widget( 10.0 ) )
.padding_right( Length::widget( 4.0 ) )
.padding_v( Length::widget( 3.0 ) );
let mut press = pressable( face );
if let Some( select ) = on_select
{
press = press.on_press( select( idx ) );
if draggable { press = press.on_drag_start( select( idx ) ); }
}
viewport( press ).into()
}
+34
View File
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::cell::RefCell;
use std::collections::HashMap;
use crate::types::Color;
use crate::wallpaper::ImageData;
/// Rasterised at twice the drawn size so icons stay sharp at scale 2.
const RASTER_PX: u32 = 48;
thread_local!
{
static CACHE: RefCell<HashMap<( String, [ u8; 4 ] ), Option<ImageData>>> = RefCell::new( HashMap::new() );
}
/// The theme's symbolic icon `name` in `tint`, kept so the same pixels are handed out every frame.
pub( crate ) fn tinted( name: &str, tint: Color ) -> Option<ImageData>
{
let key = ( name.to_string(), [ channel( tint.r ), channel( tint.g ), channel( tint.b ), channel( tint.a ) ] );
CACHE.with( |cache|
{
cache.borrow_mut()
.entry( key )
.or_insert_with( || crate::chassis::theme_icon_tinted( name, RASTER_PX, tint ) )
.clone()
} )
}
fn channel( v: f32 ) -> u8
{
( v.clamp( 0.0, 1.0 ) * 255.0 ) as u8
}
+61
View File
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::layout::row::row;
use crate::layout::spacer::spacer;
use crate::types::{ Color, Length };
use crate::widget::flex::flex;
use crate::widget::pressable::pressable;
use crate::widget::{ container, text, Element };
use super::theme::{ BAR_ICON_SIZE, BAR_TITLE_SIZE };
use super::{ parts, TabItem, TabStrip, TabStripColors };
/// `[leading] active tab or the app's centre [counter] [trailing]`.
pub( super ) fn build<Msg: Clone + 'static>( strip: TabStrip<Msg> ) -> Element<Msg>
{
let TabStrip { tabs, selected, on_overview, leading, trailing, center, colors, .. } = strip;
let center = center.unwrap_or_else( || title( tabs.get( selected ), &colors ) );
let mut bar = row().spacing( Length::widget( 8.0 ) );
for button in leading
{
bar = bar.push( button );
}
bar = bar.push( flex( center ) );
if let Some( msg ) = on_overview
{
bar = bar.push( counter( tabs.len(), msg, colors.ink ) );
}
for button in trailing
{
bar = bar.push( button );
}
container( bar )
.background( colors.strip )
.padding_h( Length::widget( 8.0 ) )
.padding_v( Length::widget( 5.0 ) )
.into()
}
fn title<Msg: Clone + 'static>( tab: Option<&TabItem>, colors: &TabStripColors ) -> Element<Msg>
{
let Some( tab ) = tab else { return spacer().into() };
let mut line = row().spacing( Length::widget( 8.0 ) ).push( spacer().width( Length::widget( 4.0 ) ) );
if let Some( icon ) = parts::icon( tab, Length::widget( BAR_ICON_SIZE ), colors.idle_ink )
{
line = line.push( icon );
}
line.push( flex( text( tab.label.clone() ).size( BAR_TITLE_SIZE ).weight( 600 ).color( colors.ink ) ) ).into()
}
/// Rounded square holding the number of open tabs.
fn counter<Msg: Clone + 'static>( count: usize, msg: Msg, ink: Color ) -> Element<Msg>
{
let label = text( count.min( 99 ).to_string() ).size( 13.0 ).weight( 600 ).color( ink ).align_center();
let badge = container( label )
.border( ink, 2.0 )
.radius( 7.0 )
.padding_h( Length::widget( 5.0 ) )
.padding_v( Length::widget( 3.0 ) );
pressable( container( badge ).padding( Length::widget( 8.0 ) ) ).on_press( msg ).into()
}
+305
View File
@@ -0,0 +1,305 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! TabStrip — the open documents of an app, as a browser or a terminal shows them.
mod cards;
mod desktop;
mod icons;
mod mobile;
mod motion;
mod overview;
mod parts;
pub( crate ) mod row;
mod theme;
#[ cfg( test ) ]
mod tests;
use std::sync::Arc;
use crate::types::Color;
use crate::wallpaper::ImageData;
use super::Element;
pub use motion::TabMotion;
pub use overview::{ tab_overview, TabOverview };
pub use cards::TabCards;
pub use row::{ TabCells, TabRow };
/// The form a [`TabStrip`] takes.
#[ derive( Debug, Clone, Copy, PartialEq, Eq, Default ) ]
pub enum TabLayout
{
/// A strip of every tab.
#[ default ]
Desktop,
/// A bar with the active tab and a counter.
Mobile,
}
/// The colours of a [`TabStrip`] and its [`TabOverview`]. The default follows the theme.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub struct TabStripColors
{
/// Behind the strip, the phone bar and the overview.
pub strip: Color,
/// Text and icons drawn on the strip itself: the phone bar's title, the counter, the overview's header.
pub ink: Color,
pub active_fill: Color,
pub active_edge: Color,
pub active_ink: Color,
pub idle_fill: Color,
pub idle_edge: Color,
pub idle_ink: Color,
}
impl Default for TabStripColors
{
fn default() -> Self
{
let palette = crate::theme::palette();
Self
{
strip: palette.surface_alt,
ink: palette.text_primary,
active_fill: palette.accent,
active_edge: palette.accent,
active_ink: palette.text_primary,
idle_fill: palette.surface,
idle_edge: palette.text_secondary,
idle_ink: palette.text_secondary,
}
}
}
impl TabStripColors
{
pub( crate ) fn of( &self, active: bool ) -> ( Color, Color, Color )
{
if active { ( self.active_fill, self.active_edge, self.active_ink ) } else { ( self.idle_fill, self.idle_edge, self.idle_ink ) }
}
}
#[ derive( Clone ) ]
pub( crate ) enum TabIcon
{
Image( ImageData ),
/// A symbolic theme icon, drawn in the colour of the tab's text.
Symbol( String ),
}
/// One tab: a key that stays the same while it is open, which its animation follows, and what it shows.
#[ derive( Clone ) ]
pub struct TabItem
{
pub( crate ) key: u64,
pub( crate ) label: String,
pub( crate ) icon: Option<TabIcon>,
pub( crate ) closable: bool,
}
impl TabItem
{
pub fn new( key: u64, label: impl Into<String> ) -> Self
{
Self { key, label: label.into(), icon: None, closable: true }
}
/// An icon of its own, such as a page's favicon.
pub fn image( mut self, image: ImageData ) -> Self
{
self.icon = Some( TabIcon::Image( image ) );
self
}
/// A symbolic icon of the theme, by its catalogue name.
pub fn symbol( mut self, name: impl Into<String> ) -> Self
{
self.icon = Some( TabIcon::Symbol( name.into() ) );
self
}
/// Whether it has a close button. Default: `true`.
pub fn closable( mut self, closable: bool ) -> Self
{
self.closable = closable;
self
}
}
/// Create a [`TabItem`].
pub fn tab( key: u64, label: impl Into<String> ) -> TabItem
{
TabItem::new( key, label )
}
type IndexMsg<Msg> = Arc<dyn Fn( usize ) -> Msg>;
/// The open documents, as a strip on a desktop and a bar on a phone.
///
/// On a desktop, a strip of tabs with their icon, title and close button, sharing the width between a minimum and a part of the
/// screen and scrolling sideways when even the minimum does not fit; buttons may go before the tabs, right after the last one
/// and at the far right, and part of the width is always left clear. On a phone, a bar with the active tab and a counter that
/// asks for the [`TabOverview`], a grid of cards of every tab.
///
/// The widget is stateless but for its [`TabMotion`], which the app keeps so tabs grow, shrink and slide as they open and close:
///
/// ```rust,no_run
/// # use ltk::{ tab, tab_strip, Element, TabLayout, TabMotion };
/// # #[ derive( Clone ) ] enum Msg { Select( usize ), Close( usize ), Overview }
/// # struct App { titles: Vec<( u64, String )>, active: usize, motion: TabMotion, phone: bool }
/// # impl App { fn _ex( &self ) -> Element<Msg> {
/// tab_strip( self.titles.iter().map( |( key, title )| tab( *key, title.clone() ).symbol( "features/console" ) ) )
/// .selected( self.active )
/// .on_select( Msg::Select )
/// .on_close( Msg::Close )
/// .on_overview( Msg::Overview )
/// .layout( if self.phone { TabLayout::Mobile } else { TabLayout::Desktop } )
/// .motion( &self.motion )
/// .into()
/// # }}
/// ```
pub struct TabStrip<Msg: Clone>
{
pub( crate ) tabs: Vec<TabItem>,
pub( crate ) selected: usize,
pub( crate ) on_select: Option<IndexMsg<Msg>>,
pub( crate ) on_close: Option<IndexMsg<Msg>>,
pub( crate ) on_overview: Option<Msg>,
pub( crate ) reorderable: bool,
pub( crate ) leading: Vec<Element<Msg>>,
pub( crate ) after: Vec<Element<Msg>>,
pub( crate ) trailing: Vec<Element<Msg>>,
pub( crate ) center: Option<Element<Msg>>,
pub( crate ) colors: TabStripColors,
pub( crate ) layout: TabLayout,
pub( crate ) motion: TabMotion,
}
impl<Msg: Clone + 'static> TabStrip<Msg>
{
pub fn new( tabs: impl IntoIterator<Item = TabItem> ) -> Self
{
Self
{
tabs: tabs.into_iter().collect(),
selected: 0,
on_select: None,
on_close: None,
on_overview: None,
reorderable: false,
leading: Vec::new(),
after: Vec::new(),
trailing: Vec::new(),
center: None,
colors: TabStripColors::default(),
layout: TabLayout::Desktop,
motion: TabMotion::default(),
}
}
pub fn selected( mut self, idx: usize ) -> Self
{
self.selected = idx;
self
}
/// Called with the index of the tab pressed.
pub fn on_select( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_select = Some( Arc::new( f ) );
self
}
/// Called with the index of the tab whose close button was pressed.
pub fn on_close( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_close = Some( Arc::new( f ) );
self
}
/// The message the phone bar's counter sends, to show the [`TabOverview`]. Without it the bar has no counter.
pub fn on_overview( mut self, msg: Msg ) -> Self
{
self.on_overview = Some( msg );
self
}
/// Whether the desktop strip's tabs can be dragged into another order; the app forwards the drag to the
/// [`TabMotion`] and moves the tab [`TabMotion::drag_end`] reports. Dragging a tab selects it. Default: `false`.
pub fn reorderable( mut self, reorderable: bool ) -> Self
{
self.reorderable = reorderable;
self
}
/// A button at the far left, before the tabs; also at the left of the phone bar.
pub fn leading( mut self, button: impl Into<Element<Msg>> ) -> Self
{
self.leading.push( button.into() );
self
}
/// A button right after the last tab, such as a new tab button. Not on the phone bar: the overview's header has those.
pub fn after_tabs( mut self, button: impl Into<Element<Msg>> ) -> Self
{
self.after.push( button.into() );
self
}
/// A button at the far right; also at the right of the phone bar.
pub fn trailing( mut self, button: impl Into<Element<Msg>> ) -> Self
{
self.trailing.push( button.into() );
self
}
/// What the phone bar shows in place of the active tab's icon and title, such as an address field.
pub fn mobile_center( mut self, center: impl Into<Element<Msg>> ) -> Self
{
self.center = Some( center.into() );
self
}
pub fn colors( mut self, colors: TabStripColors ) -> Self
{
self.colors = colors;
self
}
pub fn layout( mut self, layout: TabLayout ) -> Self
{
self.layout = layout;
self
}
/// The animation state the app keeps; without it the tabs do not animate.
pub fn motion( mut self, motion: &TabMotion ) -> Self
{
self.motion = motion.clone();
self
}
pub fn build( self ) -> Element<Msg>
{
match self.layout
{
TabLayout::Desktop => desktop::build( self ),
TabLayout::Mobile => mobile::build( self ),
}
}
}
impl<Msg: Clone + 'static> From<TabStrip<Msg>> for Element<Msg>
{
fn from( strip: TabStrip<Msg> ) -> Self
{
strip.build()
}
}
/// Create a [`TabStrip`] of `tabs`.
pub fn tab_strip<Msg: Clone + 'static>( tabs: impl IntoIterator<Item = TabItem> ) -> TabStrip<Msg>
{
TabStrip::new( tabs )
}
+592
View File
@@ -0,0 +1,592 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The widths and places a [`TabStrip`](super::TabStrip)'s tabs, and its overview's cards, move through as tabs come, go and are
//! dragged.
use std::sync::{ Arc, Mutex, MutexGuard, PoisonError };
use std::time::Instant;
use crate::types::Rect;
use super::theme::{ CLOSE_HOLD, SLIDE };
/// Animation state of a tab strip, kept by the app across frames.
///
/// Hand the same value to [`TabStrip::motion`](super::TabStrip::motion) on every view and return
/// [`Self::is_animating`] from [`App::is_animating`](crate::App::is_animating). A tab that opens grows from nothing and the others
/// narrow to make room; a tab that closes leaves a gap that shrinks away while the others keep their width for a moment, so the
/// close button of the tab that slides in lands under the pointer. Clones share the state.
///
/// A [`reorderable`](super::TabStrip::reorderable) strip or overview is dragged through the app: forward
/// [`App::on_drag_move`](crate::App::on_drag_move) to [`Self::drag_move`] and answer [`App::on_drop`](crate::App::on_drop) with
/// what [`Self::drag_end`] reports. A strip and its overview, never shown at once, may share one.
#[ derive( Clone, Default ) ]
pub struct TabMotion( Arc<Mutex<Motion>> );
impl TabMotion
{
pub fn new() -> Self
{
Self::default()
}
/// A tab is moving or being dragged, or the others are holding their width after a close.
pub fn is_animating( &self ) -> bool
{
self.lock().is_animating( Instant::now() )
}
/// Follow the pointer, in physical pixels; `false` when the drag did not start on one of the tabs.
pub fn drag_move( &self, x: f32, y: f32 ) -> bool
{
self.lock().drag_move( x, y, Instant::now() )
}
/// End the drag: the index the tab had and the one it has now, when it moved.
pub fn drag_end( &self ) -> Option<( usize, usize )>
{
self.lock().drag_end( Instant::now() )
}
/// Lay the tabs `keys` out at `width` each, as far as the animation has got; physical pixels.
pub( crate ) fn arrange( &self, keys: &[ u64 ], width: f32 ) -> Vec<Placed>
{
self.lock().arrange( keys, width, Instant::now() )
}
/// What the last [`Self::arrange`] found.
pub( crate ) fn placed( &self ) -> Vec<Placed>
{
self.lock().placed.clone()
}
/// Where each tab goes in `rect`, the dragged one last, and where the tabs end.
pub( crate ) fn lay( &self, rect: Rect, gap: f32 ) -> ( Vec<Laid>, f32 )
{
self.lock().lay( rect, gap )
}
/// Where each card of `keys` goes in `rect`, in `columns` of `cell`-sized cards, the dragged one last.
pub( crate ) fn lay_cards( &self, keys: &[ u64 ], rect: Rect, columns: usize, cell: ( f32, f32 ), gap: f32 ) -> Vec<Laid>
{
self.lock().lay_cards( keys, rect, columns, cell, gap, Instant::now() )
}
fn lock( &self ) -> MutexGuard<'_, Motion>
{
self.0.lock().unwrap_or_else( PoisonError::into_inner )
}
}
/// A tab's place in the strip: `key` is `None` for the gap a closed tab leaves.
#[ derive( Clone, Copy, Debug, PartialEq ) ]
pub( crate ) struct Placed
{
pub key: Option<u64>,
pub width: f32,
/// How much of its full width the slot has, which the gap after it follows.
pub presence: f32,
/// How far it still is from its place, sliding there after the tabs were reordered.
pub shift: ( f32, f32 ),
}
/// A tab or a card as drawn.
#[ derive( Clone, Copy, Debug, PartialEq ) ]
pub( crate ) struct Laid
{
pub key: u64,
pub rect: Rect,
}
#[ derive( Clone, Copy ) ]
struct Slot
{
key: u64,
gone: bool,
from: f32,
to: f32,
start: Instant,
/// The width it has when whole: its target, or the one it had when it closed.
full: f32,
shift_from: ( f32, f32 ),
shift_start: Instant,
}
/// Ease-out cubic over [`SLIDE`] since `start`.
fn eased( start: Instant, now: Instant ) -> f32
{
let t = ( now.saturating_duration_since( start ).as_secs_f32() / SLIDE.as_secs_f32() ).min( 1.0 );
1.0 - ( 1.0 - t ).powi( 3 )
}
impl Slot
{
fn new( key: u64, now: Instant ) -> Self
{
Self { key, gone: false, from: 0.0, to: 0.0, start: now, full: 0.0, shift_from: ( 0.0, 0.0 ), shift_start: now }
}
fn width( &self, now: Instant ) -> f32
{
self.from + ( self.to - self.from ) * eased( self.start, now )
}
fn shift( &self, now: Instant ) -> ( f32, f32 )
{
let left = 1.0 - eased( self.shift_start, now );
( self.shift_from.0 * left, self.shift_from.1 * left )
}
fn moving( &self, now: Instant ) -> bool
{
( self.from != self.to && now < self.start + SLIDE ) || ( self.shift_from != ( 0.0, 0.0 ) && now < self.shift_start + SLIDE )
}
fn retarget( &mut self, to: f32, now: Instant )
{
if ( self.to - to ).abs() < 0.5 { return; }
self.from = self.width( now );
self.to = to;
self.start = now;
self.full = to;
}
fn snap( &mut self, to: f32 )
{
self.from = to;
self.to = to;
self.full = to;
}
fn close( &mut self, now: Instant )
{
let width = self.width( now );
self.gone = true;
self.from = width;
self.to = 0.0;
self.start = now;
self.full = width;
}
fn slide( &mut self, by: ( f32, f32 ), now: Instant )
{
self.shift_from = by;
self.shift_start = now;
}
fn placed( &self, now: Instant ) -> Placed
{
let width = self.width( now ).max( 0.0 );
let presence = if self.full > 0.0 { ( width / self.full ).clamp( 0.0, 1.0 ) } else { 0.0 };
Placed { key: ( !self.gone ).then_some( self.key ), width, presence, shift: self.shift( now ) }
}
}
struct Drag
{
key: u64,
/// Where the tab was taken, from its top left corner.
grab: ( f32, f32 ),
pointer: ( f32, f32 ),
}
/// How the last layout arranged the slots.
#[ derive( Clone, Copy, Default ) ]
enum Shape
{
#[ default ]
Strip,
Grid { columns: usize, cell: ( f32, f32 ) },
}
#[ derive( Default ) ]
struct Motion
{
slots: Vec<Slot>,
keys: Vec<u64>,
/// The tabs keep their width until then after one closes.
hold: Option<Instant>,
/// Laid out once already: the first layout places the tabs without moving them.
settled: bool,
placed: Vec<Placed>,
drag: Option<Drag>,
/// The last layout: its rect, gap and shape, and the tabs as drawn.
rect: Rect,
gap: f32,
shape: Shape,
laid: Vec<Laid>,
}
/// The left edge of every slot from the strip's, each gap as wide as the slot before it is present.
fn offsets( placed: &[ Placed ], gap: f32 ) -> Vec<f32>
{
let mut x = 0.0;
placed.iter().enumerate().map( |( i, slot )|
{
if i > 0 { x += gap * placed[ i - 1 ].presence; }
let at = x;
x += slot.width;
at
} ).collect()
}
impl Motion
{
fn is_animating( &self, now: Instant ) -> bool
{
self.drag.is_some() || self.hold.is_some() || self.slots.iter().any( |slot| slot.moving( now ) )
}
fn arrange( &mut self, keys: &[ u64 ], width: f32, now: Instant ) -> Vec<Placed>
{
let changed = keys != self.keys.as_slice();
if changed
{
if self.reconcile( keys, now ) { self.hold = Some( now + CLOSE_HOLD ); }
self.keys = keys.to_vec();
}
self.slots.retain( |slot| !slot.gone || slot.moving( now ) );
let holding = self.hold.is_some_and( |until| now < until );
let release = self.hold.is_some() && !holding;
if release { self.hold = None; }
let held = self.slots.iter().find( |slot| !slot.gone && slot.to > 0.0 ).map_or( width, |slot| slot.to );
for slot in self.slots.iter_mut().filter( |slot| !slot.gone )
{
let fresh = slot.to == 0.0;
if !self.settled
{
slot.snap( width );
} else if fresh {
slot.retarget( if holding { held } else { width }, now );
} else if holding {
continue;
} else if changed || release || slot.moving( now ) {
slot.retarget( width, now );
} else {
slot.snap( width );
}
}
self.settled = true;
self.refresh( now );
self.placed.clone()
}
fn refresh( &mut self, now: Instant )
{
self.placed = self.slots.iter().map( |slot| slot.placed( now ) ).collect();
}
/// Close the slots whose key went, put the new keys after their neighbour; `true` when one closed.
fn reconcile( &mut self, keys: &[ u64 ], now: Instant ) -> bool
{
let mut closed = false;
for slot in self.slots.iter_mut().filter( |slot| !slot.gone && !keys.contains( &slot.key ) )
{
slot.close( now );
closed = true;
}
if self.drag.as_ref().is_some_and( |drag| !keys.contains( &drag.key ) ) { self.drag = None; }
let present: Vec<u64> = self.slots.iter().filter( |slot| !slot.gone ).map( |slot| slot.key ).collect();
if !keys.iter().filter( |key| present.contains( key ) ).eq( present.iter() )
{
self.slots.retain( |slot| !slot.gone );
self.slots.sort_by_key( |slot| keys.iter().position( |&key| key == slot.key ) );
}
for ( idx, &key ) in keys.iter().enumerate()
{
if self.slots.iter().any( |slot| !slot.gone && slot.key == key ) { continue; }
let at = match idx.checked_sub( 1 ).map( |before| keys[ before ] )
{
Some( before ) => self.slots.iter().position( |slot| !slot.gone && slot.key == before ).map_or( self.slots.len(), |at| at + 1 ),
None => 0,
};
self.slots.insert( at, Slot::new( key, now ) );
}
closed
}
/// Where every slot belongs, from the layout's top left corner.
fn natural( &self, placed: &[ Placed ] ) -> Vec<( f32, f32 )>
{
match self.shape
{
Shape::Strip => offsets( placed, self.gap ).into_iter().map( |x| ( x, 0.0 ) ).collect(),
Shape::Grid { columns, cell } =>
{
let mut n = 0;
placed.iter().map( |slot|
{
let at = ( ( n % columns ) as f32 * ( cell.0 + self.gap ), ( n / columns ) as f32 * ( cell.1 + self.gap ) );
if slot.key.is_some() { n += 1; }
at
} ).collect()
}
}
}
/// Every present slot at its place, sized by `size`; the dragged one last, under the pointer but within `bounds`.
fn place( &mut self, size: impl Fn( &Placed ) -> ( f32, f32 ), bounds: Rect ) -> Vec<Laid>
{
let natural = self.natural( &self.placed );
let mut laid = Vec::new();
let mut lifted = None;
for ( slot, at ) in self.placed.iter().zip( natural )
{
let Some( key ) = slot.key else { continue };
let ( width, height ) = size( slot );
match &self.drag
{
Some( drag ) if drag.key == key =>
{
let x = ( drag.pointer.0 - drag.grab.0 ).min( bounds.x + bounds.width - width ).max( bounds.x );
let y = ( drag.pointer.1 - drag.grab.1 ).min( bounds.y + bounds.height - height ).max( bounds.y );
lifted = Some( Laid { key, rect: Rect { x, y, width, height } } );
}
_ =>
{
let rect = Rect { x: self.rect.x + at.0 + slot.shift.0, y: self.rect.y + at.1 + slot.shift.1, width, height };
laid.push( Laid { key, rect } );
}
}
}
laid.extend( lifted );
self.laid = laid.clone();
laid
}
fn lay( &mut self, rect: Rect, gap: f32 ) -> ( Vec<Laid>, f32 )
{
self.rect = rect;
self.gap = gap;
self.shape = Shape::Strip;
let end = offsets( &self.placed, gap ).last().zip( self.placed.last() ).map_or( 0.0, |( at, slot )| at + slot.width );
let laid = self.place( |slot| ( slot.width, rect.height ), Rect { width: end, ..rect } );
( laid, rect.x + end )
}
/// The cards neither grow nor shrink: a closed one goes at once.
fn lay_cards( &mut self, keys: &[ u64 ], rect: Rect, columns: usize, cell: ( f32, f32 ), gap: f32, now: Instant ) -> Vec<Laid>
{
if keys != self.keys.as_slice()
{
self.reconcile( keys, now );
self.keys = keys.to_vec();
}
self.slots.retain( |slot| !slot.gone );
self.rect = rect;
self.gap = gap;
self.shape = Shape::Grid { columns, cell };
// Cards have no width, so the strip must not grow its tabs from them.
self.settled = false;
self.refresh( now );
let rows = keys.len().div_ceil( columns ) as f32;
let height = rows * cell.1 + ( rows - 1.0 ).max( 0.0 ) * gap;
self.place( |_| cell, Rect { height, ..rect } )
}
fn drag_move( &mut self, x: f32, y: f32, now: Instant ) -> bool
{
if self.drag.is_none()
{
let hit = self.laid.iter().find( |laid| x >= laid.rect.x && x < laid.rect.x + laid.rect.width && y >= laid.rect.y && y < laid.rect.y + laid.rect.height );
let Some( hit ) = hit else { return false };
self.drag = Some( Drag { key: hit.key, grab: ( x - hit.rect.x, y - hit.rect.y ), pointer: ( x, y ) } );
}
let Some( drag ) = self.drag.as_mut() else { return false };
drag.pointer = ( x, y );
let ( key, at ) = ( drag.key, ( x - drag.grab.0, y - drag.grab.1 ) );
self.follow( key, at, now );
true
}
/// Move the dragged slot to where it is now, its top left corner at `at`, and slide the others out of its way.
fn follow( &mut self, key: u64, at: ( f32, f32 ), now: Instant )
{
let Some( from ) = self.slots.iter().position( |slot| !slot.gone && slot.key == key ) else { return };
let placed = self.slots.iter().map( |slot| slot.placed( now ) ).collect::<Vec<_>>();
let before = self.natural( &placed );
let to = self.target( from, at, &placed, &before );
if to == from { return; }
let was: Vec<( u64, ( f32, f32 ) )> = self.slots.iter().zip( before.iter().zip( &placed ) )
.map( |( slot, ( at, p ) )| ( slot.key, ( at.0 + p.shift.0, at.1 + p.shift.1 ) ) )
.collect();
let slot = self.slots.remove( from );
self.slots.insert( to, slot );
let after = self.natural( &self.slots.iter().map( |slot| slot.placed( now ) ).collect::<Vec<_>>() );
for ( slot, at ) in self.slots.iter_mut().zip( after )
{
if slot.key == key || slot.gone { continue; }
if let Some( &( _, old ) ) = was.iter().find( |( k, _ )| *k == slot.key ) { slot.slide( ( old.0 - at.0, old.1 - at.1 ), now ); }
}
self.refresh( now );
}
/// The index among the slots but `from` that the dragged slot goes to.
fn target( &self, from: usize, at: ( f32, f32 ), placed: &[ Placed ], natural: &[ ( f32, f32 ) ] ) -> usize
{
match self.shape
{
Shape::Strip =>
{
let centre = at.0 - self.rect.x + placed[ from ].width / 2.0;
let mut to = 0;
for ( j, i ) in ( 0..placed.len() ).filter( |&i| i != from ).enumerate()
{
if placed[ i ].key.is_some() && natural[ i ].0 + placed[ i ].width / 2.0 < centre { to = j + 1; }
}
to
}
Shape::Grid { columns, cell } =>
{
let rows = placed.len().div_ceil( columns );
let column = ( ( at.0 - self.rect.x + cell.0 / 2.0 ) / ( cell.0 + self.gap ) ).floor().clamp( 0.0, ( columns - 1 ) as f32 ) as usize;
let row = ( ( at.1 - self.rect.y + cell.1 / 2.0 ) / ( cell.1 + self.gap ) ).floor().clamp( 0.0, rows.saturating_sub( 1 ) as f32 ) as usize;
( row * columns + column ).min( placed.len() - 1 )
}
}
}
fn drag_end( &mut self, now: Instant ) -> Option<( usize, usize )>
{
let drag = self.drag.take()?;
let placed = self.slots.iter().map( |slot| slot.placed( now ) ).collect::<Vec<_>>();
let natural = self.natural( &placed );
let shown = self.laid.iter().find( |laid| laid.key == drag.key ).map( |laid| laid.rect );
let origin = ( self.rect.x, self.rect.y );
if let Some( ( slot, at ) ) = self.slots.iter_mut().zip( natural ).find( |( slot, _ )| !slot.gone && slot.key == drag.key )
{
if let Some( rect ) = shown { slot.slide( ( rect.x - origin.0 - at.0, rect.y - origin.1 - at.1 ), now ); }
}
self.refresh( now );
let to = self.slots.iter().filter( |slot| !slot.gone ).position( |slot| slot.key == drag.key )?;
let from = self.keys.iter().position( |&key| key == drag.key )?;
let key = self.keys.remove( from );
self.keys.insert( to, key );
( from != to ).then_some( ( from, to ) )
}
}
#[ cfg( test ) ]
mod tests
{
use std::time::Duration;
use super::*;
fn widths( placed: &[ Placed ] ) -> Vec<( Option<u64>, f32 )>
{
placed.iter().map( |p| ( p.key, p.width.round() ) ).collect()
}
#[ test ]
fn the_first_layout_does_not_move()
{
let mut motion = Motion::default();
let now = Instant::now();
assert_eq!( widths( &motion.arrange( &[ 1, 2 ], 100.0, now ) ), [ ( Some( 1 ), 100.0 ), ( Some( 2 ), 100.0 ) ] );
assert!( !motion.is_animating( now ) );
}
#[ test ]
fn a_new_tab_grows_and_the_others_narrow()
{
let mut motion = Motion::default();
let start = Instant::now();
motion.arrange( &[ 1 ], 200.0, start );
let placed = motion.arrange( &[ 1, 2 ], 100.0, start );
assert_eq!( widths( &placed ), [ ( Some( 1 ), 200.0 ), ( Some( 2 ), 0.0 ) ] );
assert!( motion.is_animating( start ) );
let end = start + SLIDE;
assert_eq!( widths( &motion.arrange( &[ 1, 2 ], 100.0, end ) ), [ ( Some( 1 ), 100.0 ), ( Some( 2 ), 100.0 ) ] );
}
#[ test ]
fn a_closed_tab_leaves_a_shrinking_gap_and_the_others_wait()
{
let mut motion = Motion::default();
let start = Instant::now();
motion.arrange( &[ 1, 2, 3 ], 100.0, start );
let placed = motion.arrange( &[ 1, 3 ], 150.0, start );
assert_eq!( widths( &placed ), [ ( Some( 1 ), 100.0 ), ( None, 100.0 ), ( Some( 3 ), 100.0 ) ] );
let gone = start + SLIDE;
assert_eq!( widths( &motion.arrange( &[ 1, 3 ], 150.0, gone ) ), [ ( Some( 1 ), 100.0 ), ( Some( 3 ), 100.0 ) ] );
assert!( motion.is_animating( gone ) );
let released = start + CLOSE_HOLD;
motion.arrange( &[ 1, 3 ], 150.0, released );
let after = released + SLIDE;
assert_eq!( widths( &motion.arrange( &[ 1, 3 ], 150.0, after ) ), [ ( Some( 1 ), 150.0 ), ( Some( 3 ), 150.0 ) ] );
assert!( !motion.is_animating( after + Duration::from_millis( 1 ) ) );
}
#[ test ]
fn another_close_restarts_the_wait()
{
let mut motion = Motion::default();
let start = Instant::now();
motion.arrange( &[ 1, 2, 3 ], 100.0, start );
motion.arrange( &[ 1, 3 ], 150.0, start );
let second = start + Duration::from_millis( 300 );
motion.arrange( &[ 1 ], 300.0, second );
let still = start + CLOSE_HOLD + SLIDE;
assert_eq!( motion.arrange( &[ 1 ], 300.0, still ).iter().find( |p| p.key == Some( 1 ) ).map( |p| p.width.round() ), Some( 100.0 ) );
}
#[ test ]
fn a_resize_alone_does_not_animate()
{
let mut motion = Motion::default();
let now = Instant::now();
motion.arrange( &[ 1, 2 ], 100.0, now );
assert_eq!( widths( &motion.arrange( &[ 1, 2 ], 120.0, now ) ), [ ( Some( 1 ), 120.0 ), ( Some( 2 ), 120.0 ) ] );
assert!( !motion.is_animating( now ) );
}
#[ test ]
fn a_dragged_tab_moves_where_its_centre_goes()
{
let mut motion = Motion::default();
let now = Instant::now();
let strip = Rect { x: 0.0, y: 0.0, width: 400.0, height: 30.0 };
motion.arrange( &[ 1, 2, 3 ], 100.0, now );
motion.lay( strip, 0.0 );
assert!( !motion.drag_move( 50.0, 40.0, now ) );
assert!( motion.drag_move( 50.0, 10.0, now ) );
assert!( motion.drag_move( 260.0, 10.0, now ) );
let order: Vec<u64> = motion.lay( strip, 0.0 ).0.iter().map( |laid| laid.key ).collect();
assert_eq!( order, [ 2, 3, 1 ] );
assert_eq!( motion.drag_end( now ), Some( ( 0, 2 ) ) );
assert!( motion.arrange( &[ 2, 3, 1 ], 100.0, now ).iter().all( |p| p.width == 100.0 ) );
}
#[ test ]
fn cards_fill_the_rows_in_order()
{
let mut motion = Motion::default();
let laid = motion.lay_cards( &[ 1, 2, 3 ], Rect { x: 0.0, y: 0.0, width: 210.0, height: 0.0 }, 2, ( 100.0, 80.0 ), 10.0, Instant::now() );
let corners: Vec<( u64, f32, f32 )> = laid.iter().map( |laid| ( laid.key, laid.rect.x, laid.rect.y ) ).collect();
assert_eq!( corners, [ ( 1, 0.0, 0.0 ), ( 2, 110.0, 0.0 ), ( 3, 0.0, 90.0 ) ] );
}
#[ test ]
fn a_dragged_card_takes_the_cell_under_its_centre()
{
let mut motion = Motion::default();
let now = Instant::now();
let grid = Rect { x: 0.0, y: 0.0, width: 210.0, height: 0.0 };
motion.lay_cards( &[ 1, 2, 3, 4 ], grid, 2, ( 100.0, 80.0 ), 10.0, now );
assert!( motion.drag_move( 20.0, 20.0, now ) );
assert!( motion.drag_move( 130.0, 110.0, now ) );
let order: Vec<u64> = motion.lay_cards( &[ 1, 2, 3, 4 ], grid, 2, ( 100.0, 80.0 ), 10.0, now ).iter().map( |laid| laid.key ).collect();
assert_eq!( order, [ 2, 3, 4, 1 ] );
assert_eq!( motion.drag_end( now ), Some( ( 0, 3 ) ) );
}
}
+213
View File
@@ -0,0 +1,213 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The phone's view of every open tab: a grid of cards, under a header with a way back.
use std::sync::Arc;
use crate::layout::column::column;
use crate::layout::row::row;
use crate::layout::spacer::spacer;
use crate::layout::stack::{ stack, HAlign, VAlign };
use crate::types::Length;
use crate::widget::pressable::pressable;
use crate::widget::scroll::scroll;
use crate::widget::separator::separator;
use crate::widget::{ container, text, Element };
use super::theme::{ BACK_ICON, BODY_WIDTH, BUTTON_ICON, BUTTON_SIZE, CARD_RADIUS, COLUMNS, EDGE_WIDTH };
use super::cards::TabCards;
use super::{ parts, IndexMsg, TabItem, TabMotion, TabStripColors };
/// In viewport width, capped: a card comes out about square on a phone and stops growing on a wide window.
fn card_margin() -> Length { Length::vw( 8.0 ).at_most( 60.0 ) }
fn icon_size() -> Length { Length::vw( 14.0 ).at_most( 96.0 ) }
pub struct TabOverview<Msg: Clone>
{
pub( crate ) tabs: Vec<TabItem>,
pub( crate ) selected: usize,
pub( crate ) on_select: Option<IndexMsg<Msg>>,
pub( crate ) on_close: Option<IndexMsg<Msg>>,
pub( crate ) reorder: bool,
pub( crate ) motion: TabMotion,
pub( crate ) title: String,
pub( crate ) back: Option<Msg>,
pub( crate ) actions: Vec<Element<Msg>>,
pub( crate ) colors: TabStripColors,
}
impl<Msg: Clone + 'static> TabOverview<Msg>
{
pub fn new( tabs: impl IntoIterator<Item = TabItem> ) -> Self
{
Self
{
tabs: tabs.into_iter().collect(),
selected: 0,
on_select: None,
on_close: None,
reorder: false,
motion: TabMotion::default(),
title: String::new(),
back: None,
actions: Vec::new(),
colors: TabStripColors::default(),
}
}
pub fn selected( mut self, idx: usize ) -> Self
{
self.selected = idx;
self
}
pub fn on_select( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_select = Some( Arc::new( f ) );
self
}
pub fn on_close( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self
{
self.on_close = Some( Arc::new( f ) );
self
}
/// Whether the cards can be dragged into another order, as [`TabStrip::reorderable`](super::TabStrip::reorderable). Default: `false`.
pub fn reorderable( mut self, reorderable: bool ) -> Self
{
self.reorder = reorderable;
self
}
/// The state a drag goes through; the strip's may be handed here too.
pub fn motion( mut self, motion: &TabMotion ) -> Self
{
self.motion = motion.clone();
self
}
pub fn title( mut self, title: impl Into<String> ) -> Self
{
self.title = title.into();
self
}
/// The message of the back button at the header's left; without it there is none.
pub fn back( mut self, msg: Msg ) -> Self
{
self.back = Some( msg );
self
}
/// A button at the header's right, such as a new tab button.
pub fn action( mut self, button: impl Into<Element<Msg>> ) -> Self
{
self.actions.push( button.into() );
self
}
pub fn colors( mut self, colors: TabStripColors ) -> Self
{
self.colors = colors;
self
}
/// The whole page: header, then the cards scrolling under it.
pub fn build( mut self ) -> Element<Msg>
{
let colors = self.colors;
let header = self.header();
let body = column()
.padding( Length::widget( 12.0 ) )
.spacing( Length::widget( 4.0 ) )
.max_width( Length::widget( BODY_WIDTH ) )
.push( self.cards() );
let page = column()
.padding( 0.0 )
.spacing( 0.0 )
.push( header )
.push( separator().pad_v( 0.0 ) )
.push( scroll( body ) );
container( page ).background( colors.strip ).into()
}
/// The grid of cards alone, for an app that lays the page out itself.
pub fn cards( self ) -> Element<Msg>
{
let cards = self.tabs.iter().enumerate()
.map( |( idx, tab )| ( tab.key, card( tab, idx, idx == self.selected, self.reorder, &self.colors, self.on_select.as_ref(), self.on_close.as_ref() ) ) )
.collect();
TabCards { cards, columns: COLUMNS, spacing: Length::widget( 12.0 ), motion: self.motion }.into()
}
/// Takes the header's parts, leaving the tabs for the cards.
fn header( &mut self ) -> Element<Msg>
{
let ink = self.colors.ink;
let mut top = row().padding( Length::widget( 8.0 ) ).spacing( Length::widget( 8.0 ) );
if let Some( msg ) = self.back.take()
{
top = top.push( parts::symbol_button( BACK_ICON, "<", msg, ink, BUTTON_SIZE, BUTTON_ICON ) );
}
top = top.push( text( std::mem::take( &mut self.title ) ).size( 20.0 ).weight( 600 ).color( ink ) ).push( spacer() );
for button in std::mem::take( &mut self.actions )
{
top = top.push( button );
}
top.into()
}
}
impl<Msg: Clone + 'static> From<TabOverview<Msg>> for Element<Msg>
{
fn from( overview: TabOverview<Msg> ) -> Self
{
overview.build()
}
}
/// Create a [`TabOverview`] of `tabs`.
pub fn tab_overview<Msg: Clone + 'static>( tabs: impl IntoIterator<Item = TabItem> ) -> TabOverview<Msg>
{
TabOverview::new( tabs )
}
fn card<Msg: Clone + 'static>(
tab: &TabItem,
idx: usize,
active: bool,
draggable: bool,
colors: &TabStripColors,
on_select: Option<&IndexMsg<Msg>>,
on_close: Option<&IndexMsg<Msg>>,
) -> Element<Msg>
{
let ( fill, edge, ink ) = colors.of( active );
let mut content = column()
.padding( Length::widget( 12.0 ) )
.spacing( Length::widget( 10.0 ) )
.push( spacer().height( card_margin() ) );
if let Some( icon ) = parts::icon( tab, icon_size(), ink )
{
content = content.push( icon );
}
content = content
.push( text( tab.label.clone() ).size( 13.0 ).color( ink ).align_center() )
.push( spacer().height( card_margin() ) );
let face = container( content ).background( fill ).border( edge, EDGE_WIDTH ).radius( CARD_RADIUS );
let mut press = pressable( face );
if let Some( select ) = on_select
{
press = press.on_press( select( idx ) );
if draggable { press = press.on_drag_start( select( idx ) ); }
}
let mut card = stack().push( press );
if let Some( close ) = on_close.filter( |_| tab.closable )
{
card = card.push_aligned_margin( parts::close( close( idx ), ink ), HAlign::End, VAlign::Top, 4.0 );
}
card.into()
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Color, Length };
use crate::widget::button::ButtonVariant;
use crate::widget::{ button, icon_button, image, Element };
use super::icons;
use super::theme::{ CLOSE_ICON, CLOSE_ICON_SIZE, CLOSE_SIZE };
use super::{ TabIcon, TabItem };
/// The tab's icon at `size`, a symbolic one in `ink`.
pub( super ) fn icon<Msg: Clone>( tab: &TabItem, size: Length, ink: Color ) -> Option<Element<Msg>>
{
let ( rgba, w, h ) = match tab.icon.as_ref()?
{
TabIcon::Image( data ) => data.clone(),
TabIcon::Symbol( name ) => icons::tinted( name, ink )?,
};
Some( image( rgba, w, h ).size( size, size ).into() )
}
pub( super ) fn close<Msg: Clone>( msg: Msg, ink: Color ) -> Element<Msg>
{
symbol_button( CLOSE_ICON, "x", msg, ink, CLOSE_SIZE, CLOSE_ICON_SIZE )
}
/// A borderless button with a symbolic icon in `ink`, or `fallback` as text when the theme lacks the icon.
pub( super ) fn symbol_button<Msg: Clone>( name: &str, fallback: &str, msg: Msg, ink: Color, size: f32, icon_size: f32 ) -> Element<Msg>
{
let base = match icons::tinted( name, ink )
{
Some( ( rgba, w, h ) ) => icon_button( rgba, w, h ).icon_size( Length::widget( icon_size ) ),
None => button( fallback ),
};
base.variant( ButtonVariant::Tertiary )
.width( Length::widget( size ) )
.height( Length::widget( size ) )
.on_press( msg )
.into()
}
+161
View File
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The two layout elements behind the desktop strip. A horizontal scroll measures its child with no width limit, so the tabs
//! inside it cannot see how much room there is: [`TabRow`] sits outside the scroll, works the tab width out of its own rect and
//! hands it to the [`TabMotion`], and [`TabCells`], inside the scroll, places the tabs at the widths the motion reports.
use crate::render::Canvas;
use crate::types::{ Length, Rect };
use crate::widget::Element;
use super::motion::{ Placed, TabMotion };
use super::theme::{ GAP, MAX_TAB_FRACTION, MIN_TAB_WIDTH, RESERVE };
pub struct TabRow<Msg: Clone>
{
/// A horizontal scroll over a [`TabCells`].
pub( crate ) child: Box<Element<Msg>>,
pub( crate ) keys: Vec<u64>,
pub( crate ) motion: TabMotion,
}
impl<Msg: Clone> TabRow<Msg>
{
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
( max_width, self.child.preferred_size( max_width, canvas ).1 )
}
/// Settle the tab width for `rect` and return the rect the tabs scroll in, short of the space kept clear on the right.
pub( crate ) fn arrange( &self, rect: Rect, canvas: &Canvas ) -> Rect
{
let room = rect.width * ( 1.0 - RESERVE );
let gap = canvas.resolve_geom( Length::widget( GAP ) );
let after = self.cells().map_or( 0.0, |cells| cells.after_width( canvas, gap ) );
let count = self.keys.len();
let spare = room - after - gap * count.saturating_sub( 1 ) as f32;
let screen = crate::types::output_logical_size().map_or( canvas.size().0 as f32, |size| size.0 as f32 * canvas.dpi_scale() );
let widest = screen * MAX_TAB_FRACTION;
let least = canvas.resolve_geom( Length::widget( MIN_TAB_WIDTH ) );
let width = if count == 0 { 0.0 } else { ( spare / count as f32 ).min( widest ).max( least ) };
self.motion.arrange( &self.keys, width );
Rect { width: room.max( 0.0 ), ..rect }
}
fn cells( &self ) -> Option<&TabCells<Msg>>
{
match self.child.as_ref()
{
Element::Scroll( scroll ) => match scroll.child.as_ref()
{
Element::TabCells( cells ) => Some( cells ),
_ => None,
},
_ => None,
}
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> TabRow<U>
where
U: Clone + 'static,
Msg: 'static,
{
TabRow { child: Box::new( self.child.map_arc( f ) ), keys: self.keys, motion: self.motion }
}
}
pub struct TabCells<Msg: Clone>
{
/// The tab handles by key, each clipped to the width it gets.
pub( crate ) tabs: Vec<( u64, Element<Msg> )>,
/// Buttons right after the last tab.
pub( crate ) after: Vec<Element<Msg>>,
pub( crate ) motion: TabMotion,
}
impl<Msg: Clone> TabCells<Msg>
{
pub( crate ) fn child( &self, idx: usize ) -> &Element<Msg>
{
match self.tabs.get( idx )
{
Some( ( _, tab ) ) => tab,
None => &self.after[ idx - self.tabs.len() ],
}
}
/// The buttons after the tabs, with the gap before each.
pub( crate ) fn after_width( &self, canvas: &Canvas, gap: f32 ) -> f32
{
self.after.iter().map( |button| gap + button.preferred_size( f32::INFINITY, canvas ).0 ).sum()
}
pub fn preferred_size( &self, _max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
let gap = canvas.resolve_geom( Length::widget( GAP ) );
let placed = self.motion.placed();
let tabs = placed.iter().map( |slot| slot.width ).sum::<f32>() + gaps( &placed, gap );
let height = self.tabs.iter().map( |( _, tab )| tab ).chain( &self.after )
.map( |child| child.preferred_size( f32::INFINITY, canvas ).1 )
.fold( 0.0_f32, f32::max );
( tabs + self.after_width( canvas, gap ), height )
}
/// Tabs at full height, the dragged one last so it is drawn on top, then the buttons centred on the tabs.
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<( Rect, usize )>
{
let gap = canvas.resolve_geom( Length::widget( GAP ) );
let ( laid, end ) = self.motion.lay( rect, gap );
let mut rects: Vec<_> = laid.iter()
.filter_map( |laid| self.tabs.iter().position( |( key, _ )| *key == laid.key )
.map( |idx| ( laid.rect, idx ) ) )
.collect();
let mut x = end;
for ( i, button ) in self.after.iter().enumerate()
{
let ( width, height ) = button.preferred_size( f32::INFINITY, canvas );
x += gap;
rects.push( ( Rect { x, y: rect.y + ( rect.height - height ) / 2.0, width, height }, self.tabs.len() + i ) );
x += width;
}
rects
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> TabCells<U>
where
U: Clone + 'static,
Msg: 'static,
{
TabCells
{
tabs: self.tabs.into_iter().map( |( key, tab )| ( key, tab.map_arc( f ) ) ).collect(),
after: self.after.into_iter().map( |button| button.map_arc( f ) ).collect(),
motion: self.motion,
}
}
}
/// The gaps between the slots, each as wide as the slot before it is present, so a closing tab takes its gap with it.
fn gaps( placed: &[ Placed ], gap: f32 ) -> f32
{
let all: f32 = placed.iter().map( |slot| gap * slot.presence ).sum();
let last = placed.last().map_or( 0.0, |slot| gap * slot.presence );
all - last
}
impl<Msg: Clone> From<TabRow<Msg>> for Element<Msg>
{
fn from( row: TabRow<Msg> ) -> Self
{
Element::TabRow( row )
}
}
impl<Msg: Clone> From<TabCells<Msg>> for Element<Msg>
{
fn from( cells: TabCells<Msg> ) -> Self
{
Element::TabCells( cells )
}
}
+170
View File
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::cards::TabCards;
use super::row::{ TabCells, TabRow };
use super::theme::{ GAP, MAX_TAB_FRACTION, MIN_TAB_WIDTH, RESERVE };
use super::*;
use crate::layout::spacer::spacer;
use crate::render::Canvas;
use crate::types::{ Length, Rect };
use crate::widget::scroll::scroll;
const SCREEN: u32 = 1000;
fn canvas() -> Canvas { Canvas::new( SCREEN, 100 ) }
fn strip( count: u64, motion: &TabMotion, after: Vec<Element<()>> ) -> TabRow<()>
{
let tabs = ( 1..=count ).map( |key| ( key, spacer().into() ) ).collect::<Vec<_>>();
let keys = tabs.iter().map( |( key, _ )| *key ).collect();
let cells = TabCells { tabs, after, motion: motion.clone() };
TabRow { child: Box::new( scroll( cells ).horizontal().into() ), keys, motion: motion.clone() }
}
fn cells( row: &TabRow<()> ) -> &TabCells<()>
{
match row.child.as_ref()
{
Element::Scroll( scroll ) => match scroll.child.as_ref()
{
Element::TabCells( cells ) => cells,
_ => unreachable!(),
},
_ => unreachable!(),
}
}
/// The rects of the tabs and buttons in a bar as wide as the screen.
fn lay_out( row: &TabRow<()>, canvas: &Canvas ) -> ( Rect, Vec<( Rect, usize )> )
{
let inner = row.arrange( bar(), canvas );
( inner, cells( row ).layout( inner, canvas ) )
}
fn bar() -> Rect
{
Rect { x: 0.0, y: 0.0, width: SCREEN as f32, height: 40.0 }
}
#[ test ]
fn the_right_of_the_strip_stays_clear()
{
let motion = TabMotion::new();
let row = strip( 3, &motion, Vec::new() );
let inner = row.arrange( bar(), &canvas() );
assert!( ( inner.width - SCREEN as f32 * ( 1.0 - RESERVE ) ).abs() < 0.01 );
}
#[ test ]
fn few_tabs_stop_at_a_fifth_of_the_screen()
{
let motion = TabMotion::new();
let ( _, rects ) = lay_out( &strip( 2, &motion, Vec::new() ), &canvas() );
assert_eq!( rects.len(), 2 );
assert!( rects.iter().all( |( rect, _ )| ( rect.width - SCREEN as f32 * MAX_TAB_FRACTION ).abs() < 0.01 ) );
}
#[ test ]
fn many_tabs_keep_their_minimum()
{
let motion = TabMotion::new();
let canvas = canvas();
let least = canvas.resolve_geom( Length::widget( MIN_TAB_WIDTH ) );
let ( _, rects ) = lay_out( &strip( 30, &motion, Vec::new() ), &canvas );
assert!( rects.iter().all( |( rect, _ )| ( rect.width - least ).abs() < 0.01 ) );
}
#[ test ]
fn tabs_follow_one_another_and_the_buttons_follow_the_last()
{
let motion = TabMotion::new();
let canvas = canvas();
let gap = canvas.resolve_geom( Length::widget( GAP ) );
let button = spacer().width( Length::widget( 40.0 ) ).into();
let ( _, rects ) = lay_out( &strip( 3, &motion, vec![ button ] ), &canvas );
assert_eq!( rects.iter().map( |( _, idx )| *idx ).collect::<Vec<_>>(), [ 0, 1, 2, 3 ] );
for pair in rects.windows( 2 )
{
assert!( ( pair[ 1 ].0.x - ( pair[ 0 ].0.x + pair[ 0 ].0.width + gap ) ).abs() < 0.01 );
}
}
#[ test ]
fn the_buttons_after_the_tabs_take_their_room_from_the_tabs()
{
let canvas = canvas();
let button = spacer().width( Length::widget( 300.0 ) ).into();
let wide = lay_out( &strip( 4, &TabMotion::new(), Vec::new() ), &canvas ).1[ 0 ].0.width;
let narrow = lay_out( &strip( 4, &TabMotion::new(), vec![ button ] ), &canvas ).1[ 0 ].0.width;
assert!( narrow < wide );
}
#[ test ]
fn the_dragged_tab_is_drawn_last()
{
let motion = TabMotion::new();
let canvas = canvas();
let row = strip( 3, &motion, Vec::new() );
let ( inner, rects ) = lay_out( &row, &canvas );
let first = rects[ 0 ].0;
assert!( motion.drag_move( first.x + 5.0, first.y + 5.0 ) );
assert_eq!( cells( &row ).layout( inner, &canvas ).last().map( |( _, idx )| *idx ), Some( 0 ) );
assert_eq!( motion.drag_end(), None );
}
#[ test ]
fn a_drag_away_from_the_tabs_is_not_the_strips()
{
let motion = TabMotion::new();
lay_out( &strip( 3, &motion, Vec::new() ), &canvas() );
assert!( !motion.drag_move( 10.0, 500.0 ) );
assert!( !motion.drag_move( SCREEN as f32 - 1.0, 10.0 ) );
assert!( !motion.is_animating() );
}
#[ test ]
fn a_tab_is_closable_and_bare_by_default()
{
let item = tab( 7, "Shell" );
assert_eq!( ( item.key, item.label.as_str(), item.closable ), ( 7, "Shell", true ) );
assert!( item.icon.is_none() );
assert!( matches!( item.symbol( "features/console" ).closable( false ), TabItem { icon: Some( TabIcon::Symbol( _ ) ), closable: false, .. } ) );
}
#[ test ]
fn the_strip_defaults_to_a_fixed_desktop_strip()
{
let bar = tab_strip::<()>( [ tab( 1, "a" ), tab( 2, "b" ) ] );
assert_eq!( bar.tabs.len(), 2 );
assert_eq!( ( bar.selected, bar.layout, bar.reorderable ), ( 0, TabLayout::Desktop, false ) );
assert!( bar.on_select.is_none() && bar.on_close.is_none() && bar.on_overview.is_none() );
assert!( bar.reorderable( true ).layout( TabLayout::Mobile ).reorderable );
}
#[ test ]
fn colours_follow_the_state_of_the_tab()
{
let colors = TabStripColors::default();
assert_eq!( colors.of( true ), ( colors.active_fill, colors.active_edge, colors.active_ink ) );
assert_eq!( colors.of( false ), ( colors.idle_fill, colors.idle_edge, colors.idle_ink ) );
}
#[ test ]
fn the_overview_lays_its_cards_out_in_rows()
{
let canvas = canvas();
let gap = canvas.resolve_geom( Length::widget( 12.0 ) );
let cards = TabCards::<()>
{
cards: ( 1..=3 ).map( |key| ( key, spacer().height( Length::widget( 50.0 ) ).into() ) ).collect(),
columns: 2,
spacing: Length::widget( 12.0 ),
motion: TabMotion::new(),
};
let rects = cards.layout( Rect { x: 0.0, y: 0.0, width: 400.0, height: 0.0 }, &canvas );
assert_eq!( rects.iter().map( |( _, idx )| *idx ).collect::<Vec<_>>(), [ 0, 1, 2 ] );
assert!( ( rects[ 1 ].0.x - ( rects[ 0 ].0.width + gap ) ).abs() < 0.01 );
assert!( ( rects[ 2 ].0.y - ( rects[ 0 ].0.height + gap ) ).abs() < 0.01 );
assert!( ( cards.preferred_size( 400.0, &canvas ).1 - ( rects[ 0 ].0.height * 2.0 + gap ) ).abs() < 0.01 );
}
+34
View File
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::time::Duration;
/// Part of the width between the leading and trailing buttons kept clear of tabs and buttons.
pub( crate ) const RESERVE: f32 = 0.075;
/// Widest a tab gets, as a part of the screen's width.
pub( crate ) const MAX_TAB_FRACTION: f32 = 0.20;
pub( crate ) const MIN_TAB_WIDTH: f32 = 90.0;
pub( crate ) const GAP: f32 = 4.0;
pub( crate ) const SLIDE: Duration = Duration::from_millis( 180 );
/// How long the other tabs keep their width after one closes, so the next close button lands under the pointer.
pub( crate ) const CLOSE_HOLD: Duration = Duration::from_millis( 1000 );
pub( crate ) const LABEL_SIZE: f32 = 13.0;
pub( crate ) const ICON_SIZE: f32 = 16.0;
pub( crate ) const CLOSE_SIZE: f32 = 28.0;
pub( crate ) const CLOSE_ICON_SIZE: f32 = 16.0;
pub( crate ) const BUTTON_SIZE: f32 = 40.0;
pub( crate ) const BUTTON_ICON: f32 = 22.0;
pub( crate ) const TAB_RADIUS: f32 = 8.0;
pub( crate ) const EDGE_WIDTH: f32 = 1.5;
pub( crate ) const BAR_TITLE_SIZE: f32 = 16.0;
pub( crate ) const BAR_ICON_SIZE: f32 = 20.0;
pub( crate ) const COLUMNS: usize = 2;
pub( crate ) const CARD_RADIUS: f32 = 16.0;
pub( crate ) const BODY_WIDTH: f32 = 760.0;
pub( crate ) const CLOSE_ICON: &str = "navigation/close-small";
pub( crate ) const BACK_ICON: &str = "general/left";