sectile-0.1.0.0: src/Data/Sectile/Segments.hs
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- |
-- Module : Data.Sectile.Segments
-- Copyright : Gautier DI FOLCO
-- License : ISC
--
-- Maintainer : Gautier DI FOLCO <foss@difolco.dev>
-- Stability : Stable
-- Portability : Portable
module Data.Sectile.Segments
( -- * Core builders
string,
ScopingBindings (..),
row,
sh,
time,
volume,
mpris,
git,
httpPoll,
PropagatingStyle (..),
reformat,
)
where
import qualified Control.Exception
import Control.Monad (void)
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Bifunctor (first)
import qualified Data.ByteString.Builder as B
import qualified Data.ByteString.Lazy as LBS
import qualified Data.HashMap.Strict as HashMap
import qualified Data.List as List
import Data.Maybe (catMaybes, fromMaybe)
import qualified Data.Sectile.Tmux as Colour
import Data.Sectile.Types
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import qualified Data.Time as Time
import qualified Data.Time.Clock.POSIX as POSIX
import qualified System.Process as Process
import qualified Text.EDE as EDE
-- | Create a pure text segment.
string :: (Applicative m) => T.Text -> Segment m
string txt =
Segment $
pure $ do
currentSt <- currentStyle
bnds <- currentBindings
let (finalStyle, rendered) = Colour.parseAnsiChunks currentSt txt
explain renderStyle renderChunks =
DetailList $
[ DetailPlain "Type: string",
DetailPlain $ "Value: " <> T.encodeUtf8Builder txt,
DetailPlain $ "Style: " <> fromMaybe "<none>" (renderStyle currentSt) <> " -> " <> fromMaybe "<none>" (renderStyle finalStyle),
DetailPlain $ "Rendered: " <> renderChunks rendered
]
<> bindingsDetail bnds
_ <- updateStyle (const finalStyle)
pure Formatted {..}
-- | Scope or propagate bindings
data ScopingBindings
= Isolating
| Propagating
deriving stock (Eq, Show)
-- | Combine multiple segments into a named row.
row :: (Monad m) => SegmentsRunner m -> ScopingBindings -> Name -> [Segment m] -> Segment m
row runSegments scopingBindings name@(Name nameBuilder) ss =
Segment $ do
formattedsM <- runSegments (.runSegment) ss
pure $ scopeBindings name $ do
initialBindings <- currentBindings
let rebindings =
case scopingBindings of
Propagating -> pure ()
Isolating -> void $ updateBindings $ const initialBindings
formatteds <- mapM (<* rebindings) formattedsM
let rendered = concatMap (.rendered) formatteds
explain :: (Colour.ChunkStyle -> Maybe B.Builder) -> ([Colour.Chunk] -> B.Builder) -> Detail B.Builder
explain renderStyle renderChunks =
DetailList $
[ DetailPlain $ "Name: " <> nameBuilder,
DetailPlain "Type: row",
DetailPlain $ "Rendered: " <> renderChunks rendered,
DetailPlain "Details:"
]
<> map (\formatted -> DetailNested $ formatted.explain renderStyle renderChunks) formatteds
pure Formatted {..}
-- | Run a shell command and capture its stdout as a segment.
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 $ do
currentSt <- currentStyle
bnds <- currentBindings
let (finalStyle, rendered) = Colour.parseAnsiChunks currentSt stdout
explain renderStyle renderChunks =
DetailList $
[ DetailPlain $ "Name: " <> name,
DetailPlain "Type: sh",
DetailPlain $ "Command: " <> T.encodeUtf8Builder (T.pack cmd),
DetailPlain $ "STDOUT: " <> T.encodeUtf8Builder stdout,
DetailPlain $ "Style: " <> fromMaybe "<none>" (renderStyle currentSt) <> " -> " <> fromMaybe "<none>" (renderStyle finalStyle),
DetailPlain $ "Rendered: " <> renderChunks rendered
]
<> bindingsDetail bnds
_ <- updateStyle (const finalStyle)
pure Formatted {..}
-- | Display the current time formatted with the given format string.
time :: Name -> String -> Segment IO
time (Name name) format =
Segment $ do
result <- tryIO $ Time.formatTime Time.defaultTimeLocale format <$> Time.getZonedTime
posix <- POSIX.getPOSIXTime
let txt = case result of
Right t -> T.pack t
Left _ -> "Error on " <> TL.toStrict (TLE.decodeUtf8 (B.toLazyByteString name))
let nameT = T.decodeUtf8 (LBS.toStrict (B.toLazyByteString name))
let generatedBnds = HashMap.singleton (nameT <> ".raw") (Aeson.Number (realToFrac posix))
pure $ do
currentSt <- currentStyle
_ <- appendBindings generatedBnds
bnds <- currentBindings
let (finalStyle, rendered) = Colour.parseAnsiChunks currentSt txt
explain renderStyle renderChunks =
DetailList $
[ DetailPlain $ "Name: " <> name,
DetailPlain "Type: time",
DetailPlain $ "Format: " <> T.encodeUtf8Builder (T.pack format),
DetailPlain $ "Formatted: " <> T.encodeUtf8Builder txt,
DetailPlain $ "Style: " <> fromMaybe "<none>" (renderStyle currentSt) <> " -> " <> fromMaybe "<none>" (renderStyle finalStyle),
DetailPlain $ "Rendered: " <> renderChunks rendered
]
<> bindingsDetail bnds
_ <- updateStyle (const finalStyle)
pure Formatted {..}
-- | Display the current volume using wpctl (Pipewire).
volume :: Name -> Segment IO
volume name = sh name "wpctl get-volume @DEFAULT_AUDIO_SINK@" Nothing
-- | Display the currently playing song via playerctl (MPRIS).
mpris :: Name -> Segment IO
mpris name = sh name "playerctl metadata --format '{{artist}} - {{title}}'" Nothing
-- | Display git branch and status for a specific repository.
git :: Name -> FilePath -> Segment IO
git name path = sh name ("git -C " <> path <> " status --porcelain -b | head -n 1") Nothing
-- | Display the result of polling an HTTP endpoint using curl.
httpPoll :: Name -> String -> Segment IO
httpPoll name url = sh name ("curl -s " <> url) Nothing
-- | Try to read a process, catching any IOException.
tryReadProcess :: Process.CreateProcess -> IO (Either IOError String)
tryReadProcess proc = tryIO (Process.readCreateProcess proc "")
-- | Try an IO action, catching IOExceptions.
tryIO :: IO a -> IO (Either IOError a)
tryIO act = (Right <$> act) `Control.Exception.catch` (pure . Left)
-- | Style propagation for reformatted segments
data PropagatingStyle
= Reset
| PropagateIncoming
| PropagateInner
deriving stock (Eq, Show)
-- | Reformat a segment's output using an EDE template.
reformat :: (Functor m) => PropagatingStyle -> T.Text -> Segment m -> Segment m
reformat propStyle format (Segment s) = Segment $ fmap transform s
where
transform action = do
oldSt <- currentStyle
formatted <- action
innerSt <- currentStyle
bnds <- currentBindings
let rawText = mconcat $ map Colour.chunkText formatted.rendered
styleText =
T.decodeUtf8 $
LBS.toStrict $
B.toLazyByteString $
Colour.renderChunksUtf8BSBuilder Colour.With24BitColours formatted.rendered
effectiveIncomingSt = case propStyle of
Reset -> Colour.noStyle
PropagateIncoming -> oldSt
PropagateInner -> innerSt
incomingStyleText =
T.replace "#[default]" "" $
T.decodeUtf8 $
LBS.toStrict $
B.toLazyByteString $
Colour.renderChunksUtf8BSBuilder Colour.With24BitColours [Colour.Chunk "" effectiveIncomingSt]
styleToObj :: T.Text -> Colour.ChunkStyle -> Aeson.Value
styleToObj sText st =
Aeson.toJSON $
HashMap.fromList $
[ ("raw" :: T.Text, Aeson.String sText)
]
<> catMaybes
[ (,) "foreground" . Aeson.String . Colour.renderColour <$> Colour.chunkStyleForeground st,
(,) "background" . Aeson.String . Colour.renderColour <$> Colour.chunkStyleBackground st,
(,) "italic" . Aeson.Bool <$> Colour.chunkStyleItalic st,
(,) "strikethrough" . Aeson.Bool <$> Colour.chunkStyleStrikethrough st,
(,) "swapForegroundBackground" . Aeson.Bool <$> Colour.chunkStyleSwapForegroundBackground st,
(,) "concealed" . Aeson.Bool <$> Colour.chunkStyleConcealed st,
(,) "overlined" . Aeson.Bool <$> Colour.chunkStyleOverlined st,
(,) "bold" . Aeson.Bool . (== Colour.BoldIntensity) <$> Colour.chunkStyleConsoleIntensity st,
(,) "dim" . Aeson.Bool . (== Colour.FaintIntensity) <$> Colour.chunkStyleConsoleIntensity st,
(,) "underlined" . Aeson.Bool . (`elem` [Colour.SingleUnderline, Colour.DoubleUnderline]) <$> Colour.chunkStyleUnderlining st,
(,) "blink" . Aeson.Bool . (`elem` [Colour.SlowBlinking, Colour.RapidBlinking]) <$> Colour.chunkStyleBlinking st,
(,) "hyperlink" . Aeson.String <$> Colour.chunkStyleHyperlink st
]
envObj =
HashMap.fromList
[ ( "_inner",
Aeson.toJSON $
HashMap.fromList
[ ("raw" :: T.Text, Aeson.String rawText),
("style" :: T.Text, styleToObj styleText innerSt)
]
),
( "_incoming",
Aeson.toJSON $
HashMap.fromList
[ ("style" :: T.Text, styleToObj incomingStyleText oldSt)
]
)
]
mergedEnv = HashMap.union envObj bnds
let explain renderStyle renderChunks =
DetailList $
[ DetailPlain "Type: reformat",
DetailPlain $ "Format: " <> T.encodeUtf8Builder format,
DetailPlain $ "PropagatingStyle: " <> B.stringUtf8 (show propStyle)
]
<> bindingsDetail mergedEnv
<> [ DetailPlain "Inner segment:",
DetailNested $ formatted.explain renderStyle renderChunks
]
case EDE.parse (T.encodeUtf8 format) of
EDE.Failure err -> do
let (_errStyle, errRendered) = Colour.parseAnsiChunks Colour.noStyle (T.pack $ show err)
pure (formatted {rendered = errRendered, explain = explain})
EDE.Success tmpl -> case EDE.render tmpl (nestify mergedEnv) of
EDE.Failure err -> do
let (_errStyle, errRendered) = Colour.parseAnsiChunks Colour.noStyle (T.pack $ show err)
pure (formatted {rendered = errRendered, explain = explain})
EDE.Success renderedText -> do
let (newStyle, newRendered) = Colour.parseAnsiChunks effectiveIncomingSt (TL.toStrict renderedText)
_ <- updateStyle (const newStyle)
pure (formatted {rendered = newRendered, explain = explain})
nestify :: HashMap.HashMap T.Text Aeson.Value -> HashMap.HashMap T.Text Aeson.Value
nestify flatMap = HashMap.fromList $ map (first Key.toText) $ KeyMap.toList $ List.foldl' insertPath KeyMap.empty (HashMap.toList flatMap)
where
insertPath :: KeyMap.KeyMap Aeson.Value -> (T.Text, Aeson.Value) -> KeyMap.KeyMap Aeson.Value
insertPath obj (key, val) = go obj (T.splitOn "." key) val
go :: KeyMap.KeyMap Aeson.Value -> [T.Text] -> Aeson.Value -> KeyMap.KeyMap Aeson.Value
go obj [] _ = obj
go obj [k] val =
let k' = Key.fromText k
in case KeyMap.lookup k' obj of
Just (Aeson.Object _) ->
obj
_ ->
KeyMap.insert k' val obj
go obj (k : ks) val =
let k' = Key.fromText k
in case KeyMap.lookup k' obj of
Just (Aeson.Object existingObj) ->
KeyMap.insert k' (Aeson.Object (go existingObj ks val)) obj
_ ->
KeyMap.insert k' (Aeson.Object (go KeyMap.empty ks val)) obj