From ce98c7b82c8f8588b4c8950da068cf9e432f8df6 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Thu, 15 Oct 2026 08:22:41 +0200 Subject: [PATCH] =?UTF-8?q?TabStrip,=20the=20open=20documents=20of=20an=20?= =?UTF-8?q?application=20as=20a=20browser=20or=20a=20terminal=20shows=20th?= =?UTF-8?q?em,=20with=20tabs=20that=20grow,=20shrink=20and=20can=20be=20dr?= =?UTF-8?q?agged,=20an=20overview=20of=20cards=20for=20a=20phone,=20a=20wi?= =?UTF-8?q?dget=20anchored=20under=20another=20one=20and=20list=20items=20?= =?UTF-8?q?in=20colours=20of=20their=20own=20Trust=20and=20prompt=20each?= =?UTF-8?q?=20drew=20their=20own=20strip=20of=20tabs,=20their=20own=20phon?= =?UTF-8?q?e=20bar=20with=20a=20counter=20and=20their=20own=20grid=20of=20?= =?UTF-8?q?cards,=20and=20neither=20animated=20anything=20nor=20let=20a=20?= =?UTF-8?q?tab=20move.=20`tab=5Fstrip`=20does=20all=20of=20it=20once,=20in?= =?UTF-8?q?=20colours=20the=20application=20chooses,=20so=20the=20same=20w?= =?UTF-8?q?idget=20serves=20a=20black=20terminal=20and=20a=20browser=20tha?= =?UTF-8?q?t=20follows=20the=20theme.=20Two=20things=20the=20menus=20of=20?= =?UTF-8?q?those=20applications=20needed=20come=20with=20it:=20a=20way=20t?= =?UTF-8?q?o=20place=20a=20menu=20under=20the=20button=20that=20opens=20it?= =?UTF-8?q?,=20and=20list=20items=20that=20are=20not=20drawn=20in=20the=20?= =?UTF-8?q?theme's=20colours.=20The=20widget.=20`tab=5Fstrip(=20tabs=20)`?= =?UTF-8?q?=20takes=20`TabItem`s,=20made=20with=20`tab(=20key,=20label=20)?= =?UTF-8?q?`,=20where=20the=20key=20stays=20the=20same=20while=20the=20tab?= =?UTF-8?q?=20is=20open,=20plus=20`.image(=20ImageData=20)`=20for=20a=20pa?= =?UTF-8?q?ge's=20icon,=20`.symbol(=20name=20)`=20for=20a=20theme=20icon?= =?UTF-8?q?=20drawn=20in=20the=20tab's=20ink,=20and=20`.closable(=20bool?= =?UTF-8?q?=20)`.=20The=20builder=20sets=20the=20`selected`=20tab,=20`on?= =?UTF-8?q?=5Fselect`,=20`on=5Fclose`,=20`on=5Foverview`,=20the=20buttons?= =?UTF-8?q?=20before=20the=20tabs=20(`leading`),=20right=20after=20the=20l?= =?UTF-8?q?ast=20one=20(`after=5Ftabs`)=20and=20at=20the=20far=20right=20(?= =?UTF-8?q?`trailing`),=20the=20phone=20bar's=20`mobile=5Fcenter`,=20the?= =?UTF-8?q?=20`TabStripColors`,=20the=20`TabLayout`,=20`reorderable`=20and?= =?UTF-8?q?=20the=20`TabMotion`.=20`TabStripColors`=20sets=20the=20strip's?= =?UTF-8?q?=20background=20and=20ink=20and=20the=20fill,=20edge=20and=20in?= =?UTF-8?q?k=20of=20the=20active=20tab=20and=20of=20the=20others;=20the=20?= =?UTF-8?q?default=20follows=20the=20theme.=20On=20a=20desktop.=20`[leadin?= =?UTF-8?q?g]=20[tabs=E2=80=A6][after=5Ftabs]=20=20clear=20=20[trailing]`.?= =?UTF-8?q?=20The=20tabs=20share=20the=20width=20between=2090=20widget=20p?= =?UTF-8?q?ixels=20and=20a=20fifth=20of=20the=20screen's=20width=20and=20s?= =?UTF-8?q?croll=20sideways=20when=20even=20the=20minimum=20does=20not=20f?= =?UTF-8?q?it,=20and=207.5=20%=20of=20the=20width=20between=20the=20leadin?= =?UTF-8?q?g=20and=20trailing=20buttons=20is=20always=20left=20clear,=20al?= =?UTF-8?q?so=20while=20they=20scroll.=20A=20horizontal=20scroll=20measure?= =?UTF-8?q?s=20its=20child=20with=20no=20width=20limit,=20so=20the=20tabs?= =?UTF-8?q?=20inside=20it=20cannot=20see=20how=20much=20room=20there=20is;?= =?UTF-8?q?=20two=20new=20elements=20solve=20that.=20`TabRow`,=20outside?= =?UTF-8?q?=20the=20scroll,=20works=20the=20tab=20width=20out=20of=20its?= =?UTF-8?q?=20own=20rect,=20less=20the=20clear=20part=20and=20the=20button?= =?UTF-8?q?s=20after=20the=20tabs,=20and=20hands=20it=20to=20the=20`TabMot?= =?UTF-8?q?ion`;=20`TabCells`,=20inside=20the=20scroll,=20places=20the=20t?= =?UTF-8?q?abs,=20the=20gaps=20of=20closing=20ones=20and=20the=20buttons?= =?UTF-8?q?=20where=20the=20motion=20says.=20Each=20tab=20is=20clipped=20b?= =?UTF-8?q?y=20a=20`viewport`,=20so=20it=20can=20grow=20from=20nothing=20a?= =?UTF-8?q?nd=20shrink=20away.=20On=20a=20phone.=20`TabLayout::Mobile`=20i?= =?UTF-8?q?s=20a=20bar=20with=20the=20leading=20buttons,=20the=20active=20?= =?UTF-8?q?tab's=20icon=20and=20title=20or=20the=20application's=20`mobile?= =?UTF-8?q?=5Fcenter`,=20a=20counter=20that=20sends=20`on=5Foverview`=20an?= =?UTF-8?q?d=20the=20trailing=20buttons.=20`tab=5Foverview(=20tabs=20)`=20?= =?UTF-8?q?is=20the=20grid=20of=20cards=20it=20opens,=20with=20a=20`title`?= =?UTF-8?q?,=20a=20`back`=20message=20and=20header=20`action`s:=20`build`?= =?UTF-8?q?=20gives=20the=20whole=20page,=20header=20and=20scrolling=20car?= =?UTF-8?q?ds,=20and=20`cards`=20the=20grid=20alone=20for=20an=20applicati?= =?UTF-8?q?on=20that=20lays=20its=20page=20out=20itself.=20The=20cards=20a?= =?UTF-8?q?re=20placed=20by=20a=20third=20new=20element,=20`TabCards`,=20e?= =?UTF-8?q?very=20one=20as=20tall=20as=20the=20tallest,=20so=20a=20dragged?= =?UTF-8?q?=20card=20can=20follow=20the=20finger.=20The=20motion.=20`TabMo?= =?UTF-8?q?tion`=20is=20the=20only=20state,=20kept=20by=20the=20applicatio?= =?UTF-8?q?n,=20handed=20to=20every=20view=20and=20reported=20from=20`App:?= =?UTF-8?q?:is=5Fanimating`;=20it=20is=20an=20`Arc`=20because=20`vi?= =?UTF-8?q?ew`=20only=20gets=20`&self`,=20and=20clones=20share=20it.=20A?= =?UTF-8?q?=20tab=20that=20opens=20grows=20from=20nothing=20while=20the=20?= =?UTF-8?q?others=20narrow,=20and=20one=20that=20closes=20leaves=20a=20gap?= =?UTF-8?q?=20that=20shrinks=20away=20while=20the=20others=20keep=20their?= =?UTF-8?q?=20width=20for=20a=20second,=20so=20the=20close=20button=20of?= =?UTF-8?q?=20the=20tab=20that=20slides=20in=20lands=20under=20the=20point?= =?UTF-8?q?er;=20another=20close=20starts=20the=20second=20again.=20Change?= =?UTF-8?q?s=20take=20180=20ms,=20ease-out.=20The=20first=20layout=20and?= =?UTF-8?q?=20a=20resize=20alone=20place=20the=20tabs=20without=20moving?= =?UTF-8?q?=20them.=20Reordering.=20With=20`reorderable(=20true=20)`=20eac?= =?UTF-8?q?h=20tab,=20and=20each=20card=20of=20the=20overview,=20carries?= =?UTF-8?q?=20its=20select=20message=20as=20`on=5Fdrag=5Fstart`,=20so=20it?= =?UTF-8?q?=20is=20dragged=20after=2024=20px=20of=20mouse=20motion=20or=20?= =?UTF-8?q?a=20long=20press,=20and=20dragging=20a=20tab=20selects=20it.=20?= =?UTF-8?q?The=20drag=20reaches=20the=20application,=20which=20forwards=20?= =?UTF-8?q?`App::on=5Fdrag=5Fmove`=20to=20`TabMotion::drag=5Fmove`=20and?= =?UTF-8?q?=20answers=20`App::on=5Fdrop`=20with=20the=20`(=20from,=20to=20?= =?UTF-8?q?)`=20that=20`TabMotion::drag=5Fend`=20reports.=20The=20motion?= =?UTF-8?q?=20remembers=20where=20it=20drew=20every=20tab:=20the=20first?= =?UTF-8?q?=20move=20takes=20the=20tab=20under=20it=20and=20returns=20`fal?= =?UTF-8?q?se`=20when=20there=20is=20none,=20the=20tab=20then=20follows=20?= =?UTF-8?q?the=20pointer=20within=20the=20tabs=20and=20is=20drawn=20on=20t?= =?UTF-8?q?op,=20and=20the=20others=20slide=20out=20of=20its=20way=20as=20?= =?UTF-8?q?its=20centre=20passes=20theirs,=20or=20as=20it=20enters=20anoth?= =?UTF-8?q?er=20cell=20of=20the=20overview.=20On=20release=20it=20slides?= =?UTF-8?q?=20into=20its=20place.=20A=20strip=20and=20its=20overview=20are?= =?UTF-8?q?=20never=20shown=20at=20once=20and=20may=20share=20one=20motion?= =?UTF-8?q?.=20Anchored.=20`AnchoredOverlay`,=20which=20only=20the=20combo?= =?UTF-8?q?=20used,=20is=20public=20as=20`anchored(=20child,=20id=20)`:=20?= =?UTF-8?q?it=20draws=20its=20child=20under=20the=20widget=20built=20with?= =?UTF-8?q?=20that=20`WidgetId`,=20as=20it=20was=20laid=20out=20on=20the?= =?UTF-8?q?=20previous=20frame.=20`align`=20lines=20it=20up=20with=20the?= =?UTF-8?q?=20anchor's=20left=20edge,=20its=20right=20edge=20or=20its=20mi?= =?UTF-8?q?ddle,=20`gap`=20sets=20the=20space=20under=20it,=20and=20the=20?= =?UTF-8?q?child=20is=20now=20kept=20within=20the=20rect=20its=20parent=20?= =?UTF-8?q?gives=20it,=20which=20also=20keeps=20a=20combo's=20list=20insid?= =?UTF-8?q?e=20the=20window.=20List=20items=20and=20icons.=20`ListItem::in?= =?UTF-8?q?k(=20label,=20secondary=20)`=20draws=20the=20label,=20the=20sub?= =?UTF-8?q?title=20and=20the=20trailing=20text=20in=20colours=20of=20the?= =?UTF-8?q?=20application's=20own=20and=20tints=20hover=20and=20press=20wi?= =?UTF-8?q?th=20the=20label's,=20for=20rows=20on=20a=20surface=20that=20is?= =?UTF-8?q?=20not=20the=20theme's.=20A=20button's=20icon=20is=20now=20fitt?= =?UTF-8?q?ed=20into=20its=20square=20without=20being=20stretched:=20a=20w?= =?UTF-8?q?ide=20icon=20such=20as=20`actions/remove`,=20a=2025=20by=204=20?= =?UTF-8?q?minus,=20was=20drawn=20as=20a=20block=20as=20tall=20as=20it=20w?= =?UTF-8?q?as=20wide.=20Documentation,=20example=20and=20tests.=20`docs/wi?= =?UTF-8?q?dgets.md`=20describes=20`tab=5Fstrip`=20and=20`anchored`=20and?= =?UTF-8?q?=20the=20new=20`ink`.=20`cargo=20run=20--example=20tab=5Fstrip`?= =?UTF-8?q?=20opens=20three=20documents=20that=20can=20be=20added,=20close?= =?UTF-8?q?d=20and=20dragged,=20on=20a=20desktop=20strip=20or=20a=20phone?= =?UTF-8?q?=20bar=20with=20its=20overview.=20The=20tests=20cover=20the=20s?= =?UTF-8?q?hare=20of=20the=20width,=20its=20limits=20and=20the=20clear=20p?= =?UTF-8?q?art,=20the=20buttons=20after=20the=20tabs,=20the=20drawing=20or?= =?UTF-8?q?der=20of=20a=20dragged=20tab,=20the=20builder's=20defaults,=20t?= =?UTF-8?q?he=20overview's=20rows,=20and=20in=20`motion.rs`=20the=20growth?= =?UTF-8?q?,=20the=20gap,=20the=20wait=20and=20its=20restart,=20a=20resize?= =?UTF-8?q?,=20and=20dragging=20a=20tab=20and=20a=20card;=20`anchored`=20a?= =?UTF-8?q?nd=20the=20icon=20fit=20have=20tests=20of=20their=20own.=20Not?= =?UTF-8?q?=20done.=20A=20strip=20that=20scrolls=20does=20not=20scroll=20t?= =?UTF-8?q?o=20the=20active=20tab=20or=20follow=20a=20tab=20dragged=20towa?= =?UTF-8?q?rds=20its=20edge,=20and=20the=20overview=20does=20not=20scroll?= =?UTF-8?q?=20while=20a=20card=20is=20dragged=20to=20the=20edge=20of=20the?= =?UTF-8?q?=20screen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/widgets.md | 55 ++- examples/tab_strip.rs | 174 ++++++++ src/draw/layout.rs | 27 +- src/lib.rs | 2 + src/widget/anchored_overlay/mod.rs | 38 ++ src/widget/anchored_overlay/tests.rs | 13 + src/widget/button/mod.rs | 22 +- src/widget/button/tests.rs | 12 + src/widget/element.rs | 14 +- src/widget/list_item/mod.rs | 29 +- src/widget/mod.rs | 1 + src/widget/tab_strip/cards.rs | 81 ++++ src/widget/tab_strip/desktop.rs | 81 ++++ src/widget/tab_strip/icons.rs | 34 ++ src/widget/tab_strip/mobile.rs | 61 +++ src/widget/tab_strip/mod.rs | 305 ++++++++++++++ src/widget/tab_strip/motion.rs | 592 +++++++++++++++++++++++++++ src/widget/tab_strip/overview.rs | 213 ++++++++++ src/widget/tab_strip/parts.rs | 41 ++ src/widget/tab_strip/row.rs | 161 ++++++++ src/widget/tab_strip/tests.rs | 170 ++++++++ src/widget/tab_strip/theme.rs | 34 ++ 22 files changed, 2143 insertions(+), 17 deletions(-) create mode 100644 examples/tab_strip.rs create mode 100644 src/widget/tab_strip/cards.rs create mode 100644 src/widget/tab_strip/desktop.rs create mode 100644 src/widget/tab_strip/icons.rs create mode 100644 src/widget/tab_strip/mobile.rs create mode 100644 src/widget/tab_strip/mod.rs create mode 100644 src/widget/tab_strip/motion.rs create mode 100644 src/widget/tab_strip/overview.rs create mode 100644 src/widget/tab_strip/parts.rs create mode 100644 src/widget/tab_strip/row.rs create mode 100644 src/widget/tab_strip/tests.rs create mode 100644 src/widget/tab_strip/theme.rs diff --git a/docs/widgets.md b/docs/widgets.md index 7f616ef..615e70b 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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`. 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, menu: Element ) -> Element { +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::( [ "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::() **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 ) -> Element { +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, diff --git a/examples/tab_strip.rs b/examples/tab_strip.rs new file mode 100644 index 0000000..82ff61a --- /dev/null +++ b/examples/tab_strip.rs @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `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, + 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 + '_ + { + self.tabs.iter().map( |&key| tab( key, format!( "Document {key}" ) ).symbol( "archives/document" ) ) + } + + fn switch_button( &self ) -> Element + { + 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 + { + button( "+" ).variant( ButtonVariant::Tertiary ).width( Length::widget( 40.0 ) ).on_press( Message::New ).into() + } + + fn page( &self ) -> Element + { + 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> { None } + fn restore_state( &mut self, _state: Vec ) {} + + fn view( &self ) -> Element + { + 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 + { + self.motion.drag_end().map( |( from, to )| Message::Move( from, to ) ) + } + + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym == Keysym::Escape { std::process::exit( 0 ); } + None + } +} + +fn main() +{ + ltk::run( TabStripApp::new() ); +} diff --git a/src/draw/layout.rs b/src/draw/layout.rs index 9f814d8..06d9e04 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -94,6 +94,31 @@ pub( crate ) fn layout_and_draw( } idx } + Element::TabRow( r ) => + { + let inner = r.arrange( rect, canvas ); + layout_and_draw::( 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::( 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::( 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( 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, diff --git a/src/lib.rs b/src/lib.rs index d03682b..9984e22 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 }; diff --git a/src/widget/anchored_overlay/mod.rs b/src/widget/anchored_overlay/mod.rs index d2d9beb..5c00778 100644 --- a/src/widget/anchored_overlay/mod.rs +++ b/src/widget/anchored_overlay/mod.rs @@ -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 /// 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 AnchoredOverlay @@ -53,9 +56,36 @@ impl AnchoredOverlay 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 AnchoredOverlay child: Box::new( self.child.map_arc( f ) ), anchor_id: self.anchor_id, gap: self.gap, + align: self.align, } } } @@ -99,3 +130,10 @@ impl From> for Element 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( child: impl Into>, anchor: WidgetId ) -> AnchoredOverlay +{ + AnchoredOverlay::new( child, anchor, 0.0 ) +} diff --git a/src/widget/anchored_overlay/tests.rs b/src/widget/anchored_overlay/tests.rs index e566a7a..ad583d5 100644 --- a/src/widget/anchored_overlay/tests.rs +++ b/src/widget/anchored_overlay/tests.rs @@ -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 ); +} diff --git a/src/widget/button/mod.rs b/src/widget/button/mod.rs index 8ff627f..e6342d4 100644 --- a/src/widget/button/mod.rs +++ b/src/widget/button/mod.rs @@ -505,14 +505,8 @@ impl Button { 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 Button 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 Button } } } + +/// 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 } +} diff --git a/src/widget/button/tests.rs b/src/widget/button/tests.rs index 4ea849d..0f14846 100644 --- a/src/widget/button/tests.rs +++ b/src/widget/button/tests.rs @@ -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 ) ); +} diff --git a/src/widget/element.rs b/src/widget/element.rs index d5f7f32..f5dc2e0 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -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 Spinner( spinner::Spinner ), External( external::External ), Carousel( carousel::Carousel ), + TabRow( tab_strip::TabRow ), + TabCells( tab_strip::TabCells ), + TabCards( tab_strip::TabCards ), } impl Element @@ -80,6 +83,9 @@ impl Element 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 Element 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 Element 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 ) ), } } } diff --git a/src/widget/list_item/mod.rs b/src/widget/list_item/mod.rs index c888dd5..ba1f0b5 100644 --- a/src/widget/list_item/mod.rs +++ b/src/widget/list_item/mod.rs @@ -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 pub( crate ) height: Option, /// Optional override of the primary-label font size. pub( crate ) font_size: Option, + /// 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 ListItem @@ -92,9 +94,18 @@ impl ListItem 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 ListItem } 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 ListItem pad_h: self.pad_h, height: self.height, font_size: self.font_size, + ink: self.ink, } } } diff --git a/src/widget/mod.rs b/src/widget/mod.rs index f0df29a..074ccea 100755 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -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; diff --git a/src/widget/tab_strip/cards.rs b/src/widget/tab_strip/cards.rs new file mode 100644 index 0000000..f14107f --- /dev/null +++ b/src/widget/tab_strip/cards.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 +{ + pub( crate ) cards: Vec<( u64, Element )>, + pub( crate ) columns: usize, + pub( crate ) spacing: Length, + pub( crate ) motion: TabMotion, +} + +impl TabCards +{ + pub( crate ) fn child( &self, idx: usize ) -> &Element + { + &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::>(); + 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( self, f: &crate::widget::MapFn ) -> TabCards + 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 From> for Element +{ + fn from( cards: TabCards ) -> Self + { + Element::TabCards( cards ) + } +} diff --git a/src/widget/tab_strip/desktop.rs b/src/widget/tab_strip/desktop.rs new file mode 100644 index 0000000..082eba3 --- /dev/null +++ b/src/widget/tab_strip/desktop.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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( strip: TabStrip ) -> Element +{ + 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( + tab: TabItem, + idx: usize, + active: bool, + draggable: bool, + colors: &TabStripColors, + on_select: Option<&IndexMsg>, + on_close: Option<&IndexMsg>, +) -> Element +{ + 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() +} diff --git a/src/widget/tab_strip/icons.rs b/src/widget/tab_strip/icons.rs new file mode 100644 index 0000000..5e98fbb --- /dev/null +++ b/src/widget/tab_strip/icons.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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>> = 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 +{ + 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 +} diff --git a/src/widget/tab_strip/mobile.rs b/src/widget/tab_strip/mobile.rs new file mode 100644 index 0000000..d61ba1f --- /dev/null +++ b/src/widget/tab_strip/mobile.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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( strip: TabStrip ) -> Element +{ + 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( tab: Option<&TabItem>, colors: &TabStripColors ) -> Element +{ + 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( count: usize, msg: Msg, ink: Color ) -> Element +{ + 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() +} diff --git a/src/widget/tab_strip/mod.rs b/src/widget/tab_strip/mod.rs new file mode 100644 index 0000000..c71251e --- /dev/null +++ b/src/widget/tab_strip/mod.rs @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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, + pub( crate ) closable: bool, +} + +impl TabItem +{ + pub fn new( key: u64, label: impl Into ) -> 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 ) -> 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 ) -> TabItem +{ + TabItem::new( key, label ) +} + +type IndexMsg = Arc 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 { +/// 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 +{ + pub( crate ) tabs: Vec, + pub( crate ) selected: usize, + pub( crate ) on_select: Option>, + pub( crate ) on_close: Option>, + pub( crate ) on_overview: Option, + pub( crate ) reorderable: bool, + pub( crate ) leading: Vec>, + pub( crate ) after: Vec>, + pub( crate ) trailing: Vec>, + pub( crate ) center: Option>, + pub( crate ) colors: TabStripColors, + pub( crate ) layout: TabLayout, + pub( crate ) motion: TabMotion, +} + +impl TabStrip +{ + pub fn new( tabs: impl IntoIterator ) -> 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> ) -> 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> ) -> 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> ) -> 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> ) -> 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 + { + match self.layout + { + TabLayout::Desktop => desktop::build( self ), + TabLayout::Mobile => mobile::build( self ), + } + } +} + +impl From> for Element +{ + fn from( strip: TabStrip ) -> Self + { + strip.build() + } +} + +/// Create a [`TabStrip`] of `tabs`. +pub fn tab_strip( tabs: impl IntoIterator ) -> TabStrip +{ + TabStrip::new( tabs ) +} diff --git a/src/widget/tab_strip/motion.rs b/src/widget/tab_strip/motion.rs new file mode 100644 index 0000000..cd4871e --- /dev/null +++ b/src/widget/tab_strip/motion.rs @@ -0,0 +1,592 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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> ); + +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 + { + self.lock().arrange( keys, width, Instant::now() ) + } + + /// What the last [`Self::arrange`] found. + pub( crate ) fn placed( &self ) -> Vec + { + 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, 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 + { + 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, + 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, + keys: Vec, + /// The tabs keep their width until then after one closes. + hold: Option, + /// Laid out once already: the first layout places the tabs without moving them. + settled: bool, + placed: Vec, + drag: Option, + /// The last layout: its rect, gap and shape, and the tabs as drawn. + rect: Rect, + gap: f32, + shape: Shape, + laid: Vec, +} + +/// 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 +{ + 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 + { + 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 = 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 + { + 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, 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 + { + 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::>(); + 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::>() ); + 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::>(); + 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, 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 = 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 = 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 ) ) ); + } +} diff --git a/src/widget/tab_strip/overview.rs b/src/widget/tab_strip/overview.rs new file mode 100644 index 0000000..480bd45 --- /dev/null +++ b/src/widget/tab_strip/overview.rs @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 +{ + pub( crate ) tabs: Vec, + pub( crate ) selected: usize, + pub( crate ) on_select: Option>, + pub( crate ) on_close: Option>, + pub( crate ) reorder: bool, + pub( crate ) motion: TabMotion, + pub( crate ) title: String, + pub( crate ) back: Option, + pub( crate ) actions: Vec>, + pub( crate ) colors: TabStripColors, +} + +impl TabOverview +{ + pub fn new( tabs: impl IntoIterator ) -> 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 ) -> 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> ) -> 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 + { + 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 + { + 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 + { + 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 From> for Element +{ + fn from( overview: TabOverview ) -> Self + { + overview.build() + } +} + +/// Create a [`TabOverview`] of `tabs`. +pub fn tab_overview( tabs: impl IntoIterator ) -> TabOverview +{ + TabOverview::new( tabs ) +} + +fn card( + tab: &TabItem, + idx: usize, + active: bool, + draggable: bool, + colors: &TabStripColors, + on_select: Option<&IndexMsg>, + on_close: Option<&IndexMsg>, +) -> Element +{ + 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() +} diff --git a/src/widget/tab_strip/parts.rs b/src/widget/tab_strip/parts.rs new file mode 100644 index 0000000..3d8b1b8 --- /dev/null +++ b/src/widget/tab_strip/parts.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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( tab: &TabItem, size: Length, ink: Color ) -> Option> +{ + 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: Msg, ink: Color ) -> Element +{ + 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( name: &str, fallback: &str, msg: Msg, ink: Color, size: f32, icon_size: f32 ) -> Element +{ + 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() +} diff --git a/src/widget/tab_strip/row.rs b/src/widget/tab_strip/row.rs new file mode 100644 index 0000000..d8aac4e --- /dev/null +++ b/src/widget/tab_strip/row.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 +{ + /// A horizontal scroll over a [`TabCells`]. + pub( crate ) child: Box>, + pub( crate ) keys: Vec, + pub( crate ) motion: TabMotion, +} + +impl TabRow +{ + 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> + { + 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( self, f: &crate::widget::MapFn ) -> TabRow + where + U: Clone + 'static, + Msg: 'static, + { + TabRow { child: Box::new( self.child.map_arc( f ) ), keys: self.keys, motion: self.motion } + } +} + +pub struct TabCells +{ + /// The tab handles by key, each clipped to the width it gets. + pub( crate ) tabs: Vec<( u64, Element )>, + /// Buttons right after the last tab. + pub( crate ) after: Vec>, + pub( crate ) motion: TabMotion, +} + +impl TabCells +{ + pub( crate ) fn child( &self, idx: usize ) -> &Element + { + 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::() + 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( self, f: &crate::widget::MapFn ) -> TabCells + 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 From> for Element +{ + fn from( row: TabRow ) -> Self + { + Element::TabRow( row ) + } +} + +impl From> for Element +{ + fn from( cells: TabCells ) -> Self + { + Element::TabCells( cells ) + } +} diff --git a/src/widget/tab_strip/tests.rs b/src/widget/tab_strip/tests.rs new file mode 100644 index 0000000..34a6382 --- /dev/null +++ b/src/widget/tab_strip/tests.rs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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> ) -> TabRow<()> +{ + let tabs = ( 1..=count ).map( |key| ( key, spacer().into() ) ).collect::>(); + 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::>(), [ 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::>(), [ 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 ); +} diff --git a/src/widget/tab_strip/theme.rs b/src/widget/tab_strip/theme.rs new file mode 100644 index 0000000..3d8046e --- /dev/null +++ b/src/widget/tab_strip/theme.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +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";