From 2ea50c48871da00cb4715b53be628c2db24636f3 Mon Sep 17 00:00:00 2001 From: Andres Date: Sat, 21 Feb 2026 03:19:13 -0400 Subject: [PATCH 01/16] feat(workspaces): add smooth horizontal wheel switching for spaces --- src/zen/workspaces/ZenWorkspaces.mjs | 182 ++++++++++++++++++++++++--- 1 file changed, 167 insertions(+), 15 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 135a2bd986..3a1aec4560 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -41,6 +41,10 @@ class nsZenWorkspaces { _workspaceCache = []; #lastScrollTime = 0; + #lastHorizontalWheelEventTime = 0; + #horizontalScrollAccumulator = 0; + #horizontalScrollFinalizeTimer = null; + #horizontalWheelGestureActive = false; bookmarkMenus = [ "PlacesToolbar", @@ -526,17 +530,25 @@ class nsZenWorkspaces { if (!this.workspaceEnabled || !gNavToolbox.matches(":hover")) { return; } + // Some devices emit AppCommand and wheel for the same horizontal wheel action. + // Ignore AppCommand if a horizontal wheel event just occurred. + if (Date.now() - this.#lastHorizontalWheelEventTime < 250) { + return; + } + if (this.#horizontalWheelGestureActive) { + this.#cancelHorizontalWheelGesture(); + } const direction = this.naturalScroll ? -1 : 1; // event is forward or back switch (event.command) { case "Forward": - this.changeWorkspaceShortcut(1 * direction); + this.changeWorkspaceShortcut(1 * direction, true); event.stopImmediatePropagation(); event.preventDefault(); break; case "Back": - this.changeWorkspaceShortcut(-1 * direction); + this.changeWorkspaceShortcut(-1 * direction, true); event.stopImmediatePropagation(); event.preventDefault(); break; @@ -551,8 +563,9 @@ class nsZenWorkspaces { #setupSidebarHandlers() { const toolbox = gNavToolbox; - const scrollCooldown = 200; // Milliseconds to wait before allowing another scroll - const scrollThreshold = 1; // Minimum scroll delta to trigger workspace change + const verticalScrollCooldown = 200; // Milliseconds to wait before allowing another scroll + const verticalScrollThreshold = 1; + const horizontalSnapPositionThreshold = 55; toolbox.addEventListener( "wheel", @@ -561,12 +574,20 @@ class nsZenWorkspaces { return; } - // Only process non-gesture scrolls - if (event.deltaMode !== 1) { + const absDeltaX = Math.abs(event.deltaX); + const absDeltaY = Math.abs(event.deltaY); + const isHorizontalScroll = absDeltaX > 0 && absDeltaX >= absDeltaY; + const isVerticalScroll = absDeltaY > 0 && absDeltaY > absDeltaX; + + if (!isHorizontalScroll && !isVerticalScroll) { return; } - const isVerticalScroll = event.deltaY && !event.deltaX; + // Horizontal mouse wheels can report pixel deltas, so only enforce + // line-based deltas for vertical scrolling. + if (!isHorizontalScroll && event.deltaMode !== 1) { + return; + } //if the scroll is vertical this checks that a modifier key is used before proceeding if (isVerticalScroll) { @@ -585,20 +606,61 @@ class nsZenWorkspaces { } } - let currentTime = Date.now(); - if (currentTime - this.#lastScrollTime < scrollCooldown) { + const currentTime = Date.now(); + + if (isHorizontalScroll) { + if (this.#inChangingWorkspace) { + return; + } + this.#startHorizontalWheelGesture(); + const scrollDirection = this.naturalScroll ? -1 : 1; + const deltaPixels = this.#normalizeHorizontalWheelDelta(event) * scrollDirection; + if (!deltaPixels) { + return; + } + this.#lastHorizontalWheelEventTime = currentTime; + if ( + this.#horizontalScrollAccumulator !== 0 && + Math.sign(this.#horizontalScrollAccumulator) !== Math.sign(deltaPixels) + ) { + this.#horizontalScrollAccumulator = 0; + } + this.#horizontalScrollAccumulator += deltaPixels; + const stripWidth = + window.windowUtils.getBoundsWithoutFlushing( + document.getElementById("navigator-toolbox") + ).width + + window.windowUtils.getBoundsWithoutFlushing( + document.getElementById("zen-sidebar-splitter") + ).width * + 2; + let translateX = this.#horizontalScrollAccumulator; + let forceMultiplier = Math.max(0.5, 1 - Math.abs(translateX) / (stripWidth * 4.5)); + translateX *= forceMultiplier; + if (Math.abs(deltaPixels) > 0.8) { + this._swipeState.direction = deltaPixels > 0 ? "left" : "right"; + } + const currentWorkspace = this.getActiveWorkspaceFromCache(); + this._organizeWorkspaceStripLocations(currentWorkspace, true, translateX); + if (Math.abs(translateX) >= horizontalSnapPositionThreshold) { + void this.#finalizeHorizontalWheelGesture(true); + return; + } + this.#scheduleHorizontalWheelGestureFinalize(); return; } - //this decides which delta to use - const delta = isVerticalScroll ? event.deltaY : event.deltaX; - if (Math.abs(delta) < scrollThreshold) { + if (this.#horizontalWheelGestureActive) { + this.#cancelHorizontalWheelGesture(); + } + if (currentTime - this.#lastScrollTime < verticalScrollCooldown) { + return; + } + if (Math.abs(event.deltaY) < verticalScrollThreshold) { return; } - // Determine scroll direction - let rawDirection = delta > 0 ? 1 : -1; - + const rawDirection = event.deltaY > 0 ? 1 : -1; let direction = this.naturalScroll ? -1 : 1; this.changeWorkspaceShortcut(rawDirection * direction); @@ -608,6 +670,90 @@ class nsZenWorkspaces { ); } + #startHorizontalWheelGesture() { + if (this.#horizontalWheelGestureActive) { + return; + } + this.#horizontalWheelGestureActive = true; + gZenFolders.cancelPopupTimer(); + document.documentElement.setAttribute("swipe-gesture", "true"); + document.addEventListener("popupshown", this.popupOpenHandler, { once: true }); + this._swipeState = { + isGestureActive: true, + lastDelta: 0, + direction: null, + }; + Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", true); + } + + #normalizeHorizontalWheelDelta(event) { + switch (event.deltaMode) { + case event.DOM_DELTA_LINE: + return event.deltaX * 40; + case event.DOM_DELTA_PAGE: + return event.deltaX * 160; + case event.DOM_DELTA_PIXEL: + default: + return event.deltaX * 0.35; + } + } + + #scheduleHorizontalWheelGestureFinalize() { + if (this.#horizontalScrollFinalizeTimer) { + clearTimeout(this.#horizontalScrollFinalizeTimer); + } + this.#horizontalScrollFinalizeTimer = setTimeout(() => { + this.#horizontalScrollFinalizeTimer = null; + void this.#finalizeHorizontalWheelGesture(); + }, 180); + } + + async #finalizeHorizontalWheelGesture(forceSwitch = false) { + if (!this.#horizontalWheelGestureActive) { + return; + } + const threshold = 55; + const shouldSwitch = forceSwitch || Math.abs(this.#horizontalScrollAccumulator) >= threshold; + const workspaceOffset = this.#horizontalScrollAccumulator > 0 ? -1 : 1; + this.#horizontalScrollAccumulator = 0; + if (shouldSwitch) { + await this.changeWorkspaceShortcut(workspaceOffset, true); + this.#lastScrollTime = Date.now(); + } else { + this._cancelSwipeAnimation(); + } + this.#cleanupHorizontalWheelGesture(); + } + + #cancelHorizontalWheelGesture() { + if (!this.#horizontalWheelGestureActive) { + return; + } + if (this.#horizontalScrollFinalizeTimer) { + clearTimeout(this.#horizontalScrollFinalizeTimer); + this.#horizontalScrollFinalizeTimer = null; + } + this.#horizontalScrollAccumulator = 0; + this._cancelSwipeAnimation(); + this.#cleanupHorizontalWheelGesture(); + } + + #cleanupHorizontalWheelGesture() { + this.#horizontalWheelGestureActive = false; + this._swipeState = { + isGestureActive: false, + lastDelta: 0, + direction: null, + }; + Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", false); + document.documentElement.removeAttribute("swipe-gesture"); + gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); + document.documentElement.style.setProperty("--zen-background-opacity", "1"); + delete this._hasAnimatedBackgrounds; + this.updateTabsContainers(); + document.removeEventListener("popupshown", this.popupOpenHandler, { once: true }); + } + initializeGestureHandlers() { const elements = [ gNavToolbox, @@ -651,6 +797,12 @@ class nsZenWorkspaces { _popupOpenHandler() { // If a popup is opened, we should stop the swipe gesture if (this._swipeState?.isGestureActive) { + if (this.#horizontalScrollFinalizeTimer) { + clearTimeout(this.#horizontalScrollFinalizeTimer); + this.#horizontalScrollFinalizeTimer = null; + } + this.#horizontalWheelGestureActive = false; + this.#horizontalScrollAccumulator = 0; document.documentElement.removeAttribute("swipe-gesture"); gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); this.updateTabsContainers(); From 83cfd55116e1a6c4f81b0884a4efd28f50529280 Mon Sep 17 00:00:00 2001 From: Andres Date: Sun, 22 Feb 2026 04:06:32 -0400 Subject: [PATCH 02/16] refactor(workspaces): replace horizontal finalize magic number --- src/zen/workspaces/ZenWorkspaces.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 3a1aec4560..7afc3c08cb 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -566,6 +566,9 @@ class nsZenWorkspaces { const verticalScrollCooldown = 200; // Milliseconds to wait before allowing another scroll const verticalScrollThreshold = 1; const horizontalSnapPositionThreshold = 55; + // Keep this short so wheel gestures feel responsive, but long enough to + // wait for bursty horizontal wheel events before deciding where to snap. + const horizontalGestureFinalizeDelay = 180; toolbox.addEventListener( "wheel", @@ -646,7 +649,7 @@ class nsZenWorkspaces { void this.#finalizeHorizontalWheelGesture(true); return; } - this.#scheduleHorizontalWheelGestureFinalize(); + this.#scheduleHorizontalWheelGestureFinalize(horizontalGestureFinalizeDelay); return; } @@ -698,14 +701,14 @@ class nsZenWorkspaces { } } - #scheduleHorizontalWheelGestureFinalize() { + #scheduleHorizontalWheelGestureFinalize(finalizeDelay) { if (this.#horizontalScrollFinalizeTimer) { clearTimeout(this.#horizontalScrollFinalizeTimer); } this.#horizontalScrollFinalizeTimer = setTimeout(() => { this.#horizontalScrollFinalizeTimer = null; void this.#finalizeHorizontalWheelGesture(); - }, 180); + }, finalizeDelay); } async #finalizeHorizontalWheelGesture(forceSwitch = false) { From 9484ccf942fb65fcdabe50cfa1d7dbc160e9add3 Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 19:03:56 -0400 Subject: [PATCH 03/16] refactor(workspaces): migrate sidebar swipe handling to wheel events --- src/zen/workspaces/ZenWorkspaces.mjs | 201 +++++---------------------- 1 file changed, 37 insertions(+), 164 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 7afc3c08cb..5ac88ba245 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -42,7 +42,6 @@ class nsZenWorkspaces { #lastScrollTime = 0; #lastHorizontalWheelEventTime = 0; - #horizontalScrollAccumulator = 0; #horizontalScrollFinalizeTimer = null; #horizontalWheelGestureActive = false; @@ -179,7 +178,6 @@ class nsZenWorkspaces { this.workspaceEnabled && !this.isPrivateWindow ) { - this.initializeGestureHandlers(); this.initializeWorkspaceNavigation(); } } @@ -623,28 +621,12 @@ class nsZenWorkspaces { } this.#lastHorizontalWheelEventTime = currentTime; if ( - this.#horizontalScrollAccumulator !== 0 && - Math.sign(this.#horizontalScrollAccumulator) !== Math.sign(deltaPixels) + this._swipeState.lastDelta !== 0 && + Math.sign(this._swipeState.lastDelta) !== Math.sign(deltaPixels) ) { - this.#horizontalScrollAccumulator = 0; + this._swipeState.lastDelta = 0; } - this.#horizontalScrollAccumulator += deltaPixels; - const stripWidth = - window.windowUtils.getBoundsWithoutFlushing( - document.getElementById("navigator-toolbox") - ).width + - window.windowUtils.getBoundsWithoutFlushing( - document.getElementById("zen-sidebar-splitter") - ).width * - 2; - let translateX = this.#horizontalScrollAccumulator; - let forceMultiplier = Math.max(0.5, 1 - Math.abs(translateX) / (stripWidth * 4.5)); - translateX *= forceMultiplier; - if (Math.abs(deltaPixels) > 0.8) { - this._swipeState.direction = deltaPixels > 0 ? "left" : "right"; - } - const currentWorkspace = this.getActiveWorkspaceFromCache(); - this._organizeWorkspaceStripLocations(currentWorkspace, true, translateX); + const translateX = this.#applyHorizontalSwipeDelta(deltaPixels); if (Math.abs(translateX) >= horizontalSnapPositionThreshold) { void this.#finalizeHorizontalWheelGesture(true); return; @@ -701,6 +683,32 @@ class nsZenWorkspaces { } } + #applyHorizontalSwipeDelta(deltaPixels) { + const stripWidth = + window.windowUtils.getBoundsWithoutFlushing(document.getElementById("navigator-toolbox")) + .width + + window.windowUtils.getBoundsWithoutFlushing(document.getElementById("zen-sidebar-splitter")) + .width * + 2; + let translateX = this._swipeState.lastDelta + deltaPixels; + // Match workspace swipe resistance so wheel and gesture interactions feel consistent. + const forceMultiplier = Math.min(1, 1 - Math.abs(translateX) / (stripWidth * 4.5)); + if (forceMultiplier > 0.5) { + translateX *= forceMultiplier; + this._swipeState.lastDelta = deltaPixels + (translateX - deltaPixels) * 0.5; + } else { + translateX = this._swipeState.lastDelta; + } + + if (Math.abs(deltaPixels) > 0.8) { + this._swipeState.direction = deltaPixels > 0 ? "left" : "right"; + } + + const currentWorkspace = this.getActiveWorkspaceFromCache(); + this._organizeWorkspaceStripLocations(currentWorkspace, true, translateX); + return translateX; + } + #scheduleHorizontalWheelGestureFinalize(finalizeDelay) { if (this.#horizontalScrollFinalizeTimer) { clearTimeout(this.#horizontalScrollFinalizeTimer); @@ -716,10 +724,11 @@ class nsZenWorkspaces { return; } const threshold = 55; - const shouldSwitch = forceSwitch || Math.abs(this.#horizontalScrollAccumulator) >= threshold; - const workspaceOffset = this.#horizontalScrollAccumulator > 0 ? -1 : 1; - this.#horizontalScrollAccumulator = 0; - if (shouldSwitch) { + const swipeDelta = this._swipeState.lastDelta; + const shouldSwitch = forceSwitch || Math.abs(swipeDelta) >= threshold; + const workspaceOffset = swipeDelta > 0 ? -1 : 1; + this._swipeState.lastDelta = 0; + if (shouldSwitch && swipeDelta !== 0) { await this.changeWorkspaceShortcut(workspaceOffset, true); this.#lastScrollTime = Date.now(); } else { @@ -736,7 +745,7 @@ class nsZenWorkspaces { clearTimeout(this.#horizontalScrollFinalizeTimer); this.#horizontalScrollFinalizeTimer = null; } - this.#horizontalScrollAccumulator = 0; + this._swipeState.lastDelta = 0; this._cancelSwipeAnimation(); this.#cleanupHorizontalWheelGesture(); } @@ -757,46 +766,6 @@ class nsZenWorkspaces { document.removeEventListener("popupshown", this.popupOpenHandler, { once: true }); } - initializeGestureHandlers() { - const elements = [ - gNavToolbox, - // event handlers do not work on elements inside shadow DOM so we need to attach them directly - document.getElementById("tabbrowser-arrowscrollbox").shadowRoot.querySelector("scrollbox"), - ]; - - // Attach gesture handlers to each element - for (const element of elements) { - if (!element) { - continue; - } - this.attachGestureHandlers(element); - } - } - - attachGestureHandlers(element) { - element.addEventListener("MozSwipeGestureMayStart", this._handleSwipeMayStart.bind(this), true); - element.addEventListener("MozSwipeGestureStart", this._handleSwipeStart.bind(this), true); - element.addEventListener("MozSwipeGestureUpdate", this._handleSwipeUpdate.bind(this), true); - - // Use MozSwipeGesture instead of MozSwipeGestureEnd because MozSwipeGestureEnd is fired after animation ends, - // while MozSwipeGesture is fired immediately after swipe ends. - element.addEventListener("MozSwipeGesture", this._handleSwipeEnd.bind(this), true); - - element.addEventListener( - "MozSwipeGestureEnd", - () => { - Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", false); - document.documentElement.removeAttribute("swipe-gesture"); - gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); - document.documentElement.style.setProperty("--zen-background-opacity", "1"); - delete this._hasAnimatedBackgrounds; - this.updateTabsContainers(); - document.removeEventListener("popupshown", this.popupOpenHandler, { once: true }); - }, - true - ); - } - _popupOpenHandler() { // If a popup is opened, we should stop the swipe gesture if (this._swipeState?.isGestureActive) { @@ -805,7 +774,7 @@ class nsZenWorkspaces { this.#horizontalScrollFinalizeTimer = null; } this.#horizontalWheelGestureActive = false; - this.#horizontalScrollAccumulator = 0; + this._swipeState.lastDelta = 0; document.documentElement.removeAttribute("swipe-gesture"); gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); this.updateTabsContainers(); @@ -813,102 +782,6 @@ class nsZenWorkspaces { } } - _handleSwipeMayStart(event) { - if (this.privateWindowOrDisabled || this.#inChangingWorkspace) { - return; - } - if ( - event.target.closest("#zen-sidebar-foot-buttons") || - event.target.closest('#urlbar[zen-floating-urlbar="true"]') - ) { - return; - } - - // Only handle horizontal swipes - if (event.direction === event.DIRECTION_LEFT || event.direction === event.DIRECTION_RIGHT) { - event.preventDefault(); - event.stopPropagation(); - - // Set allowed directions based on available workspaces - event.allowedDirections |= event.DIRECTION_LEFT | event.DIRECTION_RIGHT; - } - } - - _handleSwipeStart(event) { - if (!this.workspaceEnabled) { - return; - } - - gZenFolders.cancelPopupTimer(); - - document.documentElement.setAttribute("swipe-gesture", "true"); - document.addEventListener("popupshown", this.popupOpenHandler, { once: true }); - - event.preventDefault(); - event.stopPropagation(); - this._swipeState = { - isGestureActive: true, - lastDelta: 0, - direction: null, - }; - Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", true); - } - - _handleSwipeUpdate(event) { - if (!this.workspaceEnabled || !this._swipeState?.isGestureActive) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const delta = event.delta * 300; - const stripWidth = - window.windowUtils.getBoundsWithoutFlushing(document.getElementById("navigator-toolbox")) - .width + - window.windowUtils.getBoundsWithoutFlushing(document.getElementById("zen-sidebar-splitter")) - .width * - 2; - let translateX = this._swipeState.lastDelta + delta; - // Add a force multiplier as we are translating the strip depending on how close to the edge we are - let forceMultiplier = Math.min(1, 1 - Math.abs(translateX) / (stripWidth * 4.5)); // 4.5 instead of 4 to add a bit of a buffer - if (forceMultiplier > 0.5) { - translateX *= forceMultiplier; - this._swipeState.lastDelta = delta + (translateX - delta) * 0.5; - } else { - translateX = this._swipeState.lastDelta; - } - - if (Math.abs(delta) > 0.8) { - this._swipeState.direction = delta > 0 ? "left" : "right"; - } - - // Apply a translateX to the tab strip to give the user feedback on the swipe - const currentWorkspace = this.getActiveWorkspaceFromCache(); - this._organizeWorkspaceStripLocations(currentWorkspace, true, translateX); - } - - async _handleSwipeEnd(event) { - if (!this.workspaceEnabled) { - return; - } - event.preventDefault(); - event.stopPropagation(); - const isRTL = document.documentElement.matches(":-moz-locale-dir(rtl)"); - const moveForward = (event.direction === SimpleGestureEvent.DIRECTION_RIGHT) !== isRTL; - - const rawDirection = moveForward ? 1 : -1; - const direction = this.naturalScroll ? -1 : 1; - await this.changeWorkspaceShortcut(rawDirection * direction, true); - - // Reset swipe state - this._swipeState = { - isGestureActive: false, - lastDelta: 0, - direction: null, - }; - } - get activeWorkspace() { return this.#activeWorkspace; } From 5f957f33a90030f18844e4e5c1aa0f798ba26ffb Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 19:10:46 -0400 Subject: [PATCH 04/16] fix(workspaces): restore smoother wheel accumulator behavior --- src/zen/workspaces/ZenWorkspaces.mjs | 29 +++++++++++++--------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 5ac88ba245..83bec1d748 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -42,6 +42,7 @@ class nsZenWorkspaces { #lastScrollTime = 0; #lastHorizontalWheelEventTime = 0; + #horizontalScrollAccumulator = 0; #horizontalScrollFinalizeTimer = null; #horizontalWheelGestureActive = false; @@ -621,11 +622,12 @@ class nsZenWorkspaces { } this.#lastHorizontalWheelEventTime = currentTime; if ( - this._swipeState.lastDelta !== 0 && - Math.sign(this._swipeState.lastDelta) !== Math.sign(deltaPixels) + this.#horizontalScrollAccumulator !== 0 && + Math.sign(this.#horizontalScrollAccumulator) !== Math.sign(deltaPixels) ) { - this._swipeState.lastDelta = 0; + this.#horizontalScrollAccumulator = 0; } + this.#horizontalScrollAccumulator += deltaPixels; const translateX = this.#applyHorizontalSwipeDelta(deltaPixels); if (Math.abs(translateX) >= horizontalSnapPositionThreshold) { void this.#finalizeHorizontalWheelGesture(true); @@ -690,15 +692,10 @@ class nsZenWorkspaces { window.windowUtils.getBoundsWithoutFlushing(document.getElementById("zen-sidebar-splitter")) .width * 2; - let translateX = this._swipeState.lastDelta + deltaPixels; + let translateX = this.#horizontalScrollAccumulator; // Match workspace swipe resistance so wheel and gesture interactions feel consistent. - const forceMultiplier = Math.min(1, 1 - Math.abs(translateX) / (stripWidth * 4.5)); - if (forceMultiplier > 0.5) { - translateX *= forceMultiplier; - this._swipeState.lastDelta = deltaPixels + (translateX - deltaPixels) * 0.5; - } else { - translateX = this._swipeState.lastDelta; - } + const forceMultiplier = Math.max(0.5, 1 - Math.abs(translateX) / (stripWidth * 4.5)); + translateX *= forceMultiplier; if (Math.abs(deltaPixels) > 0.8) { this._swipeState.direction = deltaPixels > 0 ? "left" : "right"; @@ -724,11 +721,11 @@ class nsZenWorkspaces { return; } const threshold = 55; - const swipeDelta = this._swipeState.lastDelta; + const swipeDelta = this.#horizontalScrollAccumulator; const shouldSwitch = forceSwitch || Math.abs(swipeDelta) >= threshold; const workspaceOffset = swipeDelta > 0 ? -1 : 1; - this._swipeState.lastDelta = 0; - if (shouldSwitch && swipeDelta !== 0) { + this.#horizontalScrollAccumulator = 0; + if (shouldSwitch) { await this.changeWorkspaceShortcut(workspaceOffset, true); this.#lastScrollTime = Date.now(); } else { @@ -745,7 +742,7 @@ class nsZenWorkspaces { clearTimeout(this.#horizontalScrollFinalizeTimer); this.#horizontalScrollFinalizeTimer = null; } - this._swipeState.lastDelta = 0; + this.#horizontalScrollAccumulator = 0; this._cancelSwipeAnimation(); this.#cleanupHorizontalWheelGesture(); } @@ -774,7 +771,7 @@ class nsZenWorkspaces { this.#horizontalScrollFinalizeTimer = null; } this.#horizontalWheelGestureActive = false; - this._swipeState.lastDelta = 0; + this.#horizontalScrollAccumulator = 0; document.documentElement.removeAttribute("swipe-gesture"); gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); this.updateTabsContainers(); From db53d36626298a6f58cfbf0d98586f976487776f Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 19:12:36 -0400 Subject: [PATCH 05/16] docs(workspaces): explain wheel swipe smoothing decisions --- src/zen/workspaces/ZenWorkspaces.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 83bec1d748..913a8956ce 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -625,6 +625,8 @@ class nsZenWorkspaces { this.#horizontalScrollAccumulator !== 0 && Math.sign(this.#horizontalScrollAccumulator) !== Math.sign(deltaPixels) ) { + // Reset on direction flips so small wheel nudges do not fight against + // stale momentum from the opposite side. this.#horizontalScrollAccumulator = 0; } this.#horizontalScrollAccumulator += deltaPixels; @@ -692,6 +694,8 @@ class nsZenWorkspaces { window.windowUtils.getBoundsWithoutFlushing(document.getElementById("zen-sidebar-splitter")) .width * 2; + // Keep an explicit wheel accumulator so low-amplitude wheel input still + // feels continuous instead of stepping between discrete event deltas. let translateX = this.#horizontalScrollAccumulator; // Match workspace swipe resistance so wheel and gesture interactions feel consistent. const forceMultiplier = Math.max(0.5, 1 - Math.abs(translateX) / (stripWidth * 4.5)); @@ -722,6 +726,8 @@ class nsZenWorkspaces { } const threshold = 55; const swipeDelta = this.#horizontalScrollAccumulator; + // forceSwitch is used when the visual strip translation already crossed + // the snap position threshold and should commit immediately. const shouldSwitch = forceSwitch || Math.abs(swipeDelta) >= threshold; const workspaceOffset = swipeDelta > 0 ? -1 : 1; this.#horizontalScrollAccumulator = 0; From 9c7aae44fa1b171bcbbc09515a4def7bc42c9be9 Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 19:13:53 -0400 Subject: [PATCH 06/16] tune(workspaces): slightly lower horizontal wheel snap distance --- src/zen/workspaces/ZenWorkspaces.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 913a8956ce..e8c078a25d 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -45,6 +45,9 @@ class nsZenWorkspaces { #horizontalScrollAccumulator = 0; #horizontalScrollFinalizeTimer = null; #horizontalWheelGestureActive = false; + // Slightly lower than the previous 55 to reduce required wheel travel + // while keeping accidental switches uncommon. + #horizontalWheelSnapThreshold = 50; bookmarkMenus = [ "PlacesToolbar", @@ -564,7 +567,6 @@ class nsZenWorkspaces { const verticalScrollCooldown = 200; // Milliseconds to wait before allowing another scroll const verticalScrollThreshold = 1; - const horizontalSnapPositionThreshold = 55; // Keep this short so wheel gestures feel responsive, but long enough to // wait for bursty horizontal wheel events before deciding where to snap. const horizontalGestureFinalizeDelay = 180; @@ -631,7 +633,7 @@ class nsZenWorkspaces { } this.#horizontalScrollAccumulator += deltaPixels; const translateX = this.#applyHorizontalSwipeDelta(deltaPixels); - if (Math.abs(translateX) >= horizontalSnapPositionThreshold) { + if (Math.abs(translateX) >= this.#horizontalWheelSnapThreshold) { void this.#finalizeHorizontalWheelGesture(true); return; } @@ -724,11 +726,10 @@ class nsZenWorkspaces { if (!this.#horizontalWheelGestureActive) { return; } - const threshold = 55; const swipeDelta = this.#horizontalScrollAccumulator; // forceSwitch is used when the visual strip translation already crossed // the snap position threshold and should commit immediately. - const shouldSwitch = forceSwitch || Math.abs(swipeDelta) >= threshold; + const shouldSwitch = forceSwitch || Math.abs(swipeDelta) >= this.#horizontalWheelSnapThreshold; const workspaceOffset = swipeDelta > 0 ? -1 : 1; this.#horizontalScrollAccumulator = 0; if (shouldSwitch) { From dca77471c4f8748ddca3b9283c41b4891ceebfd2 Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 19:19:22 -0400 Subject: [PATCH 07/16] tune(workspaces): increase wheel sensitivity without lowering snap threshold --- src/zen/workspaces/ZenWorkspaces.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index e8c078a25d..dacf8909bf 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -45,9 +45,10 @@ class nsZenWorkspaces { #horizontalScrollAccumulator = 0; #horizontalScrollFinalizeTimer = null; #horizontalWheelGestureActive = false; - // Slightly lower than the previous 55 to reduce required wheel travel - // while keeping accidental switches uncommon. - #horizontalWheelSnapThreshold = 50; + #horizontalWheelSnapThreshold = 55; + // Multiplier for wheel-derived deltas. Slightly >1 keeps behavior familiar + // while requiring a bit less physical wheel travel. + #horizontalWheelSensitivity = 1.12; bookmarkMenus = [ "PlacesToolbar", @@ -618,7 +619,10 @@ class nsZenWorkspaces { } this.#startHorizontalWheelGesture(); const scrollDirection = this.naturalScroll ? -1 : 1; - const deltaPixels = this.#normalizeHorizontalWheelDelta(event) * scrollDirection; + const deltaPixels = + this.#normalizeHorizontalWheelDelta(event) * + scrollDirection * + this.#horizontalWheelSensitivity; if (!deltaPixels) { return; } From ba9f00b22148ec870be8b3fdf3017c49a8218244 Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 19:23:31 -0400 Subject: [PATCH 08/16] tune(workspaces): finalize horizontal wheel swipe on idle --- src/zen/workspaces/ZenWorkspaces.mjs | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index dacf8909bf..63c94b110c 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -46,9 +46,6 @@ class nsZenWorkspaces { #horizontalScrollFinalizeTimer = null; #horizontalWheelGestureActive = false; #horizontalWheelSnapThreshold = 55; - // Multiplier for wheel-derived deltas. Slightly >1 keeps behavior familiar - // while requiring a bit less physical wheel travel. - #horizontalWheelSensitivity = 1.12; bookmarkMenus = [ "PlacesToolbar", @@ -619,10 +616,7 @@ class nsZenWorkspaces { } this.#startHorizontalWheelGesture(); const scrollDirection = this.naturalScroll ? -1 : 1; - const deltaPixels = - this.#normalizeHorizontalWheelDelta(event) * - scrollDirection * - this.#horizontalWheelSensitivity; + const deltaPixels = this.#normalizeHorizontalWheelDelta(event) * scrollDirection; if (!deltaPixels) { return; } @@ -636,11 +630,7 @@ class nsZenWorkspaces { this.#horizontalScrollAccumulator = 0; } this.#horizontalScrollAccumulator += deltaPixels; - const translateX = this.#applyHorizontalSwipeDelta(deltaPixels); - if (Math.abs(translateX) >= this.#horizontalWheelSnapThreshold) { - void this.#finalizeHorizontalWheelGesture(true); - return; - } + this.#applyHorizontalSwipeDelta(deltaPixels); this.#scheduleHorizontalWheelGestureFinalize(horizontalGestureFinalizeDelay); return; } @@ -726,14 +716,12 @@ class nsZenWorkspaces { }, finalizeDelay); } - async #finalizeHorizontalWheelGesture(forceSwitch = false) { + async #finalizeHorizontalWheelGesture() { if (!this.#horizontalWheelGestureActive) { return; } const swipeDelta = this.#horizontalScrollAccumulator; - // forceSwitch is used when the visual strip translation already crossed - // the snap position threshold and should commit immediately. - const shouldSwitch = forceSwitch || Math.abs(swipeDelta) >= this.#horizontalWheelSnapThreshold; + const shouldSwitch = Math.abs(swipeDelta) >= this.#horizontalWheelSnapThreshold; const workspaceOffset = swipeDelta > 0 ? -1 : 1; this.#horizontalScrollAccumulator = 0; if (shouldSwitch) { From a84a1e0e4df3bf3a069a91ad7a0b0305d1361a3f Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 19:28:41 -0400 Subject: [PATCH 09/16] tune(workspaces): snap horizontal wheel gesture only after idle --- src/zen/workspaces/ZenWorkspaces.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 63c94b110c..52c7ca3d66 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -567,7 +567,7 @@ class nsZenWorkspaces { const verticalScrollThreshold = 1; // Keep this short so wheel gestures feel responsive, but long enough to // wait for bursty horizontal wheel events before deciding where to snap. - const horizontalGestureFinalizeDelay = 180; + const horizontalGestureFinalizeDelay = 120; toolbox.addEventListener( "wheel", From adbcab8d5e65afbcbaf7794a087b9f87bea615b9 Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 19:31:59 -0400 Subject: [PATCH 10/16] tune(workspaces): reduce horizontal wheel finalize delay --- src/zen/workspaces/ZenWorkspaces.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 52c7ca3d66..c17cfd198a 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -567,7 +567,7 @@ class nsZenWorkspaces { const verticalScrollThreshold = 1; // Keep this short so wheel gestures feel responsive, but long enough to // wait for bursty horizontal wheel events before deciding where to snap. - const horizontalGestureFinalizeDelay = 120; + const horizontalGestureFinalizeDelay = 70; toolbox.addEventListener( "wheel", From 5b31d0b1ba7d40ee4ba04b1f91ecbecdfd64340f Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 20:00:55 -0400 Subject: [PATCH 11/16] tune(workspaces): polish wheel scrub and settle timing --- src/zen/workspaces/ZenWorkspaces.mjs | 45 ++++++++++++++++------------ 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index c17cfd198a..89aca606f9 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -565,9 +565,13 @@ class nsZenWorkspaces { const verticalScrollCooldown = 200; // Milliseconds to wait before allowing another scroll const verticalScrollThreshold = 1; - // Keep this short so wheel gestures feel responsive, but long enough to - // wait for bursty horizontal wheel events before deciding where to snap. - const horizontalGestureFinalizeDelay = 70; + const horizontalWheelSensitivity = 1.1; + // Keep settle timing fast for wheel UX, while still respecting platform + // transaction timing as an upper bound. + const horizontalGestureFinalizeDelay = Math.min( + Services.prefs.getIntPref("mousewheel.transaction.timeout", 150), + 95 + ); toolbox.addEventListener( "wheel", @@ -611,27 +615,30 @@ class nsZenWorkspaces { const currentTime = Date.now(); if (isHorizontalScroll) { - if (this.#inChangingWorkspace) { - return; - } - this.#startHorizontalWheelGesture(); const scrollDirection = this.naturalScroll ? -1 : 1; - const deltaPixels = this.#normalizeHorizontalWheelDelta(event) * scrollDirection; + const deltaPixels = + this.#normalizeHorizontalWheelDelta(event) * + scrollDirection * + horizontalWheelSensitivity; if (!deltaPixels) { return; } this.#lastHorizontalWheelEventTime = currentTime; - if ( - this.#horizontalScrollAccumulator !== 0 && - Math.sign(this.#horizontalScrollAccumulator) !== Math.sign(deltaPixels) - ) { - // Reset on direction flips so small wheel nudges do not fight against - // stale momentum from the opposite side. - this.#horizontalScrollAccumulator = 0; + if (this.#inChangingWorkspace) { + return; } + this.#startHorizontalWheelGesture(); this.#horizontalScrollAccumulator += deltaPixels; - this.#applyHorizontalSwipeDelta(deltaPixels); - this.#scheduleHorizontalWheelGestureFinalize(horizontalGestureFinalizeDelay); + const translateX = this.#applyHorizontalSwipeDelta(deltaPixels); + if (Math.abs(translateX) >= this.#horizontalWheelSnapThreshold) { + if (this.#horizontalScrollFinalizeTimer) { + clearTimeout(this.#horizontalScrollFinalizeTimer); + this.#horizontalScrollFinalizeTimer = null; + } + void this.#finalizeHorizontalWheelGesture(true); + } else { + this.#scheduleHorizontalWheelGestureFinalize(horizontalGestureFinalizeDelay); + } return; } @@ -716,12 +723,12 @@ class nsZenWorkspaces { }, finalizeDelay); } - async #finalizeHorizontalWheelGesture() { + async #finalizeHorizontalWheelGesture(forceSwitch = false) { if (!this.#horizontalWheelGestureActive) { return; } const swipeDelta = this.#horizontalScrollAccumulator; - const shouldSwitch = Math.abs(swipeDelta) >= this.#horizontalWheelSnapThreshold; + const shouldSwitch = forceSwitch || Math.abs(swipeDelta) >= this.#horizontalWheelSnapThreshold; const workspaceOffset = swipeDelta > 0 ? -1 : 1; this.#horizontalScrollAccumulator = 0; if (shouldSwitch) { From 4d41a54fe0890f298cae9f274b5d9819fbcb66d1 Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 20:06:15 -0400 Subject: [PATCH 12/16] fix(workspaces): restore native trackpad swipe handlers --- src/zen/workspaces/ZenWorkspaces.mjs | 137 +++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 89aca606f9..eb34cfdb10 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -180,6 +180,7 @@ class nsZenWorkspaces { this.workspaceEnabled && !this.isPrivateWindow ) { + this.initializeGestureHandlers(); this.initializeWorkspaceNavigation(); } } @@ -769,6 +770,46 @@ class nsZenWorkspaces { document.removeEventListener("popupshown", this.popupOpenHandler, { once: true }); } + initializeGestureHandlers() { + const elements = [ + gNavToolbox, + // event handlers do not work on elements inside shadow DOM so we need to attach them directly + document.getElementById("tabbrowser-arrowscrollbox").shadowRoot.querySelector("scrollbox"), + ]; + + // Attach gesture handlers to each element + for (const element of elements) { + if (!element) { + continue; + } + this.attachGestureHandlers(element); + } + } + + attachGestureHandlers(element) { + element.addEventListener("MozSwipeGestureMayStart", this._handleSwipeMayStart.bind(this), true); + element.addEventListener("MozSwipeGestureStart", this._handleSwipeStart.bind(this), true); + element.addEventListener("MozSwipeGestureUpdate", this._handleSwipeUpdate.bind(this), true); + + // Use MozSwipeGesture instead of MozSwipeGestureEnd because MozSwipeGestureEnd is fired after animation ends, + // while MozSwipeGesture is fired immediately after swipe ends. + element.addEventListener("MozSwipeGesture", this._handleSwipeEnd.bind(this), true); + + element.addEventListener( + "MozSwipeGestureEnd", + () => { + Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", false); + document.documentElement.removeAttribute("swipe-gesture"); + gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); + document.documentElement.style.setProperty("--zen-background-opacity", "1"); + delete this._hasAnimatedBackgrounds; + this.updateTabsContainers(); + document.removeEventListener("popupshown", this.popupOpenHandler, { once: true }); + }, + true + ); + } + _popupOpenHandler() { // If a popup is opened, we should stop the swipe gesture if (this._swipeState?.isGestureActive) { @@ -785,6 +826,102 @@ class nsZenWorkspaces { } } + _handleSwipeMayStart(event) { + if (this.privateWindowOrDisabled || this.#inChangingWorkspace) { + return; + } + if ( + event.target.closest("#zen-sidebar-foot-buttons") || + event.target.closest('#urlbar[zen-floating-urlbar="true"]') + ) { + return; + } + + // Only handle horizontal swipes + if (event.direction === event.DIRECTION_LEFT || event.direction === event.DIRECTION_RIGHT) { + event.preventDefault(); + event.stopPropagation(); + + // Set allowed directions based on available workspaces + event.allowedDirections |= event.DIRECTION_LEFT | event.DIRECTION_RIGHT; + } + } + + _handleSwipeStart(event) { + if (!this.workspaceEnabled) { + return; + } + + gZenFolders.cancelPopupTimer(); + + document.documentElement.setAttribute("swipe-gesture", "true"); + document.addEventListener("popupshown", this.popupOpenHandler, { once: true }); + + event.preventDefault(); + event.stopPropagation(); + this._swipeState = { + isGestureActive: true, + lastDelta: 0, + direction: null, + }; + Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", true); + } + + _handleSwipeUpdate(event) { + if (!this.workspaceEnabled || !this._swipeState?.isGestureActive) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const delta = event.delta * 300; + const stripWidth = + window.windowUtils.getBoundsWithoutFlushing(document.getElementById("navigator-toolbox")) + .width + + window.windowUtils.getBoundsWithoutFlushing(document.getElementById("zen-sidebar-splitter")) + .width * + 2; + let translateX = this._swipeState.lastDelta + delta; + // Add a force multiplier as we are translating the strip depending on how close to the edge we are + let forceMultiplier = Math.min(1, 1 - Math.abs(translateX) / (stripWidth * 4.5)); // 4.5 instead of 4 to add a bit of a buffer + if (forceMultiplier > 0.5) { + translateX *= forceMultiplier; + this._swipeState.lastDelta = delta + (translateX - delta) * 0.5; + } else { + translateX = this._swipeState.lastDelta; + } + + if (Math.abs(delta) > 0.8) { + this._swipeState.direction = delta > 0 ? "left" : "right"; + } + + // Apply a translateX to the tab strip to give the user feedback on the swipe + const currentWorkspace = this.getActiveWorkspaceFromCache(); + this._organizeWorkspaceStripLocations(currentWorkspace, true, translateX); + } + + async _handleSwipeEnd(event) { + if (!this.workspaceEnabled) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const isRTL = document.documentElement.matches(":-moz-locale-dir(rtl)"); + const moveForward = (event.direction === SimpleGestureEvent.DIRECTION_RIGHT) !== isRTL; + + const rawDirection = moveForward ? 1 : -1; + const direction = this.naturalScroll ? -1 : 1; + await this.changeWorkspaceShortcut(rawDirection * direction, true); + + // Reset swipe state + this._swipeState = { + isGestureActive: false, + lastDelta: 0, + direction: null, + }; + } + get activeWorkspace() { return this.#activeWorkspace; } From 212fbfeacf03d93b12170636257653e3c115d289 Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 20:34:02 -0400 Subject: [PATCH 13/16] fix(workspaces): isolate trackpad swipe from wheel workspace handling --- src/zen/workspaces/ZenWorkspaces.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index eb34cfdb10..a7ee7e4fed 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -42,6 +42,8 @@ class nsZenWorkspaces { #lastScrollTime = 0; #lastHorizontalWheelEventTime = 0; + #lastTrackpadSwipeEventTime = 0; + #trackpadPostSwipeSuppressionMs = 2000; #horizontalScrollAccumulator = 0; #horizontalScrollFinalizeTimer = null; #horizontalWheelGestureActive = false; @@ -531,6 +533,9 @@ class nsZenWorkspaces { if (!this.workspaceEnabled || !gNavToolbox.matches(":hover")) { return; } + if (Date.now() - this.#lastTrackpadSwipeEventTime < this.#trackpadPostSwipeSuppressionMs) { + return; + } // Some devices emit AppCommand and wheel for the same horizontal wheel action. // Ignore AppCommand if a horizontal wheel event just occurred. if (Date.now() - this.#lastHorizontalWheelEventTime < 250) { @@ -616,6 +621,12 @@ class nsZenWorkspaces { const currentTime = Date.now(); if (isHorizontalScroll) { + if ( + event.deltaMode === event.DOM_DELTA_PIXEL && + currentTime - this.#lastTrackpadSwipeEventTime < this.#trackpadPostSwipeSuppressionMs + ) { + return; + } const scrollDirection = this.naturalScroll ? -1 : 1; const deltaPixels = this.#normalizeHorizontalWheelDelta(event) * @@ -798,6 +809,7 @@ class nsZenWorkspaces { element.addEventListener( "MozSwipeGestureEnd", () => { + this.#lastTrackpadSwipeEventTime = Date.now(); Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", false); document.documentElement.removeAttribute("swipe-gesture"); gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); @@ -827,6 +839,7 @@ class nsZenWorkspaces { } _handleSwipeMayStart(event) { + this.#lastTrackpadSwipeEventTime = Date.now(); if (this.privateWindowOrDisabled || this.#inChangingWorkspace) { return; } @@ -848,6 +861,7 @@ class nsZenWorkspaces { } _handleSwipeStart(event) { + this.#lastTrackpadSwipeEventTime = Date.now(); if (!this.workspaceEnabled) { return; } @@ -868,6 +882,7 @@ class nsZenWorkspaces { } _handleSwipeUpdate(event) { + this.#lastTrackpadSwipeEventTime = Date.now(); if (!this.workspaceEnabled || !this._swipeState?.isGestureActive) { return; } @@ -902,6 +917,7 @@ class nsZenWorkspaces { } async _handleSwipeEnd(event) { + this.#lastTrackpadSwipeEventTime = Date.now(); if (!this.workspaceEnabled) { return; } From 7140fe0f9562d3d6bcac8c13adb3294cc4bd7e0e Mon Sep 17 00:00:00 2001 From: Andres Date: Tue, 24 Feb 2026 20:41:13 -0400 Subject: [PATCH 14/16] fix(workspaces): match mouse horizontal wheel to natural scroll direction --- src/zen/workspaces/ZenWorkspaces.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index a7ee7e4fed..cedc84227d 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -545,7 +545,7 @@ class nsZenWorkspaces { this.#cancelHorizontalWheelGesture(); } - const direction = this.naturalScroll ? -1 : 1; + const direction = this.naturalScroll ? 1 : -1; // event is forward or back switch (event.command) { case "Forward": @@ -627,7 +627,7 @@ class nsZenWorkspaces { ) { return; } - const scrollDirection = this.naturalScroll ? -1 : 1; + const scrollDirection = this.naturalScroll ? 1 : -1; const deltaPixels = this.#normalizeHorizontalWheelDelta(event) * scrollDirection * From 2e462805e0505af303a144199e986c9a66fbe825 Mon Sep 17 00:00:00 2001 From: Andres Date: Wed, 25 Feb 2026 00:47:29 -0400 Subject: [PATCH 15/16] fix(workspaces): stabilize trackpad direction reversals in sidebar swipes --- src/zen/workspaces/ZenWorkspaces.mjs | 59 +++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index cedc84227d..b4d4e88eef 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -48,6 +48,11 @@ class nsZenWorkspaces { #horizontalScrollFinalizeTimer = null; #horizontalWheelGestureActive = false; #horizontalWheelSnapThreshold = 55; + // Trackpad swipe deltas are cumulative; keep explicit state so slow gestures + // and mid-gesture reversals stay stable and deterministic. + #trackpadLastEventDelta = null; + #trackpadLastMotionDirection = 0; + #trackpadMotionBaseline = null; bookmarkMenus = [ "PlacesToolbar", @@ -840,7 +845,9 @@ class nsZenWorkspaces { _handleSwipeMayStart(event) { this.#lastTrackpadSwipeEventTime = Date.now(); - if (this.privateWindowOrDisabled || this.#inChangingWorkspace) { + // Allow new swipe intent even while a previous workspace transition settles, + // otherwise reversing direction feels blocked until finger lift. + if (this.privateWindowOrDisabled) { return; } if ( @@ -878,6 +885,9 @@ class nsZenWorkspaces { lastDelta: 0, direction: null, }; + this.#trackpadLastEventDelta = null; + this.#trackpadLastMotionDirection = 0; + this.#trackpadMotionBaseline = null; Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", true); } @@ -890,27 +900,49 @@ class nsZenWorkspaces { event.preventDefault(); event.stopPropagation(); - const delta = event.delta * 300; + const rawDelta = event.delta * 300; + if (this.#trackpadMotionBaseline === null) { + this.#trackpadMotionBaseline = rawDelta; + } + let delta = rawDelta - this.#trackpadMotionBaseline; + let stepDelta = 0; + if (typeof this.#trackpadLastEventDelta === "number") { + stepDelta = rawDelta - this.#trackpadLastEventDelta; + } + this.#trackpadLastEventDelta = rawDelta; + + const motionStepThreshold = 0.8; + if (Math.abs(stepDelta) >= motionStepThreshold) { + const stepDirection = stepDelta > 0 ? 1 : -1; + if ( + this.#trackpadLastMotionDirection && + stepDirection !== this.#trackpadLastMotionDirection + ) { + // Rebase when reversing so the swipe starts immediately in the new direction + // instead of fighting the previous accumulated displacement. + this.#trackpadMotionBaseline = rawDelta; + delta = 0; + this._swipeState.lastDelta = 0; + } + this.#trackpadLastMotionDirection = stepDirection; + this._swipeState.direction = stepDirection > 0 ? "left" : "right"; + } const stripWidth = window.windowUtils.getBoundsWithoutFlushing(document.getElementById("navigator-toolbox")) .width + window.windowUtils.getBoundsWithoutFlushing(document.getElementById("zen-sidebar-splitter")) .width * 2; - let translateX = this._swipeState.lastDelta + delta; + let translateX = delta; // Add a force multiplier as we are translating the strip depending on how close to the edge we are let forceMultiplier = Math.min(1, 1 - Math.abs(translateX) / (stripWidth * 4.5)); // 4.5 instead of 4 to add a bit of a buffer if (forceMultiplier > 0.5) { translateX *= forceMultiplier; - this._swipeState.lastDelta = delta + (translateX - delta) * 0.5; + this._swipeState.lastDelta = translateX; } else { translateX = this._swipeState.lastDelta; } - if (Math.abs(delta) > 0.8) { - this._swipeState.direction = delta > 0 ? "left" : "right"; - } - // Apply a translateX to the tab strip to give the user feedback on the swipe const currentWorkspace = this.getActiveWorkspaceFromCache(); this._organizeWorkspaceStripLocations(currentWorkspace, true, translateX); @@ -924,7 +956,13 @@ class nsZenWorkspaces { event.preventDefault(); event.stopPropagation(); const isRTL = document.documentElement.matches(":-moz-locale-dir(rtl)"); - const moveForward = (event.direction === SimpleGestureEvent.DIRECTION_RIGHT) !== isRTL; + let swipeDirection = event.direction; + if (this.#trackpadLastMotionDirection < 0) { + swipeDirection = SimpleGestureEvent.DIRECTION_RIGHT; + } else if (this.#trackpadLastMotionDirection > 0) { + swipeDirection = SimpleGestureEvent.DIRECTION_LEFT; + } + const moveForward = (swipeDirection === SimpleGestureEvent.DIRECTION_RIGHT) !== isRTL; const rawDirection = moveForward ? 1 : -1; const direction = this.naturalScroll ? -1 : 1; @@ -936,6 +974,9 @@ class nsZenWorkspaces { lastDelta: 0, direction: null, }; + this.#trackpadLastEventDelta = null; + this.#trackpadLastMotionDirection = 0; + this.#trackpadMotionBaseline = null; } get activeWorkspace() { From 7fdf26e56ea0a2d5f59cc0ad3f8bef55ac86d6cb Mon Sep 17 00:00:00 2001 From: Andres Date: Wed, 25 Feb 2026 01:06:02 -0400 Subject: [PATCH 16/16] fix(workspaces): smooth slow trackpad direction reversals --- src/zen/workspaces/ZenWorkspaces.mjs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index b4d4e88eef..9e82c8bd50 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -911,19 +911,11 @@ class nsZenWorkspaces { } this.#trackpadLastEventDelta = rawDelta; - const motionStepThreshold = 0.8; + // Keep direction detection responsive at low swipe velocities so reversing + // mid-gesture does not stall around the strip center. + const motionStepThreshold = 0.35; if (Math.abs(stepDelta) >= motionStepThreshold) { const stepDirection = stepDelta > 0 ? 1 : -1; - if ( - this.#trackpadLastMotionDirection && - stepDirection !== this.#trackpadLastMotionDirection - ) { - // Rebase when reversing so the swipe starts immediately in the new direction - // instead of fighting the previous accumulated displacement. - this.#trackpadMotionBaseline = rawDelta; - delta = 0; - this._swipeState.lastDelta = 0; - } this.#trackpadLastMotionDirection = stepDirection; this._swipeState.direction = stepDirection > 0 ? "left" : "right"; } @@ -966,7 +958,13 @@ class nsZenWorkspaces { const rawDirection = moveForward ? 1 : -1; const direction = this.naturalScroll ? -1 : 1; + const previousWorkspaceId = this.activeWorkspace; await this.changeWorkspaceShortcut(rawDirection * direction, true); + if (this.activeWorkspace === previousWorkspaceId) { + // Ensure the strip recenters when a swipe gesture resolves without a committed + // workspace change (for example while another transition is still settling). + this._cancelSwipeAnimation(); + } // Reset swipe state this._swipeState = {