packages feed

monomer-hagrid-0.1.0.0: src/Monomer/Hagrid.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
{-# OPTIONS_GHC -Wno-name-shadowing #-}

-- | A datagrid widget for the Monomer UI library.
module Monomer.Hagrid
  ( -- * Types
    HagridCfg,
    Column (..),
    ColumnAlign (..),
    ColumnWidget (..),
    ColumnFooterWidget (..),
    ColumnSortKey (..),
    SortDirection (..),
    ScrollToRowCallback,

    -- * Configuration options
    initialSort,

    -- * Hagrid constructors
    hagrid,
    hagrid_,

    -- * Column Constructors
    textColumn,
    showOrdColumn,
    widgetColumn,

    -- * Messages
    scrollToRow,
  )
where

import Control.Applicative ((<|>))
import Control.Lens ((.~), (<>~), (^.))
import Control.Lens.Combinators (non)
import Control.Lens.Lens ((&))
import Control.Monad as X (forM_)
import Data.Data (Typeable)
import Data.Default.Class as X (Default, def)
import Data.Foldable (foldl')
import qualified Data.List as List
import Data.List.Index (indexed, izipWith, modifyAt)
import Data.Maybe (fromJust, maybeToList)
import Data.Maybe as X (fromMaybe)
import Data.Ord (Down (Down))
import Data.Sequence (Seq ((:<|), (:|>)))
import qualified Data.Sequence as S
import Data.Text (Text)
import qualified Data.Text as T
import Data.Typeable (cast)
import Monomer
import qualified Monomer.Lens as L
import Monomer.Widgets.Container
import Monomer.Widgets.Single

-- | Configuration options for Hagrid widgets.
data HagridCfg s e = HagridCfg
  { cfgInitialSort :: Maybe (Int, SortDirection)
  }

instance Default (HagridCfg s e) where
  def = HagridCfg {cfgInitialSort = Nothing}

instance Semigroup (HagridCfg s e) where
  c1 <> c2 = HagridCfg {cfgInitialSort = c2.cfgInitialSort <|> c1.cfgInitialSort}

instance Monoid (HagridCfg s e) where
  mempty = def

-- | Configures the initial sort column and direction.
initialSort ::
  -- | The initial sort column (zero-indexed, out of bounds values will have no effect).
  Int ->
  -- | The initial sort direction.
  SortDirection ->
  HagridCfg s e
initialSort column direction =
  HagridCfg {cfgInitialSort = Just (column, direction)}

-- | A column definition.
data Column e a = Column
  { -- | The name of the column, displayed in the column header.
    name :: Text,
    -- | Creates the widget for each cell in the column.
    widget :: ColumnWidget e a,
    -- | Creates the widget for the column footer, if any.
    footerWidget :: ColumnFooterWidget e a,
    -- | How to align the widget within each cell in the column.
    align :: ColumnAlign,
    -- | Determines if and how the column can be sorted by clicking the column header.
    sortKey :: ColumnSortKey a,
    -- | The initial width of the column, in pixels. The user can then change the
    -- width by dragging the edge of the column header.
    initialWidth :: Int,
    -- | The minimum allowed width of the column, in pixels.
    minWidth :: Int,
    -- | The padding to the left and right of the widget in each cell of the column, in pixels.
    paddingW :: Double,
    -- | The padding above and below the widget in each cell in the column, in pixels.
    paddingH :: Double,
    -- | An optional event to emit when a user has finished resizing the column. The function receives the new width in pixels.
    resizeHandler :: Maybe (Int -> e),
    -- | An optional event to emit when a user has sorted the column by clicking the header. The function receives the new sort direction.
    sortHandler :: Maybe (SortDirection -> e)
  }

-- | How to create the widget that displays each cell in a column.
data ColumnWidget e a
  = -- | Create a label widget.. The function receives the original item index (i.e.
    -- not the index in the sorted list) and the item itself.
    LabelWidget (Int -> a -> Text)
  | -- | Create a widget of arbitrary type. The function receives the original item
    -- index (i.e. not the index in the sorted list) and the item itself.
    CustomWidget (forall s. WidgetModel s => Int -> a -> WidgetNode s e)

-- | How to create the footer widget for a column.
data ColumnFooterWidget e a
  = -- | No footer widget for this column.
    NoFooterWidget
  | -- | Create a footer widget. The function receives the items in their current sort
    -- order, and also along with each item it's original (unsorted) index.
    CustomFooterWidget (forall s. WidgetModel s => [(a, Int)] -> WidgetNode s e)

-- | How to align the widget within each cell of a column.
data ColumnAlign
  = ColumnAlignLeft
  | ColumnAlignRight
  deriving (Eq, Show)

-- | Whether a column can be sorted by the user clicking the column header, and if so, how.
data ColumnSortKey a
  = -- | Means that a column can't be sorted.
    DontSort
  | -- | Means that a column can be sorted, using the specified sort key function.
    forall b. Ord b => SortWith (a -> b)

-- | Whether a column is being sorted in ascending or descending order.
data SortDirection
  = SortAscending
  | SortDescending
  deriving (Eq, Show)

-- | Picks an item to scroll to, based on the sorted or original grid contents.
type ScrollToRowCallback a =
  -- | The items in the grid, in the originally provided order, along with each item's index
  -- in the current grid order.
  [(a, Int)] ->
  -- | The row to scroll to, as an index into the sorted items (e.g. 0 is always the first row
  -- in the grid, regardless of the current order). 'Nothing' will cancel the scroll.
  Maybe Int

data HagridEvent ep
  = ContentScrollChange ScrollStatus
  | OrderByColumn Int
  | ResizeColumn Int Int
  | ResizeColumnFinished Int
  | forall a. Typeable a => ScrollToRow (ScrollToRowCallback a)
  | ParentEvent ep

data HagridModel a = HagridModel
  { sortedItems :: [(a, Int)], -- each item, plus its index in the original (unsorted) list
    columns :: [ModelColumn],
    sortColumn :: Maybe (Int, SortDirection)
  }
  deriving (Eq, Show)

data ModelColumn = ModelColumn
  { currentWidth :: Int,
    name :: Text
  }
  deriving (Eq, Show)

-- | The state of the header or footer, which have a scroll offset because they
-- scroll horizontally along with the content pane.
data OffsetXState = OffsetXState
  { offsetX :: Double
  }
  deriving (Eq, Show)

data OffsetXEvent = SetOffsetX Double

data HeaderDragHandleState = HeaderDragHandleState
  { dragStartMouseX :: Double,
    dragStartColumnW :: Int
  }
  deriving (Eq, Show)

data ContentPaneMessage a
  = ContentPaneScrollToRow (ScrollToRowCallback a)

-- | Creates a hagrid widget, using the default configuration.
hagrid ::
  forall a s e.
  (CompositeModel a, WidgetModel s, WidgetEvent e) =>
  -- | The definitions for each column in the grid.
  [Column e a] ->
  -- | The items for each row in the grid.
  [a] ->
  WidgetNode s e
hagrid = hagrid_ def

-- | Creates a hagrid widget, using the given configuration.
hagrid_ ::
  forall a s e.
  (CompositeModel a, WidgetModel s, WidgetEvent e) =>
  [HagridCfg s e] ->
  -- | The definitions for each column in the grid.
  [Column e a] ->
  -- | The items for each row in the grid.
  [a] ->
  WidgetNode s e
hagrid_ cfg columnDefs items = widget
  where
    -- todo: accept lens ?

    widget =
      compositeD_
        "Hagrid.Root"
        (WidgetValue (initialModel cfg columnDefs items))
        buildUI
        handleEvent
        [compositeMergeModel mergeModel]

    buildUI :: UIBuilder (HagridModel a) (HagridEvent e)
    buildUI _wenv model = tree
      where
        tree =
          vstack
            [ headerPane columnDefs model `nodeKey` headerPaneKey,
              contentScroll `nodeKey` contentScrollKey,
              footerPane columnDefs model `nodeKey` footerPaneKey
            ]
        contentScroll =
          scroll_ [onChange ContentScrollChange] $
            contentPane columnDefs model `nodeKey` contentPaneKey

    handleEvent :: EventHandler (HagridModel a) (HagridEvent e) sp e
    handleEvent wenv _node model = \case
      ScrollToRow row ->
        [Message (WidgetKey contentPaneKey) (ContentPaneScrollToRow row)]
      ContentScrollChange ScrollStatus {scrollDeltaX} ->
        [ Message (WidgetKey headerPaneKey) (SetOffsetX scrollDeltaX),
          Message (WidgetKey footerPaneKey) (SetOffsetX scrollDeltaX)
        ]
      OrderByColumn colIndex -> result
        where
          Column {sortHandler, sortKey} = columnDefs !! colIndex
          (sortColumn, sortedItems)
            | Just (c, dir) <- model.sortColumn,
              c == colIndex =
                let sortColumn = Just (colIndex, flipSortDirection dir)
                    sortedItems = reverse model.sortedItems
                 in (sortColumn, sortedItems)
            | otherwise =
                let sortColumn = Just (colIndex, SortAscending)
                    sortedItems = sortItems columnDefs sortColumn model.sortedItems
                 in (sortColumn, sortedItems)
          result = case sortKey of
            DontSort -> []
            SortWith _ -> Model model {sortColumn, sortedItems} : handler
          handler =
            Report <$> maybeToList (sortHandler <*> (snd <$> sortColumn))
      ResizeColumn colIndex newWidth ->
        [ Model (model {columns = modifyAt colIndex (\c -> c {currentWidth = newWidth}) model.columns}),
          Request (ResizeWidgets headerPaneId),
          Request (ResizeWidgets footerPaneId),
          Request (ResizeWidgets contentPaneId)
        ]
      ResizeColumnFinished colIndex -> result
        where
          ModelColumn {currentWidth} = model.columns !! colIndex
          Column {resizeHandler} = columnDefs !! colIndex
          result =
            Report <$> maybeToList (resizeHandler <*> Just currentWidth)
      ParentEvent e ->
        [Report e]
      where
        headerPaneId = fromJust (widgetIdFromKey wenv (WidgetKey headerPaneKey))
        footerPaneId = fromJust (widgetIdFromKey wenv (WidgetKey footerPaneKey))
        contentPaneId = fromJust (widgetIdFromKey wenv (WidgetKey contentPaneKey))

    -- If the column names have not changed then preseve the column widths and sort
    -- order too, otherwise unrelated model changes will reset the column widths/sort.
    mergeModel :: MergeModelHandler (HagridModel a) (HagridEvent e) s
    mergeModel _wenv _parentModel oldModel newModel = resultModel
      where
        resultModel
          | columnNames oldModel == columnNames newModel =
              newModel
                { columns = oldModel.columns,
                  sortColumn = oldModel.sortColumn,
                  sortedItems = sortItems columnDefs oldModel.sortColumn newModel.sortedItems
                }
          | otherwise =
              newModel
        columnNames m =
          (.name) <$> m.columns

drawSortIndicator :: Renderer -> Rect -> Maybe Color -> SortDirection -> IO ()
drawSortIndicator renderer rect color dir = drawCmd
  where
    drawCmd = case dir of
      SortAscending -> drawTriangle renderer p2 p4 p3 color
      SortDescending -> drawTriangle renderer p1 p2 p4 color
    Rect x y w h = rect
    p1 = Point x y
    p2 = Point (x + w) y
    p3 = Point x (y + h)
    p4 = Point (x + w) (y + h)

accentColor :: WidgetEnv s e -> Color
accentColor wenv = transColor
  where
    style = wenv ^. L.theme . L.basic . L.btnStyle
    color = fromMaybe (rgb 255 255 255) (_sstText style >>= _txsFontColor)
    transColor = color {_colorA = 0.7}

headerPane :: forall s ep a. WidgetEvent ep => [Column ep a] -> HagridModel a -> WidgetNode s (HagridEvent ep)
headerPane columnDefs model = makeNode (OffsetXState 0)
  where
    makeNode :: OffsetXState -> WidgetNode s (HagridEvent ep)
    makeNode state = node
      where
        node =
          defaultWidgetNode "Hagrid.HeaderPane" (makeWidget state)
            & L.children .~ S.fromList childWidgets

        childWidgets =
          mconcat (izipWith childWidgetPair columnDefs model.columns)

        childWidgetPair i columnDef column = [btn, handle]
          where
            btn = headerButton i columnDef
            handle = headerDragHandle i columnDef column

    makeWidget :: OffsetXState -> Widget s (HagridEvent ep)
    makeWidget state = container
      where
        container =
          createContainer
            state
            def
              { containerChildrenOffset = Just (Point state.offsetX 0),
                containerUpdateCWenv = updateCWenv,
                containerMerge = merge,
                containerHandleMessage = handleMessage,
                containerGetSizeReq = getSizeReq,
                containerResize = resize,
                containerRenderAfter = renderAfter
              }

        -- needed to ensure child widgets don't disappear when scrolling
        updateCWenv wenv node _cnode _cidx = newWenv
          where
            style = currentStyle wenv node
            carea = getContentArea node style
            newWenv =
              wenv
                & L.viewport .~ moveRect (Point (-state.offsetX) 0) carea

        -- keep the scroll offset from the old node
        merge _wenv node _oldNode oldState = resultNode newNode
          where
            newNode = node & L.widget .~ makeWidget oldState

        handleMessage :: ContainerMessageHandler s (HagridEvent ep)
        handleMessage _wenv node _target msg = result
          where
            handleTypedMessage (SetOffsetX offsetX)
              | offsetX == state.offsetX = Nothing
              | otherwise =
                  Just . resultNode $
                    node & L.widget .~ makeWidget state {offsetX}
            result = cast msg >>= handleTypedMessage

        getSizeReq _wenv _node _children = (w, h)
          where
            w = fixedSize (sum (fromIntegral . currentWidth <$> model.columns) + hScrollFudgeFactor)
            h = fixedSize dragHandleHeight

        resize _wenv node viewport _children = (resultNode node, assignedAreas)
          where
            Rect l t _w h = viewport
            widgetWidths = do
              w <- currentWidth <$> model.columns
              [w - dragHandleWidth, dragHandleWidth]
            (assignedAreas, _) = foldl' assignArea (mempty, l) widgetWidths
            assignArea (areas, colX) columnWidth =
              (areas :|> Rect colX t (fromIntegral columnWidth) h, colX + fromIntegral columnWidth)

        renderAfter wenv node renderer =
          forM_ model.sortColumn (renderSortIndicator wenv node renderer)

        renderSortIndicator wenv node renderer (sortCol, sortDirection) = do
          drawSortIndicator renderer indRect (Just (accentColor wenv)) sortDirection
          where
            style = wenv ^. L.theme . L.basic . L.btnStyle
            Rect l t _w h = node ^. L.info . L.viewport
            size = style ^. L.text . non def . L.fontSize . non def
            colOffset = fromIntegral (sum (take (sortCol + 1) (currentWidth <$> model.columns)) - dragHandleWidth)
            indW = unFontSize size * 2 / 3
            pad = indW / 3
            indT = case sortDirection of
              SortAscending -> t + h - pad - indW
              SortDescending -> t + pad
            indL = l + state.offsetX + colOffset - indW - pad
            indRect = Rect indL indT indW indW

headerButton :: WidgetEvent ep => Int -> Column ep a -> WidgetNode s (HagridEvent ep)
headerButton colIndex columnDef =
  button_ columnDef.name (OrderByColumn colIndex) [ellipsis]
    `styleBasic` [radius 0]

footerPane :: forall s ep a. (CompositeModel a, CompositeModel s, Typeable ep) => [Column ep a] -> HagridModel a -> WidgetNode (HagridModel s) (HagridEvent ep)
footerPane columnDefs model = makeNode (OffsetXState 0)
  where
    makeNode :: OffsetXState -> WidgetNode (HagridModel s) (HagridEvent ep)
    makeNode state = node
      where
        node =
          defaultWidgetNode "Hagrid.FooterPane" (makeWidget state)
            & L.children .~ childWidgets

    childWidgets :: Seq (WidgetNode (HagridModel s) (HagridEvent ep))
    childWidgets =
      S.fromList $
        footerWidgetNode model.sortedItems . footerWidget <$> columnDefs

    makeWidget :: OffsetXState -> Widget (HagridModel s) (HagridEvent ep)
    makeWidget state = container
      where
        container =
          createContainer
            state
            def
              { containerChildrenOffset = Just (Point state.offsetX 0),
                containerUpdateCWenv = updateCWenv,
                containerMerge = merge,
                containerHandleMessage = handleMessage,
                containerGetSizeReq = getSizeReq,
                containerResize = resize
              }

        -- needed to ensure child widgets don't disappear when scrolling
        updateCWenv wenv node _cnode _cidx = newWenv
          where
            style = currentStyle wenv node
            carea = getContentArea node style
            newWenv =
              wenv
                & L.viewport .~ moveRect (Point (-state.offsetX) 0) carea

        -- keep the scroll offset from the old node
        merge _wenv node _oldNode oldState = resultNode newNode
          where
            newNode = node & L.widget .~ makeWidget oldState

        handleMessage :: ContainerMessageHandler (HagridModel s) (HagridEvent ep)
        handleMessage _wenv node _target msg = result
          where
            handleTypedMessage (SetOffsetX offsetX)
              | offsetX == state.offsetX = Nothing
              | otherwise =
                  Just . resultNode $
                    node & L.widget .~ makeWidget state {offsetX}
            result = cast msg >>= handleTypedMessage

        getSizeReq _wenv _node children = (w, h)
          where
            w = fixedSize (sum (fromIntegral . currentWidth <$> model.columns) + hScrollFudgeFactor)
            h = foldl' sizeReqMergeMax (fixedSize 0) ((_wniSizeReqH . _wnInfo) <$> children)

        resize _wenv node viewport _children = (resultNode node, assignedAreas)
          where
            Rect l t _w h = viewport
            (assignedAreas, _) = foldl' assignArea (mempty, l) model.columns
            assignArea (areas, colX) ModelColumn {currentWidth} =
              (areas :|> Rect colX t (fromIntegral currentWidth) h, colX + fromIntegral currentWidth)

headerDragHandle :: WidgetEvent ep => Int -> Column ep a -> ModelColumn -> WidgetNode s (HagridEvent ep)
headerDragHandle colIndex columnDef column = tree
  where
    tree = defaultWidgetNode "Hagrid.HeaderDragHandle" (headerDragHandleWidget Nothing)

    headerDragHandleWidget state = single
      where
        single =
          createSingle
            state
            def
              { singleGetBaseStyle = getBaseStyle,
                singleMerge = merge,
                singleHandleEvent = handleEvent,
                singleRender = render
              }

        getBaseStyle _wenv _node =
          Just def {_styleBasic = Just def {_sstCursorIcon = Just CursorSizeH}}

        merge _wenv newNode _oldNode oldState =
          -- preserve the drag state (this will be called continually as the column resizes)
          resultNode $ newNode & L.widget .~ headerDragHandleWidget oldState

        handleEvent wenv node _target = \case
          ButtonAction (Point _pX _pY) _btn BtnPressed _clicks -> Just result
            where
              -- todo: only if not focussed? set focus?
              result = resultNode newNode
              newNode = node & L.widget .~ headerDragHandleWidget newState
              newState = Just (HeaderDragHandleState _pX column.currentWidth)
          ButtonAction _point _btn BtnReleased _clicks -> Just result
            where
              result = resultReqs newNode [RaiseEvent (ResizeColumnFinished colIndex)]
              newNode = node & L.widget .~ headerDragHandleWidget Nothing
          Move (Point _pX _pY) -> Just result
            where
              result
                | Just nw <- newColumnW =
                    resizeRequest
                      & L.requests
                        <>~ S.fromList
                          [RaiseEvent (ResizeColumn colIndex nw)]
                | otherwise =
                    resultReqs node []
              newColumnW = do
                HeaderDragHandleState clickX columnW <- state
                pure (max columnDef.minWidth (columnW + fromFractional (_pX - clickX)))
          _ -> Nothing
          where
            resizeRequest = widgetResize (node ^. L.widget) wenv node vp (const True)
            vp = node ^. L.info . L.viewport

        render :: WidgetEnv s e -> WidgetNode s e -> Renderer -> IO ()
        render wenv node renderer = do
          drawRect renderer vp (Just (accentColor wenv)) Nothing
          where
            vp = node ^. L.info . L.viewport

-- | This needs to be at least as big as the width of a vertical scrollbar.
hScrollFudgeFactor :: Double
hScrollFudgeFactor = 100

contentPane ::
  forall a ep.
  (CompositeModel a, WidgetEvent ep) =>
  [Column ep a] ->
  HagridModel a ->
  WidgetNode (HagridModel a) (HagridEvent ep)
contentPane columnDefs model = node
  where
    node =
      defaultWidgetNode "Hagrid.ContentPane" contentPaneContainer
        & L.children .~ S.fromList (mconcat childWidgetRows)

    childWidgetRows =
      [ [cellWidget idx item widget | Column {widget} <- columnDefs]
        | (item, idx) <- model.sortedItems
      ]

    nCols = length columnDefs
    columnDefsSeq = S.fromList columnDefs

    contentPaneContainer =
      createContainer
        model
        def
          { containerGetSizeReq = getSizeReq,
            containerResize = resize,
            containerRender = render,
            containerHandleEvent = handleEvent,
            containerHandleMessage = handleMessage
          }

    getSizeReq _wenv _node children = (w, h)
      where
        w = fixedSize (sum (fromIntegral . currentWidth <$> model.columns))
        h = fixedSize (sum (toRowHeights children columnDefsSeq))

    resize wenv node viewport children = (resultNode node, assignedAreas)
      where
        style = currentStyle wenv node
        Rect l t _w _h = fromMaybe def (removeOuterBounds style viewport)

        colXs = sizesToPositions (S.fromList (fromIntegral . currentWidth <$> model.columns))
        rowYs = sizesToPositions (toRowHeights children columnDefsSeq)

        assignedAreas = do
          (rowN, row) <-
            S.mapWithIndex (\i -> (i,)) (S.chunksOf nCols children)
          (colN, columnDef, widget) <-
            S.mapWithIndex (\i (cd, w) -> (i, cd, w)) (S.zip columnDefsSeq row)
          pure (assignArea colN columnDef rowN widget)

        assignArea col Column {paddingW, paddingH, align} row widget = Rect chX chY chW chH
          where
            (chX, chW)
              | widgetReqW >= cellW = (cellX, cellW)
              | align == ColumnAlignLeft = (cellX, widgetReqW)
              | otherwise = (cellX + cellW - widgetReqW, widgetReqW)
            (chY, chH) =
              (cellY, cellH)

            cellX = l + S.index colXs col + paddingW
            cellY = t + S.index rowYs row + paddingH
            cellW = S.index colXs (col + 1) - S.index colXs col - paddingW * 2
            cellH = S.index rowYs (row + 1) - S.index rowYs row - paddingH * 2

            widgetReqW =
              widget
                & _wnInfo
                & _wniSizeReqW
                & \r -> _szrFixed r + _szrFlex r

    render wenv node renderer = do
      forM_ (neighbours rowYs) $ \(y1, y2, even) -> do
        let color
              | mouseover && _pY mouse >= (t + y1) && _pY mouse < (t + y2) = Just mouseOverColor
              | not even = Just oddRowBgColor
              | otherwise = Nothing
        drawRect renderer (Rect l (t + y1) lastColX (y2 - y1)) color Nothing

      forM_ (S.drop 1 colXs) $ \colX -> do
        drawLine renderer (Point (l + colX) t) (Point (l + colX) (t + lastRowY)) 1 (Just lineColor)

      forM_ (S.drop 1 rowYs) $ \rowY -> do
        drawLine renderer (Point l (t + rowY)) (Point (l + lastColX) (t + rowY)) 1 (Just lineColor)
      where
        colXs = sizesToPositions (S.fromList (fromIntegral . currentWidth <$> model.columns))
        rowYs = sizesToPositions (toRowHeights (node ^. L.children) columnDefsSeq)
        lastColX
          | _ :|> a <- colXs = a
          | otherwise = 0
        lastRowY
          | _ :|> a <- rowYs = a
          | otherwise = 0
        vp = node ^. L.info . L.viewport
        Rect l t _w _h = vp
        mouseover = pointInRect mouse vp
        mouse = wenv ^. L.inputStatus . L.mousePos
        mouseOverColor = (accentColor wenv) {_colorA = 0.3}
        oddRowBgColor = (accentColor wenv) {_colorA = 0.1}
        lineColor = accentColor wenv

    handleEvent _wenv node _path = \case
      Move (Point _pX _pY) ->
        -- refresh which row shows as hovered
        Just (resultReqs node [RenderOnce])
      _ -> Nothing

    handleMessage :: ContainerMessageHandler (HagridModel a) (HagridEvent ep)
    handleMessage wenv node _path msg = result
      where
        result = cast msg >>= handleTypedMessage

        handleTypedMessage (ContentPaneScrollToRow callback) = result
          where
            result
              | Just row <- callback indexedItems,
                Just y1 <- S.lookup row rowYs,
                Just y2 <- S.lookup (row + 1) rowYs =
                  Just (resultReqs node [SendMessage scrollId (ScrollTo (Rect vp._rX (vp._rY + y1) 1 (y2 - y1)))])
              | otherwise =
                  Nothing

            indexedItems =
              model.sortedItems
                & indexed
                & List.sortOn (snd . snd)
                & map (\(sortedIndex, (item, _originalIndex)) -> (item, sortedIndex))

            vp = node ^. L.info . L.viewport
            rowYs = sizesToPositions (toRowHeights (node ^. L.children) columnDefsSeq)

            scrollId = fromJust (widgetIdFromKey wenv (WidgetKey contentScrollKey))

initialModel :: [HagridCfg s e] -> [Column ep a] -> [a] -> HagridModel a
initialModel cfg columnDefs items = model
  where
    model =
      HagridModel
        { sortedItems = sortItems columnDefs sortColumn (zip items [0 ..]),
          columns = initialColumn <$> columnDefs,
          sortColumn
        }

    sortColumn
      | Just (col, dir) <- (mconcat cfg).cfgInitialSort,
        col >= 0,
        col < length columnDefs =
          Just (col, dir)
      | otherwise = Nothing

    initialColumn Column {name, initialWidth} =
      ModelColumn
        { name,
          currentWidth = initialWidth
        }

dragHandleWidth :: Int
dragHandleWidth = 4

dragHandleHeight :: Double
dragHandleHeight = 40

headerPaneKey :: Text
headerPaneKey = "Hagrid.headerPane"

contentScrollKey :: Text
contentScrollKey = "Hagrid.contentScroll"

contentPaneKey :: Text
contentPaneKey = "Hagrid.contentPane"

footerPaneKey :: Text
footerPaneKey = "Hagrid.footerPane"

sortItems :: [Column ep a] -> Maybe (Int, SortDirection) -> [(a, Int)] -> [(a, Int)]
sortItems columnDefs sortColumn items =
  case modelSortKey columnDefs sortColumn of
    DontSort -> items
    SortWith f -> List.sortOn (f . fst) items

modelSortKey :: [Column ep a] -> Maybe (Int, SortDirection) -> ColumnSortKey a
modelSortKey columnDefs sortColumn = case sortColumn of
  Just (sc, dir)
    | Column {sortKey = SortWith f} <- columnDefs !! sc ->
        case dir of
          SortAscending -> SortWith f
          SortDescending -> SortWith (Down . f)
  _ ->
    DontSort

sizesToPositions :: Seq Double -> Seq Double
sizesToPositions = S.scanl (+) 0

toRowHeights :: Seq (WidgetNode s e1) -> Seq (Column e2 a) -> Seq Double
toRowHeights children columnDefs = mergeHeights <$> S.chunksOf (length columnDefs) children
  where
    mergeHeights rowWidgets =
      foldl' max 0 (S.zipWith widgetHeight columnDefs rowWidgets)

    widgetHeight Column {paddingH} widget =
      widget
        & _wnInfo
        & _wniSizeReqH
        & \r -> _szrFixed r + _szrFlex r + paddingH * 2

neighbours :: Seq a -> Seq (a, a, Bool)
neighbours = \case
  a :<| b :<| c :<| rest -> (a, b, False) :<| (b, c, True) :<| neighbours (c :<| rest)
  a :<| b :<| S.Empty -> S.singleton (a, b, False)
  _ -> S.empty

-- | Creates a column that displays a text value, and is sortable by the text.
textColumn ::
  -- | Name of the column, to display in the header.
  Text ->
  -- | Called with the item for each row to get the text to display for that row.
  (a -> Text) ->
  Column e a
textColumn name get = (defaultColumn name widget) {sortKey}
  where
    widget = LabelWidget (const get)
    sortKey = SortWith get

-- | Creates a column that displays the result of calling @'show'@ on a value, and is sortable by the value.
showOrdColumn ::
  (Show b, Ord b) =>
  -- | Name of the column, to display in the header.
  Text ->
  -- | Called with the item for each row to get the value to display (via @'show'@) and sort by.
  (a -> b) ->
  Column e a
showOrdColumn name get = (defaultColumn name widget) {sortKey}
  where
    widget = LabelWidget (const (T.pack . show . get))
    sortKey = SortWith get

-- | Creates a column that displays the a custom widget in each cell.
widgetColumn ::
  -- | Name of the column, to display in the header.
  Text ->
  -- | Called with the (original, not sorted) index and the item for each row to get the widget to
  -- display for that row.
  (forall s. Int -> a -> WidgetNode s e) ->
  Column e a
widgetColumn name get = defaultColumn name (CustomWidget get)

defaultColumn :: Text -> ColumnWidget e a -> Column e a
defaultColumn name widget =
  Column
    { name,
      widget,
      footerWidget = NoFooterWidget,
      align = ColumnAlignLeft,
      initialWidth = defaultColumnInitialWidth,
      sortKey = DontSort,
      minWidth = defaultColumnMinWidth,
      paddingW = defaultColumnPadding,
      paddingH = defaultColumnPadding,
      resizeHandler = Nothing,
      sortHandler = Nothing
    }

cellWidget :: (CompositeModel a, WidgetEvent e, WidgetModel s) => Int -> a -> ColumnWidget e a -> WidgetNode (HagridModel s) (HagridEvent e)
cellWidget idx item = \case
  LabelWidget get -> label_ (get idx item) [ellipsis]
  CustomWidget get -> widget
    where
      widget =
        compositeD_ "Hagrid.Cell" (WidgetValue item) buildUI handleEvent []
      buildUI _wenv model =
        get idx model
      handleEvent _wenv _node _model e =
        [Report (ParentEvent e)]

footerWidgetNode ::
  (CompositeModel a, CompositeModel s, Typeable e) =>
  [(a, Int)] ->
  ColumnFooterWidget e a ->
  WidgetNode (HagridModel s) (HagridEvent e)
footerWidgetNode items = \case
  NoFooterWidget -> spacer
  CustomFooterWidget get -> widget
    where
      widget =
        compositeD_ "Hagrid.FooterCell" (WidgetValue items) buildUI handleEvent []
      buildUI _wenv _model =
        get items
      handleEvent _wenv _node _model e =
        [Report (ParentEvent e)]

-- | Sends a message to the targeted 'hagrid' widget, that causes the
-- widget to scroll such that a specified row becomes visible.
scrollToRow ::
  forall s e sp ep a.
  (Typeable a, Typeable e) =>
  -- | The widget to target.
  WidgetKey ->
  -- | Determines which row to scroll to.
  ScrollToRowCallback a ->
  EventResponse s e sp ep
scrollToRow key row =
  Message key (ScrollToRow row :: HagridEvent e)

defaultColumnInitialWidth :: Int
defaultColumnInitialWidth = 100

defaultColumnMinWidth :: Int
defaultColumnMinWidth = 60

defaultColumnPadding :: Double
defaultColumnPadding = 10

flipSortDirection :: SortDirection -> SortDirection
flipSortDirection SortAscending = SortDescending
flipSortDirection SortDescending = SortAscending