💫

yabaiでmacのアプリを配置

2025/01/20に公開

はじめに


yabai は macOS 向けのタイル型ウィンドウマネージャーです。
本記事では、Jupyter Notebook(Python)を使いながら、yabai が提供するコマンドラインインターフェイス(yabai -m ...)を駆使して、ウィンドウを一括で配置するサンプルを紹介します。
「画面上のアプリを縦3×横5のグリッドに整列させたい」というケースを想定しており、同様のアプローチで様々なレイアウトに応用できます。

前提条件

  • macOS(例: Ventura 以降) で動作確認
  • yabai のインストール & 設定が完了している(SIP無効化やシステム拡張の有効化が必要な場合があります)
  • Python と Jupyter が使用可能
  • Python ライブラリ pandas がインストール済み

yabai ではウィンドウ移動やリサイズなどの機能を利用するために、フルディスクアクセスアクセシビリティの許可が必要になる場合があります。詳細は 公式ドキュメント をご確認ください。

コード全体

以下のコードを Jupyter Notebook などで実行すると、現在アクティブなスペースのウィンドウを縦 4 × 横 6のグリッドに配置します。

import json
import time
import pandas as pd

# yabai の起動状態を確認し、起動していなければサービスを開始
status = !yabai -m query --display
if status[0].startswith("yabai-msg: failed to connect to socket"):
    !yabai --start-service 
    time.sleep(1)

# -----------------------------------------
# 1. 情報の取得
# -----------------------------------------
# ディスプレイ情報の取得
_disp_raw = !yabai -m query --displays
df_display = pd.DataFrame(json.loads("".join(_disp_raw)))
df_display.rename(columns = {"index":"display_index"},inplace=True)

# ディスプレイのフレーム情報が dict になっているので、ここで面積も計算する
df_display["display_frame"] = df_display["frame"]
df_display["area"] = df_display["display_frame"].apply(lambda f: f["w"] * f["h"])

# スペース情報の取得
_space_raw = !yabai -m query --spaces
df_space = pd.DataFrame(json.loads("".join(_space_raw)))
df_space.rename(columns = {"index":"space_index"},inplace=True)

# ウィンドウ情報の取得(必要なカラムのみ)
_window_raw = !yabai -m query --windows
df_window = pd.DataFrame(json.loads("".join(_window_raw)))
df_window = df_window[["id", "app", "title", "frame", "space", "display", "is-visible"]]

# 現在のディスプレイのフレーム情報をウィンドウ情報にマージ
df_window = pd.merge(
    df_window,
    df_display[["display_index", "display_frame"]],
    left_on="display",
    right_on="display_index",
    how="left"
)


# --- 3. 各ディスプレイごとにグリッド設定を参照するための config ---
# config のキーはarea降順になります。
config = {
    "display": {
        "1": {"max_rows": 4, "max_cols": 6},
        "2": {"max_rows": 2, "max_cols": 2},
        "default": {"max_rows": 4, "max_cols": 5}  # 設定がない場合のデフォルト
    }
}

import pandas as pd

# --- 1. ディスプレイ情報とスペース情報のマージ ---
df_space_and_area = pd.merge(
    df_display[["display_index", "area", "display_frame"]],
    df_space[["space_index", "display", "is-visible"]],
    left_on=["display_index"],
    right_on=["display"],
    how="outer"
)
df_target_space = df_space_and_area.set_index("is-visible").loc[True].reset_index(drop=True)

# --- 2. 各ディスプレイの順位を面積降順で決定する ---
# display_index と順位(文字列)をマッピングする辞書を作成
df_display_sorted = df_display.sort_values("area", ascending=False).reset_index(drop=True)
rank_mapping = dict(zip(
    df_display_sorted["display_index"],
    [str(i+1) for i in df_display_sorted.index]
    )
)
    
# --- 4. 各ディスプレイごとにグリッド DataFrame を作成(クロス結合の代替処理) ---
grid_list = []  # 各ディスプレイごとの結果を格納するリスト
for rec in df_target_space.to_dict("records"):
    rank = rank_mapping.get(rec["display_index"], "default")
    # config["display"] から該当するグリッド設定を取得
    max_rows = config["display"].get(rank, config["display"]["default"])["max_rows"]
    max_cols = config["display"].get(rank, config["display"]["default"])["max_cols"]
    
    # 指定された max_rows, max_cols でグリッド(行番号、列番号)の DataFrame を作成
    grid = pd.DataFrame([{"row": r, "col": c} for r in range(max_rows) for c in range(max_cols)])
    
    # grid を付加(クロス結合)
    df_disp_grid = pd.concat([pd.DataFrame([rec] * len(grid)), grid], axis=1)
    grid_list.append(df_disp_grid)

# --- 5. すべてのディスプレイ分を結合 ---
df_grid = pd.concat(grid_list).sort_values(["area","row", "col"],ascending=[False,True,True]).reset_index(drop=True)


# --- df_grid に移動先座標とリサイズ先サイズを追加する処理 ---

def calc_geometry(row):
    """
    各グリッドセルに対して、移動先 (target_x, target_y) とリサイズ先 (target_width, target_height) を計算する
    """
    # ディスプレイのフレーム情報(dict: {"x", "y", "w", "h"})
    disp_frame = row["display_frame"]
    # display_index から rank("1", "2", ... または "default")を取得
    rank = rank_mapping.get(row["display_index"], "default")
    # グリッド設定を config から取得
    grid_setting = config["display"].get(rank, config["display"]["default"])
    max_rows = grid_setting["max_rows"]
    max_cols = grid_setting["max_cols"]

    # 各セルの幅・高さは、ディスプレイのサイズをグリッド分割して計算
    cell_width = disp_frame["w"] / max_cols
    cell_height = disp_frame["h"] / max_rows

    # セルの左上座標を計算(ディスプレイの左上座標 + グリッド上の位置)
    target_x = disp_frame["x"] + row["col"] * cell_width
    target_y = disp_frame["y"] + row["row"] * cell_height

    return pd.Series({
        "target_x": int(target_x),
        "target_y": int(target_y),
        "target_width": int(cell_width),
        "target_height": int(cell_height)
    })

# df_grid に新しい列を追加
df_geometry = df_grid.apply(calc_geometry, axis=1)
df_grid[['target_x', 'target_y', 'target_width', 'target_height']] = df_geometry
df_grid

df_target = pd.merge(
    df_grid.reset_index(drop=True),
    df_window[["id","app","title"]].reset_index(drop=True),
    left_index=True, 
    right_index=True,
    how="left"
)
# df_target["id"] = df_target["id"].astype(int)
df_target = df_target[~df_target["id"].isnull()]
df_target["id"] = df_target["id"].astype(int)
df_target


# 例: df_target(各ウィンドウの割り当て先情報が入った DataFrame)の各行についてコマンドを生成・実行
for n,window in enumerate(df_target.to_dict("records")):
    # --- 1. ウィンドウを対象スペースへ移動 ---
    cmd_space = f'yabai -m window {window["id"]} --space {window["space_index"]}'
    !{cmd_space}
    print(cmd_space)

    # --- 2. ウィンドウ位置の移動 ---
    # ここでは、すでに計算された target_x, target_y を利用して移動先を指定
    cmd_move = f'yabai -m window {window["id"]} --move abs:{window["target_x"]}:{window["target_y"]}'
    !{cmd_move}
    print(cmd_move)

    # --- 3. ウィンドウサイズの変更 ---
    # ここでは、target_width, target_height を利用してリサイズ
    cmd_resize = f'yabai -m window {window["id"]} --resize abs:{window["target_width"]}:{window["target_height"]}'
    !{cmd_resize}
    print(cmd_resize)

コードのポイント
yabaiのデータ取得
・yabai -m query --displays: 接続中のディスプレイ情報
・yabai -m query --spaces: スペース情報
・yabai -m query --windows: ウィンドウ情報
これらを pandas の DataFrame に格納してこねくりまわしています。

まとめ
本記事では、yabai のコマンドを Python (pandas) と組み合わせて使い、アプリウィンドウをグリッドレイアウトに一括配置する方法を解説しました。
yabai -m query ... で得られる各種データを Python 側で自由に処理できるので、例えばアプリ別・用途別のウィンドウ配置や、モニターごとに異なるレイアウトを適用するなど、多彩な自動化が可能になります。

余談
osascriptを使うとchrome内の複数のあるタブを新しいwindowへ移動できたりするので
ブラウザがごちゃついてきたときに1タブ1windowになるように分割してから画面整理するとはかどりそう

tell application "Google Chrome"
	-- 操作対象のウィンドウを明示的に変数に代入する(元のウィンドウ)
	set originalWindow to front window
	-- 現在のアクティブタブのURLを取得
	set activeTabURL to URL of active tab of originalWindow
	
	-- 新しいウィンドウを作成し、そのウィンドウのタブにURLを設定する
	set newWindow to make new window
	set URL of active tab of newWindow to activeTabURL
	
	-- 元のウィンドウのアクティブタブを閉じる
	close active tab of originalWindow
end tell
GitHubで編集を提案

Discussion