packages feed

nano-ui-form-0.1.0.0: lib/NanoUI/Form/Backend.hs

-- | The ditto environment forms run in: field input values, form prefixes and
-- submitted state, kept in the widget store.
module NanoUI.Form.Backend
  ( FormInput (..)
  , formInputToText
  , FormUI (..)
  , liftNanoUI
  , getActiveFormPrefix
  , withFormPrefix
  , withFormWidgets
  , updateFieldInput
  , markFormSubmitted
  , isFormSubmitted
  , resetFormState
    -- * Stored form state, for tests
  , FormStateStore (..)
  , emptyFormStateStore
  , getFormStore
  , setFormStore
  , setActiveFormPrefix
  ) where

import Control.Monad (when, (<$!>))
import Data.Dynamic (fromDynamic, toDyn)
import qualified Data.IntMap.Strict as IM
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Builder as TB
import qualified Data.Text.Lazy.Builder.Int as TB
import qualified Data.Text.Lazy.Builder.RealFloat as TB
import Data.Hashable (hash)
import Data.Maybe (fromMaybe)
import qualified Ditto.Backend as Ditto
import Ditto.Backend
  ( FormError (..)
  , commonFormErrorText
  )
import Ditto.Core (Environment (..))
import Ditto.Types (Value (..), encodeFormId)
import GHC.Generics (Generic)
import Effectful.Exception (bracket)
import NanoUI (NanoUI, uiIO, withKey)
import NanoUI.Monad (askContext)
import NanoUI.Context (Context, getStore, markDirty, setStore)
import NanoUI.Store (WidgetStore (..))

-- | A form field's raw input value, before parsing.
data FormInput
  = FormInputText !Text
  | FormInputBool !Bool
  | FormInputInt !Int
  | FormInputFloat !Float
  | FormInputList ![Text]
  deriving stock (Eq, Show, Generic)

-- | String representation of a 'FormInput'
formInputToText :: FormInput -> Text
formInputToText (FormInputText t) = t
formInputToText (FormInputBool b) = if b then "true" else "false"
formInputToText (FormInputInt i) = TL.toStrict (TB.toLazyText (TB.decimal i))
formInputToText (FormInputFloat f) = TL.toStrict (TB.toLazyText (TB.realFloat f))
formInputToText (FormInputList ts) = T.intercalate "," ts

-- | Internal store for form state across UI frames.
data FormStateStore = FormStateStore
  { fssInputs    :: !(Map.Map Text FormInput)
  , fssSubmitted :: !Bool
  } deriving stock (Eq, Show, Generic)

-- | Empty form state store.
emptyFormStateStore :: FormStateStore
emptyFormStateStore = FormStateStore Map.empty False

-- Keep reset identity alongside the form's values without exposing it in the
-- public FormStateStore. A new generation starts fresh form-local widget state,
-- including composite controls and text-area buffers.
data StoredForm = StoredForm
  { sfGeneration :: !Int
  , sfState      :: !FormStateStore
  }
  deriving (Eq)

-- | Form execution monad wrapping 'NanoUI'.
newtype FormUI a = FormUI { unFormUI :: NanoUI a }
  deriving newtype (Functor, Applicative, Monad)

-- | Lift a 'NanoUI' action into 'FormUI'.
liftNanoUI :: NanoUI a -> FormUI a
liftNanoUI = FormUI

-- | 'FormInput' instance for 'FormInput' allowing ditto decoding.
instance Ditto.FormInput FormInput where
  type FileType FormInput = ()

  getInputText (FormInputText t) = Right t
  getInputText (FormInputList (t : _)) = Right t
  getInputText other = Right (formInputToText other)

  getInputTexts (FormInputList ts) = ts
  getInputTexts other = [formInputToText other]

  getInputString fi = T.unpack <$> Ditto.getInputText fi

  getInputFile _ = Right ()

-- | 'FormError' instance translating common form errors into 'Text'.
instance FormError FormInput Text where
  commonFormError = commonFormErrorText formInputToText

-- | Well-known slot key in 'storeDyn' for the dynamically scoped form prefix.
activePrefixSlot :: Int
activePrefixSlot = -0x464F524D -- -'FORM'

-- | Hash a form prefix to a unique 'IntMap' key.
formStoreKey :: Text -> Int
formStoreKey prefix = hash ("nano-ui-form:" :: Text, prefix)

-- | Retrieve the active form prefix in the current context.
getActiveFormPrefix :: Context -> IO Text
getActiveFormPrefix ctx = do
  ws <- getStore ctx
  pure $! fromMaybe "" (IM.lookup activePrefixSlot (storeDyn ws) >>= fromDynamic)

-- | Set the active form prefix in the current context.
setActiveFormPrefix :: Context -> Text -> IO ()
setActiveFormPrefix ctx prefix = do
  ws <- getStore ctx
  setStore ctx ws {storeDyn = IM.insert activePrefixSlot (toDyn prefix) (storeDyn ws)}

-- | Evaluate or render a form under its own prefix, restoring the enclosing
-- prefix afterwards. Restore only this slot, so field updates survive the scope.
withFormPrefix :: Text -> NanoUI a -> NanoUI a
withFormPrefix prefix action = do
  ctx <- askContext
  let restorePrefix previous = uiIO $ do
        ws <- getStore ctx
        setStore ctx ws
          { storeDyn = IM.alter (const previous) activePrefixSlot (storeDyn ws)
          }
  bracket
    (uiIO $ IM.lookup activePrefixSlot . storeDyn <$> getStore ctx)
    restorePrefix
    (\_ -> uiIO (setActiveFormPrefix ctx prefix) >> action)

-- | Stable widget identity for a form, renewed when its state is reset.
withFormWidgets :: Text -> NanoUI a -> NanoUI a
withFormWidgets prefix action = do
  ctx <- askContext
  stored <- uiIO (getStoredForm ctx prefix)
  withKey (prefix, sfGeneration stored) action

getStoredForm :: Context -> Text -> IO StoredForm
getStoredForm ctx prefix = do
  ws <- getStore ctx
  -- Resolve the lookup here rather than returning a thunk over the whole store.
  pure $! fromMaybe (StoredForm 0 emptyFormStateStore) (IM.lookup (formStoreKey prefix) (storeDyn ws) >>= fromDynamic)

setStoredForm :: Context -> Text -> StoredForm -> IO ()
setStoredForm ctx prefix !stored = do
  ws <- getStore ctx
  setStore ctx ws {storeDyn = IM.insert (formStoreKey prefix) (toDyn stored) (storeDyn ws)}

-- | Retrieve the 'FormStateStore' for a given form prefix.
getFormStore :: Context -> Text -> IO FormStateStore
getFormStore ctx prefix = sfState <$!> getStoredForm ctx prefix

-- | Persist the 'FormStateStore' for a given form prefix.
setFormStore :: Context -> Text -> FormStateStore -> IO ()
setFormStore ctx prefix fss = do
  stored <- getStoredForm ctx prefix
  setStoredForm ctx prefix stored {sfState = fss}

-- Form state lives in a Dynamic slot, which the core cannot compare. Keep
-- equality and redraw notification here rather than in each mutation.
modifyFormStore :: Context -> Text -> (FormStateStore -> FormStateStore) -> IO ()
modifyFormStore ctx prefix update =
  modifyStoredForm ctx prefix (\stored -> stored {sfState = update (sfState stored)})

modifyStoredForm :: Context -> Text -> (StoredForm -> StoredForm) -> IO ()
modifyStoredForm ctx prefix update = do
  previous <- getStoredForm ctx prefix
  let next = update previous
  when (next /= previous) $ do
    setStoredForm ctx prefix next
    markDirty ctx

-- | Update a specific field's input in the form store.
updateFieldInput :: Context -> Text -> Text -> FormInput -> IO ()
updateFieldInput ctx prefix fieldKey inputVal =
  modifyFormStore ctx prefix $ \fss ->
    fss {fssInputs = Map.insert fieldKey inputVal (fssInputs fss)}

-- | Mark a form as submitted.
markFormSubmitted :: Context -> Text -> Bool -> IO ()
markFormSubmitted ctx prefix isSubmitted =
  modifyFormStore ctx prefix (\fss -> fss {fssSubmitted = isSubmitted})

-- | Check if a form has been submitted.
isFormSubmitted :: Context -> Text -> IO Bool
isFormSubmitted ctx prefix = do
  fss <- getFormStore ctx prefix
  pure (fssSubmitted fss)

-- | Reset values and renew widget identity so cached control state cannot
-- repopulate the form with its old values on the next frame.
resetFormState :: Context -> Text -> IO ()
resetFormState ctx prefix = modifyStoredForm ctx prefix $ \stored ->
  if sfState stored == emptyFormStateStore
    then stored
    else StoredForm (sfGeneration stored + 1) emptyFormStateStore

-- | Environment instance for 'FormUI' connecting ditto to nano-ui's context store.
instance Environment FormUI FormInput where
  environment fid = FormUI $ do
    ctx <- askContext
    prefix <- uiIO (getActiveFormPrefix ctx)
    fss <- uiIO (getFormStore ctx prefix)
    let fieldKey = encodeFormId fid
    pure $ case Map.lookup fieldKey (fssInputs fss) of
      Just val -> Found val
      Nothing  -> Default