-
-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathlayout.py
1736 lines (1494 loc) · 64.7 KB
/
layout.py
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
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# SPDX-FileCopyrightText: 2023-present Aravinda Rao <maniacalace@gmail.com>
# SPDX-License-Identifier: MIT
import ast
import collections
import enum
import itertools
import os
import pathlib
import re
import tempfile
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Callable, ClassVar
from libqtile import hook
from libqtile.backend.base.window import Window
from libqtile.command.base import expose_command
from libqtile.config import ScreenRect
from libqtile.group import _Group
from libqtile.layout.base import Layout
from libqtile.log_utils import logger
import qtile_bonsai.validation as validation
from qtile_bonsai.core.geometry import (
Axis,
AxisParam,
Direction,
Direction1D,
Direction1DParam,
DirectionParam,
)
from qtile_bonsai.core.nodes import Node, Pane, SplitContainer, Tab, TabContainer
from qtile_bonsai.core.tree import (
InvalidNodeSelectionError,
NodeHierarchyPullOutSelectionMode,
NodeHierarchySelectionMode,
TreeEvent,
)
from qtile_bonsai.theme import Gruvbox
from qtile_bonsai.tree import (
BonsaiNodeMixin,
BonsaiPane,
BonsaiTabContainer,
BonsaiTree,
)
from qtile_bonsai.utils.config import ConfigOption
from qtile_bonsai.utils.process import modify_terminal_cmd_with_cwd
class Bonsai(Layout):
WindowHandler = Callable[[BonsaiTree], BonsaiPane]
class AddClientMode(enum.Enum):
restoration_in_progress = 1
normal = 2
class InteractionMode(enum.Enum):
normal = 1
container_select = 2
level_specific_config_format = re.compile(r"^L(\d+)\.(.+)")
# Analogous to qtile's `Layout.defaults`, but has some more handy metadata.
# `Layout.defaults` is set below, derived from this.
options: ClassVar[list[ConfigOption]] = [
ConfigOption(
"window.margin",
0,
"""
Size of the margin space around windows.
Can be an int or a list of ints in [top, right, bottom, left] ordering.
""",
),
ConfigOption(
"window.single.margin",
ConfigOption.UNSET,
"""
Size of the margin space around a window when it is the single window
remaining under a top-level tab.
Can be an int or a list of ints in [top, right, bottom, left] ordering.
If not specified, will fall back to reading from `window.margin`.
""",
default_value_label="(unset)",
),
ConfigOption(
"window.border_size",
1,
"""
Width of the border around windows. Must be a single integer value since
that's what qtile allows for window borders.
""",
validator=validation.validate_border_size,
),
ConfigOption(
"window.single.border_size",
ConfigOption.UNSET,
"""
Size of the border around a window when it is the single window remaining
under a top-level tab.
Must be a single integer value since that's what qtile allows for window
borders.
If not specified, will fall back to reading from `window.border_size`.
""",
default_value_label="(unset)",
),
ConfigOption(
"window.border_color",
Gruvbox.dull_yellow,
"Color of the border around windows",
default_value_label="Gruvbox.dull_yellow",
),
ConfigOption(
"window.active.border_color",
Gruvbox.vivid_yellow,
"Color of the border around an active window",
default_value_label="Gruvbox.vivid_yellow",
),
ConfigOption(
"window.normalize_on_remove",
True,
"""
Whether or not to normalize the remaining windows after a window is removed.
If `True`, the remaining sibling windows will all become of equal size.
If `False`, the next (right/down) window will take up the free space.
""",
),
ConfigOption(
"window.default_add_mode",
"tab",
"""
(Experimental)
Determines how windows should be added to the layout if they weren't
explicitly spawned from a tab/split command.
The following values are allowed:
1. "tab" (default):
Open as a top level tab.
This is the default and may be convenient since externally spawned
GUI apps would added as background tabs instead of messing up any
active split layout.
2. "split_x":
Open as a top-level split, on the right end.
3. "split_y":
Open as a top-level split, on the bottom end.
4. "match_previous":
Remember how the previous window was opened (tab/split), and open
the new window in the same way.
5. (custom-function):
A callback of the form `(BonsaiTree) -> BonsaiPane`.
For advanced handling of implicitly-added windows. You are given the
internal `BonsaiTree` object to manipulate however, and should
return the Pane that should receive focus after the window is added.
This callback could theoretically be used to drive more 'automatic'
layouts. eg. one could re-implement all of the built-in qtile
layouts with this. But you might as well subclass `BonsaiLayout` for
elaborate customizations.
""",
validator=validation.validate_default_add_mode,
),
ConfigOption(
"tab_bar.height",
20,
"Height of tab bars",
),
ConfigOption(
"tab_bar.hide_when",
"single_tab",
"""
When to hide the tab bar. Allowed values are 'never', 'always',
'single_tab'.
When 'single_tab' is configured, the bar is not shown whenever there is a
lone tab remaining, but shows up again when another tab is added.
For nested tab levels, configuring 'always' or 'single_tab' actually means
that when only a single tab remains, its contents get 'merged' upwards,
eliminating the sub-tab level.
""",
),
ConfigOption(
"tab_bar.hide_L1_when_bonsai_bar_on_screen",
True,
"""
For L1 (top level) tab bars only. If `True`, the L1 tab bar is hidden away
if there is a `BonsaiBar` widget on the screen this layout's group is on.
Otherwise the the L1 tab bar is shown (depending on `tab_bar.hide_when`).
This is dynamic and essentially makes it so the L1 tab bar shows up 'when
required'.
Handy in multi-screen setups if some screens aren't configured to have a
qtile-bar, but the main screen does and has a `BonsaiBar` widget as well.
Note that this takes precedence over `tab_bar.hide_when` for L1 bars.
""",
),
ConfigOption(
"tab_bar.margin",
0,
"""
Size of the margin space around tab bars.
Can be an int or a list of ints in [top, right, bottom, left] ordering.
""",
),
ConfigOption(
"tab_bar.border_size",
0,
"""
Size of the border around tab bars.
Must be a single integer value since that's what qtile allows for window
borders.
""",
validator=validation.validate_border_size,
),
ConfigOption(
"tab_bar.border_color",
Gruvbox.dark_yellow,
"Color of border around tab bars",
default_value_label="Gruvbox.dark_yellow",
),
ConfigOption(
"tab_bar.bg_color",
Gruvbox.bg0,
"Background color of tab bars, beind their tabs",
default_value_label="Gruvbox.bg0",
),
ConfigOption(
"tab_bar.tab.width",
50,
"""
Width of a tab on a tab bar.
Can be an int or `auto`. If `auto`, the tabs take up as much of the
available screen space as possible.
Note that this width follows the 'margin box'/'principal box' model, so it
includes any configured margin amount.
""",
),
ConfigOption(
"tab_bar.tab.margin",
0,
"""
Size of the space on either outer side of individual tabs.
Can be an int or a list of ints in [top, right, bottom, left] ordering.
""",
),
ConfigOption(
"tab_bar.tab.padding",
0,
"""
Size of the space on either inner side of individual tabs.
Can be an int or a list of ints in [top, right, bottom, left] ordering.
""",
),
ConfigOption(
"tab_bar.tab.bg_color",
Gruvbox.dull_yellow,
"Background color of individual tabs",
default_value_label="Gruvbox.dull_yellow",
),
ConfigOption(
"tab_bar.tab.fg_color",
Gruvbox.fg1,
"Foreground text color of individual tabs",
default_value_label="Gruvbox.fg1",
),
ConfigOption(
"tab_bar.tab.font_family", "Mono", "Font family to use for tab titles"
),
ConfigOption("tab_bar.tab.font_size", 13, "Font size to use for tab titles"),
ConfigOption(
"tab_bar.tab.active.bg_color",
Gruvbox.vivid_yellow,
"Background color of active tabs",
default_value_label="Gruvbox.vivid_yellow",
),
ConfigOption(
"tab_bar.tab.active.fg_color",
Gruvbox.bg0_hard,
"Foreground text color of the active tab",
default_value_label="Gruvbox.bg0_hard",
),
ConfigOption(
"tab_bar.tab.title_provider",
None,
"""
A callback that generates the title for a tab. The callback accepts 3
parameters and returns the final title string. The params are:
1. `index`:
The index of the current tab in the list of tabs.
2. `active_pane`:
The active `Pane` instance under this tab. A `Pane` is just a container
for a window and can be accessed via `pane.window`.
3. `tab`:
The current `Tab` instance.
For example, here's a callback that returns the active window's title:
def my_title_provider(index, active_pane, tab):
return active_pane.window.name
""",
),
ConfigOption(
"container_select_mode.border_size",
3,
"""
Size of the border around the active selection when `container_select_mode` is
active.
""",
),
ConfigOption(
"container_select_mode.border_color",
Gruvbox.dark_purple,
"""
Color of the border around the active selection when `container_select_mode` is
active.
""",
default_value_label="Gruvbox.dark_purple",
),
ConfigOption(
"auto_cwd_for_terminals",
True,
"""
(Experimental)
If `True`, when spawning new windows by specifying a `program` that happens
to be a well-known terminal emulator, will try to open the new terminal
window in same working directory as the last focused window.
""",
),
ConfigOption(
"restore.threshold_seconds",
4,
"""
You likely don't need to tweak this.
Controls the time within which a persisted state file is considered to be
from a recent qtile config-reload/restart event. If the persisted file is
this many seconds old, we restore our window tree from it.
""",
),
]
defaults: ClassVar[list[tuple[str, Any, str]]] = [
(option.name, option.default_value, option.description)
for option in options
if option.default_value is not ConfigOption.UNSET
]
def __init__(self, **config) -> None:
super().__init__(**config)
self.add_defaults(self.defaults)
# We declare everything here, but things are initialized in `self._init()`. See
# docs for `self.clone()`.
self._tree: BonsaiTree
self._focused_window: Window | None
self._windows_to_panes: dict[Window, BonsaiPane]
self._add_client_mode: Bonsai.AddClientMode
self._interaction_mode: Bonsai.InteractionMode
self._next_window_handler: Bonsai.WindowHandler = (
self._handle_next_window_as_tab
)
self._restoration_window_id_to_pane_id: dict[int, int] = {}
# See docstring for `_handle_delayed_release_of_removed_nodes()`
self._removed_nodes_for_delayed_release = []
@property
def focused_window(self) -> Window | None:
return self._focused_window
@property
def focused_pane(self) -> Pane | None:
if self.focused_window is not None:
return self._windows_to_panes[self.focused_window]
return None
@property
def actionable_node(self) -> Node | None:
if self.interaction_mode == Bonsai.InteractionMode.container_select:
return self._tree.selected_node
return self.focused_pane
@property
def interaction_mode(self) -> "Bonsai.InteractionMode":
return self._interaction_mode
@interaction_mode.setter
def interaction_mode(self, value: "Bonsai.InteractionMode"):
if value == self._interaction_mode:
return
self._interaction_mode = value
if (
value == Bonsai.InteractionMode.container_select
and self.focused_pane is not None
):
self._tree.activate_selection(self.focused_pane)
elif value == Bonsai.InteractionMode.normal:
self._tree.clear_selection()
self._request_relayout()
def clone(self, group: _Group):
"""In the manner qtile expects, creates a fresh, blank-slate instance of this
class.
This is a bit different from traditional copying/cloning of any 'current' state
of the layout instance. In qtile, the config file holds the 'first' instance of
the layout. Then, as each `Group` is created, it is initialized with 'clones' of
that original instance.
All the qtile-provided built-in layouts perform a state-resetting in their
`clone()` implementations.
So in practice, it seems qtile treats `Layout.clone()` sort of like an 'init'
function.
Here, we lean into this fully. We can instantiate a `Bonsai` layout instance,
but we can only use it after `_init()` is called, which happens via `clone()`
when qtile is ready to provide us with the associated `Group` instance.
"""
pseudo_clone = super().clone(group)
pseudo_clone._init(group)
return pseudo_clone
def layout(self, windows: Sequence[Window], screen_rect: ScreenRect):
"""Handles window layout based on the internal tree representation.
Unlike the base class implementation, this does not invoke `Layout.configure()`
for each window, as there are other elements such as tab-bar panels to process
as well.
"""
# qtile handles fullscreened windows by itself in a special way. But such
# windows are not 'removed' from tiled layouts and they are not passed here in
# `layout()`.
# We just need to ensure that we don't interfere with their rendering when qtile
# is managing them - eg. we should not invoke `window.hide()` on them - that
# leaves us in limbo. We simply ensure the fullscreened windows are unhidden and
# hide away everything else on the layout.
# There ought to be just one fullscreen window at a time, but we look for a list
# just in case programs misbehave. qtile will likely put one of them as the
# topmost.
# The `p.window is not None` check is to handle the case where we're in the
# middle of state restoration.
fullscreened_panes = [
p
for p in self._tree.iter_panes()
if p.window is not None and p.window.fullscreen
]
if fullscreened_panes:
self._tree.hide()
for p in fullscreened_panes:
p.window.unhide()
return
self._sync_with_screen_rect(screen_rect)
self._tree.render(screen_rect)
def configure(self, window: Window, screen_rect: ScreenRect):
"""Defined since this is an abstract method, but not implemented since things
are handled in `self.layout()`.
"""
raise NotImplementedError
def add_client(self, window: Window):
"""Register a newly added window with this layout.
This is usually straightforward, but we do some funky things here to support
restoration of state after a qtile 'reload config'/'restart' event.
Unlike the built-in qtile layouts which are mostly deterministic in how they
arrange windows, in qtile-bonsai, windows are arranged at the whims of the
end user.
When a qtile 'reload config' or 'restart' event happens, qtile will destroy
each layout instance and create it anew post-reload/restart. We use this
opportunity to save our state to a file. When the layout is next instantiated,
we simply check if a 'very recent' state file exists - which we take to mean
that a reload/restart event just happened.
After layout re-instantiation, qtile uses the usual window-creation flow and
passes each existing window to it one-by-one as if new windows were being
created in rapid succession.
We have to hook into this 're-addition of windows' flow to perform our
restoration. Note that this has to work over multiple steps, each time when
qtile calls `Layout.add_client()`. We keep this up until all existing windows
are processed, after which we switch from 'restoration' to 'normal' mode.
"""
if self._add_client_mode == Bonsai.AddClientMode.restoration_in_progress:
pane = self._handle_add_client__restoration_in_progress(window)
else:
pane = self._handle_add_client__normal(window)
pane.window = window
self._windows_to_panes[window] = pane
# Prefer to safely revert back to normal mode on any additions to the tree
self.interaction_mode = Bonsai.InteractionMode.normal
def remove(self, window: Window) -> Window | None:
pane = self._windows_to_panes.get(window)
if pane is None:
# There seems to be some edge cases where `Layout.remove()` can be invoked
# even though the window was not added to the layout. The built-in layouts
# also seem to have this protection. Known scenarios:
# 1. When a program starts out as floating, and then is made fullscreen, and
# then we quit it. The window never got added to a tiled layout, but
# `Layout.remove(win)` is still invoked for it.
# NOTE: It was hard to create an integration test for this. Some weird
# issue where when we made a floating window into fullscreen, then
# `core.Core._xpoll` caused a re-invocation of the `window.fullscreen`
# setter, messing our test. Happens only during integration test - not
# during manual test.
return None
normalize_on_remove = self._tree.get_config(
"window.normalize_on_remove", level=pane.tab_level
)
_, _, next_focus_pane = self._tree.remove(pane, normalize=normalize_on_remove)
del self._windows_to_panes[window]
if self._focused_window is window:
self._focused_window = None
# Prefer to safely revert back to normal mode on any removals to the tree.
# Note that this may trigger a relayout.
self.interaction_mode = Bonsai.InteractionMode.normal
if next_focus_pane is not None:
# 💢 We're going to explicitly ask qtile to focus this next pane's window.
# There is some seemingly quirky handling of focus by qtile where sometimes
# it's handled by logic in `Group` vs other times by logic in `Window`. So
# the 'next focus window' that we return from here is not always respected.
#
# One example that leaves us in such limbo is:
# Open tabs T1, T2, T3, T4. Such that T4 was spawned from T2. Say T2 is a
# program that remains in the foreground and spawns T4 as a separate window.
# If we switch back to T2 and `ctrl-c` it and kill T4 in the background, we
# should remain on T2.
# But in this case, qtile does not respect our 'next focus window' of T2
# returned from `Layout.remove()` here. It instead does not invoke
# `Layout.focus(T2)` via and instead this time delegates to `Window.focus()`
# which uses its own internal focus history records and picks T3 to get
# focus.
# Our layout isn't given info about this (via Layout.focus() as usual) and
# leaves us out-of-sync.
#
# Explicitly triggering a focus seems to help here without any side effects.
# Built in layouts also make use of this API.
# But don't do it for stuff removed as floating - as they will be focused
# separately. Hmm, starting to get weird now.
if not window.floating:
self._request_focus(next_focus_pane)
return next_focus_pane.window
# We only need this re-rendering in the case when there is no subsequent window
# to focus
self._request_relayout()
return None
def focus(self, window: Window):
self._focused_window = window
self._tree.focus(self.focused_pane)
def focus_first(self) -> Window | None:
first = next(self._tree.iter_panes(), None)
if first is not None:
return first.window
return None
def focus_last(self) -> Window | None:
panes = list(self._tree.iter_panes())
if panes:
return panes[-1].window
return None
def focus_next(self, window) -> Window | None:
current_pane = self._windows_to_panes[window]
panes = list(self._tree.iter_panes())
i = panes.index(current_pane)
if i != len(panes) - 1:
return panes[i + 1].window
return None
def next(self, window) -> Window | None:
return self.focus_next(window)
def focus_previous(self, window) -> Window | None:
current_pane = self._windows_to_panes[window]
panes = list(self._tree.iter_panes())
i = panes.index(current_pane)
if i != 0:
return panes[i - 1].window
return None
def previous(self, window) -> Window | None:
return self.previous(window)
def hide(self):
# While other layouts are active, ensure that any new windows are captured
# consistenty with the default tab layout here.
self._reset_next_window_handler()
self._tree.hide()
self.interaction_mode = Bonsai.InteractionMode.normal
# Use this opportunity for some cleanup
self._handle_delayed_release_of_removed_nodes()
def show(self, screen_rect: ScreenRect):
# When a group (and its layout) is 'shown' on some screen, there are some
# dynamic screen-dependent properties that affect our tree, so reevaluate them.
self._tree.reevaluate_dynamic_attributes()
# We'll have to trigger a relayout since `Layout.show()` happens after the usual
# `Layout.layout()`
self._request_relayout()
def finalize(self):
self._finalize_hooks()
self._persist_tree_state()
self._handle_delayed_release_of_removed_nodes()
self._tree.finalize()
@expose_command
def spawn_split(
self,
program: str,
axis: AxisParam,
*,
ratio: float = 0.5,
normalize: bool = True,
position: Direction1DParam = Direction1D.next,
):
"""
Launch the provided `program` into a new window that splits the currently
focused window along the specified `axis`.
Args:
`program`:
The program to launch.
`axis`:
The axis along which to split the currently focused window. Can be 'x'
or 'y'.
An `x` split will end up with two left/right windows.
A `y` split will end up with two top/bottom windows.
`ratio`:
The ratio of sizes by which to split the current window.
If a window has a width of 100, then splitting on the x-axis with a
ratio = 0.3 will result in a left window of width 30 and a right window
of width 70.
Defaults to 0.5.
`normalize`:
If `True`, overrides `ratio` and leads to the new window and all sibling
windows becoming of equal size along the corresponding split axis.
Defaults to `True`.
`position`:
Whether the new split content appears after or before the currently
focused window.
Can be `"next"` or `"previous"`. Defaults to `"next"`.
Examples:
- `layout.spawn_split(my_terminal, "x")`
- `layout.spawn_split( my_terminal, "y", ratio=0.2, normalize=False)`
- `layout.spawn_split(my_terminal, "x", position="previous")`
"""
def _handle_next_window(tree: BonsaiTree) -> BonsaiPane:
if tree.is_empty:
return tree.tab()
target = self.actionable_node or tree.find_mru_pane()
return tree.split(
target, axis, ratio=ratio, normalize=normalize, position=position
)
self._next_window_handler = _handle_next_window
self._spawn_program(program)
@expose_command
def spawn_tab(
self,
program: str,
*,
new_level: bool = False,
level: int | None = None,
):
"""
Launch the provided `program` into a new window as a new tab.
Args:
`program`:
The program to launch.
`new_level`:
If `True`, create a new sub-tab level with 2 tabs. The first sub-tab
being the currently focused window, the second sub-tab being the newly
launched program.
`level`:
If provided, launch the new window as a tab at the provided `level` of
tabs in the currently focused window's tab hierarchy.
Level 1 is the topmost level.
Examples:
- `layout.spawn_tab(my_terminal)`
- `layout.spawn_tab(my_terminal, new_level=True)`
- `layout.spawn_tab("qutebrowser", level=1)`
"""
# We use this closed-over flag to ensure that subtab UX is sensible. After a new
# subtab is invoked, subsequent 'spawn tab' invocations should not implicitly
# continue to create further subtab levels due to the captured `new_level` value
# in `_handle_next_window`.
fall_back_to_default_tab_spawning = False
def _handle_next_window(tree: BonsaiTree) -> BonsaiPane:
nonlocal fall_back_to_default_tab_spawning
if not fall_back_to_default_tab_spawning:
fall_back_to_default_tab_spawning = True
return tree.tab(self.actionable_node, new_level=new_level, level=level)
# Subsequent implicitly created tabs are spawned at whatever level
# `self.actionable_node` is in.
return tree.tab(self.actionable_node)
self._next_window_handler = _handle_next_window
self._spawn_program(program)
@expose_command
def move_focus(self, direction: DirectionParam, *, wrap: bool = True):
"""
Move focus to the window in the specified direction relative to the currently
focused window. If there are multiple candidates, the most recently focused of
them will be chosen.
When `container_select_mode` is active, will similarly pick neighboring nodes,
which may consist of multiple windows under it.
Args:
`direction`:
The direction in which a neighbor is found to move focus to. Can be
"up"/"down"/"left"/"right".
`wrap`:
If `True`, will wrap around the edge and select items from the other
end of the screen. Defaults to `True`.
"""
if self._tree.is_empty:
return
if self.interaction_mode == Bonsai.InteractionMode.container_select:
if self._tree.selected_node is not None:
next_node = self._tree.adjacent_node(
self._tree.selected_node, direction, wrap=wrap
)
self._tree.activate_selection(next_node)
self._request_relayout()
else:
next_pane = self._tree.adjacent_pane(
self.focused_pane, direction, wrap=wrap
)
self._request_focus(next_pane)
@expose_command
def left(self, *, wrap: bool = True):
"""
Same as `move_focus("left")`. For compatibility with API of other built-in
layouts.
"""
if self._tree.is_empty:
return
self.move_focus(Direction.left, wrap=wrap)
@expose_command
def right(self, *, wrap: bool = True):
"""
Same as `move_focus("right")`. For compatibility with API of other built-in
layouts.
"""
if self._tree.is_empty:
return
self.move_focus(Direction.right, wrap=wrap)
@expose_command
def up(self, *, wrap: bool = True):
"""
Same as `move_focus("up")`. For compatibility with API of other built-in
layouts.
"""
if self._tree.is_empty:
return
self.move_focus(Direction.up, wrap=wrap)
@expose_command
def down(self, *, wrap: bool = True):
"""
Same as `move_focus("down")`. For compatibility with API of other built-in
layouts.
"""
if self._tree.is_empty:
return
self.move_focus(Direction.down, wrap=wrap)
@expose_command
def next_tab(self, *, level: int = -1, wrap: bool = True):
"""
Switch focus to the next tab. The window that was previously active there will
be focused.
Args:
`level`:
When subtabs are involved, specifies at which (1-based) tab-level the
tab-activation should take place.
Defaults to `-1`, meaning the nearest tab.
`wrap`:
If `True`, will cycle back to the fist tab if invoked on the last tab.
Defaults to `True`.
Examples:
- `layout.next_tab()
- `layout.next_tab(level=1) # Explicitly activate the next top-most tab.
"""
if self._tree.is_empty:
return
if self._cancel_if_unsupported_container_select_mode_op():
return
next_pane = self._tree.next_tab(self.actionable_node, level=level, wrap=wrap)
if next_pane is not None:
self._request_focus(next_pane)
@expose_command
def prev_tab(self, *, level: int = -1, wrap: bool = True):
"""
Same as `next_tab()` but switches focus to the previous tab.
"""
if self._tree.is_empty:
return
if self._cancel_if_unsupported_container_select_mode_op():
return
next_pane = self._tree.prev_tab(self.actionable_node, level=level, wrap=wrap)
if next_pane is not None:
self._request_focus(next_pane)
@expose_command
def focus_nth_tab(self, n: int, *, level: int = -1):
"""
Switches focus to the nth tab at the specified tab `level`.
Args:
`n`:
The 1-based index of the tab that should be focused.
`level`:
When there are subtab levels at play, which level of tabs among the
hierarchy should be acted upon. Tab levels are 1-based.
`level=1` indicates outermost/top-level tabs.
`level=-1` (default) indicates the innermost/nearest tabs.
Examples:
- `layout.focus_nth_tab(4) # 4th tab
- `layout.focus_nth_tab(2, level=1) # 2nd topmost-level tab`
- `layout.focus_nth_tab(3, level=-1) # 3rd of the 'nearest' tabs`
"""
if self._tree.is_empty:
return
ancestor_tcs = list(reversed(self.focused_pane.get_ancestors(TabContainer)))
if not (level == -1 or 0 < level <= len(ancestor_tcs)):
logger.debug("`level` should be either -1 or a valid 1-indexed tab level.")
return
if level == -1:
level = len(ancestor_tcs)
tc = ancestor_tcs[level - 1]
if not (0 < n <= len(tc.children)):
logger.debug("`n` is out of range.")
return
pane = self._tree.find_mru_pane(start_node=tc.children[n - 1])
self._request_focus(pane)
@expose_command
def focus_nth_window(
self, n: int, *, ignore_inactive_tabs_at_levels: list[int] | None = None
):
"""Switches focus to the nth window.
Counting is always done based on the geospatial position of windows - ie.
starting from the leftmost+innermost window (ie. we traverse leaves of the tree,
left to right).
Args:
`n`:
The 1-based index of the window in the list of all candidate windows.
`ignore_inactive_tabs_at_levels`:
For the specified list of tab levels, only consider windows under the
active tab at that level, ignoring windows under inactive/background
tabs.
eg. `[1]` means we should start counting `n` from the first window in
the currently active level 1 (top-level) tab, ignoring windows under
inactive tabs. But if there are any subtabs under this active tabs, we
DO consider the inactive windows under background/inactive subtabs.
eg. `[1,2]` means we start counting `n` from the first window of the
active top-level tab, and if there are any level 2 subtabs under the
active tab, we pick windows only from the active level 2 tab as well,
ignoring inactive subtabs.
eg. `[]` or `None` (default) means consider every single window - even
if it's inactive under a background tab.
eg. `[2]` means we start counting from the very first window at the top
level, even if it is inactive under a background tab. But whenever there
are level 2 subtabs to consider, we only count its windows that are
under the active level 2 subtab.
Examples:
- `layout.focus_nth_window(1)`
- layout.focus_nth_window(3, ignore_inactive_tabs_at_levels=[1])
- layout.focus_nth_window(2, ignore_inactive_tabs_at_levels=[1, 2])
"""
if n < 1:
logger.debug("`n` is out of range.")
return
if ignore_inactive_tabs_at_levels is None:
ignore_inactive_tabs_at_levels = []
candidates = []
for p in self._tree.iter_panes():
for t in p.get_ancestors(Tab):
if (
t.tab_level in ignore_inactive_tabs_at_levels
and t is not t.parent.active_child
):
break
else:
candidates.append(p)
try:
pane = candidates[n - 1]
except IndexError:
logger.debug("`n` is out of range.")
return
self._request_focus(pane)
@expose_command
def resize(self, direction: DirectionParam, amount: int = 50):
"""
Resizes by moving an appropriate border leftwards. Usually this is the
right/bottom border, but for the 'last' node under a SplitContainer, it will be
the left/top border.
Basically the way tmux does resizing.
If there are multiple nested windows under the area being resized, those windows
are resized proportionally.
Args:
`amount`:
The amount by which to resize.
Examples:
- `layout.resize("left", 100)`
- `layout.resize("right", 100)`
"""
if self._tree.is_empty:
return
direction = Direction(direction)
self._tree.resize(
self.actionable_node, direction.axis, direction.axis_unit * amount
)
self._request_relayout()
@expose_command
def swap(self, direction: DirectionParam, *, wrap: bool = False):
"""
Swaps the currently focused window with the nearest window in the specified
direction. If there are multiple candidates to pick from, then the most recently
focused one is chosen.
Args:
`wrap`:
If `True`, will wrap around the edge and select windows from the other
end of the screen to swap.
Defaults to `False`.