From fcea3caeef5e3ecc257f82f5a976438410105957 Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:29:27 +0200 Subject: [PATCH 01/11] Fix sitemap scroll hitching under frequent long-poll updates (#1136) (#1138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SwiftUI sitemap introduced in 3.2.x hitches during inertial scrolling when items update frequently. Every long-poll result previously called reconcileWidgets + copyWidgetProperties unconditionally, then always reassigned @Published rowInputs — causing a broad objectWillChange broadcast on every cycle even when nothing the list renders had changed. Fix: snapshot row inputs from fresh page data before any widget mutation. updateUI now returns early (skipping all reconciliation and @Published writes) when the computed preview inputs equal the current rowInputs and the page title is unchanged. When searchText is active, reconciliation still runs to keep currentPage fresh so clearing the filter won't expose stale hidden widgets. Widen widget-version granularity from bare widgetId to full RowID so repeated widget IDs on one page track independently. widgetUpdateVersions is now keyed by RowID.rawValue; bumpWidgetVersions increments by row- identity pairs; SliderRowView, SegmentedRowView, SelectionRowView, and ImageRowView pass input.rowID to the lookup; affected row-input types carry their rowID. Adds SitemapPageViewModelDiffingTests covering: stable rebuild equality, objectWillChange suppression on unchanged data, structure-change rebuild, bumpWidgetVersions no-op on identical inputs, per-row version isolation, structure-change full bump, and repeated-widgetId independence. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/Models/SitemapPageViewModel.swift | 89 ++++++--- openHAB/UI/SwiftUI/Rows/ImageRowView.swift | 2 +- .../UI/SwiftUI/Rows/SegmentedRowView.swift | 24 ++- .../UI/SwiftUI/Rows/SelectionRowView.swift | 2 +- openHAB/UI/SwiftUI/Rows/SliderRowView.swift | 9 +- .../UI/SwiftUI/SitemapRowInputMapper.swift | 8 +- openHAB/UI/SwiftUI/WidgetRowInputs.swift | 16 +- .../SitemapPageViewModelDiffingTests.swift | 185 ++++++++++++++++++ 8 files changed, 285 insertions(+), 50 deletions(-) create mode 100644 openHABTestsSwift/SitemapPageViewModelDiffingTests.swift diff --git a/openHAB/Models/SitemapPageViewModel.swift b/openHAB/Models/SitemapPageViewModel.swift index 875a9c606..164d0f4b5 100644 --- a/openHAB/Models/SitemapPageViewModel.swift +++ b/openHAB/Models/SitemapPageViewModel.swift @@ -267,32 +267,48 @@ class SitemapPageViewModel: ObservableObject { func rebuildRowInputs() { let pageKey = "\(defaultSitemap)|\(pageId)" + let widgets = relevantWidgets + let inputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: widgets) + rowWidgetIndex = buildWidgetIndex(pageKey: pageKey, widgets: widgets) + if inputs != rowInputs { + rowInputs = inputs + } + } + + private func buildWidgetIndex(pageKey: String, widgets: [OpenHABWidget]) -> [RowID: OpenHABWidget] { var occurrenceByWidgetID: [String: Int] = [:] - var inputs: [SitemapRowInput] = [] var index: [RowID: OpenHABWidget] = [:] - inputs.reserveCapacity(relevantWidgets.count) - index.reserveCapacity(relevantWidgets.count) - - for widget in relevantWidgets { + index.reserveCapacity(widgets.count) + for widget in widgets { let identityWidgetID = SitemapRowInputMapper.rowIdentityWidgetID(for: widget) occurrenceByWidgetID[identityWidgetID, default: 0] += 1 let occurrence = occurrenceByWidgetID[identityWidgetID]! let rowID = RowID(pageKey: pageKey, widgetId: identityWidgetID, occurrence: occurrence) - let input = SitemapRowInputMapper.map(widget: widget, rowID: rowID) - inputs.append(input) index[rowID] = widget } + return index + } - rowWidgetIndex = index - rowInputs = inputs + /// Increments `widgetUpdateVersions` for each row whose content differs between `oldInputs` + /// and `newInputs`, keyed by full row identity. + func bumpWidgetVersions(from oldInputs: [SitemapRowInput], to newInputs: [SitemapRowInput]) { + if newInputs.count == oldInputs.count { + for (new, old) in zip(newInputs, oldInputs) where new != old { + widgetUpdateVersions[new.rowID.rawValue, default: 0] += 1 + } + } else { + for input in newInputs { + widgetUpdateVersions[input.rowID.rawValue, default: 0] += 1 + } + } } func widget(for rowID: RowID) -> OpenHABWidget? { rowWidgetIndex[rowID] } - func widgetUpdateVersion(for widgetId: String) -> Int { - widgetUpdateVersions[widgetId] ?? 0 + func widgetUpdateVersion(for rowID: RowID) -> Int { + widgetUpdateVersions[rowID.rawValue] ?? 0 } func sliderOverrideValue(for itemname: String?) -> Double? { @@ -528,34 +544,51 @@ extension SitemapPageViewModel { @MainActor private func updateUI(with page: OpenHABPage, origin: PageUpdateOrigin) { logger.debug("Incoming sitemap update origin=\(origin.rawValue, privacy: .public), widgets=\(page.widgets.count)") - let newWidgets = page.widgets - // Check if list structure changed (count, order, or IDs) + let pageKey = "\(defaultSitemap)|\(pageId)" + + // Snapshot what the list would render from the new data — before any widget mutation. + let incomingFiltered: [OpenHABWidget] + if searchText.isEmpty { + incomingFiltered = page.widgets + } else { + incomingFiltered = page.widgets.filter { + $0.label.lowercased().contains(searchText.lowercased()) && $0.type != .frame + } + } + let previewInputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: incomingFiltered) + + let titleChanged = currentPage == nil || currentPage?.title != page.title + let canSkipReconciliation = searchText.isEmpty && previewInputs == rowInputs && !titleChanged + guard !canSkipReconciliation else { + _ = clearSyncedSliderOverrides(using: page.widgets) + return + } + + // Something changed — reconcile widget objects and update stored state. let currentWidgets = currentPage?.widgets ?? [] - let structureChanged = currentWidgets.count != newWidgets.count - || !zip(currentWidgets, newWidgets).allSatisfy { $0.widgetId == $1.widgetId } - let reconciledWidgets = reconcileWidgets(newWidgets, with: currentWidgets) + let structureChanged = currentWidgets.count != page.widgets.count + || !zip(currentWidgets, page.widgets).allSatisfy { $0.widgetId == $1.widgetId } + let reconciledWidgets = reconcileWidgets(page.widgets, with: currentWidgets) + injectSendCommand(for: reconciledWidgets) - // Only replace currentPage when structure or title changed - if structureChanged || currentPage?.title != page.title || currentPage == nil { + if structureChanged || titleChanged { page.widgets = reconciledWidgets - injectSendCommand(for: reconciledWidgets) currentPage = page } else { currentPage?.widgets = reconciledWidgets - // Inject sendCommand into existing widgets without replacing the page - injectSendCommand(for: reconciledWidgets) } - trackWidgetUpdates(in: reconciledWidgets) _ = clearSyncedSliderOverrides(using: reconciledWidgets) - rebuildRowInputs() - } - private func trackWidgetUpdates(in widgets: [OpenHABWidget]) { - for widget in widgets { - widgetUpdateVersions[widget.widgetId, default: 0] += 1 - } + // Rebuild command-dispatch index from the now-current reconciled widgets. + rowWidgetIndex = buildWidgetIndex(pageKey: pageKey, widgets: relevantWidgets) + + // Bump widget versions only for rows whose content actually changed. + bumpWidgetVersions(from: rowInputs, to: previewInputs) + + // Publish new row inputs — guaranteed to differ from current (checked above). + rowInputs = previewInputs } private func clearSyncedSliderOverrides(using widgets: [OpenHABWidget]) -> Int { diff --git a/openHAB/UI/SwiftUI/Rows/ImageRowView.swift b/openHAB/UI/SwiftUI/Rows/ImageRowView.swift index 140c6a8d4..b6e7936f0 100644 --- a/openHAB/UI/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/ImageRowView.swift @@ -54,7 +54,7 @@ private struct ImageRowContent: View { } private var chartWidgetVersion: Int { - viewModel.widgetUpdateVersion(for: input.widgetId) + viewModel.widgetUpdateVersion(for: input.rowID) } private var chartSyncToken: String { diff --git a/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift index 6671a0a0f..eda8a6e92 100644 --- a/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift @@ -344,7 +344,7 @@ struct SegmentedRowView: View { var body: some View { SegmentedRowContent( input: input, - widgetVersion: viewModel.widgetUpdateVersion(for: input.widgetId), + widgetVersion: viewModel.widgetUpdateVersion(for: input.rowID), fallbackSymbol: fallbackSymbol ) { command, policy, phase in guard let itemName = input.itemName else { return } @@ -377,13 +377,16 @@ private extension SegmentedRowView { icon: String = "switch", mappings: [OpenHABWidgetMapping], selectedState: String? = nil) -> SegmentedRowInput { - SegmentedRowInput.from(widget: PreviewWidgetFactory.segmented( - label: label, - mappings: mappings, - selectedState: selectedState, - icon: icon, - valueText: detailLabel - )) + SegmentedRowInput.from( + widget: PreviewWidgetFactory.segmented( + label: label, + mappings: mappings, + selectedState: selectedState, + icon: icon, + valueText: detailLabel + ), + rowID: RowID(pageKey: "preview", widgetId: UUID().uuidString, occurrence: 1) + ) } } @@ -577,7 +580,10 @@ private extension SegmentedRowView { #Preview("From PreviewConstants") { PreviewList { SegmentedRowView( - input: SegmentedRowInput.from(widget: PreviewConstants.openHABSitemapPage!.widgets[4]) + input: SegmentedRowInput.from( + widget: PreviewConstants.openHABSitemapPage!.widgets[4], + rowID: RowID(pageKey: "preview", widgetId: UUID().uuidString, occurrence: 1) + ) ) } } diff --git a/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift b/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift index a52897c2c..9ce3013cd 100644 --- a/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift @@ -178,7 +178,7 @@ struct SelectionRowView: View { var body: some View { SelectionRowContent( input: input, - widgetVersion: viewModel.widgetUpdateVersion(for: input.widgetId) + widgetVersion: viewModel.widgetUpdateVersion(for: input.rowID) ) { command in guard let itemName = input.itemName else { return } viewModel.sendCommand(command, for: itemName) diff --git a/openHAB/UI/SwiftUI/Rows/SliderRowView.swift b/openHAB/UI/SwiftUI/Rows/SliderRowView.swift index 1b058508d..d143b4017 100644 --- a/openHAB/UI/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/SliderRowView.swift @@ -218,7 +218,7 @@ struct SliderRowView: View { makeSliderRowContent( SliderRowConfig( input: input, - widgetVersion: viewModel.widgetUpdateVersion(for: input.widgetId), + widgetVersion: viewModel.widgetUpdateVersion(for: input.rowID), overrideValue: viewModel.sliderOverrideValue(for: input.itemName), fallbackSymbol: fallbackSymbol, viewModel: viewModel @@ -245,7 +245,7 @@ private extension SliderRowView { step: step, icon: icon, switchSupport: switchSupport - )) + ), rowID: RowID(pageKey: "preview", widgetId: UUID().uuidString, occurrence: 1)) } } @@ -335,7 +335,10 @@ private extension SliderRowView { #Preview("From PreviewConstants") { PreviewList { SliderRowView( - input: SliderRowInput.from(widget: PreviewConstants.openHABSitemapPage!.widgets[3]), + input: SliderRowInput.from( + widget: PreviewConstants.openHABSitemapPage!.widgets[3], + rowID: RowID(pageKey: "preview", widgetId: UUID().uuidString, occurrence: 1) + ), fallbackSymbol: .sliderHorizontal3 ) } diff --git a/openHAB/UI/SwiftUI/SitemapRowInputMapper.swift b/openHAB/UI/SwiftUI/SitemapRowInputMapper.swift index 051e6c4b8..ddfd08ca1 100644 --- a/openHAB/UI/SwiftUI/SitemapRowInputMapper.swift +++ b/openHAB/UI/SwiftUI/SitemapRowInputMapper.swift @@ -37,11 +37,11 @@ enum SitemapRowInputMapper { switch widget.renderingKind { case .slider: - return SitemapRowInput.slider(rowID, SliderRowInput.from(widget: widget)) + return SitemapRowInput.slider(rowID, SliderRowInput.from(widget: widget, rowID: rowID)) case .selection: - return SitemapRowInput.selection(rowID, SelectionRowInput.from(widget: widget)) + return SitemapRowInput.selection(rowID, SelectionRowInput.from(widget: widget, rowID: rowID)) case .segmentedSwitch: - return SitemapRowInput.segmented(rowID, SegmentedRowInput.from(widget: widget)) + return SitemapRowInput.segmented(rowID, SegmentedRowInput.from(widget: widget, rowID: rowID)) case .frame: return SitemapRowInput.frame(rowID, FrameRowInput.from(widget: widget)) case .text: @@ -57,7 +57,7 @@ enum SitemapRowInputMapper { case .colorPicker: return SitemapRowInput.colorPicker(rowID, ColorPickerRowInput.from(widget: widget)) case .image, .chart, .video, .webview, .mapview: - return SitemapRowInput.media(rowID, MediaRowInput.from(widget: widget)) + return SitemapRowInput.media(rowID, MediaRowInput.from(widget: widget, rowID: rowID)) case .colorTemperaturePicker: return SitemapRowInput.colorTemperature(rowID, ColorTemperatureRowInput.from(widget: widget)) case .buttonGrid: diff --git a/openHAB/UI/SwiftUI/WidgetRowInputs.swift b/openHAB/UI/SwiftUI/WidgetRowInputs.swift index e6f8fc64a..7229e06a5 100644 --- a/openHAB/UI/SwiftUI/WidgetRowInputs.swift +++ b/openHAB/UI/SwiftUI/WidgetRowInputs.swift @@ -44,6 +44,7 @@ struct RowIconInput: Equatable, Sendable { } struct SelectionRowInput: Equatable, RowWithIconInput { + let rowID: RowID let displayState: WidgetDisplayState let mappings: [OpenHABWidgetMapping] let labelColor: String @@ -53,9 +54,10 @@ struct SelectionRowInput: Equatable, RowWithIconInput { let icon: RowIconInput let itemName: String? - static func from(widget: OpenHABWidget) -> SelectionRowInput { + static func from(widget: OpenHABWidget, rowID: RowID) -> SelectionRowInput { let displayState = widget.displayState return SelectionRowInput( + rowID: rowID, displayState: displayState, mappings: displayState.mappings, labelColor: widget.labelcolor, @@ -69,6 +71,7 @@ struct SelectionRowInput: Equatable, RowWithIconInput { } struct SegmentedRowInput: Equatable, RowWithIconInput { + let rowID: RowID let displayState: WidgetDisplayState let mappings: [OpenHABWidgetMapping] let labelColor: String @@ -77,9 +80,10 @@ struct SegmentedRowInput: Equatable, RowWithIconInput { let icon: RowIconInput let itemName: String? - static func from(widget: OpenHABWidget) -> SegmentedRowInput { + static func from(widget: OpenHABWidget, rowID: RowID) -> SegmentedRowInput { let displayState = widget.displayState return SegmentedRowInput( + rowID: rowID, displayState: displayState, mappings: displayState.mappings, labelColor: widget.labelcolor, @@ -408,6 +412,7 @@ struct ColorTemperatureRowInput: Equatable, RowWithIconInput { } struct MediaRowInput: Equatable { + let rowID: RowID let widgetId: String let renderingKind: WidgetRenderingKind let displayState: WidgetDisplayState @@ -423,12 +428,13 @@ struct MediaRowInput: Equatable { let coordinateLatitude: Double? let coordinateLongitude: Double? - static func from(widget: OpenHABWidget) -> MediaRowInput { + static func from(widget: OpenHABWidget, rowID: RowID) -> MediaRowInput { let coordinate = widget.coordinate let hasValidCoordinate = (-90.0 ... 90.0).contains(coordinate.latitude) && (-180.0 ... 180.0).contains(coordinate.longitude) return MediaRowInput( + rowID: rowID, widgetId: widget.widgetId, renderingKind: widget.renderingKind, displayState: widget.displayState, @@ -478,6 +484,7 @@ struct TextRowInput: Equatable, RowWithIconInput { } struct SliderRowInput: Equatable, RowWithIconInput { + let rowID: RowID let widgetId: String let displayState: WidgetDisplayState let numberPattern: String? @@ -500,7 +507,7 @@ struct SliderRowInput: Equatable, RowWithIconInput { displayState.minValue ... displayState.maxValue } - static func from(widget: OpenHABWidget) -> SliderRowInput { + static func from(widget: OpenHABWidget, rowID: RowID) -> SliderRowInput { let displayState = widget.displayState let numberPattern = if let pattern = widget.pattern, !pattern.isEmpty { pattern @@ -515,6 +522,7 @@ struct SliderRowInput: Equatable, RowWithIconInput { } return SliderRowInput( + rowID: rowID, widgetId: widget.widgetId, displayState: displayState, numberPattern: numberPattern, diff --git a/openHABTestsSwift/SitemapPageViewModelDiffingTests.swift b/openHABTestsSwift/SitemapPageViewModelDiffingTests.swift new file mode 100644 index 000000000..647c6264a --- /dev/null +++ b/openHABTestsSwift/SitemapPageViewModelDiffingTests.swift @@ -0,0 +1,185 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +@testable import openHAB +import Combine +import OpenHABCore +import Testing + +/// Tests that the row-input and widget-version pipelines avoid unnecessary +/// @Published writes when incoming data is unchanged. +@MainActor +@Suite +struct SitemapPageViewModelDiffingTests { + // MARK: rowInputs diffing + + @Test + func rebuildRowInputsProducesStableOutputForUnchangedWidgets() { + let widgets = [makeTextWidget(widgetID: "w1", label: "Light"), makeTextWidget(widgetID: "w2", label: "Temp")] + let viewModel = SitemapPageViewModel(title: "Test", widgets: widgets) + + let first = viewModel.rowInputs + viewModel.rebuildRowInputs() + let second = viewModel.rowInputs + + #expect(first == second) + } + + @Test + func rebuildRowInputsDoesNotFireObjectWillChangeWhenUnchanged() { + let widgets = [makeTextWidget(widgetID: "w1", label: "Light")] + let viewModel = SitemapPageViewModel(title: "Test", widgets: widgets) + + var changeCount = 0 + let cancellable = viewModel.objectWillChange.sink { changeCount += 1 } + + let countBefore = changeCount + // Second rebuild with identical widgets — guard should suppress the assignment. + viewModel.rebuildRowInputs() + #expect(changeCount == countBefore) + + withExtendedLifetime(cancellable) {} + } + + @Test + func rebuildRowInputsFiresObjectWillChangeWhenWidgetsChange() { + let widget = makeTextWidget(widgetID: "w1", label: "Light") + let viewModel = SitemapPageViewModel(title: "Test", widgets: [widget]) + + var changeCount = 0 + let cancellable = viewModel.objectWillChange.sink { changeCount += 1 } + + let countBefore = changeCount + + // Swap in a differently-labelled widget and rebuild. + let changed = makeTextWidget(widgetID: "w1", label: "Updated") + viewModel.currentPage = makePage(widgets: [changed]) + viewModel.rebuildRowInputs() + + #expect(changeCount > countBefore) + + withExtendedLifetime(cancellable) {} + } + + @Test + func rebuildRowInputsHandlesStructureChange() { + let widgets = [makeTextWidget(widgetID: "w1", label: "One"), makeTextWidget(widgetID: "w2", label: "Two")] + let viewModel = SitemapPageViewModel(title: "Test", widgets: widgets) + + #expect(viewModel.rowInputs.count == 2) + + viewModel.currentPage = makePage(widgets: [makeTextWidget(widgetID: "w1", label: "One")]) + viewModel.rebuildRowInputs() + + #expect(viewModel.rowInputs.count == 1) + } + + // MARK: bumpWidgetVersions + + @Test + func bumpWidgetVersionsDoesNotIncrementForIdenticalInputs() { + let viewModel = SitemapPageViewModel(title: "Test", widgets: [makeTextWidget(widgetID: "w1", label: "Temp")]) + let inputs = viewModel.rowInputs + let before = viewModel.widgetUpdateVersion(for: inputs[0].rowID) + + viewModel.bumpWidgetVersions(from: inputs, to: inputs) + + #expect(viewModel.widgetUpdateVersion(for: inputs[0].rowID) == before) + } + + @Test + func bumpWidgetVersionsIncrementsOnlyForChangedRow() { + let w1 = makeTextWidget(widgetID: "w1", label: "Stable") + let w2 = makeTextWidget(widgetID: "w2", label: "Changing") + let viewModel = SitemapPageViewModel(title: "Test", widgets: [w1, w2]) + + let oldInputs = viewModel.rowInputs + + // Rebuild with w2 changed. + viewModel.currentPage = makePage(widgets: [w1, makeTextWidget(widgetID: "w2", label: "Updated")]) + viewModel.rebuildRowInputs() + let newInputs = viewModel.rowInputs + + let v1Before = viewModel.widgetUpdateVersion(for: oldInputs[0].rowID) + let v2Before = viewModel.widgetUpdateVersion(for: oldInputs[1].rowID) + + viewModel.bumpWidgetVersions(from: oldInputs, to: newInputs) + + #expect(viewModel.widgetUpdateVersion(for: newInputs[0].rowID) == v1Before, "Stable widget version must not change") + #expect(viewModel.widgetUpdateVersion(for: newInputs[1].rowID) > v2Before, "Changed widget version must increment") + } + + @Test + func bumpWidgetVersionsIncrementsAllRowsOnStructureChange() { + let viewModel = SitemapPageViewModel( + title: "Test", + widgets: [makeTextWidget(widgetID: "w1", label: "One")] + ) + let oldInputs = viewModel.rowInputs + + viewModel.currentPage = makePage(widgets: [ + makeTextWidget(widgetID: "w1", label: "One"), + makeTextWidget(widgetID: "w2", label: "Two") + ]) + viewModel.rebuildRowInputs() + let newInputs = viewModel.rowInputs + + let v1Before = viewModel.widgetUpdateVersion(for: oldInputs[0].rowID) + let v2Before = 0 + + viewModel.bumpWidgetVersions(from: oldInputs, to: newInputs) + + // Count changed — all new rows should get a bump. + #expect(viewModel.widgetUpdateVersion(for: newInputs[0].rowID) > v1Before) + #expect(viewModel.widgetUpdateVersion(for: newInputs[1].rowID) > v2Before) + } + + @Test + func bumpWidgetVersionsHandlesRepeatedWidgetIDsIndependently() { + let oldWidgets = [ + makeTextWidget(widgetID: "dup", label: "One"), + makeTextWidget(widgetID: "dup", label: "Two") + ] + let viewModel = SitemapPageViewModel(title: "Test", widgets: oldWidgets) + let oldInputs = viewModel.rowInputs + + viewModel.currentPage = makePage(widgets: [ + makeTextWidget(widgetID: "dup", label: "One"), + makeTextWidget(widgetID: "dup", label: "Updated") + ]) + viewModel.rebuildRowInputs() + let newInputs = viewModel.rowInputs + + let firstBefore = viewModel.widgetUpdateVersion(for: oldInputs[0].rowID) + let secondBefore = viewModel.widgetUpdateVersion(for: oldInputs[1].rowID) + + viewModel.bumpWidgetVersions(from: oldInputs, to: newInputs) + + #expect(viewModel.widgetUpdateVersion(for: newInputs[0].rowID) == firstBefore) + #expect(viewModel.widgetUpdateVersion(for: newInputs[1].rowID) > secondBefore) + } +} + +// MARK: - Helpers + +private extension SitemapPageViewModelDiffingTests { + func makeTextWidget(widgetID: String, label: String) -> OpenHABWidget { + let widget = OpenHABWidget() + widget.widgetId = widgetID + widget.type = .text + widget.label = label + return widget + } + + func makePage(widgets: [OpenHABWidget]) -> OpenHABPage { + OpenHABPage(pageId: "p1", title: "Test", link: "", leaf: false, widgets: widgets, icon: "") + } +} From 58b7c1c9fc2b908baf7d4c0a3579da3ca62ada6c Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:29:45 +0200 Subject: [PATCH 02/11] Remove widget reconciliation from SwiftUI sitemap update path (#1139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateUI now replaces currentPage wholesale instead of mutating existing OpenHABWidget objects through reconcileWidgets/copyWidgetProperties. Row inputs are already computed from fresh page data before any stored state changes, so the mutable widget graph was redundant as a rendering model. Deletes: - reconcileWidgets(_:with:) and copyWidgetProperties(from:to:) — 70 lines of in-place @Published mutation that ran on every changed poll - injectSendCommand(for:) — the injected widget.sendCommand closure was never invoked; all row views call viewModel.sendCommand(command,for:itemName) directly via @EnvironmentObject - rowWidgetIndex / buildWidgetIndex / widget(for:) — the RowID→OpenHABWidget lookup was dead code; no row view called widget(for:) - WidgetRenderKey and its five support types — only used by trackWidgetUpdates which was removed in the previous commit loadCurrentPage drops the injectSendCommand call for the same reason. rebuildRowInputs drops the index rebuild. No behaviour change for any interactive row; slider optimistic values, segmented/selection sync, and media refresh all go through the row-input and item-name paths that were already in place. Signed-off-by: Tim Mueller-Seydlitz --- .../SitemapPageViewModel+SupportTypes.swift | 177 ------------------ openHAB/Models/SitemapPageViewModel.swift | 141 ++------------ 2 files changed, 13 insertions(+), 305 deletions(-) diff --git a/openHAB/Models/SitemapPageViewModel+SupportTypes.swift b/openHAB/Models/SitemapPageViewModel+SupportTypes.swift index 9db4d1d50..d40f4daa3 100644 --- a/openHAB/Models/SitemapPageViewModel+SupportTypes.swift +++ b/openHAB/Models/SitemapPageViewModel+SupportTypes.swift @@ -26,180 +26,3 @@ struct QueuedCommand { let version: Int } -// swiftlint:disable:next file_types_order -struct WidgetRenderKey: Equatable { - let label: String - let icon: String - let state: String - let iconColor: String - let labelColor: String - let valueColor: String - let url: String - let period: String - let service: String - let legend: Bool? - let refresh: Int - let height: Double? - let forceAsItem: Bool? - let visibility: Bool - let staticIcon: Bool? - let switchSupport: Bool - let minValue: Double - let maxValue: Double - let step: Double - let pattern: String? - let unit: String? - let type: OpenHABWidget.WidgetType - let linkedPageLink: String? - let linkedPageTitle: String? - let mappings: [WidgetMappingKey] - let item: WidgetItemKey? - let childWidgetIDs: [String] - - static func from(widget: OpenHABWidget) -> WidgetRenderKey { - WidgetRenderKey( - label: widget.label, - icon: widget.icon, - state: widget.state, - iconColor: widget.iconColor, - labelColor: widget.labelcolor, - valueColor: widget.valuecolor, - url: widget.url, - period: widget.period, - service: widget.service, - legend: widget.legend, - refresh: widget.refresh, - height: widget.height, - forceAsItem: widget.forceAsItem, - visibility: widget.visibility, - staticIcon: widget.staticIcon, - switchSupport: widget.switchSupport, - minValue: widget.minValue, - maxValue: widget.maxValue, - step: widget.step, - pattern: widget.pattern, - unit: widget.unit, - type: widget.type, - linkedPageLink: widget.linkedPage?.link, - linkedPageTitle: widget.linkedPage?.title, - mappings: widget.mappings.map(WidgetMappingKey.init), - item: WidgetItemKey.from(item: widget.item), - childWidgetIDs: widget.widgets.map(\.widgetId) - ) - } - - // Could be synthesized automatically by compiler. But this takes too long - static func == (lhs: WidgetRenderKey, rhs: WidgetRenderKey) -> Bool { - lhs.label == rhs.label && - lhs.icon == rhs.icon && - lhs.state == rhs.state && - lhs.iconColor == rhs.iconColor && - lhs.labelColor == rhs.labelColor && - lhs.valueColor == rhs.valueColor && - lhs.url == rhs.url && - lhs.period == rhs.period && - lhs.service == rhs.service && - lhs.legend == rhs.legend && - lhs.refresh == rhs.refresh && - lhs.height == rhs.height && - lhs.forceAsItem == rhs.forceAsItem && - lhs.visibility == rhs.visibility && - lhs.staticIcon == rhs.staticIcon && - lhs.switchSupport == rhs.switchSupport && - lhs.minValue == rhs.minValue && - lhs.maxValue == rhs.maxValue && - lhs.step == rhs.step && - lhs.pattern == rhs.pattern && - lhs.unit == rhs.unit && - lhs.type == rhs.type && - lhs.linkedPageLink == rhs.linkedPageLink && - lhs.linkedPageTitle == rhs.linkedPageTitle && - lhs.mappings == rhs.mappings && - lhs.item == rhs.item && - lhs.childWidgetIDs == rhs.childWidgetIDs - } -} - -struct WidgetMappingKey: Equatable { - let command: String - let label: String - let row: Int? - let column: Int? - let icon: String? - let releaseCommand: String? - - init(_ mapping: OpenHABWidgetMapping) { - command = mapping.command - label = mapping.label - row = mapping.row - column = mapping.column - icon = mapping.icon - releaseCommand = mapping.releaseCommand - } -} - -struct WidgetItemKey: Equatable { - let name: String - let state: String? - let link: String - let label: String - let type: OpenHABItem.ItemType? - let groupType: OpenHABItem.ItemType? - let stateDescription: WidgetStateDescriptionKey? - let commandOptions: [WidgetCommandOptionKey] - - static func from(item: OpenHABItem?) -> WidgetItemKey? { - guard let item else { return nil } - return WidgetItemKey( - name: item.name, - state: item.state, - link: item.link, - label: item.label, - type: item.type, - groupType: item.groupType, - stateDescription: WidgetStateDescriptionKey.from(stateDescription: item.stateDescription), - commandOptions: item.commandDescription?.commandOptions.map(WidgetCommandOptionKey.init) ?? [] - ) - } -} - -struct WidgetStateDescriptionKey: Equatable { - let minimum: Double - let maximum: Double - let step: Double - let readOnly: Bool - let numberPattern: String? - let options: [WidgetOptionKey] - - static func from(stateDescription: OpenHABStateDescription?) -> WidgetStateDescriptionKey? { - guard let stateDescription else { return nil } - return WidgetStateDescriptionKey( - minimum: stateDescription.minimum, - maximum: stateDescription.maximum, - step: stateDescription.step, - readOnly: stateDescription.readOnly, - numberPattern: stateDescription.numberPattern, - options: stateDescription.options.map(WidgetOptionKey.init) - ) - } -} - -struct WidgetOptionKey: Equatable { - let value: String - let label: String - - init(_ option: OpenHABOptions) { - value = option.value - label = option.label - } -} - -struct WidgetCommandOptionKey: Equatable { - let command: String - let label: String? - - init(_ option: OpenHABCommandOptions) { - command = option.command - label = option.label - } -} diff --git a/openHAB/Models/SitemapPageViewModel.swift b/openHAB/Models/SitemapPageViewModel.swift index 164d0f4b5..38431620b 100644 --- a/openHAB/Models/SitemapPageViewModel.swift +++ b/openHAB/Models/SitemapPageViewModel.swift @@ -96,7 +96,6 @@ class SitemapPageViewModel: ObservableObject { private var commandStateResetTasks: [String: Task] = [:] private var commandStateVersions: [String: Int] = [:] private var queuedCommands: [String: QueuedCommand] = [:] - private var rowWidgetIndex: [RowID: OpenHABWidget] = [:] private var sliderValueOverrides: [String: Double] = [:] private var sliderOverrideResetTasks: [String: Task] = [:] private var lastForegroundRefreshAt: Date = .distantPast @@ -267,28 +266,12 @@ class SitemapPageViewModel: ObservableObject { func rebuildRowInputs() { let pageKey = "\(defaultSitemap)|\(pageId)" - let widgets = relevantWidgets - let inputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: widgets) - rowWidgetIndex = buildWidgetIndex(pageKey: pageKey, widgets: widgets) + let inputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: relevantWidgets) if inputs != rowInputs { rowInputs = inputs } } - private func buildWidgetIndex(pageKey: String, widgets: [OpenHABWidget]) -> [RowID: OpenHABWidget] { - var occurrenceByWidgetID: [String: Int] = [:] - var index: [RowID: OpenHABWidget] = [:] - index.reserveCapacity(widgets.count) - for widget in widgets { - let identityWidgetID = SitemapRowInputMapper.rowIdentityWidgetID(for: widget) - occurrenceByWidgetID[identityWidgetID, default: 0] += 1 - let occurrence = occurrenceByWidgetID[identityWidgetID]! - let rowID = RowID(pageKey: pageKey, widgetId: identityWidgetID, occurrence: occurrence) - index[rowID] = widget - } - return index - } - /// Increments `widgetUpdateVersions` for each row whose content differs between `oldInputs` /// and `newInputs`, keyed by full row identity. func bumpWidgetVersions(from oldInputs: [SitemapRowInput], to newInputs: [SitemapRowInput]) { @@ -303,10 +286,6 @@ class SitemapPageViewModel: ObservableObject { } } - func widget(for rowID: RowID) -> OpenHABWidget? { - rowWidgetIndex[rowID] - } - func widgetUpdateVersion(for rowID: RowID) -> Int { widgetUpdateVersions[rowID.rawValue] ?? 0 } @@ -547,7 +526,7 @@ extension SitemapPageViewModel { let pageKey = "\(defaultSitemap)|\(pageId)" - // Snapshot what the list would render from the new data — before any widget mutation. + // Snapshot new inputs from fresh page data — no widget object mutation. let incomingFiltered: [OpenHABWidget] if searchText.isEmpty { incomingFiltered = page.widgets @@ -559,36 +538,23 @@ extension SitemapPageViewModel { let previewInputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: incomingFiltered) let titleChanged = currentPage == nil || currentPage?.title != page.title - let canSkipReconciliation = searchText.isEmpty && previewInputs == rowInputs && !titleChanged - guard !canSkipReconciliation else { + let inputsChanged = previewInputs != rowInputs + + // When search is empty and nothing the UI renders has changed, skip the update entirely. + if searchText.isEmpty, !inputsChanged, !titleChanged { _ = clearSyncedSliderOverrides(using: page.widgets) return } - // Something changed — reconcile widget objects and update stored state. - let currentWidgets = currentPage?.widgets ?? [] - let structureChanged = currentWidgets.count != page.widgets.count - || !zip(currentWidgets, page.widgets).allSatisfy { $0.widgetId == $1.widgetId } - let reconciledWidgets = reconcileWidgets(page.widgets, with: currentWidgets) - injectSendCommand(for: reconciledWidgets) - - if structureChanged || titleChanged { - page.widgets = reconciledWidgets - currentPage = page - } else { - currentPage?.widgets = reconciledWidgets - } - - _ = clearSyncedSliderOverrides(using: reconciledWidgets) - - // Rebuild command-dispatch index from the now-current reconciled widgets. - rowWidgetIndex = buildWidgetIndex(pageKey: pageKey, widgets: relevantWidgets) + // Replace currentPage wholesale — no in-place widget reconciliation. + currentPage = page - // Bump widget versions only for rows whose content actually changed. - bumpWidgetVersions(from: rowInputs, to: previewInputs) + _ = clearSyncedSliderOverrides(using: page.widgets) - // Publish new row inputs — guaranteed to differ from current (checked above). - rowInputs = previewInputs + if inputsChanged { + bumpWidgetVersions(from: rowInputs, to: previewInputs) + rowInputs = previewInputs + } } private func clearSyncedSliderOverrides(using widgets: [OpenHABWidget]) -> Int { @@ -661,80 +627,10 @@ extension SitemapPageViewModel { throw SitemapPageError.noData } - injectSendCommand(for: page.widgets) currentPage = page rebuildRowInputs() } - private func reconcileWidgets(_ newWidgets: [OpenHABWidget], with currentWidgets: [OpenHABWidget]) -> [OpenHABWidget] { - var buckets: [String: [OpenHABWidget]] = [:] - for widget in currentWidgets { - buckets[widget.widgetId, default: []].append(widget) - } - - var reconciled: [OpenHABWidget] = [] - reconciled.reserveCapacity(newWidgets.count) - - for newWidget in newWidgets { - if var candidates = buckets[newWidget.widgetId], !candidates.isEmpty { - let existing = candidates.removeFirst() - buckets[newWidget.widgetId] = candidates - - // Always copy server properties to avoid missing updates when - // non-keyed fields change (for example group summary/state rows). - let previousChildren = existing.widgets - copyWidgetProperties(from: newWidget, to: existing) - existing.widgets = reconcileWidgets(newWidget.widgets, with: previousChildren) - - reconciled.append(existing) - } else { - reconciled.append(newWidget) - } - } - - return reconciled - } - - private func copyWidgetProperties(from source: OpenHABWidget, to target: OpenHABWidget) { - target.label = source.label - target.icon = source.icon - target.state = source.state - target.type = source.type - target.isLeaf = source.isLeaf - target.item = source.item - target.iconColor = source.iconColor - target.labelcolor = source.labelcolor - target.valuecolor = source.valuecolor - target.url = source.url - target.period = source.period - target.service = source.service - target.legend = source.legend - target.refresh = source.refresh - target.height = source.height - target.forceAsItem = source.forceAsItem - target.minValue = source.minValue - target.maxValue = source.maxValue - target.step = source.step - target.pattern = source.pattern - target.unit = source.unit - target.switchSupport = source.switchSupport - target.mappings = source.mappings - target.linkedPage = source.linkedPage - target.visibility = source.visibility - target.staticIcon = source.staticIcon - target.text = source.text - target.inputHint = source.inputHint - target.encoding = source.encoding - target.labelSource = source.labelSource - target.releaseOnly = source.releaseOnly - target.row = source.row - target.column = source.column - target.releaseCommand = source.releaseCommand - target.command = source.command - target.stateless = source.stateless - target.yAxisDecimalPattern = source.yAxisDecimalPattern - } - private func shouldRetryLongPolling(after error: any Error) -> Bool { if let urlError = OpenAPIErrorInspector.underlyingURLError(from: error) { switch urlError.code { @@ -757,17 +653,6 @@ extension SitemapPageViewModel { return false } - private func injectSendCommand(for widgets: [OpenHABWidget]) { - for widget in widgets { - widget.sendCommand = { [weak self] item, command in - self?.sendCommand(item, commandToSend: command) - } - - // If widget has nested children (e.g., frames/groups), inject recursively - injectSendCommand(for: widget.widgets) - } - } - @MainActor func pushSitemap(name: String, path: String?) async { defaultSitemap = name From 9e61a17383fb88952b977947c8036dda8973bfce Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:45:10 +0200 Subject: [PATCH 03/11] Decouple hot sitemap rows from shared view model (#1140) * Decouple hot sitemap rows from shared view model * Clean up slider override decoupling * Restore page-scoped icon identity --------- Signed-off-by: Tim Mueller-Seydlitz --- openHAB/Models/SitemapPageViewModel.swift | 107 +++--------------- .../UI/SwiftUI/EmbeddingRowInputView.swift | 2 +- openHAB/UI/SwiftUI/IconView.swift | 21 +++- openHAB/UI/SwiftUI/RowViewWithIcon.swift | 5 +- openHAB/UI/SwiftUI/Rows/FrameRowView.swift | 1 - openHAB/UI/SwiftUI/Rows/GenericRowView.swift | 1 - .../UI/SwiftUI/Rows/SegmentedRowView.swift | 9 +- .../UI/SwiftUI/Rows/SelectionRowView.swift | 9 +- openHAB/UI/SwiftUI/Rows/SliderRowView.swift | 51 +++++---- openHAB/UI/SwiftUI/Rows/TextRowView.swift | 1 - openHAB/UI/SwiftUI/SitemapPageView.swift | 21 +++- openHAB/UI/SwiftUI/SitemapRowActions.swift | 36 ++++++ openHAB/UI/SwiftUI/SitemapRowInput.swift | 28 +++++ openHAB/UI/SwiftUI/WidgetRowInputs.swift | 55 +++++++++ .../SliderOverrideSyncTests.swift | 67 ----------- 15 files changed, 211 insertions(+), 203 deletions(-) create mode 100644 openHAB/UI/SwiftUI/SitemapRowActions.swift delete mode 100644 openHABTestsSwift/SliderOverrideSyncTests.swift diff --git a/openHAB/Models/SitemapPageViewModel.swift b/openHAB/Models/SitemapPageViewModel.swift index 38431620b..b6010bef0 100644 --- a/openHAB/Models/SitemapPageViewModel.swift +++ b/openHAB/Models/SitemapPageViewModel.swift @@ -59,7 +59,7 @@ enum RowInteractionState: Equatable { @MainActor class SitemapPageViewModel: ObservableObject { - @Published var currentPage: OpenHABPage? + var currentPage: OpenHABPage? @Published var searchText = "" { didSet { rebuildRowInputs() @@ -69,6 +69,7 @@ class SitemapPageViewModel: ObservableObject { @Published var error: (any LocalizedError)? @Published var isLoading = true @Published var isUpdating = false + @Published private(set) var pageTitle = "" @Published var openHABRootUrl: String? @Published var showSearchField = false @Published private(set) var commandStates: [String: WidgetCommandLifecycleState] = [:] @@ -87,8 +88,7 @@ class SitemapPageViewModel: ObservableObject { private var defaultSitemap = "" private var defaultSitemapLabel = "" private var fallbackTitle = "" - @Published var pageId = "" - private var isLinkedPage = false + var pageId = "" private var pageNetworkStatus: NetworkStatus? private var pageNetworkStatusAvailable = false private var activePageHandlingKey: String? @@ -96,8 +96,6 @@ class SitemapPageViewModel: ObservableObject { private var commandStateResetTasks: [String: Task] = [:] private var commandStateVersions: [String: Int] = [:] private var queuedCommands: [String: QueuedCommand] = [:] - private var sliderValueOverrides: [String: Double] = [:] - private var sliderOverrideResetTasks: [String: Task] = [:] private var lastForegroundRefreshAt: Date = .distantPast var relevantWidgets: [OpenHABWidget] { @@ -108,7 +106,7 @@ class SitemapPageViewModel: ObservableObject { } } - var pageTitle: String { + private func computePageTitle() -> String { // Strip bracket content from title (e.g., "Living Room[2]" becomes "Living Room") let title = currentPage?.title.components(separatedBy: "[")[0].trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !title.isEmpty { @@ -123,8 +121,11 @@ class SitemapPageViewModel: ObservableObject { } } - var isLinked: Bool { - isLinkedPage + private func refreshPageTitle() { + let newTitle = computePageTitle() + if pageTitle != newTitle { + pageTitle = newTitle + } } var commandLifecycleSummary: CommandLifecycleSummary { @@ -183,7 +184,6 @@ class SitemapPageViewModel: ObservableObject { init(pageUrl: String, title: String, pageId: String = "") { loadSettings() startObservers() - isLinkedPage = true fallbackTitle = title defaultSitemapLabel = title @@ -201,11 +201,11 @@ class SitemapPageViewModel: ObservableObject { } else { self.pageId = pageId } + refreshPageTitle() } /// Initializes the view model with a fixed set of widgets, without loading or polling init(pageUrl: String = "", title: String = "Preview Page", pageId: String = "", widgets: [OpenHABWidget]) { - isLinkedPage = !pageUrl.isEmpty fallbackTitle = title self.pageId = pageId currentPage = OpenHABPage( @@ -216,6 +216,7 @@ class SitemapPageViewModel: ObservableObject { widgets: widgets, icon: "" ) + refreshPageTitle() rebuildRowInputs() } @@ -290,24 +291,6 @@ class SitemapPageViewModel: ObservableObject { widgetUpdateVersions[rowID.rawValue] ?? 0 } - func sliderOverrideValue(for itemname: String?) -> Double? { - guard let itemname, !itemname.isEmpty else { return nil } - return sliderValueOverrides[itemname] - } - - func setSliderOverrideValue(_ value: Double, for itemname: String?) { - guard let itemname, !itemname.isEmpty else { return } - sliderOverrideResetTasks[itemname]?.cancel() - sliderOverrideResetTasks[itemname] = nil - objectWillChange.send() - sliderValueOverrides[itemname] = value - } - - @discardableResult - func syncSliderOverridesWithServerState(for widgets: [OpenHABWidget]) -> Int { - clearSyncedSliderOverrides(using: widgets) - } - deinit { connectionObserverTask?.cancel() networkStatusObserverTask?.cancel() @@ -315,8 +298,6 @@ class SitemapPageViewModel: ObservableObject { foregroundRefreshTask?.cancel() commandStateResetTasks.values.forEach { $0.cancel() } commandStateResetTasks.removeAll() - sliderOverrideResetTasks.values.forEach { $0.cancel() } - sliderOverrideResetTasks.removeAll() } } @@ -542,56 +523,19 @@ extension SitemapPageViewModel { // When search is empty and nothing the UI renders has changed, skip the update entirely. if searchText.isEmpty, !inputsChanged, !titleChanged { - _ = clearSyncedSliderOverrides(using: page.widgets) return } // Replace currentPage wholesale — no in-place widget reconciliation. currentPage = page - - _ = clearSyncedSliderOverrides(using: page.widgets) + refreshPageTitle() if inputsChanged { bumpWidgetVersions(from: rowInputs, to: previewInputs) - rowInputs = previewInputs + rowInputs = previewInputs.map { $0.applyingWidgetVersions(widgetUpdateVersions) } } } - private func clearSyncedSliderOverrides(using widgets: [OpenHABWidget]) -> Int { - guard !sliderValueOverrides.isEmpty else { return 0 } - var cleared = 0 - - for widget in widgets { - guard let item = widget.item else { - cleared += clearSyncedSliderOverrides(using: widget.widgets) - continue - } - - let itemname = item.name - guard let overrideValue = sliderValueOverrides[itemname] else { - cleared += clearSyncedSliderOverrides(using: widget.widgets) - continue - } - - let serverValue = item.state?.parseAsNumber(format: item.stateDescription?.numberPattern).value ?? .nan - guard serverValue.isFinite else { - cleared += clearSyncedSliderOverrides(using: widget.widgets) - continue - } - - let threshold = max(widget.step, 0.001) - if abs(serverValue - overrideValue) <= threshold { - clearSliderOverride(for: itemname) - cleared += 1 - logger.debug("Cleared slider override for \(itemname, privacy: .public) (server=\(serverValue), override=\(overrideValue))") - } - - cleared += clearSyncedSliderOverrides(using: widget.widgets) - } - - return cleared - } - func reload() async { do { isLoading = true @@ -628,6 +572,7 @@ extension SitemapPageViewModel { } currentPage = page + refreshPageTitle() rebuildRowInputs() } @@ -658,6 +603,7 @@ extension SitemapPageViewModel { defaultSitemap = name defaultSitemapLabel = "" // Clear old label so it gets fetched for the new sitemap pageId = path ?? "" + refreshPageTitle() error = nil // Clear any previous errors when switching sitemaps startPageHandling(forceRestart: true, reason: "push-sitemap") } @@ -674,6 +620,7 @@ extension SitemapPageViewModel { // Find the sitemap matching our defaultSitemap name and get its label if let sitemap = sitemaps.first(where: { $0.name == defaultSitemap }) { defaultSitemapLabel = sitemap.label + refreshPageTitle() // swiftformat:disable:next redundantSelf logger.info("Found label '\(self.defaultSitemapLabel)' for sitemap '\(self.defaultSitemap)'") } else { @@ -704,6 +651,7 @@ extension SitemapPageViewModel { // Auto-select the only available sitemap defaultSitemap = filteredSitemaps[0].name defaultSitemapLabel = filteredSitemaps[0].label + refreshPageTitle() // swiftformat:disable:next redundantSelf logger.info("Auto-selected single sitemap: \(self.defaultSitemap)") @@ -715,6 +663,7 @@ extension SitemapPageViewModel { // Multiple sitemaps available - select the first one defaultSitemap = filteredSitemaps[0].name defaultSitemapLabel = filteredSitemaps[0].label + refreshPageTitle() // swiftformat:disable:next redundantSelf logger.info("Auto-selected first sitemap from \(filteredSitemaps.count) available: \(self.defaultSitemap)") @@ -839,7 +788,6 @@ extension SitemapPageViewModel { commandDispatcher.cancelPending(for: itemname, key: key) if key == nil { queuedCommands.removeValue(forKey: itemname) - clearSliderOverride(for: itemname) if case .queued = commandStates[itemname] { setCommandState(.idle, for: itemname) } @@ -1039,12 +987,10 @@ private extension SitemapPageViewModel { func handleCommandSuccess(for itemname: String, version: Int) { guard commandStateVersions[itemname] == version else { return } scheduleCommandStateReset(for: itemname, version: version, after: .milliseconds(450)) - scheduleSliderOverrideResetFallback(for: itemname, version: version, after: .seconds(5)) } func handleCommandFailure(for itemname: String, version: Int, errorDescription: String) { guard commandStateVersions[itemname] == version else { return } - clearSliderOverride(for: itemname) setCommandState(.failed(message: errorDescription), for: itemname) } @@ -1058,23 +1004,6 @@ private extension SitemapPageViewModel { } } - func scheduleSliderOverrideResetFallback(for itemname: String, version: Int, after delay: Duration) { - sliderOverrideResetTasks[itemname]?.cancel() - sliderOverrideResetTasks[itemname] = Task { @MainActor [weak self] in - try? await Task.sleep(for: delay) - guard let self else { return } - guard commandStateVersions[itemname] == version else { return } - clearSliderOverride(for: itemname) - } - } - - func clearSliderOverride(for itemname: String) { - guard sliderValueOverrides[itemname] != nil else { return } - sliderOverrideResetTasks[itemname]?.cancel() - sliderOverrideResetTasks[itemname] = nil - objectWillChange.send() - sliderValueOverrides.removeValue(forKey: itemname) - } } extension Published.Publisher where Output: Sendable { diff --git a/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift b/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift index 8aada8a2a..7f48f10de 100644 --- a/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift +++ b/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift @@ -98,7 +98,7 @@ private struct LinkedPageRowContent: View { var body: some View { let displayState = input.displayState HStack { - IconInputView(input: input.icon, rowIdentity: input.widgetId, size: CGSize(width: 32, height: 32)) + IconInputView(input: input.icon, rowIdentity: input.linkedPageLink, size: CGSize(width: 32, height: 32)) Text(displayState.labelText) .ohTextToken(.rowLabel) diff --git a/openHAB/UI/SwiftUI/IconView.swift b/openHAB/UI/SwiftUI/IconView.swift index 9d7bc38a1..ee1271e5f 100644 --- a/openHAB/UI/SwiftUI/IconView.swift +++ b/openHAB/UI/SwiftUI/IconView.swift @@ -40,14 +40,24 @@ actor IconCacheTracker { } } +private struct SitemapPageIdentityKey: EnvironmentKey { + static let defaultValue = "" +} + +extension EnvironmentValues { + var sitemapPageIdentity: String { + get { self[SitemapPageIdentityKey.self] } + set { self[SitemapPageIdentityKey.self] = newValue } + } +} + struct IconInputView: View { let input: RowIconInput let rowIdentity: String let fallbackSymbol: SFSymbol? @ObservedObject private var networkTracker = MainActorNetworkTracker.shared @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject var viewModel: SitemapPageViewModel - + @Environment(\.sitemapPageIdentity) private var pageIdentity let size: CGSize let iconType: IconType = .svg @@ -119,7 +129,7 @@ struct IconInputView: View { .cancelOnDisappear(true) .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) - .id("\(viewModel.pageId)-\(rowIdentity)-\(colorScheme)") + .id("\(pageIdentity)-\(rowIdentity)-\(colorScheme)") } } } @@ -142,8 +152,7 @@ struct IconView: View { @ObservedObject var widget: OpenHABWidget @ObservedObject private var networkTracker = MainActorNetworkTracker.shared @Environment(\.colorScheme) private var colorScheme - @EnvironmentObject var viewModel: SitemapPageViewModel - + @Environment(\.sitemapPageIdentity) private var pageIdentity let size: CGSize let iconType: IconType = .svg /// Optional SF Symbol to show as fallback when network icon is unavailable (useful for previews) @@ -220,7 +229,7 @@ struct IconView: View { .cancelOnDisappear(true) .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height) - .id("\(viewModel.pageId)-\(widget.id)-\(colorScheme)") + .id("\(pageIdentity)-\(widget.id)-\(colorScheme)") } } } diff --git a/openHAB/UI/SwiftUI/RowViewWithIcon.swift b/openHAB/UI/SwiftUI/RowViewWithIcon.swift index 0e8779572..0aa24672f 100644 --- a/openHAB/UI/SwiftUI/RowViewWithIcon.swift +++ b/openHAB/UI/SwiftUI/RowViewWithIcon.swift @@ -16,6 +16,7 @@ import SwiftUI /// caller-provided content inside an `HStack`. struct RowViewWithIcon: View { let input: any RowWithIconInput + let rowIdentity: String let fallbackSymbol: SFSymbol? let alignment: VerticalAlignment let spacing: CGFloat? @@ -24,18 +25,20 @@ struct RowViewWithIcon: View { var body: some View { HStack(alignment: alignment, spacing: spacing) { if input.icon.showIcon { - IconInputView(input: input.icon, rowIdentity: input.widgetId, size: CGSize(width: 32, height: 32), fallbackSymbol: fallbackSymbol) + IconInputView(input: input.icon, rowIdentity: rowIdentity, size: CGSize(width: 32, height: 32), fallbackSymbol: fallbackSymbol) } content() } } init(input: any RowWithIconInput, + rowIdentity: String? = nil, fallbackSymbol: SFSymbol? = .questionmark, alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) { self.input = input + self.rowIdentity = rowIdentity ?? input.widgetId self.fallbackSymbol = fallbackSymbol self.alignment = alignment self.spacing = spacing diff --git a/openHAB/UI/SwiftUI/Rows/FrameRowView.swift b/openHAB/UI/SwiftUI/Rows/FrameRowView.swift index 8401ccf5d..f808ded97 100644 --- a/openHAB/UI/SwiftUI/Rows/FrameRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/FrameRowView.swift @@ -54,6 +54,5 @@ struct FrameRowView: View { List([widget]) { widget in FrameRowView(input: FrameRowInput.from(widget: widget)) } - .environmentObject(SitemapPageViewModel()) } #endif diff --git a/openHAB/UI/SwiftUI/Rows/GenericRowView.swift b/openHAB/UI/SwiftUI/Rows/GenericRowView.swift index 5e21c7415..9596076ed 100644 --- a/openHAB/UI/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/GenericRowView.swift @@ -56,6 +56,5 @@ struct GenericRowView: View { List([widget]) { widget in GenericRowView(input: GenericRowInput.from(widget: widget)) } - .environmentObject(SitemapPageViewModel()) } #endif diff --git a/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift b/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift index eda8a6e92..91facc2ed 100644 --- a/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/SegmentedRowView.swift @@ -34,7 +34,7 @@ private struct SegmentedRowContent: View { var body: some View { let selectedIndex = effectiveSelectedIndex(displayState: input.displayState, mappings: input.mappings) - RowViewWithIcon(input: input, fallbackSymbol: fallbackSymbol, spacing: 0) { + RowViewWithIcon(input: input, rowIdentity: input.rowID.rawValue, fallbackSymbol: fallbackSymbol, spacing: 0) { if !input.displayState.labelText.isEmpty { let labelText = input.displayState.labelText Text(labelText) @@ -338,17 +338,16 @@ private struct SegmentedRowContent: View { struct SegmentedRowView: View { let input: SegmentedRowInput var fallbackSymbol: SFSymbol? - - @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.sitemapRowActions) private var rowActions var body: some View { SegmentedRowContent( input: input, - widgetVersion: viewModel.widgetUpdateVersion(for: input.rowID), + widgetVersion: input.widgetVersion, fallbackSymbol: fallbackSymbol ) { command, policy, phase in guard let itemName = input.itemName else { return } - viewModel.sendCommand(command, for: itemName, policy: policy, phase: phase) + rowActions.sendCommand(itemName, command, policy, phase) } } } diff --git a/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift b/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift index 9ce3013cd..aeaeb493f 100644 --- a/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/SelectionRowView.swift @@ -58,7 +58,7 @@ private struct SelectionRowContent: View { @ViewBuilder private func rowContent(displayedCommand: String) -> some View { - RowViewWithIcon(input: input) { + RowViewWithIcon(input: input, rowIdentity: input.rowID.rawValue) { if !input.displayState.labelText.isEmpty { let labelText = input.displayState.labelText Text(labelText) @@ -172,16 +172,15 @@ private struct SelectionRowContent: View { struct SelectionRowView: View { let input: SelectionRowInput - - @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.sitemapRowActions) private var rowActions var body: some View { SelectionRowContent( input: input, - widgetVersion: viewModel.widgetUpdateVersion(for: input.rowID) + widgetVersion: input.widgetVersion ) { command in guard let itemName = input.itemName else { return } - viewModel.sendCommand(command, for: itemName) + rowActions.sendCommand(itemName, command, .immediate, .change) } } } diff --git a/openHAB/UI/SwiftUI/Rows/SliderRowView.swift b/openHAB/UI/SwiftUI/Rows/SliderRowView.swift index d143b4017..5afb8a9d5 100644 --- a/openHAB/UI/SwiftUI/Rows/SliderRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/SliderRowView.swift @@ -16,36 +16,35 @@ import SwiftUI private struct SliderRowConfig { let input: SliderRowInput - let widgetVersion: Int - let overrideValue: Double? let fallbackSymbol: SFSymbol? - let viewModel: SitemapPageViewModel + let rowActions: SitemapRowActions } @MainActor private func makeSliderRowContent(_ config: SliderRowConfig) -> SliderRowContent { SliderRowContent( input: config.input, - widgetVersion: config.widgetVersion, - overrideValue: config.overrideValue, + widgetVersion: config.input.widgetVersion, fallbackSymbol: config.fallbackSymbol, onToggleSwitch: { command in guard let itemName = config.input.itemName else { return } - config.viewModel.sendCommand(command, for: itemName) + config.rowActions.sendCommand(itemName, command, .immediate, .change) }, onCancelPending: { key in guard let itemName = config.input.itemName else { return } - config.viewModel.cancelPendingCommand(for: itemName, key: key) + config.rowActions.cancelPendingCommand(itemName, key) }, onSendValue: { value, policy, phase, key in guard let itemName = config.input.itemName else { return } - config.viewModel.setSliderOverrideValue(value, for: itemName) - let numberState = NumberState( - value: value, - unit: config.input.unit, - format: config.input.numberPattern + config.rowActions.sendNumericUpdate( + itemName, + value, + config.input.unit, + config.input.numberPattern, + policy, + phase, + key ) - config.viewModel.sendToUpdate(itemname: itemName, state: numberState, policy: policy, phase: phase, key: key) } ) } @@ -53,7 +52,6 @@ private func makeSliderRowContent(_ config: SliderRowConfig) -> SliderRowContent private struct SliderRowContent: View { let input: SliderRowInput let widgetVersion: Int - let overrideValue: Double? let fallbackSymbol: SFSymbol? let onToggleSwitch: (String) -> Void let onCancelPending: (String?) -> Void @@ -64,6 +62,7 @@ private struct SliderRowContent: View { @State private var isEditing = false @State private var dragStartVersion: Int? @State private var dragWidgetId: String? + @State private var optimisticValue: Double? var body: some View { let displayedSliderValue = effectiveValue(state: input) @@ -97,6 +96,7 @@ private struct SliderRowContent: View { .release, input.sliderCommandKey ) + optimisticValue = displayedSliderValue dragValue = nil dragStartVersion = nil dragWidgetId = nil @@ -108,14 +108,22 @@ private struct SliderRowContent: View { dragValue = nil dragStartVersion = nil dragWidgetId = nil + optimisticValue = nil } .onChange(of: input.widgetId) { _ in isEditing = false dragValue = nil dragStartVersion = nil dragWidgetId = nil + optimisticValue = nil } .onChange(of: widgetVersion) { _ in + let threshold = max(input.step, 0.001) + if !isEditing, optimisticValue != nil { + optimisticValue = nil + return + } + guard isEditing else { return } // If server refresh advanced while dragging, only cancel when the server value diverges // meaningfully from the local drag value. This avoids jarring cancels on polling echoes. @@ -128,7 +136,6 @@ private struct SliderRowContent: View { } if widgetVersion != dragStartVersion { - let threshold = max(input.step, 0.001) let currentDragValue = dragValue ?? input.serverValue let hasMeaningfulServerChange = abs(input.serverValue - currentDragValue) > threshold if hasMeaningfulServerChange { @@ -148,7 +155,7 @@ private struct SliderRowContent: View { private func labelContent(state: SliderRowInput) -> some View { let displayedValue = effectiveValue(state: state) let currentValueText = currentValueText(state: state, value: displayedValue) - RowViewWithIcon(input: state, fallbackSymbol: fallbackSymbol) { + RowViewWithIcon(input: state, rowIdentity: state.rowID.rawValue, fallbackSymbol: fallbackSymbol) { if !state.displayState.labelText.isEmpty { let labelText = state.displayState.labelText Text(labelText) @@ -190,7 +197,10 @@ private struct SliderRowContent: View { if isEditing { return dragValue ?? state.serverValue } - return overrideValue ?? state.serverValue + if let optimisticValue { + return optimisticValue + } + return state.serverValue } private func currentValueText(state: SliderRowInput, value: Double) -> String { @@ -211,17 +221,14 @@ private struct SliderRowContent: View { struct SliderRowView: View { let input: SliderRowInput var fallbackSymbol: SFSymbol? - - @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.sitemapRowActions) private var rowActions var body: some View { makeSliderRowContent( SliderRowConfig( input: input, - widgetVersion: viewModel.widgetUpdateVersion(for: input.rowID), - overrideValue: viewModel.sliderOverrideValue(for: input.itemName), fallbackSymbol: fallbackSymbol, - viewModel: viewModel + rowActions: rowActions ) ) } diff --git a/openHAB/UI/SwiftUI/Rows/TextRowView.swift b/openHAB/UI/SwiftUI/Rows/TextRowView.swift index 31c168977..0f5d7fcf1 100644 --- a/openHAB/UI/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/TextRowView.swift @@ -77,6 +77,5 @@ struct TextRowView: View { TextRowView(input: TextRowInput.from(widget: widget)) Spacer() } - .environmentObject(SitemapPageViewModel()) } #endif diff --git a/openHAB/UI/SwiftUI/SitemapPageView.swift b/openHAB/UI/SwiftUI/SitemapPageView.swift index 121629c18..ad89066e4 100644 --- a/openHAB/UI/SwiftUI/SitemapPageView.swift +++ b/openHAB/UI/SwiftUI/SitemapPageView.swift @@ -18,10 +18,6 @@ struct SitemapPageView: View { @StateObject var viewModel = SitemapPageViewModel() @State private var idleTimerDisabled = false - private var isLinkedPage: Bool { - viewModel.isLinked - } - var body: some View { Group { if viewModel.isLoading, viewModel.rowInputs.isEmpty { @@ -40,6 +36,23 @@ struct SitemapPageView: View { } } .environmentObject(viewModel) + .environment(\.sitemapPageIdentity, viewModel.pageId) + .environment(\.sitemapRowActions, SitemapRowActions( + sendCommand: { itemName, command, policy, phase in + viewModel.sendCommand(command, for: itemName, policy: policy, phase: phase) + }, + cancelPendingCommand: { itemName, key in + viewModel.cancelPendingCommand(for: itemName, key: key) + }, + sendNumericUpdate: { itemName, value, unit, format, policy, phase, key in + let state = NumberState( + value: value, + unit: unit, + format: format + ) + viewModel.sendToUpdate(itemname: itemName, state: state, policy: policy, phase: phase, key: key) + } + )) .listStyle(.plain) .listRowSpacing(0) .environment(\.defaultMinListRowHeight, 32) diff --git a/openHAB/UI/SwiftUI/SitemapRowActions.swift b/openHAB/UI/SwiftUI/SitemapRowActions.swift new file mode 100644 index 000000000..1934f9164 --- /dev/null +++ b/openHAB/UI/SwiftUI/SitemapRowActions.swift @@ -0,0 +1,36 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import SwiftUI + +struct SitemapRowActions: Sendable { + var sendCommand: @MainActor @Sendable (_ itemName: String, _ command: String, _ policy: WidgetCommandPolicy, _ phase: WidgetCommandPhase) -> Void + var cancelPendingCommand: @MainActor @Sendable (_ itemName: String, _ key: String?) -> Void + var sendNumericUpdate: @MainActor @Sendable (_ itemName: String, _ value: Double, _ unit: String?, _ format: String?, _ policy: WidgetCommandPolicy, _ phase: WidgetCommandPhase, _ key: String?) -> Void + + static let noop = SitemapRowActions( + sendCommand: { _, _, _, _ in }, + cancelPendingCommand: { _, _ in }, + sendNumericUpdate: { _, _, _, _, _, _, _ in } + ) +} + +private struct SitemapRowActionsKey: EnvironmentKey { + static let defaultValue = SitemapRowActions.noop +} + +extension EnvironmentValues { + var sitemapRowActions: SitemapRowActions { + get { self[SitemapRowActionsKey.self] } + set { self[SitemapRowActionsKey.self] = newValue } + } +} diff --git a/openHAB/UI/SwiftUI/SitemapRowInput.swift b/openHAB/UI/SwiftUI/SitemapRowInput.swift index 6607939c4..ebceaa461 100644 --- a/openHAB/UI/SwiftUI/SitemapRowInput.swift +++ b/openHAB/UI/SwiftUI/SitemapRowInput.swift @@ -68,3 +68,31 @@ enum SitemapRowInput: Identifiable, Equatable, Sendable { } } } + +extension SitemapRowInput { + func applyingWidgetVersions(_ versions: [String: Int]) -> SitemapRowInput { + let version = versions[rowID.rawValue] ?? 0 + + switch self { + case let .slider(rowID, input): + return .slider(rowID, input.withWidgetVersion(version)) + case let .selection(rowID, input): + return .selection(rowID, input.withWidgetVersion(version)) + case let .segmented(rowID, input): + return .segmented(rowID, input.withWidgetVersion(version)) + case .frame, + .linked, + .text, + .setpoint, + .rollershutter, + .toggle, + .input, + .colorPicker, + .media, + .colorTemperature, + .buttonGrid, + .generic: + return self + } + } +} diff --git a/openHAB/UI/SwiftUI/WidgetRowInputs.swift b/openHAB/UI/SwiftUI/WidgetRowInputs.swift index 7229e06a5..02a648cfc 100644 --- a/openHAB/UI/SwiftUI/WidgetRowInputs.swift +++ b/openHAB/UI/SwiftUI/WidgetRowInputs.swift @@ -45,6 +45,7 @@ struct RowIconInput: Equatable, Sendable { struct SelectionRowInput: Equatable, RowWithIconInput { let rowID: RowID + let widgetVersion: Int let displayState: WidgetDisplayState let mappings: [OpenHABWidgetMapping] let labelColor: String @@ -58,6 +59,7 @@ struct SelectionRowInput: Equatable, RowWithIconInput { let displayState = widget.displayState return SelectionRowInput( rowID: rowID, + widgetVersion: 0, displayState: displayState, mappings: displayState.mappings, labelColor: widget.labelcolor, @@ -68,10 +70,26 @@ struct SelectionRowInput: Equatable, RowWithIconInput { itemName: widget.item?.name ) } + + func withWidgetVersion(_ widgetVersion: Int) -> SelectionRowInput { + SelectionRowInput( + rowID: rowID, + widgetVersion: widgetVersion, + displayState: displayState, + mappings: mappings, + labelColor: labelColor, + valueColor: valueColor, + readOnly: readOnly, + widgetId: widgetId, + icon: icon, + itemName: itemName + ) + } } struct SegmentedRowInput: Equatable, RowWithIconInput { let rowID: RowID + let widgetVersion: Int let displayState: WidgetDisplayState let mappings: [OpenHABWidgetMapping] let labelColor: String @@ -84,6 +102,7 @@ struct SegmentedRowInput: Equatable, RowWithIconInput { let displayState = widget.displayState return SegmentedRowInput( rowID: rowID, + widgetVersion: 0, displayState: displayState, mappings: displayState.mappings, labelColor: widget.labelcolor, @@ -93,6 +112,20 @@ struct SegmentedRowInput: Equatable, RowWithIconInput { itemName: widget.item?.name ) } + + func withWidgetVersion(_ widgetVersion: Int) -> SegmentedRowInput { + SegmentedRowInput( + rowID: rowID, + widgetVersion: widgetVersion, + displayState: displayState, + mappings: mappings, + labelColor: labelColor, + valueColor: valueColor, + widgetId: widgetId, + icon: icon, + itemName: itemName + ) + } } struct SetpointRowInput: Equatable, RowWithIconInput { @@ -485,6 +518,7 @@ struct TextRowInput: Equatable, RowWithIconInput { struct SliderRowInput: Equatable, RowWithIconInput { let rowID: RowID + let widgetVersion: Int let widgetId: String let displayState: WidgetDisplayState let numberPattern: String? @@ -523,6 +557,7 @@ struct SliderRowInput: Equatable, RowWithIconInput { return SliderRowInput( rowID: rowID, + widgetVersion: 0, widgetId: widget.widgetId, displayState: displayState, numberPattern: numberPattern, @@ -539,6 +574,26 @@ struct SliderRowInput: Equatable, RowWithIconInput { ) } + func withWidgetVersion(_ widgetVersion: Int) -> SliderRowInput { + SliderRowInput( + rowID: rowID, + widgetVersion: widgetVersion, + widgetId: widgetId, + displayState: displayState, + numberPattern: numberPattern, + unit: unit, + readOnly: readOnly, + switchSupport: switchSupport, + step: step, + labelColor: labelColor, + valueColor: valueColor, + shouldSendUpdatesDuringMove: shouldSendUpdatesDuringMove, + serverValue: serverValue, + icon: icon, + itemName: itemName + ) + } + private static func resolvedSliderUnit(from widget: OpenHABWidget) -> String? { if let explicitUnit = widget.unit, !explicitUnit.isEmpty { return explicitUnit diff --git a/openHABTestsSwift/SliderOverrideSyncTests.swift b/openHABTestsSwift/SliderOverrideSyncTests.swift deleted file mode 100644 index 3aed8ba5d..000000000 --- a/openHABTestsSwift/SliderOverrideSyncTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2010-2026 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -@testable import openHAB -import OpenHABCore -import Testing - -@MainActor -@Suite -struct SliderOverrideSyncTests { - @Test - func clearsSliderOverrideWhenServerCatchesUp() { - let viewModel = SitemapPageViewModel(pageUrl: "", title: "Preview", pageId: "", widgets: []) - let itemName = "test_LEDLight_Brightness" - viewModel.setSliderOverrideValue(86, for: itemName) - - let widget = makeSliderWidget(widgetID: "00", itemName: itemName, itemState: "86.0", step: 1.0) - let cleared = viewModel.syncSliderOverridesWithServerState(for: [widget]) - - #expect(cleared == 1) - #expect(viewModel.sliderOverrideValue(for: itemName) == nil) - } - - @Test - func keepsSliderOverrideWhenServerHasNotCaughtUp() { - let viewModel = SitemapPageViewModel(pageUrl: "", title: "Preview", pageId: "", widgets: []) - let itemName = "test_LEDLight_Brightness" - viewModel.setSliderOverrideValue(86, for: itemName) - - let widget = makeSliderWidget(widgetID: "00", itemName: itemName, itemState: "79.0", step: 1.0) - let cleared = viewModel.syncSliderOverridesWithServerState(for: [widget]) - - #expect(cleared == 0) - #expect(viewModel.sliderOverrideValue(for: itemName) == 86) - } -} - -private extension SliderOverrideSyncTests { - func makeSliderWidget(widgetID: String, itemName: String, itemState: String, step: Double) -> OpenHABWidget { - let widget = OpenHABWidget() - widget.widgetId = widgetID - widget.type = .slider - widget.step = step - widget.item = OpenHABItem( - name: itemName, - type: "Dimmer", - state: itemState, - link: "", - label: nil, - groupType: nil, - stateDescription: nil, - commandDescription: nil, - members: [], - category: nil, - options: nil - ) - return widget - } -} From a8f92cd0bb8bb4aebdf59676ebfc45cf9a56ebee Mon Sep 17 00:00:00 2001 From: Tim Bert <5411131+timbms@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:18:34 +0200 Subject: [PATCH 04/11] Feature/lazy linked page navigation v3 (#1142) * Lazy-load linked sitemap page navigation * Remove redundant sitemap title modifiers * Fix linked page mapper tests --------- Signed-off-by: Tim Mueller-Seydlitz --- openHAB/UI/SwiftUI/EmbeddingRowInputView.swift | 8 ++------ openHAB/UI/SwiftUI/SitemapNavigationView.swift | 2 -- openHAB/UI/SwiftUI/SitemapPageView.swift | 8 ++++++++ openHAB/UI/SwiftUI/WidgetRowInputs.swift | 14 ++++++++++---- openHABTestsSwift/SitemapRowInputMapperTests.swift | 4 ++-- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift b/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift index 7f48f10de..88f12a09e 100644 --- a/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift +++ b/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift @@ -81,11 +81,7 @@ private struct LinkedPageRowInputView: View { let input: LinkedPageRowInput var body: some View { - NavigationLink( - destination: SitemapPageView( - viewModel: SitemapPageViewModel(pageUrl: input.linkedPageLink, title: input.linkedPageTitle) - ) - ) { + NavigationLink(value: input.destination) { LinkedPageRowContent(input: input) } .buttonStyle(.plain) @@ -98,7 +94,7 @@ private struct LinkedPageRowContent: View { var body: some View { let displayState = input.displayState HStack { - IconInputView(input: input.icon, rowIdentity: input.linkedPageLink, size: CGSize(width: 32, height: 32)) + IconInputView(input: input.icon, rowIdentity: input.destination.pageUrl, size: CGSize(width: 32, height: 32)) Text(displayState.labelText) .ohTextToken(.rowLabel) diff --git a/openHAB/UI/SwiftUI/SitemapNavigationView.swift b/openHAB/UI/SwiftUI/SitemapNavigationView.swift index 5861fa830..c2b315dd1 100644 --- a/openHAB/UI/SwiftUI/SitemapNavigationView.swift +++ b/openHAB/UI/SwiftUI/SitemapNavigationView.swift @@ -44,8 +44,6 @@ struct SitemapNavigationView: View { @ViewBuilder private var sitemapContent: some View { let page = SitemapPageView(viewModel: viewModel) - .navigationTitle(viewModel.pageTitle) - .navigationBarTitleDisplayMode(.automatic) .toolbar { if !isInteractionIdle { ToolbarItem(placement: .navigationBarLeading) { diff --git a/openHAB/UI/SwiftUI/SitemapPageView.swift b/openHAB/UI/SwiftUI/SitemapPageView.swift index ad89066e4..951f6bbcb 100644 --- a/openHAB/UI/SwiftUI/SitemapPageView.swift +++ b/openHAB/UI/SwiftUI/SitemapPageView.swift @@ -77,6 +77,14 @@ struct SitemapPageView: View { UIApplication.shared.isIdleTimerDisabled = false } } + .navigationDestination(for: LinkedPageDestination.self) { destination in + SitemapPageView( + viewModel: SitemapPageViewModel( + pageUrl: destination.pageUrl, + title: destination.title + ) + ) + } .navigationTitle(viewModel.pageTitle) .navigationBarTitleDisplayMode(.large) .alert("Error", isPresented: Binding( diff --git a/openHAB/UI/SwiftUI/WidgetRowInputs.swift b/openHAB/UI/SwiftUI/WidgetRowInputs.swift index 02a648cfc..c16a17445 100644 --- a/openHAB/UI/SwiftUI/WidgetRowInputs.swift +++ b/openHAB/UI/SwiftUI/WidgetRowInputs.swift @@ -16,6 +16,11 @@ protocol RowWithIconInput { var icon: RowIconInput { get } } +struct LinkedPageDestination: Hashable, Sendable { + let pageUrl: String + let title: String +} + struct RowIconInput: Equatable, Sendable { let icon: String let iconColor: String @@ -377,8 +382,7 @@ struct LinkedPageRowInput: Equatable, RowWithIconInput { let labelColor: String let valueColor: String let icon: RowIconInput - let linkedPageLink: String - let linkedPageTitle: String + let destination: LinkedPageDestination let isFrame: Bool static func from(widget: OpenHABWidget) -> LinkedPageRowInput? { @@ -389,8 +393,10 @@ struct LinkedPageRowInput: Equatable, RowWithIconInput { labelColor: widget.labelcolor, valueColor: widget.valuecolor, icon: RowIconInput.from(widget: widget), - linkedPageLink: linkedPage.link, - linkedPageTitle: linkedPage.title, + destination: LinkedPageDestination( + pageUrl: linkedPage.link, + title: linkedPage.title + ), isFrame: widget.type == .frame ) } diff --git a/openHABTestsSwift/SitemapRowInputMapperTests.swift b/openHABTestsSwift/SitemapRowInputMapperTests.swift index a94fd4eae..f5f61ff00 100644 --- a/openHABTestsSwift/SitemapRowInputMapperTests.swift +++ b/openHABTestsSwift/SitemapRowInputMapperTests.swift @@ -42,8 +42,8 @@ struct SitemapRowInputMapperTests { } #expect(mappedRowID == rowID) #expect(input.widgetId == widget.widgetId) - #expect(input.linkedPageLink == "https://example.invalid/linked") - #expect(input.linkedPageTitle == "Linked") + #expect(input.destination.pageUrl == "https://example.invalid/linked") + #expect(input.destination.title == "Linked") } } From 7fdffba5d57a8f309fbccee9420773d8407fcfc6 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 15 Apr 2026 15:39:20 +0200 Subject: [PATCH 05/11] Add temporary sitemap diagnostics logging Signed-off-by: Tim Mueller-Seydlitz --- .../OpenHABCore/Util/Preferences.swift | 1 + openHAB/Models/SitemapPageViewModel.swift | 36 ++++++ .../UI/SettingsView/DebugSettingsView.swift | 6 +- openHAB/UI/SettingsView/SettingsView.swift | 6 +- .../UI/SwiftUI/EmbeddingRowInputView.swift | 1 + openHAB/UI/SwiftUI/IconView.swift | 2 + openHAB/UI/SwiftUI/Rows/ImageRowView.swift | 5 + openHAB/UI/SwiftUI/SitemapDiagnostics.swift | 117 ++++++++++++++++++ 8 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 openHAB/UI/SwiftUI/SitemapDiagnostics.swift diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index 5e5ecc9e0..d16bcc08c 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -113,6 +113,7 @@ public struct HomePreferences: Codable, Equatable { @MainActor public struct ApplicationPreferences: Codable, Equatable { public var showSearchField = true + public var sitemapDiagnosticsLogging = false } // MARK: Retrieving preference from user defaults, reacting to preference change diff --git a/openHAB/Models/SitemapPageViewModel.swift b/openHAB/Models/SitemapPageViewModel.swift index b6010bef0..355cc266b 100644 --- a/openHAB/Models/SitemapPageViewModel.swift +++ b/openHAB/Models/SitemapPageViewModel.swift @@ -276,15 +276,24 @@ class SitemapPageViewModel: ObservableObject { /// Increments `widgetUpdateVersions` for each row whose content differs between `oldInputs` /// and `newInputs`, keyed by full row identity. func bumpWidgetVersions(from oldInputs: [SitemapRowInput], to newInputs: [SitemapRowInput]) { + let changedKinds = SitemapDiagnostics.changedRowKinds(from: oldInputs, to: newInputs) + let changedRowCount: Int if newInputs.count == oldInputs.count { + changedRowCount = zip(newInputs, oldInputs).reduce(into: 0) { count, pair in + if pair.0 != pair.1 { + count += 1 + } + } for (new, old) in zip(newInputs, oldInputs) where new != old { widgetUpdateVersions[new.rowID.rawValue, default: 0] += 1 } } else { + changedRowCount = newInputs.count for input in newInputs { widgetUpdateVersions[input.rowID.rawValue, default: 0] += 1 } } + SitemapDiagnostics.logWidgetVersions(changedRowCount: changedRowCount, changedRowKinds: changedKinds) } func widgetUpdateVersion(for rowID: RowID) -> Int { @@ -504,6 +513,7 @@ extension SitemapPageViewModel { @MainActor private func updateUI(with page: OpenHABPage, origin: PageUpdateOrigin) { logger.debug("Incoming sitemap update origin=\(origin.rawValue, privacy: .public), widgets=\(page.widgets.count)") + let mapStart = Date() let pageKey = "\(defaultSitemap)|\(pageId)" @@ -517,9 +527,34 @@ extension SitemapPageViewModel { } } let previewInputs = SitemapRowInputMapper.map(pageKey: pageKey, widgets: incomingFiltered) + let mapDurationMs = Int((Date().timeIntervalSince(mapStart) * 1000).rounded()) + let diffStart = Date() let titleChanged = currentPage == nil || currentPage?.title != page.title let inputsChanged = previewInputs != rowInputs + let changedRowCount: Int + if previewInputs.count == rowInputs.count { + changedRowCount = zip(previewInputs, rowInputs).reduce(into: 0) { count, pair in + if pair.0 != pair.1 { + count += 1 + } + } + } else { + changedRowCount = previewInputs.count + } + let changedRowKinds = SitemapDiagnostics.changedRowKinds(from: rowInputs, to: previewInputs) + let diffDurationMs = Int((Date().timeIntervalSince(diffStart) * 1000).rounded()) + SitemapDiagnostics.logUpdate( + origin: origin, + widgetCount: page.widgets.count, + rowCount: previewInputs.count, + inputsChanged: inputsChanged, + titleChanged: titleChanged, + changedRowCount: changedRowCount, + changedRowKinds: changedRowKinds, + mapDurationMs: mapDurationMs, + diffDurationMs: diffDurationMs + ) // When search is empty and nothing the UI renders has changed, skip the update entirely. if searchText.isEmpty, !inputsChanged, !titleChanged { @@ -533,6 +568,7 @@ extension SitemapPageViewModel { if inputsChanged { bumpWidgetVersions(from: rowInputs, to: previewInputs) rowInputs = previewInputs.map { $0.applyingWidgetVersions(widgetUpdateVersions) } + SitemapDiagnostics.logPublishedRows(rowCount: rowInputs.count, changedRowCount: changedRowCount) } } diff --git a/openHAB/UI/SettingsView/DebugSettingsView.swift b/openHAB/UI/SettingsView/DebugSettingsView.swift index c3478a547..cad96b5a8 100644 --- a/openHAB/UI/SettingsView/DebugSettingsView.swift +++ b/openHAB/UI/SettingsView/DebugSettingsView.swift @@ -18,6 +18,7 @@ import SwiftUI struct DebugSettingsView: View { @Binding var settingsSendCrashReports: Bool + @Binding var settingsSitemapDiagnosticsLogging: Bool @State private var hasBeenLoaded = false @State var showCrashReportingAlert = false @@ -57,6 +58,7 @@ struct DebugSettingsView: View { Text(LocalizedStringKey("crash_reporting_info")) } Section(header: Text(LocalizedStringKey("debug"))) { + Toggle("Sitemap Diagnostics Logging", isOn: $settingsSitemapDiagnosticsLogging) NavigationLink { LogsViewer() } label: { @@ -80,11 +82,13 @@ struct DebugSettingsView: View { @State private var ignoreSSL = true @State private var idleOff = false @State private var sendCrashReports = false + @State private var sitemapDiagnosticsLogging = false var body: some View { Form { DebugSettingsView( - settingsSendCrashReports: $sendCrashReports + settingsSendCrashReports: $sendCrashReports, + settingsSitemapDiagnosticsLogging: $sitemapDiagnosticsLogging ) } } diff --git a/openHAB/UI/SettingsView/SettingsView.swift b/openHAB/UI/SettingsView/SettingsView.swift index 2c606fb4e..1abbb41b6 100644 --- a/openHAB/UI/SettingsView/SettingsView.swift +++ b/openHAB/UI/SettingsView/SettingsView.swift @@ -20,6 +20,7 @@ struct SettingsView: View { @State private var settingsRealTimeSliders = true @State private var settingsShowSearchField = true @State private var settingsSendCrashReports = false + @State private var settingsSitemapDiagnosticsLogging = false @State private var settingsIconType: IconType = .svg @State private var settingsSortSitemapsBy: SortSitemapsOrder = .label @State private var settingsDefaultMainUIPath = "" @@ -63,7 +64,8 @@ struct SettingsView: View { ) DebugSettingsView( - settingsSendCrashReports: $settingsSendCrashReports + settingsSendCrashReports: $settingsSendCrashReports, + settingsSitemapDiagnosticsLogging: $settingsSitemapDiagnosticsLogging ) AboutSettingsView() @@ -123,6 +125,7 @@ struct SettingsView: View { settingsIdleOff = Preferences.shared.idleOff settingsRealTimeSliders = Preferences.shared.currentHomePreferences.realTimeSliders settingsShowSearchField = Preferences.shared.applicationPreferences.showSearchField + settingsSitemapDiagnosticsLogging = Preferences.shared.applicationPreferences.sitemapDiagnosticsLogging settingsSendCrashReports = Preferences.shared.sendCrashReports settingsIconType = IconType(rawValue: Preferences.shared.currentHomePreferences.iconType) ?? .svg settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.shared.currentHomePreferences.sortSitemapsBy) ?? .label @@ -154,6 +157,7 @@ struct SettingsView: View { Preferences.shared.modifyApplicationPreferences { @MainActor applicationPreferences in applicationPreferences.showSearchField = settingsShowSearchField + applicationPreferences.sitemapDiagnosticsLogging = settingsSitemapDiagnosticsLogging } // Apply global UI changes immediately (status bar visibility) diff --git a/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift b/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift index 88f12a09e..ac15f8c62 100644 --- a/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift +++ b/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift @@ -92,6 +92,7 @@ private struct LinkedPageRowContent: View { let input: LinkedPageRowInput var body: some View { + let _ = SitemapDiagnostics.logRender(kind: "linked", identity: input.destination.pageUrl) let displayState = input.displayState HStack { IconInputView(input: input.icon, rowIdentity: input.destination.pageUrl, size: CGSize(width: 32, height: 32)) diff --git a/openHAB/UI/SwiftUI/IconView.swift b/openHAB/UI/SwiftUI/IconView.swift index ee1271e5f..4f94a43be 100644 --- a/openHAB/UI/SwiftUI/IconView.swift +++ b/openHAB/UI/SwiftUI/IconView.swift @@ -92,6 +92,7 @@ struct IconInputView: View { } var body: some View { + let _ = SitemapDiagnostics.logRender(kind: "iconInput", identity: "\(pageIdentity)|\(rowIdentity)", detail: input.icon) ZStack { if let fallbackSymbol, currentImage == nil { Image(systemSymbol: fallbackSymbol) @@ -190,6 +191,7 @@ struct IconView: View { } var body: some View { + let _ = SitemapDiagnostics.logRender(kind: "iconWidget", identity: "\(pageIdentity)|\(widget.id)", detail: widget.icon) ZStack { // No icon URL - show fallback symbol if available if let fallbackSymbol { diff --git a/openHAB/UI/SwiftUI/Rows/ImageRowView.swift b/openHAB/UI/SwiftUI/Rows/ImageRowView.swift index b6e7936f0..95ac41060 100644 --- a/openHAB/UI/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/ImageRowView.swift @@ -67,6 +67,11 @@ private struct ImageRowContent: View { } var body: some View { + let _ = SitemapDiagnostics.logRender( + kind: isChartByMediaKind ? "chartImage" : "mediaImage", + identity: input.rowID.rawValue, + detail: "refresh=\(input.refresh)" + ) let displayState = input.displayState VStack(alignment: .leading, spacing: 8) { if !displayState.labelText.isEmpty, input.labelSourceRawValue == OpenHABWidget.LabelSource.sitemapDefinition.rawValue { diff --git a/openHAB/UI/SwiftUI/SitemapDiagnostics.swift b/openHAB/UI/SwiftUI/SitemapDiagnostics.swift new file mode 100644 index 000000000..e6819a821 --- /dev/null +++ b/openHAB/UI/SwiftUI/SitemapDiagnostics.swift @@ -0,0 +1,117 @@ +// Copyright (c) 2010-2026 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation +import OpenHABCore +import os.log + +@MainActor +enum SitemapDiagnostics { + private static let logger = Logger(subsystem: "org.openhab", category: "SitemapDiagnostics") + + static var isEnabled: Bool { + Preferences.shared.applicationPreferences.sitemapDiagnosticsLogging + } + + static func logUpdate( + origin: PageUpdateOrigin, + widgetCount: Int, + rowCount: Int, + inputsChanged: Bool, + titleChanged: Bool, + changedRowCount: Int, + changedRowKinds: String, + mapDurationMs: Int, + diffDurationMs: Int + ) { + guard isEnabled else { return } + logger.info( + """ + update origin=\(origin.rawValue, privacy: .public) widgets=\(widgetCount, privacy: .public) rows=\(rowCount, privacy: .public) \ + inputsChanged=\(inputsChanged, privacy: .public) titleChanged=\(titleChanged, privacy: .public) \ + changedRows=\(changedRowCount, privacy: .public) changedKinds=\(changedRowKinds, privacy: .public) \ + mapMs=\(mapDurationMs, privacy: .public) diffMs=\(diffDurationMs, privacy: .public) + """ + ) + } + + static func logPublishedRows(rowCount: Int, changedRowCount: Int) { + guard isEnabled else { return } + logger.info("rowInputs published rows=\(rowCount, privacy: .public) changedRows=\(changedRowCount, privacy: .public)") + } + + static func logWidgetVersions(changedRowCount: Int, changedRowKinds: String) { + guard isEnabled else { return } + logger.info("widgetVersions changedRows=\(changedRowCount, privacy: .public) changedKinds=\(changedRowKinds, privacy: .public)") + } + + static func logRender(kind: String, identity: String, detail: String = "") { + guard isEnabled else { return } + logger.debug("render kind=\(kind, privacy: .public) identity=\(identity, privacy: .private(mask: .hash)) detail=\(detail, privacy: .public)") + } + + static func changedRowKinds(from oldInputs: [SitemapRowInput], to newInputs: [SitemapRowInput]) -> String { + let changedKinds: [String] + if newInputs.count == oldInputs.count { + changedKinds = zip(newInputs, oldInputs) + .compactMap { newInput, oldInput in + newInput != oldInput ? rowKind(for: newInput) : nil + } + } else { + changedKinds = newInputs.map(rowKind(for:)) + } + + guard !changedKinds.isEmpty else { return "none" } + + let counts = changedKinds.reduce(into: [String: Int]()) { counts, kind in + counts[kind, default: 0] += 1 + } + + return counts.keys.sorted().map { kind in + "\(kind):\(counts[kind, default: 0])" + }.joined(separator: ",") + } + + static func rowKind(for input: SitemapRowInput) -> String { + switch input { + case .frame: + "frame" + case .linked: + "linked" + case .text: + "text" + case .slider: + "slider" + case .selection: + "selection" + case .segmented: + "segmented" + case .setpoint: + "setpoint" + case .rollershutter: + "rollershutter" + case .toggle: + "toggle" + case .input: + "input" + case .colorPicker: + "colorPicker" + case .media: + "media" + case .colorTemperature: + "colorTemperature" + case .buttonGrid: + "buttonGrid" + case .generic: + "generic" + } + } +} From eea417aa4dcb5edeb0c6186686e28adf08d08cb8 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 15 Apr 2026 16:36:40 +0200 Subject: [PATCH 06/11] Refine sitemap diagnostics and reduce refresh churn Signed-off-by: Tim Mueller-Seydlitz --- CommonUI/Sources/CommonUI/LogsViewer.swift | 28 ++++++++++++++ .../OpenHABCore/Util/Preferences.swift | 25 ++++++++++-- openHAB/Models/SitemapPageViewModel.swift | 22 +++++++++++ .../Supporting Files/Localizable.xcstrings | 4 ++ openHAB/UI/SwiftUI/IconView.swift | 38 ++++++++++++------- openHAB/UI/SwiftUI/Rows/ImageRowView.swift | 20 +++++----- openHAB/UI/SwiftUI/Rows/VideoRowView.swift | 23 ++++++++++- openHAB/UI/SwiftUI/SitemapPageView.swift | 1 + openHAB/UI/SwiftUI/SitemapRowInput.swift | 3 +- openHAB/UI/SwiftUI/WidgetRowInputs.swift | 23 +++++++++++ 10 files changed, 158 insertions(+), 29 deletions(-) diff --git a/CommonUI/Sources/CommonUI/LogsViewer.swift b/CommonUI/Sources/CommonUI/LogsViewer.swift index 131f2eea6..7778481de 100644 --- a/CommonUI/Sources/CommonUI/LogsViewer.swift +++ b/CommonUI/Sources/CommonUI/LogsViewer.swift @@ -20,6 +20,7 @@ public struct LogsViewer: View { "(subsystem BEGINSWITH $PREFIX)") @State private var text = String(localized: "Loading…") + @State private var exportURL: URL? let myFont = Font .system(size: 10) @@ -31,8 +32,20 @@ public struct LogsViewer: View { .font(myFont) .padding() } + .navigationTitle("Logs") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if let exportURL { + ShareLink(item: exportURL) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Share Logs") + } + } + } .task { text = await fetchLogs() + exportURL = makeExportURL(for: text) } } @@ -63,6 +76,21 @@ public struct LogsViewer: View { return error.localizedDescription } } + + private func makeExportURL(for text: String) -> URL? { + let fileName = "openhab-logs-\(Date.now.formatted(.iso8601.year().month().day().time(includingFractionalSeconds: false)))" + .replacingOccurrences(of: ":", with: "-") + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(fileName) + .appendingPathExtension("log") + + do { + try text.write(to: url, atomically: true, encoding: .utf8) + return url + } catch { + return nil + } + } } private extension OSLogEntryLog.Level { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index d16bcc08c..2e55abd85 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -114,6 +114,25 @@ public struct HomePreferences: Codable, Equatable { public struct ApplicationPreferences: Codable, Equatable { public var showSearchField = true public var sitemapDiagnosticsLogging = false + + public init( + showSearchField: Bool = true, + sitemapDiagnosticsLogging: Bool = false + ) { + self.showSearchField = showSearchField + self.sitemapDiagnosticsLogging = sitemapDiagnosticsLogging + } + + enum CodingKeys: String, CodingKey { + case showSearchField + case sitemapDiagnosticsLogging + } + + nonisolated public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + showSearchField = try container.decodeIfPresent(Bool.self, forKey: .showSearchField) ?? true + sitemapDiagnosticsLogging = try container.decodeIfPresent(Bool.self, forKey: .sitemapDiagnosticsLogging) ?? false + } } // MARK: Retrieving preference from user defaults, reacting to preference change @@ -147,15 +166,15 @@ private enum PreferencesAccess { @MainActor fileprivate static func preferenceChanged(newValue: T, key: String, isHomeProperty: Bool, subject: CurrentValueSubject, sanitize: (T) -> (T?) = { $0 }, converter: (T) -> (some Sendable)?) { guard let sanitized = sanitize(newValue) else { - Logger.preferences.debug("Preference \(key) new value \(String(describing: newValue), privacy: .private) could not be sanitized, will be ignored") + Logger.preferences.debug("Preference \(key, privacy: .public) value of type \(String(describing: T.self), privacy: .public) could not be sanitized, will be ignored") return } let convertedValue = converter(sanitized) guard convertedValue != nil else { - Logger.preferences.debug("Preference \(key) conversion of new value \(String(describing: sanitized), privacy: .private) failed, do not store.") + Logger.preferences.debug("Preference \(key, privacy: .public) conversion failed for value type \(String(describing: T.self), privacy: .public)") return } - Logger.preferences.debug("Preference \(key) will be changed to value \(String(describing: newValue), privacy: .private)") + Logger.preferences.debug("Preference \(key, privacy: .public) will be changed for value type \(String(describing: T.self), privacy: .public)") sharedDefaults.set(convertedValue, forKey: key) subject.send(sanitized) diff --git a/openHAB/Models/SitemapPageViewModel.swift b/openHAB/Models/SitemapPageViewModel.swift index 355cc266b..1cad77a07 100644 --- a/openHAB/Models/SitemapPageViewModel.swift +++ b/openHAB/Models/SitemapPageViewModel.swift @@ -128,6 +128,16 @@ class SitemapPageViewModel: ObservableObject { } } + var sitemapIconConnection: SitemapIconConnection { + guard let activeConnectionInfo else { + return .none + } + return SitemapIconConnection( + rootUrl: activeConnectionInfo.configuration.url, + version: activeConnectionInfo.version + ) + } + var commandLifecycleSummary: CommandLifecycleSummary { let failedCount = commandStates.values.reduce(into: 0) { result, state in if case .failed = state { @@ -332,6 +342,18 @@ extension SitemapPageViewModel { guard now.timeIntervalSince(lastForegroundRefreshAt) > 0.75 else { return } lastForegroundRefreshAt = now + let hasActiveHealthyPipeline = trackerStatus == .connected + && currentPage != nil + && pageHandlingTask != nil + && pageHandlingTask?.isCancelled == false + && error == nil + && !isLoading + && !isUpdating + if hasActiveHealthyPipeline { + logger.info("FG refresh: skipped while sitemap is already active and connected") + return + } + guard foregroundRefreshTask == nil else { return } logger.info("FG refresh: scheduled") foregroundRefreshTask = Task { [weak self] in diff --git a/openHAB/Supporting Files/Localizable.xcstrings b/openHAB/Supporting Files/Localizable.xcstrings index 8057478e7..04186adfa 100644 --- a/openHAB/Supporting Files/Localizable.xcstrings +++ b/openHAB/Supporting Files/Localizable.xcstrings @@ -11734,6 +11734,10 @@ } } }, + "Sitemap Diagnostics Logging" : { + "comment" : "A toggle that enables or disables logging of sitemap diagnostics.", + "isCommentAutoGenerated" : true + }, "Sitemap for Apple Watch" : { "comment" : "A label for selecting a sitemap to be used with an Apple Watch app.", "localizations" : { diff --git a/openHAB/UI/SwiftUI/IconView.swift b/openHAB/UI/SwiftUI/IconView.swift index 4f94a43be..32e67c4a4 100644 --- a/openHAB/UI/SwiftUI/IconView.swift +++ b/openHAB/UI/SwiftUI/IconView.swift @@ -44,20 +44,36 @@ private struct SitemapPageIdentityKey: EnvironmentKey { static let defaultValue = "" } +private struct SitemapIconConnectionKey: EnvironmentKey { + static let defaultValue = SitemapIconConnection.none +} + +struct SitemapIconConnection: Equatable, Sendable { + let rootUrl: String + let version: Int + + static let none = SitemapIconConnection(rootUrl: "", version: 0) +} + extension EnvironmentValues { var sitemapPageIdentity: String { get { self[SitemapPageIdentityKey.self] } set { self[SitemapPageIdentityKey.self] = newValue } } + + var sitemapIconConnection: SitemapIconConnection { + get { self[SitemapIconConnectionKey.self] } + set { self[SitemapIconConnectionKey.self] = newValue } + } } struct IconInputView: View { let input: RowIconInput let rowIdentity: String let fallbackSymbol: SFSymbol? - @ObservedObject private var networkTracker = MainActorNetworkTracker.shared @Environment(\.colorScheme) private var colorScheme @Environment(\.sitemapPageIdentity) private var pageIdentity + @Environment(\.sitemapIconConnection) private var iconConnection let size: CGSize let iconType: IconType = .svg @@ -72,17 +88,14 @@ struct IconInputView: View { private var iconURL: URL? { guard input.showIcon, !input.icon.isEmpty else { return nil } - - guard - let activeConnection = networkTracker.activeConnection, - !activeConnection.configuration.url.isEmpty else { + guard !iconConnection.rootUrl.isEmpty else { logger.debug("No active connection to fetch icon") return nil } return Endpoint.icon( - rootUrl: activeConnection.configuration.url, - version: activeConnection.version, + rootUrl: iconConnection.rootUrl, + version: iconConnection.version, icon: input.icon, state: input.iconState, iconType: iconType, @@ -151,9 +164,9 @@ struct IconInputView: View { /// A SwiftUI view that displays widget icons with openHAB-specific styling and caching struct IconView: View { @ObservedObject var widget: OpenHABWidget - @ObservedObject private var networkTracker = MainActorNetworkTracker.shared @Environment(\.colorScheme) private var colorScheme @Environment(\.sitemapPageIdentity) private var pageIdentity + @Environment(\.sitemapIconConnection) private var iconConnection let size: CGSize let iconType: IconType = .svg /// Optional SF Symbol to show as fallback when network icon is unavailable (useful for previews) @@ -171,17 +184,14 @@ struct IconView: View { private var iconURL: URL? { guard !widget.icon.isEmpty else { return nil } - - guard - let activeConnection = networkTracker.activeConnection, - !activeConnection.configuration.url.isEmpty else { + guard !iconConnection.rootUrl.isEmpty else { logger.debug("No active connection to fetch icon") return nil } return Endpoint.icon( - rootUrl: activeConnection.configuration.url, - version: activeConnection.version, + rootUrl: iconConnection.rootUrl, + version: iconConnection.version, icon: widget.icon, state: widget.iconState(), iconType: iconType, diff --git a/openHAB/UI/SwiftUI/Rows/ImageRowView.swift b/openHAB/UI/SwiftUI/Rows/ImageRowView.swift index 95ac41060..6f99791c1 100644 --- a/openHAB/UI/SwiftUI/Rows/ImageRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/ImageRowView.swift @@ -17,12 +17,12 @@ import SwiftUI private struct ImageRowConfig { let input: MediaRowInput - let viewModel: SitemapPageViewModel + let rootUrl: String } @MainActor private func makeImageRowContent(_ config: ImageRowConfig) -> ImageRowContent { - ImageRowContent(input: config.input, viewModel: config.viewModel) + ImageRowContent(input: config.input, rootUrl: config.rootUrl) } private struct ImageRowContent: View { @@ -32,7 +32,7 @@ private struct ImageRowContent: View { } let input: MediaRowInput - @ObservedObject var viewModel: SitemapPageViewModel + let rootUrl: String @Environment(\.colorScheme) var colorScheme @State private var refreshTimer: Timer? @State private var forceRefreshKey = UUID() @@ -54,7 +54,7 @@ private struct ImageRowContent: View { } private var chartWidgetVersion: Int { - viewModel.widgetUpdateVersion(for: input.rowID) + input.widgetVersion } private var chartSyncToken: String { @@ -62,7 +62,7 @@ private struct ImageRowContent: View { case .dark: "dark" case .light: "light" } - let rootKey = viewModel.openHABRootUrl ?? "" + let rootKey = rootUrl return "\(isChartByMediaKind)|\(input.widgetId)|\(input.imageDescriptor.period)|\(input.url)|\(rootKey)|\(themeKey)|\(chartWidgetVersion)" } @@ -146,7 +146,7 @@ private struct ImageRowContent: View { @ViewBuilder private var regularImageView: some View { - switch input.imageDescriptor.resolveImagePayload(rootUrl: viewModel.openHABRootUrl ?? "", chartStyle: chartStyle) { + switch input.imageDescriptor.resolveImagePayload(rootUrl: rootUrl, chartStyle: chartStyle) { case let .embedded(data: data): let cacheKey = if shouldCache { "\(input.widgetId)-\(data.hashValue)" @@ -219,7 +219,7 @@ private struct ImageRowContent: View { } let currentChartKey = makeChartDisplayKey() - switch input.imageDescriptor.resolveImagePayload(rootUrl: viewModel.openHABRootUrl ?? "", chartStyle: chartStyle) { + switch input.imageDescriptor.resolveImagePayload(rootUrl: rootUrl, chartStyle: chartStyle) { case let .link(url): chartDisplayState = ChartDisplayState(key: currentChartKey, url: url) case .embedded, .empty: @@ -232,20 +232,20 @@ private struct ImageRowContent: View { case .dark: "dark" case .light: "light" } - let rootKey = viewModel.openHABRootUrl ?? "" + let rootKey = rootUrl return "\(input.widgetId)|\(input.imageDescriptor.period)|\(themeKey)|\(rootKey)" } } struct ImageRowView: View { let input: MediaRowInput - @EnvironmentObject var viewModel: SitemapPageViewModel + @Environment(\.sitemapIconConnection) private var iconConnection var body: some View { makeImageRowContent( ImageRowConfig( input: input, - viewModel: viewModel + rootUrl: iconConnection.rootUrl ) ) } diff --git a/openHAB/UI/SwiftUI/Rows/VideoRowView.swift b/openHAB/UI/SwiftUI/Rows/VideoRowView.swift index 23274516d..ce2235364 100644 --- a/openHAB/UI/SwiftUI/Rows/VideoRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/VideoRowView.swift @@ -40,6 +40,7 @@ private struct VideoRowContent: View { @State private var currentStreamUrl: URL? @State private var imageObservationTimer: Timer? @State private var playerObserver: NSKeyValueObservation? + @State private var cleanupTask: Task? private let logger = Logger(subsystem: "org.openhab", category: "VideoRowView") @@ -98,12 +99,14 @@ private struct VideoRowContent: View { } .frame(maxWidth: .infinity) .onAppear { + cancelScheduledCleanup() setupVideo(url: videoURL) } .onDisappear { - cleanup() + scheduleCleanup() } .onChange(of: input.url) { newValue in + cancelScheduledCleanup() if !newValue.isEmpty, let newURL = URL(string: newValue) { setupVideo(url: newURL) } else { @@ -132,6 +135,8 @@ private struct VideoRowContent: View { @MainActor private func setupVideo(url: URL) { + cancelScheduledCleanup() + // Avoid redundant setup if URL hasn't changed if currentStreamUrl?.absoluteString == url.absoluteString { return @@ -149,6 +154,20 @@ private struct VideoRowContent: View { } } + private func scheduleCleanup() { + cleanupTask?.cancel() + cleanupTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + cleanup() + } + } + + private func cancelScheduledCleanup() { + cleanupTask?.cancel() + cleanupTask = nil + } + @MainActor private func setupMJPEG(url: URL) { // Create a dummy UIImageView for the SimpleMJPEGPlayer @@ -223,6 +242,8 @@ private struct VideoRowContent: View { } private func cleanup() { + cancelScheduledCleanup() + // Clean up timer imageObservationTimer?.invalidate() imageObservationTimer = nil diff --git a/openHAB/UI/SwiftUI/SitemapPageView.swift b/openHAB/UI/SwiftUI/SitemapPageView.swift index 951f6bbcb..062d788ec 100644 --- a/openHAB/UI/SwiftUI/SitemapPageView.swift +++ b/openHAB/UI/SwiftUI/SitemapPageView.swift @@ -37,6 +37,7 @@ struct SitemapPageView: View { } .environmentObject(viewModel) .environment(\.sitemapPageIdentity, viewModel.pageId) + .environment(\.sitemapIconConnection, viewModel.sitemapIconConnection) .environment(\.sitemapRowActions, SitemapRowActions( sendCommand: { itemName, command, policy, phase in viewModel.sendCommand(command, for: itemName, policy: policy, phase: phase) diff --git a/openHAB/UI/SwiftUI/SitemapRowInput.swift b/openHAB/UI/SwiftUI/SitemapRowInput.swift index ebceaa461..b3f527b5b 100644 --- a/openHAB/UI/SwiftUI/SitemapRowInput.swift +++ b/openHAB/UI/SwiftUI/SitemapRowInput.swift @@ -80,6 +80,8 @@ extension SitemapRowInput { return .selection(rowID, input.withWidgetVersion(version)) case let .segmented(rowID, input): return .segmented(rowID, input.withWidgetVersion(version)) + case let .media(rowID, input): + return .media(rowID, input.withWidgetVersion(version)) case .frame, .linked, .text, @@ -88,7 +90,6 @@ extension SitemapRowInput { .toggle, .input, .colorPicker, - .media, .colorTemperature, .buttonGrid, .generic: diff --git a/openHAB/UI/SwiftUI/WidgetRowInputs.swift b/openHAB/UI/SwiftUI/WidgetRowInputs.swift index c16a17445..735d55912 100644 --- a/openHAB/UI/SwiftUI/WidgetRowInputs.swift +++ b/openHAB/UI/SwiftUI/WidgetRowInputs.swift @@ -452,6 +452,7 @@ struct ColorTemperatureRowInput: Equatable, RowWithIconInput { struct MediaRowInput: Equatable { let rowID: RowID + let widgetVersion: Int let widgetId: String let renderingKind: WidgetRenderingKind let displayState: WidgetDisplayState @@ -474,6 +475,7 @@ struct MediaRowInput: Equatable { return MediaRowInput( rowID: rowID, + widgetVersion: 0, widgetId: widget.widgetId, renderingKind: widget.renderingKind, displayState: widget.displayState, @@ -490,6 +492,27 @@ struct MediaRowInput: Equatable { coordinateLongitude: hasValidCoordinate ? coordinate.longitude : nil ) } + + func withWidgetVersion(_ widgetVersion: Int) -> MediaRowInput { + MediaRowInput( + rowID: rowID, + widgetVersion: widgetVersion, + widgetId: widgetId, + renderingKind: renderingKind, + displayState: displayState, + imageDescriptor: imageDescriptor, + labelColor: labelColor, + valueColor: valueColor, + readOnly: readOnly, + refresh: refresh, + url: url, + encoding: encoding, + labelSourceRawValue: labelSourceRawValue, + preferredRowHeight: preferredRowHeight, + coordinateLatitude: coordinateLatitude, + coordinateLongitude: coordinateLongitude + ) + } } struct FrameRowInput: Equatable { From b3787e6454046d9ee909e184ab5adddf7403d0ca Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Wed, 15 Apr 2026 18:55:26 +0200 Subject: [PATCH 07/11] Restore sitemap refresh on foreground Signed-off-by: Tim Mueller-Seydlitz --- openHAB/Models/SitemapPageViewModel.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/openHAB/Models/SitemapPageViewModel.swift b/openHAB/Models/SitemapPageViewModel.swift index 1cad77a07..b01e0c416 100644 --- a/openHAB/Models/SitemapPageViewModel.swift +++ b/openHAB/Models/SitemapPageViewModel.swift @@ -342,18 +342,6 @@ extension SitemapPageViewModel { guard now.timeIntervalSince(lastForegroundRefreshAt) > 0.75 else { return } lastForegroundRefreshAt = now - let hasActiveHealthyPipeline = trackerStatus == .connected - && currentPage != nil - && pageHandlingTask != nil - && pageHandlingTask?.isCancelled == false - && error == nil - && !isLoading - && !isUpdating - if hasActiveHealthyPipeline { - logger.info("FG refresh: skipped while sitemap is already active and connected") - return - } - guard foregroundRefreshTask == nil else { return } logger.info("FG refresh: scheduled") foregroundRefreshTask = Task { [weak self] in From dd50959ce9e23afcf8eae1ca3afafc78985c6101 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 17 Apr 2026 15:16:14 +0200 Subject: [PATCH 08/11] Restore sitemap cache on foreground refresh Signed-off-by: Tim Mueller-Seydlitz --- openHAB/Models/SitemapPageViewModel.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openHAB/Models/SitemapPageViewModel.swift b/openHAB/Models/SitemapPageViewModel.swift index b01e0c416..27c1d3161 100644 --- a/openHAB/Models/SitemapPageViewModel.swift +++ b/openHAB/Models/SitemapPageViewModel.swift @@ -796,11 +796,12 @@ extension SitemapPageViewModel { } private func makeSitemapService(for connection: ConnectionInfo) throws -> OpenAPIService { - // Keep sitemap polling fresh after foreground transitions or long inactivity. - // Long-term config disables URL cache and aligns with watchOS behavior. + // Preserve normal URL caching on iOS so a recreated sitemap view can + // render the last page snapshot immediately while the foreground refresh + // fetches updated data in the background. try OpenAPIService( connectionConfiguration: connection.configuration, - serviceConfiguration: .longTerm + serviceConfiguration: .asDefault ) } From 3ef9896c4b0d16295a52a367ab29b8849cc8d914 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 17 Apr 2026 19:12:31 +0200 Subject: [PATCH 09/11] Fix stale display on foreground return for text, generic, and linked rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text, generic, and linked rows had no update mechanism — no @EnvironmentObject subscription and no widgetVersion — so SwiftUI could skip re-evaluating them when rowInputs changed on foreground refresh. Add widgetVersion to TextRowInput, GenericRowInput, and LinkedPageRowInput, handle them in applyingWidgetVersions(), and remove the leftover @MainActor factory-function pattern from TextRowView and GenericRowView. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/UI/SwiftUI/Rows/GenericRowView.swift | 11 +----- openHAB/UI/SwiftUI/Rows/TextRowView.swift | 11 +----- openHAB/UI/SwiftUI/SitemapRowInput.swift | 8 +++- openHAB/UI/SwiftUI/WidgetRowInputs.swift | 41 ++++++++++++++++++++ 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/openHAB/UI/SwiftUI/Rows/GenericRowView.swift b/openHAB/UI/SwiftUI/Rows/GenericRowView.swift index 9596076ed..a3e1d3eb1 100644 --- a/openHAB/UI/SwiftUI/Rows/GenericRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/GenericRowView.swift @@ -13,15 +13,6 @@ import CommonUI import OpenHABCore import SwiftUI -private struct GenericRowConfig { - let input: GenericRowInput -} - -@MainActor -private func makeGenericRowContent(_ config: GenericRowConfig) -> GenericRowContent { - GenericRowContent(input: config.input) -} - private struct GenericRowContent: View { let input: GenericRowInput @@ -46,7 +37,7 @@ private struct GenericRowContent: View { struct GenericRowView: View { let input: GenericRowInput var body: some View { - makeGenericRowContent(GenericRowConfig(input: input)) + GenericRowContent(input: input) } } diff --git a/openHAB/UI/SwiftUI/Rows/TextRowView.swift b/openHAB/UI/SwiftUI/Rows/TextRowView.swift index 0f5d7fcf1..57b96f20c 100644 --- a/openHAB/UI/SwiftUI/Rows/TextRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/TextRowView.swift @@ -15,15 +15,6 @@ import OpenHABCore import SFSafeSymbols import SwiftUI -private struct TextRowConfig { - let input: TextRowInput -} - -@MainActor -private func makeTextRowContent(_ config: TextRowConfig) -> TextRowContent { - TextRowContent(input: config.input) -} - private struct TextRowContent: View { let input: TextRowInput @@ -66,7 +57,7 @@ struct TextRowView: View { let input: TextRowInput var body: some View { - makeTextRowContent(TextRowConfig(input: input)) + TextRowContent(input: input) } } diff --git a/openHAB/UI/SwiftUI/SitemapRowInput.swift b/openHAB/UI/SwiftUI/SitemapRowInput.swift index b3f527b5b..ba27090a6 100644 --- a/openHAB/UI/SwiftUI/SitemapRowInput.swift +++ b/openHAB/UI/SwiftUI/SitemapRowInput.swift @@ -82,9 +82,13 @@ extension SitemapRowInput { return .segmented(rowID, input.withWidgetVersion(version)) case let .media(rowID, input): return .media(rowID, input.withWidgetVersion(version)) + case let .text(rowID, input): + return .text(rowID, input.withWidgetVersion(version)) + case let .linked(rowID, input): + return .linked(rowID, input.withWidgetVersion(version)) + case let .generic(rowID, input): + return .generic(rowID, input.withWidgetVersion(version)) case .frame, - .linked, - .text, .setpoint, .rollershutter, .toggle, diff --git a/openHAB/UI/SwiftUI/WidgetRowInputs.swift b/openHAB/UI/SwiftUI/WidgetRowInputs.swift index 735d55912..62ca08b84 100644 --- a/openHAB/UI/SwiftUI/WidgetRowInputs.swift +++ b/openHAB/UI/SwiftUI/WidgetRowInputs.swift @@ -360,6 +360,7 @@ struct ButtonGridRowInput: Equatable, RowWithIconInput { struct GenericRowInput: Equatable, RowWithIconInput { let widgetId: String + let widgetVersion: Int let displayState: WidgetDisplayState let labelColor: String let valueColor: String @@ -368,16 +369,29 @@ struct GenericRowInput: Equatable, RowWithIconInput { static func from(widget: OpenHABWidget) -> GenericRowInput { GenericRowInput( widgetId: widget.widgetId, + widgetVersion: 0, displayState: widget.displayState, labelColor: widget.labelcolor, valueColor: widget.valuecolor, icon: RowIconInput.from(widget: widget) ) } + + func withWidgetVersion(_ widgetVersion: Int) -> GenericRowInput { + GenericRowInput( + widgetId: widgetId, + widgetVersion: widgetVersion, + displayState: displayState, + labelColor: labelColor, + valueColor: valueColor, + icon: icon + ) + } } struct LinkedPageRowInput: Equatable, RowWithIconInput { let widgetId: String + let widgetVersion: Int let displayState: WidgetDisplayState let labelColor: String let valueColor: String @@ -389,6 +403,7 @@ struct LinkedPageRowInput: Equatable, RowWithIconInput { guard let linkedPage = widget.linkedPage else { return nil } return LinkedPageRowInput( widgetId: widget.widgetId, + widgetVersion: 0, displayState: widget.displayState, labelColor: widget.labelcolor, valueColor: widget.valuecolor, @@ -400,6 +415,19 @@ struct LinkedPageRowInput: Equatable, RowWithIconInput { isFrame: widget.type == .frame ) } + + func withWidgetVersion(_ widgetVersion: Int) -> LinkedPageRowInput { + LinkedPageRowInput( + widgetId: widgetId, + widgetVersion: widgetVersion, + displayState: displayState, + labelColor: labelColor, + valueColor: valueColor, + icon: icon, + destination: destination, + isFrame: isFrame + ) + } } struct ColorTemperatureRowInput: Equatable, RowWithIconInput { @@ -529,6 +557,7 @@ struct FrameRowInput: Equatable { struct TextRowInput: Equatable, RowWithIconInput { let widgetId: String + let widgetVersion: Int let displayState: WidgetDisplayState let labelColor: String let valueColor: String @@ -537,12 +566,24 @@ struct TextRowInput: Equatable, RowWithIconInput { static func from(widget: OpenHABWidget) -> TextRowInput { TextRowInput( widgetId: widget.widgetId, + widgetVersion: 0, displayState: widget.displayState, labelColor: widget.labelcolor, valueColor: widget.valuecolor, icon: RowIconInput.from(widget: widget) ) } + + func withWidgetVersion(_ widgetVersion: Int) -> TextRowInput { + TextRowInput( + widgetId: widgetId, + widgetVersion: widgetVersion, + displayState: displayState, + labelColor: labelColor, + valueColor: valueColor, + icon: icon + ) + } } struct SliderRowInput: Equatable, RowWithIconInput { From 009d8c8c678b8c12cf65842df34475b7df0e499c Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Fri, 17 Apr 2026 20:29:21 +0200 Subject: [PATCH 10/11] Fix missing widgetVersion in MediaRowView fallback and SitemapRowInput syntax Signed-off-by: Tim Mueller-Seydlitz --- openHAB/UI/SwiftUI/Rows/MediaRowView.swift | 1 + openHAB/UI/SwiftUI/SitemapRowInput.swift | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openHAB/UI/SwiftUI/Rows/MediaRowView.swift b/openHAB/UI/SwiftUI/Rows/MediaRowView.swift index b2389f904..12e889e61 100644 --- a/openHAB/UI/SwiftUI/Rows/MediaRowView.swift +++ b/openHAB/UI/SwiftUI/Rows/MediaRowView.swift @@ -18,6 +18,7 @@ struct MediaRowView: View { private var genericFallbackInput: GenericRowInput { GenericRowInput( widgetId: input.widgetId, + widgetVersion: input.widgetVersion, displayState: input.displayState, labelColor: input.labelColor, valueColor: input.valueColor, diff --git a/openHAB/UI/SwiftUI/SitemapRowInput.swift b/openHAB/UI/SwiftUI/SitemapRowInput.swift index ba27090a6..43eb84533 100644 --- a/openHAB/UI/SwiftUI/SitemapRowInput.swift +++ b/openHAB/UI/SwiftUI/SitemapRowInput.swift @@ -95,8 +95,7 @@ extension SitemapRowInput { .input, .colorPicker, .colorTemperature, - .buttonGrid, - .generic: + .buttonGrid: return self } } From 4e702a8e8b42d4ad3b519b9d9ab705f8bb73ab78 Mon Sep 17 00:00:00 2001 From: Tim Mueller-Seydlitz Date: Sat, 18 Apr 2026 08:05:24 +0200 Subject: [PATCH 11/11] Fix stale rows on scroll by subscribing EmbeddingRowInputView to view model Off-screen list rows rendered stale data when scrolled into view after a foreground refresh. SwiftUI reuses cached renders for views with no observable dependencies. Adding @EnvironmentObject to EmbeddingRowInputView ensures every row is re-evaluated when rowInputs change, covering all row types at once. Signed-off-by: Tim Mueller-Seydlitz --- openHAB/UI/SwiftUI/EmbeddingRowInputView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift b/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift index ac15f8c62..f59d7b792 100644 --- a/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift +++ b/openHAB/UI/SwiftUI/EmbeddingRowInputView.swift @@ -116,6 +116,11 @@ private struct LinkedPageRowContent: View { struct EmbeddingRowInputView: View { let rowInput: SitemapRowInput + // Subscribes to the view model so SwiftUI re-evaluates this view when + // rowInputs change — without this, rows that scroll into view after a + // foreground refresh may render stale data from a cached render. + @EnvironmentObject private var viewModel: SitemapPageViewModel + private var regularRowBackground: Color { Color(UIColor.ohSecondarySystemGroupedBackground) }