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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Sources/Perch/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
5 changes: 5 additions & 0 deletions Sources/Perch/Core/Hotkeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
}

Expand Down
16 changes: 15 additions & 1 deletion Sources/Perch/Screen/Brightness.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
11 changes: 11 additions & 0 deletions Sources/Perch/SelfTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
5 changes: 5 additions & 0 deletions Sources/Perch/UI/SettingsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"
}
}
Expand All @@ -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
}
}
Expand Down
13 changes: 6 additions & 7 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ <h3>Put windows where you want them</h3>
</div>
</div>
<div class="pp-foot">
<svg class="bird"><use href="#i-bird"/></svg><span>Perch 1.9.1 — up to date</span>
<svg class="bird"><use href="#i-bird"/></svg><span>Perch 1.9.2 — up to date</span>
<span class="sp"></span><em>Check</em>
</div>
</div>
Expand Down Expand Up @@ -848,7 +848,7 @@ <h3>Know what your Mac is doing</h3>
<div class="live-rows" id="liveRows"></div>
</div>
<div class="pp-foot">
<svg class="bird"><use href="#i-bird"/></svg><span>Perch 1.9.1 — up to date</span>
<svg class="bird"><use href="#i-bird"/></svg><span>Perch 1.9.2 — up to date</span>
<span class="sp"></span><em>Check</em>
</div>
</div>
Expand Down Expand Up @@ -890,7 +890,7 @@ <h3>The small stuff, one click away</h3>
<div class="pp-card" id="switchCard"></div>
</div>
<div class="pp-foot">
<svg class="bird"><use href="#i-bird"/></svg><span>Perch 1.9.1 — up to date</span>
<svg class="bird"><use href="#i-bird"/></svg><span>Perch 1.9.2 — up to date</span>
<span class="sp"></span><em>Check</em>
</div>
</div>
Expand Down Expand Up @@ -918,7 +918,7 @@ <h2>Everything in the box</h2>
<div class="cap"><svg class="ico"><use href="#i-sparkle"/></svg><div><b>Screenshots</b><span>Drag 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.</span></div></div>
<div class="cap"><svg class="ico"><use href="#i-download"/></svg><div><b>Updates install themselves</b><span>Perch 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.</span></div></div>
<div class="cap"><svg class="ico"><use href="#i-moon"/></svg><div><b>Night mode</b><span>6500K to 2400K via display gamma. Manual or scheduled.</span></div></div>
<div class="cap"><svg class="ico"><use href="#i-sun"/></svg><div><b>Brightness</b><span>Per display, including external monitors over any cable.</span></div></div>
<div class="cap"><svg class="ico"><use href="#i-sun"/></svg><div><b>Brightness</b><span>Per 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.</span></div></div>
<div class="cap"><svg class="ico"><use href="#i-sound"/></svg><div><b>Sound</b><span>Output 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.</span></div></div>
<div class="cap"><svg class="ico"><use href="#i-coffee"/></svg><div><b>Keep awake</b><span>Hold 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.</span></div></div>
</div>
Expand Down Expand Up @@ -946,6 +946,7 @@ <h2>Shortcuts</h2>
<tr><td><kbd>⌃⌥⌫</kbd></td><td>Restore original size</td></tr>
<tr><td><kbd>⌃⌥⇧←</kbd> <kbd>→</kbd></td><td>Move to previous / next display</td></tr>
<tr><td><kbd>⌃⌥⇧4</kbd></td><td>Screenshot — stays armed; W window, F display, D delayed, T text, C colour, P pin</td></tr>
<tr><td><kbd>⌃⌥=</kbd> <kbd>⌃⌥-</kbd></td><td>Brightness up / down on every display, external ones included</td></tr>
<tr><td><kbd>⌃⌥N</kbd></td><td>Night mode</td></tr>
<tr><td><kbd>⌃⌥⇧S</kbd></td><td>Screen cleaning</td></tr>
<tr><td><kbd>⌃⌥⇧L</kbd></td><td>Keyboard cleaning</td></tr>
Expand Down Expand Up @@ -1530,11 +1531,9 @@ <h2>Privacy</h2>
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<kbd>⌃⌥=</kbd><kbd>⌃⌥-</kbd>';
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.';
Expand Down
Loading