Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f8186a5
cmux-next: keep each painting's subject clear of the sidebar and agen…
teamleaderleo Oct 18, 2026
837bbac
cmux-next: report the art's covering columns from the sidebar and lay…
teamleaderleo Oct 18, 2026
b709d5f
cmux-next: add your own background from a link, and manage your images
teamleaderleo Oct 18, 2026
601ca9d
cmux-next: reveal your images in Finder off the main thread
teamleaderleo Oct 18, 2026
32991c5
cmux-next: measure whether art keeps its subject; slide past the cove…
teamleaderleo Oct 18, 2026
2b2fbaf
cmux-next: link downloads stream under a cap, stay https, and keep th…
teamleaderleo Oct 18, 2026
af13e15
Merge remote-tracking branch 'origin/leo/next-art-subject-crop' into …
teamleaderleo Oct 18, 2026
93f4995
cmux-next: place a background picture by hand (drag, zoom, reset)
teamleaderleo Oct 18, 2026
048cc79
cmux-next: link downloads are Sendable by a lock, safe to cancel befo…
teamleaderleo Oct 18, 2026
236a5d0
Merge remote-tracking branch 'origin/leo/next-own-backgrounds' into l…
teamleaderleo Oct 18, 2026
cd9818b
cmux-next: the slide regression test uses Böcklin's own subject box
teamleaderleo Oct 18, 2026
d5e5402
cmux-next: placing a background by hand is one overlay, in the worksp…
teamleaderleo Oct 18, 2026
79606b3
Merge remote-tracking branch 'origin/feat-cmux-next' into leo/next-pl…
teamleaderleo Oct 18, 2026
a620250
cmux-next: a drag moves the placed picture the right way, and the win…
teamleaderleo Oct 18, 2026
eeccaaf
cmux-next: the backgrounds page provider imports what it uses
teamleaderleo Oct 18, 2026
0dbd438
Merge remote-tracking branch 'origin/leo/next-own-backgrounds' into l…
teamleaderleo Oct 18, 2026
4e6e0bd
cmux-next: the provider's default reveal uses a public import
teamleaderleo Oct 18, 2026
c57ce7b
Merge remote-tracking branch 'origin/leo/next-own-backgrounds' into l…
teamleaderleo Oct 18, 2026
e5c03bd
cmux-next: the adjust test names the art by its selection id and comp…
teamleaderleo Oct 18, 2026
f2ac0d7
webviews: a gallery variant for Add from a Link
teamleaderleo Oct 18, 2026
83234a0
Merge remote-tracking branch 'origin/leo/next-own-backgrounds' into l…
teamleaderleo Oct 18, 2026
6befedf
cmux-next: your images' usage runs off the main actor
teamleaderleo Oct 18, 2026
78e3864
Merge remote-tracking branch 'origin/leo/next-own-backgrounds' into l…
teamleaderleo Oct 18, 2026
7d675dd
cmux-next: a link's name drops its extension at the last dot
teamleaderleo Oct 19, 2026
c1d7f6d
cmux-next: placing fades new views on window updates
teamleaderleo Oct 19, 2026
631b4d7
Merge remote-tracking branch 'origin/leo/next-own-backgrounds' into l…
teamleaderleo Oct 19, 2026
38dd1bf
Merge feat-cmux-next into leo/next-place-background
teamleaderleo Oct 20, 2026
c82625e
Merge feat-cmux-next into leo/next-place-background
teamleaderleo Oct 20, 2026
3d049f5
Merge branch 'feat-cmux-next' into leo/next-place-background
teamleaderleo Oct 20, 2026
cacfd74
Merge branch 'feat-cmux-next' into leo/next-place-background
teamleaderleo Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ struct PageFactory {
// Your own backgrounds from a link, removed or revealed (BackdropImagesPageProvider).
let images = provider.backdropImages.map { images in
BackdropImagesPageProvider(images: images.images, use: { [weak provider] in try await provider?.useBackdropImage($0) ?? "" },
inUse: { [weak services] in services?.settings?.snapshot.backdropSelection })
inUse: { [weak services] in services?.settings?.snapshot.backdropSelection },
adjust: { [weak services] in BackdropAdjustOverlay.begin(in: services?.windows.active?.window) })
}
let routes = [PageRoute(prefix: "cmux.settings.", provider: provider), PageRoute(prefix: "cmux.app.", provider: native)]
+ (images.map { [PageRoute(prefix: BackdropImagesPageProvider.prefix, provider: $0)] } ?? [])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
public import AppKit
import CmuxNextWakeups

/// Placing a background by hand (backgrounds plan phase 6): over the window, with its content
/// faded so the art shows, a drag moves the picture, scrolling or pinching zooms it, Reset goes
/// back to the automatic framing and Done (or Escape, or Return) ends. The arrow keys nudge it
/// (Shift: further), + and - zoom, 0 resets. Each change is saved in ``BackdropPlacementStore``
/// at once, so every window showing the picture follows. One overlay at a time, app-wide; it
/// ends by itself when its window shows another background.
@MainActor
public final class BackdropAdjustOverlay: NSView {
private let backdrop: WindowMaterialView
private let id: String
private let imageSize: CGSize
private var placement: BackdropUserPlacement
private var lastDrag: NSPoint?
/// The window's content views this faded, and their opacity before.
private var faded: [(NSView, CGFloat)] = []
/// Fades views the window adds while placing (a page, a toast): AppKit sends no change for
/// `subviews`, so each window update (after every event it handles) looks for new ones.
private var updateObserver: NSObjectProtocol?
/// The one overlay placing now, in any window.
private static weak var active: BackdropAdjustOverlay?
private let bar = NSVisualEffectView()

/// The content fades to this while placing, so the art shows through.
static let fadedContentAlpha: CGFloat = 0.12

/// Starts placing the picture `window` shows; false when it shows none it can place (no
/// picture, a video). A second call while placing keeps the first.
@discardableResult
public static func begin(in window: NSWindow?) -> Bool {
guard let window, let content = window.contentView, let backdrop = WindowMaterialView.backdrop(of: window),
let adjustable = backdrop.adjustable else { return false }
if let active, active.window === window, active.id == adjustable.id { return true }
active?.done()
let overlay = BackdropAdjustOverlay(backdrop: backdrop, id: adjustable.id, imageSize: adjustable.imageSize,
placement: adjustable.current, frame: content.bounds)
for view in content.subviews where view !== backdrop { overlay.fade(view) }
content.addSubview(overlay, positioned: .above, relativeTo: nil)
Comment on lines +39 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Browser panes cover placement

If Settings shares a window with a visible Chromium browser pane, that page sits above BackdropAdjustOverlay and takes clicks, drags, and scrolls in its area. The overlay is added to the parent window's view tree, below Chromium child windows. Fading the parent's subviews does not bring it above the page, which can also cover the placement controls.

Present placement through WindowOverlayHost, and hide or mask the browser content while placing.

overlay.updateObserver = NotificationCenter.default.addObserver(forName: NSWindow.didUpdateNotification, object: window,
queue: .main) { [weak overlay] _ in
MainActor.assumeIsolated { overlay?.fadeAdded() } // main-proof: observer on queue: .main
}
Comment on lines +41 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Closed windows leave observers

begin(in:) registers a block observer that only done() removes. Closing the window during placement does not call done(), and the overlay has no other cleanup path. The notification center keeps the unused registration after the overlay goes away.

End placement when the window closes, or remove the observer when the overlay is torn down.

// Brought forward: from a separate Settings window, the keys and pointer come here.
window.makeKeyAndOrderFront(nil)
window.makeFirstResponder(overlay)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Placement loses keyboard focus

begin(in:) gives the overlay keyboard focus without telling the window's focus model that an overlay opened. In a real ShellWindow, this reports the new responder. FocusResponderClassifier calls this view windowOrNone, which tells the focus model to put focus back on the current page or terminal immediately.

The overlay therefore loses the keyboard as it opens. Register placement with the existing overlay focus flow, and close that session in done().

active = overlay
return true
}

private func fade(_ view: NSView) {
guard view !== self, view !== backdrop, !faded.contains(where: { $0.0 === view }) else { return }
faded.append((view, view.alphaValue))
view.alphaValue = Self.fadedContentAlpha
}

/// Fades the window's views that came in since.
private func fadeAdded() {
guard let content = superview else { return }
for view in content.subviews { fade(view) }
}

/// The overlay `window` shows, if it is placing (tests).
public static func current(in window: NSWindow?) -> BackdropAdjustOverlay? {
window?.contentView?.subviews.lazy.compactMap { $0 as? BackdropAdjustOverlay }.first
}

init(backdrop: WindowMaterialView, id: String, imageSize: CGSize, placement: BackdropUserPlacement, frame: NSRect) {
self.backdrop = backdrop
self.id = id
self.imageSize = imageSize
self.placement = placement
super.init(frame: frame)
autoresizingMask = [.width, .height]
setAccessibilityRole(.group)
setAccessibilityLabel(Self.hint)
bar.material = .hudWindow
bar.state = .active
bar.wantsLayer = true
bar.layer?.cornerRadius = 12
let hint = ThemedLabel(labelWithString: Self.hint)
let reset = ThemedBezelButton(title: Self.resetTitle, target: self, action: #selector(reset))
let done = ThemedBezelButton(title: Self.doneTitle, target: self, action: #selector(done))
done.keyEquivalent = "\r"
let row = NSStackView(views: [hint, reset, done])
row.spacing = 12
row.edgeInsets = NSEdgeInsets(top: 10, left: 16, bottom: 10, right: 12)
row.translatesAutoresizingMaskIntoConstraints = false
bar.addSubview(row)
bar.translatesAutoresizingMaskIntoConstraints = false
addSubview(bar)
NSLayoutConstraint.activate([
row.leadingAnchor.constraint(equalTo: bar.leadingAnchor), row.trailingAnchor.constraint(equalTo: bar.trailingAnchor),
row.topAnchor.constraint(equalTo: bar.topAnchor), row.bottomAnchor.constraint(equalTo: bar.bottomAnchor),
bar.centerXAnchor.constraint(equalTo: centerXAnchor), bar.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -24),
])
}

@available(*, unavailable)
required init?(coder: NSCoder) { nil }

override public var acceptsFirstResponder: Bool { true }

/// The picture's size on screen at the current zoom.
private var shownSize: CGSize {
let view = backdrop.bounds.size
guard imageSize.width > 0, imageSize.height > 0 else { return .zero }
let scale = max(view.width / imageSize.width, view.height / imageSize.height) * placement.zoom
return CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
}

/// Moves the picture by `dx`, `dy` points (y up), as a drag does. The center stays where the
/// picture still covers the window, so a drag past an edge has no dead travel to undo.
public func move(dx: CGFloat, dy: CGFloat) {
let size = shownSize, view = backdrop.bounds.size
guard size.width > 0, size.height > 0 else { return }
// The picture follows the pointer: the point at the center moves the other way.
let middleX = backdrop.visibleMiddle, middleY = view.height / 2
// Covering keeps the image's origin in [view - size, 0], so the center point lies in
// [middle / size, (middle - view + size) / size].
let x = min(max(placement.centerX - dx / size.width, middleX / size.width), (middleX - view.width + size.width) / size.width)
let y = min(max(placement.centerY + dy / size.height, middleY / size.height), (middleY - view.height + size.height) / size.height)
save(BackdropUserPlacement(centerX: x, centerY: y, zoom: placement.zoom))
}

/// Zooms by `factor` (above 1 closer), as a pinch or a scroll does.
public func zoom(by factor: CGFloat) {
guard factor.isFinite, factor > 0 else { return }
save(BackdropUserPlacement(centerX: placement.centerX, centerY: placement.centerY, zoom: placement.zoom * factor))
Comment on lines +128 to +130

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dragging stalls after zooming out

zoom(by:) keeps the old center even when zooming out makes that center impossible to show. The renderer slides the image back, but move(dx:dy:) still starts from the old center.

For a matching image and window shape, move to centerX = 0.125 at 4×, zoom out to 2×, then drag left slightly. The visible center is already 0.25, so the first drag only corrects the stored center and does not move the picture. Start each drag from the actual rendered placement, including after zoom or resize.

}

private func save(_ new: BackdropUserPlacement) {
// The window shows another background now: nothing of this one to save.
guard backdrop.adjustable?.id == id else { return done() }
placement = new
backdrop.placements.set(new, for: id)
}

override public func keyDown(with event: NSEvent) {
let step: CGFloat = event.modifierFlags.contains(.shift) ? 50 : 10
switch event.specialKey {
case .leftArrow?: move(dx: -step, dy: 0)
case .rightArrow?: move(dx: step, dy: 0)
case .upArrow?: move(dx: 0, dy: step)
case .downArrow?: move(dx: 0, dy: -step)
default:
switch event.charactersIgnoringModifiers {
case "+", "=": zoom(by: 1.1)
case "-", "_": zoom(by: 1 / 1.1)
case "0": reset()
default: super.keyDown(with: event)
}
}
}

override public func mouseDown(with event: NSEvent) { lastDrag = event.locationInWindow }

override public func mouseDragged(with event: NSEvent) {
let point = event.locationInWindow
defer { lastDrag = point }
guard let last = lastDrag else { return }
move(dx: point.x - last.x, dy: point.y - last.y)
}

override public func mouseUp(with event: NSEvent) { lastDrag = nil }

override public func scrollWheel(with event: NSEvent) {
let delta = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY : event.scrollingDeltaY * 8
zoom(by: 1 + delta / 200)
}

override public func magnify(with event: NSEvent) { zoom(by: 1 + event.magnification) }

override public func cancelOperation(_ sender: Any?) { done() }

/// Back to the automatic framing (the picture's subject clear of the panes).
@objc public func reset() {
backdrop.placements.set(nil, for: id)
if let current = backdrop.adjustable?.current { placement = current }
}

/// Ends placing: the content comes back and the placement stays saved.
@objc public func done() {
if let updateObserver { NotificationCenter.default.removeObserver(updateObserver) }
updateObserver = nil
for (view, alpha) in faded { view.alphaValue = alpha }
faded = []
if Self.active === self { Self.active = nil }
let window = window
removeFromSuperview()
window?.makeFirstResponder(window?.contentView)
}

static var hint: String {
String(localized: "backdrop.adjust.hint", defaultValue: "Drag to move the picture. Scroll or pinch to zoom.", bundle: .module)
}
static var resetTitle: String { String(localized: "backdrop.adjust.reset", defaultValue: "Reset", bundle: .module) }
static var doneTitle: String { String(localized: "backdrop.adjust.done", defaultValue: "Done", bundle: .module) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ extension BackdropArtMetadata {
/// The image at `scale` with its top-left corner at `origin` (top-left view coordinates).
/// Whether the subject is kept is measured on the result, never assumed: its box must lie in
/// the visible span across and in the window top to bottom.
private nonisolated func place(scale: Double, origin: CGPoint, viewSize: CGSize, imageSize: CGSize,
nonisolated func place(scale: Double, origin: CGPoint, viewSize: CGSize, imageSize: CGSize,
check: (box: BackdropSubjectBox, span: ClosedRange<Double>, height: Double)) -> BackdropArtPlacement {
let image = CGRect(x: origin.x, y: origin.y, width: Double(imageSize.width) * scale, height: Double(imageSize.height) * scale)
let slack = 0.5
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
public import Foundation

/// Where the person put a background themselves (backgrounds plan phase 6): the point of the
/// image at the center of the visible span, as fractions from its top-left corner, and a zoom
/// past the cover fit. It replaces the automatic subject framing for that background.
public nonisolated struct BackdropUserPlacement: Codable, Equatable, Sendable {
public var centerX: Double
public var centerY: Double
public var zoom: Double

public static let maximumZoom = 4.0

public init(centerX: Double, centerY: Double, zoom: Double) {
let unit = { (value: Double) in min(max(value.isFinite ? value : 0.5, 0), 1) }
self.centerX = unit(centerX)
self.centerY = unit(centerY)
self.zoom = min(max(zoom.isFinite ? zoom : 1, 1), Self.maximumZoom)
}
}

/// The placements the person set, by background id (`BackdropSelection.id`), kept in
/// `background-placement.json` beside the backgrounds folder. Every window reads it; a change
/// posts ``didChange``.
@MainActor
public final class BackdropPlacementStore {
public static let shared = BackdropPlacementStore(file: BackdropArtDownloads.defaultFolder.map {
$0.deletingLastPathComponent().appendingPathComponent("background-placement.json")
})
Comment on lines +26 to +28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Placement data lacks an owner

BackdropPlacementStore.shared adds a new singleton for mutable placement data. The repository rule requires this state to have an owner and be injected, rather than reached globally. A view can use an injected store while removing a picture still changes the global store.

Construct one store in AppServices and pass it to WindowMaterialView and BackdropImagesPageProvider, including the remove action.

Rule Used: Flag new ambient global state in production Swift: a top-level (file-scope) func used as API, a top-level mutable var or a stub class/struct holding a global flag/once-token, a caseless enum/empty struct used purely as a static func/static let namesp... (source)

/// Posted (object: the store, userInfo `id`: the background) after a placement changes.
public static let didChange = Notification.Name("BackdropPlacementStore.didChange")
/// Saves run one at a time in order, so an older placement never lands after a newer one.
private nonisolated static let writes = DispatchQueue(label: "cmux.backdrop-placement.save")

private let file: URL?
private var placements: [String: BackdropUserPlacement]

/// `file` nil keeps placements in memory only (tests).
public init(file: URL?) {
self.file = file
// concurrency-allow: one small JSON file read once, when the first window asks
let data = file.flatMap { try? Data(contentsOf: $0) }
if let data, let read = try? JSONDecoder().decode([String: BackdropUserPlacement].self, from: data) {
placements = read
} else {
placements = [:]
// A file that does not read is kept beside, not overwritten by the next save.
if let file, data != nil {
// concurrency-allow: one rename of a small unreadable file, once
try? FileManager.default.moveItem(at: file, to: file.appendingPathExtension("unreadable"))
}
}
}

public func placement(for id: String) -> BackdropUserPlacement? { placements[id] }

/// Sets (nil: clears) `id`'s placement and saves the file off the main actor.
public func set(_ placement: BackdropUserPlacement?, for id: String) {
guard placements[id] != placement else { return }
placements[id] = placement
NotificationCenter.default.post(name: Self.didChange, object: self, userInfo: ["id": id])
guard let file, let data = try? JSONEncoder().encode(placements) else { return }
Self.writes.async {
try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
try? data.write(to: file, options: .atomic)
Comment on lines +63 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed saves stay hidden

BackdropPlacementStore.set silently ignores folder and file write errors. If the disk is full or the folder is not writable, the picture moves and Done appears to keep it, but the placement disappears after relaunch. Report failed saves through the app’s logging or error path instead of dropping both errors with try?.

}
Comment on lines +62 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Saves use a forbidden queue

BackdropPlacementStore introduces a custom background DispatchQueue for ordinary file saves. The repository's concurrency rule explicitly requires Swift concurrency for this work.

Put the ordered writes on an owned actor and expose an async completion point. Preserve the guarantee that older saves cannot overwrite newer ones.

Rule Used: Flag new legacy async patterns in cmux-owned Swift where Swift concurrency is the correct shape: DispatchQueue.global for ordinary async work, new Combine app state, completion-handler APIs fully under cmux control, or fire-and-forget Tasks with mean... (source)

}

/// Waits for every save queued so far (tests).
public func flush() {
Self.writes.sync {}
Comment on lines +68 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Test helper ships with app

flush() adds a test-only helper to production Sources/. Its comment names tests as its purpose, and its only callers are in BackdropUserPlacementTests. The repository rule prohibits new test-only helpers in shipping source.

Move this helper into the test target and expose only the internal state needed through @testable import.

Rule Used: Flag Swift files under a production Sources path (matching /Sources/ and not under /Tests/) that add a test-only or debug-only seam: a #if DEBUG (or other test-build-guarded) extension/member exposing internal state only for tests or a debugg... (source)

}
}

extension BackdropArtMetadata {
/// The art where the person put it: `user`'s point at the center of the visible span, at
/// `zoom` times the cover fit, slid back as far as needed to still cover the window.
public nonisolated func placement(forViewSize viewSize: CGSize, imageSize: CGSize, visible: BackdropVisibleSpan,
user: BackdropUserPlacement) -> BackdropArtPlacement {
guard viewSize.width > 0, viewSize.height > 0, imageSize.width > 0, imageSize.height > 0 else {
return .whole(viewSize)
}
let (vw, vh) = (Double(viewSize.width), Double(viewSize.height))
let scale = max(vw / Double(imageSize.width), vh / Double(imageSize.height)) * user.zoom
let (w, h) = (Double(imageSize.width) * scale, Double(imageSize.height) * scale)
let middle = (visible.minX + visible.maxX) / 2 * vw
let x = min(max(middle - user.centerX * w, vw - w), 0)
let y = min(max(vh / 2 - user.centerY * h, vh - h), 0)
// The person chose what shows: nothing is measured against a subject.
let whole = BackdropSubjectBox(minX: 0, minY: 0, maxX: 0, maxY: 0)
var placed = place(scale: scale, origin: CGPoint(x: x, y: y), viewSize: viewSize, imageSize: imageSize,
check: (box: whole, span: -Double.infinity...Double.infinity, height: .infinity))
placed.keepsSubject = true
return placed
}

/// The user placement that shows what `placement` shows: its center point and zoom, so a
/// drag starts from the automatic framing on screen.
public nonisolated func userPlacement(from placement: BackdropArtPlacement, viewSize: CGSize, imageSize: CGSize,
visible: BackdropVisibleSpan) -> BackdropUserPlacement {
let c = placement.contentsRect, f = placement.frame
guard f.width > 0, f.height > 0, viewSize.width > 0, viewSize.height > 0, imageSize.width > 0 else {
return BackdropUserPlacement(centerX: 0.5, centerY: 0.5, zoom: 1)
}
// Image fraction at a view point, through the shown part of the image.
let middleX = (visible.minX + visible.maxX) / 2 * Double(viewSize.width), middleY = Double(viewSize.height) / 2
let u = c.minX + (middleX - f.minX) / f.width * c.width
let v = 1 - (c.minY + (middleY - f.minY) / f.height * c.height)
let scale = f.width / (c.width * imageSize.width)
let cover = max(viewSize.width / imageSize.width, viewSize.height / imageSize.height)
return BackdropUserPlacement(centerX: u, centerY: v, zoom: scale / cover)
Comment on lines +108 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 First drag changes the framing

userPlacement(from:) cannot preserve automatic framing that slides beyond the window edge or shrinks below the cover fit. Those are supported results, including the bundled Böcklin case already covered by a test. The returned value clamps zoom to at least 1, and the manual renderer clamps the image back inside the cover bounds.

The first tiny drag therefore changes the framing abruptly instead of starting from what is on screen. Preserve these automatic starting states, or make the transition explicit before accepting a gesture.

}
}
Loading