Qtile Bonsai
Introduction
Qtile Bonsai provides a flexible layout for the qtile tiling window manager that allows you to arrange windows as tabs, splits and even subtabs inside splits.
You also get an API for quick-access and rearrangements of your tabs and windows.
Check out the demo below, or the visual guide further below.
qtile_bonsai_demo.mp4
Getting Started
Installation
Assuming you already have qtile up and running, you have the following options for installation.
PyPI
If you used uv to install qtile, you can install qtile-bonsai alongside it:
uv tool install qtile --with qtile-bonsai
Or if you want to use the pip interface:
uv pip install qtile-bonsai --break-system-packages
Note
The --break-system-packages is needed to install a package alongside your system
managed python packages, where the qtile installation might reside.
This should be safe enough with qtile-bonsai which doesn't have much in the way of
dependencies.
AUR
For arch-based distros, you can install it from the AUR either manually or with your favorite AUR-helper. For example:
NixOS
It is available in nixpgks as qtile-bonsai:
{ services.xserver.windowManager.qtile = { enable = true; extraPackages = python3Packages: with python3Packages; [ qtile-bonsai ]; }; }
Configuration
1. Make Bonsai available as a layout in your qtile config
from qtile_bonsai import Bonsai layouts = [ Bonsai(**{ # Specify your options here. These examples are defaults. "window.border_size": 1, "tab_bar.height": 20, # You can specify subtab level specific options if desired by prefixing # the option key with the appropriate level, eg. L1, L2, L3 etc. # For example, the following options affect only 2nd level subtabs and # their windows: # "L2.window.border_color": "#ff0000", # "L2.window.margin": 5, }), ]
2. Add your personal keybindings to your qtile config
from libqtile.config import EzKey, KeyChord from libqtile.lazy import lazy from libqtile.utils import guess_terminal terminal = guess_terminal() rofi_run_cmd = "rofi -show drun -m -1" keys = [ # Open your terminal emulator quickly. See further below for how to # directly open other apps as splits/tabs using something like rofi. EzKey("M-v", lazy.layout.spawn_split(terminal, "x")), EzKey("M-x", lazy.layout.spawn_split(terminal, "y")), EzKey("M-t", lazy.layout.spawn_tab(terminal)), EzKey("M-S-t", lazy.layout.spawn_tab(terminal, new_level=True)), # Sometimes it's handy to have a split open in the 'previous' position EzKey("M-S-v", lazy.layout.spawn_split(terminal, "x", position="previous")), EzKey("M-S-x", lazy.layout.spawn_split(terminal, "y", position="previous")), # Motions to move focus. The names are compatible with built-in layouts. EzKey("M-h", lazy.layout.left()), EzKey("M-l", lazy.layout.right()), EzKey("M-k", lazy.layout.up()), EzKey("M-j", lazy.layout.down()), EzKey("M-d", lazy.layout.prev_tab()), EzKey("M-f", lazy.layout.next_tab()), # Precise motions to move directly to specific tabs at the nearest tab level EzKey("M-1", lazy.layout.focus_nth_tab(1, level=-1)), EzKey("M-2", lazy.layout.focus_nth_tab(2, level=-1)), EzKey("M-3", lazy.layout.focus_nth_tab(3, level=-1)), EzKey("M-4", lazy.layout.focus_nth_tab(4, level=-1)), EzKey("M-5", lazy.layout.focus_nth_tab(5, level=-1)), # Precise motions to move to specific windows. The options provided here let # us pick the nth window counting only from under currently active [sub]tabs EzKey("C-1", lazy.layout.focus_nth_window(1, ignore_inactive_tabs_at_levels=[1,2])), EzKey("C-2", lazy.layout.focus_nth_window(2, ignore_inactive_tabs_at_levels=[1,2])), EzKey("C-3", lazy.layout.focus_nth_window(3, ignore_inactive_tabs_at_levels=[1,2])), EzKey("C-4", lazy.layout.focus_nth_window(4, ignore_inactive_tabs_at_levels=[1,2])), EzKey("C-5", lazy.layout.focus_nth_window(5, ignore_inactive_tabs_at_levels=[1,2])), # Resize operations EzKey("M-C-h", lazy.layout.resize("left", 100)), EzKey("M-C-l", lazy.layout.resize("right", 100)), EzKey("M-C-k", lazy.layout.resize("up", 100)), EzKey("M-C-j", lazy.layout.resize("down", 100)), # Swap windows/tabs with neighbors EzKey("M-S-h", lazy.layout.swap("left")), EzKey("M-S-l", lazy.layout.swap("right")), EzKey("M-S-k", lazy.layout.swap("up")), EzKey("M-S-j", lazy.layout.swap("down")), EzKey("A-S-d", lazy.layout.swap_tabs("previous")), EzKey("A-S-f", lazy.layout.swap_tabs("next")), # Manipulate selections after entering container-select mode EzKey("M-o", lazy.layout.select_container_outer()), EzKey("M-i", lazy.layout.select_container_inner()), # It's kinda nice to have more advanced window management commands under a # qtile key chord. KeyChord( ["mod4"], "w", [ # Use something like rofi to pick GUI apps to open as splits/tabs. EzKey("v", lazy.layout.spawn_split(rofi_run_cmd, "x")), EzKey("x", lazy.layout.spawn_split(rofi_run_cmd, "y")), EzKey("t", lazy.layout.spawn_tab(rofi_run_cmd)), EzKey("S-t", lazy.layout.spawn_tab(rofi_run_cmd, new_level=True)), # Toggle container-selection mode to split/tab over containers of # multiple windows. Manipulate using select_container_outer()/select_container_inner() EzKey("C-v", lazy.layout.toggle_container_select_mode()), EzKey("o", lazy.layout.pull_out()), EzKey("u", lazy.layout.pull_out_to_tab()), EzKey("r", lazy.layout.rename_tab()), # Directional commands to merge windows with their neighbor into subtabs. KeyChord( [], "m", [ EzKey("h", lazy.layout.merge_to_subtab("left")), EzKey("l", lazy.layout.merge_to_subtab("right")), EzKey("j", lazy.layout.merge_to_subtab("down")), EzKey("k", lazy.layout.merge_to_subtab("up")), # Merge entire tabs with each other as splits EzKey("S-h", lazy.layout.merge_tabs("previous")), EzKey("S-l", lazy.layout.merge_tabs("next")), ], ), # Directional commands for push_in() to move window inside neighbor space. KeyChord( [], "i", [ EzKey("j", lazy.layout.push_in("down")), EzKey("k", lazy.layout.push_in("up")), EzKey("h", lazy.layout.push_in("left")), EzKey("l", lazy.layout.push_in("right")), # It's nice to be able to push directly into the deepest # neighbor node when desired. The default bindings above # will have us push into the largest neighbor container. EzKey( "S-j", lazy.layout.push_in("down", dest_selection="mru_deepest"), ), EzKey( "S-k", lazy.layout.push_in("up", dest_selection="mru_deepest"), ), EzKey( "S-h", lazy.layout.push_in("left", dest_selection="mru_deepest"), ), EzKey( "S-l", lazy.layout.push_in("right", dest_selection="mru_deepest"), ), ], ), ] ), # Your other bindings # ... ]
3. [Optional] Add the BonsaiBar widget to your qtile bar
qtile-bonsai comes with an optional BonsaiBar widget that lets you view all
your top-level tabs on the qtile-bar.
The default behavior is to automatically hide the top-level/outermost tab-bar if
there is a BonsaiBar widget on the relevant screen. If there isn't, the tab
bar is shown as usual.
from libqtile import bar from libqtile.config import Screen from qtile_bonsai import BonsaiBar screens = [ Screen(top=bar.Bar([ BonsaiBar(**{ # "length": 500, # "sync_with": "bonsai_on_same_screen", # "tab.width": 50, # ... }), # ... your other widgets ... ])), ]
Visual Guide
Click on the image to open a web view with the full guide.
Reference
Layout Configuration
Tip
Most options have subtab-level support! ie. you can have one setting for top level windows and another setting for windows under 2nd level subtabs. eg:
Bonsai({ "window.margin": 10, "L2.window.margin": 5, })
The format is L<subtab-level>.<option-name> = <value>
| Option Name | Default Value | Description |
|---|---|---|
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. |
window.single.margin |
(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. |
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. |
window.single.border_size |
(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. |
window.border_color |
Gruvbox.dull_yellow | Color of the border around windows |
window.active.border_color |
Gruvbox.vivid_yellow | Color of the border around an active window |
window.normalize_on_remove |
True | Whether or not to normalize the remaining windows after a window is removed. If True, the remaining sibling windowswill all become of equal size. If False, the next (right/down) windowwill take up the free space. |
window.default_add_mode |
tab | (Experimental) Determines how windows should be added The following values are allowed: 2. "split_x": 3. "split_y": 4. "match_previous": 5. (custom-function): This callback could |
tab_bar.height |
20 | Height of tab bars |
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 For nested tab levels, configuring |
tab_bar.hide_L1_when_bonsai_bar_on_screen |
True | For L1 (top level) tab bars only. IfTrue, the L1 tab bar is hidden away ifthere is a BonsaiBar widget on thescreen 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 Note that this takes precedence over |
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. |
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. |
tab_bar.border_color |
Gruvbox.dark_yellow | Color of border around tab bars |
tab_bar.bg_color |
Gruvbox.bg0 | Background color of tab bars, beind their tabs |
tab_bar.tab.width |
50 | Width of a tab on a tab bar. Can be an int or Note that this width follows the 'margin |
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. |
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. |
tab_bar.tab.bg_color |
Gruvbox.dull_yellow | Background color of individual tabs |
tab_bar.tab.fg_color |
Gruvbox.fg1 | Foreground text color of individual tabs |
tab_bar.tab.font_family |
Mono | Font family to use for tab titles |
tab_bar.tab.font_size |
13 | Font size to use for tab titles |
tab_bar.tab.active.bg_color |
Gruvbox.vivid_yellow | Background color of active tabs |
tab_bar.tab.active.fg_color |
Gruvbox.bg0_hard | Foreground text color of the active tab |
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 underthis tab. A Pane is just acontainer for a window and can be accessed via pane.window.3. tab:The current Tab instance.For example, here's a callback that |
container_select_mode.border_size |
3 | Size of the border around the active selection when container_select_modeis active. |
container_select_mode.border_color |
Gruvbox.dark_purple | Color of the border around the active selection when container_select_modeis active. |
auto_cwd_for_terminals |
True | (Experimental) If |
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. |
Layout Commands
| Command Name | Description |
|---|---|
spawn_split |
Launch the provided program into a new window that splits thecurrently focused window along the specified axis.Args: Examples: |
spawn_tab |
Launch the provided program into a new window as a new tab.Args: Examples: |
move_focus |
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 neighboringnodes, which may consist of multiple windows under it. Args: |
left |
Same as move_focus("left"). For compatibility with API of otherbuilt-in layouts. |
right |
Same as move_focus("right"). For compatibility with API of otherbuilt-in layouts. |
up |
Same as move_focus("up"). For compatibility with API of other built-in layouts. |
down |
Same as move_focus("down"). For compatibility with API of otherbuilt-in layouts. |
next_tab |
Switch focus to the next tab. The window that was previously active there will be focused. Args: Examples: |
prev_tab |
Same as next_tab() but switches focus to the previous tab. |
focus_nth_tab |
Switches focus to the nth tab at the specified tab level.Args: Examples: |
focus_nth_window |
Switches focus to the nth window. Counting is always done based on the geospatial position of windows - Args: eg. eg. eg. eg. Examples: |
resize |
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, Args: Examples: |
swap |
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: |
swap_tabs |
Swaps the currently active tab with the previous tab. Args: |
rename_tab |
Rename the currently active tab. Args: |
merge_tabs |
Merge the currently active tab with another tab, such that both tabs' contents now appear in 2 splits. Args: Examples: |
merge_to_subtab |
Merge the currently focused window (or an ancestor node) with a neighboring node in the specified direction, so that they both comeunder a (possibly new) subtab. Args: Valid values for Examples: |
push_in |
Move the currently focused window (or a related node in its hierarchy) into a neighboring window's container. Args: Examples: |
pull_out |
Move the currently focused window out from its SplitContainer into an ancestor SplitContainer at a higher level. It effectively moves a window 'outwards'. Args: Examples: |
pull_out_to_tab |
Extract the currently focused window into a new tab at the nearest TabContainer. Args: |
normalize |
Starting from the focused window's container, make all windows in the container of equal size. Args: |
normalize_tab |
Starting from the focused window's tab, make all windows in the tab of equal size under their respective containers. Args: |
normalize_all |
Make all windows under all tabs be of equal size under their respective containers. |
toggle_container_select_mode |
Enable container-select mode where we can select not just a window, but even their container nodes. This will activate a special border around the active selection. You Handy for cases where you want to split over a collection of windows or Aside from focus-switching motions, the only operations supported are |
select_container_inner |
When in container-select mode, it will narrow the active selection by selecting the first descendent node. |
select_container_outer |
When in container-select mode, it will expand the active selection by selecting the next ancestor node. |
tree_repr |
Returns a YAML-like text representation of the internal tree hierarchy. |
BonsaiBar Widget
| Option Name | Default Value | Description |
|---|---|---|
length |
500 | The standard length property of qtilewidgets. As usual, it can be a fixed integer, or |
sync_with |
bonsai_on_same_screen | The Bonsai layout whose state should be rendered on this widget. Can be one of the following: |
bg_color |
None | Background color of the bar. If None, the qtile-bar's' background color is used. |
font_family |
Mono | Font family to use for tab titles |
font_size |
15 | Size of the font to use for tab titles |
tab.width |
50 | Width of a tab on the bar. Can be an int or Note that if the Note that this width follows the 'margin |
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. |
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. |
tab.bg_color |
Gruvbox.dull_yellow | Background color of the inactive tabs |
tab.fg_color |
Gruvbox.fg1 | Foreground color of the inactive tabs |
tab.active.bg_color |
Gruvbox.vivid_yellow | Background color of active tab |
tab.active.fg_color |
Gruvbox.bg0_hard | Foreground color of active tab |
container_select_mode.indicator.bg_color |
Gruvbox.bg0_hard | Background color of active tab when in container_select_mode. |
container_select_mode.indicator.fg_color |
Gruvbox.bg0_hard | Foreground color of active tab when in container_select_mode. |
Support
For any bug reports, please file an issue. For questions/discussions, use the GitHub Discussions section, or you can ask on the qtile subreddit.
