diff --git a/azooKeyMac/Windows/ConfigState.swift b/azooKeyMac/Windows/ConfigState.swift index 421635aa..4ce1246b 100644 --- a/azooKeyMac/Windows/ConfigState.swift +++ b/azooKeyMac/Windows/ConfigState.swift @@ -1,26 +1,77 @@ +import Combine import Core +import Foundation import SwiftUI -/// Wrapper of `State` for SwiftUI. By using this wrapper, you can update config and immediately get the view update. +/// Wrapper of `State` for SwiftUI. By using this wrapper, you can update config and immediately +/// get the view update. The wrapper also subscribes to `UserDefaults.didChangeNotification`, +/// so a config update made from another window — or via a direct `wrappedValue.value` mutation — +/// re-renders any view that holds a `ConfigState` for that item. @propertyWrapper struct ConfigState: DynamicProperty { - @State private var underlyingState: Item.Value + @StateObject private var store: ConfigStateStore + + private let item: Item init(wrappedValue: Item) { - self._underlyingState = .init(initialValue: wrappedValue.value) - self.wrappedValue = wrappedValue + self.item = wrappedValue + self._store = StateObject(wrappedValue: ConfigStateStore(item: wrappedValue)) + } + + var wrappedValue: Item { + self.item } - var wrappedValue: Item var projectedValue: Binding { Binding( - get: { - self.underlyingState - }, - set: { - self.underlyingState = $0 - self.wrappedValue.value = $0 - } + get: { self.store.value }, + set: { self.store.set($0) } ) } } + +/// Backing store for `ConfigState`. Observes `UserDefaults.didChangeNotification` so +/// out-of-band writes (other windows, direct `Item.value` mutation) still notify SwiftUI. +/// +/// `@MainActor`-isolated because it owns SwiftUI-facing `@Published` state. Observer +/// callbacks are scheduled on `.main` already; we still hop through a `Task { @MainActor }` +/// for type-level isolation since `MainActor.assumeIsolated` requires macOS 14+. +@MainActor +private final class ConfigStateStore: ObservableObject { + @Published private(set) var value: Item.Value + + private let item: Item + private var observer: NSObjectProtocol? + + init(item: Item) { + self.item = item + self.value = item.value + + // `didChangeNotification` does not carry the changed key, so we reload regardless + // and rely on SwiftUI / @Published to coalesce updates per run loop tick. + self.observer = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: UserDefaults.standard, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.reload() + } + } + } + + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + func set(_ newValue: Item.Value) { + self.value = newValue + self.item.value = newValue + } + + private func reload() { + self.value = self.item.value + } +} diff --git a/azooKeyMac/Windows/UserDictionaryEditorWindow.swift b/azooKeyMac/Windows/UserDictionaryEditorWindow.swift index 22ba337e..cddbc6d0 100644 --- a/azooKeyMac/Windows/UserDictionaryEditorWindow.swift +++ b/azooKeyMac/Windows/UserDictionaryEditorWindow.swift @@ -29,8 +29,25 @@ struct UserDictionaryEditorWindow: View { } } + /// Read the current user dictionary value through the `@ConfigState` binding (i.e. the + /// in-memory store) instead of `userDictionary.value` (which decodes from UserDefaults + /// on every access). Writes still go through `updateUserDictionary` below. + private var userDictionaryValue: Config.UserDictionary.Value { + self.$userDictionary.wrappedValue + } + private var isAdditionDisabled: Bool { - self.userDictionary.value.items.count >= 50 + self.userDictionaryValue.items.count >= 50 + } + + /// Mutate the user dictionary through the `@ConfigState` binding so the backing store and + /// any other window observing the same item are kept in sync. Direct `userDictionary.value` + /// mutation only writes to UserDefaults and bypasses the store, which left "x件のアイテム" + /// counts stale in other views (see fix/user-dictionary-count-update). + private func updateUserDictionary(_ transform: (inout Config.UserDictionary.Value) -> Void) { + var value = self.$userDictionary.wrappedValue + transform(&value) + self.$userDictionary.wrappedValue = value } var body: some View { @@ -44,15 +61,15 @@ struct UserDictionaryEditorWindow: View { if let editTargetID { let itemBinding = Binding( get: { - self.userDictionary.value.items.first { + self.userDictionaryValue.items.first { $0.id == editTargetID } ?? .init(word: "", reading: "") }, - set: { - if let index = self.userDictionary.value.items.firstIndex(where: { - $0.id == editTargetID - }) { - self.userDictionary.value.items[index] = $0 + set: { newItem in + self.updateUserDictionary { value in + if let index = value.items.firstIndex(where: { $0.id == editTargetID }) { + value.items[index] = newItem + } } } ) @@ -73,7 +90,9 @@ struct UserDictionaryEditorWindow: View { Spacer() Button("追加", systemImage: "plus") { let newItem = Config.UserDictionaryEntry(word: "", reading: "", hint: nil) - self.userDictionary.value.items.append(newItem) + self.updateUserDictionary { value in + value.items.append(newItem) + } self.editTargetID = newItem.id self.undoItem = nil } @@ -84,7 +103,9 @@ struct UserDictionaryEditorWindow: View { } if let undoItem { Button("元に戻す", systemImage: "arrow.uturn.backward") { - self.userDictionary.value.items.append(undoItem) + self.updateUserDictionary { value in + value.items.append(undoItem) + } self.undoItem = nil } } @@ -93,7 +114,7 @@ struct UserDictionaryEditorWindow: View { } HStack { Spacer() - Table(self.userDictionary.value.items) { + Table(self.userDictionaryValue.items) { TableColumn("") { item in HStack { Button("編集する", systemImage: "pencil") { @@ -103,11 +124,11 @@ struct UserDictionaryEditorWindow: View { .buttonStyle(.bordered) .labelStyle(.iconOnly) Button("削除する", systemImage: "trash", role: .destructive) { - if let itemIndex = self.userDictionary.value.items.firstIndex(where: { - $0.id == item.id - }) { - self.undoItem = self.userDictionary.value.items[itemIndex] - self.userDictionary.value.items.remove(at: itemIndex) + self.updateUserDictionary { value in + if let itemIndex = value.items.firstIndex(where: { $0.id == item.id }) { + self.undoItem = value.items[itemIndex] + value.items.remove(at: itemIndex) + } } } .buttonStyle(.bordered)