Introducing sectile

Following antikythera and numerus-closus, I've been working for a few days on sectile, which will be released soon, to compose tmux status bar.

Unlike existing libraries and tools, it aims to be both flexible and simple.

A status bar is composed of styled segments with the following syntax:

[bg=red,fg=white]something[fg=black]something else

Usually, status bar frameworks allow chaining segments' colors, which leads to the following design:

newtype Segment m = Segment
  { runSegment :: m (ChunkStyle -> [Chunk])
  }

Note: We could have declared ChunkStyle -> m [Chunk], but it would force us to run each segment sequentially, while this design allows parallel runs and sequential application.

By itself, Chunk is defined as follows:

data Chunk = Chunk
  { chunkText :: Text,
    chunkStyle :: ChunkStyle
  }

data ChunkStyle = ChunkStyle
  { chunkStyleForeground :: Maybe Colour,
    chunkStyleBackground :: Maybe Colour,
    chunkStyleItalic :: Maybe Bool,
    chunkStyleStrikethrough :: Maybe Bool,
    chunkStyleSwapForegroundBackground :: Maybe Bool,
    chunkStyleConcealed :: Maybe Bool,
    chunkStyleOverlined :: Maybe Bool,
    chunkStyleConsoleIntensity :: Maybe ConsoleIntensity,
    chunkStyleUnderlining :: Maybe Underlining,
    chunkStyleBlinking :: Maybe Blinking,
    chunkStyleHyperlink :: Maybe Text
  }

We can start by creating a trivial hard-coded string, defined as follows:

string :: (Applicative m) => T.Text -> Segment m
string txt =
  Segment $
    pure $
      \style ->
        let (_finalStyle, rendered) = Colour.parseAnsiChunks style txt
         in rendered

We need to parse the given text, as the input text could contain some style itself.The issue in the previous snippet is that we throw the final style, which is used for chaining. Let's add some types:

newtype Segment m = Segment
  { runSegment :: m (Colour.ChunkStyle -> Formatted)
  }

data Formatted = Formatted
  { rendered :: [Colour.Chunk],
    finalStyle :: Colour.ChunkStyle
  }

Then we should rewrite string as follows:

string :: (Applicative m) => T.Text -> Segment m
string txt =
  Segment $
    pure $
      \style ->
        let (finalStyle, rendered) = Colour.parseAnsiChunks style txt
         in Formatted {..}

Let's see how to leverage the initial design, with row, turning a list of segments into one, as follows:

type SegmentsRunner m =
  (Segment m -> m (Colour.ChunkStyle -> Formatted)) ->
  [Segment m] ->
  m [Colour.ChunkStyle -> Formatted]

row :: (Monad m) => SegmentsRunner m -> Name -> [Segment m] -> Segment m
row runSegments (Name name) ss =
  Segment $ do
    formats <- runSegments (.runSegment) ss
    pure $
      \style ->
        let (finalStyle, formatteds) =
              let safeLast = foldl' (const Just) Nothing
                  go (lastStyle, fs) f =
                    let fmt = f lastStyle
                     in (maybe lastStyle Colour.chunkStyle $ safeLast fmt.rendered, fmt : fs)
               in reverse <$> foldl' go (style, []) formats
            rendered = concatMap (.rendered) formatteds
         in Formatted {..}

Two things about this implementation:

  • We give a function to evaluate the list of segments, which means the evaluation could be parallel, as well as sequential
  • After the evaluation, each Formatted is piped to the next one, creating a bigger Formatted, itself wrapped in a Segment.Beyond this, we can support any shell command, as follows:
sh :: Name -> String -> Maybe [(String, String)] -> Segment IO
sh (Name name) cmd env =
  Segment $ do
    let proc =
          (Process.shell cmd)
            { Process.env = env,
              Process.std_in = Process.CreatePipe,
              Process.std_out = Process.CreatePipe,
              Process.std_err = Process.CreatePipe
            }
    result <- tryReadProcess proc
    let stdout = case result of
          Right out -> T.pack out
          Left _ -> "Error on " <> TL.toStrict (TLE.decodeUtf8 (B.toLazyByteString name))
    pure $
      \style ->
        let (finalStyle, rendered) = Colour.parseAnsiChunks style stdout
         in Formatted {..}

I plan to release sectile both as a binary and as a library; for instance, the time segment is directly based on time and is defined as follows:

time :: Name -> String -> Segment IO
time (Name name) format =
  Segment $ do
    result <- tryIO $ Time.formatTime Time.defaultTimeLocale format <$> Time.getZonedTime
    let txt = case result of
          Right t -> T.pack t
          Left _ -> "Error on " <> TL.toStrict (TLE.decodeUtf8 (B.toLazyByteString name))
    pure $
      \style ->
        let (finalStyle, rendered) = Colour.parseAnsiChunks style txt
         in Formatted {..}

Beyond this, it will also be shipped with helpers to update the color and be defined as follows:

changeStyle :: (Functor m) => (Colour.ChunkStyle -> Colour.ChunkStyle) -> Segment m -> Segment m
changeStyle c (Segment s) = Segment $ (. c) <$> s

forceStyle :: (Functor m) => (Colour.ChunkStyle -> Colour.ChunkStyle) -> Segment m -> Segment m
forceStyle c (Segment s) = Segment $ force <$> s
  where
    force f style =
      let formatted = f style
       in formatted
            { rendered = updateChunk <$> formatted.rendered,
              finalStyle = c formatted.finalStyle,
              explain = \renderer -> formatted.explain $ renderer . map updateChunk
            }
    updateChunk chunk = chunk {Colour.chunkStyle = c $ Colour.chunkStyle chunk}

changeStyle works on the input style, while forceStyle changes the generated chunks.

There are also helpers around segment size (padding, max size, and fixed size), conditional colors, and themes.

At this time I'm working on giving more flexibility to the format of each segment.