Initial refactor
This commit is contained in:
parent
d1a0caf7cf
commit
3280c51fa5
25 changed files with 830 additions and 212 deletions
|
|
@ -1,7 +0,0 @@
|
|||
FROM swift:noble
|
||||
|
||||
RUN touch /var/mail/ubuntu && \
|
||||
chown ubuntu /var/mail/ubuntu && \
|
||||
userdel -r ubuntu && \
|
||||
apt-get update && \
|
||||
apt-get install -y libadwaita-1.0 libadwaita-1-dev
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
{
|
||||
"name": "Swift",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||
"installZsh": "false",
|
||||
"username": "vscode",
|
||||
"userUid": "1000",
|
||||
"userGid": "1000",
|
||||
"upgradePackages": "false"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/git:1": {
|
||||
"version": "os-provided",
|
||||
"ppa": "false"
|
||||
},
|
||||
"ghcr.io/devcontainers-contrib/features/curl-apt-get:1": {}
|
||||
},
|
||||
"runArgs": [
|
||||
"--cap-add=SYS_PTRACE",
|
||||
"--security-opt",
|
||||
"seccomp=unconfined"
|
||||
],
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"lldb.library": "/usr/lib/liblldb.so"
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"sswg.swift-lang"
|
||||
]
|
||||
}
|
||||
},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "vscode"
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ let package = Package(
|
|||
.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"),
|
||||
.package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.16.0"),
|
||||
.package(url: "https://github.com/apple/swift-syntax", from: "603.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-log", from: "1.6.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
|
|
@ -48,6 +49,7 @@ let package = Package(
|
|||
dependencies: [
|
||||
"LuminateCore",
|
||||
.product(name: "Adwaita", package: "adwaita-swift"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
|
|
@ -79,6 +81,7 @@ let package = Package(
|
|||
"LuminateObservationMacros",
|
||||
.product(name: "Adwaita", package: "adwaita-swift"),
|
||||
.product(name: "Localized", package: "localized"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
],
|
||||
path: "Sources/Luminate",
|
||||
resources: [.process("Localized.yml")],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
Sources/Luminate/Pages/CollectionView.swift
Normal file
56
Sources/Luminate/Pages/CollectionView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,55 +26,118 @@ 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)
|
||||
.padding(Constants.padding)
|
||||
}.onClick {
|
||||
if item.isFolder ?? false {
|
||||
navigation.push(.collectionPage(item: item))
|
||||
}
|
||||
let subtitle = item.yearString
|
||||
if !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.ellipsize()
|
||||
.caption()
|
||||
.dimLabel()
|
||||
.halign(.center)
|
||||
.frame(maxWidth: 200)
|
||||
}
|
||||
}
|
||||
.padding(6, .vertical)
|
||||
.padding(12, .horizontal)
|
||||
}
|
||||
.onAppear {
|
||||
Idle {
|
||||
loadImage()
|
||||
}
|
||||
}
|
||||
.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() {
|
||||
|
|
@ -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
|
||||
}
|
||||
viewUpdateScheduler.scheduleFlush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { isLoading = false }
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
viewUpdateScheduler.scheduleFlush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
viewUpdateScheduler.scheduleFlush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
430
Sources/LuminateUI/Components/WrapBox.swift
Normal file
430
Sources/LuminateUI/Components/WrapBox.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
29
Sources/LuminateUI/Utilities/Bin+Extensions.swift
Normal file
29
Sources/LuminateUI/Utilities/Bin+Extensions.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
32
Sources/LuminateUI/Utilities/Button+Content.swift
Normal file
32
Sources/LuminateUI/Utilities/Button+Content.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue