diff --git a/CHANGELOG.md b/CHANGELOG.md index a935a38..0d87143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added + +- Listed third-party bar widgets that declare `x-shibumi.displayModes` in the Icons appearance editor, offering only their declared modes + ## 0.1.1-beta.11: Native Audio and Bluetooth cutover Public beta candidate for 2026-08-23. diff --git a/docs/control-center-v4.md b/docs/control-center-v4.md index 0f2f6e7..607f00d 100644 --- a/docs/control-center-v4.md +++ b/docs/control-center-v4.md @@ -411,8 +411,11 @@ not secondary sections inside Icons: - **Icons** owns per-widget icon/content modes plus their surfaces, colors, shape, spacing, and opacity. It follows the active bar's canonical left/center/right layout order and lists only enabled groups that implement - the Shibumi appearance contract. Provider filters do not appear in this - editor; unsupported stock or third-party widgets remain in Plugins. The + the Shibumi appearance contract, plus enabled third-party bar widgets that + declare `x-shibumi.displayModes` (see + [plugin compatibility](plugin-compatibility.md#third-party-widget-appearance)). + Provider filters do not appear in this editor; other stock or third-party + widgets remain in Plugins. The selected widget's live preview shares the inspector header, content and surface choices use visual samples, and the surface palette uses the same neutral-border, underline-selection, and hover-motion contract as Bars. diff --git a/docs/plugin-compatibility.md b/docs/plugin-compatibility.md index ed84ade..8cc31ca 100644 --- a/docs/plugin-compatibility.md +++ b/docs/plugin-compatibility.md @@ -85,6 +85,24 @@ payload verification, and uninstall remain transactional. The application launcher remains owned by Omarchy. Shibumi neither registers a `menu` entry point nor enables an application-menu service. +## Third-party widget appearance + +The Control Center's Icons editor lists Shibumi widgets and any enabled +third-party bar widget whose manifest declares the display modes it renders: + +```json +"x-shibumi": { "displayModes": ["full", "icon", "text"] } +``` + +`full` (icon and text) is the canonical default and must be included; `icon` +and `text` are optional. The editor offers only the declared modes, stores the +choice in the widget's group settings, and the bar host injects it into the +widget's `settings` as `displayMode` (the legacy `compact: true` flag means +`icon`). The V2 presentation honors `icon` and `text`; V1 keeps third-party +widgets at Default. Surface, color, outline, spacing, and opacity controls +apply through the host slot and need no widget support. Widgets that do not +declare `displayModes` keep their original rendering and stay in Plugins. + ## Third-party bar acceptance A third-party bar must implement Omarchy Quattro's standard bar-widget diff --git a/hancore.shibumi.control-center/ControlCenterPanel.qml b/hancore.shibumi.control-center/ControlCenterPanel.qml index 8e988da..c266931 100644 --- a/hancore.shibumi.control-center/ControlCenterPanel.qml +++ b/hancore.shibumi.control-center/ControlCenterPanel.qml @@ -312,6 +312,19 @@ ShibumiPanel { return changed } + // Third-party bar widgets opt into the Icons editor by declaring which + // displayMode values they render: "x-shibumi": { "displayModes": [...] }. + // Unknown values are dropped; a widget that cannot show "full" (the + // canonical default) is not listed. Suite widgets keep their catalog modes. + function pluginDisplayModes(shibumi) { + const declared = shibumi && Array.isArray(shibumi.displayModes) + ? shibumi.displayModes : [] + const result = ["full", "icon", "text"].filter(function(mode) { + return declared.indexOf(mode) >= 0 + }) + return result.indexOf("full") >= 0 ? result : [] + } + function pluginGlyph(pluginId, kinds) { const id = String(pluginId || "").toLowerCase() const semanticGlyphs = [ @@ -488,6 +501,7 @@ ShibumiPanel { version: String(manifest.version || ""), kinds: kinds, glyph: pluginGlyph(id, kinds), + displayModes: pluginDisplayModes(shibumi), enabled: enabled, barWidget: barWidget, installedInBar: barWidget diff --git a/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml b/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml index 136dc6d..9f8204c 100644 --- a/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml +++ b/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml @@ -8,6 +8,10 @@ Column { required property var controller required property var widgetOptions + // Third-party bar widgets that declare x-shibumi.displayModes join the + // catalog under their dynamic "G:" group. + readonly property var catalogOptions: + widgetOptions.concat(pluginAppearanceOptions()) property real uiScale: 1 property color foreground: Commons.Color.menu.text property color accent: Commons.Color.menu.selectedText @@ -199,6 +203,8 @@ Column { function pluginIdForGroup(groupValue) { const plugin = pluginForGroup(groupValue) if (plugin) return String(plugin.id || "") + if (isPluginCatalogGroup(groupValue)) + return String(groupValue).slice(2) const ids = { G1: "hancore.shibumi.control-center", G2: "hancore.shibumi.workspaces", @@ -336,13 +342,38 @@ Column { function catalogOptionForGroup(groupValue) { const group = String(groupValue || "") - for (let index = 0; index < widgetOptions.length; index++) { - if (String(widgetOptions[index].group || "") === group) - return widgetOptions[index] + for (let index = 0; index < catalogOptions.length; index++) { + if (String(catalogOptions[index].group || "") === group) + return catalogOptions[index] } return null } + function pluginAppearanceOptions() { + const entries = controller.pluginEntries || [] + const result = [] + for (let index = 0; index < entries.length; index++) { + const entry = entries[index] + const id = String(entry.id || "") + const modes = Array.isArray(entry.displayModes) ? entry.displayModes : [] + if (id === "" || entry.barWidget !== true || modes.length === 0 + || controller.shibumiWidgetGroup(id) !== "") continue + result.push({ + group: "G:" + id, + label: String(entry.name || id), + glyph: String(entry.glyph || "widgets"), + modes: modes + }) + } + return result + } + + function isPluginCatalogGroup(groupValue) { + const group = String(groupValue || "") + return group.indexOf("G:") === 0 + && controller.shibumiWidgetGroup(group.slice(2)) === "" + } + function isShibumiWidgetOption(source) { if (!source) return false return pluginIdForGroup(String(source.group || "")) @@ -460,6 +491,17 @@ Column { || catalogGroupForSettingsGroup(groupValue) || "") if (catalogGroup === "G1") return [] if (catalogGroup === "G9") return mediaStyleOptions + if (isPluginCatalogGroup(catalogGroup)) { + // The state service normalizes third-party display modes in V2 only; + // V1 keeps them at Default. + const option = catalogOptionForGroup(catalogGroup) + const modes = option && Array.isArray(option.modes) ? option.modes : [] + if (controller.v2LayoutActive !== true) + return [{ value: "full", label: "Default", enabled: true }] + return displayModeOptions.filter(function(candidate) { + return modes.indexOf(candidate.value) >= 0 + }) + } if (controller.v2LayoutActive === true) return displayModeOptions const compactAvailable = v1CompactGroupIds.indexOf(catalogGroup) >= 0 return compactAvailable ? [ diff --git a/tests/control-center-regression.sh b/tests/control-center-regression.sh index 107b050..75c40fc 100755 --- a/tests/control-center-regression.sh +++ b/tests/control-center-regression.sh @@ -1649,6 +1649,10 @@ workbench="$control_dir/WidgetAppearanceWorkbench.qml" || fail "direct widget Appearance workbench is missing" for workbench_contract in \ 'readonly property var overviewOptions: buildOverviewOptions()' \ + 'widgetOptions.concat(pluginAppearanceOptions())' \ + 'function pluginAppearanceOptions()' \ + 'function isPluginCatalogGroup(groupValue)' \ + '|| controller.shibumiWidgetGroup(id) !== "") continue' \ 'readonly property var activeOptions: overviewOptions.active' \ 'readonly property var inactiveOptions: overviewOptions.inactive' \ 'readonly property var editableOptions: activeOptions.concat(inactiveOptions)' \ diff --git a/tests/control-center-smoke.qml b/tests/control-center-smoke.qml index e12bf42..527a1d1 100644 --- a/tests/control-center-smoke.qml +++ b/tests/control-center-smoke.qml @@ -446,6 +446,70 @@ ShellRoot { - v1OverviewPanelHeight) > 0.5) return root.fail("Icons overview height differed between V1 and V2") panel.v2LayoutActive = false + + // A third-party bar widget that declares x-shibumi.displayModes joins + // the Icons editor with only its declared modes in V2, and stays at + // Default in V1. One without the declaration is not listed. + const savedEntries = panel.pluginEntries + panel.pluginEntries = savedEntries.concat([{ + id: "example.chess", name: "Example Chess", description: "", + author: "Example", category: "Info", searchTags: [], + kinds: ["bar-widget"], glyph: "widgets", provider: "Third-party", + removable: true, userToggleable: true, styleAvailable: true, + installedInBar: true, barWidget: true, enabled: true, group: "", + displayModes: ["full", "icon"], + replacementGroup: "", replacementTarget: "", + replacementTargetEnabled: false, replacementInEffect: false, + replacementLabel: "", replaced: false, replacedBy: "", + replacedByIds: [] + }]) + panel.v2LayoutActive = true + const v2BaseCount = appearance.activeWidgetCount + panel.thirdPartyAppearanceFixture = true + const thirdPartyCount = appearance.activeWidgetCount + const thirdPartyOpened = thirdPartyCount === v2BaseCount + 1 + && appearance.openWidgetDetails("G:example.chess", "") + const thirdPartyMode = appearance.selectedWidgetMode + const thirdPartyModeValues = appearance.selectedWidgetModeOptions + .map(function(option) { return String(option.value) }).join(",") + panel.v2LayoutActive = false + const thirdPartyV1Default = appearance.selectedWidgetModeOptions + .length === 1 + && appearance.selectedWidgetModeOptions[0].value === "full" + appearance.showWidgetOverview() + appearance.selectedWidgetGroup = "G4" + appearance.selectedWidgetId = "" + panel.pluginEntries = savedEntries.concat([{ + id: "example.plain", name: "Example Plain", description: "", + author: "Example", category: "Info", searchTags: [], + kinds: ["bar-widget"], glyph: "widgets", provider: "Third-party", + removable: true, userToggleable: true, styleAvailable: true, + installedInBar: true, barWidget: true, enabled: true, group: "", + displayModes: [], + replacementGroup: "", replacementTarget: "", + replacementTargetEnabled: false, replacementInEffect: false, + replacementLabel: "", replaced: false, replacedBy: "", + replacedByIds: [] + }]) + panel.v2LayoutActive = true + const undeclaredHidden = appearance.activeWidgetCount === v2BaseCount + panel.v2LayoutActive = false + panel.thirdPartyAppearanceFixture = false + panel.pluginEntries = savedEntries + if (!thirdPartyOpened) + return root.fail("declared third-party widget was not listed: " + + thirdPartyCount + " active widgets, expected " + + (v2BaseCount + 1)) + if (thirdPartyMode !== "full" || thirdPartyModeValues !== "icon,full") + return root.fail("third-party display modes were " + + thirdPartyModeValues + " with mode " + thirdPartyMode) + if (!thirdPartyV1Default) + return root.fail("third-party V1 appearance is not Default-only") + if (!undeclaredHidden) + return root.fail("undeclared third-party widget entered the Icons editor") + if (appearance.activeWidgetCount !== 12 + || appearance.inactiveWidgetCount !== 6) + return root.fail("third-party appearance fixture cleanup failed") if (!appearance.resetActionVisible || appearance.resetConfirmationPending || appearance.activeResetVariant !== "v1" diff --git a/tests/fixtures/ControlCenterTestPanel.qml b/tests/fixtures/ControlCenterTestPanel.qml index b8706d2..5397e27 100644 --- a/tests/fixtures/ControlCenterTestPanel.qml +++ b/tests/fixtures/ControlCenterTestPanel.qml @@ -73,20 +73,29 @@ Item { property string lastSwitchTarget: "" property string lastQuickSystemAction: "" property int reloadCalls: 0 - readonly property var activeWidgetOrder: v2LayoutActive - ? { - left: ["G1", "G2", "G3", "G5", "G6", "G4", "G7"], - center: ["G8"], - right: [ - "G9", "G10", "G11", "G14", "G12", "G13", "G16", - "G18", "G17", "G15" - ] - } - : { - left: ["G1", "G2", "G3", "G4", "G5", "G6", "G7"], - center: ["G8"], - right: ["G9", "G10", "G11", "G12", "G13", "G14", "G15"] - } + // Appends a third-party dynamic group to the active order so the smoke + // test can exercise x-shibumi.displayModes without changing the baseline + // widget counts every other phase asserts. + property bool thirdPartyAppearanceFixture: false + readonly property var activeWidgetOrder: { + const order = v2LayoutActive + ? { + left: ["G1", "G2", "G3", "G5", "G6", "G4", "G7"], + center: ["G8"], + right: [ + "G9", "G10", "G11", "G14", "G12", "G13", "G16", + "G18", "G17", "G15" + ] + } + : { + left: ["G1", "G2", "G3", "G4", "G5", "G6", "G7"], + center: ["G8"], + right: ["G9", "G10", "G11", "G12", "G13", "G14", "G15"] + } + if (thirdPartyAppearanceFixture) + order.right = order.right.concat(["G:example.chess"]) + return order + } readonly property bool quickNetworkAvailable: true readonly property bool quickNetworkEnabled: true readonly property string quickNetworkLabel: "Fixture Wi-Fi"