Skip to content

Commit

Permalink
✨ Animate window resizes; close #19
Browse files Browse the repository at this point in the history
  • Loading branch information
MrKai77 authored Sep 2, 2023
2 parents 97fc5d7 + da20db3 commit 69dba55
Show file tree
Hide file tree
Showing 10 changed files with 209 additions and 88 deletions.
14 changes: 9 additions & 5 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
A80397D22A93287C006D2796 /* MenuBarExtraAccess in Frameworks */ = {isa = PBXBuildFile; productRef = A80397D12A93287C006D2796 /* MenuBarExtraAccess */; };
A80397D42A932993006D2796 /* MenubarIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80397D32A932993006D2796 /* MenubarIconView.swift */; };
A82521EC29E234EB00139654 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82521EB29E234EB00139654 /* AboutViewController.swift */; };
A82521EE29E235AC00139654 /* AccessibilityAccessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82521ED29E235AC00139654 /* AccessibilityAccessManager.swift */; };
A82521EE29E235AC00139654 /* PermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82521ED29E235AC00139654 /* PermissionsManager.swift */; };
A8330ABD2A3AC0CA00673C8D /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8330ABC2A3AC0CA00673C8D /* Bundle+Extensions.swift */; };
A8330AC12A3AC13100673C8D /* Defaults+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8330AC02A3AC13100673C8D /* Defaults+Extensions.swift */; };
A8330AC52A3AC15900673C8D /* Notification+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8330AC42A3AC15900673C8D /* Notification+Extensions.swift */; };
Expand All @@ -33,6 +33,7 @@
A88266002980931C00BCB197 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88265FF2980931C00BCB197 /* SettingsView.swift */; };
A882660829809F6F00BCB197 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A882660729809F6F00BCB197 /* GeneralSettingsView.swift */; };
A883642F298B7288005D6C19 /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A883642E298B7288005D6C19 /* ServiceManagement.framework */; };
A8878A252AA3B2C800850A66 /* WindowTransformAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8878A242AA3B2C800850A66 /* WindowTransformAnimation.swift */; };
A8A2ABE72A3FB0370067B5A9 /* KeybindMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8A2ABE62A3FB0370067B5A9 /* KeybindMonitor.swift */; };
A8A2ABEB2A3FBFBA0067B5A9 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8A2ABEA2A3FBFBA0067B5A9 /* VisualEffectView.swift */; };
A8D5A7D62A91384D004EA5BB /* DirectionSelectorSquareSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8D5A7D52A91384D004EA5BB /* DirectionSelectorSquareSegment.swift */; };
Expand All @@ -58,7 +59,7 @@
A80397D32A932993006D2796 /* MenubarIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenubarIconView.swift; sourceTree = "<group>"; };
A80521312A84878200BF7E22 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
A82521EB29E234EB00139654 /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = "<group>"; };
A82521ED29E235AC00139654 /* AccessibilityAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityAccessManager.swift; sourceTree = "<group>"; };
A82521ED29E235AC00139654 /* PermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = "<group>"; };
A8291D6D2A4513D200C5CB69 /* .swiftlint.yml */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = "<group>"; tabWidth = 2; };
A8330ABC2A3AC0CA00673C8D /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = "<group>"; };
A8330AC02A3AC13100673C8D /* Defaults+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Defaults+Extensions.swift"; sourceTree = "<group>"; };
Expand All @@ -82,6 +83,7 @@
A88265FF2980931C00BCB197 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A882660729809F6F00BCB197 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = "<group>"; };
A883642E298B7288005D6C19 /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; };
A8878A242AA3B2C800850A66 /* WindowTransformAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTransformAnimation.swift; sourceTree = "<group>"; };
A8A2ABE62A3FB0370067B5A9 /* KeybindMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindMonitor.swift; sourceTree = "<group>"; };
A8A2ABEA2A3FBFBA0067B5A9 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = "<group>"; };
A8D5A7D52A91384D004EA5BB /* DirectionSelectorSquareSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectionSelectorSquareSegment.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -131,16 +133,17 @@
A8330ABB2A3AC05200673C8D /* Helpers */ = {
isa = PBXGroup;
children = (
A848D8A62A8C2F3F00060834 /* LoopManager.swift */,
A86CB7322A3D22E7006A78F2 /* WindowEngine.swift */,
A87376F52AA288EB001890F4 /* Window.swift */,
A8878A242AA3B2C800850A66 /* WindowTransformAnimation.swift */,
A8330AD32A3AC27600673C8D /* WindowDirection.swift */,
A8A2ABE62A3FB0370067B5A9 /* KeybindMonitor.swift */,
A82521ED29E235AC00139654 /* AccessibilityAccessManager.swift */,
A82521ED29E235AC00139654 /* PermissionsManager.swift */,
A8EF1F08299C87DF00633440 /* IconManager.swift */,
A8E6D2002A416494005751D4 /* LoopTriggerKeys.swift */,
A8504D2C2A85832F00C2EFDA /* SoftwareUpdater.swift */,
A8A2ABEA2A3FBFBA0067B5A9 /* VisualEffectView.swift */,
A848D8A62A8C2F3F00060834 /* LoopManager.swift */,
);
path = Helpers;
sourceTree = "<group>";
Expand Down Expand Up @@ -383,6 +386,7 @@
A8DCC98A2981F43F00D41065 /* PreviewSettingsView.swift in Sources */,
A87376F62AA288EB001890F4 /* Window.swift in Sources */,
A8330AC52A3AC15900673C8D /* Notification+Extensions.swift in Sources */,
A8878A252AA3B2C800850A66 /* WindowTransformAnimation.swift in Sources */,
A8D5A7D82A913862004EA5BB /* DirectionSelectorCircleSegment.swift in Sources */,
A83667C82A3D7D910001D630 /* AXUIElement+Extensions.swift in Sources */,
A8330AD42A3AC27600673C8D /* WindowDirection.swift in Sources */,
Expand All @@ -396,7 +400,7 @@
A8330ACB2A3AC1C000673C8D /* Angle+Extensions.swift in Sources */,
A8504D2D2A85832F00C2EFDA /* SoftwareUpdater.swift in Sources */,
A8330ACF2A3AC1E900673C8D /* View+Extensions.swift in Sources */,
A82521EE29E235AC00139654 /* AccessibilityAccessManager.swift in Sources */,
A82521EE29E235AC00139654 /* PermissionsManager.swift in Sources */,
A8E59C4A297F98670064D4BA /* RadialMenuController.swift in Sources */,
A86949862A8F2BB70051AAAF /* CGKeyCode+Extensions.swift in Sources */,
A80397D42A932993006D2796 /* MenubarIconView.swift in Sources */,
Expand Down
34 changes: 0 additions & 34 deletions Loop/Helpers/AccessibilityAccessManager.swift

This file was deleted.

4 changes: 1 addition & 3 deletions Loop/Helpers/KeybindMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
import Cocoa

class KeybindMonitor {

static private let accessibilityAccessManager = AccessibilityAccessManager()
static let shared = KeybindMonitor()

private var eventTap: CFMachPort?
Expand Down Expand Up @@ -89,7 +87,7 @@ class KeybindMonitor {

self.eventTap = newEventTap

if KeybindMonitor.accessibilityAccessManager.getStatus() {
if PermissionsManager.Accessibility.getStatus() {
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, newEventTap, 0)
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
CGEvent.tapEnable(tap: newEventTap!, enable: true)
Expand Down
4 changes: 2 additions & 2 deletions Loop/Helpers/LoopManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Defaults

class LoopManager {

private let accessibilityAccessManager = AccessibilityAccessManager()
private let accessibilityAccessManager = PermissionsManager()
private let keybindMonitor = KeybindMonitor.shared
private let iconManager = IconManager()

Expand Down Expand Up @@ -80,7 +80,7 @@ class LoopManager {
frontmostWindow = nil

// Loop will only open if accessibility access has been granted
if accessibilityAccessManager.getStatus() {
if PermissionsManager.Accessibility.getStatus() {
self.frontmostWindow = WindowEngine.getFrontmostWindow()
self.screenWithMouse = NSScreen.screenWithMouse

Expand Down
50 changes: 50 additions & 0 deletions Loop/Helpers/PermissionsManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// AccessibilityAccessManager.swift
// Loop
//
// Created by Kai Azim on 2023-04-08.
//

import SwiftUI
import Defaults

class PermissionsManager {
class Accessibility {
static func getStatus() -> Bool {
// Get current state for accessibility access
let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: false]
let status = AXIsProcessTrustedWithOptions(options)

return status
}

static func requestAccess() {
if PermissionsManager.Accessibility.getStatus() {
return
}
let alert = NSAlert()
alert.messageText = "\(Bundle.main.appName) Needs Accessibility Permissions"
alert.informativeText = "Please grant accessibility access to be able to resize windows."
alert.runModal()
}
}

class ScreenRecording {
static func getStatus() -> Bool {
return CGPreflightScreenCaptureAccess()
}

static func requestAccess() {
if PermissionsManager.ScreenRecording.getStatus() {
return
}

let alert = NSAlert()
alert.messageText = "\(Bundle.main.appName) Needs Screen Recording Permissions"
alert.informativeText = "Please grant screen recording access to be able to resize windows (with animation)."
alert.runModal()

CGRequestScreenCaptureAccess()
}
}
}
62 changes: 57 additions & 5 deletions Loop/Helpers/Window.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,63 @@ class Window {
return CGRect(origin: self.origin, size: self.size)
}

@discardableResult
func setFrame(_ rect: CGRect) -> Bool {
if self.setOrigin(rect.origin) && self.setSize(rect.size) {
return true
func setFrame(_ rect: CGRect, animate: Bool = false) {
if animate {
let animation = WindowTransformAnimation(rect, window: self)
animation.startInBackground()
} else {
self.setOrigin(rect.origin)
self.setSize(rect.size)
}
}

/// MacOS doesn't provide us a way to find the minimum size of a window from the accessibility API.
/// So we deliberately force-resize the window to 0x0 and see how small it goes, take note of the frame,
/// then we resotere the original window size. However, this does have one big consequence. The user
/// can see a single frame when the window is being resized to 0x0, then restored. So to counteract this,
/// we take a screenshot of the screen, overlay it, and get the minimum size then close the overlay window.
/// - Parameters:
/// - screen: The screen the window is on
/// - completion: What to do with the minimum size
func getMinSize(screen: NSScreen, completion: @escaping (CGSize) -> Void) {
// Take screenshot of screen
guard let displayID = screen.displayID else { return }
let imageRef = CGDisplayCreateImage(displayID)
let image = NSImage(cgImage: imageRef!, size: .zero)

// Initialize the overlay NSPanel
let panel = NSPanel(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.hasShadow = false
panel.backgroundColor = NSColor.white.withAlphaComponent(0.00001)
panel.level = .floating
panel.ignoresMouseEvents = true
panel.setFrame(screen.frame, display: false)
panel.contentView = NSImageView(image: image)
panel.orderFrontRegardless()

var minSize: CGSize = .zero
DispatchQueue.main.async {

// Force-resize the window to 0x0
let startingSize = self.size
self.setSize(CGSize(width: 0, height: 0))

// Take note of the minimum size
minSize = self.size

// Restore original window size
self.setSize(startingSize)

DispatchQueue.main.async {
// Close window, then activate completion handler
panel.close()
completion(minSize)
}
}
return false
}
}
46 changes: 14 additions & 32 deletions Loop/Helpers/WindowEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,21 @@ struct WindowEngine {
window.setFullscreen(false)

let oldWindowFrame = window.frame
guard let screenFrame = screen.safeScreenFrame,
let newWindowFrame = WindowEngine.generateWindowFrame(oldWindowFrame, screenFrame, direction)
else { return }
guard let screenFrame = screen.safeScreenFrame else { return }
guard var targetWindowFrame = WindowEngine.generateWindowFrame(oldWindowFrame, screenFrame, direction) else { return }

window.setFrame(newWindowFrame)
// Calculate the window's minimum window size and change the target accordingly
window.getMinSize(screen: screen) { minSize in
if (targetWindowFrame.minX + minSize.width) > screen.frame.maxX {
targetWindowFrame.origin.x = screen.frame.maxX - minSize.width
}

if window.frame != newWindowFrame {
WindowEngine.handleSizeConstrainedWindow(
window: window,
windowFrame: window.frame,
screenFrame: screenFrame
)
if (targetWindowFrame.minY + minSize.height) > screen.frame.maxY {
targetWindowFrame.origin.y = screen.frame.maxY - minSize.height
}

// Resize window
window.setFrame(targetWindowFrame, animate: true)
}
}

Expand All @@ -33,7 +36,7 @@ struct WindowEngine {
let window = Window(pid: app.processIdentifier) else { return nil }

#if DEBUG
print("=== NEW WINDOW ===")
print("===== NEW WINDOW =====")
print("Frontmost app: \(app)")
print("kAXWindowRole: \(window.role ?? "N/A")")
print("kAXStandardWindowSubrole: \(window.subrole ?? "N/A")")
Expand Down Expand Up @@ -98,25 +101,4 @@ struct WindowEngine {
return nil
}
}

private static func handleSizeConstrainedWindow(window: Window, windowFrame: CGRect, screenFrame: CGRect) {

// If the window is fully shown on the screen
if (windowFrame.maxX <= screenFrame.maxX) && (windowFrame.maxY <= screenFrame.maxY) {
return
}

// If not, then Loop will auto re-adjust the window size to be fully shown on the screen
var fixedWindowFrame = windowFrame

if fixedWindowFrame.maxX > screenFrame.maxX {
fixedWindowFrame.origin.x = screenFrame.maxX - fixedWindowFrame.width
}

if fixedWindowFrame.maxY > screenFrame.maxY {
fixedWindowFrame.origin.y = screenFrame.maxY - fixedWindowFrame.height
}

window.setOrigin(fixedWindowFrame.origin)
}
}
49 changes: 49 additions & 0 deletions Loop/Helpers/WindowTransformAnimation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// WindowTransformAnimation.swift
// Loop
//
// Created by Kai Azim on 2023-09-02.
//

import SwiftUI

/// Animate a window's resize!
class WindowTransformAnimation: NSAnimation {
private var targetFrame: CGRect
private let oldFrame: CGRect
private let window: Window

init(_ newRect: CGRect, window: Window) {
self.targetFrame = newRect
self.oldFrame = window.frame
self.window = window
super.init(duration: 0.2, animationCurve: .easeOut)
self.frameRate = 60.0
self.animationBlockingMode = .nonblocking
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func startInBackground() {
DispatchQueue.global().async { [self] in
self.start()
RunLoop.current.run()
}
}

override public var currentProgress: NSAnimation.Progress {
didSet {
let value = CGFloat(self.currentValue)
let newFrame = CGRect(
x: oldFrame.origin.x + value * (targetFrame.origin.x - oldFrame.origin.x),
y: oldFrame.origin.y + value * (targetFrame.origin.y - oldFrame.origin.y),
width: oldFrame.size.width + value * (targetFrame.size.width - oldFrame.size.width),
height: oldFrame.size.height + value * (targetFrame.size.height - oldFrame.size.height)
)

window.setFrame(newFrame)
}
}
}
7 changes: 3 additions & 4 deletions Loop/LoopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ struct LoopApp: App {

class AppDelegate: NSObject, NSApplicationDelegate {

private let accessibilityAccessManager = AccessibilityAccessManager()
private let iconManager = IconManager()
private let loopManager = LoopManager()

Expand All @@ -95,9 +94,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {

// Check accessibility access, then if access is not granted,
// show a more informative alert asking for accessibility access
if !accessibilityAccessManager.getStatus() {
accessibilityAccessManager.requestAccess()
}
PermissionsManager.Accessibility.requestAccess()
PermissionsManager.ScreenRecording.requestAccess()

iconManager.restoreCurrentAppIcon()

loopManager.startObservingKeys()
Expand Down
Loading

0 comments on commit 69dba55

Please sign in to comment.