diff --git a/locales/en-US/browser/browser/preferences/zen-preferences.ftl b/locales/en-US/browser/browser/preferences/zen-preferences.ftl
index 76bc306de24..2e530d3bdd9 100644
--- a/locales/en-US/browser/browser/preferences/zen-preferences.ftl
+++ b/locales/en-US/browser/browser/preferences/zen-preferences.ftl
@@ -75,11 +75,43 @@ zen-settings-normal-tabs-sync =
zen-settings-workspaces-sync-unpinned-tabs =
.label = Sync only pinned tabs in workspaces
-zen-tabs-cycle-by-attribute =
- .label = Ctrl+Tab cycles within Essential or Workspace tabs only
-zen-tabs-cycle-ignore-pending-tabs =
- .label = Ignore Pending tabs when cycling with Ctrl+Tab
-zen-tabs-cycle-by-attribute-warning = Ctrl+Tab will cycle by recently used order, as it is enabled
+pane-zen-tab-switcher-title = Tab Switcher
+zen-tab-switcher-header = Switching between tabs
+
+# Variables:
+# $shortcut (String) - The shortcut that cycles through tabs, like "Ctrl+Tab", or "none" when it has no shortcut.
+zen-tab-switcher-description =
+ { $shortcut ->
+ [none] Choose which tabs the tab switcher cycles through. Set a shortcut
+ *[other] Choose which tabs { $shortcut } cycles through. Change shortcut
+ }
+# Variables:
+# $shortcut (String) - The shortcut that cycles through tabs, like "Ctrl+Tab", or "none" when it has no shortcut.
+zen-tab-switcher-recently-used-order =
+ .label =
+ { $shortcut ->
+ [none] Cycle through tabs in recently used order
+ *[other] Cycle through tabs in recently used order with { $shortcut }
+ }
+zen-tab-switcher-cross-space =
+ .label = Cycle through tabs in all spaces, not just the current one
+# Variables:
+# $shortcut (String) - The shortcut that cycles through tabs, like "Ctrl+Tab", or "none" when it has no shortcut.
+zen-tab-switcher-ignore-pending-tabs =
+ .label =
+ { $shortcut ->
+ [none] Ignore pending tabs when cycling through tabs
+ *[other] Ignore pending tabs when cycling with { $shortcut }
+ }
+# Variables:
+# $shortcut (String) - The shortcut that cycles through tabs, like "Ctrl+Tab", or "none" when it has no shortcut.
+zen-tab-switcher-cycle-by-attribute =
+ .label =
+ { $shortcut ->
+ [none] Stay within Essential or Workspace tabs when cycling through tabs
+ *[other] Stay within Essential or Workspace tabs when cycling with { $shortcut }
+ }
+zen-tab-switcher-cycle-by-attribute-warning = This has no effect while cycling in recently used order.
zen-look-and-feel-compact-toolbar-themed =
.label = Use themed background for compact toolbar
@@ -198,6 +230,7 @@ zenCKSOption-group-zen-compact-mode = Compact Mode
zenCKSOption-group-zen-workspace = Workspaces
zenCKSOption-group-zen-other = Other Zen Features
zenCKSOption-group-zen-split-view = Split View
+zenCKSOption-group-zen-tabs = Tabs
zen-text-action-copy-url-shortcut = Copy current URL
zen-text-action-copy-url-markdown-shortcut = Copy current URL as Markdown
@@ -227,3 +260,6 @@ zen-split-view-shortcut-unsplit = Close Split View
zen-new-empty-split-view-shortcut = New Empty Split View
zen-close-all-unpinned-tabs-shortcut = Close All Unpinned Tabs
zen-duplicate-tab-shortcut = Duplicate Tab
+zen-tab-switcher-shortcut-cycle = Tab Switcher
+zen-tab-switcher-shortcut-next = Next Tab
+zen-tab-switcher-shortcut-previous = Previous Tab
diff --git a/prefs/zen/sync.yaml b/prefs/zen/sync.yaml
index 3eb3aaa558c..00f0572e4bb 100644
--- a/prefs/zen/sync.yaml
+++ b/prefs/zen/sync.yaml
@@ -28,6 +28,9 @@
- name: services.sync.prefs.sync.zen.tabs.ctrl-tab.ignore-pending-tabs
value: true
+- name: services.sync.prefs.sync.zen.tabs.ctrl-tab.cross-space
+ value: true
+
- name: services.sync.prefs.sync.zen.ctrlTab.show-pending-tabs
value: true
diff --git a/prefs/zen/zen.yaml b/prefs/zen/zen.yaml
index 685bb3866ed..78d056b320a 100644
--- a/prefs/zen/zen.yaml
+++ b/prefs/zen/zen.yaml
@@ -52,3 +52,6 @@
- name: zen.tabs.ctrl-tab.ignore-pending-tabs
value: false
+
+- name: zen.tabs.ctrl-tab.cross-space
+ value: false
diff --git a/src/browser/base/content/zen-keysets.inc.xhtml b/src/browser/base/content/zen-keysets.inc.xhtml
index 897550a2e4b..e49944d2b3e 100644
--- a/src/browser/base/content/zen-keysets.inc.xhtml
+++ b/src/browser/base/content/zen-keysets.inc.xhtml
@@ -28,6 +28,18 @@
+
+#ifdef XP_MACOSX
+#define ZEN_TAB_SWITCHER_MODIFIER control
+#else
+#define ZEN_TAB_SWITCHER_MODIFIER accel
+#endif
+#expand
+#expand
+#expand
+
diff --git a/src/browser/components/preferences/zen-settings.js b/src/browser/components/preferences/zen-settings.js
index 6750869dd2a..c5e1b66de89 100644
--- a/src/browser/components/preferences/zen-settings.js
+++ b/src/browser/components/preferences/zen-settings.js
@@ -713,8 +713,76 @@ var gZenLooksAndFeel = {
},
};
+var gZenTabSwitcherSettings = {
+ init() {
+ // Firefox's own checkbox for this pref, which this one replaces, does the
+ // same so a later migration doesn't undo the user's choice.
+ document.getElementById("zenTabSwitcherRecentlyUsedOrder").addEventListener("command", () => {
+ Services.prefs.clearUserPref("browser.ctrlTab.migrated");
+ });
+
+ document.getElementById("zenTabSwitcherDescription").addEventListener("click", (event) => {
+ if (event.target.closest("a")) {
+ event.preventDefault();
+ window.browsingContext.topChromeWindow.switchToTabHavingURI("about:keyboard", true);
+ }
+ });
+
+ const updateCrossSpace = () => {
+ document.getElementById("zenTabSwitcherCrossSpace").disabled = !Services.prefs.getBoolPref(
+ "browser.ctrlTab.sortByRecentlyUsed",
+ false
+ );
+ };
+ updateCrossSpace();
+ Services.prefs.addObserver("browser.ctrlTab.sortByRecentlyUsed", updateCrossSpace);
+
+ // Labels mention the cycle shortcut, which can be changed in
+ // about:keyboard. CustomKeys updates the key element in every window, so
+ // follow it there to keep them current.
+ const cycleKey =
+ window.browsingContext.topChromeWindow?.document.getElementById("zen-tab-switcher-cycle");
+ this.updateShortcutLabels(cycleKey);
+ const keyObserver = new MutationObserver(() => this.updateShortcutLabels(cycleKey));
+ if (cycleKey) {
+ keyObserver.observe(cycleKey, {
+ attributes: true,
+ attributeFilter: ["key", "keycode", "modifiers"],
+ });
+ }
+
+ window.addEventListener("unload", () => {
+ Services.prefs.removeObserver("browser.ctrlTab.sortByRecentlyUsed", updateCrossSpace);
+ keyObserver.disconnect();
+ });
+ },
+
+ /**
+ * @param {Element|null} cycleKey The element holding the cycle shortcut.
+ */
+ updateShortcutLabels(cycleKey) {
+ const { ShortcutUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ShortcutUtils.sys.mjs"
+ );
+ const shortcut =
+ cycleKey?.hasAttribute("key") || cycleKey?.hasAttribute("keycode")
+ ? ShortcutUtils.prettifyShortcut(cycleKey)
+ : "none";
+ for (const element of document.querySelectorAll(".zen-tab-switcher-shortcut-label")) {
+ // The description's translation is markup, so the shortcut is escaped
+ // there. Labels are plain text.
+ const isMarkup = element.id == "zenTabSwitcherDescription";
+ document.l10n.setArgs(element, {
+ shortcut: isMarkup ? shortcut.replaceAll("&", "&").replaceAll("<", "<") : shortcut,
+ });
+ }
+ },
+};
+
var gZenWorkspacesSettings = {
init() {
+ gZenTabSwitcherSettings.init();
+
var tabsUnloaderPrefListener = {
async observe() {
let buttonIndex = await confirmRestartPrompt(true, 1, true, true);
@@ -854,6 +922,11 @@ Preferences.addAll([
type: "bool",
default: false,
},
+ {
+ id: "zen.tabs.ctrl-tab.cross-space",
+ type: "bool",
+ default: false,
+ },
{
id: "zen.tabs.close-on-back-with-no-history",
type: "bool",
diff --git a/src/browser/components/preferences/zenTabsManagement.inc.xhtml b/src/browser/components/preferences/zenTabsManagement.inc.xhtml
index 2cf7c9c9d8a..a8353c5672e 100644
--- a/src/browser/components/preferences/zenTabsManagement.inc.xhtml
+++ b/src/browser/components/preferences/zenTabsManagement.inc.xhtml
@@ -32,16 +32,43 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
2) {
+ this.open();
+ } else if (tabs.length == 2) {
+@@ -741,7 +752,9 @@ var ctrlTab = {
+ event.preventDefault();
+ event.stopPropagation();
+
+- if (event.keyCode === event.DOM_VK_CONTROL) {
++ if (
++ this.ZenKeyboardShortcuts.isTabSwitcherReleaseEvent(event, document)
++ ) {
+ document.removeEventListener("keyup", this, { mozSystemGroup: true });
+
+ if (this.isOpen) {
+@@ -794,14 +807,17 @@ var ctrlTab = {
+ aCallback(urls);
+ },
+ _sortRecentlyUsedTabs() {
++ // Right after switching spaces, the previous and the newly selected tab
++ // can share a lastAccessed time. The selected tab always comes first.
+ this._recentlyUsedTabs.sort(
+- (tab1, tab2) => tab2.lastAccessed - tab1.lastAccessed
++ (tab1, tab2) =>
++ tab2.selected - tab1.selected || tab2.lastAccessed - tab1.lastAccessed
+ );
+ },
_initRecentlyUsedTabs() {
this._recentlyUsedTabs = Array.prototype.filter.call(
- gBrowser.tabs,
+- gBrowser.tabs,
- tab => !tab.closing && !tab.hidden
++ gZenWorkspaces.ctrlTabCandidates,
+ tab => !tab.closing && !tab.hidden && !tab.hasAttribute("zen-empty-tab")
);
this._sortRecentlyUsedTabs();
},
+@@ -846,6 +862,8 @@ var ctrlTab = {
+
+ ChromeUtils.defineESModuleGetters(ctrlTab, {
+ KeyboardLockUtils: "resource://gre/modules/KeyboardLockUtils.sys.mjs",
++ ZenKeyboardShortcuts:
++ "resource:///modules/zen/kbs/ZenKeyboardShortcuts.sys.mjs",
+ });
+
+ XPCOMUtils.defineLazyPreferenceGetter(
diff --git a/src/browser/themes/shared/preferences/zen-preferences.css b/src/browser/themes/shared/preferences/zen-preferences.css
index f763b182087..50e9ae7370a 100644
--- a/src/browser/themes/shared/preferences/zen-preferences.css
+++ b/src/browser/themes/shared/preferences/zen-preferences.css
@@ -54,6 +54,17 @@ groupbox h2 {
font-size: 15px;
}
+#zenTabSwitcherDescription html|kbd {
+ font: inherit;
+ font-size: 0.9em;
+ padding: 1px 6px;
+ border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
+ border-bottom-width: 2px;
+ border-radius: 5px;
+ background-color: light-dark(#f1f1f1, #363636);
+ white-space: nowrap;
+}
+
#categories > .category {
margin-left: 0 !important;
min-height: 40px !important;
@@ -408,6 +419,7 @@ groupbox h2 {
#helpButton,
#support-firefox,
#tabGroupSuggestions,
+#ctrlTabRecentlyUsedOrder,
#setting-control-sidebarChatbotFieldset,
#setting-control-supportFirefox,
.mission-message,
diff --git a/src/toolkit/content/widgets/tabbox-js.patch b/src/toolkit/content/widgets/tabbox-js.patch
index 8056fd11bcf..97ba1bd6943 100644
--- a/src/toolkit/content/widgets/tabbox-js.patch
+++ b/src/toolkit/content/widgets/tabbox-js.patch
@@ -1,8 +1,8 @@
diff --git a/toolkit/content/widgets/tabbox.js b/toolkit/content/widgets/tabbox.js
-index 402fe6a74b5327a30199667de18f053ad75fe910..9bcbc827d18db2f996d964f7c7e3439e064c9e0d 100644
+index 962b5835159244177edf51a0139f2f3062cab12b..ff75c00e9ed10bc3fc54453db3a1c4c956d7dd27 100644
--- a/toolkit/content/widgets/tabbox.js
+++ b/toolkit/content/widgets/tabbox.js
-@@ -11,6 +11,23 @@
+@@ -11,12 +11,31 @@
"resource://gre/modules/AppConstants.sys.mjs"
);
@@ -26,7 +26,42 @@ index 402fe6a74b5327a30199667de18f053ad75fe910..9bcbc827d18db2f996d964f7c7e3439e
let imports = {};
ChromeUtils.defineESModuleGetters(imports, {
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
-@@ -226,7 +243,7 @@
+ KeyboardLockUtils: "resource://gre/modules/KeyboardLockUtils.sys.mjs",
+ ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
+ XPCOMUtils: "resource://gre/modules/XPCOMUtils.sys.mjs",
++ ZenKeyboardShortcuts:
++ "resource:///modules/zen/kbs/ZenKeyboardShortcuts.sys.mjs",
+ });
+
+ const DIRECTION_BACKWARD = -1;
+@@ -127,9 +146,13 @@
+ // Don't check if the event was already consumed because tab
+ // navigation should always work for better user experience.
+
+- const { KeyboardLockUtils, ShortcutUtils } = imports;
++ const { KeyboardLockUtils, ShortcutUtils, ZenKeyboardShortcuts } =
++ imports;
+
+- const action = ShortcutUtils.getSystemActionForEvent(event);
++ const action = ZenKeyboardShortcuts.getSystemActionForEvent(
++ event,
++ document
++ );
+ // If we don't have an action, don't request reply.
+ if (
+ action != null &&
+@@ -147,7 +170,9 @@
+ );
+ if (this.tabs && this.handleCtrlTab) {
+ this.tabs.advanceSelectedTab(
+- event.shiftKey ? DIRECTION_BACKWARD : DIRECTION_FORWARD,
++ ZenKeyboardShortcuts.isTabSwitcherReverseEvent(event, document)
++ ? DIRECTION_BACKWARD
++ : DIRECTION_FORWARD,
+ true,
+ event
+ );
+@@ -227,7 +252,7 @@
) {
this._inAsyncOperation = false;
if (oldPanel != this._selectedPanel) {
@@ -35,7 +70,7 @@ index 402fe6a74b5327a30199667de18f053ad75fe910..9bcbc827d18db2f996d964f7c7e3439e
this._selectedPanel?.classList.add("deck-selected");
}
this.setAttribute("selectedIndex", val);
-@@ -898,7 +915,7 @@
+@@ -954,7 +979,7 @@
if (!tab) {
return;
}
@@ -44,7 +79,7 @@ index 402fe6a74b5327a30199667de18f053ad75fe910..9bcbc827d18db2f996d964f7c7e3439e
if (otherTab != tab && otherTab.selected) {
otherTab._selected = false;
}
-@@ -934,6 +951,7 @@
+@@ -990,6 +1015,7 @@
* @param {MozTab|null} [val]
*/
set selectedItem(val) {
@@ -52,7 +87,7 @@ index 402fe6a74b5327a30199667de18f053ad75fe910..9bcbc827d18db2f996d964f7c7e3439e
if (val && !val.selected) {
// The selectedIndex setter ignores invalid values
// such as -1 if |val| isn't one of our child nodes.
-@@ -1111,7 +1129,7 @@
+@@ -1167,7 +1193,7 @@
if (tab == startTab) {
return null;
}
@@ -61,7 +96,7 @@ index 402fe6a74b5327a30199667de18f053ad75fe910..9bcbc827d18db2f996d964f7c7e3439e
return tab;
}
}
-@@ -1175,13 +1193,30 @@
+@@ -1231,13 +1257,30 @@
*/
// eslint-disable-next-line no-unused-vars
advanceSelectedTab(aDir, aWrap, aEvent) {
@@ -93,7 +128,7 @@ index 402fe6a74b5327a30199667de18f053ad75fe910..9bcbc827d18db2f996d964f7c7e3439e
// Handle keyboard navigation for a hidden tab that can be selected, like the Firefox View tab,
// which has a random placement in this.allTabs.
if (startTab.hidden) {
-@@ -1194,7 +1229,7 @@
+@@ -1250,7 +1293,7 @@
newTab = this.findNextTab(startTab, {
direction: aDir,
wrap: aWrap,
diff --git a/src/zen/kbs/ZenKeyboardShortcuts.sys.mjs b/src/zen/kbs/ZenKeyboardShortcuts.sys.mjs
index 0c3970a192e..9d4d2518fc9 100644
--- a/src/zen/kbs/ZenKeyboardShortcuts.sys.mjs
+++ b/src/zen/kbs/ZenKeyboardShortcuts.sys.mjs
@@ -28,10 +28,31 @@ const LEGACY_VERSION_PREF = "zen.keyboard.shortcuts.version";
// then.
const LEGACY_DEVTOOLS_GROUP = "devTools";
+// The category about:keyboard lists Firefox's navigation shortcuts in.
+const FIREFOX_NAVIGATION_CATEGORY = "customkeys-category-navigation-2";
+
/**
* Zen's own shortcuts, grouped the way about:keyboard shows them.
*/
const ZEN_KEY_CATEGORIES = [
+ {
+ l10nId: "zenCKSOption-group-zen-tabs",
+ // Shown right after Navigation, which these used to be part of.
+ after: FIREFOX_NAVIGATION_CATEGORY,
+ keys: [
+ ["zen-tab-switcher-cycle", "zen-tab-switcher-shortcut-cycle"],
+ ["zen-tab-switcher-next", "zen-tab-switcher-shortcut-next"],
+ ["zen-tab-switcher-previous", "zen-tab-switcher-shortcut-previous"],
+ ],
+ // Firefox's shortcuts that are moved here, keeping their titles.
+ firefoxKeys: {
+ category: FIREFOX_NAVIGATION_CATEGORY,
+ ids: [
+ ...Array.from({ length: 8 }, (_, i) => `key_selectTab${i + 1}`),
+ "key_selectLastTab",
+ ],
+ },
+ },
{
l10nId: "zenCKSOption-group-zen-compact-mode",
keys: [
@@ -79,6 +100,103 @@ const ZEN_KEY_CATEGORIES = [
},
];
+/**
+ * Firefox handles tab switching (Ctrl+Tab, Ctrl+Page Down, Ctrl+Page Up and
+ * their platform variants) in ShortcutUtils.getSystemActionForEvent instead
+ * of through elements, so about:keyboard can't change them. Zen mirrors
+ * them with disabled elements that only hold the binding. Until one is
+ * customized Firefox's built-in shortcuts apply; once it is, its binding
+ * replaces them.
+ */
+export const TAB_SWITCHER_KEYS = Object.freeze({
+ CYCLE_TABS: "zen-tab-switcher-cycle",
+ NEXT_TAB: "zen-tab-switcher-next",
+ PREVIOUS_TAB: "zen-tab-switcher-previous",
+});
+
+/**
+ * Read the binding of a element into the modifier state a keyboard
+ * event needs to have to trigger it.
+ *
+ * @param {Element} keyEl
+ */
+function parseKeyElement(keyEl) {
+ const isMac = AppConstants.platform == "macosx";
+ const binding = {
+ key: keyEl.getAttribute("key")?.toUpperCase() ?? "",
+ keycode: keyEl.getAttribute("keycode")?.toUpperCase() ?? "",
+ ctrl: false,
+ alt: false,
+ shift: false,
+ meta: false,
+ };
+ for (const name of (keyEl.getAttribute("modifiers") ?? "").split(/[\s,]+/)) {
+ switch (name) {
+ case "accel":
+ binding[isMac ? "meta" : "ctrl"] = true;
+ break;
+ case "control":
+ binding.ctrl = true;
+ break;
+ case "alt":
+ case "shift":
+ binding[name] = true;
+ break;
+ case "meta":
+ case "os":
+ binding.meta = true;
+ break;
+ }
+ }
+ return binding;
+}
+
+/**
+ * Whether the key pressed in a keyboard event is the one of a binding,
+ * without looking at modifiers.
+ *
+ * @param {object} binding As returned by parseKeyElement.
+ * @param {KeyboardEvent} event
+ */
+function isBindingKey(binding, event) {
+ if (binding.keycode) {
+ return event.keyCode == event["DOM_" + binding.keycode];
+ }
+ if (!binding.key) {
+ return false;
+ }
+ if (event.key.toUpperCase() == binding.key) {
+ return true;
+ }
+ // With Alt on macOS, or Shift on a digit, event.key is the character the
+ // combination produces rather than the key itself.
+ const { keyCode } = event;
+ const isAlphanumeric =
+ (keyCode >= event.DOM_VK_A && keyCode <= event.DOM_VK_Z) ||
+ (keyCode >= event.DOM_VK_0 && keyCode <= event.DOM_VK_9);
+ return isAlphanumeric && String.fromCharCode(keyCode) == binding.key;
+}
+
+/**
+ * @param {object} binding As returned by parseKeyElement.
+ * @param {KeyboardEvent} event
+ * @param {object} [options]
+ * @param {boolean} [options.shiftReverses] Also match with Shift held when
+ * the binding doesn't use it, since Shift reverses the direction of the
+ * tab switcher.
+ */
+function bindingMatchesEvent(binding, event, { shiftReverses = false } = {}) {
+ // Like ShortcutUtils, ignore the Windows key on Windows.
+ const metaKey = event.metaKey && AppConstants.platform != "win";
+ return (
+ isBindingKey(binding, event) &&
+ event.ctrlKey == binding.ctrl &&
+ event.altKey == binding.alt &&
+ metaKey == binding.meta &&
+ (event.shiftKey == binding.shift || (shiftReverses && !binding.shift))
+ );
+}
+
/**
* Normalize a list of modifier names into the comma separated form that
* CustomKeys stores, folding together the modifiers that mean the same
@@ -217,6 +335,108 @@ class nsZenKeyboardShortcuts {
return lazy.ShortcutUtils.prettifyShortcut(keyEl);
}
+ /**
+ * The tab switcher key with this id, if the user customized it.
+ *
+ * @param {Document} document The browser window's document.
+ * @param {string} id
+ * @returns {Element|null}
+ */
+ #customizedTabSwitcherKey(document, id) {
+ if (!lazy.CustomKeys.getDefaultKey(id)) {
+ return null;
+ }
+ return document.getElementById(id);
+ }
+
+ /**
+ * ShortcutUtils.getSystemActionForEvent, with the tab switching shortcuts
+ * the user customized in place of Firefox's built-in ones.
+ *
+ * @param {KeyboardEvent} event
+ * @param {Document} document The document the event is handled in.
+ * @returns {string|null} One of ShortcutUtils' action constants, or null.
+ */
+ getSystemActionForEvent(event, document) {
+ for (const [action, id] of Object.entries(TAB_SWITCHER_KEYS)) {
+ const keyEl = this.#customizedTabSwitcherKey(document, id);
+ if (
+ keyEl &&
+ bindingMatchesEvent(parseKeyElement(keyEl), event, {
+ shiftReverses: action == "CYCLE_TABS",
+ })
+ ) {
+ return lazy.ShortcutUtils[action];
+ }
+ }
+ const action = lazy.ShortcutUtils.getSystemActionForEvent(event);
+ const id = TAB_SWITCHER_KEYS[action];
+ if (id && this.#customizedTabSwitcherKey(document, id)) {
+ return null;
+ }
+ return action;
+ }
+
+ /**
+ * Whether a press of the cycle key goes backwards. Shift reverses the
+ * direction, unless the user's binding for cycling uses Shift itself.
+ *
+ * @param {KeyboardEvent} event
+ * @param {Document} document The document the event is handled in.
+ * @returns {boolean}
+ */
+ isTabSwitcherReverseEvent(event, document) {
+ if (!event.shiftKey) {
+ return false;
+ }
+ const keyEl = this.#customizedTabSwitcherKey(
+ document,
+ TAB_SWITCHER_KEYS.CYCLE_TABS
+ );
+ return !keyEl || !parseKeyElement(keyEl).shift;
+ }
+
+ /**
+ * Whether this keyup ends a round of cycling through tabs, i.e. the user
+ * let go of the modifier they hold down while pressing the cycle key. With
+ * the default Ctrl+Tab that's Control.
+ *
+ * @param {KeyboardEvent} event
+ * @param {Document} document The browser window's document.
+ * @returns {boolean}
+ */
+ isTabSwitcherReleaseEvent(event, document) {
+ const keyEl = this.#customizedTabSwitcherKey(
+ document,
+ TAB_SWITCHER_KEYS.CYCLE_TABS
+ );
+ if (!keyEl) {
+ return event.keyCode == event.DOM_VK_CONTROL;
+ }
+ const binding = parseKeyElement(keyEl);
+ const heldKeys = [];
+ if (binding.ctrl) {
+ heldKeys.push("Control");
+ }
+ if (binding.alt) {
+ heldKeys.push("Alt");
+ }
+ if (binding.meta) {
+ heldKeys.push("Meta", "OS");
+ }
+ // Shift normally reverses the direction, so only treat it as the held key
+ // when the binding has no other modifier.
+ if (!heldKeys.length && binding.shift) {
+ heldKeys.push("Shift");
+ }
+ // A shortcut without modifiers has nothing to hold down, so it switches
+ // as soon as the key itself is released.
+ if (!heldKeys.length) {
+ return isBindingKey(binding, event);
+ }
+ return heldKeys.includes(event.key);
+ }
+
/**
* Extend the key list that about:keyboard shows with Zen's shortcuts.
*
@@ -251,9 +471,31 @@ class nsZenKeyboardShortcuts {
data.title = text[l10nId];
entries[id] = data;
}
- if (Object.keys(entries).length) {
+ const { category: from, ids: firefoxIds = [] } =
+ category.firefoxKeys ?? {};
+ for (const id of firefoxIds) {
+ if (keys[from]?.[id]) {
+ entries[id] = keys[from][id];
+ delete keys[from][id];
+ }
+ }
+ if (!Object.keys(entries).length) {
+ continue;
+ }
+ if (keys[label] || !(category.after in keys)) {
Object.assign((keys[label] ??= {}), entries);
+ continue;
+ }
+ // Rebuild the object to put the category in place, since about:keyboard
+ // shows categories in the order they were added.
+ const ordered = {};
+ for (const [name, entry] of Object.entries(keys)) {
+ ordered[name] = entry;
+ if (name == category.after) {
+ ordered[label] = entries;
+ }
}
+ keys = ordered;
}
return keys;
}
diff --git a/src/zen/spaces/ZenSpaceManager.mjs b/src/zen/spaces/ZenSpaceManager.mjs
index f1ae0cd4d8e..0928b7998c7 100644
--- a/src/zen/spaces/ZenSpaceManager.mjs
+++ b/src/zen/spaces/ZenSpaceManager.mjs
@@ -140,6 +140,13 @@ class nsZenWorkspaces {
"zen.workspaces.open-new-tab-if-last-unpinned-tab-is-closed",
false
);
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_crossSpaceCtrlTabPref",
+ "zen.tabs.ctrl-tab.cross-space",
+ false,
+ () => this.#fixCtrlTabBehavior()
+ );
this.containerSpecificEssentials = Services.prefs.getBoolPref(
"zen.workspaces.separate-essentials",
false
@@ -2762,6 +2769,36 @@ class nsZenWorkspaces {
);
}
+ /**
+ * Whether Ctrl+Tab switches between the recently used tabs of every space,
+ * instead of only those of the active space.
+ */
+ get crossSpaceCtrlTab() {
+ return !!this._crossSpaceCtrlTabPref && this.workspaceEnabled;
+ }
+
+ /**
+ * The tabs Ctrl+Tab picks from, in recently used order. It still leaves out
+ * the ones it never shows, like closing or hidden tabs.
+ */
+ get ctrlTabCandidates() {
+ if (!this.crossSpaceCtrlTab) {
+ return gBrowser.tabs;
+ }
+ // The active space brings the essentials that belong to it, the other
+ // spaces only add their own tabs.
+ const tabs = new Set(gBrowser.tabs);
+ for (const tab of this.allStoredTabs) {
+ if (
+ !tab.hasAttribute("zen-essential") &&
+ !tab.hasAttribute("zen-glance-tab")
+ ) {
+ tabs.add(tab);
+ }
+ }
+ return [...tabs];
+ }
+
// Intentionally keep it as async!
async #fixCtrlTabBehavior() {
ctrlTab.uninit();
diff --git a/src/zen/tests/kbs/browser.toml b/src/zen/tests/kbs/browser.toml
index cb8e0732216..ac74caf7dc5 100644
--- a/src/zen/tests/kbs/browser.toml
+++ b/src/zen/tests/kbs/browser.toml
@@ -11,4 +11,6 @@ support-files = [
["browser_legacy_migration.js"]
+["browser_tab_switcher_keys.js"]
+
["browser_zen_keys.js"]
diff --git a/src/zen/tests/kbs/browser_about_keyboard.js b/src/zen/tests/kbs/browser_about_keyboard.js
index 3f54d90cd99..f04359f3183 100644
--- a/src/zen/tests/kbs/browser_about_keyboard.js
+++ b/src/zen/tests/kbs/browser_about_keyboard.js
@@ -91,3 +91,52 @@ add_task(async function test_zen_keys_are_listed() {
});
});
});
+
+add_task(async function test_tab_keys_are_grouped() {
+ const l10n = new Localization(["browser/preferences/zen-preferences.ftl"]);
+ const [tabsTitle] = await l10n.formatValues([
+ { id: "zenCKSOption-group-zen-tabs" },
+ ]);
+
+ await BrowserTestUtils.withNewTab("about:keyboard", async browser => {
+ await SpecialPowers.spawn(browser, [tabsTitle], async title => {
+ const doc = content.document;
+ if (!doc.getElementById("table").firstElementChild) {
+ await ContentTaskUtils.waitForEvent(
+ content,
+ "CustomKeysUpdate",
+ false,
+ null,
+ true
+ );
+ }
+
+ const cardOf = id =>
+ doc.querySelector(`.key[data-id="${id}"]`)?.closest("moz-card");
+ const tabsCard = cardOf("zen-tab-switcher-cycle");
+ ok(tabsCard, "Cycling through tabs is listed");
+ is(tabsCard.wrappedJSObject.heading, title, "in the Tabs category");
+ for (const id of [
+ "zen-tab-switcher-next",
+ "zen-tab-switcher-previous",
+ "key_selectTab1",
+ "key_selectTab8",
+ "key_selectLastTab",
+ ]) {
+ is(cardOf(id), tabsCard, `${id} is in the Tabs category`);
+ }
+
+ const navigationCard = cardOf("goBackKb");
+ is(
+ navigationCard.getAttribute("data-l10n-id"),
+ "customkeys-category-navigation-2",
+ "Back stays in Navigation"
+ );
+ is(
+ navigationCard.nextElementSibling,
+ tabsCard,
+ "Tabs comes right after Navigation"
+ );
+ });
+ });
+});
diff --git a/src/zen/tests/kbs/browser_tab_switcher_keys.js b/src/zen/tests/kbs/browser_tab_switcher_keys.js
new file mode 100644
index 00000000000..61c5142e4d9
--- /dev/null
+++ b/src/zen/tests/kbs/browser_tab_switcher_keys.js
@@ -0,0 +1,178 @@
+/* Any copyright is dedicated to the Public Domain.
+ https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.ctrlTab.sortByRecentlyUsed", false]],
+ });
+ const tabs = [];
+ for (let i = 0; i < 3; i++) {
+ tabs.push(
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ `data:text/html,Tab ${i}`
+ )
+ );
+ }
+ registerCleanupFunction(async () => {
+ CustomKeys.resetAll();
+ for (const tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+/**
+ * Press a key and wait for the tab strip to be asked to move.
+ *
+ * @param {sinon.SinonStub} advance The stubbed advanceSelectedTab.
+ * @param {string} key
+ * @param {object} options
+ * @returns {Promise} The direction the tab strip was asked to move in.
+ */
+async function pressAndGetDirection(advance, key, options) {
+ const calls = advance.callCount;
+ EventUtils.synthesizeKey(key, options, window);
+ await TestUtils.waitForCondition(
+ () => advance.callCount > calls,
+ `${key} to switch tabs`
+ );
+ return advance.lastCall.args[0];
+}
+
+async function pressAndExpectNothing(advance, key, options, message) {
+ const calls = advance.callCount;
+ EventUtils.synthesizeKey(key, options, window);
+ await TestUtils.waitForTick();
+ await TestUtils.waitForTick();
+ is(advance.callCount, calls, message);
+}
+
+add_task(function test_tab_switcher_keys_have_defaults() {
+ const expected = {
+ "zen-tab-switcher-cycle": "VK_TAB",
+ "zen-tab-switcher-next": "VK_PAGE_DOWN",
+ "zen-tab-switcher-previous": "VK_PAGE_UP",
+ };
+ for (const [id, keycode] of Object.entries(expected)) {
+ const keyEl = document.getElementById(id);
+ ok(keyEl, `${id} exists`);
+ is(keyEl.getAttribute("keycode"), keycode, `${id} has its default key`);
+ is(
+ keyEl.getAttribute("modifiers"),
+ isMac ? "control" : "accel",
+ `${id} uses Control`
+ );
+ is(keyEl.getAttribute("disabled"), "true", `${id} never fires itself`);
+ }
+});
+
+add_task(async function test_customized_next_tab() {
+ const sandbox = sinon.createSandbox();
+ const advance = sandbox.stub(gBrowser.tabContainer, "advanceSelectedTab");
+ try {
+ is(
+ await pressAndGetDirection(advance, "KEY_PageDown", { ctrlKey: true }),
+ 1,
+ "Ctrl+Page Down moves to the next tab by default"
+ );
+
+ CustomKeys.changeKey("zen-tab-switcher-next", {
+ modifiers: UNUSED_MODIFIERS,
+ key: UNUSED_KEY,
+ });
+ is(
+ await pressAndGetDirection(advance, UNUSED_KEY, UNUSED_OPTIONS),
+ 1,
+ "The customized shortcut moves to the next tab"
+ );
+ await pressAndExpectNothing(
+ advance,
+ "KEY_PageDown",
+ { ctrlKey: true },
+ "Ctrl+Page Down no longer switches tabs"
+ );
+ is(
+ await pressAndGetDirection(advance, "KEY_PageUp", { ctrlKey: true }),
+ -1,
+ "Previous tab keeps its default"
+ );
+
+ CustomKeys.resetKey("zen-tab-switcher-next");
+ is(
+ await pressAndGetDirection(advance, "KEY_PageDown", { ctrlKey: true }),
+ 1,
+ "Ctrl+Page Down works again after a reset"
+ );
+ } finally {
+ sandbox.restore();
+ CustomKeys.resetAll();
+ }
+});
+
+add_task(async function test_cleared_previous_tab() {
+ const sandbox = sinon.createSandbox();
+ const advance = sandbox.stub(gBrowser.tabContainer, "advanceSelectedTab");
+ try {
+ CustomKeys.clearKey("zen-tab-switcher-previous");
+ await pressAndExpectNothing(
+ advance,
+ "KEY_PageUp",
+ { ctrlKey: true },
+ "A cleared shortcut does nothing"
+ );
+ } finally {
+ sandbox.restore();
+ CustomKeys.resetAll();
+ }
+});
+
+add_task(async function test_customized_cycle() {
+ const sandbox = sinon.createSandbox();
+ const advance = sandbox.stub(gBrowser.tabContainer, "advanceSelectedTab");
+ try {
+ CustomKeys.changeKey("zen-tab-switcher-cycle", {
+ modifiers: "alt",
+ key: UNUSED_KEY,
+ });
+ is(
+ await pressAndGetDirection(advance, UNUSED_KEY, { altKey: true }),
+ 1,
+ "The customized shortcut cycles forward"
+ );
+ is(
+ await pressAndGetDirection(advance, UNUSED_KEY, {
+ altKey: true,
+ shiftKey: true,
+ }),
+ -1,
+ "and backwards with Shift"
+ );
+ await pressAndExpectNothing(
+ advance,
+ "KEY_Tab",
+ { ctrlKey: true },
+ "Ctrl+Tab no longer cycles"
+ );
+
+ CustomKeys.changeKey("zen-tab-switcher-cycle", {
+ modifiers: UNUSED_MODIFIERS,
+ key: UNUSED_KEY,
+ });
+ is(
+ await pressAndGetDirection(advance, UNUSED_KEY, UNUSED_OPTIONS),
+ 1,
+ "A shortcut that uses Shift itself still cycles forward"
+ );
+ } finally {
+ sandbox.restore();
+ CustomKeys.resetAll();
+ }
+});
diff --git a/src/zen/tests/spaces/browser.toml b/src/zen/tests/spaces/browser.toml
index 699d03fbdc1..88893b14c2c 100644
--- a/src/zen/tests/spaces/browser.toml
+++ b/src/zen/tests/spaces/browser.toml
@@ -14,6 +14,8 @@ support-files = [
["browser_close_all_unpinned_tabs_warning.js"]
+["browser_ctrl_tab_cross_space.js"]
+
["browser_default_containers.js"]
["browser_double_click_newtab.js"]
diff --git a/src/zen/tests/spaces/browser_ctrl_tab_cross_space.js b/src/zen/tests/spaces/browser_ctrl_tab_cross_space.js
new file mode 100644
index 00000000000..630c59622d6
--- /dev/null
+++ b/src/zen/tests/spaces/browser_ctrl_tab_cross_space.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function openTab(title) {
+ return BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ `data:text/html,${title}`,
+ true,
+ { skipAnimation: true }
+ );
+}
+
+add_task(async function test_ctrl_tab_cross_space() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.ctrlTab.sortByRecentlyUsed", true],
+ ["zen.tabs.ctrl-tab.cross-space", false],
+ ],
+ });
+
+ const firstSpace = await gZenWorkspaces.createAndSaveWorkspace("First");
+ await gZenWorkspaces.changeWorkspace(firstSpace);
+ const otherSpaceTab = await openTab("Other space");
+
+ const secondSpace = await gZenWorkspaces.createAndSaveWorkspace("Second");
+ await gZenWorkspaces.changeWorkspace(secondSpace);
+ const currentSpaceTab = await openTab("Current space");
+
+ try {
+ ok(
+ ctrlTab.tabList.includes(currentSpaceTab),
+ "Tabs of the current space are listed"
+ );
+ ok(
+ !ctrlTab.tabList.includes(otherSpaceTab),
+ "Tabs of other spaces are not listed by default"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["zen.tabs.ctrl-tab.cross-space", true]],
+ });
+ is(ctrlTab.tabList[0], currentSpaceTab, "The selected tab comes first");
+ is(
+ ctrlTab.tabList[1],
+ otherSpaceTab,
+ "followed by the last used tab of another space"
+ );
+ ok(
+ ctrlTab.tabList.every(tab => !tab.hasAttribute("zen-empty-tab")),
+ "Empty tabs are never listed"
+ );
+
+ EventUtils.synthesizeKey("VK_TAB", { ctrlKey: true }, window);
+ EventUtils.synthesizeKey("VK_CONTROL", { type: "keyup" }, window);
+ await TestUtils.waitForCondition(
+ () => gBrowser.selectedTab == otherSpaceTab,
+ "Ctrl+Tab to select the tab of the other space"
+ );
+ await TestUtils.waitForCondition(
+ () => gZenWorkspaces.activeWorkspace == firstSpace.uuid,
+ "and switch to its space"
+ );
+
+ await SpecialPowers.popPrefEnv();
+ ok(
+ !ctrlTab.tabList.includes(currentSpaceTab),
+ "Turning the pref off goes back to the current space only"
+ );
+ } finally {
+ await SpecialPowers.popPrefEnv();
+ BrowserTestUtils.removeTab(otherSpaceTab);
+ BrowserTestUtils.removeTab(currentSpaceTab);
+ await gZenWorkspaces.removeWorkspace(secondSpace.uuid);
+ await gZenWorkspaces.removeWorkspace(firstSpace.uuid);
+ }
+});