{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -fno-warn-deprecations #-}
module Main where
import Control.Monad
import DBus.Client
import Data.Int
import Data.Char (toLower)
import Data.Maybe
import Data.Ratio
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import Data.Version (showVersion)
import qualified DBus as DBus
import qualified GI.Gdk as Gdk
import qualified GI.Gtk as Gtk
import Graphics.UI.GIGtkStrut
import Options.Applicative
import qualified StatusNotifier.Host.Service as Host
import StatusNotifier.TransparentWindow
import StatusNotifier.Tray
import System.Log.Logger
import System.Posix.Process
import System.Environment (lookupEnv)
import System.Exit (exitFailure)
import Text.Printf
import Paths_gtk_sni_tray (version)
import qualified GI.GtkLayerShell as GtkLayerShell
data Backend = BackendX11 | BackendWayland deriving (Eq, Show)
data BackendChoice = BackendAuto | BackendX11Choice | BackendWaylandChoice
deriving (Eq, Show, Read)
detectBackend :: IO Backend
detectBackend = do
supported <- GtkLayerShell.isSupported
pure $ if supported then BackendWayland else BackendX11
backendChoiceP :: Parser BackendChoice
backendChoiceP =
option (eitherReader parseBackendChoice)
( long "backend"
<> help "Backend selection: auto | x11 | wayland"
<> value BackendAuto
<> metavar "BACKEND"
)
where
parseBackendChoice s =
case map toLower s of
"auto" -> Right BackendAuto
"x11" -> Right BackendX11Choice
"wayland" -> Right BackendWaylandChoice
_ -> Left "expected one of: auto, x11, wayland"
logRuntimeInfo :: BackendChoice -> Backend -> IO ()
logRuntimeInfo backendChoice backend = do
sessionType <- lookupEnv "XDG_SESSION_TYPE"
waylandDisplay <- lookupEnv "WAYLAND_DISPLAY"
gdkBackend <- lookupEnv "GDK_BACKEND"
mDisplay <- Gdk.displayGetDefault
displayName <- case mDisplay of
Nothing -> pure Nothing
Just d -> Just <$> Gdk.displayGetName d
layerShellSupported <- GtkLayerShell.isSupported
logM "StatusNotifier.StandaloneWindow" INFO $
printf "backendChoice=%s backend=%s layerShellSupported=%s XDG_SESSION_TYPE=%s WAYLAND_DISPLAY=%s GDK_BACKEND=%s gdkDisplay=%s"
(show backendChoice)
(show backend)
(show layerShellSupported)
(show sessionType)
(show waylandDisplay)
(show gdkBackend)
(show displayName)
hasStatusNotifierWatcher :: Client -> IO Bool
hasStatusNotifierWatcher client = do
let mc =
(DBus.methodCall dbusPath
(DBus.interfaceName_ "org.freedesktop.DBus")
(DBus.memberName_ "NameHasOwner"))
{ DBus.methodCallDestination = Just dbusName
, DBus.methodCallBody = [DBus.toVariant ("org.kde.StatusNotifierWatcher" :: String)]
}
reply <- call_ client mc
case DBus.methodReturnBody reply of
[v] -> pure $ fromMaybe False (DBus.fromVariant v)
_ -> pure False
setupLayerShellWindow :: StrutConfig -> Gtk.Window -> Bool -> IO ()
setupLayerShellWindow StrutConfig
{ strutWidth = widthSize
, strutHeight = heightSize
, strutXPadding = xpadding
, strutYPadding = ypadding
, strutMonitor = monitorNumber
, strutPosition = position
, strutAlignment = alignment
, strutDisplayName = maybeDisplayName
} window reserveSpace = do
supported <- GtkLayerShell.isSupported
unless supported $
logM "StatusNotifier.StandaloneWindow" WARNING $
"Wayland detected, but gtk-layer-shell is not supported; falling back to a regular toplevel window"
when supported $ do
Gtk.windowSetDecorated window False
maybeDisplay <- maybe Gdk.displayGetDefault Gdk.displayOpen maybeDisplayName
case maybeDisplay of
Nothing -> logM "StatusNotifier.StandaloneWindow" WARNING "Failed to get GDK display for layer-shell"
Just display -> do
nMonitors <- Gdk.displayGetNMonitors display
logM "StatusNotifier.StandaloneWindow" INFO $ printf "GDK monitors reported: %d" nMonitors
let tryIndex idx = if idx < 0 || idx >= nMonitors then pure Nothing else Gdk.displayGetMonitor display idx
mPrimary <- Gdk.displayGetPrimaryMonitor display
mChosen <- case monitorNumber of
Nothing -> pure mPrimary
Just idx -> tryIndex idx
monitor <-
case mChosen <|> mPrimary of
Just m -> pure (Just m)
Nothing -> tryIndex 0
GtkLayerShell.initForWindow window
GtkLayerShell.setKeyboardMode window GtkLayerShell.KeyboardModeNone
GtkLayerShell.setNamespace window (T.pack "gtk-sni-tray")
GtkLayerShell.setLayer window GtkLayerShell.LayerTop
GtkLayerShell.setMargin window GtkLayerShell.EdgeLeft xpadding
GtkLayerShell.setMargin window GtkLayerShell.EdgeRight xpadding
GtkLayerShell.setMargin window GtkLayerShell.EdgeTop ypadding
GtkLayerShell.setMargin window GtkLayerShell.EdgeBottom ypadding
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
let fallbackExclusive =
if reserveSpace
then case position of
TopPos -> case heightSize of ExactSize h -> h + 2 * ypadding; _ -> 0
BottomPos -> case heightSize of ExactSize h -> h + 2 * ypadding; _ -> 0
LeftPos -> case widthSize of ExactSize w -> w + 2 * xpadding; _ -> 0
RightPos -> case widthSize of ExactSize w -> w + 2 * xpadding; _ -> 0
else 0
GtkLayerShell.setExclusiveZone window fallbackExclusive
case monitor of
Nothing -> logM "StatusNotifier.StandaloneWindow" WARNING "Failed to select a GDK monitor for layer-shell; using fallback sizing/anchors"
Just m -> do
GtkLayerShell.setMonitor window m
isPrim <- Gdk.monitorIsPrimary m
model <- Gdk.monitorGetModel m
manuf <- Gdk.monitorGetManufacturer m
logM "StatusNotifier.StandaloneWindow" INFO $
printf "Using monitor primary=%s manufacturer=%s model=%s"
(show isPrim) (show manuf) (show model)
monitorGeometry <- Gdk.monitorGetGeometry m
monitorWidth <- Gdk.getRectangleWidth monitorGeometry
monitorHeight <- Gdk.getRectangleHeight monitorGeometry
let availableWidth = monitorWidth - (2 * xpadding)
availableHeight = monitorHeight - (2 * ypadding)
width =
case widthSize of
ExactSize w -> w
ScreenRatio p ->
floor $ p * fromIntegral availableWidth
height =
case heightSize of
ExactSize h -> h
ScreenRatio p ->
floor $ p * fromIntegral availableHeight
clampNonNegative x = if x < 0 then 0 else x
centerOffset availSize size =
clampNonNegative $ (availSize - size) `div` 2
endOffset availSize size =
clampNonNegative $ availSize - size
(leftMargin, rightMargin, topMargin, bottomMargin) =
case position of
TopPos ->
let offset =
if width >= availableWidth
then 0
else case alignment of
Beginning -> 0
Center -> centerOffset availableWidth width
End -> endOffset availableWidth width
l = xpadding + offset
r = xpadding
in (l, r, ypadding, ypadding)
BottomPos ->
let offset =
if width >= availableWidth
then 0
else case alignment of
Beginning -> 0
Center -> centerOffset availableWidth width
End -> endOffset availableWidth width
l = xpadding + offset
r = xpadding
in (l, r, ypadding, ypadding)
LeftPos ->
let offset =
if height >= availableHeight
then 0
else case alignment of
Beginning -> 0
Center -> centerOffset availableHeight height
End -> endOffset availableHeight height
t = ypadding + offset
b = ypadding
in (xpadding, xpadding, t, b)
RightPos ->
let offset =
if height >= availableHeight
then 0
else case alignment of
Beginning -> 0
Center -> centerOffset availableHeight height
End -> endOffset availableHeight height
t = ypadding + offset
b = ypadding
in (xpadding, xpadding, t, b)
exclusive =
if reserveSpace
then case position of
TopPos -> height + topMargin
BottomPos -> height + bottomMargin
LeftPos -> width + leftMargin
RightPos -> width + rightMargin
else 0
Gtk.windowSetDefaultSize window (fromIntegral width) (fromIntegral height)
let (reqWidth, reqHeight) =
case position of
TopPos -> (min width availableWidth, height)
BottomPos -> (min width availableWidth, height)
LeftPos -> (width, min height availableHeight)
RightPos -> (width, min height availableHeight)
Gtk.widgetSetSizeRequest window
(fromIntegral reqWidth)
(fromIntegral reqHeight)
GtkLayerShell.setMargin window GtkLayerShell.EdgeLeft leftMargin
GtkLayerShell.setMargin window GtkLayerShell.EdgeRight rightMargin
GtkLayerShell.setMargin window GtkLayerShell.EdgeTop topMargin
GtkLayerShell.setMargin window GtkLayerShell.EdgeBottom bottomMargin
case position of
TopPos -> do
setAnchor GtkLayerShell.EdgeTop True
setAnchor GtkLayerShell.EdgeBottom False
if width >= availableWidth
then do
setAnchor GtkLayerShell.EdgeLeft True
setAnchor GtkLayerShell.EdgeRight True
else case alignment of
Beginning -> do
setAnchor GtkLayerShell.EdgeLeft True
setAnchor GtkLayerShell.EdgeRight False
Center -> do
setAnchor GtkLayerShell.EdgeLeft True
setAnchor GtkLayerShell.EdgeRight False
End -> do
setAnchor GtkLayerShell.EdgeLeft False
setAnchor GtkLayerShell.EdgeRight True
BottomPos -> do
setAnchor GtkLayerShell.EdgeTop False
setAnchor GtkLayerShell.EdgeBottom True
if width >= availableWidth
then do
setAnchor GtkLayerShell.EdgeLeft True
setAnchor GtkLayerShell.EdgeRight True
else case alignment of
Beginning -> do
setAnchor GtkLayerShell.EdgeLeft True
setAnchor GtkLayerShell.EdgeRight False
Center -> do
setAnchor GtkLayerShell.EdgeLeft True
setAnchor GtkLayerShell.EdgeRight False
End -> do
setAnchor GtkLayerShell.EdgeLeft False
setAnchor GtkLayerShell.EdgeRight True
LeftPos -> do
setAnchor GtkLayerShell.EdgeLeft True
setAnchor GtkLayerShell.EdgeRight False
if height >= availableHeight
then do
setAnchor GtkLayerShell.EdgeTop True
setAnchor GtkLayerShell.EdgeBottom True
else case alignment of
Beginning -> do
setAnchor GtkLayerShell.EdgeTop True
setAnchor GtkLayerShell.EdgeBottom False
Center -> do
setAnchor GtkLayerShell.EdgeTop True
setAnchor GtkLayerShell.EdgeBottom False
End -> do
setAnchor GtkLayerShell.EdgeTop False
setAnchor GtkLayerShell.EdgeBottom True
RightPos -> do
setAnchor GtkLayerShell.EdgeLeft False
setAnchor GtkLayerShell.EdgeRight True
if height >= availableHeight
then do
setAnchor GtkLayerShell.EdgeTop True
setAnchor GtkLayerShell.EdgeBottom True
else case alignment of
Beginning -> do
setAnchor GtkLayerShell.EdgeTop True
setAnchor GtkLayerShell.EdgeBottom False
Center -> do
setAnchor GtkLayerShell.EdgeTop True
setAnchor GtkLayerShell.EdgeBottom False
End -> do
setAnchor GtkLayerShell.EdgeTop False
setAnchor GtkLayerShell.EdgeBottom True
GtkLayerShell.setExclusiveZone window exclusive
positionP :: Parser StrutPosition
positionP = fromMaybe TopPos <$> optional
( flag' TopPos
( long "top"
<> help "Position the bar at the top of the screen"
)
<|> flag' BottomPos
( long "bottom"
<> help "Position the bar at the bottom of the screen"
)
<|> flag' LeftPos
( long "left"
<> help "Position the bar on the left side of the screen"
)
<|> flag' RightPos
( long "right"
<> help "Position the bar on the right side of the screen"
))
alignmentP :: Parser StrutAlignment
alignmentP = fromMaybe Center <$> optional
( flag' Beginning
( long "beginning"
<> help "Use beginning alignment"
)
<|> flag' Center
( long "center"
<> help "Use center alignment"
)
<|> flag' End
( long "end"
<> help "Use end alignment"
))
sizeP :: Parser Int32
sizeP =
option auto
( long "size"
<> short 's'
<> help "Set the size of the bar"
<> value 30
<> metavar "SIZE"
)
paddingP :: Parser Int32
paddingP =
option auto
( long "padding"
<> short 'p'
<> help "Set the padding of the bar"
<> value 0
<> metavar "PADDING"
)
monitorNumberP :: Parser [Int32]
monitorNumberP = many $
option auto
( long "monitor"
<> short 'm'
<> help "Display a tray bar on the given monitor"
<> metavar "MONITOR"
)
logP :: Parser Priority
logP =
option auto
( long "log-level"
<> short 'l'
<> help "Set the log level"
<> metavar "LEVEL"
<> value WARNING
)
colorP :: Parser (Maybe String)
colorP = optional $
strOption
( long "color"
<> short 'c'
<> help "Set the background color of the tray; See https://developer.gnome.org/gdk3/stable/gdk3-RGBA-Colors.html#gdk-rgba-parse for acceptable values"
<> metavar "COLOR"
)
expandP :: Parser Bool
expandP =
switch
( long "expand"
<> help "Let icons expand into the space allocated to the tray"
<> short 'e'
)
centerIconsP :: Parser Bool
centerIconsP =
switch
( long "center-icons"
<> help "Center the tray icons within the bar"
)
startWatcherP :: Parser Bool
startWatcherP =
switch
( long "watcher"
<> short 'w'
<> help "Start a Watcher to handle SNI registration if one does not exist"
)
noStrutP :: Parser Bool
noStrutP =
switch
( long "no-strut"
<> help "Do not reserve space for the window (X11: no strut; Wayland: exclusive zone 0)"
)
barLengthP :: Parser Rational
barLengthP =
option auto
( long "length"
<> help "Set the proportion of the screen that the tray bar should occupy -- values are parsed as haskell rationals (e.g. 1 % 2)"
<> value 1
)
overlayScaleP :: Parser Rational
overlayScaleP =
option auto
( long "overlay-scale"
<> short 'o'
<> help "The proportion of the tray icon's size that should be set for overlay icons."
<> value (5 % 7)
)
menuBackendP :: Parser MenuBackend
menuBackendP =
option (eitherReader parseMenuBackend)
( long "menu-backend"
<> help "Menu backend: haskell (default) | libdbusmenu"
<> value HaskellDBusMenu
<> metavar "BACKEND"
)
where
parseMenuBackend s =
case map toLower s of
"libdbusmenu" -> Right LibDBusMenu
"haskell" -> Right HaskellDBusMenu
_ -> Left "expected one of: libdbusmenu, haskell"
getColor :: String -> IO Gdk.RGBA
getColor colorString = do
rgba <- Gdk.newZeroRGBA
colorParsed <- Gdk.rGBAParse rgba (T.pack colorString)
unless colorParsed $ do
logM "StatusNotifier.Tray" WARNING "Failed to parse provided color"
void $ Gdk.rGBAParse rgba "#000000"
return rgba
buildWindows :: StrutPosition
-> StrutAlignment
-> Int32
-> Int32
-> [Int32]
-> Priority
-> BackendChoice
-> Maybe String
-> Bool
-> Bool
-> Bool
-> Bool
-> Rational
-> Rational
-> MenuBackend
-> IO ()
buildWindows pos align size padding monitors priority backendChoice maybeColorString expand
centerIcons startWatcher noStrut barLength overlayScale menuBackend = do
_ <- Gtk.init Nothing
logger <- getLogger "StatusNotifier"
saveGlobalLogger $ setLevel priority logger
detectedBackend <- detectBackend
let backend =
case backendChoice of
BackendAuto -> detectedBackend
BackendX11Choice -> BackendX11
BackendWaylandChoice -> BackendWayland
client <- connectSession
logRuntimeInfo backendChoice backend
watcherPresent <- hasStatusNotifierWatcher client
unless watcherPresent $ do
logM "StatusNotifier" WARNING $
"No StatusNotifierWatcher found on D-Bus (org.kde.StatusNotifierWatcher). Tray will likely be empty."
unless startWatcher $
logM "StatusNotifier" WARNING $
"Start a watcher first (recommended) or run with --watcher to start one in-process."
_ <- getRootLogger
pid <- getProcessID
host <-
Host.build
Host.defaultParams
{ Host.dbusClient = Just client
, Host.uniqueIdentifier = printf "standalone-%s" $ show pid
, Host.startWatcher = startWatcher
}
>>= maybe (logM "StatusNotifier" ERROR "Failed to start StatusNotifier host" >> exitFailure) pure
initialItems <- Host.itemInfoMap host
logM "StatusNotifier" INFO $ printf "Initial tray items: %d" (Map.size initialItems)
let c1 =
defaultStrutConfig
{ strutPosition = pos
, strutAlignment = align
, strutXPadding = padding
, strutYPadding = padding
}
defaultRatio = ScreenRatio barLength
configBase =
case pos of
TopPos -> c1 {strutHeight = ExactSize size, strutWidth = defaultRatio}
BottomPos ->
c1 {strutHeight = ExactSize size, strutWidth = defaultRatio}
RightPos ->
c1 {strutHeight = defaultRatio, strutWidth = ExactSize size}
LeftPos ->
c1 {strutHeight = defaultRatio, strutWidth = ExactSize size}
buildWithConfig config = do
let orientation =
case strutPosition config of
TopPos -> Gtk.OrientationHorizontal
BottomPos -> Gtk.OrientationHorizontal
_ -> Gtk.OrientationVertical
tray <-
buildTray host client
TrayParams
{ trayOrientation = orientation
, trayImageSize = Expand
, trayIconExpand = expand
, trayAlignment = align
, trayOverlayScale = overlayScale
, trayLeftClickAction = Activate
, trayMiddleClickAction = SecondaryActivate
, trayRightClickAction = PopupMenu
, trayMenuBackend = menuBackend
, trayCenterIcons = centerIcons
}
window <- Gtk.windowNew Gtk.WindowTypeToplevel
Gtk.windowSetResizable window False
Gtk.windowSetSkipTaskbarHint window True
Gtk.windowSetSkipPagerHint window True
Gtk.windowSetAcceptFocus window False
Gtk.windowSetFocusOnMap window False
Gtk.windowSetKeepAbove window True
Gtk.windowSetTypeHint window Gdk.WindowTypeHintDock
case backend of
BackendX11 ->
when (not noStrut) $ setupStrutWindow config window
BackendWayland ->
setupLayerShellWindow config window (not noStrut)
maybe
(makeWindowTransparent window)
(getColor >=>
Gtk.widgetOverrideBackgroundColor window [Gtk.StateFlagsNormal] .
Just)
maybeColorString
Gtk.containerAdd window tray
Gtk.widgetShowAll window
runForMonitor monitor =
buildWithConfig configBase {strutMonitor = Just monitor}
if null monitors
then buildWithConfig configBase
else mapM_ runForMonitor monitors
Gtk.main
parser :: Parser (IO ())
parser =
buildWindows <$> positionP <*> alignmentP <*> sizeP <*> paddingP <*>
monitorNumberP <*> logP <*> backendChoiceP <*> colorP <*> expandP <*>
centerIconsP <*> startWatcherP <*>
noStrutP <*> barLengthP <*> overlayScaleP <*> menuBackendP
versionOption :: Parser (a -> a)
versionOption = infoOption
(printf "gtk-sni-tray-standalone %s" $ showVersion version)
( long "version"
<> help "Show the version number of gtk-sni-tray"
)
main :: IO ()
main =
join $ execParser $ info (helper <*> versionOption <*> parser)
( fullDesc
<> progDesc "Run a standalone StatusNotifierItem/AppIndicator tray"
)