Skip to content
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
1 change: 1 addition & 0 deletions App/Sources/plonk/AppDelegate+Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ extension AppDelegate {

applyAppearance()
hotkeys.apply(config)
commands.apply(config)
dragSnap.apply(config)
grabMove.apply(config)
mouse.apply(config)
Expand Down
2 changes: 2 additions & 0 deletions App/Sources/plonk/AppDelegate+Shell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ extension AppDelegate {
case .namedZone(let number, _): commands.snap(toZone: number)
case .putBack: commands.unsnap()
case .focus(let direction): commands.moveFocus(direction)
case .throwToDisplay(let next): commands.throwToDisplay(next: next)
case .resize(let larger): commands.resize(larger: larger)
case .cycleZone: commands.cycleZone(backwards: false)
case .showZones: dragSnap.previewZones()
case .awake(let minutes):
Expand Down
8 changes: 8 additions & 0 deletions App/Sources/plonk/AppDelegate+Shortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ extension AppDelegate {
openCommandPalette()
case .zoneSetPalette:
openZoneSetPalette()
case .nextDisplay:
commands.throwToDisplay(next: true)
case .previousDisplay:
commands.throwToDisplay(next: false)
case .larger:
commands.resize(larger: true)
case .smaller:
commands.resize(larger: false)
default:
if let number = action.zoneNumber {
commands.snap(toZone: number)
Expand Down
3 changes: 3 additions & 0 deletions App/Sources/plonk/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ struct Config: Codable {
/// How near the shared edge of two zones the cursor has to come, in points,
/// before a drop covers both. Zero switches it off.
var zoneEdgeSpanPoints: Double = 16
// Whether a half's key, pressed on a window already in that half, steps
// its width through two thirds and a third; see Preset.next.
var presetsCycleOnRepeat = true
// Move and resize a window by dragging anywhere inside it with a modifier
// held. Off by default: option-drag already means something inside a lot
// of Mac apps, so this is a choice rather than a surprise.
Expand Down
17 changes: 17 additions & 0 deletions App/Sources/plonk/HotkeyAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ enum HotkeyAction: String, CaseIterable, Identifiable {
case leftHalf, rightHalf, topHalf, bottomHalf
case topLeft, topRight, bottomLeft, bottomRight
case maximize, center
/// The front window onto the next display along, or the previous one.
case nextDisplay, previousDisplay
/// The front window grown or shrunk by a step about its centre.
case larger, smaller
case showZones, captureRegion, captureText
case voice
/// The numbered zones of the screen the window is on, as the drag overlay
Expand Down Expand Up @@ -86,6 +90,10 @@ enum HotkeyAction: String, CaseIterable, Identifiable {
case .shortcutGuide: return .shortcutGuide
case .commandPalette: return .shortcutCommandPalette
case .zoneSetPalette: return .shortcutZoneSetPalette
case .nextDisplay: return .shortcutNextDisplay
case .previousDisplay: return .shortcutPreviousDisplay
case .larger: return .shortcutLarger
case .smaller: return .shortcutSmaller
default:
if let number = zoneNumber { return .shortcutZone(number) }
if let number = layoutNumber { return .shortcutZoneSet(number) }
Expand Down Expand Up @@ -114,6 +122,10 @@ enum HotkeyAction: String, CaseIterable, Identifiable {
case .shortcutGuide: return "keyboard"
case .commandPalette: return "command"
case .zoneSetPalette: return "rectangle.3.group"
case .nextDisplay: return "arrow.right.to.line"
case .previousDisplay: return "arrow.left.to.line"
case .larger: return "arrow.up.left.and.arrow.down.right"
case .smaller: return "arrow.down.right.and.arrow.up.left"
default:
if zoneNumber != nil { return "square.grid.2x2" }
if layoutNumber != nil { return "rectangle.3.group" }
Expand All @@ -126,6 +138,7 @@ enum HotkeyAction: String, CaseIterable, Identifiable {
/// would break the moment the text was translated.
enum Group: String, CaseIterable {
case halves, quarters, wholeScreen, numberedZones, zoneSets
case displays, size
case focus, pointer, guide, crop, ruler, other

var title: LocalizedStringResource {
Expand All @@ -135,6 +148,8 @@ enum HotkeyAction: String, CaseIterable, Identifiable {
case .wholeScreen: return .shortcutGroupWholeScreen
case .numberedZones: return .shortcutGroupNumberedZones
case .zoneSets: return .shortcutGroupZoneSets
case .displays: return .shortcutGroupDisplays
case .size: return .shortcutGroupSize
case .focus: return .shortcutGroupFocus
case .pointer: return .shortcutGroupPointer
case .guide: return .shortcutGroupGuide
Expand All @@ -150,6 +165,8 @@ enum HotkeyAction: String, CaseIterable, Identifiable {
case .leftHalf, .rightHalf, .topHalf, .bottomHalf: return .halves
case .topLeft, .topRight, .bottomLeft, .bottomRight: return .quarters
case .maximize, .center: return .wholeScreen
case .nextDisplay, .previousDisplay: return .displays
case .larger, .smaller: return .size
case .unsnap: return .numberedZones
case .cycleZone, .cycleZoneBack, .focusLeft, .focusRight, .focusUp, .focusDown: return .focus
case .findCursor, .jumpCursor: return .pointer
Expand Down
8 changes: 7 additions & 1 deletion App/Sources/plonk/HotkeyDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ extension HotkeyAction {
var defaultHotkey: Hotkey {
let code: Int
var shift = false
var command = false
switch self {
case .leftHalf: code = kVK_LeftArrow
case .rightHalf: code = kVK_RightArrow
Expand All @@ -22,6 +23,11 @@ extension HotkeyAction {
case .bottomRight: code = kVK_ANSI_K
case .maximize: code = kVK_Return
case .center: code = kVK_ANSI_C
// Rectangle's keys for the same four moves, so they carry over.
case .nextDisplay: code = kVK_RightArrow; command = true
case .previousDisplay: code = kVK_LeftArrow; command = true
case .larger: code = kVK_ANSI_Equal
case .smaller: code = kVK_ANSI_Minus
case .showZones: code = kVK_ANSI_Z
case .captureRegion: code = kVK_ANSI_S
case .captureText: code = kVK_ANSI_T
Expand Down Expand Up @@ -64,6 +70,6 @@ extension HotkeyAction {
// silently and switch the layout instead.
case .commandPalette: code = kVK_ANSI_A
}
return Hotkey(keyCode: UInt32(code), control: true, option: true, shift: shift)
return Hotkey(keyCode: UInt32(code), control: true, option: true, shift: shift, command: command)
}
}
42 changes: 42 additions & 0 deletions App/Sources/plonk/Preset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,45 @@ enum Preset: String, CaseIterable {
}
}
}

extension Preset {
/// The widths a half steps through when its key is pressed again: the
/// half, then two thirds, then a third, then the half again. Rectangle's
/// cycle, because the keys are Rectangle's too.
static let cycle: [Double] = [0.5, 2.0 / 3.0, 1.0 / 3.0]
/// How close a window has to be to a step, on every side, to count as
/// sitting on it. An app that sizes to a character grid lands a few
/// points off, and that still has to count.
static let cycleTolerance = 0.03

/// Where this preset puts a window that is at `current` now: the next
/// step of the cycle for a half the window is already on, the preset
/// itself for anything else. Nil is a window nothing is known about.
func next(after current: FracRect?) -> FracRect {
guard let current, isHalf else { return frac }
for (index, step) in Self.cycle.enumerated() where Self.close(current, share(of: step)) {
return share(of: Self.cycle[(index + 1) % Self.cycle.count])
}
return frac
}

private var isHalf: Bool {
[.leftHalf, .rightHalf, .topHalf, .bottomHalf].contains(self)
}

/// This half's side of the screen, taking `fraction` of it.
func share(of fraction: Double) -> FracRect {
switch self {
case .leftHalf: return FracRect(0, 0, fraction, 1)
case .rightHalf: return FracRect(1 - fraction, 0, fraction, 1)
case .topHalf: return FracRect(0, 0, 1, fraction)
case .bottomHalf: return FracRect(0, 1 - fraction, 1, fraction)
default: return frac
}
}

static func close(_ a: FracRect, _ b: FracRect) -> Bool {
abs(a.x - b.x) < cycleTolerance && abs(a.y - b.y) < cycleTolerance
&& abs(a.w - b.w) < cycleTolerance && abs(a.h - b.h) < cycleTolerance
}
}
4 changes: 4 additions & 0 deletions App/Sources/plonk/RectangleImport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ enum RectangleImport {
"bottomRight": .bottomRight,
"maximize": .maximize,
"center": .center,
"nextDisplay": .nextDisplay,
"previousDisplay": .previousDisplay,
"larger": .larger,
"smaller": .smaller,
// Rectangle's name for what ⌃⌥0 does here.
"restore": .unsnap,
]
Expand Down
11 changes: 11 additions & 0 deletions App/Sources/plonk/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@
"shortcut.guide" = "Show this app's shortcuts";
"shortcut.commandPalette" = "Open the command palette";
"shortcut.zoneSetPalette" = "Pick a zone set for this screen";
"shortcut.nextDisplay" = "Throw to the next display";
"shortcut.previousDisplay" = "Throw to the previous display";
"shortcut.larger" = "Larger";
"shortcut.smaller" = "Smaller";
"shortcut.zone %lld" = "Zone %lld";
"shortcut.zoneNamed %lld %@" = "Zone %1$lld · %2$@";
"shortcut.zoneSet %lld" = "Zone set %lld";
Expand All @@ -199,6 +203,8 @@
"shortcut.group.wholeScreen" = "Whole screen";
"shortcut.group.numberedZones" = "Numbered zones";
"shortcut.group.zoneSets" = "Zone sets";
"shortcut.group.displays" = "Displays";
"shortcut.group.size" = "Size";
"shortcut.group.focus" = "Focus";
"shortcut.group.pointer" = "Pointer";
"shortcut.group.guide" = "Guide";
Expand Down Expand Up @@ -279,6 +285,11 @@
"zones.shakeToSnap" = "Shake to show the zones";
"zones.shakeToSnapDetail" = "Wiggle a window sideways while dragging it and the zones come up without the modifier";
"zones.presets" = "Halves, quarters and the rest";
"zones.displaysAndSize" = "Between displays, and by size";
"zones.displaysAndSizeHelp" = "Throwing keeps the zone number where the other display's set has that zone, and the window's share of the screen otherwise. Larger and smaller change both sides by 30 points about the centre; a side against the screen edge stays there.";
"zones.keys" = "Keys";
"zones.cycleHalves" = "Press a half twice to cycle its width";
"zones.cycleHalvesDetail" = "⌃⌥← again takes the left two thirds, then the left third, then the half again. The other three halves do the same";
"zones.numbered" = "Numbered zones";
"zones.numberedHelp" = "The numbers the overlay draws, on whichever screen the front window is on. Hold the first key and the zones stay up: click one, or press its digit while holding, and the front window goes there. ⌃⌥0 gives a window back the frame it had before Plonk first moved it. Click a key field and press the combination; Esc cancels, Delete unbinds.";
"zones.switching" = "Switch zone sets";
Expand Down
5 changes: 5 additions & 0 deletions App/Sources/plonk/SnapMemory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ final class SnapMemory {
entries.removeValue(forKey: WindowKey(element: window))?.original
}

/// Where a window was last put, if Plonk put it anywhere.
func placement(of window: AXUIElement) -> (frac: FracRect, screenUUID: String?, zoneIndex: Int?)? {
entries[WindowKey(element: window)].map { ($0.frac, $0.screenUUID, $0.zoneIndex) }
}

/// Every remembered placement, newest last.
var placements: [(window: AXUIElement, frac: FracRect, screenUUID: String?, zoneIndex: Int?)] {
entries
Expand Down
5 changes: 5 additions & 0 deletions App/Sources/plonk/Strings+Layout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ extension LocalizedStringResource {
static let zonesShakeToSnap = Self.key("zones.shakeToSnap")
static let zonesShakeToSnapDetail = Self.key("zones.shakeToSnapDetail")
static let zonesPresets = Self.key("zones.presets")
static let zonesDisplaysAndSize = Self.key("zones.displaysAndSize")
static let zonesDisplaysAndSizeHelp = Self.key("zones.displaysAndSizeHelp")
static let zonesKeys = Self.key("zones.keys")
static let zonesCycleHalves = Self.key("zones.cycleHalves")
static let zonesCycleHalvesDetail = Self.key("zones.cycleHalvesDetail")
static let zonesNumbered = Self.key("zones.numbered")
static let zonesNumberedHelp = Self.key("zones.numberedHelp")
static let zonesSwitching = Self.key("zones.switching")
Expand Down
6 changes: 6 additions & 0 deletions App/Sources/plonk/Strings+Shortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ extension LocalizedStringResource {
static let shortcutGuide = Self.key("shortcut.guide")
static let shortcutCommandPalette = Self.key("shortcut.commandPalette")
static let shortcutZoneSetPalette = Self.key("shortcut.zoneSetPalette")
static let shortcutNextDisplay = Self.key("shortcut.nextDisplay")
static let shortcutPreviousDisplay = Self.key("shortcut.previousDisplay")
static let shortcutLarger = Self.key("shortcut.larger")
static let shortcutSmaller = Self.key("shortcut.smaller")
static let shortcutUnbound = Self.key("shortcut.unbound")
static let shortcutPressKeys = Self.key("shortcut.pressKeys")
static let shortcutAlreadyTaken = Self.key("shortcut.alreadyTaken")
Expand All @@ -42,6 +46,8 @@ extension LocalizedStringResource {
static let shortcutGroupWholeScreen = Self.key("shortcut.group.wholeScreen")
static let shortcutGroupNumberedZones = Self.key("shortcut.group.numberedZones")
static let shortcutGroupZoneSets = Self.key("shortcut.group.zoneSets")
static let shortcutGroupDisplays = Self.key("shortcut.group.displays")
static let shortcutGroupSize = Self.key("shortcut.group.size")
static let shortcutGroupFocus = Self.key("shortcut.group.focus")
static let shortcutGroupPointer = Self.key("shortcut.group.pointer")
static let shortcutGroupGuide = Self.key("shortcut.group.guide")
Expand Down
8 changes: 4 additions & 4 deletions App/Sources/plonk/URLCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ enum URLCommand: Equatable {
}

/// Rectangle's names for things this app calls something else.
///
/// `next-display` and `previous-display` are deliberately absent: they move
/// the window to another screen, and the nearest thing here, `jump-cursor`,
/// moves the pointer instead.
static let aliases: [String: HotkeyAction] = [
"restore": .unsnap,
// Rectangle answers to a second name for each half, so a script may
Expand Down Expand Up @@ -92,6 +88,10 @@ extension HotkeyAction {
case .bottomRight: return "bottom-right"
case .maximize: return "maximize"
case .center: return "center"
case .nextDisplay: return "next-display"
case .previousDisplay: return "previous-display"
case .larger: return "larger"
case .smaller: return "smaller"
case .unsnap: return "unsnap"
case .showZones: return "show-zones"
case .captureRegion: return "capture-region"
Expand Down
24 changes: 24 additions & 0 deletions App/Sources/plonk/VoiceCommands+Sizing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

// Voice commands that move a window between displays or change its size.
// Kept beside the main parser so that file stays below the line limit.

extension VoiceCommand {
/// "throw it to the next screen", "put this on the other monitor": a
/// place verb, a display by some name, and which one.
static func throwToDisplay(_ words: [String]) -> VoiceCommand? {
guard placeVerbs.contains(where: words.contains),
words.contains(where: { ["display", "screen", "monitor"].contains($0) }) else { return nil }
if words.contains("next") || words.contains("other") { return .throwToDisplay(next: true) }
if words.contains("previous") { return .throwToDisplay(next: false) }
return nil
}

/// "make it bigger", "smaller". Every word is one the parser knows, so
/// "make Chrome bigger" has already gone to the agent.
static func resize(_ words: [String]) -> VoiceCommand? {
if words.contains("bigger") || words.contains("larger") { return .resize(larger: true) }
if words.contains("smaller") { return .resize(larger: false) }
return nil
}
}
10 changes: 10 additions & 0 deletions App/Sources/plonk/VoiceCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ enum VoiceCommand: Equatable {
case namedZone(Int, String)
case putBack
case focus(WindowNavigator.Direction)
/// The front window onto the next display, or the previous one.
case throwToDisplay(next: Bool)
/// The front window grown or shrunk by a step.
case resize(larger: Bool)
case cycleZone
case showZones
/// nil minutes means no limit — awake until it is turned off.
Expand All @@ -37,6 +41,8 @@ enum VoiceCommand: Equatable {
case .namedZone(let number, let name): return .voiceAnnounceZoneNamed(number, name)
case .putBack: return .voiceAnnouncePutBack
case .focus(let direction): return .voiceAnnounceFocus(String(localized: direction.title))
case .throwToDisplay(let next): return next ? .shortcutNextDisplay : .shortcutPreviousDisplay
case .resize(let larger): return larger ? .shortcutLarger : .shortcutSmaller
case .cycleZone: return .voiceAnnounceNextInZone
case .showZones: return .voiceAnnounceZones
case .awake(let minutes):
Expand Down Expand Up @@ -104,6 +110,8 @@ extension VoiceCommand {
// half" would otherwise move the front window instead of Chrome, which
// is worse than the round trip to an agent that can find it.
if isPlain(words) {
if let thrown = throwToDisplay(words) { return thrown }
if let sized = resize(words) { return sized }
if let zone = zone(text, words) { return zone }
if let preset = preset(text, words) { return .preset(preset) }
}
Expand Down Expand Up @@ -131,11 +139,13 @@ extension VoiceCommand {
static let vocabulary: Set<String> = Set(placeVerbs).union(placementWords).union([
// what to do, beyond the place verbs
"make", "maximize", "maximise", "fill", "resize", "go",
"bigger", "larger", "smaller",
// what it is done to — never a name, always the front window
"this", "it", "that", "the", "a", "my", "current", "front", "active",
"window", "windows", "one",
// where, beyond the placement words
"side", "screen", "full", "zone", "display", "monitor",
"next", "previous", "other",
// joins and politeness
"to", "in", "into", "on", "onto", "over", "at", "of", "hand", "please", "now",
])
Expand Down
Loading
Loading