DEV Community

Chris Dawkins
Chris Dawkins

Posted on

Building a Tiling Window Manager with Rust and Penrose

During the pursuit of increased productivity, many developers strive to eliminate their usage of the mouse as much as possible. The most effective way to eliminate a large percentage of your mouse usage is by switching from a traditional style of window manager to a tiling style window manager.

What is a tiling-window manager?

Traditional window managers follow a "floating" or "stacking" philosophy. These window managers were originally intended to mimic the familiarity of moving papers around a desk. A newly opened window in a floating-window manager has no regard for the state or visibility of the other opened windows. A tiling-window manager, however, makes the assumption that if a window is open, it should be visible. A newly opened window in a tiling-window manager will be placed in a tile along with the other windows, depending on the chosen layout. The opened windows can then be cycled though, moved, resized, and closed with the use of keyboard bindings. This takes much of the work usually done with the mouse and offloads it to the keyboard thus significantly increasing productivity.

Why Penrose?

There are many existing tiling-window managers with i3 probably being the most popular choice for linux systems. These window managers can depend on extensive configuration files or in the case of dwm, git patching or C programming. Penrose takes a different approach in that Penrose is not a window manager. Penrose is a high-level rust library that you use to
build your own window manager. This gives us many options for customization while also giving us all the advantages that come with writing rust code.

Prerequisites

X11

Penrose works for the X11 window management system. This means that your choice of operating system is basically only linux or bsd.

Rust

Some familiarity with rust is required. The Rust Book is the best place
to start.

Getting Started

To start, we're going to generate a new rust binary project using cargo with the command:

cargo new mywm
Enter fullscreen mode Exit fullscreen mode

Dependencies

To build our window manager, we only need two dependencies. A logging library and penrose itself. Our needs are simple so we can just log to stdout. We will add these to our Cargo.toml.

Cargo.toml

penrose = "0.2"
simplelog = "0.8"
Enter fullscreen mode Exit fullscreen mode

Styles

Before we start building the main application, lets make some modules which contain the styles that we are going to use. Let's create a styles module, which will make our file tree look like this:

mywm
│   Cargo.toml
└───src
│   │   main.rs
│   │   styles.rs
Enter fullscreen mode Exit fullscreen mode

styles.rs

pub const PROFONT: &str = "JetBrainsMono Nerd Font";

pub mod colors {
    pub const BLACK: &str = "#000000";
    pub const GREY: &str = "#808080";
    pub const WHITE: &str = "#ffffff";
    pub const PURPLE: &str = "#a020f0";
    pub const BLUE: &str = "#0000ff";
    pub const RED: &str = "#ff0000";
}

pub mod dimensions {
    pub const HEIGHT: usize = 18;
}
Enter fullscreen mode Exit fullscreen mode

In our styles module we can add our preferred font and submodules for some basic colors and dimensions. We can declare this module directly in our main file along with our intent to use them.

main.rs

mod styles;
use styles::{ PROFONT, colors, dimensions };
Enter fullscreen mode Exit fullscreen mode

Hooks

Penrose supports the use of hooks to further modify the behavior of our window manager. For our purposes, we are only interested in creating a hook which will execute an external script upon startup. This script will allow us to do things like run feh to set our background, start window-compositors to enable window transparency, and more. We can create the hooks module the same way we created the styles modules, leaving our file tree looking like this:

mywm
│   Cargo.toml
└───src
│   │   main.rs
│   │   styles.rs
│   │   hooks.rs
Enter fullscreen mode Exit fullscreen mode

hooks.rs

use penrose::{
    core::{
        hooks::Hook,
        manager::WindowManager,
        xconnection::XConn,
    },
    Result,
    spawn,
};

pub struct StartupScript {
    path: String,
}

impl StartupScript {
    pub fn new(s: impl Into<String>) -> Self {
        Self { path: s.into() }
    }
}

impl<X: XConn> Hook<X> for StartupScript {
    fn startup(&mut self, _: &mut WindowManager<X>) -> Result<()> {
        spawn!(&self.path)
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the entirety of our hooks module. First we declare a struct which holds the path to the script on our system. Then we implement the hook trait for penrose to spawn the process. We can declare this module in the main file the same way we did our styles module.

main.rs

mod hooks;
Enter fullscreen mode Exit fullscreen mode

Main Application

Now we can move on to implementing the actual application logic. Everything from this point will be added to the main.rs file. To start, we are going to declare a few constant variables which will hold our choice of terminal, application launcher, and the path to our start script.

pub const TERMINAL: &str = "kitty";
pub const LAUNCHER: &str = "dmenu_run";
pub const PATH_TO_START_SCRIPT: &str = "$HOME/.mywm";
Enter fullscreen mode Exit fullscreen mode

Replace these values with your preferred application choices and the path to your start script. These values could be declared programmatically through the use of something like the clap crate. This would have the benefit of externalizing our configuration, which would allow us to make changes without re-compiling the entire application. That would be beyond the scope of this tutorial. You can, however, find an example of this in my personal build: HERE.

Main Function Return Type

Our main function is going to return a penrose Result to make error handling much simpler.

fn main() -> penrose::Result<()> {
}
Enter fullscreen mode Exit fullscreen mode

Logging initialization

use simplelog::{ LevelFilter, SimpleLogger };
...
if let Err(e) = SimpleLogger::init(LevelFilter::Info, simplelog::Config::default()) {
    panic!("unable to set log level: {}", e);
};
Enter fullscreen mode Exit fullscreen mode

We are going to use the simplelog crate to initialize our logger. The SimpleLogger logs to stdout, if we wanted to log to a file we could replace it with WriteLogger.

Layouts

use penrose::{
    core::{
    layout::{
        LayoutConf,
        side_stack,
    },
    Layout,
    },
};
...
let side_stack_layout = Layout::new("[[]=]", LayoutConf::default(), side_stack, 1, 0.6);

Enter fullscreen mode Exit fullscreen mode

For our purposes, we are only going to declare a single layout. This layout allows one main window and allocates 60% screen of the real-estate to the main window, and shares the remaining 40% between the other windows. The string is the symbol that will be displayed when the layout is active.

Config

use penrose::Config;
...
let config = Config::default()
    .builder()
    .show_bar(true)
    .top_bar(true)
    .layouts(vec![side_stack_layout])
    .focused_border(colors::PURPLE)?
    .build()
    .expect("Unable to build configuration");
Enter fullscreen mode Exit fullscreen mode

This config is very simple. We allocate space for a top bar, add our layouts, and choose a border color which will appear around the active window.

Top-Bar

use penrose::{
    draw::{
        TextStyle,
        Color,
        dwm_bar,
    },
    xcb::XcbDraw,
};
...

let style = TextStyle {
    font: PROFONT.to_string(),
    point_size: 11,
    fg: Color::try_from(colors::WHITE)?,
    bg: Some(Color::try_from(colors::BLACK)?),
    padding: (2.0, 2.0),
};

let empty_ws = Color::try_from(colors::GREY)?;
let draw = XcbDraw::new()?;

let bar = dwm_bar(
    draw,
    dimensions::HEIGHT,
    &style,
    Color::try_from(colors::PURPLE)?,
    empty_ws,
    config.workspaces().clone(),
)?;
Enter fullscreen mode Exit fullscreen mode

We could use something like polybar to build a powerful and sophisticated top-bar for our system. However, for this example we are going to use the built-in dwm_bar which mimics the bar that can be found in dwm. What's happening here is pretty straight-forward. First we populate the styling struct, and then we plug these values into the dwm_bar.

Keybindings

use penrose::{
    core::{
        ring::Direction::{
            Forward,
            Backward,
        },
        data_types::Change::{
            More,
            Less,
        },
        helpers::index_selectors,
    },
    Selector,
};
...
let key_bindings = gen_keybindings! {
        // Program launchers
        "M-p" => run_external!(LAUNCHER);
        "M-Return" => run_external!(TERMINAL);

        // Exit Penrose (important to remember this one!)
        "M-A-C-Escape" => run_internal!(exit);

        // client management
        "M-j" => run_internal!(cycle_client, Forward);
        "M-k" => run_internal!(cycle_client, Backward);
        "M-S-j" => run_internal!(drag_client, Forward);
        "M-S-k" => run_internal!(drag_client, Backward);
        "M-f" => run_internal!(toggle_client_fullscreen, &Selector::Focused);
        "M-c" => run_internal!(kill_client);

        // workspace management
        "M-Tab" => run_internal!(toggle_workspace);
        "M-A-period" => run_internal!(cycle_workspace, Forward);
        "M-A-comma" => run_internal!(cycle_workspace, Backward);

        // Layout management
        "M-grave" => run_internal!(cycle_layout, Forward);
        "M-S-grave" => run_internal!(cycle_layout, Backward);
        "M-A-Up" => run_internal!(update_max_main, More);
        "M-A-Down" => run_internal!(update_max_main, Less);
        "M-l" => run_internal!(update_main_ratio, More);
        "M-h" => run_internal!(update_main_ratio, Less);

        map: { "1", "2", "3", "4", "5" } to index_selectors(5) => {
             "M-{}" => focus_workspace (REF);
             "M-S-{}" => client_to_workspace (REF);
         };
    };
Enter fullscreen mode Exit fullscreen mode

Penrose includes a helpful macro that allows us to quickly set our keybindings. The 'M' key is the meta key aka Windows key. We also label our workspaces here. We are only declaring 5, but you could use any arbitrary number of workspaces. We also label our workspaces with numbers, but they could be labeled using icons or emojis.

Hooks

use penrose::{
    core::{
        hooks::Hooks,
    },
    XcbConnection,
};
...
let hooks: Hooks<XcbConnection> = vec![
    Box::new(bar),
    Box::new(hooks::StartupScript::new(PATH_TO_START_SCRIPT)),
];
Enter fullscreen mode Exit fullscreen mode

Here we create a vector to hold our hooks. We only have two hooks, the top-bar, and the start script we declared earlier.

Run

use penrose::{
    new_xcb_backed_window_manager,
    logging_error_handler,
};
...
let mut wm = new_xcb_backed_window_manager(config, hooks, logging_error_handler())?;
wm.grab_keys_and_run(key_bindings, map!{})
Enter fullscreen mode Exit fullscreen mode

All that is left now is to build it and run it.

Additional Steps

Compiling and Running

Compilation is as simple as running the cargo build command:

cargo build --release
Enter fullscreen mode Exit fullscreen mode

Now that we have a binary, how do we run it? We could use xinit to launch a session directly from a tty. Instead, if you already have a login manager installed, you can move the binary to the /usr/bin/ directory and make a mywm.desktop file that looks something like this:

[Desktop Entry]
Encoding=UTF-8
Name=Mywm
Comment=Tiling Window Manager
Exec=mywm
Type=Xsession
Enter fullscreen mode Exit fullscreen mode

Place the .desktop file in /usr/share/xsessions/ directory, and you will be able to select mywm upon login.

.mywm Hook Script

We built a hook that would run our script on startup. The script can be used to do many things, but the most common would probably be to set your background.

 #!/bin/sh
 feh --no-fehbg --bg-scale '$HOME/Pictures/background.png'
Enter fullscreen mode Exit fullscreen mode

Just make sure that .mywm has executable privileges.

Jetbrains IDE

Intellij along with other Jetbrains IDEs can have trouble when running under a tiling-window manager. To solve this problem, you need to export a variable for the JVM that runs the IDE to use:

export _JAVA_AWT_WM_NONREPARENTING=1
Enter fullscreen mode Exit fullscreen mode

The best place to put this is in an .env file like .zshenv, if zsh is your default shell.

Conclusion

Building your own window manager can be a very daunting undertaking. With tools like Penrose, much of the complexities involved are hidden behind helpful libraries. This particular build only scratches the surface of what can be accomplished. The complete code for this project can be found on my gitlab alongside my actual build.

Discussion (2)

Collapse
rounakcodes profile image
rounakcodes • Edited on

I am sure you are having a lot of fun with Penrose, building and even more understanding the low level details of a window manager. I completely get that this learning could be an end in itself instead of a means to an end. I am just curious if you found anything lacking in existing window managers (not that I have tried anything other than Xmonad) that you need Penrose for. Have fun!

Collapse
siph profile image
Chris Dawkins Author

I would say the limitations are more in myself than in other window managers/compositors. Rust is the only non-JVM language I have meaningful experience with. I found Penrose while searching for a DWM-like manager written in Rust instead of C. I'm actually still using DWM at the moment but I plan on revisiting my Penrose project to add some polish and incorporate it into my NixOs configuration.