diff options
Diffstat (limited to 'src/macos/Sources/AppDelegate.swift')
| -rw-r--r-- | src/macos/Sources/AppDelegate.swift | 484 |
1 files changed, 461 insertions, 23 deletions
diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift index 4df5899c..cda35ff0 100644 --- a/src/macos/Sources/AppDelegate.swift +++ b/src/macos/Sources/AppDelegate.swift @@ -1,31 +1,57 @@ import AppKit // The macOS host: one window, one view, one core. libpardes owns the state -// machine, the ptys and every worker thread; this file owns the window and the -// pump that lets the core move at all. +// machine, the ptys and every worker thread; this file owns the window, the +// menu bar, and the pump that lets the core move at all. // // PardesView translates events and calls pardes_key / pardes_mouse / // pardes_scroll itself, but never pardes_tick — the core only queues what it // was told and does nothing until it is pumped. Rather than grow the view's -// delegate a third method for "I just fed the core", the view posts this -// notification after every input call and we answer it with pump(). The string -// below is the whole contract with PardesView.swift; keep the two in step. -private let didInputNotification = Notification.Name("pardesDidInput") +// delegate a third method for "I just fed the core", the view posts +// pardesDidInputNotification after every input call and we answer it with +// pump(). That name is declared once, in PardesView.swift, precisely so the two +// files cannot drift apart on a string literal. 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 // ticking until the fade ended. private var pumpScheduled: Bool = false + // The core is a singleton with no "is it alive" query, and Finder can hand + // us documents before applicationDidFinishLaunching runs. Every entry point + // that would call into libpardes from outside the launch sequence checks + // this first; opens that arrive early queue below instead of crashing in a + // core that has not been constructed. + private var coreIsUp: Bool = false + private var pendingOpens: [URL] = [] func applicationDidFinishLaunching(_ notification: Notification) { + // Captured before the chdir below, because `pardes ./foo.zig` from a + // terminal means foo.zig in the shell's directory, not in $HOME. + let launchDirectory = FileManager.default.currentDirectoryPath + + // A bundle launched from Finder, the Dock or `open(1)` inherits cwd `/` + // — launchd's, not any shell's — so the first pane's shell would start + // at the root of the disk and every relative path the user types would + // resolve there. A binary run from a terminal inherits that terminal's + // directory, which is already what was meant, so only the `/` case is + // corrected: anything else is somebody's deliberate choice. + if launchDirectory == "/" { + FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory()) + } + + installMainMenu() + // ponytail: 14pt, fixed. The SDL shell steps its font on Ctrl+/Ctrl- // (gui.zig); doing that here means re-measuring the view's metrics and // pushing a resize behind it, so it waits until the font has to move. - view = PardesView(fontSize: 14) + view = PardesView(fontSize: defaultFontSize) let want = NSSize(width: 1000, height: 700) window = NSWindow( @@ -38,18 +64,67 @@ 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. - // - // ponytail: snapped once, at launch — a live drag lands wherever the - // mouse lets go. window.contentResizeIncrements would snap every - // resize, at the price of arguing with macOS full-screen tiling. window.setContentSize(NSSize( width: (want.width / view.cellWidth).rounded(.down) * view.cellWidth, height: (want.height / view.cellHeight).rounded(.down) * view.cellHeight)) - window.center() + // Autosave after the snap, never before: on a first run there is no + // saved frame and the window must still open at the snapped default, + // and registering the name first would have AppKit write the unsnapped + // 1000x700 out as the remembered geometry. + window.setFrameAutosaveName("pardes") + // setFrameAutosaveName only arms the saving half; the restore is this + // call, and it reports whether there was anything to restore. Both must + // happen before pardes_init, because the grid we boot the core with has + // to be the grid the window actually ends up at. + if !window.setFrameUsingName("pardes") { + window.center() + } + // Resize in whole cells. Increments are measured from the window's + // current size rather than from zero, which is why the snap above still + // matters: without it every drag would land a half-column short of the + // frame, and the view would draw a strip it can never put a glyph in. + window.contentResizeIncrements = NSSize(width: view.cellWidth, height: view.cellHeight) + // One core per process, so a second tab would be an empty window with + // no grid behind it. macOS offers tabs on any titled resizable window + // unless told otherwise. + window.tabbingMode = .disallowed + // 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 @@ -60,14 +135,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // own, but "may" is not something to bet every keystroke on, and there // is no click-to-focus path here that would recover it. window.makeFirstResponder(view) - // ponytail: no main menu, so no Cmd+Q — the close button and the core's - // Exit builtin are the two ways out. An NSMenu is ten lines, but the - // moment one exists it also has to decide which Cmd keys the core is - // allowed to see, and that is a real decision, not boilerplate. - // - // UNVERIFIED: activate(ignoringOtherApps:) is deprecated on the 14 SDK - // in favour of activate(), which does not exist at our 13.0 deployment - // target. Expect a deprecation warning, not an error. + // activate(ignoringOtherApps:) is deprecated in favour of activate() on + // the 14 SDK, but activate() does not exist at our 13.0 deployment + // target and the deprecation does not fire below it. This is the call + // to change the day macos_min_version reaches 14. NSApp.activate(ignoringOtherApps: true) var runtime = pardes_runtime_s( @@ -104,13 +175,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // would call pardes_deinit on a core that never came up. exit(1) } + coreIsUp = true // Only now, after init. setContentSize above resized the view, and a // pardesViewDidResize landing before pardes_init would have pushed a // resize into a core that did not exist yet. view.delegate = self NotificationCenter.default.addObserver( - self, selector: #selector(inputArrived(_:)), name: didInputNotification, object: nil) + self, selector: #selector(inputArrived(_:)), name: pardesDidInputNotification, object: nil) // pardes_init takes no cell metrics, so the core's PDF placement would // have none until the user first dragged the window. This seeds them, @@ -118,6 +190,316 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // core compares the effective pixel viewport, not the resize event, so // duplicate SIGWINCH-shaped notifications are already a no-op. pardesViewDidResize(view) + + // Positional paths, after the first frame's worth of state exists. + // Anything starting with `-` is skipped rather than opened: the core + // has no flags yet, and Finder itself passes `-psn_0_...` on some + // launch paths, which would otherwise become a Look for a file that + // does not exist. + for argument in CommandLine.arguments.dropFirst() where !argument.hasPrefix("-") { + look(absolutePath(of: argument, relativeTo: launchDirectory)) + } + + // Documents that Finder handed us before the core existed. + let queued = pendingOpens + pendingOpens = [] + for url in queued { + look(url.path) + } + } + + // Finder double-click, Dock drop, `open -a pardes file`, and "Open With". + // The URL-array form rather than application(_:openFile:), which has been + // deprecated since 10.13 and only ever reports one path at a time. + func application(_ application: NSApplication, open urls: [URL]) { + // A non-file URL would arrive here only through a URL scheme we do not + // register, but Look wants a path and would treat the scheme prefix as + // part of one. + let files = urls.filter { $0.isFileURL } + guard coreIsUp else { + // Finder sends these between applicationWillFinishLaunching and + // applicationDidFinishLaunching, so they arrive before the core is + // constructed and are replayed at the end of launch. They land + // after the argv paths, which costs nothing: each path is an + // independent Look and only the last one takes focus. + pendingOpens.append(contentsOf: files) + return + } + for url in files { + look(url.path) + } + } + + // ---------------------------------------------------------------- menu + + // Built in code rather than loaded from a nib. There is no Xcode project + // here (see docs/macos.md), so a MainMenu.xib would be a binary blob nobody + // in this tree can edit; the menu is thirty lines of Swift instead. + // + // Every item that reaches the core goes through run(), which is + // pardes_command — the core's own `command` event, the same channel a + // pardes nested inside another one speaks over. So a menu item is exactly + // the text you could have typed into a tag and executed, and there is no + // second vocabulary to keep in step with builtins.zig. + // + // The cost of having a menu at all is that these chords are now the menu's + // and the core can never see them: Cmd+Q, Cmd+H, Opt+Cmd+H, Cmd+O, Cmd+N, + // Cmd+S, Cmd+W, Cmd+V, Cmd+M and Cmd+?. AppKit offers a key equivalent to + // the main menu before the event ever reaches the key window. Every other + // Cmd chord still dies in PardesView, which swallows them because the ABI's + // modifier mask has ctrl, alt and shift and no super bit — a Cmd chord + // handed to the core would arrive as its unmodified letter and type itself + // into the buffer. + private func installMainMenu() { + let appName = ProcessInfo.processInfo.processName + let main = NSMenu() + + // AppKit treats the first top-level item as the application menu + // whatever it is titled, and substitutes the bundle name in bold. + let app = submenu(appName, of: main) + app.addItem(withTitle: "About \(appName)", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + keyEquivalent: "") + app.addItem(.separator()) + app.addItem(withTitle: "Hide \(appName)", + action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + let hideOthers = app.addItem(withTitle: "Hide Others", + action: #selector(NSApplication.hideOtherApplications(_:)), + keyEquivalent: "h") + hideOthers.keyEquivalentModifierMask = [.command, .option] + app.addItem(withTitle: "Show All", + action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + app.addItem(.separator()) + app.addItem(withTitle: "Quit \(appName)", + action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + + let file = submenu("File", of: main) + file.addItem(command("Open\u{2026}", #selector(menuOpen(_:)), "o")) + file.addItem(command("New", #selector(menuNew(_:)), "n")) + file.addItem(command("Save", #selector(menuSave(_:)), "s")) + file.addItem(.separator()) + // performClose: goes to the key window through the responder chain, so + // it keeps working if this app ever grows a panel. + file.addItem(withTitle: "Close Window", + action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + + let edit = submenu("Edit", of: main) + // Paste and nothing else. Copy, Cut, Undo and Select All have no + // builtin behind them — the core yanks into the clipboard on its own + // selection gestures and has no undo command to call — and a greyed or + // silently dead menu item teaches the user the menu lies. When the core + // grows those commands they belong here, spelled as run("...") like + // everything else. + edit.addItem(command("Paste", #selector(menuPaste(_:)), "v")) + + // The only menu whose items are not core commands: the font size is a + // property of this shell and nothing else. `Font` picks the FACE and + // lives in the topbar with the other builtins; the size is where the + // window is, so it is where macOS keeps it. + let viewMenu = submenu("View", of: main) + // Cmd+= rather than Cmd++: the key is unshifted `=` on every layout + // that has a `+` above it, and AppKit matches the character, not the + // engraving. The item is titled with the plus users look for. + viewMenu.addItem(command("Zoom In", #selector(menuZoomIn(_:)), "=")) + viewMenu.addItem(command("Zoom Out", #selector(menuZoomOut(_:)), "-")) + viewMenu.addItem(command("Actual Size", #selector(menuZoomReset(_:)), "0")) + + let windowMenu = submenu("Window", of: main) + windowMenu.addItem(withTitle: "Minimize", + action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + + let help = submenu("Help", of: main) + // Cmd+? is the system-wide help chord; AppKit draws it as the shift of + // Cmd+/ without being told about the shift. + help.addItem(command("\(appName) Help", #selector(menuHelp(_:)), "?")) + help.addItem(command("Tutorial", #selector(menuTutor(_:)), "")) + + NSApp.mainMenu = main + // Handing AppKit these two makes it keep the window list up to date and + // put the Help search field at the top of the Help menu. Both must come + // after mainMenu is assigned, or AppKit has nothing to attach them to. + NSApp.windowsMenu = windowMenu + NSApp.helpMenu = help + } + + private func submenu(_ title: String, of parent: NSMenu) -> NSMenu { + let holder = NSMenuItem(title: title, action: nil, keyEquivalent: "") + // The submenu's own title is what AppKit shows in the menu bar for the + // Window and Help menus, which it looks up by title rather than by the + // item that holds them. + let menu = NSMenu(title: title) + holder.submenu = menu + parent.addItem(holder) + return menu + } + + // An item that runs one of our own actions. Explicitly targeted at self + // rather than left to the responder chain: PardesView is the first + // responder and answers none of these, and an untargeted item that finds no + // handler renders greyed out. + private func command(_ title: String, _ action: Selector, _ key: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: key) + item.target = self + return item + } + + @objc private func menuOpen(_ sender: Any?) { + let panel = NSOpenPanel() + panel.canChooseFiles = true + // Directories are first-class Look targets — the core opens one as a + // directory pane, which is how you navigate in acme — so refusing them + // here would hide half of what Look does. + panel.canChooseDirectories = true + panel.allowsMultipleSelection = true + panel.resolvesAliases = true + guard panel.runModal() == .OK else { return } + for url in panel.urls where url.isFileURL { + look(url.path) + } + } + + @objc private func menuNew(_ sender: Any?) { run("New") } + @objc private func menuSave(_ sender: Any?) { run("Save") } + @objc private func menuHelp(_ sender: Any?) { run("Help") } + @objc private func menuTutor(_ sender: Any?) { run("Tutor") } + + // Deliberately the view's paste path and not a second one: Cmd+V from the + // menu and Cmd+V in the view must put the same bytes in through + // pardes_paste, or the two would diverge the first time either grows a + // filter. + @objc private func menuPaste(_ sender: Any?) { + guard coreIsUp else { return } + pardesViewRequestsPaste(view) + } + + // Zoom does not go through the core at all: it changes the cell, the view + // reports the new grid, and the core reflows to it exactly as it does for + // a window drag. Guarded on coreIsUp only because the resize callback it + // triggers calls into libpardes. + @objc private func menuZoomIn(_ sender: Any?) { + guard coreIsUp else { return } + view.zoom(by: 1) + } + + @objc private func menuZoomOut(_ sender: Any?) { + guard coreIsUp else { return } + view.zoom(by: -1) + } + + @objc private func menuZoomReset(_ sender: Any?) { + guard coreIsUp else { return } + view.zoomReset() + } + + // ---------------------------------------------------------------- core + + // One builtin command line, run as if it had been typed into a tag and + // executed. `command` is bridged to a temporary NUL-terminated UTF-8 buffer + // that lives exactly as long as the call, which is exactly as long as the + // core borrows it. + private func run(_ command: String) { + guard coreIsUp else { return } + pardes_command(command, command.utf8.count) + pump() + } + + // Look's operand is the whole tail of the line (executeBuiltinLine in + // src/pardes.zig splits on the first space and trims the rest), so a path + // with spaces in it needs no quoting and must not get any — quotes would + // become part of the filename. + private func look(_ path: String) { run("Look \(path)") } + + // argv paths are whatever the shell handed us. The core resolves a relative + // Look against the pane's directory, not the process's, so `pardes + // ./foo.zig` would open the wrong foo.zig — or nothing — unless it is made + // absolute here against the directory the process was launched from. + private func absolutePath(of argument: String, relativeTo directory: String) -> String { + let expanded = (argument as NSString).expandingTildeInPath + guard !(expanded as NSString).isAbsolutePath else { + return (expanded as NSString).standardizingPath + } + 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) { @@ -128,7 +510,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // the effects the core queued, so nothing the user did takes hold until it // runs. private func pump() { - if pardes_tick() { view.needsDisplay = true } + // Unconditionally dirty, and NOT `if pardes_tick()`. That return value + // answers "did I do any IO" — it is true when a pty produced bytes or + // an effect was performed, and false for everything the core changes on + // its own. A cursor moved with j, a selection, a mode change and a + // scroll all queue nothing and perform nothing, so gating the repaint + // on it leaves the screen showing the state before the keystroke until + // some unrelated event happens to force a frame. Measured: four `j` + // presses in a file pane produced a byte-identical screenshot. + // + // The waste is bounded and small. AppKit coalesces needsDisplay within + // a runloop pass, so a burst of pty output is still one frame, and an + // idle wakeup cannot happen — a reader only wakes the host after it has + // 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 + // committed action; Look went somewhere, so it gets .alignment, the + // lighter detent AppKit uses when a dragged guide snaps into place. + // Same distinction the core draws in Haptic (src/pardes.zig). + // + // No capability check on purpose. perform() is a silent no-op on a Mac + // with no Force Touch trackpad and when the user has turned feedback + // off in System Settings, so a check here would only be a second place + // to get the answer wrong — and it would be wrong the moment an + // external trackpad is plugged in mid-session. + let pulse = pardes_take_haptic() + if pulse == PARDES_HAPTIC_EXEC { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now) + } else if pulse == PARDES_HAPTIC_LOOK { + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) + } + + // Beside the haptic, and for the same reason: the `Font` builtin ran + // synchronously inside whatever input reached the core, so its answer + // is already waiting by the time the tick returns. The view re-measures + // and calls back through pardesViewDidResize, so the grid the core is + // holding follows the cell that just changed size. + if let wanted = pardes_font_take() { + view.adoptFont(path: String(cString: wanted)) + } + if pardes_should_quit() { NSApp.terminate(nil) return @@ -173,6 +602,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { return true } + // Nothing in this app's UI state can be restored: the window is one view + // over a core that has to replay its own session, and the frame is already + // handled by setFrameAutosaveName. Answering true opts into the secure + // coder AppKit wants; leaving the method out makes macOS 14 and later log a + // deprecation warning on every launch. + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } + func applicationWillTerminate(_ notification: Notification) { pardes_deinit() } |
