diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index a1aaf54..146090e 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -8,19 +8,25 @@ /* Begin PBXBuildFile section */ 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; + 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; }; 4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; }; 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; }; 68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; }; + A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; }; B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; + C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; }; EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 45BD2DF1B775C733C8F75635 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 508DECB5C05264E6130C373E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 50A018673EE6BD9C50F1EFA0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 821B742C211265914B6CC44B /* OnyxKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OnyxKit; path = Packages/OnyxKit; sourceTree = SOURCE_ROOT; }; 87597F09E971C14CF1E475DB /* Onyx.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Onyx.entitlements; sourceTree = ""; }; + B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderWidgets.swift; sourceTree = ""; }; F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -77,10 +83,13 @@ isa = PBXGroup; children = ( 0FBA13B97C53F1D0D740A633 /* Resources */, + B83C4E3CB821F903F3977E88 /* AppModel.swift */, 50A018673EE6BD9C50F1EFA0 /* Info.plist */, + 508DECB5C05264E6130C373E /* Localizable.xcstrings */, 87597F09E971C14CF1E475DB /* Onyx.entitlements */, F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */, CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */, + 45BD2DF1B775C733C8F75635 /* SettingsView.swift */, ); path = Onyx; sourceTree = ""; @@ -93,6 +102,7 @@ buildConfigurationList = C126F7CB2B14EC196CE8743D /* Build configuration list for PBXNativeTarget "Onyx" */; buildPhases = ( 6B11A5AC704B6429B9484789 /* Sources */, + B211CE3C0EF94A8D680E4AE0 /* Resources */, 7FF994FE5530D53BEFBE13FE /* Frameworks */, ); buildRules = ( @@ -131,6 +141,7 @@ hasScannedForEncodings = 0; knownRegions = ( Base, + de, en, ); mainGroup = 40AB0784F956D00B4E1362FB; @@ -148,13 +159,26 @@ }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + B211CE3C0EF94A8D680E4AE0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 6B11A5AC704B6429B9484789 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C041F171FB69F4381309D978 /* AppModel.swift in Sources */, 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */, 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */, + 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Onyx/AppModel.swift b/Onyx/AppModel.swift new file mode 100644 index 0000000..0d91b83 --- /dev/null +++ b/Onyx/AppModel.swift @@ -0,0 +1,98 @@ +import SwiftUI +import OnyxNotch +import OnyxWidgetKit + +/// Der geteilte Zustand von Onyx: was im Panel liegt und auf welchen Displays. +/// +/// Änderungen wirken sofort. Eine Einstellung, die erst nach einem Neustart +/// greift, ist in einer App ohne Fenster praktisch unauffindbar — man sieht ja +/// nicht, dass etwas passiert ist. +@MainActor +@Observable +final class AppModel { + + private let store: LayoutStore + private let defaults: UserDefaults + + /// Wird gerufen, wenn sich die Panelgröße geändert haben könnte. + var onLayoutChanged: (([WidgetPlacement]) -> Void)? + /// Wird gerufen, wenn Panels auf anderen Displays erscheinen sollen. + var onDisplayPolicyChanged: ((DisplayPolicy) -> Void)? + + var layout: [WidgetPlacement] { + didSet { + guard layout != oldValue else { return } + persistLayout() + onLayoutChanged?(layout) + } + } + + var displayPolicy: DisplayPolicy { + didSet { + guard displayPolicy != oldValue else { return } + defaults.set(displayPolicy.rawValue, forKey: Keys.displayPolicy) + onDisplayPolicyChanged?(displayPolicy) + } + } + + var showsMenuBarIcon: Bool { + didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) } + } + + private enum Keys { + static let displayPolicy = "onyx.displayPolicy" + static let showsMenuBarIcon = "onyx.showsMenuBarIcon" + } + + init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) { + self.store = store + self.defaults = defaults + + let known = Set(WidgetRegistry.shared.all.map(\.id)) + self.layout = store.load(knownWidgetIDs: known) + + self.displayPolicy = defaults.string(forKey: Keys.displayPolicy) + .flatMap(DisplayPolicy.init(rawValue:)) ?? .builtInOnly + + // Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg + // zurück, wenn die Notch einmal nicht reagiert. + self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true + } + + // MARK: - Widgets + + var panelContentSize: CGSize { WidgetGrid.panelSize(for: layout) } + + func isInPanel(_ widgetID: String) -> Bool { + layout.contains { $0.widgetID == widgetID } + } + + func add(_ widget: any OnyxWidget) { + guard !isInPanel(widget.id) else { return } + let size = widget.supportedSizes.first ?? .small + layout.append(WidgetPlacement(widgetID: widget.id, size: size)) + } + + func remove(_ widgetID: String) { + layout.removeAll { $0.widgetID == widgetID } + } + + func setSize(_ size: WidgetSize, for widgetID: String) { + guard let index = layout.firstIndex(where: { $0.widgetID == widgetID }) else { return } + layout[index].size = size + } + + func move(fromOffsets source: IndexSet, toOffset destination: Int) { + layout.move(fromOffsets: source, toOffset: destination) + } + + private func persistLayout() { + do { + try store.save(layout) + } catch { + // Ein fehlgeschlagener Schreibvorgang darf die App nicht anhalten; + // beim nächsten Start greift dann das Standardlayout. + NSLog("Onyx: Layout konnte nicht gespeichert werden — \(error.localizedDescription)") + } + } +} diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings new file mode 100644 index 0000000..aa4644a --- /dev/null +++ b/Onyx/Localizable.xcstrings @@ -0,0 +1,135 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "menu.openPanel" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Panel öffnen" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Open Panel" } } + } + }, + "menu.settings" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Einstellungen …" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Settings…" } } + } + }, + "menu.quit" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Onyx beenden" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Quit Onyx" } } + } + }, + + "settings.tab.widgets" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Widgets" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Widgets" } } + } + }, + "settings.tab.display" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Anzeige" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Display" } } + } + }, + "settings.widgets.hint" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." } } + } + }, + "settings.widgets.inPanel" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Im Panel" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "In the panel" } } + } + }, + "settings.widgets.available" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Verfügbar" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Available" } } + } + }, + "settings.widgets.empty" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "No widgets in the panel. It stays empty until you add one." } } + } + }, + "settings.widgets.add" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Hinzufügen" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Add" } } + } + }, + "settings.widgets.remove" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Aus dem Panel entfernen" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Remove from panel" } } + } + }, + + "settings.size.small" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Klein (1×1)" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Small (1×1)" } } + } + }, + "settings.size.medium" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Mittel (2×1)" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Medium (2×1)" } } + } + }, + "settings.size.large" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Groß (2×2)" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Large (2×2)" } } + } + }, + "settings.size.wide" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Breit (4×1)" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Wide (4×1)" } } + } + }, + + "settings.display.policy" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Panel anzeigen auf" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Show panel on" } } + } + }, + "settings.display.builtInOnly" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Nur dem internen Display" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Built-in display only" } } + } + }, + "settings.display.all" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Allen Displays" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "All displays" } } + } + }, + "settings.display.hint" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable." } } + } + }, + "settings.display.menuBarIcon" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Symbol in der Menüleiste anzeigen" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Show icon in the menu bar" } } + } + }, + "settings.display.menuBarIcon.hint" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx." } } + } + } + }, + "version" : "1.0" +} diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 5e54b93..5d77b93 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -11,21 +11,29 @@ struct OnyxApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate var body: some Scene { - // Onyx hat kein Hauptfenster. Alles lebt im Notch-Panel und in der - // Menüleiste; die Einstellungen kommen in Phase 2 als eigenes Fenster. - Settings { EmptyView() } + // Onyx hat kein Hauptfenster — alles lebt in der Notch und in der + // Menüleiste. Die Einstellungen sind die einzige Ausnahme; im Panel + // wäre dafür zu wenig Platz. + Settings { + if let model = delegate.model { + SettingsView(model: model) + } else { + // Nur sichtbar, wenn die Einzelinstanz-Prüfung uns abgewiesen + // hat und die App gerade wieder verschwindet. + ProgressView().frame(width: 520, height: 420) + } + } } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { + private(set) var model: AppModel? private var coordinator: NotchCoordinator? private var menuBar: MenuBarController? private var onyxItem: NSStatusItem? - private var layout: [WidgetPlacement] = [] - func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) @@ -39,29 +47,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } PlaceholderWidgets.registerAll() - let known = Set(WidgetRegistry.shared.all.map(\.id)) - layout = LayoutStore.standard().load(knownWidgetIDs: known) - let placements = layout - let coordinator = NotchCoordinator(policy: .builtInOnly) { _, presentation in + let model = AppModel() + self.model = model + + let coordinator = NotchCoordinator(policy: model.displayPolicy) { _, presentation in NotchHostingView( rootView: NotchContainer(presentation: presentation) { - WidgetGrid(placements: placements) + NotchPanelContent(model: model) }, presentation: presentation) } coordinator.start() - // Die Panelgröße folgt dem Layout, nicht umgekehrt: wer ein Widget - // hinzufügt, soll nicht auch noch die Fenstergröße nachziehen müssen. - let size = WidgetGrid.panelSize(for: layout) - coordinator.activeControllers.forEach { $0.contentSize = size } self.coordinator = coordinator + applyContentSize() + + // Einstellungen wirken sofort. Eine Änderung, die erst nach einem + // Neustart greift, ist in einer App ohne Fenster praktisch unauffindbar. + model.onLayoutChanged = { [weak self] _ in self?.applyContentSize() } + model.onDisplayPolicyChanged = { [weak self] policy in + self?.coordinator?.policy = policy + self?.applyContentSize() + } let menuBar = MenuBarController() menuBar.start() self.menuBar = menuBar installOnyxStatusItem() + observeMenuBarPreference() } func applicationWillTerminate(_ notification: Notification) { @@ -69,6 +83,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menuBar?.stop() } + private func applyContentSize() { + guard let model, let coordinator else { return } + let size = model.panelContentSize + coordinator.activeControllers.forEach { $0.contentSize = size } + } + + // MARK: - Menüleiste + /// Das feste Onyx-Element: Panel öffnen, Einstellungen, Beenden. Es ist auch /// das Sicherheitsnetz, falls die Notch einmal nicht reagiert. private func installOnyxStatusItem() { @@ -79,19 +101,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate { item.button?.image?.isTemplate = true let menu = NSMenu() - menu.addItem(withTitle: "Panel öffnen", action: #selector(togglePanel), keyEquivalent: "") - .target = self + menu.addItem(withTitle: String(localized: "menu.openPanel"), + action: #selector(togglePanel), keyEquivalent: "").target = self menu.addItem(.separator()) - menu.addItem(withTitle: "Einstellungen …", action: #selector(openSettings), keyEquivalent: ",") - .target = self + menu.addItem(withTitle: String(localized: "menu.settings"), + action: #selector(openSettings), keyEquivalent: ",").target = self menu.addItem(.separator()) - menu.addItem(withTitle: "Onyx beenden", action: #selector(quit), keyEquivalent: "q") - .target = self + menu.addItem(withTitle: String(localized: "menu.quit"), + action: #selector(quit), keyEquivalent: "q").target = self item.menu = menu onyxItem = item } + private func observeMenuBarPreference() { + guard let model else { return } + withObservationTracking { + _ = model.showsMenuBarIcon + } onChange: { + Task { @MainActor [weak self] in + self?.onyxItem?.isVisible = model.showsMenuBarIcon + self?.observeMenuBarPreference() + } + } + onyxItem?.isVisible = model.showsMenuBarIcon + } + @objc private func togglePanel() { coordinator?.togglePanelUnderPointer() } @objc private func openSettings() { @@ -102,3 +137,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func quit() { NSApp.terminate(nil) } } +/// Der Inhalt des Panels. Liest `model.layout` und zeichnet sich damit neu, +/// sobald in den Einstellungen etwas geändert wird. +private struct NotchPanelContent: View { + let model: AppModel + + var body: some View { + WidgetGrid(placements: model.layout) + } +} diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift new file mode 100644 index 0000000..e71eb47 --- /dev/null +++ b/Onyx/SettingsView.swift @@ -0,0 +1,143 @@ +import SwiftUI +import OnyxDesign +import OnyxNotch +import OnyxWidgetKit + +struct SettingsView: View { + @Bindable var model: AppModel + + var body: some View { + TabView { + WidgetSettings(model: model) + .tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") } + DisplaySettings(model: model) + .tabItem { Label("settings.tab.display", systemImage: "macbook") } + } + .frame(width: 520, height: 420) + } +} + +// MARK: - Widgets + +private struct WidgetSettings: View { + @Bindable var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("settings.widgets.hint") + .font(.callout) + .foregroundStyle(.secondary) + + List { + Section("settings.widgets.inPanel") { + // Die Reihenfolge hier ist die Reihenfolge im Panel: die + // Layout-Engine setzt die Widgets von oben links der Reihe + // nach ein. + ForEach(model.layout) { placement in + if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) { + PlacementRow(model: model, placement: placement, widget: widget) + } + } + .onMove { model.move(fromOffsets: $0, toOffset: $1) } + + if model.layout.isEmpty { + Text("settings.widgets.empty") + .foregroundStyle(.secondary) + } + } + + let available = WidgetRegistry.shared.all.filter { !model.isInPanel($0.id) } + if !available.isEmpty { + Section("settings.widgets.available") { + ForEach(available, id: \.id) { widget in + HStack { + Label(widget.displayName, systemImage: widget.symbolName) + Spacer() + Button("settings.widgets.add") { model.add(widget) } + .buttonStyle(.borderless) + } + } + } + } + } + .listStyle(.inset) + } + .padding() + } +} + +private struct PlacementRow: View { + @Bindable var model: AppModel + let placement: WidgetPlacement + let widget: any OnyxWidget + + var body: some View { + HStack { + Label(widget.displayName, systemImage: widget.symbolName) + + Spacer() + + // Nur die Größen anbieten, die das Widget auch sinnvoll ausfüllt. + // Ein Mini-Monat in 1×1 wäre unleserlich. + Picker("", selection: Binding( + get: { placement.size }, + set: { model.setSize($0, for: placement.widgetID) })) { + ForEach(widget.supportedSizes) { size in + Text(size.localizedName).tag(size) + } + } + .labelsHidden() + .frame(width: 130) + + Button { + model.remove(placement.widgetID) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help("settings.widgets.remove") + } + } +} + +// MARK: - Anzeige + +private struct DisplaySettings: View { + @Bindable var model: AppModel + + var body: some View { + Form { + Picker("settings.display.policy", selection: $model.displayPolicy) { + Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly) + Text("settings.display.all").tag(DisplayPolicy.allDisplays) + } + .pickerStyle(.radioGroup) + + Text("settings.display.hint") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Divider() + + Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon) + Text("settings.display.menuBarIcon.hint") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .formStyle(.grouped) + .padding() + } +} + +extension WidgetSize { + var localizedName: LocalizedStringKey { + switch self { + case .small: "settings.size.small" + case .medium: "settings.size.medium" + case .large: "settings.size.large" + case .wide: "settings.size.wide" + } + } +} diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index e91d4e6..683f42b 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -3,6 +3,9 @@ import PackageDescription let package = Package( name: "OnyxKit", + // Quellsprache ist Englisch, damit die Schlüssel im Code lesbar bleiben. + // Deutsch kommt als Übersetzung dazu; die Systemsprache entscheidet. + defaultLocalization: "en", platforms: [.macOS(.v26)], products: [ .library(name: "OnyxCore", targets: ["OnyxCore"]), @@ -12,7 +15,10 @@ let package = Package( .library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]), ], targets: [ - .target(name: "OnyxCore"), + // Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt + // es kein `Bundle.module`, und die Übersetzungen wären zur Laufzeit + // unauffindbar. + .target(name: "OnyxCore", resources: [.process("Localizable.xcstrings")]), .testTarget(name: "OnyxCoreTests", dependencies: ["OnyxCore"]), .target(name: "OnyxDesign"), diff --git a/Packages/OnyxKit/Sources/OnyxCore/Localizable.xcstrings b/Packages/OnyxKit/Sources/OnyxCore/Localizable.xcstrings new file mode 100644 index 0000000..b90b175 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxCore/Localizable.xcstrings @@ -0,0 +1,56 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "singleInstance.blocked.message" : { + "comment" : "Titel des Dialogs, wenn eine zweite Instanz zurücktritt", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx läuft bereits" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx is already running" + } + } + } + }, + "singleInstance.blocked.informative" : { + "comment" : "Erklärung samt Hinweis auf Xcode. %@ ist der Pfad der laufenden Instanz.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine andere Onyx-Instanz läuft und ließ sich nicht beenden. Diese Instanz wird deshalb nicht gestartet, damit nicht zwei Panels gleichzeitig auf der Notch arbeiten.\n\nLäuft sie aus Xcode, beende sie dort mit ⌘. und starte erneut.\n\n%@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Another Onyx instance is running and could not be quit. This instance will not start, so that two panels don't fight over the same notch.\n\nIf it was launched from Xcode, stop it there with ⌘. and try again.\n\n%@" + } + } + } + }, + "common.ok" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Packages/OnyxKit/Sources/OnyxCore/SingleInstance.swift b/Packages/OnyxKit/Sources/OnyxCore/SingleInstance.swift index 957cf84..6babbaa 100644 --- a/Packages/OnyxKit/Sources/OnyxCore/SingleInstance.swift +++ b/Packages/OnyxKit/Sources/OnyxCore/SingleInstance.swift @@ -72,17 +72,12 @@ public enum SingleInstance { let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "Onyx läuft bereits" - alert.informativeText = """ - Eine andere Onyx-Instanz läuft und ließ sich nicht beenden. \ - Diese Instanz wird deshalb nicht gestartet, damit nicht zwei Panels \ - gleichzeitig auf der Notch arbeiten. - - Läuft sie aus Xcode, beende sie dort mit ⌘. und starte erneut. - - \(paths) - """ - alert.addButton(withTitle: "OK") + alert.messageText = String(localized: "singleInstance.blocked.message", + bundle: .module) + alert.informativeText = String( + format: String(localized: "singleInstance.blocked.informative", bundle: .module), + paths) + alert.addButton(withTitle: String(localized: "common.ok", bundle: .module)) NSApp.activate(ignoringOtherApps: true) alert.runModal() }