A tab strip that scrolls to the selected tab and while a tab is dragged against its edge, and hooks for every mouse button and for the modifiers, for an application that hands the mouse on
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

A strip with more tabs than fit left the selected one wherever it was, so a tab opened at the end or chosen with the keyboard could stay out of sight, and a tab could only be dragged as far as the visible part reached. A terminal that passes the mouse to the program in it, as xterm does, also needs every click with its modifiers, which ltk kept to itself apart from the middle button, and the modifiers held while the wheel turns, which the wheel does not carry.
Scrolling to the selected tab. `TabRow` now knows the key of the selected tab, and in the layout it asks the `TabMotion` where the scroll of the tabs should go before drawing it; the answer is written into the scroll's own offset, which is keyed by the same index. When the selection changes the strip scrolls just enough to show that tab, through the scroll's `offset_into_view`, and keeps following it while it grows from nothing, so a new tab at the end is shown whole once it has opened. After that the strip is left where the user scrolls it.
Dragging against the edge. While a tab is dragged and its lifted copy touches either edge of the visible part, the strip scrolls 8 widget pixels further that way on each frame (`EDGE_SCROLL`), and every layout places the dragged tab under the pointer again, so it travels with the strip although the pointer holds still and drops among the tabs that come into view.
Pointer coordinates. A scroll and a viewport draw their child on a canvas of their own, so the tabs were laid out in coordinates that no longer matched the pointer once the strip scrolled. `DrawCtx.origin` now holds where the canvas being drawn on sits on the surface, set around the child of every `Scroll` and `Viewport`, and `TabCells` and `TabCards` hand it to the `TabMotion`, which takes it off the pointer in `drag_move`.
The mouse. `App::on_mouse_button( &MouseButtonEvent )` reports every press and release of the left, middle and right buttons, with the position in physical pixels and Ctrl, Shift and Alt, before ltk handles it; `MouseButton` and `MouseButtonEvent` are exported. `App::on_modifiers( ctrl, shift, alt )` is called whenever the modifiers held change. Both do nothing by default.
Tests cover a selected tab scrolled into view once and back, and a tab dragged against the edge scrolling the strip. The documentation of `tab_strip` describes the scrolling.
This commit is contained in:
2026-10-15 10:25:31 +02:00
parent ce98c7b82c
commit dcb1c7dff3
14 changed files with 181 additions and 14 deletions
+1 -1
View File
@@ -905,7 +905,7 @@ 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.
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, to show the selected tab when it changes and while a tab is dragged against its edge, 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`.
+30
View File
@@ -377,6 +377,29 @@ pub struct RawKey
pub text: Option<String>,
}
/// A mouse button, as [`App::on_mouse_button`] reports it.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum MouseButton
{
Left,
Middle,
Right,
}
/// A mouse button pressed or released, as [`App::on_mouse_button`] reports it.
#[ derive( Debug, Clone, Copy ) ]
pub struct MouseButtonEvent
{
pub button: MouseButton,
/// In physical pixels of the surface under the pointer, as [`App::on_pointer_move`] reports them.
pub x: f32,
pub y: f32,
pub pressed: bool,
pub ctrl: bool,
pub shift: bool,
pub alt: bool,
}
/// Which of the two selections an app reads, through [`App::take_paste_request`].
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum SelectionKind
@@ -1018,6 +1041,13 @@ pub trait App: 'static
/// A middle-button press, in physical pixels. Default: no-op.
fn on_pointer_middle( &mut self, _x: f32, _y: f32 ) {}
/// Every press and release of the left, middle and right buttons, with the modifiers held, before ltk handles it: for an
/// app that hands the mouse on, as a terminal does to a program that asks for it. Default: no-op.
fn on_mouse_button( &mut self, _event: &MouseButtonEvent ) {}
/// The modifiers held changed, for an app that needs them with the wheel or a motion, which do not carry them. Default: no-op.
fn on_modifiers( &mut self, _ctrl: bool, _shift: bool, _alt: bool ) {}
/// Called on every keyboard event (press *and* release) before the
/// regular focus-aware dispatch ([`Self::on_key`] /
/// [`Self::on_key_with_modifiers`]) runs. Default: no-op.
+1
View File
@@ -413,6 +413,7 @@ impl<Msg: Clone> UiSurface<Msg>
previous_widget_rects: self.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
origin: ( 0.0, 0.0 ),
};
draw::layout_and_draw( element, &mut self.canvas, options.bounds, &mut ctx, 0 );
+14
View File
@@ -96,11 +96,18 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
}
Element::TabRow( r ) =>
{
// The child is the scroll of the tabs, whose offset is keyed by this same index.
let inner = r.arrange( rect, canvas );
let offset = ctx.scroll_offsets.get( &flat_idx ).copied().unwrap_or( ( 0.0, 0.0 ) );
if let Some( x ) = r.scroll_to( offset.0, inner.width, canvas )
{
ctx.scroll_offsets.insert( flat_idx, ( x, offset.1 ) );
}
layout_and_draw::<Msg>( r.child.as_ref(), canvas, inner, ctx, flat_idx )
}
Element::TabCells( cells ) =>
{
cells.motion.set_origin( ctx.origin );
let child_rects = cells.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
@@ -111,6 +118,7 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
}
Element::TabCards( cards ) =>
{
cards.motion.set_origin( ctx.origin );
let child_rects = cards.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
@@ -300,7 +308,10 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
let rects_before = ctx.widget_rects.len();
let scroll_rects_before = ctx.scroll_rects.len();
let origin = ctx.origin;
ctx.origin = ( origin.0 + rect.x, origin.1 + rect.y );
let next_idx = layout_and_draw::<Msg>( s.child.as_ref(), &mut sub, child_rect, ctx, my_idx + 1 );
ctx.origin = origin;
// Translate widget_rects from sub-canvas space to global space; drop
// clipped ones. Also clamp `paint_rect` to the scroll viewport so a
@@ -452,7 +463,10 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
let child_rect = Rect { x: 0.0, y: 0.0, width: rect.width, height: effective_h };
let rects_before = ctx.widget_rects.len();
let scroll_rects_before = ctx.scroll_rects.len();
let origin = ctx.origin;
ctx.origin = ( origin.0 + rect.x, origin.1 + rect.y );
let next_idx = layout_and_draw::<Msg>( v.child.as_ref(), &mut sub, child_rect, ctx, flat_idx );
ctx.origin = origin;
let new_rects: Vec<LaidOutWidget<Msg>> = ctx.widget_rects.drain( rects_before.. ).collect();
for mut w in new_rects
+3
View File
@@ -98,6 +98,8 @@ pub( crate ) struct DrawCtx<Msg: Clone>
pub accessible_extras: Vec<crate::a11y::tree::AccessibleExtra>,
/// Depth counter for containers marked `a11y_live`.
pub live_depth: u32,
/// Where the canvas being drawn on sits on the surface: a scroll or a viewport draws its child on a canvas of its own.
pub origin: ( f32, f32 ),
}
/// Build the per-frame [`DrawCtx`] from the surface's [`FrameState`], moving the
@@ -133,6 +135,7 @@ pub( crate ) fn build_draw_ctx<Msg: Clone>(
previous_widget_rects: frame.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
origin: ( 0.0, 0.0 ),
}
}
+1
View File
@@ -329,6 +329,7 @@ fn empty_draw_ctx<Msg: Clone>() -> DrawCtx<Msg>
previous_widget_rects: Vec::new(),
accessible_extras: Vec::new(),
live_depth: 0,
origin: ( 0.0, 0.0 ),
}
}
+1
View File
@@ -153,6 +153,7 @@ impl<A: App> KeyboardHandler for AppData<A>
self.shift_pressed = modifiers.shift;
self.ctrl_pressed = modifiers.ctrl;
self.alt_pressed = modifiers.alt;
self.app.on_modifiers( modifiers.ctrl, modifiers.shift, modifiers.alt );
}
fn update_repeat_info(
+33
View File
@@ -55,6 +55,7 @@ impl<A: App> PointerHandler for AppData<A>
let focus = self.focus_for_surface( &event.surface )
.unwrap_or( SurfaceFocus::Main );
self.pointer_focus = focus;
self.report_mouse_button( focus, event );
match event.kind
{
PointerEventKind::Enter { .. } => self.on_pointer_enter( conn, qh, pointer, event ),
@@ -71,3 +72,35 @@ impl<A: App> PointerHandler for AppData<A>
}
}
}
impl<A: App> AppData<A>
{
/// Hand a press or release of the left, middle or right button to [`App::on_mouse_button`].
fn report_mouse_button( &mut self, focus: SurfaceFocus, event: &PointerEvent )
{
let ( code, pressed ) = match event.kind
{
PointerEventKind::Press { button, .. } => ( button, true ),
PointerEventKind::Release { button, .. } => ( button, false ),
_ => return,
};
let button = match code
{
0x110 => crate::app::MouseButton::Left,
0x111 => crate::app::MouseButton::Right,
0x112 => crate::app::MouseButton::Middle,
_ => return,
};
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.app.on_mouse_button( &crate::app::MouseButtonEvent
{
button,
x: pos.x,
y: pos.y,
pressed,
ctrl: self.ctrl_pressed,
shift: self.shift_pressed,
alt: self.alt_pressed,
} );
}
}
+1 -1
View File
@@ -313,7 +313,7 @@ pub use app::
{
Anchor, App, BackAtRoot, BackResponse, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec,
SubsurfaceId, SubsurfaceSpec, SubsurfaceParent,
InvalidationScope, RawKey, SelectionKind, SurfaceTarget, ToplevelEvent,
InvalidationScope, MouseButton, MouseButtonEvent, RawKey, SelectionKind, SurfaceTarget, ToplevelEvent,
};
pub use theme::
{
+2 -1
View File
@@ -18,11 +18,12 @@ pub( super ) fn build<Msg: Clone + 'static>( strip: TabStrip<Msg> ) -> Element<M
{
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 current = tabs.get( selected ).map( |tab| tab.key );
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 tab_row = TabRow { child: Box::new( scroll( cells ).horizontal().into() ), keys, selected: current, motion };
let mut bar = row().spacing( Length::widget( GAP ) );
for button in leading
+78 -4
View File
@@ -8,6 +8,7 @@ use std::sync::{ Arc, Mutex, MutexGuard, PoisonError };
use std::time::Instant;
use crate::types::Rect;
use crate::widget::scroll::offset_into_view;
use super::theme::{ CLOSE_HOLD, SLIDE };
@@ -37,6 +38,12 @@ impl TabMotion
self.lock().is_animating( Instant::now() )
}
/// Where the canvas the tabs are laid out on sits on the surface, which the pointer is measured from.
pub( crate ) fn set_origin( &self, origin: ( f32, f32 ) )
{
self.lock().origin = origin;
}
/// 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
{
@@ -64,7 +71,14 @@ impl TabMotion
/// Where each tab goes in `rect`, the dragged one last, and where the tabs end.
pub( crate ) fn lay( &self, rect: Rect, gap: f32 ) -> ( Vec<Laid>, f32 )
{
self.lock().lay( rect, gap )
self.lock().lay( rect, gap, Instant::now() )
}
/// Where the strip, scrolled by `offset` in a `viewport` that wide, should scroll to: to show the `selected` tab when it
/// changed, or `step` further towards the edge a dragged tab is held against; `None` to leave it.
pub( crate ) fn scroll( &self, selected: Option<u64>, offset: f32, viewport: f32, step: f32 ) -> Option<f32>
{
self.lock().scroll( selected, offset, viewport, step, Instant::now() )
}
/// Where each card of `keys` goes in `rect`, in `columns` of `cell`-sized cards, the dragged one last.
@@ -216,6 +230,11 @@ struct Motion
gap: f32,
shape: Shape,
laid: Vec<Laid>,
/// The selected tab last scrolled into view, and whether it is still being followed while it grows.
revealed: Option<u64>,
revealing: bool,
/// Of the canvas the last layout was on, on the surface.
origin: ( f32, f32 ),
}
/// The left edge of every slot from the strip's, each gap as wide as the slot before it is present.
@@ -359,11 +378,16 @@ impl Motion
laid
}
fn lay( &mut self, rect: Rect, gap: f32 ) -> ( Vec<Laid>, f32 )
fn lay( &mut self, rect: Rect, gap: f32, now: Instant ) -> ( Vec<Laid>, f32 )
{
self.rect = rect;
self.gap = gap;
self.shape = Shape::Strip;
// The strip may have scrolled under a pointer that held still.
if let Some( ( key, at ) ) = self.drag.as_ref().map( |drag| ( drag.key, ( drag.pointer.0 - drag.grab.0, drag.pointer.1 - drag.grab.1 ) ) )
{
self.follow( key, at, now );
}
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 )
@@ -389,8 +413,31 @@ impl Motion
self.place( |_| cell, Rect { height, ..rect } )
}
fn scroll( &mut self, selected: Option<u64>, offset: f32, viewport: f32, step: f32, now: Instant ) -> Option<f32>
{
// The tabs are laid out on the scroll's own canvas, whose visible part starts at 0.
if let Some( drag ) = &self.drag
{
let lifted = self.laid.iter().find( |laid| laid.key == drag.key )?.rect;
if lifted.x + lifted.width >= viewport - 0.5 { return Some( offset + step ); }
if lifted.x <= 0.5 && offset > 0.0 { return Some( ( offset - step ).max( 0.0 ) ); }
return None;
}
if selected != self.revealed
{
self.revealed = selected;
self.revealing = true;
}
if !self.revealing { return None; }
if !self.is_animating( now ) { self.revealing = false; }
let at = self.placed.iter().position( |slot| slot.key.is_some() && slot.key == selected )?;
let x = offsets( &self.placed, self.gap )[ at ];
Some( offset_into_view( offset, x, self.placed[ at ].width, viewport ) )
}
fn drag_move( &mut self, x: f32, y: f32, now: Instant ) -> bool
{
let ( x, y ) = ( x - self.origin.0, y - self.origin.1 );
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 );
@@ -557,11 +604,11 @@ mod tests
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 );
motion.lay( strip, 0.0, now );
assert!( !motion.drag_move( 50.0, 40.0, now ) );
assert!( motion.drag_move( 50.0, 10.0, now ) );
assert!( motion.drag_move( 260.0, 10.0, now ) );
let order: Vec<u64> = motion.lay( strip, 0.0 ).0.iter().map( |laid| laid.key ).collect();
let order: Vec<u64> = motion.lay( strip, 0.0, now ).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 ) );
@@ -589,4 +636,31 @@ mod tests
assert_eq!( order, [ 2, 3, 4, 1 ] );
assert_eq!( motion.drag_end( now ), Some( ( 0, 3 ) ) );
}
#[ test ]
fn the_selected_tab_is_scrolled_into_view_once()
{
let mut motion = Motion::default();
let now = Instant::now();
let keys: Vec<u64> = ( 1..=10 ).collect();
motion.arrange( &keys, 100.0, now );
motion.lay( Rect { x: 0.0, y: 0.0, width: 1000.0, height: 30.0 }, 0.0, now );
assert_eq!( motion.scroll( Some( 8 ), 0.0, 300.0, 10.0, now ), Some( 500.0 ) );
assert_eq!( motion.scroll( Some( 8 ), 0.0, 300.0, 10.0, now ), None );
assert_eq!( motion.scroll( Some( 2 ), 500.0, 300.0, 10.0, now ), Some( 100.0 ) );
}
#[ test ]
fn a_tab_dragged_against_the_edge_scrolls_the_strip()
{
let mut motion = Motion::default();
let now = Instant::now();
let keys: Vec<u64> = ( 1..=10 ).collect();
motion.arrange( &keys, 100.0, now );
motion.lay( Rect { x: 0.0, y: 0.0, width: 1000.0, height: 30.0 }, 0.0, now );
assert!( motion.drag_move( 250.0, 10.0, now ) );
assert!( motion.drag_move( 290.0, 10.0, now ) );
motion.lay( Rect { x: 0.0, y: 0.0, width: 1000.0, height: 30.0 }, 0.0, now );
assert_eq!( motion.scroll( None, 0.0, 300.0, 10.0, now ), Some( 10.0 ) );
}
}
+9 -2
View File
@@ -10,13 +10,14 @@ 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 };
use super::theme::{ EDGE_SCROLL, GAP, MAX_TAB_FRACTION, MIN_TAB_WIDTH, RESERVE };
pub struct TabRow<Msg: Clone>
{
/// A horizontal scroll over a [`TabCells`].
pub( crate ) child: Box<Element<Msg>>,
pub( crate ) keys: Vec<u64>,
pub( crate ) selected: Option<u64>,
pub( crate ) motion: TabMotion,
}
@@ -43,6 +44,12 @@ impl<Msg: Clone> TabRow<Msg>
Rect { width: room.max( 0.0 ), ..rect }
}
/// Where the scroll of the tabs, now at `offset` in a `viewport` that wide, should go; `None` to leave it.
pub( crate ) fn scroll_to( &self, offset: f32, viewport: f32, canvas: &Canvas ) -> Option<f32>
{
self.motion.scroll( self.selected, offset, viewport, canvas.resolve_geom( Length::widget( EDGE_SCROLL ) ) )
}
fn cells( &self ) -> Option<&TabCells<Msg>>
{
match self.child.as_ref()
@@ -61,7 +68,7 @@ impl<Msg: Clone> TabRow<Msg>
U: Clone + 'static,
Msg: 'static,
{
TabRow { child: Box::new( self.child.map_arc( f ) ), keys: self.keys, motion: self.motion }
TabRow { child: Box::new( self.child.map_arc( f ) ), keys: self.keys, selected: self.selected, motion: self.motion }
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ fn strip( count: u64, motion: &TabMotion, after: Vec<Element<()>> ) -> TabRow<()
let tabs = ( 1..=count ).map( |key| ( key, spacer().into() ) ).collect::<Vec<_>>();
let keys = tabs.iter().map( |( key, _ )| *key ).collect();
let cells = TabCells { tabs, after, motion: motion.clone() };
TabRow { child: Box::new( scroll( cells ).horizontal().into() ), keys, motion: motion.clone() }
TabRow { child: Box::new( scroll( cells ).horizontal().into() ), keys, selected: None, motion: motion.clone() }
}
fn cells( row: &TabRow<()> ) -> &TabCells<()>
+2
View File
@@ -9,6 +9,8 @@ pub( crate ) const RESERVE: f32 = 0.075;
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;
/// How far a strip scrolls on each frame a dragged tab is held against its edge.
pub( crate ) const EDGE_SCROLL: f32 = 8.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.