Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 76 additions & 11 deletions Macterm/Views/WindowAppearance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,10 @@ enum WindowAppearance {
// Override the titlebar's private background layer so its color
// matches the terminal background (or stays transparent when the
// window is). Without this the titlebar paints its own material
// and you get a visible seam at y=titlebarHeight.
syncTitlebar(window: window, isTransparent: effectiveTransparent)
// and you get a visible seam at y=titlebarHeight. Native fullscreen
// gets forced opaque above, but its separate titlebar window still
// needs the background hidden or it draws a top-edge bar.
syncTitlebar(window: window, hideBackground: effectiveTransparent || forceOpaque)
}

/// Update the inactive-glass tint when the window gains/loses key status.
Expand Down Expand Up @@ -307,9 +309,13 @@ enum WindowAppearance {
return window.value(forKey: "_cornerRadius") as? CGFloat
}

private static func syncTitlebar(window: NSWindow, isTransparent: Bool) {
guard let container = titlebarContainer(in: window) else { return }
private static func syncTitlebar(window: NSWindow, hideBackground: Bool) {
for container in titlebarContainers(for: window) {
syncTitlebarContainer(container, hideBackground: hideBackground)
}
}

private static func syncTitlebarContainer(_ container: NSView, hideBackground: Bool) {
if let titlebarView = container.firstDescendant(withClassName: "NSTitlebarView") {
titlebarView.wantsLayer = true
// On Tahoe, the NavigationSplitView's sidebar is a liquid-glass
Expand All @@ -322,16 +328,75 @@ enum WindowAppearance {
}

// NSTitlebarBackgroundView has subviews that force their own background
// colors; hide it only when transparent, so the default opaque-mode
// chrome stays intact.
container.firstDescendant(withClassName: "NSTitlebarBackgroundView")?.isHidden = isTransparent
// colors; hide it when transparent or when native fullscreen's
// companion titlebar window would otherwise paint a top-edge band.
container.firstDescendant(withClassName: "NSTitlebarBackgroundView")?.isHidden = hideBackground
}

private static func titlebarContainers(for window: NSWindow) -> [NSView] {
var containers: [NSView] = []
appendTitlebarContainer(in: window, to: &containers)

guard window.styleMask.contains(.fullScreen) else { return containers }

for fullscreenWindow in fullscreenTitlebarWindows(for: window) {
appendTitlebarContainer(in: fullscreenWindow, to: &containers)
}
return containers
}

private static func appendTitlebarContainer(in window: NSWindow, to containers: inout [NSView]) {
guard let container = titlebarContainer(in: window) else { return }
guard !containers.contains(where: { $0 === container }) else { return }
containers.append(container)
}

private static func fullscreenTitlebarWindows(for window: NSWindow) -> [NSWindow] {
var windows: [NSWindow] = []

for childWindow in window.childWindows ?? [] {
appendWindow(childWindow, to: &windows)
}
for accessory in window.titlebarAccessoryViewControllers {
guard let accessoryWindow = accessory.view.window else { continue }
appendWindow(accessoryWindow, to: &windows)
}
for appWindow in NSApplication.shared.windows {
appendWindow(appWindow, to: &windows)
}

return windows.filter { candidate in
guard candidate !== window else { return false }
guard String(describing: type(of: candidate)) == "NSToolbarFullScreenWindow" else { return false }
guard titlebarContainer(in: candidate) != nil else { return false }
return fullscreenTitlebarWindow(candidate, belongsTo: window)
}
}

private static func appendWindow(_ candidate: NSWindow, to windows: inout [NSWindow]) {
guard !windows.contains(where: { $0 === candidate }) else { return }
windows.append(candidate)
}

private static func fullscreenTitlebarWindow(_ candidate: NSWindow, belongsTo window: NSWindow) -> Bool {
if window.childWindows?.contains(where: { $0 === candidate }) == true { return true }
if let screen = window.screen, let candidateScreen = candidate.screen {
return screen === candidateScreen
}
if let screen = window.screen {
return candidate.frame.intersects(screen.frame)
}
if let candidateScreen = candidate.screen {
return window.frame.intersects(candidateScreen.frame)
}
return candidate.frame.intersects(window.frame)
}

private static func titlebarContainer(in window: NSWindow) -> NSView? {
// The titlebar container lives on the window's content view's root in
// normal mode, and on a separate NSToolbarFullScreenWindow in native
// fullscreen. We don't support native fullscreen tab bars, so the
// first path suffices for Macterm.
// The titlebar container lives on the window's content view's root.
// In native fullscreen AppKit hosts another copy on a private
// NSToolbarFullScreenWindow; callers discover that companion window
// separately and run this same guarded lookup against it.
guard let contentView = window.contentView else { return nil }
var root: NSView = contentView
while let s = root.superview {
Expand Down
62 changes: 31 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@

- **Vertical Project Sidebar**: Native macOS sidebar for organizing projects and tabs vertically.
- **Persistent Multiplexing**: Projects, tabs, and panes are saved and restored automatically on relaunch.
- **Ghostty Config Compatibility**: Macterm reads your existing `~/.config/ghostty/config`. Theme, font, palette, keybinds — all of it just works.
- **Keyboard-first Navigation**: Customizable keybinds for navigating projects, tabs, and panes.
- **Declarative Layouts**: Define a `.macterm/layout.yaml` describing each project's tabs, splits, and the process every pane runs; apply or save it from the command palette.
- **Ghostty Config Compatibility**: Macterm reads your existing Ghostty config. Theme, font, palette, keybinds — all of it just works.
- **Command Palette**: Versatile command palette to interact with multiplexing and manage projects
- **Quick terminal**: Global terminal accessible from anywhere with a hotkey.
- **Declarative Layouts**: Commit a `.macterm/layout.yaml` describing each project's tabs, splits, and the process every pane runs; apply or save it from the command palette.
- **Smart Tab Naming**: Tabs name themselves after the program running in the pane, making them easily identifiable in the sidebar.
- **Keyboard-driven Control**: Customizable keybinds for many actions including navigating projects, tabs, and panes.

## Install

Expand All @@ -45,7 +45,7 @@
brew install --cask thdxg/tap/macterm
```

The cask strips the Gatekeeper quarantine xattr on install, so the app launches without any extra prompts. Updates are delivered via Sparkle inside the app.
> The cask strips the Gatekeeper quarantine xattr on install, so the app launches without any extra prompts.

### From Releases

Expand Down Expand Up @@ -78,60 +78,60 @@ https://github.com/user-attachments/assets/1486ed55-e653-43ce-98aa-232a61d234a7

Macterm reads your `~/.config/ghostty/config` on launch — themes, fonts, palettes, keybinds, and everything else Ghostty supports works the same here. See the [Ghostty option reference](https://ghostty.org/docs/config/reference) for the full list of available settings. If your config is elsewhere, set the path in **Settings → General → Ghostty Config**.

Macterm ships a thin defaults layer on top of Ghostty's own defaults. These are the values that differ:

| Option | Macterm default | Ghostty default |
| -------------------------------------------------------------------------------------- | --------------- | --------------- |
| [`theme`](https://ghostty.org/docs/config/reference#theme) | `Rose Pine` | _(none)_ |
| [`font-size`](https://ghostty.org/docs/config/reference#font-size) | `16` | `12` |
| [`window-padding-x`](https://ghostty.org/docs/config/reference#window-padding-x) | `16` | `2` |
| [`window-padding-y`](https://ghostty.org/docs/config/reference#window-padding-y) | `16` | `2` |
| [`macos-option-as-alt`](https://ghostty.org/docs/config/reference#macos-option-as-alt) | `true` | `false` |

Add any of these to your Ghostty config to override them. Macterm-specific settings (window opacity, blur style, quick terminal size, hotkeys) live in **Macterm → Settings**.
Macterm ships a thin layer of [first-launch defaults](https://github.com/thdxg/macterm/blob/main/Macterm/Config/MactermConfig.swift#L43-L47) on top of Ghostty's own — add any of those keys to your Ghostty config to override them. Macterm-specific settings (window opacity, blur style, quick terminal size, hotkeys) live in **Macterm → Settings**.

A few settings are overridden because Macterm handles that chrome itself: `background-opacity` and `background-blur` are forced to `0` (use **Settings → General → Window** instead), and titlebar, window decoration, split-divider, and quick-terminal settings are ignored.

Ghostty keybinds work normally unless they conflict with a Macterm shortcut — on conflict, Macterm wins. Every Macterm shortcut is rebindable in **Settings → Keymaps**. Note that Ghostty app-level actions (`new_split`, `new_tab`, etc.) do nothing in Macterm; use Macterm's own keybinds for those.

Shell integration works standalone — no Ghostty.app needed. The one exception is the `ssh-env`, `ssh-terminfo`, and `path` features, which require the `ghostty` CLI; install Ghostty.app to enable them. The `ssh` features additionally need a Ghostty new enough to provide the `+ssh` action (Ghostty 1.4.0 / tip); against an older install they stay disabled and `ssh` runs normally.
The `ssh-env`, `ssh-terminfo`, and `path` features require the `ghostty` CLI; install Ghostty.app to enable them. The `ssh` features additionally need a Ghostty new enough to provide the `+ssh` action (Ghostty 1.4.0 / tip); against an older install they stay disabled and `ssh` runs normally.

## Usage

### Command Palette

Press `⌘P` to open the command palette — the fastest way to drive Macterm without leaving the keyboard. It searches across everything in one list:

- **Commands** — split, close, and focus panes; create, rename, and reorder tabs; toggle window chrome; and more. Each row shows its current keybind.
- **Projects** — jump to any open project, or rename and remove them.

To open a directory as a project, just start typing a path (anything beginning with `/` or `~`). The palette switches to path mode and autocompletes directories as you go; press return on a match to open it (or switch to it, if it's already a project).

## Declarative Project Layouts
### Project Layouts

You can declare a project's tabs, split layout, and the process each pane runs in a `.macterm/layout.yaml` file at the project root. When a project has a layout file, Macterm builds its workspace from it on open — the committed layout takes precedence over any restored session for that project. You can also run **Save layout** from the command palette to write your current workspace out, or **Apply layout** to re-apply the file on demand.
Declare a project's tabs, split layout, and the process each pane runs in a `.macterm/layout.yaml` file at the project root. When a project has a layout file, Macterm builds its workspace from it on open — the committed layout is the source of truth, taking precedence over any restored session for that project. Run **Save layout** from the palette to write your current workspace out, or **Apply layout** to re-apply the file on demand.

```yaml
name: "MyApp" # the project this layout is for (optional)
# .../myapp/.macterm/layout.yaml

# yaml-language-server: $schema=https://raw.githubusercontent.com/thdxg/macterm/main/schemas/layout.schema.json
name: "MyApp" # the project name (optional; defaults to directory name)
tabs:
# A single-pane tab is just a pane (no wrapper).
# A single-pane tab
- run: "npm run dev"
# A tab can carry a `name`, and splits nest under `split:`.
# A tab with custom name and splits
- name: "Dev"
split:
direction: horizontal # horizontal | vertical
ratio: 0.6 # divider position, 0–1 (defaults to 0.5)
first:
cwd: "./api" # project-relative working directory
run: "npm run dev" # typed into the pane's shell on launch
shell: /bin/zsh # optional per-pane shell
shell: /bin/zsh # shell (optional; defaults to login shell)
second:
split:
direction: vertical
first: { cwd: "./api", run: "npm test -- --watch" }
second: {} # plain shell, no command
second: {} # plain shell pane
```

A pane's `run` is typed into a normal shell (so you keep the prompt and history, and the pane survives when the command exits). The shell is the pane's `shell` if set, else the one from your Ghostty config.

**Save** records the project `name:`, each tab's split layout, every pane's working directory, and the command each pane is currently running (its foreground process — so a pane running `npm run dev` is saved with that `run:`, a pane idle at a prompt gets none). The captured command is the resolved process invocation (e.g. `node …/npm-cli.js run dev`), which you can tidy by hand. If a pane is sitting in a non-default shell (one you launched yourself, like `zsh` from your usual `nu`), Save records it as `shell:`; a pane in your default shell records none, so the layout stays portable. If you apply a layout whose `name:` doesn't match the current project, Macterm asks you to confirm first.
A pane's `run` is typed into a normal shell, so you keep the prompt and history and the pane survives when the command exits. The shell is the pane's `shell` if set, else your login shell.

**Apply** reconciles the live workspace toward the file with minimal disruption: a pane already running the declared `run` in the same directory is kept (only resized if its split ratio changed), and only panes that genuinely deviate are restarted or closed. When an apply would terminate any pane, Macterm asks for confirmation first. An invalid layout file is reported and never applied.
Related commands:

**Editor support.** A [JSON schema](schemas/layout.schema.json) describes the format, giving completion and validation in editors that use the YAML Language Server (VS Code, Neovim, Zed, …). Saved files include the modeline that wires it up automatically; for a hand-authored file, add it to the top yourself:
- **Save layout**: Records the project `name:`, each tab's split layout, every pane's working directory, and the command each pane is currently running (a pane idle at a prompt gets none). The captured command is the resolved process invocation (e.g. `node …/npm-cli.js run dev`), which you can tidy by hand. A pane sitting in a non-default shell (one you launched yourself, like `zsh` from your usual `nu`) is saved with that `shell:`; a pane in your default shell records none, so the layout stays portable. Applying a layout whose `name:` doesn't match the current project prompts for confirmation first.

```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/thdxg/macterm/main/schemas/layout.schema.json
```
- **Apply layout**: Reconciles the live workspace toward the file with minimal disruption: a pane already running the declared `run` in the same directory is kept (only resized if its split ratio changed), and only panes that genuinely deviate are restarted or closed. When an apply would terminate any pane, Macterm asks first. An invalid layout file is reported and not applied.

## Contributing

Expand Down