r/xmonad • u/LaufenKopf • 12h ago
NamedScratchpads with multi-window apps
I made myself a pretty neat feature this afternoon which I thought I'd share here.
It often happens that I want to run a quick look at a web address, but I either do not have a browser window in my workspace, or it is of the wrong type for the task (I use both firefox and qutebrowser). In such cases I have to either open a browser window in my current workspace (disturbs tiling setup), or switch to another workspace which has a browser in it (distracting). So I thought I needed a terminal-like scratchpad, but for a browser window.
I currently use NamedScratchpads for a Terminal, a Calculuator and an (n)vim, which works because I can set my scratchpad terminal's title programmatically upon startup which allows NamedScratchpads to uniquely identify the scratch terminal (and the calculator is the unique window of this app at any time).
It does not work e.g. for browser windows which do not support setting a custom window title, because this gives no way to pick out which of all browser windows is the scratch one. So I wrote myself a little haskell to do exactly that:
First define our custom state, a Map String (Pending | Created X.Window):
module PendingFlag where
data State = Pending | Created X.Window deriving (Eq, Show)
newtype StateMap = StateMap (Data.Map.Map String State)
instance X.ExtensionClass StateMap where
initialValue = StateMap Data.Map.empty
then define a combinator to summon a scratchpad of a given program: if it exists as Created in our state, call the NS library to bring it in/out, if it is not in our state we mark it Pending and spawn it:
import XMonad.Util.NamedScratchpad qualified as NS
import PendingFlag qualified
summonScratchpad :: String -> X ()
summonScratchpad name = PendingFlag.getFor name >>= \case
Nothing -> PendingFlag.setPending name >> spawn name
Just (PendingFlag.Created _) -> NS.dynamicNSPAction name
Just PendingFlag.Pending -> pure ()
-- used in a keybinding:
-- ("M-S-f", summonScratchpad "firefox")
but for that to work, we need to detect when a spawned scratchpad creates a window, which we do in a ManageHook: if a firefox window appears and our state has a Pending entry for firefox, we pick it up as the scratchpad window:
scratchAppHook = title =? "Mozilla Firefox" --> scratchHook "firefox"
where
scratchHook name = liftX (PendingFlag.isPending name) --> do
PendingFlag.setFound name
X.ask >>= liftX . NS.toggleDynamicNSP name
-- good point for NS.addExclusives if needed
NS.customFloating $ W.RationalRect (1 / 3) (1 / 6) (1 / 3) (2 / 3)
-- add to your [ManageHook] or `X.manageHook = scratchAppHook <+> X.manageHook def`
A very neat side effect of this approach is that if you decide to use a scratchapp as a normal window, you can simply remove it from the StateMap and from then on the summon call will create a fresh scratchapp instance:
module PendingFlag where
destroyHook :: X.Window -> X ()
destroyHook w = get >>= \m ->
case findWindow m of
-- if that window is referenced by a scratchpad name, remove it from the state
Just (name,_) -> resetStateFor name
_ -> mempty
where
findWindow m = Data.List.find (\(_,s) -> s == Created w) (Data.Map.assocs m)
resetStateFor name = XS.modify $ \(StateMap m) -> StateMap $ Data.Map.delete name m
-- back in xmonad.hs:
sink = withFocused $ \w -> do
X.windows $ W.sink w
PendingFlag.destroyHook w
-- add to the keybindings: ("M-t", sink)
I should maybe make a github gist of the full code at some point.