在 Niri 中实现 Hyprland 风格的 Master 自动布局

nixos 从0实现全集 - 目录

背景

Hyprland 内置了多种布局模式,其中 Master 布局 是很多人喜欢的:第一个窗口占据屏幕左侧主区域(Master),后续窗口自动排列在右侧(Stack),形成一个稳定的两列结构。窗口始终在右侧堆叠,主区域不变。

Master对非带鱼屏的使用者,尤其是习惯开一个 ide/editer +浏览器+笔记+ 若干终端随时打开关闭 在同一个屏幕的开发人员极度友好,因为自动布局+所有窗口可见。尤其在ai时代 每一个工作区对应一个项目,还是很舒适的。

Niri 的核心理念是 滚动平铺(Scrollable Tiling):窗口从左到右排列在无限宽的列上,每打开一个新窗口就在右侧开辟新列。这与 Master 布局的"固定两列"哲学完全不同。在带鱼屏上体验应该是极度舒适的,但在普通屏幕上同一个工作区的多数窗口不可见让hyprland/sway的master用户非常难以接受。

niri官方好像明确拒绝了 新建窗口会改变已有创建大小的行为,相关pr被拒绝了,短时间是不可能期待官方实现了。

但借助 Niri 强大的 IPC 机制,我们可以通过脚本实现一个类似 Master 布局的体验。降低自己从hyprland切换到niri的时候不适感。

实现方案

方案由三部分组成:

  1. niri_tile_to_n.py — 常驻后台的 IPC 事件监听脚本,根据窗口数量自动排列
  2. niri_swap_window.py — 窗口位置交换脚本,用于手动将当前窗口移到首位
  3. 快捷键绑定 — 在 niri 配置中串联上述工具

核心:tile_to_n.py — 自动布局引擎

原理

niri_tile_to_n.py 通过 Niri 的 IPC 事件流(Event Stream)实时监听窗口变化。当窗口数 ≤ N(默认 10)时,自动触发布局调整。

Niri 的 IPC 通过 Unix Socket 通信,niri msg 是对其的 CLI 封装。脚本直接连接 Socket 获取事件流,比轮询更高效。

脚本通过 NiriRequests 读取事件,通过 NiriActions 发送动作,读写分离避免阻塞。

niri_tile_to_n.py 基于 heyoeyo/niri_tweaks 修改使用。 同类项目参考:Miriway/Miriway(Mir 合成器的 Niri 改进,目前的功能和tile_to_n.py差距不大,至于性能方面因逻辑简单 对python版本也没有太大优势,未来可期)

布局规则

窗口数行为
1自动最大化 — 首个窗口占据全屏
2折叠并排 — 第二到N个窗口打开时自动取消最大化,形成两列

3 个以上窗口时(假设N=3),通过 ConsumeOrExpelWindowLeft/Right 将窗口向左消耗到第二列。由于 Niri 是横向平铺,多余的窗口会在右侧继续展开:

1
2
3
4
5
6
7
平铺效果(6 个窗口时,Niri 横向滚动视图):

     |   win2  |       |
win1 |---------|  win4 | win5  win6
     |  win3   |       |

← 可见区域 / 屏幕边界 →  ← 右侧继续滚动扩展 →
  • win1:Master 主窗口(第一列)
  • win2 / win3:Stack 列(被 ConsumeOrExpelWindowLeft 消耗到第二列,上下堆叠)
  • win4 / win5 / win6:超过两列的窗口继续向右滚动展开(未被消耗,独立成列)

关闭窗口

当平铺窗口关闭到只剩一个时,自动恢复最大化——回到全屏 solo 状态。

核心代码片段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 获取窗口状态 — 通过宽度启发式判断是否最大化
def get_additional_window_data(window_data, workspace_state, output_width_lut):
    win_pos = window_data["layout"]["pos_in_scrolling_layout"]
    win_col, win_row = win_pos if win_pos is not None else (None, None)
    augment_dict = {"col_idx": win_col, "row_idx": win_row, "is_maximized": False}

    win_width = window_data["layout"]["window_size"][0]
    output_width = output_width_lut.get(win_output, None)
    if output_width is not None:
        augment_dict["is_maximized"] = (win_width / output_width) > 0.8

    return augment_dict

判断依据:窗口宽度超过屏幕宽度 80% 即视为"最大化"。这个启发式算法简洁有效,避免了维护额外的状态位。

布局触发逻辑

1
2
3
4
5
6
7
8
curr_tile_wins = get_windows_by_conditions(win_state, workspace_id=ws_id, is_floating=False)
if len(curr_tile_wins) == 1 and MAXIMIZE_SOLOS:
    maximize_window(curr_tile_wins[0])
elif len(curr_tile_wins) == 2 and COLLAPSE_SOLOS_ON_OPEN:
    collapse_maximized()
elif 3 <= len(curr_tile_wins) <= TILE_TO_N:
    action = "ConsumeOrExpelWindowRight" if col_idx == 2 else "ConsumeOrExpelWindowLeft"
    niri_action.action(action, id=new_window_id)

关键点:脚本不做静态布局管理,而是在窗口状态变化时通过 IPC 动作触发 Niri 的内置窗口操作。这种"事件驱动"的方式与 Niri 的滚动平铺模型配合良好。

窗口交换脚本 niri_swap_window.py

自动布局管理的是窗口创建/关闭时的排列,但用户有时需要手动将当前窗口提升到首位

niri_swap_window.py 实现了一个"交换到第一"的语义:

算法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ordered_wins = sort_by_column_then_row(windows)
focused_idx = ordered_wins.index(focused)

if focused_idx == 0:
    # 已为第一 → 与第二窗口交换
    if same_column:
        focus_target  move_window_up
    else:
        swap_window_right
else:
    # 否则移到第一位置
    swap_window_left × col_diff     # 向左移到第一列
    move_window_up × row_diff       # 向上移到列顶
  • 同列内:用 move-window-up 交换上下位置
  • 跨列:用 swap-window-left/right 直接交换窗口
  • 仅操作当前工作区的平铺窗口,跳过浮动窗口

绑定到 Mod+Alt+M

Consume / Expel 机制详解

niri_tile_to_n 自动布局的核心依赖 Niri 的 ConsumeExpel 操作。理解这两个操作是理解布局行为的关键:

ConsumeOrExpelWindowLeft/Right

场景行为
当前列 只有 当前窗口左侧/右侧列合并进来(相当于"吸收"隔壁列)
当前列有 多个 窗口将当前窗口弹出到新列(相当于"分裂"出去)

ConsumeWindowIntoColumn

将当前列右侧的窗口合并到本列(移除中间的分隔),各窗口变为上下排列。

ExpelWindowFromColumn

将当前列的最后一个窗口弹出到新列(从上下排列变为左右排列)。

这组操作构成了 Niri 的动态列管理原语,与 Hyprland 的 master-slave 切换本质上等价,只是物理模型不同。

NixOS 和niri配置

模块结构

1
2
3
4
5
modules/desktop/
├── wayland_wm_niri.nix       # Niri 模块入口
├── configFile/
│   ├── niri_config.kdl        # 物理机配置
│   └── niri_config_wsl.kdl    # WSL 下niri配置

配置要点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# wayland_wm_niri.nix(简化)
programs.niri.enable = true;

environment.systemPackages = with pkgs; [
  xwayland-satellite  # Niri 需要独立 XWayland
  niri
];

# 配置通过 mkOutOfStoreSymlink 链接到 repo
xdg.configFile."niri/config.kdl".source =
  config.lib.file.mkOutOfStoreSymlink "${repoDir}/modules/desktop/configFile/niri_config.kdl";

自动布局自启动

1
2
3
4
5
6
// niri_config.kdl
spawn-at-startup "sh" "-c" "nohup $REPO_DIR/script/niri_tile_to_n.py -n 10 >/dev/null 2>&1 &"

// 手动启停  注意不能对已经创建的窗口生效,这点和hyprland有区别
Mod+Shift+T { spawn-sh "nohup $REPO_DIR/script/niri_tile_to_n.py -n 10 >/dev/null 2>&1 &"; }
Mod+Ctrl+Shift+T { spawn-sh "pkill -f niri_tile_to_n 2>/dev/null || true"; }

快捷键设计

按键动作说明
Mod+Left/Right聚焦左右列列级导航
Mod+Up/Down聚焦上下窗口列内导航
Mod+Home/End聚焦首尾列快速跳转
Mod+Ctrl+Up/Down窗口上下移动列内重排
Mod+Ctrl+Left/Right列左右移动列级重排
Mod+Alt+M 重点窗口交换到首位类似 Hyprland master 提升
Mod+BracketLeft/Right消耗/驱逐窗口核心平铺操作
Mod+Shift+T启动自动布局按需启停

完整的 niri快捷键部分binds 结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
binds {
    // 焦点导航
    Mod+Left { focus-column-left; }
    Mod+Right { focus-column-right; }
    Mod+Up { focus-window-up; }
    Mod+Down { focus-window-down; }
    Mod+Home { focus-column-first; }
    Mod+End { focus-column-last; }

    // 窗口移动
    Mod+Ctrl+Left { move-column-left; }
    Mod+Ctrl+Right { move-column-right; }
    Mod+Ctrl+Up { move-window-up; }
    Mod+Ctrl+Down { move-window-down; }

    // 窗口交换
    Mod+Alt+M { spawn "sh" "-c" "$REPO_DIR/script/niri_swap_window.py"; }
}

效果与总结

优势和缺点

  • 兼容原生 Niri — 所有操作都是标准的 IPC 动作,没有 patch 或 fork 。如果miri方案需要封装一个nixpkg,而且miri目前也不够完善。
  • 事件驱动 — 不轮询、不抢占,资源占用低
  • 可选择性启用 — 绑定快捷键按需启停 niri_tile_to_n
  • 没有办法100%浮现hyprland的体验,只有 自动布局 以及把某一个窗口和master窗口交换的功能,拖拽交换master会导致轻微混乱。

与 Hyprland Master 的差异

方面Hyprland MasterNiri + niri_tile_to_n
布局模型固定两列,Master 固定多列滚动,通过 Consume 约束为两-n列
窗口顺序最右窗口在 Stack 顶部底部窗口在 Stack 底部(取决于 Consume 方向)
布局切换内置,零延迟IPC 驱动,略有延迟,对已经创建的窗口无效
灵活性Master 模式不可变可随时 pkill 回到原生滚动平铺

参考资料


附录:完整源码

niri_tile_to_n.py(604 行)
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
#!/usr/bin/env python3
# -*- coding: utf-8 -*-


# ---------------------------------------------------------------------------------------------------------------------
# %% Imports

import socket
import json
import os
import signal
import argparse
from dataclasses import dataclass
from time import perf_counter, sleep
from collections import deque


# ---------------------------------------------------------------------------------------------------------------------
# %% Args

# Set built-in defaults (helpful for debugging)
default_N = 3
default_delay_ms = 1000 if perf_counter() < 5 else 0
default_maximize_solos = True
default_maximize_solo_on_close = True
default_collapse_solos_on_open = True
default_apply_on_move = False
default_debug_names = False
default_debug_data = False

# Define script arguments
parser = argparse.ArgumentParser(
    description="Script which makes niri behave like an auto-tiler when there are fewer than 'N' windows"
)
parser.add_argument(
    "-n",
    default=default_N,
    type=int,
    help=f"Number of windows handled with auto-tiling (default {default_N})",
)
parser.add_argument(
    "-delay",
    default=default_delay_ms,
    type=int,
    help=f"Number of milliseconds to delay before listening to niri IPC (default: {default_delay_ms})",
)
parser.add_argument(
    "-x",
    action="store_false" if default_maximize_solos else "store_true",
    help=f"Auto-maximize first window opened on a workspace (default: {default_maximize_solos})",
)
parser.add_argument(
    "-xc",
    action="store_false" if default_maximize_solo_on_close else "store_true",
    help=f"When closing windows, if one window remains, auto-maximize it (default: {default_maximize_solo_on_close})",
)
parser.add_argument(
    "-c",
    action="store_false" if default_collapse_solos_on_open else "store_true",
    help=f"Collapse solo maximized window when opening a second window (default: {default_collapse_solos_on_open})",
)
parser.add_argument(
    "-m",
    action="store_false" if default_apply_on_move else "store_true",
    help=f"Apply tiling logic to windows that are moved into other workspaces (default: {default_apply_on_move})",
)
parser.add_argument(
    "-e",
    "--maximize_to_edges",
    action="store_true",
    help="Use maximize-to-edges instead of maximize-column",
)
parser.add_argument(
    "-dn",
    action="store_false" if default_debug_names else "store_true",
    help="Enable event name printing, for debugging",
)
parser.add_argument(
    "-dd",
    action="store_false" if default_debug_data else "store_true",
    help="Enable event data printing, for debugging",
)
parser.add_argument(
    "-iw",
    type=int,
    action="append",
    help="Ignore workspace with this id (can be specified multiple times)"
)

# Get script configs
args, _ = parser.parse_known_args()
TILE_TO_N = args.n
STARTUP_DELAY_MS = args.delay
MAXIMIZE_SOLOS = args.x
MAXIMIZE_SOLOS_ON_CLOSE = args.xc
COLLAPSE_SOLOS_ON_OPEN = args.c
APPLY_TO_MOVED_WINDOWS = args.m
USE_MAX_TO_EDGES = args.maximize_to_edges
ENABLE_EVENT_NAME_DEBUG_PRINT = args.dn
ENABLE_EVENT_DATA_DEBUG_PRINT = args.dd
IGNORED_WORKSPACE_IDS = args.iw


# ---------------------------------------------------------------------------------------------------------------------
# %% Data types


@dataclass
class TimeKeeper:
    t1: int = 0
    t2: int = 0

    def get_time_elapsed_ms(self) -> int:
        """Reports the time (in ms) since the last time this function was called"""
        self.t1 = self.t2
        self.t2 = round(perf_counter() * 1000)
        delta_ms = self.t2 - self.t1
        return delta_ms


@dataclass
class FocusState:
    workspace_id: int = None
    window_id: int = None

    def copy_inplace(self, other_focus_state):
        """Overwrite current data with data from another object (avoids creating new instances)"""
        self.workspace_id = other_focus_state.workspace_id
        self.window_id = other_focus_state.window_id
        return self


# ---------------------------------------------------------------------------------------------------------------------
# %% Classes


class NiriSocket:
    """Helper used to read & write json messages to a niri socket connection"""

    def __init__(self, socket_path: str, buffer_size: int = 4096):

        # Sanity check
        is_bad_path = socket_path is None or str(socket_path) == ""
        assert not is_bad_path, "Cannot connect to niri, no socket path given..."

        self._skt = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self._skt.connect(skt_path)
        self._bufsize = buffer_size

        # Storage for
        self._msg_queue = deque([])
        self._inprog_str = None

    def _read_next(self):

        # Read from existing (buffered) messages, if any
        if len(self._msg_queue) > 0:
            next_msg = self._msg_queue.popleft()
            return json.loads(next_msg)

        while True:
            # Listen for raw (binary) string data from socket
            # -> Will return 0 bytes if connection closes
            resp_binstr = self._skt.recv(self._bufsize)
            if len(resp_binstr) == 0:
                print("DEBUG - READNEXT: No data received!")
                return {}

            # If we have an in-progress result, append the new data to it
            resp_str = resp_binstr.decode("utf-8")
            if self._inprog_str is not None:
                resp_str = "".join((self._inprog_str, resp_str))
                self._inprog_str = None

            # Stop listening if got at least 1 message
            # - Expect response to look like: "message 1\\nmessage 2\\nmessage 3\\n"
            # - If incomplete, we'll see something not ending with '\\n': "message 1\\nmessa"
            msg_list = resp_str.split("\\n")
            last_msg_piece = msg_list.pop()
            contains_incomplete_message = len(last_msg_piece) > 0
            self._inprog_str = last_msg_piece if contains_incomplete_message else None
            if len(msg_list) > 0:
                break

        # Sanity check, make sure we read something
        if len(msg_list) == 0:
            raise IOError("Error reading next message (empty message list)!")

        # If we have more than 1 message, return only the 'next one
        # (future calls to this function will return the queued up messages)
        out_msg_str = msg_list[0]
        if len(msg_list) > 1:
            self._msg_queue.extend(msg_list[1:])

        return json.loads(out_msg_str)

    def _send_string(self, string: str):
        """Helper used to send simple string messages (e.g. for requests)"""
        return self._skt.sendall(f'"{string}"\\n'.encode("utf-8"))

    def _send_json(self, json_data: dict):
        """Helper used to send json message (e.g. for actions)"""
        json_as_str = json.dumps(json_data, indent=None, separators=(",", ":"))
        return self._skt.sendall(("".join([json_as_str, "\\n"])).encode("utf-8"))

    def close(self):
        self._skt.close()

    @staticmethod
    def get_niri_socket_path():
        return os.environ.get("NIRI_SOCKET")


class NiriRequests(NiriSocket):
    """
    Helper used to make requests to niri
    See: https://yalter.github.io/niri/niri_ipc/enum.Request.html
    """

    def get_version(self):
        return self.request("Version")

    def request(self, message: str):
        self._send_string(message)

        # Listen for ok/err response
        resp_json = self._read_next()
        is_ok_resp = "Ok" in resp_json.keys()
        resp_data = resp_json["Ok" if is_ok_resp else "Err"]
        return is_ok_resp, resp_data

    def read_eventstream(self):

        is_ok, evt_resp = self.request("EventStream")
        if not is_ok:
            print("DEBUG - EventStream response:", evt_resp, sep="\\n")
            raise IOError("Error requesting EventStream")

        # Read events from stream, forever
        while True:
            event_json = self._read_next()
            event_name = tuple(event_json.keys())[0]
            event_data = event_json.get(event_name, None)
            yield event_name, event_data
        return


class NiriActions(NiriSocket):
    """
    Helper used to trigger actions through the niri IPC
    See: https://yalter.github.io/niri/niri_ipc/enum.Action.html
    """

    def action(self, message: str, **kwargs):

        # Build action request
        json_data = {"Action": {message: kwargs}}
        self._send_json(json_data)

        # Listen for ok/err response
        resp_json = self._read_next()
        is_ok_resp = "Err" not in resp_json.keys()
        resp_data = resp_json if is_ok_resp else resp_json["Err"]
        return is_ok_resp, resp_data


# ---------------------------------------------------------------------------------------------------------------------
# %% Functions


def catch_sigterm(signum, frame):
    """Turn SIGTERM events into exceptions for graceful shutdown"""
    raise InterruptedError


def make_workspace_state_from_WorkspacesChanged(event_data: dict) -> dict[int, dict]:
    return {info_dict["id"]: info_dict for info_dict in event_data["workspaces"]}


def make_window_state_from_WindowsChanged(event_data: dict, workspace_state, output_width_lut: dict) -> dict[int, dict]:
    state = {}
    for info_dict in event_data["windows"]:
        win_id = info_dict["id"]
        win_aug_data = get_additional_window_data(info_dict, workspace_state, output_width_lut)
        info_dict.update(win_aug_data)
        state[win_id] = info_dict
    return state


def get_windows_by_conditions(window_state: dict[int, dict], **conditions) -> dict[int, dict]:
    """Function used to filter window state data according to key-value conditions"""
    meets_conditions = lambda data: all(data[k] == v for k, v in conditions.items())
    return {winid: windata for winid, windata in window_state.items() if meets_conditions(windata)}


def get_additional_window_data(
    window_data: dict,
    workspace_state: dict,
    output_width_lut: dict,
    max_width_threshold: float = 0.8,
) -> dict:
    """Helper used to generate addition windowing data (particularly 'is_maximized' flag)"""
    # Set up augmentation data
    win_pos = window_data["layout"]["pos_in_scrolling_layout"]
    win_col, win_row = win_pos if win_pos is not None else (None, None)
    augment_dict = {
        "col_idx": win_col,
        "row_idx": win_row,
        "is_maximized": False,
    }

    # Try to figure out if window is maximized
    win_wspace_id = window_data["workspace_id"]
    win_output = workspace_state.get(win_wspace_id, {}).get("output", None)
    output_width = output_width_lut.get(win_output, None)
    if output_width is not None:
        win_width = window_data["layout"]["window_size"][0]
        augment_dict["is_maximized"] = (win_width / output_width) > max_width_threshold

    return augment_dict


def toggle_window_maximization(target_window_id: int, focused_window_id: int):
    """Helper used to toggle the maximization state of a window, without messing with current focused window"""

    if target_window_id == focused_window_id:
        niri_action.action("MaximizeWindowToEdges" if USE_MAX_TO_EDGES else "MaximizeColumn")
    else:
        niri_action.action("FocusWindow", id=target_window_id)
        niri_action.action("MaximizeWindowToEdges" if USE_MAX_TO_EDGES else "MaximizeColumn")
        niri_action.action("FocusWindow", id=focused_window_id)

    return


def maximize_window(window_state: dict, focus_state: FocusState, target_window_id: int) -> bool:
    """
    Helper used to maximize a window if it\'s not already maximized.
    This function assumes window state includes \'is_maximized\' flag!
    Returns True if the window needed maximization, false otherwise
    """

    solo_win_data = window_state[target_window_id]
    need_maximization = not solo_win_data["is_maximized"]
    if need_maximization:
        solo_id = solo_win_data["id"]
        toggle_window_maximization(solo_id, focus_state.window_id)
        win_state[solo_id]["is_maximized"] = True

    return need_maximization


def collapse_window(window_state: dict, focus_state: FocusState, target_window_id: int) -> bool:
    """
    Helperused to collapse a maximized window. This function assumes
    that the window state includes \'is_maximized\' flag!
    Returns: True if window needed collapse, false otherwise
    """

    solo_win_data = window_state[target_window_id]
    need_collapse = solo_win_data["is_maximized"]
    if need_collapse:
        solo_id = solo_win_data["id"]
        toggle_window_maximization(solo_id, focus_state.window_id)
        win_state[solo_id]["is_maximized"] = False

    return need_collapse


# ---------------------------------------------------------------------------------------------------------------------
# %% Setup

# Handle startup delay (prevent listening to niri during potentially busy startup)
if STARTUP_DELAY_MS > 0:
    sleep(STARTUP_DELAY_MS / 1000)

# Get niri socket from env
skt_path = NiriSocket.get_niri_socket_path()
if skt_path is None or skt_path == "":
    print("Couldn\'t find niri socket! (from env: NIRI_SOCKET)")
    quit()

# Create separate read/write sockets, since eventstream reader cannot issue actions
niri_reader = NiriRequests(skt_path)
niri_action = NiriActions(skt_path)

# Sanity check. Make sure we have the right version
is_version_ok, version_resp = niri_reader.request("Version")
expected_version, actual_version = "26.04 (8ed0da4)", version_resp.get("Version", "unknown")
if actual_version != expected_version:
    print(
        "",
        "WARNING - Unexpected niri version!",
        f"expected: {expected_version}",
        f"  actual: {actual_version}",
        "Errors may occur...",
        sep="\\n",
    )


# ---------------------------------------------------------------------------------------------------------------------
# %% *** IPC listening loop ***

# Get monitor into
is_outputs_ok, outputs_resp = niri_reader.request("Outputs")
if not is_outputs_ok:
    print("Error requesting info about monitors", outputs_resp, sep="\\n")
    quit()
output_full_info = {out_key: out_dict["logical"] for out_key, out_dict in outputs_resp["Outputs"].items()}
output_width_lut = {out_key: out_info["width"] for out_key, out_info in output_full_info.items() if out_info is not None}

# Initialize state tracking
prev_focus_state = FocusState()
focus_state = FocusState()
timekeeper = TimeKeeper()
win_state = None
wspace_state = None

# Main listening loop
signal.signal(signal.SIGTERM, catch_sigterm)
try:
    init_time = timekeeper.get_time_elapsed_ms()
    for evt_name, evt_data in niri_reader.read_eventstream():

        # For debugging printouts, add spaces between events that don\'t occur together
        time_elapsed_ms = timekeeper.get_time_elapsed_ms()
        if ENABLE_EVENT_NAME_DEBUG_PRINT or ENABLE_EVENT_DATA_DEBUG_PRINT:
            if time_elapsed_ms > 250:
                print("", f"Time elapsed (sec): {(timekeeper.t2 - init_time) // 1000}", sep="\\n")
            if ENABLE_EVENT_NAME_DEBUG_PRINT:
                print(evt_name)
            if ENABLE_EVENT_DATA_DEBUG_PRINT:
                print(evt_data)

        # Handle all IPC stream events
        prev_focus_state.copy_inplace(focus_state)
        closed_window_data, newest_window_data = None, None
        if evt_name == "WorkspacesChanged":
            wspace_state = make_workspace_state_from_WorkspacesChanged(evt_data)
            for item in wspace_state.values():
                if item["is_focused"]:
                    focus_state.workspace_id = item["id"]

        elif evt_name == "WorkspaceUrgencyChanged":
            evt_wspace_id = evt_data["id"]
            wspace_state[evt_wspace_id]["is_urgent"] = evt_data["urgent"]

        elif evt_name == "WorkspaceActivated":
            if evt_data["focused"]:
                focus_state.workspace_id = evt_data["id"]
                wspace_state[prev_focus_state.workspace_id]["is_focused"] = False
            pass

        elif evt_name == "WindowsChanged":
            win_state = make_window_state_from_WindowsChanged(evt_data, wspace_state, output_width_lut)
            for item in win_state.values():
                if item["is_focused"]:
                    focus_state.window_id = item["id"]

        elif evt_name == "WindowOpenedOrChanged":
            evt_win_id = evt_data["window"]["id"]
            evt_win_wspace_id = evt_data["window"]["workspace_id"]
            evt_is_new_window = evt_win_id not in win_state.keys()
            evt_is_moved_window, prev_win_wspace_id = False, None
            if not evt_is_new_window:
                prev_win_wspace_id = win_state[evt_win_id]["workspace_id"]
                evt_is_moved_window = prev_win_wspace_id != evt_win_wspace_id

            if evt_data["window"]["is_focused"]:
                focus_state.window_id = evt_win_id

            win_aug_data = get_additional_window_data(evt_data["window"], wspace_state, output_width_lut)
            win_state[evt_win_id] = {**evt_data["window"], **win_aug_data}
            need_check_rearrange = evt_is_new_window or (evt_is_moved_window and APPLY_TO_MOVED_WINDOWS)
            newest_window_data = win_state[evt_win_id] if need_check_rearrange else None

        elif evt_name == "WindowClosed":
            evt_win_id = evt_data["id"]
            closed_window_data = win_state.pop(evt_win_id)

        elif evt_name == "WindowFocusChanged":
            focus_state.window_id = evt_data["id"]

        elif evt_name == "WindowFocusTimestampChanged":
            evt_win_id = evt_data["id"]
            win_state[evt_win_id]["focus_timestamp"] = evt_data["focus_timestamp"]

        elif evt_name == "WindowUrgencyChanged":
            evt_win_id = evt_data["id"]
            win_state[evt_win_id]["is_urgent"] = evt_data["urgent"]

        elif evt_name == "WindowLayoutsChanged":
            for evt_win_id, evt_new_layout in evt_data["changes"]:
                win_state[evt_win_id]["layout"] = evt_new_layout
                win_aug_data = get_additional_window_data(win_state[evt_win_id], wspace_state, output_width_lut)
                win_state[evt_win_id].update(win_aug_data)
            pass

        elif evt_name == "OverviewOpenedOrClosed":
            evt_is_overview_open = evt_data["is_open"]

        elif evt_name == "ConfigLoaded":
            pass

        elif evt_name == "CastsChanged":
            pass

        else:
            print("Unknown event:", evt_name)

        # Handle max-on-close
        if closed_window_data is not None:
            if MAXIMIZE_SOLOS_ON_CLOSE:
                curr_wspace_id = closed_window_data["workspace_id"]
                curr_wins = get_windows_by_conditions(win_state, workspace_id=curr_wspace_id, is_floating=False)
                if len(curr_wins) == 1:
                    solo_id = tuple(curr_wins.keys())[0]
                    maximize_window(win_state, focus_state, solo_id)
                pass

        # Handle window-creation behaviors
        if newest_window_data is not None:

            if newest_window_data["is_maximized"] or newest_window_data["is_floating"]:
                continue

            curr_wspace_id = newest_window_data["workspace_id"]

            if IGNORED_WORKSPACE_IDS and curr_wspace_id and (curr_wspace_id in IGNORED_WORKSPACE_IDS):
                print(f"Ignored event on workspace {curr_wspace_id}")
                continue

            curr_tile_wins = get_windows_by_conditions(win_state, workspace_id=curr_wspace_id, is_floating=False)
            num_tile_wins = len(curr_tile_wins)
            if num_tile_wins == 0 or num_tile_wins > TILE_TO_N:
                continue

            if MAXIMIZE_SOLOS and num_tile_wins == 1:
                solo_id = tuple(curr_tile_wins.keys())[0]
                maximize_window(win_state, focus_state, solo_id)

            curr_max_wins: dict = get_windows_by_conditions(curr_tile_wins, is_maximized=True)
            num_max_wins = len(curr_max_wins)
            if COLLAPSE_SOLOS_ON_OPEN and num_max_wins == 1 and num_tile_wins == 2:
                solo_max_id = tuple(curr_max_wins.keys())[0]
                collapse_window(win_state, focus_state, solo_max_id)
                num_max_wins -= 1

            is_zero_max_windows = num_max_wins == 0
            if is_zero_max_windows and (2 < num_tile_wins <= TILE_TO_N):
                is_new_win_onscreen = newest_window_data["col_idx"] == 2
                consume_action = "ConsumeOrExpelWindowRight" if is_new_win_onscreen else "ConsumeOrExpelWindowLeft"
                niri_action.action(consume_action, id=newest_window_data["id"])

            pass

except (KeyboardInterrupt, InterruptedError):
    pass

finally:
    niri_action.close()
    niri_reader.close()
    print("", f"({os.path.basename(__file__)}) - Closed niri IPC connection", sep="\\n")
niri_swap_window.py(101 行)
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
#!/usr/bin/env python3
"""
Swap focused window with first window in niri.
If already first, swap with second window.
"""

import json
import subprocess
import sys


def niri(*args):
    result = subprocess.run(["niri", "msg", *args], capture_output=True, text=True)
    if result.returncode != 0:
        sys.exit(1)
    return result.stdout.strip()


def get_json(what):
    raw = niri("--json", what)
    data = json.loads(raw)
    if isinstance(data, dict):
        if "Ok" in data:
            data = data["Ok"]
        if "Windows" in data:
            return data["Windows"]
        if "windows" in data:
            return data["windows"]
        if "Workspaces" in data:
            return data["Workspaces"]
        if "workspaces" in data:
            return data["workspaces"]
    return data


def main():
    windows = get_json("windows")
    workspaces = get_json("workspaces")

    current_wspace_id = None
    for ws in workspaces:
        if ws.get("is_focused"):
            current_wspace_id = ws["id"]
            break

    def has_pos(w):
        pos = w.get("layout", {}).get("pos_in_scrolling_layout")
        return pos is not None

    current_wins = [
        w for w in windows
        if w.get("workspace_id") == current_wspace_id
        and not w.get("is_floating", False)
        and has_pos(w)
    ]

    if not current_wins:
        return

    current_wins.sort(key=lambda w: (
        w["layout"]["pos_in_scrolling_layout"][0],
        w["layout"]["pos_in_scrolling_layout"][1]
    ))

    focused = None
    for w in current_wins:
        if w.get("is_focused"):
            focused = w
            break

    if focused is None:
        return

    focused_idx = current_wins.index(focused)

    if focused_idx == 0:
        if len(current_wins) < 2:
            return
        target = current_wins[1]
        f_pos = focused["layout"]["pos_in_scrolling_layout"]
        t_pos = target["layout"]["pos_in_scrolling_layout"]

        if f_pos[0] == t_pos[0]:
            subprocess.run(["niri", "msg", "action", "focus-window", str(target["id"])])
            subprocess.run(["niri", "msg", "action", "move-window-up"])
        else:
            subprocess.run(["niri", "msg", "action", "swap-window-right"])
    else:
        f_pos = focused["layout"]["pos_in_scrolling_layout"]
        col_diff = f_pos[0]

        for _ in range(col_diff):
            subprocess.run(["niri", "msg", "action", "swap-window-left"])

        row_diff = f_pos[1]
        for _ in range(row_diff):
            subprocess.run(["niri", "msg", "action", "move-window-up"])


if __name__ == "__main__":
    main()
Licensed under CC BY-NC-SA 4.0
comments powered by Disqus