Search code examples
haskellfunctional-programmingstate-monadio-monadxmobar

Can I have a monitor in XMobar that keeps state form one invocation to the next?


tl;dr

I guess my question could be distilled to a Y/N question: "Can the IO monad keep state only via I/O actions?" In other words, is my understanding correct that if I have to write an action

run :: IO String

that is executed repeatedly by some other IO monad instance, there's no way I can write it so that it keeps memory, from a call to the next, of a state, other than by serializing the state somewhere (e.g. to file or in the caller if it offers an API to do so)?

But if the answer is "Yes, you can't do that", then question is the one in the title: how can I write a monitor for XMobar which keeps a local state?

The full story

I'm experimenting with xmobar, and in particular with its plugins and monitors.

All monitors are run via Run $ SomeMonitor where SomeMonitor must be of type Runnable, i.e. it can be any type implementing the (Exec r, Read r, Show r) interface, of which Exec is the interesting one, because it's where you put the business logic of the plugin, as it has type IO String, so it can create the String that is displayed by XMobar using IO.

Here was my first, very easy, experiment,

data ArchUpdates = ArchUpdates deriving (Read, Show)

instance Exec ArchUpdates where
  rate _ = 36000
  run _ = fmap (makeMessage . length . lines) $ getCommandOutput "checkupdates"
  where
    makeMessage :: Int -> String
    makeMessage = show -- simplified version

where I determine how many updates are possible via pacman on my ArchLinux system.

Now, clearly the value shown by XMobar changes over time, but not because it has an explicit dependency on time; it's because the state of the system changes, and the change is retrieved via an I/O action, expressed in the code above by getCommandOutput "checkupdates".

But what if I wanted a plugin with an explicit dependency on time? I mean, a plugin that updates every second and shows, in turn, one of several strings? Say it shows first "A long time ago", then "in a galaxy far,", and finally "far away...", and then loops again.

My requirement makes me think of State, but the Exec constraint is forcing me in the IO monad, so I don't think that the StateT transformer is the way to go, because I can't use another monad wrapping IO in run. I'd rather need IO to wrap some state... but I can't change IO!

Therefore the only way I see to keep state is for serializing it somewhere, and then re-reading it:

instance Exec MyPlugin where
  rate _ = 1000
  run _ = do
    old <- getCurrentStateOfSelf
    return $ makeNewState old

where makeNewState is String -> String¹, but getCurrentStateOfSelf should be provided by XMobar's API, otherwise what'd be left? Writing the state to a file?

  run _ = do
    old <- readStateFromFile
    let new = makeNewState old
    writeStateToFile new
    return new

This would be horrible.


I tried asking ChatGPT, and it gave me this:

import Control.Monad.State

func :: StateT Int IO String
func = do
  currentState <- get
  liftIO $ putStrLn $ "Current state: " ++ show currentState
  modify (+1)
  return "Hello, World!"

main :: IO ()
main = do
  (result, newState) <- runStateT func 0
  putStrLn $ "Result: " ++ result
  putStrLn $ "Final state: " ++ show newState

but I don't think that's a solution, because main is still in charge of making multiple calls to runStateT piping the state from one call to another; it's not like multiple calls to main are accessing successive states of func, which is what I'd want.

I guess probably the answer is that I'm hitting a wall: XMobar montitors have been designed in the IO monad whereas I'd need the State monad (then clearly XMobar would still use the IO monad to show stuff on screen, because after all it is a program, so it comes from compiling a main function, which is IO ()).


(¹) In the case of the 3 distinct strings above, it could be a closure with a [String] local state that takes a String, locates it in the cycle state, and gives back the item after that.


Solution

  • The general approach in amalloy's answer of initialising an IORef in the setup code and then making it available to the loop action is sound. However, as we have found out, by doing the initialisation in the main of xmobar.hs and trying to pass it through the plugin type we end up swimming against the current of the arrangement xmobar expects for its plugins. We can instead adapt the idea while implementing Exec in terms of start (:: Exec e => e -> (String -> IO ()) -> IO ()) rather than run (:: Exec e => e -> IO String), at the modest cost of having to lay down the updating loop ourselves. (The snippet below is partly based on the Date example from xmobar's docs.)

    import Xmobar
    import Data.IORef
    import Control.Monad (forever)
    
    data MyPlugin = MyPlugin
      deriving (Read, Show)
    
    instance Exec MyPlugin where
      start MyPlugin callback = do
        ref <- newIORef 0
        forever $ do
          output <- atomicModifyIORef' ref next
          callback output
          tenthSeconds 10  -- Wait one second.
        where
        next x = (x + 1, show x)
    
    -- Then add MyPlugin to your config's commands and template as usual.
    

    Incidentally, since start allows us to write the update loop, using StateT for the updates now becomes viable as well:

    import Xmobar
    import Control.Monad.State.Strict
    import Control.Monad (forever)
    
    -- etc.
    
    instance Exec MyPlugin where
      start MyPlugin callback = evalStateT loop 0
        where
        next = gets show <* modify' (+ 1)
        loop = forever $ do
          output <- next
          liftIO $ do
            callback output
            tenthSeconds 10