taffybar-4.1.2: src/System/Taffybar/Widget/HyprlandWorkspaces.hs
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE OverloadedStrings #-}
-----------------------------------------------------------------------------
-- |
-- Module : System.Taffybar.Widget.HyprlandWorkspaces
-- 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.HyprlandWorkspaces 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 Data.Char (toLower)
import qualified Data.ByteString as BS
import Data.Default (Default(..))
import qualified Data.Foldable as F
import Data.Int (Int32)
import Data.List (intercalate, sortOn, stripPrefix)
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
import qualified Data.Map.Strict as M
import qualified Data.MultiMap as MM
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Text (Text)
import qualified Data.Text as T
import System.Log.Logger (Priority(..), logM)
import Text.Printf (printf)
import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk
import qualified GI.Gtk as Gtk
import System.Environment.XDG.DesktopEntry
( DesktopEntry
, deFilename
, getDirectoryEntriesDefault
)
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.Util
( WindowIconWidget(..)
, computeIconStripLayout
, getImageForDesktopEntry
, handlePixbufGetterException
, scaledPixbufGetter
, syncWidgetPool
, updateWindowIconWidgetState
, widgetSetClassGI
, windowStatusClassFromFlags
)
import System.Taffybar.Widget.Generic.ScalingImage (getScalingImageStrategy)
import System.Taffybar.Widget.Workspaces.Shared
( WorkspaceState(..)
, setWorkspaceWidgetStatusClass
, buildWorkspaceIconLabelOverlay
, mkWorkspaceIconWidget
)
import System.Taffybar.WindowIcon (getWindowIconFromClasses, pixBufFromColor)
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
, widgetBuilder :: HyprlandControllerConstructor
, widgetGap :: Int
, maxIcons :: Maybe Int
, minIcons :: Int
, iconSize :: Int32
, getWindowIconPixbuf :: HyprlandWindowIconPixbufGetter
, labelSetter :: HyprlandWorkspace -> TaffyIO String
, showWorkspaceFn :: HyprlandWorkspace -> Bool
, iconSort :: [HyprlandWindow] -> TaffyIO [HyprlandWindow]
, urgentWorkspaceState :: Bool
}
defaultHyprlandWorkspacesConfig :: HyprlandWorkspacesConfig
defaultHyprlandWorkspacesConfig = cfg
where
cfg = HyprlandWorkspacesConfig
{ getWorkspaces = getHyprlandWorkspaces
, switchToWorkspace = hyprlandSwitchToWorkspace
, updateIntervalSeconds = 1
, widgetBuilder = defaultHyprlandWidgetBuilder cfg
, widgetGap = 0
, maxIcons = Nothing
, minIcons = 0
, iconSize = 16
, getWindowIconPixbuf = defaultHyprlandGetWindowIconPixbuf
, labelSetter = return . workspaceName
, showWorkspaceFn = \ws ->
workspaceState ws /= Empty && not (isSpecialHyprWorkspace ws)
-- Match the X11 Workspaces widget default: order icons by window position.
, iconSort = pure . sortHyprlandWindowsByPosition
, urgentWorkspaceState = False
}
instance Default HyprlandWorkspacesConfig where
def = defaultHyprlandWorkspacesConfig
hyprlandWorkspacesNew :: HyprlandWorkspacesConfig -> TaffyIO Gtk.Widget
hyprlandWorkspacesNew cfg = do
cont <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal $
fromIntegral (widgetGap cfg)
_ <- 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
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 (showWorkspaceFn cfg) ws))
(minIcons cfg)
(show (maxIcons cfg))
(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 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 <- widgetBuilder cfg 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 = showWorkspaceFn cfg
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
| urgentWorkspaceState 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
lbl <- liftIO $ Gtk.labelNew Nothing
_ <- widgetSetClassGI lbl "workspace-label"
labelText <- labelSetter cfg ws
liftIO $ Gtk.labelSetMarkup lbl (T.pack labelText)
liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) lbl
return $ HyprlandWWC $ HyprlandLabelController
{ hlcLabel = lbl
, hlcLabelSetter = labelSetter cfg
}
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
sortedWindows <- iconSort cfg $ windows ws
let (effectiveMinIcons, _targetLen, paddedWindows) =
computeIconStripLayout (minIcons cfg) (maxIcons cfg) 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
strategy <- getScalingImageStrategy
liftIO $
mkWorkspaceIconWidget
strategy
(Just $ iconSize cfg)
transparentOnNone
(\size w -> runReaderT (getWindowIconPixbuf cfg 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) (liftIO . getWindowIconFromClasses size) (windowClass windowData))
(maybe (return Nothing) (liftIO . getWindowIconFromClasses 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
}