From 69d155e08c11b9f9f45164242e17310bfb6e4224 Mon Sep 17 00:00:00 2001 From: itouuuuuuuuu <20165240+itouuuuuuuuu@users.noreply.github.com> Date: Sun, 10 May 2026 01:41:04 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E3=83=A6=E3=83=BC=E3=82=B6=E8=BE=9E?= =?UTF-8?q?=E6=9B=B8=E7=B7=A8=E9=9B=86=E5=BE=8C=E3=81=AB=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E3=81=AE=E4=BB=B6=E6=95=B0=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E3=82=92=E6=9B=B4=E6=96=B0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設定画面に表示される "N件のアイテム" のカウントが、ユーザ辞書編集ウィンドウで 項目を追加・削除しても古いまま残るバグを修正する。 原因: @ConfigState は内部で @State (underlyingState) を保持していたが、ConfigWindow と UserDictionaryEditorWindow はそれぞれ独立した @ConfigState インスタンスを持つ。 編集ウィンドウは self.userDictionary.value.items.append(...) のように Binding を 経由しない直接 mutation で UserDefaults だけを更新しており、別ウィンドウである ConfigWindow の @State は同期されない。ConfigWindow には body 再評価のトリガーが ないため、件数表示が更新されなかった。 修正: 1. ConfigState を @StateObject + ConfigStateStore (ObservableObject) に置き換え、 UserDefaults.didChangeNotification を購読してプロセス内のあらゆる UserDefaults 更新で store.value を reload する。これにより別ウィンドウからの書き込みでも View の再描画がトリガーされる。 2. ConfigStateStore は SwiftUI-facing な UI state を持つため @MainActor で隔離。 通知 callback は queue: .main で main thread に乗るが、型レベル isolation の ため Task { @MainActor in ... } で hop する (MainActor.assumeIsolated は macOS 14+ のため未採用、deployment target は macOS 12/13)。 3. UserDictionaryEditorWindow の追加・削除・編集処理を updateUserDictionary ヘルパに集約し、必ず $userDictionary (Binding) 経由で書き込むようにした。 store と UserDefaults が同期更新され、同一 view 内の即時反映も担保される。 4. 同 view の read 経路も userDictionaryValue computed property (= $userDictionary.wrappedValue) に統一し、表示は store / 保存は Binding setter という構図を明確化。Item.value (毎回 UserDefaults を JSON decode する computed property) 経由の参照を排除した。 Co-Authored-By: Claude Opus 4.7 (1M context) --- azooKeyMac/Windows/ConfigState.swift | 75 ++++++++++++++++--- .../Windows/UserDictionaryEditorWindow.swift | 51 +++++++++---- 2 files changed, 99 insertions(+), 27 deletions(-) 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)