diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 19e1e5c..c00b10c 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 378A5A417112CCB31EF3F774 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; 4E729C7A6498B4679C8D2C42 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; + 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; }; 68EE5495435A82E20248EBC2 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; }; EA4D6312E8A96E177F10797F /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; @@ -19,6 +20,7 @@ 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 = ""; }; + 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 */ @@ -76,6 +78,7 @@ 50A018673EE6BD9C50F1EFA0 /* Info.plist */, 87597F09E971C14CF1E475DB /* Onyx.entitlements */, F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */, + CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */, ); path = Onyx; sourceTree = ""; @@ -148,6 +151,7 @@ buildActionMask = 2147483647; files = ( 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */, + 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 41a255f..371e1cb 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -3,6 +3,7 @@ import AppKit import OnyxDesign import OnyxNotch import OnyxMenuBar +import OnyxWidgetKit @main struct OnyxApp: App { @@ -22,17 +23,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var menuBar: MenuBarController? private var onyxItem: NSStatusItem? + private var layout: [WidgetPlacement] = [] + func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) - let coordinator = NotchCoordinator(policy: .builtInOnly) { displayID, presentation in + 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 NotchHostingView( rootView: NotchContainer(presentation: presentation) { - NotchPanelContent(displayID: displayID) + WidgetGrid(placements: placements) }, 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.expandedSize = size } self.coordinator = coordinator let menuBar = MenuBarController() @@ -80,35 +92,3 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func quit() { NSApp.terminate(nil) } } -/// Platzhalterinhalt für Phase 1. Er beweist, dass Fenster, Geometrie und -/// Zustandsmaschine zusammenspielen; die Widget-Ebene kommt in Phase 2. -private struct NotchPanelContent: View { - let displayID: CGDirectDisplayID - - var body: some View { - ZStack { - OnyxSurface() - - VStack(alignment: .leading, spacing: Onyx.Metric.tileSpacing) { - Text("Onyx") - .font(Onyx.Font.title) - .foregroundStyle(Onyx.Color.textPrimary) - - Text("Phase 1 — Notch-Shell steht.") - .font(Onyx.Font.body) - .foregroundStyle(Onyx.Color.textSecondary) - - HStack(spacing: Onyx.Metric.tileSpacing) { - ForEach(["Kalender", "Wetter", "Medien"], id: \.self) { name in - OnyxTile { - Text(name) - .font(Onyx.Font.caption) - .foregroundStyle(Onyx.Color.textTertiary) - } - } - } - } - .padding(Onyx.Metric.panelPadding) - } - } -} diff --git a/Onyx/PlaceholderWidgets.swift b/Onyx/PlaceholderWidgets.swift new file mode 100644 index 0000000..c7dbf52 --- /dev/null +++ b/Onyx/PlaceholderWidgets.swift @@ -0,0 +1,73 @@ +import SwiftUI +import OnyxDesign +import OnyxWidgetKit + +/// Vorläufige Widgets für Phase 2. +/// +/// Sie tragen keine echten Daten — ihr Zweck ist zu belegen, dass Registry, +/// Layout-Engine, Persistenz und Panelgröße zusammenspielen, bevor die Provider +/// dazukommen. Jedes wird in seiner Phase durch das echte Widget ersetzt; die +/// Kennungen bleiben dabei gleich, damit gespeicherte Layouts weitergelten. +struct PlaceholderWidget: OnyxWidget { + let id: String + let displayName: String + let symbolName: String + let supportedSizes: [WidgetSize] + /// In welcher Phase dieses Widget echt wird. + let phase: String + + func makeView(size: WidgetSize) -> AnyView { + AnyView( + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: symbolName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Onyx.Color.accent) + Text(displayName) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) + } + Spacer(minLength: 0) + Text(phase) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textTertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + ) + } +} + +@MainActor +enum PlaceholderWidgets { + static func registerAll() { + let widgets: [PlaceholderWidget] = [ + .init(id: "calendar", displayName: "Kalender", symbolName: "calendar", + supportedSizes: [.medium, .large], phase: "Phase 3"), + .init(id: "weather", displayName: "Wetter", symbolName: "cloud.sun", + supportedSizes: [.small, .medium], phase: "Phase 3"), + .init(id: "media", displayName: "Medien", symbolName: "play.circle", + supportedSizes: [.medium, .wide], phase: "Phase 4"), + .init(id: "cpu", displayName: "CPU", symbolName: "cpu", + supportedSizes: [.small, .medium], phase: "Phase 5"), + .init(id: "gpu", displayName: "GPU", symbolName: "cpu.fill", + supportedSizes: [.small, .medium], phase: "Phase 5"), + .init(id: "memory", displayName: "Speicher", symbolName: "memorychip", + supportedSizes: [.small, .medium], phase: "Phase 5"), + .init(id: "battery", displayName: "Akku", symbolName: "battery.100", + supportedSizes: [.small, .medium], phase: "Phase 5"), + .init(id: "sensors", displayName: "Sensoren", symbolName: "thermometer", + supportedSizes: [.medium, .large], phase: "Phase 5"), + .init(id: "network", displayName: "Netzwerk", symbolName: "network", + supportedSizes: [.small, .medium, .large], phase: "Phase 5"), + .init(id: "fans", displayName: "Lüfter", symbolName: "fan", + supportedSizes: [.small, .medium], phase: "Phase 6"), + .init(id: "audio", displayName: "Audio-Mixer", symbolName: "slider.horizontal.3", + supportedSizes: [.medium, .large], phase: "Phase 7"), + .init(id: "shelf", displayName: "Ablage", symbolName: "tray.full", + supportedSizes: [.small, .medium], phase: "Phase 8"), + .init(id: "airdrop", displayName: "AirDrop", symbolName: "shareplay", + supportedSizes: [.small], phase: "Phase 8"), + ] + widgets.forEach { WidgetRegistry.shared.register($0) } + } +} diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutEngine.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutEngine.swift new file mode 100644 index 0000000..2d75be1 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutEngine.swift @@ -0,0 +1,154 @@ +import Foundation +import CoreGraphics +import OnyxDesign + +/// Die Größen, in denen ein Widget im Panel auftreten darf. +public enum WidgetSize: String, Codable, Sendable, CaseIterable, Identifiable { + /// 1 × 1 — ein Messwert, ein Ring. + case small + /// 2 × 1 — Messwert mit Verlauf, kurze Liste. + case medium + /// 2 × 2 — Mini-Monat, Sensorliste. + case large + /// 4 × 1 — Medienzeile über die volle Breite. + case wide + + public var id: String { rawValue } + + public var columnSpan: Int { + switch self { + case .small: 1 + case .medium, .large: 2 + case .wide: 4 + } + } + + public var rowSpan: Int { + switch self { + case .small, .medium, .wide: 1 + case .large: 2 + } + } +} + +/// Ein Widget an seinem Platz im Layout, wie der Nutzer es angeordnet hat. +public struct WidgetPlacement: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public var widgetID: String + public var size: WidgetSize + + public init(id: UUID = UUID(), widgetID: String, size: WidgetSize) { + self.id = id + self.widgetID = widgetID + self.size = size + } +} + +public struct GridCell: Hashable, Sendable, CustomStringConvertible { + public let row: Int + public let column: Int + + public init(row: Int, column: Int) { + self.row = row + self.column = column + } + + public var description: String { "(\(row),\(column))" } +} + +/// Ein Widget mit ausgerechneter Rasterposition. +public struct ResolvedPlacement: Equatable, Identifiable, Sendable { + public let placement: WidgetPlacement + public let row: Int + public let column: Int + + public var id: UUID { placement.id } + public var columnSpan: Int { placement.size.columnSpan } + public var rowSpan: Int { placement.size.rowSpan } +} + +/// Setzt Widgets in ein Raster mit fester Spaltenzahl. +public enum LayoutEngine { + + public static let columns = 4 + /// Kantenlänge eines 1×1-Feldes. + public static let cellSize: CGFloat = 96 + + /// Ordnet die Widgets in der gespeicherten Reihenfolge ein, jedes an die + /// erste Stelle, an die es passt. + /// + /// Der Reihe nach von links oben zu suchen statt stur hintereinander zu + /// setzen ist der ganze Unterschied: neben einem 2×2-Widget bleiben rechts + /// zwei 1×1-Plätze frei, und ein Verfahren, das immer nur hinten anfügt, + /// lässt sie dauerhaft leer. + public static func resolve(_ placements: [WidgetPlacement]) -> [ResolvedPlacement] { + var occupied = Set() + var resolved: [ResolvedPlacement] = [] + + for placement in placements { + let span = placement.size + // Zu breit für das Raster: einpassen statt verwerfen. Ein Widget + // verschwinden zu lassen wäre für den Nutzer nicht erklärbar. + let columnSpan = min(span.columnSpan, columns) + + guard let cell = firstFreeCell(columnSpan: columnSpan, + rowSpan: span.rowSpan, + occupied: occupied) else { continue } + + for row in cell.row..<(cell.row + span.rowSpan) { + for column in cell.column..<(cell.column + columnSpan) { + occupied.insert(GridCell(row: row, column: column)) + } + } + resolved.append(ResolvedPlacement(placement: placement, + row: cell.row, + column: cell.column)) + } + return resolved + } + + private static func firstFreeCell(columnSpan: Int, + rowSpan: Int, + occupied: Set) -> GridCell? { + let maxRow = (occupied.map(\.row).max() ?? -1) + rowSpan + 1 + + for row in 0...maxRow { + for column in 0...(columns - columnSpan) { + let fits = (row..<(row + rowSpan)).allSatisfy { r in + (column..<(column + columnSpan)).allSatisfy { c in + !occupied.contains(GridCell(row: r, column: c)) + } + } + if fits { return GridCell(row: row, column: column) } + } + } + return nil + } + + /// Die Größe, die das Panel für dieses Layout braucht. + public static func panelSize(for resolved: [ResolvedPlacement]) -> CGSize { + let spacing = Onyx.Metric.tileSpacing + let padding = Onyx.Metric.panelPadding + + let rows = resolved.map { $0.row + $0.rowSpan }.max() ?? 1 + let usedRows = max(rows, 1) + + return CGSize( + width: CGFloat(columns) * cellSize + CGFloat(columns - 1) * spacing + 2 * padding, + height: CGFloat(usedRows) * cellSize + CGFloat(usedRows - 1) * spacing + 2 * padding) + } + + /// Der Rahmen eines Widgets innerhalb des Panels, in SwiftUI-Koordinaten + /// (Ursprung oben links). + public static func frame(for item: ResolvedPlacement, in panelSize: CGSize) -> CGRect { + let spacing = Onyx.Metric.tileSpacing + let padding = Onyx.Metric.panelPadding + + let x = padding + CGFloat(item.column) * (cellSize + spacing) + let y = padding + CGFloat(item.row) * (cellSize + spacing) + let width = CGFloat(item.columnSpan) * cellSize + CGFloat(item.columnSpan - 1) * spacing + let height = CGFloat(item.rowSpan) * cellSize + CGFloat(item.rowSpan - 1) * spacing + + return CGRect(x: x, y: y, width: width, height: height) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutStore.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutStore.swift new file mode 100644 index 0000000..bccfe47 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxWidgetKit/LayoutStore.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Liest und schreibt die Widget-Anordnung. +/// +/// Die Datei überlebt App-Updates und kann deshalb Widgets nennen, die es nicht +/// mehr gibt. Solche Einträge fallen beim Laden weg. Was aber **nicht** passieren +/// darf: dass ein Nutzer nach einem Update vor einem leeren Panel sitzt, ohne dass +/// es dafür einen erkennbaren Grund gibt. Deshalb der Unterschied zwischen +/// „absichtlich leer" und „nichts Brauchbares übrig". +public struct LayoutStore: Sendable { + + private struct Document: Codable { + var version: Int + var placements: [WidgetPlacement] + } + + private static let currentVersion = 1 + + public let url: URL + + public init(url: URL) { + self.url = url + } + + /// Der Normalfall: `~/Library/Application Support/Onyx/layout.json` + public static func standard() -> LayoutStore { + let base = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Onyx", isDirectory: true) + return LayoutStore(url: base.appendingPathComponent("layout.json")) + } + + public func save(_ placements: [WidgetPlacement]) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let document = Document(version: Self.currentVersion, placements: placements) + try encoder.encode(document).write(to: url, options: .atomic) + } + + public func load(knownWidgetIDs: Set) -> [WidgetPlacement] { + guard let data = try? Data(contentsOf: url), + let document = try? JSONDecoder().decode(Document.self, from: data) else { + // Keine Datei oder unlesbar — beides führt zum Standardlayout. + return Self.defaultLayout(knownWidgetIDs: knownWidgetIDs) + } + + let surviving = document.placements.filter { knownWidgetIDs.contains($0.widgetID) } + + // Eine leere Datei ist eine Aussage: der Nutzer hat alle Widgets entfernt. + // Eine Datei, aus der nach dem Filtern nichts übrig bleibt, ist dagegen + // ein Zeichen dafür, dass sich die Kennungen geändert haben — hier wäre + // ein leeres Panel eine stille Fehlfunktion. + if surviving.isEmpty && !document.placements.isEmpty { + return Self.defaultLayout(knownWidgetIDs: knownWidgetIDs) + } + return surviving + } + + /// Womit Onyx beim ersten Start dasteht. Bewusst zurückhaltend: lieber + /// wenige Widgets, die sofort etwas zeigen, als ein volles Panel, das + /// erst konfiguriert werden will. + public static func defaultLayout(knownWidgetIDs: Set) -> [WidgetPlacement] { + let preferred: [(String, WidgetSize)] = [ + ("calendar", .large), + ("weather", .medium), + ("cpu", .small), + ("battery", .small), + ("media", .wide), + ] + return preferred + .filter { knownWidgetIDs.contains($0.0) } + .map { WidgetPlacement(widgetID: $0.0, size: $0.1) } + } +} diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/OnyxWidget.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/OnyxWidget.swift new file mode 100644 index 0000000..d6f8a09 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxWidgetKit/OnyxWidget.swift @@ -0,0 +1,50 @@ +import SwiftUI + +/// Ein Widget im Notch-Panel. +/// +/// Das Panel kennt ausschließlich dieses Protokoll. Ein neues Widget kommt +/// dazu, indem es sich bei der Registry meldet — am Panel selbst ändert sich +/// dafür keine Zeile. +@MainActor +public protocol OnyxWidget: Identifiable, Sendable { + /// Stabil über App-Versionen hinweg: dieser Wert steht in der Layout-Datei. + var id: String { get } + /// Lokalisierter Name für die Einstellungen. + var displayName: String { get } + var symbolName: String { get } + + /// Welche Größen dieses Widget sinnvoll ausfüllen kann. + var supportedSizes: [WidgetSize] { get } + + @ViewBuilder func makeView(size: WidgetSize) -> AnyView + /// Einstellungen dieses Widgets, falls es welche hat. + @ViewBuilder func makeSettingsView() -> AnyView? +} + +public extension OnyxWidget { + func makeSettingsView() -> AnyView? { nil } +} + +/// Alle bekannten Widgets. Die App füllt sie beim Start. +@MainActor +public final class WidgetRegistry { + public static let shared = WidgetRegistry() + + private var widgets: [String: any OnyxWidget] = [:] + private var order: [String] = [] + + private init() {} + + public func register(_ widget: any OnyxWidget) { + if widgets[widget.id] == nil { order.append(widget.id) } + widgets[widget.id] = widget + } + + public func widget(id: String) -> (any OnyxWidget)? { widgets[id] } + + /// In Registrierungsreihenfolge — das ist auch die Reihenfolge in den + /// Einstellungen. + public var all: [any OnyxWidget] { order.compactMap { widgets[$0] } } + + public func contains(_ id: String) -> Bool { widgets[id] != nil } +} diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/Placeholder.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/Placeholder.swift deleted file mode 100644 index ac8accc..0000000 --- a/Packages/OnyxKit/Sources/OnyxWidgetKit/Placeholder.swift +++ /dev/null @@ -1 +0,0 @@ -// OnyxWidgetKit diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift new file mode 100644 index 0000000..3413503 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift @@ -0,0 +1,56 @@ +import SwiftUI +import OnyxDesign + +/// Zeichnet ein aufgelöstes Layout. +/// +/// Die Positionen kommen aus `LayoutEngine`, nicht aus einem SwiftUI-Grid. Ein +/// `LazyVGrid` kann keine Kacheln über zwei Zeilen führen, und genau das braucht +/// der Mini-Monat. +public struct WidgetGrid: View { + + private let resolved: [ResolvedPlacement] + private let panelSize: CGSize + + public init(placements: [WidgetPlacement]) { + self.resolved = LayoutEngine.resolve(placements) + self.panelSize = LayoutEngine.panelSize(for: resolved) + } + + public var body: some View { + ZStack(alignment: .topLeading) { + ForEach(resolved) { item in + let frame = LayoutEngine.frame(for: item, in: panelSize) + + widgetView(for: item) + .frame(width: frame.width, height: frame.height) + .offset(x: frame.minX, y: frame.minY) + } + } + .frame(width: panelSize.width, height: panelSize.height, alignment: .topLeading) + } + + @ViewBuilder + private func widgetView(for item: ResolvedPlacement) -> some View { + if let widget = WidgetRegistry.shared.widget(id: item.placement.widgetID) { + OnyxTile { widget.makeView(size: item.placement.size) } + } else { + // Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch, + // ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall. + OnyxTile { + VStack(spacing: 4) { + Image(systemName: "questionmark.square.dashed") + Text(item.placement.widgetID) + .font(Onyx.Font.caption) + } + .foregroundStyle(Onyx.Color.textTertiary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + /// Die Größe, die das Panel für dieses Layout braucht — der Controller + /// stellt das Fenster darauf ein. + public static func panelSize(for placements: [WidgetPlacement]) -> CGSize { + LayoutEngine.panelSize(for: LayoutEngine.resolve(placements)) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutEngineTests.swift b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutEngineTests.swift new file mode 100644 index 0000000..c858d81 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutEngineTests.swift @@ -0,0 +1,194 @@ +import Testing +import CoreGraphics +@testable import OnyxWidgetKit + +// Das Panel-Raster hat vier Spalten. Widgets werden in der gespeicherten +// Reihenfolge eingesetzt, jedes an die erste Stelle, an die es passt. Bleibt +// links eine Lücke, füllt sie das nächste Widget, das hineinpasst — sonst +// stünden im Panel dauernd Löcher, die niemand füllen kann. + +@Suite("Layout-Engine") +struct LayoutEngineTests { + + private func placement(_ widget: String, _ size: WidgetSize) -> WidgetPlacement { + WidgetPlacement(widgetID: widget, size: size) + } + + // MARK: - Grundlegendes Setzen + + @Test("Ein kleines Widget landet oben links") + func singleSmall() { + let resolved = LayoutEngine.resolve([placement("cpu", .small)]) + + #expect(resolved.count == 1) + #expect(resolved[0].column == 0) + #expect(resolved[0].row == 0) + } + + @Test("Vier kleine Widgets füllen genau die erste Zeile") + func fourSmallFillFirstRow() { + let resolved = LayoutEngine.resolve((0..<4).map { placement("w\($0)", .small) }) + + #expect(resolved.map(\.row) == [0, 0, 0, 0]) + #expect(resolved.map(\.column) == [0, 1, 2, 3]) + } + + @Test("Das fünfte kleine Widget rutscht in die zweite Zeile") + func fifthWraps() { + let resolved = LayoutEngine.resolve((0..<5).map { placement("w\($0)", .small) }) + + #expect(resolved[4].row == 1) + #expect(resolved[4].column == 0) + } + + @Test("Ein breites Widget belegt alle vier Spalten") + func wideTakesFullRow() { + let resolved = LayoutEngine.resolve([placement("media", .wide)]) + + #expect(resolved[0].column == 0) + #expect(resolved[0].columnSpan == 4) + } + + @Test("Ein hohes Widget belegt zwei Zeilen") + func largeSpansTwoRows() { + let resolved = LayoutEngine.resolve([placement("calendar", .large)]) + + #expect(resolved[0].columnSpan == 2) + #expect(resolved[0].rowSpan == 2) + } + + // MARK: - Packen + + @Test("Ein breites Widget passt nicht neben ein kleines und beginnt eine neue Zeile") + func wideStartsNewRowWhenBlocked() { + let resolved = LayoutEngine.resolve([ + placement("cpu", .small), + placement("media", .wide), + ]) + + #expect(resolved[0].row == 0) + #expect(resolved[1].row == 1) + #expect(resolved[1].column == 0) + } + + @Test("Kleine Widgets füllen die Lücke neben einem hohen Widget") + func smallFillsGapBesideLarge() { + // Ein 2×2-Widget links lässt rechts zwei freie 1×1-Plätze in Zeile 0 + // und zwei in Zeile 1 — die dürfen nicht verloren gehen. + let resolved = LayoutEngine.resolve([ + placement("calendar", .large), + placement("cpu", .small), + placement("ram", .small), + placement("gpu", .small), + ]) + + #expect(resolved[1].row == 0) + #expect(resolved[1].column == 2) + #expect(resolved[2].row == 0) + #expect(resolved[2].column == 3) + #expect(resolved[3].row == 1) + #expect(resolved[3].column == 2) + } + + @Test("Belegte Felder werden nie doppelt vergeben") + func noOverlaps() { + let resolved = LayoutEngine.resolve([ + placement("a", .large), placement("b", .medium), placement("c", .small), + placement("d", .wide), placement("e", .small), placement("f", .large), + ]) + + var occupied = Set() + for item in resolved { + for row in item.row..<(item.row + item.rowSpan) { + for column in item.column..<(item.column + item.columnSpan) { + let cell = GridCell(row: row, column: column) + #expect(!occupied.contains(cell), "Feld \(cell) doppelt belegt") + occupied.insert(cell) + } + } + } + } + + @Test("Nichts ragt über den rechten Rand hinaus") + func nothingOverflowsRight() { + let resolved = LayoutEngine.resolve([ + placement("a", .small), placement("b", .medium), + placement("c", .large), placement("d", .wide), + ]) + + for item in resolved { + #expect(item.column + item.columnSpan <= LayoutEngine.columns) + } + } + + @Test("Die gespeicherte Reihenfolge bleibt erhalten") + func orderIsStable() { + let placements = [placement("a", .small), placement("b", .wide), placement("c", .small)] + let resolved = LayoutEngine.resolve(placements) + + #expect(resolved.map(\.placement.widgetID) == ["a", "b", "c"]) + } + + @Test("Leeres Layout ergibt kein Ergebnis und keinen Absturz") + func emptyLayout() { + #expect(LayoutEngine.resolve([]).isEmpty) + } + + // MARK: - Panelgröße + + @Test("Panelgröße folgt der belegten Zeilenzahl, nicht der Widgetzahl") + func panelSizeFollowsRows() { + let oneRow = LayoutEngine.panelSize(for: LayoutEngine.resolve([ + placement("a", .small), placement("b", .small), + ])) + let twoRows = LayoutEngine.panelSize(for: LayoutEngine.resolve( + (0..<5).map { placement("w\($0)", .small) })) + + #expect(twoRows.height > oneRow.height) + // Die Breite steht fest: vier Spalten, immer. + #expect(twoRows.width == oneRow.width) + } + + @Test("Ein leeres Panel hat trotzdem eine sinnvolle Mindestgröße") + func emptyPanelHasMinimumSize() { + let size = LayoutEngine.panelSize(for: []) + + #expect(size.width > 0) + #expect(size.height > 0) + } + + @Test("Ein hohes Widget allein ergibt zwei Zeilen Höhe") + func largeAloneIsTwoRowsTall() { + let large = LayoutEngine.panelSize(for: LayoutEngine.resolve([placement("a", .large)])) + let small = LayoutEngine.panelSize(for: LayoutEngine.resolve([placement("a", .small)])) + + #expect(large.height > small.height) + } + + // MARK: - Rahmen im Panel + + @Test("Ein Widget in Spalte 2 sitzt weiter rechts als eines in Spalte 0") + func framesFollowColumns() { + let resolved = LayoutEngine.resolve([ + placement("a", .medium), placement("b", .medium), + ]) + let panel = LayoutEngine.panelSize(for: resolved) + + let first = LayoutEngine.frame(for: resolved[0], in: panel) + let second = LayoutEngine.frame(for: resolved[1], in: panel) + + #expect(second.minX > first.minX) + #expect(first.minY == second.minY) + } + + @Test("Ein breites Widget ist im Rahmen wirklich breiter als ein kleines") + func wideFrameIsWider() { + let wide = LayoutEngine.resolve([placement("a", .wide)]) + let small = LayoutEngine.resolve([placement("a", .small)]) + + let wideFrame = LayoutEngine.frame(for: wide[0], in: LayoutEngine.panelSize(for: wide)) + let smallFrame = LayoutEngine.frame(for: small[0], in: LayoutEngine.panelSize(for: small)) + + #expect(wideFrame.width > smallFrame.width) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutStoreTests.swift b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutStoreTests.swift new file mode 100644 index 0000000..7e75768 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/LayoutStoreTests.swift @@ -0,0 +1,129 @@ +import Testing +import Foundation +@testable import OnyxWidgetKit + +// Die Layout-Datei überlebt App-Updates. Sie kann also Widgets nennen, die es +// nicht mehr gibt, oder aus einer neueren Version stammen — und sie kann halb +// geschrieben auf der Platte liegen, wenn der Rechner mitten im Speichern +// abstürzt. Keiner dieser Fälle darf zu einem leeren Panel führen, ohne dass +// jemand merkt warum. + +@Suite("Layout-Persistenz") +struct LayoutStoreTests { + + private func temporaryURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("onyx-test-\(UUID().uuidString)") + .appendingPathComponent("layout.json") + } + + private let known: Set = ["calendar", "weather", "media", "cpu"] + + @Test("Gespeichertes Layout kommt unverändert zurück") + func roundTrip() throws { + let url = temporaryURL() + let store = LayoutStore(url: url) + let layout = [ + WidgetPlacement(widgetID: "calendar", size: .large), + WidgetPlacement(widgetID: "media", size: .wide), + ] + + try store.save(layout) + let loaded = store.load(knownWidgetIDs: known) + + #expect(loaded.map(\.widgetID) == ["calendar", "media"]) + #expect(loaded.map(\.size) == [.large, .wide]) + } + + @Test("Die Kennung eines Widgets bleibt über das Speichern hinweg dieselbe") + func identitySurvives() throws { + let url = temporaryURL() + let store = LayoutStore(url: url) + let placement = WidgetPlacement(widgetID: "cpu", size: .small) + + try store.save([placement]) + + #expect(store.load(knownWidgetIDs: known).first?.id == placement.id) + } + + @Test("Fehlende Datei ergibt das Standardlayout, keinen Fehler") + func missingFileFallsBack() { + let loaded = LayoutStore(url: temporaryURL()).load(knownWidgetIDs: known) + + #expect(!loaded.isEmpty) + #expect(loaded.allSatisfy { known.contains($0.widgetID) }) + } + + @Test("Beschädigte Datei ergibt das Standardlayout statt eines Absturzes") + func corruptFileFallsBack() throws { + let url = temporaryURL() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data("{ das ist kein JSON".utf8).write(to: url) + + let loaded = LayoutStore(url: url).load(knownWidgetIDs: known) + + #expect(!loaded.isEmpty) + } + + @Test("Ein entferntes Widget wird aus dem Layout gefiltert, der Rest bleibt") + func unknownWidgetIsDropped() throws { + let url = temporaryURL() + let store = LayoutStore(url: url) + try store.save([ + WidgetPlacement(widgetID: "calendar", size: .large), + WidgetPlacement(widgetID: "widget-aus-einer-alten-version", size: .small), + WidgetPlacement(widgetID: "media", size: .wide), + ]) + + let loaded = store.load(knownWidgetIDs: known) + + #expect(loaded.map(\.widgetID) == ["calendar", "media"]) + } + + @Test("Eine Datei, in der ALLE Widgets unbekannt sind, ergibt das Standardlayout") + func allUnknownFallsBack() throws { + // Sonst startet die App nach einem Umbau der Widget-Kennungen mit einem + // leeren Panel und keiner Erklärung. + let url = temporaryURL() + let store = LayoutStore(url: url) + try store.save([WidgetPlacement(widgetID: "voellig-unbekannt", size: .small)]) + + let loaded = store.load(knownWidgetIDs: known) + + #expect(!loaded.isEmpty) + #expect(loaded.allSatisfy { known.contains($0.widgetID) }) + } + + @Test("Ein absichtlich leeres Layout bleibt leer") + func deliberatelyEmptyStaysEmpty() throws { + // Wer alle Widgets entfernt, meint das auch so — das ist etwas anderes + // als eine kaputte Datei. + let url = temporaryURL() + let store = LayoutStore(url: url) + try store.save([]) + + #expect(store.load(knownWidgetIDs: known).isEmpty) + } + + @Test("Speichern legt fehlende Verzeichnisse an") + func savingCreatesDirectories() throws { + let url = temporaryURL() + try LayoutStore(url: url).save([WidgetPlacement(widgetID: "cpu", size: .small)]) + + #expect(FileManager.default.fileExists(atPath: url.path)) + } + + @Test("Das Standardlayout nennt nur Widgets, die es wirklich gibt") + func defaultLayoutIsValid() { + let defaults = LayoutStore.defaultLayout(knownWidgetIDs: known) + + #expect(!defaults.isEmpty) + #expect(defaults.allSatisfy { known.contains($0.widgetID) }) + } + + @Test("Ohne bekannte Widgets bleibt auch das Standardlayout leer") + func defaultLayoutWithNoWidgets() { + #expect(LayoutStore.defaultLayout(knownWidgetIDs: []).isEmpty) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxWidgetKitTests/Placeholder.swift b/Packages/OnyxKit/Tests/OnyxWidgetKitTests/Placeholder.swift deleted file mode 100644 index d292376..0000000 --- a/Packages/OnyxKit/Tests/OnyxWidgetKitTests/Placeholder.swift +++ /dev/null @@ -1 +0,0 @@ -// OnyxWidgetKitTests