feat: add panel to set user preferences (closes #8)

This commit is contained in:
Louis Pontoise committed 2019-10-23 23:00:49 +09:00
1 parent 5ddd2f95fd
commit a99482525b
12 files changed
+487 -254

No files matched your search

+22 -14
View File
@@ -7,18 +7,24 @@
objects = {
/* Begin PBXBuildFile section */
D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */; };
D04BA20D4A240843293B3B52 /* Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA56355579F78776E6D51 /* Cell.swift */; };
D04BA278D9EFA568C8D18A4C /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAD1BED44EAEB77FED8A4 /* WindowManager.swift */; };
D04BA3261C7DA5F48310E654 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA90C6C36DB1D65BC2B66 /* Application.swift */; };
D04BA57A871B7269BEBAFF84 /* Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA35456DA0DDA74F9687E /* Keyboard.swift */; };
D04BA6368E681BE3A408AC99 /* PreferencesPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA02F476DE30C4647886C /* PreferencesPanel.swift */; };
D04BA70FF7262BF5F9E6E13B /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */; };
D04BA86F2E687F573F920D93 /* HighInterpolationImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE70D6AFD2E52A71701D /* HighInterpolationImageView.swift */; };
D04BA8EBC0365A019A27C7EA /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */; };
D04BA9119E2329DB5A35B3C7 /* ThumbnailsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */; };
D04BA960DDD1D32A3019C835 /* CollectionViewCenterFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA3202A2C22C347E849B3 /* CollectionViewCenterFlowLayout.swift */; };
D04BAE1A64F5572B19F9E2B1 /* preferences.json in Resources */ = {isa = PBXBuildFile; fileRef = D04BA8924E1644C932B8CBAA /* preferences.json */; };
D04BA9CCE02D30C8164A552A /* SystemPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */; };
D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAA44C837F3A67403B9DB /* main.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
D04BA02F476DE30C4647886C /* PreferencesPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesPanel.swift; sourceTree = "<group>"; };
D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = "<group>"; };
D04BA0CE87BE264C52987ED1 /* 7 windows - 2 lines - wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - wide window.jpg"; sourceTree = "<group>"; };
D04BA0E1C5DBC07108AC2F54 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
D04BA0F5EB832B8E142B654B /* 4 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "4 windows - 1 line.jpg"; sourceTree = "<group>"; };
@@ -27,9 +33,11 @@
D04BA1FC9022590D7AA02486 /* 1 window - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "1 window - 1 line.jpg"; sourceTree = "<group>"; };
D04BA27584274BC8C2D9809F /* 3 windows - 1 line.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "3 windows - 1 line.png"; sourceTree = "<group>"; };
D04BA2C7B51F68651B3C60E2 /* 6 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "6 windows - 1 line.jpg"; sourceTree = "<group>"; };
D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemPermissions.swift; sourceTree = "<group>"; };
D04BA3202A2C22C347E849B3 /* CollectionViewCenterFlowLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewCenterFlowLayout.swift; sourceTree = "<group>"; };
D04BA32F25860B686DFE818A /* 3 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line.jpg"; sourceTree = "<group>"; };
D04BA35456DA0DDA74F9687E /* Keyboard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keyboard.swift; sourceTree = "<group>"; };
D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = "<group>"; };
D04BA4336B6004A0A99849AD /* package.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = package.json; sourceTree = "<group>"; };
D04BA4B5292629AA6B560216 /* package_release.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = package_release.sh; sourceTree = "<group>"; };
D04BA4F23325560BC0BCDDB7 /* 7 windows - 2 lines - tall window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - tall window.jpg"; sourceTree = "<group>"; };
@@ -41,7 +49,6 @@
D04BA7B6AAB0812631BBC7A2 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.info; path = Info.plist; sourceTree = "<group>"; };
D04BA7ECCE728582D9ECA613 /* determine_version.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = determine_version.sh; sourceTree = "<group>"; };
D04BA82F792DF53958D92572 /* alt-tab-macos.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "alt-tab-macos.app"; sourceTree = BUILT_PRODUCTS_DIR; };
D04BA8924E1644C932B8CBAA /* preferences.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = preferences.json; sourceTree = "<group>"; };
D04BA8DDB96B0A60C3394351 /* dark-background.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-background.png"; sourceTree = "<group>"; };
D04BA90C6C36DB1D65BC2B66 /* Application.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
D04BA92541D46EA4F6943A72 /* package-lock.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "package-lock.json"; sourceTree = "<group>"; };
@@ -57,6 +64,7 @@
D04BAD82F652C4ED5D8BF7AE /* 3 windows - 1 line - tall window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "3 windows - 1 line - tall window.jpg"; sourceTree = "<group>"; };
D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D04BAE1243C9B4BE3ED1B524 /* 7 windows - 2 lines - extra wide window.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "7 windows - 2 lines - extra wide window.jpg"; sourceTree = "<group>"; };
D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailsPanel.swift; sourceTree = "<group>"; };
D04BAE70D6AFD2E52A71701D /* HighInterpolationImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HighInterpolationImageView.swift; sourceTree = "<group>"; };
D04BAF076A30A1BAFEDBEA66 /* 5 windows - 2 lines.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "5 windows - 2 lines.jpg"; sourceTree = "<group>"; };
D04BAFA277EAE3BDDDB61110 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = "<group>"; };
@@ -91,14 +99,6 @@
);
sourceTree = "<group>";
};
D04BA18D1EDDD56CA89604B6 /* resources */ = {
isa = PBXGroup;
children = (
D04BA8924E1644C932B8CBAA /* preferences.json */,
);
path = resources;
sourceTree = "<group>";
};
D04BA1A461BBB5A9BE15E015 /* Products */ = {
isa = PBXGroup;
children = (
@@ -155,6 +155,8 @@
D04BA35456DA0DDA74F9687E /* Keyboard.swift */,
D04BAD1BED44EAEB77FED8A4 /* WindowManager.swift */,
D04BADCB1C0F50340A6CAFC2 /* Preferences.swift */,
D04BA3F15EAE8D8C39B6F2CF /* Screen.swift */,
D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */,
);
path = logic;
sourceTree = "<group>";
@@ -166,6 +168,9 @@
D04BAE70D6AFD2E52A71701D /* HighInterpolationImageView.swift */,
D04BA56355579F78776E6D51 /* Cell.swift */,
D04BA90C6C36DB1D65BC2B66 /* Application.swift */,
D04BA02F476DE30C4647886C /* PreferencesPanel.swift */,
D04BAE5BBE182DD5DDFE2E3E /* ThumbnailsPanel.swift */,
D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */,
);
path = ui;
sourceTree = "<group>";
@@ -195,7 +200,6 @@
D04BA7B6AAB0812631BBC7A2 /* Info.plist */,
D04BAA44C837F3A67403B9DB /* main.swift */,
D04BAA1C553891551B903DA7 /* logic */,
D04BA18D1EDDD56CA89604B6 /* resources */,
);
path = "alt-tab-macos";
sourceTree = "<group>";
@@ -251,7 +255,6 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D04BAE1A64F5572B19F9E2B1 /* preferences.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -270,6 +273,11 @@
D04BA278D9EFA568C8D18A4C /* WindowManager.swift in Sources */,
D04BA3261C7DA5F48310E654 /* Application.swift in Sources */,
D04BA70FF7262BF5F9E6E13B /* Preferences.swift in Sources */,
D04BA6368E681BE3A408AC99 /* PreferencesPanel.swift in Sources */,
D04BA9119E2329DB5A35B3C7 /* ThumbnailsPanel.swift in Sources */,
D04BA8EBC0365A019A27C7EA /* Screen.swift in Sources */,
D04BA9CCE02D30C8164A552A /* SystemPermissions.swift in Sources */,
D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -284,7 +292,7 @@
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = "alt-tab-macos/Info.plist";
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 10.11;
MACOSX_DEPLOYMENT_TARGET = 10.12;
PRODUCT_BUNDLE_IDENTIFIER = "com.lwouis.alt-tab-macos";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 4.2;
@@ -299,7 +307,7 @@
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = "alt-tab-macos/Info.plist";
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 10.11;
MACOSX_DEPLOYMENT_TARGET = 10.12;
PRODUCT_BUNDLE_IDENTIFIER = "com.lwouis.alt-tab-macos";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 4.2;
+7 -1
View File
@@ -1,5 +1,11 @@
import Cocoa
class Keyboard {
static func listenToGlobalEvents(_ delegate: Application) {
listenToGlobalKeyboardEvents(delegate)
}
}
var eventTap: CFMachPort?
func listenToGlobalKeyboardEvents(_ delegate: Application) {
@@ -22,7 +28,7 @@ func listenToGlobalKeyboardEvents(_ delegate: Application) {
func keyboardHandler(_ cgEvent: CGEvent, _ delegate: Application) -> Unmanaged<CGEvent>? {
if cgEvent.type == .keyDown || cgEvent.type == .keyUp || cgEvent.type == .flagsChanged {
if let event = NSEvent.init(cgEvent: cgEvent) {
if let event = NSEvent(cgEvent: cgEvent) {
let keyDown = event.type == .keyDown
let optionKeyEvent = event.keyCode == Preferences.metaKey
let tabKeyEvent = event.keyCode == Preferences.tabKey
+160 -77
View File
@@ -1,90 +1,173 @@
import Foundation
import Cocoa
struct PreferencesDecodable: Decodable {
let maxScreenUsage: CGFloat?
let windowPadding: CGFloat?
let cellPadding: CGFloat?
let cellBorderWidth: CGFloat?
let maxThumbnailsPerRow: CGFloat?
let thumbnailMaxWidth: CGFloat?
let thumbnailMaxHeight: CGFloat?
let iconSize: CGFloat?
let fontHeight: CGFloat?
let interItemPadding: CGFloat?
let tabKey: UInt16?
let metaKey: UInt16?
let metaModifierFlagInt: UInt?
let highlightColorString : String?
let thumbnailQuality : UInt?
let windowDisplayDelay: Int?
}
let defaults = [
"maxScreenUsage": "0.8",
"windowPadding": "20",
"cellPadding": "6",
"cellBorderWidth": "2",
"maxThumbnailsPerRow": "4",
"thumbnailMaxWidth": "0",
"thumbnailMaxHeight": "0",
"iconSize": "32",
"fontHeight": "15",
"interItemPadding": "4",
"tabKey": "48",
"metaKey": "59",
"metaModifierFlag": "262144",
"highlightColor": "white",
"thumbnailQuality": "4",
"windowDisplayDelay": "200",
]
func fileLines(_ url: URL) throws -> Int {
try String(contentsOf: url, encoding: .utf8).split(separator: "\n").count
}
class Preferences {
static var rawValues = [String: String]()
static var maxScreenUsage: CGFloat = 0
static var windowPadding: CGFloat = 0
static var cellPadding: CGFloat = 0
static var cellBorderWidth: CGFloat = 0
static var maxThumbnailsPerRow: CGFloat = 0
static var thumbnailMaxWidth: CGFloat = 0
static var thumbnailMaxHeight: CGFloat = 0
static var iconSize: CGFloat = 0
static var fontHeight: CGFloat = 0
static var interItemPadding: CGFloat = 0
static var tabKey: UInt16 = 0
static var metaKey: UInt16 = 0
static var metaModifierFlag: NSEvent.ModifierFlags = .control
static var highlightColor: NSColor = .white
static var thumbnailQuality: NSImageInterpolation = .high
static var windowDisplayDelay: DispatchTimeInterval = .milliseconds(200)
static var font: NSFont = .systemFont(ofSize: 15)
class Preferences: Decodable {
private static let decoded: PreferencesDecodable? = decodeFromJson()
private static let defaultsFile = fileFromPreferencesFolder("alt-tab-macos-defaults.json")
private static let userFile = fileFromPreferencesFolder("alt-tab-macos.json")
private static func decodeFromJson() -> PreferencesDecodable? {
static func loadFromDiskAndUpdateValues() {
do {
let defaultFile = Bundle.main.url(forResource: "preferences", withExtension: "json")!
let userFile = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("Preferences", isDirectory: true)
.appendingPathComponent("alt-tab-macos.json")
try handleNoFileOrOldFile(userFile: userFile, defaultFile: defaultFile)
return try JSONDecoder().decode(PreferencesDecodable.self, from: Data(contentsOf: userFile))
rawValues = try loadFromDisk()
try rawValues.forEach {
try updateAndValidateValue($0.key, $0.value)
}
} catch {
debugPrint("Error parsing preferences.json", error)
debugPrint("Error loading preferences from JSON file on disk", error)
NSApp.terminate(NSApplication.shared)
}
}
static func updateAndValidateValue(_ valueName: String, _ value: String) throws {
switch valueName {
case "maxScreenUsage":
maxScreenUsage = try CGFloat(value).orThrow()
case "windowPadding":
windowPadding = try CGFloat(value).orThrow()
case "cellPadding":
cellPadding = try CGFloat(value).orThrow()
case "cellBorderWidth":
cellBorderWidth = try CGFloat(value).orThrow()
case "maxThumbnailsPerRow":
maxThumbnailsPerRow = try CGFloat(value).orThrow()
case "thumbnailMaxWidth":
thumbnailMaxWidth = try CGFloat(value).orThrow()
case "thumbnailMaxHeight":
thumbnailMaxHeight = try CGFloat(value).orThrow()
case "iconSize":
iconSize = try CGFloat(value).orThrow()
case "fontHeight":
fontHeight = try CGFloat(value).orThrow()
case "interItemPadding":
interItemPadding = try CGFloat(value).orThrow()
case "tabKey":
tabKey = try UInt16(value).orThrow()
case "metaKey":
metaKey = try UInt16(value).orThrow()
case "metaModifierFlag":
metaModifierFlag = NSEvent.ModifierFlags(rawValue: try UInt(value).orThrow())
case "highlightColor":
highlightColor = NSColor.white
case "thumbnailQuality":
thumbnailQuality = NSImageInterpolation(rawValue: try UInt(value).orThrow())!
case "windowDisplayDelay":
windowDisplayDelay = DispatchTimeInterval.milliseconds(try Int(value).orThrow())
case "font":
font = NSFont.systemFont(ofSize: fontHeight)
default:
throw "Tried to update an unknown preference"
}
rawValues[valueName] = value
}
static func saveRawToDisk() throws {
try saveToDisk(rawValues, userFile)
}
private static func fileLines(_ url: URL) throws -> Int {
try String(contentsOf: url, encoding: .utf8).split(separator: "\n").count
}
private static func loadFromDisk() throws -> [String: String] {
try handleNoFileOrOldFile(userFile)
return try JSONDecoder().decode([String: String].self, from: Data(contentsOf: userFile))
}
private static func handleNoFileOrOldFile(_ userFile: URL) throws {
if !FileManager.default.fileExists(atPath: defaultsFile.path) {
try saveDefaultsToDisk()
}
if !FileManager.default.fileExists(atPath: userFile.path) {
try FileManager.default.copyItem(at: defaultsFile, to: userFile)
} else {
if try fileLines(defaultsFile) > fileLines(userFile) {
// TODO: handle upgrades in a smarter way (e.g. merge files)
try FileManager.default.removeItem(at: userFile)
try FileManager.default.copyItem(at: defaultsFile, to: userFile)
}
}
}
private static func saveDefaultsToDisk() throws {
try saveToDisk(defaults, defaultsFile)
}
private static func saveToDisk(_ values: [String: String], _ path: URL) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
try encoder
.encode(values)
.write(to: path)
}
private static func fileFromPreferencesFolder(_ fileName: String) -> URL {
FileManager.default
.urls(for: .libraryDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("Preferences", isDirectory: true)
.appendingPathComponent(fileName)
}
}
// add CGFloat constructor from String
extension CGFloat {
init?(_ string: String) {
guard let number = NumberFormatter().number(from: string) else {
return nil
}
self.init(number.floatValue)
}
private static func handleNoFileOrOldFile(userFile: URL, defaultFile: URL) throws {
if !FileManager.default.fileExists(atPath: userFile.path) {
try FileManager.default.copyItem(at: defaultFile, to: userFile)
} else if try fileLines(defaultFile) > fileLines(userFile) {
// TODO: handle upgrades in a smarter way (e.g. merge files)
try FileManager.default.removeItem(at: userFile)
try FileManager.default.copyItem(at: defaultFile, to: userFile)
}
}
// maximum width/height of the main window, in percentage of screen width/height
static let maxScreenUsage = decoded?.maxScreenUsage ?? 0.8
// padding in the main window
static let windowPadding = decoded?.windowPadding ?? 20
// padding in each cell
static let cellPadding = decoded?.cellPadding ?? 6
// border width of each cell
static let cellBorderWidth = decoded?.cellBorderWidth ?? 2
// maximum number of thumbnails on each row
static let maxThumbnailsPerRow = decoded?.maxThumbnailsPerRow ?? 4
// maximum width of each thumbnail
static var thumbnailMaxWidth = decoded?.thumbnailMaxWidth ?? 0
// maximum height of eacg thumbnail
static var thumbnailMaxHeight = decoded?.thumbnailMaxHeight ?? 0
// width/height for each cell app icon
static let iconSize = decoded?.iconSize ?? 32
// font height for each cell title
static let fontHeight = decoded?.fontHeight ?? 15
// padding between cells within the main window
static let interItemPadding = decoded?.interItemPadding ?? 4
// NSEvent.keyCode (e.g. leftCommand=55, leftOption=58, leftControl=59, rightCommand=54, rightOption=61)
static let tabKey = decoded?.tabKey ?? 48
// NSEvent.keyCode (e.g. leftCommand=55, leftOption=58, leftControl=59, rightCommand=54, rightOption=61)
static let metaKey = decoded?.metaKey ?? 59
// NSImageInterpolation (e.g. none=1, low=2, medium=4, high=3)
static let thumbnailQuality = NSImageInterpolation.init(rawValue: decoded?.thumbnailQuality ?? 3) ?? .high
// NSEvent.ModifierFlags (e.g. control=262144, command=1048576, shift=131072, option=524288)
static let metaModifierFlag = NSEvent.ModifierFlags(rawValue: decoded?.metaModifierFlagInt ?? 262144)
// delay (in ms) before the window is displayed after the shortcut is pressed
static let windowDisplayDelay = DispatchTimeInterval.milliseconds(decoded?.windowDisplayDelay ?? 200)
// color for the currently selected cell
static let highlightColor = NSColor.white
// derived properties
static let font: NSFont = .systemFont(ofSize: Preferences.fontHeight)
}
// add throw-on-nil method on Optional
extension Optional {
func orThrow() throws -> Wrapped {
switch self {
case .some(let value):
return value
case .none:
throw "Optional contained nil"
}
}
}
// allow String to be treated as Error (e.g. throw "explanation")
extension String: Error {
}
+42
View File
@@ -0,0 +1,42 @@
import Foundation
import Cocoa
class Screen {
static func listenToChanges() {
NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: NSApplication.shared,
queue: OperationQueue.main
) { notification -> Void in
updateThumbnailMaxSize()
}
}
static func updateThumbnailMaxSize() -> Void {
if Preferences.thumbnailMaxWidth == 0 && Preferences.thumbnailMaxHeight == 0 {
do {
let main = NSScreen.main!.frame
try Preferences.updateAndValidateValue("thumbnailMaxWidth",
String((NSScreen.main!.frame.size.width * Preferences.maxScreenUsage - Preferences.windowPadding * 2) / Preferences.maxThumbnailsPerRow - Preferences.interItemPadding).orThrow())
try Preferences.updateAndValidateValue("thumbnailMaxHeight",
String(Preferences.thumbnailMaxWidth * (main.height / main.width)).orThrow())
try Preferences.saveRawToDisk()
} catch {
debugPrint("Error updating preferences based on screen size", error)
NSApp.terminate(NSApplication.shared)
}
}
}
}
// add String constructor from CGFloat that round up at 1 decimal
extension String {
init?(_ cgFloat: CGFloat) {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = 1
guard let string = formatter.string(from: cgFloat as NSNumber) else {
return nil
}
self.init(string)
}
}
@@ -0,0 +1,20 @@
import Foundation
import Cocoa
class SystemPermissions {
static func ensureAccessibilityCheckboxIsChecked() {
if !AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) {
debugPrint("Before using this app, you need to give permission in System Preferences > Security & Privacy > Privacy > Accessibility.\nPlease authorize and re-launch.\nSee https://help.rescuetime.com/article/59-how-do-i-enable-accessibility-permissions-on-mac-osx")
NSApp.terminate(self)
}
}
static func ensureScreenRecordingCheckboxIsChecked() {
let firstWindow = cgWindows()[0]
let windowImage = CGWindowListCreateImage(.null, .optionIncludingWindow, firstWindow[kCGWindowNumber] as! CGWindowID, [.boundsIgnoreFraming, .bestResolution])
if windowImage == nil {
debugPrint("Before using this app, you need to give permission in System Preferences > Security & Privacy > Privacy > Screen Recording.\nPlease authorize and re-launch.\nSee https://dropshare.zendesk.com/hc/en-us/articles/360033453434-Enabling-Screen-Recording-Permission-on-macOS-Catalina-10-15-")
NSApp.terminate(self)
}
}
}
-18
View File
@@ -1,18 +0,0 @@
{
"maxScreenUsage": 0.8,
"windowPadding": 20,
"cellPadding": 6,
"cellBorderWidth": 2,
"maxThumbnailsPerRow": 4,
"thumbnailMaxWidth": 0,
"thumbnailMaxHeight": 0,
"iconSize": 32,
"fontHeight": 15,
"interItemPadding": 4,
"tabKey": 48,
"metaKey": 59,
"metaModifierFlagInt": 262144,
"highlightColorString": "white",
"thumbnailQuality": 4,
"windowDisplayDelay": 200
}
+28 -142
View File
@@ -1,19 +1,12 @@
import Foundation
import Cocoa
let cellId = NSUserInterfaceItemIdentifier("Cell")
func updateThumbnailMaxSize() -> Void {
let main = NSScreen.main!.frame
Preferences.thumbnailMaxWidth = (NSScreen.main!.frame.size.width * Preferences.maxScreenUsage - Preferences.windowPadding * 2) / Preferences.maxThumbnailsPerRow - Preferences.interItemPadding
Preferences.thumbnailMaxHeight = Preferences.thumbnailMaxWidth * (main.height / main.width)
}
class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate, NSCollectionViewDataSource, NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout {
var item: NSStatusItem?
class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate {
static let name = "AltTab"
var statusItem: NSStatusItem?
var backgroundView: NSVisualEffectView?
var collectionView_: NSCollectionView?
var window: NSPanel?
var thumbnailsPanel: ThumbnailsPanel?
var preferencesPanel: PreferencesPanel?
var selectedOpenWindow: Int = 0
var numberOfColumns: Int = 0
var openWindows: [OpenWindow] = []
@@ -31,94 +24,25 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate, NSCol
}
func applicationDidFinishLaunching(_ aNotification: Notification) {
ensureScreenRecordingCheckboxIsChecked()
ensureAccessibilityCheckboxIsChecked()
updateThumbnailMaxSize()
makeStatusBarItem()
makeWindow()
makeCollectionView()
backgroundView = NSVisualEffectView()
backgroundView!.blendingMode = .behindWindow
backgroundView!.material = .dark
backgroundView!.state = .active
backgroundView!.addSubview(collectionView_!)
window!.contentView = backgroundView
listenToGlobalKeyboardEvents(self)
listenToScreenChanges()
SystemPermissions.ensureScreenRecordingCheckboxIsChecked()
SystemPermissions.ensureAccessibilityCheckboxIsChecked()
Preferences.loadFromDiskAndUpdateValues()
Screen.updateThumbnailMaxSize()
statusItem = StatusItem.make(self)
thumbnailsPanel = ThumbnailsPanel(self)
preferencesPanel = PreferencesPanel()
Keyboard.listenToGlobalEvents(self)
Screen.listenToChanges()
}
@objc func showCenteredPreferencesPanel() {
showCenteredPanel(preferencesPanel!)
}
func showCenteredPanel(_ panel: NSPanel) {
panel.center()
panel.makeKeyAndOrderFront(nil)
Application.shared.activate(ignoringOtherApps: true)
Application.shared.runModal(for: window!)
Application.shared.hide(nil)
}
func listenToScreenChanges() {
NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: NSApplication.shared,
queue: OperationQueue.main
) { notification -> Void in
updateThumbnailMaxSize()
}
}
func applicationDidBecomeActive(_ notification: Notification) {
if !appIsBeingUsed {
focusSelectedWindow(currentlySelectedWindow())
}
}
func makeCollectionView() {
let layout = CollectionViewCenterFlowLayout()
layout.estimatedItemSize = NSSize(width: Preferences.thumbnailMaxWidth, height: Preferences.thumbnailMaxHeight)
layout.minimumInteritemSpacing = 5
layout.minimumLineSpacing = 5
collectionView_ = NSCollectionView()
collectionView_!.dataSource = self
collectionView_!.delegate = self
collectionView_!.collectionViewLayout = layout
collectionView_!.backgroundColors = [.clear]
collectionView_!.isSelectable = true
collectionView_!.allowsMultipleSelection = false
collectionView_!.register(Cell.self, forItemWithIdentifier: cellId)
}
func makeWindow() {
window = NSPanel()
window!.styleMask = [.borderless]
window!.level = .floating
window!.animationBehavior = NSWindow.AnimationBehavior.none
window!.backgroundColor = .clear
window!.setIsVisible(true)
window!.delegate = self
}
func makeStatusBarItem() {
item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item!.button!.title = "AltTab"
item!.menu = NSMenu()
item!.menu!.addItem(
withTitle: "Version #VERSION#",
action: nil,
keyEquivalent: "")
item!.menu!.addItem(
withTitle: "Quit \(ProcessInfo.processInfo.processName)",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q")
}
func ensureAccessibilityCheckboxIsChecked() {
if !AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) {
debugPrint("Before using this app, you need to give permission in System Preferences > Security & Privacy > Privacy > Accessibility.\nPlease authorize and re-launch.\nSee https://help.rescuetime.com/article/59-how-do-i-enable-accessibility-permissions-on-mac-osx")
NSApp.terminate(self)
}
}
func ensureScreenRecordingCheckboxIsChecked() {
let firstWindow = cgWindows()[0]
let windowImage = CGWindowListCreateImage(.null, .optionIncludingWindow, firstWindow[kCGWindowNumber] as! CGWindowID, [.boundsIgnoreFraming, .bestResolution])
if windowImage == nil {
debugPrint("Before using this app, you need to give permission in System Preferences > Security & Privacy > Privacy > Screen Recording.\nPlease authorize and re-launch.\nSee https://dropshare.zendesk.com/hc/en-us/articles/360033453434-Enabling-Screen-Recording-Permission-on-macOS-Catalina-10-15-")
NSApp.terminate(self)
}
}
func computeOpenWindows() {
@@ -141,44 +65,6 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate, NSCol
}
}
func highlightThumbnail(_ step: Int) {
collectionView_!.selectItems(at: [IndexPath(item: selectedOpenWindow, section: 0)], scrollPosition: .top)
collectionView_!.deselectItems(at: [IndexPath(item: cellWithStep(-step), section: 0)])
}
func computeThumbnails() {
let maxSize = NSSize(width: NSScreen.main!.frame.width * Preferences.maxScreenUsage, height: NSScreen.main!.frame.height * Preferences.maxScreenUsage)
collectionView_!.setFrameSize(maxSize)
collectionView_!.collectionViewLayout!.invalidateLayout()
collectionView_!.reloadData()
collectionView_!.layoutSubtreeIfNeeded()
window!.setContentSize(NSSize(width: collectionView_!.frame.size.width + Preferences.windowPadding * 2, height: collectionView_!.frame.size.height + Preferences.windowPadding * 2))
backgroundView!.setFrameSize(window!.frame.size)
collectionView_!.setFrameOrigin(NSPoint(x: Preferences.windowPadding, y: Preferences.windowPadding))
window!.center()
}
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
// debugPrint("collectionView: count items", openWindows.count)
return openWindows.count
}
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
// debugPrint("collectionView: make item", indexPath.item)
let item = collectionView.makeItem(withIdentifier: cellId, for: indexPath) as! Cell
item.updateWithNewContent(openWindows[indexPath.item], self.focusSelectedWindow)
return item
}
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize {
// debugPrint("collectionView: item size")
if indexPath.item < openWindows.count {
let (width, height) = computeDownscaledSize(openWindows[indexPath.item].thumbnail)
return NSSize(width: CGFloat(width) + Preferences.cellPadding * 2, height: CGFloat(height) + max(Preferences.fontHeight, Preferences.iconSize) + Preferences.interItemPadding + Preferences.cellPadding * 2)
}
return .zero
}
func cellWithStep(_ step: Int) -> Int {
selectedOpenWindow + step < 0 ? openWindows.count - 1 : (selectedOpenWindow + step) % openWindows.count
}
@@ -191,14 +77,14 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate, NSCol
isFirstSummon = false
let workItem = DispatchWorkItem {
self.computeOpenWindows()
self.computeThumbnails()
self.highlightThumbnail(step)
Application.shared.unhideWithoutActivation()
self.thumbnailsPanel!.computeThumbnails()
self.thumbnailsPanel!.highlightThumbnail(step)
self.showCenteredPanel(self.thumbnailsPanel!)
}
workItems.append(workItem)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay, execute: workItem)
} else {
highlightThumbnail(step)
self.thumbnailsPanel!.highlightThumbnail(step)
}
}
}
@@ -211,7 +97,7 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate, NSCol
workItems.forEach({ $0.cancel() })
workItems.removeAll()
window?.focus()
Application.shared.hide(nil)
thumbnailsPanel!.orderOut(nil)
appIsBeingUsed = false
isFirstSummon = true
}
+1 -1
View File
@@ -28,7 +28,7 @@ class Cell: NSCollectionViewItem {
textContainer.maximumNumberOfLines = 1
textContainer.lineFragmentPadding = 0
layoutManager.addTextContainer(textContainer)
text = NSTextView.init(frame: NSRect.zero, textContainer: textContainer)
text = NSTextView(frame: NSRect.zero, textContainer: textContainer)
text.drawsBackground = true
text.backgroundColor = .clear
text.isSelectable = false
+95
View File
@@ -0,0 +1,95 @@
import Cocoa
class PreferencesPanel: NSPanel, NSTextViewDelegate {
var inputsMap = [NSTextView: String]()
override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) {
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
let rows = makeLabelsAndInputs()
let gridView = makeGridView(rows)
makeAndAddWarningLabel(gridView)
title = Application.name + " Preferences"
hidesOnDeactivate = false
contentView = gridView
}
private func makeLabelsAndInputs() -> [[NSTextView]] {
var rows = [[NSTextView]]()
[
("maxScreenUsage", "Maximum size of the main window, in percentage of screen size"),
("windowPadding", "Padding in the main window"),
("cellPadding", "Padding in each cell"),
("cellBorderWidth", "Border width of each cell"),
("maxThumbnailsPerRow", "Maximum number of thumbnails on each row"),
("thumbnailMaxWidth", "Maximum width of each thumbnail"),
("thumbnailMaxHeight", "Maximum height of each thumbnail"),
("iconSize", "Width/height for each cell app icon"),
("fontHeight", "Font height for each cell title"),
("interItemPadding", "Padding between cells within the main window"),
("tabKey", "Tab key (NSEvent.keyCode)"),
("metaKey", "Meta key (NSEvent.keyCode)"),
("metaModifierFlag", "Meta key (NSEvent.ModifierFlags)"),
// ("highlightColor", "Color for the currently selected cell"),
("thumbnailQuality", "NSImageInterpolation (e.g. none=1, low=2, medium=4, high=3)"),
("windowDisplayDelay", "Delay in ms before the window is displayed after pressing the shortcut"),
].forEach {
let p = makePreference($1, Preferences.rawValues[$0]!)
rows.append(p)
inputsMap[p[1]] = $0
}
return rows
}
private func makeGridView(_ rows: [[NSTextView]]) -> NSGridView {
let gridView = NSGridView(views: rows)
gridView.setContentHuggingPriority(.defaultLow, for: .horizontal)
gridView.setContentHuggingPriority(.defaultLow, for: .vertical)
gridView.widthAnchor.constraint(greaterThanOrEqualToConstant: 580).isActive = true
return gridView
}
private func makeAndAddWarningLabel(_ gridView: NSGridView) {
let warningLabel = makeLabel("Some settings require restarting the app to apply")
warningLabel.textColor = .systemRed
warningLabel.alignment = .center
gridView.addRow(with: [warningLabel, NSGridCell.emptyContentView])
gridView.mergeCells(inHorizontalRange: NSRange(location: 0, length: 2), verticalRange: NSRange(location: gridView.numberOfRows - 1, length: 1))
}
private func makePreference(_ labelText: String, _ initialValue: String) -> [NSTextView] {
let label = makeLabel(labelText)
label.alignment = .right
let input = NSTextView()
input.delegate = self
input.font = Preferences.font
input.string = initialValue
input.widthAnchor.constraint(equalToConstant: 65).isActive = true
return [label, input]
}
private func makeLabel(_ text: String) -> NSTextView {
let label = NSTextView()
label.drawsBackground = true
label.backgroundColor = .clear
label.isSelectable = false
label.isEditable = false
label.font = Preferences.font
label.string = text
label.enabledTextCheckingTypes = 0
label.heightAnchor.constraint(greaterThanOrEqualToConstant: Preferences.fontHeight + Preferences.interItemPadding).isActive = true
return label
}
func textDidEndEditing(_ notification: Notification) {
if let textView = notification.object as? NSTextView {
let key = inputsMap[textView]!
do {
try Preferences.updateAndValidateValue(key, textView.string)
try Preferences.saveRawToDisk()
} catch {
textView.string = Preferences.rawValues[key]!
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import Cocoa
class StatusItem {
static func make(_ application: Application) -> NSStatusItem {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.button!.title = Application.name
item.menu = NSMenu()
item.menu!.addItem(
withTitle: "Version #VERSION#",
action: nil,
keyEquivalent: "")
item.menu!.addItem(
withTitle: "Preferences…",
action: #selector(application.showCenteredPreferencesPanel),
keyEquivalent: ",")
item.menu!.addItem(
withTitle: "Quit \(ProcessInfo.processInfo.processName)",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q")
return item
}
}
+89
View File
@@ -0,0 +1,89 @@
import Cocoa
class ThumbnailsPanel: NSPanel, NSCollectionViewDataSource, NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout {
var backgroundView: NSVisualEffectView?
var collectionView_: NSCollectionView?
var application: Application?
let cellId = NSUserInterfaceItemIdentifier("Cell")
override init(contentRect: NSRect, styleMask style: StyleMask, backing backingStoreType: BackingStoreType, defer flag: Bool) {
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
}
convenience init(_ application: Application) {
self.init()
self.application = application
styleMask = [.borderless]
isFloatingPanel = true
animationBehavior = NSWindow.AnimationBehavior.none
backgroundColor = .clear
hidesOnDeactivate = false
makeThumbnailsPanelBackgroundView()
contentView = backgroundView!
}
private func makeThumbnailsPanelBackgroundView() {
backgroundView = NSVisualEffectView()
backgroundView!.blendingMode = .behindWindow
backgroundView!.material = .dark
backgroundView!.state = .active
makeCollectionView()
backgroundView!.addSubview(collectionView_!)
}
func makeCollectionView() {
collectionView_ = NSCollectionView()
collectionView_!.dataSource = self
collectionView_!.delegate = self
collectionView_!.collectionViewLayout = makeLayout()
collectionView_!.backgroundColors = [.clear]
collectionView_!.isSelectable = true
collectionView_!.allowsMultipleSelection = false
collectionView_!.register(Cell.self, forItemWithIdentifier: cellId)
}
func makeLayout() -> CollectionViewCenterFlowLayout {
let layout = CollectionViewCenterFlowLayout()
layout.estimatedItemSize = NSSize(width: Preferences.thumbnailMaxWidth, height: Preferences.thumbnailMaxHeight)
layout.minimumInteritemSpacing = 5
layout.minimumLineSpacing = 5
return layout
}
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
// debugPrint("collectionView: count items", openWindows.count)
application!.openWindows.count
}
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
// debugPrint("collectionView: make item", indexPath.item)
let item = collectionView.makeItem(withIdentifier: cellId, for: indexPath) as! Cell
item.updateWithNewContent(application!.openWindows[indexPath.item], application!.focusSelectedWindow)
return item
}
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize {
// debugPrint("collectionView: item size")
if indexPath.item < application!.openWindows.count {
let (width, height) = computeDownscaledSize(application!.openWindows[indexPath.item].thumbnail)
return NSSize(width: CGFloat(width) + Preferences.cellPadding * 2, height: CGFloat(height) + max(Preferences.fontHeight, Preferences.iconSize) + Preferences.interItemPadding + Preferences.cellPadding * 2)
}
return .zero
}
func highlightThumbnail(_ step: Int) {
collectionView_!.selectItems(at: [IndexPath(item: application!.selectedOpenWindow, section: 0)], scrollPosition: .top)
collectionView_!.deselectItems(at: [IndexPath(item: application!.cellWithStep(-step), section: 0)])
}
func computeThumbnails() {
let maxSize = NSSize(width: NSScreen.main!.frame.width * Preferences.maxScreenUsage, height: NSScreen.main!.frame.height * Preferences.maxScreenUsage)
collectionView_!.setFrameSize(maxSize)
collectionView_!.collectionViewLayout!.invalidateLayout()
collectionView_!.reloadData()
collectionView_!.layoutSubtreeIfNeeded()
setContentSize(NSSize(width: collectionView_!.frame.size.width + Preferences.windowPadding * 2, height: collectionView_!.frame.size.height + Preferences.windowPadding * 2))
backgroundView!.setFrameSize(frame.size)
collectionView_!.setFrameOrigin(NSPoint(x: Preferences.windowPadding, y: Preferences.windowPadding))
}
}
+1 -1
View File
@@ -4,6 +4,6 @@ set -ex
version="$(cat VERSION.txt)"
# set the version for the app menubar menu text
sed -i '' -e "s/#VERSION#/$version/" alt-tab-macos/ui/Application.swift
sed -i '' -e "s/#VERSION#/$version/" alt-tab-macos/ui/StatusItem.swift
# set the version in the app meta-data for the AppStore and app "Get Info" panel
sed -i '' -e "s/#VERSION#/$version/" alt-tab-macos/Info.plist