diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-10 17:23:18 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 09:58:59 -0300 |
| commit | a797a1ab2f648e773f7a1b28d12bf9441b9f13f4 (patch) | |
| tree | 45db37a3d190425b3ca2453d46c57d2af27cfc93 /src/macos/Sources/AppDelegate.swift | |
| parent | eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (diff) | |
| download | pardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.tar.gz pardes-a797a1ab2f648e773f7a1b28d12bf9441b9f13f4.zip | |
macos: pixel attachments, live theming, and a signed app
The AppKit shell now draws what the core renders, follows the theme without a
relaunch, and builds into something you can hand to someone.
- Pixel attachments. Surface.images was dropped on the floor here, so a PDF
pane showed nothing at all: native_images is now set, pardes_image_s carries
the geometry the core already clipped, and PardesView keeps one CGImage per
(serial, page, revision) so scrolling costs a draw and not a decode. Image
panes get real pixels instead of the petscii fallback.
- Themes take hold live. pardes_tick never advanced the chrome animation, so
every tagline kept the previous theme's colours until the next launch and
the 16 ms re-pump spun for the rest of the session. pardes_theme_bg retires
the hand-agreed #121212 and drives the window background and the titlebar
appearance; a theme with no background of its own now gets a transparent
window over an NSVisualEffectView.
- The cell snaps to whole DEVICE pixels rather than whole points. Monaco
advances 8.4014pt at 14, so ceiling to 9 spaced every column 7.1% wider than
the face was drawn for.
- The dial is one notch per 10 degrees instead of 20, and a release keeps
turning in proportion to how hard it was thrown -- ramping up from zero at
the floor, so a slow twist coasts not a little but not at all.
- A file dropped on the grid is a click plus Look, so it opens beside the pane
it was dropped on. No drop concept was added to the core.
- The titlebar follows the focused pane: proxy icon, filename, and the dirty
dot. File.saved_revision is the watermark that last one needed.
- Config (SPC f c) prints the resolved startup config path.
- build.zig assembles, signs and packages the bundle itself; build-app.sh is
gone. -Dmacos-identity= takes a Developer ID, macos-dmg makes the image, and
the icon is Glenda.
Diffstat (limited to 'src/macos/Sources/AppDelegate.swift')
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 133 |
1 files changed, 122 insertions, 11 deletions
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift index f5ca4080..cda35ff0 100644 --- a/src/macos/Sources/AppDelegate.swift +++ b/src/macos/Sources/AppDelegate.swift @@ -15,6 +15,9 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { private var window: NSWindow! private var view: PardesView! + /// The blur behind the grid, shown only while the theme declares no + /// background of its own. See applyTheme. + private var backdrop: NSVisualEffectView! // One pump chain at a time. pump() re-arms itself while a theme transition // is in flight and every input pumps as well, so without this a burst of // keys during a fade would leave one 60 Hz chain per keystroke, all of them @@ -61,7 +64,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // isReleasedWhenClosed on would have AppKit release it out from under // that reference the moment the close button is pressed. window.isReleasedWhenClosed = false - window.contentView = view + // The grid and the blur are SIBLINGS in a plain container, not parent + // and child. A transparent theme has to show something through the + // grid, and AppKit will not blur what is behind a window unless an + // NSVisualEffectView asks it to — but the backdrop is hidden for every + // theme that brings its own background, and hiding a superview hides + // its subviews with it. Nested, an opaque theme drew a blank window. + let container = NSView(frame: NSRect(origin: .zero, size: want)) + container.autoresizesSubviews = true + backdrop = NSVisualEffectView(frame: container.bounds) + // .underWindowBackground is the material meant for exactly this — the + // full-window wash behind content, rather than the sidebar/HUD + // materials that carry their own tint. .behindWindow is what samples + // the desktop instead of the window's own layers. + backdrop.material = .underWindowBackground + backdrop.blendingMode = .behindWindow + // .active, not .followsWindowActiveState: the grid stays readable when + // the window is not key, and a backdrop that flattens to grey on focus + // loss makes an unfocused pardes look switched off. + backdrop.state = .active + backdrop.autoresizingMask = [.width, .height] + view.frame = container.bounds + view.autoresizingMask = [.width, .height] + // Order matters: the blur is BEHIND the grid. + container.addSubview(backdrop) + container.addSubview(view, positioned: .above, relativeTo: backdrop) + window.contentView = container // Trim the content box down to whole cells: a partial column or row is // dead space the core can never draw into. @@ -89,16 +117,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // no grid behind it. macOS offers tabs on any titled resizable window // unless told otherwise. window.tabbingMode = .disallowed - // The same constant PardesView paints as defaultBG. Two places name it - // because two different things draw: the view fills its own bounds, and - // AppKit fills the titlebar and every pixel of a live resize the view - // has not caught up with yet — without this that gap flashes white on - // every drag. The day the core exposes its theme background over the - // ABI, both should read it instead of agreeing by hand. - window.backgroundColor = NSColor(srgbRed: 0x12 / 255, green: 0x12 / 255, blue: 0x12 / 255, alpha: 1) - // A dark window with a light-mode titlebar reads as a bug. This also - // gets the traffic-light glyphs and the resize cursor right. - window.appearance = NSAppearance(named: .darkAqua) + // What the core is wearing, not a constant agreed by hand — two things + // draw and both have to say the same thing: the view fills its own + // bounds, and AppKit fills the titlebar and every pixel of a live + // resize the view has not caught up with yet. Without that agreement + // the gap flashes on every drag. It also owns window.appearance: a dark + // window with a light-mode titlebar reads as a bug, and so does the + // reverse the moment someone wears `acme`. + applyTheme() // On screen before pardes_init, so that the backingScaleFactor read // when seeding the cell metrics below is the one of the screen the // window actually landed on. Drawing before the core exists costs an @@ -396,6 +422,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { return ((directory as NSString).appendingPathComponent(expanded) as NSString).standardizingPath } + /// Dress the WINDOW in what the core is wearing: the background AppKit + /// paints where the view does not (the titlebar, and the strip a live + /// resize outruns), and the blur behind a theme that brings no background + /// of its own. + /// + /// Cheap enough to call every pump: the view compares before it dirties + /// itself, and the window properties are written only when the answer + /// moved. `dressed` is what makes the FIRST call unconditional — a boot + /// theme whose background happened to equal the view's starting guess + /// would otherwise leave the window in AppKit's default clothes forever. + private var dressed = false + + private func applyTheme() { + let changed = view.adoptThemeBG(pardes_theme_bg()) + guard changed || !dressed else { return } + dressed = true + if let rgb = view.themeBG { + backdrop.isHidden = true + window.isOpaque = true + window.backgroundColor = NSColor( + srgbRed: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: 1) + // ...and follow the theme into light mode, so the titlebar, the + // traffic lights and the resize cursor stop belonging to a + // different application than the grid under them. Luminance off + // the same sRGB channels the grid is drawn with. + let luma = (0.2126 * CGFloat((rgb >> 16) & 0xFF) + + 0.7152 * CGFloat((rgb >> 8) & 0xFF) + + 0.0722 * CGFloat(rgb & 0xFF)) / 255 + window.appearance = NSAppearance(named: luma > 0.5 ? .aqua : .darkAqua) + } else { + // Transparent: the window stops painting anything of its own and + // the blur takes over. isOpaque false is what lets the desktop + // reach the backdrop at all — a titled window is opaque by default + // and would composite over it. + backdrop.isHidden = false + window.isOpaque = false + window.backgroundColor = .clear + window.appearance = NSAppearance(named: .darkAqua) + } + } + + /// The focused pane, in the titlebar: the proxy icon macOS lets you drag + /// and Cmd-click for the path, and the dot in the close button that means + /// unsaved. + /// + /// A pure read-out — the window says what the core already decided, and + /// nothing here can change it. There is no document ARCHITECTURE behind it + /// and deliberately so: no NSDocument, no save panel, no "do you want to + /// save" on close. Save is a builtin, the pane's tag says so, and this is + /// the same two facts spelled where a Mac user looks for them. + /// + /// Cached, because setting representedURL makes AppKit hit the filesystem + /// for the icon and this runs on every pump. + private var shownPath: String? + private var shownDirty = false + + private func applyDocument() { + let path = pardes_active_path().map { String(cString: $0) } + if path != shownPath { + shownPath = path + // A terminal or an output buffer is not a document: no path means + // no proxy icon, rather than a stale one from the last file pane. + window.representedURL = path.map { URL(fileURLWithPath: $0) } + // ...and the title goes with it. A proxy icon beside a title that + // names something else reads as a bug, and AppKit will not fill the + // title in for us while `title` has been set by hand. The window is + // a SESSION and not a document, so it falls back to the app's own + // name the moment focus lands somewhere with no file behind it. + window.title = path.map { ($0 as NSString).lastPathComponent } ?? "pardes" + } + let dirty = pardes_active_dirty() + if dirty != shownDirty { + shownDirty = dirty + window.isDocumentEdited = dirty + } + } + @objc private func inputArrived(_ notification: Notification) { pump() } @@ -419,6 +525,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // read bytes. _ = pardes_tick() view.needsDisplay = true + // After the tick, because a `Theme` command runs inside it and the + // window has to follow the grid in the same frame rather than at the + // next launch. + applyTheme() + applyDocument() // Two verbs, two patterns, because they are two different answers: // Exec did something, so it gets .generic, the definite tap of a |
