From 274288e86fd99c4d2f3603ff177fde8e8bea5032 Mon Sep 17 00:00:00 2001 From: ostapondo <33957189+ostapondo@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:53:26 +0200 Subject: [PATCH] feat: throw to the next display, larger and smaller, and cycled halves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four actions on Rectangle's keys: ⌃⌥⌘→ and ⌃⌥⌘← send the front window to the next or previous display, keeping its zone number where that display has one and its share of the screen otherwise; ⌃⌥= and ⌃⌥- grow and shrink it by 30 points about the centre, a side against the screen edge staying put. A half's key pressed on a window already in that half steps through two thirds and a third, with a switch on the Zones page. All four answer to Rectangle's URL names and import with its shortcuts, and "throw it to the next screen" and "make it bigger" work out loud. --- App/Sources/plonk/AppDelegate+Model.swift | 1 + App/Sources/plonk/AppDelegate+Shell.swift | 2 + App/Sources/plonk/AppDelegate+Shortcuts.swift | 8 ++ App/Sources/plonk/Config.swift | 3 + App/Sources/plonk/HotkeyAction.swift | 17 +++ App/Sources/plonk/HotkeyDefaults.swift | 8 +- App/Sources/plonk/Preset.swift | 42 +++++++ App/Sources/plonk/RectangleImport.swift | 4 + .../Resources/en.lproj/Localizable.strings | 11 ++ App/Sources/plonk/SnapMemory.swift | 5 + App/Sources/plonk/Strings+Layout.swift | 5 + App/Sources/plonk/Strings+Shortcuts.swift | 6 + App/Sources/plonk/URLCommand.swift | 8 +- App/Sources/plonk/VoiceCommands.swift | 28 +++++ App/Sources/plonk/WindowCommands.swift | 62 ++++++++++- App/Sources/plonk/WindowNavigator.swift | 10 ++ App/Sources/plonk/WindowSizing.swift | 60 ++++++++++ App/Sources/plonk/ZonesPage.swift | 6 +- App/Sources/plonk/ZonesTuning.swift | 11 ++ App/Tests/plonkTests/URLCommandTests.swift | 11 +- .../plonkTests/VoiceThrowResizeTests.swift | 30 +++++ App/Tests/plonkTests/WindowSizingTests.swift | 105 ++++++++++++++++++ CHANGELOG.md | 10 ++ README.md | 5 +- docs/from-rectangle.md | 22 ++-- docs/hotkeys.md | 7 +- 26 files changed, 459 insertions(+), 28 deletions(-) create mode 100644 App/Sources/plonk/WindowSizing.swift create mode 100644 App/Tests/plonkTests/VoiceThrowResizeTests.swift create mode 100644 App/Tests/plonkTests/WindowSizingTests.swift diff --git a/App/Sources/plonk/AppDelegate+Model.swift b/App/Sources/plonk/AppDelegate+Model.swift index c9d0d3b..8296f36 100644 --- a/App/Sources/plonk/AppDelegate+Model.swift +++ b/App/Sources/plonk/AppDelegate+Model.swift @@ -68,6 +68,7 @@ extension AppDelegate { applyAppearance() hotkeys.apply(config) + commands.apply(config) dragSnap.apply(config) grabMove.apply(config) mouse.apply(config) diff --git a/App/Sources/plonk/AppDelegate+Shell.swift b/App/Sources/plonk/AppDelegate+Shell.swift index 3043435..f8c127b 100644 --- a/App/Sources/plonk/AppDelegate+Shell.swift +++ b/App/Sources/plonk/AppDelegate+Shell.swift @@ -34,6 +34,8 @@ extension AppDelegate { case .zone(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): diff --git a/App/Sources/plonk/AppDelegate+Shortcuts.swift b/App/Sources/plonk/AppDelegate+Shortcuts.swift index cd0ed1a..6a432e7 100644 --- a/App/Sources/plonk/AppDelegate+Shortcuts.swift +++ b/App/Sources/plonk/AppDelegate+Shortcuts.swift @@ -59,6 +59,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) diff --git a/App/Sources/plonk/Config.swift b/App/Sources/plonk/Config.swift index bb2579b..580438a 100644 --- a/App/Sources/plonk/Config.swift +++ b/App/Sources/plonk/Config.swift @@ -31,6 +31,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. diff --git a/App/Sources/plonk/HotkeyAction.swift b/App/Sources/plonk/HotkeyAction.swift index 65f32e3..ccab95b 100644 --- a/App/Sources/plonk/HotkeyAction.swift +++ b/App/Sources/plonk/HotkeyAction.swift @@ -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 @@ -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) } @@ -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" } @@ -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 { @@ -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 @@ -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 diff --git a/App/Sources/plonk/HotkeyDefaults.swift b/App/Sources/plonk/HotkeyDefaults.swift index a8d8707..fe2af94 100644 --- a/App/Sources/plonk/HotkeyDefaults.swift +++ b/App/Sources/plonk/HotkeyDefaults.swift @@ -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 @@ -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 @@ -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) } } diff --git a/App/Sources/plonk/Preset.swift b/App/Sources/plonk/Preset.swift index e6911ab..0f291bd 100644 --- a/App/Sources/plonk/Preset.swift +++ b/App/Sources/plonk/Preset.swift @@ -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 + } +} diff --git a/App/Sources/plonk/RectangleImport.swift b/App/Sources/plonk/RectangleImport.swift index f9b4320..8d9cd33 100644 --- a/App/Sources/plonk/RectangleImport.swift +++ b/App/Sources/plonk/RectangleImport.swift @@ -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, ] diff --git a/App/Sources/plonk/Resources/en.lproj/Localizable.strings b/App/Sources/plonk/Resources/en.lproj/Localizable.strings index b9fc978..651bce9 100644 --- a/App/Sources/plonk/Resources/en.lproj/Localizable.strings +++ b/App/Sources/plonk/Resources/en.lproj/Localizable.strings @@ -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.zoneSet %lld" = "Zone set %lld"; "shortcut.group.halves" = "Halves"; @@ -198,6 +202,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"; @@ -275,6 +281,11 @@ "zones.modifierCommand" = "⌘ Command"; "zones.modifierControl" = "⌃ Control"; "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. ⌃⌥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"; diff --git a/App/Sources/plonk/SnapMemory.swift b/App/Sources/plonk/SnapMemory.swift index 0a854de..97441fb 100644 --- a/App/Sources/plonk/SnapMemory.swift +++ b/App/Sources/plonk/SnapMemory.swift @@ -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 diff --git a/App/Sources/plonk/Strings+Layout.swift b/App/Sources/plonk/Strings+Layout.swift index 9189e89..45ed3b9 100644 --- a/App/Sources/plonk/Strings+Layout.swift +++ b/App/Sources/plonk/Strings+Layout.swift @@ -48,6 +48,11 @@ extension LocalizedStringResource { static let zonesModifierCommand = Self.key("zones.modifierCommand") static let zonesModifierControl = Self.key("zones.modifierControl") 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") diff --git a/App/Sources/plonk/Strings+Shortcuts.swift b/App/Sources/plonk/Strings+Shortcuts.swift index 163952d..980d16b 100644 --- a/App/Sources/plonk/Strings+Shortcuts.swift +++ b/App/Sources/plonk/Strings+Shortcuts.swift @@ -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") @@ -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") diff --git a/App/Sources/plonk/URLCommand.swift b/App/Sources/plonk/URLCommand.swift index df30cfa..ac888ef 100644 --- a/App/Sources/plonk/URLCommand.swift +++ b/App/Sources/plonk/URLCommand.swift @@ -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 @@ -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" diff --git a/App/Sources/plonk/VoiceCommands.swift b/App/Sources/plonk/VoiceCommands.swift index 1477fa9..1e37b5b 100644 --- a/App/Sources/plonk/VoiceCommands.swift +++ b/App/Sources/plonk/VoiceCommands.swift @@ -18,6 +18,10 @@ enum VoiceCommand: Equatable { case zone(Int) 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. @@ -34,6 +38,8 @@ enum VoiceCommand: Equatable { case .zone(let number): return .voiceAnnounceZone(number) 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): @@ -92,6 +98,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) } } @@ -111,6 +119,7 @@ extension VoiceCommand { private static let vocabulary: Set = Set(placeVerbs).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", @@ -118,6 +127,7 @@ extension VoiceCommand { "left", "right", "top", "bottom", "upper", "lower", "up", "down", "centre", "center", "middle", "half", "halves", "quarter", "quarters", "corner", "side", "screen", "full", "zone", "display", "monitor", + "next", "previous", "other", // joins and politeness "to", "in", "into", "on", "onto", "over", "at", "of", "hand", "please", "now", ]) @@ -152,6 +162,24 @@ extension VoiceCommand { return .zone(number) } + /// "throw it to the next screen", "put this on the other monitor": a + /// place verb, a display by some name, and which one. + private 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. + private 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 + } + private static func isPutBack(_ text: String) -> Bool { text.contains("put it back") || text.contains("put this back") || text.contains("put that back") || text.contains("back where it was") || text.contains("undo that") diff --git a/App/Sources/plonk/WindowCommands.swift b/App/Sources/plonk/WindowCommands.swift index 45cd56e..bf894a0 100644 --- a/App/Sources/plonk/WindowCommands.swift +++ b/App/Sources/plonk/WindowCommands.swift @@ -18,12 +18,19 @@ final class WindowCommands { /// Empty space left around a snapped window, in points. /// The gap for a screen: that of the set it wears. var zoneGap: ((Int) -> CGFloat)? + /// Whether pressing a half's key again steps its width; see Preset.next. + var cyclesHalves = true init(windows: WindowManager, memory: SnapMemory) { self.windows = windows self.memory = memory } + /// Take the settings as they now stand. + func apply(_ config: Config) { + cyclesHalves = config.presetsCycleOnRepeat + } + /// The frontmost window, unless its app is excluded or there is none. private func focused() -> (app: NSRunningApplication, window: AXUIElement, frame: CGRect)? { guard windows.isTrusted, @@ -37,10 +44,61 @@ final class WindowCommands { func apply(_ preset: Preset) { guard let target = focused() else { return } let screen = windows.screenIndex(ofWindow: target.window) - remember(target, frac: preset.frac, screen: screen) + // A half pressed on a window already in it steps to the next width. + let current = cyclesHalves ? windows.fraction(ofWindow: target.window)?.frac : nil + let frac = preset.next(after: current) + remember(target, frac: frac, screen: screen) // Presets are halves and quarters of the screen, not zones, so the // zone gap does not apply to them. - windows.apply(frac: preset.frac, toWindow: target.window, screenIndex: screen) + windows.apply(frac: frac, toWindow: target.window, screenIndex: screen) + } + + /// Throw the front window to the next display along, or the previous one, + /// wrapping round. It keeps its zone number where the other display's set + /// has that zone, and its share of the screen otherwise, so a window in + /// zone 2 of one monitor lands in zone 2 of the next. + func throwToDisplay(next: Bool) { + guard let target = focused() else { return } + let screens = windows.screens() + guard screens.count > 1 else { + announce?(String(localized: .hudOneScreenOnly)) + return + } + let order = WindowNavigator.displayOrder(screens.map(\.frame)) + let current = windows.screenIndex(ofWindow: target.window) + guard let position = order.firstIndex(of: current) else { return } + let destination = order[(position + (next ? 1 : order.count - 1)) % order.count] + let zones = zonesForScreen?(destination) ?? [] + if let placed = memory.placement(of: target.window), + placed.screenUUID == ScreenIdentity.uuid(forIndex: current), + let zone = placed.zoneIndex, zones.indices.contains(zone) { + remember(target, frac: zones[zone].frac, screen: destination, zoneIndex: zone) + windows.apply(frac: zones[zone].frac, toWindow: target.window, screenIndex: destination, + gap: zoneGap?(destination) ?? 0) + return + } + guard let share = windows.fraction(ofWindow: target.window) else { return } + remember(target, frac: share.frac, screen: destination) + windows.apply(frac: share.frac, toWindow: target.window, screenIndex: destination) + } + + /// Grow or shrink the front window by a step about its centre; the rules + /// are WindowSizing's. Remembered like a snap, so ⌃⌥0 undoes it too. + func resize(larger: Bool) { + guard let target = focused() else { return } + let screens = windows.screens() + let screen = windows.screenIndex(ofWindow: target.window) + guard screens.indices.contains(screen) else { return } + let visible = screens[screen].visible + guard visible.width > 0, visible.height > 0 else { return } + let rect = WindowSizing.resized(target.frame, by: larger ? WindowSizing.step : -WindowSizing.step, + within: visible) + guard rect != target.frame else { return } + let frac = FracRect(Double((rect.minX - visible.minX) / visible.width), + Double((rect.minY - visible.minY) / visible.height), + Double(rect.width / visible.width), Double(rect.height / visible.height)) + remember(target, frac: frac, screen: screen) + windows.restore(frame: rect, toWindow: target.window) } /// Snap to the zone the drag overlay draws that number on. diff --git a/App/Sources/plonk/WindowNavigator.swift b/App/Sources/plonk/WindowNavigator.swift index dc16850..a99c0d4 100644 --- a/App/Sources/plonk/WindowNavigator.swift +++ b/App/Sources/plonk/WindowNavigator.swift @@ -67,6 +67,16 @@ enum WindowNavigator { return members[(position + step) % members.count] } + /// The displays left to right, then top to bottom, by the corner of each + /// frame: the order "the next display" walks, whatever order macOS lists + /// them in. Indices into `frames`. + static func displayOrder(_ frames: [CGRect]) -> [Int] { + frames.indices.sorted { a, b in + if frames[a].minX != frames[b].minX { return frames[a].minX < frames[b].minX } + return frames[a].minY < frames[b].minY + } + } + /// Keeps a restored frame on screen: a window remembered on a display that /// has since been unplugged would otherwise come back somewhere invisible. /// Only nudged into view — the size is what the user asked to get back. diff --git a/App/Sources/plonk/WindowSizing.swift b/App/Sources/plonk/WindowSizing.swift new file mode 100644 index 0000000..b02a52b --- /dev/null +++ b/App/Sources/plonk/WindowSizing.swift @@ -0,0 +1,60 @@ +import CoreGraphics + +// Growing and shrinking a window by a step, the way Rectangle's Larger and +// Smaller do, so a window can be sized without a zone. AX space, like every +// frame here: origin top-left, y grows downward. + +enum WindowSizing { + /// Points added to the width and to the height per press, half on each + /// side. Rectangle's number, since the keys are Rectangle's. + static let step: CGFloat = 30 + /// Under this on a side a window is a title bar, and shrinking stops. + static let minimumSide: CGFloat = 120 + /// A side this close to the screen's edge is against it, and stays there. + static let edgeTolerance: CGFloat = 5 + + /// `frame` grown about its centre by `step`, or shrunk by a negative one, + /// held inside `visible`. A side against the screen's edge stays against + /// it, so a window filling the left half grows to the right rather than + /// off the screen, shrinks away from the edge it is not on, and keeps + /// its full height either way. Only a window filling the whole screen + /// shrinks from every side, since there is nowhere else for it to go. A + /// window that would end up under `minimumSide` is handed back as it was. + static func resized(_ frame: CGRect, by step: CGFloat, within visible: CGRect) -> CGRect { + let whole = abs(frame.minX - visible.minX) <= edgeTolerance && abs(frame.maxX - visible.maxX) <= edgeTolerance + && abs(frame.minY - visible.minY) <= edgeTolerance && abs(frame.maxY - visible.maxY) <= edgeTolerance + let (minX, maxX) = axis(frame.minX, frame.maxX, by: step, within: visible.minX, visible.maxX, + fromBothEnds: whole) + let (minY, maxY) = axis(frame.minY, frame.maxY, by: step, within: visible.minY, visible.maxY, + fromBothEnds: whole) + let result = CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + guard result.width >= minimumSide, result.height >= minimumSide else { return frame } + return result + } + + /// One axis: both ends move half the step apart, unless one is pinned to + /// the screen's edge, in which case the other takes the whole step. With + /// both pinned the window spans the screen on this axis and stays that + /// way, except that a window filling the whole screen may shrink from + /// both ends at once (`fromBothEnds`). + private static func axis(_ low: CGFloat, _ high: CGFloat, by step: CGFloat, + within lowest: CGFloat, _ highest: CGFloat, + fromBothEnds: Bool) -> (CGFloat, CGFloat) { + let pinnedLow = abs(low - lowest) <= edgeTolerance + let pinnedHigh = abs(high - highest) <= edgeTolerance + var a = low + var b = high + switch (pinnedLow, pinnedHigh) { + case (true, true): + if step < 0, fromBothEnds { a -= step / 2; b += step / 2 } + case (true, false): + b += step + case (false, true): + a -= step + case (false, false): + a -= step / 2 + b += step / 2 + } + return (max(a, lowest), min(b, highest)) + } +} diff --git a/App/Sources/plonk/ZonesPage.swift b/App/Sources/plonk/ZonesPage.swift index 45ea117..44c129f 100644 --- a/App/Sources/plonk/ZonesPage.swift +++ b/App/Sources/plonk/ZonesPage.swift @@ -13,7 +13,7 @@ struct ZonesPage: View { /// not flashing the overlay, which moves no window and is filed with the /// zones it shows instead. private var presetActions: [HotkeyAction] { - let grouped: Set = [.numberedZones, .zoneSets, .focus, .other] + let grouped: Set = [.numberedZones, .zoneSets, .focus, .other, .displays, .size] return HotkeyAction.owned(by: "zones").filter { !grouped.contains($0.group) } } @@ -73,6 +73,10 @@ struct ZonesPage: View { CardSection(title: .zonesPresets, first: true) { ShortcutRows(model: model, actions: presetActions) } + CardSection(title: .zonesDisplaysAndSize, note: .zonesDisplaysAndSizeHelp) { + ShortcutRows(model: model, actions: HotkeyAction.owned(by: "zones", group: .displays) + + HotkeyAction.owned(by: "zones", group: .size)) + } CardSection(title: .zonesNumbered, note: .zonesNumberedHelp) { ShortcutRows(model: model, actions: numberedActions) } diff --git a/App/Sources/plonk/ZonesTuning.swift b/App/Sources/plonk/ZonesTuning.swift index 8640f41..30cadc2 100644 --- a/App/Sources/plonk/ZonesTuning.swift +++ b/App/Sources/plonk/ZonesTuning.swift @@ -9,6 +9,7 @@ struct ZonesTuning: View { var body: some View { VStack(alignment: .leading, spacing: 16) { appearance + keys desktopChanges exclusions } @@ -51,6 +52,16 @@ struct ZonesTuning: View { } } + // MARK: - Keys + + private var keys: some View { + SettingsCard(title: .zonesKeys) { + ToggleRow(title: .zonesCycleHalves, + detail: .zonesCycleHalvesDetail, + isOn: model.binding(\.presetsCycleOnRepeat)) + } + } + // MARK: - Desktop changes private var desktopChanges: some View { diff --git a/App/Tests/plonkTests/URLCommandTests.swift b/App/Tests/plonkTests/URLCommandTests.swift index fd657b5..880bd98 100644 --- a/App/Tests/plonkTests/URLCommandTests.swift +++ b/App/Tests/plonkTests/URLCommandTests.swift @@ -57,10 +57,13 @@ struct URLCommandTests { #expect(parse("plonk://execute-action?name=bottom-side") == .success(.action(.bottomHalf))) } - /// Moving a window to the next display is a different thing from moving the - /// pointer there, so this is refused rather than quietly doing the other. - @Test func theDisplayActionsAreNotPretendedTo() { - #expect(parse("plonk://execute-action?name=next-display") == .failure(.unknownAction("next-display"))) + /// Rectangle's names for the four moves this app grew later, so a script + /// that already drives them needs nothing but the scheme swapped. + @Test func theDisplayAndSizeActionsAnswerToRectanglesNames() { + #expect(parse("plonk://execute-action?name=next-display") == .success(.action(.nextDisplay))) + #expect(parse("plonk://execute-action?name=previous-display") == .success(.action(.previousDisplay))) + #expect(parse("plonk://execute-action?name=larger") == .success(.action(.larger))) + #expect(parse("plonk://execute-action?name=smaller") == .success(.action(.smaller))) } /// Push-to-talk finishes on the key coming back up. A URL has no second diff --git a/App/Tests/plonkTests/VoiceThrowResizeTests.swift b/App/Tests/plonkTests/VoiceThrowResizeTests.swift new file mode 100644 index 0000000..667eaed --- /dev/null +++ b/App/Tests/plonkTests/VoiceThrowResizeTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import plonk + +/// Throwing a window to another display and sizing it, said out loud. +struct VoiceThrowResizeTests { + + @Test func aWindowIsThrownToTheNextOrPreviousDisplay() { + #expect(VoiceCommand.parse("throw it to the next screen") == .throwToDisplay(next: true)) + #expect(VoiceCommand.parse("put this on the other monitor") == .throwToDisplay(next: true)) + #expect(VoiceCommand.parse("move it to the previous display") == .throwToDisplay(next: false)) + } + + @Test func aWindowIsMadeBiggerOrSmaller() { + #expect(VoiceCommand.parse("make it bigger") == .resize(larger: true)) + #expect(VoiceCommand.parse("larger") == .resize(larger: true)) + #expect(VoiceCommand.parse("make this smaller") == .resize(larger: false)) + } + + /// An app named, or no display named, is not one of these. + @Test func whatStillGoesToTheAgent() { + #expect(VoiceCommand.parse("throw chrome to the next screen") == nil) + #expect(VoiceCommand.parse("make chrome bigger") == nil) + #expect(VoiceCommand.parse("put this next to the terminal") == nil) + } + + @Test func theHalvesStillWin() { + #expect(VoiceCommand.parse("snap this left") == .preset(.leftHalf)) + #expect(VoiceCommand.parse("fill the screen") == .preset(.maximize)) + } +} diff --git a/App/Tests/plonkTests/WindowSizingTests.swift b/App/Tests/plonkTests/WindowSizingTests.swift new file mode 100644 index 0000000..5a63fee --- /dev/null +++ b/App/Tests/plonkTests/WindowSizingTests.swift @@ -0,0 +1,105 @@ +import CoreGraphics +import Testing +@testable import plonk + +// AX space: origin top-left, y grows downward. + +/// Larger and smaller: a step about the centre, held to the screen. +struct WindowSizingTests { + + private let visible = CGRect(x: 0, y: 25, width: 1000, height: 575) + + @Test func growsAboutTheCentre() { + let grown = WindowSizing.resized(CGRect(x: 100, y: 100, width: 200, height: 200), by: 30, within: visible) + #expect(grown == CGRect(x: 85, y: 85, width: 230, height: 230)) + } + + @Test func shrinksAboutTheCentre() { + let shrunk = WindowSizing.resized(CGRect(x: 100, y: 100, width: 200, height: 200), by: -30, within: visible) + #expect(shrunk == CGRect(x: 115, y: 115, width: 170, height: 170)) + } + + /// A window filling the left half grows to the right, not off the screen, + /// and shrinks away from the edge it is on. + @Test func aSideAgainstTheScreenEdgeStaysThere() { + let leftHalf = CGRect(x: 0, y: 25, width: 500, height: 575) + let grown = WindowSizing.resized(leftHalf, by: 30, within: visible) + #expect(grown.minX == 0 && grown.width == 530) + #expect(grown.minY == 25 && grown.height == 575) + let shrunk = WindowSizing.resized(leftHalf, by: -30, within: visible) + #expect(shrunk.minX == 0 && shrunk.width == 470) + #expect(shrunk.minY == 25 && shrunk.maxY == 600) + } + + /// A window filling the whole screen has nowhere to grow, and shrinks + /// from every side since there is no edge to shrink away from. + @Test func aWindowFillingTheScreenOnlyShrinksAndDoesSoFromEverySide() { + let full = visible + #expect(WindowSizing.resized(full, by: 30, within: visible) == full) + let shrunk = WindowSizing.resized(full, by: -30, within: visible) + #expect(shrunk == full.insetBy(dx: 15, dy: 15)) + } + + @Test func neverLeavesTheScreen() { + let grown = WindowSizing.resized(CGRect(x: 790, y: 390, width: 200, height: 200), by: 30, within: visible) + #expect(visible.contains(grown)) + } + + @Test func shrinkingStopsAtATitleBar() { + let small = CGRect(x: 100, y: 100, width: 130, height: 130) + #expect(WindowSizing.resized(small, by: -30, within: visible) == small) + } +} + +/// The widths a half steps through when its key is pressed again. +struct PresetCycleTests { + + @Test func aHalfPressedAgainTakesTwoThirdsThenAThirdThenTheHalf() { + let half = Preset.leftHalf + let twoThirds = half.next(after: half.frac) + #expect(abs(twoThirds.w - 2.0 / 3.0) < 0.001 && twoThirds.x == 0) + let third = half.next(after: twoThirds) + #expect(abs(third.w - 1.0 / 3.0) < 0.001 && third.x == 0) + let back = half.next(after: third) + #expect(back.w == 0.5) + } + + @Test func theRightAndBottomHalvesKeepToTheirSide() { + let right = Preset.rightHalf.next(after: Preset.rightHalf.frac) + #expect(abs(right.x - 1.0 / 3.0) < 0.001 && abs(right.w - 2.0 / 3.0) < 0.001) + let bottom = Preset.bottomHalf.next(after: Preset.bottomHalf.frac) + #expect(abs(bottom.y - 1.0 / 3.0) < 0.001 && abs(bottom.h - 2.0 / 3.0) < 0.001) + } + + /// A window anywhere else, or one nothing is known about, gets the half. + @Test func aWindowNotOnTheCycleGetsTheHalf() { + #expect(Preset.leftHalf.next(after: FracRect(0.2, 0.1, 0.6, 0.8)).w == 0.5) + #expect(Preset.leftHalf.next(after: nil).w == 0.5) + #expect(Preset.leftHalf.next(after: Preset.rightHalf.frac).x == 0) + } + + /// A terminal sized to its character grid sits a few points off the half + /// and still counts as being in it. + @Test func aWindowAFewPointsOffStillCounts() { + let nearly = FracRect(0, 0, 0.49, 0.99) + #expect(abs(Preset.leftHalf.next(after: nearly).w - 2.0 / 3.0) < 0.001) + } + + @Test func onlyHalvesCycle() { + #expect(Preset.topLeft.next(after: Preset.topLeft.frac).w == 0.5) + #expect(Preset.maximize.next(after: Preset.maximize.frac).w == 1) + } +} + +/// Which display is next. +struct DisplayOrderTests { + + @Test func displaysGoLeftToRightThenTopToBottom() { + let frames = [ + CGRect(x: 0, y: 0, width: 1000, height: 600), + CGRect(x: -1200, y: 0, width: 1200, height: 800), + CGRect(x: 0, y: -900, width: 1000, height: 900), + ] + #expect(WindowNavigator.displayOrder(frames) == [1, 2, 0]) + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a75ec..20659bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,16 @@ one of those. so a setup that was never touched looks exactly as it did. - **The MCP server reports its installed version.** Run `plonk-mcp --version` or `plonk-mcp -v` to print the package version without starting the server. +- **Throw a window to the next display, grow it, shrink it, cycle a half.** + `⌃⌥⌘→` and `⌃⌥⌘←` send the front window to the next or previous display, + into the same zone number where that display has one. `⌃⌥=` and `⌃⌥-` + grow and shrink it by 30 points about the centre, with a side against the + screen edge staying put. `⌃⌥←` on a window already in the left half takes + two thirds, then a third, then the half again, and the other three halves + do the same; the Keys card on the Zones page turns the cycle off. Keys, + names and cycle are Rectangle's, so they import with the rest, and the + `next-display` URL is answered rather than refused. Out loud: "throw it to + the next screen", "make it bigger". ### Changed diff --git a/README.md b/README.md index c195837..1b18876 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,9 @@ between minor versions. [CHANGELOG.md](CHANGELOG.md) says what moved. `⌘` too and it takes two of them at once. Turn on grab-and-move to pull a window from anywhere inside it instead of aiming for the title bar. -**Press a key.** `⌃⌥←` for the left half. `⌃⌥1` to `⌃⌥9` for the numbered zones -on that screen. `⌃⌥0` puts a window back where it was before Plonk touched it. +**Press a key.** `⌃⌥←` for the left half, and again for two thirds. `⌃⌥1` to +`⌃⌥9` for the numbered zones on that screen. `⌃⌥⌘→` throws a window to the +next display. `⌃⌥0` puts a window back where it was before Plonk touched it. **Say it.** Hold `⌃⌥V` and name the place: "snap this left", "zone three". That runs in the app, offline, on-device. diff --git a/docs/from-rectangle.md b/docs/from-rectangle.md index e38e77c..4acdcb7 100644 --- a/docs/from-rectangle.md +++ b/docs/from-rectangle.md @@ -16,6 +16,8 @@ alone, and both apps landed on it. | Corners | `⌃⌥U` `⌃⌥I` `⌃⌥J` `⌃⌥K` | the same | | Maximize | `⌃⌥↩` | the same | | Centre | `⌃⌥C` | the same | +| Next, previous display | `⌃⌥⌘→` `⌃⌥⌘←` | the same | +| Larger, smaller | `⌃⌥=` `⌃⌥-` | the same | | Put it back | `⌃⌥⌫` | `⌃⌥0` | | Thirds | `⌃⌥D` `⌃⌥E` `⌃⌥F` `⌃⌥T` `⌃⌥G` | `⌃⌥1`–`⌃⌥9`, over a zone set | @@ -41,7 +43,8 @@ Rectangle, and then `~/Library/Application Support/Rectangle/RectangleConfig.jso if you exported your settings from the old machine. Rectangle does not have to be running, or still installed, for the second one. -What comes across: the eight halves and corners, maximize, centre, and restore. +What comes across: the eight halves and corners, maximize, centre, restore, +next and previous display, and larger and smaller. What does not: thirds, fourths, sixths, eighths, ninths, and the rest of the fixed grid. Those are not missing features, they are zone sets here, and there is no way to tell which numbered zone a "first third" should become without @@ -72,21 +75,16 @@ sed -i '' 's|rectangle://execute-action|plonk://execute-action|g' your-script.sh ``` `left-half`, `right-half`, `top-half`, `bottom-half`, `top-left`, `top-right`, -`bottom-left`, `bottom-right`, `maximize` and `center` are spelled exactly as -Rectangle spells them, and `restore` works as well as `unsnap`. Past that the +`bottom-left`, `bottom-right`, `maximize`, `center`, `next-display`, +`previous-display`, `larger` and `smaller` are spelled exactly as Rectangle +spells them, and `restore` works as well as `unsnap`. Past that the names are Plonk's own: `zone-1` to `zone-9`, `zone-set-1` to `zone-set-9`, `cycle-zone`, `focus-left`, `capture-text`, `ruler`, `crop-live`, and one for every other shortcut. -Two answers you can get instead of a window moving, both of which say so on -screen rather than failing silently: - -- **`first-third` and the rest of the fixed grid.** Named, and told to use a - zone number instead. -- **`next-display` and `previous-display`.** Refused rather than approximated. - Those move the window to another screen; the nearest thing here moves the - pointer, and answering to the name while doing something else is worse than - not answering. +One answer you can get instead of a window moving, and it says so on screen +rather than failing silently: **`first-third` and the rest of the fixed grid** +are named, and told to use a zone number instead. ### Or let Plonk answer `rectangle://` directly diff --git a/docs/hotkeys.md b/docs/hotkeys.md index 7057d6e..e4dda1a 100644 --- a/docs/hotkeys.md +++ b/docs/hotkeys.md @@ -10,7 +10,9 @@ | | | | --- | --- | -| `⌃⌥` arrows, `U I J K`, `↩`, `C` | Halves, quarters, maximize, centre | +| `⌃⌥` arrows, `U I J K`, `↩`, `C` | Halves, quarters, maximize, centre. A half pressed again takes two thirds, then a third | +| `⌃⌥⌘←` · `⌃⌥⌘→` | Throw the window to the previous · next display | +| `⌃⌥-` · `⌃⌥=` | Smaller · larger, 30 points about the centre | | `⌃⌥1`–`⌃⌥9`, `⌃⌥0` | Into a numbered zone, or back where it was | | `⌃⌥⇧1`–`⌃⌥⇧9` | Swap the whole zone set on this screen | | `⌃⌥L` | The zone sets as a list on screen — pick one, or press `E` to edit it | @@ -39,6 +41,7 @@ open -g "plonk://execute-action?name=ruler" The names are `left-half`, `right-half`, `top-half`, `bottom-half`, `top-left`, `top-right`, `bottom-left`, `bottom-right`, `maximize`, `center`, +`next-display`, `previous-display`, `larger`, `smaller`, `unsnap`, `zone-1` to `zone-9`, `zone-set-1` to `zone-set-9`, `zone-set-palette`, `cycle-zone`, `cycle-zone-back`, `focus-left`, `focus-right`, `focus-up`, `focus-down`, `show-zones`, `capture-region`, @@ -49,7 +52,7 @@ The names are `left-half`, `right-half`, `top-half`, `bottom-half`, has no second half, so one would leave the microphone listening with nothing to close it. Asking for it says so rather than starting it. -The first ten are spelled the way Rectangle spells them, and `restore` is +The first fourteen are spelled the way Rectangle spells them, and `restore` is accepted for `unsnap`, so a config written against `rectangle://` works after one substitution. [Coming from Rectangle](from-rectangle.md) has that, and the switch that skips it.