taffybar-5.2.0: src/System/Taffybar/Widget/Workspaces/Hyprland.hs
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE OverloadedStrings #-}
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- |
-- Module : System.Taffybar.Widget.Workspaces.Hyprland
-- Copyright : (c) Ivan A. Malison
-- License : BSD3-style (see LICENSE)
--
-- Maintainer : Ivan A. Malison
-- Stability : unstable
-- Portability : unportable
--
-- Hyprland workspaces widget backed by hyprctl.
module System.Taffybar.Widget.Workspaces.Hyprland where
import Control.Applicative ((<|>))
import Control.Concurrent (forkIO, killThread)
import Control.Concurrent.STM.TChan (TChan, readTChan)
import Control.Monad (foldM, forM_, when)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.STM (atomically)
import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)
import Data.Aeson (FromJSON (..), withObject, (.!=), (.:), (.:?))
import qualified Data.ByteString as BS
import Data.Char (toLower)
import Data.Default (Default (..))
import qualified Data.Foldable as F
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Int (Int32)
import Data.List (intercalate, sortOn, stripPrefix)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import qualified Data.MultiMap as MM
import Data.Text (Text)
import qualified Data.Text as T
import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk
import qualified GI.Gtk as Gtk
import System.Environment.XDG.DesktopEntry
( DesktopEntry,
deFilename,
getDirectoryEntriesDefault,
)
import System.Log.Logger (Priority (..), logM)
import System.Taffybar.Context
import System.Taffybar.Hyprland
( getHyprlandEventChan,
runHyprlandCommandJsonT,
runHyprlandCommandRawT,
)
import qualified System.Taffybar.Information.Hyprland as Hypr
import System.Taffybar.Util
import System.Taffybar.Widget.Generic.ScalingImage (getScalingImageStrategy)
import System.Taffybar.Widget.Util
( WindowIconWidget (..),
computeIconStripLayout,
getImageForDesktopEntry,
handlePixbufGetterException,
scaledPixbufGetter,
syncWidgetPool,
updateWindowIconWidgetState,
widgetSetClassGI,
windowStatusClassFromFlags,
)
import System.Taffybar.Widget.Workspaces.Config
( WorkspaceWidgetCommonConfig (WorkspaceWidgetCommonConfig),
)
import qualified System.Taffybar.Widget.Workspaces.Config as WorkspaceWidgetConfig
import System.Taffybar.Widget.Workspaces.Shared
( WorkspaceState (..),
buildWorkspaceIconLabelOverlay,
mkWorkspaceIconWidget,
setWorkspaceWidgetStatusClass,
)
import System.Taffybar.WindowIcon (getCachedWindowIconFromClasses, pixBufFromColor)
import Text.Printf (printf)
stripSuffix :: (Eq a) => [a] -> [a] -> Maybe [a]
stripSuffix suffix value =
reverse <$> stripPrefix (reverse suffix) (reverse value)
isSpecialHyprWorkspace :: HyprlandWorkspace -> Bool
isSpecialHyprWorkspace ws =
let name = T.toLower $ T.pack $ workspaceName ws
in T.isPrefixOf "special" name || workspaceIdx ws < 0
data HyprlandWindow = HyprlandWindow
{ windowAddress :: Text,
windowTitle :: String,
windowClass :: Maybe String,
windowInitialClass :: Maybe String,
-- | The top-left position (x, y) of the window, as reported by
-- @hyprctl clients -j@. This is used for optional icon ordering.
windowAt :: Maybe (Int, Int),
windowUrgent :: Bool,
windowActive :: Bool,
windowMinimized :: Bool
}
deriving (Show, Eq)
data HyprlandWorkspace = HyprlandWorkspace
{ workspaceIdx :: Int,
workspaceName :: String,
workspaceState :: WorkspaceState,
windows :: [HyprlandWindow]
}
deriving (Show, Eq)
newtype HyprlandWorkspaceCache = HyprlandWorkspaceCache [HyprlandWorkspace]
newtype HyprlandWorkspaceWidgetCache
= HyprlandWorkspaceWidgetCache (M.Map Int HyprlandWorkspaceEntry)
newtype HyprlandWorkspaceOrderCache
= HyprlandWorkspaceOrderCache [Int]
data HyprlandWorkspaceEntry = HyprlandWorkspaceEntry
{ hweWrapper :: Gtk.Widget,
hweController :: HyprlandWWC,
hweLast :: HyprlandWorkspace
}
type HyprlandIconWidget = WindowIconWidget HyprlandWindow
-- | Controller typeclass for Hyprland workspace widgets, mirroring
-- the X11 'WorkspaceWidgetController' pattern.
class HyprlandWorkspaceWidgetController wc where
hwcGetWidget :: wc -> IO Gtk.Widget
hwcUpdateWidget :: wc -> HyprlandWorkspace -> TaffyIO wc
-- | Existential wrapper for Hyprland workspace controllers.
data HyprlandWWC = forall a. (HyprlandWorkspaceWidgetController a) => HyprlandWWC a
instance HyprlandWorkspaceWidgetController HyprlandWWC where
hwcGetWidget (HyprlandWWC wc) = hwcGetWidget wc
hwcUpdateWidget (HyprlandWWC wc) ws = HyprlandWWC <$> hwcUpdateWidget wc ws
type HyprlandControllerConstructor = HyprlandWorkspace -> TaffyIO HyprlandWWC
type HyprlandParentControllerConstructor =
HyprlandControllerConstructor -> HyprlandControllerConstructor
type HyprlandWindowIconPixbufGetter =
Int32 -> HyprlandWindow -> TaffyIO (Maybe Gdk.Pixbuf)
data HyprlandWorkspacesConfig
= HyprlandWorkspacesConfig
{ getWorkspaces :: TaffyIO [HyprlandWorkspace],
switchToWorkspace :: HyprlandWorkspace -> TaffyIO (),
updateIntervalSeconds :: Double,
iconSize :: Int32,
workspacesConfig ::
WorkspaceWidgetCommonConfig
(ReaderT Context IO)
HyprlandWorkspace
HyprlandWindow
HyprlandWWC
}
hyprlandWorkspacesCommonConfig ::
HyprlandWorkspacesConfig ->
WorkspaceWidgetCommonConfig (ReaderT Context IO) HyprlandWorkspace HyprlandWindow HyprlandWWC
hyprlandWorkspacesCommonConfig = workspacesConfig
-- | Modify the nested 'WorkspaceWidgetCommonConfig' inside a
-- 'HyprlandWorkspacesConfig'.
--
-- This helper exists to avoid accidental self-recursive config definitions
-- when overriding multiple fields. Prefer:
--
-- > cfg = modifyCommonHyprlandWorkspacesConfig (\c -> c { WorkspaceWidgetConfig.minIcons = 1 }) defaultHyprlandWorkspacesConfig
--
-- over tying the knot yourself (e.g. defining @common@ in terms of @cfg@ while
-- also defining @cfg@ in terms of @common@), which can hang at runtime.
modifyCommonHyprlandWorkspacesConfig ::
( WorkspaceWidgetCommonConfig (ReaderT Context IO) HyprlandWorkspace HyprlandWindow HyprlandWWC ->
WorkspaceWidgetCommonConfig (ReaderT Context IO) HyprlandWorkspace HyprlandWindow HyprlandWWC
) ->
HyprlandWorkspacesConfig ->
HyprlandWorkspacesConfig
modifyCommonHyprlandWorkspacesConfig f cfg =
cfg {workspacesConfig = f (workspacesConfig cfg)}
-- | Replace the nested common config inside 'HyprlandWorkspacesConfig'.
--
-- If you're just tweaking a few fields, prefer
-- 'modifyCommonHyprlandWorkspacesConfig' to avoid accidentally creating a
-- self-recursive definition that can hang at runtime.
applyCommonHyprlandWorkspacesConfig ::
WorkspaceWidgetCommonConfig (ReaderT Context IO) HyprlandWorkspace HyprlandWindow HyprlandWWC ->
HyprlandWorkspacesConfig ->
HyprlandWorkspacesConfig
applyCommonHyprlandWorkspacesConfig common cfg =
cfg {workspacesConfig = common}
defaultHyprlandWorkspacesConfig :: HyprlandWorkspacesConfig
defaultHyprlandWorkspacesConfig = cfg
where
cfg =
HyprlandWorkspacesConfig
{ getWorkspaces = getHyprlandWorkspaces,
switchToWorkspace = hyprlandSwitchToWorkspace,
updateIntervalSeconds = 1,
iconSize = 16,
workspacesConfig =
WorkspaceWidgetCommonConfig
{ WorkspaceWidgetConfig.widgetBuilder = defaultHyprlandWidgetBuilder cfg,
WorkspaceWidgetConfig.widgetGap = 0,
WorkspaceWidgetConfig.maxIcons = Nothing,
WorkspaceWidgetConfig.minIcons = 0,
WorkspaceWidgetConfig.getWindowIconPixbuf = defaultHyprlandGetWindowIconPixbuf,
WorkspaceWidgetConfig.labelSetter = return . workspaceName,
WorkspaceWidgetConfig.showWorkspaceFn = \ws ->
workspaceState ws /= Empty && not (isSpecialHyprWorkspace ws),
-- Match the X11 Workspaces widget default: order icons by window position.
WorkspaceWidgetConfig.iconSort = pure . sortHyprlandWindowsByPosition,
WorkspaceWidgetConfig.urgentWorkspaceState = False
}
}
instance Default HyprlandWorkspacesConfig where
def = defaultHyprlandWorkspacesConfig
hyprlandWorkspacesNew :: HyprlandWorkspacesConfig -> TaffyIO Gtk.Widget
hyprlandWorkspacesNew cfg = do
let common = hyprlandWorkspacesCommonConfig cfg
cont <-
liftIO $
Gtk.boxNew Gtk.OrientationHorizontal $
fromIntegral (WorkspaceWidgetConfig.widgetGap common)
_ <- widgetSetClassGI cont "workspaces"
ctx <- ask
let refresh = runReaderT (refreshWorkspaces cfg cont) ctx
liftIO refresh
-- Ensure this top-level container is visible when packed into the bar.
-- Otherwise the start widget area can appear blank under Wayland/Hyprland.
liftIO $ Gtk.widgetShowAll cont
eventChan <- getHyprlandEventChan
events <- liftIO $ Hypr.subscribeHyprlandEvents eventChan
tid <- liftIO $ forkIO $ hyprlandUpdateLoop refresh events
_ <- liftIO $ Gtk.onWidgetUnrealize cont $ killThread tid
Gtk.toWidget cont
hyprlandUpdateLoop :: IO () -> TChan T.Text -> IO ()
hyprlandUpdateLoop refresh events = do
line <- atomically $ readTChan events
when (isRelevantHyprEvent (T.unpack line)) refresh
hyprlandUpdateLoop refresh events
isRelevantHyprEvent :: String -> Bool
isRelevantHyprEvent line =
let eventName = takeWhile (/= '>') line
in eventName
`elem` [ "workspace",
"workspacev2",
"focusedmon",
"activewindow",
"activewindowv2",
"openwindow",
"closewindow",
"movewindow",
"movewindowv2",
"moveworkspace",
"renameworkspace",
"createworkspace",
"destroyworkspace",
"monitoradded",
"monitorremoved",
-- Synthetic "event" emitted by our event reader thread whenever it
-- (re)connects to Hyprland. This lets us refresh after a compositor
-- restart without polling.
"taffybar-hyprland-connected"
]
refreshWorkspaces :: HyprlandWorkspacesConfig -> Gtk.Box -> ReaderT Context IO ()
refreshWorkspaces cfg cont = do
let common = hyprlandWorkspacesCommonConfig cfg
ws <- getWorkspaces cfg
HyprlandWorkspaceCache prev <- getStateDefault $ return (HyprlandWorkspaceCache [])
-- Detect whether the cached widgets have been orphaned (e.g. the bar window
-- was destroyed and recreated after a compositor reload). When the old GTK
-- objects are gone we must re-render even if the workspace data is unchanged.
HyprlandWorkspaceWidgetCache wc <-
getStateDefault $ return (HyprlandWorkspaceWidgetCache M.empty)
widgetsStale <- case M.elems wc of
[] -> return False
(first : _) -> liftIO $ not <$> Gtk.widgetGetRealized (hweWrapper first)
let ignoreEmptyResult = null ws && not (null prev)
when ignoreEmptyResult $
liftIO $
wLog WARNING $
printf
"Hyprland workspaces refresh returned empty list; retaining previous state (prevTotal=%d)."
(length prev)
let needsRender = (ws /= prev || widgetsStale) && not ignoreEmptyResult
when needsRender $ do
liftIO $
wLog DEBUG $
printf
"Hyprland workspaces refresh: total=%d shown=%d (minIcons=%d maxIcons=%s) widgetsStale=%s %s"
(length ws)
(length (filter (WorkspaceWidgetConfig.showWorkspaceFn common) ws))
(WorkspaceWidgetConfig.minIcons common)
(show (WorkspaceWidgetConfig.maxIcons common))
(show widgetsStale)
(summarizeWorkspaces ws)
_ <- setState (HyprlandWorkspaceCache ws)
ctx <- ask
liftIO $ postGUIASync $ runReaderT (renderWorkspaces cfg cont ws) ctx
renderWorkspaces ::
HyprlandWorkspacesConfig -> Gtk.Box -> [HyprlandWorkspace] -> ReaderT Context IO ()
renderWorkspaces cfg cont workspaces = do
let common = hyprlandWorkspacesCommonConfig cfg
let workspaces' = map (applyUrgentState cfg) workspaces
HyprlandWorkspaceWidgetCache widgetCache <-
getStateDefault $ return (HyprlandWorkspaceWidgetCache M.empty)
HyprlandWorkspaceOrderCache prevOrder <-
getStateDefault $ return (HyprlandWorkspaceOrderCache [])
-- If any cached widget has been unrealized (e.g. because the bar window was
-- recreated after a compositor reload), the old GTK objects are dead and must
-- be discarded so that fresh widgets are built for the new container.
stale <- case M.elems widgetCache of
[] -> return False
(first : _) -> liftIO $ not <$> Gtk.widgetGetRealized (hweWrapper first)
let oldCache = if stale then M.empty else widgetCache
oldOrder = if stale then [] else prevOrder
when stale $
liftIO $
wLog DEBUG "renderWorkspaces: discarding stale widget cache (widgets unrealized)"
let buildOrUpdate newCache ws = do
let idx = workspaceIdx ws
entry <- case M.lookup idx oldCache of
Just prevEntry
| hweLast prevEntry == ws -> return prevEntry
| otherwise -> do
newCtrl <- hwcUpdateWidget (hweController prevEntry) ws
return prevEntry {hweController = newCtrl, hweLast = ws}
Nothing -> do
ctrl <- WorkspaceWidgetConfig.widgetBuilder common ws
ctrlWidget <- liftIO $ hwcGetWidget ctrl
wrapperBox <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal 0
liftIO $ Gtk.containerAdd wrapperBox ctrlWidget
wrapper <- Gtk.toWidget wrapperBox
return
HyprlandWorkspaceEntry
{ hweWrapper = wrapper,
hweController = ctrl,
hweLast = ws
}
return (M.insert idx entry newCache, entry)
(newCache, orderedEntriesRev) <-
foldM
( \(cacheAcc, entriesAcc) ws -> do
(cacheAcc', entry) <- buildOrUpdate cacheAcc ws
return (cacheAcc', (ws, entry) : entriesAcc)
)
(M.empty, [])
workspaces'
let orderedEntries = reverse orderedEntriesRev
let primaryShowFn = WorkspaceWidgetConfig.showWorkspaceFn common
primaryShownCount = length $ filter (primaryShowFn . fst) orderedEntries
fallbackShowFn ws = workspaceState ws /= Empty
fallbackShownCount = length $ filter (fallbackShowFn . fst) orderedEntries
(finalShowFn, finalShownCount, fallbackUsed) =
if primaryShownCount == 0 && fallbackShownCount > 0
then (fallbackShowFn, fallbackShownCount, True)
else (primaryShowFn, primaryShownCount, False)
when (fallbackUsed && not (null orderedEntries)) $
liftIO $
wLog WARNING $
printf
"Hyprland workspaces: showWorkspaceFn hid all %d workspaces; falling back to showing non-empty workspaces (including special:*). %s"
(length orderedEntries)
(summarizeWorkspaces (map fst orderedEntries))
when (finalShownCount == 0 && not (null orderedEntries)) $
liftIO $
wLog WARNING $
printf
"Hyprland workspaces widget is blank: no workspaces passed the show filter (total=%d). %s"
(length orderedEntries)
(summarizeWorkspaces (map fst orderedEntries))
-- Remove wrappers for workspaces that disappeared.
let removed = M.difference oldCache newCache
forM_ (M.elems removed) $ \entry ->
liftIO $ Gtk.containerRemove cont (hweWrapper entry)
-- Add wrappers for newly created workspaces.
let added = M.difference newCache oldCache
forM_ (M.elems added) $ \entry -> do
liftIO $ Gtk.containerAdd cont (hweWrapper entry)
liftIO $ Gtk.widgetShowAll (hweWrapper entry)
let desiredOrder = map (workspaceIdx . fst) orderedEntries
needsReorder =
desiredOrder /= oldOrder || not (M.null added) || not (M.null removed)
when needsReorder $ do
-- Reorder wrappers to match the order returned by hyprctl.
forM_ (zip [0 :: Int ..] orderedEntries) $ \(pos, (_ws, entry)) ->
liftIO $ Gtk.boxReorderChild cont (hweWrapper entry) (fromIntegral pos)
-- Show/hide the controller widget without removing the wrapper from the box.
forM_ orderedEntries $ \(ws, entry) -> do
ctrlWidget <- liftIO $ hwcGetWidget (hweController entry)
if finalShowFn ws
then liftIO $ Gtk.widgetShow ctrlWidget
else liftIO $ Gtk.widgetHide ctrlWidget
_ <- setState (HyprlandWorkspaceWidgetCache newCache)
_ <- setState (HyprlandWorkspaceOrderCache desiredOrder)
return ()
summarizeWorkspaces :: [HyprlandWorkspace] -> String
summarizeWorkspaces wss =
let summarizeOne ws =
printf
"%d:%s:%s(wins=%d)"
(workspaceIdx ws)
(workspaceName ws)
(show (workspaceState ws))
(length (windows ws))
maxShown = 12
body = intercalate ", " $ map summarizeOne (take maxShown wss)
suffix = if length wss > maxShown then ", ..." else ""
in "workspaces=[" ++ body ++ suffix ++ "]"
applyUrgentState :: HyprlandWorkspacesConfig -> HyprlandWorkspace -> HyprlandWorkspace
applyUrgentState cfg ws
| WorkspaceWidgetConfig.urgentWorkspaceState (hyprlandWorkspacesCommonConfig cfg)
&& workspaceState ws == Hidden
&& any windowUrgent (windows ws) =
ws {workspaceState = Urgent}
| otherwise = ws
-- Controller types
data HyprlandLabelController = HyprlandLabelController
{ hlcLabel :: Gtk.Label,
hlcLabelSetter :: HyprlandWorkspace -> TaffyIO String
}
instance HyprlandWorkspaceWidgetController HyprlandLabelController where
hwcGetWidget = Gtk.toWidget . hlcLabel
hwcUpdateWidget lc ws = do
labelText <- hlcLabelSetter lc ws
liftIO $ do
Gtk.labelSetMarkup (hlcLabel lc) (T.pack labelText)
setWorkspaceWidgetStatusClass (workspaceState ws) (hlcLabel lc)
return lc
hyprlandBuildLabelController :: HyprlandWorkspacesConfig -> HyprlandControllerConstructor
hyprlandBuildLabelController cfg ws = do
let common = hyprlandWorkspacesCommonConfig cfg
lbl <- liftIO $ Gtk.labelNew Nothing
_ <- widgetSetClassGI lbl "workspace-label"
labelText <- WorkspaceWidgetConfig.labelSetter common ws
liftIO $ Gtk.labelSetMarkup lbl (T.pack labelText)
liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) lbl
return $
HyprlandWWC $
HyprlandLabelController
{ hlcLabel = lbl,
hlcLabelSetter = WorkspaceWidgetConfig.labelSetter common
}
data HyprlandIconController = HyprlandIconController
{ hicIconsContainer :: Gtk.Box,
hicIconImages :: [HyprlandIconWidget],
hicWorkspace :: HyprlandWorkspace,
hicConfig :: HyprlandWorkspacesConfig
}
instance HyprlandWorkspaceWidgetController HyprlandIconController where
hwcGetWidget = Gtk.toWidget . hicIconsContainer
hwcUpdateWidget ic ws = do
newImages <-
if windows ws /= windows (hicWorkspace ic)
then updateIcons (hicConfig ic) ws (hicIconsContainer ic) (hicIconImages ic)
else return (hicIconImages ic)
return ic {hicIconImages = newImages, hicWorkspace = ws}
hyprlandBuildIconController :: HyprlandWorkspacesConfig -> HyprlandControllerConstructor
hyprlandBuildIconController cfg ws = do
iconsBox <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal 0
icons <- updateIcons cfg ws iconsBox []
return $
HyprlandWWC $
HyprlandIconController
{ hicIconsContainer = iconsBox,
hicIconImages = icons,
hicWorkspace = ws,
hicConfig = cfg
}
data HyprlandContentsController = HyprlandContentsController
{ hccContainerWidget :: Gtk.Widget,
hccControllers :: [HyprlandWWC]
}
instance HyprlandWorkspaceWidgetController HyprlandContentsController where
hwcGetWidget = return . hccContainerWidget
hwcUpdateWidget cc ws = do
liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) (hccContainerWidget cc)
newControllers <- mapM (`hwcUpdateWidget` ws) (hccControllers cc)
return cc {hccControllers = newControllers}
hyprlandBuildContentsController ::
[HyprlandControllerConstructor] -> HyprlandControllerConstructor
hyprlandBuildContentsController constructors ws = do
controllers <- mapM ($ ws) constructors
widgets <- liftIO $ mapM hwcGetWidget controllers
widget <- liftIO $ do
cons <- Gtk.boxNew Gtk.OrientationHorizontal 0
mapM_ (Gtk.containerAdd cons) widgets
_ <- widgetSetClassGI cons "contents"
Gtk.toWidget cons
liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) widget
return $
HyprlandWWC $
HyprlandContentsController
{ hccContainerWidget = widget,
hccControllers = controllers
}
hyprlandBuildLabelOverlayController ::
HyprlandWorkspacesConfig -> HyprlandControllerConstructor
hyprlandBuildLabelOverlayController cfg ws = do
iconCtrl <- hyprlandBuildIconController cfg ws
labelCtrl <- hyprlandBuildLabelController cfg ws
iconWidget <- liftIO $ hwcGetWidget iconCtrl
labelWidget <- liftIO $ hwcGetWidget labelCtrl
widget <- buildWorkspaceIconLabelOverlay iconWidget labelWidget
liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) widget
return $
HyprlandWWC $
HyprlandContentsController
{ hccContainerWidget = widget,
hccControllers = [iconCtrl, labelCtrl]
}
-- | Like 'hyprlandBuildLabelOverlayController' but accepts a custom function
-- to combine the icon and label widgets into a single container widget.
hyprlandBuildCustomOverlayController ::
(Gtk.Widget -> Gtk.Widget -> TaffyIO Gtk.Widget) ->
HyprlandWorkspacesConfig ->
HyprlandControllerConstructor
hyprlandBuildCustomOverlayController combiner cfg ws = do
iconCtrl <- hyprlandBuildIconController cfg ws
labelCtrl <- hyprlandBuildLabelController cfg ws
iconWidget <- liftIO $ hwcGetWidget iconCtrl
labelWidget <- liftIO $ hwcGetWidget labelCtrl
widget <- combiner iconWidget labelWidget
liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) widget
return $
HyprlandWWC $
HyprlandContentsController
{ hccContainerWidget = widget,
hccControllers = [iconCtrl, labelCtrl]
}
hyprlandDefaultBuildContentsController ::
HyprlandWorkspacesConfig -> HyprlandControllerConstructor
hyprlandDefaultBuildContentsController = hyprlandBuildLabelOverlayController
data HyprlandButtonController = HyprlandButtonController
{ hbcButton :: Gtk.EventBox,
hbcWorkspaceRef :: IORef HyprlandWorkspace,
hbcContentsController :: HyprlandWWC
}
instance HyprlandWorkspaceWidgetController HyprlandButtonController where
hwcGetWidget = Gtk.toWidget . hbcButton
hwcUpdateWidget wbc ws = do
liftIO $ writeIORef (hbcWorkspaceRef wbc) ws
newContents <- hwcUpdateWidget (hbcContentsController wbc) ws
return wbc {hbcContentsController = newContents}
hyprlandBuildButtonController ::
HyprlandWorkspacesConfig -> HyprlandParentControllerConstructor
hyprlandBuildButtonController cfg contentsBuilder ws = do
cc <- contentsBuilder ws
ctx <- ask
contentsWidget <- liftIO $ hwcGetWidget cc
wsRef <- liftIO $ newIORef ws
ebox <- liftIO $ do
eb <- Gtk.eventBoxNew
Gtk.eventBoxSetVisibleWindow eb False
Gtk.containerAdd eb contentsWidget
_ <- Gtk.onWidgetButtonPressEvent eb $ const $ do
wsCurrent <- readIORef wsRef
runReaderT (switchToWorkspace cfg wsCurrent) ctx
return True
return eb
return $
HyprlandWWC $
HyprlandButtonController
{ hbcButton = ebox,
hbcWorkspaceRef = wsRef,
hbcContentsController = cc
}
defaultHyprlandWidgetBuilder :: HyprlandWorkspacesConfig -> HyprlandControllerConstructor
defaultHyprlandWidgetBuilder cfg =
hyprlandBuildButtonController cfg (hyprlandDefaultBuildContentsController cfg)
updateIcons ::
HyprlandWorkspacesConfig ->
HyprlandWorkspace ->
Gtk.Box ->
[HyprlandIconWidget] ->
ReaderT Context IO [HyprlandIconWidget]
updateIcons cfg ws iconsBox iconWidgets = do
let common = hyprlandWorkspacesCommonConfig cfg
sortedWindows <- WorkspaceWidgetConfig.iconSort common $ windows ws
let (effectiveMinIcons, _targetLen, paddedWindows) =
computeIconStripLayout
(WorkspaceWidgetConfig.minIcons common)
(WorkspaceWidgetConfig.maxIcons common)
sortedWindows
buildOne i = buildIconWidget (i < effectiveMinIcons) cfg
syncWidgetPool iconsBox iconWidgets paddedWindows buildOne iconContainer updateIconWidget
buildIconWidget :: Bool -> HyprlandWorkspacesConfig -> ReaderT Context IO HyprlandIconWidget
buildIconWidget transparentOnNone cfg = do
ctx <- ask
let common = hyprlandWorkspacesCommonConfig cfg
strategy <- getScalingImageStrategy
liftIO $
mkWorkspaceIconWidget
strategy
(Just $ iconSize cfg)
transparentOnNone
(\size w -> runReaderT (WorkspaceWidgetConfig.getWindowIconPixbuf common size w) ctx)
(`pixBufFromColor` 0)
updateIconWidget :: HyprlandIconWidget -> Maybe HyprlandWindow -> ReaderT Context IO ()
updateIconWidget iconWidget windowData =
updateWindowIconWidgetState
iconWidget
windowData
(T.pack . windowTitle)
getWindowStatusString
getWindowStatusString :: HyprlandWindow -> T.Text
getWindowStatusString windowData =
windowStatusClassFromFlags
(windowMinimized windowData)
(windowActive windowData)
(windowUrgent windowData)
-- | Sort windows by their top-left corner position.
--
-- This mirrors the X11 Workspaces widget default ('sortWindowsByPosition'),
-- but uses Hyprland's @at@ coordinate from @hyprctl clients -j@.
sortHyprlandWindowsByPosition :: [HyprlandWindow] -> [HyprlandWindow]
sortHyprlandWindowsByPosition =
sortOn $ \w ->
( windowMinimized w,
fromMaybe (999999999, 999999999) (windowAt w)
)
wLog :: (MonadIO m) => Priority -> String -> m ()
wLog l s = liftIO $ logM "System.Taffybar.Widget.HyprlandWorkspaces" l s
-- Window icon lookup
scaledWindowIconPixbufGetter ::
HyprlandWindowIconPixbufGetter -> HyprlandWindowIconPixbufGetter
scaledWindowIconPixbufGetter = scaledPixbufGetter
handleIconGetterException ::
HyprlandWindowIconPixbufGetter -> HyprlandWindowIconPixbufGetter
handleIconGetterException = handlePixbufGetterException wLog
defaultHyprlandGetWindowIconPixbuf :: HyprlandWindowIconPixbufGetter
defaultHyprlandGetWindowIconPixbuf =
scaledWindowIconPixbufGetter $
getWindowIconPixbufFromDesktopEntry <|||> getWindowIconPixbufFromClass
getWindowIconPixbufFromClass :: HyprlandWindowIconPixbufGetter
getWindowIconPixbufFromClass size windowData =
maybeTCombine
(maybe (return Nothing) (getCachedWindowIconFromClasses size) (windowClass windowData))
(maybe (return Nothing) (getCachedWindowIconFromClasses size) (windowInitialClass windowData))
getWindowIconPixbufFromDesktopEntry :: HyprlandWindowIconPixbufGetter
getWindowIconPixbufFromDesktopEntry =
handleIconGetterException $ \size windowData ->
maybeTCombine
(maybe (return Nothing) (getWindowIconFromDesktopEntryByAppId size) (windowClass windowData))
(maybe (return Nothing) (getWindowIconFromDesktopEntryByAppId size) (windowInitialClass windowData))
getDirectoryEntriesByAppId :: TaffyIO (MM.MultiMap String DesktopEntry)
getDirectoryEntriesByAppId = getStateDefault readDirectoryEntriesByAppId
readDirectoryEntriesByAppId :: TaffyIO (MM.MultiMap String DesktopEntry)
readDirectoryEntriesByAppId =
liftIO $ indexDesktopEntriesByAppId <$> getDirectoryEntriesDefault
indexDesktopEntriesByAppId :: [DesktopEntry] -> MM.MultiMap String DesktopEntry
indexDesktopEntriesByAppId =
F.foldl' (\m de -> MM.insert (normalizeAppId $ deFilename de) de m) MM.empty
normalizeAppId :: String -> String
normalizeAppId name =
let stripped = fromMaybe name (stripSuffix ".desktop" name)
in map toLower stripped
getWindowIconFromDesktopEntryByAppId ::
Int32 -> String -> TaffyIO (Maybe Gdk.Pixbuf)
getWindowIconFromDesktopEntryByAppId size appId = do
entries <- MM.lookup (normalizeAppId appId) <$> getDirectoryEntriesByAppId
case entries of
[] -> return Nothing
(entry : _) -> do
liftIO $
logM "System.Taffybar.Widget.HyprlandWorkspaces" DEBUG $
printf
"Using desktop entry for icon %s (appId=%s)"
(deFilename entry)
appId
liftIO $ getImageForDesktopEntry size entry
-- Hyprland backend
data HyprlandWorkspaceRef = HyprlandWorkspaceRef
{ hwrId :: Int,
hwrName :: Text
}
deriving (Show, Eq)
instance FromJSON HyprlandWorkspaceRef where
parseJSON = withObject "HyprlandWorkspaceRef" $ \v ->
HyprlandWorkspaceRef
<$> v .: "id"
<*> v .: "name"
data HyprlandWorkspaceInfo = HyprlandWorkspaceInfo
{ hwiId :: Int,
hwiName :: Text,
hwiWindows :: Int
}
deriving (Show, Eq)
instance FromJSON HyprlandWorkspaceInfo where
parseJSON = withObject "HyprlandWorkspaceInfo" $ \v ->
HyprlandWorkspaceInfo
<$> v .: "id"
<*> v .: "name"
<*> v .:? "windows" .!= 0
data HyprlandMonitorInfo = HyprlandMonitorInfo
{ hmFocused :: Bool,
hmActiveWorkspace :: Maybe HyprlandWorkspaceRef
}
deriving (Show, Eq)
instance FromJSON HyprlandMonitorInfo where
parseJSON = withObject "HyprlandMonitorInfo" $ \v ->
HyprlandMonitorInfo
<$> v .:? "focused" .!= False
<*> v .:? "activeWorkspace"
data HyprlandClient = HyprlandClient
{ hcAddress :: Text,
hcTitle :: Text,
hcInitialTitle :: Maybe Text,
hcClass :: Maybe Text,
hcInitialClass :: Maybe Text,
hcAt :: Maybe (Int, Int),
hcWorkspace :: HyprlandWorkspaceRef,
hcFocused :: Bool,
hcHidden :: Bool,
hcMapped :: Bool,
hcUrgent :: Bool
}
deriving (Show, Eq)
instance FromJSON HyprlandClient where
parseJSON = withObject "HyprlandClient" $ \v ->
HyprlandClient
<$> v .: "address"
<*> v .:? "title" .!= ""
<*> v .:? "initialTitle"
<*> v .:? "class"
<*> v .:? "initialClass"
<*> (vec2FromList <$> v .:? "at")
<*> v .: "workspace"
<*> v .:? "focused" .!= False
<*> v .:? "hidden" .!= False
<*> v .:? "mapped" .!= True
<*> v .:? "urgent" .!= False
where
vec2FromList :: Maybe [Int] -> Maybe (Int, Int)
vec2FromList (Just [x, y]) = Just (x, y)
vec2FromList _ = Nothing
newtype HyprlandActiveWindow = HyprlandActiveWindow
{ hawAddress :: Text
}
deriving (Show, Eq)
instance FromJSON HyprlandActiveWindow where
parseJSON = withObject "HyprlandActiveWindow" $ \v ->
HyprlandActiveWindow <$> v .: "address"
runHyprctlJson :: (FromJSON a) => [String] -> TaffyIO (Either String a)
runHyprctlJson args = do
let args' =
case args of
("-j" : rest) -> rest
_ -> args
result <- runHyprlandCommandJsonT (Hypr.hyprCommandJson args')
pure $ case result of
Left err -> Left (show err)
Right out -> Right out
runHyprlandCommandRaw :: [String] -> TaffyIO (Either String BS.ByteString)
runHyprlandCommandRaw args = do
let cmd =
case args of
("-j" : rest) -> Hypr.hyprCommandJson rest
_ -> Hypr.hyprCommand args
result <- runHyprlandCommandRawT cmd
pure $ case result of
Left err -> Left (show err)
Right out -> Right out
hyprlandSwitchToWorkspace :: HyprlandWorkspace -> TaffyIO ()
hyprlandSwitchToWorkspace ws = do
result <- runHyprlandCommandRaw ["dispatch", "workspace", workspaceName ws]
case result of
Left err -> wLog WARNING $ printf "Failed to switch workspace: %s" err
Right _ -> return ()
getHyprlandWorkspaces :: TaffyIO [HyprlandWorkspace]
getHyprlandWorkspaces = do
workspacesResult <- runHyprctlJson ["-j", "workspaces"]
clientsResult <- runHyprctlJson ["-j", "clients"]
monitorsResult <- runHyprctlJson ["-j", "monitors"]
activeWorkspaceResult <- runHyprctlJson ["-j", "activeworkspace"]
activeWindowAddress <- getActiveWindowAddress
workspaces <- case workspacesResult of
Left err -> wLog WARNING (printf "hyprctl workspaces failed: %s" err) >> return []
Right ws -> return ws
let workspacesCount = length workspaces
workspacesWindowsSum = sum (map hwiWindows workspaces)
(clientsOk, clients) <- case clientsResult of
Left err -> do
wLog WARNING $
printf
"hyprctl clients failed: %s (workspaces=%d windowsSum=%d)"
err
workspacesCount
workspacesWindowsSum
return (False, [])
Right cs -> return (True, cs)
monitors <- case monitorsResult of
Left err -> wLog WARNING (printf "hyprctl monitors failed: %s" err) >> return []
Right ms -> return ms
activeWorkspace <- case activeWorkspaceResult of
Left err -> wLog WARNING (printf "hyprctl activeworkspace failed: %s" err) >> return Nothing
Right ws -> return $ Just ws
buildWorkspacesFromHyprland
workspaces
clientsOk
clients
monitors
activeWorkspace
activeWindowAddress
getActiveWindowAddress :: TaffyIO (Maybe Text)
getActiveWindowAddress = do
result <- runHyprctlJson ["-j", "activewindow"]
case result of
Left _ -> return Nothing
Right activeWindow -> return $ Just $ hawAddress activeWindow
buildWorkspacesFromHyprland ::
[HyprlandWorkspaceInfo] ->
Bool ->
[HyprlandClient] ->
[HyprlandMonitorInfo] ->
Maybe HyprlandWorkspaceRef ->
Maybe Text ->
TaffyIO [HyprlandWorkspace]
buildWorkspacesFromHyprland workspaces clientsOk clients monitors activeWorkspace activeWindowAddress = do
let windowsByWorkspace = collectWorkspaceWindows activeWindowAddress clients
sortedWorkspaces = sortOn hwiId workspaces
visibleWorkspaceIds =
map hwrId $ mapMaybe hmActiveWorkspace monitors
focusedWorkspaceId =
listToMaybe
[ hwrId ws
| m <- monitors,
hmFocused m,
Just ws <- [hmActiveWorkspace m]
]
activeWorkspaceId =
focusedWorkspaceId <|> fmap hwrId activeWorkspace
return $ map (buildWorkspace windowsByWorkspace visibleWorkspaceIds activeWorkspaceId) sortedWorkspaces
where
buildWorkspace windowsMap visibleIds activeId wsInfo =
let wsId = hwiId wsInfo
wsName = hwiName wsInfo
wins = M.findWithDefault [] wsId windowsMap
hasWindows = not (null wins) || hwiWindows wsInfo > 0
state
| Just wsId == activeId = Active
| wsId `elem` visibleIds = Visible
-- If we couldn't fetch clients, don't aggressively hide workspaces.
-- This prevents "invisible" workspaces on transient Hyprland restarts.
| not hasWindows && clientsOk = Empty
| otherwise = Hidden
in HyprlandWorkspace
{ workspaceIdx = wsId,
workspaceName = T.unpack wsName,
workspaceState = state,
windows = wins
}
collectWorkspaceWindows :: Maybe Text -> [HyprlandClient] -> M.Map Int [HyprlandWindow]
collectWorkspaceWindows activeWindowAddress =
F.foldl' (addWindow activeWindowAddress) M.empty
where
addWindow activeAddr windowsMap client =
let wsId = hwrId (hcWorkspace client)
windowData = windowFromClient activeAddr client
in M.insertWith (++) wsId [windowData] windowsMap
windowFromClient :: Maybe Text -> HyprlandClient -> HyprlandWindow
windowFromClient activeAddr client =
let titleText =
if T.null (hcTitle client)
then fromMaybe "" (hcInitialTitle client)
else hcTitle client
active =
hcFocused client || Just (hcAddress client) == activeAddr
minimized =
hcHidden client || not (hcMapped client)
in HyprlandWindow
{ windowAddress = hcAddress client,
windowTitle = T.unpack titleText,
windowClass = T.unpack <$> hcClass client,
windowInitialClass = T.unpack <$> hcInitialClass client,
windowAt = hcAt client,
windowUrgent = hcUrgent client,
windowActive = active,
windowMinimized = minimized
}