packages feed

taffybar-5.2.0: src/System/Taffybar/Information/Workspaces/Hyprland.hs

{-# LANGUAGE OverloadedStrings #-}

-----------------------------------------------------------------------------

-----------------------------------------------------------------------------

-- |
-- Module      : System.Taffybar.Information.Workspaces.Hyprland
-- Copyright   : (c) Ivan A. Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan A. Malison
-- Stability   : unstable
-- Portability : unportable
--
-- Shared Hyprland workspace-state provider using a broadcast channel + state MVar.
module System.Taffybar.Information.Workspaces.Hyprland
  ( HyprlandWorkspaceProviderConfig (..),
    defaultHyprlandWorkspaceProviderConfig,
    defaultHyprlandWorkspaceState,
    isRelevantHyprlandWorkspaceEvent,
    getHyprlandWorkspaceStateAndEventChansAndVar,
    getHyprlandWorkspaceStateAndEventChansAndVarWith,
    getHyprlandWorkspaceEventChan,
    getHyprlandWorkspaceEventChanWith,
    getHyprlandWorkspaceStateChanAndVar,
    getHyprlandWorkspaceStateChanAndVarWith,
    getHyprlandWorkspaceStateChan,
    getHyprlandWorkspaceStateChanWith,
    getHyprlandWorkspaceState,
    getHyprlandWorkspaceStateWith,
  )
where

import Control.Applicative ((<|>))
import Control.Concurrent.MVar
import Control.Concurrent.STM.TChan
import Control.Exception.Enclosed (catchAny)
import Control.Monad (forever, when)
import Control.Monad.IO.Class (MonadIO (..))
import Control.Monad.STM (atomically)
import Data.Either (isRight)
import qualified Data.Foldable as F
import Data.List (sortOn)
import qualified Data.Map.Strict as M
import Data.Maybe (catMaybes, fromMaybe, listToMaybe)
import qualified Data.Text as T
import System.Log.Logger (Priority (..), logM)
import System.Taffybar.Context (TaffyIO, getStateDefault, taffyFork)
import System.Taffybar.Hyprland (getHyprlandClient, getHyprlandEventChan)
import qualified System.Taffybar.Information.Hyprland as Hypr
import qualified System.Taffybar.Information.Hyprland.API as HyprAPI
import qualified System.Taffybar.Information.Hyprland.Types as HyprTypes
import System.Taffybar.Information.Workspaces.Model

data HyprlandWorkspaceProviderConfig = HyprlandWorkspaceProviderConfig
  { workspaceSnapshotGetter :: TaffyIO (Bool, [WorkspaceInfo]),
    workspaceEventFilter :: T.Text -> Bool
  }

defaultHyprlandWorkspaceProviderConfig :: HyprlandWorkspaceProviderConfig
defaultHyprlandWorkspaceProviderConfig =
  HyprlandWorkspaceProviderConfig
    { workspaceSnapshotGetter = buildHyprlandWorkspaceSnapshot,
      workspaceEventFilter = isRelevantHyprlandWorkspaceEvent
    }

defaultHyprlandWorkspaceState :: WorkspaceSnapshot
defaultHyprlandWorkspaceState =
  WorkspaceSnapshot
    { snapshotBackend = WorkspaceBackendHyprland,
      snapshotRevision = 0,
      snapshotWindowDataComplete = False,
      snapshotWorkspaces = []
    }

newtype HyprlandWorkspaceStateChanVar
  = HyprlandWorkspaceStateChanVar
      (TChan WorkspaceSnapshot, TChan WorkspaceEventBatch, MVar WorkspaceSnapshot)

wLog :: (MonadIO m) => Priority -> String -> m ()
wLog level msg = liftIO $ logM "System.Taffybar.Information.Workspaces.Hyprland" level msg

-- | Parse Hyprland event-socket lines and determine whether workspace state
-- should be refreshed.
isRelevantHyprlandWorkspaceEvent :: T.Text -> Bool
isRelevantHyprlandWorkspaceEvent line =
  let eventName = T.takeWhile (/= '>') line
   in eventName
        `elem` [ "workspace",
                 "workspacev2",
                 "focusedmon",
                 "activewindow",
                 "activewindowv2",
                 "openwindow",
                 "closewindow",
                 "movewindow",
                 "movewindowv2",
                 "moveworkspace",
                 "renameworkspace",
                 "createworkspace",
                 "destroyworkspace",
                 "monitoradded",
                 "monitorremoved",
                 "taffybar-hyprland-connected"
               ]

getHyprlandWorkspaceStateAndEventChansAndVar ::
  TaffyIO (TChan WorkspaceSnapshot, TChan WorkspaceEventBatch, MVar WorkspaceSnapshot)
getHyprlandWorkspaceStateAndEventChansAndVar =
  getHyprlandWorkspaceStateAndEventChansAndVarWith defaultHyprlandWorkspaceProviderConfig

getHyprlandWorkspaceStateAndEventChansAndVarWith ::
  HyprlandWorkspaceProviderConfig ->
  TaffyIO (TChan WorkspaceSnapshot, TChan WorkspaceEventBatch, MVar WorkspaceSnapshot)
getHyprlandWorkspaceStateAndEventChansAndVarWith cfg = do
  HyprlandWorkspaceStateChanVar chansAndVar <- getStateDefault $ buildHyprlandWorkspaceStateChanVar cfg
  return chansAndVar

getHyprlandWorkspaceEventChan :: TaffyIO (TChan WorkspaceEventBatch)
getHyprlandWorkspaceEventChan =
  getHyprlandWorkspaceEventChanWith defaultHyprlandWorkspaceProviderConfig

getHyprlandWorkspaceEventChanWith ::
  HyprlandWorkspaceProviderConfig ->
  TaffyIO (TChan WorkspaceEventBatch)
getHyprlandWorkspaceEventChanWith cfg = do
  (_, eventChan, _) <- getHyprlandWorkspaceStateAndEventChansAndVarWith cfg
  return eventChan

getHyprlandWorkspaceStateChanAndVar ::
  TaffyIO (TChan WorkspaceSnapshot, MVar WorkspaceSnapshot)
getHyprlandWorkspaceStateChanAndVar =
  getHyprlandWorkspaceStateChanAndVarWith defaultHyprlandWorkspaceProviderConfig

-- | Obtain the shared Hyprland workspace state channel and state MVar.
--
-- Note: like other 'getStateDefault' based providers, the first caller wins:
-- if this provider has already been initialized, later calls with a different
-- config return the existing provider state.
getHyprlandWorkspaceStateChanAndVarWith ::
  HyprlandWorkspaceProviderConfig ->
  TaffyIO (TChan WorkspaceSnapshot, MVar WorkspaceSnapshot)
getHyprlandWorkspaceStateChanAndVarWith cfg = do
  (stateChan, _, stateVar) <- getHyprlandWorkspaceStateAndEventChansAndVarWith cfg
  return (stateChan, stateVar)

getHyprlandWorkspaceStateChan :: TaffyIO (TChan WorkspaceSnapshot)
getHyprlandWorkspaceStateChan =
  getHyprlandWorkspaceStateChanWith defaultHyprlandWorkspaceProviderConfig

getHyprlandWorkspaceStateChanWith ::
  HyprlandWorkspaceProviderConfig ->
  TaffyIO (TChan WorkspaceSnapshot)
getHyprlandWorkspaceStateChanWith cfg =
  fst <$> getHyprlandWorkspaceStateChanAndVarWith cfg

getHyprlandWorkspaceState :: TaffyIO WorkspaceSnapshot
getHyprlandWorkspaceState =
  getHyprlandWorkspaceStateWith defaultHyprlandWorkspaceProviderConfig

getHyprlandWorkspaceStateWith ::
  HyprlandWorkspaceProviderConfig ->
  TaffyIO WorkspaceSnapshot
getHyprlandWorkspaceStateWith cfg = do
  (_, var) <- getHyprlandWorkspaceStateChanAndVarWith cfg
  liftIO $ readMVar var

buildHyprlandWorkspaceStateChanVar ::
  HyprlandWorkspaceProviderConfig ->
  TaffyIO HyprlandWorkspaceStateChanVar
buildHyprlandWorkspaceStateChanVar cfg = do
  stateChan <- liftIO newBroadcastTChanIO
  eventChan <- liftIO newBroadcastTChanIO
  var <- liftIO $ newMVar defaultHyprlandWorkspaceState
  taffyFork $ hyprlandWorkspaceStateLoop cfg stateChan eventChan var
  return $ HyprlandWorkspaceStateChanVar (stateChan, eventChan, var)

hyprlandWorkspaceStateLoop ::
  HyprlandWorkspaceProviderConfig ->
  TChan WorkspaceSnapshot ->
  TChan WorkspaceEventBatch ->
  MVar WorkspaceSnapshot ->
  TaffyIO ()
hyprlandWorkspaceStateLoop cfg stateChan workspaceEventChan var = do
  refreshHyprlandWorkspaceState cfg stateChan workspaceEventChan var
  hyprlandEventChan <- getHyprlandEventChan
  events <- liftIO $ Hypr.subscribeHyprlandEvents hyprlandEventChan
  forever $ do
    line <- liftIO $ atomically $ readTChan events
    when (workspaceEventFilter cfg line) $
      refreshHyprlandWorkspaceState cfg stateChan workspaceEventChan var

refreshHyprlandWorkspaceState ::
  HyprlandWorkspaceProviderConfig ->
  TChan WorkspaceSnapshot ->
  TChan WorkspaceEventBatch ->
  MVar WorkspaceSnapshot ->
  TaffyIO ()
refreshHyprlandWorkspaceState cfg stateChan workspaceEventChan var = do
  previous <- liftIO $ readMVar var
  (complete, workspaces) <-
    workspaceSnapshotGetter cfg
      `catchAny` \err -> do
        wLog WARNING $ "Hyprland workspace snapshot update failed: " <> show err
        return (False, snapshotWorkspaces previous)
  let next =
        WorkspaceSnapshot
          { snapshotBackend = WorkspaceBackendHyprland,
            snapshotRevision = snapshotRevision previous + 1,
            snapshotWindowDataComplete = complete,
            snapshotWorkspaces = workspaces
          }
      eventBatch =
        WorkspaceEventBatch
          { eventBatchBackend = snapshotBackend next,
            eventBatchRevision = snapshotRevision next,
            eventBatchWindowDataComplete = snapshotWindowDataComplete next,
            eventBatchEvents = diffWorkspaceSnapshots previous next
          }
  liftIO $ do
    _ <- swapMVar var next
    atomically $ do
      writeTChan stateChan next
      writeTChan workspaceEventChan eventBatch

buildHyprlandWorkspaceSnapshot :: TaffyIO (Bool, [WorkspaceInfo])
buildHyprlandWorkspaceSnapshot = do
  client <- getHyprlandClient
  workspacesResult <- liftIO $ HyprAPI.getHyprlandWorkspaces client
  clientsResult <- liftIO $ HyprAPI.getHyprlandClients client
  monitorsResult <- liftIO $ HyprAPI.getHyprlandMonitors client
  activeWorkspaceResult <- liftIO $ HyprAPI.getHyprlandActiveWorkspace client
  activeWindowResult <- liftIO $ HyprAPI.getHyprlandActiveWindow client

  workspaces <- case workspacesResult of
    Left err -> wLog WARNING ("hyprctl workspaces failed: " <> show err) >> return []
    Right ws -> return ws

  clients <- case clientsResult of
    Left err -> wLog WARNING ("hyprctl clients failed: " <> show err) >> return []
    Right cs -> return cs

  monitors <- case monitorsResult of
    Left err -> wLog WARNING ("hyprctl monitors failed: " <> show err) >> return []
    Right ms -> return ms

  activeWorkspaceId <- case activeWorkspaceResult of
    Left err -> wLog WARNING ("hyprctl activeworkspace failed: " <> show err) >> return Nothing
    Right ws -> return $ HyprTypes.hyprActiveWorkspaceId ws

  activeWindowAddress <- case activeWindowResult of
    Left err -> wLog WARNING ("hyprctl activewindow failed: " <> show err) >> return Nothing
    Right win ->
      let address = HyprTypes.hyprActiveWindowAddress win
       in return $ if T.null address then Nothing else Just address

  let windowsByWorkspace = collectWorkspaceWindows activeWindowAddress clients
      sortedWorkspaces = sortOn HyprTypes.hyprWorkspaceId workspaces
      visibleWorkspaceIds =
        [ HyprTypes.hyprWorkspaceRefId wsRef
        | monitor <- monitors,
          Just wsRef <- [HyprTypes.hyprMonitorActiveWorkspace monitor]
        ]
      focusedWorkspaceId =
        listToMaybe
          [ HyprTypes.hyprWorkspaceRefId wsRef
          | monitor <- monitors,
            HyprTypes.hyprMonitorFocused monitor,
            Just wsRef <- [HyprTypes.hyprMonitorActiveWorkspace monitor]
          ]
      activeWorkspaceId' = focusedWorkspaceId <|> activeWorkspaceId
      clientsOk = isRight clientsResult
      toWorkspace wsInfo =
        let wsId = HyprTypes.hyprWorkspaceId wsInfo
            wsName = HyprTypes.hyprWorkspaceName wsInfo
            wsWindows = M.findWithDefault [] wsId windowsByWorkspace
            hasWindows =
              not (null wsWindows)
                || fromMaybe 0 (HyprTypes.hyprWorkspaceWindows wsInfo) > 0
            wsState
              | Just wsId == activeWorkspaceId' = WorkspaceActive
              | wsId `elem` visibleWorkspaceIds = WorkspaceVisible
              | not hasWindows && clientsOk = WorkspaceEmpty
              | otherwise = WorkspaceHidden
         in WorkspaceInfo
              { workspaceIdentity =
                  WorkspaceIdentity
                    { workspaceNumericId = Just wsId,
                      workspaceName = wsName
                    },
                workspaceState = wsState,
                workspaceHasUrgentWindow = any windowUrgent wsWindows,
                workspaceIsSpecial = isSpecialWorkspace wsId wsName,
                workspaceWindows = wsWindows
              }
  return (clientsOk, map toWorkspace sortedWorkspaces)

isSpecialWorkspace :: Int -> T.Text -> Bool
isSpecialWorkspace wsId wsName =
  let lowered = T.toLower wsName
   in wsId < 0 || T.isPrefixOf "special" lowered

collectWorkspaceWindows ::
  Maybe T.Text ->
  [HyprTypes.HyprlandClientInfo] ->
  M.Map Int [WindowInfo]
collectWorkspaceWindows activeWindowAddress =
  F.foldl' (addWindow activeWindowAddress) M.empty
  where
    addWindow activeAddr windowsMap client =
      let wsId = HyprTypes.hyprWorkspaceRefId $ HyprTypes.hyprClientWorkspace client
          windowData = windowFromClient activeAddr client
       in M.insertWith (++) wsId [windowData] windowsMap

windowFromClient :: Maybe T.Text -> HyprTypes.HyprlandClientInfo -> WindowInfo
windowFromClient activeAddr client =
  let rawTitle = HyprTypes.hyprClientTitle client
      title =
        if T.null rawTitle
          then fromMaybe "" (HyprTypes.hyprClientInitialTitle client)
          else rawTitle
      active =
        HyprTypes.hyprClientFocused client
          || Just (HyprTypes.hyprClientAddress client) == activeAddr
      minimized =
        HyprTypes.hyprClientHidden client
          || not (HyprTypes.hyprClientMapped client)
   in WindowInfo
        { windowIdentity = HyprlandWindowIdentity (HyprTypes.hyprClientAddress client),
          windowTitle = title,
          windowClassHints =
            catMaybes
              [ HyprTypes.hyprClientClass client,
                HyprTypes.hyprClientInitialClass client
              ],
          windowPosition = HyprTypes.hyprClientAt client,
          windowUrgent = HyprTypes.hyprClientUrgent client,
          windowActive = active,
          windowMinimized = minimized
        }