packages feed

taffybar-7.2.5: src/System/Taffybar/WidgetPriority.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}

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

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

-- |
-- Module      : System.Taffybar.WidgetPriority
-- Copyright   : (c) Ivan A. Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan A. Malison
-- Stability   : unstable
-- Portability : unportable
--
-- Priority-based dynamic hiding of bar widgets when the bar runs out of
-- horizontal space.
--
-- Wrap any widget constructor with 'withPriority' to make it hideable:
--
-- > endWidgets = [clock, withPriority 10 battery, withPriority 3 weather]
--
-- Widgets without a priority are never hidden. When the natural widths of
-- all widgets exceed the width allocated to the bar, prioritized widgets
-- are hidden lowest-priority-first until everything fits. Hidden widgets
-- are shown again when enough space (plus a small hysteresis margin)
-- becomes available. The mechanism is entirely opt-in: bars that contain
-- no prioritized widgets get no visibility controller at all.
module System.Taffybar.WidgetPriority
  ( -- * Assigning priorities
    withPriority,
    setWidgetPriority,
    getWidgetPriority,

    -- * Hiding configuration
    DynamicHidingConfig (..),
    defaultDynamicHidingConfig,

    -- * Bar wiring (used by "System.Taffybar.Context")
    setupDynamicHiding,
  )
where

import Control.Monad (filterM, forM, forM_, unless, void, when)
import Control.Monad.IO.Class (MonadIO)
import Data.Default (Default (..))
import Data.IORef
import Data.Int (Int32)
import Data.List (sortOn)
import Data.Maybe (isJust)
import Data.Ord (Down (..))
import qualified Data.Text as T
import Foreign.Ptr (Ptr, intPtrToPtr, ptrToIntPtr)
import qualified GI.GLib as GLib
import qualified GI.GObject as GObject
import qualified GI.Gtk as Gtk
import System.Log.Logger (Priority (DEBUG), logM)
import Text.Printf (printf)

priorityLog :: Priority -> String -> IO ()
priorityLog = logM "System.Taffybar.WidgetPriority"

-- | GObject data key under which a widget's priority is stored.
priorityDataKey :: T.Text
priorityDataKey = "taffy-widget-priority"

-- | GObject data key marking widgets that were hidden by the visibility
-- controller (as opposed to widgets that hid themselves).
hiddenByControllerKey :: T.Text
hiddenByControllerKey = "taffy-priority-hidden"

-- Priorities are smuggled through g_object_set_data's @Ptr ()@ payload.
-- 0 (the null pointer) means "no priority set", so non-negative priorities
-- are stored shifted up by one.
encodePriority :: Int -> Ptr ()
encodePriority p = intPtrToPtr $ fromIntegral $ if p >= 0 then p + 1 else p

decodePriority :: Ptr () -> Maybe Int
decodePriority ptr =
  case fromIntegral (ptrToIntPtr ptr) :: Int of
    0 -> Nothing
    v
      | v > 0 -> Just (v - 1)
      | otherwise -> Just v

-- | Annotate an already-built widget with a hiding priority. The annotation
-- mechanism works on any 'GObject.Object', though the bar only consults it on
-- top-level section widgets.
setWidgetPriority :: (MonadIO m, GObject.IsObject o) => Int -> o -> m ()
setWidgetPriority p widget = do
  object <- GObject.toObject widget
  GObject.objectSetData object priorityDataKey (encodePriority p)

-- | Read back a priority set with 'setWidgetPriority' or 'withPriority'.
getWidgetPriority :: (MonadIO m, GObject.IsObject o) => o -> m (Maybe Int)
getWidgetPriority widget = do
  object <- GObject.toObject widget
  decodePriority <$> GObject.objectGetData object priorityDataKey

-- | Give a hiding priority to the widget produced by the given constructor.
-- Lower priorities are hidden first when the bar runs out of space; widgets
-- built without 'withPriority' are never hidden. This combinator does not
-- change the constructor's type, so prioritized and raw widgets mix freely
-- in the same widget list. Apply it as the outermost wrapper, after any
-- combinators that rebuild the widget inside a new container.
withPriority :: (MonadIO m) => Int -> m Gtk.Widget -> m Gtk.Widget
withPriority p buildWidget = do
  widget <- buildWidget
  setWidgetPriority p widget
  return widget

setHiddenByController :: (MonadIO m, GObject.IsObject o) => Bool -> o -> m ()
setHiddenByController flag widget = do
  object <- GObject.toObject widget
  GObject.objectSetData object hiddenByControllerKey $
    intPtrToPtr (if flag then 1 else 0)

getHiddenByController :: (MonadIO m, GObject.IsObject o) => o -> m Bool
getHiddenByController widget = do
  object <- GObject.toObject widget
  (/= 0) . ptrToIntPtr <$> GObject.objectGetData object hiddenByControllerKey

-- | Configuration for the dynamic hiding controller.
data DynamicHidingConfig = DynamicHidingConfig
  { -- | Extra pixels that must be available before a hidden widget is shown
    -- again. This hysteresis margin prevents widgets from flapping between
    -- hidden and shown when the bar is right at the edge of fitting.
    reshowSlackPx :: Int32,
    -- | CSS class added to widgets while they are hidden by the controller.
    hiddenCssClass :: T.Text,
    -- | How often (in milliseconds) to re-evaluate visibility even without a
    -- size-allocation event. Hidden widgets do not trigger relayout when
    -- their natural size changes, so without this they could stay hidden
    -- after shrinking enough to fit. 'Nothing' disables the periodic check.
    recheckIntervalMs :: Maybe Word
  }

-- | 'DynamicHidingConfig' with a 20px reshow margin and a 1s periodic check.
defaultDynamicHidingConfig :: DynamicHidingConfig
defaultDynamicHidingConfig =
  DynamicHidingConfig
    { reshowSlackPx = 20,
      hiddenCssClass = "priority-hidden",
      recheckIntervalMs = Just 1000
    }

instance Default DynamicHidingConfig where
  def = defaultDynamicHidingConfig

-- | Install a visibility controller on a bar box. The widget list should
-- contain the box's top-level section widgets in visual order. Does nothing
-- when no widget in the list carries a priority, so it is safe to call
-- unconditionally.
setupDynamicHiding ::
  DynamicHidingConfig -> Int32 -> Gtk.Box -> [Gtk.Widget] -> IO ()
setupDynamicHiding config spacing box widgets = do
  anyPrioritized <- not . null <$> filterM (fmap isJust . getWidgetPriority) widgets
  when anyPrioritized $ do
    priorityLog DEBUG $
      printf "Enabling dynamic widget hiding for a bar with %d widgets" (length widgets)
    destroyedRef <- newIORef False
    recheckPendingRef <- newIORef False

    let recompute = do
          destroyed <- readIORef destroyedRef
          unless destroyed $ applyVisibilityPass config spacing box widgets

        scheduleRecompute = do
          pending <- atomicModifyIORef' recheckPendingRef (True,)
          unless pending $
            void $
              GLib.idleAdd GLib.PRIORITY_DEFAULT_IDLE $ do
                writeIORef recheckPendingRef False
                recompute
                return False

    void $ Gtk.onWidgetSizeAllocate box $ const scheduleRecompute
    scheduleRecompute

    timeoutId <- forM (recheckIntervalMs config) $ \intervalMs ->
      GLib.timeoutAdd GLib.PRIORITY_DEFAULT_IDLE (fromIntegral intervalMs) $ do
        recompute
        return True

    void $ Gtk.onWidgetDestroy box $ do
      writeIORef destroyedRef True
      forM_ timeoutId GLib.sourceRemove

data WidgetInfo = WidgetInfo
  { wiWidget :: Gtk.Widget,
    wiPriority :: Maybe Int,
    wiVisible :: Bool,
    wiHiddenByUs :: Bool,
    wiNaturalWidth :: Int32
  }

-- | A widget is under the controller's authority when it has a priority and
-- is either visible or was hidden by the controller itself. Prioritized
-- widgets that hid themselves are left alone until they reappear.
wiManaged :: WidgetInfo -> Bool
wiManaged info =
  isJust (wiPriority info) && (wiVisible info || wiHiddenByUs info)

applyVisibilityPass ::
  DynamicHidingConfig -> Int32 -> Gtk.Box -> [Gtk.Widget] -> IO ()
applyVisibilityPass config spacing box widgets = do
  available <- Gtk.widgetGetAllocatedWidth box
  when (available > 1) $ do
    infos <- forM widgets $ \widget -> do
      priority <- getWidgetPriority widget
      visible <- Gtk.widgetGetVisible widget
      hiddenByUs <- getHiddenByController widget
      natural <- snd <$> Gtk.widgetGetPreferredWidth widget
      return
        WidgetInfo
          { wiWidget = widget,
            wiPriority = priority,
            wiVisible = visible,
            wiHiddenByUs = hiddenByUs,
            wiNaturalWidth = natural
          }

    let (managed, unmanaged) = (filter wiManaged infos, filter (not . wiManaged) infos)
        unmanagedVisible = filter wiVisible unmanaged
        -- Every visible widget is charged one unit of box spacing. This
        -- slightly overestimates the gaps GTK actually inserts, which only
        -- errs toward hiding a touch early.
        widthWithGap info = wiNaturalWidth info + spacing
        baseline = sum (map widthWithGap unmanagedVisible)

        -- Strict priority order: walk from highest priority down and cut
        -- off at the first widget that does not fit. sortOn is stable, so
        -- widgets with equal priority keep their visual order.
        ordered = sortOn (Down . wiPriority) managed

        decide _ [] = []
        decide used (info : rest)
          | required <= available =
              (info, True) : decide (used + widthWithGap info) rest
          | otherwise = map (,False) (info : rest)
          where
            slack = if wiVisible info then 0 else reshowSlackPx config
            required = used + widthWithGap info + slack

        decisions = decide baseline ordered

    forM_ decisions $ \(info, shouldShow) ->
      when (wiVisible info /= shouldShow) $ do
        let widget = wiWidget info
        styleContext <- Gtk.widgetGetStyleContext widget
        if shouldShow
          then do
            priorityLog DEBUG $
              printf "Showing widget with priority %s" (show $ wiPriority info)
            setHiddenByController False widget
            Gtk.widgetSetNoShowAll widget False
            Gtk.styleContextRemoveClass styleContext (hiddenCssClass config)
            Gtk.widgetShow widget
          else do
            priorityLog DEBUG $
              printf "Hiding widget with priority %s" (show $ wiPriority info)
            setHiddenByController True widget
            -- Some widgets refresh themselves with widgetShowAll on a
            -- parent container; no-show-all keeps those refreshes from
            -- undoing the controller's decision.
            Gtk.widgetSetNoShowAll widget True
            Gtk.styleContextAddClass styleContext (hiddenCssClass config)
            Gtk.widgetHide widget