Repository navigation
cmux-next: place a background picture by hand (drag, zoom, reset) #21743
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f8186a5
837bbac
b709d5f
601ca9d
32991c5
2b2fbaf
af13e15
93f4995
048cc79
236a5d0
cd9818b
d5e5402
79606b3
a620250
eeccaaf
0dbd438
4e6e0bd
c57ce7b
e5c03bd
f2ac0d7
83234a0
6befedf
78e3864
7d675dd
c1d7f6d
631b4d7
38dd1bf
c82625e
3d049f5
cacfd74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The overlay therefore loses the keyboard as it opens. Register placement with the existing overlay focus flow, and close that session in |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a matching image and window shape, move to |
||
| } | ||
|
|
||
| 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 |
|---|---|---|
| @@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Construct one store in 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
Comment on lines
+62
to
+65
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Move this helper into the test target and expose only the internal state needed through 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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. |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If Settings shares a window with a visible Chromium browser pane, that page sits above
BackdropAdjustOverlayand 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.