Bilder ohne Warten — und der Beweis, dass der Mac die Leiste selbst zeichnet
Der Schriftzug hing weiter nicht an der Animation, und der Zwischenspeicher war nicht schuld: er lag im Actor, und ein `await` kostet immer mindestens einen Durchlauf. Das neue Bild erschien deshalb im ersten Bild ohne Schriftzug und im zweiten mit — er sprang also getrennt herein, obwohl er längst da war. Der Speicher liegt jetzt außerhalb des Actors und lässt sich beim Zeichnen lesen. Das gilt für alle Bilder: auch eine zurückscrollende Kachel blitzt nicht mehr kurz grau auf. Zur Seitenleiste auf dem Mac, gemessen statt vermutet: `automatic` löst sich laut Apples eigener Beschreibung auf iOS zu `overlap` auf (die Leiste schwebt über dem Inhalt) und auf macOS zu `tile` (sie schiebt ihn zur Seite und klebt am Fensterrand). Der Griff dorthin sitzt richtig — derselbe Weg hat die Leiste auf Kommando ganz ausgeblendet —, aber `overlap` wird auf dem Mac stillschweigend verworfen. Die Bitte bleibt trotzdem stehen; sie kostet nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e5e01838f7
commit
e7de8a9055
@@ -170,6 +170,13 @@ struct RootView: View {
|
|||||||
/// ausfahrbares Blatt, eine geteilte Ansicht und eine Reihe oben. Sie
|
/// ausfahrbares Blatt, eine geteilte Ansicht und eine Reihe oben. Sie
|
||||||
/// konnten zusammen weniger als das eine Bauteil und mussten dreimal
|
/// konnten zusammen weniger als das eine Bauteil und mussten dreimal
|
||||||
/// gepflegt werden.
|
/// gepflegt werden.
|
||||||
|
/// Läuft diese App gerade auf einem Mac?
|
||||||
|
///
|
||||||
|
/// Nicht über `#if` zu haben: „Designed for iPad" **ist** der iOS-Bau,
|
||||||
|
/// unverändert, nur auf anderer Hardware. Erst zur Laufzeit ist der
|
||||||
|
/// Unterschied sichtbar.
|
||||||
|
static let isOnMac = ProcessInfo.processInfo.isMacCatalystApp
|
||||||
|
|
||||||
private var library: some View {
|
private var library: some View {
|
||||||
TabView(selection: $selection) {
|
TabView(selection: $selection) {
|
||||||
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
|
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
|
||||||
@@ -241,6 +248,10 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
.tabViewStyle(.sidebarAdaptable)
|
.tabViewStyle(.sidebarAdaptable)
|
||||||
.tabViewCustomization($customization)
|
.tabViewCustomization($customization)
|
||||||
|
// Auf dem Mac würde die Leiste sonst den Inhalt zur Seite schieben
|
||||||
|
// und selbst am Fensterrand kleben. Auf dem iPad tut das System es
|
||||||
|
// ohnehin so; der Aufruf schadet dort nicht.
|
||||||
|
.background { SidebarOverlapRequest().frame(width: 0, height: 0) }
|
||||||
// Das Konto sitzt unten in der Seitenleiste, nicht als Eintrag
|
// Das Konto sitzt unten in der Seitenleiste, nicht als Eintrag
|
||||||
// zwischen den Mediatheken. Dort sucht man es.
|
// zwischen den Mediatheken. Dort sucht man es.
|
||||||
.tabViewSidebarBottomBar {
|
.tabViewSidebarBottomBar {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#if os(iOS)
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Bittet die Seitenleiste, den Inhalt zu **überlagern** statt ihn zu verdrängen.
|
||||||
|
///
|
||||||
|
/// `UITabBarControllerSidebar.preferredLayout` steht ab Werk auf `automatic`,
|
||||||
|
/// und das löst sich laut Apples eigener Beschreibung je nach Gerät anders auf:
|
||||||
|
/// auf iOS zu `overlap` — die Leiste schwebt als eigene Fläche über dem Inhalt —
|
||||||
|
/// und auf macOS zu `tile`, wo sie den Inhalt zur Seite schiebt und selbst am
|
||||||
|
/// Fensterrand klebt. Das ist der Unterschied, den man zwischen iPad und Mac
|
||||||
|
/// sieht, und er kommt nicht aus dieser App.
|
||||||
|
///
|
||||||
|
/// **Unter Mac Catalyst gemessen: es hilft dort nicht.** Der Griff sitzt
|
||||||
|
/// richtig — zum Beweis hat derselbe Weg die Leiste auf Kommando ganz
|
||||||
|
/// ausgeblendet —, aber `overlap` wird auf dem Mac stillschweigend verworfen
|
||||||
|
/// und es bleibt bei `tile`. Der Aufruf bleibt trotzdem stehen: er ist die
|
||||||
|
/// richtige Bitte, er kostet nichts, und sollte „Designed for iPad" sich näher
|
||||||
|
/// am iPhone-Bau verhalten als Catalyst, greift er dort.
|
||||||
|
///
|
||||||
|
/// SwiftUI reicht die Eigenschaft nicht durch, also wird der zuständige
|
||||||
|
/// `UITabBarController` gesucht. Gesucht wird vom eigenen Platz aus nach oben
|
||||||
|
/// bis zur Wurzel und von dort nach unten — die Leiste liegt in der
|
||||||
|
/// SwiftUI-Sicht *über* uns, nicht unter uns, und die reine Elternkette führt
|
||||||
|
/// deshalb an ihr vorbei.
|
||||||
|
struct SidebarOverlapRequest: UIViewControllerRepresentable {
|
||||||
|
func makeUIViewController(context: Context) -> UIViewController {
|
||||||
|
Probe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ controller: UIViewController, context: Context) {}
|
||||||
|
|
||||||
|
/// Ein Controller, der nichts zeigt und nur da ist, um im Baum zu stehen.
|
||||||
|
private final class Probe: UIViewController {
|
||||||
|
override func didMove(toParent parent: UIViewController?) {
|
||||||
|
super.didMove(toParent: parent)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
// Ein zweites Mal: beim ersten Anhängen steht der
|
||||||
|
// Tab-Controller noch nicht zwingend im Baum.
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply() {
|
||||||
|
guard let controller = Self.tabBarController(from: self) else { return }
|
||||||
|
guard controller.sidebar.preferredLayout != .overlap else { return }
|
||||||
|
controller.sidebar.preferredLayout = .overlap
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func tabBarController(from start: UIViewController) -> UITabBarController? {
|
||||||
|
var root = start
|
||||||
|
while let parent = root.parent { root = parent }
|
||||||
|
return search(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func search(_ controller: UIViewController) -> UITabBarController? {
|
||||||
|
if let found = controller as? UITabBarController { return found }
|
||||||
|
for child in controller.children {
|
||||||
|
if let found = search(child) { return found }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -29,6 +29,35 @@ final class LayoutSnapshots: XCTestCase {
|
|||||||
try? data.write(to: folder.appendingPathComponent("\(name).png"))
|
try? data.write(to: folder.appendingPathComponent("\(name).png"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das Umschalten zwischen Seitenleiste und Leiste oben.
|
||||||
|
///
|
||||||
|
/// Beim Umschalten steht die Leiste kurz falsch, bevor sie sich fängt.
|
||||||
|
/// Fotografiert wird in Serie, weil genau der Zwischenzustand gesucht ist
|
||||||
|
/// und ein einzelnes Bild danach nichts zeigt.
|
||||||
|
func testSidebarToggle() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["-FlixyStubServer", "http://127.0.0.1:8096"]
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
XCUIDevice.shared.orientation = .landscapeLeft
|
||||||
|
XCTAssertTrue(app.staticTexts["Weiterschauen"].waitForExistence(timeout: 20))
|
||||||
|
Thread.sleep(forTimeInterval: 2)
|
||||||
|
shoot(app, "10-seitenleiste")
|
||||||
|
|
||||||
|
// Der Knopf oben rechts in der Leiste schaltet um. Er trägt keine
|
||||||
|
// übersetzte Beschriftung, also über seine Stellung gesucht.
|
||||||
|
let toggle = app.buttons.element(boundBy: 1)
|
||||||
|
XCTAssertTrue(toggle.exists)
|
||||||
|
toggle.tap()
|
||||||
|
|
||||||
|
for step in 0..<8 {
|
||||||
|
shoot(app, String(format: "11-umschalten-%d", step))
|
||||||
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 2)
|
||||||
|
shoot(app, "12-oben")
|
||||||
|
}
|
||||||
|
|
||||||
func testHomeAndDetail() throws {
|
func testHomeAndDetail() throws {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launchArguments = ["-FlixyStubServer", "http://127.0.0.1:8096"]
|
app.launchArguments = ["-FlixyStubServer", "http://127.0.0.1:8096"]
|
||||||
|
|||||||
@@ -27,7 +27,16 @@ public actor ArtworkLoader {
|
|||||||
public static let shared = ArtworkLoader()
|
public static let shared = ArtworkLoader()
|
||||||
|
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
private let cache = NSCache<NSURL, PlatformImage>()
|
/// Der Speicher liegt **außerhalb** des Actors, damit er sich ohne
|
||||||
|
/// `await` lesen lässt.
|
||||||
|
///
|
||||||
|
/// Das klingt nach einer Kleinigkeit und ist der ganze Punkt: ein `await`
|
||||||
|
/// kostet immer mindestens einen Durchlauf, und genau der war beim
|
||||||
|
/// Überblenden des Aufmachers zu sehen. Das neue Bild erschien im ersten
|
||||||
|
/// Bild noch ohne Schriftzug, im zweiten mit — der Schriftzug sprang also
|
||||||
|
/// getrennt herein, obwohl er längst da war. Wer das Bild schon hat, soll
|
||||||
|
/// es im selben Augenblick zeigen können.
|
||||||
|
private let cache = ImageMemoryCache()
|
||||||
/// Läuft eine Anfrage schon, wird sie geteilt statt ein zweites Mal
|
/// Läuft eine Anfrage schon, wird sie geteilt statt ein zweites Mal
|
||||||
/// gestellt. Ohne das lädt eine Reihe, die dasselbe Plakat zweimal zeigt,
|
/// gestellt. Ohne das lädt eine Reihe, die dasselbe Plakat zweimal zeigt,
|
||||||
/// es auch zweimal.
|
/// es auch zweimal.
|
||||||
@@ -49,8 +58,15 @@ public actor ArtworkLoader {
|
|||||||
cache.countLimit = 400
|
cache.countLimit = 400
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das Bild, wenn es schon im Arbeitsspeicher liegt — ohne Warten.
|
||||||
|
///
|
||||||
|
/// `nonisolated`, damit eine Ansicht es beim Zeichnen aufrufen kann.
|
||||||
|
public nonisolated static func cachedImage(for url: URL) -> PlatformImage? {
|
||||||
|
shared.cache.image(for: url)
|
||||||
|
}
|
||||||
|
|
||||||
public func image(for url: URL) async -> PlatformImage? {
|
public func image(for url: URL) async -> PlatformImage? {
|
||||||
if let cached = cache.object(forKey: url as NSURL) { return cached }
|
if let cached = cache.image(for: url) { return cached }
|
||||||
if let running = inFlight[url] { return await running.value }
|
if let running = inFlight[url] { return await running.value }
|
||||||
|
|
||||||
let task = Task<PlatformImage?, Never> { [session] in
|
let task = Task<PlatformImage?, Never> { [session] in
|
||||||
@@ -65,16 +81,33 @@ public actor ArtworkLoader {
|
|||||||
|
|
||||||
let image = await task.value
|
let image = await task.value
|
||||||
inFlight[url] = nil
|
inFlight[url] = nil
|
||||||
if let image { cache.setObject(image, forKey: url as NSURL) }
|
if let image { cache.store(image, for: url) }
|
||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Das Bild, wenn es schon im Arbeitsspeicher liegt.
|
}
|
||||||
///
|
|
||||||
/// Damit eine Kachel, die zum zweiten Mal erscheint, nicht erst kurz grau
|
/// Ein Bildspeicher, der sich von überall lesen lässt.
|
||||||
/// aufblitzt — das ist beim Scrollen der sichtbarste Unterschied.
|
///
|
||||||
public func cached(_ url: URL) -> PlatformImage? {
|
/// `NSCache` ist laut Apples Dokumentation von sich aus für den Zugriff aus
|
||||||
cache.object(forKey: url as NSURL)
|
/// mehreren Strängen gebaut — der Compiler weiß das nur nicht. Deshalb hier
|
||||||
|
/// von Hand versichert statt den Speicher in einen Actor zu sperren, was ihn
|
||||||
|
/// genau um die Eigenschaft brächte, für die er hier steht: ohne Warten
|
||||||
|
/// lesbar zu sein.
|
||||||
|
private final class ImageMemoryCache: @unchecked Sendable {
|
||||||
|
private let storage = NSCache<NSURL, PlatformImage>()
|
||||||
|
|
||||||
|
var countLimit: Int {
|
||||||
|
get { storage.countLimit }
|
||||||
|
set { storage.countLimit = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
func image(for url: URL) -> PlatformImage? {
|
||||||
|
storage.object(forKey: url as NSURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func store(_ image: PlatformImage, for url: URL) {
|
||||||
|
storage.setObject(image, forKey: url as NSURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,21 @@ public struct FlixyArtwork: View {
|
|||||||
.task(id: url) { await load() }
|
.task(id: url) { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das Bild, das jetzt gezeigt werden kann.
|
||||||
|
///
|
||||||
|
/// Der Blick in den Speicher steht **hier**, nicht in `load()`: eine
|
||||||
|
/// Kachel, die zum zweiten Mal erscheint, zeigt ihr Bild damit schon im
|
||||||
|
/// ersten Bild und blitzt nicht kurz grau auf. Über ein `await` ginge das
|
||||||
|
/// nicht — das kostet immer einen Durchlauf.
|
||||||
|
private var available: PlatformImage? {
|
||||||
|
if let loaded { return loaded }
|
||||||
|
guard let url else { return nil }
|
||||||
|
return ArtworkLoader.cachedImage(for: url)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
if let loaded {
|
if let loaded = available {
|
||||||
switch contentMode {
|
switch contentMode {
|
||||||
case .fill: Image(platform: loaded).resizable().scaledToFill()
|
case .fill: Image(platform: loaded).resizable().scaledToFill()
|
||||||
case .fit: Image(platform: loaded).resizable().scaledToFit()
|
case .fit: Image(platform: loaded).resizable().scaledToFit()
|
||||||
@@ -70,10 +82,9 @@ public struct FlixyArtwork: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liegt es schon im Arbeitsspeicher, gibt es kein Aufblitzen: ohne
|
// Was schon da ist, zeigt `available` bereits — hier bleibt nur das
|
||||||
// diesen Schritt wäre jede zurückscrollende Kachel kurz grau.
|
// Nachladen.
|
||||||
if let cached = await ArtworkLoader.shared.cached(url) {
|
if ArtworkLoader.cachedImage(for: url) != nil {
|
||||||
loaded = cached
|
|
||||||
hasFailed = false
|
hasFailed = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func titleView(scale: CGFloat) -> some View {
|
private func titleView(scale: CGFloat) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if let logo {
|
if let logo = availableLogo {
|
||||||
Image(platform: logo)
|
Image(platform: logo)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
@@ -89,14 +89,22 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: logoURL) {
|
.task(id: logoURL) {
|
||||||
guard let logoURL else {
|
guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
|
||||||
logo = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logo = await ArtworkLoader.shared.image(for: logoURL)
|
logo = await ArtworkLoader.shared.image(for: logoURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Schriftzug, wenn er **jetzt** gezeigt werden kann.
|
||||||
|
///
|
||||||
|
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
|
||||||
|
/// Bild des Übergangs da. Nur wenn nicht, wird nachgeladen — und dann
|
||||||
|
/// steht bis dahin der gesetzte Titel.
|
||||||
|
private var availableLogo: PlatformImage? {
|
||||||
|
if let logo { return logo }
|
||||||
|
guard let logoURL else { return nil }
|
||||||
|
return ArtworkLoader.cachedImage(for: logoURL)
|
||||||
|
}
|
||||||
|
|
||||||
private func plainTitle(scale: CGFloat) -> some View {
|
private func plainTitle(scale: CGFloat) -> some View {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 34 * scale, weight: .bold))
|
.font(.system(size: 34 * scale, weight: .bold))
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import Foundation
|
|||||||
// PyObjC nicht mehr mit, und `screencapture` kennt nur Kennungen, keine Namen.
|
// PyObjC nicht mehr mit, und `screencapture` kennt nur Kennungen, keine Namen.
|
||||||
|
|
||||||
let name = CommandLine.arguments.dropFirst().first ?? "Flixy"
|
let name = CommandLine.arguments.dropFirst().first ?? "Flixy"
|
||||||
|
/// Zweites Argument: die Prozesskennung. Nötig, sobald zwei Bauarten
|
||||||
|
/// desselben Programms gleichzeitig laufen — sonst trifft es die falsche.
|
||||||
|
let wantedPID = CommandLine.arguments.dropFirst(2).first.flatMap(Int.init)
|
||||||
|
|
||||||
guard let windows = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] else {
|
guard let windows = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] else {
|
||||||
FileHandle.standardError.write(Data("Fensterliste nicht lesbar\n".utf8))
|
FileHandle.standardError.write(Data("Fensterliste nicht lesbar\n".utf8))
|
||||||
@@ -19,6 +22,7 @@ let candidates = windows.filter { window in
|
|||||||
guard let owner = window[kCGWindowOwnerName as String] as? String, owner == name else { return false }
|
guard let owner = window[kCGWindowOwnerName as String] as? String, owner == name else { return false }
|
||||||
// Ebene 0 ist das normale Fenster; alles darüber sind Menüs und Schatten.
|
// Ebene 0 ist das normale Fenster; alles darüber sind Menüs und Schatten.
|
||||||
guard (window[kCGWindowLayer as String] as? Int) == 0 else { return false }
|
guard (window[kCGWindowLayer as String] as? Int) == 0 else { return false }
|
||||||
|
if let wantedPID, (window[kCGWindowOwnerPID as String] as? Int) != wantedPID { return false }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user