Introduction

MARSWM

A modern window manager featuring dynamic tiling (rusty successor to moonwm).

An example image

The YAML format is used for configuration with the default file path being ~/.config/marswm/marswm.yaml. You can get the default configuration with marswm --print-default-config.

Documentation

You can find the documentation here

Quick Start Guide

You can find the documentation here

Installation

Archlinux (AUR)

paru -S marswm
# or
yay -S marswm

marswm-git is also available as the development version.

NetBSD (Official repositories)

pkgin install marswm

or, if you prefer to build it from source

cd /usr/pkgsrc/wm/marswm
make install

Nix

marswm is currently not officially packaged for Nix. You can use the derivation in examples/default.nix to install it on your machine. Make sure to update the version number and hash accordingly.

Other (cargo)

This guide shows installation for a standard Linux distribution that supports the Standard File Hierarchy.

For non-standard distributions (e.g. doas instead of sudo, no FHS-support) you will have to change some things. But in that case chances are you know what to do anyway.

First make sure you have the following libraries installed natively via your package manager: libX11, libXft, libXinerama, libXrandr. Make sure to also include their development version if your distribution splits up packages in this manner.

Then you can build and install marswm and its components with cargo:

sudo cargo install --root=/usr/local/ marswm marsbar mars-relay

To run marswm directly from your display manager of choice you will have to add a .desktop file. You can copy ./marswm.desktop, but make sure to replace PATH with your actual path (e.g. /usr/local/bin). Usually it goes into /usr/share/xsessions.

Quick Start Guide

This guide shows you how to build a fully functional desktop environment with marswm.

Assumptions

There are a few assumptions made in these scripts with regards to your system in this guide and in the scripts:

  • marswm, marsbar and mars-relay are already installed (see "Installation")
  • You use either PulseAudio or pipewire with pactl installed (probably in the pulseaudio package)
  • Your distro uses systemd

No worries, if one of those does not apply to your setup. It should be straightforward to adjust the scripts for example for a non-systemd distro, but you have to do so on your own.

Startup Script

First of all it makes sense to create a startup script in which we can put programs to run when our WM starts. Create a file called mars-startup in your $PATH) (e.g. ~/.local/bin/mars-startup):

#/bin/sh

# helper function to detect if a program is already running
is_running () {
	pgrep --uid "$UID" "$1" > /dev/null
}

# load default layout (use arandr to set it)
[ -f ~/.screenlayout/default.sh ] && /bin/sh ~/.screenlayout/default.sh;

# programs to automatically start
is_running marsbar || marsbar &

We will add further lines to this script later on.

Status Script

marsbar can use scripts to display status information and generate menus.

You can find an example script mars-status in examples section. Read Examples/Installing Scripts for more information on how to install it.

Now you can add the script to your marsbar config in ~/.config/marswm/marsbar.yaml:

status_cmd: "mars-status"
action_cmd: "mars-status action"

Wallpaper

This repo also contains a simple script to set your wallpaper: wallpaper-daemon. It automatically adjusts your wallpaper whenever your screen configuration changes. Read Examples/Installing Scripts for more information on how to install it.

Now we can add it to our autostart script:

is_running wallpaper-daem || wallpaper-daemon &

It will load whatever wallpaper you put in ~/.background-image.

Application Menu(s)

Another script provided in examples/ is xdg-xmenu.py. Read Examples/Installing Scripts for more information on how to install it.

Now we can add it to our button bindings. It is suggested to put it in ~/.config/marswm/buttonbindings_ext.yaml as this way it does not interfere with other default bindings:

- modifiers: []
  button: 3
  targets: [root]
  action: !execute xdg-xmenu -m | xmenu | /bin/sh

Make sure to restart the WM for the bindings to take effect.

After installing the script you can generate the icon cache it by running xdg-xmenu -f. Now you should be able to access the menu when right-clicking the desktop.

Rofi / dmenu

You will probably also want a keyboard-driven option to access your applications. By default marswm comes with keybindings for Rofi preconfigured. Make sure to install and customize it to your liking, then you should be able to run it by pressing MOD + d.

A lightweight alternative is dmenu, but you will have to add your own keybindings for it to work properly.

Audio, Media and Brightness Key Bindings

A modern desktop should also provide working key bindings for audio, media and brightness control, so let's add these (~/.config/marswm/keybindings_ext.yaml):

# Volume Control
- key: XF86AudioRaiseVolume
  action: !execute pactl set-sink-volume @DEFAULT_SINK@ +5% && canberra-gtk-play -i audio-volume-change
- key: XF86AudioLowerVolume
  action: !execute pactl set-sink-volume @DEFAULT_SINK@ -5% && canberra-gtk-play -i audio-volume-change
- key: XF86AudioMute
  action: !execute pactl set-sink-mute @DEFAULT_SINK@ toggle
- key: XF86AudioMicMute
  action: !execute pactl set-source-mute @DEFAULT_SOURCE@ toggle

# Media Control
- key: XF86AudioPlay
  action: !execute playerctl play-pause -p Lollypop,spotify
- key: XF86AudioPause
  action: !execute playerctl play-pause -p Lollypop,spotify
- key: XF86AudioPrev
  action: !execute playerctl previous -p Lollypop,spotify
- key: XF86AudioNext
  action: !execute playerctl next -p Lollypop,spotify

# Brightness Control
- key: XF86MonBrightnessUp
  action: !execute light -A 10
- key: XF86MonBrightnessDown
  action: !execute light -U 10

Note that these keybindings depend on pactl, playerctl and light, so make sure to install these.

Screenshots

Surely you will want to be able to take screenshots, so lets set up a key binding for them. We will use maim, tee and xclip so make sure to have them installed. You will also want to create a directory for your screenshots (e.g. ~/Pictures/Screenshots).

The key binding (add to ~/.config/marswm/keybindings_ext.yaml) looks like this:

# Screenshots
- modifiers: [ Mod4 ]
  key: Print
  action: !execute maim -s | tee "$HOME/Pictures/Screenshots/$(date '+%Y-%m-%d_%H-%M-%S.png')" | xclip -selection clipboard -t image/png -i

Now once you press Alt + Print you will be able to select an area to take a screenshot from. The image will be saved and copied to your clipboard. You may want to test this once to make sure everything works.

Touch Gestures

There is an example config for Touchégg provided along with this repo. Copy the file to ~/.config/touchegg/touchegg.conf. Then install and setup touchegg (it requires a server daemon to run, as well as a client).

You will then be able to cycle through workspaces using three fingers on your touchpad, as well as accessing the window menu (swipe down).

Help View

It is hard to keep track of what key binding you used for what program and even harder to remember the default keys. To make this easier you leverage a simple script that displays the keybinding configuration. The script takes into account whether you have overwritten the original default keybindings.

You can find the script mars-help in examples section. Read Examples/Installing Scripts for more information on how to install it.

Then add the following keybinding to ~/.config/marswm/keybindings_ext.yml (where you replace $TERMINAL with your terminal emulator of choice):

- modifiers: [ Mod4, Shift ]
  key: slash
  action: !execute $TERMINAL -e "sleep 0.1; mars-relay fullscreen set; mars-help"

Now when you press Mod4 + Shift + slash (Mod4 + question) on an American keyboard you get a searchable overview of the configured keybindings. Of course you can change the binding for this to whatever you like.

Additional Suggestions

These are additional programs suggested to complete your desktop setup:

  • nmapplet - applet for managing your network (e.g. Wifi setup) (only works if your machine uses NetworkManager)
  • blueman + blueman-applet - GUI and applet for managing your Bluetooth connections
  • arandr - GUI to setup screen configurations

Configuration

Multi-Monitor Setups and Workspaces

The window manager supports multi-monitor setups, although they are not as well tested as they probably should be for daily usage. Every (non-overlapping) monitor gets its own set of workspaces, which is also exposed as such to other applications like status bars. You can configure the number of the primary monitor and secondary monitors with the primary_workspaces and the secondary_workspaces option respectively.

It is suggested to use a relatively low number of workspaces for secondary monitors as they might clutter your bar otherwise.

Startup Command

You might want to execute a script or command on startup in order to launch a bar, a compositor or a notification daemon. This is what the on_startup option is for.

Initial Window Placement

You can specify where windows should be placed initially (applies to floating windows only). Possible settings are:

  • center - center the window on the screen
  • pointer - place the window below the pointer
  • wherever - don't care about placing the window at a special position

The corresponding setting is called initial_placement.

Layouts

marswm supports dynamic tiling and takes a lot of inspiration for it from dwm.

Currently the following layouts are supported:

  • floating - the clients are not automatically tiled in any way and can be freely positioned by the user
  • stack - other windows are tiled vertically to the right of the main windows
  • bottom-stack - other windows are tiled horizontally below the main windows
  • monocle - all window are stacked on top of each other and fill the whole area
  • deck - other windows are stacked to the right of the main windows on top of each other
  • dynamic - this one is a little more complicated and is described in more detail down below

You can influence the layout of the windows with different parameters. All of the following options belong in the layout section:

  • default - specifies the default layout for new workspaces
  • gap_width - size of the gap between windows and between the windowing area and the screen edge
  • main_ratio - share of space that the main windows take on the screen
  • nmain - how many windows the main area contains on a new workspace

Some of these values can be changed at runtime through respective key bindings.

The dynamic Layout

As the name suggest the dynamic layout can be used to implement a variety of different layouts. It is configured by these two parameters (also in the layout section of the configuration file):

  • stack_position - specifies where the stack windows should be placed in relation to the main windows
  • stack_mode - describes whether the stack windows should be in a split or deck configuration

Theming

You can configure different parts of how marswm looks in the theming section of the configuration file.

These attributes influence the coloring of window borders:

  • active_color - frame color of currently focused window
  • inactive_color - frame color of unfocused windows
  • border_color - color of the inner and outer border around the frame

Note: Although they may look very weird in the output of marswm --print-default-config colors can simply be written as hex values (like 0x1a2b3c).

To show a window's title at the top of its frame use these settings:

  • show_title - a boolean value determining whether the title is shown or not
  • font - the font that is used for drawing the title

Attributes specifying width are all in pixels:

  • frame_width - tuple describing the width of the frame on each side (excluding inner and outer borders)
  • inner_border_width - inner border between the window content and frame
  • outer_border_width - outer border around the window frame
  • title_vpadding - vertical padding for title
  • title_hpadding - horizontal padding for title

There is also a sub-section for the border configuration of windows that usually don't want to be decorated. It is part of the general theming section and is called no_decoration. The values frame_width, inner_border_width and outer_border_width are available and work the same as with normal windows.

Key Bindings

marswm comes with a set of default key bindings. Call marswm --print-default-keys to get an overview of them.

In contrast to the other sections of this manual the keybindings are not configured in the default configuration file. Instead they are read from a separate YAML file (usually in ~/.config/marswm/keybindings.yaml). The bindings in that file will overwrite the default bindings. If you wish to just extend the default key bindings by some custom ones you can use the file ~/.config/marswm/keybindings_ext.yaml which will then get merged with the default key bindings.

A key binding entry consists of a list of modifers, the key you want to bind as well as an action to execute as soon as a key is pressed. Here is an example:

- modifiers: [Mod4, Shift]
  key: '1'
  action: !move-workspace 0

You can find documentation for actions here.

Button Bindings

Button actions can be configured similarly to key bindings in the files ~/.config/marswm/buttonbindings.yaml and ~/.config/marswm/buttonbindings_ext.yaml respectively. marswm --print-default-buttons tells you the button bindings installed by default.

The targets field specifies which window areas should be used for the button event. Possible values are window, frame and root. The actions are the same as used for key bindings.

Here is an example:

- modifiers: [Mod4, Shift]
  button: 2
  targets: [WindowFrame, ClientWindow]
  action: close-client

You can find documentation for actions here.

Window Rules

It is possible to configure the state of newly mapped windows with window rules. The file ~/.config/marswm/rules.yaml may contain a list of such rules. The rules consist of an identifier part as well as configuration options and a list of actions to apply on matching windows.

For example:

- identifiers:
    application: 'thunderbird'
  actions: [ !move-workspace 5 ]

You can find documentation for actions here.

Identifiers:

  • application - name of the application (second string of the WM_CLASS property on X11)
  • title - window title

Configuration Options:

  • actions - list of binding actions to execute for the new window
  • floating - specify whether a window should initially be tiled or floating
  • ignore_window - leads to the window not being managed by the window manager
  • initial_placement - allows overwriting the placement value in your general configuration
  • workspace - set to the workspace you would prefer the application to launch on

The configuration should be stored in the YAML format at ~/.config/marswm/marsbar.yaml You can get the default configuration with marsbar --print-default-config.

The Status Script

You can set the status on the right side of the bar with a custom skript or program. On X11 it uses the custom property _MARS_STATUS on the root window. You can use any program to set it, but mars-relay also supports the set-status command:

mars-relay set-status "Today is $(date +%F)"

You also have the possibility to use multiple modules for different metrics. They are separated by a special character, the default is currently 0x1f. In a shell script you could use it like so:

load="$(cut -d' ' -f1 /proc/load)"
date="$(date +%F)"
status="$(printf "%s\x1f%s" "load: $load" "date: $date")"
mars-relay set-status "$status"

The script/program is expected to update the status on its own. It can either be started by your own startup scripts/systemd/etc. or by marsbar itself. To launch the script with marsbar you have to make sure the script is executable (chmod +x). Then you can add it to the config file under the option status_cmd.

Button Actions

marsbar also lets you handle button clicks for those status blocks. These are handled by a script/program which can be a different executable or just the same as used for status updates. Place the path to the executable under the action_cmd option in the config file.

When a button is pressed that executable is called with the environment variables BLOCK and BUTTON are set:

  • $BLOCK contains the index of the status block that was clicked
  • $BUTTON contains the index number of the mouse button that generated the event

Theming

Theming is available under the style subsection in the configuration file.

This section might look something like this:

style:
  background: 0x262626             # background color of the bar
  expand_workspace_widgets: false  # make all workspace widgets the same width
  height: 31                       # height of the whole bar
  font: FiraCode:size=12           # font of text surfaces (as xft name)
  workspaces:
    foreground: 0x262626           # foreground (text) color of the workspace widget
    inner_background: 0x5F87AF     # background of the individual workspaces
    outer_background: 0x262626     # background *around* the individual workspaces
    padding_horz: 0                # horizontal padding around the workspaces
    padding_vert: 0                # vertical padding around the workspaces
    text_padding_horz: 10          # horizontal padding around the text
    text_padding_vert: 4           # vertical padding around the text
    spacing: 0                     # spacing between the individual workspaces
  title:
    foreground: 0xBCBCBC           # foreground (text) color
    background: 0x262626           # background color of the text widget
  status:
    foreground: 0x262626           # foreground (text) color
    inner_background: 0xAF5F5F     # background of the individual blocks
    outer_background: 0x262626     # background *around* the individual blocks
    padding_horz: 4                # horizontal padding around the blocks
    padding_vert: 4                # vertical padding around the blocks
    text_padding_horz: 5           # horizontal padding around the text
    text_padding_vert: 0           # vertical padding around the text
    spacing: 4                     # spacing between the individual blocks

Note: Although they may look very weird in the output of marsbar --print-default-config colors can simply be written as hex values (like 0x1a2b3c).

Examples

This chapter contains a couple of example scripts and configurations. All examples are available on Github.

Installing Configurations

Most config files go into ~/.config/<program>/<program>.<suffix>. For example marswm's main config goes into ~/.config/marswm/marswm.yaml.

Installing Scripts

To install a script you will first need to install all of its dependencies.

Then place the script into a directory that is listed in your $PATH. It is suggested to use ~/.local/bin/ to store all of your personal scripts. You can check if that is in your path with echo $PATH and add it to your ~/.profile otherwise.

You will also have to make the script executable:

chmod +x <script>

mars-help.sh

Dependencies:

  • bat
#!/bin/sh

{
	if [ -f ~/.config/marswm/keybindings_ext.yaml ]; then
		echo "### CUSTOM KEY BINDINGS ###"
		cat ~/.config/marswm/keybindings_ext.yaml
		echo
	fi

	echo "### DEFAULT KEY BINDINGS ###"
	if [ -f ~/.config/marswm/keybindings.yaml ]; then
		cat ~/.config/marswm/keybindings.yaml
	else
		marswm --print-default-keys
	fi
} | bat -l yaml --paging always

mars-status.sh

Dependencies:

#/bin/sh

set +o nounset

SEPARATOR='\x1f'
BATTERY_PATH="$(find /sys/class/power_supply -maxdepth 1 -mindepth 1 | grep -i bat | head -n 1)"


### HELPERS

confirmation_submenu () {
	printf "\n\tYou sure?\n\t\t%s" "$1"
}

gen_media_menu () {
	for player in $(playerctl -l); do
		gen_player_menu "$player"
	done
}

gen_player_menu () {
	echo "$1"
	printf "\t%s - %s\n" "$(property_for_player "$1" title)" "$(property_for_player "$1" artist)"
	printf "\t%s\tplayerctl play-pause -p \"%s\"\n" "$(play_pause_label "$1")" "$1"
	printf "\tnext\tplayerctl next -p \"%s\"\n" "$1"
	printf "\tprevious\tplayerctl prev -p \"%s\"\n" "$1"
}

pa_volume () {
	pactl get-sink-volume @DEFAULT_SINK@ | grep "Volume" | sed 's/.*\/\s*\(.*\) \s*\/.*/\1/;'
}

pa_muted () {
	if pactl get-sink-mute @DEFAULT_SINK@ | grep no > /dev/null; then
		return 1
	else
		return 0
	fi
}

pa_loop () {
	pactl subscribe | grep --line-buffered "Event 'change' on sink " | while read -r _; do
		update_blocks
	done
}

play_pause_label () {
	if [ "$(playerctl status -p "$1")" = "Playing" ]; then
		echo "pause"
	else
		echo "play"
	fi
}

property_for_player () {
	playerctl metadata -p "$1" | grep "xesam:$2" | sed 's/^\([a-zA-Z]*\) xesam:\([a-zA-Z]*\) *\(.*\)/\3/'
}

audio_menu () {
	SINK_MENU="$(pactl list sinks | grep "Name: \|Description:" \
		| sed 'N; s/\t*Name: \(.*\)\n\t*Description: \(.*\)/\t\2\tpactl set-default-sink \1/')"
	SOURCE_MENU="$(pactl list sources | grep "Name: \|Description:" \
		| sed 'N; s/\t*Name: \(.*\)\n\t*Description: \(.*\)/\t\2\tpactl set-default-source \1/')"
	printf "Change default output\n%s\nChange default input\n%s" "$SINK_MENU" "$SOURCE_MENU" | xmenu | sh
}

media_menu () {
	gen_media_menu | xmenu | sh
}

system_menu () {
SYSTEM_MENU="Logout $(confirmation_submenu 'pkill marswm')
Suspend $(confirmation_submenu 'systemctl suspend')
Poweroff $(confirmation_submenu poweroff)
Reboot $(confirmation_submenu reboot)

Output Profile
$(find ~/.screenlayout -type f | sed 's/^\(.*\)\/\(.*\)\(\.sh\)/\t\2\tsh \1\/\2\3/')"
	echo "$SYSTEM_MENU" | xmenu | sh
}


### BUTTON HANDLERS

battery_button () {
	profile="$(powerprofilesctl list | sed '/^   /d;/^$/d;s/\(.*\):/\1/' | xmenu | sed 's/.* //')"
	if [ -n "$profile" ]; then
		powerprofilesctl set "$profile"
	fi
	# case "$BUTTON" in
	# 	1) pademelon-widgets ppd-dialog ;;
	# esac
}

volume_button () {
	case "$BUTTON" in
		1) media_menu ;;
		2) pactl set-sink-mute @DEFAULT_SINK@ toggle ;;
		3) audio_menu ;;
		4) pactl set-sink-volume @DEFAULT_SINK@ +5% \
			&& canberra-gtk-play -i audio-volume-change ;;
		5) pactl set-sink-volume @DEFAULT_SINK@ -5% \
			&& canberra-gtk-play -i audio-volume-change ;;
	esac
}

date_button () {
	case "$BUTTON" in
		3) system_menu ;;
		*) notify-send "$(date)" ;;
	esac
}


### STATUS BLOCKS

volume_block () {
	if pa_muted; then
		printf 'volume: muted'
	else
		printf 'volume: %s' "$(pa_volume)"
	fi
}

battery_block () {
	if [ -e "$BATTERY_PATH" ]; then
		status="$(cat "$BATTERY_PATH/status")"
		if [ "$status" = 'Charging' ]; then
			printf 'charging: %s' "$(cat "$BATTERY_PATH/capacity")%"
		else
			printf 'battery: %s' "$(cat "$BATTERY_PATH/capacity")%"
		fi
	else
		echo "plugged in"
	fi
}

date_block () {
	printf 'date: %s' "$(date +%H:%M)"
}

blocks () {
	printf "%s$SEPARATOR" "$(volume_block)"
	printf "%s$SEPARATOR" "$(battery_block)"
	printf "%s" "$(date_block)"
}

update_blocks () {
	mars-relay set-status "$(blocks)"
}


loop () {
	(pa_loop) &

	while true; do
		update_blocks
		sleep 10
	done
}

if [ "$1" = "action" ]; then
	case "$BLOCK" in
		0) volume_button ;;
		1) battery_button ;;
		2) date_button ;;
		_) echo unhandled ;;
	esac
else
	loop
fi

touchegg.conf

<touchégg>

	<settings>
	</settings>


	<application name="All">


		<gesture type="SWIPE" fingers="3" direction="RIGHT">
			<action type="RUN_COMMAND">
				<repeat>false</repeat>
				<command>mars-relay switch-workspace-prev &</command>
				<on>begin</on>
			</action>
		</gesture>

		<gesture type="SWIPE" fingers="3" direction="LEFT">
			<action type="RUN_COMMAND">
				<repeat>false</repeat>
				<command>mars-relay switch-workspace-next &</command>
				<on>begin</on>
			</action>
		</gesture>

		<gesture type="SWIPE" fingers="3" direction="DOWN">
			<action type="RUN_COMMAND">
				<repeat>false</repeat>
				<command>mars-relay menu &</command>
				<on>begin</on>
			</action>
		</gesture>
	</application>
</touchégg>

wallpaper-daemon.sh

Dependencies:

  • xev
  • xwallpaper
#!/bin/sh

WALLPAPER_FILE="$HOME/.background-image"

xwallpaper --zoom "$WALLPAPER_FILE"

xev -root -event randr \
	| grep --line-buffered XRROutputChangeNotifyEvent \
	| while read -r; do
	xwallpaper --zoom "$WALLPAPER_FILE"
done

xdg-xmenu.py

Dependencies:

  • python3
  • imagemagick
#!/usr/bin/env python3
#
# Original version by OliverLew: https://github.com/OliverLew/xdg-xmenu
#

from pathlib import Path
import argparse
import os, os.path
import re
import shutil
import subprocess
import sys
import time

XDG_DATA_HOME       = os.getenv('XDG_DATA_HOME', os.path.join(os.getenv('HOME'), '.local/share'))
XDG_CONFIG_HOME     = os.getenv('XDG_CONFIG_HOME', os.path.join(os.getenv('HOME'), '.config'))
XDG_DATA_DIRS       = set(os.getenv('XDG_DATA_DIRS', '/usr/share:/usr/local/share').split(':'))
APPLICATION_DIRS    = set([os.path.join(dir, 'applications') for dir in XDG_DATA_DIRS.copy()]\
                        + [os.path.join(XDG_DATA_HOME, 'applications')])
CACHE_DIR           = os.path.join(os.getenv('XDG_CACHE_HOME',
                        os.path.join(os.getenv('HOME'), '.cache')), os.path.basename(__file__))
TERMINAL            = os.getenv('TERMINAL', 'xterm')
EXECFILTER          = re.compile('%[a-zA-Z]')
ICON_SIZE           = 24
IMAGEMAGICK_OPTIONS = ['-background', 'none', '-size', '{0}x{0}'.format(ICON_SIZE)]
MULTIPLE_CATEGORIES = True
PRINT_IMAGES        = True
SORT_BY_CATEGORY    = True
COMPILE_IMAGES      = True
FORCE_REFRESH_CACHE = False
EXPIRY_DAYS         = 7
EXPIRY_TIME         = EXPIRY_DAYS * 24 * 60 * 60
UPDATEEXPIRED       = False
USER_ICON_THEME     = ''

imagefiles = []
icontheme = None
applications = {}
categories = {}


class Category:
    label = ''
    iconname = ''
    iconpath = ''
    match = re.compile('a^')
    apps = {}

    def format(self):
        out = ''
        if PRINT_IMAGES and self.iconpath != '':
            out += 'IMG:{}\t'.format(self.iconpath)
        out += self.label
        return out


class Application:
    categories = set()
    execute = ''
    genname = ''
    iconname = ''
    iconpath = ''
    name = ''
    terminal = False
    nodisplay = False
    onlyshowin = ''

    def format(self):
        out = ''
        if PRINT_IMAGES and self.iconpath != '':
            out += 'IMG:{}\t'.format(self.iconpath)
        out += self.name
        if self.genname != '':
            out += ' ({})'.format(self.genname)
        if self.terminal:
            out += '\t{} -e {}'.format(TERMINAL, self.execute)
        else:
            out += '\t{}'.format(self.execute)
        return out


def add_category(label, iconname, matchstr):
    newcat = Category()
    newcat.label = label
    newcat.apps = {}
    newcat.iconname = iconname
    newcat.match = re.compile(matchstr)
    categories[label] = newcat


def cache_is_expired():
    now = time.time()
    if not os.path.isdir(CACHE_DIR):
        return False
    for filename in os.listdir(CACHE_DIR):
        filestamp = os.stat(os.path.join(CACHE_DIR, filename)).st_mtime
        filecompare = now - EXPIRY_TIME
        if filestamp >= filecompare:
            return False
    return True


def dict_to_application(dictionary:dict):
    app = Application()
    app.name = dictionary['Name']
    app.execute = re.sub(EXECFILTER, '', dictionary['Exec'])
    if 'GenericName' in dictionary:
        app.genname = dictionary['GenericName']
    if 'Icon' in dictionary and PRINT_IMAGES:
        app.iconname = os.path.basename(dictionary['Icon'])
        set_icon(app)
    if 'Categories' in dictionary:
        app.categories = dictionary['Categories'].split(';')
    if 'Terminal' in dictionary:
        app.terminal = dictionary['Terminal'] in ('true', 'True')
    if 'NoDisplay' in dictionary:
        app.nodisplay = dictionary['NoDisplay'] in ('true', 'True')
    if 'OnlyShowIn' in dictionary:
        app.onlyshowin = dictionary['OnlyShowIn'].split(';')

    if SORT_BY_CATEGORY:
        added = False
        for appcat in app.categories:
            if appcat in categories:
                if not app in categories[appcat].apps:
                    categories[appcat].apps[app.name] = app
                added = True
            else:
                for cat in categories.values():
                    if cat.match.match(appcat):
                        cat.apps[app.name] = app
                        added = True
                        break
            if added and not MULTIPLE_CATEGORIES:
                break
        if not added:
            categories['Other'].apps[app.name] = app

    return app


def get_image_files():
    filelist = []
    for icondir in [os.path.join(dd, 'icons') for dd in XDG_DATA_DIRS]:
        for root, dirs, files in os.walk(icondir):
            for f in files:
                file = os.path.join(root, f)
                if os.path.isfile(file):
                    filelist.append(file)
    return filelist


def get_icon_theme():
    if USER_ICON_THEME != '':
        return USER_ICON_THEME
    path = os.path.join(XDG_CONFIG_HOME, 'gtk-3.0/settings.ini')
    if os.path.isfile(path):
        file = open(path, 'r')
        for line in file.readlines():
            if 'gtk-icon-theme-name' in line:
                return line.strip().split('=')[1]
    return ''


def load_desktop_file(filepath):
    file = open(filepath, 'r')
    application_dict = {}
    for line in file.readlines():
        try:
            key, value = line.strip().split('=', 1)
            # cancel before additional options are added
            if key in application_dict:
                break
            application_dict[key] = value.strip()
        except: pass
    try:
        application = dict_to_application(application_dict)
        applications[application.name] = application
    except KeyError: pass


def load_desktop_files():
    for directory in APPLICATION_DIRS:
        if not os.path.isdir(directory):
            continue
        for subfile in os.listdir(directory):
            filepath = os.path.join(directory, subfile)
            if os.path.islink(filepath):
                filepath = os.readlink(filepath)
            if os.path.isfile(filepath):
                load_desktop_file(filepath)


def load_icon(name, ext, path):
    if not os.path.isdir(CACHE_DIR):
        os.makedirs(CACHE_DIR)
    dest = os.path.join(CACHE_DIR, name + '.png')
    if ext in ('png'):
        shutil.copyfile(path, dest)
    else:
        proc = subprocess.Popen(['convert'] + IMAGEMAGICK_OPTIONS + [path, dest], stdout=subprocess.PIPE)
        proc.wait()
    if os.path.isfile(dest):
        return dest
    else:
        return ''


def set_icon(entity):
    global imagefiles, icontheme
    name = entity.iconname
    if not FORCE_REFRESH_CACHE and (os.path.isfile(os.path.join(CACHE_DIR, name + '.png'))):
        entity.iconpath = os.path.join(CACHE_DIR, name + '.png')
    elif not FORCE_REFRESH_CACHE and (os.path.isfile(os.path.join(CACHE_DIR, name + '.notfound'))):
        pass
    elif COMPILE_IMAGES or FORCE_REFRESH_CACHE:
        image_found = False
        if imagefiles == []:
            imagefiles = get_image_files()
        if icontheme == None:
            icontheme = get_icon_theme()
        for imagefile in imagefiles:
            for ext in ['png', 'svg']:
                if imagefile.endswith('/{}.{}'.format(name, ext)) \
                        and (entity.iconpath == '' or (type(icontheme) == str and icontheme in imagefile)):
                    entity.iconpath = load_icon(name, ext, imagefile)
                    print("{}.png <= {}".format(name, imagefile), file=sys.stderr)
                    image_found = True
        if not image_found:
            if not os.path.exists(CACHE_DIR):
                os.makedirs(CACHE_DIR)
            Path(os.path.join(CACHE_DIR, name + '.notfound')).touch()
            print("=> {}.notfound".format(name), file=sys.stderr)


def format_xmenu(applications):
    if SORT_BY_CATEGORY:
        if PRINT_IMAGES:
            for cat in categories.values():
                set_icon(cat)
        for cat in categories.values():
            visible_apps = [app for app in cat.apps.values() if not app.nodisplay and app.onlyshowin == '']
            if len(visible_apps) > 0:
                print(cat.format())
                for app in sorted(visible_apps, key=lambda x: x.name):
                    print('\t{}'.format(app.format()))
    else:
        for app in sorted(applications, key=lambda x: x.name):
            print(app.format())


def parse_args():
    parser = argparse.ArgumentParser(description='Generate application menu in xmenu format')
    parser.add_argument('-a', '--applications', help='don\'t sort applications by category',
                        action='store_false', dest='sort_by_category')
    parser.add_argument('-e', '--expired', help='refresh image cache if older than {} days'.format(EXPIRY_DAYS),
                        action='store_true', dest='updateexpired')
    parser.add_argument('-f', '--force', help='force refresh image cache',
                        action='store_true', dest='force_refresh_cache')
    parser.add_argument('-i', '--icontheme', help='select a custom icon theme', nargs=1, type=str,
                        default=[''],  metavar='THEME', action='store', dest='user_icon_theme')
    parser.add_argument('-l', '--lazy', help='don\'t compile images on demand (this is way faster)',
                        action='store_false', dest='compile_images')
    parser.add_argument('-m', '--multiple', help='add applications to multiple categories',
                        action='store_true', dest='multiple_categories')
    parser.add_argument('-t', '--text', help='no image output (implies -l)',
                        action='store_false', dest='print_images')
    return parser.parse_args()


add_category('Multimedia',    'applications-multimedia',    '(Audio|Video).*')
add_category('Development',   'applications-development',   'Development')
add_category('Education',     'applications-education',     'Education')
add_category('Games',         'applications-games',         'Game')
add_category('Graphics',      'applications-graphics',      'Graphics')
add_category('Internet',      'applications-internet',      'Network')
add_category('Office',        'applications-office',        'Office')
add_category('Science',       'applications-science',       'Science')
add_category('Settings',      'preferences-desktop',        'Settings')
add_category('System',        'applications-system',        'System')
add_category('Accessories',   'applications-accessories',   'Utility')
add_category('Others',        'applications-other',         'a^')


if __name__ == '__main__':
    args = parse_args()
    MULTIPLE_CATEGORIES = args.multiple_categories
    SORT_BY_CATEGORY = args.sort_by_category
    PRINT_IMAGES = args.print_images
    COMPILE_IMAGES = args.compile_images and PRINT_IMAGES
    UPDATEEXPIRED = args.updateexpired
    FORCE_REFRESH_CACHE = args.force_refresh_cache or (UPDATEEXPIRED and cache_is_expired())
    USER_ICON_THEME = args.user_icon_theme[0]
    load_desktop_files()
    format_xmenu(list(applications.values()))