packages feed

taffybar-4.1.2: src/System/Taffybar/Widget/Generic/AutoFillImage.hs

{-# LANGUAGE OverloadedStrings #-}
-- | A draw-based alternative to "System.Taffybar.Widget.Generic.AutoSizeImage"
-- that scales a pixbuf to fit its allocated area while preserving aspect ratio,
-- avoiding the resize feedback loops inherent in 'Gtk.Image'.
module System.Taffybar.Widget.Generic.AutoFillImage
  ( autoFillImage
  , autoFillImageNew
  , AutoFillCache(..)
  , fitPixbufToBox
  ) where

import qualified Control.Concurrent.MVar as MV
import           Control.Monad
import           Control.Monad.IO.Class
import           Data.Int
import qualified GI.Cairo.Render as C
import           GI.Cairo.Render.Connector
import qualified GI.Gdk as Gdk
import qualified GI.GdkPixbuf.Enums as GdkPixbuf
import           GI.GdkPixbuf.Objects.Pixbuf as Gdk
import qualified GI.Gtk as Gtk
import           System.Taffybar.Widget.Generic.AutoSizeImage
import           System.Taffybar.Widget.Util

data AutoFillCache = AutoFillCache
  { afRequestSize :: Int32
  , afScaleFactor :: Int32
  , afInsets :: BorderInfo
  , afContentWidth :: Int32
  , afContentHeight :: Int32
  , afSourcePixbuf :: Maybe Gdk.Pixbuf
  , afScaledPixbuf :: Maybe Gdk.Pixbuf
  , afOffsetX :: Double
  , afOffsetY :: Double
  }

fitPixbufToBox
  :: Int32 -- ^ scale factor
  -> BorderInfo
  -> Int32 -- ^ allocated width (logical px)
  -> Int32 -- ^ allocated height (logical px)
  -> Gdk.Pixbuf
  -> IO (Int32, Int32, Double, Double, Maybe Gdk.Pixbuf)
fitPixbufToBox scaleFactor insets allocW allocH pixbuf = do
  pbW' <- Gdk.getPixbufWidth pixbuf
  pbH' <- Gdk.getPixbufHeight pixbuf

  let contentW = max 1 $ allocW - fromIntegral (borderWidth insets)
      contentH = max 1 $ allocH - fromIntegral (borderHeight insets)

      targetWDev = max 1 $ contentW * scaleFactor
      targetHDev = max 1 $ contentH * scaleFactor

      pbW = fromIntegral pbW' :: Double
      pbH = fromIntegral pbH' :: Double
      targetW = fromIntegral targetWDev :: Double
      targetH = fromIntegral targetHDev :: Double

      scale =
        if pbW <= 0 || pbH <= 0
        then 1
        else min (targetW / pbW) (targetH / pbH)

      drawWDev = max 1 $ floor (pbW * scale)
      drawHDev = max 1 $ floor (pbH * scale)

      drawWLogical = fromIntegral drawWDev / fromIntegral scaleFactor
      drawHLogical = fromIntegral drawHDev / fromIntegral scaleFactor

      leftInset = fromIntegral (borderLeft insets) :: Double
      topInset = fromIntegral (borderTop insets) :: Double
      offsetX = leftInset + (fromIntegral contentW - drawWLogical) / 2
      offsetY = topInset + (fromIntegral contentH - drawHLogical) / 2

  scaledM <- Gdk.pixbufScaleSimple pixbuf drawWDev drawHDev GdkPixbuf.InterpTypeBilinear
  -- GDK can return NULL; treat that as "draw nothing".
  pure (contentW, contentH, offsetX, offsetY, scaledM)

-- | A draw-based alternative to 'autoSizeImage' that avoids resize loops and
-- naturally responds to CSS changes. The widget will always draw the current
-- pixbuf scaled to fit its allocated area (minus padding+border).
--
-- This uses a 'Gtk.DrawingArea' instead of 'Gtk.Image' because GTK3 does not
-- support "scale-to-allocation" semantics for 'Gtk.Image'.
autoFillImage
  :: MonadIO m
  => Gtk.DrawingArea
  -> (Int32 -> IO (Maybe Gdk.Pixbuf))
  -> Gtk.Orientation
  -> m (IO ())
autoFillImage drawArea getPixbuf orientation = liftIO $ do
  case orientation of
    Gtk.OrientationHorizontal -> Gtk.widgetSetVexpand drawArea True
    _ -> Gtk.widgetSetHexpand drawArea True

  -- Keep existing styling working.
  void $ widgetSetClassGI drawArea "auto-size-image"
  void $ widgetSetClassGI drawArea "auto-fill-image"

  -- Ensure the widget has a non-zero natural size even before the first
  -- allocation.
  Gtk.widgetSetSizeRequest drawArea 16 16

  -- Cache is only accessed from the GTK main loop via signal handlers.
  cacheVar <- MV.newMVar AutoFillCache
    { afRequestSize = 0
    , afScaleFactor = 1
    , afInsets = borderInfoZero
    , afContentWidth = 1
    , afContentHeight = 1
    , afSourcePixbuf = Nothing
    , afScaledPixbuf = Nothing
    , afOffsetX = 0
    , afOffsetY = 0
    }

  let
    recompute force = do
      allocation <- Gtk.widgetGetAllocation drawArea
      allocW <- Gdk.getRectangleWidth allocation
      allocH <- Gdk.getRectangleHeight allocation

      -- CSS can change dynamically (taffybar supports live CSS reload), so we
      -- recompute insets every time we recompute sizing.
      insets <- getInsetInfo drawArea
      scaleFactor <- Gtk.widgetGetScaleFactor drawArea

      let contentW = max 1 $ allocW - fromIntegral (borderWidth insets)
          contentH = max 1 $ allocH - fromIntegral (borderHeight insets)
          requestSize =
            case orientation of
              Gtk.OrientationHorizontal -> contentH
              _ -> contentW

      -- Update the widget's natural size so it won't collapse to 0 when packed
      -- without expand.
      Gtk.widgetSetSizeRequest drawArea
        (fromIntegral requestSize + fromIntegral (borderWidth insets))
        (fromIntegral requestSize + fromIntegral (borderHeight insets))

      old <- MV.readMVar cacheVar
      srcFresh <-
        if force || requestSize /= afRequestSize old
        then getPixbuf requestSize
        else pure Nothing

      -- If the getter fails transiently, keep drawing the last known pixbuf.
      let src =
            case srcFresh of
              Just pb -> Just pb
              Nothing -> afSourcePixbuf old

      let needsRefit =
            force
            || requestSize /= afRequestSize old
            || scaleFactor /= afScaleFactor old
            || insets /= afInsets old
            || contentW /= afContentWidth old
            || contentH /= afContentHeight old

      when needsRefit $ do
        newCache <-
          case src of
            Nothing -> pure old
              { afRequestSize = requestSize
              , afScaleFactor = scaleFactor
              , afInsets = insets
              , afContentWidth = contentW
              , afContentHeight = contentH
              , afSourcePixbuf = Nothing
              , afScaledPixbuf = Nothing
              , afOffsetX = 0
              , afOffsetY = 0
              }
            Just pb -> do
              (cw, ch, ox, oy, scaledM) <-
                fitPixbufToBox (max 1 scaleFactor) insets allocW allocH pb
              pure old
                { afRequestSize = requestSize
                , afScaleFactor = max 1 scaleFactor
                , afInsets = insets
                , afContentWidth = cw
                , afContentHeight = ch
                , afSourcePixbuf = Just pb
                , afScaledPixbuf = scaledM
                , afOffsetX = ox
                , afOffsetY = oy
                }

        void $ MV.swapMVar cacheVar newCache
        Gtk.widgetQueueDraw drawArea

  -- Redraw when GTK allocates or when style changes.
  void $ Gtk.onWidgetSizeAllocate drawArea $ \_ -> recompute False
  void $ Gtk.onWidgetStyleUpdated drawArea $ recompute True

  void $ Gtk.onWidgetDraw drawArea $ \ctx -> do
    st <- MV.readMVar cacheVar
    case afScaledPixbuf st of
      Nothing -> pure True
      Just pb -> do
        Gdk.cairoSetSourcePixbuf ctx pb (afOffsetX st) (afOffsetY st)
        renderWithContext C.paint ctx
        pure True

  pure $ recompute True

-- | Convenience constructor for 'autoFillImage'.
autoFillImageNew
  :: MonadIO m
  => (Int32 -> IO (Maybe Gdk.Pixbuf))
  -> Gtk.Orientation
  -> m Gtk.DrawingArea
autoFillImageNew getPixBuf orientation = do
  drawArea <- Gtk.drawingAreaNew
  void $ autoFillImage drawArea getPixBuf orientation
  pure drawArea