packages feed

termbox-banana-0.2.0: examples/Hoogle.hs

{-# language LambdaCase          #-}
{-# language OverloadedStrings   #-}
{-# language RecursiveDo         #-}
{-# language ScopedTypeVariables #-}

module Main where

import Control.Concurrent
import Control.Concurrent.Async
import Control.Concurrent.STM
import Control.Exception (SomeException)
import Control.Lens
import Control.Monad
import Control.Monad.State.Strict
import Control.Monad.Writer.Strict
import Data.Aeson (Value)
import Data.Aeson.Lens
import Data.Foldable (fold, for_)
import Data.Maybe
import Data.String (fromString)
import Data.Text.Lens (unpacked)
import Network.HTTP.Simple
import Reactive.Banana
import Reactive.Banana.Frameworks
import Text.HTML.TagSoup

import qualified Termbox.Banana as Termbox

main :: IO ()
main =
  Termbox.run_
    (Termbox.InputModeEsc Termbox.MouseModeNo)
    Termbox.OutputModeNormal
    moment

moment
  :: Event Termbox.Event
  -> Behavior (Int, Int)
  -> MomentIO (Behavior Termbox.Scene, Event ())
moment eEvent bSize = mdo
  eTick :: Event () <-
    makeTickEvent

  requestQueue :: TQueue String <-
    liftIO newTQueueIO

  eSearchBox :: Event String <-
    makeSearchBoxEvent eKey

  reactimate
    ((atomically . writeTQueue requestQueue . reverse) <$> eSearchBox)

  let
    eEmptySearchBox :: Event String
    eEmptySearchBox =
      filterE null eSearchBox

  bSearchBox :: Behavior String <-
    stepper "" eSearchBox

  (eSearchResults, fireSearchResults) <-
    newEvent

  liftIO . void . forkIO $
    runHttpRequestThread
      requestQueue
      fireSearchResults

  bSearchResults :: Behavior (Either SomeException [Value]) <-
    stepper
      (Right [])
      (unionWith
        const
        (Right [] <$ eEmptySearchBox)
        eSearchResults)

  bSpinnerFrame :: Behavior (Maybe Int) <-
    accumB Nothing (unions
      [ ((\query ->
            if null query
              then const Nothing
              else const (Just 1))
          <$> eSearchBox)
      , const Nothing <$ eSearchResults
      , const Nothing <$ eEmptySearchBox
      , fmap (+1) <$ whenE (isJust <$> bSpinnerFrame) eTick
      ])

  let
    bCells :: Behavior Termbox.Cells
    bCells =
      render
        <$> bHeight
        <*> bSearchBox
        <*> bSearchResults
        <*> bSpinnerFrame

  let
    bCursor :: Behavior Termbox.Cursor
    bCursor =
      (\height searchBox -> Termbox.Cursor (length searchBox + 2) height)
        <$> bHeight
        <*> bSearchBox

  let
    bScene :: Behavior Termbox.Scene
    bScene =
      Termbox.Scene
        <$> bCells
        <*> bCursor

  pure (bScene, () <$ filterE (== Termbox.KeyEsc) eKey)

  where
    eKey :: Event Termbox.Key
    eKey =
      filterJust (eventAsKey <$> eEvent)

    bHeight :: Behavior Int
    bHeight =
      snd <$> bSize

makeTickEvent :: MomentIO (Event ())
makeTickEvent = do
  (e, f) <- newEvent
  liftIO . void . forkIO . forever $ do
    f ()
    threadDelay 100000
  pure e

makeSearchBoxEvent
  :: MonadMoment m
  => Event Termbox.Key
  -> m (Event String)
makeSearchBoxEvent eKey =
  accumE
    ""
    (unions
      [ (:) <$> filterJust (keyAsChar <$> eKey)
      , (' ' :) <$  filterE (== Termbox.KeySpace) eKey
      , safeTail <$ filterE (== Termbox.KeyBackspace2) eKey
      ])

runHttpRequestThread
  :: TQueue String -- ^ Request queue
  -> (Either SomeException [Value] -> IO ()) -- ^ Response callback
  -> IO ()
runHttpRequestThread requestQueue respond =
  loop Nothing
  where
    loop :: Maybe (Async [Value]) -> IO ()
    loop maybeInFlightRequest =
      join . atomically $
        (do
          query <- readTQueue requestQueue
          pure $ do
            for_ maybeInFlightRequest $ \inFlightRequest ->
              forkIO (cancel inFlightRequest)
            if null query
              then
                loop Nothing
              else do
                inFlightRequest <- async (performHoogleSearch query)
                loop (Just inFlightRequest))
        <|>
        (case maybeInFlightRequest of
          Nothing ->
            retry
          Just inFlightRequest -> do
            response <- waitCatchSTM inFlightRequest
            pure $ do
              respond response
              loop Nothing)

render
  :: Int
  -> String
  -> Either SomeException [Value]
  -> Maybe Int
  -> Termbox.Cells
render height searchBox searchResults spinnerFrame =
  fold
    [ case searchResults of
        Left ex ->
          renderSearchResultsError height ex
        Right results ->
          renderSearchResults height results
            & execWriterT
            & (`evalState` 0)
    , renderSearchBox height searchBox spinnerFrame
    ]

renderSearchBox :: Int -> String -> Maybe Int -> Termbox.Cells
renderSearchBox height searchBox spinnerFrame =
  renderString 0 (height-1) (promptChar : ' ' : reverse searchBox)
  where
    promptChar :: Char
    promptChar =
      case spinnerFrame of
        Nothing ->
          'λ'
        Just n  ->
          let
            cs =
              "⣧⣏⡟⠿⢻⣹⣼⣶"
          in
            cs !! (n `mod` length cs)

renderSearchResults
  :: Int
  -> [Value]
  -> WriterT Termbox.Cells (State Int) ()
renderSearchResults height = \case
  [] ->
    pure ()

  result : results -> do
    row <- get

    let
      ss = searchResultToLines result

    when (row + length ss < height-1) $ do
      for_ (zip [row..] ss) $ \(r, s) ->
        tell (renderString 0 r s)
      modify' (+ (length ss + 1))
      renderSearchResults height results

renderSearchResultsError
  :: Int
  -> SomeException
  -> Termbox.Cells
renderSearchResultsError height ex =
  ex
    & show
    & lines
    & take height
    & zip [0..]
    & foldMap (\(row, line) -> renderString 0 row line)

searchResultToLines :: Value -> [String]
searchResultToLines result =
  case result ^?! key "type" of
    "" ->
      unwords
        [ "[" ++ resultPackage result ++ "]"
        , "[" ++ resultModule result ++ "]"
        , resultItem result
        ]
        : map ("  " ++) (resultDocs result)

    "module" ->
      unwords
        [ "[" ++ resultPackage result ++ "]"
        , "[" ++ drop 7 (resultItem result) ++ "]"
        ]
        : map ("  " ++) (resultDocs result)

    "package" ->
      ("[" ++ drop 8 (resultItem result) ++ "]")
        : map ("  " ++) (resultDocs result)

    _ ->
      error (show result)

resultPackage :: Value -> String
resultPackage result =
  result
    ^?! key "package"
      . key "name"
      . _String
      . unpacked

resultModule :: Value -> String
resultModule result =
  result
    ^?! key "module"
      . key "name"
      . _String
      . unpacked

resultItem :: Value -> String
resultItem result =
  result
    ^?! key "item"
      . _String
      . unpacked
      . to htmlToText

resultDocs :: Value -> [String]
resultDocs result =
  result
    ^?! key "docs"
      . _String
      . unpacked
      . to htmlToText
      . to lines
      . to collapseLines

  where
    collapseLines :: [String] -> [String]
    collapseLines = \case
      [] ->
        []

      [""] ->
        []

      "" : ss ->
        case collapseLines ss of
          "" : ts -> "" : ts
          ts      -> "" : ts

      s : ss ->
        s : collapseLines ss

htmlToText :: String -> String
htmlToText html =
  concat $ do
    TagText text <- parseTags html
    pure text


renderString :: Int -> Int -> String -> Termbox.Cells
renderString col row =
  foldMap (\(i, c) -> Termbox.set i row (Termbox.Cell c mempty mempty)) .
    zip [col..]

performHoogleSearch :: String -> IO [Value]
performHoogleSearch query =
  getResponseBody <$> httpJSON (fromString searchUrl)
  where
    searchUrl :: String
    searchUrl =
      "https://hoogle.haskell.org?mode=json&count=10&hoogle=" ++ query

eventAsKey :: Termbox.Event -> Maybe Termbox.Key
eventAsKey = \case
  Termbox.EventKey k _ -> Just k
  _ -> Nothing

keyAsChar :: Termbox.Key -> Maybe Char
keyAsChar = \case
  Termbox.KeyChar c -> Just c
  _ -> Nothing

safeTail :: [a] -> [a]
safeTail = \case
  [] -> []
  _:xs -> xs