From 7ca60c06dcbe8de403929e965b259505914440b2 Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Fri, 25 Sep 2026 06:02:35 +0100 Subject: [PATCH 01/13] Add proximity transition setting and controls to behaviors page --- src/config.js | 11 ++++++++ src/schemas/gschemas.compiled | Bin 0 -> 1080 bytes ...sions.org.pshow.gradienttopbar.gschema.xml | 5 ++++ src/settings/behavioursPage.js | 26 ++++++++++++++++-- 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/schemas/gschemas.compiled 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/schemas/gschemas.compiled b/src/schemas/gschemas.compiled new file mode 100644 index 0000000000000000000000000000000000000000..b93c2715ddefbd584fe9329f8488a0f945bea87f GIT binary patch literal 1080 zcmbVKziSjh6dpBaeu$z$A%%qmi&=1YBMBI+QYc!8SgDmTdpGQk?(HlylQSDpunUsE zz*e+MkuqRoZy^YZ*htXI$|~*pz1h3nXd^h>yYKPMoA5XF8~eV;}^9{Jmti10be2Rz5S|*r~D-NUEoi|yQj}i(48sof&T^M z$8h#T^@8dtKMQ^hcpdS1@!KcjDL)TB1HM7*KYyPPPdUxq10EoDuFc;ep7PV+XYdI0 zzI~l*W3ho{(nRp3OBb5o<=`)3DPU(785cHa%KiI6rhmg}AX?+in)e-w4K-Aoau6mm z6CZM!)`Orln#Q>* z94PBV(f5aXDDn;f4iJnhUU8*@An-jxXH=R>(`rSDKM1TlveDCyVD(%(H!6k8SPb{Y z-k7mBX51OmZ+TProximity 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..d5df7b8 100644 --- a/src/settings/behavioursPage.js +++ b/src/settings/behavioursPage.js @@ -14,7 +14,9 @@ import { getStyleTrigger, setStyleTrigger, getProximityDistance, - setProximityDistance + setProximityDistance, + getProximityTransition, + setProximityTransition } from '../config.js'; import { MAXIMIZED_BEHAVIOR, STYLE_TRIGGER } from '../constants.js'; @@ -85,6 +87,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 +131,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', () => { @@ -135,9 +152,13 @@ class Behavior extends Adw.PreferencesPage { proximityDistanceRow.connect('notify::value', () => { setProximityDistance(this._settings, Math.round(proximityDistanceRow.value)); }); + 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,7 +167,8 @@ 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', () => { From 60e9da286ff050453565edaaf139dcc62ddd8a64 Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Fri, 25 Sep 2026 06:02:50 +0100 Subject: [PATCH 02/13] Add gradient transition functionality with set and remove methods --- src/gradient.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/gradient.js b/src/gradient.js index 3471c7d..bfdf94b 100644 --- a/src/gradient.js +++ b/src/gradient.js @@ -2,10 +2,13 @@ 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'; +let transitionActor = null; + const generateCss = config => { const { gradientDirection, colors, maximizedGradientDirection, maximizedColors } = config; return `.${GRADIENT_CLASS} { @@ -52,6 +55,27 @@ export const unloadGradientStylesheet = extensionPath => { theme.unload_stylesheet(getUserStylesheet(extensionPath)); }; +export const setGradientTransition = progress => { + if (!transitionActor) { + transitionActor = new St.Widget({ + style_class: MAXIMIZED_GRADIENT_CLASS, + reactive: false + }); + transitionActor.add_constraint(new Clutter.BindConstraint({ + source: panel, + coordinate: Clutter.BindCoordinate.SIZE + })); + panel.insert_child_at_index(transitionActor, 0); + } + + transitionActor.opacity = Math.round(progress * 255); +}; + +export const removeGradientTransition = () => { + transitionActor?.destroy(); + transitionActor = null; +}; + export const toggleGradient = (enabled, useAlternateStyle = false) => { // Remove all styles first panel.remove_style_class_name(GRADIENT_CLASS); From b8452a0a6c578678158167af5f06777b240ed619 Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Fri, 25 Sep 2026 17:18:39 +0100 Subject: [PATCH 03/13] Refactor transition actor allocation and update state handling for maximized windows --- src/events/behaviours/maximizedWindows.js | 9 +++++++++ src/events/windowEvents.js | 4 ++-- src/gradient.js | 17 +++++++++++++---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/events/behaviours/maximizedWindows.js b/src/events/behaviours/maximizedWindows.js index 0544f91..ad61bf9 100644 --- a/src/events/behaviours/maximizedWindows.js +++ b/src/events/behaviours/maximizedWindows.js @@ -12,6 +12,15 @@ export default class MaximizedWindows { return [BOTH, VERTICAL].includes(window.get_maximize_flags()); } + // TODO: remove this + getTriggerWindowIds(windows) { + return new Set( + windows + .filter(window => this.matches(window)) + .map(window => window.get_id()) + ); + } + // 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/windowEvents.js b/src/events/windowEvents.js index a4c7ffc..467bc91 100644 --- a/src/events/windowEvents.js +++ b/src/events/windowEvents.js @@ -95,8 +95,8 @@ export default class WindowEvents { * Forces a state update by re-evaluating maximized windows and emitting a state change * Used when the state needs to be refreshed regardless of detected changes */ - updateState(force = false) { - this.triggerWindows = this.getTriggerWindowIds(); + updateState(force = false, excludedWindowId = null) { + this.triggerWindows = this.getTriggerWindowIds(excludedWindowId); this.emitStateChange(force); } diff --git a/src/gradient.js b/src/gradient.js index bfdf94b..8f5e7bc 100644 --- a/src/gradient.js +++ b/src/gradient.js @@ -9,6 +9,13 @@ const MAXIMIZED_GRADIENT_CLASS = 'panel-maximized-gradient'; let transitionActor = null; +const allocateTransitionActor = () => { + const box = new Clutter.ActorBox(); + box.x2 = panel.width; + box.y2 = panel.height; + transitionActor.allocate(box); +}; + const generateCss = config => { const { gradientDirection, colors, maximizedGradientDirection, maximizedColors } = config; return `.${GRADIENT_CLASS} { @@ -61,11 +68,13 @@ export const setGradientTransition = progress => { style_class: MAXIMIZED_GRADIENT_CLASS, reactive: false }); - transitionActor.add_constraint(new Clutter.BindConstraint({ - source: panel, - coordinate: Clutter.BindCoordinate.SIZE - })); panel.insert_child_at_index(transitionActor, 0); + panel.connectObject( + 'notify::allocation', + allocateTransitionActor, + transitionActor + ); + allocateTransitionActor(); } transitionActor.opacity = Math.round(progress * 255); From f12bdd6d3313f32359a7476170a3a7ade140f937 Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Fri, 25 Sep 2026 17:19:56 +0100 Subject: [PATCH 04/13] Enhance proximity transition handling and gradient management in window events --- src/events/behaviours/proximityWindows.js | 33 +++++++++++++---- src/events/windowEvents.js | 23 +++++------- src/extension.js | 45 +++++++++++++++++++++-- 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/events/behaviours/proximityWindows.js b/src/events/behaviours/proximityWindows.js index 9248849..a29361b 100644 --- a/src/events/behaviours/proximityWindows.js +++ b/src/events/behaviours/proximityWindows.js @@ -4,19 +4,38 @@ import { getProximityDistance } from '../../config.js'; // Keeps all panel-layout dependencies inside the proximity behaviour. export default class ProximityWindows { - constructor(settings) { + constructor(settings, onProgressChanged) { this.settings = settings; + this.onProgressChanged = onProgressChanged; } - matches(window) { + getTriggerWindowIds(windows) { const { primaryMonitor } = layoutManager; - if (!primaryMonitor) - return false; + if (!primaryMonitor) { + // reset on new montiors + this.onProgressChanged(0); + return new Set(); + } - // 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(); + let triggerProgress = 0; + const triggerWindowIds = new Set(); + + windows.forEach(window => { + const windowDistance = window.get_frame_rect().y - panelBottom; + // TODO: extract this in a util method + const progress = distance === 0 + ? Number(windowDistance <= 0) + : Math.max(0, Math.min(1, 1 - windowDistance / distance)); + + if (progress > 0) + triggerWindowIds.add(window.get_id()); + triggerProgress = Math.max(triggerProgress, progress); + }); + + this.onProgressChanged(triggerProgress); + return triggerWindowIds; } // Proximity can change when a window moves, resizes, or changes workspace. diff --git a/src/events/windowEvents.js b/src/events/windowEvents.js index 467bc91..88e35a3 100644 --- a/src/events/windowEvents.js +++ b/src/events/windowEvents.js @@ -135,9 +135,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()); }; /** @@ -163,8 +162,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()); }; /** @@ -232,7 +230,7 @@ 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); @@ -270,17 +268,16 @@ export default class WindowEvents { * * @returns {Set} A set containing the triggering window IDs */ - getTriggerWindowIds() { + getTriggerWindowIds(excludedWindowId = null) { 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()) - ); + const windows = this.workspace + .list_windows() + .filter(isVisibleApplicationWindow) + .filter(window => window.get_id() !== excludedWindowId); + + return this.behaviour.getTriggerWindowIds(windows); } setBehaviour(behaviour) { diff --git a/src/extension.js b/src/extension.js index dbf4033..60e556c 100755 --- a/src/extension.js +++ b/src/extension.js @@ -1,8 +1,15 @@ import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; -import { applyGradientStyle, toggleGradient, unloadGradientStylesheet } from './gradient.js'; +import { + applyGradientStyle, + removeGradientTransition, + setGradientTransition, + toggleGradient, + unloadGradientStylesheet +} from './gradient.js'; import { getConfig, getMaximizedBehavior, + getProximityTransition, getStyleTrigger, attachSettingsListeners, detachSettingsListeners @@ -17,11 +24,12 @@ export default class GradientTopBar extends Extension { onSettingsChanged(settings) { const config = getConfig(settings); applyGradientStyle(config, this.path); + removeGradientTransition(); const maximizedBehavior = getMaximizedBehavior(settings); this.windowEvents.setBehaviour( getStyleTrigger(settings) === STYLE_TRIGGER.PROXIMITY - ? new ProximityWindows(settings) + ? new ProximityWindows(settings, this.updateProximityTransition.bind(this)) : new MaximizedWindows() ); @@ -50,16 +58,43 @@ export default class GradientTopBar extends Extension { this.isAlternateStyleApplied = useAlternateStyle; } + updateProximityTransition(progress) { + // TODO: maybe put this in a util function with a descriptive name + const enabled = + !this.windowEvents?.inOverview && + getStyleTrigger(this._settings) === STYLE_TRIGGER.PROXIMITY && + getProximityTransition(this._settings) && + getMaximizedBehavior(this._settings) === MAXIMIZED_BEHAVIOR.APPLY_STYLE; + + if (!enabled) { + removeGradientTransition(); + return; + } + + this.toggleGradient(true, false); + setGradientTransition(progress); + } + setWindowStateCallback() { this.windowEvents.setStateChangeCallback( ({ triggerWindows, inOverview }) => { if (inOverview) { + removeGradientTransition(); this.toggleGradient(true, false); return; } const hasTriggeredWindows = triggerWindows.size > 0; const maximizedBehavior = getMaximizedBehavior(this._settings); + const useTransition = + getStyleTrigger(this._settings) === STYLE_TRIGGER.PROXIMITY && + getProximityTransition(this._settings) && + maximizedBehavior === MAXIMIZED_BEHAVIOR.APPLY_STYLE; + + if (useTransition) + return; + + removeGradientTransition(); if (!hasTriggeredWindows) { // No triggering windows, apply normal gradient @@ -99,7 +134,10 @@ export default class GradientTopBar extends Extension { global.window_manager, global.get_workspace_manager(), getStyleTrigger(this._settings) === STYLE_TRIGGER.PROXIMITY - ? new ProximityWindows(this._settings) + ? new ProximityWindows( + this._settings, + this.updateProximityTransition.bind(this) + ) : new MaximizedWindows() ); this.setWindowStateCallback(); @@ -122,6 +160,7 @@ export default class GradientTopBar extends Extension { } this.toggleGradient(false); + removeGradientTransition(); unloadGradientStylesheet(this.path); detachSettingsListeners(this._settings, this._settingsHandlerIds); From 11c05f40eaca0c2b9bb676920c5fcba5e5f0729f Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Fri, 25 Sep 2026 17:36:31 +0100 Subject: [PATCH 05/13] Refactor proximity transition logic and improve progress calculation in proximity windows --- src/events/behaviours/proximityWindows.js | 21 +++++++++++---------- src/extension.js | 16 ++++++---------- src/prefs.js | 2 +- src/schemas/gschemas.compiled | Bin 1080 -> 0 bytes 4 files changed, 18 insertions(+), 21 deletions(-) delete mode 100644 src/schemas/gschemas.compiled diff --git a/src/events/behaviours/proximityWindows.js b/src/events/behaviours/proximityWindows.js index a29361b..325c821 100644 --- a/src/events/behaviours/proximityWindows.js +++ b/src/events/behaviours/proximityWindows.js @@ -2,6 +2,11 @@ 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, onProgressChanged) { @@ -9,33 +14,29 @@ export default class ProximityWindows { this.onProgressChanged = onProgressChanged; } - getTriggerWindowIds(windows) { + evaluate(windows) { const { primaryMonitor } = layoutManager; if (!primaryMonitor) { - // reset on new montiors this.onProgressChanged(0); - return new Set(); + return []; } const distance = getProximityDistance(this.settings); const panelBottom = primaryMonitor.y + panel.get_height(); let triggerProgress = 0; - const triggerWindowIds = new Set(); + const triggerWindows = []; windows.forEach(window => { const windowDistance = window.get_frame_rect().y - panelBottom; - // TODO: extract this in a util method - const progress = distance === 0 - ? Number(windowDistance <= 0) - : Math.max(0, Math.min(1, 1 - windowDistance / distance)); + const progress = calculateProgress(windowDistance, distance); if (progress > 0) - triggerWindowIds.add(window.get_id()); + triggerWindows.push(window); triggerProgress = Math.max(triggerProgress, progress); }); this.onProgressChanged(triggerProgress); - return triggerWindowIds; + return triggerWindows; } // Proximity can change when a window moves, resizes, or changes workspace. diff --git a/src/extension.js b/src/extension.js index 60e556c..cea066c 100755 --- a/src/extension.js +++ b/src/extension.js @@ -58,15 +58,15 @@ export default class GradientTopBar extends Extension { this.isAlternateStyleApplied = useAlternateStyle; } - updateProximityTransition(progress) { - // TODO: maybe put this in a util function with a descriptive name - const enabled = - !this.windowEvents?.inOverview && + isProximityTransitionEnabled() { + return !this.windowEvents?.inOverview && getStyleTrigger(this._settings) === STYLE_TRIGGER.PROXIMITY && getProximityTransition(this._settings) && getMaximizedBehavior(this._settings) === MAXIMIZED_BEHAVIOR.APPLY_STYLE; + } - if (!enabled) { + updateProximityTransition(progress) { + if (!this.isProximityTransitionEnabled()) { removeGradientTransition(); return; } @@ -86,12 +86,8 @@ export default class GradientTopBar extends Extension { const hasTriggeredWindows = triggerWindows.size > 0; const maximizedBehavior = getMaximizedBehavior(this._settings); - const useTransition = - getStyleTrigger(this._settings) === STYLE_TRIGGER.PROXIMITY && - getProximityTransition(this._settings) && - maximizedBehavior === MAXIMIZED_BEHAVIOR.APPLY_STYLE; - if (useTransition) + if (this.isProximityTransitionEnabled()) return; removeGradientTransition(); 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/gschemas.compiled b/src/schemas/gschemas.compiled deleted file mode 100644 index b93c2715ddefbd584fe9329f8488a0f945bea87f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1080 zcmbVKziSjh6dpBaeu$z$A%%qmi&=1YBMBI+QYc!8SgDmTdpGQk?(HlylQSDpunUsE zz*e+MkuqRoZy^YZ*htXI$|~*pz1h3nXd^h>yYKPMoA5XF8~eV;}^9{Jmti10be2Rz5S|*r~D-NUEoi|yQj}i(48sof&T^M z$8h#T^@8dtKMQ^hcpdS1@!KcjDL)TB1HM7*KYyPPPdUxq10EoDuFc;ep7PV+XYdI0 zzI~l*W3ho{(nRp3OBb5o<=`)3DPU(785cHa%KiI6rhmg}AX?+in)e-w4K-Aoau6mm z6CZM!)`Orln#Q>* z94PBV(f5aXDDn;f4iJnhUU8*@An-jxXH=R>(`rSDKM1TlveDCyVD(%(H!6k8SPb{Y z-k7mBX51OmZ+T Date: Fri, 25 Sep 2026 17:37:24 +0100 Subject: [PATCH 06/13] Refactor window event handling to streamline trigger window ID evaluation --- src/events/behaviours/maximizedWindows.js | 9 ++------- src/events/windowEvents.js | 6 +++++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/events/behaviours/maximizedWindows.js b/src/events/behaviours/maximizedWindows.js index ad61bf9..8933569 100644 --- a/src/events/behaviours/maximizedWindows.js +++ b/src/events/behaviours/maximizedWindows.js @@ -12,13 +12,8 @@ export default class MaximizedWindows { return [BOTH, VERTICAL].includes(window.get_maximize_flags()); } - // TODO: remove this - getTriggerWindowIds(windows) { - return new Set( - windows - .filter(window => this.matches(window)) - .map(window => window.get_id()) - ); + evaluate(windows) { + return windows.filter(window => this.matches(window)); } // Maximization changes window size; moving an unmaximized window is irrelevant. diff --git a/src/events/windowEvents.js b/src/events/windowEvents.js index 88e35a3..e1d6f6b 100644 --- a/src/events/windowEvents.js +++ b/src/events/windowEvents.js @@ -277,7 +277,11 @@ export default class WindowEvents { .filter(isVisibleApplicationWindow) .filter(window => window.get_id() !== excludedWindowId); - return this.behaviour.getTriggerWindowIds(windows); + return new Set( + this.behaviour + .evaluate(windows) + .map(window => window.get_id()) + ); } setBehaviour(behaviour) { From f91fe6c2defd7a652d574fc11028f2f5a6340c2e Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Fri, 25 Sep 2026 17:46:28 +0100 Subject: [PATCH 07/13] Refactor window evaluation methods to improve clarity and handle edge cases --- src/events/behaviours/maximizedWindows.js | 3 ++- src/events/behaviours/proximityWindows.js | 2 +- src/events/windowEvents.js | 2 +- src/gradient.js | 3 +++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/events/behaviours/maximizedWindows.js b/src/events/behaviours/maximizedWindows.js index 8933569..6c0c1a6 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,7 +13,7 @@ export default class MaximizedWindows { return [BOTH, VERTICAL].includes(window.get_maximize_flags()); } - evaluate(windows) { + getTriggerWindows(windows) { return windows.filter(window => this.matches(window)); } diff --git a/src/events/behaviours/proximityWindows.js b/src/events/behaviours/proximityWindows.js index 325c821..690a006 100644 --- a/src/events/behaviours/proximityWindows.js +++ b/src/events/behaviours/proximityWindows.js @@ -14,7 +14,7 @@ export default class ProximityWindows { this.onProgressChanged = onProgressChanged; } - evaluate(windows) { + getTriggerWindows(windows) { const { primaryMonitor } = layoutManager; if (!primaryMonitor) { this.onProgressChanged(0); diff --git a/src/events/windowEvents.js b/src/events/windowEvents.js index e1d6f6b..57352af 100644 --- a/src/events/windowEvents.js +++ b/src/events/windowEvents.js @@ -279,7 +279,7 @@ export default class WindowEvents { return new Set( this.behaviour - .evaluate(windows) + .getTriggerWindows(windows) .map(window => window.get_id()) ); } diff --git a/src/gradient.js b/src/gradient.js index 8f5e7bc..0f31c0e 100644 --- a/src/gradient.js +++ b/src/gradient.js @@ -63,6 +63,9 @@ export const unloadGradientStylesheet = extensionPath => { }; export const setGradientTransition = progress => { + if (!transitionActor && progress === 0) + return; + if (!transitionActor) { transitionActor = new St.Widget({ style_class: MAXIMIZED_GRADIENT_CLASS, From b9f1ac28b3f48fcdcb56922d17f65b94e89ca1b7 Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Fri, 25 Sep 2026 18:09:18 +0100 Subject: [PATCH 08/13] Add DiscreteEffect and ProgressiveEffect classes for gradient management --- src/effects/discreteEffect.js | 26 ++++++++++++++++++++++++++ src/effects/progressiveEffect.js | 17 +++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/effects/discreteEffect.js create mode 100644 src/effects/progressiveEffect.js diff --git a/src/effects/discreteEffect.js b/src/effects/discreteEffect.js new file mode 100644 index 0000000..2a9ca81 --- /dev/null +++ b/src/effects/discreteEffect.js @@ -0,0 +1,26 @@ +import { toggleGradient } from '../gradient.js'; +import { MAXIMIZED_BEHAVIOR } from '../constants.js'; + +export default class DiscreteEffect { + constructor(triggeredBehavior) { + this.triggeredBehavior = triggeredBehavior; + } + + apply(effectStrength) { + if (effectStrength === 0 || this.triggeredBehavior === MAXIMIZED_BEHAVIOR.KEEP_GRADIENT) { + toggleGradient(true, false); + return; + } + + if (this.triggeredBehavior === MAXIMIZED_BEHAVIOR.KEEP_THEME) { + toggleGradient(false); + return; + } + + toggleGradient(true, true); + } + + destroy() { + toggleGradient(false); + } +} diff --git a/src/effects/progressiveEffect.js b/src/effects/progressiveEffect.js new file mode 100644 index 0000000..d7ce7bd --- /dev/null +++ b/src/effects/progressiveEffect.js @@ -0,0 +1,17 @@ +import { + removeGradientTransition, + setGradientTransition, + toggleGradient +} from '../gradient.js'; + +export default class ProgressiveEffect { + apply(effectStrength) { + toggleGradient(true, false); + setGradientTransition(effectStrength); + } + + destroy() { + removeGradientTransition(); + toggleGradient(false); + } +} From 60ae644cab41c8742060ca6a53c4252a0dd3a809 Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Fri, 25 Sep 2026 18:37:36 +0100 Subject: [PATCH 09/13] Refactor gradient management and window event handling to support new effect classes --- src/events/states.js | 16 +---- src/extension.js | 147 +++++++++---------------------------------- src/gradient.js | 19 ++++-- 3 files changed, 48 insertions(+), 134 deletions(-) diff --git a/src/events/states.js b/src/events/states.js index fab9ae5..5d7db77 100644 --- a/src/events/states.js +++ b/src/events/states.js @@ -1,24 +1,14 @@ -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; }; diff --git a/src/extension.js b/src/extension.js index cea066c..33daa67 100755 --- a/src/extension.js +++ b/src/extension.js @@ -1,16 +1,7 @@ import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; -import { - applyGradientStyle, - removeGradientTransition, - setGradientTransition, - toggleGradient, - unloadGradientStylesheet -} from './gradient.js'; +import { applyGradientStyle, unloadGradientStylesheet } from './gradient.js'; import { getConfig, - getMaximizedBehavior, - getProximityTransition, - getStyleTrigger, attachSettingsListeners, detachSettingsListeners } from './config.js'; @@ -19,27 +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); - removeGradientTransition(); - - const maximizedBehavior = getMaximizedBehavior(settings); - this.windowEvents.setBehaviour( - getStyleTrigger(settings) === STYLE_TRIGGER.PROXIMITY - ? new ProximityWindows(settings, this.updateProximityTransition.bind(this)) - : 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; } @@ -47,106 +42,27 @@ 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; - } - - isProximityTransitionEnabled() { - return !this.windowEvents?.inOverview && - getStyleTrigger(this._settings) === STYLE_TRIGGER.PROXIMITY && - getProximityTransition(this._settings) && - getMaximizedBehavior(this._settings) === MAXIMIZED_BEHAVIOR.APPLY_STYLE; - } - - updateProximityTransition(progress) { - if (!this.isProximityTransitionEnabled()) { - removeGradientTransition(); - return; - } - - this.toggleGradient(true, false); - setGradientTransition(progress); - } - setWindowStateCallback() { this.windowEvents.setStateChangeCallback( - ({ triggerWindows, inOverview }) => { - if (inOverview) { - removeGradientTransition(); - this.toggleGradient(true, false); - return; - } - - const hasTriggeredWindows = triggerWindows.size > 0; - const maximizedBehavior = getMaximizedBehavior(this._settings); - - if (this.isProximityTransitionEnabled()) - return; - - removeGradientTransition(); - - 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; - } - } + ({ effectStrength }) => this.effect.apply(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, - this.updateProximityTransition.bind(this) - ) - : 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() { @@ -155,12 +71,11 @@ export default class GradientTopBar extends Extension { this.windowEvents = null; } - this.toggleGradient(false); - removeGradientTransition(); + 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 0f31c0e..ff8d543 100644 --- a/src/gradient.js +++ b/src/gradient.js @@ -8,6 +8,7 @@ const GRADIENT_CLASS = 'panel-gradient'; const MAXIMIZED_GRADIENT_CLASS = 'panel-maximized-gradient'; let transitionActor = null; +let appliedGradientClass = null; const allocateTransitionActor = () => { const box = new Clutter.ActorBox(); @@ -89,10 +90,18 @@ export const removeGradientTransition = () => { }; 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); - + 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; }; From ca9a59da0769664b06050b7a37a76b9d5f7595b0 Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Fri, 25 Sep 2026 18:39:09 +0100 Subject: [PATCH 10/13] Implement proximity distance save scheduling to optimize settings updates --- src/settings/behavioursPage.js | 39 +++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/settings/behavioursPage.js b/src/settings/behavioursPage.js index d5df7b8..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'; @@ -21,6 +22,8 @@ import { import { MAXIMIZED_BEHAVIOR, STYLE_TRIGGER } from '../constants.js'; +const PROXIMITY_DISTANCE_SAVE_DELAY_MS = 300; + const MaximizedBehavior = GObject.registerClass( { Properties: { @@ -56,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') @@ -150,7 +186,7 @@ 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); @@ -172,6 +208,7 @@ class Behavior extends Adw.PreferencesPage { }; const settingsHandlerIds = attachSettingsListeners(settings, onSettingsChanged); window.connect('close-request', () => { + commitProximityDistance(); detachSettingsListeners(settings, settingsHandlerIds); }); } From 43c472c3eb964da0be6ead8534fa74c1b27ffea4 Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Fri, 25 Sep 2026 18:39:28 +0100 Subject: [PATCH 11/13] Refactor window event handling and proximity calculations to improve effect strength evaluation --- src/events/behaviours/maximizedWindows.js | 4 +- src/events/behaviours/proximityWindows.js | 29 +++++---------- src/events/windowEvents.js | 45 ++++++++++------------- 3 files changed, 32 insertions(+), 46 deletions(-) diff --git a/src/events/behaviours/maximizedWindows.js b/src/events/behaviours/maximizedWindows.js index 6c0c1a6..817aebb 100644 --- a/src/events/behaviours/maximizedWindows.js +++ b/src/events/behaviours/maximizedWindows.js @@ -13,8 +13,8 @@ export default class MaximizedWindows { return [BOTH, VERTICAL].includes(window.get_maximize_flags()); } - getTriggerWindows(windows) { - return windows.filter(window => this.matches(window)); + getEffectStrength(windows) { + return Number(windows.some(window => this.matches(window))); } // Maximization changes window size; moving an unmaximized window is irrelevant. diff --git a/src/events/behaviours/proximityWindows.js b/src/events/behaviours/proximityWindows.js index 690a006..6f6fc04 100644 --- a/src/events/behaviours/proximityWindows.js +++ b/src/events/behaviours/proximityWindows.js @@ -9,34 +9,25 @@ const calculateProgress = (windowDistance, proximityDistance) => // Keeps all panel-layout dependencies inside the proximity behaviour. export default class ProximityWindows { - constructor(settings, onProgressChanged) { + constructor(settings) { this.settings = settings; - this.onProgressChanged = onProgressChanged; } - getTriggerWindows(windows) { + getEffectStrength(windows) { const { primaryMonitor } = layoutManager; - if (!primaryMonitor) { - this.onProgressChanged(0); - return []; - } + if (!primaryMonitor) + return 0; const distance = getProximityDistance(this.settings); const panelBottom = primaryMonitor.y + panel.get_height(); - let triggerProgress = 0; - const triggerWindows = []; - windows.forEach(window => { + return windows.reduce((effectStrength, window) => { const windowDistance = window.get_frame_rect().y - panelBottom; - const progress = calculateProgress(windowDistance, distance); - - if (progress > 0) - triggerWindows.push(window); - triggerProgress = Math.max(triggerProgress, progress); - }); - - this.onProgressChanged(triggerProgress); - return triggerWindows; + return Math.max( + effectStrength, + calculateProgress(windowDistance, distance) + ); + }, 0); } // Proximity can change when a window moves, resizes, or changes workspace. diff --git a/src/events/windowEvents.js b/src/events/windowEvents.js index 57352af..2f53f15 100644 --- a/src/events/windowEvents.js +++ b/src/events/windowEvents.js @@ -28,8 +28,7 @@ const isVisibleApplicationWindow = window => !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 +38,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,7 +51,7 @@ export default class WindowEvents { this.workspace = null; this.inOverview = false; - this.triggerWindows = new Set(); + this.effectStrength = 0; this.lastState = null; } @@ -68,11 +67,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,11 +91,11 @@ 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, excludedWindowId = null) { - this.triggerWindows = this.getTriggerWindowIds(excludedWindowId); + this.effectStrength = this.getEffectStrength(excludedWindowId); this.emitStateChange(force); } @@ -148,7 +147,7 @@ export default class WindowEvents { if (isDesktopIconsNG(window)) return; - this.behaviour.attachWindowEvents( + this.behaviourDetector.attachWindowEvents( this.eventManager, window, onWindowChange @@ -214,7 +213,7 @@ export default class WindowEvents { this.display, () => this.updateState() ); - this.behaviour.attachGlobalEvents( + this.behaviourDetector.attachGlobalEvents( this.eventManager, force => this.updateState(force) ); @@ -235,7 +234,7 @@ export default class WindowEvents { this.display.list_all_windows().forEach(attachWindowEvents); - this.triggerWindows = this.getTriggerWindowIds(); + this.effectStrength = this.getEffectStrength(); // Add screen lock/unlock event handling this.eventManager.attachUnlockScreenEvent(() => { @@ -257,42 +256,38 @@ export default class WindowEvents { }); this.workspace = null; - this.triggerWindows = new Set(); + this.effectStrength = 0; this.inOverview = null; this.lastState = 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(excludedWindowId = null) { + getEffectStrength(excludedWindowId = null) { if (!this.workspace) - return new Set(); + return 0; const windows = this.workspace .list_windows() .filter(isVisibleApplicationWindow) .filter(window => window.get_id() !== excludedWindowId); - return new Set( - this.behaviour - .getTriggerWindows(windows) - .map(window => window.get_id()) - ); + return this.behaviourDetector.getEffectStrength(windows); } - setBehaviour(behaviour) { - if (this.behaviour.constructor === behaviour.constructor) + 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(); From f1b5b5657262ec2eb449c4915767168cab11b442 Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Sat, 26 Sep 2026 02:51:48 +0100 Subject: [PATCH 12/13] smooth transiiton between workspaces --- src/effects/discreteEffect.js | 11 +++++++++-- src/gradient.js | 13 +++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/effects/discreteEffect.js b/src/effects/discreteEffect.js index 2a9ca81..6902c32 100644 --- a/src/effects/discreteEffect.js +++ b/src/effects/discreteEffect.js @@ -1,4 +1,8 @@ -import { toggleGradient } from '../gradient.js'; +import { + removeGradientTransition, + setGradientTransition, + toggleGradient +} from '../gradient.js'; import { MAXIMIZED_BEHAVIOR } from '../constants.js'; export default class DiscreteEffect { @@ -9,6 +13,7 @@ export default class DiscreteEffect { apply(effectStrength) { if (effectStrength === 0 || this.triggeredBehavior === MAXIMIZED_BEHAVIOR.KEEP_GRADIENT) { toggleGradient(true, false); + setGradientTransition(0); return; } @@ -17,10 +22,12 @@ export default class DiscreteEffect { return; } - toggleGradient(true, true); + toggleGradient(true, false); + setGradientTransition(1); } destroy() { + removeGradientTransition(); toggleGradient(false); } } diff --git a/src/gradient.js b/src/gradient.js index ff8d543..3910e35 100644 --- a/src/gradient.js +++ b/src/gradient.js @@ -6,6 +6,7 @@ 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; @@ -19,7 +20,10 @@ const allocateTransitionActor = () => { 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}; @@ -70,6 +74,7 @@ export const setGradientTransition = progress => { if (!transitionActor) { transitionActor = new St.Widget({ style_class: MAXIMIZED_GRADIENT_CLASS, + opacity: 0, reactive: false }); panel.insert_child_at_index(transitionActor, 0); @@ -81,7 +86,11 @@ export const setGradientTransition = progress => { allocateTransitionActor(); } - transitionActor.opacity = Math.round(progress * 255); + transitionActor.ease({ + opacity: Math.round(progress * 255), + duration: TRANSITION_DURATION_MS, + mode: Clutter.AnimationMode.EASE_OUT_QUAD + }); }; export const removeGradientTransition = () => { From 8c3d5ace37653ae18f6487d5b431978a90339fa0 Mon Sep 17 00:00:00 2001 From: Petar Vasilev Date: Sat, 26 Sep 2026 14:57:14 +0100 Subject: [PATCH 13/13] Enhance gradient effects and workspace transition handling with new scrub and settle methods --- src/effects/discreteEffect.js | 36 ++++++++ src/effects/effectStrength.js | 2 + src/effects/progressiveEffect.js | 17 ++++ src/events/states.js | 27 ++++++ src/events/windowEvents.js | 144 ++++++++++++++++++++++++++++--- src/extension.js | 20 ++++- src/gradient.js | 70 +++++++++++---- 7 files changed, 287 insertions(+), 29 deletions(-) create mode 100644 src/effects/effectStrength.js diff --git a/src/effects/discreteEffect.js b/src/effects/discreteEffect.js index 6902c32..973a487 100644 --- a/src/effects/discreteEffect.js +++ b/src/effects/discreteEffect.js @@ -1,9 +1,14 @@ 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) { @@ -26,6 +31,37 @@ export default class DiscreteEffect { 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 index d7ce7bd..4cd1b1e 100644 --- a/src/effects/progressiveEffect.js +++ b/src/effects/progressiveEffect.js @@ -1,8 +1,10 @@ import { removeGradientTransition, + scrubAlternateGradient, setGradientTransition, toggleGradient } from '../gradient.js'; +import { blendEffectStrength } from './effectStrength.js'; export default class ProgressiveEffect { apply(effectStrength) { @@ -10,6 +12,21 @@ export default class ProgressiveEffect { 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/states.js b/src/events/states.js index 5d7db77..90358ca 100644 --- a/src/events/states.js +++ b/src/events/states.js @@ -12,3 +12,30 @@ export const areSameState = (state1, state2) => { 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 2f53f15..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,15 +18,15 @@ 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; @@ -53,6 +56,9 @@ export default class WindowEvents { this.inOverview = false; this.effectStrength = 0; this.lastState = null; + this.workspaceTransitionGroup = null; + this.workspaceTransitionProgressHandlerId = null; + this.workspaceTransitionDestroyHandlerId = null; } /** @@ -96,7 +102,113 @@ export default class WindowEvents { */ updateState(force = false, excludedWindowId = null) { this.effectStrength = this.getEffectStrength(excludedWindowId); - this.emitStateChange(force); + 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; } /** @@ -123,7 +235,7 @@ export default class WindowEvents { */ const onWorkspaceChanged = workspaceManager => { this.workspace = workspaceManager.get_active_workspace(); - this.updateState(); + this.updateState(true); }; /** @@ -217,6 +329,7 @@ export default class WindowEvents { 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()); @@ -250,6 +363,7 @@ 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); @@ -259,6 +373,9 @@ export default class WindowEvents { this.effectStrength = 0; this.inOverview = null; this.lastState = null; + this.workspaceTransitionGroup = null; + this.workspaceTransitionProgressHandlerId = null; + this.workspaceTransitionDestroyHandlerId = null; this.enabled = false; } @@ -268,12 +385,19 @@ export default class WindowEvents { * @returns {number} The effect strength from zero to one */ getEffectStrength(excludedWindowId = null) { - if (!this.workspace) + return this.getEffectStrengthForWorkspace( + this.workspace, + excludedWindowId + ); + } + + getEffectStrengthForWorkspace(workspace, excludedWindowId = null) { + if (!workspace) return 0; - const windows = this.workspace + const windows = workspace .list_windows() - .filter(isVisibleApplicationWindow) + .filter(isApplicationWindow) .filter(window => window.get_id() !== excludedWindowId); return this.behaviourDetector.getEffectStrength(windows); diff --git a/src/extension.js b/src/extension.js index 33daa67..44a734c 100755 --- a/src/extension.js +++ b/src/extension.js @@ -43,9 +43,23 @@ export default class GradientTopBar extends Extension { } setWindowStateCallback() { - this.windowEvents.setStateChangeCallback( - ({ effectStrength }) => this.effect.apply(effectStrength) - ); + this.windowEvents.setStateChangeCallback(state => { + if (state.workspaceTransition === 'progress') { + this.effect.scrub( + state.startEffectStrength, + state.endEffectStrength, + state.transitionProgress + ); + return; + } + + if (state.workspaceTransition === 'complete') { + this.effect.settle(state.effectStrength); + return; + } + + this.effect.apply(state.effectStrength); + }); } enable() { diff --git a/src/gradient.js b/src/gradient.js index 3910e35..754ce0b 100644 --- a/src/gradient.js +++ b/src/gradient.js @@ -18,6 +18,33 @@ const allocateTransitionActor = () => { 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 `#panel { @@ -71,28 +98,39 @@ export const setGradientTransition = progress => { if (!transitionActor && progress === 0) return; - if (!transitionActor) { - transitionActor = new St.Widget({ - style_class: MAXIMIZED_GRADIENT_CLASS, - opacity: 0, - reactive: false - }); - panel.insert_child_at_index(transitionActor, 0); - panel.connectObject( - 'notify::allocation', - allocateTransitionActor, - transitionActor - ); - allocateTransitionActor(); - } - - transitionActor.ease({ + 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;