From dcb1c7dff31ebde50e0e3bc88827a077a0c3acf1 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Thu, 15 Oct 2026 10:25:31 +0200 Subject: [PATCH] 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 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. --- docs/widgets.md | 2 +- src/app.rs | 30 ++++++++++++ src/core.rs | 1 + src/draw/layout.rs | 16 ++++++- src/draw/mod.rs | 3 ++ src/event_loop/subsurface.rs | 1 + src/input/keyboard/mod.rs | 1 + src/input/pointer/mod.rs | 33 +++++++++++++ src/lib.rs | 2 +- src/widget/tab_strip/desktop.rs | 3 +- src/widget/tab_strip/motion.rs | 82 +++++++++++++++++++++++++++++++-- src/widget/tab_strip/row.rs | 17 +++++-- src/widget/tab_strip/tests.rs | 2 +- src/widget/tab_strip/theme.rs | 2 + 14 files changed, 181 insertions(+), 14 deletions(-) diff --git a/docs/widgets.md b/docs/widgets.md index 615e70b..e62e76f 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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`. diff --git a/src/app.rs b/src/app.rs index c632374..9b9a99b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -377,6 +377,29 @@ pub struct RawKey pub text: Option, } +/// 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. diff --git a/src/core.rs b/src/core.rs index 719727e..ef0ea59 100644 --- a/src/core.rs +++ b/src/core.rs @@ -413,6 +413,7 @@ impl UiSurface 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 ); diff --git a/src/draw/layout.rs b/src/draw/layout.rs index 06d9e04..147df45 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -96,11 +96,18 @@ pub( crate ) fn layout_and_draw( } Element::TabRow( r ) => { - let inner = r.arrange( rect, canvas ); + // 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::( 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( } 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( 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::( 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( 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::( v.child.as_ref(), &mut sub, child_rect, ctx, flat_idx ); + ctx.origin = origin; let new_rects: Vec> = ctx.widget_rects.drain( rects_before.. ).collect(); for mut w in new_rects diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 6a45898..2cebdf9 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -98,6 +98,8 @@ pub( crate ) struct DrawCtx pub accessible_extras: Vec, /// 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( previous_widget_rects: frame.widget_rects.clone(), accessible_extras: Vec::new(), live_depth: 0, + origin: ( 0.0, 0.0 ), } } diff --git a/src/event_loop/subsurface.rs b/src/event_loop/subsurface.rs index e6d184f..19a981a 100644 --- a/src/event_loop/subsurface.rs +++ b/src/event_loop/subsurface.rs @@ -329,6 +329,7 @@ fn empty_draw_ctx() -> DrawCtx previous_widget_rects: Vec::new(), accessible_extras: Vec::new(), live_depth: 0, + origin: ( 0.0, 0.0 ), } } diff --git a/src/input/keyboard/mod.rs b/src/input/keyboard/mod.rs index 8eba403..47a020a 100644 --- a/src/input/keyboard/mod.rs +++ b/src/input/keyboard/mod.rs @@ -153,6 +153,7 @@ impl KeyboardHandler for AppData 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( diff --git a/src/input/pointer/mod.rs b/src/input/pointer/mod.rs index d6e3a13..189c939 100644 --- a/src/input/pointer/mod.rs +++ b/src/input/pointer/mod.rs @@ -55,6 +55,7 @@ impl PointerHandler for AppData 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 PointerHandler for AppData } } } + +impl AppData +{ + /// 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, + } ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9984e22..88f36c2 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -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:: { diff --git a/src/widget/tab_strip/desktop.rs b/src/widget/tab_strip/desktop.rs index 082eba3..e104a68 100644 --- a/src/widget/tab_strip/desktop.rs +++ b/src/widget/tab_strip/desktop.rs @@ -18,11 +18,12 @@ pub( super ) fn build( strip: TabStrip ) -> Element 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, 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, offset: f32, viewport: f32, step: f32 ) -> Option + { + 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, + /// The selected tab last scrolled into view, and whether it is still being followed while it grows. + revealed: Option, + 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, f32 ) + fn lay( &mut self, rect: Rect, gap: f32, now: Instant ) -> ( Vec, 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, offset: f32, viewport: f32, step: f32, now: Instant ) -> Option + { + // 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 = motion.lay( strip, 0.0 ).0.iter().map( |laid| laid.key ).collect(); + let order: Vec = 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 = ( 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 = ( 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 ) ); + } } diff --git a/src/widget/tab_strip/row.rs b/src/widget/tab_strip/row.rs index d8aac4e..bb8dc6c 100644 --- a/src/widget/tab_strip/row.rs +++ b/src/widget/tab_strip/row.rs @@ -10,14 +10,15 @@ 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 { /// A horizontal scroll over a [`TabCells`]. - pub( crate ) child: Box>, - pub( crate ) keys: Vec, - pub( crate ) motion: TabMotion, + pub( crate ) child: Box>, + pub( crate ) keys: Vec, + pub( crate ) selected: Option, + pub( crate ) motion: TabMotion, } impl TabRow @@ -43,6 +44,12 @@ impl TabRow 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 + { + self.motion.scroll( self.selected, offset, viewport, canvas.resolve_geom( Length::widget( EDGE_SCROLL ) ) ) + } + fn cells( &self ) -> Option<&TabCells> { match self.child.as_ref() @@ -61,7 +68,7 @@ impl TabRow 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 } } } diff --git a/src/widget/tab_strip/tests.rs b/src/widget/tab_strip/tests.rs index 34a6382..4079fb8 100644 --- a/src/widget/tab_strip/tests.rs +++ b/src/widget/tab_strip/tests.rs @@ -19,7 +19,7 @@ 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() } + TabRow { child: Box::new( scroll( cells ).horizontal().into() ), keys, selected: None, motion: motion.clone() } } fn cells( row: &TabRow<()> ) -> &TabCells<()> diff --git a/src/widget/tab_strip/theme.rs b/src/widget/tab_strip/theme.rs index 3d8046e..7699fa9 100644 --- a/src/widget/tab_strip/theme.rs +++ b/src/widget/tab_strip/theme.rs @@ -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.