Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions src/vs/base/browser/animationSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { addDisposableListener, getWindow, onDidUnregisterWindow } from './dom.js';
import { CodeWindow } from './window.js';
import { Disposable, IDisposable, toDisposable } from '../common/lifecycle.js';

export interface ISynchronizeAnimationsOptions {
/**
* Also synchronize animations running on descendant elements (e.g. the dots
Expand Down Expand Up @@ -64,3 +68,132 @@ export function synchronizeCSSAnimations(element: HTMLElement, options?: ISynchr
}
}
}

export interface IPauseCSSAnimationsWhenHiddenOptions extends ISynchronizeAnimationsOptions {
readonly pausedClass: string;
}

interface ITrackedAnimation {
readonly options: IPauseCSSAnimationsWhenHiddenOptions;
}

interface IAnimationVisibilityObserver {
readonly observer: IntersectionObserver;
readonly trackedAnimations: Map<HTMLElement, ITrackedAnimation>;
readonly intersectingElements: Set<HTMLElement>;
readonly visibilityListener: IDisposable;
}

const animationVisibilityObservers = new Map<CodeWindow, IAnimationVisibilityObserver>();
let unregisterWindowListener: IDisposable | undefined;

/**
* Pauses CSS animations while their element is outside the viewport or its document is hidden.
*/
export function pauseCSSAnimationsWhenHidden(element: HTMLElement, options: IPauseCSSAnimationsWhenHiddenOptions): IDisposable {
const targetWindow = getWindow(element);
if (typeof targetWindow.IntersectionObserver !== 'function') {
return Disposable.None;
}

let state = animationVisibilityObservers.get(targetWindow);
if (!state) {
const trackedAnimations = new Map<HTMLElement, ITrackedAnimation>();
const intersectingElements = new Set<HTMLElement>();
const observer = new targetWindow.IntersectionObserver(entries => {
const toResync: Array<[HTMLElement, IPauseCSSAnimationsWhenHiddenOptions]> = [];
for (const entry of entries) {
const target = entry.target as HTMLElement;
const trackedAnimation = trackedAnimations.get(target);
if (!trackedAnimation) {
continue;
}
if (!target.isConnected) {
observer.unobserve(target);
trackedAnimations.delete(target);
intersectingElements.delete(target);
continue;
}
if (entry.isIntersecting) {
intersectingElements.add(target);
} else {
intersectingElements.delete(target);
}
const paused = targetWindow.document.hidden || !entry.isIntersecting;
target.classList.toggle(trackedAnimation.options.pausedClass, paused);
if (!paused) {
toResync.push([target, trackedAnimation.options]);
}
}

for (const [target, trackedOptions] of toResync) {
synchronizeCSSAnimations(target, trackedOptions);
}
disposeVisibilityObserverIfEmpty(targetWindow, animationVisibilityObservers.get(targetWindow));
});
const visibilityListener = addDisposableListener(targetWindow.document, 'visibilitychange', () => {
const documentHidden = targetWindow.document.hidden;
const toResync: Array<[HTMLElement, IPauseCSSAnimationsWhenHiddenOptions]> = [];
for (const [target, trackedAnimation] of trackedAnimations) {
if (!target.isConnected) {
observer.unobserve(target);
trackedAnimations.delete(target);
intersectingElements.delete(target);
continue;
}
const paused = documentHidden || !intersectingElements.has(target);
target.classList.toggle(trackedAnimation.options.pausedClass, paused);
if (!paused) {
toResync.push([target, trackedAnimation.options]);
}
}
for (const [target, trackedOptions] of toResync) {
synchronizeCSSAnimations(target, trackedOptions);
}
disposeVisibilityObserverIfEmpty(targetWindow, animationVisibilityObservers.get(targetWindow));
});
state = { observer, trackedAnimations, intersectingElements, visibilityListener };
animationVisibilityObservers.set(targetWindow, state);

if (!unregisterWindowListener) {
unregisterWindowListener = onDidUnregisterWindow(window => {
const state = animationVisibilityObservers.get(window);
if (state) {
state.observer.disconnect();
state.visibilityListener.dispose();
animationVisibilityObservers.delete(window);
disposeUnregisterWindowListenerIfUnused();
}
});
}
}

element.classList.add(options.pausedClass);
state.trackedAnimations.set(element, { options });
state.observer.observe(element);

return toDisposable(() => {
state.observer.unobserve(element);
state.trackedAnimations.delete(element);
state.intersectingElements.delete(element);
element.classList.remove(options.pausedClass);
disposeVisibilityObserverIfEmpty(targetWindow, state);
});
}

function disposeVisibilityObserverIfEmpty(targetWindow: CodeWindow, state: IAnimationVisibilityObserver | undefined): void {
if (!state || state.trackedAnimations.size !== 0 || animationVisibilityObservers.get(targetWindow) !== state) {
return;
}
state.observer.disconnect();
state.visibilityListener.dispose();
animationVisibilityObservers.delete(targetWindow);
disposeUnregisterWindowListenerIfUnused();
}

function disposeUnregisterWindowListenerIfUnused(): void {
if (animationVisibilityObservers.size === 0) {
unregisterWindowListener?.dispose();
unregisterWindowListener = undefined;
}
}
83 changes: 19 additions & 64 deletions src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { getWindow, h, onDidUnregisterWindow } from '../../dom.js';
import { synchronizeCSSAnimations } from '../../animationSync.js';
import { CodeWindow } from '../../window.js';
import { h } from '../../dom.js';
import { pauseCSSAnimationsWhenHidden } from '../../animationSync.js';
import { IDisposable } from '../../../common/lifecycle.js';
import './pixelSpinner.css';

Expand All @@ -27,6 +26,10 @@ export interface IPixelSpinnerOptions {
readonly variant?: 'grid' | 'ring';
}

export interface IPixelSpinner extends IDisposable {
readonly element: HTMLElement;
}

/**
* Creates a small pixel-art style spinner. Color is driven by `currentColor`,
* so consumers can control the visual color via the parent element's `color`
Expand All @@ -36,9 +39,9 @@ export interface IPixelSpinnerOptions {
*
* @param parent Optional parent to append the spinner to.
* @param options Optional spinner configuration.
* @returns The spinner root element.
* @returns The spinner and its root element.
*/
export function createPixelSpinner(parent?: HTMLElement, options?: IPixelSpinnerOptions): HTMLElement {
export function createPixelSpinner(parent?: HTMLElement, options?: IPixelSpinnerOptions): IPixelSpinner {
const variant = options?.variant ?? 'grid';
const rootClass = variant === 'ring' ? 'span.monaco-pixel-spinner.monaco-pixel-spinner-ring' : 'span.monaco-pixel-spinner';
const root = h(rootClass).root;
Expand All @@ -52,8 +55,11 @@ export function createPixelSpinner(parent?: HTMLElement, options?: IPixelSpinner
root.appendChild(h('span.monaco-pixel-spinner-dot').root);
}
parent?.appendChild(root);
trackSpinner(root);
return root;
const animationTracking = trackSpinner(root);
return {
element: root,
dispose: () => animationTracking.dispose(),
};
}


Expand All @@ -67,62 +73,11 @@ const SPINNER_ANIMATION_NAMES = new Set([
'monaco-pixel-spinner-dot-cycle-short',
'monaco-pixel-spinner-ring-pulse',
]);
const observersByWindow = new Map<CodeWindow, IntersectionObserver>();
let unregisterWindowListener: IDisposable | undefined;

function getObserverFor(targetWindow: CodeWindow): IntersectionObserver | undefined {
if (typeof targetWindow.IntersectionObserver !== 'function') {
return undefined;
}
let observer = observersByWindow.get(targetWindow);
if (!observer) {
observer = new targetWindow.IntersectionObserver(entries => {
// Two passes so all style writes happen before any style read: the
// pause-class toggles below dirty style, and `getAnimations()` in the
// sync pass flushes it. Interleaving them would force a style recalc
// per entry instead of one for the whole batch.
const toResync: HTMLElement[] = [];
for (const entry of entries) {
const target = entry.target as HTMLElement;
if (!target.isConnected) {
observer!.unobserve(target);
continue;
}
target.classList.toggle(PAUSED_CLASS, !entry.isIntersecting);
if (entry.isIntersecting) {
toResync.push(target);
}
}
// Re-sync resumed spinners to the shared timeline: while paused
// offscreen the animation froze and its startTime drifted from
// spinners that kept running. Anchor it back (now that it is running
// again) so all visible spinners display the same frame.
for (const target of toResync) {
synchronizeCSSAnimations(target, { subtree: true, animationNames: SPINNER_ANIMATION_NAMES });
}
});
observersByWindow.set(targetWindow, observer);

if (!unregisterWindowListener) {
unregisterWindowListener = onDidUnregisterWindow(window => {
const obs = observersByWindow.get(window);
if (obs) {
obs.disconnect();
observersByWindow.delete(window);
}
});
}
}
return observer;
}

function trackSpinner(root: HTMLElement): void {
const observer = getObserverFor(getWindow(root));
if (!observer) {
return;
}
// Start paused; the observer delivers an initial notification that resumes
// the spinner if it is actually on screen.
root.classList.add(PAUSED_CLASS);
observer.observe(root);
function trackSpinner(root: HTMLElement): IDisposable {
return pauseCSSAnimationsWhenHidden(root, {
pausedClass: PAUSED_CLASS,
subtree: true,
animationNames: SPINNER_ANIMATION_NAMES,
});
}
2 changes: 2 additions & 0 deletions src/vs/sessions/SESSIONS_LIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Each session row displays:

Quick-chat rows (`.session-item.quick-chat`, driven by the reactive `ISession.isQuickChat` observable) are single-line entries: the details (second) row is hidden entirely and its content is never built — smaller icon, one line of title only, tighter row height (see `SessionsTreeDelegate.ITEM_HEIGHT_QUICK_CHAT`). Regular sessions keep the standard two-line row (title + details row).

Continuous row animations preserve their existing appearance while limiting rendering work: the title shimmer follows the same three-second path with at most 60 visual updates per second, and both it and the shared pixel spinner pause outside the viewport and whenever their document is hidden.

`SessionsFlatList` reuses the same session row renderer for sectionless surfaces, including the approval row and dynamic row height updates. Consumers that size their own container listen for content-height changes and relayout the list. When embedded inside another hover, consumers disable row hovers so moving over the list does not replace the parent hover.

### Grouping
Expand Down
33 changes: 24 additions & 9 deletions src/vs/sessions/browser/sessionStatusIcon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import * as DOM from '../../base/browser/dom.js';
import { disposableTimeout } from '../../base/common/async.js';
import { Disposable, DisposableStore } from '../../base/common/lifecycle.js';
import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../base/common/lifecycle.js';
import { ThemeIcon } from '../../base/common/themables.js';
import { createPixelSpinner } from '../../base/browser/ui/pixelSpinner/pixelSpinner.js';
import { asCssVariable } from '../../platform/theme/common/colorUtils.js';
Expand Down Expand Up @@ -60,6 +60,7 @@ export class SessionStatusIcon extends Disposable {

/** Owns the removal timers for outgoing icons mid cross-fade. */
private readonly _swapStore = this._register(new DisposableStore());
private readonly _iconDisposables = this._register(new DisposableMap<HTMLElement>());

constructor(
private readonly _container: HTMLElement,
Expand Down Expand Up @@ -98,6 +99,7 @@ export class SessionStatusIcon extends Disposable {
this._currentCacheKey = undefined;
this._lastInputs = undefined;
this._swapStore.clear();
this._iconDisposables.clearAndDisposeAll();
DOM.clearNode(this._container);
}

Expand All @@ -107,18 +109,21 @@ export class SessionStatusIcon extends Disposable {

let cacheKey: string;
let color: string;
let createIcon: () => HTMLElement;
let createIcon: () => { element: HTMLElement; disposable?: IDisposable };
if (isSpinner) {
const isNeedsInput = status === SessionStatus.NeedsInput;
const variant: 'grid' | 'ring' = isNeedsInput ? 'ring' : 'grid';
cacheKey = isNeedsInput ? PIXEL_SPINNER_RING_KEY : PIXEL_SPINNER_GRID_KEY;
color = isNeedsInput ? asCssVariable('list.warningForeground') : asCssVariable('textLink.foreground');
createIcon = () => createPixelSpinner(undefined, { variant });
createIcon = () => {
const spinner = createPixelSpinner(undefined, { variant });
return { element: spinner.element, disposable: spinner };
};
} else {
const icon = this._sessionsListModelService.getStatusIcon(status, isRead, isArchived, completedStateIcon);
cacheKey = ThemeIcon.asCSSSelector(icon);
color = icon.color ? asCssVariable(icon.color.id) : '';
createIcon = () => $(`span${cacheKey}`);
createIcon = () => ({ element: $(`span${cacheKey}`) });
}

// Reduced-motion fallback for needs-input pulses the codicon; harmless when a spinner is shown.
Expand All @@ -131,9 +136,9 @@ export class SessionStatusIcon extends Disposable {

const animate = this._currentCacheKey !== undefined;
this._currentCacheKey = cacheKey;
const iconEl = createIcon();
iconEl.style.color = color;
this._swapIcon(iconEl, animate);
const { element: iconElement, disposable: iconDisposable } = createIcon();
iconElement.style.color = color;
this._swapIcon(iconElement, animate, iconDisposable);
}

/** Updates the color of the current (non fading-out) icon without rebuilding it. */
Expand All @@ -152,10 +157,14 @@ export class SessionStatusIcon extends Disposable {
* new child can settle into its slot during the fade. Safe to call repeatedly:
* each outgoing element is marked so a follow-up swap never re-processes it.
*/
private _swapIcon(newChild: HTMLElement, animate: boolean): void {
private _swapIcon(newChild: HTMLElement, animate: boolean, disposable: IDisposable | undefined): void {
if (!animate) {
this._iconDisposables.clearAndDisposeAll();
DOM.clearNode(this._container);
this._container.appendChild(newChild);
if (disposable) {
this._iconDisposables.set(newChild, disposable);
}
return;
}
for (const existing of Array.from(this._container.children) as HTMLElement[]) {
Expand All @@ -168,11 +177,17 @@ export class SessionStatusIcon extends Disposable {
existing.style.left = '0';
existing.style.transition = `opacity ${ICON_SWAP_FADE_MS}ms ease`;
DOM.scheduleAtNextAnimationFrame(DOM.getWindow(existing), () => { existing.style.opacity = '0'; });
disposableTimeout(() => existing.remove(), ICON_SWAP_FADE_MS + 40, this._swapStore);
disposableTimeout(() => {
existing.remove();
this._iconDisposables.deleteAndDispose(existing);
}, ICON_SWAP_FADE_MS + 40, this._swapStore);
}
newChild.style.opacity = '0';
newChild.style.transition = `opacity ${ICON_SWAP_FADE_MS}ms ease`;
this._container.appendChild(newChild);
if (disposable) {
this._iconDisposables.set(newChild, disposable);
}
DOM.scheduleAtNextAnimationFrame(DOM.getWindow(newChild), () => { newChild.style.opacity = '1'; });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,11 @@
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: session-title-shimmer 3s linear infinite;
animation: session-title-shimmer 3s steps(180, jump-none) infinite;
}

.monaco-list-row:not(.selected) .session-item.in-progress .session-title.session-title-shimmer-paused {
animation-play-state: paused;
}

.vs-dark .monaco-list-row:not(.selected) .session-item.in-progress .session-title,
Expand Down
Loading
Loading