IDE shell around Emacs for macOS, built as a dynamic module (.dylib) loaded by Emacs at startup.
Sources/
HyaloShared/ SwiftUI views, view models, shared models (platform-agnostic)
HyaloMac/ macOS: AppKit, EmacsSwiftModule, NSToolbar, NSSplitView
Products:
Hyalo .dynamic macOS library loaded by Emacs (swift build)
- HyaloShared: models, view models, managers, pure SwiftUI views. Separated from HyaloMac so it carries no EmacsSwiftModule dependency and can be tested independently.
- HyaloMac: AppKit, EmacsSwiftModule
env.defun()/env.openChannel(), NSToolbar, NSSplitViewController, SwiftTerm terminal - Emacs Lisp files in
lisp/(31 files, see table below) - 15 modular init files in
init/ - Channel architecture for bidirectional Swift/Emacs Lisp communication (see Channel Architecture below)
- Native file tree using FileManager-based
FileTreeNodefor navigator
emacs --init-directory /path/to/hyaloThis launches Emacs with the modular init system:
init-bootstrap— Package archives, GC tuning, exec-path-from-shellinit-core— diminish, general.el, which-keyinit-emacs— Cursor, startup, recentf, saveplace, autorevertinit-tty— Terminal clipboard, popup routing, and modeline fallbacksinit-appearance— Fonts, modus-themes, nano-themes, ef-themes, lin, iota-dimmerinit-editing— Editing packages (god-mode, windmove, outline)init-completion— Vertico, Consult, Marginalia, Orderlessinit-tools— Dev tools (project, magit, eglot, flymake)init-help— Help system (helpful, elisp-refs)init-modes— Language modes (json, swift, toml, typescript, yaml, git-modes)init-markdown— Markdown and knowledge managementinit-header— File header management (header2)init-hyalo— Module load, window setup, panel toggles, keybindingsinit-agents— AI agents (copilot)init-tengwar— Tengwar script rendering (optional)
Hyalo supports a personal config directory, following the Doom Emacs pattern. The directory is resolved in this order:
| Priority | Location | Condition |
|---|---|---|
| 1 | $HYALODIR |
Environment variable is set |
| 2 | ~/.config/hyalo/ |
Directory exists (XDG-compliant) |
| 3 | ~/.hyalo.d/ |
Default fallback |
The directory is not auto-created. Place one or both of these files inside it:
| File | Loaded | Purpose |
|---|---|---|
init.el |
Before modules | Set variables that influence module loading |
config.el |
After all modules | Override settings, enable features, add keybindings |
Example — enable the welcome panel on startup:
mkdir -p ~/.config/hyalo
cat > ~/.config/hyalo/config.el << 'EOF'
;; Show welcome panel on startup
(setq hyalo-welcome-show-on-startup t)
EOFErrors in user config files are demoted to messages so a broken config never prevents Emacs from starting.
- macOS 26+ with Xcode 17+
- Swift 6.2 or later
- Emacs 30.1 or later compiled with
--with-modules - Feedstock at
~/Syntropment/hyalo-feedstock(must be built first)
swift build # debug build (default)
swift build --target Hyalo # builds Hyalo.dylib only
pixi run build-release # release build
pixi run package # assemble Hyalo.app bundle
pixi run dmg # create DMG installerDebug (swift build) |
Release (swift build -c release) |
|
|---|---|---|
| Output | .build/debug/libHyalo.dylib |
.build/release/libHyalo.dylib |
#if DEBUG code |
Compiled in | Stripped out |
| Optimization | -Onone (fast compile) |
-O (optimized) |
| Used by | pixi run run |
pixi run run-release, pixi run package, Finder (Hyalo.app) |
Debug builds include #if DEBUG-gated NSLog calls (e.g. [Hyalo:Minibuffer] trace messages). These are useful during development but produce noisy console output.
Release builds strip all #if DEBUG code at compile time — no trace messages, smaller binary, better performance. The package task depends on build-release, so Hyalo.app bundles are always release builds.
For day-to-day development: use the default swift build (debug). The pixi run run task loads from .build/debug/.
To run with the release build:
pixi run run-release # build release + launch Emacs with .build/release/libHyalo.dylibTo silence Swift trace logs without a release build: the #if DEBUG logs have already been removed from MinibufferManager.swift. If new trace logs are added during development, gate them behind #if DEBUG — they'll be stripped from Hyalo.app automatically.
Hyalo has three log layers:
| Layer | Output | Control |
|---|---|---|
Elisp boot ([hyalo:boot]) |
stderr + /tmp/hyalo-boot.log |
--debug-init flag or HYALO_DEBUG=1 env var |
Elisp runtime ([hyalo]) |
*elog* buffer (after init) |
hyalo-elog level (default: info) |
Swift (NSLog) |
stderr / Console.app | #if DEBUG (compile-time) |
Boot logs are silent by default. To enable verbose boot logging:
HYALO_DEBUG=1 emacs --init-directory /path/to/hyalo
# or
emacs --init-directory /path/to/hyalo --debug-initThemes use modus-themes as the foundation, with nano-themes (built on modus) providing the default light/dark pair:
- Default light:
nano-light(Material Design palette) - Default dark:
nano-dark(Nord palette) - Additional:
ef-themescollection available viaM-x load-theme
Theme-appearance synchronization:
- Loading a dark theme automatically switches the Swift window to dark appearance
- Loading a light theme automatically switches the Swift window to light appearance
- System appearance changes (
ns-system-appearance-change-functions) load the configured default theme - Selecting Light/Dark in the inspector appearance panel loads the corresponding theme via channel callback
- Appearance settings (mode, opacity, material) persist across sessions via UserDefaults
- Window divider color is derived from the active theme palette
- Terminal palette is derived from theme face colors
| File | Purpose |
|---|---|
init-bootstrap.el |
Package system, GC optimization, exec-path-from-shell |
init-core.el |
diminish, general.el keybinder, which-key (side window in GUI, minibuffer in tty) |
init-emacs.el |
Cursor, startup, recentf, saveplace, autorevert |
init-tty.el |
Terminal clipboard, popup routing, tty modeline, and GUI fallback surfaces |
init-appearance.el |
Fonts, modus-themes, nano-themes, ef-themes, lin, iota-dimmer |
init-editing.el |
Editing packages (god-mode, windmove, outline) |
init-completion.el |
Vertico, Consult, Marginalia, Orderless |
init-tools.el |
Dev tools (project, magit, eglot, flymake, diff-hl, tty LSP bindings) |
init-help.el |
Help system (helpful, elisp-refs) |
init-modes.el |
Language modes (json, swift, toml, typescript, yaml, git-modes) |
init-markdown.el |
Markdown and knowledge management |
init-header.el |
File header management (header2) |
init-hyalo.el |
macOS integration, module load, window setup, keybindings |
init-agents.el |
AI agents (copilot) |
init-tengwar.el |
Tengwar script rendering (optional) |
| File | Lines | Purpose |
|---|---|---|
hyalo.el |
344 | Core loader, build, module-load |
hyalo-window.el |
431 | Window controller, setup orchestration, Cmd+O/P, panel toggles |
hyalo-channels.el |
369 | Channel lifecycle, callback handlers, rg search execution |
hyalo-navigator.el |
76 | Buffer list, file tree push to Swift |
hyalo-status.el |
861 | Hook-driven status updates (cursor, tabs, branch, file info, navigator refresh) |
hyalo-appearance.el |
281 | Vibrancy, background color, divider color, frame transparency |
hyalo-themes.el |
362 | Theme switching, appearance sync, terminal palette |
hyalo-compile.el |
139 | Native compilation activity tracking |
hyalo-diagnostics.el |
116 | Flymake diagnostics panel integration |
hyalo-environment.el |
406 | Environment detection and breadcrumb push |
hyalo-gutter.el |
308 | Diff-hl gutter integration |
hyalo-keycast.el |
74 | Keycast toolbar integration |
hyalo-lib.el |
159 | Transient hooks, first-use hooks, idle package loader |
hyalo-menu.el |
40 | Menu bar integration |
hyalo-minibuffer.el |
612 | Minibuffer bridge: candidate extraction, invisible overlay, timer-based JSON push |
hyalo-minimap.el |
199 | Minimap integration |
hyalo-package.el |
233 | Package manager toolbar integration |
hyalo-source-control.el |
138 | Source control integration |
hyalo-splash.el |
363 | Splash screen |
hyalo-system.el |
121 | System information panel |
hyalo-user.el |
67 | User config directory discovery and loading |
hyalo-doctor.el |
233 | Environment doctor: checks Swift, macOS, Xcode, fonts, tools |
iota-dimmer.el |
558 | Inactive window dimming (HSL-based face color manipulation) |
iota-faces.el |
294 | Iota face definitions |
iota-theme-transparent.el |
568 | Transparent theme variant |
nano-themes.el |
204 | N Λ N O theme infrastructure (built on modus-themes) |
nano-light-theme.el |
290 | N Λ N O light theme (Material Design palette) |
nano-dark-theme.el |
288 | N Λ N O dark theme (Nord palette) |
header2.el |
1287 | File header creation and update (vendored) |
The minibuffer bridge provides a native macOS overlay for Emacs completion:
- Thin overlay architecture: The Swift panel is display-only (no TextField). All text editing happens in the Emacs minibuffer. Keystrokes go directly to Emacs via a non-activating
NSPanel. - Emacs owns filtering/ordering: Vertico, Orderless, Marginalia run natively. Swift only renders their output.
- Invisible overlay: Emacs minibuffer text is hidden via
(overlay-put ... 'invisible t)while the Swift panel is active, similar toemacs-mini-frame. - Candidate rendering: SwiftUI
Listwith match highlighting (accent color on matched ranges), monospaced annotation columns, Liquid Glass background. - Session IDs: Monotonically increasing counter rejects stale updates from previous minibuffer sessions.
See AUDIT.md for detailed architecture and bug analysis.
Hyalo uses two communication mechanisms between Emacs Lisp and Swift:
Defined via env.defun() in Module.swift. Called directly from Elisp. Execute on the Emacs thread. Most dispatch to DispatchQueue.main.async for @MainActor-isolated SwiftUI state updates, meaning the actual state change is deferred — the Elisp function returns before Swift processes the update.
| Function | Purpose | Dispatch |
|---|---|---|
hyalo-navigator-set-active-file |
Set navigator tree selection from Emacs buffer change | main.async |
hyalo-navigator-set-active-buffer |
Set active buffer in buffer list | main.async |
hyalo-navigator-set-project-root |
Set project root, rebuild file tree | main.async |
hyalo-navigator-refresh-file-tree |
Force file tree rebuild | main.async |
hyalo-navigator-update-buffers |
Push buffer list JSON | main.async |
hyalo-status-update |
Push cursor, mode, encoding, etc. | main.async |
hyalo-update-editor-tabs |
Push editor tab list JSON | assumeIsolated |
hyalo-select-editor-tab |
Select tab by buffer name | assumeIsolated |
hyalo-update-branch-info |
Push git branch JSON | main.async |
hyalo-set-project-name |
Set toolbar project name | main.async |
hyalo-set-workspace-appearance |
Set light/dark appearance | main.async |
hyalo-set-current-theme-name |
Set theme name in inspector | main.async |
hyalo-set-color-theme |
Push color theme JSON | main.async |
hyalo-set-terminal-palette |
Push terminal palette JSON | main.async |
hyalo-set-vibrancy-material |
Set vibrancy material level | main.async |
hyalo-set-background-color |
Set tint color hex + alpha | main.async |
hyalo-update-file-info |
Push file info JSON to inspector | main.async |
hyalo-update-search-results |
Push search results JSON | main.async |
hyalo-update-search-status |
Push search status counts | main.async |
hyalo-update-diagnostics |
Push flymake diagnostics JSON | main.async |
hyalo-update-build-status |
Push native compilation status | main.async |
hyalo-update-build-progress |
Push compilation progress | main.async |
hyalo-update-package-status |
Push package list JSON | main.async |
hyalo-update-open-quickly-items |
Push file list for Cmd+O | main.async |
hyalo-update-command-list |
Push command list for Cmd+P | main.async |
Key insight: Functions using DispatchQueue.main.async return to Elisp immediately. The Swift-side state update happens on the next main run loop iteration. Multiple synchronous calls from the same Elisp function (e.g., set-active-buffer followed by set-active-file) are queued in order but execute later.
Created via env.openChannel(). Channel callbacks are Swift closures that, when called, schedule Elisp execution on the Emacs event loop via wakeEmacs(). The Elisp runs when Emacs processes its next event.
| Channel | Callbacks (Swift → Emacs) | Purpose |
|---|---|---|
hyalo-navigator |
switch-to-buffer, kill-buffer, find-file |
User clicks in navigator sidebar |
hyalo-editor-tabs |
switch-to-buffer, kill-buffer, previous-buffer, next-buffer |
User clicks tab bar |
hyalo-status |
hyalo-status--set-encoding, set-line-ending, set-indent |
User clicks status bar items |
hyalo-toolbar |
hyalo-channels--handle-branch-switch |
User switches branch in picker |
hyalo-command-palette |
hyalo-channels--handle-open-file, handle-execute-command |
User selects in Cmd+O / Cmd+P |
hyalo-search |
hyalo-channels--handle-search, handle-search-navigate |
User searches or clicks result |
hyalo-appearance |
hyalo-channels--handle-appearance-mode, handle-opacity-change |
User changes appearance settings |
hyalo-diagnostics |
hyalo-channels--handle-diagnostic-navigate |
User clicks diagnostic |
hyalo-package |
handle-package-refresh, upgrade-all, upgrade-single, list |
Package manager actions |
hyalo-minibuffer |
select-candidate, abort |
User selects candidate or aborts |
Key insight: Channel callbacks invoke wakeEmacs() after queuing the callback. Emacs processes the callback when it returns to its event loop. If Emacs is busy (e.g., processing a hook), the callback is deferred. This means there is an indeterminate delay between the Swift-side action and the Elisp execution.
This illustrates the async ordering problem. When the user clicks file B in the sidebar (currently showing file A):
Swift (main thread) Emacs (event loop)
────────────────── ──────────────────
1. List sets viewState.selection = B.id
2. onChange fires → onFileSelect(B_path)
3. Guard: B_path != activeFilePath(A) → pass
4. NavigatorManager.onFileSelect(B_path)
5. channel.callback(find-file B)
6. wakeEmacs()
↓ (async) 7. find-file B executes
8. window-buffer-change-functions fires
9. hyalo-navigator-set-active-file(B)
↓ (sync call into Swift defun)
10. DispatchQueue.main.async {
setActiveFile(B) 10'. [returns to Emacs immediately]
} 11. buffer-list-update-hook fires
↓ (async) 12. hyalo-navigator--update-buffers
13. setActiveFile(B) executes
14. activeFilePath = B
15. viewState.selection = B.id (already B)
RISK: Between steps 6 and 7, if Emacs processes other events
(e.g., timer-fired buffer-list-update-hook), it may call
hyalo-navigator-set-active-file(A) for the OLD buffer, which
queues setActiveFile(A) on main.async. If that executes AFTER
step 13, the selection reverts to A.
The actual race was more subtle — activeFilePath/activeBuffer/pendingTabId were only updated in the async callback from Emacs, not immediately when the user clicked. This meant:
- User clicks A →
activeFilePath = nil(not yet set) → channel sendsfind-file A - User clicks B (before A's callback) →
activeFilePath = nil→ guard passes! → channel sendsfind-file B - Callback for A arrives →
setActiveFile(A)→ setsactiveFilePath = A,selection = A→ revert!
Fix (applied to FileTreeViewModel, BufferListViewModel, EditorTabViewModel):
- Set active/pending state immediately when the user clicks (
selectFile,selectBuffer,selectFilefor tabs) before calling the channel callback - Check for stale callbacks in the Emacs callback methods (
setActiveFile,setActiveBuffer,onTabSelected) — skip if the callback doesn't match what's currently active/pending - Use immediate state in guards — the guard in
onFileSelect/onBufferSelectnow uses the immediately-set value to block rapid duplicates
All buffer switch operations (file navigator, buffer list, tab bar, command palette) now use a centralized hyalo-push-active-buffer-state function in hyalo-status.el. This function updates all Swift UI components (buffer list selection, file navigator selection, editor tab bar) in a single call, ensuring consistency.
All navigator selection touchpoints emit [Hyalo:Nav:trace] logs (NSLog on Swift, hyalo-trace on Elisp). To observe the flow:
- Swift logs: visible in Console.app (filter:
Hyalo:Nav:trace) - Elisp logs: visible in
*elog*buffer (requireselogpackage, auto-initialized) - Enable elog trace level:
(setq hyalo-elog (elog-logger :name "hyalo" :level 'trace :buffer "*elog*" :handlers '(buffer)))
Status updates use Emacs hooks with debounced timers (no polling):
| Data | Trigger | Debounce |
|---|---|---|
| Cursor position, mode, encoding | post-command-hook |
50ms |
| Editor tabs | buffer-list-update-hook, after-save-hook, first-change-hook |
200ms |
| File info + git history | window-buffer-change-functions |
immediate |
| Branch info + project name | window-buffer-change-functions |
immediate |
| Navigator file tree | window-buffer-change-functions (on project root change) |
500ms |
On buffer switch, default-directory is updated to the enclosing git root. If the project root changes, the navigator file tree refreshes automatically.
| Key | Command | Description |
|---|---|---|
Cmd+O |
hyalo/open-quickly |
Fuzzy file search panel |
Cmd+P |
hyalo/command-palette |
M-x style command palette |
C-c t n |
hyalo-toggle-navigator |
Toggle left sidebar |
C-c t i |
hyalo-toggle-inspector |
Toggle right sidebar |
C-c t u |
hyalo-toggle-utility-area |
Toggle bottom panel |
Rebuild and reload without restarting Emacs:
M-x hyalo-rebuild-and-reload# Time Profiler (CPU hotspots in Swift views, minibuffer, completions)
xctrace record --template "Time Profiler" \
--attach <PID> --output hyalo-trace.trace --time-limit 30s# Allocations (object creation in MinibufferViewModel.update, JSON decode)
xctrace record --template "Allocations" \
--attach <PID> --output hyalo-alloc.trace --time-limit 30s# SwiftUI (view body evaluations, redundant updates)
xctrace record --template "SwiftUI" \
--attach <PID> --output hyalo-swiftui.trace --time-limit 30s