Skip to content

Commit c194cb1

Browse files
committed
fix(tray): enhance scrolling behavior and layout management for tray icons
1 parent dc8f8ee commit c194cb1

4 files changed

Lines changed: 199 additions & 70 deletions

File tree

src/modules/trayIcons/trayContainer.ts

Lines changed: 155 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,21 @@ import * as PanelMenu from '@girs/gnome-shell/ui/panelMenu';
99

1010
import { 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';
1913
import type { TrayState, TrayItem } from './trayState.ts';
2014
import { TrayIconItem, destroyTooltip } from './trayIconItem.ts';
2115

22-
const SCROLL_STEP = 28;
2316
const ICON_GAP = 3;
2417
const ITEM_PADDING = 3; // Must match .aurora-tray-icon-item padding in SCSS
2518
const ANIM_DURATION = 600;
19+
const PANEL_SAFETY_GAP = 8;
2620
const LOG_PREFIX = 'AuroraTray';
2721

2822
@GObject.registerClass
2923
class 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;

src/modules/trayIcons/trayIconItem.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,12 @@ export class TrayIconItem extends St.Button {
123123
this._applyIcon();
124124

125125
this._badge = new St.Widget({
126-
style: `
127-
width: ${BADGE_SIZE}px;
128-
height: ${BADGE_SIZE}px;
129-
border-radius: ${BADGE_SIZE / 2}px;
130-
background-color: #3584e4;
131-
border: 1.5px solid rgba(0,0,0,0.35);
132-
`,
126+
style: `width: ${BADGE_SIZE}px; height: ${BADGE_SIZE}px;`,
127+
style_class: 'aurora-tray-attention-badge',
133128
visible: false,
134129
reactive: false,
130+
x_expand: true,
131+
y_expand: true,
135132
x_align: Clutter.ActorAlign.END,
136133
y_align: Clutter.ActorAlign.START,
137134
});

src/styles/_tray-icons.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@
6262
min-width: 0;
6363
}
6464

65+
.aurora-tray-attention-badge {
66+
z-position: 1;
67+
border-radius: 999px;
68+
background-color: #3584e4;
69+
}
70+
6571
.aurora-tray-menu {
6672
min-width: 120px;
6773
}

0 commit comments

Comments
 (0)