packages feed

taffybar 7.1.0 → 7.2.0

raw patch · 55 files changed

+3752/−504 lines, 55 filesdep +gi-gobjectdep +gi-wireplumberdep ~JuicyPixelsdep ~QuickCheckdep ~X11

Dependencies added: gi-gobject, gi-wireplumber

Dependency ranges changed: JuicyPixels, QuickCheck, X11, aeson, ansi-terminal, attoparsec, bytestring, conduit, containers, data-default, dbus, dbus-hslogger, dbus-menu, dhall, directory, disk-free-space, either, enclosed-exceptions, extra, filepath, gi-cairo-connector, gi-cairo-render, gi-glib, gi-gtk-layer-shell, gi-pango, gtk-scaling-image, gtk-sni-tray, gtk-strut, haskell-gi-base, hslogger, hspec, http-client, http-client-tls, http-conduit, http-types, monad-control, multimap, network, optparse-applicative, parsec, process, rate-limit, regex-compat, split, status-notifier-item, stm, template-haskell, text, time, time-units, transformers, tuple, typed-process, unix, unliftio, unliftio-core, utf8-string, xdg-desktop-entry, xml, xml-helpers, xmonad, xmonad-contrib, yaml

Files

CHANGELOG.md view
@@ -1,22 +1,75 @@+# 7.2.0++## New Features++* Add the OmniMenu widget and supporting desktop-entry parsing updates.+* Add WirePlumber-backed audio information and expose it through the audio+  widget fallback path.+* Add Hyprland workspace history ordering, pinned-window tracking, special+  workspace remapping, and external workspace icon refresh support.+* Use the generated ChromeWindowInfo DBus client.++## Fixes++* Prefer live Hyprland backends over stale X11/Wayland environment state.+* Improve Hyprland lifecycle recovery, workspace redraws, workspace sorting,+  and workspace icon activation.+* Fix HiDPI scaled-image rendering and normalize menu icon sizing.+* Stabilize SNI tray ordering and prioritized tray hover expansion.+* Clean up Stan/compiler diagnostics across the monorepo.++## Packaging++* Bump companion-package lower bounds for the refreshed monorepo package+  releases.+* Add and depend on `gi-wireplumber >= 0.5.14.1`.+* Bump the package version to 7.2.0.+ # 7.1.0  ## New Features  * Add structured OpenAI usage information and an OpenAI usage widget. * Add Anthropic usage information and an Anthropic usage widget.+* Expose the new OpenAI and Anthropic usage information/widget modules from+  the library.  ## Fixes  * Refresh window state after Hyprland reconnects.-* Fix backend detection and X11 strut sizing.+* Fix backend detection by preparing display environment variables before GTK+  initialization, preferring explicit X11 sessions over stale Wayland sockets,+  validating live Hyprland sockets, and detecting the backend from the GDK+  display when possible.+* Fix X11 strut/window handling by safely checking that realized GTK windows+  are X11 windows before lowering them. * Handle non-X11 GDK windows safely. * Stabilize CSS loading for themed end widgets.+* Apply end-widget pill palettes directly at runtime and use+  `nth-last-child` selectors for end pill rotation.  ## Refactors  * Decouple layout and workspace/window information providers from widget   rendering, and move shared workspace/window support helpers out of widget   modules.+* Clean up imports and internal helper code in the Hyprland layout and+  workspaces modules.+* Refresh vendored companion-package code in the monorepo, including+  `dbus-hslogger`, `dbus-menu`, `gtk-sni-tray`, `gtk-strut`,+  `status-notifier-item`, `xdg-desktop-entry`, and `gtk-scaling-image`.++## Tests++* Add backend-detection coverage for explicit X11 sessions when ambient+  Wayland sockets are present.+* Add `network` to the unit-test dependencies for Unix socket test support.+* Clean up unit-test imports.++## Packaging++* Bump the package version to 7.1.0.+* Update `flake.lock`.  # 7.0.1 
+ dbus-xml/org.imalison.ChromeWindowInfo.xml view
@@ -0,0 +1,22 @@+<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"+"https://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">+<node>+ <interface name="org.imalison.ChromeWindowInfo">+  <method name="GetLastPayload">+   <arg type="s" name="payload_json" direction="out"/>+  </method>+  <method name="GetWindowPayloads">+   <arg type="s" name="payloads_json" direction="out"/>+  </method>+  <method name="GetSchema">+   <arg type="s" name="schema" direction="out"/>+  </method>+  <signal name="Updated">+   <arg type="s" name="payload_json"/>+  </signal>+  <signal name="WindowUpdated">+   <arg type="s" name="window_id"/>+   <arg type="s" name="payload_json"/>+  </signal>+ </interface>+</node>
src/System/Taffybar.hs view
@@ -130,13 +130,16 @@  import qualified Config.Dyre as Dyre import qualified Config.Dyre.Params as Dyre+import Control.Concurrent (threadDelay) import qualified Control.Concurrent.MVar as MV import Control.Exception (finally)+import Control.Exception.Enclosed (catchAny) import Control.Monad import Data.Char (toLower) import Data.Function (on) import qualified Data.GI.Gtk.Threading as GIThreading import Data.List (groupBy, isPrefixOf, sort)+import Data.Maybe (isJust) import qualified Data.Text as T import Data.Word (Word32) import qualified GI.GLib as G@@ -152,6 +155,7 @@ import System.FilePath (normalise, splitSearchPath, takeDirectory, takeFileName, (</>)) import qualified System.IO as IO import System.Log.Logger+import System.Posix.Files (getFileStatus, isSocket) import System.Taffybar.Context import System.Taffybar.Context.Backend (prepareBackendEnvironment) import System.Taffybar.Hooks@@ -343,7 +347,7 @@    -- Fix up stale session-manager environment before GTK/GDK choose a display,   -- then use GDK's actual opened display as the source of truth.-  prepareBackendEnvironment+  waitForBackendEnvironment   void initThreads    _ <- Gtk.init Nothing@@ -361,6 +365,53 @@         exitTaffybar context    logTaffy DEBUG "Exited normally"++waitForBackendEnvironment :: IO ()+waitForBackendEnvironment = do+  prepareBackendEnvironment+  shouldWait <- shouldWaitForWaylandDisplay+  when shouldWait $ do+    logTaffy NOTICE "Waiting for Wayland display before starting GTK"+    go (120 :: Int)+  where+    retryDelayMicros = 500000++    go 0 = do+      prepareBackendEnvironment+      stillWaiting <- shouldWaitForWaylandDisplay+      when stillWaiting $+        logTaffy WARNING "Starting GTK even though no Wayland display socket is available"+    go n = do+      threadDelay retryDelayMicros+      prepareBackendEnvironment+      shouldWait <- shouldWaitForWaylandDisplay+      when shouldWait $ go (n - 1)++shouldWaitForWaylandDisplay :: IO Bool+shouldWaitForWaylandDisplay = do+  mRuntime <- lookupEnv "XDG_RUNTIME_DIR"+  mDisplay <- lookupEnv "DISPLAY"+  mSessionType <- lookupEnv "XDG_SESSION_TYPE"+  mWaylandDisplay <- lookupEnv "WAYLAND_DISPLAY"+  let explicitX11Session =+        mSessionType == Just "x11" && maybe False (not . null) mDisplay+      expectsWayland =+        not explicitX11Session+          && (mSessionType == Just "wayland" || isJust mWaylandDisplay)+  if not expectsWayland+    then pure False+    else do+      waylandOk <- maybe (pure False) isWaylandDisplaySocket $ do+        runtime <- mRuntime+        waylandDisplay <- mWaylandDisplay+        pure (runtime </> waylandDisplay)+      pure $ not waylandOk++isWaylandDisplaySocket :: FilePath -> IO Bool+isWaylandDisplaySocket path =+  catchAny+    (isSocket <$> getFileStatus path)+    (const $ pure False)  logTaffy :: Priority -> String -> IO () logTaffy = logM "System.Taffybar"
src/System/Taffybar/Context.hs view
@@ -87,6 +87,7 @@ import Control.Monad.Trans.Reader import qualified DBus as D import qualified DBus.Client as DBus+import Data.Char (toLower) import Data.Data import Data.Default (Default (..)) import Data.GI.Base (castTo)@@ -106,8 +107,17 @@ import qualified GI.GtkLayerShell as GtkLayerShell import Graphics.UI.GIGtkStrut import StatusNotifier.TransparentWindow+import System.Environment (getArgs, getExecutablePath, lookupEnv)+import System.Exit (ExitCode (ExitFailure, ExitSuccess)) import System.Log.Logger (Priority (..), logM)-import System.Taffybar.Context.Backend (Backend (..), detectBackend)+import System.Posix.Process (exitImmediately)+import System.Process+  ( CreateProcess (close_fds, new_session, std_err, std_in, std_out),+    StdStream (NoStream),+    createProcess,+    proc,+  )+import System.Taffybar.Context.Backend (Backend (..), detectBackend, prepareBackendEnvironment) import qualified System.Taffybar.DBus.Client.Params as DBusParams import System.Taffybar.Information.EWMHDesktopInfo   ( ewmhActiveWindow,@@ -137,7 +147,6 @@     setupFocusedMonitorClassUpdates,   ) import Text.Printf-import Unsafe.Coerce  logIO :: Priority -> String -> IO () logIO = logM "System.Taffybar.Context"@@ -159,12 +168,7 @@ data Value = forall t. (Typeable t) => Value t  fromValue :: forall t. (Typeable t) => Value -> Maybe t-fromValue (Value v) =-  if typeOf v == typeRep (Proxy :: Proxy t)-    then-      Just $ unsafeCoerce v-    else-      Nothing+fromValue (Value v) = cast v  -- | 'BarConfig' specifies the configuration for a single taffybar window. data BarLevelConfig = BarLevelConfig@@ -340,18 +344,22 @@               backend = backendType,               contextBarConfig = Nothing             }-    _ <--      runMaybeT $-        MaybeT GI.Gdk.displayGetDefault-          >>= (lift . GI.Gdk.displayGetDefaultScreen)-          >>= ( lift-                  . flip-                    GI.Gdk.afterScreenMonitorsChanged-                    -- XXX: We have to do a force refresh here because there is no-                    -- way to reliably move windows, since the window manager can do-                    -- whatever it pleases.-                    (runReaderT forceRefreshTaffyWindows context)-              )+    when (backendType == BackendX11) $+      void $+        runMaybeT $+          MaybeT GI.Gdk.displayGetDefault+            >>= (lift . GI.Gdk.displayGetDefaultScreen)+            >>= ( lift+                    . flip+                      GI.Gdk.afterScreenMonitorsChanged+                      -- XXX: We have to do a force refresh here because there is no+                      -- way to reliably move windows, since the window manager can do+                      -- whatever it pleases.+                      ( runReaderT+                          (forceRefreshTaffyWindowsBecause "gdk screen monitors changed")+                          context+                      )+                )      -- Some compositors/backends will keep the reserved space for a layer-shell     -- surface/strut window after suspend, but fail to properly re-display the@@ -396,7 +404,10 @@           threadDelay 1_000_000           _ <- MV.swapMVar pendingVar False           logIO NOTICE "Resumed from sleep - forcing taffybar window refresh"-          postGUIASync $ runReaderT forceRefreshTaffyWindows ctx+          postGUIASync $+            runReaderT+              (forceRefreshTaffyWindowsBecause "logind resume")+              ctx        callback :: D.Signal -> IO ()       callback sig =@@ -416,19 +427,25 @@           ++ show e     Right _ -> pure () --- | Recreate Wayland bar windows when the Hyprland event socket reconnects.+-- | Recover Wayland bars when the Hyprland event socket reconnects. -- -- Hyprland restarts can leave existing layer-shell surfaces invisible even -- though the taffybar process and widget state loops are still alive. The -- Hyprland event reader emits @taffybar-hyprland-connected@ after every event--- socket connection, so use reconnects as the compositor-lifecycle signal and--- force a top-level window refresh.+-- socket connection, so use reconnects as the compositor-lifecycle signal.+--+-- By default this re-execs taffybar. A full compositor restart can invalidate+-- GTK's Wayland display connection itself, so merely recreating Gtk.Window+-- values in the same process is not reliable. Set+-- @TAFFYBAR_HYPRLAND_RECONNECT_ACTION=refresh@ to keep the older window-only+-- refresh behavior while debugging. registerHyprlandReconnectRefresh :: Context -> IO () registerHyprlandReconnectRefresh ctx = do   eventChan <- withHyprlandEventChan ctx   events <- subscribeHyprlandEvents eventChan   readyVar <- MV.newMVar False-  pendingVar <- MV.newMVar False+  recoveryPendingVar <- MV.newMVar False+  monitorRefreshPendingVar <- MV.newMVar False   void $ forkIO $ do     -- The event reader also emits a connection event during normal startup.     -- Ignore early connection events so startup does not immediately rebuild@@ -436,29 +453,141 @@     threadDelay 2_000_000     void $ MV.swapMVar readyVar True -  let isConnectedEvent line =-        T.takeWhile (/= '>') line == "taffybar-hyprland-connected"+  let lifecycleEventName line =+        let hyprlandEventName = T.takeWhile (/= '>') line+         in if hyprlandEventName+              `elem` [ "taffybar-hyprland-connected",+                       "configreloaded"+                     ]+              then Just hyprlandEventName+              else Nothing -      scheduleRefresh = do-        wasPending <- MV.swapMVar pendingVar True+      monitorEventName line =+        let hyprlandEventName = T.takeWhile (/= '>') line+         in if hyprlandEventName+              `elem` [ "monitoradded",+                       "monitorremoved"+                     ]+              then Just hyprlandEventName+              else Nothing++      scheduleProcessRecovery reason = do+        wasPending <- MV.swapMVar recoveryPendingVar True         unless wasPending $ void $ forkIO $ do           -- Give Hyprland/GDK a short settling window before rebuilding           -- layer-shell surfaces.           threadDelay 1_000_000-          _ <- MV.swapMVar pendingVar False-          logIO NOTICE "Hyprland event socket reconnected - forcing taffybar window refresh"-          postGUIASync $ runReaderT forceRefreshTaffyWindows ctx+          _ <- MV.swapMVar recoveryPendingVar False+          action <- hyprlandReconnectAction+          case action of+            "refresh" -> do+              logIO NOTICE $ "Hyprland " ++ reason ++ " - forcing taffybar window refresh"+              postGUIASync $+                runReaderT+                  (forceRefreshTaffyWindowsBecause $ "hyprland " ++ reason)+                  ctx+            _ -> restartTaffybarProcess ctx reason -      handleConnectedEvent = do+      scheduleMonitorRefresh reason = do+        wasPending <- MV.swapMVar monitorRefreshPendingVar True+        unless wasPending $ void $ forkIO $ do+          -- Output topology updates can arrive in small batches; wait briefly+          -- so bar configs are rebuilt against the settled monitor list.+          threadDelay 500_000+          _ <- MV.swapMVar monitorRefreshPendingVar False+          logIO NOTICE $ "Hyprland " ++ reason ++ " - forcing taffybar window refresh"+          postGUIASync $+            runReaderT+              (forceRefreshTaffyWindowsBecause $ "hyprland " ++ reason)+              ctx++      handleLifecycleEvent hyprlandEventName = do         ready <- MV.readMVar readyVar         if ready-          then scheduleRefresh+          then+            if hyprlandEventName == "configreloaded"+              then scheduleProcessRecovery "config reloaded"+              else scheduleProcessRecovery "event socket reconnected"           else logIO DEBUG "Ignoring Hyprland event socket connection during startup" +      handleMonitorEvent hyprlandEventName = do+        ready <- MV.readMVar readyVar+        if ready+          then scheduleMonitorRefresh $ T.unpack hyprlandEventName+          else logIO DEBUG "Ignoring Hyprland monitor event during startup"+   void $ forkIO $ forever $ do     line <- atomically $ readTChan events-    when (isConnectedEvent line) handleConnectedEvent+    maybe (pure ()) handleLifecycleEvent (lifecycleEventName line)+    maybe (pure ()) handleMonitorEvent (monitorEventName line) +hyprlandReconnectAction :: IO String+hyprlandReconnectAction = do+  mAction <- lookupEnv "TAFFYBAR_HYPRLAND_RECONNECT_ACTION"+  pure $ maybe "restart" (map toLower) mAction++restartTaffybarProcess :: Context -> String -> IO ()+restartTaffybarProcess ctx reason = do+  prepareBackendEnvironment+  executable <- getExecutablePath+  args <- getArgs+  logIO NOTICE $+    "Hyprland "+      ++ reason+      ++ " - re-executing taffybar process: "+      ++ executable+  underSystemd <- isSystemdService+  if underSystemd+    then exitImmediately (ExitFailure 75)+    else do+      disconnectClient "session" (sessionDBusClient ctx)+      disconnectClient "system" (systemDBusClient ctx)+      result <- try $ spawnDetachedRestart executable args+      case result of+        Left (e :: SomeException) ->+          logIO WARNING $+            "Failed to restart taffybar process: "+              ++ show e+        Right _ ->+          exitImmediately ExitSuccess+  where+    isSystemdService = maybe False (not . null) <$> lookupEnv "INVOCATION_ID"++    spawnDetachedRestart executable args =+      createProcess+        (proc "sh" (["-c", "sleep 1; exec \"$@\"", "taffybar-restart", executable] ++ args))+          { close_fds = True,+            new_session = True,+            std_in = NoStream,+            std_out = NoStream,+            std_err = NoStream+          }++    disconnectClient name client =+      DBus.disconnect client+        `catchAny` \e ->+          logIO WARNING $+            "Failed to disconnect "+              ++ name+              ++ " DBus client before restart: "+              ++ show e++taffybarWaylandLayer :: IO GtkLayerShell.Layer+taffybarWaylandLayer = do+  mLayer <- lookupEnv "TAFFYBAR_WAYLAND_LAYER"+  case fmap (map toLower) mLayer of+    Just "background" -> pure GtkLayerShell.LayerBackground+    Just "bottom" -> pure GtkLayerShell.LayerBottom+    Just "overlay" -> pure GtkLayerShell.LayerOverlay+    Just "top" -> pure GtkLayerShell.LayerTop+    Just invalid -> do+      logIO WARNING $+        "Invalid TAFFYBAR_WAYLAND_LAYER="+          ++ invalid+          ++ "; using top"+      pure GtkLayerShell.LayerTop+    Nothing -> pure GtkLayerShell.LayerTop+ -- | Build an empty taffybar context. This function is mostly useful for -- invoking functions that yield 'TaffyIO' values in a testing setting (e.g. in -- a repl).@@ -785,10 +914,10 @@     del :: Gtk.Window -> TaffyIO ()     del = Gtk.widgetDestroy --- | Forcibly refresh taffybar windows, even if there are existing windows that--- correspond to the uniques in the bar configs yielded by 'barConfigGetter'.-forceRefreshTaffyWindows :: TaffyIO ()-forceRefreshTaffyWindows = removeTaffyWindows >> refreshTaffyWindows+-- | Forcibly refresh taffybar windows.+forceRefreshTaffyWindowsBecause :: String -> TaffyIO ()+forceRefreshTaffyWindowsBecause _reason = do+  removeTaffyWindows >> refreshTaffyWindows  -- | Destroys all top-level windows belonging to Taffybar, then -- requests the GTK main loop to exit.@@ -1001,7 +1130,8 @@               GtkLayerShell.setMargin window GtkLayerShell.EdgeTop ypadding               GtkLayerShell.setMargin window GtkLayerShell.EdgeBottom ypadding -              GtkLayerShell.setLayer window GtkLayerShell.LayerTop+              layer <- taffybarWaylandLayer+              GtkLayerShell.setLayer window layer                let setAnchor = GtkLayerShell.setAnchor window 
src/System/Taffybar/Context/Backend.hs view
@@ -31,17 +31,21 @@  import Control.Exception.Enclosed (catchAny) import Control.Monad+import Data.Char (toLower) import Data.GI.Base (castTo)-import Data.List (isPrefixOf, isSuffixOf)-import Data.Maybe (isJust)+import Data.List (isInfixOf, isPrefixOf, isSuffixOf, sortOn)+import Data.Maybe (fromMaybe, isJust, listToMaybe)+import Data.Ord (Down (..)) import qualified Data.Text as T import qualified GI.Gdk as Gdk import qualified GI.GdkX11.Objects.X11Display as GdkX11+import qualified Network.Socket as NS import System.Directory (doesPathExist, listDirectory) import System.Environment (lookupEnv, setEnv, unsetEnv) import System.FilePath ((</>)) import System.Log.Logger (Priority (..), logM) import System.Posix.Files (getFileStatus, isSocket)+import Text.Read (readMaybe)  logIO :: Priority -> String -> IO () logIO = logM "System.Taffybar.Context.Backend"@@ -89,13 +93,21 @@     then pure Nothing     else do       entries <- listDirectory hyprDir-      go hyprDir entries-  where-    go _ [] = pure Nothing-    go hyprDir (e : es) = do-      isSig <- isLiveHyprlandSignature (hyprDir </> e)-      if isSig then pure (Just e) else go hyprDir es+      liveEntries <- filterM (isLiveHyprlandSignature . (hyprDir </>)) entries+      pure $ listToMaybe $ sortOn hyprlandSignatureSortKey liveEntries +hyprlandSignatureSortKey :: String -> (Down Integer, Down String)+hyprlandSignatureSortKey signature =+  ( Down $ fromMaybe (-1) $ hyprlandSignatureStartTime signature,+    Down signature+  )++hyprlandSignatureStartTime :: String -> Maybe Integer+hyprlandSignatureStartTime signature =+  case drop 1 $ dropWhile (/= '_') signature of+    "" -> Nothing+    rest -> readMaybe $ takeWhile (/= '_') rest+ isSocketPath :: FilePath -> IO Bool isSocketPath path =   catchAny@@ -105,12 +117,46 @@ isLiveHyprlandSignature :: FilePath -> IO Bool isLiveHyprlandSignature dir =   (||)-    <$> isSocketPath (dir </> ".socket.sock")-    <*> isSocketPath (dir </> ".socket2.sock")+    <$> canConnectUnixSocket (dir </> ".socket.sock")+    <*> canConnectUnixSocket (dir </> ".socket2.sock") +canConnectUnixSocket :: FilePath -> IO Bool+canConnectUnixSocket path = do+  sock <- NS.socket NS.AF_UNIX NS.Stream NS.defaultProtocol+  ( do+      NS.connect sock (NS.SockAddrUnix path)+      NS.close sock+      pure True+    )+    `catchAny` \_ -> do+      void $ NS.close sock `catchAny` \_ -> pure ()+      pure False+ envIsNonEmpty :: Maybe String -> Bool envIsNonEmpty = maybe False (not . null) +envIsX11GdkBackend :: Maybe String -> Bool+envIsX11GdkBackend = maybe False ((== "x11") . map toLower)++envContainsWaylandDesktop :: Maybe String -> Bool+envContainsWaylandDesktop =+  maybe False $+    \value ->+      let lowered = map toLower value+       in "hyprland" `isInfixOf` lowered++waylandSocketAvailable :: FilePath -> Maybe String -> IO Bool+waylandSocketAvailable runtime mWaylandDisplay =+  case mWaylandDisplay of+    Just wl | not (null wl) -> isSocketPath (runtime </> wl)+    _ -> pure False++hyprlandSignatureAvailable :: FilePath -> Maybe String -> IO Bool+hyprlandSignatureAvailable runtime mSignature =+  case mSignature of+    Just sig | not (null sig) -> isLiveHyprlandSignature (runtime </> "hypr" </> sig)+    _ -> pure False+ -- | Detect the display-server backend, compensating for stale or missing -- environment variables. --@@ -126,52 +172,96 @@ --   from an X11 session). -- -- This function probes @XDG_RUNTIME_DIR@ only when the current process--- environment does not already identify an active X11 session, then fixes up--- the process environment so GDK sees consistent display variables.+-- context already points at Wayland, or when there is no explicit X display to+-- compete with. This avoids choosing a Wayland compositor merely because one+-- exists somewhere in the user's runtime directory. prepareBackendEnvironment :: IO () prepareBackendEnvironment = do   mRuntime <- lookupEnv "XDG_RUNTIME_DIR"   mDisplay <- lookupEnv "DISPLAY"   mSessionType <- lookupEnv "XDG_SESSION_TYPE"+  mGdkBackend <- lookupEnv "GDK_BACKEND"+  mCurrentDesktop <- lookupEnv "XDG_CURRENT_DESKTOP"+  mDesktopSession <- lookupEnv "DESKTOP_SESSION"+  rawHyprlandSignature <- lookupEnv "HYPRLAND_INSTANCE_SIGNATURE"   rawWaylandDisplay <- lookupEnv "WAYLAND_DISPLAY"    let hasDisplay = envIsNonEmpty mDisplay-      explicitX11Session = mSessionType == Just "x11" && hasDisplay+      explicitlyRequestedX11 =+        envIsX11GdkBackend mGdkBackend +  currentWaylandOk <- case mRuntime of+    Just runtime -> waylandSocketAvailable runtime rawWaylandDisplay+    Nothing -> pure False+  currentHyprlandOk <- case mRuntime of+    Just runtime -> hyprlandSignatureAvailable runtime rawHyprlandSignature+    Nothing -> pure False+  discoveredHyprlandSignature <- case mRuntime of+    Just runtime | not explicitlyRequestedX11 -> discoverHyprlandSignature runtime+    _ -> pure Nothing++  let hasHyprlandEvidence =+        currentHyprlandOk+          || envIsNonEmpty rawHyprlandSignature+          || envIsNonEmpty discoveredHyprlandSignature+          || envContainsWaylandDesktop mCurrentDesktop+          || envContainsWaylandDesktop mDesktopSession+      staleSessionTypeClaimsX11 =+        mSessionType == Just "x11"+          && hasDisplay+          && not currentWaylandOk+          && not hasHyprlandEvidence+      explicitX11Session =+        explicitlyRequestedX11 || staleSessionTypeClaimsX11+      processContextExpectsWayland =+        currentWaylandOk+          || mSessionType == Just "wayland"+          || hasHyprlandEvidence+      shouldDiscoverAmbientWayland =+        not explicitX11Session && (processContextExpectsWayland || not hasDisplay)+   -- If the process environment identifies the active session as X11, trust it-  -- over ambient Wayland sockets in XDG_RUNTIME_DIR. Those sockets can outlive-  -- or coexist with a different login session and are not sufficient evidence-  -- that this process should initialize GTK as Wayland.+  -- over ambient Wayland sockets in XDG_RUNTIME_DIR. A live WAYLAND_DISPLAY+  -- from the process environment is stronger evidence than XDG_SESSION_TYPE,+  -- because user shells can retain a stale session type.   when explicitX11Session $ do     unsetEnv "WAYLAND_DISPLAY"     unsetEnv "HYPRLAND_INSTANCE_SIGNATURE"+    setEnv "GDK_BACKEND" "x11"     logIO DEBUG "X11 session detected; ignoring ambient Wayland sockets" -  -- Discover and fix up WAYLAND_DISPLAY if it is missing or empty.-  void $ do+  -- Discover and fix up WAYLAND_DISPLAY if it is missing, empty, or stale.+  repairedWaylandDisplay <- do     case (mRuntime, rawWaylandDisplay) of       _ | explicitX11Session -> pure Nothing-      (Just runtime, val) | maybe True null val -> do+      (Just _, val) | currentWaylandOk -> pure val+      (Just runtime, _) | shouldDiscoverAmbientWayland -> do         mSock <- discoverWaylandSocket runtime         case mSock of           Just sock -> do             logIO INFO $ "Discovered wayland socket: " ++ sock             setEnv "WAYLAND_DISPLAY" sock             pure (Just sock)-          Nothing -> pure rawWaylandDisplay-      _ -> pure rawWaylandDisplay+          Nothing -> pure Nothing+      _ -> pure Nothing -  -- Discover and fix up HYPRLAND_INSTANCE_SIGNATURE if it is missing or empty.-  unless explicitX11Session $ do+  when (not explicitX11Session && envIsNonEmpty repairedWaylandDisplay) $ do+    setEnv "GDK_BACKEND" "wayland"+    when (mSessionType /= Just "wayland") $+      setEnv "XDG_SESSION_TYPE" "wayland"++  -- Discover and fix up HYPRLAND_INSTANCE_SIGNATURE if it is missing, empty, or stale.+  when (processContextExpectsWayland || envIsNonEmpty repairedWaylandDisplay) $ do     raw <- lookupEnv "HYPRLAND_INSTANCE_SIGNATURE"     case (mRuntime, raw) of-      (Just runtime, val) | maybe True null val -> do-        mSig <- discoverHyprlandSignature runtime-        case mSig of-          Just sig -> do-            logIO INFO $ "Discovered Hyprland signature: " ++ sig-            setEnv "HYPRLAND_INSTANCE_SIGNATURE" sig-          Nothing -> pure ()+      (Just runtime, val) -> do+        currentOk <- hyprlandSignatureAvailable runtime val+        unless currentOk $ do+          case discoveredHyprlandSignature of+            Just sig -> do+              logIO INFO $ "Discovered Hyprland signature: " ++ sig+              setEnv "HYPRLAND_INSTANCE_SIGNATURE" sig+            Nothing -> pure ()       _ -> pure ()  -- | Detect the backend from the display that GDK actually opened.
+ src/System/Taffybar/DBus/Client/ChromeWindowInfo.hs view
@@ -0,0 +1,17 @@+{-# LANGUAGE TemplateHaskell #-}++module System.Taffybar.DBus.Client.ChromeWindowInfo where++import DBus.Generation ()+import System.FilePath+import System.Taffybar.DBus.Client.Params+import System.Taffybar.DBus.Client.Util++generateClientFromFile+  defaultRecordGenerationParams+    { recordName = Just "ChromeWindowInfo",+      recordPrefix = "chromeWindowInfo"+    }+  chromeWindowInfoGenerationParams+  False+  $ "dbus-xml" </> "org.imalison.ChromeWindowInfo.xml"
src/System/Taffybar/DBus/Client/Params.hs view
@@ -8,6 +8,26 @@ import Language.Haskell.TH import System.Taffybar.DBus.Client.Util +-- | The bus name for chrome-favicon-dbus.+chromeWindowInfoBusName :: BusName+chromeWindowInfoBusName = "org.imalison.ChromeWindowInfo"++-- | The object path exposed by chrome-favicon-dbus.+chromeWindowInfoObjectPath :: ObjectPath+chromeWindowInfoObjectPath = "/org/imalison/ChromeWindowInfo"++-- | The interface name exposed by chrome-favicon-dbus.+chromeWindowInfoInterfaceName :: InterfaceName+chromeWindowInfoInterfaceName = "org.imalison.ChromeWindowInfo"++chromeWindowInfoGenerationParams :: GenerationParams+chromeWindowInfoGenerationParams =+  defaultGenerationParams+    { genTakeSignalErrorHandler = True,+      genBusName = Just chromeWindowInfoBusName,+      genObjectPath = Just chromeWindowInfoObjectPath+    }+ playerGenerationParams :: GenerationParams playerGenerationParams =   defaultGenerationParams@@ -139,7 +159,7 @@   | BatteryTypeKeyboard   | BatteryTypePda   | BatteryTypePhone-  deriving (Show, Ord, Eq, Enum)+  deriving (Show, Ord, Eq, Enum, Bounded)  -- | UPower battery charging state enum. data BatteryState@@ -150,7 +170,7 @@   | BatteryStateFullyCharged   | BatteryStatePendingCharge   | BatteryStatePendingDischarge-  deriving (Show, Ord, Eq, Enum)+  deriving (Show, Ord, Eq, Enum, Bounded)  -- | UPower battery chemistry enum. data BatteryTechnology@@ -161,7 +181,7 @@   | BatteryTechnologyLeadAcid   | BatteryTechnologyNickelCadmium   | BatteryTechnologyNickelMetalHydride-  deriving (Show, Ord, Eq, Enum)+  deriving (Show, Ord, Eq, Enum, Bounded)  batteryTypeForName :: GetTypeForName batteryTypeForName name = const $
src/System/Taffybar/Hooks.hs view
@@ -33,12 +33,12 @@ import Control.Monad.STM (atomically) import Control.Monad.Trans.Class import Control.Monad.Trans.Reader-import Data.List (isSuffixOf, nub) import qualified Data.MultiMap as MM+import qualified Data.Set as Set import System.Directory (doesDirectoryExist) import System.Environment.XDG.DesktopEntry import System.FSNotify (Event (eventIsDirectory, eventPath), EventIsDirectory (IsFile), startManager, watchDir)-import System.FilePath ((</>))+import System.FilePath (takeExtension, (</>)) import System.Log.Logger import System.Taffybar.Context import System.Taffybar.DBus@@ -151,12 +151,20 @@  isDesktopEntryEvent :: Event -> Bool isDesktopEntryEvent event =-  eventIsDirectory event == IsFile && ".desktop" `isSuffixOf` eventPath event+  eventIsDirectory event == IsFile && takeExtension (eventPath event) == ".desktop"  getDesktopEntryApplicationDirs :: IO [FilePath] getDesktopEntryApplicationDirs = do   xdgDataDirs <- getXDGDataDirs-  filterM doesDirectoryExist $ map (</> "applications") $ nub xdgDataDirs+  filterM doesDirectoryExist $ map (</> "applications") $ dedupe xdgDataDirs++dedupe :: (Ord a) => [a] -> [a]+dedupe = go Set.empty+  where+    go _ [] = []+    go seen (x : xs)+      | x `Set.member` seen = go seen xs+      | otherwise = x : go (Set.insert x seen) xs  -- | Read 'DesktopEntry' values into a 'MM.Multimap', where they are indexed by -- the class name specified in the 'DesktopEntry'.
src/System/Taffybar/Information/AnthropicUsage.hs view
@@ -33,6 +33,7 @@ import Data.Aeson.Types (Parser) import qualified Data.ByteString.Lazy.Char8 as LBS import Data.List (sortOn)+import qualified Data.List as List import Data.Maybe (fromMaybe, mapMaybe) import qualified Data.Set as Set import qualified Data.Text as T@@ -377,7 +378,7 @@ dedupeTranscriptEntries :: [TranscriptUsageEntry] -> [TranscriptUsageEntry] dedupeTranscriptEntries =   snd-    . foldl+    . List.foldl'       ( \(seen, entries) entry ->           let requestId = transcriptUsageRequestId entry            in if Set.member requestId seen@@ -414,7 +415,7 @@  activeBlock :: UTCTime -> NominalDiffTime -> [TranscriptUsageEntry] -> Maybe (UTCTime, [TranscriptUsageEntry]) activeBlock now duration entries =-  case foldl addToBlock Nothing (sortOn transcriptUsageTimestamp entries) of+  case List.foldl' addToBlock Nothing (sortOn transcriptUsageTimestamp entries) of     Nothing -> Nothing     Just (start, blockEntries)       | addUTCTime duration start >= now -> Just (start, blockEntries)
+ src/System/Taffybar/Information/Audio.hs view
@@ -0,0 +1,195 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE KindSignatures #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++-- |+-- Module      : System.Taffybar.Information.Audio+-- Copyright   : (c) Ivan A. Malison+-- License     : BSD3-style (see LICENSE)+--+-- Maintainer  : Ivan A. Malison+-- Stability   : unstable+-- Portability : unportable+--+-- Unified audio information for PulseAudio and PipeWire/WirePlumber systems.+--+-- PulseAudio is preferred when its DBus protocol is available. On modern+-- PipeWire systems where @pipewire-pulse@ does not expose that DBus protocol,+-- this module falls back to the WirePlumber backend.+module System.Taffybar.Information.Audio+  ( AudioBackend (..),+    AudioInfo (..),+    defaultAudioPulseSink,+    defaultAudioWirePlumberNode,+    audioBackendAvailable,+    getAudioInfo,+    getAudioInfoChan,+    getAudioInfoState,+    getAudioInfoChanAndVar,+    getAudioInfoChanFor,+    getAudioInfoStateFor,+    toggleAudioMute,+    adjustAudioVolume,+    pulseSinkToWirePlumberNode,+  )+where++import Control.Concurrent (forkIO)+import Control.Concurrent.MVar+import Control.Concurrent.STM.TChan+import Control.Monad (forever, when)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.STM (atomically)+import Data.Proxy (Proxy (..))+import qualified Data.Text as T+import GHC.TypeLits (KnownSymbol, SomeSymbol (..), Symbol, someSymbolVal, symbolVal)+import System.Taffybar.Context (TaffyIO)+import qualified System.Taffybar.Information.PulseAudio as PulseAudio+import qualified System.Taffybar.Information.WirePlumber as WirePlumber++data AudioBackend+  = PulseAudioBackend+  | WirePlumberBackend+  deriving (Eq, Show)++data AudioInfo = AudioInfo+  { audioVolumePercent :: Maybe Int,+    audioMuted :: Maybe Bool,+    audioNodeName :: String,+    audioBackend :: AudioBackend+  }+  deriving (Eq, Show)++defaultAudioPulseSink :: String+defaultAudioPulseSink = "@DEFAULT_SINK@"++defaultAudioWirePlumberNode :: String+defaultAudioWirePlumberNode = "@DEFAULT_AUDIO_SINK@"++newtype AudioInfoChanVar (a :: Symbol)+  = AudioInfoChanVar (TChan (Maybe AudioInfo), MVar (Maybe AudioInfo))++audioBackendAvailable :: IO AudioBackend+audioBackendAvailable = do+  pulseAvailable <- PulseAudio.pulseAudioAvailable+  pure $+    if pulseAvailable+      then PulseAudioBackend+      else WirePlumberBackend++getAudioInfo :: String -> String -> IO (Maybe AudioInfo)+getAudioInfo pulseSink wirePlumberNode = do+  backend <- audioBackendAvailable+  case backend of+    PulseAudioBackend -> fmap fromPulseAudioInfo <$> PulseAudio.getPulseAudioInfo pulseSink+    WirePlumberBackend -> fmap fromWirePlumberInfo <$> WirePlumber.getWirePlumberInfo wirePlumberNode++getAudioInfoChan :: String -> String -> TaffyIO (TChan (Maybe AudioInfo))+getAudioInfoChan pulseSink wirePlumberNode =+  case someSymbolVal $ encodeAudioSpecs pulseSink wirePlumberNode of+    SomeSymbol (Proxy :: Proxy sym) -> getAudioInfoChanFor @sym++getAudioInfoState :: String -> String -> TaffyIO (Maybe AudioInfo)+getAudioInfoState pulseSink wirePlumberNode =+  case someSymbolVal $ encodeAudioSpecs pulseSink wirePlumberNode of+    SomeSymbol (Proxy :: Proxy sym) -> getAudioInfoStateFor @sym++getAudioInfoChanAndVar :: String -> String -> TaffyIO (TChan (Maybe AudioInfo), MVar (Maybe AudioInfo))+getAudioInfoChanAndVar pulseSink wirePlumberNode =+  case someSymbolVal $ encodeAudioSpecs pulseSink wirePlumberNode of+    SomeSymbol (Proxy :: Proxy sym) -> do+      AudioInfoChanVar pair <- getAudioInfoChanVarFor @sym+      pure pair++getAudioInfoChanFor :: forall a. (KnownSymbol a) => TaffyIO (TChan (Maybe AudioInfo))+getAudioInfoChanFor = do+  AudioInfoChanVar (chan, _) <- getAudioInfoChanVarFor @a+  pure chan++getAudioInfoStateFor :: forall a. (KnownSymbol a) => TaffyIO (Maybe AudioInfo)+getAudioInfoStateFor = do+  AudioInfoChanVar (_, var) <- getAudioInfoChanVarFor @a+  liftIO $ readMVar var++getAudioInfoChanVarFor :: forall a. (KnownSymbol a) => TaffyIO (AudioInfoChanVar a)+getAudioInfoChanVarFor = do+  let (pulseSink, wirePlumberNode) = decodeAudioSpecs $ symbolVal (Proxy @a)+  backend <- liftIO audioBackendAvailable+  case backend of+    PulseAudioBackend -> do+      (pulseChan, pulseVar) <- PulseAudio.getPulseAudioInfoChanAndVar pulseSink+      buildMappedChanVar fromPulseAudioInfo pulseChan pulseVar+    WirePlumberBackend -> do+      (wireChan, wireVar) <- WirePlumber.getWirePlumberInfoChanAndVar wirePlumberNode+      buildMappedChanVar fromWirePlumberInfo wireChan wireVar++toggleAudioMute :: String -> String -> IO Bool+toggleAudioMute pulseSink wirePlumberNode = do+  backend <- audioBackendAvailable+  case backend of+    PulseAudioBackend -> PulseAudio.togglePulseAudioMute pulseSink+    WirePlumberBackend -> WirePlumber.toggleWirePlumberMute wirePlumberNode++adjustAudioVolume :: String -> String -> Int -> IO Bool+adjustAudioVolume pulseSink wirePlumberNode deltaPercent = do+  backend <- audioBackendAvailable+  case backend of+    PulseAudioBackend -> PulseAudio.adjustPulseAudioVolume pulseSink deltaPercent+    WirePlumberBackend -> WirePlumber.adjustWirePlumberVolume wirePlumberNode deltaPercent++pulseSinkToWirePlumberNode :: String -> String+pulseSinkToWirePlumberNode "" = defaultAudioWirePlumberNode+pulseSinkToWirePlumberNode sink+  | sink == defaultAudioPulseSink = defaultAudioWirePlumberNode+  | otherwise = sink++buildMappedChanVar ::+  (backendInfo -> AudioInfo) ->+  TChan (Maybe backendInfo) ->+  MVar (Maybe backendInfo) ->+  TaffyIO (AudioInfoChanVar a)+buildMappedChanVar convert backendChan backendVar = do+  initialInfo <- liftIO $ fmap convert <$> readMVar backendVar+  liftIO $ do+    chan <- newBroadcastTChanIO+    var <- newMVar initialInfo+    atomically $ writeTChan chan initialInfo+    _ <- forkIO $ do+      ourChan <- atomically $ dupTChan backendChan+      forever $ do+        info <- fmap convert <$> atomically (readTChan ourChan)+        oldInfo <- swapMVar var info+        when (oldInfo /= info) $+          atomically $+            writeTChan chan info+    pure $ AudioInfoChanVar (chan, var)++fromPulseAudioInfo :: PulseAudio.PulseAudioInfo -> AudioInfo+fromPulseAudioInfo info =+  AudioInfo+    { audioVolumePercent = PulseAudio.pulseAudioVolumePercent info,+      audioMuted = PulseAudio.pulseAudioMuted info,+      audioNodeName = PulseAudio.pulseAudioSinkName info,+      audioBackend = PulseAudioBackend+    }++fromWirePlumberInfo :: WirePlumber.WirePlumberInfo -> AudioInfo+fromWirePlumberInfo info =+  AudioInfo+    { audioVolumePercent = Just $ round $ WirePlumber.wirePlumberVolume info * 100,+      audioMuted = Just $ WirePlumber.wirePlumberMuted info,+      audioNodeName = T.unpack $ WirePlumber.wirePlumberNodeName info,+      audioBackend = WirePlumberBackend+    }++encodeAudioSpecs :: String -> String -> String+encodeAudioSpecs pulseSink wirePlumberNode = pulseSink ++ "\n" ++ wirePlumberNode++decodeAudioSpecs :: String -> (String, String)+decodeAudioSpecs encoded =+  case break (== '\n') encoded of+    (pulseSink, '\n' : wirePlumberNode) -> (pulseSink, wirePlumberNode)+    (pulseSink, _) -> (pulseSink, pulseSinkToWirePlumberNode pulseSink)
src/System/Taffybar/Information/Battery.hs view
@@ -104,6 +104,12 @@     f :: (Num a, IsVariant a) => Variant -> a     f = fromMaybe (fromIntegral dflt) . fromVariant +boundedEnumFromInt :: (Bounded a, Enum a) => a -> Int -> a+boundedEnumFromInt fallback value+  | value < fromEnum (minBound `asTypeOf` fallback) = fallback+  | value > fromEnum (maxBound `asTypeOf` fallback) = fallback+  | otherwise = toEnum value+ -- XXX: Remove this once it is exposed in haskell-dbus  -- | Placeholder 'MethodError' used when required reply payload is missing.@@ -137,7 +143,10 @@       batteryVendor = readDict dict "Vendor" "",       batteryModel = readDict dict "Model" "",       batterySerial = readDict dict "Serial" "",-      batteryType = toEnum $ fromIntegral $ readDictIntegral dict "Type" 0,+      batteryType =+        boundedEnumFromInt BatteryTypeUnknown $+          fromIntegral $+            readDictIntegral dict "Type" 0,       batteryPowerSupply = readDict dict "PowerSupply" False,       batteryHasHistory = readDict dict "HasHistory" False,       batteryHasStatistics = readDict dict "HasStatistics" False,@@ -152,11 +161,15 @@       batteryTimeToFull = readDict dict "TimeToFull" 0,       batteryPercentage = readDict dict "Percentage" 0.0,       batteryIsPresent = readDict dict "IsPresent" False,-      batteryState = toEnum $ readDictIntegral dict "State" 0,+      batteryState =+        boundedEnumFromInt BatteryStateUnknown $+          readDictIntegral dict "State" 0,       batteryIsRechargeable = readDict dict "IsRechargable" True,       batteryCapacity = readDict dict "Capacity" 0.0,       batteryTechnology =-        toEnum $ fromIntegral $ readDictIntegral dict "Technology" 0,+        boundedEnumFromInt BatteryTechnologyUnknown $+          fromIntegral $+            readDictIntegral dict "Technology" 0,       batteryUpdateTime = readDict dict "UpdateTime" 0,       batteryLuminosity = readDict dict "Luminosity" 0.0,       batteryTemperature = readDict dict "Temperature" 0.0,
src/System/Taffybar/Information/CPU2.hs view
@@ -34,6 +34,7 @@ import System.Taffybar.Context (TaffyIO) import System.Taffybar.Information.StreamInfo import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay)+import Text.Read (readMaybe)  -- | Relative CPU load values, expressed as ratios in [0,1]. data CPULoad = CPULoad@@ -131,7 +132,8 @@ -- | Read one core-temperature file from sysfs and convert milli-degrees to -- degrees Celsius. readCPUTempFile :: FilePath -> IO Double-readCPUTempFile cpuTempFilePath = (/ 1000) . read <$> readFile cpuTempFilePath+readCPUTempFile cpuTempFilePath =+  maybe 0 (/ 1000) . readMaybe <$> readFile cpuTempFilePath  -- | List core-temperature input files in a hwmon directory. getAllTemperatureFiles :: FilePath -> IO [FilePath]
src/System/Taffybar/Information/Chrome.hs view
@@ -11,6 +11,7 @@ import Control.Monad.Trans.Class import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy as LBS+import qualified Data.List as List import qualified Data.Map as M import Data.Maybe import qualified GI.GLib as Gdk@@ -133,4 +134,4 @@     wins <- getWindows     titles <- mapM getWindowTitle wins     let winsWithTitles = zip wins titles-    return $ foldl addTabIdEntry tabMap winsWithTitles+    return $ List.foldl' addTabIdEntry tabMap winsWithTitles
+ src/System/Taffybar/Information/ChromeWindowInfo.hs view
@@ -0,0 +1,90 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Event source for the chrome-favicon-bridge D-Bus service.+module System.Taffybar.Information.ChromeWindowInfo+  ( registerChromeWindowInfoRefreshRequests,+  )+where++import Control.Exception.Enclosed (catchAny)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Reader (ask, asks, runReaderT)+import qualified DBus as D+import qualified DBus.Client as DBus+import qualified Data.Text as T+import System.Log.Logger (Priority (..), logM)+import System.Taffybar.Context (TaffyIO, getStateDefault, sessionDBusClient)+import qualified System.Taffybar.DBus.Client.ChromeWindowInfo as ChromeWindowInfoDBus+import System.Taffybar.Information.Workspaces.Model+  ( WindowIdentity (..),+    WorkspacesRefreshTarget (..),+  )+import System.Taffybar.Information.Workspaces.Refresh (requestWorkspacesRefresh)++data ChromeWindowInfoRefreshSubscription+  = ChromeWindowInfoRefreshSubscription++registerChromeWindowInfoRefreshRequests :: TaffyIO ()+registerChromeWindowInfoRefreshRequests = do+  _ <-+    getStateDefault $+      buildChromeWindowInfoRefreshSubscription+        >> return ChromeWindowInfoRefreshSubscription+  return ()++buildChromeWindowInfoRefreshSubscription :: TaffyIO ()+buildChromeWindowInfoRefreshSubscription = do+  ctx <- ask+  client <- asks sessionDBusClient+  let requestRefresh target =+        runReaderT (requestWorkspacesRefresh target) ctx+  liftIO $+    ( do+        registerChromeWindowInfoSignals client requestRefresh+        logM+          "System.Taffybar.Information.ChromeWindowInfo"+          DEBUG+          "Subscribed to Chrome window info signals"+    )+      `catchAny` \err ->+        chromeWindowInfoLog+          WARNING+          ("Failed to subscribe to Chrome window info signals: " <> show err)++registerChromeWindowInfoSignals ::+  DBus.Client ->+  (WorkspacesRefreshTarget -> IO ()) ->+  IO ()+registerChromeWindowInfoSignals client requestRefresh = do+  _ <-+    ChromeWindowInfoDBus.registerForWindowUpdated+      client+      DBus.matchAny+      (emitWindowRefresh requestRefresh)+      logMalformedWindowUpdatedSignal+  return ()++emitWindowRefresh :: (WorkspacesRefreshTarget -> IO ()) -> D.Signal -> String -> String -> IO ()+emitWindowRefresh requestRefresh _signal windowId _payload =+  requestRefresh $+    RefreshWindow $+      HyprlandWindowIdentity $+        normalizeHyprlandWindowId $+          T.pack windowId++logMalformedWindowUpdatedSignal :: D.Signal -> IO ()+logMalformedWindowUpdatedSignal signal =+  chromeWindowInfoLog+    WARNING+    ("Ignoring malformed ChromeWindowInfo WindowUpdated signal: " <> show (D.signalBody signal))++chromeWindowInfoLog :: Priority -> String -> IO ()+chromeWindowInfoLog =+  logM "System.Taffybar.Information.ChromeWindowInfo"++normalizeHyprlandWindowId :: T.Text -> T.Text+normalizeHyprlandWindowId address =+  let trimmed = T.strip address+   in if "0x" `T.isPrefixOf` trimmed || T.null trimmed+        then trimmed+        else "0x" <> trimmed
src/System/Taffybar/Information/Hyprland.hs view
@@ -79,7 +79,6 @@ import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT) import Data.Aeson (FromJSON (..), eitherDecode', withObject, (.:)) import qualified Data.ByteString as BS-import qualified Data.ByteString.Char8 as BS8 import qualified Data.ByteString.Lazy as BL import Data.List (sortOn) import Data.Ord (Down (..))@@ -419,8 +418,8 @@ hyprlandCommandToSocketCommand :: HyprlandCommand -> Either HyprlandError BS.ByteString hyprlandCommandToSocketCommand HyprlandCommand {commandArgs = args, commandJson = isJson}   | null args = Left $ HyprlandCommandBuildFailed "No Hyprland command provided"-  | isJson = Right $ BS8.pack $ "j/" ++ unwords args-  | otherwise = Right $ BS8.pack $ unwords args+  | isJson = Right $ TE.encodeUtf8 $ T.pack $ "j/" ++ unwords args+  | otherwise = Right $ TE.encodeUtf8 $ T.pack $ unwords args  -- | Run a Hyprland command, preferring the command socket if enabled. --
src/System/Taffybar/Information/Hyprland/API.hs view
@@ -111,9 +111,9 @@   | otherwise = Right $ HyprlandAddress t  data HyprlandDispatch-  = -- | @hyprctl dispatch workspace <name-or-id>@+  = -- | @hyprctl dispatch 'hl.dsp.focus({ workspace = "<name-or-id>" })'@     DispatchWorkspace HyprlandWorkspaceTarget-  | -- | @hyprctl dispatch focuswindow address:<addr>@+  | -- | @hyprctl dispatch 'hl.dsp.focus({ window = "address:<addr>" })'@     DispatchFocusWindowAddress HyprlandAddress   deriving (Show, Eq) @@ -124,6 +124,26 @@ dispatchToArgs :: HyprlandDispatch -> [String] dispatchToArgs action =   case action of-    DispatchWorkspace ws -> ["dispatch", "workspace", T.unpack (hyprlandWorkspaceTargetText ws)]+    DispatchWorkspace ws ->+      [ "dispatch",+        "hl.dsp.focus({ workspace = "+          <> luaString (hyprlandWorkspaceTargetText ws)+          <> " })"+      ]     DispatchFocusWindowAddress addr ->-      ["dispatch", "focuswindow", "address:" <> T.unpack (hyprlandAddressText addr)]+      [ "dispatch",+        "hl.dsp.focus({ window = "+          <> luaString ("address:" <> hyprlandAddressText addr)+          <> " })"+      ]++luaString :: Text -> String+luaString text =+  "\"" <> concatMap escapeChar (T.unpack text) <> "\""+  where+    escapeChar '"' = "\\\""+    escapeChar '\\' = "\\\\"+    escapeChar '\n' = "\\n"+    escapeChar '\r' = "\\r"+    escapeChar '\t' = "\\t"+    escapeChar c = [c]
src/System/Taffybar/Information/Hyprland/Types.hs view
@@ -105,6 +105,7 @@     hyprClientHidden :: Bool,     hyprClientMapped :: Bool,     hyprClientUrgent :: Bool,+    hyprClientPinned :: Bool,     hyprClientAt :: Maybe (Int, Int)   }   deriving (Show, Eq)@@ -123,6 +124,7 @@       <*> v .:? "hidden" .!= False       <*> v .:? "mapped" .!= True       <*> v .:? "urgent" .!= False+      <*> v .:? "pinned" .!= False       <*> pure at  -- | Result from @hyprctl -j activeworkspace@.
+ src/System/Taffybar/Information/HyprlandWorkspaceHistory.hs view
@@ -0,0 +1,102 @@+{-# LANGUAGE OverloadedStrings #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module      : System.Taffybar.Information.HyprlandWorkspaceHistory+-- Copyright   : (c) Ivan A. Malison+-- License     : BSD3-style (see LICENSE)+--+-- Maintainer  : Ivan A. Malison+-- Stability   : unstable+-- Portability : unportable+--+-- Reader for the runtime JSON snapshot written by the hypr-workspace-history+-- Hyprland plugin.+module System.Taffybar.Information.HyprlandWorkspaceHistory+  ( HyprlandWorkspaceHistoryMonitor (..),+    HyprlandWorkspaceHistorySnapshot (..),+    workspaceHistoryStateFileName,+    workspaceHistoryStatePath,+    readHyprlandWorkspaceHistorySnapshot,+    workspaceHistoryOrderForMonitor,+    workspaceHistoryOrderForActiveMonitor,+  )+where++import Control.Exception.Enclosed (catchAny)+import Data.Aeson (FromJSON (..), eitherDecodeStrict', withObject, (.:), (.:?))+import qualified Data.ByteString as BS+import qualified Data.Map.Strict as M+import Data.Text (Text)+import Data.Word (Word64)+import System.Directory (doesFileExist)+import System.Environment (lookupEnv)+import System.FilePath ((</>))++newtype HyprlandWorkspaceHistoryMonitor = HyprlandWorkspaceHistoryMonitor+  { hwhMonitorHistory :: [Int]+  }+  deriving (Eq, Show)++instance FromJSON HyprlandWorkspaceHistoryMonitor where+  parseJSON = withObject "HyprlandWorkspaceHistoryMonitor" $ \v ->+    HyprlandWorkspaceHistoryMonitor+      <$> v .: "history"++data HyprlandWorkspaceHistorySnapshot = HyprlandWorkspaceHistorySnapshot+  { hwhVersion :: Int,+    hwhRevision :: Word64,+    hwhActiveMonitor :: Maybe Text,+    hwhActiveWorkspace :: Maybe Int,+    hwhMonitors :: M.Map Text HyprlandWorkspaceHistoryMonitor+  }+  deriving (Eq, Show)++instance FromJSON HyprlandWorkspaceHistorySnapshot where+  parseJSON = withObject "HyprlandWorkspaceHistorySnapshot" $ \v ->+    HyprlandWorkspaceHistorySnapshot+      <$> v .: "version"+      <*> v .: "revision"+      <*> v .:? "active_monitor"+      <*> v .:? "active_workspace"+      <*> v .: "monitors"++workspaceHistoryStateFileName :: FilePath+workspaceHistoryStateFileName = "hyprland-workspace-history.json"++workspaceHistoryStatePath :: IO (Maybe FilePath)+workspaceHistoryStatePath = do+  mRuntimeDir <- lookupEnv "XDG_RUNTIME_DIR"+  pure $ (</> workspaceHistoryStateFileName) <$> mRuntimeDir++readHyprlandWorkspaceHistorySnapshot :: IO (Either String (Maybe HyprlandWorkspaceHistorySnapshot))+readHyprlandWorkspaceHistorySnapshot =+  ( do+      mPath <- workspaceHistoryStatePath+      case mPath of+        Nothing -> pure $ Right Nothing+        Just path -> do+          exists <- doesFileExist path+          if not exists+            then pure $ Right Nothing+            else do+              bytes <- BS.readFile path+              pure $ Just <$> eitherDecodeStrict' bytes+  )+    `catchAny` \err -> pure $ Left (show err)++workspaceHistoryOrderForMonitor ::+  Text ->+  HyprlandWorkspaceHistorySnapshot ->+  Maybe [Int]+workspaceHistoryOrderForMonitor monitor snapshot =+  hwhMonitorHistory <$> M.lookup monitor (hwhMonitors snapshot)++workspaceHistoryOrderForActiveMonitor ::+  HyprlandWorkspaceHistorySnapshot ->+  Maybe [Int]+workspaceHistoryOrderForActiveMonitor snapshot =+  hwhActiveMonitor snapshot >>= (`workspaceHistoryOrderForMonitor` snapshot)
src/System/Taffybar/Information/Memory.hs view
@@ -7,9 +7,10 @@ where  import qualified Data.ByteString.Char8 as BS8+import Text.Read (readMaybe)  toMB :: String -> Double-toMB size = (read size :: Double) / 1024+toMB size = maybe 0 (/ 1024) (readMaybe size)  safeRatio :: Double -> Double -> Double safeRatio
src/System/Taffybar/Information/Network.hs view
@@ -158,6 +158,6 @@  -- | Sum download/upload speeds across interfaces. sumSpeeds :: [(Rational, Rational)] -> (Rational, Rational)-sumSpeeds = foldr1 sumOne+sumSpeeds = foldr sumOne (0, 0)   where     sumOne (d1, u1) (d2, u2) = (d1 + d2, u1 + u2)
src/System/Taffybar/Information/NetworkManager.hs view
@@ -44,7 +44,7 @@ import DBus.Internal.Types (Serial (..)) import qualified DBus.TH as DBus import qualified Data.ByteString as BS-import Data.List (minimumBy)+import qualified Data.List as List import Data.Map (Map) import qualified Data.Map as M import Data.Maybe (fromMaybe, listToMaybe)@@ -461,17 +461,17 @@  pickBestActiveConnection :: [ActiveConnectionInfo] -> Maybe ActiveConnectionInfo pickBestActiveConnection [] = Nothing-pickBestActiveConnection infos =+pickBestActiveConnection (firstInfo : restInfos) =   let rank t         | t == "802-3-ethernet" = 0 :: Int         | t == "802-11-wireless" = 1         | t == "vpn" = 2         | T.null t = 99         | otherwise = 50-   in Just $-        minimumBy-          (\a b -> compare (rank (activeConnectionType a)) (rank (activeConnectionType b)))-          infos+      betterActiveConnection a b+        | rank (activeConnectionType a) <= rank (activeConnectionType b) = a+        | otherwise = b+   in Just $ List.foldl' betterActiveConnection firstInfo restInfos  toNetworkType :: Text -> NetworkType toNetworkType t
src/System/Taffybar/Information/OpenAIUsage.hs view
@@ -5,6 +5,9 @@ -- This module currently reads the ChatGPT OAuth token file written by Codex and -- calls ChatGPT's Codex usage endpoint. That endpoint is not part of OpenAI's -- public API, so callers can override the endpoint, auth path, and user agent.+-- Local Codex transcript JSONL files are used to derive actual token counts,+-- because the remote endpoint currently exposes percentages and reset times but+-- not token totals. module System.Taffybar.Information.OpenAIUsage   ( OpenAIUsageConfig (..),     defaultOpenAIUsageConfig,@@ -14,6 +17,7 @@     OpenAIUsageAdditionalRateLimit (..),     OpenAIUsageRateLimit (..),     OpenAIUsageWindow (..),+    OpenAIUsageTotals (..),     OpenAIUsageCredits (..),     getOpenAIUsageInfo,     updateOpenAIUsage,@@ -31,13 +35,24 @@ import Control.Monad (forever, void) import Control.Monad.IO.Class (liftIO) import Data.Aeson+import Data.Aeson.Types (Parser) import qualified Data.ByteString.Lazy as LBS+import qualified Data.ByteString.Lazy.Char8 as LBS8+import Data.Maybe (mapMaybe) import qualified Data.Text as T import qualified Data.Text.Encoding as TE+import Data.Time.Clock+import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import Network.HTTP.Simple-import System.Directory (doesFileExist, getHomeDirectory)+import System.Directory+  ( doesDirectoryExist,+    doesFileExist,+    getHomeDirectory,+    getModificationTime,+    listDirectory,+  ) import System.Environment (lookupEnv)-import System.FilePath ((</>))+import System.FilePath (takeExtension, (</>)) import System.Log.Logger (Priority (WARNING), logM) import System.Taffybar.Context (TaffyIO, getStateDefault) import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay)@@ -46,7 +61,9 @@   { openAIUsagePollInterval :: Double,     openAIUsageEndpoint :: String,     openAIUsageUserAgent :: String,-    openAIUsageAuthPath :: Maybe FilePath+    openAIUsageAuthPath :: Maybe FilePath,+    openAIUsageSessionsPath :: Maybe FilePath,+    openAIUsageFileLookbackSeconds :: NominalDiffTime   }  defaultOpenAIUsageConfig :: OpenAIUsageConfig@@ -55,7 +72,9 @@     { openAIUsagePollInterval = 60 * 15,       openAIUsageEndpoint = "https://chatgpt.com/backend-api/codex/usage",       openAIUsageUserAgent = "taffybar-openai-usage",-      openAIUsageAuthPath = Nothing+      openAIUsageAuthPath = Nothing,+      openAIUsageSessionsPath = Nothing,+      openAIUsageFileLookbackSeconds = 8 * 24 * 60 * 60     }  data OpenAIUsageAuth = OpenAIUsageAuth@@ -124,17 +143,71 @@ data OpenAIUsageWindow = OpenAIUsageWindow   { openAIUsageUsedPercent :: Int,     openAIUsageWindowDurationSeconds :: Maybe Int,-    openAIUsageResetAfterSeconds :: Maybe Int+    openAIUsageResetAfterSeconds :: Maybe Int,+    openAIUsageResetAt :: Maybe UTCTime,+    openAIUsageWindowTotals :: Maybe OpenAIUsageTotals   }   deriving (Eq, Show)  instance FromJSON OpenAIUsageWindow where-  parseJSON = withObject "OpenAIUsageWindow" $ \o ->+  parseJSON = withObject "OpenAIUsageWindow" $ \o -> do+    resetAt <- (o .:? "reset_at" :: Parser (Maybe Int))     OpenAIUsageWindow       <$> o .: "used_percent"       <*> o .:? "limit_window_seconds"       <*> o .:? "reset_after_seconds"+      <*> pure (posixSecondsToUTCTime . realToFrac <$> (resetAt :: Maybe Int))+      <*> pure Nothing +data OpenAIUsageTotals = OpenAIUsageTotals+  { openAIUsageRequestCount :: Int,+    openAIUsageInputTokens :: Int,+    openAIUsageCachedInputTokens :: Int,+    openAIUsageOutputTokens :: Int,+    openAIUsageReasoningOutputTokens :: Int,+    openAIUsageTotalTokens :: Int+  }+  deriving (Eq, Show)++instance Semigroup OpenAIUsageTotals where+  a <> b =+    OpenAIUsageTotals+      { openAIUsageRequestCount = openAIUsageRequestCount a + openAIUsageRequestCount b,+        openAIUsageInputTokens = openAIUsageInputTokens a + openAIUsageInputTokens b,+        openAIUsageCachedInputTokens = openAIUsageCachedInputTokens a + openAIUsageCachedInputTokens b,+        openAIUsageOutputTokens = openAIUsageOutputTokens a + openAIUsageOutputTokens b,+        openAIUsageReasoningOutputTokens = openAIUsageReasoningOutputTokens a + openAIUsageReasoningOutputTokens b,+        openAIUsageTotalTokens = openAIUsageTotalTokens a + openAIUsageTotalTokens b+      }++instance Monoid OpenAIUsageTotals where+  mempty =+    OpenAIUsageTotals+      { openAIUsageRequestCount = 0,+        openAIUsageInputTokens = 0,+        openAIUsageCachedInputTokens = 0,+        openAIUsageOutputTokens = 0,+        openAIUsageReasoningOutputTokens = 0,+        openAIUsageTotalTokens = 0+      }++instance FromJSON OpenAIUsageTotals where+  parseJSON = withObject "OpenAIUsageTotals" $ \o -> do+    input <- o .:? "input_tokens" .!= 0+    cachedInput <- o .:? "cached_input_tokens" .!= 0+    outputTokens <- o .:? "output_tokens" .!= 0+    reasoningOutput <- o .:? "reasoning_output_tokens" .!= 0+    total <- o .:? "total_tokens" .!= (input + outputTokens)+    return $+      OpenAIUsageTotals+        { openAIUsageRequestCount = 1,+          openAIUsageInputTokens = input,+          openAIUsageCachedInputTokens = cachedInput,+          openAIUsageOutputTokens = outputTokens,+          openAIUsageReasoningOutputTokens = reasoningOutput,+          openAIUsageTotalTokens = total+        }+ data OpenAIUsageCredits = OpenAIUsageCredits   { openAIUsageHasCredits :: Bool,     openAIUsageCreditsUnlimited :: Bool,@@ -151,8 +224,11 @@  getOpenAIUsageInfo :: OpenAIUsageConfig -> IO OpenAIUsageInfo getOpenAIUsageInfo config = do+  now <- getCurrentTime   auth <- readOpenAIUsageAuth config-  fetchOpenAIUsage config auth+  info <- fetchOpenAIUsage config auth+  entries <- readOpenAITranscriptEntries config now+  return $ addOpenAITranscriptTotals entries info  updateOpenAIUsage :: OpenAIUsageConfig -> IO OpenAIUsageSnapshot updateOpenAIUsage config = do@@ -197,6 +273,126 @@   if statusCode >= 200 && statusCode < 300     then either fail return $ eitherDecode body     else fail $ "OpenAI usage endpoint returned HTTP " <> show statusCode++data OpenAITranscriptEntry = OpenAITranscriptEntry+  { openAITranscriptTimestamp :: UTCTime,+    openAITranscriptTotals :: OpenAIUsageTotals+  }+  deriving (Eq, Show)++data OpenAITranscriptJSON = OpenAITranscriptJSON+  { openAITranscriptJSONTimestamp :: Maybe UTCTime,+    openAITranscriptJSONUsage :: Maybe OpenAIUsageTotals+  }++instance FromJSON OpenAITranscriptJSON where+  parseJSON = withObject "OpenAITranscriptJSON" $ \o -> do+    payload <- o .:? "payload"+    usage <- maybe (return Nothing) parseTokenCountPayload payload+    OpenAITranscriptJSON+      <$> o .:? "timestamp"+      <*> pure usage++parseTokenCountPayload :: Value -> Parser (Maybe OpenAIUsageTotals)+parseTokenCountPayload =+  withObject "TokenCountPayload" $ \payload -> do+    payloadType <- payload .:? "type" :: Parser (Maybe T.Text)+    case payloadType of+      Just "token_count" -> payload .:? "info" >>= traverse parseTokenCountInfo+      _ -> return Nothing++parseTokenCountInfo :: Value -> Parser OpenAIUsageTotals+parseTokenCountInfo =+  withObject "TokenCountInfo" $ \info ->+    info .: "last_token_usage"++readOpenAITranscriptEntries :: OpenAIUsageConfig -> UTCTime -> IO [OpenAITranscriptEntry]+readOpenAITranscriptEntries config now = do+  sessionsPath <- maybe defaultCodexSessionsPath return (openAIUsageSessionsPath config)+  exists <- doesDirectoryExist sessionsPath+  if not exists+    then return []+    else do+      files <- jsonlFilesModifiedSince (addUTCTime (negate $ openAIUsageFileLookbackSeconds config) now) sessionsPath+      concat <$> mapM readTranscriptFile files++defaultCodexSessionsPath :: IO FilePath+defaultCodexSessionsPath = do+  codexHome <- lookupEnv "CODEX_HOME"+  case codexHome of+    Just dir -> return $ dir </> "sessions"+    Nothing -> do+      home <- getHomeDirectory+      return $ home </> ".codex" </> "sessions"++jsonlFilesModifiedSince :: UTCTime -> FilePath -> IO [FilePath]+jsonlFilesModifiedSince cutoff path = do+  entries <- listDirectory path+  concat+    <$> mapM+      ( \entry -> do+          let child = path </> entry+          isDirectory <- doesDirectoryExist child+          isFile <- doesFileExist child+          if isDirectory+            then jsonlFilesModifiedSince cutoff child+            else+              if isFile && takeExtension child == ".jsonl"+                then do+                  modified <- getModificationTime child+                  return [child | modified >= cutoff]+                else return []+      )+      entries++readTranscriptFile :: FilePath -> IO [OpenAITranscriptEntry]+readTranscriptFile path = do+  bytes <- LBS8.readFile path+  return $ mapMaybe transcriptLineToEntry (LBS8.lines bytes)++transcriptLineToEntry :: LBS.ByteString -> Maybe OpenAITranscriptEntry+transcriptLineToEntry bytes = do+  decoded <- either (const Nothing) Just $ eitherDecode bytes+  timestamp <- openAITranscriptJSONTimestamp decoded+  usage <- openAITranscriptJSONUsage decoded+  return $+    OpenAITranscriptEntry+      { openAITranscriptTimestamp = timestamp,+        openAITranscriptTotals = usage+      }++addOpenAITranscriptTotals :: [OpenAITranscriptEntry] -> OpenAIUsageInfo -> OpenAIUsageInfo+addOpenAITranscriptTotals entries info =+  info+    { openAIUsageRateLimit = addRateLimitTranscriptTotals entries (openAIUsageRateLimit info)+    }++addRateLimitTranscriptTotals :: [OpenAITranscriptEntry] -> OpenAIUsageRateLimit -> OpenAIUsageRateLimit+addRateLimitTranscriptTotals entries limit =+  limit+    { openAIUsagePrimaryWindow = addWindowTranscriptTotals entries <$> openAIUsagePrimaryWindow limit,+      openAIUsageSecondaryWindow = addWindowTranscriptTotals entries <$> openAIUsageSecondaryWindow limit+    }++addWindowTranscriptTotals :: [OpenAITranscriptEntry] -> OpenAIUsageWindow -> OpenAIUsageWindow+addWindowTranscriptTotals entries window =+  window+    { openAIUsageWindowTotals =+        foldMap openAITranscriptTotals <$> windowEntries window entries+    }++windowEntries :: OpenAIUsageWindow -> [OpenAITranscriptEntry] -> Maybe [OpenAITranscriptEntry]+windowEntries window entries = do+  duration <- openAIUsageWindowDurationSeconds window+  end <- openAIUsageResetAt window+  let start = addUTCTime (negate $ fromIntegral duration) end+  return $+    filter+      ( \entry ->+          let timestamp = openAITranscriptTimestamp entry+           in timestamp >= start && timestamp < end+      )+      entries  newtype OpenAIUsageChanVar   = OpenAIUsageChanVar
src/System/Taffybar/Information/Privacy.hs view
@@ -38,6 +38,7 @@   ) where +import Control.Applicative ((<|>)) import Control.Concurrent.MVar import Control.Concurrent.STM.TChan import Control.Exception (SomeException, catch)@@ -240,11 +241,6 @@                 nodeName = nName,                 isMonitor = monitor               }-  where-    (<|>) :: Maybe a -> Maybe a -> Maybe a-    (<|>) ma mb = case ma of-      Nothing -> mb-      just -> just  -- | Map media.class to NodeType. classToNodeType :: Text -> Maybe NodeType
src/System/Taffybar/Information/PulseAudio.hs view
@@ -35,6 +35,7 @@     getPulseAudioInfoChanFor,     getPulseAudioInfoStateFor,     connectPulseAudio,+    pulseAudioAvailable,     togglePulseAudioMute,     adjustPulseAudioVolume,   )@@ -412,6 +413,13 @@         Left (_ :: SomeException) -> return Nothing         Right client -> return (Just client) +pulseAudioAvailable :: IO Bool+pulseAudioAvailable = do+  mClient <- connectPulseAudio+  case mClient of+    Nothing -> pure False+    Just client -> disconnect client >> pure True+ withPulseAudio :: (Client -> IO a) -> IO (Maybe a) withPulseAudio action = do   mClient <- connectPulseAudio@@ -469,7 +477,7 @@       paServerLookupInterfaceName   case propsResult of     Left err -> do-      audioLogF WARNING "Failed to read PulseAudio ServerLookup1 properties: %s" err+      audioLogF DEBUG "Failed to read PulseAudio ServerLookup1 properties: %s" err       return Nothing     Right props -> case readDictMaybe props "Address" of       Just addr | not (null addr) -> return (Just addr)
src/System/Taffybar/Information/Wakeup/Manager.hs view
@@ -47,6 +47,7 @@     writeTChan,   ) import Control.Exception.Enclosed (catchAny)+import qualified Data.List as List import qualified Data.Map.Strict as M import Data.Maybe (mapMaybe) import Data.Time.Clock.POSIX (getPOSIXTime)@@ -216,9 +217,10 @@         )  nextDueNs :: M.Map Word64 IntervalRegistration -> Maybe Word64-nextDueNs registrations-  | M.null registrations = Nothing-  | otherwise = Just $ minimum $ intervalNextDueNs <$> M.elems registrations+nextDueNs registrations =+  case intervalNextDueNs <$> M.elems registrations of+    [] -> Nothing+    firstDue : restDue -> Just $ List.foldl' min firstDue restDue  secondsToNanoseconds :: Int -> Either String Word64 secondsToNanoseconds seconds
src/System/Taffybar/Information/WirePlumber.hs view
@@ -1,5 +1,6 @@ {-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE DataKinds #-}+{-# LANGUAGE ForeignFunctionInterface #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-}@@ -18,16 +19,19 @@ -- Stability   : unstable -- Portability : unportable ----- WirePlumber/PipeWire audio information using the @wpctl@ command-line tool.+-- WirePlumber/PipeWire audio information using libwireplumber through+-- GObject introspection. ----- This module provides access to audio volume and mute state for the default--- audio sink or source via WirePlumber's command-line interface.+-- This module keeps a WirePlumber core and object manager open, reads the+-- current node state from cached PipeWire params, and refreshes when+-- WirePlumber emits object or param change signals. module System.Taffybar.Information.WirePlumber   ( WirePlumberInfo (..),     NodeType (..),     getWirePlumberInfo,     getWirePlumberInfoChan,     getWirePlumberInfoState,+    getWirePlumberInfoChanAndVar,     getWirePlumberInfoChanFor,     getWirePlumberInfoStateFor,     toggleWirePlumberMute,@@ -36,22 +40,50 @@   ) where +import Control.Concurrent (forkOS, runInBoundThread) import Control.Concurrent.MVar import Control.Concurrent.STM.TChan-import Control.Exception (SomeException, try)-import Control.Monad (void)+import Control.Exception (SomeException, finally, try)+import Control.Monad (forM, forM_, unless, when) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.STM (atomically)-import Data.List (isInfixOf)+import Data.Aeson (decodeStrict)+import Data.Aeson.Types (parseMaybe, withObject, (.:))+import qualified Data.ByteString.Char8 as B+import Data.Char (isDigit)+import qualified Data.GI.Base.BasicTypes as GI+import qualified Data.GI.Base.GValue as GValue+import qualified Data.GI.Base.GVariant as GVariant+import qualified Data.GI.Base.ManagedPtr as ManagedPtr+import Data.IORef+import Data.Maybe (catMaybes, fromMaybe, listToMaybe, mapMaybe) import Data.Proxy (Proxy (..)) import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import Foreign+import Foreign.C.String+import Foreign.C.Types import GHC.TypeLits (KnownSymbol, SomeSymbol (..), Symbol, someSymbolVal, symbolVal)+import qualified GI.GLib.Structs.MainContext as MainContext+import qualified GI.GLib.Structs.MainLoop as MainLoop+import qualified GI.GObject.Objects.Object as GObject+import qualified GI.Wp.Enums as Wp+import qualified GI.Wp.Flags as Wp+import qualified GI.Wp.Functions as Wp+import qualified GI.Wp.Interfaces.PipewireObject as PipewireObject+import qualified GI.Wp.Objects.Conf as Conf+import qualified GI.Wp.Objects.Core as Core+import qualified GI.Wp.Objects.Metadata as Metadata+import qualified GI.Wp.Objects.Node as Node+import qualified GI.Wp.Objects.ObjectManager as ObjectManager+import qualified GI.Wp.Structs.Iterator as Iterator+import qualified GI.Wp.Structs.ObjectInterest as ObjectInterest+import qualified GI.Wp.Structs.SpaPod as SpaPod+import System.IO.Unsafe (unsafePerformIO) import System.Log.Logger (Priority (..)) import System.Taffybar.Context (TaffyIO, getStateDefault)-import System.Taffybar.Information.Wakeup (taffyForeverWithDelay) import System.Taffybar.Util (logPrintF, runCommand)-import Text.Read (readMaybe)  -- | Type of audio node. data NodeType@@ -67,24 +99,62 @@     wirePlumberVolume :: Double,     -- | Whether the node is muted     wirePlumberMuted :: Bool,-    -- | Name of the node (e.g., "@DEFAULT_AUDIO_SINK@")+    -- | PipeWire node name     wirePlumberNodeName :: Text   }   deriving (Eq, Show) +data WirePlumberSelector+  = DefaultNode NodeType+  | NodeName Text+  | NodeId Text+  deriving (Eq, Show)++data WirePlumberObjectRefs = WirePlumberObjectRefs+  { wirePlumberObjectRef :: GObject.Object,+    wirePlumberNodeRef :: Maybe Node.Node,+    wirePlumberMetadataRef :: Maybe Metadata.Metadata+  }++newtype WirePlumberInfoChanVar (a :: Symbol)+  = WirePlumberInfoChanVar (TChan (Maybe WirePlumberInfo), MVar (Maybe WirePlumberInfo))+ wpLogPath :: String wpLogPath = "System.Taffybar.Information.WirePlumber"  wpLogF :: (MonadIO m, Show t) => Priority -> String -> t -> m () wpLogF = logPrintF wpLogPath -newtype WirePlumberInfoChanVar (a :: Symbol)-  = WirePlumberInfoChanVar (TChan (Maybe WirePlumberInfo), MVar (Maybe WirePlumberInfo))+foreign import ccall "wp_spa_pod_get_property"+  c_wp_spa_pod_get_property ::+    Ptr SpaPod.SpaPod ->+    Ptr CString ->+    Ptr (Ptr SpaPod.SpaPod) ->+    IO CInt +foreign import ccall "wp_metadata_find"+  c_wp_metadata_find ::+    Ptr Metadata.Metadata ->+    Word32 ->+    CString ->+    Ptr CString ->+    IO CString++wpInitialized :: MVar Bool+wpInitialized = unsafePerformIO $ newMVar False+{-# NOINLINE wpInitialized #-}++initWirePlumber :: IO ()+initWirePlumber =+  modifyMVar_ wpInitialized $ \initialized -> do+    unless initialized $+      Wp.init [Wp.InitFlagsAll]+    pure True+ -- | Get a broadcast channel for WirePlumber info for the provided node spec. ----- The first call for a given node spec will start a monitoring thread that--- polls @wpctl@ periodically and broadcasts updates.+-- The first call for a given node spec starts a monitoring thread that keeps a+-- WirePlumber connection open and refreshes on PipeWire object/param changes. -- Subsequent calls return the already created channel. getWirePlumberInfoChan :: String -> TaffyIO (TChan (Maybe WirePlumberInfo)) getWirePlumberInfoChan nodeSpec =@@ -92,13 +162,19 @@     SomeSymbol (Proxy :: Proxy sym) -> getWirePlumberInfoChanFor @sym  -- | Read the current WirePlumber info state for the provided node spec.------ See 'getWirePlumberInfoChan' for monitoring behavior. getWirePlumberInfoState :: String -> TaffyIO (Maybe WirePlumberInfo) getWirePlumberInfoState nodeSpec =   case someSymbolVal nodeSpec of     SomeSymbol (Proxy :: Proxy sym) -> getWirePlumberInfoStateFor @sym +-- | Get both the broadcast channel and current state MVar for WirePlumber info.+getWirePlumberInfoChanAndVar :: String -> TaffyIO (TChan (Maybe WirePlumberInfo), MVar (Maybe WirePlumberInfo))+getWirePlumberInfoChanAndVar nodeSpec =+  case someSymbolVal nodeSpec of+    SomeSymbol (Proxy :: Proxy sym) -> do+      WirePlumberInfoChanVar pair <- getWirePlumberInfoChanVarFor @sym+      pure pair+ -- | Get a broadcast channel for WirePlumber info for a node spec given as a -- type-level string. getWirePlumberInfoChanFor :: forall a. (KnownSymbol a) => TaffyIO (TChan (Maybe WirePlumberInfo))@@ -117,75 +193,406 @@ getWirePlumberInfoChanVarFor =   getStateDefault $ do     let nodeSpec = symbolVal (Proxy @a)-    chan <- liftIO newBroadcastTChanIO-    var <- liftIO $ newMVar Nothing-    liftIO $ refreshWirePlumberInfo nodeSpec chan var-    let pollIntervalSeconds :: Double-        pollIntervalSeconds = fromIntegral pollIntervalMicros / 1000000-    void $-      taffyForeverWithDelay pollIntervalSeconds $-        liftIO $-          refreshWirePlumberInfo nodeSpec chan var-    pure $ WirePlumberInfoChanVar (chan, var)---- | Polling interval in microseconds (1 second).-pollIntervalMicros :: Int-pollIntervalMicros = 1_000_000+    liftIO $ do+      chan <- newBroadcastTChanIO+      var <- newMVar Nothing+      _ <- forkOS $ monitorWirePlumberInfo nodeSpec chan var+      pure $ WirePlumberInfoChanVar (chan, var) -refreshWirePlumberInfo ::+monitorWirePlumberInfo ::   String ->   TChan (Maybe WirePlumberInfo) ->   MVar (Maybe WirePlumberInfo) ->   IO ()-refreshWirePlumberInfo nodeSpec chan var = do-  result <- try $ getWirePlumberInfo nodeSpec-  let info = case result of-        Left (_ :: SomeException) -> Nothing-        Right i -> i-  _ <- swapMVar var info-  atomically $ writeTChan chan info+monitorWirePlumberInfo nodeSpec chan var = do+  result <- try $ do+    context <- MainContext.mainContextNew+    MainContext.mainContextPushThreadDefault (Just context)+    ( do+        initWirePlumber+        loop <- MainLoop.mainLoopNew (Just context) False+        core <- Core.coreNew (Just context) (Nothing :: Maybe Conf.Conf) Nothing+        manager <- buildObjectManager nodeSpec+        refreshLock <- newMVar ()+        connectedSignalRefs <- newIORef []+        connectedObjectRefs <- newIORef [] --- | Query volume and mute state for the provided node spec.------ The node spec can be:--- * "@DEFAULT_AUDIO_SINK@" or "" for the default output--- * "@DEFAULT_AUDIO_SOURCE@" for the default input--- * A numeric node ID------ Returns 'Nothing' if wpctl is not available or fails.+        let writeInfo info = do+              oldInfo <- swapMVar var info+              when (oldInfo /= info) $+                atomically $+                  writeTChan chan info++            refresh = withMVar refreshLock $ \_ ->+              readWirePlumberInfoFromRefs connectedObjectRefs nodeSpec >>= writeInfo++            rememberSignal ref = modifyIORef' connectedSignalRefs (ref :)++            rememberObject object maybeNode maybeMetadata =+              modifyIORef'+                connectedObjectRefs+                (WirePlumberObjectRefs object maybeNode maybeMetadata :)++            connectNode object = do+              maybeNode <- ManagedPtr.castTo Node.Node object+              forM_ maybeNode $ \node -> do+                mediaClass <- PipewireObject.pipewireObjectGetProperty node "media.class"+                when (mediaClass `elem` [Just "Audio/Sink", Just "Audio/Source"]) $ do+                  handler <-+                    PipewireObject.onPipewireObjectParamsChanged node $ \param ->+                      when (param == "Props") refresh+                  rememberSignal handler+              pure maybeNode++            connectMetadata object = do+              maybeMetadata <- ManagedPtr.castTo Metadata.Metadata object+              forM_ maybeMetadata $ \metadata -> do+                handler <-+                  Metadata.onMetadataChanged metadata $ \subject key _type _value ->+                    when (subject == 0 && key `elem` defaultMetadataKeys) refresh+                rememberSignal handler+              pure maybeMetadata++            connectObject object = do+              maybeNode <- connectNode object+              maybeMetadata <- connectMetadata object+              rememberObject object maybeNode maybeMetadata++        _ <- ObjectManager.onObjectManagerInstalled manager $ do+          objects <- getManagedObjects manager+          mapM_ connectObject objects+          refresh++        _ <- ObjectManager.onObjectManagerObjectAdded manager $ \object -> do+          connectObject object+          refresh++        _ <- ObjectManager.onObjectManagerObjectRemoved manager $ const refresh+        _ <- ObjectManager.onObjectManagerObjectsChanged manager refresh++        Core.coreInstallObjectManager core manager+        connected <- Core.coreConnect core+        unless connected $ fail "failed to connect WirePlumber core"+        MainLoop.mainLoopRun loop+        ManagedPtr.touchManagedPtr core+        ManagedPtr.touchManagedPtr manager+        ManagedPtr.touchManagedPtr loop+      )+      `finally` MainContext.mainContextPopThreadDefault (Just context)+  case result of+    Right () -> pure ()+    Left (e :: SomeException) -> do+      wpLogF WARNING "WirePlumber monitor failed: %s" e+      _ <- swapMVar var Nothing+      atomically $ writeTChan chan Nothing++buildObjectManager :: String -> IO ObjectManager.ObjectManager+buildObjectManager nodeSpec = do+  manager <- ObjectManager.objectManagerNew+  nodeType <- GI.glibType @Node.Node+  metadataType <- GI.glibType @Metadata.Metadata++  nodeInterest <- ObjectInterest.objectInterestNewType nodeType+  forM_ (selectorMediaClass $ parseNodeSpec nodeSpec) $ \mediaClass -> do+    mediaClassVariant <- GVariant.gvariantFromText mediaClass+    ObjectInterest.objectInterestAddConstraint+      nodeInterest+      Wp.ConstraintTypePwProperty+      "media.class"+      Wp.ConstraintVerbEquals+      (Just mediaClassVariant)+  ObjectManager.objectManagerAddInterestFull manager nodeInterest+  ObjectManager.objectManagerRequestObjectFeatures+    manager+    nodeType+    (fromIntegral $ fromEnum Wp.ProxyFeaturesPipewireObjectFeaturesAll)++  metadataInterest <- ObjectInterest.objectInterestNewType metadataType+  ObjectManager.objectManagerAddInterestFull manager metadataInterest+  ObjectManager.objectManagerRequestObjectFeatures+    manager+    metadataType+    (fromIntegral $ fromEnum Wp.ProxyFeaturesPipewireObjectFeaturesMinimal)++  pure manager++-- | Query volume and mute state for the provided node spec once. getWirePlumberInfo :: String -> IO (Maybe WirePlumberInfo)-getWirePlumberInfo nodeSpec = do-  let node = if null nodeSpec then "@DEFAULT_AUDIO_SINK@" else nodeSpec-  result <- runCommand "wpctl" ["get-volume", node]+getWirePlumberInfo nodeSpec = runInBoundThread $ do+  result <- try $ do+    context <- MainContext.mainContextNew+    MainContext.mainContextPushThreadDefault (Just context)+    ( do+        initWirePlumber+        loop <- MainLoop.mainLoopNew (Just context) False+        core <- Core.coreNew (Just context) (Nothing :: Maybe Conf.Conf) Nothing+        manager <- buildObjectManager nodeSpec+        var <- newMVar Nothing++        _ <- ObjectManager.onObjectManagerInstalled manager $ do+          objects <- getManagedObjects manager+          refs <- mapM buildWirePlumberObjectRefs objects+          info <- readWirePlumberInfoFromObjectRefs refs nodeSpec+          _ <- swapMVar var info+          MainLoop.mainLoopQuit loop++        _ <- Core.coreTimeoutAdd core 5000 $ do+          MainLoop.mainLoopQuit loop+          pure False++        Core.coreInstallObjectManager core manager+        connected <- Core.coreConnect core+        unless connected $ fail "failed to connect WirePlumber core"+        MainLoop.mainLoopRun loop+        info <- readMVar var+        ManagedPtr.touchManagedPtr core+        ManagedPtr.touchManagedPtr manager+        ManagedPtr.touchManagedPtr loop+        pure info+      )+      `finally` MainContext.mainContextPopThreadDefault (Just context)   case result of-    Left err -> do-      wpLogF WARNING "wpctl get-volume failed: %s" err-      return Nothing-    Right output -> return $ parseWpctlOutput node output+    Right info -> pure info+    Left (e :: SomeException) -> do+      wpLogF WARNING "WirePlumber query failed: %s" e+      pure Nothing --- | Parse the output of @wpctl get-volume@.------ Expected formats:--- * "Volume: 0.50"--- * "Volume: 0.50 [MUTED]"-parseWpctlOutput :: String -> String -> Maybe WirePlumberInfo-parseWpctlOutput nodeSpec output = do-  let trimmed = dropWhile (== ' ') output-  -- Check if it starts with "Volume:"-  volumeStr <--    if "Volume:" `isInfixOf` trimmed-      then Just $ drop 7 $ dropWhile (/= ':') trimmed-      else Nothing-  -- Parse the volume value and mute state-  let isMuted = "[MUTED]" `isInfixOf` volumeStr-      cleanVolStr = takeWhile (\c -> c /= '[' && c /= '\n') $ dropWhile (== ' ') volumeStr-  volume <- readMaybe cleanVolStr-  return+readWirePlumberInfoFromRefs ::+  IORef [WirePlumberObjectRefs] ->+  String ->+  IO (Maybe WirePlumberInfo)+readWirePlumberInfoFromRefs refs nodeSpec =+  readIORef refs >>= flip readWirePlumberInfoFromObjectRefs nodeSpec++readWirePlumberInfoFromObjectRefs ::+  [WirePlumberObjectRefs] ->+  String ->+  IO (Maybe WirePlumberInfo)+readWirePlumberInfoFromObjectRefs refs nodeSpec =+  ( do+      let metadata = mapMaybe wirePlumberMetadataRef refs+          nodes = mapMaybe wirePlumberNodeRef refs+      selectedNode <- selectNode metadata nodes (parseNodeSpec nodeSpec)+      maybe (pure Nothing) (fmap Just . readNodeInfo) selectedNode+  )+    `finally` mapM_ touchWirePlumberObjectRefs refs++touchWirePlumberObjectRefs :: WirePlumberObjectRefs -> IO ()+touchWirePlumberObjectRefs refs = do+  ManagedPtr.touchManagedPtr $ wirePlumberObjectRef refs+  mapM_ ManagedPtr.touchManagedPtr $ wirePlumberNodeRef refs+  mapM_ ManagedPtr.touchManagedPtr $ wirePlumberMetadataRef refs++buildWirePlumberObjectRefs :: GObject.Object -> IO WirePlumberObjectRefs+buildWirePlumberObjectRefs object =+  WirePlumberObjectRefs object+    <$> objectToNode object+    <*> objectToMetadata object++getManagedObjects :: ObjectManager.ObjectManager -> IO [GObject.Object]+getManagedObjects manager = do+  iterator <- ObjectManager.objectManagerNewIterator manager+  catMaybes <$> iteratorValues @(Maybe GObject.Object) iterator++objectToNode :: GObject.Object -> IO (Maybe Node.Node)+objectToNode = ManagedPtr.castTo Node.Node++objectToMetadata :: GObject.Object -> IO (Maybe Metadata.Metadata)+objectToMetadata = ManagedPtr.castTo Metadata.Metadata++selectNode ::+  [Metadata.Metadata] ->+  [Node.Node] ->+  WirePlumberSelector ->+  IO (Maybe Node.Node)+selectNode metadata nodes selector =+  case selector of+    DefaultNode nodeType -> do+      maybeDefaultName <- readDefaultNodeName metadata nodeType+      case maybeDefaultName of+        Just defaultName -> findM (nodeNameMatches defaultName) =<< filterNodesByType nodeType nodes+        Nothing -> listToMaybe <$> filterNodesByType nodeType nodes+    NodeName name -> findM (nodeNameMatches name) nodes+    NodeId nodeId -> findM (nodeIdMatches nodeId) nodes++filterNodesByType :: NodeType -> [Node.Node] -> IO [Node.Node]+filterNodesByType nodeType =+  filterMIO $ \node ->+    (== Just (nodeTypeMediaClass nodeType))+      <$> PipewireObject.pipewireObjectGetProperty node "media.class"++nodeNameMatches :: Text -> Node.Node -> IO Bool+nodeNameMatches expected node =+  (== Just expected) <$> PipewireObject.pipewireObjectGetProperty node "node.name"++nodeIdMatches :: Text -> Node.Node -> IO Bool+nodeIdMatches expected node =+  (== Just expected) <$> PipewireObject.pipewireObjectGetProperty node "object.id"++readDefaultNodeName :: [Metadata.Metadata] -> NodeType -> IO (Maybe Text)+readDefaultNodeName metadata nodeType = do+  names <-+    forM metadata $ \m -> do+      result <- metadataFindNoFree m 0 (defaultMetadataKey nodeType)+      pure $ result >>= parseDefaultNodeName . fst+  pure $ listToMaybe $ catMaybes names++readNodeInfo :: Node.Node -> IO WirePlumberInfo+readNodeInfo node = do+  nodeName <- fromMaybe "" <$> PipewireObject.pipewireObjectGetProperty node "node.name"+  props <- readNodeProps node+  maybeRawVolume <-+    maybe (pure Nothing) podArrayChildNumber $+      lookup "channelVolumes" props+  maybeMuted <-+    maybe (pure Nothing) podBool $+      lookup "mute" props+  let volume = pipeWireRawVolumeToLinear $ fromMaybe 0 maybeRawVolume+      muted = fromMaybe False maybeMuted+  pure $     WirePlumberInfo       { wirePlumberVolume = volume,-        wirePlumberMuted = isMuted,-        wirePlumberNodeName = T.pack nodeSpec+        wirePlumberMuted = muted,+        wirePlumberNodeName = nodeName       }++readNodeProps :: Node.Node -> IO [(Text, SpaPod.SpaPod)]+readNodeProps node = do+  maybeParams <- PipewireObject.pipewireObjectEnumParamsSync node "Props" Nothing+  case maybeParams of+    Nothing -> pure []+    Just params -> do+      pods <- catMaybes <$> iteratorValues @(Maybe SpaPod.SpaPod) params+      fmap concat $+        forM pods $ \pod -> do+          iterator <- SpaPod.spaPodNewIterator pod+          props <- catMaybes <$> iteratorValues @(Maybe SpaPod.SpaPod) iterator+          fmap catMaybes $+            forM props $ \propPod -> do+              (ok, key, value) <- spaPodGetPropertyNoFree propPod+              pure $ if ok then Just (key, value) else Nothing++spaPodGetPropertyNoFree :: SpaPod.SpaPod -> IO (Bool, Text, SpaPod.SpaPod)+spaPodGetPropertyNoFree pod =+  ManagedPtr.withManagedPtr pod $ \podPtr ->+    alloca $ \keyPtr ->+      alloca $ \valuePtr -> do+        result <- c_wp_spa_pod_get_property podPtr keyPtr valuePtr+        keyPtrValue <- peek keyPtr+        valuePtrValue <- peek valuePtr+        key <-+          if keyPtrValue == nullPtr+            then pure ""+            else T.pack <$> peekCString keyPtrValue+        value <- ManagedPtr.wrapBoxed SpaPod.SpaPod valuePtrValue+        pure (result /= 0, key, value)++metadataFindNoFree :: Metadata.Metadata -> Word32 -> Text -> IO (Maybe (Text, Text))+metadataFindNoFree metadata subject key =+  ManagedPtr.withManagedPtr metadata $ \metadataPtr ->+    B.useAsCString (T.encodeUtf8 key) $ \keyPtr ->+      alloca $ \typePtr -> do+        valuePtr <- c_wp_metadata_find metadataPtr subject keyPtr typePtr+        if valuePtr == nullPtr+          then pure Nothing+          else do+            typePtrValue <- peek typePtr+            value <- T.pack <$> peekCString valuePtr+            type_ <-+              if typePtrValue == nullPtr+                then pure ""+                else T.pack <$> peekCString typePtrValue+            pure $ Just (value, type_)++podArrayChildNumber :: SpaPod.SpaPod -> IO (Maybe Double)+podArrayChildNumber pod =+  do+    isArray <- SpaPod.spaPodIsArray pod+    if isArray+      then SpaPod.spaPodGetArrayChild pod >>= podNumber+      else podNumber pod++podNumber :: SpaPod.SpaPod -> IO (Maybe Double)+podNumber pod = do+  isFloat <- SpaPod.spaPodIsFloat pod+  isDouble <- SpaPod.spaPodIsDouble pod+  if isFloat+    then do+      (ok, value) <- SpaPod.spaPodGetFloat pod+      pure $ if ok then Just (realToFrac value) else Nothing+    else+      if isDouble+        then do+          (ok, value) <- SpaPod.spaPodGetDouble pod+          pure $ if ok then Just value else Nothing+        else pure Nothing++podBool :: SpaPod.SpaPod -> IO (Maybe Bool)+podBool pod = do+  isBool <- SpaPod.spaPodIsBoolean pod+  if isBool+    then do+      (ok, value) <- SpaPod.spaPodGetBoolean pod+      pure $ if ok then Just value else Nothing+    else pure Nothing++parseDefaultNodeName :: Text -> Maybe Text+parseDefaultNodeName value =+  decodeStrict (T.encodeUtf8 value) >>= parseMaybe parseName+  where+    parseName = withObject "WirePlumber default audio target" (.: "name")++pipeWireRawVolumeToLinear :: Double -> Double+pipeWireRawVolumeToLinear rawVolume = rawVolume ** (1 / 3 :: Double)++parseNodeSpec :: String -> WirePlumberSelector+parseNodeSpec "" = DefaultNode Sink+parseNodeSpec "@DEFAULT_AUDIO_SINK@" = DefaultNode Sink+parseNodeSpec "@DEFAULT_SINK@" = DefaultNode Sink+parseNodeSpec "@DEFAULT_AUDIO_SOURCE@" = DefaultNode Source+parseNodeSpec "@DEFAULT_SOURCE@" = DefaultNode Source+parseNodeSpec spec+  | all isDigit spec = NodeId $ T.pack spec+  | otherwise = NodeName $ T.pack spec++selectorMediaClass :: WirePlumberSelector -> Maybe Text+selectorMediaClass (DefaultNode nodeType) = Just $ nodeTypeMediaClass nodeType+selectorMediaClass _ = Nothing++nodeTypeMediaClass :: NodeType -> Text+nodeTypeMediaClass Sink = "Audio/Sink"+nodeTypeMediaClass Source = "Audio/Source"++defaultMetadataKey :: NodeType -> Text+defaultMetadataKey Sink = "default.audio.sink"+defaultMetadataKey Source = "default.audio.source"++defaultMetadataKeys :: [Text]+defaultMetadataKeys =+  [ "default.audio.sink",+    "default.audio.source",+    "default.configured.audio.sink",+    "default.configured.audio.source"+  ]++findM :: (a -> IO Bool) -> [a] -> IO (Maybe a)+findM _ [] = pure Nothing+findM predicate (x : xs) = do+  matches <- predicate x+  if matches then pure $ Just x else findM predicate xs++filterMIO :: (a -> IO Bool) -> [a] -> IO [a]+filterMIO predicate = fmap (map fst . filter snd) . mapM (\x -> (x,) <$> predicate x)++iteratorValues :: (GValue.IsGValue a) => Iterator.Iterator -> IO [a]+iteratorValues iterator = do+  (hasNext, value) <- Iterator.iteratorNext iterator+  if hasNext+    then do+      item <- GValue.fromGValue value+      rest <- iteratorValues iterator+      pure (item : rest)+    else pure []  -- | Toggle mute for the provided node spec. Returns True on success. toggleWirePlumberMute :: String -> IO Bool
src/System/Taffybar/Information/Workspaces/EWMH.hs view
@@ -69,6 +69,7 @@   ) import System.Taffybar.Information.SafeX11 (safeGetGeometry) import System.Taffybar.Information.Workspaces.Model+import System.Taffybar.Information.Workspaces.Refresh (workspaceRefreshRequestLoop) import System.Taffybar.Information.X11DesktopInfo (X11Property, X11Window, getDisplay) import qualified System.Taffybar.Information.X11DesktopInfo as X11 @@ -168,6 +169,7 @@   eventChan <- liftIO newBroadcastTChanIO   stateVar <- liftIO $ newMVar defaultEWMHWorkspaceState   taffyFork $ ewmhWorkspaceStateLoop cfg stateChan eventChan stateVar+  taffyFork $ workspaceRefreshRequestLoop stateChan eventChan stateVar   return $ EWMHWorkspaceStateChanVar (stateChan, eventChan, stateVar)  ewmhWorkspaceStateLoop ::@@ -223,12 +225,13 @@         return (False, snapshotWorkspaces previous)   let nextRevision = snapshotRevision previous + 1       next =-        WorkspaceSnapshot-          { snapshotBackend = WorkspaceBackendEWMH,-            snapshotRevision = nextRevision,-            snapshotWindowDataComplete = complete,-            snapshotWorkspaces = workspaces-          }+        preserveWorkspaceUpdateRevisions previous $+          WorkspaceSnapshot+            { snapshotBackend = WorkspaceBackendEWMH,+              snapshotRevision = nextRevision,+              snapshotWindowDataComplete = complete,+              snapshotWorkspaces = workspaces+            }       eventBatch =         WorkspaceEventBatch           { eventBatchBackend = WorkspaceBackendEWMH,@@ -277,6 +280,7 @@                   { workspaceNumericId = Just idx,                     workspaceName = T.pack name                   },+              workspaceUpdateRevision = 0,               workspaceState = wsViewState,               workspaceHasUrgentWindow = any windowUrgent windowsInfo,               workspaceIsSpecial = isSpecialWorkspaceName name,@@ -310,12 +314,14 @@   return     WindowInfo       { windowIdentity = X11WindowIdentity (fromIntegral window),+        windowUpdateRevision = 0,         windowTitle = T.pack wTitle,         windowClassHints = map T.pack $ parseWindowClasses wClass,         windowPosition = wPosition,         windowUrgent = window `elem` urgentWindows,         windowActive = Just window == activeWindow,-        windowMinimized = wMinimized+        windowMinimized = wMinimized,+        windowPinned = False       }  getWindowPosition :: X11Window -> X11Property (Maybe (Int, Int))
src/System/Taffybar/Information/Workspaces/Hyprland.hs view
@@ -16,7 +16,12 @@ -- Shared Hyprland workspace-state provider using a broadcast channel + state MVar. module System.Taffybar.Information.Workspaces.Hyprland   ( HyprlandWorkspaceProviderConfig (..),+    HyprlandSpecialWorkspaceWindowTarget (..),     defaultHyprlandWorkspaceProviderConfig,+    applySpecialWorkspaceWindowTargets,+    specialWorkspaceWindowsToNamed,+    specialWorkspaceWindowsToMinimized,+    sortWorkspaceInfos,     defaultHyprlandWorkspaceState,     isRelevantHyprlandWorkspaceEvent,     getHyprlandWorkspaceStateAndEventChansAndVar,@@ -33,15 +38,16 @@ where  import Control.Applicative ((<|>))+import Control.Concurrent (threadDelay) import Control.Concurrent.MVar-import Control.Concurrent.STM.TChan+import Control.Concurrent.STM.TChan (TChan, newBroadcastTChanIO, readTChan, tryReadTChan, writeTChan) 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 Data.List (nub, sortOn) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, listToMaybe) import qualified Data.Text as T@@ -52,17 +58,27 @@ import qualified System.Taffybar.Information.Hyprland.API as HyprAPI import qualified System.Taffybar.Information.Hyprland.Types as HyprTypes import System.Taffybar.Information.Workspaces.Model+import System.Taffybar.Information.Workspaces.Refresh (workspaceRefreshRequestLoop)  data HyprlandWorkspaceProviderConfig = HyprlandWorkspaceProviderConfig   { workspaceSnapshotGetter :: TaffyIO (Bool, [WorkspaceInfo]),-    workspaceEventFilter :: T.Text -> Bool+    workspaceEventFilter :: T.Text -> Bool,+    specialWorkspaceWindowTarget ::+      WorkspaceInfo ->+      Maybe HyprlandSpecialWorkspaceWindowTarget   } +data HyprlandSpecialWorkspaceWindowTarget = HyprlandSpecialWorkspaceWindowTarget+  { targetWorkspaceName :: T.Text,+    targetWindowModifier :: WindowInfo -> WindowInfo+  }+ defaultHyprlandWorkspaceProviderConfig :: HyprlandWorkspaceProviderConfig defaultHyprlandWorkspaceProviderConfig =   HyprlandWorkspaceProviderConfig     { workspaceSnapshotGetter = buildHyprlandWorkspaceSnapshot,-      workspaceEventFilter = isRelevantHyprlandWorkspaceEvent+      workspaceEventFilter = isRelevantHyprlandWorkspaceEvent,+      specialWorkspaceWindowTarget = const Nothing     }  defaultHyprlandWorkspaceState :: WorkspaceSnapshot@@ -97,6 +113,7 @@                  "movewindow",                  "movewindowv2",                  "moveworkspace",+                 "pin",                  "renameworkspace",                  "createworkspace",                  "destroyworkspace",@@ -174,6 +191,7 @@   eventChan <- liftIO newBroadcastTChanIO   var <- liftIO $ newMVar defaultHyprlandWorkspaceState   taffyFork $ hyprlandWorkspaceStateLoop cfg stateChan eventChan var+  taffyFork $ workspaceRefreshRequestLoop stateChan eventChan var   return $ HyprlandWorkspaceStateChanVar (stateChan, eventChan, var)  hyprlandWorkspaceStateLoop ::@@ -186,9 +204,19 @@   refreshHyprlandWorkspaceState cfg stateChan workspaceEventChan var   hyprlandEventChan <- getHyprlandEventChan   events <- liftIO $ Hypr.subscribeHyprlandEvents hyprlandEventChan+  let collectPendingRelevantEvents = do+        maybeLine <- tryReadTChan events+        case maybeLine of+          Nothing -> return []+          Just line ->+            if workspaceEventFilter cfg line+              then (line :) <$> collectPendingRelevantEvents+              else collectPendingRelevantEvents   forever $ do     line <- liftIO $ atomically $ readTChan events-    when (workspaceEventFilter cfg line) $+    when (workspaceEventFilter cfg line) $ do+      liftIO $ threadDelay 25_000+      _ <- liftIO $ atomically collectPendingRelevantEvents       refreshHyprlandWorkspaceState cfg stateChan workspaceEventChan var  refreshHyprlandWorkspaceState ::@@ -199,18 +227,23 @@   TaffyIO () refreshHyprlandWorkspaceState cfg stateChan workspaceEventChan var = do   previous <- liftIO $ readMVar var-  (complete, workspaces) <-+  (complete, rawWorkspaces) <-     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-          }+  let workspaces =+        applySpecialWorkspaceWindowTargets+          (specialWorkspaceWindowTarget cfg)+          rawWorkspaces+      next =+        preserveWorkspaceUpdateRevisions previous $+          WorkspaceSnapshot+            { snapshotBackend = WorkspaceBackendHyprland,+              snapshotRevision = snapshotRevision previous + 1,+              snapshotWindowDataComplete = complete,+              snapshotWorkspaces = workspaces+            }       eventBatch =         WorkspaceEventBatch           { eventBatchBackend = snapshotBackend next,@@ -256,7 +289,6 @@        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,@@ -289,18 +321,134 @@                     { workspaceNumericId = Just wsId,                       workspaceName = wsName                     },+                workspaceUpdateRevision = 0,                 workspaceState = wsState,                 workspaceHasUrgentWindow = any windowUrgent wsWindows,                 workspaceIsSpecial = isSpecialWorkspace wsId wsName,                 workspaceWindows = wsWindows               }-  return (clientsOk, map toWorkspace sortedWorkspaces)+  return (clientsOk, sortWorkspaceInfos $ map toWorkspace workspaces)  isSpecialWorkspace :: Int -> T.Text -> Bool isSpecialWorkspace wsId wsName =   let lowered = T.toLower wsName    in wsId < 0 || T.isPrefixOf "special" lowered +sortWorkspaceInfos :: [WorkspaceInfo] -> [WorkspaceInfo]+sortWorkspaceInfos =+  sortOn $ \workspace ->+    let identity = workspaceIdentity workspace+     in ( workspaceIsSpecial workspace,+          fromMaybe maxBound $ workspaceNumericId identity,+          workspaceName identity+        )++workspaceInfoName :: WorkspaceInfo -> T.Text+workspaceInfoName =+  workspaceName . workspaceIdentity++workspaceInfoIsMinimized :: WorkspaceInfo -> Bool+workspaceInfoIsMinimized workspace =+  let name = T.toLower $ workspaceInfoName workspace+   in name == "minimized" || name == "special:minimized"++specialWorkspaceWindowsToNamed ::+  T.Text ->+  (WindowInfo -> WindowInfo) ->+  WorkspaceInfo ->+  Maybe HyprlandSpecialWorkspaceWindowTarget+specialWorkspaceWindowsToNamed targetName modifyWindow workspace+  | workspaceIsSpecial workspace && workspaceInfoName workspace /= targetName =+      Just $+        HyprlandSpecialWorkspaceWindowTarget+          { targetWorkspaceName = targetName,+            targetWindowModifier = modifyWindow+          }+  | otherwise = Nothing++specialWorkspaceWindowsToMinimized ::+  WorkspaceInfo ->+  Maybe HyprlandSpecialWorkspaceWindowTarget+specialWorkspaceWindowsToMinimized workspace+  | workspaceIsSpecial workspace && not (workspaceInfoIsMinimized workspace) =+      Just $+        HyprlandSpecialWorkspaceWindowTarget+          { targetWorkspaceName = "special:minimized",+            targetWindowModifier = \window -> window {windowMinimized = True}+          }+  | otherwise = Nothing++applySpecialWorkspaceWindowTargets ::+  (WorkspaceInfo -> Maybe HyprlandSpecialWorkspaceWindowTarget) ->+  [WorkspaceInfo] ->+  [WorkspaceInfo]+applySpecialWorkspaceWindowTargets targetForWorkspace workspaces =+  map addTargetWindows withMissingTargets+  where+    workspaceWasMoved workspace =+      workspaceIdentity workspace `elem` movedSourceIdentities+    sourceName workspace = workspaceName $ workspaceIdentity workspace+    moves =+      [ (workspaceIdentity workspace, targetWorkspaceName target, movedWindows)+      | workspace <- workspaces,+        Just target <- [targetForWorkspace workspace],+        targetWorkspaceName target /= sourceName workspace,+        let movedWindows = map (targetWindowModifier target) (workspaceWindows workspace),+        not (null movedWindows)+      ]+    movedSourceIdentities =+      [sourceIdentity | (sourceIdentity, _, _) <- moves]+    targetNamesInOrder =+      nub [targetName | (_, targetName, _) <- moves]+    targetWindowsByName =+      M.fromListWith+        (flip (++))+        [(targetName, movedWindows) | (_, targetName, movedWindows) <- moves]+    clearMovedSource workspace+      | workspaceWasMoved workspace =+          workspace+            { workspaceState = WorkspaceEmpty,+              workspaceHasUrgentWindow = False,+              workspaceWindows = []+            }+      | otherwise = workspace+    baseWorkspaces = map clearMovedSource workspaces+    existingNames = map sourceName baseWorkspaces+    withMissingTargets =+      baseWorkspaces+        ++ [ emptyTargetWorkspace targetName+           | targetName <- targetNamesInOrder,+             targetName `notElem` existingNames+           ]+    emptyTargetWorkspace targetName =+      WorkspaceInfo+        { workspaceIdentity =+            WorkspaceIdentity+              { workspaceNumericId = Nothing,+                workspaceName = targetName+              },+          workspaceUpdateRevision = 0,+          workspaceState = WorkspaceEmpty,+          workspaceHasUrgentWindow = False,+          workspaceIsSpecial = True,+          workspaceWindows = []+        }+    addTargetWindows workspace =+      let addedWindows = M.findWithDefault [] (sourceName workspace) targetWindowsByName+       in if null addedWindows+            then workspace+            else+              workspace+                { workspaceState =+                    if workspaceState workspace == WorkspaceEmpty+                      then WorkspaceHidden+                      else workspaceState workspace,+                  workspaceHasUrgentWindow =+                    workspaceHasUrgentWindow workspace+                      || any windowUrgent addedWindows,+                  workspaceWindows = workspaceWindows workspace ++ addedWindows+                }+ collectWorkspaceWindows ::   Maybe T.Text ->   [HyprTypes.HyprlandClientInfo] ->@@ -313,6 +461,16 @@           windowData = windowFromClient activeAddr client        in M.insertWith (++) wsId [windowData] windowsMap +clientIsOnMinimizedWorkspace :: HyprTypes.HyprlandClientInfo -> Bool+clientIsOnMinimizedWorkspace =+  workspaceRefIsMinimized . HyprTypes.hyprClientWorkspace++workspaceRefIsMinimized :: HyprTypes.HyprlandWorkspaceRef -> Bool+workspaceRefIsMinimized workspace =+  let name = T.toLower $ HyprTypes.hyprWorkspaceRefName workspace+   in name == "minimized"+        || name == "special:minimized"+ windowFromClient :: Maybe T.Text -> HyprTypes.HyprlandClientInfo -> WindowInfo windowFromClient activeAddr client =   let rawTitle = HyprTypes.hyprClientTitle client@@ -326,8 +484,10 @@       minimized =         HyprTypes.hyprClientHidden client           || not (HyprTypes.hyprClientMapped client)+          || clientIsOnMinimizedWorkspace client    in WindowInfo         { windowIdentity = HyprlandWindowIdentity (HyprTypes.hyprClientAddress client),+          windowUpdateRevision = 0,           windowTitle = title,           windowClassHints =             catMaybes@@ -337,5 +497,6 @@           windowPosition = HyprTypes.hyprClientAt client,           windowUrgent = HyprTypes.hyprClientUrgent client,           windowActive = active,-          windowMinimized = minimized+          windowMinimized = minimized,+          windowPinned = HyprTypes.hyprClientPinned client         }
src/System/Taffybar/Information/Workspaces/Model.hs view
@@ -24,6 +24,9 @@     WorkspaceInfo (..),     WindowIdentity (..),     WindowInfo (..),+    WorkspacesRefreshTarget (..),+    bumpWorkspaceRefreshTarget,+    preserveWorkspaceUpdateRevisions,   ) where @@ -90,6 +93,7 @@ -- | Backend-neutral workspace information. data WorkspaceInfo = WorkspaceInfo   { workspaceIdentity :: WorkspaceIdentity,+    workspaceUpdateRevision :: Word64,     workspaceState :: WorkspaceViewState,     workspaceHasUrgentWindow :: Bool,     workspaceIsSpecial :: Bool,@@ -105,6 +109,7 @@ -- | Backend-neutral window information. data WindowInfo = WindowInfo   { windowIdentity :: WindowIdentity,+    windowUpdateRevision :: Word64,     windowTitle :: Text,     -- | Ordered class/app-id hints used for icon lookup.     windowClassHints :: [Text],@@ -112,9 +117,73 @@     windowPosition :: Maybe (Int, Int),     windowUrgent :: Bool,     windowActive :: Bool,-    windowMinimized :: Bool+    windowMinimized :: Bool,+    windowPinned :: Bool   }   deriving (Eq, Show)++data WorkspacesRefreshTarget+  = RefreshAllWorkspaces+  | RefreshWorkspace WorkspaceIdentity+  | RefreshWindow WindowIdentity+  deriving (Eq, Show)++bumpWorkspaceRefreshTarget :: WorkspacesRefreshTarget -> WorkspaceSnapshot -> WorkspaceSnapshot+bumpWorkspaceRefreshTarget target snapshot =+  snapshot {snapshotWorkspaces = map bumpWorkspaceForTarget (snapshotWorkspaces snapshot)}+  where+    bumpWorkspaceRevision workspace =+      workspace {workspaceUpdateRevision = workspaceUpdateRevision workspace + 1}+    bumpWindowRevision window =+      window {windowUpdateRevision = windowUpdateRevision window + 1}+    bumpWorkspaceAndWindows workspace =+      bumpWorkspaceRevision+        workspace+          { workspaceWindows = map bumpWindowRevision (workspaceWindows workspace)+          }+    bumpWindowForTarget targetWindow window+      | windowIdentity window == targetWindow = bumpWindowRevision window+      | otherwise = window+    bumpWorkspaceForTarget workspace =+      case target of+        RefreshAllWorkspaces -> bumpWorkspaceAndWindows workspace+        RefreshWorkspace targetWorkspace+          | workspaceIdentity workspace == targetWorkspace -> bumpWorkspaceAndWindows workspace+          | otherwise -> workspace+        RefreshWindow targetWindow ->+          workspace {workspaceWindows = map (bumpWindowForTarget targetWindow) (workspaceWindows workspace)}++preserveWorkspaceUpdateRevisions :: WorkspaceSnapshot -> WorkspaceSnapshot -> WorkspaceSnapshot+preserveWorkspaceUpdateRevisions previous next =+  next {snapshotWorkspaces = map preserveWorkspaceRevision (snapshotWorkspaces next)}+  where+    previousWorkspaces =+      M.fromList+        [ (workspaceIdentity workspace, workspace)+        | workspace <- snapshotWorkspaces previous+        ]+    previousWindows =+      M.fromList+        [ (windowIdentity window, window)+        | workspace <- snapshotWorkspaces previous,+          window <- workspaceWindows workspace+        ]+    preserveWindowRevision window =+      case M.lookup (windowIdentity window) previousWindows of+        Just previousWindow ->+          window {windowUpdateRevision = windowUpdateRevision previousWindow}+        Nothing -> window+    preserveWorkspaceRevision workspace =+      let withWindows =+            workspace+              { workspaceWindows = map preserveWindowRevision (workspaceWindows workspace)+              }+       in case M.lookup (workspaceIdentity workspace) previousWorkspaces of+            Just previousWorkspace ->+              withWindows+                { workspaceUpdateRevision = workspaceUpdateRevision previousWorkspace+                }+            Nothing -> withWindows  -- | Produce incremental events that describe the change from an old snapshot to -- a new one.
+ src/System/Taffybar/Information/Workspaces/Refresh.hs view
@@ -0,0 +1,75 @@+-- | Shared workspace refresh requests.+module System.Taffybar.Information.Workspaces.Refresh+  ( getWorkspacesRefreshRequestChan,+    requestWorkspacesRefresh,+    workspaceRefreshRequestLoop,+  )+where++import Control.Concurrent.MVar (MVar, modifyMVar_)+import Control.Concurrent.STM.TChan+  ( TChan,+    dupTChan,+    newBroadcastTChanIO,+    readTChan,+    writeTChan,+  )+import Control.Monad (forever, when)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.STM (atomically)+import System.Taffybar.Context (TaffyIO, getStateDefault)+import System.Taffybar.Information.Workspaces.Model++newtype WorkspacesRefreshRequestChan+  = WorkspacesRefreshRequestChan (TChan WorkspacesRefreshTarget)++getWorkspacesRefreshRequestChan :: TaffyIO (TChan WorkspacesRefreshTarget)+getWorkspacesRefreshRequestChan = do+  WorkspacesRefreshRequestChan chan <-+    getStateDefault $+      WorkspacesRefreshRequestChan <$> liftIO newBroadcastTChanIO+  return chan++requestWorkspacesRefresh :: WorkspacesRefreshTarget -> TaffyIO ()+requestWorkspacesRefresh target = do+  chan <- getWorkspacesRefreshRequestChan+  liftIO $ atomically $ writeTChan chan target++workspaceRefreshRequestLoop ::+  TChan WorkspaceSnapshot ->+  TChan WorkspaceEventBatch ->+  MVar WorkspaceSnapshot ->+  TaffyIO ()+workspaceRefreshRequestLoop stateChan eventChan stateVar = do+  requestChan <- getWorkspacesRefreshRequestChan+  requests <- liftIO $ atomically $ dupTChan requestChan+  forever $ do+    target <- liftIO $ atomically $ readTChan requests+    liftIO $ publishRefreshTarget stateChan eventChan stateVar target++publishRefreshTarget ::+  TChan WorkspaceSnapshot ->+  TChan WorkspaceEventBatch ->+  MVar WorkspaceSnapshot ->+  WorkspacesRefreshTarget ->+  IO ()+publishRefreshTarget stateChan eventChan stateVar target =+  modifyMVar_ stateVar $ \previous -> do+    let refreshed = bumpWorkspaceRefreshTarget target previous+        changed = snapshotWorkspaces refreshed /= snapshotWorkspaces previous+        next =+          refreshed+            { snapshotRevision = snapshotRevision previous + 1+            }+        eventBatch =+          WorkspaceEventBatch+            { eventBatchBackend = snapshotBackend next,+              eventBatchRevision = snapshotRevision next,+              eventBatchWindowDataComplete = snapshotWindowDataComplete next,+              eventBatchEvents = diffWorkspaceSnapshots previous next+            }+    when changed $+      atomically $ do+        writeTChan stateChan next+        writeTChan eventChan eventBatch+    return $ if changed then next else previous
src/System/Taffybar/Information/XDG/Protocol.hs view
@@ -37,7 +37,6 @@ import Data.Char (toLower) import Data.List import Data.Maybe-import qualified Debug.Trace as D import GHC.IO.Encoding import Safe (headMay) import System.Directory@@ -67,9 +66,20 @@       (getXdgDirectory XdgConfig "")       (getXdgDirectoryList XdgConfigDirs)   maybePrefix <- (mMenuPrefix <|>) <$> getXDGMenuPrefix-  let maybeAddDash t = if last t == '-' then t else t ++ "-"-      dashedPrefix = maybe "" maybeAddDash maybePrefix-  return $ map (</> "menus" </> dashedPrefix ++ "applications.menu") configDirs+  let maybeAddDash "" = ""+      maybeAddDash t =+        case unsnoc t of+          Just (_, '-') -> t+          _ -> t ++ "-"+      prefixes =+        case maybePrefix of+          Nothing -> [""]+          Just prefix ->+            let dashedPrefix = maybeAddDash prefix+             in if null dashedPrefix then [""] else [dashedPrefix, ""]+      menuFilename prefix dir =+        dir </> "menus" </> (prefix ++ "applications.menu")+  return [menuFilename prefix dir | prefix <- prefixes, dir <- configDirs]  -- | XDG Menu, cf. "Desktop Menu Specification". data XDGMenu = XDGMenu@@ -187,7 +197,7 @@             mapMaybe parseSingleItem $               elChildren e       "Not" -> Not <$> (parseSingleItem =<< listToMaybe (elChildren e))-      unknown -> D.trace ("Unknown Condition item: " ++ unknown) Nothing+      _ -> Nothing  -- | Combinable conditions for Include and Exclude statements. data DesktopEntryCondition@@ -209,7 +219,7 @@     parseLayoutItem e = case qName (elName e) of       "Separator" -> Just XliSeparator       "Filename" -> Just $ XliFile $ strContent e-      unknown -> D.trace ("Unknown layout item: " ++ unknown) Nothing+      _ -> Nothing  -- | Determine whether a desktop entry fulfils a condition. matchesCondition :: DesktopEntry -> DesktopEntryCondition -> Bool
src/System/Taffybar/Util.hs view
@@ -157,7 +157,7 @@ -- | Text variant of 'truncateString'. truncateText :: Int -> T.Text -> T.Text truncateText n incoming-  | T.length incoming <= n = incoming+  | T.compareLength incoming n /= GT = incoming   | otherwise = T.append (T.take n incoming) "…"  -- | Run the provided command with the provided arguments.
src/System/Taffybar/Widget.hs view
@@ -70,6 +70,9 @@   -- * "System.Taffybar.Widget.NetworkManager"   , module System.Taffybar.Widget.NetworkManager +  -- * "System.Taffybar.Widget.OmniMenu"+  , module System.Taffybar.Widget.OmniMenu+   -- * "System.Taffybar.Widget.PowerProfiles"   , module System.Taffybar.Widget.PowerProfiles @@ -128,6 +131,7 @@ import System.Taffybar.Widget.MPRIS2 import System.Taffybar.Widget.NetworkGraph import System.Taffybar.Widget.NetworkManager+import System.Taffybar.Widget.OmniMenu import System.Taffybar.Widget.PowerProfiles import System.Taffybar.Widget.SNITray import System.Taffybar.Widget.SNITray.PrioritizedCollapsible
src/System/Taffybar/Widget/AnthropicUsage.hs view
@@ -13,6 +13,7 @@     anthropicUsageLabelNewWith,     anthropicUsageFiveHourWindowLabelNew,     anthropicUsageWeeklyWindowLabelNew,+    anthropicUsageSectionNewWith,     anthropicUsageStackNew,     anthropicUsageStackNewWith,     anthropicUsageNew,@@ -37,7 +38,7 @@ import System.Taffybar.Context (TaffyIO, getStateDefault) import System.Taffybar.Information.AnthropicUsage import System.Taffybar.Util (postGUIASync)-import System.Taffybar.Widget.Util (widgetSetClassGI)+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI) import Text.Printf (printf)  data AnthropicUsageDisplayMode@@ -49,6 +50,15 @@   = AnthropicUsageDisplayModeState       (MVar AnthropicUsageDisplayMode, TChan AnthropicUsageDisplayMode) +data AnthropicUsageLabelParts = AnthropicUsageLabelParts+  { anthropicUsageLabelWidget :: Gtk.Widget,+    anthropicUsageLabel :: Gtk.Label,+    anthropicUsageLabelDisplayState :: AnthropicUsageDisplayModeState,+    anthropicUsageLabelSnapshotVar :: MVar AnthropicUsageSnapshot,+    anthropicUsageLabelUsageChan :: TChan AnthropicUsageSnapshot,+    anthropicUsageLabelRefreshNow :: IO ()+  }+ data AnthropicUsageLabelConfig = AnthropicUsageLabelConfig   { anthropicUsageLabelInfoConfig :: AnthropicUsageConfig,     anthropicUsageLabelDefaultDisplayMode :: AnthropicUsageDisplayMode,@@ -114,15 +124,38 @@  anthropicUsageStackNewWith :: AnthropicUsageStackConfig -> TaffyIO Gtk.Widget anthropicUsageStackNewWith config = do-  fiveHour <- anthropicUsageLabelNewWith $ windowLabelConfig AnthropicUsageFiveHourWindow config-  weekly <- anthropicUsageLabelNewWith $ windowLabelConfig AnthropicUsageWeeklyWindow config+  (stack, fiveHourParts) <- anthropicUsageStackPartsNewWith config+  liftIO $+    wrapAnthropicUsageMenu+      "anthropic-usage"+      stack+      (windowLabelConfig AnthropicUsageFiveHourWindow config)+      fiveHourParts++anthropicUsageSectionNewWith :: Gtk.Widget -> AnthropicUsageStackConfig -> TaffyIO Gtk.Widget+anthropicUsageSectionNewWith iconWidget config = do+  (stack, fiveHourParts) <- anthropicUsageStackPartsNewWith config   liftIO $ do+    section <- buildIconLabelBox iconWidget stack+    _ <- widgetSetClassGI section "usage-section"+    wrapAnthropicUsageMenu+      "anthropic-usage"+      section+      (windowLabelConfig AnthropicUsageFiveHourWindow config)+      fiveHourParts++anthropicUsageStackPartsNewWith :: AnthropicUsageStackConfig -> TaffyIO (Gtk.Widget, AnthropicUsageLabelParts)+anthropicUsageStackPartsNewWith config = do+  fiveHourParts <- anthropicUsageLabelPartsNewWith $ windowLabelConfig AnthropicUsageFiveHourWindow config+  weeklyParts <- anthropicUsageLabelPartsNewWith $ windowLabelConfig AnthropicUsageWeeklyWindow config+  liftIO $ do     box <- Gtk.boxNew Gtk.OrientationVertical 0     _ <- widgetSetClassGI box "anthropic-usage-stack"-    Gtk.boxPackStart box fiveHour False False 0-    Gtk.boxPackStart box weekly False False 0+    Gtk.boxPackStart box (anthropicUsageLabelWidget fiveHourParts) False False 0+    Gtk.boxPackStart box (anthropicUsageLabelWidget weeklyParts) False False 0     Gtk.widgetShowAll box-    Gtk.toWidget box+    widget <- Gtk.toWidget box+    return (widget, fiveHourParts)  windowLabelConfig :: AnthropicUsageWindowSelector -> AnthropicUsageStackConfig -> AnthropicUsageLabelConfig windowLabelConfig selector stackConfig =@@ -137,6 +170,11 @@  anthropicUsageLabelNewWith :: AnthropicUsageLabelConfig -> TaffyIO Gtk.Widget anthropicUsageLabelNewWith config = do+  parts <- anthropicUsageLabelPartsNewWith config+  liftIO $ wrapAnthropicUsageMenu "anthropic-usage" (anthropicUsageLabelWidget parts) config parts++anthropicUsageLabelPartsNewWith :: AnthropicUsageLabelConfig -> TaffyIO AnthropicUsageLabelParts+anthropicUsageLabelPartsNewWith config = do   let infoConfig = anthropicUsageLabelInfoConfig config   usageChan <- getAnthropicUsageChan infoConfig   initialSnapshot <- getAnthropicUsageState infoConfig@@ -145,29 +183,53 @@    liftIO $ do     label <- Gtk.labelNew (Just (anthropicUsageLabelFallbackText config))-    ebox <- Gtk.eventBoxNew     snapshotVar <- newMVar initialSnapshot     let refreshNow = runReaderT (forceAnthropicUsageRefresh infoConfig) ctx     _ <- widgetSetClassGI label (anthropicUsageLabelClass config)-    _ <- widgetSetClassGI ebox "anthropic-usage"-    Gtk.containerAdd ebox label     updateLabelFromState config label displayState initialSnapshot -    void $ Gtk.onWidgetRealize ebox $ do+    void $ Gtk.onWidgetRealize label $ do       usageThread <- forkUsageListener config label displayState snapshotVar usageChan       modeThread <- forkDisplayModeListener config label displayState snapshotVar-      void $ Gtk.onWidgetUnrealize ebox $ do+      void $ Gtk.onWidgetUnrealize label $ do         killThread usageThread         killThread modeThread -    void $-      Gtk.onWidgetButtonPressEvent ebox $ \_event -> do-        refreshNow-        showUsageMenu ebox config label displayState snapshotVar refreshNow-        return True+    Gtk.widgetShowAll label+    widget <- Gtk.toWidget label+    return $+      AnthropicUsageLabelParts+        { anthropicUsageLabelWidget = widget,+          anthropicUsageLabel = label,+          anthropicUsageLabelDisplayState = displayState,+          anthropicUsageLabelSnapshotVar = snapshotVar,+          anthropicUsageLabelUsageChan = usageChan,+          anthropicUsageLabelRefreshNow = refreshNow+        } -    Gtk.widgetShowAll ebox-    Gtk.toWidget ebox+wrapAnthropicUsageMenu ::+  T.Text ->+  Gtk.Widget ->+  AnthropicUsageLabelConfig ->+  AnthropicUsageLabelParts ->+  IO Gtk.Widget+wrapAnthropicUsageMenu klass child config parts = do+  ebox <- Gtk.eventBoxNew+  _ <- widgetSetClassGI ebox klass+  Gtk.containerAdd ebox child+  void $+    Gtk.onWidgetButtonPressEvent ebox $ \_event -> do+      showUsageMenu+        ebox+        config+        (anthropicUsageLabel parts)+        (anthropicUsageLabelDisplayState parts)+        (anthropicUsageLabelSnapshotVar parts)+        (anthropicUsageLabelUsageChan parts)+        (anthropicUsageLabelRefreshNow parts)+      return True+  Gtk.widgetShowAll ebox+  Gtk.toWidget ebox  getAnthropicUsageDisplayModeState :: AnthropicUsageDisplayMode -> TaffyIO AnthropicUsageDisplayModeState getAnthropicUsageDisplayModeState defaultMode =@@ -276,15 +338,50 @@   Gtk.Label ->   AnthropicUsageDisplayModeState ->   MVar AnthropicUsageSnapshot ->+  TChan AnthropicUsageSnapshot ->   IO () ->   IO ()-showUsageMenu anchor config label displayState snapshotVar refreshNow = do+showUsageMenu anchor config label displayState snapshotVar usageChan refreshNow = do   currentEvent <- Gtk.getCurrentEvent-  snapshot <- readMVar snapshotVar-  displayMode <- readDisplayMode displayState   menu <- Gtk.menuNew   Gtk.menuAttachToWidget menu anchor Nothing+  populateUsageMenu menu config label displayState snapshotVar refreshNow +  ourUsageChan <- atomically $ dupTChan usageChan+  menuThread <-+    forkIO $+      forever $ do+        snapshot <- atomically $ readTChan ourUsageChan+        void $ swapMVar snapshotVar snapshot+        postGUIASync $ populateUsageMenu menu config label displayState snapshotVar refreshNow++  void $+    Gtk.onWidgetHide menu $+      void $+        GLib.idleAdd GLib.PRIORITY_LOW $ do+          killThread menuThread+          Gtk.widgetDestroy menu+          return False++  Gtk.widgetShowAll menu+  Gtk.menuPopupAtPointer menu currentEvent+  void $ forkIO refreshNow++populateUsageMenu ::+  Gtk.Menu ->+  AnthropicUsageLabelConfig ->+  Gtk.Label ->+  AnthropicUsageDisplayModeState ->+  MVar AnthropicUsageSnapshot ->+  IO () ->+  IO ()+populateUsageMenu menu config label displayState snapshotVar refreshNow = do+  children <- Gtk.containerGetChildren menu+  forM_ children $ \item -> do+    Gtk.containerRemove menu item+    Gtk.widgetDestroy item+  snapshot <- readMVar snapshotVar+  displayMode <- readDisplayMode displayState   appendInfoItem menu "Claude Code usage"   appendSnapshotMenuItems menu displayMode snapshot @@ -298,21 +395,14 @@     Gtk.onMenuItemActivate toggleItem $ do       setDisplayMode displayState (toggleDisplayMode displayMode)       readMVar snapshotVar >>= updateLabelFromState config label displayState+      populateUsageMenu menu config label displayState snapshotVar refreshNow   Gtk.menuShellAppend menu toggleItem    refreshItem <- Gtk.menuItemNewWithLabel ("Refresh" :: T.Text)-  void $ Gtk.onMenuItemActivate refreshItem refreshNow+  void $ Gtk.onMenuItemActivate refreshItem $ void $ forkIO refreshNow   Gtk.menuShellAppend menu refreshItem -  void $-    Gtk.onWidgetHide menu $-      void $-        GLib.idleAdd GLib.PRIORITY_LOW $ do-          Gtk.widgetDestroy menu-          return False-   Gtk.widgetShowAll menu-  Gtk.menuPopupAtPointer menu currentEvent  appendInfoItem :: Gtk.Menu -> T.Text -> IO () appendInfoItem menu text = do
+ src/System/Taffybar/Widget/Audio.hs view
@@ -0,0 +1,275 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      : System.Taffybar.Widget.Audio+-- Copyright   : (c) Ivan A. Malison+-- License     : BSD3-style (see LICENSE)+--+-- Maintainer  : Ivan A. Malison+-- Stability   : unstable+-- Portability : unportable+--+-- Audio widget that automatically uses PulseAudio DBus when available and+-- falls back to WirePlumber/PipeWire through libwireplumber otherwise.+module System.Taffybar.Widget.Audio+  ( AudioWidgetConfig (..),+    defaultAudioWidgetConfig,+    audioIconNew,+    audioIconNewWith,+    audioLabelNew,+    audioLabelNewWith,+    audioNew,+    audioNewWith,+  )+where++import Control.Concurrent.MVar (MVar, readMVar)+import Control.Concurrent.STM.TChan (TChan)+import Control.Monad (void, when)+import Control.Monad.IO.Class+import Data.Char (ord)+import Data.Default (Default (..))+import qualified Data.Text as T+import qualified GI.GLib as G+import qualified GI.Gdk.Enums as Gdk+import qualified GI.Gdk.Structs.EventScroll as GdkEvent+import qualified GI.Gtk as Gtk+import System.Taffybar.Context (TaffyIO)+import System.Taffybar.Information.Audio+import System.Taffybar.Util (postGUIASync)+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI)+import Text.StringTemplate++data AudioWidgetConfig = AudioWidgetConfig+  { audioPulseSink :: String,+    audioWirePlumberNode :: String,+    audioFormat :: String,+    audioMuteFormat :: String,+    audioUnknownFormat :: String,+    audioTooltipFormat :: Maybe String,+    audioScrollStepPercent :: Maybe Int,+    audioToggleMuteOnClick :: Bool+  }++defaultAudioWidgetConfig :: AudioWidgetConfig+defaultAudioWidgetConfig =+  AudioWidgetConfig+    { audioPulseSink = defaultAudioPulseSink,+      audioWirePlumberNode = defaultAudioWirePlumberNode,+      audioFormat = "$volume$%",+      audioMuteFormat = "muted",+      audioUnknownFormat = "n/a",+      audioTooltipFormat =+        Just "Backend: $backend$\nNode: $node$\nVolume: $volume$%\nMuted: $muted$",+      audioScrollStepPercent = Just 5,+      audioToggleMuteOnClick = True+    }++instance Default AudioWidgetConfig where+  def = defaultAudioWidgetConfig++audioIconNew :: TaffyIO Gtk.Widget+audioIconNew = audioIconNewWith defaultAudioWidgetConfig++audioIconNewWith :: AudioWidgetConfig -> TaffyIO Gtk.Widget+audioIconNewWith config = do+  let pulseSink = audioPulseSink config+      wirePlumberNode = audioWirePlumberNode config+  (chan, var) <- getAudioInfoChanAndVar pulseSink wirePlumberNode+  audioIconNewWithState chan var++audioIconNewWithState ::+  TChan (Maybe AudioInfo) ->+  MVar (Maybe AudioInfo) ->+  TaffyIO Gtk.Widget+audioIconNewWithState chan var =+  liftIO $ do+    label <- Gtk.labelNew Nothing+    _ <- widgetSetClassGI label "audio-icon"+    let updateIcon info = do+          let iconText = case info of+                Nothing -> T.pack "\xF026"+                Just i -> audioTextIcon (audioMuted i) (audioVolumePercent i)+          markup <- escapeIconText iconText+          postGUIASync $ Gtk.labelSetMarkup label $ T.pack markup+    void $ Gtk.onWidgetRealize label $ readMVar var >>= updateIcon+    Gtk.widgetShowAll label+    (Gtk.toWidget =<< channelWidgetNew label chan updateIcon)+      >>= (`widgetSetClassGI` "audio-icon")++audioLabelNew :: TaffyIO Gtk.Widget+audioLabelNew = audioLabelNewWith defaultAudioWidgetConfig++audioLabelNewWith :: AudioWidgetConfig -> TaffyIO Gtk.Widget+audioLabelNewWith config = do+  let pulseSink = audioPulseSink config+      wirePlumberNode = audioWirePlumberNode config+  (chan, var) <- getAudioInfoChanAndVar pulseSink wirePlumberNode+  audioLabelNewWithState config chan var++audioLabelNewWithState ::+  AudioWidgetConfig ->+  TChan (Maybe AudioInfo) ->+  MVar (Maybe AudioInfo) ->+  TaffyIO Gtk.Widget+audioLabelNewWithState config chan var = do+  let pulseSink = audioPulseSink config+      wirePlumberNode = audioWirePlumberNode config++  liftIO $ do+    label <- Gtk.labelNew Nothing+    _ <- widgetSetClassGI label "audio-label"++    let updateLabel info = do+          (labelText, tooltipText) <- formatAudioWidget config info+          postGUIASync $ do+            Gtk.labelSetMarkup label labelText+            Gtk.widgetSetTooltipMarkup label tooltipText++        refreshNow = getAudioInfo pulseSink wirePlumberNode >>= updateLabel++        whenToggleMute widget =+          when (audioToggleMuteOnClick config) $+            void $+              Gtk.onWidgetButtonPressEvent widget $ \_ -> do+                void $ toggleAudioMute pulseSink wirePlumberNode+                refreshNow+                return True++        whenScrollAdjust widget =+          case audioScrollStepPercent config of+            Nothing -> return ()+            Just step | step <= 0 -> return ()+            Just step -> do+              _ <- Gtk.onWidgetScrollEvent widget $ \scrollEvent -> do+                dir <- GdkEvent.getEventScrollDirection scrollEvent+                let doAdjust delta = do+                      void $ adjustAudioVolume pulseSink wirePlumberNode delta+                      refreshNow+                      return True+                case dir of+                  Gdk.ScrollDirectionUp -> doAdjust step+                  Gdk.ScrollDirectionDown -> doAdjust (-step)+                  Gdk.ScrollDirectionLeft -> doAdjust step+                  Gdk.ScrollDirectionRight -> doAdjust (-step)+                  _ -> return False+              return ()++    void $ Gtk.onWidgetRealize label $ readMVar var >>= updateLabel++    whenToggleMute label+    whenScrollAdjust label++    Gtk.widgetShowAll label+    (Gtk.toWidget =<< channelWidgetNew label chan updateLabel)+      >>= (`widgetSetClassGI` "audio-label")++audioNew :: TaffyIO Gtk.Widget+audioNew = audioNewWith defaultAudioWidgetConfig++audioNewWith :: AudioWidgetConfig -> TaffyIO Gtk.Widget+audioNewWith config = do+  let pulseSink = audioPulseSink config+      wirePlumberNode = audioWirePlumberNode config+  (chan, var) <- getAudioInfoChanAndVar pulseSink wirePlumberNode+  iconWidget <- audioIconNewWithState chan var+  labelWidget <- audioLabelNewWithState config chan var+  liftIO $+    buildIconLabelBox iconWidget labelWidget+      >>= (`widgetSetClassGI` "audio")++formatAudioWidget ::+  AudioWidgetConfig ->+  Maybe AudioInfo ->+  IO (T.Text, Maybe T.Text)+formatAudioWidget config info = do+  attrs <- maybe buildUnknownAttrs buildAttrs info+  let labelTemplate =+        case info of+          Nothing -> audioUnknownFormat config+          Just audio ->+            case audioMuted audio of+              Just True -> audioMuteFormat config+              _ -> audioFormat config+      labelText = renderTemplate labelTemplate attrs+      tooltipText = fmap (`renderTemplate` attrs) (audioTooltipFormat config)+  return (T.pack labelText, T.pack <$> tooltipText)++buildAttrs :: AudioInfo -> IO [(String, String)]+buildAttrs info = do+  let volumeText = maybe "?" show (audioVolumePercent info)+      mutedText = case audioMuted info of+        Just True -> "yes"+        Just False -> "no"+        Nothing -> "unknown"+      nodeText = audioNodeName info+      backendText = case audioBackend info of+        PulseAudioBackend -> "pulseaudio"+        WirePlumberBackend -> "wireplumber"+      iconText = audioTextIcon (audioMuted info) (audioVolumePercent info)+  volume <- escapeText $ T.pack volumeText+  muted <- escapeText $ T.pack mutedText+  node <- escapeText $ T.pack nodeText+  backend <- escapeText $ T.pack backendText+  icon <- escapeIconText iconText+  return+    [ ("volume", volume),+      ("muted", muted),+      ("node", node),+      ("backend", backend),+      ("icon", icon)+    ]++buildUnknownAttrs :: IO [(String, String)]+buildUnknownAttrs = do+  icon <- escapeIconText (T.pack "\xF026")+  return+    [ ("volume", "?"),+      ("muted", "unknown"),+      ("node", "unknown"),+      ("backend", "unknown"),+      ("icon", icon)+    ]++audioTextIcon :: Maybe Bool -> Maybe Int -> T.Text+audioTextIcon muted volumePercent =+  case muted of+    Just True -> T.pack "\xF026"+    _ ->+      case volumePercent of+        Just v | v <= 0 -> T.pack "\xF026"+        Just v | v <= 33 -> T.pack "\xF027"+        _ -> T.pack "\xF028"++renderTemplate :: String -> [(String, String)] -> String+renderTemplate template attrs = render $ setManyAttrib attrs (newSTMP template)++escapeText :: T.Text -> IO String+escapeText input = T.unpack <$> G.markupEscapeText input (-1)++escapeIconText :: T.Text -> IO String+escapeIconText input =+  let iconSpan s =+        "<span font_family=\"Iosevka Nerd Font\" font_weight=\"normal\" size=\"large\">" ++ s ++ "</span>"+   in do+        rendered <-+          concat+            <$> mapM+              ( \c -> do+                  esc <- escapeText (T.singleton c)+                  pure $+                    if isPUA c+                      then iconSpan esc+                      else esc+              )+              (T.unpack input)+        pure $+          if any isPUA (T.unpack input)+            then rendered ++ iconSpan "&#x2004;"+            else rendered++isPUA :: Char -> Bool+isPUA c =+  let o = ord c+   in o >= 0xE000 && o <= 0xF8FF
src/System/Taffybar/Widget/BatteryTextIcon.hs view
@@ -29,6 +29,7 @@ import Control.Monad.IO.Class import Control.Monad.Trans.Reader import Data.Default (Default (..))+import Data.Maybe (fromMaybe, listToMaybe) import qualified Data.Text as T import GI.Gtk as Gtk import System.Taffybar.Context@@ -92,7 +93,7 @@ selectGlyph glyphs pct =   let n = length glyphs       idx = min (n - 1) $ floor (pct / 100.0 * fromIntegral n)-   in glyphs !! max 0 idx+   in fromMaybe "?" $ listToMaybe $ drop (max 0 idx) glyphs  -- | A battery icon widget that uses text glyphs (e.g. Nerd Font icons) to -- display battery state. The glyph changes based on charge level and
src/System/Taffybar/Widget/FreedesktopNotifications.hs view
@@ -106,7 +106,7 @@ noteFreshId :: NotifyState -> IO Word32 noteFreshId NotifyState {noteIdSource} = atomically $ do   nId <- readTVar noteIdSource-  writeTVar noteIdSource (succ nId)+  writeTVar noteIdSource (nId + 1)   return nId  --------------------------------------------------------------------------------
src/System/Taffybar/Widget/Generic/Graph.hs view
@@ -273,7 +273,11 @@   styleContext <- C.liftIO $ Gtk.widgetGetStyleContext drawArea   dataColors <-     if graphColorsFromCss cfg-      then C.liftIO $ forM [1 .. length hist] (getDataColorFromCss styleContext)+      then+        C.liftIO $+          forM+            (take (length hist) [1 :: Int ..])+            (getDataColorFromCss styleContext)       else return $ graphDataColors cfg   let -- Subtract 1 here since the first data point doesn't require       -- any movement in the X direction
src/System/Taffybar/Widget/MPRIS2.hs view
@@ -297,7 +297,7 @@     WARNING     "widget update called with no widget or %s"     ("nowplaying" :: String)-    >> return undefined+    >> error "simplePlayerWidget: impossible widget update state"  -- | This player widget constructor extends the default MPRIS2 row with previous, -- play/pause, and next buttons.@@ -406,7 +406,7 @@     WARNING     "widget update called with no widget or %s"     ("nowplaying" :: String)-    >> return undefined+    >> error "simplePlayerWidgetWithControls: impossible widget update state"  -- | Construct a new MPRIS2 widget using the `simplePlayerWidget` constructor. mpris2New :: TaffyIO Gtk.Widget
+ src/System/Taffybar/Widget/OmniMenu.hs view
@@ -0,0 +1,213 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++-- |+-- Module      : System.Taffybar.Widget.OmniMenu+-- Copyright   : (c) Ivan A. Malison+-- License     : BSD3-style (see LICENSE)+--+-- Maintainer  : Ivan A. Malison+-- Stability   : unstable+-- Portability : unportable+--+-- A menu button for launcher and session-control commands. The button can wrap+-- any widget, so callers can use an icon from a theme, a file image, a label, or+-- their own composed image widget as the visible trigger.+module System.Taffybar.Widget.OmniMenu+  ( OmniMenuConfig (..),+    OmniMenuItem (..),+    OmniMenuSection (..),+    defaultOmniMenuConfig,+    omniMenuNew,+    omniMenuNewFromFile,+    omniMenuNewFromIconName,+    omniMenuNewWithConfig,+  )+where++import Control.Monad (unless, void, when)+import Control.Monad.IO.Class+import Data.Foldable (foldlM)+import Data.Int (Int32)+import qualified Data.Text as T+import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk+import qualified GI.Gtk as Gtk+import System.Log.Logger+import System.Process+import System.Taffybar.Widget.Generic.AutoFillImage+import System.Taffybar.Widget.Generic.DynamicMenu+import System.Taffybar.Widget.Util+import System.Taffybar.Widget.XDGMenu.Menu++data OmniMenuItem = OmniMenuItem+  { omniMenuItemLabel :: T.Text,+    omniMenuItemCommand :: T.Text,+    omniMenuItemIcon :: Maybe T.Text,+    omniMenuItemTooltip :: Maybe T.Text+  }++data OmniMenuSection = OmniMenuSection+  { omniMenuSectionLabel :: T.Text,+    omniMenuSectionItems :: [OmniMenuItem]+  }++data OmniMenuConfig = OmniMenuConfig+  { omniMenuClickWidget :: Gtk.Widget,+    omniMenuIncludeApplications :: Bool,+    omniMenuXDGMenuPrefix :: Maybe String,+    omniMenuSections :: [OmniMenuSection]+  }++defaultOmniMenuConfig :: Gtk.Widget -> OmniMenuConfig+defaultOmniMenuConfig clickWidget =+  OmniMenuConfig+    { omniMenuClickWidget = clickWidget,+      omniMenuIncludeApplications = True,+      omniMenuXDGMenuPrefix = Nothing,+      omniMenuSections = []+    }++omniMenuNew :: (MonadIO m) => Gtk.Widget -> [OmniMenuSection] -> m Gtk.Widget+omniMenuNew clickWidget sections =+  omniMenuNewWithConfig $+    (defaultOmniMenuConfig clickWidget)+      { omniMenuSections = sections+      }++omniMenuNewFromFile :: (MonadIO m) => FilePath -> [OmniMenuSection] -> m Gtk.Widget+omniMenuNewFromFile path sections = do+  image <- Gtk.imageNewFromFile path+  clickWidget <- Gtk.toWidget image+  omniMenuNew clickWidget sections++omniMenuNewFromIconName :: (MonadIO m) => T.Text -> [OmniMenuSection] -> m Gtk.Widget+omniMenuNewFromIconName iconName sections = do+  image <-+    Gtk.imageNewFromIconName+      (Just iconName)+      (fromIntegral $ fromEnum Gtk.IconSizeMenu)+  clickWidget <- Gtk.toWidget image+  omniMenuNew clickWidget sections++omniMenuNewWithConfig :: (MonadIO m) => OmniMenuConfig -> m Gtk.Widget+omniMenuNewWithConfig OmniMenuConfig {..} = do+  menuButton <-+    dynamicMenuNew+      DynamicMenuConfig+        { dmClickWidget = omniMenuClickWidget,+          dmPopulateMenu = populateOmniMenu+        }+  _ <- widgetSetClassGI menuButton "omni-menu-button"+  return menuButton+  where+    populateOmniMenu menu = do+      applicationsAdded <-+        if omniMenuIncludeApplications+          then addApplicationsMenu menu omniMenuXDGMenuPrefix+          else pure False+      void $ foldlM (addSection menu) applicationsAdded omniMenuSections+      Gtk.widgetShowAll menu++omniMenuLog :: Priority -> String -> IO ()+omniMenuLog = logM "System.Taffybar.Widget.OmniMenu"++omniMenuImageMenuItemNew ::+  T.Text -> (Int32 -> IO (Maybe Gdk.Pixbuf)) -> IO Gtk.MenuItem+omniMenuImageMenuItemNew labelText pixbufGetter = do+  box <- Gtk.boxNew Gtk.OrientationHorizontal 6+  iconSlot <- Gtk.boxNew Gtk.OrientationHorizontal 0+  label <- Gtk.labelNew $ Just labelText+  image <- autoFillImageNew pixbufGetter Gtk.OrientationHorizontal+  item <- Gtk.menuItemNew+  Gtk.widgetSetSizeRequest iconSlot omniMenuIconSize omniMenuIconSize+  Gtk.containerAdd iconSlot image+  Gtk.containerAdd box iconSlot+  Gtk.containerAdd box label+  Gtk.containerAdd item box+  Gtk.widgetSetHalign box Gtk.AlignStart+  Gtk.widgetSetHalign iconSlot Gtk.AlignCenter+  Gtk.widgetSetValign iconSlot Gtk.AlignCenter+  Gtk.widgetSetHalign image Gtk.AlignCenter+  Gtk.widgetSetValign image Gtk.AlignCenter+  Gtk.widgetSetValign label Gtk.AlignCenter+  Gtk.widgetSetValign box Gtk.AlignFill+  return item++omniMenuIconSize :: Int32+omniMenuIconSize = 16++addApplicationsMenu :: (Gtk.IsMenuShell menuShell) => menuShell -> Maybe String -> IO Bool+addApplicationsMenu menuShell menuPrefix = do+  xdgMenu <- buildMenu menuPrefix+  if isEmptyXDGMenu xdgMenu+    then pure False+    else do+      item <- Gtk.menuItemNewWithLabel "Applications"+      submenu <- Gtk.menuNew+      Gtk.menuItemSetSubmenu item (Just submenu)+      addXDGMenuContents submenu xdgMenu+      Gtk.menuShellAppend menuShell item+      pure True++isEmptyXDGMenu :: Menu -> Bool+isEmptyXDGMenu Menu {..} =+  null fmEntries && all isEmptyXDGMenu fmSubmenus++addXDGMenuContents :: (Gtk.IsMenuShell menuShell) => menuShell -> Menu -> IO ()+addXDGMenuContents menuShell Menu {..} = do+  mapM_ (addXDGSubmenu menuShell) fmSubmenus+  mapM_ (addXDGEntry menuShell) fmEntries++addXDGSubmenu :: (Gtk.IsMenuShell menuShell) => menuShell -> Menu -> IO ()+addXDGSubmenu menuShell menu@Menu {..} =+  unless (null fmEntries && null fmSubmenus) $ do+    item <-+      omniMenuImageMenuItemNew+        (T.pack fmName)+        (getImageForMaybeIconName (T.pack <$> fmIcon))+    submenu <- Gtk.menuNew+    Gtk.menuItemSetSubmenu item (Just submenu)+    addXDGMenuContents submenu menu+    Gtk.menuShellAppend menuShell item++addXDGEntry :: (Gtk.IsMenuShell menuShell) => menuShell -> MenuEntry -> IO ()+addXDGEntry menuShell MenuEntry {..} = do+  item <- omniMenuImageMenuItemNew feName (getImageForMaybeIconName feIcon)+  Gtk.widgetSetTooltipText item (Just feComment)+  Gtk.menuShellAppend menuShell item+  void $ Gtk.onMenuItemActivate item $ do+    omniMenuLog DEBUG $ "Launching '" ++ feCommand ++ "'"+    void $ spawnCommand feCommand++addSection :: (Gtk.IsMenuShell menuShell) => menuShell -> Bool -> OmniMenuSection -> IO Bool+addSection menuShell hasPrevious OmniMenuSection {..} =+  if null omniMenuSectionItems+    then pure hasPrevious+    else do+      when hasPrevious $ addSeparator menuShell+      addSectionHeader menuShell omniMenuSectionLabel+      mapM_ (addCommandItem menuShell) omniMenuSectionItems+      pure True++addSeparator :: (Gtk.IsMenuShell menuShell) => menuShell -> IO ()+addSeparator menuShell = do+  separator <- Gtk.separatorMenuItemNew+  Gtk.menuShellAppend menuShell separator++addSectionHeader :: (Gtk.IsMenuShell menuShell) => menuShell -> T.Text -> IO ()+addSectionHeader menuShell label = do+  item <- Gtk.menuItemNewWithLabel label+  Gtk.widgetSetSensitive item False+  Gtk.menuShellAppend menuShell item++addCommandItem :: (Gtk.IsMenuShell menuShell) => menuShell -> OmniMenuItem -> IO ()+addCommandItem menuShell OmniMenuItem {..} = do+  item <-+    omniMenuImageMenuItemNew+      omniMenuItemLabel+      (getImageForMaybeIconName omniMenuItemIcon)+  Gtk.widgetSetTooltipText item omniMenuItemTooltip+  Gtk.menuShellAppend menuShell item+  void $ Gtk.onMenuItemActivate item $ do+    omniMenuLog DEBUG $ "Launching '" ++ T.unpack omniMenuItemCommand ++ "'"+    void $ spawnCommand $ T.unpack omniMenuItemCommand
src/System/Taffybar/Widget/OpenAIUsage.hs view
@@ -13,6 +13,7 @@     openAIUsageLabelNewWith,     openAIUsagePrimaryWindowLabelNew,     openAIUsageSecondaryWindowLabelNew,+    openAIUsageSectionNewWith,     openAIUsageStackNew,     openAIUsageStackNewWith,     openAIUsageNew,@@ -24,7 +25,7 @@  import Control.Concurrent (ThreadId, forkIO, killThread) import Control.Concurrent.MVar (MVar, newMVar, readMVar, swapMVar)-import Control.Concurrent.STM (atomically)+import Control.Concurrent.STM (atomically, newTVarIO, readTVarIO, writeTVar) import Control.Concurrent.STM.TChan (TChan, dupTChan, newBroadcastTChanIO, readTChan, writeTChan) import Control.Monad (forM_, forever, void) import Control.Monad.IO.Class (liftIO)@@ -36,7 +37,7 @@ import System.Taffybar.Context (TaffyIO, getStateDefault) import System.Taffybar.Information.OpenAIUsage import System.Taffybar.Util (postGUIASync)-import System.Taffybar.Widget.Util (widgetSetClassGI)+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI) import Text.Printf (printf)  data OpenAIUsageDisplayMode@@ -48,6 +49,15 @@   = OpenAIUsageDisplayModeState       (MVar OpenAIUsageDisplayMode, TChan OpenAIUsageDisplayMode) +data OpenAIUsageLabelParts = OpenAIUsageLabelParts+  { openAIUsageLabelWidget :: Gtk.Widget,+    openAIUsageLabel :: Gtk.Label,+    openAIUsageLabelDisplayState :: OpenAIUsageDisplayModeState,+    openAIUsageLabelSnapshotVar :: MVar OpenAIUsageSnapshot,+    openAIUsageLabelUsageChan :: TChan OpenAIUsageSnapshot,+    openAIUsageLabelRefreshNow :: IO ()+  }+ data OpenAIUsageLabelConfig = OpenAIUsageLabelConfig   { openAIUsageLabelInfoConfig :: OpenAIUsageConfig,     openAIUsageLabelDefaultDisplayMode :: OpenAIUsageDisplayMode,@@ -113,15 +123,38 @@  openAIUsageStackNewWith :: OpenAIUsageStackConfig -> TaffyIO Gtk.Widget openAIUsageStackNewWith config = do-  primary <- openAIUsageLabelNewWith $ windowLabelConfig OpenAIUsagePrimaryWindow config-  secondary <- openAIUsageLabelNewWith $ windowLabelConfig OpenAIUsageSecondaryWindow config+  (stack, primaryParts) <- openAIUsageStackPartsNewWith config+  liftIO $+    wrapOpenAIUsageMenu+      "openai-usage"+      stack+      (windowLabelConfig OpenAIUsagePrimaryWindow config)+      primaryParts++openAIUsageSectionNewWith :: Gtk.Widget -> OpenAIUsageStackConfig -> TaffyIO Gtk.Widget+openAIUsageSectionNewWith iconWidget config = do+  (stack, primaryParts) <- openAIUsageStackPartsNewWith config   liftIO $ do+    section <- buildIconLabelBox iconWidget stack+    _ <- widgetSetClassGI section "usage-section"+    wrapOpenAIUsageMenu+      "openai-usage"+      section+      (windowLabelConfig OpenAIUsagePrimaryWindow config)+      primaryParts++openAIUsageStackPartsNewWith :: OpenAIUsageStackConfig -> TaffyIO (Gtk.Widget, OpenAIUsageLabelParts)+openAIUsageStackPartsNewWith config = do+  primaryParts <- openAIUsageLabelPartsNewWith $ windowLabelConfig OpenAIUsagePrimaryWindow config+  secondaryParts <- openAIUsageLabelPartsNewWith $ windowLabelConfig OpenAIUsageSecondaryWindow config+  liftIO $ do     box <- Gtk.boxNew Gtk.OrientationVertical 0     _ <- widgetSetClassGI box "openai-usage-stack"-    Gtk.boxPackStart box primary False False 0-    Gtk.boxPackStart box secondary False False 0+    Gtk.boxPackStart box (openAIUsageLabelWidget primaryParts) False False 0+    Gtk.boxPackStart box (openAIUsageLabelWidget secondaryParts) False False 0     Gtk.widgetShowAll box-    Gtk.toWidget box+    widget <- Gtk.toWidget box+    return (widget, primaryParts)  windowLabelConfig :: OpenAIUsageWindowSelector -> OpenAIUsageStackConfig -> OpenAIUsageLabelConfig windowLabelConfig selector stackConfig =@@ -136,6 +169,11 @@  openAIUsageLabelNewWith :: OpenAIUsageLabelConfig -> TaffyIO Gtk.Widget openAIUsageLabelNewWith config = do+  parts <- openAIUsageLabelPartsNewWith config+  liftIO $ wrapOpenAIUsageMenu "openai-usage" (openAIUsageLabelWidget parts) config parts++openAIUsageLabelPartsNewWith :: OpenAIUsageLabelConfig -> TaffyIO OpenAIUsageLabelParts+openAIUsageLabelPartsNewWith config = do   let infoConfig = openAIUsageLabelInfoConfig config   usageChan <- getOpenAIUsageChan infoConfig   initialSnapshot <- getOpenAIUsageState infoConfig@@ -144,30 +182,104 @@    liftIO $ do     label <- Gtk.labelNew (Just (openAIUsageLabelFallbackText config))-    ebox <- Gtk.eventBoxNew     snapshotVar <- newMVar initialSnapshot     let refreshNow = runReaderT (forceOpenAIUsageRefresh infoConfig) ctx     _ <- widgetSetClassGI label (openAIUsageLabelClass config)-    _ <- widgetSetClassGI ebox "openai-usage"-    Gtk.containerAdd ebox label     updateLabelFromState config label displayState initialSnapshot -    void $ Gtk.onWidgetRealize ebox $ do+    void $ Gtk.onWidgetRealize label $ do       usageThread <- forkUsageListener config label displayState snapshotVar usageChan       modeThread <- forkDisplayModeListener config label displayState snapshotVar-      void $ Gtk.onWidgetUnrealize ebox $ do+      void $ Gtk.onWidgetUnrealize label $ do         killThread usageThread         killThread modeThread -    void $-      Gtk.onWidgetButtonPressEvent ebox $ \_event -> do-        refreshNow-        showUsageMenu ebox config label displayState snapshotVar refreshNow-        return True+    Gtk.widgetShowAll label+    widget <- Gtk.toWidget label+    return $+      OpenAIUsageLabelParts+        { openAIUsageLabelWidget = widget,+          openAIUsageLabel = label,+          openAIUsageLabelDisplayState = displayState,+          openAIUsageLabelSnapshotVar = snapshotVar,+          openAIUsageLabelUsageChan = usageChan,+          openAIUsageLabelRefreshNow = refreshNow+        } -    Gtk.widgetShowAll ebox-    Gtk.toWidget ebox+wrapOpenAIUsageMenu ::+  T.Text ->+  Gtk.Widget ->+  OpenAIUsageLabelConfig ->+  OpenAIUsageLabelParts ->+  IO Gtk.Widget+wrapOpenAIUsageMenu klass child config parts = do+  ebox <- Gtk.eventBoxNew+  _ <- widgetSetClassGI ebox klass+  Gtk.containerAdd ebox child+  initialSnapshot <- readMVar (openAIUsageLabelSnapshotVar parts)+  menu <-+    buildUsageMenu+      ebox+      config+      (openAIUsageLabel parts)+      (openAIUsageLabelDisplayState parts)+      (openAIUsageLabelSnapshotVar parts)+      (openAIUsageLabelRefreshNow parts)+      initialSnapshot+  menuVar <- newTVarIO menu +  let rebuildMenu snapshot =+        buildUsageMenu+          ebox+          config+          (openAIUsageLabel parts)+          (openAIUsageLabelDisplayState parts)+          (openAIUsageLabelSnapshotVar parts)+          (openAIUsageLabelRefreshNow parts)+          snapshot+          >>= atomically . writeTVar menuVar++  void $ Gtk.onWidgetRealize ebox $ do+    menuThread <-+      forkCachedMenuUpdater+        rebuildMenu+        (openAIUsageLabelSnapshotVar parts)+        (openAIUsageLabelUsageChan parts)+    void $ Gtk.onWidgetUnrealize ebox $ killThread menuThread++  void $+    Gtk.onWidgetButtonPressEvent ebox $ \_event -> do+      currentEvent <- Gtk.getCurrentEvent+      readyMenu <- readTVarIO menuVar+      Gtk.widgetShowAll readyMenu+      Gtk.menuPopupAtPointer readyMenu currentEvent+      void $ forkIO (openAIUsageLabelRefreshNow parts)+      return True+  Gtk.widgetShowAll ebox+  Gtk.toWidget ebox++buildUsageMenu ::+  Gtk.EventBox ->+  OpenAIUsageLabelConfig ->+  Gtk.Label ->+  OpenAIUsageDisplayModeState ->+  MVar OpenAIUsageSnapshot ->+  IO () ->+  OpenAIUsageSnapshot ->+  IO Gtk.Menu+buildUsageMenu anchor config label displayState snapshotVar refreshNow snapshot = do+  menu <- Gtk.menuNew+  Gtk.menuAttachToWidget menu anchor Nothing+  appendUsageMenuContents+    menu+    config+    label+    displayState+    snapshotVar+    refreshNow+    snapshot+  return menu+ getOpenAIUsageDisplayModeState :: OpenAIUsageDisplayMode -> TaffyIO OpenAIUsageDisplayModeState getOpenAIUsageDisplayModeState defaultMode =   getStateDefault $ liftIO $ do@@ -270,21 +382,33 @@               ("Reached: " <>) <$> openAIUsageReachedType info             ] -showUsageMenu ::-  Gtk.EventBox ->+forkCachedMenuUpdater ::+  (OpenAIUsageSnapshot -> IO ()) ->+  MVar OpenAIUsageSnapshot ->+  TChan OpenAIUsageSnapshot ->+  IO ThreadId+forkCachedMenuUpdater rebuildMenu snapshotVar usageChan = do+  ourUsageChan <- atomically $ dupTChan usageChan+  forkIO $+    forever $ do+      snapshot <- atomically $ readTChan ourUsageChan+      void $ swapMVar snapshotVar snapshot+      void $+        GLib.idleAdd GLib.PRIORITY_LOW $ do+          rebuildMenu snapshot+          return False++appendUsageMenuContents ::+  Gtk.Menu ->   OpenAIUsageLabelConfig ->   Gtk.Label ->   OpenAIUsageDisplayModeState ->   MVar OpenAIUsageSnapshot ->   IO () ->+  OpenAIUsageSnapshot ->   IO ()-showUsageMenu anchor config label displayState snapshotVar refreshNow = do-  currentEvent <- Gtk.getCurrentEvent-  snapshot <- readMVar snapshotVar+appendUsageMenuContents menu config label displayState snapshotVar refreshNow snapshot = do   displayMode <- readDisplayMode displayState-  menu <- Gtk.menuNew-  Gtk.menuAttachToWidget menu anchor Nothing-   appendInfoItem menu "OpenAI Codex usage"   appendSnapshotMenuItems menu displayMode snapshot @@ -297,23 +421,14 @@   void $     Gtk.onMenuItemActivate toggleItem $ do       setDisplayMode displayState (toggleDisplayMode displayMode)-      readMVar snapshotVar >>= updateLabelFromState config label displayState+      snapshot' <- readMVar snapshotVar+      updateLabelFromState config label displayState snapshot'   Gtk.menuShellAppend menu toggleItem    refreshItem <- Gtk.menuItemNewWithLabel ("Refresh" :: T.Text)-  void $ Gtk.onMenuItemActivate refreshItem refreshNow+  void $ Gtk.onMenuItemActivate refreshItem $ void $ forkIO refreshNow   Gtk.menuShellAppend menu refreshItem -  void $-    Gtk.onWidgetHide menu $-      void $-        GLib.idleAdd GLib.PRIORITY_LOW $ do-          Gtk.widgetDestroy menu-          return False--  Gtk.widgetShowAll menu-  Gtk.menuPopupAtPointer menu currentEvent- appendInfoItem :: Gtk.Menu -> T.Text -> IO () appendInfoItem menu text = do   item <- Gtk.menuItemNewWithLabel text@@ -368,17 +483,37 @@     [ formatWindowMenuLine displayMode "5h" <$> openAIUsagePrimaryWindow limit,       formatWindowMenuLine displayMode "7d" <$> openAIUsageSecondaryWindow limit     ]+    <> concat+      ( catMaybes+          [ formatWindowTokenMenuLines "5h" <$> openAIUsagePrimaryWindow limit,+            formatWindowTokenMenuLines "7d" <$> openAIUsageSecondaryWindow limit+          ]+      )     <> [formatRateLimitStatus limit]  formatWindowMenuLine :: OpenAIUsageDisplayMode -> T.Text -> OpenAIUsageWindow -> T.Text formatWindowMenuLine displayMode fallbackName window =   formatWindowName fallbackName window     <> ": "-    <> formatWindowPercent displayMode window+    <> formatWindowValue displayMode window     <> " "     <> displayModeText displayMode+    <> formatWindowPercentSuffix displayMode window     <> maybe "" ((", resets in " <>) . formatDuration) (openAIUsageResetAfterSeconds window) +formatWindowTokenMenuLines :: T.Text -> OpenAIUsageWindow -> [T.Text]+formatWindowTokenMenuLines fallbackName window =+  case openAIUsageWindowTotals window of+    Nothing -> []+    Just totals ->+      [ formatWindowName fallbackName window <> " tokens: " <> formatTotalTokens totals,+        formatWindowName fallbackName window <> " requests: " <> T.pack (show $ openAIUsageRequestCount totals),+        formatWindowName fallbackName window <> " input: " <> formatCount (openAIUsageInputTokens totals),+        formatWindowName fallbackName window <> " cached input: " <> formatCount (openAIUsageCachedInputTokens totals),+        formatWindowName fallbackName window <> " output: " <> formatCount (openAIUsageOutputTokens totals),+        formatWindowName fallbackName window <> " reasoning output: " <> formatCount (openAIUsageReasoningOutputTokens totals)+      ]+ formatRateLimitStatus :: OpenAIUsageRateLimit -> T.Text formatRateLimitStatus limit   | openAIUsageLimitReached limit = "Status: reached"@@ -419,9 +554,10 @@   Just $     formatWindowName name window       <> " "-      <> formatWindowPercent displayMode window+      <> formatWindowValue displayMode window       <> " "       <> displayModeText displayMode+      <> formatWindowPercentSuffix displayMode window       <> maybe "" ((" / " <>) . formatDuration) (openAIUsageWindowDurationSeconds window)       <> maybe "" ((", resets in " <>) . formatDuration) (openAIUsageResetAfterSeconds window) @@ -429,7 +565,7 @@ formatWindowLabel displayMode fallbackName window =   formatWindowName fallbackName window     <> " "-    <> formatWindowPercentWithIndicator displayMode window+    <> formatWindowValueWithIndicator displayMode window  formatWindowName :: T.Text -> OpenAIUsageWindow -> T.Text formatWindowName fallbackName window =@@ -439,10 +575,18 @@ formatWindowPercent displayMode window =   T.pack $ printf "%d%%" $ displayPercent displayMode window -formatWindowPercentWithIndicator :: OpenAIUsageDisplayMode -> OpenAIUsageWindow -> T.Text-formatWindowPercentWithIndicator displayMode window =-  formatWindowPercent displayMode window <> displayModeIndicator displayMode+formatWindowValue :: OpenAIUsageDisplayMode -> OpenAIUsageWindow -> T.Text+formatWindowValue = formatWindowPercent +formatWindowValueWithIndicator :: OpenAIUsageDisplayMode -> OpenAIUsageWindow -> T.Text+formatWindowValueWithIndicator displayMode window =+  formatWindowValue displayMode window <> displayModeIndicator displayMode++formatWindowPercentSuffix :: OpenAIUsageDisplayMode -> OpenAIUsageWindow -> T.Text+formatWindowPercentSuffix OpenAIUsageDisplayUsed window+  | isJust (openAIUsageWindowTotals window) = " (tokens in menu)"+formatWindowPercentSuffix _ _ = ""+ displayPercent :: OpenAIUsageDisplayMode -> OpenAIUsageWindow -> Int displayPercent OpenAIUsageDisplayUsed = openAIUsageUsedPercent displayPercent OpenAIUsageDisplayRemaining = max 0 . (100 -) . openAIUsageUsedPercent@@ -463,6 +607,16 @@            else fromMaybeText "0" (openAIUsageCreditsBalance credits)        )     <> if openAIUsageHasCredits credits then " available" else ""++formatTotalTokens :: OpenAIUsageTotals -> T.Text+formatTotalTokens = formatCount . openAIUsageTotalTokens++formatCount :: Int -> T.Text+formatCount count+  | count >= 1_000_000 = T.pack $ printf "%.1fM" (fromIntegral count / 1_000_000 :: Double)+  | count >= 10_000 = T.pack $ printf "%dk" (count `div` 1_000)+  | count >= 1_000 = T.pack $ printf "%.1fk" (fromIntegral count / 1_000 :: Double)+  | otherwise = T.pack $ show count  selectedWindow :: OpenAIUsageWindowSelector -> OpenAIUsageRateLimit -> Maybe OpenAIUsageWindow selectedWindow OpenAIUsagePrimaryWindow = openAIUsagePrimaryWindow
src/System/Taffybar/Widget/SNITray/PrioritizedCollapsible.hs view
@@ -19,7 +19,7 @@ where  import Control.Applicative ((<|>))-import Control.Monad (forM_, guard, void, when)+import Control.Monad (forM_, guard, unless, void, when) import Control.Monad.Trans.Class import Control.Monad.Trans.Reader import qualified DBus as D@@ -33,11 +33,12 @@ import Data.Char (isAlphaNum, isDigit, toLower) import Data.Foldable (traverse_) import Data.IORef-import Data.Int (Int32)-import Data.List (isSuffixOf, nub, sortOn, stripPrefix)+import Data.Int (Int32, Int64)+import Data.List (intercalate, isPrefixOf, sortOn, stripPrefix) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) import Data.Ord (Down (..))+import qualified Data.Set as Set import qualified Data.Text as T import Data.Unique (hashUnique) import Data.Word (Word32)@@ -45,7 +46,7 @@ import qualified GI.GLib as GLib import qualified GI.Gdk as Gdk import qualified GI.Gtk as Gtk-import Graphics.UI.GIGtkStrut (StrutAlignment (End))+import Graphics.UI.GIGtkStrut (StrutAlignment (Beginning)) import qualified StatusNotifier.Host.Service as H import StatusNotifier.Tray import qualified StatusNotifier.Tray as Tray@@ -159,6 +160,14 @@     prioritizedCollapsibleSNITrayStartPriorityEditMode :: Bool,     -- | Always show the expand/collapse toggle button.     prioritizedCollapsibleSNITrayAlwaysShowExpandToggle :: Bool,+    -- | Temporarily expand collapsed tray icons while the pointer is over the tray.+    prioritizedCollapsibleSNITrayHoverExpand :: Bool,+    -- | Delay before hover expansion starts.+    prioritizedCollapsibleSNITrayHoverExpandDelayMs :: Word32,+    -- | Delay before hover expansion collapses again.+    prioritizedCollapsibleSNITrayHoverCollapseDelayMs :: Word32,+    -- | Duration for the clipped tray extent animation while hover expanding/collapsing.+    prioritizedCollapsibleSNITrayHoverAnimationDurationMs :: Word32,     -- | Label renderer for the priority-edit-mode toggle.     --     -- Argument: @editing@.@@ -182,6 +191,10 @@       prioritizedCollapsibleSNITrayVisibilityThreshold = Nothing,       prioritizedCollapsibleSNITrayStartPriorityEditMode = False,       prioritizedCollapsibleSNITrayAlwaysShowExpandToggle = True,+      prioritizedCollapsibleSNITrayHoverExpand = False,+      prioritizedCollapsibleSNITrayHoverExpandDelayMs = 120,+      prioritizedCollapsibleSNITrayHoverCollapseDelayMs = 500,+      prioritizedCollapsibleSNITrayHoverAnimationDurationMs = 320,       prioritizedCollapsibleSNITrayPriorityModeLabel = defaultPrioritizedCollapsibleSNITrayPriorityModeLabel     } @@ -282,6 +295,16 @@ itemStableIdentityKey :: H.ItemInfo -> String itemStableIdentityKey info = "item-identity:" ++ itemStableIdentity info +itemDBusAddress :: H.ItemInfo -> String+itemDBusAddress info =+  D.formatBusName (H.itemServiceName info) <> "|" <> D.formatObjectPath (H.itemServicePath info)++orderingComponent :: Maybe String -> (Bool, String)+orderingComponent value =+  case value >>= nonEmptyString of+    Nothing -> (True, "")+    Just text -> (False, map toLower text)+ hasNumericSuffix :: String -> String -> Bool hasNumericSuffix prefix value =   case stripPrefix prefix value of@@ -295,6 +318,9 @@ sharedItemIdPrefixes :: [String] sharedItemIdPrefixes = ["chrome_status_icon_", "systray_"] +unstableServiceNamePrefixes :: [String]+unstableServiceNamePrefixes = ["org.kde.StatusNotifierItem-"]+ matchingItemIdPrefix :: [String] -> String -> Maybe String matchingItemIdPrefix prefixes itemId =   let lowerItemId = map toLower itemId@@ -325,6 +351,33 @@   guard (isLikelyUnstableItemId itemId)   return ("item-id:" ++ itemId) +itemIdOrderingToken :: H.ItemInfo -> Maybe String+itemIdOrderingToken info = do+  itemId <- H.itemId info >>= nonEmptyString+  return $ fromMaybe itemId (unstableItemIdPrefix itemId)++itemServiceNameOrderingToken :: H.ItemInfo -> Maybe String+itemServiceNameOrderingToken info =+  let serviceName = D.formatBusName (H.itemServiceName info)+   in if ":" `isPrefixOf` serviceName || any (`isPrefixOf` serviceName) unstableServiceNamePrefixes+        then Nothing+        else Just serviceName++itemOrderingKey :: Maybe String -> H.ItemInfo -> [(Bool, String)]+itemOrderingKey maybeProcessKey info =+  map+    orderingComponent+    [ maybeProcessKey,+      itemIdOrderingToken info,+      nonEmptyString (H.iconName info),+      nonEmptyString (H.iconTitle info),+      H.itemCategory info >>= nonEmptyString,+      itemServiceNameOrderingToken info,+      show <$> H.menuPath info,+      Just (show (H.itemServicePath info)),+      Just (itemDBusAddress info)+    ]+ sharedItemIdPrefixIconNameKey :: H.ItemInfo -> Maybe String sharedItemIdPrefixIconNameKey info = do   itemId <- H.itemId info >>= nonEmptyString@@ -355,7 +408,7 @@  processIdentityTokenFromArg :: String -> Maybe String processIdentityTokenFromArg arg-  | ".asar" `isSuffixOf` arg =+  | takeExtension arg == ".asar" =       nonEmptyString (takeBaseName (takeDirectory arg))   | otherwise = Nothing @@ -412,15 +465,21 @@   pairs <- mapM withKey infos   return $ M.fromList (catMaybes pairs)   where-    withKey info-      | not (isLikelySharedItemIdForInfo info) = return Nothing-      | otherwise = do-          processKey <- processDisambiguationKeyForItem client info-          return $ fmap (itemStableIdentity info,) processKey+    withKey info = do+      processKey <- processDisambiguationKeyForItem client info+      return $ fmap (itemStableIdentity info,) processKey +dedupe :: (Ord a) => [a] -> [a]+dedupe = go Set.empty+  where+    go _ [] = []+    go seen (x : xs)+      | x `Set.member` seen = go seen xs+      | otherwise = x : go (Set.insert x seen) xs+ priorityLookupKeyCandidates :: Maybe String -> H.ItemInfo -> [String] priorityLookupKeyCandidates maybeProcessKey info =-  nub $+  dedupe $     concat       [ maybeToList maybeProcessKey,         map ("icon-name:" ++) (maybeToList (nonEmptyString (H.iconName info))),@@ -434,7 +493,7 @@  priorityEditableKeyCandidates :: Maybe String -> H.ItemInfo -> [String] priorityEditableKeyCandidates maybeProcessKey info =-  nub $+  dedupe $     concat       [ maybeToList maybeProcessKey,         map ("icon-name:" ++) (maybeToList (nonEmptyString (H.iconName info))),@@ -465,34 +524,40 @@           mapMaybe (`M.lookup` priorities) (priorityLookupKeyCandidates maybeProcessKey info)    in clampPriority (fromMaybe defaultPriority matchedPriority) -itemIdentityMatcher :: H.ItemInfo -> Tray.TrayItemMatcher-itemIdentityMatcher info =-  let stableIdentity = itemStableIdentity info-   in mkTrayItemMatcher-        ("priority:identity:" <> stableIdentity)-        (\candidate -> itemStableIdentity candidate == stableIdentity)+formatMaybe :: Maybe String -> String+formatMaybe = fromMaybe "-" -priorityMatchersFromMapAndItems ::-  Bool ->-  Int ->-  Int ->-  Int ->-  SNIPriorityMap ->+describeItemForOrder ::+  (H.ItemInfo -> Int) ->   (H.ItemInfo -> Maybe String) ->+  H.ItemInfo ->+  String+describeItemForOrder priorityForInfo processKeyForInfo info =+  printf+    "p=%d process=%s itemId=%s icon=%s title=%s category=%s service=%s path=%s"+    (priorityForInfo info)+    (formatMaybe $ processKeyForInfo info)+    (formatMaybe $ H.itemId info)+    (H.iconName info)+    (H.iconTitle info)+    (formatMaybe $ H.itemCategory info)+    (D.formatBusName $ H.itemServiceName info)+    (D.formatObjectPath $ H.itemServicePath info)++describeOrderedInfos ::+  (H.ItemInfo -> Int) ->+  (H.ItemInfo -> Maybe String) ->   [H.ItemInfo] ->-  [Tray.TrayItemMatcher]-priorityMatchersFromMapAndItems highPriorityFirstInMatcherOrder priorityMin priorityMax defaultPriority priorities processKeyForInfo infos =-  let sortedInfos =-        sortedInfosByPriority-          highPriorityFirstInMatcherOrder-          priorityMin-          priorityMax-          defaultPriority-          priorities-          processKeyForInfo-          infos-      fallbackMatcher = mkTrayItemMatcher "priority:identity:fallback" (const True)-   in map itemIdentityMatcher sortedInfos ++ [fallbackMatcher]+  String+describeOrderedInfos priorityForInfo processKeyForInfo infos =+  intercalate+    " | "+    [ printf+        "%d:{%s}"+        index+        (describeItemForOrder priorityForInfo processKeyForInfo info)+    | (index, info) <- zip [0 :: Int ..] infos+    ]  sortedInfosByPriority ::   Bool ->@@ -503,7 +568,7 @@   (H.ItemInfo -> Maybe String) ->   [H.ItemInfo] ->   [H.ItemInfo]-sortedInfosByPriority highPriorityFirstInMatcherOrder priorityMin priorityMax defaultPriority priorities processKeyForInfo infos =+sortedInfosByPriority highPriorityFirstInTrayOrder priorityMin priorityMax defaultPriority priorities processKeyForInfo infos =   let itemPriority info =         itemPriorityFromMap           priorityMin@@ -513,13 +578,11 @@           (processKeyForInfo info)           info       prioritySortKey info =-        if highPriorityFirstInMatcherOrder+        if highPriorityFirstInTrayOrder           then negate (itemPriority info)           else itemPriority info-   in -- Matchers may need to be reversed to keep higher numeric priorities on the-      -- visual left when the tray is end-aligned.-      sortOn-        (\info -> (prioritySortKey info, itemStableIdentity info))+   in sortOn+        (\info -> (prioritySortKey info, itemOrderingKey (processKeyForInfo info) info))         infos  lookupExplicitPriority ::@@ -746,10 +809,7 @@         defaultPriority = clampPriority prioritizedCollapsibleSNITrayDefaultPriority         visibilityThreshold = clampPriority <$> prioritizedCollapsibleSNITrayVisibilityThreshold         trayOrientation' = trayOrientation sniTrayTrayParams-        highPriorityFirstInMatcherOrder =-          case trayAlignment sniTrayTrayParams of-            End -> False-            _ -> True+        highPriorityFirstInTrayOrder = True      statePath <- resolveSNIPriorityStateFile prioritizedCollapsibleSNITrayPriorityStateFile     persistedPriorityFile <- loadSNIPriorityFileFromFile statePath@@ -768,6 +828,11 @@             Just persistedThreshold -> clampPriority <$> persistedThreshold     prioritiesRef <- newIORef persistedPriorities     expandedRef <- newIORef collapsibleSNITrayStartExpanded+    hoverExpandedRef <- newIORef False+    hoverInsideRef <- newIORef False+    hoverSerialRef <- newIORef (0 :: Int)+    animationSerialRef <- newIORef (0 :: Int)+    animationActiveRef <- newIORef False     priorityEditModeRef <- newIORef prioritizedCollapsibleSNITrayStartPriorityEditMode     maxVisibleIconsRef <- newIORef initialMaxVisibleIcons     visibilityThresholdRef <- newIORef initialVisibilityThreshold@@ -780,10 +845,23 @@     outer <- Gtk.boxNew trayOrientation' 0     _ <- widgetSetClassGI outer "sni-tray-collapsible"     _ <- widgetSetClassGI outer "sni-tray-prioritized-collapsible"-    outerWidget <- Gtk.toWidget outer+    outerEventBox <- Gtk.eventBoxNew+    _ <- widgetSetClassGI outerEventBox "sni-tray-hover-expand-target"+    Gtk.containerAdd outerEventBox outer+    outerWidget <- Gtk.toWidget outerEventBox      trayContainer <- Gtk.boxNew trayOrientation' 0     _ <- widgetSetClassGI trayContainer "sni-tray-collapsible-container"+    trayClipper <-+      Gtk.scrolledWindowNew+        (Nothing :: Maybe Gtk.Adjustment)+        (Nothing :: Maybe Gtk.Adjustment)+    Gtk.scrolledWindowSetPolicy trayClipper Gtk.PolicyTypeNever Gtk.PolicyTypeNever+    Gtk.scrolledWindowSetShadowType trayClipper Gtk.ShadowTypeNone+    Gtk.scrolledWindowSetOverlayScrolling trayClipper False+    Gtk.scrolledWindowSetPropagateNaturalWidth trayClipper False+    Gtk.scrolledWindowSetPropagateNaturalHeight trayClipper False+    _ <- widgetSetClassGI trayClipper "sni-tray-collapsible-clipper"      overflowCountLabel <- Gtk.labelNew Nothing     _ <- widgetSetClassGI overflowCountLabel "sni-tray-overflow-count-label"@@ -826,9 +904,17 @@           when recomputeProcessKeys $             writeIORef processDisambiguationKeysRef processKeyMap           priorities <- readIORef prioritiesRef-          let orderedInfos =+          let priorityForInfo info =+                itemPriorityFromMap+                  priorityMin+                  priorityMax+                  defaultPriority+                  priorities+                  (processKeyForInfoFromMap processKeyMap info)+                  info+              orderedInfos =                 sortedInfosByPriority-                  highPriorityFirstInMatcherOrder+                  highPriorityFirstInTrayOrder                   priorityMin                   priorityMax                   defaultPriority@@ -836,6 +922,12 @@                   (processKeyForInfoFromMap processKeyMap)                   infos           writeIORef orderedInfosRef orderedInfos+          prioritizedTrayLog DEBUG $+            printf+              "Prioritized tray computed order recomputeProcessKeys=%s count=%d order=[%s]"+              (show recomputeProcessKeys)+              (length orderedInfos)+              (describeOrderedInfos priorityForInfo (processKeyForInfoFromMap processKeyMap) orderedInfos)           return orderedInfos          scheduleRefresh recomputeProcessKeys waitForExactChildCount updateType = do@@ -897,16 +989,18 @@             else do               removeClassIfPresent "sni-tray-editing" outer -        refreshTray tray = do+        getEffectiveExpanded = do           expanded <- readIORef expandedRef+          hoverExpanded <- readIORef hoverExpandedRef+          return (expanded || hoverExpanded)++        computeNaturalVisibleCount totalCount = do+          effectiveExpanded <- getEffectiveExpanded           priorities <- readIORef prioritiesRef           maxVisibleIcons <- readIORef maxVisibleIconsRef           thresholdValue <- readIORef visibilityThresholdRef           orderedInfos <- readIORef orderedInfosRef           processKeyMap <- readIORef processDisambiguationKeysRef-          reorderTrayChildrenByIdentities tray (map itemStableIdentity orderedInfos)-          children <- Gtk.containerGetChildren tray-           let itemPriority info =                 itemPriorityFromMap                   priorityMin@@ -915,7 +1009,6 @@                   priorities                   (processKeyForInfoFromMap processKeyMap info)                   info-              totalCount = length children               collapsedThresholdVisibleCount =                 case thresholdValue of                   Nothing -> totalCount@@ -927,19 +1020,171 @@                 | maxVisibleIcons > 0 =                     min collapsedThresholdVisibleCount maxVisibleIcons                 | otherwise = collapsedThresholdVisibleCount-              visibleCount-                | expanded = totalCount-                | otherwise = collapsedVisibleCount+          return $+            if effectiveExpanded+              then totalCount+              else collapsedVisibleCount++        childPreferredExtent child =+          case trayOrientation' of+            Gtk.OrientationVertical -> snd <$> Gtk.widgetGetPreferredHeight child+            _ -> snd <$> Gtk.widgetGetPreferredWidth child++        preferredTrayExtentForCount tray children visibleCount = do+          childExtents <- mapM childPreferredExtent (take visibleCount children)+          spacing <- Gtk.boxGetSpacing tray+          let spacingExtent =+                if visibleCount > 1+                  then fromIntegral (visibleCount - 1) * spacing+                  else 0+          return $ sum childExtents + spacingExtent++        setTrayClipperExtent extent =+          case trayOrientation' of+            Gtk.OrientationVertical ->+              Gtk.widgetSetSizeRequest trayClipper (-1) (fromIntegral extent)+            _ ->+              Gtk.widgetSetSizeRequest trayClipper (fromIntegral extent) (-1)++        allocatedTrayClipperExtent = do+          allocatedExtent <-+            case trayOrientation' of+              Gtk.OrientationVertical -> Gtk.widgetGetAllocatedHeight trayClipper+              _ -> Gtk.widgetGetAllocatedWidth trayClipper+          return $ max 0 allocatedExtent++        easeOutCubic :: Double -> Double+        easeOutCubic progress =+          1 - ((1 - progress) ^ (3 :: Int))++        animateTrayExtentTo tray targetVisibleCount = do+          modifyIORef' animationSerialRef (+ 1)+          animationSerial <- readIORef animationSerialRef+          writeIORef animationActiveRef True+          children <- Gtk.containerGetChildren tray+          forM_ children Gtk.widgetShow+          startExtent <- allocatedTrayClipperExtent+          targetExtent <- preferredTrayExtentForCount tray children targetVisibleCount+          let durationMs = prioritizedCollapsibleSNITrayHoverAnimationDurationMs+          if durationMs == 0 || startExtent == targetExtent+            then do+              setTrayClipperExtent targetExtent+              writeIORef animationActiveRef False+              void $ refreshTray tray+            else do+              startTime <- GLib.getMonotonicTime+              let durationUs = fromIntegral durationMs * 1000 :: Int64+                  extentAt now =+                    let elapsed = max 0 (now - startTime)+                        progress =+                          min+                            1+                            (fromIntegral elapsed / fromIntegral durationUs :: Double)+                        eased = easeOutCubic progress+                     in round $+                          fromIntegral startExtent+                            + (fromIntegral (targetExtent - startExtent) * eased :: Double)+              void $+                GLib.timeoutAdd GLib.PRIORITY_DEFAULT 16 $ do+                  currentSerial <- readIORef animationSerialRef+                  if currentSerial /= animationSerial+                    then return False+                    else do+                      now <- GLib.getMonotonicTime+                      setTrayClipperExtent (extentAt now)+                      if now - startTime >= durationUs+                        then do+                          setTrayClipperExtent targetExtent+                          writeIORef animationActiveRef False+                          void $ refreshTray tray+                          return False+                        else return True++        setHoverExpanded shouldExpand = do+          wasHoverExpanded <- readIORef hoverExpandedRef+          writeIORef hoverExpandedRef shouldExpand+          maybeTray <- readIORef trayRef+          case maybeTray of+            Nothing -> return ()+            Just tray ->+              when (wasHoverExpanded /= shouldExpand) $+                do+                  children <- Gtk.containerGetChildren tray+                  targetVisibleCount <- computeNaturalVisibleCount (length children)+                  animateTrayExtentTo tray targetVisibleCount++        scheduleHoverExpanded shouldExpand delayMs = do+          modifyIORef' hoverSerialRef (+ 1)+          hoverSerial <- readIORef hoverSerialRef+          void $+            GLib.timeoutAdd GLib.PRIORITY_DEFAULT delayMs $ do+              currentSerial <- readIORef hoverSerialRef+              hoverInside <- readIORef hoverInsideRef+              when+                ( currentSerial == hoverSerial+                    && hoverInside == shouldExpand+                )+                (setHoverExpanded shouldExpand)+              return False++        refreshTray tray = do+          effectiveExpanded <- getEffectiveExpanded+          animationActive <- readIORef animationActiveRef+          orderedInfos <- readIORef orderedInfosRef+          reorderTrayChildrenByIdentities tray (map itemStableIdentity orderedInfos)+          children <- Gtk.containerGetChildren tray++          naturalVisibleCount <- computeNaturalVisibleCount (length children)+          priorities <- readIORef prioritiesRef+          processKeyMap <- readIORef processDisambiguationKeysRef+          let totalCount = length children+              visibleCount =+                max 0 (min totalCount naturalVisibleCount)               hiddenCount = max 0 (totalCount - visibleCount)               hiddenCountText = T.pack (show hiddenCount)+              priorityForInfo info =+                itemPriorityFromMap+                  priorityMin+                  priorityMax+                  defaultPriority+                  priorities+                  (processKeyForInfoFromMap processKeyMap info)+                  info+              orderedInfoByIdentity =+                M.fromList [(itemStableIdentity info, info) | info <- orderedInfos]+              describeChildIdentity index maybeIdentity =+                case maybeIdentity >>= (`M.lookup` orderedInfoByIdentity) of+                  Just info ->+                    printf+                      "%d:{%s}"+                      index+                      (describeItemForOrder priorityForInfo (processKeyForInfoFromMap processKeyMap) info)+                  Nothing ->+                    printf+                      "%d:{identity=%s}"+                      index+                      (formatMaybe maybeIdentity)+          childIdentities <- mapM Tray.getTrayItemIdentity children+          prioritizedTrayLog DEBUG $+            printf+              "Prioritized tray rendered order expanded=%s animating=%s visible=%d hidden=%d total=%d children=[%s]"+              (show effectiveExpanded)+              (show animationActive)+              visibleCount+              hiddenCount+              totalCount+              (intercalate " | " (zipWith describeChildIdentity [0 :: Int ..] childIdentities))            forM_ (zip [0 :: Int ..] children) $ \(childIndex, child) -> do-            let shouldShow = childIndex < visibleCount+            let shouldShow = animationActive || childIndex < visibleCount             isVisible <- Gtk.widgetGetVisible child             when (isVisible /= shouldShow) $               if shouldShow                 then Gtk.widgetShow child                 else Gtk.widgetHide child+          unless animationActive $ do+            visibleExtent <- preferredTrayExtentForCount tray children visibleCount+            setTrayClipperExtent visibleExtent           writeIORef hiddenCountRef hiddenCount            if hiddenCount > 0@@ -950,10 +1195,15 @@               Gtk.labelSetText overflowCountLabel ""               Gtk.widgetHide overflowCountLabel -          if expanded+          if effectiveExpanded             then addClassIfMissing "sni-tray-collapsible-expanded" outer             else removeClassIfPresent "sni-tray-collapsible-expanded" outer +          hoverExpanded <- readIORef hoverExpandedRef+          if hoverExpanded+            then addClassIfMissing "sni-tray-hover-expanded" outer+            else removeClassIfPresent "sni-tray-hover-expanded" outer+           return hiddenCount          refresh = do@@ -988,20 +1238,8 @@                   (hashUnique handlerId)               writeIORef updateHandlerRef (Just handlerId) -        buildTrayWithPriorities priorities processKeyMap infos = do-          let priorityConfig =-                sniTrayPriorityConfig-                  { trayPriorityMatchers =-                      priorityMatchersFromMapAndItems-                        highPriorityFirstInMatcherOrder-                        priorityMin-                        priorityMax-                        defaultPriority-                        priorities-                        (processKeyForInfoFromMap processKeyMap)-                        infos-                  }-              baseHooks = trayEventHooks sniTrayTrayParams+        buildTrayForPrioritizedWrapper = do+          let baseHooks = trayEventHooks sniTrayTrayParams               baseClickHook = trayClickHook baseHooks               combinedClickHook clickContext = do                 editMode <- readIORef priorityEditModeRef@@ -1013,12 +1251,16 @@                     Just clickHook -> clickHook clickContext                     Nothing -> return UseDefaultClickAction               trayParams =+                -- This wrapper owns ordering and visibility. Keep the inner+                -- tray as a widget factory with straightforward child packing.                 sniTrayTrayParams-                  { trayEventHooks =+                  { trayAlignment = Beginning,+                    trayPriorityConfig = Tray.defaultTrayPriorityConfig,+                    trayEventHooks =                       baseHooks {trayClickHook = Just combinedClickHook},                     trayShowNewIconsImmediately = False                   }-          tray <- buildTray host client trayParams {trayPriorityConfig = priorityConfig}+          tray <- buildTray host client trayParams           _ <- widgetSetClassGI tray "sni-tray"           return tray @@ -1043,8 +1285,28 @@           return True         else return False +    when prioritizedCollapsibleSNITrayHoverExpand $ do+      Gtk.widgetAddEvents+        outerEventBox+        [ Gdk.EventMaskEnterNotifyMask,+          Gdk.EventMaskLeaveNotifyMask+        ]+      Gtk.widgetAddEvents settingsToggle [Gdk.EventMaskEnterNotifyMask]+      _ <- Gtk.onWidgetEnterNotifyEvent outerEventBox $ \_event -> do+        writeIORef hoverInsideRef True+        return False+      _ <- Gtk.onWidgetLeaveNotifyEvent outerEventBox $ \_event -> do+        writeIORef hoverInsideRef False+        scheduleHoverExpanded False prioritizedCollapsibleSNITrayHoverCollapseDelayMs+        return False+      _ <- Gtk.onWidgetEnterNotifyEvent settingsToggle $ \_event -> do+        writeIORef hoverInsideRef True+        scheduleHoverExpanded True prioritizedCollapsibleSNITrayHoverExpandDelayMs+        return False+      return ()+     _ <--      Gtk.onWidgetDestroy outer $+      Gtk.onWidgetDestroy outerEventBox $         readIORef updateHandlerRef           >>= traverse_             ( \handlerId -> do@@ -1055,16 +1317,15 @@                 H.removeUpdateHandler host handlerId             ) -    orderedInfos <- updateOrderedInfos True-    priorities <- readIORef prioritiesRef-    processKeyMap <- readIORef processDisambiguationKeysRef-    tray <- buildTrayWithPriorities priorities processKeyMap orderedInfos-    Gtk.boxPackStart trayContainer tray False False 0+    _ <- updateOrderedInfos True+    tray <- buildTrayForPrioritizedWrapper+    Gtk.containerAdd trayClipper tray+    Gtk.boxPackStart trayContainer trayClipper False False 0     writeIORef trayRef (Just tray)     installUpdateHandler     Gtk.widgetShow tray -    Gtk.widgetShowAll outer+    Gtk.widgetShowAll outerWidget     refreshPriorityModeToggle     _ <- refresh     return outerWidget
src/System/Taffybar/Widget/WakeupDebug.hs view
@@ -16,7 +16,7 @@ import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Reader (ask, runReaderT) import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)-import Data.List (nub, sort)+import qualified Data.Set as Set import qualified Data.Text as T import Data.Word (Word64) import qualified GI.Gdk as Gdk@@ -191,7 +191,11 @@ formatIntervals :: [Word64] -> T.Text formatIntervals intervals   | null intervals = "none"-  | otherwise = T.intercalate ", " $ fmap formatIntervalNanoseconds (sort (nub intervals))+  | otherwise =+      T.intercalate ", " $+        fmap formatIntervalNanoseconds $+          Set.toAscList $+            Set.fromList intervals  formatIntervalNanoseconds :: Word64 -> T.Text formatIntervalNanoseconds nanoseconds
src/System/Taffybar/Widget/Weather.hs view
@@ -88,6 +88,7 @@ import System.Taffybar.Widget.Util (widgetSetClassGI) import Text.Parsec import Text.Printf+import Text.Read (readMaybe) import Text.StringTemplate  -- | Parsed NOAA weather report values used by the weather widget.@@ -132,20 +133,27 @@   _ <- manyTill anyChar $ char '('   c <- manyTill num $ char ' '   _ <- skipRestOfLine-  return (floor (read c :: Double), floor (read f :: Double))+  parsedTempC <- parseNumber "temperature Celsius" c :: Parser Double+  parsedTempF <- parseNumber "temperature Fahrenheit" f :: Parser Double+  return (floor parsedTempC, floor parsedTempF)  pRh :: Parser Int pRh = do   s <- manyTill digit $ char '%' <|> char '.'-  return $ read s+  parseNumber "relative humidity" s  pPressure :: Parser Int pPressure = do   _ <- manyTill anyChar $ char '('   s <- manyTill digit $ char ' '   _ <- skipRestOfLine-  return $ read s+  parseNumber "pressure" s +parseNumber :: (Read a) => String -> String -> Parser a+parseNumber valueLabel raw =+  maybe (fail $ "Could not parse " ++ valueLabel ++ ": " ++ raw) return $+    readMaybe raw+ parseData :: Parser WeatherInfo parseData = do   st <- getAllBut ','@@ -327,7 +335,7 @@               noHttp = fromMaybe str $ stripPrefix "http://" str               (phost, pport) = case span (':' /=) noHttp of                 (h, "") -> (strToBs h, 80) -- HTTP seems to assume 80 to be the default-                (h, ':' : p) -> (strToBs h, read p)+                (h, ':' : p) -> (strToBs h, fromMaybe 80 (readMaybe p))                 _ -> error "unreachable: broken span"            in useProxy $ Proxy phost pport   mgr <- newManager $ managerSetProxy usedProxy tlsManagerSettings
src/System/Taffybar/Widget/Windows.hs view
@@ -29,11 +29,13 @@ 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,@@ -218,19 +220,14 @@     forM_ windows $ \windowInfo ->       lift $ do         labelText <- runReaderT (getMenuLabel config windowInfo) context-        iconPixbuf <--          case getActiveWindowIconPixbuf config of-            Nothing -> pure Nothing-            Just getIcon -> runReaderT (getIcon windowsMenuIconSize windowInfo) context         let focusCallback =               runReaderT (onMenuWindowClick config windowInfo) context                 >> return True         item <- Gtk.menuItemNew         content <- Gtk.boxNew Gtk.OrientationHorizontal 6-        forM_ iconPixbuf $ \pixbuf -> do-          icon <- Gtk.imageNewFromPixbuf (Just pixbuf)+        forM_ (getActiveWindowIconPixbuf config) $ \getIcon -> do+          icon <- buildMenuIcon context getIcon windowInfo           Gtk.boxPackStart content icon False False 0-          pure icon         label <- Gtk.labelNew (Just labelText)         Gtk.labelSetXalign label 0         Gtk.boxPackStart content label True True 0@@ -244,22 +241,39 @@   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 = fromIntegral $ fromEnum Gtk.IconSizeMenu+windowsMenuIconSize = 16  getWindows :: WorkspaceSnapshot -> [WindowInfo]-getWindows snapshot = reverse ordered+getWindows snapshot = orderedBuilder []   where     allWindows =       [ win       | wsInfo <- snapshotWorkspaces snapshot,         win <- workspaceWindows wsInfo       ]-    (_, ordered) = foldl keepFirst (Set.empty, []) allWindows-    keepFirst (seen, acc) windowInfo-      | windowIdentity windowInfo `Set.member` seen = (seen, acc)+    (_, 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, windowInfo : acc)+          (Set.insert (windowIdentity windowInfo) seen, build . (windowInfo :))  getActiveWindow :: WorkspaceSnapshot -> Maybe WindowInfo getActiveWindow snapshot =
src/System/Taffybar/Widget/Workspaces.hs view
@@ -55,6 +55,7 @@ import Data.Int (Int32) import Data.List (elemIndex) import qualified Data.Map.Strict as M+import Data.Maybe (listToMaybe) import qualified Data.Text as T import Data.Word (Word64) import qualified GI.Gdk.Enums as Gdk@@ -72,7 +73,9 @@     workspaceUpdateEvents,   ) import System.Taffybar.Information.Workspaces.Hyprland-  ( getHyprlandWorkspaceStateChanAndVar,+  ( HyprlandWorkspaceProviderConfig,+    defaultHyprlandWorkspaceProviderConfig,+    getHyprlandWorkspaceStateChanAndVarWith,   ) import System.Taffybar.Information.Workspaces.Model import System.Taffybar.Information.Workspaces.Support@@ -112,7 +115,6 @@     mkWindowIconWidgetBase,     syncWidgetPool,     updateWidgetClasses,-    updateWindowIconWidgetState,     widgetSetClassGI,     windowStatusClassFromFlags,   )@@ -139,6 +141,7 @@     labelSetter :: WorkspaceInfo -> TaffyIO String,     showWorkspaceFn :: WorkspaceInfo -> Bool,     iconSort :: [WindowInfo] -> TaffyIO [WindowInfo],+    hyprlandWorkspaceProviderConfig :: HyprlandWorkspaceProviderConfig,     urgentWorkspaceState :: Bool,     onWorkspaceClick :: WorkspaceInfo -> TaffyIO (),     onWindowClick :: WindowInfo -> TaffyIO ()@@ -241,11 +244,11 @@ defaultEWMHWorkspaceStateSource =   getEWMHWorkspaceStateChanAndVarWith defaultChannelEWMHWorkspaceProviderConfig -autoWorkspaceStateSource :: TaffyIO (TChan WorkspaceSnapshot, MV.MVar WorkspaceSnapshot)-autoWorkspaceStateSource = do+autoWorkspaceStateSource :: WorkspacesConfig -> TaffyIO (TChan WorkspaceSnapshot, MV.MVar WorkspaceSnapshot)+autoWorkspaceStateSource cfg = do   backendType <- asks backend   case backendType of-    BackendWayland -> getHyprlandWorkspaceStateChanAndVar+    BackendWayland -> getHyprlandWorkspaceStateChanAndVarWith (hyprlandWorkspaceProviderConfig cfg)     BackendX11 -> defaultEWMHWorkspaceStateSource  defaultWorkspacesConfig :: WorkspacesConfig@@ -260,6 +263,7 @@       labelSetter = return . T.unpack . workspaceName . workspaceIdentity,       showWorkspaceFn = \ws -> hideEmpty ws && not (workspaceIsSpecial ws),       iconSort = pure . sortWindowsByPosition,+      hyprlandWorkspaceProviderConfig = defaultHyprlandWorkspaceProviderConfig,       urgentWorkspaceState = False,       onWorkspaceClick = defaultOnWorkspaceClick,       onWindowClick = defaultOnWindowClick@@ -284,6 +288,7 @@         liftIO $ writeIORef wsRef newWs         labelText <- labelSetter cfg newWs         let wsState = toCSSState cfg newWs+            windowsChanged = workspaceWindows oldWs /= workspaceWindows newWs         liftIO $ Gtk.labelSetMarkup label (T.pack labelText)         setWorkspaceWidgetStatusClass wsState contents         setWorkspaceWidgetStatusClass wsState label@@ -291,11 +296,14 @@         let needsIconUpdate =               forceIcons                 || null currentIcons-                || workspaceWindows oldWs /= workspaceWindows newWs+                || windowsChanged         updatedIcons <-           if needsIconUpdate             then updateWorkspaceIcons cfg wsRef iconsBox currentIcons newWs             else return currentIcons+        when forceIcons $+          liftIO $+            forM_ updatedIcons iconForceUpdate         liftIO $ writeIORef iconsRef updatedIcons         liftIO $ writeIORef lastWorkspaceRef newWs       controller =@@ -320,7 +328,7 @@   cont <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal (fromIntegral $ widgetGap cfg)   _ <- widgetSetClassGI cont "workspaces"   cacheVar <- liftIO $ MV.newMVar (WorkspaceCache M.empty [] [] 0)-  (chan, snapshotVar) <- autoWorkspaceStateSource+  (chan, snapshotVar) <- autoWorkspaceStateSource cfg   initialSnapshot <- liftIO $ MV.readMVar snapshotVar   renderWorkspaces cfg cont cacheVar initialSnapshot   _ <-@@ -493,7 +501,7 @@         let total = length identities         guard (total > 0)         let wrappedIndex = (idx + step + total) `mod` total-        return (identities !! wrappedIndex)+        listToMaybe $ drop wrappedIndex identities   case getTargetIdentity >>= (`M.lookup` cacheEntries cache) of     Nothing -> return False     Just targetEntry -> do@@ -501,6 +509,17 @@       onWorkspaceClick cfg targetWorkspace       return True +activateWorkspaceIcon ::+  WorkspacesConfig ->+  WorkspaceInfo ->+  Maybe WindowInfo ->+  TaffyIO ()+activateWorkspaceIcon cfg currentWs maybeWindow = do+  backendType <- asks backend+  case (backendType, maybeWindow) of+    (BackendX11, Just windowInfo) -> onWindowClick cfg windowInfo+    _ -> onWorkspaceClick cfg currentWs+ updateWorkspaceIcons ::   WorkspacesConfig ->   IORef WorkspaceInfo ->@@ -541,21 +560,36 @@       Gtk.onWidgetButtonPressEvent (iconContainer iconWidget) $         const $ do           maybeWindow <- MV.readMVar (iconWindow iconWidget)-          case maybeWindow of-            Just windowInfo -> runReaderT (onWindowClick cfg windowInfo) ctx-            Nothing -> do-              currentWs <- readIORef workspaceRef-              runReaderT (onWorkspaceClick cfg currentWs) ctx+          currentWs <- readIORef workspaceRef+          runReaderT (activateWorkspaceIcon cfg currentWs maybeWindow) ctx           return True   return iconWidget  updateIconWidget :: WindowIconWidget WindowInfo -> Maybe WindowInfo -> TaffyIO ()-updateIconWidget iconWidget windowData =-  updateWindowIconWidgetState-    iconWidget-    windowData-    windowTitle-    getWindowStatusString+updateIconWidget iconWidget windowData = do+  oldWindowData <- liftIO $ MV.swapMVar (iconWindow iconWidget) windowData+  Gtk.widgetSetTooltipText (iconContainer iconWidget) (windowTitle <$> windowData)+  let pixbufKeyChanged =+        (windowIconPixbufKey <$> oldWindowData) /= (windowIconPixbufKey <$> windowData)+  when pixbufKeyChanged $+    liftIO $+      iconForceUpdate iconWidget+  let statusString = maybe "inactive" getWindowStatusString windowData+      stateClasses =+        case windowData of+          Just windowInfo | windowPinned windowInfo -> [statusString, "pinned"]+          _ -> [statusString]+  updateWidgetClasses+    (iconContainer iconWidget)+    stateClasses+    windowIconStatusClasses++windowIconPixbufKey :: WindowInfo -> (WindowIdentity, Word64, [T.Text], T.Text)+windowIconPixbufKey windowInfo =+  (windowIdentity windowInfo, windowUpdateRevision windowInfo, windowClassHints windowInfo, windowTitle windowInfo)++windowIconStatusClasses :: [T.Text]+windowIconStatusClasses = ["active", "urgent", "minimized", "normal", "inactive", "pinned"]  getWindowStatusString :: WindowInfo -> T.Text getWindowStatusString windowInfo =
src/System/Taffybar/Widget/WttrIn.hs view
@@ -58,9 +58,7 @@ callWttr url =   let unknownLocation rsp =         -- checks for a common wttr.in bug-        case T.stripPrefix "Unknown location; please try" rsp of-          Nothing -> False-          Just strippedRsp -> T.length strippedRsp < T.length rsp+        isJust $ T.stripPrefix "Unknown location; please try" rsp       isImage = isJust . matchRegex (mkRegex ".png")       getResponseData r =         ( statusIsSuccessful $ responseStatus r,
src/System/Taffybar/Window/FocusedMonitor.hs view
@@ -20,8 +20,8 @@   ) where -import Control.Concurrent (forkIO, killThread)-import Control.Concurrent.STM.TChan (TChan, readTChan)+import Control.Concurrent (forkIO, killThread, threadDelay)+import Control.Concurrent.STM.TChan (TChan, readTChan, tryReadTChan) import Control.Monad (forever, when) import Control.Monad.STM (atomically) import Data.Int (Int32)@@ -125,9 +125,20 @@       { getFocusedMonitorHyprlandEvents = getEvents       } -> do         events <- getEvents+        let collectPendingRelevantEvents = do+              maybeEventLine <- tryReadTChan events+              case maybeEventLine of+                Nothing -> return []+                Just eventLine ->+                  if isRelevantFocusedMonitorHyprlandEvent eventLine+                    then (eventLine :) <$> collectPendingRelevantEvents+                    else collectPendingRelevantEvents         tid <- forkIO $ forever $ do           eventLine <- atomically $ readTChan events-          when (isRelevantFocusedMonitorHyprlandEvent eventLine) refresh+          when (isRelevantFocusedMonitorHyprlandEvent eventLine) $ do+            threadDelay 25_000+            _ <- atomically collectPendingRelevantEvents+            refresh         _ <- Gtk.onWidgetUnrealize window $ killThread tid         return ()   refresh
src/System/Taffybar/WindowIcon.hs view
@@ -158,7 +158,7 @@     sortedIcons = sortBy (comparing ewmhHeight) icons     smallestLargerIcon =       take 1 $ dropWhile ((<= fromIntegral imgSize) . ewmhHeight) sortedIcons-    largestIcon = take 1 $ reverse sortedIcons+    largestIcon = take 1 $ sortBy (comparing (Down . ewmhHeight)) icons     prefIcon = smallestLargerIcon ++ largestIcon  -- | Create a pixbuf from the best matching EWMH icon for the requested size.@@ -200,7 +200,10 @@   (MonadIO m) =>   Int32 -> Word32 -> m Gdk.Pixbuf pixBufFromColor imgSize c = do-  pixbuf <- fromJust <$> Gdk.pixbufNew Gdk.ColorspaceRgb True 8 imgSize imgSize+  pixbuf <-+    fromMaybe+      (error "pixBufFromColor: failed to allocate pixbuf")+      <$> Gdk.pixbufNew Gdk.ColorspaceRgb True 8 imgSize imgSize   Gdk.pixbufFill pixbuf c   return pixbuf @@ -224,10 +227,10 @@   (Monad m) =>   (p -> String -> m (Maybe a)) -> p -> String -> m (Maybe a) getWindowIconForAllClasses doOnClass size klass =-  foldl combine (return Nothing) $ parseWindowClasses klass+  foldr combine (return Nothing) $ parseWindowClasses klass   where-    combine soFar theClass =-      maybeTCombine soFar (doOnClass size theClass)+    combine theClass =+      maybeTCombine (doOnClass size theClass)  -- | Resolve a window icon through desktop-entry icon metadata. getWindowIconFromDesktopEntryByClasses ::
taffybar.cabal view
@@ -1,7 +1,9 @@ cabal-version: 3.4 name: taffybar-version: 7.1.0+version: 7.2.0 synopsis: A desktop bar similar to xmobar, but with more GUI+description: Taffybar is a desktop status bar with GTK widgets for window+  manager state, system information, tray icons, and custom user modules. license: BSD-3-Clause license-file: LICENSE author: Ivan Malison@@ -14,6 +16,7 @@   taffybar.css   icons/*.svg extra-source-files:+  dbus-xml/org.imalison.ChromeWindowInfo.xml   dbus-xml/org.PulseAudio.ServerLookup1.xml   dbus-xml/org.PulseAudio.Core1.xml   dbus-xml/org.PulseAudio.Core1.Device.xml@@ -58,68 +61,70 @@     MonoLocalBinds    build-depends: HStringTemplate >= 0.8 && < 0.9-               , X11 >= 1.5.0.1-               , aeson-               , ansi-terminal-               , bytestring-               , conduit-               , containers-               , data-default-               , dbus >= 1.2.11 && < 2.0.0-               , dbus-hslogger >= 0.1.1.0 && < 0.2.0.0-               , dbus-menu >= 0.1.3.2-               , directory-               , disk-free-space >= 0.1.0.1+               , X11 >= 1.5.0.1 && < 1.11+               , aeson >= 1.4 && < 2.3+               , ansi-terminal >= 0.10 && < 1.2+               , bytestring >= 0.10 && < 0.13+               , conduit >= 1.3 && < 1.4+               , containers >= 0.5 && < 0.8+               , data-default >= 0.7 && < 0.9+               , dbus >= 1.2.11 && < 2+               , dbus-hslogger >= 0.1.1.1 && < 0.2+               , dbus-menu >= 0.1.3.3 && < 0.2+               , directory >= 1.3 && < 1.4+               , disk-free-space >= 0.1.0.1 && < 0.2                , dyre >= 0.9.0 && < 0.10-               , either >= 4.0.0.0-               , enclosed-exceptions >= 1.0.0.1-               , filepath+               , either >= 4.0.0.0 && < 5.1+               , enclosed-exceptions >= 1.0.0.1 && < 1.1+               , filepath >= 1.4 && < 1.6                , fsnotify >= 0.4 && < 0.5-               , monad-control-               , gi-cairo-connector-               , gi-cairo-render-                , gi-gdk3 >=3.0.30 && <3.1+               , monad-control >= 1.0 && < 1.1+               , gi-cairo-connector >= 0.1 && < 0.2+               , gi-cairo-render >= 0.1 && < 0.2+               , gi-gdk3 >=3.0.30 && <3.1                , gi-gdkpixbuf >=2.0.6 && <2.1                , gi-gdkx113 >=3.0.17 && < 4-               , gi-glib-               , gi-gtk-layer-shell >= 0.1.7+               , gi-glib >= 2.0 && < 2.1+               , gi-gobject >= 2.0 && < 2.1+               , gi-gtk-layer-shell >= 0.1.7 && < 0.2                , gi-gtk3 >= 3.0.44 && < 4                , gi-gtk-hs >= 0.3.17 && < 0.4-               , gi-pango-               , gtk-scaling-image >= 0.1.0.0 && < 0.2-               , gtk-sni-tray >= 0.2.1.1-               , gtk-strut >= 0.1.4.0-               , haskell-gi-base >= 0.24-               , hslogger-               , http-conduit-               , http-client >= 0.5-               , http-client-tls-               , http-types-               , multimap >= 1.2.1-               , network-               , parsec >= 3.1-               , process >= 1.0.1.1-               , rate-limit >= 1.1.1-               , regex-compat+               , gi-pango >= 1.0 && < 1.1+               , gi-wireplumber >= 0.5.14.1 && < 0.6+               , gtk-scaling-image >= 0.1.0.1 && < 0.2+               , gtk-sni-tray >= 0.2.1.2 && < 0.3+               , gtk-strut >= 0.1.4.1 && < 0.2+               , haskell-gi-base >= 0.24 && < 0.27+               , hslogger >= 1.2 && < 1.4+               , http-conduit >= 2.3 && < 2.4+               , http-client >= 0.5 && < 0.8+               , http-client-tls >= 0.3 && < 0.4+               , http-types >= 0.12 && < 0.13+               , multimap >= 1.2.1 && < 1.3+               , network >= 3.1 && < 3.3+               , parsec >= 3.1 && < 3.2+               , process >= 1.0.1.1 && < 1.7+               , rate-limit >= 1.1.1 && < 1.5+               , regex-compat >= 0.95 && < 0.96                , safe >= 0.3 && < 1                , scotty >= 0.20 && < 0.31-               , split >= 0.1.4.2-               , status-notifier-item >= 0.3.2.13-               , stm-               , template-haskell-               , text-               , time >= 1.9 && < 2.0+               , split >= 0.1.4.2 && < 0.3+               , status-notifier-item >= 0.3.2.14 && < 0.4+               , stm >= 2.5 && < 2.6+               , template-haskell >= 2.17 && < 2.24+               , text >= 1.2 && < 2.2+               , time >= 1.9 && < 2                , time-locale-compat >= 0.1 && < 0.2-               , time-units >= 1.0.0-               , transformers >= 0.3.0.0-               , tuple >= 0.3.0.2-               , unix-               , utf8-string-               , xdg-desktop-entry >= 0.1.1.4+               , time-units >= 1.0.0 && < 1.1+               , transformers >= 0.3.0.0 && < 0.7+               , tuple >= 0.3.0.2 && < 0.4+               , unix >= 2.7 && < 2.9+               , utf8-string >= 1.0 && < 1.1+               , xdg-desktop-entry >= 0.1.1.5 && < 0.2                , xdg-basedir >= 0.2 && < 0.3-               , xml-               , xml-helpers-               , yaml+               , xml >= 1.3 && < 1.4+               , xml-helpers >= 1.0 && < 1.1+               , yaml >= 0.11 && < 0.12    hs-source-dirs: src   pkgconfig-depends: gtk+-3.0@@ -134,7 +139,9 @@                  , System.Taffybar.Example                  , System.Taffybar.Hyprland                  , System.Taffybar.Hooks+                 , System.Taffybar.Information.Audio                  , System.Taffybar.Information.Hyprland+                 , System.Taffybar.Information.HyprlandWorkspaceHistory                  , System.Taffybar.Information.PulseAudio                  , System.Taffybar.Information.WirePlumber                  , System.Taffybar.Information.AnthropicUsage@@ -144,6 +151,7 @@                  , System.Taffybar.Information.Bluetooth                  , System.Taffybar.Information.CPU2                  , System.Taffybar.Information.Chrome+                 , System.Taffybar.Information.ChromeWindowInfo                  , System.Taffybar.Information.Crypto                  , System.Taffybar.Information.DiskIO                  , System.Taffybar.Information.DiskUsage@@ -168,6 +176,7 @@                  , System.Taffybar.Information.Workspaces.EWMH                  , System.Taffybar.Information.Workspaces.Hyprland                  , System.Taffybar.Information.Workspaces.Model+                 , System.Taffybar.Information.Workspaces.Refresh                  , System.Taffybar.Information.Wakeup                  , System.Taffybar.Information.Wlsunset                  , System.Taffybar.Information.X11DesktopInfo@@ -177,6 +186,7 @@                  , System.Taffybar.SimpleConfig                  , System.Taffybar.Util                  , System.Taffybar.Widget+                 , System.Taffybar.Widget.Audio                  , System.Taffybar.Widget.PulseAudio                  , System.Taffybar.Widget.AnthropicUsage                  , System.Taffybar.Widget.ASUS@@ -213,6 +223,7 @@                  , System.Taffybar.Widget.MPRIS2                  , System.Taffybar.Widget.NetworkGraph                  , System.Taffybar.Widget.NetworkManager+                 , System.Taffybar.Widget.OmniMenu                  , System.Taffybar.Widget.OpenAIUsage                  , System.Taffybar.Widget.PowerProfiles                  , System.Taffybar.Widget.Privacy@@ -240,10 +251,11 @@                  , System.Taffybar.WindowIcon    if flag(Deprecated-Pager-Hints)-    build-depends: xmonad+    build-depends: xmonad >= 0.17 && < 0.19     exposed-modules: System.Taffybar.Support.PagerHints    other-modules: Paths_taffybar+               , System.Taffybar.DBus.Client.ChromeWindowInfo                , System.Taffybar.DBus.Client.Login1Manager                , System.Taffybar.DBus.Client.MPRIS2                , System.Taffybar.DBus.Client.NetworkManager@@ -271,11 +283,11 @@ executable taffybar   import: haskell, exe   build-depends: data-default-               , dhall+               , dhall >= 1.40 && < 1.43                , directory                , gi-gtk3                , hslogger-               , optparse-applicative+               , optparse-applicative >= 0.17 && < 0.20                , taffybar                , text @@ -291,14 +303,14 @@   import: haskell, exe   hs-source-dirs: app   main-is: TestWM.hs-  build-depends: xmonad-               , xmonad-contrib+  build-depends: xmonad >= 0.17 && < 0.19+               , xmonad-contrib >= 0.17 && < 0.19  executable taffybar-appearance-snap   import: haskell, exe   hs-source-dirs: app   main-is: AppearanceSnap.hs-  build-depends: JuicyPixels+  build-depends: JuicyPixels >= 3.3 && < 3.4                , X11                , bytestring                , data-default@@ -311,15 +323,15 @@                , taffybar                , text                , transformers-               , typed-process+               , typed-process >= 0.2 && < 0.3                , unix-               , unliftio+               , unliftio >= 0.2 && < 0.3  executable taffybar-appearance-snap-hyprland   import: haskell, exe   hs-source-dirs: app   main-is: AppearanceSnapHyprland.hs-  build-depends: JuicyPixels+  build-depends: JuicyPixels >= 3.3 && < 3.4                , bytestring                , data-default                , directory@@ -328,9 +340,9 @@                , gtk-strut                , taffybar                , text-               , typed-process+               , typed-process >= 0.2 && < 0.3                , unix-               , unliftio+               , unliftio >= 0.2 && < 0.3                , transformers  common test@@ -349,22 +361,22 @@                  , System.Taffybar.Test.DBusSpec                  , System.Taffybar.Test.UtilSpec                  , System.Taffybar.Test.XvfbSpec-  build-depends: attoparsec+  build-depends: attoparsec >= 0.13 && < 0.15                , bytestring                , containers                , data-default                , dbus-               , extra+               , extra >= 1.7 && < 1.9                , filepath                , hslogger-               , hspec+               , hspec >= 2.8 && < 3                , taffybar                , text-               , typed-process+               , typed-process >= 0.2 && < 0.3                , unix-               , unliftio-               , unliftio-core-               , QuickCheck >= 2+               , unliftio >= 0.2 && < 0.3+               , unliftio-core >= 0.2 && < 0.3+               , QuickCheck >= 2 && < 2.16   build-tool-depends: hspec-discover:hspec-discover == 2.*  test-suite unit@@ -379,13 +391,17 @@                , System.Taffybar.Information.CryptoSpec                , System.Taffybar.Information.LayoutSpec                , System.Taffybar.Information.Workspaces.EWMHSpec+               , System.Taffybar.Information.Workspaces.HyprlandSpec                , System.Taffybar.Information.X11DesktopInfoSpec                , System.Taffybar.Information.WakeupSpec                , System.Taffybar.SimpleConfigSpec+               , System.Taffybar.Widget.SNITray.PrioritizedCollapsibleSpec                , System.Taffybar.Widget.Workspaces.ChannelSpec                , System.Taffybar.Widget.WindowsSpec   build-depends: data-default                , bytestring+               , containers+               , dbus                , directory                , JuicyPixels                , filepath@@ -395,6 +411,7 @@                , hspec-core                , hspec-golden                , network+               , status-notifier-item                , taffybar                , taffybar:testlib                , typed-process
test/data/appearance-hyprland-bar.png view

binary file changed (3729 → 1559 bytes)

test/unit/System/Taffybar/ContextSpec.hs view
@@ -32,8 +32,10 @@ import GI.Gtk (Widget) import Network.Socket qualified as Socket import System.Directory (createDirectoryIfMissing, getTemporaryDirectory, removePathForcibly)+import System.Environment (lookupEnv) import System.FilePath ((</>)) import System.Taffybar.Context+import System.Taffybar.Context.Backend (prepareBackendEnvironment) import System.Taffybar.SimpleConfig import System.Taffybar.Test.DBusSpec (withTestDBus) import System.Taffybar.Test.UtilSpec (logSetup, withEnv, withSetEnv)@@ -97,6 +99,147 @@           detectBackend `shouldReturn` BackendX11       removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ()) +    it "discovers Wayland from live Hyprland sockets when only DISPLAY is inherited" $ do+      tmp <- getTemporaryDirectory+      let runtime = tmp </> "taffybar-test-runtime-display-with-hyprland"+          liveWayland = "wayland-live"+          oldSig = "abcdef_1000_old"+          liveSig = "abcdef_2000_new"+          oldDir = runtime </> "hypr" </> oldSig+          liveDir = runtime </> "hypr" </> liveSig+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())+      createDirectoryIfMissing True oldDir+      createDirectoryIfMissing True liveDir+      withUnixSocket (runtime </> liveWayland)+        $ withUnixSocket (oldDir </> ".socket.sock")+        $ withUnixSocket (liveDir </> ".socket.sock")+        $ withEnv+          [ ("XDG_RUNTIME_DIR", const $ Just runtime),+            ("DISPLAY", const $ Just ":0"),+            ("WAYLAND_DISPLAY", const Nothing),+            ("HYPRLAND_INSTANCE_SIGNATURE", const Nothing),+            ("XDG_SESSION_TYPE", const $ Just "tty"),+            ("XDG_CURRENT_DESKTOP", const Nothing),+            ("DESKTOP_SESSION", const Nothing),+            ("GDK_BACKEND", const Nothing)+          ]+        $ do+          detectBackend `shouldReturn` BackendWayland+          lookupEnv "WAYLAND_DISPLAY" `shouldReturn` Just liveWayland+          lookupEnv "HYPRLAND_INSTANCE_SIGNATURE" `shouldReturn` Just liveSig+          lookupEnv "GDK_BACKEND" `shouldReturn` Just "wayland"+          lookupEnv "XDG_SESSION_TYPE" `shouldReturn` Just "wayland"+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())++  describe "prepareBackendEnvironment" $ do+    it "replaces a stale WAYLAND_DISPLAY with a live discovered socket" $ do+      tmp <- getTemporaryDirectory+      let runtime = tmp </> "taffybar-test-runtime-repair-wayland"+          liveWayland = "wayland-live"+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())+      createDirectoryIfMissing True runtime+      withUnixSocket (runtime </> liveWayland)+        $ withEnv+          [ ("XDG_RUNTIME_DIR", const $ Just runtime),+            ("WAYLAND_DISPLAY", const $ Just "wayland-stale"),+            ("XDG_SESSION_TYPE", const $ Just "wayland"),+            ("GDK_BACKEND", const Nothing)+          ]+        $ do+          prepareBackendEnvironment+          lookupEnv "WAYLAND_DISPLAY" `shouldReturn` Just liveWayland+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())++    it "does not infer Wayland from an ambient socket when X11 is the only process context" $ do+      tmp <- getTemporaryDirectory+      let runtime = tmp </> "taffybar-test-runtime-ambient-wayland"+          liveWayland = "wayland-live"+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())+      createDirectoryIfMissing True runtime+      withUnixSocket (runtime </> liveWayland)+        $ withEnv+          [ ("XDG_RUNTIME_DIR", const $ Just runtime),+            ("DISPLAY", const $ Just ":0"),+            ("WAYLAND_DISPLAY", const Nothing),+            ("HYPRLAND_INSTANCE_SIGNATURE", const Nothing),+            ("XDG_SESSION_TYPE", const Nothing),+            ("XDG_CURRENT_DESKTOP", const Nothing),+            ("DESKTOP_SESSION", const Nothing),+            ("GDK_BACKEND", const Nothing)+          ]+        $ do+          prepareBackendEnvironment+          lookupEnv "WAYLAND_DISPLAY" `shouldReturn` Nothing+          lookupEnv "GDK_BACKEND" `shouldReturn` Nothing+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())++    it "steers GTK to Wayland when a live Wayland socket is selected" $ do+      tmp <- getTemporaryDirectory+      let runtime = tmp </> "taffybar-test-runtime-wayland-gdk-backend"+          liveWayland = "wayland-live"+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())+      createDirectoryIfMissing True runtime+      withUnixSocket (runtime </> liveWayland)+        $ withEnv+          [ ("XDG_RUNTIME_DIR", const $ Just runtime),+            ("DISPLAY", const $ Just ":0"),+            ("WAYLAND_DISPLAY", const $ Just liveWayland),+            ("XDG_SESSION_TYPE", const $ Just "x11"),+            ("GDK_BACKEND", const Nothing)+          ]+        $ do+          prepareBackendEnvironment+          lookupEnv "GDK_BACKEND" `shouldReturn` Just "wayland"+          lookupEnv "XDG_SESSION_TYPE" `shouldReturn` Just "wayland"+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())++    it "prefers a live Hyprland instance over stale X11 session variables" $ do+      tmp <- getTemporaryDirectory+      let runtime = tmp </> "taffybar-test-runtime-hyprland-over-x11"+          liveWayland = "wayland-live"+          liveSig = "hyprland-live"+          liveDir = runtime </> "hypr" </> liveSig+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())+      createDirectoryIfMissing True liveDir+      withUnixSocket (runtime </> liveWayland)+        $ withUnixSocket (liveDir </> ".socket.sock")+        $ withEnv+          [ ("XDG_RUNTIME_DIR", const $ Just runtime),+            ("DISPLAY", const $ Just ":0"),+            ("WAYLAND_DISPLAY", const Nothing),+            ("HYPRLAND_INSTANCE_SIGNATURE", const $ Just liveSig),+            ("XDG_SESSION_TYPE", const $ Just "x11"),+            ("XDG_CURRENT_DESKTOP", const $ Just "none+xmonad"),+            ("DESKTOP_SESSION", const $ Just "none+xmonad"),+            ("GDK_BACKEND", const Nothing)+          ]+        $ do+          prepareBackendEnvironment+          lookupEnv "WAYLAND_DISPLAY" `shouldReturn` Just liveWayland+          lookupEnv "HYPRLAND_INSTANCE_SIGNATURE" `shouldReturn` Just liveSig+          lookupEnv "GDK_BACKEND" `shouldReturn` Just "wayland"+          lookupEnv "XDG_SESSION_TYPE" `shouldReturn` Just "wayland"+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())++    it "replaces a stale HYPRLAND_INSTANCE_SIGNATURE with a live discovered signature" $ do+      tmp <- getTemporaryDirectory+      let runtime = tmp </> "taffybar-test-runtime-repair-hyprland"+          liveSig = "hyprland-live"+          liveDir = runtime </> "hypr" </> liveSig+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())+      createDirectoryIfMissing True liveDir+      withUnixSocket (liveDir </> ".socket.sock")+        $ withEnv+          [ ("XDG_RUNTIME_DIR", const $ Just runtime),+            ("XDG_SESSION_TYPE", const $ Just "wayland"),+            ("HYPRLAND_INSTANCE_SIGNATURE", const $ Just "hyprland-stale"),+            ("GDK_BACKEND", const Nothing)+          ]+        $ do+          prepareBackendEnvironment+          lookupEnv "HYPRLAND_INSTANCE_SIGNATURE" `shouldReturn` Just liveSig+      removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ())+   describe "Fuzz tests" $ do     prop "eval generators" prop_genSimpleConfig     xprop "TaffybarConfig" prop_taffybarConfig@@ -110,6 +253,7 @@     Socket.close     $ \sock -> do       Socket.bind sock (Socket.SockAddrUnix path)+      Socket.listen sock 1       action  ------------------------------------------------------------------------
+ test/unit/System/Taffybar/Information/Workspaces/HyprlandSpec.hs view
@@ -0,0 +1,124 @@+module System.Taffybar.Information.Workspaces.HyprlandSpec (spec) where++import Data.Text qualified as T+import System.Taffybar.Information.Workspaces.Hyprland+  ( applySpecialWorkspaceWindowTargets,+    sortWorkspaceInfos,+    specialWorkspaceWindowsToMinimized,+  )+import System.Taffybar.Information.Workspaces.Model+import Test.Hspec++mkWindow :: T.Text -> Bool -> WindowInfo+mkWindow title minimized =+  WindowInfo+    { windowIdentity = HyprlandWindowIdentity title,+      windowUpdateRevision = 0,+      windowTitle = title,+      windowClassHints = [],+      windowPosition = Nothing,+      windowUrgent = False,+      windowActive = False,+      windowMinimized = minimized,+      windowPinned = False+    }++mkWorkspace ::+  Maybe Int ->+  T.Text ->+  WorkspaceViewState ->+  Bool ->+  [WindowInfo] ->+  WorkspaceInfo+mkWorkspace idx name state special windows =+  WorkspaceInfo+    { workspaceIdentity =+        WorkspaceIdentity+          { workspaceNumericId = idx,+            workspaceName = name+          },+      workspaceUpdateRevision = 0,+      workspaceState = state,+      workspaceHasUrgentWindow = any windowUrgent windows,+      workspaceIsSpecial = special,+      workspaceWindows = windows+    }++workspaceByName :: T.Text -> [WorkspaceInfo] -> WorkspaceInfo+workspaceByName name workspaces =+  case filter ((== name) . workspaceName . workspaceIdentity) workspaces of+    [workspace] -> workspace+    [] -> error $ "Expected workspace named " <> T.unpack name+    _ -> error $ "Expected one workspace named " <> T.unpack name++spec :: Spec+spec = do+  describe "sortWorkspaceInfos" $+    it "orders normal numeric workspaces before special workspaces" $ do+      let specialMinimized =+            mkWorkspace (Just (-98)) "special:minimized" WorkspaceHidden True []+          specialScratch =+            mkWorkspace (Just (-99)) "special:scratchpad" WorkspaceHidden True []+          normal1 =+            mkWorkspace (Just 1) "1" WorkspaceActive False []+          normal2 =+            mkWorkspace (Just 2) "2" WorkspaceEmpty False []++      sortWorkspaceInfos [specialMinimized, normal2, specialScratch, normal1]+        `shouldBe` [normal1, normal2, specialScratch, specialMinimized]++  describe "applySpecialWorkspaceWindowTargets" $ do+    it "moves non-minimized special workspace windows into special:minimized" $ do+      let scratchWindow = mkWindow "scratch" False+          minimizedWindow = mkWindow "minimized" True+          scratch =+            mkWorkspace+              (Just (-99))+              "special:scratchpad"+              WorkspaceHidden+              True+              [scratchWindow]+          minimized =+            mkWorkspace+              (Just (-98))+              "special:minimized"+              WorkspaceHidden+              True+              [minimizedWindow]+          result =+            applySpecialWorkspaceWindowTargets+              specialWorkspaceWindowsToMinimized+              [scratch, minimized]+          movedScratch = workspaceByName "special:scratchpad" result+          minimizedBucket = workspaceByName "special:minimized" result+          movedWindow =+            case filter ((== HyprlandWindowIdentity "scratch") . windowIdentity) $+              workspaceWindows minimizedBucket of+              [window] -> window+              _ -> error "Expected scratch window in minimized bucket"++      workspaceWindows movedScratch `shouldBe` []+      workspaceState movedScratch `shouldBe` WorkspaceEmpty+      workspaceWindows minimizedBucket `shouldBe` [minimizedWindow, movedWindow]+      windowMinimized movedWindow `shouldBe` True++    it "creates a special:minimized bucket when the target workspace is missing" $ do+      let scratchWindow = mkWindow "scratch" False+          scratch =+            mkWorkspace+              (Just (-99))+              "special:scratchpad"+              WorkspaceHidden+              True+              [scratchWindow]+          result =+            applySpecialWorkspaceWindowTargets+              specialWorkspaceWindowsToMinimized+              [scratch]+          minimizedBucket = workspaceByName "special:minimized" result++      workspaceNumericId (workspaceIdentity minimizedBucket) `shouldBe` Nothing+      workspaceIsSpecial minimizedBucket `shouldBe` True+      workspaceState minimizedBucket `shouldBe` WorkspaceHidden+      map windowIdentity (workspaceWindows minimizedBucket)+        `shouldBe` [HyprlandWindowIdentity "scratch"]
+ test/unit/System/Taffybar/Widget/SNITray/PrioritizedCollapsibleSpec.hs view
@@ -0,0 +1,115 @@+module System.Taffybar.Widget.SNITray.PrioritizedCollapsibleSpec (spec) where++import DBus (busName_, formatBusName, objectPath_)+import Data.Map.Strict qualified as M+import StatusNotifier.Host.Service qualified as H+import System.Taffybar.Widget.SNITray.PrioritizedCollapsible+import Test.Hspec++spec :: Spec+spec =+  describe "sortedInfosByPriority" $ do+    it "uses stable metadata before D-Bus address for equal priorities" $ do+      let beta =+            testItem+              ":1.1"+              "/StatusNotifierItem"+              (Just "statusnotifieritem-1")+              "Zeta"+              "zeta-icon"+          alpha =+            testItem+              ":1.9"+              "/StatusNotifierItem"+              (Just "statusnotifieritem-9")+              "Alpha"+              "alpha-icon"+          sorted =+            sortedInfosByPriority+              True+              (-5)+              5+              0+              M.empty+              (const Nothing)+              [beta, alpha]++      map H.iconTitle sorted `shouldBe` ["Alpha", "Zeta"]++    it "uses process keys before D-Bus address" $ do+      let zeta =+            testItem+              ":1.1"+              "/StatusNotifierItem"+              (Just "systray_1")+              "Zeta"+              "same-icon"+          alpha =+            testItem+              ":1.9"+              "/StatusNotifierItem"+              (Just "systray_9")+              "Alpha"+              "same-icon"+          processKey info =+            case formatBusName (H.itemServiceName info) of+              ":1.1" -> Just "process:zeta"+              ":1.9" -> Just "process:alpha"+              _ -> Nothing+          sorted =+            sortedInfosByPriority+              True+              (-5)+              5+              0+              M.empty+              processKey+              [zeta, alpha]++      map H.iconTitle sorted `shouldBe` ["Alpha", "Zeta"]++    it "keeps priority ahead of the semantic tie-breaker" $ do+      let beta =+            testItem+              ":1.1"+              "/StatusNotifierItem"+              Nothing+              "Beta"+              "beta-icon"+          alpha =+            testItem+              ":1.9"+              "/StatusNotifierItem"+              Nothing+              "Alpha"+              "alpha-icon"+          sorted =+            sortedInfosByPriority+              True+              (-5)+              5+              0+              (M.fromList [("icon-title:Beta", 1)])+              (const Nothing)+              [alpha, beta]++      map H.iconTitle sorted `shouldBe` ["Beta", "Alpha"]++testItem :: String -> String -> Maybe String -> String -> String -> H.ItemInfo+testItem serviceName servicePath itemId iconTitle iconName =+  H.ItemInfo+    { H.itemServiceName = busName_ serviceName,+      H.itemServicePath = objectPath_ servicePath,+      H.itemId = itemId,+      H.itemStatus = Nothing,+      H.itemCategory = Nothing,+      H.itemToolTip = Nothing,+      H.iconTitle = iconTitle,+      H.iconName = iconName,+      H.overlayIconName = Nothing,+      H.iconThemePath = Nothing,+      H.iconPixmaps = [],+      H.overlayIconPixmaps = [],+      H.menuPath = Nothing,+      H.itemIsMenu = True+    }
test/unit/System/Taffybar/Widget/Workspaces/ChannelSpec.hs view
@@ -10,12 +10,14 @@ mkX11Window wid minimized pos =   WindowInfo     { windowIdentity = X11WindowIdentity wid,+      windowUpdateRevision = 0,       windowTitle = T.pack (show wid),       windowClassHints = [],       windowPosition = pos,       windowUrgent = False,       windowActive = False,-      windowMinimized = minimized+      windowMinimized = minimized,+      windowPinned = False     }  mkWorkspace :: Int -> String -> WorkspaceViewState -> [WindowInfo] -> WorkspaceInfo@@ -26,6 +28,7 @@           { workspaceNumericId = Just idx,             workspaceName = T.pack name           },+      workspaceUpdateRevision = 0,       workspaceState = state,       workspaceHasUrgentWindow = any windowUrgent windows,       workspaceIsSpecial = False,@@ -76,3 +79,27 @@                 [WindowAdded newW2, WindowChanged newW1]             ]       diffWorkspaceSnapshots oldSnap newSnap `shouldBe` expectedEvents++    it "bumps update revisions for targeted window refreshes" $ do+      let w1 = mkX11Window 1 False (Just (0, 0))+          w2 = mkX11Window 2 False (Just (50, 50))+          ws1 = mkWorkspace 1 "1" WorkspaceActive [w1, w2]+          snap = mkSnapshot 10 [ws1]+          refreshed = bumpWorkspaceRefreshTarget (RefreshWindow $ X11WindowIdentity 2) snap+          refreshedRevisions =+            concatMap+              (map windowUpdateRevision . workspaceWindows)+              (snapshotWorkspaces refreshed)+      refreshedRevisions `shouldBe` [0, 1]++    it "preserves update revisions across backend snapshots" $ do+      let oldW1 = (mkX11Window 1 False (Just (0, 0))) {windowUpdateRevision = 3}+          oldWs1 = (mkWorkspace 1 "1" WorkspaceActive [oldW1]) {workspaceUpdateRevision = 4}+          previous = mkSnapshot 10 [oldWs1]+          newW1 = (mkX11Window 1 False (Just (5, 5))) {windowUpdateRevision = 0}+          newWs1 = (mkWorkspace 1 "1" WorkspaceHidden [newW1]) {workspaceUpdateRevision = 0}+          next = preserveWorkspaceUpdateRevisions previous $ mkSnapshot 11 [newWs1]+          preservedWorkspaces = snapshotWorkspaces next+          preservedWindows = concatMap workspaceWindows preservedWorkspaces+      map workspaceUpdateRevision preservedWorkspaces `shouldBe` [4]+      map windowUpdateRevision preservedWindows `shouldBe` [3]