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
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:
@@ -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() );
|
||||
}
|
||||
Reference in New Issue
Block a user