@@ -9,25 +9,21 @@ import * as PanelMenu from '@girs/gnome-shell/ui/panelMenu';
99
1010import { logger } from '~/core/logger.ts' ;
1111
12- import {
13- createTrayState ,
14- toggleCollapsed ,
15- applyScroll ,
16- addAttention ,
17- clearAttention ,
18- } from './trayState.ts' ;
12+ import { createTrayState , toggleCollapsed , addAttention , clearAttention } from './trayState.ts' ;
1913import type { TrayState , TrayItem } from './trayState.ts' ;
2014import { TrayIconItem , destroyTooltip } from './trayIconItem.ts' ;
2115
22- const SCROLL_STEP = 28 ;
2316const ICON_GAP = 3 ;
2417const ITEM_PADDING = 3 ; // Must match .aurora-tray-icon-item padding in SCSS
2518const ANIM_DURATION = 600 ;
19+ const PANEL_SAFETY_GAP = 8 ;
2620const LOG_PREFIX = 'AuroraTray' ;
2721
2822@GObject . registerClass
2923class TrayClipArea extends Clutter . Actor {
3024 public fullWidth = 0 ;
25+ public reservedWidth = 0 ;
26+ private _childOffsetX = 0 ;
3127 private _viewportWidth = 0 ;
3228 private _clipStart = 0 ;
3329 private _viewportTimeoutId = 0 ;
@@ -45,33 +41,41 @@ class TrayClipArea extends Clutter.Actor {
4541 super . vfunc_allocate ( box ) ;
4642 const ownH = Math . round ( box . y2 - box . y1 ) ;
4743 const childW = Math . round ( this . fullWidth ) ;
44+ const childX = Math . round ( this . _childOffsetX ) ;
4845
4946 this . _syncClip ( ) ;
5047
5148 const childBox = new Clutter . ActorBox ( ) ;
52- childBox . set_origin ( 0 , 0 ) ;
49+ childBox . set_origin ( childX , 0 ) ;
5350 childBox . set_size ( childW , ownH ) ;
5451 for ( const child of this . get_children ( ) ) {
5552 child . allocate ( childBox ) ;
5653 }
5754 }
5855
5956 private _syncClip ( ) : void {
60- const fullWidth = Math . round ( this . fullWidth ) ;
61- const clipStart = Math . min ( fullWidth , Math . max ( 0 , Math . round ( this . _clipStart ) ) ) ;
57+ const reservedWidth = Math . round ( this . reservedWidth ) ;
58+ const clipStart = Math . min ( reservedWidth , Math . max ( 0 , Math . round ( this . _clipStart ) ) ) ;
6259 const visibleWidth = Math . min (
63- fullWidth - clipStart ,
60+ reservedWidth - clipStart ,
6461 Math . max ( 0 , Math . round ( this . _viewportWidth ) ) ,
6562 ) ;
6663 const height = Math . max ( 0 , Math . round ( this . height ) ) ;
6764 this . set_clip ( clipStart , 0 , visibleWidth , height ) ;
6865 }
6966
70- setViewport ( fullWidth : number , viewportWidth : number , clipStart : number ) : void {
67+ setViewport (
68+ fullWidth : number ,
69+ viewportWidth : number ,
70+ clipStart : number ,
71+ reservedWidth = fullWidth ,
72+ ) : void {
7173 this . fullWidth = Math . round ( fullWidth ) ;
74+ this . reservedWidth = Math . max ( 0 , Math . round ( reservedWidth ) ) ;
75+ this . _childOffsetX = Math . min ( 0 , this . reservedWidth - this . fullWidth ) ;
7276 this . _viewportWidth = viewportWidth ;
7377 this . _clipStart = clipStart ;
74- this . set_width ( this . fullWidth ) ;
78+ this . set_width ( this . reservedWidth ) ;
7579 this . _syncClip ( ) ;
7680 }
7781
@@ -129,7 +133,7 @@ class TrayClipArea extends Clutter.Actor {
129133
130134 layoutSnapshot ( ) : string {
131135 const child = this . get_first_child ( ) ;
132- return `reservedWidth=${ Math . round ( this . width ) } viewportWidth=${ Math . round ( this . _viewportWidth ) } clipStart=${ Math . round ( this . _clipStart ) } allocated=${ Math . round ( this . allocation . x2 - this . allocation . x1 ) } fullWidth=${ Math . round ( this . fullWidth ) } childX=${ child ? Math . round ( child . x ) : 'none' } childWidth=${ child ? Math . round ( child . width ) : 'none' } ` ;
136+ return `reservedWidth=${ Math . round ( this . reservedWidth ) } actorWidth= ${ Math . round ( this . width ) } viewportWidth=${ Math . round ( this . _viewportWidth ) } clipStart=${ Math . round ( this . _clipStart ) } allocated=${ Math . round ( this . allocation . x2 - this . allocation . x1 ) } fullWidth=${ Math . round ( this . fullWidth ) } childOffsetX= ${ Math . round ( this . _childOffsetX ) } childX=${ child ? Math . round ( child . x ) : 'none' } childWidth=${ child ? Math . round ( child . width ) : 'none' } ` ;
133137 }
134138}
135139
@@ -161,6 +165,7 @@ export class TrayContainer extends PanelMenu.Button {
161165 declare private _autoCollapseTimeoutId : number ;
162166 declare private _opacityTargets : WeakMap < TrayIconItem , number > ;
163167 declare private _scrollTarget : number ;
168+ declare private _smoothScrollAccumulator : number ;
164169
165170 private _animScrollValue = 0 ;
166171
@@ -200,6 +205,7 @@ export class TrayContainer extends PanelMenu.Button {
200205 this . _autoCollapseTimeoutId = 0 ;
201206 this . _opacityTargets = new WeakMap ( ) ;
202207 this . _scrollTarget = 0 ;
208+ this . _smoothScrollAccumulator = 0 ;
203209
204210 // Chevron button (collapse/expand toggle)
205211 this . _chevronIcon = new St . Icon ( {
@@ -240,13 +246,24 @@ export class TrayContainer extends PanelMenu.Button {
240246
241247 // Scroll to peek
242248 this . connect ( 'scroll-event' , ( _actor : Clutter . Actor , event : Clutter . Event ) => {
243- if ( ! this . _state . collapsed ) return Clutter . EVENT_PROPAGATE ;
244- this . _userInteracted = true ;
249+ if ( ! this . _canScrollIcons ( ) ) return Clutter . EVENT_PROPAGATE ;
245250 const direction = event . get_scroll_direction ( ) ;
246- const delta = direction === Clutter . ScrollDirection . UP ? SCROLL_STEP : - SCROLL_STEP ;
247- applyScroll ( this . _state , delta , this . _maxScroll ( ) ) ;
248- this . _syncScrollPosition ( ) ;
249- return Clutter . EVENT_STOP ;
251+ if ( direction === Clutter . ScrollDirection . SMOOTH ) {
252+ const [ dx , dy ] = event . get_scroll_delta ( ) ;
253+ const delta = Math . abs ( dx ) > Math . abs ( dy ) ? dx : dy ;
254+ this . _smoothScrollAccumulator += delta ;
255+ if ( Math . abs ( this . _smoothScrollAccumulator ) < 1 ) return Clutter . EVENT_STOP ;
256+ if ( ! this . _scrollByItems ( this . _smoothScrollAccumulator > 0 ? - 1 : 1 ) )
257+ return Clutter . EVENT_PROPAGATE ;
258+ this . _smoothScrollAccumulator = 0 ;
259+ return Clutter . EVENT_STOP ;
260+ }
261+
262+ const deltaItems =
263+ direction === Clutter . ScrollDirection . UP || direction === Clutter . ScrollDirection . LEFT
264+ ? 1
265+ : - 1 ;
266+ return this . _scrollByItems ( deltaItems ) ? Clutter . EVENT_STOP : Clutter . EVENT_PROPAGATE ;
250267 } ) ;
251268 }
252269
@@ -255,10 +272,85 @@ export class TrayContainer extends PanelMenu.Button {
255272 }
256273
257274 private _maxScroll ( ) : number {
258- const hiddenCount = Math . max ( 0 , this . _items . size - this . _limit ) ;
275+ return this . _maxScrollForLimit ( this . _effectiveLimit ( ) ) ;
276+ }
277+
278+ private _maxExpandedScroll ( ) : number {
279+ return Math . max ( 0 , Math . round ( this . _clipArea . fullWidth - this . _clipArea . reservedWidth ) ) ;
280+ }
281+
282+ private _maxScrollForLimit ( limit : number ) : number {
283+ const hiddenCount = Math . max ( 0 , this . _items . size - limit ) ;
259284 return hiddenCount * ( this . _itemWidth ( ) + ICON_GAP ) ;
260285 }
261286
287+ private _effectiveLimit ( maxClipWidth = this . _availableClipWidth ( true ) ) : number {
288+ if ( maxClipWidth === null ) return this . _limit ;
289+
290+ const itemStride = this . _itemWidth ( ) + ICON_GAP ;
291+ const maxVisibleByWidth = Math . max ( 1 , Math . floor ( ( maxClipWidth + ICON_GAP ) / itemStride ) ) ;
292+ return Math . max ( 1 , Math . min ( this . _limit , maxVisibleByWidth ) ) ;
293+ }
294+
295+ private _availableClipWidth ( includeChevron : boolean ) : number | null {
296+ const panelContainer =
297+ ( this as unknown as { container ?: Clutter . Actor } ) . container ?? ( this as Clutter . Actor ) ;
298+ const parent = panelContainer . get_parent ( ) ;
299+ if ( ! parent ) return null ;
300+
301+ const parentWidth = this . _availablePanelSideWidth ( parent ) ;
302+ if ( parentWidth <= 0 ) return null ;
303+
304+ let siblingsWidth = 0 ;
305+ for ( const child of parent . get_children ( ) ) {
306+ if ( child === panelContainer || ! child . visible ) continue ;
307+ const [ , naturalWidth ] = child . get_preferred_width ( - 1 ) ;
308+ siblingsWidth += Math . ceil ( naturalWidth ) ;
309+ }
310+
311+ const [ , chevronWidth ] = includeChevron ? this . _chevron . get_preferred_width ( - 1 ) : [ 0 , 0 ] ;
312+ const availableWidth =
313+ parentWidth -
314+ siblingsWidth -
315+ Math . ceil ( chevronWidth ) -
316+ ( includeChevron ? ICON_GAP : 0 ) -
317+ PANEL_SAFETY_GAP ;
318+ return Math . max ( this . _itemWidth ( ) , Math . floor ( availableWidth ) ) ;
319+ }
320+
321+ private _availablePanelSideWidth ( parent : Clutter . Actor ) : number {
322+ const fallbackWidth = Math . round ( parent . allocation . x2 - parent . allocation . x1 ) ;
323+ const panel = parent . get_parent ( ) as ( Clutter . Actor & { _centerBox ?: Clutter . Actor } ) | null ;
324+ const centerBox = panel ?. _centerBox ;
325+ if ( ! centerBox ) return fallbackWidth ;
326+
327+ if ( this . get_text_direction ( ) === Clutter . TextDirection . RTL ) {
328+ return Math . max ( fallbackWidth , Math . round ( centerBox . allocation . x1 - parent . allocation . x1 ) ) ;
329+ }
330+
331+ return Math . max ( fallbackWidth , Math . round ( parent . allocation . x2 - centerBox . allocation . x2 ) ) ;
332+ }
333+
334+ private _canScrollIcons ( ) : boolean {
335+ return this . _state . collapsed ? this . _maxScroll ( ) > 0 : this . _maxExpandedScroll ( ) > 0 ;
336+ }
337+
338+ private _scrollByItems ( deltaItems : number ) : boolean {
339+ const maxScroll = this . _state . collapsed ? this . _maxScroll ( ) : this . _maxExpandedScroll ( ) ;
340+ if ( maxScroll <= 0 ) return false ;
341+
342+ this . _userInteracted = true ;
343+ const itemStride = this . _itemWidth ( ) + ICON_GAP ;
344+ const signedDelta = this . _state . collapsed ? deltaItems : - deltaItems ;
345+ this . _state . scrollOffset = Math . max (
346+ 0 ,
347+ Math . min ( maxScroll , this . _state . scrollOffset + signedDelta * itemStride ) ,
348+ ) ;
349+ this . _syncScrollPosition ( ) ;
350+ this . _applyIconOpacity ( ) ;
351+ return true ;
352+ }
353+
262354 addItem ( item : TrayItem ) : void {
263355 // Immediate dedup removal — no pop-out animation to avoid _syncLayout conflicts
264356 const oldWidget = this . _items . get ( item . id ) ;
@@ -378,13 +470,27 @@ export class TrayContainer extends PanelMenu.Button {
378470
379471 private _visibleIds ( ) : Set < string > {
380472 const keys = [ ...this . _items . keys ( ) ] ;
381- return new Set ( keys . slice ( Math . max ( 0 , keys . length - this . _limit ) ) ) ;
473+ const limit = this . _effectiveLimit ( ) ;
474+ const hiddenCount = Math . max ( 0 , keys . length - limit ) ;
475+ const itemStride = this . _itemWidth ( ) + ICON_GAP ;
476+ const startIndex =
477+ itemStride > 0
478+ ? Math . max ( 0 , Math . min ( hiddenCount , Math . round ( this . _state . scrollOffset / itemStride ) ) )
479+ : hiddenCount ;
480+ return new Set ( keys . slice ( startIndex , startIndex + limit ) ) ;
382481 }
383482
384483 private _syncLayout ( animated = false ) : void {
385484 const count = this . _items . size ;
386485 this . visible = count > 0 ;
387- const hasOverflow = count > this . _limit ;
486+ const itemW = this . _itemWidth ( ) ;
487+ const fullWidth = count * itemW + Math . max ( 0 , count - 1 ) * ICON_GAP ;
488+ const availableClipWidthWithoutChevron = this . _availableClipWidth ( false ) ;
489+ const effectiveLimitWithoutChevron = this . _effectiveLimit ( availableClipWidthWithoutChevron ) ;
490+ const shouldReserveChevron = count > effectiveLimitWithoutChevron ;
491+ const availableClipWidth = this . _availableClipWidth ( shouldReserveChevron ) ;
492+ const effectiveLimit = this . _effectiveLimit ( availableClipWidth ) ;
493+ const hasOverflow = count > effectiveLimit ;
388494 this . _chevron . visible = hasOverflow ;
389495
390496 // Chevron rotation: 0° = expanded (points right), 180° = collapsed (points left).
@@ -394,23 +500,27 @@ export class TrayContainer extends PanelMenu.Button {
394500 mode : Clutter . AnimationMode . EASE_OUT_CUBIC ,
395501 } ) ;
396502
397- // collapsed → maxScroll anchors the row to newest icons (right-aligned in clip).
398- // expanded → 0 resets any manual scroll.
399- this . _state . scrollOffset = this . _state . collapsed ? this . _maxScroll ( ) : 0 ;
400-
401- const itemW = this . _itemWidth ( ) ;
402- const visibleCount = Math . min ( count , this . _limit ) ;
503+ const visibleCount = Math . min ( count , effectiveLimit ) ;
403504 const collapsedWidth = visibleCount * itemW + Math . max ( 0 , visibleCount - 1 ) * ICON_GAP ;
404- const fullWidth = count * itemW + Math . max ( 0 , count - 1 ) * ICON_GAP ;
405- const hiddenWidth = Math . max ( 0 , fullWidth - collapsedWidth ) ;
406- const targetViewportWidth = Math . round ( this . _state . collapsed ? collapsedWidth : fullWidth ) ;
407- const targetClipStart = Math . round ( this . _state . collapsed ? hiddenWidth : 0 ) ;
505+ const reservedWidth =
506+ availableClipWidth === null
507+ ? fullWidth
508+ : Math . min ( fullWidth , Math . max ( collapsedWidth , availableClipWidth ) ) ;
509+ const collapsedClipStart = Math . max ( 0 , reservedWidth - collapsedWidth ) ;
510+
511+ // collapsed -> maxScroll anchors the row to newest icons (right-aligned in clip).
512+ // expanded -> 0 resets any manual scroll.
513+ this . _state . scrollOffset = this . _state . collapsed ? this . _maxScrollForLimit ( effectiveLimit ) : 0 ;
514+ if ( ! this . _state . collapsed ) this . _smoothScrollAccumulator = 0 ;
515+
516+ const targetViewportWidth = Math . round ( this . _state . collapsed ? collapsedWidth : reservedWidth ) ;
517+ const targetClipStart = Math . round ( this . _state . collapsed ? collapsedClipStart : 0 ) ;
408518 const startViewportWidth = Math . round ( this . _clipArea . viewportWidth || targetViewportWidth ) ;
409519 const startClipStart = Math . round ( this . _clipArea . clipStart ) ;
410520
411521 if ( animated ) {
412522 logger . log (
413- `Viewport animation collapsed=${ this . _state . collapsed } count=${ count } limit=${ this . _limit } visible=${ visibleCount } fullWidth=${ fullWidth } hiddenWidth =${ hiddenWidth } fromViewport=${ startViewportWidth } toViewport=${ targetViewportWidth } fromClipStart=${ startClipStart } toClipStart=${ targetClipStart } scrollOffset=${ this . _state . scrollOffset } chevronX=${ Math . round ( this . _chevron . translationX ) } ${ this . _clipArea . layoutSnapshot ( ) } ` ,
523+ `Viewport animation collapsed=${ this . _state . collapsed } count=${ count } limit=${ this . _limit } effectiveLimit= ${ effectiveLimit } visible=${ visibleCount } fullWidth=${ fullWidth } reservedWidth =${ reservedWidth } availableClipWidth= ${ availableClipWidth ?? 'none' } fromViewport=${ startViewportWidth } toViewport=${ targetViewportWidth } fromClipStart=${ startClipStart } toClipStart=${ targetClipStart } scrollOffset=${ this . _state . scrollOffset } chevronX=${ Math . round ( this . _chevron . translationX ) } ${ this . _clipArea . layoutSnapshot ( ) } ` ,
414524 { prefix : LOG_PREFIX } ,
415525 ) ;
416526 }
@@ -440,7 +550,7 @@ export class TrayContainer extends PanelMenu.Button {
440550 widget . opacity = 255 ;
441551 }
442552 }
443- this . _clipArea . setViewport ( fullWidth , startViewportWidth , startClipStart ) ;
553+ this . _clipArea . setViewport ( fullWidth , startViewportWidth , startClipStart , reservedWidth ) ;
444554 this . _setChevronAnchor ( startClipStart ) ;
445555 this . _clipArea . animateViewport (
446556 startViewportWidth ,
@@ -467,7 +577,7 @@ export class TrayContainer extends PanelMenu.Button {
467577 return GLib . SOURCE_REMOVE ;
468578 } ) ;
469579 } else {
470- this . _clipArea . setViewport ( fullWidth , targetViewportWidth , targetClipStart ) ;
580+ this . _clipArea . setViewport ( fullWidth , targetViewportWidth , targetClipStart , reservedWidth ) ;
471581 this . _setChevronAnchor ( targetClipStart ) ;
472582 this . _applyIconOpacity ( ) ;
473583 }
@@ -483,12 +593,9 @@ export class TrayContainer extends PanelMenu.Button {
483593 }
484594
485595 private _applyIconOpacity ( ) : void {
486- const count = this . _items . size ;
487- const hiddenCount = Math . max ( 0 , count - this . _limit ) ;
488- const allWidgets = [ ...this . _items . values ( ) ] ;
489- for ( let i = 0 ; i < allWidgets . length ; i ++ ) {
490- const widget = allWidgets [ i ] ! ;
491- const targetOpacity = i < hiddenCount && this . _state . collapsed ? 0 : 255 ;
596+ const visibleIds = this . _visibleIds ( ) ;
597+ for ( const [ id , widget ] of this . _items ) {
598+ const targetOpacity = ! this . _state . collapsed || visibleIds . has ( id ) ? 255 : 0 ;
492599 if ( this . _opacityTargets . get ( widget ) === targetOpacity ) continue ;
493600 this . _opacityTargets . set ( widget , targetOpacity ) ;
494601 widget . remove_transition ( 'opacity' ) ;
@@ -497,10 +604,11 @@ export class TrayContainer extends PanelMenu.Button {
497604 }
498605
499606 private _syncScrollPosition ( duration = 150 ) : void {
500- // Right-aligned allocation shows newest icons at translationX=0.
501- // Positive translationX shifts row right (peek at older icons on the left).
502- // scrollOffset=maxScroll (default collapsed) → targetX=0, no shift needed.
503- const targetX = this . _state . collapsed ? this . _maxScroll ( ) - this . _state . scrollOffset : 0 ;
607+ // Right-aligned allocation shows newest icons at translationX=0. Positive
608+ // translationX shifts the row right, revealing older icons on the left.
609+ const targetX = this . _state . collapsed
610+ ? this . _maxScroll ( ) - this . _state . scrollOffset
611+ : this . _state . scrollOffset ;
504612
505613 if ( this . _scrollTarget === targetX ) return ;
506614 this . _scrollTarget = targetX ;
0 commit comments