feat: add same-window compact playback with magnetic snapping

This commit is contained in:
2026-10-10 21:07:14 +02:00
parent 681a7be6f7
commit fa66020951
5 changed files with 826 additions and 39 deletions
+230
View File
@@ -0,0 +1,230 @@
import AppKit
import Foundation
@main
@MainActor
struct CompactWindowChecks {
struct Failure: Error, CustomStringConvertible {
let description: String
}
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
if !condition() { throw Failure(description: "FAIL: \(message)") }
}
static func close(_ actual: CGFloat, _ expected: CGFloat) -> Bool {
abs(actual - expected) < 0.001
}
static func sameFrame(_ actual: NSRect, _ expected: NSRect) -> Bool {
close(actual.minX, expected.minX) && close(actual.minY, expected.minY)
&& close(actual.width, expected.width) && close(actual.height, expected.height)
}
static func geometry() throws {
let screen = NSRect(x: 100, y: 200, width: 1_000, height: 700)
func snap(_ x: CGFloat, _ y: CGFloat, to expectedX: CGFloat, _ expectedY: CGFloat) throws {
let frame = NSRect(x: x, y: y, width: 320, height: 180)
let expected = NSRect(x: expectedX, y: expectedY, width: 320, height: 180)
try require(sameFrame(CompactWindowController.snappedFrame(frame, visibleFrame: screen), expected),
"snap (\(x), \(y)) to (\(expectedX), \(expectedY)), preserving size")
}
// Inset edges: x = 116 / 764, y = 216 / 704. Each axis acts independently.
try snap(130, 460, to: 116, 460)
try snap(750, 460, to: 764, 460)
try snap(440, 230, to: 440, 216)
try snap(440, 690, to: 440, 704)
try snap(130, 230, to: 116, 216)
try snap(750, 230, to: 764, 216)
try snap(130, 690, to: 116, 704)
try snap(750, 690, to: 764, 704)
try snap(140, 460, to: 116, 460)
try snap(740, 460, to: 764, 460)
try snap(440, 240, to: 440, 216)
try snap(440, 680, to: 440, 704)
try snap(92, 460, to: 116, 460)
try snap(788, 460, to: 764, 460)
try snap(141, 460, to: 141, 460)
try snap(739, 460, to: 739, 460)
try snap(440, 241, to: 440, 241)
try snap(440, 679, to: 440, 679)
try snap(440, 192, to: 440, 216)
try snap(440, 728, to: 440, 704)
try snap(91, 460, to: 91, 460)
try snap(789, 460, to: 789, 460)
try snap(440, 191, to: 440, 191)
try snap(440, 729, to: 440, 729)
try snap(440, 460, to: 440, 460)
let negativeScreen = NSRect(x: -1_440, y: -900, width: 1_440, height: 900)
let negativeFrame = NSRect(x: -1_410, y: -870, width: 320, height: 180)
try require(sameFrame(CompactWindowController.snappedFrame(negativeFrame, visibleFrame: negativeScreen),
NSRect(x: -1_424, y: -884, width: 320, height: 180)),
"negative-origin screen uses its own inset edges")
let narrowScreen = NSRect(x: 0, y: 0, width: 100, height: 200)
try require(sameFrame(CompactWindowController.snappedFrame(
NSRect(x: 35, y: 80, width: 40, height: 20), visibleFrame: narrowScreen),
NSRect(x: 44, y: 80, width: 40, height: 20)),
"overlapping magnetic zones choose the closest edge, not first edge")
let usableScreen = NSRect(x: -1_200, y: 50, width: 1_200, height: 800)
let landscape = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: usableScreen)
try require(sameFrame(landscape, NSRect(x: -336, y: 66, width: 320, height: 180)),
"16:9 enters at bottom right with 320pt width and 16pt inset")
let portrait = CompactWindowController.initialFrame(aspect: 9.0 / 16.0, visibleFrame: usableScreen)
try require(sameFrame(portrait, NSRect(x: -336, y: 66, width: 320, height: 5_120.0 / 9.0)),
"portrait preserves exact aspect when it fits")
for aspect in [16.0 / 9.0, 9.0 / 16.0, 100.0, 0.01] {
let frame = CompactWindowController.initialFrame(aspect: aspect, visibleFrame: usableScreen)
try require(frame.origin.x.isFinite && frame.origin.y.isFinite
&& frame.width.isFinite && frame.height.isFinite,
"initial geometry is finite for aspect \(aspect)")
try require(frame.width >= 160 && frame.height >= 90,
"extreme aspect \(aspect) never defeats minimum size")
try require(frame.minX >= usableScreen.minX + 16 && frame.minY >= usableScreen.minY + 16
&& frame.maxX <= usableScreen.maxX - 16 && frame.maxY <= usableScreen.maxY - 16,
"aspect \(aspect) stays inside inset usable screen")
try require(close(frame.maxX, usableScreen.maxX - 16) && close(frame.minY, usableScreen.minY + 16),
"aspect \(aspect) stays anchored bottom right")
}
let smallScreen = NSRect(x: 0, y: 0, width: 300, height: 400)
let bounded = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: smallScreen)
try require(sameFrame(bounded, NSRect(x: 16, y: 16, width: 268, height: 150.75)),
"target width shrinks to usable bounds without distorting ordinary aspect")
}
static func windowBehavior(defaults: UserDefaults) throws {
_ = NSApplication.shared
guard let screen = NSScreen.main else {
throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen")
}
// Creating a window does not display it: never orderFront, makeKey, or enter native fullscreen.
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
defer { window.close() }
guard let content = window.contentView else { throw Failure(description: "FAIL: missing content view") }
let sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14))
content.addSubview(sentinel)
let sentinelFrame = sentinel.frame
window.minSize = NSSize(width: 300, height: 180)
window.contentMinSize = NSSize(width: 220, height: 120)
window.aspectRatio = NSSize(width: 4, height: 3)
window.collectionBehavior = [.managed, .fullScreenPrimary]
window.level = .normal
window.hidesOnDeactivate = true
window.isMovableByWindowBackground = false
let originalFrame = window.frame
let originalMinSize = window.minSize
let originalContentMinSize = window.contentMinSize
let originalAspect = window.aspectRatio
let originalStyle = window.styleMask
let originalTitleVisibility = window.titleVisibility
let originalTransparentTitlebar = window.titlebarAppearsTransparent
let originalCollection = window.collectionBehavior
let originalLevel = window.level
let originalHides = window.hidesOnDeactivate
let originalDrag = window.isMovableByWindowBackground
let identity = ObjectIdentifier(window)
let controller = CompactWindowController(defaults: defaults)
defer { controller.restore(resumeFullScreen: false) }
controller.attach(window)
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary")
try require(controller.snappingEnabled, "fresh isolated preferences default snapping on")
func preservedContent() throws {
try require(ObjectIdentifier(window) == identity && sentinel.window === window,
"compact mode keeps the actual rendering window")
try require(window.contentView === content && sentinel.superview === content
&& content.subviews.count == 1 && sameFrame(sentinel.frame, sentinelFrame),
"compact mode preserves content and attached sentinel without reparenting")
try require(!window.isVisible, "checks never display a window")
}
func restored() throws {
try require(!controller.isCompact && !controller.isTransitioning, "restore finishes in ordinary mode")
try require(sameFrame(window.frame, originalFrame), "restore recovers original normal geometry")
try require(window.minSize == originalMinSize && window.contentMinSize == originalContentMinSize
&& window.aspectRatio == originalAspect, "restore recovers aspect and both minima")
try require(window.styleMask == originalStyle && window.collectionBehavior == originalCollection
&& window.level == originalLevel, "restore recovers style, Spaces policy, and level")
try require(window.titleVisibility == originalTitleVisibility
&& window.titlebarAppearsTransparent == originalTransparentTitlebar,
"restore recovers original native chrome")
try require(window.hidesOnDeactivate == originalHides
&& window.isMovableByWindowBackground == originalDrag,
"restore recovers deactivate and background drag behavior")
try preservedContent()
}
controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isCompact && !controller.isTransitioning, "windowed toggle enters compact synchronously")
try require(window.level == .floating && !window.hidesOnDeactivate, "compact floats and remains visible on deactivate")
try require(window.collectionBehavior.contains([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
"compact can join Spaces, other-app fullscreen, and Stage Manager")
try require(window.collectionBehavior.intersection([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
"compact removes incompatible primary and auxiliary policies")
try require(window.contentMinSize == NSSize(width: 160, height: 90), "compact uses 160x90 content minimum")
try require(window.isMovableByWindowBackground, "compact allows background dragging")
try require(window.canBecomeKey, "compact window still accepts keyboard playback controls")
try preservedContent()
let visible = (window.screen ?? screen).visibleFrame
try require(close(window.frame.maxX, visible.maxX - 16)
&& close(window.frame.minY, visible.minY + 16)
&& close(window.frame.width / window.frame.height, 16.0 / 9.0),
"actual compact window enters at inset bottom right with playback aspect")
let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
width: window.frame.width, height: window.frame.height)
window.setFrame(nearEdge, display: false)
try require(sameFrame(window.frame, nearEdge), "native move installs unsnapped drag position")
controller.snapAfterDrag()
try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16)
&& window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
controller.snappingEnabled = false
let second = CompactWindowController(defaults: defaults)
try require(!second.snappingEnabled, "disabled preference persists across controller instances")
window.setFrame(nearEdge, display: false)
controller.snapAfterDrag()
try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched")
second.snappingEnabled = true
let third = CompactWindowController(defaults: defaults)
try require(third.snappingEnabled, "re-enabled preference persists across controller instances")
controller.snappingEnabled = true
let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135)
window.setFrame(resized, display: false)
controller.updateAspect(16.0 / 9.0)
try require(sameFrame(window.frame, resized),
"repeated aspect updates preserve a user's compact resize and placement")
controller.updateAspect(4.0 / 3.0)
try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode")
try require(close(window.frame.width / window.frame.height, 4.0 / 3.0),
"actual compact window follows changed playback aspect")
try preservedContent()
controller.toggle(aspect: 4.0 / 3.0)
try restored()
controller.toggle(aspect: 16.0 / 9.0)
controller.restore(resumeFullScreen: false)
try restored()
controller.restore(resumeFullScreen: false)
try restored()
// Replay AppKit's failure lifecycle without creating a visible fullscreen Space.
NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: window)
controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isTransitioning && !controller.isCompact,
"compact entry waits for an in-flight native fullscreen transition")
window.delegate?.windowDidFailToEnterFullScreen?(window)
try restored()
}
static func main() throws {
let suite = "terebi.compact-window-checks.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suite) else {
throw Failure(description: "FAIL: cannot create isolated preference suite")
}
defer { defaults.removePersistentDomain(forName: suite) }
try geometry()
try windowBehavior(defaults: defaults)
print("Compact window checks passed")
}
}
+11 -4
View File
@@ -7,9 +7,10 @@ ARCH := $(shell uname -m)
CXXFLAGS := -std=c++20 -mmacosx-version-min=15.0 -O2 -g -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore
OBJECTS := .build/player.o .build/Output.o
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift
APP := .build/terebi.app
.PHONY: all run check check-audio
.PHONY: all run check check-audio check-window
all: $(APP)/Contents/MacOS/terebi $(APP)/Contents/Info.plist
.build:
@@ -21,9 +22,9 @@ all: $(APP)/Contents/MacOS/terebi $(APP)/Contents/Info.plist
.build/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | .build
$(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@
$(APP)/Contents/MacOS/terebi: Sources/App/TerebiApp.swift $(OBJECTS) Makefile
$(APP)/Contents/MacOS/terebi: $(SWIFT_SOURCES) $(OBJECTS) Makefile
mkdir -p $(APP)/Contents/MacOS
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g -import-objc-header Sources/Core/player_macos.h $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g -import-objc-header Sources/Core/player_macos.h $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
$(APP)/Contents/Info.plist: Resources/Info.plist
mkdir -p $(APP)/Contents
@@ -35,7 +36,7 @@ run: all
.build/playback-check: Checks/playback.mm $(OBJECTS) Makefile
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
check: .build/playback-check check-audio
check: .build/playback-check check-audio check-window
mkdir -p .build/fixtures
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 4 -c:v mpeg4 -bf 2 -c:a aac .build/fixtures/av.mp4
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -c:v copy .build/fixtures/video.mp4
@@ -65,3 +66,9 @@ check-audio: .build/audio-continuity-check
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone441.mka -ar 48000 -ac 2 -f f32le .build/fixtures/tone441.mka.f32
ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone-aac.m4a -ar 48000 -ac 2 -f f32le .build/fixtures/tone-aac.m4a.f32
.build/audio-continuity-check .build/fixtures
.build/compact-window-check: Checks/compact-window.swift Sources/App/CompactWindow.swift Makefile | .build
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g Checks/compact-window.swift Sources/App/CompactWindow.swift -framework AppKit -o $@
check-window: .build/compact-window-check
.build/compact-window-check
+10 -3
View File
@@ -22,6 +22,10 @@ The layout follows [IINA's video-first window](https://iina.io/img/screenshot.pn
Opening video fits the window to its display aspect ratio, including non-square pixels, and constrains subsequent window resizing. Fullscreen retains the normal letterboxed renderer; audio-only playback and close release the aspect constraint. Extreme ratios that cannot fit both the screen and usable controls keep a freely resizable letterboxed window.
**Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use **⌃⌘P** or **Playback Actions → Enter Compact Mode**. It starts at the current screen's bottom-right corner with a 16-point inset and a preferred width of 320 points, preserving display aspect where usable screen bounds and the 160 × 90-point minimum allow; extreme ratios retain letterboxing. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Small hover controls provide play/pause, restore, and Playback Actions; keyboard playback controls remain available.
Drag the compact video to move the window; on release, each axis snaps independently when within 24 points of an inset usable-screen edge. **Snap to Screen Edges** is enabled by default and remembers its toggle across launches. Disabling snapping permits free placement. Exiting compact mode restores the previous normal geometry and window behavior, including keep-on-top; entry from fullscreen waits for the native exit animation, and restoring that session returns to fullscreen. Double-clicking compact video also restores the previous mode. Video replacement updates the compact aspect; audio-only replacement, playback errors, and closing the file leave compact mode. Closing the window still quits the app.
| Key | Action |
|---|---|
| `K` / `Space` | Toggle playback |
@@ -37,12 +41,12 @@ Single-key shortcuts apply only to the playback window and do not intercept text
Frame stepping uses decoded presentation timestamps, not an average-FPS time offset, including variable-frame-rate video and reordered B-frames. Steps start from the displayed frame, stop at the first/last frame, and keep the timeline paused. Consecutive same-direction repeats share one counted queue entry without skipping frames or reordering seeks/direction changes. The last presented image remains visible during decoder preroll and queued steps; the progress thumb follows each authoritative stepped position. Opening/closing media still clears the old image. Audio-only files disable stepping; normal playback resumes with audio synchronized to the selected frame.
- Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking video also toggles fullscreen.
- Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking normal-mode video also toggles fullscreen.
- The timeline slider seeks on release and holds the requested position until queued seeking finishes. Both slider tracks span the native thumb-center travel range; the orange fill ends at the current volume thumb center. The volume thumb is opaque light gray.
- **Single-click video:** hide controls. Mouse movement reveals them; they fade after 2.5 seconds, including while paused. Hovering the titlebar/controller, dragging, seeking, an inactive window, errors, or VoiceOver keep them visible.
- **Single-click normal-mode video:** hide controls. Mouse movement reveals them; they fade after 2.5 seconds, including while paused. Hovering the normal titlebar or either controller, dragging, seeking, an inactive normal-mode window, errors, or VoiceOver keep them visible. Compact controls can fade while another app is active; hovering its video reveals them without hiding the desktop cursor.
- Click the right-hand time label to switch between total and remaining time. The titlebar pin keeps the window on top.
Only implemented actions have controls; there are no placeholder playlist, subtitle, picture-in-picture, or settings buttons. Layout and interaction references: IINA v1.5.0's [floating controller](https://github.com/iina/iina/blob/v1.5.0/iina/OSCFloatingView.swift), [preferences](https://github.com/iina/iina/blob/v1.5.0/iina/Preference.swift), and [titlebar](https://github.com/iina/iina/blob/v1.5.0/iina/Titlebar.swift).
Only implemented actions have controls; there are no placeholder playlist, subtitle, or settings buttons. Layout and interaction references: IINA v1.5.0's [floating controller](https://github.com/iina/iina/blob/v1.5.0/iina/OSCFloatingView.swift), [preferences](https://github.com/iina/iina/blob/v1.5.0/iina/Preference.swift), and [titlebar](https://github.com/iina/iina/blob/v1.5.0/iina/Titlebar.swift).
## Where to change things
@@ -50,6 +54,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti
- `Sources/Core/player.cpp`: single demux/decode worker, bounded buffers, timestamps, audio-master synchronization, paused previews, exact frame stepping, decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes all old timeline state before resuming.
- `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely.
- `Sources/App/TerebiApp.swift`: MainActor state polling, file panel/Finder/drop handling, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. The shared `PlayerModel.open` path enqueues play after an accepted open, on the new file's command timeline; the C interface still opens paused for callers that need explicit playback control.
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, magnetic edge snapping, and its persisted preference.
- `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency.
## Runnable check
@@ -62,6 +67,8 @@ Generates short fixtures and exercises the real playback interface and native ou
`make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, and coarse container timestamps. It verifies that repeated forward/backward stepping stays silent and that resumed audio matches the exact selected-frame PCM suffix. It also checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook.
`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, isolated preference persistence, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks.
Volume persists across open, seek, and close. Live gain changes ramp over five milliseconds without callback allocation or locking; a stopped output adopts the requested gain before playback starts, so muted startup cannot leak audio.
+383
View File
@@ -0,0 +1,383 @@
import AppKit
@MainActor
final class CompactWindowController: NSObject {
var onChange: (() -> Void)?
private(set) var isCompact = false
private(set) var isTransitioning = false
var snappingEnabled: Bool {
didSet {
guard snappingEnabled != oldValue else { return }
defaults.set(snappingEnabled, forKey: "compactSnappingEnabled")
onChange?()
}
}
private struct Snapshot {
let frame: NSRect
let minSize: NSSize
let contentMinSize: NSSize
let aspectRatio: NSSize
let styleMask: NSWindow.StyleMask
let titleVisibility: NSWindow.TitleVisibility
let titlebarAppearsTransparent: Bool
let collectionBehavior: NSWindow.CollectionBehavior
let level: NSWindow.Level
let hidesOnDeactivate: Bool
let isMovableByWindowBackground: Bool
@MainActor
init(_ window: NSWindow) {
frame = window.frame
minSize = window.minSize
contentMinSize = window.contentMinSize
aspectRatio = window.aspectRatio
styleMask = window.styleMask
titleVisibility = window.titleVisibility
titlebarAppearsTransparent = window.titlebarAppearsTransparent
collectionBehavior = window.collectionBehavior
level = window.level
hidesOnDeactivate = window.hidesOnDeactivate
isMovableByWindowBackground = window.isMovableByWindowBackground
}
}
private enum FullScreenTransition { case entering, exiting }
private let defaults: UserDefaults
private weak var window: NSWindow?
private var snapshot: Snapshot?
private var fullScreenTransition: FullScreenTransition?
private var delegateProxy: FullScreenDelegate?
private var sessionActive = false
private var wantsCompact = false
private var wantsFullScreen = false
private var originatedFullScreen = false
private var videoAspect = 16.0 / 9.0
private var fittedAspect: Double?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
defaults.register(defaults: ["compactSnappingEnabled": true])
snappingEnabled = defaults.bool(forKey: "compactSnappingEnabled")
super.init()
}
deinit { NotificationCenter.default.removeObserver(self) }
func attach(_ window: NSWindow) {
guard window !== self.window else { return }
if let oldWindow = self.window, fullScreenTransition == nil,
!oldWindow.styleMask.contains(.fullScreen) {
restoreSnapshot(to: oldWindow)
}
restoreDelegate()
NotificationCenter.default.removeObserver(self)
self.window = window
snapshot = nil
fullScreenTransition = nil
sessionActive = false
wantsCompact = false
wantsFullScreen = false
originatedFullScreen = false
setState(compact: false, transitioning: false)
let proxy = FullScreenDelegate(owner: self, original: window.delegate)
delegateProxy = proxy
window.delegate = proxy
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.didEnterFullScreenNotification,
NSWindow.willExitFullScreenNotification, NSWindow.didExitFullScreenNotification,
NSWindow.willCloseNotification] {
NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)),
name: name, object: window)
}
}
func toggle(aspect: Double) {
if sessionActive && wantsCompact {
restore()
return
}
guard let window, aspect.isFinite, aspect > 0 else { return }
if !sessionActive {
sessionActive = true
originatedFullScreen = window.styleMask.contains(.fullScreen) || fullScreenTransition != nil
}
videoAspect = aspect
wantsCompact = true
wantsFullScreen = false
reconcile()
}
func restore(resumeFullScreen: Bool = true) {
guard sessionActive else { return }
wantsCompact = false
wantsFullScreen = resumeFullScreen && originatedFullScreen
reconcile()
}
func updateAspect(_ aspect: Double) {
guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return }
videoAspect = aspect
guard isCompact, !isTransitioning, let window else { return }
fitCompactWindow(window, initial: false)
}
func snapAfterDrag() {
guard isCompact, !isTransitioning, snappingEnabled, let window else { return }
let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window))
if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) }
}
@objc private func windowNotification(_ notification: Notification) {
switch notification.name {
case NSWindow.willEnterFullScreenNotification:
fullScreenTransition = .entering
setState(compact: isCompact, transitioning: true)
case NSWindow.willExitFullScreenNotification:
fullScreenTransition = .exiting
setState(compact: isCompact, transitioning: true)
case NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification:
fullScreenTransition = nil
reconcile()
case NSWindow.willCloseNotification:
restoreDelegate()
NotificationCenter.default.removeObserver(self)
window = nil
snapshot = nil
fullScreenTransition = nil
sessionActive = false
wantsCompact = false
wantsFullScreen = false
originatedFullScreen = false
setState(compact: false, transitioning: false)
default:
break
}
}
private func restoreDelegate() {
if let window, let delegateProxy, window.delegate === delegateProxy {
window.delegate = delegateProxy.original
}
delegateProxy = nil
}
private func fullScreenFailed() {
fullScreenTransition = nil
wantsFullScreen = false
if isCompact {
wantsCompact = true
setState(compact: true, transitioning: false)
} else {
if let window, !window.styleMask.contains(.fullScreen) { restoreSnapshot(to: window) }
finishSession()
}
}
@MainActor
private final class FullScreenDelegate: NSObject, NSWindowDelegate {
// NSObject forwarding returns untyped Any; accesses remain on AppKit's main thread.
nonisolated(unsafe) weak var original: (any NSWindowDelegate)?
private weak var owner: CompactWindowController?
init(owner: CompactWindowController, original: (any NSWindowDelegate)?) {
self.owner = owner
self.original = original
}
nonisolated override func responds(to selector: Selector!) -> Bool {
if super.responds(to: selector) { return true }
return MainActor.assumeIsolated { original?.responds(to: selector) ?? false }
}
nonisolated override func forwardingTarget(for selector: Selector!) -> Any? {
let responds = MainActor.assumeIsolated { original?.responds(to: selector) ?? false }
return responds ? original : nil
}
func windowDidFailToEnterFullScreen(_ window: NSWindow) {
owner?.fullScreenFailed()
original?.windowDidFailToEnterFullScreen?(window)
}
func windowDidFailToExitFullScreen(_ window: NSWindow) {
owner?.fullScreenFailed()
original?.windowDidFailToExitFullScreen?(window)
}
}
// A toggle changes the destination, never reverses an in-flight native animation.
private func reconcile() {
guard let window, fullScreenTransition == nil else { return }
guard sessionActive else {
setState(compact: isCompact, transitioning: false)
return
}
if wantsCompact {
if window.styleMask.contains(.fullScreen) {
toggleNativeFullScreen(window, transition: .exiting)
} else if !isCompact {
enterCompact(window)
} else {
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) }
setState(compact: true, transitioning: false)
}
return
}
if window.styleMask.contains(.fullScreen) {
if wantsFullScreen && snapshot == nil {
finishSession()
} else {
toggleNativeFullScreen(window, transition: .exiting)
}
return
}
if snapshot != nil {
setState(compact: isCompact, transitioning: true)
restoreSnapshot(to: window)
setState(compact: false, transitioning: wantsFullScreen)
}
if wantsFullScreen {
toggleNativeFullScreen(window, transition: .entering)
} else {
finishSession()
}
}
private func toggleNativeFullScreen(_ window: NSWindow, transition: FullScreenTransition) {
fullScreenTransition = transition
setState(compact: isCompact, transitioning: true)
window.toggleFullScreen(nil)
}
private func enterCompact(_ window: NSWindow) {
setState(compact: false, transitioning: true)
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
snapshot = Snapshot(window)
window.aspectRatio = .zero
window.minSize = .zero
window.contentMinSize = .zero
// A borderless NSWindow cannot become key; retain native keyboard and resize behavior.
window.styleMask.formUnion([.titled, .fullSizeContentView, .resizable])
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.minSize = NSSize(width: 160, height: 90)
window.contentMinSize = NSSize(width: 160, height: 90)
var behavior = window.collectionBehavior
behavior.subtract([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone, .moveToActiveSpace])
behavior.formUnion([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary])
window.collectionBehavior = behavior
window.level = .floating
window.hidesOnDeactivate = false
window.isMovableByWindowBackground = true
fitCompactWindow(window, initial: true)
setState(compact: true, transitioning: false)
}
private func restoreSnapshot(to window: NSWindow) {
guard let snapshot else { return }
window.aspectRatio = .zero
window.minSize = .zero
window.contentMinSize = .zero
window.styleMask = snapshot.styleMask
window.titleVisibility = snapshot.titleVisibility
window.titlebarAppearsTransparent = snapshot.titlebarAppearsTransparent
window.collectionBehavior = snapshot.collectionBehavior
window.level = snapshot.level
window.hidesOnDeactivate = snapshot.hidesOnDeactivate
window.isMovableByWindowBackground = snapshot.isMovableByWindowBackground
window.minSize = snapshot.minSize
window.contentMinSize = snapshot.contentMinSize
window.setFrame(snapshot.frame, display: true)
window.aspectRatio = snapshot.aspectRatio
self.snapshot = nil
}
private func finishSession() {
sessionActive = false
originatedFullScreen = false
wantsCompact = false
wantsFullScreen = false
setState(compact: false, transitioning: false)
}
private func setState(compact: Bool, transitioning: Bool) {
guard isCompact != compact || isTransitioning != transitioning else { return }
isCompact = compact
isTransitioning = transitioning
onChange?()
}
private func visibleFrame(for window: NSWindow) -> NSRect {
(window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
}
private func fitCompactWindow(_ window: NSWindow, initial: Bool) {
let visibleFrame = visibleFrame(for: window)
let usable = Self.usableFrame(visibleFrame)
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame)
if !initial {
frame.size = Self.compactSize(aspect: videoAspect, width: window.frame.width, available: usable.size)
frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width)
frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height)
}
window.aspectRatio = .zero
window.setFrame(frame, display: true)
if Self.canFitAspect(videoAspect, available: usable.size) {
window.aspectRatio = NSSize(width: videoAspect, height: 1)
}
fittedAspect = videoAspect
}
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect) -> NSRect {
let usable = usableFrame(visibleFrame)
let size = compactSize(aspect: aspect, width: 320, available: usable.size)
return NSRect(x: usable.maxX - size.width, y: usable.minY, width: size.width, height: size.height)
}
nonisolated static func snappedFrame(_ frame: NSRect, visibleFrame: NSRect) -> NSRect {
guard frame.origin.x.isFinite, frame.origin.y.isFinite,
frame.width.isFinite, frame.height.isFinite, frame.width >= 0, frame.height >= 0,
validVisibleFrame(visibleFrame) else { return frame }
let usable = usableFrame(visibleFrame)
var result = frame
result.origin.x = snappedOrigin(frame.minX, lower: usable.minX, upper: usable.maxX - frame.width)
result.origin.y = snappedOrigin(frame.minY, lower: usable.minY, upper: usable.maxY - frame.height)
return result
}
nonisolated private static func snappedOrigin(_ origin: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
let nearest = abs(origin - lower) <= abs(origin - upper) ? lower : upper
return abs(origin - nearest) <= 24 ? nearest : origin
}
nonisolated private static func validVisibleFrame(_ frame: NSRect) -> Bool {
frame.origin.x.isFinite && frame.origin.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
frame.width > 0 && frame.height > 0 && frame.maxX.isFinite && frame.maxY.isFinite
}
nonisolated private static func usableFrame(_ visibleFrame: NSRect) -> NSRect {
guard validVisibleFrame(visibleFrame) else { return .zero }
return visibleFrame.insetBy(dx: min(16, visibleFrame.width / 2),
dy: min(16, visibleFrame.height / 2))
}
nonisolated private static func canFitAspect(_ aspect: Double, available: NSSize) -> Bool {
guard aspect.isFinite, aspect > 0, available.width > 0, available.height > 0 else { return false }
return aspect >= min(160, available.width) / available.height &&
aspect <= available.width / min(90, available.height)
}
nonisolated private static func compactSize(aspect: Double, width: CGFloat, available: NSSize) -> NSSize {
guard available.width > 0, available.height > 0 else { return .zero }
let aspect = aspect.isFinite && aspect > 0 ? aspect : 16.0 / 9.0
var width = min(max(width, min(160, available.width)), available.width)
if canFitAspect(aspect, available: available) {
width = min(max(width, min(90, available.height) * aspect), available.height * aspect)
return NSSize(width: width, height: width / aspect)
}
// Extreme video ratios letterbox instead of violating the compact minimum or screen bounds.
return NSSize(width: width, height: min(max(width / (16.0 / 9.0), min(90, available.height)), available.height))
}
}
+191 -31
View File
@@ -16,6 +16,17 @@ final class PlayerModel: ObservableObject {
@Published private(set) var controlsVisible = true
@Published private(set) var isFullScreen = false
@Published private(set) var isOnTop = false
let compactWindow = CompactWindowController()
var isCompact: Bool { compactWindow.isCompact }
var canToggleCompact: Bool {
isCompact || compactWindow.isTransitioning ||
(canTogglePlayback && state.has_video && state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0)
}
var compactContentMinimumHeight: CGFloat {
guard let window else { return 90 }
// Hosting-view minimums include the native titlebar safe area, even with full-size content.
return max(0, 90 - (window.frame.height - window.contentLayoutRect.height))
}
let videoView: NSView?
private var handle: OpaquePointer?
@@ -41,6 +52,7 @@ final class PlayerModel: ObservableObject {
videoView = nil
commandError = "Could not create the media player."
}
compactWindow.onChange = { [weak self] in self?.objectWillChange.send() }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.poll() }
}
@@ -131,6 +143,7 @@ final class PlayerModel: ObservableObject {
commandError = "Could not queue closing the file. Please try again."
return
}
compactWindow.restore(resumeFullScreen: false)
fileName = nil
fileIcon = nil
needsVideoFit = false
@@ -178,8 +191,11 @@ final class PlayerModel: ObservableObject {
}
func attachWindow(_ window: NSWindow) {
guard self.window !== window else { return }
self.window = window
window.title = fileName ?? "terebi"
window.contentMinSize = NSSize(width: 400, height: 240)
compactWindow.attach(window)
updateWindowState()
fitVideoWindow()
}
@@ -188,18 +204,30 @@ final class PlayerModel: ObservableObject {
guard let window else { return }
isFullScreen = window.styleMask.contains(.fullScreen)
windowActive = window.isKeyWindow
window.level = isOnTop && !isFullScreen ? .floating : .normal
if !isFullScreen { fitVideoWindow() }
if !compactWindow.isTransitioning {
window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal
}
if !isFullScreen && !compactWindow.isTransitioning { fitVideoWindow() }
revealControls()
}
func toggleFullScreen() {
guard !compactWindow.isTransitioning else { return }
if isCompact { compactWindow.restore(resumeFullScreen: false) }
window?.toggleFullScreen(nil)
revealControls()
}
func toggleCompact() {
guard canToggleCompact else { return }
compactWindow.toggle(aspect: state.video_aspect_ratio)
controlsHovered = false
titleHovered = false
revealControls()
}
func toggleOnTop() {
guard !isFullScreen else { return }
guard !isFullScreen, !isCompact, !compactWindow.isTransitioning else { return }
isOnTop.toggle()
window?.level = isOnTop ? .floating : .normal
revealControls()
@@ -229,7 +257,7 @@ final class PlayerModel: ObservableObject {
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) {
controlsVisible = false
}
NSCursor.setHiddenUntilMouseMoves(hideCursor)
NSCursor.setHiddenUntilMouseMoves(hideCursor && windowActive && !isCompact)
}
func beginSeeking() {
@@ -252,14 +280,19 @@ final class PlayerModel: ObservableObject {
private var shouldHideControls: Bool {
canTogglePlayback && state.has_video && fileName != nil &&
!isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging &&
windowActive && errorText == nil &&
(windowActive || isCompact) && errorText == nil &&
!NSWorkspace.shared.isVoiceOverEnabled
}
private func fitVideoWindow() {
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen else { return }
needsVideoFit = false
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen,
!compactWindow.isTransitioning else { return }
let aspect = state.video_aspect_ratio
if isCompact {
compactWindow.updateAspect(state.video_aspect_ratio)
return
}
needsVideoFit = false
guard state.has_video, aspect.isFinite, aspect > 0 else {
window.aspectRatio = .zero
return
@@ -292,6 +325,10 @@ final class PlayerModel: ObservableObject {
}
let statusChanged = state.status != next.status
state = next
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
(!isBusy && !next.has_video) {
compactWindow.restore(resumeFullScreen: false)
}
if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false }
if !isSeeking && !seekPending {
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
@@ -302,6 +339,7 @@ final class PlayerModel: ObservableObject {
}
func shutdown() {
compactWindow.restore(resumeFullScreen: false)
timer?.invalidate()
timer = nil
hideDeadline = nil
@@ -417,6 +455,50 @@ private struct VideoSurface: NSViewRepresentable {
func updateNSView(_ nsView: NSView, context: Context) {}
}
@MainActor
private struct CompactDragSurface: NSViewRepresentable {
let model: PlayerModel
func makeNSView(context: Context) -> DragView { DragView(model: model) }
func updateNSView(_ view: DragView, context: Context) {}
final class DragView: NSView {
private let model: PlayerModel
private var hoverTracking: NSTrackingArea?
init(model: PlayerModel) {
self.model = model
super.init(frame: .zero)
}
required init?(coder: NSCoder) { nil }
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let hoverTracking { removeTrackingArea(hoverTracking) }
let area = NSTrackingArea(rect: .zero,
options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited, .mouseMoved],
owner: self, userInfo: nil)
hoverTracking = area
addTrackingArea(area)
}
override func mouseEntered(with event: NSEvent) { model.revealControls() }
override func mouseMoved(with event: NSEvent) { model.revealControls() }
override func mouseExited(with event: NSEvent) { model.hideControls(hideCursor: false) }
override func mouseDown(with event: NSEvent) {
if event.clickCount == 2 {
model.toggleCompact()
return
}
model.setControlsDragging(true)
window?.performDrag(with: event)
model.compactWindow.snapAfterDrag()
model.setControlsDragging(false)
}
}
}
@MainActor
private struct PlayerContent: View {
@ObservedObject var model: PlayerModel
@@ -460,20 +542,61 @@ private struct PlayerContent: View {
iconButton(model.isOnTop ? "pin.fill" : "pin",
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.foregroundStyle(.white.opacity(model.isOnTop ? 1 : 0.6))
.disabled(model.isFullScreen)
.disabled(model.isFullScreen || model.compactWindow.isTransitioning)
}
.padding(.leading, model.isFullScreen ? 16 : 86)
.padding(.trailing, 12)
.frame(height: 32)
.foregroundStyle(.white.opacity(0.6))
.background(PlayerGlass(titlebar: true).allowsHitTesting(false))
.background(WindowChrome(model: model))
.onHover(perform: model.setTitleHovered)
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
.accessibilityHidden(!model.controlsVisible)
}
private var actionsMenu: some View {
Menu {
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
Button("Close File", action: model.closeFile).disabled(!model.canClose)
Divider()
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
.disabled(!model.canToggleCompact)
Toggle("Snap to Screen Edges", isOn: Binding(
get: { model.compactWindow.snappingEnabled },
set: { model.compactWindow.snappingEnabled = $0 }))
Divider()
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
} label: {
Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.accessibilityLabel("Playback Actions")
.help("Playback Actions")
}
private var compactControls: some View {
HStack(spacing: 16) {
iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill",
model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
.disabled(!model.canTogglePlayback)
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
.disabled(model.compactWindow.isTransitioning)
actionsMenu
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.foregroundStyle(.white.opacity(0.85))
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
.onHover(perform: model.setControlsHovered)
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
.accessibilityHidden(!model.controlsVisible)
}
private var playbackControls: some View {
VStack(spacing: 8) {
HStack(spacing: 0) {
@@ -509,20 +632,8 @@ private struct PlayerContent: View {
HStack(spacing: 12) {
iconButton(model.isFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
Menu {
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
Button("Close File", action: model.closeFile).disabled(!model.canClose)
Divider()
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.disabled(model.isFullScreen)
} label: {
Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.accessibilityLabel("Playback Actions")
.help("Playback Actions")
.disabled(model.compactWindow.isTransitioning)
actionsMenu
}
.frame(width: 106, alignment: .trailing)
}
@@ -580,6 +691,9 @@ private struct PlayerContent: View {
.exclusively(before: TapGesture().onEnded { model.hideControls() })
)
}
if model.isCompact {
CompactDragSurface(model: model).accessibilityHidden(true)
}
if let error = model.errorText {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow)
@@ -617,9 +731,24 @@ private struct PlayerContent: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if model.isCompact {
Text(model.fileName ?? "terebi")
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.padding(8)
.frame(maxWidth: .infinity)
.foregroundStyle(.white.opacity(0.75))
.background(PlayerGlass(titlebar: true))
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(false)
.accessibilityHidden(!model.controlsVisible)
compactControls
.position(x: geometry.size.width / 2, y: geometry.size.height - 28)
} else {
titlebar
}
if model.canTogglePlayback {
if model.canTogglePlayback && !model.isCompact {
playbackControls
.frame(width: 440, height: 68)
.background {
@@ -663,14 +792,17 @@ private struct PlayerContent: View {
}
.onContinuousHover { phase in
switch phase {
case .active: break
case .active:
if model.isCompact { model.revealControls() }
case .ended: model.hideControls(hideCursor: false)
}
}
}
.frame(minWidth: model.isCompact ? 160 : 400,
minHeight: model.isCompact ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.frame(minWidth: 400, minHeight: 240)
.preferredColorScheme(.dark)
.background(WindowChrome(model: model))
}
}
@@ -687,6 +819,7 @@ private struct WindowChrome: NSViewRepresentable {
private weak var configuredWindow: NSWindow?
private var monitor: Any?
private var observers: [NSObjectProtocol] = []
private var dragOrigin: NSPoint?
init(model: PlayerModel) {
self.model = model
@@ -747,9 +880,23 @@ private struct WindowChrome: NSViewRepresentable {
}
})
}
monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .keyDown]) { [weak self] event in
monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .leftMouseUp, .keyDown]) { [weak self] event in
let consumed = MainActor.assumeIsolated {
guard let self, event.window === self.configuredWindow else { return false }
guard let self, let window = self.configuredWindow else { return false }
if event.type == .leftMouseUp {
let origin = self.dragOrigin
self.dragOrigin = nil
if let origin {
Task { @MainActor [weak self, weak window] in
guard let self, let window, self.configuredWindow === window,
self.model.isCompact, window.frame.origin != origin else { return }
self.model.compactWindow.snapAfterDrag()
}
}
return false
}
guard event.window === window else { return false }
if event.type == .leftMouseDown { self.dragOrigin = window.frame.origin }
if event.type == .keyDown && event.keyCode == 48 { self.model.revealControls() }
if event.type == .mouseMoved || event.type == .leftMouseDown {
self.model.revealControls()
@@ -768,7 +915,7 @@ private struct WindowChrome: NSViewRepresentable {
}
func updateButtons() {
let hidden = !model.controlsVisible || model.isFullScreen
let hidden = !model.controlsVisible || model.isFullScreen || model.isCompact
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
if let view = configuredWindow?.standardWindowButton(button), view.isHidden != hidden {
view.isHidden = hidden
@@ -776,11 +923,17 @@ private struct WindowChrome: NSViewRepresentable {
}
}
override func mouseDown(with event: NSEvent) { window?.performDrag(with: event) }
override func mouseDown(with event: NSEvent) {
model.setControlsDragging(true)
window?.performDrag(with: event)
model.compactWindow.snapAfterDrag()
model.setControlsDragging(false)
}
func detach() {
if let monitor { NSEvent.removeMonitor(monitor) }
monitor = nil
dragOrigin = nil
observers.forEach(NotificationCenter.default.removeObserver)
observers.removeAll()
configuredWindow = nil
@@ -849,8 +1002,15 @@ struct TerebiApp: App {
CommandGroup(after: .windowArrangement) {
Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
.keyboardShortcut("f", modifiers: [.control, .command])
.disabled(model.compactWindow.isTransitioning)
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
.keyboardShortcut("p", modifiers: [.control, .command])
.disabled(!model.canToggleCompact)
Toggle("Snap to Screen Edges", isOn: Binding(
get: { model.compactWindow.snappingEnabled },
set: { model.compactWindow.snappingEnabled = $0 }))
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.disabled(model.isFullScreen)
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
}
}
}