taffybar 5.1.3 → 5.2.0
raw patch · 166 files changed
+19005/−9001 lines, 166 filesdep +dhalldep ~dbus-menudep ~gi-gtk3dep ~gtk-sni-traybinary-added
Dependencies added: dhall
Dependency ranges changed: dbus-menu, gi-gtk3, gtk-sni-tray, status-notifier-item
Files
- CHANGELOG.md +58/−0
- app/AppearanceSnap.hs +337/−129
- app/AppearanceSnapHyprland.hs +206/−105
- app/DhallConfig.hs +534/−0
- app/Main.hs +121/−36
- app/TestWM.hs +8/−7
- dbus-xml/org.freedesktop.login1.Manager.xml +10/−0
- doc/config.md +9/−0
- doc/custom.md +18/−0
- doc/dhall-config-proposal.md +214/−0
- doc/dhall-config.md +163/−0
- doc/faq.md +216/−2
- src/System/Taffybar.hs +166/−167
- src/System/Taffybar/Auth.hs +20/−11
- src/System/Taffybar/Context.hs +1033/−685
- src/System/Taffybar/Context/Backend.hs +34/−26
- src/System/Taffybar/DBus.hs +11/−6
- src/System/Taffybar/DBus/Client/Login1Manager.hs +21/−0
- src/System/Taffybar/DBus/Client/MPRIS2.hs +4/−3
- src/System/Taffybar/DBus/Client/NetworkManager.hs +7/−6
- src/System/Taffybar/DBus/Client/NetworkManagerAccessPoint.hs +6/−5
- src/System/Taffybar/DBus/Client/NetworkManagerActiveConnection.hs +6/−5
- src/System/Taffybar/DBus/Client/Params.hs +56/−27
- src/System/Taffybar/DBus/Client/PulseAudioCore.hs +6/−5
- src/System/Taffybar/DBus/Client/PulseAudioDevice.hs +6/−5
- src/System/Taffybar/DBus/Client/PulseAudioServerLookup.hs +6/−5
- src/System/Taffybar/DBus/Client/UPower.hs +8/−6
- src/System/Taffybar/DBus/Client/UPowerDevice.hs +9/−5
- src/System/Taffybar/DBus/Client/Util.hs +61/−49
- src/System/Taffybar/DBus/Toggle.hs +77/−57
- src/System/Taffybar/Example.hs +102/−66
- src/System/Taffybar/Hooks.hs +103/−41
- src/System/Taffybar/Hyprland.hs +33/−30
- src/System/Taffybar/Information/ASUS.hs +341/−0
- src/System/Taffybar/Information/Backlight.hs +79/−70
- src/System/Taffybar/Information/Battery.hs +161/−122
- src/System/Taffybar/Information/Bluetooth.hs +125/−98
- src/System/Taffybar/Information/CPU.hs +12/−29
- src/System/Taffybar/Information/CPU2.hs +76/−12
- src/System/Taffybar/Information/Chrome.hs +59/−41
- src/System/Taffybar/Information/Crypto.hs +97/−56
- src/System/Taffybar/Information/DiskIO.hs +7/−7
- src/System/Taffybar/Information/DiskUsage.hs +62/−53
- src/System/Taffybar/Information/EWMHDesktopInfo.hs +123/−73
- src/System/Taffybar/Information/Hyprland.hs +187/−147
- src/System/Taffybar/Information/Hyprland/API.hs +129/−0
- src/System/Taffybar/Information/Hyprland/Types.hs +160/−0
- src/System/Taffybar/Information/Inhibitor.hs +82/−61
- src/System/Taffybar/Information/KeyboardState.hs +23/−20
- src/System/Taffybar/Information/MPRIS2.hs +80/−39
- src/System/Taffybar/Information/Memory.hs +51/−37
- src/System/Taffybar/Information/Network.hs +74/−52
- src/System/Taffybar/Information/NetworkManager.hs +205/−167
- src/System/Taffybar/Information/PowerProfiles.hs +88/−71
- src/System/Taffybar/Information/Privacy.hs +129/−117
- src/System/Taffybar/Information/PulseAudio.hs +26/−17
- src/System/Taffybar/Information/SafeX11.hs +128/−98
- src/System/Taffybar/Information/ScreenLock.hs +14/−11
- src/System/Taffybar/Information/StreamInfo.hs +27/−25
- src/System/Taffybar/Information/Systemd.hs +128/−90
- src/System/Taffybar/Information/Temperature.hs +52/−38
- src/System/Taffybar/Information/Udev.hs +25/−21
- src/System/Taffybar/Information/Wakeup.hs +159/−0
- src/System/Taffybar/Information/Wakeup/Manager.hs +319/−0
- src/System/Taffybar/Information/WirePlumber.hs +17/−13
- src/System/Taffybar/Information/Wlsunset.hs +148/−67
- src/System/Taffybar/Information/Workspaces/EWMH.hs +324/−0
- src/System/Taffybar/Information/Workspaces/Hyprland.hs +341/−0
- src/System/Taffybar/Information/Workspaces/Model.hs +173/−0
- src/System/Taffybar/Information/X11DesktopInfo.hs +116/−82
- src/System/Taffybar/Information/XDG/Protocol.hs +178/−137
- src/System/Taffybar/LogFormatter.hs +21/−7
- src/System/Taffybar/LogLevels.hs +21/−18
- src/System/Taffybar/SimpleConfig.hs +128/−89
- src/System/Taffybar/Support/PagerHints.hs +33/−26
- src/System/Taffybar/Util.hs +212/−117
- src/System/Taffybar/Widget.hs +53/−15
- src/System/Taffybar/Widget/ASUS.hs +217/−0
- src/System/Taffybar/Widget/Backlight.hs +89/−82
- src/System/Taffybar/Widget/Battery.hs +85/−65
- src/System/Taffybar/Widget/BatteryDonut.hs +97/−81
- src/System/Taffybar/Widget/BatteryTextIcon.hs +61/−54
- src/System/Taffybar/Widget/Bluetooth.hs +53/−45
- src/System/Taffybar/Widget/CPUMonitor.hs +29/−26
- src/System/Taffybar/Widget/ChannelWorkspaces.hs +42/−0
- src/System/Taffybar/Widget/CommandRunner.hs +32/−20
- src/System/Taffybar/Widget/Crypto.hs +64/−47
- src/System/Taffybar/Widget/DiskIOMonitor.hs +22/−16
- src/System/Taffybar/Widget/DiskUsage.hs +62/−48
- src/System/Taffybar/Widget/FSMonitor.hs +20/−15
- src/System/Taffybar/Widget/FreedesktopNotifications.hs +147/−105
- src/System/Taffybar/Widget/Generic/AutoFillImage.hs +134/−119
- src/System/Taffybar/Widget/Generic/AutoSizeImage.hs +94/−76
- src/System/Taffybar/Widget/Generic/ChannelGraph.hs +11/−8
- src/System/Taffybar/Widget/Generic/ChannelWidget.hs +5/−2
- src/System/Taffybar/Widget/Generic/DynamicMenu.hs +21/−15
- src/System/Taffybar/Widget/Generic/Graph.hs +180/−108
- src/System/Taffybar/Widget/Generic/Icon.hs +73/−48
- src/System/Taffybar/Widget/Generic/PollingBar.hs +47/−24
- src/System/Taffybar/Widget/Generic/PollingGraph.hs +46/−34
- src/System/Taffybar/Widget/Generic/PollingLabel.hs +83/−40
- src/System/Taffybar/Widget/Generic/ScalingImage.hs +28/−27
- src/System/Taffybar/Widget/Generic/VerticalBar.hs +89/−77
- src/System/Taffybar/Widget/HyprlandLayout.hs +53/−42
- src/System/Taffybar/Widget/HyprlandWindows.hs +0/−175
- src/System/Taffybar/Widget/HyprlandWorkspaces.hs +54/−867
- src/System/Taffybar/Widget/ImageCommandButton.hs +80/−0
- src/System/Taffybar/Widget/Inhibitor.hs +59/−48
- src/System/Taffybar/Widget/KeyboardState.hs +86/−76
- src/System/Taffybar/Widget/Layout.hs +25/−21
- src/System/Taffybar/Widget/MPRIS2.hs +412/−181
- src/System/Taffybar/Widget/NetworkGraph.hs +31/−24
- src/System/Taffybar/Widget/NetworkManager.hs +293/−267
- src/System/Taffybar/Widget/PowerProfiles.hs +39/−33
- src/System/Taffybar/Widget/Privacy.hs +51/−45
- src/System/Taffybar/Widget/PulseAudio.hs +105/−90
- src/System/Taffybar/Widget/SNIMenu.hs +26/−18
- src/System/Taffybar/Widget/SNITray.hs +269/−30
- src/System/Taffybar/Widget/SNITray/PrioritizedCollapsible.hs +1030/−0
- src/System/Taffybar/Widget/ScreenLock.hs +40/−32
- src/System/Taffybar/Widget/SimpleClock.hs +108/−83
- src/System/Taffybar/Widget/SimpleCommandButton.hs +24/−16
- src/System/Taffybar/Widget/Systemd.hs +50/−42
- src/System/Taffybar/Widget/Temperature.hs +73/−70
- src/System/Taffybar/Widget/Text/CPUMonitor.hs +43/−21
- src/System/Taffybar/Widget/Text/MemoryMonitor.hs +84/−49
- src/System/Taffybar/Widget/Text/NetworkMonitor.hs +52/−38
- src/System/Taffybar/Widget/Util.hs +142/−101
- src/System/Taffybar/Widget/WakeupDebug.hs +216/−0
- src/System/Taffybar/Widget/Weather.hs +126/−97
- src/System/Taffybar/Widget/Windows.hs +204/−77
- src/System/Taffybar/Widget/WirePlumber.hs +15/−7
- src/System/Taffybar/Widget/Wlsunset.hs +134/−65
- src/System/Taffybar/Widget/Workspaces.hs +663/−787
- src/System/Taffybar/Widget/Workspaces/Channel.hs +21/−0
- src/System/Taffybar/Widget/Workspaces/Common.hs +19/−0
- src/System/Taffybar/Widget/Workspaces/Config.hs +41/−0
- src/System/Taffybar/Widget/Workspaces/EWMH.hs +884/−0
- src/System/Taffybar/Widget/Workspaces/EWMH/Compat.hs +171/−0
- src/System/Taffybar/Widget/Workspaces/Hyprland.hs +963/−0
- src/System/Taffybar/Widget/Workspaces/Hyprland/Compat.hs +253/−0
- src/System/Taffybar/Widget/Workspaces/Shared.hs +40/−35
- src/System/Taffybar/Widget/Workspaces/X11.hs +18/−0
- src/System/Taffybar/Widget/WttrIn.hs +5/−4
- src/System/Taffybar/Widget/XDGMenu/Menu.hs +74/−55
- src/System/Taffybar/Widget/XDGMenu/MenuWidget.hs +37/−29
- src/System/Taffybar/Window/FocusedMonitor.hs +133/−0
- src/System/Taffybar/WindowIcon.hs +183/−52
- taffybar.cabal +37/−6
- taffybar.css +31/−0
- test/data/appearance-ewmh-bar-levels.png binary
- test/data/appearance-test.css +15/−0
- test/lib/System/Taffybar/Test/DBusSpec.hs +115/−86
- test/lib/System/Taffybar/Test/UtilSpec.hs +127/−108
- test/lib/System/Taffybar/Test/XvfbSpec.hs +214/−186
- test/unit/System/Taffybar/AppearanceSpec.hs +180/−64
- test/unit/System/Taffybar/AuthSpec.hs +39/−37
- test/unit/System/Taffybar/ContextSpec.hs +97/−83
- test/unit/System/Taffybar/Information/CryptoSpec.hs +15/−0
- test/unit/System/Taffybar/Information/WakeupSpec.hs +80/−0
- test/unit/System/Taffybar/Information/X11DesktopInfoSpec.hs +8/−7
- test/unit/System/Taffybar/SimpleConfigSpec.hs +17/−13
- test/unit/System/Taffybar/Widget/HyprlandWorkspacesSpec.hs +14/−17
- test/unit/System/Taffybar/Widget/WindowsSpec.hs +14/−0
- test/unit/System/Taffybar/Widget/Workspaces/ChannelSpec.hs +78/−0
- test/unit/unit-tests.hs +2/−3
CHANGELOG.md view
@@ -1,5 +1,63 @@ # Unreleased +# 5.2.0++## New Features++* Add optional `barLevels` support to `BarConfig`/`SimpleTaffyConfig` for+ stacked multi-row bars with per-level `start`/`center`/`end` widget lists.+* Keep backward compatibility for existing configs: when `barLevels` is+ `Nothing`, the legacy single-row widget fields are used unchanged.+* Add `mpris2NewWithControls`, an opt-in MPRIS2 constructor that shows+ previous/play-pause/next controls gated by player capabilities+ (`CanGoPrevious`, `CanPlay`, `CanPause`, `CanGoNext`).+* Add channel-based CPU sampling via+ `System.Taffybar.Information.CPU2.getCPULoadChan`, and switch CPU widgets to+ use the channel-backed implementation.++## Deprecations++* Deprecate `System.Taffybar.Information.CPU` and+ `System.Taffybar.Information.CPU.cpuLoad`.+* Deprecate non-channel CPU2 polling/parsing entry points:+ `System.Taffybar.Information.CPU2.getCPUInfo` and+ `System.Taffybar.Information.CPU2.getCPULoad`.++## Dependency Bumps++ * Bump `gtk-sni-tray` lower bound to 0.1.14.0.+ * Bump `status-notifier-item` lower bound to 0.3.2.6, pulling in important+ robustness fixes for the StatusNotifier host/watcher implementations+ (registration ownership checks, restart deduplication, cache-backed restore,+ and better reconciliation after watcher restarts).++## Tray++ * Add optional SNI tray priority pass-through via new additive constructors+ while keeping existing tray constructors backwards compatible.+ * Add a collapsible SNI tray widget with overflow indicator and expand/collapse+ interaction.+ * Add a prioritized collapsible SNI tray variant with:+ persisted icon priorities, a button-driven priority edit mode, explicit+ expand/collapse toggle, and priority threshold filtering.+ * Put prioritized-collapsible tray implementation in its own module:+ `System.Taffybar.Widget.SNITray.PrioritizedCollapsible`.+ * Make equal-priority icon ordering stable in the prioritized collapsible tray+ using deterministic per-item identity tie-breaking.+ * Improve prioritized collapsible tray UX:+ separate icon buttons for expand/edit/settings, menu-based priority editing,+ and menu controls for max-visible and threshold settings.+ * StatusNotifier host/watcher behavior is now significantly more robust via the+ `status-notifier-item` update (better restart handling, deduplication, and+ state reconciliation).++## Tests++ * Add a new X11 appearance golden test for two-level bars+ (`appearance-ewmh-bar-levels.png`).+ * Extend Hyprland VM appearance checks to exercise `--levels` rendering and+ assert multi-row output markers.+ # 5.1.3 ## Dependency Bumps
app/AppearanceSnap.hs view
@@ -8,101 +8,123 @@ -- this executable in CI. module Main (main) where -import Control.Concurrent (MVar, forkIO, newEmptyMVar, threadDelay, takeMVar)+import qualified Codec.Picture as JP+import Control.Concurrent (MVar, forkIO, newEmptyMVar, takeMVar, threadDelay) import Control.Concurrent.MVar (readMVar, tryPutMVar, tryReadMVar) import Control.Exception (SomeException, bracket, try) import Control.Monad (filterM, unless, void, when) import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Reader (ask)-import Data.Bits ((.|.), shiftL)+import Data.Bits (shiftL, (.|.))+import qualified Data.ByteString as B+import qualified Data.ByteString.Lazy as BL import Data.Default (def)-import Data.Maybe (listToMaybe)-import Data.Unique (newUnique)+import Data.List (nub)+import Data.Maybe (fromMaybe, listToMaybe) import qualified Data.Text as T-import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, makeAbsolute)-import System.Environment (getArgs, setEnv, unsetEnv)-import System.Exit (ExitCode (..), exitWith)-import System.FilePath (takeDirectory, (</>))-import System.Info (arch, os)-import System.IO (hPutStrLn, stderr)+import Data.Unique (Unique, newUnique) import Data.Word (Word32)--import UnliftIO.Temporary (withSystemTempDirectory)- import Foreign.C.Types (CLong)-import System.Posix.Process (exitImmediately)-import System.Posix.Signals (signalProcess, sigKILL, sigTERM)--import qualified Codec.Picture as JP-import qualified Data.ByteString as B-import qualified Data.ByteString.Lazy as BL- import qualified GI.Gdk as Gdk import qualified GI.GdkPixbuf.Objects.Pixbuf as PB import qualified GI.Gtk as Gtk-+import Graphics.UI.GIGtkStrut+ ( StrutConfig (..),+ StrutPosition (TopPos),+ StrutSize (ExactSize),+ defaultStrutConfig,+ ) import Graphics.X11.Xlib- ( Atom- , Display- , Window- , closeDisplay- , createSimpleWindow- , defaultRootWindow- , internAtom- , mapWindow- , openDisplay- , storeName- , sync+ ( Atom,+ Display,+ Window,+ closeDisplay,+ createSimpleWindow,+ defaultRootWindow,+ internAtom,+ mapWindow,+ openDisplay,+ storeName,+ sync, ) import Graphics.X11.Xlib.Extras- ( ClassHint (..)- , changeProperty32- , getWindowProperty32- , propModeReplace- , setClassHint- )--import Graphics.UI.GIGtkStrut- ( StrutConfig (..)- , StrutPosition (TopPos)- , StrutSize (ExactSize)- , defaultStrutConfig+ ( ClassHint (..),+ changeProperty32,+ getWindowProperty32,+ propModeReplace,+ queryTree,+ setClassHint, )+import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, makeAbsolute)+import System.Environment (getArgs, lookupEnv, setEnv, unsetEnv)+import System.Exit (ExitCode (..), exitWith)+import System.FilePath (takeDirectory, (</>))+import System.IO (hPutStrLn, stderr)+import System.Info (arch, os) import System.Posix.Files (createSymbolicLink, removeLink)+import System.Posix.Process (exitImmediately)+import System.Posix.Signals (sigKILL, sigTERM, signalProcess) import System.Process.Typed (Process, getPid, proc, startProcess)- import System.Taffybar (startTaffybar) import System.Taffybar.Context- ( BarConfig (..)- , Context (..)- , TaffyIO- , TaffybarConfig (..)- , exitTaffybar+ ( BarConfig (..),+ BarLevelConfig (..),+ Context (..),+ TaffyIO,+ TaffybarConfig (..),+ exitTaffybar, ) import System.Taffybar.Util (postGUIASync)-import System.Taffybar.Widget.Workspaces- ( WorkspacesConfig (..)- , getWindowIconPixbufFromEWMH- , sortWindowsByStackIndex- , workspacesNew+import qualified System.Taffybar.Widget.Windows as Windows+import qualified System.Taffybar.Widget.Workspaces as ChannelWorkspaces+import qualified System.Taffybar.Widget.Workspaces.Config as WorkspaceConfig+import System.Taffybar.Widget.Workspaces.EWMH+ ( WorkspacesConfig,+ getWindowIconPixbufFromEWMH,+ sortWindowsByStackIndex,+ workspacesNew, )+import qualified System.Taffybar.Widget.Workspaces.EWMH as Workspaces+import Text.Read (readMaybe)+import UnliftIO.Temporary (withSystemTempDirectory) data Args = Args- { outFile :: FilePath- , cssFile :: FilePath+ { outFile :: FilePath,+ cssFile :: FilePath,+ layoutMode :: LayoutMode,+ workspaceWidgetMode :: WorkspaceWidgetMode,+ expectedTopStrut :: Maybe Int } +data LayoutMode = LayoutLegacy | LayoutLevels | LayoutWindowsTitleStress+ deriving (Eq, Show)++data WorkspaceWidgetMode+ = UseLegacyWorkspaces+ | UseChannelWorkspaces+ deriving (Eq, Show)++snapshotWatchdogTimeoutUsec :: Int+snapshotWatchdogTimeoutUsec = 60_000_000+ main :: IO () main = do- Args { outFile = outPath, cssFile = cssPath } <- parseArgs =<< getArgs+ Args+ { outFile = outPath,+ cssFile = cssPath,+ layoutMode = mode,+ workspaceWidgetMode = wsMode,+ expectedTopStrut = mExpectedTopStrut+ } <-+ parseArgs =<< getArgs -- Force X11 backend selection even if the surrounding session is Wayland. unsetEnv "WAYLAND_DISPLAY" unsetEnv "HYPRLAND_INSTANCE_SIGNATURE" setEnv "XDG_SESSION_TYPE" "x11" setEnv "GDK_BACKEND" "x11"- setEnv "GDK_SCALE" "1"- setEnv "GDK_DPI_SCALE" "1"+ setEnvDefault "GDK_SCALE" "1"+ setEnvDefault "GDK_DPI_SCALE" "1" setEnv "GTK_CSD" "0" setEnv "GTK_THEME" "Adwaita" setEnv "NO_AT_BRIDGE" "1"@@ -134,14 +156,21 @@ -- -- If this fails, the parent Xvfb teardown will still kill the X server, -- which causes the WM to exit.- res <- runUnderWm wmProc outPath cssPath+ res <- runUnderWm wmProc outPath cssPath mode wsMode mExpectedTopStrut killProcessNoWait wmProc pure res exitWith ec -runUnderWm :: Process () () () -> FilePath -> FilePath -> IO ExitCode-runUnderWm wmProc outPath cssPath = do+runUnderWm ::+ Process () () () ->+ FilePath ->+ FilePath ->+ LayoutMode ->+ WorkspaceWidgetMode ->+ Maybe Int ->+ IO ExitCode+runUnderWm wmProc outPath cssPath mode wsMode mExpectedTopStrut = do ctxVar <- (newEmptyMVar :: IO (MVar Context)) resultVar <- (newEmptyMVar :: IO (MVar (Either String BL.ByteString))) doneVar <- (newEmptyMVar :: IO (MVar ExitCode))@@ -153,44 +182,42 @@ -- Hard watchdog for CI stability: always tries to end the GTK loop, and -- stops the WM as a last resort.- void $ forkIO $ watchdogThread wmProc ctxVar resultVar doneVar 30_000_000+ void $ forkIO $ watchdogThread wmProc ctxVar resultVar doneVar snapshotWatchdogTimeoutUsec barUnique <- newUnique- let wsCfg =- (def :: WorkspacesConfig)- -- Avoid font-dependent output in the appearance golden: we only care- -- that icons/layout render deterministically.- { labelSetter = const (pure "")- , maxIcons = Just 1- , minIcons = 1- , getWindowIconPixbuf = getWindowIconPixbufFromEWMH- , iconSort = sortWindowsByStackIndex- }- barCfg =- BarConfig- { strutConfig =- defaultStrutConfig- { strutHeight = ExactSize 40- , strutMonitor = Just 0- , strutPosition = TopPos+ let wsCfgDefault = def :: WorkspacesConfig+ legacyWsCfg =+ wsCfgDefault+ { Workspaces.workspacesConfig =+ (Workspaces.workspacesConfig wsCfgDefault)+ { -- Avoid font-dependent output in the appearance golden: we only care+ -- that icons/layout render deterministically.+ WorkspaceConfig.labelSetter = const (pure ""),+ WorkspaceConfig.maxIcons = Just 1,+ WorkspaceConfig.minIcons = 1,+ WorkspaceConfig.getWindowIconPixbuf = getWindowIconPixbufFromEWMH,+ WorkspaceConfig.iconSort = sortWindowsByStackIndex }- , widgetSpacing = 8- , startWidgets = [workspacesNew wsCfg]- , centerWidgets = [testBoxWidget "test-center-box" 200 20]- , endWidgets =- [ testBoxWidget "test-pill" 52 20- , testBoxWidget "test-pill" 52 20- , testBoxWidget "test-right-box" 16 16- ]- , barId = barUnique }+ channelWsCfg =+ ChannelWorkspaces.defaultEWMHWorkspacesConfig+ { ChannelWorkspaces.labelSetter = const (pure ""),+ ChannelWorkspaces.maxIcons = Just 1,+ ChannelWorkspaces.minIcons = 1,+ ChannelWorkspaces.showWorkspaceFn = const True+ }+ workspaceWidget =+ case wsMode of+ UseLegacyWorkspaces -> workspacesNew legacyWsCfg+ UseChannelWorkspaces -> ChannelWorkspaces.workspacesNew channelWsCfg+ barCfg = buildBarConfig workspaceWidget barUnique mode cfg = def- { dbusClientParam = Nothing- , cssPaths = [cssPath]- , getBarConfigsParam = pure [barCfg]- , startupHook = scheduleSnapshot ctxVar resultVar- , errorMsg = Nothing+ { dbusClientParam = Nothing,+ cssPaths = [cssPath],+ getBarConfigsParam = pure [barCfg],+ startupHook = scheduleSnapshot ctxVar resultVar wsMode mExpectedTopStrut,+ errorMsg = Nothing } withTestWindows $ do@@ -200,6 +227,71 @@ -- Should have been set by finalizeThread or watchdogThread. takeMVar doneVar +buildBarConfig :: TaffyIO Gtk.Widget -> Unique -> LayoutMode -> BarConfig+buildBarConfig workspaceWidget barUnique mode =+ case mode of+ LayoutLegacy ->+ BarConfig+ { strutConfig = baseStrutConfig 40,+ widgetSpacing = 8,+ startWidgets = [workspaceWidget],+ centerWidgets = [testBoxWidget "test-center-box" 200 20],+ endWidgets =+ [ testBoxWidget "test-pill" 52 20,+ testBoxWidget "test-pill" 52 20,+ testBoxWidget "test-right-box" 16 16+ ],+ barLevels = Nothing,+ barId = barUnique+ }+ LayoutLevels ->+ BarConfig+ { strutConfig = baseStrutConfig 72,+ widgetSpacing = 8,+ startWidgets = [testBoxWidget "test-ignored-old-left" 40 16],+ centerWidgets = [testBoxWidget "test-ignored-old-center" 120 16],+ endWidgets = [testBoxWidget "test-ignored-old-right" 40 16],+ barLevels =+ Just+ [ BarLevelConfig+ { levelStartWidgets = [workspaceWidget],+ levelCenterWidgets = [testBoxWidget "test-center-box" 200 20],+ levelEndWidgets =+ [ testBoxWidget "test-pill" 52 20,+ testBoxWidget "test-right-box" 16 16+ ]+ },+ BarLevelConfig+ { levelStartWidgets = [testBoxWidget "test-level2-left" 78 14],+ levelCenterWidgets = [testBoxWidget "test-level2-center" 150 14],+ levelEndWidgets = [testBoxWidget "test-level2-right" 78 14]+ }+ ],+ barId = barUnique+ }+ LayoutWindowsTitleStress ->+ BarConfig+ { strutConfig = baseStrutConfig 55,+ widgetSpacing = 8,+ startWidgets = [workspaceWidget, Windows.windowsNew windowsCfg],+ centerWidgets = [],+ endWidgets = [testBoxWidget "test-pill" 52 20],+ barLevels = Nothing,+ barId = barUnique+ }+ where+ windowsCfg =+ def+ { Windows.getActiveLabel = const $ pure "line 1\nline 2\nline 3\nline 4",+ Windows.getActiveWindowIconPixbuf = Nothing+ }+ baseStrutConfig h =+ defaultStrutConfig+ { strutHeight = ExactSize h,+ strutMonitor = Just 0,+ strutPosition = TopPos+ }+ testBoxWidget :: T.Text -> Int -> Int -> TaffyIO Gtk.Widget testBoxWidget klass w h = liftIO $ do box <- Gtk.eventBoxNew@@ -248,7 +340,6 @@ setIcon w1 (argb 0xFF 0xE0 0x3A 0x3A) -- red setIcon w2 (argb 0xFF 0x3A 0xE0 0x6A) -- green- mapWindow d w1 mapWindow d w2 sync d False@@ -266,8 +357,13 @@ pixels = replicate (w * h) (fromIntegral px) in header ++ pixels -scheduleSnapshot :: MVar Context -> MVar (Either String BL.ByteString) -> TaffyIO ()-scheduleSnapshot ctxVar resultVar = do+scheduleSnapshot ::+ MVar Context ->+ MVar (Either String BL.ByteString) ->+ WorkspaceWidgetMode ->+ Maybe Int ->+ TaffyIO ()+scheduleSnapshot ctxVar resultVar wsMode mExpectedTopStrut = do ctx <- ask liftIO $ void (tryPutMVar ctxVar ctx) liftIO $ void $ forkIO (pollLoop ctx)@@ -277,16 +373,16 @@ case done of Just _ -> pure () Nothing -> do- postGUIASync (trySnapshotOnGuiThread ctx' resultVar)+ postGUIASync (trySnapshotOnGuiThread ctx' resultVar wsMode mExpectedTopStrut) threadDelay 50_000 pollLoop ctx' -finalizeThread- :: MVar Context- -> MVar (Either String BL.ByteString)- -> MVar ExitCode- -> FilePath- -> IO ()+finalizeThread ::+ MVar Context ->+ MVar (Either String BL.ByteString) ->+ MVar ExitCode ->+ FilePath ->+ IO () finalizeThread ctxVar resultVar doneVar outPath = do res <- takeMVar resultVar case res of@@ -303,13 +399,13 @@ Nothing -> pure () Just ctx -> void (try (exitTaffybar ctx) :: IO (Either SomeException ())) -watchdogThread- :: Process () () ()- -> MVar Context- -> MVar (Either String BL.ByteString)- -> MVar ExitCode- -> Int- -> IO ()+watchdogThread ::+ Process () () () ->+ MVar Context ->+ MVar (Either String BL.ByteString) ->+ MVar ExitCode ->+ Int ->+ IO () watchdogThread wmProc ctxVar resultVar doneVar usec = do threadDelay usec didSet <- tryPutMVar resultVar (Left "Timed out waiting for appearance snapshot")@@ -343,7 +439,7 @@ Nothing -> do existing <- filterM doesFileExist localCandidates case existing of- (p:_) -> makeAbsolute p+ (p : _) -> makeAbsolute p [] -> die (name ++ " not found on PATH") waitForEwmh :: Int -> IO ()@@ -368,8 +464,13 @@ then pure False else threadDelay 50_000 >> go d root atom (n + 1) maxSteps' -trySnapshotOnGuiThread :: Context -> MVar (Either String BL.ByteString) -> IO ()-trySnapshotOnGuiThread ctx resultVar = do+trySnapshotOnGuiThread ::+ Context ->+ MVar (Either String BL.ByteString) ->+ WorkspaceWidgetMode ->+ Maybe Int ->+ IO ()+trySnapshotOnGuiThread ctx resultVar wsMode mExpectedTopStrut = do -- Read the windows list and snapshot the first bar window. ws <- readMVar (existingWindows ctx) case listToMaybe ws of@@ -378,14 +479,51 @@ mImg <- try (snapshotGtkWindowImageRGBA8 win) :: IO (Either SomeException (JP.Image JP.PixelRGBA8)) case mImg of Left _ -> pure ()- Right img ->- -- Don't accept a snapshot until the workspace icons and basic bar- -- styling are actually rendered, otherwise we can end up with a- -- largely transparent screenshot that doesn't exercise much beyond- -- the icon path.- when (imageHasTestIcons img) $+ Right img -> do+ strutReady <- case mExpectedTopStrut of+ Nothing -> pure True+ Just expectedTop -> do+ mActualTop <- getDockTopStrut+ pure (mActualTop == Just expectedTop)+ -- Don't accept a snapshot until the bar has actually rendered.+ -- Legacy mode checks icon colors, while channel mode has a weaker+ -- readiness check because it does not use the legacy EWMH icon path.+ when (strutReady && isSnapshotReady wsMode img) $ void (tryPutMVar resultVar (Right (JP.encodePng img))) +getDockTopStrut :: IO (Maybe Int)+getDockTopStrut = do+ ed <- try (openDisplay "") :: IO (Either SomeException Display)+ case ed of+ Left _ -> pure Nothing+ Right d -> do+ let root = defaultRootWindow d+ clientListAtom <- internAtom d "_NET_CLIENT_LIST" False+ strutPartialAtom <- internAtom d "_NET_WM_STRUT_PARTIAL" False+ mClientIds <- getWindowProperty32 d clientListAtom root+ let clientWindows = maybe [] (map fromIntegral) mClientIds+ treeResult <- try (queryTree d root) :: IO (Either SomeException (Window, Window, [Window]))+ let treeWindows = either (const []) (\(_, _, ws) -> ws) treeResult+ candidateWindows = nub (clientWindows ++ treeWindows)+ tops <- mapM (windowTopStrut d strutPartialAtom) candidateWindows+ closeDisplay d+ pure $ case [top | Just top <- tops, top > 0] of+ [] -> Nothing+ xs -> Just (maximum xs)+ where+ windowTopStrut :: Display -> Atom -> Window -> IO (Maybe Int)+ windowTopStrut d strutPartialAtom win = do+ mStrut <- getWindowProperty32 d strutPartialAtom win+ pure $ do+ (_left : _right : top : _rest) <- mStrut+ pure (fromIntegral top)++isSnapshotReady :: WorkspaceWidgetMode -> JP.Image JP.PixelRGBA8 -> Bool+isSnapshotReady wsMode img =+ case wsMode of+ UseLegacyWorkspaces -> imageHasTestIcons img+ UseChannelWorkspaces -> imageHasOpaqueContent img+ imageHasTestIcons :: JP.Image JP.PixelRGBA8 -> Bool imageHasTestIcons img = let tol :: Int@@ -393,10 +531,10 @@ opaqueThreshold :: Int opaqueThreshold = 5000 near (JP.PixelRGBA8 r g b a) (tr, tg, tb) =- a > 200 &&- abs (fromIntegral r - tr) <= tol &&- abs (fromIntegral g - tg) <= tol &&- abs (fromIntegral b - tb) <= tol+ a > 200+ && abs (fromIntegral r - tr) <= tol+ && abs (fromIntegral g - tg) <= tol+ && abs (fromIntegral b - tb) <= tol redTarget :: (Int, Int, Int) redTarget = (224, 58, 58) greenTarget :: (Int, Int, Int)@@ -419,6 +557,27 @@ in goX 0 seenR seenG opaqueCount in go 0 False False (0 :: Int) +imageHasOpaqueContent :: JP.Image JP.PixelRGBA8 -> Bool+imageHasOpaqueContent img =+ let w = JP.imageWidth img+ h = JP.imageHeight img+ opaqueThreshold = 5000 :: Int+ go y count+ | y >= h = count > opaqueThreshold+ | otherwise =+ let goX x c+ | x >= w = go (y + 1) c+ | otherwise =+ let px = JP.pixelAt img x y+ c' =+ c+ + ( case px of+ JP.PixelRGBA8 _ _ _ a -> if a > 200 then 1 else 0+ )+ in c' > opaqueThreshold || goX (x + 1) c'+ in goX 0 count+ in go 0 0+ snapshotGtkWindowImageRGBA8 :: Gtk.Window -> IO (JP.Image JP.PixelRGBA8) snapshotGtkWindowImageRGBA8 win = do drainGtkEvents 200@@ -465,10 +624,59 @@ parseArgs :: [String] -> IO Args parseArgs args =- case args of- ["--out", outPath, "--css", cssPath] -> pure Args { outFile = outPath, cssFile = cssPath }- ["--css", cssPath, "--out", outPath] -> pure Args { outFile = outPath, cssFile = cssPath }- _ -> die "usage: taffybar-appearance-snap --out OUT.png --css appearance-test.css"+ let selectedLayoutMode+ | "--windows-title-stress" `elem` args = LayoutWindowsTitleStress+ | "--levels" `elem` args = LayoutLevels+ | otherwise = LayoutLegacy+ selectedWorkspaceWidgetMode =+ if "--channel-workspaces" `elem` args+ then UseChannelWorkspaces+ else UseLegacyWorkspaces+ (mExpectedTopStrut, argsSansTopStrutFlag) = parseTopStrutFlag args+ argsSansFlags =+ filter+ (`notElem` ["--levels", "--windows-title-stress", "--channel-workspaces"])+ argsSansTopStrutFlag+ in case argsSansFlags of+ ["--out", outPath, "--css", cssPath] ->+ pure+ Args+ { outFile = outPath,+ cssFile = cssPath,+ layoutMode = selectedLayoutMode,+ workspaceWidgetMode = selectedWorkspaceWidgetMode,+ expectedTopStrut = mExpectedTopStrut+ }+ ["--css", cssPath, "--out", outPath] ->+ pure+ Args+ { outFile = outPath,+ cssFile = cssPath,+ layoutMode = selectedLayoutMode,+ workspaceWidgetMode = selectedWorkspaceWidgetMode,+ expectedTopStrut = mExpectedTopStrut+ }+ _ ->+ die "usage: taffybar-appearance-snap --out OUT.png --css appearance-test.css [--levels|--windows-title-stress] [--channel-workspaces] [--expect-top-strut N]"+ where+ parseTopStrutFlag :: [String] -> (Maybe Int, [String])+ parseTopStrutFlag [] = (Nothing, [])+ parseTopStrutFlag ("--expect-top-strut" : raw : rest) =+ case readMaybe raw of+ Nothing -> (Nothing, "--expect-top-strut" : raw : rest)+ Just n ->+ let (mFromRest, restArgs) = parseTopStrutFlag rest+ in (Just (fromMaybe n mFromRest), restArgs)+ parseTopStrutFlag (x : rest) =+ let (mValue, restArgs) = parseTopStrutFlag rest+ in (mValue, x : restArgs)++setEnvDefault :: String -> String -> IO ()+setEnvDefault name value = do+ existing <- lookupEnv name+ case existing of+ Just _ -> pure ()+ Nothing -> setEnv name value die :: String -> IO a die msg = do
app/AppearanceSnapHyprland.hs view
@@ -8,63 +8,79 @@ -- this executable in CI. module Main (main) where -import Control.Concurrent (MVar, forkIO, newEmptyMVar, threadDelay, takeMVar)+import qualified Codec.Picture as JP+import Control.Concurrent (MVar, forkIO, newEmptyMVar, takeMVar, threadDelay) import Control.Concurrent.MVar (tryPutMVar, tryReadMVar) import Control.Exception (SomeException, try) import Control.Monad (void) import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Reader (ask)+import qualified Data.ByteString as B+import qualified Data.ByteString.Lazy as BL import Data.Default (def) import Data.IORef (IORef, newIORef, readIORef, writeIORef)-import Data.Unique (newUnique)+import qualified Data.Text as T+import Data.Unique (Unique, newUnique)+import qualified GI.Gtk as Gtk+import Graphics.UI.GIGtkStrut+ ( StrutConfig (..),+ StrutPosition (TopPos),+ StrutSize (ExactSize),+ defaultStrutConfig,+ ) import System.Directory- ( createDirectoryIfMissing- , findExecutable- , makeAbsolute+ ( createDirectoryIfMissing,+ findExecutable,+ makeAbsolute, ) import System.Environment (getArgs, lookupEnv, setEnv, unsetEnv) import System.Exit (ExitCode (..), exitWith) import System.FilePath (takeDirectory, (</>)) import System.IO (hPutStrLn, stderr) import System.Posix.Process (exitImmediately)--import qualified Codec.Picture as JP-import qualified Data.ByteString as B-import qualified Data.ByteString.Lazy as BL-import qualified Data.Text as T-import UnliftIO.Temporary (withSystemTempDirectory)- import System.Process.Typed- ( proc- , readProcess- )--import qualified GI.Gtk as Gtk--import Graphics.UI.GIGtkStrut- ( StrutConfig (..)- , StrutPosition (TopPos)- , StrutSize (ExactSize)- , defaultStrutConfig+ ( proc,+ readProcess, )- import System.Taffybar (startTaffybar) import System.Taffybar.Context- ( BarConfig (..)- , Context (..)- , TaffyIO- , TaffybarConfig (..)- , exitTaffybar+ ( BarConfig (..),+ BarLevelConfig (..),+ Context (..),+ TaffyIO,+ TaffybarConfig (..),+ exitTaffybar, )+import qualified System.Taffybar.Widget.Workspaces as ChannelWorkspaces+import UnliftIO.Temporary (withSystemTempDirectory) data Args = Args- { outFile :: FilePath- , cssFile :: FilePath+ { outFile :: FilePath,+ cssFile :: FilePath,+ layoutMode :: LayoutMode,+ workspaceWidgetMode :: WorkspaceWidgetMode } +data LayoutMode = LayoutLegacy | LayoutLevels+ deriving (Eq, Show)++data WorkspaceWidgetMode+ = UseLegacyWorkspaces+ | UseChannelWorkspaces+ deriving (Eq, Show)++snapshotWatchdogTimeoutUsec :: Int+snapshotWatchdogTimeoutUsec = 60_000_000+ main :: IO () main = do- Args { outFile = outPath, cssFile = cssPath } <- parseArgs =<< getArgs+ Args+ { outFile = outPath,+ cssFile = cssPath,+ layoutMode = mode,+ workspaceWidgetMode = wsMode+ } <-+ parseArgs =<< getArgs -- Reduce variability (but do not clobber WAYLAND_DISPLAY / XDG_RUNTIME_DIR / -- HYPRLAND_INSTANCE_SIGNATURE; those are provided by the compositor session).@@ -101,12 +117,17 @@ createDirectoryIfMissing True (takeDirectory outPath) - runUnderHyprland outPath cssPath+ runUnderHyprland outPath cssPath mode wsMode exitWith ec -runUnderHyprland :: FilePath -> FilePath -> IO ExitCode-runUnderHyprland outPath cssPath = do+runUnderHyprland ::+ FilePath ->+ FilePath ->+ LayoutMode ->+ WorkspaceWidgetMode ->+ IO ExitCode+runUnderHyprland outPath cssPath mode wsMode = do ctxVar :: MVar Context <- newEmptyMVar resultVar :: MVar (Either String BL.ByteString) <- newEmptyMVar doneVar :: MVar ExitCode <- newEmptyMVar@@ -116,36 +137,19 @@ void $ forkIO $ finalizeThread ctxVar resultVar doneVar outPath -- Hard watchdog for CI stability: always tries to end the GTK loop.- void $ forkIO $ watchdogThread ctxVar resultVar doneVar lastShotRef 30_000_000+ void $ forkIO $ watchdogThread ctxVar resultVar doneVar lastShotRef snapshotWatchdogTimeoutUsec barUnique <- newUnique - let barCfg =- BarConfig- { strutConfig =- defaultStrutConfig- { strutHeight = ExactSize 40- , strutMonitor = Just 0- , strutPosition = TopPos- }- , widgetSpacing = 8- , startWidgets = [testPillBoxWidget "test-pill" 56 20]- , centerWidgets = [testBoxWidget "test-center-box" 200 20]- , endWidgets =- [ testPillBoxWidget "test-pill" 52 20- , testPillBoxWidget "test-pill" 46 20- , testBoxWidget "test-right-box" 16 16- ]- , barId = barUnique- }+ let barCfg = buildBarConfig barUnique mode wsMode cfg = def- { dbusClientParam = Nothing- , cssPaths = [cssPath]- , getBarConfigsParam = pure [barCfg]- , startupHook = scheduleSnapshot ctxVar resultVar lastShotRef- , errorMsg = Nothing+ { dbusClientParam = Nothing,+ cssPaths = [cssPath],+ getBarConfigsParam = pure [barCfg],+ startupHook = scheduleSnapshot ctxVar resultVar lastShotRef mode,+ errorMsg = Nothing } -- Blocks in Gtk.main until we request shutdown.@@ -174,8 +178,69 @@ Gtk.widgetShowAll widget pure widget -scheduleSnapshot :: MVar Context -> MVar (Either String BL.ByteString) -> IORef (Maybe BL.ByteString) -> TaffyIO ()-scheduleSnapshot ctxVar resultVar lastShotRef = do+buildBarConfig :: Unique -> LayoutMode -> WorkspaceWidgetMode -> BarConfig+buildBarConfig barUnique mode wsMode =+ case mode of+ LayoutLegacy ->+ BarConfig+ { strutConfig = baseStrutConfig 40,+ widgetSpacing = 8,+ startWidgets = [startWidget],+ centerWidgets = [testBoxWidget "test-center-box" 200 20],+ endWidgets =+ [ testPillBoxWidget "test-pill" 52 20,+ testPillBoxWidget "test-pill" 46 20,+ testBoxWidget "test-right-box" 16 16+ ],+ barLevels = Nothing,+ barId = barUnique+ }+ LayoutLevels ->+ BarConfig+ { strutConfig = baseStrutConfig 72,+ widgetSpacing = 8,+ startWidgets = [testBoxWidget "test-ignored-old-left" 40 16],+ centerWidgets = [testBoxWidget "test-ignored-old-center" 120 16],+ endWidgets = [testBoxWidget "test-ignored-old-right" 40 16],+ barLevels =+ Just+ [ BarLevelConfig+ { levelStartWidgets = [startWidget],+ levelCenterWidgets = [testBoxWidget "test-center-box" 200 20],+ levelEndWidgets =+ [ testPillBoxWidget "test-pill" 52 20,+ testBoxWidget "test-right-box" 16 16+ ]+ },+ BarLevelConfig+ { levelStartWidgets = [testBoxWidget "test-level2-left" 78 14],+ levelCenterWidgets = [testBoxWidget "test-level2-center" 150 14],+ levelEndWidgets = [testBoxWidget "test-level2-right" 78 14]+ }+ ],+ barId = barUnique+ }+ where+ baseStrutConfig h =+ defaultStrutConfig+ { strutHeight = ExactSize h,+ strutMonitor = Just 0,+ strutPosition = TopPos+ }+ startWidget =+ case wsMode of+ UseLegacyWorkspaces -> testPillBoxWidget "test-pill" 56 20+ UseChannelWorkspaces ->+ ChannelWorkspaces.workspacesNew $+ ChannelWorkspaces.defaultWorkspacesConfig+ { ChannelWorkspaces.labelSetter = const (pure ""),+ ChannelWorkspaces.maxIcons = Just 1,+ ChannelWorkspaces.minIcons = 1,+ ChannelWorkspaces.showWorkspaceFn = const True+ }++scheduleSnapshot :: MVar Context -> MVar (Either String BL.ByteString) -> IORef (Maybe BL.ByteString) -> LayoutMode -> TaffyIO ()+scheduleSnapshot ctxVar resultVar lastShotRef mode = do ctx <- ask liftIO $ void (tryPutMVar ctxVar ctx) @@ -185,38 +250,44 @@ threadDelay 2_000_000 -- Require two consecutive identical frames to avoid capturing during -- initial GTK/compositor settling (animations, late mapping, etc).- takeSnapshotWithRetries lastShotRef resultVar 60+ takeSnapshotWithRetries lastShotRef resultVar mode 60 -takeSnapshotWithRetries :: IORef (Maybe BL.ByteString) -> MVar (Either String BL.ByteString) -> Int -> IO ()-takeSnapshotWithRetries _ resultVar 0 =+takeSnapshotWithRetries :: IORef (Maybe BL.ByteString) -> MVar (Either String BL.ByteString) -> LayoutMode -> Int -> IO ()+takeSnapshotWithRetries _ resultVar _ 0 = void $ tryPutMVar resultVar (Left "Failed to capture Hyprland appearance snapshot")-takeSnapshotWithRetries lastShotRef resultVar n = do+takeSnapshotWithRetries lastShotRef resultVar mode n = do done <- tryReadMVar resultVar case done of Just _ -> pure () Nothing -> do- shot <- takeSnapshot+ shot <- takeSnapshot mode case shot of Left _ -> do threadDelay 200_000- takeSnapshotWithRetries lastShotRef resultVar (n - 1)+ takeSnapshotWithRetries lastShotRef resultVar mode (n - 1) Right encoded -> do prev <- readIORef lastShotRef writeIORef lastShotRef (Just encoded) case prev of- Just prevEncoded | prevEncoded == encoded ->- void $ tryPutMVar resultVar (Right encoded)+ Just prevEncoded+ | prevEncoded == encoded ->+ void $ tryPutMVar resultVar (Right encoded) _ -> do threadDelay 200_000- takeSnapshotWithRetries lastShotRef resultVar (n - 1)+ takeSnapshotWithRetries lastShotRef resultVar mode (n - 1) -takeSnapshot :: IO (Either String BL.ByteString)-takeSnapshot =+takeSnapshot :: LayoutMode -> IO (Either String BL.ByteString)+takeSnapshot mode = withSystemTempDirectory "tbshot" $ \tmp -> do let shotPath = tmp </> "shot.png"+ shotHeight :: Int+ shotHeight =+ case mode of+ LayoutLegacy -> 40+ LayoutLevels -> 72 e <-- try (readProcess (proc "grim" ["-s", "1", "-l", "1", "-g", "0,0 1024x40", shotPath]))- :: IO (Either SomeException (ExitCode, BL.ByteString, BL.ByteString))+ try (readProcess (proc "grim" ["-s", "1", "-l", "1", "-g", "0,0 1024x" ++ show shotHeight, shotPath])) ::+ IO (Either SomeException (ExitCode, BL.ByteString, BL.ByteString)) case e of Left _ -> pure (Left ("grim failed" :: String)) Right (ec, _stdout, _stderr) ->@@ -228,39 +299,41 @@ Left _ -> pure (Left "PNG decode failed") Right dyn -> let img = JP.convertRGBA8 dyn- in- if hasExpectedMarkers img- then pure (Right (JP.encodePng img))- else pure (Left "Expected marker colors not present (bar likely not rendered yet)")+ in if hasExpectedMarkers mode img+ then pure (Right (JP.encodePng img))+ else pure (Left "Expected marker colors not present (bar likely not rendered yet)") -hasExpectedMarkers :: JP.Image JP.PixelRGBA8 -> Bool-hasExpectedMarkers img =+hasExpectedMarkers :: LayoutMode -> JP.Image JP.PixelRGBA8 -> Bool+hasExpectedMarkers mode img = -- These solid colors come from test/data/appearance-test.css and are chosen -- specifically so we can detect when the bar has actually rendered. let centerBox = JP.PixelRGBA8 0x3a 0x3a 0x3a 0xff rightBox = JP.PixelRGBA8 0x3a 0x5a 0x7a 0xff+ level2Center = JP.PixelRGBA8 0x3a 0x7a 0x5a 0xff centerCount = countColor img centerBox rightCount = countColor img rightBox- in centerCount >= 200 && rightCount >= 50+ level2Count = countColor img level2Center+ in case mode of+ LayoutLegacy -> centerCount >= 200 && rightCount >= 50+ LayoutLevels -> centerCount >= 200 && rightCount >= 50 && level2Count >= 100 countColor :: JP.Image JP.PixelRGBA8 -> JP.PixelRGBA8 -> Int countColor img needle = let w = JP.imageWidth img h = JP.imageHeight img- in- length- [ ()- | y <- [0 .. h - 1]- , x <- [0 .. w - 1]- , JP.pixelAt img x y == needle- ]+ in length+ [ ()+ | y <- [0 .. h - 1],+ x <- [0 .. w - 1],+ JP.pixelAt img x y == needle+ ] -finalizeThread- :: MVar Context- -> MVar (Either String BL.ByteString)- -> MVar ExitCode- -> FilePath- -> IO ()+finalizeThread ::+ MVar Context ->+ MVar (Either String BL.ByteString) ->+ MVar ExitCode ->+ FilePath ->+ IO () finalizeThread ctxVar resultVar doneVar outPath = do res <- takeMVar resultVar case res of@@ -277,13 +350,13 @@ Nothing -> pure () Just ctx -> void (try (exitTaffybar ctx) :: IO (Either SomeException ())) -watchdogThread- :: MVar Context- -> MVar (Either String BL.ByteString)- -> MVar ExitCode- -> IORef (Maybe BL.ByteString)- -> Int- -> IO ()+watchdogThread ::+ MVar Context ->+ MVar (Either String BL.ByteString) ->+ MVar ExitCode ->+ IORef (Maybe BL.ByteString) ->+ Int ->+ IO () watchdogThread ctxVar resultVar doneVar lastShotRef usec = do threadDelay usec void $ tryPutMVar doneVar (ExitFailure 124)@@ -316,10 +389,38 @@ parseArgs :: [String] -> IO Args parseArgs args =- case args of- ["--out", outPath, "--css", cssPath] -> pure Args { outFile = outPath, cssFile = cssPath }- ["--css", cssPath, "--out", outPath] -> pure Args { outFile = outPath, cssFile = cssPath }- _ -> fail "usage: taffybar-appearance-snap-hyprland --out OUT.png --css appearance-test.css"+ let selectedLayoutMode =+ if "--levels" `elem` args+ then LayoutLevels+ else LayoutLegacy+ selectedWorkspaceWidgetMode =+ if "--channel-workspaces" `elem` args+ then UseChannelWorkspaces+ else UseLegacyWorkspaces+ argsSansFlags =+ filter+ (`notElem` ["--levels", "--channel-workspaces"])+ args+ in case argsSansFlags of+ ["--out", outPath, "--css", cssPath] ->+ pure+ Args+ { outFile = outPath,+ cssFile = cssPath,+ layoutMode = selectedLayoutMode,+ workspaceWidgetMode = selectedWorkspaceWidgetMode+ }+ ["--css", cssPath, "--out", outPath] ->+ pure+ Args+ { outFile = outPath,+ cssFile = cssPath,+ layoutMode = selectedLayoutMode,+ workspaceWidgetMode = selectedWorkspaceWidgetMode+ }+ _ ->+ fail+ "usage: taffybar-appearance-snap-hyprland --out OUT.png --css appearance-test.css [--levels] [--channel-workspaces]" die :: String -> IO a die msg = do
+ app/DhallConfig.hs view
@@ -0,0 +1,534 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}++module DhallConfig+ ( runDhallConfigFromFile,+ )+where++import Control.Monad.IO.Class (liftIO)+import Data.Default (def)+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import qualified Dhall+import GHC.Generics (Generic)+import qualified GI.Gtk as Gtk+import Numeric.Natural (Natural)+import System.Taffybar (startTaffybar)+import System.Taffybar.Context (TaffyIO, TaffybarConfig)+import System.Taffybar.DBus (withLogServer, withToggleServer)+import System.Taffybar.Hooks (withBatteryRefresh, withLogLevels)+import qualified System.Taffybar.Information.Wlsunset as WlsunsetInfo+import System.Taffybar.SimpleConfig+import System.Taffybar.Widget+import qualified System.Taffybar.Widget.Backlight as Backlight+import qualified System.Taffybar.Widget.DiskUsage as DiskUsage+import qualified System.Taffybar.Widget.NetworkManager as NetworkManager+import qualified System.Taffybar.Widget.PulseAudio as PulseAudio+import qualified System.Taffybar.Widget.ScreenLock as ScreenLock+import qualified System.Taffybar.Widget.Windows as Windows+import qualified System.Taffybar.Widget.Wlsunset as Wlsunset+import qualified System.Taffybar.Widget.Workspaces.Config as WorkspaceConfig+import qualified System.Taffybar.Widget.Workspaces.EWMH as EWMHWorkspaces+import qualified System.Taffybar.Widget.Workspaces.Hyprland as HyprlandWorkspaces+import qualified System.Taffybar.Widget.Workspaces.Shared as WorkspaceShared++runDhallConfigFromFile :: FilePath -> IO ()+runDhallConfigFromFile path = do+ cfg <- Dhall.inputFile Dhall.auto path+ let simpleConfig = toSimpleConfig cfg+ taffyConfig = applyHookSpec (dhallHooks cfg) (toTaffybarConfig simpleConfig)+ startTaffybar taffyConfig++data DhallTaffybarConfig = DhallTaffybarConfig+ { dhallMonitors :: MonitorSpec,+ dhallBar :: BarSpec,+ dhallHooks :: HookSpec,+ dhallWidgets :: WidgetSections+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data MonitorSpec+ = AllMonitors+ | PrimaryMonitor+ | MonitorList [Natural]+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data PositionSpec = TopBar | BottomBar+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data BarHeightSpec+ = ScreenRatioHeight Double+ | ExactPixelHeight Natural+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data BarSpec = BarSpec+ { barPositionSpec :: PositionSpec,+ barHeightSpec :: BarHeightSpec,+ barPaddingPx :: Natural,+ barWidgetSpacing :: Natural,+ barCssPaths :: [T.Text]+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data HookSpec = HookSpec+ { hookLogServer :: Bool,+ hookToggleServer :: Bool,+ hookLogLevels :: Bool,+ hookBatteryRefresh :: Bool+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data WidgetSections = WidgetSections+ { widgetStart :: [WidgetSpec],+ widgetCenter :: [WidgetSpec],+ widgetEnd :: [WidgetSpec]+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data WidgetSpec+ = Clock (Maybe ClockSpec)+ | PulseAudio (Maybe PulseAudioSpec)+ | Backlight (Maybe BacklightSpec)+ | DiskUsage (Maybe DiskUsageSpec)+ | Windows (Maybe WindowsSpec)+ | WorkspacesEWMH (Maybe EWMHWorkspacesSpec)+ | WorkspacesHyprland (Maybe HyprlandWorkspacesSpec)+ | ScreenLock (Maybe ScreenLockSpec)+ | Wlsunset (Maybe WlsunsetSpec)+ | Layout+ | SNITray+ | SNITrayWithWatcher+ | MPRIS2+ | Battery+ | NetworkManagerWifi+ | WithClass WithClassSpec+ | WithContentsBox WidgetSpec+ | Box BoxSpec+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data WithClassSpec = WithClassSpec+ { withClassName :: T.Text,+ withClassWidget :: WidgetSpec+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data BoxOrientation = Horizontal | Vertical+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data BoxSpec = BoxSpec+ { boxOrientation :: BoxOrientation,+ boxSpacing :: Natural,+ boxChildren :: [WidgetSpec]+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data ClockUpdateSpec+ = ConstantUpdateInterval Double+ | RoundedTarget RoundedTargetSpec+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data RoundedTargetSpec = RoundedTargetSpec+ { roundedSeconds :: Natural,+ roundedOffset :: Double+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data ClockSpec = ClockSpec+ { clockFormat :: T.Text,+ clockUpdateSpec :: ClockUpdateSpec+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data PulseAudioSpec = PulseAudioSpec+ { pulseSink :: T.Text,+ pulseFormat :: T.Text,+ pulseMuteFormat :: T.Text,+ pulseUnknownFormat :: T.Text,+ pulseTooltipFormat :: Maybe T.Text,+ pulseScrollStepPercent :: Maybe Natural,+ pulseToggleMuteOnClick :: Bool+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data BacklightSpec = BacklightSpec+ { backlightPollInterval :: Double,+ backlightDeviceName :: Maybe T.Text,+ backlightFormatSpec :: T.Text,+ backlightUnknownFormatSpec :: T.Text,+ backlightTooltipFormatSpec :: Maybe T.Text,+ backlightScrollStepPercentSpec :: Maybe Natural,+ backlightBrightnessctlPathSpec :: T.Text,+ backlightIconSpec :: T.Text+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data DiskUsageSpec = DiskUsageSpec+ { diskPath :: T.Text,+ diskPollInterval :: Double,+ diskFormat :: T.Text,+ diskTooltipFormat :: Maybe T.Text,+ diskIcon :: T.Text+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data WindowsSpec = WindowsSpec+ { windowsMenuLabelLength :: Natural,+ windowsActiveLabelLength :: Natural,+ windowsShowIcon :: Bool+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data EWMHWorkspacesSpec = EWMHWorkspacesSpec+ { ewmhMinIcons :: Natural,+ ewmhMaxIcons :: Maybe Natural,+ ewmhWidgetGap :: Natural,+ ewmhShowEmpty :: Bool,+ ewmhUrgentWorkspaceState :: Bool,+ ewmhBorderWidth :: Natural,+ ewmhUpdateRateLimitMicroseconds :: Natural+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data HyprlandWorkspacesSpec = HyprlandWorkspacesSpec+ { hyprlandMinIcons :: Natural,+ hyprlandMaxIcons :: Maybe Natural,+ hyprlandWidgetGap :: Natural,+ hyprlandShowEmpty :: Bool,+ hyprlandShowSpecial :: Bool,+ hyprlandUrgentWorkspaceState :: Bool,+ hyprlandUpdateIntervalSeconds :: Double,+ hyprlandIconSize :: Natural+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data ScreenLockSpec = ScreenLockSpec+ { screenLockIconSpec :: T.Text,+ screenLockManageIdle :: Bool,+ screenLockManageSleep :: Bool,+ screenLockManageShutdown :: Bool+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++data WlsunsetSpec = WlsunsetSpec+ { wlsunsetCommandSpec :: T.Text,+ wlsunsetHighTempSpec :: Natural,+ wlsunsetLowTempSpec :: Natural,+ wlsunsetPollIntervalSpec :: Natural,+ wlsunsetIconSpec :: T.Text+ }+ deriving stock (Eq, Show, Generic)+ deriving anyclass (Dhall.FromDhall)++toSimpleConfig :: DhallTaffybarConfig -> SimpleTaffyConfig+toSimpleConfig DhallTaffybarConfig {dhallMonitors, dhallBar, dhallWidgets} =+ defaultSimpleTaffyConfig+ { monitorsAction = monitorSpecAction dhallMonitors,+ barHeight = barHeightFromSpec (barHeightSpec dhallBar),+ barPadding = naturalToInt (barPaddingPx dhallBar),+ barPosition = positionFromSpec (barPositionSpec dhallBar),+ widgetSpacing = naturalToInt (barWidgetSpacing dhallBar),+ cssPaths = map T.unpack (barCssPaths dhallBar),+ startWidgets = map widgetFromSpec (widgetStart dhallWidgets),+ centerWidgets = map widgetFromSpec (widgetCenter dhallWidgets),+ endWidgets = map widgetFromSpec (widgetEnd dhallWidgets)+ }++monitorSpecAction :: MonitorSpec -> TaffyIO [Int]+monitorSpecAction = \case+ AllMonitors -> useAllMonitors+ PrimaryMonitor -> usePrimaryMonitor+ MonitorList monitors -> pure (map naturalToInt monitors)++barHeightFromSpec :: BarHeightSpec -> StrutSize+barHeightFromSpec = \case+ ScreenRatioHeight r -> ScreenRatio (toRational r)+ ExactPixelHeight px -> ExactSize (fromIntegral px)++positionFromSpec :: PositionSpec -> Position+positionFromSpec = \case+ TopBar -> Top+ BottomBar -> Bottom++applyHookSpec :: HookSpec -> TaffybarConfig -> TaffybarConfig+applyHookSpec HookSpec {hookLogServer, hookToggleServer, hookLogLevels, hookBatteryRefresh} =+ applyWhen hookBatteryRefresh withBatteryRefresh+ . applyWhen hookLogLevels withLogLevels+ . applyWhen hookToggleServer withToggleServer+ . applyWhen hookLogServer withLogServer+ where+ applyWhen True f = f+ applyWhen False _ = id++widgetFromSpec :: WidgetSpec -> TaffyIO Gtk.Widget+widgetFromSpec = \case+ Clock maybeClockSpec -> textClockNewWith (clockConfigFromSpec maybeClockSpec)+ PulseAudio maybePulseSpec -> PulseAudio.pulseAudioNewWith (pulseAudioConfigFromSpec maybePulseSpec)+ Backlight maybeBacklightSpec -> Backlight.backlightNewChanWith (backlightConfigFromSpec maybeBacklightSpec)+ DiskUsage maybeDiskSpec -> DiskUsage.diskUsageNewWith (diskUsageConfigFromSpec maybeDiskSpec)+ Windows maybeWindowsSpec -> Windows.windowsNew (windowsConfigFromSpec maybeWindowsSpec)+ WorkspacesEWMH maybeSpec -> EWMHWorkspaces.workspacesNew (ewmhConfigFromSpec maybeSpec)+ WorkspacesHyprland maybeSpec -> HyprlandWorkspaces.hyprlandWorkspacesNew (hyprlandConfigFromSpec maybeSpec)+ ScreenLock maybeSpec -> ScreenLock.screenLockNewWithConfig (screenLockConfigFromSpec maybeSpec)+ Wlsunset maybeSpec -> Wlsunset.wlsunsetNewWithConfig (wlsunsetConfigFromSpec maybeSpec)+ Layout -> layoutNew defaultLayoutConfig+ SNITray -> sniTrayNew+ SNITrayWithWatcher -> sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt+ MPRIS2 -> mpris2New+ Battery -> batteryIconNew+ NetworkManagerWifi -> NetworkManager.networkManagerWifiIconLabelNew+ WithClass WithClassSpec {withClassName, withClassWidget} ->+ widgetFromSpec withClassWidget >>= flip widgetSetClassGI withClassName+ WithContentsBox wrappedWidget ->+ widgetFromSpec wrappedWidget >>= buildContentsBox+ Box BoxSpec {boxOrientation, boxSpacing, boxChildren} -> do+ children <- mapM widgetFromSpec boxChildren+ let orientation = case boxOrientation of+ Horizontal -> Gtk.OrientationHorizontal+ Vertical -> Gtk.OrientationVertical+ liftIO $ do+ box <- Gtk.boxNew orientation (fromIntegral boxSpacing)+ mapM_ (\child -> Gtk.boxPackStart box child False False 0) children+ Gtk.widgetShowAll box+ Gtk.toWidget box++clockConfigFromSpec :: Maybe ClockSpec -> ClockConfig+clockConfigFromSpec Nothing = defaultClockConfig+clockConfigFromSpec (Just ClockSpec {clockFormat, clockUpdateSpec}) =+ defaultClockConfig+ { clockFormatString = T.unpack clockFormat,+ clockUpdateStrategy =+ case clockUpdateSpec of+ ConstantUpdateInterval interval -> ConstantInterval interval+ RoundedTarget RoundedTargetSpec {roundedSeconds, roundedOffset} ->+ RoundedTargetInterval (naturalToInt roundedSeconds) roundedOffset+ }++pulseAudioConfigFromSpec :: Maybe PulseAudioSpec -> PulseAudio.PulseAudioWidgetConfig+pulseAudioConfigFromSpec Nothing = PulseAudio.defaultPulseAudioWidgetConfig+pulseAudioConfigFromSpec+ ( Just+ PulseAudioSpec+ { pulseSink,+ pulseFormat,+ pulseMuteFormat,+ pulseUnknownFormat,+ pulseTooltipFormat,+ pulseScrollStepPercent,+ pulseToggleMuteOnClick+ }+ ) =+ PulseAudio.defaultPulseAudioWidgetConfig+ { PulseAudio.pulseAudioSink = T.unpack pulseSink,+ PulseAudio.pulseAudioFormat = T.unpack pulseFormat,+ PulseAudio.pulseAudioMuteFormat = T.unpack pulseMuteFormat,+ PulseAudio.pulseAudioUnknownFormat = T.unpack pulseUnknownFormat,+ PulseAudio.pulseAudioTooltipFormat = fmap T.unpack pulseTooltipFormat,+ PulseAudio.pulseAudioScrollStepPercent = fmap naturalToInt pulseScrollStepPercent,+ PulseAudio.pulseAudioToggleMuteOnClick = pulseToggleMuteOnClick+ }++backlightConfigFromSpec :: Maybe BacklightSpec -> Backlight.BacklightWidgetConfig+backlightConfigFromSpec Nothing = Backlight.defaultBacklightWidgetConfig+backlightConfigFromSpec+ ( Just+ BacklightSpec+ { backlightPollInterval,+ backlightDeviceName,+ backlightFormatSpec,+ backlightUnknownFormatSpec,+ backlightTooltipFormatSpec,+ backlightScrollStepPercentSpec,+ backlightBrightnessctlPathSpec,+ backlightIconSpec+ }+ ) =+ Backlight.defaultBacklightWidgetConfig+ { Backlight.backlightPollingInterval = backlightPollInterval,+ Backlight.backlightDevice = fmap T.unpack backlightDeviceName,+ Backlight.backlightFormat = T.unpack backlightFormatSpec,+ Backlight.backlightUnknownFormat = T.unpack backlightUnknownFormatSpec,+ Backlight.backlightTooltipFormat = fmap T.unpack backlightTooltipFormatSpec,+ Backlight.backlightScrollStepPercent = fmap naturalToInt backlightScrollStepPercentSpec,+ Backlight.backlightBrightnessctlPath = T.unpack backlightBrightnessctlPathSpec,+ Backlight.backlightIcon = backlightIconSpec+ }++diskUsageConfigFromSpec :: Maybe DiskUsageSpec -> DiskUsage.DiskUsageWidgetConfig+diskUsageConfigFromSpec Nothing = DiskUsage.defaultDiskUsageWidgetConfig+diskUsageConfigFromSpec+ ( Just+ DiskUsageSpec+ { diskPath,+ diskPollInterval,+ diskFormat,+ diskTooltipFormat,+ diskIcon+ }+ ) =+ DiskUsage.defaultDiskUsageWidgetConfig+ { DiskUsage.diskUsagePath = T.unpack diskPath,+ DiskUsage.diskUsagePollInterval = diskPollInterval,+ DiskUsage.diskUsageFormat = T.unpack diskFormat,+ DiskUsage.diskUsageTooltipFormat = fmap T.unpack diskTooltipFormat,+ DiskUsage.diskUsageIcon = diskIcon+ }++windowsConfigFromSpec :: Maybe WindowsSpec -> Windows.WindowsConfig+windowsConfigFromSpec Nothing = Windows.defaultWindowsConfig+windowsConfigFromSpec+ (Just WindowsSpec {windowsMenuLabelLength, windowsActiveLabelLength, windowsShowIcon}) =+ Windows.defaultWindowsConfig+ { Windows.getMenuLabel = Windows.truncatedGetMenuLabel (naturalToInt windowsMenuLabelLength),+ Windows.getActiveLabel = Windows.truncatedGetActiveLabel (naturalToInt windowsActiveLabelLength),+ Windows.getActiveWindowIconPixbuf =+ if windowsShowIcon+ then Windows.getActiveWindowIconPixbuf Windows.defaultWindowsConfig+ else Nothing+ }++defaultEWMHWorkspacesSpec :: EWMHWorkspacesSpec+defaultEWMHWorkspacesSpec =+ EWMHWorkspacesSpec+ { ewmhMinIcons = 0,+ ewmhMaxIcons = Nothing,+ ewmhWidgetGap = 0,+ ewmhShowEmpty = True,+ ewmhUrgentWorkspaceState = False,+ ewmhBorderWidth = 2,+ ewmhUpdateRateLimitMicroseconds = 100000+ }++ewmhConfigFromSpec :: Maybe EWMHWorkspacesSpec -> EWMHWorkspaces.WorkspacesConfig+ewmhConfigFromSpec maybeSpec =+ let spec = fromMaybe defaultEWMHWorkspacesSpec maybeSpec+ base = EWMHWorkspaces.defaultWorkspacesConfig+ common = EWMHWorkspaces.workspacesCommonConfig base+ common' =+ common+ { WorkspaceConfig.minIcons = naturalToInt (ewmhMinIcons spec),+ WorkspaceConfig.maxIcons = fmap naturalToInt (ewmhMaxIcons spec),+ WorkspaceConfig.widgetGap = naturalToInt (ewmhWidgetGap spec),+ WorkspaceConfig.showWorkspaceFn = if ewmhShowEmpty spec then const True else EWMHWorkspaces.hideEmpty,+ WorkspaceConfig.urgentWorkspaceState = ewmhUrgentWorkspaceState spec+ }+ base' = EWMHWorkspaces.applyCommonWorkspacesConfig common' base+ in base'+ { EWMHWorkspaces.borderWidth = naturalToInt (ewmhBorderWidth spec),+ EWMHWorkspaces.updateRateLimitMicroseconds = fromIntegral (ewmhUpdateRateLimitMicroseconds spec)+ }++defaultHyprlandWorkspacesSpec :: HyprlandWorkspacesSpec+defaultHyprlandWorkspacesSpec =+ HyprlandWorkspacesSpec+ { hyprlandMinIcons = 0,+ hyprlandMaxIcons = Nothing,+ hyprlandWidgetGap = 0,+ hyprlandShowEmpty = False,+ hyprlandShowSpecial = False,+ hyprlandUrgentWorkspaceState = False,+ hyprlandUpdateIntervalSeconds = 1.0,+ hyprlandIconSize = 16+ }++hyprlandConfigFromSpec :: Maybe HyprlandWorkspacesSpec -> HyprlandWorkspaces.HyprlandWorkspacesConfig+hyprlandConfigFromSpec maybeSpec =+ let spec = fromMaybe defaultHyprlandWorkspacesSpec maybeSpec+ base = HyprlandWorkspaces.defaultHyprlandWorkspacesConfig+ common = HyprlandWorkspaces.hyprlandWorkspacesCommonConfig base+ shouldShowWorkspace ws =+ let isEmpty = HyprlandWorkspaces.workspaceState ws == WorkspaceShared.Empty+ isSpecial =+ let name = T.toLower $ T.pack $ HyprlandWorkspaces.workspaceName ws+ in T.isPrefixOf "special" name || HyprlandWorkspaces.workspaceIdx ws < 0+ emptyAllowed = hyprlandShowEmpty spec || not isEmpty+ specialAllowed = hyprlandShowSpecial spec || not isSpecial+ in emptyAllowed && specialAllowed+ common' =+ common+ { WorkspaceConfig.minIcons = naturalToInt (hyprlandMinIcons spec),+ WorkspaceConfig.maxIcons = fmap naturalToInt (hyprlandMaxIcons spec),+ WorkspaceConfig.widgetGap = naturalToInt (hyprlandWidgetGap spec),+ WorkspaceConfig.showWorkspaceFn = shouldShowWorkspace,+ WorkspaceConfig.urgentWorkspaceState = hyprlandUrgentWorkspaceState spec+ }+ base' = HyprlandWorkspaces.applyCommonHyprlandWorkspacesConfig common' base+ in base'+ { HyprlandWorkspaces.updateIntervalSeconds = hyprlandUpdateIntervalSeconds spec,+ HyprlandWorkspaces.iconSize = fromIntegral (hyprlandIconSize spec)+ }++screenLockConfigFromSpec :: Maybe ScreenLockSpec -> ScreenLock.ScreenLockConfig+screenLockConfigFromSpec Nothing = ScreenLock.defaultScreenLockConfig+screenLockConfigFromSpec+ ( Just+ ScreenLockSpec+ { screenLockIconSpec,+ screenLockManageIdle,+ screenLockManageSleep,+ screenLockManageShutdown+ }+ ) =+ let inhibitTypes =+ concat+ [ [InhibitIdle | screenLockManageIdle],+ [InhibitSleep | screenLockManageSleep],+ [InhibitShutdown | screenLockManageShutdown]+ ]+ selectedTypes = if null inhibitTypes then [InhibitIdle] else inhibitTypes+ in ScreenLock.defaultScreenLockConfig+ { ScreenLock.screenLockIcon = screenLockIconSpec,+ ScreenLock.screenLockInhibitTypes = selectedTypes+ }++wlsunsetConfigFromSpec :: Maybe WlsunsetSpec -> Wlsunset.WlsunsetWidgetConfig+wlsunsetConfigFromSpec Nothing = Wlsunset.defaultWlsunsetWidgetConfig+wlsunsetConfigFromSpec+ ( Just+ WlsunsetSpec+ { wlsunsetCommandSpec,+ wlsunsetHighTempSpec,+ wlsunsetLowTempSpec,+ wlsunsetPollIntervalSpec,+ wlsunsetIconSpec+ }+ ) =+ let infoConfig =+ (def :: WlsunsetInfo.WlsunsetConfig)+ { WlsunsetInfo.wlsunsetCommand = T.unpack wlsunsetCommandSpec,+ WlsunsetInfo.wlsunsetHighTemp = naturalToInt wlsunsetHighTempSpec,+ WlsunsetInfo.wlsunsetLowTemp = naturalToInt wlsunsetLowTempSpec,+ WlsunsetInfo.wlsunsetPollIntervalSec = naturalToInt wlsunsetPollIntervalSpec+ }+ in Wlsunset.defaultWlsunsetWidgetConfig+ { Wlsunset.wlsunsetWidgetInfoConfig = infoConfig,+ Wlsunset.wlsunsetWidgetIcon = wlsunsetIconSpec+ }++naturalToInt :: Natural -> Int+naturalToInt = fromIntegral
app/Main.hs view
@@ -1,63 +1,148 @@ {-# OPTIONS_GHC -fno-warn-unused-imports #-}+ -- | This is just a stub executable that uses dyre to read the config file and -- recompile itself.-module Main ( main ) where+module Main (main) where import Data.Default (def)+import Data.Maybe (fromMaybe) import Data.Semigroup ((<>)) import Data.Version+import DhallConfig (runDhallConfigFromFile) import Options.Applicative+import Paths_taffybar (version) import System.Directory+import System.Exit (exitFailure) import System.Log.Logger import System.Taffybar import System.Taffybar.Context import System.Taffybar.Example import Text.Printf -import Paths_taffybar (version)+data StartupMode+ = StartupAuto+ | StartupDyre+ | StartupExample+ | StartupDhall+ deriving (Eq, Show) +data Options = Options+ { optsLogLevel :: Priority,+ optsStartupMode :: StartupMode+ }+ logP :: Parser Priority logP =- option auto- ( long "log-level"- <> short 'l'- <> help "Set the log level"- <> metavar "LEVEL"- <> value WARNING- )+ option+ auto+ ( long "log-level"+ <> short 'l'+ <> help "Set the log level"+ <> metavar "LEVEL"+ <> value WARNING+ ) versionOption :: Parser (a -> a)-versionOption = infoOption- (printf "taffybar %s" $ showVersion version)- ( long "version"- <> help "Show the version number of taffybar"- )+versionOption =+ infoOption+ (printf "taffybar %s" $ showVersion version)+ ( long "version"+ <> help "Show the version number of taffybar"+ ) +startupModeP :: Parser StartupMode+startupModeP =+ fromMaybe StartupAuto+ <$> optional+ ( hsubparser+ ( command+ "auto"+ (info (pure StartupAuto) (progDesc "Use config autodetection (default)"))+ <> command+ "dyre"+ (info (pure StartupDyre) (progDesc "Run dyre with ~/.config/taffybar/taffybar.hs"))+ <> command+ "example"+ (info (pure StartupExample) (progDesc "Run built-in example configuration"))+ <> command+ "dhall"+ (info (pure StartupDhall) (progDesc "Run ~/.config/taffybar/taffybar.dhall"))+ )+ )++optionsP :: Parser Options+optionsP =+ Options+ <$> logP+ <*> startupModeP++runExampleForDetectedBackend :: IO ()+runExampleForDetectedBackend = do+ detectedBackend <- detectBackend+ let exampleConfig = case detectedBackend of+ BackendWayland -> exampleWaylandTaffybarConfig+ BackendX11 -> exampleTaffybarConfig+ logM+ "System.Taffybar"+ WARNING+ (printf "Starting with example configuration for %s." (show detectedBackend))+ startTaffybar exampleConfig++runDhall :: IO ()+runDhall = do+ dhallFilepath <- getTaffyFile "taffybar.dhall"+ dhallExists <- doesFileExist dhallFilepath+ if dhallExists+ then runDhallConfigFromFile dhallFilepath+ else do+ logM+ "System.Taffybar"+ ERROR+ ( printf+ "Dhall mode selected, but %s does not exist."+ dhallFilepath+ )+ exitFailure++runAuto :: IO ()+runAuto = do+ taffyFilepath <- getTaffyFile "taffybar.hs"+ dhallFilepath <- getTaffyFile "taffybar.dhall"+ hasHaskellConfig <- doesFileExist taffyFilepath+ hasDhallConfig <- doesFileExist dhallFilepath++ if hasHaskellConfig+ -- XXX: The configuration record here does not get used, this just calls in to dyre.+ then dyreTaffybar def+ else+ if hasDhallConfig+ then runDhall+ else do+ logM+ "System.Taffybar"+ WARNING+ ( printf "No taffybar configuration file found at %s or %s." taffyFilepath dhallFilepath+ ++ " Falling back to built-in example configuration."+ )+ runExampleForDetectedBackend+ main :: IO () main = do- logLevel <- execParser $ info (helper <*> versionOption <*> logP)- ( fullDesc- <> progDesc "Start taffybar, recompiling if necessary"- )+ opts <-+ execParser $+ info+ (helper <*> versionOption <*> optionsP)+ ( fullDesc+ <> progDesc "Start taffybar"+ )+ let logLevel = optsLogLevel opts+ startupMode = optsStartupMode opts logger <- getLogger "System.Taffybar" saveGlobalLogger $ setLevel logLevel logger - taffyFilepath <- getTaffyFile "taffybar.hs"- configurationExists <- doesFileExist taffyFilepath-- if configurationExists- -- XXX: The configuration record here does not get used, this just calls in to dyre.- then dyreTaffybar def- else do- detectedBackend <- detectBackend- let exampleConfig = case detectedBackend of- BackendWayland -> exampleWaylandTaffybarConfig- BackendX11 -> exampleTaffybarConfig- logM "System.Taffybar" WARNING- ( printf "No taffybar configuration file found at %s." taffyFilepath- ++ " Starting with example configuration for "- ++ show detectedBackend- ++ "."- )- startTaffybar exampleConfig+ case startupMode of+ StartupAuto -> runAuto+ StartupDyre -> dyreTaffybar def+ StartupExample -> runExampleForDetectedBackend+ StartupDhall -> runDhall
app/TestWM.hs view
@@ -4,12 +4,12 @@ import XMonad.Hooks.EwmhDesktops (ewmh, ewmhFullscreen) import XMonad.Hooks.ManageDocks (docks) import XMonad.ManageHook- ( (-->)- , (<+>)- , className- , composeAll- , doShift- , (=?)+ ( className,+ composeAll,+ doShift,+ (-->),+ (<+>),+ (=?), ) -- Minimal EWMH-compliant WM used by CI appearance tests.@@ -26,5 +26,6 @@ { manageHook = composeAll [ className =? "GreenWin" --> doShift "2"- ] <+> manageHook def+ ]+ <+> manageHook def }
+ dbus-xml/org.freedesktop.login1.Manager.xml view
@@ -0,0 +1,10 @@+<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"+"https://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">+<node>+ <interface name="org.freedesktop.login1.Manager">+ <signal name="PrepareForSleep">+ <arg type="b" name="start"/>+ </signal>+ </interface>+</node>+
doc/config.md view
@@ -10,6 +10,15 @@ 3. Compile your Taffybar executable some other way. Typically this would be done with the "Project Approach" (see below). +The `taffybar` binary also supports explicit startup subcommands:++- `taffybar auto` (default behavior when no subcommand is provided)+- `taffybar dyre`+- `taffybar example`+- `taffybar dhall`++See [`doc/dhall-config.md`](./dhall-config.md) for the experimental Dhall schema.+ ## Before installing Taffybar's [installation procedure](./install.md) varies depending
doc/custom.md view
@@ -73,3 +73,21 @@ the same type. [widgetSetClassGI]: https://hackage.haskell.org/package/taffybar/docs/System-Taffybar-Widget-Util.html#v:widgetSetClassGI++### Built-in widget classes++Most built-in widgets now expose a stable root CSS class by default, so+you can style them without wrapping constructors in your config.++Examples:+- `.backlight`, `.backlight-icon`, `.backlight-label`+- `.pulse-audio`, `.pulse-audio-icon`, `.pulse-audio-label`+- `.wire-plumber`, `.wire-plumber-icon`, `.wire-plumber-label`+- `.disk-usage`, `.disk-usage-icon`, `.disk-usage-label`+- `.network-manager-wifi*` and `.network-manager-network*`+- `.text-clock`, `.weather`, `.cpu-monitor`, `.network-graph`++Taffybar also applies shared classes for common internals:+- `.icon-label`, `.icon`, `.label`+- `.polling-label`, `.polling-label-container`, `.polling-label-text`+- `.graph`, `.graph-canvas`
+ doc/dhall-config-proposal.md view
@@ -0,0 +1,214 @@+# Dhall Config Proposal for `taffybar` (Issue #462)++## Context+Issue [#462](https://github.com/taffybar/taffybar/issues/462) asks for a plain text config path so users do not need a local GHC toolchain just to customize Taffybar.++Current startup behavior in `app/Main.hs` is:++1. If `~/.config/taffybar/taffybar.hs` exists, run Dyre (compile Haskell config).+2. Otherwise, run built-in example config.++There is no middle path for data-driven configuration.++## Goals+1. Allow users to configure Taffybar without compiling a Haskell config.+2. Keep existing `taffybar.hs` + Dyre workflow fully supported.+3. Cover a meaningful subset of real-world configs (including most of the structure in `imalison`'s current config).+4. Preserve an escape hatch for advanced behavior that fundamentally needs Haskell.++## Non-goals+1. Full parity with arbitrary Haskell configuration in v1.+2. Serializing every existing widget config type directly as-is.+3. Removing Dyre or `taffybar.hs` support.++## Investigation Findings++### 1. Existing config types are mixed data + functions+Many useful widget config records are data-oriented and Dhall-friendly:+- `PulseAudioWidgetConfig`+- `BacklightWidgetConfig`+- `DiskUsageWidgetConfig`+- `ClockConfig`+- `ScreenLockConfig`+- `WlsunsetWidgetConfig`++But key types include function fields, so they are not directly serializable:+- `LayoutConfig` (`formatLayout :: Text -> TaffyIO Text`)+- `WindowsConfig` (menu label and active label builders)+- `MPRIS2Config` (widget wrapper + update function)+- workspace configs (`EWMH` and `Hyprland`) with many callback fields+- `SimpleTaffyConfig` and `TaffybarConfig` hold widget constructors (`TaffyIO Gtk.Widget`) and hooks++### 2. Direct `dhall-haskell` derivation on current types is not enough+Even with `dhall` + `dhall-haskell`, we cannot derive `FromDhall` for function-valued fields. A direct "just decode existing records" approach only works for a narrow subset.++### 3. User config parity requires a widget DSL, not only record decoding+The referenced local config includes:+- backend and hostname conditional widget sets+- wrapper/composition patterns (boxed widgets, vertical stacks)+- custom callbacks (workspace label/icon behavior, MPRIS label formatting, custom polling rows)++A viable plain-text path needs a small interpreted widget/config AST.++## Options Considered++### Option A: Decode existing config records directly (minimal)+Approach:+- Add `FromDhall` instances for data-only records.+- Expose a few fixed widget constructors.++Pros:+- Smallest initial patch.++Cons:+- Low coverage; cannot express many commonly customized widgets.+- Does not get close to "mostly parity" with advanced configs.++### Option B: Dhall AST + interpreter to runtime config (recommended)+Approach:+- Introduce Dhall-specific config ADTs (pure data).+- Decode Dhall to those ADTs.+- Interpret ADTs into `SimpleTaffyConfig` / `TaffybarConfig` + widget constructors.++Pros:+- Solves plain-text requirement without requiring local GHC.+- Allows structured growth of supported features.+- Keeps hard-to-serialize internals private.++Cons:+- More design and implementation effort.+- Requires maintaining interpreter + schema docs.++### Option C: Dhall generates Haskell, still run Dyre+Approach:+- Dhall expression renders `taffybar.hs`, then Dyre compiles.++Pros:+- Reuses existing Haskell pipeline.++Cons:+- Does not solve core issue (still needs compilation toolchain).++## Recommended Design++### 1. Make startup mode explicit via subcommands+Add subcommands for all current startup behaviors:++- `taffybar auto` (default when no subcommand is given)+- `taffybar dyre`+- `taffybar example`+- `taffybar dhall`++Semantics:++- `dyre`: run current `taffybar.hs` + Dyre flow.+- `example`: run built-in example config only.+- `dhall`: load and run `taffybar.dhall` only.+- `auto` (compatibility mode):+ 1. if `taffybar.hs` exists -> `dyre`+ 2. else if `taffybar.dhall` exists -> `dhall`+ 3. else -> `example`++Rationale:+- Keeps `taffybar.hs` as the primary/first-class path.+- Makes Dhall support explicit and discoverable.+- Preserves existing behavior for users who run `taffybar` with no args.++### 2. Introduce a data-only Dhall schema + interpreter+New modules (proposed):+- `System.Taffybar.Config.Dhall.Types`+- `System.Taffybar.Config.Dhall.Decode`+- `System.Taffybar.Config.Dhall.Interpret`++Core schema shape:+- top-level bar settings (position, height, padding, spacing, css paths)+- monitor policy (`all`, `primary`, explicit list)+- conditional blocks:+ - `whenBackend` (`x11` / `wayland`)+ - `whenHostIn` (hostname allow-list)+- widget spec sum type:+ - builtin widgets (pulse, backlight, disk, clock, tray, etc.)+ - wrappers/combinators:+ - `WithClass`+ - `WithContentsBox`+ - `Box` (`horizontal`/`vertical`, spacing)+ - `Sequence` (list of widgets)+- optional hook toggles:+ - `enableLogServer`+ - `enableToggleServer`+ - `enableLogLevels`++### 3. Keep an explicit Haskell escape hatch+Document that these remain Haskell-only (initially):+- arbitrary callback functions+- deeply custom workspace icon/label logic+- custom MPRIS widget update functions+- bespoke polling loops not represented by existing widget primitives++Recommendation: if a feature does not fit the Dhall DSL, use `taffybar.hs`.++### 4. Use `dhall-haskell` where it helps, not as the whole model+Use `dhall-haskell` for decoding the Dhall-facing ADTs. Do not try to decode internal runtime config records directly.++## Coverage vs `imalison` current `taffybar.hs`++Likely supportable in v1/v2:+- widget list composition by section (start/center/end)+- host/backend-dependent widget selection+- css file selection+- most standard widget options (clock, pulse, backlight, disk, tray basics)+- wrapper patterns (class + boxed + stacked groups)++Needs additional design (v2+ or Haskell-only):+- custom workspace callbacks (`labelSetter`, `getWindowIconPixbuf`, custom `widgetBuilder`)+- custom MPRIS text transformation callback+- custom runtime icon remap algorithm for Hyprland windows+- fully custom polling widgets (e.g., bespoke RAM/SWAP row loop)++Practical outcome: "mostly possible" for this config is realistic if we include compositional widgets + conditional logic; full parity is not realistic without embedding a programmable language layer beyond Dhall data.++## Implementation Plan++### Phase 0: RFC and schema freeze+1. Add this proposal and request feedback on schema scope.+2. Lock v1 supported widgets/combinators.++### Phase 1: MVP Dhall path+1. Refactor CLI to add startup subcommands (`auto`, `dyre`, `example`, `dhall`).+2. Preserve current no-arg behavior by mapping it to `auto`.+3. Add `dhall` and `dhall-haskell` dependencies.+4. Implement Dhall decoding for top-level config + 6-10 common widgets.+5. Implement interpreter to `SimpleTaffyConfig`.+6. Wire `taffybar dhall` path in `app/Main.hs`.+7. Add docs in `doc/config.md` with CLI usage and a `taffybar.dhall` example.++### Phase 2: Composition + conditions+1. Add widget combinators (`WithClass`, `WithContentsBox`, `Box`, `Sequence`).+2. Add backend and hostname conditions.+3. Add hook toggles.++### Phase 3: Advanced widget presets+1. Add workspace preset options (data-only toggles for common settings).+2. Add tray/menu backend options and more widget-specific fields.+3. Add compatibility test fixtures for several real configs.++## Testing Strategy+1. Unit test Dhall decode (valid + invalid schema cases).+2. Unit test interpreter for deterministic mapping from spec to config structures.+3. Integration smoke test: load sample `taffybar.dhall`, build widget trees, no runtime exceptions during startup path.+4. Golden tests for error messages from invalid Dhall.++## Risks+1. Schema drift: adding widgets over time can bloat schema.+2. User expectations of full Haskell parity.+3. Long-term maintenance cost of interpreter.++Mitigations:+- Version schema docs.+- Clearly label unsupported features and Haskell escape hatch.+- Keep v1 scope intentionally small, then iterate.++## Open Questions+1. Should `taffybar auto` and plain `taffybar` remain strict compatibility mode forever, or eventually warn users toward explicit subcommands?+2. For unsupported advanced behavior, should we add a limited plugin hook system, or keep explicit "use Haskell config" guidance only?+3. Do we want a built-in converter to help migrate from common `SimpleTaffyConfig` Haskell patterns to Dhall templates?
+ doc/dhall-config.md view
@@ -0,0 +1,163 @@+# Dhall Config (Experimental)++The `taffybar dhall` subcommand reads `~/.config/taffybar/taffybar.dhall` and decodes it into a data-only configuration.++`taffybar auto` (and plain `taffybar`) prefers `taffybar.hs` first, then `taffybar.dhall`, then built-in example config.++## Top-level shape++The Dhall expression must decode to a record with these fields:++- `dhallMonitors : MonitorSpec`+- `dhallBar : BarSpec`+- `dhallHooks : HookSpec`+- `dhallWidgets : WidgetSections`++## MonitorSpec constructors++- `AllMonitors`+- `PrimaryMonitor`+- `MonitorList [Natural]`++## BarSpec fields++- `barPositionSpec : PositionSpec` (`TopBar` or `BottomBar`)+- `barHeightSpec : BarHeightSpec` (`ScreenRatioHeight Double` or `ExactPixelHeight Natural`)+- `barPaddingPx : Natural`+- `barWidgetSpacing : Natural`+- `barCssPaths : List Text`++## HookSpec fields++- `hookLogServer : Bool`+- `hookToggleServer : Bool`+- `hookLogLevels : Bool`+- `hookBatteryRefresh : Bool`++## Widget sections++`WidgetSections` has:++- `widgetStart : List WidgetSpec`+- `widgetCenter : List WidgetSpec`+- `widgetEnd : List WidgetSpec`++## WidgetSpec constructors++Plain/default constructors:++- `Layout`+- `SNITray`+- `SNITrayWithWatcher`+- `MPRIS2`+- `Battery`+- `NetworkManagerWifi`++Configurable constructors (payload type is `Optional ...`; use `None` for defaults):++- `Clock (Optional ClockSpec)`+- `PulseAudio (Optional PulseAudioSpec)`+- `Backlight (Optional BacklightSpec)`+- `DiskUsage (Optional DiskUsageSpec)`+- `Windows (Optional WindowsSpec)`+- `WorkspacesEWMH (Optional EWMHWorkspacesSpec)`+- `WorkspacesHyprland (Optional HyprlandWorkspacesSpec)`+- `ScreenLock (Optional ScreenLockSpec)`+- `Wlsunset (Optional WlsunsetSpec)`++Wrappers/combinators:++- `WithClass WithClassSpec`+- `WithContentsBox WidgetSpec`+- `Box BoxSpec`++## Wrapper specs++`WithClassSpec`:++- `withClassName : Text`+- `withClassWidget : WidgetSpec`++`BoxSpec`:++- `boxOrientation : BoxOrientation` (`Horizontal` or `Vertical`)+- `boxSpacing : Natural`+- `boxChildren : List WidgetSpec`++## Config record field names++`ClockSpec`++- `clockFormat : Text`+- `clockUpdateSpec : ClockUpdateSpec` (`ConstantUpdateInterval Double` or `RoundedTarget { roundedSeconds : Natural, roundedOffset : Double }`)++`PulseAudioSpec`++- `pulseSink : Text`+- `pulseFormat : Text`+- `pulseMuteFormat : Text`+- `pulseUnknownFormat : Text`+- `pulseTooltipFormat : Optional Text`+- `pulseScrollStepPercent : Optional Natural`+- `pulseToggleMuteOnClick : Bool`++`BacklightSpec`++- `backlightPollInterval : Double`+- `backlightDeviceName : Optional Text`+- `backlightFormatSpec : Text`+- `backlightUnknownFormatSpec : Text`+- `backlightTooltipFormatSpec : Optional Text`+- `backlightScrollStepPercentSpec : Optional Natural`+- `backlightBrightnessctlPathSpec : Text`+- `backlightIconSpec : Text`++`DiskUsageSpec`++- `diskPath : Text`+- `diskPollInterval : Double`+- `diskFormat : Text`+- `diskTooltipFormat : Optional Text`+- `diskIcon : Text`++`WindowsSpec`++- `windowsMenuLabelLength : Natural`+- `windowsActiveLabelLength : Natural`+- `windowsShowIcon : Bool`++`EWMHWorkspacesSpec`++- `ewmhMinIcons : Natural`+- `ewmhMaxIcons : Optional Natural`+- `ewmhWidgetGap : Natural`+- `ewmhShowEmpty : Bool`+- `ewmhUrgentWorkspaceState : Bool`+- `ewmhBorderWidth : Natural`+- `ewmhUpdateRateLimitMicroseconds : Natural`++`HyprlandWorkspacesSpec`++- `hyprlandMinIcons : Natural`+- `hyprlandMaxIcons : Optional Natural`+- `hyprlandWidgetGap : Natural`+- `hyprlandShowEmpty : Bool`+- `hyprlandShowSpecial : Bool`+- `hyprlandUrgentWorkspaceState : Bool`+- `hyprlandUpdateIntervalSeconds : Double`+- `hyprlandIconSize : Natural`++`ScreenLockSpec`++- `screenLockIconSpec : Text`+- `screenLockManageIdle : Bool`+- `screenLockManageSleep : Bool`+- `screenLockManageShutdown : Bool`++`WlsunsetSpec`++- `wlsunsetCommandSpec : Text`+- `wlsunsetHighTempSpec : Natural`+- `wlsunsetLowTempSpec : Natural`+- `wlsunsetPollIntervalSpec : Natural`+- `wlsunsetIconSpec : Text`
doc/faq.md view
@@ -1,6 +1,220 @@ # FAQ -For the time being, Taffybar's frequently asked questions page lives in+This page collects common setup and troubleshooting questions for Taffybar.+It is the successor to [issue #332 - Add a FAQ section to README/Docs/Wiki](https://github.com/taffybar/taffybar/issues/332). -Some of this information is collected in [Running Taffybar](./run.md).+For detailed runtime setup, also see [Running Taffybar](./run.md).++## What services and runtime prerequisites are required?++If Taffybar starts but parts of it do not work, check these first:++1. D-Bus system and session buses are running.+2. `status-notifier-watcher` is running before Taffybar if you use `SNITray`.+3. `upower` is running if you use battery widgets.+4. A compositor (for example picom) is running if you want transparency/rounded corners.+5. Non-Haskell C dependencies are installed, including `libdbusmenu-gtk3`+ (or distro equivalent), as described in [Installation](./install.md).++## Where should I put `taffybar.hs` and `taffybar.css`?++Use `$XDG_CONFIG_HOME/taffybar/` (usually `~/.config/taffybar/`).++`taffybar.css` placed there is loaded automatically.+Taffybar also watches CSS files for changes and reloads them.++## Why is my tray empty or missing icons?++### Does `SNITray` support XEmbed tray icons?++No. `SNITray` supports StatusNotifierItem/AppIndicator icons, not legacy XEmbed.++For XEmbed-only apps, run a bridge such as:+<https://github.com/KDE/plasma-workspace/tree/master/xembed-sni-proxy>++### Why does `nm-applet` not show up?++Start it with `--indicator`:++```bash+nm-applet --indicator+```++If you use XDG autostart, edit `~/.config/autostart/nm-applet.desktop` and+ensure `Exec=` includes `--indicator`.+If your setup still uses session-management flags, `--sm-disable --indicator`+also works.++Related issues:++- <https://github.com/taffybar/taffybar/issues/491>++## Why do I get "Could not load a pixbuf..." or "null pixbuf" errors?++Most icon/pixbuf failures come from environment or theme setup:++1. Ensure `GDK_PIXBUF_MODULE_FILE` is set correctly.+2. Ensure `XDG_DATA_DIRS` includes directories containing your desktop files and icons.+3. Ensure icon themes are installed (for example `hicolor-icon-theme`, and your chosen GTK theme/icon theme).+4. If menu icons are missing, set `gtk-menu-images = 1` in `~/.config/gtk-3.0/settings.ini`.++Example `settings.ini`:++```ini+[Settings]+gtk-icon-theme-name = Adwaita+gtk-menu-images = 1+```++If you are on NixOS and start Taffybar with `systemd --user`, read the+NixOS sections in [Running Taffybar](./run.md) for+`GDK_PIXBUF_MODULE_FILE` and `XDG_DATA_DIRS` import details.++Some older posts mention `GDK_PIXBUF_ICON_LOADER`; in current Nix/NixOS setup,+the relevant variable is `GDK_PIXBUF_MODULE_FILE`.++Related issues:++- <https://github.com/taffybar/taffybar/issues/367>+- <https://github.com/taffybar/taffybar/issues/373>+- <https://github.com/NixOS/nixpkgs/issues/43836>++## How do I size workspace/tray icons with CSS?++Taffybar icon widgets include CSS classes such as `.auto-size-image`,+`.sni-tray`, and `.window-icon`.++Example:++```css+/* Increase icon vertical size and spacing */+.auto-size-image, .sni-tray {+ padding-top: 4px;+ padding-bottom: 4px;+}++.window-icon {+ opacity: 1;+}+```++Use `GTK_DEBUG=interactive taffybar` to inspect live widget classes in GTK Inspector.++## How do workspace window icon getters work?++By default, workspace icon lookup tries desktop entries, then WM class, then EWMH.+You can override this with `getWindowIconPixbuf`.++Example forcing EWMH icons at a fixed size:++```haskell+import System.Taffybar.Widget.Workspaces++myWorkspacesConfig :: WorkspacesConfig+myWorkspacesConfig =+ defaultWorkspacesConfig+ { getWindowIconPixbuf =+ constantScaleWindowIconPixbufGetter 18 getWindowIconPixbufFromEWMH+ }+```++`defaultGetWindowIconPixbuf` is still the recommended default for most users.++## Why do windows appear in the active workspace on multi-monitor XMonad?++This is usually an older XMonad EWMH implementation issue, not a Taffybar bug.+See:++- <https://github.com/xmonad/xmonad-contrib/pull/238>+- <https://github.com/taffybar/taffybar/issues/239>+- <https://github.com/taffybar/taffybar/issues/369>++Use a recent `xmonad-contrib` that includes the EWMH fix from the PR above.++## Why does Taffybar segfault on older GHC versions?++There were known crash issues around GHC 8.4.x (for example `8.4.2`).+See:++- <https://github.com/taffybar/taffybar/issues/358>++Use a GHC version listed in `tested-with` in `taffybar.cabal`.++## Do you have startup examples (`.xprofile` and `systemd --user`)?++### `.xprofile` example++```bash+# Start tray watcher first when using SNITray+status-notifier-watcher &++# Optional bridge for legacy XEmbed-only tray apps+# xembed-sni-proxy &++# AppIndicator mode+nm-applet --sm-disable --indicator &++taffybar &+```++### `systemd --user` example++`~/.config/systemd/user/taffybar.service`:++```ini+[Unit]+Description=Taffybar+After=graphical-session.target+Wants=graphical-session.target++[Service]+ExecStart=/usr/bin/env taffybar+Restart=on-failure++[Install]+WantedBy=default.target+```++Before starting the unit, import session environment variables once per login:++```bash+systemctl --user import-environment DISPLAY XAUTHORITY \+ DBUS_SESSION_BUS_ADDRESS XDG_DATA_DIRS GDK_PIXBUF_MODULE_FILE+```++On NixOS, you can instead use:+`services.xserver.displayManager.importedVariables`.++## How do I use Cachix with Nix builds?++Taffybar does not require a specific project Cachix cache, but you can use your own:++```bash+nix build .#taffybar+cachix use my-cache+cachix push my-cache "$(readlink -f result)"+```++For team use, add your cache URL/key to Nix `substituters` and+`trusted-public-keys`.++## How do I add click handling to a widget?++Wrap the widget in a `Gtk.EventBox` and handle button press events:++```haskell+import Control.Monad (void)+import Control.Monad.IO.Class (liftIO)+import qualified GI.Gtk as Gtk++makeClickable :: IO Gtk.Widget -> IO () -> IO Gtk.Widget+makeClickable mkInner onClick = do+ inner <- mkInner+ box <- Gtk.eventBoxNew+ Gtk.containerAdd box inner+ Gtk.eventBoxSetVisibleWindow box False+ void $ Gtk.onWidgetButtonPressEvent box $ liftIO onClick >> pure False+ Gtk.widgetShowAll box+ Gtk.toWidget box+```
src/System/Taffybar.hs view
@@ -1,6 +1,10 @@ {-# LANGUAGE NamedFieldPuns #-} {-# OPTIONS_GHC -fno-warn-missing-signatures #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar -- Copyright : (c) Ivan A. Malison@@ -9,163 +13,154 @@ -- Maintainer : Ivan A. Malison -- Stability : unstable -- Portability : unportable------------------------------------------------------------------------------ module System.Taffybar- (- -- | Taffybar is a system status bar meant for use with window managers like- -- "XMonad" and i3wm. Taffybar is somewhat similar to xmobar, but it opts to use- -- more heavy weight GUI in the form of GTK rather than the mostly textual- -- approach favored by the latter. This allows it to provide features like an- -- SNI system tray, and a workspace widget with window icons.- --+ ( -- | Taffybar is a system status bar meant for use with window managers like+ -- "XMonad" and i3wm. Taffybar is somewhat similar to xmobar, but it opts to use+ -- more heavy weight GUI in the form of GTK rather than the mostly textual+ -- approach favored by the latter. This allows it to provide features like an+ -- SNI system tray, and a workspace widget with window icons. - -- * Configuration- -- |- -- The interface that Taffybar provides to the end user is roughly as follows:- -- you give Taffybar a list of ('TaffyIO' actions that build) GTK widgets and- -- it renders them in a horizontal bar for you (taking care of ugly details- -- like reserving strut space so that window managers don't put windows over- -- it).- --- -- The config file in which you specify the GTK widgets to render is just a- -- Haskell source file which is used to produce a custom executable with the- -- desired set of widgets. This approach requires that Taffybar be installed- -- as a Haskell library (not merely as an executable), and that the GHC- -- compiler be available for recompiling the configuration. The upshot of this- -- approach is that Taffybar's behavior and widget set are not limited to the- -- set of widgets provided by the library, because custom code and widgets can- -- be provided to Taffybar for instantiation and execution.- --- -- The following code snippet is a simple example of what a Taffybar- -- configuration might look like (also see "System.Taffybar.Example"):- --- -- > {-# LANGUAGE OverloadedStrings #-}- -- > import Data.Default (def)- -- > import System.Taffybar- -- > import System.Taffybar.Information.CPU- -- > import System.Taffybar.SimpleConfig- -- > import System.Taffybar.Widget- -- > import System.Taffybar.Widget.Generic.Graph- -- > import System.Taffybar.Widget.Generic.PollingGraph- -- >- -- > cpuCallback = do- -- > (_, systemLoad, totalLoad) <- cpuLoad- -- > return [ totalLoad, systemLoad ]- -- >- -- > main = do- -- > let cpuCfg = def- -- > { graphDataColors = [ (0, 1, 0, 1), (1, 0, 1, 0.5)]- -- > , graphLabel = Just "cpu"- -- > }- -- > clock = textClockNewWith def- -- > cpu = pollingGraphNew cpuCfg 0.5 cpuCallback- -- > workspaces = workspacesNew def- -- > simpleConfig = def- -- > { startWidgets = [ workspaces ]- -- > , endWidgets = [ sniTrayNew, clock, cpu ]- -- > }- -- > simpleTaffybar simpleConfig- --- -- This configuration creates a bar with four widgets. On the left is a widget- -- that shows information about the workspace configuration. The rightmost- -- widget is the system tray, with a clock and then a CPU graph.- --- -- The CPU widget plots two graphs on the same widget: total CPU use in green- -- and then system CPU use in a kind of semi-transparent purple on top of the- -- green.- --- -- It is important to note that the widget lists are __not__ @'GI.Gtk.Widget'@. They are- -- actually @'TaffyIO' 'GI.Gtk.Widget'@ since the bar needs to construct them after- -- performing some GTK initialization.+ -- * Configuration - getTaffyFile+ -- |+ -- The interface that Taffybar provides to the end user is roughly as follows:+ -- you give Taffybar a list of ('TaffyIO' actions that build) GTK widgets and+ -- it renders them in a horizontal bar for you (taking care of ugly details+ -- like reserving strut space so that window managers don't put windows over+ -- it).+ --+ -- The config file in which you specify the GTK widgets to render is just a+ -- Haskell source file which is used to produce a custom executable with the+ -- desired set of widgets. This approach requires that Taffybar be installed+ -- as a Haskell library (not merely as an executable), and that the GHC+ -- compiler be available for recompiling the configuration. The upshot of this+ -- approach is that Taffybar's behavior and widget set are not limited to the+ -- set of widgets provided by the library, because custom code and widgets can+ -- be provided to Taffybar for instantiation and execution.+ --+ -- The following code snippet is a simple example of what a Taffybar+ -- configuration might look like (also see "System.Taffybar.Example"):+ --+ -- > {-# LANGUAGE OverloadedStrings #-}+ -- > import Data.Default (def)+ -- > import System.Taffybar+ -- > import System.Taffybar.SimpleConfig+ -- > import System.Taffybar.Widget+ -- >+ -- > main = do+ -- > let cpuCfg = def+ -- > { graphDataColors = [ (0, 1, 0, 1), (1, 0, 1, 0.5)]+ -- > , graphLabel = Just "cpu"+ -- > }+ -- > clock = textClockNewWith def+ -- > cpu = cpuMonitorNew cpuCfg 0.5 "cpu"+ -- > workspaces = workspacesNew def+ -- > simpleConfig = def+ -- > { startWidgets = [ workspaces ]+ -- > , endWidgets = [ sniTrayNew, clock, cpu ]+ -- > }+ -- > simpleTaffybar simpleConfig+ --+ -- This configuration creates a bar with four widgets. On the left is a widget+ -- that shows information about the workspace configuration. The rightmost+ -- widget is the system tray, with a clock and then a CPU graph.+ --+ -- The CPU widget plots two graphs on the same widget: total CPU use in green+ -- and then system CPU use in a kind of semi-transparent purple on top of the+ -- green.+ --+ -- It is important to note that the widget lists are __not__ @'GI.Gtk.Widget'@. They are+ -- actually @'TaffyIO' 'GI.Gtk.Widget'@ since the bar needs to construct them after+ -- performing some GTK initialization.+ getTaffyFile, - -- ** Colors- --- -- | Although Taffybar is based on GTK, it ignores your GTK theme. The default- -- theme that it uses lives at- -- https://github.com/taffybar/taffybar/blob/master/taffybar.css You can alter- -- this theme by editing @~\/.config\/taffybar\/taffybar.css@ to your liking.- -- For an idea of the customizations you can make, see- -- <https://live.gnome.org/GnomeArt/Tutorials/GtkThemes>.+ -- ** Colors + -- - -- * Taffybar and DBus- --- -- | Taffybar has a strict dependency on "DBus", so you must ensure that the DBus daemon is- -- started before starting Taffybar.- --- -- * If you start your window manager using a graphical login manager like @gdm@- -- or @kdm@, DBus should be started automatically for you.- --- -- * If you start xmonad with a different graphical login manager that does- -- not start DBus for you automatically, put the line- -- @eval \`dbus-launch --auto-syntax\`@ into your @~\/.xsession@ *before* xmonad and taffybar are- -- started. This command sets some environment variables that the two must- -- agree on.- --- -- * If you start xmonad via @startx@ or a similar command, add the- -- above command to @~\/.xinitrc@- --- -- * System tray compatability- --- -- "System.Taffybar.Widget.SNITray" only supports the newer- -- StatusNotifierItem (SNI) protocol; older xembed applets will not work.- -- AppIndicator is also a valid implementation of SNI.- --- -- Additionally, this module does not handle recognising new tray applets.- -- Instead it is necessary to run status-notifier-watcher from the- -- [status-notifier-item](https://github.com/taffybar/status-notifier-item)- -- package early on system startup.- -- In case this is not possible, the alternative widget- -- 'System.Taffybar.Widget.SNITray.sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt' is available, but- -- this may not necessarily be able to pick up everything.+ -- | Although Taffybar is based on GTK, it ignores your GTK theme. The default+ -- theme that it uses lives at+ -- https://github.com/taffybar/taffybar/blob/master/taffybar.css You can alter+ -- this theme by editing @~\/.config\/taffybar\/taffybar.css@ to your liking.+ -- For an idea of the customizations you can make, see+ -- <https://live.gnome.org/GnomeArt/Tutorials/GtkThemes>. - -- * Starting- , startTaffybar+ -- * Taffybar and DBus - -- ** Using Dyre- , dyreTaffybar- , dyreTaffybarMain- , taffybarDyreParams- ) where+ -- -import qualified Control.Concurrent.MVar as MV+ -- | Taffybar has a strict dependency on "DBus", so you must ensure that the DBus daemon is+ -- started before starting Taffybar.+ --+ -- * If you start your window manager using a graphical login manager like @gdm@+ -- or @kdm@, DBus should be started automatically for you.+ --+ -- * If you start xmonad with a different graphical login manager that does+ -- not start DBus for you automatically, put the line+ -- @eval \`dbus-launch --auto-syntax\`@ into your @~\/.xsession@ *before* xmonad and taffybar are+ -- started. This command sets some environment variables that the two must+ -- agree on.+ --+ -- * If you start xmonad via @startx@ or a similar command, add the+ -- above command to @~\/.xinitrc@+ --+ -- * System tray compatability+ --+ -- "System.Taffybar.Widget.SNITray" only supports the newer+ -- StatusNotifierItem (SNI) protocol; older xembed applets will not work.+ -- AppIndicator is also a valid implementation of SNI.+ --+ -- Additionally, this module does not handle recognising new tray applets.+ -- Instead it is necessary to run status-notifier-watcher from the+ -- [status-notifier-item](https://github.com/taffybar/status-notifier-item)+ -- package early on system startup.+ -- In case this is not possible, the alternative widget+ -- 'System.Taffybar.Widget.SNITray.sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt' is available, but+ -- this may not necessarily be able to pick up everything.++ -- * Starting+ startTaffybar,++ -- ** Using Dyre+ dyreTaffybar,+ dyreTaffybarMain,+ taffybarDyreParams,+ )+where+ import qualified Config.Dyre as Dyre import qualified Config.Dyre.Params as Dyre-import Control.Exception ( finally )-import Data.Function ( on )-import Data.Maybe (isJust)-import Control.Monad+import qualified Control.Concurrent.MVar as MV+import Control.Exception (finally)+import Control.Monad+import Data.Function (on) import qualified Data.GI.Gtk.Threading as GIThreading-import Data.List ( groupBy, sort, isPrefixOf )+import Data.List (groupBy, isPrefixOf, sort) import qualified Data.Text as T-import Data.Word (Word32)+import Data.Word (Word32)+import qualified GI.GLib as G import qualified GI.Gdk as Gdk import qualified GI.Gtk as Gtk-import qualified GI.GLib as G-import Graphics.X11.Xlib.Misc ( initThreads )-import System.Directory-import System.Environment (lookupEnv)-import System.Environment.XDG.BaseDir ( getUserConfigFile )-import System.Exit ( exitFailure )-import System.FilePath ( (</>), normalise, takeDirectory, takeFileName )-import System.FSNotify ( startManager, watchDir, stopManager, EventIsDirectory (..), Event (..) )+import Graphics.X11.Xlib.Misc (initThreads)+import Paths_taffybar (getDataDir)+import System.Directory+import System.Environment.XDG.BaseDir (getUserConfigFile)+import System.Exit (exitFailure)+import System.FSNotify (Event (..), EventIsDirectory (..), startManager, stopManager, watchDir)+import System.FilePath (normalise, takeDirectory, takeFileName, (</>)) import qualified System.IO as IO-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.Hooks-import System.Taffybar.Util ( onSigINT, maybeHandleSigHUP, rebracket_ )--import Paths_taffybar ( getDataDir )+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Hooks+import System.Taffybar.Util (maybeHandleSigHUP, onSigINT, rebracket_) -- | The parameters that are passed to Dyre when taffybar is invoked with -- 'dyreTaffybar'. taffybarDyreParams = (Dyre.newParams "taffybar" dyreTaffybarMain showError)- { Dyre.ghcOpts = ["-threaded", "-rtsopts"]- , Dyre.rtsOptsHandling = Dyre.RTSAppend ["-I0", "-V0"]- }+ { Dyre.ghcOpts = ["-threaded", "-rtsopts"],+ Dyre.rtsOptsHandling = Dyre.RTSAppend ["-I0", "-V0"]+ } -- | Use Dyre to configure and start Taffybar. This will automatically recompile -- Taffybar whenever there are changes to your @taffybar.hs@ configuration file.@@ -173,7 +168,7 @@ dyreTaffybar = Dyre.wrapMain taffybarDyreParams showError :: TaffybarConfig -> String -> TaffybarConfig-showError cfg msg = cfg { errorMsg = Just msg }+showError cfg msg = cfg {errorMsg = Just msg} -- | The main function that Dyre should run. This is used in 'taffybarDyreParams'. dyreTaffybarMain :: TaffybarConfig -> IO ()@@ -197,13 +192,14 @@ -- | Return CSS files which should be loaded for the given config. getCSSPaths :: TaffybarConfig -> IO [FilePath]-getCSSPaths TaffybarConfig{cssPaths} = sequence (defaultCSS:userCSS)+getCSSPaths TaffybarConfig {cssPaths} = sequence (defaultCSS : userCSS) where -- Vendor CSS file, which is always loaded before user's CSS. defaultCSS = getDataFile "taffybar.css" -- User's configured CSS files, with XDG config file being the default.- userCSS | null cssPaths = [getTaffyFile "taffybar.css"]- | otherwise = map return cssPaths+ userCSS+ | null cssPaths = [getTaffyFile "taffybar.css"]+ | otherwise = map return cssPaths -- | Overrides the default GTK theme and settings with CSS styles from -- the given files (if they exist).@@ -232,7 +228,7 @@ -- * @GTK_STYLE_PROVIDER_PRIORITY_USER@ = 800 -- -- The file @XDG_CONFIG_HOME/gtk-3.0/gtk.css@ uses priority 800.-startCSS' :: Word32 -> [FilePath] -> IO (IO (), Gtk.CssProvider)+startCSS' :: Word32 -> [FilePath] -> IO (IO (), Gtk.CssProvider) startCSS' prio cssFilePaths = do provider <- Gtk.cssProviderNew mapM_ (logLoadCSSFile provider) =<< filterM doesFileExist cssFilePaths@@ -281,14 +277,18 @@ mapM_ (\(dir, fs) -> watchDir mgr dir (eventP fs) callback) cssDirs pure (stopManager mgr) where- getDirs = filterM (doesDirectoryExist . fst)- . filter (not . isPrefixOf "/nix/store/" . fst)- . dirGroups- . sort- dirGroups xs = [ (takeDirectory f, map takeFileName (f:fs))- | (f:fs) <- groupBy ((==) `on` takeDirectory) xs]- eventP fs ev = eventIsDirectory ev == IsFile- && takeFileName (eventPath ev) `elem` fs+ getDirs =+ filterM (doesDirectoryExist . fst)+ . filter (not . isPrefixOf "/nix/store/" . fst)+ . dirGroups+ . sort+ dirGroups xs =+ [ (takeDirectory f, map takeFileName (f : fs))+ | (f : fs) <- groupBy ((==) `on` takeDirectory) xs+ ]+ eventP fs ev =+ eventIsDirectory ev == IsFile+ && takeFileName (eventPath ev) `elem` fs -- inotify events arrive in batches. To avoid unnecessary reloads, -- accumulate events in an MVar and call the notifier after a@@ -296,11 +296,11 @@ debounce msec cb = do buffer <- MV.newMVar [] let mainLoopCallback = do- evs <- MV.modifyMVar buffer (pure . ([],))- unless (null evs) cb- pure G.SOURCE_REMOVE+ evs <- MV.modifyMVar buffer (pure . ([],))+ unless (null evs) cb+ pure G.SOURCE_REMOVE pure $ \ev -> do- MV.modifyMVar_ buffer (pure . (ev:))+ MV.modifyMVar_ buffer (pure . (ev :)) void $ G.timeoutAdd G.PRIORITY_LOW msec mainLoopCallback -- | Start Taffybar with the provided 'TaffybarConfig'. This function will not@@ -314,27 +314,26 @@ updateGlobalLogger "" removeHandler setTaffyLogFormatter "System.Taffybar" setTaffyLogFormatter "StatusNotifier"- useX11 <- shouldInitX11- when useX11 $ void initThreads++ -- Detect backend once up-front so any environment fixups happen before GTK+ -- initialization, and so we don't have multiple ad-hoc checks spread around.+ backendType <- detectBackend+ when (backendType == BackendX11) $ void initThreads+ _ <- Gtk.init Nothing GIThreading.setCurrentThreadAsGUIThread cssPathsToLoad <- getCSSPaths config- context <- buildContext config+ context <- buildContextWithBackend backendType config - withCSSReloadable cssPathsToLoad $ Gtk.main- `finally` logTaffy DEBUG "Finished main loop"- `onSigINT` do- logTaffy INFO "Interrupted"- exitTaffybar context+ withCSSReloadable cssPathsToLoad $+ Gtk.main+ `finally` logTaffy DEBUG "Finished main loop"+ `onSigINT` do+ logTaffy INFO "Interrupted"+ exitTaffybar context logTaffy DEBUG "Exited normally"--shouldInitX11 :: IO Bool-shouldInitX11 = do- sessionType <- lookupEnv "XDG_SESSION_TYPE"- waylandDisplay <- lookupEnv "WAYLAND_DISPLAY"- return $ not (sessionType == Just "wayland" || isJust waylandDisplay) logTaffy :: Priority -> String -> IO () logTaffy = logM "System.Taffybar"
src/System/Taffybar/Auth.hs view
@@ -1,22 +1,31 @@ {-# LANGUAGE OverloadedStrings #-}++-- | Helpers for retrieving and parsing credentials from the @pass@ password+-- store. module System.Taffybar.Auth where -import Control.Monad.IO.Class-import Data.Maybe-import System.Taffybar.Util-import Text.Regex+import Control.Monad.IO.Class+import Data.Maybe+import System.Taffybar.Util+import Text.Regex +-- | Regex for @field: value@ lines in @pass show@ output. fieldRegex :: Regex fieldRegex = mkRegexWithOpts "^(.*?): *(.*?)$" True True -passGet :: MonadIO m => String -> m (Either String (String, [(String, String)]))+-- | Read a secret with @pass show@ and parse additional fields from subsequent+-- lines.+--+-- Returns either an error string or @(primarySecret, extraFields)@.+passGet :: (MonadIO m) => String -> m (Either String (String, [(String, String)])) passGet credentialName = (>>= getPassComponents . lines) <$> runPassShow- where runPassShow = runCommand "pass" ["show", credentialName]+ where+ runPassShow = runCommand "pass" ["show", credentialName] - getPassComponents [] = Left "pass show command produced no output"- getPassComponents (key:rest) = Right (key, buildEntries rest)+ getPassComponents [] = Left "pass show command produced no output"+ getPassComponents (key : rest) = Right (key, buildEntries rest) - buildEntries = mapMaybe buildEntry . mapMaybe (matchRegex fieldRegex)+ buildEntries = mapMaybe buildEntry . mapMaybe (matchRegex fieldRegex) - buildEntry [fieldName, fieldValue] = Just (fieldName, fieldValue)- buildEntry _ = Nothing+ buildEntry [fieldName, fieldValue] = Just (fieldName, fieldValue)+ buildEntry _ = Nothing
src/System/Taffybar/Context.hs view
@@ -1,685 +1,1033 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE ExistentialQuantification #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE MonoLocalBinds #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE ImpredicativeTypes #-}--------------------------------------------------------------------------------- |--- Module : System.Taffybar.Context--- Copyright : (c) Ivan A. Malison--- License : BSD3-style (see LICENSE)------ Maintainer : Ivan A. Malison--- Stability : unstable--- Portability : unportable------ The "System.Taffybar.Context" module provides the core functionality of the--- taffybar library. It gets its name from the 'Context' record, which stores--- runtime information and objects, which are used by many of the widgets that--- taffybar provides. 'Context' is typically accessed through the 'Reader'--- interface of 'TaffyIO'.--------------------------------------------------------------------------------module System.Taffybar.Context- ( -- * Configuration- TaffybarConfig(..)- , defaultTaffybarConfig- , appendHook- -- ** Bars- , BarConfig(..)- , BarConfigGetter- , showBarId-- -- * Taffy monad- , Taffy- , TaffyIO- -- ** Context- , Context(..)- , buildContext- , buildEmptyContext- -- ** Context State- , getState- , getStateDefault- , putState- , setState-- -- * Control- , refreshTaffyWindows- , exitTaffybar-- -- * X11- , runX11- , runX11Def- -- ** Event subscription- , subscribeToAll- , subscribeToPropertyEvents- , unsubscribe-- -- * Threading- , taffyFork- -- * Backend (re-exported from "System.Taffybar.Context.Backend")- , Backend(..)- , detectBackend- ) where--import Control.Arrow ((&&&), (***))-import Control.Concurrent (forkIO)-import qualified Control.Concurrent.MVar as MV-import Control.Exception.Enclosed (catchAny)-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Class-import Control.Monad.Trans.Maybe-import Control.Monad.Trans.Reader-import qualified DBus.Client as DBus-import Data.Data-import Data.Default (Default(..))-import Data.GI.Base.ManagedPtr (unsafeCastTo)-import Data.Int-import Data.IORef-import Data.List-import qualified Data.Map as M-import qualified Data.Text as T-import Data.Tuple.Select-import Data.Tuple.Sequence-import Data.Unique-import qualified GI.Gdk-import qualified GI.GdkX11 as GdkX11-import qualified GI.GtkLayerShell as GtkLayerShell-import GI.GdkX11.Objects.X11Window-import qualified GI.Gtk as Gtk-import Graphics.UI.GIGtkStrut-import StatusNotifier.TransparentWindow-import System.Log.Logger (Priority(..), logM)-import System.Taffybar.Context.Backend (Backend(..), detectBackend)-import System.Taffybar.Information.Hyprland- ( HyprlandClient- , HyprlandEventChan- , defaultHyprlandClientConfig- , newHyprlandClient- )-import System.Taffybar.Information.SafeX11 hiding (setState)-import System.Taffybar.Information.X11DesktopInfo-import System.Taffybar.Util-import System.Taffybar.Widget.Util-import Text.Printf-import Unsafe.Coerce--logIO :: Priority -> String -> IO ()-logIO = logM "System.Taffybar.Context"--logC :: MonadIO m => Priority -> String -> m ()-logC p = liftIO . logIO p---- | 'Taffy' is a monad transformer that provides 'ReaderT' for 'Context'.-type Taffy m v = ReaderT Context m v---- | 'TaffyIO' is 'IO' wrapped with a 'ReaderT' providing 'Context'. This is the--- type of most widgets and callback in Taffybar.-type TaffyIO v = ReaderT Context IO v--type Listener = Event -> Taffy IO ()-type SubscriptionList = [(Unique, Listener)]-data Value = forall t. Typeable t => Value t--fromValue :: forall t. Typeable t => Value -> Maybe t-fromValue (Value v) =- if typeOf v == typeRep (Proxy :: Proxy t) then- Just $ unsafeCoerce v- else- Nothing---- | 'BarConfig' specifies the configuration for a single taffybar window.-data BarConfig = BarConfig- {- -- | The strut configuration to use for the bar- strutConfig :: StrutConfig- -- | The amount of spacing in pixels between bar widgets- , widgetSpacing :: Int32- -- | Constructors for widgets that should be placed at the beginning of the bar.- , startWidgets :: [TaffyIO Gtk.Widget]- -- | Constructors for widgets that should be placed in the center of the bar.- , centerWidgets :: [TaffyIO Gtk.Widget]- -- | Constructors for widgets that should be placed at the end of the bar.- , endWidgets :: [TaffyIO Gtk.Widget]- -- | A unique identifier for the bar, that can be used e.g. when toggling.- , barId :: Unique- }--instance Eq BarConfig where- a == b = barId a == barId b--type BarConfigGetter = TaffyIO [BarConfig]---- | 'TaffybarConfig' provides an advanced interface for configuring taffybar.--- Through the 'getBarConfigsParam', it is possible to specify different--- taffybar configurations depending on the number of monitors present, and even--- to specify different taffybar configurations for each monitor.-data TaffybarConfig = TaffybarConfig- {- -- | An optional dbus client to use.- dbusClientParam :: Maybe DBus.Client- -- | Hooks that should be executed at taffybar startup.- , startupHook :: TaffyIO ()- -- | A 'TaffyIO' action that returns a list of 'BarConfig' where each element- -- describes a taffybar window that should be spawned.- , getBarConfigsParam :: BarConfigGetter- -- | A list of 'FilePath' each of which should be loaded as css files at- -- startup.- , cssPaths :: [FilePath]- -- | A field used (only) by dyre to provide an error message.- , errorMsg :: Maybe String- }----- | Append the provided 'TaffyIO' hook to the 'startupHook' of the given--- 'TaffybarConfig'.-appendHook :: TaffyIO () -> TaffybarConfig -> TaffybarConfig-appendHook hook config = config- { startupHook = startupHook config >> hook }---- | Default values for a 'TaffybarConfig'. Not usuable without at least--- properly setting 'getBarConfigsParam'.-defaultTaffybarConfig :: TaffybarConfig-defaultTaffybarConfig = TaffybarConfig- { dbusClientParam = Nothing- , startupHook = return ()- , getBarConfigsParam = return []- , cssPaths = []- , errorMsg = Nothing- }--instance Default TaffybarConfig where- def = defaultTaffybarConfig---- | A "Context" value holds all of the state associated with a single running--- instance of taffybar. It is typically accessed from a widget constructor--- through the "TaffyIO" monad transformer stack.-data Context = Context- {- -- | The X11Context that will be used to service X11Property requests.- x11ContextVar :: Maybe (MV.MVar X11Context)- -- | The handlers which will be evaluated against incoming X11 events.- , listeners :: MV.MVar SubscriptionList- -- | A collection of miscellaneous pieces of state which are keyed by their- -- types. Most new pieces of state should go here, rather than in a new field- -- in 'Context'. State stored here is typically accessed through- -- 'getStateDefault'.- , contextState :: MV.MVar (M.Map TypeRep Value)- -- | Used to track the windows that taffybar is currently controlling, and- -- which 'BarConfig' objects they are associated with.- , existingWindows :: MV.MVar [(BarConfig, Gtk.Window)]- -- | The shared user session 'DBus.Client'.- , sessionDBusClient :: DBus.Client- -- | The shared system session 'DBus.Client'.- , systemDBusClient :: DBus.Client- -- | The action that will be evaluated to get the bar configs associated with- -- each active monitor taffybar should run on.- , getBarConfigs :: BarConfigGetter- -- | A Hyprland client initialized at startup (used by Hyprland widgets).- --- -- Stored directly on 'Context' to avoid deadlocks from nested- -- 'getStateDefault' usage during widget initialization.- , hyprlandClient :: HyprlandClient- -- | Lazily-initialized shared Hyprland event channel (socket reader thread).- --- -- Stored outside of 'contextState' for the same reason as 'hyprlandClient'.- , hyprlandEventChanVar :: MV.MVar (Maybe HyprlandEventChan)- -- | The backend taffybar is running on.- , backend :: Backend- -- | Populated with the BarConfig that resulted in the creation of a given- -- widget, when its constructor is called. This lets widgets access thing like- -- who their neighbors are. Note that the value of 'contextBarConfig' is- -- different for widgets belonging to bar windows on different monitors.- , contextBarConfig :: Maybe BarConfig- }---- | Build the "Context" for a taffybar process.-buildContext :: TaffybarConfig -> IO Context-buildContext TaffybarConfig- { dbusClientParam = maybeDBus- , getBarConfigsParam = barConfigGetter- , startupHook = startup- } = do- logIO DEBUG "Building context"- dbusC <- maybe DBus.connectSession return maybeDBus- sDBusC <- DBus.connectSystem- _ <- DBus.requestName dbusC "org.taffybar.Bar"- [DBus.nameAllowReplacement, DBus.nameReplaceExisting]- backendType <- detectBackend- listenersVar <- MV.newMVar []- state <- MV.newMVar M.empty- hyprClient <- newHyprlandClient defaultHyprlandClientConfig- hyprEventChanVar <- MV.newMVar Nothing- x11Context <- case backendType of- BackendX11 -> Just <$> (getX11Context def >>= MV.newMVar)- BackendWayland -> return Nothing- windowsVar <- MV.newMVar []- let context = Context- { x11ContextVar = x11Context- , listeners = listenersVar- , contextState = state- , sessionDBusClient = dbusC- , systemDBusClient = sDBusC- , getBarConfigs = barConfigGetter- , hyprlandClient = hyprClient- , hyprlandEventChanVar = hyprEventChanVar- , existingWindows = windowsVar- , backend = backendType- , contextBarConfig = Nothing- }- _ <- runMaybeT $ MaybeT GI.Gdk.displayGetDefault >>=- (lift . GI.Gdk.displayGetDefaultScreen) >>=- (lift . flip GI.Gdk.afterScreenMonitorsChanged- -- XXX: We have to do a force refresh here because there is no- -- way to reliably move windows, since the window manager can do- -- whatever it pleases.- (runReaderT forceRefreshTaffyWindows context))- flip runReaderT context $ do- logC DEBUG "Starting X11 Handler"- startX11EventHandler- logC DEBUG "Running startup hook"- startup- logC DEBUG "Queing build windows command"- refreshTaffyWindows- logIO DEBUG "Context build finished"- return context---- | Build an empty taffybar context. This function is mostly useful for--- invoking functions that yield 'TaffyIO' values in a testing setting (e.g. in--- a repl).-buildEmptyContext :: IO Context-buildEmptyContext = buildContext def---- | Format the 'barId' as a numeric string.-showBarId :: BarConfig -> String-showBarId = show . hashUnique . barId--buildBarWindow :: Context -> BarConfig -> IO Gtk.Window-buildBarWindow context barConfig = do- let thisContext = context { contextBarConfig = Just barConfig }- logC INFO $- printf "Building window for Taffybar(id=%s) with %s"- (showBarId barConfig)- (show $ strutConfig barConfig)-- window <- Gtk.windowNew Gtk.WindowTypeToplevel-- void $ Gtk.onWidgetDestroy window $ do- let bId = showBarId barConfig- logC INFO $ printf "Window for Taffybar(id=%s) destroyed" bId- MV.modifyMVar_ (existingWindows context) (pure . filter ((/=) window . sel2))- logC DEBUG $ printf "Window for Taffybar(id=%s) unregistered" bId-- box <- Gtk.boxNew Gtk.OrientationHorizontal $ fromIntegral $- widgetSpacing barConfig- _ <- widgetSetClassGI box "taffy-box"- Gtk.widgetSetVexpand box False- Gtk.setWidgetValign box Gtk.AlignFill- centerBox <- Gtk.boxNew Gtk.OrientationHorizontal $- fromIntegral $ widgetSpacing barConfig-- _ <- widgetSetClassGI centerBox "center-box"- Gtk.widgetSetVexpand centerBox True- Gtk.setWidgetValign centerBox Gtk.AlignFill- Gtk.setWidgetHalign centerBox Gtk.AlignCenter- Gtk.boxSetCenterWidget box (Just centerBox)-- setupBarWindow context (strutConfig barConfig) window- Gtk.containerAdd window box-- _ <- widgetSetClassGI window "taffy-window"-- let addWidgetWith widgetAdd (count, buildWidget) =- runReaderT buildWidget thisContext >>= widgetAdd count- addToStart count widget = do- _ <- widgetSetClassGI widget $ T.pack $ printf "left-%d" (count :: Int)- Gtk.boxPackStart box widget False False 0- addToEnd count widget = do- _ <- widgetSetClassGI widget $ T.pack $ printf "right-%d" (count :: Int)- Gtk.boxPackEnd box widget False False 0- addToCenter count widget = do- _ <- widgetSetClassGI widget $ T.pack $ printf "center-%d" (count :: Int)- Gtk.boxPackStart centerBox widget False False 0-- logIO DEBUG "Building start widgets"- mapM_ (addWidgetWith addToStart) $ zip [1..] (startWidgets barConfig)- logIO DEBUG "Building center widgets"- mapM_ (addWidgetWith addToCenter) $ zip [1..] (centerWidgets barConfig)- logIO DEBUG "Building end widgets"- mapM_ (addWidgetWith addToEnd) $ zip [1..] (endWidgets barConfig)-- makeWindowTransparent window-- logIO DEBUG "Showing window"- Gtk.widgetShow window- Gtk.widgetShow box- Gtk.widgetShow centerBox-- when (backend context == BackendX11) $- runX11Context context () $ void $ runMaybeT $ do- gdkWindow <- MaybeT $ Gtk.widgetGetWindow window- xid <- GdkX11.x11WindowGetXid =<< liftIO (unsafeCastTo X11Window gdkWindow)- logC DEBUG $ printf "Lowering X11 window %s" $ show xid- lift $ doLowerWindow (fromIntegral xid)-- return window---- | Use the "barConfigGetter" field of "Context" to get the set of taffybar--- windows that should active. Will avoid recreating windows if there is already--- a window with the appropriate geometry and "BarConfig".-refreshTaffyWindows :: TaffyIO ()-refreshTaffyWindows = mapReaderT postGUIASync $ do- logC DEBUG "Refreshing windows"- ctx <- ask- windowsVar <- asks existingWindows-- let rebuildWindows currentWindows = flip runReaderT ctx $- do- barConfigs <- join $ asks getBarConfigs-- let currentConfigs = map sel1 currentWindows- newConfs = filter (`notElem` currentConfigs) barConfigs- (remainingWindows, removedWindows) =- partition ((`elem` barConfigs) . sel1) currentWindows- setPropertiesFromPair (barConf, window) =- setupBarWindow ctx (strutConfig barConf) window-- newWindowPairs <- lift $ do- logIO DEBUG $ printf "removedWindows: %s" $- show $ map (strutConfig . sel1) removedWindows- logIO DEBUG $ printf "remainingWindows: %s" $- show $ map (strutConfig . sel1) remainingWindows- logIO DEBUG $ printf "newWindows: %s" $- show $ map strutConfig newConfs- logIO DEBUG $ printf "barConfigs: %s" $- show $ map strutConfig barConfigs-- -- TODO: This should actually use the config that is provided from- -- getBarConfigs so that the strut properties of the window can be- -- altered.- logIO DEBUG "Updating strut properties for existing windows"- mapM_ setPropertiesFromPair remainingWindows-- logIO DEBUG "Constructing new windows"- mapM (sequenceT . ((return :: a -> IO a) &&& buildBarWindow ctx))- newConfs-- return (newWindowPairs ++ remainingWindows, map sel2 removedWindows)-- -- Destroy removed windows AFTER releasing the MVar to avoid deadlock- -- with the onWidgetDestroy handler, which also modifies existingWindows.- windowsToDestroy <- lift $ MV.modifyMVar windowsVar rebuildWindows- lift $ do- logIO DEBUG "Removing windows"- mapM_ Gtk.widgetDestroy windowsToDestroy- logC DEBUG "Finished refreshing windows"- return ()---- | Unconditionally delete all existing Taffybar top-level windows.-removeTaffyWindows :: TaffyIO ()-removeTaffyWindows = asks existingWindows >>= liftIO . MV.readMVar >>= deleteWindows- where- deleteWindows = mapM_ (sequenceT . (msg *** del))-- msg :: BarConfig -> TaffyIO ()- msg barConfig = logC INFO $- printf "Destroying window for Taffybar(id=%s)" (showBarId barConfig)-- del :: Gtk.Window -> TaffyIO ()- del = Gtk.widgetDestroy---- | Forcibly refresh taffybar windows, even if there are existing windows that--- correspond to the uniques in the bar configs yielded by 'barConfigGetter'.-forceRefreshTaffyWindows :: TaffyIO ()-forceRefreshTaffyWindows = removeTaffyWindows >> refreshTaffyWindows---- | Destroys all top-level windows belonging to Taffybar, then--- requests the GTK main loop to exit.------ This ensures that the windows disappear promptly. For GTK windows--- to be destroyed, the main loop still needs to be running.-exitTaffybar :: Context -> IO ()-exitTaffybar ctx = do- postGUIASync $ runReaderT removeTaffyWindows ctx- Gtk.mainQuit--asksContextVar :: (r -> MV.MVar b) -> ReaderT r IO b-asksContextVar getter = asks getter >>= lift . MV.readMVar---- | Run a function needing an X11 connection in 'TaffyIO'.-runX11 :: X11Property a -> TaffyIO a-runX11 action = do- maybeCtxVar <- asks x11ContextVar- case maybeCtxVar of- Nothing ->- liftIO $ fail "X11 context unavailable (Wayland backend in use)"- Just ctxVar -> do- ctx <- liftIO $ MV.readMVar ctxVar- liftIO $ runReaderT action ctx---- | Use 'runX11' together with 'postX11RequestSyncProp' on the provided--- property. Return the provided default if 'Nothing' is returned--- 'postX11RequestSyncProp'.-runX11Def :: a -> X11Property a -> TaffyIO a-runX11Def dflt prop = runX11 $ postX11RequestSyncProp prop dflt--runX11Context :: MonadIO m => Context -> a -> X11Property a -> m a-runX11Context context dflt prop =- liftIO $ runReaderT (runX11Def dflt prop) context---- | Get a state value by type from the 'contextState' field of 'Context'.-getState :: forall t. Typeable t => Taffy IO (Maybe t)-getState = do- stateMap <- asksContextVar contextState- let maybeValue = M.lookup (typeRep (Proxy :: Proxy t)) stateMap- return $ maybeValue >>= fromValue---- | Like "putState", but avoids aquiring a lock if the value is already in the--- map.-getStateDefault :: Typeable t => Taffy IO t -> Taffy IO t-getStateDefault defaultGetter =- getState >>= maybe (putState defaultGetter) return---- | Get a value of the type returned by the provided action from the the--- current taffybar state, unless the state does not exist, in which case the--- action will be called to populate the state map.-putState :: forall t. Typeable t => Taffy IO t -> Taffy IO t-putState getValue = do- contextVar <- asks contextState- ctx <- ask- lift $ MV.modifyMVar contextVar $ \contextStateMap ->- let theType = typeRep (Proxy :: Proxy t)- currentValue = M.lookup theType contextStateMap- insertAndReturn value =- (M.insert theType (Value value) contextStateMap, value)- in flip runReaderT ctx $ maybe- (insertAndReturn <$> getValue)- (return . (contextStateMap,))- (currentValue >>= fromValue)---- | Overwrite a state value by type in the 'contextState' field of 'Context'.--- 'putState'/'getStateDefault' are intentionally "set-once" helpers; widgets--- that maintain mutable caches should use this to update them.-setState :: forall t. Typeable t => t -> Taffy IO t-setState value = do- contextVar <- asks contextState- let theType = typeRep (Proxy :: Proxy t)- lift $ MV.modifyMVar_ contextVar $ \contextStateMap ->- return $ M.insert theType (Value value) contextStateMap- return value---- | A version of 'forkIO' in 'TaffyIO'.-taffyFork :: ReaderT r IO () -> ReaderT r IO ()-taffyFork = void . mapReaderT forkIO--startX11EventHandler :: Taffy IO ()-startX11EventHandler = do- backendType <- asks backend- when (backendType == BackendX11) $ taffyFork $ do- c <- ask- -- XXX: The event loop needs its own X11Context to separately handle- -- communications from the X server. We deliberately avoid using the context- -- from x11ContextVar here.- lift $ withX11Context def $ eventLoop- (\e -> runReaderT (handleX11Event e) c)--setupBarWindow :: Context -> StrutConfig -> Gtk.Window -> IO ()-setupBarWindow context config window =- case backend context of- BackendX11 -> setupStrutWindow config window- BackendWayland -> setupLayerShellWindow config window---- | Remove the listener associated with the provided "Unique" from the--- collection of listeners.-unsubscribe :: Unique -> Taffy IO ()-unsubscribe identifier = do- listenersVar <- asks listeners- lift $ MV.modifyMVar_ listenersVar $ return . filter ((== identifier) . fst)---- | Subscribe to all incoming events on the X11 event loop. The returned--- "Unique" value can be used to unregister the listener using "unsuscribe".-subscribeToAll :: Listener -> Taffy IO Unique-subscribeToAll listener = do- identifier <- lift newUnique- listenersVar <- asks listeners- let- -- XXX: This type annotation probably has something to do with the warnings- -- that occur without MonoLocalBinds, but it still seems to be necessary- addListener :: SubscriptionList -> SubscriptionList- addListener = ((identifier, listener):)- lift $ MV.modifyMVar_ listenersVar (return . addListener)- return identifier---- | Subscribe to X11 "PropertyEvent"s where the property changed is in the--- provided list.-subscribeToPropertyEvents :: [String] -> Listener -> Taffy IO Unique-subscribeToPropertyEvents eventNames listener = do- eventAtoms <- mapM (runX11 . getAtom) eventNames- let filteredListener event@PropertyEvent { ev_atom = atom } =- when (atom `elem` eventAtoms) $- catchAny (listener event) (const $ return ())- filteredListener _ = return ()- subscribeToAll filteredListener--handleX11Event :: Event -> Taffy IO ()-handleX11Event event =- asksContextVar listeners >>= mapM_ applyListener- where applyListener :: (Unique, Listener) -> Taffy IO ()- applyListener (_, listener) = taffyFork $ listener event--setupLayerShellWindow :: StrutConfig -> Gtk.Window -> IO ()-setupLayerShellWindow StrutConfig- { strutWidth = widthSize- , strutHeight = heightSize- , strutXPadding = xpadding- , strutYPadding = ypadding- , strutMonitor = monitorNumber- , strutPosition = position- , strutDisplayName = maybeDisplayName- } window = do- supported <- GtkLayerShell.isSupported- unless supported $- logIO WARNING "Wayland backend selected, but gtk-layer-shell is not supported"- when supported $ do- maybeDisplay <- maybe GI.Gdk.displayGetDefault GI.Gdk.displayOpen maybeDisplayName- case maybeDisplay of- Nothing -> logIO WARNING "Failed to get GDK display for layer-shell"- Just display -> do- maybeMonitor <-- maybe (GI.Gdk.displayGetPrimaryMonitor display)- (GI.Gdk.displayGetMonitor display)- monitorNumber- case maybeMonitor of- Nothing -> logIO WARNING "Failed to get GDK monitor for layer-shell"- Just monitor -> do- monitorGeometry <- GI.Gdk.monitorGetGeometry monitor- monitorWidth <- GI.Gdk.getRectangleWidth monitorGeometry- monitorHeight <- GI.Gdk.getRectangleHeight monitorGeometry- let width =- case widthSize of- ExactSize w -> w- ScreenRatio p ->- floor $ p * fromIntegral (monitorWidth - (2 * xpadding))- height =- case heightSize of- ExactSize h -> h- ScreenRatio p ->- floor $ p * fromIntegral (monitorHeight - (2 * ypadding))- exclusive =- case position of- TopPos -> height + 2 * ypadding- BottomPos -> height + 2 * ypadding- LeftPos -> width + 2 * xpadding- RightPos -> width + 2 * xpadding-- Gtk.windowSetDefaultSize window (fromIntegral width) (fromIntegral height)- let (reqWidth, reqHeight) =- case position of- TopPos -> (-1, height)- BottomPos -> (-1, height)- LeftPos -> (width, -1)- RightPos -> (width, -1)- Gtk.widgetSetSizeRequest window- (fromIntegral reqWidth)- (fromIntegral reqHeight)-- alreadyLayer <- GtkLayerShell.isLayerWindow window- unless alreadyLayer $ do- GtkLayerShell.initForWindow window- GtkLayerShell.setKeyboardMode window GtkLayerShell.KeyboardModeNone- GtkLayerShell.setNamespace window (T.pack "taffybar")- GtkLayerShell.setMonitor window monitor-- GtkLayerShell.setMargin window GtkLayerShell.EdgeLeft xpadding- GtkLayerShell.setMargin window GtkLayerShell.EdgeRight xpadding- GtkLayerShell.setMargin window GtkLayerShell.EdgeTop ypadding- GtkLayerShell.setMargin window GtkLayerShell.EdgeBottom ypadding-- GtkLayerShell.setLayer window GtkLayerShell.LayerTop-- let setAnchor = GtkLayerShell.setAnchor window-- case position of- TopPos -> do- setAnchor GtkLayerShell.EdgeTop True- setAnchor GtkLayerShell.EdgeBottom False- setAnchor GtkLayerShell.EdgeLeft True- setAnchor GtkLayerShell.EdgeRight True- BottomPos -> do- setAnchor GtkLayerShell.EdgeTop False- setAnchor GtkLayerShell.EdgeBottom True- setAnchor GtkLayerShell.EdgeLeft True- setAnchor GtkLayerShell.EdgeRight True- LeftPos -> do- setAnchor GtkLayerShell.EdgeLeft True- setAnchor GtkLayerShell.EdgeRight False- setAnchor GtkLayerShell.EdgeTop True- setAnchor GtkLayerShell.EdgeBottom True- RightPos -> do- setAnchor GtkLayerShell.EdgeLeft False- setAnchor GtkLayerShell.EdgeRight True- setAnchor GtkLayerShell.EdgeTop True- setAnchor GtkLayerShell.EdgeBottom True-- GtkLayerShell.setExclusiveZone window exclusive-- -- Dynamically update exclusive zone when the bar's actual- -- allocated size exceeds the configured size (issue #270).- lastExclusiveRef <- newIORef exclusive- void $ Gtk.onWidgetSizeAllocate window $ \alloc -> do- allocH <- GI.Gdk.getRectangleHeight alloc- allocW <- GI.Gdk.getRectangleWidth alloc- let newExclusive =- case position of- TopPos -> fromIntegral allocH + 2 * ypadding- BottomPos -> fromIntegral allocH + 2 * ypadding- LeftPos -> fromIntegral allocW + 2 * xpadding- RightPos -> fromIntegral allocW + 2 * xpadding- prev <- readIORef lastExclusiveRef- when (newExclusive /= prev) $ do- logIO DEBUG $- printf "Updating exclusive zone from %d to %d" prev newExclusive- writeIORef lastExclusiveRef newExclusive- GtkLayerShell.setExclusiveZone window newExclusive+{-# LANGUAGE ExistentialQuantification #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE ImpredicativeTypes #-}+{-# LANGUAGE MonoLocalBinds #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Context+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- The "System.Taffybar.Context" module provides the core functionality of the+-- taffybar library. It gets its name from the 'Context' record, which stores+-- runtime information and objects, which are used by many of the widgets that+-- taffybar provides. 'Context' is typically accessed through the 'Reader'+-- interface of 'TaffyIO'.+module System.Taffybar.Context+ ( -- * Configuration+ TaffybarConfig (..),+ defaultTaffybarConfig,+ appendHook,++ -- ** Bars+ BarLevelConfig (..),+ BarConfig (..),+ BarConfigGetter,+ showBarId,++ -- * Taffy monad+ Taffy,+ TaffyIO,++ -- ** Context+ Context (..),+ buildContext,+ buildContextWithBackend,+ buildEmptyContext,++ -- ** Context State+ getState,+ getStateDefault,+ putState,+ setState,++ -- * Control+ refreshTaffyWindows,+ exitTaffybar,++ -- * X11+ runX11,+ runX11Def,++ -- ** Event subscription+ subscribeToAll,+ subscribeToPropertyEvents,+ unsubscribe,++ -- * Threading+ taffyFork,++ -- * Backend (re-exported from "System.Taffybar.Context.Backend")+ Backend (..),+ detectBackend,+ )+where++import Control.Arrow ((&&&), (***))+import Control.Concurrent (forkIO, threadDelay)+import qualified Control.Concurrent.MVar as MV+import Control.Concurrent.STM.TChan (TChan)+import Control.Exception (SomeException, try)+import Control.Exception.Enclosed (catchAny)+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Class+import Control.Monad.Trans.Maybe+import Control.Monad.Trans.Reader+import qualified DBus as D+import qualified DBus.Client as DBus+import Data.Data+import Data.Default (Default (..))+import Data.GI.Base.ManagedPtr (unsafeCastTo)+import Data.IORef+import Data.Int+import Data.List+import qualified Data.Map as M+import qualified Data.Text as T+import Data.Tuple.Select+import Data.Tuple.Sequence+import Data.Unique+import qualified GI.Gdk+import qualified GI.GdkX11 as GdkX11+import GI.GdkX11.Objects.X11Window+import qualified GI.Gtk as Gtk+import qualified GI.GtkLayerShell as GtkLayerShell+import Graphics.UI.GIGtkStrut+import StatusNotifier.TransparentWindow+import System.Log.Logger (Priority (..), logM)+import System.Taffybar.Context.Backend (Backend (..), detectBackend)+import qualified System.Taffybar.DBus.Client.Params as DBusParams+import System.Taffybar.Information.EWMHDesktopInfo+ ( ewmhActiveWindow,+ ewmhCurrentDesktop,+ getActiveWindow,+ getWindows,+ )+import System.Taffybar.Information.Hyprland+ ( HyprlandClient,+ HyprlandEventChan,+ buildHyprlandEventChan,+ defaultHyprlandClientConfig,+ getFocusedMonitorPosition,+ newHyprlandClient,+ subscribeHyprlandEvents,+ )+import System.Taffybar.Information.SafeX11 hiding (setState)+import System.Taffybar.Information.Wakeup.Manager+ ( WakeupManager,+ newWakeupManager,+ )+import System.Taffybar.Information.X11DesktopInfo+import System.Taffybar.Util+import System.Taffybar.Widget.Util+import System.Taffybar.Window.FocusedMonitor+ ( FocusedMonitorHooks (..),+ setupFocusedMonitorClassUpdates,+ )+import Text.Printf+import Unsafe.Coerce++logIO :: Priority -> String -> IO ()+logIO = logM "System.Taffybar.Context"++logC :: (MonadIO m) => Priority -> String -> m ()+logC p = liftIO . logIO p++-- | 'Taffy' is a monad transformer that provides 'ReaderT' for 'Context'.+type Taffy m v = ReaderT Context m v++-- | 'TaffyIO' is 'IO' wrapped with a 'ReaderT' providing 'Context'. This is the+-- type of most widgets and callback in Taffybar.+type TaffyIO v = ReaderT Context IO v++type Listener = Event -> Taffy IO ()++type SubscriptionList = [(Unique, Listener)]++data Value = forall t. (Typeable t) => Value t++fromValue :: forall t. (Typeable t) => Value -> Maybe t+fromValue (Value v) =+ if typeOf v == typeRep (Proxy :: Proxy t)+ then+ Just $ unsafeCoerce v+ else+ Nothing++-- | 'BarConfig' specifies the configuration for a single taffybar window.+data BarLevelConfig = BarLevelConfig+ { -- | Constructors for widgets that should be placed at the beginning of the level.+ levelStartWidgets :: [TaffyIO Gtk.Widget],+ -- | Constructors for widgets that should be placed in the center of the level.+ levelCenterWidgets :: [TaffyIO Gtk.Widget],+ -- | Constructors for widgets that should be placed at the end of the level.+ levelEndWidgets :: [TaffyIO Gtk.Widget]+ }++-- | 'BarConfig' specifies the configuration for a single taffybar window.+data BarConfig = BarConfig+ { -- | The strut configuration to use for the bar+ strutConfig :: StrutConfig,+ -- | The amount of spacing in pixels between bar widgets+ widgetSpacing :: Int32,+ -- | Constructors for widgets that should be placed at the beginning of the bar.+ startWidgets :: [TaffyIO Gtk.Widget],+ -- | Constructors for widgets that should be placed in the center of the bar.+ centerWidgets :: [TaffyIO Gtk.Widget],+ -- | Constructors for widgets that should be placed at the end of the bar.+ endWidgets :: [TaffyIO Gtk.Widget],+ -- | Optional level-based widget configuration. If this field is set,+ -- 'startWidgets', 'centerWidgets' and 'endWidgets' are ignored.+ barLevels :: Maybe [BarLevelConfig],+ -- | A unique identifier for the bar, that can be used e.g. when toggling.+ barId :: Unique+ }++instance Eq BarConfig where+ a == b = barId a == barId b++-- | Action that returns the list of bar configurations to realize.+type BarConfigGetter = TaffyIO [BarConfig]++-- | 'TaffybarConfig' provides an advanced interface for configuring taffybar.+-- Through the 'getBarConfigsParam', it is possible to specify different+-- taffybar configurations depending on the number of monitors present, and even+-- to specify different taffybar configurations for each monitor.+data TaffybarConfig = TaffybarConfig+ { -- | An optional dbus client to use.+ dbusClientParam :: Maybe DBus.Client,+ -- | Hooks that should be executed at taffybar startup.+ startupHook :: TaffyIO (),+ -- | A 'TaffyIO' action that returns a list of 'BarConfig' where each element+ -- describes a taffybar window that should be spawned.+ getBarConfigsParam :: BarConfigGetter,+ -- | A list of 'FilePath' each of which should be loaded as css files at+ -- startup.+ cssPaths :: [FilePath],+ -- | A field used (only) by dyre to provide an error message.+ errorMsg :: Maybe String+ }++-- | Append the provided 'TaffyIO' hook to the 'startupHook' of the given+-- 'TaffybarConfig'.+appendHook :: TaffyIO () -> TaffybarConfig -> TaffybarConfig+appendHook hook config =+ config+ { startupHook = startupHook config >> hook+ }++-- | Default values for a 'TaffybarConfig'. Not usuable without at least+-- properly setting 'getBarConfigsParam'.+defaultTaffybarConfig :: TaffybarConfig+defaultTaffybarConfig =+ TaffybarConfig+ { dbusClientParam = Nothing,+ startupHook = return (),+ getBarConfigsParam = return [],+ cssPaths = [],+ errorMsg = Nothing+ }++instance Default TaffybarConfig where+ def = defaultTaffybarConfig++-- | A "Context" value holds all of the state associated with a single running+-- instance of taffybar. It is typically accessed from a widget constructor+-- through the "TaffyIO" monad transformer stack.+data Context = Context+ { -- | The X11Context that will be used to service X11Property requests.+ x11ContextVar :: Maybe (MV.MVar X11Context),+ -- | The handlers which will be evaluated against incoming X11 events.+ listeners :: MV.MVar SubscriptionList,+ -- | A collection of miscellaneous pieces of state which are keyed by their+ -- types. Most new pieces of state should go here, rather than in a new field+ -- in 'Context'. State stored here is typically accessed through+ -- 'getStateDefault'.+ contextState :: MV.MVar (M.Map TypeRep Value),+ -- | Used to track the windows that taffybar is currently controlling, and+ -- which 'BarConfig' objects they are associated with.+ existingWindows :: MV.MVar [(BarConfig, Gtk.Window)],+ -- | The shared user session 'DBus.Client'.+ sessionDBusClient :: DBus.Client,+ -- | The shared system session 'DBus.Client'.+ systemDBusClient :: DBus.Client,+ -- | The action that will be evaluated to get the bar configs associated with+ -- each active monitor taffybar should run on.+ getBarConfigs :: BarConfigGetter,+ -- | A Hyprland client initialized at startup (used by Hyprland widgets).+ --+ -- Stored directly on 'Context' to avoid deadlocks from nested+ -- 'getStateDefault' usage during widget initialization.+ hyprlandClient :: HyprlandClient,+ -- | Lazily-initialized shared Hyprland event channel (socket reader thread).+ --+ -- Stored outside of 'contextState' for the same reason as 'hyprlandClient'.+ hyprlandEventChanVar :: MV.MVar (Maybe HyprlandEventChan),+ -- | Shared wakeup manager for coordinated polling intervals.+ --+ -- Stored outside 'contextState' so wakeup registration cannot deadlock when+ -- called from state initializers that already hold the context-state lock.+ wakeupManager :: WakeupManager,+ -- | The backend taffybar is running on.+ backend :: Backend,+ -- | Populated with the BarConfig that resulted in the creation of a given+ -- widget, when its constructor is called. This lets widgets access thing like+ -- who their neighbors are. Note that the value of 'contextBarConfig' is+ -- different for widgets belonging to bar windows on different monitors.+ contextBarConfig :: Maybe BarConfig+ }++-- | Build the "Context" for a taffybar process.+buildContext :: TaffybarConfig -> IO Context+buildContext cfg = do+ backendType <- detectBackend+ buildContextWithBackend backendType cfg++-- | Build the "Context" for a taffybar process using a pre-detected backend.+--+-- This avoids duplicated backend detection and ensures backend-related+-- environment fixups happen before any downstream initialization that depends+-- on them (e.g. GTK/GDK backend selection).+buildContextWithBackend :: Backend -> TaffybarConfig -> IO Context+buildContextWithBackend+ backendType+ TaffybarConfig+ { dbusClientParam = maybeDBus,+ getBarConfigsParam = barConfigGetter,+ startupHook = startup+ } = do+ logIO DEBUG "Building context"+ dbusC <- maybe DBus.connectSession return maybeDBus+ sDBusC <- DBus.connectSystem+ _ <-+ DBus.requestName+ dbusC+ "org.taffybar.Bar"+ [DBus.nameAllowReplacement, DBus.nameReplaceExisting]+ listenersVar <- MV.newMVar []+ state <- MV.newMVar M.empty+ wakeupMgr <- newWakeupManager+ hyprClient <- newHyprlandClient defaultHyprlandClientConfig+ hyprEventChanVar <- MV.newMVar Nothing+ x11Context <- case backendType of+ BackendX11 -> Just <$> (getX11Context def >>= MV.newMVar)+ BackendWayland -> return Nothing+ windowsVar <- MV.newMVar []+ let context =+ Context+ { x11ContextVar = x11Context,+ listeners = listenersVar,+ contextState = state,+ sessionDBusClient = dbusC,+ systemDBusClient = sDBusC,+ getBarConfigs = barConfigGetter,+ hyprlandClient = hyprClient,+ hyprlandEventChanVar = hyprEventChanVar,+ wakeupManager = wakeupMgr,+ existingWindows = windowsVar,+ backend = backendType,+ contextBarConfig = Nothing+ }+ _ <-+ runMaybeT $+ MaybeT GI.Gdk.displayGetDefault+ >>= (lift . GI.Gdk.displayGetDefaultScreen)+ >>= ( lift+ . flip+ GI.Gdk.afterScreenMonitorsChanged+ -- XXX: We have to do a force refresh here because there is no+ -- way to reliably move windows, since the window manager can do+ -- whatever it pleases.+ (runReaderT forceRefreshTaffyWindows context)+ )++ -- Some compositors/backends will keep the reserved space for a layer-shell+ -- surface/strut window after suspend, but fail to properly re-display the+ -- window. Listen for systemd-logind resume and force a refresh.+ registerResumeRefresh context++ flip runReaderT context $ do+ logC DEBUG "Starting X11 Handler"+ startX11EventHandler+ logC DEBUG "Running startup hook"+ startup+ logC DEBUG "Queing build windows command"+ refreshTaffyWindows+ logIO DEBUG "Context build finished"+ return context++-- | Register a logind sleep/resume listener that forces a window refresh after+-- resume. This is a pragmatic workaround for cases where the bar stays "reserved"+-- (exclusive zone/strut) but stops being visible after resume.+registerResumeRefresh :: Context -> IO ()+registerResumeRefresh ctx = do+ let client = systemDBusClient ctx+ rule =+ DBus.matchAny+ { DBus.matchInterface = Just DBusParams.login1ManagerInterfaceName,+ DBus.matchMember = Just "PrepareForSleep",+ DBus.matchPath = Just DBusParams.login1ObjectPath+ }++ -- Debounce: on some systems we can see multiple resume-related events close+ -- together. Avoid spamming refreshes.+ pendingVar <- MV.newMVar False++ let scheduleRefresh :: IO ()+ scheduleRefresh = do+ wasPending <- MV.swapMVar pendingVar True+ unless wasPending $ void $ forkIO $ do+ -- Give the compositor a moment to re-establish outputs/surfaces.+ threadDelay 1_000_000+ _ <- MV.swapMVar pendingVar False+ logIO NOTICE "Resumed from sleep - forcing taffybar window refresh"+ postGUIASync $ runReaderT forceRefreshTaffyWindows ctx++ callback :: D.Signal -> IO ()+ callback sig =+ case D.signalBody sig of+ [v] ->+ case D.fromVariant v :: Maybe Bool of+ Just True -> logIO DEBUG "PrepareForSleep(True) received"+ Just False -> scheduleRefresh+ Nothing -> logIO WARNING "PrepareForSleep signal had unexpected body type"+ _ -> logIO WARNING "PrepareForSleep signal had unexpected body arity"++ result <- try (DBus.addMatch client rule callback) :: IO (Either SomeException DBus.SignalHandler)+ case result of+ Left e ->+ logIO WARNING $+ "Failed to register logind PrepareForSleep handler (resume refresh disabled): "+ ++ show e+ Right _ -> pure ()++-- | Build an empty taffybar context. This function is mostly useful for+-- invoking functions that yield 'TaffyIO' values in a testing setting (e.g. in+-- a repl).+buildEmptyContext :: IO Context+buildEmptyContext = buildContext def++-- | Format the 'barId' as a numeric string.+showBarId :: BarConfig -> String+showBarId = show . hashUnique . barId++getActiveWindowCenterPoint :: TaffyIO (Maybe (Int, Int))+getActiveWindowCenterPoint = runX11 $ do+ maybeActiveWindow <- getActiveWindow+ allWindows <- getWindows+ case maybeActiveWindow >>= \activeWindow -> activeWindow `guardElem` allWindows of+ Nothing -> return Nothing+ Just activeWindow -> do+ display <- getDisplay+ (_, x, y, width, height, _, _) <- lift $ safeGetGeometry display activeWindow+ let centerX = fromIntegral x + fromIntegral width `div` 2+ centerY = fromIntegral y + fromIntegral height `div` 2+ return $ Just (centerX, centerY)+ where+ guardElem value values =+ if value `elem` values+ then Just value+ else Nothing++getPointerMonitor :: GI.Gdk.Display -> IO (Maybe GI.Gdk.Monitor)+getPointerMonitor display = runMaybeT $ do+ seat <- lift $ GI.Gdk.displayGetDefaultSeat display+ pointer <- MaybeT $ GI.Gdk.seatGetPointer seat+ (_, x, y) <- lift $ GI.Gdk.deviceGetPosition pointer+ MaybeT $ Just <$> GI.Gdk.displayGetMonitorAtPoint display x y++getFocusedMonitorX11 :: Context -> IO (Maybe GI.Gdk.Monitor)+getFocusedMonitorX11 context = runMaybeT $ do+ display <- MaybeT GI.Gdk.displayGetDefault+ maybeCenterPoint <- lift $ runReaderT getActiveWindowCenterPoint context+ case maybeCenterPoint of+ Just (x, y) ->+ MaybeT $+ Just+ <$> GI.Gdk.displayGetMonitorAtPoint+ display+ (fromIntegral x)+ (fromIntegral y)+ Nothing -> MaybeT $ getPointerMonitor display++getFocusedMonitorWayland :: Context -> IO (Maybe GI.Gdk.Monitor)+getFocusedMonitorWayland context = runMaybeT $ do+ display <- MaybeT GI.Gdk.displayGetDefault+ maybeFocusedMonitorPosition <- lift $ getFocusedMonitorPosition (hyprlandClient context)+ case maybeFocusedMonitorPosition of+ Just (x, y) ->+ MaybeT $+ Just+ <$> GI.Gdk.displayGetMonitorAtPoint+ display+ (fromIntegral x)+ (fromIntegral y)+ Nothing -> MaybeT $ getPointerMonitor display++withHyprlandEventChan :: Context -> IO HyprlandEventChan+withHyprlandEventChan context =+ MV.modifyMVar (hyprlandEventChanVar context) $ \existing ->+ case existing of+ Just eventChan -> return (existing, eventChan)+ Nothing -> do+ eventChan <- buildHyprlandEventChan (hyprlandClient context)+ return (Just eventChan, eventChan)++getHyprlandFocusedMonitorEvents :: Context -> IO (TChan T.Text)+getHyprlandFocusedMonitorEvents context = do+ eventChan <- withHyprlandEventChan context+ subscribeHyprlandEvents eventChan++buildBarWindow :: Context -> BarConfig -> IO Gtk.Window+buildBarWindow context barConfig = do+ let thisContext = context {contextBarConfig = Just barConfig}+ logC INFO $+ printf+ "Building window for Taffybar(id=%s) with %s"+ (showBarId barConfig)+ (show $ strutConfig barConfig)++ window <- Gtk.windowNew Gtk.WindowTypeToplevel++ void $ Gtk.onWidgetDestroy window $ do+ let bId = showBarId barConfig+ logC INFO $ printf "Window for Taffybar(id=%s) destroyed" bId+ MV.modifyMVar_ (existingWindows context) (pure . filter ((/=) window . sel2))+ logC DEBUG $ printf "Window for Taffybar(id=%s) unregistered" bId++ setupBarWindow context (strutConfig barConfig) window++ let focusedMonitorHooks =+ case backend thisContext of+ BackendX11 ->+ FocusedMonitorHooksX11+ { resolveFocusedMonitorX11 = getFocusedMonitorX11 thisContext,+ subscribeToFocusedMonitorX11Events =+ flip runReaderT thisContext+ . subscribeToPropertyEvents [ewmhActiveWindow, ewmhCurrentDesktop]+ . const+ . lift,+ unsubscribeFromFocusedMonitorX11Events =+ flip runReaderT thisContext . unsubscribe+ }+ BackendWayland ->+ FocusedMonitorHooksWayland+ { resolveFocusedMonitorWayland = getFocusedMonitorWayland thisContext,+ getFocusedMonitorHyprlandEvents = getHyprlandFocusedMonitorEvents thisContext+ }++ _ <- widgetSetClassGI window "taffy-window"+ setupFocusedMonitorClassUpdates+ focusedMonitorHooks+ window+ (strutMonitor (strutConfig barConfig))++ let addWidgetWith widgetAdd (count, buildWidget) =+ runReaderT buildWidget thisContext >>= widgetAdd count++ shownBoxes <-+ case barLevels barConfig of+ Nothing -> do+ box <-+ Gtk.boxNew Gtk.OrientationHorizontal $+ fromIntegral $+ widgetSpacing barConfig+ _ <- widgetSetClassGI box "taffy-box"+ Gtk.widgetSetVexpand box False+ Gtk.setWidgetValign box Gtk.AlignFill+ centerBox <-+ Gtk.boxNew Gtk.OrientationHorizontal $+ fromIntegral $+ widgetSpacing barConfig++ _ <- widgetSetClassGI centerBox "center-box"+ Gtk.widgetSetVexpand centerBox True+ Gtk.setWidgetValign centerBox Gtk.AlignFill+ Gtk.setWidgetHalign centerBox Gtk.AlignCenter+ Gtk.boxSetCenterWidget box (Just centerBox)++ Gtk.containerAdd window box++ let addToStart count widget = do+ _ <- widgetSetClassGI widget $ T.pack $ printf "left-%d" (count :: Int)+ Gtk.boxPackStart box widget False False 0+ addToEnd count widget = do+ _ <- widgetSetClassGI widget $ T.pack $ printf "right-%d" (count :: Int)+ Gtk.boxPackEnd box widget False False 0+ addToCenter count widget = do+ _ <- widgetSetClassGI widget $ T.pack $ printf "center-%d" (count :: Int)+ Gtk.boxPackStart centerBox widget False False 0++ logIO DEBUG "Building start widgets"+ mapM_ (addWidgetWith addToStart) $ zip [1 ..] (startWidgets barConfig)+ logIO DEBUG "Building center widgets"+ mapM_ (addWidgetWith addToCenter) $ zip [1 ..] (centerWidgets barConfig)+ logIO DEBUG "Building end widgets"+ mapM_ (addWidgetWith addToEnd) $ zip [1 ..] (endWidgets barConfig)++ return [box, centerBox]+ Just levels -> do+ levelsBox <- Gtk.boxNew Gtk.OrientationVertical 0+ _ <- widgetSetClassGI levelsBox "taffy-levels"+ Gtk.widgetSetVexpand levelsBox False+ Gtk.setWidgetValign levelsBox Gtk.AlignFill+ Gtk.containerAdd window levelsBox++ let flattenRows = concatMap (\(box, centerBox) -> [box, centerBox])+ buildLevel (levelCount, BarLevelConfig {levelStartWidgets = starts, levelCenterWidgets = centers, levelEndWidgets = ends}) = do+ box <-+ Gtk.boxNew Gtk.OrientationHorizontal $+ fromIntegral $+ widgetSpacing barConfig+ _ <- widgetSetClassGI box "taffy-box"+ _ <- widgetSetClassGI box $ T.pack $ printf "level-%d" (levelCount :: Int)+ Gtk.widgetSetVexpand box False+ Gtk.setWidgetValign box Gtk.AlignFill++ centerBox <-+ Gtk.boxNew Gtk.OrientationHorizontal $+ fromIntegral $+ widgetSpacing barConfig+ _ <- widgetSetClassGI centerBox "center-box"+ _ <- widgetSetClassGI centerBox $ T.pack $ printf "level-%d-center" (levelCount :: Int)+ Gtk.widgetSetVexpand centerBox True+ Gtk.setWidgetValign centerBox Gtk.AlignFill+ Gtk.setWidgetHalign centerBox Gtk.AlignCenter+ Gtk.boxSetCenterWidget box (Just centerBox)++ Gtk.boxPackStart levelsBox box False True 0++ let addToStart count widget = do+ _ <- widgetSetClassGI widget $ T.pack $ printf "left-%d" (count :: Int)+ Gtk.boxPackStart box widget False False 0+ addToEnd count widget = do+ _ <- widgetSetClassGI widget $ T.pack $ printf "right-%d" (count :: Int)+ Gtk.boxPackEnd box widget False False 0+ addToCenter count widget = do+ _ <- widgetSetClassGI widget $ T.pack $ printf "center-%d" (count :: Int)+ Gtk.boxPackStart centerBox widget False False 0++ logIO DEBUG $ printf "Building level %d start widgets" (levelCount :: Int)+ mapM_ (addWidgetWith addToStart) $ zip [1 ..] starts+ logIO DEBUG $ printf "Building level %d center widgets" (levelCount :: Int)+ mapM_ (addWidgetWith addToCenter) $ zip [1 ..] centers+ logIO DEBUG $ printf "Building level %d end widgets" (levelCount :: Int)+ mapM_ (addWidgetWith addToEnd) $ zip [1 ..] ends++ return (box, centerBox)++ levelRows <- mapM buildLevel $ zip [1 ..] levels+ return $ levelsBox : flattenRows levelRows++ makeWindowTransparent window++ logIO DEBUG "Showing window"+ Gtk.widgetShow window+ mapM_ Gtk.widgetShow shownBoxes++ when (backend context == BackendX11) $+ runX11Context context () $+ void $+ runMaybeT $ do+ gdkWindow <- MaybeT $ Gtk.widgetGetWindow window+ xid <- GdkX11.x11WindowGetXid =<< liftIO (unsafeCastTo X11Window gdkWindow)+ logC DEBUG $ printf "Lowering X11 window %s" $ show xid+ lift $ doLowerWindow (fromIntegral xid)++ return window++-- | Use the "barConfigGetter" field of "Context" to get the set of taffybar+-- windows that should active. Will avoid recreating windows if there is already+-- a window with the appropriate geometry and "BarConfig".+refreshTaffyWindows :: TaffyIO ()+refreshTaffyWindows = mapReaderT postGUIASync $ do+ logC DEBUG "Refreshing windows"+ ctx <- ask+ windowsVar <- asks existingWindows++ let rebuildWindows currentWindows = flip runReaderT ctx $+ do+ barConfigs <- join $ asks getBarConfigs++ let currentConfigs = map sel1 currentWindows+ newConfs = filter (`notElem` currentConfigs) barConfigs+ (remainingWindows, removedWindows) =+ partition ((`elem` barConfigs) . sel1) currentWindows+ setPropertiesFromPair (barConf, window) =+ setupBarWindow ctx (strutConfig barConf) window++ newWindowPairs <- lift $ do+ logIO DEBUG $+ printf "removedWindows: %s" $+ show $+ map (strutConfig . sel1) removedWindows+ logIO DEBUG $+ printf "remainingWindows: %s" $+ show $+ map (strutConfig . sel1) remainingWindows+ logIO DEBUG $+ printf "newWindows: %s" $+ show $+ map strutConfig newConfs+ logIO DEBUG $+ printf "barConfigs: %s" $+ show $+ map strutConfig barConfigs++ -- TODO: This should actually use the config that is provided from+ -- getBarConfigs so that the strut properties of the window can be+ -- altered.+ logIO DEBUG "Updating strut properties for existing windows"+ mapM_ setPropertiesFromPair remainingWindows++ logIO DEBUG "Constructing new windows"+ mapM+ (sequenceT . ((return :: a -> IO a) &&& buildBarWindow ctx))+ newConfs++ return (newWindowPairs ++ remainingWindows, map sel2 removedWindows)++ -- Destroy removed windows AFTER releasing the MVar to avoid deadlock+ -- with the onWidgetDestroy handler, which also modifies existingWindows.+ windowsToDestroy <- lift $ MV.modifyMVar windowsVar rebuildWindows+ lift $ do+ logIO DEBUG "Removing windows"+ mapM_ Gtk.widgetDestroy windowsToDestroy+ logC DEBUG "Finished refreshing windows"+ return ()++-- | Unconditionally delete all existing Taffybar top-level windows.+removeTaffyWindows :: TaffyIO ()+removeTaffyWindows = asks existingWindows >>= liftIO . MV.readMVar >>= deleteWindows+ where+ deleteWindows = mapM_ (sequenceT . (msg *** del))++ msg :: BarConfig -> TaffyIO ()+ msg barConfig =+ logC INFO $+ printf "Destroying window for Taffybar(id=%s)" (showBarId barConfig)++ del :: Gtk.Window -> TaffyIO ()+ del = Gtk.widgetDestroy++-- | Forcibly refresh taffybar windows, even if there are existing windows that+-- correspond to the uniques in the bar configs yielded by 'barConfigGetter'.+forceRefreshTaffyWindows :: TaffyIO ()+forceRefreshTaffyWindows = removeTaffyWindows >> refreshTaffyWindows++-- | Destroys all top-level windows belonging to Taffybar, then+-- requests the GTK main loop to exit.+--+-- This ensures that the windows disappear promptly. For GTK windows+-- to be destroyed, the main loop still needs to be running.+exitTaffybar :: Context -> IO ()+exitTaffybar ctx = do+ postGUIASync $ runReaderT removeTaffyWindows ctx+ Gtk.mainQuit++asksContextVar :: (r -> MV.MVar b) -> ReaderT r IO b+asksContextVar getter = asks getter >>= lift . MV.readMVar++-- | Run a function needing an X11 connection in 'TaffyIO'.+runX11 :: X11Property a -> TaffyIO a+runX11 action = do+ maybeCtxVar <- asks x11ContextVar+ case maybeCtxVar of+ Nothing ->+ liftIO $ fail "X11 context unavailable (Wayland backend in use)"+ Just ctxVar -> do+ ctx <- liftIO $ MV.readMVar ctxVar+ liftIO $ runReaderT action ctx++-- | Use 'runX11' together with 'postX11RequestSyncProp' on the provided+-- property. Return the provided default if 'Nothing' is returned+-- 'postX11RequestSyncProp'.+runX11Def :: a -> X11Property a -> TaffyIO a+runX11Def dflt prop = runX11 $ postX11RequestSyncProp prop dflt++runX11Context :: (MonadIO m) => Context -> a -> X11Property a -> m a+runX11Context context dflt prop =+ liftIO $ runReaderT (runX11Def dflt prop) context++-- | Get a state value by type from the 'contextState' field of 'Context'.+getState :: forall t. (Typeable t) => Taffy IO (Maybe t)+getState = do+ stateMap <- asksContextVar contextState+ let maybeValue = M.lookup (typeRep (Proxy :: Proxy t)) stateMap+ return $ maybeValue >>= fromValue++-- | Like "putState", but avoids aquiring a lock if the value is already in the+-- map.+getStateDefault :: (Typeable t) => Taffy IO t -> Taffy IO t+getStateDefault defaultGetter =+ getState >>= maybe (putState defaultGetter) return++-- | Get a value of the type returned by the provided action from the the+-- current taffybar state, unless the state does not exist, in which case the+-- action will be called to populate the state map.+putState :: forall t. (Typeable t) => Taffy IO t -> Taffy IO t+putState getValue = do+ contextVar <- asks contextState+ ctx <- ask+ lift $ MV.modifyMVar contextVar $ \contextStateMap ->+ let theType = typeRep (Proxy :: Proxy t)+ currentValue = M.lookup theType contextStateMap+ insertAndReturn value =+ (M.insert theType (Value value) contextStateMap, value)+ in flip runReaderT ctx $+ maybe+ (insertAndReturn <$> getValue)+ (return . (contextStateMap,))+ (currentValue >>= fromValue)++-- | Overwrite a state value by type in the 'contextState' field of 'Context'.+-- 'putState'/'getStateDefault' are intentionally "set-once" helpers; widgets+-- that maintain mutable caches should use this to update them.+setState :: forall t. (Typeable t) => t -> Taffy IO t+setState value = do+ contextVar <- asks contextState+ let theType = typeRep (Proxy :: Proxy t)+ lift $ MV.modifyMVar_ contextVar $ \contextStateMap ->+ return $ M.insert theType (Value value) contextStateMap+ return value++-- | A version of 'forkIO' in 'TaffyIO'.+taffyFork :: ReaderT r IO () -> ReaderT r IO ()+taffyFork = void . mapReaderT forkIO++startX11EventHandler :: Taffy IO ()+startX11EventHandler = do+ backendType <- asks backend+ when (backendType == BackendX11) $ taffyFork $ do+ c <- ask+ -- XXX: The event loop needs its own X11Context to separately handle+ -- communications from the X server. We deliberately avoid using the context+ -- from x11ContextVar here.+ lift $+ withX11Context def $+ eventLoop+ (\e -> runReaderT (handleX11Event e) c)++setupBarWindow :: Context -> StrutConfig -> Gtk.Window -> IO ()+setupBarWindow context config window =+ case backend context of+ BackendX11 -> do+ setupStrutWindow config window+ setX11WindowSizeFromStrut config window+ BackendWayland -> setupLayerShellWindow config window++-- | Remove the listener associated with the provided "Unique" from the+-- collection of listeners.+unsubscribe :: Unique -> Taffy IO ()+unsubscribe identifier = do+ listenersVar <- asks listeners+ lift $ MV.modifyMVar_ listenersVar $ return . filter ((== identifier) . fst)++-- | Subscribe to all incoming events on the X11 event loop. The returned+-- "Unique" value can be used to unregister the listener using "unsuscribe".+subscribeToAll :: Listener -> Taffy IO Unique+subscribeToAll listener = do+ identifier <- lift newUnique+ listenersVar <- asks listeners+ let -- XXX: This type annotation probably has something to do with the warnings+ -- that occur without MonoLocalBinds, but it still seems to be necessary+ addListener :: SubscriptionList -> SubscriptionList+ addListener = ((identifier, listener) :)+ lift $ MV.modifyMVar_ listenersVar (return . addListener)+ return identifier++-- | Subscribe to X11 "PropertyEvent"s where the property changed is in the+-- provided list.+subscribeToPropertyEvents :: [String] -> Listener -> Taffy IO Unique+subscribeToPropertyEvents eventNames listener = do+ eventAtoms <- mapM (runX11 . getAtom) eventNames+ let filteredListener event@PropertyEvent {ev_atom = atom} =+ when (atom `elem` eventAtoms) $+ catchAny (listener event) (const $ return ())+ filteredListener _ = return ()+ subscribeToAll filteredListener++handleX11Event :: Event -> Taffy IO ()+handleX11Event event =+ asksContextVar listeners >>= mapM_ applyListener+ where+ applyListener :: (Unique, Listener) -> Taffy IO ()+ applyListener (_, listener) = taffyFork $ listener event++setupLayerShellWindow :: StrutConfig -> Gtk.Window -> IO ()+setupLayerShellWindow+ StrutConfig+ { strutWidth = widthSize,+ strutHeight = heightSize,+ strutXPadding = xpadding,+ strutYPadding = ypadding,+ strutMonitor = monitorNumber,+ strutPosition = position,+ strutDisplayName = maybeDisplayName+ }+ window = do+ supported <- GtkLayerShell.isSupported+ unless supported $+ logIO WARNING "Wayland backend selected, but gtk-layer-shell is not supported"+ when supported $ do+ maybeDisplay <- maybe GI.Gdk.displayGetDefault GI.Gdk.displayOpen maybeDisplayName+ case maybeDisplay of+ Nothing -> logIO WARNING "Failed to get GDK display for layer-shell"+ Just display -> do+ maybeMonitor <-+ maybe+ (GI.Gdk.displayGetPrimaryMonitor display)+ (GI.Gdk.displayGetMonitor display)+ monitorNumber+ case maybeMonitor of+ Nothing -> logIO WARNING "Failed to get GDK monitor for layer-shell"+ Just monitor -> do+ monitorGeometry <- GI.Gdk.monitorGetGeometry monitor+ monitorWidth <- GI.Gdk.getRectangleWidth monitorGeometry+ monitorHeight <- GI.Gdk.getRectangleHeight monitorGeometry+ let width =+ case widthSize of+ ExactSize w -> w+ ScreenRatio p ->+ floor $ p * fromIntegral (monitorWidth - (2 * xpadding))+ height =+ case heightSize of+ ExactSize h -> h+ ScreenRatio p ->+ floor $ p * fromIntegral (monitorHeight - (2 * ypadding))+ exclusive =+ case position of+ TopPos -> height + 2 * ypadding+ BottomPos -> height + 2 * ypadding+ LeftPos -> width + 2 * xpadding+ RightPos -> width + 2 * xpadding++ Gtk.windowSetDefaultSize window (fromIntegral width) (fromIntegral height)+ let (reqWidth, reqHeight) =+ case position of+ TopPos -> (-1, height)+ BottomPos -> (-1, height)+ LeftPos -> (width, -1)+ RightPos -> (width, -1)+ Gtk.widgetSetSizeRequest+ window+ (fromIntegral reqWidth)+ (fromIntegral reqHeight)++ alreadyLayer <- GtkLayerShell.isLayerWindow window+ unless alreadyLayer $ do+ GtkLayerShell.initForWindow window+ GtkLayerShell.setKeyboardMode window GtkLayerShell.KeyboardModeNone+ GtkLayerShell.setNamespace window (T.pack "taffybar")+ GtkLayerShell.setMonitor window monitor++ GtkLayerShell.setMargin window GtkLayerShell.EdgeLeft xpadding+ GtkLayerShell.setMargin window GtkLayerShell.EdgeRight xpadding+ GtkLayerShell.setMargin window GtkLayerShell.EdgeTop ypadding+ GtkLayerShell.setMargin window GtkLayerShell.EdgeBottom ypadding++ GtkLayerShell.setLayer window GtkLayerShell.LayerTop++ let setAnchor = GtkLayerShell.setAnchor window++ case position of+ TopPos -> do+ setAnchor GtkLayerShell.EdgeTop True+ setAnchor GtkLayerShell.EdgeBottom False+ setAnchor GtkLayerShell.EdgeLeft True+ setAnchor GtkLayerShell.EdgeRight True+ BottomPos -> do+ setAnchor GtkLayerShell.EdgeTop False+ setAnchor GtkLayerShell.EdgeBottom True+ setAnchor GtkLayerShell.EdgeLeft True+ setAnchor GtkLayerShell.EdgeRight True+ LeftPos -> do+ setAnchor GtkLayerShell.EdgeLeft True+ setAnchor GtkLayerShell.EdgeRight False+ setAnchor GtkLayerShell.EdgeTop True+ setAnchor GtkLayerShell.EdgeBottom True+ RightPos -> do+ setAnchor GtkLayerShell.EdgeLeft False+ setAnchor GtkLayerShell.EdgeRight True+ setAnchor GtkLayerShell.EdgeTop True+ setAnchor GtkLayerShell.EdgeBottom True++ GtkLayerShell.setExclusiveZone window exclusive++ -- Dynamically update exclusive zone when the bar's actual+ -- allocated size exceeds the configured size (issue #270).+ lastExclusiveRef <- newIORef exclusive+ void $ Gtk.onWidgetSizeAllocate window $ \alloc -> do+ allocH <- GI.Gdk.getRectangleHeight alloc+ allocW <- GI.Gdk.getRectangleWidth alloc+ let newExclusive =+ case position of+ TopPos -> fromIntegral allocH + 2 * ypadding+ BottomPos -> fromIntegral allocH + 2 * ypadding+ LeftPos -> fromIntegral allocW + 2 * xpadding+ RightPos -> fromIntegral allocW + 2 * xpadding+ prev <- readIORef lastExclusiveRef+ when (newExclusive /= prev) $ do+ logIO DEBUG $+ printf "Updating exclusive zone from %d to %d" prev newExclusive+ writeIORef lastExclusiveRef newExclusive+ GtkLayerShell.setExclusiveZone window newExclusive++setX11WindowSizeFromStrut :: StrutConfig -> Gtk.Window -> IO ()+setX11WindowSizeFromStrut+ StrutConfig+ { strutWidth = widthSize,+ strutHeight = heightSize,+ strutXPadding = xpadding,+ strutYPadding = ypadding,+ strutMonitor = monitorNumber,+ strutPosition = position,+ strutDisplayName = maybeDisplayName+ }+ window = do+ maybeDisplay <- maybe GI.Gdk.displayGetDefault GI.Gdk.displayOpen maybeDisplayName+ case maybeDisplay of+ Nothing -> pure ()+ Just display -> do+ maybeMonitor <-+ maybe+ (GI.Gdk.displayGetPrimaryMonitor display)+ (GI.Gdk.displayGetMonitor display)+ monitorNumber+ case maybeMonitor of+ Nothing -> pure ()+ Just monitor -> do+ monitorGeometry <- GI.Gdk.monitorGetGeometry monitor+ monitorWidth <- GI.Gdk.getRectangleWidth monitorGeometry+ monitorHeight <- GI.Gdk.getRectangleHeight monitorGeometry+ let width =+ case widthSize of+ ExactSize w -> w+ ScreenRatio p ->+ floor $ p * fromIntegral (monitorWidth - (2 * xpadding))+ height =+ case heightSize of+ ExactSize h -> h+ ScreenRatio p ->+ floor $ p * fromIntegral (monitorHeight - (2 * ypadding))+ (reqWidth, reqHeight) =+ case position of+ TopPos -> (-1, height)+ BottomPos -> (-1, height)+ LeftPos -> (width, -1)+ RightPos -> (width, -1)+ Gtk.windowSetDefaultSize window (fromIntegral width) (fromIntegral height)+ Gtk.widgetSetSizeRequest+ window+ (fromIntegral reqWidth)+ (fromIntegral reqHeight)
src/System/Taffybar/Context/Backend.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Context.Backend -- Copyright : (c) Ivan A. Malison@@ -14,31 +17,34 @@ -- whether to use an X11 or Wayland backend, compensating for stale or -- missing environment variables that are common when the @systemd -- --user@ manager persists across login sessions.------------------------------------------------------------------------------- module System.Taffybar.Context.Backend- ( Backend(..)- , detectBackend- -- * Discovery helpers- , discoverWaylandSocket- , discoverHyprlandSignature- ) where+ ( Backend (..),+ detectBackend, -import Control.Exception.Enclosed (catchAny)-import Control.Monad-import Data.List (isPrefixOf, isSuffixOf)-import System.Directory (doesPathExist, listDirectory)-import System.Environment (lookupEnv, setEnv, unsetEnv)-import System.FilePath ((</>))-import System.Log.Logger (Priority(..), logM)-import System.Posix.Files (getFileStatus, isSocket)+ -- * Discovery helpers+ discoverWaylandSocket,+ discoverHyprlandSignature,+ )+where +import Control.Exception.Enclosed (catchAny)+import Control.Monad+import Data.List (isPrefixOf, isSuffixOf)+import System.Directory (doesPathExist, listDirectory)+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.FilePath ((</>))+import System.Log.Logger (Priority (..), logM)+import System.Posix.Files (getFileStatus, isSocket)+ logIO :: Priority -> String -> IO () logIO = logM "System.Taffybar.Context.Backend" +-- | Backend selected for the current taffybar process. data Backend- = BackendX11- | BackendWayland+ = -- | Use the X11 backend.+ BackendX11+ | -- | Use the Wayland backend.+ BackendWayland deriving (Eq, Show) -- | Try to find a @wayland-*@ socket in the given runtime directory.@@ -50,17 +56,19 @@ discoverWaylandSocket runtime = do entries <- listDirectory runtime let candidates =- [ e | e <- entries- , "wayland-" `isPrefixOf` e- , not (".lock" `isSuffixOf` e)+ [ e+ | e <- entries,+ "wayland-" `isPrefixOf` e,+ not (".lock" `isSuffixOf` e) ] go candidates where go [] = pure Nothing- go (c:cs) = do- ok <- catchAny- (isSocket <$> getFileStatus (runtime </> c))- (const $ pure False)+ go (c : cs) = do+ ok <-+ catchAny+ (isSocket <$> getFileStatus (runtime </> c))+ (const $ pure False) if ok then pure (Just c) else go cs -- | Try to find a Hyprland instance signature in @XDG_RUNTIME_DIR/hypr/@.@@ -78,7 +86,7 @@ go hyprDir entries where go _ [] = pure Nothing- go hyprDir (e:es) = do+ go hyprDir (e : es) = do isSig <- doesPathExist (hyprDir </> e </> "hyprland.lock") if isSig then pure (Just e) else go hyprDir es
src/System/Taffybar/DBus.hs view
@@ -1,10 +1,12 @@+-- | Convenience combinators for enabling taffybar DBus services. module System.Taffybar.DBus- ( module System.Taffybar.DBus.Toggle- , appendHook- , startTaffyLogServer- , withLogServer- , withToggleServer- ) where+ ( module System.Taffybar.DBus.Toggle,+ appendHook,+ startTaffyLogServer,+ withLogServer,+ withToggleServer,+ )+where import Control.Monad.Trans.Class import Control.Monad.Trans.Reader@@ -12,12 +14,15 @@ import System.Taffybar.Context import System.Taffybar.DBus.Toggle +-- | Start the DBus-backed log sink using the shared session client. startTaffyLogServer :: TaffyIO () startTaffyLogServer = asks sessionDBusClient >>= lift . startLogServer +-- | Extend a config to start the DBus log server during startup. withLogServer :: TaffybarConfig -> TaffybarConfig withLogServer = appendHook startTaffyLogServer +-- | Extend a config with the DBus toggle service. withToggleServer :: TaffybarConfig -> TaffybarConfig withToggleServer = handleDBusToggles
+ src/System/Taffybar/DBus/Client/Login1Manager.hs view
@@ -0,0 +1,21 @@+{-# LANGUAGE TemplateHaskell #-}++module System.Taffybar.DBus.Client.Login1Manager where++import DBus.Generation+import System.FilePath+import System.Taffybar.DBus.Client.Params+import System.Taffybar.DBus.Client.Util++-- NOTE: The corresponding introspection XML is intentionally minimal (signal-only).+-- The full logind Manager interface contains method/arg names that collide with+-- Haskell reserved words and with generated property setters, which causes TH+-- generation to fail.+generateClientFromFile+ defaultRecordGenerationParams+ { recordName = Just "Login1ManagerInfo",+ recordPrefix = "login1"+ }+ login1GenerationParams {genObjectPath = Just login1ObjectPath}+ False+ $ "dbus-xml" </> "org.freedesktop.login1.Manager.xml"
src/System/Taffybar/DBus/Client/MPRIS2.hs view
@@ -1,12 +1,13 @@ {-# LANGUAGE TemplateHaskell #-}+ module System.Taffybar.DBus.Client.MPRIS2 where -import System.Taffybar.DBus.Client.Util import System.FilePath import System.Taffybar.DBus.Client.Params+import System.Taffybar.DBus.Client.Util generateClientFromFile defaultRecordGenerationParams playerGenerationParams False $- "dbus-xml" </> "org.mpris.MediaPlayer2.xml"+ "dbus-xml" </> "org.mpris.MediaPlayer2.xml" generateClientFromFile defaultRecordGenerationParams playerGenerationParams False $- "dbus-xml" </> "org.mpris.MediaPlayer2.Player.xml"+ "dbus-xml" </> "org.mpris.MediaPlayer2.Player.xml"
src/System/Taffybar/DBus/Client/NetworkManager.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE TemplateHaskell #-}+ module System.Taffybar.DBus.Client.NetworkManager where import DBus.Generation@@ -8,9 +9,9 @@ generateClientFromFile defaultRecordGenerationParams- { recordName = Just "NetworkManagerInfo"- , recordPrefix = "nm"- }- nmGenerationParams { genObjectPath = Just nmObjectPath }- False $- "dbus-xml" </> "org.freedesktop.NetworkManager.xml"+ { recordName = Just "NetworkManagerInfo",+ recordPrefix = "nm"+ }+ nmGenerationParams {genObjectPath = Just nmObjectPath}+ False+ $ "dbus-xml" </> "org.freedesktop.NetworkManager.xml"
src/System/Taffybar/DBus/Client/NetworkManagerAccessPoint.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE TemplateHaskell #-}+ module System.Taffybar.DBus.Client.NetworkManagerAccessPoint where import System.FilePath@@ -7,9 +8,9 @@ generateClientFromFile defaultRecordGenerationParams- { recordName = Just "AccessPointInfo"- , recordPrefix = "nmap"- }+ { recordName = Just "AccessPointInfo",+ recordPrefix = "nmap"+ } nmGenerationParams- False $- "dbus-xml" </> "org.freedesktop.NetworkManager.AccessPoint.xml"+ False+ $ "dbus-xml" </> "org.freedesktop.NetworkManager.AccessPoint.xml"
src/System/Taffybar/DBus/Client/NetworkManagerActiveConnection.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE TemplateHaskell #-}+ module System.Taffybar.DBus.Client.NetworkManagerActiveConnection where import System.FilePath@@ -7,9 +8,9 @@ generateClientFromFile defaultRecordGenerationParams- { recordName = Just "ActiveConnectionInfo"- , recordPrefix = "nmac"- }+ { recordName = Just "ActiveConnectionInfo",+ recordPrefix = "nmac"+ } nmGenerationParams- False $- "dbus-xml" </> "org.freedesktop.NetworkManager.Connection.Active.xml"+ False+ $ "dbus-xml" </> "org.freedesktop.NetworkManager.Connection.Active.xml"
src/System/Taffybar/DBus/Client/Params.hs view
@@ -1,5 +1,6 @@-{-# LANGUAGE TemplateHaskellQuotes #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskellQuotes #-}+ module System.Taffybar.DBus.Client.Params where import DBus@@ -8,10 +9,11 @@ import System.Taffybar.DBus.Client.Util playerGenerationParams :: GenerationParams-playerGenerationParams = defaultGenerationParams- { genTakeSignalErrorHandler = True- , genObjectPath = Just "/org/mpris/MediaPlayer2"- }+playerGenerationParams =+ defaultGenerationParams+ { genTakeSignalErrorHandler = True,+ genObjectPath = Just "/org/mpris/MediaPlayer2"+ } -- | The bus name for NetworkManager. nmBusName :: BusName@@ -41,11 +43,11 @@ "/org/freedesktop/NetworkManager/AccessPoint" nmGenerationParams :: GenerationParams-nmGenerationParams = defaultGenerationParams- { genTakeSignalErrorHandler = True- , genBusName = Just nmBusName- }-+nmGenerationParams =+ defaultGenerationParams+ { genTakeSignalErrorHandler = True,+ genBusName = Just nmBusName+ } -- | The base object path for the UPower interface uPowerBaseObjectPath :: ObjectPath@@ -59,11 +61,31 @@ uPowerDeviceInterfaceName = "org.freedesktop.UPower.Device" uPowerGenerationParams :: GenerationParams-uPowerGenerationParams = defaultGenerationParams- { genTakeSignalErrorHandler = True- , genBusName = Just uPowerBusName- }+uPowerGenerationParams =+ defaultGenerationParams+ { genTakeSignalErrorHandler = True,+ genBusName = Just uPowerBusName+ } +-- | The bus name for systemd-logind.+login1BusName :: BusName+login1BusName = "org.freedesktop.login1"++-- | The object path for the systemd-logind manager.+login1ObjectPath :: ObjectPath+login1ObjectPath = "/org/freedesktop/login1"++-- | The interface name for the systemd-logind manager.+login1ManagerInterfaceName :: InterfaceName+login1ManagerInterfaceName = "org.freedesktop.login1.Manager"++login1GenerationParams :: GenerationParams+login1GenerationParams =+ defaultGenerationParams+ { genTakeSignalErrorHandler = True,+ genBusName = Just login1BusName+ }+ -- | The bus name for PulseAudio's server lookup on the session bus. paServerLookupBusName :: BusName paServerLookupBusName = "org.PulseAudio1"@@ -86,23 +108,27 @@ paDeviceInterfaceName = "org.PulseAudio.Core1.Device" paServerLookupGenerationParams :: GenerationParams-paServerLookupGenerationParams = defaultGenerationParams- { genTakeSignalErrorHandler = True- , genBusName = Just paServerLookupBusName- , genObjectPath = Just paServerLookupObjectPath- }+paServerLookupGenerationParams =+ defaultGenerationParams+ { genTakeSignalErrorHandler = True,+ genBusName = Just paServerLookupBusName,+ genObjectPath = Just paServerLookupObjectPath+ } paCoreGenerationParams :: GenerationParams-paCoreGenerationParams = defaultGenerationParams- { genTakeSignalErrorHandler = True- , genObjectPath = Just paCoreObjectPath- }+paCoreGenerationParams =+ defaultGenerationParams+ { genTakeSignalErrorHandler = True,+ genObjectPath = Just paCoreObjectPath+ } paDeviceGenerationParams :: GenerationParams-paDeviceGenerationParams = defaultGenerationParams- { genTakeSignalErrorHandler = True- }+paDeviceGenerationParams =+ defaultGenerationParams+ { genTakeSignalErrorHandler = True+ } +-- | UPower device type enum. data BatteryType = BatteryTypeUnknown | BatteryTypeLinePower@@ -115,6 +141,7 @@ | BatteryTypePhone deriving (Show, Ord, Eq, Enum) +-- | UPower battery charging state enum. data BatteryState = BatteryStateUnknown | BatteryStateCharging@@ -125,6 +152,7 @@ | BatteryStatePendingDischarge deriving (Show, Ord, Eq, Enum) +-- | UPower battery chemistry enum. data BatteryTechnology = BatteryTechnologyUnknown | BatteryTechnologyLithiumIon@@ -142,4 +170,5 @@ "State" -> yes ''BatteryState "Technology" -> yes ''BatteryTechnology _ -> Nothing- where yes = Just . ConT+ where+ yes = Just . ConT
src/System/Taffybar/DBus/Client/PulseAudioCore.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE TemplateHaskell #-}+ module System.Taffybar.DBus.Client.PulseAudioCore where import DBus.Generation ()@@ -8,9 +9,9 @@ generateClientFromFile defaultRecordGenerationParams- { recordName = Just "PulseAudioCoreInfo"- , recordPrefix = "pac"- }+ { recordName = Just "PulseAudioCoreInfo",+ recordPrefix = "pac"+ } paCoreGenerationParams- False $- "dbus-xml" </> "org.PulseAudio.Core1.xml"+ False+ $ "dbus-xml" </> "org.PulseAudio.Core1.xml"
src/System/Taffybar/DBus/Client/PulseAudioDevice.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE TemplateHaskell #-}+ module System.Taffybar.DBus.Client.PulseAudioDevice where import DBus.Generation ()@@ -8,9 +9,9 @@ generateClientFromFile defaultRecordGenerationParams- { recordName = Just "PulseAudioDeviceInfo"- , recordPrefix = "pad"- }+ { recordName = Just "PulseAudioDeviceInfo",+ recordPrefix = "pad"+ } paDeviceGenerationParams- False $- "dbus-xml" </> "org.PulseAudio.Core1.Device.xml"+ False+ $ "dbus-xml" </> "org.PulseAudio.Core1.Device.xml"
src/System/Taffybar/DBus/Client/PulseAudioServerLookup.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE TemplateHaskell #-}+ module System.Taffybar.DBus.Client.PulseAudioServerLookup where import DBus.Generation ()@@ -8,9 +9,9 @@ generateClientFromFile defaultRecordGenerationParams- { recordName = Just "PulseAudioServerLookupInfo"- , recordPrefix = "pasl"- }+ { recordName = Just "PulseAudioServerLookupInfo",+ recordPrefix = "pasl"+ } paServerLookupGenerationParams- False $- "dbus-xml" </> "org.PulseAudio.ServerLookup1.xml"+ False+ $ "dbus-xml" </> "org.PulseAudio.ServerLookup1.xml"
src/System/Taffybar/DBus/Client/UPower.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE TemplateHaskell #-}+ module System.Taffybar.DBus.Client.UPower where import DBus.Generation@@ -7,9 +8,10 @@ import System.Taffybar.DBus.Client.Util generateClientFromFile- defaultRecordGenerationParams { recordName = Just "UPowerInfo"- , recordPrefix = "upi"- }- uPowerGenerationParams { genObjectPath = Just uPowerBaseObjectPath }- False $- "dbus-xml" </> "org.freedesktop.UPower.xml"+ defaultRecordGenerationParams+ { recordName = Just "UPowerInfo",+ recordPrefix = "upi"+ }+ uPowerGenerationParams {genObjectPath = Just uPowerBaseObjectPath}+ False+ $ "dbus-xml" </> "org.freedesktop.UPower.xml"
src/System/Taffybar/DBus/Client/UPowerDevice.hs view
@@ -1,15 +1,19 @@ {-# LANGUAGE TemplateHaskell #-}++-- | Generated DBus client bindings for @org.freedesktop.UPower.Device@. module System.Taffybar.DBus.Client.UPowerDevice where import System.FilePath import System.Taffybar.DBus.Client.Params import System.Taffybar.DBus.Client.Util +-- | Generate the UPower device client and the 'BatteryInfo' record type. generateClientFromFile defaultRecordGenerationParams- { recordName = Just "BatteryInfo"- , recordPrefix = "battery"- , recordTypeForName = batteryTypeForName- }+ { recordName = Just "BatteryInfo",+ recordPrefix = "battery",+ recordTypeForName = batteryTypeForName+ } uPowerGenerationParams- False $ "dbus-xml" </> "org.freedesktop.UPower.Device.xml"+ False+ $ "dbus-xml" </> "org.freedesktop.UPower.Device.xml"
src/System/Taffybar/DBus/Client/Util.hs view
@@ -1,17 +1,18 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskellQuotes #-}+ module System.Taffybar.DBus.Client.Util where import Control.Monad (forM)-import DBus.Generation+import DBus.Generation import qualified DBus.Internal.Types as T import qualified DBus.Introspection as I import qualified Data.Char as Char-import Data.Coerce-import Data.Maybe-import Language.Haskell.TH-import StatusNotifier.Util (getIntrospectionObjectFromFile)+import Data.Coerce+import Data.Maybe+import Language.Haskell.TH+import StatusNotifier.Util (getIntrospectionObjectFromFile) deriveShowAndEQ :: [DerivClause] deriveShowAndEQ =@@ -19,11 +20,16 @@ buildDataFromNameTypePairs :: Name -> [(Name, Type)] -> Dec buildDataFromNameTypePairs name pairs =- DataD [] name [] Nothing [RecC name (map mkVarBangType pairs)]- deriveShowAndEQ- where mkVarBangType (fieldName, fieldType) =- (fieldName, Bang NoSourceUnpackedness NoSourceStrictness, fieldType)-+ DataD+ []+ name+ []+ Nothing+ [RecC name (map mkVarBangType pairs)]+ deriveShowAndEQ+ where+ mkVarBangType (fieldName, fieldType) =+ (fieldName, Bang NoSourceUnpackedness NoSourceStrictness, fieldType) standaloneDeriveEqShow :: Name -> [Dec] standaloneDeriveEqShow _ = []@@ -31,47 +37,53 @@ type GetTypeForName = String -> T.Type -> Maybe Type data RecordGenerationParams = RecordGenerationParams- { recordName :: Maybe String- , recordPrefix :: String- , recordTypeForName :: GetTypeForName+ { recordName :: Maybe String,+ recordPrefix :: String,+ recordTypeForName :: GetTypeForName } defaultRecordGenerationParams :: RecordGenerationParams-defaultRecordGenerationParams = RecordGenerationParams- { recordName = Nothing- , recordPrefix = "_"- , recordTypeForName = const $ const Nothing- }+defaultRecordGenerationParams =+ RecordGenerationParams+ { recordName = Nothing,+ recordPrefix = "_",+ recordTypeForName = const $ const Nothing+ } -generateGetAllRecord- :: RecordGenerationParams- -> GenerationParams- -> I.Interface- -> Q [Dec]+generateGetAllRecord ::+ RecordGenerationParams ->+ GenerationParams ->+ I.Interface ->+ Q [Dec] generateGetAllRecord- RecordGenerationParams- { recordName = recordNameString- , recordPrefix = prefix- , recordTypeForName = getTypeForName- }- GenerationParams { getTHType = getArgType }- I.Interface { I.interfaceName = interfaceName- , I.interfaceProperties = properties- } = do- let theRecordName =- maybe (mkName $ map Char.toUpper $ filter Char.isLetter $ coerce interfaceName)- mkName recordNameString- let getPairFromProperty I.Property- { I.propertyName = propName- , I.propertyType = propType- } =- ( mkName $ prefix ++ propName- , fromMaybe (getArgType propType) $ getTypeForName propName propType- )- getAllRecord =- buildDataFromNameTypePairs- theRecordName $ map getPairFromProperty properties- return $ getAllRecord:standaloneDeriveEqShow theRecordName+ RecordGenerationParams+ { recordName = recordNameString,+ recordPrefix = prefix,+ recordTypeForName = getTypeForName+ }+ GenerationParams {getTHType = getArgType}+ I.Interface+ { I.interfaceName = interfaceName,+ I.interfaceProperties = properties+ } = do+ let theRecordName =+ maybe+ (mkName $ map Char.toUpper $ filter Char.isLetter $ coerce interfaceName)+ mkName+ recordNameString+ let getPairFromProperty+ I.Property+ { I.propertyName = propName,+ I.propertyType = propType+ } =+ ( mkName $ prefix ++ propName,+ fromMaybe (getArgType propType) $ getTypeForName propName propType+ )+ getAllRecord =+ buildDataFromNameTypePairs+ theRecordName+ $ map getPairFromProperty properties+ return $ getAllRecord : standaloneDeriveEqShow theRecordName generateClientFromFile :: RecordGenerationParams -> GenerationParams -> Bool -> FilePath -> Q [Dec] generateClientFromFile recordGenerationParams params useObjectPath filepath = do@@ -83,6 +95,6 @@ else params (<++>) = liftA2 (++) fmap concat $ forM (I.objectInterfaces object) $ \interface -> do- generateGetAllRecord recordGenerationParams params interface <++>- generateClient realParams interface <++>- generateSignalsFromInterface realParams interface+ generateGetAllRecord recordGenerationParams params interface+ <++> generateClient realParams interface+ <++> generateSignalsFromInterface realParams interface
src/System/Taffybar/DBus/Toggle.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.DBus.Toggle -- Copyright : (c) Ivan A. Malison@@ -11,32 +15,30 @@ -- -- This module provides a dbus interface that allows users to toggle the display -- of taffybar on each monitor while it is running.--------------------------------------------------------------------------------module System.Taffybar.DBus.Toggle ( handleDBusToggles ) where+module System.Taffybar.DBus.Toggle (handleDBusToggles) where import qualified Control.Concurrent.MVar as MV-import Control.Exception-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Class-import Control.Monad.Trans.Maybe-import Control.Monad.Trans.Reader-import DBus-import DBus.Client-import Data.Int+import Control.Exception+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Class+import Control.Monad.Trans.Maybe+import Control.Monad.Trans.Reader+import DBus+import DBus.Client+import Data.Int import qualified Data.Map as M-import Data.Maybe+import Data.Maybe import qualified GI.Gdk as Gdk-import Graphics.UI.GIGtkStrut-import System.Directory-import System.FilePath.Posix-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.Information.Hyprland (getFocusedMonitorPosition)-import System.Taffybar.Util-import Text.Printf-import Text.Read ( readMaybe )+import Graphics.UI.GIGtkStrut+import System.Directory+import System.FilePath.Posix+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Information.Hyprland (getFocusedMonitorPosition)+import System.Taffybar.Util+import Text.Printf+import Text.Read (readMaybe) -- $usage --@@ -53,7 +55,7 @@ logIO :: System.Log.Logger.Priority -> String -> IO () logIO = logM "System.Taffybar.DBus.Toggle" -logT :: MonadIO m => System.Log.Logger.Priority -> String -> m ()+logT :: (MonadIO m) => System.Log.Logger.Priority -> String -> m () logT p = liftIO . logIO p getActiveMonitorNumber :: Context -> MaybeT IO Int@@ -75,26 +77,30 @@ getActiveMonitorNumberWayland ctx = do (x, y) <- MaybeT $ getFocusedMonitorPosition (hyprlandClient ctx) display <- MaybeT Gdk.displayGetDefault- monitor <- lift $ Gdk.displayGetMonitorAtPoint display- (fromIntegral x) (fromIntegral y)+ monitor <-+ lift $+ Gdk.displayGetMonitorAtPoint+ display+ (fromIntegral x)+ (fromIntegral y) lift $ getMonitorNumber monitor getMonitorNumber :: Gdk.Monitor -> IO Int getMonitorNumber monitor = do display <- Gdk.monitorGetDisplay monitor monitorCount <- Gdk.displayGetNMonitors display- monitors <- mapM (Gdk.displayGetMonitor display) [0..(monitorCount-1)]+ monitors <- mapM (Gdk.displayGetMonitor display) [0 .. (monitorCount - 1)] monitorGeometry <- Gdk.getMonitorGeometry monitor let equalsMonitor (Just other, _) = do otherGeometry <- Gdk.getMonitorGeometry other case (otherGeometry, monitorGeometry) of- (Nothing, Nothing) -> return True- (Just g1, Just g2) -> Gdk.rectangleEqual g1 g2- _ -> return False+ (Nothing, Nothing) -> return True+ (Just g1, Just g2) -> Gdk.rectangleEqual g1 g2+ _ -> return False equalsMonitor _ = return False- snd . fromMaybe (Nothing, 0) . listToMaybe <$>- filterM equalsMonitor (zip monitors [0..])+ snd . fromMaybe (Nothing, 0) . listToMaybe+ <$> filterM equalsMonitor (zip monitors [0 ..]) taffybarTogglePath :: ObjectPath taffybarTogglePath = "/taffybar/toggle"@@ -130,11 +136,17 @@ lift $ MV.modifyMVar_ enabledVar $ \numToEnabled -> do let current = fromMaybe True $ M.lookup mon numToEnabled result = M.insert mon (fn current) numToEnabled- logIO DEBUG $ printf "Toggle state before: %s, after %s"- (show numToEnabled) (show result)+ logIO DEBUG $+ printf+ "Toggle state before: %s, after %s"+ (show numToEnabled)+ (show result) catch (writeFile stateFile (show result)) $ \e ->- logIO WARNING $ printf "Unable to write to toggle state file %s, error: %s"- (show stateFile) (show (e :: SomeException))+ logIO WARNING $+ printf+ "Unable to write to toggle state file %s, error: %s"+ (show stateFile)+ (show (e :: SomeException)) return result refreshTaffyWindows toggleTaffy = do@@ -145,21 +157,26 @@ client <- asks sessionDBusClient let interface = defaultInterface- { interfaceName = taffybarToggleInterface- , interfaceMethods =- [ autoMethod "toggleCurrent" toggleTaffy- , autoMethod "toggleOnMonitor" $ takeInt $ toggleTaffyOnMon not- , autoMethod "hideOnMonitor" $- takeInt $ toggleTaffyOnMon (const False)- , autoMethod "showOnMonitor" $- takeInt $ toggleTaffyOnMon (const True)- , autoMethod "refresh" $ runReaderT refreshTaffyWindows ctx- , autoMethod "exit" $ exitTaffybar ctx- ]- }+ { interfaceName = taffybarToggleInterface,+ interfaceMethods =+ [ autoMethod "toggleCurrent" toggleTaffy,+ autoMethod "toggleOnMonitor" $ takeInt $ toggleTaffyOnMon not,+ autoMethod "hideOnMonitor" $+ takeInt $+ toggleTaffyOnMon (const False),+ autoMethod "showOnMonitor" $+ takeInt $+ toggleTaffyOnMon (const True),+ autoMethod "refresh" $ runReaderT refreshTaffyWindows ctx,+ autoMethod "exit" $ exitTaffybar ctx+ ]+ } lift $ do- _ <- requestName client "taffybar.toggle"- [nameAllowReplacement, nameReplaceExisting]+ _ <-+ requestName+ client+ "taffybar.toggle"+ [nameAllowReplacement, nameReplaceExisting] export client taffybarTogglePath interface dbusTogglesStartupHook :: TaffyIO ()@@ -171,17 +188,20 @@ filepathExists <- doesFileExist stateFilepath mStartingMap <- if filepathExists- then- readMaybe <$> readFile stateFilepath- else- return Nothing+ then+ readMaybe <$> readFile stateFilepath+ else+ return Nothing MV.modifyMVar_ enabledVar $ const $ return $ fromMaybe M.empty mStartingMap logT DEBUG "Exporting toggles interface" exportTogglesInterface +-- | Extend a 'TaffybarConfig' with a DBus toggle interface and persisted+-- per-monitor visibility state. handleDBusToggles :: TaffybarConfig -> TaffybarConfig handleDBusToggles config =- config { getBarConfigsParam =- toggleBarConfigGetter $ getBarConfigsParam config- , startupHook = startupHook config >> dbusTogglesStartupHook- }+ config+ { getBarConfigsParam =+ toggleBarConfigGetter $ getBarConfigsParam config,+ startupHook = startupHook config >> dbusTogglesStartupHook+ }
src/System/Taffybar/Example.hs view
@@ -1,6 +1,10 @@ {-# LANGUAGE DisambiguateRecordFields #-} {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Example -- Copyright : (c) Ivan A. Malison@@ -9,7 +13,8 @@ -- Maintainer : Ivan A. Malison -- Stability : unstable -- Portability : unportable------------------------------------------------------------------------------+--+-- Example taffybar configurations used as reference defaults. module System.Taffybar.Example where -- XXX: in an actual taffybar.hs configuration file, you will need the module@@ -19,69 +24,95 @@ -- > main = dyreTaffybar exampleTaffybarConfig import Data.Default (def)-import System.Taffybar.Context (TaffybarConfig(..))+import System.Taffybar.Context (TaffybarConfig (..)) import System.Taffybar.Hooks-import System.Taffybar.Information.CPU import System.Taffybar.Information.Memory import System.Taffybar.SimpleConfig import System.Taffybar.Widget import System.Taffybar.Widget.Generic.PollingGraph-import qualified System.Taffybar.Widget.Workspaces as Workspaces+import qualified System.Taffybar.Widget.Workspaces.Config as WorkspaceConfig+import qualified System.Taffybar.Widget.Workspaces.EWMH as Workspaces -transparent, yellow1, yellow2, green1, green2, taffyBlue- :: (Double, Double, Double, Double)+-- | Fully transparent RGBA color.+transparent :: (Double, Double, Double, Double) transparent = (0.0, 0.0, 0.0, 0.0)++-- | Accent yellow color (dark).+yellow1 :: (Double, Double, Double, Double) yellow1 = (0.9453125, 0.63671875, 0.2109375, 1.0)++-- | Accent yellow color (light).+yellow2 :: (Double, Double, Double, Double) yellow2 = (0.9921875, 0.796875, 0.32421875, 1.0)++-- | Accent green color.+green1 :: (Double, Double, Double, Double) green1 = (0, 1, 0, 1)++-- | Accent translucent magenta color.+green2 :: (Double, Double, Double, Double) green2 = (1, 0, 1, 0.5)++-- | Primary blue accent.+taffyBlue :: (Double, Double, Double, Double) taffyBlue = (0.129, 0.588, 0.953, 1) -myGraphConfig, netCfg, memCfg, cpuCfg :: GraphConfig+-- | Base graph configuration used by example graphs.+myGraphConfig :: GraphConfig myGraphConfig = def- { graphPadding = 0- , graphBorderWidth = 0- , graphWidth = 75- , graphBackgroundColor = transparent- }+ { graphPadding = 0,+ graphBorderWidth = 0,+ graphWidth = 75,+ graphBackgroundColor = transparent+ } -netCfg = myGraphConfig- { graphDataColors = [yellow1, yellow2]- , graphLabel = Just "net"- }+-- | Network graph configuration for the example bar.+netCfg :: GraphConfig+netCfg =+ myGraphConfig+ { graphDataColors = [yellow1, yellow2],+ graphLabel = Just "net"+ } -memCfg = myGraphConfig- { graphDataColors = [taffyBlue]- , graphLabel = Just "mem"- }+-- | Memory graph configuration for the example bar.+memCfg :: GraphConfig+memCfg =+ myGraphConfig+ { graphDataColors = [taffyBlue],+ graphLabel = Just "mem"+ } -cpuCfg = myGraphConfig- { graphDataColors = [green1, green2]- , graphLabel = Just "cpu"- }+-- | CPU graph configuration for the example bar.+cpuCfg :: GraphConfig+cpuCfg =+ myGraphConfig+ { graphDataColors = [green1, green2],+ graphLabel = Just "cpu"+ } +-- | Callback used by the example memory graph. memCallback :: IO [Double] memCallback = do mi <- parseMeminfo return [memoryUsedRatio mi] -cpuCallback :: IO [Double]-cpuCallback = do- (_, systemLoad, totalLoad) <- cpuLoad- return [totalLoad, systemLoad]-+-- | Example X11-oriented taffybar configuration. exampleTaffybarConfig :: TaffybarConfig exampleTaffybarConfig =- let myWorkspacesConfig :: WorkspacesConfig+ let myWorkspacesConfig :: Workspaces.WorkspacesConfig myWorkspacesConfig =- def- { Workspaces.minIcons = 1- , Workspaces.widgetGap = 0- , Workspaces.showWorkspaceFn = hideEmpty- }- workspaces = workspacesNew myWorkspacesConfig- cpu = pollingGraphNew cpuCfg 0.5 cpuCallback+ let cfg = def+ in cfg+ { Workspaces.workspacesConfig =+ (Workspaces.workspacesConfig cfg)+ { WorkspaceConfig.minIcons = 1,+ WorkspaceConfig.widgetGap = 0,+ WorkspaceConfig.showWorkspaceFn = Workspaces.hideEmpty+ }+ }+ workspaces = Workspaces.workspacesNew myWorkspacesConfig+ cpu = cpuMonitorNew cpuCfg 0.5 "cpu" mem = pollingGraphNew memCfg 1 memCallback net = networkGraphNew netCfg Nothing clock = textClockNewWith def@@ -90,36 +121,41 @@ -- See https://github.com/taffybar/gtk-sni-tray#statusnotifierwatcher -- for a better way to set up the sni tray tray = sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt- myConfig = def- { startWidgets =- workspaces : map (>>= buildContentsBox) [ layout, windowsW ]- , endWidgets = map (>>= buildContentsBox)- [ batteryIconNew- , clock- , tray- , cpu- , mem- , net- , mpris2New- ]- , barPosition = Top- , barPadding = 10- , barHeight = ExactSize 50- , widgetSpacing = 0- }- in withLogServer $ withToggleServer $ toTaffybarConfig myConfig+ myConfig =+ def+ { startWidgets =+ workspaces : map (>>= buildContentsBox) [layout, windowsW],+ endWidgets =+ map+ (>>= buildContentsBox)+ [ batteryIconNew,+ clock,+ tray,+ cpu,+ mem,+ net,+ mpris2New+ ],+ barPosition = Top,+ barPadding = 10,+ barHeight = ExactSize 50,+ widgetSpacing = 0+ }+ in withLogServer $ withToggleServer $ toTaffybarConfig myConfig +-- | Example minimal Wayland-oriented taffybar configuration. exampleWaylandTaffybarConfig :: TaffybarConfig exampleWaylandTaffybarConfig = let clock = textClockNewWith def- cpu = pollingGraphNew cpuCfg 1 cpuCallback- myConfig = def- { startWidgets = []- , endWidgets = [ clock, cpu ]- , barPosition = Top- , barHeight = ExactSize 28- , barPadding = 0- , widgetSpacing = 8- , monitorsAction = usePrimaryMonitor- }- in withLogServer $ withToggleServer $ toTaffybarConfig myConfig+ cpu = cpuMonitorNew cpuCfg 1 "cpu"+ myConfig =+ def+ { startWidgets = [],+ endWidgets = [clock, cpu],+ barPosition = Top,+ barHeight = ExactSize 28,+ barPadding = 0,+ widgetSpacing = 8,+ monitorsAction = usePrimaryMonitor+ }+ in withLogServer $ withToggleServer $ toTaffybarConfig myConfig
src/System/Taffybar/Hooks.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Hooks -- Copyright : (c) Ivan A. Malison@@ -10,51 +13,55 @@ -- -- This module provides various startup hooks that can be added to -- 'TaffybarConfig'.------------------------------------------------------------------------------- module System.Taffybar.Hooks- ( module System.Taffybar.DBus- , module System.Taffybar.Hooks- , module System.Taffybar.LogLevels- , ChromeTabImageData(..)- , getChromeTabImageDataChannel- , getChromeTabImageDataTable- , getX11WindowToChromeTabId- , refreshBatteriesOnPropChange- ) where+ ( module System.Taffybar.DBus,+ module System.Taffybar.Hooks,+ module System.Taffybar.LogLevels,+ ChromeTabImageData (..),+ getChromeTabImageDataChannel,+ getChromeTabImageDataTable,+ getX11WindowToChromeTabId,+ refreshBatteriesOnPropChange,+ )+where -import Control.Concurrent-import Control.Concurrent.STM.TChan-import Control.Monad-import Control.Monad.STM (atomically)-import Control.Monad.Trans.Class-import Control.Monad.Trans.Reader+import Control.Concurrent+import qualified Control.Concurrent.MVar as MV+import Control.Concurrent.STM.TChan+import Control.Monad+import Control.Monad.IO.Class (liftIO)+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Reader+import Data.List (isSuffixOf, nub) import qualified Data.MultiMap as MM-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.DBus-import System.Taffybar.Information.Battery-import System.Taffybar.Information.Chrome-import System.Taffybar.Information.Network-import System.Environment.XDG.DesktopEntry-import System.Taffybar.LogFormatter-import System.Taffybar.LogLevels-import System.Taffybar.Util+import System.Directory (doesDirectoryExist)+import System.Environment.XDG.DesktopEntry+import System.FSNotify (Event (eventIsDirectory, eventPath), EventIsDirectory (IsFile), startManager, watchDir)+import System.FilePath ((</>))+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.DBus+import System.Taffybar.Information.Battery+import System.Taffybar.Information.Chrome+import System.Taffybar.Information.Network+import System.Taffybar.LogFormatter+import System.Taffybar.LogLevels -- | The type of the channel that provides network information in taffybar.-newtype NetworkInfoChan =- NetworkInfoChan (TChan [(String, (Rational, Rational))])+newtype NetworkInfoChan+ = NetworkInfoChan (TChan [(String, (Rational, Rational))]) -- | Build a 'NetworkInfoChan' that refreshes at the provided interval.-buildNetworkInfoChan :: Double -> IO NetworkInfoChan+buildNetworkInfoChan :: Double -> TaffyIO NetworkInfoChan buildNetworkInfoChan interval = do- chan <- newBroadcastTChanIO- _ <- forkIO $ monitorNetworkInterfaces interval (void . atomically . writeTChan chan)+ chan <- liftIO newBroadcastTChanIO+ monitorNetworkInterfaces interval (void . atomically . writeTChan chan) return $ NetworkInfoChan chan -- | Get the 'NetworkInfoChan' from 'Context', creating it if it does not exist. getNetworkChan :: TaffyIO NetworkInfoChan-getNetworkChan = getStateDefault $ lift $ buildNetworkInfoChan 2.0+getNetworkChan = getStateDefault $ buildNetworkInfoChan 2.0 -- | Set the log formatter used in the taffybar process setTaffyLogFormatter :: String -> IO ()@@ -83,22 +90,77 @@ -- -- To use a custom path instead, call 'loadLogLevelsFromFile' directly. withLogLevels :: TaffybarConfig -> TaffybarConfig-withLogLevels = appendHook $- lift $ defaultLogLevelsPath >>= loadLogLevelsFromFile+withLogLevels =+ appendHook $+ lift $+ defaultLogLevelsPath >>= loadLogLevelsFromFile +newtype DesktopEntryCacheWatch+ = DesktopEntryCacheWatch (IO ())+ -- | Load the 'DesktopEntry' cache from 'Context' state. getDirectoryEntriesByClassName :: TaffyIO (MM.MultiMap String DesktopEntry) getDirectoryEntriesByClassName = getStateDefault readDirectoryEntriesDefault --- | Update the 'DesktopEntry' cache every 60 seconds.+-- | Keep the 'DesktopEntry' cache fresh by watching XDG application directories+-- for desktop entry file updates. updateDirectoryEntriesCache :: TaffyIO ()-updateDirectoryEntriesCache = ask >>= \ctx ->- void $ lift $ foreverWithDelay (60 :: Double) $ flip runReaderT ctx $- void $ putState readDirectoryEntriesDefault+updateDirectoryEntriesCache = do+ _ <- getStateDefault startDesktopEntryCacheWatch+ return () +startDesktopEntryCacheWatch :: TaffyIO DesktopEntryCacheWatch+startDesktopEntryCacheWatch = do+ ctx <- ask+ appDirs <- lift getDesktopEntryApplicationDirs+ updateSignal <- lift MV.newEmptyMVar+ mgr <- lift startManager+ stopActions <-+ lift $+ forM appDirs $+ \dir ->+ watchDir mgr dir isDesktopEntryEvent $+ const $+ void $+ MV.tryPutMVar updateSignal ()+ _ <-+ lift $+ forkIO $+ forever $ do+ _ <- MV.takeMVar updateSignal+ threadDelay 200000+ flushUpdates updateSignal+ void $ runReaderT refreshDirectoryEntriesCache ctx+ void refreshDirectoryEntriesCache+ return $+ DesktopEntryCacheWatch $+ sequence_ stopActions++refreshDirectoryEntriesCache :: TaffyIO (MM.MultiMap String DesktopEntry)+refreshDirectoryEntriesCache = do+ entries <- readDirectoryEntriesDefault+ setState entries++flushUpdates :: MV.MVar () -> IO ()+flushUpdates updateSignal = do+ next <- MV.tryTakeMVar updateSignal+ case next of+ Nothing -> return ()+ Just _ -> flushUpdates updateSignal++isDesktopEntryEvent :: Event -> Bool+isDesktopEntryEvent event =+ eventIsDirectory event == IsFile && ".desktop" `isSuffixOf` eventPath event++getDesktopEntryApplicationDirs :: IO [FilePath]+getDesktopEntryApplicationDirs = do+ xdgDataDirs <- getXDGDataDirs+ filterM doesDirectoryExist $ map (</> "applications") $ nub xdgDataDirs+ -- | Read 'DesktopEntry' values into a 'MM.Multimap', where they are indexed by -- the class name specified in the 'DesktopEntry'. readDirectoryEntriesDefault :: TaffyIO (MM.MultiMap String DesktopEntry)-readDirectoryEntriesDefault = lift $- indexDesktopEntriesByClassName <$> getDirectoryEntriesDefault+readDirectoryEntriesDefault =+ lift $+ indexDesktopEntriesByClassName <$> getDirectoryEntriesDefault
src/System/Taffybar/Hyprland.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Hyprland -- Copyright : (c) Ivan A. Malison@@ -13,46 +16,43 @@ -- Hyprland resources are stored directly on 'Context' (not in 'contextState') -- to avoid deadlocks from nested 'getStateDefault' usage during widget -- initialization.------------------------------------------------------------------------------- module System.Taffybar.Hyprland ( -- * Shared Client- getHyprlandClient- , getHyprlandClientWith+ getHyprlandClient,+ getHyprlandClientWith, -- * Hyprland Monad In TaffyIO- , HyprlandIO- , runHyprland+ HyprlandIO,+ runHyprland, -- * Shared Event Channel- , getHyprlandEventChan- , getHyprlandEventChanWith+ getHyprlandEventChan,+ getHyprlandEventChanWith, -- * Convenience Runners- , runHyprlandCommandRawT- , runHyprlandCommandJsonT- ) where+ runHyprlandCommandRawT,+ runHyprlandCommandJsonT,+ )+where import qualified Control.Concurrent.MVar as MV-import Control.Monad.IO.Class (liftIO)-import Data.Aeson (FromJSON)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Reader (ReaderT, asks)+import Data.Aeson (FromJSON) import qualified Data.ByteString as BS--import Control.Monad.Trans.Reader (ReaderT, asks)--import System.Taffybar.Context (Context(..), TaffyIO)-import System.Taffybar.Information.Hyprland- ( HyprlandClient- , HyprlandClientConfig- , HyprlandCommand- , HyprlandError- , HyprlandEventChan- , HyprlandT- , buildHyprlandEventChan- , defaultHyprlandClientConfig- , runHyprlandCommandJson- , runHyprlandCommandRaw- , runHyprlandT+import System.Taffybar.Context (Context (..), TaffyIO)+import System.Taffybar.Information.Hyprland+ ( HyprlandClient,+ HyprlandClientConfig,+ HyprlandCommand,+ HyprlandError,+ HyprlandEventChan,+ HyprlandT,+ buildHyprlandEventChan,+ defaultHyprlandClientConfig,+ runHyprlandCommandJson,+ runHyprlandCommandRaw,+ runHyprlandT, ) -- | Get a shared 'HyprlandClient' from the 'Context' state.@@ -75,6 +75,7 @@ -- applied. We therefore spell out the underlying 'ReaderT Context IO' here. type HyprlandIO a = HyprlandT (ReaderT Context IO) a +-- | Run a Hyprland action using the shared client from 'Context'. runHyprland :: HyprlandIO a -> TaffyIO a runHyprland action = getHyprlandClient >>= \client -> runHyprlandT client action @@ -100,10 +101,12 @@ c <- buildHyprlandEventChan client pure (Just c, c) +-- | Run a raw Hyprland command in 'TaffyIO'. runHyprlandCommandRawT :: HyprlandCommand -> TaffyIO (Either HyprlandError BS.ByteString) runHyprlandCommandRawT cmd = getHyprlandClient >>= \client -> liftIO $ runHyprlandCommandRaw client cmd -runHyprlandCommandJsonT :: FromJSON a => HyprlandCommand -> TaffyIO (Either HyprlandError a)+-- | Run a JSON-decoded Hyprland command in 'TaffyIO'.+runHyprlandCommandJsonT :: (FromJSON a) => HyprlandCommand -> TaffyIO (Either HyprlandError a) runHyprlandCommandJsonT cmd = getHyprlandClient >>= \client -> liftIO $ runHyprlandCommandJson client cmd
+ src/System/Taffybar/Information/ASUS.hs view
@@ -0,0 +1,341 @@+{-# LANGUAGE OverloadedStrings #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Information.ASUS+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- This module provides information about the current ASUS platform profile+-- and CPU state using the asusd DBus API (xyz.ljones.Asusd) and sysfs.+module System.Taffybar.Information.ASUS+ ( ASUSPlatformProfile (..),+ ASUSInfo (..),+ getASUSInfo,+ getASUSInfoFromClient,+ getASUSInfoChan,+ getASUSInfoState,+ cycleASUSProfile,+ setASUSProfile,+ asusProfileToString,+ asusProfileFromUInt,+ )+where++import Control.Concurrent.MVar+import Control.Concurrent.STM.TChan+import Control.Exception (SomeException, try)+import Control.Monad (forM, forever, void)+import Control.Monad.IO.Class+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Except+import Control.Monad.Trans.Reader+import DBus+import DBus.Client+import DBus.Internal.Types (Serial (..))+import qualified DBus.TH as DBus+import Data.Map (Map)+import qualified Data.Map as M+import Data.Maybe (catMaybes, fromMaybe, listToMaybe)+import Data.Text (Text)+import Data.Word (Word32)+import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)+import System.FilePath ((</>))+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay)+import System.Taffybar.Util (logPrintF, maybeToEither)+import Text.Read (readMaybe)++-- | ASUS platform profile modes.+data ASUSPlatformProfile = Quiet | Performance | Balanced+ deriving (Eq, Show, Ord, Enum, Bounded)++-- | Combined ASUS platform info with CPU state.+data ASUSInfo = ASUSInfo+ { asusProfile :: ASUSPlatformProfile,+ -- | Average CPU frequency across all cores+ asusCpuFreqGHz :: Double,+ -- | CPU package temperature in Celsius+ asusCpuTempC :: Double+ }+ deriving (Eq, Show)++-- DBus constants++asusBusName :: BusName+asusBusName = "xyz.ljones.Asusd"++asusObjectPath :: ObjectPath+asusObjectPath = "/xyz/ljones"++asusInterfaceName :: InterfaceName+asusInterfaceName = "xyz.ljones.Platform"++asusLogPath :: String+asusLogPath = "System.Taffybar.Information.ASUS"++asusLogF :: (MonadIO m, Show t) => Priority -> String -> t -> m ()+asusLogF = logPrintF asusLogPath++-- | Convert profile enum to string.+asusProfileToString :: ASUSPlatformProfile -> Text+asusProfileToString Quiet = "Quiet"+asusProfileToString Balanced = "Balanced"+asusProfileToString Performance = "Performance"++-- | Parse profile from asusd uint32: 0=Quiet, 1=Performance, 2=Balanced.+asusProfileFromUInt :: Word32 -> Maybe ASUSPlatformProfile+asusProfileFromUInt 0 = Just Quiet+asusProfileFromUInt 1 = Just Performance+asusProfileFromUInt 2 = Just Balanced+asusProfileFromUInt _ = Nothing++asusProfileToUInt :: ASUSPlatformProfile -> Word32+asusProfileToUInt Quiet = 0+asusProfileToUInt Performance = 1+asusProfileToUInt Balanced = 2++-- | Default info when asusd is unavailable.+unknownASUSInfo :: ASUSInfo+unknownASUSInfo =+ ASUSInfo+ { asusProfile = Balanced,+ asusCpuFreqGHz = 0,+ asusCpuTempC = 0+ }++-- XXX: Remove this once it is exposed in haskell-dbus+dummyMethodError :: MethodError+dummyMethodError = methodError (Serial 1) $ errorName_ "org.ClientTypeMismatch"++readDictMaybe :: (IsVariant a) => Map Text Variant -> Text -> Maybe a+readDictMaybe dict key = M.lookup key dict >>= fromVariant++getProperties ::+ Client ->+ IO (Either MethodError (Map Text Variant))+getProperties client = runExceptT $ do+ reply <-+ ExceptT $+ getAllProperties client $+ (methodCall asusObjectPath asusInterfaceName "FakeMethod")+ { methodCallDestination = Just asusBusName+ }+ ExceptT $+ return $+ maybeToEither dummyMethodError $+ listToMaybe (methodReturnBody reply) >>= fromVariant++-- | Read current platform profile from DBus.+readProfileFromClient :: Client -> IO ASUSPlatformProfile+readProfileFromClient 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)++-- | Set the ASUS platform profile via DBus property.+setASUSProfile :: Client -> ASUSPlatformProfile -> IO (Either MethodError ())+setASUSProfile client profile = do+ result <-+ setProperty+ client+ (methodCall asusObjectPath asusInterfaceName "PlatformProfile")+ { methodCallDestination = Just asusBusName+ }+ (toVariant (asusProfileToUInt profile))+ return $ case result of+ Left err -> Left err+ Right _ -> Right ()++-- | Cycle to the next profile by calling the NextPlatformProfile method.+cycleASUSProfile :: Client -> IO (Either MethodError ())+cycleASUSProfile client = do+ let mc =+ (methodCall asusObjectPath asusInterfaceName "NextPlatformProfile")+ { methodCallDestination = Just asusBusName+ }+ result <- call client mc+ return $ case result of+ 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+ if not exists+ then return 0+ 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+ where+ isCpuDir name =+ take 3 name == "cpu" && all (`elem` ("0123456789" :: String)) (drop 3 name)+ readFreqFile path = do+ exists' <- doesFileExist path+ if not exists'+ then return Nothing+ else do+ result <- try $ readFile 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 (== ' ')++-- sysfs CPU temperature reading++-- | Read CPU package temperature in Celsius from sysfs.+-- Prefers x86_pkg_temp zone, falls back to highest temperature.+readCpuTempC :: IO Double+readCpuTempC = do+ let thermalDir = "/sys/class/thermal"+ exists <- doesDirectoryExist thermalDir+ if not exists+ then return 0+ else do+ entries <- listDirectory thermalDir+ let zones = filter (\e -> take 12 e == "thermal_zone") entries+ readings <- forM zones $ \zone -> do+ let typePath = thermalDir </> zone </> "type"+ tempPath = thermalDir </> zone </> "temp"+ zoneType <- readFileSafe typePath+ tempVal <- readTempFile tempPath+ return $ case tempVal of+ Nothing -> Nothing+ Just t -> Just (fromMaybe zone zoneType, t)+ let validReadings = catMaybes readings+ pkgTemp = lookup "x86_pkg_temp" validReadings+ case pkgTemp of+ Just t -> return t+ Nothing ->+ if null validReadings+ then return 0+ else return $ maximum $ map snd validReadings+ where+ readFileSafe path = do+ exists' <- doesFileExist path+ if not exists'+ then return Nothing+ else do+ result <- try $ readFile path :: IO (Either SomeException String)+ case result of+ Left _ -> return Nothing+ Right s -> return $ Just $ strip s+ readTempFile path = do+ exists' <- doesFileExist path+ if not exists'+ then return Nothing+ else do+ result <- try $ readFile path :: IO (Either SomeException String)+ case result of+ Left _ -> return Nothing+ Right s -> case readMaybe (strip s) :: Maybe Integer of+ Nothing -> return Nothing+ Just milliDeg -> return $ Just (fromIntegral milliDeg / 1000.0)+ strip = dropWhile (== ' ') . reverse . dropWhile (== '\n') . reverse . dropWhile (== ' ')++-- | Get current ASUS info using the system DBus client from Context.+getASUSInfo :: TaffyIO ASUSInfo+getASUSInfo = asks systemDBusClient >>= liftIO . getASUSInfoFromClient++-- | Get current ASUS info from a DBus client.+getASUSInfoFromClient :: Client -> IO ASUSInfo+getASUSInfoFromClient client = do+ profile <- readProfileFromClient client+ freq <- readCpuFreqGHz+ temp <- readCpuTempC+ return+ ASUSInfo+ { asusProfile = profile,+ asusCpuFreqGHz = freq,+ asusCpuTempC = temp+ }++-- State management for monitoring++newtype ASUSInfoChanVar+ = ASUSInfoChanVar (TChan ASUSInfo, MVar ASUSInfo)++-- | Get the current ASUS info state.+getASUSInfoState :: TaffyIO ASUSInfo+getASUSInfoState = do+ ASUSInfoChanVar (_, theVar) <- getASUSInfoChanVar+ lift $ readMVar theVar++-- | Get a broadcast channel for ASUS info updates.+getASUSInfoChan :: TaffyIO (TChan ASUSInfo)+getASUSInfoChan = do+ ASUSInfoChanVar (chan, _) <- getASUSInfoChanVar+ return chan++getASUSInfoChanVar :: TaffyIO ASUSInfoChanVar+getASUSInfoChanVar =+ getStateDefault $ ASUSInfoChanVar <$> monitorASUSInfo++monitorASUSInfo :: TaffyIO (TChan ASUSInfo, MVar ASUSInfo)+monitorASUSInfo = do+ infoVar <- lift $ newMVar unknownASUSInfo+ chan <- liftIO newBroadcastTChanIO+ wakeupChan <- getWakeupChannelForDelay (2 :: Double)+ ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan+ taffyFork $ do+ ctx <- ask+ let updateInfo = updateASUSInfo chan infoVar+ signalCallback _ _ _ _ = runReaderT updateInfo ctx+ waitForNextPoll = void $ atomically $ readTChan ourWakeupChan+ _ <- registerForASUSPropertiesChanged signalCallback+ -- Do an initial update+ updateInfo+ -- Then poll every 2 seconds for CPU freq/temp changes+ lift $ forever $ do+ waitForNextPoll+ runReaderT updateInfo ctx+ return (chan, infoVar)++registerForASUSPropertiesChanged ::+ (Signal -> String -> Map String Variant -> [String] -> IO ()) ->+ ReaderT Context IO SignalHandler+registerForASUSPropertiesChanged signalHandler = do+ client <- asks systemDBusClient+ lift $+ DBus.registerForPropertiesChanged+ client+ matchAny+ { matchInterface = Just asusInterfaceName,+ matchPath = Just asusObjectPath+ }+ signalHandler++updateASUSInfo ::+ TChan ASUSInfo ->+ MVar ASUSInfo ->+ TaffyIO ()+updateASUSInfo chan var = do+ info <- getASUSInfo+ lift $ do+ void $ swapMVar var info+ atomically $ writeTChan chan info
src/System/Taffybar/Information/Backlight.hs view
@@ -5,6 +5,9 @@ {-# LANGUAGE TypeApplications #-} -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Backlight -- Copyright : (c) Ivan A. Malison@@ -15,41 +18,40 @@ -- Portability : unportable -- -- Simple helpers to read backlight status from /sys/class/backlight.---------------------------------------------------------------------------------- module System.Taffybar.Information.Backlight- ( BacklightInfo(..)- , getBacklightDevices- , getBacklightInfo- , getBacklightInfoChan- , getBacklightInfoChanWithInterval- , getBacklightInfoState- ) where+ ( BacklightInfo (..),+ getBacklightDevices,+ getBacklightInfo,+ getBacklightInfoChan,+ getBacklightInfoChanWithInterval,+ getBacklightInfoState,+ )+where -import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent (forkIO) import Control.Concurrent.MVar import Control.Concurrent.STM.TChan import Control.Exception (SomeException, bracket, catch, try)-import Control.Monad (forever)+import Control.Monad (forever, void) import Control.Monad.IO.Class (liftIO) import Control.Monad.STM (atomically) import Data.List (sort, sortBy) import Data.Maybe (fromMaybe, listToMaybe)-import Data.Ord (Down(..), comparing)-import Data.Proxy (Proxy(..))+import Data.Ord (Down (..), comparing)+import Data.Proxy (Proxy (..)) import GHC.Conc (threadWaitRead)-import GHC.TypeLits (KnownSymbol, SomeSymbol(..), Symbol, someSymbolVal)+import GHC.TypeLits (KnownSymbol, SomeSymbol (..), Symbol, someSymbolVal) import System.Directory (doesDirectoryExist, listDirectory) import System.FilePath ((</>))-import System.Log.Logger (Priority(..))+import System.Log.Logger (Priority (..)) import System.Taffybar.Context (TaffyIO, getStateDefault) import System.Taffybar.Information.Udev- ( backlightMonitorFd- , closeBacklightMonitor- , drainBacklightMonitor- , openBacklightMonitor+ ( backlightMonitorFd,+ closeBacklightMonitor,+ drainBacklightMonitor,+ openBacklightMonitor, )+import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay) import System.Taffybar.Util (logPrintF) import System.Timeout (timeout) import Text.Read (readMaybe)@@ -62,11 +64,12 @@ -- | Information about a backlight device. data BacklightInfo = BacklightInfo- { backlightDevice :: FilePath- , backlightBrightness :: Int- , backlightMaxBrightness :: Int- , backlightPercent :: Int- } deriving (Eq, Show)+ { backlightDevice :: FilePath,+ backlightBrightness :: Int,+ backlightMaxBrightness :: Int,+ backlightPercent :: Int+ }+ deriving (Eq, Show) -- | List all backlight device names under /sys/class/backlight. getBacklightDevices :: IO [FilePath]@@ -89,7 +92,7 @@ backlightLogPath :: String backlightLogPath = "System.Taffybar.Information.Backlight" -backlightLogF :: Show t => Priority -> String -> t -> IO ()+backlightLogF :: (Show t) => Priority -> String -> t -> IO () backlightLogF = logPrintF backlightLogPath newtype BacklightInfoChanVar (a :: Symbol)@@ -123,65 +126,67 @@ case someSymbolVal (fromMaybe "__default__" deviceOverride) of SomeSymbol (Proxy :: Proxy sym) -> getBacklightInfoStateFor @sym deviceOverride -getBacklightInfoChanFor- :: forall (a :: Symbol)- . KnownSymbol a- => Maybe FilePath- -> Double- -> TaffyIO (TChan (Maybe BacklightInfo))+getBacklightInfoChanFor ::+ forall (a :: Symbol).+ (KnownSymbol a) =>+ Maybe FilePath ->+ Double ->+ TaffyIO (TChan (Maybe BacklightInfo)) getBacklightInfoChanFor deviceOverride intervalSeconds = do BacklightInfoChanVar (chan, _) <- getBacklightInfoChanVarFor @a deviceOverride intervalSeconds pure chan -getBacklightInfoStateFor- :: forall (a :: Symbol)- . KnownSymbol a- => Maybe FilePath- -> TaffyIO (Maybe BacklightInfo)+getBacklightInfoStateFor ::+ forall (a :: Symbol).+ (KnownSymbol a) =>+ Maybe FilePath ->+ TaffyIO (Maybe BacklightInfo) getBacklightInfoStateFor deviceOverride = do BacklightInfoChanVar (_, var) <- getBacklightInfoChanVarFor @a deviceOverride defaultBacklightRefreshIntervalSeconds liftIO $ readMVar var -getBacklightInfoChanVarFor- :: forall (a :: Symbol)- . KnownSymbol a- => Maybe FilePath- -> Double- -> TaffyIO (BacklightInfoChanVar a)+getBacklightInfoChanVarFor ::+ forall (a :: Symbol).+ (KnownSymbol a) =>+ Maybe FilePath ->+ Double ->+ TaffyIO (BacklightInfoChanVar a) getBacklightInfoChanVarFor deviceOverride intervalSeconds = getStateDefault $ do+ wakeupChan <- getWakeupChannelForDelay (max 0.000001 intervalSeconds)+ ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan liftIO $ do chan <- newBroadcastTChanIO var <- newMVar Nothing- _ <- forkIO $ monitorBacklightInfo deviceOverride intervalSeconds chan var+ _ <- forkIO $ monitorBacklightInfo deviceOverride intervalSeconds ourWakeupChan chan var pure $ BacklightInfoChanVar (chan, var) monitorBacklightInfo :: Maybe FilePath -> Double ->+ TChan a -> TChan (Maybe BacklightInfo) -> MVar (Maybe BacklightInfo) -> IO ()-monitorBacklightInfo deviceOverride intervalSeconds chan var = do- let- intervalMicros :: Int- intervalMicros = max 1 (floor (intervalSeconds * 1000000))+monitorBacklightInfo deviceOverride intervalSeconds wakeupChan chan var = do+ let intervalMicros :: Int+ intervalMicros = max 1 (floor (intervalSeconds * 1000000)) - writeInfo info = do- _ <- swapMVar var info- atomically $ writeTChan chan info+ writeInfo info = do+ _ <- swapMVar var info+ atomically $ writeTChan chan info - refresh = getBacklightInfo deviceOverride >>= writeInfo+ refresh = getBacklightInfo deviceOverride >>= writeInfo - pollingFallback e = do- -- Polling is a last resort, but we prefer it to a stuck widget.- backlightLogF WARNING "Backlight udev monitor unavailable, falling back to polling: %s" e- refresh- forever $ do- threadDelay intervalMicros+ pollingFallback e = do+ -- Polling is a last resort, but we prefer it to a stuck widget.+ backlightLogF WARNING "Backlight udev monitor unavailable, falling back to polling: %s" e refresh+ forever $ do+ void $ atomically $ readTChan wakeupChan+ refresh monitorResult <- try openBacklightMonitor case monitorResult of@@ -219,12 +224,14 @@ case (brightness, maxBrightness) of (Just current, Just maxVal) | maxVal > 0 -> do let percent = round $ (fromIntegral current * 100 :: Double) / fromIntegral maxVal- return $ Just BacklightInfo- { backlightDevice = device- , backlightBrightness = current- , backlightMaxBrightness = maxVal- , backlightPercent = percent- }+ return $+ Just+ BacklightInfo+ { backlightDevice = device,+ backlightBrightness = current,+ backlightMaxBrightness = maxVal,+ backlightPercent = percent+ } _ -> return Nothing readMaxBrightness :: FilePath -> IO (Maybe Int)@@ -233,9 +240,11 @@ readIntFile :: FilePath -> IO (Maybe Int) readIntFile path =- catch (do- contents <- readFile path- case words contents of- (val:_) -> return $ readMaybe val- _ -> return Nothing- ) $ \(_ :: SomeException) -> return Nothing+ catch+ ( do+ contents <- readFile path+ case words contents of+ (val : _) -> return $ readMaybe val+ _ -> return Nothing+ )+ $ \(_ :: SomeException) -> return Nothing
src/System/Taffybar/Information/Battery.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Battery -- Copyright : (c) Ivan A. Malison@@ -12,52 +16,55 @@ -- This module provides functions for querying battery information using the -- UPower dbus, as well as a broadcast "TChan" system for allowing multiple -- readers to receive 'BatteryState' updates without duplicating requests.------------------------------------------------------------------------------ module System.Taffybar.Information.Battery- ( BatteryInfo(..)- , BatteryState(..)- , BatteryTechnology(..)- , BatteryType(..)- , module System.Taffybar.Information.Battery- ) where+ ( BatteryInfo (..),+ BatteryState (..),+ BatteryTechnology (..),+ BatteryType (..),+ module System.Taffybar.Information.Battery,+ )+where -import Control.Concurrent-import Control.Concurrent.STM.TChan-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.STM (atomically)-import Control.Monad.Trans.Class-import Control.Monad.Trans.Except-import Control.Monad.Trans.Reader-import DBus-import DBus.Client-import DBus.Internal.Types (Serial(..))+import Control.Concurrent+import Control.Concurrent.STM.TChan+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Except+import Control.Monad.Trans.Reader+import DBus+import DBus.Client+import DBus.Internal.Types (Serial (..)) import qualified DBus.TH as DBus-import Data.Int-import Data.List-import Data.Map ( Map )+import Data.Int+import Data.List+import Data.Map (Map) import qualified Data.Map as M-import Data.Maybe-import Data.Text ( Text )-import Data.Word-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.DBus.Client.Params-import System.Taffybar.DBus.Client.UPower-import System.Taffybar.DBus.Client.UPowerDevice-import System.Taffybar.Util+import Data.Maybe+import Data.Text (Text)+import Data.Word+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.DBus.Client.Params+import System.Taffybar.DBus.Client.UPower+import System.Taffybar.DBus.Client.UPowerDevice+import System.Taffybar.Util +-- | Logger namespace for battery modules. batteryLogPath :: String batteryLogPath = "System.Taffybar.Information.Battery" -batteryLog- :: MonadIO m- => Priority -> String -> m ()+-- | Log a message to the battery logger.+batteryLog ::+ (MonadIO m) =>+ Priority -> String -> m () batteryLog priority = liftIO . logM batteryLogPath priority -batteryLogF- :: (MonadIO m, Show t)- => Priority -> String -> t -> m ()+-- | Formatted logging helper for the battery logger.+batteryLogF ::+ (MonadIO m, Show t) =>+ Priority -> String -> t -> m () batteryLogF = logPrintF batteryLogPath -- | The DBus path prefix where UPower enumerates its objects.@@ -85,19 +92,21 @@ readDictIntegral dict key dflt = fromMaybe (fromIntegral dflt) $ do v <- M.lookup key dict case variantType v of- TypeWord8 -> return $ fromIntegral (f v :: Word8)- TypeWord16 -> return $ fromIntegral (f v :: Word16)- TypeWord32 -> return $ fromIntegral (f v :: Word32)- TypeWord64 -> return $ fromIntegral (f v :: Word64)- TypeInt16 -> return $ fromIntegral (f v :: Int16)- TypeInt32 -> return $ fromIntegral (f v :: Int32)- TypeInt64 -> return $ fromIntegral (f v :: Int64)- _ -> Nothing+ TypeWord8 -> return $ fromIntegral (f v :: Word8)+ TypeWord16 -> return $ fromIntegral (f v :: Word16)+ TypeWord32 -> return $ fromIntegral (f v :: Word32)+ TypeWord64 -> return $ fromIntegral (f v :: Word64)+ TypeInt16 -> return $ fromIntegral (f v :: Int16)+ TypeInt32 -> return $ fromIntegral (f v :: Int32)+ TypeInt64 -> return $ fromIntegral (f v :: Int64)+ _ -> Nothing where f :: (Num a, IsVariant a) => Variant -> a f = fromMaybe (fromIntegral dflt) . fromVariant -- XXX: Remove this once it is exposed in haskell-dbus++-- | Placeholder 'MethodError' used when required reply payload is missing. dummyMethodError :: MethodError dummyMethodError = methodError (Serial 1) $ errorName_ "org.ClientTypeMismatch" @@ -105,49 +114,58 @@ -- If some fields are not actually present, they may have bogus values -- here. Don't bet anything critical on it. getBatteryInfo :: ObjectPath -> TaffyIO (Either MethodError BatteryInfo)-getBatteryInfo battPath = asks systemDBusClient >>= \client -> lift $ runExceptT $ do- reply <- ExceptT $ getAllProperties client $- (methodCall battPath uPowerDeviceInterfaceName "FakeMethod")- { methodCallDestination = Just uPowerBusName }- dict <- ExceptT $ return $ maybeToEither dummyMethodError $- listToMaybe (methodReturnBody reply) >>= fromVariant- return $ infoMapToBatteryInfo dict+getBatteryInfo battPath =+ asks systemDBusClient >>= \client -> lift $ runExceptT $ do+ reply <-+ ExceptT $+ getAllProperties client $+ (methodCall battPath uPowerDeviceInterfaceName "FakeMethod")+ { methodCallDestination = Just uPowerBusName+ }+ dict <-+ ExceptT $+ return $+ maybeToEither dummyMethodError $+ listToMaybe (methodReturnBody reply) >>= fromVariant+ return $ infoMapToBatteryInfo dict +-- | Decode a UPower property map into a 'BatteryInfo' record. infoMapToBatteryInfo :: Map Text Variant -> BatteryInfo infoMapToBatteryInfo dict =- BatteryInfo- { batteryNativePath = readDict dict "NativePath" ""- , batteryVendor = readDict dict "Vendor" ""- , batteryModel = readDict dict "Model" ""- , batterySerial = readDict dict "Serial" ""- , batteryType = toEnum $ fromIntegral $ readDictIntegral dict "Type" 0- , batteryPowerSupply = readDict dict "PowerSupply" False- , batteryHasHistory = readDict dict "HasHistory" False- , batteryHasStatistics = readDict dict "HasStatistics" False- , batteryOnline = readDict dict "Online" False- , batteryEnergy = readDict dict "Energy" 0.0- , batteryEnergyEmpty = readDict dict "EnergyEmpty" 0.0- , batteryEnergyFull = readDict dict "EnergyFull" 0.0- , batteryEnergyFullDesign = readDict dict "EnergyFullDesign" 0.0- , batteryEnergyRate = readDict dict "EnergyRate" 0.0- , batteryVoltage = readDict dict "Voltage" 0.0- , batteryTimeToEmpty = readDict dict "TimeToEmpty" 0- , batteryTimeToFull = readDict dict "TimeToFull" 0- , batteryPercentage = readDict dict "Percentage" 0.0- , batteryIsPresent = readDict dict "IsPresent" False- , batteryState = toEnum $ readDictIntegral dict "State" 0- , batteryIsRechargeable = readDict dict "IsRechargable" True- , batteryCapacity = readDict dict "Capacity" 0.0- , batteryTechnology =- toEnum $ fromIntegral $ readDictIntegral dict "Technology" 0- , batteryUpdateTime = readDict dict "UpdateTime" 0- , batteryLuminosity = readDict dict "Luminosity" 0.0- , batteryTemperature = readDict dict "Temperature" 0.0- , batteryWarningLevel = readDict dict "WarningLevel" 0- , batteryBatteryLevel = readDict dict "BatteryLevel" 0- , batteryIconName = readDict dict "IconName" ""- }+ BatteryInfo+ { batteryNativePath = readDict dict "NativePath" "",+ batteryVendor = readDict dict "Vendor" "",+ batteryModel = readDict dict "Model" "",+ batterySerial = readDict dict "Serial" "",+ batteryType = toEnum $ fromIntegral $ readDictIntegral dict "Type" 0,+ batteryPowerSupply = readDict dict "PowerSupply" False,+ batteryHasHistory = readDict dict "HasHistory" False,+ batteryHasStatistics = readDict dict "HasStatistics" False,+ batteryOnline = readDict dict "Online" False,+ batteryEnergy = readDict dict "Energy" 0.0,+ batteryEnergyEmpty = readDict dict "EnergyEmpty" 0.0,+ batteryEnergyFull = readDict dict "EnergyFull" 0.0,+ batteryEnergyFullDesign = readDict dict "EnergyFullDesign" 0.0,+ batteryEnergyRate = readDict dict "EnergyRate" 0.0,+ batteryVoltage = readDict dict "Voltage" 0.0,+ batteryTimeToEmpty = readDict dict "TimeToEmpty" 0,+ batteryTimeToFull = readDict dict "TimeToFull" 0,+ batteryPercentage = readDict dict "Percentage" 0.0,+ batteryIsPresent = readDict dict "IsPresent" False,+ batteryState = toEnum $ readDictIntegral dict "State" 0,+ batteryIsRechargeable = readDict dict "IsRechargable" True,+ batteryCapacity = readDict dict "Capacity" 0.0,+ batteryTechnology =+ toEnum $ fromIntegral $ readDictIntegral dict "Technology" 0,+ batteryUpdateTime = readDict dict "UpdateTime" 0,+ batteryLuminosity = readDict dict "Luminosity" 0.0,+ batteryTemperature = readDict dict "Temperature" 0.0,+ batteryWarningLevel = readDict dict "WarningLevel" 0,+ batteryBatteryLevel = readDict dict "BatteryLevel" 0,+ batteryIconName = readDict dict "IconName" ""+ } +-- | Enumerate UPower object paths that represent batteries. getBatteryPaths :: TaffyIO (Either MethodError [ObjectPath]) getBatteryPaths = do client <- asks systemDBusClient@@ -155,65 +173,80 @@ paths <- ExceptT $ enumerateDevices client return $ filter isBattery paths -newtype DisplayBatteryChanVar =- DisplayBatteryChanVar (TChan BatteryInfo, MVar BatteryInfo)+-- | Shared display-battery update channel and latest-value cache.+newtype DisplayBatteryChanVar+ = DisplayBatteryChanVar (TChan BatteryInfo, MVar BatteryInfo) +-- | Read the latest display battery snapshot. getDisplayBatteryInfo :: TaffyIO BatteryInfo getDisplayBatteryInfo = do DisplayBatteryChanVar (_, theVar) <- getDisplayBatteryChanVar lift $ readMVar theVar +-- | Default set of UPower properties that trigger display-battery refreshes. defaultMonitorDisplayBatteryProperties :: [String]-defaultMonitorDisplayBatteryProperties = [ "IconName", "State", "Percentage" ]+defaultMonitorDisplayBatteryProperties = ["IconName", "State", "Percentage"] -- | Start the monitoring of the display battery, and setup the associated -- channel and mvar for the current state. setupDisplayBatteryChanVar :: [String] -> TaffyIO DisplayBatteryChanVar-setupDisplayBatteryChanVar properties = getStateDefault $- DisplayBatteryChanVar <$> monitorDisplayBattery properties+setupDisplayBatteryChanVar properties =+ getStateDefault $+ DisplayBatteryChanVar <$> monitorDisplayBattery properties +-- | Get (or initialize) the shared display-battery channel+state holder. getDisplayBatteryChanVar :: TaffyIO DisplayBatteryChanVar getDisplayBatteryChanVar = setupDisplayBatteryChanVar defaultMonitorDisplayBatteryProperties +-- | Get the broadcast channel for display-battery updates. getDisplayBatteryChan :: TaffyIO (TChan BatteryInfo) getDisplayBatteryChan = do DisplayBatteryChanVar (chan, _) <- getDisplayBatteryChanVar return chan -updateBatteryInfo- :: TChan BatteryInfo- -> MVar BatteryInfo- -> ObjectPath- -> TaffyIO ()+-- | Refresh battery info for a path and publish it to the shared state.+updateBatteryInfo ::+ TChan BatteryInfo ->+ MVar BatteryInfo ->+ ObjectPath ->+ TaffyIO () updateBatteryInfo chan var path = getBatteryInfo path >>= lift . either warnOfFailure doWrites where doWrites info =- batteryLogF DEBUG "Writing info %s" info >>- swapMVar var info >> void (atomically $ writeTChan chan info)+ batteryLogF DEBUG "Writing info %s" info+ >> swapMVar var info+ >> void (atomically $ writeTChan chan info) warnOfFailure = batteryLogF WARNING "Failed to update battery info %s" -registerForAnyUPowerPropertiesChanged- :: (Signal -> String -> Map String Variant -> [String] -> IO ())- -> ReaderT Context IO SignalHandler+-- | Register for any UPower device property changes.+registerForAnyUPowerPropertiesChanged ::+ (Signal -> String -> Map String Variant -> [String] -> IO ()) ->+ ReaderT Context IO SignalHandler registerForAnyUPowerPropertiesChanged = registerForUPowerPropertyChanges [] -registerForUPowerPropertyChanges- :: [String]- -> (Signal -> String -> Map String Variant -> [String] -> IO ())- -> ReaderT Context IO SignalHandler+-- | Register for UPower device property changes, optionally filtering by+-- property names.+registerForUPowerPropertyChanges ::+ [String] ->+ (Signal -> String -> Map String Variant -> [String] -> IO ()) ->+ ReaderT Context IO SignalHandler registerForUPowerPropertyChanges properties signalHandler = do client <- asks systemDBusClient- lift $ DBus.registerForPropertiesChanged+ lift $+ DBus.registerForPropertiesChanged client- matchAny { matchInterface = Just uPowerDeviceInterfaceName- , matchPathNamespace = Just uPowerDevicesPath }+ matchAny+ { matchInterface = Just uPowerDeviceInterfaceName,+ matchPathNamespace = Just uPowerDevicesPath+ } handleIfPropertyMatches- where handleIfPropertyMatches rawSignal n propertiesMap l =- let propertyPresent prop = M.member prop propertiesMap- in when (any propertyPresent properties || null properties) $- signalHandler rawSignal n propertiesMap l+ where+ handleIfPropertyMatches rawSignal n propertiesMap l =+ let propertyPresent prop = M.member prop propertiesMap+ in when (any propertyPresent properties || null properties) $+ signalHandler rawSignal n propertiesMap l -- | Monitor the DisplayDevice for changes, writing a new "BatteryInfo" object -- to returned "MVar" and "Chan" objects@@ -227,10 +260,12 @@ taffyFork $ do ctx <- ask let warnOfFailedGetDevice err =- batteryLogF WARNING "Failure getting DisplayBattery: %s" err >>- return "/org/freedesktop/UPower/devices/DisplayDevice"- displayPath <- lift $ getDisplayDevice client >>=- either warnOfFailedGetDevice return+ batteryLogF WARNING "Failure getting DisplayBattery: %s" err+ >> return "/org/freedesktop/UPower/devices/DisplayDevice"+ displayPath <-+ lift $+ getDisplayDevice client+ >>= either warnOfFailedGetDevice return let doUpdate = updateBatteryInfo chan infoVar displayPath signalCallback _ _ changedProps _ = do@@ -246,13 +281,16 @@ -- something is updated and the update actually being visible. See -- https://github.com/taffybar/taffybar/issues/330 for more details. refreshBatteriesOnPropChange :: TaffyIO ()-refreshBatteriesOnPropChange = ask >>= \ctx ->- let updateIfRealChange _ _ changedProps _ =- flip runReaderT ctx $- when (any ((`notElem` ["UpdateTime", "Voltage"]) . fst) $- M.toList changedProps) $- lift (threadDelay 1000000) >> refreshAllBatteries- in void $ registerForAnyUPowerPropertiesChanged updateIfRealChange+refreshBatteriesOnPropChange =+ ask >>= \ctx ->+ let updateIfRealChange _ _ changedProps _ =+ flip runReaderT ctx+ $ when+ ( any ((`notElem` ["UpdateTime", "Voltage"]) . fst) $+ M.toList changedProps+ )+ $ lift (threadDelay 1000000) >> refreshAllBatteries+ in void $ registerForAnyUPowerPropertiesChanged updateIfRealChange -- | Request a refresh of all UPower batteries. This is only needed if UPower's -- refresh mechanism is not working properly.@@ -266,8 +304,9 @@ -- NB. The Refresh() method is only available if the UPower daemon -- was started in debug mode. So ignore any errors about the method -- not being implemented.- let logRefreshError e = unless (methodErrorName e == errorUnknownMethod) $- batteryLogF ERROR "Failed to refresh battery: %s" e+ let logRefreshError e =+ unless (methodErrorName e == errorUnknownMethod) $+ batteryLogF ERROR "Failed to refresh battery: %s" e logGetPathsError = batteryLogF ERROR "Failed to get battery paths %s" void $ either logGetPathsError (mapM_ $ either logRefreshError return) eerror
src/System/Taffybar/Information/Bluetooth.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Bluetooth -- Copyright : (c) Ivan A. Malison@@ -17,28 +21,31 @@ -- The module uses the DBus ObjectManager interface to dynamically discover -- Bluetooth controllers and devices, and monitors property changes for -- real-time updates.------------------------------------------------------------------------------ module System.Taffybar.Information.Bluetooth ( -- * Data Types- BluetoothInfo(..)- , BluetoothDevice(..)- , BluetoothController(..)- , BluetoothStatus(..)+ BluetoothInfo (..),+ BluetoothDevice (..),+ BluetoothController (..),+ BluetoothStatus (..),+ -- * Information Access- , getBluetoothInfo- , getBluetoothInfoChan- , getBluetoothInfoState+ getBluetoothInfo,+ getBluetoothInfoChan,+ getBluetoothInfoState,+ -- * Connection- , connectBluez- ) where+ connectBluez,+ )+where -import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent (forkIO) import Control.Concurrent.MVar import Control.Concurrent.STM.TChan import Control.Exception (SomeException, finally, try)-import Control.Monad (forever)+import Control.Monad (forever, void) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.STM (atomically)+import Control.Monad.Trans.Reader (asks) import DBus import DBus.Client import Data.List (sortOn)@@ -48,43 +55,46 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Word (Word8)-import System.Log.Logger (Priority(..))+import System.Log.Logger (Priority (..)) import System.Taffybar.Context (TaffyIO, getStateDefault, systemDBusClient)+import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay) import System.Taffybar.Util (logPrintF)-import Control.Monad.Trans.Reader (asks) -- | Information about a Bluetooth device. data BluetoothDevice = BluetoothDevice- { devicePath :: ObjectPath- , deviceName :: String- , deviceAlias :: String- , deviceAddress :: String- , deviceIcon :: Maybe String- , deviceConnected :: Bool- , devicePaired :: Bool- , deviceTrusted :: Bool- , deviceBlocked :: Bool- , deviceBatteryPercentage :: Maybe Word8- } deriving (Eq, Show)+ { devicePath :: ObjectPath,+ deviceName :: String,+ deviceAlias :: String,+ deviceAddress :: String,+ deviceIcon :: Maybe String,+ deviceConnected :: Bool,+ devicePaired :: Bool,+ deviceTrusted :: Bool,+ deviceBlocked :: Bool,+ deviceBatteryPercentage :: Maybe Word8+ }+ deriving (Eq, Show) -- | Information about a Bluetooth controller (adapter). data BluetoothController = BluetoothController- { controllerPath :: ObjectPath- , controllerAlias :: String- , controllerAddress :: String- , controllerPowered :: Bool- , controllerDiscoverable :: Bool- , controllerDiscovering :: Bool- , controllerPairable :: Bool- } deriving (Eq, Show)+ { controllerPath :: ObjectPath,+ controllerAlias :: String,+ controllerAddress :: String,+ controllerPowered :: Bool,+ controllerDiscoverable :: Bool,+ controllerDiscovering :: Bool,+ controllerPairable :: Bool+ }+ deriving (Eq, Show) -- | Complete Bluetooth state information. data BluetoothInfo = BluetoothInfo- { bluetoothController :: Maybe BluetoothController- , bluetoothConnectedDevices :: [BluetoothDevice]- , bluetoothAllDevices :: [BluetoothDevice]- , bluetoothStatus :: BluetoothStatus- } deriving (Eq, Show)+ { bluetoothController :: Maybe BluetoothController,+ bluetoothConnectedDevices :: [BluetoothDevice],+ bluetoothAllDevices :: [BluetoothDevice],+ bluetoothStatus :: BluetoothStatus+ }+ deriving (Eq, Show) -- | High-level Bluetooth status. data BluetoothStatus@@ -123,8 +133,8 @@ battery1InterfaceName = "org.bluez.Battery1" -- | Newtype wrapper for the channel/mvar pair to enable getStateDefault.-newtype BluetoothInfoChanVar =- BluetoothInfoChanVar (TChan BluetoothInfo, MVar BluetoothInfo)+newtype BluetoothInfoChanVar+ = BluetoothInfoChanVar (TChan BluetoothInfo, MVar BluetoothInfo) -- | Get a broadcast channel for Bluetooth info updates. --@@ -146,27 +156,34 @@ getBluetoothInfoChanVar = getStateDefault $ do client <- asks systemDBusClient+ retryWakeupChan <- getWakeupChannelForDelay (5 :: Double)+ blockWakeupChan <- getWakeupChannelForDelay (1000 :: Double)+ retryOurWakeupChan <- liftIO $ atomically $ dupTChan retryWakeupChan+ blockOurWakeupChan <- liftIO $ atomically $ dupTChan blockWakeupChan liftIO $ do chan <- newBroadcastTChanIO var <- newMVar defaultBluetoothInfo- _ <- forkIO $ monitorBluetoothInfo client chan var+ _ <- forkIO $ monitorBluetoothInfo client retryOurWakeupChan blockOurWakeupChan chan var pure $ BluetoothInfoChanVar (chan, var) defaultBluetoothInfo :: BluetoothInfo-defaultBluetoothInfo = BluetoothInfo- { bluetoothController = Nothing- , bluetoothConnectedDevices = []- , bluetoothAllDevices = []- , bluetoothStatus = BluetoothNoController- }+defaultBluetoothInfo =+ BluetoothInfo+ { bluetoothController = Nothing,+ bluetoothConnectedDevices = [],+ bluetoothAllDevices = [],+ bluetoothStatus = BluetoothNoController+ } -- | Monitor Bluetooth information changes. monitorBluetoothInfo :: Client ->+ TChan a ->+ TChan b -> TChan BluetoothInfo -> MVar BluetoothInfo -> IO ()-monitorBluetoothInfo client chan var = do+monitorBluetoothInfo client retryWakeupChan blockWakeupChan chan var = do refreshLock <- newMVar () let writeInfo info = do _ <- swapMVar var info@@ -184,26 +201,29 @@ -- Match rule for BlueZ property changes propertiesChangedMatcher :: MatchRule- propertiesChangedMatcher = matchAny- { matchSender = Just bluezBusName- , matchInterface = Just propertiesInterfaceName- , matchMember = Just "PropertiesChanged"- }+ propertiesChangedMatcher =+ matchAny+ { matchSender = Just bluezBusName,+ matchInterface = Just propertiesInterfaceName,+ matchMember = Just "PropertiesChanged"+ } -- Match rule for ObjectManager signals interfacesAddedMatcher :: MatchRule- interfacesAddedMatcher = matchAny- { matchSender = Just bluezBusName- , matchInterface = Just objectManagerInterfaceName- , matchMember = Just "InterfacesAdded"- }+ interfacesAddedMatcher =+ matchAny+ { matchSender = Just bluezBusName,+ matchInterface = Just objectManagerInterfaceName,+ matchMember = Just "InterfacesAdded"+ } interfacesRemovedMatcher :: MatchRule- interfacesRemovedMatcher = matchAny- { matchSender = Just bluezBusName- , matchInterface = Just objectManagerInterfaceName- , matchMember = Just "InterfacesRemoved"- }+ interfacesRemovedMatcher =+ matchAny+ { matchSender = Just bluezBusName,+ matchInterface = Just objectManagerInterfaceName,+ matchMember = Just "InterfacesRemoved"+ } loop = do -- Initial refresh@@ -230,10 +250,10 @@ Right _ -> pure () -- Wait before retrying- threadDelay 5000000+ void $ atomically $ readTChan retryWakeupChan loop - blockForever = forever $ threadDelay 1000000000+ blockForever = forever $ void $ atomically $ readTChan blockWakeupChan loop @@ -256,12 +276,13 @@ | not (controllerPowered c) -> BluetoothOff | not (null connectedDevices) -> BluetoothConnected | otherwise -> BluetoothOn- return BluetoothInfo- { bluetoothController = controller- , bluetoothConnectedDevices = connectedDevices- , bluetoothAllDevices = devices- , bluetoothStatus = status- }+ return+ BluetoothInfo+ { bluetoothController = controller,+ bluetoothConnectedDevices = connectedDevices,+ bluetoothAllDevices = devices,+ bluetoothStatus = status+ } -- | Get Bluetooth info using the system DBus client. getBluetoothInfo :: Client -> IO BluetoothInfo@@ -278,14 +299,18 @@ -- | Get all managed objects from BlueZ ObjectManager. getManagedObjects :: Client -> IO (Either MethodError (Map ObjectPath (Map Text (Map Text Variant)))) getManagedObjects client = do- let callMsg = (methodCall bluezRootPath objectManagerInterfaceName "GetManagedObjects")- { methodCallDestination = Just bluezBusName }+ let callMsg =+ (methodCall bluezRootPath objectManagerInterfaceName "GetManagedObjects")+ { methodCallDestination = Just bluezBusName+ } reply <- call client callMsg return $ case reply of Left err -> Left err Right ret -> case listToMaybe (methodReturnBody ret) >>= fromVariant of- Nothing -> Left $ methodError (methodReturnSerial ret) $- errorName_ "org.taffybar.InvalidResponse"+ Nothing ->+ Left $+ methodError (methodReturnSerial ret) $+ errorName_ "org.taffybar.InvalidResponse" Just objects -> Right objects -- | Parse controllers from managed objects.@@ -296,17 +321,18 @@ parseController :: (ObjectPath, Map Text (Map Text Variant)) -> Maybe BluetoothController parseController (path, interfaces) = do props <- M.lookup (T.pack $ formatInterfaceName adapter1InterfaceName) interfaces- let readProp :: IsVariant a => Text -> a -> a+ let readProp :: (IsVariant a) => Text -> a -> a readProp key def = fromMaybe def $ M.lookup key props >>= fromVariant- return BluetoothController- { controllerPath = path- , controllerAlias = readProp "Alias" ""- , controllerAddress = readProp "Address" ""- , controllerPowered = readProp "Powered" False- , controllerDiscoverable = readProp "Discoverable" False- , controllerDiscovering = readProp "Discovering" False- , controllerPairable = readProp "Pairable" False- }+ return+ BluetoothController+ { controllerPath = path,+ controllerAlias = readProp "Alias" "",+ controllerAddress = readProp "Address" "",+ controllerPowered = readProp "Powered" False,+ controllerDiscoverable = readProp "Discoverable" False,+ controllerDiscovering = readProp "Discovering" False,+ controllerPairable = readProp "Pairable" False+ } -- | Parse devices from managed objects. parseDevices :: Map ObjectPath (Map Text (Map Text Variant)) -> [BluetoothDevice]@@ -316,19 +342,20 @@ parseDevice :: (ObjectPath, Map Text (Map Text Variant)) -> Maybe BluetoothDevice parseDevice (path, interfaces) = do props <- M.lookup (T.pack $ formatInterfaceName device1InterfaceName) interfaces- let readProp :: IsVariant a => Text -> a -> a+ let readProp :: (IsVariant a) => Text -> a -> a readProp key def = fromMaybe def $ M.lookup key props >>= fromVariant batteryProps = M.lookup (T.pack $ formatInterfaceName battery1InterfaceName) interfaces batteryPct = batteryProps >>= M.lookup "Percentage" >>= fromVariant- return BluetoothDevice- { devicePath = path- , deviceName = readProp "Name" ""- , deviceAlias = readProp "Alias" ""- , deviceAddress = readProp "Address" ""- , deviceIcon = M.lookup "Icon" props >>= fromVariant- , deviceConnected = readProp "Connected" False- , devicePaired = readProp "Paired" False- , deviceTrusted = readProp "Trusted" False- , deviceBlocked = readProp "Blocked" False- , deviceBatteryPercentage = batteryPct- }+ return+ BluetoothDevice+ { devicePath = path,+ deviceName = readProp "Name" "",+ deviceAlias = readProp "Alias" "",+ deviceAddress = readProp "Address" "",+ deviceIcon = M.lookup "Icon" props >>= fromVariant,+ deviceConnected = readProp "Connected" False,+ devicePaired = readProp "Paired" False,+ deviceTrusted = readProp "Trusted" False,+ deviceBlocked = readProp "Blocked" False,+ deviceBatteryPercentage = batteryPct+ }
src/System/Taffybar/Information/CPU.hs view
@@ -1,35 +1,18 @@-module System.Taffybar.Information.CPU ( cpuLoad ) where--import Control.Concurrent ( threadDelay )-import System.IO ( IOMode(ReadMode), openFile, hGetLine, hClose )+{-# LANGUAGE NamedFieldPuns #-} -procData :: IO [Double]-procData = do- h <- openFile "/proc/stat" ReadMode- firstLine <- hGetLine h- length firstLine `seq` return ()- hClose h- return (procParser firstLine)+-- | Read short-window CPU utilization samples from @/proc/stat@.+module System.Taffybar.Information.CPU+ {-# DEPRECATED "Legacy CPU module. Use System.Taffybar.Information.CPU2.getCPULoadChan (preferred) or sampleCPULoad/CPULoad." #-}+ (cpuLoad)+where -procParser :: String -> [Double]-procParser = map read . drop 1 . words+import System.Taffybar.Information.CPU2 (CPULoad (..), sampleCPULoad) -truncVal :: Double -> Double-truncVal v- | isNaN v || v < 0.0 = 0.0- | otherwise = v+{-# DEPRECATED cpuLoad "Legacy API. Use System.Taffybar.Information.CPU2.getCPULoadChan (preferred) or sampleCPULoad." #-} --- | Return a pair with (user time, system time, total time) (read--- from /proc/stat). The function waits for 50 ms between samples.+-- | Return a triple with (user time, system time, total time), sampled from+-- /proc/stat over 50ms. cpuLoad :: IO (Double, Double, Double) cpuLoad = do- a <- procData- threadDelay 50000- b <- procData- let dif = zipWith (-) b a- tot = sum dif- pct = map (/ tot) dif- user = sum $ take 2 pct- system = pct !! 2- t = user + system- return (truncVal user, truncVal system, truncVal t)+ CPULoad {cpuUserLoad, cpuSystemLoad, cpuTotalLoad} <- sampleCPULoad 0.05 "cpu"+ return (cpuUserLoad, cpuSystemLoad, cpuTotalLoad)
src/System/Taffybar/Information/CPU2.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE TupleSections #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.CPU2 -- Copyright : (c) José A. Romero L.@@ -14,12 +18,13 @@ -- "System.Taffybar.Information.StreamInfo" module. -- And also provides information about the temperature of cores. -- (Now supports only physical cpu).---------------------------------------------------------------------------------- module System.Taffybar.Information.CPU2 where +import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.STM.TChan import Control.Monad+import Control.Monad.STM (atomically)+import Data.IORef import Data.List import Data.Maybe import Safe@@ -27,14 +32,25 @@ import System.FilePath import System.Taffybar.Information.StreamInfo +-- | Relative CPU load values, expressed as ratios in [0,1].+data CPULoad = CPULoad+ { cpuUserLoad :: Double,+ cpuSystemLoad :: Double,+ cpuTotalLoad :: Double+ }+ deriving (Eq, Show)+ -- | Returns a list of 5 to 7 elements containing all the values available for -- the given core (or all of them aggregated, if "cpu" is passed).+{-# DEPRECATED getCPUInfo "Legacy low-level API. Use getCPULoadChan (preferred) or sampleCPULoad." #-} getCPUInfo :: String -> IO [Int] getCPUInfo = getParsedInfo "/proc/stat" parse +-- | Parse @/proc/stat@ contents into CPU-name/sample tuples. parse :: String -> [(String, [Int])] parse = mapMaybe (tuplize . words) . filter (\x -> take 3 x == "cpu") . lines +-- | Convert tokenized @/proc/stat@ lines into a typed CPU sample entry. tuplize :: [String] -> Maybe (String, [Int]) tuplize s = do cpu <- s `atMay` 0@@ -43,31 +59,79 @@ -- | Returns a two-element list containing relative system and user times -- calculated using two almost simultaneous samples of the @\/proc\/stat@ file -- for the given core (or all of them aggregated, if \"cpu\" is passed).+{-# DEPRECATED getCPULoad "Legacy polling API. Use getCPULoadChan (preferred) or sampleCPULoad." #-} getCPULoad :: String -> IO [Double] getCPULoad cpu = do load <- getLoad 0.05 $ getCPUInfo cpu case load of- l0:l1:l2:_ -> return [ l0 + l1, l2 ]+ l0 : l1 : l2 : _ -> return [l0 + l1, l2] _ -> return [] +-- | Sample CPU usage for a given core over the provided interval (seconds).+sampleCPULoad :: Double -> String -> IO CPULoad+sampleCPULoad interval cpu = toCPULoad <$> getLoad interval (getCPUInfo cpu)++-- | Build a broadcast channel that is fed by a polling thread.+--+-- Each channel has its own polling thread; if multiple widgets should share a+-- data source, create once and reuse the returned channel.+getCPULoadChan :: String -> Double -> IO (TChan CPULoad)+getCPULoadChan cpu interval = do+ chan <- newBroadcastTChanIO+ initial <- getCPUInfo cpu+ sample <- newIORef initial+ let delayMicroseconds = max 1 (floor $ interval * 1000000)+ _ <- forkIO $ forever $ do+ load <- toCPULoad <$> getAccLoad sample (getCPUInfo cpu)+ atomically $ writeTChan chan load+ threadDelay delayMicroseconds+ return chan++toCPULoad :: [Double] -> CPULoad+toCPULoad load =+ case load of+ l0 : l1 : l2 : _ ->+ CPULoad+ { cpuUserLoad = l0 + l1,+ cpuSystemLoad = l2,+ cpuTotalLoad = l0 + l1 + l2+ }+ _ ->+ CPULoad+ { cpuUserLoad = 0,+ cpuSystemLoad = 0,+ cpuTotalLoad = 0+ }+ -- | Get the directory in which core temperature files are kept. getCPUTemperatureDirectory :: IO FilePath getCPUTemperatureDirectory =- (baseDir </>) . fromMaybe "hwmon0" .- find (isPrefixOf "hwmon")- <$> listDirectory baseDir- where baseDir =- "/" </> "sys" </> "bus" </> "platform" </>- "devices" </> "coretemp.0" </> "hwmon"+ (baseDir </>)+ . fromMaybe "hwmon0"+ . find (isPrefixOf "hwmon")+ <$> listDirectory baseDir+ where+ baseDir =+ "/"+ </> "sys"+ </> "bus"+ </> "platform"+ </> "devices"+ </> "coretemp.0"+ </> "hwmon" +-- | Read one core-temperature file from sysfs and convert milli-degrees to+-- degrees Celsius. readCPUTempFile :: FilePath -> IO Double readCPUTempFile cpuTempFilePath = (/ 1000) . read <$> readFile cpuTempFilePath +-- | List core-temperature input files in a hwmon directory. getAllTemperatureFiles :: FilePath -> IO [FilePath] getAllTemperatureFiles temperaturesDirectory =- filter (liftM2 (&&) (isPrefixOf "temp") (isSuffixOf "input")) <$>- listDirectory temperaturesDirectory+ filter (liftM2 (&&) (isPrefixOf "temp") (isSuffixOf "input"))+ <$> listDirectory temperaturesDirectory +-- | Read all available CPU core temperatures from sysfs. getCPUTemperatures :: IO [(String, Double)] getCPUTemperatures = do dir <- getCPUTemperatureDirectory
src/System/Taffybar/Information/Chrome.hs view
@@ -1,93 +1,107 @@ {-# LANGUAGE OverloadedStrings #-}++-- | Track Chrome tab favicon updates and map them to X11 windows. module System.Taffybar.Information.Chrome where -import Control.Concurrent-import Control.Concurrent.STM.TChan-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.STM (atomically)-import Control.Monad.Trans.Class+import Control.Concurrent+import Control.Concurrent.STM.TChan+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Class import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy as LBS import qualified Data.Map as M-import Data.Maybe+import Data.Maybe import qualified GI.GLib as Gdk import qualified GI.GdkPixbuf as Gdk-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.Information.EWMHDesktopInfo-import System.Taffybar.Information.SafeX11-import Text.Read hiding (lift)-import Text.Regex-import Web.Scotty+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Information.EWMHDesktopInfo+import System.Taffybar.Information.SafeX11+import Text.Read hiding (lift)+import Text.Regex+import Web.Scotty +-- | Module logger. logIO :: System.Log.Logger.Priority -> String -> IO () logIO = logM "System.Taffybar.Information.Chrome" +-- | Favicon image data associated with a Chrome tab id. data ChromeTabImageData = ChromeTabImageData- { tabImageData :: Gdk.Pixbuf- , tabImageDataId :: Int+ { tabImageData :: Gdk.Pixbuf,+ tabImageDataId :: Int } -newtype ChromeTabImageDataState =- ChromeTabImageDataState- (MVar (M.Map Int ChromeTabImageData), TChan ChromeTabImageData)+-- | Shared favicon table and broadcast update channel.+newtype ChromeTabImageDataState+ = ChromeTabImageDataState+ (MVar (M.Map Int ChromeTabImageData), TChan ChromeTabImageData) +-- | Get or initialize Chrome favicon state. getChromeTabImageDataState :: TaffyIO ChromeTabImageDataState getChromeTabImageDataState = do ChromeFaviconServerPort port <- fromMaybe (ChromeFaviconServerPort 5000) <$> getState getStateDefault (listenForChromeFaviconUpdates port) +-- | Get the broadcast channel for favicon updates. getChromeTabImageDataChannel :: TaffyIO (TChan ChromeTabImageData) getChromeTabImageDataChannel = do ChromeTabImageDataState (_, chan) <- getChromeTabImageDataState return chan +-- | Get the mutable favicon table keyed by tab id. getChromeTabImageDataTable :: TaffyIO (MVar (M.Map Int ChromeTabImageData)) getChromeTabImageDataTable = do ChromeTabImageDataState (table, _) <- getChromeTabImageDataState return table +-- | TCP port used by the local Chrome favicon update server. newtype ChromeFaviconServerPort = ChromeFaviconServerPort Int +-- | Start an HTTP listener that receives favicon updates from browser helpers. listenForChromeFaviconUpdates :: Int -> TaffyIO ChromeTabImageDataState listenForChromeFaviconUpdates port = do infoVar <- lift $ newMVar M.empty inChan <- liftIO newBroadcastTChanIO outChan <- liftIO . atomically $ dupTChan inChan- _ <- lift $ forkIO $ scotty port $- post "/setTabImageData/:tabID" $ do- tabID <- queryParam "tabID"- imageData <- LBS.toStrict <$> body- when (BS.length imageData > 0) $ lift $ do- loader <- Gdk.pixbufLoaderNew- Gdk.pixbufLoaderWriteBytes loader =<< Gdk.bytesNew (Just imageData)- Gdk.pixbufLoaderClose loader- let updateChannelAndMVar pixbuf =- let chromeTabImageData =- ChromeTabImageData- { tabImageData = pixbuf- , tabImageDataId = tabID- }- in- modifyMVar_ infoVar $ \currentMap ->- do- _ <- atomically $ writeTChan inChan chromeTabImageData- return $ M.insert tabID chromeTabImageData currentMap- Gdk.pixbufLoaderGetPixbuf loader >>= maybe (return ()) updateChannelAndMVar+ _ <- lift $+ forkIO $+ scotty port $+ post "/setTabImageData/:tabID" $ do+ tabID <- queryParam "tabID"+ imageData <- LBS.toStrict <$> body+ when (BS.length imageData > 0) $ lift $ do+ loader <- Gdk.pixbufLoaderNew+ Gdk.pixbufLoaderWriteBytes loader =<< Gdk.bytesNew (Just imageData)+ Gdk.pixbufLoaderClose loader+ let updateChannelAndMVar pixbuf =+ let chromeTabImageData =+ ChromeTabImageData+ { tabImageData = pixbuf,+ tabImageDataId = tabID+ }+ in modifyMVar_ infoVar $ \currentMap ->+ do+ _ <- atomically $ writeTChan inChan chromeTabImageData+ return $ M.insert tabID chromeTabImageData currentMap+ Gdk.pixbufLoaderGetPixbuf loader >>= maybe (return ()) updateChannelAndMVar return $ ChromeTabImageDataState (infoVar, outChan) +-- | Mapping from X11 window ids to parsed Chrome tab ids. newtype X11WindowToChromeTabId = X11WindowToChromeTabId (MVar (M.Map X11Window Int)) +-- | Get or initialize the X11-window to tab-id mapping. getX11WindowToChromeTabId :: TaffyIO X11WindowToChromeTabId getX11WindowToChromeTabId = getStateDefault $ X11WindowToChromeTabId <$> maintainX11WindowToChromeTabId +-- | Maintain and update the X11-window to tab-id mapping from title changes. maintainX11WindowToChromeTabId :: TaffyIO (MVar (M.Map X11Window Int)) maintainX11WindowToChromeTabId = do startTabMap <- updateTabMap M.empty tabMapVar <- lift $ newMVar startTabMap- let handleEvent PropertyEvent { ev_window = window } =+ let handleEvent PropertyEvent {ev_window = window} = do title <- runX11Def "" $ getWindowTitle window lift $ modifyMVar_ tabMapVar $ \currentMap -> do@@ -98,17 +112,21 @@ _ <- subscribeToPropertyEvents [ewmhWMName] handleEvent return tabMapVar +-- | Regex for tab-id markers embedded in window titles. tabIDRegex :: Regex tabIDRegex = mkRegexWithOpts "[|]%([0-9]*)%[|]" True True +-- | Extract a tab id from a window title, if present. getTabIdFromTitle :: String -> Maybe Int getTabIdFromTitle title = matchRegex tabIDRegex title >>= listToMaybe >>= readMaybe +-- | Insert or update a window->tab mapping based on a title string. addTabIdEntry :: M.Map X11Window Int -> (X11Window, String) -> M.Map X11Window Int addTabIdEntry theMap (win, title) =- maybe theMap ((flip $ M.insert win) theMap) $ getTabIdFromTitle title+ maybe theMap ((flip $ M.insert win) theMap) $ getTabIdFromTitle title +-- | Rebuild the window->tab map by scanning all current X11 windows. updateTabMap :: M.Map X11Window Int -> TaffyIO (M.Map X11Window Int) updateTabMap tabMap = runX11Def tabMap $ do
src/System/Taffybar/Information/Crypto.hs view
@@ -1,8 +1,12 @@-{-# LANGUAGE KindSignatures #-} {-# LANGUAGE DataKinds #-}-{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE KindSignatures #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Crypto -- Copyright : (c) Ivan A. Malison@@ -14,75 +18,96 @@ -- -- This module provides utility functions for retrieving data about crypto -- assets.------------------------------------------------------------------------------ module System.Taffybar.Information.Crypto where -import Control.Concurrent-import Control.Concurrent.STM.TChan-import Control.Exception.Enclosed (catchAny)-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.STM (atomically)-import Data.Aeson-import Data.Aeson.Types (parseMaybe)+import Control.Concurrent+import Control.Concurrent.STM.TChan+import Control.Exception.Enclosed (catchAny)+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.STM (atomically)+import Data.Aeson import qualified Data.Aeson.Key as Key+import Data.Aeson.Types (parseMaybe) import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.UTF8 as BS import qualified Data.Map as M-import Data.Maybe-import Data.Proxy-import Data.Text (Text)+import Data.Maybe+import Data.Proxy+import Data.Text (Text) import qualified Data.Text as T-import GHC.TypeLits-import Network.HTTP.Simple hiding (Proxy)-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.Util-import Text.Printf+import GHC.TypeLits+import Network.HTTP.Simple hiding (Proxy)+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Util+import Text.Printf -getSymbolToCoinGeckoId :: MonadIO m => m (M.Map Text Text)+-- | Build a map from lowercase symbol to CoinGecko coin identifier.+getSymbolToCoinGeckoId :: (MonadIO m) => m (M.Map Text Text) getSymbolToCoinGeckoId = do- let uri = "https://api.coingecko.com/api/v3/coins/list?include_platform=false"- request = parseRequest_ uri- bodyText <- liftIO $ catchAny (getResponseBody <$> httpLBS request) $ \e -> do- liftIO $ logM "System.Taffybar.Information.Crypto" WARNING $- printf "Error fetching coins list from coin gecko %s" $ show e- return ""- let coinInfos :: [CoinGeckoInfo]- coinInfos = fromMaybe [] $ decode bodyText+ let uri = "https://api.coingecko.com/api/v3/coins/list?include_platform=false"+ request = parseRequest_ uri+ bodyText <- liftIO $ catchAny (getResponseBody <$> httpLBS request) $ \e -> do+ liftIO $+ logM "System.Taffybar.Information.Crypto" WARNING $+ printf "Error fetching coins list from coin gecko %s" $+ show e+ return ""+ let coinInfos :: [CoinGeckoInfo]+ coinInfos = fromMaybe [] $ decode bodyText - return $ M.fromList $ map (\CoinGeckoInfo { identifier = theId, symbol = theSymbol } ->- (theSymbol, theId)) coinInfos+ return $+ M.fromList $+ map+ ( \CoinGeckoInfo {identifier = theId, symbol = theSymbol} ->+ (theSymbol, theId)+ )+ coinInfos +-- | Cached symbol-to-CoinGecko-id map. newtype SymbolToCoinGeckoId = SymbolToCoinGeckoId (M.Map Text Text) -newtype CryptoPriceInfo = CryptoPriceInfo { lastPrice :: Double }+-- | Last observed price value.+newtype CryptoPriceInfo = CryptoPriceInfo {lastPrice :: Double} -newtype CryptoPriceChannel (a :: Symbol) =- CryptoPriceChannel (TChan CryptoPriceInfo, MVar CryptoPriceInfo)+-- | Broadcast channel and latest-value cache for a typed symbol pair.+newtype CryptoPriceChannel (a :: Symbol)+ = CryptoPriceChannel (TChan CryptoPriceInfo, MVar CryptoPriceInfo) -getCryptoPriceChannel :: KnownSymbol a => TaffyIO (CryptoPriceChannel a)+-- | Get (or initialize) the shared price channel for a symbol pair type.+getCryptoPriceChannel :: (KnownSymbol a) => TaffyIO (CryptoPriceChannel a) getCryptoPriceChannel = do -- XXX: This is a gross hack that is needed to avoid deadlock symbolToId <- getStateDefault $ SymbolToCoinGeckoId <$> getSymbolToCoinGeckoId getStateDefault $ buildCryptoPriceChannel (60.0 :: Double) symbolToId -data CoinGeckoInfo =- CoinGeckoInfo { identifier :: Text, symbol :: Text }+-- | Minimal record returned by CoinGecko coin-list endpoints.+data CoinGeckoInfo+ = CoinGeckoInfo {identifier :: Text, symbol :: Text} deriving (Show) instance FromJSON CoinGeckoInfo where parseJSON = withObject "CoinGeckoInfo" (\v -> CoinGeckoInfo <$> v .: "id" <*> v .: "symbol") -logCrypto :: MonadIO m => Priority -> String -> m ()+-- | Log helper for crypto information code.+logCrypto :: (MonadIO m) => Priority -> String -> m () logCrypto p = liftIO . logM "System.Taffybar.Information.Crypto" p -resolveSymbolPair :: KnownSymbol a => Proxy a -> SymbolToCoinGeckoId -> Either String (Text, Text)+maxCryptoBackoffForDelay :: Double -> Double+maxCryptoBackoffForDelay delay = delay * 16++nextCryptoBackoff :: Double -> Double -> (Double, Double)+nextCryptoBackoff maxBackoff current =+ (min (current * 2) maxBackoff, current)++-- | Resolve a type-level symbol pair like @BTC-USD@ into a CoinGecko id and+-- quote currency.+resolveSymbolPair :: (KnownSymbol a) => Proxy a -> SymbolToCoinGeckoId -> Either String (Text, Text) resolveSymbolPair sym symbolToId = do (symbolName, inCurrency) <- parseSymbolPair (symbolVal sym) cgIdentifier <- lookupSymbolCoinGeckoId symbolToId symbolName pure (cgIdentifier, inCurrency)- where parseSymbolPair :: String -> Either String (Text, Text) parseSymbolPair symbolPair = case T.splitOn "-" (T.toLower $ T.pack symbolPair) of@@ -90,14 +115,17 @@ _ -> Left $ printf "Type parameter \"%s\" does not match the form \"ASSET-CURRENCY\"" symbolPair lookupSymbolCoinGeckoId :: SymbolToCoinGeckoId -> Text -> Either String Text- lookupSymbolCoinGeckoId (SymbolToCoinGeckoId m) symbolName = maybeToEither- (printf "Symbol \"%s\" not found in coin gecko list" (T.unpack symbolName))- (M.lookup symbolName m)+ lookupSymbolCoinGeckoId (SymbolToCoinGeckoId m) symbolName =+ maybeToEither+ (printf "Symbol \"%s\" not found in coin gecko list" (T.unpack symbolName))+ (M.lookup symbolName m) +-- | Create a background polling channel for a symbol pair with retry backoff. buildCryptoPriceChannel ::- forall a. KnownSymbol a => Double -> SymbolToCoinGeckoId -> TaffyIO (CryptoPriceChannel a)+ forall a. (KnownSymbol a) => Double -> SymbolToCoinGeckoId -> TaffyIO (CryptoPriceChannel a) buildCryptoPriceChannel delay symbolToId = do let initialBackoff = delay+ maxBackoff = maxCryptoBackoffForDelay delay chan <- liftIO newBroadcastTChanIO var <- liftIO $ newMVar $ CryptoPriceInfo 0.0 backoffVar <- liftIO $ newMVar initialBackoff@@ -111,27 +139,40 @@ case resolveSymbolPair (Proxy :: Proxy a) symbolToId of Left err -> logCrypto WARNING err Right (cgIdentifier, inCurrency) ->- void $ foreverWithVariableDelay $- catchAny (liftIO $ getLatestPrice cgIdentifier inCurrency >>=- maybe (return ()) (doWrites . CryptoPriceInfo) >> return delay) $ \e -> do- logCrypto WARNING $ printf "Error when fetching crypto price: %s" (show e)- modifyMVar backoffVar $ \current ->- return (min (current * 2) delay, current)+ void+ $ foreverWithVariableDelay+ $ catchAny+ ( liftIO $+ getLatestPrice cgIdentifier inCurrency+ >>= maybe (return ()) (doWrites . CryptoPriceInfo)+ >> return delay+ )+ $ \e -> do+ logCrypto WARNING $ printf "Error when fetching crypto price: %s" (show e)+ modifyMVar backoffVar $ \current ->+ return $ nextCryptoBackoff maxBackoff current return $ CryptoPriceChannel (chan, var) -getLatestPrice :: MonadIO m => Text -> Text -> m (Maybe Double)+-- | Fetch the latest price for one CoinGecko id in a target currency.+getLatestPrice :: (MonadIO m) => Text -> Text -> m (Maybe Double) getLatestPrice tokenId inCurrency = do- let uri = printf "https://api.coingecko.com/api/v3/simple/price?ids=%s&vs_currencies=%s"- tokenId inCurrency+ let uri =+ printf+ "https://api.coingecko.com/api/v3/simple/price?ids=%s&vs_currencies=%s"+ tokenId+ inCurrency request = parseRequest_ uri bodyText <- getResponseBody <$> httpLBS request return $ decode bodyText >>= parseMaybe ((.: Key.fromText tokenId) >=> (.: Key.fromText inCurrency)) -getCryptoMeta :: MonadIO m => String -> String -> m LBS.ByteString+-- | Fetch metadata for a symbol from CoinMarketCap's API.+getCryptoMeta :: (MonadIO m) => String -> String -> m LBS.ByteString getCryptoMeta cmcAPIKey symbolName = do let headers = [("X-CMC_PRO_API_KEY", BS.fromString cmcAPIKey)] :: RequestHeaders- uri = printf "https://pro-api.coinmarketcap.com/v1/cryptocurrency/info?symbol=%s"- symbolName+ uri =+ printf+ "https://pro-api.coinmarketcap.com/v1/cryptocurrency/info?symbol=%s"+ symbolName request = setRequestHeaders headers $ parseRequest_ uri getResponseBody <$> httpLBS request
src/System/Taffybar/Information/DiskIO.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.DiskIO -- Copyright : (c) José A. Romero L.@@ -11,13 +14,11 @@ -- Provides information about read/write operations in a given disk or -- partition, obtained from parsing the @\/proc\/diskstats@ file with some -- of the facilities included in the "System.Taffybar.Information.StreamInfo" module.--------------------------------------------------------------------------------module System.Taffybar.Information.DiskIO ( getDiskTransfer ) where+module System.Taffybar.Information.DiskIO (getDiskTransfer) where -import Data.Maybe ( mapMaybe )-import Safe ( atMay, headMay, readDef )-import System.Taffybar.Information.StreamInfo ( getParsedInfo, getTransfer )+import Data.Maybe (mapMaybe)+import Safe (atMay, headMay, readDef)+import System.Taffybar.Information.StreamInfo (getParsedInfo, getTransfer) -- | Returns a two-element list containing the speed of transfer for read and -- write operations performed in the given disk\/partition (e.g. \"sda\",@@ -39,4 +40,3 @@ used <- s `atMay` 3 capacity <- s `atMay` 7 return (device, [readDef (-1) used, readDef (-1) capacity])-
src/System/Taffybar/Information/DiskUsage.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.DiskUsage -- Copyright : (c) Ivan A. Malison@@ -19,71 +22,74 @@ -- monitored path is supported through the shared API. If you need to -- monitor several mount points independently, call 'getDiskUsageInfo' -- directly with 'pollingLabelNew'.------------------------------------------------------------------------------- module System.Taffybar.Information.DiskUsage- ( DiskUsageInfo(..)- , getDiskUsageInfo- , getDiskUsageInfoChan- , getDiskUsageInfoState- ) where+ ( DiskUsageInfo (..),+ getDiskUsageInfo,+ getDiskUsageInfoChan,+ getDiskUsageInfoState,+ )+where -import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.MVar import Control.Concurrent.STM.TChan import Control.Exception.Enclosed (catchAny)-import Control.Monad (forever, void)+import Control.Monad (void) import Control.Monad.IO.Class (liftIO) import Control.Monad.STM (atomically)-import System.DiskSpace (getDiskUsage, diskTotal, diskFree, diskAvail)-import System.Log.Logger (Priority(..))+import System.DiskSpace (diskAvail, diskFree, diskTotal, getDiskUsage)+import System.Log.Logger (Priority (..)) import System.Taffybar.Context (TaffyIO, getStateDefault)+import System.Taffybar.Information.Wakeup (taffyForeverWithDelay) import System.Taffybar.Util (logPrintF) -- | Disk usage statistics for a single filesystem. data DiskUsageInfo = DiskUsageInfo- { diskInfoTotal :: !Integer- -- ^ Total space in bytes.- , diskInfoFree :: !Integer- -- ^ Free space in bytes (includes reserved blocks).- , diskInfoAvailable :: !Integer- -- ^ Space available to unprivileged users, in bytes.- , diskInfoUsed :: !Integer- -- ^ Used space in bytes (@total - free@).- , diskInfoUsedPercent :: !Double- -- ^ Percentage of total space that is used.- , diskInfoFreePercent :: !Double- -- ^ Percentage of total space available to unprivileged users.- } deriving (Show, Eq)+ { -- | Total space in bytes.+ diskInfoTotal :: !Integer,+ -- | Free space in bytes (includes reserved blocks).+ diskInfoFree :: !Integer,+ -- | Space available to unprivileged users, in bytes.+ diskInfoAvailable :: !Integer,+ -- | Used space in bytes (@total - free@).+ diskInfoUsed :: !Integer,+ -- | Percentage of total space that is used.+ diskInfoUsedPercent :: !Double,+ -- | Percentage of total space available to unprivileged users.+ diskInfoFreePercent :: !Double+ }+ deriving (Show, Eq) -- | Query disk usage for the filesystem containing @path@ via @statvfs(2)@. getDiskUsageInfo :: FilePath -> IO DiskUsageInfo getDiskUsageInfo path = do du <- getDiskUsage path let total = diskTotal du- free = diskFree du+ free = diskFree du avail = diskAvail du- used = total - free- usedPct = if total > 0- then fromIntegral used * 100.0 / fromIntegral total- else 0- freePct = if total > 0- then fromIntegral avail * 100.0 / fromIntegral total- else 0- return DiskUsageInfo- { diskInfoTotal = total- , diskInfoFree = free- , diskInfoAvailable = avail- , diskInfoUsed = used- , diskInfoUsedPercent = usedPct- , diskInfoFreePercent = freePct- }+ used = total - free+ usedPct =+ if total > 0+ then fromIntegral used * 100.0 / fromIntegral total+ else 0+ freePct =+ if total > 0+ then fromIntegral avail * 100.0 / fromIntegral total+ else 0+ return+ DiskUsageInfo+ { diskInfoTotal = total,+ diskInfoFree = free,+ diskInfoAvailable = avail,+ diskInfoUsed = used,+ diskInfoUsedPercent = usedPct,+ diskInfoFreePercent = freePct+ } -- -------------------------------------------------------------------------- -- Shared polling channel -newtype DiskUsageChanVar =- DiskUsageChanVar (TChan DiskUsageInfo, MVar DiskUsageInfo)+newtype DiskUsageChanVar+ = DiskUsageChanVar (TChan DiskUsageInfo, MVar DiskUsageInfo) -- | Get a broadcast channel that is updated by a shared polling thread. -- The first call starts the poller; subsequent calls return the same channel.@@ -99,17 +105,20 @@ liftIO $ readMVar var setupDiskUsageChanVar :: Double -> FilePath -> TaffyIO DiskUsageChanVar-setupDiskUsageChanVar interval path = getStateDefault $ liftIO $ do- chan <- newBroadcastTChanIO- info <- getDiskUsageInfo path- var <- newMVar info- void $ forkIO $ forever $ do- threadDelay (floor $ interval * 1000000)- catchAny- (do newInfo <- getDiskUsageInfo path- void $ swapMVar var newInfo- atomically $ writeTChan chan newInfo)- (logPrintF logName WARNING "DiskUsage poll failed: %s")+setupDiskUsageChanVar interval path = getStateDefault $ do+ chan <- liftIO newBroadcastTChanIO+ info <- liftIO $ getDiskUsageInfo path+ var <- liftIO $ newMVar info+ void $+ taffyForeverWithDelay interval $+ liftIO $+ catchAny+ ( do+ newInfo <- getDiskUsageInfo path+ void $ swapMVar var newInfo+ atomically $ writeTChan chan newInfo+ )+ (logPrintF logName WARNING "DiskUsage poll failed: %s") pure $ DiskUsageChanVar (chan, var) logName :: String
src/System/Taffybar/Information/EWMHDesktopInfo.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.EWMHDesktopInfo -- Copyright : (c) José A. Romero L.@@ -16,50 +19,48 @@ -- > import XMonad.Hooks.EwmhDesktops (ewmh) -- > -- > main = xmonad $ ewmh $ ...---------------------------------------------------------------------------------- module System.Taffybar.Information.EWMHDesktopInfo- ( EWMHIcon(..)- , EWMHIconData- , WorkspaceId(..)- , X11Window- , allEWMHProperties- , ewmhActiveWindow- , ewmhClientList- , ewmhClientListStacking- , ewmhCurrentDesktop- , ewmhDesktopNames- , ewmhNumberOfDesktops- , ewmhStateHidden- , ewmhWMClass- , ewmhWMDesktop- , ewmhWMIcon- , ewmhWMName- , ewmhWMName2- , ewmhWMState- , ewmhWMStateHidden- , focusWindow- , getActiveWindow- , getCurrentWorkspace- , getVisibleWorkspaces- , getWindowClass- , getWindowIconsData- , getWindowMinimized- , getWindowState- , getWindowStateProperty- , getWindowTitle- , getWindows- , getWindowsStacking- , getWorkspace- , getWorkspaceNames- , isWindowUrgent- , parseWindowClasses- , switchOneWorkspace- , switchToWorkspace- , withX11Context- , withEWMHIcons- ) where+ ( EWMHIcon (..),+ EWMHIconData,+ WorkspaceId (..),+ X11Window,+ allEWMHProperties,+ ewmhActiveWindow,+ ewmhClientList,+ ewmhClientListStacking,+ ewmhCurrentDesktop,+ ewmhDesktopNames,+ ewmhNumberOfDesktops,+ ewmhStateHidden,+ ewmhWMClass,+ ewmhWMDesktop,+ ewmhWMIcon,+ ewmhWMName,+ ewmhWMName2,+ ewmhWMState,+ ewmhWMStateHidden,+ focusWindow,+ getActiveWindow,+ getCurrentWorkspace,+ getVisibleWorkspaces,+ getWindowClass,+ getWindowIconsData,+ getWindowMinimized,+ getWindowState,+ getWindowStateProperty,+ getWindowTitle,+ getWindows,+ getWindowsStacking,+ getWorkspace,+ getWorkspaceNames,+ isWindowUrgent,+ parseWindowClasses,+ switchOneWorkspace,+ switchToWorkspace,+ withX11Context,+ withEWMHIcons,+ )+where import Control.Monad ((>=>)) import Control.Monad.IO.Class@@ -77,9 +78,10 @@ import System.Taffybar.Information.SafeX11 import System.Taffybar.Information.X11DesktopInfo -logHere :: MonadIO m => Priority -> String -> m ()+logHere :: (MonadIO m) => Priority -> String -> m () logHere p = liftIO . logM "System.Taffybar.Information.EWMHDesktopInfo" p +-- | Workspace index (starting at 0). newtype WorkspaceId = WorkspaceId Int deriving (Show, Read, Ord, Eq) -- A super annoying detail of the XGetWindowProperty interface is that: "If the@@ -93,52 +95,98 @@ type EWMHProperty = String -ewmhActiveWindow, ewmhClientList, ewmhClientListStacking, ewmhCurrentDesktop, ewmhDesktopNames, ewmhNumberOfDesktops, ewmhStateHidden, ewmhWMDesktop, ewmhWMStateHidden, ewmhWMClass, ewmhWMState, ewmhWMIcon, ewmhWMName, ewmhWMName2 :: EWMHProperty+-- | EWMH property name for active window id.+ewmhActiveWindow :: EWMHProperty ewmhActiveWindow = "_NET_ACTIVE_WINDOW"++-- | EWMH property name for client list in mapping order.+ewmhClientList :: EWMHProperty ewmhClientList = "_NET_CLIENT_LIST"++-- | EWMH property name for client list in stacking order.+ewmhClientListStacking :: EWMHProperty ewmhClientListStacking = "_NET_CLIENT_LIST_STACKING"++-- | EWMH property name for current desktop index.+ewmhCurrentDesktop :: EWMHProperty ewmhCurrentDesktop = "_NET_CURRENT_DESKTOP"++-- | EWMH property name for desktop names.+ewmhDesktopNames :: EWMHProperty ewmhDesktopNames = "_NET_DESKTOP_NAMES"++-- | EWMH property name for number of desktops.+ewmhNumberOfDesktops :: EWMHProperty ewmhNumberOfDesktops = "_NET_NUMBER_OF_DESKTOPS"++-- | EWMH atom name for hidden window state.+ewmhStateHidden :: EWMHProperty ewmhStateHidden = "_NET_WM_STATE_HIDDEN"++-- | ICCCM property name for window class.+ewmhWMClass :: EWMHProperty ewmhWMClass = "WM_CLASS"++-- | EWMH property name for window desktop assignment.+ewmhWMDesktop :: EWMHProperty ewmhWMDesktop = "_NET_WM_DESKTOP"++-- | EWMH property name for window icon data.+ewmhWMIcon :: EWMHProperty ewmhWMIcon = "_NET_WM_ICON"++-- | EWMH property name for UTF-8 window title.+ewmhWMName :: EWMHProperty ewmhWMName = "_NET_WM_NAME"++-- | Fallback ICCCM property name for window title.+ewmhWMName2 :: EWMHProperty ewmhWMName2 = "WM_NAME"++-- | EWMH property name for window state list.+ewmhWMState :: EWMHProperty ewmhWMState = "_NET_WM_STATE"++-- | EWMH atom string for hidden window state entry.+ewmhWMStateHidden :: EWMHProperty ewmhWMStateHidden = "_NET_WM_STATE_HIDDEN" +-- | List of EWMH property names commonly observed by workspace/window widgets. allEWMHProperties :: [EWMHProperty] allEWMHProperties =- [ ewmhActiveWindow- , ewmhClientList- , ewmhClientListStacking- , ewmhCurrentDesktop- , ewmhDesktopNames- , ewmhNumberOfDesktops- , ewmhStateHidden- , ewmhWMClass- , ewmhWMDesktop- , ewmhWMIcon- , ewmhWMName- , ewmhWMName2- , ewmhWMState- , ewmhWMStateHidden+ [ ewmhActiveWindow,+ ewmhClientList,+ ewmhClientListStacking,+ ewmhCurrentDesktop,+ ewmhDesktopNames,+ ewmhNumberOfDesktops,+ ewmhStateHidden,+ ewmhWMClass,+ ewmhWMDesktop,+ ewmhWMIcon,+ ewmhWMName,+ ewmhWMName2,+ ewmhWMState,+ ewmhWMStateHidden ] +-- | Raw EWMH icon data buffer and element count. type EWMHIconData = (ForeignPtr PixelsWordType, Int) +-- | Parsed EWMH icon entry. data EWMHIcon = EWMHIcon- { ewmhWidth :: Int- , ewmhHeight :: Int- , ewmhPixelsARGB :: Ptr PixelsWordType- } deriving (Show, Eq)+ { ewmhWidth :: Int,+ ewmhHeight :: Int,+ ewmhPixelsARGB :: Ptr PixelsWordType+ }+ deriving (Show, Eq) +-- | Check whether a particular EWMH window-state atom is present. getWindowStateProperty :: String -> X11Window -> X11Property Bool getWindowStateProperty property window = not . null <$> getWindowState window [property] +-- | Read and decode the EWMH window state list, filtered to requested names. getWindowState :: X11Window -> [String] -> X11Property [String] getWindowState window request = do let getAsLong s = fromIntegral <$> getAtom s@@ -172,7 +220,8 @@ -- available. getWorkspaceNames :: X11Property [(WorkspaceId, String)] getWorkspaceNames = go <$> readAsListOfString Nothing ewmhDesktopNames- where go = zip [WorkspaceId i | i <- [0..]]+ where+ go = zip [WorkspaceId i | i <- [0 ..]] -- | Ask the window manager to switch to the workspace with the given -- index, starting from 0.@@ -190,13 +239,13 @@ -- | Check for corner case and switch one workspace up getPrev :: WorkspaceId -> Int -> WorkspaceId getPrev (WorkspaceId idx) end- | idx > 0 = WorkspaceId $ idx-1+ | idx > 0 = WorkspaceId $ idx - 1 | otherwise = WorkspaceId end -- | Check for corner case and switch one workspace down getNext :: WorkspaceId -> Int -> WorkspaceId getNext (WorkspaceId idx) end- | idx < end = WorkspaceId $ idx+1+ | idx < end = WorkspaceId $ idx + 1 | otherwise = WorkspaceId 0 -- | Get the title of the given X11 window.@@ -206,12 +255,13 @@ prop <- readAsString w ewmhWMName case prop of "" -> readAsString w ewmhWMName2- _ -> return prop+ _ -> return prop -- | Get the class of the given X11 window. getWindowClass :: X11Window -> X11Property String getWindowClass window = readAsString (Just window) ewmhWMClass +-- | Split a raw @WM_CLASS@ string into non-empty class components. parseWindowClasses :: String -> [String] parseWindowClasses = filter (not . null) . splitOn "\NUL" @@ -248,14 +298,14 @@ newArr = advancePtr pixelsPtr thisSize thisIcon = EWMHIcon- { ewmhWidth = iwidth- , ewmhHeight = iheight- , ewmhPixelsARGB = pixelsPtr- }+ { ewmhWidth = iwidth,+ ewmhHeight = iheight,+ ewmhPixelsARGB = pixelsPtr+ } getRes newSize | newSize < 0 =- logHere ERROR "Attempt to recurse on negative value in parseIcons"- >> return []+ logHere ERROR "Attempt to recurse on negative value in parseIcons"+ >> return [] | otherwise = (thisIcon :) <$> parseIcons newSize newArr getRes $ totalSize - fromIntegral (thisSize + 2)
src/System/Taffybar/Information/Hyprland.hs view
@@ -1,6 +1,9 @@ {-# LANGUAGE OverloadedStrings #-} -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Hyprland -- Copyright : (c) Ivan A. Malison@@ -16,93 +19,92 @@ -- -- This module is intended to centralize the socket/path logic so widgets can -- share it. It is not (yet) wired into widgets.------------------------------------------------------------------------------- module System.Taffybar.Information.Hyprland ( -- * Client- HyprlandClient- , HyprlandClientConfig(..)- , defaultHyprlandClientConfig- , newHyprlandClient- , reloadHyprlandClient- , HyprlandClientEnv(..)- , getHyprlandClientEnv+ HyprlandClient,+ HyprlandClientConfig (..),+ defaultHyprlandClientConfig,+ newHyprlandClient,+ reloadHyprlandClient,+ HyprlandClientEnv (..),+ getHyprlandClientEnv, -- * Hyprland Monad- , HyprlandT- , runHyprlandT- , askHyprlandClient- , runHyprlandCommandRawM- , runHyprlandCommandJsonM+ HyprlandT,+ runHyprlandT,+ askHyprlandClient,+ runHyprlandCommandRawM,+ runHyprlandCommandJsonM, -- * Shared Event Channel- , HyprlandEventChan(..)- , subscribeHyprlandEvents- , buildHyprlandEventChan+ HyprlandEventChan (..),+ subscribeHyprlandEvents,+ buildHyprlandEventChan, -- * Commands- , HyprlandCommand(..)- , hyprCommand- , hyprCommandJson- , hyprlandCommandToSocketCommand- , runHyprlandCommandRaw- , runHyprlandCommandJson+ HyprlandCommand (..),+ hyprCommand,+ hyprCommandJson,+ hyprlandCommandToSocketCommand,+ runHyprlandCommandRaw,+ runHyprlandCommandJson, -- * Sockets- , HyprlandSocket(..)- , hyprlandSocketName- , hyprlandSocketPaths- , openHyprlandSocket- , openHyprlandEventSocket- , withHyprlandEventSocket+ HyprlandSocket (..),+ hyprlandSocketName,+ hyprlandSocketPaths,+ openHyprlandSocket,+ openHyprlandEventSocket,+ withHyprlandEventSocket, -- * Monitor queries- , getFocusedMonitorPosition+ getFocusedMonitorPosition, -- * Errors- , HyprlandError(..)- ) where+ HyprlandError (..),+ )+where -import Control.Concurrent (forkIO, threadDelay)-import Control.Concurrent.STM.TChan- ( TChan- , dupTChan- , newBroadcastTChanIO- , writeTChan+import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.STM.TChan+ ( TChan,+ dupTChan,+ newBroadcastTChanIO,+ writeTChan, )-import Control.Exception.Enclosed (catchAny)-import Control.Monad (forM, forever, void)-import Control.Monad.IO.Class (MonadIO(..))-import Control.Monad.STM (atomically)-import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)-import Data.Aeson (FromJSON(..), eitherDecode', withObject, (.:))+import Control.Exception.Enclosed (catchAny)+import Control.Monad (forM, forever, void)+import Control.Monad.IO.Class (MonadIO (..))+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)+import Data.Aeson (FromJSON (..), eitherDecode', withObject, (.:)) import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BS8 import qualified Data.ByteString.Lazy as BL-import Data.List (sortOn)-import Data.Ord (Down(..))+import Data.List (sortOn)+import Data.Ord (Down (..)) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Network.Socket as NS import qualified Network.Socket.ByteString as NSB-import System.Directory (doesDirectoryExist, listDirectory)-import System.Environment (lookupEnv)-import System.FilePath ((</>), takeDirectory, takeFileName)-import System.IO- ( BufferMode(LineBuffering)- , Handle- , IOMode(ReadWriteMode)- , hClose- , hGetLine- , hSetBuffering+import System.Directory (doesDirectoryExist, listDirectory)+import System.Environment (lookupEnv)+import System.FilePath (takeDirectory, takeFileName, (</>))+import System.IO+ ( BufferMode (LineBuffering),+ Handle,+ IOMode (ReadWriteMode),+ hClose,+ hGetLine,+ hSetBuffering, )-import System.Log.Logger (Priority(..), logM)-import System.Posix.Files (getFileStatus, isSocket, modificationTime)-import System.Posix.Types (EpochTime)-import Text.Printf (printf)--import System.Taffybar.Util (runCommand)+import System.Log.Logger (Priority (..), logM)+import System.Posix.Files (getFileStatus, isSocket, modificationTime)+import System.Posix.Types (EpochTime)+import System.Taffybar.Util (runCommand)+import Text.Printf (printf) +-- | Errors that can occur while talking to Hyprland. data HyprlandError = HyprlandEnvMissing String | HyprlandSocketUnavailable HyprlandSocket [FilePath]@@ -112,27 +114,35 @@ | HyprlandCommandBuildFailed String deriving (Show, Eq) +-- | Configuration for creating and using a 'HyprlandClient'. data HyprlandClientConfig = HyprlandClientConfig- { useSocket :: Bool- -- ^ Try the Hyprland command/event sockets when possible.- , fallbackToHyprctl :: Bool- -- ^ If the socket path is unavailable, fall back to invoking @hyprctl@.- , hyprctlPath :: FilePath- } deriving (Show, Eq)+ { -- | Try the Hyprland command/event sockets when possible.+ useSocket :: Bool,+ -- | If the socket path is unavailable, fall back to invoking @hyprctl@.+ fallbackToHyprctl :: Bool,+ hyprctlPath :: FilePath+ }+ deriving (Show, Eq) +-- | Default 'HyprlandClientConfig'.+--+-- Uses sockets when available and falls back to @hyprctl@. defaultHyprlandClientConfig :: HyprlandClientConfig defaultHyprlandClientConfig = HyprlandClientConfig- { useSocket = True- , fallbackToHyprctl = True- , hyprctlPath = "hyprctl"+ { useSocket = True,+ fallbackToHyprctl = True,+ hyprctlPath = "hyprctl" } +-- | Environment-derived values used for socket path resolution. data HyprlandClientEnv = HyprlandClientEnv- { instanceSignature :: String- , runtimeDir :: Maybe FilePath- } deriving (Show, Eq)+ { instanceSignature :: String,+ runtimeDir :: Maybe FilePath+ }+ deriving (Show, Eq) +-- | Read Hyprland-specific values from the process environment. getHyprlandClientEnv :: IO (Either HyprlandError HyprlandClientEnv) getHyprlandClientEnv = do mSig <- lookupEnv "HYPRLAND_INSTANCE_SIGNATURE"@@ -141,15 +151,19 @@ return $ Left $ HyprlandEnvMissing "HYPRLAND_INSTANCE_SIGNATURE" Just sig -> do mRuntime <- lookupEnv "XDG_RUNTIME_DIR"- return $ Right $ HyprlandClientEnv- { instanceSignature = sig- , runtimeDir = mRuntime- }+ return $+ Right $+ HyprlandClientEnv+ { instanceSignature = sig,+ runtimeDir = mRuntime+ } +-- | Runtime client state used by command and event helpers. data HyprlandClient = HyprlandClient- { clientConfig :: HyprlandClientConfig- , clientEnv :: Maybe HyprlandClientEnv- } deriving (Show, Eq)+ { clientConfig :: HyprlandClientConfig,+ clientEnv :: Maybe HyprlandClientEnv+ }+ deriving (Show, Eq) -- | Construct a 'HyprlandClient' by reading environment variables. --@@ -160,7 +174,7 @@ newHyprlandClient cfg = do envResult <- getHyprlandClientEnv let env = either (const Nothing) Just envResult- pure $ HyprlandClient { clientConfig = cfg, clientEnv = env }+ pure $ HyprlandClient {clientConfig = cfg, clientEnv = env} -- | Reload the environment-derived portions of a 'HyprlandClient'. reloadHyprlandClient :: HyprlandClient -> IO HyprlandClient@@ -169,50 +183,59 @@ -- | A reader transformer that carries a 'HyprlandClient'. type HyprlandT m a = ReaderT HyprlandClient m a +-- | Run a 'HyprlandT' computation with the provided client. runHyprlandT :: HyprlandClient -> HyprlandT m a -> m a runHyprlandT = flip runReaderT -askHyprlandClient :: Monad m => HyprlandT m HyprlandClient+-- | Get the current 'HyprlandClient' from 'HyprlandT'.+askHyprlandClient :: (Monad m) => HyprlandT m HyprlandClient askHyprlandClient = ask +-- | Run a command in 'HyprlandT' and return the raw bytes response. runHyprlandCommandRawM :: (MonadIO m) => HyprlandCommand -> HyprlandT m (Either HyprlandError BS.ByteString) runHyprlandCommandRawM cmd = do client <- ask liftIO $ runHyprlandCommandRaw client cmd +-- | Run a command in 'HyprlandT' and decode the response as JSON. runHyprlandCommandJsonM :: (MonadIO m, FromJSON a) => HyprlandCommand -> HyprlandT m (Either HyprlandError a) runHyprlandCommandJsonM cmd = do client <- ask liftIO $ runHyprlandCommandJson client cmd +-- | Which Hyprland socket should be used for a request. data HyprlandSocket = HyprlandCommandSocket | HyprlandEventSocket deriving (Show, Eq) +-- | Socket file basename for a given 'HyprlandSocket' kind. hyprlandSocketName :: HyprlandSocket -> FilePath hyprlandSocketName HyprlandCommandSocket = ".socket.sock" hyprlandSocketName HyprlandEventSocket = ".socket2.sock" +-- | Candidate socket paths for a client and socket type. hyprlandSocketPaths :: HyprlandClient -> HyprlandSocket -> [FilePath]-hyprlandSocketPaths HyprlandClient { clientEnv = Nothing } _ = []+hyprlandSocketPaths HyprlandClient {clientEnv = Nothing} _ = [] hyprlandSocketPaths HyprlandClient { clientEnv =- Just HyprlandClientEnv- { instanceSignature = sig- , runtimeDir = mRuntimeDir- }+ Just+ HyprlandClientEnv+ { instanceSignature = sig,+ runtimeDir = mRuntimeDir+ } } sock =- let name = hyprlandSocketName sock- runtimePaths =- case mRuntimeDir of- Nothing -> []- Just rd -> [rd ++ "/hypr/" ++ sig ++ "/" ++ name]- tmpPath = "/tmp/hypr/" ++ sig ++ "/" ++ name- in runtimePaths ++ [tmpPath]+ let name = hyprlandSocketName sock+ runtimePaths =+ case mRuntimeDir of+ Nothing -> []+ Just rd -> [rd ++ "/hypr/" ++ sig ++ "/" ++ name]+ tmpPath = "/tmp/hypr/" ++ sig ++ "/" ++ name+ in runtimePaths ++ [tmpPath] +-- | Open a Hyprland unix socket, trying known and discovered locations. openHyprlandSocket :: HyprlandClient -> HyprlandSocket -> IO (Either HyprlandError NS.Socket) openHyprlandSocket client sock = do -- Prefer the instance signature from the process environment first. If that@@ -234,7 +257,7 @@ where connectFirst :: [FilePath] -> IO (Maybe NS.Socket) connectFirst [] = pure Nothing- connectFirst (path:rest) = do+ connectFirst (path : rest) = do result <- connectToSocket path case result of Left _ -> connectFirst rest@@ -244,8 +267,8 @@ discoverHyprlandSocketPaths _client sock = do mRuntime <- lookupEnv "XDG_RUNTIME_DIR" let bases =- maybe [] (\rd -> [rd </> "hypr"]) mRuntime ++- ["/tmp/hypr"]+ maybe [] (\rd -> [rd </> "hypr"]) mRuntime+ ++ ["/tmp/hypr"] let name = hyprlandSocketName sock pathsWithTimes <- fmap concat $ forM bases $ \base -> do baseExists <- doesDirectoryExist base@@ -265,23 +288,26 @@ socketPathMTime :: FilePath -> IO (Maybe EpochTime) socketPathMTime path =- (do+ ( do st <- getFileStatus path if isSocket st then pure $ Just $ modificationTime st else pure Nothing- ) `catchAny` \_ -> pure Nothing+ )+ `catchAny` \_ -> pure Nothing connectToSocket :: FilePath -> IO (Either HyprlandError NS.Socket) connectToSocket path = do sock <- NS.socket NS.AF_UNIX NS.Stream NS.defaultProtocol- (do+ ( do NS.connect sock (NS.SockAddrUnix path) return $ Right sock- ) `catchAny` \e -> do+ )+ `catchAny` \e -> do void $ NS.close sock `catchAny` \_ -> pure () return $ Left $ HyprlandSocketException path (show e) +-- | Open the Hyprland event socket and return it as a line-buffered 'Handle'. openHyprlandEventSocket :: HyprlandClient -> IO (Either HyprlandError Handle) openHyprlandEventSocket client = do sockResult <- openHyprlandSocket client HyprlandEventSocket@@ -292,30 +318,34 @@ hSetBuffering handle LineBuffering return (Right handle) +-- | Bracket-style helper for safely using the Hyprland event socket handle. withHyprlandEventSocket :: HyprlandClient -> (Handle -> IO a) -> IO (Either HyprlandError a) withHyprlandEventSocket client action = do handleResult <- openHyprlandEventSocket client case handleResult of Left err -> return (Left err) Right handle ->- (do+ ( do result <- action handle hClose handle return (Right result)- ) `catchAny` \e -> do+ )+ `catchAny` \e -> do hClose handle return (Left $ HyprlandSocketException (show HyprlandEventSocket) (show e)) -- | A shared broadcast channel for Hyprland events read from the event socket. -- -- Readers should call 'subscribeHyprlandEvents' to get their own cursor.-newtype HyprlandEventChan =- HyprlandEventChan (TChan T.Text)+newtype HyprlandEventChan+ = HyprlandEventChan (TChan T.Text) +-- | Subscribe to a per-reader cursor for a shared Hyprland event channel. subscribeHyprlandEvents :: HyprlandEventChan -> IO (TChan T.Text) subscribeHyprlandEvents (HyprlandEventChan chan) = atomically $ dupTChan chan +-- | Create and maintain a shared Hyprland event broadcast channel. buildHyprlandEventChan :: HyprlandClient -> IO HyprlandEventChan buildHyprlandEventChan client = do chan <- newBroadcastTChanIO@@ -347,9 +377,9 @@ -- | Minimal monitor info parsed from @hyprctl monitors -j@. data HyprMonitorInfo = HyprMonitorInfo- { hmX :: Int- , hmY :: Int- , hmFocused :: Bool+ { hmX :: Int,+ hmY :: Int,+ hmFocused :: Bool } instance FromJSON HyprMonitorInfo where@@ -367,22 +397,27 @@ Left _ -> return Nothing Right monitors -> case filter hmFocused monitors of- (m:_) -> return $ Just (hmX m, hmY m)+ (m : _) -> return $ Just (hmX m, hmY m) [] -> return Nothing +-- | Structured representation of a Hyprland command invocation. data HyprlandCommand = HyprlandCommand- { commandArgs :: [String]- , commandJson :: Bool- } deriving (Show, Eq)+ { commandArgs :: [String],+ commandJson :: Bool+ }+ deriving (Show, Eq) +-- | Construct a non-JSON command. hyprCommand :: [String] -> HyprlandCommand-hyprCommand args = HyprlandCommand { commandArgs = args, commandJson = False }+hyprCommand args = HyprlandCommand {commandArgs = args, commandJson = False} +-- | Construct a command whose output should be JSON. hyprCommandJson :: [String] -> HyprlandCommand-hyprCommandJson args = HyprlandCommand { commandArgs = args, commandJson = True }+hyprCommandJson args = HyprlandCommand {commandArgs = args, commandJson = True} +-- | Encode a 'HyprlandCommand' into the bytes expected by the command socket. hyprlandCommandToSocketCommand :: HyprlandCommand -> Either HyprlandError BS.ByteString-hyprlandCommandToSocketCommand HyprlandCommand { commandArgs = args, commandJson = isJson }+hyprlandCommandToSocketCommand HyprlandCommand {commandArgs = args, commandJson = isJson} | null args = Left $ HyprlandCommandBuildFailed "No Hyprland command provided" | isJson = Right $ BS8.pack $ "j/" ++ unwords args | otherwise = Right $ BS8.pack $ unwords args@@ -396,27 +431,29 @@ client@HyprlandClient { clientConfig = HyprlandClientConfig- { useSocket = useSock- , fallbackToHyprctl = fallback- , hyprctlPath = hyprctl+ { useSocket = useSock,+ fallbackToHyprctl = fallback,+ hyprctlPath = hyprctl } } cmd = do- socketResult <-- if useSock- then runHyprlandCommandSocket client cmd- else pure $ Left $ HyprlandSocketUnavailable HyprlandCommandSocket []- case socketResult of- Right out -> pure (Right out)- Left sockErr ->- if fallback- then do- hyprctlResult <- runHyprlandCommandHyprctl client hyprctl cmd- pure $ case hyprctlResult of- Right out -> Right out- Left hyprctlErr -> Left $ HyprlandHyprctlFailed $- printf "%s (socket error: %s)" hyprctlErr (show sockErr)- else pure (Left sockErr)+ socketResult <-+ if useSock+ then runHyprlandCommandSocket client cmd+ else pure $ Left $ HyprlandSocketUnavailable HyprlandCommandSocket []+ case socketResult of+ Right out -> pure (Right out)+ Left sockErr ->+ if fallback+ then do+ hyprctlResult <- runHyprlandCommandHyprctl client hyprctl cmd+ pure $ case hyprctlResult of+ Right out -> Right out+ Left hyprctlErr ->+ Left $+ HyprlandHyprctlFailed $+ printf "%s (socket error: %s)" hyprctlErr (show sockErr)+ else pure (Left sockErr) runHyprlandCommandSocket :: HyprlandClient -> HyprlandCommand -> IO (Either HyprlandError BS.ByteString) runHyprlandCommandSocket client cmd = do@@ -424,7 +461,7 @@ case sockResult of Left err -> pure (Left err) Right sock ->- (do+ ( do cmdBytes <- case hyprlandCommandToSocketCommand cmd of Left e -> NS.close sock >> pure (Left e) Right b -> pure (Right b)@@ -436,16 +473,17 @@ resp <- recvAll sock NS.close sock pure (Right resp)- ) `catchAny` \e -> do+ )+ `catchAny` \e -> do NS.close sock pure $ Left $ HyprlandSocketException (show HyprlandCommandSocket) (show e) runHyprlandCommandHyprctl :: HyprlandClient -> FilePath -> HyprlandCommand -> IO (Either String BS.ByteString)-runHyprlandCommandHyprctl client hyprctl HyprlandCommand { commandArgs = args, commandJson = isJson } = do+runHyprlandCommandHyprctl client hyprctl HyprlandCommand {commandArgs = args, commandJson = isJson} = do mSig <- pickHyprlandInstanceSignature client let flags =- ["-j" | isJson] ++- maybe [] (\sig -> ["-i", sig]) mSig+ ["-j" | isJson]+ ++ maybe [] (\sig -> ["-i", sig]) mSig result <- runCommand hyprctl (flags ++ args) pure $ case result of Left err -> Left err@@ -454,12 +492,13 @@ pickHyprlandInstanceSignature :: HyprlandClient -> IO (Maybe String) pickHyprlandInstanceSignature client = case clientEnv client of- Just HyprlandClientEnv { instanceSignature = sig } -> do+ Just HyprlandClientEnv {instanceSignature = sig} -> do -- Prefer the signature from the environment if we can actually connect -- to the command socket. (After a Hyprland restart the old socket path -- might still exist but be stale.)- envAlive <- anyM canConnectSocket $- hyprlandSocketPaths client HyprlandCommandSocket+ envAlive <-+ anyM canConnectSocket $+ hyprlandSocketPaths client HyprlandCommandSocket if envAlive then pure (Just sig) else discover@@ -477,12 +516,12 @@ -- Use the newest discovered command socket. paths <- discoverHyprlandSocketPaths client HyprlandCommandSocket pure $ case paths of- p:_ -> Just $ takeFileName $ takeDirectory p+ p : _ -> Just $ takeFileName $ takeDirectory p [] -> Nothing -anyM :: Monad m => (a -> m Bool) -> [a] -> m Bool+anyM :: (Monad m) => (a -> m Bool) -> [a] -> m Bool anyM _ [] = pure False-anyM p (x:xs) = do+anyM p (x : xs) = do b <- p x if b then pure True else anyM p xs @@ -493,9 +532,10 @@ chunk <- NSB.recv sock 4096 if BS.null chunk then return (BS.concat (reverse acc))- else go (chunk:acc)+ else go (chunk : acc) -runHyprlandCommandJson :: FromJSON a => HyprlandClient -> HyprlandCommand -> IO (Either HyprlandError a)+-- | Run a command and decode the response body as JSON.+runHyprlandCommandJson :: (FromJSON a) => HyprlandClient -> HyprlandCommand -> IO (Either HyprlandError a) runHyprlandCommandJson client cmd = do raw <- runHyprlandCommandRaw client cmd pure $ case raw of
+ src/System/Taffybar/Information/Hyprland/API.hs view
@@ -0,0 +1,129 @@+{-# LANGUAGE OverloadedStrings #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Information.Hyprland.API+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Typed, validated Hyprland operations built on top of+-- "System.Taffybar.Information.Hyprland".+--+-- This module is intended for widget use: it exposes typed query functions for+-- the common @hyprctl -j@ endpoints and a small set of typed dispatch commands.+module System.Taffybar.Information.Hyprland.API+ ( -- * Queries+ getHyprlandClients,+ getHyprlandWorkspaces,+ getHyprlandMonitors,+ getHyprlandActiveWorkspace,+ getHyprlandActiveWindow,++ -- * Dispatch+ HyprlandWorkspaceTarget,+ mkHyprlandWorkspaceTarget,+ hyprlandWorkspaceTargetText,+ HyprlandAddress,+ mkHyprlandAddress,+ hyprlandAddressText,+ HyprlandDispatch (..),+ dispatchHyprland,+ )+where++import Data.Aeson (FromJSON)+import qualified Data.ByteString as BS+import Data.Char (isSpace)+import Data.Text (Text)+import qualified Data.Text as T+import System.Taffybar.Information.Hyprland+ ( HyprlandClient,+ HyprlandError (..),+ hyprCommand,+ hyprCommandJson,+ runHyprlandCommandJson,+ runHyprlandCommandRaw,+ )+import System.Taffybar.Information.Hyprland.Types+ ( HyprlandActiveWindowInfo,+ HyprlandActiveWorkspaceInfo,+ HyprlandClientInfo,+ HyprlandMonitorInfo,+ HyprlandWorkspaceInfo,+ )++runJson :: (FromJSON a) => HyprlandClient -> [String] -> IO (Either HyprlandError a)+runJson client args = runHyprlandCommandJson client (hyprCommandJson args)++getHyprlandClients :: HyprlandClient -> IO (Either HyprlandError [HyprlandClientInfo])+getHyprlandClients client = runJson client ["clients"]++getHyprlandWorkspaces :: HyprlandClient -> IO (Either HyprlandError [HyprlandWorkspaceInfo])+getHyprlandWorkspaces client = runJson client ["workspaces"]++getHyprlandMonitors :: HyprlandClient -> IO (Either HyprlandError [HyprlandMonitorInfo])+getHyprlandMonitors client = runJson client ["monitors"]++getHyprlandActiveWorkspace :: HyprlandClient -> IO (Either HyprlandError HyprlandActiveWorkspaceInfo)+getHyprlandActiveWorkspace client = runJson client ["activeworkspace"]++getHyprlandActiveWindow :: HyprlandClient -> IO (Either HyprlandError HyprlandActiveWindowInfo)+getHyprlandActiveWindow client = runJson client ["activewindow"]++newtype HyprlandWorkspaceTarget = HyprlandWorkspaceTarget Text+ deriving (Show, Eq)++hyprlandWorkspaceTargetText :: HyprlandWorkspaceTarget -> Text+hyprlandWorkspaceTargetText (HyprlandWorkspaceTarget t) = t++-- | Construct a workspace target validated enough to avoid socket argument splitting.+--+-- Note: This is stricter than @hyprctl@ (which can accept arguments with+-- whitespace), because the Hyprland command socket is a single string and we+-- currently build it with 'unwords'.+mkHyprlandWorkspaceTarget :: Text -> Either HyprlandError HyprlandWorkspaceTarget+mkHyprlandWorkspaceTarget t+ | T.null t = Left $ HyprlandCommandBuildFailed "Hyprland workspace target must not be empty"+ | T.any isSpace t = Left $ HyprlandCommandBuildFailed "Hyprland workspace target must not contain whitespace"+ | otherwise = Right $ HyprlandWorkspaceTarget t++newtype HyprlandAddress = HyprlandAddress Text+ deriving (Show, Eq)++hyprlandAddressText :: HyprlandAddress -> Text+hyprlandAddressText (HyprlandAddress t) = t++-- | Construct an address validated enough to prevent obvious argument splitting.+--+-- Hyprland currently represents window addresses like @0x123abc@, but we avoid+-- hard-coding that shape to reduce breakage if Hyprland changes formatting.+mkHyprlandAddress :: Text -> Either HyprlandError HyprlandAddress+mkHyprlandAddress t+ | T.null t = Left $ HyprlandCommandBuildFailed "Hyprland address must not be empty"+ | T.any isSpace t = Left $ HyprlandCommandBuildFailed "Hyprland address must not contain whitespace"+ | otherwise = Right $ HyprlandAddress t++data HyprlandDispatch+ = -- | @hyprctl dispatch workspace <name-or-id>@+ DispatchWorkspace HyprlandWorkspaceTarget+ | -- | @hyprctl dispatch focuswindow address:<addr>@+ DispatchFocusWindowAddress HyprlandAddress+ deriving (Show, Eq)++dispatchHyprland :: HyprlandClient -> HyprlandDispatch -> IO (Either HyprlandError BS.ByteString)+dispatchHyprland client action =+ runHyprlandCommandRaw client (hyprCommand (dispatchToArgs action))++dispatchToArgs :: HyprlandDispatch -> [String]+dispatchToArgs action =+ case action of+ DispatchWorkspace ws -> ["dispatch", "workspace", T.unpack (hyprlandWorkspaceTargetText ws)]+ DispatchFocusWindowAddress addr ->+ ["dispatch", "focuswindow", "address:" <> T.unpack (hyprlandAddressText addr)]
+ src/System/Taffybar/Information/Hyprland/Types.hs view
@@ -0,0 +1,160 @@+{-# LANGUAGE OverloadedStrings #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Information.Hyprland.Types+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Typed representations of JSON structures returned by @hyprctl -j@.+--+-- These are intended to be used by widgets so they do not need to deal with+-- raw JSON parsing and partial field lookups.+module System.Taffybar.Information.Hyprland.Types+ ( HyprlandWorkspaceRef (..),+ HyprlandWorkspaceInfo (..),+ HyprlandMonitorInfo (..),+ HyprlandClientInfo (..),+ HyprlandActiveWorkspaceInfo (..),+ HyprlandActiveWindowInfo (..),+ )+where++import Control.Applicative ((<|>))+import Data.Aeson (FromJSON (..), withObject, (.!=), (.:), (.:?))+import Data.Aeson.Types (Parser)+import Data.Text (Text)++parseOptionalAt :: Maybe [Int] -> Parser (Maybe (Int, Int))+parseOptionalAt mAt =+ case mAt of+ Nothing -> pure Nothing+ Just [x, y] -> pure $ Just (x, y)+ Just _ -> fail "Expected \"at\" to be a 2-element array"++data HyprlandWorkspaceRef = HyprlandWorkspaceRef+ { hyprWorkspaceRefId :: Int,+ hyprWorkspaceRefName :: Text+ }+ deriving (Show, Eq)++instance FromJSON HyprlandWorkspaceRef where+ parseJSON = withObject "HyprlandWorkspaceRef" $ \v ->+ HyprlandWorkspaceRef+ <$> v .: "id"+ <*> v .: "name"++-- | Entry from @hyprctl -j workspaces@.+data HyprlandWorkspaceInfo = HyprlandWorkspaceInfo+ { hyprWorkspaceId :: Int,+ hyprWorkspaceName :: Text,+ hyprWorkspaceMonitor :: Maybe Text,+ hyprWorkspaceWindows :: Maybe Int+ }+ deriving (Show, Eq)++instance FromJSON HyprlandWorkspaceInfo where+ parseJSON = withObject "HyprlandWorkspaceInfo" $ \v ->+ HyprlandWorkspaceInfo+ <$> v .: "id"+ <*> v .: "name"+ <*> v .:? "monitor"+ <*> v .:? "windows"++-- | Entry from @hyprctl -j monitors@.+data HyprlandMonitorInfo = HyprlandMonitorInfo+ { hyprMonitorName :: Maybe Text,+ hyprMonitorId :: Maybe Int,+ hyprMonitorFocused :: Bool,+ hyprMonitorX :: Maybe Int,+ hyprMonitorY :: Maybe Int,+ hyprMonitorWidth :: Maybe Int,+ hyprMonitorHeight :: Maybe Int,+ hyprMonitorActiveWorkspace :: Maybe HyprlandWorkspaceRef+ }+ deriving (Show, Eq)++instance FromJSON HyprlandMonitorInfo where+ parseJSON = withObject "HyprlandMonitorInfo" $ \v ->+ HyprlandMonitorInfo+ <$> v .:? "name"+ <*> v .:? "id"+ <*> v .:? "focused" .!= False+ <*> v .:? "x"+ <*> v .:? "y"+ <*> v .:? "width"+ <*> v .:? "height"+ <*> v .:? "activeWorkspace"++-- | Entry from @hyprctl -j clients@.+data HyprlandClientInfo = HyprlandClientInfo+ { hyprClientAddress :: Text,+ hyprClientTitle :: Text,+ hyprClientInitialTitle :: Maybe Text,+ hyprClientClass :: Maybe Text,+ hyprClientInitialClass :: Maybe Text,+ hyprClientWorkspace :: HyprlandWorkspaceRef,+ hyprClientFocused :: Bool,+ hyprClientHidden :: Bool,+ hyprClientMapped :: Bool,+ hyprClientUrgent :: Bool,+ hyprClientAt :: Maybe (Int, Int)+ }+ deriving (Show, Eq)++instance FromJSON HyprlandClientInfo where+ parseJSON = withObject "HyprlandClientInfo" $ \v -> do+ at <- parseOptionalAt =<< (v .:? "at")+ HyprlandClientInfo+ <$> v .: "address"+ <*> v .:? "title" .!= ""+ <*> v .:? "initialTitle"+ <*> v .:? "class"+ <*> v .:? "initialClass"+ <*> v .: "workspace"+ <*> v .:? "focused" .!= False+ <*> v .:? "hidden" .!= False+ <*> v .:? "mapped" .!= True+ <*> v .:? "urgent" .!= False+ <*> pure at++-- | Result from @hyprctl -j activeworkspace@.+--+-- Hyprland has used multiple field spellings for the layout; we normalize those+-- into 'hawLayout'.+data HyprlandActiveWorkspaceInfo = HyprlandActiveWorkspaceInfo+ { hyprActiveWorkspaceId :: Maybe Int,+ hyprActiveWorkspaceName :: Maybe Text,+ hyprActiveWorkspaceLayout :: Maybe Text+ }+ deriving (Show, Eq)++instance FromJSON HyprlandActiveWorkspaceInfo where+ parseJSON = withObject "HyprlandActiveWorkspaceInfo" $ \v -> do+ layout <- v .:? "layout" <|> v .:? "layoutName" <|> v .:? "layoutname"+ HyprlandActiveWorkspaceInfo+ <$> v .:? "id"+ <*> v .:? "name"+ <*> pure layout++-- | Result from @hyprctl -j activewindow@.+data HyprlandActiveWindowInfo = HyprlandActiveWindowInfo+ { hyprActiveWindowAddress :: Text,+ hyprActiveWindowTitle :: Maybe Text,+ hyprActiveWindowClass :: Maybe Text+ }+ deriving (Show, Eq)++instance FromJSON HyprlandActiveWindowInfo where+ parseJSON = withObject "HyprlandActiveWindowInfo" $ \v ->+ HyprlandActiveWindowInfo+ <$> v .:? "address" .!= ""+ <*> v .:? "title"+ <*> v .:? "class"
src/System/Taffybar/Information/Inhibitor.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Inhibitor -- Copyright : (c) Ivan A. Malison@@ -13,35 +17,37 @@ -- systemd-logind DBus interface. The inhibitor is acquired by calling the -- Inhibit method on org.freedesktop.login1.Manager, which returns a file -- descriptor. The lock is held as long as the fd is open.------------------------------------------------------------------------------ module System.Taffybar.Information.Inhibitor ( -- * Types- InhibitType(..)- , InhibitorState(..)- , InhibitorContext(..)+ InhibitType (..),+ InhibitorState (..),+ InhibitorContext (..),+ -- * Inhibitor Management- , getInhibitorContext- , getInhibitorState- , toggleInhibitor- , getInhibitorChan+ getInhibitorContext,+ getInhibitorState,+ toggleInhibitor,+ getInhibitorChan,+ -- * Utilities- , inhibitTypeToString- , inhibitTypesFromStrings- ) where+ inhibitTypeToString,+ inhibitTypesFromStrings,+ )+where -import Control.Concurrent-import Control.Concurrent.STM.TChan-import Control.Monad.IO.Class-import Control.Monad.STM (atomically)-import Control.Monad.Trans.Reader-import DBus-import DBus.Client-import Data.List (intercalate)+import Control.Concurrent+import Control.Concurrent.STM.TChan+import Control.Monad.IO.Class+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Reader+import DBus+import DBus.Client+import Data.List (intercalate) import qualified Data.Text as T-import System.Log.Logger-import System.Posix.IO (closeFd)-import System.Posix.Types (Fd)-import System.Taffybar.Context+import System.Log.Logger+import System.Posix.IO (closeFd)+import System.Posix.Types (Fd)+import System.Taffybar.Context -- | Types of inhibitors supported by systemd-logind data InhibitType@@ -56,22 +62,23 @@ -- | Current state of the inhibitor data InhibitorState = InhibitorState- { inhibitorActive :: Bool- , inhibitorTypes :: [InhibitType]- } deriving (Eq, Show)+ { inhibitorActive :: Bool,+ inhibitorTypes :: [InhibitType]+ }+ deriving (Eq, Show) -- | Context for managing an inhibitor, stored in taffybar's state data InhibitorContext = InhibitorContext- { inhibitorChan :: TChan InhibitorState- , inhibitorStateVar :: MVar InhibitorState- , inhibitorFdVar :: MVar (Maybe Fd)- , inhibitorConfig :: [InhibitType]+ { inhibitorChan :: TChan InhibitorState,+ inhibitorStateVar :: MVar InhibitorState,+ inhibitorFdVar :: MVar (Maybe Fd),+ inhibitorConfig :: [InhibitType] } inhibitorLogPath :: String inhibitorLogPath = "System.Taffybar.Information.Inhibitor" -inhibitorLog :: MonadIO m => Priority -> String -> m ()+inhibitorLog :: (MonadIO m) => Priority -> String -> m () inhibitorLog priority = liftIO . logM inhibitorLogPath priority -- | Convert an InhibitType to its string representation for DBus@@ -128,14 +135,18 @@ why = "User requested inhibition" mode = "block" :: String inhibitorLog DEBUG $ "Acquiring inhibitor for: " ++ what- reply <- call client (methodCall login1ObjectPath login1Interface "Inhibit")- { methodCallDestination = Just login1BusName- , methodCallBody = [ toVariant (T.pack what)- , toVariant (T.pack who)- , toVariant (T.pack why)- , toVariant (T.pack mode)- ]- }+ reply <-+ call+ client+ (methodCall login1ObjectPath login1Interface "Inhibit")+ { methodCallDestination = Just login1BusName,+ methodCallBody =+ [ toVariant (T.pack what),+ toVariant (T.pack who),+ toVariant (T.pack why),+ toVariant (T.pack mode)+ ]+ } case reply of Left err -> do inhibitorLog WARNING $ "Failed to acquire inhibitor: " ++ show err@@ -148,12 +159,16 @@ return $ Right fd Nothing -> do inhibitorLog WARNING "Invalid fd type in response"- return $ Left $ methodError (methodReturnSerial ret) $- errorName_ "org.taffybar.InvalidResponse"+ return $+ Left $+ methodError (methodReturnSerial ret) $+ errorName_ "org.taffybar.InvalidResponse" _ -> do inhibitorLog WARNING "Unexpected response format"- return $ Left $ methodError (methodReturnSerial ret) $- errorName_ "org.taffybar.InvalidResponse"+ return $+ Left $+ methodError (methodReturnSerial ret) $+ errorName_ "org.taffybar.InvalidResponse" -- | Release an inhibitor by closing its file descriptor releaseInhibitor :: Fd -> IO ()@@ -166,17 +181,21 @@ getInhibitorContext types = getStateDefault $ do inhibitorLog DEBUG "Initializing InhibitorContext" chan <- liftIO newBroadcastTChanIO- stateVar <- liftIO $ newMVar InhibitorState- { inhibitorActive = False- , inhibitorTypes = types- }+ stateVar <-+ liftIO $+ newMVar+ InhibitorState+ { inhibitorActive = False,+ inhibitorTypes = types+ } fdVar <- liftIO $ newMVar Nothing- return InhibitorContext- { inhibitorChan = chan- , inhibitorStateVar = stateVar- , inhibitorFdVar = fdVar- , inhibitorConfig = types- }+ return+ InhibitorContext+ { inhibitorChan = chan,+ inhibitorStateVar = stateVar,+ inhibitorFdVar = fdVar,+ inhibitorConfig = types+ } -- | Get the current inhibitor state getInhibitorState :: [InhibitType] -> TaffyIO InhibitorState@@ -200,10 +219,11 @@ Just fd -> do -- Currently active, release it releaseInhibitor fd- let newState = InhibitorState- { inhibitorActive = False- , inhibitorTypes = types- }+ let newState =+ InhibitorState+ { inhibitorActive = False,+ inhibitorTypes = types+ } _ <- swapMVar (inhibitorStateVar ctx) newState atomically $ writeTChan (inhibitorChan ctx) newState inhibitorLog DEBUG "Inhibitor deactivated"@@ -213,10 +233,11 @@ result <- acquireInhibitor client types case result of Right fd -> do- let newState = InhibitorState- { inhibitorActive = True- , inhibitorTypes = types- }+ let newState =+ InhibitorState+ { inhibitorActive = True,+ inhibitorTypes = types+ } _ <- swapMVar (inhibitorStateVar ctx) newState atomically $ writeTChan (inhibitorChan ctx) newState inhibitorLog DEBUG "Inhibitor activated"
src/System/Taffybar/Information/KeyboardState.hs view
@@ -1,6 +1,9 @@ {-# LANGUAGE OverloadedStrings #-} --------------------------------------------------------------------------------++--------------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.KeyboardState -- Copyright : (c) Ivan Malison@@ -12,28 +15,27 @@ -- -- Provides information about keyboard lock states (Caps Lock, Num Lock, -- Scroll Lock) by reading LED brightness values from sysfs.------------------------------------------------------------------------------------- module System.Taffybar.Information.KeyboardState- ( KeyboardState(..)- , getKeyboardState- , defaultKeyboardState- , findLedPath- , defaultLedBasePath- ) where+ ( KeyboardState (..),+ getKeyboardState,+ defaultKeyboardState,+ findLedPath,+ defaultLedBasePath,+ )+where -import Control.Exception (catch, SomeException)+import Control.Exception (SomeException, catch) import Data.List (find)-import System.Directory (listDirectory, doesFileExist)+import System.Directory (doesFileExist, listDirectory) import System.FilePath ((</>)) -- | Represents the state of keyboard lock keys. data KeyboardState = KeyboardState- { capsLock :: Bool- , numLock :: Bool- , scrollLock :: Bool- } deriving (Eq, Show)+ { capsLock :: Bool,+ numLock :: Bool,+ scrollLock :: Bool+ }+ deriving (Eq, Show) -- | Default keyboard state with all locks off. defaultKeyboardState :: KeyboardState@@ -92,8 +94,9 @@ numState <- maybe (return False) readLedState numPath scrollState <- maybe (return False) readLedState scrollPath - return KeyboardState- { capsLock = capsState- , numLock = numState- , scrollLock = scrollState- }+ return+ KeyboardState+ { capsLock = capsState,+ numLock = numState,+ scrollLock = scrollState+ }
src/System/Taffybar/Information/MPRIS2.hs view
@@ -1,6 +1,10 @@-{-# LANGUAGE NoMonoLocalBinds #-} {-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE NoMonoLocalBinds #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.MPRIS2 -- Copyright : (c) Ivan A. Malison@@ -10,62 +14,99 @@ -- Stability : unstable -- Portability : unportable ---------------------------------------------------------------------------------+-- Query and decode MPRIS2 metadata from DBus media-player services. module System.Taffybar.Information.MPRIS2 where -import Control.Applicative-import Control.Monad.IO.Class-import Control.Monad.Trans.Class-import Control.Monad.Trans.Except-import Control.Monad.Trans.Maybe+import Control.Applicative+import Control.Monad.IO.Class+import Control.Monad.Trans.Class+import Control.Monad.Trans.Except+import Control.Monad.Trans.Maybe import qualified DBus import qualified DBus.Client as DBus import qualified DBus.Internal.Types as DBus import qualified DBus.TH as DBus-import Data.Coerce-import Data.List+import Data.Coerce+import Data.List import qualified Data.Map as M-import Data.Maybe-import System.Log.Logger-import System.Taffybar.DBus.Client.MPRIS2-import Text.Printf+import Data.Maybe+import System.Log.Logger+import System.Taffybar.DBus.Client.MPRIS2+import Text.Printf +-- | Normalized now-playing metadata for a single MPRIS2 player. data NowPlaying = NowPlaying- { npTitle :: String- , npArtists :: [String]- , npStatus :: String- , npBusName :: DBus.BusName- } deriving (Show, Eq)+ { npTitle :: String,+ npArtists :: [String],+ npStatus :: String,+ npBusName :: DBus.BusName,+ npCanGoPrevious :: Bool,+ npCanPlay :: Bool,+ npCanPause :: Bool,+ npCanGoNext :: Bool+ }+ deriving (Show, Eq) +-- | Convert an 'Either' into a 'Maybe' while logging failures. eitherToMaybeWithLog :: (MonadIO m, Show a1) => Either a1 a2 -> m (Maybe a2) eitherToMaybeWithLog (Right v) = return $ Just v eitherToMaybeWithLog (Left e) = liftIO $ do logM "System.Taffybar.Information.MPRIS2" WARNING $- printf "Got error: %s" $ show e+ printf "Got error: %s" $+ show e return Nothing -getNowPlayingInfo :: MonadIO m => DBus.Client -> m [NowPlaying]+-- | Query all active MPRIS2 players and return their now-playing metadata.+getNowPlayingInfo :: (MonadIO m) => DBus.Client -> m [NowPlaying] getNowPlayingInfo client =- fmap (fromMaybe []) $ eitherToMaybeWithLog =<< liftIO (runExceptT $ do- allBusNames <- ExceptT $ DBus.listNames client- let mediaPlayerBusNames =- filter (isPrefixOf "org.mpris.MediaPlayer2.") allBusNames- getSongData _busName = runMaybeT $- do- let busName = coerce _busName- metadataMap <-- MaybeT $ getMetadata client busName >>= eitherToMaybeWithLog- (title, artists) <- MaybeT $ return $ getSongInfo metadataMap- status <- MaybeT $ getPlaybackStatus client busName >>=- eitherToMaybeWithLog- return NowPlaying { npTitle = title- , npArtists = artists- , npStatus = status- , npBusName = busName- }- lift $ catMaybes <$> mapM getSongData mediaPlayerBusNames)+ fmap (fromMaybe []) $+ eitherToMaybeWithLog+ =<< liftIO+ ( runExceptT $ do+ allBusNames <- ExceptT $ DBus.listNames client+ let mediaPlayerBusNames =+ filter (isPrefixOf "org.mpris.MediaPlayer2.") allBusNames+ getSongData _busName = runMaybeT $+ do+ let busName = coerce _busName+ metadataMap <-+ MaybeT $ getMetadata client busName >>= eitherToMaybeWithLog+ (title, artists) <- MaybeT $ return $ getSongInfo metadataMap+ status <-+ MaybeT $+ getPlaybackStatus client busName+ >>= eitherToMaybeWithLog+ canGoPrevious <-+ lift $+ fromMaybe False+ <$> (getCanGoPrevious client busName >>= eitherToMaybeWithLog)+ canPlay <-+ lift $+ fromMaybe False+ <$> (getCanPlay client busName >>= eitherToMaybeWithLog)+ canPause <-+ lift $+ fromMaybe False+ <$> (getCanPause client busName >>= eitherToMaybeWithLog)+ canGoNext <-+ lift $+ fromMaybe False+ <$> (getCanGoNext client busName >>= eitherToMaybeWithLog)+ return+ NowPlaying+ { npTitle = title,+ npArtists = artists,+ npStatus = status,+ npBusName = busName,+ npCanGoPrevious = canGoPrevious,+ npCanPlay = canPlay,+ npCanPause = canPause,+ npCanGoNext = canGoNext+ }+ lift $ catMaybes <$> mapM getSongData mediaPlayerBusNames+ ) +-- | Extract title and artist list from an MPRIS metadata map. getSongInfo :: M.Map String DBus.Variant -> Maybe (String, [String]) getSongInfo songData = do let lookupVariant k = M.lookup k songData >>= DBus.fromVariant
src/System/Taffybar/Information/Memory.hs view
@@ -1,57 +1,71 @@-module System.Taffybar.Information.Memory (- MemoryInfo(..),- parseMeminfo- ) where+-- | Parse memory usage data from Linux @/proc/meminfo@ and expose a derived+-- summary record used by memory widgets.+module System.Taffybar.Information.Memory+ ( MemoryInfo (..),+ parseMeminfo,+ )+where toMB :: String -> Double toMB size = (read size :: Double) / 1024 +safeRatio :: Double -> Double -> Double+safeRatio+ _+ 0 = 0+safeRatio numerator denominator = numerator / denominator++-- | Snapshot of parsed memory and swap values from @/proc/meminfo@. data MemoryInfo = MemoryInfo- { memoryTotal :: Double- , memoryFree :: Double- , memoryBuffer :: Double- , memoryCache :: Double- , memorySwapTotal :: Double- , memorySwapFree :: Double- , memorySwapUsed :: Double -- swapTotal - swapFree- , memorySwapUsedRatio :: Double -- swapUsed / swapTotal- , memoryAvailable :: Double -- An estimate of how much memory is available- , memoryRest :: Double -- free + buffer + cache- , memoryUsed :: Double -- total - rest- , memoryUsedRatio :: Double -- used / total+ { memoryTotal :: Double,+ memoryFree :: Double,+ memoryBuffer :: Double,+ memoryCache :: Double,+ memorySwapTotal :: Double,+ memorySwapFree :: Double,+ memorySwapUsed :: Double, -- swapTotal - swapFree+ memorySwapUsedRatio :: Double, -- swapUsed / swapTotal+ memoryAvailable :: Double, -- An estimate of how much memory is available+ memoryRest :: Double, -- free + buffer + cache+ memoryUsed :: Double, -- total - rest+ memoryUsedRatio :: Double -- used / total } emptyMemoryInfo :: MemoryInfo emptyMemoryInfo = MemoryInfo 0 0 0 0 0 0 0 0 0 0 0 0 parseLines :: [String] -> MemoryInfo -> MemoryInfo-parseLines (line:rest) memInfo = parseLines rest newMemInfo- where newMemInfo = case words line of- (label:size:_) ->- case label of- "MemTotal:" -> memInfo { memoryTotal = toMB size }- "MemFree:" -> memInfo { memoryFree = toMB size }- "MemAvailable:" -> memInfo { memoryAvailable = toMB size }- "Buffers:" -> memInfo { memoryBuffer = toMB size }- "Cached:" -> memInfo { memoryCache = toMB size }- "SwapTotal:" -> memInfo { memorySwapTotal = toMB size }- "SwapFree:" -> memInfo { memorySwapFree = toMB size }- _ -> memInfo- _ -> memInfo+parseLines (line : rest) memInfo = parseLines rest newMemInfo+ where+ newMemInfo = case words line of+ (label : size : _) ->+ case label of+ "MemTotal:" -> memInfo {memoryTotal = toMB size}+ "MemFree:" -> memInfo {memoryFree = toMB size}+ "MemAvailable:" -> memInfo {memoryAvailable = toMB size}+ "Buffers:" -> memInfo {memoryBuffer = toMB size}+ "Cached:" -> memInfo {memoryCache = toMB size}+ "SwapTotal:" -> memInfo {memorySwapTotal = toMB size}+ "SwapFree:" -> memInfo {memorySwapFree = toMB size}+ _ -> memInfo+ _ -> memInfo parseLines _ memInfo = memInfo +-- | Read @/proc/meminfo@ and return memory/swap totals and usage ratios in MiB. parseMeminfo :: IO MemoryInfo parseMeminfo = do s <- readFile "/proc/meminfo" let m = parseLines (lines s) emptyMemoryInfo rest = memoryFree m + memoryBuffer m + memoryCache m used = memoryTotal m - rest- usedRatio = used / memoryTotal m+ usedRatio = safeRatio used (memoryTotal m) swapUsed = memorySwapTotal m - memorySwapFree m- swapUsedRatio = swapUsed / memorySwapTotal m- return m { memoryRest = rest- , memoryUsed = used- , memoryUsedRatio = usedRatio- , memorySwapUsed = swapUsed- , memorySwapUsedRatio = swapUsedRatio- }+ swapUsedRatio = safeRatio swapUsed (memorySwapTotal m)+ return+ m+ { memoryRest = rest,+ memoryUsed = used,+ memoryUsedRatio = usedRatio,+ memorySwapUsed = swapUsed,+ memorySwapUsedRatio = swapUsedRatio+ }
src/System/Taffybar/Information/Network.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Network -- Copyright : (c) José A. Romero L.@@ -11,23 +14,22 @@ -- Provides information about network traffic over selected interfaces, -- obtained from parsing the @\/proc\/net\/dev@ file using some of the -- facilities provided by the "System.Taffybar.Information.StreamInfo" module.---------------------------------------------------------------------------------- module System.Taffybar.Information.Network where import qualified Control.Concurrent.MVar as MV-import Control.Exception (catch, SomeException)-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Maybe (MaybeT(..))-import Data.Maybe ( mapMaybe )-import Data.Time.Clock-import Data.Time.Clock.System-import Safe ( atMay, initSafe, readDef )-import System.Taffybar.Information.StreamInfo ( getParsedInfo )-import System.Taffybar.Util+import Control.Exception (SomeException, catch)+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Maybe (MaybeT (..))+import Data.Maybe (mapMaybe)+import Data.Time.Clock+import Data.Time.Clock.System+import Safe (atMay, initSafe, readDef)+import System.Taffybar.Context (TaffyIO)+import System.Taffybar.Information.StreamInfo (getParsedInfo)+import System.Taffybar.Information.Wakeup (taffyForeverWithDelay) +-- | Source file for Linux per-interface byte counters. networkInfoFile :: FilePath networkInfoFile = "/proc/net/dev" @@ -39,14 +41,19 @@ isInterfaceUp iface handleFailure $ getParsedInfo networkInfoFile parseDevNet' iface +-- | Parse @/proc/net/dev@ into a simplified map of+-- @(interface, [receivedBytes, transmittedBytes])@. parseDevNet' :: String -> [(String, [Int])] parseDevNet' input = map makeList $ parseDevNet input- where makeList (a, (u, d)) = (a, [u, d])+ where+ makeList (a, (u, d)) = (a, [u, d]) +-- | Parse @/proc/net/dev@ into @(interface, (receivedBytes, transmittedBytes))@. parseDevNet :: String -> [(String, (Int, Int))] parseDevNet = mapMaybe (getDeviceUpDown . words) . drop 2 . lines +-- | Parse one tokenized @/proc/net/dev@ line. getDeviceUpDown :: [String] -> Maybe (String, (Int, Int)) getDeviceUpDown s = do dev <- initSafe <$> s `atMay` 0@@ -57,6 +64,8 @@ out = length s - 8 -- Nothing if interface does not exist or is down++-- | Check whether a network interface exists and reports as up. isInterfaceUp :: String -> MaybeT IO () isInterfaceUp iface = do state <- handleFailure $ readFile $ "/sys/class/net/" ++ iface ++ "/operstate"@@ -64,77 +73,90 @@ 'u' : _ -> return () _ -> mzero +-- | Convert IO exceptions into @Nothing@ for polling helpers. handleFailure :: IO a -> MaybeT IO a handleFailure action = MaybeT $ catch (Just <$> action) eToNothing where eToNothing :: SomeException -> IO (Maybe a) eToNothing _ = pure Nothing +-- | Collect per-interface byte-counter samples with timestamps. getDeviceSamples :: IO (Maybe [TxSample]) getDeviceSamples = runMaybeT $ handleFailure $ do contents <- readFile networkInfoFile length contents `seq` return () time <- liftIO getSystemTime let mkSample (device, (up, down)) =- TxSample { sampleUp = up- , sampleDown = down- , sampleTime = time- , sampleDevice = device- }+ TxSample+ { sampleUp = up,+ sampleDown = down,+ sampleTime = time,+ sampleDevice = device+ } return $ map mkSample $ parseDevNet contents +-- | Raw network sample for a single interface at a point in time. data TxSample = TxSample- { sampleUp :: Int- , sampleDown :: Int- , sampleTime :: SystemTime- , sampleDevice :: String- } deriving (Show, Eq)+ { sampleUp :: Int,+ sampleDown :: Int,+ sampleTime :: SystemTime,+ sampleDevice :: String+ }+ deriving (Show, Eq) -monitorNetworkInterfaces- :: RealFrac a1- => a1 -> ([(String, (Rational, Rational))] -> IO ()) -> IO ()-monitorNetworkInterfaces interval onUpdate = void $ do- samplesVar <- MV.newMVar []+-- | Periodically monitor interface speeds and call a callback with+-- per-interface download/upload rates in bytes per second.+monitorNetworkInterfaces ::+ (RealFrac a1) =>+ a1 -> ([(String, (Rational, Rational))] -> IO ()) -> TaffyIO ()+monitorNetworkInterfaces interval onUpdate = do+ samplesVar <- liftIO $ MV.newMVar [] let sampleToSpeeds (device, (s1, s2)) = (device, getSpeed s1 s2) doOnUpdate samples = do let speedInfo = map sampleToSpeeds samples onUpdate speedInfo return samples doUpdate = MV.modifyMVar_ samplesVar (updateSamples >=> doOnUpdate)- foreverWithDelay interval doUpdate+ void $ taffyForeverWithDelay interval (liftIO doUpdate) +-- | Update sample history by pairing newest and previous samples per interface. updateSamples :: [(String, (TxSample, TxSample))] -> IO [(String, (TxSample, TxSample))] updateSamples currentSamples = do- let getLast sample@TxSample { sampleDevice = device } =+ let getLast sample@TxSample {sampleDevice = device} = maybe sample fst $ lookup device currentSamples- getSamplePair sample@TxSample { sampleDevice = device } =+ getSamplePair sample@TxSample {sampleDevice = device} = let lastSample = getLast sample- in lastSample `seq` (device, (sample, lastSample))+ in lastSample `seq` (device, (sample, lastSample)) maybe currentSamples (map getSamplePair) <$> getDeviceSamples +-- | Compute download/upload speeds from two samples. getSpeed :: TxSample -> TxSample -> (Rational, Rational)-getSpeed TxSample { sampleUp = thisUp- , sampleDown = thisDown- , sampleTime = thisTime- }- TxSample { sampleUp = lastUp- , sampleDown = lastDown- , sampleTime = lastTime- } =- let intervalDiffTime =- diffUTCTime- (systemToUTCTime thisTime)- (systemToUTCTime lastTime)- intervalRatio =- if intervalDiffTime == 0- then 0- else toRational $ 1 / intervalDiffTime- in ( fromIntegral (thisDown - lastDown) * intervalRatio- , fromIntegral (thisUp - lastUp) * intervalRatio- )+getSpeed+ TxSample+ { sampleUp = thisUp,+ sampleDown = thisDown,+ sampleTime = thisTime+ }+ TxSample+ { sampleUp = lastUp,+ sampleDown = lastDown,+ sampleTime = lastTime+ } =+ let intervalDiffTime =+ diffUTCTime+ (systemToUTCTime thisTime)+ (systemToUTCTime lastTime)+ intervalRatio =+ if intervalDiffTime == 0+ then 0+ else toRational $ 1 / intervalDiffTime+ in ( fromIntegral (thisDown - lastDown) * intervalRatio,+ fromIntegral (thisUp - lastUp) * intervalRatio+ ) +-- | Sum download/upload speeds across interfaces. sumSpeeds :: [(Rational, Rational)] -> (Rational, Rational) sumSpeeds = foldr1 sumOne where
src/System/Taffybar/Information/NetworkManager.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.NetworkManager -- Copyright : (c) Ivan A. Malison@@ -11,49 +15,50 @@ -- -- This module provides information about the active WiFi connection using -- NetworkManager's DBus API.------------------------------------------------------------------------------ module System.Taffybar.Information.NetworkManager- ( WifiInfo(..)- , WifiState(..)- , NetworkInfo(..)- , NetworkState(..)- , NetworkType(..)- , getWifiInfo- , getWifiInfoFromClient- , getWifiInfoChan- , getWifiInfoState- , getNetworkInfo- , getNetworkInfoFromClient- , getNetworkInfoChan- , getNetworkInfoState- ) where+ ( WifiInfo (..),+ WifiState (..),+ NetworkInfo (..),+ NetworkState (..),+ NetworkType (..),+ getWifiInfo,+ getWifiInfoFromClient,+ getWifiInfoChan,+ getWifiInfoState,+ getNetworkInfo,+ getNetworkInfoFromClient,+ getNetworkInfoChan,+ getNetworkInfoState,+ )+where -import Control.Concurrent.MVar-import Control.Concurrent.STM.TChan-import Control.Monad.IO.Class-import Control.Monad.STM (atomically)-import Control.Monad.Trans.Class-import Control.Monad.Trans.Except-import Control.Monad.Trans.Reader-import DBus-import DBus.Client-import DBus.Internal.Types (Serial(..))+import Control.Concurrent.MVar+import Control.Concurrent.STM.TChan+import Control.Monad.IO.Class+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Except+import Control.Monad.Trans.Reader+import DBus+import DBus.Client+import DBus.Internal.Types (Serial (..)) import qualified DBus.TH as DBus import qualified Data.ByteString as BS-import Data.List (minimumBy)-import Data.Map (Map)+import Data.List (minimumBy)+import Data.Map (Map) import qualified Data.Map as M-import Data.Maybe (fromMaybe, listToMaybe)-import Data.Text (Text)+import Data.Maybe (fromMaybe, listToMaybe)+import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding.Error as TEE-import Data.Word (Word8)-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.DBus.Client.Params-import System.Taffybar.Util (logPrintF, maybeToEither)+import Data.Word (Word8)+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.DBus.Client.Params+import System.Taffybar.Util (logPrintF, maybeToEither) +-- | High-level WiFi connectivity state from NetworkManager. data WifiState = WifiDisabled | WifiDisconnected@@ -61,19 +66,23 @@ | WifiUnknown deriving (Eq, Show) +-- | Snapshot of WiFi-specific connection information. data WifiInfo = WifiInfo- { wifiState :: WifiState- , wifiSsid :: Maybe Text- , wifiStrength :: Maybe Int- , wifiConnectionId :: Maybe Text- } deriving (Eq, Show)+ { wifiState :: WifiState,+ wifiSsid :: Maybe Text,+ wifiStrength :: Maybe Int,+ wifiConnectionId :: Maybe Text+ }+ deriving (Eq, Show) +-- | High-level non-device-specific network connectivity state. data NetworkState = NetworkConnected | NetworkDisconnected | NetworkUnknown deriving (Eq, Show) +-- | Active network transport type. data NetworkType = NetworkWifi | NetworkWired@@ -81,14 +90,16 @@ | NetworkOther Text deriving (Eq, Show) +-- | Snapshot of the current active network connection. data NetworkInfo = NetworkInfo- { networkState :: NetworkState- , networkType :: Maybe NetworkType- , networkSsid :: Maybe Text- , networkStrength :: Maybe Int- , networkConnectionId :: Maybe Text- , networkWirelessEnabled :: Maybe Bool- } deriving (Eq, Show)+ { networkState :: NetworkState,+ networkType :: Maybe NetworkType,+ networkSsid :: Maybe Text,+ networkStrength :: Maybe Int,+ networkConnectionId :: Maybe Text,+ networkWirelessEnabled :: Maybe Bool+ }+ deriving (Eq, Show) wifiLogPath :: String wifiLogPath = "System.Taffybar.Information.NetworkManager"@@ -102,37 +113,39 @@ wifiUnknownInfo :: WifiInfo wifiUnknownInfo = WifiInfo- { wifiState = WifiUnknown- , wifiSsid = Nothing- , wifiStrength = Nothing- , wifiConnectionId = Nothing+ { wifiState = WifiUnknown,+ wifiSsid = Nothing,+ wifiStrength = Nothing,+ wifiConnectionId = Nothing } wifiDisabledInfo :: WifiInfo wifiDisabledInfo = WifiInfo- { wifiState = WifiDisabled- , wifiSsid = Nothing- , wifiStrength = Nothing- , wifiConnectionId = Nothing+ { wifiState = WifiDisabled,+ wifiSsid = Nothing,+ wifiStrength = Nothing,+ wifiConnectionId = Nothing } wifiDisconnectedInfo :: WifiInfo wifiDisconnectedInfo = WifiInfo- { wifiState = WifiDisconnected- , wifiSsid = Nothing- , wifiStrength = Nothing- , wifiConnectionId = Nothing+ { wifiState = WifiDisconnected,+ wifiSsid = Nothing,+ wifiStrength = Nothing,+ wifiConnectionId = Nothing } newtype WifiInfoChanVar = WifiInfoChanVar (TChan WifiInfo, MVar WifiInfo) +-- | Read the latest cached 'WifiInfo' value. getWifiInfoState :: TaffyIO WifiInfo getWifiInfoState = do WifiInfoChanVar (_, theVar) <- getWifiInfoChanVar lift $ readMVar theVar +-- | Get a broadcast channel of WiFi info updates. getWifiInfoChan :: TaffyIO (TChan WifiInfo) getWifiInfoChan = do WifiInfoChanVar (chan, _) <- getWifiInfoChanVar@@ -157,46 +170,52 @@ updateInfo return (chan, infoVar) -registerForNetworkManagerPropertiesChanged- :: (Signal -> String -> Map String Variant -> [String] -> IO ())- -> ReaderT Context IO SignalHandler+registerForNetworkManagerPropertiesChanged ::+ (Signal -> String -> Map String Variant -> [String] -> IO ()) ->+ ReaderT Context IO SignalHandler registerForNetworkManagerPropertiesChanged signalHandler = do client <- asks systemDBusClient- lift $ DBus.registerForPropertiesChanged- client- matchAny { matchInterface = Just nmInterfaceName- , matchPath = Just nmObjectPath- }- signalHandler+ lift $+ DBus.registerForPropertiesChanged+ client+ matchAny+ { matchInterface = Just nmInterfaceName,+ matchPath = Just nmObjectPath+ }+ signalHandler -registerForActiveConnectionPropertiesChanged- :: (Signal -> String -> Map String Variant -> [String] -> IO ())- -> ReaderT Context IO SignalHandler+registerForActiveConnectionPropertiesChanged ::+ (Signal -> String -> Map String Variant -> [String] -> IO ()) ->+ ReaderT Context IO SignalHandler registerForActiveConnectionPropertiesChanged signalHandler = do client <- asks systemDBusClient- lift $ DBus.registerForPropertiesChanged- client- matchAny { matchInterface = Just nmActiveConnectionInterfaceName- , matchPathNamespace = Just nmActiveConnectionPathNamespace- }- signalHandler+ lift $+ DBus.registerForPropertiesChanged+ client+ matchAny+ { matchInterface = Just nmActiveConnectionInterfaceName,+ matchPathNamespace = Just nmActiveConnectionPathNamespace+ }+ signalHandler -registerForAccessPointPropertiesChanged- :: (Signal -> String -> Map String Variant -> [String] -> IO ())- -> ReaderT Context IO SignalHandler+registerForAccessPointPropertiesChanged ::+ (Signal -> String -> Map String Variant -> [String] -> IO ()) ->+ ReaderT Context IO SignalHandler registerForAccessPointPropertiesChanged signalHandler = do client <- asks systemDBusClient- lift $ DBus.registerForPropertiesChanged- client- matchAny { matchInterface = Just nmAccessPointInterfaceName- , matchPathNamespace = Just nmAccessPointPathNamespace- }- signalHandler+ lift $+ DBus.registerForPropertiesChanged+ client+ matchAny+ { matchInterface = Just nmAccessPointInterfaceName,+ matchPathNamespace = Just nmAccessPointPathNamespace+ }+ signalHandler -updateWifiInfo- :: TChan WifiInfo- -> MVar WifiInfo- -> TaffyIO ()+updateWifiInfo ::+ TChan WifiInfo ->+ MVar WifiInfo ->+ TaffyIO () updateWifiInfo chan var = do info <- getWifiInfo lift $ do@@ -207,24 +226,31 @@ dummyMethodError :: MethodError dummyMethodError = methodError (Serial 1) $ errorName_ "org.ClientTypeMismatch" -readDictMaybe :: IsVariant a => Map Text Variant -> Text -> Maybe a+readDictMaybe :: (IsVariant a) => Map Text Variant -> Text -> Maybe a readDictMaybe dict key = M.lookup key dict >>= fromVariant -getProperties- :: Client- -> ObjectPath- -> InterfaceName- -> IO (Either MethodError (Map Text Variant))+getProperties ::+ Client ->+ ObjectPath ->+ InterfaceName ->+ IO (Either MethodError (Map Text Variant)) getProperties client path iface = runExceptT $ do- reply <- ExceptT $ getAllProperties client $- (methodCall path iface "FakeMethod")- { methodCallDestination = Just nmBusName }- ExceptT $ return $ maybeToEither dummyMethodError $- listToMaybe (methodReturnBody reply) >>= fromVariant+ reply <-+ ExceptT $+ getAllProperties client $+ (methodCall path iface "FakeMethod")+ { methodCallDestination = Just nmBusName+ }+ ExceptT $+ return $+ maybeToEither dummyMethodError $+ listToMaybe (methodReturnBody reply) >>= fromVariant +-- | Query WiFi information using taffybar's shared DBus client. getWifiInfo :: TaffyIO WifiInfo getWifiInfo = asks systemDBusClient >>= liftIO . getWifiInfoFromClient +-- | Query WiFi information from the provided NetworkManager DBus client. getWifiInfoFromClient :: Client -> IO WifiInfo getWifiInfoFromClient client = do nmPropsResult <- getProperties client nmObjectPath nmInterfaceName@@ -239,8 +265,9 @@ case wirelessEnabled of Just False -> return wifiDisabledInfo Just True -> case activeConnections of- Just paths -> fromMaybe wifiDisconnectedInfo <$>- findActiveWifi client paths+ Just paths ->+ fromMaybe wifiDisconnectedInfo+ <$> findActiveWifi client paths Nothing -> do logM wifiLogPath WARNING "NetworkManager missing ActiveConnections" return wifiUnknownInfo@@ -250,7 +277,7 @@ findActiveWifi :: Client -> [ObjectPath] -> IO (Maybe WifiInfo) findActiveWifi _ [] = return Nothing-findActiveWifi client (path:rest) = do+findActiveWifi client (path : rest) = do connPropsResult <- getProperties client path nmActiveConnectionInterfaceName case connPropsResult of Left err -> do@@ -265,20 +292,23 @@ specificObject = readDictMaybe connProps "SpecificObject" :: Maybe ObjectPath (ssid, strength) <- getAccessPointInfo client specificObject- return $ Just WifiInfo- { wifiState = WifiConnected- , wifiSsid = ssid- , wifiStrength = strength- , wifiConnectionId = connId- }+ return $+ Just+ WifiInfo+ { wifiState = WifiConnected,+ wifiSsid = ssid,+ wifiStrength = strength,+ wifiConnectionId = connId+ } -getAccessPointInfo- :: Client- -> Maybe ObjectPath- -> IO (Maybe Text, Maybe Int)+getAccessPointInfo ::+ Client ->+ Maybe ObjectPath ->+ IO (Maybe Text, Maybe Int) getAccessPointInfo _ Nothing = return (Nothing, Nothing)-getAccessPointInfo _ (Just path) | path == nullObjectPath =- return (Nothing, Nothing)+getAccessPointInfo _ (Just path)+ | path == nullObjectPath =+ return (Nothing, Nothing) getAccessPointInfo client (Just path) = do apPropsResult <- getProperties client path nmAccessPointInterfaceName case apPropsResult of@@ -289,8 +319,8 @@ let ssidBytes = readDictMaybe apProps "Ssid" :: Maybe [Word8] strength = readDictMaybe apProps "Strength" :: Maybe Word8 return- ( ssidBytes >>= decodeSsid- , fromIntegral <$> strength+ ( ssidBytes >>= decodeSsid,+ fromIntegral <$> strength ) decodeSsid :: [Word8] -> Maybe Text@@ -305,19 +335,21 @@ networkUnknownInfo :: NetworkInfo networkUnknownInfo = NetworkInfo- { networkState = NetworkUnknown- , networkType = Nothing- , networkSsid = Nothing- , networkStrength = Nothing- , networkConnectionId = Nothing- , networkWirelessEnabled = Nothing+ { networkState = NetworkUnknown,+ networkType = Nothing,+ networkSsid = Nothing,+ networkStrength = Nothing,+ networkConnectionId = Nothing,+ networkWirelessEnabled = Nothing } +-- | Read the latest cached 'NetworkInfo' value. getNetworkInfoState :: TaffyIO NetworkInfo getNetworkInfoState = do NetworkInfoChanVar (_, theVar) <- getNetworkInfoChanVar lift $ readMVar theVar +-- | Get a broadcast channel of network info updates. getNetworkInfoChan :: TaffyIO (TChan NetworkInfo) getNetworkInfoChan = do NetworkInfoChanVar (chan, _) <- getNetworkInfoChanVar@@ -341,26 +373,29 @@ updateInfo return (chan, infoVar) -updateNetworkInfo- :: TChan NetworkInfo- -> MVar NetworkInfo- -> TaffyIO ()+updateNetworkInfo ::+ TChan NetworkInfo ->+ MVar NetworkInfo ->+ TaffyIO () updateNetworkInfo chan var = do info <- getNetworkInfo lift $ do _ <- swapMVar var info atomically $ writeTChan chan info +-- | Query network information using taffybar's shared DBus client. getNetworkInfo :: TaffyIO NetworkInfo getNetworkInfo = asks systemDBusClient >>= liftIO . getNetworkInfoFromClient data ActiveConnectionInfo = ActiveConnectionInfo- { activeConnectionPath :: ObjectPath- , activeConnectionType :: Text- , activeConnectionId :: Maybe Text- , activeConnectionSpecificObject :: Maybe ObjectPath- } deriving (Eq, Show)+ { activeConnectionPath :: ObjectPath,+ activeConnectionType :: Text,+ activeConnectionId :: Maybe Text,+ activeConnectionSpecificObject :: Maybe ObjectPath+ }+ deriving (Eq, Show) +-- | Query network information from the provided NetworkManager DBus client. getNetworkInfoFromClient :: Client -> IO NetworkInfo getNetworkInfoFromClient client = do nmPropsResult <- getProperties client nmObjectPath nmInterfaceName@@ -378,23 +413,25 @@ case best of Nothing ->- return networkUnknownInfo- { networkState = NetworkDisconnected- , networkWirelessEnabled = wirelessEnabled- }+ return+ networkUnknownInfo+ { networkState = NetworkDisconnected,+ networkWirelessEnabled = wirelessEnabled+ } Just ac -> do (ssid, strength) <- if activeConnectionType ac == "802-11-wireless" then getAccessPointInfo client (activeConnectionSpecificObject ac) else return (Nothing, Nothing)- return networkUnknownInfo- { networkState = NetworkConnected- , networkType = Just $ toNetworkType (activeConnectionType ac)- , networkSsid = ssid- , networkStrength = strength- , networkConnectionId = activeConnectionId ac- , networkWirelessEnabled = wirelessEnabled- }+ return+ networkUnknownInfo+ { networkState = NetworkConnected,+ networkType = Just $ toNetworkType (activeConnectionType ac),+ networkSsid = ssid,+ networkStrength = strength,+ networkConnectionId = activeConnectionId ac,+ networkWirelessEnabled = wirelessEnabled+ } getActiveConnectionInfo :: Client -> ObjectPath -> IO ActiveConnectionInfo getActiveConnectionInfo client path = do@@ -402,38 +439,39 @@ case connPropsResult of Left err -> do wifiLogF DEBUG "Failed to read active connection %s" err- return ActiveConnectionInfo- { activeConnectionPath = path- , activeConnectionType = ""- , activeConnectionId = Nothing- , activeConnectionSpecificObject = Nothing- }+ return+ ActiveConnectionInfo+ { activeConnectionPath = path,+ activeConnectionType = "",+ activeConnectionId = Nothing,+ activeConnectionSpecificObject = Nothing+ } Right connProps -> do let connType = fromMaybe "" (readDictMaybe connProps "Type" :: Maybe Text) connId = readDictMaybe connProps "Id" :: Maybe Text specificObject = readDictMaybe connProps "SpecificObject" :: Maybe ObjectPath- return ActiveConnectionInfo- { activeConnectionPath = path- , activeConnectionType = connType- , activeConnectionId = connId- , activeConnectionSpecificObject = specificObject- }+ return+ ActiveConnectionInfo+ { activeConnectionPath = path,+ activeConnectionType = connType,+ activeConnectionId = connId,+ activeConnectionSpecificObject = specificObject+ } pickBestActiveConnection :: [ActiveConnectionInfo] -> Maybe ActiveConnectionInfo pickBestActiveConnection [] = Nothing pickBestActiveConnection infos =- let- rank t- | t == "802-3-ethernet" = 0 :: Int- | t == "802-11-wireless" = 1- | t == "vpn" = 2- | T.null t = 99- | otherwise = 50- in Just $- minimumBy- (\a b -> compare (rank (activeConnectionType a)) (rank (activeConnectionType b)))- infos+ let rank t+ | t == "802-3-ethernet" = 0 :: Int+ | t == "802-11-wireless" = 1+ | t == "vpn" = 2+ | T.null t = 99+ | otherwise = 50+ in Just $+ minimumBy+ (\a b -> compare (rank (activeConnectionType a)) (rank (activeConnectionType b)))+ infos toNetworkType :: Text -> NetworkType toNetworkType t
src/System/Taffybar/Information/PowerProfiles.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.PowerProfiles -- Copyright : (c) Ivan A. Malison@@ -11,38 +15,38 @@ -- -- This module provides information about the current power profile using -- the power-profiles-daemon DBus API (net.hadess.PowerProfiles).------------------------------------------------------------------------------ module System.Taffybar.Information.PowerProfiles- ( PowerProfile(..)- , PowerProfileInfo(..)- , getPowerProfileInfo- , getPowerProfileInfoFromClient- , getPowerProfileInfoChan- , getPowerProfileInfoState- , cycleProfile- , setProfile- , powerProfileToString- , stringToPowerProfile- ) where+ ( PowerProfile (..),+ PowerProfileInfo (..),+ getPowerProfileInfo,+ getPowerProfileInfoFromClient,+ getPowerProfileInfoChan,+ getPowerProfileInfoState,+ cycleProfile,+ setProfile,+ powerProfileToString,+ stringToPowerProfile,+ )+where -import Control.Concurrent.MVar-import Control.Concurrent.STM.TChan-import Control.Monad.IO.Class-import Control.Monad.STM (atomically)-import Control.Monad.Trans.Class-import Control.Monad.Trans.Except-import Control.Monad.Trans.Reader-import DBus-import DBus.Client-import DBus.Internal.Types (Serial(..))+import Control.Concurrent.MVar+import Control.Concurrent.STM.TChan+import Control.Monad.IO.Class+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Except+import Control.Monad.Trans.Reader+import DBus+import DBus.Client+import DBus.Internal.Types (Serial (..)) import qualified DBus.TH as DBus-import Data.Map (Map)+import Data.Map (Map) import qualified Data.Map as M-import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)-import Data.Text (Text)-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.Util (logPrintF, maybeToEither)+import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)+import Data.Text (Text)+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Util (logPrintF, maybeToEither) -- | Power profile modes supported by power-profiles-daemon. data PowerProfile@@ -53,10 +57,11 @@ -- | Information about the current power profile state. data PowerProfileInfo = PowerProfileInfo- { currentProfile :: PowerProfile- , availableProfiles :: [PowerProfile]- , performanceDegraded :: Maybe Text- } deriving (Eq, Show)+ { currentProfile :: PowerProfile,+ availableProfiles :: [PowerProfile],+ performanceDegraded :: Maybe Text+ }+ deriving (Eq, Show) -- | The DBus bus name for power-profiles-daemon. powerProfilesBusName :: BusName@@ -91,28 +96,34 @@ -- | Default info when power-profiles-daemon is unavailable. unknownProfileInfo :: PowerProfileInfo-unknownProfileInfo = PowerProfileInfo- { currentProfile = Balanced- , availableProfiles = []- , performanceDegraded = Nothing- }+unknownProfileInfo =+ PowerProfileInfo+ { currentProfile = Balanced,+ availableProfiles = [],+ performanceDegraded = Nothing+ } -- XXX: Remove this once it is exposed in haskell-dbus dummyMethodError :: MethodError dummyMethodError = methodError (Serial 1) $ errorName_ "org.ClientTypeMismatch" -readDictMaybe :: IsVariant a => Map Text Variant -> Text -> Maybe a+readDictMaybe :: (IsVariant a) => Map Text Variant -> Text -> Maybe a readDictMaybe dict key = M.lookup key dict >>= fromVariant -getProperties- :: Client- -> IO (Either MethodError (Map Text Variant))+getProperties ::+ Client ->+ IO (Either MethodError (Map Text Variant)) getProperties client = runExceptT $ do- reply <- ExceptT $ getAllProperties client $- (methodCall powerProfilesObjectPath powerProfilesInterfaceName "FakeMethod")- { methodCallDestination = Just powerProfilesBusName }- ExceptT $ return $ maybeToEither dummyMethodError $- listToMaybe (methodReturnBody reply) >>= fromVariant+ reply <-+ ExceptT $+ getAllProperties client $+ (methodCall powerProfilesObjectPath powerProfilesInterfaceName "FakeMethod")+ { methodCallDestination = Just powerProfilesBusName+ }+ ExceptT $+ return $+ maybeToEither dummyMethodError $+ listToMaybe (methodReturnBody reply) >>= fromVariant -- | Get current power profile info using the system DBus client from Context. getPowerProfileInfo :: TaffyIO PowerProfileInfo@@ -140,20 +151,24 @@ availableProfs = maybe [] (mapMaybe parseProfile) profilesArray currentProf = fromMaybe Balanced (activeProfileStr >>= stringToPowerProfile) - return PowerProfileInfo- { currentProfile = currentProf- , availableProfiles = availableProfs- , performanceDegraded = if degraded == Just "" then Nothing else degraded- }+ return+ PowerProfileInfo+ { currentProfile = currentProf,+ availableProfiles = availableProfs,+ performanceDegraded = if degraded == Just "" then Nothing else degraded+ } -- | Set the active power profile. setProfile :: Client -> PowerProfile -> IO (Either MethodError ()) setProfile client profile = do let profileStr = powerProfileToString profile- result <- setProperty client- (methodCall powerProfilesObjectPath powerProfilesInterfaceName "ActiveProfile")- { methodCallDestination = Just powerProfilesBusName }- (toVariant profileStr)+ result <-+ setProperty+ client+ (methodCall powerProfilesObjectPath powerProfilesInterfaceName "ActiveProfile")+ { methodCallDestination = Just powerProfilesBusName+ }+ (toVariant profileStr) return $ case result of Left err -> Left err Right _ -> Right ()@@ -168,12 +183,12 @@ PowerSaver -> Balanced Balanced -> Performance Performance -> PowerSaver- in setProfile client nextProfile+ in setProfile client nextProfile -- State management for monitoring -newtype PowerProfileInfoChanVar =- PowerProfileInfoChanVar (TChan PowerProfileInfo, MVar PowerProfileInfo)+newtype PowerProfileInfoChanVar+ = PowerProfileInfoChanVar (TChan PowerProfileInfo, MVar PowerProfileInfo) -- | Get the current power profile info state. getPowerProfileInfoState :: TaffyIO PowerProfileInfo@@ -203,22 +218,24 @@ updateInfo return (chan, infoVar) -registerForPowerProfilesPropertiesChanged- :: (Signal -> String -> Map String Variant -> [String] -> IO ())- -> ReaderT Context IO SignalHandler+registerForPowerProfilesPropertiesChanged ::+ (Signal -> String -> Map String Variant -> [String] -> IO ()) ->+ ReaderT Context IO SignalHandler registerForPowerProfilesPropertiesChanged signalHandler = do client <- asks systemDBusClient- lift $ DBus.registerForPropertiesChanged- client- matchAny { matchInterface = Just powerProfilesInterfaceName- , matchPath = Just powerProfilesObjectPath- }- signalHandler+ lift $+ DBus.registerForPropertiesChanged+ client+ matchAny+ { matchInterface = Just powerProfilesInterfaceName,+ matchPath = Just powerProfilesObjectPath+ }+ signalHandler -updatePowerProfileInfo- :: TChan PowerProfileInfo- -> MVar PowerProfileInfo- -> TaffyIO ()+updatePowerProfileInfo ::+ TChan PowerProfileInfo ->+ MVar PowerProfileInfo ->+ TaffyIO () updatePowerProfileInfo chan var = do info <- getPowerProfileInfo lift $ do
src/System/Taffybar/Information/Privacy.hs view
@@ -4,6 +4,9 @@ {-# LANGUAGE ScopedTypeVariables #-} -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Privacy -- Copyright : (c) Ivan A. Malison@@ -16,87 +19,99 @@ -- PipeWire-based privacy monitoring for microphone, camera, and screen sharing. -- -- This module uses @pw-dump@ to detect active audio/video streams.---------------------------------------------------------------------------------- module System.Taffybar.Information.Privacy ( -- * Data types- PrivacyInfo(..)- , PrivacyNode(..)- , NodeType(..)+ PrivacyInfo (..),+ PrivacyNode (..),+ NodeType (..),+ -- * Query functions- , getPrivacyInfo+ getPrivacyInfo,+ -- * Channel-based monitoring- , getPrivacyInfoChan- , getPrivacyInfoState+ getPrivacyInfoChan,+ getPrivacyInfoState,+ -- * Configuration- , PrivacyConfig(..)- , defaultPrivacyConfig- ) where+ PrivacyConfig (..),+ defaultPrivacyConfig,+ )+where -import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.MVar import Control.Concurrent.STM.TChan import Control.Exception (SomeException, catch)-import Control.Monad (forever)+import Control.Monad (void) import Control.Monad.IO.Class (liftIO) import Control.Monad.STM (atomically) import Data.Aeson- ( FromJSON(..)- , (.:)- , (.:?)- , withObject+ ( FromJSON (..),+ withObject,+ (.:),+ (.:?), ) import qualified Data.Aeson as Aeson import qualified Data.ByteString.Lazy as BL-import Data.Default (Default(..))-import qualified Data.Text.Encoding as TE+import Data.Default (Default (..)) import Data.List (nubBy) import Data.Maybe (fromMaybe, mapMaybe) import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Encoding as TE import GHC.Generics (Generic)-import System.Log.Logger (Priority(..))+import System.Log.Logger (Priority (..)) import System.Taffybar.Context (TaffyIO, getStateDefault)+import System.Taffybar.Information.Wakeup (taffyForeverWithDelay) import System.Taffybar.Util (logPrintF, runCommand) -- | Type of privacy-relevant node. data NodeType- = AudioInput -- ^ Microphone / audio capture- | AudioOutput -- ^ Audio playback (less privacy-sensitive, but useful)- | VideoInput -- ^ Camera / screen capture+ = -- | Microphone / audio capture+ AudioInput+ | -- | Audio playback (less privacy-sensitive, but useful)+ AudioOutput+ | -- | Camera / screen capture+ VideoInput deriving (Eq, Show, Generic, Ord) -- | Information about an active privacy-relevant node. data PrivacyNode = PrivacyNode- { nodeType :: NodeType- , appName :: Text- , appIcon :: Maybe Text- , nodeName :: Text- , isMonitor :: Bool- } deriving (Eq, Show, Generic)+ { nodeType :: NodeType,+ appName :: Text,+ appIcon :: Maybe Text,+ nodeName :: Text,+ isMonitor :: Bool+ }+ deriving (Eq, Show, Generic) -- | Aggregated privacy information. newtype PrivacyInfo = PrivacyInfo { activeNodes :: [PrivacyNode]- } deriving (Eq, Show, Generic)+ }+ deriving (Eq, Show, Generic) -- | Configuration for the privacy monitor. data PrivacyConfig = PrivacyConfig- { privacyPollingInterval :: Double -- ^ Polling interval in seconds- , privacyPwDumpPath :: FilePath -- ^ Path to pw-dump command- , privacyIgnoreMonitors :: Bool -- ^ Whether to ignore monitor streams- , privacyIgnoreAudioOutput :: Bool -- ^ Whether to ignore audio output streams- } deriving (Eq, Show, Generic)+ { -- | Polling interval in seconds+ privacyPollingInterval :: Double,+ -- | Path to pw-dump command+ privacyPwDumpPath :: FilePath,+ -- | Whether to ignore monitor streams+ privacyIgnoreMonitors :: Bool,+ -- | Whether to ignore audio output streams+ privacyIgnoreAudioOutput :: Bool+ }+ deriving (Eq, Show, Generic) -- | Default privacy configuration. defaultPrivacyConfig :: PrivacyConfig-defaultPrivacyConfig = PrivacyConfig- { privacyPollingInterval = 2.0- , privacyPwDumpPath = "pw-dump"- , privacyIgnoreMonitors = True- , privacyIgnoreAudioOutput = True- }+defaultPrivacyConfig =+ PrivacyConfig+ { privacyPollingInterval = 2.0,+ privacyPwDumpPath = "pw-dump",+ privacyIgnoreMonitors = True,+ privacyIgnoreAudioOutput = True+ } instance Default PrivacyConfig where def = defaultPrivacyConfig@@ -104,51 +119,57 @@ privacyLogPath :: String privacyLogPath = "System.Taffybar.Information.Privacy" -privacyLogF :: Show t => Priority -> String -> t -> IO ()+privacyLogF :: (Show t) => Priority -> String -> t -> IO () privacyLogF = logPrintF privacyLogPath -- | Internal representation of a PipeWire object from pw-dump. data PwObject = PwObject- { pwId :: Int- , pwType :: Text- , pwInfo :: Maybe PwInfo- } deriving (Eq, Show, Generic)+ { pwId :: Int,+ pwType :: Text,+ pwInfo :: Maybe PwInfo+ }+ deriving (Eq, Show, Generic) data PwInfo = PwInfo- { pwState :: Maybe Text- , pwProps :: Maybe PwProps- } deriving (Eq, Show, Generic)+ { pwState :: Maybe Text,+ pwProps :: Maybe PwProps+ }+ deriving (Eq, Show, Generic) data PwProps = PwProps- { propMediaClass :: Maybe Text- , propMediaName :: Maybe Text- , propNodeName :: Maybe Text- , propAppName :: Maybe Text- , propAppIconName :: Maybe Text- , propPortalAppId :: Maybe Text- , propStreamMonitor :: Maybe Text- } deriving (Eq, Show, Generic)+ { propMediaClass :: Maybe Text,+ propMediaName :: Maybe Text,+ propNodeName :: Maybe Text,+ propAppName :: Maybe Text,+ propAppIconName :: Maybe Text,+ propPortalAppId :: Maybe Text,+ propStreamMonitor :: Maybe Text+ }+ deriving (Eq, Show, Generic) instance FromJSON PwObject where- parseJSON = withObject "PwObject" $ \v -> PwObject- <$> v .: "id"- <*> v .: "type"- <*> v .:? "info"+ parseJSON = withObject "PwObject" $ \v ->+ PwObject+ <$> v .: "id"+ <*> v .: "type"+ <*> v .:? "info" instance FromJSON PwInfo where- parseJSON = withObject "PwInfo" $ \v -> PwInfo- <$> v .:? "state"- <*> v .:? "props"+ parseJSON = withObject "PwInfo" $ \v ->+ PwInfo+ <$> v .:? "state"+ <*> v .:? "props" instance FromJSON PwProps where- parseJSON = withObject "PwProps" $ \v -> PwProps- <$> v .:? "media.class"- <*> v .:? "media.name"- <*> v .:? "node.name"- <*> v .:? "application.name"- <*> v .:? "application.icon-name"- <*> v .:? "pipewire.access.portal.app_id"- <*> v .:? "stream.monitor"+ parseJSON = withObject "PwProps" $ \v ->+ PwProps+ <$> v .:? "media.class"+ <*> v .:? "media.name"+ <*> v .:? "node.name"+ <*> v .:? "application.name"+ <*> v .:? "application.icon-name"+ <*> v .:? "pipewire.access.portal.app_id"+ <*> v .:? "stream.monitor" -- | Get current privacy information by running pw-dump. getPrivacyInfo :: PrivacyConfig -> IO PrivacyInfo@@ -193,29 +214,32 @@ nType <- classToNodeType mediaClass -- Get application name (try multiple sources)- let name = fromMaybe "Unknown" $- propAppName props- <|> propPortalAppId props- <|> propNodeName props- <|> propMediaName props+ let name =+ fromMaybe "Unknown" $+ propAppName props+ <|> propPortalAppId props+ <|> propNodeName props+ <|> propMediaName props -- Get icon name- icon = propAppIconName props- <|> propPortalAppId props- <|> propAppName props+ icon =+ propAppIconName props+ <|> propPortalAppId props+ <|> propAppName props -- Check if it's a monitor stream monitor = propStreamMonitor props == Just "true" nName = fromMaybe "" $ propNodeName props - Just PrivacyNode- { nodeType = nType- , appName = name- , appIcon = icon- , nodeName = nName- , isMonitor = monitor- }+ Just+ PrivacyNode+ { nodeType = nType,+ appName = name,+ appIcon = icon,+ nodeName = nName,+ isMonitor = monitor+ } where (<|>) :: Maybe a -> Maybe a -> Maybe a (<|>) ma mb = case ma of@@ -243,8 +267,9 @@ | otherwise = True -- | State for the privacy info channel.-newtype PrivacyInfoChanVar = PrivacyInfoChanVar- (TChan PrivacyInfo, MVar PrivacyInfo)+newtype PrivacyInfoChanVar+ = PrivacyInfoChanVar+ (TChan PrivacyInfo, MVar PrivacyInfo) -- | Get a broadcast channel for privacy info updates. --@@ -264,35 +289,22 @@ getPrivacyInfoChanVar :: PrivacyConfig -> TaffyIO PrivacyInfoChanVar getPrivacyInfoChanVar config = getStateDefault $ do- liftIO $ do- chan <- newBroadcastTChanIO- var <- newMVar (PrivacyInfo [])- _ <- forkIO $ monitorPrivacyInfo config chan var- pure $ PrivacyInfoChanVar (chan, var)+ chan <- liftIO newBroadcastTChanIO+ var <- liftIO $ newMVar (PrivacyInfo [])+ let intervalSeconds :: Double+ intervalSeconds = max 0.1 (privacyPollingInterval config)+ liftIO $ refreshPrivacyInfo config chan var+ void $ taffyForeverWithDelay intervalSeconds (liftIO $ refreshPrivacyInfo config chan var)+ pure $ PrivacyInfoChanVar (chan, var) -monitorPrivacyInfo ::+refreshPrivacyInfo :: PrivacyConfig -> TChan PrivacyInfo -> MVar PrivacyInfo -> IO ()-monitorPrivacyInfo config chan var = do- let- intervalMicros :: Int- intervalMicros = max 100000 (floor (privacyPollingInterval config * 1000000))-- writeInfo info = do- _ <- swapMVar var info- atomically $ writeTChan chan info-- refresh = do- info <- catch (getPrivacyInfo config) $ \(e :: SomeException) -> do- privacyLogF WARNING "Privacy info refresh failed: %s" e- return $ PrivacyInfo []- writeInfo info-- -- Initial refresh- refresh- -- Polling loop- forever $ do- threadDelay intervalMicros- refresh+refreshPrivacyInfo config chan var = do+ info <- catch (getPrivacyInfo config) $ \(e :: SomeException) -> do+ privacyLogF WARNING "Privacy info refresh failed: %s" e+ return $ PrivacyInfo []+ _ <- swapMVar var info+ atomically $ writeTChan chan info
src/System/Taffybar/Information/PulseAudio.hs view
@@ -41,7 +41,7 @@ where import Control.Applicative ((<|>))-import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent (forkIO) import Control.Concurrent.MVar import Control.Concurrent.STM.TChan import Control.Exception (SomeException, finally, throwIO, try)@@ -74,6 +74,7 @@ paServerLookupInterfaceName, paServerLookupObjectPath, )+import System.Taffybar.Information.Wakeup (getWakeupChannelForDelay) import System.Taffybar.Util (logPrintF, maybeToEither) -- | PulseAudio information for the default (or provided) sink.@@ -128,34 +129,40 @@ -- This is the closest analogue to 'System.Taffybar.Information.Crypto', where -- the type parameter provides a stable key for 'getStateDefault' so multiple -- parameterized versions can coexist in 'Context'.-getPulseAudioInfoChanFor :: forall a. KnownSymbol a => TaffyIO (TChan (Maybe PulseAudioInfo))+getPulseAudioInfoChanFor :: forall a. (KnownSymbol a) => TaffyIO (TChan (Maybe PulseAudioInfo)) getPulseAudioInfoChanFor = do PulseAudioInfoChanVar (chan, _) <- getPulseAudioInfoChanVarFor @a pure chan -- | Read the current PulseAudio info state for a sink spec given as a -- type-level string.-getPulseAudioInfoStateFor :: forall a. KnownSymbol a => TaffyIO (Maybe PulseAudioInfo)+getPulseAudioInfoStateFor :: forall a. (KnownSymbol a) => TaffyIO (Maybe PulseAudioInfo) getPulseAudioInfoStateFor = do PulseAudioInfoChanVar (_, var) <- getPulseAudioInfoChanVarFor @a liftIO $ readMVar var -getPulseAudioInfoChanVarFor :: forall a. KnownSymbol a => TaffyIO (PulseAudioInfoChanVar a)+getPulseAudioInfoChanVarFor :: forall a. (KnownSymbol a) => TaffyIO (PulseAudioInfoChanVar a) getPulseAudioInfoChanVarFor = getStateDefault $ do let sinkSpec = symbolVal (Proxy @a)+ retryWakeupChan <- getWakeupChannelForDelay (5 :: Double)+ blockWakeupChan <- getWakeupChannelForDelay (1000 :: Double)+ retryOurWakeupChan <- liftIO $ atomically $ dupTChan retryWakeupChan+ blockOurWakeupChan <- liftIO $ atomically $ dupTChan blockWakeupChan liftIO $ do chan <- newBroadcastTChanIO var <- newMVar Nothing- _ <- forkIO $ monitorPulseAudioInfo sinkSpec chan var+ _ <- forkIO $ monitorPulseAudioInfo sinkSpec retryOurWakeupChan blockOurWakeupChan chan var pure $ PulseAudioInfoChanVar (chan, var) monitorPulseAudioInfo :: String ->+ TChan a ->+ TChan b -> TChan (Maybe PulseAudioInfo) -> MVar (Maybe PulseAudioInfo) -> IO ()-monitorPulseAudioInfo sinkSpec chan var = do+monitorPulseAudioInfo sinkSpec retryWakeupChan blockWakeupChan chan var = do refreshLock <- newMVar () let writeInfo info = do _ <- swapMVar var info@@ -203,8 +210,8 @@ listenForSignal client signalName objects = do let callMsg = (methodCall paCorePath paCoreInterfaceName "ListenForSignal")- { methodCallDestination = Nothing- , methodCallBody = [toVariant signalName, toVariant objects]+ { methodCallDestination = Nothing,+ methodCallBody = [toVariant signalName, toVariant objects] } reply <- call client callMsg case reply of@@ -241,17 +248,17 @@ deviceSignalMatcher :: MemberName -> MatchRule deviceSignalMatcher memberName = matchAny- { matchPathNamespace = Just paCoreObjectPath- , matchInterface = Just paDeviceInterfaceName- , matchMember = Just memberName+ { matchPathNamespace = Just paCoreObjectPath,+ matchInterface = Just paDeviceInterfaceName,+ matchMember = Just memberName } coreSignalMatcher :: MemberName -> MatchRule coreSignalMatcher memberName = matchAny- { matchPath = Just paCoreObjectPath- , matchInterface = Just paCoreInterfaceName- , matchMember = Just memberName+ { matchPath = Just paCoreObjectPath,+ matchInterface = Just paCoreInterfaceName,+ matchMember = Just memberName } loop = do@@ -260,7 +267,7 @@ Nothing -> do writeInfo Nothing -- No polling; just retry connection periodically.- threadDelay 5000000+ void $ atomically $ readTChan retryWakeupChan loop Just client -> do let runWithClient = do@@ -326,8 +333,7 @@ -- Avoid BlockedIndefinitelyOnMVar exceptions from the "empty mvar trick". blockForever =- forever $ threadDelay 1000000000 -- ~1000s; interruptible by async exceptions.-+ forever $ void $ atomically $ readTChan blockWakeupChan -- ~1000s; interruptible by async exceptions. loop -- | Query volume and mute state for the provided sink name.@@ -337,6 +343,8 @@ result <- withPulseAudio (`getPulseAudioInfoFromClient` sinkSpec) return $ join result +-- | Query volume/mute info for a sink using an existing PulseAudio DBus+-- client. getPulseAudioInfoFromClient :: Client -> String -> IO (Maybe PulseAudioInfo) getPulseAudioInfoFromClient client sinkSpec = do sinkPath <- resolveSinkPath client sinkSpec@@ -392,6 +400,7 @@ nullObjectPath :: ObjectPath nullObjectPath = objectPath_ "/" +-- | Connect to the PulseAudio DBus server (if available). connectPulseAudio :: IO (Maybe Client) connectPulseAudio = do addressString <- getPulseAudioAddress
src/System/Taffybar/Information/SafeX11.hs view
@@ -1,8 +1,16 @@-{-# LANGUAGE MultiParamTypeClasses, StandaloneDeriving, FlexibleInstances,- InterruptibleFFI, ExistentialQuantification, DeriveDataTypeable #-}+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE ExistentialQuantification #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE InterruptibleFFI #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE StandaloneDeriving #-} {-# OPTIONS_GHC -Wno-orphans #-}+{-# OPTIONS_HADDOCK hide #-} -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.SafeX11 -- Copyright : (c) Ivan A. Malison@@ -11,35 +19,39 @@ -- Maintainer : Ivan A. Malison -- Stability : unstable -- Portability : unportable------------------------------------------------------------------------------ module System.Taffybar.Information.SafeX11- ( module Graphics.X11.Xlib- , module Graphics.X11.Xlib.Extras- , getWMHints- , getWindowProperty8- , getWindowProperty16- , getWindowProperty32- , postX11RequestSyncDef- , rawGetWindowPropertyBytes- , safeGetGeometry+ ( module Graphics.X11.Xlib,+ module Graphics.X11.Xlib.Extras,+ getWMHints,+ getWindowProperty8,+ getWindowProperty16,+ getWindowProperty32,+ postX11RequestSyncDef,+ rawGetWindowPropertyBytes,+ safeGetGeometry, )- where+where import Control.Concurrent import Control.Exception import Control.Monad import Control.Monad.Trans.Class-import Control.Monad.Trans.Maybe (MaybeT(..))+import Control.Monad.Trans.Maybe (MaybeT (..)) import Data.Either.Combinators import Data.Typeable import Foreign hiding (void) import Foreign.C.Types import GHC.ForeignPtr import Graphics.X11.Xlib-import Graphics.X11.Xlib.Extras- hiding (rawGetWindowProperty, getWindowProperty8,- getWindowProperty16, getWindowProperty32,- xGetWMHints, getWMHints, refreshKeyboardMapping)+import Graphics.X11.Xlib.Extras hiding+ ( getWMHints,+ getWindowProperty16,+ getWindowProperty32,+ getWindowProperty8,+ rawGetWindowProperty,+ refreshKeyboardMapping,+ xGetWMHints,+ ) import System.IO.Unsafe import System.Log.Logger import System.Timeout@@ -49,46 +61,48 @@ logHere = logM "System.Taffybar.Information.SafeX11" foreign import ccall safe "XlibExtras.h XGetWMHints"- safeXGetWMHints :: Display -> Window -> IO (Ptr WMHints)+ safeXGetWMHints :: Display -> Window -> IO (Ptr WMHints) foreign import ccall interruptible "XlibExtras.h XGetWindowProperty"- safeXGetWindowProperty ::- Display ->- Window ->- Atom ->- CLong ->- CLong ->- Bool ->- Atom ->- Ptr Atom ->- Ptr CInt ->- Ptr CULong ->- Ptr CULong ->- Ptr (Ptr CUChar) -> IO Status+ safeXGetWindowProperty ::+ Display ->+ Window ->+ Atom ->+ CLong ->+ CLong ->+ Bool ->+ Atom ->+ Ptr Atom ->+ Ptr CInt ->+ Ptr CULong ->+ Ptr CULong ->+ Ptr (Ptr CUChar) ->+ IO Status -rawGetWindowPropertyBytes- :: Storable a- => Int -> Display -> Atom -> Window -> IO (Maybe (ForeignPtr a, Int))+rawGetWindowPropertyBytes ::+ (Storable a) =>+ Int -> Display -> Atom -> Window -> IO (Maybe (ForeignPtr a, Int)) rawGetWindowPropertyBytes bits d atom w = alloca $ \actual_type_return -> alloca $ \actual_format_return -> alloca $ \nitems_return -> alloca $ \bytes_after_return -> alloca $ \prop_return -> do- ret <- postX11RequestSync $- safeXGetWindowProperty- d- w- atom- 0- 0xFFFFFFFF- False- anyPropertyType- actual_type_return- actual_format_return- nitems_return- bytes_after_return- prop_return+ ret <-+ postX11RequestSync $+ safeXGetWindowProperty+ d+ w+ atom+ 0+ 0xFFFFFFFF+ False+ anyPropertyType+ actual_type_return+ actual_format_return+ nitems_return+ bytes_after_return+ prop_return if fromRight (-1) ret /= 0 then return Nothing else do@@ -101,16 +115,16 @@ | actual_format == 0 = return Nothing -- Property not found | actual_format /= bits = xFree prop_ptr >> return Nothing | otherwise = do- ptr <- newConcForeignPtr (castPtr prop_ptr) (void $ xFree prop_ptr)- return $ Just (ptr, nitems)+ ptr <- newConcForeignPtr (castPtr prop_ptr) (void $ xFree prop_ptr)+ return $ Just (ptr, nitems) data SafeX11Exception = SafeX11Exception deriving (Show, Eq, Typeable) instance Exception SafeX11Exception data IORequest = forall a. IORequest- { ioAction :: IO a- , ioResponse :: Chan (Either SafeX11Exception a)+ { ioAction :: IO a,+ ioResponse :: Chan (Either SafeX11Exception a) } {-# NOINLINE requestQueue #-}@@ -123,21 +137,23 @@ withErrorHandler :: XErrorHandler -> IO a -> IO a withErrorHandler new_handler action = do- handler <- mkXErrorHandler (\d e -> new_handler d e >> return 0)- original <- _xSetErrorHandler handler- res <- action- _ <- _xSetErrorHandler original- return res+ handler <- mkXErrorHandler (\d e -> new_handler d e >> return 0)+ original <- _xSetErrorHandler handler+ res <- action+ _ <- _xSetErrorHandler original+ return res deriving instance Show ErrorEvent startHandlingX11Requests :: IO () startHandlingX11Requests = withErrorHandler handleError handleX11Requests- where handleError _ xerrptr = do- ee <- getErrorEvent xerrptr- logHere WARNING $- printf "Handling X11 error with error handler: %s" $ show ee+ where+ handleError _ xerrptr = do+ ee <- getErrorEvent xerrptr+ logHere WARNING $+ printf "Handling X11 error with error handler: %s" $+ show ee handleX11Requests :: IO () handleX11Requests = do@@ -146,10 +162,12 @@ res <- catch (maybe (Left SafeX11Exception) Right <$> timeout 500000 action)- (\e -> do- logHere WARNING $ printf "Handling X11 error with catch: %s" $- show (e :: IOException)- return $ Left SafeX11Exception)+ ( \e -> do+ logHere WARNING $+ printf "Handling X11 error with catch: %s" $+ show (e :: IOException)+ return $ Left SafeX11Exception+ ) writeChan responseChannel res handleX11Requests return ()@@ -172,8 +190,8 @@ fromRight def <$> postX11RequestSync action rawGetWindowProperty ::- Storable a- => Int -> Display -> Atom -> Window -> IO (Maybe [a])+ (Storable a) =>+ Int -> Display -> Atom -> Window -> IO (Maybe [a]) rawGetWindowProperty bits d atom w = runMaybeT $ do (ptr, count) <- MaybeT $ rawGetWindowPropertyBytes bits d atom w@@ -190,39 +208,51 @@ getWMHints :: Display -> Window -> IO WMHints getWMHints dpy w = do- p <- safeXGetWMHints dpy w- if p == nullPtr- then return $ WMHints 0 False 0 0 0 0 0 0 0- else do x <- peek p; _ <- xFree p; return x+ p <- safeXGetWMHints dpy w+ if p == nullPtr+ then return $ WMHints 0 False 0 0 0 0 0 0 0+ else do x <- peek p; _ <- xFree p; return x -safeGetGeometry :: Display -> Drawable ->- IO (Window, Position, Position, Dimension, Dimension, Dimension, CInt)+safeGetGeometry ::+ Display ->+ Drawable ->+ IO (Window, Position, Position, Dimension, Dimension, Dimension, CInt) safeGetGeometry display d =- outParameters7 (throwIfZero "getGeometry") $- xGetGeometry display d+ outParameters7 (throwIfZero "getGeometry") $+ xGetGeometry display d -outParameters7 :: (Storable a, Storable b, Storable c, Storable d, Storable e, Storable f, Storable g) =>- (IO r -> IO ()) -> (Ptr a -> Ptr b -> Ptr c -> Ptr d -> Ptr e -> Ptr f -> Ptr g -> IO r) ->- IO (a,b,c,d,e,f,g)+outParameters7 ::+ (Storable a, Storable b, Storable c, Storable d, Storable e, Storable f, Storable g) =>+ (IO r -> IO ()) ->+ (Ptr a -> Ptr b -> Ptr c -> Ptr d -> Ptr e -> Ptr f -> Ptr g -> IO r) ->+ IO (a, b, c, d, e, f, g) outParameters7 check fn =- alloca $ \ a_return ->- alloca $ \ b_return ->- alloca $ \ c_return ->- alloca $ \ d_return ->- alloca $ \ e_return ->- alloca $ \ f_return ->- alloca $ \ g_return -> do- check (fn a_return b_return c_return d_return e_return f_return g_return)- a <- peek a_return- b <- peek b_return- c <- peek c_return- d <- peek d_return- e <- peek e_return- f <- peek f_return- g <- peek g_return- return (a,b,c,d,e,f,g)+ alloca $ \a_return ->+ alloca $ \b_return ->+ alloca $ \c_return ->+ alloca $ \d_return ->+ alloca $ \e_return ->+ alloca $ \f_return ->+ alloca $ \g_return -> do+ check (fn a_return b_return c_return d_return e_return f_return g_return)+ a <- peek a_return+ b <- peek b_return+ c <- peek c_return+ d <- peek d_return+ e <- peek e_return+ f <- peek f_return+ g <- peek g_return+ return (a, b, c, d, e, f, g) foreign import ccall safe "HsXlib.h XGetGeometry"- xGetGeometry :: Display -> Drawable ->- Ptr Window -> Ptr Position -> Ptr Position -> Ptr Dimension ->- Ptr Dimension -> Ptr Dimension -> Ptr CInt -> IO Status+ xGetGeometry ::+ Display ->+ Drawable ->+ Ptr Window ->+ Ptr Position ->+ Ptr Position ->+ Ptr Dimension ->+ Ptr Dimension ->+ Ptr Dimension ->+ Ptr CInt ->+ IO Status
src/System/Taffybar/Information/ScreenLock.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.ScreenLock -- Copyright : (c) Ivan A. Malison@@ -11,28 +14,28 @@ -- This module provides screen lock functionality as a thin wrapper around -- "System.Taffybar.Information.Inhibitor". It re-exports the inhibitor -- management functions and adds a 'lockScreen' action that spawns hyprlock.------------------------------------------------------------------------------ module System.Taffybar.Information.ScreenLock ( -- * Re-exports from Inhibitor- getInhibitorChan- , getInhibitorState- , toggleInhibitor+ getInhibitorChan,+ getInhibitorState,+ toggleInhibitor,+ -- * Screen Lock- , lockScreen- ) where+ lockScreen,+ )+where import Control.Monad (void) import Control.Monad.IO.Class import System.Process (spawnCommand)- import System.Taffybar.Information.Inhibitor- ( getInhibitorChan- , getInhibitorState- , toggleInhibitor+ ( getInhibitorChan,+ getInhibitorState,+ toggleInhibitor, ) -- | Lock the screen by spawning hyprlock. Output is redirected to -- @\/dev\/null@ to avoid flooding taffybar's log with hyprlock's -- verbose Wayland messages.-lockScreen :: MonadIO m => m ()+lockScreen :: (MonadIO m) => m () lockScreen = liftIO $ void $ spawnCommand "hyprlock >/dev/null 2>&1"
src/System/Taffybar/Information/StreamInfo.hs view
@@ -1,4 +1,7 @@ --------------------------------------------------------------------------------++--------------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.StreamInfo -- Copyright : (c) José A. Romero L.@@ -12,28 +15,26 @@ -- POSIX systems. Provides methods for applying a custom parsing function to the -- contents of the file and to calculate differentials across one or more values -- provided via the file.------------------------------------------------------------------------------------- module System.Taffybar.Information.StreamInfo- ( getParsedInfo- , getLoad- , getAccLoad- , getTransfer- ) where+ ( getParsedInfo,+ getLoad,+ getAccLoad,+ getTransfer,+ )+where -import Control.Concurrent ( threadDelay )+import Control.Concurrent (threadDelay) import Data.IORef-import Data.Maybe ( fromMaybe )+import Data.Maybe (fromMaybe) -- | Apply the given parser function to the file under the given path to produce -- a lookup map, then use the given selector as key to extract from it the -- desired value. getParsedInfo :: FilePath -> (String -> [(String, [a])]) -> String -> IO [a] getParsedInfo path parser selector = do- file <- readFile path- length file `seq` return ()- return (fromMaybe [] $ lookup selector $ parser file)+ file <- readFile path+ length file `seq` return ()+ return (fromMaybe [] $ lookup selector $ parser file) truncVal :: (RealFloat a) => a -> a truncVal v@@ -44,33 +45,34 @@ -- elements against their sum. toRatioList :: (Integral a, RealFloat b) => [a] -> [b] toRatioList deltas = map truncVal ratios- where total = fromIntegral $ sum deltas- ratios = map ((/total) . fromIntegral) deltas+ where+ total = fromIntegral $ sum deltas+ ratios = map ((/ total) . fromIntegral) deltas -- | Execute the given action twice with the given delay in-between and return -- the difference between the two samples. probe :: (Num a, RealFrac b) => IO [a] -> b -> IO [a] probe action delay = do- a <- action- threadDelay $ round (delay * 1e6)- b <- action- return $ zipWith (-) b a+ a <- action+ threadDelay $ round (delay * 1e6)+ b <- action+ return $ zipWith (-) b a -- | Execute the given action once and return the difference between the -- obtained sample and the one contained in the given IORef. accProbe :: (Num a) => IO [a] -> IORef [a] -> IO [a] accProbe action sample = do- a <- readIORef sample- b <- action- writeIORef sample b- return $ zipWith (-) b a+ a <- readIORef sample+ b <- action+ writeIORef sample b+ return $ zipWith (-) b a -- | Probe the given action and, interpreting the result as a variation in time, -- return the speed of change of its values. getTransfer :: (Integral a, RealFloat b) => b -> IO [a] -> IO [b] getTransfer interval action = do- deltas <- probe action interval- return $ map (truncVal . (/interval) . fromIntegral) deltas+ deltas <- probe action interval+ return $ map (truncVal . (/ interval) . fromIntegral) deltas -- | Probe the given action and return the relative variation of each of the -- obtained values against the whole, where the whole is calculated as the sum
src/System/Taffybar/Information/Systemd.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Systemd -- Copyright : (c) Ivan A. Malison@@ -12,64 +16,79 @@ -- This module provides information about systemd failed units using the -- org.freedesktop.systemd1 DBus interface. It monitors both the system bus -- (for system units) and the session bus (for user units).------------------------------------------------------------------------------ module System.Taffybar.Information.Systemd- ( SystemdState(..)- , SystemdInfo(..)- , getSystemdInfo- , getSystemdInfoFromClients- , getSystemdInfoChan- , getSystemdInfoState- , systemdUnknownInfo- ) where+ ( SystemdState (..),+ SystemdInfo (..),+ getSystemdInfo,+ getSystemdInfoFromClients,+ getSystemdInfoChan,+ getSystemdInfoState,+ systemdUnknownInfo,+ )+where -import Control.Concurrent-import Control.Concurrent.STM.TChan-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.STM (atomically)-import Control.Monad.Trans.Class-import Control.Monad.Trans.Reader-import DBus-import DBus.Client+import Control.Concurrent+import Control.Concurrent.STM.TChan+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Reader+import DBus+import DBus.Client import qualified DBus.TH as DBus-import Data.Maybe (fromMaybe)-import Data.Text (Text)+import Data.Maybe (fromMaybe)+import Data.Text (Text) import qualified Data.Text as T-import Data.Word (Word32)-import System.Log.Logger-import System.Taffybar.Context+import Data.Word (Word32)+import System.Log.Logger+import System.Taffybar.Context -- | The state of a systemd instance (system or user). data SystemdState- = SystemdRunning -- ^ All units are running properly- | SystemdDegraded -- ^ Some units have failed- | SystemdStarting -- ^ System is starting up- | SystemdStopping -- ^ System is shutting down- | SystemdMaintenance -- ^ System is in maintenance mode- | SystemdInitializing -- ^ System is initializing- | SystemdOther Text -- ^ Unknown state- | SystemdUnavailable -- ^ systemd is not available on this bus+ = -- | All units are running properly+ SystemdRunning+ | -- | Some units have failed+ SystemdDegraded+ | -- | System is starting up+ SystemdStarting+ | -- | System is shutting down+ SystemdStopping+ | -- | System is in maintenance mode+ SystemdMaintenance+ | -- | System is initializing+ SystemdInitializing+ | -- | Unknown state+ SystemdOther Text+ | -- | systemd is not available on this bus+ SystemdUnavailable deriving (Eq, Show) -- | Information about systemd failed units. data SystemdInfo = SystemdInfo- { systemState :: SystemdState -- ^ State of system systemd- , userState :: SystemdState -- ^ State of user systemd- , systemFailedCount :: Int -- ^ Number of failed system units- , userFailedCount :: Int -- ^ Number of failed user units- , totalFailedCount :: Int -- ^ Total failed units (system + user)- } deriving (Eq, Show)+ { -- | State of system systemd+ systemState :: SystemdState,+ -- | State of user systemd+ userState :: SystemdState,+ -- | Number of failed system units+ systemFailedCount :: Int,+ -- | Number of failed user units+ userFailedCount :: Int,+ -- | Total failed units (system + user)+ totalFailedCount :: Int+ }+ deriving (Eq, Show) -- | Default info when systemd is unknown/unavailable. systemdUnknownInfo :: SystemdInfo-systemdUnknownInfo = SystemdInfo- { systemState = SystemdUnavailable- , userState = SystemdUnavailable- , systemFailedCount = 0- , userFailedCount = 0- , totalFailedCount = 0- }+systemdUnknownInfo =+ SystemdInfo+ { systemState = SystemdUnavailable,+ userState = SystemdUnavailable,+ systemFailedCount = 0,+ userFailedCount = 0,+ totalFailedCount = 0+ } systemdLogPath :: String systemdLogPath = "System.Taffybar.Information.Systemd"@@ -88,13 +107,13 @@ parseSystemdState :: Text -> SystemdState parseSystemdState txt = case T.toLower txt of- "running" -> SystemdRunning- "degraded" -> SystemdDegraded- "starting" -> SystemdStarting- "stopping" -> SystemdStopping- "maintenance" -> SystemdMaintenance+ "running" -> SystemdRunning+ "degraded" -> SystemdDegraded+ "starting" -> SystemdStarting+ "stopping" -> SystemdStopping+ "maintenance" -> SystemdMaintenance "initializing" -> SystemdInitializing- _ -> SystemdOther txt+ _ -> SystemdOther txt -- | Newtype wrapper for the channel/var pair stored in Context. newtype SystemdInfoChanVar = SystemdInfoChanVar (TChan SystemdInfo, MVar SystemdInfo)@@ -143,20 +162,26 @@ signalCallback _ _ _ _ = runReaderT debouncedUpdate ctx -- Register for PropertiesChanged on system bus- _ <- lift $ DBus.registerForPropertiesChanged- systemClient- matchAny { matchInterface = Just systemdManagerInterface- , matchPath = Just systemdObjectPath- }- signalCallback+ _ <-+ lift $+ DBus.registerForPropertiesChanged+ systemClient+ matchAny+ { matchInterface = Just systemdManagerInterface,+ matchPath = Just systemdObjectPath+ }+ signalCallback -- Register for PropertiesChanged on session bus- _ <- lift $ DBus.registerForPropertiesChanged- sessionClient- matchAny { matchInterface = Just systemdManagerInterface- , matchPath = Just systemdObjectPath- }- signalCallback+ _ <-+ lift $+ DBus.registerForPropertiesChanged+ sessionClient+ matchAny+ { matchInterface = Just systemdManagerInterface,+ matchPath = Just systemdObjectPath+ }+ signalCallback -- Initial update doUpdate@@ -164,12 +189,12 @@ return (chan, infoVar) -- | Update the systemd info by querying both buses.-updateSystemdInfo- :: TChan SystemdInfo- -> MVar SystemdInfo- -> Client- -> Client- -> TaffyIO ()+updateSystemdInfo ::+ TChan SystemdInfo ->+ MVar SystemdInfo ->+ Client ->+ Client ->+ TaffyIO () updateSystemdInfo chan var systemClient sessionClient = do info <- liftIO $ getSystemdInfoFromClients systemClient sessionClient liftIO $ do@@ -188,13 +213,14 @@ getSystemdInfoFromClients systemClient sessionClient = do (sysState, sysFailed) <- getSystemdStateAndFailed systemClient "system" (usrState, usrFailed) <- getSystemdStateAndFailed sessionClient "user"- return SystemdInfo- { systemState = sysState- , userState = usrState- , systemFailedCount = sysFailed- , userFailedCount = usrFailed- , totalFailedCount = sysFailed + usrFailed- }+ return+ SystemdInfo+ { systemState = sysState,+ userState = usrState,+ systemFailedCount = sysFailed,+ userFailedCount = usrFailed,+ totalFailedCount = sysFailed + usrFailed+ } -- | Query a single systemd instance for its state and failed count. getSystemdStateAndFailed :: Client -> String -> IO (SystemdState, Int)@@ -206,9 +232,11 @@ "Failed to get " ++ busType ++ " systemd state: " ++ show err return (SystemdUnavailable, 0) Right stateVariant -> do- let state = maybe (SystemdOther "unknown")- parseSystemdState- (fromVariant stateVariant :: Maybe Text)+ let state =+ maybe+ (SystemdOther "unknown")+ parseSystemdState+ (fromVariant stateVariant :: Maybe Text) -- Only query failed count if degraded if state == SystemdDegraded then do@@ -226,21 +254,31 @@ -- | Get a property from the systemd1.Manager interface. getSystemdProperty :: Client -> MemberName -> IO (Either MethodError Variant) getSystemdProperty client propName = do- reply <- call client $ (methodCall systemdObjectPath- "org.freedesktop.DBus.Properties"- "Get")- { methodCallDestination = Just systemdBusName- , methodCallBody = [toVariant systemdManagerInterface, toVariant propName]- }+ reply <-+ call client $+ ( methodCall+ systemdObjectPath+ "org.freedesktop.DBus.Properties"+ "Get"+ )+ { methodCallDestination = Just systemdBusName,+ methodCallBody = [toVariant systemdManagerInterface, toVariant propName]+ } case reply of Left err -> return $ Left err Right ret -> case methodReturnBody ret of [v] -> case fromVariant v of Just inner -> return $ Right inner- Nothing -> return $ Left $ methodError- (methodReturnSerial ret)- (errorName_ "org.taffybar.Systemd.TypeMismatch")- _ -> return $ Left $ methodError- (methodReturnSerial ret)- (errorName_ "org.taffybar.Systemd.InvalidResponse")+ Nothing ->+ return $+ Left $+ methodError+ (methodReturnSerial ret)+ (errorName_ "org.taffybar.Systemd.TypeMismatch")+ _ ->+ return $+ Left $+ methodError+ (methodReturnSerial ret)+ (errorName_ "org.taffybar.Systemd.InvalidResponse")
src/System/Taffybar/Information/Temperature.hs view
@@ -1,4 +1,7 @@ --------------------------------------------------------------------------------++--------------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Temperature -- Copyright : (c) Ivan Malison@@ -10,21 +13,19 @@ -- -- This module provides functions to read system temperature information from -- Linux thermal zones and hwmon devices.------------------------------------------------------------------------------------- module System.Taffybar.Information.Temperature- ( TemperatureInfo(..)- , ThermalSensor(..)- , TemperatureUnit(..)- , discoverSensors- , readSensorTemperature- , readAllTemperatures- , readTemperaturesFrom- , convertTemperature- ) where+ ( TemperatureInfo (..),+ ThermalSensor (..),+ TemperatureUnit (..),+ discoverSensors,+ readSensorTemperature,+ readAllTemperatures,+ readTemperaturesFrom,+ convertTemperature,+ )+where -import Control.Exception (try, SomeException)+import Control.Exception (SomeException, try) import Control.Monad (forM) import Data.List (sortOn) import Data.Maybe (catMaybes, fromMaybe)@@ -38,16 +39,23 @@ -- | Information about a thermal sensor data ThermalSensor = ThermalSensor- { sensorName :: String -- ^ Human-readable sensor name- , sensorPath :: FilePath -- ^ Path to the temperature file- , sensorZone :: String -- ^ Zone or hwmon identifier- } deriving (Show, Eq)+ { -- | Human-readable sensor name+ sensorName :: String,+ -- | Path to the temperature file+ sensorPath :: FilePath,+ -- | Zone or hwmon identifier+ sensorZone :: String+ }+ deriving (Show, Eq) -- | Temperature reading from a sensor data TemperatureInfo = TemperatureInfo- { tempCelsius :: Double -- ^ Temperature in Celsius- , tempSensor :: ThermalSensor -- ^ The sensor this reading is from- } deriving (Show, Eq)+ { -- | Temperature in Celsius+ tempCelsius :: Double,+ -- | The sensor this reading is from+ tempSensor :: ThermalSensor+ }+ deriving (Show, Eq) -- | Convert temperature from Celsius to another unit convertTemperature :: TemperatureUnit -> Double -> Double@@ -80,11 +88,13 @@ if tempExists then do sensorType <- readFileSafe typePath- return $ Just ThermalSensor- { sensorName = fromMaybe zone sensorType- , sensorPath = tempPath- , sensorZone = zone- }+ return $+ Just+ ThermalSensor+ { sensorName = fromMaybe zone sensorType,+ sensorPath = tempPath,+ sensorZone = zone+ } else return Nothing return $ catMaybes sensors where@@ -116,11 +126,12 @@ (Just l, _) -> l (_, Just n) -> n ++ "_temp" ++ sensorId _ -> hwmon ++ "_temp" ++ sensorId- return ThermalSensor- { sensorName = name- , sensorPath = tempPath- , sensorZone = hwmon- }+ return+ ThermalSensor+ { sensorName = name,+ sensorPath = tempPath,+ sensorZone = hwmon+ } return $ concat sensorLists where isPrefixOf prefix str = take (length prefix) str == prefix@@ -128,12 +139,12 @@ isSuffixOf suffix str = let suffixLen = length suffix strLen = length str- in strLen >= suffixLen && drop (strLen - suffixLen) str == suffix+ in strLen >= suffixLen && drop (strLen - suffixLen) str == suffix -- Extract sensor number from "temp1_input" -> "1" extractSensorId file =- let withoutPrefix = drop 4 file -- remove "temp"- withoutSuffix = take (length withoutPrefix - 6) withoutPrefix -- remove "_input"- in withoutSuffix+ let withoutPrefix = drop 4 file -- remove "temp"+ withoutSuffix = take (length withoutPrefix - 6) withoutPrefix -- remove "_input"+ in withoutSuffix -- | Read temperature from a file, returns Nothing if file cannot be read readFileSafe :: FilePath -> IO (Maybe String)@@ -159,10 +170,13 @@ Right content -> case readMaybe (strip content) :: Maybe Integer of Nothing -> return Nothing- Just milliDegrees -> return $ Just TemperatureInfo- { tempCelsius = fromIntegral milliDegrees / 1000.0- , tempSensor = sensor- }+ Just milliDegrees ->+ return $+ Just+ TemperatureInfo+ { tempCelsius = fromIntegral milliDegrees / 1000.0,+ tempSensor = sensor+ } where strip = dropWhile (== ' ') . reverse . dropWhile (== '\n') . reverse . dropWhile (== ' ')
src/System/Taffybar/Information/Udev.hs view
@@ -8,21 +8,24 @@ -- 'GHC.Conc.threadWaitRead'. -- 3. Drain pending udev events via 'udev_monitor_receive_device'. module System.Taffybar.Information.Udev- ( UdevBacklightMonitor- , openBacklightMonitor- , closeBacklightMonitor- , backlightMonitorFd- , drainBacklightMonitor- ) where+ ( UdevBacklightMonitor,+ openBacklightMonitor,+ closeBacklightMonitor,+ backlightMonitorFd,+ drainBacklightMonitor,+ )+where -import Control.Monad (void, when) import Control.Exception (bracketOnError, throwIO)+import Control.Monad (void, when) import Foreign hiding (void) import Foreign.C-import System.Posix.Types (Fd(..))+import System.Posix.Types (Fd (..)) data Udev+ data UdevMonitor+ data UdevDevice foreign import ccall unsafe "udev_new"@@ -38,11 +41,11 @@ c_udev_monitor_unref :: Ptr UdevMonitor -> IO (Ptr UdevMonitor) foreign import ccall unsafe "udev_monitor_filter_add_match_subsystem_devtype"- c_udev_monitor_filter_add_match_subsystem_devtype- :: Ptr UdevMonitor- -> CString- -> CString -- nullable- -> IO CInt+ c_udev_monitor_filter_add_match_subsystem_devtype ::+ Ptr UdevMonitor ->+ CString ->+ CString -> -- nullable+ IO CInt foreign import ccall unsafe "udev_monitor_enable_receiving" c_udev_monitor_enable_receiving :: Ptr UdevMonitor -> IO CInt@@ -57,9 +60,9 @@ c_udev_device_unref :: Ptr UdevDevice -> IO (Ptr UdevDevice) data UdevBacklightMonitor = UdevBacklightMonitor- { monitorUdev :: Ptr Udev- , monitorHandle :: Ptr UdevMonitor- , monitorFd :: Fd+ { monitorUdev :: Ptr Udev,+ monitorHandle :: Ptr UdevMonitor,+ monitorFd :: Fd } openBacklightMonitor :: IO UdevBacklightMonitor@@ -78,11 +81,12 @@ whenNeg rc2 "udev_monitor_enable_receiving failed" fdC <- c_udev_monitor_get_fd mon' whenNeg fdC "udev_monitor_get_fd failed"- pure $ UdevBacklightMonitor- { monitorUdev = udev'- , monitorHandle = mon'- , monitorFd = Fd fdC- }+ pure $+ UdevBacklightMonitor+ { monitorUdev = udev',+ monitorHandle = mon',+ monitorFd = Fd fdC+ } where whenNull p msg = when (p == nullPtr) $ throwIO (userError msg) whenNeg rc msg = when (rc < 0) $ throwIO (userError msg)
+ src/System/Taffybar/Information/Wakeup.hs view
@@ -0,0 +1,159 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE KindSignatures #-}+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++-- |+-- Module : System.Taffybar.Information.Wakeup+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Shared coordinated wakeup channels keyed by polling interval.+--+-- This module provides a single scheduler thread that wakes registered+-- intervals from a shared monotonic timeline. Intervals that evenly divide each+-- other naturally align to the same schedule (e.g. 10s wakeups occur on every+-- other 5s boundary). The wall-clock phase is chosen from the Unix epoch, which+-- means wakeups are top-of-second aligned and minute-start aligned whenever the+-- interval permits.+module System.Taffybar.Information.Wakeup+ ( WakeupEvent (..),+ WakeupSchedulerEvent (..),+ WakeupChannel (..),+ taffyForeverWithDelay,+ getWakeupChannelNanoseconds,+ getWakeupChannelSeconds,+ getWakeupChannelForDelay,+ getWakeupChannel,+ getWakeupSchedulerEvents,+ getRegisteredWakeupIntervalsNanoseconds,+ intervalSecondsToNanoseconds,+ nextWallAlignedWakeupNs,+ nextAlignedWakeupNs,+ intervalDueAtStepNs,+ )+where++import Control.Concurrent (ThreadId, forkIO)+import Control.Concurrent.STM (atomically)+import Control.Concurrent.STM.TChan (TChan, dupTChan, readTChan)+import Control.Exception.Enclosed (catchAny)+import Control.Monad (forever, void)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Reader (ask, asks, runReaderT)+import Data.Proxy (Proxy (..))+import Data.Word (Word64)+import GHC.TypeNats (KnownNat, Nat, natVal)+import System.Log.Logger (Priority (..), logM)+import System.Taffybar.Context (Context, TaffyIO, wakeupManager)+import System.Taffybar.Information.Wakeup.Manager+ ( WakeupEvent (..),+ WakeupManager,+ WakeupSchedulerEvent (..),+ intervalDueAtStepNs,+ intervalSecondsToNanoseconds,+ nextAlignedWakeupNs,+ nextWallAlignedWakeupNs,+ registerWakeupInterval,+ secondsToNanoseconds,+ subscribeWakeupSchedulerEvents,+ )+import qualified System.Taffybar.Information.Wakeup.Manager as WakeupManager+import Text.Printf (printf)++-- | Type-tagged wakeup channel for @TypeApplications@-based lookup.+newtype WakeupChannel (seconds :: Nat)+ = WakeupChannel {unWakeupChannel :: TChan WakeupEvent}++-- | Execute a 'TaffyIO' action on a shared synchronized wakeup schedule.+--+-- This is the preferred context-aware replacement for+-- 'System.Taffybar.Util.foreverWithDelay'.+taffyForeverWithDelay :: (RealFrac d) => d -> TaffyIO () -> TaffyIO ThreadId+taffyForeverWithDelay delay action = do+ wakeupChan <- getWakeupChannelForDelay delay+ ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan+ context <- ask+ liftIO $+ forkIO $+ forever $ do+ safeAction context+ void $ atomically $ readTChan ourWakeupChan+ where+ safeAction :: Context -> IO ()+ safeAction context =+ catchAny+ (runReaderT action context)+ (logM logPath WARNING . printf "Error in taffyForeverWithDelay %s" . show)++-- | Return the shared wakeup channel for an interval in nanoseconds.+getWakeupChannelNanoseconds :: Word64 -> TaffyIO (TChan WakeupEvent)+getWakeupChannelNanoseconds intervalNs = do+ manager <- getWakeupManager+ liftIO $ registerWakeupInterval manager intervalNs++-- | Subscribe to aggregated scheduler wakeup events.+getWakeupSchedulerEvents :: TaffyIO (TChan WakeupSchedulerEvent)+getWakeupSchedulerEvents = do+ manager <- getWakeupManager+ liftIO $ subscribeWakeupSchedulerEvents manager++-- | Return all currently registered wakeup intervals (nanoseconds).+getRegisteredWakeupIntervalsNanoseconds :: TaffyIO [Word64]+getRegisteredWakeupIntervalsNanoseconds = do+ manager <- getWakeupManager+ liftIO $ WakeupManager.getRegisteredWakeupIntervalsNanoseconds manager++-- | Return the shared wakeup channel for @seconds@.+getWakeupChannelSeconds :: Int -> TaffyIO (TChan WakeupEvent)+getWakeupChannelSeconds seconds =+ case secondsToNanoseconds seconds of+ Left err -> fail err+ Right intervalNs -> getWakeupChannelNanoseconds intervalNs++-- | Return the shared wakeup channel for an interval expressed in seconds.+getWakeupChannelForDelay :: (RealFrac d) => d -> TaffyIO (TChan WakeupEvent)+getWakeupChannelForDelay seconds =+ case intervalSecondsToNanoseconds seconds of+ Left err -> fail err+ Right intervalNs -> getWakeupChannelNanoseconds intervalNs++-- | Type-driven variant of 'getWakeupChannelSeconds'.+--+-- Example:+--+-- @+-- wake5 <- getWakeupChannel @5+-- wake10 <- getWakeupChannel @10+-- @+getWakeupChannel ::+ forall seconds.+ (KnownNat seconds) =>+ TaffyIO (TChan WakeupEvent)+getWakeupChannel = do+ seconds <- intervalSecondsFromType @seconds+ getWakeupChannelSeconds seconds++getWakeupManager :: TaffyIO WakeupManager+getWakeupManager = asks wakeupManager++intervalSecondsFromType :: forall seconds m. (KnownNat seconds, MonadFail m) => m Int+intervalSecondsFromType =+ let secondsInteger = toInteger (natVal (Proxy :: Proxy seconds))+ maxIntInteger = toInteger (maxBound :: Int)+ in if+ | secondsInteger <= 0 ->+ fail "Wakeup interval must be greater than 0 seconds"+ | secondsInteger > maxIntInteger ->+ fail "Wakeup interval does not fit into an Int"+ | otherwise ->+ return (fromInteger secondsInteger)++logPath :: String+logPath = "System.Taffybar.Information.Wakeup"
+ src/System/Taffybar/Information/Wakeup/Manager.hs view
@@ -0,0 +1,319 @@+-- |+-- Module : System.Taffybar.Information.Wakeup.Manager+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Internal shared wakeup manager implementation.+module System.Taffybar.Information.Wakeup.Manager+ ( WakeupEvent (..),+ WakeupSchedulerEvent (..),+ WakeupManager,+ newWakeupManager,+ registerWakeupInterval,+ subscribeWakeupSchedulerEvents,+ getRegisteredWakeupIntervalsNanoseconds,+ secondsToNanoseconds,+ intervalSecondsToNanoseconds,+ nextWallAlignedWakeupNs,+ nextAlignedWakeupNs,+ intervalDueAtStepNs,+ )+where++import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.STM+ ( STM,+ TVar,+ atomically,+ check,+ newTVarIO,+ orElse,+ readTVar,+ registerDelay,+ writeTVar,+ )+import Control.Concurrent.STM.TChan+ ( TChan,+ dupTChan,+ newBroadcastTChan,+ newBroadcastTChanIO,+ newTChanIO,+ readTChan,+ tryReadTChan,+ writeTChan,+ )+import Control.Exception.Enclosed (catchAny)+import qualified Data.Map.Strict as M+import Data.Maybe (mapMaybe)+import Data.Time.Clock.POSIX (getPOSIXTime)+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import System.Log.Logger (Priority (..), logM)+import Text.Printf (printf)++-- | A wakeup event emitted for a registered interval.+data WakeupEvent = WakeupEvent+ { wakeupIntervalNanoseconds :: !Word64,+ -- | Monotonic counter for this interval. If the scheduler was delayed,+ -- this counter can advance by more than one between events.+ wakeupTickCount :: !Word64+ }+ deriving (Eq, Show)++-- | A scheduler event containing all interval wakeups published together.+data WakeupSchedulerEvent = WakeupSchedulerEvent+ { wakeupEventTimeNanoseconds :: !Word64,+ wakeupDueEvents :: ![WakeupEvent]+ }+ deriving (Eq, Show)++-- Internal scheduler state for one interval.+data IntervalRegistration = IntervalRegistration+ { intervalNanoseconds :: !Word64,+ intervalChannel :: TChan WakeupEvent,+ intervalNextDueNs :: !Word64,+ intervalTickCount :: !Word64+ }++-- Shared wakeup manager state.+data WakeupManager = WakeupManager+ { -- Approximate wall clock as @monotonic + wakeupRealtimeOffsetNs@.+ wakeupRealtimeOffsetNs :: !Integer,+ wakeupIntervals :: TVar (M.Map Word64 IntervalRegistration),+ wakeupRescheduleChan :: TChan (),+ wakeupDebugChan :: TChan WakeupSchedulerEvent+ }++newWakeupManager :: IO WakeupManager+newWakeupManager = do+ realtimeOffsetNs <- getRealtimeOffsetNs+ intervalsVar <- newTVarIO M.empty+ rescheduleChan <- newTChanIO+ debugChan <- newBroadcastTChanIO+ let manager =+ WakeupManager+ { wakeupRealtimeOffsetNs = realtimeOffsetNs,+ wakeupIntervals = intervalsVar,+ wakeupRescheduleChan = rescheduleChan,+ wakeupDebugChan = debugChan+ }+ _ <- forkIO (runWakeupScheduler manager)+ pure manager++registerWakeupInterval :: WakeupManager -> Word64 -> IO (TChan WakeupEvent)+registerWakeupInterval manager intervalNs =+ case validateIntervalNanoseconds intervalNs of+ Left err -> ioError (userError err)+ Right validIntervalNs -> do+ now <- getMonotonicTimeNSec+ atomically $ do+ registrations <- readTVar (wakeupIntervals manager)+ case M.lookup validIntervalNs registrations of+ Just registration -> pure (intervalChannel registration)+ Nothing -> do+ channel <- newBroadcastTChan+ let registration =+ IntervalRegistration+ { intervalNanoseconds = validIntervalNs,+ intervalChannel = channel,+ intervalNextDueNs =+ nextWallAlignedWakeupNs+ (wakeupRealtimeOffsetNs manager)+ validIntervalNs+ now,+ intervalTickCount = 0+ }+ writeTVar+ (wakeupIntervals manager)+ (M.insert validIntervalNs registration registrations)+ -- Ensure the scheduler recomputes immediately, so newly added short+ -- intervals are not blocked behind an older long sleep.+ writeTChan (wakeupRescheduleChan manager) ()+ pure channel++subscribeWakeupSchedulerEvents :: WakeupManager -> IO (TChan WakeupSchedulerEvent)+subscribeWakeupSchedulerEvents manager =+ atomically $ dupTChan (wakeupDebugChan manager)++getRegisteredWakeupIntervalsNanoseconds :: WakeupManager -> IO [Word64]+getRegisteredWakeupIntervalsNanoseconds manager =+ atomically $ M.keys <$> readTVar (wakeupIntervals manager)++runWakeupScheduler :: WakeupManager -> IO ()+runWakeupScheduler manager =+ schedulerLoop `catchAny` onSchedulerException+ where+ schedulerLoop = do+ maybeNextDue <- atomically $ nextDueNs <$> readTVar (wakeupIntervals manager)+ case maybeNextDue of+ Nothing -> atomically (readTChan (wakeupRescheduleChan manager) >> drainRescheduleChan (wakeupRescheduleChan manager))+ Just nextDue -> do+ now <- getMonotonicTimeNSec+ if nextDue <= now+ then publishDueIntervals manager now+ else waitForRescheduleOrTimeout manager (nextDue - now)+ schedulerLoop++ onSchedulerException e = do+ logM logPath WARNING $ printf "Wakeup scheduler crashed and will be restarted: %s" (show e)+ threadDelay 1000000+ runWakeupScheduler manager++waitForRescheduleOrTimeout :: WakeupManager -> Word64 -> IO ()+waitForRescheduleOrTimeout manager delayNs = do+ timeoutVar <- registerDelay (toRegisterDelayMicros delayNs)+ atomically $+ (readTChan (wakeupRescheduleChan manager) >> drainRescheduleChan (wakeupRescheduleChan manager))+ `orElse` (readTVar timeoutVar >>= check)++publishDueIntervals :: WakeupManager -> Word64 -> IO ()+publishDueIntervals manager nowNs =+ atomically $ do+ registrations <- readTVar (wakeupIntervals manager)+ updatedWithEvents <- mapM (advanceIntervalIfDue nowNs) registrations+ let updated = fst <$> updatedWithEvents+ dueEvents = mapMaybe snd (M.elems updatedWithEvents)+ writeTVar (wakeupIntervals manager) updated+ if null dueEvents+ then pure ()+ else+ writeTChan+ (wakeupDebugChan manager)+ WakeupSchedulerEvent+ { wakeupEventTimeNanoseconds = nowNs,+ wakeupDueEvents = dueEvents+ }++advanceIntervalIfDue :: Word64 -> IntervalRegistration -> STM (IntervalRegistration, Maybe WakeupEvent)+advanceIntervalIfDue nowNs registration+ | intervalNextDueNs registration > nowNs = pure (registration, Nothing)+ | otherwise = do+ let intervalsElapsed =+ ((nowNs - intervalNextDueNs registration) `div` intervalNanoseconds registration) + 1+ nextDueNs' =+ fromInteger $+ toInteger (intervalNextDueNs registration)+ + toInteger intervalsElapsed * toInteger (intervalNanoseconds registration)+ tickCount = intervalTickCount registration + intervalsElapsed+ wakeupEvent =+ WakeupEvent+ { wakeupIntervalNanoseconds = intervalNanoseconds registration,+ wakeupTickCount = tickCount+ }+ writeTChan+ (intervalChannel registration)+ wakeupEvent+ pure+ ( registration+ { intervalNextDueNs = nextDueNs',+ intervalTickCount = tickCount+ },+ Just wakeupEvent+ )++nextDueNs :: M.Map Word64 IntervalRegistration -> Maybe Word64+nextDueNs registrations+ | M.null registrations = Nothing+ | otherwise = Just $ minimum $ intervalNextDueNs <$> M.elems registrations++secondsToNanoseconds :: Int -> Either String Word64+secondsToNanoseconds seconds+ | seconds <= 0 = Left "Wakeup interval must be greater than 0 seconds"+ | secondsInteger > maxWord64Integer =+ Left "Wakeup interval is too large"+ | otherwise =+ Right (fromInteger secondsInteger)+ where+ secondsInteger = toInteger seconds * 1000000000+ maxWord64Integer = toInteger (maxBound :: Word64)++-- | Convert an interval in seconds to nanoseconds.+intervalSecondsToNanoseconds :: (RealFrac d) => d -> Either String Word64+intervalSecondsToNanoseconds seconds+ | secondsRational <= 0 = Left "Wakeup interval must be greater than 0 seconds"+ | intervalNanosecondsInteger <= 0 = Left "Wakeup interval rounded to zero nanoseconds"+ | intervalNanosecondsInteger > maxWord64Integer = Left "Wakeup interval is too large"+ | otherwise = Right (fromInteger intervalNanosecondsInteger)+ where+ secondsRational = toRational seconds+ intervalNanosecondsInteger = round (secondsRational * 1000000000)+ maxWord64Integer = toInteger (maxBound :: Word64)++validateIntervalNanoseconds :: Word64 -> Either String Word64+validateIntervalNanoseconds intervalNs+ | intervalNs == 0 = Left "Wakeup interval must be greater than 0 nanoseconds"+ | otherwise = Right intervalNs++-- | Convert a delay in nanoseconds into a value suitable for+-- 'Control.Concurrent.STM.registerDelay'.+toRegisterDelayMicros :: Word64 -> Int+toRegisterDelayMicros nanoseconds =+ fromInteger (min maxIntInteger micros)+ where+ micros = (toInteger nanoseconds + 999) `div` 1000+ maxIntInteger = toInteger (maxBound :: Int)++drainRescheduleChan :: TChan () -> STM ()+drainRescheduleChan chan = do+ maybeEvent <- tryReadTChan chan+ case maybeEvent of+ Nothing -> pure ()+ Just () -> drainRescheduleChan chan++getRealtimeOffsetNs :: IO Integer+getRealtimeOffsetNs = do+ monotonicBefore <- getMonotonicTimeNSec+ wallNow <- getPOSIXTimeNanoseconds+ monotonicAfter <- getMonotonicTimeNSec+ let monotonicMidpoint =+ (toInteger monotonicBefore + toInteger monotonicAfter) `div` 2+ pure $ wallNow - monotonicMidpoint++getPOSIXTimeNanoseconds :: IO Integer+getPOSIXTimeNanoseconds = do+ -- POSIX time has picosecond precision; convert to nanoseconds.+ posix <- getPOSIXTime+ let picoseconds :: Integer+ picoseconds = round (posix * 1000000000000)+ pure (picoseconds `div` 1000)++-- | Return the next due monotonic timestamp strictly after @nowNs@ for the+-- given @intervalNs@, aligned to wall-clock interval boundaries.+--+-- The wall clock is approximated as @monotonic + realtimeOffsetNs@.+nextWallAlignedWakeupNs :: Integer -> Word64 -> Word64 -> Word64+nextWallAlignedWakeupNs realtimeOffsetNs intervalNs nowNs =+ fromInteger (toInteger nowNs + delta)+ where+ wallNowNs = toInteger nowNs + realtimeOffsetNs+ intervalInteger = toInteger intervalNs+ remainder = wallNowNs `mod` intervalInteger+ delta+ | remainder == 0 = intervalInteger+ | otherwise = intervalInteger - remainder++-- | Return the @step@-th due timestamp for @intervalNs@ from @anchorNs@.+--+-- Steps are 1-indexed (step 1 is the first due time at @anchor + interval@).+intervalDueAtStepNs :: Word64 -> Word64 -> Word64 -> Word64+intervalDueAtStepNs anchorNs intervalNs step =+ fromInteger $+ toInteger anchorNs+ + toInteger intervalNs * toInteger step++-- | Return the next due timestamp strictly after @nowNs@ for the given+-- @intervalNs@ from @anchorNs@.+nextAlignedWakeupNs :: Word64 -> Word64 -> Word64 -> Word64+nextAlignedWakeupNs anchorNs intervalNs nowNs =+ intervalDueAtStepNs anchorNs intervalNs (stepsElapsed + 1)+ where+ stepsElapsed+ | nowNs <= anchorNs = 0+ | otherwise = (nowNs - anchorNs) `div` intervalNs++logPath :: String+logPath = "System.Taffybar.Information.Wakeup"
src/System/Taffybar/Information/WirePlumber.hs view
@@ -36,11 +36,10 @@ ) where -import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.MVar import Control.Concurrent.STM.TChan import Control.Exception (SomeException, try)-import Control.Monad (forever)+import Control.Monad (void) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.STM (atomically) import Data.List (isInfixOf)@@ -50,6 +49,7 @@ import GHC.TypeLits (KnownSymbol, SomeSymbol (..), Symbol, someSymbolVal, symbolVal) import System.Log.Logger (Priority (..)) import System.Taffybar.Context (TaffyIO, getStateDefault)+import System.Taffybar.Information.Wakeup (taffyForeverWithDelay) import System.Taffybar.Util (logPrintF, runCommand) import Text.Read (readMaybe) @@ -101,45 +101,49 @@ -- | Get a broadcast channel for WirePlumber info for a node spec given as a -- type-level string.-getWirePlumberInfoChanFor :: forall a. KnownSymbol a => TaffyIO (TChan (Maybe WirePlumberInfo))+getWirePlumberInfoChanFor :: forall a. (KnownSymbol a) => TaffyIO (TChan (Maybe WirePlumberInfo)) getWirePlumberInfoChanFor = do WirePlumberInfoChanVar (chan, _) <- getWirePlumberInfoChanVarFor @a pure chan -- | Read the current WirePlumber info state for a node spec given as a -- type-level string.-getWirePlumberInfoStateFor :: forall a. KnownSymbol a => TaffyIO (Maybe WirePlumberInfo)+getWirePlumberInfoStateFor :: forall a. (KnownSymbol a) => TaffyIO (Maybe WirePlumberInfo) getWirePlumberInfoStateFor = do WirePlumberInfoChanVar (_, var) <- getWirePlumberInfoChanVarFor @a liftIO $ readMVar var -getWirePlumberInfoChanVarFor :: forall a. KnownSymbol a => TaffyIO (WirePlumberInfoChanVar a)+getWirePlumberInfoChanVarFor :: forall a. (KnownSymbol a) => TaffyIO (WirePlumberInfoChanVar a) getWirePlumberInfoChanVarFor = getStateDefault $ do let nodeSpec = symbolVal (Proxy @a)- liftIO $ do- chan <- newBroadcastTChanIO- var <- newMVar Nothing- _ <- forkIO $ monitorWirePlumberInfo nodeSpec chan var- pure $ WirePlumberInfoChanVar (chan, var)+ chan <- liftIO newBroadcastTChanIO+ var <- liftIO $ newMVar Nothing+ liftIO $ refreshWirePlumberInfo nodeSpec chan var+ let pollIntervalSeconds :: Double+ pollIntervalSeconds = fromIntegral pollIntervalMicros / 1000000+ void $+ taffyForeverWithDelay pollIntervalSeconds $+ liftIO $+ refreshWirePlumberInfo nodeSpec chan var+ pure $ WirePlumberInfoChanVar (chan, var) -- | Polling interval in microseconds (1 second). pollIntervalMicros :: Int pollIntervalMicros = 1_000_000 -monitorWirePlumberInfo ::+refreshWirePlumberInfo :: String -> TChan (Maybe WirePlumberInfo) -> MVar (Maybe WirePlumberInfo) -> IO ()-monitorWirePlumberInfo nodeSpec chan var = forever $ do+refreshWirePlumberInfo nodeSpec chan var = do result <- try $ getWirePlumberInfo nodeSpec let info = case result of Left (_ :: SomeException) -> Nothing Right i -> i _ <- swapMVar var info atomically $ writeTChan chan info- threadDelay pollIntervalMicros -- | Query volume and mute state for the provided node spec. --
src/System/Taffybar/Information/Wlsunset.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.Wlsunset -- Copyright : (c) Ivan A. Malison@@ -10,78 +13,101 @@ -- -- 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-warm → forced-cool--- → auto) via @SIGUSR1@.------------------------------------------------------------------------------+-- the process and tracks mode cycling (auto → forced high temp →+-- forced low temp → auto) via @SIGUSR1@. module System.Taffybar.Information.Wlsunset ( -- * Types- WlsunsetMode(..)- , WlsunsetState(..)- , WlsunsetConfig(..)+ WlsunsetMode (..),+ WlsunsetState (..),+ WlsunsetConfig (..),+ -- * State access- , getWlsunsetChan- , getWlsunsetState+ getWlsunsetChan,+ getWlsunsetState,+ -- * Actions- , cycleWlsunsetMode- , startWlsunset- , stopWlsunset- , toggleWlsunset- ) where+ cycleWlsunsetMode,+ startWlsunset,+ stopWlsunset,+ toggleWlsunset,+ restartWlsunsetWithTemps,+ )+where -import Control.Concurrent (threadDelay)-import Control.Concurrent.MVar-import Control.Concurrent.STM.TChan-import Control.Exception.Enclosed (catchAny)-import Control.Monad (void, when)-import Control.Monad.IO.Class-import Control.Monad.STM (atomically)-import Control.Monad.Trans.Class-import Data.Default (Default(..))-import System.Log.Logger-import System.Posix.Signals (signalProcess, sigUSR1)-import System.Posix.Types (CPid(..))-import System.Process (readProcess, spawnCommand)-import System.Taffybar.Context-import System.Taffybar.Util (logPrintF)-import Text.Read (readMaybe)+import Control.Concurrent.MVar+import Control.Concurrent.STM.TChan+import Control.Exception.Enclosed (catchAny)+import Control.Monad (void, when)+import Control.Monad.IO.Class+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Class+import Data.Default (Default (..))+import Data.List (isPrefixOf)+import System.Log.Logger+import System.Posix.Signals (sigUSR1, signalProcess)+import System.Posix.Types (CPid (..))+import System.Process (readProcess, spawnCommand)+import System.Taffybar.Context+import System.Taffybar.Information.Wakeup (getWakeupChannelSeconds)+import System.Taffybar.Util (logPrintF)+import Text.Read (readMaybe) -- | The three operating modes that wlsunset cycles through when it--- receives @SIGUSR1@.+-- receives @SIGUSR1@. The cycle order matches wlsunset's internal+-- ring: Auto → ForcedHighTemp → ForcedLowTemp → Auto. data WlsunsetMode- = WlsunsetAuto -- ^ Normal day/night schedule- | WlsunsetForcedWarm -- ^ Forced warm (night) temperature- | WlsunsetForcedCool -- ^ Forced cool (day) temperature+ = -- | Normal day/night schedule+ WlsunsetAuto+ | -- | Forced high (day) temperature — no color shift+ WlsunsetForcedHighTemp+ | -- | Forced low (night) temperature — warm/reddish colors+ WlsunsetForcedLowTemp deriving (Eq, Show, Ord, Enum, Bounded) -- | Observable state of the wlsunset process. data WlsunsetState = WlsunsetState- { wlsunsetRunning :: Bool- , wlsunsetMode :: WlsunsetMode- } deriving (Eq, Show)+ { wlsunsetRunning :: Bool,+ wlsunsetMode :: WlsunsetMode,+ -- | The effective high temperature wlsunset is running with.+ wlsunsetEffectiveHighTemp :: Int,+ -- | The effective low temperature wlsunset is running with.+ wlsunsetEffectiveLowTemp :: Int+ }+ deriving (Eq, Show) -- | Configuration for the wlsunset monitor. data WlsunsetConfig = WlsunsetConfig { -- | Full shell command used to start wlsunset (e.g. -- @\"wlsunset -l 38.9 -L -77.0\"@).- wlsunsetCommand :: String+ wlsunsetCommand :: String,+ -- | High (day) temperature in Kelvin. Used for display only.+ -- Should match the @-T@ flag passed to wlsunset (default 6500).+ wlsunsetHighTemp :: Int,+ -- | 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.- , wlsunsetPollIntervalSec :: Int- } deriving (Eq, Show)+ wlsunsetPollIntervalSec :: Int+ }+ deriving (Eq, Show) instance Default WlsunsetConfig where- def = WlsunsetConfig- { wlsunsetCommand = "wlsunset"- , wlsunsetPollIntervalSec = 2- }+ def =+ WlsunsetConfig+ { wlsunsetCommand = "wlsunset",+ wlsunsetHighTemp = 6500,+ wlsunsetLowTemp = 4000,+ wlsunsetPollIntervalSec = 2+ } -- | Internal state bundle stored in 'contextState' via 'getStateDefault'.-newtype WlsunsetChanVar =- WlsunsetChanVar (TChan WlsunsetState, MVar WlsunsetState, WlsunsetConfig)+newtype WlsunsetChanVar+ = WlsunsetChanVar (TChan WlsunsetState, MVar WlsunsetState, WlsunsetConfig) wlsunsetLogPath :: String wlsunsetLogPath = "System.Taffybar.Information.Wlsunset" -wlsunsetLog :: MonadIO m => Priority -> String -> m ()+wlsunsetLog :: (MonadIO m) => Priority -> String -> m () wlsunsetLog priority = liftIO . logM wlsunsetLogPath priority wlsunsetLogF :: (MonadIO m, Show t) => Priority -> String -> t -> m ()@@ -100,7 +126,7 @@ where parsePids = map (CPid . fromIntegral) . concatMap toList . lines toList s = case (readMaybe s :: Maybe Int) of- Just n -> [n]+ Just n -> [n] Nothing -> [] -- | Send @SIGUSR1@ to a wlsunset process to cycle its mode.@@ -128,20 +154,26 @@ lift $ readMVar var -- | Start the polling loop that monitors wlsunset.-monitorWlsunset- :: WlsunsetConfig- -> TaffyIO (TChan WlsunsetState, MVar WlsunsetState, WlsunsetConfig)+monitorWlsunset ::+ WlsunsetConfig ->+ TaffyIO (TChan WlsunsetState, MVar WlsunsetState, WlsunsetConfig) monitorWlsunset cfg = do- let initialState = WlsunsetState { wlsunsetRunning = False- , wlsunsetMode = WlsunsetAuto- }+ let initialState =+ WlsunsetState+ { wlsunsetRunning = False,+ wlsunsetMode = WlsunsetAuto,+ wlsunsetEffectiveHighTemp = wlsunsetHighTemp cfg,+ wlsunsetEffectiveLowTemp = wlsunsetLowTemp cfg+ } stateVar <- liftIO $ newMVar initialState- chan <- liftIO newBroadcastTChanIO+ chan <- liftIO newBroadcastTChanIO+ wakeupChan <- getWakeupChannelSeconds (max 1 (wlsunsetPollIntervalSec cfg))+ ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan taffyFork $ do wlsunsetLog DEBUG "Starting wlsunset polling loop" let loop = do liftIO $ pollWlsunset chan stateVar- liftIO $ threadDelay (wlsunsetPollIntervalSec cfg * 1000000)+ liftIO $ void $ atomically $ readTChan ourWakeupChan loop loop return (chan, stateVar, cfg)@@ -157,11 +189,13 @@ -- When the process freshly appears, reset mode to Auto. newMode | not wasRunning && isRunning = WlsunsetAuto- | not isRunning = WlsunsetAuto- | otherwise = wlsunsetMode old- new = WlsunsetState { wlsunsetRunning = isRunning- , wlsunsetMode = newMode- }+ | not isRunning = WlsunsetAuto+ | otherwise = wlsunsetMode old+ new =+ old+ { wlsunsetRunning = isRunning,+ wlsunsetMode = newMode+ } when (new /= old) $ do wlsunsetLogF DEBUG "Wlsunset state changed: %s" new atomically $ writeTChan chan new@@ -172,7 +206,7 @@ -- --------------------------------------------------------------------------- -- | Cycle wlsunset mode by sending @SIGUSR1@ to the process.--- The mode cycles: Auto → ForcedWarm → ForcedCool → Auto.+-- The mode cycles: Auto → ForcedHighTemp → ForcedLowTemp → Auto. cycleWlsunsetMode :: WlsunsetConfig -> TaffyIO () cycleWlsunsetMode cfg = do WlsunsetChanVar (chan, var, _) <- getWlsunsetChanVar cfg@@ -180,14 +214,14 @@ pids <- pgrepWlsunset case pids of [] -> wlsunsetLog DEBUG "cycleWlsunsetMode: wlsunset not running"- _ -> do+ _ -> do mapM_ sendUSR1 pids modifyMVar_ var $ \old -> do let newMode = case wlsunsetMode old of- WlsunsetAuto -> WlsunsetForcedWarm- WlsunsetForcedWarm -> WlsunsetForcedCool- WlsunsetForcedCool -> WlsunsetAuto- new = old { wlsunsetMode = newMode }+ WlsunsetAuto -> WlsunsetForcedHighTemp+ WlsunsetForcedHighTemp -> WlsunsetForcedLowTemp+ WlsunsetForcedLowTemp -> WlsunsetAuto+ new = old {wlsunsetMode = newMode} wlsunsetLogF DEBUG "Cycled wlsunset mode: %s" newMode atomically $ writeTChan chan new return new@@ -204,7 +238,7 @@ pids <- pgrepWlsunset case pids of [] -> wlsunsetLog DEBUG "stopWlsunset: wlsunset not running"- _ -> do+ _ -> do wlsunsetLog DEBUG "Stopping wlsunset (SIGTERM)" mapM_ (signalProcess 15) pids @@ -215,3 +249,50 @@ if wlsunsetRunning st then stopWlsunset cfg else startWlsunset cfg++-- | Restart wlsunset with specific low and high temperatures.+-- Kills the running instance, updates the effective temperatures in state,+-- and spawns a new instance with @-t lowTemp -T highTemp@.+restartWlsunsetWithTemps :: WlsunsetConfig -> Int -> Int -> TaffyIO ()+restartWlsunsetWithTemps cfg lowTemp highTemp = do+ WlsunsetChanVar (chan, var, _) <- getWlsunsetChanVar cfg+ liftIO $ do+ pids <- pgrepWlsunset+ mapM_ (signalProcess 15) pids+ let cmd = buildCommandWithTemps (wlsunsetCommand cfg) lowTemp highTemp+ wlsunsetLog DEBUG $ "Restarting wlsunset: " ++ cmd+ void $ spawnCommand cmd+ modifyMVar_ var $ \old -> do+ let new =+ old+ { wlsunsetMode = WlsunsetAuto,+ wlsunsetEffectiveHighTemp = highTemp,+ wlsunsetEffectiveLowTemp = lowTemp+ }+ atomically $ writeTChan chan new+ return new++-- ---------------------------------------------------------------------------+-- Command building+-- ---------------------------------------------------------------------------++-- | Build a wlsunset command with specific temperature flags.+-- Strips any existing @-t@\/@-T@ flags from the base command and appends+-- new ones.+buildCommandWithTemps :: String -> Int -> Int -> String+buildCommandWithTemps baseCmd lowTemp highTemp =+ unwords (stripTempArgs (words baseCmd))+ ++ " -t "+ ++ show lowTemp+ ++ " -T "+ ++ show highTemp++-- | Strip @-t \<val\>@ and @-T \<val\>@ arguments from a word list.+stripTempArgs :: [String] -> [String]+stripTempArgs [] = []+stripTempArgs ("-t" : _ : rest) = stripTempArgs rest+stripTempArgs ("-T" : _ : rest) = stripTempArgs rest+stripTempArgs (x : rest)+ | "-t" `isPrefixOf` x && length x > 2 = stripTempArgs rest+ | "-T" `isPrefixOf` x && length x > 2 = stripTempArgs rest+ | otherwise = x : stripTempArgs rest
+ src/System/Taffybar/Information/Workspaces/EWMH.hs view
@@ -0,0 +1,324 @@+{-# LANGUAGE OverloadedStrings #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Information.Workspaces.EWMH+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Shared EWMH/X11 workspace-state provider using broadcast channels + state MVar.+module System.Taffybar.Information.Workspaces.EWMH+ ( EWMHWorkspaceProviderConfig (..),+ defaultEWMHWorkspaceProviderConfig,+ defaultEWMHWorkspaceState,+ getEWMHWorkspaceStateAndEventChansAndVar,+ getEWMHWorkspaceStateAndEventChansAndVarWith,+ getEWMHWorkspaceEventChan,+ getEWMHWorkspaceEventChanWith,+ getEWMHWorkspaceStateChanAndVar,+ getEWMHWorkspaceStateChanAndVarWith,+ getEWMHWorkspaceStateChan,+ getEWMHWorkspaceStateChanWith,+ getEWMHWorkspaceState,+ getEWMHWorkspaceStateWith,+ )+where++import Control.Concurrent.MVar+import Control.Concurrent.STM.TChan+import Control.Exception.Enclosed (catchAny)+import Control.Monad (filterM, foldM, void)+import Control.Monad.IO.Class (MonadIO (..))+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Reader (ask, runReaderT)+import Control.RateLimit (RateLimit (PerInvocation), generateRateLimitedFunction)+import Data.Char (toLower)+import Data.List (isPrefixOf, (\\))+import qualified Data.MultiMap as MM+import qualified Data.Text as T+import Data.Time.Units (Microsecond, fromMicroseconds)+import System.Log.Logger (Priority (..), logM)+import System.Taffybar.Context+ ( TaffyIO,+ getStateDefault,+ runX11,+ subscribeToPropertyEvents,+ taffyFork,+ )+import System.Taffybar.Information.EWMHDesktopInfo+ ( WorkspaceId (WorkspaceId),+ allEWMHProperties,+ ewmhWMIcon,+ getActiveWindow,+ getVisibleWorkspaces,+ getWindowClass,+ getWindowMinimized,+ getWindowTitle,+ getWindows,+ getWorkspace,+ getWorkspaceNames,+ isWindowUrgent,+ parseWindowClasses,+ )+import System.Taffybar.Information.SafeX11 (safeGetGeometry)+import System.Taffybar.Information.Workspaces.Model+import System.Taffybar.Information.X11DesktopInfo (X11Property, X11Window, getDisplay)++data EWMHWorkspaceProviderConfig = EWMHWorkspaceProviderConfig+ { workspaceSnapshotGetter :: TaffyIO (Bool, [WorkspaceInfo]),+ workspaceUpdateEvents :: [String],+ workspaceUpdateRateLimitMicroseconds :: Integer+ }++defaultEWMHWorkspaceProviderConfig :: EWMHWorkspaceProviderConfig+defaultEWMHWorkspaceProviderConfig =+ EWMHWorkspaceProviderConfig+ { workspaceSnapshotGetter = buildEWMHWorkspaceSnapshot,+ workspaceUpdateEvents = allEWMHProperties \\ [ewmhWMIcon],+ workspaceUpdateRateLimitMicroseconds = 100000+ }++defaultEWMHWorkspaceState :: WorkspaceSnapshot+defaultEWMHWorkspaceState =+ WorkspaceSnapshot+ { snapshotBackend = WorkspaceBackendEWMH,+ snapshotRevision = 0,+ snapshotWindowDataComplete = False,+ snapshotWorkspaces = []+ }++newtype EWMHWorkspaceStateChanVar+ = EWMHWorkspaceStateChanVar+ (TChan WorkspaceSnapshot, TChan WorkspaceEventBatch, MVar WorkspaceSnapshot)++wLog :: (MonadIO m) => Priority -> String -> m ()+wLog level message =+ liftIO $ logM "System.Taffybar.Information.Workspaces.EWMH" level message++getEWMHWorkspaceStateAndEventChansAndVar ::+ TaffyIO (TChan WorkspaceSnapshot, TChan WorkspaceEventBatch, MVar WorkspaceSnapshot)+getEWMHWorkspaceStateAndEventChansAndVar =+ getEWMHWorkspaceStateAndEventChansAndVarWith defaultEWMHWorkspaceProviderConfig++getEWMHWorkspaceStateAndEventChansAndVarWith ::+ EWMHWorkspaceProviderConfig ->+ TaffyIO (TChan WorkspaceSnapshot, TChan WorkspaceEventBatch, MVar WorkspaceSnapshot)+getEWMHWorkspaceStateAndEventChansAndVarWith cfg = do+ EWMHWorkspaceStateChanVar chansAndVar <- getStateDefault $ buildEWMHWorkspaceStateChanVar cfg+ return chansAndVar++getEWMHWorkspaceEventChan :: TaffyIO (TChan WorkspaceEventBatch)+getEWMHWorkspaceEventChan =+ getEWMHWorkspaceEventChanWith defaultEWMHWorkspaceProviderConfig++getEWMHWorkspaceEventChanWith ::+ EWMHWorkspaceProviderConfig ->+ TaffyIO (TChan WorkspaceEventBatch)+getEWMHWorkspaceEventChanWith cfg = do+ (_, eventChan, _) <- getEWMHWorkspaceStateAndEventChansAndVarWith cfg+ return eventChan++getEWMHWorkspaceStateChanAndVar ::+ TaffyIO (TChan WorkspaceSnapshot, MVar WorkspaceSnapshot)+getEWMHWorkspaceStateChanAndVar =+ getEWMHWorkspaceStateChanAndVarWith defaultEWMHWorkspaceProviderConfig++getEWMHWorkspaceStateChanAndVarWith ::+ EWMHWorkspaceProviderConfig ->+ TaffyIO (TChan WorkspaceSnapshot, MVar WorkspaceSnapshot)+getEWMHWorkspaceStateChanAndVarWith cfg = do+ (stateChan, _, stateVar) <- getEWMHWorkspaceStateAndEventChansAndVarWith cfg+ return (stateChan, stateVar)++getEWMHWorkspaceStateChan :: TaffyIO (TChan WorkspaceSnapshot)+getEWMHWorkspaceStateChan =+ getEWMHWorkspaceStateChanWith defaultEWMHWorkspaceProviderConfig++getEWMHWorkspaceStateChanWith ::+ EWMHWorkspaceProviderConfig ->+ TaffyIO (TChan WorkspaceSnapshot)+getEWMHWorkspaceStateChanWith cfg =+ fst <$> getEWMHWorkspaceStateChanAndVarWith cfg++getEWMHWorkspaceState :: TaffyIO WorkspaceSnapshot+getEWMHWorkspaceState =+ getEWMHWorkspaceStateWith defaultEWMHWorkspaceProviderConfig++getEWMHWorkspaceStateWith ::+ EWMHWorkspaceProviderConfig ->+ TaffyIO WorkspaceSnapshot+getEWMHWorkspaceStateWith cfg = do+ (_, var) <- getEWMHWorkspaceStateChanAndVarWith cfg+ liftIO $ readMVar var++buildEWMHWorkspaceStateChanVar ::+ EWMHWorkspaceProviderConfig ->+ TaffyIO EWMHWorkspaceStateChanVar+buildEWMHWorkspaceStateChanVar cfg = do+ stateChan <- liftIO newBroadcastTChanIO+ eventChan <- liftIO newBroadcastTChanIO+ stateVar <- liftIO $ newMVar defaultEWMHWorkspaceState+ taffyFork $ ewmhWorkspaceStateLoop cfg stateChan eventChan stateVar+ return $ EWMHWorkspaceStateChanVar (stateChan, eventChan, stateVar)++ewmhWorkspaceStateLoop ::+ EWMHWorkspaceProviderConfig ->+ TChan WorkspaceSnapshot ->+ TChan WorkspaceEventBatch ->+ MVar WorkspaceSnapshot ->+ TaffyIO ()+ewmhWorkspaceStateLoop cfg stateChan eventChan stateVar = do+ refreshEWMHWorkspaceState cfg stateChan eventChan stateVar+ setupEWMHWorkspaceSubscription cfg stateChan eventChan stateVar+ `catchAny` \err ->+ wLog WARNING $ "Failed to subscribe to EWMH workspace events: " <> show err++setupEWMHWorkspaceSubscription ::+ EWMHWorkspaceProviderConfig ->+ TChan WorkspaceSnapshot ->+ TChan WorkspaceEventBatch ->+ MVar WorkspaceSnapshot ->+ TaffyIO ()+setupEWMHWorkspaceSubscription cfg stateChan eventChan stateVar = do+ ctx <- ask+ let rate = fromMicroseconds (workspaceUpdateRateLimitMicroseconds cfg) :: Microsecond+ combineRequests _ b = Just (b, const ((), ()))+ refreshForEvent _ =+ runReaderT+ (refreshEWMHWorkspaceState cfg stateChan eventChan stateVar)+ ctx+ rateLimitedRefresh <-+ liftIO $+ generateRateLimitedFunction+ (PerInvocation rate)+ refreshForEvent+ combineRequests+ _ <-+ subscribeToPropertyEvents+ (workspaceUpdateEvents cfg)+ (liftIO . void . rateLimitedRefresh)+ return ()++refreshEWMHWorkspaceState ::+ EWMHWorkspaceProviderConfig ->+ TChan WorkspaceSnapshot ->+ TChan WorkspaceEventBatch ->+ MVar WorkspaceSnapshot ->+ TaffyIO ()+refreshEWMHWorkspaceState cfg stateChan eventChan stateVar = do+ previous <- liftIO $ readMVar stateVar+ (complete, workspaces) <-+ workspaceSnapshotGetter cfg+ `catchAny` \err -> do+ wLog WARNING $ "EWMH workspace snapshot update failed: " <> show err+ return (False, snapshotWorkspaces previous)+ let nextRevision = snapshotRevision previous + 1+ next =+ WorkspaceSnapshot+ { snapshotBackend = WorkspaceBackendEWMH,+ snapshotRevision = nextRevision,+ snapshotWindowDataComplete = complete,+ snapshotWorkspaces = workspaces+ }+ eventBatch =+ WorkspaceEventBatch+ { eventBatchBackend = WorkspaceBackendEWMH,+ eventBatchRevision = nextRevision,+ eventBatchWindowDataComplete = complete,+ eventBatchEvents = diffWorkspaceSnapshots previous next+ }+ liftIO $ do+ _ <- swapMVar stateVar next+ atomically $ do+ writeTChan stateChan next+ writeTChan eventChan eventBatch++buildEWMHWorkspaceSnapshot :: TaffyIO (Bool, [WorkspaceInfo])+buildEWMHWorkspaceSnapshot = do+ workspaces <- runX11 collectEWMHWorkspaceSnapshot+ return (True, workspaces)++collectEWMHWorkspaceSnapshot :: X11Property [WorkspaceInfo]+collectEWMHWorkspaceSnapshot = do+ names <- getWorkspaceNames+ windows <- getWindows+ workspaceToWindows <- getWorkspaceToWindows windows+ urgentWindows <- filterM isWindowUrgent windows+ activeWindow <- getActiveWindow+ visibleWorkspaces <- getVisibleWorkspaces+ let activeWorkspace = case visibleWorkspaces of+ [] -> Nothing+ ws : _ -> Just ws+ nonFocusedVisibleWorkspaces = case visibleWorkspaces of+ [] -> []+ _ : rest -> rest+ toWorkspaceInfo (WorkspaceId idx, name) = do+ let workspaceKey = WorkspaceId idx+ wsWindowIds = MM.lookup workspaceKey workspaceToWindows+ wsViewState+ | Just workspaceKey == activeWorkspace = WorkspaceActive+ | workspaceKey `elem` nonFocusedVisibleWorkspaces = WorkspaceVisible+ | null wsWindowIds = WorkspaceEmpty+ | otherwise = WorkspaceHidden+ windowsInfo <- mapM (getWindowInfo activeWindow urgentWindows) wsWindowIds+ return+ WorkspaceInfo+ { workspaceIdentity =+ WorkspaceIdentity+ { workspaceNumericId = Just idx,+ workspaceName = T.pack name+ },+ workspaceState = wsViewState,+ workspaceHasUrgentWindow = any windowUrgent windowsInfo,+ workspaceIsSpecial = isSpecialWorkspaceName name,+ workspaceWindows = windowsInfo+ }+ mapM toWorkspaceInfo names++isSpecialWorkspaceName :: String -> Bool+isSpecialWorkspaceName name =+ let lowered = map toLower name+ in lowered == "nsp" || "special:" `isPrefixOf` lowered++getWorkspaceToWindows ::+ [X11Window] ->+ X11Property (MM.MultiMap WorkspaceId X11Window)+getWorkspaceToWindows =+ foldM+ (\theMap window -> MM.insert <$> getWorkspace window <*> pure window <*> pure theMap)+ MM.empty++getWindowInfo ::+ Maybe X11Window ->+ [X11Window] ->+ X11Window ->+ X11Property WindowInfo+getWindowInfo activeWindow urgentWindows window = do+ wTitle <- getWindowTitle window+ wClass <- getWindowClass window+ wMinimized <- getWindowMinimized window+ wPosition <- getWindowPosition window+ return+ WindowInfo+ { windowIdentity = X11WindowIdentity (fromIntegral window),+ windowTitle = T.pack wTitle,+ windowClassHints = map T.pack $ parseWindowClasses wClass,+ windowPosition = wPosition,+ windowUrgent = window `elem` urgentWindows,+ windowActive = Just window == activeWindow,+ windowMinimized = wMinimized+ }++getWindowPosition :: X11Window -> X11Property (Maybe (Int, Int))+getWindowPosition window = do+ display <- getDisplay+ liftIO $+ ((\(_, x, y, _, _, _, _) -> Just (fromIntegral x, fromIntegral y)) <$> safeGetGeometry display window)+ `catchAny` const (return Nothing)
+ src/System/Taffybar/Information/Workspaces/Hyprland.hs view
@@ -0,0 +1,341 @@+{-# LANGUAGE OverloadedStrings #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Information.Workspaces.Hyprland+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Shared Hyprland workspace-state provider using a broadcast channel + state MVar.+module System.Taffybar.Information.Workspaces.Hyprland+ ( HyprlandWorkspaceProviderConfig (..),+ defaultHyprlandWorkspaceProviderConfig,+ defaultHyprlandWorkspaceState,+ isRelevantHyprlandWorkspaceEvent,+ getHyprlandWorkspaceStateAndEventChansAndVar,+ getHyprlandWorkspaceStateAndEventChansAndVarWith,+ getHyprlandWorkspaceEventChan,+ getHyprlandWorkspaceEventChanWith,+ getHyprlandWorkspaceStateChanAndVar,+ getHyprlandWorkspaceStateChanAndVarWith,+ getHyprlandWorkspaceStateChan,+ getHyprlandWorkspaceStateChanWith,+ getHyprlandWorkspaceState,+ getHyprlandWorkspaceStateWith,+ )+where++import Control.Applicative ((<|>))+import Control.Concurrent.MVar+import Control.Concurrent.STM.TChan+import Control.Exception.Enclosed (catchAny)+import Control.Monad (forever, when)+import Control.Monad.IO.Class (MonadIO (..))+import Control.Monad.STM (atomically)+import Data.Either (isRight)+import qualified Data.Foldable as F+import Data.List (sortOn)+import qualified Data.Map.Strict as M+import Data.Maybe (catMaybes, fromMaybe, listToMaybe)+import qualified Data.Text as T+import System.Log.Logger (Priority (..), logM)+import System.Taffybar.Context (TaffyIO, getStateDefault, taffyFork)+import System.Taffybar.Hyprland (getHyprlandClient, getHyprlandEventChan)+import qualified System.Taffybar.Information.Hyprland as Hypr+import qualified System.Taffybar.Information.Hyprland.API as HyprAPI+import qualified System.Taffybar.Information.Hyprland.Types as HyprTypes+import System.Taffybar.Information.Workspaces.Model++data HyprlandWorkspaceProviderConfig = HyprlandWorkspaceProviderConfig+ { workspaceSnapshotGetter :: TaffyIO (Bool, [WorkspaceInfo]),+ workspaceEventFilter :: T.Text -> Bool+ }++defaultHyprlandWorkspaceProviderConfig :: HyprlandWorkspaceProviderConfig+defaultHyprlandWorkspaceProviderConfig =+ HyprlandWorkspaceProviderConfig+ { workspaceSnapshotGetter = buildHyprlandWorkspaceSnapshot,+ workspaceEventFilter = isRelevantHyprlandWorkspaceEvent+ }++defaultHyprlandWorkspaceState :: WorkspaceSnapshot+defaultHyprlandWorkspaceState =+ WorkspaceSnapshot+ { snapshotBackend = WorkspaceBackendHyprland,+ snapshotRevision = 0,+ snapshotWindowDataComplete = False,+ snapshotWorkspaces = []+ }++newtype HyprlandWorkspaceStateChanVar+ = HyprlandWorkspaceStateChanVar+ (TChan WorkspaceSnapshot, TChan WorkspaceEventBatch, MVar WorkspaceSnapshot)++wLog :: (MonadIO m) => Priority -> String -> m ()+wLog level msg = liftIO $ logM "System.Taffybar.Information.Workspaces.Hyprland" level msg++-- | Parse Hyprland event-socket lines and determine whether workspace state+-- should be refreshed.+isRelevantHyprlandWorkspaceEvent :: T.Text -> Bool+isRelevantHyprlandWorkspaceEvent line =+ let eventName = T.takeWhile (/= '>') line+ in eventName+ `elem` [ "workspace",+ "workspacev2",+ "focusedmon",+ "activewindow",+ "activewindowv2",+ "openwindow",+ "closewindow",+ "movewindow",+ "movewindowv2",+ "moveworkspace",+ "renameworkspace",+ "createworkspace",+ "destroyworkspace",+ "monitoradded",+ "monitorremoved",+ "taffybar-hyprland-connected"+ ]++getHyprlandWorkspaceStateAndEventChansAndVar ::+ TaffyIO (TChan WorkspaceSnapshot, TChan WorkspaceEventBatch, MVar WorkspaceSnapshot)+getHyprlandWorkspaceStateAndEventChansAndVar =+ getHyprlandWorkspaceStateAndEventChansAndVarWith defaultHyprlandWorkspaceProviderConfig++getHyprlandWorkspaceStateAndEventChansAndVarWith ::+ HyprlandWorkspaceProviderConfig ->+ TaffyIO (TChan WorkspaceSnapshot, TChan WorkspaceEventBatch, MVar WorkspaceSnapshot)+getHyprlandWorkspaceStateAndEventChansAndVarWith cfg = do+ HyprlandWorkspaceStateChanVar chansAndVar <- getStateDefault $ buildHyprlandWorkspaceStateChanVar cfg+ return chansAndVar++getHyprlandWorkspaceEventChan :: TaffyIO (TChan WorkspaceEventBatch)+getHyprlandWorkspaceEventChan =+ getHyprlandWorkspaceEventChanWith defaultHyprlandWorkspaceProviderConfig++getHyprlandWorkspaceEventChanWith ::+ HyprlandWorkspaceProviderConfig ->+ TaffyIO (TChan WorkspaceEventBatch)+getHyprlandWorkspaceEventChanWith cfg = do+ (_, eventChan, _) <- getHyprlandWorkspaceStateAndEventChansAndVarWith cfg+ return eventChan++getHyprlandWorkspaceStateChanAndVar ::+ TaffyIO (TChan WorkspaceSnapshot, MVar WorkspaceSnapshot)+getHyprlandWorkspaceStateChanAndVar =+ getHyprlandWorkspaceStateChanAndVarWith defaultHyprlandWorkspaceProviderConfig++-- | Obtain the shared Hyprland workspace state channel and state MVar.+--+-- Note: like other 'getStateDefault' based providers, the first caller wins:+-- if this provider has already been initialized, later calls with a different+-- config return the existing provider state.+getHyprlandWorkspaceStateChanAndVarWith ::+ HyprlandWorkspaceProviderConfig ->+ TaffyIO (TChan WorkspaceSnapshot, MVar WorkspaceSnapshot)+getHyprlandWorkspaceStateChanAndVarWith cfg = do+ (stateChan, _, stateVar) <- getHyprlandWorkspaceStateAndEventChansAndVarWith cfg+ return (stateChan, stateVar)++getHyprlandWorkspaceStateChan :: TaffyIO (TChan WorkspaceSnapshot)+getHyprlandWorkspaceStateChan =+ getHyprlandWorkspaceStateChanWith defaultHyprlandWorkspaceProviderConfig++getHyprlandWorkspaceStateChanWith ::+ HyprlandWorkspaceProviderConfig ->+ TaffyIO (TChan WorkspaceSnapshot)+getHyprlandWorkspaceStateChanWith cfg =+ fst <$> getHyprlandWorkspaceStateChanAndVarWith cfg++getHyprlandWorkspaceState :: TaffyIO WorkspaceSnapshot+getHyprlandWorkspaceState =+ getHyprlandWorkspaceStateWith defaultHyprlandWorkspaceProviderConfig++getHyprlandWorkspaceStateWith ::+ HyprlandWorkspaceProviderConfig ->+ TaffyIO WorkspaceSnapshot+getHyprlandWorkspaceStateWith cfg = do+ (_, var) <- getHyprlandWorkspaceStateChanAndVarWith cfg+ liftIO $ readMVar var++buildHyprlandWorkspaceStateChanVar ::+ HyprlandWorkspaceProviderConfig ->+ TaffyIO HyprlandWorkspaceStateChanVar+buildHyprlandWorkspaceStateChanVar cfg = do+ stateChan <- liftIO newBroadcastTChanIO+ eventChan <- liftIO newBroadcastTChanIO+ var <- liftIO $ newMVar defaultHyprlandWorkspaceState+ taffyFork $ hyprlandWorkspaceStateLoop cfg stateChan eventChan var+ return $ HyprlandWorkspaceStateChanVar (stateChan, eventChan, var)++hyprlandWorkspaceStateLoop ::+ HyprlandWorkspaceProviderConfig ->+ TChan WorkspaceSnapshot ->+ TChan WorkspaceEventBatch ->+ MVar WorkspaceSnapshot ->+ TaffyIO ()+hyprlandWorkspaceStateLoop cfg stateChan workspaceEventChan var = do+ refreshHyprlandWorkspaceState cfg stateChan workspaceEventChan var+ hyprlandEventChan <- getHyprlandEventChan+ events <- liftIO $ Hypr.subscribeHyprlandEvents hyprlandEventChan+ forever $ do+ line <- liftIO $ atomically $ readTChan events+ when (workspaceEventFilter cfg line) $+ refreshHyprlandWorkspaceState cfg stateChan workspaceEventChan var++refreshHyprlandWorkspaceState ::+ HyprlandWorkspaceProviderConfig ->+ TChan WorkspaceSnapshot ->+ TChan WorkspaceEventBatch ->+ MVar WorkspaceSnapshot ->+ TaffyIO ()+refreshHyprlandWorkspaceState cfg stateChan workspaceEventChan var = do+ previous <- liftIO $ readMVar var+ (complete, workspaces) <-+ workspaceSnapshotGetter cfg+ `catchAny` \err -> do+ wLog WARNING $ "Hyprland workspace snapshot update failed: " <> show err+ return (False, snapshotWorkspaces previous)+ let next =+ WorkspaceSnapshot+ { snapshotBackend = WorkspaceBackendHyprland,+ snapshotRevision = snapshotRevision previous + 1,+ snapshotWindowDataComplete = complete,+ snapshotWorkspaces = workspaces+ }+ eventBatch =+ WorkspaceEventBatch+ { eventBatchBackend = snapshotBackend next,+ eventBatchRevision = snapshotRevision next,+ eventBatchWindowDataComplete = snapshotWindowDataComplete next,+ eventBatchEvents = diffWorkspaceSnapshots previous next+ }+ liftIO $ do+ _ <- swapMVar var next+ atomically $ do+ writeTChan stateChan next+ writeTChan workspaceEventChan eventBatch++buildHyprlandWorkspaceSnapshot :: TaffyIO (Bool, [WorkspaceInfo])+buildHyprlandWorkspaceSnapshot = do+ client <- getHyprlandClient+ workspacesResult <- liftIO $ HyprAPI.getHyprlandWorkspaces client+ clientsResult <- liftIO $ HyprAPI.getHyprlandClients client+ monitorsResult <- liftIO $ HyprAPI.getHyprlandMonitors client+ activeWorkspaceResult <- liftIO $ HyprAPI.getHyprlandActiveWorkspace client+ activeWindowResult <- liftIO $ HyprAPI.getHyprlandActiveWindow client++ workspaces <- case workspacesResult of+ Left err -> wLog WARNING ("hyprctl workspaces failed: " <> show err) >> return []+ Right ws -> return ws++ clients <- case clientsResult of+ Left err -> wLog WARNING ("hyprctl clients failed: " <> show err) >> return []+ Right cs -> return cs++ monitors <- case monitorsResult of+ Left err -> wLog WARNING ("hyprctl monitors failed: " <> show err) >> return []+ Right ms -> return ms++ activeWorkspaceId <- case activeWorkspaceResult of+ Left err -> wLog WARNING ("hyprctl activeworkspace failed: " <> show err) >> return Nothing+ Right ws -> return $ HyprTypes.hyprActiveWorkspaceId ws++ activeWindowAddress <- case activeWindowResult of+ Left err -> wLog WARNING ("hyprctl activewindow failed: " <> show err) >> return Nothing+ Right win ->+ let address = HyprTypes.hyprActiveWindowAddress win+ in return $ if T.null address then Nothing else Just address++ let windowsByWorkspace = collectWorkspaceWindows activeWindowAddress clients+ sortedWorkspaces = sortOn HyprTypes.hyprWorkspaceId workspaces+ visibleWorkspaceIds =+ [ HyprTypes.hyprWorkspaceRefId wsRef+ | monitor <- monitors,+ Just wsRef <- [HyprTypes.hyprMonitorActiveWorkspace monitor]+ ]+ focusedWorkspaceId =+ listToMaybe+ [ HyprTypes.hyprWorkspaceRefId wsRef+ | monitor <- monitors,+ HyprTypes.hyprMonitorFocused monitor,+ Just wsRef <- [HyprTypes.hyprMonitorActiveWorkspace monitor]+ ]+ activeWorkspaceId' = focusedWorkspaceId <|> activeWorkspaceId+ clientsOk = isRight clientsResult+ toWorkspace wsInfo =+ let wsId = HyprTypes.hyprWorkspaceId wsInfo+ wsName = HyprTypes.hyprWorkspaceName wsInfo+ wsWindows = M.findWithDefault [] wsId windowsByWorkspace+ hasWindows =+ not (null wsWindows)+ || fromMaybe 0 (HyprTypes.hyprWorkspaceWindows wsInfo) > 0+ wsState+ | Just wsId == activeWorkspaceId' = WorkspaceActive+ | wsId `elem` visibleWorkspaceIds = WorkspaceVisible+ | not hasWindows && clientsOk = WorkspaceEmpty+ | otherwise = WorkspaceHidden+ in WorkspaceInfo+ { workspaceIdentity =+ WorkspaceIdentity+ { workspaceNumericId = Just wsId,+ workspaceName = wsName+ },+ workspaceState = wsState,+ workspaceHasUrgentWindow = any windowUrgent wsWindows,+ workspaceIsSpecial = isSpecialWorkspace wsId wsName,+ workspaceWindows = wsWindows+ }+ return (clientsOk, map toWorkspace sortedWorkspaces)++isSpecialWorkspace :: Int -> T.Text -> Bool+isSpecialWorkspace wsId wsName =+ let lowered = T.toLower wsName+ in wsId < 0 || T.isPrefixOf "special" lowered++collectWorkspaceWindows ::+ Maybe T.Text ->+ [HyprTypes.HyprlandClientInfo] ->+ M.Map Int [WindowInfo]+collectWorkspaceWindows activeWindowAddress =+ F.foldl' (addWindow activeWindowAddress) M.empty+ where+ addWindow activeAddr windowsMap client =+ let wsId = HyprTypes.hyprWorkspaceRefId $ HyprTypes.hyprClientWorkspace client+ windowData = windowFromClient activeAddr client+ in M.insertWith (++) wsId [windowData] windowsMap++windowFromClient :: Maybe T.Text -> HyprTypes.HyprlandClientInfo -> WindowInfo+windowFromClient activeAddr client =+ let rawTitle = HyprTypes.hyprClientTitle client+ title =+ if T.null rawTitle+ then fromMaybe "" (HyprTypes.hyprClientInitialTitle client)+ else rawTitle+ active =+ HyprTypes.hyprClientFocused client+ || Just (HyprTypes.hyprClientAddress client) == activeAddr+ minimized =+ HyprTypes.hyprClientHidden client+ || not (HyprTypes.hyprClientMapped client)+ in WindowInfo+ { windowIdentity = HyprlandWindowIdentity (HyprTypes.hyprClientAddress client),+ windowTitle = title,+ windowClassHints =+ catMaybes+ [ HyprTypes.hyprClientClass client,+ HyprTypes.hyprClientInitialClass client+ ],+ windowPosition = HyprTypes.hyprClientAt client,+ windowUrgent = HyprTypes.hyprClientUrgent client,+ windowActive = active,+ windowMinimized = minimized+ }
+ src/System/Taffybar/Information/Workspaces/Model.hs view
@@ -0,0 +1,173 @@+-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Information.Workspaces.Model+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Backend-agnostic workspace/window model shared by workspace-related widgets.+module System.Taffybar.Information.Workspaces.Model+ ( WorkspaceBackend (..),+ WorkspaceSnapshot (..),+ WorkspaceEventBatch (..),+ WorkspaceEvent (..),+ WindowEvent (..),+ diffWorkspaceSnapshots,+ WorkspaceViewState (..),+ WorkspaceIdentity (..),+ WorkspaceInfo (..),+ WindowIdentity (..),+ WindowInfo (..),+ )+where++import qualified Data.Map.Strict as M+import Data.Text (Text)+import Data.Word (Word64)++data WorkspaceBackend+ = WorkspaceBackendEWMH+ | WorkspaceBackendHyprland+ deriving (Eq, Show)++-- | Snapshot of workspace state produced by a backend provider.+data WorkspaceSnapshot = WorkspaceSnapshot+ { snapshotBackend :: WorkspaceBackend,+ snapshotRevision :: Word64,+ -- | Whether window-level data was complete in this snapshot.+ snapshotWindowDataComplete :: Bool,+ snapshotWorkspaces :: [WorkspaceInfo]+ }+ deriving (Eq, Show)++-- | Delta events corresponding to a snapshot revision.+--+-- Consumers that want incremental updates can subscribe to these events,+-- while snapshot consumers keep using 'WorkspaceSnapshot'.+data WorkspaceEventBatch = WorkspaceEventBatch+ { eventBatchBackend :: WorkspaceBackend,+ eventBatchRevision :: Word64,+ eventBatchWindowDataComplete :: Bool,+ eventBatchEvents :: [WorkspaceEvent]+ }+ deriving (Eq, Show)++data WorkspaceEvent+ = WorkspaceOrderChanged [WorkspaceIdentity]+ | WorkspaceAdded WorkspaceInfo+ | WorkspaceRemoved WorkspaceIdentity+ | WorkspaceChanged WorkspaceInfo+ | WorkspaceWindowsChanged WorkspaceIdentity [WindowEvent]+ deriving (Eq, Show)++data WindowEvent+ = WindowAdded WindowInfo+ | WindowRemoved WindowIdentity+ | WindowChanged WindowInfo+ deriving (Eq, Show)++-- | Visibility/occupancy state that controls workspace presentation.+data WorkspaceViewState+ = WorkspaceActive+ | WorkspaceVisible+ | WorkspaceHidden+ | WorkspaceEmpty+ deriving (Eq, Show)++-- | Backend-neutral workspace identity.+data WorkspaceIdentity = WorkspaceIdentity+ { workspaceNumericId :: Maybe Int,+ workspaceName :: Text+ }+ deriving (Eq, Ord, Show)++-- | Backend-neutral workspace information.+data WorkspaceInfo = WorkspaceInfo+ { workspaceIdentity :: WorkspaceIdentity,+ workspaceState :: WorkspaceViewState,+ workspaceHasUrgentWindow :: Bool,+ workspaceIsSpecial :: Bool,+ workspaceWindows :: [WindowInfo]+ }+ deriving (Eq, Show)++data WindowIdentity+ = X11WindowIdentity Word64+ | HyprlandWindowIdentity Text+ deriving (Eq, Ord, Show)++-- | Backend-neutral window information.+data WindowInfo = WindowInfo+ { windowIdentity :: WindowIdentity,+ windowTitle :: Text,+ -- | Ordered class/app-id hints used for icon lookup.+ windowClassHints :: [Text],+ -- | Top-left position in global coordinates when available.+ windowPosition :: Maybe (Int, Int),+ windowUrgent :: Bool,+ windowActive :: Bool,+ windowMinimized :: Bool+ }+ deriving (Eq, Show)++-- | Produce incremental events that describe the change from an old snapshot to+-- a new one.+diffWorkspaceSnapshots :: WorkspaceSnapshot -> WorkspaceSnapshot -> [WorkspaceEvent]+diffWorkspaceSnapshots previous next =+ orderEvent ++ removedEvents ++ addedEvents ++ changedEvents+ where+ oldWorkspaces = snapshotWorkspaces previous+ newWorkspaces = snapshotWorkspaces next+ oldOrder = map workspaceIdentity oldWorkspaces+ newOrder = map workspaceIdentity newWorkspaces+ orderEvent =+ [WorkspaceOrderChanged newOrder | oldOrder /= newOrder]+ oldMap = M.fromList [(workspaceIdentity ws, ws) | ws <- oldWorkspaces]+ newMap = M.fromList [(workspaceIdentity ws, ws) | ws <- newWorkspaces]+ removedEvents =+ [WorkspaceRemoved wsId | wsId <- M.keys oldMap, M.notMember wsId newMap]+ addedEvents =+ [WorkspaceAdded ws | (wsId, ws) <- M.toList newMap, M.notMember wsId oldMap]+ changedEvents =+ concatMap mkChangedEvents sharedIds+ sharedIds = M.keys $ M.intersection oldMap newMap+ mkChangedEvents wsId =+ case (M.lookup wsId oldMap, M.lookup wsId newMap) of+ (Just oldWs, Just newWs) ->+ let oldWithoutWindows = oldWs {workspaceWindows = []}+ newWithoutWindows = newWs {workspaceWindows = []}+ metadataEvents =+ [WorkspaceChanged newWs | oldWithoutWindows /= newWithoutWindows]+ windowEvents = diffWorkspaceWindows oldWs newWs+ windowsChangedEvents =+ [ WorkspaceWindowsChanged wsId windowEvents+ | not (null windowEvents)+ ]+ in metadataEvents ++ windowsChangedEvents+ _ -> []++diffWorkspaceWindows :: WorkspaceInfo -> WorkspaceInfo -> [WindowEvent]+diffWorkspaceWindows oldWs newWs =+ removedEvents ++ addedEvents ++ changedEvents+ where+ oldWindows = workspaceWindows oldWs+ newWindows = workspaceWindows newWs+ oldMap = M.fromList [(windowIdentity w, w) | w <- oldWindows]+ newMap = M.fromList [(windowIdentity w, w) | w <- newWindows]+ removedEvents =+ [WindowRemoved wId | wId <- M.keys oldMap, M.notMember wId newMap]+ addedEvents =+ [WindowAdded w | (wId, w) <- M.toList newMap, M.notMember wId oldMap]+ changedEvents =+ [ WindowChanged newW+ | wId <- M.keys $ M.intersection oldMap newMap,+ Just oldW <- [M.lookup wId oldMap],+ Just newW <- [M.lookup wId newMap],+ oldW /= newW+ ]
src/System/Taffybar/Information/X11DesktopInfo.hs view
@@ -3,6 +3,9 @@ {-# LANGUAGE RecordWildCards #-} -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.X11DesktopInfo -- Copyright : (c) José A. Romero L.@@ -20,48 +23,46 @@ -- > import XMonad.Hooks.TaffybarPagerHints (pagerHints) -- > -- > main = xmonad $ ewmh $ pagerHints $ ...---------------------------------------------------------------------------------- module System.Taffybar.Information.X11DesktopInfo ( -- * Context- X11Context- , DisplayName(..)- , getX11Context- , withX11Context+ X11Context,+ DisplayName (..),+ getX11Context,+ withX11Context, - -- * Properties- , X11Property- , X11PropertyT+ -- * Properties+ X11Property,+ X11PropertyT, - -- ** Event loop- , eventLoop+ -- ** Event loop+ eventLoop, - -- ** Context getters- , getDisplay- , getAtom+ -- ** Context getters+ getDisplay,+ getAtom, - -- ** Basic properties of windows- , X11Window- , PropertyFetcher- , fetch- , readAsInt- , readAsListOfInt- , readAsListOfString- , readAsListOfWindow- , readAsString+ -- ** Basic properties of windows+ X11Window,+ PropertyFetcher,+ fetch,+ readAsInt,+ readAsListOfInt,+ readAsListOfString,+ readAsListOfWindow,+ readAsString, - -- ** Getters- , isWindowUrgent- , getPrimaryOutputNumber- , getVisibleTags+ -- ** Getters+ isWindowUrgent,+ getPrimaryOutputNumber,+ getVisibleTags, - -- ** Operations- , doLowerWindow- , postX11RequestSyncProp- , sendCommandEvent- , sendWindowEvent- ) where+ -- ** Operations+ doLowerWindow,+ postX11RequestSyncProp,+ sendCommandEvent,+ sendWindowEvent,+ )+where import Codec.Binary.UTF8.String as UTF8 import qualified Control.Concurrent.MVar as MV@@ -70,29 +71,30 @@ import Control.Monad.Trans.Class import Control.Monad.Trans.Reader import Data.Bits (testBit, (.|.))-import Data.Default (Default(..))+import Data.Default (Default (..)) import Data.List (elemIndex) import Data.List.Split (endBy) import Data.Maybe (fromMaybe, listToMaybe) import GHC.Generics (Generic)-import Graphics.X11.Xrandr (XRRScreenResources(..), XRROutputInfo(..), xrrGetOutputInfo, xrrGetScreenResources, xrrGetOutputPrimary)+import Graphics.X11.Xrandr (XRROutputInfo (..), XRRScreenResources (..), xrrGetOutputInfo, xrrGetOutputPrimary, xrrGetScreenResources) import System.Taffybar.Information.SafeX11 hiding (displayName) -- | Represents a connection to an X11 display. -- Use 'getX11Context' to construct one of these. data X11Context = X11Context- { ctxDisplayName :: DisplayName- , ctxDisplay :: Display- , ctxRoot :: Window- , ctxAtomCache :: MV.MVar [(String, Atom)]+ { ctxDisplayName :: DisplayName,+ ctxDisplay :: Display,+ ctxRoot :: Window,+ ctxAtomCache :: MV.MVar [(String, Atom)] } -- | Specifies an X11 display to connect to.-data DisplayName = DefaultDisplay- -- ^ Use the @DISPLAY@ environment variable.- | DisplayName String- -- ^ Of the form @hostname:number.screen_number@- deriving (Show, Read, Eq, Ord, Generic)+data DisplayName+ = -- | Use the @DISPLAY@ environment variable.+ DefaultDisplay+ | -- | Of the form @hostname:number.screen_number@+ DisplayName String+ deriving (Show, Read, Eq, Ord, Generic) instance Default DisplayName where def = DefaultDisplay@@ -104,9 +106,14 @@ -- | A 'ReaderT' with 'X11Context'. type X11PropertyT m a = ReaderT X11Context m a+ -- | 'IO' actions with access to an 'X11Context'. type X11Property a = X11PropertyT IO a++-- | X11 window identifier. type X11Window = Window++-- | Low-level property fetcher for a given window and atom. type PropertyFetcher a = Display -> Atom -> X11Window -> IO (Maybe [a]) -- | Makes a connection to the default X11 display using@@ -124,53 +131,72 @@ getDisplay :: X11Property Display getDisplay = ctxDisplay <$> ask -doRead :: Integral a => b -> ([a] -> b)- -> PropertyFetcher a- -> Maybe X11Window- -> String- -> X11Property b+doRead ::+ (Integral a) =>+ b ->+ ([a] -> b) ->+ PropertyFetcher a ->+ Maybe X11Window ->+ String ->+ X11Property b doRead b transform windowPropFn window name = maybe b transform <$> fetch windowPropFn window name -- | Retrieve the property of the given window (or the root window, if Nothing) -- with the given name as a value of type Int. If that property hasn't been set, -- then return -1.-readAsInt :: Maybe X11Window -- ^ window to read from. Nothing means the root window.- -> String -- ^ name of the property to retrieve- -> X11Property Int+readAsInt ::+ -- | window to read from. Nothing means the root window.+ Maybe X11Window ->+ -- | name of the property to retrieve+ String ->+ X11Property Int readAsInt = doRead (-1) (maybe (-1) fromIntegral . listToMaybe) getWindowProperty32 -- | Retrieve the property of the given window (or the root window, if Nothing) -- with the given name as a list of Ints. If that property hasn't been set, then -- return an empty list.-readAsListOfInt :: Maybe X11Window -- ^ window to read from. Nothing means the root window.- -> String -- ^ name of the property to retrieve- -> X11Property [Int]+readAsListOfInt ::+ -- | window to read from. Nothing means the root window.+ Maybe X11Window ->+ -- | name of the property to retrieve+ String ->+ X11Property [Int] readAsListOfInt = doRead [] (map fromIntegral) getWindowProperty32 -- | Retrieve the property of the given window (or the root window, if Nothing) -- with the given name as a String. If the property hasn't been set, then return -- an empty string.-readAsString :: Maybe X11Window -- ^ window to read from. Nothing means the root window.- -> String -- ^ name of the property to retrieve- -> X11Property String+readAsString ::+ -- | window to read from. Nothing means the root window.+ Maybe X11Window ->+ -- | name of the property to retrieve+ String ->+ X11Property String readAsString = doRead "" (UTF8.decode . map fromIntegral) getWindowProperty8 -- | Retrieve the property of the given window (or the root window, if Nothing) -- with the given name as a list of Strings. If the property hasn't been set, -- then return an empty list.-readAsListOfString :: Maybe X11Window -- ^ window to read from. Nothing means the root window.- -> String -- ^ name of the property to retrieve- -> X11Property [String]+readAsListOfString ::+ -- | window to read from. Nothing means the root window.+ Maybe X11Window ->+ -- | name of the property to retrieve+ String ->+ X11Property [String] readAsListOfString = doRead [] parse getWindowProperty8- where parse = endBy "\0" . UTF8.decode . map fromIntegral+ where+ parse = endBy "\0" . UTF8.decode . map fromIntegral -- | Retrieve the property of the given window (or the root window, if Nothing) -- with the given name as a list of X11 Window IDs. If the property hasn't been -- set, then return an empty list.-readAsListOfWindow :: Maybe X11Window -- ^ window to read from. Nothing means the root window.- -> String -- ^ name of the property to retrieve- -> X11Property [X11Window]+readAsListOfWindow ::+ -- | window to read from. Nothing means the root window.+ Maybe X11Window ->+ -- | name of the property to retrieve+ String ->+ X11Property [X11Window] readAsListOfWindow = doRead [] (map fromIntegral) getWindowProperty32 -- | Determine whether the \"urgent\" flag is set in the WM_HINTS of the given@@ -198,7 +224,7 @@ updateCache currentCache = do atom <- internAtom d s False- return ((s, atom):currentCache, atom)+ return ((s, atom) : currentCache, atom) maybe updateCacheAction return a -- | Spawn a new thread and listen inside it to all incoming events, invoking@@ -213,7 +239,7 @@ allocaXEvent $ \e -> forever $ do event <- nextEvent d e >> getEvent e case event of- MapNotifyEvent { ev_window = window } ->+ MapNotifyEvent {ev_window = window} -> selectInput d window propertyChangeMask _ -> return () dispatch event@@ -239,17 +265,21 @@ d <- openDisplay $ fromDisplayName ctxDisplayName ctxRoot <- rootWindow d $ defaultScreen d ctxAtomCache <- MV.newMVar []- return $ X11Context{ctxDisplay=d,..}+ return $ X11Context {ctxDisplay = d, ..} -- | Apply the given function to the given window in order to obtain the X11 -- property with the given name, or Nothing if no such property can be read.-fetch :: (Integral a)- => PropertyFetcher a -- ^ Function to use to retrieve the property.- -> Maybe X11Window -- ^ Window to read from. Nothing means the root Window.- -> String -- ^ Name of the property to retrieve.- -> X11Property (Maybe [a])+fetch ::+ (Integral a) =>+ -- | Function to use to retrieve the property.+ PropertyFetcher a ->+ -- | Window to read from. Nothing means the root Window.+ Maybe X11Window ->+ -- | Name of the property to retrieve.+ String ->+ X11Property (Maybe [a]) fetch fetcher window name = do- X11Context{..} <- ask+ X11Context {..} <- ask atom <- getAtom name liftIO $ fetcher ctxDisplay atom (fromMaybe ctxRoot window) @@ -261,12 +291,16 @@ -- | Emit an event of type @ClientMessage@ that can be listened to and consumed -- by XMonad event hooks.-sendCustomEvent :: Atom -- ^ Command- -> Atom -- ^ Argument- -> Maybe X11Window -- ^ 'Just' a window, or 'Nothing' for the root window- -> X11Property ()+sendCustomEvent ::+ -- | Command+ Atom ->+ -- | Argument+ Atom ->+ -- | 'Just' a window, or 'Nothing' for the root window+ Maybe X11Window ->+ X11Property () sendCustomEvent cmd arg win = do- X11Context{..} <- ask+ X11Context {..} <- ask let win' = fromMaybe ctxRoot win liftIO $ allocaXEvent $ \e -> do setEventType e clientMessage@@ -293,7 +327,7 @@ -- | Return all the active RANDR outputs. getActiveOutputs :: X11Property [RROutput] getActiveOutputs = do- X11Context{..} <- ask+ X11Context {..} <- ask liftIO (xrrGetScreenResources ctxDisplay ctxRoot) >>= \case Just sres -> filterM (isActiveOutput sres) (xrr_sr_outputs sres) Nothing -> return []@@ -301,7 +335,7 @@ -- | Get the index of the primary monitor as set and ordered by Xrandr. getPrimaryOutputNumber :: X11Property (Maybe Int) getPrimaryOutputNumber = do- X11Context{..} <- ask+ X11Context {..} <- ask primary <- liftIO $ xrrGetOutputPrimary ctxDisplay ctxRoot outputs <- getActiveOutputs return $ primary `elemIndex` outputs
src/System/Taffybar/Information/XDG/Protocol.hs view
@@ -1,4 +1,10 @@ -----------------------------------------------------------------------------++---- specification, see+-- See also 'MenuWidget'.+--+-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Information.XDG.Protocol -- Copyright : 2017 Ulf Jasper@@ -11,55 +17,55 @@ -- Implementation of version 1.1 of the XDG "Desktop Menu -- Specification", see -- https://specifications.freedesktop.org/menu-spec/menu-spec-1.1.html----- specification, see--- See also 'MenuWidget'.--------------------------------------------------------------------------------- module System.Taffybar.Information.XDG.Protocol- ( XDGMenu(..)- , DesktopEntryCondition(..)- , getApplicationEntries- , getDirectoryDirs- , getPreferredLanguages- , getXDGDesktop- , getXDGMenuFilenames- , matchesCondition- , readXDGMenu- ) where+ ( XDGMenu (..),+ DesktopEntryCondition (..),+ getApplicationEntries,+ getDirectoryDirs,+ getPreferredLanguages,+ getXDGDesktop,+ getXDGMenuFilenames,+ matchesCondition,+ readXDGMenu,+ )+where -import Control.Applicative-import Control.Monad-import Control.Monad.Trans.Class-import Control.Monad.Trans.Maybe-import Data.Char (toLower)-import Data.List-import Data.Maybe+import Control.Applicative+import Control.Monad+import Control.Monad.Trans.Class+import Control.Monad.Trans.Maybe+import Data.Char (toLower)+import Data.List+import Data.Maybe import qualified Debug.Trace as D-import GHC.IO.Encoding-import Safe (headMay)-import System.Directory-import System.Environment-import System.Environment.XDG.DesktopEntry-import System.FilePath.Posix-import System.Log.Logger-import System.Posix.Files-import System.Taffybar.Util-import Text.XML.Light-import Text.XML.Light.Helpers+import GHC.IO.Encoding+import Safe (headMay)+import System.Directory+import System.Environment+import System.Environment.XDG.DesktopEntry+import System.FilePath.Posix+import System.Log.Logger+import System.Posix.Files+import System.Taffybar.Util+import Text.XML.Light+import Text.XML.Light.Helpers getXDGMenuPrefix :: IO (Maybe String) getXDGMenuPrefix = lookupEnv "XDG_MENU_PREFIX" -- | Find filename(s) of the application menu(s).-getXDGMenuFilenames- :: Maybe String -- ^ Overrides the value of the environment variable- -- XDG_MENU_PREFIX. Specifies the prefix for the menu (e.g.- -- 'Just "mate-"').- -> IO [FilePath]+getXDGMenuFilenames ::+ -- | Overrides the value of the environment variable+ -- XDG_MENU_PREFIX. Specifies the prefix for the menu (e.g.+ -- 'Just "mate-"').+ Maybe String ->+ IO [FilePath] getXDGMenuFilenames mMenuPrefix = do configDirs <-- liftA2 (:) (getXdgDirectory XdgConfig "")- (getXdgDirectoryList XdgConfigDirs)+ liftA2+ (:)+ (getXdgDirectory XdgConfig "")+ (getXdgDirectoryList XdgConfigDirs) maybePrefix <- (mMenuPrefix <|>) <$> getXDGMenuPrefix let maybeAddDash t = if last t == '-' then t else t ++ "-" dashedPrefix = maybe "" maybeAddDash maybePrefix@@ -67,38 +73,55 @@ -- | XDG Menu, cf. "Desktop Menu Specification". data XDGMenu = XDGMenu- { xmAppDir :: Maybe String- , xmDefaultAppDirs :: Bool -- Use $XDG_DATA_DIRS/applications- , xmDirectoryDir :: Maybe String- , xmDefaultDirectoryDirs :: Bool -- Use $XDG_DATA_DIRS/desktop-directories- , xmLegacyDirs :: [String]- , xmName :: String- , xmDirectory :: String- , xmOnlyUnallocated :: Bool- , xmDeleted :: Bool- , xmInclude :: Maybe DesktopEntryCondition- , xmExclude :: Maybe DesktopEntryCondition- , xmSubmenus :: [XDGMenu]- , xmLayout :: [XDGLayoutItem]- } deriving (Show)+ { xmAppDir :: Maybe String,+ xmDefaultAppDirs :: Bool, -- Use $XDG_DATA_DIRS/applications+ xmDirectoryDir :: Maybe String,+ xmDefaultDirectoryDirs :: Bool, -- Use $XDG_DATA_DIRS/desktop-directories+ xmLegacyDirs :: [String],+ xmName :: String,+ xmDirectory :: String,+ xmOnlyUnallocated :: Bool,+ xmDeleted :: Bool,+ xmInclude :: Maybe DesktopEntryCondition,+ xmExclude :: Maybe DesktopEntryCondition,+ xmSubmenus :: [XDGMenu],+ xmLayout :: [XDGLayoutItem]+ }+ deriving (Show) -data XDGLayoutItem =- XliFile String | XliSeparator | XliMenu String | XliMerge String- deriving(Show)+data XDGLayoutItem+ = XliFile String+ | XliSeparator+ | XliMenu String+ | XliMerge String+ deriving (Show) -- | Return a list of all available desktop entries for a given xdg menu.-getApplicationEntries- :: [String] -- ^ Preferred languages- -> XDGMenu- -> IO [DesktopEntry]+getApplicationEntries ::+ -- | Preferred languages+ [String] ->+ XDGMenu ->+ IO [DesktopEntry] getApplicationEntries langs xm = do- defEntries <- if xmDefaultAppDirs xm- then do dataDirs <- getXDGDataDirs- concat <$> mapM (listDesktopEntries ".desktop" .- (</> "applications")) dataDirs- else return []- return $ sortBy (\de1 de2 -> compare (map toLower (deName langs de1))- (map toLower (deName langs de2))) defEntries+ defEntries <-+ if xmDefaultAppDirs xm+ then do+ dataDirs <- getXDGDataDirs+ concat+ <$> mapM+ ( listDesktopEntries ".desktop"+ . (</> "applications")+ )+ dataDirs+ else return []+ return $+ sortBy+ ( \de1 de2 ->+ compare+ (map toLower (deName langs de1))+ (map toLower (deName langs de2))+ )+ defEntries -- | Parse menu. parseMenu :: Element -> Maybe XDGMenu@@ -110,8 +133,9 @@ name = fromMaybe "Name?" $ getChildData "Name" elt dir = fromMaybe "Dir?" $ getChildData "Directory" elt onlyUnallocated =- case ( getChildData "OnlyUnallocated" elt- , getChildData "NotOnlyUnallocated" elt) of+ case ( getChildData "OnlyUnallocated" elt,+ getChildData "NotOnlyUnallocated" elt+ ) of (Nothing, Nothing) -> False -- ?! (Nothing, Just _) -> False (Just _, Nothing) -> True@@ -121,94 +145,108 @@ exclude = parseConditions "Exclude" elt layout = parseLayout elt subMenus = fromMaybe [] $ mapChildren "Menu" elt parseMenu- in Just- XDGMenu- { xmAppDir = appDir- , xmDefaultAppDirs = defaultAppDirs- , xmDirectoryDir = directoryDir- , xmDefaultDirectoryDirs = defaultDirectoryDirs- , xmLegacyDirs = []- , xmName = name- , xmDirectory = dir- , xmOnlyUnallocated = onlyUnallocated- , xmDeleted = deleted- , xmInclude = include- , xmExclude = exclude- , xmSubmenus = subMenus- , xmLayout = layout -- FIXME- }+ in Just+ XDGMenu+ { xmAppDir = appDir,+ xmDefaultAppDirs = defaultAppDirs,+ xmDirectoryDir = directoryDir,+ xmDefaultDirectoryDirs = defaultDirectoryDirs,+ xmLegacyDirs = [],+ xmName = name,+ xmDirectory = dir,+ xmOnlyUnallocated = onlyUnallocated,+ xmDeleted = deleted,+ xmInclude = include,+ xmExclude = exclude,+ xmSubmenus = subMenus,+ xmLayout = layout -- FIXME+ } -- | Parse Desktop Entry conditions for Include/Exclude clauses. parseConditions :: String -> Element -> Maybe DesktopEntryCondition parseConditions key elt = case findChild (unqual key) elt of Nothing -> Nothing Just inc -> doParseConditions (elChildren inc)- where doParseConditions :: [Element] -> Maybe DesktopEntryCondition- doParseConditions [] = Nothing- doParseConditions [e] = parseSingleItem e- doParseConditions elts = Just $ Or $ mapMaybe parseSingleItem elts+ where+ doParseConditions :: [Element] -> Maybe DesktopEntryCondition+ doParseConditions [] = Nothing+ doParseConditions [e] = parseSingleItem e+ doParseConditions elts = Just $ Or $ mapMaybe parseSingleItem elts - parseSingleItem e = case qName (elName e) of- "Category" -> Just $ Category $ strContent e- "Filename" -> Just $ Filename $ strContent e- "And" -> Just $ And $ mapMaybe parseSingleItem- $ elChildren e- "Or" -> Just $ Or $ mapMaybe parseSingleItem- $ elChildren e- "Not" -> Not <$> (parseSingleItem =<< listToMaybe (elChildren e))- unknown -> D.trace ("Unknown Condition item: " ++ unknown) Nothing+ parseSingleItem e = case qName (elName e) of+ "Category" -> Just $ Category $ strContent e+ "Filename" -> Just $ Filename $ strContent e+ "And" ->+ Just $+ And $+ mapMaybe parseSingleItem $+ elChildren e+ "Or" ->+ Just $+ Or $+ mapMaybe parseSingleItem $+ elChildren e+ "Not" -> Not <$> (parseSingleItem =<< listToMaybe (elChildren e))+ unknown -> D.trace ("Unknown Condition item: " ++ unknown) Nothing -- | Combinable conditions for Include and Exclude statements.-data DesktopEntryCondition = Category String- | Filename String- | Not DesktopEntryCondition- | And [DesktopEntryCondition]- | Or [DesktopEntryCondition]- | All- | None+data DesktopEntryCondition+ = Category String+ | Filename String+ | Not DesktopEntryCondition+ | And [DesktopEntryCondition]+ | Or [DesktopEntryCondition]+ | All+ | None deriving (Read, Show, Eq) parseLayout :: Element -> [XDGLayoutItem] parseLayout elt = case findChild (unqual "Layout") elt of Nothing -> [] Just lt -> mapMaybe parseLayoutItem (elChildren lt)- where parseLayoutItem :: Element -> Maybe XDGLayoutItem- parseLayoutItem e = case qName (elName e) of- "Separator" -> Just XliSeparator- "Filename" -> Just $ XliFile $ strContent e- unknown -> D.trace ("Unknown layout item: " ++ unknown) Nothing+ where+ parseLayoutItem :: Element -> Maybe XDGLayoutItem+ parseLayoutItem e = case qName (elName e) of+ "Separator" -> Just XliSeparator+ "Filename" -> Just $ XliFile $ strContent e+ unknown -> D.trace ("Unknown layout item: " ++ unknown) Nothing -- | Determine whether a desktop entry fulfils a condition. matchesCondition :: DesktopEntry -> DesktopEntryCondition -> Bool matchesCondition de (Category cat) = deHasCategory de cat-matchesCondition de (Filename fn) = fn == deFilename de-matchesCondition de (Not cond) = not $ matchesCondition de cond-matchesCondition de (And conds) = all (matchesCondition de) conds-matchesCondition de (Or conds) = any (matchesCondition de) conds-matchesCondition _ All = True-matchesCondition _ None = False+matchesCondition de (Filename fn) = fn == deFilename de+matchesCondition de (Not cond) = not $ matchesCondition de cond+matchesCondition de (And conds) = all (matchesCondition de) conds+matchesCondition de (Or conds) = any (matchesCondition de) conds+matchesCondition _ All = True+matchesCondition _ None = False -- | Determine locale language settings getPreferredLanguages :: IO [String] getPreferredLanguages = do mLcMessages <- lookupEnv "LC_MESSAGES" lang <- case mLcMessages of- Nothing -> lookupEnv "LANG" -- FIXME?- Just lm -> return (Just lm)+ Nothing -> lookupEnv "LANG" -- FIXME?+ Just lm -> return (Just lm) case lang of Nothing -> return []- Just l -> return $- let woEncoding = takeWhile (/= '.') l- (language, _cm) = span (/= '_') woEncoding- (country, _m) = span (/= '@') (drop 1 _cm)- modifier = drop 1 _m- in dgl language country modifier- where dgl "" "" "" = []- dgl l "" "" = [l]- dgl l c "" = [l ++ "_" ++ c, l]- dgl l "" m = [l ++ "@" ++ m, l]- dgl l c m = [l ++ "_" ++ c ++ "@" ++ m, l ++ "_" ++ c,- l ++ "@" ++ m]+ Just l ->+ return $+ let woEncoding = takeWhile (/= '.') l+ (language, _cm) = span (/= '_') woEncoding+ (country, _m) = span (/= '@') (drop 1 _cm)+ modifier = drop 1 _m+ in dgl language country modifier+ where+ dgl "" "" "" = []+ dgl l "" "" = [l]+ dgl l c "" = [l ++ "_" ++ c, l]+ dgl l "" m = [l ++ "@" ++ m, l]+ dgl l c m =+ [ l ++ "_" ++ c ++ "@" ++ m,+ l ++ "_" ++ c,+ l ++ "@" ++ m+ ] -- | Determine current Desktop getXDGDesktop :: IO String@@ -232,15 +270,18 @@ -- | Load and assemble the XDG menu from a specific file, if it exists. maybeMenu :: FilePath -> IO (Maybe (XDGMenu, [DesktopEntry])) maybeMenu filename =- ifM (doesFileExist filename)- (do+ ifM+ (doesFileExist filename)+ ( do contents <- readFile filename langs <- getPreferredLanguages runMaybeT $ do m <- MaybeT $ return $ parseXMLDoc contents >>= parseMenu des <- lift $ getApplicationEntries langs m- return (m, des))- (do- logM "System.Taffybar.Information.XDG.Protocol" WARNING $- "Menu file '" ++ filename ++ "' does not exist!"- return Nothing)+ return (m, des)+ )+ ( do+ logM "System.Taffybar.Information.XDG.Protocol" WARNING $+ "Menu file '" ++ filename ++ "' does not exist!"+ return Nothing+ )
src/System/Taffybar/LogFormatter.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.LogFormatter -- Copyright : (c) Ivan A. Malison@@ -7,19 +10,22 @@ -- Maintainer : Ivan A. Malison -- Stability : unstable -- Portability : unportable------------------------------------------------------------------------------+--+-- ANSI-colored log formatting helpers for taffybar logging output. module System.Taffybar.LogFormatter where import System.Console.ANSI+import System.IO import System.Log.Formatter import System.Log.Handler.Simple import System.Log.Logger import Text.Printf-import System.IO +-- | Build an ANSI foreground color escape sequence. setColor :: Color -> String setColor color = setSGRCode [SetColor Foreground Vivid color] +-- | Choose a color for a log priority. priorityToColor :: Priority -> Color priorityToColor CRITICAL = Red priorityToColor ALERT = Red@@ -30,20 +36,28 @@ priorityToColor INFO = Blue priorityToColor DEBUG = Green +-- | ANSI reset sequence. reset :: String reset = setSGRCode [Reset] +-- | Wrap text in ANSI color escapes and reset formatting afterward. colorize :: Color -> String -> String colorize color txt = setColor color <> txt <> reset +-- | Formatter used for taffybar logs. taffyLogFormatter :: LogFormatter a taffyLogFormatter _ (level, msg) name = return $ printf "%s %s - %s" colorizedPriority colorizedName msg- where priorityColor = priorityToColor level- colorizedPriority = colorize priorityColor- ("[" <> show level <> "]")- colorizedName = colorize Green name+ where+ priorityColor = priorityToColor level+ colorizedPriority =+ colorize+ priorityColor+ ("[" <> show level <> "]")+ colorizedName = colorize Green name +-- | Default stderr log handler configured with 'taffyLogFormatter'. taffyLogHandler :: IO (GenericHandler Handle) taffyLogHandler = setFormatter <$> streamHandler stderr DEBUG- where setFormatter h = h { formatter = taffyLogFormatter }+ where+ setFormatter h = h {formatter = taffyLogFormatter}
src/System/Taffybar/LogLevels.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.LogLevels -- Copyright : (c) Ivan A. Malison@@ -22,22 +26,21 @@ -- -- Valid log levels are: @DEBUG@, @INFO@, @NOTICE@, @WARNING@, @ERROR@, -- @CRITICAL@, @ALERT@, @EMERGENCY@.------------------------------------------------------------------------------- module System.Taffybar.LogLevels- ( loadLogLevelsFromFile- , defaultLogLevelsPath- ) where+ ( loadLogLevelsFromFile,+ defaultLogLevelsPath,+ )+where -import Control.Monad (forM_, when)-import Data.Aeson (Value(..))+import Control.Monad (forM_, when)+import Data.Aeson (Value (..)) import qualified Data.Aeson.Key as Key import qualified Data.Aeson.KeyMap as KM import qualified Data.Text as T import qualified Data.Yaml as Yaml-import System.Directory (doesFileExist)-import System.Environment.XDG.BaseDir (getUserConfigFile)-import System.Log.Logger+import System.Directory (doesFileExist)+import System.Environment.XDG.BaseDir (getUserConfigFile)+import System.Log.Logger -- | The default path for the log levels configuration file: -- @~\/.config\/taffybar\/log-levels.yaml@.@@ -88,12 +91,12 @@ readPriority :: String -> Maybe Priority readPriority s = case s of- "DEBUG" -> Just DEBUG- "INFO" -> Just INFO- "NOTICE" -> Just NOTICE- "WARNING" -> Just WARNING- "ERROR" -> Just ERROR- "CRITICAL" -> Just CRITICAL- "ALERT" -> Just ALERT+ "DEBUG" -> Just DEBUG+ "INFO" -> Just INFO+ "NOTICE" -> Just NOTICE+ "WARNING" -> Just WARNING+ "ERROR" -> Just ERROR+ "CRITICAL" -> Just CRITICAL+ "ALERT" -> Just ALERT "EMERGENCY" -> Just EMERGENCY- _ -> Nothing+ _ -> Nothing
src/System/Taffybar/SimpleConfig.hs view
@@ -1,4 +1,10 @@+{-# LANGUAGE PatternSynonyms #-}+{-# LANGUAGE ViewPatterns #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.SimpleConfig -- Copyright : (c) Ivan A. Malison@@ -10,35 +16,55 @@ -- -- This module defines a simpler, but less flexible config system than the one -- offered in "System.Taffybar.Context".------------------------------------------------------------------------------ module System.Taffybar.SimpleConfig- ( SimpleTaffyConfig(..)- , Position(..)- , defaultSimpleTaffyConfig- , simpleDyreTaffybar- , simpleTaffybar- , toTaffyConfig- , toTaffybarConfig- , useAllMonitors- , usePrimaryMonitor- , StrutSize(..)- ) where+ ( SimpleTaffyConfig (..),+ BarLevelConfig (..),+ Position (..),+ defaultSimpleTaffyConfig,+ simpleDyreTaffybar,+ simpleTaffybar,+ toTaffyConfig,+ toTaffybarConfig,+ useAllMonitors,+ usePrimaryMonitor,+ StrutSize,+ pattern ExactSize,+ pattern ScreenRatio,+ )+where import qualified Control.Concurrent.MVar as MV-import Control.Monad-import Control.Monad.Trans.Class-import Data.Default (Default(..))-import Data.List-import Data.Maybe-import Data.Unique+import Control.Monad+import Control.Monad.Trans.Class+import Data.Default (Default (..))+import Data.List+import Data.Maybe+import Data.Unique+import GI.Gdk import qualified GI.Gtk as Gtk-import GI.Gdk-import Graphics.UI.GIGtkStrut-import System.Taffybar-import qualified System.Taffybar.Context as BC (BarConfig(..), TaffybarConfig(..))-import System.Taffybar.Context hiding (TaffybarConfig(..), BarConfig(..))-import System.Taffybar.Util+import Graphics.UI.GIGtkStrut hiding (ExactSize, ScreenRatio, StrutSize)+import qualified Graphics.UI.GIGtkStrut as GIGtkStrut+import System.Taffybar+import System.Taffybar.Context hiding (BarConfig (..), TaffybarConfig (..))+import qualified System.Taffybar.Context as BC (BarConfig (..), TaffybarConfig (..))+import System.Taffybar.Util +-- | Size of a bar strut reservation. This is an alias for+-- 'Graphics.UI.GIGtkStrut.StrutSize'.+type StrutSize = GIGtkStrut.StrutSize++-- | Reserve an exact number of pixels.+pattern ExactSize :: Int -> StrutSize+pattern ExactSize size <- GIGtkStrut.ExactSize (fromIntegral -> size)+ where+ ExactSize size = GIGtkStrut.ExactSize (fromIntegral size)++-- | Reserve a fraction of the monitor dimension as a strut size.+pattern ScreenRatio :: Rational -> StrutSize+pattern ScreenRatio ratio = GIGtkStrut.ScreenRatio ratio++{-# COMPLETE ExactSize, ScreenRatio #-}+ -- | An ADT representing the edge of the monitor along which taffybar should be -- displayed. data Position = Top | Bottom@@ -48,45 +74,49 @@ -- 'TaffybarConfig'. Unless you have a good reason to use taffybar's more -- advanced interface, you should stick to using this one. data SimpleTaffyConfig = SimpleTaffyConfig- {- -- | The monitor number to put the bar on (default: 'usePrimaryMonitor')- monitorsAction :: TaffyIO [Int]- -- | Number of pixels to reserve for the bar (default: 30)- , barHeight :: StrutSize- -- | Number of additional pixels to reserve for the bar strut (default: 0)- , barPadding :: Int- -- | The position of the bar on the screen (default: 'Top')- , barPosition :: Position- -- | The number of pixels between widgets (default: 5)- , widgetSpacing :: Int- -- | Widget constructors whose outputs are placed at the beginning of the bar- , startWidgets :: [TaffyIO Gtk.Widget]- -- | Widget constructors whose outputs are placed in the center of the bar- , centerWidgets :: [TaffyIO Gtk.Widget]- -- | Widget constructors whose outputs are placed at the end of the bar- , endWidgets :: [TaffyIO Gtk.Widget]- -- | List of paths to CSS stylesheets that should be loaded at startup.- , cssPaths :: [FilePath]- -- | Hook to run at taffybar startup.- , startupHook :: TaffyIO ()+ { -- | The monitor number to put the bar on (default: 'usePrimaryMonitor')+ monitorsAction :: TaffyIO [Int],+ -- | Number of pixels to reserve for the bar (default: 30)+ barHeight :: StrutSize,+ -- | Number of additional pixels to reserve for the bar strut (default: 0)+ barPadding :: Int,+ -- | The position of the bar on the screen (default: 'Top')+ barPosition :: Position,+ -- | The number of pixels between widgets (default: 5)+ widgetSpacing :: Int,+ -- | Widget constructors whose outputs are placed at the beginning of the bar+ startWidgets :: [TaffyIO Gtk.Widget],+ -- | Widget constructors whose outputs are placed in the center of the bar+ centerWidgets :: [TaffyIO Gtk.Widget],+ -- | Widget constructors whose outputs are placed at the end of the bar+ endWidgets :: [TaffyIO Gtk.Widget],+ -- | Optional level-based widget configuration. If this field is set,+ -- 'startWidgets', 'centerWidgets' and 'endWidgets' are ignored.+ barLevels :: Maybe [BarLevelConfig],+ -- | List of paths to CSS stylesheets that should be loaded at startup.+ cssPaths :: [FilePath],+ -- | Hook to run at taffybar startup.+ startupHook :: TaffyIO () } -- | Sensible defaults for most of the fields of 'SimpleTaffyConfig'. You'll -- need to specify the widgets you want in the bar with 'startWidgets', -- 'centerWidgets' and 'endWidgets'. defaultSimpleTaffyConfig :: SimpleTaffyConfig-defaultSimpleTaffyConfig = SimpleTaffyConfig- { monitorsAction = useAllMonitors- , barHeight = ScreenRatio $ 1 / 27- , barPadding = 0- , barPosition = Top- , widgetSpacing = 5- , startWidgets = []- , centerWidgets = []- , endWidgets = []- , cssPaths = []- , startupHook = return ()- }+defaultSimpleTaffyConfig =+ SimpleTaffyConfig+ { monitorsAction = useAllMonitors,+ barHeight = ScreenRatio $ 1 / 27,+ barPadding = 0,+ barPosition = Top,+ widgetSpacing = 5,+ startWidgets = [],+ centerWidgets = [],+ endWidgets = [],+ barLevels = Nothing,+ cssPaths = [],+ startupHook = return ()+ } instance Default SimpleTaffyConfig where def = defaultSimpleTaffyConfig@@ -94,21 +124,24 @@ -- | Convert a 'SimpleTaffyConfig' into a 'StrutConfig' that can be used with -- gtk-strut. toStrutConfig :: SimpleTaffyConfig -> Int -> StrutConfig-toStrutConfig SimpleTaffyConfig { barHeight = height- , barPadding = padding- , barPosition = pos- } monitor =- defaultStrutConfig- { strutHeight = height- , strutYPadding = fromIntegral padding- , strutXPadding = fromIntegral padding- , strutAlignment = Center- , strutMonitor = Just $ fromIntegral monitor- , strutPosition =- case pos of- Top -> TopPos- Bottom -> BottomPos- }+toStrutConfig+ SimpleTaffyConfig+ { barHeight = height,+ barPadding = padding,+ barPosition = pos+ }+ monitor =+ defaultStrutConfig+ { strutHeight = height,+ strutYPadding = fromIntegral padding,+ strutXPadding = fromIntegral padding,+ strutAlignment = Center,+ strutMonitor = Just $ fromIntegral monitor,+ strutPosition =+ case pos of+ Top -> TopPos+ Bottom -> BottomPos+ } toBarConfig :: SimpleTaffyConfig -> Int -> IO BC.BarConfig toBarConfig config monitor = do@@ -116,17 +149,20 @@ barId <- newUnique return BC.BarConfig- { BC.strutConfig = strutConfig- , BC.widgetSpacing = fromIntegral $ widgetSpacing config- , BC.startWidgets = startWidgets config- , BC.centerWidgets = centerWidgets config- , BC.endWidgets = endWidgets config- , BC.barId = barId- }+ { BC.strutConfig = strutConfig,+ BC.widgetSpacing = fromIntegral $ widgetSpacing config,+ BC.startWidgets = startWidgets config,+ BC.centerWidgets = centerWidgets config,+ BC.endWidgets = endWidgets config,+ BC.barLevels = barLevels config,+ BC.barId = barId+ } newtype SimpleBarConfigs = SimpleBarConfigs (MV.MVar [(Int, BC.BarConfig)]) {-# DEPRECATED toTaffyConfig "Use toTaffybarConfig instead" #-}++-- | Deprecated alias for 'toTaffybarConfig'. toTaffyConfig :: SimpleTaffyConfig -> BC.TaffybarConfig toTaffyConfig = toTaffybarConfig @@ -134,10 +170,10 @@ -- with 'startTaffybar' or 'dyreTaffybar'. toTaffybarConfig :: SimpleTaffyConfig -> BC.TaffybarConfig toTaffybarConfig conf =- def- { BC.getBarConfigsParam = configGetter- , BC.cssPaths = cssPaths conf- , BC.startupHook = startupHook conf+ def+ { BC.getBarConfigsParam = configGetter,+ BC.cssPaths = cssPaths conf,+ BC.startupHook = startupHook conf } where configGetter = do@@ -149,10 +185,9 @@ (monitorNumber, lookup monitorNumber barConfigs) lookupAndUpdate barConfigs = do- let (alreadyPresent, toCreate) = partition (isJust . snd) $- map (lookupWithIndex barConfigs) monitorNumbers+ map (lookupWithIndex barConfigs) monitorNumbers alreadyPresentConfigs = mapMaybe snd alreadyPresent newlyCreated <-@@ -172,15 +207,19 @@ getMonitorCount :: IO Int getMonitorCount =- fromIntegral <$> (screenGetDefault >>= maybe (return 0)- (screenGetDisplay >=> displayGetNMonitors))+ fromIntegral+ <$> ( screenGetDefault+ >>= maybe+ (return 0)+ (screenGetDisplay >=> displayGetNMonitors)+ ) -- | Supply this value for 'monitorsAction' to display the taffybar window on -- all monitors. useAllMonitors :: TaffyIO [Int] useAllMonitors = lift $ do count <- getMonitorCount- return [0..count-1]+ return [0 .. count - 1] -- | Supply this value for 'monitorsAction' to display the taffybar window only -- on the primary monitor.@@ -192,7 +231,7 @@ Just display -> do maybePrimary <- displayGetPrimaryMonitor display monitorCount <- displayGetNMonitors display- monitors <- catMaybes <$> mapM (displayGetMonitor display) [0..(monitorCount-1)]+ monitors <- catMaybes <$> mapM (displayGetMonitor display) [0 .. (monitorCount - 1)] let primaryIndex = case maybePrimary of Nothing -> 0 Just primary -> fromMaybe 0 (elemIndex primary monitors)
src/System/Taffybar/Support/PagerHints.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Support.PagerHints -- Copyright : (c) José A. Romero L.@@ -27,15 +30,13 @@ -- -- The second one should be considered read-only, and is set every time -- XMonad calls its log hooks.---------------------------------------------------------------------------------- module System.Taffybar.Support.PagerHints- {-# DEPRECATED "Use XMonad.Hooks.TaffybarPagerHints instead" #-} (- -- * Usage- -- $usage- pagerHints- ) where+ {-# DEPRECATED "Use XMonad.Hooks.TaffybarPagerHints instead" #-}+ ( -- * Usage+ -- $usage+ pagerHints,+ )+where import Codec.Binary.UTF8.String (encode) import Control.Monad@@ -64,9 +65,13 @@ -- | Add support for the \"Current Layout\" and \"Visible Workspaces\" custom -- hints to the given config. pagerHints :: XConfig a -> XConfig a-pagerHints c = c { handleEventHook = handleEventHook c +++ pagerHintsEventHook- , logHook = logHook c +++ pagerHintsLogHook }- where x +++ y = x `mappend` y+pagerHints c =+ c+ { handleEventHook = handleEventHook c +++ pagerHintsEventHook,+ logHook = logHook c +++ pagerHintsLogHook+ }+ where+ x +++ y = x `mappend` y -- | Update the current values of both custom hints. pagerHintsLogHook :: X ()@@ -88,29 +93,31 @@ -- | Set the value of the \"Visible Workspaces\" hint to the one given. setVisibleWorkspaces :: [String] -> X () setVisibleWorkspaces vis = withDisplay $ \dpy -> do- r <- asks theRoot- a <- xVisibleProp- c <- getAtom "UTF8_STRING"- let vis' = map fromIntegral $ concatMap ((++[0]) . encode) vis+ r <- asks theRoot+ a <- xVisibleProp+ c <- getAtom "UTF8_STRING"+ let vis' = map fromIntegral $ concatMap ((++ [0]) . encode) vis io $ changeProperty8 dpy r a c propModeReplace vis' -- | Handle all \"Current Layout\" events received from pager widgets, and -- set the current layout accordingly. pagerHintsEventHook :: Event -> X All-pagerHintsEventHook ClientMessageEvent {- ev_message_type = mt,- ev_data = d- } = withWindowSet $ \_ -> do- a <- xLayoutProp- when (mt == a) $ sendLayoutMessage d- return (All True)+pagerHintsEventHook+ ClientMessageEvent+ { ev_message_type = mt,+ ev_data = d+ } = withWindowSet $ \_ -> do+ a <- xLayoutProp+ when (mt == a) $ sendLayoutMessage d+ return (All True) pagerHintsEventHook _ = return (All True) -- | Request a change in the current layout by sending an internal message -- to XMonad. sendLayoutMessage :: [CInt] -> X () sendLayoutMessage evData = case evData of- [] -> return ()- x:_ -> if x < 0- then sendMessage FirstLayout- else sendMessage NextLayout+ [] -> return ()+ x : _ ->+ if x < 0+ then sendMessage FirstLayout+ else sendMessage NextLayout
src/System/Taffybar/Util.hs view
@@ -1,6 +1,10 @@-{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE CPP #-}+{-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Util -- Copyright : (c) Ivan A. Malison@@ -9,121 +13,148 @@ -- Maintainer : Ivan A. Malison -- Stability : unstable -- Portability : unportable------------------------------------------------------------------------------+module System.Taffybar.Util+ ( -- * Configuration+ taffyStateDir, -module System.Taffybar.Util (- -- * Configuration- taffyStateDir- -- * GTK concurrency- , module Gtk- -- * GLib- , catchGErrorsAsLeft- -- * Logging- , logPrintF- -- * Text- , truncateString- , truncateText- -- * Resources- , downloadURIToPath- , getPixbufFromFilePath- , safePixbufNewFromFile- -- * Logic Combinators- , (<||>)- , (<|||>)- , forkM- , ifM- , anyM- , maybeTCombine- , maybeToEither- -- * Control- , foreverWithVariableDelay- , foreverWithDelay- -- * Process control- , runCommand- , onSigINT- , maybeHandleSigHUP- , handlePosixSignal- -- * Resource management- , rebracket- , rebracket_- -- * Deprecated- , logPrintFDebug- , liftReader- , liftActionTaker- , (??)- , runCommandFromPath- ) where+ -- * GTK concurrency+ module Gtk, -import Conduit-import Control.Applicative-import Control.Arrow ((&&&))-import Control.Concurrent (ThreadId, forkIO, threadDelay)+ -- * GLib+ catchGErrorsAsLeft,++ -- * Logging+ logPrintF,++ -- * Text+ truncateString,+ truncateText,++ -- * Resources+ downloadURIToPath,+ getPixbufFromFilePath,+ safePixbufNewFromFile,++ -- * Logic Combinators+ (<||>),+ (<|||>),+ forkM,+ ifM,+ anyM,+ maybeTCombine,+ maybeToEither,++ -- * Control+ VariableDelayConfig (..),+ defaultVariableDelayConfig,+ foreverWithVariableDelayWithConfig,+ foreverWithVariableDelay,+ foreverWithDelay,++ -- * Process control+ runCommand,+ onSigINT,+ maybeHandleSigHUP,+ handlePosixSignal,++ -- * Resource management+ rebracket,+ rebracket_,++ -- * Deprecated+ logPrintFDebug,+ liftReader,+ liftActionTaker,+ (??),+ runCommandFromPath,+ )+where++import Conduit+import Control.Applicative+import Control.Arrow ((&&&))+import Control.Concurrent (ThreadId, forkIO, threadDelay) import qualified Control.Concurrent.MVar as MV-import Control.Exception.Base-import Control.Monad-import Control.Monad.Trans.Maybe-import Control.Monad.Trans.Reader-import Data.Either.Combinators-import Data.GI.Base.GError-import Control.Exception.Enclosed (catchAny)-import Data.GI.Gtk.Threading as Gtk (postGUIASync, postGUISync)-import Data.GI.Gtk.Threading (postGUIASyncWithPriority)-import Data.Maybe-import Data.IORef (newIORef, readIORef, writeIORef)+import Control.Exception.Base+import Control.Exception.Enclosed (catchAny)+import Control.Monad+import Control.Monad.Trans.Maybe+import Control.Monad.Trans.Reader+import Data.Either.Combinators+import Data.GI.Base.GError+import Data.GI.Gtk.Threading (postGUIASyncWithPriority)+import Data.GI.Gtk.Threading as Gtk (postGUIASync, postGUISync)+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.Maybe import qualified Data.Text as T-import Data.Tuple.Sequence-import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk+import qualified Data.Time.Clock as Clock+import Data.Tuple.Sequence import qualified GI.GLib.Constants as G-import Network.HTTP.Simple-import System.Directory-import System.Environment.XDG.BaseDir-import System.Exit (ExitCode (..), exitWith)-import System.FilePath.Posix-import System.IO (hIsTerminalDevice, stdout, stderr)-import System.Log.Logger-import System.Posix.Signals (Signal, Handler(..), installHandler, sigHUP, sigINT)+import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk+import Network.HTTP.Simple+import System.Directory+import System.Environment.XDG.BaseDir+import System.Exit (ExitCode (..), exitWith)+import System.FilePath.Posix+import System.IO (hIsTerminalDevice, stderr, stdout)+import System.Log.Logger+import System.Posix.Signals (Handler (..), Signal, installHandler, sigHUP, sigINT) import qualified System.Process as P-import Text.Printf-+import Text.Printf +-- | Per-user state directory for taffybar runtime/cache data. taffyStateDir :: IO FilePath taffyStateDir = getUserDataDir "taffybar" {-# DEPRECATED liftReader "Use Control.Monad.Trans.Reader.mapReaderT instead" #-}-liftReader :: Monad m => (m1 a -> m b) -> ReaderT r m1 a -> ReaderT r m b++-- | Deprecated alias for 'mapReaderT'.+liftReader :: (Monad m) => (m1 a -> m b) -> ReaderT r m1 a -> ReaderT r m b liftReader = mapReaderT -logPrintF- :: (MonadIO m, Show t)- => String -> Priority -> String -> t -> m ()+-- | Log a formatted value using 'printf' into a specific logger path.+logPrintF ::+ (MonadIO m, Show t) =>+ String -> Priority -> String -> t -> m () logPrintF logPath priority format toPrint = liftIO $ logM logPath priority $ printf format $ show toPrint {-# DEPRECATED logPrintFDebug "Use logPrintF instead" #-}++-- | Deprecated debug-level convenience wrapper around 'logPrintF'. logPrintFDebug :: (MonadIO m, Show t) => String -> String -> t -> m () logPrintFDebug path = logPrintF path DEBUG infixl 4 ??-(??) :: Functor f => f (a -> b) -> a -> f b++-- | Apply a pure argument to a functor-wrapped function.+(??) :: (Functor f) => f (a -> b) -> a -> f b fab ?? a = fmap ($ a) fab {-# INLINE (??) #-} {-# DEPRECATED (??) "Use @f <*> pure a@ instead" #-} -ifM :: Monad m => m Bool -> m a -> m a -> m a+-- | Monadic conditional.+ifM :: (Monad m) => m Bool -> m a -> m a -> m a ifM cond whenTrue whenFalse = cond >>= (\bool -> if bool then whenTrue else whenFalse) -forkM :: Monad m => (c -> m a) -> (c -> m b) -> c -> m (a, b)+-- | Evaluate two monadic computations from the same input and return both+-- results.+forkM :: (Monad m) => (c -> m a) -> (c -> m b) -> c -> m (a, b) forkM a b = sequenceT . (a &&& b) +-- | Convert a 'Maybe' into an 'Either' with a provided left value. maybeToEither :: b -> Maybe a -> Either b a maybeToEither = flip maybe Right . Left +-- | Truncate a string to at most @n@ characters, appending an ellipsis when+-- truncation occurs. truncateString :: Int -> String -> String truncateString n incoming | length incoming <= n = incoming | otherwise = take n incoming ++ "…" +-- | Text variant of 'truncateString'. truncateText :: Int -> T.Text -> T.Text truncateText n incoming | T.length incoming <= n = incoming@@ -133,17 +164,19 @@ -- -- If the command filename does not contain a slash, then the @PATH@ -- environment variable is searched for the executable.-runCommand :: MonadIO m => FilePath -> [String] -> m (Either String String)+runCommand :: (MonadIO m) => FilePath -> [String] -> m (Either String String) runCommand cmd args = liftIO $ do (ecode, out, err) <- P.readProcessWithExitCode cmd args "" logM "System.Taffybar.Util" INFO $- printf "Running command %s with args %s" (show cmd) (show args)+ printf "Running command %s with args %s" (show cmd) (show args) return $ case ecode of ExitSuccess -> Right out ExitFailure exitCode -> Left $ printf "Exit code %s: %s " (show exitCode) err {-# DEPRECATED runCommandFromPath "Use runCommand instead" #-}-runCommandFromPath :: MonadIO m => FilePath -> [String] -> m (Either String String)++-- | Deprecated alias for 'runCommand'.+runCommandFromPath :: (MonadIO m) => FilePath -> [String] -> m (Either String String) runCommandFromPath = runCommand -- | A variant of 'bracket' which allows for reloading.@@ -178,61 +211,116 @@ -- automatically allocate the resource before running the enclosed -- action. rebracket_ :: IO (IO ()) -> (IO () -> IO a) -> IO a-rebracket_ alloc action = rebracket ((, ()) <$> alloc) $+rebracket_ alloc action = rebracket ((,()) <$> alloc) $ \reload -> reload >> action reload -- | Execute the provided IO action at the provided interval. foreverWithDelay :: (MonadIO m, RealFrac d) => d -> IO () -> m ThreadId foreverWithDelay delay action = foreverWithVariableDelay $ safeAction >> return delay- where safeAction =- catchAny action $ \e ->- logPrintF "System.Taffybar.Util" WARNING "Error in foreverWithDelay %s" e+ where+ safeAction =+ catchAny action $ \e ->+ logPrintF "System.Taffybar.Util" WARNING "Error in foreverWithDelay %s" e+{-# DEPRECATED foreverWithDelay "Use System.Taffybar.Information.Wakeup.taffyForeverWithDelay for context-aware synchronized wakeups" #-} -- | Execute the provided IO action, and use the value it returns to decide how -- long to wait until executing it again. The value returned by the action is -- interpreted as a number of seconds.+newtype VariableDelayConfig d = VariableDelayConfig+ { -- | Maximum sleep chunk used while waiting between polls.+ --+ -- If 'Nothing', waits use a single 'threadDelay' for the entire delay.+ -- If present, waits are chunked using wall-clock time, which helps polling+ -- loops recover promptly after suspend/resume.+ variableDelayMaxWaitChunk :: Maybe d+ }+ deriving (Eq, Ord, Show)++defaultVariableDelayConfig :: VariableDelayConfig d+defaultVariableDelayConfig =+ VariableDelayConfig+ { variableDelayMaxWaitChunk = Nothing+ }+ foreverWithVariableDelay :: (MonadIO m, RealFrac d) => IO d -> m ThreadId-foreverWithVariableDelay action = liftIO $ forkIO $ action >>= delayThenAction- where delayThenAction delay =- threadDelay (floor $ delay * 1000000) >> action >>= delayThenAction+foreverWithVariableDelay = foreverWithVariableDelayWithConfig defaultVariableDelayConfig -liftActionTaker- :: (Monad m)- => ((a -> m a) -> m b) -> (a -> ReaderT c m a) -> ReaderT c m b+foreverWithVariableDelayWithConfig ::+ (MonadIO m, RealFrac d) => VariableDelayConfig d -> IO d -> m ThreadId+foreverWithVariableDelayWithConfig config action = liftIO $ forkIO $ action >>= delayThenAction+ where+ delayThenAction delay = do+ waitDelay delay+ action >>= delayThenAction++ waitDelay delay+ | delay <= 0 = pure ()+ | otherwise =+ case variableDelayMaxWaitChunk config of+ Just waitChunk | waitChunk > 0 -> do+ start <- Clock.getCurrentTime+ let waitChunkMicros =+ max 1 $+ floor+ (realToFrac waitChunk * 1000000 :: Double)+ waitUntil waitChunkMicros $ Clock.addUTCTime (realToFrac delay) start+ _ -> threadDelay (floor $ delay * 1000000)++ waitUntil waitChunkMicros target = do+ now <- Clock.getCurrentTime+ let remainingMicros =+ floor+ (realToFrac (Clock.diffUTCTime target now) * 1000000 :: Double)+ sleepMicros = min waitChunkMicros remainingMicros+ when (sleepMicros > 0) $ threadDelay sleepMicros >> waitUntil waitChunkMicros target++-- | Lift an action-taker combinator into a 'ReaderT' context.+liftActionTaker ::+ (Monad m) =>+ ((a -> m a) -> m b) -> (a -> ReaderT c m a) -> ReaderT c m b liftActionTaker actionTaker action = do ctx <- ask lift $ actionTaker $ flip runReaderT ctx . action -maybeTCombine- :: Monad m- => m (Maybe a) -> m (Maybe a) -> m (Maybe a)+-- | Combine two @Maybe@-producing actions, preferring the first successful one.+maybeTCombine ::+ (Monad m) =>+ m (Maybe a) -> m (Maybe a) -> m (Maybe a) maybeTCombine a b = runMaybeT $ MaybeT a <|> MaybeT b infixl 3 <||>++-- | Pointwise 'maybeTCombine' for unary functions. (<||>) ::- Monad m =>+ (Monad m) => (t -> m (Maybe a)) -> (t -> m (Maybe a)) -> t -> m (Maybe a) a <||> b = combineOptions- where combineOptions v = maybeTCombine (a v) (b v)+ where+ combineOptions v = maybeTCombine (a v) (b v) infixl 3 <|||>-(<|||>)- :: Monad m- => (t -> t1 -> m (Maybe a))- -> (t -> t1 -> m (Maybe a))- -> t- -> t1- -> m (Maybe a)++-- | Pointwise 'maybeTCombine' for binary functions.+(<|||>) ::+ (Monad m) =>+ (t -> t1 -> m (Maybe a)) ->+ (t -> t1 -> m (Maybe a)) ->+ t ->+ t1 ->+ m (Maybe a) a <|||> b = combineOptions- where combineOptions v v1 = maybeTCombine (a v v1) (b v v1)+ where+ combineOptions v v1 = maybeTCombine (a v v1) (b v v1) +-- | Catch GLib errors as 'Left', otherwise return 'Right'. catchGErrorsAsLeft :: IO a -> IO (Either GError a) catchGErrorsAsLeft action = catch (Right <$> action) (return . Left) catchGErrorsAsNothing :: IO a -> IO (Maybe a) catchGErrorsAsNothing = fmap rightToMaybe . catchGErrorsAsLeft +-- | Safely load a pixbuf from file, returning 'Nothing' on GDK errors. safePixbufNewFromFile :: FilePath -> IO (Maybe Gdk.Pixbuf) safePixbufNewFromFile = handleResult . catchGErrorsAsNothing . Gdk.pixbufNewFromFile@@ -243,27 +331,31 @@ handleResult = id #endif +-- | Load a pixbuf from file and emit a warning log on failure. getPixbufFromFilePath :: FilePath -> IO (Maybe Gdk.Pixbuf) getPixbufFromFilePath filepath = do result <- safePixbufNewFromFile filepath when (isNothing result) $- logM "System.Taffybar.WindowIcon" WARNING $- printf "Failed to load icon from filepath %s" filepath+ logM "System.Taffybar.WindowIcon" WARNING $+ printf "Failed to load icon from filepath %s" filepath return result +-- | Download a URI response body directly to a local file path. downloadURIToPath :: Request -> FilePath -> IO () downloadURIToPath uri filepath =- createDirectoryIfMissing True directory >>- runConduitRes (httpSource uri getResponseBody .| sinkFile filepath)- where (directory, _) = splitFileName filepath+ createDirectoryIfMissing True directory+ >> runConduitRes (httpSource uri getResponseBody .| sinkFile filepath)+ where+ (directory, _) = splitFileName filepath +-- | Monadic 'any'. anyM :: (Monad m) => (a -> m Bool) -> [a] -> m Bool anyM _ [] = return False-anyM p (x:xs) = do+anyM p (x : xs) = do q <- p x if q- then return True- else anyM p xs+ then return True+ else anyM p xs -- | Installs a useful posix signal handler for 'sigINT' (i.e. Ctrl-C) -- for cases when the 'Control.Exception.UserInterrupt' exception gets@@ -276,10 +368,12 @@ -- -- If the signal handler was invoked, the program will exit with -- status 130 after the main loop action returns.-onSigINT- :: IO a -- ^ The main loop 'IO' action- -> IO () -- ^ Callback for @SIGINT@- -> IO a+onSigINT ::+ -- | The main loop 'IO' action+ IO a ->+ -- | Callback for @SIGINT@+ IO () ->+ IO a onSigINT action callback = do exitStatus <- newIORef Nothing @@ -301,7 +395,8 @@ -- program, which is the correct thing to do. maybeHandleSigHUP :: IO () -> IO a -> IO a maybeHandleSigHUP callback action =- ifM (anyM hIsTerminalDevice [stdout, stderr])+ ifM+ (anyM hIsTerminalDevice [stdout, stderr]) action (handlePosixSignal sigHUP callback action)
src/System/Taffybar/Widget.hs view
@@ -1,6 +1,17 @@+{- ORMOLU_DISABLE -} {-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} +-- |+-- Module : System.Taffybar.Widget+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Convenience re-export module for commonly used widgets. module System.Taffybar.Widget ( module System.Taffybar.Widget.Util -- * "System.Taffybar.Widget.PulseAudio"@@ -41,6 +52,9 @@ -- * "System.Taffybar.Widget.FreedesktopNotifications" , module System.Taffybar.Widget.FreedesktopNotifications + -- * "System.Taffybar.Widget.ImageCommandButton"+ , module System.Taffybar.Widget.ImageCommandButton+ -- * "System.Taffybar.Widget.Inhibitor" , module System.Taffybar.Widget.Inhibitor @@ -61,6 +75,7 @@ -- * "System.Taffybar.Widget.SNITray" , module System.Taffybar.Widget.SNITray+ , module System.Taffybar.Widget.SNITray.PrioritizedCollapsible -- * "System.Taffybar.Widget.SimpleClock" , module System.Taffybar.Widget.SimpleClock@@ -68,6 +83,9 @@ -- * "System.Taffybar.Widget.SimpleCommandButton" , module System.Taffybar.Widget.SimpleCommandButton + -- * "System.Taffybar.Widget.WakeupDebug"+ , module System.Taffybar.Widget.WakeupDebug+ -- * "System.Taffybar.Widget.Text.CPUMonitor" , module System.Taffybar.Widget.Text.CPUMonitor @@ -83,6 +101,12 @@ -- * "System.Taffybar.Widget.Windows" , module System.Taffybar.Widget.Windows + -- * "System.Taffybar.Widget.ChannelWorkspaces"+ , ChannelWorkspacesConfig+ , defaultChannelWorkspacesConfig+ , defaultChannelEWMHWorkspacesConfig+ , channelWorkspacesNew+ -- * "System.Taffybar.Widget.HyprlandWorkspaces" , HyprlandWorkspacesConfig(..) , defaultHyprlandWorkspacesConfig@@ -116,6 +140,8 @@ , module System.Taffybar.Widget.XDGMenu.MenuWidget ) where +import qualified GI.Gtk as Gtk+import System.Taffybar.Context (TaffyIO) import System.Taffybar.Widget.Battery import System.Taffybar.Widget.BatteryDonut import System.Taffybar.Widget.BatteryTextIcon@@ -128,6 +154,7 @@ import System.Taffybar.Widget.DiskUsage import System.Taffybar.Widget.FSMonitor import System.Taffybar.Widget.FreedesktopNotifications+import System.Taffybar.Widget.ImageCommandButton import System.Taffybar.Widget.Inhibitor import System.Taffybar.Widget.Layout import System.Taffybar.Widget.MPRIS2@@ -135,8 +162,10 @@ import System.Taffybar.Widget.NetworkManager import System.Taffybar.Widget.PowerProfiles import System.Taffybar.Widget.SNITray+import System.Taffybar.Widget.SNITray.PrioritizedCollapsible import System.Taffybar.Widget.SimpleClock import System.Taffybar.Widget.SimpleCommandButton+import System.Taffybar.Widget.WakeupDebug import System.Taffybar.Widget.Text.CPUMonitor import System.Taffybar.Widget.Text.MemoryMonitor import System.Taffybar.Widget.Text.NetworkMonitor@@ -145,17 +174,22 @@ import System.Taffybar.Widget.Backlight import System.Taffybar.Widget.Weather import System.Taffybar.Widget.Windows-import System.Taffybar.Widget.HyprlandWorkspaces+import System.Taffybar.Widget.Workspaces.Hyprland hiding ( HyprlandWorkspacesConfig(..) , defaultHyprlandWorkspacesConfig+ , hyprlandBuildButtonController+ , hyprlandBuildContentsController+ , hyprlandBuildCustomOverlayController+ , hyprlandBuildIconController+ , hyprlandBuildLabelController+ , hyprlandBuildLabelOverlayController , hyprlandWorkspacesNew- , HyprlandWorkspace(..)- , HyprlandWindow(..)- , HyprlandWindowIconPixbufGetter- , HyprlandWorkspaceWidgetController(..)- , HyprlandWWC(..)- , HyprlandControllerConstructor- , HyprlandParentControllerConstructor+ , defaultHyprlandWidgetBuilder+ )+import System.Taffybar.Widget.Workspaces.Hyprland.Compat+ ( HyprlandWorkspacesConfig(..)+ , defaultHyprlandWorkspacesConfig+ , hyprlandWorkspacesNew , hyprlandBuildLabelController , hyprlandBuildIconController , hyprlandBuildContentsController@@ -163,13 +197,17 @@ , hyprlandBuildCustomOverlayController , hyprlandBuildButtonController , defaultHyprlandWidgetBuilder- , defaultHyprlandGetWindowIconPixbuf- , getHyprlandWorkspaces- , hyprlandSwitchToWorkspace- , getActiveWindowAddress- , runHyprctlJson- , HyprlandClient(..)- , windowFromClient ) import System.Taffybar.Widget.Workspaces import System.Taffybar.Widget.XDGMenu.MenuWidget++type ChannelWorkspacesConfig = WorkspacesConfig++defaultChannelWorkspacesConfig :: ChannelWorkspacesConfig+defaultChannelWorkspacesConfig = defaultWorkspacesConfig++defaultChannelEWMHWorkspacesConfig :: ChannelWorkspacesConfig+defaultChannelEWMHWorkspacesConfig = defaultEWMHWorkspacesConfig++channelWorkspacesNew :: ChannelWorkspacesConfig -> TaffyIO Gtk.Widget+channelWorkspacesNew = workspacesNew
+ src/System/Taffybar/Widget/ASUS.hs view
@@ -0,0 +1,217 @@+{-# LANGUAGE OverloadedStrings #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.ASUS+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- This module provides a widget for displaying and controlling the current+-- 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.+module System.Taffybar.Widget.ASUS+ ( ASUSWidgetConfig (..),+ defaultASUSWidgetConfig,+ asusWidgetNew,+ asusWidgetNewWithConfig,+ )+where++import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Reader+import Data.Default (Default (..))+import qualified Data.Text as T+import qualified GI.GLib as GLib+import qualified GI.Gdk as Gdk+import qualified GI.Gtk as Gtk+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Information.ASUS+import System.Taffybar.Util (logPrintF, postGUIASync)+import System.Taffybar.Widget.Generic.ChannelWidget+import Text.Printf (printf)++-- | Configuration for the ASUS widget.+data ASUSWidgetConfig = ASUSWidgetConfig+ { -- | Nerd font icon for quiet mode+ asusQuietIcon :: T.Text,+ -- | Nerd font icon for balanced mode+ asusBalancedIcon :: T.Text,+ -- | Nerd font icon for performance mode+ asusPerformanceIcon :: T.Text,+ -- | Show CPU frequency+ asusShowFreq :: Bool,+ -- | Show CPU temperature+ asusShowTemp :: Bool+ }+ deriving (Eq, Show)++-- | Default configuration.+-- Icons: nf-md-leaf (quiet), nf-md-scale_balance (balanced), nf-md-rocket_launch (performance)+defaultASUSWidgetConfig :: ASUSWidgetConfig+defaultASUSWidgetConfig =+ ASUSWidgetConfig+ { asusQuietIcon = T.pack "\xF06C9", -- nf-md-leaf+ asusBalancedIcon = T.pack "\xF0A7A", -- nf-md-scale_balance+ asusPerformanceIcon = T.pack "\xF0E4E", -- nf-md-rocket_launch+ asusShowFreq = True,+ asusShowTemp = True+ }++instance Default ASUSWidgetConfig where+ def = defaultASUSWidgetConfig++asusLogPath :: String+asusLogPath = "System.Taffybar.Widget.ASUS"++asusLogF :: (MonadIO m, Show t) => Priority -> String -> t -> m ()+asusLogF = logPrintF asusLogPath++-- | Create an ASUS widget with default configuration.+asusWidgetNew :: TaffyIO Gtk.Widget+asusWidgetNew = asusWidgetNewWithConfig defaultASUSWidgetConfig++-- | Create an ASUS widget with custom configuration.+asusWidgetNewWithConfig :: ASUSWidgetConfig -> TaffyIO Gtk.Widget+asusWidgetNewWithConfig config = do+ chan <- getASUSInfoChan+ ctx <- ask++ liftIO $ do+ label <- Gtk.labelNew Nothing+ ebox <- Gtk.eventBoxNew+ Gtk.containerAdd ebox label+ styleCtx <- Gtk.widgetGetStyleContext ebox+ Gtk.styleContextAddClass styleCtx "asus-profile"++ let updateWidget info = postGUIASync $ do+ let icon = getTextIcon config (asusProfile info)+ freqText =+ if asusShowFreq config+ then T.pack $ printf " %.1fGHz" (asusCpuFreqGHz info)+ else ""+ tempText =+ if asusShowTemp config+ then T.pack $ printf " %.0f\x00B0C" (asusCpuTempC info)+ else ""+ labelText = icon <> freqText <> tempText+ Gtk.labelSetText label labelText+ updateProfileClasses ebox info+ updateTooltip ebox info++ void $ Gtk.onWidgetRealize ebox $ do+ initialInfo <- runReaderT getASUSInfoState ctx+ updateWidget initialInfo++ setupClickHandler ctx ebox+ Gtk.widgetShowAll ebox+ Gtk.toWidget =<< channelWidgetNew ebox chan updateWidget++-- | Select the nerd font text icon for a given profile.+getTextIcon :: ASUSWidgetConfig -> ASUSPlatformProfile -> T.Text+getTextIcon config Quiet = asusQuietIcon config+getTextIcon config Balanced = asusBalancedIcon config+getTextIcon config Performance = asusPerformanceIcon config++-- | Update CSS classes based on the current profile.+updateProfileClasses :: (Gtk.IsWidget w) => w -> ASUSInfo -> IO ()+updateProfileClasses widget info = do+ let profile = asusProfile info+ allClasses = ["quiet", "balanced", "performance"] :: [T.Text]+ currentClass = case profile of+ Quiet -> "quiet"+ Balanced -> "balanced"+ Performance -> "performance"+ styleCtx <- Gtk.widgetGetStyleContext widget+ 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+ 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)++-- | Set up click handler: left-click opens profile menu, right-click cycles.+setupClickHandler :: Context -> Gtk.EventBox -> IO ()+setupClickHandler ctx ebox = do+ void $ Gtk.onWidgetButtonPressEvent ebox $ \event -> do+ button <- Gdk.getEventButtonButton event+ eventType <- Gdk.getEventButtonType event+ if eventType /= Gdk.EventTypeButtonPress+ then return False+ else case button of+ 1 -> do+ showProfileMenu ctx ebox+ return True+ 3 -> do+ let client = systemDBusClient ctx+ result <- cycleASUSProfile client+ case result of+ Left err ->+ asusLogF WARNING "Failed to cycle ASUS profile: %s" (show err)+ Right () ->+ return ()+ return True+ _ -> return False++-- | Build and show a popup menu for selecting a profile.+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)+ ]++ 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++ void $+ Gtk.onWidgetHide menu $+ void $+ GLib.idleAdd GLib.PRIORITY_LOW $ do+ Gtk.widgetDestroy menu+ return False++ Gtk.widgetShowAll menu+ Gtk.menuPopupAtPointer menu currentEvent
src/System/Taffybar/Widget/Backlight.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}------------------------------------------------------------------------------ ++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Backlight -- Copyright : (c) Ivan A. Malison@@ -11,93 +15,92 @@ -- -- Simple backlight widget using /sys/class/backlight and optional -- brightnessctl adjustments.---------------------------------------------------------------------------------- module System.Taffybar.Widget.Backlight- ( BacklightWidgetConfig(..)- , defaultBacklightWidgetConfig- , backlightIconNew- , backlightIconNewWith- , backlightLabelNew- , backlightLabelNewWith- , backlightLabelNewChan- , backlightLabelNewChanWith- , backlightNew- , backlightNewWith- , backlightNewChan- , backlightNewChanWith- ) where+ ( BacklightWidgetConfig (..),+ defaultBacklightWidgetConfig,+ backlightIconNew,+ backlightIconNewWith,+ backlightLabelNew,+ backlightLabelNewWith,+ backlightLabelNewChan,+ backlightLabelNewChanWith,+ backlightNew,+ backlightNewWith,+ backlightNewChan,+ backlightNewChanWith,+ )+where import Control.Exception (SomeException, catch) import Control.Monad (void) import Control.Monad.IO.Class-import Data.Default (Default(..))+import Data.Default (Default (..)) import qualified Data.Text as T-import qualified GI.Gdk.Structs.EventScroll as GdkEvent+import qualified GI.GLib as G import qualified GI.Gdk.Enums as Gdk+import qualified GI.Gdk.Structs.EventScroll as GdkEvent import qualified GI.Gtk as Gtk-import qualified GI.GLib as G import System.Taffybar.Context (TaffyIO) import qualified System.Taffybar.Information.Backlight as BL import System.Taffybar.Util (postGUIASync, runCommand) import System.Taffybar.Widget.Generic.ChannelWidget import System.Taffybar.Widget.Generic.PollingLabel-import System.Taffybar.Widget.Util (buildIconLabelBox)+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI) import Text.StringTemplate -- | Configuration for the backlight widget. data BacklightWidgetConfig = BacklightWidgetConfig- { backlightPollingInterval :: Double- , backlightDevice :: Maybe FilePath- , backlightFormat :: String- , backlightUnknownFormat :: String- , backlightTooltipFormat :: Maybe String- , backlightScrollStepPercent :: Maybe Int- , backlightBrightnessctlPath :: FilePath- , backlightIcon :: T.Text- -- ^ Nerd font icon character (default U+F0EB, \xF0EB).+ { backlightPollingInterval :: Double,+ backlightDevice :: Maybe FilePath,+ backlightFormat :: String,+ backlightUnknownFormat :: String,+ backlightTooltipFormat :: Maybe String,+ backlightScrollStepPercent :: Maybe Int,+ backlightBrightnessctlPath :: FilePath,+ -- | Nerd font icon character (default U+F0EB, \xF0EB).+ backlightIcon :: T.Text } -- | Default backlight widget configuration. defaultBacklightWidgetConfig :: BacklightWidgetConfig defaultBacklightWidgetConfig = BacklightWidgetConfig- { backlightPollingInterval = 2- , backlightDevice = Nothing- , backlightFormat = "bl: $percent$%"- , backlightUnknownFormat = "bl: n/a"- , backlightTooltipFormat =- Just "Device: $device$\nBrightness: $brightness$/$max$ ($percent$%)"- , backlightScrollStepPercent = Just 5- , backlightBrightnessctlPath = "brightnessctl"- , backlightIcon = T.pack "\xF0EB"+ { backlightPollingInterval = 2,+ backlightDevice = Nothing,+ backlightFormat = "bl: $percent$%",+ backlightUnknownFormat = "bl: n/a",+ backlightTooltipFormat =+ Just "Device: $device$\nBrightness: $brightness$/$max$ ($percent$%)",+ backlightScrollStepPercent = Just 5,+ backlightBrightnessctlPath = "brightnessctl",+ backlightIcon = T.pack "\xF0EB" } instance Default BacklightWidgetConfig where def = defaultBacklightWidgetConfig -- | Create a backlight icon widget with default configuration.-backlightIconNew :: MonadIO m => m Gtk.Widget+backlightIconNew :: (MonadIO m) => m Gtk.Widget backlightIconNew = backlightIconNewWith defaultBacklightWidgetConfig -- | Create a backlight icon widget with the provided configuration.-backlightIconNewWith :: MonadIO m => BacklightWidgetConfig -> m Gtk.Widget+backlightIconNewWith :: (MonadIO m) => BacklightWidgetConfig -> m Gtk.Widget backlightIconNewWith config = liftIO $ do label <- Gtk.labelNew (Just (backlightIcon config))+ _ <- widgetSetClassGI label "backlight-icon" Gtk.widgetShowAll label Gtk.toWidget label -- | Create a combined icon+label backlight widget with default configuration.-backlightNew :: MonadIO m => m Gtk.Widget+backlightNew :: (MonadIO m) => m Gtk.Widget backlightNew = backlightNewWith defaultBacklightWidgetConfig -- | Create a combined icon+label backlight widget.-backlightNewWith :: MonadIO m => BacklightWidgetConfig -> m Gtk.Widget+backlightNewWith :: (MonadIO m) => BacklightWidgetConfig -> m Gtk.Widget backlightNewWith config = liftIO $ do iconWidget <- backlightIconNewWith config labelWidget <- backlightLabelNewWith config- buildIconLabelBox iconWidget labelWidget+ buildIconLabelBox iconWidget labelWidget >>= (`widgetSetClassGI` "backlight") -- | Create a combined icon+label backlight widget (channel-driven). backlightNewChan :: TaffyIO Gtk.Widget@@ -108,18 +111,22 @@ backlightNewChanWith config = do iconWidget <- liftIO $ backlightIconNewWith config labelWidget <- backlightLabelNewChanWith config- liftIO $ buildIconLabelBox iconWidget labelWidget+ liftIO $+ buildIconLabelBox iconWidget labelWidget+ >>= (`widgetSetClassGI` "backlight") -- | Create a backlight widget with default configuration.-backlightLabelNew :: MonadIO m => m Gtk.Widget+backlightLabelNew :: (MonadIO m) => m Gtk.Widget backlightLabelNew = backlightLabelNewWith defaultBacklightWidgetConfig -- | Create a backlight widget with the provided configuration.-backlightLabelNewWith :: MonadIO m => BacklightWidgetConfig -> m Gtk.Widget+backlightLabelNewWith :: (MonadIO m) => BacklightWidgetConfig -> m Gtk.Widget backlightLabelNewWith config = liftIO $ do- widget <- pollingLabelNewWithTooltip- (backlightPollingInterval config)- (formatBacklightWidget config)+ widget <-+ pollingLabelNewWithTooltip+ (backlightPollingInterval config)+ (formatBacklightWidget config)+ _ <- widgetSetClassGI widget "backlight-label" case backlightScrollStepPercent config of Nothing -> return ()@@ -148,22 +155,23 @@ -- | Create a backlight widget driven by a 'TChan' (no polling in the widget). backlightLabelNewChanWith :: BacklightWidgetConfig -> TaffyIO Gtk.Widget backlightLabelNewChanWith config = do- chan <- BL.getBacklightInfoChanWithInterval- (backlightDevice config)- (backlightPollingInterval config)+ chan <-+ BL.getBacklightInfoChanWithInterval+ (backlightDevice config)+ (backlightPollingInterval config) initialInfo <- BL.getBacklightInfoState (backlightDevice config) liftIO $ do label <- Gtk.labelNew Nothing+ _ <- widgetSetClassGI label "backlight-label" - let- updateLabel info = do- (labelText, tooltipText) <- formatBacklightWidgetFromInfo config info- postGUIASync $ do- Gtk.labelSetMarkup label labelText- Gtk.widgetSetTooltipMarkup label tooltipText+ let updateLabel info = do+ (labelText, tooltipText) <- formatBacklightWidgetFromInfo config info+ postGUIASync $ do+ Gtk.labelSetMarkup label labelText+ Gtk.widgetSetTooltipMarkup label tooltipText - refreshNow = BL.getBacklightInfo (backlightDevice config) >>= updateLabel+ refreshNow = BL.getBacklightInfo (backlightDevice config) >>= updateLabel void $ Gtk.onWidgetRealize label $ updateLabel initialInfo @@ -186,19 +194,20 @@ return () Gtk.widgetShowAll label- Gtk.toWidget =<< channelWidgetNew label chan updateLabel+ (Gtk.toWidget =<< channelWidgetNew label chan updateLabel)+ >>= (`widgetSetClassGI` "backlight-label") -formatBacklightWidget- :: BacklightWidgetConfig- -> IO (T.Text, Maybe T.Text)+formatBacklightWidget ::+ BacklightWidgetConfig ->+ IO (T.Text, Maybe T.Text) formatBacklightWidget config = do info <- BL.getBacklightInfo (backlightDevice config) formatBacklightWidgetFromInfo config info -formatBacklightWidgetFromInfo- :: BacklightWidgetConfig- -> Maybe BL.BacklightInfo- -> IO (T.Text, Maybe T.Text)+formatBacklightWidgetFromInfo ::+ BacklightWidgetConfig ->+ Maybe BL.BacklightInfo ->+ IO (T.Text, Maybe T.Text) formatBacklightWidgetFromInfo config info = case info of Nothing -> return (T.pack $ backlightUnknownFormat config, Nothing)@@ -210,20 +219,19 @@ buildAttrs :: BL.BacklightInfo -> IO [(String, String)] buildAttrs info = do- let- brightnessText = show $ BL.backlightBrightness info- maxText = show $ BL.backlightMaxBrightness info- percentText = show $ BL.backlightPercent info- deviceText = BL.backlightDevice info+ let brightnessText = show $ BL.backlightBrightness info+ maxText = show $ BL.backlightMaxBrightness info+ percentText = show $ BL.backlightPercent info+ deviceText = BL.backlightDevice info brightness <- escapeText $ T.pack brightnessText maxBrightness <- escapeText $ T.pack maxText percent <- escapeText $ T.pack percentText device <- escapeText $ T.pack deviceText return- [ ("brightness", brightness)- , ("max", maxBrightness)- , ("percent", percent)- , ("device", device)+ [ ("brightness", brightness),+ ("max", maxBrightness),+ ("percent", percent),+ ("device", device) ] renderTemplate :: String -> [(String, String)] -> String@@ -237,11 +245,10 @@ void $ safeRunCommand (backlightBrightnessctlPath config) (brightnessctlArgs delta) where brightnessctlArgs change =- let- step = abs change- direction = if change >= 0 then "+" else "-"- deviceArgs = maybe [] (\dev -> ["-d", dev]) (backlightDevice config)- in deviceArgs ++ ["set", show step ++ "%" ++ direction]+ let step = abs change+ direction = if change >= 0 then "+" else "-"+ deviceArgs = maybe [] (\dev -> ["-d", dev]) (backlightDevice config)+ in deviceArgs ++ ["set", show step ++ "%" ++ direction] safeRunCommand :: FilePath -> [String] -> IO (Either String String) safeRunCommand cmd args =
src/System/Taffybar/Widget/Battery.hs view
@@ -1,6 +1,10 @@-{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Battery -- Copyright : (c) Ivan A. Malison@@ -15,49 +19,51 @@ -- widget displayed (if using a multi-head configuration or multiple battery -- widgets), these widgets use the broadcast "TChan" based system for receiving -- updates defined in "System.Taffybar.Information.Battery".------------------------------------------------------------------------------ module System.Taffybar.Widget.Battery- ( batteryIconNew- , BatteryClassesConfig(..)- , defaultBatteryClassesConfig- , setBatteryStateClasses- , textBatteryNew- , textBatteryNewWithLabelAction- ) where+ ( batteryIconNew,+ BatteryClassesConfig (..),+ defaultBatteryClassesConfig,+ setBatteryStateClasses,+ textBatteryNew,+ textBatteryNewWithLabelAction,+ )+where -import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Reader-import Data.Default (Default(..))-import Data.GI.Base.Overloading (IsDescendantOf)-import Data.Int (Int64)-import Data.IORef (newIORef, readIORef, writeIORef)+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Reader+import Data.Default (Default (..))+import Data.GI.Base.Overloading (IsDescendantOf)+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.Int (Int64) import qualified Data.Text as T-import GI.Gtk as Gtk-import System.Taffybar.Context-import System.Taffybar.Information.Battery-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Generic.ScalingImage (scalingImage)-import System.Taffybar.Widget.Util hiding (themeLoadFlags)-import Text.Printf-import Text.StringTemplate+import GI.Gtk as Gtk+import System.Taffybar.Context+import System.Taffybar.Information.Battery+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Generic.ScalingImage (scalingImage)+import System.Taffybar.Widget.Util hiding (themeLoadFlags)+import Text.Printf+import Text.StringTemplate -- | Just the battery info that will be used for display (this makes combining -- several easier). data BatteryWidgetInfo = BWI- { seconds :: Maybe Int64- , percent :: Int- , status :: String- } deriving (Eq, Show)+ { seconds :: Maybe Int64,+ percent :: Int,+ status :: String+ }+ deriving (Eq, Show) -- | Format a duration expressed as seconds to hours and minutes formatDuration :: Maybe Int64 -> String formatDuration Nothing = ""-formatDuration (Just secs) = let minutes = secs `div` 60- hours = minutes `div` 60- minutes' = minutes `mod` 60- in printf "%02d:%02d" hours minutes'+formatDuration (Just secs) =+ let minutes = secs `div` 60+ hours = minutes `div` 60+ minutes' = minutes `mod` 60+ in printf "%02d:%02d" hours minutes' getBatteryWidgetInfo :: BatteryInfo -> BatteryWidgetInfo getBatteryWidgetInfo info =@@ -75,17 +81,20 @@ BatteryStateCharging -> "Charging" BatteryStateDischarging -> "Discharging" _ -> "✔"- in BWI {seconds = battTime, percent = battPctNum, status = battStatus}+ in BWI {seconds = battTime, percent = battPctNum, status = battStatus} -- | Given (maybe summarized) battery info and format: provides the string to display formatBattInfo :: BatteryWidgetInfo -> String -> T.Text formatBattInfo info fmt = let tpl = newSTMP fmt- tpl' = setManyAttrib [ ("percentage", (show . percent) info)- , ("time", formatDuration (seconds info))- , ("status", status info)- ] tpl- in render tpl'+ tpl' =+ setManyAttrib+ [ ("percentage", (show . percent) info),+ ("time", formatDuration (seconds info)),+ ("status", status info)+ ]+ tpl+ in render tpl' -- | A simple textual battery widget. The displayed format is specified format -- string where $percentage$ is replaced with the percentage of battery@@ -93,48 +102,57 @@ -- charged/discharged. textBatteryNew :: String -> TaffyIO Widget textBatteryNew format = textBatteryNewWithLabelAction labelSetter- where labelSetter label info = do- setBatteryStateClasses def label info- labelSetMarkup label $- formatBattInfo (getBatteryWidgetInfo info) format+ where+ labelSetter label info = do+ setBatteryStateClasses def label info+ labelSetMarkup label $+ formatBattInfo (getBatteryWidgetInfo info) format +-- | CSS-threshold configuration for battery level classes. data BatteryClassesConfig = BatteryClassesConfig- { batteryHighThreshold :: Double- , batteryLowThreshold :: Double- , batteryCriticalThreshold :: Double+ { batteryHighThreshold :: Double,+ batteryLowThreshold :: Double,+ batteryCriticalThreshold :: Double } +-- | Default thresholds for 'BatteryClassesConfig'. defaultBatteryClassesConfig :: BatteryClassesConfig defaultBatteryClassesConfig = BatteryClassesConfig- { batteryHighThreshold = 80- , batteryLowThreshold = 20- , batteryCriticalThreshold = 5- }+ { batteryHighThreshold = 80,+ batteryLowThreshold = 20,+ batteryCriticalThreshold = 5+ } instance Default BatteryClassesConfig where def = defaultBatteryClassesConfig +-- | Add/remove CSS classes on a widget to reflect battery charging state and+-- level thresholds. setBatteryStateClasses :: (IsDescendantOf Widget a, GObject a, MonadIO m) => BatteryClassesConfig -> a -> BatteryInfo -> m () setBatteryStateClasses config widget info = do case batteryState info of- BatteryStateCharging -> addClassIfMissing "charging" widget >>- removeClassIfPresent "discharging" widget- BatteryStateDischarging -> addClassIfMissing "discharging" widget >>- removeClassIfPresent "charging" widget- _ -> removeClassIfPresent "charging" widget >>- removeClassIfPresent "discharging" widget+ BatteryStateCharging ->+ addClassIfMissing "charging" widget+ >> removeClassIfPresent "discharging" widget+ BatteryStateDischarging ->+ addClassIfMissing "discharging" widget+ >> removeClassIfPresent "charging" widget+ _ ->+ removeClassIfPresent "charging" widget+ >> removeClassIfPresent "discharging" widget classIf "high" $ percentage >= batteryHighThreshold config classIf "low" $ percentage <= batteryLowThreshold config classIf "critical" $ percentage <= batteryCriticalThreshold config- where percentage = batteryPercentage info- classIf klass condition =- if condition- then addClassIfMissing klass widget- else removeClassIfPresent klass widget+ where+ percentage = batteryPercentage info+ classIf klass condition =+ if condition+ then addClassIfMissing klass widget+ else removeClassIfPresent klass widget -- | Like `textBatteryNew` but provides a more general way to update the label -- widget. The argument provided is an action that is used to update the text@@ -148,13 +166,15 @@ label <- labelNew Nothing let updateWidget = postGUIASync . flip runReaderT ctx . labelSetter label- void $ onWidgetRealize label $- runReaderT getDisplayBatteryInfo ctx >>= updateWidget+ void $+ onWidgetRealize label $+ runReaderT getDisplayBatteryInfo ctx >>= updateWidget toWidget =<< channelWidgetNew label chan updateWidget themeLoadFlags :: [IconLookupFlags] themeLoadFlags = [IconLookupFlagsGenericFallback, IconLookupFlagsUseBuiltin] +-- | Create an icon-only battery widget using the battery icon name from UPower. batteryIconNew :: TaffyIO Widget batteryIconNew = do chan <- getDisplayBatteryChan@@ -165,8 +185,8 @@ iw <- readIORef imageWidgetRef styleCtx <- widgetGetStyleContext iw name <- T.pack . batteryIconName <$> runReaderT getDisplayBatteryInfo ctx- iconThemeLookupIcon defaultTheme name size themeLoadFlags >>=- traverse (\info -> fst <$> iconInfoLoadSymbolicForContext info styleCtx)+ iconThemeLookupIcon defaultTheme name size themeLoadFlags+ >>= traverse (\info -> fst <$> iconInfoLoadSymbolicForContext info styleCtx) (imageWidget, updateImage) <- scalingImage setIconForSize OrientationHorizontal liftIO $ do writeIORef imageWidgetRef imageWidget
src/System/Taffybar/Widget/BatteryDonut.hs view
@@ -1,6 +1,10 @@ {-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE RecordWildCards #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.BatteryDonut -- Copyright : (c) Ivan A. Malison@@ -12,92 +16,97 @@ -- -- A battery widget that draws a donut\/arc using Cairo to visualize battery -- charge level. The arc color changes based on configurable charge thresholds.------------------------------------------------------------------------------ module System.Taffybar.Widget.BatteryDonut- ( batteryDonutNew- , batteryDonutNewWith- , batteryDonutLabelNew- , batteryDonutLabelNewWith- , BatteryDonutConfig(..)- , defaultBatteryDonutConfig- ) where+ ( batteryDonutNew,+ batteryDonutNewWith,+ batteryDonutLabelNew,+ batteryDonutLabelNewWith,+ BatteryDonutConfig (..),+ defaultBatteryDonutConfig,+ )+where -import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Reader-import Data.Default (Default(..))-import Data.Int (Int32)-import Data.IORef+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Reader+import Data.Default (Default (..))+import Data.IORef+import Data.Int (Int32) import qualified GI.Cairo.Render as C-import GI.Cairo.Render.Connector (renderWithContext)-import GI.Gtk as Gtk-import System.Taffybar.Context-import System.Taffybar.Information.Battery-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Battery (textBatteryNew)-import System.Taffybar.Widget.Util (widgetSetClassGI,- addClassIfMissing, removeClassIfPresent,- buildIconLabelBox)+import GI.Cairo.Render.Connector (renderWithContext)+import GI.Gtk as Gtk+import System.Taffybar.Context+import System.Taffybar.Information.Battery+import System.Taffybar.Util+import System.Taffybar.Widget.Battery (textBatteryNew)+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Util+ ( addClassIfMissing,+ buildIconLabelBox,+ removeClassIfPresent,+ widgetSetClassGI,+ ) import qualified System.Taffybar.Widget.Util as WU -- | Configuration for the donut/arc battery widget. data BatteryDonutConfig = BatteryDonutConfig- { donutHighColor :: (Double, Double, Double)- -- ^ Color when charge is high (default: green)- , donutMediumColor :: (Double, Double, Double)- -- ^ Color when charge is medium (default: yellow)- , donutLowColor :: (Double, Double, Double)- -- ^ Color when charge is low (default: orange)- , donutCriticalColor :: (Double, Double, Double)- -- ^ Color when charge is critical (default: red)- , donutBackgroundColor :: (Double, Double, Double)- -- ^ Background ring color (default: dim gray)- , donutHighThreshold :: Double- -- ^ Percentage above which color is "high" (default: 60)- , donutMediumThreshold :: Double- -- ^ Percentage above which color is "medium" (default: 30)- , donutCriticalThreshold :: Double- -- ^ Percentage below which color is "critical" (default: 10)- , donutSize :: Int32- -- ^ Requested widget size in pixels (default: 24)- , donutChargingColor :: Maybe (Double, Double, Double)- -- ^ Optional override color when charging (default: Nothing)- , donutLabelFormat :: String- -- ^ Format string for the text label in the combined donut+label variant.+ { -- | Color when charge is high (default: green)+ donutHighColor :: (Double, Double, Double),+ -- | Color when charge is medium (default: yellow)+ donutMediumColor :: (Double, Double, Double),+ -- | Color when charge is low (default: orange)+ donutLowColor :: (Double, Double, Double),+ -- | Color when charge is critical (default: red)+ donutCriticalColor :: (Double, Double, Double),+ -- | Background ring color (default: dim gray)+ donutBackgroundColor :: (Double, Double, Double),+ -- | Percentage above which color is "high" (default: 60)+ donutHighThreshold :: Double,+ -- | Percentage above which color is "medium" (default: 30)+ donutMediumThreshold :: Double,+ -- | Percentage below which color is "critical" (default: 10)+ donutCriticalThreshold :: Double,+ -- | Requested widget size in pixels (default: 24)+ donutSize :: Int32,+ -- | Optional override color when charging (default: Nothing)+ donutChargingColor :: Maybe (Double, Double, Double),+ -- | Format string for the text label in the combined donut+label variant. -- Uses the same template syntax as 'textBatteryNew': @$percentage$@, -- @$time$@, and @$status$@ are replaced with the corresponding values. -- (default: @"$percentage$%"@)+ donutLabelFormat :: String } -- | Default donut battery configuration. defaultBatteryDonutConfig :: BatteryDonutConfig-defaultBatteryDonutConfig = BatteryDonutConfig- { donutHighColor = (0.3, 0.85, 0.3)- , donutMediumColor = (0.95, 0.85, 0.2)- , donutLowColor = (0.95, 0.5, 0.1)- , donutCriticalColor = (0.9, 0.2, 0.2)- , donutBackgroundColor = (0.3, 0.3, 0.3)- , donutHighThreshold = 60- , donutMediumThreshold = 30- , donutCriticalThreshold = 10- , donutSize = 24- , donutChargingColor = Nothing- , donutLabelFormat = "$percentage$%"- }+defaultBatteryDonutConfig =+ BatteryDonutConfig+ { donutHighColor = (0.3, 0.85, 0.3),+ donutMediumColor = (0.95, 0.85, 0.2),+ donutLowColor = (0.95, 0.5, 0.1),+ donutCriticalColor = (0.9, 0.2, 0.2),+ donutBackgroundColor = (0.3, 0.3, 0.3),+ donutHighThreshold = 60,+ donutMediumThreshold = 30,+ donutCriticalThreshold = 10,+ donutSize = 24,+ donutChargingColor = Nothing,+ donutLabelFormat = "$percentage$%"+ } instance Default BatteryDonutConfig where def = defaultBatteryDonutConfig -- | Select the arc color based on battery percentage and charging state. donutColorForLevel :: BatteryDonutConfig -> Double -> BatteryState -> (Double, Double, Double)-donutColorForLevel BatteryDonutConfig{..} pct state =+donutColorForLevel BatteryDonutConfig {..} pct state = case (state, donutChargingColor) of (BatteryStateCharging, Just color) -> color- _ | pct >= donutHighThreshold -> donutHighColor- | pct >= donutMediumThreshold -> donutMediumColor+ _+ | pct >= donutHighThreshold -> donutHighColor+ | pct >= donutMediumThreshold -> donutMediumColor | pct >= donutCriticalThreshold -> donutLowColor- | otherwise -> donutCriticalColor+ | otherwise -> donutCriticalColor -- | Render the donut arc for the battery. renderDonut :: BatteryDonutConfig -> Double -> BatteryState -> Int -> Int -> C.Render ()@@ -132,24 +141,30 @@ (Gtk.IsWidget w, MonadIO m) => BatteryDonutConfig -> w -> BatteryInfo -> m () setDonutBatteryClasses cfg widget info = do case batteryState info of- BatteryStateCharging -> addClassIfMissing "charging" widget >>- removeClassIfPresent "discharging" widget- BatteryStateDischarging -> addClassIfMissing "discharging" widget >>- removeClassIfPresent "charging" widget- _ -> removeClassIfPresent "charging" widget >>- removeClassIfPresent "discharging" widget+ BatteryStateCharging ->+ addClassIfMissing "charging" widget+ >> removeClassIfPresent "discharging" widget+ BatteryStateDischarging ->+ addClassIfMissing "discharging" widget+ >> removeClassIfPresent "charging" widget+ _ ->+ removeClassIfPresent "charging" widget+ >> removeClassIfPresent "discharging" widget classIf "high" $ percentage >= donutHighThreshold cfg- classIf "medium" $ percentage >= donutMediumThreshold cfg &&- percentage < donutHighThreshold cfg- classIf "low" $ percentage < donutMediumThreshold cfg &&- percentage >= donutCriticalThreshold cfg+ classIf "medium" $+ percentage >= donutMediumThreshold cfg+ && percentage < donutHighThreshold cfg+ classIf "low" $+ percentage < donutMediumThreshold cfg+ && percentage >= donutCriticalThreshold cfg classIf "critical" $ percentage < donutCriticalThreshold cfg- where percentage = batteryPercentage info- classIf klass condition =- if condition- then addClassIfMissing klass widget- else removeClassIfPresent klass widget+ where+ percentage = batteryPercentage info+ classIf klass condition =+ if condition+ then addClassIfMissing klass widget+ else removeClassIfPresent klass widget -- | Create a donut/arc battery widget with the default configuration. batteryDonutNew :: TaffyIO Widget@@ -182,8 +197,9 @@ setDonutBatteryClasses cfg drawArea info widgetQueueDraw drawArea - void $ onWidgetRealize drawArea $- runReaderT getDisplayBatteryInfo ctx >>= updateWidget+ void $+ onWidgetRealize drawArea $+ runReaderT getDisplayBatteryInfo ctx >>= updateWidget toWidget =<< channelWidgetNew drawArea chan updateWidget
src/System/Taffybar/Widget/BatteryTextIcon.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.BatteryTextIcon -- Copyright : (c) Ivan A. Malison@@ -13,70 +17,71 @@ -- display battery state. The glyph changes based on charge level and whether -- the battery is charging or discharging. CSS classes are applied for -- additional styling.------------------------------------------------------------------------------ module System.Taffybar.Widget.BatteryTextIcon- ( batteryTextIconNew- , batteryTextIconNewWith- , BatteryTextIconConfig(..)- , defaultBatteryTextIconConfig- ) where+ ( batteryTextIconNew,+ batteryTextIconNewWith,+ BatteryTextIconConfig (..),+ defaultBatteryTextIconConfig,+ )+where -import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Reader-import Data.Default (Default(..))+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Reader+import Data.Default (Default (..)) import qualified Data.Text as T-import GI.Gtk as Gtk-import System.Taffybar.Context-import System.Taffybar.Information.Battery-import System.Taffybar.Util-import System.Taffybar.Widget.Battery-import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Util hiding (themeLoadFlags)+import GI.Gtk as Gtk+import System.Taffybar.Context+import System.Taffybar.Information.Battery+import System.Taffybar.Util+import System.Taffybar.Widget.Battery+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Util hiding (themeLoadFlags) -- | Configuration for the nerd font battery text icon widget. data BatteryTextIconConfig = BatteryTextIconConfig { -- | Glyphs to use when discharging, ordered from empty (0%) to full (100%). -- Must have at least one element.- batteryTextIconDischarging :: [T.Text]+ batteryTextIconDischarging :: [T.Text], -- | Glyphs to use when charging, ordered from empty (0%) to full (100%). -- Must have at least one element.- , batteryTextIconCharging :: [T.Text]+ batteryTextIconCharging :: [T.Text], -- | Glyph to use when fully charged / on AC with no discharge.- , batteryTextIconFull :: T.Text+ batteryTextIconFull :: T.Text } -- | Default config using Nerd Font battery icons (Material Design Icons). defaultBatteryTextIconConfig :: BatteryTextIconConfig-defaultBatteryTextIconConfig = BatteryTextIconConfig- { batteryTextIconDischarging =- [ "\xf008e" -- nf-md-battery_outline (0%)- , "\xf007a" -- nf-md-battery_10- , "\xf007b" -- nf-md-battery_20- , "\xf007c" -- nf-md-battery_30- , "\xf007d" -- nf-md-battery_40- , "\xf007e" -- nf-md-battery_50- , "\xf007f" -- nf-md-battery_60- , "\xf0080" -- nf-md-battery_70- , "\xf0081" -- nf-md-battery_80- , "\xf0082" -- nf-md-battery_90- , "\xf0079" -- nf-md-battery (100%)- ]- , batteryTextIconCharging =- [ "\xf089f" -- nf-md-battery_charging_outline (0%)- , "\xf089c" -- nf-md-battery_charging_10- , "\xf0086" -- nf-md-battery_charging_20- , "\xf0087" -- nf-md-battery_charging_30- , "\xf0088" -- nf-md-battery_charging_40- , "\xf089d" -- nf-md-battery_charging_50- , "\xf0089" -- nf-md-battery_charging_60- , "\xf089e" -- nf-md-battery_charging_70- , "\xf008a" -- nf-md-battery_charging_80- , "\xf008b" -- nf-md-battery_charging_90- , "\xf0085" -- nf-md-battery_charging_100- ]- , batteryTextIconFull = "\xf0085" -- nf-md-battery_charging_100- }+defaultBatteryTextIconConfig =+ BatteryTextIconConfig+ { batteryTextIconDischarging =+ [ "\xf008e", -- nf-md-battery_outline (0%)+ "\xf007a", -- nf-md-battery_10+ "\xf007b", -- nf-md-battery_20+ "\xf007c", -- nf-md-battery_30+ "\xf007d", -- nf-md-battery_40+ "\xf007e", -- nf-md-battery_50+ "\xf007f", -- nf-md-battery_60+ "\xf0080", -- nf-md-battery_70+ "\xf0081", -- nf-md-battery_80+ "\xf0082", -- nf-md-battery_90+ "\xf0079" -- nf-md-battery (100%)+ ],+ batteryTextIconCharging =+ [ "\xf089f", -- nf-md-battery_charging_outline (0%)+ "\xf089c", -- nf-md-battery_charging_10+ "\xf0086", -- nf-md-battery_charging_20+ "\xf0087", -- nf-md-battery_charging_30+ "\xf0088", -- nf-md-battery_charging_40+ "\xf089d", -- nf-md-battery_charging_50+ "\xf0089", -- nf-md-battery_charging_60+ "\xf089e", -- nf-md-battery_charging_70+ "\xf008a", -- nf-md-battery_charging_80+ "\xf008b", -- nf-md-battery_charging_90+ "\xf0085" -- nf-md-battery_charging_100+ ],+ batteryTextIconFull = "\xf0085" -- nf-md-battery_charging_100+ } instance Default BatteryTextIconConfig where def = defaultBatteryTextIconConfig@@ -87,7 +92,7 @@ selectGlyph glyphs pct = let n = length glyphs idx = min (n - 1) $ floor (pct / 100.0 * fromIntegral n)- in glyphs !! max 0 idx+ in glyphs !! max 0 idx -- | A battery icon widget that uses text glyphs (e.g. Nerd Font icons) to -- display battery state. The glyph changes based on charge level and@@ -114,11 +119,13 @@ selectGlyph (batteryTextIconCharging config) pct BatteryStateDischarging -> selectGlyph (batteryTextIconDischarging config) pct- _ | pct >= 99 -> batteryTextIconFull config+ _+ | pct >= 99 -> batteryTextIconFull config | otherwise ->- selectGlyph (batteryTextIconDischarging config) pct+ selectGlyph (batteryTextIconDischarging config) pct labelSetLabel label glyph setBatteryStateClasses def label info- void $ onWidgetRealize label $- runReaderT getDisplayBatteryInfo ctx >>= updateWidget+ void $+ onWidgetRealize label $+ runReaderT getDisplayBatteryInfo ctx >>= updateWidget toWidget =<< channelWidgetNew label chan updateWidget
src/System/Taffybar/Widget/Bluetooth.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Bluetooth -- Copyright : (c) Ivan A. Malison@@ -14,21 +18,21 @@ -- -- The widget uses the 'TChan'-based system from -- "System.Taffybar.Information.Bluetooth" for receiving updates.------------------------------------------------------------------------------ module System.Taffybar.Widget.Bluetooth- ( BluetoothWidgetConfig(..)- , defaultBluetoothWidgetConfig- , bluetoothIconNew- , bluetoothIconNewWith- , bluetoothLabelNew- , bluetoothLabelNewWith- , bluetoothNew- , bluetoothNewWith- ) where+ ( BluetoothWidgetConfig (..),+ defaultBluetoothWidgetConfig,+ bluetoothIconNew,+ bluetoothIconNewWith,+ bluetoothLabelNew,+ bluetoothLabelNewWith,+ bluetoothNew,+ bluetoothNewWith,+ )+where import Control.Monad (void) import Control.Monad.IO.Class-import Data.Default (Default(..))+import Data.Default (Default (..)) import Data.List (intercalate) import qualified Data.Text as T import qualified GI.GLib as G@@ -42,45 +46,48 @@ -- | Configuration for the Bluetooth widget. data BluetoothWidgetConfig = BluetoothWidgetConfig- { -- | Format string when Bluetooth is connected.+ { -- \$num_connections$, $controller_alias$++ -- | Format string when Bluetooth is connected. -- Available variables: $status$, $device_alias$, $device_battery$,- -- $num_connections$, $controller_alias$- bluetoothFormatConnected :: String+ bluetoothFormatConnected :: String, -- | Format string when Bluetooth is on but not connected.- , bluetoothFormatOn :: String+ bluetoothFormatOn :: String, -- | Format string when Bluetooth is off (powered down).- , bluetoothFormatOff :: String+ bluetoothFormatOff :: String, -- | Format string when no Bluetooth controller is found.- , bluetoothFormatNoController :: String+ bluetoothFormatNoController :: String, -- | Optional tooltip format. -- Additional variable: $device_list$- , bluetoothTooltipFormat :: Maybe String+ bluetoothTooltipFormat :: Maybe String, -- | Format for each device in the tooltip device list. -- Variables: $device_alias$, $device_battery$- , bluetoothDeviceListFormat :: String+ bluetoothDeviceListFormat :: String, -- | Icon to display when Bluetooth is connected.- , bluetoothConnectedIcon :: T.Text+ bluetoothConnectedIcon :: T.Text, -- | Icon to display when Bluetooth is on but not connected.- , bluetoothOnIcon :: T.Text+ bluetoothOnIcon :: T.Text, -- | Icon to display when Bluetooth is off (powered down).- , bluetoothOffIcon :: T.Text+ bluetoothOffIcon :: T.Text, -- | Icon to display when no Bluetooth controller is found.- , bluetoothNoControllerIcon :: T.Text+ bluetoothNoControllerIcon :: T.Text } +-- | Default Bluetooth widget configuration. defaultBluetoothWidgetConfig :: BluetoothWidgetConfig-defaultBluetoothWidgetConfig = BluetoothWidgetConfig- { bluetoothFormatConnected = "$device_alias$"- , bluetoothFormatOn = ""- , bluetoothFormatOff = ""- , bluetoothFormatNoController = ""- , bluetoothTooltipFormat = Just "Bluetooth: $status$\nController: $controller_alias$\n$device_list$"- , bluetoothDeviceListFormat = "$device_alias$ ($device_battery$%)"- , bluetoothConnectedIcon = T.pack "\xF293"- , bluetoothOnIcon = T.pack "\xF293"- , bluetoothOffIcon = T.pack "\xF294"- , bluetoothNoControllerIcon = T.pack "\xF294"- }+defaultBluetoothWidgetConfig =+ BluetoothWidgetConfig+ { bluetoothFormatConnected = "$device_alias$",+ bluetoothFormatOn = "",+ bluetoothFormatOff = "",+ bluetoothFormatNoController = "",+ bluetoothTooltipFormat = Just "Bluetooth: $status$\nController: $controller_alias$\n$device_list$",+ bluetoothDeviceListFormat = "$device_alias$ ($device_battery$%)",+ bluetoothConnectedIcon = T.pack "\xF293",+ bluetoothOnIcon = T.pack "\xF293",+ bluetoothOffIcon = T.pack "\xF294",+ bluetoothNoControllerIcon = T.pack "\xF294"+ } instance Default BluetoothWidgetConfig where def = defaultBluetoothWidgetConfig@@ -193,7 +200,7 @@ -- Get the first connected device for primary display primaryDevice = case bluetoothConnectedDevices info of [] -> Nothing- (d:_) -> Just d+ (d : _) -> Just d deviceAliasText = maybe "" deviceAlias primaryDevice deviceBatteryText = maybe "?" (maybe "?" show . deviceBatteryPercentage) primaryDevice@@ -201,12 +208,13 @@ controllerAliasText = maybe "none" controllerAlias (bluetoothController info) -- Build device list for tooltip- deviceListText = intercalate "\n" $- map formatDeviceEntry (bluetoothConnectedDevices info)+ deviceListText =+ intercalate "\n" $+ map formatDeviceEntry (bluetoothConnectedDevices info) formatDeviceEntry dev = let battery = maybe "?" show (deviceBatteryPercentage dev)- in deviceAlias dev ++ " (" ++ battery ++ "%)"+ in deviceAlias dev ++ " (" ++ battery ++ "%)" status <- escapeText $ T.pack statusText deviceAliasEsc <- escapeText $ T.pack deviceAliasText@@ -215,12 +223,12 @@ deviceListEsc <- escapeText $ T.pack deviceListText return- [ ("status", status)- , ("device_alias", deviceAliasEsc)- , ("device_battery", deviceBatteryEsc)- , ("num_connections", show numConnections)- , ("controller_alias", controllerAliasEsc)- , ("device_list", deviceListEsc)+ [ ("status", status),+ ("device_alias", deviceAliasEsc),+ ("device_battery", deviceBatteryEsc),+ ("num_connections", show numConnections),+ ("controller_alias", controllerAliasEsc),+ ("device_list", deviceListEsc) ] -- | Render a template with the given attributes.
src/System/Taffybar/Widget/CPUMonitor.hs view
@@ -1,4 +1,9 @@+{-# LANGUAGE OverloadedStrings #-}+ --------------------------------------------------------------------------------++--------------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.CPUMonitor -- Copyright : (c) José A. Romero L.@@ -8,36 +13,34 @@ -- Stability : unstable -- Portability : unportable ----- Simple CPU monitor that uses a PollingGraph to visualize variations in the--- user and system CPU times in one selected core, or in all cores available.------------------------------------------------------------------------------------+-- Simple CPU monitor that uses a channel-driven graph to visualize variations+-- in the user and system CPU times in one selected core, or in all cores+-- available. module System.Taffybar.Widget.CPUMonitor where import Control.Monad.IO.Class-import Data.IORef import qualified GI.Gtk-import System.Taffybar.Information.CPU2 (getCPUInfo)-import System.Taffybar.Information.StreamInfo (getAccLoad)-import System.Taffybar.Widget.Generic.PollingGraph+import System.Taffybar.Information.CPU2 (CPULoad (..), getCPULoadChan)+import System.Taffybar.Widget.Generic.ChannelGraph+import System.Taffybar.Widget.Generic.Graph+import System.Taffybar.Widget.Util (widgetSetClassGI) --- | Creates a new CPU monitor. This is a PollingGraph fed by regular calls to--- getCPUInfo, associated to an IORef used to remember the values yielded by the--- last call to this function.-cpuMonitorNew- :: MonadIO m- => GraphConfig -- ^ Configuration data for the Graph.- -> Double -- ^ Polling period (in seconds).- -> String -- ^ Name of the core to watch (e.g. \"cpu\", \"cpu0\").- -> m GI.Gtk.Widget+-- | Creates a new CPU monitor. This is a channel-driven graph fed by CPU load+-- samples for one core (or all cores when "cpu" is selected).+cpuMonitorNew ::+ (MonadIO m) =>+ -- | Configuration data for the Graph.+ GraphConfig ->+ -- | Polling period (in seconds).+ Double ->+ -- | Name of the core to watch (e.g. \"cpu\", \"cpu0\").+ String ->+ m GI.Gtk.Widget cpuMonitorNew cfg interval cpu = liftIO $ do- info <- getCPUInfo cpu- sample <- newIORef info- pollingGraphNew cfg interval $ probe sample cpu+ chan <- getCPULoadChan cpu interval+ channelGraphNew cfg chan toSample+ >>= (`widgetSetClassGI` "cpu-monitor") -probe :: IORef [Int] -> String -> IO [Double]-probe sample cpuName = do- load <- getAccLoad sample $ getCPUInfo cpuName- case load of- l0:l1:l2:_ -> return [ l0 + l1, l2 ] -- user, system- _ -> return []+toSample :: CPULoad -> IO [Double]+toSample CPULoad {cpuTotalLoad = totalLoad, cpuSystemLoad = systemLoad} =+ return [totalLoad, systemLoad]
+ src/System/Taffybar/Widget/ChannelWorkspaces.hs view
@@ -0,0 +1,42 @@+-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.ChannelWorkspaces+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Transitional, prefixed exports for the channel-driven Workspaces widget.+--+-- The canonical API lives in "System.Taffybar.Widget.Workspaces"+-- (with names like 'workspacesNew'). This module provides prefixed aliases so+-- "System.Taffybar.Widget" can expose the new widget without colliding with+-- legacy workspace exports.+module System.Taffybar.Widget.ChannelWorkspaces+ {-# DEPRECATED "Use System.Taffybar.Widget.Workspaces instead." #-}+ ( ChannelWorkspacesConfig,+ defaultChannelWorkspacesConfig,+ defaultChannelEWMHWorkspacesConfig,+ channelWorkspacesNew,+ )+where++import qualified GI.Gtk as Gtk+import System.Taffybar.Context (TaffyIO)+import qualified System.Taffybar.Widget.Workspaces as Channel++type ChannelWorkspacesConfig = Channel.WorkspacesConfig++defaultChannelWorkspacesConfig :: ChannelWorkspacesConfig+defaultChannelWorkspacesConfig = Channel.defaultWorkspacesConfig++defaultChannelEWMHWorkspacesConfig :: ChannelWorkspacesConfig+defaultChannelEWMHWorkspacesConfig = Channel.defaultEWMHWorkspacesConfig++channelWorkspacesNew :: ChannelWorkspacesConfig -> TaffyIO Gtk.Widget+channelWorkspacesNew = Channel.workspacesNew
src/System/Taffybar/Widget/CommandRunner.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ --------------------------------------------------------------------------------++--------------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.CommandRunner -- Copyright : (c) Arseniy Seroka@@ -11,34 +15,42 @@ -- -- Simple function which runs user defined command and -- returns it's output in PollingLabel widget-----------------------------------------------------------------------------------module System.Taffybar.Widget.CommandRunner ( commandRunnerNew ) where+module System.Taffybar.Widget.CommandRunner (commandRunnerNew) where -import Control.Monad.IO.Class-import qualified GI.Gtk-import System.Log.Logger-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.PollingLabel-import Text.Printf+import Control.Monad.IO.Class import qualified Data.Text as T+import qualified GI.Gtk+import System.Log.Logger+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.PollingLabel+import System.Taffybar.Widget.Util (widgetSetClassGI)+import Text.Printf -- | Creates a new command runner widget. This is a 'PollingLabel' fed by -- regular calls to command given by argument. The results of calling this -- function are displayed as string.-commandRunnerNew- :: MonadIO m- => Double -- ^ Polling period (in seconds).- -> String -- ^ Command to execute. Should be in $PATH or an absolute path- -> [String] -- ^ Command argument. May be @[]@- -> T.Text -- ^ If command fails this will be displayed.- -> m GI.Gtk.Widget+commandRunnerNew ::+ (MonadIO m) =>+ -- | Polling period (in seconds).+ Double ->+ -- | Command to execute. Should be in $PATH or an absolute path+ String ->+ -- | Command argument. May be @[]@+ [String] ->+ -- | If command fails this will be displayed.+ T.Text ->+ m GI.Gtk.Widget commandRunnerNew interval cmd args defaultOutput =- pollingLabelNew interval $ runCommandWithDefault cmd args defaultOutput+ pollingLabelNew interval (runCommandWithDefault cmd args defaultOutput)+ >>= (`widgetSetClassGI` "command-runner") runCommandWithDefault :: FilePath -> [String] -> T.Text -> IO T.Text runCommandWithDefault cmd args def = T.filter (/= '\n') <$> (runCommand cmd args >>= either logError (return . T.pack))- where logError err =- logM "System.Taffybar.Widget.CommandRunner" ERROR- (printf "Got error in CommandRunner %s" err) >> return def+ where+ logError err =+ logM+ "System.Taffybar.Widget.CommandRunner"+ ERROR+ (printf "Got error in CommandRunner %s" err)+ >> return def
src/System/Taffybar/Widget/Crypto.hs view
@@ -1,9 +1,13 @@-{-# LANGUAGE TypeApplications #-}-{-# LANGUAGE DataKinds #-}-{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Crypto -- Copyright : (c) Ivan A. Malison@@ -15,34 +19,34 @@ -- -- This module provides widgets for tracking the price of crypto currency -- assets.------------------------------------------------------------------------------ module System.Taffybar.Widget.Crypto where -import Control.Concurrent-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Class-import Control.Monad.Trans.Maybe-import Control.Monad.Trans.Reader-import Data.Aeson-import Data.Aeson.Types+import Control.Concurrent+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Class+import Control.Monad.Trans.Maybe+import Control.Monad.Trans.Reader+import Data.Aeson import qualified Data.Aeson.Key as Key+import Data.Aeson.Types import qualified Data.ByteString.Lazy as LBS-import Data.Maybe-import Data.Proxy+import Data.Maybe+import Data.Proxy import qualified Data.Text-import GHC.TypeLits+import GHC.TypeLits import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk import qualified GI.Gtk as Gtk-import Network.HTTP.Simple hiding (Proxy)-import System.FilePath.Posix-import System.Taffybar.Context-import System.Taffybar.Information.Crypto hiding (symbol)-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Generic.ScalingImage (scalingImage)-import System.Taffybar.WindowIcon-import Text.Printf+import Network.HTTP.Simple hiding (Proxy)+import System.FilePath.Posix+import System.Taffybar.Context+import System.Taffybar.Information.Crypto hiding (symbol)+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Generic.ScalingImage (scalingImage)+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI)+import System.Taffybar.WindowIcon+import Text.Printf -- | Extends 'cryptoPriceLabel' with an icon corresponding to the symbol of the -- purchase crypto that will appear to the left of the price label. See the@@ -53,26 +57,25 @@ -- symbol of the relevant token and the underlying currency in which its price -- should be expressed. See the docstring of 'cryptoPriceLabel' for details -- about the exact format that this string should take.-cryptoPriceLabelWithIcon :: forall a. KnownSymbol a => TaffyIO Gtk.Widget+cryptoPriceLabelWithIcon :: forall a. (KnownSymbol a) => TaffyIO Gtk.Widget cryptoPriceLabelWithIcon = do label <- cryptoPriceLabel @a let symbolPair = symbolVal (Proxy :: Proxy a) symbol = takeWhile (/= '-') symbolPair- hbox <- Gtk.boxNew Gtk.OrientationHorizontal 0 ctx <- ask let refresh size =- Just <$> runReaderT- (fromMaybe <$> pixBufFromColor size 0 <*> getCryptoPixbuf symbol) ctx+ Just+ <$> runReaderT+ (fromMaybe <$> pixBufFromColor size 0 <*> getCryptoPixbuf symbol)+ ctx (image, _) <- scalingImage refresh Gtk.OrientationHorizontal-- Gtk.containerAdd hbox image- Gtk.containerAdd hbox label-- Gtk.widgetShowAll hbox-- Gtk.toWidget hbox+ _ <- widgetSetClassGI image "crypto-price-icon"+ _ <- widgetSetClassGI label "crypto-price-label"+ buildIconLabelBox image label+ >>= (`widgetSetClassGI` "crypto-price") +-- | Stored CoinMarketCap API key used for on-demand icon fetches. newtype CMCAPIKey = CMCAPIKey String -- | Set the coinmarketcap.com api key that will be used for retrieving crypto@@ -92,22 +95,30 @@ -- associated with the asset that you want to track as follows: -- -- > cryptoPriceLabel @"BTC-USD"-cryptoPriceLabel :: forall a. KnownSymbol a => TaffyIO Gtk.Widget+cryptoPriceLabel :: forall a. (KnownSymbol a) => TaffyIO Gtk.Widget cryptoPriceLabel = getCryptoPriceChannel @a >>= cryptoPriceLabel' +-- | Build a crypto price label from an existing price channel. cryptoPriceLabel' :: CryptoPriceChannel a -> TaffyIO Gtk.Widget cryptoPriceLabel' (CryptoPriceChannel (chan, var)) = do label <- Gtk.labelNew Nothing- let updateWidget CryptoPriceInfo { lastPrice = cryptoPrice } =- postGUIASync $ Gtk.labelSetMarkup label $- Data.Text.pack $ show cryptoPrice- void $ Gtk.onWidgetRealize label $- readMVar var >>= updateWidget- Gtk.toWidget =<< channelWidgetNew label chan updateWidget+ _ <- widgetSetClassGI label "crypto-price-label"+ let updateWidget CryptoPriceInfo {lastPrice = cryptoPrice} =+ postGUIASync $+ Gtk.labelSetMarkup label $+ Data.Text.pack $+ show cryptoPrice+ void $+ Gtk.onWidgetRealize label $+ readMVar var >>= updateWidget+ (Gtk.toWidget =<< channelWidgetNew label chan updateWidget)+ >>= (`widgetSetClassGI` "crypto-price-label") +-- | Directory used to cache downloaded crypto icons. cryptoIconsDir :: IO FilePath cryptoIconsDir = (</> "crypto_icons") <$> taffyStateDir +-- | Cache path for a specific crypto symbol icon. pathForCryptoSymbol :: String -> IO FilePath pathForCryptoSymbol symbol = (</> printf "%s.png" symbol) <$> cryptoIconsDir@@ -120,16 +131,20 @@ getCryptoPixbuf :: String -> TaffyIO (Maybe Gdk.Pixbuf) getCryptoPixbuf = getCryptoIconFromCache <||> getCryptoIconFromCMC -getCryptoIconFromCache :: MonadIO m => String -> m (Maybe Gdk.Pixbuf)-getCryptoIconFromCache symbol = liftIO $- pathForCryptoSymbol symbol >>= safePixbufNewFromFile+-- | Try to load a cached icon for a symbol from disk.+getCryptoIconFromCache :: (MonadIO m) => String -> m (Maybe Gdk.Pixbuf)+getCryptoIconFromCache symbol =+ liftIO $+ pathForCryptoSymbol symbol >>= safePixbufNewFromFile +-- | Try to fetch an icon via CoinMarketCap using the configured API key. getCryptoIconFromCMC :: String -> TaffyIO (Maybe Gdk.Pixbuf) getCryptoIconFromCMC symbol = runMaybeT $ do CMCAPIKey cmcAPIKey <- MaybeT getState MaybeT $ lift $ getCryptoIconFromCMC' cmcAPIKey symbol +-- | Fetch a symbol icon from CoinMarketCap metadata and cache it to disk. getCryptoIconFromCMC' :: String -> String -> IO (Maybe Gdk.Pixbuf) getCryptoIconFromCMC' cmcAPIKey symbol = do jsonText <- getCryptoMeta cmcAPIKey symbol@@ -138,7 +153,9 @@ maybe (return ()) (`downloadURIToPath` path) uri safePixbufNewFromFile path +-- | Extract the logo URI for a symbol from CoinMarketCap metadata JSON. getIconURIFromJSON :: String -> LBS.ByteString -> Maybe Data.Text.Text getIconURIFromJSON symbol jsonText =- decode jsonText >>= parseMaybe- ((.: "data") >=> (.: Key.fromString symbol) >=> (.: "logo"))+ decode jsonText+ >>= parseMaybe+ ((.: "data") >=> (.: Key.fromString symbol) >=> (.: "logo"))
src/System/Taffybar/Widget/DiskIOMonitor.hs view
@@ -1,4 +1,9 @@+{-# LANGUAGE OverloadedStrings #-}+ --------------------------------------------------------------------------------++--------------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.DiskIOMonitor -- Copyright : (c) José A. Romero L.@@ -10,31 +15,32 @@ -- -- Simple Disk IO monitor that uses a PollingGraph to visualize the speed of -- read/write operations in one selected disk or partition.--------------------------------------------------------------------------------------module System.Taffybar.Widget.DiskIOMonitor ( dioMonitorNew ) where+module System.Taffybar.Widget.DiskIOMonitor (dioMonitorNew) where -import Control.Monad.IO.Class import qualified GI.Gtk-import System.Taffybar.Information.DiskIO ( getDiskTransfer )-import System.Taffybar.Widget.Generic.PollingGraph ( GraphConfig, pollingGraphNew )+import System.Taffybar.Context (TaffyIO)+import System.Taffybar.Information.DiskIO (getDiskTransfer)+import System.Taffybar.Widget.Generic.PollingGraph (GraphConfig, pollingGraphNew)+import System.Taffybar.Widget.Util (widgetSetClassGI) -- | Creates a new disk IO monitor widget. This is a 'PollingGraph' fed by -- regular calls to 'getDiskTransfer'. The results of calling this function -- are normalized to the maximum value of the obtained probe (either read or -- write transfer).-dioMonitorNew- :: MonadIO m- => GraphConfig -- ^ Configuration data for the Graph.- -> Double -- ^ Polling period (in seconds).- -> String -- ^ Name of the disk or partition to watch (e.g. \"sda\", \"sdb1\").- -> m GI.Gtk.Widget-dioMonitorNew cfg pollSeconds =- pollingGraphNew cfg pollSeconds . probeDisk+dioMonitorNew ::+ -- | Configuration data for the Graph.+ GraphConfig ->+ -- | Polling period (in seconds).+ Double ->+ -- | Name of the disk or partition to watch (e.g. \"sda\", \"sdb1\").+ String ->+ TaffyIO GI.Gtk.Widget+dioMonitorNew cfg pollSeconds disk = do+ widget <- pollingGraphNew cfg pollSeconds (probeDisk disk)+ widgetSetClassGI widget "disk-io-monitor" probeDisk :: String -> IO [Double] probeDisk disk = do transfer <- getDiskTransfer disk let top = foldr max 1.0 transfer- return $ map (/top) transfer+ return $ map (/ top) transfer
src/System/Taffybar/Widget/DiskUsage.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.DiskUsage -- Copyright : (c) Ivan A. Malison@@ -14,56 +18,60 @@ -- -- Template variables: @$total$@, @$used$@, @$free$@, @$available$@, -- @$usedPercent$@, @$freePercent$@, @$path$@.+-- @$free$@ and @$available$@ both report space available to unprivileged users+-- (the @Avail@ value from @df@). -- -- Size values are auto-formatted with appropriate units (GiB, TiB, etc.).------------------------------------------------------------------------------- module System.Taffybar.Widget.DiskUsage- ( DiskUsageWidgetConfig(..)- , defaultDiskUsageWidgetConfig- , diskUsageIconNew- , diskUsageIconNewWith- , diskUsageLabelNew- , diskUsageLabelNewWith- , diskUsageNew- , diskUsageNewWith- ) where+ ( DiskUsageWidgetConfig (..),+ defaultDiskUsageWidgetConfig,+ diskUsageIconNew,+ diskUsageIconNewWith,+ diskUsageLabelNew,+ diskUsageLabelNewWith,+ diskUsageNew,+ diskUsageNewWith,+ )+where import Control.Monad (void) import Control.Monad.IO.Class-import Data.Default (Default(..))+import Data.Default (Default (..)) import qualified Data.Text as T import qualified GI.Gtk as Gtk import System.Taffybar.Context (TaffyIO) import System.Taffybar.Information.DiskUsage import System.Taffybar.Util (postGUIASync) import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Util (buildIconLabelBox)+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI) import Text.Printf (printf) import Text.StringTemplate +-- | Configuration for disk usage widgets. data DiskUsageWidgetConfig = DiskUsageWidgetConfig- { diskUsagePath :: FilePath- -- ^ Filesystem path to monitor (default @\/@).- , diskUsagePollInterval :: Double- -- ^ Polling interval in seconds (default 60).- , diskUsageFormat :: String- -- ^ Label format string (default @\"$free$\"@).- , diskUsageTooltipFormat :: Maybe String- -- ^ Optional tooltip format string.- , diskUsageIcon :: T.Text- -- ^ Nerd font icon character (default U+F0A0, ).+ { -- | Filesystem path to monitor (default @\/@).+ diskUsagePath :: FilePath,+ -- | Polling interval in seconds (default 60).+ diskUsagePollInterval :: Double,+ -- | Label format string (default @\"$free$\"@).+ diskUsageFormat :: String,+ -- | Optional tooltip format string.+ diskUsageTooltipFormat :: Maybe String,+ -- | Nerd font icon character (default U+F0A0, ).+ diskUsageIcon :: T.Text } +-- | Default disk usage widget configuration. defaultDiskUsageWidgetConfig :: DiskUsageWidgetConfig-defaultDiskUsageWidgetConfig = DiskUsageWidgetConfig- { diskUsagePath = "/"- , diskUsagePollInterval = 60- , diskUsageFormat = "$free$"- , diskUsageTooltipFormat =- Just "$path$: $used$ / $total$ ($usedPercent$% used)"- , diskUsageIcon = T.pack "\xF0A0" --- }+defaultDiskUsageWidgetConfig =+ DiskUsageWidgetConfig+ { diskUsagePath = "/",+ diskUsagePollInterval = 60,+ diskUsageFormat = "$free$",+ diskUsageTooltipFormat =+ Just "$path$: $used$ / $total$ ($usedPercent$% used)",+ diskUsageIcon = T.pack "\xF0A0" --+ } instance Default DiskUsageWidgetConfig where def = defaultDiskUsageWidgetConfig@@ -75,13 +83,14 @@ -- | Create a disk usage label with the given configuration. diskUsageLabelNewWith :: DiskUsageWidgetConfig -> TaffyIO Gtk.Widget diskUsageLabelNewWith config = do- let path = diskUsagePath config+ let path = diskUsagePath config interval = diskUsagePollInterval config- chan <- getDiskUsageInfoChan interval path+ chan <- getDiskUsageInfoChan interval path initialInfo <- getDiskUsageInfoState interval path liftIO $ do label <- Gtk.labelNew Nothing+ _ <- widgetSetClassGI label "disk-usage-label" let updateLabel info = postGUIASync $ do let (labelText, tooltipText) = formatDiskUsage config info@@ -90,7 +99,8 @@ void $ Gtk.onWidgetRealize label $ updateLabel initialInfo Gtk.widgetShowAll label- Gtk.toWidget =<< channelWidgetNew label chan updateLabel+ (Gtk.toWidget =<< channelWidgetNew label chan updateLabel)+ >>= (`widgetSetClassGI` "disk-usage-label") -- | Create a disk usage icon widget with default configuration. diskUsageIconNew :: TaffyIO Gtk.Widget@@ -100,6 +110,7 @@ diskUsageIconNewWith :: DiskUsageWidgetConfig -> TaffyIO Gtk.Widget diskUsageIconNewWith config = liftIO $ do label <- Gtk.labelNew (Just (diskUsageIcon config))+ _ <- widgetSetClassGI label "disk-usage-icon" Gtk.widgetShowAll label Gtk.toWidget label @@ -112,7 +123,9 @@ diskUsageNewWith config = do iconWidget <- diskUsageIconNewWith config labelWidget <- diskUsageLabelNewWith config- liftIO $ buildIconLabelBox iconWidget labelWidget+ liftIO $+ buildIconLabelBox iconWidget labelWidget+ >>= (`widgetSetClassGI` "disk-usage") -- -------------------------------------------------------------------------- -- Formatting@@ -120,9 +133,9 @@ formatDiskUsage :: DiskUsageWidgetConfig -> DiskUsageInfo -> (T.Text, Maybe T.Text) formatDiskUsage config info = let attrs = diskUsageAttrs (diskUsagePath config) info- labelText = renderTpl (diskUsageFormat config) attrs+ labelText = renderTpl (diskUsageFormat config) attrs tooltipText = renderTpl <$> diskUsageTooltipFormat config <*> pure attrs- in (labelText, tooltipText)+ in (labelText, tooltipText) renderTpl :: String -> [(String, String)] -> T.Text renderTpl template attrs =@@ -130,23 +143,24 @@ diskUsageAttrs :: FilePath -> DiskUsageInfo -> [(String, String)] diskUsageAttrs path info =- [ ("total", formatBytes (diskInfoTotal info))- , ("used", formatBytes (diskInfoUsed info))- , ("free", formatBytes (diskInfoFree info))- , ("available", formatBytes (diskInfoAvailable info))- , ("usedPercent", printf "%.0f" (diskInfoUsedPercent info))- , ("freePercent", printf "%.0f" (diskInfoFreePercent info))- , ("path", path)+ [ ("total", formatBytes (diskInfoTotal info)),+ ("used", formatBytes (diskInfoUsed info)),+ -- Prefer user-available blocks for the primary "free" template variable.+ ("free", formatBytes (diskInfoAvailable info)),+ ("available", formatBytes (diskInfoAvailable info)),+ ("usedPercent", printf "%.0f" (diskInfoUsedPercent info)),+ ("freePercent", printf "%.0f" (diskInfoFreePercent info)),+ ("path", path) ] -- | Format a byte count with auto-scaled units. formatBytes :: Integer -> String formatBytes bytes | gb >= 1024 = printf "%.1f TiB" (gb / 1024 :: Double)- | gb >= 1 = printf "%.1f GiB" gb- | mb >= 1 = printf "%.0f MiB" mb- | otherwise = printf "%.0f KiB" kb+ | gb >= 1 = printf "%.1f GiB" gb+ | mb >= 1 = printf "%.0f MiB" mb+ | otherwise = printf "%.0f KiB" kb where- kb = fromIntegral bytes / 1024 :: Double+ kb = fromIntegral bytes / 1024 :: Double mb = kb / 1024 gb = mb / 1024
src/System/Taffybar/Widget/FSMonitor.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.FSMonitor -- Copyright : (c) José A. Romero L.@@ -12,32 +16,33 @@ -- Simple text widget that monitors the current usage of selected disk -- partitions by regularly parsing the output of the df command in Linux -- systems.-----------------------------------------------------------------------------------module System.Taffybar.Widget.FSMonitor ( fsMonitorNew ) where+module System.Taffybar.Widget.FSMonitor (fsMonitorNew) where -import Control.Monad.IO.Class-import qualified GI.Gtk-import System.Process ( readProcess )-import System.Taffybar.Widget.Generic.PollingLabel ( pollingLabelNew )+import Control.Monad.IO.Class import qualified Data.Text as T+import qualified GI.Gtk+import System.Process (readProcess)+import System.Taffybar.Widget.Generic.PollingLabel (pollingLabelNew)+import System.Taffybar.Widget.Util (widgetSetClassGI) -- | Creates a new filesystem monitor widget. It contains one 'PollingLabel' -- that displays the data returned by the df command. The usage level of all -- requested partitions is extracted in one single operation.-fsMonitorNew- :: MonadIO m- => Double -- ^ Polling interval (in seconds, e.g. 500)- -> [String] -- ^ Names of the partitions to monitor (e.g. [\"\/\", \"\/home\"])- -> m GI.Gtk.Widget+fsMonitorNew ::+ (MonadIO m) =>+ -- | Polling interval (in seconds, e.g. 500)+ Double ->+ -- | Names of the partitions to monitor (e.g. [\"\/\", \"\/home\"])+ [String] ->+ m GI.Gtk.Widget fsMonitorNew interval fsList = liftIO $ do- label <- pollingLabelNew interval $ showFSInfo fsList+ label <- pollingLabelNew interval (showFSInfo fsList)+ _ <- widgetSetClassGI label "fs-monitor" GI.Gtk.widgetShowAll label GI.Gtk.toWidget label showFSInfo :: [String] -> IO T.Text showFSInfo fsList = do- fsOut <- readProcess "df" ("-kP":fsList) ""+ fsOut <- readProcess "df" ("-kP" : fsList) "" let fss = map (take 2 . reverse . words) $ drop 1 $ lines fsOut return $ T.pack $ unwords $ map ((\s -> "[" ++ s ++ "]") . unwords) fss
src/System/Taffybar/Widget/FreedesktopNotifications.hs view
@@ -2,6 +2,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-}+ -- | This widget listens on DBus for freedesktop notifications -- (<http://developer.gnome.org/notification-spec/>). Currently it is -- somewhat ugly, but the format is somewhat configurable.@@ -19,51 +20,57 @@ -- A timeout thread is associated with a notification id. -- It sleeps until the specific timeout and then removes every notification -- with that id from the queue- module System.Taffybar.Widget.FreedesktopNotifications- ( Notification(..)- , NotificationConfig(..)- , defaultNotificationConfig- , notifyAreaNew- ) where+ ( Notification (..),+ NotificationConfig (..),+ defaultNotificationConfig,+ notifyAreaNew,+ )+where -import Control.Concurrent-import Control.Concurrent.STM-import Control.Monad ( forever, void )-import Control.Monad.IO.Class-import DBus-import DBus.Client-import Data.Default ( Default(..) )-import Data.Foldable-import Data.Int ( Int32 )-import Data.Map ( Map )-import Data.Sequence ( Seq, (|>), viewl, ViewL(..) )+import Control.Concurrent+import Control.Concurrent.STM+import Control.Monad (forever, void)+import Control.Monad.IO.Class+import DBus+import DBus.Client+import Data.Default (Default (..))+import Data.Foldable+import Data.Int (Int32)+import Data.Map (Map)+import Data.Sequence (Seq, ViewL (..), viewl, (|>)) import qualified Data.Sequence as S-import Data.Text ( Text )+import Data.Text (Text) import qualified Data.Text as T-import Data.Word ( Word32 )-import GI.GLib (markupEscapeText)-import GI.Gtk+import Data.Word (Word32)+import GI.GLib (markupEscapeText)+import GI.Gtk import qualified GI.Pango as Pango-import System.Taffybar.Util+import System.Taffybar.Util+import System.Taffybar.Widget.Util (widgetSetClassGI) -- | A simple structure representing a Freedesktop notification data Notification = Notification- { noteAppName :: Text- , noteReplaceId :: Word32- , noteSummary :: Text- , noteBody :: Text- , noteExpireTimeout :: Maybe Int32- , noteId :: Word32- } deriving (Show, Eq)+ { noteAppName :: Text,+ noteReplaceId :: Word32,+ noteSummary :: Text,+ noteBody :: Text,+ noteExpireTimeout :: Maybe Int32,+ noteId :: Word32+ }+ deriving (Show, Eq) data NotifyState = NotifyState- { noteWidget :: Label- , noteContainer :: Widget- , noteConfig :: NotificationConfig -- ^ The associated configuration- , noteQueue :: TVar (Seq Notification) -- ^ The queue of active notifications- , noteIdSource :: TVar Word32 -- ^ A source of fresh notification ids- , noteChan :: TChan () -- ^ Writing to this channel wakes up the display thread+ { noteWidget :: Label,+ noteContainer :: Widget,+ -- | The associated configuration+ noteConfig :: NotificationConfig,+ -- | The queue of active notifications+ noteQueue :: TVar (Seq Notification),+ -- | A source of fresh notification ids+ noteIdSource :: TVar Word32,+ -- | Writing to this channel wakes up the display thread+ noteChan :: TChan () } initialNoteState :: Widget -> Label -> NotificationConfig -> IO NotifyState@@ -71,18 +78,21 @@ m <- newTVarIO 1 q <- newTVarIO S.empty ch <- newBroadcastTChanIO- return NotifyState { noteQueue = q- , noteIdSource = m- , noteWidget = l- , noteContainer = wrapper- , noteConfig = cfg- , noteChan = ch- }+ return+ NotifyState+ { noteQueue = q,+ noteIdSource = m,+ noteWidget = l,+ noteContainer = wrapper,+ noteConfig = cfg,+ noteChan = ch+ } -- | Removes every notification with id 'nId' from the queue notePurge :: NotifyState -> Word32 -> IO ()-notePurge s nId = atomically . modifyTVar' (noteQueue s) $- S.filter ((nId /=) . noteId)+notePurge s nId =+ atomically . modifyTVar' (noteQueue s) $+ S.filter ((nId /=) . noteId) -- | Removes the first (oldest) notification from the queue noteNext :: NotifyState -> IO ()@@ -94,41 +104,54 @@ -- | Generates a fresh notification id noteFreshId :: NotifyState -> IO Word32-noteFreshId NotifyState { noteIdSource } = atomically $ do+noteFreshId NotifyState {noteIdSource} = atomically $ do nId <- readTVar noteIdSource writeTVar noteIdSource (succ nId) return nId --------------------------------------------------------------------------------+ -- | Handles a new notification-notify :: NotifyState- -> Text -- ^ Application name- -> Word32 -- ^ Replaces id- -> Text -- ^ App icon- -> Text -- ^ Summary- -> Text -- ^ Body- -> [Text] -- ^ Actions- -> Map Text Variant -- ^ Hints- -> Int32 -- ^ Expires timeout (milliseconds)- -> IO Word32+notify ::+ NotifyState ->+ -- | Application name+ Text ->+ -- | Replaces id+ Word32 ->+ -- | App icon+ Text ->+ -- | Summary+ Text ->+ -- | Body+ Text ->+ -- | Actions+ [Text] ->+ -- | Hints+ Map Text Variant ->+ -- | Expires timeout (milliseconds)+ Int32 ->+ IO Word32 notify s appName replaceId _ summary body _ _ timeout = do realId <- if replaceId == 0 then noteFreshId s else return replaceId let configTimeout = notificationMaxTimeout (noteConfig s)- realTimeout = if timeout <= 0 -- Gracefully handle out of spec negative values- then configTimeout- else case configTimeout of- Nothing -> Just timeout- Just maxTimeout -> Just (min maxTimeout timeout)+ realTimeout =+ if timeout <= 0 -- Gracefully handle out of spec negative values+ then configTimeout+ else case configTimeout of+ Nothing -> Just timeout+ Just maxTimeout -> Just (min maxTimeout timeout) escapedSummary <- markupEscapeText summary (-1) escapedBody <- markupEscapeText body (-1)- let n = Notification { noteAppName = appName- , noteReplaceId = replaceId- , noteSummary = escapedSummary- , noteBody = escapedBody- , noteExpireTimeout = realTimeout- , noteId = realId- }+ let n =+ Notification+ { noteAppName = appName,+ noteReplaceId = replaceId,+ noteSummary = escapedSummary,+ noteBody = escapedBody,+ noteExpireTimeout = realTimeout,+ noteId = realId+ } -- Either add the new note to the queue or replace an existing note if their ids match atomically $ do queue <- readTVar $ noteQueue s@@ -145,29 +168,34 @@ notePurge s nId wakeupDisplayThread s -notificationDaemon :: (AutoMethod f1, AutoMethod f2)- => f1 -> f2 -> IO ()+notificationDaemon ::+ (AutoMethod f1, AutoMethod f2) =>+ f1 -> f2 -> IO () notificationDaemon onNote onCloseNote = do client <- connectSession _ <- requestName client "org.freedesktop.Notifications" [nameAllowReplacement, nameReplaceExisting] export client "/org/freedesktop/Notifications" interface where getServerInformation :: IO (Text, Text, Text, Text)- getServerInformation = return ("haskell-notification-daemon",- "nochair.net",- "0.0.1",- "1.1")+ getServerInformation =+ return+ ( "haskell-notification-daemon",+ "nochair.net",+ "0.0.1",+ "1.1"+ ) getCapabilities :: IO [Text] getCapabilities = return ["body", "body-markup"]- interface = defaultInterface- { interfaceName = "org.freedesktop.Notifications"- , interfaceMethods =- [ autoMethod "GetServerInformation" getServerInformation- , autoMethod "GetCapabilities" getCapabilities- , autoMethod "CloseNotification" onCloseNote- , autoMethod "Notify" onNote- ]- }+ interface =+ defaultInterface+ { interfaceName = "org.freedesktop.Notifications",+ interfaceMethods =+ [ autoMethod "GetServerInformation" getServerInformation,+ autoMethod "GetCapabilities" getCapabilities,+ autoMethod "CloseNotification" onCloseNote,+ autoMethod "Notify" onNote+ ]+ } -------------------------------------------------------------------------------- wakeupDisplayThread :: NotifyState -> IO ()@@ -182,10 +210,10 @@ ns <- readTVarIO (noteQueue s) postGUIASync $ if S.length ns == 0- then widgetHide (noteContainer s)- else do- labelSetMarkup (noteWidget s) $ formatMessage (noteConfig s) (toList ns)- widgetShowAll (noteContainer s)+ then widgetHide (noteContainer s)+ else do+ labelSetMarkup (noteWidget s) $ formatMessage (noteConfig s) (toList ns)+ widgetShowAll (noteContainer s) where formatMessage NotificationConfig {..} ns = T.take notificationMaxLength $ notificationFormatter ns@@ -195,28 +223,35 @@ startTimeoutThread s Notification {..} = case noteExpireTimeout of Nothing -> return () Just timeout -> void $ forkIO $ do- threadDelay (fromIntegral timeout * 10^(3 :: Int))+ threadDelay (fromIntegral timeout * 10 ^ (3 :: Int)) notePurge s noteId wakeupDisplayThread s --------------------------------------------------------------------------------++-- | Rendering and behavior settings for the notification widget. data NotificationConfig = NotificationConfig- { notificationMaxTimeout :: Maybe Int32 -- ^ Maximum time that a notification will be displayed (in seconds). Default: None- , notificationMaxLength :: Int -- ^ Maximum length displayed, in characters. Default: 100- , notificationFormatter :: [Notification] -> T.Text -- ^ Function used to format notifications, takes the notifications from first to last+ { -- | Maximum time that a notification will be displayed (in seconds). Default: None+ notificationMaxTimeout :: Maybe Int32,+ -- | Maximum length displayed, in characters. Default: 100+ notificationMaxLength :: Int,+ -- | Function used to format notifications, takes the notifications from first to last+ notificationFormatter :: [Notification] -> T.Text } defaultFormatter :: [Notification] -> T.Text defaultFormatter [] = ""-defaultFormatter (n:ns) =+defaultFormatter (n : ns) = let count = length ns + 1- prefix = if count == 1- then ""- else "(" <> T.pack (show count) <> ") "- msg = if T.null (noteBody n)- then noteSummary n- else noteSummary n <> ": " <> noteBody n- in "<span fgcolor='yellow'>" <> prefix <> "</span>" <> msg+ prefix =+ if count == 1+ then ""+ else "(" <> T.pack (show count) <> ") "+ msg =+ if T.null (noteBody n)+ then noteSummary n+ else noteSummary n <> ": " <> noteBody n+ in "<span fgcolor='yellow'>" <> prefix <> "</span>" <> msg -- | The default formatter is one of -- * Summary : Body@@ -226,25 +261,32 @@ -- depending on the presence of a notification body, and where N is the number of queued notifications. defaultNotificationConfig :: NotificationConfig defaultNotificationConfig =- NotificationConfig { notificationMaxTimeout = Nothing- , notificationMaxLength = 100- , notificationFormatter = defaultFormatter- }+ NotificationConfig+ { notificationMaxTimeout = Nothing,+ notificationMaxLength = 100,+ notificationFormatter = defaultFormatter+ } instance Default NotificationConfig where def = defaultNotificationConfig -- | Create a new notification area with the given configuration.-notifyAreaNew :: MonadIO m => NotificationConfig -> m Widget+notifyAreaNew :: (MonadIO m) => NotificationConfig -> m Widget notifyAreaNew cfg = liftIO $ do frame <- frameNew Nothing+ _ <- widgetSetClassGI frame "notifications-frame" box <- boxNew OrientationHorizontal 3+ _ <- widgetSetClassGI box "notifications-box" textArea <- labelNew (Nothing :: Maybe Text)+ _ <- widgetSetClassGI textArea "notification-text" button <- eventBoxNew+ _ <- widgetSetClassGI button "notification-close" sep <- separatorNew OrientationHorizontal+ _ <- widgetSetClassGI sep "notification-separator" bLabel <- labelNew (Nothing :: Maybe Text) widgetSetName bLabel "NotificationCloseButton"+ _ <- widgetSetClassGI bLabel "notification-close-label" labelSetMarkup bLabel "×" labelSetMaxWidthChars textArea (fromIntegral $ notificationMaxLength cfg)@@ -264,6 +306,7 @@ _ <- onWidgetButtonReleaseEvent button (userCancel s) realizableWrapper <- boxNew OrientationHorizontal 0+ _ <- widgetSetClassGI realizableWrapper "notifications" boxPackStart realizableWrapper frame False False 0 widgetShow realizableWrapper @@ -277,9 +320,8 @@ -- Don't show the widget by default - it will appear when needed toWidget realizableWrapper- where- -- | Close the current note and pull up the next, if any+ -- \| Close the current note and pull up the next, if any userCancel s _ = do noteNext s wakeupDisplayThread s
src/System/Taffybar/Widget/Generic/AutoFillImage.hs view
@@ -1,46 +1,56 @@ {-# LANGUAGE OverloadedStrings #-}+ -- | A draw-based alternative to "System.Taffybar.Widget.Generic.AutoSizeImage" -- that scales a pixbuf to fit its allocated area while preserving aspect ratio, -- avoiding the resize feedback loops inherent in 'Gtk.Image'. module System.Taffybar.Widget.Generic.AutoFillImage- ( autoFillImage- , autoFillImageNew- , AutoFillCache(..)- , fitPixbufToBox- ) where+ ( autoFillImage,+ autoFillImageNew,+ AutoFillCache (..),+ fitPixbufToBox,+ )+where import qualified Control.Concurrent.MVar as MV-import Control.Monad-import Control.Monad.IO.Class-import Data.Int+import Control.Monad+import Control.Monad.IO.Class+import Data.Int import qualified GI.Cairo.Render as C-import GI.Cairo.Render.Connector+import GI.Cairo.Render.Connector import qualified GI.Gdk as Gdk import qualified GI.GdkPixbuf.Enums as GdkPixbuf-import GI.GdkPixbuf.Objects.Pixbuf as Gdk+import GI.GdkPixbuf.Objects.Pixbuf as Gdk import qualified GI.Gtk as Gtk-import System.Taffybar.Widget.Generic.AutoSizeImage-import System.Taffybar.Widget.Util+import System.Taffybar.Widget.Generic.AutoSizeImage+import System.Taffybar.Widget.Util +-- | Cached sizing/scaling state for an auto-fill image widget. data AutoFillCache = AutoFillCache- { afRequestSize :: Int32- , afScaleFactor :: Int32- , afInsets :: BorderInfo- , afContentWidth :: Int32- , afContentHeight :: Int32- , afSourcePixbuf :: Maybe Gdk.Pixbuf- , afScaledPixbuf :: Maybe Gdk.Pixbuf- , afOffsetX :: Double- , afOffsetY :: Double+ { afRequestSize :: Int32,+ afScaleFactor :: Int32,+ afInsets :: BorderInfo,+ afContentWidth :: Int32,+ afContentHeight :: Int32,+ afSourcePixbuf :: Maybe Gdk.Pixbuf,+ afScaledPixbuf :: Maybe Gdk.Pixbuf,+ afOffsetX :: Double,+ afOffsetY :: Double } -fitPixbufToBox- :: Int32 -- ^ scale factor- -> BorderInfo- -> Int32 -- ^ allocated width (logical px)- -> Int32 -- ^ allocated height (logical px)- -> Gdk.Pixbuf- -> IO (Int32, Int32, Double, Double, Maybe Gdk.Pixbuf)+-- | Scale and center a pixbuf inside the allocated widget content box.+--+-- Returns content width/height, draw offsets, and an optionally scaled pixbuf+-- (GDK may fail and return @Nothing@).+fitPixbufToBox ::+ -- | scale factor+ Int32 ->+ BorderInfo ->+ -- | allocated width (logical px)+ Int32 ->+ -- | allocated height (logical px)+ Int32 ->+ Gdk.Pixbuf ->+ IO (Int32, Int32, Double, Double, Maybe Gdk.Pixbuf) fitPixbufToBox scaleFactor insets allocW allocH pixbuf = do pbW' <- Gdk.getPixbufWidth pixbuf pbH' <- Gdk.getPixbufHeight pixbuf@@ -58,8 +68,8 @@ scale = if pbW <= 0 || pbH <= 0- then 1- else min (targetW / pbW) (targetH / pbH)+ then 1+ else min (targetW / pbW) (targetH / pbH) drawWDev = max 1 $ floor (pbW * scale) drawHDev = max 1 $ floor (pbH * scale)@@ -82,12 +92,12 @@ -- -- This uses a 'Gtk.DrawingArea' instead of 'Gtk.Image' because GTK3 does not -- support "scale-to-allocation" semantics for 'Gtk.Image'.-autoFillImage- :: MonadIO m- => Gtk.DrawingArea- -> (Int32 -> IO (Maybe Gdk.Pixbuf))- -> Gtk.Orientation- -> m (IO ())+autoFillImage ::+ (MonadIO m) =>+ Gtk.DrawingArea ->+ (Int32 -> IO (Maybe Gdk.Pixbuf)) ->+ Gtk.Orientation ->+ m (IO ()) autoFillImage drawArea getPixbuf orientation = liftIO $ do case orientation of Gtk.OrientationHorizontal -> Gtk.widgetSetVexpand drawArea True@@ -102,93 +112,98 @@ Gtk.widgetSetSizeRequest drawArea 16 16 -- Cache is only accessed from the GTK main loop via signal handlers.- cacheVar <- MV.newMVar AutoFillCache- { afRequestSize = 0- , afScaleFactor = 1- , afInsets = borderInfoZero- , afContentWidth = 1- , afContentHeight = 1- , afSourcePixbuf = Nothing- , afScaledPixbuf = Nothing- , afOffsetX = 0- , afOffsetY = 0- }+ cacheVar <-+ MV.newMVar+ AutoFillCache+ { afRequestSize = 0,+ afScaleFactor = 1,+ afInsets = borderInfoZero,+ afContentWidth = 1,+ afContentHeight = 1,+ afSourcePixbuf = Nothing,+ afScaledPixbuf = Nothing,+ afOffsetX = 0,+ afOffsetY = 0+ } - let- recompute force = do- allocation <- Gtk.widgetGetAllocation drawArea- allocW <- Gdk.getRectangleWidth allocation- allocH <- Gdk.getRectangleHeight allocation+ let recompute force = do+ allocation <- Gtk.widgetGetAllocation drawArea+ allocW <- Gdk.getRectangleWidth allocation+ allocH <- Gdk.getRectangleHeight allocation - -- CSS can change dynamically (taffybar supports live CSS reload), so we- -- recompute insets every time we recompute sizing.- insets <- getInsetInfo drawArea- scaleFactor <- Gtk.widgetGetScaleFactor drawArea+ -- CSS can change dynamically (taffybar supports live CSS reload), so we+ -- recompute insets every time we recompute sizing.+ insets <- getInsetInfo drawArea+ scaleFactor <- Gtk.widgetGetScaleFactor drawArea - let contentW = max 1 $ allocW - fromIntegral (borderWidth insets)- contentH = max 1 $ allocH - fromIntegral (borderHeight insets)- requestSize =- case orientation of- Gtk.OrientationHorizontal -> contentH- _ -> contentW+ let contentW = max 1 $ allocW - fromIntegral (borderWidth insets)+ contentH = max 1 $ allocH - fromIntegral (borderHeight insets)+ requestSize =+ case orientation of+ Gtk.OrientationHorizontal -> contentH+ _ -> contentW - -- Update the widget's natural size so it won't collapse to 0 when packed- -- without expand.- Gtk.widgetSetSizeRequest drawArea- (fromIntegral requestSize + fromIntegral (borderWidth insets))- (fromIntegral requestSize + fromIntegral (borderHeight insets))+ -- Update the widget's natural size so it won't collapse to 0 when packed+ -- without expand.+ Gtk.widgetSetSizeRequest+ drawArea+ (fromIntegral requestSize + fromIntegral (borderWidth insets))+ (fromIntegral requestSize + fromIntegral (borderHeight insets)) - old <- MV.readMVar cacheVar- srcFresh <-- if force || requestSize /= afRequestSize old- then getPixbuf requestSize- else pure Nothing+ old <- MV.readMVar cacheVar+ srcFresh <-+ if force || requestSize /= afRequestSize old+ then getPixbuf requestSize+ else pure Nothing - -- If the getter fails transiently, keep drawing the last known pixbuf.- let src =- case srcFresh of- Just pb -> Just pb- Nothing -> afSourcePixbuf old+ -- If the getter fails transiently, keep drawing the last known pixbuf.+ let src =+ case srcFresh of+ Just pb -> Just pb+ Nothing -> afSourcePixbuf old - let needsRefit =- force- || requestSize /= afRequestSize old- || scaleFactor /= afScaleFactor old- || insets /= afInsets old- || contentW /= afContentWidth old- || contentH /= afContentHeight old+ let needsRefit =+ force+ || requestSize /= afRequestSize old+ || scaleFactor /= afScaleFactor old+ || insets /= afInsets old+ || contentW /= afContentWidth old+ || contentH /= afContentHeight old - when needsRefit $ do- newCache <-- case src of- Nothing -> pure old- { afRequestSize = requestSize- , afScaleFactor = scaleFactor- , afInsets = insets- , afContentWidth = contentW- , afContentHeight = contentH- , afSourcePixbuf = Nothing- , afScaledPixbuf = Nothing- , afOffsetX = 0- , afOffsetY = 0- }- Just pb -> do- (cw, ch, ox, oy, scaledM) <-- fitPixbufToBox (max 1 scaleFactor) insets allocW allocH pb- pure old- { afRequestSize = requestSize- , afScaleFactor = max 1 scaleFactor- , afInsets = insets- , afContentWidth = cw- , afContentHeight = ch- , afSourcePixbuf = Just pb- , afScaledPixbuf = scaledM- , afOffsetX = ox- , afOffsetY = oy- }+ when needsRefit $ do+ newCache <-+ case src of+ Nothing ->+ pure+ old+ { afRequestSize = requestSize,+ afScaleFactor = scaleFactor,+ afInsets = insets,+ afContentWidth = contentW,+ afContentHeight = contentH,+ afSourcePixbuf = Nothing,+ afScaledPixbuf = Nothing,+ afOffsetX = 0,+ afOffsetY = 0+ }+ Just pb -> do+ (cw, ch, ox, oy, scaledM) <-+ fitPixbufToBox (max 1 scaleFactor) insets allocW allocH pb+ pure+ old+ { afRequestSize = requestSize,+ afScaleFactor = max 1 scaleFactor,+ afInsets = insets,+ afContentWidth = cw,+ afContentHeight = ch,+ afSourcePixbuf = Just pb,+ afScaledPixbuf = scaledM,+ afOffsetX = ox,+ afOffsetY = oy+ } - void $ MV.swapMVar cacheVar newCache- Gtk.widgetQueueDraw drawArea+ void $ MV.swapMVar cacheVar newCache+ Gtk.widgetQueueDraw drawArea -- Redraw when GTK allocates or when style changes. void $ Gtk.onWidgetSizeAllocate drawArea $ \_ -> recompute False@@ -206,11 +221,11 @@ pure $ recompute True -- | Convenience constructor for 'autoFillImage'.-autoFillImageNew- :: MonadIO m- => (Int32 -> IO (Maybe Gdk.Pixbuf))- -> Gtk.Orientation- -> m Gtk.DrawingArea+autoFillImageNew ::+ (MonadIO m) =>+ (Int32 -> IO (Maybe Gdk.Pixbuf)) ->+ Gtk.Orientation ->+ m Gtk.DrawingArea autoFillImageNew getPixBuf orientation = do drawArea <- Gtk.drawingAreaNew void $ autoFillImage drawArea getPixBuf orientation
src/System/Taffybar/Widget/Generic/AutoSizeImage.hs view
@@ -1,38 +1,43 @@ {-# LANGUAGE OverloadedStrings #-}++-- | Auto-scaling image helpers that resize pixbufs to widget allocation. module System.Taffybar.Widget.Generic.AutoSizeImage- ( autoSizeImage- , autoSizeImageNew- , imageMenuItemNew- , ImageScaleStrategy(..)- , BorderInfo(..)- , borderInfoZero- , borderWidth- , borderHeight- , getBorderInfo- , getInsetInfo- , getContentAllocation- ) where+ ( autoSizeImage,+ autoSizeImageNew,+ imageMenuItemNew,+ ImageScaleStrategy (..),+ BorderInfo (..),+ borderInfoZero,+ borderWidth,+ borderHeight,+ getBorderInfo,+ getInsetInfo,+ getContentAllocation,+ )+where import qualified Control.Concurrent.MVar as MV-import Control.Monad-import Control.Monad.IO.Class-import Data.Default (Default(..))-import Data.Int-import Data.Maybe+import Control.Monad+import Control.Monad.IO.Class+import Data.Default (Default (..))+import Data.Int+import Data.Maybe import qualified Data.Text as T import qualified GI.Gdk as Gdk-import GI.GdkPixbuf.Objects.Pixbuf as Gdk+import GI.GdkPixbuf.Objects.Pixbuf as Gdk import qualified GI.Gtk as Gtk-import StatusNotifier.Tray (scalePixbufToSize)-import System.Log.Logger-import System.Taffybar.Util-import System.Taffybar.Widget.Util-import Text.Printf+import StatusNotifier.Tray (scalePixbufToSize)+import System.Log.Logger+import System.Taffybar.Util+import System.Taffybar.Widget.Util+import Text.Printf -- | Strategy for how auto-scaling image widgets render their pixbufs. data ImageScaleStrategy- = ImageResize -- ^ Use 'Gtk.Image' with 'imageSetFromPixbuf' + 'queueResize' (original behavior).- | ImageDraw -- ^ Use 'Gtk.DrawingArea' with Cairo rendering (avoids resize feedback loops).+ = -- | Use 'Gtk.Image' with 'imageSetFromPixbuf' + 'queueResize' (original behavior).+ ImageResize+ | -- | Use 'Gtk.DrawingArea' with Cairo rendering (avoids resize feedback loops).+ ImageDraw deriving (Eq, Show) instance Default ImageScaleStrategy where@@ -43,46 +48,54 @@ borderFunctions :: [Gtk.StyleContext -> [Gtk.StateFlags] -> IO Gtk.Border] borderFunctions =- [ Gtk.styleContextGetPadding- , Gtk.styleContextGetMargin- , Gtk.styleContextGetBorder+ [ Gtk.styleContextGetPadding,+ Gtk.styleContextGetMargin,+ Gtk.styleContextGetBorder ] -- Insets that are inside a widget's allocation and should be respected when -- drawing inside it. insetFunctions :: [Gtk.StyleContext -> [Gtk.StateFlags] -> IO Gtk.Border] insetFunctions =- [ Gtk.styleContextGetPadding- , Gtk.styleContextGetBorder+ [ Gtk.styleContextGetPadding,+ Gtk.styleContextGetBorder ] +-- | Aggregate border/padding/margin dimensions for a widget. data BorderInfo = BorderInfo- { borderTop :: Int16- , borderBottom :: Int16- , borderLeft :: Int16- , borderRight :: Int16- } deriving (Show, Eq)+ { borderTop :: Int16,+ borderBottom :: Int16,+ borderLeft :: Int16,+ borderRight :: Int16+ }+ deriving (Show, Eq) +-- | Zero-valued 'BorderInfo'. borderInfoZero :: BorderInfo borderInfoZero = BorderInfo 0 0 0 0 +-- | Total horizontal border extent.+-- This includes left + right values. borderWidth, borderHeight :: BorderInfo -> Int16 borderWidth borderInfo = borderLeft borderInfo + borderRight borderInfo++-- | Total vertical border extent.+-- This includes top + bottom values. borderHeight borderInfo = borderTop borderInfo + borderBottom borderInfo toBorderInfo :: (MonadIO m) => Gtk.Border -> m BorderInfo toBorderInfo border = BorderInfo- <$> Gtk.getBorderTop border- <*> Gtk.getBorderBottom border- <*> Gtk.getBorderLeft border- <*> Gtk.getBorderRight border+ <$> Gtk.getBorderTop border+ <*> Gtk.getBorderBottom border+ <*> Gtk.getBorderLeft border+ <*> Gtk.getBorderRight border addBorderInfo :: BorderInfo -> BorderInfo -> BorderInfo addBorderInfo (BorderInfo t1 b1 l1 r1)- (BorderInfo t2 b2 l2 r2)- = BorderInfo (t1 + t2) (b1 + b2) (l1 + l2) (r1 + r2)+ (BorderInfo t2 b2 l2 r2) =+ BorderInfo (t1 + t2) (b1 + b2) (l1 + l2) (r1 + r2) -- | Get the total size of the border (the sum of its assigned margin, border -- and padding values) that will be drawn for a widget as a "BorderInfo" record.@@ -113,9 +126,9 @@ -- | Get the actual allocation for a "Gtk.Widget", accounting for the size of -- its CSS assined margin, border and padding values.-getContentAllocation- :: (MonadIO m, Gtk.IsWidget a)- => a -> BorderInfo -> m Gdk.Rectangle+getContentAllocation ::+ (MonadIO m, Gtk.IsWidget a) =>+ a -> BorderInfo -> m Gdk.Rectangle getContentAllocation widget borderInfo = do allocation <- Gtk.widgetGetAllocation widget currentWidth <- Gdk.getRectangleWidth allocation@@ -123,26 +136,28 @@ currentX <- Gdk.getRectangleX allocation currentY <- Gdk.getRectangleX allocation - Gdk.setRectangleWidth allocation $ max 1 $- currentWidth - fromIntegral (borderWidth borderInfo)- Gdk.setRectangleHeight allocation $ max 1 $- currentHeight - fromIntegral (borderHeight borderInfo)+ Gdk.setRectangleWidth allocation $+ max 1 $+ currentWidth - fromIntegral (borderWidth borderInfo)+ Gdk.setRectangleHeight allocation $+ max 1 $+ currentHeight - fromIntegral (borderHeight borderInfo) Gdk.setRectangleX allocation $- currentX + fromIntegral (borderLeft borderInfo)+ currentX + fromIntegral (borderLeft borderInfo) Gdk.setRectangleY allocation $- currentY + fromIntegral (borderTop borderInfo)+ currentY + fromIntegral (borderTop borderInfo) return allocation -- | Automatically update the "Gdk.Pixbuf" of a "Gtk.Image" using the provided -- action whenever the "Gtk.Image" is allocated. Returns an action that forces a -- refresh of the image through the provided action.-autoSizeImage- :: MonadIO m- => Gtk.Image- -> (Int32 -> IO (Maybe Gdk.Pixbuf))- -> Gtk.Orientation- -> m (IO ())+autoSizeImage ::+ (MonadIO m) =>+ Gtk.Image ->+ (Int32 -> IO (Maybe Gdk.Pixbuf)) ->+ Gtk.Orientation ->+ m (IO ()) autoSizeImage image getPixbuf orientation = liftIO $ do case orientation of Gtk.OrientationHorizontal -> Gtk.widgetSetVexpand image True@@ -177,20 +192,21 @@ pbWidth <- fromMaybe 0 <$> traverse Gdk.getPixbufWidth pixbuf pbHeight <- fromMaybe 0 <$> traverse Gdk.getPixbufHeight pixbuf let pbSize = case orientation of- Gtk.OrientationHorizontal -> pbHeight- _ -> pbWidth+ Gtk.OrientationHorizontal -> pbHeight+ _ -> pbWidth logLevel = if pbSize <= size then DEBUG else WARNING imageLog logLevel $- printf "Allocating image: size %s, width %s, \- \ height %s, aw: %s, ah: %s, pbw: %s pbh: %s"- (show size)- (show width)- (show height)- (show _width)- (show _height)- (show pbWidth)- (show pbHeight)+ printf+ "Allocating image: size %s, width %s, \+ \ height %s, aw: %s, ah: %s, pbw: %s pbh: %s"+ (show size)+ (show width)+ (show height)+ (show _width)+ (show _height)+ (show pbWidth)+ (show pbHeight) Gtk.imageSetFromPixbuf image pixbuf postGUIASync $ Gtk.widgetQueueResize image@@ -201,20 +217,22 @@ -- | Make a new "Gtk.Image" and call "autoSizeImage" on it. Automatically scale -- the "Gdk.Pixbuf" returned from the provided getter to the appropriate size -- using "scalePixbufToSize".-autoSizeImageNew- :: MonadIO m- => (Int32 -> IO Gdk.Pixbuf) -> Gtk.Orientation -> m Gtk.Image+autoSizeImageNew ::+ (MonadIO m) =>+ (Int32 -> IO Gdk.Pixbuf) -> Gtk.Orientation -> m Gtk.Image autoSizeImageNew getPixBuf orientation = do image <- Gtk.imageNew- void $ autoSizeImage image- (\size -> Just <$> (getPixBuf size >>= scalePixbufToSize size orientation))- orientation+ void $+ autoSizeImage+ image+ (\size -> Just <$> (getPixBuf size >>= scalePixbufToSize size orientation))+ orientation return image -- | Make a new "Gtk.MenuItem" that has both a label and an icon.-imageMenuItemNew- :: MonadIO m- => T.Text -> (Int32 -> IO (Maybe Gdk.Pixbuf)) -> m Gtk.MenuItem+imageMenuItemNew ::+ (MonadIO m) =>+ T.Text -> (Int32 -> IO (Maybe Gdk.Pixbuf)) -> m Gtk.MenuItem imageMenuItemNew labelText pixbufGetter = do box <- Gtk.boxNew Gtk.OrientationHorizontal 0 label <- Gtk.labelNew $ Just labelText
src/System/Taffybar/Widget/Generic/ChannelGraph.hs view
@@ -1,3 +1,4 @@+-- | Graph widgets driven by samples arriving over a broadcast channel. module System.Taffybar.Widget.Generic.ChannelGraph where import Control.Concurrent@@ -11,15 +12,17 @@ -- | Given a broadcast 'TChan' and an action to consume that broadcast chan and -- turn it into graphable values, build a graph that will update as values are -- broadcast over the channel.-channelGraphNew- :: MonadIO m- => GraphConfig -> TChan a -> (a -> IO [Double]) -> m GI.Gtk.Widget+channelGraphNew ::+ (MonadIO m) =>+ GraphConfig -> TChan a -> (a -> IO [Double]) -> m GI.Gtk.Widget channelGraphNew config chan sampleBuilder = do (graphWidget, graphHandle) <- graphNew config _ <- onWidgetRealize graphWidget $ do- ourChan <- atomically $ dupTChan chan- sampleThread <- forkIO $ forever $- atomically (readTChan ourChan) >>=- (graphAddSample graphHandle <=< sampleBuilder)- void $ onWidgetUnrealize graphWidget $ killThread sampleThread+ ourChan <- atomically $ dupTChan chan+ sampleThread <-+ forkIO $+ forever $+ atomically (readTChan ourChan)+ >>= (graphAddSample graphHandle <=< sampleBuilder)+ void $ onWidgetUnrealize graphWidget $ killThread sampleThread return graphWidget
src/System/Taffybar/Widget/Generic/ChannelWidget.hs view
@@ -1,3 +1,4 @@+-- | Connect widget updates to a broadcast 'TChan' on a dedicated worker thread. module System.Taffybar.Widget.Generic.ChannelWidget where import Control.Concurrent@@ -16,8 +17,10 @@ channelWidgetNew widget channel updateWidget = do void $ onWidgetRealize widget $ do ourChan <- atomically $ dupTChan channel- processingThreadId <- forkIO $ forever $- atomically (readTChan ourChan) >>= updateWidget+ processingThreadId <-+ forkIO $+ forever $+ atomically (readTChan ourChan) >>= updateWidget void $ onWidgetUnrealize widget $ killThread processingThreadId widgetShowAll widget return widget
src/System/Taffybar/Widget/Generic/DynamicMenu.hs view
@@ -1,29 +1,35 @@+-- | Build menu buttons whose menu contents are rebuilt each time they are+-- opened. module System.Taffybar.Widget.Generic.DynamicMenu where -import Control.Monad.IO.Class+import Control.Monad.IO.Class import qualified GI.Gtk as Gtk +-- | Configuration for a dynamic menu button. data DynamicMenuConfig = DynamicMenuConfig- { dmClickWidget :: Gtk.Widget- , dmPopulateMenu :: Gtk.Menu -> IO ()+ { dmClickWidget :: Gtk.Widget,+ dmPopulateMenu :: Gtk.Menu -> IO () } -dynamicMenuNew :: MonadIO m => DynamicMenuConfig -> m Gtk.Widget-dynamicMenuNew DynamicMenuConfig- { dmClickWidget = clickWidget- , dmPopulateMenu = populateMenu- } = do- button <- Gtk.menuButtonNew- menu <- Gtk.menuNew- Gtk.containerAdd button clickWidget- Gtk.menuButtonSetPopup button $ Just menu+-- | Wrap a widget in a menu button and repopulate the attached menu on click.+dynamicMenuNew :: (MonadIO m) => DynamicMenuConfig -> m Gtk.Widget+dynamicMenuNew+ DynamicMenuConfig+ { dmClickWidget = clickWidget,+ dmPopulateMenu = populateMenu+ } = do+ button <- Gtk.menuButtonNew+ menu <- Gtk.menuNew+ Gtk.containerAdd button clickWidget+ Gtk.menuButtonSetPopup button $ Just menu - _ <- Gtk.onButtonPressed button $ emptyMenu menu >> populateMenu menu+ _ <- Gtk.onButtonPressed button $ emptyMenu menu >> populateMenu menu - Gtk.widgetShowAll button+ Gtk.widgetShowAll button - Gtk.toWidget button+ Gtk.toWidget button +-- | Remove and destroy all current menu items. emptyMenu :: (Gtk.IsContainer a, MonadIO m) => a -> m () emptyMenu menu = Gtk.containerForeach menu $ \item ->
src/System/Taffybar/Widget/Generic/Graph.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE ScopedTypeVariables #-}+ -- | This is a graph widget inspired by the widget of the same name in Awesome -- (the window manager). It plots a series of data points similarly to a bar -- graph. This version must be explicitly fed data with 'graphAddSample'. For a@@ -8,90 +9,105 @@ -- sets are plotted in the order provided by the caller. -- -- Note: all of the data fed to this widget should be in the range [0,1].-module System.Taffybar.Widget.Generic.Graph (- -- * Types- GraphHandle- , GraphConfig(..)- , GraphDirection(..)- , GraphStyle(..)- -- * Functions- , graphNew- , graphAddSample- , defaultGraphConfig- ) where+module System.Taffybar.Widget.Generic.Graph+ ( -- * Types+ GraphHandle,+ GraphConfig (..),+ GraphDirection (..),+ GraphStyle (..), -import Control.Concurrent-import Control.Monad ( when )-import Control.Monad.IO.Class-import Data.Default ( Default(..) )-import Data.Sequence ( Seq(..), (<|), viewl, ViewL(..) )+ -- * Functions+ graphNew,+ graphAddSample,+ defaultGraphConfig,+ )+where++import Control.Concurrent+import Control.Exception (bracket_)+import Control.Monad (forM, when)+import Control.Monad.IO.Class+import Data.Default (Default (..))+import Data.Sequence (Seq (..), ViewL (..), viewl, (<|)) import qualified Data.Sequence as S import qualified Data.Text as T import qualified GI.Cairo.Render as C-import GI.Cairo.Render.Connector+import GI.Cairo.Render.Connector import qualified GI.Cairo.Render.Matrix as M+import qualified GI.Gdk.Structs.RGBA as GdkRGBA import qualified GI.Gtk as Gtk-import System.Taffybar.Util-import System.Taffybar.Widget.Util+import System.Taffybar.Util+import System.Taffybar.Widget.Util +-- | Handle used to add samples to a graph widget. newtype GraphHandle = GH (MVar GraphState)-data GraphState =- GraphState { graphIsBootstrapped :: Bool- , graphHistory :: [Seq Double]- , graphCanvas :: Gtk.DrawingArea- , graphConfig :: GraphConfig- } +data GraphState+ = GraphState+ { graphIsBootstrapped :: Bool,+ graphHistory :: [Seq Double],+ graphCanvas :: Gtk.DrawingArea,+ graphConfig :: GraphConfig+ }++-- | Direction in which historical samples flow across the widget. data GraphDirection = LEFT_TO_RIGHT | RIGHT_TO_LEFT deriving (Eq) -- 'RGBA' represents a color with a transparency.-type RGBA = (Double, Double, Double, Double)+type GraphColor = (Double, Double, Double, Double) -- | The style of the graph. Generally, you will want to draw all 'Area' graphs -- first, and then all 'Line' graphs. data GraphStyle- = Area -- ^ Thea area below the value is filled- | Line -- ^ The values are connected by a line (one pixel wide)+ = -- | Thea area below the value is filled+ Area+ | -- | The values are connected by a line (one pixel wide)+ Line -- | The configuration options for the graph. The padding is the number of -- pixels reserved as blank space around the widget in each direction.-data GraphConfig = GraphConfig {- -- | Number of pixels of padding on each side of the graph widget- graphPadding :: Int- -- | The background color of the graph (default black)- , graphBackgroundColor :: RGBA- -- | The border color drawn around the graph (default gray)- , graphBorderColor :: RGBA- -- | The width of the border (default 1, use 0 to disable the border)- , graphBorderWidth :: Int- -- | Colors for each data set (default cycles between red, green and blue)- , graphDataColors :: [RGBA]- -- | How to draw each data point (default @repeat Area@)- , graphDataStyles :: [GraphStyle]- -- | The number of data points to retain for each data set (default 20)- , graphHistorySize :: Int- -- | May contain Pango markup (default @Nothing@)- , graphLabel :: Maybe T.Text- -- | The width (in pixels) of the graph widget (default 50)- , graphWidth :: Int- -- | The direction in which the graph will move as time passes (default LEFT_TO_RIGHT)- , graphDirection :: GraphDirection+data GraphConfig = GraphConfig+ { -- | Number of pixels of padding on each side of the graph widget+ graphPadding :: Int,+ -- | The background color of the graph (default black)+ graphBackgroundColor :: GraphColor,+ -- | The border color drawn around the graph (default gray)+ graphBorderColor :: GraphColor,+ -- | The width of the border (default 1, use 0 to disable the border)+ graphBorderWidth :: Int,+ -- | Colors for each data set (default cycles between red, green and blue)+ graphDataColors :: [GraphColor],+ -- | Draw graph colors from CSS classes. When enabled, each dataset color is+ -- read from @color@ using @graph-data-N@ classes on the drawing area.+ graphColorsFromCss :: Bool,+ -- | How to draw each data point (default @repeat Area@)+ graphDataStyles :: [GraphStyle],+ -- | The number of data points to retain for each data set (default 20)+ graphHistorySize :: Int,+ -- | May contain Pango markup (default @Nothing@)+ graphLabel :: Maybe T.Text,+ -- | The width (in pixels) of the graph widget (default 50)+ graphWidth :: Int,+ -- | The direction in which the graph will move as time passes (default LEFT_TO_RIGHT)+ graphDirection :: GraphDirection } +-- | Default graph configuration. defaultGraphConfig :: GraphConfig defaultGraphConfig = GraphConfig- { graphPadding = 2- , graphBackgroundColor = (0.0, 0.0, 0.0, 1.0)- , graphBorderColor = (0.5, 0.5, 0.5, 1.0)- , graphBorderWidth = 1- , graphDataColors = cycle [(1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0)]- , graphDataStyles = repeat Area- , graphHistorySize = 20- , graphLabel = Nothing- , graphWidth = 50- , graphDirection = LEFT_TO_RIGHT- }+ { graphPadding = 2,+ graphBackgroundColor = (0.0, 0.0, 0.0, 1.0),+ graphBorderColor = (0.5, 0.5, 0.5, 1.0),+ graphBorderWidth = 1,+ graphDataColors = cycle [(1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0)],+ graphColorsFromCss = False,+ graphDataStyles = repeat Area,+ graphHistorySize = 20,+ graphLabel = Nothing,+ graphWidth = 50,+ graphDirection = LEFT_TO_RIGHT+ } instance Default GraphConfig where def = defaultGraphConfig@@ -106,9 +122,9 @@ histsAndNewVals = zip pcts (graphHistory s) newHists = case graphHistory s of [] -> map S.singleton pcts- _ -> map (\(p,h) -> S.take histSize $ p <| h) histsAndNewVals+ _ -> map (\(p, h) -> S.take histSize $ p <| h) histsAndNewVals when (graphIsBootstrapped s) $ do- modifyMVar_ mv (\s' -> return s' { graphHistory = newHists })+ modifyMVar_ mv (\s' -> return s' {graphHistory = newHists}) postGUIASync $ Gtk.widgetQueueDraw drawArea where pcts = map (clamp 0 1) rawData@@ -118,37 +134,73 @@ outlineData :: (Double -> Double) -> Double -> Double -> C.Render () outlineData pctToY xStep pct = do- (curX,_) <- C.getCurrentPoint+ (curX, _) <- C.getCurrentPoint C.lineTo (curX + xStep) (pctToY pct) -renderFrameAndBackground :: GraphConfig -> Int -> Int -> C.Render ()-renderFrameAndBackground cfg w h = do- let (backR, backG, backB, backA) = graphBackgroundColor cfg- (frameR, frameG, frameB, frameA) = graphBorderColor cfg- pad = graphPadding cfg+normalStateFlags :: [Gtk.StateFlags]+normalStateFlags = [Gtk.StateFlagsNormal]++rgbaToGraphColor :: GdkRGBA.RGBA -> IO GraphColor+rgbaToGraphColor rgba = do+ r <- GdkRGBA.getRGBARed rgba+ g <- GdkRGBA.getRGBAGreen rgba+ b <- GdkRGBA.getRGBABlue rgba+ a <- GdkRGBA.getRGBAAlpha rgba+ return (r, g, b, a)++withStyleContextSave :: Gtk.StyleContext -> IO a -> IO a+withStyleContextSave styleContext =+ bracket_+ (Gtk.styleContextSave styleContext)+ (Gtk.styleContextRestore styleContext)++getDataColorFromCss :: Gtk.StyleContext -> Int -> IO GraphColor+getDataColorFromCss styleContext index =+ withStyleContextSave styleContext $ do+ Gtk.styleContextAddClass styleContext (T.pack "graph-data")+ Gtk.styleContextAddClass styleContext (T.pack $ "graph-data-" <> show index)+ Gtk.styleContextGetColor styleContext normalStateFlags >>= rgbaToGraphColor++renderFrameAndBackground :: GraphConfig -> Maybe Gtk.StyleContext -> Int -> Int -> C.Render ()+renderFrameAndBackground cfg styleContext w h = do+ let pad = graphPadding cfg fpad = fromIntegral pad fw = fromIntegral w fh = fromIntegral h-- -- Draw the requested background- C.setSourceRGBA backR backG backB backA- C.rectangle fpad fpad (fw - 2 * fpad) (fh - 2 * fpad)- C.fill-- -- Draw a frame around the widget area (unless equal to background color,- -- which likely means the user does not want a frame)- when (graphBorderWidth cfg > 0) $ do- let p = fromIntegral (graphBorderWidth cfg)- C.setLineWidth p- C.setSourceRGBA frameR frameG frameB frameA- C.rectangle (fpad + (p / 2)) (fpad + (p / 2))- (fw - 2 * fpad - p) (fh - 2 * fpad - p)- C.stroke+ frameX = fpad+ frameY = fpad+ frameWidth = fw - 2 * fpad+ frameHeight = fh - 2 * fpad+ case (graphColorsFromCss cfg, styleContext) of+ (True, Just context) ->+ toRender $ \cairoCtx -> do+ Gtk.renderBackground context cairoCtx frameX frameY frameWidth frameHeight+ when (graphBorderWidth cfg > 0) $+ Gtk.renderFrame context cairoCtx frameX frameY frameWidth frameHeight+ _ -> do+ let (backR, backG, backB, backA) = graphBackgroundColor cfg+ (frameR, frameG, frameB, frameA) = graphBorderColor cfg+ -- Draw the requested background+ C.setSourceRGBA backR backG backB backA+ C.rectangle frameX frameY frameWidth frameHeight+ C.fill + -- Draw a frame around the widget area (unless equal to background color,+ -- which likely means the user does not want a frame)+ when (graphBorderWidth cfg > 0) $ do+ let p = fromIntegral (graphBorderWidth cfg)+ C.setLineWidth p+ C.setSourceRGBA frameR frameG frameB frameA+ C.rectangle+ (fpad + (p / 2))+ (fpad + (p / 2))+ (fw - 2 * fpad - p)+ (fh - 2 * fpad - p)+ C.stroke -renderGraph :: [Seq Double] -> GraphConfig -> Int -> Int -> Double -> C.Render ()-renderGraph hists cfg w h xStep = do- renderFrameAndBackground cfg w h+renderGraph :: [Seq Double] -> [GraphColor] -> GraphConfig -> Maybe Gtk.StyleContext -> Int -> Int -> Double -> C.Render ()+renderGraph hists dataColors cfg styleContext w h xStep = do+ renderFrameAndBackground cfg styleContext w h C.setLineWidth 0.1 @@ -167,12 +219,13 @@ -- transformation with an offset to the right equal to the width of the -- widget. when (graphDirection cfg == RIGHT_TO_LEFT) $- C.transform $ M.Matrix (-1) 0 0 1 (fromIntegral w) 0+ C.transform $+ M.Matrix (-1) 0 0 1 (fromIntegral w) 0 let pctToY pct = fromIntegral h * (1 - pct) renderDataSet hist color style = case viewl hist of- EmptyL -> return ()- _oneSample :< Empty -> return ()+ EmptyL -> return ()+ _oneSample :< Empty -> return () newestSample :< hist' -> do let (r, g, b, a) = color originY = pctToY newestSample@@ -191,20 +244,24 @@ C.setLineWidth 1.0 C.stroke -- sequence_ $ zipWith3 renderDataSet hists (graphDataColors cfg)- (graphDataStyles cfg)+ sequence_ $+ zipWith3+ renderDataSet+ hists+ dataColors+ (graphDataStyles cfg) drawBorder :: MVar GraphState -> Gtk.DrawingArea -> C.Render () drawBorder mv drawArea = do (w, h) <- widgetGetAllocatedSize drawArea s <- liftIO $ readMVar mv let cfg = graphConfig s- renderFrameAndBackground cfg w h- liftIO $ modifyMVar_ mv (\s' -> return s' { graphIsBootstrapped = True })+ styleContext <- C.liftIO $ Gtk.widgetGetStyleContext drawArea+ renderFrameAndBackground cfg (Just styleContext) w h+ liftIO $ modifyMVar_ mv (\s' -> return s' {graphIsBootstrapped = True}) return () -drawGraph :: MVar GraphState -> Gtk.DrawingArea -> C.Render ()+drawGraph :: MVar GraphState -> Gtk.DrawingArea -> C.Render () drawGraph mv drawArea = do (w, h) <- widgetGetAllocatedSize drawArea drawBorder mv drawArea@@ -212,35 +269,50 @@ let hist = graphHistory s cfg = graphConfig s histSize = graphHistorySize cfg- -- Subtract 1 here since the first data point doesn't require+ styleContext <- C.liftIO $ Gtk.widgetGetStyleContext drawArea+ dataColors <-+ if graphColorsFromCss cfg+ then C.liftIO $ forM [1 .. length hist] (getDataColorFromCss styleContext)+ else return $ graphDataColors cfg+ let -- Subtract 1 here since the first data point doesn't require -- any movement in the X direction xStep = fromIntegral w / fromIntegral (histSize - 1) case hist of- [] -> renderFrameAndBackground cfg w h- _ -> renderGraph hist cfg w h xStep+ [] -> renderFrameAndBackground cfg (Just styleContext) w h+ _ -> renderGraph hist dataColors cfg (Just styleContext) w h xStep -graphNew :: MonadIO m => GraphConfig -> m (Gtk.Widget, GraphHandle)+-- | Construct a new graph widget and its mutable handle.+graphNew :: (MonadIO m) => GraphConfig -> m (Gtk.Widget, GraphHandle) graphNew cfg = liftIO $ do drawArea <- Gtk.drawingAreaNew- mv <- newMVar GraphState { graphIsBootstrapped = False- , graphHistory = []- , graphCanvas = drawArea- , graphConfig = cfg- }+ _ <- widgetSetClassGI drawArea (T.pack "graph-canvas")+ Gtk.widgetGetStyleContext drawArea >>= \styleContext ->+ Gtk.styleContextAddClass styleContext (T.pack "graph")+ mv <-+ newMVar+ GraphState+ { graphIsBootstrapped = False,+ graphHistory = [],+ graphCanvas = drawArea,+ graphConfig = cfg+ } Gtk.widgetSetSizeRequest drawArea (fromIntegral $ graphWidth cfg) (-1)- _ <- Gtk.onWidgetDraw drawArea $ \ctx -> renderWithContext- (drawGraph mv drawArea) ctx >> return True+ _ <- Gtk.onWidgetDraw drawArea $ \ctx ->+ renderWithContext+ (drawGraph mv drawArea)+ ctx+ >> return True box <- Gtk.boxNew Gtk.OrientationHorizontal 1-+ _ <- widgetSetClassGI box (T.pack "graph") Gtk.widgetSetVexpand drawArea True Gtk.widgetSetVexpand box True Gtk.boxPackStart box drawArea True True 0 widget <- case graphLabel cfg of- Nothing -> Gtk.toWidget box+ Nothing -> Gtk.toWidget box Just labelText -> do overlay <- Gtk.overlayNew label <- Gtk.labelNew Nothing
src/System/Taffybar/Widget/Generic/Icon.hs view
@@ -1,26 +1,30 @@ -- | This is a simple static image widget, and a polling image widget that -- updates its contents by calling a callback at a set interval. module System.Taffybar.Widget.Generic.Icon- ( iconImageWidgetNew- , iconImageWidgetNewFromName- , pollingIconImageWidgetNew- , pollingIconImageWidgetNewFromName- ) where+ ( iconImageWidgetNew,+ iconImageWidgetNewFromName,+ pollingIconImageWidgetNew,+ pollingIconImageWidgetNewFromName,+ )+where -import Control.Concurrent ( forkIO, threadDelay )-import qualified Data.Text as T+import Control.Concurrent (killThread) import Control.Exception as E-import Control.Monad ( forever, void )-import Control.Monad.IO.Class+import Control.Monad (void)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Control.Monad.Trans.Reader (ask, runReaderT)+import Data.GI.Gtk.Threading (postGUIASync)+import qualified Data.Text as T import GI.Gtk-import System.Taffybar.Util+import System.Taffybar.Context (TaffyIO)+import System.Taffybar.Information.Wakeup (taffyForeverWithDelay) -- | Create a new widget that displays a static image -- -- > iconImageWidgetNew path -- -- returns a widget with icon at @path@.-iconImageWidgetNew :: MonadIO m => FilePath -> m Widget+iconImageWidgetNew :: (MonadIO m) => FilePath -> m Widget iconImageWidgetNew path = liftIO $ imageNewFromFile path >>= putInBox -- | Create a new widget that displays a static image@@ -29,10 +33,11 @@ -- -- returns a widget with the icon named @name@. Icon -- names are sourced from the current GTK theme.-iconImageWidgetNewFromName :: MonadIO m => T.Text -> m Widget-iconImageWidgetNewFromName name = liftIO $- imageNewFromIconName (Just name) (fromIntegral $ fromEnum IconSizeMenu)- >>= putInBox+iconImageWidgetNewFromName :: (MonadIO m) => T.Text -> m Widget+iconImageWidgetNewFromName name =+ liftIO $+ imageNewFromIconName (Just name) (fromIntegral $ fromEnum IconSizeMenu)+ >>= putInBox -- | Create a new widget that updates itself at regular intervals. The -- function@@ -45,14 +50,18 @@ -- -- If the IO action throws an exception, it will be swallowed and the -- label will not update until the update interval expires.-pollingIconImageWidgetNew- :: MonadIO m- => FilePath -- ^ Initial file path of the icon- -> Double -- ^ Update interval (in seconds)- -> IO FilePath -- ^ Command to run to get the input filepath- -> m Widget+pollingIconImageWidgetNew ::+ -- | Initial file path of the icon+ FilePath ->+ -- | Update interval (in seconds)+ Double ->+ -- | Command to run to get the input filepath+ IO FilePath ->+ TaffyIO Widget pollingIconImageWidgetNew path interval cmd =- pollingIcon interval cmd+ pollingIcon+ interval+ cmd (imageNewFromFile path) (\image path' -> imageSetFromFile image (Just path')) @@ -67,39 +76,55 @@ -- -- If the IO action throws an exception, it will be swallowed and the -- label will not update until the update interval expires.-pollingIconImageWidgetNewFromName- :: MonadIO m- => T.Text -- ^ Icon Name- -> Double -- ^ Update interval (in seconds)- -> IO T.Text -- ^ Command to run update the icon name- -> m Widget+pollingIconImageWidgetNewFromName ::+ -- | Icon Name+ T.Text ->+ -- | Update interval (in seconds)+ Double ->+ -- | Command to run update the icon name+ IO T.Text ->+ TaffyIO Widget pollingIconImageWidgetNewFromName name interval cmd =- pollingIcon interval cmd+ pollingIcon+ interval+ cmd (imageNewFromIconName (Just name) (fromIntegral $ fromEnum IconSizeMenu)) (\image name' -> imageSetFromIconName image (Just name') $ fromIntegral $ fromEnum IconSizeMenu) -- | Creates a polling icon.-pollingIcon- :: MonadIO m- => Double -- ^ Update Interval (in seconds)- -> IO name -- ^ IO action that updates image's icon-name/filepath- -> IO Image -- ^ MonadIO action that creates the initial image.- -> (Image -> name -> IO b)- -- ^ MonadIO action that updates the image.- -> m Widget -- ^ Polling Icon-pollingIcon interval doUpdateName doInitImage doSetImage = liftIO $ do- image <- doInitImage- _ <- onWidgetRealize image $ do- _ <- forkIO $ forever $ do- let tryUpdate = liftIO $ do- name' <- doUpdateName- postGUIASync $ void $ doSetImage image name'- E.catch tryUpdate ignoreIOException- threadDelay $ floor (interval * 1000000)+pollingIcon ::+ -- | Update Interval (in seconds)+ Double ->+ -- | IO action that updates image's icon-name/filepath+ IO name ->+ -- | MonadIO action that creates the initial image.+ IO Image ->+ -- | MonadIO action that updates the image.+ (Image -> name -> IO b) ->+ -- | Polling Icon+ TaffyIO Widget+pollingIcon interval doUpdateName doInitImage doSetImage = do+ context <- ask+ image <- liftIO doInitImage+ liftIO $ do+ _ <- onWidgetRealize image $ do+ sampleThread <-+ runReaderT+ ( taffyForeverWithDelay interval $+ liftIO $+ E.catch+ ( do+ name' <- doUpdateName+ postGUIASync $ void $ doSetImage image name'+ )+ ignoreIOException+ )+ context+ void $ onWidgetUnrealize image $ killThread sampleThread return ()- putInBox image+ liftIO $ putInBox image -putInBox :: IsWidget child => child -> IO Widget+putInBox :: (IsWidget child) => child -> IO Widget putInBox icon = do box <- boxNew OrientationHorizontal 0 boxPackStart box icon False False 0
src/System/Taffybar/Widget/Generic/PollingBar.hs view
@@ -1,36 +1,59 @@ -- | Like the vertical bar, but this widget automatically updates -- itself with a callback at fixed intervals.-module System.Taffybar.Widget.Generic.PollingBar (- -- * Types- VerticalBarHandle,- BarConfig(..),- BarDirection(..),- -- * Constructors and accessors- pollingBarNew,- verticalBarFromCallback,- defaultBarConfig- ) where+module System.Taffybar.Widget.Generic.PollingBar+ ( -- * Types+ VerticalBarHandle,+ BarConfig (..),+ BarDirection (..), + -- * Constructors and accessors+ pollingBarNew,+ verticalBarFromCallback,+ defaultBarConfig,+ )+where+ import Control.Concurrent-import Control.Exception.Enclosed ( tryAny )+import Control.Exception.Enclosed (tryAny)+import Control.Monad (void)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Control.Monad.Trans.Reader (ask, runReaderT)+import Data.Foldable (traverse_) import qualified GI.Gtk-import System.Taffybar.Widget.Util ( backgroundLoop )-import Control.Monad.IO.Class-+import System.Taffybar.Context (TaffyIO)+import System.Taffybar.Information.Wakeup (taffyForeverWithDelay) import System.Taffybar.Widget.Generic.VerticalBar+import System.Taffybar.Widget.Util (backgroundLoop) -verticalBarFromCallback :: MonadIO m- => BarConfig -> IO Double -> m GI.Gtk.Widget+-- | Construct a bar widget driven directly by a sample callback.+verticalBarFromCallback ::+ (MonadIO m) =>+ BarConfig -> IO Double -> m GI.Gtk.Widget verticalBarFromCallback cfg action = liftIO $ do (drawArea, h) <- verticalBarNew cfg _ <- GI.Gtk.onWidgetRealize drawArea $ backgroundLoop $ do- esample <- tryAny action- traverse (verticalBarSetPercent h) esample+ esample <- tryAny action+ traverse (verticalBarSetPercent h) esample return drawArea -pollingBarNew :: MonadIO m- => BarConfig -> Double -> IO Double -> m GI.Gtk.Widget-pollingBarNew cfg pollSeconds action =- liftIO $- verticalBarFromCallback cfg $ action <* delay- where delay = threadDelay $ floor (pollSeconds * 1000000)+-- | Construct a polling bar with a fixed polling interval (seconds).+pollingBarNew ::+ BarConfig -> Double -> IO Double -> TaffyIO GI.Gtk.Widget+pollingBarNew cfg pollSeconds action = do+ context <- ask+ (drawArea, h) <- verticalBarNew cfg++ liftIO $ do+ _ <- GI.Gtk.onWidgetRealize drawArea $ do+ sampleThread <-+ runReaderT+ ( taffyForeverWithDelay pollSeconds $+ liftIO $ do+ esample <- tryAny action+ traverse_ (verticalBarSetPercent h) esample+ )+ context+ void $ GI.Gtk.onWidgetUnrealize drawArea $ killThread sampleThread+ return ()++ return drawArea
src/System/Taffybar/Widget/Generic/PollingGraph.hs view
@@ -1,46 +1,58 @@ -- | A variant of the Graph widget that automatically updates itself -- with a callback at a fixed interval.-module System.Taffybar.Widget.Generic.PollingGraph (- -- * Types- GraphHandle,- GraphConfig(..),- GraphDirection(..),- GraphStyle(..),- -- * Constructors and accessors- pollingGraphNew,- pollingGraphNewWithTooltip,- defaultGraphConfig- ) where+module System.Taffybar.Widget.Generic.PollingGraph+ ( -- * Types+ GraphHandle,+ GraphConfig (..),+ GraphDirection (..),+ GraphStyle (..), -import Control.Concurrent+ -- * Constructors and accessors+ pollingGraphNew,+ pollingGraphNewWithTooltip,+ defaultGraphConfig,+ )+where++import Control.Concurrent import qualified Control.Exception.Enclosed as E-import Control.Monad-import Control.Monad.IO.Class+import Control.Monad+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Reader (ask, runReaderT) import qualified Data.Text as T-import GI.Gtk-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.Graph+import GI.Gtk+import System.Taffybar.Context (TaffyIO)+import System.Taffybar.Information.Wakeup (taffyForeverWithDelay)+import System.Taffybar.Widget.Generic.Graph -pollingGraphNewWithTooltip- :: MonadIO m- => GraphConfig -> Double -> IO ([Double], Maybe T.Text) -> m GI.Gtk.Widget-pollingGraphNewWithTooltip cfg pollSeconds action = liftIO $ do+-- | Construct a polling graph whose callback also supplies tooltip text.+pollingGraphNewWithTooltip ::+ GraphConfig -> Double -> IO ([Double], Maybe T.Text) -> TaffyIO GI.Gtk.Widget+pollingGraphNewWithTooltip cfg pollSeconds action = do+ context <- ask (graphWidget, graphHandle) <- graphNew cfg - _ <- onWidgetRealize graphWidget $ do- sampleThread <- foreverWithDelay pollSeconds $ do- esample <- E.tryAny action- case esample of- Left _ -> return ()- Right (sample, tooltipStr) -> do- graphAddSample graphHandle sample- widgetSetTooltipMarkup graphWidget tooltipStr- void $ onWidgetUnrealize graphWidget $ killThread sampleThread+ liftIO $ do+ _ <- onWidgetRealize graphWidget $ do+ sampleThread <-+ runReaderT+ ( taffyForeverWithDelay pollSeconds $+ liftIO $ do+ esample <- E.tryAny action+ case esample of+ Left _ -> return ()+ Right (sample, tooltipStr) -> do+ graphAddSample graphHandle sample+ widgetSetTooltipMarkup graphWidget tooltipStr+ )+ context+ void $ onWidgetUnrealize graphWidget $ killThread sampleThread+ return () return graphWidget -pollingGraphNew- :: MonadIO m- => GraphConfig -> Double -> IO [Double] -> m GI.Gtk.Widget+-- | Construct a polling graph from a fixed-interval sample callback.+pollingGraphNew ::+ GraphConfig -> Double -> IO [Double] -> TaffyIO GI.Gtk.Widget pollingGraphNew cfg pollSeconds action =- pollingGraphNewWithTooltip cfg pollSeconds $ fmap (, Nothing) action+ pollingGraphNewWithTooltip cfg pollSeconds $ fmap (,Nothing) action
src/System/Taffybar/Widget/Generic/PollingLabel.hs view
@@ -4,17 +4,17 @@ -- a callback at a set interval. module System.Taffybar.Widget.Generic.PollingLabel where -import Control.Concurrent-import Control.Exception.Enclosed as E-import Control.Monad-import Control.Monad.IO.Class+import Control.Concurrent+import Control.Exception.Enclosed as E+import Control.Monad+import Control.Monad.IO.Class import qualified Data.Text as T-import GI.Gtk import qualified GI.Gdk as Gdk-import System.Log.Logger-import System.Taffybar.Util-import System.Taffybar.Widget.Util-import Text.Printf+import GI.Gtk+import System.Log.Logger+import System.Taffybar.Util+import System.Taffybar.Widget.Util+import Text.Printf -- | Create a new widget that updates itself at regular intervals. The -- function@@ -29,62 +29,105 @@ -- -- If the IO action throws an exception, it will be swallowed and the label will -- not update until the update interval expires.-pollingLabelNew- :: MonadIO m- => Double -- ^ Update interval (in seconds)- -> IO T.Text -- ^ Command to run to get the input string- -> m GI.Gtk.Widget+pollingLabelNew ::+ (MonadIO m) =>+ -- | Update interval (in seconds)+ Double ->+ -- | Command to run to get the input string+ IO T.Text ->+ m GI.Gtk.Widget pollingLabelNew interval cmd =- pollingLabelNewWithTooltip interval $ (, Nothing) <$> cmd+ pollingLabelNewWithTooltip interval $ (,Nothing) <$> cmd -pollingLabelNewWithTooltip- :: MonadIO m- => Double -- ^ Update interval (in seconds)- -> IO (T.Text, Maybe T.Text) -- ^ Command to run to get the input string- -> m GI.Gtk.Widget+-- | Like 'pollingLabelNew', but also updates tooltip text.+pollingLabelNewWithTooltip ::+ (MonadIO m) =>+ -- | Update interval (in seconds)+ Double ->+ -- | Command to run to get the input string+ IO (T.Text, Maybe T.Text) ->+ m GI.Gtk.Widget pollingLabelNewWithTooltip interval action = pollingLabelWithVariableDelay $ withInterval <$> action- where withInterval (a, b) = (a, b, interval)+ where+ withInterval (a, b) = (a, b, interval) -pollingLabelWithVariableDelay- :: MonadIO m- => IO (T.Text, Maybe T.Text, Double)- -> m GI.Gtk.Widget-pollingLabelWithVariableDelay action =- pollingLabelWithVariableDelayAndRefresh action False+-- | Create a polling label where each action result controls the next delay.+pollingLabelWithVariableDelay ::+ (MonadIO m) =>+ IO (T.Text, Maybe T.Text, Double) ->+ m GI.Gtk.Widget+pollingLabelWithVariableDelay =+ pollingLabelWithVariableDelayWithConfig defaultPollingLabelConfig +data PollingLabelConfig d = PollingLabelConfig+ { pollingLabelRefreshOnClick :: Bool,+ pollingLabelVariableDelayConfig :: VariableDelayConfig d+ }++defaultPollingLabelConfig :: PollingLabelConfig d+defaultPollingLabelConfig =+ PollingLabelConfig+ { pollingLabelRefreshOnClick = False,+ pollingLabelVariableDelayConfig = defaultVariableDelayConfig+ }+ -- TODO: Customize the delay and message on mouse click-pollingLabelWithVariableDelayAndRefresh- :: MonadIO m- => IO (T.Text, Maybe T.Text, Double)- -> Bool -- ^ Whether to refresh the label on mouse click- -> m GI.Gtk.Widget++-- | Like 'pollingLabelWithVariableDelay', with optional click-to-refresh.+pollingLabelWithVariableDelayAndRefresh ::+ (MonadIO m) =>+ IO (T.Text, Maybe T.Text, Double) ->+ -- | Whether to refresh the label on mouse click+ Bool ->+ m GI.Gtk.Widget pollingLabelWithVariableDelayAndRefresh action refreshOnClick =+ pollingLabelWithVariableDelayWithConfig+ ( defaultPollingLabelConfig+ { pollingLabelRefreshOnClick = refreshOnClick+ }+ )+ action++pollingLabelWithVariableDelayWithConfig ::+ (MonadIO m) =>+ PollingLabelConfig Double ->+ IO (T.Text, Maybe T.Text, Double) ->+ m GI.Gtk.Widget+pollingLabelWithVariableDelayWithConfig config action = liftIO $ do grid <- gridNew label <- labelNew Nothing ebox <- eventBoxNew+ _ <- widgetSetClassGI grid "polling-label-container"+ _ <- widgetSetClassGI label "polling-label-text"+ _ <- widgetSetClassGI ebox "polling-label" - when refreshOnClick $ void $ onWidgetButtonPressEvent ebox $ onClick [Gdk.EventTypeButtonPress] $ do+ when (pollingLabelRefreshOnClick config) $ void $ onWidgetButtonPressEvent ebox $ onClick [Gdk.EventTypeButtonPress] $ do postGUIASync $ labelSetMarkup label "Refreshing..." forkIO $ do- newLavelStr <- E.tryAny action >>= \case- Left _ -> return "Error"- Right (_labelStr, _, _) -> return _labelStr+ newLavelStr <-+ E.tryAny action >>= \case+ Left _ -> return "Error"+ Right (_labelStr, _, _) -> return _labelStr postGUIASync $ labelSetMarkup label newLavelStr let updateLabel (labelStr, tooltipStr, delay) = do postGUIASync $ do- labelSetMarkup label labelStr- widgetSetTooltipMarkup label tooltipStr+ labelSetMarkup label labelStr+ widgetSetTooltipMarkup label tooltipStr logM "System.Taffybar.Widget.Generic.PollingLabel" DEBUG $- printf "Polling label delay was %s" $ show delay+ printf "Polling label delay was %s" $+ show delay return delay updateLabelHandlingErrors = E.tryAny action >>= either (const $ return 1) updateLabel _ <- onWidgetRealize label $ do- sampleThread <- foreverWithVariableDelay updateLabelHandlingErrors+ sampleThread <-+ foreverWithVariableDelayWithConfig+ (pollingLabelVariableDelayConfig config)+ updateLabelHandlingErrors void $ onWidgetUnrealize label $ killThread sampleThread vFillCenter label
src/System/Taffybar/Widget/Generic/ScalingImage.hs view
@@ -6,39 +6,40 @@ -- (plain IO with an explicit strategy) instead of calling the underlying -- implementations directly. module System.Taffybar.Widget.Generic.ScalingImage- ( scalingImageNew- , scalingImage- , getScalingImageStrategy- , setScalingImageStrategy- ) where+ ( scalingImageNew,+ scalingImage,+ getScalingImageStrategy,+ setScalingImageStrategy,+ )+where -import Control.Monad-import Control.Monad.IO.Class-import Data.Int-import Data.Typeable+import Control.Monad+import Control.Monad.IO.Class+import Data.Int+import Data.Typeable import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk import qualified GI.Gtk as Gtk-import StatusNotifier.Tray (scalePixbufToSize)-import System.Taffybar.Context-import System.Taffybar.Widget.Generic.AutoFillImage (autoFillImage)-import System.Taffybar.Widget.Generic.AutoSizeImage- ( ImageScaleStrategy(..)- , autoSizeImage+import StatusNotifier.Tray (scalePixbufToSize)+import System.Taffybar.Context+import System.Taffybar.Widget.Generic.AutoFillImage (autoFillImage)+import System.Taffybar.Widget.Generic.AutoSizeImage+ ( ImageScaleStrategy (..),+ autoSizeImage, ) -newtype ScalingImageStrategySetting =- ScalingImageStrategySetting ImageScaleStrategy- deriving Typeable+newtype ScalingImageStrategySetting+ = ScalingImageStrategySetting ImageScaleStrategy+ deriving (Typeable) -- | Create a scaling image widget using the given strategy. -- -- Returns a @(widget, refreshAction)@ pair. The widget is a generic -- 'Gtk.Widget' regardless of which strategy is used.-scalingImageNew- :: ImageScaleStrategy- -> (Int32 -> IO (Maybe Gdk.Pixbuf))- -> Gtk.Orientation- -> IO (Gtk.Widget, IO ())+scalingImageNew ::+ ImageScaleStrategy ->+ (Int32 -> IO (Maybe Gdk.Pixbuf)) ->+ Gtk.Orientation ->+ IO (Gtk.Widget, IO ()) scalingImageNew ImageResize getPixbuf orientation = do image <- Gtk.imageNew let scaledGetter size =@@ -53,10 +54,10 @@ return (widget, refresh) -- | TaffyIO variant that reads the strategy from context state.-scalingImage- :: (Int32 -> IO (Maybe Gdk.Pixbuf))- -> Gtk.Orientation- -> TaffyIO (Gtk.Widget, IO ())+scalingImage ::+ (Int32 -> IO (Maybe Gdk.Pixbuf)) ->+ Gtk.Orientation ->+ TaffyIO (Gtk.Widget, IO ()) scalingImage getPixbuf orientation = do strategy <- getScalingImageStrategy liftIO $ scalingImageNew strategy getPixbuf orientation
src/System/Taffybar/Widget/Generic/VerticalBar.hs view
@@ -1,85 +1,96 @@ -- | A vertical bar that can plot data in the range [0, 1]. The -- colors are configurable.-module System.Taffybar.Widget.Generic.VerticalBar (- -- * Types- VerticalBarHandle,- BarConfig(..),- BarDirection(..),- -- * Accessors/Constructors- verticalBarNew,- verticalBarSetPercent,- defaultBarConfig,- defaultBarConfigIO- ) where+module System.Taffybar.Widget.Generic.VerticalBar+ ( -- * Types+ VerticalBarHandle,+ BarConfig (..),+ BarDirection (..), -import Control.Concurrent-import Control.Monad-import Control.Monad.IO.Class+ -- * Accessors/Constructors+ verticalBarNew,+ verticalBarSetPercent,+ defaultBarConfig,+ defaultBarConfigIO,+ )+where++import Control.Concurrent+import Control.Monad+import Control.Monad.IO.Class import qualified GI.Cairo.Render as C-import GI.Cairo.Render.Connector-import GI.Gtk hiding (widgetGetAllocatedSize)-import System.Taffybar.Util-import System.Taffybar.Widget.Util+import GI.Cairo.Render.Connector+import GI.Gtk hiding (widgetGetAllocatedSize)+import System.Taffybar.Util+import System.Taffybar.Widget.Util +-- | Handle used to update an existing vertical bar widget. newtype VerticalBarHandle = VBH (MVar VerticalBarState)+ data VerticalBarState = VerticalBarState- { barIsBootstrapped :: Bool- , barPercent :: Double- , barCanvas :: DrawingArea- , barConfig :: BarConfig+ { barIsBootstrapped :: Bool,+ barPercent :: Double,+ barCanvas :: DrawingArea,+ barConfig :: BarConfig } +-- | Direction in which the filled portion grows. data BarDirection = HORIZONTAL | VERTICAL +-- | Static or IO-driven configuration for the vertical bar. data BarConfig- = BarConfig {- -- | Color of the border drawn around the widget- barBorderColor :: (Double, Double, Double)- -- | The background color of the widget- , barBackgroundColor :: Double -> (Double, Double, Double)- -- | A function to determine the color of the widget for the current data point- , barColor :: Double -> (Double, Double, Double)- -- | Number of pixels of padding around the widget- , barPadding :: Int- , barWidth :: Int- , barDirection :: BarDirection}- | BarConfigIO { barBorderColorIO :: IO (Double, Double, Double)- , barBackgroundColorIO :: Double -> IO (Double, Double, Double)- , barColorIO :: Double -> IO (Double, Double, Double)- , barPadding :: Int- , barWidth :: Int- , barDirection :: BarDirection}+ = BarConfig+ { -- | Color of the border drawn around the widget+ barBorderColor :: (Double, Double, Double),+ -- | The background color of the widget+ barBackgroundColor :: Double -> (Double, Double, Double),+ -- | A function to determine the color of the widget for the current data point+ barColor :: Double -> (Double, Double, Double),+ -- | Number of pixels of padding around the widget+ barPadding :: Int,+ barWidth :: Int,+ barDirection :: BarDirection+ }+ | BarConfigIO+ { barBorderColorIO :: IO (Double, Double, Double),+ barBackgroundColorIO :: Double -> IO (Double, Double, Double),+ barColorIO :: Double -> IO (Double, Double, Double),+ barPadding :: Int,+ barWidth :: Int,+ barDirection :: BarDirection+ } -- | A default bar configuration. The color of the active portion of -- the bar must be specified. defaultBarConfig :: (Double -> (Double, Double, Double)) -> BarConfig defaultBarConfig c = BarConfig- { barBorderColor = (0.5, 0.5, 0.5)- , barBackgroundColor = const (0, 0, 0)- , barColor = c- , barPadding = 2- , barWidth = 15- , barDirection = VERTICAL- }+ { barBorderColor = (0.5, 0.5, 0.5),+ barBackgroundColor = const (0, 0, 0),+ barColor = c,+ barPadding = 2,+ barWidth = 15,+ barDirection = VERTICAL+ } +-- | IO-driven variant of 'defaultBarConfig'. defaultBarConfigIO :: (Double -> IO (Double, Double, Double)) -> BarConfig defaultBarConfigIO c = BarConfigIO- { barBorderColorIO = return (0.5, 0.5, 0.5)- , barBackgroundColorIO = \_ -> return (0, 0, 0)- , barColorIO = c- , barPadding = 2- , barWidth = 15- , barDirection = VERTICAL- }+ { barBorderColorIO = return (0.5, 0.5, 0.5),+ barBackgroundColorIO = \_ -> return (0, 0, 0),+ barColorIO = c,+ barPadding = 2,+ barWidth = 15,+ barDirection = VERTICAL+ } +-- | Update the bar value (clamped to @[0, 1]@) and queue a redraw. verticalBarSetPercent :: VerticalBarHandle -> Double -> IO () verticalBarSetPercent (VBH mv) pct = do s <- readMVar mv let drawArea = barCanvas s when (barIsBootstrapped s) $ do- modifyMVar_ mv (\s' -> return s' { barPercent = clamp 0 1 pct })+ modifyMVar_ mv (\s' -> return s' {barPercent = clamp 0 1 pct}) postGUIASync $ widgetQueueDraw drawArea clamp :: Double -> Double -> Double -> Double@@ -88,20 +99,20 @@ liftedBackgroundColor :: BarConfig -> Double -> IO (Double, Double, Double) liftedBackgroundColor bc pct = case bc of- BarConfig { barBackgroundColor = bcolor } -> return (bcolor pct)- BarConfigIO { barBackgroundColorIO = bcolor } -> bcolor pct+ BarConfig {barBackgroundColor = bcolor} -> return (bcolor pct)+ BarConfigIO {barBackgroundColorIO = bcolor} -> bcolor pct liftedBorderColor :: BarConfig -> IO (Double, Double, Double) liftedBorderColor bc = case bc of- BarConfig { barBorderColor = border } -> return border- BarConfigIO { barBorderColorIO = border } -> border+ BarConfig {barBorderColor = border} -> return border+ BarConfigIO {barBorderColorIO = border} -> border liftedBarColor :: BarConfig -> Double -> IO (Double, Double, Double) liftedBarColor bc pct = case bc of- BarConfig { barColor = c } -> return (c pct)- BarConfigIO { barColorIO = c } -> c pct+ BarConfig {barColor = c} -> return (c pct)+ BarConfigIO {barColorIO = c} -> c pct renderFrame_ :: Double -> BarConfig -> Int -> Int -> C.Render () renderFrame_ pct cfg width height = do@@ -127,14 +138,14 @@ renderBar pct cfg width height = do let direction = barDirection cfg activeHeight = case direction of- VERTICAL -> pct * fromIntegral height- HORIZONTAL -> fromIntegral height- activeWidth = case direction of- VERTICAL -> fromIntegral width- HORIZONTAL -> pct * fromIntegral width- newOrigin = case direction of- VERTICAL -> fromIntegral height - activeHeight- HORIZONTAL -> 0+ VERTICAL -> pct * fromIntegral height+ HORIZONTAL -> fromIntegral height+ activeWidth = case direction of+ VERTICAL -> fromIntegral width+ HORIZONTAL -> pct * fromIntegral width+ newOrigin = case direction of+ VERTICAL -> fromIntegral height - activeHeight+ HORIZONTAL -> 0 pad = barPadding cfg renderFrame_ pct cfg width height@@ -156,22 +167,23 @@ drawBar mv drawArea = do (w, h) <- widgetGetAllocatedSize drawArea s <- liftIO $ do- s <- readMVar mv- modifyMVar_ mv (\s' -> return s' { barIsBootstrapped = True })- return s+ s <- readMVar mv+ modifyMVar_ mv (\s' -> return s' {barIsBootstrapped = True})+ return s renderBar (barPercent s) (barConfig s) w h -verticalBarNew :: MonadIO m => BarConfig -> m (GI.Gtk.Widget, VerticalBarHandle)+-- | Construct a bar widget and its mutable update handle.+verticalBarNew :: (MonadIO m) => BarConfig -> m (GI.Gtk.Widget, VerticalBarHandle) verticalBarNew cfg = liftIO $ do drawArea <- drawingAreaNew mv <- newMVar VerticalBarState- { barIsBootstrapped = False- , barPercent = 0- , barCanvas = drawArea- , barConfig = cfg- }+ { barIsBootstrapped = False,+ barPercent = 0,+ barCanvas = drawArea,+ barConfig = cfg+ } widgetSetSizeRequest drawArea (fromIntegral $ barWidth cfg) (-1) _ <- onWidgetDraw drawArea $ \ctx -> renderWithContext (drawBar mv drawArea) ctx >> return True box <- boxNew OrientationHorizontal 1
src/System/Taffybar/Widget/HyprlandLayout.hs view
@@ -1,6 +1,9 @@ {-# LANGUAGE OverloadedStrings #-} -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.HyprlandLayout -- Copyright : (c) Ivan A. Malison@@ -12,54 +15,56 @@ -- -- Simple text widget that shows the Hyprland layout used in the currently -- active workspace.------------------------------------------------------------------------------- module System.Taffybar.Widget.HyprlandLayout- ( HyprlandLayoutConfig(..)- , defaultHyprlandLayoutConfig- , hyprlandLayoutNew- ) where+ ( HyprlandLayoutConfig (..),+ defaultHyprlandLayoutConfig,+ hyprlandLayoutNew,+ )+where -import Control.Applicative ((<|>))-import Control.Concurrent (killThread)-import Control.Monad (void)-import Control.Monad.Trans.Class-import Control.Monad.Trans.Reader-import Data.Aeson (FromJSON(..), withObject, (.:?))-import Data.Default (Default(..))-import Data.Maybe (fromMaybe)-import Data.Text (Text)+import Control.Applicative ((<|>))+import Control.Concurrent (killThread)+import Control.Monad (void)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Reader+import Data.Aeson (FromJSON (..), withObject, (.:?))+import Data.Default (Default (..))+import Data.Maybe (fromMaybe)+import Data.Text (Text) import qualified Data.Text as T-import GI.Gdk+import GI.Gdk import qualified GI.Gtk as Gtk-import System.Log.Logger (Priority(..))-import System.Taffybar.Context-import System.Taffybar.Hyprland- ( runHyprlandCommandJsonT- , runHyprlandCommandRawT+import System.Log.Logger (Priority (..))+import System.Taffybar.Context+import System.Taffybar.Hyprland+ ( runHyprlandCommandJsonT,+ runHyprlandCommandRawT, ) import qualified System.Taffybar.Information.Hyprland as Hypr-import System.Taffybar.Util-import System.Taffybar.Widget.Util+import System.Taffybar.Information.Wakeup (taffyForeverWithDelay)+import System.Taffybar.Util+import System.Taffybar.Widget.Util +-- | Configuration for 'hyprlandLayoutNew'. data HyprlandLayoutConfig = HyprlandLayoutConfig- { formatLayout :: T.Text -> TaffyIO T.Text- , updateIntervalSeconds :: Double- , onLeftClick :: Maybe [String]- , onRightClick :: Maybe [String]+ { formatLayout :: T.Text -> TaffyIO T.Text,+ updateIntervalSeconds :: Double,+ onLeftClick :: Maybe [String],+ onRightClick :: Maybe [String] } instance Default HyprlandLayoutConfig where def = defaultHyprlandLayoutConfig +-- | Default Hyprland layout widget configuration. defaultHyprlandLayoutConfig :: HyprlandLayoutConfig defaultHyprlandLayoutConfig = HyprlandLayoutConfig- { formatLayout = return- , updateIntervalSeconds = 1- , onLeftClick = Nothing- , onRightClick = Nothing- }+ { formatLayout = return,+ updateIntervalSeconds = 1,+ onLeftClick = Nothing,+ onRightClick = Nothing+ } -- | Create a new Hyprland Layout widget. hyprlandLayoutNew :: HyprlandLayoutConfig -> TaffyIO Gtk.Widget@@ -74,8 +79,7 @@ lift $ postGUIASync $ Gtk.labelSetMarkup label markup void refresh- threadId <- lift $ foreverWithDelay (updateIntervalSeconds config) $- void $ runReaderT refresh ctx+ threadId <- taffyForeverWithDelay (updateIntervalSeconds config) (void refresh) ebox <- lift Gtk.eventBoxNew lift $ Gtk.containerAdd ebox label@@ -106,15 +110,19 @@ result <- runHyprlandCommandRawT (Hypr.hyprCommand ("dispatch" : args)) case result of Left err ->- logPrintF "System.Taffybar.Widget.HyprlandLayout" WARNING- "Failed to dispatch Hyprland command: %s" (show err)+ logPrintF+ "System.Taffybar.Widget.HyprlandLayout"+ WARNING+ "Failed to dispatch Hyprland command: %s"+ (show err) Right _ -> return () -- Hyprland JSON helpers newtype HyprlandActiveWorkspace = HyprlandActiveWorkspace { hawLayout :: Maybe Text- } deriving (Show, Eq)+ }+ deriving (Show, Eq) instance FromJSON HyprlandActiveWorkspace where parseJSON = withObject "HyprlandActiveWorkspace" $ \v -> do@@ -126,17 +134,20 @@ result <- runHyprctlJson ["-j", "activeworkspace"] case result of Left err ->- logPrintF "System.Taffybar.Widget.HyprlandLayout" WARNING- "hyprctl activeworkspace failed: %s" err >>- return ""+ logPrintF+ "System.Taffybar.Widget.HyprlandLayout"+ WARNING+ "hyprctl activeworkspace failed: %s"+ err+ >> return "" Right (HyprlandActiveWorkspace layout) -> return $ fromMaybe "" layout -runHyprctlJson :: FromJSON a => [String] -> TaffyIO (Either String a)+runHyprctlJson :: (FromJSON a) => [String] -> TaffyIO (Either String a) runHyprctlJson args = do let args' = case args of- ("-j":rest) -> rest+ ("-j" : rest) -> rest _ -> args result <- runHyprlandCommandJsonT (Hypr.hyprCommandJson args') pure $ case result of
− src/System/Taffybar/Widget/HyprlandWindows.hs
@@ -1,175 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}---------------------------------------------------------------------------------- |--- Module : System.Taffybar.Widget.HyprlandWindows--- Copyright : (c) Ivan A. Malison--- License : BSD3-style (see LICENSE)------ Maintainer : Ivan A. Malison--- Stability : unstable--- Portability : unportable------ Menu widget that shows the title of the currently focused Hyprland window--- and that, when clicked, displays a menu from which the user may select a--- window to which to switch focus.--------------------------------------------------------------------------------module System.Taffybar.Widget.HyprlandWindows where--import Control.Concurrent (killThread)-import Control.Monad (forM_, void)-import Control.Monad.Trans.Class-import Control.Monad.Trans.Reader-import Control.Monad.Trans.Maybe-import Data.Default (Default(..))-import Data.List (find)-import qualified Data.Text as T-import qualified GI.Gtk as Gtk-import System.Log.Logger (Priority(..))-import System.Taffybar.Context-import System.Taffybar.Hyprland (runHyprlandCommandRawT)-import qualified System.Taffybar.Information.Hyprland as Hypr-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.DynamicMenu-import System.Taffybar.Widget.Generic.ScalingImage (scalingImage)-import System.Taffybar.Widget.Util-import System.Taffybar.Widget.HyprlandWorkspaces- ( HyprlandWindow(..)- , HyprlandWindowIconPixbufGetter- , HyprlandClient- , defaultHyprlandGetWindowIconPixbuf- , getActiveWindowAddress- , runHyprctlJson- , windowFromClient- )---- | Window menu widget configuration for Hyprland.-data HyprlandWindowsConfig = HyprlandWindowsConfig- { getMenuLabel :: HyprlandWindow -> TaffyIO T.Text- -- ^ A monadic function used to build labels for windows in the menu.- , getActiveLabel :: Maybe HyprlandWindow -> TaffyIO T.Text- -- ^ Action to build the label text for the active window.- , getActiveWindowIconPixbuf :: Maybe HyprlandWindowIconPixbufGetter- -- ^ Optional function to retrieve a pixbuf to show next to the window label.- , updateIntervalSeconds :: Double- }--truncatedGetMenuLabel :: Int -> HyprlandWindow -> TaffyIO T.Text-truncatedGetMenuLabel maxLength window =- return $ truncateText maxLength (T.pack $ windowTitle window)--defaultGetMenuLabel :: HyprlandWindow -> TaffyIO T.Text-defaultGetMenuLabel = truncatedGetMenuLabel 35--defaultGetActiveLabel :: Maybe HyprlandWindow -> TaffyIO T.Text-defaultGetActiveLabel = maybe (return "") defaultGetMenuLabel--defaultHyprlandWindowsConfig :: HyprlandWindowsConfig-defaultHyprlandWindowsConfig =- HyprlandWindowsConfig- { getMenuLabel = defaultGetMenuLabel- , getActiveLabel = defaultGetActiveLabel- , getActiveWindowIconPixbuf = Just defaultHyprlandGetWindowIconPixbuf- , updateIntervalSeconds = 1- }--instance Default HyprlandWindowsConfig where- def = defaultHyprlandWindowsConfig---- | Create a new Hyprland Windows widget.-hyprlandWindowsNew :: HyprlandWindowsConfig -> TaffyIO Gtk.Widget-hyprlandWindowsNew config = do- hbox <- lift $ Gtk.boxNew Gtk.OrientationHorizontal 0-- refreshIcon <- case getActiveWindowIconPixbuf config of- Just getIcon -> do- (rf, icon) <- buildWindowsIcon getIcon- Gtk.boxPackStart hbox icon True True 0- pure rf- Nothing -> pure (pure ())-- (setLabelTitle, label) <- buildWindowsLabel- Gtk.boxPackStart hbox label True True 0- let refreshLabel = do- activeWindow <- getActiveHyprlandWindow- labelText <- getActiveLabel config activeWindow- lift $ setLabelTitle labelText-- let refresh = refreshLabel >> lift refreshIcon- ctx <- ask- void refresh- threadId <- lift $ foreverWithDelay (updateIntervalSeconds config) $- void $ runReaderT refresh ctx-- _ <- lift $ Gtk.onWidgetUnrealize hbox $ killThread threadId-- Gtk.widgetShowAll hbox- boxWidget <- Gtk.toWidget hbox-- runTaffy <- asks (flip runReaderT)- menu <- dynamicMenuNew- DynamicMenuConfig { dmClickWidget = boxWidget- , dmPopulateMenu = runTaffy . fillMenu config- }-- widgetSetClassGI menu "windows"--buildWindowsLabel :: TaffyIO (T.Text -> IO (), Gtk.Widget)-buildWindowsLabel = do- label <- lift $ Gtk.labelNew Nothing- let setLabelTitle title = postGUIASync $ Gtk.labelSetMarkup label title- (setLabelTitle,) <$> Gtk.toWidget label--buildWindowsIcon :: HyprlandWindowIconPixbufGetter -> TaffyIO (IO (), Gtk.Widget)-buildWindowsIcon windowIconPixbufGetter = do- runTaffy <- asks (flip runReaderT)- let getActiveWindowPixbuf size = runTaffy . runMaybeT $ do- wd <- MaybeT getActiveHyprlandWindow- MaybeT $ windowIconPixbufGetter size wd-- (imageWidget, updateImage) <- scalingImage getActiveWindowPixbuf Gtk.OrientationHorizontal- return (postGUIASync updateImage, imageWidget)--getActiveHyprlandWindow :: TaffyIO (Maybe HyprlandWindow)-getActiveHyprlandWindow = find windowActive <$> getHyprlandWindows--getHyprlandWindows :: TaffyIO [HyprlandWindow]-getHyprlandWindows = do- activeAddr <- getActiveWindowAddress- clientsResult <- runHyprctlJson ["-j", "clients"]- case clientsResult of- Left err ->- logPrintF "System.Taffybar.Widget.HyprlandWindows" WARNING- "hyprctl clients failed: %s" err >>- return []- Right clients ->- return $ map (windowFromClient activeAddr) (clients :: [HyprlandClient])--fillMenu :: Gtk.IsMenuShell a => HyprlandWindowsConfig -> a -> ReaderT Context IO ()-fillMenu config menu = ask >>= \context -> do- windowIds <- getHyprlandWindows- forM_ windowIds $ \windowData ->- lift $ do- labelText <- runReaderT (getMenuLabel config windowData) context- let focusCallback = runReaderT (focusHyprlandWindow windowData) context >>- return True- item <- Gtk.menuItemNewWithLabel labelText- _ <- Gtk.onWidgetButtonPressEvent item $ const focusCallback- Gtk.menuShellAppend menu item- Gtk.widgetShow item--focusHyprlandWindow :: HyprlandWindow -> TaffyIO ()-focusHyprlandWindow windowData = do- result <-- runHyprlandCommandRawT $- Hypr.hyprCommand- [ "dispatch"- , "focuswindow"- , "address:" <> T.unpack (windowAddress windowData)- ]- case result of- Left err ->- logPrintF "System.Taffybar.Widget.HyprlandWindows" WARNING- "Failed to focus window: %s" (show err)- Right _ -> return ()
src/System/Taffybar/Widget/HyprlandWorkspaces.hs view
@@ -1,7 +1,7 @@-{-# LANGUAGE ExistentialQuantification #-}-{-# LANGUAGE OverloadedStrings #-}+----------------------------------------------------------------------------- -----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.HyprlandWorkspaces -- Copyright : (c) Ivan A. Malison@@ -10,871 +10,58 @@ -- Maintainer : Ivan A. Malison -- Stability : unstable -- Portability : unportable------ Hyprland workspaces widget backed by hyprctl.--------------------------------------------------------------------------------module System.Taffybar.Widget.HyprlandWorkspaces where--import Control.Applicative ((<|>))-import Control.Concurrent (forkIO, killThread)-import Control.Concurrent.STM.TChan (TChan, readTChan)-import Control.Monad (foldM, forM_, when)-import Control.Monad.IO.Class (MonadIO(liftIO))-import Control.Monad.STM (atomically)-import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)-import Data.Aeson (FromJSON(..), withObject, (.:), (.:?), (.!=))-import Data.Char (toLower)-import qualified Data.ByteString as BS-import Data.Default (Default(..))-import qualified Data.Foldable as F-import Data.Int (Int32)-import Data.List (intercalate, sortOn, stripPrefix)-import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)-import qualified Data.Map.Strict as M-import qualified Data.MultiMap as MM-import Data.IORef (IORef, newIORef, readIORef, writeIORef)-import Data.Text (Text)-import qualified Data.Text as T-import System.Log.Logger (Priority(..), logM)-import Text.Printf (printf)--import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk-import qualified GI.Gtk as Gtk--import System.Environment.XDG.DesktopEntry- ( DesktopEntry- , deFilename- , getDirectoryEntriesDefault- )-import System.Taffybar.Context-import System.Taffybar.Hyprland- ( getHyprlandEventChan- , runHyprlandCommandJsonT- , runHyprlandCommandRawT+module System.Taffybar.Widget.HyprlandWorkspaces+ {-# DEPRECATED "Legacy Hyprland workspaces widget API. Use System.Taffybar.Widget.Workspaces (or System.Taffybar.Widget.ChannelWorkspaces) instead." #-}+ ( module System.Taffybar.Widget.Workspaces.Hyprland,+ HyprlandWorkspacesConfig (..),+ defaultHyprlandWorkspacesConfig,+ hyprlandWorkspacesNew,+ hyprlandWorkspacesCommonConfig,+ applyCommonHyprlandWorkspacesConfig,+ refreshWorkspaces,+ applyUrgentState,+ hyprlandBuildLabelController,+ hyprlandBuildIconController,+ hyprlandBuildContentsController,+ hyprlandBuildLabelOverlayController,+ hyprlandBuildCustomOverlayController,+ hyprlandBuildButtonController,+ defaultHyprlandWidgetBuilder,+ buildIconWidget, )-import qualified System.Taffybar.Information.Hyprland as Hypr-import System.Taffybar.Util-import System.Taffybar.Widget.Util- ( WindowIconWidget(..)- , computeIconStripLayout- , getImageForDesktopEntry- , handlePixbufGetterException- , scaledPixbufGetter- , syncWidgetPool- , updateWindowIconWidgetState- , widgetSetClassGI- , windowStatusClassFromFlags+where++import System.Taffybar.Widget.Workspaces.Hyprland hiding+ ( HyprlandWorkspacesConfig (..),+ applyCommonHyprlandWorkspacesConfig,+ applyUrgentState,+ buildIconWidget,+ defaultHyprlandWidgetBuilder,+ defaultHyprlandWorkspacesConfig,+ hyprlandBuildButtonController,+ hyprlandBuildContentsController,+ hyprlandBuildCustomOverlayController,+ hyprlandBuildIconController,+ hyprlandBuildLabelController,+ hyprlandBuildLabelOverlayController,+ hyprlandWorkspacesCommonConfig,+ hyprlandWorkspacesNew,+ refreshWorkspaces, )-import System.Taffybar.Widget.Generic.ScalingImage (getScalingImageStrategy)-import System.Taffybar.Widget.Workspaces.Shared- ( WorkspaceState(..)- , setWorkspaceWidgetStatusClass- , buildWorkspaceIconLabelOverlay- , mkWorkspaceIconWidget+import System.Taffybar.Widget.Workspaces.Hyprland.Compat+ ( HyprlandWorkspacesConfig (..),+ applyCommonHyprlandWorkspacesConfig,+ applyUrgentState,+ buildIconWidget,+ defaultHyprlandWidgetBuilder,+ defaultHyprlandWorkspacesConfig,+ hyprlandBuildButtonController,+ hyprlandBuildContentsController,+ hyprlandBuildCustomOverlayController,+ hyprlandBuildIconController,+ hyprlandBuildLabelController,+ hyprlandBuildLabelOverlayController,+ hyprlandWorkspacesCommonConfig,+ hyprlandWorkspacesNew,+ refreshWorkspaces, )-import System.Taffybar.WindowIcon (getWindowIconFromClasses, pixBufFromColor)--stripSuffix :: Eq a => [a] -> [a] -> Maybe [a]-stripSuffix suffix value =- reverse <$> stripPrefix (reverse suffix) (reverse value)--isSpecialHyprWorkspace :: HyprlandWorkspace -> Bool-isSpecialHyprWorkspace ws =- let name = T.toLower $ T.pack $ workspaceName ws- in T.isPrefixOf "special" name || workspaceIdx ws < 0--data HyprlandWindow = HyprlandWindow- { windowAddress :: Text- , windowTitle :: String- , windowClass :: Maybe String- , windowInitialClass :: Maybe String- -- | The top-left position (x, y) of the window, as reported by- -- @hyprctl clients -j@. This is used for optional icon ordering.- , windowAt :: Maybe (Int, Int)- , windowUrgent :: Bool- , windowActive :: Bool- , windowMinimized :: Bool- } deriving (Show, Eq)--data HyprlandWorkspace = HyprlandWorkspace- { workspaceIdx :: Int- , workspaceName :: String- , workspaceState :: WorkspaceState- , windows :: [HyprlandWindow]- } deriving (Show, Eq)--newtype HyprlandWorkspaceCache = HyprlandWorkspaceCache [HyprlandWorkspace]--newtype HyprlandWorkspaceWidgetCache- = HyprlandWorkspaceWidgetCache (M.Map Int HyprlandWorkspaceEntry)--newtype HyprlandWorkspaceOrderCache- = HyprlandWorkspaceOrderCache [Int]--data HyprlandWorkspaceEntry = HyprlandWorkspaceEntry- { hweWrapper :: Gtk.Widget- , hweController :: HyprlandWWC- , hweLast :: HyprlandWorkspace- }--type HyprlandIconWidget = WindowIconWidget HyprlandWindow---- | Controller typeclass for Hyprland workspace widgets, mirroring--- the X11 'WorkspaceWidgetController' pattern.-class HyprlandWorkspaceWidgetController wc where- hwcGetWidget :: wc -> IO Gtk.Widget- hwcUpdateWidget :: wc -> HyprlandWorkspace -> TaffyIO wc---- | Existential wrapper for Hyprland workspace controllers.-data HyprlandWWC = forall a. HyprlandWorkspaceWidgetController a => HyprlandWWC a--instance HyprlandWorkspaceWidgetController HyprlandWWC where- hwcGetWidget (HyprlandWWC wc) = hwcGetWidget wc- hwcUpdateWidget (HyprlandWWC wc) ws = HyprlandWWC <$> hwcUpdateWidget wc ws--type HyprlandControllerConstructor = HyprlandWorkspace -> TaffyIO HyprlandWWC-type HyprlandParentControllerConstructor =- HyprlandControllerConstructor -> HyprlandControllerConstructor--type HyprlandWindowIconPixbufGetter =- Int32 -> HyprlandWindow -> TaffyIO (Maybe Gdk.Pixbuf)--data HyprlandWorkspacesConfig =- HyprlandWorkspacesConfig- { getWorkspaces :: TaffyIO [HyprlandWorkspace]- , switchToWorkspace :: HyprlandWorkspace -> TaffyIO ()- , updateIntervalSeconds :: Double- , widgetBuilder :: HyprlandControllerConstructor- , widgetGap :: Int- , maxIcons :: Maybe Int- , minIcons :: Int- , iconSize :: Int32- , getWindowIconPixbuf :: HyprlandWindowIconPixbufGetter- , labelSetter :: HyprlandWorkspace -> TaffyIO String- , showWorkspaceFn :: HyprlandWorkspace -> Bool- , iconSort :: [HyprlandWindow] -> TaffyIO [HyprlandWindow]- , urgentWorkspaceState :: Bool- }--defaultHyprlandWorkspacesConfig :: HyprlandWorkspacesConfig-defaultHyprlandWorkspacesConfig = cfg- where- cfg = HyprlandWorkspacesConfig- { getWorkspaces = getHyprlandWorkspaces- , switchToWorkspace = hyprlandSwitchToWorkspace- , updateIntervalSeconds = 1- , widgetBuilder = defaultHyprlandWidgetBuilder cfg- , widgetGap = 0- , maxIcons = Nothing- , minIcons = 0- , iconSize = 16- , getWindowIconPixbuf = defaultHyprlandGetWindowIconPixbuf- , labelSetter = return . workspaceName- , showWorkspaceFn = \ws ->- workspaceState ws /= Empty && not (isSpecialHyprWorkspace ws)- -- Match the X11 Workspaces widget default: order icons by window position.- , iconSort = pure . sortHyprlandWindowsByPosition- , urgentWorkspaceState = False- }--instance Default HyprlandWorkspacesConfig where- def = defaultHyprlandWorkspacesConfig--hyprlandWorkspacesNew :: HyprlandWorkspacesConfig -> TaffyIO Gtk.Widget-hyprlandWorkspacesNew cfg = do- cont <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal $- fromIntegral (widgetGap cfg)- _ <- widgetSetClassGI cont "workspaces"- ctx <- ask- let refresh = runReaderT (refreshWorkspaces cfg cont) ctx- liftIO refresh- -- Ensure this top-level container is visible when packed into the bar.- -- Otherwise the start widget area can appear blank under Wayland/Hyprland.- liftIO $ Gtk.widgetShowAll cont- eventChan <- getHyprlandEventChan- events <- liftIO $ Hypr.subscribeHyprlandEvents eventChan- tid <- liftIO $ forkIO $ hyprlandUpdateLoop refresh events- _ <- liftIO $ Gtk.onWidgetUnrealize cont $ killThread tid- Gtk.toWidget cont--hyprlandUpdateLoop :: IO () -> TChan T.Text -> IO ()-hyprlandUpdateLoop refresh events = do- line <- atomically $ readTChan events- when (isRelevantHyprEvent (T.unpack line)) refresh- hyprlandUpdateLoop refresh events--isRelevantHyprEvent :: String -> Bool-isRelevantHyprEvent line =- let eventName = takeWhile (/= '>') line- in eventName `elem`- [ "workspace"- , "workspacev2"- , "focusedmon"- , "activewindow"- , "activewindowv2"- , "openwindow"- , "closewindow"- , "movewindow"- , "movewindowv2"- , "moveworkspace"- , "renameworkspace"- , "createworkspace"- , "destroyworkspace"- , "monitoradded"- , "monitorremoved"- -- Synthetic "event" emitted by our event reader thread whenever it- -- (re)connects to Hyprland. This lets us refresh after a compositor- -- restart without polling.- , "taffybar-hyprland-connected"- ]--refreshWorkspaces :: HyprlandWorkspacesConfig -> Gtk.Box -> ReaderT Context IO ()-refreshWorkspaces cfg cont = do- ws <- getWorkspaces cfg- HyprlandWorkspaceCache prev <- getStateDefault $ return (HyprlandWorkspaceCache [])- -- Detect whether the cached widgets have been orphaned (e.g. the bar window- -- was destroyed and recreated after a compositor reload). When the old GTK- -- objects are gone we must re-render even if the workspace data is unchanged.- HyprlandWorkspaceWidgetCache wc <-- getStateDefault $ return (HyprlandWorkspaceWidgetCache M.empty)- widgetsStale <- case M.elems wc of- [] -> return False- (first:_) -> liftIO $ not <$> Gtk.widgetGetRealized (hweWrapper first)- let ignoreEmptyResult = null ws && not (null prev)- when ignoreEmptyResult $- liftIO $ wLog WARNING $- printf- "Hyprland workspaces refresh returned empty list; retaining previous state (prevTotal=%d)."- (length prev)- let needsRender = (ws /= prev || widgetsStale) && not ignoreEmptyResult- when needsRender $ do- liftIO $ wLog DEBUG $- printf "Hyprland workspaces refresh: total=%d shown=%d (minIcons=%d maxIcons=%s) widgetsStale=%s %s"- (length ws)- (length (filter (showWorkspaceFn cfg) ws))- (minIcons cfg)- (show (maxIcons cfg))- (show widgetsStale)- (summarizeWorkspaces ws)- _ <- setState (HyprlandWorkspaceCache ws)- ctx <- ask- liftIO $ postGUIASync $ runReaderT (renderWorkspaces cfg cont ws) ctx--renderWorkspaces ::- HyprlandWorkspacesConfig -> Gtk.Box -> [HyprlandWorkspace] -> ReaderT Context IO ()-renderWorkspaces cfg cont workspaces = do- let workspaces' = map (applyUrgentState cfg) workspaces- HyprlandWorkspaceWidgetCache widgetCache <-- getStateDefault $ return (HyprlandWorkspaceWidgetCache M.empty)- HyprlandWorkspaceOrderCache prevOrder <-- getStateDefault $ return (HyprlandWorkspaceOrderCache [])- -- If any cached widget has been unrealized (e.g. because the bar window was- -- recreated after a compositor reload), the old GTK objects are dead and must- -- be discarded so that fresh widgets are built for the new container.- stale <- case M.elems widgetCache of- [] -> return False- (first:_) -> liftIO $ not <$> Gtk.widgetGetRealized (hweWrapper first)- let oldCache = if stale then M.empty else widgetCache- oldOrder = if stale then [] else prevOrder- when stale $- liftIO $ wLog DEBUG "renderWorkspaces: discarding stale widget cache (widgets unrealized)"- let buildOrUpdate newCache ws = do- let idx = workspaceIdx ws- entry <- case M.lookup idx oldCache of- Just prevEntry- | hweLast prevEntry == ws -> return prevEntry- | otherwise -> do- newCtrl <- hwcUpdateWidget (hweController prevEntry) ws- return prevEntry { hweController = newCtrl, hweLast = ws }- Nothing -> do- ctrl <- widgetBuilder cfg ws- ctrlWidget <- liftIO $ hwcGetWidget ctrl- wrapperBox <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal 0- liftIO $ Gtk.containerAdd wrapperBox ctrlWidget- wrapper <- Gtk.toWidget wrapperBox- return HyprlandWorkspaceEntry- { hweWrapper = wrapper- , hweController = ctrl- , hweLast = ws- }- return (M.insert idx entry newCache, entry)-- (newCache, orderedEntriesRev) <-- foldM- (\(cacheAcc, entriesAcc) ws -> do- (cacheAcc', entry) <- buildOrUpdate cacheAcc ws- return (cacheAcc', (ws, entry) : entriesAcc)- )- (M.empty, [])- workspaces'- let orderedEntries = reverse orderedEntriesRev-- let primaryShowFn = showWorkspaceFn cfg- primaryShownCount = length $ filter (primaryShowFn . fst) orderedEntries- fallbackShowFn ws = workspaceState ws /= Empty- fallbackShownCount = length $ filter (fallbackShowFn . fst) orderedEntries- (finalShowFn, finalShownCount, fallbackUsed) =- if primaryShownCount == 0 && fallbackShownCount > 0- then (fallbackShowFn, fallbackShownCount, True)- else (primaryShowFn, primaryShownCount, False)-- when (fallbackUsed && not (null orderedEntries)) $- liftIO $ wLog WARNING $- printf- "Hyprland workspaces: showWorkspaceFn hid all %d workspaces; falling back to showing non-empty workspaces (including special:*). %s"- (length orderedEntries)- (summarizeWorkspaces (map fst orderedEntries))- when (finalShownCount == 0 && not (null orderedEntries)) $- liftIO $ wLog WARNING $- printf- "Hyprland workspaces widget is blank: no workspaces passed the show filter (total=%d). %s"- (length orderedEntries)- (summarizeWorkspaces (map fst orderedEntries))-- -- Remove wrappers for workspaces that disappeared.- let removed = M.difference oldCache newCache- forM_ (M.elems removed) $ \entry ->- liftIO $ Gtk.containerRemove cont (hweWrapper entry)-- -- Add wrappers for newly created workspaces.- let added = M.difference newCache oldCache- forM_ (M.elems added) $ \entry -> do- liftIO $ Gtk.containerAdd cont (hweWrapper entry)- liftIO $ Gtk.widgetShowAll (hweWrapper entry)-- let desiredOrder = map (workspaceIdx . fst) orderedEntries- needsReorder =- desiredOrder /= oldOrder || not (M.null added) || not (M.null removed)- when needsReorder $ do- -- Reorder wrappers to match the order returned by hyprctl.- forM_ (zip [0 :: Int ..] orderedEntries) $ \(pos, (_ws, entry)) ->- liftIO $ Gtk.boxReorderChild cont (hweWrapper entry) (fromIntegral pos)-- -- Show/hide the controller widget without removing the wrapper from the box.- forM_ orderedEntries $ \(ws, entry) -> do- ctrlWidget <- liftIO $ hwcGetWidget (hweController entry)- if finalShowFn ws- then liftIO $ Gtk.widgetShow ctrlWidget- else liftIO $ Gtk.widgetHide ctrlWidget-- _ <- setState (HyprlandWorkspaceWidgetCache newCache)- _ <- setState (HyprlandWorkspaceOrderCache desiredOrder)- return ()--summarizeWorkspaces :: [HyprlandWorkspace] -> String-summarizeWorkspaces wss =- let summarizeOne ws =- printf "%d:%s:%s(wins=%d)"- (workspaceIdx ws)- (workspaceName ws)- (show (workspaceState ws))- (length (windows ws))- maxShown = 12- body = intercalate ", " $ map summarizeOne (take maxShown wss)- suffix = if length wss > maxShown then ", ..." else ""- in "workspaces=[" ++ body ++ suffix ++ "]"--applyUrgentState :: HyprlandWorkspacesConfig -> HyprlandWorkspace -> HyprlandWorkspace-applyUrgentState cfg ws- | urgentWorkspaceState cfg- && workspaceState ws == Hidden- && any windowUrgent (windows ws) =- ws { workspaceState = Urgent }- | otherwise = ws---- Controller types--data HyprlandLabelController = HyprlandLabelController- { hlcLabel :: Gtk.Label- , hlcLabelSetter :: HyprlandWorkspace -> TaffyIO String- }--instance HyprlandWorkspaceWidgetController HyprlandLabelController where- hwcGetWidget = Gtk.toWidget . hlcLabel- hwcUpdateWidget lc ws = do- labelText <- hlcLabelSetter lc ws- liftIO $ do- Gtk.labelSetMarkup (hlcLabel lc) (T.pack labelText)- setWorkspaceWidgetStatusClass (workspaceState ws) (hlcLabel lc)- return lc--hyprlandBuildLabelController :: HyprlandWorkspacesConfig -> HyprlandControllerConstructor-hyprlandBuildLabelController cfg ws = do- lbl <- liftIO $ Gtk.labelNew Nothing- _ <- widgetSetClassGI lbl "workspace-label"- labelText <- labelSetter cfg ws- liftIO $ Gtk.labelSetMarkup lbl (T.pack labelText)- liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) lbl- return $ HyprlandWWC $ HyprlandLabelController- { hlcLabel = lbl- , hlcLabelSetter = labelSetter cfg- }--data HyprlandIconController = HyprlandIconController- { hicIconsContainer :: Gtk.Box- , hicIconImages :: [HyprlandIconWidget]- , hicWorkspace :: HyprlandWorkspace- , hicConfig :: HyprlandWorkspacesConfig- }--instance HyprlandWorkspaceWidgetController HyprlandIconController where- hwcGetWidget = Gtk.toWidget . hicIconsContainer- hwcUpdateWidget ic ws = do- newImages <-- if windows ws /= windows (hicWorkspace ic)- then updateIcons (hicConfig ic) ws (hicIconsContainer ic) (hicIconImages ic)- else return (hicIconImages ic)- return ic { hicIconImages = newImages, hicWorkspace = ws }--hyprlandBuildIconController :: HyprlandWorkspacesConfig -> HyprlandControllerConstructor-hyprlandBuildIconController cfg ws = do- iconsBox <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal 0- icons <- updateIcons cfg ws iconsBox []- return $ HyprlandWWC $ HyprlandIconController- { hicIconsContainer = iconsBox- , hicIconImages = icons- , hicWorkspace = ws- , hicConfig = cfg- }--data HyprlandContentsController = HyprlandContentsController- { hccContainerWidget :: Gtk.Widget- , hccControllers :: [HyprlandWWC]- }--instance HyprlandWorkspaceWidgetController HyprlandContentsController where- hwcGetWidget = return . hccContainerWidget- hwcUpdateWidget cc ws = do- liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) (hccContainerWidget cc)- newControllers <- mapM (`hwcUpdateWidget` ws) (hccControllers cc)- return cc { hccControllers = newControllers }--hyprlandBuildContentsController ::- [HyprlandControllerConstructor] -> HyprlandControllerConstructor-hyprlandBuildContentsController constructors ws = do- controllers <- mapM ($ ws) constructors- widgets <- liftIO $ mapM hwcGetWidget controllers- widget <- liftIO $ do- cons <- Gtk.boxNew Gtk.OrientationHorizontal 0- mapM_ (Gtk.containerAdd cons) widgets- _ <- widgetSetClassGI cons "contents"- Gtk.toWidget cons- liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) widget- return $ HyprlandWWC $ HyprlandContentsController- { hccContainerWidget = widget- , hccControllers = controllers- }--hyprlandBuildLabelOverlayController ::- HyprlandWorkspacesConfig -> HyprlandControllerConstructor-hyprlandBuildLabelOverlayController cfg ws = do- iconCtrl <- hyprlandBuildIconController cfg ws- labelCtrl <- hyprlandBuildLabelController cfg ws- iconWidget <- liftIO $ hwcGetWidget iconCtrl- labelWidget <- liftIO $ hwcGetWidget labelCtrl- widget <- buildWorkspaceIconLabelOverlay iconWidget labelWidget- liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) widget- return $ HyprlandWWC $ HyprlandContentsController- { hccContainerWidget = widget- , hccControllers = [iconCtrl, labelCtrl]- }---- | Like 'hyprlandBuildLabelOverlayController' but accepts a custom function--- to combine the icon and label widgets into a single container widget.-hyprlandBuildCustomOverlayController ::- (Gtk.Widget -> Gtk.Widget -> TaffyIO Gtk.Widget)- -> HyprlandWorkspacesConfig -> HyprlandControllerConstructor-hyprlandBuildCustomOverlayController combiner cfg ws = do- iconCtrl <- hyprlandBuildIconController cfg ws- labelCtrl <- hyprlandBuildLabelController cfg ws- iconWidget <- liftIO $ hwcGetWidget iconCtrl- labelWidget <- liftIO $ hwcGetWidget labelCtrl- widget <- combiner iconWidget labelWidget- liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) widget- return $ HyprlandWWC $ HyprlandContentsController- { hccContainerWidget = widget- , hccControllers = [iconCtrl, labelCtrl]- }--hyprlandDefaultBuildContentsController ::- HyprlandWorkspacesConfig -> HyprlandControllerConstructor-hyprlandDefaultBuildContentsController = hyprlandBuildLabelOverlayController--data HyprlandButtonController = HyprlandButtonController- { hbcButton :: Gtk.EventBox- , hbcWorkspaceRef :: IORef HyprlandWorkspace- , hbcContentsController :: HyprlandWWC- }--instance HyprlandWorkspaceWidgetController HyprlandButtonController where- hwcGetWidget = Gtk.toWidget . hbcButton- hwcUpdateWidget wbc ws = do- liftIO $ writeIORef (hbcWorkspaceRef wbc) ws- newContents <- hwcUpdateWidget (hbcContentsController wbc) ws- return wbc { hbcContentsController = newContents }--hyprlandBuildButtonController ::- HyprlandWorkspacesConfig -> HyprlandParentControllerConstructor-hyprlandBuildButtonController cfg contentsBuilder ws = do- cc <- contentsBuilder ws- ctx <- ask- contentsWidget <- liftIO $ hwcGetWidget cc- wsRef <- liftIO $ newIORef ws- ebox <- liftIO $ do- eb <- Gtk.eventBoxNew- Gtk.eventBoxSetVisibleWindow eb False- Gtk.containerAdd eb contentsWidget- _ <- Gtk.onWidgetButtonPressEvent eb $ const $ do- wsCurrent <- readIORef wsRef- runReaderT (switchToWorkspace cfg wsCurrent) ctx- return True- return eb- return $ HyprlandWWC $ HyprlandButtonController- { hbcButton = ebox- , hbcWorkspaceRef = wsRef- , hbcContentsController = cc- }--defaultHyprlandWidgetBuilder :: HyprlandWorkspacesConfig -> HyprlandControllerConstructor-defaultHyprlandWidgetBuilder cfg =- hyprlandBuildButtonController cfg (hyprlandDefaultBuildContentsController cfg)--updateIcons ::- HyprlandWorkspacesConfig- -> HyprlandWorkspace- -> Gtk.Box- -> [HyprlandIconWidget]- -> ReaderT Context IO [HyprlandIconWidget]-updateIcons cfg ws iconsBox iconWidgets = do- sortedWindows <- iconSort cfg $ windows ws- let (effectiveMinIcons, _targetLen, paddedWindows) =- computeIconStripLayout (minIcons cfg) (maxIcons cfg) sortedWindows- buildOne i = buildIconWidget (i < effectiveMinIcons) cfg- syncWidgetPool iconsBox iconWidgets paddedWindows buildOne iconContainer updateIconWidget--buildIconWidget :: Bool -> HyprlandWorkspacesConfig -> ReaderT Context IO HyprlandIconWidget-buildIconWidget transparentOnNone cfg = do- ctx <- ask- strategy <- getScalingImageStrategy- liftIO $- mkWorkspaceIconWidget- strategy- (Just $ iconSize cfg)- transparentOnNone- (\size w -> runReaderT (getWindowIconPixbuf cfg size w) ctx)- (`pixBufFromColor` 0)--updateIconWidget :: HyprlandIconWidget -> Maybe HyprlandWindow -> ReaderT Context IO ()-updateIconWidget iconWidget windowData =- updateWindowIconWidgetState- iconWidget- windowData- (T.pack . windowTitle)- getWindowStatusString---getWindowStatusString :: HyprlandWindow -> T.Text-getWindowStatusString windowData =- windowStatusClassFromFlags- (windowMinimized windowData)- (windowActive windowData)- (windowUrgent windowData)---- | Sort windows by their top-left corner position.------ This mirrors the X11 Workspaces widget default ('sortWindowsByPosition'),--- but uses Hyprland's @at@ coordinate from @hyprctl clients -j@.-sortHyprlandWindowsByPosition :: [HyprlandWindow] -> [HyprlandWindow]-sortHyprlandWindowsByPosition =- sortOn $ \w ->- ( windowMinimized w- , fromMaybe (999999999, 999999999) (windowAt w)- )--wLog :: MonadIO m => Priority -> String -> m ()-wLog l s = liftIO $ logM "System.Taffybar.Widget.HyprlandWorkspaces" l s---- Window icon lookup--scaledWindowIconPixbufGetter ::- HyprlandWindowIconPixbufGetter -> HyprlandWindowIconPixbufGetter-scaledWindowIconPixbufGetter = scaledPixbufGetter--handleIconGetterException ::- HyprlandWindowIconPixbufGetter -> HyprlandWindowIconPixbufGetter-handleIconGetterException = handlePixbufGetterException wLog--defaultHyprlandGetWindowIconPixbuf :: HyprlandWindowIconPixbufGetter-defaultHyprlandGetWindowIconPixbuf =- scaledWindowIconPixbufGetter $- getWindowIconPixbufFromDesktopEntry <|||> getWindowIconPixbufFromClass--getWindowIconPixbufFromClass :: HyprlandWindowIconPixbufGetter-getWindowIconPixbufFromClass size windowData =- maybeTCombine- (maybe (return Nothing) (liftIO . getWindowIconFromClasses size) (windowClass windowData))- (maybe (return Nothing) (liftIO . getWindowIconFromClasses size) (windowInitialClass windowData))--getWindowIconPixbufFromDesktopEntry :: HyprlandWindowIconPixbufGetter-getWindowIconPixbufFromDesktopEntry =- handleIconGetterException $ \size windowData ->- maybeTCombine- (maybe (return Nothing) (getWindowIconFromDesktopEntryByAppId size) (windowClass windowData))- (maybe (return Nothing) (getWindowIconFromDesktopEntryByAppId size) (windowInitialClass windowData))--getDirectoryEntriesByAppId :: TaffyIO (MM.MultiMap String DesktopEntry)-getDirectoryEntriesByAppId = getStateDefault readDirectoryEntriesByAppId--readDirectoryEntriesByAppId :: TaffyIO (MM.MultiMap String DesktopEntry)-readDirectoryEntriesByAppId =- liftIO $ indexDesktopEntriesByAppId <$> getDirectoryEntriesDefault--indexDesktopEntriesByAppId :: [DesktopEntry] -> MM.MultiMap String DesktopEntry-indexDesktopEntriesByAppId =- F.foldl' (\m de -> MM.insert (normalizeAppId $ deFilename de) de m) MM.empty--normalizeAppId :: String -> String-normalizeAppId name =- let stripped = fromMaybe name (stripSuffix ".desktop" name)- in map toLower stripped--getWindowIconFromDesktopEntryByAppId ::- Int32 -> String -> TaffyIO (Maybe Gdk.Pixbuf)-getWindowIconFromDesktopEntryByAppId size appId = do- entries <- MM.lookup (normalizeAppId appId) <$> getDirectoryEntriesByAppId- case entries of- [] -> return Nothing- (entry:_) -> do- liftIO $ logM "System.Taffybar.Widget.HyprlandWorkspaces" DEBUG $- printf "Using desktop entry for icon %s (appId=%s)"- (deFilename entry) appId- liftIO $ getImageForDesktopEntry size entry---- Hyprland backend--data HyprlandWorkspaceRef = HyprlandWorkspaceRef- { hwrId :: Int- , hwrName :: Text- } deriving (Show, Eq)--instance FromJSON HyprlandWorkspaceRef where- parseJSON = withObject "HyprlandWorkspaceRef" $ \v ->- HyprlandWorkspaceRef- <$> v .: "id"- <*> v .: "name"--data HyprlandWorkspaceInfo = HyprlandWorkspaceInfo- { hwiId :: Int- , hwiName :: Text- , hwiWindows :: Int- } deriving (Show, Eq)--instance FromJSON HyprlandWorkspaceInfo where- parseJSON = withObject "HyprlandWorkspaceInfo" $ \v ->- HyprlandWorkspaceInfo- <$> v .: "id"- <*> v .: "name"- <*> v .:? "windows" .!= 0--data HyprlandMonitorInfo = HyprlandMonitorInfo- { hmFocused :: Bool- , hmActiveWorkspace :: Maybe HyprlandWorkspaceRef- } deriving (Show, Eq)--instance FromJSON HyprlandMonitorInfo where- parseJSON = withObject "HyprlandMonitorInfo" $ \v ->- HyprlandMonitorInfo- <$> v .:? "focused" .!= False- <*> v .:? "activeWorkspace"--data HyprlandClient = HyprlandClient- { hcAddress :: Text- , hcTitle :: Text- , hcInitialTitle :: Maybe Text- , hcClass :: Maybe Text- , hcInitialClass :: Maybe Text- , hcAt :: Maybe (Int, Int)- , hcWorkspace :: HyprlandWorkspaceRef- , hcFocused :: Bool- , hcHidden :: Bool- , hcMapped :: Bool- , hcUrgent :: Bool- } deriving (Show, Eq)--instance FromJSON HyprlandClient where- parseJSON = withObject "HyprlandClient" $ \v ->- HyprlandClient- <$> v .: "address"- <*> v .:? "title" .!= ""- <*> v .:? "initialTitle"- <*> v .:? "class"- <*> v .:? "initialClass"- <*> (vec2FromList <$> v .:? "at")- <*> v .: "workspace"- <*> v .:? "focused" .!= False- <*> v .:? "hidden" .!= False- <*> v .:? "mapped" .!= True- <*> v .:? "urgent" .!= False- where- vec2FromList :: Maybe [Int] -> Maybe (Int, Int)- vec2FromList (Just [x, y]) = Just (x, y)- vec2FromList _ = Nothing--newtype HyprlandActiveWindow = HyprlandActiveWindow- { hawAddress :: Text- } deriving (Show, Eq)--instance FromJSON HyprlandActiveWindow where- parseJSON = withObject "HyprlandActiveWindow" $ \v ->- HyprlandActiveWindow <$> v .: "address"--runHyprctlJson :: FromJSON a => [String] -> TaffyIO (Either String a)-runHyprctlJson args = do- let args' =- case args of- ("-j":rest) -> rest- _ -> args- result <- runHyprlandCommandJsonT (Hypr.hyprCommandJson args')- pure $ case result of- Left err -> Left (show err)- Right out -> Right out--runHyprlandCommandRaw :: [String] -> TaffyIO (Either String BS.ByteString)-runHyprlandCommandRaw args = do- let cmd =- case args of- ("-j":rest) -> Hypr.hyprCommandJson rest- _ -> Hypr.hyprCommand args- result <- runHyprlandCommandRawT cmd- pure $ case result of- Left err -> Left (show err)- Right out -> Right out--hyprlandSwitchToWorkspace :: HyprlandWorkspace -> TaffyIO ()-hyprlandSwitchToWorkspace ws = do- result <- runHyprlandCommandRaw ["dispatch", "workspace", workspaceName ws]- case result of- Left err -> wLog WARNING $ printf "Failed to switch workspace: %s" err- Right _ -> return ()--getHyprlandWorkspaces :: TaffyIO [HyprlandWorkspace]-getHyprlandWorkspaces = do- workspacesResult <- runHyprctlJson ["-j", "workspaces"]- clientsResult <- runHyprctlJson ["-j", "clients"]- monitorsResult <- runHyprctlJson ["-j", "monitors"]- activeWorkspaceResult <- runHyprctlJson ["-j", "activeworkspace"]- activeWindowAddress <- getActiveWindowAddress-- workspaces <- case workspacesResult of- Left err -> wLog WARNING (printf "hyprctl workspaces failed: %s" err) >> return []- Right ws -> return ws-- let workspacesCount = length workspaces- workspacesWindowsSum = sum (map hwiWindows workspaces)- (clientsOk, clients) <- case clientsResult of- Left err -> do- wLog WARNING $- printf- "hyprctl clients failed: %s (workspaces=%d windowsSum=%d)"- err- workspacesCount- workspacesWindowsSum- return (False, [])- Right cs -> return (True, cs)-- monitors <- case monitorsResult of- Left err -> wLog WARNING (printf "hyprctl monitors failed: %s" err) >> return []- Right ms -> return ms-- activeWorkspace <- case activeWorkspaceResult of- Left err -> wLog WARNING (printf "hyprctl activeworkspace failed: %s" err) >> return Nothing- Right ws -> return $ Just ws-- buildWorkspacesFromHyprland- workspaces- clientsOk- clients- monitors- activeWorkspace- activeWindowAddress--getActiveWindowAddress :: TaffyIO (Maybe Text)-getActiveWindowAddress = do- result <- runHyprctlJson ["-j", "activewindow"]- case result of- Left _ -> return Nothing- Right activeWindow -> return $ Just $ hawAddress activeWindow--buildWorkspacesFromHyprland ::- [HyprlandWorkspaceInfo]- -> Bool- -> [HyprlandClient]- -> [HyprlandMonitorInfo]- -> Maybe HyprlandWorkspaceRef- -> Maybe Text- -> TaffyIO [HyprlandWorkspace]-buildWorkspacesFromHyprland workspaces clientsOk clients monitors activeWorkspace activeWindowAddress = do- let windowsByWorkspace = collectWorkspaceWindows activeWindowAddress clients- sortedWorkspaces = sortOn hwiId workspaces- visibleWorkspaceIds =- map hwrId $ mapMaybe hmActiveWorkspace monitors- focusedWorkspaceId =- listToMaybe [ hwrId ws- | m <- monitors- , hmFocused m- , Just ws <- [hmActiveWorkspace m]- ]- activeWorkspaceId =- focusedWorkspaceId <|> fmap hwrId activeWorkspace-- return $ map (buildWorkspace windowsByWorkspace visibleWorkspaceIds activeWorkspaceId) sortedWorkspaces- where- buildWorkspace windowsMap visibleIds activeId wsInfo =- let wsId = hwiId wsInfo- wsName = hwiName wsInfo- wins = M.findWithDefault [] wsId windowsMap- hasWindows = not (null wins) || hwiWindows wsInfo > 0- state- | Just wsId == activeId = Active- | wsId `elem` visibleIds = Visible- -- If we couldn't fetch clients, don't aggressively hide workspaces.- -- This prevents "invisible" workspaces on transient Hyprland restarts.- | not hasWindows && clientsOk = Empty- | otherwise = Hidden- in HyprlandWorkspace- { workspaceIdx = wsId- , workspaceName = T.unpack wsName- , workspaceState = state- , windows = wins- }--collectWorkspaceWindows :: Maybe Text -> [HyprlandClient] -> M.Map Int [HyprlandWindow]-collectWorkspaceWindows activeWindowAddress =- F.foldl' (addWindow activeWindowAddress) M.empty- where- addWindow activeAddr windowsMap client =- let wsId = hwrId (hcWorkspace client)- windowData = windowFromClient activeAddr client- in M.insertWith (++) wsId [windowData] windowsMap--windowFromClient :: Maybe Text -> HyprlandClient -> HyprlandWindow-windowFromClient activeAddr client =- let titleText =- if T.null (hcTitle client)- then fromMaybe "" (hcInitialTitle client)- else hcTitle client- active =- hcFocused client || Just (hcAddress client) == activeAddr- minimized =- hcHidden client || not (hcMapped client)- in HyprlandWindow- { windowAddress = hcAddress client- , windowTitle = T.unpack titleText- , windowClass = T.unpack <$> hcClass client- , windowInitialClass = T.unpack <$> hcInitialClass client- , windowAt = hcAt client- , windowUrgent = hcUrgent client- , windowActive = active- , windowMinimized = minimized- }
+ src/System/Taffybar/Widget/ImageCommandButton.hs view
@@ -0,0 +1,80 @@+--------------------------------------------------------------------------------++--------------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.ImageCommandButton+-- Copyright : (c) Ulf Jasper+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ulf Jasper <ulf.jasper@web.de>+-- Stability : unstable+-- Portability : unportable+--+-- Simple button which shows an image and runs a user defined command when+-- being clicked.+module System.Taffybar.Widget.ImageCommandButton+ ( -- * Usage+ -- $usage+ imageCommandButtonNew,+ imageCommandButtonNewFromName,+ )+where++import Control.Monad (void)+import Control.Monad.IO.Class+import qualified Data.Text as T+import GI.Gtk+import System.Process++-- $usage+--+-- In order to use this widget add the following lines to your+-- @taffybar.hs@ file:+--+-- > import System.Taffybar.Widget+-- > main = do+-- > let fileIconButton = imageCommandButtonNew "/path/to/icon.png" "xterm"+-- > themedIconButton = imageCommandButtonNewFromName "system-shutdown" "systemctl poweroff"+--+-- Now you can use these widgets like any other Taffybar widget.++-- | Creates a new image command button using an icon from a file path.+imageCommandButtonNew ::+ (MonadIO m) =>+ -- | File path of the image.+ FilePath ->+ -- | Command to execute. Should be in $PATH or an absolute path.+ T.Text ->+ m Widget+imageCommandButtonNew path cmd = do+ image <- imageNewFromFile path+ imageCommandButtonWithImage image cmd++-- | Creates a new image command button using an icon from the current GTK+-- theme.+imageCommandButtonNewFromName ::+ (MonadIO m) =>+ -- | Name of the icon in the current GTK icon theme.+ T.Text ->+ -- | Command to execute. Should be in $PATH or an absolute path.+ T.Text ->+ m Widget+imageCommandButtonNewFromName iconName cmd = do+ image <-+ imageNewFromIconName+ (Just iconName)+ (fromIntegral $ fromEnum IconSizeMenu)+ imageCommandButtonWithImage image cmd++imageCommandButtonWithImage ::+ (MonadIO m) =>+ Image ->+ T.Text ->+ m Widget+imageCommandButtonWithImage image cmd = do+ button <- buttonNew+ containerAdd button image+ void $ onButtonClicked button $ void $ spawnCommand $ T.unpack cmd+ widgetShowAll button+ toWidget button
src/System/Taffybar/Widget/Inhibitor.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Inhibitor -- Copyright : (c) Ivan A. Malison@@ -27,60 +31,64 @@ -- > , inhibitorActiveText = "AWAKE" -- > , inhibitorInactiveText = "zzz" -- > }------------------------------------------------------------------------------ module System.Taffybar.Widget.Inhibitor ( -- * Widget constructors- inhibitorNew- , inhibitorNewWithConfig- , inhibitorIconNew- , inhibitorIconNewWithConfig- , inhibitorLabelNew- , inhibitorLabelNewWithConfig+ inhibitorNew,+ inhibitorNewWithConfig,+ inhibitorIconNew,+ inhibitorIconNewWithConfig,+ inhibitorLabelNew,+ inhibitorLabelNewWithConfig,+ -- * Configuration- , InhibitorConfig(..)- , defaultInhibitorConfig+ InhibitorConfig (..),+ defaultInhibitorConfig,+ -- * Re-exports- , InhibitType(..)- ) where+ InhibitType (..),+ )+where -import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Reader+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Reader import qualified Data.Text as T import qualified GI.Gdk as Gdk-import GI.Gtk as Gtk-import System.Taffybar.Context-import System.Taffybar.Information.Inhibitor-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Util+import GI.Gtk as Gtk+import System.Taffybar.Context+import System.Taffybar.Information.Inhibitor+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Util -- | Configuration for the inhibitor widget data InhibitorConfig = InhibitorConfig { -- | What types of inhibitors to manage (default: [InhibitIdle])- inhibitWhat :: [InhibitType]+ inhibitWhat :: [InhibitType], -- | Text to display when inhibitor is active- , inhibitorActiveText :: T.Text+ inhibitorActiveText :: T.Text, -- | Text to display when inhibitor is inactive- , inhibitorInactiveText :: T.Text+ inhibitorInactiveText :: T.Text, -- | Icon to display when inhibitor is active (default: U+F0F4, nf-fa-coffee)- , inhibitorActiveIcon :: T.Text+ inhibitorActiveIcon :: T.Text, -- | Icon to display when inhibitor is inactive (default: U+F236, nf-fa-bed)- , inhibitorInactiveIcon :: T.Text+ inhibitorInactiveIcon :: T.Text, -- | CSS class prefix (results in "prefix-active" and "prefix-inactive")- , inhibitorCssPrefix :: T.Text- } deriving (Eq, Show)+ inhibitorCssPrefix :: T.Text+ }+ deriving (Eq, Show) -- | Default configuration: inhibits idle, shows simple icon-style text defaultInhibitorConfig :: InhibitorConfig-defaultInhibitorConfig = InhibitorConfig- { inhibitWhat = [InhibitIdle]- , inhibitorActiveText = "INHIBIT"- , inhibitorInactiveText = "inhibit"- , inhibitorActiveIcon = T.pack "\xF0F4"- , inhibitorInactiveIcon = T.pack "\xF236"- , inhibitorCssPrefix = "inhibitor"- }+defaultInhibitorConfig =+ InhibitorConfig+ { inhibitWhat = [InhibitIdle],+ inhibitorActiveText = "INHIBIT",+ inhibitorInactiveText = "inhibit",+ inhibitorActiveIcon = T.pack "\xF0F4",+ inhibitorInactiveIcon = T.pack "\xF236",+ inhibitorCssPrefix = "inhibitor"+ } -- | Create a combined icon+label inhibitor widget with default configuration inhibitorNew :: TaffyIO Widget@@ -124,11 +132,12 @@ ctx <- ask liftIO $ do label <- labelNew Nothing- let updateIcon state = postGUIASync $- labelSetText label $- if inhibitorActive state- then inhibitorActiveIcon config- else inhibitorInactiveIcon config+ let updateIcon state =+ postGUIASync $+ labelSetText label $+ if inhibitorActive state+ then inhibitorActiveIcon config+ else inhibitorInactiveIcon config void $ onWidgetRealize label $ do initialState <- runReaderT (getInhibitorState types) ctx updateIcon initialState@@ -168,14 +177,16 @@ let updateWidget state = postGUIASync $ do let (text, activeClass, inactiveClass) = if inhibitorActive state- then ( inhibitorActiveText config- , inhibitorCssPrefix config <> "-active"- , inhibitorCssPrefix config <> "-inactive"- )- else ( inhibitorInactiveText config- , inhibitorCssPrefix config <> "-inactive"- , inhibitorCssPrefix config <> "-active"- )+ then+ ( inhibitorActiveText config,+ inhibitorCssPrefix config <> "-active",+ inhibitorCssPrefix config <> "-inactive"+ )+ else+ ( inhibitorInactiveText config,+ inhibitorCssPrefix config <> "-inactive",+ inhibitorCssPrefix config <> "-active"+ ) labelSetText label text addClassIfMissing activeClass ebox removeClassIfPresent inactiveClass ebox
src/System/Taffybar/Widget/KeyboardState.hs view
@@ -1,6 +1,9 @@ {-# LANGUAGE OverloadedStrings #-} --------------------------------------------------------------------------------++--------------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.KeyboardState -- Copyright : (c) Ivan Malison@@ -12,23 +15,23 @@ -- -- A widget that displays keyboard lock key states (Caps Lock, Num Lock, -- Scroll Lock). States are read from sysfs LED brightness files.------------------------------------------------------------------------------------- module System.Taffybar.Widget.KeyboardState ( -- * Widget Constructors- keyboardStateNew- , keyboardStateNewWithConfig- , keyboardStateLabelNew- , keyboardStateLabelNewWithConfig- , keyboardStateIconNew- , keyboardStateIconNewWithConfig+ keyboardStateNew,+ keyboardStateNewWithConfig,+ keyboardStateLabelNew,+ keyboardStateLabelNewWithConfig,+ keyboardStateIconNew,+ keyboardStateIconNewWithConfig,+ -- * Configuration- , KeyboardStateConfig(..)- , defaultKeyboardStateConfig+ KeyboardStateConfig (..),+ defaultKeyboardStateConfig,+ -- * Format Functions- , formatKeyboardState- ) where+ formatKeyboardState,+ )+where import Control.Monad.IO.Class (MonadIO, liftIO) import qualified Data.Text as T@@ -39,92 +42,99 @@ -- | Configuration for the keyboard state widget. data KeyboardStateConfig = KeyboardStateConfig- { kscCapsLockOnText :: T.Text- -- ^ Text to display when Caps Lock is on. Default: "[CAPS]"- , kscCapsLockOffText :: T.Text- -- ^ Text to display when Caps Lock is off. Default: ""- , kscNumLockOnText :: T.Text- -- ^ Text to display when Num Lock is on. Default: "[NUM]"- , kscNumLockOffText :: T.Text- -- ^ Text to display when Num Lock is off. Default: ""- , kscScrollLockOnText :: T.Text- -- ^ Text to display when Scroll Lock is on. Default: "[SCROLL]"- , kscScrollLockOffText :: T.Text- -- ^ Text to display when Scroll Lock is off. Default: ""- , kscShowCapsLock :: Bool- -- ^ Whether to show Caps Lock state. Default: True- , kscShowNumLock :: Bool- -- ^ Whether to show Num Lock state. Default: False- , kscShowScrollLock :: Bool- -- ^ Whether to show Scroll Lock state. Default: False- , kscSeparator :: T.Text- -- ^ Separator between lock indicators. Default: " "- , kscPollingInterval :: Double- -- ^ Polling interval in seconds. Default: 0.5- , kscIcon :: T.Text- -- ^ Icon text for the icon widget variant. Default: keyboard icon (nf-md-keyboard)- } deriving (Eq, Show)+ { -- | Text to display when Caps Lock is on. Default: "[CAPS]"+ kscCapsLockOnText :: T.Text,+ -- | Text to display when Caps Lock is off. Default: ""+ kscCapsLockOffText :: T.Text,+ -- | Text to display when Num Lock is on. Default: "[NUM]"+ kscNumLockOnText :: T.Text,+ -- | Text to display when Num Lock is off. Default: ""+ kscNumLockOffText :: T.Text,+ -- | Text to display when Scroll Lock is on. Default: "[SCROLL]"+ kscScrollLockOnText :: T.Text,+ -- | Text to display when Scroll Lock is off. Default: ""+ kscScrollLockOffText :: T.Text,+ -- | Whether to show Caps Lock state. Default: True+ kscShowCapsLock :: Bool,+ -- | Whether to show Num Lock state. Default: False+ kscShowNumLock :: Bool,+ -- | Whether to show Scroll Lock state. Default: False+ kscShowScrollLock :: Bool,+ -- | Separator between lock indicators. Default: " "+ kscSeparator :: T.Text,+ -- | Polling interval in seconds. Default: 0.5+ kscPollingInterval :: Double,+ -- | Icon text for the icon widget variant. Default: keyboard icon (nf-md-keyboard)+ kscIcon :: T.Text+ }+ deriving (Eq, Show) -- | Default configuration for the keyboard state widget. -- Shows only Caps Lock by default, polling every 0.5 seconds. defaultKeyboardStateConfig :: KeyboardStateConfig-defaultKeyboardStateConfig = KeyboardStateConfig- { kscCapsLockOnText = "[CAPS]"- , kscCapsLockOffText = ""- , kscNumLockOnText = "[NUM]"- , kscNumLockOffText = ""- , kscScrollLockOnText = "[SCROLL]"- , kscScrollLockOffText = ""- , kscShowCapsLock = True- , kscShowNumLock = False- , kscShowScrollLock = False- , kscSeparator = " "- , kscPollingInterval = 0.5- , kscIcon = T.pack "\xF80B"- }+defaultKeyboardStateConfig =+ KeyboardStateConfig+ { kscCapsLockOnText = "[CAPS]",+ kscCapsLockOffText = "",+ kscNumLockOnText = "[NUM]",+ kscNumLockOffText = "",+ kscScrollLockOnText = "[SCROLL]",+ kscScrollLockOffText = "",+ kscShowCapsLock = True,+ kscShowNumLock = False,+ kscShowScrollLock = False,+ kscSeparator = " ",+ kscPollingInterval = 0.5,+ kscIcon = T.pack "\xF80B"+ } -- | Format the keyboard state according to the configuration. formatKeyboardState :: KeyboardStateConfig -> KeyboardState -> T.Text formatKeyboardState cfg state =- let parts = filter (not . T.null)- [ if kscShowCapsLock cfg- then if capsLock state- then kscCapsLockOnText cfg- else kscCapsLockOffText cfg- else ""- , if kscShowNumLock cfg- then if numLock state- then kscNumLockOnText cfg- else kscNumLockOffText cfg- else ""- , if kscShowScrollLock cfg- then if scrollLock state- then kscScrollLockOnText cfg- else kscScrollLockOffText cfg- else ""- ]- in T.intercalate (kscSeparator cfg) parts+ let parts =+ filter+ (not . T.null)+ [ if kscShowCapsLock cfg+ then+ if capsLock state+ then kscCapsLockOnText cfg+ else kscCapsLockOffText cfg+ else "",+ if kscShowNumLock cfg+ then+ if numLock state+ then kscNumLockOnText cfg+ else kscNumLockOffText cfg+ else "",+ if kscShowScrollLock cfg+ then+ if scrollLock state+ then kscScrollLockOnText cfg+ else kscScrollLockOffText cfg+ else ""+ ]+ in T.intercalate (kscSeparator cfg) parts -- | Create a keyboard state label widget with default configuration. -- Shows Caps Lock status, polling every 0.5 seconds.-keyboardStateLabelNew :: MonadIO m => m Gtk.Widget+keyboardStateLabelNew :: (MonadIO m) => m Gtk.Widget keyboardStateLabelNew = keyboardStateLabelNewWithConfig defaultKeyboardStateConfig -- | Create a keyboard state label widget with custom configuration. -- Uses PollingLabel to periodically update the display.-keyboardStateLabelNewWithConfig :: MonadIO m => KeyboardStateConfig -> m Gtk.Widget+keyboardStateLabelNewWithConfig :: (MonadIO m) => KeyboardStateConfig -> m Gtk.Widget keyboardStateLabelNewWithConfig cfg = liftIO $ do widget <- pollingLabelNew (kscPollingInterval cfg) (getFormattedState cfg) _ <- widgetSetClassGI widget "keyboard-state" return widget -- | Create a keyboard state icon widget with default configuration.-keyboardStateIconNew :: MonadIO m => m Gtk.Widget+keyboardStateIconNew :: (MonadIO m) => m Gtk.Widget keyboardStateIconNew = keyboardStateIconNewWithConfig defaultKeyboardStateConfig -- | Create a keyboard state icon widget with custom configuration. -- Displays a static icon label.-keyboardStateIconNewWithConfig :: MonadIO m => KeyboardStateConfig -> m Gtk.Widget+keyboardStateIconNewWithConfig :: (MonadIO m) => KeyboardStateConfig -> m Gtk.Widget keyboardStateIconNewWithConfig cfg = liftIO $ do label <- Gtk.labelNew (Just (kscIcon cfg)) Gtk.widgetShowAll label@@ -132,12 +142,12 @@ -- | Create a combined keyboard state widget (icon + label) with default -- configuration.-keyboardStateNew :: MonadIO m => m Gtk.Widget+keyboardStateNew :: (MonadIO m) => m Gtk.Widget keyboardStateNew = keyboardStateNewWithConfig defaultKeyboardStateConfig -- | Create a combined keyboard state widget (icon + label) with custom -- configuration.-keyboardStateNewWithConfig :: MonadIO m => KeyboardStateConfig -> m Gtk.Widget+keyboardStateNewWithConfig :: (MonadIO m) => KeyboardStateConfig -> m Gtk.Widget keyboardStateNewWithConfig cfg = do iconWidget <- keyboardStateIconNewWithConfig cfg labelWidget <- keyboardStateLabelNewWithConfig cfg
src/System/Taffybar/Widget/Layout.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Layout -- Copyright : (c) Ivan Malison@@ -13,27 +17,25 @@ -- workspace, and that allows to change it by clicking with the mouse: -- left-click to switch to the next layout in the list, right-click to switch to -- the first one (as configured in @xmonad.hs@)------------------------------------------------------------------------------- module System.Taffybar.Widget.Layout- (- -- * Usage- -- $usage- LayoutConfig(..)- , defaultLayoutConfig- , layoutNew- ) where+ ( -- * Usage+ -- $usage+ LayoutConfig (..),+ defaultLayoutConfig,+ layoutNew,+ )+where -import Control.Monad.Trans.Class-import Control.Monad.Trans.Reader-import Data.Default (Default(..))+import Control.Monad.Trans.Class+import Control.Monad.Trans.Reader+import Data.Default (Default (..)) import qualified Data.Text as T+import GI.Gdk import qualified GI.Gtk as Gtk-import GI.Gdk-import System.Taffybar.Context-import System.Taffybar.Information.X11DesktopInfo-import System.Taffybar.Util-import System.Taffybar.Widget.Util+import System.Taffybar.Context+import System.Taffybar.Information.X11DesktopInfo+import System.Taffybar.Util+import System.Taffybar.Widget.Util -- $usage --@@ -54,10 +56,12 @@ -- -- now you can use @los@ as any other Taffybar widget. +-- | Configuration for how the current layout name is rendered. newtype LayoutConfig = LayoutConfig { formatLayout :: T.Text -> TaffyIO T.Text } +-- | Default layout formatting: display the layout name unchanged. defaultLayoutConfig :: LayoutConfig defaultLayoutConfig = LayoutConfig return @@ -102,10 +106,10 @@ buttonNumber <- getEventButtonButton btn case pressType of EventTypeButtonPress ->- case buttonNumber of- 1 -> runReaderT (runX11Def () (switch 1)) context >> return True- 2 -> runReaderT (runX11Def () (switch (-1))) context >> return True- _ -> return False+ case buttonNumber of+ 1 -> runReaderT (runX11Def () (switch 1)) context >> return True+ 2 -> runReaderT (runX11Def () (switch (-1))) context >> return True+ _ -> return False _ -> return False -- | Emit a new custom event of type _XMONAD_CURRENT_LAYOUT, that can be
src/System/Taffybar/Widget/MPRIS2.hs view
@@ -1,7 +1,11 @@+{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-}-{-# LANGUAGE FlexibleContexts #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.MPRIS2 -- Copyright : (c) Ivan A. Malison@@ -14,49 +18,50 @@ -- This is a "Now Playing" widget that listens for MPRIS2 events on DBus. You -- can find the MPRIS2 specification here at -- (<https://specifications.freedesktop.org/mpris-spec/latest/>).------------------------------------------------------------------------------ module System.Taffybar.Widget.MPRIS2 where -import Control.Arrow+import Control.Arrow import qualified Control.Concurrent.MVar as MV-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Class-import Control.Monad.Trans.Except-import Control.Monad.Trans.Reader-import DBus-import DBus.Client+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Class+import Control.Monad.Trans.Except+import Control.Monad.Trans.Reader+import DBus+import DBus.Client import qualified DBus.TH as DBus-import Data.Default (Default(..))-import Data.GI.Base.Overloading (IsDescendantOf)-import Data.Int-import Data.List+import Data.Default (Default (..))+import Data.GI.Base.Overloading (IsDescendantOf)+import Data.Int+import Data.List import qualified Data.Map as M import qualified Data.Text as T import qualified GI.GLib as G-import GI.GdkPixbuf.Objects.Pixbuf as Gdk+import GI.GdkPixbuf.Objects.Pixbuf as Gdk import qualified GI.Gtk as Gtk-import System.Environment.XDG.DesktopEntry-import System.Log.Logger-import System.Taffybar.Context+import System.Environment.XDG.DesktopEntry+import System.Log.Logger+import System.Taffybar.Context import qualified System.Taffybar.DBus.Client.MPRIS2 as MPRIS2DBus-import System.Taffybar.Information.MPRIS2-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.AutoSizeImage-import System.Taffybar.Widget.Util-import System.Taffybar.WindowIcon-import Text.Printf+import System.Taffybar.Information.MPRIS2+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.AutoSizeImage+import System.Taffybar.Widget.Util+import System.Taffybar.WindowIcon+import Text.Printf +-- | Log helper for this module. mprisLog :: (MonadIO m, Show t) => Priority -> String -> t -> m () mprisLog = logPrintF "System.Taffybar.Widget.MPRIS2" -- | A type representing a function that produces an IO action that adds the -- provided widget to some container. type WidgetAdder a m =- (IsDescendantOf Gtk.Widget a- , MonadIO m- , Gtk.GObject a- ) => a -> m ()+ ( IsDescendantOf Gtk.Widget a,+ MonadIO m,+ Gtk.GObject a+ ) =>+ a -> m () -- | The type of a customization function that is used to update a widget with -- the provided now playing info. The type a should be the internal state used@@ -69,209 +74,435 @@ (forall w. WidgetAdder w IO) -> Maybe a -> Maybe NowPlaying -> TaffyIO a -- | Configuration for an MPRIS2 Widget-data MPRIS2Config a =- MPRIS2Config- {- -- | A function that will be used to wrap the outer MPRIS2 grid widget- mprisWidgetWrapper :: Gtk.Widget -> IO Gtk.Widget- -- | This function will be called to instantiate and update the player widgets- -- of each dbus player client. See the docstring for `UpdateMPRIS2PlayerWidget`- -- for more details.- , updatePlayerWidget :: UpdateMPRIS2PlayerWidget a+data MPRIS2Config a+ = MPRIS2Config+ { -- | A function that will be used to wrap the outer MPRIS2 grid widget+ mprisWidgetWrapper :: Gtk.Widget -> IO Gtk.Widget,+ -- | This function will be called to instantiate and update the player widgets+ -- of each dbus player client. See the docstring for `UpdateMPRIS2PlayerWidget`+ -- for more details.+ updatePlayerWidget :: UpdateMPRIS2PlayerWidget a } +-- | Default MPRIS2 widget configuration using 'simplePlayerWidget'. defaultMPRIS2Config :: MPRIS2Config MPRIS2PlayerWidget defaultMPRIS2Config = MPRIS2Config- { mprisWidgetWrapper = return- , updatePlayerWidget = simplePlayerWidget def- }+ { mprisWidgetWrapper = return,+ updatePlayerWidget = simplePlayerWidget def+ } +-- | Internal widget state for the default simple player renderer. data MPRIS2PlayerWidget = MPRIS2PlayerWidget- { playerLabel :: Gtk.Label- , playerWidget :: Gtk.Widget+ { playerLabel :: Gtk.Label,+ playerWidget :: Gtk.Widget } +data MPRIS2PlayerControlsWidget = MPRIS2PlayerControlsWidget+ { controlsPlayerLabel :: Gtk.Label,+ controlsPlayerWidget :: Gtk.Widget,+ controlsNowPlayingVar :: MV.MVar NowPlaying,+ controlsPreviousButton :: Gtk.Button,+ controlsPlayPauseButton :: Gtk.Button,+ controlsPlayPauseButtonLabel :: Gtk.Label,+ controlsNextButton :: Gtk.Button+ }++defaultMPRIS2ControlsConfig :: MPRIS2Config MPRIS2PlayerControlsWidget+defaultMPRIS2ControlsConfig =+ MPRIS2Config+ { mprisWidgetWrapper = return,+ updatePlayerWidget = simplePlayerWidgetWithControls def+ }++-- | Configuration for 'simplePlayerWidget'. data SimpleMPRIS2PlayerConfig = SimpleMPRIS2PlayerConfig- { setNowPlayingLabel :: NowPlaying -> IO T.Text- , showPlayerWidgetFn :: NowPlaying -> IO Bool+ { setNowPlayingLabel :: NowPlaying -> IO T.Text,+ showPlayerWidgetFn :: NowPlaying -> IO Bool } +-- | Default 'SimpleMPRIS2PlayerConfig'. defaultPlayerConfig :: SimpleMPRIS2PlayerConfig-defaultPlayerConfig = SimpleMPRIS2PlayerConfig- { setNowPlayingLabel = playingText 20 30- , showPlayerWidgetFn =- \NowPlaying { npStatus = status } -> return $ status /= "Stopped"- }+defaultPlayerConfig =+ SimpleMPRIS2PlayerConfig+ { setNowPlayingLabel = playingText 20 30,+ showPlayerWidgetFn =+ \NowPlaying {npStatus = status} -> return $ status /= "Stopped"+ } instance Default SimpleMPRIS2PlayerConfig where def = defaultPlayerConfig +-- | Lift a @Maybe@-producing IO function into an 'ExceptT' with a custom+-- failure message. makeExcept :: String -> (a -> IO (Maybe b)) -> a -> ExceptT String IO b makeExcept errorString actionBuilder = ExceptT . fmap (maybeToEither errorString) . actionBuilder +-- | Resolve an icon for a player bus name and load it at the requested size.+-- Falls back to a default icon (or a blank pixbuf) on errors. loadIconAtSize :: Client -> BusName -> Int32 -> IO Gdk.Pixbuf loadIconAtSize client busName size =- let- failure err =- mprisLog WARNING "Failed to load default image: %s" err >>- pixBufFromColor size 0- loadDefault =- loadIcon size "play.svg" >>= either failure return- logErrorAndLoadDefault err =- mprisLog WARNING "Failed to get MPRIS icon: %s" err >>- mprisLog WARNING "MPRIS failure for: %s" busName >>- loadDefault- chromeSpecialCase l@(Left _) =- if "chrom" `isInfixOf` formatBusName busName- then Right "google-chrome" else l- chromeSpecialCase x = x- in- either logErrorAndLoadDefault return =<<- runExceptT (ExceptT (left show . chromeSpecialCase <$> MPRIS2DBus.getDesktopEntry client busName)- >>= makeExcept "Failed to get desktop entry"- getDirectoryEntryDefault- >>= makeExcept "Failed to get image"- (getImageForDesktopEntry size))+ let failure err =+ mprisLog WARNING "Failed to load default image: %s" err+ >> pixBufFromColor size 0+ loadDefault =+ loadIcon size "play.svg" >>= either failure return+ logErrorAndLoadDefault err =+ mprisLog WARNING "Failed to get MPRIS icon: %s" err+ >> mprisLog WARNING "MPRIS failure for: %s" busName+ >> loadDefault+ chromeSpecialCase l@(Left _) =+ if "chrom" `isInfixOf` formatBusName busName+ then Right "google-chrome"+ else l+ chromeSpecialCase x = x+ in either logErrorAndLoadDefault return+ =<< runExceptT+ ( ExceptT (left show . chromeSpecialCase <$> MPRIS2DBus.getDesktopEntry client busName)+ >>= makeExcept+ "Failed to get desktop entry"+ getDirectoryEntryDefault+ >>= makeExcept+ "Failed to get image"+ (getImageForDesktopEntry size)+ ) --- | This is the default player widget constructor that is used to build mpris--- widgets. It provides only an icon and NowPlaying text.-simplePlayerWidget ::- SimpleMPRIS2PlayerConfig -> UpdateMPRIS2PlayerWidget MPRIS2PlayerWidget+backIconText :: T.Text+backIconText = "⏮" -simplePlayerWidget _ _- (Just p@MPRIS2PlayerWidget { playerWidget = widget })- Nothing =- lift $ Gtk.widgetHide widget >> return p+playIconText :: T.Text+playIconText = "▶" -simplePlayerWidget c addToParent Nothing- np@(Just NowPlaying { npBusName = busName }) = do- ctx <- ask- client <- asks sessionDBusClient- lift $ do- mprisLog DEBUG "Building widget for %s" busName- image <- autoSizeImageNew (loadIconAtSize client busName) Gtk.OrientationHorizontal- playerBox <- Gtk.gridNew- label <- Gtk.labelNew Nothing- ebox <- Gtk.eventBoxNew- _ <- Gtk.onWidgetButtonPressEvent ebox $- const $ MPRIS2DBus.playPause client busName >> return True- Gtk.containerAdd playerBox image- Gtk.containerAdd playerBox label- Gtk.containerAdd ebox playerBox- vFillCenter playerBox- addToParent ebox- Gtk.widgetSetVexpand playerBox True- Gtk.widgetSetName playerBox $ T.pack $ formatBusName busName- Gtk.widgetShowAll ebox- Gtk.widgetHide ebox- widget <- Gtk.toWidget ebox- let widgetData =- MPRIS2PlayerWidget { playerLabel = label, playerWidget = widget }- flip runReaderT ctx $- simplePlayerWidget c addToParent (Just widgetData) np+pauseIconText :: T.Text+pauseIconText = "⏸" -simplePlayerWidget config _- (Just w@MPRIS2PlayerWidget- { playerLabel = label- , playerWidget = widget- }) (Just nowPlaying) = lift $ do- mprisLog DEBUG "Setting state %s" nowPlaying- Gtk.labelSetMarkup label =<< setNowPlayingLabel config nowPlaying- shouldShow <- showPlayerWidgetFn config nowPlaying- if shouldShow- then Gtk.widgetShowAll widget- else Gtk.widgetHide widget- return w+nextIconText :: T.Text+nextIconText = "⏭" +toggleIconText :: T.Text+toggleIconText = "⏯"++isPlaying :: NowPlaying -> Bool+isPlaying NowPlaying {npStatus = status} = status == "Playing"++canTogglePlayback :: NowPlaying -> Bool+canTogglePlayback nowPlaying = npCanPause nowPlaying || npCanPlay nowPlaying++playPauseIconText :: NowPlaying -> T.Text+playPauseIconText nowPlaying+ | isPlaying nowPlaying && npCanPause nowPlaying = pauseIconText+ | npCanPlay nowPlaying = playIconText+ | otherwise = toggleIconText++runPlayPauseAction :: Client -> NowPlaying -> IO ()+runPlayPauseAction client nowPlaying+ | isPlaying nowPlaying && npCanPause nowPlaying =+ void $ MPRIS2DBus.pause client (npBusName nowPlaying)+ | not (isPlaying nowPlaying) && npCanPlay nowPlaying =+ void $ MPRIS2DBus.play client (npBusName nowPlaying)+ | canTogglePlayback nowPlaying =+ void $ MPRIS2DBus.playPause client (npBusName nowPlaying)+ | otherwise = return ()++updateControlButtons :: MPRIS2PlayerControlsWidget -> NowPlaying -> IO ()+updateControlButtons+ MPRIS2PlayerControlsWidget+ { controlsPreviousButton = previousButton,+ controlsPlayPauseButton = playPauseButton,+ controlsPlayPauseButtonLabel = playPauseButtonLabel,+ controlsNextButton = nextButton+ }+ nowPlaying = do+ Gtk.widgetSetVisible previousButton (npCanGoPrevious nowPlaying)+ Gtk.widgetSetSensitive previousButton (npCanGoPrevious nowPlaying)+ Gtk.widgetSetVisible playPauseButton (canTogglePlayback nowPlaying)+ Gtk.widgetSetSensitive playPauseButton (canTogglePlayback nowPlaying)+ Gtk.labelSetText playPauseButtonLabel (playPauseIconText nowPlaying)+ Gtk.widgetSetVisible nextButton (npCanGoNext nowPlaying)+ Gtk.widgetSetSensitive nextButton (npCanGoNext nowPlaying)++newControlButton :: T.Text -> IO (Gtk.Button, Gtk.Label)+newControlButton iconText = do+ button <- Gtk.buttonNew+ label <- Gtk.labelNew $ Just iconText+ Gtk.containerAdd button label+ Gtk.widgetShowAll button+ return (button, label)++-- | This is the default player widget constructor that is used to build mpris+-- widgets. It provides only an icon and NowPlaying text.+simplePlayerWidget ::+ SimpleMPRIS2PlayerConfig -> UpdateMPRIS2PlayerWidget MPRIS2PlayerWidget+simplePlayerWidget+ _+ _+ (Just p@MPRIS2PlayerWidget {playerWidget = widget})+ Nothing =+ lift $ Gtk.widgetHide widget >> return p+simplePlayerWidget+ c+ addToParent+ Nothing+ np@(Just NowPlaying {npBusName = busName}) = do+ ctx <- ask+ client <- asks sessionDBusClient+ lift $ do+ mprisLog DEBUG "Building widget for %s" busName+ image <- autoSizeImageNew (loadIconAtSize client busName) Gtk.OrientationHorizontal+ playerBox <- Gtk.gridNew+ label <- Gtk.labelNew Nothing+ ebox <- Gtk.eventBoxNew+ _ <-+ Gtk.onWidgetButtonPressEvent ebox $+ const $+ MPRIS2DBus.playPause client busName >> return True+ Gtk.containerAdd playerBox image+ Gtk.containerAdd playerBox label+ Gtk.containerAdd ebox playerBox+ vFillCenter playerBox+ addToParent ebox+ Gtk.widgetSetVexpand playerBox True+ Gtk.widgetSetName playerBox $ T.pack $ formatBusName busName+ Gtk.widgetShowAll ebox+ Gtk.widgetHide ebox+ widget <- Gtk.toWidget ebox+ let widgetData =+ MPRIS2PlayerWidget {playerLabel = label, playerWidget = widget}+ flip runReaderT ctx $+ simplePlayerWidget c addToParent (Just widgetData) np+simplePlayerWidget+ config+ _+ ( Just+ w@MPRIS2PlayerWidget+ { playerLabel = label,+ playerWidget = widget+ }+ )+ (Just nowPlaying) = lift $ do+ mprisLog DEBUG "Setting state %s" nowPlaying+ Gtk.labelSetMarkup label =<< setNowPlayingLabel config nowPlaying+ shouldShow <- showPlayerWidgetFn config nowPlaying+ if shouldShow+ then Gtk.widgetShowAll widget+ else Gtk.widgetHide widget+ return w simplePlayerWidget _ _ _ _ =- mprisLog WARNING "widget update called with no widget or %s"- ("nowplaying" :: String) >> return undefined+ mprisLog+ WARNING+ "widget update called with no widget or %s"+ ("nowplaying" :: String)+ >> return undefined +-- | This player widget constructor extends the default MPRIS2 row with previous,+-- play/pause, and next buttons.+simplePlayerWidgetWithControls ::+ SimpleMPRIS2PlayerConfig -> UpdateMPRIS2PlayerWidget MPRIS2PlayerControlsWidget+simplePlayerWidgetWithControls+ _+ _+ (Just p@MPRIS2PlayerControlsWidget {controlsPlayerWidget = widget})+ Nothing =+ lift $ Gtk.widgetHide widget >> return p+simplePlayerWidgetWithControls+ c+ addToParent+ Nothing+ np@(Just nowPlaying@NowPlaying {npBusName = busName}) = do+ ctx <- ask+ client <- asks sessionDBusClient+ lift $ do+ mprisLog DEBUG "Building widget for %s" busName+ image <- autoSizeImageNew (loadIconAtSize client busName) Gtk.OrientationHorizontal+ playerBox <- Gtk.boxNew Gtk.OrientationHorizontal 0+ clickArea <- Gtk.boxNew Gtk.OrientationHorizontal 0+ controlsBox <- Gtk.boxNew Gtk.OrientationHorizontal 0+ label <- Gtk.labelNew Nothing+ nowPlayingVar <- MV.newMVar nowPlaying+ (previousButton, _) <- newControlButton backIconText+ (playPauseButton, playPauseButtonLabel) <- newControlButton toggleIconText+ (nextButton, _) <- newControlButton nextIconText+ _ <- widgetSetClassGI controlsBox "mpris-controls"+ _ <- widgetSetClassGI previousButton "mpris-control"+ _ <- widgetSetClassGI previousButton "mpris-control-previous"+ _ <- widgetSetClassGI playPauseButton "mpris-control"+ _ <- widgetSetClassGI playPauseButton "mpris-control-play-pause"+ _ <- widgetSetClassGI nextButton "mpris-control"+ _ <- widgetSetClassGI nextButton "mpris-control-next"+ _ <- Gtk.onButtonClicked previousButton $ do+ currentState <- MV.readMVar nowPlayingVar+ when+ (npCanGoPrevious currentState)+ (void $ MPRIS2DBus.previous client (npBusName currentState))+ _ <- Gtk.onButtonClicked playPauseButton $ do+ currentState <- MV.readMVar nowPlayingVar+ runPlayPauseAction client currentState+ _ <- Gtk.onButtonClicked nextButton $ do+ currentState <- MV.readMVar nowPlayingVar+ when+ (npCanGoNext currentState)+ (void $ MPRIS2DBus.next client (npBusName currentState))+ ebox <- Gtk.eventBoxNew+ _ <-+ Gtk.onWidgetButtonPressEvent ebox $+ const $ do+ currentState <- MV.readMVar nowPlayingVar+ runPlayPauseAction client currentState+ return True+ Gtk.boxPackStart clickArea image False False 0+ Gtk.boxPackStart clickArea label True True 0+ Gtk.containerAdd ebox clickArea+ Gtk.boxPackStart controlsBox previousButton False False 0+ Gtk.boxPackStart controlsBox playPauseButton False False 0+ Gtk.boxPackStart controlsBox nextButton False False 0+ Gtk.boxPackStart playerBox ebox True True 0+ Gtk.boxPackStart playerBox controlsBox False False 0+ vFillCenter playerBox+ addToParent playerBox+ Gtk.widgetSetVexpand playerBox True+ Gtk.widgetSetName playerBox $ T.pack $ formatBusName busName+ Gtk.widgetShowAll playerBox+ Gtk.widgetHide playerBox+ widget <- Gtk.toWidget playerBox+ let widgetData =+ MPRIS2PlayerControlsWidget+ { controlsPlayerLabel = label,+ controlsPlayerWidget = widget,+ controlsNowPlayingVar = nowPlayingVar,+ controlsPreviousButton = previousButton,+ controlsPlayPauseButton = playPauseButton,+ controlsPlayPauseButtonLabel = playPauseButtonLabel,+ controlsNextButton = nextButton+ }+ flip runReaderT ctx $+ simplePlayerWidgetWithControls c addToParent (Just widgetData) np+simplePlayerWidgetWithControls+ config+ _+ ( Just+ w@MPRIS2PlayerControlsWidget+ { controlsPlayerLabel = label,+ controlsPlayerWidget = widget,+ controlsNowPlayingVar = nowPlayingVar+ }+ )+ (Just nowPlaying) = lift $ do+ mprisLog DEBUG "Setting state %s" nowPlaying+ void $ MV.swapMVar nowPlayingVar nowPlaying+ Gtk.labelSetMarkup label =<< setNowPlayingLabel config nowPlaying+ shouldShow <- showPlayerWidgetFn config nowPlaying+ if shouldShow+ then Gtk.widgetShowAll widget >> updateControlButtons w nowPlaying+ else Gtk.widgetHide widget+ return w+simplePlayerWidgetWithControls _ _ _ _ =+ mprisLog+ WARNING+ "widget update called with no widget or %s"+ ("nowplaying" :: String)+ >> return undefined+ -- | Construct a new MPRIS2 widget using the `simplePlayerWidget` constructor. mpris2New :: TaffyIO Gtk.Widget mpris2New = mpris2NewWithConfig defaultMPRIS2Config +-- | Construct a new MPRIS2 widget with transport control buttons+-- (previous/play-pause/next) when the player advertises support for them.+mpris2NewWithControls :: TaffyIO Gtk.Widget+mpris2NewWithControls = mpris2NewWithConfig defaultMPRIS2ControlsConfig+ -- | Construct a new MPRIS2 widget with the provided configuration. mpris2NewWithConfig :: MPRIS2Config a -> TaffyIO Gtk.Widget-mpris2NewWithConfig config = ask >>= \ctx -> asks sessionDBusClient >>= \client -> lift $ do- grid <- Gtk.gridNew- outerWidget <- Gtk.toWidget grid >>= mprisWidgetWrapper config- vFillCenter grid- playerWidgetsVar <- MV.newMVar M.empty- let- updateWidget = updatePlayerWidget config- updatePlayerWidgets nowPlayings playerWidgets = do- let- updateWidgetFromNP np@NowPlaying { npBusName = busName } =- (busName,) <$> updateWidget (Gtk.containerAdd grid)- (M.lookup busName playerWidgets) (Just np)- activeBusNames = map npBusName nowPlayings- existingBusNames = M.keys playerWidgets- inactiveBusNames = existingBusNames \\ activeBusNames- callForNoPlayingAvailable busName =- updateWidget (Gtk.containerAdd grid)- (M.lookup busName playerWidgets) Nothing+mpris2NewWithConfig config =+ ask >>= \ctx ->+ asks sessionDBusClient >>= \client -> lift $ do+ grid <- Gtk.gridNew+ outerWidget <- Gtk.toWidget grid >>= mprisWidgetWrapper config+ vFillCenter grid+ playerWidgetsVar <- MV.newMVar M.empty+ let updateWidget = updatePlayerWidget config+ updatePlayerWidgets nowPlayings playerWidgets = do+ let updateWidgetFromNP np@NowPlaying {npBusName = busName} =+ (busName,)+ <$> updateWidget+ (Gtk.containerAdd grid)+ (M.lookup busName playerWidgets)+ (Just np)+ activeBusNames = map npBusName nowPlayings+ existingBusNames = M.keys playerWidgets+ inactiveBusNames = existingBusNames \\ activeBusNames+ callForNoPlayingAvailable busName =+ updateWidget+ (Gtk.containerAdd grid)+ (M.lookup busName playerWidgets)+ Nothing - -- Invoke the widgets with no NowPlaying so they can hide etc.- mapM_ callForNoPlayingAvailable inactiveBusNames- -- Update all the other widgets- updatedWidgets <- M.fromList <$> mapM updateWidgetFromNP nowPlayings- return $ M.union updatedWidgets playerWidgets+ -- Invoke the widgets with no NowPlaying so they can hide etc.+ mapM_ callForNoPlayingAvailable inactiveBusNames+ -- Update all the other widgets+ updatedWidgets <- M.fromList <$> mapM updateWidgetFromNP nowPlayings+ return $ M.union updatedWidgets playerWidgets - updatePlayerWidgetsVar nowPlayings = postGUISync $- MV.modifyMVar_ playerWidgetsVar $ flip runReaderT ctx .- updatePlayerWidgets nowPlayings+ updatePlayerWidgetsVar nowPlayings =+ postGUISync $+ MV.modifyMVar_ playerWidgetsVar $+ flip runReaderT ctx+ . updatePlayerWidgets nowPlayings - setPlayingClass = do- anyVisible <- anyM Gtk.widgetIsVisible =<< Gtk.containerGetChildren grid- if anyVisible- then do- addClassIfMissing "visible-children" outerWidget- removeClassIfPresent "no-visible-children" outerWidget- else do- addClassIfMissing "no-visible-children" outerWidget- removeClassIfPresent "visible-children" outerWidget+ setPlayingClass = do+ anyVisible <- anyM Gtk.widgetIsVisible =<< Gtk.containerGetChildren grid+ if anyVisible+ then do+ addClassIfMissing "visible-children" outerWidget+ removeClassIfPresent "no-visible-children" outerWidget+ else do+ addClassIfMissing "no-visible-children" outerWidget+ removeClassIfPresent "visible-children" outerWidget - doUpdate = do- nowPlayings <- getNowPlayingInfo client- updatePlayerWidgetsVar nowPlayings- setPlayingClass+ doUpdate = do+ nowPlayings <- getNowPlayingInfo client+ updatePlayerWidgetsVar nowPlayings+ setPlayingClass - signalCallback _ _ _ _ = doUpdate+ signalCallback _ _ _ _ = doUpdate - propMatcher = matchAny { matchPath = Just "/org/mpris/MediaPlayer2" }+ propMatcher = matchAny {matchPath = Just "/org/mpris/MediaPlayer2"} - handleNameOwnerChanged _ name _ _ = do- playerWidgets <- MV.readMVar playerWidgetsVar- busName <- parseBusName name- when (busName `M.member` playerWidgets) doUpdate+ handleNameOwnerChanged _ name _ _ = do+ playerWidgets <- MV.readMVar playerWidgetsVar+ busName <- parseBusName name+ when (busName `M.member` playerWidgets) doUpdate - _ <- Gtk.onWidgetRealize grid $ do- updateHandler <-- DBus.registerForPropertiesChanged client propMatcher signalCallback- nameHandler <-- DBus.registerForNameOwnerChanged client matchAny handleNameOwnerChanged- doUpdate- void $ Gtk.onWidgetUnrealize grid $- removeMatch client updateHandler >> removeMatch client nameHandler+ _ <- Gtk.onWidgetRealize grid $ do+ updateHandler <-+ DBus.registerForPropertiesChanged client propMatcher signalCallback+ nameHandler <-+ DBus.registerForNameOwnerChanged client matchAny handleNameOwnerChanged+ doUpdate+ void $+ Gtk.onWidgetUnrealize grid $+ removeMatch client updateHandler >> removeMatch client nameHandler - Gtk.widgetShow grid- setPlayingClass- return outerWidget+ Gtk.widgetShow grid+ setPlayingClass+ return outerWidget -- | Generate now playing text with the artist truncated to a maximum given by -- the first provided int, and the song title truncated to a maximum given by -- the second provided int.-playingText :: MonadIO m => Int -> Int -> NowPlaying -> m T.Text+playingText :: (MonadIO m) => Int -> Int -> NowPlaying -> m T.Text playingText artistMax songMax NowPlaying {npArtists = artists, npTitle = title} = G.markupEscapeText formattedText (-1)- where truncatedTitle = truncateString songMax title- formattedText = T.pack $ if null artists+ where+ truncatedTitle = truncateString songMax title+ formattedText =+ T.pack $+ if null artists then truncatedTitle- else printf- "%s - %s"- (truncateString artistMax $ intercalate "," artists)- truncatedTitle+ else+ printf+ "%s - %s"+ (truncateString artistMax $ intercalate "," artists)+ truncatedTitle
src/System/Taffybar/Widget/NetworkGraph.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.NetworkGraph -- Copyright : (c) Ivan A. Malison@@ -9,12 +12,11 @@ -- Portability : unportable -- -- This module provides a channel based network graph widget.------------------------------------------------------------------------------- module System.Taffybar.Widget.NetworkGraph where -import Data.Default (Default(..))+import Data.Default (Default (..)) import Data.Foldable (for_)+import qualified Data.Text as T import qualified GI.Gtk import GI.Gtk.Objects.Widget (widgetSetTooltipMarkup) import System.Taffybar.Context@@ -25,29 +27,32 @@ import System.Taffybar.Widget.Generic.ChannelWidget import System.Taffybar.Widget.Generic.Graph import System.Taffybar.Widget.Text.NetworkMonitor+import System.Taffybar.Widget.Util (widgetSetClassGI) -- | 'NetworkGraphConfig' configures the network graph widget. data NetworkGraphConfig = NetworkGraphConfig- { networkGraphGraphConfig :: GraphConfig -- ^ The configuration of the graph itself.- -- | A tooltip format string, together with the precision that should be used- -- for numbers in the string.- , networkGraphTooltipFormat :: Maybe (String, Int)- -- | A function to scale the y axis of the network config. The default is- -- `logBase $ 2 ** 32`.- , networkGraphScale :: Double -> Double- -- | A filter function that determines whether a given interface will be- -- included in the network stats.- , interfacesFilter :: String -> Bool+ { -- | The configuration of the graph itself.+ -- | A tooltip format string, together with the precision that should be used+ -- for numbers in the string.+ networkGraphGraphConfig :: GraphConfig,+ networkGraphTooltipFormat :: Maybe (String, Int),+ -- | A function to scale the y axis of the network config. The default is+ -- `logBase $ 2 ** 32`.+ networkGraphScale :: Double -> Double,+ -- | A filter function that determines whether a given interface will be+ -- included in the network stats.+ interfacesFilter :: String -> Bool } -- | Default configuration paramters for the network graph. defaultNetworkGraphConfig :: NetworkGraphConfig-defaultNetworkGraphConfig = NetworkGraphConfig- { networkGraphGraphConfig = def- , networkGraphTooltipFormat = Just (defaultNetFormat, 3)- , networkGraphScale = logBase $ 2 ** 32- , interfacesFilter = const True- }+defaultNetworkGraphConfig =+ NetworkGraphConfig+ { networkGraphGraphConfig = def,+ networkGraphTooltipFormat = Just (defaultNetFormat, 3),+ networkGraphScale = logBase $ 2 ** 32,+ interfacesFilter = const True+ } instance Default NetworkGraphConfig where def = defaultNetworkGraphConfig@@ -56,10 +61,11 @@ -- and a list of interfaces. networkGraphNew :: GraphConfig -> Maybe [String] -> TaffyIO GI.Gtk.Widget networkGraphNew config interfaces =- networkGraphNewWith def- { networkGraphGraphConfig = config- , interfacesFilter = maybe (const True) (flip elem) interfaces- }+ networkGraphNewWith+ def+ { networkGraphGraphConfig = config,+ interfacesFilter = maybe (const True) (flip elem) interfaces+ } -- | 'networkGraphNewWith' instantiates a network graph widget from a -- 'NetworkGraphConfig'.@@ -70,9 +76,10 @@ toSample (up, down) = map (networkGraphScale config . fromRational) [up, down] sampleBuilder = return . toSample . getUpDown widget <- channelGraphNew (networkGraphGraphConfig config) chan sampleBuilder+ _ <- widgetSetClassGI widget (T.pack "network-graph") for_ (networkGraphTooltipFormat config) $ \(format, precision) -> channelWidgetNew widget chan $ \speedInfo -> let (up, down) = sumSpeeds $ map snd speedInfo tooltip = showInfo format precision (fromRational down, fromRational up)- in postGUIASync $ widgetSetTooltipMarkup widget $ Just tooltip+ in postGUIASync $ widgetSetTooltipMarkup widget $ Just tooltip return widget
src/System/Taffybar/Widget/NetworkManager.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.NetworkManager -- Copyright : (c) Ivan A. Malison@@ -10,94 +14,91 @@ -- Portability : unportable -- -- Network widgets backed by NetworkManager's DBus API.------------------------------------------------------------------------------ module System.Taffybar.Widget.NetworkManager- ( WifiWidgetConfig(..)- , defaultWifiWidgetConfig- , networkManagerWifiLabelNew- , networkManagerWifiLabelNewWith-- , NetworkManagerWifiIconConfig(..)- , defaultNetworkManagerWifiIconConfig- , networkManagerWifiIconNew- , networkManagerWifiIconNewWith-- , networkManagerWifiNew- , networkManagerWifiNewWith-- -- Wifi text icon (nerd font label)- , networkManagerWifiTextIconNew- , networkManagerWifiTextIconNewWith- -- Wifi combined icon-label- , networkManagerWifiIconLabelNew- , networkManagerWifiIconLabelNewWith-- , NetworkWidgetConfig(..)- , defaultNetworkWidgetConfig- , networkManagerNetworkLabelNew- , networkManagerNetworkLabelNewWith-- , NetworkManagerNetworkIconConfig(..)- , defaultNetworkManagerNetworkIconConfig- , networkManagerNetworkIconNew- , networkManagerNetworkIconNewWith-- , networkManagerNetworkNew- , networkManagerNetworkNewWith-- -- Network text icon (nerd font label)- , networkManagerNetworkTextIconNew- , networkManagerNetworkTextIconNewWith- -- Network combined icon-label- , networkManagerNetworkIconLabelNew- , networkManagerNetworkIconLabelNewWith- ) where+ ( WifiWidgetConfig (..),+ defaultWifiWidgetConfig,+ networkManagerWifiLabelNew,+ networkManagerWifiLabelNewWith,+ NetworkManagerWifiIconConfig (..),+ defaultNetworkManagerWifiIconConfig,+ networkManagerWifiIconNew,+ networkManagerWifiIconNewWith,+ networkManagerWifiNew,+ networkManagerWifiNewWith,+ -- Wifi text icon (nerd font label)+ networkManagerWifiTextIconNew,+ networkManagerWifiTextIconNewWith,+ -- Wifi combined icon-label+ networkManagerWifiIconLabelNew,+ networkManagerWifiIconLabelNewWith,+ NetworkWidgetConfig (..),+ defaultNetworkWidgetConfig,+ networkManagerNetworkLabelNew,+ networkManagerNetworkLabelNewWith,+ NetworkManagerNetworkIconConfig (..),+ defaultNetworkManagerNetworkIconConfig,+ networkManagerNetworkIconNew,+ networkManagerNetworkIconNewWith,+ networkManagerNetworkNew,+ networkManagerNetworkNewWith,+ -- Network text icon (nerd font label)+ networkManagerNetworkTextIconNew,+ networkManagerNetworkTextIconNewWith,+ -- Network combined icon-label+ networkManagerNetworkIconLabelNew,+ networkManagerNetworkIconLabelNewWith,+ )+where -import Control.Applicative ((<|>))-import Control.Concurrent.MVar-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Reader-import Data.Default (Default(..))-import Data.Int (Int32)-import Data.IORef (newIORef, readIORef, writeIORef)-import Data.Maybe (fromMaybe)+import Control.Applicative ((<|>))+import Control.Concurrent.MVar+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Reader+import Data.Default (Default (..))+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.Int (Int32)+import Data.Maybe (fromMaybe) import qualified Data.Text as T import qualified GI.GLib as G-import GI.Gtk-import System.Taffybar.Context-import System.Taffybar.Information.NetworkManager-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Generic.ScalingImage (scalingImage)-import System.Taffybar.Widget.Util (buildIconLabelBox)-import Text.StringTemplate+import GI.Gtk+import System.Taffybar.Context+import System.Taffybar.Information.NetworkManager+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Generic.ScalingImage (scalingImage)+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI)+import Text.StringTemplate +-- | Formatting configuration for WiFi label widgets. data WifiWidgetConfig = WifiWidgetConfig- { wifiConnectedFormat :: String- , wifiDisconnectedFormat :: String- , wifiDisabledFormat :: String- , wifiUnknownFormat :: String- , wifiTooltipFormat :: Maybe String+ { wifiConnectedFormat :: String,+ wifiDisconnectedFormat :: String,+ wifiDisabledFormat :: String,+ wifiUnknownFormat :: String,+ wifiTooltipFormat :: Maybe String } +-- | Default 'WifiWidgetConfig'. defaultWifiWidgetConfig :: WifiWidgetConfig defaultWifiWidgetConfig = WifiWidgetConfig- { wifiConnectedFormat = "$ssid$ $strength$%"- , wifiDisconnectedFormat = "disconnected"- , wifiDisabledFormat = "off"- , wifiUnknownFormat = "unknown"- , wifiTooltipFormat =+ { wifiConnectedFormat = "$ssid$ $strength$%",+ wifiDisconnectedFormat = "disconnected",+ wifiDisabledFormat = "off",+ wifiUnknownFormat = "unknown",+ wifiTooltipFormat = Just "SSID: $ssid$\nStrength: $strength$%\nConnection: $connection$\nState: $state$" } instance Default WifiWidgetConfig where def = defaultWifiWidgetConfig +-- | Build a WiFi status label widget with default formatting. networkManagerWifiLabelNew :: TaffyIO Widget networkManagerWifiLabelNew = networkManagerWifiLabelNewWith defaultWifiWidgetConfig +-- | Build a WiFi status label widget with custom formatting. networkManagerWifiLabelNewWith :: WifiWidgetConfig -> TaffyIO Widget networkManagerWifiLabelNewWith config = do chan <- getWifiInfoChan@@ -109,43 +110,49 @@ postGUIASync $ do labelSetMarkup label labelText widgetSetTooltipMarkup label tooltipText- void $ onWidgetRealize label $- runReaderT getWifiInfoState ctx >>= updateWidget- toWidget =<< channelWidgetNew label chan updateWidget+ void $+ onWidgetRealize label $+ runReaderT getWifiInfoState ctx >>= updateWidget+ (toWidget =<< channelWidgetNew label chan updateWidget)+ >>= (`widgetSetClassGI` "network-manager-wifi-label") +-- | Icon configuration for WiFi icon widgets. data NetworkManagerWifiIconConfig = NetworkManagerWifiIconConfig- { wifiIconNone :: String- , wifiIconWeak :: String- , wifiIconOk :: String- , wifiIconGood :: String- , wifiIconExcellent :: String- , wifiIconDisconnected :: String- , wifiIconDisabled :: String- , wifiIconUnknown :: String- , wifiIconTooltipFormat :: Maybe String+ { wifiIconNone :: String,+ wifiIconWeak :: String,+ wifiIconOk :: String,+ wifiIconGood :: String,+ wifiIconExcellent :: String,+ wifiIconDisconnected :: String,+ wifiIconDisabled :: String,+ wifiIconUnknown :: String,+ wifiIconTooltipFormat :: Maybe String } +-- | Default 'NetworkManagerWifiIconConfig'. defaultNetworkManagerWifiIconConfig :: NetworkManagerWifiIconConfig defaultNetworkManagerWifiIconConfig = NetworkManagerWifiIconConfig- { wifiIconNone = "network-wireless-signal-none-symbolic"- , wifiIconWeak = "network-wireless-signal-weak-symbolic"- , wifiIconOk = "network-wireless-signal-ok-symbolic"- , wifiIconGood = "network-wireless-signal-good-symbolic"- , wifiIconExcellent = "network-wireless-signal-excellent-symbolic"- , wifiIconDisconnected = "network-wireless-offline-symbolic"- , wifiIconDisabled = "network-wireless-disabled-symbolic"- , wifiIconUnknown = "network-wireless-symbolic"- , wifiIconTooltipFormat =+ { wifiIconNone = "network-wireless-signal-none-symbolic",+ wifiIconWeak = "network-wireless-signal-weak-symbolic",+ wifiIconOk = "network-wireless-signal-ok-symbolic",+ wifiIconGood = "network-wireless-signal-good-symbolic",+ wifiIconExcellent = "network-wireless-signal-excellent-symbolic",+ wifiIconDisconnected = "network-wireless-offline-symbolic",+ wifiIconDisabled = "network-wireless-disabled-symbolic",+ wifiIconUnknown = "network-wireless-symbolic",+ wifiIconTooltipFormat = Just "SSID: $ssid$\nStrength: $strength$%\nConnection: $connection$\nState: $state$" } themeLoadFlags :: [IconLookupFlags] themeLoadFlags = [IconLookupFlagsGenericFallback, IconLookupFlagsUseBuiltin] +-- | Build a WiFi icon widget with default icon names and formatting. networkManagerWifiIconNew :: TaffyIO Widget networkManagerWifiIconNew = networkManagerWifiIconNewWith defaultNetworkManagerWifiIconConfig +-- | Build a WiFi icon widget with custom icon names and tooltip formatting. networkManagerWifiIconNewWith :: NetworkManagerWifiIconConfig -> TaffyIO Widget networkManagerWifiIconNewWith config = do chan <- getWifiInfoChan@@ -154,36 +161,35 @@ initialInfo <- liftIO $ runReaderT getWifiInfoState ctx infoVar <- liftIO $ newMVar initialInfo imageWidgetRef <- liftIO $ newIORef (error "imageWidget not initialised")- let- setIconForSize size = do- iw <- readIORef imageWidgetRef- styleCtx <- widgetGetStyleContext iw- info <- readMVar infoVar- let iconNames = wifiIconCandidates config info- iconInfo <- lookupFirstIcon defaultTheme size iconNames- traverse (extractPixbuf styleCtx) iconInfo- extractPixbuf styleCtx iconInfo =- fst <$> iconInfoLoadSymbolicForContext iconInfo styleCtx+ let setIconForSize size = do+ iw <- readIORef imageWidgetRef+ styleCtx <- widgetGetStyleContext iw+ info <- readMVar infoVar+ let iconNames = wifiIconCandidates config info+ iconInfo <- lookupFirstIcon defaultTheme size iconNames+ traverse (extractPixbuf styleCtx) iconInfo+ extractPixbuf styleCtx iconInfo =+ fst <$> iconInfoLoadSymbolicForContext iconInfo styleCtx (imageWidget, updateImage) <- scalingImage setIconForSize OrientationHorizontal liftIO $ do writeIORef imageWidgetRef imageWidget- let- updateWidget info = do- _ <- swapMVar infoVar info- (_, tooltipText) <- formatWifiWidget (iconTooltipAsLabelConfig config) info- postGUIASync $ do- widgetSetTooltipMarkup imageWidget tooltipText- updateImage+ let updateWidget info = do+ _ <- swapMVar infoVar info+ (_, tooltipText) <- formatWifiWidget (iconTooltipAsLabelConfig config) info+ postGUIASync $ do+ widgetSetTooltipMarkup imageWidget tooltipText+ updateImage void $ onWidgetRealize imageWidget $ updateWidget initialInfo- toWidget =<< channelWidgetNew imageWidget chan updateWidget+ (toWidget =<< channelWidgetNew imageWidget chan updateWidget)+ >>= (`widgetSetClassGI` "network-manager-wifi-icon") iconTooltipAsLabelConfig :: NetworkManagerWifiIconConfig -> WifiWidgetConfig iconTooltipAsLabelConfig cfg =- defaultWifiWidgetConfig { wifiTooltipFormat = wifiIconTooltipFormat cfg }+ defaultWifiWidgetConfig {wifiTooltipFormat = wifiIconTooltipFormat cfg} lookupFirstIcon :: IconTheme -> Int32 -> [String] -> IO (Maybe IconInfo) lookupFirstIcon _ _ [] = return Nothing-lookupFirstIcon theme size (name:names) = do+lookupFirstIcon theme size (name : names) = do info <- iconThemeLookupIcon theme (T.pack name) size themeLoadFlags case info of Just _ -> return info@@ -193,25 +199,25 @@ wifiIconCandidates cfg info = case wifiState info of WifiDisabled ->- [ wifiIconDisabled cfg- , wifiIconDisconnected cfg- , wifiIconNone cfg+ [ wifiIconDisabled cfg,+ wifiIconDisconnected cfg,+ wifiIconNone cfg ] WifiDisconnected ->- [ wifiIconDisconnected cfg- , wifiIconNone cfg+ [ wifiIconDisconnected cfg,+ wifiIconNone cfg ] WifiUnknown ->- [ wifiIconUnknown cfg- , wifiIconNone cfg+ [ wifiIconUnknown cfg,+ wifiIconNone cfg ] WifiConnected ->- strengthIconName cfg (wifiStrength info) :- [ wifiIconGood cfg- , wifiIconOk cfg- , wifiIconWeak cfg- , wifiIconNone cfg- ]+ strengthIconName cfg (wifiStrength info)+ : [ wifiIconGood cfg,+ wifiIconOk cfg,+ wifiIconWeak cfg,+ wifiIconNone cfg+ ] strengthIconName :: NetworkManagerWifiIconConfig -> Maybe Int -> String strengthIconName cfg strength =@@ -223,27 +229,26 @@ | s < 70 -> wifiIconGood cfg | otherwise -> wifiIconExcellent cfg +-- | Build a combined WiFi icon and label widget with default configs. networkManagerWifiNew :: TaffyIO Widget networkManagerWifiNew = networkManagerWifiNewWith defaultWifiWidgetConfig defaultNetworkManagerWifiIconConfig -networkManagerWifiNewWith- :: WifiWidgetConfig- -> NetworkManagerWifiIconConfig- -> TaffyIO Widget+-- | Build a combined WiFi icon and label widget with custom configs.+networkManagerWifiNewWith ::+ WifiWidgetConfig ->+ NetworkManagerWifiIconConfig ->+ TaffyIO Widget networkManagerWifiNewWith labelCfg iconCfg = do iconWidget <- networkManagerWifiIconNewWith iconCfg labelWidget <- networkManagerWifiLabelNewWith labelCfg- liftIO $ do- box <- boxNew OrientationHorizontal 0- containerAdd box iconWidget- containerAdd box labelWidget- widgetShowAll box- toWidget box+ liftIO $+ buildIconLabelBox iconWidget labelWidget+ >>= (`widgetSetClassGI` "network-manager-wifi") -formatWifiWidget- :: WifiWidgetConfig- -> WifiInfo- -> IO (T.Text, Maybe T.Text)+formatWifiWidget ::+ WifiWidgetConfig ->+ WifiInfo ->+ IO (T.Text, Maybe T.Text) formatWifiWidget config info = do attrs <- buildAttrs info let labelTemplate = case wifiState info of@@ -260,28 +265,26 @@ buildAttrs :: WifiInfo -> IO [(String, String)] buildAttrs info = do- let- displayName =- fromMaybe "" (wifiSsid info <|> wifiConnectionId info)- ssidText = if T.null displayName then "unknown" else displayName- strengthText = maybe "?" show (wifiStrength info)- stateText = wifiStateText (wifiState info)- connectionText = fromMaybe "" (wifiConnectionId info)+ let displayName =+ fromMaybe "" (wifiSsid info <|> wifiConnectionId info)+ ssidText = if T.null displayName then "unknown" else displayName+ strengthText = maybe "?" show (wifiStrength info)+ stateText = wifiStateText (wifiState info)+ connectionText = fromMaybe "" (wifiConnectionId info) ssid <- escapeText ssidText strength <- escapeText (T.pack strengthText) state <- escapeText (T.pack stateText) connection <- escapeText connectionText return- [ ("ssid", ssid)- , ("strength", strength)- , ("state", state)- , ("connection", connection)+ [ ("ssid", ssid),+ ("strength", strength),+ ("state", state),+ ("connection", connection) ] escapeText :: T.Text -> IO String escapeText input = T.unpack <$> G.markupEscapeText input (-1) - wifiStateText :: WifiState -> String wifiStateText WifiDisabled = "disabled" wifiStateText WifiDisconnected = "disconnected"@@ -300,38 +303,42 @@ -- Network (WiFi + wired + vpn + disconnected) +-- | Formatting configuration for generic network label widgets. data NetworkWidgetConfig = NetworkWidgetConfig- { networkWifiFormat :: String- , networkWiredFormat :: String- , networkVpnFormat :: String- , networkOtherFormat :: String- , networkDisconnectedFormat :: String- , networkWifiDisabledFormat :: String- , networkUnknownFormat :: String- , networkTooltipFormat :: Maybe String+ { networkWifiFormat :: String,+ networkWiredFormat :: String,+ networkVpnFormat :: String,+ networkOtherFormat :: String,+ networkDisconnectedFormat :: String,+ networkWifiDisabledFormat :: String,+ networkUnknownFormat :: String,+ networkTooltipFormat :: Maybe String } +-- | Default 'NetworkWidgetConfig'. defaultNetworkWidgetConfig :: NetworkWidgetConfig defaultNetworkWidgetConfig = NetworkWidgetConfig- { networkWifiFormat = "$ssid$ $strength$%"- , networkWiredFormat = "$connection$"- , networkVpnFormat = "$connection$"- , networkOtherFormat = "$type$ $connection$"- , networkDisconnectedFormat = "disconnected"- , networkWifiDisabledFormat = "disconnected (wifi off)"- , networkUnknownFormat = "unknown"- , networkTooltipFormat =+ { networkWifiFormat = "$ssid$ $strength$%",+ networkWiredFormat = "$connection$",+ networkVpnFormat = "$connection$",+ networkOtherFormat = "$type$ $connection$",+ networkDisconnectedFormat = "disconnected",+ networkWifiDisabledFormat = "disconnected (wifi off)",+ networkUnknownFormat = "unknown",+ networkTooltipFormat = Just "Type: $type$\nConnection: $connection$\nSSID: $ssid$\nStrength: $strength$%\nState: $state$" } instance Default NetworkWidgetConfig where def = defaultNetworkWidgetConfig +-- | Build a network status label widget with default formatting. networkManagerNetworkLabelNew :: TaffyIO Widget networkManagerNetworkLabelNew = networkManagerNetworkLabelNewWith defaultNetworkWidgetConfig +-- | Build a network status label widget with custom formatting. networkManagerNetworkLabelNewWith :: NetworkWidgetConfig -> TaffyIO Widget networkManagerNetworkLabelNewWith config = do chan <- getNetworkInfoChan@@ -343,47 +350,53 @@ postGUIASync $ do labelSetMarkup label labelText widgetSetTooltipMarkup label tooltipText- void $ onWidgetRealize label $- runReaderT getNetworkInfoState ctx >>= updateWidget- toWidget =<< channelWidgetNew label chan updateWidget+ void $+ onWidgetRealize label $+ runReaderT getNetworkInfoState ctx >>= updateWidget+ (toWidget =<< channelWidgetNew label chan updateWidget)+ >>= (`widgetSetClassGI` "network-manager-network-label") +-- | Icon configuration for generic network icon widgets. data NetworkManagerNetworkIconConfig = NetworkManagerNetworkIconConfig- { netIconWired :: String- , netIconVpn :: String- , netIconOther :: String- , netIconDisconnected :: String- , netIconUnknown :: String- , netIconWifiDisabled :: String- , netWifiIconNone :: String- , netWifiIconWeak :: String- , netWifiIconOk :: String- , netWifiIconGood :: String- , netWifiIconExcellent :: String- , netIconTooltipFormat :: Maybe String+ { netIconWired :: String,+ netIconVpn :: String,+ netIconOther :: String,+ netIconDisconnected :: String,+ netIconUnknown :: String,+ netIconWifiDisabled :: String,+ netWifiIconNone :: String,+ netWifiIconWeak :: String,+ netWifiIconOk :: String,+ netWifiIconGood :: String,+ netWifiIconExcellent :: String,+ netIconTooltipFormat :: Maybe String } +-- | Default 'NetworkManagerNetworkIconConfig'. defaultNetworkManagerNetworkIconConfig :: NetworkManagerNetworkIconConfig defaultNetworkManagerNetworkIconConfig = NetworkManagerNetworkIconConfig- { netIconWired = "network-wired-symbolic"- , netIconVpn = "network-vpn-symbolic"- , netIconOther = "network-transmit-receive-symbolic"- , netIconDisconnected = "network-offline-symbolic"- , netIconUnknown = "network-error-symbolic"- , netIconWifiDisabled = "network-wireless-disabled-symbolic"- , netWifiIconNone = "network-wireless-signal-none-symbolic"- , netWifiIconWeak = "network-wireless-signal-weak-symbolic"- , netWifiIconOk = "network-wireless-signal-ok-symbolic"- , netWifiIconGood = "network-wireless-signal-good-symbolic"- , netWifiIconExcellent = "network-wireless-signal-excellent-symbolic"- , netIconTooltipFormat =+ { netIconWired = "network-wired-symbolic",+ netIconVpn = "network-vpn-symbolic",+ netIconOther = "network-transmit-receive-symbolic",+ netIconDisconnected = "network-offline-symbolic",+ netIconUnknown = "network-error-symbolic",+ netIconWifiDisabled = "network-wireless-disabled-symbolic",+ netWifiIconNone = "network-wireless-signal-none-symbolic",+ netWifiIconWeak = "network-wireless-signal-weak-symbolic",+ netWifiIconOk = "network-wireless-signal-ok-symbolic",+ netWifiIconGood = "network-wireless-signal-good-symbolic",+ netWifiIconExcellent = "network-wireless-signal-excellent-symbolic",+ netIconTooltipFormat = Just "Type: $type$\nConnection: $connection$\nSSID: $ssid$\nStrength: $strength$%\nState: $state$" } +-- | Build a network icon widget with default icon names and formatting. networkManagerNetworkIconNew :: TaffyIO Widget networkManagerNetworkIconNew = networkManagerNetworkIconNewWith defaultNetworkManagerNetworkIconConfig +-- | Build a network icon widget with custom icon names and tooltip formatting. networkManagerNetworkIconNewWith :: NetworkManagerNetworkIconConfig -> TaffyIO Widget networkManagerNetworkIconNewWith config = do chan <- getNetworkInfoChan@@ -392,76 +405,75 @@ initialInfo <- liftIO $ runReaderT getNetworkInfoState ctx infoVar <- liftIO $ newMVar initialInfo imageWidgetRef <- liftIO $ newIORef (error "imageWidget not initialised")- let- setIconForSize size = do- iw <- readIORef imageWidgetRef- styleCtx <- widgetGetStyleContext iw- info <- readMVar infoVar- let iconNames = networkIconCandidates config info- iconInfo <- lookupFirstIcon defaultTheme size iconNames- traverse (extractPixbuf styleCtx) iconInfo- extractPixbuf styleCtx iconInfo =- fst <$> iconInfoLoadSymbolicForContext iconInfo styleCtx+ let setIconForSize size = do+ iw <- readIORef imageWidgetRef+ styleCtx <- widgetGetStyleContext iw+ info <- readMVar infoVar+ let iconNames = networkIconCandidates config info+ iconInfo <- lookupFirstIcon defaultTheme size iconNames+ traverse (extractPixbuf styleCtx) iconInfo+ extractPixbuf styleCtx iconInfo =+ fst <$> iconInfoLoadSymbolicForContext iconInfo styleCtx (imageWidget, updateImage) <- scalingImage setIconForSize OrientationHorizontal liftIO $ do writeIORef imageWidgetRef imageWidget- let- updateWidget info = do- _ <- swapMVar infoVar info- (_, tooltipText) <- formatNetworkWidget (iconTooltipAsNetworkLabelConfig config) info- postGUIASync $ do- widgetSetTooltipMarkup imageWidget tooltipText- updateImage+ let updateWidget info = do+ _ <- swapMVar infoVar info+ (_, tooltipText) <- formatNetworkWidget (iconTooltipAsNetworkLabelConfig config) info+ postGUIASync $ do+ widgetSetTooltipMarkup imageWidget tooltipText+ updateImage void $ onWidgetRealize imageWidget $ updateWidget initialInfo- toWidget =<< channelWidgetNew imageWidget chan updateWidget+ (toWidget =<< channelWidgetNew imageWidget chan updateWidget)+ >>= (`widgetSetClassGI` "network-manager-network-icon") iconTooltipAsNetworkLabelConfig :: NetworkManagerNetworkIconConfig -> NetworkWidgetConfig iconTooltipAsNetworkLabelConfig cfg =- defaultNetworkWidgetConfig { networkTooltipFormat = netIconTooltipFormat cfg }+ defaultNetworkWidgetConfig {networkTooltipFormat = netIconTooltipFormat cfg} networkIconCandidates :: NetworkManagerNetworkIconConfig -> NetworkInfo -> [String] networkIconCandidates cfg info = case networkState info of NetworkUnknown ->- [ netIconUnknown cfg- , netIconOther cfg- , netIconDisconnected cfg+ [ netIconUnknown cfg,+ netIconOther cfg,+ netIconDisconnected cfg ] NetworkDisconnected -> case networkWirelessEnabled info of Just False ->- [ netIconWifiDisabled cfg- , netIconDisconnected cfg+ [ netIconWifiDisabled cfg,+ netIconDisconnected cfg ] _ ->- [ netIconDisconnected cfg- , netIconOther cfg+ [ netIconDisconnected cfg,+ netIconOther cfg ] NetworkConnected -> case networkType info of Just NetworkWired ->- [ netIconWired cfg- , netIconOther cfg+ [ netIconWired cfg,+ netIconOther cfg ] Just NetworkVpn ->- [ netIconVpn cfg- , netIconOther cfg+ [ netIconVpn cfg,+ netIconOther cfg ] Just NetworkWifi ->- strengthToWifiIcon cfg (networkStrength info) :- [ netWifiIconGood cfg- , netWifiIconOk cfg- , netWifiIconWeak cfg- , netWifiIconNone cfg- ]+ strengthToWifiIcon cfg (networkStrength info)+ : [ netWifiIconGood cfg,+ netWifiIconOk cfg,+ netWifiIconWeak cfg,+ netWifiIconNone cfg+ ] Just (NetworkOther _) ->- [ netIconOther cfg- , netIconWired cfg+ [ netIconOther cfg,+ netIconWired cfg ] Nothing ->- [ netIconOther cfg- , netIconWired cfg- , netIconDisconnected cfg+ [ netIconOther cfg,+ netIconWired cfg,+ netIconDisconnected cfg ] strengthToWifiIcon :: NetworkManagerNetworkIconConfig -> Maybe Int -> String@@ -474,28 +486,27 @@ | s < 70 -> netWifiIconGood cfg | otherwise -> netWifiIconExcellent cfg +-- | Build a combined network icon and label widget with default configs. networkManagerNetworkNew :: TaffyIO Widget networkManagerNetworkNew = networkManagerNetworkNewWith defaultNetworkWidgetConfig defaultNetworkManagerNetworkIconConfig -networkManagerNetworkNewWith- :: NetworkWidgetConfig- -> NetworkManagerNetworkIconConfig- -> TaffyIO Widget+-- | Build a combined network icon and label widget with custom configs.+networkManagerNetworkNewWith ::+ NetworkWidgetConfig ->+ NetworkManagerNetworkIconConfig ->+ TaffyIO Widget networkManagerNetworkNewWith labelCfg iconCfg = do iconWidget <- networkManagerNetworkIconNewWith iconCfg labelWidget <- networkManagerNetworkLabelNewWith labelCfg- liftIO $ do- box <- boxNew OrientationHorizontal 0- containerAdd box iconWidget- containerAdd box labelWidget- widgetShowAll box- toWidget box+ liftIO $+ buildIconLabelBox iconWidget labelWidget+ >>= (`widgetSetClassGI` "network-manager-network") -formatNetworkWidget- :: NetworkWidgetConfig- -> NetworkInfo- -> IO (T.Text, Maybe T.Text)+formatNetworkWidget ::+ NetworkWidgetConfig ->+ NetworkInfo ->+ IO (T.Text, Maybe T.Text) formatNetworkWidget config info = do attrs <- buildNetworkAttrs info let labelTemplate =@@ -518,25 +529,24 @@ buildNetworkAttrs :: NetworkInfo -> IO [(String, String)] buildNetworkAttrs info = do- let- rawConnText = fromMaybe "" (networkConnectionId info)- connText = if T.null rawConnText then "unknown" else rawConnText- typeText = networkTypeText (networkType info)- displaySsid = fromMaybe "" (networkSsid info)- ssidText = if T.null displaySsid then "unknown" else displaySsid- strengthText = maybe "?" show (networkStrength info)- stateText = networkStateText (networkState info)+ let rawConnText = fromMaybe "" (networkConnectionId info)+ connText = if T.null rawConnText then "unknown" else rawConnText+ typeText = networkTypeText (networkType info)+ displaySsid = fromMaybe "" (networkSsid info)+ ssidText = if T.null displaySsid then "unknown" else displaySsid+ strengthText = maybe "?" show (networkStrength info)+ stateText = networkStateText (networkState info) conn <- escapeText connText typ <- escapeText typeText ssid <- escapeText ssidText strength <- escapeText (T.pack strengthText) state <- escapeText (T.pack stateText) return- [ ("connection", conn)- , ("type", typ)- , ("ssid", ssid)- , ("strength", strength)- , ("state", state)+ [ ("connection", conn),+ ("type", typ),+ ("ssid", ssid),+ ("strength", strength),+ ("state", state) ] networkTypeText :: Maybe NetworkType -> T.Text@@ -570,10 +580,12 @@ -- Wifi text icon +-- | Build a WiFi text-icon label widget with default formatting config. networkManagerWifiTextIconNew :: TaffyIO Widget networkManagerWifiTextIconNew = networkManagerWifiTextIconNewWith defaultWifiWidgetConfig +-- | Build a WiFi text-icon label widget. networkManagerWifiTextIconNewWith :: WifiWidgetConfig -> TaffyIO Widget networkManagerWifiTextIconNewWith _config = do chan <- getWifiInfoChan@@ -583,28 +595,36 @@ let updateIcon info = do let iconText = wifiTextIcon info postGUIASync $ labelSetText label iconText- void $ onWidgetRealize label $- runReaderT getWifiInfoState ctx >>= updateIcon- toWidget =<< channelWidgetNew label chan updateIcon+ void $+ onWidgetRealize label $+ runReaderT getWifiInfoState ctx >>= updateIcon+ (toWidget =<< channelWidgetNew label chan updateIcon)+ >>= (`widgetSetClassGI` "network-manager-wifi-text-icon") -- Wifi icon-label +-- | Build a combined WiFi text-icon and label widget with default formatting. networkManagerWifiIconLabelNew :: TaffyIO Widget networkManagerWifiIconLabelNew = networkManagerWifiIconLabelNewWith defaultWifiWidgetConfig +-- | Build a combined WiFi text-icon and label widget. networkManagerWifiIconLabelNewWith :: WifiWidgetConfig -> TaffyIO Widget networkManagerWifiIconLabelNewWith config = do iconWidget <- networkManagerWifiTextIconNewWith config labelWidget <- networkManagerWifiLabelNewWith config- liftIO $ buildIconLabelBox iconWidget labelWidget+ liftIO $+ buildIconLabelBox iconWidget labelWidget+ >>= (`widgetSetClassGI` "network-manager-wifi-icon-label") -- Network text icon +-- | Build a network text-icon label widget with default formatting config. networkManagerNetworkTextIconNew :: TaffyIO Widget networkManagerNetworkTextIconNew = networkManagerNetworkTextIconNewWith defaultNetworkWidgetConfig +-- | Build a network text-icon label widget. networkManagerNetworkTextIconNewWith :: NetworkWidgetConfig -> TaffyIO Widget networkManagerNetworkTextIconNewWith _config = do chan <- getNetworkInfoChan@@ -614,18 +634,24 @@ let updateIcon info = do let iconText = networkTextIcon info postGUIASync $ labelSetText label iconText- void $ onWidgetRealize label $- runReaderT getNetworkInfoState ctx >>= updateIcon- toWidget =<< channelWidgetNew label chan updateIcon+ void $+ onWidgetRealize label $+ runReaderT getNetworkInfoState ctx >>= updateIcon+ (toWidget =<< channelWidgetNew label chan updateIcon)+ >>= (`widgetSetClassGI` "network-manager-network-text-icon") -- Network icon-label +-- | Build a combined network text-icon and label widget with default formatting. networkManagerNetworkIconLabelNew :: TaffyIO Widget networkManagerNetworkIconLabelNew = networkManagerNetworkIconLabelNewWith defaultNetworkWidgetConfig +-- | Build a combined network text-icon and label widget. networkManagerNetworkIconLabelNewWith :: NetworkWidgetConfig -> TaffyIO Widget networkManagerNetworkIconLabelNewWith config = do iconWidget <- networkManagerNetworkTextIconNewWith config labelWidget <- networkManagerNetworkLabelNewWith config- liftIO $ buildIconLabelBox iconWidget labelWidget+ liftIO $+ buildIconLabelBox iconWidget labelWidget+ >>= (`widgetSetClassGI` "network-manager-network-icon-label")
src/System/Taffybar/Widget/PowerProfiles.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.PowerProfiles -- Copyright : (c) Ivan A. Malison@@ -19,52 +23,54 @@ -- * 'powerProfilesNew' -- combined icon + label -- -- All variants cycle through profiles on left-click.------------------------------------------------------------------------------ module System.Taffybar.Widget.PowerProfiles- ( PowerProfilesConfig(..)- , defaultPowerProfilesConfig- , powerProfilesIconNew- , powerProfilesIconNewWithConfig- , powerProfilesLabelNew- , powerProfilesLabelNewWithConfig- , powerProfilesNew- , powerProfilesNewWithConfig- ) where+ ( PowerProfilesConfig (..),+ defaultPowerProfilesConfig,+ powerProfilesIconNew,+ powerProfilesIconNewWithConfig,+ powerProfilesLabelNew,+ powerProfilesLabelNewWithConfig,+ powerProfilesNew,+ powerProfilesNewWithConfig,+ )+where -import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Reader-import Data.Default (Default(..))+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Reader+import Data.Default (Default (..)) import qualified Data.Text as T import qualified GI.Gdk as Gdk import qualified GI.Gtk as Gtk-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.Information.PowerProfiles-import System.Taffybar.Util (postGUIASync, logPrintF)-import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Util (buildIconLabelBox)+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Information.PowerProfiles+import System.Taffybar.Util (logPrintF, postGUIASync)+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Util (buildIconLabelBox) -- | Configuration for the power profiles widget. data PowerProfilesConfig = PowerProfilesConfig { -- | Format string for the label. Use @$profile$@ for the profile name.- powerProfilesFormat :: String+ powerProfilesFormat :: String, -- | Nerd font text icon for power-saver mode (default U+F06C9, nf-md-leaf).- , powerProfilesPowerSaverTextIcon :: T.Text+ powerProfilesPowerSaverTextIcon :: T.Text, -- | Nerd font text icon for balanced mode (default U+F0A7A, nf-md-scale_balance).- , powerProfilesBalancedTextIcon :: T.Text+ powerProfilesBalancedTextIcon :: T.Text, -- | Nerd font text icon for performance mode (default U+F0E4E, nf-md-rocket_launch).- , powerProfilesPerformanceTextIcon :: T.Text- } deriving (Eq, Show)+ powerProfilesPerformanceTextIcon :: T.Text+ }+ deriving (Eq, Show) -- | Default configuration for the power profiles widget. defaultPowerProfilesConfig :: PowerProfilesConfig-defaultPowerProfilesConfig = PowerProfilesConfig- { powerProfilesFormat = "$profile$"- , powerProfilesPowerSaverTextIcon = T.pack "\xF06C9"- , powerProfilesBalancedTextIcon = T.pack "\xF0A7A"- , powerProfilesPerformanceTextIcon = T.pack "\xF0E4E"- }+defaultPowerProfilesConfig =+ PowerProfilesConfig+ { powerProfilesFormat = "$profile$",+ powerProfilesPowerSaverTextIcon = T.pack "\xF06C9",+ powerProfilesBalancedTextIcon = T.pack "\xF0A7A",+ powerProfilesPerformanceTextIcon = T.pack "\xF0E4E"+ } instance Default PowerProfilesConfig where def = defaultPowerProfilesConfig@@ -190,7 +196,7 @@ replace old new = T.unpack . T.replace (T.pack old) (T.pack new) . T.pack -- | Update CSS classes based on the current profile. Works with any widget.-updateProfileClasses :: Gtk.IsWidget w => w -> PowerProfileInfo -> IO ()+updateProfileClasses :: (Gtk.IsWidget w) => w -> PowerProfileInfo -> IO () updateProfileClasses widget info = do let profile = currentProfile info allClasses = ["power-saver", "balanced", "performance"]@@ -203,7 +209,7 @@ Gtk.styleContextAddClass styleCtx currentClass -- | Update tooltip with current profile info.-updateTooltip :: Gtk.IsWidget w => w -> PowerProfileInfo -> IO ()+updateTooltip :: (Gtk.IsWidget w) => w -> PowerProfileInfo -> IO () updateTooltip widget info = do let profile = currentProfile info profileName = powerProfileToString profile
src/System/Taffybar/Widget/Privacy.hs view
@@ -1,6 +1,9 @@ {-# LANGUAGE OverloadedStrings #-} -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Privacy -- Copyright : (c) Ivan A. Malison@@ -31,27 +34,27 @@ -- * @.privacy-audio-input@ - Audio input (microphone) icon -- * @.privacy-audio-output@ - Audio output icon -- * @.privacy-video-input@ - Video input (camera/screen share) icon---------------------------------------------------------------------------------- module System.Taffybar.Widget.Privacy ( -- * Widget constructors- privacyNew- , privacyNewWith+ privacyNew,+ privacyNewWith,+ -- * Configuration- , PrivacyWidgetConfig(..)- , defaultPrivacyWidgetConfig+ PrivacyWidgetConfig (..),+ defaultPrivacyWidgetConfig,+ -- * Re-exports- , PrivacyConfig(..)- , defaultPrivacyConfig- ) where+ PrivacyConfig (..),+ defaultPrivacyConfig,+ )+where import Control.Concurrent (forkIO) import Control.Concurrent.STM.TChan import Control.Monad (void, when) import Control.Monad.IO.Class (liftIO) import Control.Monad.STM (atomically)-import Data.Default (Default(..))+import Data.Default (Default (..)) import Data.Int (Int32) import Data.List (intercalate) import Data.Maybe (catMaybes)@@ -59,43 +62,45 @@ import qualified GI.Gtk as Gtk import System.Taffybar.Context (TaffyIO) import System.Taffybar.Information.Privacy- ( PrivacyConfig(..)- , PrivacyInfo(..)- , PrivacyNode(..)- , NodeType(..)- , defaultPrivacyConfig- , getPrivacyInfoChan- , getPrivacyInfoState+ ( NodeType (..),+ PrivacyConfig (..),+ PrivacyInfo (..),+ PrivacyNode (..),+ defaultPrivacyConfig,+ getPrivacyInfoChan,+ getPrivacyInfoState, ) import System.Taffybar.Util (postGUIASync) import System.Taffybar.Widget.Util (widgetSetClassGI) -- | Configuration for the privacy widget. data PrivacyWidgetConfig = PrivacyWidgetConfig- { privacyWidgetConfig :: PrivacyConfig- -- ^ Underlying privacy monitoring configuration- , audioInputIcon :: T.Text- -- ^ Icon name for microphone- , audioOutputIcon :: T.Text- -- ^ Icon name for audio output- , videoInputIcon :: T.Text- -- ^ Icon name for camera/screen share- , privacyIconSize :: Int32- -- ^ Size of the icons (as Int32 for GTK)- , privacySpacing :: Int- -- ^ Spacing between icons- } deriving (Eq, Show)+ { -- | Underlying privacy monitoring configuration+ privacyWidgetConfig :: PrivacyConfig,+ -- | Icon name for microphone+ audioInputIcon :: T.Text,+ -- | Icon name for audio output+ audioOutputIcon :: T.Text,+ -- | Icon name for camera/screen share+ videoInputIcon :: T.Text,+ -- | Size of the icons (as Int32 for GTK)+ privacyIconSize :: Int32,+ -- | Spacing between icons+ privacySpacing :: Int+ }+ deriving (Eq, Show) -- | Default privacy widget configuration. defaultPrivacyWidgetConfig :: PrivacyWidgetConfig-defaultPrivacyWidgetConfig = PrivacyWidgetConfig- { privacyWidgetConfig = defaultPrivacyConfig- , audioInputIcon = "audio-input-microphone-symbolic"- , audioOutputIcon = "audio-speakers-symbolic"- , videoInputIcon = "camera-video-symbolic"- , privacyIconSize = fromIntegral $ fromEnum Gtk.IconSizeMenu- , privacySpacing = 4- }+defaultPrivacyWidgetConfig =+ PrivacyWidgetConfig+ { privacyWidgetConfig = defaultPrivacyConfig,+ audioInputIcon = "audio-input-microphone-symbolic",+ audioOutputIcon = "audio-speakers-symbolic",+ videoInputIcon = "camera-video-symbolic",+ privacyIconSize = fromIntegral $ fromEnum Gtk.IconSizeMenu,+ privacySpacing = 4+ } instance Default PrivacyWidgetConfig where def = defaultPrivacyWidgetConfig@@ -195,9 +200,10 @@ formatNode n = " - " ++ T.unpack (appName n) - sections = catMaybes- [ formatSection "Microphone" audioInNodes- , formatSection "Camera/Screen" videoInNodes- , formatSection "Audio Output" audioOutNodes- ]- in intercalate "\n" sections+ sections =+ catMaybes+ [ formatSection "Microphone" audioInNodes,+ formatSection "Camera/Screen" videoInNodes,+ formatSection "Audio Output" audioOutNodes+ ]+ in intercalate "\n" sections
src/System/Taffybar/Widget/PulseAudio.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.PulseAudio -- Copyright : (c) Ivan A. Malison@@ -14,70 +18,74 @@ -- Note: PulseAudio's DBus socket is not always enabled by default. -- If the widget shows "vol: n/a", ensure the PulseAudio server has loaded -- @module-dbus-protocol@ (so @/run/user/$UID/pulse/dbus-socket@ exists).------------------------------------------------------------------------------- module System.Taffybar.Widget.PulseAudio- ( PulseAudioWidgetConfig(..)- , defaultPulseAudioWidgetConfig- , pulseAudioIconNew- , pulseAudioIconNewWith- , pulseAudioLabelNew- , pulseAudioLabelNewWith- , pulseAudioNew- , pulseAudioNewWith- ) where+ ( PulseAudioWidgetConfig (..),+ defaultPulseAudioWidgetConfig,+ pulseAudioIconNew,+ pulseAudioIconNewWith,+ pulseAudioLabelNew,+ pulseAudioLabelNewWith,+ pulseAudioNew,+ pulseAudioNewWith,+ )+where import Control.Concurrent.MVar (readMVar) import Control.Monad (void, when) import Control.Monad.IO.Class-import Data.Default (Default(..))+import Data.Default (Default (..)) import qualified Data.Text as T+import qualified GI.GLib as G import qualified GI.Gdk.Enums as Gdk import qualified GI.Gdk.Structs.EventScroll as GdkEvent-import qualified GI.GLib as G import qualified GI.Gtk as Gtk import System.Taffybar.Context (TaffyIO) import System.Taffybar.Information.PulseAudio import System.Taffybar.Util (postGUIASync) import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Util (buildIconLabelBox)+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI) import Text.StringTemplate +-- | Configuration for PulseAudio widgets. data PulseAudioWidgetConfig = PulseAudioWidgetConfig- { pulseAudioSink :: String- , pulseAudioFormat :: String- , pulseAudioMuteFormat :: String- , pulseAudioUnknownFormat :: String- , pulseAudioTooltipFormat :: Maybe String- , pulseAudioScrollStepPercent :: Maybe Int- , pulseAudioToggleMuteOnClick :: Bool+ { pulseAudioSink :: String,+ pulseAudioFormat :: String,+ pulseAudioMuteFormat :: String,+ pulseAudioUnknownFormat :: String,+ pulseAudioTooltipFormat :: Maybe String,+ pulseAudioScrollStepPercent :: Maybe Int,+ pulseAudioToggleMuteOnClick :: Bool } +-- | Default PulseAudio widget configuration. defaultPulseAudioWidgetConfig :: PulseAudioWidgetConfig defaultPulseAudioWidgetConfig = PulseAudioWidgetConfig- { pulseAudioSink = "@DEFAULT_SINK@"- , pulseAudioFormat = "$volume$%"- , pulseAudioMuteFormat = "muted"- , pulseAudioUnknownFormat = "n/a"- , pulseAudioTooltipFormat =- Just "Sink: $sink$\nVolume: $volume$%\nMuted: $muted$"- , pulseAudioScrollStepPercent = Just 5- , pulseAudioToggleMuteOnClick = True+ { pulseAudioSink = "@DEFAULT_SINK@",+ pulseAudioFormat = "$volume$%",+ pulseAudioMuteFormat = "muted",+ pulseAudioUnknownFormat = "n/a",+ pulseAudioTooltipFormat =+ Just "Sink: $sink$\nVolume: $volume$%\nMuted: $muted$",+ pulseAudioScrollStepPercent = Just 5,+ pulseAudioToggleMuteOnClick = True } instance Default PulseAudioWidgetConfig where def = defaultPulseAudioWidgetConfig +-- | Create an icon-only PulseAudio widget with default config. pulseAudioIconNew :: TaffyIO Gtk.Widget pulseAudioIconNew = pulseAudioIconNewWith defaultPulseAudioWidgetConfig +-- | Create an icon-only PulseAudio widget with custom config. pulseAudioIconNewWith :: PulseAudioWidgetConfig -> TaffyIO Gtk.Widget pulseAudioIconNewWith config = do let sinkSpec = pulseAudioSink config (chan, var) <- getPulseAudioInfoChanAndVar sinkSpec liftIO $ do label <- Gtk.labelNew Nothing+ _ <- widgetSetClassGI label "pulse-audio-icon" let updateIcon info = do let iconText = case info of Nothing -> T.pack "\xF026"@@ -85,11 +93,14 @@ postGUIASync $ Gtk.labelSetText label iconText void $ Gtk.onWidgetRealize label $ readMVar var >>= updateIcon Gtk.widgetShowAll label- Gtk.toWidget =<< channelWidgetNew label chan updateIcon+ (Gtk.toWidget =<< channelWidgetNew label chan updateIcon)+ >>= (`widgetSetClassGI` "pulse-audio-icon") +-- | Create a label-only PulseAudio widget with default config. pulseAudioLabelNew :: TaffyIO Gtk.Widget pulseAudioLabelNew = pulseAudioLabelNewWith defaultPulseAudioWidgetConfig +-- | Create a label-only PulseAudio widget with custom config. pulseAudioLabelNewWith :: PulseAudioWidgetConfig -> TaffyIO Gtk.Widget pulseAudioLabelNewWith config = do let sinkSpec = pulseAudioSink config@@ -97,41 +108,42 @@ liftIO $ do label <- Gtk.labelNew Nothing+ _ <- widgetSetClassGI label "pulse-audio-label" - let- updateLabel info = do- (labelText, tooltipText) <- formatPulseAudioWidget config info- postGUIASync $ do- Gtk.labelSetMarkup label labelText- Gtk.widgetSetTooltipMarkup label tooltipText+ let updateLabel info = do+ (labelText, tooltipText) <- formatPulseAudioWidget config info+ postGUIASync $ do+ Gtk.labelSetMarkup label labelText+ Gtk.widgetSetTooltipMarkup label tooltipText - refreshNow = getPulseAudioInfo sinkSpec >>= updateLabel+ refreshNow = getPulseAudioInfo sinkSpec >>= updateLabel - whenToggleMute widget =- when (pulseAudioToggleMuteOnClick config) $- void $ Gtk.onWidgetButtonPressEvent widget $ \_ -> do- void $ togglePulseAudioMute sinkSpec- refreshNow- return True+ whenToggleMute widget =+ when (pulseAudioToggleMuteOnClick config) $+ void $+ Gtk.onWidgetButtonPressEvent widget $ \_ -> do+ void $ togglePulseAudioMute sinkSpec+ refreshNow+ return True - whenScrollAdjust widget =- case pulseAudioScrollStepPercent config of- Nothing -> return ()- Just step | step <= 0 -> return ()- Just step -> do- _ <- Gtk.onWidgetScrollEvent widget $ \scrollEvent -> do- dir <- GdkEvent.getEventScrollDirection scrollEvent- let doAdjust delta = do- void $ adjustPulseAudioVolume sinkSpec delta- refreshNow- return True- case dir of- Gdk.ScrollDirectionUp -> doAdjust step- Gdk.ScrollDirectionDown -> doAdjust (-step)- Gdk.ScrollDirectionLeft -> doAdjust step- Gdk.ScrollDirectionRight -> doAdjust (-step)- _ -> return False- return ()+ whenScrollAdjust widget =+ case pulseAudioScrollStepPercent config of+ Nothing -> return ()+ Just step | step <= 0 -> return ()+ Just step -> do+ _ <- Gtk.onWidgetScrollEvent widget $ \scrollEvent -> do+ dir <- GdkEvent.getEventScrollDirection scrollEvent+ let doAdjust delta = do+ void $ adjustPulseAudioVolume sinkSpec delta+ refreshNow+ return True+ case dir of+ Gdk.ScrollDirectionUp -> doAdjust step+ Gdk.ScrollDirectionDown -> doAdjust (-step)+ Gdk.ScrollDirectionLeft -> doAdjust step+ Gdk.ScrollDirectionRight -> doAdjust (-step)+ _ -> return False+ return () void $ Gtk.onWidgetRealize label $ readMVar var >>= updateLabel @@ -139,59 +151,62 @@ whenScrollAdjust label Gtk.widgetShowAll label- Gtk.toWidget =<< channelWidgetNew label chan updateLabel+ (Gtk.toWidget =<< channelWidgetNew label chan updateLabel)+ >>= (`widgetSetClassGI` "pulse-audio-label") +-- | Create a combined icon+label PulseAudio widget with default config. pulseAudioNew :: TaffyIO Gtk.Widget pulseAudioNew = pulseAudioNewWith defaultPulseAudioWidgetConfig +-- | Create a combined icon+label PulseAudio widget with custom config. pulseAudioNewWith :: PulseAudioWidgetConfig -> TaffyIO Gtk.Widget pulseAudioNewWith config = do iconWidget <- pulseAudioIconNewWith config labelWidget <- pulseAudioLabelNewWith config- liftIO $ buildIconLabelBox iconWidget labelWidget+ liftIO $+ buildIconLabelBox iconWidget labelWidget+ >>= (`widgetSetClassGI` "pulse-audio") -formatPulseAudioWidget- :: PulseAudioWidgetConfig- -> Maybe PulseAudioInfo- -> IO (T.Text, Maybe T.Text)+formatPulseAudioWidget ::+ PulseAudioWidgetConfig ->+ Maybe PulseAudioInfo ->+ IO (T.Text, Maybe T.Text) formatPulseAudioWidget config info = do attrs <- maybe buildUnknownAttrs buildAttrs info- let- labelTemplate =- case info of- Nothing -> pulseAudioUnknownFormat config- Just audio ->- case pulseAudioMuted audio of- Just True -> pulseAudioMuteFormat config- _ -> pulseAudioFormat config- labelText = renderTemplate labelTemplate attrs- tooltipText = fmap (`renderTemplate` attrs) (pulseAudioTooltipFormat config)+ let labelTemplate =+ case info of+ Nothing -> pulseAudioUnknownFormat config+ Just audio ->+ case pulseAudioMuted audio of+ Just True -> pulseAudioMuteFormat config+ _ -> pulseAudioFormat config+ labelText = renderTemplate labelTemplate attrs+ tooltipText = fmap (`renderTemplate` attrs) (pulseAudioTooltipFormat config) return (T.pack labelText, T.pack <$> tooltipText) buildAttrs :: PulseAudioInfo -> IO [(String, String)] buildAttrs info = do- let- volumeText = maybe "?" show (pulseAudioVolumePercent info)- mutedText = case pulseAudioMuted info of- Just True -> "yes"- Just False -> "no"- Nothing -> "unknown"- sinkText = pulseAudioSinkName info+ let volumeText = maybe "?" show (pulseAudioVolumePercent info)+ mutedText = case pulseAudioMuted info of+ Just True -> "yes"+ Just False -> "no"+ Nothing -> "unknown"+ sinkText = pulseAudioSinkName info volume <- escapeText $ T.pack volumeText muted <- escapeText $ T.pack mutedText sink <- escapeText $ T.pack sinkText return- [ ("volume", volume)- , ("muted", muted)- , ("sink", sink)+ [ ("volume", volume),+ ("muted", muted),+ ("sink", sink) ] buildUnknownAttrs :: IO [(String, String)] buildUnknownAttrs = return- [ ("volume", "?")- , ("muted", "unknown")- , ("sink", "unknown")+ [ ("volume", "?"),+ ("muted", "unknown"),+ ("sink", "unknown") ] pulseAudioTextIcon :: Maybe Bool -> Maybe Int -> T.Text
src/System/Taffybar/Widget/SNIMenu.hs view
@@ -1,20 +1,23 @@ {-# LANGUAGE OverloadedStrings #-}++-- | Helpers for attaching DBusMenu popups to widgets. module System.Taffybar.Widget.SNIMenu- ( withSniMenu- , withNmAppletMenu- ) where+ ( withSniMenu,+ withNmAppletMenu,+ )+where import Control.Exception.Enclosed (catchAny) import Control.Monad (void) import Control.Monad.IO.Class (liftIO) import Control.Monad.Trans.Reader (ask) import DBus (BusName, ObjectPath)-import qualified GI.Gdk as Gdk (getEventButtonButton, getEventButtonType, EventType(..))+import qualified DBusMenu import qualified GI.GLib as GLib+import qualified GI.Gdk as Gdk (EventType (..), getEventButtonButton, getEventButtonType) import qualified GI.Gtk as Gtk-import qualified DBusMenu-import System.Log.Logger (Priority(..), logM)-import System.Taffybar.Context (Context(..), TaffyIO)+import System.Log.Logger (Priority (..), logM)+import System.Taffybar.Context (Context (..), TaffyIO) import Text.Printf (printf) sniMenuLogger :: Priority -> String -> IO ()@@ -38,18 +41,22 @@ then do currentEvent <- Gtk.getCurrentEvent catchAny- (do let client = sessionDBusClient ctx+ ( do+ let client = sessionDBusClient ctx gtkMenu <- DBusMenu.buildMenu client busName menuPath Gtk.menuAttachToWidget gtkMenu ebox Nothing _ <- Gtk.onWidgetHide gtkMenu $- void $ GLib.idleAdd GLib.PRIORITY_LOW $ do- Gtk.widgetDestroy gtkMenu- return False+ void $+ GLib.idleAdd GLib.PRIORITY_LOW $ do+ Gtk.widgetDestroy gtkMenu+ return False Gtk.widgetShowAll gtkMenu- Gtk.menuPopupAtPointer gtkMenu currentEvent)- (sniMenuLogger WARNING- . printf "Failed to build SNI menu for %s: %s" (show busName)- . show)+ Gtk.menuPopupAtPointer gtkMenu currentEvent+ )+ ( sniMenuLogger WARNING+ . printf "Failed to build SNI menu for %s: %s" (show busName)+ . show+ ) return True else return False @@ -58,6 +65,7 @@ -- | Convenience wrapper: click to open nm-applet's network menu. withNmAppletMenu :: TaffyIO Gtk.Widget -> TaffyIO Gtk.Widget-withNmAppletMenu = withSniMenu- "org.freedesktop.network-manager-applet"- "/org/ayatana/NotificationItem/nm_applet/Menu"+withNmAppletMenu =+ withSniMenu+ "org.freedesktop.network-manager-applet"+ "/org/ayatana/NotificationItem/nm_applet/Menu"
src/System/Taffybar/Widget/SNITray.hs view
@@ -1,5 +1,10 @@ {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.SNITray -- Copyright : (c) Ivan A. Malison@@ -24,51 +29,283 @@ -- generally not recommended, because it can lead to issues with the -- registration of tray icons if taffybar crashes/restarts, or if tray icon -- providing applications are ever started before taffybar.------------------------------------------------------------------------------- module System.Taffybar.Widget.SNITray- ( TrayParams- , module System.Taffybar.Widget.SNITray- ) where+ ( TrayPriorityConfig,+ module System.Taffybar.Widget.SNITray,+ )+where -import Control.Monad.Trans.Class-import Control.Monad.Trans.Reader-import qualified GI.Gtk+import Control.Monad (void)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Reader+import Data.IORef+import Data.List (findIndex)+import qualified Data.Map.Strict as M+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import qualified GI.GLib as GLib+import qualified GI.Gdk as Gdk+import qualified GI.Gtk as Gtk import qualified StatusNotifier.Host.Service as H-import StatusNotifier.Tray-import System.Posix.Process-import System.Taffybar.Context-import System.Taffybar.Widget.Util-import Text.Printf+import StatusNotifier.Tray hiding (TrayItemMatcher, TrayParams, defaultTrayPriorityConfig)+import qualified StatusNotifier.Tray as Tray+import System.Posix.Process+import System.Taffybar.Context+import System.Taffybar.Widget.Util+import Text.Printf +-- | Parameters controlling tray construction and orientation.+type TrayParams = Tray.TrayParams++-- | Predicate used to match tray items in priority configuration.+type TrayItemMatcher = Tray.TrayItemMatcher++-- | Default tray icon priority configuration.+defaultTrayPriorityConfig :: TrayPriorityConfig+defaultTrayPriorityConfig = Tray.defaultTrayPriorityConfig++-- | Configuration for the base SNI tray widget.+data SNITrayConfig = SNITrayConfig+ { sniTrayTrayParams :: TrayParams,+ sniTrayPriorityConfig :: TrayPriorityConfig+ }++-- | Default 'SNITrayConfig'.+defaultSNITrayConfig :: SNITrayConfig+defaultSNITrayConfig =+ SNITrayConfig+ { sniTrayTrayParams = defaultTrayParams,+ sniTrayPriorityConfig = defaultTrayPriorityConfig+ }++-- | Configuration for the collapsible SNI tray widget.+data CollapsibleSNITrayParams = CollapsibleSNITrayParams+ { -- | Base tray configuration used to build the underlying SNI tray.+ collapsibleSNITrayConfig :: SNITrayConfig,+ -- | Maximum number of tray icons to show when collapsed.+ --+ -- Non-positive values disable collapsing.+ collapsibleSNITrayMaxVisibleIcons :: Int,+ -- | Optional collapsed-mode priority cutoff.+ --+ -- When set, collapsed mode shows only icons whose priority index is less+ -- than or equal to this value, where the index comes from+ -- 'trayPriorityMatchers' order in 'TrayPriorityConfig'. This takes+ -- precedence over 'collapsibleSNITrayMaxVisibleIcons' when collapsed.+ collapsibleSNITrayCollapsedPriorityCutoff :: Maybe Int,+ -- | Whether the tray starts expanded.+ collapsibleSNITrayStartExpanded :: Bool,+ -- | Show the indicator while expanded.+ -- Useful to allow clicking it again to collapse.+ collapsibleSNITrayShowIndicatorWhenExpanded :: Bool,+ -- | Label renderer for the indicator.+ --+ -- Arguments: @(hiddenCount, expanded)@.+ collapsibleSNITrayIndicatorLabel :: Int -> Bool -> T.Text+ }++-- | Default indicator label renderer for the collapsible tray.+defaultCollapsibleSNITrayIndicatorLabel :: Int -> Bool -> T.Text+defaultCollapsibleSNITrayIndicatorLabel hiddenCount expanded+ | expanded = "-"+ | otherwise = "+" <> T.pack (show hiddenCount)++-- | Default 'CollapsibleSNITrayParams'.+defaultCollapsibleSNITrayParams :: CollapsibleSNITrayParams+defaultCollapsibleSNITrayParams =+ CollapsibleSNITrayParams+ { collapsibleSNITrayConfig = defaultSNITrayConfig,+ collapsibleSNITrayMaxVisibleIcons = 6,+ collapsibleSNITrayCollapsedPriorityCutoff = Nothing,+ collapsibleSNITrayStartExpanded = False,+ collapsibleSNITrayShowIndicatorWhenExpanded = True,+ collapsibleSNITrayIndicatorLabel = defaultCollapsibleSNITrayIndicatorLabel+ }+ -- | Build a new StatusNotifierItem tray that will share a host with any other -- trays that are constructed automatically-sniTrayNew :: TaffyIO GI.Gtk.Widget-sniTrayNew = sniTrayNewFromParams defaultTrayParams+sniTrayNew :: TaffyIO Gtk.Widget+sniTrayNew = sniTrayNewFromConfig defaultSNITrayConfig +-- | Build a new StatusNotifierItem tray from custom 'SNITrayConfig'.+sniTrayNewFromConfig :: SNITrayConfig -> TaffyIO Gtk.Widget+sniTrayNewFromConfig config =+ getTrayHost False >>= sniTrayNewFromHostConfig config+ -- | Build a new StatusNotifierItem tray from the provided 'TrayParams'.-sniTrayNewFromParams :: TrayParams -> TaffyIO GI.Gtk.Widget+sniTrayNewFromParams :: TrayParams -> TaffyIO Gtk.Widget sniTrayNewFromParams params =- getTrayHost False >>= sniTrayNewFromHostParams params+ sniTrayNewFromConfig $+ defaultSNITrayConfig {sniTrayTrayParams = params} -- | Build a new StatusNotifierItem tray from the provided 'TrayParams' and -- 'H.Host'.-sniTrayNewFromHostParams :: TrayParams -> H.Host -> TaffyIO GI.Gtk.Widget-sniTrayNewFromHostParams params host = do+sniTrayNewFromHostParams :: TrayParams -> H.Host -> TaffyIO Gtk.Widget+sniTrayNewFromHostParams params =+ sniTrayNewFromHostConfig $+ defaultSNITrayConfig {sniTrayTrayParams = params}++-- | Build a new StatusNotifierItem tray from custom 'SNITrayConfig' and+-- 'H.Host'.+sniTrayNewFromHostConfig :: SNITrayConfig -> H.Host -> TaffyIO Gtk.Widget+sniTrayNewFromHostConfig SNITrayConfig {..} host = do client <- asks sessionDBusClient lift $ do- tray <- buildTray host client params+ tray <-+ buildTray+ host+ client+ sniTrayTrayParams {trayPriorityConfig = sniTrayPriorityConfig} _ <- widgetSetClassGI tray "sni-tray"- GI.Gtk.widgetShowAll tray- GI.Gtk.toWidget tray+ Gtk.widgetShowAll tray+ Gtk.toWidget tray +-- | Build a collapsible StatusNotifierItem tray with default params.+sniTrayCollapsibleNew :: TaffyIO Gtk.Widget+sniTrayCollapsibleNew =+ sniTrayCollapsibleNewFromParams defaultCollapsibleSNITrayParams++-- | Build a collapsible StatusNotifierItem tray from custom params.+sniTrayCollapsibleNewFromParams ::+ CollapsibleSNITrayParams -> TaffyIO Gtk.Widget+sniTrayCollapsibleNewFromParams params =+ getTrayHost False >>= sniTrayCollapsibleNewFromHostParams params++-- | Build a collapsible StatusNotifierItem tray from custom params and a host.+sniTrayCollapsibleNewFromHostParams ::+ CollapsibleSNITrayParams -> H.Host -> TaffyIO Gtk.Widget+sniTrayCollapsibleNewFromHostParams CollapsibleSNITrayParams {..} host = do+ client <- asks sessionDBusClient+ lift $ do+ let SNITrayConfig {..} = collapsibleSNITrayConfig+ TrayPriorityConfig {trayPriorityMatchers = priorityMatchers} = sniTrayPriorityConfig+ tray <-+ buildTray+ host+ client+ sniTrayTrayParams {trayPriorityConfig = sniTrayPriorityConfig}+ _ <- widgetSetClassGI tray "sni-tray"+ outer <- Gtk.boxNew (trayOrientation sniTrayTrayParams) 0+ _ <- widgetSetClassGI outer "sni-tray-collapsible"++ indicatorLabel <- Gtk.labelNew Nothing+ _ <- widgetSetClassGI indicatorLabel "sni-tray-overflow-indicator-label"+ indicator <- Gtk.eventBoxNew+ _ <- widgetSetClassGI indicator "sni-tray-overflow-indicator"+ Gtk.containerAdd indicator indicatorLabel++ Gtk.boxPackStart outer tray False False 0+ Gtk.boxPackStart outer indicator False False 0++ expandedRef <- newIORef collapsibleSNITrayStartExpanded++ let maxVisible = collapsibleSNITrayMaxVisibleIcons+ refresh = do+ children <- Gtk.containerGetChildren tray++ expanded <- readIORef expandedRef+ priorityVisibleCount <-+ case collapsibleSNITrayCollapsedPriorityCutoff of+ Nothing -> return Nothing+ Just cutoff -> do+ infoMap <- H.itemInfoMap host+ let getPriorityIndex info =+ fromMaybe+ (length priorityMatchers)+ (findIndex (`trayItemMatcherPredicate` info) priorityMatchers)+ visibleCount =+ length $+ filter+ (\info -> getPriorityIndex info <= cutoff)+ (M.elems infoMap)+ return (Just visibleCount)+ let shouldLimit = maybe (maxVisible > 0) (const True) priorityVisibleCount+ collapsedVisibleCount = fromMaybe maxVisible priorityVisibleCount+ visibleCount+ | shouldLimit && not expanded = max 0 collapsedVisibleCount+ | otherwise = length children+ visibleChildren = take visibleCount children+ hiddenChildren = drop visibleCount children+ hiddenCount = length hiddenChildren+ showIndicator+ | expanded =+ shouldLimit+ && length children > maxVisible+ && collapsibleSNITrayShowIndicatorWhenExpanded+ | otherwise = hiddenCount > 0+ indicatorText =+ collapsibleSNITrayIndicatorLabel hiddenCount expanded++ mapM_ Gtk.widgetShow visibleChildren+ mapM_ Gtk.widgetHide hiddenChildren++ if showIndicator+ then do+ Gtk.labelSetText indicatorLabel indicatorText+ Gtk.widgetSetTooltipText+ indicator+ ( Just $+ if expanded+ then "Collapse tray icons"+ else T.pack $ "Show " ++ show hiddenCount ++ " more tray icons"+ )+ Gtk.widgetShowAll indicator+ else do+ Gtk.widgetSetTooltipText indicator Nothing+ Gtk.widgetHide indicator++ if expanded+ then addClassIfMissing "sni-tray-collapsible-expanded" outer+ else removeClassIfPresent "sni-tray-collapsible-expanded" outer++ return hiddenCount++ _ <- Gtk.onWidgetButtonPressEvent indicator $ \event -> do+ eventType <- Gdk.getEventButtonType event+ button <- Gdk.getEventButtonButton event+ if eventType == Gdk.EventTypeButtonPress && button == 1+ then do+ hiddenCount <- refresh+ expanded <- readIORef expandedRef+ if hiddenCount > 0 || expanded+ then modifyIORef' expandedRef not >> void refresh >> return True+ else return False+ else return False++ let queueRefresh _ _ =+ void $+ Gdk.threadsAddIdle GLib.PRIORITY_DEFAULT_IDLE $+ void refresh >> return False+ handlerId <- H.addUpdateHandler host queueRefresh+ _ <- Gtk.onWidgetDestroy outer $ H.removeUpdateHandler host handlerId++ _ <- refresh+ Gtk.widgetShowAll outer+ Gtk.toWidget outer+ -- | Build a new StatusNotifierItem tray that also starts its own watcher, -- without depending on status-notifier-icon. This will not register applets -- started before the watcher is started.-sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt :: TaffyIO GI.Gtk.Widget+sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt :: TaffyIO Gtk.Widget sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt =- getTrayHost True >>= sniTrayNewFromHostParams defaultTrayParams+ sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoItFromConfig+ defaultSNITrayConfig +-- | Build a new StatusNotifierItem tray that also starts its own watcher,+-- with custom 'SNITrayConfig'.+sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoItFromConfig ::+ SNITrayConfig -> TaffyIO Gtk.Widget+sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoItFromConfig config =+ getTrayHost True >>= sniTrayNewFromHostConfig config++-- | Build a collapsible StatusNotifierItem tray that also starts its own+-- watcher, without depending on status-notifier-icon.+sniTrayCollapsibleThatStartsWatcherEvenThoughThisIsABadWayToDoIt ::+ TaffyIO Gtk.Widget+sniTrayCollapsibleThatStartsWatcherEvenThoughThisIsABadWayToDoIt =+ getTrayHost True+ >>= sniTrayCollapsibleNewFromHostParams defaultCollapsibleSNITrayParams+ -- | Get a 'H.Host' from 'TaffyIO' internal state, that can be used to construct -- SNI tray widgets. The boolean parameter determines whether or not a watcher -- will be started the first time 'getTrayHost' is invoked.@@ -76,10 +313,12 @@ getTrayHost startWatcher = getStateDefault $ do pid <- lift getProcessID client <- asks sessionDBusClient- Just host <- lift $ H.build H.defaultParams- { H.dbusClient = Just client- , H.uniqueIdentifier = printf "taffybar-%s" $ show pid- , H.startWatcher = startWatcher- }+ Just host <-+ lift $+ H.build+ H.defaultParams+ { H.dbusClient = Just client,+ H.uniqueIdentifier = printf "taffybar-%s" $ show pid,+ H.startWatcher = startWatcher+ } return host-
+ src/System/Taffybar/Widget/SNITray/PrioritizedCollapsible.hs view
@@ -0,0 +1,1030 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TupleSections #-}++-- |+-- Module : System.Taffybar.Widget.SNITray.PrioritizedCollapsible+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Prioritized, collapsible StatusNotifierItem tray with editable per-icon+-- priorities and persisted priority state.+module System.Taffybar.Widget.SNITray.PrioritizedCollapsible+ ( module System.Taffybar.Widget.SNITray.PrioritizedCollapsible,+ )+where++import Control.Applicative ((<|>))+import Control.Monad (forM_, guard, join, void)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Reader+import qualified DBus as D+import qualified DBus.Client as DBus+import qualified Data.Aeson as A+import qualified Data.Aeson.Key as AKey+import qualified Data.Aeson.KeyMap as AKeyMap+import Data.Aeson.Types (Parser, parseMaybe)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import Data.Char (isAlphaNum, isDigit, toLower)+import Data.IORef+import Data.Int (Int32)+import Data.List (isSuffixOf, nub, sortOn, stripPrefix)+import qualified Data.Map.Strict as M+import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList)+import Data.Ord (Down (..))+import qualified Data.Text as T+import Data.Word (Word32)+import qualified Data.Yaml as Y+import qualified GI.GLib as GLib+import qualified GI.Gdk as Gdk+import qualified GI.Gtk as Gtk+import Graphics.UI.GIGtkStrut (StrutAlignment (End))+import qualified StatusNotifier.Host.Service as H+import StatusNotifier.Tray+import qualified StatusNotifier.Tray as Tray+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.Environment.XDG.BaseDir (getUserConfigFile)+import System.FilePath (isRelative, replaceExtension, takeBaseName, takeDirectory, takeExtension)+import System.Taffybar.Context+import System.Taffybar.Widget.SNITray+ ( CollapsibleSNITrayParams (..),+ SNITrayConfig (..),+ defaultCollapsibleSNITrayParams,+ getTrayHost,+ )+import System.Taffybar.Widget.Util+import Text.Read (readMaybe)++type SNIPriorityMap = M.Map String Int++data SNIPriorityEntry = SNIPriorityEntry+ { sniPriorityEntryKey :: String,+ sniPriorityEntryPriority :: Int+ }++data SNIPriorityFile = SNIPriorityFile+ { sniPriorityFilePriorities :: SNIPriorityMap,+ sniPriorityFileMaxVisibleIcons :: Maybe Int,+ -- Nothing: no persisted override (use config default)+ -- Just Nothing: explicitly no threshold+ -- Just (Just n): explicit threshold n+ sniPriorityFileVisibilityThreshold :: Maybe (Maybe Int)+ }++instance A.FromJSON SNIPriorityEntry where+ parseJSON = A.withObject "SNIPriorityEntry" $ \obj ->+ SNIPriorityEntry+ <$> obj A..: "key"+ <*> obj A..: "priority"++instance A.ToJSON SNIPriorityEntry where+ toJSON SNIPriorityEntry {..} =+ A.object+ [ "key" A..= sniPriorityEntryKey,+ "priority" A..= sniPriorityEntryPriority+ ]++parsePrioritiesValue :: A.Value -> Parser SNIPriorityMap+parsePrioritiesValue value =+ parseEntries value <|> A.parseJSON value+ where+ parseEntries v = do+ entries <- A.parseJSON v :: Parser [SNIPriorityEntry]+ let toPair SNIPriorityEntry {..} =+ (sniPriorityEntryKey, sniPriorityEntryPriority)+ return $ M.fromList (map toPair entries)++instance A.FromJSON SNIPriorityFile where+ parseJSON = A.withObject "SNIPriorityFile" $ \obj -> do+ maybePriorities <- obj A..:? "priorities"+ priorities <- maybe (return M.empty) parsePrioritiesValue maybePriorities+ maxVisibleIcons <- obj A..:? "max_visible_icons"+ let thresholdKey = AKey.fromString "visibility_threshold"+ visibilityThreshold <-+ if AKeyMap.member thresholdKey obj+ then Just <$> obj A..:? "visibility_threshold"+ else return Nothing+ return $+ SNIPriorityFile+ { sniPriorityFilePriorities = priorities,+ sniPriorityFileMaxVisibleIcons = maxVisibleIcons,+ sniPriorityFileVisibilityThreshold = visibilityThreshold+ }++instance A.ToJSON SNIPriorityFile where+ toJSON SNIPriorityFile {..} =+ let sortedEntries =+ map+ (uncurry SNIPriorityEntry)+ (sortOn (\(key, priority) -> (Down priority, key)) (M.toList sniPriorityFilePriorities))+ in A.object+ ( [ "format_version" A..= (2 :: Int),+ "priorities" A..= sortedEntries+ ]+ <> maybe [] (\value -> ["max_visible_icons" A..= value]) sniPriorityFileMaxVisibleIcons+ <> maybe [] (\value -> ["visibility_threshold" A..= value]) sniPriorityFileVisibilityThreshold+ )++-- | Configuration for a collapsible tray with editable icon priorities.+data PrioritizedCollapsibleSNITrayParams = PrioritizedCollapsibleSNITrayParams+ { -- | Base collapsible tray parameters.+ prioritizedCollapsibleSNITrayParams :: CollapsibleSNITrayParams,+ -- | Path for priority state persistence.+ --+ -- Relative paths are resolved under @~/.config/taffybar/@.+ prioritizedCollapsibleSNITrayPriorityStateFile :: FilePath,+ -- | Minimum priority value.+ prioritizedCollapsibleSNITrayPriorityMin :: Int,+ -- | Maximum priority value.+ prioritizedCollapsibleSNITrayPriorityMax :: Int,+ -- | Default priority assigned to unmapped icons.+ prioritizedCollapsibleSNITrayDefaultPriority :: Int,+ -- | Hide icons with priority below this value.+ --+ -- This only applies while collapsed; expanded mode always shows all icons.+ prioritizedCollapsibleSNITrayVisibilityThreshold :: Maybe Int,+ -- | Whether priority edit mode starts enabled.+ prioritizedCollapsibleSNITrayStartPriorityEditMode :: Bool,+ -- | Always show the expand/collapse toggle button.+ prioritizedCollapsibleSNITrayAlwaysShowExpandToggle :: Bool,+ -- | Label renderer for the priority-edit-mode toggle.+ --+ -- Argument: @editing@.+ prioritizedCollapsibleSNITrayPriorityModeLabel :: Bool -> T.Text+ }++defaultPrioritizedCollapsibleSNITrayPriorityModeLabel :: Bool -> T.Text+defaultPrioritizedCollapsibleSNITrayPriorityModeLabel editing+ | editing = "P*"+ | otherwise = "P"++-- | Default params for 'PrioritizedCollapsibleSNITrayParams'.+defaultPrioritizedCollapsibleSNITrayParams :: PrioritizedCollapsibleSNITrayParams+defaultPrioritizedCollapsibleSNITrayParams =+ PrioritizedCollapsibleSNITrayParams+ { prioritizedCollapsibleSNITrayParams = defaultCollapsibleSNITrayParams,+ prioritizedCollapsibleSNITrayPriorityStateFile = "sni-priorities.yaml",+ prioritizedCollapsibleSNITrayPriorityMin = -5,+ prioritizedCollapsibleSNITrayPriorityMax = 5,+ prioritizedCollapsibleSNITrayDefaultPriority = 0,+ prioritizedCollapsibleSNITrayVisibilityThreshold = Nothing,+ prioritizedCollapsibleSNITrayStartPriorityEditMode = False,+ prioritizedCollapsibleSNITrayAlwaysShowExpandToggle = True,+ prioritizedCollapsibleSNITrayPriorityModeLabel = defaultPrioritizedCollapsibleSNITrayPriorityModeLabel+ }++clampPriorityInRange :: Int -> Int -> Int -> Int+clampPriorityInRange priorityMin priorityMax =+ max priorityMin . min priorityMax++resolveSNIPriorityStateFile :: FilePath -> IO FilePath+resolveSNIPriorityStateFile path+ | isRelative path = getUserConfigFile "taffybar" path+ | otherwise = return path++parseLegacySNIPriorityMap :: BS.ByteString -> Maybe SNIPriorityMap+parseLegacySNIPriorityMap content =+ M.fromList <$> (readMaybe (BS8.unpack content) :: Maybe [(String, Int)])++parseLegacySNIPriorityFile :: BS.ByteString -> Maybe SNIPriorityFile+parseLegacySNIPriorityFile content =+ legacyFile <$> parseLegacySNIPriorityMap content+ where+ legacyFile priorities =+ SNIPriorityFile+ { sniPriorityFilePriorities = priorities,+ sniPriorityFileMaxVisibleIcons = Nothing,+ sniPriorityFileVisibilityThreshold = Nothing+ }++parseSNIPriorityFile :: BS.ByteString -> Maybe SNIPriorityFile+parseSNIPriorityFile content =+ parseYamlState <|> parseYamlPriorityOnly <|> parseLegacySNIPriorityFile content+ where+ parseYamlState =+ either+ (const Nothing)+ Just+ (Y.decodeEither' content :: Either Y.ParseException SNIPriorityFile)+ parseYamlPriorityOnly = do+ yamlValue <- either (const Nothing) Just (Y.decodeEither' content :: Either Y.ParseException A.Value)+ priorities <- parseMaybe parsePrioritiesValue yamlValue+ return $+ SNIPriorityFile+ { sniPriorityFilePriorities = priorities,+ sniPriorityFileMaxVisibleIcons = Nothing,+ sniPriorityFileVisibilityThreshold = Nothing+ }++loadSNIPriorityFileFromPath :: FilePath -> IO (Maybe SNIPriorityFile)+loadSNIPriorityFileFromPath path = do+ exists <- doesFileExist path+ if not exists+ then return Nothing+ else parseSNIPriorityFile <$> BS.readFile path++legacyPriorityStateFallbackPath :: FilePath -> Maybe FilePath+legacyPriorityStateFallbackPath path+ | takeExtension path == ".yaml" = Just (replaceExtension path "dat")+ | otherwise = Nothing++loadSNIPriorityFileFromFile :: FilePath -> IO SNIPriorityFile+loadSNIPriorityFileFromFile path = do+ loaded <- loadSNIPriorityFileFromPath path+ case loaded of+ Just priorityFile -> return priorityFile+ Nothing ->+ case legacyPriorityStateFallbackPath path of+ Nothing -> return emptyPriorityFile+ Just legacyPath -> do+ fallbackLoaded <- loadSNIPriorityFileFromPath legacyPath+ case fallbackLoaded of+ Nothing -> return emptyPriorityFile+ Just priorityFile -> do+ -- One-time migration path for existing show/read .dat files.+ persistSNIPriorityFileToFile path priorityFile+ return priorityFile++emptyPriorityFile :: SNIPriorityFile+emptyPriorityFile =+ SNIPriorityFile+ { sniPriorityFilePriorities = M.empty,+ sniPriorityFileMaxVisibleIcons = Nothing,+ sniPriorityFileVisibilityThreshold = Nothing+ }++persistSNIPriorityFileToFile :: FilePath -> SNIPriorityFile -> IO ()+persistSNIPriorityFileToFile path priorityFile = do+ createDirectoryIfMissing True (takeDirectory path)+ BS.writeFile path (Y.encode priorityFile)++nonEmptyString :: String -> Maybe String+nonEmptyString value+ | null value = Nothing+ | otherwise = Just value++itemStableIdentity :: H.ItemInfo -> String+itemStableIdentity info =+ show (H.itemServiceName info) <> "|" <> show (H.itemServicePath info)++itemStableIdentityKey :: H.ItemInfo -> String+itemStableIdentityKey info = "item-identity:" ++ itemStableIdentity info++hasNumericSuffix :: String -> String -> Bool+hasNumericSuffix prefix value =+ case stripPrefix prefix value of+ Just suffix -> not (null suffix) && all isDigit suffix+ Nothing -> False++unstableItemIdPrefixes :: [String]+unstableItemIdPrefixes =+ ["chrome_status_icon_", "systray_", "statusnotifieritem-", "statusnotifieritem_"]++sharedItemIdPrefixes :: [String]+sharedItemIdPrefixes = ["chrome_status_icon_", "systray_"]++matchingItemIdPrefix :: [String] -> String -> Maybe String+matchingItemIdPrefix prefixes itemId =+ let lowerItemId = map toLower itemId+ matchingPrefixes =+ filter (`hasNumericSuffix` lowerItemId) prefixes+ in case matchingPrefixes of+ prefix : _ -> Just (take (length prefix) itemId)+ [] -> Nothing++unstableItemIdPrefix :: String -> Maybe String+unstableItemIdPrefix = matchingItemIdPrefix unstableItemIdPrefixes++sharedItemIdPrefix :: String -> Maybe String+sharedItemIdPrefix = matchingItemIdPrefix sharedItemIdPrefixes++isLikelyUnstableItemId :: String -> Bool+isLikelyUnstableItemId = isJust . unstableItemIdPrefix++stableItemIdKey :: H.ItemInfo -> Maybe String+stableItemIdKey info = do+ itemId <- H.itemId info >>= nonEmptyString+ guard (not (isLikelyUnstableItemId itemId))+ return ("item-id:" ++ itemId)++unstableItemIdKey :: H.ItemInfo -> Maybe String+unstableItemIdKey info = do+ itemId <- H.itemId info >>= nonEmptyString+ guard (isLikelyUnstableItemId itemId)+ return ("item-id:" ++ itemId)++sharedItemIdPrefixIconNameKey :: H.ItemInfo -> Maybe String+sharedItemIdPrefixIconNameKey info = do+ itemId <- H.itemId info >>= nonEmptyString+ prefix <- sharedItemIdPrefix itemId+ iconName <- nonEmptyString (H.iconName info)+ return ("item-id-prefix+icon-name:" ++ prefix ++ "|" ++ iconName)++sharedItemIdPrefixIconTitleKey :: H.ItemInfo -> Maybe String+sharedItemIdPrefixIconTitleKey info = do+ itemId <- H.itemId info >>= nonEmptyString+ prefix <- sharedItemIdPrefix itemId+ iconTitle <- nonEmptyString (H.iconTitle info)+ return ("item-id-prefix+icon-title:" ++ prefix ++ "|" ++ iconTitle)++isLikelySharedItemIdForInfo :: H.ItemInfo -> Bool+isLikelySharedItemIdForInfo info =+ case H.itemId info >>= nonEmptyString of+ Nothing -> False+ Just itemId -> isJust (sharedItemIdPrefix itemId)++normalizeProcessToken :: String -> String+normalizeProcessToken =+ dropWhile (== '-') . reverse . dropWhile (== '-') . reverse . map normalizeChar+ where+ normalizeChar c+ | isAlphaNum c = toLower c+ | otherwise = '-'++processIdentityTokenFromArg :: String -> Maybe String+processIdentityTokenFromArg arg+ | ".asar" `isSuffixOf` arg =+ nonEmptyString (takeBaseName (takeDirectory arg))+ | otherwise = Nothing++processIdentityTokenFromCmdline :: [String] -> Maybe String+processIdentityTokenFromCmdline [] = Nothing+processIdentityTokenFromCmdline (exeArg : args) =+ let argToken = listToMaybe (mapMaybe processIdentityTokenFromArg args)+ exeToken = nonEmptyString (takeBaseName exeArg)+ normalized = normalizeProcessToken <$> (argToken <|> exeToken)+ in normalized >>= nonEmptyString++readProcessCommandLine :: Word32 -> IO [String]+readProcessCommandLine pid = do+ let cmdlinePath = "/proc/" ++ show pid ++ "/cmdline"+ exists <- doesFileExist cmdlinePath+ if not exists+ then return []+ else do+ bytes <- BS.readFile cmdlinePath+ return $ filter (not . null) (map BS8.unpack (BS8.split '\0' bytes))++dbusDaemonName :: D.BusName+dbusDaemonName = D.busName_ "org.freedesktop.DBus"++dbusDaemonPath :: D.ObjectPath+dbusDaemonPath = D.objectPath_ "/org/freedesktop/DBus"++getConnectionUnixProcessID :: DBus.Client -> D.BusName -> IO (Maybe Word32)+getConnectionUnixProcessID client busName = do+ let method =+ (D.methodCall dbusDaemonPath "org.freedesktop.DBus" "GetConnectionUnixProcessID")+ { D.methodCallDestination = Just dbusDaemonName,+ D.methodCallBody = [D.toVariant (D.formatBusName busName)]+ }+ result <- DBus.call client method+ case result of+ Left _ -> return Nothing+ Right reply ->+ case D.methodReturnBody reply of+ [pidVariant] -> return (D.fromVariant pidVariant)+ _ -> return Nothing++processDisambiguationKeyForItem :: DBus.Client -> H.ItemInfo -> IO (Maybe String)+processDisambiguationKeyForItem client info = do+ maybePid <- getConnectionUnixProcessID client (H.itemServiceName info)+ case maybePid of+ Nothing -> return Nothing+ Just pid -> do+ cmdline <- readProcessCommandLine pid+ return $ ("process:" ++) <$> processIdentityTokenFromCmdline cmdline++processDisambiguationKeysForItems :: DBus.Client -> [H.ItemInfo] -> IO (M.Map String String)+processDisambiguationKeysForItems client infos = do+ pairs <- mapM withKey infos+ return $ M.fromList (catMaybes pairs)+ where+ withKey info+ | not (isLikelySharedItemIdForInfo info) = return Nothing+ | otherwise = do+ processKey <- processDisambiguationKeyForItem client info+ return $ fmap (itemStableIdentity info,) processKey++priorityLookupKeyCandidates :: Maybe String -> H.ItemInfo -> [String]+priorityLookupKeyCandidates maybeProcessKey info =+ nub $+ concat+ [ maybeToList maybeProcessKey,+ map ("icon-name:" ++) (maybeToList (nonEmptyString (H.iconName info))),+ maybeToList (stableItemIdKey info),+ map ("icon-title:" ++) (maybeToList (nonEmptyString (H.iconTitle info))),+ maybeToList (sharedItemIdPrefixIconNameKey info),+ maybeToList (sharedItemIdPrefixIconTitleKey info),+ [itemStableIdentityKey info],+ maybeToList (unstableItemIdKey info)+ ]++priorityEditableKeyCandidates :: Maybe String -> H.ItemInfo -> [String]+priorityEditableKeyCandidates maybeProcessKey info =+ nub $+ concat+ [ maybeToList maybeProcessKey,+ map ("icon-name:" ++) (maybeToList (nonEmptyString (H.iconName info))),+ maybeToList (stableItemIdKey info),+ map ("icon-title:" ++) (maybeToList (nonEmptyString (H.iconTitle info))),+ maybeToList (sharedItemIdPrefixIconNameKey info),+ maybeToList (sharedItemIdPrefixIconTitleKey info),+ [itemStableIdentityKey info],+ maybeToList (unstableItemIdKey info)+ ]++priorityKeyFromItem :: Maybe String -> H.ItemInfo -> Maybe String+priorityKeyFromItem maybeProcessKey =+ listToMaybe . priorityEditableKeyCandidates maybeProcessKey++itemPriorityFromMap ::+ Int ->+ Int ->+ Int ->+ SNIPriorityMap ->+ Maybe String ->+ H.ItemInfo ->+ Int+itemPriorityFromMap priorityMin priorityMax defaultPriority priorities maybeProcessKey info =+ let clampPriority = clampPriorityInRange priorityMin priorityMax+ matchedPriority =+ listToMaybe $+ mapMaybe (`M.lookup` priorities) (priorityLookupKeyCandidates maybeProcessKey info)+ in clampPriority (fromMaybe defaultPriority matchedPriority)++itemIdentityMatcher :: H.ItemInfo -> Tray.TrayItemMatcher+itemIdentityMatcher info =+ let stableIdentity = itemStableIdentity info+ in mkTrayItemMatcher+ ("priority:identity:" <> stableIdentity)+ (\candidate -> itemStableIdentity candidate == stableIdentity)++priorityMatchersFromMapAndItems ::+ Bool ->+ Int ->+ Int ->+ Int ->+ SNIPriorityMap ->+ (H.ItemInfo -> Maybe String) ->+ [H.ItemInfo] ->+ [Tray.TrayItemMatcher]+priorityMatchersFromMapAndItems highPriorityFirstInMatcherOrder priorityMin priorityMax defaultPriority priorities processKeyForInfo infos =+ let sortedInfos =+ sortedInfosByPriority+ highPriorityFirstInMatcherOrder+ priorityMin+ priorityMax+ defaultPriority+ priorities+ processKeyForInfo+ infos+ fallbackMatcher = mkTrayItemMatcher "priority:identity:fallback" (const True)+ in map itemIdentityMatcher sortedInfos ++ [fallbackMatcher]++sortedInfosByPriority ::+ Bool ->+ Int ->+ Int ->+ Int ->+ SNIPriorityMap ->+ (H.ItemInfo -> Maybe String) ->+ [H.ItemInfo] ->+ [H.ItemInfo]+sortedInfosByPriority highPriorityFirstInMatcherOrder priorityMin priorityMax defaultPriority priorities processKeyForInfo infos =+ let itemPriority info =+ itemPriorityFromMap+ priorityMin+ priorityMax+ defaultPriority+ priorities+ (processKeyForInfo info)+ info+ prioritySortKey info =+ if highPriorityFirstInMatcherOrder+ then negate (itemPriority info)+ else itemPriority info+ in -- Matchers may need to be reversed to keep higher numeric priorities on the+ -- visual left when the tray is end-aligned.+ sortOn+ (\info -> (prioritySortKey info, itemStableIdentity info))+ infos++lookupExplicitPriority ::+ SNIPriorityMap ->+ Maybe String ->+ H.ItemInfo ->+ Maybe Int+lookupExplicitPriority priorities maybeProcessKey info =+ listToMaybe $ mapMaybe (`M.lookup` priorities) (priorityLookupKeyCandidates maybeProcessKey info)++setExplicitPriorityForItem ::+ IORef SNIPriorityMap ->+ (SNIPriorityMap -> IO ()) ->+ Maybe String ->+ H.ItemInfo ->+ Maybe Int ->+ IO ()+setExplicitPriorityForItem prioritiesRef afterUpdate maybeProcessKey info newPriority =+ case priorityKeyFromItem maybeProcessKey info of+ Nothing -> return ()+ Just primaryKey -> do+ let editableKeys = priorityEditableKeyCandidates maybeProcessKey info+ modifyIORef' prioritiesRef $ \priorities ->+ case newPriority of+ Nothing -> foldr M.delete priorities editableKeys+ Just priority -> M.insert primaryKey priority priorities+ readIORef prioritiesRef >>= afterUpdate++menuIconSize :: Int32+menuIconSize = fromIntegral $ fromEnum Gtk.IconSizeMenu++showPriorityEditMenu ::+ Gtk.Widget ->+ Int ->+ Int ->+ Int ->+ Maybe Int ->+ (Maybe Int -> IO ()) ->+ IO ()+showPriorityEditMenu anchor priorityMin priorityMax defaultPriority currentExplicit onSelection = do+ currentEvent <- Gtk.getCurrentEvent+ menu <- Gtk.menuNew+ Gtk.menuAttachToWidget menu anchor Nothing++ let options = [priorityMax, priorityMax - 1 .. priorityMin]+ forM_ options $ \priority -> do+ let prefix =+ if currentExplicit == Just priority+ then "\x2713 " :: T.Text+ else " "+ labelText = prefix <> "Set priority " <> T.pack (show priority)+ item <- Gtk.menuItemNewWithLabel labelText+ void $ Gtk.onMenuItemActivate item $ onSelection (Just priority)+ Gtk.menuShellAppend menu item++ sep <- Gtk.separatorMenuItemNew+ Gtk.menuShellAppend menu sep++ let clearPrefix =+ if isNothing currentExplicit+ then "\x2713 " :: T.Text+ else " "+ clearLabel =+ clearPrefix+ <> "Clear override (default "+ <> T.pack (show defaultPriority)+ <> ")"+ clearItem <- Gtk.menuItemNewWithLabel clearLabel+ void $ Gtk.onMenuItemActivate clearItem $ onSelection Nothing+ Gtk.menuShellAppend menu clearItem++ void $+ Gtk.onWidgetHide menu $+ void $+ GLib.idleAdd GLib.PRIORITY_LOW $ do+ Gtk.widgetDestroy menu+ return False++ Gtk.widgetShowAll menu+ Gtk.menuPopupAtPointer menu currentEvent++showPrioritySettingsMenu ::+ Gtk.EventBox ->+ Int ->+ Int ->+ IORef Int ->+ IORef (Maybe Int) ->+ IO () ->+ IO ()+showPrioritySettingsMenu anchor priorityMin priorityMax maxVisibleRef thresholdRef onSettingsChanged = do+ currentEvent <- Gtk.getCurrentEvent+ currentMaxVisible <- readIORef maxVisibleRef+ currentThreshold <- readIORef thresholdRef++ menu <- Gtk.menuNew+ Gtk.menuAttachToWidget menu anchor Nothing++ maxVisibleItem <- Gtk.menuItemNewWithLabel ("Max visible (collapsed)" :: T.Text)+ maxVisibleMenu <- Gtk.menuNew+ Gtk.menuItemSetSubmenu maxVisibleItem (Just maxVisibleMenu)+ let maxVisibleOptions = [0 .. 20]+ forM_ maxVisibleOptions $ \option -> do+ let optionLabel =+ if option <= 0+ then "No limit" :: T.Text+ else T.pack (show option)+ prefix =+ if option == currentMaxVisible+ then "\x2713 " :: T.Text+ else " "+ item <- Gtk.menuItemNewWithLabel (prefix <> optionLabel)+ void $ Gtk.onMenuItemActivate item $ do+ writeIORef maxVisibleRef option+ onSettingsChanged+ Gtk.menuShellAppend maxVisibleMenu item+ Gtk.menuShellAppend menu maxVisibleItem++ thresholdItem <- Gtk.menuItemNewWithLabel ("Priority threshold" :: T.Text)+ thresholdMenu <- Gtk.menuNew+ Gtk.menuItemSetSubmenu thresholdItem (Just thresholdMenu)+ let thresholdOptions = Nothing : map Just [priorityMin .. priorityMax]+ forM_ thresholdOptions $ \option -> do+ let optionLabel =+ case option of+ Nothing -> "No threshold" :: T.Text+ Just value -> ">= " <> T.pack (show value)+ prefix =+ if option == currentThreshold+ then "\x2713 " :: T.Text+ else " "+ item <- Gtk.menuItemNewWithLabel (prefix <> optionLabel)+ void $ Gtk.onMenuItemActivate item $ do+ writeIORef thresholdRef option+ onSettingsChanged+ Gtk.menuShellAppend thresholdMenu item+ Gtk.menuShellAppend menu thresholdItem++ void $+ Gtk.onWidgetHide menu $+ void $+ GLib.idleAdd GLib.PRIORITY_LOW $ do+ Gtk.widgetDestroy menu+ return False++ Gtk.widgetShowAll menu+ Gtk.menuPopupAtPointer menu currentEvent++-- | Build a collapsible StatusNotifierItem tray with priority editing controls+-- and persisted priority state.+sniTrayPrioritizedCollapsibleNew :: TaffyIO Gtk.Widget+sniTrayPrioritizedCollapsibleNew =+ sniTrayPrioritizedCollapsibleNewFromParams defaultPrioritizedCollapsibleSNITrayParams++-- | Build a prioritized collapsible tray from custom params.+sniTrayPrioritizedCollapsibleNewFromParams ::+ PrioritizedCollapsibleSNITrayParams -> TaffyIO Gtk.Widget+sniTrayPrioritizedCollapsibleNewFromParams params =+ getTrayHost False >>= sniTrayPrioritizedCollapsibleNewFromHostParams params++-- | Build a prioritized collapsible tray from custom params and a host.+sniTrayPrioritizedCollapsibleNewFromHostParams ::+ PrioritizedCollapsibleSNITrayParams -> H.Host -> TaffyIO Gtk.Widget+sniTrayPrioritizedCollapsibleNewFromHostParams PrioritizedCollapsibleSNITrayParams {..} host = do+ client <- asks sessionDBusClient+ lift $ do+ let CollapsibleSNITrayParams {..} = prioritizedCollapsibleSNITrayParams+ SNITrayConfig {..} = collapsibleSNITrayConfig+ rawPriorityMin = prioritizedCollapsibleSNITrayPriorityMin+ rawPriorityMax = prioritizedCollapsibleSNITrayPriorityMax+ priorityMin = min rawPriorityMin rawPriorityMax+ priorityMax = max rawPriorityMin rawPriorityMax+ clampPriority = clampPriorityInRange priorityMin priorityMax+ defaultPriority = clampPriority prioritizedCollapsibleSNITrayDefaultPriority+ visibilityThreshold = clampPriority <$> prioritizedCollapsibleSNITrayVisibilityThreshold+ trayOrientation' = trayOrientation sniTrayTrayParams+ highPriorityFirstInMatcherOrder =+ case trayAlignment sniTrayTrayParams of+ End -> False+ _ -> True++ statePath <- resolveSNIPriorityStateFile prioritizedCollapsibleSNITrayPriorityStateFile+ persistedPriorityFile <- loadSNIPriorityFileFromFile statePath+ let persistedPriorities =+ M.map clampPriority (sniPriorityFilePriorities persistedPriorityFile)+ initialMaxVisibleIcons =+ max+ 0+ ( fromMaybe+ collapsibleSNITrayMaxVisibleIcons+ (sniPriorityFileMaxVisibleIcons persistedPriorityFile)+ )+ initialVisibilityThreshold =+ case sniPriorityFileVisibilityThreshold persistedPriorityFile of+ Nothing -> visibilityThreshold+ Just persistedThreshold -> clampPriority <$> persistedThreshold+ prioritiesRef <- newIORef persistedPriorities+ expandedRef <- newIORef collapsibleSNITrayStartExpanded+ priorityEditModeRef <- newIORef prioritizedCollapsibleSNITrayStartPriorityEditMode+ maxVisibleIconsRef <- newIORef initialMaxVisibleIcons+ visibilityThresholdRef <- newIORef initialVisibilityThreshold+ knownItemIdentitiesRef <- newIORef ([] :: [String])+ orderedInfosRef <- newIORef ([] :: [H.ItemInfo])+ processDisambiguationKeysRef <- newIORef (M.empty :: M.Map String String)+ trayRef <- newIORef Nothing+ rebuildTrayRef <- newIORef (return ())++ outer <- Gtk.boxNew trayOrientation' 0+ _ <- widgetSetClassGI outer "sni-tray-collapsible"+ _ <- widgetSetClassGI outer "sni-tray-prioritized-collapsible"+ outerWidget <- Gtk.toWidget outer++ trayContainer <- Gtk.boxNew trayOrientation' 0+ _ <- widgetSetClassGI trayContainer "sni-tray-collapsible-container"++ overflowCountLabel <- Gtk.labelNew Nothing+ _ <- widgetSetClassGI overflowCountLabel "sni-tray-overflow-count-label"++ expandIcon <- Gtk.imageNewFromIconName (Just "pan-down-symbolic") menuIconSize+ expandToggle <- Gtk.eventBoxNew+ _ <- widgetSetClassGI expandToggle "sni-tray-expand-toggle"+ Gtk.containerAdd expandToggle expandIcon++ priorityModeIcon <- Gtk.imageNewFromIconName (Just "document-edit-symbolic") menuIconSize+ priorityModeToggle <- Gtk.eventBoxNew+ _ <- widgetSetClassGI priorityModeToggle "sni-tray-edit-toggle"+ Gtk.containerAdd priorityModeToggle priorityModeIcon++ settingsIcon <- Gtk.imageNewFromIconName (Just "emblem-system-symbolic") menuIconSize+ settingsToggle <- Gtk.eventBoxNew+ _ <- widgetSetClassGI settingsToggle "sni-tray-settings-toggle"+ Gtk.containerAdd settingsToggle settingsIcon+ Gtk.widgetSetTooltipText settingsToggle (Just "Tray display settings")++ Gtk.boxPackStart outer trayContainer False False 0+ Gtk.boxPackStart outer overflowCountLabel False False 0+ Gtk.boxPackStart outer expandToggle False False 0+ Gtk.boxPackStart outer priorityModeToggle False False 0+ Gtk.boxPackStart outer settingsToggle False False 0++ let queueRebuild = do+ rebuild <- readIORef rebuildTrayRef+ void $+ Gdk.threadsAddIdle GLib.PRIORITY_DEFAULT_IDLE $+ rebuild >> return False++ persistCurrentState = do+ priorities <- readIORef prioritiesRef+ maxVisibleIcons <- readIORef maxVisibleIconsRef+ visibilityThresholdOverride <- readIORef visibilityThresholdRef+ persistSNIPriorityFileToFile+ statePath+ SNIPriorityFile+ { sniPriorityFilePriorities = priorities,+ sniPriorityFileMaxVisibleIcons = Just (max 0 maxVisibleIcons),+ sniPriorityFileVisibilityThreshold = Just visibilityThresholdOverride+ }++ processKeyForInfoFromMap processKeyMap info =+ M.lookup (itemStableIdentity info) processKeyMap++ editPriorityForClick clickContext = do+ let clickedInfo = trayClickItemInfo clickContext+ priorities <- readIORef prioritiesRef+ processKeyMap <- readIORef processDisambiguationKeysRef+ let maybeProcessKey = processKeyForInfoFromMap processKeyMap clickedInfo+ currentExplicit =+ lookupExplicitPriority priorities maybeProcessKey clickedInfo+ updatePriority newPriority = do+ setExplicitPriorityForItem+ prioritiesRef+ (\_ -> persistCurrentState >> queueRebuild)+ maybeProcessKey+ clickedInfo+ (fmap clampPriority newPriority)+ showPriorityEditMenu+ outerWidget+ priorityMin+ priorityMax+ defaultPriority+ currentExplicit+ updatePriority++ refreshPriorityModeToggle = do+ editing <- readIORef priorityEditModeRef+ let tooltipText =+ if editing+ then "Disable icon priority edit mode"+ else "Enable icon priority edit mode"+ Gtk.widgetSetTooltipText+ priorityModeToggle+ (Just tooltipText)+ if editing+ then addClassIfMissing "sni-tray-edit-toggle-active" priorityModeToggle+ else removeClassIfPresent "sni-tray-edit-toggle-active" priorityModeToggle+ if editing+ then do+ addClassIfMissing "sni-tray-editing" outer+ else do+ removeClassIfPresent "sni-tray-editing" outer+ Gtk.widgetShowAll priorityModeToggle++ refresh = do+ maybeTray <- readIORef trayRef+ case maybeTray of+ Nothing -> return 0+ Just tray -> do+ children <- Gtk.containerGetChildren tray+ expanded <- readIORef expandedRef+ priorities <- readIORef prioritiesRef+ maxVisibleIcons <- readIORef maxVisibleIconsRef+ thresholdValue <- readIORef visibilityThresholdRef+ orderedInfos <- readIORef orderedInfosRef+ processKeyMap <- readIORef processDisambiguationKeysRef++ let itemPriority info =+ itemPriorityFromMap+ priorityMin+ priorityMax+ defaultPriority+ priorities+ (processKeyForInfoFromMap processKeyMap info)+ info+ totalCount = length children+ collapsedThresholdVisibleCount =+ case thresholdValue of+ Nothing -> totalCount+ Just threshold ->+ min+ totalCount+ (length $ filter (\info -> itemPriority info >= threshold) orderedInfos)+ collapsedVisibleCount+ | maxVisibleIcons > 0 =+ min collapsedThresholdVisibleCount maxVisibleIcons+ | otherwise = collapsedThresholdVisibleCount+ visibleCount+ | expanded = totalCount+ | otherwise = collapsedVisibleCount+ visibleChildren = take visibleCount children+ hiddenChildren = drop visibleCount children+ hiddenCount = length hiddenChildren+ showExpandToggle =+ prioritizedCollapsibleSNITrayAlwaysShowExpandToggle+ || ( if expanded+ then collapsibleSNITrayShowIndicatorWhenExpanded && hiddenCount > 0+ else hiddenCount > 0+ )+ expandIconName =+ if expanded+ then "pan-up-symbolic"+ else "pan-down-symbolic"+ expandTooltip =+ if expanded+ then "Allow tray icon hiding"+ else "Show all tray icons"+ hiddenCountText = T.pack (show hiddenCount)++ mapM_ Gtk.widgetShow visibleChildren+ mapM_ Gtk.widgetHide hiddenChildren++ Gtk.imageSetFromIconName expandIcon (Just expandIconName) menuIconSize+ if showExpandToggle+ then do+ Gtk.widgetSetTooltipText expandToggle (Just expandTooltip)+ Gtk.widgetShowAll expandToggle+ else do+ Gtk.widgetSetTooltipText expandToggle Nothing+ Gtk.widgetHide expandToggle++ if hiddenCount > 0+ then do+ Gtk.labelSetText overflowCountLabel hiddenCountText+ Gtk.widgetShow overflowCountLabel+ else do+ Gtk.labelSetText overflowCountLabel ""+ Gtk.widgetHide overflowCountLabel++ if expanded+ then addClassIfMissing "sni-tray-collapsible-expanded" outer+ else removeClassIfPresent "sni-tray-collapsible-expanded" outer++ return hiddenCount++ buildTrayWithPriorities priorities processKeyMap infos = do+ let priorityConfig =+ sniTrayPriorityConfig+ { trayPriorityMatchers =+ priorityMatchersFromMapAndItems+ highPriorityFirstInMatcherOrder+ priorityMin+ priorityMax+ defaultPriority+ priorities+ (processKeyForInfoFromMap processKeyMap)+ infos+ }+ baseHooks = trayEventHooks sniTrayTrayParams+ baseClickHook = trayClickHook baseHooks+ combinedClickHook clickContext = do+ editMode <- readIORef priorityEditModeRef+ if editMode+ then do+ editPriorityForClick clickContext+ return ConsumeClick+ else case baseClickHook of+ Just clickHook -> clickHook clickContext+ Nothing -> return UseDefaultClickAction+ trayParams =+ sniTrayTrayParams+ { trayEventHooks =+ baseHooks {trayClickHook = Just combinedClickHook}+ }+ tray <- buildTray host client trayParams {trayPriorityConfig = priorityConfig}+ _ <- widgetSetClassGI tray "sni-tray"+ return tray++ rebuildTray = do+ priorities <- readIORef prioritiesRef+ infoMap <- H.itemInfoMap host+ processKeyMap <- processDisambiguationKeysForItems client (M.elems infoMap)+ let infos = M.elems infoMap+ orderedInfos =+ sortedInfosByPriority+ highPriorityFirstInMatcherOrder+ priorityMin+ priorityMax+ defaultPriority+ priorities+ (processKeyForInfoFromMap processKeyMap)+ infos+ currentItemIdentities =+ sortOn id (map itemStableIdentity infos)+ tray <- buildTrayWithPriorities priorities processKeyMap orderedInfos+ Gtk.widgetHide tray+ oldTray <- readIORef trayRef+ forM_ oldTray $ \existingTray -> do+ Gtk.containerRemove trayContainer existingTray+ Gtk.widgetDestroy existingTray+ Gtk.boxPackStart trayContainer tray False False 0+ writeIORef trayRef (Just tray)+ writeIORef orderedInfosRef orderedInfos+ writeIORef processDisambiguationKeysRef processKeyMap+ writeIORef knownItemIdentitiesRef currentItemIdentities+ void refresh+ Gtk.widgetShow tray++ writeIORef rebuildTrayRef rebuildTray++ _ <- Gtk.onWidgetButtonPressEvent expandToggle $ \event -> do+ eventType <- Gdk.getEventButtonType event+ button <- Gdk.getEventButtonButton event+ if eventType == Gdk.EventTypeButtonPress && button == 1+ then do+ modifyIORef' expandedRef not+ void refresh+ return True+ else return False++ _ <- Gtk.onWidgetButtonPressEvent priorityModeToggle $ \event -> do+ eventType <- Gdk.getEventButtonType event+ button <- Gdk.getEventButtonButton event+ if eventType == Gdk.EventTypeButtonPress && button == 1+ then do+ modifyIORef' priorityEditModeRef not+ refreshPriorityModeToggle+ return True+ else return False++ _ <- Gtk.onWidgetButtonPressEvent settingsToggle $ \event -> do+ eventType <- Gdk.getEventButtonType event+ button <- Gdk.getEventButtonButton event+ if eventType == Gdk.EventTypeButtonPress && button == 1+ then do+ showPrioritySettingsMenu+ settingsToggle+ priorityMin+ priorityMax+ maxVisibleIconsRef+ visibilityThresholdRef+ (persistCurrentState >> void refresh)+ return True+ else return False++ let queueRefresh updateType _ =+ void $+ Gdk.threadsAddIdle GLib.PRIORITY_DEFAULT_IDLE $+ do+ case updateType of+ H.ItemAdded -> do+ infoMap <- H.itemInfoMap host+ knownItemIdentities <- readIORef knownItemIdentitiesRef+ let currentItemIdentities =+ sortOn id (map itemStableIdentity (M.elems infoMap))+ if currentItemIdentities /= knownItemIdentities+ then do+ join (readIORef rebuildTrayRef)+ else void refresh+ H.ItemRemoved -> do+ infoMap <- H.itemInfoMap host+ knownItemIdentities <- readIORef knownItemIdentitiesRef+ let currentItemIdentities =+ sortOn id (map itemStableIdentity (M.elems infoMap))+ if currentItemIdentities /= knownItemIdentities+ then do+ join (readIORef rebuildTrayRef)+ else void refresh+ _ -> void refresh+ return False+ handlerId <- H.addUpdateHandler host queueRefresh+ _ <- Gtk.onWidgetDestroy outer $ H.removeUpdateHandler host handlerId++ rebuildTray+ refreshPriorityModeToggle+ _ <- refresh+ Gtk.widgetShowAll outer+ return outerWidget
src/System/Taffybar/Widget/ScreenLock.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.ScreenLock -- Copyright : (c) Ivan A. Malison@@ -33,44 +37,46 @@ -- > { screenLockIcon = "\xF023" -- > , screenLockInhibitTypes = [InhibitIdle, InhibitSleep] -- > }------------------------------------------------------------------------------ module System.Taffybar.Widget.ScreenLock- ( screenLockNew- , screenLockNewWithConfig- , ScreenLockConfig(..)- , defaultScreenLockConfig- ) where+ ( screenLockNew,+ screenLockNewWithConfig,+ ScreenLockConfig (..),+ defaultScreenLockConfig,+ )+where -import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Reader-import Data.Default (Default(..))+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Reader+import Data.Default (Default (..)) import qualified Data.Text as T-import qualified GI.Gdk as Gdk import qualified GI.GLib as GLib+import qualified GI.Gdk as Gdk import qualified GI.Gtk as Gtk-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.Information.Inhibitor (InhibitType(..), InhibitorState(..))-import System.Taffybar.Information.ScreenLock-import System.Taffybar.Util (postGUIASync)-import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Util (widgetSetClassGI, addClassIfMissing, removeClassIfPresent)+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Information.Inhibitor (InhibitType (..), InhibitorState (..))+import System.Taffybar.Information.ScreenLock+import System.Taffybar.Util (postGUIASync)+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Util (addClassIfMissing, removeClassIfPresent, widgetSetClassGI) -- | Configuration for the screen lock widget. data ScreenLockConfig = ScreenLockConfig { -- | Icon text to display (default: U+F023, nf-fa-lock).- screenLockIcon :: T.Text+ screenLockIcon :: T.Text, -- | What types of inhibitors to manage (default: @[InhibitIdle]@).- , screenLockInhibitTypes :: [InhibitType]- } deriving (Eq, Show)+ screenLockInhibitTypes :: [InhibitType]+ }+ deriving (Eq, Show) -- | Default configuration for the screen lock widget. defaultScreenLockConfig :: ScreenLockConfig-defaultScreenLockConfig = ScreenLockConfig- { screenLockIcon = T.pack "\xF023"- , screenLockInhibitTypes = [InhibitIdle]- }+defaultScreenLockConfig =+ ScreenLockConfig+ { screenLockIcon = T.pack "\xF023",+ screenLockInhibitTypes = [InhibitIdle]+ } instance Default ScreenLockConfig where def = defaultScreenLockConfig@@ -78,7 +84,7 @@ screenLockLogPath :: String screenLockLogPath = "System.Taffybar.Widget.ScreenLock" -screenLockLog :: MonadIO m => Priority -> String -> m ()+screenLockLog :: (MonadIO m) => Priority -> String -> m () screenLockLog priority = liftIO . logM screenLockLogPath priority -- | Create a screen lock widget with default configuration.@@ -114,8 +120,8 @@ else removeClassIfPresent "screen-lock-inhibited" ebox let tooltipText = if active- then "Screen Lock (idle inhibitor active)"- else "Screen Lock"+ then "Screen Lock (idle inhibitor active)"+ else "Screen Lock" Gtk.widgetSetTooltipText ebox (Just tooltipText) -- Set initial state on realize@@ -172,10 +178,12 @@ Gtk.menuShellAppend menu inhibitItem -- Destroy menu when hidden (same pattern as SNIMenu)- void $ Gtk.onWidgetHide menu $- void $ GLib.idleAdd GLib.PRIORITY_LOW $ do- Gtk.widgetDestroy menu- return False+ void $+ Gtk.onWidgetHide menu $+ void $+ GLib.idleAdd GLib.PRIORITY_LOW $ do+ Gtk.widgetDestroy menu+ return False Gtk.widgetShowAll menu Gtk.menuPopupAtPointer menu currentEvent
src/System/Taffybar/Widget/SimpleClock.hs view
@@ -1,30 +1,33 @@ {-# LANGUAGE OverloadedStrings #-}++-- | Text clock widget with optional popup calendar. module System.Taffybar.Widget.SimpleClock- ( textClockNew- , textClockNewWith- , defaultClockConfig- , ClockConfig(..)- , ClockUpdateStrategy(..)- ) where+ ( textClockNew,+ textClockNewWith,+ defaultClockConfig,+ ClockConfig (..),+ ClockUpdateStrategy (..),+ )+where -import Control.Monad.IO.Class-import Data.Default ( Default(..) )-import Data.Maybe+import Control.Monad.IO.Class+import Data.Default (Default (..))+import Data.Maybe import qualified Data.Text as T-import Data.Time.Calendar ( toGregorian )+import Data.Time.Calendar (toGregorian) import qualified Data.Time.Clock as Clock-import Data.Time.Format-import Data.Time.LocalTime+import Data.Time.Format+import Data.Time.LocalTime import qualified Data.Time.Locale.Compat as L import qualified GI.Gdk as Gdk-import GI.Gtk-import System.Taffybar.Widget.Generic.PollingLabel-import System.Taffybar.Widget.Util+import GI.Gtk+import System.Taffybar.Util (VariableDelayConfig, defaultVariableDelayConfig)+import System.Taffybar.Widget.Generic.PollingLabel+import System.Taffybar.Widget.Util -- | This module implements a very simple text-based clock widget. The widget -- also toggles a calendar widget when clicked. This calendar is not fancy at -- all and has no data backend.- makeCalendar :: IO TimeZone -> IO Window makeCalendar tzfn = do container <- windowNew WindowTypeToplevel@@ -39,11 +42,11 @@ resetCalendarDate cal tzfn = do tz <- tzfn current <- Clock.getCurrentTime- let (y,m,d) = toGregorian $ localDay $ utcToLocalTime tz current+ let (y, m, d) = toGregorian $ localDay $ utcToLocalTime tz current calendarSelectMonth cal (fromIntegral m - 1) (fromIntegral y) calendarSelectDay cal (fromIntegral d) -toggleCalendar :: IsWidget w => w -> Window -> IO Bool+toggleCalendar :: (IsWidget w) => w -> Window -> IO Bool toggleCalendar w c = do isVis <- widgetGetVisible c if isVis@@ -57,35 +60,47 @@ -- parameter. The format string can include Pango markup -- (<http://developer.gnome.org/pango/stable/PangoMarkupFormat.html>). textClockNew ::- MonadIO m => Maybe L.TimeLocale -> String -> Double -> m GI.Gtk.Widget+ (MonadIO m) => Maybe L.TimeLocale -> String -> Double -> m GI.Gtk.Widget textClockNew userLocale format interval = textClockNewWith cfg where- cfg = def { clockTimeLocale = userLocale- , clockFormatString = format- , clockUpdateStrategy = ConstantInterval interval- }+ cfg =+ def+ { clockTimeLocale = userLocale,+ clockFormatString = format,+ clockUpdateStrategy = ConstantInterval interval+ } +-- | Strategy for scheduling clock updates. data ClockUpdateStrategy- = ConstantInterval Double- | RoundedTargetInterval Int Double+ = -- | Re-render at a fixed interval in seconds.+ ConstantInterval Double+ | -- | Re-render near rounded time boundaries.+ -- The first argument is the rounding step in seconds and the second is an+ -- offset in seconds.+ RoundedTargetInterval Int Double deriving (Eq, Ord, Show) +-- | Configuration for 'textClockNewWith'. data ClockConfig = ClockConfig- { clockTimeZone :: Maybe TimeZone- , clockTimeLocale :: Maybe L.TimeLocale- , clockFormatString :: String- , clockUpdateStrategy :: ClockUpdateStrategy- } deriving (Eq, Ord, Show)+ { clockTimeZone :: Maybe TimeZone,+ clockTimeLocale :: Maybe L.TimeLocale,+ clockFormatString :: String,+ clockUpdateStrategy :: ClockUpdateStrategy,+ clockVariableDelayConfig :: VariableDelayConfig Double+ }+ deriving (Eq, Ord, Show) -- | A clock configuration that defaults to the current locale defaultClockConfig :: ClockConfig-defaultClockConfig = ClockConfig- { clockTimeZone = Nothing- , clockTimeLocale = Nothing- , clockFormatString = "%a %b %_d %r"- , clockUpdateStrategy = RoundedTargetInterval 5 0.0- }+defaultClockConfig =+ ClockConfig+ { clockTimeZone = Nothing,+ clockTimeLocale = Nothing,+ clockFormatString = "%a %b %_d %r",+ clockUpdateStrategy = RoundedTargetInterval 5 0.0,+ clockVariableDelayConfig = defaultVariableDelayConfig+ } instance Default ClockConfig where def = defaultClockConfig@@ -94,54 +109,64 @@ -- a configurable time zone through the 'ClockConfig'. -- -- See also 'textClockNew'.-textClockNewWith :: MonadIO m => ClockConfig -> m Widget-textClockNewWith ClockConfig- { clockTimeZone = userZone- , clockTimeLocale = userLocale- , clockFormatString = formatString- , clockUpdateStrategy = updateStrategy- } = liftIO $ do- let getTZ = maybe getCurrentTimeZone return userZone- locale = fromMaybe L.defaultTimeLocale userLocale+textClockNewWith :: (MonadIO m) => ClockConfig -> m Widget+textClockNewWith+ ClockConfig+ { clockTimeZone = userZone,+ clockTimeLocale = userLocale,+ clockFormatString = formatString,+ clockUpdateStrategy = updateStrategy,+ clockVariableDelayConfig = variableDelayConfig+ } = liftIO $ do+ let getTZ = maybe getCurrentTimeZone return userZone+ locale = fromMaybe L.defaultTimeLocale userLocale - let getUserZonedTime =- utcToZonedTime <$> getTZ <*> Clock.getCurrentTime+ let getUserZonedTime =+ utcToZonedTime <$> getTZ <*> Clock.getCurrentTime - doTimeFormat zonedTime = T.pack $ formatTime locale formatString zonedTime+ doTimeFormat zonedTime = T.pack $ formatTime locale formatString zonedTime - getRoundedTimeAndNextTarget = do- zonedTime <- getUserZonedTime- return $ case updateStrategy of- ConstantInterval interval ->- (doTimeFormat zonedTime, Nothing, interval)- RoundedTargetInterval roundSeconds offset ->- let roundSecondsDiffTime = fromIntegral roundSeconds- addTheRound = addLocalTime roundSecondsDiffTime- localTime = zonedTimeToLocalTime zonedTime- ourLocalTimeOfDay = localTimeOfDay localTime- seconds = round $ todSec ourLocalTimeOfDay- secondsFactor = seconds `div` roundSeconds- displaySeconds = secondsFactor * roundSeconds- baseLocalTimeOfDay =- ourLocalTimeOfDay { todSec = fromIntegral displaySeconds }- ourLocalTime =- localTime { localTimeOfDay = baseLocalTimeOfDay }- roundedLocalTime =- if seconds `mod` roundSeconds > roundSeconds `div` 2- then addTheRound ourLocalTime- else ourLocalTime- roundedZonedTime =- zonedTime { zonedTimeToLocalTime = roundedLocalTime }- nextTarget = addTheRound ourLocalTime- amountToWait = realToFrac $ diffLocalTime nextTarget localTime- in (doTimeFormat roundedZonedTime, Nothing, amountToWait - offset)+ getRoundedTimeAndNextTarget = do+ zonedTime <- getUserZonedTime+ return $ case updateStrategy of+ ConstantInterval interval ->+ (doTimeFormat zonedTime, Nothing, interval)+ RoundedTargetInterval roundSeconds offset ->+ let roundSecondsDiffTime = fromIntegral roundSeconds+ addTheRound = addLocalTime roundSecondsDiffTime+ localTime = zonedTimeToLocalTime zonedTime+ ourLocalTimeOfDay = localTimeOfDay localTime+ seconds = round $ todSec ourLocalTimeOfDay+ secondsFactor = seconds `div` roundSeconds+ displaySeconds = secondsFactor * roundSeconds+ baseLocalTimeOfDay =+ ourLocalTimeOfDay {todSec = fromIntegral displaySeconds}+ ourLocalTime =+ localTime {localTimeOfDay = baseLocalTimeOfDay}+ roundedLocalTime =+ if seconds `mod` roundSeconds > roundSeconds `div` 2+ then addTheRound ourLocalTime+ else ourLocalTime+ roundedZonedTime =+ zonedTime {zonedTimeToLocalTime = roundedLocalTime}+ nextTarget = addTheRound ourLocalTime+ amountToWait = realToFrac $ diffLocalTime nextTarget localTime+ in (doTimeFormat roundedZonedTime, Nothing, amountToWait - offset) - label <- pollingLabelWithVariableDelay getRoundedTimeAndNextTarget- ebox <- eventBoxNew- containerAdd ebox label- eventBoxSetVisibleWindow ebox False- cal <- makeCalendar getTZ- _ <- onWidgetButtonPressEvent ebox $ onClick [Gdk.EventTypeButtonPress] $- toggleCalendar label cal- widgetShowAll ebox- toWidget ebox+ let pollingConfig =+ defaultPollingLabelConfig+ { pollingLabelVariableDelayConfig = variableDelayConfig+ }+ label <- pollingLabelWithVariableDelayWithConfig pollingConfig getRoundedTimeAndNextTarget+ _ <- widgetSetClassGI label "text-clock-label"+ ebox <- eventBoxNew+ _ <- widgetSetClassGI ebox "text-clock"+ containerAdd ebox label+ eventBoxSetVisibleWindow ebox False+ cal <- makeCalendar getTZ+ _ <-+ onWidgetButtonPressEvent ebox $+ onClick [Gdk.EventTypeButtonPress] $+ toggleCalendar label cal+ widgetShowAll ebox+ toWidget ebox
src/System/Taffybar/Widget/SimpleCommandButton.hs view
@@ -1,4 +1,9 @@+{-# LANGUAGE OverloadedStrings #-}+ --------------------------------------------------------------------------------++--------------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.SimpleCommandButton -- Copyright : (c) Ulf Jasper@@ -9,19 +14,19 @@ -- Portability : unportable -- -- Simple button which runs a user defined command when being clicked-----------------------------------------------------------------------------------module System.Taffybar.Widget.SimpleCommandButton (- -- * Usage- -- $usage- simpleCommandButtonNew)+module System.Taffybar.Widget.SimpleCommandButton+ ( -- * Usage+ -- $usage+ simpleCommandButtonNew,+ ) where -import Control.Monad (void)-import Control.Monad.IO.Class-import GI.Gtk-import System.Process+import Control.Monad (void)+import Control.Monad.IO.Class import qualified Data.Text as T+import GI.Gtk+import System.Process+import System.Taffybar.Widget.Util (widgetSetClassGI) -- $usage --@@ -35,12 +40,15 @@ -- Now you can use @cmdButton@ like any other Taffybar widget. -- | Creates a new simple command button.-simpleCommandButtonNew- :: MonadIO m- => T.Text -- ^ Contents of the button's label.- -> T.Text -- ^ Command to execute. Should be in $PATH or an absolute path- -> m Widget-simpleCommandButtonNew txt cmd = do+simpleCommandButtonNew ::+ (MonadIO m) =>+ -- | Contents of the button's label.+ T.Text ->+ -- | Command to execute. Should be in $PATH or an absolute path+ T.Text ->+ m Widget+simpleCommandButtonNew txt cmd = do button <- buttonNewWithLabel txt+ _ <- widgetSetClassGI button "simple-command-button" void $ onButtonClicked button $ void $ spawnCommand $ T.unpack cmd toWidget button
src/System/Taffybar/Widget/Systemd.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Systemd -- Copyright : (c) Ivan A. Malison@@ -26,58 +30,60 @@ -- > .systemd-widget { padding: 0 5px; } -- > .systemd-ok { color: #98c379; } -- > .systemd-degraded { color: #e06c75; }------------------------------------------------------------------------------ module System.Taffybar.Widget.Systemd- ( SystemdConfig(..)- , defaultSystemdConfig- , systemdIconNew- , systemdIconNewWithConfig- , systemdLabelNew- , systemdLabelNewWithConfig- , systemdNew- , systemdNewWithConfig- ) where+ ( SystemdConfig (..),+ defaultSystemdConfig,+ systemdIconNew,+ systemdIconNewWithConfig,+ systemdLabelNew,+ systemdLabelNewWithConfig,+ systemdNew,+ systemdNewWithConfig,+ )+where -import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Reader-import Data.Default (Default(..))+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Reader+import Data.Default (Default (..)) import qualified Data.Text as T-import GI.Gtk as Gtk-import System.Taffybar.Context-import System.Taffybar.Information.Systemd-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Util-import Text.StringTemplate+import GI.Gtk as Gtk+import System.Taffybar.Context+import System.Taffybar.Information.Systemd+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Util+import Text.StringTemplate -- | Configuration options for the systemd widget. data SystemdConfig = SystemdConfig { -- | Format string when there are failed units. -- Available variables: $count$, $system$, $user$- systemdFormat :: String+ systemdFormat :: String, -- | Format string when there are no failures (only used if hideOnOk is False).- , systemdFormatOk :: String+ systemdFormatOk :: String, -- | Whether to hide the widget when there are no failed units.- , systemdHideOnOk :: Bool+ systemdHideOnOk :: Bool, -- | Whether to monitor system units.- , systemdMonitorSystem :: Bool+ systemdMonitorSystem :: Bool, -- | Whether to monitor user units.- , systemdMonitorUser :: Bool+ systemdMonitorUser :: Bool, -- | Nerd font icon character (default U+F071, warning triangle).- , systemdIcon :: T.Text- } deriving (Eq, Show)+ systemdIcon :: T.Text+ }+ deriving (Eq, Show) -- | Default configuration for the systemd widget. defaultSystemdConfig :: SystemdConfig-defaultSystemdConfig = SystemdConfig- { systemdFormat = "$count$"- , systemdFormatOk = "\x2713" -- Check mark- , systemdHideOnOk = True- , systemdMonitorSystem = True- , systemdMonitorUser = True- , systemdIcon = T.pack "\xF071" -- Warning triangle- }+defaultSystemdConfig =+ SystemdConfig+ { systemdFormat = "$count$",+ systemdFormatOk = "\x2713", -- Check mark+ systemdHideOnOk = True,+ systemdMonitorSystem = True,+ systemdMonitorUser = True,+ systemdIcon = T.pack "\xF071" -- Warning triangle+ } instance Default SystemdConfig where def = defaultSystemdConfig@@ -140,7 +146,7 @@ computeEffectiveCount config info = let sysCount = if systemdMonitorSystem config then systemFailedCount info else 0 usrCount = if systemdMonitorUser config then userFailedCount info else 0- in sysCount + usrCount+ in sysCount + usrCount -- | Update the label text based on the current state. updateSystemdLabel :: SystemdConfig -> Gtk.Label -> SystemdInfo -> IO ()@@ -149,11 +155,13 @@ isOk = effectiveCount == 0 formatStr = if isOk then systemdFormatOk config else systemdFormat config tpl = newSTMP formatStr- tpl' = setManyAttrib- [ ("count", show effectiveCount)- , ("system", show (systemFailedCount info))- , ("user", show (userFailedCount info))- ] tpl+ tpl' =+ setManyAttrib+ [ ("count", show effectiveCount),+ ("system", show (systemFailedCount info)),+ ("user", show (userFailedCount info))+ ]+ tpl labelSetMarkup label (render tpl') -- | Update widget visibility based on configuration.
src/System/Taffybar/Widget/Temperature.hs view
@@ -1,4 +1,7 @@ --------------------------------------------------------------------------------+--------------------------------------------------------------------------------+{-# LANGUAGE OverloadedStrings #-}+ -- | -- Module : System.Taffybar.Widget.Temperature -- Copyright : (c) Ivan Malison@@ -11,58 +14,57 @@ -- A widget for displaying system temperature. Monitors thermal zones and/or -- hwmon devices and displays the current temperature with configurable -- thresholds for warning and critical states.--------------------------------------------------------------------------------------{-# LANGUAGE OverloadedStrings #-}- module System.Taffybar.Widget.Temperature ( -- * Combined icon+label widget- temperatureNew- , temperatureNewWith+ temperatureNew,+ temperatureNewWith,+ -- * Icon-only widget- , temperatureIconNew- , temperatureIconNewWith+ temperatureIconNew,+ temperatureIconNewWith,+ -- * Label-only widget- , temperatureLabelNew- , temperatureLabelNewWith+ temperatureLabelNew,+ temperatureLabelNewWith,+ -- * Configuration- , TemperatureConfig(..)- , defaultTemperatureConfig+ TemperatureConfig (..),+ defaultTemperatureConfig,+ -- * Re-exports- , TemperatureUnit(..)- ) where+ TemperatureUnit (..),+ )+where import Control.Monad.IO.Class (MonadIO, liftIO)-import Data.Default (Default(..))+import Data.Default (Default (..)) import Data.List (intercalate) import qualified Data.Text as T import qualified GI.Gtk as Gtk-import qualified Text.StringTemplate as ST- import System.Taffybar.Information.Temperature import System.Taffybar.Widget.Generic.PollingLabel (pollingLabelNewWithTooltip)-import System.Taffybar.Widget.Util (buildIconLabelBox)+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI)+import qualified Text.StringTemplate as ST -- | Configuration for the temperature widget data TemperatureConfig = TemperatureConfig- { tempFormat :: String- -- ^ Format string for the label. Available variables:+ { -- | Format string for the label. Available variables: -- temp (in configured unit), tempC (Celsius), tempF (Fahrenheit), tempK (Kelvin)- , tempUnit :: TemperatureUnit- -- ^ Unit to use for the {temp} variable (default: Celsius)- , tempWarningThreshold :: Double- -- ^ Temperature (in Celsius) at which to show warning style (default: 70)- , tempCriticalThreshold :: Double- -- ^ Temperature (in Celsius) at which to show critical style (default: 85)- , tempPollInterval :: Double- -- ^ How often to poll for temperature updates, in seconds (default: 10)- , tempSensorFilter :: ThermalSensor -> Bool- -- ^ Filter function to select which sensors to monitor (default: all)- , tempAggregation :: [TemperatureInfo] -> Maybe Double- -- ^ How to aggregate multiple sensor readings (default: maximum)- , temperatureIcon :: T.Text- -- ^ Nerd font icon character (default U+F2C9, nf-fa-thermometer).+ tempFormat :: String,+ -- | Unit to use for the {temp} variable (default: Celsius)+ tempUnit :: TemperatureUnit,+ -- | Temperature (in Celsius) at which to show warning style (default: 70)+ 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)+ tempPollInterval :: Double,+ -- | Filter function to select which sensors to monitor (default: all)+ tempSensorFilter :: ThermalSensor -> Bool,+ -- | How to aggregate multiple sensor readings (default: maximum)+ tempAggregation :: [TemperatureInfo] -> Maybe Double,+ -- | Nerd font icon character (default U+F2C9, nf-fa-thermometer).+ temperatureIcon :: T.Text } instance Default TemperatureConfig where@@ -70,48 +72,51 @@ -- | Default configuration for the temperature widget defaultTemperatureConfig :: TemperatureConfig-defaultTemperatureConfig = TemperatureConfig- { tempFormat = "$temp$\176C" -- degree symbol- , tempUnit = Celsius- , tempWarningThreshold = 70- , tempCriticalThreshold = 85- , tempPollInterval = 10- , tempSensorFilter = const True- , tempAggregation = \temps ->- if null temps- then Nothing- else Just $ maximum $ map tempCelsius temps- , temperatureIcon = T.pack "\xF2C9"- }+defaultTemperatureConfig =+ TemperatureConfig+ { tempFormat = "$temp$\176C", -- degree symbol+ tempUnit = Celsius,+ tempWarningThreshold = 70,+ tempCriticalThreshold = 85,+ tempPollInterval = 10,+ tempSensorFilter = const True,+ tempAggregation = \temps ->+ if null temps+ then Nothing+ else Just $ maximum $ map tempCelsius temps,+ temperatureIcon = T.pack "\xF2C9"+ } -- | Create a combined icon+label temperature widget with default configuration.-temperatureNew :: MonadIO m => m Gtk.Widget+temperatureNew :: (MonadIO m) => m Gtk.Widget temperatureNew = temperatureNewWith defaultTemperatureConfig -- | Create a combined icon+label temperature widget.-temperatureNewWith :: MonadIO m => TemperatureConfig -> m Gtk.Widget+temperatureNewWith :: (MonadIO m) => TemperatureConfig -> m Gtk.Widget temperatureNewWith config = liftIO $ do iconWidget <- temperatureIconNewWith config labelWidget <- temperatureLabelNewWith config buildIconLabelBox iconWidget labelWidget+ >>= (`widgetSetClassGI` "temperature") -- | Create a temperature icon widget with default configuration.-temperatureIconNew :: MonadIO m => m Gtk.Widget+temperatureIconNew :: (MonadIO m) => m Gtk.Widget temperatureIconNew = temperatureIconNewWith defaultTemperatureConfig -- | Create a temperature icon widget with the provided configuration.-temperatureIconNewWith :: MonadIO m => TemperatureConfig -> m Gtk.Widget+temperatureIconNewWith :: (MonadIO m) => TemperatureConfig -> m Gtk.Widget temperatureIconNewWith config = liftIO $ do label <- Gtk.labelNew (Just (temperatureIcon config))+ _ <- widgetSetClassGI label "temperature-icon" Gtk.widgetShowAll label Gtk.toWidget label -- | Create a temperature label widget with default configuration.-temperatureLabelNew :: MonadIO m => m Gtk.Widget+temperatureLabelNew :: (MonadIO m) => m Gtk.Widget temperatureLabelNew = temperatureLabelNewWith defaultTemperatureConfig -- | Create a temperature label widget with custom configuration.-temperatureLabelNewWith :: MonadIO m => TemperatureConfig -> m Gtk.Widget+temperatureLabelNewWith :: (MonadIO m) => TemperatureConfig -> m Gtk.Widget temperatureLabelNewWith config = liftIO $ do -- Discover sensors once at startup, filtered by config allSensors <- discoverSensors@@ -128,13 +133,7 @@ labelText = formatTemperature config tempDisplay tempC tempF tempK tooltipText = formatTooltip temps return (labelText, Just tooltipText)-- -- We need to update CSS classes dynamically, but pollingLabel doesn't- -- support that directly. Instead, we set up the widget and update classes- -- in the polling callback through a custom approach.- -- For now, we'll use a simpler approach with the base widget.- Gtk.toWidget widget-+ widgetSetClassGI widget "temperature-label" where readTemperaturesFiltered :: [ThermalSensor] -> IO [TemperatureInfo] readTemperaturesFiltered sensors =@@ -144,13 +143,15 @@ formatTemperature :: TemperatureConfig -> Double -> Double -> Double -> Double -> T.Text formatTemperature config tempDisplay tempC tempF tempK = let template = ST.newSTMP (tempFormat config)- template' = ST.setManyAttrib- [ ("temp", formatDouble tempDisplay)- , ("tempC", formatDouble tempC)- , ("tempF", formatDouble tempF)- , ("tempK", formatDouble tempK)- ] template- in T.pack $ ST.render template'+ template' =+ ST.setManyAttrib+ [ ("temp", formatDouble tempDisplay),+ ("tempC", formatDouble tempC),+ ("tempF", formatDouble tempF),+ ("tempK", formatDouble tempK)+ ]+ template+ in T.pack $ ST.render template' where formatDouble :: Double -> String formatDouble d = show (round d :: Int)@@ -161,5 +162,7 @@ T.pack $ intercalate "\n" $ map formatSensor temps where formatSensor info =- sensorName (tempSensor info) ++ ": " ++- show (round (tempCelsius info) :: Int) ++ "\176C"+ sensorName (tempSensor info)+ ++ ": "+ ++ show (round (tempCelsius info) :: Int)+ ++ "\176C"
src/System/Taffybar/Widget/Text/CPUMonitor.hs view
@@ -1,30 +1,52 @@+{-# LANGUAGE NamedFieldPuns #-}++-- | Textual CPU monitor widgets. module System.Taffybar.Widget.Text.CPUMonitor (textCpuMonitorNew) where -import Control.Monad.IO.Class ( MonadIO )-import Text.Printf ( printf )-import qualified Text.StringTemplate as ST-import System.Taffybar.Information.CPU-import System.Taffybar.Widget.Generic.PollingLabel ( pollingLabelNew )+import Control.Monad (void)+import Control.Monad.IO.Class (MonadIO, liftIO)+import qualified Data.Text as T import qualified GI.Gtk+import System.Taffybar.Information.CPU2 (CPULoad (..), getCPULoadChan)+import System.Taffybar.Util (postGUIASync)+import System.Taffybar.Widget.Generic.ChannelWidget (channelWidgetNew)+import System.Taffybar.Widget.Util (widgetSetClassGI)+import Text.Printf (printf)+import qualified Text.StringTemplate as ST -- | Creates a simple textual CPU monitor. It updates once every polling -- period (in seconds).-textCpuMonitorNew :: MonadIO m- => String -- ^ Format. You can use variables: $total$, $user$, $system$- -> Double -- ^ Polling period (in seconds)- -> m GI.Gtk.Widget-textCpuMonitorNew fmt period = do- label <- pollingLabelNew period callback- GI.Gtk.toWidget label- where- callback = do- (userLoad, systemLoad, totalLoad) <- cpuLoad- let pct = formatPercent . (* 100)- let template = ST.newSTMP fmt- let template' = ST.setManyAttrib [ ("user", pct userLoad),- ("system", pct systemLoad),- ("total", pct totalLoad) ] template- return $ ST.render template'+textCpuMonitorNew ::+ (MonadIO m) =>+ -- | Format. You can use variables: $total$, $user$, $system$+ String ->+ -- | Polling period (in seconds)+ Double ->+ m GI.Gtk.Widget+textCpuMonitorNew fmt period = liftIO $ do+ chan <- getCPULoadChan "cpu" period+ label <- GI.Gtk.labelNew Nothing+ _ <- widgetSetClassGI label (T.pack "text-cpu-monitor")+ void $+ channelWidgetNew label chan $ \sample ->+ postGUIASync $+ GI.Gtk.labelSetMarkup label $+ renderCpuInfo fmt sample+ widget <- GI.Gtk.toWidget label+ widgetSetClassGI widget (T.pack "text-cpu-monitor")++renderCpuInfo :: String -> CPULoad -> T.Text+renderCpuInfo fmt CPULoad {cpuUserLoad, cpuSystemLoad, cpuTotalLoad} =+ let pct = formatPercent . (* 100)+ template = ST.newSTMP fmt+ template' =+ ST.setManyAttrib+ [ ("user", pct cpuUserLoad),+ ("system", pct cpuSystemLoad),+ ("total", pct cpuTotalLoad)+ ]+ template+ in T.pack $ ST.render template' formatPercent :: Double -> String formatPercent = printf "%.2f"
src/System/Taffybar/Widget/Text/MemoryMonitor.hs view
@@ -1,64 +1,99 @@+-- | Text memory monitor helpers built on top of+-- "System.Taffybar.Information.Memory". module System.Taffybar.Widget.Text.MemoryMonitor (textMemoryMonitorNew, showMemoryInfo) where -import Control.Monad.IO.Class ( MonadIO )+import Control.Monad.IO.Class (MonadIO) import qualified Data.Text as T-import qualified Text.StringTemplate as ST-import System.Taffybar.Information.Memory-import System.Taffybar.Widget.Generic.PollingLabel ( pollingLabelNew ) import qualified GI.Gtk-import Text.Printf ( printf )+import System.Taffybar.Information.Memory+import System.Taffybar.Widget.Generic.PollingLabel (pollingLabelNew)+import System.Taffybar.Widget.Util (widgetSetClassGI)+import Text.Printf (printf)+import qualified Text.StringTemplate as ST -- | Creates a simple textual memory monitor. It updates once every polling -- period (in seconds).-textMemoryMonitorNew :: MonadIO m- => String -- ^ Format. You can use variables: "used", "total", "free", "buffer",- -- "cache", "rest", "available", "swapUsed", "swapTotal", "swapFree".- -> Double -- ^ Polling period in seconds.- -> m GI.Gtk.Widget+textMemoryMonitorNew ::+ (MonadIO m) =>+ -- | Format. You can use variables: "used", "total", "free", "buffer",+ -- "cache", "rest", "available", "swapUsed", "swapTotal", "swapFree",+ -- "usedRatio", "swapUsedRatio", "usedPercent", "swapUsedPercent".+ String ->+ -- | Polling period in seconds.+ Double ->+ m GI.Gtk.Widget textMemoryMonitorNew fmt period = do- label <- pollingLabelNew period (showMemoryInfo fmt 3 <$> parseMeminfo)- GI.Gtk.toWidget label+ label <- pollingLabelNew period (showMemoryInfo fmt 3 <$> parseMeminfo)+ widgetSetClassGI label (T.pack "text-memory-monitor") +-- | Render a 'MemoryInfo' snapshot into a template string.+--+-- The template supports keys documented in 'textMemoryMonitorNew'. showMemoryInfo :: String -> Int -> MemoryInfo -> T.Text showMemoryInfo fmt prec info = let template = ST.newSTMP fmt- labels = [ "used"- , "total"- , "free"- , "buffer"- , "cache"- , "rest"- , "available"- , "swapUsed"- , "swapTotal"- , "swapFree"- ]- actions = [ memoryUsed- , memoryTotal- , memoryFree- , memoryBuffer- , memoryCache- , memoryRest- , memoryAvailable- , memorySwapUsed- , memorySwapTotal- , memorySwapFree- ]- actions' = map (toAuto prec .) actions- stats = [f info | f <- actions']- template' = ST.setManyAttrib (zip labels stats) template- in ST.render template'+ sizeLabels =+ [ "used",+ "total",+ "free",+ "buffer",+ "cache",+ "rest",+ "available",+ "swapUsed",+ "swapTotal",+ "swapFree"+ ]+ sizeActions =+ [ memoryUsed,+ memoryTotal,+ memoryFree,+ memoryBuffer,+ memoryCache,+ memoryRest,+ memoryAvailable,+ memorySwapUsed,+ memorySwapTotal,+ memorySwapFree+ ]+ sizeStats =+ [ (label, toAuto prec (action info))+ | (label, action) <- zip sizeLabels sizeActions+ ]+ ratioStats =+ [ ("usedRatio", toRatio prec (memoryUsedRatio info)),+ ("swapUsedRatio", toRatio prec (memorySwapUsedRatio info))+ ]+ percentStats =+ [ ("usedPercent", toPercent 1 (memoryUsedRatio info)),+ ("swapUsedPercent", toPercent 1 (memorySwapUsedRatio info))+ ]+ template' = ST.setManyAttrib (sizeStats ++ ratioStats ++ percentStats) template+ in ST.render template' toAuto :: Int -> Double -> String toAuto prec value = printf "%.*f%s" p v unit- where value' = max 0 value- mag :: Int- mag = if value' == 0 then 0 else max 0 $ min 2 $ floor $ logBase 1024 value'- v = value' / 1024 ** fromIntegral mag- unit = case mag of- 0 -> "MiB"- 1 -> "GiB"- 2 -> "TiB"- _ -> "??B" -- unreachable- p :: Int- p = max 0 $ floor $ fromIntegral prec - logBase 10 v+ where+ value' = max 0 value+ mag :: Int+ mag = if value' == 0 then 0 else max 0 $ min 2 $ floor $ logBase 1024 value'+ v = value' / 1024 ** fromIntegral mag+ unit = case mag of+ 0 -> "MiB"+ 1 -> "GiB"+ 2 -> "TiB"+ _ -> "??B" -- unreachable+ p :: Int+ p = max 0 $ floor $ fromIntegral prec - logBase 10 v++toRatio :: Int -> Double -> String+toRatio prec value = printf "%.*f" p value'+ where+ p = max 0 prec+ value' = max 0 value++toPercent :: Int -> Double -> String+toPercent prec value = printf "%.*f%%" p (value' * 100)+ where+ p = max 0 prec+ value' = max 0 value
src/System/Taffybar/Widget/Text/NetworkMonitor.hs view
@@ -1,68 +1,82 @@+-- | Text network throughput widget. module System.Taffybar.Widget.Text.NetworkMonitor where -import Control.Monad-import Control.Monad.Trans.Class+import Control.Monad+import Control.Monad.Trans.Class import qualified Data.Text as T-import GI.Gtk-import System.Taffybar.Context-import System.Taffybar.Hooks-import System.Taffybar.Information.Network-import System.Taffybar.Util-import System.Taffybar.Widget.Generic.ChannelWidget-import Text.Printf-import Text.StringTemplate+import GI.Gtk+import System.Taffybar.Context+import System.Taffybar.Hooks+import System.Taffybar.Information.Network+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.ChannelWidget+import System.Taffybar.Widget.Util (widgetSetClassGI)+import Text.Printf+import Text.StringTemplate +-- | Default template for 'networkMonitorNew'. defaultNetFormat :: String defaultNetFormat = "▼ $inAuto$ ▲ $outAuto$" +-- | Render inbound/outbound speeds into a template. showInfo :: String -> Int -> (Double, Double) -> T.Text showInfo template prec (incomingb, outgoingb) =- let- attribs = [ ("inB", show incomingb)- , ("inKB", toKB prec incomingb)- , ("inMB", toMB prec incomingb)- , ("inAuto", toAuto prec incomingb)- , ("outB", show outgoingb)- , ("outKB", toKB prec outgoingb)- , ("outMB", toMB prec outgoingb)- , ("outAuto", toAuto prec outgoingb)- ]- in- render . setManyAttrib attribs $ newSTMP template+ let attribs =+ [ ("inB", show incomingb),+ ("inKB", toKB prec incomingb),+ ("inMB", toMB prec incomingb),+ ("inAuto", toAuto prec incomingb),+ ("outB", show outgoingb),+ ("outKB", toKB prec outgoingb),+ ("outMB", toMB prec outgoingb),+ ("outAuto", toAuto prec outgoingb)+ ]+ in render . setManyAttrib attribs $ newSTMP template +-- | Convert bytes per second to KiB/s. toKB :: Int -> Double -> String-toKB prec = setDigits prec . (/1024)+toKB prec = setDigits prec . (/ 1024) +-- | Convert bytes per second to MiB/s. toMB :: Int -> Double -> String toMB prec = setDigits prec . (/ (1024 * 1024)) +-- | Render a floating-point value with fixed precision. setDigits :: Int -> Double -> String setDigits dig = printf format- where format = "%." ++ show dig ++ "f"+ where+ format = "%." ++ show dig ++ "f" +-- | Convert bytes per second to an automatically chosen unit. toAuto :: Int -> Double -> String toAuto prec value = printf "%.*f%s" p v unit- where value' = max 0 value- mag :: Int- mag = if value' == 0 then 0 else max 0 $ min 4 $ floor $ logBase 1024 value'- v = value' / 1024 ** fromIntegral mag- unit = case mag of- 0 -> "B/s"- 1 -> "KiB/s"- 2 -> "MiB/s"- 3 -> "GiB/s"- 4 -> "TiB/s"- _ -> "??B/s" -- unreachable- p :: Int- p = max 0 $ floor $ fromIntegral prec - logBase 10 v+ where+ value' = max 0 value+ mag :: Int+ mag = if value' == 0 then 0 else max 0 $ min 4 $ floor $ logBase 1024 value'+ v = value' / 1024 ** fromIntegral mag+ unit = case mag of+ 0 -> "B/s"+ 1 -> "KiB/s"+ 2 -> "MiB/s"+ 3 -> "GiB/s"+ 4 -> "TiB/s"+ _ -> "??B/s" -- unreachable+ p :: Int+ p = max 0 $ floor $ fromIntegral prec - logBase 10 v +-- | Create a text network monitor widget.+--+-- The optional interface list restricts aggregation to matching interface+-- names; when omitted, all interfaces are included. networkMonitorNew :: String -> Maybe [String] -> TaffyIO GI.Gtk.Widget networkMonitorNew template interfaces = do NetworkInfoChan chan <- getNetworkChan let filterFn = maybe (const True) (flip elem) interfaces label <- lift $ labelNew Nothing+ _ <- lift $ widgetSetClassGI label (T.pack "text-network-monitor") void $ channelWidgetNew label chan $ \speedInfo -> let (up, down) = sumSpeeds $ map snd $ filter (filterFn . fst) speedInfo labelString = showInfo template 3 (fromRational down, fromRational up)- in postGUIASync $ labelSetMarkup label labelString+ in postGUIASync $ labelSetMarkup label labelString toWidget label
src/System/Taffybar/Widget/Util.hs view
@@ -1,8 +1,11 @@ {-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE TypeApplications #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-} -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Util -- Copyright : (c) Ivan Malison@@ -13,41 +16,37 @@ -- Portability : unportable -- -- Utility functions to facilitate building GTK interfaces.---------------------------------------------------------------------------------- module System.Taffybar.Widget.Util where -import Control.Concurrent ( forkIO )+import Control.Concurrent (forkIO) import qualified Control.Concurrent.MVar as MV-import Control.Exception.Enclosed (catchAny)-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Control (MonadBaseControl)-import Data.Bifunctor ( first )-import Data.Functor ( ($>) )-import Data.GI.Base.Overloading (IsDescendantOf)-import Data.Int+import Control.Exception.Enclosed (catchAny)+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Control (MonadBaseControl)+import Data.Bifunctor (first)+import Data.Functor (($>))+import Data.GI.Base.Overloading (IsDescendantOf)+import Data.Int import qualified Data.Text as T import qualified GI.Gdk as D import qualified GI.GdkPixbuf.Objects.Pixbuf as GI import qualified GI.GdkPixbuf.Objects.Pixbuf as PB-import GI.Gtk as Gtk-import StatusNotifier.Tray (scalePixbufToSize)-import System.Log.Logger (Priority(..))-import System.Environment.XDG.DesktopEntry-import System.FilePath.Posix-import System.Taffybar.Util-import Text.Printf--import Paths_taffybar ( getDataDir )+import GI.Gtk as Gtk+import Paths_taffybar (getDataDir)+import StatusNotifier.Tray (scalePixbufToSize)+import System.Environment.XDG.DesktopEntry+import System.FilePath.Posix+import System.Log.Logger (Priority (..))+import System.Taffybar.Util+import Text.Printf -- | Common record used for window icon widgets in workspace switchers. data WindowIconWidget a = WindowIconWidget- { iconContainer :: Gtk.EventBox- , iconImage :: Gtk.Widget- , iconWindow :: MV.MVar (Maybe a)- , iconForceUpdate :: IO ()+ { iconContainer :: Gtk.EventBox,+ iconImage :: Gtk.Widget,+ iconWindow :: MV.MVar (Maybe a),+ iconForceUpdate :: IO () } -- | Construct the GTK widgets and CSS classes for a window icon widget, leaving@@ -56,7 +55,7 @@ -- -- The caller is responsible for creating the image widget and adding it to the -- event box.-mkWindowIconWidgetBase :: MonadIO m => Maybe Int32 -> m (WindowIconWidget a)+mkWindowIconWidgetBase :: (MonadIO m) => Maybe Int32 -> m (WindowIconWidget a) mkWindowIconWidgetBase _mSize = liftIO $ do windowVar <- MV.newMVar Nothing ebox <- Gtk.eventBoxNew@@ -64,10 +63,10 @@ placeholder <- Gtk.toWidget ebox return WindowIconWidget- { iconContainer = ebox- , iconImage = placeholder- , iconWindow = windowVar- , iconForceUpdate = return ()+ { iconContainer = ebox,+ iconImage = placeholder,+ iconWindow = windowVar,+ iconForceUpdate = return () } -- | List of possible status class names for window icon widgets.@@ -78,18 +77,19 @@ -- | Add/remove classes on a widget while removing stale classes. updateWidgetClasses ::- (Foldable t1, Foldable t, Gtk.IsWidget a, MonadIO m)- => a- -> t1 T.Text- -> t T.Text- -> m ()+ (Foldable t1, Foldable t, Gtk.IsWidget a, MonadIO m) =>+ a ->+ t1 T.Text ->+ t T.Text ->+ m () updateWidgetClasses widget toAdd toRemove = do context <- Gtk.widgetGetStyleContext widget let hasClass = Gtk.styleContextHasClass context addIfMissing klass = hasClass klass >>= (`when` Gtk.styleContextAddClass context klass) . not- removeIfPresent klass = unless (klass `elem` toAdd) $- hasClass klass >>= (`when` Gtk.styleContextRemoveClass context klass)+ removeIfPresent klass =+ unless (klass `elem` toAdd) $+ hasClass klass >>= (`when` Gtk.styleContextRemoveClass context klass) mapM_ removeIfPresent toRemove mapM_ addIfMissing toAdd @@ -111,14 +111,16 @@ [statusString] possibleStatusStrings +-- | Wrap an icon getter to scale returned pixbufs to the requested size. scaledPixbufGetter :: (MonadIO m) => (Int32 -> a -> m (Maybe GI.Pixbuf)) -> (Int32 -> a -> m (Maybe GI.Pixbuf)) scaledPixbufGetter getter size windowData =- getter size windowData >>=- traverse (liftIO . scalePixbufToSize size Gtk.OrientationHorizontal)+ getter size windowData+ >>= traverse (liftIO . scalePixbufToSize size Gtk.OrientationHorizontal) +-- | Catch exceptions from a pixbuf getter and log them. handlePixbufGetterException :: (MonadBaseControl IO m, Show a) => (Priority -> String -> m ()) ->@@ -128,32 +130,42 @@ m (Maybe GI.Pixbuf) handlePixbufGetterException logFn getter size windowData = catchAny (getter size windowData) $ \e -> do- _ <- logFn WARNING $ printf "Failed to get window icon for %s: %s"- (show windowData) (show e)+ _ <-+ logFn WARNING $+ printf+ "Failed to get window icon for %s: %s"+ (show windowData)+ (show e) return Nothing -- | Execute the given action as a response to any of the given types -- of mouse button clicks.-onClick :: [D.EventType] -- ^ Types of button clicks to listen to.- -> IO a -- ^ Action to execute.- -> D.EventButton- -> IO Bool+onClick ::+ -- | Types of button clicks to listen to.+ [D.EventType] ->+ -- | Action to execute.+ IO a ->+ D.EventButton ->+ IO Bool onClick triggers action btn = do click <- D.getEventButtonType btn if click `elem` triggers- then action >> return True- else return False+ then action >> return True+ else return False -- | Attach the given widget as a popup with the given title to the -- given window. The newly attached popup is not shown initially. Use -- the 'displayPopup' function to display it.-attachPopup :: (Gtk.IsWidget w, Gtk.IsWindow wnd) =>- w -- ^ The widget to set as popup.- -> T.Text -- ^ The title of the popup.- -> wnd -- ^ The window to attach the popup to.- -> IO ()+attachPopup ::+ (Gtk.IsWidget w, Gtk.IsWindow wnd) =>+ -- | The widget to set as popup.+ w ->+ -- | The title of the popup.+ T.Text ->+ -- | The window to attach the popup to.+ wnd ->+ IO () attachPopup widget title window = do- windowSetTitle window title windowSetTypeHint window D.WindowTypeHintTooltip windowSetSkipTaskbarHint window True@@ -165,20 +177,23 @@ where getWindow :: IO (Maybe Window) getWindow = do- windowGType <- glibType @Window- Just ancestor <- Gtk.widgetGetAncestor widget windowGType- castTo Window ancestor+ windowGType <- glibType @Window+ Just ancestor <- Gtk.widgetGetAncestor widget windowGType+ castTo Window ancestor -- | Display the given popup widget (previously prepared using the -- 'attachPopup' function) immediately beneath (or above) the given -- window.-displayPopup :: (Gtk.IsWidget w, Gtk.IsWidget wnd, Gtk.IsWindow wnd) =>- w -- ^ The popup widget.- -> wnd -- ^ The window the widget was attached to.- -> IO ()+displayPopup ::+ (Gtk.IsWidget w, Gtk.IsWidget wnd, Gtk.IsWindow wnd) =>+ -- | The popup widget.+ w ->+ -- | The window the widget was attached to.+ wnd ->+ IO () displayPopup widget window = do windowSetPosition window WindowPositionMouse- (x, y ) <- windowGetPosition window+ (x, y) <- windowGetPosition window (_, natReq) <- widgetGetPreferredSize =<< widgetGetToplevel widget y' <- getRequisitionHeight natReq widgetShowAll window@@ -186,9 +201,10 @@ then windowMove window x (y - y') else windowMove window x y' -widgetGetAllocatedSize- :: (Gtk.IsWidget self, MonadIO m)- => self -> m (Int, Int)+-- | Read current allocated widget width and height.+widgetGetAllocatedSize ::+ (Gtk.IsWidget self, MonadIO m) =>+ self -> m (Int, Int) widgetGetAllocatedSize widget = do w <- Gtk.widgetGetAllocatedWidth widget h <- Gtk.widgetGetAllocatedHeight widget@@ -196,101 +212,125 @@ -- | Creates markup with the given foreground and background colors and the -- given contents.-colorize :: String -- ^ Foreground color.- -> String -- ^ Background color.- -> String -- ^ Contents.- -> String+colorize ::+ -- | Foreground color.+ String ->+ -- | Background color.+ String ->+ -- | Contents.+ String ->+ String colorize fg bg = printf "<span%s%s>%s</span>" (attr ("fg" :: String) fg :: String) (attr ("bg" :: String) bg :: String)- where attr name value- | null value = ""- | otherwise = printf " %scolor=\"%s\"" name value+ where+ attr name value+ | null value = ""+ | otherwise = printf " %scolor=\"%s\"" name value +-- | Run an action forever on a background thread. backgroundLoop :: IO a -> IO () backgroundLoop = void . forkIO . forever -drawOn :: Gtk.IsWidget object => object -> IO () -> IO object+-- | Register an action on widget realization and return the widget.+drawOn :: (Gtk.IsWidget object) => object -> IO () -> IO object drawOn drawArea action = Gtk.onWidgetRealize drawArea action $> drawArea +-- | Add a CSS class to a widget and return it. widgetSetClassGI :: (Gtk.IsWidget b, MonadIO m) => b -> T.Text -> m b widgetSetClassGI widget klass =- Gtk.widgetGetStyleContext widget >>=- flip Gtk.styleContextAddClass klass >> return widget+ Gtk.widgetGetStyleContext widget+ >>= flip Gtk.styleContextAddClass klass+ >> return widget +-- | Standard icon-theme load flags used by icon helpers. themeLoadFlags :: [Gtk.IconLookupFlags] themeLoadFlags =- [ Gtk.IconLookupFlagsGenericFallback- , Gtk.IconLookupFlagsUseBuiltin+ [ Gtk.IconLookupFlagsGenericFallback,+ Gtk.IconLookupFlagsUseBuiltin ] +-- | Resolve and load an icon for a desktop entry. getImageForDesktopEntry :: Int32 -> DesktopEntry -> IO (Maybe GI.Pixbuf) getImageForDesktopEntry size de = getImageForMaybeIconName (T.pack <$> deIcon de) size +-- | Resolve and load an icon from an optional icon name. getImageForMaybeIconName :: Maybe T.Text -> Int32 -> IO (Maybe GI.Pixbuf) getImageForMaybeIconName mIconName size = join <$> traverse (`getImageForIconName` size) mIconName +-- | Resolve and load an icon by theme name or filesystem path. getImageForIconName :: T.Text -> Int32 -> IO (Maybe GI.Pixbuf) getImageForIconName iconName size =- maybeTCombine (loadPixbufByName size iconName)- (getPixbufFromFilePath (T.unpack iconName) >>=- traverse (scalePixbufToSize size Gtk.OrientationHorizontal))+ maybeTCombine+ (loadPixbufByName size iconName)+ ( getPixbufFromFilePath (T.unpack iconName)+ >>= traverse (scalePixbufToSize size Gtk.OrientationHorizontal)+ ) +-- | Load an icon pixbuf from the current icon theme by symbolic name. loadPixbufByName :: Int32 -> T.Text -> IO (Maybe GI.Pixbuf) loadPixbufByName size name = do iconTheme <- Gtk.iconThemeGetDefault hasIcon <- Gtk.iconThemeHasIcon iconTheme name if hasIcon- then Gtk.iconThemeLoadIcon iconTheme name size themeLoadFlags- else return Nothing+ then Gtk.iconThemeLoadIcon iconTheme name size themeLoadFlags+ else return Nothing +-- | Center a widget in both axes. alignCenter :: (Gtk.IsWidget o, MonadIO m) => o -> m () alignCenter widget =- Gtk.setWidgetValign widget Gtk.AlignCenter >>- Gtk.setWidgetHalign widget Gtk.AlignCenter+ Gtk.setWidgetValign widget Gtk.AlignCenter+ >> Gtk.setWidgetHalign widget Gtk.AlignCenter +-- | Make a widget vertically fill and horizontally centered. vFillCenter :: (Gtk.IsWidget o, MonadIO m) => o -> m () vFillCenter widget =- Gtk.widgetSetVexpand widget True >>- Gtk.setWidgetValign widget Gtk.AlignFill >>- Gtk.setWidgetHalign widget Gtk.AlignCenter+ Gtk.widgetSetVexpand widget True+ >> Gtk.setWidgetValign widget Gtk.AlignFill+ >> Gtk.setWidgetHalign widget Gtk.AlignCenter +-- | Load and scale a pixbuf from file to a target height. pixbufNewFromFileAtScaleByHeight :: Int32 -> String -> IO (Either String PB.Pixbuf) pixbufNewFromFileAtScaleByHeight height name =- fmap (handleResult . first show) $ catchGErrorsAsLeft $- PB.pixbufNewFromFileAtScale name (-1) height True+ fmap (handleResult . first show) $+ catchGErrorsAsLeft $+ PB.pixbufNewFromFileAtScale name (-1) height True where handleResult = (maybe (Left "gdk function returned NULL") Right =<<) +-- | Load an icon shipped with taffybar's data files. loadIcon :: Int32 -> String -> IO (Either String PB.Pixbuf) loadIcon height name =- getDataDir >>=- pixbufNewFromFileAtScaleByHeight height . (</> "icons" </> name)+ getDataDir+ >>= pixbufNewFromFileAtScaleByHeight height . (</> "icons" </> name) +-- | Set a minimum widget width while preserving natural height. setMinWidth :: (Gtk.IsWidget w, MonadIO m) => Int -> w -> m w setMinWidth width widget = liftIO $ do Gtk.widgetSetSizeRequest widget (fromIntegral width) (-1) return widget +-- | Add a CSS class if it is not already present. addClassIfMissing :: (IsDescendantOf Widget a, MonadIO m, GObject a) => T.Text -> a -> m () addClassIfMissing klass widget = do context <- Gtk.widgetGetStyleContext widget- Gtk.styleContextHasClass context klass >>=- (`when` Gtk.styleContextAddClass context klass) . not+ Gtk.styleContextHasClass context klass+ >>= (`when` Gtk.styleContextAddClass context klass) . not +-- | Remove a CSS class when it is currently present. removeClassIfPresent :: (IsDescendantOf Widget a, MonadIO m, GObject a) => T.Text -> a -> m () removeClassIfPresent klass widget = do context <- Gtk.widgetGetStyleContext widget- Gtk.styleContextHasClass context klass >>=- (`when` Gtk.styleContextRemoveClass context klass)+ Gtk.styleContextHasClass context klass+ >>= (`when` Gtk.styleContextRemoveClass context klass) -- | Wrap a widget with two container boxes. The inner box will have the class -- "inner-pad", and the outer box will have the class "outer-pad". These boxes -- can be used to add padding between the outline of the widget and its -- contents, or for the purpose of displaying a different background behind the -- widget.-buildPadBox :: MonadIO m => Gtk.Widget -> m Gtk.Widget+buildPadBox :: (MonadIO m) => Gtk.Widget -> m Gtk.Widget buildPadBox contents = liftIO $ do innerBox <- Gtk.boxNew Gtk.OrientationHorizontal 0 outerBox <- Gtk.boxNew Gtk.OrientationHorizontal 0@@ -304,7 +344,8 @@ Gtk.widgetShow innerBox Gtk.toWidget outerBox -buildContentsBox :: MonadIO m => Gtk.Widget -> m Gtk.Widget+-- | Wrap a widget in a @contents@ box and then in the standard pad boxes.+buildContentsBox :: (MonadIO m) => Gtk.Widget -> m Gtk.Widget buildContentsBox widget = liftIO $ do contents <- Gtk.boxNew Gtk.OrientationHorizontal 0 Gtk.containerAdd contents widget@@ -315,7 +356,7 @@ -- | Combine an icon widget and a label widget in a horizontal box with -- standardised CSS classes. The box gets class @icon-label@, the first child -- gets @icon@, and the second child gets @label@.-buildIconLabelBox :: MonadIO m => Gtk.Widget -> Gtk.Widget -> m Gtk.Widget+buildIconLabelBox :: (MonadIO m) => Gtk.Widget -> Gtk.Widget -> m Gtk.Widget buildIconLabelBox iconWidget labelWidget = liftIO $ do box <- Gtk.boxNew Gtk.OrientationHorizontal 0 _ <- widgetSetClassGI iconWidget "icon"@@ -330,7 +371,7 @@ -- and mark overlays as "pass through" so they don't capture clicks/scrolls. -- -- This is useful for workspace widgets that overlay a label on top of icons.-buildOverlayWithPassThrough :: MonadIO m => Gtk.Widget -> [Gtk.Widget] -> m Gtk.Widget+buildOverlayWithPassThrough :: (MonadIO m) => Gtk.Widget -> [Gtk.Widget] -> m Gtk.Widget buildOverlayWithPassThrough base overlays = liftIO $ do overlay <- Gtk.overlayNew Gtk.containerAdd overlay base@@ -343,7 +384,7 @@ -- -- This is used by workspace widgets to overlay a label on top of icons in a -- consistent way across different backends.-buildBottomLeftAlignedBox :: MonadIO m => T.Text -> Gtk.Widget -> m Gtk.Widget+buildBottomLeftAlignedBox :: (MonadIO m) => T.Text -> Gtk.Widget -> m Gtk.Widget buildBottomLeftAlignedBox boxClass child = liftIO $ do ebox <- Gtk.eventBoxNew _ <- widgetSetClassGI ebox boxClass@@ -368,7 +409,7 @@ shownItems = take maxNeeded items paddedItems = map Just shownItems ++ replicate (targetLen - length shownItems) Nothing- in (effectiveMinIcons, targetLen, paddedItems)+ in (effectiveMinIcons, targetLen, paddedItems) -- | CSS class name for a window icon given its state. --
+ src/System/Taffybar/Widget/WakeupDebug.hs view
@@ -0,0 +1,216 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Debug widget for coordinated wakeup scheduler visibility.+module System.Taffybar.Widget.WakeupDebug+ ( WakeupDebugWidgetConfig (..),+ defaultWakeupDebugWidgetConfig,+ wakeupDebugWidgetNew,+ wakeupDebugWidgetNewWithConfig,+ )+where++import Control.Concurrent (forkIO, killThread, threadDelay)+import Control.Concurrent.STM (atomically)+import Control.Concurrent.STM.TChan (TChan, readTChan)+import Control.Monad (forever, void, when)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Reader (ask, runReaderT)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import Data.List (nub, sort)+import qualified Data.Text as T+import Data.Word (Word64)+import qualified GI.Gdk as Gdk+import qualified GI.Gtk as Gtk+import Numeric (showFFloat)+import System.Taffybar.Context (TaffyIO)+import System.Taffybar.Information.Wakeup+ ( WakeupEvent (..),+ WakeupSchedulerEvent (..),+ getRegisteredWakeupIntervalsNanoseconds,+ getWakeupSchedulerEvents,+ )+import System.Taffybar.Util (postGUIASync)+import System.Taffybar.Widget.Util (onClick, widgetSetClassGI)++-- | Configuration for 'wakeupDebugWidgetNewWithConfig'.+data WakeupDebugWidgetConfig = WakeupDebugWidgetConfig+ { wakeupDebugFlashDurationMilliseconds :: Int,+ wakeupDebugExpandedByDefault :: Bool+ }+ deriving (Eq, Ord, Show)++defaultWakeupDebugWidgetConfig :: WakeupDebugWidgetConfig+defaultWakeupDebugWidgetConfig =+ WakeupDebugWidgetConfig+ { wakeupDebugFlashDurationMilliseconds = 180,+ wakeupDebugExpandedByDefault = False+ }++data WakeupDebugState = WakeupDebugState+ { wakeupDebugLastEvents :: [WakeupEvent],+ wakeupDebugRegisteredIntervals :: [Word64],+ wakeupDebugExpanded :: Bool+ }++wakeupDebugWidgetNew :: TaffyIO Gtk.Widget+wakeupDebugWidgetNew =+ wakeupDebugWidgetNewWithConfig defaultWakeupDebugWidgetConfig++wakeupDebugWidgetNewWithConfig :: WakeupDebugWidgetConfig -> TaffyIO Gtk.Widget+wakeupDebugWidgetNewWithConfig config = do+ context <- ask+ schedulerEvents <- getWakeupSchedulerEvents+ registered <- getRegisteredWakeupIntervalsNanoseconds+ liftIO $ do+ label <- Gtk.labelNew (Nothing :: Maybe T.Text)+ eventBox <- Gtk.eventBoxNew+ _ <- widgetSetClassGI eventBox "wakeup-debug"+ _ <- widgetSetClassGI label "wakeup-debug-label"+ Gtk.eventBoxSetVisibleWindow eventBox False+ Gtk.containerAdd eventBox label++ flashTokenRef <- newIORef (0 :: Int)+ stateRef <-+ newIORef+ WakeupDebugState+ { wakeupDebugLastEvents = [],+ wakeupDebugRegisteredIntervals = registered,+ wakeupDebugExpanded = wakeupDebugExpandedByDefault config+ }++ refreshWidget stateRef label eventBox++ let refreshRegisteredIntervals = runReaderT getRegisteredWakeupIntervalsNanoseconds context++ _ <-+ Gtk.onWidgetButtonPressEvent eventBox $+ onClick [Gdk.EventTypeButtonPress] $ do+ intervals <- refreshRegisteredIntervals+ _ <-+ atomicModifyIORef' stateRef $ \state ->+ let nextState =+ state+ { wakeupDebugRegisteredIntervals = intervals,+ wakeupDebugExpanded = not (wakeupDebugExpanded state)+ }+ in (nextState, nextState)+ refreshWidget stateRef label eventBox++ _ <- Gtk.onWidgetRealize eventBox $ do+ listenerThread <- forkIO $ wakeupDebugLoop schedulerEvents refreshRegisteredIntervals stateRef label eventBox flashTokenRef config+ void $ Gtk.onWidgetUnrealize eventBox $ killThread listenerThread++ Gtk.widgetShowAll eventBox+ Gtk.toWidget eventBox++wakeupDebugLoop ::+ TChan WakeupSchedulerEvent ->+ IO [Word64] ->+ IORef WakeupDebugState ->+ Gtk.Label ->+ Gtk.EventBox ->+ IORef Int ->+ WakeupDebugWidgetConfig ->+ IO ()+wakeupDebugLoop schedulerEvents refreshRegisteredIntervals stateRef label eventBox flashTokenRef config =+ forever $ do+ wakeupEvent <- atomically $ readTChan schedulerEvents+ intervals <- refreshRegisteredIntervals+ _ <-+ atomicModifyIORef' stateRef $ \state ->+ let nextState =+ state+ { wakeupDebugLastEvents = wakeupDueEvents wakeupEvent,+ wakeupDebugRegisteredIntervals = intervals+ }+ in (nextState, nextState)+ refreshWidget stateRef label eventBox+ triggerFlash flashTokenRef (wakeupDebugFlashDurationMilliseconds config) eventBox++refreshWidget :: IORef WakeupDebugState -> Gtk.Label -> Gtk.EventBox -> IO ()+refreshWidget stateRef label eventBox = do+ state <- readIORef stateRef+ postGUIASync $ do+ Gtk.labelSetText label (renderWakeupDebugLabel state)+ Gtk.widgetSetTooltipText eventBox (Just (renderWakeupDebugTooltip state))++triggerFlash :: IORef Int -> Int -> Gtk.EventBox -> IO ()+triggerFlash tokenRef flashDurationMs eventBox = do+ token <- atomicModifyIORef' tokenRef $ \cur -> let next = cur + 1 in (next, next)+ postGUIASync $ setFlashClass eventBox True+ _ <- forkIO $ do+ threadDelay (flashDurationMs * 1000)+ latestToken <- readIORef tokenRef+ when (latestToken == token) $+ postGUIASync $+ setFlashClass eventBox False+ pure ()++setFlashClass :: Gtk.EventBox -> Bool -> IO ()+setFlashClass eventBox shouldSet = do+ context <- Gtk.widgetGetStyleContext eventBox+ if shouldSet+ then Gtk.styleContextAddClass context "wakeup-debug-hit"+ else Gtk.styleContextRemoveClass context "wakeup-debug-hit"++renderWakeupDebugLabel :: WakeupDebugState -> T.Text+renderWakeupDebugLabel state =+ if wakeupDebugExpanded state+ then+ T.intercalate+ "\n"+ [ "wake " <> formatEventIntervals (wakeupDebugLastEvents state),+ "reg " <> formatIntervals (wakeupDebugRegisteredIntervals state)+ ]+ else "wake " <> formatEventIntervals (wakeupDebugLastEvents state)++renderWakeupDebugTooltip :: WakeupDebugState -> T.Text+renderWakeupDebugTooltip state =+ T.intercalate+ "\n"+ [ "Click to toggle registered interval list",+ "Last wakeup intervals: " <> formatEventIntervals (wakeupDebugLastEvents state),+ "Last wakeup ticks: " <> formatEventTicks (wakeupDebugLastEvents state),+ "Registered intervals: " <> formatIntervals (wakeupDebugRegisteredIntervals state)+ ]++formatEventIntervals :: [WakeupEvent] -> T.Text+formatEventIntervals events =+ formatIntervals $ fmap wakeupIntervalNanoseconds events++formatEventTicks :: [WakeupEvent] -> T.Text+formatEventTicks events+ | null events = "none"+ | otherwise = T.intercalate ", " $ fmap renderEvent events+ where+ renderEvent event =+ formatIntervalNanoseconds (wakeupIntervalNanoseconds event)+ <> "#"+ <> T.pack (show (wakeupTickCount event))++formatIntervals :: [Word64] -> T.Text+formatIntervals intervals+ | null intervals = "none"+ | otherwise = T.intercalate ", " $ fmap formatIntervalNanoseconds (sort (nub intervals))++formatIntervalNanoseconds :: Word64 -> T.Text+formatIntervalNanoseconds nanoseconds+ | nanoseconds `mod` secondsToNanoseconds == 0 =+ T.pack (show (nanoseconds `div` secondsToNanoseconds)) <> "s"+ | nanoseconds `mod` millisecondsToNanoseconds == 0 =+ T.pack (show (nanoseconds `div` millisecondsToNanoseconds)) <> "ms"+ | nanoseconds `mod` microsecondsToNanoseconds == 0 =+ T.pack (show (nanoseconds `div` microsecondsToNanoseconds)) <> "us"+ | otherwise = T.pack (showFFloat (Just 3) secondsValue "s")+ where+ secondsValue :: Double+ secondsValue = fromIntegral nanoseconds / 1000000000++secondsToNanoseconds :: Word64+secondsToNanoseconds = 1000000000++millisecondsToNanoseconds :: Word64+millisecondsToNanoseconds = 1000000++microsecondsToNanoseconds :: Word64+microsecondsToNanoseconds = 1000
src/System/Taffybar/Widget/Weather.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-}+ -- | This module defines a simple textual weather widget that polls -- NOAA for weather data. To find your weather station, you can use -- either of the following:@@ -61,15 +62,15 @@ -- Implementation Note: the weather data parsing code is taken from xmobar. This -- version of the code makes direct HTTP requests instead of invoking a separate -- cURL process.- module System.Taffybar.Widget.Weather- ( WeatherConfig(..)- , WeatherInfo(..)- , WeatherFormatter(WeatherFormatter)- , weatherNew- , weatherCustomNew- , defaultWeatherConfig- ) where+ ( WeatherConfig (..),+ WeatherInfo (..),+ WeatherFormatter (WeatherFormatter),+ weatherNew,+ weatherCustomNew,+ defaultWeatherConfig,+ )+where import Control.Monad.IO.Class import qualified Data.ByteString.Lazy as LB@@ -77,34 +78,36 @@ import Data.Maybe (fromMaybe) import qualified Data.Text as T import qualified Data.Text.Encoding as T-import GI.GLib(markupEscapeText)+import GI.GLib (markupEscapeText) import GI.Gtk import Network.HTTP.Client import Network.HTTP.Client.TLS (tlsManagerSettings) import Network.HTTP.Types.Status import System.Log.Logger+import System.Taffybar.Widget.Generic.PollingLabel+import System.Taffybar.Widget.Util (widgetSetClassGI) import Text.Parsec import Text.Printf import Text.StringTemplate -import System.Taffybar.Widget.Generic.PollingLabel-+-- | Parsed NOAA weather report values used by the weather widget. data WeatherInfo = WI- { stationPlace :: String- , stationState :: String- , year :: String- , month :: String- , day :: String- , hour :: String- , wind :: String- , visibility :: String- , skyCondition :: String- , tempC :: Int- , tempF :: Int- , dewPoint :: String- , humidity :: Int- , pressure :: Int- } deriving (Show)+ { stationPlace :: String,+ stationState :: String,+ year :: String,+ month :: String,+ day :: String,+ hour :: String,+ wind :: String,+ visibility :: String,+ skyCondition :: String,+ tempC :: Int,+ tempF :: Int,+ dewPoint :: String,+ humidity :: Int,+ pressure :: Int+ }+ deriving (Show) -- Parsers stolen from xmobar @@ -118,9 +121,9 @@ _ <- char '.' d <- getNumbersAsString _ <- char ' '- (h:hh:mi:mimi) <- getNumbersAsString+ (h : hh : mi : mimi) <- getNumbersAsString _ <- char ' '- return (y, m, d , [h]++[hh]++":"++[mi]++mimi)+ return (y, m, d, [h] ++ [hh] ++ ":" ++ [mi] ++ mimi) pTemp :: Parser (Int, Int) pTemp = do@@ -149,12 +152,12 @@ _ <- space ss <- getAllBut '(' _ <- skipRestOfLine >> getAllBut '/'- (y,m,d,h) <- pTime+ (y, m, d, h) <- pTime w <- getAfterString "Wind: " v <- getAfterString "Visibility: " sk <- getAfterString "Sky conditions: " _ <- skipTillString "Temperature: "- (tC,tF) <- pTemp+ (tC, tF) <- pTemp dp <- getAfterString "Dew Point: " _ <- skipTillString "Relative Humidity: " rh <- pRh@@ -175,7 +178,7 @@ skipTillString :: String -> Parser String skipTillString s =- manyTill skipRestOfLine $ string s+ manyTill skipRestOfLine $ string s getNumbersAsString :: Parser String getNumbersAsString = skipMany space >> many1 digit >>= \n -> return n@@ -190,8 +193,9 @@ downloadURL mgr request = do response <- httpLbs request mgr case responseStatus response of- s | s >= status200 && s < status300 ->- return $ Right (T.unpack . T.decodeUtf8 . LB.toStrict $ responseBody response)+ s+ | s >= status200 && s < status300 ->+ return $ Right (T.unpack . T.decodeUtf8 . LB.toStrict $ responseBody response) otherStatus -> return . Left $ "HTTP 2XX status was expected but received " ++ show otherStatus @@ -208,27 +212,31 @@ defaultFormatter :: StringTemplate String -> WeatherInfo -> String defaultFormatter tpl wi = render tpl' where- tpl' = setManyAttrib [ ("stationPlace", stationPlace wi)- , ("stationState", stationState wi)- , ("year", year wi)- , ("month", month wi)- , ("day", day wi)- , ("hour", hour wi)- , ("wind", wind wi)- , ("visibility", visibility wi)- , ("skyCondition", skyCondition wi)- , ("tempC", show (tempC wi))- , ("tempF", show (tempF wi))- , ("dewPoint", dewPoint wi)- , ("humidity", show (humidity wi))- , ("pressure", show (pressure wi))- ] tpl+ tpl' =+ setManyAttrib+ [ ("stationPlace", stationPlace wi),+ ("stationState", stationState wi),+ ("year", year wi),+ ("month", month wi),+ ("day", day wi),+ ("hour", hour wi),+ ("wind", wind wi),+ ("visibility", visibility wi),+ ("skyCondition", skyCondition wi),+ ("tempC", show (tempC wi)),+ ("tempF", show (tempF wi)),+ ("dewPoint", dewPoint wi),+ ("humidity", show (humidity wi)),+ ("pressure", show (pressure wi))+ ]+ tpl -getCurrentWeather :: IO (Either String WeatherInfo)- -> StringTemplate String- -> StringTemplate String- -> WeatherFormatter- -> IO (T.Text, Maybe T.Text)+getCurrentWeather ::+ IO (Either String WeatherInfo) ->+ StringTemplate String ->+ StringTemplate String ->+ WeatherFormatter ->+ IO (T.Text, Maybe T.Text) getCurrentWeather getter labelTpl tooltipTpl formatter = do dat <- getter case dat of@@ -256,19 +264,26 @@ -- The default interpolates variables into a string as described -- above. Custom formatters can do basically anything. data WeatherFormatter- = WeatherFormatter (WeatherInfo -> String) -- ^ Specify a custom formatter for 'WeatherInfo'- | DefaultWeatherFormatter -- ^ Use the default StringTemplate formatter+ = -- | Specify a custom formatter for 'WeatherInfo'+ WeatherFormatter (WeatherInfo -> String)+ | -- | Use the default StringTemplate formatter+ DefaultWeatherFormatter -- | The configuration for the weather widget. You can provide a custom -- format string through 'weatherTemplate' as described above, or you can -- provide a custom function to turn a 'WeatherInfo' into a String via the -- 'weatherFormatter' field. data WeatherConfig = WeatherConfig- { weatherStation :: String -- ^ The weather station to poll. No default- , weatherTemplate :: String -- ^ Template string, as described above. Default: $tempF$ °F- , weatherTemplateTooltip :: String -- ^ Template string, as described above. Default: $tempF$ °F- , weatherFormatter :: WeatherFormatter -- ^ Default: substitute in all interpolated variables (above)- , weatherProxy :: Maybe String -- ^ The proxy server, e.g. "http://proxy:port". Default: Nothing+ { -- | The weather station to poll. No default+ weatherStation :: String,+ -- | Template string, as described above. Default: $tempF$ °F+ weatherTemplate :: String,+ -- | Template string, as described above. Default: $tempF$ °F+ weatherTemplateTooltip :: String,+ -- | Default: substitute in all interpolated variables (above)+ weatherFormatter :: WeatherFormatter,+ -- | The proxy server, e.g. "http://proxy:port". Default: Nothing+ weatherProxy :: Maybe String } -- | A sensible default configuration for the weather widget that just@@ -276,29 +291,32 @@ defaultWeatherConfig :: String -> WeatherConfig defaultWeatherConfig station = WeatherConfig- { weatherStation = station- , weatherTemplate = "$tempF$ °F"- , weatherTemplateTooltip =- unlines- [ "Station: $stationPlace$"- , "Time: $day$.$month$.$year$ $hour$"- , "Temperature: $tempF$ °F"- , "Pressure: $pressure$ hPa"- , "Wind: $wind$"- , "Visibility: $visibility$"- , "Sky Condition: $skyCondition$"- , "Dew Point: $dewPoint$"- , "Humidity: $humidity$"- ]- , weatherFormatter = DefaultWeatherFormatter- , weatherProxy = Nothing- }+ { weatherStation = station,+ weatherTemplate = "$tempF$ °F",+ weatherTemplateTooltip =+ unlines+ [ "Station: $stationPlace$",+ "Time: $day$.$month$.$year$ $hour$",+ "Temperature: $tempF$ °F",+ "Pressure: $pressure$ hPa",+ "Wind: $wind$",+ "Visibility: $visibility$",+ "Sky Condition: $skyCondition$",+ "Dew Point: $dewPoint$",+ "Humidity: $humidity$"+ ],+ weatherFormatter = DefaultWeatherFormatter,+ weatherProxy = Nothing+ } -- | Create a periodically-updating weather widget that polls NOAA.-weatherNew :: MonadIO m- => WeatherConfig -- ^ Configuration to render- -> Double -- ^ Polling period in _minutes_- -> m GI.Gtk.Widget+weatherNew ::+ (MonadIO m) =>+ -- | Configuration to render+ WeatherConfig ->+ -- | Polling period in _minutes_+ Double ->+ m GI.Gtk.Widget weatherNew cfg delayMinutes = liftIO $ do -- TODO: add explicit proxy host/port to WeatherConfig and -- get rid of this ugly stringly-typed setting@@ -307,32 +325,43 @@ Just str -> let strToBs = T.encodeUtf8 . T.pack noHttp = fromMaybe str $ stripPrefix "http://" str- (phost, pport) = case span (':'/=) noHttp of+ (phost, pport) = case span (':' /=) noHttp of (h, "") -> (strToBs h, 80) -- HTTP seems to assume 80 to be the default- (h, ':':p) -> (strToBs h, read p)+ (h, ':' : p) -> (strToBs h, read p) _ -> error "unreachable: broken span"- in useProxy $ Proxy phost pport+ in useProxy $ Proxy phost pport mgr <- newManager $ managerSetProxy usedProxy tlsManagerSettings let url = printf "%s/%s.TXT" baseUrl (weatherStation cfg) let getter = getWeather mgr url- weatherCustomNew getter (weatherTemplate cfg) (weatherTemplateTooltip cfg)- (weatherFormatter cfg) delayMinutes+ weatherCustomNew+ getter+ (weatherTemplate cfg)+ (weatherTemplateTooltip cfg)+ (weatherFormatter cfg)+ delayMinutes -- | Create a periodically-updating weather widget using custom weather getter-weatherCustomNew- :: MonadIO m- => IO (Either String WeatherInfo) -- ^ Weather querying action- -> String -- ^ Weather template- -> String -- ^ Weather template- -> WeatherFormatter -- ^ Weather formatter- -> Double -- ^ Polling period in _minutes_- -> m GI.Gtk.Widget+weatherCustomNew ::+ (MonadIO m) =>+ -- | Weather querying action+ IO (Either String WeatherInfo) ->+ -- | Weather template+ String ->+ -- | Weather template+ String ->+ -- | Weather formatter+ WeatherFormatter ->+ -- | Polling period in _minutes_+ Double ->+ m GI.Gtk.Widget weatherCustomNew getter labelTpl tooltipTpl formatter delayMinutes = liftIO $ do let labelTpl' = newSTMP labelTpl tooltipTpl' = newSTMP tooltipTpl - l <- pollingLabelNewWithTooltip (delayMinutes * 60)- (getCurrentWeather getter labelTpl' tooltipTpl' formatter)-+ l <-+ pollingLabelNewWithTooltip+ (delayMinutes * 60)+ (getCurrentWeather getter labelTpl' tooltipTpl' formatter)+ _ <- widgetSetClassGI l "weather" GI.Gtk.widgetShowAll l return l
src/System/Taffybar/Widget/Windows.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Windows -- Copyright : (c) Ivan Malison@@ -11,131 +15,254 @@ -- -- Menu widget that shows the title of the currently focused window and that, -- when clicked, displays a menu from which the user may select a window to--- which to switch the focus.-------------------------------------------------------------------------------+-- switch focus to. module System.Taffybar.Widget.Windows where -import Control.Monad-import Control.Monad.Trans.Class-import Control.Monad.Trans.Reader-import Control.Monad.Trans.Maybe-import Data.Default (Default(..))-import Data.Maybe+import qualified Control.Concurrent.MVar as MV+import Control.Concurrent.STM.TChan (TChan)+import Control.Monad (forM_, void)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Maybe+import Control.Monad.Trans.Reader+import Data.Default (Default (..))+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Int (Int32)+import Data.List (find)+import qualified Data.Set as Set import qualified Data.Text as T-import GI.GLib (markupEscapeText)+import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk import qualified GI.Gtk as Gtk-import System.Taffybar.Context-import System.Taffybar.Information.EWMHDesktopInfo-import System.Taffybar.Widget.Generic.DynamicMenu-import System.Taffybar.Widget.Generic.ScalingImage (scalingImage)-import System.Taffybar.Widget.Util-import System.Taffybar.Widget.Workspaces (WindowIconPixbufGetter, getWindowData, defaultGetWindowIconPixbuf)-import System.Taffybar.Util+import qualified GI.Pango as Pango+import System.Taffybar.Context+import System.Taffybar.Information.EWMHDesktopInfo+ ( ewmhWMIcon,+ )+import System.Taffybar.Information.Workspaces.EWMH+ ( defaultEWMHWorkspaceProviderConfig,+ getEWMHWorkspaceStateChanAndVarWith,+ workspaceUpdateEvents,+ )+import System.Taffybar.Information.Workspaces.Hyprland+ ( getHyprlandWorkspaceStateChanAndVar,+ )+import System.Taffybar.Information.Workspaces.Model+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.ChannelWidget (channelWidgetNew)+import System.Taffybar.Widget.Generic.ScalingImage (scalingImage)+import System.Taffybar.Widget.Util (widgetSetClassGI)+import System.Taffybar.Widget.Workspaces+ ( defaultOnWindowClick,+ getWindowIconPixbufByClassHints,+ sortWindowsByPosition,+ ) +-- | Behavior configuration for the windows menu widget. data WindowsConfig = WindowsConfig- { getMenuLabel :: X11Window -> TaffyIO T.Text- -- ^ A monadic function that will be used to make a label for the window in- -- the window menu.- , getActiveLabel :: TaffyIO T.Text- -- ^ Action to build the label text for the active window.- , getActiveWindowIconPixbuf :: Maybe WindowIconPixbufGetter- -- ^ Optional function to retrieve a pixbuf to show next to the- -- window label.+ { -- | A monadic function used to build labels for windows in the menu.+ getMenuLabel :: WindowInfo -> TaffyIO T.Text,+ -- | Action to build the label text for the active window.+ getActiveLabel :: Maybe WindowInfo -> TaffyIO T.Text,+ -- | Optional function to retrieve a pixbuf to show next to the+ -- active-window label.+ getActiveWindowIconPixbuf :: Maybe (Int32 -> WindowInfo -> TaffyIO (Maybe Gdk.Pixbuf)),+ -- | Sort menu windows before rendering.+ menuWindowSort :: [WindowInfo] -> TaffyIO [WindowInfo],+ -- | Action when a menu item is selected.+ onMenuWindowClick :: WindowInfo -> TaffyIO () } -defaultGetMenuLabel :: X11Window -> TaffyIO T.Text-defaultGetMenuLabel window = do- windowString <- runX11Def "(nameless window)" (getWindowTitle window)- return $ T.pack windowString+defaultChannelEWMHWorkspaceStateSource ::+ TaffyIO (TChan WorkspaceSnapshot, MV.MVar WorkspaceSnapshot)+defaultChannelEWMHWorkspaceStateSource =+ getEWMHWorkspaceStateChanAndVarWith providerConfig+ where+ providerConfig =+ defaultEWMHWorkspaceProviderConfig+ { workspaceUpdateEvents =+ ewmhWMIcon : workspaceUpdateEvents defaultEWMHWorkspaceProviderConfig+ } -defaultGetActiveLabel :: TaffyIO T.Text-defaultGetActiveLabel = do- label <- fromMaybe "" <$> (runX11Def Nothing getActiveWindow >>=- traverse defaultGetMenuLabel)- markupEscapeText label (-1)+autoWindowStateSource :: TaffyIO (TChan WorkspaceSnapshot, MV.MVar WorkspaceSnapshot)+autoWindowStateSource = do+ backendType <- asks backend+ case backendType of+ BackendWayland -> getHyprlandWorkspaceStateChanAndVar+ BackendX11 -> defaultChannelEWMHWorkspaceStateSource -truncatedGetActiveLabel :: Int -> TaffyIO T.Text-truncatedGetActiveLabel maxLength =- truncateText maxLength <$> defaultGetActiveLabel+-- | Build a menu label from the window title.+defaultGetMenuLabel :: WindowInfo -> TaffyIO T.Text+defaultGetMenuLabel = truncatedGetMenuLabel 35 -truncatedGetMenuLabel :: Int -> X11Window -> TaffyIO T.Text-truncatedGetMenuLabel maxLength =- fmap (truncateText maxLength) . defaultGetMenuLabel+-- | Default active-window label renderer.+defaultGetActiveLabel :: Maybe WindowInfo -> TaffyIO T.Text+defaultGetActiveLabel = maybe (return "") defaultGetMenuLabel +-- | Truncate the active-window label to a maximum length.+truncatedGetActiveLabel :: Int -> Maybe WindowInfo -> TaffyIO T.Text+truncatedGetActiveLabel maxLength windowInfo =+ truncateText maxLength <$> defaultGetActiveLabel windowInfo++-- | Truncate window labels in the popup menu to a maximum length.+truncatedGetMenuLabel :: Int -> WindowInfo -> TaffyIO T.Text+truncatedGetMenuLabel maxLength windowInfo =+ return $ truncateText maxLength (windowTitle windowInfo)++-- | Default configuration used by 'windowsNew'. defaultWindowsConfig :: WindowsConfig defaultWindowsConfig = WindowsConfig- { getMenuLabel = truncatedGetMenuLabel 35- , getActiveLabel = truncatedGetActiveLabel 35- , getActiveWindowIconPixbuf = Just defaultGetWindowIconPixbuf- }+ { getMenuLabel = defaultGetMenuLabel,+ getActiveLabel = defaultGetActiveLabel,+ getActiveWindowIconPixbuf = Just getWindowIconPixbufByClassHints,+ menuWindowSort = pure . sortWindowsByPosition,+ onMenuWindowClick = defaultOnWindowClick+ } +-- | EWMH/X11 preset configuration.+defaultEWMHWindowsConfig :: WindowsConfig+defaultEWMHWindowsConfig = defaultWindowsConfig+ instance Default WindowsConfig where def = defaultWindowsConfig --- | Create a new Windows widget that will use the given Pager as--- its source of events.+-- | Create a new channel-driven backend-agnostic windows widget. windowsNew :: WindowsConfig -> TaffyIO Gtk.Widget windowsNew config = do hbox <- lift $ Gtk.boxNew Gtk.OrientationHorizontal 0+ menu <- lift Gtk.menuNew+ (stateChan, stateVar) <- autoWindowStateSource+ initialSnapshot <- liftIO $ MV.readMVar stateVar+ activeWindowRef <- liftIO $ newIORef $ getActiveWindow initialSnapshot refreshIcon <- case getActiveWindowIconPixbuf config of Just getIcon -> do- (rf, icon) <- buildWindowsIcon getIcon+ (rf, icon) <- buildWindowsIcon activeWindowRef getIcon Gtk.boxPackStart hbox icon True True 0 pure rf Nothing -> pure (pure ()) (setLabelTitle, label) <- buildWindowsLabel Gtk.boxPackStart hbox label True True 0- let refreshLabel = getActiveLabel config >>= lift . setLabelTitle - subscription <- subscribeToPropertyEvents- [ewmhActiveWindow, ewmhWMName, ewmhWMClass]- (const $ refreshLabel >> lift refreshIcon)+ let refreshFromSnapshot snapshot = do+ let activeWindow = getActiveWindow snapshot+ liftIO $ writeIORef activeWindowRef activeWindow+ labelText <- getActiveLabel config activeWindow+ lift $ setLabelTitle labelText+ lift refreshIcon+ lift $ clearMenu menu+ fillMenu config snapshot menu - void $ mapReaderT (Gtk.onWidgetUnrealize hbox) (unsubscribe subscription)+ void $ refreshFromSnapshot initialSnapshot - Gtk.widgetShowAll hbox- boxWidget <- Gtk.toWidget hbox+ ctx <- ask+ _ <-+ liftIO $+ Gtk.onWidgetRealize hbox $ do+ latestSnapshot <- MV.readMVar stateVar+ void $ runReaderT (refreshFromSnapshot latestSnapshot) ctx+ _ <-+ liftIO $+ channelWidgetNew+ hbox+ stateChan+ (\snapshot -> postGUIASync $ runReaderT (refreshFromSnapshot snapshot) ctx) - runTaffy <- asks (flip runReaderT)- menu <- dynamicMenuNew- DynamicMenuConfig { dmClickWidget = boxWidget- , dmPopulateMenu = runTaffy . fillMenu config- }+ boxWidget <- Gtk.toWidget hbox+ button <- lift Gtk.menuButtonNew+ lift $ Gtk.containerAdd button boxWidget+ lift $ Gtk.menuButtonSetPopup button $ Just menu+ lift $ Gtk.widgetShowAll button+ menuButtonWidget <- Gtk.toWidget button - widgetSetClassGI menu "windows"+ widgetSetClassGI menuButtonWidget "windows" +-- | Build the active-window label and return an update action for it. buildWindowsLabel :: TaffyIO (T.Text -> IO (), Gtk.Widget) buildWindowsLabel = do label <- lift $ Gtk.labelNew Nothing- let setLabelTitle title = postGUIASync $ Gtk.labelSetMarkup label title+ lift $ Gtk.labelSetSingleLineMode label True+ lift $ Gtk.labelSetEllipsize label Pango.EllipsizeModeEnd+ let setLabelTitle title = postGUIASync $ Gtk.labelSetText label title (setLabelTitle,) <$> Gtk.toWidget label -buildWindowsIcon :: WindowIconPixbufGetter -> TaffyIO (IO (), Gtk.Widget)-buildWindowsIcon windowIconPixbufGetter = do+-- | Build the active-window icon and return an update action for it.+buildWindowsIcon ::+ IORef (Maybe WindowInfo) ->+ (Int32 -> WindowInfo -> TaffyIO (Maybe Gdk.Pixbuf)) ->+ TaffyIO (IO (), Gtk.Widget)+buildWindowsIcon activeWindowRef windowIconPixbufGetter = do runTaffy <- asks (flip runReaderT) let getActiveWindowPixbuf size = runTaffy . runMaybeT $ do- wd <- MaybeT $ runX11Def Nothing $- traverse (getWindowData Nothing []) =<< getActiveWindow- MaybeT $ windowIconPixbufGetter size wd+ windowInfo <- MaybeT $ liftIO $ readIORef activeWindowRef+ MaybeT $ windowIconPixbufGetter size windowInfo (imageWidget, updateImage) <- scalingImage getActiveWindowPixbuf Gtk.OrientationHorizontal return (postGUIASync updateImage, imageWidget) --- | Populate the given menu widget with the list of all currently open windows.-fillMenu :: Gtk.IsMenuShell a => WindowsConfig -> a -> ReaderT Context IO ()-fillMenu config menu = ask >>= \context ->- runX11Def () $ do- windowIds <- getWindows- forM_ windowIds $ \windowId ->+-- | Populate the given menu widget with the list of currently open windows.+fillMenu ::+ (Gtk.IsMenuShell a) =>+ WindowsConfig ->+ WorkspaceSnapshot ->+ a ->+ ReaderT Context IO ()+fillMenu config snapshot menu =+ ask >>= \context -> do+ windows <- menuWindowSort config (getWindows snapshot)+ forM_ windows $ \windowInfo -> lift $ do- labelText <- runReaderT (getMenuLabel config windowId) context- let focusCallback = runReaderT (runX11 $ focusWindow windowId) context >>- return True- item <- Gtk.menuItemNewWithLabel labelText+ labelText <- runReaderT (getMenuLabel config windowInfo) context+ iconPixbuf <-+ case getActiveWindowIconPixbuf config of+ Nothing -> pure Nothing+ Just getIcon -> runReaderT (getIcon windowsMenuIconSize windowInfo) context+ let focusCallback =+ runReaderT (onMenuWindowClick config windowInfo) context+ >> return True+ item <- Gtk.menuItemNew+ content <- Gtk.boxNew Gtk.OrientationHorizontal 6+ forM_ iconPixbuf $ \pixbuf -> do+ icon <- Gtk.imageNewFromPixbuf (Just pixbuf)+ Gtk.boxPackStart content icon False False 0+ pure icon+ label <- Gtk.labelNew (Just labelText)+ Gtk.labelSetXalign label 0+ Gtk.boxPackStart content label True True 0+ Gtk.containerAdd item content _ <- Gtk.onWidgetButtonPressEvent item $ const focusCallback Gtk.menuShellAppend menu item- Gtk.widgetShow item+ Gtk.widgetShowAll item++clearMenu :: (Gtk.IsContainer a) => a -> IO ()+clearMenu menu =+ Gtk.containerForeach menu $ \item ->+ Gtk.containerRemove menu item >> Gtk.widgetDestroy item++windowsMenuIconSize :: Int32+windowsMenuIconSize = fromIntegral $ fromEnum Gtk.IconSizeMenu++getWindows :: WorkspaceSnapshot -> [WindowInfo]+getWindows snapshot = reverse ordered+ where+ allWindows =+ [ win+ | wsInfo <- snapshotWorkspaces snapshot,+ win <- workspaceWindows wsInfo+ ]+ (_, ordered) = foldl keepFirst (Set.empty, []) allWindows+ keepFirst (seen, acc) windowInfo+ | windowIdentity windowInfo `Set.member` seen = (seen, acc)+ | otherwise =+ (Set.insert (windowIdentity windowInfo) seen, windowInfo : acc)++getActiveWindow :: WorkspaceSnapshot -> Maybe WindowInfo+getActiveWindow snapshot =+ find windowActive allWindows+ where+ allWindows =+ [ win+ | wsInfo <- snapshotWorkspaces snapshot,+ win <- workspaceWindows wsInfo+ ]
src/System/Taffybar/Widget/WirePlumber.hs view
@@ -42,15 +42,15 @@ import Data.Char (ord) import Data.Default (Default (..)) import qualified Data.Text as T+import qualified GI.GLib as G import qualified GI.Gdk.Enums as Gdk import qualified GI.Gdk.Structs.EventScroll as GdkEvent-import qualified GI.GLib as G import qualified GI.Gtk as Gtk import System.Taffybar.Context (TaffyIO) import System.Taffybar.Information.WirePlumber import System.Taffybar.Util (postGUIASync) import System.Taffybar.Widget.Generic.ChannelWidget-import System.Taffybar.Widget.Util (buildIconLabelBox)+import System.Taffybar.Widget.Util (buildIconLabelBox, widgetSetClassGI) import Text.StringTemplate -- | Configuration for the WirePlumber widget.@@ -113,6 +113,7 @@ initialInfo <- getWirePlumberInfoState nodeSpec liftIO $ do label <- Gtk.labelNew Nothing+ _ <- widgetSetClassGI label "wire-plumber-icon" let updateIcon info = do let iconText = case info of Nothing -> T.pack "\xF026"@@ -123,7 +124,8 @@ postGUIASync $ Gtk.labelSetText label iconText void $ Gtk.onWidgetRealize label $ updateIcon initialInfo Gtk.widgetShowAll label- Gtk.toWidget =<< channelWidgetNew label chan updateIcon+ (Gtk.toWidget =<< channelWidgetNew label chan updateIcon)+ >>= (`widgetSetClassGI` "wire-plumber-icon") -- | Create a WirePlumber label-only widget for the default audio sink. -- Includes scroll-to-adjust and click-to-mute interaction.@@ -139,6 +141,7 @@ liftIO $ do label <- Gtk.labelNew Nothing+ _ <- widgetSetClassGI label "wire-plumber-label" let updateLabel info = do (labelText, tooltipText) <- formatWirePlumberWidget config info@@ -181,7 +184,8 @@ whenScrollAdjust label Gtk.widgetShowAll label- Gtk.toWidget =<< channelWidgetNew label chan updateLabel+ (Gtk.toWidget =<< channelWidgetNew label chan updateLabel)+ >>= (`widgetSetClassGI` "wire-plumber-label") -- | Create a combined icon + label WirePlumber widget for the default audio sink. wirePlumberNew :: TaffyIO Gtk.Widget@@ -192,15 +196,19 @@ wirePlumberNewWith config = do iconWidget <- wirePlumberIconNewWith config labelWidget <- wirePlumberLabelNewWith config- liftIO $ buildIconLabelBox iconWidget labelWidget+ liftIO $+ buildIconLabelBox iconWidget labelWidget+ >>= (`widgetSetClassGI` "wire-plumber") -- | Create a new WirePlumber widget for the default audio source (microphone). wirePlumberSourceNew :: TaffyIO Gtk.Widget-wirePlumberSourceNew = wirePlumberNewWith defaultWirePlumberSourceWidgetConfig+wirePlumberSourceNew = wirePlumberSourceNewWith defaultWirePlumberSourceWidgetConfig -- | Create a new WirePlumber widget for audio source with custom configuration. wirePlumberSourceNewWith :: WirePlumberWidgetConfig -> TaffyIO Gtk.Widget-wirePlumberSourceNewWith = wirePlumberNewWith+wirePlumberSourceNewWith config = do+ widget <- wirePlumberNewWith config+ liftIO $ widgetSetClassGI widget "wire-plumber-source" formatWirePlumberWidget :: WirePlumberWidgetConfig ->
src/System/Taffybar/Widget/Wlsunset.hs view
@@ -1,5 +1,9 @@ {-# LANGUAGE OverloadedStrings #-}+ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Wlsunset -- Copyright : (c) Ivan A. Malison@@ -15,56 +19,58 @@ -- The widget displays a sun icon whose CSS class reflects the current -- wlsunset state: ----- * @wlsunset-warm@ -- forced warm (night) temperature--- * @wlsunset-cool@ -- forced cool (day) temperature--- * @wlsunset-off@ -- process stopped--- * (no extra class) -- automatic\/running normally+-- * @wlsunset-high-temp@ -- forced high (day) temperature, no shift+-- * @wlsunset-low-temp@ -- forced low (night) temperature+-- * @wlsunset-off@ -- process stopped+-- * (no extra class) -- automatic\/running normally -- -- Left-clicking opens a popup menu for mode selection and start\/stop. -- Right-clicking quick-toggles the process on\/off.------------------------------------------------------------------------------ module System.Taffybar.Widget.Wlsunset- ( wlsunsetNew- , wlsunsetNewWithConfig- , WlsunsetWidgetConfig(..)- , defaultWlsunsetWidgetConfig- ) where+ ( wlsunsetNew,+ wlsunsetNewWithConfig,+ WlsunsetWidgetConfig (..),+ defaultWlsunsetWidgetConfig,+ )+where -import Control.Monad (void, forM_, replicateM_)-import Control.Monad.IO.Class (liftIO)-import Control.Monad.Trans.Reader (ask, runReaderT)-import Data.Default (Default(..))+import Control.Monad (forM_, replicateM_, void)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Reader (ask, runReaderT)+import Data.Default (Default (..)) import qualified Data.Text as T-import qualified GI.Gdk as Gdk import qualified GI.GLib as GLib+import qualified GI.Gdk as Gdk import qualified GI.Gtk as Gtk-import System.Taffybar.Context (Context, TaffyIO)-import System.Taffybar.Information.Wlsunset-import System.Taffybar.Util (postGUIASync)-import System.Taffybar.Widget.Generic.ChannelWidget (channelWidgetNew)+import System.Taffybar.Context (Context, TaffyIO)+import System.Taffybar.Information.Wlsunset+import System.Taffybar.Util (postGUIASync)+import System.Taffybar.Widget.Generic.ChannelWidget (channelWidgetNew) -- | Widget-level configuration for the wlsunset widget. Wraps the -- underlying 'WlsunsetConfig' from the Information layer. data WlsunsetWidgetConfig = WlsunsetWidgetConfig { -- | The underlying information-layer config.- wlsunsetWidgetInfoConfig :: WlsunsetConfig+ wlsunsetWidgetInfoConfig :: WlsunsetConfig, -- | Text icon to display (default: sun icon U+F0599, nf-md-white_balance_sunny).- , wlsunsetWidgetIcon :: T.Text- } deriving (Eq, Show)+ wlsunsetWidgetIcon :: T.Text+ }+ deriving (Eq, Show) -- | Default widget configuration. defaultWlsunsetWidgetConfig :: WlsunsetWidgetConfig-defaultWlsunsetWidgetConfig = WlsunsetWidgetConfig- { wlsunsetWidgetInfoConfig = def- , wlsunsetWidgetIcon = T.pack "\xF0599"- }+defaultWlsunsetWidgetConfig =+ WlsunsetWidgetConfig+ { wlsunsetWidgetInfoConfig = def,+ wlsunsetWidgetIcon = T.pack "\xF0599"+ } instance Default WlsunsetWidgetConfig where def = defaultWlsunsetWidgetConfig -- | All CSS classes that the widget may toggle based on state. allStateClasses :: [T.Text]-allStateClasses = ["wlsunset-warm", "wlsunset-cool", "wlsunset-off"]+allStateClasses = ["wlsunset-high-temp", "wlsunset-low-temp", "wlsunset-off"] -- | Create a wlsunset widget with the default configuration. wlsunsetNew :: TaffyIO Gtk.Widget@@ -93,7 +99,7 @@ let updateWidget st = postGUIASync $ do updateStateClasses ebox st- updateTooltip ebox st+ updateTooltip infoCfg ebox st void $ Gtk.onWidgetRealize ebox $ do initialState <- runReaderT (getWlsunsetState infoCfg) ctx@@ -108,7 +114,7 @@ -- --------------------------------------------------------------------------- -- | Update CSS classes on a widget based on the current wlsunset state.-updateStateClasses :: Gtk.IsWidget w => w -> WlsunsetState -> IO ()+updateStateClasses :: (Gtk.IsWidget w) => w -> WlsunsetState -> IO () updateStateClasses widget st = do styleCtx <- Gtk.widgetGetStyleContext widget -- Remove all state classes first@@ -121,22 +127,24 @@ stateToClass st | not (wlsunsetRunning st) = Just "wlsunset-off" | otherwise = case wlsunsetMode st of- WlsunsetAuto -> Nothing- WlsunsetForcedWarm -> Just "wlsunset-warm"- WlsunsetForcedCool -> Just "wlsunset-cool"+ WlsunsetAuto -> Nothing+ WlsunsetForcedHighTemp -> Just "wlsunset-high-temp"+ WlsunsetForcedLowTemp -> Just "wlsunset-low-temp" -- --------------------------------------------------------------------------- -- Tooltip -- --------------------------------------------------------------------------- -- | Update the tooltip text to reflect the current state.-updateTooltip :: Gtk.IsWidget w => w -> WlsunsetState -> IO ()-updateTooltip widget st = do- let text = case (wlsunsetRunning st, wlsunsetMode st) of- (False, _) -> "wlsunset: stopped"- (True, WlsunsetAuto) -> "wlsunset: automatic"- (True, WlsunsetForcedWarm) -> "wlsunset: forced warm"- (True, WlsunsetForcedCool) -> "wlsunset: forced cool"+updateTooltip :: (Gtk.IsWidget w) => WlsunsetConfig -> w -> WlsunsetState -> IO ()+updateTooltip _cfg widget st = do+ let highT = T.pack $ show (wlsunsetEffectiveHighTemp st) ++ "K"+ lowT = T.pack $ show (wlsunsetEffectiveLowTemp st) ++ "K"+ text = case (wlsunsetRunning st, wlsunsetMode st) of+ (False, _) -> "wlsunset: stopped"+ (True, WlsunsetAuto) -> "wlsunset: automatic (" <> lowT <> " – " <> highT <> ")"+ (True, WlsunsetForcedHighTemp) -> "wlsunset: high temp (" <> highT <> ")"+ (True, WlsunsetForcedLowTemp) -> "wlsunset: low temp (" <> lowT <> ")" Gtk.widgetSetTooltipText widget (Just text) -- ---------------------------------------------------------------------------@@ -166,6 +174,10 @@ -- Popup menu -- --------------------------------------------------------------------------- +-- | Temperature presets from 2500K to 6500K in 500K increments.+tempPresets :: [Int]+tempPresets = [2500, 3000, 3500, 4000, 4500, 5000, 5500, 6000, 6500]+ -- | Build and show a popup menu attached to the event box. showPopupMenu :: Context -> Gtk.EventBox -> WlsunsetConfig -> IO () showPopupMenu ctx ebox infoCfg = do@@ -178,38 +190,95 @@ -- Mode items (disabled when not running) let running = wlsunsetRunning st currentMode = wlsunsetMode st- modes = [ ("Automatic", WlsunsetAuto)- , ("Force Warm", WlsunsetForcedWarm)- , ("Force Cool", WlsunsetForcedCool)- ]+ effectiveHigh = wlsunsetEffectiveHighTemp st+ effectiveLow = wlsunsetEffectiveLowTemp st+ highT = T.pack $ show effectiveHigh ++ "K"+ lowT = T.pack $ show effectiveLow ++ "K"+ modes =+ [ ("Automatic", WlsunsetAuto),+ ("High Temp (" <> highT <> ")", WlsunsetForcedHighTemp),+ ("Low Temp (" <> lowT <> ")", WlsunsetForcedLowTemp)+ ] forM_ modes $ \(labelText, targetMode) -> do- let prefix = if running && currentMode == targetMode- then "\x2713 " :: T.Text -- checkmark- else " "+ let prefix =+ if running && currentMode == targetMode+ then "\x2713 " :: T.Text -- checkmark+ else " " item <- Gtk.menuItemNewWithLabel (prefix <> labelText) Gtk.widgetSetSensitive item running- void $ Gtk.onMenuItemActivate item $- runReaderT (cycleToMode infoCfg currentMode targetMode) ctx+ void $+ Gtk.onMenuItemActivate item $+ runReaderT (cycleToMode infoCfg currentMode targetMode) ctx Gtk.menuShellAppend menu item - -- Separator- sep <- Gtk.separatorMenuItemNew- Gtk.menuShellAppend menu sep+ -- Separator before temperature presets+ sep1 <- Gtk.separatorMenuItemNew+ Gtk.menuShellAppend menu sep1 + -- Temperature presets submenu+ tempSubMenuItem <- Gtk.menuItemNewWithLabel ("Set Temperature" :: T.Text)+ tempSubmenu <- Gtk.menuNew+ Gtk.menuItemSetSubmenu tempSubMenuItem (Just tempSubmenu)++ let isFixedTemp = effectiveLow == effectiveHigh+ forM_ tempPresets $ \temp -> do+ let label = T.pack $ show temp ++ "K"+ prefix =+ if running && isFixedTemp && effectiveLow == temp+ then "\x2713 " :: T.Text+ else " "+ tempItem <- Gtk.menuItemNewWithLabel (prefix <> label)+ Gtk.widgetSetSensitive tempItem running+ void $+ Gtk.onMenuItemActivate tempItem $+ runReaderT (restartWlsunsetWithTemps infoCfg temp temp) ctx+ Gtk.menuShellAppend tempSubmenu tempItem++ -- "Reset to default" item in the submenu+ resetSep <- Gtk.separatorMenuItemNew+ Gtk.menuShellAppend tempSubmenu resetSep++ let defaultLow = wlsunsetLowTemp infoCfg+ defaultHigh = wlsunsetHighTemp infoCfg+ resetLabel =+ T.pack $+ "Reset (" ++ show defaultLow ++ "K – " ++ show defaultHigh ++ "K)"+ resetPrefix =+ if running && effectiveLow == defaultLow && effectiveHigh == defaultHigh+ then "\x2713 " :: T.Text+ else " "+ resetItem <- Gtk.menuItemNewWithLabel (resetPrefix <> resetLabel)+ Gtk.widgetSetSensitive resetItem running+ void $+ Gtk.onMenuItemActivate resetItem $+ runReaderT (restartWlsunsetWithTemps infoCfg defaultLow defaultHigh) ctx+ Gtk.menuShellAppend tempSubmenu resetItem++ Gtk.menuShellAppend menu tempSubMenuItem++ -- Separator before toggle+ sep2 <- Gtk.separatorMenuItemNew+ Gtk.menuShellAppend menu sep2+ -- Start/Stop item- let toggleLabel = if running then "Stop wlsunset" :: T.Text- else "Start wlsunset"+ let toggleLabel =+ if running+ then "Stop wlsunset" :: T.Text+ else "Start wlsunset" toggleItem <- Gtk.menuItemNewWithLabel toggleLabel- void $ Gtk.onMenuItemActivate toggleItem $- runReaderT (toggleWlsunset infoCfg) ctx+ void $+ Gtk.onMenuItemActivate toggleItem $+ runReaderT (toggleWlsunset infoCfg) ctx Gtk.menuShellAppend menu toggleItem -- Cleanup: destroy menu after it hides- void $ Gtk.onWidgetHide menu $- void $ GLib.idleAdd GLib.PRIORITY_LOW $ do- Gtk.widgetDestroy menu- return False+ void $+ Gtk.onWidgetHide menu $+ void $+ GLib.idleAdd GLib.PRIORITY_LOW $ do+ Gtk.widgetDestroy menu+ return False Gtk.widgetShowAll menu Gtk.menuPopupAtPointer menu currentEvent@@ -219,7 +288,7 @@ -- --------------------------------------------------------------------------- -- | Cycle from the current mode to a target mode. The mode ring is:--- Auto -> ForcedWarm -> ForcedCool -> Auto. We calculate the number of+-- Auto -> ForcedHighTemp -> ForcedLowTemp -> Auto. We calculate the number of -- SIGUSR1 signals needed and send them. cycleToMode :: WlsunsetConfig -> WlsunsetMode -> WlsunsetMode -> TaffyIO () cycleToMode infoCfg currentMode targetMode = do@@ -227,13 +296,13 @@ replicateM_ cyclesNeeded (cycleWlsunsetMode infoCfg) -- | Calculate how many SIGUSR1 cycles are needed to go from one mode--- to another in the ring Auto -> ForcedWarm -> ForcedCool -> Auto.+-- to another in the ring Auto -> ForcedHighTemp -> ForcedLowTemp -> Auto. cyclesToReach :: WlsunsetMode -> WlsunsetMode -> Int cyclesToReach from to | from == to = 0- | otherwise = (toOrd to - toOrd from) `mod` 3+ | otherwise = (toOrd to - toOrd from) `mod` 3 where toOrd :: WlsunsetMode -> Int- toOrd WlsunsetAuto = 0- toOrd WlsunsetForcedWarm = 1- toOrd WlsunsetForcedCool = 2+ toOrd WlsunsetAuto = 0+ toOrd WlsunsetForcedHighTemp = 1+ toOrd WlsunsetForcedLowTemp = 2
src/System/Taffybar/Widget/Workspaces.hs view
@@ -1,787 +1,663 @@-{-# OPTIONS_GHC -fno-warn-type-defaults #-}-{-# LANGUAGE ScopedTypeVariables, ExistentialQuantification, RankNTypes, OverloadedStrings, StrictData #-}--------------------------------------------------------------------------------- |--- Module : System.Taffybar.Widget.Workspaces--- Copyright : (c) Ivan A. Malison--- License : BSD3-style (see LICENSE)------ Maintainer : Ivan A. Malison--- Stability : unstable--- Portability : unportable--------------------------------------------------------------------------------module System.Taffybar.Widget.Workspaces- ( module System.Taffybar.Widget.Workspaces- , module System.Taffybar.Widget.Workspaces.Shared- ) where--import Control.Arrow ((&&&))-import Control.Concurrent-import qualified Control.Concurrent.MVar as MV-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Class-import Control.Monad.Trans.Reader-import Control.RateLimit-import Data.Default (Default(..))-import qualified Data.Foldable as F-import Data.GI.Base.ManagedPtr (unsafeCastTo)-import Data.Int-import Data.List (elemIndex, intersect, sortBy, (\\))-import qualified Data.Map as M-import Data.Maybe-import qualified Data.MultiMap as MM-import qualified Data.Set as Set-import qualified Data.Text as T-import Data.Time.Units-import Data.Tuple.Select-import Data.Tuple.Sequence-import qualified GI.Gdk.Enums as Gdk-import qualified GI.Gdk.Structs.EventScroll as Gdk-import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk-import qualified GI.Gtk as Gtk-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.Information.EWMHDesktopInfo-import System.Taffybar.Information.SafeX11-import System.Taffybar.Information.X11DesktopInfo-import System.Taffybar.Util-import System.Taffybar.Widget.Util-import System.Taffybar.Widget.Generic.ScalingImage (getScalingImageStrategy)-import System.Taffybar.Widget.Workspaces.Shared- ( WorkspaceState(..)- , getCSSClass- , cssWorkspaceStates- , setWorkspaceWidgetStatusClass- , buildWorkspaceIconLabelOverlay- , mkWorkspaceIconWidget- )-import System.Taffybar.WindowIcon-import Text.Printf--data WindowData = WindowData- { windowId :: X11Window- , windowTitle :: String- , windowClass :: String- , windowUrgent :: Bool- , windowActive :: Bool- , windowMinimized :: Bool- } deriving (Show, Eq)--data WidgetUpdate = WorkspaceUpdate Workspace | IconUpdate [X11Window]--data Workspace = Workspace- { workspaceIdx :: WorkspaceId- , workspaceName :: String- , workspaceState :: WorkspaceState- , windows :: [WindowData]- } deriving (Show, Eq)--data WorkspacesContext = WorkspacesContext- { controllersVar :: MV.MVar (M.Map WorkspaceId WWC)- , workspacesVar :: MV.MVar (M.Map WorkspaceId Workspace)- , workspacesWidget :: Gtk.Box- , workspacesConfig :: WorkspacesConfig- , taffyContext :: Context- }--type WorkspacesIO a = ReaderT WorkspacesContext IO a--liftContext :: TaffyIO a -> WorkspacesIO a-liftContext action = asks taffyContext >>= lift . runReaderT action--liftX11Def :: a -> X11Property a -> WorkspacesIO a-liftX11Def dflt prop = liftContext $ runX11Def dflt prop--class WorkspaceWidgetController wc where- getWidget :: wc -> WorkspacesIO Gtk.Widget- updateWidget :: wc -> WidgetUpdate -> WorkspacesIO wc- updateWidgetX11 :: wc -> WidgetUpdate -> WorkspacesIO wc- updateWidgetX11 cont _ = return cont--data WWC = forall a. WorkspaceWidgetController a => WWC a--instance WorkspaceWidgetController WWC where- getWidget (WWC wc) = getWidget wc- updateWidget (WWC wc) update = WWC <$> updateWidget wc update- updateWidgetX11 (WWC wc) update = WWC <$> updateWidgetX11 wc update--type ControllerConstructor = Workspace -> WorkspacesIO WWC-type ParentControllerConstructor =- ControllerConstructor -> ControllerConstructor--type WindowIconPixbufGetter =- Int32 -> WindowData -> TaffyIO (Maybe Gdk.Pixbuf)--data WorkspacesConfig =- WorkspacesConfig- { widgetBuilder :: ControllerConstructor- , widgetGap :: Int- , maxIcons :: Maybe Int- , minIcons :: Int- , getWindowIconPixbuf :: WindowIconPixbufGetter- , labelSetter :: Workspace -> WorkspacesIO String- , showWorkspaceFn :: Workspace -> Bool- , borderWidth :: Int- , updateEvents :: [String]- , updateRateLimitMicroseconds :: Integer- , iconSort :: [WindowData] -> WorkspacesIO [WindowData]- , urgentWorkspaceState :: Bool- }--defaultWorkspacesConfig :: WorkspacesConfig-defaultWorkspacesConfig =- WorkspacesConfig- { widgetBuilder = buildButtonController defaultBuildContentsController- , widgetGap = 0- , maxIcons = Nothing- , minIcons = 0- , getWindowIconPixbuf = defaultGetWindowIconPixbuf- , labelSetter = return . workspaceName- , showWorkspaceFn = const True- , borderWidth = 2- , iconSort = sortWindowsByPosition- , updateEvents = allEWMHProperties \\ [ewmhWMIcon]- , updateRateLimitMicroseconds = 100000- , urgentWorkspaceState = False- }--instance Default WorkspacesConfig where- def = defaultWorkspacesConfig--hideEmpty :: Workspace -> Bool-hideEmpty Workspace { workspaceState = Empty } = False-hideEmpty _ = True--wLog :: MonadIO m => Priority -> String -> m ()-wLog l s = liftIO $ logM "System.Taffybar.Widget.Workspaces" l s--updateVar :: MV.MVar a -> (a -> WorkspacesIO a) -> WorkspacesIO a-updateVar var modify = do- ctx <- ask- lift $ MV.modifyMVar var $ fmap (\a -> (a, a)) . flip runReaderT ctx . modify--updateWorkspacesVar :: WorkspacesIO (M.Map WorkspaceId Workspace)-updateWorkspacesVar = do- workspacesRef <- asks workspacesVar- updateVar workspacesRef buildWorkspaceData--getWorkspaceToWindows ::- [X11Window] -> X11Property (MM.MultiMap WorkspaceId X11Window)-getWorkspaceToWindows =- foldM- (\theMap window ->- MM.insert <$> getWorkspace window <*> pure window <*> pure theMap)- MM.empty--getWindowData :: Maybe X11Window- -> [X11Window]- -> X11Window- -> X11Property WindowData-getWindowData activeWindow urgentWindows window = do- wTitle <- getWindowTitle window- wClass <- getWindowClass window- wMinimized <- getWindowMinimized window- return- WindowData- { windowId = window- , windowTitle = wTitle- , windowClass = wClass- , windowUrgent = window `elem` urgentWindows- , windowActive = Just window == activeWindow- , windowMinimized = wMinimized- }--buildWorkspaceData :: M.Map WorkspaceId Workspace- -> WorkspacesIO (M.Map WorkspaceId Workspace)-buildWorkspaceData _ = ask >>= \context -> liftX11Def M.empty $ do- names <- getWorkspaceNames- wins <- getWindows- workspaceToWindows <- getWorkspaceToWindows wins- urgentWindows <- filterM isWindowUrgent wins- activeWindow <- getActiveWindow- active:visible <- getVisibleWorkspaces- let getWorkspaceState idx ws- | idx == active = Active- | idx `elem` visible = Visible- | urgentWorkspaceState (workspacesConfig context) &&- not (null (ws `intersect` urgentWindows)) =- Urgent- | null ws = Empty- | otherwise = Hidden- foldM- (\theMap (idx, name) -> do- let ws = MM.lookup idx workspaceToWindows- windowInfos <- mapM (getWindowData activeWindow urgentWindows) ws- return $- M.insert- idx- Workspace- { workspaceIdx = idx- , workspaceName = name- , workspaceState = getWorkspaceState idx ws- , windows = windowInfos- }- theMap)- M.empty- names--addWidgetsToTopLevel :: WorkspacesIO ()-addWidgetsToTopLevel = do- WorkspacesContext- { controllersVar = controllersRef- , workspacesWidget = cont- } <- ask- controllersMap <- lift $ MV.readMVar controllersRef- -- Elems returns elements in ascending order of their keys so this will always- -- add the widgets in the correct order- mapM_ addWidget $ M.elems controllersMap- lift $ Gtk.widgetShowAll cont--addWidget :: WWC -> WorkspacesIO ()-addWidget controller = do- cont <- asks workspacesWidget- workspaceWidget <- getWidget controller- lift $ do- -- XXX: This hbox exists to (hopefully) prevent the issue where workspace- -- widgets appear out of order, in the switcher, by acting as an empty- -- place holder when the actual widget is hidden.- hbox <- Gtk.boxNew Gtk.OrientationHorizontal 0- void $ Gtk.widgetGetParent workspaceWidget >>=- traverse (unsafeCastTo Gtk.Box) >>=- traverse (`Gtk.containerRemove` workspaceWidget)- Gtk.containerAdd hbox workspaceWidget- Gtk.containerAdd cont hbox--workspacesNew :: WorkspacesConfig -> TaffyIO Gtk.Widget-workspacesNew cfg = ask >>= \tContext -> lift $ do- cont <- Gtk.boxNew Gtk.OrientationHorizontal $ fromIntegral (widgetGap cfg)- controllersRef <- MV.newMVar M.empty- workspacesRef <- MV.newMVar M.empty- let context =- WorkspacesContext- { controllersVar = controllersRef- , workspacesVar = workspacesRef- , workspacesWidget = cont- , workspacesConfig = cfg- , taffyContext = tContext- }- -- This will actually create all the widgets- runReaderT updateAllWorkspaceWidgets context- updateHandler <- onWorkspaceUpdate context- iconHandler <- onIconsChanged context- let doUpdate = lift . updateHandler- handleConfigureEvents e@(ConfigureEvent {}) = doUpdate e- handleConfigureEvents _ = return ()- (workspaceSubscription, iconSubscription, geometrySubscription) <-- flip runReaderT tContext $ sequenceT- ( subscribeToPropertyEvents (updateEvents cfg) doUpdate- , subscribeToPropertyEvents [ewmhWMIcon] (lift . onIconChanged iconHandler)- , subscribeToAll handleConfigureEvents- )- let doUnsubscribe = flip runReaderT tContext $- mapM_ unsubscribe- [ iconSubscription- , workspaceSubscription- , geometrySubscription- ]- _ <- Gtk.onWidgetUnrealize cont doUnsubscribe- _ <- widgetSetClassGI cont "workspaces"- Gtk.toWidget cont--updateAllWorkspaceWidgets :: WorkspacesIO ()-updateAllWorkspaceWidgets = do- wLog DEBUG "Updating workspace widgets"-- workspacesMap <- updateWorkspacesVar- wLog DEBUG $ printf "Workspaces: %s" $ show workspacesMap-- wLog DEBUG "Adding and removing widgets"- updateWorkspaceControllers-- let updateController' idx controller =- maybe (return controller)- (updateWidget controller . WorkspaceUpdate) $- M.lookup idx workspacesMap- logUpdateController i =- wLog DEBUG $ printf "Updating %s workspace widget" $ show i- updateController i cont = logUpdateController i >>- updateController' i cont-- wLog DEBUG "Done updating individual widget"-- doWidgetUpdate updateController-- wLog DEBUG "Showing and hiding controllers"- setControllerWidgetVisibility--setControllerWidgetVisibility :: WorkspacesIO ()-setControllerWidgetVisibility = do- ctx@WorkspacesContext- { workspacesVar = workspacesRef- , controllersVar = controllersRef- , workspacesConfig = cfg- } <- ask- lift $ do- workspacesMap <- MV.readMVar workspacesRef- controllersMap <- MV.readMVar controllersRef- forM_ (M.elems workspacesMap) $ \ws ->- let action = if showWorkspaceFn cfg ws- then Gtk.widgetShow- else Gtk.widgetHide- in- traverse (flip runReaderT ctx . getWidget)- (M.lookup (workspaceIdx ws) controllersMap) >>=- maybe (return ()) action--doWidgetUpdate :: (WorkspaceId -> WWC -> WorkspacesIO WWC) -> WorkspacesIO ()-doWidgetUpdate updateController = do- c@WorkspacesContext { controllersVar = controllersRef } <- ask- lift $ MV.modifyMVar_ controllersRef $ \controllers -> do- wLog DEBUG "Updating controllers ref"- controllersList <-- mapM- (\(idx, controller) -> do- newController <- runReaderT (updateController idx controller) c- return (idx, newController)) $- M.toList controllers- return $ M.fromList controllersList--updateWorkspaceControllers :: WorkspacesIO ()-updateWorkspaceControllers = do- WorkspacesContext- { controllersVar = controllersRef- , workspacesVar = workspacesRef- , workspacesWidget = cont- , workspacesConfig = cfg- } <- ask- workspacesMap <- lift $ MV.readMVar workspacesRef- controllersMap <- lift $ MV.readMVar controllersRef-- let newWorkspacesSet = M.keysSet workspacesMap- existingWorkspacesSet = M.keysSet controllersMap-- when (existingWorkspacesSet /= newWorkspacesSet) $ do- let addWorkspaces = Set.difference newWorkspacesSet existingWorkspacesSet- removeWorkspaces = Set.difference existingWorkspacesSet newWorkspacesSet- builder = widgetBuilder cfg-- _ <- updateVar controllersRef $ \controllers -> do- let oldRemoved = F.foldl' (flip M.delete) controllers removeWorkspaces- buildController idx = builder <$> M.lookup idx workspacesMap- buildAndAddController theMap idx =- maybe (return theMap) (>>= return . flip (M.insert idx) theMap)- (buildController idx)- foldM buildAndAddController oldRemoved $ Set.toList addWorkspaces- -- Clear the container and repopulate it- lift $ Gtk.containerForeach cont (Gtk.containerRemove cont)- addWidgetsToTopLevel--rateLimitFn- :: forall req resp.- WorkspacesContext- -> (req -> IO resp)- -> ResultsCombiner req resp- -> IO (req -> IO resp)-rateLimitFn context =- let limit = (updateRateLimitMicroseconds $ workspacesConfig context)- rate = fromMicroseconds limit :: Microsecond in- generateRateLimitedFunction $ PerInvocation rate--onWorkspaceUpdate :: WorkspacesContext -> IO (Event -> IO ())-onWorkspaceUpdate context = do- rateLimited <- rateLimitFn context doUpdate combineRequests- let withLog event = do- case event of- PropertyEvent _ _ _ _ _ atom _ _ ->- wLog DEBUG $ printf "Event %s" $ show atom- _anythingElse -> return ()- void $ forkIO $ rateLimited event- return withLog- where- combineRequests _ b = Just (b, const ((), ()))- doUpdate _ = postGUIASync $ runReaderT updateAllWorkspaceWidgets context--onIconChanged :: (Set.Set X11Window -> IO ()) -> Event -> IO ()-onIconChanged handler event =- case event of- PropertyEvent { ev_window = wid } -> do- wLog DEBUG $ printf "Icon changed event %s" $ show wid- handler $ Set.singleton wid- _ -> return ()--onIconsChanged :: WorkspacesContext -> IO (Set.Set X11Window -> IO ())-onIconsChanged context = rateLimitFn context onIconsChanged' combineRequests- where- combineRequests windows1 windows2 =- Just (Set.union windows1 windows2, const ((), ()))- onIconsChanged' wids = do- wLog DEBUG $ printf "Icon update execute %s" $ show wids- postGUIASync $ flip runReaderT context $- doWidgetUpdate- (\idx c ->- wLog DEBUG (printf "Updating %s icons." $ show idx) >>- updateWidget c (IconUpdate $ Set.toList wids))--initializeWWC ::- WorkspaceWidgetController a => a -> Workspace -> ReaderT WorkspacesContext IO WWC-initializeWWC controller ws =- WWC <$> updateWidget controller (WorkspaceUpdate ws)---- | A WrappingController can be used to wrap some child widget with another--- abitrary widget.-data WrappingController = WrappingController- { wrappedWidget :: Gtk.Widget- , wrappedController :: WWC- }--instance WorkspaceWidgetController WrappingController where- getWidget = lift . Gtk.toWidget . wrappedWidget- updateWidget wc update = do- updated <- updateWidget (wrappedController wc) update- return wc { wrappedController = updated }--data WorkspaceContentsController = WorkspaceContentsController- { containerWidget :: Gtk.Widget- , contentsControllers :: [WWC]- }--buildContentsController :: [ControllerConstructor] -> ControllerConstructor-buildContentsController constructors ws = do- controllers <- mapM ($ ws) constructors- ctx <- ask- tempController <- lift $ do- cons <- Gtk.boxNew Gtk.OrientationHorizontal 0- mapM_ (flip runReaderT ctx . getWidget >=> Gtk.containerAdd cons) controllers- outerBox <- Gtk.toWidget cons >>= buildPadBox- _ <- widgetSetClassGI cons "contents"- widget <- Gtk.toWidget outerBox- return- WorkspaceContentsController- { containerWidget = widget- , contentsControllers = controllers- }- initializeWWC tempController ws--defaultBuildContentsController :: ControllerConstructor-defaultBuildContentsController =- buildContentsController [buildLabelController, buildIconController]--bottomLeftAlignedBoxWrapper :: T.Text -> ControllerConstructor -> ControllerConstructor-bottomLeftAlignedBoxWrapper boxClass constructor ws = do- controller <- constructor ws- widget <- getWidget controller- wrapped <- lift $ buildBottomLeftAlignedBox boxClass widget- let wrappingController = WrappingController- { wrappedWidget = wrapped- , wrappedController = controller- }- initializeWWC wrappingController ws--buildLabelOverlayController :: ControllerConstructor-buildLabelOverlayController ws = do- iconController <- buildIconController ws- labelController <- buildLabelController ws- ctx <- ask- tempController <- lift $ do- iconWidget <- runReaderT (getWidget iconController) ctx- labelWidget <- runReaderT (getWidget labelController) ctx- widget <- buildWorkspaceIconLabelOverlay iconWidget labelWidget- return- WorkspaceContentsController- { containerWidget = widget- , contentsControllers = [iconController, labelController]- }- initializeWWC tempController ws--buildOverlayContentsController ::- [ControllerConstructor] -> [ControllerConstructor] -> ControllerConstructor-buildOverlayContentsController mainConstructors overlayConstructors ws = do- controllers <- mapM ($ ws) mainConstructors- overlayControllers <- mapM ($ ws) overlayConstructors- ctx <- ask- tempController <- lift $ do- mainContents <- Gtk.boxNew Gtk.OrientationHorizontal 0- mapM_ (flip runReaderT ctx . getWidget >=> Gtk.containerAdd mainContents)- controllers- outerBox <- Gtk.toWidget mainContents >>= buildPadBox- _ <- widgetSetClassGI mainContents "contents"- overlayWidgets <- mapM (flip runReaderT ctx . getWidget) overlayControllers- widget <- buildOverlayWithPassThrough outerBox overlayWidgets- return- WorkspaceContentsController- { containerWidget = widget- , contentsControllers = controllers ++ overlayControllers- }- initializeWWC tempController ws--instance WorkspaceWidgetController WorkspaceContentsController where- getWidget = return . containerWidget- updateWidget cc update = do- WorkspacesContext {} <- ask- case update of- WorkspaceUpdate newWorkspace ->- lift $ setWorkspaceWidgetStatusClass (workspaceState newWorkspace) $ containerWidget cc- _ -> return ()- newControllers <- mapM (`updateWidget` update) $ contentsControllers cc- return cc {contentsControllers = newControllers}- updateWidgetX11 cc update = do- newControllers <- mapM (`updateWidgetX11` update) $ contentsControllers cc- return cc {contentsControllers = newControllers}--newtype LabelController = LabelController { label :: Gtk.Label }--buildLabelController :: ControllerConstructor-buildLabelController ws = do- tempController <- lift $ do- lbl <- Gtk.labelNew Nothing- _ <- widgetSetClassGI lbl "workspace-label"- return LabelController { label = lbl }- initializeWWC tempController ws--instance WorkspaceWidgetController LabelController where- getWidget = lift . Gtk.toWidget . label- updateWidget lc (WorkspaceUpdate newWorkspace) = do- WorkspacesContext { workspacesConfig = cfg } <- ask- labelText <- labelSetter cfg newWorkspace- lift $ do- Gtk.labelSetMarkup (label lc) $ T.pack labelText- setWorkspaceWidgetStatusClass (workspaceState newWorkspace) $ label lc- return lc- updateWidget lc _ = return lc--type IconWidget = WindowIconWidget WindowData--buildIconWidget :: Bool -> Workspace -> WorkspacesIO IconWidget-buildIconWidget transparentOnNone ws = do- ctx <- ask- let tContext = taffyContext ctx- cfg = workspacesConfig ctx- getPB size windowData =- runReaderT (getWindowIconPixbuf cfg size windowData) tContext- strategy <- liftContext getScalingImageStrategy- lift $ do- iconWidget <-- mkWorkspaceIconWidget- strategy- Nothing- transparentOnNone- getPB- (`pixBufFromColor` 0)- _ <-- Gtk.onWidgetButtonPressEvent (iconContainer iconWidget) $- const $ liftIO $ do- info <- MV.readMVar (iconWindow iconWidget)- case info of- Just updatedInfo ->- flip runReaderT ctx $- liftX11Def () $ focusWindow $ windowId updatedInfo- _ -> liftIO $ void $ switch ctx (workspaceIdx ws)- return True- return iconWidget--data IconController = IconController- { iconsContainer :: Gtk.Box- , iconImages :: [IconWidget]- , iconWorkspace :: Workspace- }--buildIconController :: ControllerConstructor-buildIconController ws = do- tempController <-- lift $ do- hbox <- Gtk.boxNew Gtk.OrientationHorizontal 0- return- IconController- {iconsContainer = hbox, iconImages = [], iconWorkspace = ws}- initializeWWC tempController ws--instance WorkspaceWidgetController IconController where- getWidget = lift . Gtk.toWidget . iconsContainer- updateWidget ic (WorkspaceUpdate newWorkspace) = do- newImages <- updateImages ic newWorkspace- return ic { iconImages = newImages, iconWorkspace = newWorkspace }- updateWidget ic (IconUpdate updatedIcons) =- updateWindowIconsById ic updatedIcons >> return ic--updateWindowIconsById ::- IconController -> [X11Window] -> WorkspacesIO ()-updateWindowIconsById ic windowIds =- mapM_ maybeUpdateWindowIcon $ iconImages ic- where- maybeUpdateWindowIcon widget =- do- info <- lift $ MV.readMVar $ iconWindow widget- when (maybe False (flip elem windowIds . windowId) info) $- updateIconWidget ic widget info--scaledWindowIconPixbufGetter :: WindowIconPixbufGetter -> WindowIconPixbufGetter-scaledWindowIconPixbufGetter = scaledPixbufGetter--constantScaleWindowIconPixbufGetter ::- Int32 -> WindowIconPixbufGetter -> WindowIconPixbufGetter-constantScaleWindowIconPixbufGetter constantSize getter =- const $ scaledWindowIconPixbufGetter getter constantSize--handleIconGetterException :: WindowIconPixbufGetter -> WindowIconPixbufGetter-handleIconGetterException = handlePixbufGetterException wLog--getWindowIconPixbufFromEWMH :: WindowIconPixbufGetter-getWindowIconPixbufFromEWMH = handleIconGetterException $ \size windowData ->- runX11Def Nothing (getIconPixBufFromEWMH size $ windowId windowData)--getWindowIconPixbufFromClass :: WindowIconPixbufGetter-getWindowIconPixbufFromClass = handleIconGetterException $ \size windowData ->- lift $ getWindowIconFromClasses size (windowClass windowData)--getWindowIconPixbufFromDesktopEntry :: WindowIconPixbufGetter-getWindowIconPixbufFromDesktopEntry = handleIconGetterException $ \size windowData ->- getWindowIconFromDesktopEntryByClasses size (windowClass windowData)--getWindowIconPixbufFromChrome :: WindowIconPixbufGetter-getWindowIconPixbufFromChrome _ windowData =- getPixBufFromChromeData $ windowId windowData--defaultGetWindowIconPixbuf :: WindowIconPixbufGetter-defaultGetWindowIconPixbuf =- scaledWindowIconPixbufGetter unscaledDefaultGetWindowIconPixbuf--unscaledDefaultGetWindowIconPixbuf :: WindowIconPixbufGetter-unscaledDefaultGetWindowIconPixbuf =- getWindowIconPixbufFromDesktopEntry <|||>- getWindowIconPixbufFromClass <|||>- getWindowIconPixbufFromEWMH--addCustomIconsToDefaultWithFallbackByPath- :: (WindowData -> Maybe FilePath)- -> FilePath- -> WindowIconPixbufGetter-addCustomIconsToDefaultWithFallbackByPath getCustomIconPath fallbackPath =- addCustomIconsAndFallback- getCustomIconPath- (const $ lift $ getPixbufFromFilePath fallbackPath)- unscaledDefaultGetWindowIconPixbuf--addCustomIconsAndFallback- :: (WindowData -> Maybe FilePath)- -> (Int32 -> TaffyIO (Maybe Gdk.Pixbuf))- -> WindowIconPixbufGetter- -> WindowIconPixbufGetter-addCustomIconsAndFallback getCustomIconPath fallback defaultGetter =- scaledWindowIconPixbufGetter $- getCustomIcon <|||> defaultGetter <|||> (\s _ -> fallback s)- where- getCustomIcon :: Int32 -> WindowData -> TaffyIO (Maybe Gdk.Pixbuf)- getCustomIcon _ wdata =- lift $- maybe (return Nothing) getPixbufFromFilePath $ getCustomIconPath wdata---- | Sort windows by top-left corner position.-sortWindowsByPosition :: [WindowData] -> WorkspacesIO [WindowData]-sortWindowsByPosition wins = do- let getGeometryWorkspaces w = getDisplay >>= liftIO . (`safeGetGeometry` w)- getGeometries = mapM- (forkM return- (((sel2 &&& sel3) <$>) . getGeometryWorkspaces) .- windowId)- wins- windowGeometries <- liftX11Def [] getGeometries- let getLeftPos wd =- fromMaybe (999999999, 99999999) $ lookup (windowId wd) windowGeometries- compareWindowData a b =- compare- (windowMinimized a, getLeftPos a)- (windowMinimized b, getLeftPos b)- return $ sortBy compareWindowData wins---- | Sort windows in reverse _NET_CLIENT_LIST_STACKING order.--- Starting in xmonad-contrib 0.17.0, this is effectively focus history, active first.--- Previous versions erroneously stored focus-sort-order in _NET_CLIENT_LIST.-sortWindowsByStackIndex :: [WindowData] -> WorkspacesIO [WindowData]-sortWindowsByStackIndex wins = do- stackingWindows <- liftX11Def [] getWindowsStacking- let getStackIdx wd = fromMaybe (-1) $ elemIndex (windowId wd) stackingWindows- compareWindowData a b = compare (getStackIdx b) (getStackIdx a)- return $ sortBy compareWindowData wins--updateImages :: IconController -> Workspace -> WorkspacesIO [IconWidget]-updateImages ic ws = do- WorkspacesContext {workspacesConfig = cfg} <- ask- sortedWindows <- iconSort cfg $ windows ws- wLog DEBUG $ printf "Updating images for %s" (show ws)- let (effectiveMinIcons, _targetLen, paddedWindows) =- computeIconStripLayout (minIcons cfg) (maxIcons cfg) sortedWindows- buildOne i = buildIconWidget (i < effectiveMinIcons) ws- updateOne = updateIconWidget ic- syncWidgetPool (iconsContainer ic) (iconImages ic) paddedWindows buildOne iconContainer updateOne--getWindowStatusString :: WindowData -> T.Text-getWindowStatusString windowData =- windowStatusClassFromFlags- (windowMinimized windowData)- (windowActive windowData)- (windowUrgent windowData)--updateIconWidget- :: IconController- -> IconWidget- -> Maybe WindowData- -> WorkspacesIO ()-updateIconWidget _ iconWidget windowData =- updateWindowIconWidgetState- iconWidget- windowData- (T.pack . windowTitle)- getWindowStatusString--data WorkspaceButtonController = WorkspaceButtonController- { button :: Gtk.EventBox- , buttonWorkspace :: Workspace- , contentsController :: WWC- }--buildButtonController :: ParentControllerConstructor-buildButtonController contentsBuilder workspace = do- cc <- contentsBuilder workspace- workspacesRef <- asks workspacesVar- ctx <- ask- widget <- getWidget cc- lift $ do- ebox <- Gtk.eventBoxNew- Gtk.containerAdd ebox widget- Gtk.eventBoxSetVisibleWindow ebox False- _ <-- Gtk.onWidgetScrollEvent ebox $ \scrollEvent -> do- dir <- Gdk.getEventScrollDirection scrollEvent- workspaces <- liftIO $ MV.readMVar workspacesRef- let switchOne a =- liftIO $- flip runReaderT ctx $- liftX11Def- ()- (switchOneWorkspace a (length (M.toList workspaces) - 1)) >>- return True- case dir of- Gdk.ScrollDirectionUp -> switchOne True- Gdk.ScrollDirectionLeft -> switchOne True- Gdk.ScrollDirectionDown -> switchOne False- Gdk.ScrollDirectionRight -> switchOne False- _ -> return False- _ <- Gtk.onWidgetButtonPressEvent ebox $ const $ switch ctx $ workspaceIdx workspace- return $- WWC- WorkspaceButtonController- { button = ebox, buttonWorkspace = workspace, contentsController = cc }--switch :: (MonadIO m) => WorkspacesContext -> WorkspaceId -> m Bool-switch ctx idx = do- liftIO $ flip runReaderT ctx $ liftX11Def () $ switchToWorkspace idx- return True--instance WorkspaceWidgetController WorkspaceButtonController- where- getWidget wbc = lift $ Gtk.toWidget $ button wbc- updateWidget wbc update = do- newContents <- updateWidget (contentsController wbc) update- return wbc { contentsController = newContents }+{-# LANGUAGE OverloadedStrings #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.Workspaces+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Channel-driven workspace widget that renders backend-agnostic workspace state.+module System.Taffybar.Widget.Workspaces+ ( WorkspacesConfig (..),+ WorkspaceWidgetController (..),+ ControllerConstructor,+ defaultWidgetBuilder,+ WindowIconPixbufGetter,+ defaultWorkspacesConfig,+ defaultEWMHWorkspacesConfig,+ workspacesNew,+ hideEmpty,+ sortWindowsByPosition,+ sortWindowsByStackIndex,+ scaledWindowIconPixbufGetter,+ constantScaleWindowIconPixbufGetter,+ handleIconGetterException,+ getWindowIconPixbufFromClassHints,+ getWindowIconPixbufFromDesktopEntry,+ getWindowIconPixbufFromClass,+ getWindowIconPixbufByClassHints,+ getWindowIconPixbufFromChrome,+ getWindowIconPixbufFromEWMH,+ defaultGetWindowIconPixbuf,+ unscaledDefaultGetWindowIconPixbuf,+ addCustomIconsToDefaultWithFallbackByPath,+ addCustomIconsAndFallback,+ defaultOnWorkspaceClick,+ defaultOnWorkspaceClickEWMH,+ defaultOnWindowClick,+ )+where++import qualified Control.Concurrent.MVar as MV+import Control.Concurrent.STM.TChan (TChan)+import Control.Exception.Enclosed (catchAny)+import Control.Monad (foldM, forM_, guard, when)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Reader (ask, asks, runReaderT)+import Data.Default (Default (..))+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Int (Int32)+import Data.List (elemIndex, sortBy, sortOn)+import qualified Data.Map.Strict as M+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import Data.Word (Word64)+import qualified GI.Gdk.Enums as Gdk+import qualified GI.Gdk.Structs.EventScroll as Gdk+import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk+import qualified GI.Gtk as Gtk+import System.Log.Logger (Priority (..), logM)+import System.Taffybar.Context (Backend (..), TaffyIO, backend, runX11Def)+import System.Taffybar.Hyprland (getHyprlandClient)+import System.Taffybar.Information.EWMHDesktopInfo+ ( WorkspaceId (WorkspaceId),+ ewmhWMIcon,+ focusWindow,+ getWindowsStacking,+ switchToWorkspace,+ )+import qualified System.Taffybar.Information.Hyprland.API as HyprAPI+import System.Taffybar.Information.Workspaces.EWMH+ ( EWMHWorkspaceProviderConfig,+ defaultEWMHWorkspaceProviderConfig,+ getEWMHWorkspaceStateChanAndVarWith,+ workspaceUpdateEvents,+ )+import System.Taffybar.Information.Workspaces.Hyprland+ ( getHyprlandWorkspaceStateChanAndVar,+ )+import System.Taffybar.Information.Workspaces.Model+import System.Taffybar.Util (getPixbufFromFilePath, postGUIASync, (<|||>))+import System.Taffybar.Widget.Generic.ChannelWidget (channelWidgetNew)+import System.Taffybar.Widget.Generic.ScalingImage (getScalingImageStrategy)+import System.Taffybar.Widget.Util+ ( WindowIconWidget (..),+ computeIconStripLayout,+ handlePixbufGetterException,+ scaledPixbufGetter,+ syncWidgetPool,+ updateWindowIconWidgetState,+ widgetSetClassGI,+ windowStatusClassFromFlags,+ )+import System.Taffybar.Widget.Workspaces.Shared+ ( WorkspaceState (..),+ buildWorkspaceIconLabelOverlay,+ mkWorkspaceIconWidget,+ setWorkspaceWidgetStatusClass,+ )+import System.Taffybar.WindowIcon+ ( getCachedIconPixBufFromEWMH,+ getCachedWindowIconFromClasses,+ getCachedWindowIconFromDesktopEntryByClasses,+ getPixBufFromChromeData,+ pixBufFromColor,+ )++type WindowIconPixbufGetter = Int32 -> WindowInfo -> TaffyIO (Maybe Gdk.Pixbuf)++data WorkspaceWidgetController = WorkspaceWidgetController+ { controllerWidget :: Gtk.Widget,+ controllerUpdate :: WorkspaceInfo -> TaffyIO (),+ controllerRefreshIcons :: WorkspaceInfo -> TaffyIO ()+ }++type ControllerConstructor =+ WorkspacesConfig -> WorkspaceInfo -> TaffyIO WorkspaceWidgetController++data WorkspacesConfig = WorkspacesConfig+ { widgetGap :: Int,+ widgetBuilder :: ControllerConstructor,+ iconSize :: Maybe Int32,+ maxIcons :: Maybe Int,+ minIcons :: Int,+ getWindowIconPixbuf :: WindowIconPixbufGetter,+ labelSetter :: WorkspaceInfo -> TaffyIO String,+ showWorkspaceFn :: WorkspaceInfo -> Bool,+ iconSort :: [WindowInfo] -> TaffyIO [WindowInfo],+ urgentWorkspaceState :: Bool,+ onWorkspaceClick :: WorkspaceInfo -> TaffyIO (),+ onWindowClick :: WindowInfo -> TaffyIO ()+ }++data WorkspaceEntry = WorkspaceEntry+ { entryWrapper :: Gtk.Widget,+ entryButton :: Gtk.EventBox,+ entryController :: WorkspaceWidgetController,+ entryWorkspaceRef :: IORef WorkspaceInfo,+ entryLastWorkspace :: WorkspaceInfo+ }++data WorkspaceCache = WorkspaceCache+ { cacheEntries :: M.Map WorkspaceIdentity WorkspaceEntry,+ cacheOrder :: [WorkspaceIdentity],+ cacheLastWorkspaces :: [WorkspaceInfo],+ cacheLastRevision :: Word64+ }++defaultChannelEWMHWorkspaceProviderConfig :: EWMHWorkspaceProviderConfig+defaultChannelEWMHWorkspaceProviderConfig =+ defaultEWMHWorkspaceProviderConfig+ { workspaceUpdateEvents =+ ewmhWMIcon : workspaceUpdateEvents defaultEWMHWorkspaceProviderConfig+ }++defaultEWMHWorkspaceStateSource ::+ TaffyIO (TChan WorkspaceSnapshot, MV.MVar WorkspaceSnapshot)+defaultEWMHWorkspaceStateSource =+ getEWMHWorkspaceStateChanAndVarWith defaultChannelEWMHWorkspaceProviderConfig++autoWorkspaceStateSource :: TaffyIO (TChan WorkspaceSnapshot, MV.MVar WorkspaceSnapshot)+autoWorkspaceStateSource = do+ backendType <- asks backend+ case backendType of+ BackendWayland -> getHyprlandWorkspaceStateChanAndVar+ BackendX11 -> defaultEWMHWorkspaceStateSource++defaultWorkspacesConfig :: WorkspacesConfig+defaultWorkspacesConfig =+ WorkspacesConfig+ { widgetGap = 0,+ widgetBuilder = defaultWidgetBuilder,+ iconSize = Just 16,+ maxIcons = Nothing,+ minIcons = 0,+ getWindowIconPixbuf = defaultGetWindowIconPixbuf,+ labelSetter = return . T.unpack . workspaceName . workspaceIdentity,+ showWorkspaceFn = \ws -> hideEmpty ws && not (workspaceIsSpecial ws),+ iconSort = pure . sortWindowsByPosition,+ urgentWorkspaceState = False,+ onWorkspaceClick = defaultOnWorkspaceClick,+ onWindowClick = defaultOnWindowClick+ }++defaultEWMHWorkspacesConfig :: WorkspacesConfig+defaultEWMHWorkspacesConfig = defaultWorkspacesConfig++defaultWidgetBuilder :: ControllerConstructor+defaultWidgetBuilder cfg wsInfo = do+ wsRef <- liftIO $ newIORef wsInfo+ lastWorkspaceRef <- liftIO $ newIORef wsInfo+ iconsRef <- liftIO $ newIORef []+ iconsBox <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal 0+ label <- liftIO $ Gtk.labelNew Nothing+ _ <- widgetSetClassGI label "workspace-label"+ iconsWidget <- Gtk.toWidget iconsBox+ labelWidget <- Gtk.toWidget label+ contents <- buildWorkspaceIconLabelOverlay iconsWidget labelWidget+ let updateController forceIcons newWs = do+ oldWs <- liftIO $ readIORef lastWorkspaceRef+ liftIO $ writeIORef wsRef newWs+ labelText <- labelSetter cfg newWs+ let wsState = toCSSState cfg newWs+ liftIO $ Gtk.labelSetMarkup label (T.pack labelText)+ liftIO $ setWorkspaceWidgetStatusClass wsState contents+ liftIO $ setWorkspaceWidgetStatusClass wsState label+ currentIcons <- liftIO $ readIORef iconsRef+ let needsIconUpdate =+ forceIcons+ || null currentIcons+ || workspaceWindows oldWs /= workspaceWindows newWs+ updatedIcons <-+ if needsIconUpdate+ then updateWorkspaceIcons cfg wsRef iconsBox currentIcons newWs+ else return currentIcons+ liftIO $ writeIORef iconsRef updatedIcons+ liftIO $ writeIORef lastWorkspaceRef newWs+ controller =+ WorkspaceWidgetController+ { controllerWidget = contents,+ controllerUpdate = updateController False,+ controllerRefreshIcons = updateController True+ }+ controllerRefreshIcons controller wsInfo+ return controller++instance Default WorkspacesConfig where+ def = defaultWorkspacesConfig++hideEmpty :: WorkspaceInfo -> Bool+hideEmpty WorkspaceInfo {workspaceState = WorkspaceEmpty} = False+hideEmpty _ = True++sortWindowsByPosition :: [WindowInfo] -> [WindowInfo]+sortWindowsByPosition =+ sortOn $ \w ->+ ( windowMinimized w,+ fromMaybe (999999999, 999999999) (windowPosition w)+ )++sortWindowsByStackIndex :: [WindowInfo] -> TaffyIO [WindowInfo]+sortWindowsByStackIndex wins = do+ stackingWindows <- runX11Def [] getWindowsStacking+ let getStackIdx windowInfo =+ case windowIdentity windowInfo of+ X11WindowIdentity wid -> fromMaybe (-1) $ elemIndex (fromIntegral wid) stackingWindows+ HyprlandWindowIdentity _ -> -1+ compareWindowData a b = compare (getStackIdx b) (getStackIdx a)+ return $ sortBy compareWindowData wins++scaledWindowIconPixbufGetter :: WindowIconPixbufGetter -> WindowIconPixbufGetter+scaledWindowIconPixbufGetter = scaledPixbufGetter++constantScaleWindowIconPixbufGetter ::+ Int32 -> WindowIconPixbufGetter -> WindowIconPixbufGetter+constantScaleWindowIconPixbufGetter constantSize getter =+ const $ scaledWindowIconPixbufGetter getter constantSize++handleIconGetterException :: WindowIconPixbufGetter -> WindowIconPixbufGetter+handleIconGetterException = handlePixbufGetterException wLog++getWindowIconPixbufFromClassHints :: WindowIconPixbufGetter+getWindowIconPixbufFromClassHints =+ getWindowIconPixbufFromDesktopEntry <|||> getWindowIconPixbufFromClass++getWindowIconPixbufFromDesktopEntry :: WindowIconPixbufGetter+getWindowIconPixbufFromDesktopEntry = handleIconGetterException $ \size winInfo ->+ tryHints size (map T.unpack (windowClassHints winInfo))+ where+ tryHints _ [] = return Nothing+ tryHints requestedSize (klass : rest) = do+ fromDesktopEntry <- getCachedWindowIconFromDesktopEntryByClasses requestedSize klass+ case fromDesktopEntry of+ Just _ -> return fromDesktopEntry+ Nothing -> tryHints requestedSize rest++getWindowIconPixbufFromClass :: WindowIconPixbufGetter+getWindowIconPixbufFromClass = handleIconGetterException $ \size winInfo ->+ tryHints size (map T.unpack (windowClassHints winInfo))+ where+ tryHints _ [] = return Nothing+ tryHints requestedSize (klass : rest) = do+ fromClass <- getCachedWindowIconFromClasses requestedSize klass+ case fromClass of+ Just _ -> return fromClass+ Nothing -> tryHints requestedSize rest++getWindowIconPixbufByClassHints :: Int32 -> WindowInfo -> TaffyIO (Maybe Gdk.Pixbuf)+getWindowIconPixbufByClassHints = getWindowIconPixbufFromClassHints++getWindowIconPixbufFromChrome :: WindowIconPixbufGetter+getWindowIconPixbufFromChrome _ windowData =+ case windowIdentity windowData of+ X11WindowIdentity wid -> getPixBufFromChromeData (fromIntegral wid)+ HyprlandWindowIdentity _ -> return Nothing++getWindowIconPixbufFromEWMH :: WindowIconPixbufGetter+getWindowIconPixbufFromEWMH = handleIconGetterException $ \size windowData ->+ case windowIdentity windowData of+ X11WindowIdentity wid -> getCachedIconPixBufFromEWMH size (fromIntegral wid)+ HyprlandWindowIdentity _ -> return Nothing++defaultGetWindowIconPixbuf :: WindowIconPixbufGetter+defaultGetWindowIconPixbuf =+ scaledWindowIconPixbufGetter unscaledDefaultGetWindowIconPixbuf++unscaledDefaultGetWindowIconPixbuf :: WindowIconPixbufGetter+unscaledDefaultGetWindowIconPixbuf =+ getWindowIconPixbufFromDesktopEntry+ <|||> getWindowIconPixbufFromClass+ <|||> getWindowIconPixbufFromEWMH++addCustomIconsToDefaultWithFallbackByPath ::+ (WindowInfo -> Maybe FilePath) ->+ FilePath ->+ WindowIconPixbufGetter+addCustomIconsToDefaultWithFallbackByPath getCustomIconPath fallbackPath =+ addCustomIconsAndFallback+ getCustomIconPath+ (const $ liftIO $ getPixbufFromFilePath fallbackPath)+ unscaledDefaultGetWindowIconPixbuf++addCustomIconsAndFallback ::+ (WindowInfo -> Maybe FilePath) ->+ (Int32 -> TaffyIO (Maybe Gdk.Pixbuf)) ->+ WindowIconPixbufGetter ->+ WindowIconPixbufGetter+addCustomIconsAndFallback getCustomIconPath fallback defaultGetter =+ scaledWindowIconPixbufGetter $+ getCustomIcon <|||> defaultGetter <|||> (\s _ -> fallback s)+ where+ getCustomIcon :: WindowIconPixbufGetter+ getCustomIcon _ windowInfo =+ maybe (return Nothing) (liftIO . getPixbufFromFilePath) $+ getCustomIconPath windowInfo++defaultOnWorkspaceClick :: WorkspaceInfo -> TaffyIO ()+defaultOnWorkspaceClick wsInfo = do+ backendType <- asks backend+ case backendType of+ BackendX11 -> defaultOnWorkspaceClickEWMH wsInfo+ BackendWayland -> defaultOnWorkspaceClickHyprland wsInfo++defaultOnWorkspaceClickHyprland :: WorkspaceInfo -> TaffyIO ()+defaultOnWorkspaceClickHyprland wsInfo = do+ client <- getHyprlandClient+ let targetText = workspaceName (workspaceIdentity wsInfo)+ case HyprAPI.mkHyprlandWorkspaceTarget targetText of+ Left err ->+ wLog WARNING $+ "Failed to build Hyprland workspace target for " <> show targetText <> ": " <> show err+ Right target -> do+ result <- liftIO $ HyprAPI.dispatchHyprland client (HyprAPI.DispatchWorkspace target)+ case result of+ Left err ->+ wLog WARNING $+ "Failed to switch workspace via Hyprland dispatch: " <> show err+ Right _ -> return ()++defaultOnWorkspaceClickEWMH :: WorkspaceInfo -> TaffyIO ()+defaultOnWorkspaceClickEWMH wsInfo =+ case workspaceNumericId (workspaceIdentity wsInfo) of+ Nothing ->+ wLog WARNING $+ "Workspace has no numeric id for EWMH switch: " <> show (workspaceIdentity wsInfo)+ Just workspaceId ->+ runX11Def () (switchToWorkspace (WorkspaceId workspaceId))+ `catchAny` \err ->+ wLog WARNING $+ "Failed to switch EWMH workspace " <> show workspaceId <> ": " <> show err++defaultOnWindowClick :: WindowInfo -> TaffyIO ()+defaultOnWindowClick windowInfo =+ case windowIdentity windowInfo of+ X11WindowIdentity wid ->+ runX11Def () (focusWindow (fromIntegral wid))+ `catchAny` \err ->+ wLog WARNING $+ "Failed to focus X11 window " <> show wid <> ": " <> show err+ HyprlandWindowIdentity address -> do+ client <- getHyprlandClient+ case HyprAPI.mkHyprlandAddress address of+ Left err ->+ wLog WARNING $+ "Failed to build Hyprland window address " <> show address <> ": " <> show err+ Right addr -> do+ result <-+ liftIO $+ HyprAPI.dispatchHyprland client (HyprAPI.DispatchFocusWindowAddress addr)+ case result of+ Left err ->+ wLog WARNING $+ "Failed to focus Hyprland window " <> show address <> ": " <> show err+ Right _ -> return ()++workspacesNew :: WorkspacesConfig -> TaffyIO Gtk.Widget+workspacesNew cfg = do+ ctx <- ask+ cont <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal (fromIntegral $ widgetGap cfg)+ _ <- widgetSetClassGI cont "workspaces"+ cacheVar <- liftIO $ MV.newMVar (WorkspaceCache M.empty [] [] 0)+ (chan, snapshotVar) <- autoWorkspaceStateSource+ initialSnapshot <- liftIO $ MV.readMVar snapshotVar+ renderWorkspaces cfg cont cacheVar initialSnapshot+ _ <-+ liftIO $+ Gtk.onWidgetRealize cont $ do+ latestSnapshot <- MV.readMVar snapshotVar+ postGUIASync $+ runReaderT+ (renderWorkspaces cfg cont cacheVar latestSnapshot)+ ctx+ _ <-+ liftIO $+ channelWidgetNew+ cont+ chan+ (\snapshot -> postGUIASync $ runReaderT (renderWorkspaces cfg cont cacheVar snapshot) ctx)+ Gtk.toWidget cont++renderWorkspaces ::+ WorkspacesConfig ->+ Gtk.Box ->+ MV.MVar WorkspaceCache ->+ WorkspaceSnapshot ->+ TaffyIO ()+renderWorkspaces cfg cont cacheVar snapshot = do+ ctx <- ask+ liftIO $+ MV.modifyMVar_ cacheVar $ \oldCache ->+ runReaderT (updateCache cfg cont cacheVar snapshot oldCache) ctx++updateCache ::+ WorkspacesConfig ->+ Gtk.Box ->+ MV.MVar WorkspaceCache ->+ WorkspaceSnapshot ->+ WorkspaceCache ->+ TaffyIO WorkspaceCache+updateCache cfg cont cacheVar snapshot oldCache = do+ let workspaces = snapshotWorkspaces snapshot+ forceIconRefresh =+ snapshotBackend snapshot == WorkspaceBackendEWMH+ && snapshotRevision snapshot /= cacheLastRevision oldCache+ if workspaces == cacheLastWorkspaces oldCache && not forceIconRefresh+ then return oldCache+ else do+ let oldEntries = cacheEntries oldCache+ buildOrUpdate (cacheAcc, entriesAcc) wsInfo = do+ let wsKey = workspaceIdentity wsInfo+ entry <-+ case M.lookup wsKey oldEntries of+ Just existing+ | entryLastWorkspace existing == wsInfo ->+ if forceIconRefresh+ then refreshWorkspaceEntryIcons existing wsInfo+ else return existing+ | otherwise -> updateWorkspaceEntry existing wsInfo+ Nothing -> buildWorkspaceEntry cfg cacheVar wsInfo+ return (M.insert wsKey entry cacheAcc, (wsInfo, entry) : entriesAcc)+ (newEntries, orderedEntriesRev) <-+ foldM buildOrUpdate (M.empty, []) workspaces+ let orderedEntries = reverse orderedEntriesRev+ removed = M.difference oldEntries newEntries+ added = M.difference newEntries oldEntries+ forM_ (M.elems removed) $+ liftIO . Gtk.containerRemove cont . entryWrapper+ forM_ (M.elems added) $ \entry -> do+ liftIO $ Gtk.containerAdd cont (entryWrapper entry)+ liftIO $ Gtk.widgetShowAll (entryWrapper entry)+ let desiredOrder = map (workspaceIdentity . fst) orderedEntries+ needsReorder =+ desiredOrder /= cacheOrder oldCache+ || not (M.null added)+ || not (M.null removed)+ when needsReorder $+ forM_ (zip [0 :: Int ..] orderedEntries) $ \(position, (_wsInfo, entry)) ->+ liftIO $ Gtk.boxReorderChild cont (entryWrapper entry) (fromIntegral position)+ forM_ orderedEntries $ \(wsInfo, entry) ->+ if showWorkspaceFn cfg wsInfo+ then liftIO $ Gtk.widgetShow (entryButton entry)+ else liftIO $ Gtk.widgetHide (entryButton entry)+ return+ WorkspaceCache+ { cacheEntries = newEntries,+ cacheOrder = desiredOrder,+ cacheLastWorkspaces = workspaces,+ cacheLastRevision = snapshotRevision snapshot+ }++buildWorkspaceEntry ::+ WorkspacesConfig ->+ MV.MVar WorkspaceCache ->+ WorkspaceInfo ->+ TaffyIO WorkspaceEntry+buildWorkspaceEntry cfg cacheVar wsInfo = do+ wsRef <- liftIO $ newIORef wsInfo+ controller <- widgetBuilder cfg cfg wsInfo+ button <- liftIO Gtk.eventBoxNew+ _ <- widgetSetClassGI button "workspace-button"+ liftIO $ Gtk.eventBoxSetVisibleWindow button False+ liftIO $ Gtk.containerAdd button (controllerWidget controller)+ wrapperBox <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal 0+ liftIO $ Gtk.containerAdd wrapperBox button+ wrapper <- Gtk.toWidget wrapperBox+ _ <- widgetSetClassGI wrapper "workspace-wrapper"+ ctx <- ask+ _ <-+ liftIO $+ Gtk.onWidgetButtonPressEvent button $+ const $ do+ currentWs <- readIORef wsRef+ runReaderT (onWorkspaceClick cfg currentWs) ctx+ return True+ _ <-+ liftIO $+ Gtk.onWidgetScrollEvent button $ \scrollEvent -> do+ direction <- Gdk.getEventScrollDirection scrollEvent+ let scrollPrevious = runReaderT (switchWorkspaceRelative cfg cacheVar wsRef True) ctx+ scrollNext = runReaderT (switchWorkspaceRelative cfg cacheVar wsRef False) ctx+ case direction of+ Gdk.ScrollDirectionUp -> scrollPrevious+ Gdk.ScrollDirectionLeft -> scrollPrevious+ Gdk.ScrollDirectionDown -> scrollNext+ Gdk.ScrollDirectionRight -> scrollNext+ _ -> return False+ return+ WorkspaceEntry+ { entryWrapper = wrapper,+ entryButton = button,+ entryController = controller,+ entryWorkspaceRef = wsRef,+ entryLastWorkspace = wsInfo+ }++updateWorkspaceEntry ::+ WorkspaceEntry ->+ WorkspaceInfo ->+ TaffyIO WorkspaceEntry+updateWorkspaceEntry entry wsInfo = do+ liftIO $ writeIORef (entryWorkspaceRef entry) wsInfo+ controllerUpdate (entryController entry) wsInfo+ return entry {entryLastWorkspace = wsInfo}++refreshWorkspaceEntryIcons ::+ WorkspaceEntry ->+ WorkspaceInfo ->+ TaffyIO WorkspaceEntry+refreshWorkspaceEntryIcons entry wsInfo = do+ liftIO $ writeIORef (entryWorkspaceRef entry) wsInfo+ controllerRefreshIcons (entryController entry) wsInfo+ return entry {entryLastWorkspace = wsInfo}++switchWorkspaceRelative ::+ WorkspacesConfig ->+ MV.MVar WorkspaceCache ->+ IORef WorkspaceInfo ->+ Bool ->+ TaffyIO Bool+switchWorkspaceRelative cfg cacheVar wsRef moveBackward = do+ currentWs <- liftIO $ readIORef wsRef+ cache <- liftIO $ MV.readMVar cacheVar+ let identities = cacheOrder cache+ currentIdentity = workspaceIdentity currentWs+ step = if moveBackward then (-1) else 1+ getTargetIdentity = do+ idx <- elemIndex currentIdentity identities+ let total = length identities+ guard (total > 0)+ let wrappedIndex = (idx + step + total) `mod` total+ return (identities !! wrappedIndex)+ case getTargetIdentity >>= (`M.lookup` cacheEntries cache) of+ Nothing -> return False+ Just targetEntry -> do+ targetWorkspace <- liftIO $ readIORef (entryWorkspaceRef targetEntry)+ onWorkspaceClick cfg targetWorkspace+ return True++updateWorkspaceIcons ::+ WorkspacesConfig ->+ IORef WorkspaceInfo ->+ Gtk.Box ->+ [WindowIconWidget WindowInfo] ->+ WorkspaceInfo ->+ TaffyIO [WindowIconWidget WindowInfo]+updateWorkspaceIcons cfg workspaceRef iconsBox iconWidgets wsInfo = do+ sortedWindows <- iconSort cfg (workspaceWindows wsInfo)+ let (effectiveMinIcons, _targetLen, paddedWindows) =+ computeIconStripLayout+ (minIcons cfg)+ (maxIcons cfg)+ sortedWindows+ buildOne i = buildIconWidget cfg workspaceRef (i < effectiveMinIcons)+ updateOne = updateIconWidget+ syncWidgetPool iconsBox iconWidgets paddedWindows buildOne iconContainer updateOne++buildIconWidget ::+ WorkspacesConfig ->+ IORef WorkspaceInfo ->+ Bool ->+ TaffyIO (WindowIconWidget WindowInfo)+buildIconWidget cfg workspaceRef transparentOnNone = do+ ctx <- ask+ strategy <- getScalingImageStrategy+ let getPB size windowInfo = runReaderT (getWindowIconPixbuf cfg size windowInfo) ctx+ iconWidget <-+ liftIO $+ mkWorkspaceIconWidget+ strategy+ (iconSize cfg)+ transparentOnNone+ getPB+ (`pixBufFromColor` 0)+ _ <-+ liftIO $+ Gtk.onWidgetButtonPressEvent (iconContainer iconWidget) $+ const $ do+ maybeWindow <- MV.readMVar (iconWindow iconWidget)+ case maybeWindow of+ Just windowInfo -> runReaderT (onWindowClick cfg windowInfo) ctx+ Nothing -> do+ currentWs <- readIORef workspaceRef+ runReaderT (onWorkspaceClick cfg currentWs) ctx+ return True+ return iconWidget++updateIconWidget :: WindowIconWidget WindowInfo -> Maybe WindowInfo -> TaffyIO ()+updateIconWidget iconWidget windowData =+ updateWindowIconWidgetState+ iconWidget+ windowData+ windowTitle+ getWindowStatusString++getWindowStatusString :: WindowInfo -> T.Text+getWindowStatusString windowInfo =+ windowStatusClassFromFlags+ (windowMinimized windowInfo)+ (windowActive windowInfo)+ (windowUrgent windowInfo)++toCSSState :: WorkspacesConfig -> WorkspaceInfo -> WorkspaceState+toCSSState cfg wsInfo+ | urgentWorkspaceState cfg+ && workspaceState wsInfo == WorkspaceHidden+ && workspaceHasUrgentWindow wsInfo =+ Urgent+ | otherwise =+ case workspaceState wsInfo of+ WorkspaceActive -> Active+ WorkspaceVisible -> Visible+ WorkspaceHidden -> Hidden+ WorkspaceEmpty -> Empty++wLog :: Priority -> String -> TaffyIO ()+wLog level message =+ liftIO $ logM "System.Taffybar.Widget.Workspaces" level message
+ src/System/Taffybar/Widget/Workspaces/Channel.hs view
@@ -0,0 +1,21 @@+{-# OPTIONS_GHC -Wno-deprecations #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.Workspaces.Channel+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+module System.Taffybar.Widget.Workspaces.Channel+ {-# DEPRECATED "Use System.Taffybar.Widget.Workspaces instead." #-}+ ( module System.Taffybar.Widget.Workspaces,+ )+where++import System.Taffybar.Widget.Workspaces
+ src/System/Taffybar/Widget/Workspaces/Common.hs view
@@ -0,0 +1,19 @@+-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.Workspaces.Common+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+module System.Taffybar.Widget.Workspaces.Common+ {-# DEPRECATED "Use System.Taffybar.Widget.Workspaces.Config" #-}+ ( module System.Taffybar.Widget.Workspaces.Config,+ )+where++import System.Taffybar.Widget.Workspaces.Config
+ src/System/Taffybar/Widget/Workspaces/Config.hs view
@@ -0,0 +1,41 @@+{-# LANGUAGE RankNTypes #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.Workspaces.Config+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Shared configuration shape used by both workspace widgets.+module System.Taffybar.Widget.Workspaces.Config+ ( WorkspaceWidgetCommonConfig (..),+ )+where++import Data.Int (Int32)+import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk+import System.Taffybar.Context (TaffyIO)++-- | Common, backend-agnostic workspace-widget configuration.+--+-- The @m@ type parameter captures the backend-specific context monad used for+-- widget/controller updates.+data WorkspaceWidgetCommonConfig m workspace window controller+ = WorkspaceWidgetCommonConfig+ { widgetBuilder :: workspace -> m controller,+ widgetGap :: Int,+ maxIcons :: Maybe Int,+ minIcons :: Int,+ getWindowIconPixbuf :: Int32 -> window -> TaffyIO (Maybe Gdk.Pixbuf),+ labelSetter :: workspace -> m String,+ showWorkspaceFn :: workspace -> Bool,+ iconSort :: [window] -> m [window],+ urgentWorkspaceState :: Bool+ }
+ src/System/Taffybar/Widget/Workspaces/EWMH.hs view
@@ -0,0 +1,884 @@+{-# LANGUAGE ExistentialQuantification #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE StrictData #-}+{-# OPTIONS_GHC -fno-warn-type-defaults #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.Workspaces.EWMH+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+module System.Taffybar.Widget.Workspaces.EWMH+ ( module System.Taffybar.Widget.Workspaces.EWMH,+ module System.Taffybar.Widget.Workspaces.Shared,+ module System.Taffybar.Widget.Workspaces.Config,+ )+where++import Control.Arrow ((&&&))+import Control.Concurrent+import qualified Control.Concurrent.MVar as MV+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Class+import Control.Monad.Trans.Reader+import Control.RateLimit+import Data.Char (toLower)+import Data.Default (Default (..))+import qualified Data.Foldable as F+import Data.GI.Base.ManagedPtr (unsafeCastTo)+import Data.Int+import Data.List (elemIndex, intersect, isPrefixOf, sortBy, (\\))+import qualified Data.Map as M+import Data.Maybe+import qualified Data.MultiMap as MM+import qualified Data.Set as Set+import qualified Data.Text as T+import Data.Time.Units+import Data.Tuple.Select+import Data.Tuple.Sequence+import qualified GI.Gdk.Enums as Gdk+import qualified GI.Gdk.Structs.EventScroll as Gdk+import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk+import qualified GI.Gtk as Gtk+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Information.EWMHDesktopInfo+import System.Taffybar.Information.SafeX11+import System.Taffybar.Information.X11DesktopInfo+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.ScalingImage (getScalingImageStrategy)+import System.Taffybar.Widget.Util+import System.Taffybar.Widget.Workspaces.Config+ ( WorkspaceWidgetCommonConfig (WorkspaceWidgetCommonConfig),+ )+import qualified System.Taffybar.Widget.Workspaces.Config as WorkspaceWidgetConfig+import System.Taffybar.Widget.Workspaces.Shared+ ( WorkspaceState (..),+ buildWorkspaceIconLabelOverlay,+ cssWorkspaceStates,+ getCSSClass,+ mkWorkspaceIconWidget,+ setWorkspaceWidgetStatusClass,+ )+import System.Taffybar.WindowIcon+import Text.Printf++data WindowData = WindowData+ { windowId :: X11Window,+ windowTitle :: String,+ windowClass :: String,+ windowUrgent :: Bool,+ windowActive :: Bool,+ windowMinimized :: Bool+ }+ deriving (Show, Eq)++data WidgetUpdate = WorkspaceUpdate Workspace | IconUpdate [X11Window]++data Workspace = Workspace+ { workspaceIdx :: WorkspaceId,+ workspaceName :: String,+ workspaceState :: WorkspaceState,+ windows :: [WindowData]+ }+ deriving (Show, Eq)++data WorkspacesContext = WorkspacesContext+ { controllersVar :: MV.MVar (M.Map WorkspaceId WWC),+ workspacesVar :: MV.MVar (M.Map WorkspaceId Workspace),+ workspacesWidget :: Gtk.Box,+ ewmhConfig :: WorkspacesConfig,+ taffyContext :: Context+ }++type WorkspacesIO a = ReaderT WorkspacesContext IO a++liftContext :: TaffyIO a -> WorkspacesIO a+liftContext action = asks taffyContext >>= lift . runReaderT action++liftX11Def :: a -> X11Property a -> WorkspacesIO a+liftX11Def dflt prop = liftContext $ runX11Def dflt prop++class WorkspaceWidgetController wc where+ getWidget :: wc -> WorkspacesIO Gtk.Widget+ updateWidget :: wc -> WidgetUpdate -> WorkspacesIO wc+ updateWidgetX11 :: wc -> WidgetUpdate -> WorkspacesIO wc+ updateWidgetX11 cont _ = return cont++data WWC = forall a. (WorkspaceWidgetController a) => WWC a++instance WorkspaceWidgetController WWC where+ getWidget (WWC wc) = getWidget wc+ updateWidget (WWC wc) update = WWC <$> updateWidget wc update+ updateWidgetX11 (WWC wc) update = WWC <$> updateWidgetX11 wc update++type ControllerConstructor = Workspace -> WorkspacesIO WWC++type ParentControllerConstructor =+ ControllerConstructor -> ControllerConstructor++type WindowIconPixbufGetter =+ Int32 -> WindowData -> TaffyIO (Maybe Gdk.Pixbuf)++data WorkspacesConfig+ = WorkspacesConfig+ { workspacesConfig ::+ WorkspaceWidgetCommonConfig+ (ReaderT WorkspacesContext IO)+ Workspace+ WindowData+ WWC,+ borderWidth :: Int,+ updateEvents :: [String],+ updateRateLimitMicroseconds :: Integer+ }++workspacesCommonConfig ::+ WorkspacesConfig ->+ WorkspaceWidgetCommonConfig (ReaderT WorkspacesContext IO) Workspace WindowData WWC+workspacesCommonConfig = workspacesConfig++-- | Modify the nested 'WorkspaceWidgetCommonConfig' inside a 'WorkspacesConfig'.+--+-- Prefer this helper when overriding multiple fields to avoid accidentally+-- introducing a self-recursive config definition that can hang at runtime.+modifyCommonWorkspacesConfig ::+ ( WorkspaceWidgetCommonConfig (ReaderT WorkspacesContext IO) Workspace WindowData WWC ->+ WorkspaceWidgetCommonConfig (ReaderT WorkspacesContext IO) Workspace WindowData WWC+ ) ->+ WorkspacesConfig ->+ WorkspacesConfig+modifyCommonWorkspacesConfig f cfg =+ cfg {workspacesConfig = f (workspacesConfig cfg)}++-- | Replace the nested common config inside 'WorkspacesConfig'.+--+-- If you're just tweaking a few fields, prefer 'modifyCommonWorkspacesConfig'+-- to avoid accidentally creating a self-recursive definition that can hang at+-- runtime.+applyCommonWorkspacesConfig ::+ WorkspaceWidgetCommonConfig (ReaderT WorkspacesContext IO) Workspace WindowData WWC ->+ WorkspacesConfig ->+ WorkspacesConfig+applyCommonWorkspacesConfig common cfg =+ cfg {workspacesConfig = common}++defaultWorkspacesConfig :: WorkspacesConfig+defaultWorkspacesConfig =+ WorkspacesConfig+ { workspacesConfig =+ WorkspaceWidgetCommonConfig+ { WorkspaceWidgetConfig.widgetBuilder =+ buildButtonController defaultBuildContentsController,+ WorkspaceWidgetConfig.widgetGap = 0,+ WorkspaceWidgetConfig.maxIcons = Nothing,+ WorkspaceWidgetConfig.minIcons = 0,+ WorkspaceWidgetConfig.getWindowIconPixbuf = defaultGetWindowIconPixbuf,+ WorkspaceWidgetConfig.labelSetter = return . workspaceName,+ WorkspaceWidgetConfig.showWorkspaceFn = \ws -> hideEmpty ws && hideSpecial ws,+ WorkspaceWidgetConfig.iconSort = sortWindowsByPosition,+ WorkspaceWidgetConfig.urgentWorkspaceState = False+ },+ borderWidth = 2,+ updateEvents = allEWMHProperties \\ [ewmhWMIcon],+ updateRateLimitMicroseconds = 100000+ }++instance Default WorkspacesConfig where+ def = defaultWorkspacesConfig++hideEmpty :: Workspace -> Bool+hideEmpty Workspace {workspaceState = Empty} = False+hideEmpty _ = True++hideSpecial :: Workspace -> Bool+hideSpecial Workspace {workspaceName = name} =+ let lowered = map toLower name+ in lowered /= "nsp" && not ("special:" `isPrefixOf` lowered)++wLog :: (MonadIO m) => Priority -> String -> m ()+wLog l s = liftIO $ logM "System.Taffybar.Widget.Workspaces" l s++updateVar :: MV.MVar a -> (a -> WorkspacesIO a) -> WorkspacesIO a+updateVar var modify = do+ ctx <- ask+ lift $ MV.modifyMVar var $ fmap (\a -> (a, a)) . flip runReaderT ctx . modify++updateWorkspacesVar :: WorkspacesIO (M.Map WorkspaceId Workspace)+updateWorkspacesVar = do+ workspacesRef <- asks workspacesVar+ updateVar workspacesRef buildWorkspaceData++getWorkspaceToWindows ::+ [X11Window] -> X11Property (MM.MultiMap WorkspaceId X11Window)+getWorkspaceToWindows =+ foldM+ ( \theMap window ->+ MM.insert <$> getWorkspace window <*> pure window <*> pure theMap+ )+ MM.empty++getWindowData ::+ Maybe X11Window ->+ [X11Window] ->+ X11Window ->+ X11Property WindowData+getWindowData activeWindow urgentWindows window = do+ wTitle <- getWindowTitle window+ wClass <- getWindowClass window+ wMinimized <- getWindowMinimized window+ return+ WindowData+ { windowId = window,+ windowTitle = wTitle,+ windowClass = wClass,+ windowUrgent = window `elem` urgentWindows,+ windowActive = Just window == activeWindow,+ windowMinimized = wMinimized+ }++buildWorkspaceData ::+ M.Map WorkspaceId Workspace ->+ WorkspacesIO (M.Map WorkspaceId Workspace)+buildWorkspaceData _ =+ ask >>= \context -> liftX11Def M.empty $ do+ names <- getWorkspaceNames+ wins <- getWindows+ workspaceToWindows <- getWorkspaceToWindows wins+ urgentWindows <- filterM isWindowUrgent wins+ activeWindow <- getActiveWindow+ active : visible <- getVisibleWorkspaces+ let common = workspacesCommonConfig (ewmhConfig context)+ getWorkspaceState idx ws+ | idx == active = Active+ | idx `elem` visible = Visible+ | WorkspaceWidgetConfig.urgentWorkspaceState common+ && not (null (ws `intersect` urgentWindows)) =+ Urgent+ | null ws = Empty+ | otherwise = Hidden+ foldM+ ( \theMap (idx, name) -> do+ let ws = MM.lookup idx workspaceToWindows+ windowInfos <- mapM (getWindowData activeWindow urgentWindows) ws+ return $+ M.insert+ idx+ Workspace+ { workspaceIdx = idx,+ workspaceName = name,+ workspaceState = getWorkspaceState idx ws,+ windows = windowInfos+ }+ theMap+ )+ M.empty+ names++addWidgetsToTopLevel :: WorkspacesIO ()+addWidgetsToTopLevel = do+ WorkspacesContext+ { controllersVar = controllersRef,+ workspacesWidget = cont+ } <-+ ask+ controllersMap <- lift $ MV.readMVar controllersRef+ -- Elems returns elements in ascending order of their keys so this will always+ -- add the widgets in the correct order+ mapM_ addWidget $ M.elems controllersMap+ lift $ Gtk.widgetShowAll cont++addWidget :: WWC -> WorkspacesIO ()+addWidget controller = do+ cont <- asks workspacesWidget+ workspaceWidget <- getWidget controller+ lift $ do+ -- XXX: This hbox exists to (hopefully) prevent the issue where workspace+ -- widgets appear out of order, in the switcher, by acting as an empty+ -- place holder when the actual widget is hidden.+ hbox <- Gtk.boxNew Gtk.OrientationHorizontal 0+ void $+ Gtk.widgetGetParent workspaceWidget+ >>= traverse (unsafeCastTo Gtk.Box)+ >>= traverse (`Gtk.containerRemove` workspaceWidget)+ Gtk.containerAdd hbox workspaceWidget+ Gtk.containerAdd cont hbox++workspacesNew :: WorkspacesConfig -> TaffyIO Gtk.Widget+workspacesNew cfg =+ ask >>= \tContext -> lift $ do+ let common = workspacesCommonConfig cfg+ cont <- Gtk.boxNew Gtk.OrientationHorizontal $ fromIntegral (WorkspaceWidgetConfig.widgetGap common)+ controllersRef <- MV.newMVar M.empty+ workspacesRef <- MV.newMVar M.empty+ let context =+ WorkspacesContext+ { controllersVar = controllersRef,+ workspacesVar = workspacesRef,+ workspacesWidget = cont,+ ewmhConfig = cfg,+ taffyContext = tContext+ }+ -- This will actually create all the widgets+ runReaderT updateAllWorkspaceWidgets context+ updateHandler <- onWorkspaceUpdate context+ iconHandler <- onIconsChanged context+ let doUpdate = lift . updateHandler+ handleConfigureEvents e@(ConfigureEvent {}) = doUpdate e+ handleConfigureEvents _ = return ()+ (workspaceSubscription, iconSubscription, geometrySubscription) <-+ flip runReaderT tContext $+ sequenceT+ ( subscribeToPropertyEvents (updateEvents cfg) doUpdate,+ subscribeToPropertyEvents [ewmhWMIcon] (lift . onIconChanged iconHandler),+ subscribeToAll handleConfigureEvents+ )+ let doUnsubscribe =+ flip runReaderT tContext $+ mapM_+ unsubscribe+ [ iconSubscription,+ workspaceSubscription,+ geometrySubscription+ ]+ _ <- Gtk.onWidgetUnrealize cont doUnsubscribe+ _ <- widgetSetClassGI cont "workspaces"+ Gtk.toWidget cont++updateAllWorkspaceWidgets :: WorkspacesIO ()+updateAllWorkspaceWidgets = do+ wLog DEBUG "Updating workspace widgets"++ workspacesMap <- updateWorkspacesVar+ wLog DEBUG $ printf "Workspaces: %s" $ show workspacesMap++ wLog DEBUG "Adding and removing widgets"+ updateWorkspaceControllers++ let updateController' idx controller =+ maybe+ (return controller)+ (updateWidget controller . WorkspaceUpdate)+ $ M.lookup idx workspacesMap+ logUpdateController i =+ wLog DEBUG $ printf "Updating %s workspace widget" $ show i+ updateController i cont =+ logUpdateController i+ >> updateController' i cont++ wLog DEBUG "Done updating individual widget"++ doWidgetUpdate updateController++ wLog DEBUG "Showing and hiding controllers"+ setControllerWidgetVisibility++setControllerWidgetVisibility :: WorkspacesIO ()+setControllerWidgetVisibility = do+ ctx@WorkspacesContext+ { workspacesVar = workspacesRef,+ controllersVar = controllersRef,+ ewmhConfig = cfg+ } <-+ ask+ let common = workspacesCommonConfig cfg+ lift $ do+ workspacesMap <- MV.readMVar workspacesRef+ controllersMap <- MV.readMVar controllersRef+ forM_ (M.elems workspacesMap) $ \ws ->+ let action =+ if WorkspaceWidgetConfig.showWorkspaceFn common ws+ then Gtk.widgetShow+ else Gtk.widgetHide+ in traverse+ (flip runReaderT ctx . getWidget)+ (M.lookup (workspaceIdx ws) controllersMap)+ >>= maybe (return ()) action++doWidgetUpdate :: (WorkspaceId -> WWC -> WorkspacesIO WWC) -> WorkspacesIO ()+doWidgetUpdate updateController = do+ c@WorkspacesContext {controllersVar = controllersRef} <- ask+ lift $ MV.modifyMVar_ controllersRef $ \controllers -> do+ wLog DEBUG "Updating controllers ref"+ controllersList <-+ mapM+ ( \(idx, controller) -> do+ newController <- runReaderT (updateController idx controller) c+ return (idx, newController)+ )+ $ M.toList controllers+ return $ M.fromList controllersList++updateWorkspaceControllers :: WorkspacesIO ()+updateWorkspaceControllers = do+ WorkspacesContext+ { controllersVar = controllersRef,+ workspacesVar = workspacesRef,+ workspacesWidget = cont,+ ewmhConfig = cfg+ } <-+ ask+ workspacesMap <- lift $ MV.readMVar workspacesRef+ controllersMap <- lift $ MV.readMVar controllersRef++ let newWorkspacesSet = M.keysSet workspacesMap+ existingWorkspacesSet = M.keysSet controllersMap+ common = workspacesCommonConfig cfg++ when (existingWorkspacesSet /= newWorkspacesSet) $ do+ let addWorkspaces = Set.difference newWorkspacesSet existingWorkspacesSet+ removeWorkspaces = Set.difference existingWorkspacesSet newWorkspacesSet+ builder = WorkspaceWidgetConfig.widgetBuilder common++ _ <- updateVar controllersRef $ \controllers -> do+ let oldRemoved = F.foldl' (flip M.delete) controllers removeWorkspaces+ buildController idx = builder <$> M.lookup idx workspacesMap+ buildAndAddController theMap idx =+ maybe+ (return theMap)+ (>>= return . flip (M.insert idx) theMap)+ (buildController idx)+ foldM buildAndAddController oldRemoved $ Set.toList addWorkspaces+ -- Clear the container and repopulate it+ lift $ Gtk.containerForeach cont (Gtk.containerRemove cont)+ addWidgetsToTopLevel++rateLimitFn ::+ forall req resp.+ WorkspacesContext ->+ (req -> IO resp) ->+ ResultsCombiner req resp ->+ IO (req -> IO resp)+rateLimitFn context =+ let limit = (updateRateLimitMicroseconds $ ewmhConfig context)+ rate = fromMicroseconds limit :: Microsecond+ in generateRateLimitedFunction $ PerInvocation rate++onWorkspaceUpdate :: WorkspacesContext -> IO (Event -> IO ())+onWorkspaceUpdate context = do+ rateLimited <- rateLimitFn context doUpdate combineRequests+ let withLog event = do+ case event of+ PropertyEvent _ _ _ _ _ atom _ _ ->+ wLog DEBUG $ printf "Event %s" $ show atom+ _anythingElse -> return ()+ void $ forkIO $ rateLimited event+ return withLog+ where+ combineRequests _ b = Just (b, const ((), ()))+ doUpdate _ = postGUIASync $ runReaderT updateAllWorkspaceWidgets context++onIconChanged :: (Set.Set X11Window -> IO ()) -> Event -> IO ()+onIconChanged handler event =+ case event of+ PropertyEvent {ev_window = wid} -> do+ wLog DEBUG $ printf "Icon changed event %s" $ show wid+ handler $ Set.singleton wid+ _ -> return ()++onIconsChanged :: WorkspacesContext -> IO (Set.Set X11Window -> IO ())+onIconsChanged context = rateLimitFn context onIconsChanged' combineRequests+ where+ combineRequests windows1 windows2 =+ Just (Set.union windows1 windows2, const ((), ()))+ onIconsChanged' wids = do+ wLog DEBUG $ printf "Icon update execute %s" $ show wids+ postGUIASync $+ flip runReaderT context $+ doWidgetUpdate+ ( \idx c ->+ wLog DEBUG (printf "Updating %s icons." $ show idx)+ >> updateWidget c (IconUpdate $ Set.toList wids)+ )++initializeWWC ::+ (WorkspaceWidgetController a) => a -> Workspace -> ReaderT WorkspacesContext IO WWC+initializeWWC controller ws =+ WWC <$> updateWidget controller (WorkspaceUpdate ws)++-- | A WrappingController can be used to wrap some child widget with another+-- abitrary widget.+data WrappingController = WrappingController+ { wrappedWidget :: Gtk.Widget,+ wrappedController :: WWC+ }++instance WorkspaceWidgetController WrappingController where+ getWidget = lift . Gtk.toWidget . wrappedWidget+ updateWidget wc update = do+ updated <- updateWidget (wrappedController wc) update+ return wc {wrappedController = updated}++data WorkspaceContentsController = WorkspaceContentsController+ { containerWidget :: Gtk.Widget,+ contentsControllers :: [WWC]+ }++buildContentsController :: [ControllerConstructor] -> ControllerConstructor+buildContentsController constructors ws = do+ controllers <- mapM ($ ws) constructors+ ctx <- ask+ tempController <- lift $ do+ cons <- Gtk.boxNew Gtk.OrientationHorizontal 0+ mapM_ (flip runReaderT ctx . getWidget >=> Gtk.containerAdd cons) controllers+ outerBox <- Gtk.toWidget cons >>= buildPadBox+ _ <- widgetSetClassGI cons "contents"+ widget <- Gtk.toWidget outerBox+ return+ WorkspaceContentsController+ { containerWidget = widget,+ contentsControllers = controllers+ }+ initializeWWC tempController ws++defaultBuildContentsController :: ControllerConstructor+defaultBuildContentsController =+ buildContentsController [buildLabelController, buildIconController]++bottomLeftAlignedBoxWrapper :: T.Text -> ControllerConstructor -> ControllerConstructor+bottomLeftAlignedBoxWrapper boxClass constructor ws = do+ controller <- constructor ws+ widget <- getWidget controller+ wrapped <- lift $ buildBottomLeftAlignedBox boxClass widget+ let wrappingController =+ WrappingController+ { wrappedWidget = wrapped,+ wrappedController = controller+ }+ initializeWWC wrappingController ws++buildLabelOverlayController :: ControllerConstructor+buildLabelOverlayController ws = do+ iconController <- buildIconController ws+ labelController <- buildLabelController ws+ ctx <- ask+ tempController <- lift $ do+ iconWidget <- runReaderT (getWidget iconController) ctx+ labelWidget <- runReaderT (getWidget labelController) ctx+ widget <- buildWorkspaceIconLabelOverlay iconWidget labelWidget+ return+ WorkspaceContentsController+ { containerWidget = widget,+ contentsControllers = [iconController, labelController]+ }+ initializeWWC tempController ws++buildOverlayContentsController ::+ [ControllerConstructor] -> [ControllerConstructor] -> ControllerConstructor+buildOverlayContentsController mainConstructors overlayConstructors ws = do+ controllers <- mapM ($ ws) mainConstructors+ overlayControllers <- mapM ($ ws) overlayConstructors+ ctx <- ask+ tempController <- lift $ do+ mainContents <- Gtk.boxNew Gtk.OrientationHorizontal 0+ mapM_+ (flip runReaderT ctx . getWidget >=> Gtk.containerAdd mainContents)+ controllers+ outerBox <- Gtk.toWidget mainContents >>= buildPadBox+ _ <- widgetSetClassGI mainContents "contents"+ overlayWidgets <- mapM (flip runReaderT ctx . getWidget) overlayControllers+ widget <- buildOverlayWithPassThrough outerBox overlayWidgets+ return+ WorkspaceContentsController+ { containerWidget = widget,+ contentsControllers = controllers ++ overlayControllers+ }+ initializeWWC tempController ws++instance WorkspaceWidgetController WorkspaceContentsController where+ getWidget = return . containerWidget+ updateWidget cc update = do+ WorkspacesContext {} <- ask+ case update of+ WorkspaceUpdate newWorkspace ->+ lift $ setWorkspaceWidgetStatusClass (workspaceState newWorkspace) $ containerWidget cc+ _ -> return ()+ newControllers <- mapM (`updateWidget` update) $ contentsControllers cc+ return cc {contentsControllers = newControllers}+ updateWidgetX11 cc update = do+ newControllers <- mapM (`updateWidgetX11` update) $ contentsControllers cc+ return cc {contentsControllers = newControllers}++newtype LabelController = LabelController {label :: Gtk.Label}++buildLabelController :: ControllerConstructor+buildLabelController ws = do+ tempController <- lift $ do+ lbl <- Gtk.labelNew Nothing+ _ <- widgetSetClassGI lbl "workspace-label"+ return LabelController {label = lbl}+ initializeWWC tempController ws++instance WorkspaceWidgetController LabelController where+ getWidget = lift . Gtk.toWidget . label+ updateWidget lc (WorkspaceUpdate newWorkspace) = do+ WorkspacesContext {ewmhConfig = cfg} <- ask+ let common = workspacesCommonConfig cfg+ labelText <- WorkspaceWidgetConfig.labelSetter common newWorkspace+ lift $ do+ Gtk.labelSetMarkup (label lc) $ T.pack labelText+ setWorkspaceWidgetStatusClass (workspaceState newWorkspace) $ label lc+ return lc+ updateWidget lc _ = return lc++type IconWidget = WindowIconWidget WindowData++buildIconWidget :: Bool -> Workspace -> WorkspacesIO IconWidget+buildIconWidget transparentOnNone ws = do+ ctx <- ask+ let tContext = taffyContext ctx+ cfg = ewmhConfig ctx+ common = workspacesCommonConfig cfg+ getPB size windowData =+ runReaderT (WorkspaceWidgetConfig.getWindowIconPixbuf common size windowData) tContext+ strategy <- liftContext getScalingImageStrategy+ lift $ do+ iconWidget <-+ mkWorkspaceIconWidget+ strategy+ Nothing+ transparentOnNone+ getPB+ (`pixBufFromColor` 0)+ _ <-+ Gtk.onWidgetButtonPressEvent (iconContainer iconWidget) $+ const $+ liftIO $ do+ info <- MV.readMVar (iconWindow iconWidget)+ case info of+ Just updatedInfo ->+ flip runReaderT ctx $+ liftX11Def () $+ focusWindow $+ windowId updatedInfo+ _ -> liftIO $ void $ switch ctx (workspaceIdx ws)+ return True+ return iconWidget++data IconController = IconController+ { iconsContainer :: Gtk.Box,+ iconImages :: [IconWidget],+ iconWorkspace :: Workspace+ }++buildIconController :: ControllerConstructor+buildIconController ws = do+ tempController <-+ lift $ do+ hbox <- Gtk.boxNew Gtk.OrientationHorizontal 0+ return+ IconController+ { iconsContainer = hbox,+ iconImages = [],+ iconWorkspace = ws+ }+ initializeWWC tempController ws++instance WorkspaceWidgetController IconController where+ getWidget = lift . Gtk.toWidget . iconsContainer+ updateWidget ic (WorkspaceUpdate newWorkspace) = do+ newImages <- updateImages ic newWorkspace+ return ic {iconImages = newImages, iconWorkspace = newWorkspace}+ updateWidget ic (IconUpdate updatedIcons) =+ updateWindowIconsById ic updatedIcons >> return ic++updateWindowIconsById ::+ IconController -> [X11Window] -> WorkspacesIO ()+updateWindowIconsById ic windowIds =+ mapM_ maybeUpdateWindowIcon $ iconImages ic+ where+ maybeUpdateWindowIcon widget =+ do+ info <- lift $ MV.readMVar $ iconWindow widget+ when (maybe False (flip elem windowIds . windowId) info) $+ updateIconWidget ic widget info++scaledWindowIconPixbufGetter :: WindowIconPixbufGetter -> WindowIconPixbufGetter+scaledWindowIconPixbufGetter = scaledPixbufGetter++constantScaleWindowIconPixbufGetter ::+ Int32 -> WindowIconPixbufGetter -> WindowIconPixbufGetter+constantScaleWindowIconPixbufGetter constantSize getter =+ const $ scaledWindowIconPixbufGetter getter constantSize++handleIconGetterException :: WindowIconPixbufGetter -> WindowIconPixbufGetter+handleIconGetterException = handlePixbufGetterException wLog++getWindowIconPixbufFromEWMH :: WindowIconPixbufGetter+getWindowIconPixbufFromEWMH = handleIconGetterException $ \size windowData ->+ getCachedIconPixBufFromEWMH size (windowId windowData)++getWindowIconPixbufFromClass :: WindowIconPixbufGetter+getWindowIconPixbufFromClass = handleIconGetterException $ \size windowData ->+ getCachedWindowIconFromClasses size (windowClass windowData)++getWindowIconPixbufFromDesktopEntry :: WindowIconPixbufGetter+getWindowIconPixbufFromDesktopEntry = handleIconGetterException $ \size windowData ->+ getCachedWindowIconFromDesktopEntryByClasses size (windowClass windowData)++getWindowIconPixbufFromChrome :: WindowIconPixbufGetter+getWindowIconPixbufFromChrome _ windowData =+ getPixBufFromChromeData $ windowId windowData++defaultGetWindowIconPixbuf :: WindowIconPixbufGetter+defaultGetWindowIconPixbuf =+ scaledWindowIconPixbufGetter unscaledDefaultGetWindowIconPixbuf++unscaledDefaultGetWindowIconPixbuf :: WindowIconPixbufGetter+unscaledDefaultGetWindowIconPixbuf =+ getWindowIconPixbufFromDesktopEntry+ <|||> getWindowIconPixbufFromClass+ <|||> getWindowIconPixbufFromEWMH++addCustomIconsToDefaultWithFallbackByPath ::+ (WindowData -> Maybe FilePath) ->+ FilePath ->+ WindowIconPixbufGetter+addCustomIconsToDefaultWithFallbackByPath getCustomIconPath fallbackPath =+ addCustomIconsAndFallback+ getCustomIconPath+ (const $ lift $ getPixbufFromFilePath fallbackPath)+ unscaledDefaultGetWindowIconPixbuf++addCustomIconsAndFallback ::+ (WindowData -> Maybe FilePath) ->+ (Int32 -> TaffyIO (Maybe Gdk.Pixbuf)) ->+ WindowIconPixbufGetter ->+ WindowIconPixbufGetter+addCustomIconsAndFallback getCustomIconPath fallback defaultGetter =+ scaledWindowIconPixbufGetter $+ getCustomIcon <|||> defaultGetter <|||> (\s _ -> fallback s)+ where+ getCustomIcon :: Int32 -> WindowData -> TaffyIO (Maybe Gdk.Pixbuf)+ getCustomIcon _ wdata =+ lift $+ maybe (return Nothing) getPixbufFromFilePath $+ getCustomIconPath wdata++-- | Sort windows by top-left corner position.+sortWindowsByPosition :: [WindowData] -> WorkspacesIO [WindowData]+sortWindowsByPosition wins = do+ let getGeometryWorkspaces w = getDisplay >>= liftIO . (`safeGetGeometry` w)+ getGeometries =+ mapM+ ( forkM+ return+ (((sel2 &&& sel3) <$>) . getGeometryWorkspaces)+ . windowId+ )+ wins+ windowGeometries <- liftX11Def [] getGeometries+ let getLeftPos wd =+ fromMaybe (999999999, 99999999) $ lookup (windowId wd) windowGeometries+ compareWindowData a b =+ compare+ (windowMinimized a, getLeftPos a)+ (windowMinimized b, getLeftPos b)+ return $ sortBy compareWindowData wins++-- | Sort windows in reverse _NET_CLIENT_LIST_STACKING order.+-- Starting in xmonad-contrib 0.17.0, this is effectively focus history, active first.+-- Previous versions erroneously stored focus-sort-order in _NET_CLIENT_LIST.+sortWindowsByStackIndex :: [WindowData] -> WorkspacesIO [WindowData]+sortWindowsByStackIndex wins = do+ stackingWindows <- liftX11Def [] getWindowsStacking+ let getStackIdx wd = fromMaybe (-1) $ elemIndex (windowId wd) stackingWindows+ compareWindowData a b = compare (getStackIdx b) (getStackIdx a)+ return $ sortBy compareWindowData wins++updateImages :: IconController -> Workspace -> WorkspacesIO [IconWidget]+updateImages ic ws = do+ WorkspacesContext {ewmhConfig = cfg} <- ask+ let common = workspacesCommonConfig cfg+ sortedWindows <- WorkspaceWidgetConfig.iconSort common $ windows ws+ wLog DEBUG $ printf "Updating images for %s" (show ws)+ let (effectiveMinIcons, _targetLen, paddedWindows) =+ computeIconStripLayout+ (WorkspaceWidgetConfig.minIcons common)+ (WorkspaceWidgetConfig.maxIcons common)+ sortedWindows+ buildOne i = buildIconWidget (i < effectiveMinIcons) ws+ updateOne = updateIconWidget ic+ syncWidgetPool (iconsContainer ic) (iconImages ic) paddedWindows buildOne iconContainer updateOne++getWindowStatusString :: WindowData -> T.Text+getWindowStatusString windowData =+ windowStatusClassFromFlags+ (windowMinimized windowData)+ (windowActive windowData)+ (windowUrgent windowData)++updateIconWidget ::+ IconController ->+ IconWidget ->+ Maybe WindowData ->+ WorkspacesIO ()+updateIconWidget _ iconWidget windowData =+ updateWindowIconWidgetState+ iconWidget+ windowData+ (T.pack . windowTitle)+ getWindowStatusString++data WorkspaceButtonController = WorkspaceButtonController+ { button :: Gtk.EventBox,+ buttonWorkspace :: Workspace,+ contentsController :: WWC+ }++buildButtonController :: ParentControllerConstructor+buildButtonController contentsBuilder workspace = do+ cc <- contentsBuilder workspace+ workspacesRef <- asks workspacesVar+ ctx <- ask+ widget <- getWidget cc+ lift $ do+ ebox <- Gtk.eventBoxNew+ Gtk.containerAdd ebox widget+ Gtk.eventBoxSetVisibleWindow ebox False+ _ <-+ Gtk.onWidgetScrollEvent ebox $ \scrollEvent -> do+ dir <- Gdk.getEventScrollDirection scrollEvent+ workspaces <- liftIO $ MV.readMVar workspacesRef+ let switchOne a =+ liftIO $+ flip runReaderT ctx $+ liftX11Def+ ()+ (switchOneWorkspace a (length (M.toList workspaces) - 1))+ >> return True+ case dir of+ Gdk.ScrollDirectionUp -> switchOne True+ Gdk.ScrollDirectionLeft -> switchOne True+ Gdk.ScrollDirectionDown -> switchOne False+ Gdk.ScrollDirectionRight -> switchOne False+ _ -> return False+ _ <- Gtk.onWidgetButtonPressEvent ebox $ const $ switch ctx $ workspaceIdx workspace+ return $+ WWC+ WorkspaceButtonController+ { button = ebox,+ buttonWorkspace = workspace,+ contentsController = cc+ }++switch :: (MonadIO m) => WorkspacesContext -> WorkspaceId -> m Bool+switch ctx idx = do+ liftIO $ flip runReaderT ctx $ liftX11Def () $ switchToWorkspace idx+ return True++instance WorkspaceWidgetController WorkspaceButtonController where+ getWidget wbc = lift $ Gtk.toWidget $ button wbc+ updateWidget wbc update = do+ newContents <- updateWidget (contentsController wbc) update+ return wbc {contentsController = newContents}
+ src/System/Taffybar/Widget/Workspaces/EWMH/Compat.hs view
@@ -0,0 +1,171 @@+{-# LANGUAGE StrictData #-}+{-# OPTIONS_GHC -Wno-deprecations #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.Workspaces.EWMH.Compat+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Flat legacy compatibility config for EWMH workspaces.+--+-- This module is intentionally not re-exported by the umbrella widget+-- modules so that consumers opt in explicitly.+module System.Taffybar.Widget.Workspaces.EWMH.Compat+ ( WorkspacesConfig (..),+ defaultWorkspacesConfig,+ workspacesNew,+ workspacesCommonConfig,+ modifyCommonWorkspacesConfig,+ applyCommonWorkspacesConfig,+ toEWMHWorkspacesConfig,+ fromEWMHWorkspacesConfig,+ FlatWorkspacesConfig,+ defaultFlatWorkspacesConfig,+ fromFlatWorkspacesConfig,+ toFlatWorkspacesConfig,+ )+where++import Control.Monad.Trans.Reader (ReaderT)+import Data.Default (Default (..))+import qualified GI.Gtk as Gtk+import System.Taffybar.Context (TaffyIO)+import System.Taffybar.Widget.Workspaces.Config+ ( WorkspaceWidgetCommonConfig (WorkspaceWidgetCommonConfig),+ )+import qualified System.Taffybar.Widget.Workspaces.Config as WorkspaceConfig+import qualified System.Taffybar.Widget.Workspaces.EWMH as EWMH++data WorkspacesConfig+ = WorkspacesConfig+ { widgetBuilder :: EWMH.ControllerConstructor,+ widgetGap :: Int,+ maxIcons :: Maybe Int,+ minIcons :: Int,+ getWindowIconPixbuf :: EWMH.WindowIconPixbufGetter,+ labelSetter :: EWMH.Workspace -> EWMH.WorkspacesIO String,+ showWorkspaceFn :: EWMH.Workspace -> Bool,+ borderWidth :: Int,+ updateEvents :: [String],+ updateRateLimitMicroseconds :: Integer,+ iconSort :: [EWMH.WindowData] -> EWMH.WorkspacesIO [EWMH.WindowData],+ urgentWorkspaceState :: Bool+ }++{-# DEPRECATED widgetBuilder "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.EWMH.WorkspacesConfig` instead." #-}++{-# DEPRECATED widgetGap "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.EWMH.WorkspacesConfig` instead." #-}++{-# DEPRECATED maxIcons "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.EWMH.WorkspacesConfig` instead." #-}++{-# DEPRECATED minIcons "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.EWMH.WorkspacesConfig` instead." #-}++{-# DEPRECATED getWindowIconPixbuf "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.EWMH.WorkspacesConfig` instead." #-}++{-# DEPRECATED labelSetter "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.EWMH.WorkspacesConfig` instead." #-}++{-# DEPRECATED showWorkspaceFn "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.EWMH.WorkspacesConfig` instead." #-}++{-# DEPRECATED borderWidth "Legacy flat config field. Use `System.Taffybar.Widget.Workspaces.EWMH.borderWidth` on the canonical EWMH config type instead." #-}++{-# DEPRECATED updateEvents "Legacy flat config field. Use `System.Taffybar.Widget.Workspaces.EWMH.updateEvents` on the canonical EWMH config type instead." #-}++{-# DEPRECATED updateRateLimitMicroseconds "Legacy flat config field. Use `System.Taffybar.Widget.Workspaces.EWMH.updateRateLimitMicroseconds` on the canonical EWMH config type instead." #-}++{-# DEPRECATED iconSort "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.EWMH.WorkspacesConfig` instead." #-}++{-# DEPRECATED urgentWorkspaceState "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.EWMH.WorkspacesConfig` instead." #-}++toEWMHWorkspacesConfig :: WorkspacesConfig -> EWMH.WorkspacesConfig+toEWMHWorkspacesConfig flat =+ EWMH.WorkspacesConfig+ { EWMH.workspacesConfig =+ WorkspaceWidgetCommonConfig+ { WorkspaceConfig.widgetBuilder = widgetBuilder flat,+ WorkspaceConfig.widgetGap = widgetGap flat,+ WorkspaceConfig.maxIcons = maxIcons flat,+ WorkspaceConfig.minIcons = minIcons flat,+ WorkspaceConfig.getWindowIconPixbuf = getWindowIconPixbuf flat,+ WorkspaceConfig.labelSetter = labelSetter flat,+ WorkspaceConfig.showWorkspaceFn = showWorkspaceFn flat,+ WorkspaceConfig.iconSort = iconSort flat,+ WorkspaceConfig.urgentWorkspaceState = urgentWorkspaceState flat+ },+ EWMH.borderWidth = borderWidth flat,+ EWMH.updateEvents = updateEvents flat,+ EWMH.updateRateLimitMicroseconds = updateRateLimitMicroseconds flat+ }++fromEWMHWorkspacesConfig :: EWMH.WorkspacesConfig -> WorkspacesConfig+fromEWMHWorkspacesConfig cfg =+ let common = EWMH.workspacesConfig cfg+ in WorkspacesConfig+ { widgetBuilder = WorkspaceConfig.widgetBuilder common,+ widgetGap = WorkspaceConfig.widgetGap common,+ maxIcons = WorkspaceConfig.maxIcons common,+ minIcons = WorkspaceConfig.minIcons common,+ getWindowIconPixbuf = WorkspaceConfig.getWindowIconPixbuf common,+ labelSetter = WorkspaceConfig.labelSetter common,+ showWorkspaceFn = WorkspaceConfig.showWorkspaceFn common,+ borderWidth = EWMH.borderWidth cfg,+ updateEvents = EWMH.updateEvents cfg,+ updateRateLimitMicroseconds = EWMH.updateRateLimitMicroseconds cfg,+ iconSort = WorkspaceConfig.iconSort common,+ urgentWorkspaceState = WorkspaceConfig.urgentWorkspaceState common+ }++defaultWorkspacesConfig :: WorkspacesConfig+defaultWorkspacesConfig =+ fromEWMHWorkspacesConfig EWMH.defaultWorkspacesConfig++workspacesNew :: WorkspacesConfig -> TaffyIO Gtk.Widget+workspacesNew = EWMH.workspacesNew . toEWMHWorkspacesConfig++workspacesCommonConfig ::+ WorkspacesConfig ->+ WorkspaceWidgetCommonConfig (ReaderT EWMH.WorkspacesContext IO) EWMH.Workspace EWMH.WindowData EWMH.WWC+workspacesCommonConfig =+ EWMH.workspacesConfig . toEWMHWorkspacesConfig++-- | Modify the nested common config inside a legacy flat workspaces config.+--+-- Prefer this over defining @common@ in terms of a recursively-defined @cfg@,+-- which can accidentally create a black-hole and hang at runtime.+modifyCommonWorkspacesConfig ::+ ( WorkspaceWidgetCommonConfig (ReaderT EWMH.WorkspacesContext IO) EWMH.Workspace EWMH.WindowData EWMH.WWC ->+ WorkspaceWidgetCommonConfig (ReaderT EWMH.WorkspacesContext IO) EWMH.Workspace EWMH.WindowData EWMH.WWC+ ) ->+ WorkspacesConfig ->+ WorkspacesConfig+modifyCommonWorkspacesConfig f cfg =+ applyCommonWorkspacesConfig (f (workspacesCommonConfig cfg)) cfg++applyCommonWorkspacesConfig ::+ WorkspaceWidgetCommonConfig (ReaderT EWMH.WorkspacesContext IO) EWMH.Workspace EWMH.WindowData EWMH.WWC ->+ WorkspacesConfig ->+ WorkspacesConfig+applyCommonWorkspacesConfig common cfg =+ fromEWMHWorkspacesConfig $+ EWMH.applyCommonWorkspacesConfig common (toEWMHWorkspacesConfig cfg)++type FlatWorkspacesConfig = WorkspacesConfig++defaultFlatWorkspacesConfig :: FlatWorkspacesConfig+defaultFlatWorkspacesConfig = defaultWorkspacesConfig++fromFlatWorkspacesConfig :: FlatWorkspacesConfig -> EWMH.WorkspacesConfig+fromFlatWorkspacesConfig = toEWMHWorkspacesConfig++toFlatWorkspacesConfig :: EWMH.WorkspacesConfig -> FlatWorkspacesConfig+toFlatWorkspacesConfig = fromEWMHWorkspacesConfig++instance Default WorkspacesConfig where+ def = defaultWorkspacesConfig
+ src/System/Taffybar/Widget/Workspaces/Hyprland.hs view
@@ -0,0 +1,963 @@+{-# LANGUAGE ExistentialQuantification #-}+{-# LANGUAGE OverloadedStrings #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.Workspaces.Hyprland+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Hyprland workspaces widget backed by hyprctl.+module System.Taffybar.Widget.Workspaces.Hyprland where++import Control.Applicative ((<|>))+import Control.Concurrent (forkIO, killThread)+import Control.Concurrent.STM.TChan (TChan, readTChan)+import Control.Monad (foldM, forM_, when)+import Control.Monad.IO.Class (MonadIO (liftIO))+import Control.Monad.STM (atomically)+import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)+import Data.Aeson (FromJSON (..), withObject, (.!=), (.:), (.:?))+import qualified Data.ByteString as BS+import Data.Char (toLower)+import Data.Default (Default (..))+import qualified Data.Foldable as F+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Int (Int32)+import Data.List (intercalate, sortOn, stripPrefix)+import qualified Data.Map.Strict as M+import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)+import qualified Data.MultiMap as MM+import Data.Text (Text)+import qualified Data.Text as T+import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk+import qualified GI.Gtk as Gtk+import System.Environment.XDG.DesktopEntry+ ( DesktopEntry,+ deFilename,+ getDirectoryEntriesDefault,+ )+import System.Log.Logger (Priority (..), logM)+import System.Taffybar.Context+import System.Taffybar.Hyprland+ ( getHyprlandEventChan,+ runHyprlandCommandJsonT,+ runHyprlandCommandRawT,+ )+import qualified System.Taffybar.Information.Hyprland as Hypr+import System.Taffybar.Util+import System.Taffybar.Widget.Generic.ScalingImage (getScalingImageStrategy)+import System.Taffybar.Widget.Util+ ( WindowIconWidget (..),+ computeIconStripLayout,+ getImageForDesktopEntry,+ handlePixbufGetterException,+ scaledPixbufGetter,+ syncWidgetPool,+ updateWindowIconWidgetState,+ widgetSetClassGI,+ windowStatusClassFromFlags,+ )+import System.Taffybar.Widget.Workspaces.Config+ ( WorkspaceWidgetCommonConfig (WorkspaceWidgetCommonConfig),+ )+import qualified System.Taffybar.Widget.Workspaces.Config as WorkspaceWidgetConfig+import System.Taffybar.Widget.Workspaces.Shared+ ( WorkspaceState (..),+ buildWorkspaceIconLabelOverlay,+ mkWorkspaceIconWidget,+ setWorkspaceWidgetStatusClass,+ )+import System.Taffybar.WindowIcon (getCachedWindowIconFromClasses, pixBufFromColor)+import Text.Printf (printf)++stripSuffix :: (Eq a) => [a] -> [a] -> Maybe [a]+stripSuffix suffix value =+ reverse <$> stripPrefix (reverse suffix) (reverse value)++isSpecialHyprWorkspace :: HyprlandWorkspace -> Bool+isSpecialHyprWorkspace ws =+ let name = T.toLower $ T.pack $ workspaceName ws+ in T.isPrefixOf "special" name || workspaceIdx ws < 0++data HyprlandWindow = HyprlandWindow+ { windowAddress :: Text,+ windowTitle :: String,+ windowClass :: Maybe String,+ windowInitialClass :: Maybe String,+ -- | The top-left position (x, y) of the window, as reported by+ -- @hyprctl clients -j@. This is used for optional icon ordering.+ windowAt :: Maybe (Int, Int),+ windowUrgent :: Bool,+ windowActive :: Bool,+ windowMinimized :: Bool+ }+ deriving (Show, Eq)++data HyprlandWorkspace = HyprlandWorkspace+ { workspaceIdx :: Int,+ workspaceName :: String,+ workspaceState :: WorkspaceState,+ windows :: [HyprlandWindow]+ }+ deriving (Show, Eq)++newtype HyprlandWorkspaceCache = HyprlandWorkspaceCache [HyprlandWorkspace]++newtype HyprlandWorkspaceWidgetCache+ = HyprlandWorkspaceWidgetCache (M.Map Int HyprlandWorkspaceEntry)++newtype HyprlandWorkspaceOrderCache+ = HyprlandWorkspaceOrderCache [Int]++data HyprlandWorkspaceEntry = HyprlandWorkspaceEntry+ { hweWrapper :: Gtk.Widget,+ hweController :: HyprlandWWC,+ hweLast :: HyprlandWorkspace+ }++type HyprlandIconWidget = WindowIconWidget HyprlandWindow++-- | Controller typeclass for Hyprland workspace widgets, mirroring+-- the X11 'WorkspaceWidgetController' pattern.+class HyprlandWorkspaceWidgetController wc where+ hwcGetWidget :: wc -> IO Gtk.Widget+ hwcUpdateWidget :: wc -> HyprlandWorkspace -> TaffyIO wc++-- | Existential wrapper for Hyprland workspace controllers.+data HyprlandWWC = forall a. (HyprlandWorkspaceWidgetController a) => HyprlandWWC a++instance HyprlandWorkspaceWidgetController HyprlandWWC where+ hwcGetWidget (HyprlandWWC wc) = hwcGetWidget wc+ hwcUpdateWidget (HyprlandWWC wc) ws = HyprlandWWC <$> hwcUpdateWidget wc ws++type HyprlandControllerConstructor = HyprlandWorkspace -> TaffyIO HyprlandWWC++type HyprlandParentControllerConstructor =+ HyprlandControllerConstructor -> HyprlandControllerConstructor++type HyprlandWindowIconPixbufGetter =+ Int32 -> HyprlandWindow -> TaffyIO (Maybe Gdk.Pixbuf)++data HyprlandWorkspacesConfig+ = HyprlandWorkspacesConfig+ { getWorkspaces :: TaffyIO [HyprlandWorkspace],+ switchToWorkspace :: HyprlandWorkspace -> TaffyIO (),+ updateIntervalSeconds :: Double,+ iconSize :: Int32,+ workspacesConfig ::+ WorkspaceWidgetCommonConfig+ (ReaderT Context IO)+ HyprlandWorkspace+ HyprlandWindow+ HyprlandWWC+ }++hyprlandWorkspacesCommonConfig ::+ HyprlandWorkspacesConfig ->+ WorkspaceWidgetCommonConfig (ReaderT Context IO) HyprlandWorkspace HyprlandWindow HyprlandWWC+hyprlandWorkspacesCommonConfig = workspacesConfig++-- | Modify the nested 'WorkspaceWidgetCommonConfig' inside a+-- 'HyprlandWorkspacesConfig'.+--+-- This helper exists to avoid accidental self-recursive config definitions+-- when overriding multiple fields. Prefer:+--+-- > cfg = modifyCommonHyprlandWorkspacesConfig (\c -> c { WorkspaceWidgetConfig.minIcons = 1 }) defaultHyprlandWorkspacesConfig+--+-- over tying the knot yourself (e.g. defining @common@ in terms of @cfg@ while+-- also defining @cfg@ in terms of @common@), which can hang at runtime.+modifyCommonHyprlandWorkspacesConfig ::+ ( WorkspaceWidgetCommonConfig (ReaderT Context IO) HyprlandWorkspace HyprlandWindow HyprlandWWC ->+ WorkspaceWidgetCommonConfig (ReaderT Context IO) HyprlandWorkspace HyprlandWindow HyprlandWWC+ ) ->+ HyprlandWorkspacesConfig ->+ HyprlandWorkspacesConfig+modifyCommonHyprlandWorkspacesConfig f cfg =+ cfg {workspacesConfig = f (workspacesConfig cfg)}++-- | Replace the nested common config inside 'HyprlandWorkspacesConfig'.+--+-- If you're just tweaking a few fields, prefer+-- 'modifyCommonHyprlandWorkspacesConfig' to avoid accidentally creating a+-- self-recursive definition that can hang at runtime.+applyCommonHyprlandWorkspacesConfig ::+ WorkspaceWidgetCommonConfig (ReaderT Context IO) HyprlandWorkspace HyprlandWindow HyprlandWWC ->+ HyprlandWorkspacesConfig ->+ HyprlandWorkspacesConfig+applyCommonHyprlandWorkspacesConfig common cfg =+ cfg {workspacesConfig = common}++defaultHyprlandWorkspacesConfig :: HyprlandWorkspacesConfig+defaultHyprlandWorkspacesConfig = cfg+ where+ cfg =+ HyprlandWorkspacesConfig+ { getWorkspaces = getHyprlandWorkspaces,+ switchToWorkspace = hyprlandSwitchToWorkspace,+ updateIntervalSeconds = 1,+ iconSize = 16,+ workspacesConfig =+ WorkspaceWidgetCommonConfig+ { WorkspaceWidgetConfig.widgetBuilder = defaultHyprlandWidgetBuilder cfg,+ WorkspaceWidgetConfig.widgetGap = 0,+ WorkspaceWidgetConfig.maxIcons = Nothing,+ WorkspaceWidgetConfig.minIcons = 0,+ WorkspaceWidgetConfig.getWindowIconPixbuf = defaultHyprlandGetWindowIconPixbuf,+ WorkspaceWidgetConfig.labelSetter = return . workspaceName,+ WorkspaceWidgetConfig.showWorkspaceFn = \ws ->+ workspaceState ws /= Empty && not (isSpecialHyprWorkspace ws),+ -- Match the X11 Workspaces widget default: order icons by window position.+ WorkspaceWidgetConfig.iconSort = pure . sortHyprlandWindowsByPosition,+ WorkspaceWidgetConfig.urgentWorkspaceState = False+ }+ }++instance Default HyprlandWorkspacesConfig where+ def = defaultHyprlandWorkspacesConfig++hyprlandWorkspacesNew :: HyprlandWorkspacesConfig -> TaffyIO Gtk.Widget+hyprlandWorkspacesNew cfg = do+ let common = hyprlandWorkspacesCommonConfig cfg+ cont <-+ liftIO $+ Gtk.boxNew Gtk.OrientationHorizontal $+ fromIntegral (WorkspaceWidgetConfig.widgetGap common)+ _ <- widgetSetClassGI cont "workspaces"+ ctx <- ask+ let refresh = runReaderT (refreshWorkspaces cfg cont) ctx+ liftIO refresh+ -- Ensure this top-level container is visible when packed into the bar.+ -- Otherwise the start widget area can appear blank under Wayland/Hyprland.+ liftIO $ Gtk.widgetShowAll cont+ eventChan <- getHyprlandEventChan+ events <- liftIO $ Hypr.subscribeHyprlandEvents eventChan+ tid <- liftIO $ forkIO $ hyprlandUpdateLoop refresh events+ _ <- liftIO $ Gtk.onWidgetUnrealize cont $ killThread tid+ Gtk.toWidget cont++hyprlandUpdateLoop :: IO () -> TChan T.Text -> IO ()+hyprlandUpdateLoop refresh events = do+ line <- atomically $ readTChan events+ when (isRelevantHyprEvent (T.unpack line)) refresh+ hyprlandUpdateLoop refresh events++isRelevantHyprEvent :: String -> Bool+isRelevantHyprEvent line =+ let eventName = takeWhile (/= '>') line+ in eventName+ `elem` [ "workspace",+ "workspacev2",+ "focusedmon",+ "activewindow",+ "activewindowv2",+ "openwindow",+ "closewindow",+ "movewindow",+ "movewindowv2",+ "moveworkspace",+ "renameworkspace",+ "createworkspace",+ "destroyworkspace",+ "monitoradded",+ "monitorremoved",+ -- Synthetic "event" emitted by our event reader thread whenever it+ -- (re)connects to Hyprland. This lets us refresh after a compositor+ -- restart without polling.+ "taffybar-hyprland-connected"+ ]++refreshWorkspaces :: HyprlandWorkspacesConfig -> Gtk.Box -> ReaderT Context IO ()+refreshWorkspaces cfg cont = do+ let common = hyprlandWorkspacesCommonConfig cfg+ ws <- getWorkspaces cfg+ HyprlandWorkspaceCache prev <- getStateDefault $ return (HyprlandWorkspaceCache [])+ -- Detect whether the cached widgets have been orphaned (e.g. the bar window+ -- was destroyed and recreated after a compositor reload). When the old GTK+ -- objects are gone we must re-render even if the workspace data is unchanged.+ HyprlandWorkspaceWidgetCache wc <-+ getStateDefault $ return (HyprlandWorkspaceWidgetCache M.empty)+ widgetsStale <- case M.elems wc of+ [] -> return False+ (first : _) -> liftIO $ not <$> Gtk.widgetGetRealized (hweWrapper first)+ let ignoreEmptyResult = null ws && not (null prev)+ when ignoreEmptyResult $+ liftIO $+ wLog WARNING $+ printf+ "Hyprland workspaces refresh returned empty list; retaining previous state (prevTotal=%d)."+ (length prev)+ let needsRender = (ws /= prev || widgetsStale) && not ignoreEmptyResult+ when needsRender $ do+ liftIO $+ wLog DEBUG $+ printf+ "Hyprland workspaces refresh: total=%d shown=%d (minIcons=%d maxIcons=%s) widgetsStale=%s %s"+ (length ws)+ (length (filter (WorkspaceWidgetConfig.showWorkspaceFn common) ws))+ (WorkspaceWidgetConfig.minIcons common)+ (show (WorkspaceWidgetConfig.maxIcons common))+ (show widgetsStale)+ (summarizeWorkspaces ws)+ _ <- setState (HyprlandWorkspaceCache ws)+ ctx <- ask+ liftIO $ postGUIASync $ runReaderT (renderWorkspaces cfg cont ws) ctx++renderWorkspaces ::+ HyprlandWorkspacesConfig -> Gtk.Box -> [HyprlandWorkspace] -> ReaderT Context IO ()+renderWorkspaces cfg cont workspaces = do+ let common = hyprlandWorkspacesCommonConfig cfg+ let workspaces' = map (applyUrgentState cfg) workspaces+ HyprlandWorkspaceWidgetCache widgetCache <-+ getStateDefault $ return (HyprlandWorkspaceWidgetCache M.empty)+ HyprlandWorkspaceOrderCache prevOrder <-+ getStateDefault $ return (HyprlandWorkspaceOrderCache [])+ -- If any cached widget has been unrealized (e.g. because the bar window was+ -- recreated after a compositor reload), the old GTK objects are dead and must+ -- be discarded so that fresh widgets are built for the new container.+ stale <- case M.elems widgetCache of+ [] -> return False+ (first : _) -> liftIO $ not <$> Gtk.widgetGetRealized (hweWrapper first)+ let oldCache = if stale then M.empty else widgetCache+ oldOrder = if stale then [] else prevOrder+ when stale $+ liftIO $+ wLog DEBUG "renderWorkspaces: discarding stale widget cache (widgets unrealized)"+ let buildOrUpdate newCache ws = do+ let idx = workspaceIdx ws+ entry <- case M.lookup idx oldCache of+ Just prevEntry+ | hweLast prevEntry == ws -> return prevEntry+ | otherwise -> do+ newCtrl <- hwcUpdateWidget (hweController prevEntry) ws+ return prevEntry {hweController = newCtrl, hweLast = ws}+ Nothing -> do+ ctrl <- WorkspaceWidgetConfig.widgetBuilder common ws+ ctrlWidget <- liftIO $ hwcGetWidget ctrl+ wrapperBox <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal 0+ liftIO $ Gtk.containerAdd wrapperBox ctrlWidget+ wrapper <- Gtk.toWidget wrapperBox+ return+ HyprlandWorkspaceEntry+ { hweWrapper = wrapper,+ hweController = ctrl,+ hweLast = ws+ }+ return (M.insert idx entry newCache, entry)++ (newCache, orderedEntriesRev) <-+ foldM+ ( \(cacheAcc, entriesAcc) ws -> do+ (cacheAcc', entry) <- buildOrUpdate cacheAcc ws+ return (cacheAcc', (ws, entry) : entriesAcc)+ )+ (M.empty, [])+ workspaces'+ let orderedEntries = reverse orderedEntriesRev++ let primaryShowFn = WorkspaceWidgetConfig.showWorkspaceFn common+ primaryShownCount = length $ filter (primaryShowFn . fst) orderedEntries+ fallbackShowFn ws = workspaceState ws /= Empty+ fallbackShownCount = length $ filter (fallbackShowFn . fst) orderedEntries+ (finalShowFn, finalShownCount, fallbackUsed) =+ if primaryShownCount == 0 && fallbackShownCount > 0+ then (fallbackShowFn, fallbackShownCount, True)+ else (primaryShowFn, primaryShownCount, False)++ when (fallbackUsed && not (null orderedEntries)) $+ liftIO $+ wLog WARNING $+ printf+ "Hyprland workspaces: showWorkspaceFn hid all %d workspaces; falling back to showing non-empty workspaces (including special:*). %s"+ (length orderedEntries)+ (summarizeWorkspaces (map fst orderedEntries))+ when (finalShownCount == 0 && not (null orderedEntries)) $+ liftIO $+ wLog WARNING $+ printf+ "Hyprland workspaces widget is blank: no workspaces passed the show filter (total=%d). %s"+ (length orderedEntries)+ (summarizeWorkspaces (map fst orderedEntries))++ -- Remove wrappers for workspaces that disappeared.+ let removed = M.difference oldCache newCache+ forM_ (M.elems removed) $ \entry ->+ liftIO $ Gtk.containerRemove cont (hweWrapper entry)++ -- Add wrappers for newly created workspaces.+ let added = M.difference newCache oldCache+ forM_ (M.elems added) $ \entry -> do+ liftIO $ Gtk.containerAdd cont (hweWrapper entry)+ liftIO $ Gtk.widgetShowAll (hweWrapper entry)++ let desiredOrder = map (workspaceIdx . fst) orderedEntries+ needsReorder =+ desiredOrder /= oldOrder || not (M.null added) || not (M.null removed)+ when needsReorder $ do+ -- Reorder wrappers to match the order returned by hyprctl.+ forM_ (zip [0 :: Int ..] orderedEntries) $ \(pos, (_ws, entry)) ->+ liftIO $ Gtk.boxReorderChild cont (hweWrapper entry) (fromIntegral pos)++ -- Show/hide the controller widget without removing the wrapper from the box.+ forM_ orderedEntries $ \(ws, entry) -> do+ ctrlWidget <- liftIO $ hwcGetWidget (hweController entry)+ if finalShowFn ws+ then liftIO $ Gtk.widgetShow ctrlWidget+ else liftIO $ Gtk.widgetHide ctrlWidget++ _ <- setState (HyprlandWorkspaceWidgetCache newCache)+ _ <- setState (HyprlandWorkspaceOrderCache desiredOrder)+ return ()++summarizeWorkspaces :: [HyprlandWorkspace] -> String+summarizeWorkspaces wss =+ let summarizeOne ws =+ printf+ "%d:%s:%s(wins=%d)"+ (workspaceIdx ws)+ (workspaceName ws)+ (show (workspaceState ws))+ (length (windows ws))+ maxShown = 12+ body = intercalate ", " $ map summarizeOne (take maxShown wss)+ suffix = if length wss > maxShown then ", ..." else ""+ in "workspaces=[" ++ body ++ suffix ++ "]"++applyUrgentState :: HyprlandWorkspacesConfig -> HyprlandWorkspace -> HyprlandWorkspace+applyUrgentState cfg ws+ | WorkspaceWidgetConfig.urgentWorkspaceState (hyprlandWorkspacesCommonConfig cfg)+ && workspaceState ws == Hidden+ && any windowUrgent (windows ws) =+ ws {workspaceState = Urgent}+ | otherwise = ws++-- Controller types++data HyprlandLabelController = HyprlandLabelController+ { hlcLabel :: Gtk.Label,+ hlcLabelSetter :: HyprlandWorkspace -> TaffyIO String+ }++instance HyprlandWorkspaceWidgetController HyprlandLabelController where+ hwcGetWidget = Gtk.toWidget . hlcLabel+ hwcUpdateWidget lc ws = do+ labelText <- hlcLabelSetter lc ws+ liftIO $ do+ Gtk.labelSetMarkup (hlcLabel lc) (T.pack labelText)+ setWorkspaceWidgetStatusClass (workspaceState ws) (hlcLabel lc)+ return lc++hyprlandBuildLabelController :: HyprlandWorkspacesConfig -> HyprlandControllerConstructor+hyprlandBuildLabelController cfg ws = do+ let common = hyprlandWorkspacesCommonConfig cfg+ lbl <- liftIO $ Gtk.labelNew Nothing+ _ <- widgetSetClassGI lbl "workspace-label"+ labelText <- WorkspaceWidgetConfig.labelSetter common ws+ liftIO $ Gtk.labelSetMarkup lbl (T.pack labelText)+ liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) lbl+ return $+ HyprlandWWC $+ HyprlandLabelController+ { hlcLabel = lbl,+ hlcLabelSetter = WorkspaceWidgetConfig.labelSetter common+ }++data HyprlandIconController = HyprlandIconController+ { hicIconsContainer :: Gtk.Box,+ hicIconImages :: [HyprlandIconWidget],+ hicWorkspace :: HyprlandWorkspace,+ hicConfig :: HyprlandWorkspacesConfig+ }++instance HyprlandWorkspaceWidgetController HyprlandIconController where+ hwcGetWidget = Gtk.toWidget . hicIconsContainer+ hwcUpdateWidget ic ws = do+ newImages <-+ if windows ws /= windows (hicWorkspace ic)+ then updateIcons (hicConfig ic) ws (hicIconsContainer ic) (hicIconImages ic)+ else return (hicIconImages ic)+ return ic {hicIconImages = newImages, hicWorkspace = ws}++hyprlandBuildIconController :: HyprlandWorkspacesConfig -> HyprlandControllerConstructor+hyprlandBuildIconController cfg ws = do+ iconsBox <- liftIO $ Gtk.boxNew Gtk.OrientationHorizontal 0+ icons <- updateIcons cfg ws iconsBox []+ return $+ HyprlandWWC $+ HyprlandIconController+ { hicIconsContainer = iconsBox,+ hicIconImages = icons,+ hicWorkspace = ws,+ hicConfig = cfg+ }++data HyprlandContentsController = HyprlandContentsController+ { hccContainerWidget :: Gtk.Widget,+ hccControllers :: [HyprlandWWC]+ }++instance HyprlandWorkspaceWidgetController HyprlandContentsController where+ hwcGetWidget = return . hccContainerWidget+ hwcUpdateWidget cc ws = do+ liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) (hccContainerWidget cc)+ newControllers <- mapM (`hwcUpdateWidget` ws) (hccControllers cc)+ return cc {hccControllers = newControllers}++hyprlandBuildContentsController ::+ [HyprlandControllerConstructor] -> HyprlandControllerConstructor+hyprlandBuildContentsController constructors ws = do+ controllers <- mapM ($ ws) constructors+ widgets <- liftIO $ mapM hwcGetWidget controllers+ widget <- liftIO $ do+ cons <- Gtk.boxNew Gtk.OrientationHorizontal 0+ mapM_ (Gtk.containerAdd cons) widgets+ _ <- widgetSetClassGI cons "contents"+ Gtk.toWidget cons+ liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) widget+ return $+ HyprlandWWC $+ HyprlandContentsController+ { hccContainerWidget = widget,+ hccControllers = controllers+ }++hyprlandBuildLabelOverlayController ::+ HyprlandWorkspacesConfig -> HyprlandControllerConstructor+hyprlandBuildLabelOverlayController cfg ws = do+ iconCtrl <- hyprlandBuildIconController cfg ws+ labelCtrl <- hyprlandBuildLabelController cfg ws+ iconWidget <- liftIO $ hwcGetWidget iconCtrl+ labelWidget <- liftIO $ hwcGetWidget labelCtrl+ widget <- buildWorkspaceIconLabelOverlay iconWidget labelWidget+ liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) widget+ return $+ HyprlandWWC $+ HyprlandContentsController+ { hccContainerWidget = widget,+ hccControllers = [iconCtrl, labelCtrl]+ }++-- | Like 'hyprlandBuildLabelOverlayController' but accepts a custom function+-- to combine the icon and label widgets into a single container widget.+hyprlandBuildCustomOverlayController ::+ (Gtk.Widget -> Gtk.Widget -> TaffyIO Gtk.Widget) ->+ HyprlandWorkspacesConfig ->+ HyprlandControllerConstructor+hyprlandBuildCustomOverlayController combiner cfg ws = do+ iconCtrl <- hyprlandBuildIconController cfg ws+ labelCtrl <- hyprlandBuildLabelController cfg ws+ iconWidget <- liftIO $ hwcGetWidget iconCtrl+ labelWidget <- liftIO $ hwcGetWidget labelCtrl+ widget <- combiner iconWidget labelWidget+ liftIO $ setWorkspaceWidgetStatusClass (workspaceState ws) widget+ return $+ HyprlandWWC $+ HyprlandContentsController+ { hccContainerWidget = widget,+ hccControllers = [iconCtrl, labelCtrl]+ }++hyprlandDefaultBuildContentsController ::+ HyprlandWorkspacesConfig -> HyprlandControllerConstructor+hyprlandDefaultBuildContentsController = hyprlandBuildLabelOverlayController++data HyprlandButtonController = HyprlandButtonController+ { hbcButton :: Gtk.EventBox,+ hbcWorkspaceRef :: IORef HyprlandWorkspace,+ hbcContentsController :: HyprlandWWC+ }++instance HyprlandWorkspaceWidgetController HyprlandButtonController where+ hwcGetWidget = Gtk.toWidget . hbcButton+ hwcUpdateWidget wbc ws = do+ liftIO $ writeIORef (hbcWorkspaceRef wbc) ws+ newContents <- hwcUpdateWidget (hbcContentsController wbc) ws+ return wbc {hbcContentsController = newContents}++hyprlandBuildButtonController ::+ HyprlandWorkspacesConfig -> HyprlandParentControllerConstructor+hyprlandBuildButtonController cfg contentsBuilder ws = do+ cc <- contentsBuilder ws+ ctx <- ask+ contentsWidget <- liftIO $ hwcGetWidget cc+ wsRef <- liftIO $ newIORef ws+ ebox <- liftIO $ do+ eb <- Gtk.eventBoxNew+ Gtk.eventBoxSetVisibleWindow eb False+ Gtk.containerAdd eb contentsWidget+ _ <- Gtk.onWidgetButtonPressEvent eb $ const $ do+ wsCurrent <- readIORef wsRef+ runReaderT (switchToWorkspace cfg wsCurrent) ctx+ return True+ return eb+ return $+ HyprlandWWC $+ HyprlandButtonController+ { hbcButton = ebox,+ hbcWorkspaceRef = wsRef,+ hbcContentsController = cc+ }++defaultHyprlandWidgetBuilder :: HyprlandWorkspacesConfig -> HyprlandControllerConstructor+defaultHyprlandWidgetBuilder cfg =+ hyprlandBuildButtonController cfg (hyprlandDefaultBuildContentsController cfg)++updateIcons ::+ HyprlandWorkspacesConfig ->+ HyprlandWorkspace ->+ Gtk.Box ->+ [HyprlandIconWidget] ->+ ReaderT Context IO [HyprlandIconWidget]+updateIcons cfg ws iconsBox iconWidgets = do+ let common = hyprlandWorkspacesCommonConfig cfg+ sortedWindows <- WorkspaceWidgetConfig.iconSort common $ windows ws+ let (effectiveMinIcons, _targetLen, paddedWindows) =+ computeIconStripLayout+ (WorkspaceWidgetConfig.minIcons common)+ (WorkspaceWidgetConfig.maxIcons common)+ sortedWindows+ buildOne i = buildIconWidget (i < effectiveMinIcons) cfg+ syncWidgetPool iconsBox iconWidgets paddedWindows buildOne iconContainer updateIconWidget++buildIconWidget :: Bool -> HyprlandWorkspacesConfig -> ReaderT Context IO HyprlandIconWidget+buildIconWidget transparentOnNone cfg = do+ ctx <- ask+ let common = hyprlandWorkspacesCommonConfig cfg+ strategy <- getScalingImageStrategy+ liftIO $+ mkWorkspaceIconWidget+ strategy+ (Just $ iconSize cfg)+ transparentOnNone+ (\size w -> runReaderT (WorkspaceWidgetConfig.getWindowIconPixbuf common size w) ctx)+ (`pixBufFromColor` 0)++updateIconWidget :: HyprlandIconWidget -> Maybe HyprlandWindow -> ReaderT Context IO ()+updateIconWidget iconWidget windowData =+ updateWindowIconWidgetState+ iconWidget+ windowData+ (T.pack . windowTitle)+ getWindowStatusString++getWindowStatusString :: HyprlandWindow -> T.Text+getWindowStatusString windowData =+ windowStatusClassFromFlags+ (windowMinimized windowData)+ (windowActive windowData)+ (windowUrgent windowData)++-- | Sort windows by their top-left corner position.+--+-- This mirrors the X11 Workspaces widget default ('sortWindowsByPosition'),+-- but uses Hyprland's @at@ coordinate from @hyprctl clients -j@.+sortHyprlandWindowsByPosition :: [HyprlandWindow] -> [HyprlandWindow]+sortHyprlandWindowsByPosition =+ sortOn $ \w ->+ ( windowMinimized w,+ fromMaybe (999999999, 999999999) (windowAt w)+ )++wLog :: (MonadIO m) => Priority -> String -> m ()+wLog l s = liftIO $ logM "System.Taffybar.Widget.HyprlandWorkspaces" l s++-- Window icon lookup++scaledWindowIconPixbufGetter ::+ HyprlandWindowIconPixbufGetter -> HyprlandWindowIconPixbufGetter+scaledWindowIconPixbufGetter = scaledPixbufGetter++handleIconGetterException ::+ HyprlandWindowIconPixbufGetter -> HyprlandWindowIconPixbufGetter+handleIconGetterException = handlePixbufGetterException wLog++defaultHyprlandGetWindowIconPixbuf :: HyprlandWindowIconPixbufGetter+defaultHyprlandGetWindowIconPixbuf =+ scaledWindowIconPixbufGetter $+ getWindowIconPixbufFromDesktopEntry <|||> getWindowIconPixbufFromClass++getWindowIconPixbufFromClass :: HyprlandWindowIconPixbufGetter+getWindowIconPixbufFromClass size windowData =+ maybeTCombine+ (maybe (return Nothing) (getCachedWindowIconFromClasses size) (windowClass windowData))+ (maybe (return Nothing) (getCachedWindowIconFromClasses size) (windowInitialClass windowData))++getWindowIconPixbufFromDesktopEntry :: HyprlandWindowIconPixbufGetter+getWindowIconPixbufFromDesktopEntry =+ handleIconGetterException $ \size windowData ->+ maybeTCombine+ (maybe (return Nothing) (getWindowIconFromDesktopEntryByAppId size) (windowClass windowData))+ (maybe (return Nothing) (getWindowIconFromDesktopEntryByAppId size) (windowInitialClass windowData))++getDirectoryEntriesByAppId :: TaffyIO (MM.MultiMap String DesktopEntry)+getDirectoryEntriesByAppId = getStateDefault readDirectoryEntriesByAppId++readDirectoryEntriesByAppId :: TaffyIO (MM.MultiMap String DesktopEntry)+readDirectoryEntriesByAppId =+ liftIO $ indexDesktopEntriesByAppId <$> getDirectoryEntriesDefault++indexDesktopEntriesByAppId :: [DesktopEntry] -> MM.MultiMap String DesktopEntry+indexDesktopEntriesByAppId =+ F.foldl' (\m de -> MM.insert (normalizeAppId $ deFilename de) de m) MM.empty++normalizeAppId :: String -> String+normalizeAppId name =+ let stripped = fromMaybe name (stripSuffix ".desktop" name)+ in map toLower stripped++getWindowIconFromDesktopEntryByAppId ::+ Int32 -> String -> TaffyIO (Maybe Gdk.Pixbuf)+getWindowIconFromDesktopEntryByAppId size appId = do+ entries <- MM.lookup (normalizeAppId appId) <$> getDirectoryEntriesByAppId+ case entries of+ [] -> return Nothing+ (entry : _) -> do+ liftIO $+ logM "System.Taffybar.Widget.HyprlandWorkspaces" DEBUG $+ printf+ "Using desktop entry for icon %s (appId=%s)"+ (deFilename entry)+ appId+ liftIO $ getImageForDesktopEntry size entry++-- Hyprland backend++data HyprlandWorkspaceRef = HyprlandWorkspaceRef+ { hwrId :: Int,+ hwrName :: Text+ }+ deriving (Show, Eq)++instance FromJSON HyprlandWorkspaceRef where+ parseJSON = withObject "HyprlandWorkspaceRef" $ \v ->+ HyprlandWorkspaceRef+ <$> v .: "id"+ <*> v .: "name"++data HyprlandWorkspaceInfo = HyprlandWorkspaceInfo+ { hwiId :: Int,+ hwiName :: Text,+ hwiWindows :: Int+ }+ deriving (Show, Eq)++instance FromJSON HyprlandWorkspaceInfo where+ parseJSON = withObject "HyprlandWorkspaceInfo" $ \v ->+ HyprlandWorkspaceInfo+ <$> v .: "id"+ <*> v .: "name"+ <*> v .:? "windows" .!= 0++data HyprlandMonitorInfo = HyprlandMonitorInfo+ { hmFocused :: Bool,+ hmActiveWorkspace :: Maybe HyprlandWorkspaceRef+ }+ deriving (Show, Eq)++instance FromJSON HyprlandMonitorInfo where+ parseJSON = withObject "HyprlandMonitorInfo" $ \v ->+ HyprlandMonitorInfo+ <$> v .:? "focused" .!= False+ <*> v .:? "activeWorkspace"++data HyprlandClient = HyprlandClient+ { hcAddress :: Text,+ hcTitle :: Text,+ hcInitialTitle :: Maybe Text,+ hcClass :: Maybe Text,+ hcInitialClass :: Maybe Text,+ hcAt :: Maybe (Int, Int),+ hcWorkspace :: HyprlandWorkspaceRef,+ hcFocused :: Bool,+ hcHidden :: Bool,+ hcMapped :: Bool,+ hcUrgent :: Bool+ }+ deriving (Show, Eq)++instance FromJSON HyprlandClient where+ parseJSON = withObject "HyprlandClient" $ \v ->+ HyprlandClient+ <$> v .: "address"+ <*> v .:? "title" .!= ""+ <*> v .:? "initialTitle"+ <*> v .:? "class"+ <*> v .:? "initialClass"+ <*> (vec2FromList <$> v .:? "at")+ <*> v .: "workspace"+ <*> v .:? "focused" .!= False+ <*> v .:? "hidden" .!= False+ <*> v .:? "mapped" .!= True+ <*> v .:? "urgent" .!= False+ where+ vec2FromList :: Maybe [Int] -> Maybe (Int, Int)+ vec2FromList (Just [x, y]) = Just (x, y)+ vec2FromList _ = Nothing++newtype HyprlandActiveWindow = HyprlandActiveWindow+ { hawAddress :: Text+ }+ deriving (Show, Eq)++instance FromJSON HyprlandActiveWindow where+ parseJSON = withObject "HyprlandActiveWindow" $ \v ->+ HyprlandActiveWindow <$> v .: "address"++runHyprctlJson :: (FromJSON a) => [String] -> TaffyIO (Either String a)+runHyprctlJson args = do+ let args' =+ case args of+ ("-j" : rest) -> rest+ _ -> args+ result <- runHyprlandCommandJsonT (Hypr.hyprCommandJson args')+ pure $ case result of+ Left err -> Left (show err)+ Right out -> Right out++runHyprlandCommandRaw :: [String] -> TaffyIO (Either String BS.ByteString)+runHyprlandCommandRaw args = do+ let cmd =+ case args of+ ("-j" : rest) -> Hypr.hyprCommandJson rest+ _ -> Hypr.hyprCommand args+ result <- runHyprlandCommandRawT cmd+ pure $ case result of+ Left err -> Left (show err)+ Right out -> Right out++hyprlandSwitchToWorkspace :: HyprlandWorkspace -> TaffyIO ()+hyprlandSwitchToWorkspace ws = do+ result <- runHyprlandCommandRaw ["dispatch", "workspace", workspaceName ws]+ case result of+ Left err -> wLog WARNING $ printf "Failed to switch workspace: %s" err+ Right _ -> return ()++getHyprlandWorkspaces :: TaffyIO [HyprlandWorkspace]+getHyprlandWorkspaces = do+ workspacesResult <- runHyprctlJson ["-j", "workspaces"]+ clientsResult <- runHyprctlJson ["-j", "clients"]+ monitorsResult <- runHyprctlJson ["-j", "monitors"]+ activeWorkspaceResult <- runHyprctlJson ["-j", "activeworkspace"]+ activeWindowAddress <- getActiveWindowAddress++ workspaces <- case workspacesResult of+ Left err -> wLog WARNING (printf "hyprctl workspaces failed: %s" err) >> return []+ Right ws -> return ws++ let workspacesCount = length workspaces+ workspacesWindowsSum = sum (map hwiWindows workspaces)+ (clientsOk, clients) <- case clientsResult of+ Left err -> do+ wLog WARNING $+ printf+ "hyprctl clients failed: %s (workspaces=%d windowsSum=%d)"+ err+ workspacesCount+ workspacesWindowsSum+ return (False, [])+ Right cs -> return (True, cs)++ monitors <- case monitorsResult of+ Left err -> wLog WARNING (printf "hyprctl monitors failed: %s" err) >> return []+ Right ms -> return ms++ activeWorkspace <- case activeWorkspaceResult of+ Left err -> wLog WARNING (printf "hyprctl activeworkspace failed: %s" err) >> return Nothing+ Right ws -> return $ Just ws++ buildWorkspacesFromHyprland+ workspaces+ clientsOk+ clients+ monitors+ activeWorkspace+ activeWindowAddress++getActiveWindowAddress :: TaffyIO (Maybe Text)+getActiveWindowAddress = do+ result <- runHyprctlJson ["-j", "activewindow"]+ case result of+ Left _ -> return Nothing+ Right activeWindow -> return $ Just $ hawAddress activeWindow++buildWorkspacesFromHyprland ::+ [HyprlandWorkspaceInfo] ->+ Bool ->+ [HyprlandClient] ->+ [HyprlandMonitorInfo] ->+ Maybe HyprlandWorkspaceRef ->+ Maybe Text ->+ TaffyIO [HyprlandWorkspace]+buildWorkspacesFromHyprland workspaces clientsOk clients monitors activeWorkspace activeWindowAddress = do+ let windowsByWorkspace = collectWorkspaceWindows activeWindowAddress clients+ sortedWorkspaces = sortOn hwiId workspaces+ visibleWorkspaceIds =+ map hwrId $ mapMaybe hmActiveWorkspace monitors+ focusedWorkspaceId =+ listToMaybe+ [ hwrId ws+ | m <- monitors,+ hmFocused m,+ Just ws <- [hmActiveWorkspace m]+ ]+ activeWorkspaceId =+ focusedWorkspaceId <|> fmap hwrId activeWorkspace++ return $ map (buildWorkspace windowsByWorkspace visibleWorkspaceIds activeWorkspaceId) sortedWorkspaces+ where+ buildWorkspace windowsMap visibleIds activeId wsInfo =+ let wsId = hwiId wsInfo+ wsName = hwiName wsInfo+ wins = M.findWithDefault [] wsId windowsMap+ hasWindows = not (null wins) || hwiWindows wsInfo > 0+ state+ | Just wsId == activeId = Active+ | wsId `elem` visibleIds = Visible+ -- If we couldn't fetch clients, don't aggressively hide workspaces.+ -- This prevents "invisible" workspaces on transient Hyprland restarts.+ | not hasWindows && clientsOk = Empty+ | otherwise = Hidden+ in HyprlandWorkspace+ { workspaceIdx = wsId,+ workspaceName = T.unpack wsName,+ workspaceState = state,+ windows = wins+ }++collectWorkspaceWindows :: Maybe Text -> [HyprlandClient] -> M.Map Int [HyprlandWindow]+collectWorkspaceWindows activeWindowAddress =+ F.foldl' (addWindow activeWindowAddress) M.empty+ where+ addWindow activeAddr windowsMap client =+ let wsId = hwrId (hcWorkspace client)+ windowData = windowFromClient activeAddr client+ in M.insertWith (++) wsId [windowData] windowsMap++windowFromClient :: Maybe Text -> HyprlandClient -> HyprlandWindow+windowFromClient activeAddr client =+ let titleText =+ if T.null (hcTitle client)+ then fromMaybe "" (hcInitialTitle client)+ else hcTitle client+ active =+ hcFocused client || Just (hcAddress client) == activeAddr+ minimized =+ hcHidden client || not (hcMapped client)+ in HyprlandWindow+ { windowAddress = hcAddress client,+ windowTitle = T.unpack titleText,+ windowClass = T.unpack <$> hcClass client,+ windowInitialClass = T.unpack <$> hcInitialClass client,+ windowAt = hcAt client,+ windowUrgent = hcUrgent client,+ windowActive = active,+ windowMinimized = minimized+ }
+ src/System/Taffybar/Widget/Workspaces/Hyprland/Compat.hs view
@@ -0,0 +1,253 @@+{-# LANGUAGE StrictData #-}+{-# OPTIONS_GHC -Wno-deprecations #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.Workspaces.Hyprland.Compat+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Flat legacy compatibility config for Hyprland workspaces.+--+-- This module is intentionally not re-exported by the umbrella widget+-- modules so that consumers opt in explicitly.+module System.Taffybar.Widget.Workspaces.Hyprland.Compat+ ( HyprlandWorkspacesConfig (..),+ defaultHyprlandWorkspacesConfig,+ hyprlandWorkspacesNew,+ hyprlandWorkspacesCommonConfig,+ modifyCommonHyprlandWorkspacesConfig,+ applyCommonHyprlandWorkspacesConfig,+ toHyprlandWorkspacesConfig,+ fromHyprlandWorkspacesConfig,+ refreshWorkspaces,+ applyUrgentState,+ hyprlandBuildLabelController,+ hyprlandBuildIconController,+ hyprlandBuildContentsController,+ hyprlandBuildLabelOverlayController,+ hyprlandBuildCustomOverlayController,+ hyprlandBuildButtonController,+ defaultHyprlandWidgetBuilder,+ buildIconWidget,+ FlatHyprlandWorkspacesConfig,+ defaultFlatHyprlandWorkspacesConfig,+ fromFlatHyprlandWorkspacesConfig,+ toFlatHyprlandWorkspacesConfig,+ )+where++import Control.Monad.Trans.Reader (ReaderT)+import Data.Default (Default (..))+import Data.Int (Int32)+import qualified GI.Gtk as Gtk+import System.Taffybar.Context (Context, TaffyIO)+import System.Taffybar.Widget.Workspaces.Config+ ( WorkspaceWidgetCommonConfig (WorkspaceWidgetCommonConfig),+ )+import qualified System.Taffybar.Widget.Workspaces.Config as WorkspaceConfig+import qualified System.Taffybar.Widget.Workspaces.Hyprland as Hyprland++data HyprlandWorkspacesConfig+ = HyprlandWorkspacesConfig+ { getWorkspaces :: TaffyIO [Hyprland.HyprlandWorkspace],+ switchToWorkspace :: Hyprland.HyprlandWorkspace -> TaffyIO (),+ updateIntervalSeconds :: Double,+ widgetBuilder :: Hyprland.HyprlandControllerConstructor,+ widgetGap :: Int,+ maxIcons :: Maybe Int,+ minIcons :: Int,+ iconSize :: Int32,+ getWindowIconPixbuf :: Hyprland.HyprlandWindowIconPixbufGetter,+ labelSetter :: Hyprland.HyprlandWorkspace -> TaffyIO String,+ showWorkspaceFn :: Hyprland.HyprlandWorkspace -> Bool,+ iconSort :: [Hyprland.HyprlandWindow] -> TaffyIO [Hyprland.HyprlandWindow],+ urgentWorkspaceState :: Bool+ }++{-# DEPRECATED getWorkspaces "Legacy flat config field. Use `System.Taffybar.Widget.Workspaces.Hyprland.getWorkspaces` on the canonical nested config type instead." #-}++{-# DEPRECATED switchToWorkspace "Legacy flat config field. Use `System.Taffybar.Widget.Workspaces.Hyprland.switchToWorkspace` on the canonical nested config type instead." #-}++{-# DEPRECATED updateIntervalSeconds "Legacy flat config field. Use `System.Taffybar.Widget.Workspaces.Hyprland.updateIntervalSeconds` on the canonical nested config type instead." #-}++{-# DEPRECATED widgetBuilder "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.Hyprland.HyprlandWorkspacesConfig` instead." #-}++{-# DEPRECATED widgetGap "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.Hyprland.HyprlandWorkspacesConfig` instead." #-}++{-# DEPRECATED maxIcons "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.Hyprland.HyprlandWorkspacesConfig` instead." #-}++{-# DEPRECATED minIcons "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.Hyprland.HyprlandWorkspacesConfig` instead." #-}++{-# DEPRECATED iconSize "Legacy flat config field. Use `System.Taffybar.Widget.Workspaces.Hyprland.iconSize` on the canonical nested config type instead." #-}++{-# DEPRECATED getWindowIconPixbuf "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.Hyprland.HyprlandWorkspacesConfig` instead." #-}++{-# DEPRECATED labelSetter "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.Hyprland.HyprlandWorkspacesConfig` instead." #-}++{-# DEPRECATED showWorkspaceFn "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.Hyprland.HyprlandWorkspacesConfig` instead." #-}++{-# DEPRECATED iconSort "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.Hyprland.HyprlandWorkspacesConfig` instead." #-}++{-# DEPRECATED urgentWorkspaceState "Legacy flat config field. Use the nested `workspacesConfig` field in `System.Taffybar.Widget.Workspaces.Hyprland.HyprlandWorkspacesConfig` instead." #-}++toHyprlandWorkspacesConfig ::+ HyprlandWorkspacesConfig -> Hyprland.HyprlandWorkspacesConfig+toHyprlandWorkspacesConfig flat =+ Hyprland.HyprlandWorkspacesConfig+ { Hyprland.getWorkspaces = getWorkspaces flat,+ Hyprland.switchToWorkspace = switchToWorkspace flat,+ Hyprland.updateIntervalSeconds = updateIntervalSeconds flat,+ Hyprland.iconSize = iconSize flat,+ Hyprland.workspacesConfig =+ WorkspaceWidgetCommonConfig+ { WorkspaceConfig.widgetBuilder = widgetBuilder flat,+ WorkspaceConfig.widgetGap = widgetGap flat,+ WorkspaceConfig.maxIcons = maxIcons flat,+ WorkspaceConfig.minIcons = minIcons flat,+ WorkspaceConfig.getWindowIconPixbuf = getWindowIconPixbuf flat,+ WorkspaceConfig.labelSetter = labelSetter flat,+ WorkspaceConfig.showWorkspaceFn = showWorkspaceFn flat,+ WorkspaceConfig.iconSort = iconSort flat,+ WorkspaceConfig.urgentWorkspaceState = urgentWorkspaceState flat+ }+ }++fromHyprlandWorkspacesConfig ::+ Hyprland.HyprlandWorkspacesConfig -> HyprlandWorkspacesConfig+fromHyprlandWorkspacesConfig cfg =+ let common = Hyprland.workspacesConfig cfg+ in HyprlandWorkspacesConfig+ { getWorkspaces = Hyprland.getWorkspaces cfg,+ switchToWorkspace = Hyprland.switchToWorkspace cfg,+ updateIntervalSeconds = Hyprland.updateIntervalSeconds cfg,+ widgetBuilder = WorkspaceConfig.widgetBuilder common,+ widgetGap = WorkspaceConfig.widgetGap common,+ maxIcons = WorkspaceConfig.maxIcons common,+ minIcons = WorkspaceConfig.minIcons common,+ iconSize = Hyprland.iconSize cfg,+ getWindowIconPixbuf = WorkspaceConfig.getWindowIconPixbuf common,+ labelSetter = WorkspaceConfig.labelSetter common,+ showWorkspaceFn = WorkspaceConfig.showWorkspaceFn common,+ iconSort = WorkspaceConfig.iconSort common,+ urgentWorkspaceState = WorkspaceConfig.urgentWorkspaceState common+ }++defaultHyprlandWorkspacesConfig :: HyprlandWorkspacesConfig+defaultHyprlandWorkspacesConfig =+ fromHyprlandWorkspacesConfig Hyprland.defaultHyprlandWorkspacesConfig++hyprlandWorkspacesNew :: HyprlandWorkspacesConfig -> TaffyIO Gtk.Widget+hyprlandWorkspacesNew =+ Hyprland.hyprlandWorkspacesNew . toHyprlandWorkspacesConfig++hyprlandWorkspacesCommonConfig ::+ HyprlandWorkspacesConfig ->+ WorkspaceWidgetCommonConfig (ReaderT Context IO) Hyprland.HyprlandWorkspace Hyprland.HyprlandWindow Hyprland.HyprlandWWC+hyprlandWorkspacesCommonConfig =+ Hyprland.workspacesConfig . toHyprlandWorkspacesConfig++-- | Modify the nested common config inside a legacy flat Hyprland workspaces+-- config.+--+-- Prefer this over defining @common@ in terms of a recursively-defined @cfg@,+-- which can accidentally create a black-hole and hang at runtime.+modifyCommonHyprlandWorkspacesConfig ::+ ( WorkspaceWidgetCommonConfig (ReaderT Context IO) Hyprland.HyprlandWorkspace Hyprland.HyprlandWindow Hyprland.HyprlandWWC ->+ WorkspaceWidgetCommonConfig (ReaderT Context IO) Hyprland.HyprlandWorkspace Hyprland.HyprlandWindow Hyprland.HyprlandWWC+ ) ->+ HyprlandWorkspacesConfig ->+ HyprlandWorkspacesConfig+modifyCommonHyprlandWorkspacesConfig f cfg =+ applyCommonHyprlandWorkspacesConfig (f (hyprlandWorkspacesCommonConfig cfg)) cfg++applyCommonHyprlandWorkspacesConfig ::+ WorkspaceWidgetCommonConfig (ReaderT Context IO) Hyprland.HyprlandWorkspace Hyprland.HyprlandWindow Hyprland.HyprlandWWC ->+ HyprlandWorkspacesConfig ->+ HyprlandWorkspacesConfig+applyCommonHyprlandWorkspacesConfig common cfg =+ fromHyprlandWorkspacesConfig $+ Hyprland.applyCommonHyprlandWorkspacesConfig common (toHyprlandWorkspacesConfig cfg)++refreshWorkspaces ::+ HyprlandWorkspacesConfig -> Gtk.Box -> ReaderT Context IO ()+refreshWorkspaces cfg =+ Hyprland.refreshWorkspaces (toHyprlandWorkspacesConfig cfg)++applyUrgentState ::+ HyprlandWorkspacesConfig ->+ Hyprland.HyprlandWorkspace ->+ Hyprland.HyprlandWorkspace+applyUrgentState cfg =+ Hyprland.applyUrgentState (toHyprlandWorkspacesConfig cfg)++hyprlandBuildLabelController ::+ HyprlandWorkspacesConfig -> Hyprland.HyprlandControllerConstructor+hyprlandBuildLabelController =+ Hyprland.hyprlandBuildLabelController . toHyprlandWorkspacesConfig++hyprlandBuildIconController ::+ HyprlandWorkspacesConfig -> Hyprland.HyprlandControllerConstructor+hyprlandBuildIconController =+ Hyprland.hyprlandBuildIconController . toHyprlandWorkspacesConfig++hyprlandBuildContentsController ::+ [Hyprland.HyprlandControllerConstructor] -> Hyprland.HyprlandControllerConstructor+hyprlandBuildContentsController =+ Hyprland.hyprlandBuildContentsController++hyprlandBuildLabelOverlayController ::+ HyprlandWorkspacesConfig ->+ Hyprland.HyprlandControllerConstructor+hyprlandBuildLabelOverlayController =+ Hyprland.hyprlandBuildLabelOverlayController . toHyprlandWorkspacesConfig++hyprlandBuildCustomOverlayController ::+ (Gtk.Widget -> Gtk.Widget -> TaffyIO Gtk.Widget) ->+ HyprlandWorkspacesConfig ->+ Hyprland.HyprlandControllerConstructor+hyprlandBuildCustomOverlayController overlay cfg =+ Hyprland.hyprlandBuildCustomOverlayController+ overlay+ (toHyprlandWorkspacesConfig cfg)++hyprlandBuildButtonController ::+ HyprlandWorkspacesConfig ->+ Hyprland.HyprlandParentControllerConstructor+hyprlandBuildButtonController cfg =+ Hyprland.hyprlandBuildButtonController (toHyprlandWorkspacesConfig cfg)++defaultHyprlandWidgetBuilder ::+ HyprlandWorkspacesConfig -> Hyprland.HyprlandControllerConstructor+defaultHyprlandWidgetBuilder =+ Hyprland.defaultHyprlandWidgetBuilder . toHyprlandWorkspacesConfig++buildIconWidget ::+ Bool ->+ HyprlandWorkspacesConfig ->+ ReaderT Context IO Hyprland.HyprlandIconWidget+buildIconWidget transparentOnNone cfg =+ Hyprland.buildIconWidget transparentOnNone (toHyprlandWorkspacesConfig cfg)++type FlatHyprlandWorkspacesConfig = HyprlandWorkspacesConfig++defaultFlatHyprlandWorkspacesConfig :: FlatHyprlandWorkspacesConfig+defaultFlatHyprlandWorkspacesConfig = defaultHyprlandWorkspacesConfig++fromFlatHyprlandWorkspacesConfig ::+ FlatHyprlandWorkspacesConfig -> Hyprland.HyprlandWorkspacesConfig+fromFlatHyprlandWorkspacesConfig = toHyprlandWorkspacesConfig++toFlatHyprlandWorkspacesConfig ::+ Hyprland.HyprlandWorkspacesConfig -> FlatHyprlandWorkspacesConfig+toFlatHyprlandWorkspacesConfig = fromHyprlandWorkspacesConfig++instance Default HyprlandWorkspacesConfig where+ def = defaultHyprlandWorkspacesConfig
@@ -1,6 +1,9 @@ {-# LANGUAGE OverloadedStrings #-} -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.Workspaces.Shared -- Copyright : (c) Ivan A. Malison@@ -11,37 +14,33 @@ -- Portability : unportable -- -- Shared UI helpers for workspace widgets (X11/EWMH and Hyprland).---------------------------------------------------------------------------------- module System.Taffybar.Widget.Workspaces.Shared- ( WorkspaceState(..)- , getCSSClass- , cssWorkspaceStates- , setWorkspaceWidgetStatusClass- , buildWorkspaceIconLabelOverlay- , mkWorkspaceIconWidget- ) where+ ( WorkspaceState (..),+ getCSSClass,+ cssWorkspaceStates,+ setWorkspaceWidgetStatusClass,+ buildWorkspaceIconLabelOverlay,+ mkWorkspaceIconWidget,+ )+where import qualified Control.Concurrent.MVar as MV-import Control.Monad-import Control.Monad.IO.Class (MonadIO(..))-import Data.Int (Int32)+import Control.Monad+import Control.Monad.IO.Class (MonadIO (..))+import Data.Int (Int32) import qualified Data.Text as T- import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk import qualified GI.Gtk as Gtk--import System.Taffybar.Widget.Generic.AutoSizeImage (ImageScaleStrategy)-import System.Taffybar.Widget.Generic.ScalingImage (scalingImageNew)-import System.Taffybar.Widget.Util- ( WindowIconWidget(..)- , buildBottomLeftAlignedBox- , buildContentsBox- , buildOverlayWithPassThrough- , mkWindowIconWidgetBase- , updateWidgetClasses- , widgetSetClassGI+import System.Taffybar.Widget.Generic.AutoSizeImage (ImageScaleStrategy)+import System.Taffybar.Widget.Generic.ScalingImage (scalingImageNew)+import System.Taffybar.Widget.Util+ ( WindowIconWidget (..),+ buildBottomLeftAlignedBox,+ buildContentsBox,+ buildOverlayWithPassThrough,+ mkWindowIconWidgetBase,+ updateWidgetClasses,+ widgetSetClassGI, ) data WorkspaceState@@ -70,9 +69,11 @@ -- window icons are the base content and the workspace label is overlaid in the -- bottom-left corner. buildWorkspaceIconLabelOverlay ::- MonadIO m =>- Gtk.Widget -> -- ^ Widget containing the window icon strip.- Gtk.Widget -> -- ^ Workspace label widget.+ (MonadIO m) =>+ -- | Widget containing the window icon strip.+ Gtk.Widget ->+ -- | Workspace label widget.+ Gtk.Widget -> m Gtk.Widget buildWorkspaceIconLabelOverlay iconsWidget labelWidget = do base <- buildContentsBox iconsWidget@@ -85,11 +86,16 @@ -- This is shared by both X11 and Hyprland workspace widgets so that CSS classes -- and widget behavior remain consistent across backends. mkWorkspaceIconWidget ::- ImageScaleStrategy -> -- ^ Which scaling implementation to use.- Maybe Int32 -> -- ^ Optional size request for the icon image.- Bool -> -- ^ Whether to render a transparent placeholder when there is no data.- (Int32 -> a -> IO (Maybe Gdk.Pixbuf)) -> -- ^ Icon pixbuf getter.- (Int32 -> IO Gdk.Pixbuf) -> -- ^ Transparent placeholder pixbuf generator.+ -- | Which scaling implementation to use.+ ImageScaleStrategy ->+ -- | Optional size request for the icon image.+ Maybe Int32 ->+ -- | Whether to render a transparent placeholder when there is no data.+ Bool ->+ -- | Icon pixbuf getter.+ (Int32 -> a -> IO (Maybe Gdk.Pixbuf)) ->+ -- | Transparent placeholder pixbuf generator.+ (Int32 -> IO Gdk.Pixbuf) -> IO (WindowIconWidget a) mkWorkspaceIconWidget strategy mSize transparentOnNone getPixbufFor mkTransparent = do base <- mkWindowIconWidgetBase mSize@@ -114,5 +120,4 @@ forM_ mSize $ \s -> Gtk.widgetSetSizeRequest imageWidget (fromIntegral s) (fromIntegral s) Gtk.containerAdd (iconContainer base) imageWidget- return base { iconImage = imageWidget, iconForceUpdate = refreshImage }-+ return base {iconImage = imageWidget, iconForceUpdate = refreshImage}
+ src/System/Taffybar/Widget/Workspaces/X11.hs view
@@ -0,0 +1,18 @@+-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Widget.Workspaces.X11+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+module System.Taffybar.Widget.Workspaces.X11+ ( module System.Taffybar.Widget.Workspaces.EWMH,+ )+where++import System.Taffybar.Widget.Workspaces.EWMH
src/System/Taffybar/Widget/WttrIn.hs view
@@ -41,16 +41,17 @@ -- > -- seconds. -- > textWttrNew "http://wttr.in/London?format=3" 60 textWttrNew ::- MonadIO m =>+ (MonadIO m) => -- | URL. All non-alphanumeric characters must be properly %-encoded. String -> -- | Update Interval (in seconds) Double -> m Widget textWttrNew url interval = pollingLabelWithVariableDelayAndRefresh action True- where action = do- rsp <- callWttr url- return (rsp, Nothing, interval)+ where+ action = do+ rsp <- callWttr url+ return (rsp, Nothing, interval) -- | IO Action that calls wttr.in as per the user's request. callWttr :: String -> IO T.Text
src/System/Taffybar/Widget/XDGMenu/Menu.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.XDGMenu.Menu -- Copyright : 2017 Ulf Jasper@@ -13,14 +16,13 @@ -- https://specifications.freedesktop.org/menu-spec/menu-spec-1.1.html -- -- See also 'MenuWidget'.------------------------------------------------------------------------------- module System.Taffybar.Widget.XDGMenu.Menu- ( Menu(..)- , MenuEntry(..)- , buildMenu- , getApplicationEntries- ) where+ ( Menu (..),+ MenuEntry (..),+ buildMenu,+ getApplicationEntries,+ )+where import Data.Char (toLower) import Data.List@@ -31,21 +33,23 @@ -- | Displayable menu data Menu = Menu- { fmName :: String- , fmComment :: String- , fmIcon :: Maybe String- , fmSubmenus :: [Menu]- , fmEntries :: [MenuEntry]- , fmOnlyUnallocated :: Bool- } deriving (Eq, Show)+ { fmName :: String,+ fmComment :: String,+ fmIcon :: Maybe String,+ fmSubmenus :: [Menu],+ fmEntries :: [MenuEntry],+ fmOnlyUnallocated :: Bool+ }+ deriving (Eq, Show) -- | Displayable menu entry data MenuEntry = MenuEntry- { feName :: T.Text- , feComment :: T.Text- , feCommand :: String- , feIcon :: Maybe T.Text- } deriving (Eq, Show)+ { feName :: T.Text,+ feComment :: T.Text,+ feCommand :: String,+ feIcon :: Maybe T.Text+ }+ deriving (Eq, Show) -- | Fetch menus and desktop entries and assemble the menu. buildMenu :: Maybe String -> IO Menu@@ -62,53 +66,68 @@ return fm' -- | Convert xdg menu to displayable menu-xdgToMenu- :: String- -> [String]- -> [FilePath]- -> [DesktopEntry]- -> XDGMenu- -> IO (Menu, [MenuEntry])+xdgToMenu ::+ String ->+ [String] ->+ [FilePath] ->+ [DesktopEntry] ->+ XDGMenu ->+ IO (Menu, [MenuEntry]) xdgToMenu desktop langs dirDirs des xm = do dirEntry <- getDirectoryEntry dirDirs (xmDirectory xm) mas <- mapM (xdgToMenu desktop langs dirDirs des) (xmSubmenus xm) let (menus, subaes) = unzip mas- menus' = sortBy (\fm1 fm2 -> compare (map toLower $ fmName fm1)- (map toLower $ fmName fm2)) menus- entries = map (xdgToMenuEntry langs) $- -- hide NoDisplay- filter (not . deNoDisplay) $- -- onlyshowin- filter (matchesOnlyShowIn desktop) $- -- excludes- filter (not . flip matchesCondition (fromMaybe None (xmExclude xm))) $+ menus' =+ sortBy+ ( \fm1 fm2 ->+ compare+ (map toLower $ fmName fm1)+ (map toLower $ fmName fm2)+ )+ menus+ entries =+ map (xdgToMenuEntry langs) $+ -- hide NoDisplay+ filter (not . deNoDisplay) $+ -- onlyshowin+ filter (matchesOnlyShowIn desktop) $+ -- excludes+ filter (not . flip matchesCondition (fromMaybe None (xmExclude xm))) $ -- includes filter (`matchesCondition` fromMaybe None (xmInclude xm)) des onlyUnallocated = xmOnlyUnallocated xm aes = if onlyUnallocated then [] else entries ++ concat subaes- let fm = Menu {fmName = maybe (xmName xm) (deName langs) dirEntry,- fmComment = maybe "???" (fromMaybe "???" . deComment langs) dirEntry,- fmIcon = deIcon =<< dirEntry,- fmSubmenus = menus',- fmEntries = entries,- fmOnlyUnallocated = onlyUnallocated}+ let fm =+ Menu+ { fmName = maybe (xmName xm) (deName langs) dirEntry,+ fmComment = maybe "???" (fromMaybe "???" . deComment langs) dirEntry,+ fmIcon = deIcon =<< dirEntry,+ fmSubmenus = menus',+ fmEntries = entries,+ fmOnlyUnallocated = onlyUnallocated+ } return (fm, aes) -- | Check the "only show in" logic matchesOnlyShowIn :: String -> DesktopEntry -> Bool matchesOnlyShowIn desktop de = matchesShowIn && notMatchesNotShowIn- where matchesShowIn = case deOnlyShowIn de of- [] -> True- desktops -> desktop `elem` desktops- notMatchesNotShowIn = case deNotShowIn de of- [] -> True- desktops -> desktop `notElem` desktops+ where+ matchesShowIn = case deOnlyShowIn de of+ [] -> True+ desktops -> desktop `elem` desktops+ notMatchesNotShowIn = case deNotShowIn de of+ [] -> True+ desktops -> desktop `notElem` desktops -- | convert xdg desktop entry to displayble menu entry xdgToMenuEntry :: [String] -> DesktopEntry -> MenuEntry xdgToMenuEntry langs de = MenuEntry- {feName = name, feComment = comment, feCommand = cmd, feIcon = mIcon}+ { feName = name,+ feComment = comment,+ feCommand = cmd,+ feIcon = mIcon+ } where mc = case deCommand de of@@ -116,10 +135,10 @@ Just c -> Just $ "(" ++ c ++ ")" comment = T.pack $- fromMaybe "??" $- case deComment langs de of- Nothing -> mc- Just tt -> Just $ tt ++ maybe "" ("\n" ++) mc+ fromMaybe "??" $+ case deComment langs de of+ Nothing -> mc+ Just tt -> Just $ tt ++ maybe "" ("\n" ++) mc cmd = fromMaybe "FIXME" $ deCommand de name = T.pack $ deName langs de mIcon = T.pack <$> deIcon de@@ -128,9 +147,9 @@ fixOnlyUnallocated :: [MenuEntry] -> Menu -> Menu fixOnlyUnallocated fes fm = fm- { fmEntries = entries- , fmSubmenus = map (fixOnlyUnallocated fes) (fmSubmenus fm)- }+ { fmEntries = entries,+ fmSubmenus = map (fixOnlyUnallocated fes) (fmSubmenus fm)+ } where entries = if fmOnlyUnallocated fm
src/System/Taffybar/Widget/XDGMenu/MenuWidget.hs view
@@ -1,4 +1,7 @@ -----------------------------------------------------------------------------++-----------------------------------------------------------------------------+ -- | -- Module : System.Taffybar.Widget.XDGMenu.MenuWidget -- Copyright : 2017 Ulf Jasper@@ -13,25 +16,22 @@ -- according to the version 1.1 of the XDG "Desktop Menu -- Specification", see -- https://specifications.freedesktop.org/menu-spec/menu-spec-1.1.html------------------------------------------------------------------------------- module System.Taffybar.Widget.XDGMenu.MenuWidget- (- -- * Usage- -- $usage- menuWidgetNew+ ( -- * Usage+ -- $usage+ menuWidgetNew, ) where -import Control.Monad-import Control.Monad.IO.Class+import Control.Monad+import Control.Monad.IO.Class import qualified Data.Text as T-import GI.Gtk hiding (Menu, imageMenuItemNew)-import System.Log.Logger-import System.Process-import System.Taffybar.Widget.Generic.AutoSizeImage-import System.Taffybar.Widget.Util-import System.Taffybar.Widget.XDGMenu.Menu+import GI.Gtk hiding (Menu, imageMenuItemNew)+import System.Log.Logger+import System.Process+import System.Taffybar.Widget.Generic.AutoSizeImage+import System.Taffybar.Widget.Util+import System.Taffybar.Widget.XDGMenu.Menu -- $usage --@@ -60,10 +60,13 @@ logHere = logM "System.Taffybar.Widget.XDGMenu.MenuWidget" -- | Add a desktop entry to a gtk menu by appending a gtk menu item.-addItem :: (IsMenuShell msc) =>- msc -- ^ GTK menu- -> MenuEntry -- ^ Desktop entry- -> IO ()+addItem ::+ (IsMenuShell msc) =>+ -- | GTK menu+ msc ->+ -- | Desktop entry+ MenuEntry ->+ IO () addItem ms de = do item <- imageMenuItemNew (feName de) (getImageForMaybeIconName (feIcon de)) setWidgetTooltipText item (feComment de)@@ -76,17 +79,21 @@ return () -- | Add an xdg menu to a gtk menu by appending gtk menu items and submenus.-addMenu- :: (IsMenuShell msc)- => msc -- ^ A GTK menu- -> Menu -- ^ A menu object- -> IO ()+addMenu ::+ (IsMenuShell msc) =>+ -- | A GTK menu+ msc ->+ -- | A menu object+ Menu ->+ IO () addMenu ms fm = do let subMenus = fmSubmenus fm items = fmEntries fm when (not (null items) || not (null subMenus)) $ do- item <- imageMenuItemNew (T.pack $ fmName fm)- (getImageForMaybeIconName (T.pack <$> fmIcon fm))+ item <-+ imageMenuItemNew+ (T.pack $ fmName fm)+ (getImageForMaybeIconName (T.pack <$> fmIcon fm)) menuShellAppend ms item subMenu <- menuNew menuItemSetSubmenu item (Just subMenu)@@ -94,10 +101,11 @@ mapM_ (addItem subMenu) items -- | Create a new XDG Menu Widget.-menuWidgetNew- :: MonadIO m- => Maybe String -- ^ menu name, must end with a dash, e.g. "mate-" or "gnome-"- -> m GI.Gtk.Widget+menuWidgetNew ::+ (MonadIO m) =>+ -- | menu name, must end with a dash, e.g. "mate-" or "gnome-"+ Maybe String ->+ m GI.Gtk.Widget menuWidgetNew mMenuPrefix = liftIO $ do mb <- menuBarNew m <- buildMenu mMenuPrefix
+ src/System/Taffybar/Window/FocusedMonitor.hs view
@@ -0,0 +1,133 @@+{-# LANGUAGE OverloadedStrings #-}++-----------------------------------------------------------------------------++-----------------------------------------------------------------------------++-- |+-- Module : System.Taffybar.Window.FocusedMonitor+-- Copyright : (c) Ivan A. Malison+-- License : BSD3-style (see LICENSE)+--+-- Maintainer : Ivan A. Malison+-- Stability : unstable+-- Portability : unportable+--+-- Focused-monitor CSS class updates for bar windows.+module System.Taffybar.Window.FocusedMonitor+ ( FocusedMonitorHooks (..),+ setupFocusedMonitorClassUpdates,+ )+where++import Control.Concurrent (forkIO, killThread)+import Control.Concurrent.STM.TChan (TChan, readTChan)+import Control.Monad (forever, when)+import Control.Monad.STM (atomically)+import Data.Int (Int32)+import qualified Data.Text as T+import Data.Unique (Unique)+import qualified GI.Gdk as Gdk+import qualified GI.Gtk as Gtk+import System.Taffybar.Util (postGUIASync)+import System.Taffybar.Widget.Util (updateWidgetClasses)++data FocusedMonitorHooks+ = FocusedMonitorHooksX11+ { resolveFocusedMonitorX11 :: IO (Maybe Gdk.Monitor),+ subscribeToFocusedMonitorX11Events :: IO () -> IO Unique,+ unsubscribeFromFocusedMonitorX11Events :: Unique -> IO ()+ }+ | FocusedMonitorHooksWayland+ { resolveFocusedMonitorWayland :: IO (Maybe Gdk.Monitor),+ getFocusedMonitorHyprlandEvents :: IO (TChan T.Text)+ }++focusedMonitorClasses :: [T.Text]+focusedMonitorClasses = ["focused-monitor", "unfocused-monitor"]++focusedMonitorClass :: Bool -> T.Text+focusedMonitorClass True = "focused-monitor"+focusedMonitorClass False = "unfocused-monitor"++getBarMonitor :: Gtk.Window -> Maybe Int32 -> IO (Maybe Gdk.Monitor)+getBarMonitor window maybeMonitorNumber = do+ display <- Gtk.widgetGetDisplay window+ maybe+ (Gdk.displayGetPrimaryMonitor display)+ (Gdk.displayGetMonitor display)+ maybeMonitorNumber++monitorsMatch :: Gdk.Monitor -> Gdk.Monitor -> IO Bool+monitorsMatch left right = do+ leftGeometry <- Gdk.getMonitorGeometry left+ rightGeometry <- Gdk.getMonitorGeometry right+ case (leftGeometry, rightGeometry) of+ (Nothing, Nothing) -> return True+ (Just leftRect, Just rightRect) -> Gdk.rectangleEqual leftRect rightRect+ _ -> return False++updateFocusedMonitorClass ::+ IO (Maybe Gdk.Monitor) ->+ Gtk.Window ->+ Maybe Int32 ->+ IO ()+updateFocusedMonitorClass resolveFocusedMonitor window maybeBarMonitorNumber = do+ maybeFocusedMonitor <- resolveFocusedMonitor+ maybeBarMonitor <- getBarMonitor window maybeBarMonitorNumber+ isFocused <- case (maybeFocusedMonitor, maybeBarMonitor) of+ (Just focusedMonitor, Just barMonitor) ->+ monitorsMatch focusedMonitor barMonitor+ _ -> return False+ updateWidgetClasses+ window+ [focusedMonitorClass isFocused]+ focusedMonitorClasses++isRelevantFocusedMonitorHyprlandEvent :: T.Text -> Bool+isRelevantFocusedMonitorHyprlandEvent eventLine =+ let hyprEventName = T.takeWhile (/= '>') eventLine+ in hyprEventName+ `elem` [ "workspace",+ "workspacev2",+ "focusedmon",+ "activewindow",+ "activewindowv2",+ "monitoradded",+ "monitorremoved",+ "taffybar-hyprland-connected"+ ]++setupFocusedMonitorClassUpdates ::+ FocusedMonitorHooks ->+ Gtk.Window ->+ Maybe Int32 ->+ IO ()+setupFocusedMonitorClassUpdates hooks window maybeBarMonitorNumber = do+ let resolveFocusedMonitor = case hooks of+ FocusedMonitorHooksX11 {resolveFocusedMonitorX11 = resolve} -> resolve+ FocusedMonitorHooksWayland {resolveFocusedMonitorWayland = resolve} -> resolve+ refresh =+ postGUIASync $+ updateFocusedMonitorClass+ resolveFocusedMonitor+ window+ maybeBarMonitorNumber+ case hooks of+ FocusedMonitorHooksX11+ { subscribeToFocusedMonitorX11Events = subscribe,+ unsubscribeFromFocusedMonitorX11Events = unsubscribe+ } -> do+ subscription <- subscribe refresh+ _ <- Gtk.onWidgetUnrealize window $ unsubscribe subscription+ return ()+ FocusedMonitorHooksWayland+ { getFocusedMonitorHyprlandEvents = getEvents+ } -> do+ events <- getEvents+ tid <- forkIO $ forever $ do+ eventLine <- atomically $ readTChan events+ when (isRelevantFocusedMonitorHyprlandEvent eventLine) refresh+ _ <- Gtk.onWidgetUnrealize window $ killThread tid+ return ()+ refresh
src/System/Taffybar/WindowIcon.hs view
@@ -1,44 +1,129 @@+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE LambdaCase #-}++-- | Helpers for resolving and constructing window icons from EWMH metadata,+-- desktop entries, icon themes, and browser tab image hooks. module System.Taffybar.WindowIcon where -import Control.Concurrent-import Control.Monad-import Control.Monad.IO.Class-import Control.Monad.Trans.Class-import Control.Monad.Trans.Maybe-import Data.Bits-import Data.Int-import Data.List+import Control.Concurrent+import qualified Control.Concurrent.MVar as MV+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Reader (ask, runReaderT)+import Control.Monad.Trans.Class+import Control.Monad.Trans.Maybe+import Data.Bits+import Data.Data (Typeable)+import Data.Int+import Data.List import qualified Data.Map as M-import Data.Maybe+import Data.Maybe import qualified Data.MultiMap as MM-import Data.Ord+import Data.Ord import qualified Data.Text as T-import Data.Word-import Foreign.Marshal.Alloc-import Foreign.Marshal.Array-import Foreign.Ptr-import Foreign.Storable+import Data.Word+import Foreign.Marshal.Alloc+import Foreign.Marshal.Array+import Foreign.Ptr+import Foreign.Storable import qualified GI.GdkPixbuf.Enums as Gdk import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk-import System.Log.Logger-import System.Taffybar.Context-import System.Taffybar.Hooks-import System.Taffybar.Information.Chrome-import System.Taffybar.Information.EWMHDesktopInfo-import System.Taffybar.Information.X11DesktopInfo-import System.Environment.XDG.DesktopEntry-import System.Taffybar.Util-import System.Taffybar.Widget.Util-import Text.Printf+import qualified GI.Gtk as Gtk+import System.Environment.XDG.DesktopEntry+import System.Log.Logger+import System.Taffybar.Context+import System.Taffybar.Hooks+import System.Taffybar.Information.Chrome+import System.Taffybar.Information.EWMHDesktopInfo+import System.Taffybar.Information.SafeX11 (Event (PropertyEvent))+import System.Taffybar.Information.X11DesktopInfo+import System.Taffybar.Util+import System.Taffybar.Widget.Util+import Text.Printf +-- | Packed 32-bit RGBA color value used by 'pixBufFromColor'. type ColorRGBA = Word32 +data WindowIconCacheKey+ = DesktopEntryIconCacheKey Int32 String+ | ClassIconCacheKey Int32 String+ | EWMHIconCacheKey Int32 X11Window+ deriving (Eq, Ord, Show)++newtype WindowIconCache = WindowIconCache+ { windowIconCacheEntries :: MV.MVar (M.Map WindowIconCacheKey (Maybe Gdk.Pixbuf))+ }+ deriving (Typeable)++getWindowIconCache :: TaffyIO WindowIconCache+getWindowIconCache = getStateDefault buildWindowIconCache++buildWindowIconCache :: TaffyIO WindowIconCache+buildWindowIconCache = do+ cacheEntries <- liftIO $ MV.newMVar M.empty+ let cache = WindowIconCache {windowIconCacheEntries = cacheEntries}+ context <- ask+ iconTheme <- liftIO Gtk.iconThemeGetDefault+ _ <- liftIO $ Gtk.onIconThemeChanged iconTheme $ do+ invalidateThemeWindowIconCacheEntries cacheEntries+ void $ runReaderT refreshTaffyWindows context+ _ <-+ subscribeToPropertyEvents [ewmhWMIcon] $ \case+ PropertyEvent _ _ _ _ windowId' _ _ _ ->+ liftIO $ invalidateEWMHWindowIconCacheEntriesForWindow cacheEntries windowId'+ _ -> return ()+ return cache++getCachedWindowIcon ::+ WindowIconCacheKey ->+ TaffyIO (Maybe Gdk.Pixbuf) ->+ TaffyIO (Maybe Gdk.Pixbuf)+getCachedWindowIcon cacheKey buildIcon = do+ WindowIconCache {windowIconCacheEntries = entriesVar} <- getWindowIconCache+ cached <- liftIO $ M.lookup cacheKey <$> MV.readMVar entriesVar+ case cached of+ Just icon -> return icon+ Nothing -> do+ icon <- buildIcon+ liftIO $ MV.modifyMVar_ entriesVar (return . M.insert cacheKey icon)+ return icon++isThemeIconCacheKey :: WindowIconCacheKey -> Bool+isThemeIconCacheKey (DesktopEntryIconCacheKey _ _) = True+isThemeIconCacheKey (ClassIconCacheKey _ _) = True+isThemeIconCacheKey (EWMHIconCacheKey _ _) = False++invalidateThemeWindowIconCacheEntries ::+ MV.MVar (M.Map WindowIconCacheKey (Maybe Gdk.Pixbuf)) ->+ IO ()+invalidateThemeWindowIconCacheEntries entriesVar =+ MV.modifyMVar_ entriesVar $+ return . M.filterWithKey (\k _ -> not (isThemeIconCacheKey k))++invalidateEWMHWindowIconCacheEntriesForWindow ::+ MV.MVar (M.Map WindowIconCacheKey (Maybe Gdk.Pixbuf)) ->+ X11Window ->+ IO ()+invalidateEWMHWindowIconCacheEntriesForWindow entriesVar changedWindow =+ MV.modifyMVar_ entriesVar $+ return . M.filterWithKey keepEntry+ where+ keepEntry (EWMHIconCacheKey _ cachedWindow) _ = cachedWindow /= changedWindow+ keepEntry _ _ = True++invalidateWindowIconCacheForWindow ::+ X11Window ->+ TaffyIO ()+invalidateWindowIconCacheForWindow windowId' = do+ WindowIconCache {windowIconCacheEntries = entriesVar} <- getWindowIconCache+ liftIO $ invalidateEWMHWindowIconCacheEntriesForWindow entriesVar windowId'+ -- | Convert a C array of integer pixels in the ARGB format to the ABGR format. -- Returns an unmanged Ptr that points to a block of memory that must be freed -- manually.-pixelsARGBToBytesABGR- :: (Storable a, Bits a, Num a, Integral a)- => Ptr a -> Int -> IO (Ptr Word8)+pixelsARGBToBytesABGR ::+ (Storable a, Bits a, Num a, Integral a) =>+ Ptr a -> Int -> IO (Ptr Word8) pixelsARGBToBytesABGR ptr size = do target <- mallocArray (size * 4) let writeIndex i = do@@ -60,6 +145,7 @@ writeIndexAndNext 0 return target +-- | Pick the best icon candidate for a target size from EWMH icon variants. selectEWMHIcon :: Int32 -> [EWMHIcon] -> Maybe EWMHIcon selectEWMHIcon imgSize icons = listToMaybe prefIcon where@@ -69,6 +155,7 @@ largestIcon = take 1 $ reverse sortedIcons prefIcon = smallestLargerIcon ++ largestIcon +-- | Create a pixbuf from the best matching EWMH icon for the requested size. getPixbufFromEWMHIcons :: Int32 -> [EWMHIcon] -> IO (Maybe Gdk.Pixbuf) getPixbufFromEWMHIcons size = traverse pixBufFromEWMHIcon . selectEWMHIcon size @@ -79,60 +166,104 @@ height = fromIntegral h rowStride = width * 4 wPtr <- pixelsARGBToBytesABGR px (w * h)- Gdk.pixbufNewFromData wPtr Gdk.ColorspaceRgb True 8- width height rowStride (Just free)+ Gdk.pixbufNewFromData+ wPtr+ Gdk.ColorspaceRgb+ True+ 8+ width+ height+ rowStride+ (Just free) +-- | Read EWMH icon data from an X11 window and convert it into a pixbuf. getIconPixBufFromEWMH :: Int32 -> X11Window -> X11Property (Maybe Gdk.Pixbuf) getIconPixBufFromEWMH size x11WindowId = runMaybeT $ do ewmhData <- MaybeT $ getWindowIconsData x11WindowId MaybeT $ lift $ withEWMHIcons ewmhData (getPixbufFromEWMHIcons size) +-- | Resolve and cache the EWMH icon for an X11 window.+getCachedIconPixBufFromEWMH :: Int32 -> X11Window -> TaffyIO (Maybe Gdk.Pixbuf)+getCachedIconPixBufFromEWMH size x11WindowId =+ getCachedWindowIcon+ (EWMHIconCacheKey size x11WindowId)+ (runX11Def Nothing $ getIconPixBufFromEWMH size x11WindowId)+ -- | Create a pixbuf with the indicated RGBA color.-pixBufFromColor- :: MonadIO m- => Int32 -> Word32 -> m Gdk.Pixbuf+pixBufFromColor ::+ (MonadIO m) =>+ Int32 -> Word32 -> m Gdk.Pixbuf pixBufFromColor imgSize c = do pixbuf <- fromJust <$> Gdk.pixbufNew Gdk.ColorspaceRgb True 8 imgSize imgSize Gdk.pixbufFill pixbuf c return pixbuf -getDirectoryEntryByClass- :: String- -> TaffyIO (Maybe DesktopEntry)+-- | Resolve a desktop entry by X11 class name.+getDirectoryEntryByClass ::+ String ->+ TaffyIO (Maybe DesktopEntry) getDirectoryEntryByClass klass = do entries <- MM.lookup klass <$> getDirectoryEntriesByClassName- when (length entries > 1) $ liftIO $- logM "System.Taffybar.WindowIcon" DEBUG $ printf- "Class \"%s\" has multiple desktop entries: %s"- klass (intercalate ", " $ map deFilename entries)+ when (length entries > 1) $+ liftIO $+ logM "System.Taffybar.WindowIcon" DEBUG $+ printf+ "Class \"%s\" has multiple desktop entries: %s"+ klass+ (intercalate ", " $ map deFilename entries) return $ listToMaybe entries -getWindowIconForAllClasses- :: Monad m- => (p -> String -> m (Maybe a)) -> p -> String -> m (Maybe a)+-- | Try icon lookup for each parsed class component until one succeeds.+getWindowIconForAllClasses ::+ (Monad m) =>+ (p -> String -> m (Maybe a)) -> p -> String -> m (Maybe a) getWindowIconForAllClasses doOnClass size klass = foldl combine (return Nothing) $ parseWindowClasses klass where combine soFar theClass = maybeTCombine soFar (doOnClass size theClass) +-- | Resolve a window icon through desktop-entry icon metadata. getWindowIconFromDesktopEntryByClasses ::- Int32 -> String -> TaffyIO (Maybe Gdk.Pixbuf)+ Int32 -> String -> TaffyIO (Maybe Gdk.Pixbuf) getWindowIconFromDesktopEntryByClasses = getWindowIconForAllClasses getWindowIconFromDesktopEntryByClass- where getWindowIconFromDesktopEntryByClass size klass =- runMaybeT $ do- entry <- MaybeT $ getDirectoryEntryByClass klass- lift $ logPrintF "System.Taffybar.WindowIcon" DEBUG- "Using desktop entry for icon %s"- (deFilename entry, klass)- MaybeT $ lift $ getImageForDesktopEntry size entry+ where+ getWindowIconFromDesktopEntryByClass size klass =+ runMaybeT $ do+ entry <- MaybeT $ getDirectoryEntryByClass klass+ lift $+ logPrintF+ "System.Taffybar.WindowIcon"+ DEBUG+ "Using desktop entry for icon %s"+ (deFilename entry, klass)+ MaybeT $ lift $ getImageForDesktopEntry size entry +-- | Resolve and cache a window icon through desktop-entry icon metadata.+getCachedWindowIconFromDesktopEntryByClasses ::+ Int32 -> String -> TaffyIO (Maybe Gdk.Pixbuf)+getCachedWindowIconFromDesktopEntryByClasses size klass =+ getCachedWindowIcon+ (DesktopEntryIconCacheKey size klass)+ (getWindowIconFromDesktopEntryByClasses size klass)++-- | Resolve a window icon directly by class names in the icon theme. getWindowIconFromClasses :: Int32 -> String -> IO (Maybe Gdk.Pixbuf) getWindowIconFromClasses = getWindowIconForAllClasses getWindowIconFromClass- where getWindowIconFromClass size klass = loadPixbufByName size (T.pack klass)+ where+ getWindowIconFromClass size klass = loadPixbufByName size (T.pack klass) +-- | Resolve and cache a window icon directly by class names in the icon theme.+getCachedWindowIconFromClasses ::+ Int32 -> String -> TaffyIO (Maybe Gdk.Pixbuf)+getCachedWindowIconFromClasses size klass =+ getCachedWindowIcon+ (ClassIconCacheKey size klass)+ (liftIO $ getWindowIconFromClasses size klass)++-- | Resolve a window icon from cached Chrome tab image data. getPixBufFromChromeData :: X11Window -> TaffyIO (Maybe Gdk.Pixbuf) getPixBufFromChromeData window = do imageData <- getChromeTabImageDataTable >>= lift . readMVar
taffybar.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: taffybar-version: 5.1.3+version: 5.2.0 synopsis: A desktop bar similar to xmobar, but with more GUI license: BSD-3-Clause license-file: LICENSE@@ -17,6 +17,7 @@ dbus-xml/org.PulseAudio.ServerLookup1.xml dbus-xml/org.PulseAudio.Core1.xml dbus-xml/org.PulseAudio.Core1.Device.xml+ dbus-xml/org.freedesktop.login1.Manager.xml dbus-xml/org.freedesktop.UPower.Device.xml dbus-xml/org.freedesktop.UPower.xml dbus-xml/org.mpris.MediaPlayer2.Player.xml@@ -66,7 +67,7 @@ , data-default , dbus >= 1.2.11 && < 2.0.0 , dbus-hslogger >= 0.1.1.0 && < 0.2.0.0- , dbus-menu >= 0.1.3.2+ , dbus-menu >= 0.1.3.0 , directory , disk-free-space >= 0.1.0.1 , dyre >= 0.9.0 && < 0.10@@ -85,7 +86,7 @@ , gi-gtk3 >= 3.0.44 && < 4 , gi-gtk-hs >= 0.3.17 && < 0.4 , gi-pango- , gtk-sni-tray >= 0.1.14.2+ , gtk-sni-tray >= 0.2.0.0 , gtk-strut >= 0.1.2.1 , haskell-gi-base >= 0.24 , hslogger@@ -102,7 +103,7 @@ , safe >= 0.3 && < 1 , scotty >= 0.20 && < 0.31 , split >= 0.1.4.2- , status-notifier-item >= 0.3.1.0+ , status-notifier-item >= 0.3.2.6 , stm , template-haskell , text@@ -136,6 +137,7 @@ , System.Taffybar.Information.PulseAudio , System.Taffybar.Information.WirePlumber , System.Taffybar.Information.Backlight+ , System.Taffybar.Information.ASUS , System.Taffybar.Information.Battery , System.Taffybar.Information.Bluetooth , System.Taffybar.Information.CPU@@ -158,6 +160,10 @@ , System.Taffybar.Information.SafeX11 , System.Taffybar.Information.Systemd , System.Taffybar.Information.StreamInfo+ , System.Taffybar.Information.Workspaces.EWMH+ , System.Taffybar.Information.Workspaces.Hyprland+ , System.Taffybar.Information.Workspaces.Model+ , System.Taffybar.Information.Wakeup , System.Taffybar.Information.Wlsunset , System.Taffybar.Information.X11DesktopInfo , System.Taffybar.Information.XDG.Protocol@@ -167,6 +173,7 @@ , System.Taffybar.Util , System.Taffybar.Widget , System.Taffybar.Widget.PulseAudio+ , System.Taffybar.Widget.ASUS , System.Taffybar.Widget.Backlight , System.Taffybar.Widget.Battery , System.Taffybar.Widget.BatteryDonut@@ -175,10 +182,12 @@ , System.Taffybar.Widget.CPUMonitor , System.Taffybar.Widget.CommandRunner , System.Taffybar.Widget.Crypto+ , System.Taffybar.Widget.ChannelWorkspaces , System.Taffybar.Widget.DiskIOMonitor , System.Taffybar.Widget.DiskUsage , System.Taffybar.Widget.FSMonitor , System.Taffybar.Widget.FreedesktopNotifications+ , System.Taffybar.Widget.ImageCommandButton , System.Taffybar.Widget.Generic.AutoFillImage , System.Taffybar.Widget.Generic.AutoSizeImage , System.Taffybar.Widget.Generic.ChannelGraph@@ -192,7 +201,6 @@ , System.Taffybar.Widget.Generic.PollingLabel , System.Taffybar.Widget.Generic.VerticalBar , System.Taffybar.Widget.HyprlandLayout- , System.Taffybar.Widget.HyprlandWindows , System.Taffybar.Widget.Inhibitor , System.Taffybar.Widget.KeyboardState , System.Taffybar.Widget.Layout@@ -204,18 +212,28 @@ , System.Taffybar.Widget.ScreenLock , System.Taffybar.Widget.SNIMenu , System.Taffybar.Widget.SNITray+ , System.Taffybar.Widget.SNITray.PrioritizedCollapsible , System.Taffybar.Widget.SimpleClock , System.Taffybar.Widget.SimpleCommandButton , System.Taffybar.Widget.Systemd , System.Taffybar.Widget.Temperature+ , System.Taffybar.Widget.WakeupDebug , System.Taffybar.Widget.Text.CPUMonitor , System.Taffybar.Widget.Text.MemoryMonitor , System.Taffybar.Widget.Text.NetworkMonitor , System.Taffybar.Widget.Util , System.Taffybar.Widget.Weather , System.Taffybar.Widget.HyprlandWorkspaces+ , System.Taffybar.Widget.Workspaces.Hyprland , System.Taffybar.Widget.Windows , System.Taffybar.Widget.Workspaces+ , System.Taffybar.Widget.Workspaces.Config+ , System.Taffybar.Widget.Workspaces.Common+ , System.Taffybar.Widget.Workspaces.EWMH+ , System.Taffybar.Widget.Workspaces.EWMH.Compat+ , System.Taffybar.Widget.Workspaces.X11+ , System.Taffybar.Widget.Workspaces.Hyprland.Compat+ , System.Taffybar.Widget.Workspaces.Channel , System.Taffybar.Widget.Workspaces.Shared , System.Taffybar.Widget.WirePlumber , System.Taffybar.Widget.Wlsunset@@ -229,6 +247,7 @@ exposed-modules: System.Taffybar.Support.PagerHints other-modules: Paths_taffybar+ , System.Taffybar.DBus.Client.Login1Manager , System.Taffybar.DBus.Client.MPRIS2 , System.Taffybar.DBus.Client.NetworkManager , System.Taffybar.DBus.Client.NetworkManagerAccessPoint@@ -240,7 +259,11 @@ , System.Taffybar.DBus.Client.UPower , System.Taffybar.DBus.Client.UPowerDevice , System.Taffybar.DBus.Client.Util+ , System.Taffybar.Information.Hyprland.API+ , System.Taffybar.Information.Hyprland.Types+ , System.Taffybar.Information.Wakeup.Manager , System.Taffybar.Information.Udev+ , System.Taffybar.Window.FocusedMonitor autogen-modules: Paths_taffybar @@ -250,12 +273,16 @@ executable taffybar import: haskell, exe build-depends: data-default+ , dhall , directory+ , gi-gtk3 , hslogger , optparse-applicative , taffybar+ , text - other-modules: Paths_taffybar+ other-modules: DhallConfig+ , Paths_taffybar autogen-modules: Paths_taffybar hs-source-dirs: app@@ -351,9 +378,13 @@ , System.Taffybar.AuthSpec , System.Taffybar.AppearanceSpec , System.Taffybar.ContextSpec+ , System.Taffybar.Information.CryptoSpec , System.Taffybar.Information.X11DesktopInfoSpec+ , System.Taffybar.Information.WakeupSpec , System.Taffybar.SimpleConfigSpec , System.Taffybar.Widget.HyprlandWorkspacesSpec+ , System.Taffybar.Widget.Workspaces.ChannelSpec+ , System.Taffybar.Widget.WindowsSpec build-depends: data-default , bytestring , directory
taffybar.css view
@@ -73,6 +73,37 @@ padding-bottom: 3px; } +.sni-tray-expand-toggle,+.sni-tray-edit-toggle,+.sni-tray-settings-toggle,+.sni-tray-overflow-indicator,+.sni-tray-priority-mode-toggle {+ background-color: rgba(255, 255, 255, 0.08);+ border: 1px solid rgba(255, 255, 255, 0.18);+ border-radius: 6px;+ padding-left: 4px;+ padding-right: 4px;+ margin-left: 3px;+}++.sni-tray-expand-toggle:hover,+.sni-tray-edit-toggle:hover,+.sni-tray-settings-toggle:hover {+ background-color: rgba(255, 255, 255, 0.15);+ border-color: rgba(255, 255, 255, 0.30);+}++.sni-tray-overflow-count-label {+ min-width: 12px;+}++.sni-tray-edit-toggle-active,+.sni-tray-priority-mode-toggle-active {+ background-color: rgba(120, 190, 130, 0.40);+ border-color: rgba(120, 190, 130, 0.90);+ border-radius: 4px;+}+ .window-icon-container.active { box-shadow: inset 0 -3px @white; }
+ test/data/appearance-ewmh-bar-levels.png view
binary file changed (absent → 2033 bytes)
test/data/appearance-test.css view
@@ -53,6 +53,21 @@ border-radius: 5px; } +.test-level2-left {+ background-color: #7a3a5a;+ border-radius: 5px;+}++.test-level2-center {+ background-color: #3a7a5a;+ border-radius: 5px;+}++.test-level2-right {+ background-color: #7a6a3a;+ border-radius: 5px;+}+ .test-pill { background-color: rgba(255, 255, 255, 0.08); color: #f0f0f0;
test/lib/System/Taffybar/Test/DBusSpec.hs view
@@ -1,40 +1,45 @@ module System.Taffybar.Test.DBusSpec- ( spec- -- * Start private D-Busses for testing- , withTestDBus- , withTestDBusInDir- , Bus(..)- , withDBusDaemon_- , withConnectDBusDaemon- , withConnectDBusDaemon'- -- ** Using the private D-Bus- , setDBusEnv- , withBusEnv- -- ** @python-dbusmock@ Services- , withPythonDBusMock- , withTaffyMocks- -- * Utils- , withMatch- , withClient- ) where+ ( spec, + -- * Start private D-Busses for testing+ withTestDBus,+ withTestDBusInDir,+ Bus (..),+ withDBusDaemon_,+ withConnectDBusDaemon,+ withConnectDBusDaemon',++ -- ** Using the private D-Bus+ setDBusEnv,+ withBusEnv,++ -- ** @python-dbusmock@ Services+ withPythonDBusMock,+ withTaffyMocks,++ -- * Utils+ withMatch,+ withClient,+ )+where+ import Control.Monad (forM_, void, when) import Control.Monad.IO.Unlift (MonadUnliftIO (..))+import DBus+import DBus.Client import Data.Function ((&))+import Data.Int (Int64) import Data.Map qualified as Map import Data.Maybe (fromMaybe)-import Data.Int (Int64)-import DBus-import DBus.Client-import Test.Hspec-import System.FilePath ((</>), (<.>), takeFileName)-import System.IO (hGetLine, hClose)+import System.FilePath (takeFileName, (<.>), (</>))+import System.IO (hClose, hGetLine) import System.Process.Typed-import System.Taffybar.Test.UtilSpec (withSetEnv, logSetup, specLog, withService, getSpecLogPriority, setServiceDefaults, laxTimeout')-import UnliftIO.Directory (makeAbsolute, createDirectoryIfMissing, createFileLink)-import UnliftIO.Temporary (withSystemTempDirectory)-import UnliftIO.Exception (bracket, throwString, finally, throwIO)+import System.Taffybar.Test.UtilSpec (getSpecLogPriority, laxTimeout', logSetup, setServiceDefaults, specLog, withService, withSetEnv)+import Test.Hspec+import UnliftIO.Directory (createDirectoryIfMissing, createFileLink, makeAbsolute)+import UnliftIO.Exception (bracket, finally, throwIO, throwString) import UnliftIO.MVar qualified as MV+import UnliftIO.Temporary (withSystemTempDirectory) -- | Uses 'withDBusDaemon_' to provide both a private session bus and -- a private system bus while the given action is running.@@ -48,12 +53,14 @@ -- -- __Note__: Environment variables are global to the process, so be -- careful using this with 'parallel' unit tests.-withTestDBusInDir- :: FilePath -- ^ Directory for config files and sockets.- -> IO a -> IO a-withTestDBusInDir socketDir- = withDBusDaemon_ System socketDir- . withDBusDaemon_ Session socketDir+withTestDBusInDir ::+ -- | Directory for config files and sockets.+ FilePath ->+ IO a ->+ IO a+withTestDBusInDir socketDir =+ withDBusDaemon_ System socketDir+ . withDBusDaemon_ Session socketDir -- | Same as 'withTestDBusInDir', except that it creates and removes the -- temporary directory for you.@@ -92,7 +99,8 @@ makeDBusDaemon configFile logLevel = proc "dbus-daemon" ["--print-address", "--config-file", configFile]- & setServiceDefaults logLevel & setStdout createPipe+ & setServiceDefaults logLevel+ & setStdout createPipe -- | Start a D-Bus daemon of the given 'Bus' type, and set the -- corresponding environment variable while running the given action.@@ -108,10 +116,10 @@ withConnectDBusDaemon' :: Bus -> FilePath -> (Address -> Client -> IO a) -> IO a withConnectDBusDaemon' bus socketDir action = withDBusDaemon bus socketDir $ \addr ->- withClient addr $ \c -> action addr c+ withClient addr $ \c -> action addr c withConnectDBusDaemon :: Bus -> FilePath -> (Client -> IO a) -> IO a-withConnectDBusDaemon bus socketDir = withConnectDBusDaemon' bus socketDir. const+withConnectDBusDaemon bus socketDir = withConnectDBusDaemon' bus socketDir . const setupBusDir :: Bus -> FilePath -> IO FilePath setupBusDir bus socketDir = do@@ -124,20 +132,21 @@ -- createFileLink "/nix/store/ygd600kkc1h3p5dgw9vjm5xnfci43v0k-upower-1.90.4/share/dbus-1/system-services/org.freedesktop.UPower.service" (serviceDir </> "org.freedesktop.UPower.service") addr <- mkAddress (socketDir </> busName bus <.> "socket")- writeFile configFile $ unlines- [ "<!DOCTYPE busconfig PUBLIC \"-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN\" \"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd\">"- , "<busconfig>"- , " <type>" ++ busName bus ++ "</type>"- , " <keep_umask/>"- , " <listen>" ++ addr ++ "</listen>"- , " <servicedir>" ++ serviceDir ++ "</servicedir>"- , " <policy context=\"default\">"- , " <allow send_destination=\"*\" eavesdrop=\"true\"/>"- , " <allow eavesdrop=\"true\"/>"- , " <allow own=\"*\"/>"- , " </policy>"- , "</busconfig>"- ]+ writeFile configFile $+ unlines+ [ "<!DOCTYPE busconfig PUBLIC \"-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN\" \"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd\">",+ "<busconfig>",+ " <type>" ++ busName bus ++ "</type>",+ " <keep_umask/>",+ " <listen>" ++ addr ++ "</listen>",+ " <servicedir>" ++ serviceDir ++ "</servicedir>",+ " <policy context=\"default\">",+ " <allow send_destination=\"*\" eavesdrop=\"true\"/>",+ " <allow eavesdrop=\"true\"/>",+ " <allow own=\"*\"/>",+ " </policy>",+ "</busconfig>"+ ] pure configFile mkAddress :: FilePath -> IO String@@ -159,11 +168,12 @@ withClient :: Address -> (Client -> IO a) -> IO a withClient addr = bracket (connect addr) disconnect -withMatch :: MonadUnliftIO m => Client -> MatchRule -> (Signal -> m ()) -> m a -> m a-withMatch client rule cb action = withRunInIO $ \run -> bracket- (addMatch client rule (run . cb))- (removeMatch client)- (const $ run action)+withMatch :: (MonadUnliftIO m) => Client -> MatchRule -> (Signal -> m ()) -> m a -> m a+withMatch client rule cb action = withRunInIO $ \run ->+ bracket+ (addMatch client rule (run . cb))+ (removeMatch client)+ (const $ run action) makeBusNameWaiter :: Client -> (BusName -> Bool) -> IO (IO ()) makeBusNameWaiter client p = do@@ -176,10 +186,12 @@ MV.putMVar h =<< addMatch client rule cb pure (MV.takeMVar v) where- rule = matchAny { matchMember = Just "NameOwnerChanged"- , matchInterface = Just "org.freedesktop.DBus"- , matchSender = Just "org.freedesktop.DBus"- }+ rule =+ matchAny+ { matchMember = Just "NameOwnerChanged",+ matchInterface = Just "org.freedesktop.DBus",+ matchSender = Just "org.freedesktop.DBus"+ } isMatch = maybe False p . fromVariant isOwned = not . null . fromMaybe ("" :: String) . fromVariant @@ -189,39 +201,54 @@ -- | Starts up [@python-dbusmock@](https://martinpitt.github.io/python-dbusmock/). -- The given action will be run once the mock is ready.-withPythonDBusMock- :: Bus -- ^ @python-dbusmock@ wants to know which bus.- -> (Address, Client) -- ^ Connection to the 'Bus'- -> BusName -- ^ Name of mock service.- -> ObjectPath -- ^ Path of mock service.- -> InterfaceName -- ^ Interface of mock service.- -> IO a -> IO a+withPythonDBusMock ::+ -- | @python-dbusmock@ wants to know which bus.+ Bus ->+ -- | Connection to the 'Bus'+ (Address, Client) ->+ -- | Name of mock service.+ BusName ->+ -- | Path of mock service.+ ObjectPath ->+ -- | Interface of mock service.+ InterfaceName ->+ IO a ->+ IO a withPythonDBusMock bus (addr, client) name path interface action = do waiter <- makeBusNameWaiter client (== name) logLevel <- getSpecLogPriority- withService (cfg & setServiceDefaults logLevel) $ const $- waiter *> action+ withService (cfg & setServiceDefaults logLevel) $+ const $+ waiter *> action where cfg = proc "python3" args & setDBusEnv bus addr- args = ["-m", "dbusmock", busArg bus- , formatBusName name- , formatObjectPath path- , formatInterfaceName interface]+ args =+ [ "-m",+ "dbusmock",+ busArg bus,+ formatBusName name,+ formatObjectPath path,+ formatInterfaceName interface+ ] mockAddTemplate :: Client -> BusName -> ObjectPath -> String -> [(String, Variant)] -> IO () mockAddTemplate client dest path templ params = do- void $ call_ client (addTemplate templ params) { methodCallDestination = Just dest }+ void $ call_ client (addTemplate templ params) {methodCallDestination = Just dest} where- addTemplate t p = (methodCall path "org.freedesktop.DBus.Mock" "AddTemplate")- { methodCallBody = [toVariant t, toVariant (Map.fromList p)] }+ addTemplate t p =+ (methodCall path "org.freedesktop.DBus.Mock" "AddTemplate")+ { methodCallBody = [toVariant t, toVariant (Map.fromList p)]+ } ------------------------------------------------------------------------ upName :: BusName upName = "org.freedesktop.UPower"+ upPath, upDisplayDevicePath :: ObjectPath upPath = "/org/freedesktop/UPower" upDisplayDevicePath = objectPath_ (formatObjectPath upPath ++ "/devices/DisplayDevice")+ upIface, upDeviceIface :: InterfaceName upIface = "org.freedesktop.UPower" upDeviceIface = interfaceName_ (formatInterfaceName upIface ++ ".Device")@@ -233,9 +260,9 @@ mockUPower client = do -- oh dbus, so ugly. mockAddTemplate client upName upPath "upower" [("OnBattery", toVariant True)]- void $ call_ client (methodCall upPath "org.freedesktop.DBus.Mock" "AddAC") { methodCallBody = map toVariant ["mock_AC" :: String, "Mock AC"], methodCallDestination = Just upName }- void $ call_ client (methodCall upPath "org.freedesktop.DBus.Mock" "AddChargingBattery") { methodCallBody = map toVariant ["mock_BAT" :: String, "Mock Battery"] ++ [toVariant (30.0 :: Double), toVariant (1200 :: Int64)] , methodCallDestination = Just upName }- void $ setPropertyValue client (methodCall upDisplayDevicePath upDeviceIface "IconName") { methodCallDestination = Just upName } mockIconName+ void $ call_ client (methodCall upPath "org.freedesktop.DBus.Mock" "AddAC") {methodCallBody = map toVariant ["mock_AC" :: String, "Mock AC"], methodCallDestination = Just upName}+ void $ call_ client (methodCall upPath "org.freedesktop.DBus.Mock" "AddChargingBattery") {methodCallBody = map toVariant ["mock_BAT" :: String, "Mock Battery"] ++ [toVariant (30.0 :: Double), toVariant (1200 :: Int64)], methodCallDestination = Just upName}+ void $ setPropertyValue client (methodCall upDisplayDevicePath upDeviceIface "IconName") {methodCallDestination = Just upName} mockIconName withTaffyMocks :: IO a -> IO a withTaffyMocks action = do@@ -263,16 +290,18 @@ forM_ [System] $ \bus -> aroundWith (flip (withConnectDBusDaemon' bus) . curry) $- describe ("python-dbusmock " ++ show bus ++ " services") $ do- -- These tests are currently failing in CI due to python-dbusmock startup issues.- -- Mark as pending until the root cause is identified.- it "simple" $ \_ -> pendingWith "python-dbusmock fails to start in CI"+ describe ("python-dbusmock " ++ show bus ++ " services") $ do+ -- These tests are currently failing in CI due to python-dbusmock startup issues.+ -- Mark as pending until the root cause is identified.+ it "simple" $ \_ -> pendingWith "python-dbusmock fails to start in CI" - it "UPower" $ \_ -> pendingWith "python-dbusmock fails to start in CI"+ it "UPower" $ \_ -> pendingWith "python-dbusmock fails to start in CI" gdbusPing :: Bus -> ProcessConfig () () () gdbusPing bus = proc "gdbus" ["call", "--" ++ busName bus, "--dest", "org.freedesktop.DBus", "--object-path", "/org/freedesktop/DBus", "--method", "org.freedesktop.DBus.Peer.Ping"] ping :: MethodCall-ping = (methodCall "/org/freedesktop/DBus" "org.freedesktop.DBus.Peer" "Ping")- { methodCallDestination = Just "org.freedesktop.DBus" }+ping =+ (methodCall "/org/freedesktop/DBus" "org.freedesktop.DBus.Peer" "Ping")+ { methodCallDestination = Just "org.freedesktop.DBus"+ }
test/lib/System/Taffybar/Test/UtilSpec.hs view
@@ -1,49 +1,57 @@+{-# LANGUAGE CPP #-} {-# LANGUAGE DerivingVia #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ViewPatterns #-}-{-# LANGUAGE CPP #-} module System.Taffybar.Test.UtilSpec- ( spec- -- * Mock commands- , withMockCommand- , writeScript- -- * Environment setup- , withEnv- , withSetEnv- , prependPath- -- ** Running subprocesses- , withService- , setStdoutCond- , setStderrCond- , setServiceDefaults- , makeServiceDefaults- -- * Concurrency- , listLiveThreads- , diffLiveThreads- -- * OS Resources- , listFds- -- * Other test helpers- , tryIOMaybe- , laxTimeout- , laxTimeout'- , DodgyEq(..)- -- ** Logging for tests- , logSetup- , specLogSetup- , specLogSetupPrio- , specLog- , specLogAt- , getSpecLogPriority- , Priority(..)- ) where+ ( spec, + -- * Mock commands+ withMockCommand,+ writeScript,++ -- * Environment setup+ withEnv,+ withSetEnv,+ prependPath,++ -- ** Running subprocesses+ withService,+ setStdoutCond,+ setStderrCond,+ setServiceDefaults,+ makeServiceDefaults,++ -- * Concurrency+ listLiveThreads,+ diffLiveThreads,++ -- * OS Resources+ listFds,++ -- * Other test helpers+ tryIOMaybe,+ laxTimeout,+ laxTimeout',+ DodgyEq (..),++ -- ** Logging for tests+ logSetup,+ specLogSetup,+ specLogSetupPrio,+ specLog,+ specLogAt,+ getSpecLogPriority,+ Priority (..),+ )+where+ import Control.Applicative ((<|>)) import Control.Monad (guard, join, void, (<=<)) import Control.Monad.IO.Unlift import Data.Bifunctor (second)-import qualified Data.ByteString.Char8 as B8+import Data.ByteString.Char8 qualified as B8 import Data.Either.Extra (eitherToMaybe, isLeft) import Data.Function (on, (&)) import Data.List (deleteFirstsBy, uncons)@@ -53,35 +61,37 @@ #else import GHC.Conc.Sync (ThreadId(..), ThreadStatus(..)) #endif-import System.Exit (ExitCode(..))+import System.Exit (ExitCode (..)) import System.FilePath (isRelative, takeFileName, (</>))-import System.IO (Handle, BufferMode(..), hSetBuffering, stderr, hClose)-import System.Log.Logger (Priority(..), updateGlobalLogger, setLevel, logM, getLevel, getLogger, removeHandler, setHandlers)-import System.Log.Handler.Simple (GenericHandler(..))-import System.Process.Typed (readProcess, proc, ProcessConfig, Process, withProcessTerm, waitExitCode, ExitCodeException (..), setStdin, nullStream, setStdout, setStderr, StreamSpec, setCloseFds, inherit, createPipe, getStdin)+import System.IO (BufferMode (..), Handle, hClose, hSetBuffering, stderr)+import System.Log.Handler.Simple (GenericHandler (..))+import System.Log.Logger (Priority (..), getLevel, getLogger, logM, removeHandler, setHandlers, setLevel, updateGlobalLogger) import System.Posix.Files (readSymbolicLink)+import System.Process.Typed (ExitCodeException (..), Process, ProcessConfig, StreamSpec, createPipe, getStdin, inherit, nullStream, proc, readProcess, setCloseFds, setStderr, setStdin, setStdout, waitExitCode, withProcessTerm)+import System.Taffybar.LogFormatter (taffyLogHandler) import Test.Hspec import Text.Printf (printf) import Text.Read (readMaybe) import UnliftIO.Async (race) import UnliftIO.Concurrent (forkFinally, threadDelay)-import UnliftIO.Directory (Permissions (..), findExecutable, getPermissions, setPermissions, listDirectory)+import UnliftIO.Directory (Permissions (..), findExecutable, getPermissions, listDirectory, setPermissions) import UnliftIO.Environment (lookupEnv, setEnv, unsetEnv)-import UnliftIO.Exception (bracket, evaluateDeep, throwIO, throwString, tryIO, StringException (..), try)-import qualified UnliftIO.MVar as MV+import UnliftIO.Exception (StringException (..), bracket, evaluateDeep, throwIO, throwString, try, tryIO)+import UnliftIO.MVar qualified as MV import UnliftIO.Temporary (withSystemTempDirectory) import UnliftIO.Timeout (timeout) -import System.Taffybar.LogFormatter (taffyLogHandler)- -- | Run the given 'IO' action with the @PATH@ environment variable -- set up so that executing the given command name will run a -- script.-withMockCommand- :: FilePath -- ^ Name of command - should not contain slashes- -> String -- ^ Contents of script- -> IO a -- ^ Action to run with command available in search path- -> IO a+withMockCommand ::+ -- | Name of command - should not contain slashes+ FilePath ->+ -- | Contents of script+ String ->+ -- | Action to run with command available in search path+ IO a ->+ IO a withMockCommand name content action = withSystemTempDirectory "specutil" $ \dir -> do writeScript (dir </> takeFileName name) content@@ -94,7 +104,7 @@ content' <- patchShebangs content writeFile scriptFile content' p <- getPermissions scriptFile- setPermissions scriptFile (p { executable = True })+ setPermissions scriptFile (p {executable = True}) -- | Given the text of a shell script, this replaces any relative path -- in the shebang with an absolute path, according to the current@@ -112,7 +122,7 @@ takeRelativeFileName :: FilePath -> Maybe FilePath takeRelativeFileName fp = guard (isRelative fp) >> pure (takeFileName fp) -patchShebangs' :: Applicative m => (FilePath -> m (Maybe FilePath)) -> String -> m String+patchShebangs' :: (Applicative m) => (FilePath -> m (Maybe FilePath)) -> String -> m String patchShebangs' replaceExe script = case parseInterpreter script of Just (interpreter, rest) -> do let unparse exe = "#! " ++ exe ++ rest@@ -123,13 +133,12 @@ parseInterpreter (lines -> content) = do (header, rest) <- uncons content (interpreter, args) <- parseShebang header- pure (interpreter, unlines (args:rest))-+ pure (interpreter, unlines (args : rest)) where parseShebang :: String -> Maybe (String, String)- parseShebang ('#':'!':(findInterpreter -> shebang)) =- let catArgs args = unwords ("":args)- in second catArgs <$> shebang+ parseShebang ('#' : '!' : (findInterpreter -> shebang)) =+ let catArgs args = unwords ("" : args)+ in second catArgs <$> shebang parseShebang _ = Nothing findInterpreter = uncons . dropWhile ((== "env") . takeFileName) . words@@ -160,12 +169,12 @@ prependPath :: FilePath -> Maybe String -> Maybe String prependPath p = Just . (++ ":/usr/bin") . (p ++) . maybe "" (":" ++) -listFds :: MonadIO m => m [(Int, FilePath)]+listFds :: (MonadIO m) => m [(Int, FilePath)] listFds = catMaybes <$> (listDirectory fdPath >>= mapM readEntry) where fdPath = "/proc/self/fd" - readEntry :: MonadIO m => FilePath -> m (Maybe (Int, FilePath))+ readEntry :: (MonadIO m) => FilePath -> m (Maybe (Int, FilePath)) readEntry fd = do t <- liftIO $ tryIOMaybe $ readSymbolicLink (fdPath </> fd) pure $ (,) <$> readMaybe fd <*> t@@ -182,16 +191,17 @@ listLiveThreads = pure [] #endif -diffLiveThreads :: Eq a => [(a, b)] -> [(a, b)] -> [(a, b)]+diffLiveThreads :: (Eq a) => [(a, b)] -> [(a, b)] -> [(a, b)] diffLiveThreads = deleteFirstsBy ((==) `on` fst) -tryIOMaybe :: MonadUnliftIO m => m a -> m (Maybe a)+tryIOMaybe :: (MonadUnliftIO m) => m a -> m (Maybe a) tryIOMaybe = fmap eitherToMaybe . tryIO laxTimeout' :: (HasCallStack, MonadUnliftIO m) => Int -> m a -> m a-laxTimeout' n action = laxTimeout n action >>= \case- Just a -> pure a- Nothing -> expectationFailure' $ printf "Timed out after %dusec" n+laxTimeout' n action =+ laxTimeout n action >>= \case+ Just a -> pure a+ Nothing -> expectationFailure' $ printf "Timed out after %dusec" n expectationFailure' :: (HasCallStack, MonadIO m) => String -> m a expectationFailure' msg = liftIO (expectationFailure msg) >> throwString msg@@ -203,8 +213,8 @@ join <$> timeout n (MV.takeMVar result >>= either throwIO pure) -- | A wrapper to provide 'Eq' for types which only have 'Show'.-newtype DodgyEq a = DodgyEq { unDodgyEq :: a }- deriving Show via (DodgyEq a)+newtype DodgyEq a = DodgyEq {unDodgyEq :: a}+ deriving (Show) via (DodgyEq a) instance Eq (DodgyEq a) where a == b = show a == show b@@ -216,15 +226,15 @@ specLoggerName = "Test" -- | Log a test message.-specLog :: MonadIO m => String -> m ()+specLog :: (MonadIO m) => String -> m () specLog = specLogAt INFO -- | Log a test message at the given level.-specLogAt :: MonadIO m => Priority -> String -> m ()+specLogAt :: (MonadIO m) => Priority -> String -> m () specLogAt level = liftIO . logM specLoggerName level -- | Setup logging before running the specs.-logSetup :: HasCallStack => SpecWith a -> SpecWith a+logSetup :: (HasCallStack) => SpecWith a -> SpecWith a logSetup = beforeAll_ specLogSetup -- | Get log levels from environment variables and set up formatters.@@ -246,16 +256,17 @@ -- | A plain looking log handler, to contrast with 'taffyLogFormatter'. specLogHandler :: GenericHandler Handle-specLogHandler = GenericHandler- { priority = DEBUG- , formatter = \_ (level, msg) _name -> return (show level ++ ": " ++ msg)- , privData = stderr- , writeFunc = \h -> B8.hPutStrLn h . B8.pack <=< evaluateDeep- , closeFunc = \_ -> return ()- }+specLogHandler =+ GenericHandler+ { priority = DEBUG,+ formatter = \_ (level, msg) _name -> return (show level ++ ": " ++ msg),+ privData = stderr,+ writeFunc = \h -> B8.hPutStrLn h . B8.pack <=< evaluateDeep,+ closeFunc = \_ -> return ()+ } -- | Find out the configured log level for specs.-getSpecLogPriority :: MonadIO m => m Priority+getSpecLogPriority :: (MonadIO m) => m Priority getSpecLogPriority = fromMaybe WARNING . getLevel <$> liftIO (getLogger specLoggerName) -- | Converts an environment variable value to a 'Priority'.@@ -266,15 +277,16 @@ toPriority s = readMaybe s <|> fmap fromInt (readMaybe s) fromInt :: Int -> Priority- fromInt n | n >= 2 = DEBUG- | n <= 0 = WARNING- | otherwise = INFO+ fromInt n+ | n >= 2 = DEBUG+ | n <= 0 = WARNING+ | otherwise = INFO -- | Like 'withProcessTerm_', except that if the process exits -- for -- whatever reason -- before the action completes, then it's an -- error. It will immediately cancel the action and throw an -- 'ExitCodeException'.-withService :: MonadUnliftIO m => ProcessConfig stdin stdout stderr -> (Process stdin stdout stderr -> m a) -> m a+withService :: (MonadUnliftIO m) => ProcessConfig stdin stdout stderr -> (Process stdin stdout stderr -> m a) -> m a withService cfg action = withProcessTerm cfg $ \p -> do either throwEarlyExitException pure =<< race (waitExitCode p) (action p) where@@ -295,21 +307,23 @@ flip setServiceDefaults (proc prog args) <$> getSpecLogPriority setServiceDefaults :: Priority -> ProcessConfig i o e -> ProcessConfig () () ()-setServiceDefaults logLevel = setCloseFds True- . setStdin nullStream- . setStdoutCond logLevel- . setStderrCond logLevel+setServiceDefaults logLevel =+ setCloseFds True+ . setStdin nullStream+ . setStdoutCond logLevel+ . setStderrCond logLevel ------------------------------------------------------------------------ spec :: Spec spec = do- it "withMockCommand" $ example $- withMockCommand "blah" "#!/usr/bin/env bash\necho hello \"$@\"\n" $ do- (code, out, err) <- readProcess (proc "blah" ["arstd"])- code `shouldBe` ExitSuccess- out `shouldBe` "hello arstd\n"- err `shouldBe` ""+ it "withMockCommand" $+ example $+ withMockCommand "blah" "#!/usr/bin/env bash\necho hello \"$@\"\n" $ do+ (code, out, err) <- readProcess (proc "blah" ["arstd"])+ code `shouldBe` ExitSuccess+ out `shouldBe` "hello arstd\n"+ err `shouldBe` "" it "laxTimeout" $ example $ do let t = 50_000@@ -317,23 +331,28 @@ describe "withService" $ around_ (laxTimeout' 100_000) $ do let wait = const $ threadDelay maxBound- it "normal" $ example $- withService (proc "sleep" ["60"]) (const $ pure ())- `shouldReturn` ()- it "exc" $ example $- withService (proc "sleep" ["60"]) (const $ throwString "hello")- `shouldThrow` \(StringException msg _) -> msg == "hello"- it "early exit" $ example $- withService "true" wait `shouldThrow`- \exc -> eceExitCode exc == ExitSuccess- it "manual exit" $ example $- withService- (proc "cat" [] & setStdin createPipe)- (\p -> hClose (getStdin p) >> wait p)- `shouldThrow` \exc -> eceExitCode exc == ExitSuccess- it "failure" $ example $- withService "false" wait `shouldThrow`- \exc -> eceExitCode exc /= ExitSuccess+ it "normal" $+ example $+ withService (proc "sleep" ["60"]) (const $ pure ())+ `shouldReturn` ()+ it "exc" $+ example $+ withService (proc "sleep" ["60"]) (const $ throwString "hello")+ `shouldThrow` \(StringException msg _) -> msg == "hello"+ it "early exit" $+ example $+ withService "true" wait+ `shouldThrow` \exc -> eceExitCode exc == ExitSuccess+ it "manual exit" $+ example $+ withService+ (proc "cat" [] & setStdin createPipe)+ (\p -> hClose (getStdin p) >> wait p)+ `shouldThrow` \exc -> eceExitCode exc == ExitSuccess+ it "failure" $+ example $+ withService "false" wait+ `shouldThrow` \exc -> eceExitCode exc /= ExitSuccess it "error message" $ example $ do res <- try (withService (proc "false" ["arg1", "arg2"]) wait) res `shouldSatisfy` isLeft
test/lib/System/Taffybar/Test/XvfbSpec.hs view
@@ -2,72 +2,76 @@ {-# LANGUAGE OverloadedRecordDot #-} module System.Taffybar.Test.XvfbSpec- ( spec- -- * Virtual X11 server for unit testing- , withXvfb- , withXdummy- , displayArg- , displayEnv- , setDefaultDisplay_- , setDefaultDisplay- -- ** Randr- , withRandrSetup- , randrSetup- , randrTeardown- -- *** RANDR config- , RRSetup(..)- , RROutput(..)- , RROutputSettings(..)- , RRExistingMode(..)- , RRMode(..)- , RRModeName(..)- , RRModeLine(..)- , RRPosition(..)- , RRRotation(..)- , ListIndex(..)- -- ** Clients- , withXTerm- -- * Wrappers around @xprop@ command- , XPropName(..)- , xpropName- , XPropValue(..)- , xpropValue- , xpropGet- , xpropSet- , xpropRemove- , xpropList- ) where+ ( spec, + -- * Virtual X11 server for unit testing+ withXvfb,+ withXdummy,+ displayArg,+ displayEnv,+ setDefaultDisplay_,+ setDefaultDisplay,++ -- ** Randr+ withRandrSetup,+ randrSetup,+ randrTeardown,++ -- *** RANDR config+ RRSetup (..),+ RROutput (..),+ RROutputSettings (..),+ RRExistingMode (..),+ RRMode (..),+ RRModeName (..),+ RRModeLine (..),+ RRPosition (..),+ RRRotation (..),+ ListIndex (..),++ -- ** Clients+ withXTerm,++ -- * Wrappers around @xprop@ command+ XPropName (..),+ xpropName,+ XPropValue (..),+ xpropValue,+ xpropGet,+ xpropSet,+ xpropRemove,+ xpropList,+ )+where+ import Control.Applicative ((<|>))-import Control.Monad ((<=<), void, forM_, guard)-import Control.Monad.IO.Class (MonadIO(..))-import Data.Attoparsec.Text.Lazy hiding (takeWhile, take)-import qualified Data.Attoparsec.Text.Lazy as P-import qualified Data.ByteString.Lazy.Char8 as BL+import Control.Monad (forM_, guard, void, (<=<))+import Control.Monad.IO.Class (MonadIO (..))+import Data.Attoparsec.Text.Lazy hiding (take, takeWhile)+import Data.Attoparsec.Text.Lazy qualified as P import Data.Bifunctor (bimap, second)+import Data.ByteString.Lazy.Char8 qualified as BL import Data.Char (isPrint, isSpace) import Data.Coerce (coerce)-import Data.Default (Default(..))-import Data.List (findIndex, uncons, dropWhileEnd)-import Data.String (IsString(..))-import Data.Text.Lazy.Encoding (decodeUtf8')-import qualified Data.Text.Lazy as TL-import qualified Data.Text as T+import Data.Default (Default (..)) import Data.Function ((&))-import Data.Maybe (maybeToList, mapMaybe, isJust, isNothing)+import Data.List (dropWhileEnd, findIndex, uncons)+import Data.Maybe (isJust, isNothing, mapMaybe, maybeToList)+import Data.String (IsString (..))+import Data.Text qualified as T+import Data.Text.Lazy qualified as TL+import Data.Text.Lazy.Encoding (decodeUtf8') import GHC.Generics (Generic)-import System.Process.Typed import System.IO (Handle, hClose, hGetLine)-import Text.Read (readMaybe)-import UnliftIO.Concurrent (threadDelay)-import UnliftIO.Exception (fromEitherM, throwString, bracket_)-+import System.Process.Typed+import System.Taffybar.Information.X11DesktopInfo (DisplayName (..))+import System.Taffybar.Test.UtilSpec (Priority (..), getSpecLogPriority, logSetup, setServiceDefaults, setStderrCond, specLog, specLogAt, withService, withSetEnv) import Test.Hspec import Test.QuickCheck import Test.QuickCheck.Monadic--import System.Taffybar.Test.UtilSpec (withSetEnv, logSetup, specLog, specLogAt, getSpecLogPriority, Priority(..), setStderrCond, setServiceDefaults, withService)-import System.Taffybar.Information.X11DesktopInfo (DisplayName(..))+import Text.Read (readMaybe)+import UnliftIO.Concurrent (threadDelay)+import UnliftIO.Exception (bracket_, fromEitherM, throwString) ------------------------------------------------------------------------ @@ -90,27 +94,28 @@ ------------------------------------------------------------------------ -newtype XPropName = XPropName { unXPropName :: String }+newtype XPropName = XPropName {unXPropName :: String} deriving (Show, Read, Eq, Ord, Generic) -- | Construct a valid 'XPropName' from a 'String'. Any names which -- cause problems when parsing @xprop@ output are not allowed. xpropName :: String -> Maybe XPropName-xpropName n@(c:cs)- | c `elem` propNameStartChars &&- all isPrint cs &&- not (any propNameBadChars cs) = Just (XPropName n)+xpropName n@(c : cs)+ | c `elem` propNameStartChars+ && all isPrint cs+ && not (any propNameBadChars cs) =+ Just (XPropName n) xpropName _ = Nothing -- | List of characters which are valid first characters of a property -- name. propNameStartChars :: [Char]-propNameStartChars = ['a'..'z'] <> ['A'..'Z'] <> ['_']+propNameStartChars = ['a' .. 'z'] <> ['A' .. 'Z'] <> ['_'] propNameBadChars :: Char -> Bool propNameBadChars c = isSpace c || c `elem` ['(', ')', ':'] -newtype XPropValue = XPropValue { unXPropValue :: String }+newtype XPropValue = XPropValue {unXPropValue :: String} deriving (Show, Read, Eq, Ord, Semigroup, Monoid, Generic) -- | Construct a valid 'XPropValue' from a 'String'. Any values which@@ -121,7 +126,7 @@ -- | Predicate on characters which cause difficulties when parsing -- @xprop@ output. propValueBadChars :: Char -> Bool-propValueBadChars c = c `elem` ['"','\n','\r'] || not (isPrint c)+propValueBadChars c = c `elem` ['"', '\n', '\r'] || not (isPrint c) ------------------------------------------------------------------------ @@ -136,7 +141,7 @@ either throwString pure $ parseXProp1 p txt where cfg = xpropProc d ["-root", unXPropName p]- decoded :: MonadIO m => m BL.ByteString -> m TL.Text+ decoded :: (MonadIO m) => m BL.ByteString -> m TL.Text decoded = fromEitherM . fmap decodeUtf8' parseXProp1 :: XPropName -> TL.Text -> Either String [XPropValue]@@ -175,7 +180,7 @@ parsePropValue "UTF8_STRING" = quotedString parsePropValue t = fail $ "Can't parse format \"" ++ T.unpack t ++ "\"" -xpropSet :: MonadIO m => DisplayName -> XPropName -> XPropValue -> m ()+xpropSet :: (MonadIO m) => DisplayName -> XPropName -> XPropValue -> m () xpropSet d (XPropName p) (XPropValue v) = liftIO $ do specLog $ "xpropSet running: " ++ show cfg runProcess_ cfg@@ -183,14 +188,14 @@ cfg = xpropProc d args args = ["-root", "-format", p, "8u", "-set", p, v] -xpropRemove :: MonadIO m => DisplayName -> XPropName -> m ()+xpropRemove :: (MonadIO m) => DisplayName -> XPropName -> m () xpropRemove d p = do specLog $ "xpropRemove running: " ++ show cfg runProcess_ cfg where cfg = xpropProc d ["-root", "-remove", unXPropName p] -xpropList :: MonadIO m => DisplayName -> m ()+xpropList :: (MonadIO m) => DisplayName -> m () xpropList d = liftIO $ do specLog $ "xpropList running: " ++ show cfg runProcess_ cfg@@ -228,9 +233,10 @@ action d where args' = displayArgMaybe ++ ["-displayfd", "1", "-terminate", "60"] ++ args- makeXvfb logLevel = proc prog args'- & setServiceDefaults logLevel- & setStdout createPipe+ makeXvfb logLevel =+ proc prog args'+ & setServiceDefaults logLevel+ & setStdout createPipe displayArgMaybe = maybeToList (fmap displaySpec display) @@ -257,13 +263,13 @@ -- | Adds a guiding phantom type annotation for indices into lists. -- fixme: zipper rather than indices-newtype ListIndex a = ListIndex { unListIndex :: Int }+newtype ListIndex a = ListIndex {unListIndex :: Int} deriving (Show, Read, Eq, Ord, Enum, Num, Real, Integral, Generic) enumerate :: [a] -> [(ListIndex a, a)]-enumerate = zip [0..]+enumerate = zip [0 ..] -bounds' :: Integral n => (n, n) -> (ListIndex a, ListIndex a)+bounds' :: (Integral n) => (n, n) -> (ListIndex a, ListIndex a) bounds' = let f = ListIndex . fromIntegral in bimap f f bounds :: [a] -> (ListIndex a, ListIndex a)@@ -281,36 +287,39 @@ ------------------------------------------------------------------------ data RRSetup = RRSetup- { outputs :: [RROutput]- , primary :: Maybe (ListIndex RROutput)- , newModes :: [RRMode] -- unused by outputs- } deriving (Show, Read, Eq, Generic)+ { outputs :: [RROutput],+ primary :: Maybe (ListIndex RROutput),+ newModes :: [RRMode] -- unused by outputs+ }+ deriving (Show, Read, Eq, Generic) instance Default RRSetup where- def = RRSetup { outputs = [def], primary = Just 0, newModes = [] }+ def = RRSetup {outputs = [def], primary = Just 0, newModes = []} data RROutput = RROutput- { mode :: Maybe RRExistingMode- , settings :: RROutputSettings- , position :: RRPosition- } deriving (Show, Read, Eq, Generic)+ { mode :: Maybe RRExistingMode,+ settings :: RROutputSettings,+ position :: RRPosition+ }+ deriving (Show, Read, Eq, Generic) instance Default RROutput where- def = RROutput { mode = def, settings = def, position = def }+ def = RROutput {mode = def, settings = def, position = def} rrOutputOff :: RROutput-rrOutputOff = def { settings = def { disabled = True } }+rrOutputOff = def {settings = def {disabled = True}} data RROutputSettings = RROutputSettings- { disabled :: Bool- , rotate :: RRRotation- } deriving (Show, Read, Eq, Generic)+ { disabled :: Bool,+ rotate :: RRRotation+ }+ deriving (Show, Read, Eq, Generic) instance Default RROutputSettings where- def = RROutputSettings { disabled = False, rotate = def }+ def = RROutputSettings {disabled = False, rotate = def} -- | This is an index into 'modeLines'.-newtype RRExistingMode = RRExistingMode { unRRExistingMode :: ListIndex RRMode }+newtype RRExistingMode = RRExistingMode {unRRExistingMode :: ListIndex RRMode} deriving (Show, Read, Eq, Ord, Enum, Generic) instance Bounded RRExistingMode where@@ -321,9 +330,10 @@ def = minBound data RRMode = RRMode- { name :: RRModeName- , modeLine :: RRModeLine- } deriving (Show, Read, Eq, Generic)+ { name :: RRModeName,+ modeLine :: RRModeLine+ }+ deriving (Show, Read, Eq, Generic) instance IsString RRMode where fromString = uncurry RRMode . bimap (RRModeName . unquote) RRModeLine . split@@ -331,10 +341,10 @@ split = second (dropWhile isSpace) . break isSpace unquote = takeWhile (/= '"') . dropWhile (== '"') -newtype RRModeName = RRModeName { unRRModeName :: String }+newtype RRModeName = RRModeName {unRRModeName :: String} deriving (Show, Read, Eq, IsString, Generic) -newtype RRModeLine = RRModeLine { unRRModeLine :: String }+newtype RRModeLine = RRModeLine {unRRModeLine :: String} deriving (Show, Read, Eq, IsString, Generic) data RRPosition = SameAs | RightOf | LeftOf | Below | Above@@ -354,34 +364,34 @@ -- instances to generate new modelines. modeLines :: [RRMode] modeLines =- [ "1280x1024 157.500 1280 1344 1504 1728 1024 1025 1028 1072 +HSync +VSync +preferred"- , "1280x1024 135.000 1280 1296 1440 1688 1024 1025 1028 1066 +HSync +VSync"- , "1280x1024 108.000 1280 1328 1440 1688 1024 1025 1028 1066 +HSync +VSync"- , "1280x960 148.500 1280 1344 1504 1728 960 961 964 1011 +HSync +VSync"- , "1280x960 108.000 1280 1376 1488 1800 960 961 964 1000 +HSync +VSync"- , "1280x800 83.500 1280 1352 1480 1680 800 803 809 831 -HSync +VSync"- , "1152x864 108.000 1152 1216 1344 1600 864 865 868 900 +HSync +VSync"- , "1280x720 74.500 1280 1344 1472 1664 720 723 728 748 -HSync +VSync"- , "1024x768 94.500 1024 1072 1168 1376 768 769 772 808 +HSync +VSync"- , "1024x768 78.750 1024 1040 1136 1312 768 769 772 800 +HSync +VSync"- , "1024x768 75.000 1024 1048 1184 1328 768 771 777 806 -HSync -VSync"- , "1024x768 65.000 1024 1048 1184 1344 768 771 777 806 -HSync -VSync"- , "1024x576 46.500 1024 1064 1160 1296 576 579 584 599 -HSync +VSync"- , "832x624 57.284 832 864 928 1152 624 625 628 667 -HSync -VSync"- , "960x540 40.750 960 992 1088 1216 540 543 548 562 -HSync +VSync"- , "800x600 56.300 800 832 896 1048 600 601 604 631 +HSync +VSync"- , "800x600 50.000 800 856 976 1040 600 637 643 666 +HSync +VSync"- , "800x600 49.500 800 816 896 1056 600 601 604 625 +HSync +VSync"- , "800x600 40.000 800 840 968 1056 600 601 605 628 +HSync +VSync"- , "800x600 36.000 800 824 896 1024 600 601 603 625 +HSync +VSync"- , "864x486 32.500 864 888 968 1072 486 489 494 506 -HSync +VSync"- , "640x480 36.000 640 696 752 832 480 481 484 509 -HSync -VSync"- , "640x480 31.500 640 656 720 840 480 481 484 500 -HSync -VSync"- , "640x480 31.500 640 664 704 832 480 489 492 520 -HSync -VSync"- , "640x480 25.175 640 656 752 800 480 490 492 525 -HSync -VSync"- , "720x400 35.500 720 756 828 936 400 401 404 446 -HSync +VSync"- , "640x400 31.500 640 672 736 832 400 401 404 445 -HSync +VSync"- , "640x350 31.500 640 672 736 832 350 382 385 445 +HSync -VSync"+ [ "1280x1024 157.500 1280 1344 1504 1728 1024 1025 1028 1072 +HSync +VSync +preferred",+ "1280x1024 135.000 1280 1296 1440 1688 1024 1025 1028 1066 +HSync +VSync",+ "1280x1024 108.000 1280 1328 1440 1688 1024 1025 1028 1066 +HSync +VSync",+ "1280x960 148.500 1280 1344 1504 1728 960 961 964 1011 +HSync +VSync",+ "1280x960 108.000 1280 1376 1488 1800 960 961 964 1000 +HSync +VSync",+ "1280x800 83.500 1280 1352 1480 1680 800 803 809 831 -HSync +VSync",+ "1152x864 108.000 1152 1216 1344 1600 864 865 868 900 +HSync +VSync",+ "1280x720 74.500 1280 1344 1472 1664 720 723 728 748 -HSync +VSync",+ "1024x768 94.500 1024 1072 1168 1376 768 769 772 808 +HSync +VSync",+ "1024x768 78.750 1024 1040 1136 1312 768 769 772 800 +HSync +VSync",+ "1024x768 75.000 1024 1048 1184 1328 768 771 777 806 -HSync -VSync",+ "1024x768 65.000 1024 1048 1184 1344 768 771 777 806 -HSync -VSync",+ "1024x576 46.500 1024 1064 1160 1296 576 579 584 599 -HSync +VSync",+ "832x624 57.284 832 864 928 1152 624 625 628 667 -HSync -VSync",+ "960x540 40.750 960 992 1088 1216 540 543 548 562 -HSync +VSync",+ "800x600 56.300 800 832 896 1048 600 601 604 631 +HSync +VSync",+ "800x600 50.000 800 856 976 1040 600 637 643 666 +HSync +VSync",+ "800x600 49.500 800 816 896 1056 600 601 604 625 +HSync +VSync",+ "800x600 40.000 800 840 968 1056 600 601 605 628 +HSync +VSync",+ "800x600 36.000 800 824 896 1024 600 601 603 625 +HSync +VSync",+ "864x486 32.500 864 888 968 1072 486 489 494 506 -HSync +VSync",+ "640x480 36.000 640 696 752 832 480 481 484 509 -HSync -VSync",+ "640x480 31.500 640 656 720 840 480 481 484 500 -HSync -VSync",+ "640x480 31.500 640 664 704 832 480 489 492 520 -HSync -VSync",+ "640x480 25.175 640 656 752 800 480 490 492 525 -HSync -VSync",+ "720x400 35.500 720 756 828 936 400 401 404 446 -HSync +VSync",+ "640x400 31.500 640 672 736 832 400 401 404 445 -HSync +VSync",+ "640x350 31.500 640 672 736 832 350 382 385 445 +HSync -VSync" ] xrandr :: DisplayName -> [String] -> ProcessConfig () () ()@@ -415,26 +425,29 @@ Nothing -> pure () runXrandr d args- where args = concat (globalArgs ++ givenArgs ++ switchOffOthers rr.outputs)- globalArgs = [ ["--noprimary" | isNothing rr.primary ] ]+ globalArgs = [["--noprimary" | isNothing rr.primary]] - givenArgs = zipWith outputArgs [0..] rr.outputs+ givenArgs = zipWith outputArgs [0 ..] rr.outputs outputArgs :: ListIndex RROutput -> RROutput -> [String] outputArgs i output =- [ "--output", outputName i- , "--rotate", rrRotation output.settings.rotate- ] ++- maybe ["--preferred"] (\m -> ["--mode", modeName m]) output.mode ++- ["--off" | output.settings.disabled ] ++- ["--primary" | rr.primary == Just i] ++- (if i > 0 then ["--" ++ rrPosition output.position, outputName (i - 1)] else [])+ [ "--output",+ outputName i,+ "--rotate",+ rrRotation output.settings.rotate+ ]+ ++ maybe ["--preferred"] (\m -> ["--mode", modeName m]) output.mode+ ++ ["--off" | output.settings.disabled]+ ++ ["--primary" | rr.primary == Just i]+ ++ (if i > 0 then ["--" ++ rrPosition output.position, outputName (i - 1)] else []) switchOffOthers :: [RROutput] -> [[String]]- switchOffOthers os = [ ["--output", outputName i, "--off"]- | i <- [ListIndex (length os)..15] ]+ switchOffOthers os =+ [ ["--output", outputName i, "--off"]+ | i <- [ListIndex (length os) .. 15]+ ] randrTeardown :: DisplayName -> RRSetup -> IO () randrTeardown d rr = do@@ -488,14 +501,15 @@ ------------------------------------------------------------------------ -prop_xprop :: HasCallStack => DisplayName -> XPropName -> XPropValue -> Property+prop_xprop :: (HasCallStack) => DisplayName -> XPropName -> XPropValue -> Property prop_xprop d name value = monadicIO $ do xpropSet d name value value' <- xpropGet d name xpropRemove d name- pure $ if value /= mempty- then value' === [value]- else value' === []+ pure $+ if value /= mempty+ then value' === [value]+ else value' === [] instance Arbitrary XPropName where arbitrary = ((:) <$> elements propNameStartChars <*> listOf arbitraryASCIIChar) `suchThatMap` xpropName@@ -507,29 +521,32 @@ ------------------------------------------------------------------------ -prop_xrandr :: HasCallStack => DisplayName -> RRSetup -> Property+prop_xrandr :: (HasCallStack) => DisplayName -> RRSetup -> Property prop_xrandr d rr = decorate $ monadicIO $ do- qrr <- run $ withRandrSetup d rr (randrQuery d)- let (rr', qrr') = reformat rr qrr- pure $ qrr' === rr'+ qrr <- run $ withRandrSetup d rr (randrQuery d)+ let (rr', qrr') = reformat rr qrr+ pure $ qrr' === rr' where decorate =- tabulate "Outputs" [outputName i | (i, o) <- enumerate rr.outputs, not o.settings.disabled] .- (if any ((/= Unrotated) . rotate . settings) rr.outputs then label "rotation" else id) .- (if any (isNothing . mode) rr.outputs then label "using preferred mode" else id) .- cover 50 (length rr.outputs > 1) "non-trivial"+ tabulate "Outputs" [outputName i | (i, o) <- enumerate rr.outputs, not o.settings.disabled]+ . (if any ((/= Unrotated) . rotate . settings) rr.outputs then label "rotation" else id)+ . (if any (isNothing . mode) rr.outputs then label "using preferred mode" else id)+ . cover 50 (length rr.outputs > 1) "non-trivial" - reformat rrs = unzip- . map (bimap snd snd)- . dropWhileEnd (not . uncurry (||) . bimap fst fst)- . zip (tuplify (backfill 16 rrs))+ reformat rrs =+ unzip+ . map (bimap snd snd)+ . dropWhileEnd (not . uncurry (||) . bimap fst fst)+ . zip (tuplify (backfill 16 rrs)) - tuplify RRSetup{..} =+ tuplify RRSetup {..} = [ (not o.settings.disabled, (i, Just (ListIndex i) == primary))- | (i, o) <- zip [0..] outputs ]+ | (i, o) <- zip [0 ..] outputs+ ] - backfill n rrs = rrs { outputs = rr.outputs ++ extras }- where extras = replicate (max 0 (n - length rr.outputs)) rrOutputOff+ backfill n rrs = rrs {outputs = rr.outputs ++ extras}+ where+ extras = replicate (max 0 (n - length rr.outputs)) rrOutputOff -- | Scans output of @xrandr --query@ and returns values relevant to -- test assertions.@@ -553,37 +570,40 @@ nNewModes <- chooseInt (1, nOutputs) newModeLines <- vectorOf nNewModes (elements (map modeLine modeLines)) - let newModes = [ RRMode (newModeName i) m | (i, m) <- enumerate newModeLines ]+ let newModes = [RRMode (newModeName i) m | (i, m) <- enumerate newModeLines] -- TODO: also use new modes for outputs -- mode <- chooseListIndex newModes outputs <- vector nOutputs- primary <- frequency [ (5, Just <$> chooseListIndex outputs)- , (1, pure Nothing) ]- pure $ RRSetup{..}+ primary <-+ frequency+ [ (5, Just <$> chooseListIndex outputs),+ (1, pure Nothing)+ ]+ pure $ RRSetup {..} shrink rr = do (primary, outputs) <- shrinkOutputs guard $ not $ null outputs pure $ RRSetup {newModes = [], ..}- where shrinkOutputs = case rr.primary of Just p -> shrinkListIx shrink (p, rr.outputs) Nothing -> map (Nothing,) (shrinkList shrink rr.outputs) - -- fixme: shrink modes properly too, and adjust mode within outputs- -- shrinkModes ms = do- -- ms' <- shrinkListP shrink ms- -- pure ms'+-- fixme: shrink modes properly too, and adjust mode within outputs+-- shrinkModes ms = do+-- ms' <- shrinkListP shrink ms+-- pure ms' shrinkListIx :: (a -> [a]) -> (ListIndex a, [a]) -> [(Maybe (ListIndex a), [a])] shrinkListIx shr (ix, xs) = map unwrap $ shrinkList (shrinkSecond shr) (wrap xs) where- wrap = zip [0..]- unwrap ixs = let ix' = findIndex ((== ix) . fst) ixs- in (fmap ListIndex ix', map snd ixs)+ wrap = zip [0 ..]+ unwrap ixs =+ let ix' = findIndex ((== ix) . fst) ixs+ in (fmap ListIndex ix', map snd ixs) shrinkSecond :: (a -> [a]) -> (b, a) -> [(b, a)] shrinkSecond f (b, a) = map (b,) (f a)@@ -591,16 +611,18 @@ instance Arbitrary RROutput where -- Always set a mode because "--preferred" option seems dodgy arbitrary = RROutput <$> fmap Just arbitrary <*> arbitrary <*> arbitrary- shrink o = [ RROutput m s p- | (m, s, p) <- shrink (o.mode, o.settings, o.position)- , isJust m- ]+ shrink o =+ [ RROutput m s p+ | (m, s, p) <- shrink (o.mode, o.settings, o.position),+ isJust m+ ] instance Arbitrary RROutputSettings where -- Rotation and reflection don't seem to work for xdummy- arbitrary = RROutputSettings- <$> frequency [(5, pure False), (1, pure True)]- <*> pure Unrotated+ arbitrary =+ RROutputSettings+ <$> frequency [(5, pure False), (1, pure True)]+ <*> pure Unrotated shrink = genericShrink instance Arbitrary RRExistingMode where@@ -608,20 +630,26 @@ shrink = shrinkMap (RRExistingMode . ListIndex . getPositive) (Positive . unListIndex . unRRExistingMode) instance Arbitrary RRPosition where- arbitrary = frequency $ map (second pure)- [ (2, SameAs)- , (1, LeftOf)- , (4, RightOf)- , (1, Above)- , (2, Below)- ]+ arbitrary =+ frequency $+ map+ (second pure)+ [ (2, SameAs),+ (1, LeftOf),+ (4, RightOf),+ (1, Above),+ (2, Below)+ ] shrink = shrinkBoundedEnum instance Arbitrary RRRotation where- arbitrary = frequency $ map (second pure)- [ (4, Unrotated)- , (2, Inverted)- , (1, RotateLeft)- , (1, RotateRight)- ]+ arbitrary =+ frequency $+ map+ (second pure)+ [ (4, Unrotated),+ (2, Inverted),+ (1, RotateLeft),+ (1, RotateRight)+ ] shrink = shrinkBoundedEnum
test/unit/System/Taffybar/AppearanceSpec.hs view
@@ -1,57 +1,97 @@ module System.Taffybar.AppearanceSpec (spec) where -import Control.Monad (when)+import Codec.Picture qualified as JP+import Control.Monad (unless, when)+import Data.ByteString.Lazy qualified as BL+import Data.Maybe (isJust) import System.Directory (doesFileExist, findExecutable, makeAbsolute) import System.Exit (ExitCode (..)) import System.FilePath ((</>))+import System.Process.Typed+ ( inherit,+ proc,+ setStderr,+ setStdout,+ stopProcess,+ waitExitCode,+ withProcessTerm,+ )+import System.Taffybar.Test.DBusSpec (withTestDBus)+import System.Taffybar.Test.UtilSpec (withEnv, withSetEnv)+import System.Taffybar.Test.XvfbSpec (setDefaultDisplay_, withXvfb) import System.Timeout (timeout)--import qualified Codec.Picture as JP-import qualified Data.ByteString.Lazy as BL- import Test.Hspec- import UnliftIO.Directory (createDirectoryIfMissing) import UnliftIO.Environment (lookupEnv) import UnliftIO.Temporary (withSystemTempDirectory) -import System.Process.Typed- ( inherit- , proc- , setStderr- , setStdout- , stopProcess- , waitExitCode- , withProcessTerm- )--import System.Taffybar.Test.DBusSpec (withTestDBus)-import System.Taffybar.Test.UtilSpec (withEnv, withSetEnv)-import System.Taffybar.Test.XvfbSpec (setDefaultDisplay_, withXvfb)+appearanceSnapProcessTimeoutUsec :: Int+appearanceSnapProcessTimeoutUsec = 120_000_000 spec :: Spec-spec = aroundAll withIntegrationEnv $ do- it "renders a bar under an EWMH window manager" $ \env -> do- goldenFile <- makeAbsolute "test/data/appearance-ewmh-bar.png"- actualPng <- renderBarScreenshot env+spec = do+ aroundAll withIntegrationEnv $ do+ it "renders a bar under an EWMH window manager" $ \env -> do+ goldenFile <- makeAbsolute "test/data/appearance-ewmh-bar.png"+ actualPng <- renderBarScreenshot env LegacyLayout LegacyWidget+ assertGolden "appearance" goldenFile actualPng - shouldUpdate <- lookupEnv "TAFFYBAR_UPDATE_GOLDENS"- case shouldUpdate of- Just _ -> do- BL.writeFile goldenFile actualPng+ it "renders a two-level bar under an EWMH window manager" $ \env -> do+ goldenFile <- makeAbsolute "test/data/appearance-ewmh-bar-levels.png"+ actualPng <- renderBarScreenshot env LevelsLayout LegacyWidget+ assertGolden "appearance-levels" goldenFile actualPng++ it "renders the channel workspaces widget under an EWMH window manager" $ \env -> do+ actualPng <- renderBarScreenshot env LegacyLayout ChannelWidget+ assertPngLooksRendered "ewmh-channel-workspaces" actualPng++ it "keeps configured bar height when the windows title has oversized glyph metrics" $ \env -> do+ actualPng <- renderBarScreenshot env WindowsTitleStressLayout LegacyWidget+ let actualImg = decodePngRGBA8 "stress" actualPng+ JP.imageHeight actualImg `shouldBe` 55++ it "sets an appropriately scaled top strut under EWMH when GDK_SCALE=2" $ \env ->+ withSetEnv [("GDK_SCALE", "2"), ("GDK_DPI_SCALE", "1")] $ do+ actualPng <-+ renderBarScreenshotWithArgs+ env+ LegacyLayout+ LegacyWidget+ ["--expect-top-strut", "80"]+ assertPngLooksRendered "ewmh-hidpi-strut" actualPng++ it "renders the channel workspaces widget under Hyprland when available" $ do+ available <- hyprlandTestAvailable+ unless available $+ pendingWith+ "Hyprland integration environment unavailable (needs WAYLAND_DISPLAY, HYPRLAND_INSTANCE_SIGNATURE and grim)"+ actualPng <- renderHyprlandScreenshot LegacyLayout ChannelWidget+ assertPngLooksRendered "hyprland-channel-workspaces" actualPng++assertGolden :: String -> FilePath -> BL.ByteString -> IO ()+assertGolden label goldenFile actualPng = do+ shouldUpdate <- lookupEnv "TAFFYBAR_UPDATE_GOLDENS"+ case shouldUpdate of+ Just _ -> do+ BL.writeFile goldenFile actualPng+ createDirectoryIfMissing True "dist"+ BL.writeFile ("dist/" ++ label ++ "-actual.png") actualPng+ Nothing -> do+ goldenPng <- BL.readFile goldenFile+ let actualImg = decodePngRGBA8 "actual" actualPng+ goldenImg = decodePngRGBA8 "golden" goldenPng+ when (actualImg /= goldenImg) $ do createDirectoryIfMissing True "dist"- BL.writeFile "dist/appearance-actual.png" actualPng- Nothing -> do- goldenPng <- BL.readFile goldenFile- let actualImg = decodePngRGBA8 "actual" actualPng- goldenImg = decodePngRGBA8 "golden" goldenPng- when (actualImg /= goldenImg) $ do- createDirectoryIfMissing True "dist"- BL.writeFile "dist/appearance-actual.png" actualPng- BL.writeFile "dist/appearance-golden.png" goldenPng- expectationFailure $- "Appearance golden mismatch: " ++ goldenFile ++- " (wrote dist/appearance-actual.png and dist/appearance-golden.png)"+ BL.writeFile ("dist/" ++ label ++ "-actual.png") actualPng+ BL.writeFile ("dist/" ++ label ++ "-golden.png") goldenPng+ expectationFailure $+ "Appearance golden mismatch: "+ ++ goldenFile+ ++ " (wrote dist/"+ ++ label+ ++ "-actual.png and dist/"+ ++ label+ ++ "-golden.png)" newtype Env = Env { envTmpDir :: FilePath@@ -68,28 +108,36 @@ -- Keep user/system config out of the test run and reduce variability. withEnv- [ ("WAYLAND_DISPLAY", const Nothing)- , ("HYPRLAND_INSTANCE_SIGNATURE", const Nothing)- ] $- withSetEnv- [ ("GDK_BACKEND", "x11")- , ("GDK_SCALE", "1")- , ("GDK_DPI_SCALE", "1")- , ("GTK_CSD", "0")- , ("GTK_THEME", "Adwaita")- , ("XDG_SESSION_TYPE", "x11")- , ("XDG_RUNTIME_DIR", runtimeDir)- , ("NO_AT_BRIDGE", "1")- , ("GSETTINGS_BACKEND", "memory")- , ("HOME", tmp)- , ("XDG_CONFIG_HOME", tmp </> "xdg-config")- , ("XDG_CACHE_HOME", tmp </> "xdg-cache")- , ("XDG_DATA_HOME", tmp </> "xdg-data")- ] $- action (Env { envTmpDir = tmp })+ [ ("WAYLAND_DISPLAY", const Nothing),+ ("HYPRLAND_INSTANCE_SIGNATURE", const Nothing)+ ]+ $ withSetEnv+ [ ("GDK_BACKEND", "x11"),+ ("GDK_SCALE", "1"),+ ("GDK_DPI_SCALE", "1"),+ ("GTK_CSD", "0"),+ ("GTK_THEME", "Adwaita"),+ ("XDG_SESSION_TYPE", "x11"),+ ("XDG_RUNTIME_DIR", runtimeDir),+ ("NO_AT_BRIDGE", "1"),+ ("GSETTINGS_BACKEND", "memory"),+ ("HOME", tmp),+ ("XDG_CONFIG_HOME", tmp </> "xdg-config"),+ ("XDG_CACHE_HOME", tmp </> "xdg-cache"),+ ("XDG_DATA_HOME", tmp </> "xdg-data")+ ]+ $ action (Env {envTmpDir = tmp}) -renderBarScreenshot :: Env -> IO BL.ByteString-renderBarScreenshot Env { envTmpDir = tmp } = do+data LayoutKind = LegacyLayout | LevelsLayout | WindowsTitleStressLayout++data WidgetKind = LegacyWidget | ChannelWidget++renderBarScreenshot :: Env -> LayoutKind -> WidgetKind -> IO BL.ByteString+renderBarScreenshot env layout widgetKind =+ renderBarScreenshotWithArgs env layout widgetKind []++renderBarScreenshotWithArgs :: Env -> LayoutKind -> WidgetKind -> [String] -> IO BL.ByteString+renderBarScreenshotWithArgs Env {envTmpDir = tmp} layout widgetKind extraArgs = do exePath <- findComponentExecutable "taffybar-appearance-snap"@@ -99,13 +147,22 @@ cssPath <- makeAbsolute "test/data/appearance-test.css" outPath <- makeAbsolute (tmp </> "appearance-actual.png") - let pc =+ let levelArgs =+ case layout of+ LegacyLayout -> []+ LevelsLayout -> ["--levels"]+ WindowsTitleStressLayout -> ["--windows-title-stress"]+ widgetArgs =+ case widgetKind of+ LegacyWidget -> []+ ChannelWidget -> ["--channel-workspaces"]+ pc = setStdout inherit $ setStderr inherit $- proc exePath ["--out", outPath, "--css", cssPath]+ proc exePath (["--out", outPath, "--css", cssPath] ++ levelArgs ++ widgetArgs ++ extraArgs) withProcessTerm pc $ \p -> do- mEc <- timeout 60_000_000 (waitExitCode p)+ mEc <- timeout appearanceSnapProcessTimeoutUsec (waitExitCode p) case mEc of Nothing -> do stopProcess p@@ -116,6 +173,65 @@ BL.readFile outPath +renderHyprlandScreenshot :: LayoutKind -> WidgetKind -> IO BL.ByteString+renderHyprlandScreenshot layout widgetKind =+ withSystemTempDirectory "taffybar-appearance-hyprland" $ \tmp -> do+ exePath <-+ findComponentExecutable+ "taffybar-appearance-snap-hyprland"+ [ "dist/build/taffybar-appearance-snap-hyprland/taffybar-appearance-snap-hyprland"+ ]+ cssPath <- makeAbsolute "test/data/appearance-test.css"+ outPath <- makeAbsolute (tmp </> "appearance-hyprland-actual.png")+ let levelArgs =+ case layout of+ LegacyLayout -> []+ LevelsLayout -> ["--levels"]+ WindowsTitleStressLayout -> []+ widgetArgs =+ case widgetKind of+ LegacyWidget -> []+ ChannelWidget -> ["--channel-workspaces"]+ pc =+ setStdout inherit $+ setStderr inherit $+ proc exePath (["--out", outPath, "--css", cssPath] ++ levelArgs ++ widgetArgs)+ withProcessTerm pc $ \p -> do+ mEc <- timeout appearanceSnapProcessTimeoutUsec (waitExitCode p)+ case mEc of+ Nothing -> do+ stopProcess p+ expectationFailure "Timed out running taffybar-appearance-snap-hyprland"+ Just ExitSuccess -> pure ()+ Just (ExitFailure n) ->+ expectationFailure ("taffybar-appearance-snap-hyprland exited with " ++ show n)+ BL.readFile outPath++assertPngLooksRendered :: String -> BL.ByteString -> IO ()+assertPngLooksRendered label actualPng = do+ let img = decodePngRGBA8 label actualPng+ w = JP.imageWidth img+ h = JP.imageHeight img+ opaquePixelCount =+ length+ [ ()+ | y <- [0 .. h - 1],+ x <- [0 .. w - 1],+ let JP.PixelRGBA8 _ _ _ a = JP.pixelAt img x y,+ a > 200+ ]+ when (w <= 0 || h <= 0) $+ expectationFailure ("Rendered PNG has invalid dimensions for " ++ label)+ when (opaquePixelCount <= 4000) $+ expectationFailure ("Rendered PNG seems empty for " ++ label)++hyprlandTestAvailable :: IO Bool+hyprlandTestAvailable = do+ waylandDisplay <- lookupEnv "WAYLAND_DISPLAY"+ hyprSig <- lookupEnv "HYPRLAND_INSTANCE_SIGNATURE"+ grimExe <- findExecutable "grim"+ pure (isJust waylandDisplay && isJust hyprSig && isJust grimExe)+ findComponentExecutable :: String -> [FilePath] -> IO FilePath findComponentExecutable name localCandidates = do mexe <- findExecutable name@@ -124,7 +240,7 @@ Nothing -> go localCandidates where go [] = fail (name ++ " not found on PATH")- go (p:ps) = do+ go (p : ps) = do exists <- doesFileExist p if exists then makeAbsolute p else go ps
test/unit/System/Taffybar/AuthSpec.hs view
@@ -1,15 +1,13 @@ module System.Taffybar.AuthSpec (spec) where import Data.List (intercalate)-import System.FilePath ((</>), (<.>))-import Text.Printf (printf)-+import System.FilePath ((<.>), (</>))+import System.Taffybar.Auth+import System.Taffybar.Test.UtilSpec (withMockCommand) import Test.Hspec import Test.Hspec.Core.Spec (getSpecDescriptionPath) import Test.Hspec.Golden hiding (golden)--import System.Taffybar.Auth-import System.Taffybar.Test.UtilSpec (withMockCommand)+import Text.Printf (printf) spec :: Spec spec = aroundAll_ (withMockPass mockDb) $ describe "passGet" $ do@@ -24,51 +22,55 @@ taffybarGolden (intercalate "-" path) <$> runAction taffybarGolden :: String -> String -> Golden String-taffybarGolden name output = Golden- { output- , encodePretty = show- , writeToFile = writeFile- , readFromFile = readFile- , goldenFile = "test/data/" </> name <.> "golden"- , actualFile = Nothing- , failFirstTime = True- }+taffybarGolden name output =+ Golden+ { output,+ encodePretty = show,+ writeToFile = writeFile,+ readFromFile = readFile,+ goldenFile = "test/data/" </> name <.> "golden",+ actualFile = Nothing,+ failFirstTime = True+ } mockDb :: [MockEntry]-mockDb = [ mockEntry "hello" "xyzzy" []- , mockEntry "multiline" "secret" [("Username", "fred"), ("silly", "")]- , fallbackEntry "" "Error: is not in the password store.\n" 1- ]+mockDb =+ [ mockEntry "hello" "xyzzy" [],+ mockEntry "multiline" "secret" [("Username", "fred"), ("silly", "")],+ fallbackEntry "" "Error: is not in the password store.\n" 1+ ] withMockPass :: [MockEntry] -> IO a -> IO a withMockPass db = withMockCommand "pass" (mockScript db) data MockEntry = MockEntry- { passName :: String- , out :: String- , err :: String- , status :: Int- } deriving (Show, Read, Eq)+ { passName :: String,+ out :: String,+ err :: String,+ status :: Int+ }+ deriving (Show, Read, Eq) mockEntry :: String -> String -> [(String, String)] -> MockEntry mockEntry passName key info =- MockEntry { passName, out = passFile key info, err = "", status = 0 }+ MockEntry {passName, out = passFile key info, err = "", status = 0} passFile :: String -> [(String, String)] -> String-passFile key info = unlines (key:[k ++ ": " ++ v | (k, v) <- info])+passFile key info = unlines (key : [k ++ ": " ++ v | (k, v) <- info]) fallbackEntry :: String -> String -> Int -> MockEntry-fallbackEntry out err status = MockEntry { passName = "", .. }+fallbackEntry out err status = MockEntry {passName = "", ..} mockScript :: [MockEntry] -> String-mockScript db = unlines ("#!/usr/bin/env bash":map makeEntry db)+mockScript db = unlines ("#!/usr/bin/env bash" : map makeEntry db) where- makeEntry MockEntry{..} = printf template passName out err status- template = unlines- [ "pass_name='%s'"- , "if [ -z \"$pass_name\" -o \"$2\" = \"$pass_name\" ]; then"- , " echo -n '%s'"- , " >&2 echo '%s'"- , " exit %d"- , "fi"- ]+ makeEntry MockEntry {..} = printf template passName out err status+ template =+ unlines+ [ "pass_name='%s'",+ "if [ -z \"$pass_name\" -o \"$2\" = \"$pass_name\" ]; then",+ " echo -n '%s'",+ " >&2 echo '%s'",+ " exit %d",+ "fi"+ ]
test/unit/System/Taffybar/ContextSpec.hs view
@@ -2,46 +2,47 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TypeSynonymInstances #-} {-# OPTIONS_GHC -Wno-orphans #-} module System.Taffybar.ContextSpec- ( spec- -- * Utils- , runTaffyDefault- -- * Abstract Config- , GenSimpleConfig(..)- , toSimpleConfig- , GenWidget(..)- , toTaffyWidget- , GenSpace(..)- , GenCssPath(..)- , toCssPaths- , GenMonitorsAction(..)- , toMonitorsAction- ) where+ ( spec, -import Control.Monad.Trans.Reader (runReaderT)+ -- * Utils+ runTaffyDefault,++ -- * Abstract Config+ GenSimpleConfig (..),+ toSimpleConfig,+ GenWidget (..),+ toTaffyWidget,+ GenSpace (..),+ GenCssPath (..),+ toCssPaths,+ GenMonitorsAction (..),+ toMonitorsAction,+ )+where+ import Control.Exception (SomeException, catch)+import Control.Monad.Trans.Reader (runReaderT) import Data.Default (def) import Data.Ratio ((%)) import GHC.Generics (Generic) import GI.Gtk (Widget) import System.Directory (createDirectoryIfMissing, getTemporaryDirectory, removePathForcibly) import System.FilePath ((</>))--import Test.Hspec hiding (context)-import Test.Hspec.QuickCheck-import Test.QuickCheck-import Test.QuickCheck.Monadic- import System.Taffybar.Context import System.Taffybar.SimpleConfig-import System.Taffybar.Widget.SimpleClock (textClockNewWith)-import System.Taffybar.Widget.Workspaces (workspacesNew)- import System.Taffybar.Test.DBusSpec (withTestDBus) import System.Taffybar.Test.UtilSpec (logSetup, withSetEnv)-import System.Taffybar.Test.XvfbSpec (withXdummy, setDefaultDisplay_)+import System.Taffybar.Test.XvfbSpec (setDefaultDisplay_, withXdummy)+import System.Taffybar.Widget.SimpleClock (textClockNewWith)+import System.Taffybar.Widget.Workspaces (workspacesNew)+import Test.Hspec hiding (context)+import Test.Hspec.QuickCheck+import Test.QuickCheck+import Test.QuickCheck.Monadic spec :: Spec spec = logSetup $ sequential $ aroundAll_ withTestDBus $ aroundAll_ (withXdummy . flip setDefaultDisplay_) $ do@@ -53,10 +54,11 @@ removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ()) createDirectoryIfMissing True runtime withSetEnv- [ ("XDG_RUNTIME_DIR", runtime)- , ("WAYLAND_DISPLAY", wl)- , ("XDG_SESSION_TYPE", "wayland")- ] $ do+ [ ("XDG_RUNTIME_DIR", runtime),+ ("WAYLAND_DISPLAY", wl),+ ("XDG_SESSION_TYPE", "wayland")+ ]+ $ do detectBackend `shouldReturn` BackendX11 removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ()) @@ -69,10 +71,11 @@ createDirectoryIfMissing True runtime writeFile wlPath "" -- exists but is not a socket withSetEnv- [ ("XDG_RUNTIME_DIR", runtime)- , ("WAYLAND_DISPLAY", wl)- , ("XDG_SESSION_TYPE", "wayland")- ] $ do+ [ ("XDG_RUNTIME_DIR", runtime),+ ("WAYLAND_DISPLAY", wl),+ ("XDG_SESSION_TYPE", "wayland")+ ]+ $ do detectBackend `shouldReturn` BackendX11 removePathForcibly runtime `catch` (\(_ :: SomeException) -> pure ()) @@ -90,31 +93,34 @@ -- | Represents 'SimpleTaffyConfig' in a more abstract way, so that -- it's easier to 'show', 'shrink', 'assert', etc. data GenSimpleConfig = GenSimpleConfig- { monitors :: GenMonitorsAction- , size :: StrutSize- , padding :: GenSpace- , position :: Position- , spacing :: GenSpace- , start :: [GenWidget]- , center :: [GenWidget]- , end :: [GenWidget]- , css :: [GenCssPath]- } deriving (Show, Eq, Generic)+ { monitors :: GenMonitorsAction,+ size :: StrutSize,+ padding :: GenSpace,+ position :: Position,+ spacing :: GenSpace,+ start :: [GenWidget],+ center :: [GenWidget],+ end :: [GenWidget],+ css :: [GenCssPath]+ }+ deriving (Show, Eq, Generic) -- | Build an actual taffy config from the abstract form. toSimpleConfig :: GenSimpleConfig -> SimpleTaffyConfig-toSimpleConfig GenSimpleConfig{..} = SimpleTaffyConfig- { monitorsAction = toMonitorsAction monitors- , barHeight = size- , barPadding = unGenSpace padding- , barPosition = position- , widgetSpacing = unGenSpace spacing- , startWidgets = map toTaffyWidget start- , centerWidgets = map toTaffyWidget center- , endWidgets = map toTaffyWidget end- , cssPaths = toCssPaths css- , startupHook = pure () -- TODO: add something- }+toSimpleConfig GenSimpleConfig {..} =+ SimpleTaffyConfig+ { monitorsAction = toMonitorsAction monitors,+ barHeight = size,+ barPadding = unGenSpace padding,+ barPosition = position,+ widgetSpacing = unGenSpace spacing,+ startWidgets = map toTaffyWidget start,+ centerWidgets = map toTaffyWidget center,+ endWidgets = map toTaffyWidget end,+ barLevels = Nothing,+ cssPaths = toCssPaths css,+ startupHook = pure () -- TODO: add something+ } toTaffyWidget :: GenWidget -> TaffyIO Widget toTaffyWidget = \case@@ -131,14 +137,15 @@ UseTheseMonitors xs -> pure xs instance Arbitrary GenSimpleConfig where- arbitrary = GenSimpleConfig <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary+ arbitrary = GenSimpleConfig <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary shrink = genericShrink instance Arbitrary StrutSize where- arbitrary = oneof- [ ExactSize . getSmall . getPositive <$> arbitrary- , ScreenRatio <$> elements [ 1 % 27, 1 % 50, 1 % 2 ] -- TODO: more arbitrary- ]+ arbitrary =+ oneof+ [ ExactSize . getSmall . getPositive <$> arbitrary,+ ScreenRatio <$> elements [1 % 27, 1 % 50, 1 % 2] -- TODO: more arbitrary+ ] shrink (ExactSize s) = ExactSize . getPositive <$> shrink (Positive (fromIntegral s)) shrink (ScreenRatio r) = ScreenRatio <$> shrink r @@ -147,7 +154,7 @@ shrink Top = [] shrink Bottom = [Top] -newtype GenSpace = GenSpace { unGenSpace :: Int }+newtype GenSpace = GenSpace {unGenSpace :: Int} deriving (Show, Read, Eq, Generic) instance Arbitrary GenSpace where@@ -168,39 +175,46 @@ arbitrary = arbitraryBoundedEnum shrink = genericShrink -data GenMonitorsAction = UsePrimaryMonitor- | UseAllMonitors- | UseTheseMonitors [Int]+data GenMonitorsAction+ = UsePrimaryMonitor+ | UseAllMonitors+ | UseTheseMonitors [Int] deriving (Show, Read, Eq, Generic) instance Arbitrary GenMonitorsAction where- arbitrary = oneof- [ pure UsePrimaryMonitor- , pure UseAllMonitors- , wild ]+ arbitrary =+ oneof+ [ pure UsePrimaryMonitor,+ pure UseAllMonitors,+ wild+ ] where -- This could be a lot meaner. wild = do NonNegative (Small n) <- arbitrary- pure (UseTheseMonitors [0..n])+ pure (UseTheseMonitors [0 .. n]) shrink = genericShrink ------------------------------------------------------------------------ prop_genSimpleConfig :: GenSimpleConfig -> Property-prop_genSimpleConfig cfg = checkCoverage $- cover 25 (monitors cfg == UsePrimaryMonitor) "Primary monitor only" $- cfg === cfg+prop_genSimpleConfig cfg =+ checkCoverage $+ cover 25 (monitors cfg == UsePrimaryMonitor) "Primary monitor only" $+ cfg === cfg prop_taffybarConfig :: GenSimpleConfig -> Property-prop_taffybarConfig cfg = within 1_000_000 $ monadicIO $- pure (cfg =/= cfg)- -- Some possible assertions:- -- startupHook executed exactly once- -- css rules are applied- -- css files later in list have precedence- -- missing css => exception- -- error in css => warning and continue- -- widgets are visible- -- spacing/height/position/padding are observed- -- appears on the correct monitor+prop_taffybarConfig cfg =+ within 1_000_000 $+ monadicIO $+ pure (cfg =/= cfg)++-- Some possible assertions:+-- startupHook executed exactly once+-- css rules are applied+-- css files later in list have precedence+-- missing css => exception+-- error in css => warning and continue+-- widgets are visible+-- spacing/height/position/padding are observed+-- appears on the correct monitor
+ test/unit/System/Taffybar/Information/CryptoSpec.hs view
@@ -0,0 +1,15 @@+module System.Taffybar.Information.CryptoSpec (spec) where++import System.Taffybar.Information.Crypto+import Test.Hspec++spec :: Spec+spec = describe "Crypto backoff" $ do+ it "caps exponential growth at the configured maximum" $+ nextCryptoBackoff 960 960 `shouldBe` (960, 960)++ it "doubles the current backoff delay when under the maximum" $+ nextCryptoBackoff 960 120 `shouldBe` (240, 120)++ it "sets max backoff to 16x the nominal polling delay" $+ maxCryptoBackoffForDelay 60 `shouldBe` 960
+ test/unit/System/Taffybar/Information/WakeupSpec.hs view
@@ -0,0 +1,80 @@+{-# LANGUAGE NumericUnderscores #-}++module System.Taffybar.Information.WakeupSpec (spec) where++import Data.Word (Word64)+import System.Taffybar.Information.Wakeup+import Test.Hspec+import Test.Hspec.QuickCheck+import Test.QuickCheck++spec :: Spec+spec = do+ describe "nextWallAlignedWakeupNs" $ do+ it "aligns 1-second intervals to top-of-second boundaries" $ do+ let realtimeOffset = 0+ interval = 1_000_000_000+ now = 1_250_000_000+ nextWallAlignedWakeupNs realtimeOffset interval now+ `shouldBe` 2_000_000_000++ prop "returns a strict future boundary aligned to wall-clock phase" $+ \(Positive (Small intervalSeconds :: Small Int))+ (NonNegative (Small nowOffsetMicros :: Small Int))+ (Small offsetMicros :: Small Int) ->+ let anchor :: Word64+ anchor = 1_000_000_000+ interval :: Word64+ interval = fromIntegral intervalSeconds * 1_000_000_000+ now :: Word64+ now = anchor + fromIntegral nowOffsetMicros * 1_000+ realtimeOffsetNs :: Integer+ realtimeOffsetNs = fromIntegral offsetMicros * 1_000+ due = nextWallAlignedWakeupNs realtimeOffsetNs interval now+ in due > now+ && (toInteger due + realtimeOffsetNs) `mod` toInteger interval == 0++ describe "nextAlignedWakeupNs" $ do+ it "returns the next boundary when now is exactly on a boundary" $ do+ let anchor = 1_000_000_000+ interval = 5_000_000_000+ now = 6_000_000_000+ nextAlignedWakeupNs anchor interval now+ `shouldBe` 11_000_000_000++ prop "returns a strict future boundary aligned to the interval" $+ \(Positive (Small intervalSeconds :: Small Int)) (NonNegative (Small nowOffsetMicros :: Small Int)) ->+ let anchor :: Word64+ anchor = 1_000_000_000+ interval :: Word64+ interval = fromIntegral intervalSeconds * 1_000_000_000+ now :: Word64+ now = anchor + fromIntegral nowOffsetMicros * 1_000+ due = nextAlignedWakeupNs anchor interval now+ in due > now+ && (due - anchor) `mod` interval == 0++ describe "divisibility alignment" $ do+ it "keeps 10s wakeups synchronized to every other 5s wakeup" $ do+ let anchor :: Word64+ anchor = 0+ fiveSeconds :: Word64+ fiveSeconds = 5_000_000_000+ tenSeconds :: Word64+ tenSeconds = 10_000_000_000+ fiveSchedule = map (intervalDueAtStepNs anchor fiveSeconds) [1 .. 10]+ tenSchedule = map (intervalDueAtStepNs anchor tenSeconds) [1 .. 5]+ everyOtherFive = [fiveSchedule !! 1, fiveSchedule !! 3, fiveSchedule !! 5, fiveSchedule !! 7, fiveSchedule !! 9]+ tenSchedule `shouldBe` everyOtherFive++ describe "minute alignment" $ do+ it "lands on exact minute boundaries for intervals that divide 60 seconds" $ do+ let minuteNs :: Word64+ minuteNs = 60_000_000_000+ divisorIntervalsSeconds :: [Word64]+ divisorIntervalsSeconds = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60]+ checkInterval intervalSeconds =+ let intervalNs = intervalSeconds * 1_000_000_000+ stepAtMinute = 60 `div` intervalSeconds+ in intervalDueAtStepNs 0 intervalNs stepAtMinute == minuteNs+ map checkInterval divisorIntervalsSeconds `shouldBe` replicate (length divisorIntervalsSeconds) True
test/unit/System/Taffybar/Information/X11DesktopInfoSpec.hs view
@@ -2,18 +2,19 @@ module System.Taffybar.Information.X11DesktopInfoSpec (spec) where -import Test.Hspec hiding (context)--import System.Taffybar.Test.XvfbSpec (withXdummy, xpropSet, XPropName(..), XPropValue(..)) import System.Taffybar.Information.X11DesktopInfo+import System.Taffybar.Test.XvfbSpec (XPropName (..), XPropValue (..), withXdummy, xpropSet)+import Test.Hspec hiding (context) spec :: Spec spec = around withXdummy $ describe "withX11Context" $ do- it "trivial" $ \dn -> example $- withX11Context dn (pure ()) `shouldReturn` ()+ it "trivial" $ \dn ->+ example $+ withX11Context dn (pure ()) `shouldReturn` () - it "getPrimaryOutputNumber" $ \dn -> example $- withX11Context dn getPrimaryOutputNumber `shouldReturn` Just 0+ it "getPrimaryOutputNumber" $ \dn ->+ example $+ withX11Context dn getPrimaryOutputNumber `shouldReturn` Just 0 it "read property of root window" $ \dn -> do xpropSet dn (XPropName "_XMONAD_VISIBLE_WORKSPACES") (XPropValue "hello")
test/unit/System/Taffybar/SimpleConfigSpec.hs view
@@ -3,18 +3,15 @@ module System.Taffybar.SimpleConfigSpec (spec) where import Data.Maybe (maybeToList)-+import System.Taffybar.ContextSpec (runTaffyDefault)+import System.Taffybar.Information.X11DesktopInfo (DisplayName (..))+import System.Taffybar.SimpleConfig+import System.Taffybar.Test.XvfbSpec (RROutput (..), RROutputSettings (..), RRSetup (..), setDefaultDisplay_, withRandrSetup, withXdummy) import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck import Test.QuickCheck.Monadic -import System.Taffybar.ContextSpec (runTaffyDefault)-import System.Taffybar.Test.XvfbSpec (RRSetup(..), RROutput(..), RROutputSettings(..), withXdummy, setDefaultDisplay_, withRandrSetup)--import System.Taffybar.Information.X11DesktopInfo (DisplayName(..))-import System.Taffybar.SimpleConfig- spec :: Spec spec = aroundAll_ (withXdummy . flip setDefaultDisplay_) $ do -- Pending: Can't run properties without cleaning up buildContext@@ -23,18 +20,25 @@ prop_useAllMonitors :: RRSetup -> Property prop_useAllMonitors rr = monadicIO $ do- allMonitors <- run $ withRandrSetup DefaultDisplay rr $- runTaffyDefault useAllMonitors+ allMonitors <-+ run $+ withRandrSetup DefaultDisplay rr $+ runTaffyDefault useAllMonitors - let rrOutputNumbers = [ i | (i, o) <- zip [0..] rr.outputs- , not o.settings.disabled ]+ let rrOutputNumbers =+ [ i+ | (i, o) <- zip [0 ..] rr.outputs,+ not o.settings.disabled+ ] pure $ allMonitors === rrOutputNumbers prop_usePrimaryMonitor :: RRSetup -> Property prop_usePrimaryMonitor rr = monadicIO $ do- primaryMonitor <- run $ withRandrSetup DefaultDisplay rr $- runTaffyDefault usePrimaryMonitor+ primaryMonitor <-+ run $+ withRandrSetup DefaultDisplay rr $+ runTaffyDefault usePrimaryMonitor let rrPrimaryMonitor = fromIntegral <$> maybeToList rr.primary
test/unit/System/Taffybar/Widget/HyprlandWorkspacesSpec.hs view
@@ -1,25 +1,23 @@ module System.Taffybar.Widget.HyprlandWorkspacesSpec where -import Test.Hspec--import qualified Data.Text as T--import System.Taffybar.Widget.HyprlandWorkspaces- ( HyprlandWindow(..)- , sortHyprlandWindowsByPosition+import Data.Text qualified as T+import System.Taffybar.Widget.Workspaces.Hyprland+ ( HyprlandWindow (..),+ sortHyprlandWindowsByPosition, )+import Test.Hspec mkWin :: String -> Bool -> Maybe (Int, Int) -> HyprlandWindow mkWin addr minimized atPos = HyprlandWindow- { windowAddress = T.pack addr- , windowTitle = addr- , windowClass = Nothing- , windowInitialClass = Nothing- , windowAt = atPos- , windowUrgent = False- , windowActive = False- , windowMinimized = minimized+ { windowAddress = T.pack addr,+ windowTitle = addr,+ windowClass = Nothing,+ windowInitialClass = Nothing,+ windowAt = atPos,+ windowUrgent = False,+ windowActive = False,+ windowMinimized = minimized } spec :: Spec@@ -28,11 +26,10 @@ it "orders non-minimized windows first, then by (x, y) position" $ do let a = mkWin "a" False (Just (10, 0)) b = mkWin "b" False (Just (0, 100))- c = mkWin "c" True (Just (0, 0))+ c = mkWin "c" True (Just (0, 0)) sortHyprlandWindowsByPosition [a, b, c] `shouldBe` [b, a, c] it "puts windows without a position at the end" $ do let a = mkWin "a" False Nothing b = mkWin "b" False (Just (0, 0)) sortHyprlandWindowsByPosition [a, b] `shouldBe` [b, a]-
+ test/unit/System/Taffybar/Widget/WindowsSpec.hs view
@@ -0,0 +1,14 @@+module System.Taffybar.Widget.WindowsSpec (spec) where++import Data.Text qualified as T+import System.Taffybar.Util (truncateText)+import Test.Hspec++spec :: Spec+spec = describe "Windows active label truncation" $ do+ it "truncates raw labels without introducing escaped entities" $ do+ let rawLabel = "Escape &apples"+ truncated = truncateText 8 rawLabel++ truncated `shouldBe` "Escape &…"+ T.isInfixOf "&" truncated `shouldBe` False
+ test/unit/System/Taffybar/Widget/Workspaces/ChannelSpec.hs view
@@ -0,0 +1,78 @@+module System.Taffybar.Widget.Workspaces.ChannelSpec (spec) where++import Data.Text qualified as T+import Data.Word (Word64)+import System.Taffybar.Information.Workspaces.Model+import System.Taffybar.Widget.Workspaces (sortWindowsByPosition)+import Test.Hspec++mkX11Window :: Word64 -> Bool -> Maybe (Int, Int) -> WindowInfo+mkX11Window wid minimized pos =+ WindowInfo+ { windowIdentity = X11WindowIdentity wid,+ windowTitle = T.pack (show wid),+ windowClassHints = [],+ windowPosition = pos,+ windowUrgent = False,+ windowActive = False,+ windowMinimized = minimized+ }++mkWorkspace :: Int -> String -> WorkspaceViewState -> [WindowInfo] -> WorkspaceInfo+mkWorkspace idx name state windows =+ WorkspaceInfo+ { workspaceIdentity =+ WorkspaceIdentity+ { workspaceNumericId = Just idx,+ workspaceName = T.pack name+ },+ workspaceState = state,+ workspaceHasUrgentWindow = any windowUrgent windows,+ workspaceIsSpecial = False,+ workspaceWindows = windows+ }++mkSnapshot :: Word64 -> [WorkspaceInfo] -> WorkspaceSnapshot+mkSnapshot rev workspaces =+ WorkspaceSnapshot+ { snapshotBackend = WorkspaceBackendHyprland,+ snapshotRevision = rev,+ snapshotWindowDataComplete = True,+ snapshotWorkspaces = workspaces+ }++spec :: Spec+spec = do+ describe "sortWindowsByPosition" $ do+ it "orders non-minimized windows first, then by (x, y), then unknown positions" $ do+ let a = mkX11Window 1 False (Just (10, 0))+ b = mkX11Window 2 False (Just (0, 100))+ c = mkX11Window 3 True (Just (0, 0))+ d = mkX11Window 4 False Nothing+ sortWindowsByPosition [a, b, c, d] `shouldBe` [b, a, d, c]++ describe "diffWorkspaceSnapshots" $ do+ it "returns no events for identical snapshots" $ do+ let w1 = mkX11Window 1 False (Just (0, 0))+ ws1 = mkWorkspace 1 "1" WorkspaceActive [w1]+ snap = mkSnapshot 7 [ws1]+ diffWorkspaceSnapshots snap snap `shouldBe` []++ it "emits order/add/change/window events for meaningful updates" $ do+ let oldW1 = mkX11Window 1 False (Just (0, 0))+ newW1 = oldW1 {windowTitle = "updated"}+ newW2 = mkX11Window 2 False (Just (50, 50))+ oldWs1 = mkWorkspace 1 "1" WorkspaceActive [oldW1]+ newWs1 = mkWorkspace 1 "1" WorkspaceHidden [newW1, newW2]+ newWs2 = mkWorkspace 2 "2" WorkspaceVisible []+ oldSnap = mkSnapshot 10 [oldWs1]+ newSnap = mkSnapshot 11 [newWs2, newWs1]+ expectedEvents =+ [ WorkspaceOrderChanged [workspaceIdentity newWs2, workspaceIdentity newWs1],+ WorkspaceAdded newWs2,+ WorkspaceChanged newWs1,+ WorkspaceWindowsChanged+ (workspaceIdentity newWs1)+ [WindowAdded newW2, WindowChanged newW1]+ ]+ diffWorkspaceSnapshots oldSnap newSnap `shouldBe` expectedEvents
test/unit/unit-tests.hs view
@@ -2,9 +2,8 @@ import Test.Hspec import Test.Hspec.Runner--import qualified UnitSpec-import qualified TestLibSpec+import TestLibSpec qualified+import UnitSpec qualified main :: IO () main = hspecWith defaultConfig $ do