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
33 changes: 31 additions & 2 deletions packages/core/ui/activity-indicator/index.linux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,56 @@ import { ActivityIndicatorBase, busyProperty } from './activity-indicator-common
import { colorProperty } from '../styling/style-properties';
import { Color } from '../../color';
import { setWidgetCss } from '../styling/gtk-style.linux';
import { connect } from '../../utils/gtk.linux';

/** GtkSpinner, visible while busy. */
export class ActivityIndicator extends ActivityIndicatorBase {
private _busy = false;
private _disconnectMap: (() => void) | null = null;

createNativeView() {
const spinner: any = gtk_spinner_new();
gtk_widget_set_size_request(spinner, 24, 24);
return spinner;
}

initNativeView(): void {
super.initNativeView();
// on the next turn: started from inside `map` it stays still too
this._disconnectMap = connect(this.nativeViewProtected, 'map', () => setTimeout(() => this._applyBusy()));
}

disposeNativeView(): void {
this._disconnectMap?.();
this._disconnectMap = null;
super.disposeNativeView();
}

get linux(): any {
return this.nativeViewProtected;
}

[busyProperty.setNative](value: boolean) {
this._busy = value;
this._applyBusy();
}

/**
* GtkSpinner spins with a CSS animation, which GTK leaves still when the spinner is started
* before it's on screen, until something restyles it (a theme change). So it starts only
* once mapped.
*/
private _applyBusy() {
const spinner = this.nativeViewProtected;
if (value) {
if (!spinner) {
return;
}
if (this._busy && gtk_widget_get_mapped(spinner)) {
gtk_spinner_start(spinner);
} else {
gtk_spinner_stop(spinner);
}
gtk_widget_set_opacity(spinner, value ? 1 : 0);
gtk_widget_set_opacity(spinner, this._busy ? 1 : 0);
}

[colorProperty.setNative](value: Color) {
Expand Down
49 changes: 36 additions & 13 deletions packages/core/ui/animation/index.linux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ export * from './animation-shared';
export * from './animation-types';

import { AnimationBase, Properties } from './animation-common';
import type { PropertyAnimation } from './animation-shared';
import type { AnimationDefinitionInternal, PropertyAnimation } from './animation-shared';
import { CubicBezierAnimationCurve } from '../../core-types/animation-types';
import { CoreTypes } from '../../core-types';
import { Color } from '../../color';
import { requestAnimationFrame, cancelAnimationFrame } from '../../animation-frame';
import { opacityProperty, backgroundColorProperty, rotateProperty, translateXProperty, translateYProperty, scaleXProperty, scaleYProperty, heightProperty, widthProperty } from '../styling/style-properties';

type Easing = (t: number) => number;

Expand Down Expand Up @@ -53,49 +54,63 @@ export function _resolveAnimationCurve(curve: any): Easing {

const mix = (from: number, to: number, t: number) => from + (to - from) * t;

/** Reads the current value and returns an applier for progress 0..1. */
function animator(animation: PropertyAnimation): ((t: number) => void) | null {
/**
* Reads the current value and returns an applier for progress 0..1.
*
* A CSS keyframe animation (valueSource `keyframe`) writes the style's keyframe values, as iOS
* and Android do: KeyframeAnimation sets them when it starts and they win over local values, so
* local writes wouldn't show until it ends.
*/
function animator(animation: PropertyAnimation, keyframe: boolean): ((t: number) => void) | null {
const target: any = animation.target;
const to: any = animation.value;
const set = (property: { name: string; keyframe: string }, value: any) => {
if (keyframe) {
target.style[property.keyframe] = value;
} else {
target[property.name] = value;
}
};
switch (animation.property) {
case Properties.opacity: {
const from = target.opacity ?? 1;
return (t) => (target.opacity = mix(from, to, t));
return (t) => set(opacityProperty, mix(from, to, t));
}
case Properties.backgroundColor: {
const from: Color = target.backgroundColor instanceof Color ? target.backgroundColor : new Color(0, 0, 0, 0);
const end: Color = to instanceof Color ? to : new Color(to);
return (t) => (target.backgroundColor = new Color(Math.round(mix(from.a, end.a, t)), Math.round(mix(from.r, end.r, t)), Math.round(mix(from.g, end.g, t)), Math.round(mix(from.b, end.b, t))));
return (t) => set(backgroundColorProperty, new Color(Math.round(mix(from.a, end.a, t)), Math.round(mix(from.r, end.r, t)), Math.round(mix(from.g, end.g, t)), Math.round(mix(from.b, end.b, t))));
}
case Properties.translate: {
const from = [target.translateX || 0, target.translateY || 0];
return (t) => {
target.translateX = mix(from[0], to.x, t);
target.translateY = mix(from[1], to.y, t);
set(translateXProperty, mix(from[0], to.x, t));
set(translateYProperty, mix(from[1], to.y, t));
};
}
case Properties.scale: {
const from = [target.scaleX ?? 1, target.scaleY ?? 1];
return (t) => {
target.scaleX = mix(from[0], to.x, t);
target.scaleY = mix(from[1], to.y, t);
set(scaleXProperty, mix(from[0], to.x, t));
set(scaleYProperty, mix(from[1], to.y, t));
};
}
case Properties.rotate: {
const from = target.rotate || 0;
const end = typeof to === 'number' ? to : to.z;
return (t) => (target.rotate = mix(from, end, t));
return (t) => set(rotateProperty, mix(from, end, t));
}
case Properties.width:
case Properties.height: {
const key = animation.property;
const property = key === Properties.width ? widthProperty : heightProperty;
const measured = key === Properties.width ? target.getActualSize?.().width : target.getActualSize?.().height;
const from = typeof target[key] === 'number' ? target[key] : measured;
// percentages / auto can't be tweened: jump at the end
if (typeof to !== 'number' || typeof from !== 'number') {
return (t) => t >= 1 && (target[key] = to);
return (t) => t >= 1 && set(property, to);
}
return (t) => (target[key] = mix(from, to, t));
return (t) => set(property, mix(from, to, t));
}
default:
return null;
Expand All @@ -106,6 +121,14 @@ function animator(animation: PropertyAnimation): ((t: number) => void) | null {
export class Animation extends AnimationBase {
private _frame = 0;
private _cancelled = false;
private _valueSource: 'animation' | 'keyframe' = 'animation';

constructor(animationDefinitions: Array<AnimationDefinitionInternal>, playSequentially?: boolean) {
super(animationDefinitions, playSequentially);
if (animationDefinitions.length > 0 && animationDefinitions[0].valueSource !== undefined) {
this._valueSource = animationDefinitions[0].valueSource;
}
}

public play(): any {
if (this.isPlaying) {
Expand Down Expand Up @@ -159,7 +182,7 @@ export class Animation extends AnimationBase {
continue;
}
// capture start values when the property starts moving
r.apply ??= animator(animation);
r.apply ??= animator(animation, this._valueSource === 'keyframe');
if (!r.apply) continue;
const cycle = elapsed / duration;
const finished = cycle >= iterations;
Expand Down
81 changes: 80 additions & 1 deletion packages/core/ui/core/view/index.linux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { layout } from '../../../utils';
import { Background } from '../../styling/background';
import { visibilityProperty, opacityProperty, rotateProperty, scaleXProperty, scaleYProperty, translateXProperty, translateYProperty, zIndexProperty, backgroundInternalProperty, directionProperty } from '../../styling/style-properties';
import { CoreTypes } from '../../../core-types';
import { own, release } from '../../../utils/gtk.linux';
import { connect, own, release } from '../../../utils/gtk.linux';
import { createContainer, insertChild, registerNativeView, unregisterNativeView } from './gtk-layout.linux';
import { px, releaseWidgetCss, setWidgetCss } from '../../styling/gtk-style.linux';

Expand All @@ -21,6 +21,9 @@ const PFLAG_LAYOUT_REQUIRED = 1 << 2;
// set while a root runs the layout pass: GTK is already allocating
let inLayoutPass = 0;

/** Pointer events backed by a GtkEventControllerMotion while something listens to them. */
const POINTER_EVENTS = ['mouseEnter', 'mouseLeave', 'mouseMove'];

function backgroundCss(background: Background): string {
if (!background) {
return '';
Expand Down Expand Up @@ -71,10 +74,12 @@ export class View extends ViewCommon {
registerNativeView(value, this);
}
super.setNativeView(value);
this._syncPointerMotion();
}

disposeNativeView() {
const nativeView = this.nativeViewProtected;
this._releasePointerMotion();
super.disposeNativeView();
if (nativeView) {
unregisterNativeView(nativeView);
Expand All @@ -85,6 +90,80 @@ export class View extends ViewCommon {
this._isLaidOut = false;
}

public addEventListener(eventNames: string, callback: (data: any) => void, thisArg?: any, once?: boolean): void {
super.addEventListener(eventNames, callback, thisArg, once);
if (typeof eventNames === 'string' && POINTER_EVENTS.some((name) => eventNames.indexOf(name) !== -1)) {
this._syncPointerMotion();
}
}

public removeEventListener(eventNames: string, callback?: (data: any) => void, thisArg?: any): void {
super.removeEventListener(eventNames, callback, thisArg);
if (typeof eventNames === 'string' && POINTER_EVENTS.some((name) => eventNames.indexOf(name) !== -1)) {
this._syncPointerMotion();
}
}

private _pointerMotion: { widget: any; controller: any; disconnect: (() => void)[] } | null = null;
private _hovered = false;

/**
* Raises `mouseEnter`, `mouseLeave` and `mouseMove` (with getX/getY in the view's coordinates)
* and keeps the `hover` visual state, as Windows does, while any of them has a listener.
*/
private _syncPointerMotion(): void {
const widget = this.nativeViewProtected;
const wanted = !!widget && POINTER_EVENTS.some((name) => this.hasListeners(name));
if (this._pointerMotion && (!wanted || this._pointerMotion.widget !== widget)) {
this._releasePointerMotion();
}
if (!wanted || this._pointerMotion) {
return;
}
const controller = gtk_event_controller_motion_new();
const at = (eventName: string, x: number, y: number) => this.notify({ eventName, object: this, getX: () => x, getY: () => y } as any);
const disconnect = [
connect(
controller,
'enter',
(_c: any, x: number, y: number) => {
if (!this._hovered) {
this._hovered = true;
this._addVisualState('hover');
}
at('mouseEnter', x, y);
},
{ types: ['double', 'double'] },
),
connect(controller, 'motion', (_c: any, x: number, y: number) => at('mouseMove', x, y), { types: ['double', 'double'] }),
connect(controller, 'leave', () => {
if (this._hovered) {
this._hovered = false;
this._removeVisualState('hover');
}
this.notify({ eventName: 'mouseLeave', object: this });
}),
];
gtk_widget_add_controller(widget, controller);
this._pointerMotion = { widget, controller, disconnect };
}

private _releasePointerMotion(): void {
const motion = this._pointerMotion;
if (!motion) {
return;
}
this._pointerMotion = null;
for (const disconnect of motion.disconnect) {
disconnect();
}
gtk_widget_remove_controller(motion.widget, motion.controller);
if (this._hovered) {
this._hovered = false;
this._removeVisualState('hover');
}
}

public requestLayout(): void {
this._privateFlags |= PFLAG_FORCE_LAYOUT;
super.requestLayout();
Expand Down
Loading