Skip to content

Commit a36c1f8

Browse files
author
Andres
committed
feat(workspaces): add smooth horizontal wheel switching for spaces
1 parent a8e245b commit a36c1f8

1 file changed

Lines changed: 167 additions & 15 deletions

File tree

src/zen/workspaces/ZenWorkspaces.mjs

Lines changed: 167 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class nsZenWorkspaces {
4141
_workspaceCache = [];
4242

4343
#lastScrollTime = 0;
44+
#lastHorizontalWheelEventTime = 0;
45+
#horizontalScrollAccumulator = 0;
46+
#horizontalScrollFinalizeTimer = null;
47+
#horizontalWheelGestureActive = false;
4448

4549
bookmarkMenus = [
4650
"PlacesToolbar",
@@ -526,17 +530,25 @@ class nsZenWorkspaces {
526530
if (!this.workspaceEnabled || !gNavToolbox.matches(":hover")) {
527531
return;
528532
}
533+
// Some devices emit AppCommand and wheel for the same horizontal wheel action.
534+
// Ignore AppCommand if a horizontal wheel event just occurred.
535+
if (Date.now() - this.#lastHorizontalWheelEventTime < 250) {
536+
return;
537+
}
538+
if (this.#horizontalWheelGestureActive) {
539+
this.#cancelHorizontalWheelGesture();
540+
}
529541

530542
const direction = this.naturalScroll ? -1 : 1;
531543
// event is forward or back
532544
switch (event.command) {
533545
case "Forward":
534-
this.changeWorkspaceShortcut(1 * direction);
546+
this.changeWorkspaceShortcut(1 * direction, true);
535547
event.stopImmediatePropagation();
536548
event.preventDefault();
537549
break;
538550
case "Back":
539-
this.changeWorkspaceShortcut(-1 * direction);
551+
this.changeWorkspaceShortcut(-1 * direction, true);
540552
event.stopImmediatePropagation();
541553
event.preventDefault();
542554
break;
@@ -551,8 +563,9 @@ class nsZenWorkspaces {
551563
#setupSidebarHandlers() {
552564
const toolbox = gNavToolbox;
553565

554-
const scrollCooldown = 200; // Milliseconds to wait before allowing another scroll
555-
const scrollThreshold = 1; // Minimum scroll delta to trigger workspace change
566+
const verticalScrollCooldown = 200; // Milliseconds to wait before allowing another scroll
567+
const verticalScrollThreshold = 1;
568+
const horizontalSnapPositionThreshold = 55;
556569

557570
toolbox.addEventListener(
558571
"wheel",
@@ -561,12 +574,20 @@ class nsZenWorkspaces {
561574
return;
562575
}
563576

564-
// Only process non-gesture scrolls
565-
if (event.deltaMode !== 1) {
577+
const absDeltaX = Math.abs(event.deltaX);
578+
const absDeltaY = Math.abs(event.deltaY);
579+
const isHorizontalScroll = absDeltaX > 0 && absDeltaX >= absDeltaY;
580+
const isVerticalScroll = absDeltaY > 0 && absDeltaY > absDeltaX;
581+
582+
if (!isHorizontalScroll && !isVerticalScroll) {
566583
return;
567584
}
568585

569-
const isVerticalScroll = event.deltaY && !event.deltaX;
586+
// Horizontal mouse wheels can report pixel deltas, so only enforce
587+
// line-based deltas for vertical scrolling.
588+
if (!isHorizontalScroll && event.deltaMode !== 1) {
589+
return;
590+
}
570591

571592
//if the scroll is vertical this checks that a modifier key is used before proceeding
572593
if (isVerticalScroll) {
@@ -585,20 +606,61 @@ class nsZenWorkspaces {
585606
}
586607
}
587608

588-
let currentTime = Date.now();
589-
if (currentTime - this.#lastScrollTime < scrollCooldown) {
609+
const currentTime = Date.now();
610+
611+
if (isHorizontalScroll) {
612+
if (this.#inChangingWorkspace) {
613+
return;
614+
}
615+
this.#startHorizontalWheelGesture();
616+
const scrollDirection = this.naturalScroll ? -1 : 1;
617+
const deltaPixels = this.#normalizeHorizontalWheelDelta(event) * scrollDirection;
618+
if (!deltaPixels) {
619+
return;
620+
}
621+
this.#lastHorizontalWheelEventTime = currentTime;
622+
if (
623+
this.#horizontalScrollAccumulator !== 0 &&
624+
Math.sign(this.#horizontalScrollAccumulator) !== Math.sign(deltaPixels)
625+
) {
626+
this.#horizontalScrollAccumulator = 0;
627+
}
628+
this.#horizontalScrollAccumulator += deltaPixels;
629+
const stripWidth =
630+
window.windowUtils.getBoundsWithoutFlushing(
631+
document.getElementById("navigator-toolbox")
632+
).width +
633+
window.windowUtils.getBoundsWithoutFlushing(
634+
document.getElementById("zen-sidebar-splitter")
635+
).width *
636+
2;
637+
let translateX = this.#horizontalScrollAccumulator;
638+
let forceMultiplier = Math.max(0.5, 1 - Math.abs(translateX) / (stripWidth * 4.5));
639+
translateX *= forceMultiplier;
640+
if (Math.abs(deltaPixels) > 0.8) {
641+
this._swipeState.direction = deltaPixels > 0 ? "left" : "right";
642+
}
643+
const currentWorkspace = this.getActiveWorkspaceFromCache();
644+
this._organizeWorkspaceStripLocations(currentWorkspace, true, translateX);
645+
if (Math.abs(translateX) >= horizontalSnapPositionThreshold) {
646+
void this.#finalizeHorizontalWheelGesture(true);
647+
return;
648+
}
649+
this.#scheduleHorizontalWheelGestureFinalize();
590650
return;
591651
}
592652

593-
//this decides which delta to use
594-
const delta = isVerticalScroll ? event.deltaY : event.deltaX;
595-
if (Math.abs(delta) < scrollThreshold) {
653+
if (this.#horizontalWheelGestureActive) {
654+
this.#cancelHorizontalWheelGesture();
655+
}
656+
if (currentTime - this.#lastScrollTime < verticalScrollCooldown) {
657+
return;
658+
}
659+
if (Math.abs(event.deltaY) < verticalScrollThreshold) {
596660
return;
597661
}
598662

599-
// Determine scroll direction
600-
let rawDirection = delta > 0 ? 1 : -1;
601-
663+
const rawDirection = event.deltaY > 0 ? 1 : -1;
602664
let direction = this.naturalScroll ? -1 : 1;
603665
this.changeWorkspaceShortcut(rawDirection * direction);
604666

@@ -608,6 +670,90 @@ class nsZenWorkspaces {
608670
);
609671
}
610672

673+
#startHorizontalWheelGesture() {
674+
if (this.#horizontalWheelGestureActive) {
675+
return;
676+
}
677+
this.#horizontalWheelGestureActive = true;
678+
gZenFolders.cancelPopupTimer();
679+
document.documentElement.setAttribute("swipe-gesture", "true");
680+
document.addEventListener("popupshown", this.popupOpenHandler, { once: true });
681+
this._swipeState = {
682+
isGestureActive: true,
683+
lastDelta: 0,
684+
direction: null,
685+
};
686+
Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", true);
687+
}
688+
689+
#normalizeHorizontalWheelDelta(event) {
690+
switch (event.deltaMode) {
691+
case event.DOM_DELTA_LINE:
692+
return event.deltaX * 40;
693+
case event.DOM_DELTA_PAGE:
694+
return event.deltaX * 160;
695+
case event.DOM_DELTA_PIXEL:
696+
default:
697+
return event.deltaX * 0.35;
698+
}
699+
}
700+
701+
#scheduleHorizontalWheelGestureFinalize() {
702+
if (this.#horizontalScrollFinalizeTimer) {
703+
clearTimeout(this.#horizontalScrollFinalizeTimer);
704+
}
705+
this.#horizontalScrollFinalizeTimer = setTimeout(() => {
706+
this.#horizontalScrollFinalizeTimer = null;
707+
void this.#finalizeHorizontalWheelGesture();
708+
}, 180);
709+
}
710+
711+
async #finalizeHorizontalWheelGesture(forceSwitch = false) {
712+
if (!this.#horizontalWheelGestureActive) {
713+
return;
714+
}
715+
const threshold = 55;
716+
const shouldSwitch = forceSwitch || Math.abs(this.#horizontalScrollAccumulator) >= threshold;
717+
const workspaceOffset = this.#horizontalScrollAccumulator > 0 ? -1 : 1;
718+
this.#horizontalScrollAccumulator = 0;
719+
if (shouldSwitch) {
720+
await this.changeWorkspaceShortcut(workspaceOffset, true);
721+
this.#lastScrollTime = Date.now();
722+
} else {
723+
this._cancelSwipeAnimation();
724+
}
725+
this.#cleanupHorizontalWheelGesture();
726+
}
727+
728+
#cancelHorizontalWheelGesture() {
729+
if (!this.#horizontalWheelGestureActive) {
730+
return;
731+
}
732+
if (this.#horizontalScrollFinalizeTimer) {
733+
clearTimeout(this.#horizontalScrollFinalizeTimer);
734+
this.#horizontalScrollFinalizeTimer = null;
735+
}
736+
this.#horizontalScrollAccumulator = 0;
737+
this._cancelSwipeAnimation();
738+
this.#cleanupHorizontalWheelGesture();
739+
}
740+
741+
#cleanupHorizontalWheelGesture() {
742+
this.#horizontalWheelGestureActive = false;
743+
this._swipeState = {
744+
isGestureActive: false,
745+
lastDelta: 0,
746+
direction: null,
747+
};
748+
Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", false);
749+
document.documentElement.removeAttribute("swipe-gesture");
750+
gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width");
751+
document.documentElement.style.setProperty("--zen-background-opacity", "1");
752+
delete this._hasAnimatedBackgrounds;
753+
this.updateTabsContainers();
754+
document.removeEventListener("popupshown", this.popupOpenHandler, { once: true });
755+
}
756+
611757
initializeGestureHandlers() {
612758
const elements = [
613759
gNavToolbox,
@@ -651,6 +797,12 @@ class nsZenWorkspaces {
651797
_popupOpenHandler() {
652798
// If a popup is opened, we should stop the swipe gesture
653799
if (this._swipeState?.isGestureActive) {
800+
if (this.#horizontalScrollFinalizeTimer) {
801+
clearTimeout(this.#horizontalScrollFinalizeTimer);
802+
this.#horizontalScrollFinalizeTimer = null;
803+
}
804+
this.#horizontalWheelGestureActive = false;
805+
this.#horizontalScrollAccumulator = 0;
654806
document.documentElement.removeAttribute("swipe-gesture");
655807
gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width");
656808
this.updateTabsContainers();

0 commit comments

Comments
 (0)