halo main (unreleased) · latest release docsGitHub

AeroSpace

AeroSpace spaces: each display's workspaces, the focused one highlighted, updated on every workspace change. aerospace.ts builds them from halo trigger events and aerospace queries; copy it into your config. The README has the aerospace.toml lines it needs.

AeroSpace

Window-manager support is something you build, not a halo module: a trigger from the WM's hooks plus a hydrate query. The focused workspace:

const focused = externalSignal("aerospace.workspace", {
  hydrate: "aerospace list-workspaces --focused",
  parse: (payload) => payload.focused ?? payload.stdout?.trim(),
});
<Text>{() => `WS ${focused()}`}</Text>;

Add to aerospace.toml:

exec-on-workspace-change = ['/bin/bash', '-c',
  'halo trigger aerospace.workspace focused=$AEROSPACE_FOCUSED_WORKSPACE prev=$AEROSPACE_PREV_WORKSPACE']
on-focus-changed = ['exec-and-forget halo trigger aerospace.focus']

For every workspace per display, with focus and emptiness, copy aerospace.ts (below) into your config. It maps AeroSpace monitors to halo displays through ScreenInfo.index. Use halo's absolute path in aerospace.toml if AeroSpace's PATH doesn't include it.

Run it

swift run halo start --config examples/aerospace                         # from a clone
halo start --config "$(brew --prefix)/share/halo/examples/aerospace"   # Homebrew

Source

halo.json5 linesGitHub
{
  "widgets": {
    "spaces": { "widget": "spaces" }
  }
}
widgets/spaces/aerospace.ts111 linesGitHub
// AeroSpace workspaces as signals, built only on halo's public API: `halo trigger` events from aerospace.toml
// wake it, `aerospace list-workspaces` supplies the state. Copy it into your config and adapt it; the same
// shape works for yabai or any WM with a CLI and change hooks.

import { screens, sh, type ScreenInfo } from "halo";
import { computed, effect, externalSignal, signal, type Accessor } from "halo/signals";

/** An AeroSpace workspace as `aerospace()` reports it. */
export interface Workspace {
  name: string;
  /** The halo display (`ScreenInfo.id`) showing this workspace, matched exactly through the macOS display index. */
  screen: number | undefined;
  focused: boolean;
  empty: boolean;
}

const separator = "--halo--";
const query =
  `aerospace list-workspaces --monitor all --format '%{workspace}|%{monitor-appkit-nsscreen-screens-id}'` +
  ` && echo ${separator} && aerospace list-workspaces --focused` +
  ` && echo ${separator} && aerospace list-workspaces --monitor all --empty no`;

/** Live AeroSpace state, from `aerospace()`. */
export interface AeroSpace {
  /** Every workspace with its display, focus and emptiness. */
  workspaces: Accessor<Workspace[]>;
  /** The focused workspace's name. */
  focused: Accessor<string | undefined>;
  /** Workspaces on one display, in AeroSpace's order. */
  on(screen: ScreenInfo): Accessor<Workspace[]>;
}

/**
 * AeroSpace workspaces, refreshed on every `aerospace.workspace` / `aerospace.focus` trigger.
 * Call once per config, at module level or inside a component.
 */
export function aerospace(options: { command?: string } = {}): AeroSpace {
  const event = externalSignal("aerospace.workspace", {
    parse: (payload) => ({ ...payload }),
  });
  const focusEvent = externalSignal("aerospace.focus", {
    parse: (payload) => ({ ...payload }),
  });
  const raw = signal<string | undefined>(undefined);
  const command = options.command ?? query;

  let generation = 0;
  effect(() => {
    event();
    focusEvent();
    const current = ++generation;
    sh(command).then((result) => {
      if (current !== generation) return;
      if (result.code !== 0)
        return console.warn(
          `aerospace: query failed (${result.code}): ${result.stderr.trim()}`,
        );
      raw.set(result.stdout);
    });
  });

  const warned = new Set<string>();
  const workspaces = computed<Workspace[]>(() => {
    const output = raw();
    if (output === undefined) return [];
    const [all = "", focusedOutput = "", nonEmpty = ""] = output.split(separator);
    const focused = event()?.focused ?? focusedOutput.trim();
    const occupied = new Set(
      nonEmpty
        .split("\n")
        .map((line) => line.trim())
        .filter(Boolean),
    );
    const byIndex = new Map<number, ScreenInfo>(
      screens().map((screen) => [screen.index, screen]),
    );
    return all
      .split("\n")
      .map((line) => line.trim())
      .filter(Boolean)
      .map((line) => {
        const [name = "", index = ""] = line.split("|");
        const screen = byIndex.get(Number(index));
        if (!screen && !warned.has(index)) {
          warned.add(index);
          console.warn(
            `aerospace: no display at macOS index ${index} for workspace ${name}; it won't be shown`,
          );
        }
        return {
          name,
          screen: screen?.id,
          focused: name === focused,
          empty: !occupied.has(name),
        };
      });
  });

  return {
    workspaces,
    focused: computed(() => workspaces().find((workspace) => workspace.focused)?.name),
    on(screen: ScreenInfo): Accessor<Workspace[]> {
      return computed(
        () => workspaces().filter((workspace) => workspace.screen === screen.id),
        {
          equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
        },
      );
    },
  };
}
widgets/spaces/index.tsx62 linesGitHub
// The workspaces AeroSpace shows on each display, the focused one highlighted. Needs the aerospace.toml
// lines from this example's README.

import { Box, Surface, Text, type WidgetProps } from "halo";
import { aerospace, type Workspace } from "@/aerospace/widgets/spaces/aerospace";

const surface = "#1e1e2ee0";
const foreground = "#cdd6f4";
const muted = "#6c7086";
const accent = "#89b4fa";

const spaces = aerospace();

function Pill(props: { workspace: Workspace }) {
  const { focused, empty, name } = props.workspace;
  return (
    <Box
      style={{
        padding: [2, 8],
        radius: 5,
        background: focused ? accent : "transparent",
        transition: { background: 150 },
      }}
    >
      <Text
        style={{
          fontSize: 12,
          fontWeight: "semibold",
          color: focused ? "#1e1e2e" : empty ? muted : foreground,
        }}
      >
        {name}
      </Text>
    </Box>
  );
}

export default function Spaces({ screen }: WidgetProps) {
  const workspaces = spaces.on(screen);
  return (
    <Surface
      screen={screen}
      anchor={["top"]}
      margins={[8, 0, 0, 0]}
      layer="aboveMenu"
      avoid={["menuBar"]}
    >
      <Box
        style={{
          flexDirection: "row",
          align: "center",
          gap: 4,
          padding: 4,
          background: surface,
          radius: 8,
        }}
      >
        {() => workspaces().map((workspace) => <Pill workspace={workspace} />)}
      </Box>
    </Surface>
  );
}