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,