diff --git a/CHANGELOG.md b/CHANGELOG.md index 88eead9..9ba028c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to Perch. Versions follow [semver](https://semver.org), and each heading below corresponds to a published [release](https://github.com/ishanmalu/Perch/releases). +## [1.9.2] — 2026-09-04 + +### Added +- **Brightness on a shortcut.** `⌃⌥=` and `⌃⌥-` move every display a + sixteenth at a time — the same step the Mac's own brightness keys use, but + those only ever reach the built-in panel. External monitors move too: where + there is no backlight to control, Perch dims with an overlay. A brief readout + shows the new level, and both shortcuts are rebindable in Settings. + + The code behind this had been sitting in the app unreachable since brightness + control was added. `nudgeAll()` was written to be driven by a shortcut, the + shortcut was never registered, and nothing called it. It is wired up now + rather than deleted. + +### Fixed +- Stepping down can no longer land on zero. A display taken to full black has + no visible slider to bring it back, so the floor is one step above it, and a + display already at zero comes back up rather than sticking. + ## [1.9.1] — 2026-09-04 Homepage only — the app binary is unchanged from 1.9.0. It is tagged as a diff --git a/Sources/Perch/AppDelegate.swift b/Sources/Perch/AppDelegate.swift index 0eaafd3..99c8dc8 100644 --- a/Sources/Perch/AppDelegate.swift +++ b/Sources/Perch/AppDelegate.swift @@ -240,5 +240,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { hk.register("win.restore") { WindowManager.shared.restore() } hk.register("win.nextScreen") { WindowManager.shared.moveToScreen(next: true) } hk.register("win.prevScreen") { WindowManager.shared.moveToScreen(next: false) } + let step = BrightnessController.stepSize + hk.register("brightness.up") { BrightnessController.shared.nudgeAll(by: step) } + hk.register("brightness.down") { BrightnessController.shared.nudgeAll(by: -step) } } } diff --git a/Sources/Perch/Core/Hotkeys.swift b/Sources/Perch/Core/Hotkeys.swift index d214570..9659cbf 100644 --- a/Sources/Perch/Core/Hotkeys.swift +++ b/Sources/Perch/Core/Hotkeys.swift @@ -56,6 +56,11 @@ struct HotkeySpec: Codable, Equatable { "win.thirdL": .init(keyCode: UInt32(kVK_ANSI_D), modifiers: NSEvent.ModifierFlags([.control, .option]).rawValue), "win.thirdC": .init(keyCode: UInt32(kVK_ANSI_F), modifiers: NSEvent.ModifierFlags([.control, .option]).rawValue), "win.thirdR": .init(keyCode: UInt32(kVK_ANSI_G), modifiers: NSEvent.ModifierFlags([.control, .option]).rawValue), + // The Mac's own brightness keys only reach the built-in panel. These + // move every display, external ones included, which is the whole point + // of having them. + "brightness.up": .init(keyCode: UInt32(kVK_ANSI_Equal), modifiers: NSEvent.ModifierFlags([.control, .option]).rawValue), + "brightness.down": .init(keyCode: UInt32(kVK_ANSI_Minus), modifiers: NSEvent.ModifierFlags([.control, .option]).rawValue), ] } diff --git a/Sources/Perch/Screen/Brightness.swift b/Sources/Perch/Screen/Brightness.swift index 35f2fd0..9ab283a 100644 --- a/Sources/Perch/Screen/Brightness.swift +++ b/Sources/Perch/Screen/Brightness.swift @@ -78,8 +78,22 @@ final class BrightnessController: ObservableObject { for d in displays { setLevel(value, for: d.id) } } + /// One press of a brightness key. macOS moves in sixteenths, so matching + /// that keeps these keys feeling like the ones next to them. + static let stepSize = 1.0 / 16 + + /// Extracted so the clamping can be checked without a display attached. + /// Landing exactly on 0 would black a screen out with no way back, so the + /// floor is one step up from it. + static func stepped(_ level: Double, by delta: Double) -> Double { + min(1, max(stepSize, level + delta)) + } + func nudgeAll(by delta: Double) { - for d in displays { setLevel(level(for: d.id) + delta, for: d.id) } + guard !displays.isEmpty else { return } + for d in displays { + setLevel(Self.stepped(level(for: d.id), by: delta), for: d.id) + } Notifier.show("Brightness \(Int(level(for: displays.first?.id ?? 0) * 100))%", duration: 1) } diff --git a/Sources/Perch/SelfTest.swift b/Sources/Perch/SelfTest.swift index db54843..cc04c92 100644 --- a/Sources/Perch/SelfTest.swift +++ b/Sources/Perch/SelfTest.swift @@ -137,6 +137,17 @@ enum SelfTest { expect(!spec.cocoaFlags.isEmpty, "\(name) requires a modifier") } + print("\nBrightness steps stay in range") + let step = BrightnessController.stepSize + expect(BrightnessController.stepped(0.5, by: step) == 0.5625, + "one press moves a sixteenth") + expect(BrightnessController.stepped(1.0, by: step) == 1.0, + "stepping up from full stays at full") + expect(BrightnessController.stepped(0.05, by: -step) == step, + "stepping down floors at one step, never black") + expect(BrightnessController.stepped(0.0, by: -step) == step, + "a display already at zero comes back rather than sticking") + // OCR runs over whatever was captured, so it can be checked without // the screen: render known text, read it back. let sample = NSImage(size: NSSize(width: 640, height: 90)) diff --git a/Sources/Perch/UI/SettingsWindow.swift b/Sources/Perch/UI/SettingsWindow.swift index d471e88..ef6f8c0 100644 --- a/Sources/Perch/UI/SettingsWindow.swift +++ b/Sources/Perch/UI/SettingsWindow.swift @@ -517,6 +517,8 @@ private struct ShortcutSettings: View { ("win.bottomLeft", "Bottom Left Quarter"), ("win.bottomRight", "Bottom Right Quarter"), ("nightMode", "Night mode"), + ("brightness.up", "Brightness up"), + ("brightness.down", "Brightness down"), ("screenClean", "Screen cleaning"), ("keyboardClean", "Keyboard cleaning"), ("trackpadClean", "Trackpad cleaning"), @@ -568,6 +570,8 @@ private struct ShortcutSettings: View { case "screenshot": return "camera.viewfinder" case "switcher.altTab": return "arrow.left.arrow.right" case "nightMode": return "moon.fill" + case "brightness.up": return "sun.max.fill" + case "brightness.down": return "sun.min" default: return "macwindow" } } @@ -584,6 +588,7 @@ private struct ShortcutSettings: View { case "screenshot": return .pink case "switcher.altTab": return .blue case "nightMode": return .orange + case "brightness.up", "brightness.down": return .yellow default: return .gray } } diff --git a/docs/index.html b/docs/index.html index 1490057..77a8bab 100644 --- a/docs/index.html +++ b/docs/index.html @@ -796,7 +796,7 @@

Put windows where you want them

- Perch 1.9.1 — up to date + Perch 1.9.2 — up to date Check
@@ -848,7 +848,7 @@

Know what your Mac is doing

- Perch 1.9.1 — up to date + Perch 1.9.2 — up to date Check
@@ -890,7 +890,7 @@

The small stuff, one click away

- Perch 1.9.1 — up to date + Perch 1.9.2 — up to date Check
@@ -918,7 +918,7 @@

Everything in the box

ScreenshotsDrag to capture and the crosshair stays up for the next one. Grab a window, a display, or on a delay; read the text out of a selection; sample a colour; pin a shot above everything.
Updates install themselvesPerch replaces itself and restarts. The new build must be signed by the same key as the running one, or it is refused before anything moves.
Night mode6500K to 2400K via display gamma. Manual or scheduled.
-
BrightnessPer display, including external monitors over any cable.
+
BrightnessPer display, including external monitors over any cable. On a shortcut too, in the same sixteenths the Mac’s own keys use — which only ever reach the built-in panel.
SoundOutput level and mute, switch between outputs, and see which apps are actually making noise. No per-app sliders: macOS exposes no volume on a process, and the only way round it is shipping an audio driver.
Keep awakeHold it open, or for 15 minutes to 8 hours with the time left on the row. It can start itself while plugged in, while an app runs, or while the CPU is busy, and let the display sleep while the machine works.
@@ -946,6 +946,7 @@

Shortcuts

⌃⌥⌫Restore original size ⌃⌥⇧← →Move to previous / next display ⌃⌥⇧4Screenshot — stays armed; W window, F display, D delayed, T text, C colour, P pin + ⌃⌥= ⌃⌥-Brightness up / down on every display, external ones included ⌃⌥NNight mode ⌃⌥⇧SScreen cleaning ⌃⌥⇧LKeyboard cleaning @@ -1530,11 +1531,9 @@

Privacy

dimmer.style.opacity = ((1 - level) * MAX_DIM).toFixed(3); } - // No global shortcut reaches brightness, so it is tagged like the other - // panel-only actions rather than given a key it does not have. function describeBright() { wTiles.forEach(function (t) { t.classList.remove('on'); }); - wName.innerHTML = 'Brightness' + keyTag('panel'); + wName.innerHTML = 'Brightness⌃⌥=⌃⌥-'; wDesc.textContent = level === 1 ? 'Every display on one slider, external monitors included. Drag it — this one really does dim the page.' : 'At ' + Math.round(level * 100) + '%. A built-in display moves its actual backlight; anything else is dimmed with an overlay, which is what you are looking at.';