packages feed

concoct-0.1.0: src/Concoct/View/Internal.hs

{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE RankNTypes #-}

-- |
-- Module      : Concoct.View.Internal
-- Copyright   : (c) Matt Hunzinger, 2026
-- License     : BSD-style (see the LICENSE file in the distribution)
--
-- Maintainer  : matt@hunzinger.me
-- Stability   : provisional
-- Portability : non-portable (GHC extensions)
module Concoct.View.Internal
  ( -- * MonadView
    MonadView (..),

    -- * StateRef
    StateRef (..),
    readStateRef,
    writeStateRef,

    -- * ViewRef
    ViewRef (..),
    readViewRef,

    -- * Stack
    Stack (..),
    emptyStack,
    pushStack,
    popStack,
    peekStack,
    setStack,
    skipStack,
    flushStack,

    -- * ViewState
    ViewState (..),
    rebuildState,
    rebuildState',
    rebuildRef,
    rebuildRef',
    rebuildComponent,
  )
where

import Control.Monad
import Control.Monad.State
import Data.Dynamic
import Data.IORef

-- | Monadic view.
--
-- Views are interpreted in multiple passes:
--
-- * Build
-- * Rebuild
-- * Skip
-- * Unmount
--
-- And can be extended for further passes, such as layout or render.
--
-- Hooks provide access to values in a view across passes.
--
-- Views are seperated into @component@s, with each providing a scope for updates to occur.
-- A @component@ will only be rebuilt if its state is changed.
-- If a component containing children is updated, its children are rebuilt as well.
class (Monad m) => MonadView t m | m -> t where
  -- | Hook to use a mutable state reference.
  useState :: (Typeable a) => t a -> m (StateRef a)

  -- | Hook to use a constant view reference.
  useRef :: (Typeable a) => t a -> m (ViewRef a)

  -- | Hook to use an effect that runs when dependencies change.
  useEffect :: (Eq d, Typeable d) => t d -> (d -> t ()) -> m ()

  -- | Hook that runs when the current component is unmounted.
  useOnUnmount :: t () -> m ()

  -- | Component view.
  component :: (forall x. (MonadView t x) => x ()) -> m ()

  -- | Lift a monadic action into the view.
  liftView :: t () -> m ()

  -- | Conditional view.
  -- This will render the first view if the condition is @True@, otherwise the second.
  switchView ::
    t Bool ->
    (forall x. (MonadView t x) => x ()) ->
    (forall x. (MonadView t x) => x ()) ->
    m ()

  -- | List view.
  -- This will render a view for each item in the list.
  listView :: (Typeable a, Eq a) => t [a] -> (a -> (forall x. (MonadView t x) => x ())) -> m ()

-- | State reference.
-- Created with @useState@.
data StateRef a = StateRef
  { stateRef :: IORef a,
    stateRefUpdater :: IO () -> IO ()
  }

-- | Read a state reference.
readStateRef :: (MonadIO m) => StateRef a -> m a
readStateRef = liftIO . readIORef . stateRef

-- | Write to a state reference.
-- This will schedule an update to the current @component@.
writeStateRef :: (MonadIO m) => StateRef a -> a -> m ()
writeStateRef ref = liftIO . stateRefUpdater ref . writeIORef (stateRef ref)

-- | View reference.
-- Created with @useRef@.
newtype ViewRef a = ViewRef a

-- | Read a view reference.
readViewRef :: (Applicative m) => ViewRef a -> m a
readViewRef (ViewRef a) = pure a

data Stack = Stack
  { stackBefore :: [Dynamic],
    stackAfter :: [Dynamic]
  }

emptyStack :: Stack
emptyStack = Stack [] []

pushStack :: (Typeable a) => a -> Stack -> Stack
pushStack a (Stack before after) = Stack (toDyn a : before) after

popStack :: (Typeable a) => Stack -> (Maybe a, Stack)
popStack (Stack before []) = (Nothing, Stack before [])
popStack (Stack before (d : ds)) =
  case fromDynamic d of
    Just a -> (Just a, Stack (d : before) ds)
    Nothing -> popStack (Stack (d : before) ds)

peekStack :: (Typeable a) => Stack -> Maybe a
peekStack (Stack _ []) = Nothing
peekStack (Stack _ (d : _)) = fromDynamic d

setStack :: (Typeable a) => a -> Stack -> Stack
setStack a (Stack before (_ : ds)) = Stack (toDyn a : before) ds
setStack _ s = s

skipStack :: Stack -> Stack
skipStack (Stack before (d : after)) = Stack (d : before) after
skipStack s = s

flushStack :: Stack -> Stack
flushStack (Stack before after) = Stack [] (reverse before ++ after)

data ViewState = ViewState
  { viewStack :: Stack,
    viewUpdater :: IO () -> IO ()
  }

rebuildState :: (MonadIO m, Typeable a) => StateT ViewState m (StateRef a)
rebuildState = do
  vs <- get
  let (ref, s') = rebuildState' (viewStack vs)
  put vs {viewStack = s'}
  return ref

rebuildState' :: (Typeable a) => Stack -> (StateRef a, Stack)
rebuildState' s = do
  let (mref, s') = popStack s
  case mref of
    Just ref -> (ref, s')
    Nothing -> error "useState: StateRef not found in stack during rebuild"

rebuildRef :: (Monad m, Typeable a) => StateT ViewState m (ViewRef a)
rebuildRef = do
  vs <- get
  let (ref, s') = rebuildRef' (viewStack vs)
  put vs {viewStack = s'}
  return ref

rebuildRef' :: (Typeable a) => Stack -> (ViewRef a, Stack)
rebuildRef' s = do
  let (mref, s') = popStack s
  case mref of
    Just ref -> (ref, s')
    Nothing -> error "useRef: ViewRef not found in stack during rebuild"

rebuildComponent ::
  (MonadIO m, MonadView t f) =>
  (f () -> StateT ViewState m ()) ->
  (forall x. (MonadView t x) => x ()) ->
  StateT ViewState m ()
rebuildComponent g v = do
  vs <- get
  let (mref, s') = popStack (viewStack vs)
  put vs {viewStack = s'}
  case mref of
    Just ref -> do
      changed <- liftIO $ readIORef ref
      when changed $ liftIO $ writeIORef ref False
      g v
      vs' <- get
      put vs' {viewUpdater = viewUpdater vs}
    Nothing -> error "component: Change ref not found in stack during rebuild"