packages feed

taffybar-7.0.0: src/System/Taffybar/Widget/SNITray.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

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

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

-- |
-- Module      : System.Taffybar.Widget.SNITray
-- Copyright   : (c) Ivan A. Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan A. Malison
-- Stability   : unstable
-- Portability : unportable
--
-- This module exports functions for the construction of
-- StatusNotifierItem/AppIndicator tray widgets, supplied by the
-- "StatusNotifier.Tray" module from the gtk-sni-tray library. These widgets do
-- not support the older XEMBED protocol, although bridges like
-- xembed-sni-proxy do allow sni trays to provide limited support for XEMBED
-- tray icons.
--
-- Unless 'sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt' is used it is
-- necessary to run status-notifier-watcher from the
-- [status-notifier-item](https://github.com/taffybar/status-notifier-item)
-- package before starting taffybar when using the functions defined in this
-- module. Using 'sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt' is
-- generally not recommended, because it can lead to issues with the
-- registration of tray icons if taffybar crashes/restarts, or if tray icon
-- providing applications are ever started before taffybar.
module System.Taffybar.Widget.SNITray
  ( TrayPriorityConfig,
    module System.Taffybar.Widget.SNITray,
  )
where

import Control.Monad (forM_, void, when)
import Control.Monad.Trans.Class
import Control.Monad.Trans.Reader
import Data.IORef
import Data.List (findIndex)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import qualified GI.GLib as GLib
import qualified GI.Gdk as Gdk
import qualified GI.Gtk as Gtk
import qualified StatusNotifier.Host.Service as H
import StatusNotifier.Tray hiding (TrayItemMatcher, TrayParams, defaultTrayPriorityConfig)
import qualified StatusNotifier.Tray as Tray
import System.Posix.Process
import System.Taffybar.Context
import System.Taffybar.Widget.Util
import Text.Printf

-- | Parameters controlling tray construction and orientation.
type TrayParams = Tray.TrayParams

-- | Predicate used to match tray items in priority configuration.
type TrayItemMatcher = Tray.TrayItemMatcher

-- | Default tray icon priority configuration.
defaultTrayPriorityConfig :: TrayPriorityConfig
defaultTrayPriorityConfig = Tray.defaultTrayPriorityConfig

-- | Configuration for the base SNI tray widget.
data SNITrayConfig = SNITrayConfig
  { sniTrayTrayParams :: TrayParams,
    sniTrayPriorityConfig :: TrayPriorityConfig
  }

-- | Default 'SNITrayConfig'.
defaultSNITrayConfig :: SNITrayConfig
defaultSNITrayConfig =
  SNITrayConfig
    { sniTrayTrayParams = defaultTrayParams,
      sniTrayPriorityConfig = defaultTrayPriorityConfig
    }

-- | Configuration for the collapsible SNI tray widget.
data CollapsibleSNITrayParams = CollapsibleSNITrayParams
  { -- | Base tray configuration used to build the underlying SNI tray.
    collapsibleSNITrayConfig :: SNITrayConfig,
    -- | Maximum number of tray icons to show when collapsed.
    --
    -- Non-positive values disable collapsing.
    collapsibleSNITrayMaxVisibleIcons :: Int,
    -- | Optional collapsed-mode priority cutoff.
    --
    -- When set, collapsed mode shows only icons whose priority index is less
    -- than or equal to this value, where the index comes from
    -- 'trayPriorityMatchers' order in 'TrayPriorityConfig'. This takes
    -- precedence over 'collapsibleSNITrayMaxVisibleIcons' when collapsed.
    collapsibleSNITrayCollapsedPriorityCutoff :: Maybe Int,
    -- | Whether the tray starts expanded.
    collapsibleSNITrayStartExpanded :: Bool,
    -- | Show the indicator while expanded.
    -- Useful to allow clicking it again to collapse.
    collapsibleSNITrayShowIndicatorWhenExpanded :: Bool,
    -- | Label renderer for the indicator.
    --
    -- Arguments: @(hiddenCount, expanded)@.
    collapsibleSNITrayIndicatorLabel :: Int -> Bool -> T.Text
  }

-- | Default indicator label renderer for the collapsible tray.
defaultCollapsibleSNITrayIndicatorLabel :: Int -> Bool -> T.Text
defaultCollapsibleSNITrayIndicatorLabel hiddenCount expanded
  | expanded = "-"
  | otherwise = "+" <> T.pack (show hiddenCount)

-- | Default 'CollapsibleSNITrayParams'.
defaultCollapsibleSNITrayParams :: CollapsibleSNITrayParams
defaultCollapsibleSNITrayParams =
  CollapsibleSNITrayParams
    { collapsibleSNITrayConfig = defaultSNITrayConfig,
      collapsibleSNITrayMaxVisibleIcons = 6,
      collapsibleSNITrayCollapsedPriorityCutoff = Nothing,
      collapsibleSNITrayStartExpanded = False,
      collapsibleSNITrayShowIndicatorWhenExpanded = True,
      collapsibleSNITrayIndicatorLabel = defaultCollapsibleSNITrayIndicatorLabel
    }

-- | Build a new StatusNotifierItem tray that will share a host with any other
-- trays that are constructed automatically
sniTrayNew :: TaffyIO Gtk.Widget
sniTrayNew = sniTrayNewFromConfig defaultSNITrayConfig

-- | Build a new StatusNotifierItem tray from custom 'SNITrayConfig'.
sniTrayNewFromConfig :: SNITrayConfig -> TaffyIO Gtk.Widget
sniTrayNewFromConfig config =
  getTrayHost False >>= sniTrayNewFromHostConfig config

-- | Build a new StatusNotifierItem tray from the provided 'TrayParams'.
sniTrayNewFromParams :: TrayParams -> TaffyIO Gtk.Widget
sniTrayNewFromParams params =
  sniTrayNewFromConfig $
    defaultSNITrayConfig {sniTrayTrayParams = params}

-- | Build a new StatusNotifierItem tray from the provided 'TrayParams' and
-- 'H.Host'.
sniTrayNewFromHostParams :: TrayParams -> H.Host -> TaffyIO Gtk.Widget
sniTrayNewFromHostParams params =
  sniTrayNewFromHostConfig $
    defaultSNITrayConfig {sniTrayTrayParams = params}

-- | Build a new StatusNotifierItem tray from custom 'SNITrayConfig' and
-- 'H.Host'.
sniTrayNewFromHostConfig :: SNITrayConfig -> H.Host -> TaffyIO Gtk.Widget
sniTrayNewFromHostConfig SNITrayConfig {..} host = do
  client <- asks sessionDBusClient
  lift $ do
    tray <-
      buildTray
        host
        client
        sniTrayTrayParams {trayPriorityConfig = sniTrayPriorityConfig}
    _ <- widgetSetClassGI tray "sni-tray"
    Gtk.widgetShowAll tray
    Gtk.toWidget tray

-- | Build a collapsible StatusNotifierItem tray with default params.
sniTrayCollapsibleNew :: TaffyIO Gtk.Widget
sniTrayCollapsibleNew =
  sniTrayCollapsibleNewFromParams defaultCollapsibleSNITrayParams

-- | Build a collapsible StatusNotifierItem tray from custom params.
sniTrayCollapsibleNewFromParams ::
  CollapsibleSNITrayParams -> TaffyIO Gtk.Widget
sniTrayCollapsibleNewFromParams params =
  getTrayHost False >>= sniTrayCollapsibleNewFromHostParams params

-- | Build a collapsible StatusNotifierItem tray from custom params and a host.
sniTrayCollapsibleNewFromHostParams ::
  CollapsibleSNITrayParams -> H.Host -> TaffyIO Gtk.Widget
sniTrayCollapsibleNewFromHostParams CollapsibleSNITrayParams {..} host = do
  client <- asks sessionDBusClient
  lift $ do
    let SNITrayConfig {..} = collapsibleSNITrayConfig
        TrayPriorityConfig {trayPriorityMatchers = priorityMatchers} = sniTrayPriorityConfig
    tray <-
      buildTray
        host
        client
        sniTrayTrayParams
          { trayPriorityConfig = sniTrayPriorityConfig,
            trayShowNewIconsImmediately = False
          }
    _ <- widgetSetClassGI tray "sni-tray"
    outer <- Gtk.boxNew (trayOrientation sniTrayTrayParams) 0
    _ <- widgetSetClassGI outer "sni-tray-collapsible"

    indicatorLabel <- Gtk.labelNew Nothing
    _ <- widgetSetClassGI indicatorLabel "sni-tray-overflow-indicator-label"
    indicator <- Gtk.eventBoxNew
    _ <- widgetSetClassGI indicator "sni-tray-overflow-indicator"
    Gtk.containerAdd indicator indicatorLabel

    Gtk.boxPackStart outer tray False False 0
    Gtk.boxPackStart outer indicator False False 0

    expandedRef <- newIORef collapsibleSNITrayStartExpanded

    let maxVisible = collapsibleSNITrayMaxVisibleIcons
        refresh = do
          children <- Gtk.containerGetChildren tray

          expanded <- readIORef expandedRef
          priorityVisibleCount <-
            case collapsibleSNITrayCollapsedPriorityCutoff of
              Nothing -> return Nothing
              Just cutoff -> do
                infoMap <- H.itemInfoMap host
                let getPriorityIndex info =
                      fromMaybe
                        (length priorityMatchers)
                        (findIndex (`trayItemMatcherPredicate` info) priorityMatchers)
                    visibleCount =
                      length $
                        filter
                          (\info -> getPriorityIndex info <= cutoff)
                          (M.elems infoMap)
                return (Just visibleCount)
          let shouldLimit = maybe (maxVisible > 0) (const True) priorityVisibleCount
              collapsedVisibleCount = fromMaybe maxVisible priorityVisibleCount
              visibleCount
                | shouldLimit && not expanded = max 0 collapsedVisibleCount
                | otherwise = length children
              hiddenCount = max 0 (length children - visibleCount)
              showIndicator
                | expanded =
                    shouldLimit
                      && length children > maxVisible
                      && collapsibleSNITrayShowIndicatorWhenExpanded
                | otherwise = hiddenCount > 0
              indicatorText =
                collapsibleSNITrayIndicatorLabel hiddenCount expanded

          forM_ (zip [0 :: Int ..] children) $ \(childIndex, child) -> do
            let shouldShow = childIndex < visibleCount
            isVisible <- Gtk.widgetGetVisible child
            when (isVisible /= shouldShow) $
              if shouldShow
                then Gtk.widgetShow child
                else Gtk.widgetHide child

          if showIndicator
            then do
              Gtk.labelSetText indicatorLabel indicatorText
              Gtk.widgetSetTooltipText
                indicator
                ( Just $
                    if expanded
                      then "Collapse tray icons"
                      else T.pack $ "Show " ++ show hiddenCount ++ " more tray icons"
                )
              Gtk.widgetShowAll indicator
            else do
              Gtk.widgetSetTooltipText indicator Nothing
              Gtk.widgetHide indicator

          if expanded
            then addClassIfMissing "sni-tray-collapsible-expanded" outer
            else removeClassIfPresent "sni-tray-collapsible-expanded" outer

          return hiddenCount

    _ <- Gtk.onWidgetButtonPressEvent indicator $ \event -> do
      eventType <- Gdk.getEventButtonType event
      button <- Gdk.getEventButtonButton event
      if eventType == Gdk.EventTypeButtonPress && button == 1
        then do
          hiddenCount <- refresh
          expanded <- readIORef expandedRef
          if hiddenCount > 0 || expanded
            then modifyIORef' expandedRef not >> void refresh >> return True
            else return False
        else return False

    let queueRefresh _ _ =
          void $
            Gdk.threadsAddIdle GLib.PRIORITY_DEFAULT $
              void refresh >> return False
    handlerId <- H.addUpdateHandler host queueRefresh
    _ <- Gtk.onWidgetDestroy outer $ H.removeUpdateHandler host handlerId

    _ <- refresh
    Gtk.widgetShowAll outer
    Gtk.toWidget outer

-- | Build a new StatusNotifierItem tray that also starts its own watcher,
-- without depending on status-notifier-icon. This will not register applets
-- started before the watcher is started.
sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt :: TaffyIO Gtk.Widget
sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt =
  sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoItFromConfig
    defaultSNITrayConfig

-- | Build a new StatusNotifierItem tray that also starts its own watcher,
-- with custom 'SNITrayConfig'.
sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoItFromConfig ::
  SNITrayConfig -> TaffyIO Gtk.Widget
sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoItFromConfig config =
  getTrayHost True >>= sniTrayNewFromHostConfig config

-- | Build a collapsible StatusNotifierItem tray that also starts its own
-- watcher, without depending on status-notifier-icon.
sniTrayCollapsibleThatStartsWatcherEvenThoughThisIsABadWayToDoIt ::
  TaffyIO Gtk.Widget
sniTrayCollapsibleThatStartsWatcherEvenThoughThisIsABadWayToDoIt =
  getTrayHost True
    >>= sniTrayCollapsibleNewFromHostParams defaultCollapsibleSNITrayParams

-- | Get a 'H.Host' from 'TaffyIO' internal state, that can be used to construct
-- SNI tray widgets. The boolean parameter determines whether or not a watcher
-- will be started the first time 'getTrayHost' is invoked.
getTrayHost :: Bool -> TaffyIO H.Host
getTrayHost startWatcher = getStateDefault $ do
  pid <- lift getProcessID
  client <- asks sessionDBusClient
  Just host <-
    lift $
      H.build
        H.defaultParams
          { H.dbusClient = Just client,
            H.uniqueIdentifier = printf "taffybar-%s" $ show pid,
            H.startWatcher = startWatcher
          }
  return host