Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 12 additions & 33 deletions shell/plugins/bar/Bar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import QtQml.Models
import QtQuick.Layouts
import qs.Commons
import qs.Ui
Expand Down Expand Up @@ -63,7 +64,6 @@ Item {
readonly property bool barHovered: barHoverCount > 0
property bool centerSectionRevealHeld: false
property bool centerHoverRevealSuppressed: false
property int barConfigSerial: 0
property string position: "top"
// Resolves through fontconfig at paint time (Style.font.family defaults
// to "monospace"), so changing the system font (via `omarchy-font-set`)
Expand Down Expand Up @@ -585,38 +585,13 @@ Item {
setRequestedTransparency(config.transparent === true)
centerAnchor = Util.canonicalWidgetId(config.centerAnchor || "")

// layoutEntries feeds plain JS arrays to the module Repeaters, and QML
// cannot diff those: reassigning layoutConfig rebuilds every widget on
// every monitor. When a shell.json write only changed inline widget
// settings, patch the live layout and running widgets in place instead.
var next = normalizeLayout(config.layout)
var delta = BarModel.inlineSettingsDelta(layoutConfig, next)
if (delta) {
applySettingsDelta(delta)
return
}
layoutConfig = next
barConfigSerial++
}

function applySettingsDelta(delta) {
for (var i = 0; i < delta.length; i++) {
var change = delta[i]
layoutConfig[change.region][change.index] = change.entry
var settings = entrySettings(change.entry)
for (var s = 0; s < moduleSlots.length; s++) {
var slot = moduleSlots[s]
if (!slot || slot.region !== change.region || slot.moduleName !== entryId(change.entry)) continue
var item = slot.activeItem
if (item && "settings" in item) item.settings = settings
}
}
if (JSON.stringify(layoutConfig) !== JSON.stringify(next)) layoutConfig = next
}

onBarConfigChanged: applyBarConfig()

function layoutEntries(region) {
var serial = barConfigSerial
var entries = layoutConfig ? layoutConfig[region] : null
return Array.isArray(entries) ? entries : []
}
Expand Down Expand Up @@ -1722,6 +1697,10 @@ Item {
property var entries: []
property string region: ""

ListModel { id: entryModel }
onEntriesChanged: BarModel.syncEntries(entryModel, entries)
Component.onCompleted: BarModel.syncEntries(entryModel, entries)

visible: entries.length > 0
// A hidden list must not build its modules. The center section declares
// both an anchored and an unanchored arrangement and shows whichever
Expand All @@ -1740,11 +1719,11 @@ Item {
spacing: 0

Repeater {
model: moduleListRoot.entries
model: entryModel

ModuleSlot {
required property var modelData
entry: modelData
required property string entryJson
entry: JSON.parse(entryJson)
region: moduleListRoot.region
}
}
Expand All @@ -1758,11 +1737,11 @@ Item {
spacing: 0

Repeater {
model: moduleListRoot.entries
model: entryModel

ModuleSlot {
required property var modelData
entry: modelData
required property string entryJson
entry: JSON.parse(entryJson)
region: moduleListRoot.region
}
}
Expand Down
93 changes: 56 additions & 37 deletions shell/plugins/bar/BarModel.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,58 @@
// Keep delegates alive across layout edits. Reserve exact matches first so
// changing one of several instances does not steal an unchanged instance.
// JSON stays a string role: ListModel otherwise turns nested settings arrays
// into nested models, changing the widget settings contract.
function syncEntries(model, entries) {
var rows = []
var used = []
var nextKey = 0
for (var i = 0; i < model.count; i++) {
var row = model.get(i)
rows.push({ key: row.instanceKey, json: row.entryJson, id: entryId(JSON.parse(row.entryJson)) })
nextKey = Math.max(nextKey, row.instanceKey + 1)
}
var wanted = entries.map(function(entry) {
return { id: entryId(entry), json: JSON.stringify(entry), match: -1 }
})
for (var exact = 0; exact < wanted.length; exact++) {
for (var old = 0; old < rows.length; old++) {
if (!used[old] && rows[old].json === wanted[exact].json) {
wanted[exact].match = old
used[old] = true
break
}
}
}
for (var n = 0; n < wanted.length; n++) {
var item = wanted[n]
if (item.match < 0) {
for (var candidate = 0; candidate < rows.length; candidate++) {
if (!used[candidate] && rows[candidate].id === item.id) {
item.match = candidate
used[candidate] = true
break
}
}
}
item.key = item.match < 0 ? nextKey++ : rows[item.match].key
}
for (var remove = rows.length - 1; remove >= 0; remove--) {
if (!used[remove]) model.remove(remove)
}
for (var target = 0; target < wanted.length; target++) {
var entry = wanted[target]
var source = target
while (source < model.count && model.get(source).instanceKey !== entry.key) source++
if (source === model.count) {
model.insert(target, { instanceKey: entry.key, entryJson: entry.json })
} else {
if (source !== target) model.move(source, target, 1)
if (model.get(target).entryJson !== entry.json)
model.setProperty(target, "entryJson", entry.json)
}
}
}

function isPlainObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value)
}
Expand Down Expand Up @@ -65,42 +120,6 @@ function entriesAfter(entries, name) {
return index === -1 ? [] : entries.slice(index + 1)
}

// A shell.json write that only changes inline widget settings (the battery
// percentage toggle, a clock format change) must not rebuild the bar.
// Compare two normalized layouts: when the structure is unchanged — same
// entry ids in the same order per region — return the settings-only changes
// as {region, index, entry}. Return null when the change is structural, or
// touches an entry a live settings push cannot safely reach: custom modules
// read their entry directly rather than an injected settings property, and
// a duplicated id makes the push ambiguous.
function inlineSettingsDelta(current, next) {
if (!isPlainObject(current) || !isPlainObject(next)) return null
var regions = ["left", "center", "right"]
var counts = {}
for (var r = 0; r < regions.length; r++) {
var entries = Array.isArray(next[regions[r]]) ? next[regions[r]] : []
for (var i = 0; i < entries.length; i++) {
var id = entryId(entries[i])
counts[id] = (counts[id] || 0) + 1
}
}
var changes = []
for (var s = 0; s < regions.length; s++) {
var region = regions[s]
var a = Array.isArray(current[region]) ? current[region] : []
var b = Array.isArray(next[region]) ? next[region] : []
if (a.length !== b.length) return null
for (var j = 0; j < a.length; j++) {
if (entryId(a[j]) !== entryId(b[j])) return null
if (JSON.stringify(a[j]) === JSON.stringify(b[j])) continue
if (customModuleType(a[j]) || customModuleType(b[j])) return null
if (counts[entryId(b[j])] > 1) return null
changes.push({ region: region, index: j, entry: b[j] })
}
}
return changes
}

function expandPath(value, home) {
var path = String(value || "")
if (path === "") return ""
Expand Down Expand Up @@ -210,6 +229,7 @@ function nearestDropTarget(candidates, point, vertical) {

if (typeof module !== "undefined") {
module.exports = {
syncEntries: syncEntries,
isDrawnSlot: isDrawnSlot,
pickDrawnSlot: pickDrawnSlot,
pickPanelSlot: pickPanelSlot,
Expand All @@ -222,7 +242,6 @@ if (typeof module !== "undefined") {
entryIndex: entryIndex,
entriesBefore: entriesBefore,
entriesAfter: entriesAfter,
inlineSettingsDelta: inlineSettingsDelta,
expandPath: expandPath,
customModuleSafeName: customModuleSafeName,
customModuleType: customModuleType,
Expand Down
17 changes: 17 additions & 0 deletions test/shell.d/bar-layout-model-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
if ! command -v quickshell >/dev/null 2>&1; then
pass "quickshell not installed; skipping layout model runtime test"
exit 0
fi
fixture=$(mktemp -d)
trap 'rm -rf -- "$fixture"' EXIT
cp -- "$SHELL_TEST_DIR/fixtures/bar-layout-model/shell.qml" "$fixture/shell.qml"
ln -s -- "$ROOT/shell/plugins/bar/BarModel.js" "$fixture/BarModel.js"
QT_QPA_PLATFORM=offscreen timeout 10 quickshell -p "$fixture" --no-color >"$fixture/log" 2>&1 || true
if ! rg -q BAR_LAYOUT_MODEL_OK "$fixture/log" || rg -q 'BAR_LAYOUT_MODEL_FAIL|TypeError|ReferenceError' "$fixture/log"; then
cat "$fixture/log" >&2
fail "layout changes preserve QML delegates and nested settings"
fi
pass "layout changes preserve QML delegates and nested settings"
44 changes: 0 additions & 44 deletions test/shell.d/bar-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -294,50 +294,6 @@ const entries = [{ id: 'a' }, { id: 'omarchy.tray' }, { id: 'b' }]
assertDeepEqual(bar.pinTrayToInner(entries, 'left').map(bar.entryId), ['a', 'b', 'omarchy.tray'], 'bar pins tray to left inner edge')
assertDeepEqual(bar.pinTrayToInner(entries, 'right').map(bar.entryId), ['omarchy.tray', 'a', 'b'], 'bar pins tray to right inner edge')

// A settings-only shell.json write must patch the live bar, not rebuild it:
// the module Repeaters recreate every widget when their array model changes.
const settingsLayout = { left: [{ id: 'omarchy.power' }], center: [{ id: 'omarchy.clock', format: 'HH:mm' }], right: [] }
assertDeepEqual(
bar.inlineSettingsDelta(settingsLayout, { left: [{ id: 'omarchy.power', showPercentage: true }], center: [{ id: 'omarchy.clock', format: 'HH:mm' }], right: [] }),
[{ region: 'left', index: 0, entry: { id: 'omarchy.power', showPercentage: true } }],
'bar reports a settings-only change as an inline delta'
)
assertDeepEqual(
bar.inlineSettingsDelta(settingsLayout, JSON.parse(JSON.stringify(settingsLayout))),
[],
'bar reports an unchanged layout as an empty delta'
)
assertEqual(
bar.inlineSettingsDelta(settingsLayout, { left: [{ id: 'omarchy.clock', format: 'HH:mm' }], center: [{ id: 'omarchy.power' }], right: [] }),
null,
'bar treats reordered entries as structural'
)
assertEqual(
bar.inlineSettingsDelta(settingsLayout, { left: [{ id: 'omarchy.power' }, { id: 'omarchy.battery' }], center: settingsLayout.center, right: [] }),
null,
'bar treats added entries as structural'
)
assertEqual(
bar.inlineSettingsDelta(
{ left: [{ id: 'local.status', exec: 'date' }], center: [], right: [] },
{ left: [{ id: 'local.status', exec: 'uptime' }], center: [], right: [] }
),
null,
'bar rebuilds for custom modules, which read their entry directly'
)
assertEqual(
bar.inlineSettingsDelta(
{ left: [{ id: 'x' }], center: [], right: [{ id: 'x' }] },
{ left: [{ id: 'x', a: 1 }], center: [], right: [{ id: 'x' }] }
),
null,
'bar rebuilds when a changed id appears more than once in the layout'
)
assert(
/BarModel\.inlineSettingsDelta\(layoutConfig, next\)/.test(barSource),
'bar consults the inline settings delta before rebuilding the layout'
)

assertEqual(bar.moduleString({ id: 'custom', label: 42 }, 'label', 'fallback'), '42', 'bar stringifies module settings')
assertEqual(bar.entryIndex(entries, 'b'), 2, 'bar finds entry indexes')
assertDeepEqual(bar.entriesBefore(entries, 'b').map(bar.entryId), ['a', 'omarchy.tray'], 'bar returns entries before target')
Expand Down
67 changes: 67 additions & 0 deletions test/shell.d/fixtures/bar-layout-model/shell.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import QtQuick
import QtQml.Models
import Quickshell
import "BarModel.js" as BarModel

ShellRoot {
id: root
property int created: 0
ListModel { id: entries }
Item {
Repeater {
id: widgets
model: entries
Item {
required property string entryJson
property var settings: JSON.parse(entryJson)
Component.onCompleted: root.created++
}
}
}
function check(value, message) {
if (!value) throw new Error(message)
}
Timer {
interval: 1
running: true
onTriggered: {
try {
var clock = {id: "clock", format: "HH:mm"}
var sound = {id: "groups", groupId: "sound", items: ["audio"]}
var devices = {id: "groups", groupId: "devices", items: ["bluetooth"]}
BarModel.syncEntries(entries, [clock, sound, devices, "divider", "divider"])
var clockItem = widgets.itemAt(0)
var soundItem = widgets.itemAt(1)
var devicesItem = widgets.itemAt(2)
var dividerA = widgets.itemAt(3)
var dividerB = widgets.itemAt(4)
var initialCreated = root.created
BarModel.syncEntries(entries, [clock, sound, devices, "divider", "divider"])
check(root.created === initialCreated, "identical config recreated delegates")
var movedSound = {id: "groups", groupId: "sound", items: ["audio", {id: "network", nested: {values: [1, 2]}}]}
BarModel.syncEntries(entries, [devices, clock, movedSound, "divider", "divider", "network"])
check(widgets.itemAt(0) === devicesItem && widgets.itemAt(1) === clockItem
&& widgets.itemAt(2) === soundItem, "reorder/settings change lost widget identity")
check(widgets.itemAt(3) === dividerA && widgets.itemAt(4) === dividerB,
"duplicate entries lost their occurrence identity")
check(root.created === initialCreated + 1, "insertion recreated existing delegates")
check(Array.isArray(soundItem.settings.items)
&& soundItem.settings.items[1].nested.values[1] === 2, "nested settings changed shape")
BarModel.syncEntries(entries, [devices, clock, movedSound])
check(widgets.count === 3 && widgets.itemAt(2) === soundItem
&& widgets.itemAt(1) === clockItem, "removal recreated surviving delegates")
BarModel.syncEntries(entries, [devices, {id: "clock", format: "ss"}, movedSound])
check(widgets.itemAt(1) === clockItem && clockItem.settings.format === "ss",
"settings did not update the existing widget")
BarModel.syncEntries(entries, [])
check(widgets.count === 0, "empty layout retained delegates")
BarModel.syncEntries(entries, ["clock"])
check(widgets.count === 1 && widgets.itemAt(0).settings === "clock", "empty layout cannot be populated again")
console.log("BAR_LAYOUT_MODEL_OK")
} catch (error) {
console.error("BAR_LAYOUT_MODEL_FAIL: " + error)
}
Qt.quit()
}
}
}