taffybar: add configurable persistent SNI tray priorities

This commit is contained in:
2026-02-20 00:03:27 -08:00
committed by Kat Huang
parent bf77f44889
commit 18c1edba6d

View File

@@ -5,34 +5,35 @@
module Main (main) where module Main (main) where
import Control.Concurrent (threadDelay) import Control.Concurrent (threadDelay)
import Control.Monad (when)
import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Char (toLower) import Data.Char (toLower)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.Int (Int32) import Data.Int (Int32)
import Data.List (nub) import Data.List (nub, sortOn, stripPrefix)
import qualified Data.Map as M import qualified Data.Map as M
import Data.Maybe (catMaybes, fromMaybe, mapMaybe) import Data.Maybe (catMaybes, fromMaybe, listToMaybe, mapMaybe, maybeToList)
import Data.Ratio ((%)) import Data.Ratio ((%))
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified GI.Gdk as Gdk
import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk
import qualified GI.Gtk as Gtk import qualified GI.Gtk as Gtk
import Network.HostName (getHostName) import Network.HostName (getHostName)
import System.Environment (lookupEnv) import qualified StatusNotifier.Host.Service as SNIHost
import qualified StatusNotifier.Tray as SNITray import qualified StatusNotifier.Tray as SNITray
( MenuBackend (HaskellDBusMenu, LibDBusMenu), import System.Directory (createDirectoryIfMissing, doesFileExist)
defaultTrayParams, import System.Environment (lookupEnv)
trayMenuBackend,
trayOverlayScale,
)
import System.Environment.XDG.BaseDir (getUserConfigFile) import System.Environment.XDG.BaseDir (getUserConfigFile)
import System.FilePath (takeDirectory)
import System.Log.Logger (Priority (WARNING), rootLoggerName, setLevel, updateGlobalLogger) import System.Log.Logger (Priority (WARNING), rootLoggerName, setLevel, updateGlobalLogger)
import System.Taffybar (startTaffybar) import System.Taffybar (startTaffybar)
import System.Taffybar.Context (Backend (BackendWayland, BackendX11), TaffyIO, detectBackend) import System.Taffybar.Context (Backend (BackendWayland, BackendX11), TaffyIO, detectBackend)
import System.Taffybar.DBus import System.Taffybar.DBus
import System.Taffybar.DBus.Toggle import System.Taffybar.DBus.Toggle
import System.Taffybar.Hooks (withLogLevels) import System.Taffybar.Hooks (withLogLevels)
import System.Taffybar.Information.Memory (MemoryInfo (..), parseMeminfo)
import System.Taffybar.Information.EWMHDesktopInfo (WorkspaceId (..)) import System.Taffybar.Information.EWMHDesktopInfo (WorkspaceId (..))
import System.Taffybar.Information.Memory (MemoryInfo (..), parseMeminfo)
import System.Taffybar.Information.X11DesktopInfo import System.Taffybar.Information.X11DesktopInfo
import System.Taffybar.SimpleConfig import System.Taffybar.SimpleConfig
import System.Taffybar.Util (getPixbufFromFilePath, maybeTCombine, postGUIASync, (<|||>)) import System.Taffybar.Util (getPixbufFromFilePath, maybeTCombine, postGUIASync, (<|||>))
@@ -42,7 +43,9 @@ import qualified System.Taffybar.Widget.NetworkManager as NetworkManager
import qualified System.Taffybar.Widget.PulseAudio as PulseAudio import qualified System.Taffybar.Widget.PulseAudio as PulseAudio
import System.Taffybar.Widget.SNIMenu (withNmAppletMenu) import System.Taffybar.Widget.SNIMenu (withNmAppletMenu)
import System.Taffybar.Widget.SNITray import System.Taffybar.Widget.SNITray
( sniTrayNewFromParams, ( SNITrayConfig (..),
defaultSNITrayConfig,
sniTrayNewFromConfig,
sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt, sniTrayThatStartsWatcherEvenThoughThisIsABadWayToDoIt,
) )
import qualified System.Taffybar.Widget.ScreenLock as ScreenLock import qualified System.Taffybar.Widget.ScreenLock as ScreenLock
@@ -53,6 +56,7 @@ import qualified System.Taffybar.Widget.Workspaces.EWMH as X11Workspaces
import qualified System.Taffybar.Widget.Workspaces.Hyprland as Hyprland import qualified System.Taffybar.Widget.Workspaces.Hyprland as Hyprland
import System.Taffybar.WindowIcon (pixBufFromColor) import System.Taffybar.WindowIcon (pixBufFromColor)
import Text.Printf (printf) import Text.Printf (printf)
import Text.Read (readMaybe)
-- | Wrap the widget in a "TaffyBox" (via 'buildContentsBox') and add a CSS class. -- | Wrap the widget in a "TaffyBox" (via 'buildContentsBox') and add a CSS class.
decorateWithClassAndBox :: (MonadIO m) => Text -> Gtk.Widget -> m Gtk.Widget decorateWithClassAndBox :: (MonadIO m) => Text -> Gtk.Widget -> m Gtk.Widget
@@ -75,7 +79,7 @@ x11FullWorkspaceNames =
x11WorkspaceLabelSetter :: X11Workspaces.Workspace -> X11Workspaces.WorkspacesIO String x11WorkspaceLabelSetter :: X11Workspaces.Workspace -> X11Workspaces.WorkspacesIO String
x11WorkspaceLabelSetter workspace = x11WorkspaceLabelSetter workspace =
remapNSP . fromMaybe "" . lookup (X11Workspaces.workspaceIdx workspace) remapNSP . fromMaybe "" . lookup (X11Workspaces.workspaceIdx workspace)
<$> liftX11Def [] x11FullWorkspaceNames <$> X11Workspaces.liftX11Def [] x11FullWorkspaceNames
where where
remapNSP "NSP" = "S" remapNSP "NSP" = "S"
remapNSP n = n remapNSP n = n
@@ -326,8 +330,7 @@ stackedMprisLabel :: Text -> Text
stackedMprisLabel raw = stackedMprisLabel raw =
let normalized = singleLineMprisLabel raw let normalized = singleLineMprisLabel raw
(top, rest) = T.breakOn " - " normalized (top, rest) = T.breakOn " - " normalized
in in if T.null rest
if T.null rest
then normalized then normalized
else top <> "\n" <> T.drop 3 rest else top <> "\n" <> T.drop 3 rest
@@ -498,18 +501,163 @@ sunLockWidget :: TaffyIO Gtk.Widget
sunLockWidget = sunLockWidget =
stackInPill "sun-lock" [simplifiedWlsunsetWidget, simplifiedScreenLockWidget] stackInPill "sun-lock" [simplifiedWlsunsetWidget, simplifiedScreenLockWidget]
type SNIPriorityMap = M.Map String Int
sniPriorityStateRelativePath :: FilePath
sniPriorityStateRelativePath = "sni-priorities.dat"
sniPriorityMin, sniPriorityMax, sniCollapsedPriorityThreshold :: Int
sniPriorityMin = -5
sniPriorityMax = 5
sniCollapsedPriorityThreshold = 0
clampSNIPriority :: Int -> Int
clampSNIPriority =
max sniPriorityMin . min sniPriorityMax
sniPriorityStatePath :: IO FilePath
sniPriorityStatePath = getUserConfigFile "taffybar" sniPriorityStateRelativePath
loadSNIPriorityMap :: IO SNIPriorityMap
loadSNIPriorityMap = do
path <- sniPriorityStatePath
exists <- doesFileExist path
if not exists
then return M.empty
else do
content <- readFile path
return $ fromMaybe M.empty $ M.fromList <$> readMaybe content
persistSNIPriorityMap :: SNIPriorityMap -> IO ()
persistSNIPriorityMap priorities = do
path <- sniPriorityStatePath
createDirectoryIfMissing True (takeDirectory path)
writeFile path (show (M.toList priorities))
nonEmptyString :: String -> Maybe String
nonEmptyString value
| null value = Nothing
| otherwise = Just value
priorityKeyCandidates :: SNIHost.ItemInfo -> [String]
priorityKeyCandidates info =
concat
[ map ("item-id:" ++) (maybeToList (SNIHost.itemId info >>= nonEmptyString)),
map ("icon-name:" ++) (maybeToList (nonEmptyString (SNIHost.iconName info))),
map ("icon-title:" ++) (maybeToList (nonEmptyString (SNIHost.iconTitle info)))
]
priorityKeyFromItem :: SNIHost.ItemInfo -> Maybe String
priorityKeyFromItem = listToMaybe . priorityKeyCandidates
itemMatchesPriorityKey :: String -> SNIHost.ItemInfo -> Bool
itemMatchesPriorityKey key info =
case stripPrefix "item-id:" key of
Just itemIdKey -> SNIHost.itemId info == Just itemIdKey
Nothing ->
case stripPrefix "icon-name:" key of
Just iconNameKey -> SNIHost.iconName info == iconNameKey
Nothing ->
case stripPrefix "icon-title:" key of
Just iconTitleKey -> SNIHost.iconTitle info == iconTitleKey
Nothing -> False
priorityMatchersFromMap :: SNIPriorityMap -> [SNITray.TrayItemMatcher]
priorityMatchersFromMap priorities =
let sortedEntries = sortOn snd $ M.toList priorities
entryMatcher (key, priority) =
SNITray.mkTrayItemMatcher
(printf "priority:%d:%s" priority key)
(itemMatchesPriorityKey key)
fallbackMatcher =
SNITray.mkTrayItemMatcher "priority:fallback" (const True)
in
if null sortedEntries
then []
else map entryMatcher sortedEntries ++ [fallbackMatcher]
collapsedPriorityCutoffFromMap :: SNIPriorityMap -> Int
collapsedPriorityCutoffFromMap priorities =
let sortedEntries = sortOn snd $ M.toList priorities
visibleIndexes =
[ idx
| (idx, (_, priority)) <- zip [0 :: Int ..] sortedEntries,
priority <= sniCollapsedPriorityThreshold
]
in fromMaybe (-1) (safeLast visibleIndexes)
where
safeLast [] = Nothing
safeLast xs = Just (last xs)
hasPriorityGestureModifiers :: [Gdk.ModifierType] -> Bool
hasPriorityGestureModifiers modifiers =
Gdk.ModifierTypeControlMask `elem` modifiers
&& Gdk.ModifierTypeShiftMask `elem` modifiers
modifyPriorityMap ::
IORef SNIPriorityMap ->
String ->
(Maybe Int -> Maybe Int) ->
IO ()
modifyPriorityMap prioritiesRef key editPriority = do
modifyIORef' prioritiesRef (M.alter editPriority key)
readIORef prioritiesRef >>= persistSNIPriorityMap
priorityClickHookFromRef :: IORef SNIPriorityMap -> SNITray.TrayClickHook
priorityClickHookFromRef prioritiesRef clickContext = do
let modifiers = SNITray.trayClickModifiers clickContext
button = SNITray.trayClickButton clickContext
clickedInfo = SNITray.trayClickItemInfo clickContext
updatePriority delta =
Just . clampSNIPriority . maybe 0 (+ delta)
case (hasPriorityGestureModifiers modifiers, priorityKeyFromItem clickedInfo) of
(False, _) -> return SNITray.UseDefaultClickAction
(_, Nothing) -> return SNITray.UseDefaultClickAction
(True, Just key) -> do
case button of
1 ->
modifyPriorityMap prioritiesRef key (updatePriority (-1))
2 ->
modifyPriorityMap prioritiesRef key (const Nothing)
3 ->
modifyPriorityMap prioritiesRef key (updatePriority 1)
_ ->
return ()
return SNITray.ConsumeClick
sniTrayWidget :: TaffyIO Gtk.Widget sniTrayWidget :: TaffyIO Gtk.Widget
sniTrayWidget = do sniTrayWidget = do
-- If the Haskell backend regresses, flip at runtime: -- If the Haskell backend regresses, flip at runtime:
-- TAFFYBAR_SNI_MENU_BACKEND=lib -- TAFFYBAR_SNI_MENU_BACKEND=lib
backendEnv <- liftIO (lookupEnv "TAFFYBAR_SNI_MENU_BACKEND") backendEnv <- liftIO (lookupEnv "TAFFYBAR_SNI_MENU_BACKEND")
priorityMap <- liftIO loadSNIPriorityMap
priorityMapRef <- liftIO (newIORef priorityMap)
let menuBackend = let menuBackend =
case fmap (map toLower) backendEnv of case fmap (map toLower) backendEnv of
Just "lib" -> SNITray.LibDBusMenu Just "lib" -> SNITray.LibDBusMenu
_ -> SNITray.HaskellDBusMenu _ -> SNITray.HaskellDBusMenu
trayEventHooks =
SNITray.defaultTrayEventHooks
{ SNITray.trayClickHook = Just (priorityClickHookFromRef priorityMapRef)
}
trayParams =
SNITray.defaultTrayParams
{ SNITray.trayMenuBackend = menuBackend,
SNITray.trayOverlayScale = 1 % 3,
SNITray.trayEventHooks = trayEventHooks
}
trayPriorityConfig =
SNITray.defaultTrayPriorityConfig
{ SNITray.trayPriorityMatchers = priorityMatchersFromMap priorityMap
}
sniTrayConfig =
defaultSNITrayConfig
{ sniTrayTrayParams = trayParams,
sniTrayPriorityConfig = trayPriorityConfig
}
decorateWithClassAndBoxM decorateWithClassAndBoxM
"sni-tray" "sni-tray"
(sniTrayNewFromParams (SNITray.defaultTrayParams {SNITray.trayMenuBackend = menuBackend, SNITray.trayOverlayScale = 1 % 3})) (sniTrayNewFromConfig sniTrayConfig)
-- ** Layout -- ** Layout