packages feed

hpdft-0.4.6.0: TuiPreview.hs

{-# LANGUAGE OverloadedStrings #-}

module TuiPreview (runTuiPreview) where

import PDF.Document (Document)
import PDF.Text (pdfToTextStreamDoc)
import TuiScroll
  ( ScrollState(..)
  , initialScrollState
  , clampScrollTop
  , scrollBy
  , scrollToTop
  , scrollToEnd
  , scrollHalfPageDown
  , scrollHalfPageUp
  , searchForwardFrom
  , searchBackwardFrom
  , visibleLineRange
  , statusLineNumber
  , stringDisplayWidth
  , clipToDisplayWidth
  , padToDisplayWidth
  )

import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Exception (AsyncException(UserInterrupt), bracket, catch, finally, throwIO)
import Control.Monad (when)
import Data.Array ((!))
import Data.Char (isPrint)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.ByteString.Lazy as BSL
import System.Console.Terminal.Size (Window(..), hSize)
import System.IO
  ( BufferMode(..)
  , Handle
  , hFlush
  , hGetBuffering
  , hGetChar
  , hGetEcho
  , hPutStr
  , hReady
  , hSetBuffering
  , hSetEcho
  , stderr
  , stdin
  , stdout
  )
import Text.Regex.Base.RegexLike (makeRegexM, matchAll, matchTest)
import Text.Regex.TDFA (Regex)

runTuiPreview :: FilePath -> Document -> IO ()
runTuiPreview path doc =
  let run term =
        uiLoop term path doc `catch` \e ->
          case e of
            UserInterrupt -> return ()
            _ -> throwIO e
  in bracket acquireTerminal releaseTerminal run

data Terminal = Terminal
  { termStdout      :: Handle
  , termStderr      :: Handle
  , termStdin       :: Handle
  , termOldEcho     :: Bool
  , termOldBuffer   :: BufferMode
  , termRows        :: Int
  , termCols        :: Int
  , termViewportH   :: Int
  , termTextRows    :: Int
  , termViewportTop :: Int
  }

data Key
  = KChar Char
  | KUp
  | KDown
  | KPageUp
  | KPageDown
  | KEnter
  | KBackspace
  | KEsc
  | KOther

-- | Status-line mode: normal view, or entering a search pattern after @/@.
data UiMode = ModeView | ModeInput String

acquireTerminal :: IO Terminal
acquireTerminal = do
  oldEcho <- hGetEcho stdin
  oldBuf <- hGetBuffering stdin
  hSetEcho stdin False
  hSetBuffering stdin NoBuffering
  (rows, cols, vh, textRows, vTop) <- queryGeometry stdout
  -- Hide the cursor and disable autowrap: a wrap on the bottom row would
  -- scroll the whole screen and drag the viewport out of place.
  hPutStr stdout "\ESC[?25l\ESC[?7l"
  hFlush stdout
  return Terminal
    { termStdout = stdout
    , termStderr = stderr
    , termStdin = stdin
    , termOldEcho = oldEcho
    , termOldBuffer = oldBuf
    , termRows = rows
    , termCols = cols
    , termViewportH = vh
    , termTextRows = textRows
    , termViewportTop = vTop
    }

releaseTerminal :: Terminal -> IO ()
releaseTerminal term = do
  clearViewport term
  hPutStr (termStdout term) "\ESC[?25h\ESC[?7h\ESC[0m"
  hPutStr (termStdout term) $ "\ESC[" ++ show (termRows term + 1) ++ ";1H"
  hFlush (termStdout term)
  hSetEcho (termStdin term) (termOldEcho term)
  hSetBuffering (termStdin term) (termOldBuffer term)

-- Viewport layout: top bar, text rows, status bar (bottom).
queryGeometry :: Handle -> IO (Int, Int, Int, Int, Int)
queryGeometry h = do
  mw <- hSize h
  let reported f dflt = case mw of
        Just w | fromIntegral (f w) > 0 -> fromIntegral (f w)
        _ -> dflt
      rows = reported height 24
      cols = reported width 80
      vh = max 6 (rows `div` 2)
      textRows = max 1 (vh - 2)
      vTop = rows - vh + 1
  return (rows, cols, vh, textRows, vTop)

readKey :: Handle -> IO Key
readKey h = do
  c <- hGetChar h
  case c of
    '\ESC' -> do
      pending <- hReady h
      if not pending
        then return KEsc
        else do
          c1 <- hGetChar h
          if c1 /= '['
            then return KEsc
            else do
              c2 <- hGetChar h
              case c2 of
                'A' -> return KUp
                'B' -> return KDown
                '5' -> swallowTilde >> return KPageUp
                '6' -> swallowTilde >> return KPageDown
                _   -> return KOther
    '\r'   -> return KEnter
    '\n'   -> return KEnter
    '\DEL' -> return KBackspace
    '\b'   -> return KBackspace
    _      -> return (KChar c)
  where
    swallowTilde = do
      pending <- hReady h
      when pending $ hGetChar h >> return ()

uiLoop :: Terminal -> FilePath -> Document -> IO ()
uiLoop term path doc = do
  linesRef <- newIORef ([] :: [Text])
  pagesLoadedRef <- newIORef (0 :: Int)
  totalPagesRef <- newIORef (0 :: Int)
  doneRef <- newIORef False
  scrollRef <- newIORef (initialScrollState (termTextRows term))
  redrawRef <- newIORef True
  modeRef <- newIORef ModeView
  searchRef <- newIORef (Nothing :: Maybe (String, Regex))
  matchRef <- newIORef (Nothing :: Maybe Int)
  msgRef <- newIORef (Nothing :: Maybe String)

  let producer = do
        _ <- pdfToTextStreamDoc doc $ \pg total bs -> do
          writeIORef totalPagesRef total
          modifyIORef' linesRef $ appendChunk (decodeLenient bs)
          writeIORef pagesLoadedRef pg
          ls <- readIORef linesRef
          modifyIORef' scrollRef (\st -> st {scrollTotalLines = length ls})
          writeIORef redrawRef True
        writeIORef doneRef True
        writeIORef redrawRef True

  let syncTotal = do
        ls <- readIORef linesRef
        modifyIORef' scrollRef (\st -> st {scrollTotalLines = length ls})
        return ls

      -- Jump forward (or backward) to the next line matching the active pattern.
      searchStep forward = do
        msearch <- readIORef searchRef
        case msearch of
          Nothing -> writeIORef msgRef (Just "No previous search")
          Just (_, re) -> do
            ls <- syncTotal
            st <- readIORef scrollRef
            cur <- readIORef matchRef
            let p = matchTest re . T.unpack
                top = scrollTopLine st
                found
                  | forward = searchForwardFrom p (maybe (top + 1) (+ 1) cur) ls
                  | otherwise = searchBackwardFrom p (maybe (top - 1) (subtract 1) cur) ls
            case found of
              Nothing -> writeIORef msgRef (Just "Pattern not found")
              Just i -> do
                writeIORef matchRef (Just i)
                modifyIORef' scrollRef (clampScrollTop i)

      startSearch q
        | null q = do
            msearch <- readIORef searchRef
            case msearch of
              Just _ -> searchStep True
              Nothing -> return ()
        | otherwise =
            case makeRegexM q :: Maybe Regex of
              Nothing -> writeIORef msgRef (Just "Invalid pattern")
              Just re -> do
                writeIORef searchRef (Just (q, re))
                writeIORef matchRef Nothing
                searchStep True

      handleViewKey key = case key of
        KChar 'q' -> return True
        KChar '\ETX' -> return True
        KChar '/' -> writeIORef modeRef (ModeInput "") >> return False
        KChar 'n' -> searchStep True >> return False
        KChar 'N' -> searchStep False >> return False
        _ -> do
          _ <- syncTotal
          modifyIORef' scrollRef (moveKey (termTextRows term) key)
          return False

      handleInputKey q key = do
        case key of
          KEsc -> writeIORef modeRef ModeView
          KEnter -> do
            writeIORef modeRef ModeView
            startSearch q
          KBackspace ->
            writeIORef modeRef $
              if null q then ModeView else ModeInput (init q)
          KChar c
            | c == '\ETX' -> writeIORef modeRef ModeView
            | isPrint c -> writeIORef modeRef (ModeInput (q ++ [c]))
          _ -> return ()
        return False

  let loop = do
        needRedraw <- readIORef redrawRef
        when needRedraw $ do
          writeIORef redrawRef False
          ls <- readIORef linesRef
          pg <- readIORef pagesLoadedRef
          total <- readIORef totalPagesRef
          done <- readIORef doneRef
          scroll <- readIORef scrollRef
          mode <- readIORef modeRef
          msg <- readIORef msgRef
          msearch <- readIORef searchRef
          drawFrame term path scroll ls pg total done mode msg (fmap snd msearch)
        ready <- hReady (termStdin term)
        if ready
          then do
            key <- readKey (termStdin term)
            writeIORef msgRef Nothing
            mode <- readIORef modeRef
            quit <- case mode of
              ModeView -> handleViewKey key
              ModeInput q -> handleInputKey q key
            writeIORef redrawRef True
            if quit then return () else loop
          else do
            threadDelay 50000
            loop

  tid <- forkIO producer
  loop `finally` killThread tid

moveKey :: Int -> Key -> ScrollState -> ScrollState
moveKey textRows key st = case key of
  KChar 'j' -> scrollBy 1 st
  KChar ' ' -> scrollBy 1 st
  KChar 'k' -> scrollBy (-1) st
  KDown     -> scrollBy 1 st
  KUp       -> scrollBy (-1) st
  KPageDown -> scrollBy textRows st
  KPageUp   -> scrollBy (-textRows) st
  KChar 'g' -> scrollToTop st
  KChar 'G' -> scrollToEnd st
  KChar 'd' -> scrollHalfPageDown st
  KChar 'u' -> scrollHalfPageUp st
  _         -> st

-- | Line buffer invariant: the last element is the still-open line
-- (empty when the previous chunk ended with a newline).
appendChunk :: Text -> [Text] -> [Text]
appendChunk chunk [] = T.split (== '\n') chunk
appendChunk chunk ls =
  case T.split (== '\n') chunk of
    (c : cs) -> init ls ++ (last ls <> c) : cs
    [] -> ls

decodeLenient :: BSL.ByteString -> Text
decodeLenient bs =
  TE.decodeUtf8With (\_ _ -> Just '\xfffd') (BSL.toStrict bs)

drawFrame ::
  Terminal -> FilePath -> ScrollState -> [Text] ->
  Int -> Int -> Bool -> UiMode -> Maybe String -> Maybe Regex -> IO ()
drawFrame term path scroll ls pg total done mode msg mre = do
  let (start, end) = visibleLineRange scroll
      visible = take (end - start) (drop start ls)
      status = case mode of
        ModeInput q -> "/" ++ q
        ModeView -> case msg of
          Just m -> " " ++ m
          Nothing ->
            " page "
              ++ show (if total > 0 then pg else 0)
              ++ "/"
              ++ show total
              ++ (if done then "" else "...")
              ++ " | line "
              ++ show (statusLineNumber scroll)
              ++ "/"
              ++ show (max (length ls) 1)
              ++ " | j/k \x2193/\x2191 d/u g/G /:search n/N q:quit"
  drawBarRow term (termViewportTop term) (" " ++ path)
  mapM_ (drawTextRow term mre) (zip [0 ..] visible)
  let filled = length visible
      statusRow = termViewportTop term + termViewportH term - 1
  mapM_ (\i -> drawTextRow term Nothing (i, "")) [filled .. termTextRows term - 1]
  drawBarRow term statusRow status
  -- During search input, park the visible cursor after the query so IME
  -- composition windows anchor to the right spot.
  case mode of
    ModeInput _ -> do
      let col = min (termCols term) (stringDisplayWidth status + 1)
      hPutStr (termStdout term) $
        "\ESC[" ++ show statusRow ++ ";" ++ show col ++ "H\ESC[?25h"
    ModeView ->
      hPutStr (termStdout term) "\ESC[?25l"
  hFlush (termStdout term)

drawTextRow :: Terminal -> Maybe Regex -> (Int, Text) -> IO ()
drawTextRow term mre (i, txt) = do
  let row = termViewportTop term + 1 + i
      clipped = clipToDisplayWidth (termCols term) (T.unpack txt)
      rendered = maybe clipped (`highlightMatches` clipped) mre
  hPutStr (termStdout term) $ "\ESC[" ++ show row ++ ";1H\ESC[K" ++ rendered

-- | Wrap every regex match on the (already clipped) line in reverse video.
highlightMatches :: Regex -> String -> String
highlightMatches re s = go 0 s spans
  where
    spans = [(off, len) | ma <- matchAll re s, let (off, len) = ma ! 0, len > 0]
    go _ rest [] = rest
    go pos rest ((off, len) : more) =
      let (before, rest') = splitAt (off - pos) rest
          (hit, rest'') = splitAt len rest'
       in before ++ "\ESC[7m" ++ hit ++ "\ESC[0m" ++ go (off + len) rest'' more

-- | Full-width reverse-video bar (used for the top border and the status line).
drawBarRow :: Terminal -> Int -> String -> IO ()
drawBarRow term row content = do
  let padded = padToDisplayWidth (termCols term) content
  hPutStr (termStdout term) $
    "\ESC[" ++ show row ++ ";1H\ESC[7m" ++ padded ++ "\ESC[0m"

clearViewport :: Terminal -> IO ()
clearViewport term =
  mapM_ clearRow [termViewportTop term .. termViewportTop term + termViewportH term - 1]
  where
    clearRow r = hPutStr (termStdout term) $ "\ESC[" ++ show r ++ ";1H\ESC[K"