Skip to content
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

✨ Ability to use macOS 15's window manager with Loop #566

Merged
merged 4 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
A864F4682AA660CD00579738 /* WindowDragManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A864F4672AA660CD00579738 /* WindowDragManager.swift */; };
A867C20E2C26522B005831BC /* Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A867C20D2C26522B005831BC /* Observer.swift */; };
A86949862A8F2BB70051AAAF /* CGKeyCode+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86949852A8F2BB60051AAAF /* CGKeyCode+Extensions.swift */; };
A869C1A12B38C6E600AD1A84 /* StageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869C1A02B38C6E600AD1A84 /* StageManager.swift */; };
A869C1A12B38C6E600AD1A84 /* SystemWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869C1A02B38C6E600AD1A84 /* SystemWindowManager.swift */; };
A86B97AD2AB79E2500099D7F /* ShakeEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86B97AC2AB79E2500099D7F /* ShakeEffect.swift */; };
A86CB7332A3D22E7006A78F2 /* WindowEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86CB7322A3D22E7006A78F2 /* WindowEngine.swift */; };
A8789F6729805B190040512E /* RadialMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8789F6629805B190040512E /* RadialMenuView.swift */; };
Expand Down Expand Up @@ -137,7 +137,7 @@
A864F4672AA660CD00579738 /* WindowDragManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragManager.swift; sourceTree = "<group>"; };
A867C20D2C26522B005831BC /* Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observer.swift; sourceTree = "<group>"; };
A86949852A8F2BB60051AAAF /* CGKeyCode+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGKeyCode+Extensions.swift"; sourceTree = "<group>"; };
A869C1A02B38C6E600AD1A84 /* StageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageManager.swift; sourceTree = "<group>"; };
A869C1A02B38C6E600AD1A84 /* SystemWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemWindowManager.swift; sourceTree = "<group>"; };
A86AFD7529888B29008F4892 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
A86B97AC2AB79E2500099D7F /* ShakeEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShakeEffect.swift; sourceTree = "<group>"; };
A86CB7322A3D22E7006A78F2 /* WindowEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowEngine.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -263,7 +263,7 @@
A864F4672AA660CD00579738 /* WindowDragManager.swift */,
A82521ED29E235AC00139654 /* PermissionsManager.swift */,
A8EF1F08299C87DF00633440 /* IconManager.swift */,
A869C1A02B38C6E600AD1A84 /* StageManager.swift */,
A869C1A02B38C6E600AD1A84 /* SystemWindowManager.swift */,
);
path = Managers;
sourceTree = "<group>";
Expand Down Expand Up @@ -597,7 +597,7 @@
A867C20E2C26522B005831BC /* Observer.swift in Sources */,
A82740982AB00FCE00B9BDC5 /* Color+Extensions.swift in Sources */,
A82B1AF62BD35C8500E2F3F9 /* BehaviorConfiguration.swift in Sources */,
A869C1A12B38C6E600AD1A84 /* StageManager.swift in Sources */,
A869C1A12B38C6E600AD1A84 /* SystemWindowManager.swift in Sources */,
A8330ACD2A3AC1D100673C8D /* CGGeometry+Extensions.swift in Sources */,
A82B1AF22BD35A3800E2F3F9 /* PreviewConfiguration.swift in Sources */,
A8330AC72A3AC19500673C8D /* NSScreen+Extensions.swift in Sources */,
Expand Down
13 changes: 13 additions & 0 deletions Loop/Extensions/AXUIElement+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,19 @@ extension AXUIElement {

return id
}

func performAction(_ action: NSAccessibility.Action) throws {
let error = AXUIElementPerformAction(self, action as CFString)

guard error == .success else {
throw error
}
}

var children: [AXUIElement] {
let children: [AXUIElement]? = try? getValue(.children)
return children ?? []
}
}

extension AXError: Swift.Error {}
Expand Down
3 changes: 2 additions & 1 deletion Loop/Extensions/Defaults+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ extension Defaults.Keys {
)

// Advanced
static let animateWindowResizes = Key<Bool>("animateWindowResizes", default: false, iCloud: true) // BETA
static let useSystemWindowManagerWhenAvailable = Key<Bool>("useSystemWindowManagerWhenAvailable", default: false, iCloud: true)
static let animateWindowResizes = Key<Bool>("animateWindowResizes", default: false, iCloud: true)
static let disableCursorInteraction = Key<Bool>("disableCursorInteraction", default: false, iCloud: true)
static let ignoreFullscreen = Key<Bool>("ignoreFullscreen", default: false, iCloud: true)
static let hideUntilDirectionIsChosen = Key<Bool>("hideUntilDirectionIsChosen", default: false, iCloud: true)
Expand Down
4 changes: 2 additions & 2 deletions Loop/Extensions/NSScreen+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ extension NSScreen {
var stageStripFreeFrame: NSRect {
var frame = visibleFrame

if Defaults[.respectStageManager], StageManager.enabled, StageManager.shown {
if StageManager.position == .leading {
if Defaults[.respectStageManager], SystemWindowManager.StageManager.enabled, SystemWindowManager.StageManager.enabled {
if SystemWindowManager.StageManager.position == .leading {
frame.origin.x += Defaults[.stageStripSize]
}

Expand Down
9 changes: 9 additions & 0 deletions Loop/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -4118,6 +4118,9 @@
}
}
}
},
"Go Back" : {

},
"Gradient" : {
"extractionState" : "manual",
Expand Down Expand Up @@ -9807,6 +9810,9 @@
}
}
}
},
"macOS's \"Tile by dragging windows to screen edges\" feature is currently\nenabled, which will conflict with Loop's window snapping functionality." : {

},
"Measurement unit: percentage" : {
"extractionState" : "manual",
Expand Down Expand Up @@ -18495,6 +18501,9 @@
}
}
}
},
"Use macOS window manager when available" : {

},
"Use pixels" : {
"extractionState" : "manual",
Expand Down
7 changes: 7 additions & 0 deletions Loop/Luminare/Loop/AdvancedConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import Luminare
import SwiftUI

class AdvancedConfigurationModel: ObservableObject {
@Published var useSystemWindowManagerWhenAvailable = Defaults[.useSystemWindowManagerWhenAvailable] {
didSet { Defaults[.useSystemWindowManagerWhenAvailable] = useSystemWindowManagerWhenAvailable }
}

@Published var animateWindowResizes = Defaults[.animateWindowResizes] {
didSet { Defaults[.animateWindowResizes] = animateWindowResizes }
}
Expand Down Expand Up @@ -70,6 +74,9 @@ struct AdvancedConfigurationView: View {

var body: some View {
LuminareSection("General") {
if #available(macOS 15.0, *) {
LuminareToggle("Use macOS window manager when available", isOn: $model.useSystemWindowManagerWhenAvailable)
}
LuminareToggle(
"Animate window resize",
info: .init("This feature is still under development.", .orange),
Expand Down
37 changes: 27 additions & 10 deletions Loop/Luminare/Settings/Behavior/BehaviorConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ class BehaviorConfigurationModel: ObservableObject {
didSet { Defaults[.restoreWindowFrameOnDrag] = restoreWindowFrameOnDrag }
}

@Published var useSystemWindowManagerWhenAvailable = Defaults[.useSystemWindowManagerWhenAvailable] {
didSet { Defaults[.useSystemWindowManagerWhenAvailable] = useSystemWindowManagerWhenAvailable }
}

@Published var enablePadding = Defaults[.enablePadding] {
didSet { Defaults[.enablePadding] = enablePadding }
}
Expand Down Expand Up @@ -74,6 +78,7 @@ class BehaviorConfigurationModel: ObservableObject {
@Published var isPaddingConfigurationViewPresented = false

let previewVisibility = Defaults[.previewVisibility]
let systemSnappingWarning: LuminareInfoView = .init("macOS's \"Tile by dragging windows to screen edges\" feature is currently\nenabled, which will conflict with Loop's window snapping functionality.")
}

struct BehaviorConfigurationView: View {
Expand All @@ -93,17 +98,29 @@ struct BehaviorConfigurationView: View {
}

LuminareSection("Window") {
LuminareToggle("Window snapping", isOn: $model.windowSnapping)
LuminareToggle("Restore window frame on drag", isOn: $model.restoreWindowFrameOnDrag)
LuminareToggle("Include padding", isOn: $model.enablePadding)
if #available(macOS 15, *) {
LuminareToggle(
"Window snapping",
info: SystemWindowManager.MoveAndResize.snappingEnabled ? model.systemSnappingWarning : nil,
isOn: $model.windowSnapping
)
} else {
LuminareToggle("Window snapping", isOn: $model.windowSnapping)
}

if model.enablePadding {
Button("Configure padding…") {
model.isPaddingConfigurationViewPresented = true
}
.luminareModal(isPresented: $model.isPaddingConfigurationViewPresented) {
PaddingConfigurationView(isPresented: $model.isPaddingConfigurationViewPresented)
.frame(width: 400)
// Enabling the system window manager will override these options anyway, so hide them
if !model.useSystemWindowManagerWhenAvailable {
LuminareToggle("Restore window frame on drag", isOn: $model.restoreWindowFrameOnDrag)
LuminareToggle("Include padding", isOn: $model.enablePadding)

if model.enablePadding {
Button("Configure padding…") {
model.isPaddingConfigurationViewPresented = true
}
.luminareModal(isPresented: $model.isPaddingConfigurationViewPresented) {
PaddingConfigurationView(isPresented: $model.isPaddingConfigurationViewPresented)
.frame(width: 400)
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion Loop/Luminare/Settings/Keybindings/KeybindingItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ struct KeybindingItemView: View {
.init(.init(localized: "Size Adjustment"), WindowDirection.sizeAdjustment),
.init(.init(localized: "Shrink"), WindowDirection.shrink),
.init(.init(localized: "Grow"), WindowDirection.grow),
.init(.init(localized: "Move"), WindowDirection.move)
.init(.init(localized: "Move"), WindowDirection.move),
.init(.init(localized: "Go Back"), [WindowDirection.initialFrame, WindowDirection.undo])
]

var moreSection: PickerSection<WindowDirection> {
Expand Down
25 changes: 0 additions & 25 deletions Loop/Managers/StageManager.swift

This file was deleted.

169 changes: 169 additions & 0 deletions Loop/Managers/SystemWindowManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//
// SystemWindowManager.swift
// Loop
//
// Created by Kai Azim on 2023-12-24.
//

import Defaults
import SwiftUI

class SystemWindowManager {
private static let windowManagerDefaults = UserDefaults(suiteName: "com.apple.WindowManager")
private static let dockDefaults = UserDefaults(suiteName: "com.apple.dock")

// MARK: - Stage Manager

enum StageManager {
static var enabled: Bool {
windowManagerDefaults?.bool(forKey: "GloballyEnabled") ?? false
}

static var shown: Bool {
!(windowManagerDefaults?.bool(forKey: "AutoHide") ?? true)
}

static var position: Edge {
dockDefaults?.string(forKey: "orientation") == "left" ? .trailing : .leading
}
}

// MARK: - Move & Resize

// This is a direct mapping of the menu items in the "Move & Resize" menu
@available(macOS 15, *)
enum MoveAndResize: String {
// General
case minimize = "_performMiniaturize:"
case zoom = "_performZoom:"
case fill = "_zoomFill:"
case center = "_zoomCenter:"

// Halves
case left = "_zoomLeft:"
case right = "_zoomRight:"
case top = "_zoomTop:"
case bottom = "_zoomBottom:"

// Quarters
case topLeft = "_zoomTopLeft:"
case topRight = "_zoomTopRight:"
case bottomLeft = "_zoomBottomLeft:"
case bottomRight = "_zoomBottomRight:"

// Arrange
case leftAndRight = "_zoomLeftAndRight:"
case rightAndLeft = "_zoomRightAndLeft:"
case topAndBottom = "_zoomTopAndBottom:"
case bottomAndTop = "_zoomBottomAndTop:"
case quarters = "_zoomQuarters:"

case returnToPreviousSize = "_zoomUntile:"

static var generalActions: [MoveAndResize] {
[.minimize, .zoom, .fill, .center]
}

static var halvesActions: [MoveAndResize] {
[.left, .right, .top, .bottom]
}

static var quartersActions: [MoveAndResize] {
[.topLeft, .topRight, .bottomLeft, .bottomRight]
}

static var arrangeActions: [MoveAndResize] {
[.leftAndRight, .rightAndLeft, .topAndBottom, .bottomAndTop, .quarters]
}

func getItem(for app: NSRunningApplication) throws -> AXUIElement? {
let pid = app.processIdentifier

// Scan menubar items
let element = AXUIElementCreateApplication(pid)
let menubar = try (element.getValue(.menuBar) as CFTypeRef?) as! AXUIElement
let menubarItems = menubar.children.reversed() // Help menu will be last

for menubarItem in menubarItems {
guard let windowMenuItems = menubarItem.children.first?.children else {
continue
}

if MoveAndResize.generalActions.contains(self),
let menuItem = try windowMenuItems.first(where: { try $0.getValue(.identifier) == rawValue }) {
return menuItem
} else {
let menuItemsWithSubmenu = windowMenuItems.filter { $0.children.first?.children != nil }.map(\.children.first)

for item in menuItemsWithSubmenu {
if let menuItem = try item?.children.first(where: { try $0.getValue(.identifier) as String? == rawValue }) {
return menuItem
}
}
}
}

return nil
}

static var snappingEnabled: Bool {
windowManagerDefaults?.bool(forKey: "EnableTilingByEdgeDrag") ?? false
}

static var padding: CGFloat {
windowManagerDefaults?.bool(forKey: "EnableTiledWindowMargins") ?? false ? 9 : 0
}

static func syncPadding() {
let newPadding = padding

Defaults[.enablePadding] = newPadding != 0

if newPadding != 0 {
Defaults[.padding] = PaddingModel(
window: newPadding,
externalBar: 0,
top: newPadding,
bottom: newPadding,
right: newPadding,
left: newPadding,
configureScreenPadding: false
)
}
}
}
}

@available(macOS 15, *)
extension WindowDirection {
var systemEquivalent: SystemWindowManager.MoveAndResize? {
switch self {
case .minimize:
.minimize
case .maximize:
.fill
case .center:
.center
case .leftHalf:
.left
case .rightHalf:
.right
case .topHalf:
.top
case .bottomHalf:
.bottom
case .topLeftQuarter:
.topLeft
case .topRightQuarter:
.topRight
case .bottomLeftQuarter:
.bottomLeft
case .bottomRightQuarter:
.bottomRight
case .initialFrame:
.returnToPreviousSize
default:
nil
}
}
}
Loading
Loading