Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 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
32ad947
cmux-next: video backgrounds fade in and free their decoder when out …
teamleaderleo Oct 18, 2026
b709d5f
cmux-next: add your own background from a link, and manage your images
teamleaderleo Oct 18, 2026
86efdb2
cmux-next: place a Chief's picture in its circle, and go back to the …
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
059c0c5
cmux-next: a restored video seeks to its exact frame once its item is…
teamleaderleo Oct 18, 2026
2b2fbaf
cmux-next: link downloads stream under a cap, stay https, and keep th…
teamleaderleo Oct 18, 2026
67870bc
cmux-next: cap Chief pictures by pixels before decoding, and one GIF cap
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
7175953
cmux-next: a restore reported ready on the spot still seeks; the resu…
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
c757f2d
Merge remote-tracking branch 'origin/leo/next-place-background' into …
teamleaderleo Oct 18, 2026
dd25428
Merge remote-tracking branch 'origin/leo/next-video-playback' into ne…
teamleaderleo Oct 18, 2026
dc8d546
Merge remote-tracking branch 'origin/leo/next-chief-pictures-limits' …
teamleaderleo Oct 18, 2026
fb08d69
cmux-next: background video and framing settings (play, on battery, k…
teamleaderleo Oct 18, 2026
9d8f72c
cmux-next: Chief pictures move only as home.chiefPictureMotion allows
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
20a6e35
Merge remote-tracking branch 'origin/leo/next-place-background' into …
teamleaderleo Oct 18, 2026
0eab085
cmux-next: read the power source type unretained; the framing test re…
teamleaderleo Oct 18, 2026
a620250
cmux-next: a drag moves the placed picture the right way, and the win…
teamleaderleo Oct 18, 2026
bc16ade
cmux-next: the native Chief header stays still; square GIF tiles keep…
teamleaderleo Oct 18, 2026
eeccaaf
cmux-next: the backgrounds page provider imports what it uses
teamleaderleo Oct 18, 2026
bcebe1f
cmux-next: Chief picture test records an issue without returning it
teamleaderleo Oct 18, 2026
64d1115
Merge remote-tracking branch 'origin/leo/next-chief-pictures' into le…
teamleaderleo Oct 18, 2026
0dbd438
Merge remote-tracking branch 'origin/leo/next-own-backgrounds' into l…
teamleaderleo Oct 18, 2026
1440bb5
Merge remote-tracking branch 'origin/leo/next-place-background' into …
teamleaderleo Oct 18, 2026
9a91070
Merge remote-tracking branch 'origin/leo/next-chief-pictures-limits' …
teamleaderleo Oct 18, 2026
ed158b1
cmux-next: settings and Chief picture tests record issues without ret…
teamleaderleo Oct 18, 2026
830e272
cmux-next: say that Chief picture motion is for Home pages; tile word…
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
d1927b0
Merge remote-tracking branch 'origin/leo/next-place-background' into …
teamleaderleo Oct 18, 2026
e5c03bd
cmux-next: the adjust test names the art by its selection id and comp…
teamleaderleo Oct 18, 2026
0504b6e
Merge remote-tracking branch 'origin/leo/next-place-background' into …
teamleaderleo Oct 18, 2026
6e2bbc0
Regenerate settings exports
teamleaderleo Oct 18, 2026
f2ac0d7
webviews: a gallery variant for Add from a Link
teamleaderleo Oct 18, 2026
9a5c81c
Merge remote-tracking branch 'origin/feat-cmux-next' into leo/next-ba…
teamleaderleo Oct 18, 2026
872b2ff
Merge remote-tracking branch 'origin/leo/next-own-backgrounds' into l…
teamleaderleo Oct 18, 2026
b549866
chore(next): PR autofix at 872b2ff10e3d
Oct 18, 2026
6befedf
cmux-next: your images' usage runs off the main actor
teamleaderleo Oct 18, 2026
eacf8ba
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
2c1d200
cmux-next: the decoder test waits for the looper's items
teamleaderleo Oct 19, 2026
a46b450
Merge remote-tracking branch 'origin/leo/next-own-backgrounds' into l…
teamleaderleo Oct 19, 2026
885910c
Merge remote-tracking branch 'origin/feat-cmux-next' into leo/next-ba…
teamleaderleo Oct 19, 2026
91e4b52
chore(next): PR autofix at 885910cd28ad
Oct 19, 2026
0bc794f
Regenerate settings exports and strings
teamleaderleo Oct 19, 2026
5ef17f0
Merge remote-tracking branch 'origin/feat-cmux-next' into leo/next-ba…
teamleaderleo Oct 19, 2026
4688400
Regenerate settings exports
teamleaderleo Oct 19, 2026
a3c55b6
Merge feat-cmux-next into leo/next-background-settings
teamleaderleo Oct 20, 2026
fd58df3
chore(next): PR autofix at a3c55b67ae33
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,198 @@
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).
private var subviewsObservation: NSKeyValueObservation?
/// 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.subviewsObservation = content.observe(\.subviews, options: [.new]) { [weak overlay] content, _ in
let added = content.subviews.map(ObjectIdentifier.init)
MainDelivery().run { overlay?.fadeAdded(added) }
}
// Brought forward: from a separate Settings window, the keys and pointer come here.
window.makeKeyAndOrderFront(nil)
window.makeFirstResponder(overlay)
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 (by identity, read on the main actor).
private func fadeAdded(_ added: [ObjectIdentifier]) {
guard let content = superview else { return }
for view in content.subviews where added.contains(ObjectIdentifier(view)) { 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))
}

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() {
subviewsObservation = 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")
})
/// 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)
}
}

/// Waits for every save queued so far (tests).
public func flush() {
Self.writes.sync {}
}
}

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)
}
}
Loading