Repository navigation
Architecture
SceneCore (Swift Package) pure logic, unit-testable without Xcode
└── Sources/SceneCore/
├── AX/ Accessibility API wrappers
├── Animation/ Clock, FrameInterpolator, AnimationRunner (V0.2)
├── Display/ NSScreen helpers
├── Interaction/ Carbon hotkey + AX drag-swap (V0.3)
├── Layout/ Slot, Layout, LayoutEngine, Plan, Geometry,
│ LayoutTemplate, CustomLayout, PresetSeeds, LayoutStore (V0.2)
├── Settings/ AnimationConfig, HotkeyBinding, DragSwapConfig,
│ SettingsStore, Cancellable
└── Workspace/ Workspace, WorkspaceTrigger, WorkspaceSeeds,
WorkspaceStore, FocusModeReference (V0.4)
SceneApp (Xcode target) thin SwiftUI/AppKit shell + settings window
└── SceneApp/SceneApp/
├── Animation/ WindowAnimator (CVDisplayLink + AX bridge) (V0.2)
├── Interaction/ AXMoveObserverGroup, AXWindowLookup,
│ DragSwapAnimationSink (V0.3)
├── Settings/ SettingsWindowController + SettingsRoot + 5 tabs
│ (Workspaces / Layouts / Hotkeys / Interaction / About) +
│ LayoutEditorView + HotkeyCaptureView +
│ WorkspacesTab + WorkspaceEditorView +
│ WorkspaceTriggerEditor + AppPickerView +
│ LayoutPickerView + LayoutThumbnail (V0.4)
├── Stores/ LayoutStoreViewModel, SettingsStoreViewModel,
│ WorkspaceStoreViewModel (V0.4)
├── Workspace/ WorkspaceActivator, AppLauncher, FocusController
│ └── Triggers/ MonitorTriggerWatcher, TimeTriggerScheduler,
│ CalendarTriggerWatcher, TriggerSupervisor (V0.4)
├── Resources/ Localizable.xcstrings (133 keys × 4 locales),
│ InfoPlist.xcstrings (V0.4)
├── SceneAppApp.swift @main + MenuBarExtra
├── AppDelegate.swift
├── Coordinator.swift orchestration layer
├── MenuBarContentView.swift
├── OnboardingView.swift + OnboardingWindowController.swift
└── NotificationHelper.swift
Rule of thumb: anything you want a unit test for goes in SceneCore. Anything that touches NSApp, MenuBarExtra, NSWindow, CVDisplayLink, NSWorkspace, EventKit, or SwiftUI binding goes in SceneApp.
Why the split:
-
SceneCoreis pure enough thatswift testruns all 157 unit tests without a code-signed bundle — contributors without Apple Developer accounts can verify logic, including the V0.2 animation state machine, V0.3 drag-swap geometry, and V0.4 Workspace trigger encoding / store CRUD. - Xcode project stays small.
project.pbxprojchurn is minimal becausePBXFileSystemSynchronizedRootGroupauto-includes new Swift files underSceneApp/SceneApp/. - V0.5 work (per-display layouts, pattern learning, AI / NL input) slots into existing boundaries without restructuring.
Sources/SceneCore/ has no import Combine, no import SwiftUI, no ObservableObject. Stores expose a closure-based observation API:
// In SceneCore
public func onChange(_ handler: @escaping () -> Void) -> CancellableCancellable is SceneCore's own lightweight token (closure-based, deinit calls cancel). SwiftUI binding lives in SceneApp/SceneApp/Stores/*ViewModel.swift adapters — LayoutStoreViewModel, SettingsStoreViewModel, and the V0.4 WorkspaceStoreViewModel all follow the same pattern:
@MainActor
final class LayoutStoreViewModel: ObservableObject {
let store: LayoutStore
@Published private(set) var layouts: [CustomLayout] = []
private var token: Cancellable?
init(store: LayoutStore) {
self.store = store
self.layouts = store.layouts
self.token = store.onChange { [weak self] in
Task { @MainActor in self?.layouts = self?.store.layouts ?? [] }
}
}
}This isolation makes the SceneCore bundle reusable from non-SwiftUI hosts (CLI tool, future XPC service, tests) without dragging in Combine.
The hard part — state machine, interruption, cancellation, frame math — lives in pure SceneCore and is deterministically testable. The thin AppKit bridge owns the display link.
AnimationRunner (SceneCore, pure)
-
start(tracks:, config:),tick(now:),interrupt(newTargets:, config:, now:),cancel() - Holds
lastEmitted: [CGWindowID: CGRect]so interrupt mid-flight uses the currently-interpolated frame as the new start (no jump) - Writes via injected
WindowFrameSinkprotocol; reads time via injectedClock - Unit-tested via
MockClock+RecordingSink
WindowAnimator (SceneApp, thin bridge)
- Implements
WindowFrameSinkviaAXUIElementSetAttributeValuefor position + size - Owns
CVDisplayLink(60fps); each tick is dispatched to main and callsrunner.tick(now: CACurrentMediaTime()) - Manages display-link lifecycle (start, invalidate on cancel/finish, single-link guarantee on rapid retrigger)
Coordinator.applyLayout(custom)
↓
windows.count > 6 OR animation.disabled?
├─ yes → LayoutEngine.apply(plan, on:) — instant snap (no jank for many windows)
└─ no → WindowAnimator.animate(windows, placements, config)
↓
AnimationRunner.start(tracks, config)
CVDisplayLink @ 60fps
↓
each tick:
runner.tick(now: CACurrentMediaTime())
↓ for each track: FrameInterpolator → sink.write(AX position + size)
t >= 1: snap exact target → invalidate display link
A Workspace bundles a layout, lists of apps to launch / quit, an optional Focus mode reference, an optional hotkey, and 0+ triggers.
public struct Workspace: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public var name: String
public var layoutID: UUID // references CustomLayout.id
public var appsToLaunch: [String] // bundle IDs
public var appsToQuit: [String]
public var focusMode: FocusModeReference? // Shortcuts name pair (on / off)
public var hotkey: HotkeyBinding?
public var triggers: [WorkspaceTrigger]
public var isPresetSeed: Bool
public var isModified: Bool
}
public enum WorkspaceTrigger: Codable, Equatable, Sendable {
case manual
case monitorConnect(displayName: String)
case monitorDisconnect(displayName: String)
case timeOfDay(weekdayMask: WeekdayMask, hour: Int, minute: Int)
case calendarEvent(keywordContains: String)
}-
Bundle IDs, not paths. Stable across app moves;
NSWorkspace.urlForApplication(withBundleIdentifier:)resolves on demand. -
Focus via Shortcuts URL scheme (
shortcuts://run-shortcut?name=…). The macOS Focus API is private; the Shortcuts URL is the documented automation path. User creates "Setpiece Coding Focus On/Off" shortcuts in Shortcuts.app; Workspace references them by name. -
WorkspaceSeeds.allships 4 seeds: Coding / Meeting / Reading / Streaming. Hotkeys ⌘⌥1–4.appsToLaunch/appsToQuitstart empty (user-specific). Focus references left unset. -
WorkspaceStorepersists to~/Library/Application Support/Scene/workspaces.json, mirroringLayoutStore: atomic write, closure observation,knownSeedUUIDsmigration, block-save hotkey conflict.
Workspaces and Layouts share a single chord namespace (pressing ⌘⌥1 can't mean two different things at once). Enforcement uses a two-phase setHotkeyConflictProbe setter pattern on both stores:
- Before committing an update, the editing store asks: "does my peer store already own this chord?"
- The peer returns an owner descriptor (name + kind = workspace or layout) or
nil. - If owned, the editing store throws
.hotkeyConflict(existingResource:)and the UI surfaces the conflicting resource's name.
This means the user can never end up with an ambiguous binding, and the error message always names the specific resource (layout or workspace) that holds the chord.
WorkspaceActivator.activate(workspace:) orchestrates the full swap:
1. If there is a previously-active Workspace: FocusController.run(previous.focusMode.shortcutNameOff)
2. AppLauncher.quit(bundleIDs: workspace.appsToQuit) — gentle terminate
5s grace period; survivors surface a "could not quit X — unsaved work?" notification
NO force-terminate — protects unsaved work by design
3. AppLauncher.launch(bundleIDs: workspace.appsToLaunch) — parallel
4. Settle 1.5s (let new windows appear before layout math runs)
5. Coordinator.applyLayout(id: workspace.layoutID) — reuses V0.2 pipeline
6. FocusController.run(workspace.focusMode.shortcutNameOn) if non-nil
7. WorkspaceStore.setActive(workspace.id) + notification banner
Deactivating Workspace A (by activating B) does not re-run A's appsToQuit — quitting happens on activation only. Users who want guaranteed closure put the app in B's appsToQuit.
TriggerSupervisor (owned by Coordinator, lives in SceneApp/Workspace/Triggers/)
├→ MonitorTriggerWatcher
│ NSScreen.didChangeNotification → diff old vs new screen list
│ fires .monitorConnect(displayName) / .monitorDisconnect(displayName)
│
├→ TimeTriggerScheduler
│ Timer every 30s → DateComponents matching (weekdayMask + hour + minute)
│ last-fire-minute debounce
│
└→ CalendarTriggerWatcher
EKEventStore poll every 60s for events starting in next 5 min
case-insensitive substring match on event title
permission requested lazily (only when first .calendarEvent trigger is added)
TriggerSupervisor.evaluate(event):
├→ Lookup matching Workspaces — first match wins (ordered Workspaces list)
├→ Skip if matched Workspace.id == activeWorkspaceID (no thrash)
├→ Apply 30s cooldown per Workspace for auto-triggers; manual bypasses
└→ Coordinator.applyWorkspace(id: matched.id)
First-match-wins ordering means the user controls priority by reordering Workspaces in the Workspaces tab. 30s cooldown prevents monitor bounce / clock jitter from re-firing a Workspace; manual hotkey / menu click bypasses the cooldown.
-
Tooling: Xcode 15+ String Catalog. Single
Localizable.xcstrings(133 keys) +InfoPlist.xcstrings. - Locales: English (en), 繁體中文(港) (zh-HK) full parity at ship. 繁體中文(台) (zh-TW) + 日本語 (ja) partial — missing keys fall back to en.
-
Key conventions:
-
settings.tab.<name>— tab labels -
settings.action.<verb>— buttons -
settings.field.<name>— field labels -
menu.<section>.<item>— menu bar items -
onboarding.<section>— onboarding copy -
error.<context>— error alerts -
workspace.<concept>— Workspace UI
-
- No in-app language picker. SwiftUI picks the user's system locale on launch. An in-app switcher is a V0.5+ candidate.
- Cantonese house style: 粵語 not 書面語. Matches README.zh-HK.md.
Coordinator.applyLayout(_ custom: CustomLayout) is the orchestration entry point. Every path (menu click, global hotkey, drag-swap, Workspace activation) ends up here.
applyLayout(custom)
↓
AXPermission.forceRecheck() ← uses AXIsProcessTrustedWithOptions(prompt: false)
├─ denied → OnboardingWindowController.show(); STOP
└─ granted ↓
ScreenResolver.activeScreen() ← screen under the mouse
↓
AXWindowEnumerator.listVisibleWindows(on:)
↓ filters: current Space, layer==0, not minimized, not fullscreen, center on this screen
↓
LayoutEngine.plan(windows, visibleFrame, custom.toLayout()) ← PURE function
↓
shouldAnimate = settings.animation.enabled && windows.count <= 6
├─ yes → WindowAnimator.animate(...) + minimize overflow synchronously
└─ no → LayoutEngine.apply(plan, on: windows) all-instant
For a Workspace activation, the flow is the same from step "applyLayout" onward — WorkspaceActivator wraps it with the quit / launch / settle / Focus steps above.
-
LayoutTemplate(enum, 11 cases):single, twoCol/threeCol, twoRow/threeRow, grid2x2/grid3x2, lShape{Left,Right,Top,Bottom}. Each definesexpectedProportionsCount,slotCount,defaultProportions, and a pureslots(proportions:) -> [Slot]. -
CustomLayout(Codable):id: UUID, name, template, slotProportions, hotkey?, isPresetSeed, isModified.toLayout()builds aLayoutvalue the engine can plan against. -
PresetSeeds.all(V0.4: 10 seeds): V0.1's 7 layouts with stable UUIDs (11111111-000N-0000-0000-000000000N) plus V0.4's 3 vertical seeds (-0008/-0009/-000a). Default hotkeys are now ⌘⌃1–9,0 (moved off ⇧ to dodge macOS ⌘⇧3/4/5 screenshots). Slot equivalence with V0.1 is pinned bytestSeedSlotsMatchV01Layouts— the safety net that lets routing swap fromLayoutIDtoUUIDwithout behavioral change. -
LayoutStore: atomic JSON write to~/Library/Application Support/Scene/layouts.json.applyFutureSeeds(...)adds new seeds only when their UUID is not inknownSeedUUIDs— the V0.4 vertical seeds are this mechanism's first production use, and existing V0.2 installs gain them automatically without losing any deletions of the original 7.
-
AnimationConfig(V0.2):enabled, durationMs (clamped 100-500), easing. Default = enabled / 250ms / easeOut. -
DragSwapConfig(V0.3):enabled, distanceThreshold (10-100pt). Default = enabled / 20pt. -
HotkeyBinding:keyCode: UInt32, modifiers: Set<HotkeyModifier>. IncludescarbonModifiers: UInt32bridge forRegisterEventHotKey,displayString(⌘⌃A glyphs),conflicts(with:)for store-side enforcement. -
SettingsStore: persists to~/Library/Application Support/Scene/settings.json.
The app is menu-bar-only by default (NSApp.activationPolicy = .accessory). Opening Settings toggles to .regular (Dock icon appears); windowWillClose toggles back. SettingsWindowController is lazy on AppDelegate and reuses the same window across opens (window.isReleasedWhenClosed = false).
V0.4 tab order: Workspaces (new, first) → Layouts → Hotkeys → Interaction → About.
LayoutEngine never sees .noPermission. Permission failures bubble up from AXWindowEnumerator.listVisibleWindows as EnumerationError.permissionDenied, caught by Coordinator, which reopens the onboarding window.
V0.2 fix carried forward: AXIsProcessTrusted() can return a stale false for the lifetime of the process when permission is granted while the app is running. AXPermission.forceRecheck() (uses AXIsProcessTrustedWithOptions(prompt: false)) fixes this for the poll loop and the onboarding "Check again" button.
V0.4 TCC cdhash quirk (new for developers): ad-hoc signed apps have their AX grant tied to the binary's cdhash. Every rebuild produces a new cdhash, so the toggle in System Settings → Privacy & Security → Accessibility looks on but the AX API reports denied. Fix: toggle OFF then ON again in System Settings, or run tccutil reset Accessibility com.hillman.SceneApp. See Troubleshooting for details.
Lazy. Setpiece does not request Calendar access at launch. CalendarTriggerWatcher calls EKEventStore.requestAccess(to: .event) only when the user adds the first .calendarEvent trigger in the Workspace editor. The editor surfaces permission state (granted / denied / not determined) so users can re-grant without leaving Setpiece.
Every layout-touching function uses visibleFrame — the rect that excludes menu bar and Dock. Filter, plan, apply, animate, drag-swap all use the same rect so windows can never be planned outside the usable area. Never use screen.frame in layout math.
-
SceneCoreenum vsSceneCoremodule name:Sources/SceneCore/SceneCore.swiftdeclarespublic enum SceneCore, which shadows the module name in member lookup.SceneCore.Cancellableresolves to the enum, not the module. Workaround:import class SceneCore.Cancellable. -
WindowRefambiguity: AppKit transitively importsApplicationServices, which has a QuickDrawWindowReftypedef. In SceneApp files, useimport protocol SceneCore.WindowRefplustypealias SceneWindowRef = WindowRef.
RegisterEventHotKey is deprecated but remains the only API that works for menu-bar apps that need global hotkeys. NSEvent.addGlobalMonitorForEvents can't consume events, so the keystroke would still reach the frontmost app. All known window managers (Rectangle, Magnet, EpycZones, etc.) use Carbon for the same reason.
- Most of SceneCore is sync and non-actor-isolated (so it stays usable from any host).
-
AnimationRunneris class-based and not@MainActor— the SceneApp bridge ensurestick()is called on main. -
DragSwapControlleris@MainActor(instantiatesNSWindowfor preview overlay). -
Coordinator,WindowAnimator,WorkspaceActivator,AppLauncher,FocusController, and all three trigger watchers are@MainActor. - Files declaring
@Published/ObservableObjectwithout importing SwiftUI mustimport Combineexplicitly.
English
繁體中文(香港)