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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 63 additions & 12 deletions azooKeyMac/Windows/ConfigState.swift
Original file line number Diff line number Diff line change
@@ -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<Item: ConfigItem>: DynamicProperty {
@State private var underlyingState: Item.Value
@StateObject private var store: ConfigStateStore<Item>

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<Item.Value> {
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<Item: ConfigItem>: 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
}
}
51 changes: 36 additions & 15 deletions azooKeyMac/Windows/UserDictionaryEditorWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
}
)
Expand All @@ -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
}
Expand All @@ -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
}
}
Expand All @@ -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") {
Expand All @@ -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)
Expand Down
Loading