Skip to content

Instantly share code, notes, and snippets.

@jecaro
Created April 15, 2024 20:00
Show Gist options
  • Save jecaro/a6684da4f6e5891211f19d2a7c959b44 to your computer and use it in GitHub Desktop.
Save jecaro/a6684da4f6e5891211f19d2a7c959b44 to your computer and use it in GitHub Desktop.
An XMonad layout which tiles the windows in columns
-- Copyright © 2024 Jean-Charles Quillet
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy
-- of this software and associated documentation files (the “Software”), to
-- deal in the Software without restriction, including without limitation the
-- rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-- sell copies of the Software, and to permit persons to whom the Software is
-- furnished to do so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in
-- all copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
-- IN THE SOFTWARE.
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
-- | 'Columns' is a layout which tiles the windows in columns.
module Columns
( ColumnsLayout (..),
Focus (..),
Move (..),
Resize (..),
focusDown,
focusUp,
)
where
import Control.Applicative ((<|>))
import Control.Arrow (Arrow (first), second)
import Control.Monad (guard)
import Control.Monad.State (modify)
import Control.Monad.Trans.Maybe (MaybeT (..))
import Data.Foldable (Foldable (..))
import Data.List (scanl', singleton)
import Data.Maybe (listToMaybe)
import Data.Ratio ((%))
import XMonad
( LayoutClass (..),
Message,
Rectangle (..),
SomeMessage,
Window,
WindowSet,
X,
XState (..),
fromMessage,
gets,
scaleRationalRect,
sendMessage,
)
import qualified XMonad.Operations as O
import XMonad.StackSet
( RationalRect (..),
Screen (..),
Stack (..),
StackSet (..),
integrate,
peek,
)
import qualified XMonad.StackSet as StackSet
-- | The windows can be moved in every directions.
--
-- Horizontally, a window alone in its column cannot be moved before the first
-- or after the last column. If not alone, moving the window outside those
-- limits will create a new column.
-- The windows can also be moved vertically in their column.
data Move = MoveLeft | MoveRight | MoveUp | MoveDown deriving (Show, Read)
instance Message Move
-- | The windows can be resized in every directions.
--
-- When resizing horizontally:
-- - if the window to be resized is not in the last column
-- - then the right side of the window will be moved
-- - the last column will compensate the size change
-- - if the window is in the last column
-- - then the left side of the window will be moved
-- - the column on the left of the current one will compensate the size change
--
-- The same applies when resizing vertically using the bottom side of the
-- window unless it is the last window in the column in which case we use the
-- top side.
data Resize
= VerticalShrink
| VerticalExpand
| HorizontalShrink
| HorizontalExpand
deriving (Show, Read)
instance Message Resize
-- | The layout handles focus change messages.
--
-- Built-in focus cannot be used here because XMonad does not make it easy to
-- change the order of windows in the focus list.
data Focus = FocusUp | FocusDown
deriving (Show, Read)
instance Message Focus
-- | A column is a list of windows with their relative vertical dimensions.
type Column = [(Rational, Window)]
-- | The layout is a list of 'Column' with their relative horizontal dimensions.
type Columns = [(Rational, Column)]
newtype ColumnsLayout a = Columns Columns
deriving (Show, Read)
instance LayoutClass ColumnsLayout Window where
description _ = layoutDescription
emptyLayout _ _ = pure ([], Just $ Columns [])
doLayout (Columns columns) rectangle stack =
pure (rectangles, Just (Columns columns'))
where
hackedColumns = hackForTabs columns stack
columns' = updateWindowList hackedColumns stack
rectangles = toRectangles rectangle' columns'
-- If there is only one window and the screen is big, we reduce the
-- destination rectangle to put the window on the center of the screen.
rectangle'
| rect_width rectangle > 2000 && (length . toList $ stack) == 1 =
scaleRationalRect rectangle singleColumnRR
| otherwise = rectangle
singleColumnWidth = 1 % 2
singleColumnOffset = (1 - singleColumnWidth) / 2
singleColumnRR = RationalRect singleColumnOffset 0 singleColumnWidth 1
handleMessage layout@(Columns columns) message = do
mbStack <- runMaybeT $ handleFocus' =<< getStack
changedFocus <- traverse updateStack' mbStack
movedOrResized <-
runMaybeT $
Columns
<$> (handleMoveOrResize' =<< peekFocus)
pure $ movedOrResized <|> changedFocus
where
getStack = MaybeT . gets $ StackSet.stack . workspace . current . windowset
handleFocus' = hoistMaybe . handleFocus columns message
-- A 'Just' needs to be return for the new stack to be taken into account
updateStack' s = modify (setStack s) >> pure layout
peekFocus = MaybeT . gets $ peek . windowset
handleMoveOrResize' = hoistMaybe . handleMoveOrResize columns message
hoistMaybe = MaybeT . pure
layoutDescription :: String
layoutDescription = "Columns"
focusUp :: X ()
focusUp =
sendMsgOrOnWindowsSet FocusUp StackSet.focusUp
=<< getCurrentLayoutDescription
focusDown :: X ()
focusDown =
sendMsgOrOnWindowsSet FocusDown StackSet.focusDown
=<< getCurrentLayoutDescription
sendMsgOrOnWindowsSet :: (Message a) => a -> (WindowSet -> WindowSet) -> String -> X ()
sendMsgOrOnWindowsSet message f description'
| description' == layoutDescription = sendMessage message
| otherwise = O.windows f
getCurrentLayoutDescription :: X String
getCurrentLayoutDescription =
gets
( description
. StackSet.layout
. workspace
. current
. windowset
)
setStack :: Stack Window -> XState -> XState
setStack stack state =
state
{ windowset =
(windowset state)
{ current =
(current $ windowset state)
{ workspace =
(workspace . current $ windowset state)
{ StackSet.stack = Just stack
}
}
}
}
handleFocus :: Columns -> SomeMessage -> Stack Window -> Maybe (Stack Window)
handleFocus columns message stack
| Just FocusDown <- fromMessage message = setFocus' stack <$> mbNext
| Just FocusUp <- fromMessage message = setFocus' stack <$> mbPrevious
| otherwise = Nothing
where
focused = focus stack
windows = columnsToWindows columns
exists = focused `elem` windows
mbNext = guard exists >> next focused windows
mbPrevious = guard exists >> previous focused windows
setFocus' = flip setFocus
previous a = next a . reverse
setFocus w = until ((==) w . focus) StackSet.focusDown'
next _ [] = Nothing
next a (x : xs)
| a == x = listToMaybe xs
| otherwise = next a (xs <> [x])
oldNewWindows :: Columns -> Stack Window -> ([Window], [Window])
oldNewWindows columns stack = (old, new)
where
old = filter (`notElem` stackList) windows
new = filter (`notElem` windows) stackList
stackList = toList stack
windows = columnsToWindows columns
-- | Add the new windows to the layout and remove the old ones.
updateWindowList :: Columns -> Stack Window -> Columns
updateWindowList columns stack = addWindows newWindows (removeWindows oldWindows columns)
where
(oldWindows, newWindows) = oldNewWindows columns stack
-- | If one window disappeared and another appeared, we assume that the sublayout
-- tabs just changed focused.
hackForTabs :: Columns -> Stack Window -> Columns
hackForTabs columns stack = mapWindow replace columns
where
replace window
| (w1 : _, [w2]) <- oldNewWindows columns stack =
if window == w1
then w2
else window
| otherwise = window
toRectangles :: Rectangle -> [(Rational, [(Rational, a)])] -> [(a, Rectangle)]
toRectangles rectangle columns =
second (scaleRationalRect rectangle) <$> windowsAndRectangles
where
offsetsAndRatios = toOffsetRatio (second toOffsetRatio <$> columns)
windowsAndRectangles = foldMap toWindowAndRectangle offsetsAndRatios
toWindowAndRectangle (x, w, cs) = (\(y, h, ws) -> (ws, RationalRect x y w h)) <$> cs
onFocused :: (a -> a) -> Stack a -> Stack a
onFocused f (Stack a before after) = Stack (f a) before after
onFocusedM :: (Monad m) => (a -> m a) -> Stack a -> m (Stack a)
onFocusedM f (Stack a before after) = Stack <$> f a <*> pure before <*> pure after
onFocusedOrPrevious :: (a -> a) -> Stack a -> Stack a
onFocusedOrPrevious f (Stack a (a' : others) []) = Stack a (f a' : others) []
onFocusedOrPrevious f stack = onFocused f stack
handleMoveOrResize :: Columns -> SomeMessage -> Window -> Maybe Columns
handleMoveOrResize columns message window
| Just msg <- fromMessage message = move msg window columns
| Just HorizontalShrink <- fromMessage message =
onFocusedOrPrevious' shrink <$> findInColumns window columns
| Just HorizontalExpand <- fromMessage message =
onFocusedOrPrevious' expand <$> findInColumns window columns
| Just VerticalExpand <- fromMessage message =
onFocusedM'
(fmap (onFocusedOrPrevious' shrink) . findInColumn window)
=<< findInColumns window columns
| Just VerticalShrink <- fromMessage message =
onFocusedM'
(fmap (onFocusedOrPrevious' expand) . findInColumn window)
=<< findInColumns window columns
| otherwise = Nothing
where
expand = first $ flip (+) (3 / 100)
shrink = first $ flip (-) (3 / 100)
onFocusedM' f = fmap integrate . onFocusedM (sequence . second f)
onFocusedOrPrevious' f = sanitize . integrate . onFocusedOrPrevious f
move :: Move -> Window -> Columns -> Maybe Columns
move direction window columns =
case (direction, findInColumns window columns) of
(MoveRight, Just (Stack (_, [(_, _)]) _ [])) -> Nothing
(MoveLeft, Just (Stack (_, [(_, _)]) [] _)) -> Nothing
(MoveRight, Just (Stack column@(_, [(_, _)]) before (next : others))) ->
let (column', next') = swapWindowBetween window column next
in Just . integrate $ Stack column' before (next' : others)
(MoveLeft, Just (Stack column@(_, [(_, _)]) (previous : others) after)) ->
let (column', previous') = swapWindowBetween window column previous
in Just . integrate $ Stack column' (previous' : others) after
(MoveRight, Just stack) ->
let (newColumns', Stack column before after) = rationalize newColumns stack
windows = removeWindow window column
in Just . integrate $ Stack windows before (newColumns' <> after)
(MoveLeft, Just stack) ->
let (newColumns', Stack column before after) = rationalize newColumns stack
windows = removeWindow window column
in Just . integrate $ Stack windows (newColumns' <> before) after
(MoveUp, Just stack) -> integrate <$> onFocusedM (swapWindowUp window) stack
(MoveDown, Just stack) -> integrate <$> onFocusedM (swapWindowDown window) stack
_ -> Nothing
where
newColumns = [[(1, window)]]
mapWindow :: (Window -> Window) -> Columns -> Columns
mapWindow = fmap . fmap . fmap . fmap
columnsToWindows :: Columns -> [Window]
columnsToWindows = foldMap (singleton . snd) . foldMap snd
swapWindowBetween ::
Window ->
(Rational, Column) ->
(Rational, Column) ->
((Rational, Column), (Rational, Column))
swapWindowBetween window from to = (removed, added)
where
removed = removeWindow window from
added = appendWindows [window] to
swapWindowUp :: Window -> (Rational, Column) -> Maybe (Rational, Column)
swapWindowUp window (width, column)
| Just (Stack (height, _) (previous : before') after) <- findInColumn window column =
Just (width, integrate $ Stack previous ((height, window) : before') after)
| otherwise = Nothing
swapWindowDown :: Window -> (Rational, Column) -> Maybe (Rational, Column)
swapWindowDown window (width, column)
| Just (Stack (height, _) before (next : others)) <- findInColumn window column =
Just (width, integrate $ Stack next before ((height, window) : others))
| otherwise = Nothing
-- | Adjust the ratio of a list or a stack of elts so that when adding new
-- elements:
-- - the new elements are distributed according to the total number of elements
-- - the existing elements keep their proportion in the remaining space
rationalize ::
(Functor f, Foldable f) =>
[a] ->
f (Rational, a) ->
([(Rational, a)], f (Rational, a))
rationalize new existing = (new', existing')
where
nbNew = fromIntegral $ length new
nbInColumn = fromIntegral $ length existing
newRatio = nbNew % (nbNew + nbInColumn)
existingRatio = 1 - newRatio
new' = fitElements newRatio new
existing' = first (* existingRatio) <$> existing
append :: [a] -> [(Rational, a)] -> [(Rational, a)]
append new existing = uncurry (flip mappend) (rationalize new existing)
appendWindows ::
[Window] ->
(Rational, [(Rational, Window)]) ->
(Rational, [(Rational, Window)])
appendWindows windows = second (append windows)
fitElements :: Rational -> [a] -> [(Rational, a)]
fitElements dimension elts = (dimension',) <$> elts
where
dimension' = dimension / fromIntegral (length elts)
singleColumn :: Rational -> Rational -> [Window] -> Columns
singleColumn width height windows = [(width, fitElements height windows)]
findElement' :: (a -> Bool) -> [(Rational, a)] -> Maybe (Stack (Rational, a))
findElement' predicate list
| (before, c : after) <- break (predicate . snd) list =
Just $ Stack c (reverse before) after
| otherwise = Nothing
findInColumns :: Window -> Columns -> Maybe (Stack (Rational, Column))
findInColumns window = findElement' (any ((== window) . snd))
findInColumn :: Window -> Column -> Maybe (Stack (Rational, Window))
findInColumn window = findElement' (== window)
removeWindows :: [Window] -> Columns -> Columns
removeWindows windows = removeEmptyColumns . fmap (second removeWindows')
where
inWindows (_, window) = window `notElem` windows
removeWindows' = normalize . filter inWindows
removeEmptyColumns = normalize . filter (not . null . snd)
removeWindow :: Window -> (Rational, Column) -> (Rational, Column)
removeWindow window = second (normalize . filter ((/= window) . snd))
addWindows :: [Window] -> Columns -> Columns
addWindows [] columns = columns
-- When there is only one column, create a new one on the right
addWindows windows [(_, windows')] = (1 % 2, windows') : singleColumn (1 % 2) 1 windows
-- When there is more, append the windows to the last column
addWindows windows columns
| Just (columns', column) <- unsnoc columns =
sanitizeColumns $ columns' <> [appendWindows windows column]
| otherwise = singleColumn 1 1 windows
-- | Make sure the sum of all dimensions is 1
normalize :: [(Rational, a)] -> [(Rational, a)]
normalize elts = fmap (first (/ total)) elts
where
total = sum (fst <$> elts)
-- | Update the last dimension so that the sum of all dimensions is 1
sanitize :: [(Rational, a)] -> [(Rational, a)]
sanitize list
| Just (elts, (_, a)) <- unsnoc list = elts <> [(1 - sum (fst <$> elts), a)]
| otherwise = []
-- | Same on the whole layout
sanitizeColumns :: Columns -> Columns
sanitizeColumns = sanitize . fmap (second sanitize)
toOffsetRatio :: [(Rational, a)] -> [(Rational, Rational, a)]
toOffsetRatio ra = zipWith toTruple ra positions
where
toTruple (dimension, a) position = (position, dimension, a)
positions = scanl' (\position (dimension, _) -> position + dimension) 0 ra
unsnoc :: [a] -> Maybe ([a], a)
unsnoc [] = Nothing
unsnoc (x : xs)
| Just (is, l) <- unsnoc xs = Just (x : is, l)
| otherwise = Just ([], x)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment