From 12c6a4c19bd5934572449c43fe97fd00c356cbd0 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 18 May 2026 15:05:25 -0400 Subject: [PATCH] slab menubar: cross-app tiling + screen-fill rect packing + screensaver action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tileNow() now packs iTerm2 *and* Terminal.app windows into one shared grid (iTerm2 cells first, then Terminal). New windowCount(app:) probes via `application "X" is running` so a quit Terminal contributes 0 cells and is never launched. Terminal gets bounds only (no decor/wallpaper — AppleScript can't set per-session bg image on Terminal.app). - computeTileLayout/TileLayout reworked from a uniform cols×rows grid (which left holes at N=3/5/7/8/10…) to a row-balanced rect packing: near-square row count, windows spread evenly across rows, equal row heights, per-row equal column widths, last row/col snapped to the exact screen edge to absorb integer-division remainder. Always fills the visible frame edge-to-edge with no holes; sparse rows get wider panels. cellAt walks rowCounts to map a flat index → (row,col). - New "Start screensaver" menu item under "Sleep now" → startScreensaver() runs `open -a ScreenSaverEngine.app` (the System Events / "start current screen saver" verb is gone on macOS 26). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/SlabMenubar/AppDelegate.swift | 187 +++++++++++++----- .../Sources/SlabMenubar/MenuBuilder.swift | 4 + 2 files changed, 138 insertions(+), 53 deletions(-) diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 57a733a351..9bdfd20ebd 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -213,6 +213,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ShellRunner.runAsync(Paths.claudeSleep, args: ["now"]) } + /// Kick off the currently-selected screen saver now (the Slab Status + /// saver if it's chosen in System Settings). Launching ScreenSaverEngine + /// directly is the only path that still works on macOS 26 — the old + /// `System Events`/`start current screen saver` verb was removed. + @objc func startScreensaver() { + ShellRunner.runAsync( + "/usr/bin/open", + args: ["-a", "/System/Library/CoreServices/ScreenSaverEngine.app"] + ) + } + @objc func toggleMute() { let path = Paths.muteFlag let fm = FileManager.default @@ -661,27 +672,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// frame). Total inter-window gap is 2*gutter; total edge gap = gutter. static let tileGutter = 2 + /// Row-balanced rect packing. Windows are distributed across `rowCounts` + /// rows (top→bottom); a row with fewer windows simply makes each of its + /// panels wider, so the layout always fills the visible frame edge-to- + /// edge with **no empty cells** — never a square grid with holes. + /// Row heights are equal; column widths vary per row. Integer-division + /// remainder is absorbed by snapping the last row/column to the exact + /// screen edge so there is no dead strip on the right or bottom. struct TileLayout { - let cols: Int - let rows: Int - let cellWidth: Int - let cellHeight: Int - let originX: Int // visible-frame left edge - let originY: Int // visible-frame top edge (below menu bar), AS coords + let rowCounts: [Int] // windows per row, top row first; sum == count + let originX: Int // visible-frame left edge (AS coords) + let originY: Int // visible-frame top edge (below menu bar) + let width: Int // full visible width to fill + let height: Int // full visible height to fill let fontSize: Int + var rows: Int { rowCounts.count } + struct Cell { let bounds: (left: Int, top: Int, right: Int, bottom: Int) } func cellAt(index: Int) -> Cell { - let row = index / cols - let col = index % cols - let left = originX + col * cellWidth + tileGutter - let top = originY + row * cellHeight + tileGutter - let right = originX + (col + 1) * cellWidth - tileGutter - let bottom = originY + (row + 1) * cellHeight - tileGutter - return Cell(bounds: (left, top, right, bottom)) + // Walk the rows to find which one this flat index lands in. + var rem = max(0, index) + var row = rowCounts.count - 1 + for (r, c) in rowCounts.enumerated() { + if rem < c { row = r; break } + rem -= c + } + let colsInRow = max(1, rowCounts[row]) + let col = min(rem, colsInRow - 1) + + // Equal-height rows; last row snaps to the bottom edge. + let top = originY + row * height / rows + let bottom = (row == rows - 1) + ? originY + height + : originY + (row + 1) * height / rows + // Equal-width columns within the row; last col snaps to the + // right edge so a sparse row stretches full width. + let left = originX + col * width / colsInRow + let right = (col == colsInRow - 1) + ? originX + width + : originX + (col + 1) * width / colsInRow + + return Cell(bounds: ( + left + tileGutter, top + tileGutter, + right - tileGutter, bottom - tileGutter + )) } } @@ -708,24 +746,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ) } - /// Pack `count` windows into the most-square grid that fits the main - /// display's visible frame. Font size scales down for denser grids so - /// even N=10 stays legible. + /// Pack `count` windows into a row-balanced layout that fills the whole + /// visible frame — no holes, ever. Pick a near-square row count, then + /// spread the windows across those rows as evenly as possible; rows that + /// come up a window short just get wider panels. Font size scales down + /// for denser layouts so even N=10 stays legible. private static func computeTileLayout(count: Int, geom: ScreenGeom, near: Bool = false) -> TileLayout? { guard count > 0 else { return nil } - let cols = max(1, Int(ceil(Double(count).squareRoot()))) - let rows = max(1, Int(ceil(Double(count) / Double(cols)))) - let cellW = geom.width / cols - let cellH = geom.height / rows - // Inner pixels available per window after the gutter is taken out. - let innerW = max(1, cellW - 2 * tileGutter) - let innerH = max(1, cellH - 2 * tileGutter) - - // Pick the largest font that fits ~24 rows × ~80 cols of monospace - // in the inner area. A char cell is roughly fontSize * 0.6 wide and - // fontSize * 1.2 tall in Menlo/SF Mono. Enlarging is fine — for low - // N the cells are huge, so the font scales up rather than us - // wasting empty pixels at a fixed default. + + // Near-square: same density feel as the old grid, but the per-row + // counts below let sparse rows stretch instead of leaving holes. + let approxCols = max(1, Int(ceil(Double(count).squareRoot()))) + let rows = max(1, Int(ceil(Double(count) / Double(approxCols)))) + + // Distribute `count` across `rows` as evenly as possible. The first + // `rem` rows carry one extra window; the lighter (wider-panel) rows + // fall to the bottom, where a stretched window reads naturally. + let base = count / rows + let rem = count % rows + let rowCounts = (0.. 0 else { return } + // Size the grid to everything on screen across both apps. The + // `is running` guard never launches a quit Terminal.app, so a + // non-running Terminal contributes zero cells. + let nIterm = Self.windowCount(app: "iTerm2") + let nTerm = Self.windowCount(app: "Terminal") + let n = nIterm + nTerm + guard n > 0 else { return } guard let layout = Self.computeTileLayout(count: n, geom: geom, near: near) else { return } - // Reset decor memo so the next refresh re-themes every window - // from scratch (a re-pack invalidates prior placement). + // Reset decor memo so the next refresh re-themes every iTerm2 + // window from scratch (a re-pack invalidates prior placement). DispatchQueue.main.async { [weak self] in self?.lastTiledFontSize = layout.fontSize self?.lastTerminalDecor.removeAll() } - // iTerm2 has no per-window font via AppleScript, so tiling is - // pure pixel bounds — no font/bounds dance to fight a grid snap. - var lines: [String] = ["tell application \"iTerm2\"", " activate"] - for i in 0.. 0 { + lines.append("tell application \"iTerm2\"") + lines.append(" activate") + for i in 0.. 0 { + lines.append("tell application \"Terminal\"") + for j in 0.. Int { + let script = """ + if application "\(app)" is running then + tell application "\(app)" to count windows + else + 0 + end if + """ + let out = ShellRunner.run( + "/usr/bin/osascript", + args: ["-e", script], + timeout: 5 + ).output.trimmingCharacters(in: .whitespacesAndNewlines) + return max(0, Int(out) ?? 0) + } + private func syncMail(account: String?) { mailSyncing = true mailStatus = "syncing…" diff --git a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift index 9161e1b8f0..3c827296c2 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift @@ -31,6 +31,10 @@ enum MenuBuilder { menu.addItem(stayAwake) menu.addItem(item("Sleep now", selector: #selector(AppDelegate.sleepNow), target: target)) + let saver = item("Start screensaver", selector: #selector(AppDelegate.startScreensaver), target: target) + saver.toolTip = "Launch the currently-selected screen saver now (Slab Status if chosen in System Settings)." + menu.addItem(saver) + let mute = item("Mute ambient sonification", selector: #selector(AppDelegate.toggleMute), target: target) mute.state = state.muted ? .on : .off menu.addItem(mute) -- 2.51.2