diff --git a/src/config.js b/src/config.js index a6f9cb7..ae10c8e 100644 --- a/src/config.js +++ b/src/config.js @@ -14,6 +14,7 @@ const SETTINGS_GSCHEMA_PATH = `/${SETTINGS_GSCHEMA.replaceAll('.', '/')}/`; export const MAXIMIZED_BEHAVIOR = 'maximized-behavior'; export const STYLE_TRIGGER = 'style-trigger'; export const PROXIMITY_DISTANCE = 'proximity-distance'; +export const PROXIMITY_TRANSITION = 'proximity-transition'; export const GRADIENT_DIRECTION = 'gradient-direction'; export const MAXIMIZED_GRADIENT_DIRECTION = 'maximized-gradient-direction'; export const COLORS = 'colors'; @@ -29,6 +30,7 @@ export const getConfig = settings => { const maximizedBehavior = settings.get_string(MAXIMIZED_BEHAVIOR); const styleTrigger = settings.get_string(STYLE_TRIGGER); const proximityDistance = settings.get_int(PROXIMITY_DISTANCE); + const proximityTransition = settings.get_boolean(PROXIMITY_TRANSITION); const colors = settings.get_value(COLORS).deep_unpack(); const gradientDirection = settings.get_string(GRADIENT_DIRECTION); const maximizedColors = settings.get_value(MAXIMIZED_COLORS).deep_unpack(); @@ -38,6 +40,7 @@ export const getConfig = settings => { maximizedBehavior, styleTrigger, proximityDistance, + proximityTransition, gradientDirection, colors: { start: colors[0], @@ -84,6 +87,13 @@ export const setProximityDistance = (settings, value) => { settings.set_int(PROXIMITY_DISTANCE, value); }; +export const getProximityTransition = settings => + settings.get_boolean(PROXIMITY_TRANSITION); + +export const setProximityTransition = (settings, value) => { + settings.set_boolean(PROXIMITY_TRANSITION, value); +}; + /** * Gets the gradient direction setting * @@ -178,6 +188,7 @@ export const attachSettingsListeners = (settings, listener) => { MAXIMIZED_BEHAVIOR, STYLE_TRIGGER, PROXIMITY_DISTANCE, + PROXIMITY_TRANSITION, COLORS, MAXIMIZED_COLORS, MAXIMIZED_GRADIENT_DIRECTION diff --git a/src/effects/discreteEffect.js b/src/effects/discreteEffect.js new file mode 100644 index 0000000..973a487 --- /dev/null +++ b/src/effects/discreteEffect.js @@ -0,0 +1,69 @@ +import { + finishOriginalThemeTransition, + removeGradientTransition, + scrubAlternateGradient, + scrubOriginalTheme, + setGradientTransition, + toggleGradient +} from '../gradient.js'; +import { MAXIMIZED_BEHAVIOR } from '../constants.js'; +import { blendEffectStrength } from './effectStrength.js'; + + +export default class DiscreteEffect { + constructor(triggeredBehavior) { + this.triggeredBehavior = triggeredBehavior; + } + + apply(effectStrength) { + if (effectStrength === 0 || this.triggeredBehavior === MAXIMIZED_BEHAVIOR.KEEP_GRADIENT) { + toggleGradient(true, false); + setGradientTransition(0); + return; + } + + if (this.triggeredBehavior === MAXIMIZED_BEHAVIOR.KEEP_THEME) { + toggleGradient(false); + return; + } + + toggleGradient(true, false); + setGradientTransition(1); + } + + scrub(startEffectStrength, endEffectStrength, progress) { + const effectStrength = blendEffectStrength( + Number(startEffectStrength > 0), + Number(endEffectStrength > 0), + progress + ); + + if (this.triggeredBehavior === MAXIMIZED_BEHAVIOR.KEEP_THEME) { + toggleGradient(false); + scrubOriginalTheme(effectStrength); + return; + } + + toggleGradient(true, false); + if (this.triggeredBehavior === MAXIMIZED_BEHAVIOR.APPLY_STYLE) + scrubAlternateGradient(effectStrength); + } + + settle(effectStrength) { + if (this.triggeredBehavior === MAXIMIZED_BEHAVIOR.KEEP_THEME) { + const showGradient = effectStrength === 0; + toggleGradient(showGradient, false); + finishOriginalThemeTransition(showGradient); + return; + } + + toggleGradient(true, false); + if (this.triggeredBehavior === MAXIMIZED_BEHAVIOR.APPLY_STYLE) + scrubAlternateGradient(Number(effectStrength > 0)); + } + + destroy() { + removeGradientTransition(); + toggleGradient(false); + } +} diff --git a/src/effects/effectStrength.js b/src/effects/effectStrength.js new file mode 100644 index 0000000..91ca963 --- /dev/null +++ b/src/effects/effectStrength.js @@ -0,0 +1,2 @@ +export const blendEffectStrength = (startStrength, endStrength, progress) => + startStrength + (endStrength - startStrength) * progress; diff --git a/src/effects/progressiveEffect.js b/src/effects/progressiveEffect.js new file mode 100644 index 0000000..4cd1b1e --- /dev/null +++ b/src/effects/progressiveEffect.js @@ -0,0 +1,34 @@ +import { + removeGradientTransition, + scrubAlternateGradient, + setGradientTransition, + toggleGradient +} from '../gradient.js'; +import { blendEffectStrength } from './effectStrength.js'; + +export default class ProgressiveEffect { + apply(effectStrength) { + toggleGradient(true, false); + setGradientTransition(effectStrength); + } + + scrub(startEffectStrength, endEffectStrength, progress) { + const effectStrength = blendEffectStrength( + startEffectStrength, + endEffectStrength, + progress + ); + toggleGradient(true, false); + scrubAlternateGradient(effectStrength); + } + + settle(effectStrength) { + toggleGradient(true, false); + scrubAlternateGradient(effectStrength); + } + + destroy() { + removeGradientTransition(); + toggleGradient(false); + } +} diff --git a/src/events/behaviours/maximizedWindows.js b/src/events/behaviours/maximizedWindows.js index 0544f91..817aebb 100644 --- a/src/events/behaviours/maximizedWindows.js +++ b/src/events/behaviours/maximizedWindows.js @@ -5,6 +5,7 @@ const { VERTICAL, BOTH } = Meta.MaximizeFlags; // Detects windows that occupy the full monitor or are vertically maximized. // This behaviour deliberately avoids layout and position-change dependencies. export default class MaximizedWindows { + // TODO: Handle tiled windows and monitor-sized windows that were not snapped. matches(window) { if (window.is_monitor_sized() || window.is_screen_sized()) return true; @@ -12,6 +13,10 @@ export default class MaximizedWindows { return [BOTH, VERTICAL].includes(window.get_maximize_flags()); } + getEffectStrength(windows) { + return Number(windows.some(window => this.matches(window))); + } + // Maximization changes window size; moving an unmaximized window is irrelevant. attachWindowEvents(eventManager, window, updateState) { eventManager.attachWindowEventOnce('size-changed', window, updateState); diff --git a/src/events/behaviours/proximityWindows.js b/src/events/behaviours/proximityWindows.js index 9248849..6f6fc04 100644 --- a/src/events/behaviours/proximityWindows.js +++ b/src/events/behaviours/proximityWindows.js @@ -2,21 +2,32 @@ import { layoutManager, panel } from 'resource:///org/gnome/shell/ui/main.js'; import { getProximityDistance } from '../../config.js'; +const calculateProgress = (windowDistance, proximityDistance) => + proximityDistance === 0 + ? Number(windowDistance <= 0) + : Math.max(0, Math.min(1, 1 - windowDistance / proximityDistance)); + // Keeps all panel-layout dependencies inside the proximity behaviour. export default class ProximityWindows { constructor(settings) { this.settings = settings; } - matches(window) { + getEffectStrength(windows) { const { primaryMonitor } = layoutManager; if (!primaryMonitor) - return false; + return 0; - // Include a small tolerance below the panel to avoid an exact-edge requirement. const distance = getProximityDistance(this.settings); - const panelBottom = primaryMonitor.y + panel.get_height() + distance; - return window.get_frame_rect().y <= panelBottom; + const panelBottom = primaryMonitor.y + panel.get_height(); + + return windows.reduce((effectStrength, window) => { + const windowDistance = window.get_frame_rect().y - panelBottom; + return Math.max( + effectStrength, + calculateProgress(windowDistance, distance) + ); + }, 0); } // Proximity can change when a window moves, resizes, or changes workspace. diff --git a/src/events/states.js b/src/events/states.js index fab9ae5..90358ca 100644 --- a/src/events/states.js +++ b/src/events/states.js @@ -1,24 +1,41 @@ -const eqSet = (as, bs) => { - if (as.size !== bs.size) - return false; - - for (const a of as) - { if (!bs.has(a)) - return false; } - - - return true; -}; - export const areSameState = (state1, state2) => { if ([state1, state2].includes(null)) return false; if (state1.inOverview !== state2.inOverview) return false; + if (state1.effectStrength !== state2.effectStrength) + return false; if ([state1.currentWorkspace, state2.currentWorkspace].includes(undefined)) return false; if (state1.currentWorkspace.index() !== state2.currentWorkspace.index()) return false; - return eqSet(state1.triggerWindows, state2.triggerWindows); + return true; +}; + +export const getWorkspaceTransition = (workspaces, snapPoints, progress) => { + if (workspaces.length === 0 || workspaces.length !== snapPoints.length) + return null; + + const lastIndex = snapPoints.length - 1; + const endIndex = snapPoints.findIndex(point => point >= progress); + + if (endIndex <= 0) { + const index = endIndex === 0 ? 0 : lastIndex; + return { + startWorkspace: workspaces[index], + endWorkspace: workspaces[index], + progress: 0 + }; + } + + const startIndex = endIndex - 1; + const distance = snapPoints[endIndex] - snapPoints[startIndex]; + return { + startWorkspace: workspaces[startIndex], + endWorkspace: workspaces[endIndex], + progress: distance === 0 + ? 0 + : (progress - snapPoints[startIndex]) / distance + }; }; diff --git a/src/events/windowEvents.js b/src/events/windowEvents.js index a4c7ffc..82fe73d 100644 --- a/src/events/windowEvents.js +++ b/src/events/windowEvents.js @@ -1,8 +1,11 @@ import Meta from 'gi://Meta'; -import { overview } from 'resource:///org/gnome/shell/ui/main.js'; +import { overview, wm } from 'resource:///org/gnome/shell/ui/main.js'; import EventManager from './eventManager.js'; -import { areSameState } from './states.js'; +import { + areSameState, + getWorkspaceTransition +} from './states.js'; const WORKSPACE_CHANGE_EVENT = 'workspace-switched'; const WINDOW_CREATE_EVENT = 'window-created'; @@ -15,21 +18,20 @@ const WINDOW_EXIT_MONITOR = 'window-left-monitor'; // const WINDOW_ADDED_TO_WORKSPACE = 'window-added'; const OVERVIEW_SHOWING = 'showing'; const OVERVIEW_HIDING = 'hiding'; +const WORKSPACE_SWIPE_BEGIN = 'begin'; // FIXME: this causes the overview to close on login const isDesktopIconsNG = window => window.customJS_ding !== undefined; // this is to ignore "Desktop Icons NG"'s window hacks -const isVisibleApplicationWindow = window => +const isApplicationWindow = window => !isDesktopIconsNG(window) && window.is_on_primary_monitor() && - window.showing_on_its_workspace() && - !window.is_hidden() && + !window.minimized && window.get_window_type() !== Meta.WindowType.DESKTOP && !window.skip_taskbar; /** - * Manages window events and tracks window state changes - * Used to detect windows that trigger alternate panel styling + * Manages window events and tracks the behaviour detector's effect strength. */ export default class WindowEvents { /** @@ -39,11 +41,11 @@ export default class WindowEvents { * @param {Meta.WindowManager} windowManager - The GNOME Shell window manager * @param {Meta.WorkspaceManager} workspaceManager - The GNOME Shell workspace manager */ - constructor(display, windowManager, workspaceManager, behaviour) { + constructor(display, windowManager, workspaceManager) { this.display = display; this.windowManager = windowManager; this.workspaceManager = workspaceManager; - this.behaviour = behaviour; + this.behaviourDetector = null; this.eventManager = new EventManager(); this.enabled = false; @@ -52,8 +54,11 @@ export default class WindowEvents { this.workspace = null; this.inOverview = false; - this.triggerWindows = new Set(); + this.effectStrength = 0; this.lastState = null; + this.workspaceTransitionGroup = null; + this.workspaceTransitionProgressHandlerId = null; + this.workspaceTransitionDestroyHandlerId = null; } /** @@ -68,11 +73,11 @@ export default class WindowEvents { /** * Gets the current window state * - * @returns {Object} The current trigger, workspace, and overview state + * @returns {Object} The current effect strength, workspace, and overview state */ getCurrentState() { return { - triggerWindows: this.triggerWindows, + effectStrength: this.inOverview ? 0 : this.effectStrength, currentWorkspace: this.workspace, inOverview: this.inOverview }; @@ -92,12 +97,118 @@ export default class WindowEvents { } /** - * Forces a state update by re-evaluating maximized windows and emitting a state change + * Re-evaluates the active behaviour detector and emits a state change. * Used when the state needs to be refreshed regardless of detected changes */ - updateState(force = false) { - this.triggerWindows = this.getTriggerWindowIds(); - this.emitStateChange(force); + updateState(force = false, excludedWindowId = null) { + this.effectStrength = this.getEffectStrength(excludedWindowId); + if (!this.workspaceTransitionGroup) + this.emitStateChange(force); + } + + getWorkspaceAnimationController() { + return wm?._workspaceAnimation; + } + + getWorkspaceTransitionState(monitorGroup) { + if (!Array.isArray(monitorGroup?._workspaceGroups) || + typeof monitorGroup.getSnapPoints !== 'function') + return null; + + const workspaces = monitorGroup._workspaceGroups + .map(group => group.workspace); + + return getWorkspaceTransition( + workspaces, + monitorGroup.getSnapPoints(), + monitorGroup.progress + ); + } + + updateWorkspaceTransition(monitorGroup) { + const transition = this.getWorkspaceTransitionState(monitorGroup); + if (!transition) + return; + + const startStrength = this.getEffectStrengthForWorkspace( + transition.startWorkspace + ); + const endStrength = this.getEffectStrengthForWorkspace( + transition.endWorkspace + ); + + this.stateChangeCallback({ + ...this.getCurrentState(), + workspaceTransition: 'progress', + startEffectStrength: startStrength, + endEffectStrength: endStrength, + transitionProgress: transition.progress + }); + } + + finishWorkspaceTransition() { + this.workspaceTransitionGroup = null; + this.workspaceTransitionProgressHandlerId = null; + this.workspaceTransitionDestroyHandlerId = null; + this.workspace = this.workspaceManager.get_active_workspace(); + this.effectStrength = this.getEffectStrength(); + + const currentState = this.getCurrentState(); + this.lastState = currentState; + this.stateChangeCallback({ + ...currentState, + workspaceTransition: 'complete' + }); + } + + startWorkspaceTransition() { + const monitorGroup = this.getWorkspaceAnimationController() + ?._switchData?.baseMonitorGroup; + if (!monitorGroup || monitorGroup === this.workspaceTransitionGroup) + return; + + this.workspaceTransitionGroup = monitorGroup; + this.workspaceTransitionProgressHandlerId = monitorGroup.connect( + 'notify::progress', + group => this.updateWorkspaceTransition(group) + ); + this.workspaceTransitionDestroyHandlerId = monitorGroup.connect( + 'destroy', + () => this.finishWorkspaceTransition() + ); + this.updateWorkspaceTransition(monitorGroup); + } + + attachWorkspaceTransitionEvents() { + const swipeTracker = this.getWorkspaceAnimationController() + ?._swipeTracker; + if (!swipeTracker) + return; + + this.eventManager.attachGlobalEventOnce( + WORKSPACE_SWIPE_BEGIN, + swipeTracker, + () => this.startWorkspaceTransition() + ); + } + + detachWorkspaceTransitionEvents() { + if (this.workspaceTransitionGroup && + this.workspaceTransitionProgressHandlerId) { + this.workspaceTransitionGroup.disconnect( + this.workspaceTransitionProgressHandlerId + ); + } + if (this.workspaceTransitionGroup && + this.workspaceTransitionDestroyHandlerId) { + this.workspaceTransitionGroup.disconnect( + this.workspaceTransitionDestroyHandlerId + ); + } + + this.workspaceTransitionGroup = null; + this.workspaceTransitionProgressHandlerId = null; + this.workspaceTransitionDestroyHandlerId = null; } /** @@ -124,7 +235,7 @@ export default class WindowEvents { */ const onWorkspaceChanged = workspaceManager => { this.workspace = workspaceManager.get_active_workspace(); - this.updateState(); + this.updateState(true); }; /** @@ -135,9 +246,8 @@ export default class WindowEvents { */ const onWindowDestroy = (_, windowActor) => { const window = windowActor.get_meta_window(); - this.triggerWindows.delete(window.get_id()); this.eventManager.disconnectWindowEvents(window); - this.emitStateChange(); + this.updateState(false, window.get_id()); }; /** @@ -149,7 +259,7 @@ export default class WindowEvents { if (isDesktopIconsNG(window)) return; - this.behaviour.attachWindowEvents( + this.behaviourDetector.attachWindowEvents( this.eventManager, window, onWindowChange @@ -163,8 +273,7 @@ export default class WindowEvents { * @param {Meta.WindowActor} windowActor - The window actor being minimized */ const onWindowMinimize = (_, windowActor) => { - this.triggerWindows.delete(windowActor.get_meta_window().get_id()); - this.emitStateChange(); + this.updateState(false, windowActor.get_meta_window().get_id()); }; /** @@ -216,10 +325,11 @@ export default class WindowEvents { this.display, () => this.updateState() ); - this.behaviour.attachGlobalEvents( + this.behaviourDetector.attachGlobalEvents( this.eventManager, force => this.updateState(force) ); + this.attachWorkspaceTransitionEvents(); // FIXME: the workspace changes so this needs to be attached to every workspace as it is created/deleted // this.eventManager.attachGlobalEventOnce(WINDOW_ADDED_TO_WORKSPACE, this.workspace, () => this.forceStateUpdate()); @@ -232,12 +342,12 @@ export default class WindowEvents { }); this.eventManager.attachGlobalEventOnce(OVERVIEW_HIDING, overview, () => { this.inOverview = false; - this.emitStateChange(); + this.updateState(true); }); this.display.list_all_windows().forEach(attachWindowEvents); - this.triggerWindows = this.getTriggerWindowIds(); + this.effectStrength = this.getEffectStrength(); // Add screen lock/unlock event handling this.eventManager.attachUnlockScreenEvent(() => { @@ -253,45 +363,55 @@ export default class WindowEvents { * Cleans up all event listeners and resets the state */ disable() { + this.detachWorkspaceTransitionEvents(); this.eventManager.disconnectAllEvents(); this.display.list_all_windows().forEach(window => { this.eventManager.disconnectWindowEvents(window); }); this.workspace = null; - this.triggerWindows = new Set(); + this.effectStrength = 0; this.inOverview = null; this.lastState = null; + this.workspaceTransitionGroup = null; + this.workspaceTransitionProgressHandlerId = null; + this.workspaceTransitionDestroyHandlerId = null; this.enabled = false; } /** - * Gets the IDs of windows that currently trigger alternate styling + * Gets the current effect strength from visible windows * - * @returns {Set} A set containing the triggering window IDs + * @returns {number} The effect strength from zero to one */ - getTriggerWindowIds() { - if (!this.workspace) - return new Set(); - - return new Set( - this.workspace - .list_windows() - .filter(isVisibleApplicationWindow) - .filter(window => this.behaviour.matches(window)) - .map(window => window.get_id()) + getEffectStrength(excludedWindowId = null) { + return this.getEffectStrengthForWorkspace( + this.workspace, + excludedWindowId ); } - setBehaviour(behaviour) { - if (this.behaviour.constructor === behaviour.constructor) + getEffectStrengthForWorkspace(workspace, excludedWindowId = null) { + if (!workspace) + return 0; + + const windows = workspace + .list_windows() + .filter(isApplicationWindow) + .filter(window => window.get_id() !== excludedWindowId); + + return this.behaviourDetector.getEffectStrength(windows); + } + + setBehaviourDetector(behaviourDetector) { + if (this.behaviourDetector?.constructor === behaviourDetector.constructor) return; const wasEnabled = this.enabled; if (wasEnabled) this.disable(); - this.behaviour = behaviour; + this.behaviourDetector = behaviourDetector; if (wasEnabled) this.enable(); diff --git a/src/extension.js b/src/extension.js index dbf4033..44a734c 100755 --- a/src/extension.js +++ b/src/extension.js @@ -1,9 +1,7 @@ import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; -import { applyGradientStyle, toggleGradient, unloadGradientStylesheet } from './gradient.js'; +import { applyGradientStyle, unloadGradientStylesheet } from './gradient.js'; import { getConfig, - getMaximizedBehavior, - getStyleTrigger, attachSettingsListeners, detachSettingsListeners } from './config.js'; @@ -12,26 +10,31 @@ import { MAXIMIZED_BEHAVIOR, STYLE_TRIGGER } from './constants.js'; import WindowEvents from './events/windowEvents.js'; import MaximizedWindows from './events/behaviours/maximizedWindows.js'; import ProximityWindows from './events/behaviours/proximityWindows.js'; +import DiscreteEffect from './effects/discreteEffect.js'; +import ProgressiveEffect from './effects/progressiveEffect.js'; export default class GradientTopBar extends Extension { - onSettingsChanged(settings) { + configure(settings) { const config = getConfig(settings); applyGradientStyle(config, this.path); - const maximizedBehavior = getMaximizedBehavior(settings); - this.windowEvents.setBehaviour( - getStyleTrigger(settings) === STYLE_TRIGGER.PROXIMITY - ? new ProximityWindows(settings) - : new MaximizedWindows() - ); - - - // If set to keep-gradient, disable window events to save resources - if (maximizedBehavior === MAXIMIZED_BEHAVIOR.KEEP_GRADIENT) { - if (this.windowEvents) - this.windowEvents.disable(); - // Always show the gradient when in keep-gradient mode - this.toggleGradient(true, false); + const behaviourDetector = config.styleTrigger === STYLE_TRIGGER.PROXIMITY + ? new ProximityWindows(settings) + : new MaximizedWindows(); + const effect = + config.styleTrigger === STYLE_TRIGGER.PROXIMITY && + config.proximityTransition && + config.maximizedBehavior === MAXIMIZED_BEHAVIOR.APPLY_STYLE + ? new ProgressiveEffect() + : new DiscreteEffect(config.maximizedBehavior); + + this.effect?.destroy(); + this.effect = effect; + this.windowEvents.setBehaviourDetector(behaviourDetector); + + if (config.maximizedBehavior === MAXIMIZED_BEHAVIOR.KEEP_GRADIENT) { + this.windowEvents.disable(); + this.effect.apply(0); return; } @@ -39,80 +42,41 @@ export default class GradientTopBar extends Extension { this.windowEvents.updateState(true); } - toggleGradient(enabled, useAlternateStyle = false) { - // this checks if the gradient state has changed - // so we don't add classes multiple times. - if (this.isEffectApplied === enabled && this.isAlternateStyleApplied === useAlternateStyle) - return; - - toggleGradient(enabled, useAlternateStyle); - this.isEffectApplied = enabled; - this.isAlternateStyleApplied = useAlternateStyle; - } - setWindowStateCallback() { - this.windowEvents.setStateChangeCallback( - ({ triggerWindows, inOverview }) => { - if (inOverview) { - this.toggleGradient(true, false); - return; - } - - const hasTriggeredWindows = triggerWindows.size > 0; - const maximizedBehavior = getMaximizedBehavior(this._settings); + this.windowEvents.setStateChangeCallback(state => { + if (state.workspaceTransition === 'progress') { + this.effect.scrub( + state.startEffectStrength, + state.endEffectStrength, + state.transitionProgress + ); + return; + } - if (!hasTriggeredWindows) { - // No triggering windows, apply normal gradient - this.toggleGradient(true, false); - return; - } - // Handle the configured behavior for triggering windows - switch (maximizedBehavior) { - case MAXIMIZED_BEHAVIOR.KEEP_GRADIENT: - // Keep the normal gradient - this.toggleGradient(true, false); - break; - case MAXIMIZED_BEHAVIOR.KEEP_THEME: - // Remove the gradient to show the default theme - this.toggleGradient(false, false); - break; - case MAXIMIZED_BEHAVIOR.APPLY_STYLE: - // Apply the alternate gradient style - this.toggleGradient(true, true); - break; - } + if (state.workspaceTransition === 'complete') { + this.effect.settle(state.effectStrength); + return; } - ); + + this.effect.apply(state.effectStrength); + }); } enable() { - this.isEffectApplied = false; - this.isAlternateStyleApplied = false; this._settings = this.getSettings(); - this._settingsHandlerIds = attachSettingsListeners( - this._settings, - this.onSettingsChanged.bind(this) - ); - + this.effect = null; this.windowEvents = new WindowEvents( global.display, global.window_manager, - global.get_workspace_manager(), - getStyleTrigger(this._settings) === STYLE_TRIGGER.PROXIMITY - ? new ProximityWindows(this._settings) - : new MaximizedWindows() + global.get_workspace_manager() ); this.setWindowStateCallback(); - const config = getConfig(this._settings); - - // Only initialize and enable window events if not using keep-gradient - if (getMaximizedBehavior(this._settings) !== MAXIMIZED_BEHAVIOR.KEEP_GRADIENT) - this.windowEvents.enable(); - - // initially set up the gradient - applyGradientStyle(config, this.path); - this.toggleGradient(true, false); + this._settingsHandlerIds = attachSettingsListeners( + this._settings, + this.configure.bind(this) + ); + this.configure(this._settings); } disable() { @@ -121,11 +85,11 @@ export default class GradientTopBar extends Extension { this.windowEvents = null; } - this.toggleGradient(false); + this.effect?.destroy(); + this.effect = null; unloadGradientStylesheet(this.path); detachSettingsListeners(this._settings, this._settingsHandlerIds); - this.isEffectApplied = false; this._settingsHandlerIds = null; this._settings = null; } diff --git a/src/gradient.js b/src/gradient.js index 3471c7d..754ce0b 100644 --- a/src/gradient.js +++ b/src/gradient.js @@ -2,13 +2,55 @@ import { panel } from 'resource:///org/gnome/shell/ui/main.js'; import St from 'gi://St'; import Gio from 'gi://Gio'; +import Clutter from 'gi://Clutter'; const GRADIENT_CLASS = 'panel-gradient'; const MAXIMIZED_GRADIENT_CLASS = 'panel-maximized-gradient'; +const TRANSITION_DURATION_MS = 200; + +let transitionActor = null; +let appliedGradientClass = null; + +const allocateTransitionActor = () => { + const box = new Clutter.ActorBox(); + box.x2 = panel.width; + box.y2 = panel.height; + transitionActor.allocate(box); +}; + +const getTransitionActor = styleClass => { + if (!transitionActor) { + transitionActor = new St.Widget({ + style_class: styleClass, + opacity: 0, + reactive: false + }); + panel.insert_child_at_index(transitionActor, 0); + panel.connectObject( + 'notify::allocation', + allocateTransitionActor, + transitionActor + ); + allocateTransitionActor(); + } else { + transitionActor.set_style_class_name(styleClass); + } + + return transitionActor; +}; + +const setTransitionProgress = (styleClass, progress) => { + const actor = getTransitionActor(styleClass); + actor.remove_transition('opacity'); + actor.opacity = Math.round(progress * 255); +}; const generateCss = config => { const { gradientDirection, colors, maximizedGradientDirection, maximizedColors } = config; - return `.${GRADIENT_CLASS} { + return `#panel { + transition-duration: ${TRANSITION_DURATION_MS}ms; + } + .${GRADIENT_CLASS} { background-color: transparent; background-gradient-direction: ${gradientDirection}; background-gradient-start: ${colors.start}; @@ -52,11 +94,61 @@ export const unloadGradientStylesheet = extensionPath => { theme.unload_stylesheet(getUserStylesheet(extensionPath)); }; -export const toggleGradient = (enabled, useAlternateStyle = false) => { - // Remove all styles first - panel.remove_style_class_name(GRADIENT_CLASS); - panel.remove_style_class_name(MAXIMIZED_GRADIENT_CLASS); +export const setGradientTransition = progress => { + if (!transitionActor && progress === 0) + return; + + getTransitionActor(MAXIMIZED_GRADIENT_CLASS).ease({ + opacity: Math.round(progress * 255), + duration: TRANSITION_DURATION_MS, + mode: Clutter.AnimationMode.EASE_OUT_QUAD + }); +}; + +export const scrubAlternateGradient = progress => { + setTransitionProgress(MAXIMIZED_GRADIENT_CLASS, progress); +}; + +export const scrubOriginalTheme = progress => { + setTransitionProgress(GRADIENT_CLASS, 1 - progress); +}; + +export const finishOriginalThemeTransition = showGradient => { + if (!transitionActor || !showGradient) { + removeGradientTransition(); + return; + } + const actor = transitionActor; + actor.ease({ + opacity: 0, + duration: TRANSITION_DURATION_MS, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + if (transitionActor === actor) + removeGradientTransition(); + } + }); +}; + +export const removeGradientTransition = () => { + transitionActor?.destroy(); + transitionActor = null; +}; + +export const toggleGradient = (enabled, useAlternateStyle = false) => { + let gradientClass = null; if (enabled) - panel.add_style_class_name(useAlternateStyle ? MAXIMIZED_GRADIENT_CLASS : GRADIENT_CLASS); + gradientClass = useAlternateStyle ? MAXIMIZED_GRADIENT_CLASS : GRADIENT_CLASS; + + if (gradientClass === appliedGradientClass) + return; + + if (appliedGradientClass) + panel.remove_style_class_name(appliedGradientClass); + + if (gradientClass) + panel.add_style_class_name(gradientClass); + + appliedGradientClass = gradientClass; }; diff --git a/src/prefs.js b/src/prefs.js index 7384c81..1133351 100644 --- a/src/prefs.js +++ b/src/prefs.js @@ -22,7 +22,7 @@ class GradientTopBarPreferences extends ExtensionPreferences { fillPreferencesWindow(window) { const settings = this.getSettings(); - window._settings = settings; // TODO: maybe redraw the whole prefs window on settings loaded from file? + window._settings = settings; const appearancePage = new AppearancePage(window, settings); window.add(appearancePage); diff --git a/src/schemas/org.gnome.shell.extensions.org.pshow.gradienttopbar.gschema.xml b/src/schemas/org.gnome.shell.extensions.org.pshow.gradienttopbar.gschema.xml index acbc895..1810558 100644 --- a/src/schemas/org.gnome.shell.extensions.org.pshow.gradienttopbar.gschema.xml +++ b/src/schemas/org.gnome.shell.extensions.org.pshow.gradienttopbar.gschema.xml @@ -25,6 +25,11 @@ Proximity distance Distance in pixels below the panel at which a window triggers the alternate style. + + false + Transition the alternate style based on proximity + Gradually blend the alternate style as a window approaches the panel. + 'keep-gradient' Behavior when the style trigger is active diff --git a/src/settings/behavioursPage.js b/src/settings/behavioursPage.js index 85137ff..ca381f6 100644 --- a/src/settings/behavioursPage.js +++ b/src/settings/behavioursPage.js @@ -2,6 +2,7 @@ import Gtk from 'gi://Gtk'; import Adw from 'gi://Adw'; import Gio from 'gi://Gio'; import GObject from 'gi://GObject'; +import GLib from 'gi://GLib'; import { gettext } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; @@ -14,11 +15,15 @@ import { getStyleTrigger, setStyleTrigger, getProximityDistance, - setProximityDistance + setProximityDistance, + getProximityTransition, + setProximityTransition } from '../config.js'; import { MAXIMIZED_BEHAVIOR, STYLE_TRIGGER } from '../constants.js'; +const PROXIMITY_DISTANCE_SAVE_DELAY_MS = 300; + const MaximizedBehavior = GObject.registerClass( { Properties: { @@ -54,6 +59,39 @@ class Behavior extends Adw.PreferencesPage { }); this._settings = settings; + let proximityDistanceTimeoutId = null; + + const commitProximityDistance = () => { + if (proximityDistanceTimeoutId) { + GLib.Source.remove(proximityDistanceTimeoutId); + proximityDistanceTimeoutId = null; + } + + const value = Math.round(proximityDistanceRow.value); + if (value !== getProximityDistance(this._settings)) + setProximityDistance(this._settings, value); + }; + + const scheduleProximityDistanceSave = () => { + if (proximityDistanceTimeoutId) { + GLib.Source.remove(proximityDistanceTimeoutId); + proximityDistanceTimeoutId = null; + } + + const value = Math.round(proximityDistanceRow.value); + if (value === getProximityDistance(this._settings)) + return; + + proximityDistanceTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + PROXIMITY_DISTANCE_SAVE_DELAY_MS, + () => { + proximityDistanceTimeoutId = null; + setProximityDistance(this._settings, value); + return GLib.SOURCE_REMOVE; + } + ); + }; const behaviorGroup = new Adw.PreferencesGroup({ title: gettext('Behavior') @@ -85,6 +123,12 @@ class Behavior extends Adw.PreferencesPage { digits: 0 }); + const proximityTransitionRow = new Adw.SwitchRow({ + title: gettext('Transition proximity style'), + subtitle: gettext('Blend the alternate style as windows approach the panel'), + active: getProximityTransition(this._settings) + }); + // Create model for maximized behavior dropdown const maximizedBehaviorModel = new Gio.ListStore({ item_type: MaximizedBehavior @@ -123,6 +167,15 @@ class Behavior extends Adw.PreferencesPage { proximityDistanceRow.set_sensitive( getStyleTrigger(this._settings) === STYLE_TRIGGER.PROXIMITY ); + const updateProximityControls = () => { + const usesProximity = getStyleTrigger(this._settings) === STYLE_TRIGGER.PROXIMITY; + proximityDistanceRow.set_sensitive(usesProximity); + proximityTransitionRow.set_sensitive( + usesProximity && + getMaximizedBehavior(this._settings) === MAXIMIZED_BEHAVIOR.APPLY_STYLE + ); + }; + updateProximityControls(); // Connect to changes maximizedBehaviorRow.connect('notify::selected', () => { @@ -133,11 +186,15 @@ class Behavior extends Adw.PreferencesPage { setStyleTrigger(this._settings, triggerRow.selectedItem.value); }); proximityDistanceRow.connect('notify::value', () => { - setProximityDistance(this._settings, Math.round(proximityDistanceRow.value)); + scheduleProximityDistanceSave(); + }); + proximityTransitionRow.connect('notify::active', () => { + setProximityTransition(this._settings, proximityTransitionRow.active); }); behaviorGroup.add(triggerRow); behaviorGroup.add(proximityDistanceRow); + behaviorGroup.add(proximityTransitionRow); behaviorGroup.add(maximizedBehaviorRow); this.add(behaviorGroup); @@ -146,10 +203,12 @@ class Behavior extends Adw.PreferencesPage { setMaximizedBehaviorOnRow(maximizedBehaviorRow, config.maximizedBehavior); setMaximizedBehaviorOnRow(triggerRow, config.styleTrigger); proximityDistanceRow.set_value(config.proximityDistance); - proximityDistanceRow.set_sensitive(config.styleTrigger === STYLE_TRIGGER.PROXIMITY); + proximityTransitionRow.set_active(config.proximityTransition); + updateProximityControls(); }; const settingsHandlerIds = attachSettingsListeners(settings, onSettingsChanged); window.connect('close-request', () => { + commitProximityDistance(); detachSettingsListeners(settings, settingsHandlerIds); }); }