macOS Swift Host Reference
Source basis: macos/ in Termy main at commit 4be935f5647c05f2ab53dd2ad723ab7bdec6d2d0. Re-check current sources before editing.
Relevant source files:
macos/Package.swiftmacos/Sources/CTermy/CTermy.hmacos/Sources/CTermy/module.modulemapmacos/Sources/TermySwift/Support/TermyFfiBridge.swiftmacos/Sources/TermySwift/Services/LibTermyTerminal.swiftmacos/Sources/TermySwift/Services/TerminalViewModel.swiftmacos/Sources/TermySwift/Models/TerminalFrameStore.swiftmacos/Sources/TermySwift/Views/TerminalRenderPlan.swiftmacos/Sources/TermySwift/Views/TerminalGridView.swiftmacos/Sources/TermySwift/Services/DisplaySyncedRefreshDriver.swiftmacos/Sources/TermySwift/Services/NativeRenderMetrics.swift
Contents
- SwiftPM And Linking
- Swift FFI Wrapper Shape
- Wakeup Monitor
- Frame And Render Loop
- Rendering Architecture
- Cadence, Resize, And Occlusion
- Input And Safety
- Config And Appearance
- Display-Only And tmux
- Validation
SwiftPM And Linking
Build termy_ffi before Swift:
cargo build -p termy_ffi
TERMY_FFI_LIBRARY_PATH="$PWD/target/debug" swift test --package-path macosThe package uses a CTermy system library target whose header includes ../../../crates/ffi/include/termy.h. Link with -ltermy_ffi and an -L path from TERMY_FFI_LIBRARY_PATH, falling back to target/debug in development.
For app bundles, copy libtermy_ffi.dylib into Contents/Frameworks, set its install name to @rpath/libtermy_ffi.dylib, rewrite the app binary's linked path to @rpath/libtermy_ffi.dylib, and sign the dylib with the app. The existing DMG scripts show the exact bundle packaging flow.
Swift FFI Wrapper Shape
Centralize all C calls behind a small Swift wrapper like LibTermyTerminal.
Use a tiny bridge layer for common operations:
requireOK(operation, status)to throw on non-OKTermyFfiStatus.string(from:)to decodeTermyFfiByteswhile respecting byte length and nil pointers.
In the terminal wrapper:
- Load default config unless explicitly disabled.
- Read render config before terminal creation so cell metrics, colors, font, padding, cursor, scroll, and opacity match the config.
- Create with
termy_terminal_new_with_optionsfor normal shells ortermy_display_terminal_newfor display-only terminals. - Store the terminal handle and config handle as private optionals.
- In
deinit, stop the wakeup monitor first, then free terminal, then free config. - If initialization throws after acquiring config but before object initialization completes, free config explicitly because
deinitwill not run.
Do not expose raw pointers outside the wrapper unless the caller owns the full FFI lifecycle.
Wakeup Monitor
Use a dedicated background queue/thread blocked in termy_terminal_wait_for_wakeup. On wake, hop to MainActor and ask the view model to poll. Use a long timeout as a safety net; output and stop should wake immediately.
Stop sequence:
- Mark the monitor as not running.
- Call
termy_terminal_notify_wakeup(handle). - Wait for the monitor to exit.
- Then allow
termy_terminal_free(handle).
Do not free while the wait thread is blocked.
Frame And Render Loop
The production loop should:
- Drain events with
termy_terminal_drain_events. - Handle metadata events: title, reset title, bell, exit, progress, working directory, clipboard store, and shell lifecycle.
- Call
termy_terminal_take_frame_update(handle, forceFull, &update). - Apply the update to a retained
TerminalFrameStore. - Publish only lightweight state plus a render revision.
- Update a retained
TerminalRenderPlanCache. - Invalidate only dirty rects in an AppKit grid view.
Use snapshot() only for simple one-off full reads, diagnostics, or test helpers. A hot render loop should use frameUpdate(forceFull:).
For partial updates, validate cell count against dirty spans. Full updates must have cols * rows cells. Partial updates must have the sum of each span width.
Rendering Architecture
Use AppKit for the terminal grid hot path:
NSViewRepresentablewraps a customNSView.TerminalGridNSView.update(...)compares frame dimensions, display offset, render config, selection, search state, hovered link, focus, and damage.- Full changes set
needsDisplay = true. - Partial changes call
setNeedsDisplayfor each dirty span rect. - Drawing uses retained row render plans: background runs, text segments, block glyphs, and stroke glyphs.
Cache expensive drawing objects across frames:
- fonts keyed by family/size
- colors keyed by packed RGBA
- alpha colors
- shaped text lines keyed by text, color, and weight
Pixel-snap cell rects. Draw block and box glyphs as snapped geometry where appropriate so terminal UI characters tile cleanly.
Keep large render plans out of @Published state. Publish a revision counter, and let the view read the retained plan.
Cadence, Resize, And Occlusion
Use display-synced polling only while active. When idle:
- Drop to blink cadence if the focused pane has cursor blink enabled.
- Drop to inert idle cadence if nothing needs animation.
- Let the FFI wake channel schedule immediate polling for new output.
Coalesce wakeup polls. While the display link is active, ignore plain wakeups because the display link already polls at frame cadence.
Resize handling:
- Send every size step to FFI so PTY winsize stays correct.
- Throttle forced full refreshes to about 60 Hz during continuous resize.
- Always schedule a trailing forced refresh after resize coalescing.
When a pane/window is occluded or suspended:
- Stop visual refresh polling.
- Keep draining PTY events on wake so child processes do not block on full PTY buffers.
- Lower scrollback for inactive tabs if config provides an inactive limit.
- Drop layer contents and shaped-line caches for hidden grids to reduce RSS.
Input And Safety
Use libtermy input encoders:
termy_terminal_encode_keytermy_terminal_encode_mouse
Write returned bytes with termy_terminal_write and free the returned TermyFfiBytes.
For paste:
- Query
termy_terminal_bracketed_paste_mode. - If disabled, write raw UTF-8 bytes.
- If enabled, strip embedded bracketed-paste start/end markers from the payload, then wrap with bracketed-paste delimiters before writing.
For links:
- Prefer
termy_terminal_hyperlink_atfor OSC 8 links. - Fall back to host-side text detection only when no OSC 8 link exists.
- Validate URL schemes before calling
NSWorkspace.open.
Config And Appearance
Use termy_config_render_config_for_appearance so system light/dark appearance resolves the same theme values as the Rust side. On settings or appearance changes, reload render config, reload terminal colors with termy_terminal_reload_default_config_colors, and force a full refresh.
Use FFI config helpers for native settings, safety settings, tmux binary, UI font family, window size, working directory, tasks JSON, keybinds JSON, settings schema, and diagnostics. Free returned byte buffers.
Display-Only And tmux
Use display-only terminals for tmux control-mode pane output:
- Construct with
termy_display_terminal_new. - Feed
%outputbytes throughtermy_terminal_feed_output. - Render with the same frame update path as a PTY-backed terminal.
- Treat
writeas a no-op for display-only terminals unless routing input back through tmux control is implemented separately.
Validation
Run targeted Swift tests for the layer touched:
cargo build -p termy_ffi
TERMY_FFI_LIBRARY_PATH="$PWD/target/debug" swift test --package-path macosUseful focused filters:
DisplayTerminalTestsTerminalFrameStoreTestsTerminalRenderPlanCacheTestsDisplaySyncedRefreshDriverTestsNativeRenderMetricsGateTestsTerminalKeyboardInputViewTestsTerminalIMEInputTestsTerminalLinkAllowlistTests
Use macos/scripts/check-config-matrix.sh, macos/scripts/stress-native.sh, and macos/scripts/check-release-readiness.sh for app-wide changes.