nixos: add river xmonad session

This commit is contained in:
2026-04-30 02:53:57 -07:00
parent 90bd377335
commit 8d346bc37e
8 changed files with 562 additions and 1 deletions

View File

@@ -0,0 +1,322 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
module Main where
import Control.Concurrent (forkIO)
import Data.Bits ((.&.), complement)
import Data.Function (on)
import Data.List (minimumBy)
import qualified Data.Map.Strict as M
import Data.Maybe (mapMaybe)
import Graphics.X11.ExtraTypes.XF86
import System.IO (hFlush, stdout)
import System.Process (spawnCommand, waitForProcess)
import XMonad
import XMonad.Layout.Accordion
import XMonad.Layout.Cross
import XMonad.Layout.Grid
import XMonad.Layout.MultiColumns
import qualified XMonad.Layout.Renamed as RN
import XMonad.River.WindowManager
import XMonad.River.WindowManager.Wayland
import qualified XMonad.StackSet as W
data Direction = DirectionUp | DirectionDown | DirectionLeft | DirectionRight
deriving (Eq, Show)
main :: IO ()
main = do
let bindings = keyBindings
configLog $ "starting imalison-river-xmonad with keybindings=" ++ show (length bindings)
initialState <- initialRiverWMState (defaultRiverWMConfig riverLayouts)
runRiverWMWaylandConfig
RiverWMWaylandConfig
{ riverWMWaylandInitialState = initialState
, riverWMWaylandKeyBindings = bindings
}
riverLayouts =
renamed "4 Columns" (multiCol [1, 1, 1] 2 0.0 (-0.5))
||| renamed "3 Columns" (multiCol [1, 1] 2 0.01 (-0.5))
||| renamed "Grid" Grid
||| renamed "Large Main" (Tall 1 (3 / 100) (3 / 4))
||| renamed "2 Columns" (Tall 1 (3 / 100) (1 / 2))
||| renamed "Mirror 2 Columns" (Mirror (Tall 1 (3 / 100) (1 / 2)))
||| renamed "Accordion" Accordion
||| renamed "Cross" simpleCross
||| Full
where
renamed name = RN.renamed [RN.Replace name]
keyBindings
:: (LayoutClass l Window, Read (l Window))
=> [RiverWMWaylandKeyBinding l]
keyBindings =
addHyperChordBindings hyper hyperChord $
concat
[ directionalBindings super directionalFocus
, directionalBindings (super .|. shift) directionalSwap
, workspaceBindings
, layoutBindings
, spawnBindings
, mediaBindings
]
directionalBindings
:: RiverWMWaylandModifiers
-> (Direction -> RiverWMWaylandAction l)
-> [RiverWMWaylandKeyBinding l]
directionalBindings mods command =
[ key mods xK_w (command DirectionUp)
, key mods xK_s (command DirectionDown)
, key mods xK_a (command DirectionLeft)
, key mods xK_d (command DirectionRight)
]
workspaceBindings
:: [RiverWMWaylandKeyBinding l]
workspaceBindings =
[ key (mods .|. super) keysym (stackAction $ command workspace)
| (workspace, keysym) <- zip (map show [(1 :: Int) .. 9]) [xK_1 .. xK_9]
, (command, mods) <-
[ (W.greedyView, noMods)
, (W.shift, shift)
, (\workspaceId stackSet -> W.greedyView workspaceId (W.shift workspaceId stackSet), ctrl)
]
]
layoutBindings
:: (LayoutClass l Window, Read (l Window))
=> [RiverWMWaylandKeyBinding l]
layoutBindings =
[ key super xK_space (layoutAction NextLayout)
, key super xK_bracketleft (layoutAction Shrink)
, key super xK_bracketright (layoutAction Expand)
, key super xK_comma (layoutAction (IncMasterN 1))
, key super xK_period (layoutAction (IncMasterN (-1)))
]
spawnBindings
:: [RiverWMWaylandKeyBinding l]
spawnBindings =
[ key super xK_Return (spawnAction "ghostty --gtk-single-instance=false")
, key super xK_p (spawnAction "rofi -show drun -show-icons")
, key (super .|. shift) xK_p (spawnAction "rofi -show run")
, key (super .|. alt) xK_c (spawnAction "google-chrome-stable")
, key super xK_e (spawnAction "emacsclient --eval '(emacs-everywhere)'")
, key super xK_v (spawnAction "wl-paste | wtype -")
, key hyper xK_v (spawnAction "rofi -modi 'clipboard:greenclip print' -show clipboard")
, key hyper xK_p (spawnAction "rofi-pass")
, key hyper xK_h (spawnAction "rofi_shutter")
, key hyper xK_c (spawnAction "shell_command.sh")
, key hyper xK_x (spawnAction "rofi_command.sh")
, key (hyper .|. shift) xK_l (spawnAction "loginctl lock-session")
, key hyper xK_k (spawnAction "rofi_kill_process.sh")
, key (hyper .|. shift) xK_k (spawnAction "rofi_kill_all.sh")
, key hyper xK_r (spawnAction "rofi-systemd")
, key hyper xK_9 (spawnAction "start_synergy.sh")
, key hyper xK_backslash (spawnAction "$HOME/dotfiles/dotfiles/lib/functions/mpg341cx_input toggle")
, key hyper xK_i (spawnAction "rofi_select_input.hs")
, key hyper xK_o (spawnAction "rofi_paswitch")
, key hyper xK_w (spawnAction "rofi_wallpaper.sh")
, key hyper xK_y (spawnAction "rofi_agentic_skill")
]
mediaBindings
:: [RiverWMWaylandKeyBinding l]
mediaBindings =
[ key super xK_semicolon (spawnAction "playerctl play-pause")
, key noMods xF86XK_AudioPause (spawnAction "playerctl play-pause")
, key noMods xF86XK_AudioPlay (spawnAction "playerctl play-pause")
, key super xK_l (spawnAction "playerctl next")
, key noMods xF86XK_AudioNext (spawnAction "playerctl next")
, key super xK_j (spawnAction "playerctl previous")
, key noMods xF86XK_AudioPrev (spawnAction "playerctl previous")
, key noMods xF86XK_AudioRaiseVolume (spawnAction "set_volume --unmute --change-volume +5")
, key noMods xF86XK_AudioLowerVolume (spawnAction "set_volume --unmute --change-volume -5")
, key noMods xF86XK_AudioMute (spawnAction "set_volume --toggle-mute")
, key super xK_i (spawnAction "set_volume --unmute --change-volume +5")
, key super xK_k (spawnAction "set_volume --unmute --change-volume -5")
, key super xK_u (spawnAction "set_volume --toggle-mute")
, key (hyper .|. shift) xK_q (spawnAction "toggle_mute_current_window.sh")
, key (hyper .|. ctrl) xK_q (spawnAction "toggle_mute_current_window.sh only")
, key noMods xF86XK_MonBrightnessUp (spawnAction "brightness.sh up")
, key noMods xF86XK_MonBrightnessDown (spawnAction "brightness.sh down")
]
key
:: RiverWMWaylandModifiers
-> KeySym
-> RiverWMWaylandAction l
-> RiverWMWaylandKeyBinding l
key modifiers keysym action =
RiverWMWaylandKeyBinding
{ riverWMWaylandKeyModifiers = modifiers
, riverWMWaylandKeyKeysym = fromIntegral keysym
, riverWMWaylandKeyAction = action
}
spawnAction :: String -> RiverWMWaylandAction l
spawnAction command state = do
configLog $ "spawn start: " ++ command
process <- spawnCommand (riverSpawnPrelude ++ command)
_ <- forkIO $ do
exitCode <- waitForProcess process
configLog $ "spawn exit: " ++ command ++ " -> " ++ show exitCode
pure ()
pure ([], state)
riverSpawnPrelude :: String
riverSpawnPrelude =
"XDG_RUNTIME_DIR=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}\"; "
++ "export XDG_RUNTIME_DIR; "
++ "if [ -z \"${WAYLAND_DISPLAY:-}\" ]; then "
++ "for socket in \"$XDG_RUNTIME_DIR\"/wayland-*; do "
++ "[ -S \"$socket\" ] || continue; "
++ "WAYLAND_DISPLAY=\"$(basename \"$socket\")\"; "
++ "break; "
++ "done; "
++ "fi; "
++ "export WAYLAND_DISPLAY=\"${WAYLAND_DISPLAY:-wayland-1}\"; "
++ "export XDG_CURRENT_DESKTOP=river; "
++ "export XDG_SESSION_DESKTOP=river-xmonad; "
++ "export XDG_SESSION_TYPE=wayland; "
++ "export IMALISON_SESSION_TYPE=wayland; "
++ "export IMALISON_WINDOW_MANAGER=river-xmonad; "
configLog :: String -> IO ()
configLog message = do
putStrLn $ "imalison-river-xmonad: " ++ message
hFlush stdout
layoutAction
:: (LayoutClass l Window, Read (l Window), Message message)
=> message
-> RiverWMWaylandAction l
layoutAction = handleRiverWMLayoutMessage
stackAction
:: (W.StackSet WorkspaceId (l Window) Window RiverWMOutputId ScreenDetail
-> W.StackSet WorkspaceId (l Window) Window RiverWMOutputId ScreenDetail)
-> RiverWMWaylandAction l
stackAction f state =
pure $ modifyRiverWMStackSet f state
directionalSwap :: Direction -> RiverWMWaylandAction l
directionalSwap direction =
stackAction $
case direction of
DirectionUp -> W.swapUp
DirectionLeft -> W.swapUp
DirectionDown -> W.swapDown
DirectionRight -> W.swapDown
directionalFocus :: Direction -> RiverWMWaylandAction l
directionalFocus direction state =
pure $ modifyRiverWMStackSet focusDirectionalWindow state
where
focusDirectionalWindow stackSet =
maybe stackSet (`W.focusWindow` stackSet) $
directionalTarget direction state
directionalTarget :: Direction -> RiverWMState l -> Maybe Window
directionalTarget direction RiverWMState{riverWMStackSet, riverWMWindows, riverWMWindowIds} = do
focused <- W.peek riverWMStackSet
focusedId <- M.lookup focused riverWMWindowIds
focusedRect <- riverWMWindowDesired =<< M.lookup focusedId riverWMWindows
let focusedCenter = rectCenter focusedRect
candidates =
[ (window, directionScore direction focusedCenter (rectCenter rect))
| (windowId, RiverWMWindowState{riverWMWindowXWindow = window, riverWMWindowDesired = Just rect}) <-
M.toList riverWMWindows
, windowId /= focusedId
]
viable = mapMaybe sequenceCandidate candidates
fst <$> minimumMaybeBy (compare `on` snd) viable
sequenceCandidate :: (a, Maybe b) -> Maybe (a, b)
sequenceCandidate (value, Just score) = Just (value, score)
sequenceCandidate (_, Nothing) = Nothing
rectCenter :: Rectangle -> (Double, Double)
rectCenter (Rectangle x y width height) =
( fromIntegral x + fromIntegral width / 2
, fromIntegral y + fromIntegral height / 2
)
directionScore :: Direction -> (Double, Double) -> (Double, Double) -> Maybe (Double, Double)
directionScore direction (fx, fy) (cx, cy) =
case direction of
DirectionUp | cy < fy -> Just (fy - cy, abs (cx - fx))
DirectionDown | cy > fy -> Just (cy - fy, abs (cx - fx))
DirectionLeft | cx < fx -> Just (fx - cx, abs (cy - fy))
DirectionRight | cx > fx -> Just (cx - fx, abs (cy - fy))
_ -> Nothing
minimumMaybeBy :: (a -> a -> Ordering) -> [a] -> Maybe a
minimumMaybeBy _ [] = Nothing
minimumMaybeBy compareFn xs = Just (minimumBy compareFn xs)
addHyperChordBindings
:: RiverWMWaylandModifiers
-> RiverWMWaylandModifiers
-> [RiverWMWaylandKeyBinding l]
-> [RiverWMWaylandKeyBinding l]
addHyperChordBindings hyperMask chordMask bindings =
bindings ++ M.elems chosen
where
existingKeys =
M.fromList
[ ((riverWMWaylandKeyModifiers binding, riverWMWaylandKeyKeysym binding), ())
| binding <- bindings
]
chordBinding binding@RiverWMWaylandKeyBinding{riverWMWaylandKeyModifiers} =
binding
{ riverWMWaylandKeyModifiers =
(riverWMWaylandKeyModifiers .&. complement hyperMask) .|. chordMask
}
candidates =
[ ( (riverWMWaylandKeyModifiers chorded, riverWMWaylandKeyKeysym chorded)
, (score (riverWMWaylandKeyModifiers binding), chorded)
)
| binding <- bindings
, riverWMWaylandKeyModifiers binding .&. hyperMask /= 0
, let chorded = chordBinding binding
, M.notMember (riverWMWaylandKeyModifiers chorded, riverWMWaylandKeyKeysym chorded) existingKeys
]
chosen =
fmap snd $
foldl' keepBest M.empty candidates
keepBest selected (bindingKey, candidate@(candidateScore, _binding)) =
case M.lookup bindingKey selected of
Nothing -> M.insert bindingKey candidate selected
Just (bestScore, _) ->
if candidateScore < bestScore
then M.insert bindingKey candidate selected
else selected
score modifiers =
length $
filter (/= 0)
[ modifiers .&. shift
, modifiers .&. ctrl
, modifiers .&. alt
, modifiers .&. hyper
, modifiers .&. super
, modifiers .&. riverWMWaylandModifierMod5
]
noMods, shift, ctrl, alt, hyper, super, hyperChord :: RiverWMWaylandModifiers
noMods = riverWMWaylandModifierNone
shift = riverWMWaylandModifierShift
ctrl = riverWMWaylandModifierCtrl
alt = riverWMWaylandModifierAlt
hyper = riverWMWaylandModifierHyper
super = riverWMWaylandModifierSuper
hyperChord = ctrl .|. alt .|. super

View File

@@ -0,0 +1,18 @@
cabal-version: 2.4
name: imalison-river-xmonad
version: 0.1.0.0
license: BSD-3-Clause
author: Ivan Malison
maintainer: IvanMalison@gmail.com
build-type: Simple
executable imalison-river-xmonad
main-is: Main.hs
build-depends: base >= 4.12 && < 5
, containers
, process
, X11
, xmonad
, xmonad-contrib
ghc-options: -Wall -Wno-unused-do-bind -Wno-deprecations -Wno-missing-signatures -Wno-name-shadowing
default-language: Haskell2010

View File

@@ -0,0 +1,13 @@
_: pkgs: {
haskellPackages = pkgs.haskellPackages.override (old: {
overrides = pkgs.lib.composeExtensions (old.overrides or (_: _: {})) (self: _super: {
imalison-river-xmonad = self.callCabal2nix "imalison-river-xmonad" (
pkgs.lib.sourceByRegex ./.
[
"Main.hs"
"imalison-river-xmonad.cabal"
]
) { };
});
});
}

View File

@@ -37,6 +37,7 @@
./rabbitmq.nix ./rabbitmq.nix
./quickshell.nix ./quickshell.nix
./remote-hyprland.nix ./remote-hyprland.nix
./river-xmonad.nix
./secrets.nix ./secrets.nix
./ssh.nix ./ssh.nix
./sni.nix ./sni.nix

29
nixos/flake.lock generated
View File

@@ -1808,7 +1808,8 @@
"systems": "systems_4", "systems": "systems_4",
"vscode-server": "vscode-server", "vscode-server": "vscode-server",
"xmonad": "xmonad_2", "xmonad": "xmonad_2",
"xmonad-contrib": "xmonad-contrib_2" "xmonad-contrib": "xmonad-contrib_2",
"xmonad-river": "xmonad-river"
} }
}, },
"rust-analyzer-src": { "rust-analyzer-src": {
@@ -2120,6 +2121,32 @@
"type": "github" "type": "github"
} }
}, },
"xmonad-river": {
"inputs": {
"flake-utils": [
"flake-utils"
],
"git-ignore-nix": [
"git-ignore-nix"
],
"nixpkgs": [
"nixpkgs"
],
"unstable": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1777476488,
"narHash": "sha256-2Adfi0BDJEM/ITFbXJkS21wVQ96oNXLLUh4HsHonCl4=",
"path": "/home/imalison/Projects/xmonad-river",
"type": "path"
},
"original": {
"path": "/home/imalison/Projects/xmonad-river",
"type": "path"
}
},
"xmonad_2": { "xmonad_2": {
"inputs": { "inputs": {
"flake-utils": [ "flake-utils": [

View File

@@ -147,6 +147,16 @@
}; };
}; };
xmonad-river = {
url = "path:/home/imalison/Projects/xmonad-river";
inputs = {
nixpkgs.follows = "nixpkgs";
unstable.follows = "nixpkgs";
flake-utils.follows = "flake-utils";
git-ignore-nix.follows = "git-ignore-nix";
};
};
xmonad-contrib = { xmonad-contrib = {
url = "github:IvanMalison/xmonad-contrib/withMyChanges"; url = "github:IvanMalison/xmonad-contrib/withMyChanges";
inputs = { inputs = {

View File

@@ -9,6 +9,7 @@
myModules.base.enable = true; myModules.base.enable = true;
myModules.desktop.enable = true; myModules.desktop.enable = true;
myModules.xmonad.enable = true; myModules.xmonad.enable = true;
myModules.riverXmonad.enable = true;
myModules.extra.enable = false; myModules.extra.enable = false;
myModules.code.enable = true; myModules.code.enable = true;
myModules.games.enable = false; myModules.games.enable = false;

169
nixos/river-xmonad.nix Normal file
View File

@@ -0,0 +1,169 @@
{
config,
inputs,
lib,
makeEnable,
pkgs,
...
}:
let
session = import ./session-variables.nix;
riverXmonadPkgs = pkgs.extend (
lib.composeManyExtensions [
inputs.xmonad-river.overlay
inputs.xmonad-contrib.overlay
(import ../dotfiles/config/river-xmonad/overlay.nix)
]
);
riverXmonadPackage = riverXmonadPkgs.haskellPackages.imalison-river-xmonad;
riverRofi = pkgs.writeShellScriptBin "rofi" ''
exec ${pkgs.rofi}/bin/rofi -normal-window "$@"
'';
riverInit = pkgs.writeShellScript "river-xmonad-init" ''
log_dir="''${XDG_STATE_HOME:-$HOME/.local/state}/river-xmonad"
mkdir -p "$log_dir"
echo "[$(${pkgs.coreutils}/bin/date --iso-8601=seconds)] river init start"
export PATH=${lib.makeBinPath [ riverRofi ]}:$PATH
export XDG_CURRENT_DESKTOP=river
export XDG_SESSION_DESKTOP=river-xmonad
export XDG_SESSION_TYPE=wayland
export ${session.sessionType}=wayland
export ${session.windowManager}=river-xmonad
systemctl --user stop hyprland-session.target || true
systemctl --user unset-environment HYPRLAND_INSTANCE_SIGNATURE || true
${pkgs.dbus}/bin/dbus-update-activation-environment --systemd \
WAYLAND_DISPLAY DISPLAY XAUTHORITY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE \
${session.sessionType} ${session.windowManager} DBUS_SESSION_BUS_ADDRESS PATH || true
systemctl --user set-environment \
"WAYLAND_DISPLAY=''${WAYLAND_DISPLAY:-}" \
"DISPLAY=''${DISPLAY:-}" \
"XAUTHORITY=''${XAUTHORITY:-}" \
XDG_CURRENT_DESKTOP=river \
XDG_SESSION_DESKTOP=river-xmonad \
XDG_SESSION_TYPE=wayland \
${session.sessionType}=wayland \
${session.windowManager}=river-xmonad \
"DBUS_SESSION_BUS_ADDRESS=''${DBUS_SESSION_BUS_ADDRESS:-}" \
"PATH=$PATH" || true
systemctl --user import-environment \
WAYLAND_DISPLAY DISPLAY XAUTHORITY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE \
${session.sessionType} ${session.windowManager} DBUS_SESSION_BUS_ADDRESS PATH || true
systemctl --user start river-xmonad-session.target || true
echo "[$(${pkgs.coreutils}/bin/date --iso-8601=seconds)] exec imalison-river-xmonad"
exec ${riverXmonadPackage}/bin/imalison-river-xmonad
'';
riverSession = pkgs.writeShellScriptBin "river-xmonad-session" ''
log_dir="''${XDG_STATE_HOME:-$HOME/.local/state}/river-xmonad"
mkdir -p "$log_dir"
log_file="$log_dir/session.log"
exec >>"$log_file" 2>&1
echo
echo "===== river-xmonad session start: $(${pkgs.coreutils}/bin/date --iso-8601=seconds) ====="
export XDG_CURRENT_DESKTOP=river
export XDG_SESSION_DESKTOP=river-xmonad
export XDG_SESSION_TYPE=wayland
export ${session.sessionType}=wayland
export ${session.windowManager}=river-xmonad
export PATH=${lib.makeBinPath [ riverRofi ]}:$PATH
echo "river-xmonad: environment before river"
env | ${pkgs.coreutils}/bin/sort
systemctl --user stop hyprland-session.target || true
systemctl --user unset-environment HYPRLAND_INSTANCE_SIGNATURE || true
${pkgs.river}/bin/river -c ${lib.escapeShellArg "${riverInit}"}
status=$?
echo "river-xmonad: river exited with status $status at $(${pkgs.coreutils}/bin/date --iso-8601=seconds)"
systemctl --user stop river-xmonad-session.target || true
exit "$status"
'';
riverDiagnostics = pkgs.writeShellScriptBin "river-xmonad-diagnostics" ''
set -u
log_dir="''${XDG_STATE_HOME:-$HOME/.local/state}/river-xmonad"
echo "river-xmonad diagnostics: $(${pkgs.coreutils}/bin/date --iso-8601=seconds)"
echo
echo "== processes =="
${pkgs.procps}/bin/pgrep -a 'river|imalison-river-xmonad|rofi|ghostty|hyprpaper|xsettingsd|picom|autorandr' || true
echo
echo "== user manager environment =="
systemctl --user show-environment | ${pkgs.coreutils}/bin/sort | ${pkgs.gnugrep}/bin/grep -E '^(HYPR|IMALISON|XDG_CURRENT_DESKTOP|XDG_SESSION_DESKTOP|XDG_SESSION_TYPE|WAYLAND_DISPLAY|DISPLAY)=' || true
echo
echo "== session unit guards =="
systemctl --user cat river-xmonad-session.target dunst.service hyprpaper.service hyprland-session.target xsettingsd.service picom.service autorandr.service 2>/dev/null \
| ${pkgs.gnugrep}/bin/grep -E '^(# |\\[Unit\\]|Description=|ConditionEnvironment=|PartOf=|After=|WantedBy=|ExecStart=|\\[Install\\])' || true
echo
echo "== recent user journal =="
journalctl --user -b --since '10 minutes ago' --no-pager \
| ${pkgs.gnugrep}/bin/grep -Ei 'river|xmonad|rofi|ghostty|hyprpaper|xsettingsd|picom|autorandr|failed|error|segfault|core-dump' || true
echo
if [ -f "$log_dir/session.log" ]; then
echo "== $log_dir/session.log tail =="
${pkgs.coreutils}/bin/tail -n 250 "$log_dir/session.log"
else
echo "no session log at $log_dir/session.log"
fi
'';
riverSessionPackage = (pkgs.writeTextFile {
name = "river-xmonad-session";
destination = "/share/wayland-sessions/river-xmonad.desktop";
text = ''
[Desktop Entry]
Name=river-xmonad
Comment=river with xmonad as its external window manager
Exec=${riverSession}/bin/river-xmonad-session
Type=Application
DesktopNames=river
'';
}).overrideAttrs (_old: {
passthru.providedSessions = [ "river-xmonad" ];
});
in
makeEnable config "myModules.riverXmonad" false {
services.displayManager.sessionPackages = [
riverSessionPackage
];
home-manager.sharedModules = [
{
systemd.user.targets.river-xmonad-session = {
Unit = {
Description = "river-xmonad session";
ConditionEnvironment = session.riverXmonad;
BindsTo = [ "graphical-session.target" ];
Wants = [ "graphical-session-pre.target" ];
After = [ "graphical-session-pre.target" ];
Before = [ "graphical-session.target" ];
};
};
}
];
environment.systemPackages = with pkgs; [
brightnessctl
river
riverDiagnostics
riverXmonadPackage
wl-clipboard
wtype
];
}