Configuration

IonWL is configured entirely through Python. The config file is ~/.config/ionwl/ion_init.py. It runs once at startup or when reloading (ion.app.reload()).

A typical configuration sets up:

Sample configurations

Copy either to ~/.config/ionwl/ion_init.py and customize.

Minimal

A minimal starting point: window management, launchers, and compositor controls, without tiling-navigation keys. Other actions remain reachable through the right-click context menus.

# SPDX-License-Identifier: GPL-3.0-or-later

# IonWL sample configuration.
#
# Copy this file to ~/.config/ionwl/ion_init.py and customize as needed.

# Only needed for Python < 3.14.
from __future__ import annotations

__all__ = ()

import ion
from ion.app import preferences as prefs
from ion.types import MatchKey
import ion.constants.keys as kbd
import ion.ops as ops
import ion_utils.config

# -----------------------------------------------------------------------------
# Settings

_TERMINAL = ('foot',)
_LAUNCHER = ('tofi-drun', '--drun-launch=true')

prefs.fonts.titlebar_font.face = 'DejaVu Sans:size=11:weight=bold'

# -----------------------------------------------------------------------------
# Keymaps

_km_global = ion.app.keymaps['global']
_km_tiling = ion.app.keymaps['tiling']
_km_floating = ion.app.keymaps['floating']
_km_window = ion.app.keymaps['window']
_km_fullscreen = ion.app.keymaps['fullscreen']

# -----------------------------------------------------------------------------
# Context menus and pointer bindings

ion_utils.config.setup_defaults()

# -----------------------------------------------------------------------------
# Window management

# Close window, or join frame if no window focused.
for km in (_km_window, _km_tiling):
    km.items.new(MatchKey(kbd.Q, logo=True), ops.window_close(delete_empty_frame=True, delete_empty_workspace=True))

# Toggle fullscreen.
_km_window.items.new(MatchKey(kbd.F, logo=True), ops.window_fullscreen_set())
_km_fullscreen.items.new(MatchKey(kbd.F, logo=True), ops.window_fullscreen_set())

# Toggle floating (detach/reattach).
_km_window.items.new(MatchKey(kbd.X, logo=True), ops.window_floating_set())

# Toggle tag on current window.
_km_window.items.new(MatchKey(kbd.T, logo=True), ops.tag_set())

# Attach tagged windows to current frame.
for km in (_km_tiling, _km_floating):
    km.items.new(MatchKey(kbd.A, logo=True), ops.tag_attach())

# -----------------------------------------------------------------------------
# Launchers

# Terminal.
_km_global.items.new(MatchKey(kbd.RETURN, logo=True), ops.system_exec(command=_TERMINAL))

# App launcher.
_km_global.items.new(MatchKey(kbd.P, logo=True), ops.system_exec(command=_LAUNCHER))

# -----------------------------------------------------------------------------
# Compositor control

# Logout (graceful): close all windows, exit when none remain.
_km_global.items.new(MatchKey(kbd.Q, logo=True, shift=True), ops.compositor_logout())

# Exit (force): quit immediately without closing windows.
_km_global.items.new(MatchKey(kbd.Q, logo=True, shift=True, ctrl=True), ops.compositor_quit())

# Reload config.
_km_global.items.new(MatchKey(kbd.R, logo=True, shift=True), ops.compositor_reload())

Full

The bundled sample configuration wires up Notion-style keybindings with VIM-style (HJKL) navigation.

# SPDX-License-Identifier: GPL-3.0-or-later

# IonWL sample configuration.
#
# Copy this file to ~/.config/ionwl/ion_init.py and customize as needed.
#
# This config uses VIM-style navigation.

# Only needed for Python < 3.14.
from __future__ import annotations

__all__ = ()

import ion
from ion.app import preferences as prefs
from ion.types import MatchKey, MatchPointer
import ion.constants.keys as kbd
import ion.constants.pointer_buttons as btn
import ion.constants.event_actions as act
import ion.ops as ops
import ion_utils.config

# -----------------------------------------------------------------------------
# Settings

_TERMINAL = ('foot',)
_LAUNCHER = ('tofi-drun', '--drun-launch=true')
_SCRATCHPAD = '*scratch*'  # Workspace used as a scratchpad.

# Enable numlock on startup.
prefs.startup.use_numlock = True

# Enable sloppy focus (focus follows mouse).
prefs.focus.pointer_follow = True

# Warp pointer to frame on directional focus.
prefs.focus.pointer_warp = True
prefs.focus.pointer_warp_factor = (0.5, 0.5)  # Centered.
prefs.focus.pointer_warp_margin = 5

# Key repeat: 40 Hz, 200ms delay.
prefs.input.keyboard_defaults.repeat_rate = 40
prefs.input.keyboard_defaults.repeat_delay = 200

# Show desktop notification on Python errors (requires notify-send).
prefs.notify_on_error = True

# Cursor size.
prefs.cursor.size = 48

# Font settings.
prefs.fonts.titlebar_font.face = 'DejaVu Sans:size=11:weight=bold'

# -----------------------------------------------------------------------------
# Keymaps

_km_global = ion.app.keymaps['global']
_km_tiling = ion.app.keymaps['tiling']
_km_floating = ion.app.keymaps['floating']
_km_window = ion.app.keymaps['window']
_km_fullscreen = ion.app.keymaps['fullscreen']


# -----------------------------------------------------------------------------
# Startup


@ion.app.hooks.compositor_startup_post.append
def _on_startup() -> None:
    '''Create 4 desktops with a horizontal split, start background services.'''
    # Desktop 0 already exists, create 3 more.
    desktops = [ion.wm.desktops[0]]
    for i in range(3):
        desktops.append(ion.wm.desktops.new(str(i + 1)))

    # Give each desktop a workspace with a horizontal split.
    for i, desktop in enumerate(desktops):
        desktop.name = str(i + 1)
        ws = ion.wm.workspaces.new(ion.types.Rect((0, 0), (800, 600)))
        desktop.items_tiling.add(ws)
        node = ws.frames[0].split_node
        assert node is not None
        node.split(0, 1)

    ion_utils.config.setup_session(dbus=True, systemd=True)

    ion.utils.exec(('mako',))


# -----------------------------------------------------------------------------
# Context menus and pointer bindings

ion_utils.config.setup_defaults()


# -----------------------------------------------------------------------------
# Window management

# Close window, or join frame if no window focused.
for km in (_km_window, _km_tiling):
    km.items.new(MatchKey(kbd.Q, logo=True), ops.window_close(delete_empty_frame=True, delete_empty_workspace=True))

# Toggle fullscreen.
_km_window.items.new(MatchKey(kbd.F, logo=True), ops.window_fullscreen_set())

# Toggle floating (detach/reattach).
_km_window.items.new(MatchKey(kbd.X, logo=True), ops.window_floating_set())

# Toggle tag on current window.
_km_window.items.new(MatchKey(kbd.T, logo=True), ops.tag_set())

# Attach tagged windows to current frame.
for km in (_km_tiling, _km_floating):
    km.items.new(MatchKey(kbd.A, logo=True), ops.tag_attach())

# -----------------------------------------------------------------------------
# Fullscreen overrides (block operations that don't apply in fullscreen)

# Exit fullscreen.
_km_fullscreen.items.new(MatchKey(kbd.F, logo=True), ops.window_fullscreen_set())

# Block tiling move (vim-style).
_km_fullscreen.items.new(MatchKey(kbd.H, logo=True, shift=True), ops.system_noop())
_km_fullscreen.items.new(MatchKey(kbd.J, logo=True, shift=True), ops.system_noop())
_km_fullscreen.items.new(MatchKey(kbd.K, logo=True, shift=True), ops.system_noop())
_km_fullscreen.items.new(MatchKey(kbd.L, logo=True, shift=True), ops.system_noop())

# -----------------------------------------------------------------------------
# Tab cycling within frame

# Alt+Tab - next tab.
_km_window.items.new(MatchKey(kbd.TAB, alt=True), ops.frame_tab_cycle(direction=1))

# Alt+Shift+Tab - previous tab.
_km_window.items.new(MatchKey(kbd.TAB, alt=True, shift=True), ops.frame_tab_cycle(direction=-1))

# -----------------------------------------------------------------------------
# Tiling navigation (vim-style)

_km_global.items.new(MatchKey(kbd.H, logo=True), ops.tiling_focus_directional(direction='LEFT'))
_km_global.items.new(MatchKey(kbd.J, logo=True), ops.tiling_focus_directional(direction='DOWN'))
_km_global.items.new(MatchKey(kbd.K, logo=True), ops.tiling_focus_directional(direction='UP'))
_km_global.items.new(MatchKey(kbd.L, logo=True), ops.tiling_focus_directional(direction='RIGHT'))

# -----------------------------------------------------------------------------
# Moving windows between frames (vim-style)

_km_tiling.items.new(MatchKey(kbd.H, logo=True, shift=True), ops.window_tiling_move_directional(direction='LEFT'))
_km_tiling.items.new(MatchKey(kbd.J, logo=True, shift=True), ops.window_tiling_move_directional(direction='DOWN'))
_km_tiling.items.new(MatchKey(kbd.K, logo=True, shift=True), ops.window_tiling_move_directional(direction='UP'))
_km_tiling.items.new(MatchKey(kbd.L, logo=True, shift=True), ops.window_tiling_move_directional(direction='RIGHT'))

# -----------------------------------------------------------------------------
# Splitting frames

# Split horizontal (new frame to the right) - backslash key.
_km_tiling.items.new(MatchKey(kbd.BACK_SLASH, logo=True), ops.tiling_split(direction='RIGHT'))

# Split vertical (new frame below).
_km_tiling.items.new(MatchKey(kbd.BACK_SLASH, logo=True, shift=True), ops.tiling_split(direction='DOWN'))

# Join frame with sibling, moving windows.
_km_tiling.items.new(MatchKey(kbd.X, logo=True, shift=True), ops.frame_join())

# -----------------------------------------------------------------------------
# Layout manipulation

# Move focused item to the next output (wraps back to the left).
_km_global.items.new(MatchKey(kbd.Z, logo=True), ops.output_move_directional(direction='RIGHT'))

# Toggle split axis at current frame.
_km_tiling.items.new(MatchKey(kbd.Z, logo=True, shift=True), ops.tiling_split_axis_set())

# Toggle split axis at workspace root.
_km_tiling.items.new(MatchKey(kbd.Z, logo=True, ctrl=True), ops.tiling_split_axis_set(parent_depth=-1))

# -----------------------------------------------------------------------------
# Launchers

# Terminal.
_km_global.items.new(MatchKey(kbd.RETURN, logo=True), ops.system_exec(command=_TERMINAL))

# App launcher.
_km_global.items.new(MatchKey(kbd.P, logo=True), ops.system_exec(command=_LAUNCHER))

# -----------------------------------------------------------------------------
# Floating workspace (scratch)

_km_global.items.new(MatchKey(kbd.SPACE, logo=True), ops.workspace_floating_set(name=_SCRATCHPAD))
_km_window.items.new(MatchKey(kbd.W, logo=True), ops.window_workspace_push(name=_SCRATCHPAD))
_km_global.items.new(MatchKey(kbd.W, logo=True, shift=True), ops.window_workspace_pop(name=_SCRATCHPAD))

# -----------------------------------------------------------------------------
# Compositor control

# Logout (graceful): close all windows, exit when none remain.
_km_global.items.new(MatchKey(kbd.Q, logo=True, shift=True), ops.compositor_logout())

# Exit (force): quit immediately without closing windows.
_km_global.items.new(MatchKey(kbd.Q, logo=True, shift=True, ctrl=True), ops.compositor_quit())

# Reload config.
_km_global.items.new(MatchKey(kbd.R, logo=True, shift=True), ops.compositor_reload())

# -----------------------------------------------------------------------------
# Workspace cycling

# Cycle to next/previous occupied desktop.
_km_global.items.new(MatchKey(kbd.M, logo=True), ops.desktop_focus_occupied_cycle(direction=1))
_km_global.items.new(MatchKey(kbd.COMMA, logo=True), ops.desktop_focus_occupied_cycle(direction=-1))

# Cycle to next empty desktop.
_km_global.items.new(MatchKey(kbd.RETURN, logo=True, ctrl=True), ops.desktop_focus_empty_cycle(direction=1))

# Window carousel.
_km_window_carousel = ion.app.keymaps['window_carousel']
_km_window_carousel.items.new(MatchKey(kbd.RETURN, action=act.CLICK), ops.window_carousel_confirm())
_km_window_carousel.items.new(MatchKey(kbd.ESCAPE, action=act.CLICK), ops.window_carousel_cancel())
_km_window_carousel.items.new(MatchPointer(btn.LEFT, action=act.CLICK), ops.window_carousel_cancel())

_km_global.items.new(MatchKey(kbd.TAB, logo=True, ctrl=True), ops.window_cycle_carousel(keymap='window_carousel'))

# Desktop overview on logo key click.
_km_desktop_overview = ion.app.keymaps['desktop_overview']
_km_desktop_overview.items.new(MatchKey(kbd.H), ops.desktop_overview_navigate(direction='LEFT'))
_km_desktop_overview.items.new(MatchKey(kbd.J), ops.desktop_overview_navigate(direction='DOWN'))
_km_desktop_overview.items.new(MatchKey(kbd.K), ops.desktop_overview_navigate(direction='UP'))
_km_desktop_overview.items.new(MatchKey(kbd.L), ops.desktop_overview_navigate(direction='RIGHT'))
_km_desktop_overview.items.new(MatchKey(kbd.RETURN, action=act.CLICK), ops.desktop_overview_confirm())
_km_desktop_overview.items.new(MatchKey(kbd.ESCAPE, action=act.CLICK), ops.desktop_overview_cancel())
_km_desktop_overview.items.new(MatchPointer(btn.LEFT, action=act.CLICK), ops.desktop_overview_select())

for key in (kbd.LOGO_L, kbd.LOGO_R):
    _km_global.items.new(
        MatchKey(key, action=act.CLICK),
        ops.desktop_overview(keymap='desktop_overview', trim_empty=True),
    )

# Window overview.
_km_window_overview = ion.app.keymaps['window_overview']
_km_window_overview.items.new(MatchKey(kbd.H), ops.window_overview_navigate(direction='LEFT'))
_km_window_overview.items.new(MatchKey(kbd.J), ops.window_overview_navigate(direction='DOWN'))
_km_window_overview.items.new(MatchKey(kbd.K), ops.window_overview_navigate(direction='UP'))
_km_window_overview.items.new(MatchKey(kbd.L), ops.window_overview_navigate(direction='RIGHT'))
_km_window_overview.items.new(MatchKey(kbd.RETURN, action=act.CLICK), ops.window_overview_confirm())
_km_window_overview.items.new(MatchKey(kbd.ESCAPE, action=act.CLICK), ops.window_overview_cancel())
_km_window_overview.items.new(MatchPointer(btn.LEFT, action=act.CLICK), ops.window_overview_select())

_km_global.items.new(MatchKey(kbd.Y, logo=True), ops.window_overview(keymap='window_overview'))

# -----------------------------------------------------------------------------
# Media keys

_km_global.items.new(
    MatchKey(kbd.XF86_AUDIO_RAISE_VOLUME),
    ops.system_exec(command=('wpctl', 'set-volume', '@DEFAULT_AUDIO_SINK@', '5%+')),
)
_km_global.items.new(
    MatchKey(kbd.XF86_AUDIO_LOWER_VOLUME),
    ops.system_exec(command=('wpctl', 'set-volume', '@DEFAULT_AUDIO_SINK@', '5%-')),
)
_km_global.items.new(MatchKey(kbd.XF86_AUDIO_PLAY), ops.system_exec(command=('cmus-remote', '--pause')))
_km_global.items.new(MatchKey(kbd.XF86_AUDIO_PREV), ops.system_exec(command=('cmus-remote', '--prev')))
_km_global.items.new(MatchKey(kbd.XF86_AUDIO_NEXT), ops.system_exec(command=('cmus-remote', '--next')))
_km_global.items.new(MatchKey(kbd.XF86_AUDIO_REWIND), ops.system_exec(command=('cmus-remote', '--seek', '-10')))
_km_global.items.new(MatchKey(kbd.XF86_AUDIO_FORWARD), ops.system_exec(command=('cmus-remote', '--seek', '+10')))

ion.utils.log('Config loaded')

Type Checking

Validate your configuration with mypy using the installed stubs:

MYPYPATH=/opt/ionwl/share/ionwl/python mypy --strict ~/.config/ionwl/ion_init.py