summaryrefslogtreecommitdiff
path: root/noctalia/.config/plugins/keybind-cheatsheet/Panel.qml
diff options
context:
space:
mode:
Diffstat (limited to 'noctalia/.config/plugins/keybind-cheatsheet/Panel.qml')
-rw-r--r--noctalia/.config/plugins/keybind-cheatsheet/Panel.qml687
1 files changed, 687 insertions, 0 deletions
diff --git a/noctalia/.config/plugins/keybind-cheatsheet/Panel.qml b/noctalia/.config/plugins/keybind-cheatsheet/Panel.qml
new file mode 100644
index 0000000..946431e
--- /dev/null
+++ b/noctalia/.config/plugins/keybind-cheatsheet/Panel.qml
@@ -0,0 +1,687 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Io
+import qs.Commons
+import qs.Services.UI
+import qs.Services.Compositor
+import qs.Widgets
+
+Item {
+ id: root
+ property var pluginApi: null
+
+ // Settings
+ property var cfg: pluginApi?.pluginSettings || ({})
+ property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
+
+ // Settings values
+ property int settingsWidth: cfg.windowWidth ?? defaults.windowWidth ?? 1400
+ property int settingsHeight: cfg.windowHeight ?? defaults.windowHeight ?? 0
+ property bool autoHeight: cfg.autoHeight ?? defaults.autoHeight ?? true
+ property int columnCount: cfg.columnCount ?? defaults.columnCount ?? 3
+
+ // Bug 4 fix: re-evaluate rawCategories whenever Main.qml increments cheatsheetDataVersion
+ property int _dataVersion: pluginApi?.mainInstance?.cheatsheetDataVersion ?? 0
+ property var rawCategories: {
+ var _v = _dataVersion; // force QML dependency on version counter
+ return pluginApi?.pluginSettings?.cheatsheetData || [];
+ }
+ property var categories: []
+
+ // Bug 5 fix: timeout if parsing never completes
+ property bool loadingTimedOut: false
+
+ Timer {
+ id: loadingTimeoutTimer
+ interval: 4000
+ repeat: false
+ running: false
+ onTriggered: {
+ if (root.isLoading) {
+ root.loadingTimedOut = true;
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ categories = processCategories(rawCategories);
+ // Start timeout timer if we have no data yet
+ if (root.isLoading) {
+ loadingTimeoutTimer.start();
+ }
+ }
+
+
+ // Dynamic column items (up to 4 columns)
+ property var columnItems: []
+
+ // Memory leak prevention: debounce column updates
+ Timer {
+ id: columnUpdateDebounce
+ interval: 100
+ repeat: false
+ onTriggered: updateColumnItemsNow()
+ }
+
+ Component.onDestruction: {
+ // Stop timer to prevent firing after destruction
+ columnUpdateDebounce.stop();
+ loadingTimeoutTimer.stop();
+
+ // Clear column items
+ columnItems = [];
+ }
+
+ onRawCategoriesChanged: {
+ categories = processCategories(rawCategories);
+ updateColumnItems();
+ }
+
+ onCategoriesChanged: {
+ updateColumnItems();
+ contentPreferredHeight = calculateDynamicHeight();
+ }
+
+ onColumnCountChanged: {
+ updateColumnItems();
+ contentPreferredHeight = calculateDynamicHeight();
+ }
+
+ onPanelOpenScreenChanged: {
+ // Recalculate height when screen becomes available (important for bar widget opening)
+ contentPreferredHeight = calculateDynamicHeight();
+ root.searchText = "";
+ if (searchInput) searchInput.inputItem.forceActiveFocus();
+ }
+
+ onMaxScreenHeightChanged: {
+ contentPreferredHeight = calculateDynamicHeight();
+ }
+
+ function updateColumnItems() {
+ columnUpdateDebounce.restart();
+ }
+
+ function updateColumnItemsNow() {
+ columnItems = []; // Clear old items explicitly
+ var assignments = distributeCategories();
+ var items = [];
+ for (var i = 0; i < columnCount; i++) {
+ items.push(buildColumnItems(assignments[i] || []));
+ }
+ columnItems = items;
+ }
+
+ // Screen height limit (90% of screen)
+ property var panelOpenScreen: pluginApi?.panelOpenScreen
+ property real maxScreenHeight: panelOpenScreen ? panelOpenScreen.height * 0.9 : 800
+
+ property string searchText: ""
+
+ property real contentPreferredWidth: settingsWidth
+ property real contentPreferredHeight: calculateDynamicHeight()
+ readonly property var geometryPlaceholder: panelContainer
+ readonly property bool allowAttach: false
+ readonly property bool panelAnchorHorizontalCenter: true
+ readonly property bool panelAnchorVerticalCenter: true
+ anchors.fill: parent
+
+ // Key badge colors — read from settings with manifest defaults as fallback
+ readonly property color keyColorAlt: cfg.keyColorAlt || defaults.keyColorAlt || "#FF6B6B"
+ readonly property color keyColorXF86: cfg.keyColorXF86 || defaults.keyColorXF86 || "#4ECDC4"
+ readonly property color keyColorPrint: cfg.keyColorPrint || defaults.keyColorPrint || "#95E1D3"
+ readonly property color keyColorNumeric: cfg.keyColorNumeric || defaults.keyColorNumeric || "#A8DADC"
+ readonly property color keyColorMouse: cfg.keyColorMouse || defaults.keyColorMouse || "#F38181"
+ // Empty string = use theme color (mPrimary/mSecondary/mTertiary)
+ readonly property string keyColorSuperOverride: cfg.keyColorSuper ?? defaults.keyColorSuper ?? ""
+ readonly property string keyColorCtrlOverride: cfg.keyColorCtrl ?? defaults.keyColorCtrl ?? ""
+ readonly property string keyColorShiftOverride: cfg.keyColorShift ?? defaults.keyColorShift ?? ""
+ readonly property color keyColorDefault: cfg.keyColorDefault || defaults.keyColorDefault || "#6C757D"
+ readonly property color keyLabelColor: cfg.keyLabelColor || defaults.keyLabelColor || "#FFFFFF"
+ // Empty string = theme-aware fallback (Color.mOnSurface). Any non-empty
+ // value is treated as an explicit user override.
+ readonly property string descriptionColorOverride: cfg.descriptionTextColor || defaults.descriptionTextColor || ""
+ readonly property color descriptionTextColor: descriptionColorOverride !== "" ? descriptionColorOverride : Color.mOnSurface
+
+ // Per-category text color overrides (empty = fall back to keyLabelColor)
+ readonly property string keyTextSuperOverride: cfg.keyTextSuper ?? defaults.keyTextSuper ?? ""
+ readonly property string keyTextCtrlOverride: cfg.keyTextCtrl ?? defaults.keyTextCtrl ?? ""
+ readonly property string keyTextShiftOverride: cfg.keyTextShift ?? defaults.keyTextShift ?? ""
+ readonly property string keyTextAltOverride: cfg.keyTextAlt ?? defaults.keyTextAlt ?? ""
+ readonly property string keyTextXF86Override: cfg.keyTextXF86 ?? defaults.keyTextXF86 ?? ""
+ readonly property string keyTextPrintOverride: cfg.keyTextPrint ?? defaults.keyTextPrint ?? ""
+ readonly property string keyTextNumericOverride: cfg.keyTextNumeric ?? defaults.keyTextNumeric ?? ""
+ readonly property string keyTextMouseOverride: cfg.keyTextMouse ?? defaults.keyTextMouse ?? ""
+ readonly property string keyTextDefaultOverride: cfg.keyTextDefault ?? defaults.keyTextDefault ?? ""
+
+ // The generic X11 mods (Mod2/Mod3/Mod5) are first-class, customizable keys:
+ // background colour, text colour, and a display-label override that renames
+ // them to something meaningful (e.g. Mod3 -> "Hyper" for caps:hyper). Empty
+ // colour = fall back to the default key colour; empty label = raw mod name.
+ readonly property string keyColorMod2Override: cfg.keyColorMod2 ?? defaults.keyColorMod2 ?? ""
+ readonly property string keyColorMod3Override: cfg.keyColorMod3 ?? defaults.keyColorMod3 ?? ""
+ readonly property string keyColorMod5Override: cfg.keyColorMod5 ?? defaults.keyColorMod5 ?? ""
+ readonly property string keyTextMod2Override: cfg.keyTextMod2 ?? defaults.keyTextMod2 ?? ""
+ readonly property string keyTextMod3Override: cfg.keyTextMod3 ?? defaults.keyTextMod3 ?? ""
+ readonly property string keyTextMod5Override: cfg.keyTextMod5 ?? defaults.keyTextMod5 ?? ""
+ readonly property string keyLabelMod2Override: cfg.keyLabelMod2 ?? defaults.keyLabelMod2 ?? ""
+ readonly property string keyLabelMod3Override: cfg.keyLabelMod3 ?? defaults.keyLabelMod3 ?? ""
+ readonly property string keyLabelMod5Override: cfg.keyLabelMod5 ?? defaults.keyLabelMod5 ?? ""
+
+ // Workspace category split tuning
+ readonly property bool splitWorkspaces: cfg.splitLargeWorkspaceCategory ?? defaults.splitLargeWorkspaceCategory ?? true
+ readonly property int workspaceSplitThreshold: cfg.workspaceSplitThreshold ?? defaults.workspaceSplitThreshold ?? 12
+
+ // Data is loaded by Main.qml, we just display it
+ property bool isLoading: rawCategories.length === 0
+
+ function calculateDynamicHeight() {
+ // If auto height is disabled, use manual height (but still respect screen limit)
+ if (!autoHeight && settingsHeight > 0) {
+ return Math.min(settingsHeight, maxScreenHeight);
+ }
+
+ if (categories.length === 0) return Math.min(400, maxScreenHeight);
+
+ var assignments = distributeCategories();
+ var maxColumnHeight = 0;
+
+ for (var col = 0; col < columnCount; col++) {
+ var colHeight = 0;
+ var catIndices = assignments[col] || [];
+
+ for (var i = 0; i < catIndices.length; i++) {
+ var catIndex = catIndices[i];
+ if (catIndex >= categories.length) continue;
+
+ var cat = categories[catIndex];
+ colHeight += 26; // Header
+ colHeight += cat.binds.length * 20; // Binds
+ if (i < catIndices.length - 1) {
+ colHeight += 6; // Spacer
+ }
+ }
+
+ if (colHeight > maxColumnHeight) {
+ maxColumnHeight = colHeight;
+ }
+ }
+
+ // header (45) + content + margins (16)
+ var totalHeight = 45 + maxColumnHeight + 16 + 15 + 15;
+ // Limit to 80% of screen height
+ return Math.max(300, Math.min(totalHeight, maxScreenHeight));
+ }
+
+ // ========== UI ==========
+ Rectangle {
+ id: panelContainer
+ anchors.fill: parent
+ color: "transparent"
+ radius: Style.radiusL
+ clip: true
+
+ Rectangle {
+ id: header
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 45
+ color: Color.mSurfaceVariant
+ radius: Style.radiusL
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: Style.marginM
+ anchors.rightMargin: Style.marginM
+ spacing: Style.marginS
+
+ // Title section (centered)
+ Item { Layout.fillWidth: true }
+
+ NIcon {
+ icon: "keyboard"
+ pointSize: Style.fontSizeM
+ color: Color.mPrimary
+ }
+ NText {
+ text: CompositorService.isHyprland ? pluginApi?.tr("panel.title-hyprland") :
+ CompositorService.isNiri ? pluginApi?.tr("panel.title-niri") :
+ CompositorService.isMango ? pluginApi?.tr("panel.title-mango") :
+ pluginApi?.tr("panel.title")
+ font.pointSize: Style.fontSizeM
+ font.weight: Font.Bold
+ color: Color.mPrimary
+ }
+
+ NTextInput {
+ id: searchInput
+ placeholderText: pluginApi?.tr("panel.search-placeholder")
+ text: root.searchText
+
+ onTextChanged: {
+ root.searchText = text;
+ root.updateColumnItems();
+ }
+ }
+
+ Item { Layout.fillWidth: true }
+
+ // Refresh button
+ NIconButton {
+ icon: "refresh"
+ onClicked: {
+ pluginApi?.mainInstance?.refresh();
+ }
+ }
+
+ // Settings button
+ NIconButton {
+ icon: "settings"
+ onClicked: {
+ var screen = pluginApi?.panelOpenScreen;
+ if (screen && pluginApi?.manifest) {
+ pluginApi.closePanel(screen);
+ BarService.openPluginSettings(screen, pluginApi.manifest);
+ }
+ }
+ }
+ }
+ }
+
+ NText {
+ id: loadingText
+ anchors.centerIn: parent
+ text: root.loadingTimedOut ? pluginApi?.tr("panel.loading-timeout") : pluginApi?.tr("panel.loading")
+ visible: root.isLoading
+ font.pointSize: Style.fontSizeL
+ color: Color.mOnSurface
+ }
+
+ NScrollView {
+ id: scrollView
+ visible: root.categories.length > 0 && !root.isLoading
+ anchors.top: header.bottom
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ clip: true
+ leftPadding: 35
+ rightPadding: -10
+ topPadding: 15
+ bottomPadding: 15
+
+ RowLayout {
+ id: mainLayout
+ width: scrollView.availableWidth - Style.marginS
+ spacing: Style.marginS
+
+ Repeater {
+ model: root.columnItems.length
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: 2
+
+ property var colItems: root.columnItems[index] || []
+
+ Repeater {
+ model: colItems
+ Loader {
+ Layout.fillWidth: true
+ sourceComponent: modelData.type === "header" ? headerComponent :
+ (modelData.type === "spacer" ? spacerComponent : bindComponent)
+ property var itemData: modelData
+
+ // Memory leak prevention: explicit cleanup
+ Component.onDestruction: {
+ active = false;
+ sourceComponent = undefined;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: headerComponent
+ ColumnLayout {
+ Layout.preferredWidth: 300
+ Layout.topMargin: Style.marginM
+ Layout.bottomMargin: 4
+ spacing: 0
+
+ Item { Layout.fillWidth: true; height: 1 }
+
+ NText {
+ Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter
+ x: parent.width - implicitWidth
+ text: itemData.title
+ font.pointSize: Style.fontSizeM
+ font.weight: Font.Bold
+ color: Color.mPrimary
+ }
+
+ Item { Layout.fillWidth: true; height: 1 }
+ }
+ }
+
+
+ Component {
+ id: spacerComponent
+ Item {
+ height: 10
+ Layout.fillWidth: true
+ }
+ }
+
+ Component {
+ id: bindComponent
+ RowLayout {
+ id: bindRow
+ spacing: Style.marginS
+ height: 22
+ Layout.bottomMargin: 1
+
+ property bool editing: false
+
+ Flow {
+ Layout.preferredWidth: 220
+ Layout.alignment: Qt.AlignVCenter
+ spacing: 3
+ Repeater {
+ model: itemData.keys.split(" + ")
+ Rectangle {
+ width: keyText.implicitWidth + 10
+ height: 18
+ color: getKeyColor(modelData)
+ radius: 3
+ NText {
+ id: keyText
+ anchors.centerIn: parent
+ text: getKeyLabel(modelData)
+ font.pointSize: text.length > 12 ? 7 : 8
+ font.weight: Font.Bold
+ color: getKeyTextColor(modelData)
+ }
+ }
+ }
+ }
+
+ // Described bind: plain text (unchanged behaviour)
+ NText {
+ visible: !itemData.undescribed
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ text: itemData.desc
+ font.pointSize: Style.fontSizeXS
+ color: root.descriptionTextColor
+ elide: Text.ElideRight
+ }
+
+ // Undescribed bind: placeholder + add-description / hide actions
+ NText {
+ visible: itemData.undescribed && !bindRow.editing
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ text: pluginApi?.tr("panel.no-description")
+ font.pointSize: Style.fontSizeXS
+ font.italic: true
+ color: Color.mOnSurfaceVariant
+ elide: Text.ElideRight
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: { bindRow.editing = true; descInput.text = ""; descInput.inputItem.forceActiveFocus(); }
+ }
+ }
+
+ NTextInput {
+ id: descInput
+ visible: itemData.undescribed && bindRow.editing
+ Layout.fillWidth: true
+ Layout.preferredHeight: Style.baseWidgetSize
+ placeholderText: pluginApi?.tr("panel.add-description-placeholder")
+ }
+
+ NIconButton {
+ visible: itemData.undescribed && bindRow.editing
+ Layout.preferredHeight: 18
+ icon: "check"
+ tooltipText: pluginApi?.tr("panel.save-description")
+ onClicked: {
+ root.saveBindDescription(itemData.bindId, descInput.text);
+ bindRow.editing = false;
+ }
+ }
+
+ NIconButton {
+ visible: itemData.undescribed && bindRow.editing
+ Layout.preferredHeight: 18
+ icon: "close"
+ tooltipText: pluginApi?.tr("panel.cancel")
+ onClicked: { bindRow.editing = false; }
+ }
+
+ NIconButton {
+ visible: itemData.undescribed && !bindRow.editing
+ Layout.preferredHeight: 18
+ icon: "edit"
+ tooltipText: pluginApi?.tr("panel.add-description")
+ onClicked: { bindRow.editing = true; descInput.text = ""; descInput.inputItem.forceActiveFocus(); }
+ }
+
+ NIconButton {
+ visible: itemData.undescribed && !bindRow.editing
+ Layout.preferredHeight: 18
+ icon: "eye-off"
+ tooltipText: pluginApi?.tr("panel.hide-bind")
+ onClicked: { root.hideBind(itemData.bindId); }
+ }
+ }
+ }
+
+ function getKeyColor(keyName) {
+ if (keyName === "Super") return root.keyColorSuperOverride || Color.mPrimary;
+ if (keyName === "Ctrl") return root.keyColorCtrlOverride || Color.mSecondary;
+ if (keyName === "Shift") return root.keyColorShiftOverride || Color.mTertiary;
+ if (keyName === "Alt") return root.keyColorAlt;
+ if (keyName.startsWith("XF86")) return root.keyColorXF86;
+ if (keyName === "PRINT" || keyName === "Print") return root.keyColorPrint;
+ if (keyName.match(/^[0-9]$/)) return root.keyColorNumeric;
+ if (keyName.includes("MOUSE") || keyName.includes("Wheel")) return root.keyColorMouse;
+ if (keyName === "Mod2") return root.keyColorMod2Override || root.keyColorDefault;
+ if (keyName === "Mod3") return root.keyColorMod3Override || root.keyColorDefault;
+ if (keyName === "Mod5") return root.keyColorMod5Override || root.keyColorDefault;
+ return root.keyColorDefault;
+ }
+
+ function getKeyTextColor(keyName) {
+ if (keyName === "Super") return root.keyTextSuperOverride || root.keyLabelColor;
+ if (keyName === "Ctrl") return root.keyTextCtrlOverride || root.keyLabelColor;
+ if (keyName === "Shift") return root.keyTextShiftOverride || root.keyLabelColor;
+ if (keyName === "Alt") return root.keyTextAltOverride || root.keyLabelColor;
+ if (keyName.startsWith("XF86")) return root.keyTextXF86Override || root.keyLabelColor;
+ if (keyName === "PRINT" || keyName === "Print") return root.keyTextPrintOverride || root.keyLabelColor;
+ if (keyName.match(/^[0-9]$/)) return root.keyTextNumericOverride || root.keyLabelColor;
+ if (keyName.includes("MOUSE") || keyName.includes("Wheel")) return root.keyTextMouseOverride || root.keyLabelColor;
+ if (keyName === "Mod2") return root.keyTextMod2Override || root.keyLabelColor;
+ if (keyName === "Mod3") return root.keyTextMod3Override || root.keyLabelColor;
+ if (keyName === "Mod5") return root.keyTextMod5Override || root.keyLabelColor;
+ return root.keyTextDefaultOverride || root.keyLabelColor;
+ }
+
+ // Display label for a key token. Lets users rename the generic X11 modifier
+ // names (e.g. Mod3 -> "Hyper"); everything else renders as-is. Purely visual —
+ // colour lookup and the cached bind still use the original token.
+ function getKeyLabel(keyName) {
+ if (keyName === "Mod2" && root.keyLabelMod2Override) return root.keyLabelMod2Override;
+ if (keyName === "Mod3" && root.keyLabelMod3Override) return root.keyLabelMod3Override;
+ if (keyName === "Mod5" && root.keyLabelMod5Override) return root.keyLabelMod5Override;
+ return keyName;
+ }
+
+ // ===== Bind override helpers (shared keyed map in plugin settings) =====
+ function _cloneOverrides() {
+ if (!pluginApi || !pluginApi.pluginSettings) return ({});
+ var src = pluginApi.pluginSettings.bindOverrides || ({});
+ try { return JSON.parse(JSON.stringify(src)); } catch (e) { return ({}); }
+ }
+
+ function saveBindDescription(bindId, desc) {
+ if (!pluginApi || !bindId) return;
+ var o = _cloneOverrides();
+ if (!o[bindId]) o[bindId] = ({});
+ var trimmed = (desc || "").trim();
+ if (trimmed.length === 0) {
+ delete o[bindId].desc;
+ if (Object.keys(o[bindId]).length === 0) delete o[bindId];
+ } else {
+ o[bindId].desc = trimmed;
+ }
+ pluginApi.pluginSettings.bindOverrides = o;
+ pluginApi.saveSettings();
+ pluginApi.mainInstance?.refresh();
+ }
+
+ function hideBind(bindId) {
+ if (!pluginApi || !bindId) return;
+ var o = _cloneOverrides();
+ if (!o[bindId]) o[bindId] = ({});
+ o[bindId].hidden = true;
+ pluginApi.pluginSettings.bindOverrides = o;
+ pluginApi.saveSettings();
+ pluginApi.mainInstance?.refresh();
+ }
+
+ function buildColumnItems(categoryIndices) {
+ var result = [];
+ if (!categoryIndices) return result;
+
+ for (var i = 0; i < categoryIndices.length; i++) {
+ var catIndex = categoryIndices[i];
+ if (catIndex >= categories.length) continue;
+
+ var cat = categories[catIndex];
+ result.push({ type: "header", title: cat.title });
+ var term = root.searchText.toLowerCase();
+ for (var j = 0; j < cat.binds.length; j++) {
+ var bnd = cat.binds[j];
+ var isUndesc = bnd.undescribed === true;
+ if (!term || (bnd.desc && bnd.desc.toLowerCase().indexOf(term) !== -1) || (isUndesc && bnd.keys.toLowerCase().indexOf(term) !== -1)) {
+ result.push({
+ type: "bind",
+ keys: bnd.keys,
+ desc: bnd.desc,
+ bindId: bnd.bindId || "",
+ undescribed: isUndesc
+ });
+ }
+ }
+ if (i < categoryIndices.length - 1) {
+ result.push({ type: "spacer" });
+ }
+ }
+ return result;
+ }
+
+ function processCategories(cats) {
+ if (!cats || cats.length === 0) return [];
+ if (!root.splitWorkspaces) return cats;
+
+ var result = [];
+ for (var i = 0; i < cats.length; i++) {
+ var cat = cats[i];
+ if (!cat.binds || cat.binds.length <= root.workspaceSplitThreshold) {
+ result.push(cat);
+ continue;
+ }
+
+ // Detect a category dominated by workspace verbs.
+ // Hyprland verbs: workspace, movetoworkspace, movetoworkspacesilent, movecurrentworkspacetomonitor
+ // Niri verbs: focus-workspace, move-window-to-workspace, move-column-to-workspace
+ var workspaceCount = 0;
+ for (var k = 0; k < cat.binds.length; k++) {
+ var v = cat.binds[k]._verb || "";
+ if (v.indexOf("workspace") !== -1) workspaceCount++;
+ }
+ var workspaceDominated = workspaceCount >= Math.ceil(cat.binds.length * 0.6);
+ if (!workspaceDominated) {
+ result.push(cat);
+ continue;
+ }
+
+ var switching = [], moving = [], mouse = [];
+ for (var j = 0; j < cat.binds.length; j++) {
+ var bind = cat.binds[j];
+ var verb = bind._verb || "";
+ var isMouse = (bind._mainKey || "").indexOf("MOUSE") !== -1 ||
+ (bind.keys || "").indexOf("MOUSE") !== -1 ||
+ (bind.keys || "").indexOf("Wheel") !== -1;
+
+ if (isMouse) {
+ mouse.push(bind);
+ } else if (verb.indexOf("move") !== -1 || verb.indexOf("send") !== -1) {
+ // Hyprland: movetoworkspace*, Niri: move-window-to-workspace, move-column-to-workspace
+ moving.push(bind);
+ } else {
+ // Hyprland: workspace, Niri: focus-workspace
+ switching.push(bind);
+ }
+ }
+
+ if (switching.length > 0) result.push({ title: pluginApi?.tr("panel.workspace-switching"), binds: switching });
+ if (moving.length > 0) result.push({ title: pluginApi?.tr("panel.workspace-moving"), binds: moving });
+ if (mouse.length > 0) result.push({ title: pluginApi?.tr("panel.workspace-mouse"), binds: mouse });
+ }
+ return result;
+ }
+
+ function distributeCategories() {
+ var numCols = root.columnCount;
+
+ // Calculate weights for each category
+ var catData = [];
+ for (var i = 0; i < categories.length; i++) {
+ var weight = 1 + categories[i].binds.length + 1; // header + binds + spacer
+ catData.push({ index: i, weight: weight });
+ }
+
+ // Sort by weight descending (largest categories first for better distribution)
+ catData.sort(function(a, b) { return b.weight - a.weight; });
+
+ var columns = [];
+ var columnWeights = [];
+ for (var c = 0; c < numCols; c++) {
+ columns.push([]);
+ columnWeights.push(0);
+ }
+
+ // Assign each category to the column with smallest current weight
+ for (var i = 0; i < catData.length; i++) {
+ var minCol = 0;
+ for (var c = 1; c < numCols; c++) {
+ if (columnWeights[c] < columnWeights[minCol]) {
+ minCol = c;
+ }
+ }
+ columns[minCol].push(catData[i].index);
+ columnWeights[minCol] += catData[i].weight;
+ }
+
+ // Sort categories within each column by original order for consistent display
+ for (var c = 0; c < numCols; c++) {
+ columns[c].sort(function(a, b) { return a - b; });
+ }
+
+ return columns;
+ }
+
+}