Initial refactor

This commit is contained in:
Brendan Szymanski 2026-06-19 01:41:08 -04:00
parent d1a0caf7cf
commit 3280c51fa5
25 changed files with 830 additions and 212 deletions

View file

@ -21,6 +21,7 @@
import Adwaita
import Foundation
import Logging
import LuminateCore
import LuminateDI
import LuminatePlayer
@ -35,12 +36,22 @@ struct Luminate: App {
@State private var isLaunchLoading = true
init() {
LoggingSystem.bootstrap { label in
var handler = StreamLogHandler.standardOutput(label: label)
#if DEBUG
handler.logLevel = .debug
#else
handler.logLevel = .info
#endif
return handler
}
ObservationRegistrar.onChange = { StateManager.updateViews() }
if let store = try? SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) {
DIContainer.shared.register(\.persistence, value: store)
}
DIContainer.shared.register(\.imageService, value: ImageService())
DIContainer.shared.register(\.pageAnimationTracker, value: PageAnimationTracker())
DIContainer.shared.register(\.viewUpdateScheduler, value: ViewUpdateScheduler())
DIContainer.shared.register(\.logger, value: Logger(label: "dev.bscubed.Luminate"))
}
var scene: Scene {
@ -74,6 +85,9 @@ struct Luminate: App {
}
.keyboardShortcut("r".ctrl()) { _ in
}
#if DEBUG
.devel()
#endif
}
private func loadSavedSession() {
@ -107,19 +121,22 @@ struct ContentView: View {
var client: JellyfinClient
var userId: String
@State var stack: NavigationStack<Page> = .init()
@Injected(\.pageAnimationTracker) var pageAnimationTracker
var view: Body {
NavigationView($stack, "Luminate") { page in
switch page {
case .folder(let title, let items):
LibraryPage(title: title, items: items, navigation: $stack)
case .itemPage(let title, let items):
ItemPage(title: title, items: items, navigation: $stack)
.topToolbar {
ToolbarView()
}
.navigationTitle(title)
case .collectionPage(let item):
let title = item.name ?? "Library"
CollectionView(item: item, navigation: $stack)
.topToolbar {
ToolbarView()
}
.navigationTitle(title)
case .library(let item):
Text("REPLACE ME")
}
} initialView: {
HomeView(navigation: $stack)
@ -128,11 +145,5 @@ struct ContentView: View {
}
.navigationTitle("Luminate")
}
.pushed {
pageAnimationTracker.markPush()
}
.popped {
pageAnimationTracker.markPush()
}
}
}

View file

@ -0,0 +1,56 @@
//
// CollectionView.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Adwaita
import LuminateCore
import LuminateUI
public struct CollectionView: View {
nonisolated public init(
item: Components.Schemas.BaseItemDto,
navigation: Binding<NavigationStack<Page>>
) {
self.item = item
_navigation = navigation
}
private var item: Components.Schemas.BaseItemDto
@Binding var navigation: NavigationStack<Page>
public var view: Body {
ScrollView {
Clamp()
.maximumSize(1550)
.tighteningThreshold(550)
.child {
ItemGrid(
parentId: item.id,
title: item.name,
navigation: $navigation
)
.padding(32, .vertical)
}
}
.hscrollbarPolicy(.never)
.propagateNaturalHeight()
}
}

View file

@ -62,7 +62,7 @@ public struct HomeView: View {
items: resumeItems,
navigation: $navigation,
onSeeAll: {
navigation.push(.folder(title: title, items: resumeItems))
navigation.push(.itemPage(title: title, items: resumeItems))
}
)
.padding(32, .bottom)
@ -74,7 +74,7 @@ public struct HomeView: View {
items: nextUpItems,
navigation: $navigation,
onSeeAll: {
navigation.push(.folder(title: title, items: nextUpItems))
navigation.push(.itemPage(title: title, items: nextUpItems))
}
)
.padding(32, .bottom)
@ -86,7 +86,7 @@ public struct HomeView: View {
items: latestItems,
navigation: $navigation,
onSeeAll: {
navigation.push(.folder(title: title, items: latestItems))
navigation.push(.itemPage(title: title, items: latestItems))
}
)
.padding(32, .bottom)
@ -99,7 +99,7 @@ public struct HomeView: View {
}
}
.padding(8, .horizontal)
.padding(32, .bottom)
.padding(32, .vertical)
}
}
.hscrollbarPolicy(.never)

View file

@ -1,5 +1,8 @@
//
// LibraryPage.swift
// ItemPage.swift
// Luminate
//
// Created by Brendan Szymanski on 6/16/25.
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
@ -23,7 +26,7 @@ import Adwaita
import LuminateCore
import LuminateUI
public struct LibraryPage: View {
public struct ItemPage: View {
nonisolated public init(
title: String, items: [Components.Schemas.BaseItemDto],
@ -49,7 +52,7 @@ public struct LibraryPage: View {
navigation: $navigation,
title: title
)
.padding(32, .bottom)
.padding(32, .vertical)
}
}
.hscrollbarPolicy(.never)

View file

@ -20,6 +20,7 @@
//
import Foundation
import Logging
#if canImport(FoundationNetworking)
import FoundationNetworking
@ -28,36 +29,65 @@ import Foundation
public actor ImageService {
private let cacheDir: URL
private let memoryCache = NSCache<NSString, NSData>()
private var activeDownloads = 0
private var imagesDownloaded = 0
private let maxConcurrent: Int
private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
public init(cacheDir: URL? = nil) {
private var logger: Logger = .init(label: "dev.bscubed.Luminate")
public init(cacheDir: URL? = nil, maxConcurrent: Int = 6) {
let defaultCache = FileManager.default.urls(
for: .cachesDirectory, in: .userDomainMask
).first!.appendingPathComponent("luminate/images")
self.cacheDir = cacheDir ?? defaultCache
self.maxConcurrent = maxConcurrent
try? FileManager.default.createDirectory(
at: self.cacheDir, withIntermediateDirectories: true)
logger.debug("New ImageService created")
}
public func loadImage(url: URL) async throws -> Data {
let key = url.absoluteString as NSString
if let cached = memoryCache.object(forKey: key) {
logger.debug("Hit memory cache for \(key)")
return cached as Data
}
let diskKey = url.absoluteString.data(using: .utf8)!.base64EncodedString()
.replacingOccurrences(of: "/", with: "_")
let diskURL = cacheDir.appendingPathComponent(diskKey)
let diskURL = diskCacheURL(for: url)
if let data = try? Data(contentsOf: diskURL) {
memoryCache.setObject(data as NSData, forKey: key)
logger.debug("Hit disk cache for \(key)")
return data
}
if activeDownloads >= maxConcurrent {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
logger.debug("Idk what this is doing tbh")
pendingContinuations.append(continuation)
}
}
activeDownloads += 1
defer {
activeDownloads -= 1
if !pendingContinuations.isEmpty {
let next = pendingContinuations.removeFirst()
next.resume()
}
}
logger.debug("Started downloading \(url)")
let (data, _) = try await URLSession.shared.data(from: url)
let nsData = data as NSData
memoryCache.setObject(nsData, forKey: key)
try? data.write(to: diskURL)
logger.debug("Finished downloading \(url)")
return data
}
public func prefetch(urls: [URL]) async {
logger.debug("Prefetching \(urls)")
await withTaskGroup(of: Void.self) { group in
for url in urls {
group.addTask { _ = try? await self.loadImage(url: url) }
@ -66,8 +96,15 @@ public actor ImageService {
}
public func clearCache() {
logger.debug("Clearing all cache...")
memoryCache.removeAllObjects()
try? FileManager.default.removeItem(at: cacheDir)
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
}
private func diskCacheURL(for url: URL) -> URL {
let diskKey = url.absoluteString.data(using: .utf8)!.base64EncodedString()
.replacingOccurrences(of: "/", with: "_")
return cacheDir.appendingPathComponent(diskKey)
}
}

View file

@ -20,14 +20,14 @@
//
public enum Page: CustomStringConvertible {
case library(item: Components.Schemas.BaseItemDto)
case folder(title: String, items: [Components.Schemas.BaseItemDto])
case collectionPage(item: Components.Schemas.BaseItemDto)
case itemPage(title: String, items: [Components.Schemas.BaseItemDto])
public var description: String {
switch self {
case .library(let item):
case .collectionPage(let item):
return item.name ?? "Library"
case .folder(let title, _):
case .itemPage(let title, _):
return title
}
}

View file

@ -1,5 +1,5 @@
//
// PageAnimationTracking.swift
// ViewUpdateScheduling.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
@ -21,7 +21,6 @@
import Foundation
public protocol PageAnimationTracking: AnyObject {
var isAnimating: Bool { get }
func markPush()
public protocol ViewUpdateScheduling: AnyObject {
func scheduleFlush()
}

View file

@ -21,13 +21,21 @@
import Adwaita
import Foundation
import Synchronization
public final class DIContainer: @unchecked Sendable {
public static let shared = DIContainer()
public private(set) var values = InjectionValues()
private var observers: [AnyKeyPath: [UUID: @Sendable () -> Void]] = [:]
private let lock = NSLock()
private struct State {
var values = InjectionValues()
var observers: [AnyKeyPath: [UUID: @Sendable () -> Void]] = [:]
}
private let state = Mutex<State>(.init())
public var values: InjectionValues {
state.withLock { $0.values }
}
private init() {}
@ -35,8 +43,8 @@ public final class DIContainer: @unchecked Sendable {
_ keyPath: WritableKeyPath<InjectionValues, T?>,
value: T
) {
lock.withLock {
values[keyPath: keyPath] = value
state.withLock {
$0.values[keyPath: keyPath] = value
}
notifyObservers(for: keyPath)
}
@ -44,8 +52,8 @@ public final class DIContainer: @unchecked Sendable {
public func resolve<T>(
_ keyPath: KeyPath<InjectionValues, T?>
) -> T {
lock.withLock {
guard let value = values[keyPath: keyPath] else {
state.withLock {
guard let value = $0.values[keyPath: keyPath] else {
fatalError(
"DIContainer: No value registered for \(keyPath). "
+ "Call DIContainer.shared.register(\\.key, value:) during app startup."
@ -61,31 +69,30 @@ public final class DIContainer: @unchecked Sendable {
handler: @escaping @Sendable () -> Void
) -> UUID {
let id = UUID()
lock.withLock {
observers[keyPath, default: [:]][id] = handler
state.withLock {
$0.observers[keyPath, default: [:]][id] = handler
}
return id
}
func removeObserver(_ id: UUID) {
lock.withLock {
for keyPath in observers.keys {
observers[keyPath]?.removeValue(forKey: id)
state.withLock {
for keyPath in $0.observers.keys {
$0.observers[keyPath]?.removeValue(forKey: id)
}
}
}
private func notifyObservers(for keyPath: AnyKeyPath) {
let handlers: [@Sendable () -> Void] = lock.withLock {
Array((observers[keyPath] ?? [:]).values)
let handlers: [@Sendable () -> Void] = state.withLock {
Array(($0.observers[keyPath] ?? [:]).values)
}
handlers.forEach { $0() }
}
public func reset() {
lock.withLock {
values = InjectionValues()
observers.removeAll()
state.withLock {
$0 = State()
}
}
}

View file

@ -20,6 +20,7 @@
//
import Foundation
import Logging
import LuminateCore
public struct InjectionValues {
@ -29,7 +30,8 @@ public struct InjectionValues {
public var imageService: ImageService?
public var webSocketClient: WebSocketClient?
public var persistence: PersistenceService?
public var pageAnimationTracker: (any PageAnimationTracking)?
public var viewUpdateScheduler: (any ViewUpdateScheduling)?
public var logger: Logger?
public init() {}
}

View file

@ -22,6 +22,7 @@
import Adwaita
import Foundation
import LuminateCore
import LuminateDI
struct EpisodeList: View {
@ -61,6 +62,8 @@ struct EpisodeRow: View {
var episode: Components.Schemas.BaseItemDto
var client: JellyfinClient
@Injected(\.imageService) var imageService
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler
@State private var imageData: Data?
var view: Body {
@ -112,8 +115,9 @@ struct EpisodeRow: View {
maxWidth: 200
)
else { return }
let service = ImageService()
imageData = try? await service.loadImage(url: url)
let data = try? await imageService.loadImage(url: url)
_imageData.rawValue = data
viewUpdateScheduler.scheduleFlush()
}
}
}

View file

@ -26,57 +26,120 @@ import LuminateDI
struct HomePosterCell: View {
var item: Components.Schemas.BaseItemDto
let item: Components.Schemas.BaseItemDto
let width: Int = 200
@Binding var navigation: NavigationStack<Page>
@Injected(\.client) var client
@Injected(\.pageAnimationTracker) var pageAnimationTracker
@Injected(\.imageService) var imageService
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler
@State private var imageData: Data?
private struct Constants {
static let padding = 8
}
var view: Body {
VStack {
if let data = imageData {
Picture()
.contentFit(.cover)
.data(data)
.frame(minWidth: 200, minHeight: 300)
.frame(maxWidth: 200)
.frame(maxHeight: 300)
} else {
Box(spacing: 0) {}
.frame(minWidth: 200, minHeight: 300)
.frame(maxWidth: 200)
.frame(maxHeight: 300)
.card()
Bin {
VStack(spacing: 6) {
imageSection
textSection
}
VStack(spacing: 0) {
if let title = item.name, !title.isEmpty {
Text(item.name ?? "")
.ellipsize()
.heading()
.halign(.center)
.frame(maxWidth: 200)
}
let subtitle = item.yearString
if !subtitle.isEmpty {
Text(subtitle)
.ellipsize()
.caption()
.dimLabel()
.halign(.center)
.frame(maxWidth: 200)
}
.padding(Constants.padding)
}.onClick {
if item.isFolder ?? false {
navigation.push(.collectionPage(item: item))
}
.padding(6, .vertical)
.padding(12, .horizontal)
}
.onAppear {
Idle {
loadImage()
}
}
.overflow(.hidden)
.frame(minWidth: width + (Constants.padding * 2))
.halign(.fill)
.valign(.start)
.hexpand(false)
.style("activatable")
.card()
}
@ViewBuilder
private var imageSection: Body {
AspectFrame(ratio: 1.5)
.child {
image
.halign(.fill)
.hexpand()
}
.obeyChild(false)
.xalign(0.5)
.yalign(0.5)
.halign(.fill)
.hexpand()
.overflow(.hidden)
.card()
// image
// .halign(.fill)
// .hexpand()
// .frame(minHeight: Int(Double(width) * 1.5))
// .overflow(.hidden)
// .card()
}
@ViewBuilder
private var image: Body {
if let imageData {
// Picture()
// .contentFit(.cover)
// .data(imageData)
// .valign(.fill)
// .halign(.fill)
// .vexpand()
// .hexpand()
// .transition(.crossfade)
Overlay()
.overlay {
Picture()
.contentFit(.cover)
.data(imageData)
.valign(.fill)
.halign(.fill)
.vexpand()
.hexpand()
}
.transition(.crossfade)
} else {
Spinner()
.frame(minWidth: 64, minHeight: 64)
.transition(.crossfade)
}
}
@ViewBuilder
private var textSection: Body {
Bin {
VStack(spacing: 2) {
if let title = item.name, !title.isEmpty {
Text(item.name ?? "")
.maxWidthChars(0)
.ellipsize()
.heading()
}
let subtitle = item.yearString
if !subtitle.isEmpty {
Text(subtitle)
.maxWidthChars(0)
.ellipsize()
.caption()
.dimLabel()
}
}
.valign(.center)
}
.frame(minHeight: 40)
.hexpand()
}
private func loadImage() {
guard let tag = item.primaryImageTag,
let itemId = item.seriesId ?? item.id
@ -87,17 +150,13 @@ struct HomePosterCell: View {
itemId: itemId,
imageType: .primary,
tag: tag,
maxWidth: 400
maxWidth: 200
)
else { return }
let service = ImageService()
let data = try? await service.loadImage(url: url)
let data = try? await imageService.loadImage(url: url)
if pageAnimationTracker.isAnimating {
_imageData.rawValue = data
} else {
imageData = data
}
_imageData.rawValue = data
viewUpdateScheduler.scheduleFlush()
}
}
}

View file

@ -21,30 +21,30 @@
import Adwaita
import LuminateCore
import LuminateDI
public struct ItemGrid: View {
var client: JellyfinClient
var userId: String
var parentId: String?
var includeItemTypes: [Components.Schemas.BaseItemKind]?
var title: String?
@Binding var navigation: NavigationStack<Page>
@Injected(\.client) var client
@Injected(\.userId) var userId
@State private var items: [Components.Schemas.BaseItemDto] = []
@State private var isLoading = false
@State private var isLoading = true
private let pageSize: Int32 = 50
public init(
client: JellyfinClient,
userId: String,
parentId: String? = nil,
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
title: String? = nil
title: String? = nil,
navigation: Binding<NavigationStack<Page>>
) {
self.client = client
self.userId = userId
self.parentId = parentId
self.includeItemTypes = includeItemTypes
self.title = title
_navigation = navigation
}
public var view: Body {
@ -58,11 +58,14 @@ public struct ItemGrid: View {
if isLoading {
Spinner()
} else {
ScrollView {
FlowBox(items) { item in
PosterCell(item: item, client: client)
}
WrapBox(items, id: \.id) { item in
HomePosterCell(item: item, navigation: $navigation)
}
.lineSpacing(16)
.childSpacing(16)
.justify(JustifyMode.none)
.justifyLastLine(false)
.halign(.start)
}
}
.onAppear {
@ -71,26 +74,18 @@ public struct ItemGrid: View {
}
private func loadItems() {
isLoading = true
Task {
do {
let result = try await client.getItems(
userId: userId,
parentId: parentId,
includeItemTypes: includeItemTypes,
fields: [.overview, .genres, .people, .mediaSources],
sortBy: [.sortName],
sortOrder: [.ascending],
startIndex: 0,
limit: pageSize,
recursive: true
sortOrder: [.ascending]
)
await MainActor.run {
items = result.items ?? []
isLoading = false
}
items = result.items ?? []
isLoading = false
} catch {
await MainActor.run { isLoading = false }
isLoading = false
}
}
}

View file

@ -45,11 +45,14 @@ public struct LibraryGrid: View {
.title3()
.halign(.start)
.padding(10, .horizontal)
FlowBox(libraries) { library in
HomePosterCell(item: library)
WrapBox(libraries, id: \.id) { item in
HomePosterCell(item: item, navigation: $navigation)
}
.columnSpacing(16)
.rowSpacing(16)
.lineSpacing(16)
.childSpacing(16)
.justify(.fill)
.justifyLastLine(false)
.halign(.start)
}
}
}

View file

@ -61,12 +61,14 @@ public struct MediaRow: View {
ScrollView {
ForEach(items, horizontal: true) { item in
HomePosterCell(item: item)
HomePosterCell(item: item, navigation: $navigation)
.padding(16, .trailing)
}
}
.vscrollbarPolicy(.never)
.hscrollbarPolicy(.external)
.style("undershoot-start")
.style("undershoot-end")
}
}
}

View file

@ -22,12 +22,14 @@
import Adwaita
import Foundation
import LuminateCore
import LuminateDI
struct MovieDetailView: View {
var item: Components.Schemas.BaseItemDto
var client: JellyfinClient
var userId: String
@Injected(\.imageService) var imageService
@State private var isFavorite: Bool
@State private var isPlayed: Bool
@State private var similarItems: [Components.Schemas.BaseItemDto] = []
@ -52,7 +54,7 @@ struct MovieDetailView: View {
.hexpand(true)
}
HStack {
PosterCell(item: item, client: client)
PosterCell(item: item)
.frame(minWidth: 200)
.frame(maxWidth: 200)
VStack {
@ -111,7 +113,7 @@ struct MovieDetailView: View {
ScrollView {
HStack {
ForEach(similarItems) { sim in
PosterCell(item: sim, client: client)
PosterCell(item: sim)
}
}
}
@ -135,8 +137,7 @@ struct MovieDetailView: View {
itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
)
else { return }
let service = ImageService()
backdropData = try? await service.loadImage(url: url)
backdropData = try? await imageService.loadImage(url: url)
}
}

View file

@ -27,8 +27,9 @@ import LuminateDI
struct PosterCell: View {
var item: Components.Schemas.BaseItemDto
var client: JellyfinClient
@Injected(\.pageAnimationTracker) var pageAnimationTracker
@Injected(\.client) private var client: JellyfinClient
@Injected(\.imageService) private var imageService
@Injected(\.viewUpdateScheduler) private var viewUpdateScheduler
@State private var imageData: Data?
var view: Body {
@ -68,14 +69,10 @@ struct PosterCell: View {
maxWidth: 300
)
guard let url else { return }
let service = ImageService()
let data = try? await service.loadImage(url: url)
let data = try? await imageService.loadImage(url: url)
if pageAnimationTracker.isAnimating {
_imageData.rawValue = data
} else {
imageData = data
}
_imageData.rawValue = data
viewUpdateScheduler.scheduleFlush()
}
}
}

View file

@ -81,7 +81,8 @@ struct SearchResultRow: View {
var hint: Components.Schemas.SearchHint
var client: JellyfinClient
@Injected(\.pageAnimationTracker) var pageAnimationTracker
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler
@Injected(\.imageService) var imageService
@State private var imageData: Data?
var view: Body {
@ -138,14 +139,10 @@ struct SearchResultRow: View {
itemId: itemId, imageType: .primary, tag: tag, maxWidth: 160
)
else { return }
let service = ImageService()
let data = try? await service.loadImage(url: url)
let data = try? await imageService.loadImage(url: url)
if pageAnimationTracker.isAnimating {
_imageData.rawValue = data
} else {
imageData = data
}
_imageData.rawValue = data
viewUpdateScheduler.scheduleFlush()
}
}
}

View file

@ -22,12 +22,14 @@
import Adwaita
import Foundation
import LuminateCore
import LuminateDI
struct TVShowView: View {
var item: Components.Schemas.BaseItemDto
var client: JellyfinClient
var userId: String
@Injected(\.imageService) var imageService
@State private var seasons: [Components.Schemas.BaseItemDto] = []
@State private var selectedSeasonId: String?
@State private var backdropData: Data?
@ -43,7 +45,7 @@ struct TVShowView: View {
.hexpand(true)
}
HStack {
PosterCell(item: item, client: client)
PosterCell(item: item)
.frame(minWidth: 200)
.frame(maxWidth: 200)
VStack {
@ -106,8 +108,7 @@ struct TVShowView: View {
itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
)
else { return }
let service = ImageService()
backdropData = try? await service.loadImage(url: url)
backdropData = try? await imageService.loadImage(url: url)
}
}

View file

@ -0,0 +1,430 @@
//
// WrapBox.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Adwaita
import CAdw
// MARK: - Swift Enum Wrappers for C Enums
/// Controls how children are justified within each line.
public enum JustifyMode {
/// No justification.
case none
/// Children are stretched to fill the line.
case fill
/// Extra space is distributed evenly between children.
case spread
var cValue: AdwJustifyMode {
switch self {
case .none: ADW_JUSTIFY_NONE
case .fill: ADW_JUSTIFY_FILL
case .spread: ADW_JUSTIFY_SPREAD
}
}
}
/// Controls the packing direction.
public enum PackDirection {
/// Children are packed from start to end.
case startToEnd
/// Children are packed from end to start.
case endToStart
var cValue: AdwPackDirection {
switch self {
case .startToEnd: ADW_PACK_START_TO_END
case .endToStart: ADW_PACK_END_TO_START
}
}
}
/// Controls the wrapping policy.
public enum WrapPolicy {
/// Wrapping occurs at the minimum size.
case minimum
/// Wrapping occurs at the natural size.
case natural
var cValue: AdwWrapPolicy {
switch self {
case .minimum: ADW_WRAP_MINIMUM
case .natural: ADW_WRAP_NATURAL
}
}
}
/// Units for spacing and length properties.
public enum LengthUnit {
/// Pixels.
case px
/// Points.
case pt
/// Scale-independent pixels.
case sp
var cValue: AdwLengthUnit {
switch self {
case .px: ADW_LENGTH_UNIT_PX
case .pt: ADW_LENGTH_UNIT_PT
case .sp: ADW_LENGTH_UNIT_SP
}
}
}
// MARK: - WrapBox Widget
/// A responsive wrapping container that arranges children in a reflowing grid.
///
/// `WrapBox` places its children in a horizontal flow, wrapping to the next
/// line when the available width is exhausted. It is backed by `AdwWrapBox` from libadwaita.
///
/// Use inside a `ScrollView` to allow shrinking in both axes.
///
/// ```swift
/// ScrollView {
/// WrapBox(items) { item in
/// ItemCell(item: item)
/// }
/// .childSpacing(12)
/// .lineSpacing(12)
/// .lineHomogeneous(true)
/// }
/// ```
public struct WrapBox<Element, Identifier>: AdwaitaWidget where Identifier: Hashable {
#if exposeGeneratedAppearUpdateFunctions
/// Additional update functions for type extensions.
public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
/// Additional appear functions for type extensions.
public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
#else
/// Additional update functions for type extensions.
var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
/// Additional appear functions for type extensions.
var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
#endif
/// The amount of space between children.
var childSpacing: Int?
/// The unit for `childSpacing`.
var childSpacingUnit: LengthUnit?
/// The packing direction.
var packDirection: PackDirection?
/// The alignment of children within each line (0.0 to 1.0).
var align: Float?
/// The justification mode.
var justify: JustifyMode?
/// Whether to justify the last line.
var justifyLastLine: Bool?
/// The amount of space between lines.
var lineSpacing: Int?
/// The unit for `lineSpacing`.
var lineSpacingUnit: LengthUnit?
/// Whether all lines should be the same size.
var lineHomogeneous: Bool?
/// The natural length of each line.
var naturalLineLength: Int?
/// The unit for `naturalLineLength`.
var naturalLineLengthUnit: LengthUnit?
/// Whether to reverse the wrapping direction.
var wrapReverse: Bool?
/// The wrapping policy.
var wrapPolicy: WrapPolicy?
/// The dynamic widget elements.
var elements: [Element]
/// The dynamic widget content.
var content: (Element) -> Body
/// The dynamic widget identifier key path.
var id: KeyPath<Element, Identifier>
/// Initialize `WrapBox`.
/// - Parameters:
/// - elements: The elements to display.
/// - id: The key path to the element's identifier.
/// - content: A view builder for rendering each element.
public init(
_ elements: [Element],
id: KeyPath<Element, Identifier>,
@ViewBuilder content: @escaping (Element) -> Body
) {
self.elements = elements
self.content = content
self.id = id
}
// MARK: - AdwaitaWidget
public func container<Data>(data: WidgetData, type: Data.Type) -> ViewStorage
where Data: ViewRenderData {
let storage = ViewStorage(adw_wrap_box_new()?.opaque())
for function in appearFunctions {
function(storage, data)
}
return storage
}
public func update<Data>(
_ storage: ViewStorage,
data: WidgetData,
updateProperties: Bool,
type: Data.Type
) where Data: ViewRenderData {
storage.modify { widget in
// --- Apply property changes ---
if let childSpacing, updateProperties,
(storage.previousState as? Self)?.childSpacing != childSpacing
{
adw_wrap_box_set_child_spacing(widget, childSpacing.cInt)
}
if let childSpacingUnit, updateProperties,
(storage.previousState as? Self)?.childSpacingUnit != childSpacingUnit
{
adw_wrap_box_set_child_spacing_unit(widget, childSpacingUnit.cValue)
}
if let packDirection, updateProperties,
(storage.previousState as? Self)?.packDirection != packDirection
{
adw_wrap_box_set_pack_direction(widget, packDirection.cValue)
}
if let align, updateProperties,
(storage.previousState as? Self)?.align != align
{
adw_wrap_box_set_align(widget, align)
}
if let justify, updateProperties,
(storage.previousState as? Self)?.justify != justify
{
adw_wrap_box_set_justify(widget, justify.cValue)
}
if let justifyLastLine, updateProperties,
(storage.previousState as? Self)?.justifyLastLine != justifyLastLine
{
adw_wrap_box_set_justify_last_line(widget, justifyLastLine.cBool)
}
if let lineSpacing, updateProperties,
(storage.previousState as? Self)?.lineSpacing != lineSpacing
{
adw_wrap_box_set_line_spacing(widget, lineSpacing.cInt)
}
if let lineSpacingUnit, updateProperties,
(storage.previousState as? Self)?.lineSpacingUnit != lineSpacingUnit
{
adw_wrap_box_set_line_spacing_unit(widget, lineSpacingUnit.cValue)
}
if let lineHomogeneous, updateProperties,
(storage.previousState as? Self)?.lineHomogeneous != lineHomogeneous
{
adw_wrap_box_set_line_homogeneous(widget, lineHomogeneous.cBool)
}
if let naturalLineLength, updateProperties,
(storage.previousState as? Self)?.naturalLineLength != naturalLineLength
{
adw_wrap_box_set_natural_line_length(widget, naturalLineLength.cInt)
}
if let naturalLineLengthUnit, updateProperties,
(storage.previousState as? Self)?.naturalLineLengthUnit != naturalLineLengthUnit
{
adw_wrap_box_set_natural_line_length_unit(widget, naturalLineLengthUnit.cValue)
}
if let wrapReverse, updateProperties,
(storage.previousState as? Self)?.wrapReverse != wrapReverse
{
adw_wrap_box_set_wrap_reverse(widget, wrapReverse.cBool)
}
if let wrapPolicy, updateProperties,
(storage.previousState as? Self)?.wrapPolicy != wrapPolicy
{
adw_wrap_box_set_wrap_policy(widget, wrapPolicy.cValue)
}
// --- Child management ---
var contentStorage: [ViewStorage] = storage.content[.mainContent] ?? []
let oldElements = storage.fields["element"] as? [Element] ?? []
var oldByID: [Identifier: ViewStorage] = [:]
for (i, oldElement) in oldElements.enumerated() where i < contentStorage.count {
oldByID[oldElement[keyPath: id]] = contentStorage[i]
}
var newContentStorage: [ViewStorage] = []
var lastChild: OpaquePointer?
for element in elements {
let elementID = element[keyPath: id]
if let existingStorage = oldByID.removeValue(forKey: elementID) {
newContentStorage.append(existingStorage)
if let lastPtr = lastChild {
adw_wrap_box_reorder_child_after(
widget,
existingStorage.opaquePointer?.cast(),
lastPtr.cast()
)
} else {
adw_wrap_box_reorder_child_after(
widget,
existingStorage.opaquePointer?.cast(),
nil
)
}
lastChild = existingStorage.opaquePointer
} else {
let child = content(element).storage(data: data, type: type)
if let lastPtr = lastChild {
adw_wrap_box_insert_child_after(
widget,
child.opaquePointer?.cast(),
lastPtr.cast()
)
} else {
adw_wrap_box_prepend(widget, child.opaquePointer?.cast())
}
newContentStorage.append(child)
lastChild = child.opaquePointer
}
}
for (_, staleStorage) in oldByID {
adw_wrap_box_remove(widget, staleStorage.opaquePointer?.cast())
}
storage.fields["element"] = elements
storage.content[.mainContent] = newContentStorage
for (index, element) in elements.enumerated() {
content(element).updateStorage(
newContentStorage[index],
data: data,
updateProperties: updateProperties,
type: type
)
}
}
for function in updateFunctions {
function(storage, data, updateProperties)
}
if updateProperties {
storage.previousState = self
}
}
}
// MARK: - Modifier Methods
extension WrapBox {
/// The amount of space between children.
public func childSpacing(_ childSpacing: Int?) -> Self {
modify { $0.childSpacing = childSpacing }
}
/// The unit for `childSpacing`.
public func childSpacingUnit(_ childSpacingUnit: LengthUnit?) -> Self {
modify { $0.childSpacingUnit = childSpacingUnit }
}
/// The packing direction.
public func packDirection(_ packDirection: PackDirection?) -> Self {
modify { $0.packDirection = packDirection }
}
/// The alignment of children within each line (0.0 to 1.0).
public func align(_ align: Float?) -> Self {
modify { $0.align = align }
}
/// The justification mode.
public func justify(_ justify: JustifyMode?) -> Self {
modify { $0.justify = justify }
}
/// Whether to justify the last line.
public func justifyLastLine(_ justifyLastLine: Bool? = true) -> Self {
modify { $0.justifyLastLine = justifyLastLine }
}
/// The amount of space between lines.
public func lineSpacing(_ lineSpacing: Int?) -> Self {
modify { $0.lineSpacing = lineSpacing }
}
/// The unit for `lineSpacing`.
public func lineSpacingUnit(_ lineSpacingUnit: LengthUnit?) -> Self {
modify { $0.lineSpacingUnit = lineSpacingUnit }
}
/// Whether all lines should be the same size.
public func lineHomogeneous(_ lineHomogeneous: Bool? = true) -> Self {
modify { $0.lineHomogeneous = lineHomogeneous }
}
/// The natural length of each line.
public func naturalLineLength(_ naturalLineLength: Int?) -> Self {
modify { $0.naturalLineLength = naturalLineLength }
}
/// The unit for `naturalLineLength`.
public func naturalLineLengthUnit(_ naturalLineLengthUnit: LengthUnit?) -> Self {
modify { $0.naturalLineLengthUnit = naturalLineLengthUnit }
}
/// Whether to reverse the wrapping direction.
public func wrapReverse(_ wrapReverse: Bool? = true) -> Self {
modify { $0.wrapReverse = wrapReverse }
}
/// The wrapping policy.
public func wrapPolicy(_ wrapPolicy: WrapPolicy?) -> Self {
modify { $0.wrapPolicy = wrapPolicy }
}
}
// MARK: - Convenience Initializer for Identifiable Elements
extension WrapBox where Element: Identifiable, Identifier == Element.ID {
/// Initialize `WrapBox` with identifiable elements.
/// - Parameters:
/// - elements: The identifiable elements to display.
/// - content: A view builder for rendering each element.
public init(
_ elements: [Element],
@ViewBuilder content: @escaping (Element) -> Body
) {
self.elements = elements
self.content = content
self.id = \.id
}
}

View file

@ -0,0 +1,29 @@
//
// AnyView+Overflow.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Adwaita
extension Bin {
public init(@ViewBuilder content: @escaping () -> Body) {
self.init()
self = self.child(content)
}
}

View file

@ -0,0 +1,32 @@
//
// HomePosterCell.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Adwaita
extension Button {
public init(@ViewBuilder child: @escaping () -> Body, handler: @escaping () -> Void) {
self.init("", handler: handler)
self = self.label(nil)
.child(child)
.hasFrame(false)
}
}

View file

@ -1,5 +1,5 @@
//
// PageAnimationTracker.swift
// ViewUpdateScheduler.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
@ -22,22 +22,23 @@
import Adwaita
import Foundation
import LuminateCore
import Synchronization
public class PageAnimationTracker: PageAnimationTracking {
public var isAnimating = false
private var pushGeneration = 0
public class ViewUpdateScheduler: ViewUpdateScheduling {
private let isPending = Mutex<Bool>(false)
public init() {}
public func markPush() {
isAnimating = true
pushGeneration += 1
let captured = pushGeneration
Idle(delay: 250) { [weak self] in
guard let self, self.pushGeneration == captured else { return false }
self.isAnimating = false
public func scheduleFlush() {
let shouldSchedule = isPending.withLock {
if $0 { return false }
$0 = true
return true
}
guard shouldSchedule else { return }
Idle { [weak self] in
StateManager.updateViews()
return false
self?.isPending.withLock { $0 = false }
}
}
}