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

Repeated commands: Select which sizes to cycle between on repeated half actions #1434

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
4 changes: 4 additions & 0 deletions Rectangle.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
6490B39F27BF98840056C220 /* BottomCenterRightEighthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6490B39E27BF98840056C220 /* BottomCenterRightEighthCalculation.swift */; };
6490B3A127BF98C70056C220 /* BottomRightEighthCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6490B3A027BF98C70056C220 /* BottomRightEighthCalculation.swift */; };
729E0A982AFF76B1006E2F48 /* CenterProminentlyCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 729E0A972AFF76B1006E2F48 /* CenterProminentlyCalculation.swift */; };
7BE578EF2C5BF4EE0083DAE3 /* CycleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */; };
866661F2257D248A00A9CD2D /* RepeatedExecutionsInThirdsCalculation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866661F1257D248A00A9CD2D /* RepeatedExecutionsInThirdsCalculation.swift */; };
94E9B08E2C3B8D97004C7F41 /* MacTilingDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9B08D2C3B8D97004C7F41 /* MacTilingDefaults.swift */; };
94E9B0902C3E4578004C7F41 /* StringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9B08F2C3E4578004C7F41 /* StringExtension.swift */; };
Expand Down Expand Up @@ -186,6 +187,7 @@
6490B39E27BF98840056C220 /* BottomCenterRightEighthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomCenterRightEighthCalculation.swift; sourceTree = "<group>"; };
6490B3A027BF98C70056C220 /* BottomRightEighthCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomRightEighthCalculation.swift; sourceTree = "<group>"; };
729E0A972AFF76B1006E2F48 /* CenterProminentlyCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CenterProminentlyCalculation.swift; sourceTree = "<group>"; };
7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleSize.swift; sourceTree = "<group>"; };
866661F1257D248A00A9CD2D /* RepeatedExecutionsInThirdsCalculation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatedExecutionsInThirdsCalculation.swift; sourceTree = "<group>"; };
94E9B08D2C3B8D97004C7F41 /* MacTilingDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTilingDefaults.swift; sourceTree = "<group>"; };
94E9B08F2C3E4578004C7F41 /* StringExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -552,6 +554,7 @@
9821405F22B3EFB200ABFB3F /* Defaults.swift */,
984EDB0E29A42ED200D119D2 /* LaunchOnLogin.swift */,
98C1008B2305F1FA006E5344 /* SubsequentExecutionMode.swift */,
7BE578EE2C5BF4ED0083DAE3 /* CycleSize.swift */,
985B9BF422B93EEC00A2E8F0 /* ApplicationToggle.swift */,
9824703022AFA8470037B409 /* RectangleStatusItem.swift */,
9824703622B0F3200037B409 /* WindowAction.swift */,
Expand Down Expand Up @@ -922,6 +925,7 @@
9824703722B0F3200037B409 /* WindowAction.swift in Sources */,
B4521F932BD7CEFB00FD43CC /* ChangeWindowDimensionCalculation.swift in Sources */,
9821402922B3889100ABFB3F /* LowerLeftCalculation.swift in Sources */,
7BE578EF2C5BF4EE0083DAE3 /* CycleSize.swift in Sources */,
9821402122B3884600ABFB3F /* BottomHalfCalculation.swift in Sources */,
98910B42231476B30066EC23 /* PrefsViewController.swift in Sources */,
9851A5C3251BEBA300ECF78C /* OrientationAware.swift in Sources */,
Expand Down
221 changes: 122 additions & 99 deletions Rectangle/Base.lproj/Main.storyboard

Large diffs are not rendered by default.

130 changes: 130 additions & 0 deletions Rectangle/CycleSize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//
// CycleSize.swift
// Rectangle
//
// Created by Eskil Gjerde Sviggum on 01/08/2024.
// Copyright © 2024 Ryan Hanson. All rights reserved.
//

import Foundation

enum CycleSize: Int, CaseIterable {
case twoThirds = 0
case oneHalf = 1
case oneThird = 2
case oneQuarter = 3
case threeQuarters = 4

static func fromBits(bits: Int) -> Set<CycleSize> {
Set(
Self.allCases.filter {
(bits >> $0.rawValue) & 1 == 1
}
)
}

static var firstSize = CycleSize.oneHalf
static var defaultSizes: Set<CycleSize> = [.oneHalf, .twoThirds, .oneThird]

// The expected order of the cycle sizes is to start with the
// first division, then go gradually upwards in size and wrap
// around to the smaller sizes.
//
// For example if all cycles are used, the order should be:
// 1/2, 2/3, 3/4, 1/4, 1/3
static var sortedSizes: [CycleSize] = {
let sortedSizes = Self.allCases.sorted(by: { $0.fraction < $1.fraction })

guard let firstSizeIndex = sortedSizes.firstIndex(of: firstSize) else {
return sortedSizes
}

let lessThanFistSizes = sortedSizes[0..<firstSizeIndex]
let greaterThanFistSizes = sortedSizes[(firstSizeIndex + 1)..<sortedSizes.count]

return [firstSize] + greaterThanFistSizes + lessThanFistSizes
}()
}

extension CycleSize {

var title: String {
switch self {
case .twoThirds:
"⅔"
case .oneHalf:
"½"
case .oneThird:
"⅓"
case .oneQuarter:
"¼"
case .threeQuarters:
"¾"
}
}

var fraction: Float {
switch self {
case .twoThirds:
2 / 3
case .oneHalf:
1 / 2
case .oneThird:
1 / 3
case .oneQuarter:
1 / 4
case .threeQuarters:
3 / 4
}
}

var isAlwaysEnabled: Bool {
if self == .firstSize {
return true
}

return false
}

}

extension Set where Element == CycleSize {
func toBits() -> Int {
var bits = 0
self.forEach {
bits |= 1 << $0.rawValue
}
return bits
}
}

class CycleSizesDefault: Default {
public private(set) var key: String = "selectedCycleSizes"
private var initialized = false

var value: Set<CycleSize> {
didSet {
if initialized {
UserDefaults.standard.set(value.toBits(), forKey: key)
}
}
}

init() {
let bits = UserDefaults.standard.integer(forKey: key)
value = CycleSize.fromBits(bits: bits)
initialized = true
}

func load(from codable: CodableDefault) {
if let bits = codable.int {
let divisions = CycleSize.fromBits(bits: bits)
value = divisions
}
}

func toCodable() -> CodableDefault {
return CodableDefault(int: value.toBits())
}

}
4 changes: 4 additions & 0 deletions Rectangle/Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class Defaults {
static let hideMenuBarIcon = BoolDefault(key: "hideMenubarIcon")
static let alternateDefaultShortcuts = BoolDefault(key: "alternateDefaultShortcuts") // switch to magnet defaults
static let subsequentExecutionMode = SubsequentExecutionDefault()
static let selectedCycleSizes = CycleSizesDefault()
static let cycleSizesIsChanged = BoolDefault(key: "cycleSizesIsChanged")
static let allowAnyShortcut = BoolDefault(key: "allowAnyShortcut")
static let windowSnapping = OptionalBoolDefault(key: "windowSnapping")
static let almostMaximizeHeight = FloatDefault(key: "almostMaximizeHeight")
Expand Down Expand Up @@ -95,6 +97,8 @@ class Defaults {
hideMenuBarIcon,
alternateDefaultShortcuts,
subsequentExecutionMode,
selectedCycleSizes,
cycleSizesIsChanged,
allowAnyShortcut,
windowSnapping,
almostMaximizeHeight,
Expand Down
121 changes: 117 additions & 4 deletions Rectangle/PrefsWindow/SettingsViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,17 @@ class SettingsViewController: NSViewController {
@IBOutlet weak var stageSlider: NSSlider!
@IBOutlet weak var stageLabel: NSTextField!

@IBOutlet weak var cycleSizesView: NSStackView!

@IBOutlet var cycleSizesViewHeightConstraint: NSLayoutConstraint!

@IBOutlet var todoViewHeightConstraint: NSLayoutConstraint!


private var aboutTodoWindowController: NSWindowController?

private var cycleSizeCheckboxes = [NSButton]()

@IBAction func toggleLaunchOnLogin(_ sender: NSButton) {
let newSetting: Bool = sender.state == .on
if #available(macOS 13, *) {
Expand Down Expand Up @@ -64,6 +73,7 @@ class SettingsViewController: NSViewController {
}

Defaults.subsequentExecutionMode.value = mode
initializeCycleSizesView(animated: true)
}

@IBAction func gapSliderChanged(_ sender: NSSlider) {
Expand Down Expand Up @@ -123,7 +133,7 @@ class SettingsViewController: NSViewController {
@IBAction func toggleTodoMode(_ sender: NSButton) {
let newSetting: Bool = sender.state == .on
Defaults.todo.enabled = newSetting
showHideTodoModeSettings()
showHideTodoModeSettings(animated: true)
Notification.Name.todoMenuToggled.post()
}

Expand Down Expand Up @@ -226,9 +236,22 @@ class SettingsViewController: NSViewController {

initializeTodoModeSettings()

self.cycleSizeCheckboxes.forEach {
$0.removeFromSuperview()
}

let cycleSizeCheckboxes = makeCycleSizeCheckboxes()
cycleSizeCheckboxes.forEach { checkbox in
cycleSizesView.addArrangedSubview(checkbox)
}
self.cycleSizeCheckboxes = cycleSizeCheckboxes

initializeCycleSizesView(animated: false)

Notification.Name.configImported.onPost(using: {_ in
self.initializeTodoModeSettings()
self.initializeToggles()
self.initializeCycleSizesView(animated: false)
})

Notification.Name.menuBarIconHidden.onPost(using: {_ in
Expand All @@ -249,11 +272,15 @@ class SettingsViewController: NSViewController {
TodoManager.initReflowShortcut()
toggleTodoShortcutView.setAssociatedUserDefaultsKey(TodoManager.toggleDefaultsKey, withTransformerName: MASDictionaryTransformerName)
reflowTodoShortcutView.setAssociatedUserDefaultsKey(TodoManager.reflowDefaultsKey, withTransformerName: MASDictionaryTransformerName)
showHideTodoModeSettings()
showHideTodoModeSettings(animated: false)
}

private func showHideTodoModeSettings() {
todoView.isHidden = !Defaults.todo.userEnabled
private func showHideTodoModeSettings(animated: Bool) {
animateChanges(animated: animated) {
let isEnabled = Defaults.todo.userEnabled
todoView.isHidden = !isEnabled
todoViewHeightConstraint.isActive = !isEnabled
}
}

func initializeToggles() {
Expand Down Expand Up @@ -282,6 +309,92 @@ class SettingsViewController: NSViewController {
} else {
stageView.isHidden = true
}


setToggleStatesForCycleSizeCheckboxes()
}

private func initializeCycleSizesView(animated: Bool = false) {
let showOptionsView = Defaults.subsequentExecutionMode.value == .resize

if showOptionsView {
setToggleStatesForCycleSizeCheckboxes()
}

animateChanges(animated: animated) {
cycleSizesView.isHidden = !showOptionsView
cycleSizesViewHeightConstraint.isActive = !showOptionsView
}
}

private func animateChanges(animated: Bool, block: () -> Void) {
if animated {
NSAnimationContext.runAnimationGroup({context in
context.duration = 0.3
context.allowsImplicitAnimation = true

block()
view.layoutSubtreeIfNeeded()
}, completionHandler: nil)
} else {
block()
}
}

private func makeCycleSizeCheckboxes() -> [NSButton] {
CycleSize.sortedSizes.map { division in
let button = NSButton(checkboxWithTitle: division.title, target: self, action: #selector(didCheckCycleSizeCheckbox(sender:)))
button.tag = division.rawValue
button.setContentCompressionResistancePriority(.required, for: .vertical)
return button
}
}

@objc private func didCheckCycleSizeCheckbox(sender: Any?) {
guard let checkbox = sender as? NSButton else {
Logger.log("Expected action to be sent from NSButton. Instead, sender is: \(String(describing: sender))")
return
}

let rawValue = checkbox.tag

guard let cycleSize = CycleSize(rawValue: rawValue) else {
Logger.log("Expected tag of cycle size checkbox to match a value of CycleSize. Got: \(String(describing: rawValue))")
return
}

// If selected cycle sizes has not been changed, write the defaults.
if !Defaults.cycleSizesIsChanged.enabled {
Defaults.selectedCycleSizes.value = CycleSize.defaultSizes
}

Defaults.cycleSizesIsChanged.enabled = true

if checkbox.state == .on {
Defaults.selectedCycleSizes.value.insert(cycleSize)
} else {
Defaults.selectedCycleSizes.value.remove(cycleSize)
}
}

private func setToggleStatesForCycleSizeCheckboxes() {
let useDefaultCycleSizes = !Defaults.cycleSizesIsChanged.enabled
let cycleSizes = useDefaultCycleSizes ? CycleSize.defaultSizes : Defaults.selectedCycleSizes.value

cycleSizeCheckboxes.forEach { checkbox in
guard let cycleSizeForCheckbox = CycleSize(rawValue: checkbox.tag) else {
return
}

let isAlwaysEnabled = cycleSizeForCheckbox.isAlwaysEnabled
let isChecked = isAlwaysEnabled || cycleSizes.contains(cycleSizeForCheckbox)
checkbox.state = isChecked ? .on : .off

// Show that the box cannot be unchecked.
if isAlwaysEnabled {
checkbox.isEnabled = false
}
}
}

}
Expand Down
22 changes: 9 additions & 13 deletions Rectangle/WindowCalculation/RepeatedExecutionsCalculation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ protocol RepeatedExecutionsCalculation {

func calculateFirstRect(_ params: RectCalculationParameters) -> RectResult

func calculateSecondRect(_ params: RectCalculationParameters) -> RectResult

func calculateThirdRect(_ params: RectCalculationParameters) -> RectResult
func calculateRect(for cycleDivision: CycleSize, params: RectCalculationParameters) -> RectResult

}

Expand All @@ -27,18 +25,16 @@ extension RepeatedExecutionsCalculation {
else {
return calculateFirstRect(params)
}

let position = count % 3

switch (position) {
case 1:
return calculateSecondRect(params)
case 2:
return calculateThirdRect(params)
default:
return calculateFirstRect(params)
}
let useDefaultPositions = !Defaults.cycleSizesIsChanged.enabled
let positions = useDefaultPositions ? CycleSize.defaultSizes : Defaults.selectedCycleSizes.value

let sortedPositions = CycleSize.sortedSizes
.filter { positions.contains($0) }

let position = count % sortedPositions.count

return calculateRect(for: sortedPositions[position], params: params)
}

}
Loading