packages feed

taffybar 7.2.6 → 7.2.7

raw patch · 33 files changed

+2352/−319 lines, 33 filesdep ~dbus-menudep ~timePVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependency ranges changed: dbus-menu, time

API changes (from Hackage documentation)

+ System.Taffybar.Information.ASUS: [asusACProfile] :: ASUSInfo -> ASUSPlatformProfile
+ System.Taffybar.Information.ASUS: [asusBatteryProfile] :: ASUSInfo -> ASUSPlatformProfile
+ System.Taffybar.Information.ASUS: [asusOnACPower] :: ASUSInfo -> Bool
+ System.Taffybar.Information.ASUS: asusProfileToUInt :: ASUSPlatformProfile -> Word32
+ System.Taffybar.Information.ASUS: setASUSACProfile :: Client -> ASUSPlatformProfile -> IO (Either MethodError ())
+ System.Taffybar.Information.ASUS: setASUSBatteryProfile :: Client -> ASUSPlatformProfile -> IO (Either MethodError ())
+ System.Taffybar.Information.AnthropicUsage: [anthropicUsageWindowResetAt] :: AnthropicUsageWindow -> Maybe UTCTime
+ System.Taffybar.Information.CPU2: CPULoadSource :: TChan CPULoad -> IO () -> IO (IO ()) -> CPULoadSource
+ System.Taffybar.Information.CPU2: CPULoadSources :: MVar (Map CPULoadSourceKey CPULoadSource) -> CPULoadSources
+ System.Taffybar.Information.CPU2: FastRefreshActive :: !Int -> !ThreadId -> !WakeupSubscription -> FastRefreshState
+ System.Taffybar.Information.CPU2: FastRefreshInactive :: FastRefreshState
+ System.Taffybar.Information.CPU2: [acquireCPULoadFastRefresh] :: CPULoadSource -> IO (IO ())
+ System.Taffybar.Information.CPU2: [cpuLoadSourceChannel] :: CPULoadSource -> TChan CPULoad
+ System.Taffybar.Information.CPU2: [forceCPULoadRefresh] :: CPULoadSource -> IO ()
+ System.Taffybar.Information.CPU2: acquireFastRefresh :: WakeupManager -> Word64 -> TChan () -> MVar FastRefreshState -> IO (IO ())
+ System.Taffybar.Information.CPU2: cpuLoadLogPath :: String
+ System.Taffybar.Information.CPU2: createCPULoadSource :: WakeupManager -> String -> Word64 -> Word64 -> IO CPULoadSource
+ System.Taffybar.Information.CPU2: data CPULoadSource
+ System.Taffybar.Information.CPU2: data FastRefreshState
+ System.Taffybar.Information.CPU2: defaultCPUFastRefreshInterval :: Double -> Double
+ System.Taffybar.Information.CPU2: drainTChan :: TChan a -> STM ()
+ System.Taffybar.Information.CPU2: getCPULoadSource :: String -> Double -> Double -> TaffyIO CPULoadSource
+ System.Taffybar.Information.CPU2: intervalNanosecondsOrFail :: Double -> TaffyIO Word64
+ System.Taffybar.Information.CPU2: newtype CPULoadSources
+ System.Taffybar.Information.CPU2: type CPULoadSourceKey = (String, Word64, Word64)
+ System.Taffybar.Information.CPUFrequency: CPUFrequencyInfo :: Maybe Integer -> Maybe Integer -> Maybe Integer -> Int -> CPUFrequencyInfo
+ System.Taffybar.Information.CPUFrequency: [cpuFrequencyAverageKHz] :: CPUFrequencyInfo -> Maybe Integer
+ System.Taffybar.Information.CPUFrequency: [cpuFrequencyMaximumKHz] :: CPUFrequencyInfo -> Maybe Integer
+ System.Taffybar.Information.CPUFrequency: [cpuFrequencyMinimumKHz] :: CPUFrequencyInfo -> Maybe Integer
+ System.Taffybar.Information.CPUFrequency: [cpuFrequencySampleCount] :: CPUFrequencyInfo -> Int
+ System.Taffybar.Information.CPUFrequency: cpuFrequencyAverageGHz :: CPUFrequencyInfo -> Maybe Double
+ System.Taffybar.Information.CPUFrequency: cpuFrequencyMaximumGHz :: CPUFrequencyInfo -> Maybe Double
+ System.Taffybar.Information.CPUFrequency: cpuFrequencyMinimumGHz :: CPUFrequencyInfo -> Maybe Double
+ System.Taffybar.Information.CPUFrequency: data CPUFrequencyInfo
+ System.Taffybar.Information.CPUFrequency: getCPUFrequencyInfoChan :: Double -> TaffyIO (TChan CPUFrequencyInfo)
+ System.Taffybar.Information.CPUFrequency: getCPUFrequencyInfoState :: Double -> TaffyIO CPUFrequencyInfo
+ System.Taffybar.Information.CPUFrequency: instance GHC.Classes.Eq System.Taffybar.Information.CPUFrequency.CPUFrequencyInfo
+ System.Taffybar.Information.CPUFrequency: instance GHC.Internal.Show.Show System.Taffybar.Information.CPUFrequency.CPUFrequencyInfo
+ System.Taffybar.Information.CPUFrequency: readCPUFrequencyInfo :: IO CPUFrequencyInfo
+ System.Taffybar.Information.CPUFrequency: summarizeCPUFrequencies :: [Integer] -> CPUFrequencyInfo
+ System.Taffybar.Information.Memory: getMemoryInfoChan :: Double -> TaffyIO (TChan MemoryInfo)
+ System.Taffybar.Information.Memory: getMemoryInfoState :: Double -> TaffyIO MemoryInfo
+ System.Taffybar.Information.Memory: instance GHC.Classes.Eq System.Taffybar.Information.Memory.MemoryInfo
+ System.Taffybar.Information.Memory: instance GHC.Internal.Show.Show System.Taffybar.Information.Memory.MemoryInfo
+ System.Taffybar.Information.Nvidia: NvidiaGpuInfo :: !Int -> !Text -> !Maybe Double -> !Maybe Double -> !Maybe Double -> !Maybe Double -> !Maybe Double -> !Maybe Double -> !Maybe Double -> !Maybe Double -> !Maybe Double -> !Maybe Double -> !Maybe Double -> !Maybe Text -> NvidiaGpuInfo
+ System.Taffybar.Information.Nvidia: [nvidiaInfoFanSpeedPercent] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: [nvidiaInfoGpuUtilizationPercent] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: [nvidiaInfoIndex] :: NvidiaGpuInfo -> !Int
+ System.Taffybar.Information.Nvidia: [nvidiaInfoMemoryTemperatureCelsius] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: [nvidiaInfoMemoryTotalMiB] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: [nvidiaInfoMemoryUsedMiB] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: [nvidiaInfoMemoryUtilizationPercent] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: [nvidiaInfoName] :: NvidiaGpuInfo -> !Text
+ System.Taffybar.Information.Nvidia: [nvidiaInfoPerformanceState] :: NvidiaGpuInfo -> !Maybe Text
+ System.Taffybar.Information.Nvidia: [nvidiaInfoPowerDrawWatts] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: [nvidiaInfoPowerLimitWatts] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: [nvidiaInfoTargetTemperatureCelsius] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: [nvidiaInfoTemperatureCelsius] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: [nvidiaInfoThermalHeadroomCelsius] :: NvidiaGpuInfo -> !Maybe Double
+ System.Taffybar.Information.Nvidia: data NvidiaGpuInfo
+ System.Taffybar.Information.Nvidia: getNvidiaGpuInfoChan :: Double -> TaffyIO (TChan [NvidiaGpuInfo])
+ System.Taffybar.Information.Nvidia: getNvidiaGpuInfoChanWith :: FilePath -> Double -> TaffyIO (TChan [NvidiaGpuInfo])
+ System.Taffybar.Information.Nvidia: getNvidiaGpuInfoState :: Double -> TaffyIO [NvidiaGpuInfo]
+ System.Taffybar.Information.Nvidia: getNvidiaGpuInfoStateWith :: FilePath -> Double -> TaffyIO [NvidiaGpuInfo]
+ System.Taffybar.Information.Nvidia: instance GHC.Classes.Eq System.Taffybar.Information.Nvidia.NvidiaGpuInfo
+ System.Taffybar.Information.Nvidia: instance GHC.Internal.Show.Show System.Taffybar.Information.Nvidia.NvidiaGpuInfo
+ System.Taffybar.Information.Nvidia: parseNvidiaGpuInfo :: Text -> [NvidiaGpuInfo]
+ System.Taffybar.Information.Nvidia: readNvidiaGpuInfo :: IO [NvidiaGpuInfo]
+ System.Taffybar.Information.Nvidia: readNvidiaGpuInfoWith :: FilePath -> IO [NvidiaGpuInfo]
+ System.Taffybar.Information.Nvidia: shouldQueryNvidiaForRuntimeStatuses :: [Maybe Text] -> Bool
+ System.Taffybar.Information.Temperature: getTemperatureInfoChan :: Double -> TaffyIO (TChan [TemperatureInfo])
+ System.Taffybar.Information.Temperature: getTemperatureInfoState :: Double -> TaffyIO [TemperatureInfo]
+ System.Taffybar.Information.Wakeup: WakeupSubscription :: TChan WakeupEvent -> IO () -> WakeupSubscription
+ System.Taffybar.Information.Wakeup: [releaseWakeupSubscription] :: WakeupSubscription -> IO ()
+ System.Taffybar.Information.Wakeup: [wakeupSubscriptionChannel] :: WakeupSubscription -> TChan WakeupEvent
+ System.Taffybar.Information.Wakeup: data WakeupSubscription
+ System.Taffybar.Information.Wakeup: subscribeWakeupChannelForDelay :: RealFrac d => d -> TaffyIO WakeupSubscription
+ System.Taffybar.Widget.AnthropicUsage: [anthropicUsageStackLabelRenderer] :: AnthropicUsageStackConfig -> AnthropicUsageWindowSelector -> UsageWindowLabelParts -> Text
+ System.Taffybar.Widget.AnthropicUsage: anthropicUsageWindowLabelParts :: AnthropicUsageWindowSelector -> AnthropicUsageDisplayMode -> AnthropicUsageInfo -> UsageWindowLabelParts
+ System.Taffybar.Widget.AnthropicUsage: defaultAnthropicUsageWindowLabelRenderer :: AnthropicUsageWindowSelector -> UsageWindowLabelParts -> Text
+ System.Taffybar.Widget.Battery: formatBatteryInfo :: BatteryInfo -> String -> Text
+ System.Taffybar.Widget.CPUFrequency: CPUFrequencyWidgetConfig :: Double -> Text -> CPUFrequencyWidgetConfig
+ System.Taffybar.Widget.CPUFrequency: [cpuFrequencyIcon] :: CPUFrequencyWidgetConfig -> Text
+ System.Taffybar.Widget.CPUFrequency: [cpuFrequencyPollInterval] :: CPUFrequencyWidgetConfig -> Double
+ System.Taffybar.Widget.CPUFrequency: cpuFrequencyNew :: TaffyIO Widget
+ System.Taffybar.Widget.CPUFrequency: cpuFrequencyNewWithConfig :: CPUFrequencyWidgetConfig -> TaffyIO Widget
+ System.Taffybar.Widget.CPUFrequency: data CPUFrequencyWidgetConfig
+ System.Taffybar.Widget.CPUFrequency: defaultCPUFrequencyWidgetConfig :: CPUFrequencyWidgetConfig
+ System.Taffybar.Widget.CPUFrequency: instance Data.Default.Internal.Default System.Taffybar.Widget.CPUFrequency.CPUFrequencyWidgetConfig
+ System.Taffybar.Widget.CPUFrequency: instance GHC.Classes.Eq System.Taffybar.Widget.CPUFrequency.CPUFrequencyWidgetConfig
+ System.Taffybar.Widget.CPUFrequency: instance GHC.Internal.Show.Show System.Taffybar.Widget.CPUFrequency.CPUFrequencyWidgetConfig
+ System.Taffybar.Widget.CPUMonitor: cpuMonitorNewWithHover :: GraphConfig -> Double -> Double -> String -> TaffyIO Widget
+ System.Taffybar.Widget.NvidiaTemperature: nvidiaTemperatureLabelNewChan :: TaffyIO Widget
+ System.Taffybar.Widget.NvidiaTemperature: nvidiaTemperatureLabelNewChanWith :: NvidiaTemperatureConfig -> TaffyIO Widget
+ System.Taffybar.Widget.NvidiaTemperature: nvidiaTemperatureNewChan :: TaffyIO Widget
+ System.Taffybar.Widget.NvidiaTemperature: nvidiaTemperatureNewChanWith :: NvidiaTemperatureConfig -> TaffyIO Widget
+ System.Taffybar.Widget.OpenAIUsage: [openAIUsageStackLabelRenderer] :: OpenAIUsageStackConfig -> OpenAIUsageWindowSelector -> UsageWindowLabelParts -> Text
+ System.Taffybar.Widget.OpenAIUsage: defaultOpenAIUsageWindowLabelRenderer :: OpenAIUsageWindowSelector -> UsageWindowLabelParts -> Text
+ System.Taffybar.Widget.OpenAIUsage: openAIUsageWindowLabelParts :: OpenAIUsageWindowSelector -> OpenAIUsageDisplayMode -> OpenAIUsageInfo -> UsageWindowLabelParts
+ System.Taffybar.Widget.SNITray.PrioritizedCollapsible: HoverExpanded :: HoverPollState
+ System.Taffybar.Widget.SNITray.PrioritizedCollapsible: HoverIdle :: HoverPollState
+ System.Taffybar.Widget.SNITray.PrioritizedCollapsible: HoverPendingCollapse :: Int64 -> HoverPollState
+ System.Taffybar.Widget.SNITray.PrioritizedCollapsible: HoverPendingExpand :: Int64 -> HoverPollState
+ System.Taffybar.Widget.SNITray.PrioritizedCollapsible: data HoverPollState
+ System.Taffybar.Widget.SNITray.PrioritizedCollapsible: pointerWithinWidget :: IsWidget w => w -> IO Bool
+ System.Taffybar.Widget.Temperature: [tempTooltipSensorFilter] :: TemperatureConfig -> ThermalSensor -> Bool
+ System.Taffybar.Widget.Temperature: temperatureLabelNewChan :: TaffyIO Widget
+ System.Taffybar.Widget.Temperature: temperatureLabelNewChanWith :: TemperatureConfig -> TaffyIO Widget
+ System.Taffybar.Widget.Temperature: temperatureNewChan :: TaffyIO Widget
+ System.Taffybar.Widget.Temperature: temperatureNewChanWith :: TemperatureConfig -> TaffyIO Widget
+ System.Taffybar.Widget.Util: UsageWindowLabelParts :: Text -> Text -> Maybe UsageWindowPosition -> UsageWindowLabelParts
+ System.Taffybar.Widget.Util: UsageWindowPosition :: Int -> Int -> UsageWindowPosition
+ System.Taffybar.Widget.Util: [usageWindowLabelName] :: UsageWindowLabelParts -> Text
+ System.Taffybar.Widget.Util: [usageWindowLabelPosition] :: UsageWindowLabelParts -> Maybe UsageWindowPosition
+ System.Taffybar.Widget.Util: [usageWindowLabelValue] :: UsageWindowLabelParts -> Text
+ System.Taffybar.Widget.Util: [usageWindowPositionCurrent] :: UsageWindowPosition -> Int
+ System.Taffybar.Widget.Util: [usageWindowPositionTotal] :: UsageWindowPosition -> Int
+ System.Taffybar.Widget.Util: data UsageWindowLabelParts
+ System.Taffybar.Widget.Util: data UsageWindowPosition
+ System.Taffybar.Widget.Util: instance GHC.Classes.Eq System.Taffybar.Widget.Util.UsageWindowLabelParts
+ System.Taffybar.Widget.Util: instance GHC.Classes.Eq System.Taffybar.Widget.Util.UsageWindowPosition
+ System.Taffybar.Widget.Util: instance GHC.Internal.Show.Show System.Taffybar.Widget.Util.UsageWindowLabelParts
+ System.Taffybar.Widget.Util: instance GHC.Internal.Show.Show System.Taffybar.Widget.Util.UsageWindowPosition
- System.Taffybar.Information.ASUS: ASUSInfo :: ASUSPlatformProfile -> Double -> Double -> ASUSInfo
+ System.Taffybar.Information.ASUS: ASUSInfo :: ASUSPlatformProfile -> ASUSPlatformProfile -> ASUSPlatformProfile -> Bool -> Double -> Double -> ASUSInfo
- System.Taffybar.Information.AnthropicUsage: AnthropicUsageWindow :: Text -> UTCTime -> UTCTime -> Maybe Int -> Maybe Double -> AnthropicUsageTotals -> AnthropicUsageWindow
+ System.Taffybar.Information.AnthropicUsage: AnthropicUsageWindow :: Text -> UTCTime -> UTCTime -> Maybe UTCTime -> Maybe Int -> Maybe Double -> AnthropicUsageTotals -> AnthropicUsageWindow
- System.Taffybar.Widget.AnthropicUsage: AnthropicUsageStackConfig :: AnthropicUsageConfig -> AnthropicUsageDisplayMode -> Text -> AnthropicUsageStackConfig
+ System.Taffybar.Widget.AnthropicUsage: AnthropicUsageStackConfig :: AnthropicUsageConfig -> AnthropicUsageDisplayMode -> Text -> (AnthropicUsageWindowSelector -> UsageWindowLabelParts -> Text) -> AnthropicUsageStackConfig
- System.Taffybar.Widget.OpenAIUsage: OpenAIUsageStackConfig :: OpenAIUsageConfig -> OpenAIUsageDisplayMode -> Text -> OpenAIUsageStackConfig
+ System.Taffybar.Widget.OpenAIUsage: OpenAIUsageStackConfig :: OpenAIUsageConfig -> OpenAIUsageDisplayMode -> Text -> (OpenAIUsageWindowSelector -> UsageWindowLabelParts -> Text) -> OpenAIUsageStackConfig
- System.Taffybar.Widget.Temperature: TemperatureConfig :: String -> TemperatureUnit -> Double -> Double -> Double -> (ThermalSensor -> Bool) -> ([TemperatureInfo] -> Maybe Double) -> Text -> TemperatureConfig
+ System.Taffybar.Widget.Temperature: TemperatureConfig :: String -> TemperatureUnit -> Double -> Double -> Double -> (ThermalSensor -> Bool) -> (ThermalSensor -> Bool) -> ([TemperatureInfo] -> Maybe Double) -> Text -> TemperatureConfig

Files

CHANGELOG.md view
@@ -1,3 +1,55 @@+# 7.2.7++## Features++* Add shared channel-backed producers and channel-driven widget variants for+  CPU load, CPU frequency, memory, sysfs temperature, and NVIDIA telemetry.+* Add temporary coordinated wakeup subscriptions and let CPU graphs acquire a+  fast shared sampling cadence while hovered, releasing it when the pointer+  leaves.+* Add a configurable CPU-frequency information source and compact widget with+  average, range, and policy-count reporting.+* Enrich NVIDIA temperature tooltips with target and memory temperatures,+  thermal headroom, utilization, VRAM, power, fan, and performance-state+  readings. Sysfs temperature widgets can now include additional sensors in+  the tooltip without changing the compact label's aggregation.+* Add separate ASUS AC and battery power-profile controls and combine their+  state into one menu.+* Show the current day of Claude's 7-day usage window in the Anthropic usage+  widget when the OAuth endpoint reports a reset timestamp.+* Show the current day of Codex's 7-day usage window as a compact @N/7d@ value in+  the OpenAI usage widget when the endpoint reports an authoritative reset.+* Let OpenAI and Anthropic usage stacks render structured window-label parts+  with caller-supplied functions, so configurations can control ordering and+  separators without duplicating widget or usage-calculation internals.+* Allow text battery widgets to display UPower's current energy rate with the+  @$watts$@ placeholder or as battery input/output with @$signedWatts$@, and+  refresh the value on UPower sampling updates.+* Add polling-driven hover expansion and styling refinements to the prioritized+  StatusNotifier tray.++## Performance++* Slow hardware telemetry defaults, share producers across widgets, align+  periodic work through the coordinated wakeup scheduler, and suppress+  unchanged channel updates.+* Avoid invoking `nvidia-smi` while every detected NVIDIA device is+  runtime-suspended.+* Wait for wlsunset process-exit events instead of repeatedly polling process+  state while it is running.++## Fixes++* Show Codex's temporarily disabled 5-hour usage limit as unlimited while+  keeping its remaining 7-day window in the weekly row.+* Tolerate unreadable or concurrently replaced Anthropic transcript and+  credential files.++## Packaging++* Require `dbus-menu >= 0.1.3.4` for refresh reconciliation that preserves GTK+  menu items across asynchronous layout updates.+ # 7.2.6  ## Features
src/System/Taffybar/Information/ASUS.hs view
@@ -24,15 +24,18 @@     getASUSInfoState,     cycleASUSProfile,     setASUSProfile,+    setASUSACProfile,+    setASUSBatteryProfile,     asusProfileToString,     asusProfileFromUInt,+    asusProfileToUInt,   ) where  import Control.Concurrent.MVar import Control.Concurrent.STM.TChan import Control.Exception (SomeException, try)-import Control.Monad (forM, forever, void)+import Control.Monad (forM, forever, void, when) import Control.Monad.IO.Class import Control.Monad.STM (atomically) import Control.Monad.Trans.Class@@ -52,6 +55,10 @@ import System.FilePath ((</>)) import System.Log.Logger import System.Taffybar.Context+import System.Taffybar.Information.CPUFrequency+  ( cpuFrequencyAverageGHz,+    readCPUFrequencyInfo,+  ) import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay) import System.Taffybar.Util (logPrintF, maybeToEither) import Text.Read (readMaybe)@@ -63,6 +70,12 @@ -- | Combined ASUS platform info with CPU state. data ASUSInfo = ASUSInfo   { asusProfile :: ASUSPlatformProfile,+    -- | Profile selected automatically while connected to AC power+    asusACProfile :: ASUSPlatformProfile,+    -- | Profile selected automatically while running on battery power+    asusBatteryProfile :: ASUSPlatformProfile,+    -- | Whether the machine is currently connected to AC power+    asusOnACPower :: Bool,     -- | Average CPU frequency across all cores     asusCpuFreqGHz :: Double,     -- | CPU package temperature in Celsius@@ -87,6 +100,9 @@ asusLogF :: (MonadIO m, Show t) => Priority -> String -> t -> m () asusLogF = logPrintF asusLogPath +defaultASUSPollIntervalSeconds :: Double+defaultASUSPollIntervalSeconds = 10+ readAsciiFileStrict :: FilePath -> IO String readAsciiFileStrict = fmap BS8.unpack . BS8.readFile @@ -96,23 +112,26 @@ asusProfileToString Balanced = "Balanced" asusProfileToString Performance = "Performance" --- | Parse profile from asusd uint32: 0=Quiet, 1=Performance, 2=Balanced.+-- | Parse profile from asusd uint32: 0=Balanced, 1=Performance, 2=Quiet. asusProfileFromUInt :: Word32 -> Maybe ASUSPlatformProfile-asusProfileFromUInt 0 = Just Quiet+asusProfileFromUInt 0 = Just Balanced asusProfileFromUInt 1 = Just Performance-asusProfileFromUInt 2 = Just Balanced+asusProfileFromUInt 2 = Just Quiet asusProfileFromUInt _ = Nothing  asusProfileToUInt :: ASUSPlatformProfile -> Word32-asusProfileToUInt Quiet = 0+asusProfileToUInt Balanced = 0 asusProfileToUInt Performance = 1-asusProfileToUInt Balanced = 2+asusProfileToUInt Quiet = 2  -- | Default info when asusd is unavailable. unknownASUSInfo :: ASUSInfo unknownASUSInfo =   ASUSInfo     { asusProfile = Balanced,+      asusACProfile = Performance,+      asusBatteryProfile = Balanced,+      asusOnACPower = False,       asusCpuFreqGHz = 0,       asusCpuTempC = 0     }@@ -139,25 +158,34 @@       maybeToEither dummyMethodError $         listToMaybe (methodReturnBody reply) >>= fromVariant --- | Read current platform profile from DBus.-readProfileFromClient :: Client -> IO ASUSPlatformProfile-readProfileFromClient client = do+-- | Read the live, AC, and battery platform profiles from DBus.+readProfilesFromClient ::+  Client ->+  IO (ASUSPlatformProfile, ASUSPlatformProfile, ASUSPlatformProfile)+readProfilesFromClient client = do   propsResult <- getProperties client   case propsResult of     Left err -> do       asusLogF WARNING "Failed to read ASUS properties: %s" err-      return Balanced-    Right props ->-      let profileVal = readDictMaybe props "PlatformProfile" :: Maybe Word32-       in return $ fromMaybe Balanced (profileVal >>= asusProfileFromUInt)+      return (Balanced, Performance, Balanced)+    Right props -> do+      let readProfile key fallback =+            fromMaybe fallback $+              (readDictMaybe props key :: Maybe Word32) >>= asusProfileFromUInt+      return+        ( readProfile "PlatformProfile" Balanced,+          readProfile "PlatformProfileOnAc" Performance,+          readProfile "PlatformProfileOnBattery" Balanced+        ) --- | Set the ASUS platform profile via DBus property.-setASUSProfile :: Client -> ASUSPlatformProfile -> IO (Either MethodError ())-setASUSProfile client profile = do+-- | Set one of the ASUS platform profile properties.+setASUSProfileProperty ::+  Client -> MemberName -> ASUSPlatformProfile -> IO (Either MethodError ())+setASUSProfileProperty client propertyMember profile = do   result <-     setProperty       client-      (methodCall asusObjectPath asusInterfaceName "PlatformProfile")+      (methodCall asusObjectPath asusInterfaceName propertyMember)         { methodCallDestination = Just asusBusName         }       (toVariant (asusProfileToUInt profile))@@ -165,6 +193,19 @@     Left err -> Left err     Right _ -> Right () +-- | Set the ASUS platform profile via DBus property.+setASUSProfile :: Client -> ASUSPlatformProfile -> IO (Either MethodError ())+setASUSProfile client = setASUSProfileProperty client "PlatformProfile"++-- | Set the profile that asusd applies while connected to AC power.+setASUSACProfile :: Client -> ASUSPlatformProfile -> IO (Either MethodError ())+setASUSACProfile client = setASUSProfileProperty client "PlatformProfileOnAc"++-- | Set the profile that asusd applies while running on battery power.+setASUSBatteryProfile :: Client -> ASUSPlatformProfile -> IO (Either MethodError ())+setASUSBatteryProfile client =+  setASUSProfileProperty client "PlatformProfileOnBattery"+ -- | Cycle to the next profile by calling the NextPlatformProfile method. cycleASUSProfile :: Client -> IO (Either MethodError ()) cycleASUSProfile client = do@@ -177,38 +218,30 @@     Left err -> Left err     Right _ -> Right () --- sysfs CPU frequency reading---- | Read average CPU frequency in GHz from sysfs.-readCpuFreqGHz :: IO Double-readCpuFreqGHz = do-  let cpuDir = "/sys/devices/system/cpu"-  exists <- doesDirectoryExist cpuDir+-- | Check whether any mains power supply is currently online.+readOnACPower :: IO Bool+readOnACPower = do+  let powerSupplyDir = "/sys/class/power_supply"+  exists <- doesDirectoryExist powerSupplyDir   if not exists-    then return 0+    then return False     else do-      entries <- listDirectory cpuDir-      let cpuDirs = filter isCpuDir entries-      freqs <- forM cpuDirs $ \cpu -> do-        let freqPath = cpuDir </> cpu </> "cpufreq" </> "scaling_cur_freq"-        readFreqFile freqPath-      let validFreqs = catMaybes freqs-      if null validFreqs-        then return 0-        else return $ (sum validFreqs / fromIntegral (length validFreqs)) / 1_000_000+      entries <- listDirectory powerSupplyDir+      statuses <- forM entries $ \entry -> do+        let supplyDir = powerSupplyDir </> entry+        supplyType <- readPowerSupplyFile $ supplyDir </> "type"+        online <- readPowerSupplyFile $ supplyDir </> "online"+        return $ supplyType == Just "Mains" && online == Just "1"+      return $ or statuses   where-    isCpuDir name =-      take 3 name == "cpu" && all (`elem` ("0123456789" :: String)) (drop 3 name)-    readFreqFile path = do+    readPowerSupplyFile path = do       exists' <- doesFileExist path       if not exists'         then return Nothing         else do           result <- try $ readAsciiFileStrict path :: IO (Either SomeException String)-          case result of-            Left _ -> return Nothing-            Right s -> return $ fmap fromIntegral (readMaybe (strip s) :: Maybe Integer)-    strip = dropWhile (== ' ') . reverse . dropWhile (== '\n') . reverse . dropWhile (== ' ')+          return $ either (const Nothing) (Just . strip) result+    strip = reverse . dropWhile (`elem` [' ', '\n', '\r', '\t']) . reverse  -- sysfs CPU temperature reading @@ -269,13 +302,25 @@ -- | Get current ASUS info from a DBus client. getASUSInfoFromClient :: Client -> IO ASUSInfo getASUSInfoFromClient client = do-  profile <- readProfileFromClient client-  freq <- readCpuFreqGHz+  (profile, acProfile, batteryProfile) <- readProfilesFromClient client+  readASUSTelemetry+    unknownASUSInfo+      { asusProfile = profile,+        asusACProfile = acProfile,+        asusBatteryProfile = batteryProfile+      }++-- | Refresh telemetry that has no event interface while preserving the+-- profile values maintained by the asusd PropertiesChanged subscription.+readASUSTelemetry :: ASUSInfo -> IO ASUSInfo+readASUSTelemetry old = do+  onACPower <- readOnACPower+  frequencyInfo <- readCPUFrequencyInfo   temp <- readCpuTempC   return-    ASUSInfo-      { asusProfile = profile,-        asusCpuFreqGHz = freq,+    old+      { asusOnACPower = onACPower,+        asusCpuFreqGHz = fromMaybe 0 $ cpuFrequencyAverageGHz frequencyInfo,         asusCpuTempC = temp       } @@ -304,20 +349,20 @@ monitorASUSInfo = do   infoVar <- lift $ newMVar unknownASUSInfo   chan <- liftIO newBroadcastTChanIO-  wakeupChan <- getWakeupChannelForDelay (2 :: Double)+  wakeupChan <- getWakeupChannelForDelay defaultASUSPollIntervalSeconds   ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan   taffyFork $ do     ctx <- ask-    let updateInfo = updateASUSInfo chan infoVar-        signalCallback _ _ _ _ = runReaderT updateInfo ctx+    let updateProfiles = updateASUSInfo chan infoVar+        signalCallback _ _ _ _ = runReaderT updateProfiles ctx         waitForNextPoll = void $ atomically $ readTChan ourWakeupChan     _ <- registerForASUSPropertiesChanged signalCallback     -- Do an initial update-    updateInfo-    -- Then poll every 2 seconds for CPU freq/temp changes+    updateProfiles+    -- Profile changes are subscription-driven. Only sysfs telemetry is polled.     lift $ forever $ do       waitForNextPoll-      runReaderT updateInfo ctx+      updateASUSTelemetry chan infoVar   return (chan, infoVar)  registerForASUSPropertiesChanged ::@@ -340,6 +385,26 @@   TaffyIO () updateASUSInfo chan var = do   info <- getASUSInfo-  lift $ do-    void $ swapMVar var info-    atomically $ writeTChan chan info+  lift $ publishASUSInfoIfChanged chan var info++updateASUSTelemetry :: TChan ASUSInfo -> MVar ASUSInfo -> IO ()+updateASUSTelemetry chan var = do+  telemetry <- readASUSTelemetry =<< readMVar var+  modifyMVar_ var $ \latest -> do+    let info =+          latest+            { asusOnACPower = asusOnACPower telemetry,+              asusCpuFreqGHz = asusCpuFreqGHz telemetry,+              asusCpuTempC = asusCpuTempC telemetry+            }+    publishAgainst latest info+  where+    publishAgainst old info = do+      when (info /= old) $ atomically $ writeTChan chan info+      pure info++publishASUSInfoIfChanged :: TChan ASUSInfo -> MVar ASUSInfo -> ASUSInfo -> IO ()+publishASUSInfoIfChanged chan var info =+  modifyMVar_ var $ \old -> do+    when (info /= old) $ atomically $ writeTChan chan info+    pure info
src/System/Taffybar/Information/AnthropicUsage.hs view
@@ -124,6 +124,10 @@   { anthropicUsageWindowName :: T.Text,     anthropicUsageWindowStart :: UTCTime,     anthropicUsageWindowEnd :: UTCTime,+    -- | Authoritative reset time reported by the OAuth usage endpoint. The+    -- fallback transcript windows synthesize an end time, but do not claim to+    -- know when Claude's server-side limit resets.+    anthropicUsageWindowResetAt :: Maybe UTCTime,     anthropicUsageWindowBudgetTokens :: Maybe Int,     -- | Used percentage reported by the OAuth usage endpoint, when available.     anthropicUsageWindowUtilizationPercent :: Maybe Double,@@ -576,9 +580,9 @@ findScopedWeeklyLimit usage =   listToMaybe     [ limit-      | limit <- anthropicOAuthLimits usage,-        anthropicOAuthLimitKind limit == Just "weekly_scoped",-        Just _ <- [anthropicOAuthLimitScopeModel limit]+    | limit <- anthropicOAuthLimits usage,+      anthropicOAuthLimitKind limit == Just "weekly_scoped",+      Just _ <- [anthropicOAuthLimitScopeModel limit]     ]  -- | Build a display window for a per-model weekly limit. There is no@@ -593,6 +597,7 @@       anthropicUsageWindowStart =         maybe now (addUTCTime (negate weekSeconds)) resetsAt,       anthropicUsageWindowEnd = fromMaybe (addUTCTime weekSeconds now) resetsAt,+      anthropicUsageWindowResetAt = resetsAt,       anthropicUsageWindowBudgetTokens = Nothing,       anthropicUsageWindowUtilizationPercent = anthropicOAuthLimitPercent limit,       anthropicUsageWindowTotals = mempty@@ -607,18 +612,30 @@   window     { anthropicUsageWindowUtilizationPercent = anthropicOAuthWindowUtilization oauthWindow,       anthropicUsageWindowEnd =-        fromMaybe (anthropicUsageWindowEnd window) (anthropicOAuthWindowResetsAt oauthWindow)+        fromMaybe (anthropicUsageWindowEnd window) (anthropicOAuthWindowResetsAt oauthWindow),+      anthropicUsageWindowResetAt = anthropicOAuthWindowResetsAt oauthWindow     } +-- | Decode a JSON file, treating an unreadable or unparseable file the same+-- as an absent one. Claude Code rewrites its state files in place, so a poll+-- can catch them mid-write; a failed read of optional metadata must degrade+-- to 'Nothing' rather than throw and blank the whole usage snapshot. decodeFileIfExists :: (FromJSON a) => FilePath -> IO (Maybe a) decodeFileIfExists path = do   exists <- doesFileExist path   if exists     then do-      bytes <- LBS.readFile path-      case eitherDecode bytes of-        Right value -> return $ Just value-        Left err -> fail $ "Unable to parse " <> path <> ": " <> err+      result <- try $ do+        bytes <- LBS.readFile path+        evaluate $ eitherDecode bytes+      case result of+        Right (Right value) -> return $ Just value+        Right (Left err) -> do+          logM logName WARNING $ "Unable to parse " <> path <> ": " <> err+          return Nothing+        Left (err :: SomeException) -> do+          logM logName WARNING $ "Unable to read " <> path <> ": " <> show err+          return Nothing     else return Nothing  defaultClaudeStatePath :: IO FilePath@@ -767,6 +784,7 @@         { anthropicUsageWindowName = name,           anthropicUsageWindowStart = now,           anthropicUsageWindowEnd = addUTCTime duration now,+          anthropicUsageWindowResetAt = Nothing,           anthropicUsageWindowBudgetTokens = budget,           anthropicUsageWindowUtilizationPercent = Nothing,           anthropicUsageWindowTotals = mempty@@ -776,6 +794,7 @@         { anthropicUsageWindowName = name,           anthropicUsageWindowStart = start,           anthropicUsageWindowEnd = addUTCTime duration start,+          anthropicUsageWindowResetAt = Nothing,           anthropicUsageWindowBudgetTokens = budget,           anthropicUsageWindowUtilizationPercent = Nothing,           anthropicUsageWindowTotals = foldMap transcriptUsageTotals blockEntries@@ -813,6 +832,7 @@         { anthropicUsageWindowName = name,           anthropicUsageWindowStart = windowStart,           anthropicUsageWindowEnd = windowEnd,+          anthropicUsageWindowResetAt = Nothing,           anthropicUsageWindowBudgetTokens = budget,           anthropicUsageWindowUtilizationPercent = Nothing,           anthropicUsageWindowTotals = foldMap transcriptUsageTotals windowEntries
src/System/Taffybar/Information/Backlight.hs view
@@ -32,7 +32,7 @@ import Control.Concurrent.MVar import Control.Concurrent.STM.TChan import Control.Exception (SomeException, bracket, catch, try)-import Control.Monad (forever, void)+import Control.Monad (forever, void, when) import Control.Monad.IO.Class (liftIO) import Control.Monad.STM (atomically) import Data.List (sort, sortBy)@@ -60,7 +60,7 @@ backlightBasePath = "/sys/class/backlight"  defaultBacklightRefreshIntervalSeconds :: Double-defaultBacklightRefreshIntervalSeconds = 2+defaultBacklightRefreshIntervalSeconds = 30  -- | Information about a backlight device. data BacklightInfo = BacklightInfo@@ -175,8 +175,8 @@       intervalMicros = max 1 (floor (intervalSeconds * 1000000))        writeInfo info = do-        _ <- swapMVar var info-        atomically $ writeTChan chan info+        old <- swapMVar var info+        when (info /= old) $ atomically $ writeTChan chan info        refresh = getBacklightInfo deviceOverride >>= writeInfo 
src/System/Taffybar/Information/Battery.hs view
@@ -198,7 +198,8 @@  -- | Default set of UPower properties that trigger display-battery refreshes. defaultMonitorDisplayBatteryProperties :: [String]-defaultMonitorDisplayBatteryProperties = ["IconName", "State", "Percentage"]+defaultMonitorDisplayBatteryProperties =+  ["IconName", "State", "Percentage", "EnergyRate", "UpdateTime"]  -- | Start the monitoring of the display battery, and setup the associated -- channel and mvar for the current state.
src/System/Taffybar/Information/CPU2.hs view
@@ -20,20 +20,34 @@ -- (Now supports only physical cpu). module System.Taffybar.Information.CPU2 where -import Control.Concurrent (forkIO)+import Control.Concurrent (ThreadId, forkIO, killThread)+import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar)+import Control.Concurrent.STM (STM, orElse) import Control.Concurrent.STM.TChan import Control.Monad import Control.Monad.IO.Class (liftIO) import Control.Monad.STM (atomically)+import Control.Monad.Trans.Reader (asks) import Data.IORef import Data.List+import qualified Data.Map.Strict as M import Data.Maybe+import Data.Word (Word64) import Safe import System.Directory import System.FilePath-import System.Taffybar.Context (TaffyIO)+import System.Log.Logger (Priority (DEBUG), logM)+import System.Taffybar.Context (TaffyIO, getStateDefault, wakeupManager) import System.Taffybar.Information.StreamInfo-import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay)+import System.Taffybar.Information.Wakeup+  ( WakeupSubscription (..),+    intervalSecondsToNanoseconds,+  )+import System.Taffybar.Information.Wakeup.Manager+  ( WakeupManager,+    registerWakeupInterval,+    subscribeWakeupInterval,+  ) import Text.Read (readMaybe)  -- | Relative CPU load values, expressed as ratios in [0,1].@@ -75,26 +89,132 @@ sampleCPULoad :: Double -> String -> IO CPULoad sampleCPULoad interval cpu = toCPULoad <$> getLoad interval (getCPUInfo cpu) --- | Build a broadcast channel that is fed by a polling thread.+-- | A shared CPU information producer with an optional temporary fast cadence.+-- The fast cadence still comes from the coordinated wakeup scheduler and is+-- removed entirely when its final lease is released.+data CPULoadSource = CPULoadSource+  { cpuLoadSourceChannel :: TChan CPULoad,+    forceCPULoadRefresh :: IO (),+    acquireCPULoadFastRefresh :: IO (IO ())+  }++type CPULoadSourceKey = (String, Word64, Word64)++newtype CPULoadSources+  = CPULoadSources (MVar (M.Map CPULoadSourceKey CPULoadSource))++data FastRefreshState+  = FastRefreshInactive+  | FastRefreshActive !Int !ThreadId !WakeupSubscription++cpuLoadLogPath :: String+cpuLoadLogPath = "System.Taffybar.Information.CPU2"++defaultCPUFastRefreshInterval :: Double -> Double+defaultCPUFastRefreshInterval baseInterval = min baseInterval 0.5++-- | Return a process-wide CPU source keyed by CPU name and both cadences. The+-- normal cadence is permanently registered; the fast cadence is only active+-- while at least one caller holds a lease from 'acquireCPULoadFastRefresh'.+getCPULoadSource :: String -> Double -> Double -> TaffyIO CPULoadSource+getCPULoadSource cpu baseInterval fastInterval = do+  baseNs <- intervalNanosecondsOrFail baseInterval+  fastNs <- intervalNanosecondsOrFail fastInterval+  manager <- asks wakeupManager+  CPULoadSources sourcesVar <-+    getStateDefault $ liftIO $ CPULoadSources <$> newMVar M.empty+  liftIO $+    modifyMVar sourcesVar $ \sources ->+      case M.lookup (cpu, baseNs, fastNs) sources of+        Just source -> pure (sources, source)+        Nothing -> do+          source <- createCPULoadSource manager cpu baseNs fastNs+          pure (M.insert (cpu, baseNs, fastNs) source sources, source)++intervalNanosecondsOrFail :: Double -> TaffyIO Word64+intervalNanosecondsOrFail interval =+  case intervalSecondsToNanoseconds (max 0.000001 interval) of+    Left err -> fail err+    Right intervalNs -> pure intervalNs++createCPULoadSource :: WakeupManager -> String -> Word64 -> Word64 -> IO CPULoadSource+createCPULoadSource manager cpu baseIntervalNs fastIntervalNs = do+  baseChannel <- registerWakeupInterval manager baseIntervalNs+  ourBaseChannel <- atomically $ dupTChan baseChannel+  refreshChannel <- newTChanIO+  outputChannel <- newBroadcastTChanIO+  initial <- getCPUInfo cpu+  sampleRef <- newIORef initial+  fastStateVar <- newMVar FastRefreshInactive+  void $ forkIO $ forever $ do+    atomically $ do+      void (readTChan ourBaseChannel) `orElse` void (readTChan refreshChannel)+      drainTChan ourBaseChannel+      drainTChan refreshChannel+    load <- toCPULoad <$> getAccLoad sampleRef (getCPUInfo cpu)+    atomically $ writeTChan outputChannel load+  let forceRefresh = atomically $ writeTChan refreshChannel ()+  pure+    CPULoadSource+      { cpuLoadSourceChannel = outputChannel,+        forceCPULoadRefresh = forceRefresh,+        acquireCPULoadFastRefresh = do+          release <-+            if fastIntervalNs >= baseIntervalNs+              then pure $ pure ()+              else acquireFastRefresh manager fastIntervalNs refreshChannel fastStateVar+          forceRefresh+          pure release+      }++acquireFastRefresh :: WakeupManager -> Word64 -> TChan () -> MVar FastRefreshState -> IO (IO ())+acquireFastRefresh manager intervalNs refreshChannel stateVar = do+  modifyMVar_ stateVar $ \state ->+    case state of+      FastRefreshActive count threadId subscription ->+        pure $ FastRefreshActive (count + 1) threadId subscription+      FastRefreshInactive -> do+        logM cpuLoadLogPath DEBUG $ "Acquiring fast CPU refresh lease at " <> show intervalNs <> "ns"+        subscription <- subscribeWakeupInterval manager intervalNs+        ourChannel <- atomically $ dupTChan $ wakeupSubscriptionChannel subscription+        threadId <-+          forkIO $+            forever $+              atomically (readTChan ourChannel)+                >> atomically (writeTChan refreshChannel ())+        pure $ FastRefreshActive 1 threadId subscription+  releasedRef <- newIORef False+  pure $ do+    shouldRelease <- atomicModifyIORef' releasedRef $ \released -> (True, not released)+    when shouldRelease $+      modifyMVar_ stateVar $ \state ->+        case state of+          FastRefreshInactive -> pure FastRefreshInactive+          FastRefreshActive count threadId subscription+            | count > 1 -> pure $ FastRefreshActive (count - 1) threadId subscription+            | otherwise -> do+                logM cpuLoadLogPath DEBUG $ "Releasing fast CPU refresh lease at " <> show intervalNs <> "ns"+                killThread threadId+                releaseWakeupSubscription subscription+                pure FastRefreshInactive++drainTChan :: TChan a -> STM ()+drainTChan chan = do+  next <- tryReadTChan chan+  case next of+    Nothing -> pure ()+    Just _ -> drainTChan chan++-- | Build a broadcast channel that is fed by a shared polling producer. -- -- The polling thread is paced by the coordinated wakeup scheduler so CPU -- sampling aligns with other interval-driven widgets. ----- Each channel has its own sampling thread; if multiple widgets should share a--- data source, create once and reuse the returned channel.+-- Repeated calls with the same CPU and interval reuse one producer. getCPULoadChan :: String -> Double -> TaffyIO (TChan CPULoad) getCPULoadChan cpu interval = do-  wakeupChan <- getWakeupChannelForDelay (max 0.000001 interval)-  ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan-  liftIO $ do-    chan <- newBroadcastTChanIO-    initial <- getCPUInfo cpu-    sample <- newIORef initial-    _ <- forkIO $ forever $ do-      load <- toCPULoad <$> getAccLoad sample (getCPUInfo cpu)-      atomically $ writeTChan chan load-      void $ atomically $ readTChan ourWakeupChan-    return chan+  source <- getCPULoadSource cpu interval (defaultCPUFastRefreshInterval interval)+  pure $ cpuLoadSourceChannel source  toCPULoad :: [Double] -> CPULoad toCPULoad load =
+ src/System/Taffybar/Information/CPUFrequency.hs view
@@ -0,0 +1,203 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      : System.Taffybar.Information.CPUFrequency+-- Copyright   : (c) Ivan A. Malison+-- License     : BSD3-style (see LICENSE)+--+-- Generic Linux CPU-frequency information backed by one shared poller per+-- Taffybar process. Widgets on separate monitor bars reuse the same cached+-- snapshot and broadcast channel through the shared 'Context'.+module System.Taffybar.Information.CPUFrequency+  ( CPUFrequencyInfo (..),+    cpuFrequencyAverageGHz,+    cpuFrequencyMinimumGHz,+    cpuFrequencyMaximumGHz,+    summarizeCPUFrequencies,+    readCPUFrequencyInfo,+    getCPUFrequencyInfoChan,+    getCPUFrequencyInfoState,+  )+where++import Control.Concurrent (forkIO)+import Control.Concurrent.MVar (MVar, newMVar, readMVar, swapMVar)+import Control.Concurrent.STM.TChan (TChan, dupTChan, newBroadcastTChanIO, readTChan, writeTChan)+import Control.Exception (SomeException, try)+import Control.Monad (filterM, forever, void, when)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.STM (atomically)+import qualified Data.ByteString.Char8 as BS8+import Data.List (isPrefixOf, sort)+import Data.Maybe (catMaybes, mapMaybe)+import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)+import System.FilePath ((</>))+import System.Taffybar.Context (TaffyIO, getStateDefault)+import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay)+import Text.Read (readMaybe)++-- | A frequency snapshot in kHz, matching Linux's cpufreq sysfs units.+data CPUFrequencyInfo = CPUFrequencyInfo+  { cpuFrequencyAverageKHz :: Maybe Integer,+    cpuFrequencyMinimumKHz :: Maybe Integer,+    cpuFrequencyMaximumKHz :: Maybe Integer,+    cpuFrequencySampleCount :: Int+  }+  deriving (Eq, Show)++emptyCPUFrequencyInfo :: CPUFrequencyInfo+emptyCPUFrequencyInfo = CPUFrequencyInfo Nothing Nothing Nothing 0++cpuFrequencyAverageGHz :: CPUFrequencyInfo -> Maybe Double+cpuFrequencyAverageGHz = fmap kHzToGHz . cpuFrequencyAverageKHz++cpuFrequencyMinimumGHz :: CPUFrequencyInfo -> Maybe Double+cpuFrequencyMinimumGHz = fmap kHzToGHz . cpuFrequencyMinimumKHz++cpuFrequencyMaximumGHz :: CPUFrequencyInfo -> Maybe Double+cpuFrequencyMaximumGHz = fmap kHzToGHz . cpuFrequencyMaximumKHz++kHzToGHz :: Integer -> Double+kHzToGHz value = fromIntegral value / 1_000_000++-- | Summarize a set of per-policy or per-core frequency readings.+summarizeCPUFrequencies :: [Integer] -> CPUFrequencyInfo+summarizeCPUFrequencies [] = emptyCPUFrequencyInfo+summarizeCPUFrequencies values =+  CPUFrequencyInfo+    { cpuFrequencyAverageKHz = Just $ sum values `div` fromIntegral (length values),+      cpuFrequencyMinimumKHz = Just $ minimum values,+      cpuFrequencyMaximumKHz = Just $ maximum values,+      cpuFrequencySampleCount = length values+    }++-- | Read current frequencies from the generic Linux cpufreq interface.+-- Falls back to @/proc/cpuinfo@ on systems without cpufreq sysfs entries.+readCPUFrequencyInfo :: IO CPUFrequencyInfo+readCPUFrequencyInfo = do+  result <- try readCPUFrequencyInfoUnsafe+  pure $ either (const emptyCPUFrequencyInfo) id (result :: Either SomeException CPUFrequencyInfo)++readCPUFrequencyInfoUnsafe :: IO CPUFrequencyInfo+readCPUFrequencyInfoUnsafe = do+  paths <- discoverCPUFrequencyPaths+  readCPUFrequencyInfoFromPaths paths++discoverCPUFrequencyPaths :: IO [[FilePath]]+discoverCPUFrequencyPaths = do+  policyPaths <- discoverPolicyFrequencyPaths+  if null policyPaths then discoverCoreFrequencyPaths else pure policyPaths++readCPUFrequencyInfoFromPaths :: [[FilePath]] -> IO CPUFrequencyInfo+readCPUFrequencyInfoFromPaths paths = do+  values <- catMaybes <$> mapM readFrequencyPath paths+  if null values+    then summarizeCPUFrequencies <$> readProcCPUInfoFrequencies+    else pure $ summarizeCPUFrequencies values++discoverPolicyFrequencyPaths :: IO [[FilePath]]+discoverPolicyFrequencyPaths =+  discoverFrequencyPaths+    "/sys/devices/system/cpu/cpufreq"+    ("policy" `isPrefixOf`)+    (\base entry -> base </> entry)++discoverCoreFrequencyPaths :: IO [[FilePath]]+discoverCoreFrequencyPaths =+  discoverFrequencyPaths+    "/sys/devices/system/cpu"+    isCPUCoreDirectory+    (\base entry -> base </> entry </> "cpufreq")++discoverFrequencyPaths :: FilePath -> (FilePath -> Bool) -> (FilePath -> FilePath -> FilePath) -> IO [[FilePath]]+discoverFrequencyPaths base acceptEntry entryDirectory = do+  exists <- doesDirectoryExist base+  if not exists+    then pure []+    else do+      entries <- sort . filter acceptEntry <$> listDirectory base+      filterM (fmap or . mapM doesFileExist) $+        map+          ( \entry ->+              let directory = entryDirectory base entry+               in [directory </> "scaling_cur_freq", directory </> "cpuinfo_cur_freq"]+          )+          entries++isCPUCoreDirectory :: FilePath -> Bool+isCPUCoreDirectory name =+  "cpu" `isPrefixOf` name+    && not (null suffix)+    && all (`elem` (['0' .. '9'] :: String)) suffix+  where+    suffix = drop 3 name++readFrequencyPath :: [FilePath] -> IO (Maybe Integer)+readFrequencyPath [] = pure Nothing+readFrequencyPath (path : rest) = do+  result <- try $ BS8.readFile path+  case result :: Either SomeException BS8.ByteString of+    Right contents ->+      case readMaybe (BS8.unpack $ BS8.takeWhile (`notElem` ['\n', '\r', ' ', '\t']) contents) of+        Just value -> pure $ Just value+        Nothing -> readFrequencyPath rest+    Left _ -> readFrequencyPath rest++readProcCPUInfoFrequencies :: IO [Integer]+readProcCPUInfoFrequencies = do+  result <- try $ BS8.readFile "/proc/cpuinfo"+  pure $ case result :: Either SomeException BS8.ByteString of+    Left _ -> []+    Right contents -> mapMaybe parseMHzLine $ lines $ BS8.unpack contents+  where+    parseMHzLine line = case break (== ':') line of+      (key, ':' : value)+        | words key == ["cpu", "MHz"] ->+            (\mhz -> round (mhz * 1000)) <$> (readMaybe value :: Maybe Double)+      _ -> Nothing++newtype CPUFrequencyInfoChanVar+  = CPUFrequencyInfoChanVar (TChan CPUFrequencyInfo, MVar CPUFrequencyInfo)++-- | Return the process-wide frequency stream. The first caller's interval+-- wins; later widgets reuse the same sampler.+getCPUFrequencyInfoChan :: Double -> TaffyIO (TChan CPUFrequencyInfo)+getCPUFrequencyInfoChan interval = do+  CPUFrequencyInfoChanVar (chan, _) <- setupCPUFrequencyInfoChanVar interval+  pure chan++-- | Read the latest snapshot from the shared frequency sampler.+getCPUFrequencyInfoState :: Double -> TaffyIO CPUFrequencyInfo+getCPUFrequencyInfoState interval = do+  CPUFrequencyInfoChanVar (_, infoVar) <- setupCPUFrequencyInfoChanVar interval+  liftIO $ readMVar infoVar++setupCPUFrequencyInfoChanVar :: Double -> TaffyIO CPUFrequencyInfoChanVar+setupCPUFrequencyInfoChanVar interval = getStateDefault $ do+  wakeupChan <- getWakeupChannelForDelay $ max 0.000001 interval+  ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan+  liftIO $ do+    paths <- discoverCPUFrequencyPaths+    initialInfo <- readCPUFrequencyInfoFromPaths paths+    chan <- newBroadcastTChanIO+    infoVar <- newMVar initialInfo+    pathsVar <- newMVar paths+    void $ forkIO $ forever $ do+      void $ atomically $ readTChan ourWakeupChan+      currentPaths <- readMVar pathsVar+      currentValues <- catMaybes <$> mapM readFrequencyPath currentPaths+      values <-+        if null currentValues+          then do+            refreshedPaths <- discoverCPUFrequencyPaths+            refreshedValues <- catMaybes <$> mapM readFrequencyPath refreshedPaths+            void $ swapMVar pathsVar refreshedPaths+            pure refreshedValues+          else pure currentValues+      info <-+        if null values+          then summarizeCPUFrequencies <$> readProcCPUInfoFrequencies+          else pure $ summarizeCPUFrequencies values+      old <- swapMVar infoVar info+      when (info /= old) $ atomically $ writeTChan chan info+    pure $ CPUFrequencyInfoChanVar (chan, infoVar)
src/System/Taffybar/Information/Memory.hs view
@@ -3,10 +3,20 @@ module System.Taffybar.Information.Memory   ( MemoryInfo (..),     parseMeminfo,+    getMemoryInfoChan,+    getMemoryInfoState,   ) where +import Control.Concurrent (forkIO)+import Control.Concurrent.MVar (MVar, newMVar, readMVar, swapMVar)+import Control.Concurrent.STM.TChan (TChan, dupTChan, newBroadcastTChanIO, readTChan, writeTChan)+import Control.Monad (forever, void, when)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.STM (atomically) import qualified Data.ByteString.Char8 as BS8+import System.Taffybar.Context (TaffyIO, getStateDefault)+import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay) import Text.Read (readMaybe)  toMB :: String -> Double@@ -33,6 +43,7 @@     memoryUsed :: Double, -- total - rest     memoryUsedRatio :: Double -- used / total   }+  deriving (Eq, Show)  emptyMemoryInfo :: MemoryInfo emptyMemoryInfo = MemoryInfo 0 0 0 0 0 0 0 0 0 0 0 0@@ -75,3 +86,34 @@         memorySwapUsed = swapUsed,         memorySwapUsedRatio = swapUsedRatio       }++newtype MemoryInfoChanVar+  = MemoryInfoChanVar (TChan MemoryInfo, MVar MemoryInfo)++-- | Return the process-wide memory information stream. The first caller's+-- interval wins; subsequent widgets share the same sampler.+getMemoryInfoChan :: Double -> TaffyIO (TChan MemoryInfo)+getMemoryInfoChan interval = do+  MemoryInfoChanVar (chan, _) <- getMemoryInfoChanVar interval+  pure chan++-- | Read the latest snapshot from the shared memory sampler.+getMemoryInfoState :: Double -> TaffyIO MemoryInfo+getMemoryInfoState interval = do+  MemoryInfoChanVar (_, var) <- getMemoryInfoChanVar interval+  liftIO $ readMVar var++getMemoryInfoChanVar :: Double -> TaffyIO MemoryInfoChanVar+getMemoryInfoChanVar interval = getStateDefault $ do+  wakeupChan <- getWakeupChannelForDelay $ max 0.000001 interval+  ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan+  liftIO $ do+    initialInfo <- parseMeminfo+    chan <- newBroadcastTChanIO+    var <- newMVar initialInfo+    void $ forkIO $ forever $ do+      void $ atomically $ readTChan ourWakeupChan+      info <- parseMeminfo+      old <- swapMVar var info+      when (info /= old) $ atomically $ writeTChan chan info+    pure $ MemoryInfoChanVar (chan, var)
src/System/Taffybar/Information/Nvidia.hs view
@@ -11,22 +11,177 @@ -- -- NVIDIA GPU information obtained from @nvidia-smi@. module System.Taffybar.Information.Nvidia-  ( NvidiaGpuTemperature (..),+  ( NvidiaGpuInfo (..),+    parseNvidiaGpuInfo,+    readNvidiaGpuInfo,+    readNvidiaGpuInfoWith,+    shouldQueryNvidiaForRuntimeStatuses,+    getNvidiaGpuInfoChan,+    getNvidiaGpuInfoChanWith,+    getNvidiaGpuInfoState,+    getNvidiaGpuInfoStateWith,+    NvidiaGpuTemperature (..),     parseNvidiaGpuTemperatures,     readNvidiaGpuTemperatures,     readNvidiaGpuTemperaturesWith,   ) where +import Control.Concurrent (forkIO)+import Control.Concurrent.MVar+import Control.Concurrent.STM.TChan import Control.Exception (IOException, try)+import Control.Monad (forever, void, when)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.STM (atomically)+import Data.Char (isHexDigit)+import Data.Foldable (for_) import Data.List (sortOn)-import Data.Maybe (mapMaybe)+import Data.Maybe (fromMaybe, mapMaybe) import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import System.Directory (listDirectory) import System.Exit (ExitCode (ExitSuccess))+import System.FilePath ((</>)) import System.Process (readProcessWithExitCode)+import System.Taffybar.Context (TaffyIO, getStateDefault)+import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay) import Text.Read (readMaybe)+import Text.XML.Light +-- | A complete snapshot for one NVIDIA GPU.+--+-- Fields that @nvidia-smi@ reports as unavailable are represented by+-- 'Nothing'. Temperatures are in Celsius, power values are in watts, and+-- utilization and fan readings are percentages.+data NvidiaGpuInfo = NvidiaGpuInfo+  { nvidiaInfoIndex :: !Int,+    nvidiaInfoName :: !T.Text,+    nvidiaInfoTemperatureCelsius :: !(Maybe Double),+    nvidiaInfoMemoryTemperatureCelsius :: !(Maybe Double),+    nvidiaInfoTargetTemperatureCelsius :: !(Maybe Double),+    -- | Remaining temperature headroom before the target temperature.+    nvidiaInfoThermalHeadroomCelsius :: !(Maybe Double),+    nvidiaInfoFanSpeedPercent :: !(Maybe Double),+    nvidiaInfoGpuUtilizationPercent :: !(Maybe Double),+    nvidiaInfoMemoryUtilizationPercent :: !(Maybe Double),+    nvidiaInfoMemoryUsedMiB :: !(Maybe Double),+    nvidiaInfoMemoryTotalMiB :: !(Maybe Double),+    nvidiaInfoPowerDrawWatts :: !(Maybe Double),+    nvidiaInfoPowerLimitWatts :: !(Maybe Double),+    nvidiaInfoPerformanceState :: !(Maybe T.Text)+  }+  deriving (Eq, Show)++-- | Parse the XML produced by @nvidia-smi -q -x@.+parseNvidiaGpuInfo :: T.Text -> [NvidiaGpuInfo]+parseNvidiaGpuInfo contents =+  maybe [] (sortOn nvidiaInfoIndex . mapMaybe parseGpu . findElements (unqual "gpu")) $+    parseXMLDoc $+      T.unpack contents+  where+    parseGpu gpu = do+      index <- readElement ["minor_number"] gpu+      name <- elementText ["product_name"] gpu+      pure+        NvidiaGpuInfo+          { nvidiaInfoIndex = index,+            nvidiaInfoName = name,+            nvidiaInfoTemperatureCelsius = readElement ["temperature", "gpu_temp"] gpu,+            nvidiaInfoMemoryTemperatureCelsius = readElement ["temperature", "memory_temp"] gpu,+            nvidiaInfoTargetTemperatureCelsius = readElement ["temperature", "gpu_target_temperature"] gpu,+            nvidiaInfoThermalHeadroomCelsius = readElement ["temperature", "gpu_temp_tlimit"] gpu,+            nvidiaInfoFanSpeedPercent = readElement ["fan_speed"] gpu,+            nvidiaInfoGpuUtilizationPercent = readElement ["utilization", "gpu_util"] gpu,+            nvidiaInfoMemoryUtilizationPercent = readElement ["utilization", "memory_util"] gpu,+            nvidiaInfoMemoryUsedMiB = readElement ["fb_memory_usage", "used"] gpu,+            nvidiaInfoMemoryTotalMiB = readElement ["fb_memory_usage", "total"] gpu,+            nvidiaInfoPowerDrawWatts =+              firstElement+                [ ["gpu_power_readings", "average_power_draw"],+                  ["gpu_power_readings", "instant_power_draw"],+                  ["power_readings", "power_draw"]+                ]+                gpu,+            nvidiaInfoPowerLimitWatts =+              firstElement+                [ ["gpu_power_readings", "current_power_limit"],+                  ["gpu_power_readings", "requested_power_limit"],+                  ["power_readings", "power_limit"]+                ]+                gpu,+            nvidiaInfoPerformanceState = availableElementText ["performance_state"] gpu+          }++-- | Read a rich snapshot using @nvidia-smi@ from @PATH@.+readNvidiaGpuInfo :: IO [NvidiaGpuInfo]+readNvidiaGpuInfo = readNvidiaGpuInfoWith "nvidia-smi"++-- | Read a rich snapshot using the supplied @nvidia-smi@ executable.+-- Returns an empty list when the command is missing, exits unsuccessfully, or+-- is skipped because every detected NVIDIA PCI device is runtime-suspended.+readNvidiaGpuInfoWith :: FilePath -> IO [NvidiaGpuInfo]+readNvidiaGpuInfoWith command =+  fromMaybe [] <$> readNvidiaGpuInfoUpdateWith command++-- | Decide whether querying NVIDIA is safe from the runtime power states of+-- the detected NVIDIA PCI devices. A query is skipped only when at least one+-- device was detected and every device explicitly reports that it is suspended+-- or suspending. Missing and unknown states preserve the historical behavior+-- of running @nvidia-smi@.+shouldQueryNvidiaForRuntimeStatuses :: [Maybe T.Text] -> Bool+shouldQueryNvidiaForRuntimeStatuses [] = True+shouldQueryNvidiaForRuntimeStatuses statuses =+  any (maybe True ((`notElem` lowPowerStatuses) . T.strip)) statuses+  where+    lowPowerStatuses = ["suspended", "suspending"]++newtype NvidiaGpuInfoChanVar+  = NvidiaGpuInfoChanVar (TChan [NvidiaGpuInfo], MVar [NvidiaGpuInfo])++-- | Get a shared broadcast channel of rich NVIDIA snapshots.+--+-- The first call starts one polling producer for the process; subsequent calls+-- reuse it. Consequently, the command and interval from the first call win.+getNvidiaGpuInfoChan :: Double -> TaffyIO (TChan [NvidiaGpuInfo])+getNvidiaGpuInfoChan = getNvidiaGpuInfoChanWith "nvidia-smi"++-- | Like 'getNvidiaGpuInfoChan', using a supplied @nvidia-smi@ executable.+getNvidiaGpuInfoChanWith :: FilePath -> Double -> TaffyIO (TChan [NvidiaGpuInfo])+getNvidiaGpuInfoChanWith command interval = do+  NvidiaGpuInfoChanVar (chan, _) <- setupNvidiaGpuInfoChanVar command interval+  pure chan++-- | Read the latest snapshot cached by 'getNvidiaGpuInfoChan'.+getNvidiaGpuInfoState :: Double -> TaffyIO [NvidiaGpuInfo]+getNvidiaGpuInfoState = getNvidiaGpuInfoStateWith "nvidia-smi"++-- | Like 'getNvidiaGpuInfoState', using a supplied @nvidia-smi@ executable.+getNvidiaGpuInfoStateWith :: FilePath -> Double -> TaffyIO [NvidiaGpuInfo]+getNvidiaGpuInfoStateWith command interval = do+  NvidiaGpuInfoChanVar (_, var) <- setupNvidiaGpuInfoChanVar command interval+  liftIO $ readMVar var++setupNvidiaGpuInfoChanVar :: FilePath -> Double -> TaffyIO NvidiaGpuInfoChanVar+setupNvidiaGpuInfoChanVar command interval = do+  wakeupChan <- getWakeupChannelForDelay $ max 0.000001 interval+  ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan+  getStateDefault $ liftIO $ do+    initialInfo <- fromMaybe [] <$> readNvidiaGpuInfoUpdateWith command+    chan <- newBroadcastTChanIO+    var <- newMVar initialInfo+    void $ forkIO $ forever $ do+      void $ atomically $ readTChan ourWakeupChan+      maybeInfo <- readNvidiaGpuInfoUpdateWith command+      for_ maybeInfo $ \info -> do+        old <- swapMVar var info+        when (info /= old) $ atomically $ writeTChan chan info+    pure $ NvidiaGpuInfoChanVar (chan, var)+ -- | A temperature reported for one NVIDIA GPU.+--+-- This small compatibility type is retained for callers that only need the+-- current core temperature. New code should prefer 'NvidiaGpuInfo'. data NvidiaGpuTemperature = NvidiaGpuTemperature   { nvidiaGpuIndex :: Int,     nvidiaGpuTemperatureCelsius :: Double@@ -47,24 +202,102 @@             <*> readMaybe (T.unpack temperatureText)         _ -> Nothing --- | Read temperatures using @nvidia-smi@ from @PATH@.--- Returns an empty list when the command is missing or exits unsuccessfully.+-- | Read core temperatures using @nvidia-smi@ from @PATH@. readNvidiaGpuTemperatures :: IO [NvidiaGpuTemperature] readNvidiaGpuTemperatures = readNvidiaGpuTemperaturesWith "nvidia-smi" --- | Read temperatures using the supplied @nvidia-smi@ executable.+-- | Read core temperatures using the supplied @nvidia-smi@ executable. readNvidiaGpuTemperaturesWith :: FilePath -> IO [NvidiaGpuTemperature] readNvidiaGpuTemperaturesWith command = do   result <--    try-      ( readProcessWithExitCode-          command-          [ "--query-gpu=index,temperature.gpu",-            "--format=csv,noheader,nounits"-          ]-          ""-      ) ::-      IO (Either IOException (ExitCode, String, String))+    runNvidiaSmi+      command+      [ "--query-gpu=index,temperature.gpu",+        "--format=csv,noheader,nounits"+      ]   pure $ case result of-    Right (ExitSuccess, output, _) -> parseNvidiaGpuTemperatures $ T.pack output-    _ -> []+    NvidiaSmiOutput output -> parseNvidiaGpuTemperatures $ T.pack output+    NvidiaSmiSkipped -> []+    NvidiaSmiFailed -> []++data NvidiaSmiResult+  = NvidiaSmiSkipped+  | NvidiaSmiFailed+  | NvidiaSmiOutput String++readNvidiaGpuInfoUpdateWith :: FilePath -> IO (Maybe [NvidiaGpuInfo])+readNvidiaGpuInfoUpdateWith command = do+  result <- runNvidiaSmi command ["-q", "-x"]+  pure $ case result of+    NvidiaSmiSkipped -> Nothing+    NvidiaSmiFailed -> Just []+    NvidiaSmiOutput output -> Just $ parseNvidiaGpuInfo $ T.pack output++runNvidiaSmi :: FilePath -> [String] -> IO NvidiaSmiResult+runNvidiaSmi command arguments = do+  statuses <- nvidiaPciRuntimeStatuses+  if shouldQueryNvidiaForRuntimeStatuses statuses+    then do+      result <-+        try (readProcessWithExitCode command arguments "") ::+          IO (Either IOException (ExitCode, String, String))+      pure $ case result of+        Right (ExitSuccess, output, _) -> NvidiaSmiOutput output+        _ -> NvidiaSmiFailed+    else pure NvidiaSmiSkipped++nvidiaPciRuntimeStatuses :: IO [Maybe T.Text]+nvidiaPciRuntimeStatuses = do+  let driverPath = "/sys/bus/pci/drivers/nvidia"+  entriesResult <- try (listDirectory driverPath) :: IO (Either IOException [FilePath])+  case entriesResult of+    Left _ -> pure []+    Right entries ->+      traverse (readRuntimeStatus . (driverPath </>)) $+        filter isPciAddress entries++readRuntimeStatus :: FilePath -> IO (Maybe T.Text)+readRuntimeStatus devicePath = do+  result <-+    try (T.strip <$> TIO.readFile (devicePath </> "power/runtime_status")) ::+      IO (Either IOException T.Text)+  pure $ either (const Nothing) Just result++isPciAddress :: FilePath -> Bool+isPciAddress entry =+  case T.split (\character -> character == ':' || character == '.') $ T.pack entry of+    [domain, bus, device, function] ->+      and+        [ T.length domain == 4,+          T.length bus == 2,+          T.length device == 2,+          T.length function == 1,+          all (all isHexDigit . T.unpack) [domain, bus, device, function]+        ]+    _ -> False++elementAt :: [String] -> Element -> Maybe Element+elementAt [] element = Just element+elementAt (name : rest) element =+  findChild (unqual name) element >>= elementAt rest++elementText :: [String] -> Element -> Maybe T.Text+elementText path element = T.strip . T.pack . strContent <$> elementAt path element++availableElementText :: [String] -> Element -> Maybe T.Text+availableElementText path element = do+  value <- elementText path element+  if value `elem` ["", "N/A", "[N/A]"] then Nothing else Just value++readElement :: (Read a) => [String] -> Element -> Maybe a+readElement path element = do+  value <- availableElementText path element+  case reads $ T.unpack value of+    [(number, _)] -> Just number+    _ -> Nothing++firstElement :: (Read a) => [[String]] -> Element -> Maybe a+firstElement paths element =+  case mapMaybe (`readElement` element) paths of+    value : _ -> Just value+    [] -> Nothing
src/System/Taffybar/Information/Temperature.hs view
@@ -22,15 +22,25 @@     readAllTemperatures,     readTemperaturesFrom,     convertTemperature,+    getTemperatureInfoChan,+    getTemperatureInfoState,   ) where +import Control.Concurrent (forkIO)+import Control.Concurrent.MVar+import Control.Concurrent.STM.TChan import Control.Exception (SomeException, try)-import Control.Monad (forM)+import Control.Monad (forM, forever, void, when)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.STM (atomically)+import qualified Data.ByteString.Char8 as BS8 import Data.List (sortOn) import Data.Maybe (catMaybes, fromMaybe) import System.Directory (doesDirectoryExist, doesFileExist, listDirectory) import System.FilePath ((</>))+import System.Taffybar.Context (TaffyIO, getStateDefault)+import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay) import Text.Read (readMaybe)  -- | Temperature unit for display@@ -153,10 +163,10 @@   if not exists     then return Nothing     else do-      result <- try $ readFile path :: IO (Either SomeException String)+      result <- try $ BS8.readFile path :: IO (Either SomeException BS8.ByteString)       case result of         Left _ -> return Nothing-        Right content -> return $ Just $ strip content+        Right content -> return $ Just $ strip $ BS8.unpack content   where     strip = dropWhile (== ' ') . reverse . dropWhile (== '\n') . reverse . dropWhile (== ' ') @@ -164,11 +174,11 @@ -- Returns Nothing if the sensor cannot be read readSensorTemperature :: ThermalSensor -> IO (Maybe TemperatureInfo) readSensorTemperature sensor = do-  result <- try $ readFile (sensorPath sensor) :: IO (Either SomeException String)+  result <- try $ BS8.readFile (sensorPath sensor) :: IO (Either SomeException BS8.ByteString)   case result of     Left _ -> return Nothing     Right content ->-      case readMaybe (strip content) :: Maybe Integer of+      case readMaybe (strip $ BS8.unpack content) :: Maybe Integer of         Nothing -> return Nothing         Just milliDegrees ->           return $@@ -193,3 +203,45 @@ readTemperaturesFrom sensors = do   temps <- forM sensors readSensorTemperature   return $ catMaybes temps++newtype TemperatureInfoChanVar+  = TemperatureInfoChanVar (TChan [TemperatureInfo], MVar [TemperatureInfo])++-- | Get a shared broadcast channel containing all discovered temperature+-- readings. The first call starts one producer for the process; subsequent+-- calls reuse it, so the interval from the first call wins.+getTemperatureInfoChan :: Double -> TaffyIO (TChan [TemperatureInfo])+getTemperatureInfoChan interval = do+  TemperatureInfoChanVar (chan, _) <- setupTemperatureInfoChanVar interval+  pure chan++-- | Read the latest snapshot cached by 'getTemperatureInfoChan'.+getTemperatureInfoState :: Double -> TaffyIO [TemperatureInfo]+getTemperatureInfoState interval = do+  TemperatureInfoChanVar (_, var) <- setupTemperatureInfoChanVar interval+  liftIO $ readMVar var++setupTemperatureInfoChanVar :: Double -> TaffyIO TemperatureInfoChanVar+setupTemperatureInfoChanVar interval = getStateDefault $ do+  wakeupChan <- getWakeupChannelForDelay $ max 0.000001 interval+  ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan+  liftIO $ do+    sensors <- discoverSensors+    initialInfo <- sortOn (negate . tempCelsius) <$> readTemperaturesFrom sensors+    chan <- newBroadcastTChanIO+    var <- newMVar initialInfo+    sensorsVar <- newMVar sensors+    void $ forkIO $ forever $ do+      void $ atomically $ readTChan ourWakeupChan+      currentSensors <- readMVar sensorsVar+      sampled <- sortOn (negate . tempCelsius) <$> readTemperaturesFrom currentSensors+      info <-+        if null sampled+          then do+            refreshedSensors <- discoverSensors+            void $ swapMVar sensorsVar refreshedSensors+            sortOn (negate . tempCelsius) <$> readTemperaturesFrom refreshedSensors+          else pure sampled+      old <- swapMVar var info+      when (info /= old) $ atomically $ writeTChan chan info+    pure $ TemperatureInfoChanVar (chan, var)
src/System/Taffybar/Information/Wakeup.hs view
@@ -26,10 +26,12 @@   ( WakeupEvent (..),     WakeupSchedulerEvent (..),     WakeupChannel (..),+    WakeupSubscription (..),     taffyForeverWithDelay,     getWakeupChannelNanoseconds,     getWakeupChannelSeconds,     getWakeupChannelForDelay,+    subscribeWakeupChannelForDelay,     getWakeupChannel,     getWakeupSchedulerEvents,     getRegisteredWakeupIntervalsNanoseconds,@@ -56,12 +58,14 @@   ( WakeupEvent (..),     WakeupManager,     WakeupSchedulerEvent (..),+    WakeupSubscription (..),     intervalDueAtStepNs,     intervalSecondsToNanoseconds,     nextAlignedWakeupNs,     nextWallAlignedWakeupNs,     registerWakeupInterval,     secondsToNanoseconds,+    subscribeWakeupInterval,     subscribeWakeupSchedulerEvents,   ) import qualified System.Taffybar.Information.Wakeup.Manager as WakeupManager@@ -123,6 +127,16 @@   case intervalSecondsToNanoseconds seconds of     Left err -> fail err     Right intervalNs -> getWakeupChannelNanoseconds intervalNs++-- | Temporarily subscribe to a coordinated interval. Releasing the returned+-- subscription removes an otherwise-unused interval from the scheduler, so a+-- short-lived high-frequency consumer does not increase idle wakeups later.+subscribeWakeupChannelForDelay :: (RealFrac d) => d -> TaffyIO WakeupSubscription+subscribeWakeupChannelForDelay seconds = do+  manager <- getWakeupManager+  case intervalSecondsToNanoseconds seconds of+    Left err -> fail err+    Right intervalNs -> liftIO $ subscribeWakeupInterval manager intervalNs  -- | Type-driven variant of 'getWakeupChannelSeconds'. --
src/System/Taffybar/Information/Wakeup/Manager.hs view
@@ -12,8 +12,10 @@   ( WakeupEvent (..),     WakeupSchedulerEvent (..),     WakeupManager,+    WakeupSubscription (..),     newWakeupManager,     registerWakeupInterval,+    subscribeWakeupInterval,     subscribeWakeupSchedulerEvents,     getRegisteredWakeupIntervalsNanoseconds,     secondsToNanoseconds,@@ -47,6 +49,8 @@     writeTChan,   ) import Control.Exception.Enclosed (catchAny)+import Control.Monad (when)+import Data.IORef (atomicModifyIORef', newIORef) import qualified Data.List as List import qualified Data.Map.Strict as M import Data.Maybe (mapMaybe)@@ -77,9 +81,19 @@   { intervalNanoseconds :: !Word64,     intervalChannel :: TChan WakeupEvent,     intervalNextDueNs :: !Word64,-    intervalTickCount :: !Word64+    intervalTickCount :: !Word64,+    intervalPermanent :: !Bool,+    intervalLeaseCount :: !Int   } +-- | A temporary coordinated wakeup registration. Releasing the final lease+-- removes its interval from the scheduler unless a permanent registration for+-- the same interval also exists. The release action is idempotent.+data WakeupSubscription = WakeupSubscription+  { wakeupSubscriptionChannel :: TChan WakeupEvent,+    releaseWakeupSubscription :: IO ()+  }+ -- Shared wakeup manager state. data WakeupManager = WakeupManager   { -- Approximate wall clock as @monotonic + wakeupRealtimeOffsetNs@.@@ -114,7 +128,11 @@       atomically $ do         registrations <- readTVar (wakeupIntervals manager)         case M.lookup validIntervalNs registrations of-          Just registration -> pure (intervalChannel registration)+          Just registration -> do+            writeTVar+              (wakeupIntervals manager)+              (M.insert validIntervalNs registration {intervalPermanent = True} registrations)+            pure (intervalChannel registration)           Nothing -> do             channel <- newBroadcastTChan             let registration =@@ -126,7 +144,9 @@                           (wakeupRealtimeOffsetNs manager)                           validIntervalNs                           now,-                      intervalTickCount = 0+                      intervalTickCount = 0,+                      intervalPermanent = True,+                      intervalLeaseCount = 0                     }             writeTVar               (wakeupIntervals manager)@@ -135,6 +155,70 @@             -- intervals are not blocked behind an older long sleep.             writeTChan (wakeupRescheduleChan manager) ()             pure channel++-- | Acquire a temporary lease for a coordinated interval. Unlike+-- 'registerWakeupInterval', the interval stops waking the scheduler after the+-- final lease is released (unless it was also registered permanently).+subscribeWakeupInterval :: WakeupManager -> Word64 -> IO WakeupSubscription+subscribeWakeupInterval manager intervalNs =+  case validateIntervalNanoseconds intervalNs of+    Left err -> ioError (userError err)+    Right validIntervalNs -> do+      now <- getMonotonicTimeNSec+      channel <- atomically $ do+        registrations <- readTVar (wakeupIntervals manager)+        case M.lookup validIntervalNs registrations of+          Just registration -> do+            let updated = registration {intervalLeaseCount = intervalLeaseCount registration + 1}+            writeTVar (wakeupIntervals manager) (M.insert validIntervalNs updated registrations)+            pure (intervalChannel registration)+          Nothing -> do+            newChannel <- newBroadcastTChan+            let registration =+                  IntervalRegistration+                    { intervalNanoseconds = validIntervalNs,+                      intervalChannel = newChannel,+                      intervalNextDueNs =+                        nextWallAlignedWakeupNs+                          (wakeupRealtimeOffsetNs manager)+                          validIntervalNs+                          now,+                      intervalTickCount = 0,+                      intervalPermanent = False,+                      intervalLeaseCount = 1+                    }+            writeTVar+              (wakeupIntervals manager)+              (M.insert validIntervalNs registration registrations)+            writeTChan (wakeupRescheduleChan manager) ()+            pure newChannel+      releasedRef <- newIORef False+      let release = do+            shouldRelease <- atomicModifyIORef' releasedRef $ \released -> (True, not released)+            when shouldRelease $ atomically $ releaseIntervalLease manager validIntervalNs+      pure+        WakeupSubscription+          { wakeupSubscriptionChannel = channel,+            releaseWakeupSubscription = release+          }++releaseIntervalLease :: WakeupManager -> Word64 -> STM ()+releaseIntervalLease manager intervalNs = do+  registrations <- readTVar (wakeupIntervals manager)+  case M.lookup intervalNs registrations of+    Nothing -> pure ()+    Just registration -> do+      let remainingLeases = max 0 (intervalLeaseCount registration - 1)+          shouldRemove = remainingLeases == 0 && not (intervalPermanent registration)+          updatedRegistrations+            | shouldRemove = M.delete intervalNs registrations+            | otherwise =+                M.insert+                  intervalNs+                  registration {intervalLeaseCount = remainingLeases}+                  registrations+      writeTVar (wakeupIntervals manager) updatedRegistrations+      when shouldRemove $ writeTChan (wakeupRescheduleChan manager) ()  subscribeWakeupSchedulerEvents :: WakeupManager -> IO (TChan WakeupSchedulerEvent) subscribeWakeupSchedulerEvents manager =
src/System/Taffybar/Information/Wlsunset.hs view
@@ -12,9 +12,9 @@ -- Portability : unportable -- -- This module provides process-level management of @wlsunset@, a--- Wayland day\/night gamma adjustor. It polls for the running state of--- the process and tracks mode cycling (auto → forced high temp →--- forced low temp → auto) via @SIGUSR1@.+-- Wayland day\/night gamma adjustor. While the process is running it uses+-- @pidwait@\/pidfd notification for exit detection; slow polling is only used+-- while waiting for an externally-started process to appear. module System.Taffybar.Information.Wlsunset   ( -- * Types     WlsunsetMode (..),@@ -34,6 +34,7 @@   ) where +import Control.Concurrent (threadDelay) import Control.Concurrent.MVar import Control.Concurrent.STM.TChan import Control.Exception.Enclosed (catchAny)@@ -86,7 +87,8 @@     -- | Low (night) temperature in Kelvin. Used for display only.     -- Should match the @-t@ flag passed to wlsunset (default 4000).     wlsunsetLowTemp :: Int,-    -- | How often (in seconds) to poll for process status.+    -- | How often (in seconds) to look for an externally-started process while+    -- no wlsunset instance is running.     wlsunsetPollIntervalSec :: Int   }   deriving (Eq, Show)@@ -97,7 +99,7 @@       { wlsunsetCommand = "wlsunset",         wlsunsetHighTemp = 6500,         wlsunsetLowTemp = 4000,-        wlsunsetPollIntervalSec = 2+        wlsunsetPollIntervalSec = 30       }  -- | Internal state bundle stored in 'contextState' via 'getStateDefault'.@@ -170,21 +172,23 @@   wakeupChan <- getWakeupChannelSeconds (max 1 (wlsunsetPollIntervalSec cfg))   ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan   taffyFork $ do-    wlsunsetLog DEBUG "Starting wlsunset polling loop"+    wlsunsetLog DEBUG "Starting event-first wlsunset monitor"     let loop = do-          liftIO $ pollWlsunset chan stateVar-          liftIO $ void $ atomically $ readTChan ourWakeupChan+          isRunning <- liftIO $ pollWlsunset chan stateVar+          if isRunning+            then liftIO $ waitForWlsunsetExit (wlsunsetPollIntervalSec cfg)+            else liftIO $ void $ atomically $ readTChan ourWakeupChan           loop     loop   return (chan, stateVar, cfg)  -- | A single poll iteration: check process status, update state, and -- broadcast if changed.-pollWlsunset :: TChan WlsunsetState -> MVar WlsunsetState -> IO ()+pollWlsunset :: TChan WlsunsetState -> MVar WlsunsetState -> IO Bool pollWlsunset chan var = do   pids <- pgrepWlsunset   let isRunning = not (null pids)-  modifyMVar_ var $ \old -> do+  modifyMVar var $ \old -> do     let wasRunning = wlsunsetRunning old         -- When the process freshly appears, reset mode to Auto.         newMode@@ -199,8 +203,15 @@     when (new /= old) $ do       wlsunsetLogF DEBUG "Wlsunset state changed: %s" new       atomically $ writeTChan chan new-    return new+    return (new, isRunning) +-- | Block in procps' pidfd-based waiter until the current process exits.+-- Falling back to the slow discovery loop is safe if pidwait is unavailable.+waitForWlsunsetExit :: Int -> IO ()+waitForWlsunsetExit fallbackIntervalSeconds =+  void (readProcess "pidwait" ["-x", "wlsunset"] "")+    `catchAny` (\_ -> threadDelay $ max 1 fallbackIntervalSeconds * 1000000)+ -- --------------------------------------------------------------------------- -- Actions -- ---------------------------------------------------------------------------@@ -228,19 +239,25 @@  -- | Start the wlsunset process using the configured command. startWlsunset :: WlsunsetConfig -> TaffyIO ()-startWlsunset cfg = liftIO $ do-  wlsunsetLog DEBUG $ "Starting wlsunset: " ++ wlsunsetCommand cfg-  void $ spawnCommand (wlsunsetCommand cfg)+startWlsunset cfg = do+  WlsunsetChanVar (chan, var, _) <- getWlsunsetChanVar cfg+  liftIO $ do+    wlsunsetLog DEBUG $ "Starting wlsunset: " ++ wlsunsetCommand cfg+    void $ spawnCommand (wlsunsetCommand cfg)+    setWlsunsetRunning chan var True  -- | Stop wlsunset by sending @SIGTERM@ (signal 15) to all instances. stopWlsunset :: WlsunsetConfig -> TaffyIO ()-stopWlsunset _cfg = liftIO $ do-  pids <- pgrepWlsunset-  case pids of-    [] -> wlsunsetLog DEBUG "stopWlsunset: wlsunset not running"-    _ -> do-      wlsunsetLog DEBUG "Stopping wlsunset (SIGTERM)"-      mapM_ (signalProcess 15) pids+stopWlsunset cfg = do+  WlsunsetChanVar (chan, var, _) <- getWlsunsetChanVar cfg+  liftIO $ do+    pids <- pgrepWlsunset+    case pids of+      [] -> wlsunsetLog DEBUG "stopWlsunset: wlsunset not running"+      _ -> do+        wlsunsetLog DEBUG "Stopping wlsunset (SIGTERM)"+        mapM_ (signalProcess 15) pids+    setWlsunsetRunning chan var False  -- | Toggle wlsunset: stop it if running, start it if not. toggleWlsunset :: WlsunsetConfig -> TaffyIO ()@@ -265,12 +282,24 @@     modifyMVar_ var $ \old -> do       let new =             old-              { wlsunsetMode = WlsunsetAuto,+              { wlsunsetRunning = True,+                wlsunsetMode = WlsunsetAuto,                 wlsunsetEffectiveHighTemp = highTemp,                 wlsunsetEffectiveLowTemp = lowTemp               }-      atomically $ writeTChan chan new+      when (new /= old) $ atomically $ writeTChan chan new       return new++setWlsunsetRunning :: TChan WlsunsetState -> MVar WlsunsetState -> Bool -> IO ()+setWlsunsetRunning chan var running =+  modifyMVar_ var $ \old -> do+    let new =+          old+            { wlsunsetRunning = running,+              wlsunsetMode = if running then wlsunsetMode old else WlsunsetAuto+            }+    when (new /= old) $ atomically $ writeTChan chan new+    pure new  -- --------------------------------------------------------------------------- -- Command building
src/System/Taffybar/Widget/ASUS.hs view
@@ -17,7 +17,8 @@ -- ASUS platform profile along with CPU frequency and temperature. -- -- Displays: @\<icon\> \<freq\> \<temp\>@, e.g. @nf-icon 3.2GHz 72°C@.--- Left-click opens a profile selection menu; right-click cycles profiles.+-- Left-click opens one menu with separate AC and battery profile sections;+-- right-click cycles the live profile. module System.Taffybar.Widget.ASUS   ( ASUSWidgetConfig (..),     defaultASUSWidgetConfig,@@ -29,7 +30,10 @@ import Control.Monad import Control.Monad.IO.Class import Control.Monad.Trans.Reader+import DBus (MethodError)+import DBus.Client (Client) import Data.Default (Default (..))+import Data.IORef (newIORef, readIORef, writeIORef) import qualified Data.Text as T import qualified GI.GLib as GLib import qualified GI.Gdk as Gdk@@ -93,8 +97,9 @@     Gtk.containerAdd ebox label     styleCtx <- Gtk.widgetGetStyleContext ebox     Gtk.styleContextAddClass styleCtx "asus-profile"+    renderedRef <- newIORef Nothing -    let updateWidget info = postGUIASync $ do+    let updateWidget info = do           let icon = getTextIcon config (asusProfile info)               freqText =                 if asusShowFreq config@@ -105,9 +110,18 @@                   then T.pack $ printf " %.0f\x00B0C" (asusCpuTempC info)                   else ""               labelText = icon <> freqText <> tempText-          Gtk.labelSetText label labelText-          updateProfileClasses ebox info-          updateTooltip ebox info+              tooltipText = asusTooltipText info+              rendered = (labelText, tooltipText, asusProfile info)+          previous <- readIORef renderedRef+          when (previous /= Just rendered) $ do+            writeIORef renderedRef (Just rendered)+            postGUIASync $ do+              when (fmap (\(oldLabel, _, _) -> oldLabel) previous /= Just labelText) $+                Gtk.labelSetText label labelText+              when (fmap (\(_, _, oldProfile) -> oldProfile) previous /= Just (asusProfile info)) $+                updateProfileClasses ebox info+              when (fmap (\(_, oldTooltip, _) -> oldTooltip) previous /= Just tooltipText) $+                Gtk.widgetSetTooltipText ebox (Just tooltipText)      void $ Gtk.onWidgetRealize ebox $ do       initialInfo <- runReaderT getASUSInfoState ctx@@ -136,20 +150,28 @@   mapM_ (Gtk.styleContextRemoveClass styleCtx) allClasses   Gtk.styleContextAddClass styleCtx currentClass --- | Update tooltip with current profile info.-updateTooltip :: (Gtk.IsWidget w) => w -> ASUSInfo -> IO ()-updateTooltip widget info = do+asusTooltipText :: ASUSInfo -> T.Text+asusTooltipText info =   let profile = asusProfileToString (asusProfile info)-      freqStr = T.pack $ printf "%.2f GHz" (asusCpuFreqGHz info)-      tempStr = T.pack $ printf "%.1f\x00B0C" (asusCpuTempC info)-      tooltipText =-        "Profile: "-          <> profile-          <> "\nCPU Freq: "-          <> freqStr-          <> "\nCPU Temp: "-          <> tempStr-  Gtk.widgetSetTooltipText widget (Just tooltipText)+      acProfile = asusProfileToString (asusACProfile info)+      batteryProfile = asusProfileToString (asusBatteryProfile info)+      powerSource = if asusOnACPower info then "AC power" else "Battery power"+      freqStr = T.pack $ printf "%.1f GHz" (asusCpuFreqGHz info)+      tempStr = T.pack $ printf "%.0f\x00B0C" (asusCpuTempC info)+   in "Active: "+        <> profile+        <> " ("+        <> powerSource+        <> ")\nAC profile: "+        <> acProfile+        <> "\nBattery profile: "+        <> batteryProfile+        <> "\nCPU Freq: "+        <> freqStr+        <> "\nCPU Temp: "+        <> tempStr+        <> "\n\nLeft click: configure profiles"+        <> "\nRight click: cycle active profile"  -- | Set up click handler: left-click opens profile menu, right-click cycles. setupClickHandler :: Context -> Gtk.EventBox -> IO ()@@ -174,38 +196,47 @@           return True         _ -> return False --- | Build and show a popup menu for selecting a profile.+-- | Build and show a popup menu for configuring AC and battery profiles. showProfileMenu :: Context -> Gtk.EventBox -> IO () showProfileMenu ctx ebox = do   currentEvent <- Gtk.getCurrentEvent   currentInfo <- runReaderT getASUSInfoState ctx-  let currentProfile = asusProfile currentInfo    menu <- Gtk.menuNew   Gtk.menuAttachToWidget menu ebox Nothing -  let profiles =-        [ ("Quiet", Quiet),-          ("Balanced", Balanced),-          ("Performance", Performance)-        ]+  let activeProfile = asusProfileToString $ asusProfile currentInfo+      powerSource = if asusOnACPower currentInfo then "AC" else "Battery"+      statusText = "Active: " <> activeProfile <> " (" <> powerSource <> ")" -  forM_ profiles $ \(labelText, profile) -> do-    let prefix =-          if profile == currentProfile-            then "\x2713 " :: T.Text-            else "   "-    item <- Gtk.menuItemNewWithLabel (prefix <> labelText)-    void $ Gtk.onMenuItemActivate item $ do-      let client = systemDBusClient ctx-      result <- setASUSProfile client profile-      case result of-        Left err ->-          asusLogF WARNING "Failed to set ASUS profile: %s" (show err)-        Right () ->-          return ()-    Gtk.menuShellAppend menu item+  statusItem <- Gtk.menuItemNewWithLabel statusText+  Gtk.widgetSetSensitive statusItem False+  Gtk.menuShellAppend menu statusItem +  separator <- Gtk.separatorMenuItemNew+  Gtk.menuShellAppend menu separator++  appendProfileSection+    ctx+    menu+    "AC profile"+    (asusProfile currentInfo)+    (asusACProfile currentInfo)+    (asusOnACPower currentInfo)+    setASUSACProfile++  sectionSeparator <- Gtk.separatorMenuItemNew+  Gtk.menuShellAppend menu sectionSeparator++  appendProfileSection+    ctx+    menu+    "Battery profile"+    (asusProfile currentInfo)+    (asusBatteryProfile currentInfo)+    (not $ asusOnACPower currentInfo)+    setASUSBatteryProfile+   void $     Gtk.onWidgetHide menu $       void $@@ -215,3 +246,57 @@    Gtk.widgetShowAll menu   Gtk.menuPopupAtPointer menu currentEvent++-- | Add a visible section for one source-specific default profile. When the+-- source is currently active, changing its default also changes the live+-- profile.+appendProfileSection ::+  Context ->+  Gtk.Menu ->+  T.Text ->+  ASUSPlatformProfile ->+  ASUSPlatformProfile ->+  Bool ->+  (Client -> ASUSPlatformProfile -> IO (Either MethodError ())) ->+  IO ()+appendProfileSection+  ctx+  menu+  labelText+  liveProfile+  selectedProfile+  sourceIsActive+  setter = do+    let activeSuffix = if sourceIsActive then " (active)" else ""+        sectionLabel =+          labelText+            <> ": "+            <> asusProfileToString selectedProfile+            <> activeSuffix+        profiles = [Quiet, Balanced, Performance]++    headerItem <- Gtk.menuItemNewWithLabel sectionLabel+    Gtk.widgetSetSensitive headerItem False+    Gtk.menuShellAppend menu headerItem++    forM_ profiles $ \profile -> do+      let prefix = if profile == selectedProfile then "  \x2713 " else "     "+          itemLabel = prefix <> asusProfileToString profile+      item <- Gtk.menuItemNewWithLabel itemLabel+      Gtk.menuShellAppend menu item+      void $ Gtk.onMenuItemActivate item $ do+        let client = systemDBusClient ctx+        savedResult <- setter client profile+        case savedResult of+          Left err ->+            asusLogF WARNING "Failed to set saved ASUS profile: %s" (show err)+          Right () -> do+            -- asusd applies any AC/battery default immediately, even if that+            -- power source is inactive. Keep the current source on its existing+            -- profile when editing the other source's default.+            let profileToApply = if sourceIsActive then profile else liveProfile+            activeResult <- setASUSProfile client profileToApply+            case activeResult of+              Left err ->+                asusLogF WARNING "Failed to apply active ASUS profile: %s" (show err)+              Right () -> return ()
src/System/Taffybar/Widget/AnthropicUsage.hs view
@@ -18,6 +18,8 @@     anthropicUsageStackNewWith,     anthropicUsageNew,     anthropicUsageNewWith,+    defaultAnthropicUsageWindowLabelRenderer,+    anthropicUsageWindowLabelParts,     formatAnthropicUsageWindowLabel,     formatAnthropicUsageSummaryLabel,   )@@ -38,7 +40,12 @@ import System.Taffybar.Context (TaffyIO, getStateDefault) import System.Taffybar.Information.AnthropicUsage import System.Taffybar.Util (postGUIASync)-import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI)+import System.Taffybar.Widget.Util+  ( UsageWindowLabelParts (..),+    UsageWindowPosition (..),+    buildIconLabelBox,+    widgetSetClassGI,+  ) import Text.Printf (printf)  data AnthropicUsageDisplayMode@@ -80,7 +87,14 @@ data AnthropicUsageStackConfig = AnthropicUsageStackConfig   { anthropicUsageStackInfoConfig :: AnthropicUsageConfig,     anthropicUsageStackDefaultDisplayMode :: AnthropicUsageDisplayMode,-    anthropicUsageStackFallbackText :: T.Text+    anthropicUsageStackFallbackText :: T.Text,+    -- | Arrange the semantic pieces of each stack row. This controls label+    -- order and separators without requiring callers to rebuild the stack,+    -- menu, refresh handling, or usage calculations.+    anthropicUsageStackLabelRenderer ::+      AnthropicUsageWindowSelector ->+      UsageWindowLabelParts ->+      T.Text   }  defaultAnthropicUsageLabelConfig :: AnthropicUsageLabelConfig@@ -100,7 +114,8 @@   AnthropicUsageStackConfig     { anthropicUsageStackInfoConfig = defaultAnthropicUsageConfig,       anthropicUsageStackDefaultDisplayMode = AnthropicUsageDisplayUsed,-      anthropicUsageStackFallbackText = "n/a"+      anthropicUsageStackFallbackText = "n/a",+      anthropicUsageStackLabelRenderer = defaultAnthropicUsageWindowLabelRenderer     }  anthropicUsageNew :: TaffyIO Gtk.Widget@@ -167,7 +182,9 @@       anthropicUsageLabelDefaultDisplayMode = anthropicUsageStackDefaultDisplayMode stackConfig,       anthropicUsageLabelFallbackText = anthropicUsageStackFallbackText stackConfig,       anthropicUsageLabelClass = "anthropic-usage-window-label",-      anthropicUsageLabelFormatter = formatAnthropicUsageWindowLabel selector,+      anthropicUsageLabelFormatter = \displayMode info ->+        anthropicUsageStackLabelRenderer stackConfig selector $+          anthropicUsageWindowLabelParts selector displayMode info,       anthropicUsageLabelUnavailableFormatter = const (anthropicUsageStackFallbackText stackConfig)     } @@ -327,25 +344,80 @@    in (if unavailable then "Claude ! " else "Claude ") <> windows  formatAnthropicUsageWindowLabel :: AnthropicUsageWindowSelector -> AnthropicUsageDisplayMode -> AnthropicUsageInfo -> T.Text-formatAnthropicUsageWindowLabel AnthropicUsageWeeklyWindow displayMode info =-  formatWeeklyWithScopedLabel displayMode info formatAnthropicUsageWindowLabel selector displayMode info =-  maybe "" (formatWindowLabel displayMode) (selectedWindow selector info)+  defaultAnthropicUsageWindowLabelRenderer selector $+    anthropicUsageWindowLabelParts selector displayMode info +-- | Compute the semantic pieces of a window label independently of their+-- presentation order. The weekly value includes the scoped model limit so a+-- renderer can move the complete value without reconstructing it.+anthropicUsageWindowLabelParts ::+  AnthropicUsageWindowSelector ->+  AnthropicUsageDisplayMode ->+  AnthropicUsageInfo ->+  UsageWindowLabelParts+anthropicUsageWindowLabelParts AnthropicUsageWeeklyWindow displayMode info =+  let weekly = anthropicUsageWeeklyWindow info+   in UsageWindowLabelParts+        (anthropicUsageWindowName weekly)+        (formatWeeklyWithScopedValue displayMode info)+        (usageWindowPosition (anthropicUsageGeneratedAt info) <$> anthropicUsageWindowResetAt weekly)+anthropicUsageWindowLabelParts selector displayMode info =+  case selectedWindow selector info of+    Nothing -> UsageWindowLabelParts "" "" Nothing+    Just window ->+      UsageWindowLabelParts+        (anthropicUsageWindowName window)+        (formatWindowValueWithIndicator displayMode window)+        Nothing++-- | Preserve the historical Anthropic layout by default: window name, value,+-- then an optional reset-window position.+defaultAnthropicUsageWindowLabelRenderer ::+  AnthropicUsageWindowSelector ->+  UsageWindowLabelParts ->+  T.Text+defaultAnthropicUsageWindowLabelRenderer _ parts+  | T.null (usageWindowLabelName parts) && T.null (usageWindowLabelValue parts) = ""+  | otherwise =+      usageWindowLabelName parts+        <> " "+        <> usageWindowLabelValue parts+        <> maybe "" ((" " <>) . formatUsageWindowPosition) (usageWindowLabelPosition parts)+ -- | The weekly label with the per-model weekly limit folded in when present,--- e.g. @7d 65%·F45%r@, so the scoped window does not need its own row.+-- so the scoped window does not need its own row. formatWeeklyWithScopedLabel :: AnthropicUsageDisplayMode -> AnthropicUsageInfo -> T.Text formatWeeklyWithScopedLabel displayMode info =+  defaultAnthropicUsageWindowLabelRenderer AnthropicUsageWeeklyWindow $+    anthropicUsageWindowLabelParts AnthropicUsageWeeklyWindow displayMode info++formatWeeklyWithScopedValue :: AnthropicUsageDisplayMode -> AnthropicUsageInfo -> T.Text+formatWeeklyWithScopedValue displayMode info =   let weekly = anthropicUsageWeeklyWindow info       scopedPart window =         "·"           <> T.toUpper (T.take 1 (anthropicUsageWindowName window))           <> formatWindowValue displayMode window-   in anthropicUsageWindowName weekly-        <> " "-        <> formatWindowValue displayMode weekly+   in formatWindowValue displayMode weekly         <> maybe "" scopedPart (anthropicUsageScopedWeeklyWindow info)         <> displayModeIndicator displayMode++usageWindowPosition :: UTCTime -> UTCTime -> UsageWindowPosition+usageWindowPosition generatedAt resetAt =+  UsageWindowPosition day 7+  where+    secondsPerDay = 24 * 60 * 60+    windowStart = addUTCTime (negate $ 7 * secondsPerDay) resetAt+    elapsedPeriods = diffUTCTime generatedAt windowStart / secondsPerDay+    day :: Int+    day = max 0 $ min 7 $ ceiling elapsedPeriods++formatUsageWindowPosition :: UsageWindowPosition -> T.Text+formatUsageWindowPosition position =+  T.pack (show $ usageWindowPositionCurrent position)+    <> "/"+    <> T.pack (show $ usageWindowPositionTotal position)  formatAnthropicUsageTooltip :: AnthropicUsageDisplayMode -> AnthropicUsageSnapshot -> Maybe T.Text formatAnthropicUsageTooltip displayMode snapshot =
src/System/Taffybar/Widget/Backlight.hs view
@@ -65,7 +65,7 @@ defaultBacklightWidgetConfig :: BacklightWidgetConfig defaultBacklightWidgetConfig =   BacklightWidgetConfig-    { backlightPollingInterval = 2,+    { backlightPollingInterval = 30,       backlightDevice = Nothing,       backlightFormat = "bl: $percent$%",       backlightUnknownFormat = "bl: n/a",
src/System/Taffybar/Widget/Battery.hs view
@@ -24,6 +24,7 @@     BatteryClassesConfig (..),     defaultBatteryClassesConfig,     setBatteryStateClasses,+    formatBatteryInfo,     textBatteryNew,     textBatteryNewWithLabelAction,   )@@ -52,7 +53,9 @@ data BatteryWidgetInfo = BWI   { seconds :: Maybe Int64,     percent :: Int,-    status :: String+    status :: String,+    watts :: Double,+    signedWatts :: Double   }   deriving (Eq, Show) @@ -81,7 +84,16 @@           BatteryStateCharging -> "Charging"           BatteryStateDischarging -> "Discharging"           _ -> "✔"-   in BWI {seconds = battTime, percent = battPctNum, status = battStatus}+   in BWI+        { seconds = battTime,+          percent = battPctNum,+          status = battStatus,+          watts = batteryEnergyRate info,+          signedWatts = case batteryState info of+            BatteryStateCharging -> batteryEnergyRate info+            BatteryStateDischarging -> negate $ batteryEnergyRate info+            _ -> 0+        }  -- | Given (maybe summarized) battery info and format: provides the string to display formatBattInfo :: BatteryWidgetInfo -> String -> T.Text@@ -91,22 +103,36 @@         setManyAttrib           [ ("percentage", (show . percent) info),             ("time", formatDuration (seconds info)),-            ("status", status info)+            ("status", status info),+            ("watts", printf "%.1f" (watts info)),+            ("signedWatts", formatSignedWatts $ signedWatts info)           ]           tpl    in render tpl' +formatSignedWatts :: Double -> String+formatSignedWatts value+  | value > 0 = printf "+%.1f" value+  | otherwise = printf "%.1f" value++-- | Format battery information using the placeholders accepted by+-- 'textBatteryNew'.+formatBatteryInfo :: BatteryInfo -> String -> T.Text+formatBatteryInfo = formatBattInfo . getBatteryWidgetInfo+ -- | A simple textual battery widget. The displayed format is specified format -- string where $percentage$ is replaced with the percentage of battery -- remaining and $time$ is replaced with the time until the battery is fully--- charged/discharged.+-- charged/discharged. $status$ is replaced with the charging state and $watts$+-- is replaced with the current energy rate in watts. $signedWatts$ is positive+-- while charging and negative while discharging. textBatteryNew :: String -> TaffyIO Widget textBatteryNew format = textBatteryNewWithLabelAction labelSetter   where     labelSetter label info = do       setBatteryStateClasses def label info       labelSetMarkup label $-        formatBattInfo (getBatteryWidgetInfo info) format+        formatBatteryInfo info format  -- | CSS-threshold configuration for battery level classes. data BatteryClassesConfig = BatteryClassesConfig
+ src/System/Taffybar/Widget/CPUFrequency.hs view
@@ -0,0 +1,88 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      : System.Taffybar.Widget.CPUFrequency+-- Copyright   : (c) Ivan A. Malison+-- License     : BSD3-style (see LICENSE)+--+-- A generic Linux CPU clock-speed widget backed by shared information state.+module System.Taffybar.Widget.CPUFrequency+  ( CPUFrequencyWidgetConfig (..),+    defaultCPUFrequencyWidgetConfig,+    cpuFrequencyNew,+    cpuFrequencyNewWithConfig,+  )+where++import Control.Monad (void, when)+import Control.Monad.IO.Class (liftIO)+import Data.Default (Default (..))+import Data.IORef (newIORef, readIORef, writeIORef)+import qualified Data.Text as T+import qualified GI.Gtk as Gtk+import System.Taffybar.Context (TaffyIO)+import System.Taffybar.Information.CPUFrequency+import System.Taffybar.Util (postGUIASync)+import System.Taffybar.Widget.Generic.ChannelWidget (channelWidgetNew)+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI)+import Text.Printf (printf)++data CPUFrequencyWidgetConfig = CPUFrequencyWidgetConfig+  { cpuFrequencyPollInterval :: Double,+    cpuFrequencyIcon :: T.Text+  }+  deriving (Eq, Show)++defaultCPUFrequencyWidgetConfig :: CPUFrequencyWidgetConfig+defaultCPUFrequencyWidgetConfig =+  CPUFrequencyWidgetConfig+    { cpuFrequencyPollInterval = 10,+      cpuFrequencyIcon = "\xF2DB" -- Font Awesome: microchip+    }++instance Default CPUFrequencyWidgetConfig where+  def = defaultCPUFrequencyWidgetConfig++cpuFrequencyNew :: TaffyIO Gtk.Widget+cpuFrequencyNew = cpuFrequencyNewWithConfig defaultCPUFrequencyWidgetConfig++cpuFrequencyNewWithConfig :: CPUFrequencyWidgetConfig -> TaffyIO Gtk.Widget+cpuFrequencyNewWithConfig config = do+  let interval = cpuFrequencyPollInterval config+  chan <- getCPUFrequencyInfoChan interval+  initialInfo <- getCPUFrequencyInfoState interval+  liftIO $ do+    icon <- Gtk.toWidget =<< Gtk.labelNew (Just $ cpuFrequencyIcon config)+    valueLabel <- Gtk.labelNew Nothing+    value <- Gtk.toWidget valueLabel+    row <- buildIconLabelBox icon value+    _ <- widgetSetClassGI row "cpu-frequency"+    renderedRef <- newIORef Nothing++    let updateWidget info = do+          let rendered = renderCPUFrequency info+          previous <- readIORef renderedRef+          when (previous /= Just rendered) $ do+            writeIORef renderedRef $ Just rendered+            postGUIASync $ do+              Gtk.labelSetText valueLabel $ fst rendered+              Gtk.widgetSetTooltipText row $ Just $ snd rendered++    void $ Gtk.onWidgetRealize row $ updateWidget initialInfo+    Gtk.widgetShowAll row+    Gtk.toWidget =<< channelWidgetNew row chan updateWidget++renderCPUFrequency :: CPUFrequencyInfo -> (T.Text, T.Text)+renderCPUFrequency info =+  case cpuFrequencyAverageGHz info of+    Nothing -> ("n/a", "CPU clock speed unavailable")+    Just average ->+      ( T.pack $ printf "%.1fGHz" average,+        T.pack $+          printf+            "CPU clock average: %.2f GHz\nRange: %.2f-%.2f GHz\n%d frequency policies sampled"+            average+            (maybe average id $ cpuFrequencyMinimumGHz info)+            (maybe average id $ cpuFrequencyMaximumGHz info)+            (cpuFrequencySampleCount info)+      )
src/System/Taffybar/Widget/CPUMonitor.hs view
@@ -18,10 +18,19 @@ -- available. module System.Taffybar.Widget.CPUMonitor where +import Control.Monad (void) import Control.Monad.IO.Class (liftIO)+import Data.IORef (atomicModifyIORef', newIORef)+import qualified GI.Gdk as Gdk import qualified GI.Gtk import System.Taffybar.Context (TaffyIO)-import System.Taffybar.Information.CPU2 (CPULoad (..), getCPULoadChan)+import System.Taffybar.Information.CPU2+  ( CPULoad (..),+    acquireCPULoadFastRefresh,+    cpuLoadSourceChannel,+    forceCPULoadRefresh,+    getCPULoadSource,+  ) import System.Taffybar.Widget.Generic.ChannelGraph import System.Taffybar.Widget.Generic.Graph import System.Taffybar.Widget.Util (widgetSetClassGI)@@ -37,10 +46,56 @@   String ->   TaffyIO GI.Gtk.Widget cpuMonitorNew cfg interval cpu = do-  chan <- getCPULoadChan cpu interval-  liftIO $-    channelGraphNew cfg chan toSample-      >>= (`widgetSetClassGI` "cpu-monitor")+  cpuMonitorNewWithHover cfg interval (min interval 0.5) cpu++-- | Create a CPU monitor that temporarily requests a faster coordinated+-- sampling cadence while the pointer is over the graph. Pointer leave and+-- widget unrealize both release the request, so the fast scheduler interval+-- does not remain active in the background.+cpuMonitorNewWithHover ::+  -- | Configuration data for the graph.+  GraphConfig ->+  -- | Normal polling period, in seconds.+  Double ->+  -- | Polling period while hovered, in seconds.+  Double ->+  -- | Name of the core to watch (for example, @"cpu"@ or @"cpu0"@).+  String ->+  TaffyIO GI.Gtk.Widget+cpuMonitorNewWithHover cfg interval hoverInterval cpu = do+  source <- getCPULoadSource cpu interval hoverInterval+  liftIO $ do+    graphWidget <- channelGraphNew cfg (cpuLoadSourceChannel source) toSample+    eventBox <- GI.Gtk.eventBoxNew+    GI.Gtk.eventBoxSetVisibleWindow eventBox False+    GI.Gtk.eventBoxSetAboveChild eventBox True+    GI.Gtk.containerAdd eventBox graphWidget+    widget <- GI.Gtk.toWidget eventBox >>= (`widgetSetClassGI` "cpu-monitor")+    releaseRef <- newIORef Nothing++    let beginFastRefresh = do+          currentRelease <- atomicModifyIORef' releaseRef $ \current -> (current, current)+          case currentRelease of+            Just _ -> pure ()+            Nothing -> do+              release <- acquireCPULoadFastRefresh source+              previous <- atomicModifyIORef' releaseRef $ \current -> (Just release, current)+              maybe (pure ()) id previous++        endFastRefresh = do+          release <- atomicModifyIORef' releaseRef $ \current -> (Nothing, current)+          maybe (pure ()) id release++    GI.Gtk.widgetAddEvents+      widget+      [ Gdk.EventMaskEnterNotifyMask,+        Gdk.EventMaskLeaveNotifyMask+      ]+    void $ GI.Gtk.onWidgetEnterNotifyEvent widget $ \_ -> beginFastRefresh >> pure False+    void $ GI.Gtk.onWidgetLeaveNotifyEvent widget $ \_ -> endFastRefresh >> pure False+    void $ GI.Gtk.onWidgetRealize graphWidget $ forceCPULoadRefresh source+    void $ GI.Gtk.onWidgetUnrealize widget endFastRefresh+    pure widget  toSample :: CPULoad -> IO [Double] toSample CPULoad {cpuTotalLoad = totalLoad, cpuSystemLoad = systemLoad} =
src/System/Taffybar/Widget/NvidiaTemperature.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TupleSections #-}  -- | -- Module      : System.Taffybar.Widget.NvidiaTemperature@@ -9,11 +10,13 @@ -- Stability   : unstable -- Portability : unportable ----- A widget for displaying NVIDIA GPU temperatures from @nvidia-smi@.+-- NVIDIA GPU temperature widgets backed by @nvidia-smi@. module System.Taffybar.Widget.NvidiaTemperature   ( -- * Combined icon+label widget     nvidiaTemperatureNew,     nvidiaTemperatureNewWith,+    nvidiaTemperatureNewChan,+    nvidiaTemperatureNewChanWith,      -- * Icon-only widget     nvidiaTemperatureIconNew,@@ -22,6 +25,8 @@     -- * Label-only widget     nvidiaTemperatureLabelNew,     nvidiaTemperatureLabelNewWith,+    nvidiaTemperatureLabelNewChan,+    nvidiaTemperatureLabelNewChanWith,      -- * Configuration     NvidiaTemperatureConfig (..),@@ -29,15 +34,22 @@   ) where +import Control.Monad (void, when) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Default (Default (..))+import Data.IORef (newIORef, readIORef, writeIORef) import Data.List (find, intercalate, maximumBy)+import Data.Maybe (catMaybes, mapMaybe) import Data.Ord (comparing) import qualified Data.Text as T import qualified GI.Gtk as Gtk+import System.Taffybar.Context (TaffyIO) import System.Taffybar.Information.Nvidia+import System.Taffybar.Util (postGUIASync)+import System.Taffybar.Widget.Generic.ChannelWidget (channelWidgetNew) import System.Taffybar.Widget.Generic.PollingLabel (pollingLabelNewWithTooltip) import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI)+import Text.Printf (printf) import qualified Text.StringTemplate as ST  -- | Configuration for the NVIDIA temperature widget.@@ -46,7 +58,7 @@     nvidiaTemperatureCommand :: FilePath,     -- | GPU index to display. 'Nothing' displays the hottest available GPU.     nvidiaTemperatureGpuIndex :: Maybe Int,-    -- | Label template. Available variables are @gpu@ and @tempC@.+    -- | Label template. Available variables are @gpu@, @name@, and @tempC@.     nvidiaTemperatureFormat :: String,     -- | Text displayed when no temperature is available.     nvidiaTemperatureFallback :: T.Text,@@ -59,7 +71,7 @@ instance Default NvidiaTemperatureConfig where   def = defaultNvidiaTemperatureConfig --- | Default to GPU 0, refreshed every ten seconds.+-- | Default to GPU 0, refreshed every thirty seconds. defaultNvidiaTemperatureConfig :: NvidiaTemperatureConfig defaultNvidiaTemperatureConfig =   NvidiaTemperatureConfig@@ -67,7 +79,7 @@       nvidiaTemperatureGpuIndex = Just 0,       nvidiaTemperatureFormat = "GPU $tempC$\176C",       nvidiaTemperatureFallback = "GPU N/A",-      nvidiaTemperaturePollInterval = 10,+      nvidiaTemperaturePollInterval = 30,       nvidiaTemperatureIcon = "\xF2C9"     } @@ -83,6 +95,19 @@   buildIconLabelBox iconWidget labelWidget     >>= (`widgetSetClassGI` "nvidia-temperature") +-- | Create a combined icon and label widget backed by the shared channel.+nvidiaTemperatureNewChan :: TaffyIO Gtk.Widget+nvidiaTemperatureNewChan = nvidiaTemperatureNewChanWith defaultNvidiaTemperatureConfig++-- | Create a combined icon and label widget backed by the shared channel.+nvidiaTemperatureNewChanWith :: NvidiaTemperatureConfig -> TaffyIO Gtk.Widget+nvidiaTemperatureNewChanWith config = do+  iconWidget <- liftIO $ nvidiaTemperatureIconNewWith config+  labelWidget <- nvidiaTemperatureLabelNewChanWith config+  liftIO $+    buildIconLabelBox iconWidget labelWidget+      >>= (`widgetSetClassGI` "nvidia-temperature")+ -- | Create an icon widget with the default configuration. nvidiaTemperatureIconNew :: (MonadIO m) => m Gtk.Widget nvidiaTemperatureIconNew = nvidiaTemperatureIconNewWith defaultNvidiaTemperatureConfig@@ -99,44 +124,149 @@ nvidiaTemperatureLabelNew :: (MonadIO m) => m Gtk.Widget nvidiaTemperatureLabelNew = nvidiaTemperatureLabelNewWith defaultNvidiaTemperatureConfig --- | Create a label widget that polls @nvidia-smi@.+-- | Create a label widget that polls for rich NVIDIA information. nvidiaTemperatureLabelNewWith :: (MonadIO m) => NvidiaTemperatureConfig -> m Gtk.Widget-nvidiaTemperatureLabelNewWith config = do-  widget <- pollingLabelNewWithTooltip (nvidiaTemperaturePollInterval config) $ do-    temperatures <- readNvidiaGpuTemperaturesWith $ nvidiaTemperatureCommand config-    pure $ case selectTemperature config temperatures of-      Nothing -> (nvidiaTemperatureFallback config, formatTooltip temperatures)-      Just temperature ->-        (formatLabel config temperature, formatTooltip temperatures)+nvidiaTemperatureLabelNewWith config = liftIO $ do+  widget <-+    pollingLabelNewWithTooltip (nvidiaTemperaturePollInterval config) $+      formatWidget config <$> readNvidiaGpuInfoWith (nvidiaTemperatureCommand config)   widgetSetClassGI widget "nvidia-temperature-label" -selectTemperature :: NvidiaTemperatureConfig -> [NvidiaGpuTemperature] -> Maybe NvidiaGpuTemperature-selectTemperature _ [] = Nothing-selectTemperature config temperatures =+-- | Create a label driven by the shared NVIDIA information channel.+nvidiaTemperatureLabelNewChan :: TaffyIO Gtk.Widget+nvidiaTemperatureLabelNewChan = nvidiaTemperatureLabelNewChanWith defaultNvidiaTemperatureConfig++-- | Create a label driven by the shared NVIDIA information channel.+nvidiaTemperatureLabelNewChanWith :: NvidiaTemperatureConfig -> TaffyIO Gtk.Widget+nvidiaTemperatureLabelNewChanWith config = do+  let command = nvidiaTemperatureCommand config+      interval = nvidiaTemperaturePollInterval config+  chan <- getNvidiaGpuInfoChanWith command interval+  initialInfo <- getNvidiaGpuInfoStateWith command interval++  liftIO $ do+    label <- Gtk.labelNew Nothing+    _ <- widgetSetClassGI label "nvidia-temperature-label"+    renderedRef <- newIORef Nothing++    let updateLabel info = do+          let rendered@(labelText, tooltipText) = formatWidget config info+          previous <- readIORef renderedRef+          when (previous /= Just rendered) $ do+            writeIORef renderedRef (Just rendered)+            postGUIASync $ do+              Gtk.labelSetText label labelText+              Gtk.widgetSetTooltipText label tooltipText++    void $ Gtk.onWidgetRealize label $ updateLabel initialInfo+    Gtk.widgetShowAll label+    Gtk.toWidget =<< channelWidgetNew label chan updateLabel++formatWidget :: NvidiaTemperatureConfig -> [NvidiaGpuInfo] -> (T.Text, Maybe T.Text)+formatWidget config info =+  ( maybe (nvidiaTemperatureFallback config) (uncurry $ formatLabel config) $+      selectTemperature config info,+    formatTooltip info+  )++selectTemperature :: NvidiaTemperatureConfig -> [NvidiaGpuInfo] -> Maybe (NvidiaGpuInfo, Double)+selectTemperature config info =   case nvidiaTemperatureGpuIndex config of-    Just index -> find ((== index) . nvidiaGpuIndex) temperatures-    Nothing -> Just $ maximumBy (comparing nvidiaGpuTemperatureCelsius) temperatures+    Just index -> do+      gpu <- find ((== index) . nvidiaInfoIndex) info+      temperature <- nvidiaInfoTemperatureCelsius gpu+      pure (gpu, temperature)+    Nothing ->+      case mapMaybe withTemperature info of+        [] -> Nothing+        temperatures -> Just $ maximumBy (comparing snd) temperatures+  where+    withTemperature gpu = (gpu,) <$> nvidiaInfoTemperatureCelsius gpu -formatLabel :: NvidiaTemperatureConfig -> NvidiaGpuTemperature -> T.Text-formatLabel config temperature =+formatLabel :: NvidiaTemperatureConfig -> NvidiaGpuInfo -> Double -> T.Text+formatLabel config info temperature =   T.pack $ ST.render template   where     template =       ST.setManyAttrib-        [ ("gpu", show $ nvidiaGpuIndex temperature),-          ("tempC", show (round (nvidiaGpuTemperatureCelsius temperature) :: Int))+        [ ("gpu", show $ nvidiaInfoIndex info),+          ("name", T.unpack $ nvidiaInfoName info),+          ("tempC", show (round temperature :: Int))         ]         $ ST.newSTMP         $ nvidiaTemperatureFormat config -formatTooltip :: [NvidiaGpuTemperature] -> Maybe T.Text+formatTooltip :: [NvidiaGpuInfo] -> Maybe T.Text formatTooltip [] = Nothing-formatTooltip temperatures =-  Just $ T.pack $ intercalate "\n" $ map formatOne temperatures+formatTooltip info =+  Just $ T.pack $ intercalate "\n\n" $ map formatGpu info++formatGpu :: NvidiaGpuInfo -> String+formatGpu info = intercalate "\n" $ header : catMaybes detailLines   where-    formatOne temperature =-      "NVIDIA GPU "-        ++ show (nvidiaGpuIndex temperature)-        ++ ": "-        ++ show (round (nvidiaGpuTemperatureCelsius temperature) :: Int)-        ++ "\176C"+    header =+      T.unpack (nvidiaInfoName info)+        ++ " (GPU "+        ++ show (nvidiaInfoIndex info)+        ++ ")"+    detailLines =+      [ temperatureLine info,+        measurementLine "Memory temperature" "\176C" $ nvidiaInfoMemoryTemperatureCelsius info,+        utilizationLine info,+        memoryLine info,+        powerLine info,+        measurementLine "Fan" "%" $ nvidiaInfoFanSpeedPercent info,+        ("Performance state: " ++) . T.unpack <$> nvidiaInfoPerformanceState info+      ]++temperatureLine :: NvidiaGpuInfo -> Maybe String+temperatureLine info = do+  temperature <- nvidiaInfoTemperatureCelsius info+  let details =+        catMaybes+          [ ("target " ++) . formatMeasurement "\176C" <$> nvidiaInfoTargetTemperatureCelsius info,+            ("headroom " ++) . formatMeasurement "\176C" <$> nvidiaInfoThermalHeadroomCelsius info+          ]+      suffix = if null details then "" else " (" ++ intercalate ", " details ++ ")"+  pure $ "Temperature: " ++ formatMeasurement "\176C" temperature ++ suffix++utilizationLine :: NvidiaGpuInfo -> Maybe String+utilizationLine info =+  prefixedValues+    "Utilization: "+    [ ("GPU " ++) . formatMeasurement "%" <$> nvidiaInfoGpuUtilizationPercent info,+      ("memory " ++) . formatMeasurement "%" <$> nvidiaInfoMemoryUtilizationPercent info+    ]++memoryLine :: NvidiaGpuInfo -> Maybe String+memoryLine info =+  case (nvidiaInfoMemoryUsedMiB info, nvidiaInfoMemoryTotalMiB info) of+    (Just used, Just total) ->+      Just $ "VRAM: " ++ formatMeasurement " MiB" used ++ " / " ++ formatMeasurement " MiB" total+    (Just used, Nothing) -> measurementLine "VRAM" " MiB" $ Just used+    _ -> Nothing++powerLine :: NvidiaGpuInfo -> Maybe String+powerLine info =+  case (nvidiaInfoPowerDrawWatts info, nvidiaInfoPowerLimitWatts info) of+    (Just draw, Just limit) ->+      Just $ "Power: " ++ formatMeasurement " W" draw ++ " / " ++ formatMeasurement " W" limit+    (Just draw, Nothing) -> measurementLine "Power" " W" $ Just draw+    _ -> Nothing++measurementLine :: String -> String -> Maybe Double -> Maybe String+measurementLine name unit = fmap $ ((name ++ ": ") ++) . formatMeasurement unit++prefixedValues :: String -> [Maybe String] -> Maybe String+prefixedValues prefix values =+  case catMaybes values of+    [] -> Nothing+    present -> Just $ prefix ++ intercalate ", " present++formatMeasurement :: String -> Double -> String+formatMeasurement unit value = formatNumber value ++ unit++formatNumber :: Double -> String+formatNumber value+  | abs (value - fromIntegral (round value :: Int)) < 0.05 = show (round value :: Int)+  | otherwise = printf "%.1f" value
src/System/Taffybar/Widget/OpenAIUsage.hs view
@@ -18,6 +18,8 @@     openAIUsageStackNewWith,     openAIUsageNew,     openAIUsageNewWith,+    defaultOpenAIUsageWindowLabelRenderer,+    openAIUsageWindowLabelParts,     formatOpenAIUsageWindowLabel,     formatOpenAIUsageSummaryLabel,   )@@ -30,14 +32,20 @@ import Control.Monad (forM_, forever, void) import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Reader (ask, runReaderT)-import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe)+import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) import qualified Data.Text as T+import Data.Time.Clock (UTCTime, addUTCTime, diffUTCTime) import qualified GI.GLib as GLib import qualified GI.Gtk as Gtk import System.Taffybar.Context (TaffyIO, getStateDefault) import System.Taffybar.Information.OpenAIUsage import System.Taffybar.Util (postGUIASync)-import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI)+import System.Taffybar.Widget.Util+  ( UsageWindowLabelParts (..),+    UsageWindowPosition (..),+    buildIconLabelBox,+    widgetSetClassGI,+  ) import Text.Printf (printf)  data OpenAIUsageDisplayMode@@ -76,7 +84,14 @@ data OpenAIUsageStackConfig = OpenAIUsageStackConfig   { openAIUsageStackInfoConfig :: OpenAIUsageConfig,     openAIUsageStackDefaultDisplayMode :: OpenAIUsageDisplayMode,-    openAIUsageStackFallbackText :: T.Text+    openAIUsageStackFallbackText :: T.Text,+    -- | Arrange the semantic pieces of each stack row. This controls label+    -- order and separators without requiring callers to rebuild the stack,+    -- menu, refresh handling, or rate-limit calculations.+    openAIUsageStackLabelRenderer ::+      OpenAIUsageWindowSelector ->+      UsageWindowLabelParts ->+      T.Text   }  defaultOpenAIUsageLabelConfig :: OpenAIUsageLabelConfig@@ -96,7 +111,8 @@   OpenAIUsageStackConfig     { openAIUsageStackInfoConfig = defaultOpenAIUsageConfig,       openAIUsageStackDefaultDisplayMode = OpenAIUsageDisplayUsed,-      openAIUsageStackFallbackText = "n/a"+      openAIUsageStackFallbackText = "n/a",+      openAIUsageStackLabelRenderer = defaultOpenAIUsageWindowLabelRenderer     }  openAIUsageNew :: TaffyIO Gtk.Widget@@ -163,7 +179,9 @@       openAIUsageLabelDefaultDisplayMode = openAIUsageStackDefaultDisplayMode stackConfig,       openAIUsageLabelFallbackText = openAIUsageStackFallbackText stackConfig,       openAIUsageLabelClass = "openai-usage-window-label",-      openAIUsageLabelFormatter = formatOpenAIUsageWindowLabel selector,+      openAIUsageLabelFormatter = \displayMode info ->+        openAIUsageStackLabelRenderer stackConfig selector $+          openAIUsageWindowLabelParts selector displayMode info,       openAIUsageLabelUnavailableFormatter = const (openAIUsageStackFallbackText stackConfig)     } @@ -359,18 +377,86 @@       windows =         T.intercalate           " "-          [ fromMaybeText "5h" $ formatWindowLabel displayMode "5h" <$> openAIUsagePrimaryWindow limit,-            fromMaybeText "7d" $ formatWindowLabel displayMode "7d" <$> openAIUsageSecondaryWindow limit+          [ formatOpenAIUsageWindowLabel OpenAIUsagePrimaryWindow displayMode info,+            formatOpenAIUsageWindowLabel OpenAIUsageSecondaryWindow displayMode info           ]       reached = openAIUsageLimitReached limit || isJust (openAIUsageReachedType info)    in (if reached then "AI ! " else "AI ") <> windows  formatOpenAIUsageWindowLabel :: OpenAIUsageWindowSelector -> OpenAIUsageDisplayMode -> OpenAIUsageInfo -> T.Text formatOpenAIUsageWindowLabel selector displayMode info =-  case selectedWindow selector (openAIUsageRateLimit info) of-    Nothing -> windowSelectorFallbackName selector <> " n/a"-    Just window -> formatWindowLabel displayMode (windowSelectorFallbackName selector) window+  defaultOpenAIUsageWindowLabelRenderer selector $+    openAIUsageWindowLabelParts selector displayMode info +-- | Compute the semantic pieces of a window label independently of their+-- presentation order.+openAIUsageWindowLabelParts ::+  OpenAIUsageWindowSelector ->+  OpenAIUsageDisplayMode ->+  OpenAIUsageInfo ->+  UsageWindowLabelParts+openAIUsageWindowLabelParts selector displayMode info =+  case selectedWindow selector limit of+    Nothing+      | selector == OpenAIUsagePrimaryWindow && shortLimitDisabled limit ->+          parts (windowSelectorFallbackName selector) "∞" Nothing+      | otherwise -> parts (windowSelectorFallbackName selector) "n/a" Nothing+    Just window ->+      parts+        (formatWindowName (windowSelectorFallbackName selector) window)+        (formatWindowValueWithIndicator displayMode window)+        (weeklyWindowPosition selector window)+  where+    limit = openAIUsageRateLimit info+    parts = UsageWindowLabelParts++-- | Preserve the historical OpenAI layout by default: ordinary rows show the+-- window name first, while an authoritative weekly position is shown after+-- the value and replaces the redundant weekly name.+defaultOpenAIUsageWindowLabelRenderer ::+  OpenAIUsageWindowSelector ->+  UsageWindowLabelParts ->+  T.Text+defaultOpenAIUsageWindowLabelRenderer _ parts =+  case usageWindowLabelPosition parts of+    Just position ->+      usageWindowLabelValue parts+        <> " "+        <> formatUsageWindowPosition "d" position+    Nothing ->+      usageWindowLabelName parts+        <> " "+        <> usageWindowLabelValue parts++-- | The current day of the semantic weekly window, matching the Anthropic+-- widget's position indicator. OpenAI reports both an absolute @reset_at@ and+-- the remaining seconds at the time of the response; together they recover+-- the snapshot time without consulting the clock while rendering the label.+weeklyWindowPosition :: OpenAIUsageWindowSelector -> OpenAIUsageWindow -> Maybe UsageWindowPosition+weeklyWindowPosition OpenAIUsagePrimaryWindow _ = Nothing+weeklyWindowPosition OpenAIUsageSecondaryWindow window = do+  resetAt <- openAIUsageResetAt window+  resetAfter <- openAIUsageResetAfterSeconds window+  let snapshotAt = addUTCTime (negate $ fromIntegral resetAfter) resetAt+  return $ usageWindowPosition snapshotAt resetAt++usageWindowPosition :: UTCTime -> UTCTime -> UsageWindowPosition+usageWindowPosition snapshotAt resetAt =+  UsageWindowPosition day 7+  where+    secondsPerDay = 24 * 60 * 60+    windowStart = addUTCTime (negate $ 7 * secondsPerDay) resetAt+    elapsedPeriods = diffUTCTime snapshotAt windowStart / secondsPerDay+    day :: Int+    day = max 0 $ min 7 $ ceiling elapsedPeriods++formatUsageWindowPosition :: T.Text -> UsageWindowPosition -> T.Text+formatUsageWindowPosition suffix position =+  T.pack (show $ usageWindowPositionCurrent position)+    <> "/"+    <> T.pack (show $ usageWindowPositionTotal position)+    <> suffix+ formatOpenAIUsageTooltip :: OpenAIUsageDisplayMode -> OpenAIUsageSnapshot -> Maybe T.Text formatOpenAIUsageTooltip displayMode snapshot =   Just $@@ -484,14 +570,15 @@  formatRateLimitMenuLines :: OpenAIUsageDisplayMode -> OpenAIUsageRateLimit -> [T.Text] formatRateLimitMenuLines displayMode limit =-  catMaybes-    [ formatWindowMenuLine displayMode "5h" <$> openAIUsagePrimaryWindow limit,-      formatWindowMenuLine displayMode "7d" <$> openAIUsageSecondaryWindow limit-    ]+  ["5h: unlimited" | shortLimitDisabled limit]+    <> catMaybes+      [ formatWindowMenuLine displayMode "5h" <$> selectedWindow OpenAIUsagePrimaryWindow limit,+        formatWindowMenuLine displayMode "7d" <$> selectedWindow OpenAIUsageSecondaryWindow limit+      ]     <> concat       ( catMaybes-          [ formatWindowTokenMenuLines "5h" <$> openAIUsagePrimaryWindow limit,-            formatWindowTokenMenuLines "7d" <$> openAIUsageSecondaryWindow limit+          [ formatWindowTokenMenuLines "5h" <$> selectedWindow OpenAIUsagePrimaryWindow limit,+            formatWindowTokenMenuLines "7d" <$> selectedWindow OpenAIUsageSecondaryWindow limit           ]       )     <> [formatRateLimitStatus limit]@@ -541,11 +628,12 @@   let windows =         T.intercalate           ", "-          ( mapMaybe-              (uncurry (formatWindow displayMode))-              [ ("short", openAIUsagePrimaryWindow limit),-                ("long", openAIUsageSecondaryWindow limit)-              ]+          ( ["5h unlimited" | shortLimitDisabled limit]+              <> mapMaybe+                (uncurry (formatWindow displayMode))+                [ ("short", selectedWindow OpenAIUsagePrimaryWindow limit),+                  ("long", selectedWindow OpenAIUsageSecondaryWindow limit)+                ]           )       status         | openAIUsageLimitReached limit = "reached"@@ -566,12 +654,6 @@       <> maybe "" ((" / " <>) . formatDuration) (openAIUsageWindowDurationSeconds window)       <> maybe "" ((", resets in " <>) . formatDuration) (openAIUsageResetAfterSeconds window) -formatWindowLabel :: OpenAIUsageDisplayMode -> T.Text -> OpenAIUsageWindow -> T.Text-formatWindowLabel displayMode fallbackName window =-  formatWindowName fallbackName window-    <> " "-    <> formatWindowValueWithIndicator displayMode window- formatWindowName :: T.Text -> OpenAIUsageWindow -> T.Text formatWindowName fallbackName window =   maybe fallbackName formatDuration (openAIUsageWindowDurationSeconds window)@@ -624,8 +706,29 @@   | otherwise = T.pack $ show count  selectedWindow :: OpenAIUsageWindowSelector -> OpenAIUsageRateLimit -> Maybe OpenAIUsageWindow-selectedWindow OpenAIUsagePrimaryWindow = openAIUsagePrimaryWindow-selectedWindow OpenAIUsageSecondaryWindow = openAIUsageSecondaryWindow+selectedWindow OpenAIUsagePrimaryWindow limit+  | primaryWindowIsLong limit && isNothing (openAIUsageSecondaryWindow limit) = Nothing+  | otherwise = openAIUsagePrimaryWindow limit+selectedWindow OpenAIUsageSecondaryWindow limit =+  case openAIUsageSecondaryWindow limit of+    Just window -> Just window+    Nothing+      | primaryWindowIsLong limit -> openAIUsagePrimaryWindow limit+      | otherwise -> Nothing++-- Codex temporarily omits the 5-hour window when that limit is disabled. In+-- that state the 7-day window moves into the API's primary slot instead of+-- leaving a placeholder there, so identify the window by its duration before+-- assigning it to the widget's semantic 5-hour and 7-day rows.+primaryWindowIsLong :: OpenAIUsageRateLimit -> Bool+primaryWindowIsLong limit =+  case openAIUsagePrimaryWindow limit >>= openAIUsageWindowDurationSeconds of+    Just duration -> duration >= 24 * 60 * 60+    Nothing -> False++shortLimitDisabled :: OpenAIUsageRateLimit -> Bool+shortLimitDisabled limit =+  primaryWindowIsLong limit && isNothing (openAIUsageSecondaryWindow limit)  windowSelectorFallbackName :: OpenAIUsageWindowSelector -> T.Text windowSelectorFallbackName OpenAIUsagePrimaryWindow = "5h"
src/System/Taffybar/Widget/SNITray/PrioritizedCollapsible.hs view
@@ -813,6 +813,40 @@     Gtk.widgetShowAll menu     Gtk.menuPopupAtPointer menu currentEvent +-- | State machine for the polled hover-expand mechanism.+--+-- The pointer position is polled directly (see 'pointerWithinWidget') rather+-- than inferred from crossing events, so synthetic crossing events (grabs,+-- child transitions) cannot corrupt hover state. Timestamps are+-- 'GLib.getMonotonicTime' microseconds.+data HoverPollState+  = HoverIdle+  | HoverPendingExpand Int64+  | HoverExpanded+  | HoverPendingCollapse Int64++-- | Return whether the pointer is currently within the bounds of the widget's+-- window, measured directly from GDK rather than inferred from crossing events.+--+-- This is used as ground truth to resynchronize hover state that can be+-- corrupted by synthetic crossing events (grabs, child transitions).+pointerWithinWidget :: (Gtk.IsWidget w) => w -> IO Bool+pointerWithinWidget widget = do+  maybeWindow <- Gtk.widgetGetWindow widget+  case maybeWindow of+    Nothing -> return False+    Just window -> do+      display <- Gdk.windowGetDisplay window+      seat <- Gdk.displayGetDefaultSeat display+      maybePointer <- Gdk.seatGetPointer seat+      case maybePointer of+        Nothing -> return False+        Just pointer -> do+          (_, x, y, _) <- Gdk.windowGetDevicePosition window pointer+          width <- Gdk.windowGetWidth window+          height <- Gdk.windowGetHeight window+          return (x >= 0 && y >= 0 && x < width && y < height)+ -- | Build a collapsible StatusNotifierItem tray with priority editing controls -- and persisted priority state. sniTrayPrioritizedCollapsibleNew :: TaffyIO Gtk.Widget@@ -861,8 +895,8 @@     prioritiesRef <- newIORef persistedPriorities     expandedRef <- newIORef collapsibleSNITrayStartExpanded     hoverExpandedRef <- newIORef False-    hoverInsideRef <- newIORef False-    hoverSerialRef <- newIORef (0 :: Int)+    hoverPollStateRef <- newIORef HoverIdle+    hoverPollActiveRef <- newIORef False     animationSerialRef <- newIORef (0 :: Int)     animationActiveRef <- newIORef False     priorityEditModeRef <- newIORef prioritizedCollapsibleSNITrayStartPriorityEditMode@@ -1145,22 +1179,69 @@                   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+        hoverPollIntervalMs = 100 :: Word32 +        engageHoverPoll = do+          state <- readIORef hoverPollStateRef+          case state of+            HoverIdle -> do+              now <- GLib.getMonotonicTime+              writeIORef hoverPollStateRef (HoverPendingExpand now)+            _ -> return ()+          pollActive <- readIORef hoverPollActiveRef+          unless pollActive $ do+            writeIORef hoverPollActiveRef True+            void $+              GLib.timeoutAdd GLib.PRIORITY_DEFAULT hoverPollIntervalMs $ do+                keepPolling <- stepHoverPoll+                unless keepPolling $ writeIORef hoverPollActiveRef False+                return keepPolling++        stepHoverPoll = do+          state <- readIORef hoverPollStateRef+          now <- GLib.getMonotonicTime+          let expandDelayUs =+                fromIntegral prioritizedCollapsibleSNITrayHoverExpandDelayMs * 1000 :: Int64+              collapseDelayUs =+                fromIntegral prioritizedCollapsibleSNITrayHoverCollapseDelayMs * 1000 :: Int64+          case state of+            HoverIdle -> return False+            HoverPendingExpand since -> do+              inside <- pointerWithinWidget outerEventBox+              if not inside+                then do+                  writeIORef hoverPollStateRef HoverIdle+                  return False+                else+                  if now - since >= expandDelayUs+                    then do+                      setHoverExpanded True+                      writeIORef hoverPollStateRef HoverExpanded+                      return True+                    else return True+            HoverExpanded -> do+              inside <- pointerWithinWidget outerEventBox+              if inside+                then return True+                else do+                  writeIORef hoverPollStateRef (HoverPendingCollapse now)+                  return True+            HoverPendingCollapse since -> do+              inside <- pointerWithinWidget outerEventBox+              if inside+                then do+                  writeIORef hoverPollStateRef HoverExpanded+                  return True+                else+                  if now - since >= collapseDelayUs+                    then do+                      setHoverExpanded False+                      writeIORef hoverPollStateRef HoverIdle+                      return False+                    else return True+         cancelHoverExpansion = do-          modifyIORef' hoverSerialRef (+ 1)+          writeIORef hoverPollStateRef HoverIdle           writeIORef hoverExpandedRef False           modifyIORef' animationSerialRef (+ 1)           writeIORef animationActiveRef False@@ -1179,7 +1260,7 @@               visibleCount =                 max 0 (min totalCount naturalVisibleCount)               hiddenCount = max 0 (totalCount - visibleCount)-              hiddenCountText = T.pack (show hiddenCount)+              hiddenCountText = T.pack ('+' : show hiddenCount)               priorityForInfo info =                 itemPriorityFromMap                   priorityMin@@ -1325,22 +1406,9 @@         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+        engageHoverPoll         return False       return () 
src/System/Taffybar/Widget/Temperature.hs view
@@ -18,6 +18,8 @@   ( -- * Combined icon+label widget     temperatureNew,     temperatureNewWith,+    temperatureNewChan,+    temperatureNewChanWith,      -- * Icon-only widget     temperatureIconNew,@@ -26,6 +28,8 @@     -- * Label-only widget     temperatureLabelNew,     temperatureLabelNewWith,+    temperatureLabelNewChan,+    temperatureLabelNewChanWith,      -- * Configuration     TemperatureConfig (..),@@ -36,12 +40,17 @@   ) where +import Control.Monad (void, when) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Default (Default (..))+import Data.IORef (newIORef, readIORef, writeIORef) import Data.List (intercalate) import qualified Data.Text as T import qualified GI.Gtk as Gtk+import System.Taffybar.Context (TaffyIO) import System.Taffybar.Information.Temperature+import System.Taffybar.Util (postGUIASync)+import System.Taffybar.Widget.Generic.ChannelWidget (channelWidgetNew) import System.Taffybar.Widget.Generic.PollingLabel (pollingLabelNewWithTooltip) import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI) import qualified Text.StringTemplate as ST@@ -57,10 +66,13 @@     tempWarningThreshold :: Double,     -- | Temperature (in Celsius) at which to show critical style (default: 85)     tempCriticalThreshold :: Double,-    -- | How often to poll for temperature updates, in seconds (default: 10)+    -- | How often to poll for temperature updates, in seconds (default: 30)     tempPollInterval :: Double,     -- | Filter function to select which sensors to monitor (default: all)     tempSensorFilter :: ThermalSensor -> Bool,+    -- | Select additional sensors to include only in the tooltip. Sensors+    -- selected by 'tempSensorFilter' are always included (default: none).+    tempTooltipSensorFilter :: ThermalSensor -> Bool,     -- | How to aggregate multiple sensor readings (default: maximum)     tempAggregation :: [TemperatureInfo] -> Maybe Double,     -- | Nerd font icon character (default U+F2C9, nf-fa-thermometer).@@ -78,8 +90,9 @@       tempUnit = Celsius,       tempWarningThreshold = 70,       tempCriticalThreshold = 85,-      tempPollInterval = 10,+      tempPollInterval = 30,       tempSensorFilter = const True,+      tempTooltipSensorFilter = const False,       tempAggregation = \temps ->         if null temps           then Nothing@@ -99,6 +112,19 @@   buildIconLabelBox iconWidget labelWidget     >>= (`widgetSetClassGI` "temperature") +-- | Create a combined icon+label widget backed by the shared channel.+temperatureNewChan :: TaffyIO Gtk.Widget+temperatureNewChan = temperatureNewChanWith defaultTemperatureConfig++-- | Create a combined icon+label widget backed by the shared channel.+temperatureNewChanWith :: TemperatureConfig -> TaffyIO Gtk.Widget+temperatureNewChanWith config = do+  iconWidget <- liftIO $ temperatureIconNewWith config+  labelWidget <- temperatureLabelNewChanWith config+  liftIO $+    buildIconLabelBox iconWidget labelWidget+      >>= (`widgetSetClassGI` "temperature")+ -- | Create a temperature icon widget with default configuration. temperatureIconNew :: (MonadIO m) => m Gtk.Widget temperatureIconNew = temperatureIconNewWith defaultTemperatureConfig@@ -115,29 +141,59 @@ temperatureLabelNew :: (MonadIO m) => m Gtk.Widget temperatureLabelNew = temperatureLabelNewWith defaultTemperatureConfig --- | Create a temperature label widget with custom configuration.+-- | Create a polling temperature label with custom configuration. temperatureLabelNewWith :: (MonadIO m) => TemperatureConfig -> m Gtk.Widget temperatureLabelNewWith config = liftIO $ do-  -- Discover sensors once at startup, filtered by config-  allSensors <- discoverSensors-  let sensors = filter (tempSensorFilter config) allSensors--  widget <- pollingLabelNewWithTooltip (tempPollInterval config) $ do-    temps <- readTemperaturesFiltered sensors-    case tempAggregation config temps of-      Nothing -> return (T.pack "N/A", Nothing)-      Just tempC -> do-        let tempF = convertTemperature Fahrenheit tempC-            tempK = convertTemperature Kelvin tempC-            tempDisplay = convertTemperature (tempUnit config) tempC-            labelText = formatTemperature config tempDisplay tempC tempF tempK-            tooltipText = formatTooltip temps-        return (labelText, Just tooltipText)+  widget <-+    pollingLabelNewWithTooltip (tempPollInterval config) $+      formatTemperatureInfo config <$> readAllTemperatures   widgetSetClassGI widget "temperature-label"++-- | Create a channel-driven temperature label with the default configuration.+temperatureLabelNewChan :: TaffyIO Gtk.Widget+temperatureLabelNewChan = temperatureLabelNewChanWith defaultTemperatureConfig++-- | Create a channel-driven temperature label with custom configuration.+-- Sensor discovery and polling are shared by every channel-driven widget.+temperatureLabelNewChanWith :: TemperatureConfig -> TaffyIO Gtk.Widget+temperatureLabelNewChanWith config = do+  chan <- getTemperatureInfoChan $ tempPollInterval config+  initialInfo <- getTemperatureInfoState $ tempPollInterval config++  liftIO $ do+    label <- Gtk.labelNew Nothing+    _ <- widgetSetClassGI label "temperature-label"+    renderedRef <- newIORef Nothing++    let updateLabel info = do+          let rendered@(labelText, tooltipText) = formatTemperatureInfo config info+          previous <- readIORef renderedRef+          when (previous /= Just rendered) $ do+            writeIORef renderedRef (Just rendered)+            postGUIASync $ do+              Gtk.labelSetText label labelText+              Gtk.widgetSetTooltipText label tooltipText++    void $ Gtk.onWidgetRealize label $ updateLabel initialInfo+    Gtk.widgetShowAll label+    Gtk.toWidget =<< channelWidgetNew label chan updateLabel++formatTemperatureInfo :: TemperatureConfig -> [TemperatureInfo] -> (T.Text, Maybe T.Text)+formatTemperatureInfo config allTemperatures =+  case tempAggregation config temperatures of+    Nothing -> ("N/A", tooltipText)+    Just tempC ->+      let tempF = convertTemperature Fahrenheit tempC+          tempK = convertTemperature Kelvin tempC+          tempDisplay = convertTemperature (tempUnit config) tempC+       in (formatTemperature config tempDisplay tempC tempF tempK, tooltipText)   where-    readTemperaturesFiltered :: [ThermalSensor] -> IO [TemperatureInfo]-    readTemperaturesFiltered sensors =-      filter (\t -> tempSensor t `elem` sensors) <$> readAllTemperatures+    includedInLabel = tempSensorFilter config . tempSensor+    includedInTooltip info =+      includedInLabel info || tempTooltipSensorFilter config (tempSensor info)+    temperatures = filter includedInLabel allTemperatures+    tooltipTemperatures = filter includedInTooltip allTemperatures+    tooltipText = formatTooltip tooltipTemperatures  -- | Format the temperature label using the template formatTemperature :: TemperatureConfig -> Double -> Double -> Double -> Double -> T.Text@@ -156,10 +212,11 @@     formatDouble :: Double -> String     formatDouble d = show (round d :: Int) --- | Format tooltip showing all sensor readings-formatTooltip :: [TemperatureInfo] -> T.Text+-- | Format tooltip showing all selected sensor readings.+formatTooltip :: [TemperatureInfo] -> Maybe T.Text+formatTooltip [] = Nothing formatTooltip temps =-  T.pack $ intercalate "\n" $ map formatSensor temps+  Just $ T.pack $ intercalate "\n" $ map formatSensor temps   where     formatSensor info =       sensorName (tempSensor info)
src/System/Taffybar/Widget/Util.hs view
@@ -41,6 +41,25 @@ import System.Taffybar.Util import Text.Printf +-- | A position within a usage window, such as day 3 of a 7-day window.+-- Keeping this semantic rather than preformatted lets widget configurations+-- choose their own compactness, separators, and ordering.+data UsageWindowPosition = UsageWindowPosition+  { usageWindowPositionCurrent :: Int,+    usageWindowPositionTotal :: Int+  }+  deriving (Eq, Show)++-- | Preformatted semantic pieces of a usage-window label. Widget-specific+-- code computes the values; callers can arrange them without duplicating the+-- usage and reset-window calculations.+data UsageWindowLabelParts = UsageWindowLabelParts+  { usageWindowLabelName :: T.Text,+    usageWindowLabelValue :: T.Text,+    usageWindowLabelPosition :: Maybe UsageWindowPosition+  }+  deriving (Eq, Show)+ -- | Common record used for window icon widgets in workspace switchers. data WindowIconWidget a = WindowIconWidget   { iconContainer :: Gtk.EventBox,
taffybar.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: taffybar-version: 7.2.6+version: 7.2.7 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.@@ -75,7 +75,7 @@                , 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+               , dbus-menu >= 0.1.3.4 && < 0.2                , directory >= 1.3 && < 1.4                , disk-free-space >= 0.1.0.1 && < 0.2                , dyre >= 0.9.0 && < 0.10@@ -152,6 +152,7 @@                  , System.Taffybar.Information.Battery                  , System.Taffybar.Information.Bluetooth                  , System.Taffybar.Information.CPU2+                 , System.Taffybar.Information.CPUFrequency                  , System.Taffybar.Information.ChromeWindowInfo                  , System.Taffybar.Information.Crypto                  , System.Taffybar.Information.DiskIO@@ -199,6 +200,7 @@                  , System.Taffybar.Widget.BatteryTextIcon                  , System.Taffybar.Widget.Bluetooth                  , System.Taffybar.Widget.CPUMonitor+                 , System.Taffybar.Widget.CPUFrequency                  , System.Taffybar.Widget.CommandRunner                  , System.Taffybar.Widget.CoordinatedClock                  , System.Taffybar.Widget.Crypto@@ -394,9 +396,12 @@   hs-source-dirs: test/unit   main-is: unit-tests.hs   other-modules: UnitSpec+               , DBusMenuSpec                , System.Taffybar.AuthSpec                , System.Taffybar.AppearanceSpec                , System.Taffybar.ContextSpec+               , System.Taffybar.Information.ASUSSpec+               , System.Taffybar.Information.CPUFrequencySpec                , System.Taffybar.Information.CryptoSpec                , System.Taffybar.Information.LayoutSpec                , System.Taffybar.Information.NvidiaSpec@@ -406,6 +411,9 @@                , System.Taffybar.Information.WakeupSpec                , System.Taffybar.SimpleConfigSpec                , System.Taffybar.WidgetPrioritySpec+               , System.Taffybar.Widget.AnthropicUsageSpec+               , System.Taffybar.Widget.BatterySpec+               , System.Taffybar.Widget.OpenAIUsageSpec                , System.Taffybar.Widget.SNITray.PrioritizedCollapsibleSpec                , System.Taffybar.Widget.Workspaces.ChannelSpec                , System.Taffybar.Widget.WindowsSpec@@ -413,6 +421,7 @@                , bytestring                , containers                , dbus+               , dbus-menu                , directory                , JuicyPixels                , filepath@@ -425,6 +434,7 @@                , status-notifier-item                , taffybar                , taffybar:testlib+               , time                , typed-process                , unix                , unliftio
taffybar.css view
@@ -84,6 +84,8 @@ 	padding-left: 4px; 	padding-right: 4px; 	margin-left: 3px;+	opacity: 0.6;+	transition: opacity .15s ease, background-color .2s ease, border-color .2s ease; }  .sni-tray-expand-toggle:hover,@@ -91,10 +93,22 @@ .sni-tray-settings-toggle:hover { 	background-color: rgba(255, 255, 255, 0.15); 	border-color: rgba(255, 255, 255, 0.30);+	opacity: 1; }  .sni-tray-overflow-count-label { 	min-width: 12px;+	font-size: 90%;+	padding: 0 2px;+}++.sni-tray-collapsible {+	border-radius: 6px;+	transition: background-color .25s ease;+}++.sni-tray-collapsible-expanded {+	background-color: rgba(255, 255, 255, 0.06); }  .sni-tray-edit-toggle-active,
+ test/unit/DBusMenuSpec.hs view
@@ -0,0 +1,74 @@+{-# LANGUAGE OverloadedStrings #-}++module DBusMenuSpec (spec) where++import DBus (toVariant)+import DBusMenu+  ( LayoutNode (..),+    MenuItemShape,+    menuItemShape,+  )+import DBusMenu.Reconcile+  ( ReconcileAction (..),+    planReconciliation,+  )+import Data.Int (Int32)+import Data.Map.Strict qualified as Map+import Test.Hspec++spec :: Spec+spec = do+  describe "DBusMenu reconciliation" $ do+    it "reuses IDs whose GTK shape is unchanged" $ do+      let original = leaf 1 "Before" True+          updated = leaf 1 "After" False+          existing :: Map.Map Int32 MenuItemShape+          existing = Map.singleton 1 (menuItemShape original)+      planReconciliation existing [(1, menuItemShape updated)]+        `shouldBe` [ReuseItem 1]++    it "reuses stable IDs across additions, removals, and reordering" $ do+      let shape = menuItemShape (leaf 0 "" True)+          existing :: Map.Map Int32 MenuItemShape+          existing = Map.fromList [(1, shape), (2, shape), (3, shape)]+      planReconciliation existing [(3, shape), (2, shape), (4, shape)]+        `shouldBe` [ReuseItem 3, ReuseItem 2, BuildItem 4]++    it "builds a replacement when an item's GTK shape changes" $ do+      let original = leaf 1 "Leaf" True+          updated = submenu 1 "Submenu"+          existing :: Map.Map Int32 MenuItemShape+          existing = Map.singleton 1 (menuItemShape original)+      planReconciliation existing [(1, menuItemShape updated)]+        `shouldBe` [BuildItem 1]++    it "does not reuse the same widget for a duplicate desired ID" $ do+      let shape = menuItemShape (leaf 1 "Leaf" True)+          existing :: Map.Map Int32 MenuItemShape+          existing = Map.singleton 1 shape+      planReconciliation existing [(1, shape), (1, shape)]+        `shouldBe` [ReuseItem 1, BuildItem 1]++leaf :: Int -> String -> Bool -> LayoutNode+leaf itemId label enabled =+  LayoutNode+    { lnId = fromIntegral itemId,+      lnProps =+        Map.fromList+          [ ("label", toVariant label),+            ("enabled", toVariant enabled)+          ],+      lnChildren = []+    }++submenu :: Int -> String -> LayoutNode+submenu itemId label =+  LayoutNode+    { lnId = fromIntegral itemId,+      lnProps =+        Map.fromList+          [ ("label", toVariant label),+            ("children-display", toVariant ("submenu" :: String))+          ],+      lnChildren = []+    }
+ test/unit/System/Taffybar/Information/ASUSSpec.hs view
@@ -0,0 +1,14 @@+module System.Taffybar.Information.ASUSSpec (spec) where++import System.Taffybar.Information.ASUS+import Test.Hspec++spec :: Spec+spec = describe "ASUS platform profile DBus encoding" $ do+  it "decodes the asusd 6 profile values" $+    map asusProfileFromUInt [0, 1, 2, 99]+      `shouldBe` [Just Balanced, Just Performance, Just Quiet, Nothing]++  it "encodes the asusd 6 profile values" $+    map asusProfileToUInt [Balanced, Performance, Quiet]+      `shouldBe` [0, 1, 2]
+ test/unit/System/Taffybar/Information/CPUFrequencySpec.hs view
@@ -0,0 +1,22 @@+module System.Taffybar.Information.CPUFrequencySpec (spec) where++import System.Taffybar.Information.CPUFrequency+import Test.Hspec++spec :: Spec+spec =+  describe "CPU frequency summaries" $ do+    it "reports average, range, and policy count" $ do+      let info = summarizeCPUFrequencies [1_000_000, 2_000_000, 3_000_000]+      cpuFrequencyAverageKHz info `shouldBe` Just 2_000_000+      cpuFrequencyMinimumKHz info `shouldBe` Just 1_000_000+      cpuFrequencyMaximumKHz info `shouldBe` Just 3_000_000+      cpuFrequencySampleCount info `shouldBe` 3+      cpuFrequencyAverageGHz info `shouldBe` Just 2++    it "represents a missing frequency source without fabricated readings" $ do+      let info = summarizeCPUFrequencies []+      cpuFrequencyAverageKHz info `shouldBe` Nothing+      cpuFrequencyMinimumKHz info `shouldBe` Nothing+      cpuFrequencyMaximumKHz info `shouldBe` Nothing+      cpuFrequencySampleCount info `shouldBe` 0
test/unit/System/Taffybar/Information/NvidiaSpec.hs view
@@ -2,17 +2,91 @@  module System.Taffybar.Information.NvidiaSpec (spec) where +import Data.Text qualified as T import System.Taffybar.Information.Nvidia import Test.Hspec  spec :: Spec-spec = describe "NVIDIA temperature parsing" $ do-  it "parses and sorts nvidia-smi temperature rows" $-    parseNvidiaGpuTemperatures "2, 73\n0, 56\n"-      `shouldBe` [ NvidiaGpuTemperature 0 56,-                   NvidiaGpuTemperature 2 73-                 ]+spec = do+  describe "NVIDIA information parsing" $ do+    it "parses rich nvidia-smi XML snapshots and optional readings" $ do+      let info = parseNvidiaGpuInfo sampleXml+      map nvidiaInfoIndex info `shouldBe` [0, 2]+      case info of+        firstGpu : _ -> do+          nvidiaInfoName firstGpu `shouldBe` "NVIDIA Test GPU"+          nvidiaInfoTemperatureCelsius firstGpu `shouldBe` Just 61+          nvidiaInfoMemoryTemperatureCelsius firstGpu `shouldBe` Nothing+          nvidiaInfoTargetTemperatureCelsius firstGpu `shouldBe` Just 87+          nvidiaInfoThermalHeadroomCelsius firstGpu `shouldBe` Just 26+          nvidiaInfoGpuUtilizationPercent firstGpu `shouldBe` Just 37+          nvidiaInfoMemoryUsedMiB firstGpu `shouldBe` Just 2048+          nvidiaInfoPowerDrawWatts firstGpu `shouldBe` Just 34.5+          nvidiaInfoPowerLimitWatts firstGpu `shouldBe` Just 80+        [] -> expectationFailure "expected parsed GPU information" -  it "ignores malformed and unavailable rows" $-    parseNvidiaGpuTemperatures "0, N/A\nbad row\n1, 64\n"-      `shouldBe` [NvidiaGpuTemperature 1 64]+    it "rejects malformed XML" $+      parseNvidiaGpuInfo "not xml" `shouldBe` []++  describe "NVIDIA runtime power states" $ do+    it "preserves querying when no NVIDIA PCI devices are detected" $+      shouldQueryNvidiaForRuntimeStatuses [] `shouldBe` True++    it "skips querying when every device is suspended or suspending" $+      shouldQueryNvidiaForRuntimeStatuses+        [Just "suspended", Just "suspending"]+        `shouldBe` False++    it "preserves querying when any device is active" $+      shouldQueryNvidiaForRuntimeStatuses+        [Just "suspended", Just "active"]+        `shouldBe` True++    it "preserves querying when any runtime status is missing" $+      shouldQueryNvidiaForRuntimeStatuses+        [Just "suspended", Nothing]+        `shouldBe` True++    it "preserves querying for unknown runtime states" $+      shouldQueryNvidiaForRuntimeStatuses [Just "unsupported"] `shouldBe` True++  describe "NVIDIA temperature parsing" $ do+    it "parses and sorts nvidia-smi temperature rows" $+      parseNvidiaGpuTemperatures "2, 73\n0, 56\n"+        `shouldBe` [ NvidiaGpuTemperature 0 56,+                     NvidiaGpuTemperature 2 73+                   ]++    it "ignores malformed and unavailable rows" $+      parseNvidiaGpuTemperatures "0, N/A\nbad row\n1, 64\n"+        `shouldBe` [NvidiaGpuTemperature 1 64]++sampleXml :: T.Text+sampleXml =+  T.unlines+    [ "<nvidia_smi_log>",+      "  <gpu>",+      "    <product_name>NVIDIA Second GPU</product_name>",+      "    <minor_number>2</minor_number>",+      "    <temperature><gpu_temp>55 C</gpu_temp></temperature>",+      "  </gpu>",+      "  <gpu>",+      "    <product_name>NVIDIA Test GPU</product_name>",+      "    <minor_number>0</minor_number>",+      "    <fan_speed>N/A</fan_speed>",+      "    <performance_state>P2</performance_state>",+      "    <utilization><gpu_util>37 %</gpu_util><memory_util>3 %</memory_util></utilization>",+      "    <fb_memory_usage><used>2048 MiB</used><total>12282 MiB</total></fb_memory_usage>",+      "    <temperature>",+      "      <gpu_temp>61 C</gpu_temp>",+      "      <gpu_temp_tlimit>26 C</gpu_temp_tlimit>",+      "      <gpu_target_temperature>87 C</gpu_target_temperature>",+      "      <memory_temp>N/A</memory_temp>",+      "    </temperature>",+      "    <gpu_power_readings>",+      "      <average_power_draw>34.50 W</average_power_draw>",+      "      <current_power_limit>80.00 W</current_power_limit>",+      "    </gpu_power_readings>",+      "  </gpu>",+      "</nvidia_smi_log>"+    ]
+ test/unit/System/Taffybar/Widget/AnthropicUsageSpec.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE OverloadedStrings #-}++module System.Taffybar.Widget.AnthropicUsageSpec (spec) where++import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Time.Clock (UTCTime)+import System.Taffybar.Information.AnthropicUsage+import System.Taffybar.Widget.AnthropicUsage+import System.Taffybar.Widget.Util (UsageWindowLabelParts (..), UsageWindowPosition (..))+import Test.Hspec++spec :: Spec+spec =+  describe "Anthropic usage weekly window label" $ do+    it "shows the current day of seven when the OAuth endpoint supplies a reset time" $+      formatAnthropicUsageWindowLabel AnthropicUsageWeeklyWindow AnthropicUsageDisplayRemaining infoWithReset+        `shouldBe` "7d 35%·F35%r 3/7"++    it "uses the ceiling of elapsed 24-hour periods at an exact day boundary" $+      formatAnthropicUsageWindowLabel AnthropicUsageWeeklyWindow AnthropicUsageDisplayRemaining infoAtOneDay+        `shouldBe` "7d 35%r 1/7"++    it "omits the window day for a synthesized transcript fallback window" $+      formatAnthropicUsageWindowLabel AnthropicUsageWeeklyWindow AnthropicUsageDisplayRemaining infoWithoutReset+        `shouldBe` "7d 35%r"++    it "exposes semantic label parts for configurable renderers" $+      anthropicUsageWindowLabelParts AnthropicUsageWeeklyWindow AnthropicUsageDisplayRemaining infoWithReset+        `shouldBe` UsageWindowLabelParts "7d" "35%·F35%r" (Just $ UsageWindowPosition 3 7)++infoWithReset :: AnthropicUsageInfo+infoWithReset =+  (usageInfo generatedAt $ Just resetAt)+    { anthropicUsageScopedWeeklyWindow = Just $ usageWindow "Fable" Nothing+    }++infoAtOneDay :: AnthropicUsageInfo+infoAtOneDay =+  usageInfo+    (read "2026-07-13 12:00:00 UTC")+    (Just resetAt)++infoWithoutReset :: AnthropicUsageInfo+infoWithoutReset = usageInfo generatedAt Nothing++usageInfo :: UTCTime -> Maybe UTCTime -> AnthropicUsageInfo+usageInfo generated reset =+  AnthropicUsageInfo+    { anthropicUsageGeneratedAt = generated,+      anthropicUsageSubscriptionType = Just "max",+      anthropicUsageRateLimitTier = Nothing,+      anthropicUsageHasAvailableSubscription = Just True,+      anthropicUsageExtraUsageDisabledReason = Nothing,+      anthropicUsageOrganizationName = Nothing,+      anthropicUsageFiveHourWindow = usageWindow "5h" Nothing,+      anthropicUsageWeeklyWindow = usageWindow "7d" reset,+      anthropicUsageScopedWeeklyWindow = Nothing+    }++generatedAt :: UTCTime+generatedAt = read "2026-07-15 00:00:00 UTC"++resetAt :: UTCTime+resetAt = read "2026-07-19 12:00:00 UTC"++usageWindow :: Text -> Maybe UTCTime -> AnthropicUsageWindow+usageWindow name resetTime =+  AnthropicUsageWindow+    { anthropicUsageWindowName = name,+      anthropicUsageWindowStart = read "2026-07-12 12:00:00 UTC",+      anthropicUsageWindowEnd = fromMaybe (read "2026-07-19 12:00:00 UTC") resetTime,+      anthropicUsageWindowResetAt = resetTime,+      anthropicUsageWindowBudgetTokens = Nothing,+      anthropicUsageWindowUtilizationPercent = Just 65,+      anthropicUsageWindowTotals = mempty+    }
+ test/unit/System/Taffybar/Widget/BatterySpec.hs view
@@ -0,0 +1,40 @@+{-# LANGUAGE OverloadedStrings #-}++module System.Taffybar.Widget.BatterySpec (spec) where++import DBus (toVariant)+import Data.Map qualified as M+import Data.Word (Word32)+import System.Taffybar.Information.Battery+import System.Taffybar.Widget.Battery+import Test.Hspec++spec :: Spec+spec = do+  describe "formatBatteryInfo" $ do+    it "formats the current energy rate in watts" $ do+      let info =+            infoMapToBatteryInfo $+              M.fromList+                [ ("Percentage", toVariant (96.0 :: Double)),+                  ("EnergyRate", toVariant (19.966 :: Double))+                ]+      formatBatteryInfo info "$percentage$% $watts$W"+        `shouldBe` "96% 20.0W"++    it "uses positive watts for charging and negative watts for discharging" $ do+      let info state rate =+            infoMapToBatteryInfo $+              M.fromList+                [ ("State", toVariant (state :: Word32)),+                  ("EnergyRate", toVariant (rate :: Double))+                ]+      formatBatteryInfo (info 1 19.966) "$signedWatts$W"+        `shouldBe` "+20.0W"+      formatBatteryInfo (info 2 12.34) "$signedWatts$W"+        `shouldBe` "-12.3W"++  describe "defaultMonitorDisplayBatteryProperties" $+    it "refreshes on UPower sampling updates" $+      defaultMonitorDisplayBatteryProperties+        `shouldContain` ["EnergyRate", "UpdateTime"]
+ test/unit/System/Taffybar/Widget/OpenAIUsageSpec.hs view
@@ -0,0 +1,90 @@+{-# LANGUAGE OverloadedStrings #-}++module System.Taffybar.Widget.OpenAIUsageSpec (spec) where++import System.Taffybar.Information.OpenAIUsage+import System.Taffybar.Widget.OpenAIUsage+import System.Taffybar.Widget.Util (UsageWindowLabelParts (..), UsageWindowPosition (..))+import Test.Hspec++spec :: Spec+spec =+  describe "OpenAI usage window labels" $ do+    it "shows an omitted 5-hour limit as unlimited and keeps the 7-day window in the weekly row" $ do+      formatOpenAIUsageWindowLabel OpenAIUsagePrimaryWindow OpenAIUsageDisplayUsed weeklyOnlyInfo+        `shouldBe` "5h ∞"+      formatOpenAIUsageWindowLabel OpenAIUsageSecondaryWindow OpenAIUsageDisplayUsed weeklyOnlyInfo+        `shouldBe` "7d 17%u"+      formatOpenAIUsageSummaryLabel OpenAIUsageDisplayUsed weeklyOnlyInfo+        `shouldBe` "AI 5h ∞ 7d 17%u"++    it "preserves the normal 5-hour and 7-day layout when both windows are present" $ do+      formatOpenAIUsageWindowLabel OpenAIUsagePrimaryWindow OpenAIUsageDisplayRemaining normalInfo+        `shouldBe` "5h 75%r"+      formatOpenAIUsageWindowLabel OpenAIUsageSecondaryWindow OpenAIUsageDisplayRemaining normalInfo+        `shouldBe` "7d 83%r"++    it "shows the current day of seven when the API supplies an authoritative reset time" $ do+      formatOpenAIUsageWindowLabel OpenAIUsageSecondaryWindow OpenAIUsageDisplayRemaining infoWithReset+        `shouldBe` "83%r 3/7d"+      formatOpenAIUsageSummaryLabel OpenAIUsageDisplayRemaining infoWithReset+        `shouldBe` "AI 5h 75%r 83%r 3/7d"++    it "keeps the window day on the weekly row when the 5-hour limit is omitted" $+      formatOpenAIUsageWindowLabel OpenAIUsageSecondaryWindow OpenAIUsageDisplayUsed weeklyOnlyInfoWithReset+        `shouldBe` "17%u 3/7d"++    it "exposes semantic label parts for configurable renderers" $+      openAIUsageWindowLabelParts OpenAIUsageSecondaryWindow OpenAIUsageDisplayRemaining infoWithReset+        `shouldBe` UsageWindowLabelParts "7d" "83%r" (Just $ UsageWindowPosition 3 7)++weeklyOnlyInfo :: OpenAIUsageInfo+weeklyOnlyInfo = usageInfo weeklyWindow Nothing++normalInfo :: OpenAIUsageInfo+normalInfo = usageInfo fiveHourWindow (Just weeklyWindow)++infoWithReset :: OpenAIUsageInfo+infoWithReset = usageInfo fiveHourWindow (Just weeklyWindowWithReset)++weeklyOnlyInfoWithReset :: OpenAIUsageInfo+weeklyOnlyInfoWithReset = usageInfo weeklyWindowWithReset Nothing++usageInfo :: OpenAIUsageWindow -> Maybe OpenAIUsageWindow -> OpenAIUsageInfo+usageInfo primary secondary =+  OpenAIUsageInfo+    { openAIUsagePlanType = Just "pro",+      openAIUsageRateLimit =+        OpenAIUsageRateLimit+          { openAIUsageAllowed = True,+            openAIUsageLimitReached = False,+            openAIUsagePrimaryWindow = Just primary,+            openAIUsageSecondaryWindow = secondary+          },+      openAIUsageAdditionalRateLimits = [],+      openAIUsageCredits = Nothing,+      openAIUsageReachedType = Nothing+    }++fiveHourWindow :: OpenAIUsageWindow+fiveHourWindow = usageWindow 25 (5 * 60 * 60)++weeklyWindow :: OpenAIUsageWindow+weeklyWindow = usageWindow 17 (7 * 24 * 60 * 60)++weeklyWindowWithReset :: OpenAIUsageWindow+weeklyWindowWithReset =+  weeklyWindow+    { openAIUsageResetAfterSeconds = Just (4 * 24 * 60 * 60),+      openAIUsageResetAt = Just $ read "2026-07-22 12:00:00 UTC"+    }++usageWindow :: Int -> Int -> OpenAIUsageWindow+usageWindow usedPercent duration =+  OpenAIUsageWindow+    { openAIUsageUsedPercent = usedPercent,+      openAIUsageWindowDurationSeconds = Just duration,+      openAIUsageResetAfterSeconds = Just duration,+      openAIUsageResetAt = Nothing,+      openAIUsageWindowTotals = Nothing+    }