diff --git a/Bar.qml b/Bar.qml index ed8eb5b..73fc0ce 100644 --- a/Bar.qml +++ b/Bar.qml @@ -396,7 +396,7 @@ Item { } function deduplicatedUnassignedEntries(entries) { - const seen = ({}) + const seen = Object.create(null) return entries.filter(function(entry) { const id = entryId(entry) if (widgetAllowsMultiple(id)) return true @@ -413,7 +413,9 @@ Item { // Keep their persisted V1 provider entries out of V2's unassigned deck, // otherwise the same widget would be rendered twice after a switch. return deduplicatedUnassignedEntries(entries.filter(function(entry) { - return !isV1AdditionalSuiteWidget(entryId(entry)) + if (isV1AdditionalSuiteWidget(entryId(entry))) return false + const groupId = GroupRegistry.dynamicGroupIdForModule(entryId(entry)) + return groupId === "" || !layoutStateController.groupLocation(groupId) })) } return deduplicatedUnassignedEntries(entries.filter(function(entry) { @@ -422,21 +424,27 @@ Item { })) } - function v1PluginSpecsForLayout(layoutValue, excludeValue, includeSpec) { + function pluginSpecsForLayout(layoutValue, excludeValue, includeSpec, + v2Value) { const excluded = Array.isArray(excludeValue) ? excludeValue.map(function(value) { return String(value || "") }) : [String(excludeValue || "")] const source = Util.isPlainObject(layoutValue) ? layoutValue : ({}) const specs = [] - const seen = ({}) + const seen = Object.create(null) for (const region of ["left", "center", "right"]) { const entries = Array.isArray(source[region]) ? source[region] : [] for (let index = 0; index < entries.length; index++) { const entry = entries[index] const id = entryId(entry) + const hasBarWidget = hasBarWidgetEntryPoint(id) + const shibumiModule = Util.isPlainObject(entry) + && entry.shibumiModule === true && hasBarWidget + const dynamicV2Provider = v2Value === true && hasBarWidget if (id === "" || excluded.indexOf(id) >= 0 || seen[id] || !Util.isPlainObject(entry) - || entry.shibumiModule !== true + || (!shibumiModule && !dynamicV2Provider) + || (v2Value === true && GroupRegistry.isAssignedModule(id)) || widgetAllowsMultiple(id)) continue seen[id] = true specs.push({ pluginId: id, region: region }) @@ -444,7 +452,8 @@ Item { } if (includeSpec && Util.isPlainObject(includeSpec)) { const id = entryId(includeSpec) - if (id !== "" && !seen[id] && !widgetAllowsMultiple(id)) + if (id !== "" && !seen[id] && !widgetAllowsMultiple(id) + && (v2Value !== true || !GroupRegistry.isAssignedModule(id))) specs.push({ pluginId: id, region: ["left", "center", "right"].indexOf( @@ -455,16 +464,60 @@ Item { return specs } + function v1PluginSpecsForLayout(layoutValue, excludeValue, includeSpec) { + return pluginSpecsForLayout( + layoutValue, excludeValue, includeSpec, false) + } + + function v2PluginSpecsForLayout(layoutValue, excludeValue, includeSpec) { + return pluginSpecsForLayout( + layoutValue, excludeValue, includeSpec, true) + } + function v1PluginSpecs(excludeValue, includeSpec) { return v1PluginSpecsForLayout(layoutConfig, excludeValue, includeSpec) } + function v2PluginSpecs(excludeValue, includeSpec) { + return v2PluginSpecsForLayout(layoutConfig, excludeValue, includeSpec) + } + + function activePluginSpecsForLayout(layoutValue, excludeValue, includeSpec) { + return layoutStateController.v2Mode + ? v2PluginSpecsForLayout(layoutValue, excludeValue, includeSpec) + : v1PluginSpecsForLayout(layoutValue, excludeValue, includeSpec) + } + + function activePluginSpecs(excludeValue, includeSpec) { + return activePluginSpecsForLayout( + layoutConfig, excludeValue, includeSpec) + } + + function reconcileActivePluginGroups(specs, syncValue, followRegionsValue) { + return layoutStateController.v2Mode + ? layoutStateController.reconcileV2PluginGroups( + specs, syncValue, followRegionsValue) + : layoutStateController.reconcileV1PluginGroups(specs) + } + function reconcileV1PluginGroups() { + if (layoutStateController.v2Mode) return true if (!layoutStateController.reconcileV1PluginGroups(v1PluginSpecs())) return false return reconcileWidgetFamilyProviders() } + function reconcileActivePluginGroupsAndProviders() { + // The shared host layout is also the V1 provider-region source. During a + // background reconciliation, let it repair an existing V2 dynamic group + // whose provider entry was moved outside the V2 editor. Explicit V2 drag + // mutations update both stores first, so this does not undo an edit. + if (!reconcileActivePluginGroups( + activePluginSpecs(), true, layoutStateController.v2Mode)) + return false + return reconcileWidgetFamilyProviders() + } + function layoutContains(widgetId) { const id = String(widgetId || "") if (!id) return false @@ -615,13 +668,127 @@ Item { })) } + function syncV2DynamicLayout(slotsValue) { + if (!Util.isPlainObject(slotsValue)) return false + const nextLayout = currentLayoutSnapshot() + const dynamicById = Object.create(null) + const desiredByRegion = ({ left: [], center: [], right: [] }) + let changed = false + const desiredIds = Object.create(null) + for (const region of ["left", "center", "right"]) { + const slots = Array.isArray(slotsValue[region]) + ? slotsValue[region] : [] + for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) { + const moduleId = GroupRegistry.dynamicModuleIdForGroup( + String(slots[slotIndex] || "")) + if (moduleId === "") continue + if (Object.prototype.hasOwnProperty.call(desiredIds, moduleId)) + return false + desiredIds[moduleId] = true + desiredByRegion[region].push(moduleId) + } + } + + // A malformed host layout must not let one dynamic provider appear twice. + // Count before moving or reordering so duplicates across regions also + // fail closed without ever reaching shell-config persistence. + const actualCounts = Object.create(null) + for (const region of ["left", "center", "right"]) { + const entries = nextLayout[region] + for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) { + const moduleId = entryId(entries[entryIndex]) + if (!Object.prototype.hasOwnProperty.call(desiredIds, moduleId)) + continue + actualCounts[moduleId] = (actualCounts[moduleId] || 0) + 1 + } + } + for (const moduleId in desiredIds) { + if (actualCounts[moduleId] !== 1) return false + } + + // First put every V2-assigned dynamic entry in its model-owned region. + for (const region of ["left", "center", "right"]) { + const desired = desiredByRegion[region] + for (let index = 0; index < desired.length; index++) { + const moduleId = desired[index] + let sourceRegion = "" + let sourceIndex = -1 + for (const candidateRegion of ["left", "center", "right"]) { + const entries = nextLayout[candidateRegion] + const candidateIndex = entries.findIndex(function(entry) { + return entryId(entry) === moduleId + }) + if (candidateIndex >= 0) { + sourceRegion = candidateRegion + sourceIndex = candidateIndex + break + } + } + if (sourceRegion === "") return false + if (sourceRegion === region) continue + const entry = nextLayout[sourceRegion].splice(sourceIndex, 1)[0] + nextLayout[region].push(entry) + changed = true + } + } + + // Then reorder only the dynamic entries inside each region. Native and + // unassigned entries retain their relative positions and settings. + for (const region of ["left", "center", "right"]) { + const desired = desiredByRegion[region] + const desiredSet = Object.create(null) + for (let index = 0; index < desired.length; index++) { + const moduleId = desired[index] + desiredSet[moduleId] = true + const entries = nextLayout[region] + const entryIndex = entries.findIndex(function(entry) { + return entryId(entry) === moduleId + }) + if (entryIndex < 0) return false + dynamicById[moduleId] = entries[entryIndex] + } + let dynamicIndex = 0 + for (let entryIndex = 0; + entryIndex < nextLayout[region].length; entryIndex++) { + const currentId = entryId(nextLayout[region][entryIndex]) + if (!desiredSet[currentId]) continue + if (dynamicIndex >= desired.length) return false + const desiredId = desired[dynamicIndex++] + if (currentId === desiredId) continue + nextLayout[region][entryIndex] = dynamicById[desiredId] + changed = true + } + } + if (!changed) return true + if (!shell || typeof shell.mutateShellConfig !== "function") return false + shell.mutateShellConfig(function(config) { + if (!Util.isPlainObject(config.bar)) config.bar = {} + config.bar.layout = JSON.parse(JSON.stringify(nextLayout)) + }) + layoutConfig = nextLayout + return true + } + + function currentV2LayoutSnapshot() { + return layoutStateController.v2Mode + && layoutStateController.v2Slots + ? JSON.parse(JSON.stringify(layoutStateController.v2Slots)) : null + } + + function restoreV2LayoutSnapshot(snapshotValue) { + return snapshotValue === null || snapshotValue === undefined + || !layoutStateController.v2Mode + ? true : layoutStateController.restoreV2Layout(snapshotValue) + } + function providerLayoutSnapshot(groupValues) { const groups = normalizedProviderGroups(groupValues) const states = groups.length > 0 ? widgetGroupVariantStates(groups) : ({}) if (groups.length > 0 && !states) return null return { layout: currentLayoutSnapshot(), - groupStates: states || ({}) + groupStates: states || ({}), + v2Layout: currentV2LayoutSnapshot() } } @@ -633,10 +800,17 @@ Item { for (const region of ["left", "center", "right"]) { if (!Array.isArray(layout[region])) return false } - return applyProviderLayoutTransaction( - JSON.parse(JSON.stringify(layout)), - v1PluginSpecsForLayout(layout), - JSON.parse(JSON.stringify(snapshotValue.groupStates))) + const previousV2Layout = currentV2LayoutSnapshot() + if (!restoreV2LayoutSnapshot(snapshotValue.v2Layout)) return false + if (!applyProviderLayoutTransaction( + JSON.parse(JSON.stringify(layout)), + activePluginSpecsForLayout(layout), + JSON.parse(JSON.stringify(snapshotValue.groupStates)))) { + if (!restoreV2LayoutSnapshot(previousV2Layout)) + console.warn("provider snapshot V2 rollback was incomplete") + return false + } + return true } function layoutWithoutProviderIds(layoutValue, providerIds) { @@ -762,7 +936,7 @@ Item { } if (removedProviderIds.length > 0) return applyProviderLayoutTransaction(nextLayout, - v1PluginSpecs(removedProviderIds), stateValues) + activePluginSpecsForLayout(nextLayout), stateValues) const groups = Object.keys(stateValues) return groups.length === 0 || setWidgetGroupVariantStates(stateValues) } @@ -777,8 +951,9 @@ Item { if (installed === true && !hasBarWidgetEntryPoint(id)) return false - const previousSpecs = v1PluginSpecs() + const previousSpecs = activePluginSpecs() const previousLayout = currentLayoutSnapshot() + const previousV2Layout = currentV2LayoutSnapshot() const replacedProviderIds = installed === true ? conflictingLayoutProviderIds(id) : [id] const displacedGroups = [] @@ -821,9 +996,10 @@ Item { const registryWasEnabled = pluginRegistry && typeof pluginRegistry.isEnabled === "function" ? pluginRegistry.isEnabled(id) : null - const desiredSpecs = v1PluginSpecs(replacedProviderIds, + const desiredSpecs = activePluginSpecs(replacedProviderIds, installed === true ? { id: id, region: targetRegion } : null) - if (!layoutStateController.reconcileV1PluginGroups(desiredSpecs)) + if (!reconcileActivePluginGroups( + desiredSpecs, false, layoutStateController.v2Mode)) return false if (installed === true && pluginRegistry @@ -860,12 +1036,16 @@ Item { if (!Util.isPlainObject(config.bar)) config.bar = {} config.bar.layout = JSON.parse(JSON.stringify(previousLayout)) }) - layoutStateController.reconcileV1PluginGroups(previousSpecs) + const restoredGroups = reconcileActivePluginGroups( + previousSpecs, false) + const restoredV2 = restoreV2LayoutSnapshot(previousV2Layout) if (registryWasEnabled === false && pluginRegistry && typeof pluginRegistry.setEnabled === "function") pluginRegistry.setEnabled(id, false) - if (previousFamilyStates) - setWidgetGroupVariantStates(previousFamilyStates) + const restoredFamilies = !previousFamilyStates + || setWidgetGroupVariantStates(previousFamilyStates) + if (!restoredGroups || !restoredV2 || !restoredFamilies) + console.warn("provider transaction rollback was incomplete") return false } return true @@ -874,13 +1054,14 @@ Item { function applyProviderLayoutTransaction(nextLayout, desiredSpecs, stateValues) { if (!shell || typeof shell.mutateShellConfig !== "function") return false - const previousSpecs = v1PluginSpecs() + const previousSpecs = activePluginSpecs() const previousLayout = currentLayoutSnapshot() + const previousV2Layout = currentV2LayoutSnapshot() const groups = Util.isPlainObject(stateValues) ? Object.keys(stateValues) : [] const previousStates = groups.length > 0 ? widgetGroupVariantStates(groups) : null - if (!layoutStateController.reconcileV1PluginGroups(desiredSpecs)) + if (!reconcileActivePluginGroups(desiredSpecs, false)) return false shell.mutateShellConfig(function(config) { if (!Util.isPlainObject(config.bar)) config.bar = {} @@ -892,8 +1073,13 @@ Item { if (!Util.isPlainObject(config.bar)) config.bar = {} config.bar.layout = JSON.parse(JSON.stringify(previousLayout)) }) - layoutStateController.reconcileV1PluginGroups(previousSpecs) - if (previousStates) setWidgetGroupVariantStates(previousStates) + const restoredGroups = reconcileActivePluginGroups( + previousSpecs, false) + const restoredV2 = restoreV2LayoutSnapshot(previousV2Layout) + const restoredStates = !previousStates + || setWidgetGroupVariantStates(previousStates) + if (!restoredGroups || !restoredV2 || !restoredStates) + console.warn("provider layout rollback was incomplete") return false } @@ -948,7 +1134,7 @@ Item { } } return applyProviderLayoutTransaction( - nextLayout, v1PluginSpecs(alternatives), effectiveStates) + nextLayout, activePluginSpecs(alternatives), effectiveStates) } function restoreWidgetFamilyProviders(groupValues) { @@ -974,7 +1160,7 @@ Item { states[group] = { v1: true, v2: true } } return applyProviderLayoutTransaction( - nextLayout, v1PluginSpecs([id]), states) + nextLayout, activePluginSpecs([id]), states) } function removeWidgetFamilyAlternatives(groupId) { @@ -1740,7 +1926,19 @@ Item { id: v1PluginReconcileTimer interval: 1 repeat: false - onTriggered: root.reconcileV1PluginGroups() + onTriggered: { + if (root.injectionComplete) + root.reconcileActivePluginGroupsAndProviders() + } + } + + Connections { + target: layoutStateController + ignoreUnknownSignals: true + + function onV2ModeChanged() { + v1PluginReconcileTimer.restart() + } } Connections { diff --git a/core/LayoutController.qml b/core/LayoutController.qml index a11895a..3032e10 100644 --- a/core/LayoutController.qml +++ b/core/LayoutController.qml @@ -11,9 +11,13 @@ Item { property var stateService: null readonly property var config: stateService && stateService.config ? stateService.config : ({}) - readonly property bool v2Mode: config && config.presentation - ? String(config.presentation.shellStyle || "shibumi") !== "shibumi" - : false + readonly property bool v2Mode: { + const source = stateService && stateService.config + ? stateService.config : config + return source && source.presentation + ? String(source.presentation.shellStyle || "shibumi") !== "shibumi" + : false + } readonly property var layoutProtection: config && config.layoutProtection ? config.layoutProtection : ({ v1: false, v2: false }) readonly property bool v1LayoutProtected: layoutProtection.v1 === true @@ -93,13 +97,25 @@ Item { LayoutModel.copySplits(nextSplits, nextOrder)) } + function persistV2Layout(nextSlots) { + if (!stateService || typeof stateService.setV2Layout !== "function") + return false + const previousSlots = V2LayoutModel.copy(v2Slots) + if (!previousSlots || !stateService.setV2Layout(nextSlots)) return false + if (bar && typeof bar.syncV2DynamicLayout === "function" + && !bar.syncV2DynamicLayout(nextSlots)) { + if (!stateService.setV2Layout(previousSlots)) + console.warn("V2 layout rollback failed after host sync rejection") + return false + } + return true + } + function swapGroups(sourceGroupId, targetGroupId) { if (v2Mode) { const nextSlots = V2LayoutModel.swapGroups( v2Slots, sourceGroupId, targetGroupId) - return nextSlots && stateService - && typeof stateService.setV2Layout === "function" - ? stateService.setV2Layout(nextSlots) : false + return nextSlots ? persistV2Layout(nextSlots) : false } const currentOrder = currentV1Order() const nextOrder = LayoutModel.swapGroups( @@ -109,11 +125,9 @@ Item { function moveGroupToSlot(sourceGroupId, targetRegion, targetIndex) { if (v2Mode) { - if (!stateService || typeof stateService.setV2Layout !== "function") - return false const nextSlots = V2LayoutModel.moveGroupToSlot( v2Slots, sourceGroupId, targetRegion, targetIndex) - return nextSlots ? stateService.setV2Layout(nextSlots) : false + return nextSlots ? persistV2Layout(nextSlots) : false } const currentOrder = currentV1Order() const nextOrder = LayoutModel.moveGroupToSlot( @@ -158,6 +172,29 @@ Item { return persist(next.order, next.splits) } + function reconcileV2PluginGroups(specs, syncValue, followRegionsValue) { + if (!v2Mode || !stateService + || typeof stateService.setV2Layout !== "function") return false + const current = V2LayoutModel.copy(v2Slots) + const next = V2LayoutModel.reconcilePluginGroups( + current, specs, followRegionsValue === true) + if (!next || next.unplaced.length > 0) return false + if (V2LayoutModel.same(current, next.layout)) { + return syncValue === true && bar + && typeof bar.syncV2DynamicLayout === "function" + ? bar.syncV2DynamicLayout(next.layout) : true + } + if (!stateService.setV2Layout(next.layout)) return false + if (syncValue === true && bar + && typeof bar.syncV2DynamicLayout === "function" + && !bar.syncV2DynamicLayout(next.layout)) { + if (!stateService.setV2Layout(current)) + console.warn("V2 reconciliation rollback failed after host sync rejection") + return false + } + return true + } + function baseV1SlotCount(region) { return LayoutModel.baseCount(region) } @@ -171,17 +208,15 @@ Item { } function addV2Slot(region) { - if (!v2Mode || !stateService - || typeof stateService.setV2Layout !== "function") return false + if (!v2Mode) return false const next = V2LayoutModel.addSlot(v2Slots, region) - return next ? stateService.setV2Layout(next) : false + return next ? persistV2Layout(next) : false } function removeV2Slot(region) { - if (!v2Mode || !stateService - || typeof stateService.setV2Layout !== "function") return false + if (!v2Mode) return false const next = V2LayoutModel.removeSlot(v2Slots, region) - return next ? stateService.setV2Layout(next) : false + return next ? persistV2Layout(next) : false } function baseV2SlotCount(region) { @@ -195,10 +230,9 @@ Item { } function removeV2SlotAt(region, index) { - if (!v2Mode || !stateService - || typeof stateService.setV2Layout !== "function") return false + if (!v2Mode) return false const next = V2LayoutModel.removeSlotAt(v2Slots, region, index) - return next ? stateService.setV2Layout(next) : false + return next ? persistV2Layout(next) : false } function toggleSplit(region, index, editingValue) { @@ -223,10 +257,25 @@ Item { return nextSplits ? persist(currentOrder, nextSplits) : false } + function resetV2Layout() { + if (!v2Mode || !stateService + || typeof stateService.resetV2Layout !== "function") return false + if (!stateService.resetV2Layout()) return false + return !bar || typeof bar.syncV2DynamicLayout !== "function" + ? true : bar.syncV2DynamicLayout(V2LayoutModel.defaultLayout()) + } + + function restoreV2Layout(value) { + if (!v2Mode || !stateService + || typeof stateService.setV2Layout !== "function") return false + const target = V2LayoutModel.copy(value) + if (!target) return false + return V2LayoutModel.same(v2Slots, target) + || stateService.setV2Layout(target) + } + function resetLayout() { - if (v2Mode && stateService - && typeof stateService.resetV2Layout === "function") - return stateService.resetV2Layout() + if (v2Mode) return resetV2Layout() return stateService && typeof stateService.resetLayout === "function" ? stateService.resetLayout() : false } diff --git a/core/ShibumiConfig.js b/core/ShibumiConfig.js index 50bff1c..0163313 100644 --- a/core/ShibumiConfig.js +++ b/core/ShibumiConfig.js @@ -162,9 +162,13 @@ function isV1DynamicGroupId(value) { && /^[a-z0-9][a-z0-9._-]*$/.test(pluginId) } +function isDynamicGroupId(value) { + return isV1DynamicGroupId(value) +} + function isGroupId(value) { var groupId = String(value || "") - return GroupIds.indexOf(groupId) >= 0 || isV1DynamicGroupId(groupId) + return GroupIds.indexOf(groupId) >= 0 || isDynamicGroupId(groupId) } function boolArray(value, length) { @@ -241,12 +245,17 @@ function normalizedV2Layout(value) { return null for (var i = 0; i < entries.length; i++) { var id = String(entries[i] || "") - if (id !== "" && (GroupIds.indexOf(id) < 0 || seen[id])) return null + if (id !== "" && ((GroupIds.indexOf(id) < 0 + && !isDynamicGroupId(id)) || seen[id])) return null if (id !== "") seen[id] = true result[region].push(id) } } - return Object.keys(seen).length === GroupIds.length ? result : null + var fixedCount = 0 + for (var groupId in seen) { + if (GroupIds.indexOf(groupId) >= 0) fixedCount++ + } + return fixedCount === GroupIds.length ? result : null } function normalizedV2Boundaries(value) { diff --git a/core/V2LayoutModel.js b/core/V2LayoutModel.js index 8b5d6f3..2e0589b 100644 --- a/core/V2LayoutModel.js +++ b/core/V2LayoutModel.js @@ -6,6 +6,7 @@ var GroupIds = [ "G16", "G17", "G18" ] var Regions = ["left", "center", "right"] +var DynamicGroupPrefix = "G:" var Limits = { left: { min: 10, max: 13 }, center: { min: 1, max: 4 }, @@ -16,6 +17,28 @@ function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value) } +function validPluginId(value) { + var id = String(value || "") + return id.length > 0 && id.length <= 160 + && /^[a-z0-9][a-z0-9._-]*$/.test(id) +} + +function dynamicGroupId(pluginValue) { + var pluginId = String(pluginValue || "") + return validPluginId(pluginId) ? DynamicGroupPrefix + pluginId : "" +} + +function dynamicPluginId(groupValue) { + var groupId = String(groupValue || "") + if (groupId.indexOf(DynamicGroupPrefix) !== 0) return "" + var pluginId = groupId.slice(DynamicGroupPrefix.length) + return validPluginId(pluginId) ? pluginId : "" +} + +function isDynamicGroupId(value) { + return dynamicPluginId(value) !== "" +} + function defaultLayout() { return { left: ["G1", "G2", "G3", "", "G5", "G6", "G4", "G7", "", ""], @@ -29,7 +52,7 @@ function defaultLayout() { function valid(value) { if (!isObject(value)) return false - var seen = {} + var seen = Object.create(null) for (var r = 0; r < Regions.length; r++) { var region = Regions[r] var entries = value[region] @@ -39,11 +62,16 @@ function valid(value) { return false for (var i = 0; i < entries.length; i++) { var id = String(entries[i] || "") - if (id !== "" && (GroupIds.indexOf(id) < 0 || seen[id])) return false + if (id !== "" && ((GroupIds.indexOf(id) < 0 + && !isDynamicGroupId(id)) || seen[id])) return false if (id !== "") seen[id] = true } } - return Object.keys(seen).length === GroupIds.length + var fixedCount = 0 + for (var groupId in seen) { + if (GroupIds.indexOf(groupId) >= 0) fixedCount++ + } + return fixedCount === GroupIds.length } function copy(value) { @@ -67,7 +95,7 @@ function visibleOrder(value) { function locationFor(value, groupValue) { var source = valid(value) ? value : defaultLayout() var groupId = String(groupValue || "") - if (GroupIds.indexOf(groupId) < 0) return null + if (GroupIds.indexOf(groupId) < 0 && !isDynamicGroupId(groupId)) return null for (var r = 0; r < Regions.length; r++) { var region = Regions[r] var index = source[region].indexOf(groupId) @@ -138,6 +166,137 @@ function removeSlotAt(value, regionValue, indexValue) { return valid(result) ? result : null } +function normalizedPluginSpecs(value) { + if (!Array.isArray(value)) return null + var result = [] + var seen = Object.create(null) + for (var i = 0; i < value.length; i++) { + var spec = value[i] + if (!isObject(spec) || !validPluginId(spec.pluginId)) return null + var pluginId = String(spec.pluginId) + if (seen[pluginId]) continue + seen[pluginId] = true + var region = String(spec.region || "") + result.push({ + pluginId: pluginId, + region: Regions.indexOf(region) >= 0 ? region : "right" + }) + } + result.sort(function(left, right) { + return left.pluginId.localeCompare(right.pluginId) + }) + return result +} + +function compactEmptyTail(value, regionValue) { + var region = String(regionValue || "") + var limit = Limits[region] + if (!limit || !Array.isArray(value[region])) return false + var changed = false + while (value[region].length > limit.min + && String(value[region][value[region].length - 1] || "") === "") { + value[region].pop() + changed = true + } + return changed +} + +function addDynamicGroup(value, pluginValue, regionValue) { + var result = copy(valid(value) ? value : defaultLayout()) + var groupId = dynamicGroupId(pluginValue) + var region = String(regionValue || "") + if (!result || groupId === "" || Regions.indexOf(region) < 0) return null + if (locationFor(result, groupId)) return result + for (var index = 0; index < result[region].length; index++) { + if (String(result[region][index] || "") !== "") continue + result[region][index] = groupId + return valid(result) ? result : null + } + if (result[region].length >= Limits[region].max) return null + result[region].push(groupId) + return valid(result) ? result : null +} + +function moveDynamicGroupToRegion(value, groupValue, regionValue) { + var result = copy(valid(value) ? value : defaultLayout()) + var groupId = String(groupValue || "") + var region = String(regionValue || "") + var source = locationFor(result, groupId) + if (!result || !source || !isDynamicGroupId(groupId) + || Regions.indexOf(region) < 0) return null + if (source.region === region) return result + var targetIndex = -1 + for (var index = 0; index < result[region].length; index++) { + if (String(result[region][index] || "") === "") { + targetIndex = index + break + } + } + if (targetIndex < 0 && result[region].length < Limits[region].max) { + result[region].push("") + targetIndex = result[region].length - 1 + } + if (targetIndex < 0) targetIndex = 0 + return moveGroupToSlot(result, groupId, region, targetIndex) +} + +function removeDynamicGroup(value, groupValue) { + var result = copy(valid(value) ? value : defaultLayout()) + var groupId = String(groupValue || "") + var location = locationFor(result, groupId) + if (!result || !isDynamicGroupId(groupId) || !location) return null + result[location.region][location.index] = "" + compactEmptyTail(result, location.region) + return valid(result) ? result : null +} + +function reconcilePluginGroups(value, specsValue, followRegionsValue) { + var result = copy(valid(value) ? value : defaultLayout()) + var specs = normalizedPluginSpecs(specsValue) + var followRegions = followRegionsValue === true + if (!result || !specs) return null + var desired = Object.create(null) + for (var i = 0; i < specs.length; i++) + desired[dynamicGroupId(specs[i].pluginId)] = specs[i] + + var stale = [] + for (var r = 0; r < Regions.length; r++) { + var region = Regions[r] + for (var index = 0; index < result[region].length; index++) { + var groupId = String(result[region][index] || "") + if (isDynamicGroupId(groupId) && !desired[groupId] + && stale.indexOf(groupId) < 0) + stale.push(groupId) + } + } + for (var staleIndex = 0; staleIndex < stale.length; staleIndex++) { + result = removeDynamicGroup(result, stale[staleIndex]) + if (!result) return null + } + + var unplaced = [] + for (var specIndex = 0; specIndex < specs.length; specIndex++) { + var spec = specs[specIndex] + var requestedGroupId = dynamicGroupId(spec.pluginId) + var currentLocation = locationFor(result, requestedGroupId) + if (currentLocation && followRegions + && currentLocation.region !== spec.region) { + var moved = moveDynamicGroupToRegion( + result, requestedGroupId, spec.region) + if (!moved) unplaced.push(spec.pluginId) + else result = moved + continue + } + if (currentLocation) continue + var placed = addDynamicGroup(result, spec.pluginId, spec.region) + if (!placed) unplaced.push(spec.pluginId) + else result = placed + } + // A full region must not silently publish a partially placed V2 layout. + if (unplaced.length > 0) return { layout: result, unplaced: unplaced } + return { layout: result, unplaced: [] } +} + function same(left, right) { if (!valid(left) || !valid(right)) return false return JSON.stringify(left) === JSON.stringify(right) diff --git a/docs/step1c-v2-third-party-layout-validation.md b/docs/step1c-v2-third-party-layout-validation.md new file mode 100644 index 0000000..b40e4a3 --- /dev/null +++ b/docs/step1c-v2-third-party-layout-validation.md @@ -0,0 +1,102 @@ +# Step 1C V2 third-party layout validation + +Status: implementation validation on the Step 1C worktree. This is not live +acceptance and does not replace the physical multi-output gate. + +## PR #28 assessment + +PR #28 was not cherry-picked, merged, or otherwise used. Its one commit was +open and had no recorded CI checks or review decision when inspected. + +Its narrow guard is directionally valid for one defect: the V2 provider path +could invoke V1-only reconciliation. A temporary isolated Quickshell harness +against the Step 1C baseline recorded: + +```text +unguarded-v2-reconcile: writes=1 v1DynamicIndex=7 v2DynamicLocation=null v2LayoutUnchanged=true +guarded-v2-reconcile: writes=1 v1StateUnchanged=true +v1-reconcile-evidence: PASS +``` + +This proves an unintended V1 state write while V2 is active. It does **not** +prove that the current `v2Layout` is directly overwritten: the same harness +showed the V2 layout unchanged. The PR therefore does not cover the complete +Step 1C behavior, and its timer/startup path would remain incomplete if only +its three transactional call sites were guarded. + +## Step 1C implementation evidence + +The worktree now keeps V2 dynamic third-party groups in the V2 layout model, +uses the existing provider-neutral drag controller, synchronizes the shared +host `bar.layout` on cross-region and same-region moves, and leaves V1 +reconciliation isolated to V1. Generic fixtures cover activation, requested +region changes, same-region reorder, cross-region movement, removal, settings +entry preservation, and all four V2 styles (`full`, `fit`, `dock`, `notch`). + +Validated against the pinned installed-source Omarchy baseline +`b99fd91cf11db92b03bbd69e4fff908662bd74a3`: + +- `./tests/bar-host-registry-regression.sh` — passed +- `./tests/state-service-regression.sh` — passed +- `./tests/control-center-regression.sh` — passed +- `QT_QPA_PLATFORM=offscreen /usr/lib/qt6/bin/qmltestrunner -input tests/shibumi-config-regression.qml` — passed +- `tests/layout-model-regression.qml` and `tests/layout-controller-regression.qml` — passed +- full pinned `./tests/contract-regression.sh` — passed, including 24 plugins +- `./scripts/sync-bar-host.sh --check` and `./scripts/sync-shared.sh --check` — passed +- `git diff --check` — passed + +No physical multi-monitor, hardware, top/bottom live, or visual-freeze +acceptance is claimed by these fixture and offscreen results. Those remain +explicit approval gates before Step 1C completion. + +## Controlled live run + +A controlled live run was performed on 2026-08-20 against the active Wayland +session (`Omarchy 4.0.0-1`, Hyprland 0.56.2, Quickshell 0.3.0). The private +worktree payload was staged only into the two affected user plugin directories; +`/usr/share/omarchy` was not modified. + +Passed: + +- cold shell restart with exactly one production Quickshell process and a + successful `omarchy-shell shell ping` +- real third-party `hancore.omaq` activation through the Shibumi suite IPC; + `G:hancore.omaq` was persisted in V2 and the host entry retained + `shibumiModule: true` +- real third-party `hancore.bongocat` activation and removal, with the same + V2 dynamic-group lifecycle +- moving OmaQ from right to left through the host bar command; the persisted + V2 group followed to left without changing the fixed V1 groups +- a subsequent user-visible V2 Edit Layout drag moved OmaQ to the left section; + after a cold shell restart the host entry and `G:hancore.omaq` remained in + the left section at the persisted positions +- cold style matrix for `full`, `fit`, `dock`, and `notch`, with the dynamic + group retained and one Quickshell process in every case +- V1 `shibumi` cold load and return to V2 `notch`; the fixed V1 order remained + unchanged and the dynamic V2 group survived the switch +- live top and bottom position captures, removal rollback, and final shell + restart + +The original `shell.json` SHA-256 +`5c304a1c7d46b460bef77e1d419996cd20b09f09e481123cc35c55a45c9ca429` was restored +byte-for-byte. The original Shibumi bar and state payload hashes were also +restored, optional third-party plugins were disabled, and the final shell had +one production process. Captures and raw logs remain outside the repository at +`/tmp/shibumi-step1c-live-20260820150247/`. + +An initial attempt used the generic `omarchy plugin enable` command and was +abandoned after a rescan produced unrelated provider substitutions. It is not +counted as acceptance evidence; the controlled run used the Shibumi suite IPC. +The final interactive drag was performed by the user after unlocking the +session and completed with one Quickshell process and successful ping after +restart. + +After the live run, startup injection and rollback guards were hardened without +changing the exercised valid-provider path. The pinned complete contract suite +was rerun successfully on the final diff. + +This run exercised one physical output only. The user subsequently exercised a +monitor-scale change on that output and reported that the shell remained +functional. This is recorded only as a single-output scale check; physical +multi-output, mixed-scale, hardware, and any visual-freeze comparison remain +open gates. diff --git a/docs/widget-provider-contract.md b/docs/widget-provider-contract.md index 47dd4c4..92b5a5f 100644 --- a/docs/widget-provider-contract.md +++ b/docs/widget-provider-contract.md @@ -50,10 +50,12 @@ versions. Immediate Undo restores the exact prior V1/V2 activation state; deleting an active provider plugin restores its Shibumi replacement groups. The five Quattro compatibility siblings assigned by `OptionalGroups` have one -owner at a time. Plain legacy entries remain children of their fixed group, -while entries explicitly added by the plugin catalog with -`shibumiModule: true` render only through the dynamic V1 slot or V2 unassigned -deck. The catalog recognizes either form as installed. +owner at a time. Plain legacy entries remain children of their fixed group. Eligible +third-party entries explicitly added by the plugin catalog with +`shibumiModule: true` render through the dynamic V1 slot or the assigned V2 +dynamic group; assigned or consumed modules remain under their existing +provider-specific handling. Entries not yet assigned to a V2 group remain in +the V2 unassigned deck. The catalog recognizes either form as installed. Only manifests with a resolvable `entryPoints.barWidget` are accepted by the bar host. Service-only plugins remain available to widgets through Quattro's diff --git a/hancore.shibumi.bar/Bar.qml b/hancore.shibumi.bar/Bar.qml index ed8eb5b..73fc0ce 100644 --- a/hancore.shibumi.bar/Bar.qml +++ b/hancore.shibumi.bar/Bar.qml @@ -396,7 +396,7 @@ Item { } function deduplicatedUnassignedEntries(entries) { - const seen = ({}) + const seen = Object.create(null) return entries.filter(function(entry) { const id = entryId(entry) if (widgetAllowsMultiple(id)) return true @@ -413,7 +413,9 @@ Item { // Keep their persisted V1 provider entries out of V2's unassigned deck, // otherwise the same widget would be rendered twice after a switch. return deduplicatedUnassignedEntries(entries.filter(function(entry) { - return !isV1AdditionalSuiteWidget(entryId(entry)) + if (isV1AdditionalSuiteWidget(entryId(entry))) return false + const groupId = GroupRegistry.dynamicGroupIdForModule(entryId(entry)) + return groupId === "" || !layoutStateController.groupLocation(groupId) })) } return deduplicatedUnassignedEntries(entries.filter(function(entry) { @@ -422,21 +424,27 @@ Item { })) } - function v1PluginSpecsForLayout(layoutValue, excludeValue, includeSpec) { + function pluginSpecsForLayout(layoutValue, excludeValue, includeSpec, + v2Value) { const excluded = Array.isArray(excludeValue) ? excludeValue.map(function(value) { return String(value || "") }) : [String(excludeValue || "")] const source = Util.isPlainObject(layoutValue) ? layoutValue : ({}) const specs = [] - const seen = ({}) + const seen = Object.create(null) for (const region of ["left", "center", "right"]) { const entries = Array.isArray(source[region]) ? source[region] : [] for (let index = 0; index < entries.length; index++) { const entry = entries[index] const id = entryId(entry) + const hasBarWidget = hasBarWidgetEntryPoint(id) + const shibumiModule = Util.isPlainObject(entry) + && entry.shibumiModule === true && hasBarWidget + const dynamicV2Provider = v2Value === true && hasBarWidget if (id === "" || excluded.indexOf(id) >= 0 || seen[id] || !Util.isPlainObject(entry) - || entry.shibumiModule !== true + || (!shibumiModule && !dynamicV2Provider) + || (v2Value === true && GroupRegistry.isAssignedModule(id)) || widgetAllowsMultiple(id)) continue seen[id] = true specs.push({ pluginId: id, region: region }) @@ -444,7 +452,8 @@ Item { } if (includeSpec && Util.isPlainObject(includeSpec)) { const id = entryId(includeSpec) - if (id !== "" && !seen[id] && !widgetAllowsMultiple(id)) + if (id !== "" && !seen[id] && !widgetAllowsMultiple(id) + && (v2Value !== true || !GroupRegistry.isAssignedModule(id))) specs.push({ pluginId: id, region: ["left", "center", "right"].indexOf( @@ -455,16 +464,60 @@ Item { return specs } + function v1PluginSpecsForLayout(layoutValue, excludeValue, includeSpec) { + return pluginSpecsForLayout( + layoutValue, excludeValue, includeSpec, false) + } + + function v2PluginSpecsForLayout(layoutValue, excludeValue, includeSpec) { + return pluginSpecsForLayout( + layoutValue, excludeValue, includeSpec, true) + } + function v1PluginSpecs(excludeValue, includeSpec) { return v1PluginSpecsForLayout(layoutConfig, excludeValue, includeSpec) } + function v2PluginSpecs(excludeValue, includeSpec) { + return v2PluginSpecsForLayout(layoutConfig, excludeValue, includeSpec) + } + + function activePluginSpecsForLayout(layoutValue, excludeValue, includeSpec) { + return layoutStateController.v2Mode + ? v2PluginSpecsForLayout(layoutValue, excludeValue, includeSpec) + : v1PluginSpecsForLayout(layoutValue, excludeValue, includeSpec) + } + + function activePluginSpecs(excludeValue, includeSpec) { + return activePluginSpecsForLayout( + layoutConfig, excludeValue, includeSpec) + } + + function reconcileActivePluginGroups(specs, syncValue, followRegionsValue) { + return layoutStateController.v2Mode + ? layoutStateController.reconcileV2PluginGroups( + specs, syncValue, followRegionsValue) + : layoutStateController.reconcileV1PluginGroups(specs) + } + function reconcileV1PluginGroups() { + if (layoutStateController.v2Mode) return true if (!layoutStateController.reconcileV1PluginGroups(v1PluginSpecs())) return false return reconcileWidgetFamilyProviders() } + function reconcileActivePluginGroupsAndProviders() { + // The shared host layout is also the V1 provider-region source. During a + // background reconciliation, let it repair an existing V2 dynamic group + // whose provider entry was moved outside the V2 editor. Explicit V2 drag + // mutations update both stores first, so this does not undo an edit. + if (!reconcileActivePluginGroups( + activePluginSpecs(), true, layoutStateController.v2Mode)) + return false + return reconcileWidgetFamilyProviders() + } + function layoutContains(widgetId) { const id = String(widgetId || "") if (!id) return false @@ -615,13 +668,127 @@ Item { })) } + function syncV2DynamicLayout(slotsValue) { + if (!Util.isPlainObject(slotsValue)) return false + const nextLayout = currentLayoutSnapshot() + const dynamicById = Object.create(null) + const desiredByRegion = ({ left: [], center: [], right: [] }) + let changed = false + const desiredIds = Object.create(null) + for (const region of ["left", "center", "right"]) { + const slots = Array.isArray(slotsValue[region]) + ? slotsValue[region] : [] + for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) { + const moduleId = GroupRegistry.dynamicModuleIdForGroup( + String(slots[slotIndex] || "")) + if (moduleId === "") continue + if (Object.prototype.hasOwnProperty.call(desiredIds, moduleId)) + return false + desiredIds[moduleId] = true + desiredByRegion[region].push(moduleId) + } + } + + // A malformed host layout must not let one dynamic provider appear twice. + // Count before moving or reordering so duplicates across regions also + // fail closed without ever reaching shell-config persistence. + const actualCounts = Object.create(null) + for (const region of ["left", "center", "right"]) { + const entries = nextLayout[region] + for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) { + const moduleId = entryId(entries[entryIndex]) + if (!Object.prototype.hasOwnProperty.call(desiredIds, moduleId)) + continue + actualCounts[moduleId] = (actualCounts[moduleId] || 0) + 1 + } + } + for (const moduleId in desiredIds) { + if (actualCounts[moduleId] !== 1) return false + } + + // First put every V2-assigned dynamic entry in its model-owned region. + for (const region of ["left", "center", "right"]) { + const desired = desiredByRegion[region] + for (let index = 0; index < desired.length; index++) { + const moduleId = desired[index] + let sourceRegion = "" + let sourceIndex = -1 + for (const candidateRegion of ["left", "center", "right"]) { + const entries = nextLayout[candidateRegion] + const candidateIndex = entries.findIndex(function(entry) { + return entryId(entry) === moduleId + }) + if (candidateIndex >= 0) { + sourceRegion = candidateRegion + sourceIndex = candidateIndex + break + } + } + if (sourceRegion === "") return false + if (sourceRegion === region) continue + const entry = nextLayout[sourceRegion].splice(sourceIndex, 1)[0] + nextLayout[region].push(entry) + changed = true + } + } + + // Then reorder only the dynamic entries inside each region. Native and + // unassigned entries retain their relative positions and settings. + for (const region of ["left", "center", "right"]) { + const desired = desiredByRegion[region] + const desiredSet = Object.create(null) + for (let index = 0; index < desired.length; index++) { + const moduleId = desired[index] + desiredSet[moduleId] = true + const entries = nextLayout[region] + const entryIndex = entries.findIndex(function(entry) { + return entryId(entry) === moduleId + }) + if (entryIndex < 0) return false + dynamicById[moduleId] = entries[entryIndex] + } + let dynamicIndex = 0 + for (let entryIndex = 0; + entryIndex < nextLayout[region].length; entryIndex++) { + const currentId = entryId(nextLayout[region][entryIndex]) + if (!desiredSet[currentId]) continue + if (dynamicIndex >= desired.length) return false + const desiredId = desired[dynamicIndex++] + if (currentId === desiredId) continue + nextLayout[region][entryIndex] = dynamicById[desiredId] + changed = true + } + } + if (!changed) return true + if (!shell || typeof shell.mutateShellConfig !== "function") return false + shell.mutateShellConfig(function(config) { + if (!Util.isPlainObject(config.bar)) config.bar = {} + config.bar.layout = JSON.parse(JSON.stringify(nextLayout)) + }) + layoutConfig = nextLayout + return true + } + + function currentV2LayoutSnapshot() { + return layoutStateController.v2Mode + && layoutStateController.v2Slots + ? JSON.parse(JSON.stringify(layoutStateController.v2Slots)) : null + } + + function restoreV2LayoutSnapshot(snapshotValue) { + return snapshotValue === null || snapshotValue === undefined + || !layoutStateController.v2Mode + ? true : layoutStateController.restoreV2Layout(snapshotValue) + } + function providerLayoutSnapshot(groupValues) { const groups = normalizedProviderGroups(groupValues) const states = groups.length > 0 ? widgetGroupVariantStates(groups) : ({}) if (groups.length > 0 && !states) return null return { layout: currentLayoutSnapshot(), - groupStates: states || ({}) + groupStates: states || ({}), + v2Layout: currentV2LayoutSnapshot() } } @@ -633,10 +800,17 @@ Item { for (const region of ["left", "center", "right"]) { if (!Array.isArray(layout[region])) return false } - return applyProviderLayoutTransaction( - JSON.parse(JSON.stringify(layout)), - v1PluginSpecsForLayout(layout), - JSON.parse(JSON.stringify(snapshotValue.groupStates))) + const previousV2Layout = currentV2LayoutSnapshot() + if (!restoreV2LayoutSnapshot(snapshotValue.v2Layout)) return false + if (!applyProviderLayoutTransaction( + JSON.parse(JSON.stringify(layout)), + activePluginSpecsForLayout(layout), + JSON.parse(JSON.stringify(snapshotValue.groupStates)))) { + if (!restoreV2LayoutSnapshot(previousV2Layout)) + console.warn("provider snapshot V2 rollback was incomplete") + return false + } + return true } function layoutWithoutProviderIds(layoutValue, providerIds) { @@ -762,7 +936,7 @@ Item { } if (removedProviderIds.length > 0) return applyProviderLayoutTransaction(nextLayout, - v1PluginSpecs(removedProviderIds), stateValues) + activePluginSpecsForLayout(nextLayout), stateValues) const groups = Object.keys(stateValues) return groups.length === 0 || setWidgetGroupVariantStates(stateValues) } @@ -777,8 +951,9 @@ Item { if (installed === true && !hasBarWidgetEntryPoint(id)) return false - const previousSpecs = v1PluginSpecs() + const previousSpecs = activePluginSpecs() const previousLayout = currentLayoutSnapshot() + const previousV2Layout = currentV2LayoutSnapshot() const replacedProviderIds = installed === true ? conflictingLayoutProviderIds(id) : [id] const displacedGroups = [] @@ -821,9 +996,10 @@ Item { const registryWasEnabled = pluginRegistry && typeof pluginRegistry.isEnabled === "function" ? pluginRegistry.isEnabled(id) : null - const desiredSpecs = v1PluginSpecs(replacedProviderIds, + const desiredSpecs = activePluginSpecs(replacedProviderIds, installed === true ? { id: id, region: targetRegion } : null) - if (!layoutStateController.reconcileV1PluginGroups(desiredSpecs)) + if (!reconcileActivePluginGroups( + desiredSpecs, false, layoutStateController.v2Mode)) return false if (installed === true && pluginRegistry @@ -860,12 +1036,16 @@ Item { if (!Util.isPlainObject(config.bar)) config.bar = {} config.bar.layout = JSON.parse(JSON.stringify(previousLayout)) }) - layoutStateController.reconcileV1PluginGroups(previousSpecs) + const restoredGroups = reconcileActivePluginGroups( + previousSpecs, false) + const restoredV2 = restoreV2LayoutSnapshot(previousV2Layout) if (registryWasEnabled === false && pluginRegistry && typeof pluginRegistry.setEnabled === "function") pluginRegistry.setEnabled(id, false) - if (previousFamilyStates) - setWidgetGroupVariantStates(previousFamilyStates) + const restoredFamilies = !previousFamilyStates + || setWidgetGroupVariantStates(previousFamilyStates) + if (!restoredGroups || !restoredV2 || !restoredFamilies) + console.warn("provider transaction rollback was incomplete") return false } return true @@ -874,13 +1054,14 @@ Item { function applyProviderLayoutTransaction(nextLayout, desiredSpecs, stateValues) { if (!shell || typeof shell.mutateShellConfig !== "function") return false - const previousSpecs = v1PluginSpecs() + const previousSpecs = activePluginSpecs() const previousLayout = currentLayoutSnapshot() + const previousV2Layout = currentV2LayoutSnapshot() const groups = Util.isPlainObject(stateValues) ? Object.keys(stateValues) : [] const previousStates = groups.length > 0 ? widgetGroupVariantStates(groups) : null - if (!layoutStateController.reconcileV1PluginGroups(desiredSpecs)) + if (!reconcileActivePluginGroups(desiredSpecs, false)) return false shell.mutateShellConfig(function(config) { if (!Util.isPlainObject(config.bar)) config.bar = {} @@ -892,8 +1073,13 @@ Item { if (!Util.isPlainObject(config.bar)) config.bar = {} config.bar.layout = JSON.parse(JSON.stringify(previousLayout)) }) - layoutStateController.reconcileV1PluginGroups(previousSpecs) - if (previousStates) setWidgetGroupVariantStates(previousStates) + const restoredGroups = reconcileActivePluginGroups( + previousSpecs, false) + const restoredV2 = restoreV2LayoutSnapshot(previousV2Layout) + const restoredStates = !previousStates + || setWidgetGroupVariantStates(previousStates) + if (!restoredGroups || !restoredV2 || !restoredStates) + console.warn("provider layout rollback was incomplete") return false } @@ -948,7 +1134,7 @@ Item { } } return applyProviderLayoutTransaction( - nextLayout, v1PluginSpecs(alternatives), effectiveStates) + nextLayout, activePluginSpecs(alternatives), effectiveStates) } function restoreWidgetFamilyProviders(groupValues) { @@ -974,7 +1160,7 @@ Item { states[group] = { v1: true, v2: true } } return applyProviderLayoutTransaction( - nextLayout, v1PluginSpecs([id]), states) + nextLayout, activePluginSpecs([id]), states) } function removeWidgetFamilyAlternatives(groupId) { @@ -1740,7 +1926,19 @@ Item { id: v1PluginReconcileTimer interval: 1 repeat: false - onTriggered: root.reconcileV1PluginGroups() + onTriggered: { + if (root.injectionComplete) + root.reconcileActivePluginGroupsAndProviders() + } + } + + Connections { + target: layoutStateController + ignoreUnknownSignals: true + + function onV2ModeChanged() { + v1PluginReconcileTimer.restart() + } } Connections { diff --git a/hancore.shibumi.bar/core/LayoutController.qml b/hancore.shibumi.bar/core/LayoutController.qml index a11895a..3032e10 100644 --- a/hancore.shibumi.bar/core/LayoutController.qml +++ b/hancore.shibumi.bar/core/LayoutController.qml @@ -11,9 +11,13 @@ Item { property var stateService: null readonly property var config: stateService && stateService.config ? stateService.config : ({}) - readonly property bool v2Mode: config && config.presentation - ? String(config.presentation.shellStyle || "shibumi") !== "shibumi" - : false + readonly property bool v2Mode: { + const source = stateService && stateService.config + ? stateService.config : config + return source && source.presentation + ? String(source.presentation.shellStyle || "shibumi") !== "shibumi" + : false + } readonly property var layoutProtection: config && config.layoutProtection ? config.layoutProtection : ({ v1: false, v2: false }) readonly property bool v1LayoutProtected: layoutProtection.v1 === true @@ -93,13 +97,25 @@ Item { LayoutModel.copySplits(nextSplits, nextOrder)) } + function persistV2Layout(nextSlots) { + if (!stateService || typeof stateService.setV2Layout !== "function") + return false + const previousSlots = V2LayoutModel.copy(v2Slots) + if (!previousSlots || !stateService.setV2Layout(nextSlots)) return false + if (bar && typeof bar.syncV2DynamicLayout === "function" + && !bar.syncV2DynamicLayout(nextSlots)) { + if (!stateService.setV2Layout(previousSlots)) + console.warn("V2 layout rollback failed after host sync rejection") + return false + } + return true + } + function swapGroups(sourceGroupId, targetGroupId) { if (v2Mode) { const nextSlots = V2LayoutModel.swapGroups( v2Slots, sourceGroupId, targetGroupId) - return nextSlots && stateService - && typeof stateService.setV2Layout === "function" - ? stateService.setV2Layout(nextSlots) : false + return nextSlots ? persistV2Layout(nextSlots) : false } const currentOrder = currentV1Order() const nextOrder = LayoutModel.swapGroups( @@ -109,11 +125,9 @@ Item { function moveGroupToSlot(sourceGroupId, targetRegion, targetIndex) { if (v2Mode) { - if (!stateService || typeof stateService.setV2Layout !== "function") - return false const nextSlots = V2LayoutModel.moveGroupToSlot( v2Slots, sourceGroupId, targetRegion, targetIndex) - return nextSlots ? stateService.setV2Layout(nextSlots) : false + return nextSlots ? persistV2Layout(nextSlots) : false } const currentOrder = currentV1Order() const nextOrder = LayoutModel.moveGroupToSlot( @@ -158,6 +172,29 @@ Item { return persist(next.order, next.splits) } + function reconcileV2PluginGroups(specs, syncValue, followRegionsValue) { + if (!v2Mode || !stateService + || typeof stateService.setV2Layout !== "function") return false + const current = V2LayoutModel.copy(v2Slots) + const next = V2LayoutModel.reconcilePluginGroups( + current, specs, followRegionsValue === true) + if (!next || next.unplaced.length > 0) return false + if (V2LayoutModel.same(current, next.layout)) { + return syncValue === true && bar + && typeof bar.syncV2DynamicLayout === "function" + ? bar.syncV2DynamicLayout(next.layout) : true + } + if (!stateService.setV2Layout(next.layout)) return false + if (syncValue === true && bar + && typeof bar.syncV2DynamicLayout === "function" + && !bar.syncV2DynamicLayout(next.layout)) { + if (!stateService.setV2Layout(current)) + console.warn("V2 reconciliation rollback failed after host sync rejection") + return false + } + return true + } + function baseV1SlotCount(region) { return LayoutModel.baseCount(region) } @@ -171,17 +208,15 @@ Item { } function addV2Slot(region) { - if (!v2Mode || !stateService - || typeof stateService.setV2Layout !== "function") return false + if (!v2Mode) return false const next = V2LayoutModel.addSlot(v2Slots, region) - return next ? stateService.setV2Layout(next) : false + return next ? persistV2Layout(next) : false } function removeV2Slot(region) { - if (!v2Mode || !stateService - || typeof stateService.setV2Layout !== "function") return false + if (!v2Mode) return false const next = V2LayoutModel.removeSlot(v2Slots, region) - return next ? stateService.setV2Layout(next) : false + return next ? persistV2Layout(next) : false } function baseV2SlotCount(region) { @@ -195,10 +230,9 @@ Item { } function removeV2SlotAt(region, index) { - if (!v2Mode || !stateService - || typeof stateService.setV2Layout !== "function") return false + if (!v2Mode) return false const next = V2LayoutModel.removeSlotAt(v2Slots, region, index) - return next ? stateService.setV2Layout(next) : false + return next ? persistV2Layout(next) : false } function toggleSplit(region, index, editingValue) { @@ -223,10 +257,25 @@ Item { return nextSplits ? persist(currentOrder, nextSplits) : false } + function resetV2Layout() { + if (!v2Mode || !stateService + || typeof stateService.resetV2Layout !== "function") return false + if (!stateService.resetV2Layout()) return false + return !bar || typeof bar.syncV2DynamicLayout !== "function" + ? true : bar.syncV2DynamicLayout(V2LayoutModel.defaultLayout()) + } + + function restoreV2Layout(value) { + if (!v2Mode || !stateService + || typeof stateService.setV2Layout !== "function") return false + const target = V2LayoutModel.copy(value) + if (!target) return false + return V2LayoutModel.same(v2Slots, target) + || stateService.setV2Layout(target) + } + function resetLayout() { - if (v2Mode && stateService - && typeof stateService.resetV2Layout === "function") - return stateService.resetV2Layout() + if (v2Mode) return resetV2Layout() return stateService && typeof stateService.resetLayout === "function" ? stateService.resetLayout() : false } diff --git a/hancore.shibumi.bar/core/V2LayoutModel.js b/hancore.shibumi.bar/core/V2LayoutModel.js index 8b5d6f3..2e0589b 100644 --- a/hancore.shibumi.bar/core/V2LayoutModel.js +++ b/hancore.shibumi.bar/core/V2LayoutModel.js @@ -6,6 +6,7 @@ var GroupIds = [ "G16", "G17", "G18" ] var Regions = ["left", "center", "right"] +var DynamicGroupPrefix = "G:" var Limits = { left: { min: 10, max: 13 }, center: { min: 1, max: 4 }, @@ -16,6 +17,28 @@ function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value) } +function validPluginId(value) { + var id = String(value || "") + return id.length > 0 && id.length <= 160 + && /^[a-z0-9][a-z0-9._-]*$/.test(id) +} + +function dynamicGroupId(pluginValue) { + var pluginId = String(pluginValue || "") + return validPluginId(pluginId) ? DynamicGroupPrefix + pluginId : "" +} + +function dynamicPluginId(groupValue) { + var groupId = String(groupValue || "") + if (groupId.indexOf(DynamicGroupPrefix) !== 0) return "" + var pluginId = groupId.slice(DynamicGroupPrefix.length) + return validPluginId(pluginId) ? pluginId : "" +} + +function isDynamicGroupId(value) { + return dynamicPluginId(value) !== "" +} + function defaultLayout() { return { left: ["G1", "G2", "G3", "", "G5", "G6", "G4", "G7", "", ""], @@ -29,7 +52,7 @@ function defaultLayout() { function valid(value) { if (!isObject(value)) return false - var seen = {} + var seen = Object.create(null) for (var r = 0; r < Regions.length; r++) { var region = Regions[r] var entries = value[region] @@ -39,11 +62,16 @@ function valid(value) { return false for (var i = 0; i < entries.length; i++) { var id = String(entries[i] || "") - if (id !== "" && (GroupIds.indexOf(id) < 0 || seen[id])) return false + if (id !== "" && ((GroupIds.indexOf(id) < 0 + && !isDynamicGroupId(id)) || seen[id])) return false if (id !== "") seen[id] = true } } - return Object.keys(seen).length === GroupIds.length + var fixedCount = 0 + for (var groupId in seen) { + if (GroupIds.indexOf(groupId) >= 0) fixedCount++ + } + return fixedCount === GroupIds.length } function copy(value) { @@ -67,7 +95,7 @@ function visibleOrder(value) { function locationFor(value, groupValue) { var source = valid(value) ? value : defaultLayout() var groupId = String(groupValue || "") - if (GroupIds.indexOf(groupId) < 0) return null + if (GroupIds.indexOf(groupId) < 0 && !isDynamicGroupId(groupId)) return null for (var r = 0; r < Regions.length; r++) { var region = Regions[r] var index = source[region].indexOf(groupId) @@ -138,6 +166,137 @@ function removeSlotAt(value, regionValue, indexValue) { return valid(result) ? result : null } +function normalizedPluginSpecs(value) { + if (!Array.isArray(value)) return null + var result = [] + var seen = Object.create(null) + for (var i = 0; i < value.length; i++) { + var spec = value[i] + if (!isObject(spec) || !validPluginId(spec.pluginId)) return null + var pluginId = String(spec.pluginId) + if (seen[pluginId]) continue + seen[pluginId] = true + var region = String(spec.region || "") + result.push({ + pluginId: pluginId, + region: Regions.indexOf(region) >= 0 ? region : "right" + }) + } + result.sort(function(left, right) { + return left.pluginId.localeCompare(right.pluginId) + }) + return result +} + +function compactEmptyTail(value, regionValue) { + var region = String(regionValue || "") + var limit = Limits[region] + if (!limit || !Array.isArray(value[region])) return false + var changed = false + while (value[region].length > limit.min + && String(value[region][value[region].length - 1] || "") === "") { + value[region].pop() + changed = true + } + return changed +} + +function addDynamicGroup(value, pluginValue, regionValue) { + var result = copy(valid(value) ? value : defaultLayout()) + var groupId = dynamicGroupId(pluginValue) + var region = String(regionValue || "") + if (!result || groupId === "" || Regions.indexOf(region) < 0) return null + if (locationFor(result, groupId)) return result + for (var index = 0; index < result[region].length; index++) { + if (String(result[region][index] || "") !== "") continue + result[region][index] = groupId + return valid(result) ? result : null + } + if (result[region].length >= Limits[region].max) return null + result[region].push(groupId) + return valid(result) ? result : null +} + +function moveDynamicGroupToRegion(value, groupValue, regionValue) { + var result = copy(valid(value) ? value : defaultLayout()) + var groupId = String(groupValue || "") + var region = String(regionValue || "") + var source = locationFor(result, groupId) + if (!result || !source || !isDynamicGroupId(groupId) + || Regions.indexOf(region) < 0) return null + if (source.region === region) return result + var targetIndex = -1 + for (var index = 0; index < result[region].length; index++) { + if (String(result[region][index] || "") === "") { + targetIndex = index + break + } + } + if (targetIndex < 0 && result[region].length < Limits[region].max) { + result[region].push("") + targetIndex = result[region].length - 1 + } + if (targetIndex < 0) targetIndex = 0 + return moveGroupToSlot(result, groupId, region, targetIndex) +} + +function removeDynamicGroup(value, groupValue) { + var result = copy(valid(value) ? value : defaultLayout()) + var groupId = String(groupValue || "") + var location = locationFor(result, groupId) + if (!result || !isDynamicGroupId(groupId) || !location) return null + result[location.region][location.index] = "" + compactEmptyTail(result, location.region) + return valid(result) ? result : null +} + +function reconcilePluginGroups(value, specsValue, followRegionsValue) { + var result = copy(valid(value) ? value : defaultLayout()) + var specs = normalizedPluginSpecs(specsValue) + var followRegions = followRegionsValue === true + if (!result || !specs) return null + var desired = Object.create(null) + for (var i = 0; i < specs.length; i++) + desired[dynamicGroupId(specs[i].pluginId)] = specs[i] + + var stale = [] + for (var r = 0; r < Regions.length; r++) { + var region = Regions[r] + for (var index = 0; index < result[region].length; index++) { + var groupId = String(result[region][index] || "") + if (isDynamicGroupId(groupId) && !desired[groupId] + && stale.indexOf(groupId) < 0) + stale.push(groupId) + } + } + for (var staleIndex = 0; staleIndex < stale.length; staleIndex++) { + result = removeDynamicGroup(result, stale[staleIndex]) + if (!result) return null + } + + var unplaced = [] + for (var specIndex = 0; specIndex < specs.length; specIndex++) { + var spec = specs[specIndex] + var requestedGroupId = dynamicGroupId(spec.pluginId) + var currentLocation = locationFor(result, requestedGroupId) + if (currentLocation && followRegions + && currentLocation.region !== spec.region) { + var moved = moveDynamicGroupToRegion( + result, requestedGroupId, spec.region) + if (!moved) unplaced.push(spec.pluginId) + else result = moved + continue + } + if (currentLocation) continue + var placed = addDynamicGroup(result, spec.pluginId, spec.region) + if (!placed) unplaced.push(spec.pluginId) + else result = placed + } + // A full region must not silently publish a partially placed V2 layout. + if (unplaced.length > 0) return { layout: result, unplaced: unplaced } + return { layout: result, unplaced: [] } +} + function same(left, right) { if (!valid(left) || !valid(right)) return false return JSON.stringify(left) === JSON.stringify(right) diff --git a/hancore.shibumi.state/ShibumiConfig.js b/hancore.shibumi.state/ShibumiConfig.js index 50bff1c..0163313 100644 --- a/hancore.shibumi.state/ShibumiConfig.js +++ b/hancore.shibumi.state/ShibumiConfig.js @@ -162,9 +162,13 @@ function isV1DynamicGroupId(value) { && /^[a-z0-9][a-z0-9._-]*$/.test(pluginId) } +function isDynamicGroupId(value) { + return isV1DynamicGroupId(value) +} + function isGroupId(value) { var groupId = String(value || "") - return GroupIds.indexOf(groupId) >= 0 || isV1DynamicGroupId(groupId) + return GroupIds.indexOf(groupId) >= 0 || isDynamicGroupId(groupId) } function boolArray(value, length) { @@ -241,12 +245,17 @@ function normalizedV2Layout(value) { return null for (var i = 0; i < entries.length; i++) { var id = String(entries[i] || "") - if (id !== "" && (GroupIds.indexOf(id) < 0 || seen[id])) return null + if (id !== "" && ((GroupIds.indexOf(id) < 0 + && !isDynamicGroupId(id)) || seen[id])) return null if (id !== "") seen[id] = true result[region].push(id) } } - return Object.keys(seen).length === GroupIds.length ? result : null + var fixedCount = 0 + for (var groupId in seen) { + if (GroupIds.indexOf(groupId) >= 0) fixedCount++ + } + return fixedCount === GroupIds.length ? result : null } function normalizedV2Boundaries(value) { diff --git a/shared/state/ShibumiConfig.js b/shared/state/ShibumiConfig.js index 50bff1c..0163313 100644 --- a/shared/state/ShibumiConfig.js +++ b/shared/state/ShibumiConfig.js @@ -162,9 +162,13 @@ function isV1DynamicGroupId(value) { && /^[a-z0-9][a-z0-9._-]*$/.test(pluginId) } +function isDynamicGroupId(value) { + return isV1DynamicGroupId(value) +} + function isGroupId(value) { var groupId = String(value || "") - return GroupIds.indexOf(groupId) >= 0 || isV1DynamicGroupId(groupId) + return GroupIds.indexOf(groupId) >= 0 || isDynamicGroupId(groupId) } function boolArray(value, length) { @@ -241,12 +245,17 @@ function normalizedV2Layout(value) { return null for (var i = 0; i < entries.length; i++) { var id = String(entries[i] || "") - if (id !== "" && (GroupIds.indexOf(id) < 0 || seen[id])) return null + if (id !== "" && ((GroupIds.indexOf(id) < 0 + && !isDynamicGroupId(id)) || seen[id])) return null if (id !== "") seen[id] = true result[region].push(id) } } - return Object.keys(seen).length === GroupIds.length ? result : null + var fixedCount = 0 + for (var groupId in seen) { + if (GroupIds.indexOf(groupId) >= 0) fixedCount++ + } + return fixedCount === GroupIds.length ? result : null } function normalizedV2Boundaries(value) { diff --git a/tests/bar-host-registry-smoke.qml b/tests/bar-host-registry-smoke.qml index 52fbf01..e4cf361 100644 --- a/tests/bar-host-registry-smoke.qml +++ b/tests/bar-host-registry-smoke.qml @@ -129,6 +129,15 @@ ShellRoot { return true } + function setV2Layout(value) { + if (!value || !value.left || !value.center || !value.right) + return false + const next = JSON.parse(JSON.stringify(config)) + next.v2Layout = JSON.parse(JSON.stringify(value)) + publishConfig(next) + return true + } + function publishConfig(next) { config = next const shellConfig = JSON.parse(JSON.stringify(fakeShell.shellConfig)) @@ -396,6 +405,15 @@ ShellRoot { displayName: "Inline state fixture", allowMultiple: false } + }, + "example.plain": { + id: "example.plain", + kinds: ["bar-widget"], + entryPoints: { barWidget: "Plain.qml" }, + barWidget: { + displayName: "Plain fixture", + allowMultiple: false + } } }) @@ -936,6 +954,198 @@ ShellRoot { return root.fail("Shibumi Center did not remove its alternatives") } + hostBar.layoutConfig = { left: [], center: [], right: [] } + fakeShell.shellConfig.bar.layout = { left: [], center: [], right: [] } + if (!hostBar.setBarWidgetInstalled( + "example.inline-state", true, "left")) + return root.fail("V2 generic third-party activation") + hostBar.layoutConfig = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + if (!hostBar.setBarWidgetInstalled( + "example.plain", true, "left")) + return root.fail("V2 generic third-party activation") + hostBar.layoutConfig = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + const unmarkedDynamicLayout = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + const unmarkedInlineEntry = unmarkedDynamicLayout.left.find( + function(entry) { + return String(entry.id || "") === "example.inline-state" + }) + if (!unmarkedInlineEntry) + return root.fail("V2 generic unmarked settings fixture") + delete unmarkedInlineEntry.shibumiModule + hostBar.layoutConfig = unmarkedDynamicLayout + fakeShell.shellConfig.bar.layout = JSON.parse(JSON.stringify( + unmarkedDynamicLayout)) + if (!hostBar.reconcileActivePluginGroupsAndProviders() + || !hostBar.layoutController.groupLocation( + "G:example.inline-state")) + return root.fail("V2 generic unmarked provider discovery") + + const driftedDynamicLayout = { + left: [fakeShell.shellConfig.bar.layout.left[1]], + center: [], + right: [fakeShell.shellConfig.bar.layout.left[0]] + } + hostBar.layoutConfig = JSON.parse(JSON.stringify( + driftedDynamicLayout)) + fakeShell.shellConfig.bar.layout = JSON.parse(JSON.stringify( + driftedDynamicLayout)) + if (!hostBar.reconcileActivePluginGroupsAndProviders() + || hostBar.layoutController.groupLocation( + "G:example.inline-state").region !== "right") + return root.fail("V2 generic background region reconciliation") + hostBar.layoutConfig = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + if (!hostBar.setBarWidgetInstalled( + "example.inline-state", true, "right")) + return root.fail("V2 generic third-party requested-region move") + hostBar.layoutConfig = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + const requestedRegionGroup = hostBar.layoutController.groupLocation( + "G:example.inline-state") + if (!requestedRegionGroup || requestedRegionGroup.region !== "right") + return root.fail("V2 generic third-party requested-region state") + if (!hostBar.setBarWidgetInstalled( + "example.inline-state", true, "left")) + return root.fail("V2 generic third-party region restoration") + hostBar.layoutConfig = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + const dynamicV2Group = hostBar.layoutController.groupLocation( + "G:example.inline-state") + const secondDynamicV2Group = hostBar.layoutController.groupLocation( + "G:example.plain") + if (!dynamicV2Group || dynamicV2Group.region !== "left" + || !secondDynamicV2Group || secondDynamicV2Group.region !== "left" + || hostBar.unassignedLayoutEntries("left").length !== 0) + return root.fail("V2 generic third-party group placement") + const configuredDynamicLayout = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + const duplicateDynamicLayout = { + left: [ + { id: "example.plain", shibumiModule: true }, + { id: "example.plain", shibumiModule: true } + ], + center: [], + right: [] + } + hostBar.layoutConfig = duplicateDynamicLayout + fakeShell.shellConfig.bar.layout = JSON.parse( + JSON.stringify(duplicateDynamicLayout)) + const duplicateBefore = JSON.stringify(fakeShell.shellConfig.bar.layout) + if (hostBar.syncV2DynamicLayout({ + left: ["G:example.plain"], center: [], right: [] + }) + || JSON.stringify(fakeShell.shellConfig.bar.layout) + !== duplicateBefore + || JSON.stringify(fakeShell.shellConfig.bar.layout).indexOf("null") + >= 0) { + return root.fail("duplicate V2 dynamic entries were not rejected") + } + hostBar.layoutConfig = configuredDynamicLayout + fakeShell.shellConfig.bar.layout = JSON.parse( + JSON.stringify(configuredDynamicLayout)) + const crossRegionDuplicateLayout = { + left: [{ id: "example.plain", shibumiModule: true }], + center: [], + right: [{ id: "example.plain", shibumiModule: true }] + } + hostBar.layoutConfig = crossRegionDuplicateLayout + fakeShell.shellConfig.bar.layout = JSON.parse( + JSON.stringify(crossRegionDuplicateLayout)) + const crossRegionDuplicateBefore = JSON.stringify( + fakeShell.shellConfig.bar.layout) + if (hostBar.syncV2DynamicLayout({ + left: ["G:example.plain"], center: [], right: [] + }) + || JSON.stringify(fakeShell.shellConfig.bar.layout) + !== crossRegionDuplicateBefore + || JSON.stringify(fakeShell.shellConfig.bar.layout).indexOf("null") + >= 0) { + return root.fail("cross-region V2 duplicate was not rejected") + } + hostBar.layoutConfig = configuredDynamicLayout + fakeShell.shellConfig.bar.layout = JSON.parse( + JSON.stringify(configuredDynamicLayout)) + const inlineEntry = configuredDynamicLayout.left.find(function(entry) { + return String(entry.id || "") === "example.inline-state" + }) + if (!inlineEntry) return root.fail("V2 generic settings fixture") + inlineEntry.bestScore = 41 + inlineEntry.collision = "persisted" + hostBar.layoutConfig = configuredDynamicLayout + fakeShell.shellConfig.bar.layout = JSON.parse(JSON.stringify( + configuredDynamicLayout)) + if (!hostBar.layoutController.moveGroupToSlot( + "G:example.plain", "left", dynamicV2Group.index)) + return root.fail("V2 generic third-party same-region reorder") + const reorderedEntries = fakeShell.shellConfig.bar.layout.left || [] + if (reorderedEntries.length !== 2 + || String(reorderedEntries[0].id || "") + !== "example.plain" + || String(reorderedEntries[1].id || "") + !== "example.inline-state" + || reorderedEntries[1].bestScore !== 41 + || reorderedEntries[1].collision !== "persisted") + return root.fail("V2 generic third-party same-region persistence") + if (!hostBar.layoutController.moveGroupToSlot( + "G:example.inline-state", "center", 0)) + return root.fail("V2 generic third-party cross-region move") + const movedDynamicV2Group = hostBar.layoutController.groupLocation( + "G:example.inline-state") + const movedDynamicEntries = fakeShell.shellConfig.bar.layout.center || [] + if (!movedDynamicV2Group || movedDynamicV2Group.region !== "center" + || movedDynamicEntries.length !== 1 + || String(movedDynamicEntries[0].id || "") + !== "example.inline-state" + || hostBar.unassignedLayoutEntries("left").length !== 0 + || hostBar.unassignedLayoutEntries("center").length !== 0) + return root.fail("V2 generic third-party persisted move") + hostBar.layoutConfig = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + if (!hostBar.setBarWidgetInstalled( + "example.inline-state", false, "center") + || hostBar.layoutController.groupLocation( + "G:example.inline-state") !== null + || (fakeShell.shellConfig.bar.layout.center || []).length !== 0) + return root.fail("V2 generic third-party removal") + hostBar.layoutConfig = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + if (!hostBar.setBarWidgetInstalled( + "example.plain", false, "left") + || hostBar.layoutController.groupLocation( + "G:example.plain") !== null) + return root.fail("V2 generic third-party removal") + + const v2Styles = ["full", "fit", "dock", "notch"] + for (let styleIndex = 0; styleIndex < v2Styles.length; styleIndex++) { + const styleState = JSON.parse(JSON.stringify(stateService.config)) + styleState.presentation.shellStyle = v2Styles[styleIndex] + stateService.config = styleState + hostBar.layoutConfig = { left: [], center: [], right: [] } + fakeShell.shellConfig.bar.layout = { + left: [], center: [], right: [] + } + if (!hostBar.layoutController.v2Mode + || !hostBar.setBarWidgetInstalled( + "example.plain", true, "left")) + return root.fail("V2 generic lifecycle style " + + v2Styles[styleIndex]) + hostBar.layoutConfig = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + if (!hostBar.layoutController.groupLocation( + "G:example.plain")) + return root.fail("V2 generic placement style " + + v2Styles[styleIndex]) + if (!hostBar.setBarWidgetInstalled( + "example.plain", false, "left")) + return root.fail("V2 generic removal style " + + v2Styles[styleIndex]) + hostBar.layoutConfig = JSON.parse(JSON.stringify( + fakeShell.shellConfig.bar.layout)) + } + const complementaryLayout = { left: [], center: [ diff --git a/tests/layout-controller-regression.qml b/tests/layout-controller-regression.qml index 8399312..3262059 100644 --- a/tests/layout-controller-regression.qml +++ b/tests/layout-controller-regression.qml @@ -40,6 +40,26 @@ Item { return setLayout(ShibumiConfig.defaultOrder(), ShibumiConfig.defaultSplits()) } + function setV2Layout(value) { + const normalized = ShibumiConfig.normalizedV2Layout(value) + if (!normalized + || root.same(config.v2Layout, normalized)) return false + const next = ShibumiConfig.normalize(config) + next.v2Layout = normalized + config = ShibumiConfig.normalize(next) + root.writes++ + return true + } + + function resetV2Layout() { + const next = ShibumiConfig.normalize(config) + next.v2Layout = ShibumiConfig.defaultV2Layout() + next.v2Boundaries = ShibumiConfig.defaultV2Boundaries() + config = ShibumiConfig.normalize(next) + root.writes++ + return true + } + function toggleV2Boundary(indexValue) { const index = Number(indexValue) if (!Number.isInteger(index) || index < 0 || index > 1) return false @@ -247,6 +267,21 @@ Item { || controller.v2Boundaries[0] !== true) fail("protected V2 interaction state did not activate independently") + if (!controller.reconcileV2PluginGroups([ + { pluginId: "custom.v2", region: "left" } + ]) + || controller.groupLocation("G:custom.v2") === null + || !controller.moveGroupToSlot("G:custom.v2", "right", 0) + || controller.groupLocation("G:custom.v2").region !== "right" + || !controller.reconcileV2PluginGroups([]) + || controller.groupLocation("G:custom.v2") !== null + || !controller.reconcileV2PluginGroups([ + { pluginId: "custom.reset", region: "right" } + ]) + || !controller.resetLayout() + || controller.groupLocation("G:custom.reset") !== null) + fail("V2 third-party group lifecycle transaction") + console.log("layout controller regression passed") Qt.exit(0) } diff --git a/tests/shibumi-config-regression.qml b/tests/shibumi-config-regression.qml index 897ec4f..73f67d6 100644 --- a/tests/shibumi-config-regression.qml +++ b/tests/shibumi-config-regression.qml @@ -33,6 +33,29 @@ TestCase { if (!movedToEmpty || movedToEmpty.left[3] !== "G4" || movedToEmpty.left[6] !== "") fail("V2 empty slots are not valid drag targets") + const dynamicV2 = V2Layout.reconcilePluginGroups( + defaults.v2Layout, [{ pluginId: "custom.widget", region: "center" }]) + if (!dynamicV2 || dynamicV2.unplaced.length !== 0 + || dynamicV2.layout.center[1] !== "G:custom.widget" + || V2Layout.locationFor(dynamicV2.layout, "G:custom.widget") === null + || !V2Layout.valid(dynamicV2.layout)) + fail("V2 third-party group creation") + const movedDynamicV2 = V2Layout.moveGroupToSlot( + dynamicV2.layout, "G:custom.widget", "left", 0) + const removedDynamicV2 = movedDynamicV2 + ? V2Layout.removeDynamicGroup(movedDynamicV2, "G:custom.widget") : null + if (!movedDynamicV2 || movedDynamicV2.left[0] !== "G:custom.widget" + || !removedDynamicV2 + || V2Layout.locationFor(removedDynamicV2, "G:custom.widget") !== null + || !V2Layout.valid(removedDynamicV2)) + fail("V2 third-party group move/remove") + const normalizedDynamicV2 = Config.normalize({ + version: 1, + v2Layout: dynamicV2.layout + }) + if (normalizedDynamicV2.v2Layout.center[1] !== "G:custom.widget" + || !Config.isGroupId("G:custom.widget")) + fail("V2 third-party group config persistence") const extended = V2Layout.addSlot(defaults.v2Layout, "center") const trimmed = extended ? V2Layout.removeSlotAt(extended, "center", 1) : null