Skip to content

jwintz/hyalo

Repository files navigation

Hyalo

IDE shell around Emacs for macOS, built as a dynamic module (.dylib) loaded by Emacs at startup.

Documentation

Architecture

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 FileTreeNode for navigator

Test Procedure

emacs --init-directory /path/to/hyalo

This launches Emacs with the modular init system:

  1. init-bootstrap — Package archives, GC tuning, exec-path-from-shell
  2. init-core — diminish, general.el, which-key
  3. init-emacs — Cursor, startup, recentf, saveplace, autorevert
  4. init-tty — Terminal clipboard, popup routing, and modeline fallbacks
  5. init-appearance — Fonts, modus-themes, nano-themes, ef-themes, lin, iota-dimmer
  6. init-editing — Editing packages (god-mode, windmove, outline)
  7. init-completion — Vertico, Consult, Marginalia, Orderless
  8. init-tools — Dev tools (project, magit, eglot, flymake)
  9. init-help — Help system (helpful, elisp-refs)
  10. init-modes — Language modes (json, swift, toml, typescript, yaml, git-modes)
  11. init-markdown — Markdown and knowledge management
  12. init-header — File header management (header2)
  13. init-hyalo — Module load, window setup, panel toggles, keybindings
  14. init-agents — AI agents (copilot)
  15. init-tengwar — Tengwar script rendering (optional)

User Configuration

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)
EOF

Errors in user config files are demoted to messages so a broken config never prevents Emacs from starting.

Prerequisites

  • 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)

Build

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 installer

Debug vs Release builds

Debug (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.dylib

To 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.

Logging

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-init

Theme System

Themes 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-themes collection available via M-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

Init Files (init/)

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)

Lisp Files (lisp/)

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)

Minibuffer Overlay

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 to emacs-mini-frame.
  • Candidate rendering: SwiftUI List with 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.

Channel Architecture

Hyalo uses two communication mechanisms between Emacs Lisp and Swift:

1. Synchronous Functions (Emacs → 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.

2. Async Channels (Swift → Emacs)

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.

Navigator Selection Flow (example)

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.

Race Condition Fix

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:

  1. User clicks A → activeFilePath = nil (not yet set) → channel sends find-file A
  2. User clicks B (before A's callback) → activeFilePath = nil → guard passes! → channel sends find-file B
  3. Callback for A arrives → setActiveFile(A) → sets activeFilePath = A, selection = Arevert!

Fix (applied to FileTreeViewModel, BufferListViewModel, EditorTabViewModel):

  1. Set active/pending state immediately when the user clicks (selectFile, selectBuffer, selectFile for tabs) before calling the channel callback
  2. Check for stale callbacks in the Emacs callback methods (setActiveFile, setActiveBuffer, onTabSelected) — skip if the callback doesn't match what's currently active/pending
  3. Use immediate state in guards — the guard in onFileSelect/onBufferSelect now uses the immediately-set value to block rapid duplicates

Centralized State Push

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.

Tracing

All navigator selection touchpoints emit [Hyalo:Nav:trace] logs (NSLog on Swift, hyalo-trace on Elisp). To observe the flow:

  1. Swift logs: visible in Console.app (filter: Hyalo:Nav:trace)
  2. Elisp logs: visible in *elog* buffer (requires elog package, auto-initialized)
  3. Enable elog trace level: (setq hyalo-elog (elog-logger :name "hyalo" :level 'trace :buffer "*elog*" :handlers '(buffer)))

Status Update Architecture

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.

Keybindings

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

Development

Rebuild and reload without restarting Emacs:

M-x hyalo-rebuild-and-reload

Instruments via CLI

# 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

About

IDE shell around Emacs for macOS, built as a dynamic module (.dylib) loaded by Emacs at startup.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors