packages feed

reflex-vty-1.0.0.0: src-bin/example.hs

import Control.Applicative
import Control.Concurrent
import Control.Monad
import Control.Monad.Fix
import Control.Monad.IO.Class
import Data.Functor
import Data.Functor.Misc
import Data.Map (Map)
import qualified Data.Map as Map
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time (getCurrentTime)
import qualified Graphics.Vty as V
import Reflex
import Reflex.Network

import Data.Text.Zipper (TextAlignment (..))
import qualified Data.Text.Zipper as TZ
import Example.CPU
import Reflex.Vty

type VtyExample t m =
  ( MonadFix m
  , MonadHold t m
  , Reflex t
  , HasInput t m
  , HasImageWriter t m
  , HasDisplayRegion t m
  , HasFocus t m
  , HasFocusReader t m
  , HasTheme t m
  , HasColorProfile t m
  , HasCursor t m
  , HasScreenMode t m
  )

type Manager t m =
  ( HasLayout t m
  , HasFocus t m
  )

data Example
  = Example_TextEditor
  | Example_Todo
  | Example_ScrollableTextDisplay
  | Example_ClickButtonsGetEmojis
  | Example_CPUStat
  | Example_Scrollbar
  | Example_Cursor
  | Example_Showcase
  deriving (Bounded, Enum, Eq, Ord, Read, Show)

withCtrlC :: (Monad m, HasInput t m, Reflex t) => m () -> m (Event t ())
withCtrlC f = do
  inp <- input
  f
  return $ fforMaybe inp $ \case
    V.EvKey (V.KChar 'c') [V.MCtrl] -> Just ()
    _ -> Nothing

-- * Shared styling helpers

-- | The brand accent color (cyan), matching the bright end of 'brandGradient'.
accent :: Color
accent = rgbColor 0 200 230

-- | A pink → violet → cyan true-color gradient used for the brand banner.
brandGradient :: Gradient1D
brandGradient =
  gradient1D
    [ (0.0, RGB 255 64 160)
    , (0.5, RGB 150 90 255)
    , (1.0, RGB 0 200 230)
    ]

-- | Emit a single line of bold text whose characters are colored along a
-- horizontal 1D gradient. Shows off true-color 'Gradient1D' sampling.
gradientText
  :: (Reflex t, HasImageWriter t m)
  => Gradient1D -> Text -> m ()
gradientText grad txt = tellImages $ pure [img]
  where
    cs = T.unpack txt
    n = length cs
    img =
      V.horizCat
        [ V.char (V.withStyle (V.withForeColor V.defAttr (fromRGB (sampleGradient1D grad p))) V.bold) c
        | (i, c) <- zip [0 :: Int ..] cs
        , let p = fromIntegral i / fromIntegral (max 1 (n - 1))
        ]

-- | A line of text tinted with an accent foreground color (used for the
-- short instructional headers above each example).
accentText
  :: (Reflex t, Monad m, HasDisplayRegion t m, HasImageWriter t m, HasTheme t m)
  => Color -> Text -> m ()
accentText c t =
  localTheme (const (pure (defTheme {_theme_default = withForeground c def}))) $
    text (pure t)

-- | A titled box whose border and title are tinted with an accent color,
-- while its content stays in the default theme so body text remains
-- readable.
accentBox
  :: ( MonadFix m
     , MonadHold t m
     , HasDisplayRegion t m
     , HasImageWriter t m
     , HasInput t m
     , HasFocusReader t m
     , HasTheme t m
     )
  => Color -> BoxStyle -> Text -> m a -> m a
accentBox c style title child =
  localTheme (const (pure (defTheme {_theme_default = withForeground c def}))) $
    boxTitle (pure TextAlignment_Center) (pure style) (pure title) $
      localTheme (const (pure defTheme)) child

-- | A theme transformer that reverse-videos the default style while the
-- given dynamic is True. Used to highlight a focused widget (e.g. the
-- checkbox in a to-do row).
highlightFocus
  :: Reflex t
  => Dynamic t Bool -> Behavior t Theme -> Behavior t Theme
highlightFocus d thB =
  (\th foc -> if foc then th {_theme_default = withReverse (_theme_default th)} else th)
    <$> thB
    <*> current d

main :: IO ()
main = mainWidget def $ withCtrlC $ do
  enterAlternateScreen
  initManager_ $ do
    tabNavigation
    let gf = grout . fixed
        t = tile (fixed 3)
        buttons = do
          (_, ev) <-
            scrollable def $
              col $ do
                gf 3 $ col $ do
                  gf 1 $ gradientText brandGradient "reflex-vty · functional reactive terminal UIs"
                  gf 1 $ text "Select an example."
                  gf 1 $ text "Esc brings you back here · Ctrl+C quits."
                a <- t $ textButtonStatic def "Todo List"
                b <- t $ textButtonStatic def "Text Editor"
                c <- t $ textButtonStatic def "Scrollable text display"
                d <- t $ textButtonStatic def "Clickable buttons"
                e <- t $ textButtonStatic def "CPU Usage"
                f <- t $ textButtonStatic def "Scrollbar modes"
                g <- t $ textButtonStatic def "Cursor"
                h <- t $ textButtonStatic def "Showcase"
                pure
                  ( never
                  , leftmost
                      [ Left Example_Todo <$ a
                      , Left Example_TextEditor <$ b
                      , Left Example_ScrollableTextDisplay <$ c
                      , Left Example_ClickButtonsGetEmojis <$ d
                      , Left Example_CPUStat <$ e
                      , Left Example_Scrollbar <$ f
                      , Left Example_Cursor <$ g
                      , Left Example_Showcase <$ h
                      ]
                  )
          pure ev
    let escapable w = do
          void w
          i <- input
          return $ fforMaybe i $ \case
            V.EvKey V.KEsc [] -> Just $ Right ()
            _ -> Nothing
    rec out <- networkHold buttons $ ffor (switch (current out)) $ \case
          Left Example_Todo -> escapable taskList
          Left Example_TextEditor -> escapable $ localTheme (const (constant darkTheme)) testBoxes
          Left Example_ScrollableTextDisplay -> escapable scrolling
          Left Example_ClickButtonsGetEmojis -> escapable easyExample
          Left Example_CPUStat -> escapable cpuStats
          Left Example_Scrollbar -> escapable scrollbarDemo
          Left Example_Cursor -> escapable cursorDemo
          Left Example_Showcase -> escapable showcaseDemo
          Right () -> buttons
    return ()

scrollbarDemo :: (VtyExample t m, Manager t m, MonadHold t m, PostBuild t m, PerformEvent t m, TriggerEvent t m, MonadIO (Performable m)) => m ()
scrollbarDemo = col $ do
  grout (fixed 1) $ accentText accent "Four scrollbar modes. Use arrow keys or mouse wheel to scroll each panel:"
  grout flex $ row $ do
    grout flex $ void $ sbPanel "Always" ScrollbarAlways
    grout flex $ void $ sbPanel "Thumb Only" ScrollbarThumbOnly
    grout flex $ void $ sbPanel "While Scrolling" ScrollbarWhileScrolling
    grout flex $ void $ sbPanel "Hidden" ScrollbarHidden
  where
    sampleText = T.unlines $ map (\n -> "Line " <> T.pack (show n)) [(1 :: Int) .. 50]
    sbPanel label vis =
      boxTitle (pure TextAlignment_Center) (pure singleBoxStyle) (pure label) $
        scrollableText (def {_scrollableConfig_scrollbarVisibility = vis}) (constDyn sampleText)

-- * Terminal cursor demo

cursorDemo :: (VtyExample t m, Manager t m, MonadHold t m, PostBuild t m) => m ()
cursorDemo = col $ do
  grout (fixed 1) $ accentText accent "Arrows: move | s: style | v: visibility | f: alt-screen | Esc: back"
  let styles = [CursorStyleBlock, CursorStyleUnderline, CursorStyleBar]
  upE <- key V.KUp
  downE <- key V.KDown
  leftE <- key V.KLeft
  rightE <- key V.KRight
  sE <- key (V.KChar 's')
  vE <- key (V.KChar 'v')
  fE <- key (V.KChar 'f')
  posDyn <- foldDyn move (0, 0) $ leftmost [upE, downE, leftE, rightE]
  idxDyn <- foldDyn (\_ n -> (n + 1) `mod` length styles) 0 sE
  visDyn <- foldDyn (\_ b -> not b) True vE
  altDyn <- foldDyn (\_ b -> not b) True fE
  tellScreenMode $ (\b -> if b then ScreenAlternate else ScreenNormal) <$> updated altDyn
  let styleDyn = (styles !!) <$> idxDyn
      cursorDyn =
        (\(x, y) s v -> CursorState (if v then CursorVisible else CursorHidden) s (x, y))
          <$> posDyn
          <*> styleDyn
          <*> visDyn
  setCursor cursorDyn
  grout flex $
    text $
      current $
        ( \(x, y) s v a ->
            "Position: "
              <> T.pack (show x)
              <> ","
              <> T.pack (show y)
              <> " | Style: "
              <> T.pack (show s)
              <> " | Visible: "
              <> (if v then "on" else "off")
              <> " | Alt-screen: "
              <> (if a then "on" else "off")
        )
          <$> posDyn
          <*> styleDyn
          <*> visDyn
          <*> altDyn
  where
    move (k, _) (x, y) = case k of
      V.KUp -> (x, max 0 (y - 1))
      V.KDown -> (x, y + 1)
      V.KLeft -> (max 0 (x - 1), y)
      V.KRight -> (x + 1, y)
      _ -> (x, y)

-- * Mouse button and emojis example
easyExample :: (VtyExample t m, Manager t m, MonadHold t m) => m (Event t ())
easyExample = do
  row $ grout (fixed 39) $ col $ do
    (a1, b1, c1) <- grout (fixed 3) $ row $ do
      a <- tile flex $ btn "POTATO"
      b <- tile flex $ btn "TOMATO"
      c <- tile flex $ btn "EGGPLANT"
      return (a, b, c)
    (a2, b2, c2) <- grout (fixed 3) $ row $ do
      a <- tile flex $ btn "CHEESE"
      b <- tile flex $ btn "BEES"
      c <- tile flex $ btn "MY KNEES"
      return (a, b, c)
    (a3, b3, c3) <- grout (fixed 3) $ row $ do
      a <- tile flex $ btn "TIME"
      b <- tile flex $ btn "RHYME"
      c <- tile flex $ btn "A BIG CRIME"
      return (a, b, c)
    tile (fixed 7) $ accentBox accent roundedBoxStyle "CLICK BUTTONS TO DRAW*" $ do
      outputDyn <-
        foldDyn (<>) "" $
          mergeWith
            (<>)
            [a1 $> "\129364", b1 $> "🍅", c1 $> "🍆", a2 $> "\129472", b2 $> "🐝🐝", c2 $> "💘", a3 $> "⏰", b3 $> "📜", c3 $> "💰🔪🔒"]
      text (current outputDyn)
    tile flex $ text "* Requires font support for emojis. Box may render incorrectly unless your vty is initialized with an updated char width map."
  inp <- input
  return $ fforMaybe inp $ \case
    V.EvKey (V.KChar 'c') [V.MCtrl] -> Just ()
    _ -> Nothing
  where
    btn label = do
      let cfg = def {_buttonConfig_focusStyle = pure doubleBoxStyle}
      buttonClick <- textButtonStatic cfg label
      keyPress <-
        keyCombos $
          Set.fromList
            [ (V.KEnter, [])
            , (V.KChar ' ', [])
            ]
      pure $ leftmost [() <$ buttonClick, () <$ keyPress]

-- * Task list example
taskList
  :: (VtyExample t m, Manager t m, MonadHold t m, Adjustable t m, PostBuild t m)
  => m ()
taskList = col $ do
  grout (fixed 1) $ accentText accent "To-Do · Tab to move · Ctrl+T toggles · Enter adds a task"
  let todos0 =
        [ Todo "Find reflex-vty" True
        , Todo "Become functional reactive" False
        , Todo "Make vty apps" False
        ]
      btn = textButtonStatic def "Add another task"
  enter <- fmap (const ()) <$> key V.KEnter
  rec void $ grout flex $ todos todos0 $ enter <> click
      click <- tile (fixed 3) btn
  return ()

data Todo = Todo
  { _todo_label :: Text
  , _todo_done :: Bool
  }
  deriving (Eq, Ord, Read, Show)

data TodoOutput t = TodoOutput
  { _todoOutput_todo :: Dynamic t Todo
  , _todoOutput_delete :: Event t ()
  , _todoOutput_height :: Dynamic t Int
  , _todoOutput_focusId :: FocusId
  , _todoOutput_focused :: Dynamic t Bool
  -- ^ Whether any element of this row (checkbox or text field) is focused.
  --   Used to insert a newly added task directly beneath the active row.
  }

todo
  :: (VtyExample t m, Manager t m, MonadHold t m)
  => Todo
  -> m (TodoOutput t)
todo t0 = row $ do
  let toggleKeys = Set.singleton (V.KChar 't', [V.MCtrl])
  anyChildFocused $ \focused -> do
    toggleE <- keyCombos toggleKeys
    filterKeys (flip Set.notMember $ Set.insert (V.KChar '\t', []) toggleKeys) $ do
      rec -- Which sub-element of this row has focus: the row is focused
          -- when either the checkbox or the text input is, so the
          -- checkbox is focused if the row is focused and the text isn't.
          textFocused <- isFocused fid
          let cbFocused = (\f tf -> f && not tf) <$> focused <*> textFocused
          -- A caret in a left gutter marks which to-do row is active.
          grout (fixed 2) $
            tellImages $
              ffor (current focused) $ \f ->
                [V.text' (V.withStyle (V.withForeColor V.defAttr accent) V.bold) (if f then "▸ " else "  ")]
          let cfg =
                def
                  { _checkboxConfig_setValue = setVal
                  }
          value <-
            tile (fixed 4) $
              localTheme (highlightFocus cbFocused) $
                checkbox cfg $
                  _todo_done t0
          let setVal = attachWith (\v _ -> not v) (current value) $ gate (current focused) toggleE
          (fid, (ti, d)) <- tile' flex $ do
            i <- input
            v <-
              textInput $
                def
                  { _textInputConfig_initialValue = TZ.fromText $ _todo_label t0
                  }
            let deleteSelf = attachWithMaybe backspaceOnEmpty (current $ _textInput_value v) i
            return (v, deleteSelf)
      return $
        TodoOutput
          { _todoOutput_todo = Todo <$> _textInput_value ti <*> value
          , _todoOutput_delete = d
          , _todoOutput_height = _textInput_lines ti
          , _todoOutput_focusId = fid
          , _todoOutput_focused = focused
          }
  where
    backspaceOnEmpty v = \case
      V.EvKey V.KBS _ | T.null v -> Just ()
      _ -> Nothing

todos
  :: forall t m
   . ( MonadHold t m
     , Manager t m
     , VtyExample t m
     , Adjustable t m
     , PostBuild t m
     )
  => [Todo]
  -> Event t ()
  -> m (Dynamic t (Map Rational (TodoOutput t)))
todos todos0 newTodo = do
  let todosMap0 = Map.fromList $ zip [0 ..] todos0
  rec listOut <- listHoldWithKey todosMap0 updates $ \k t -> grout (fixed 1) $ do
        to <- todo t
        let sel = select selectOnDelete $ Const2 k
        pb <- getPostBuild
        requestFocus $ Refocus_Id (_todoOutput_focusId to) <$ leftmost [pb, sel]
        pure to
      let delete = flip Map.singleton Nothing <$> todoDelete
          todosMap = joinDynThroughMap $ fmap _todoOutput_todo <$> listOut
          -- The key of the row that currently holds focus, if any.
          focusedKey =
            fmap (fmap fst . Map.lookupMin . Map.filter id) $
              joinDynThroughMap $
                fmap _todoOutput_focused <$> listOut
          -- Insert the new task directly beneath the focused row by choosing a
          -- key halfway between it and the next row (so existing rows keep
          -- their keys and edit state). With no focus, or when the focused row
          -- is last, append at the end.
          insert = ffor (attach (current todosMap) (tag (current focusedKey) newTodo)) $ \(m, mfk) ->
            let newKey = case mfk of
                  Just fk -> case Map.lookupGT fk m of
                    Just (knext, _) -> (fk + knext) / 2
                    Nothing -> fk + 1
                  Nothing -> maybe 0 ((+ 1) . fst) (Map.lookupMax m)
            in Map.singleton newKey $ Just $ Todo "" False
          updates = leftmost [insert, delete]
          todoDelete =
            switch . current $
              leftmost . Map.elems . Map.mapWithKey (\k -> (k <$) . _todoOutput_delete) <$> listOut

          selectOnDelete =
            fanMap $
              (`Map.singleton` ())
                <$> attachWithMaybe
                  ( \m k ->
                      let (before, after) = Map.split k m
                      in fmap fst $ Map.lookupMax before <|> Map.lookupMin after
                  )
                  (current todosMap)
                  todoDelete
  return listOut

-- * Scrollable text example

scrolling
  :: ( VtyExample t m
     , MonadHold t m
     , Manager t m
     , PostBuild t m
     , MonadIO (Performable m)
     , TriggerEvent t m
     , PerformEvent t m
     )
  => m ()
scrolling = col $ do
  grout (fixed 2) $ text "Use your mouse wheel or up and down arrows to scroll:"
  (fid, out) <- tile' (fixed 5) $ accentBox accent roundedBoxStyle " De Bello Gallico " $ scrollableText def $ "Gallia est omnis divisa in partes tres, quarum unam incolunt Belgae, aliam Aquitani, tertiam qui ipsorum lingua Celtae, nostra Galli appellantur. Hi omnes lingua, institutis, legibus inter se differunt. Gallos ab Aquitanis Garumna flumen, a Belgis Matrona et Sequana dividit. Horum omnium fortissimi sunt Belgae, propterea quod a cultu atque humanitate provinciae longissime absunt, minimeque ad eos mercatores saepe commeant atque ea quae ad effeminandos animos pertinent important, proximique sunt Germanis, qui trans Rhenum incolunt, quibuscum continenter bellum gerunt. Qua de causa Helvetii quoque reliquos Gallos virtute praecedunt, quod fere cotidianis proeliis cum Germanis contendunt, cum aut suis finibus eos prohibent aut ipsi in eorum finibus bellum gerunt. Eorum una pars, quam Gallos obtinere dictum est, initium capit a flumine Rhodano, continetur Garumna flumine, Oceano, finibus Belgarum, attingit etiam ab Sequanis et Helvetiis flumen Rhenum, vergit ad septentriones. Belgae ab extremis Galliae finibus oriuntur, pertinent ad inferiorem partem fluminis Rheni, spectant in septentrionem et orientem solem. Aquitania a Garumna flumine ad Pyrenaeos montes et eam partem Oceani quae est ad Hispaniam pertinet; spectat inter occasum solis et septentriones.\nApud Helvetios longe nobilissimus fuit et ditissimus Orgetorix. Is M. Messala, [et P.] M. Pisone consulibus regni cupiditate inductus coniurationem nobilitatis fecit et civitati persuasit ut de finibus suis cum omnibus copiis exirent: perfacile esse, cum virtute omnibus praestarent, totius Galliae imperio potiri. Id hoc facilius iis persuasit, quod undique loci natura Helvetii continentur: una ex parte flumine Rheno latissimo atque altissimo, qui agrum Helvetium a Germanis dividit; altera ex parte monte Iura altissimo, qui est inter Sequanos et Helvetios; tertia lacu Lemanno et flumine Rhodano, qui provinciam nostram ab Helvetiis dividit. His rebus fiebat ut et minus late vagarentur et minus facile finitimis bellum inferre possent; qua ex parte homines bellandi cupidi magno dolore adficiebantur. Pro multitudine autem hominum et pro gloria belli atque fortitudinis angustos se fines habere arbitrabantur, qui in longitudinem milia passuum CCXL, in latitudinem CLXXX patebant."
  pb <- getPostBuild
  requestFocus $ Refocus_Id fid <$ pb
  grout (fixed 1) $ text $ ffor (_scrollable_scrollPosition out) $ \p ->
    "Scrolled to " <> case p of
      ScrollPos_Top -> "top"
      ScrollPos_Bottom -> "bottom"
      ScrollPos_Line n -> "line " <> T.pack (show n)
  e <- performEventAsync $ ffor pb $ \_ cb -> liftIO $ void $ forkIO $ forever $ do
    threadDelay 1000000
    t <- getCurrentTime
    cb $ [T.pack $ show t]
  xs <- foldDyn (flip (<>)) [] e
  grout (fixed 3) blank
  tile (fixed 10) $ col $ do
    grout (fixed 1) $ text "This one scrolls automatically as the output grows:"
    Scrollable pos total h <-
      tile flex $
        scrollableText (ScrollableConfig never never ScrollPos_Bottom (pure $ Just ScrollToBottom_Maintain) ScrollbarAlways) $
          T.unlines <$> xs
    grout (fixed 5) $ accentBox accent singleBoxStyle " Scroll state " $ do
      grout (fixed 1) $ row $ do
        grout (fixed 8) (text "Height:")
        grout flex $ display h
      grout (fixed 1) $ row $ do
        grout (fixed 8) $ text "Scroll:"
        grout flex $ display pos
      grout (fixed 1) $ row $ do
        grout (fixed 8) $ text "Length:"
        grout flex $ display total

--  * Text editor example with resizable boxes

testBoxes
  :: (MonadHold t m, VtyExample t m)
  => m ()
testBoxes = do
  dw <- displayWidth
  dh <- displayHeight
  let region1 = Region <$> (div' dw 6) <*> (div' dh 6) <*> (div' dw 2) <*> (div' dh 2)
      region2 = Region <$> (div' dw 4) <*> (div' dh 4) <*> (2 * div' dw 3) <*> (2 * div' dh 3)
  pane region1 (constDyn False) . boxStatic singleBoxStyle $ debugInput
  _ <-
    pane region2 (constDyn True) . boxStatic singleBoxStyle $
      let cfg =
            def
              { _textInputConfig_initialValue =
                  "This box is a text input. The box below responds to mouse drag inputs. You can also drag the separator between the boxes to resize them."
              }
          textBox =
            boxTitle (pure TextAlignment_Center) (pure roundedBoxStyle) "Text Edit" $
              multilineTextInput cfg
          dragBox = boxStatic roundedBoxStyle dragTest
      in splitVDrag (hRule doubleBoxStyle) textBox dragBox
  return ()
  where
    div' :: (Integral a, Applicative f) => f a -> f a -> f a
    div' = liftA2 div

debugInput :: (VtyExample t m, MonadHold t m) => m ()
debugInput = do
  lastEvent <- hold "No event yet" . fmap show =<< input
  text $ T.pack <$> lastEvent

dragTest :: (VtyExample t m, MonadHold t m) => m ()
dragTest = do
  lastEvent <- hold "No event yet" . fmap show =<< drag V.BLeft
  text $ T.pack <$> lastEvent

-- * Showcase: one screen showing off all styling, theming, and color

-- profiling. Tab cycles the predefined themes; the whole screen is
-- rendered under the current theme.
showcaseDemo :: (VtyExample t m, MonadHold t m, PerformEvent t m, TriggerEvent t m, MonadIO (Performable m), HasLayout t m, HasColorProfile t m) => m ()
showcaseDemo = do
  tab <- key (V.KChar '\t')
  nDyn <- foldDyn (\_ n -> n + 1) 0 tab
  prof <- colorProfile
  let themes =
        cycle
          [ ("default", defTheme)
          , ("dark", darkTheme)
          , ("charm", charmTheme)
          , ("dracula", draculaTheme)
          , ("nord", nordTheme)
          , ("zenburn", zenburnTheme)
          , ("gruvbox", gruvboxTheme)
          ]
      pick n = drop (n `mod` 7) themes
      curTheme n = case pick n of
        (_, th) : _ -> th
        [] -> defTheme
      curLabel n = case pick n of
        (label, _) : _ -> label
        [] -> ""
      themeBeh = curTheme <$> current nDyn
  focusGainedE <- gainedFocus
  focusLostE <- lostFocus
  focusedDyn <- holdDyn True $ leftmost [True <$ focusGainedE, False <$ focusLostE]
  mousePosDyn <- mousePosition
  let headerBeh =
        (\n p f (mx, my) -> T.pack (curLabel n <> " | " <> show p <> " | Focus: " <> (if f then "●" else "○") <> " | Mouse: " <> show mx <> "," <> show my <> " | Tab/Esc"))
          <$> current nDyn
          <*> prof
          <*> current focusedDyn
          <*> current mousePosDyn
  localTheme (const themeBeh) $ do
    fill (pure ' ')
    col $ do
      grout (fixed 1) $ text headerBeh
      grout flex $ void $ scrollable def $ do
        row $ do
          -- Left column: style samples
          grout flex $ col $ do
            grout (fixed 1) $ text "Borders:"
            grout (fixed 3) $ row $ do
              grout flex $ styledImage "single" (withBorder singleBorder def)
              grout flex $ styledImage "rounded" (withBorder roundedBorder def)
              grout flex $ styledImage "thick" (withBorder thickBorder def)
              grout flex $ styledImage "double" (withBorder doubleBorder def)
              grout flex $ styledImage "ascii" (withBorder asciiBorder def)
            grout (fixed 1) $ text "Padding/Margin:"
            grout (fixed 3) $ row $ do
              grout flex $ styledImage "pad 1" (withPadding 1 1 1 1 def)
              grout flex $ styledImage "pad 2" (withPadding 2 2 2 2 def)
              grout flex $ styledImage "margin 1" (withMargin 1 1 1 1 def)
            grout (fixed 1) $ text "Colors:"
            grout (fixed 3) $ row $ do
              grout flex $ styledImage "red fg" (withForeground red def)
              grout flex $ styledImage "blue bg" (withBackground blue def)
              grout flex $ styledImage "rgb" (withForeground (rgbColor 200 100 50) def)
            grout (fixed 1) $ text "Color ops:"
            grout (fixed 3) $ row $ do
              grout flex $ styledImage "darken" (withForeground (fromRGB (darken 0.4 (RGB 255 128 0))) def)
              grout flex $ styledImage "lighten" (withForeground (fromRGB (lighten 0.5 (RGB 100 0 200))) def)
              grout flex $ styledImage "mix" (withForeground (fromRGB (mix 0.5 (RGB 255 0 0) (RGB 0 0 255))) def)
              grout flex $ styledImage "complement" (withForeground (fromRGB (complementary (RGB 255 128 0))) def)
            grout (fixed 1) $ text "Gradient (1D):"
            grout (fixed 3) $ gradientSwatch
            grout (fixed 1) $ text "Canvas overlay:"
            grout (fixed 3) $ canvasOverlayDemo
            grout (fixed 1) $ text "Transforms:"
            grout (fixed 3) $ row $ do
              grout flex $ styledImage "bold" (withBold def)
              grout flex $ styledImage "italic" (withItalic def)
              grout flex $ styledImage "underline" (withUnderline UnderlineSingle def)
              grout flex $ styledImage "reverse" (withReverse def)
            grout (fixed 1) $ text "Alignment:"
            grout (fixed 3) $ row $ do
              grout flex $ styledImage "left" (withAlignH HAlignLeft . withWidth 20 $ def)
              grout flex $ styledImage "center" (withAlignH HAlignCenter . withWidth 20 $ def)
              grout flex $ styledImage "right" (withAlignH HAlignRight . withWidth 20 $ def)
            grout (fixed 1) $ text "Combined & Hyperlink:"
            grout (fixed 5) $ row $ do
              grout flex $
                styledImage
                  "combined"
                  ( withBorder roundedBorder
                      . withPadding 1 2 1 2
                      . withForeground brightGreen
                      . withBorderForeground brightMagenta
                      $ def
                  )
              grout flex $
                styledImage
                  "link"
                  (withHyperlink "https://reflex-frp.org" . withUnderline UnderlineSingle $ def)
          -- Right column: themed widgets + color profile swatches
          grout flex $ col $ do
            grout (fixed 1) $ text "Themed Widgets:"
            void $ grout (fixed 3) $ textButtonStatic def "A button"
            void $ grout (fixed 3) $ checkbox def False
            void $ grout (fixed 3) $ linkStatic "A link"
            void $ grout (fixed 3) $ textInput def
            grout (fixed 1) $ text "Color Profile Swatches:"
            grout (fixed 2) $ row $ do
              grout flex $ profileSwatch "TrueColor" ColorProfile_TrueColor
              grout flex $ profileSwatch "Ansi256" ColorProfile_Ansi256
              grout flex $ profileSwatch "Ansi16" ColorProfile_Ansi16
              grout flex $ profileSwatch "Ascii" ColorProfile_Ascii
              grout flex $ profileSwatch "NoTTY" ColorProfile_NoTTY
        pure (never, ())
  where
    orange = rgbColor 200 100 50
    styledImage label s = do
      th <- theme
      tellImages $ (\t -> [render (inherit (_theme_default t) s) label]) <$> th
    profileSwatch label prof = do
      bt <- themeAttr
      tellImages $ (\a -> [V.text' (applyProfile prof (V.withForeColor a orange)) (label <> " ")]) <$> bt
    gradientSwatch = do
      th <- theme
      dw <- displayWidth
      let grad = gradient1D [(0.0, RGB 255 0 0), (0.5, RGB 0 255 0), (1.0, RGB 0 0 255)]
          sampleAt w i = sampleGradient1D grad (fromIntegral i / fromIntegral (max 1 (w - 1)))
          bar t w = [V.horizCat $ map (\i -> render (inherit (_theme_default t) (withBackground (fromRGB (sampleAt w i)) def)) " ") [0 .. max 1 (w - 1)]]
      tellImages $ bar <$> th <*> current dw
    canvasOverlayDemo = do
      th <- theme
      let bgText = "background text shows through around the overlay"
          mkCanvas t =
            let bgBase = applyAttr (inherit (_theme_default t) def) V.defAttr
                bgRow = V.text' bgBase bgText
                bg = imageToCanvas $ V.vertCat [bgRow, bgRow, bgRow]
                overlayStyle =
                  withBorder roundedBorder
                    . withBackground (fromRGB (RGB 40 42 54))
                    . withForeground (fromRGB (RGB 255 184 108))
                    $ def
                overlayCanvas = imageToCanvas $ render overlayStyle " Overlay "
                result = placeCanvas 3 0 overlayCanvas bg
            in [canvasToImage result]
      tellImages $ mkCanvas <$> th