packages feed

taffybar-7.2.0: src/System/Taffybar/Widget/Windows.hs

{-# LANGUAGE OverloadedStrings #-}

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

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

-- |
-- Module      : System.Taffybar.Widget.Windows
-- Copyright   : (c) Ivan Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan Malison <IvanMalison@gmail.com>
-- Stability   : unstable
-- Portability : unportable
--
-- Menu widget that shows the title of the currently focused window and that,
-- when clicked, displays a menu from which the user may select a window to
-- switch focus to.
module System.Taffybar.Widget.Windows where

import qualified Control.Concurrent.MVar as MV
import Control.Concurrent.STM.TChan (TChan)
import Control.Monad (forM_, void)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Class
import Control.Monad.Trans.Maybe
import Control.Monad.Trans.Reader
import Data.Default (Default (..))
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Int (Int32)
import Data.List (find)
import qualified Data.List as List
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk
import qualified GI.Gtk as Gtk
import qualified GI.Pango as Pango
import qualified Graphics.UI.GIGtkScalingImage as Scaling
import System.Taffybar.Context
import System.Taffybar.Information.EWMHDesktopInfo
  ( ewmhWMIcon,
  )
import System.Taffybar.Information.Workspaces.EWMH
  ( defaultEWMHWorkspaceProviderConfig,
    getEWMHWorkspaceStateChanAndVarWith,
    workspaceUpdateEvents,
  )
import System.Taffybar.Information.Workspaces.Hyprland
  ( getHyprlandWorkspaceStateChanAndVar,
  )
import System.Taffybar.Information.Workspaces.Model
import System.Taffybar.Information.Workspaces.Support
  ( defaultOnWindowClick,
    getWindowIconPixbufByClassHints,
    sortWindowsByPosition,
  )
import System.Taffybar.Util
import System.Taffybar.Widget.Generic.ChannelWidget (channelWidgetNew)
import System.Taffybar.Widget.Generic.ScalingImage (scalingImage)
import System.Taffybar.Widget.Util (widgetSetClassGI)

-- | Behavior configuration for the windows menu widget.
data WindowsConfig = WindowsConfig
  { -- | A monadic function used to build labels for windows in the menu.
    getMenuLabel :: WindowInfo -> TaffyIO T.Text,
    -- | Action to build the label text for the active window.
    getActiveLabel :: Maybe WindowInfo -> TaffyIO T.Text,
    -- | Customize the active-window label widget after creation.
    configureActiveLabel :: Gtk.Label -> TaffyIO (),
    -- | Optional function to retrieve a pixbuf to show next to the
    -- active-window label.
    getActiveWindowIconPixbuf :: Maybe (Int32 -> WindowInfo -> TaffyIO (Maybe Gdk.Pixbuf)),
    -- | Sort menu windows before rendering.
    menuWindowSort :: [WindowInfo] -> TaffyIO [WindowInfo],
    -- | Action when a menu item is selected.
    onMenuWindowClick :: WindowInfo -> TaffyIO ()
  }

defaultChannelEWMHWorkspaceStateSource ::
  TaffyIO (TChan WorkspaceSnapshot, MV.MVar WorkspaceSnapshot)
defaultChannelEWMHWorkspaceStateSource =
  getEWMHWorkspaceStateChanAndVarWith providerConfig
  where
    providerConfig =
      defaultEWMHWorkspaceProviderConfig
        { workspaceUpdateEvents =
            ewmhWMIcon : workspaceUpdateEvents defaultEWMHWorkspaceProviderConfig
        }

autoWindowStateSource :: TaffyIO (TChan WorkspaceSnapshot, MV.MVar WorkspaceSnapshot)
autoWindowStateSource = do
  backendType <- asks backend
  case backendType of
    BackendWayland -> getHyprlandWorkspaceStateChanAndVar
    BackendX11 -> defaultChannelEWMHWorkspaceStateSource

-- | Build a menu label from the window title.
defaultGetMenuLabel :: WindowInfo -> TaffyIO T.Text
defaultGetMenuLabel = truncatedGetMenuLabel 35

-- | Default active-window label renderer.
defaultGetActiveLabel :: Maybe WindowInfo -> TaffyIO T.Text
defaultGetActiveLabel = maybe (return "") defaultGetMenuLabel

-- | Truncate the active-window label to a maximum length.
truncatedGetActiveLabel :: Int -> Maybe WindowInfo -> TaffyIO T.Text
truncatedGetActiveLabel maxLength windowInfo =
  truncateText maxLength <$> defaultGetActiveLabel windowInfo

-- | Truncate window labels in the popup menu to a maximum length.
truncatedGetMenuLabel :: Int -> WindowInfo -> TaffyIO T.Text
truncatedGetMenuLabel maxLength windowInfo =
  return $ truncateText maxLength (windowTitle windowInfo)

-- | Default configuration used by 'windowsNew'.
defaultWindowsConfig :: WindowsConfig
defaultWindowsConfig =
  WindowsConfig
    { getMenuLabel = defaultGetMenuLabel,
      getActiveLabel = defaultGetActiveLabel,
      configureActiveLabel = const (pure ()),
      getActiveWindowIconPixbuf = Just getWindowIconPixbufByClassHints,
      menuWindowSort = pure . sortWindowsByPosition,
      onMenuWindowClick = defaultOnWindowClick
    }

-- | EWMH/X11 preset configuration.
defaultEWMHWindowsConfig :: WindowsConfig
defaultEWMHWindowsConfig = defaultWindowsConfig

instance Default WindowsConfig where
  def = defaultWindowsConfig

-- | Create a new channel-driven backend-agnostic windows widget.
windowsNew :: WindowsConfig -> TaffyIO Gtk.Widget
windowsNew config = do
  hbox <- lift $ Gtk.boxNew Gtk.OrientationHorizontal 0
  menu <- lift Gtk.menuNew
  (stateChan, stateVar) <- autoWindowStateSource
  initialSnapshot <- liftIO $ MV.readMVar stateVar
  activeWindowRef <- liftIO $ newIORef $ getActiveWindow initialSnapshot

  refreshIcon <- case getActiveWindowIconPixbuf config of
    Just getIcon -> do
      (rf, icon) <- buildWindowsIcon activeWindowRef getIcon
      Gtk.boxPackStart hbox icon True True 0
      pure rf
    Nothing -> pure (pure ())

  (setLabelTitle, label) <- buildWindowsLabel config
  Gtk.boxPackStart hbox label True True 0

  let refreshFromSnapshot snapshot = do
        let activeWindow = getActiveWindow snapshot
        liftIO $ writeIORef activeWindowRef activeWindow
        labelText <- getActiveLabel config activeWindow
        lift $ setLabelTitle labelText
        lift refreshIcon
        lift $ clearMenu menu
        fillMenu config snapshot menu

  void $ refreshFromSnapshot initialSnapshot

  ctx <- ask
  _ <-
    liftIO $
      Gtk.onWidgetRealize hbox $ do
        latestSnapshot <- MV.readMVar stateVar
        void $ runReaderT (refreshFromSnapshot latestSnapshot) ctx
  _ <-
    liftIO $
      channelWidgetNew
        hbox
        stateChan
        (\snapshot -> postGUIASync $ runReaderT (refreshFromSnapshot snapshot) ctx)

  boxWidget <- Gtk.toWidget hbox
  button <- lift Gtk.menuButtonNew
  lift $ Gtk.containerAdd button boxWidget
  lift $ Gtk.menuButtonSetPopup button $ Just menu
  lift $ Gtk.widgetShowAll button
  menuButtonWidget <- Gtk.toWidget button

  widgetSetClassGI menuButtonWidget "windows"

-- | Build the active-window label and return an update action for it.
buildWindowsLabel :: WindowsConfig -> TaffyIO (T.Text -> IO (), Gtk.Widget)
buildWindowsLabel config = do
  label <- lift $ Gtk.labelNew Nothing
  lift $ Gtk.labelSetSingleLineMode label True
  lift $ Gtk.labelSetEllipsize label Pango.EllipsizeModeEnd
  configureActiveLabel config label
  let setLabelTitle title = postGUIASync $ Gtk.labelSetText label title
  (setLabelTitle,) <$> Gtk.toWidget label

-- | Build the active-window icon and return an update action for it.
buildWindowsIcon ::
  IORef (Maybe WindowInfo) ->
  (Int32 -> WindowInfo -> TaffyIO (Maybe Gdk.Pixbuf)) ->
  TaffyIO (IO (), Gtk.Widget)
buildWindowsIcon activeWindowRef windowIconPixbufGetter = do
  runTaffy <- asks (flip runReaderT)
  let getActiveWindowPixbuf size = runTaffy . runMaybeT $ do
        windowInfo <- MaybeT $ liftIO $ readIORef activeWindowRef
        MaybeT $ windowIconPixbufGetter size windowInfo

  (imageWidget, updateImage) <- scalingImage getActiveWindowPixbuf Gtk.OrientationHorizontal
  return (postGUIASync updateImage, imageWidget)

-- | Populate the given menu widget with the list of currently open windows.
fillMenu ::
  (Gtk.IsMenuShell a) =>
  WindowsConfig ->
  WorkspaceSnapshot ->
  a ->
  ReaderT Context IO ()
fillMenu config snapshot menu =
  ask >>= \context -> do
    windows <- menuWindowSort config (getWindows snapshot)
    forM_ windows $ \windowInfo ->
      lift $ do
        labelText <- runReaderT (getMenuLabel config windowInfo) context
        let focusCallback =
              runReaderT (onMenuWindowClick config windowInfo) context
                >> return True
        item <- Gtk.menuItemNew
        content <- Gtk.boxNew Gtk.OrientationHorizontal 6
        forM_ (getActiveWindowIconPixbuf config) $ \getIcon -> do
          icon <- buildMenuIcon context getIcon windowInfo
          Gtk.boxPackStart content icon False False 0
        label <- Gtk.labelNew (Just labelText)
        Gtk.labelSetXalign label 0
        Gtk.boxPackStart content label True True 0
        Gtk.containerAdd item content
        _ <- Gtk.onWidgetButtonPressEvent item $ const focusCallback
        Gtk.menuShellAppend menu item
        Gtk.widgetShowAll item

clearMenu :: (Gtk.IsContainer a) => a -> IO ()
clearMenu menu =
  Gtk.containerForeach menu $ \item ->
    Gtk.containerRemove menu item >> Gtk.widgetDestroy item

buildMenuIcon ::
  Context ->
  (Int32 -> WindowInfo -> TaffyIO (Maybe Gdk.Pixbuf)) ->
  WindowInfo ->
  IO Gtk.DrawingArea
buildMenuIcon context getIcon windowInfo = do
  icon <- Gtk.drawingAreaNew
  Gtk.widgetSetSizeRequest icon windowsMenuIconSize windowsMenuIconSize
  Gtk.setWidgetHalign icon Gtk.AlignCenter
  Gtk.setWidgetValign icon Gtk.AlignCenter
  void $
    Scaling.autoFillImage
      icon
      (\size -> runReaderT (getIcon size windowInfo) context)
      Gtk.OrientationHorizontal
  return icon

windowsMenuIconSize :: Int32
windowsMenuIconSize = 16

getWindows :: WorkspaceSnapshot -> [WindowInfo]
getWindows snapshot = orderedBuilder []
  where
    allWindows =
      [ win
      | wsInfo <- snapshotWorkspaces snapshot,
        win <- workspaceWindows wsInfo
      ]
    (_, orderedBuilder) = List.foldl' keepFirst (Set.empty, id) allWindows
    keepFirst (seen, build) windowInfo
      | windowIdentity windowInfo `Set.member` seen = (seen, build)
      | otherwise =
          (Set.insert (windowIdentity windowInfo) seen, build . (windowInfo :))

getActiveWindow :: WorkspaceSnapshot -> Maybe WindowInfo
getActiveWindow snapshot =
  find windowActive allWindows
  where
    allWindows =
      [ win
      | wsInfo <- snapshotWorkspaces snapshot,
        win <- workspaceWindows wsInfo
      ]