packages feed

tuispec-0.3.0.0: src/TuiSpec/Choice.hs

{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : TuiSpec.Choice
Description : Helpers for choosing numbered options in terminal prompts.

This module handles a common TUI pattern: a prompt/header appears, followed by
numbered choices such as @1) Haskell@ and @2) Python@. The helpers do not assume
that the choices are rendered as a modal, menu, palette, or any other widget.
-}
module TuiSpec.Choice (
    ChoiceSelectionError (..),
    NumberedChoice (..),
    parseNumberedChoices,
    selectNumberedChoice,
    selectNumberedChoiceWith,
    trySelectNumberedChoice,
    trySelectNumberedChoiceWith,
) where

import Control.Exception (Exception (displayException), SomeException, throwIO, try)
import Control.Monad (replicateM_, unless, when)
import Data.Char (intToDigit, isDigit)
import Data.List (find, findIndex)
import Data.Maybe (listToMaybe, mapMaybe)
import Data.Text (Text)
import Data.Text qualified as Text
import Text.Read (readMaybe)
import TuiSpec.Runner (currentView, defaultWaitOptionsFor, press, waitForSelector)
import TuiSpec.Types (Key (..), Selector (..), Tui, WaitOptions)

-- | One numbered choice parsed from the visible viewport.
data NumberedChoice = NumberedChoice
    { numberedChoiceNumber :: Int
    , numberedChoiceLabel :: Text
    , numberedChoiceSelected :: Bool
    }
    deriving (Eq, Show)

-- | Errors raised by 'selectNumberedChoice' when the prompt or choice cannot be located.
data ChoiceSelectionError
    = -- | The prompt text never appeared before the wait timeout.
      ChoicePromptTimedOut Text
    | -- | The prompt was visible but did not contain the requested choice.
      ChoiceNotFound Text Text [Text]
    | -- | The prompt was visible but had no selected marker for arrow-key fallback.
      ChoiceNotSelectable Text Text [Text]
    deriving (Eq, Show)

instance Exception ChoiceSelectionError where
    displayException err =
        case err of
            ChoicePromptTimedOut promptText ->
                "numbered choice prompt did not appear before timeout: "
                    <> Text.unpack promptText
            ChoiceNotFound promptText choiceText choices ->
                "numbered choice prompt '"
                    <> Text.unpack promptText
                    <> "' did not contain choice '"
                    <> Text.unpack choiceText
                    <> "'. Choices: "
                    <> show choices
            ChoiceNotSelectable promptText choiceText choices ->
                "numbered choice prompt '"
                    <> Text.unpack promptText
                    <> "' contained choice '"
                    <> Text.unpack choiceText
                    <> "' but no selected choice marker was visible for arrow-key fallback. Choices: "
                    <> show choices

-- | Select a numbered choice after its prompt appears, using test defaults.
selectNumberedChoice :: Tui -> Text -> Text -> IO ()
selectNumberedChoice tui =
    selectNumberedChoiceWith tui (defaultWaitOptionsFor tui)

-- | Select a numbered choice after its prompt appears, using explicit wait options.
selectNumberedChoiceWith :: Tui -> WaitOptions -> Text -> Text -> IO ()
selectNumberedChoiceWith tui waitOptions promptText choiceText = do
    promptVisible <- waitForChoicePrompt tui waitOptions promptText
    unless promptVisible $
        throwIO (ChoicePromptTimedOut promptText)
    selectVisibleNumberedChoice tui promptText choiceText

-- | Try to select a numbered choice. Returns 'False' if the prompt never appears.
trySelectNumberedChoice :: Tui -> Text -> Text -> IO Bool
trySelectNumberedChoice tui =
    trySelectNumberedChoiceWith tui (defaultWaitOptionsFor tui)

-- | Try to select a numbered choice with explicit wait options.
trySelectNumberedChoiceWith :: Tui -> WaitOptions -> Text -> Text -> IO Bool
trySelectNumberedChoiceWith tui waitOptions promptText choiceText = do
    promptVisible <- waitForChoicePrompt tui waitOptions promptText
    if promptVisible
        then do
            selectVisibleNumberedChoice tui promptText choiceText
            pure True
        else pure False

waitForChoicePrompt :: Tui -> WaitOptions -> Text -> IO Bool
waitForChoicePrompt tui waitOptions promptText = do
    result <- try (waitForSelector tui waitOptions (Exact promptText)) :: IO (Either SomeException ())
    pure (either (const False) (const True) result)

selectVisibleNumberedChoice :: Tui -> Text -> Text -> IO ()
selectVisibleNumberedChoice tui promptText choiceText = do
    viewport <- currentView tui
    let choices = parseNumberedChoices viewport
    case find (matchesNumberedChoice choiceText) choices of
        Just choice
            | numberedChoiceNumber choice >= 1 && numberedChoiceNumber choice <= 9 -> do
                press tui (CharKey (intToDigit (numberedChoiceNumber choice)))
                viewAfterDigit <- currentView tui
                whenPromptStillVisible promptText viewAfterDigit $
                    selectNumberedChoiceByArrows tui promptText choiceText choices
            | otherwise ->
                selectNumberedChoiceByArrows tui promptText choiceText choices
        Nothing ->
            throwIO (ChoiceNotFound promptText choiceText (map numberedChoiceLabel choices))

whenPromptStillVisible :: Text -> Text -> IO () -> IO ()
whenPromptStillVisible promptText viewport =
    when (promptText `Text.isInfixOf` viewport)

selectNumberedChoiceByArrows :: Tui -> Text -> Text -> [NumberedChoice] -> IO ()
selectNumberedChoiceByArrows tui promptText choiceText choices = do
    let maybeTargetIndex = findIndex (matchesNumberedChoice choiceText) choices
        maybeSelectedIndex = findIndex numberedChoiceSelected choices
    case (maybeTargetIndex, maybeSelectedIndex) of
        (Just targetIndex, Just selectedIndex) -> do
            let delta = targetIndex - selectedIndex
                key = if delta >= 0 then ArrowDown else ArrowUp
            replicateM_ (abs delta) (press tui key)
            press tui Enter
        _ ->
            throwIO (ChoiceNotSelectable promptText choiceText (map numberedChoiceLabel choices))

matchesNumberedChoice :: Text -> NumberedChoice -> Bool
matchesNumberedChoice choiceText choice =
    Text.toLower choiceText `Text.isInfixOf` Text.toLower (numberedChoiceLabel choice)

-- | Parse numbered choices from rendered viewport text.
parseNumberedChoices :: Text -> [NumberedChoice]
parseNumberedChoices =
    mapMaybe parseLine . Text.lines
  where
    parseLine line =
        listToMaybe (mapMaybe parseCandidate (Text.tails line))

    parseCandidate candidate = do
        let stripped = Text.dropWhile isLeftPad candidate
        let (selected, body) =
                case Text.stripPrefix ">" stripped of
                    Just rest -> (True, Text.dropWhile isLeftPad rest)
                    Nothing -> (False, stripped)
        let (digitsText, afterDigits) = Text.span isDigit body
        if Text.null digitsText || not (")" `Text.isPrefixOf` afterDigits)
            then Nothing
            else do
                choiceNumber <- readMaybe (Text.unpack digitsText)
                let label =
                        Text.strip $
                            Text.takeWhile (not . isChoiceBorder) $
                                Text.drop 1 afterDigits
                if Text.null label
                    then Nothing
                    else
                        Just
                            NumberedChoice
                                { numberedChoiceNumber = choiceNumber
                                , numberedChoiceLabel = label
                                , numberedChoiceSelected = selected
                                }

    isLeftPad c = c == ' ' || c == '\t' || isChoiceBorder c

isChoiceBorder :: Char -> Bool
isChoiceBorder char = char `elem` ("│┃║|─━═┌┐└┘┏┓┗┛╔╗╚╝├┤┣┫" :: String)