nano-ui-0.1.0.0: lib/NanoUI/Widgets/SplitPane.hs
{-# LANGUAGE LambdaCase #-}
-- | Pure pane-grid tree model and geometry, modelled on iced's @PaneGrid@.
--
-- A 'GridNode' is a binary split tree of panes. Each split stores an axis
-- ('AxisV' = vertical divider splitting width, 'AxisH' = horizontal divider
-- splitting height), a ratio in @[0,1]@ for the first (A) side, and the two
-- child subtrees. Every pane and split has a globally unique 'Word64' id so
-- pane state can be keyed by pane id regardless of position in the tree.
--
-- All functions here are pure; the interactive wrapper in
-- "NanoUI.Widgets.PaneGrid" persists a 'GridNode' as a "Data.Dynamic" value
-- in the widget store.
module NanoUI.Widgets.SplitPane
( GridAxis (..)
, GridNode (..)
, PaneDrop (..)
, treePanes
, treeSize
, paneExist
, subtreeMin
, mainMins
, mainLen
, splitLength
, layoutNode
, DividerInfo (..)
, treeSplit
, treeSetRatio
, treeRemovePane
, treeMovePane
, clampTreeRatio
, dropPreview
, dropTargetForPane
, topLevelDropTarget
) where
import Control.Applicative ((<|>))
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Word (Word64)
import NanoUI.Types (Rect (..), V2 (..), clamp, clamp01, rectH, rectNonEmpty, rectW, rectX, rectY)
-- | Divider orientation. 'AxisV' draws a vertical divider (panes left/right),
-- 'AxisH' draws a horizontal divider (panes stacked top/bottom).
data GridAxis = AxisV | AxisH
deriving (Eq, Ord, Show, Enum, Bounded)
-- | Binary split tree node. Pane and split ids share one monotonic counter.
-- Positional (non-record) so the multi-constructor type keeps total fields.
data GridNode
= Split
!Word64
-- ^ Split id.
!GridAxis
-- ^ Orientation of the divider.
!Float
-- ^ Ratio in @[0,1]@ for the A side.
!GridNode
-- ^ Left / top subtree.
!GridNode
-- ^ Right / bottom subtree.
| Pane
!Word64
-- ^ Pane id.
deriving (Eq, Show)
-- | Result of dropping a dragged pane on a target pane.
data PaneDrop
= DropSwap Word64
-- ^ Drop on the center of the pane: the two panes swap places.
| DropSplit Word64 GridAxis Bool
-- ^ Drop near an edge: the target pane splits along the axis and the
-- dragged pane moves into the new child. 'True' puts the dragged pane on
-- the A (left/top) side, 'False' on the B (right/bottom) side.
| DropTop GridAxis Bool
-- ^ Drop on the outer edge of the whole grid: the entire tree is wrapped
-- in a new top-level split and the dragged pane takes one side, so the
-- rest of the grid collapses onto the other. 'True' puts the dragged
-- pane on the A (left/top) side, 'False' on the B (right/bottom) side.
deriving (Eq, Show)
-- | Fold a tree bottom-up: @onPane@ for each pane id, @onSplit@ for each
-- split (id, axis, ratio) with its already-folded A and B sides. The sides
-- are passed lazily, so a short-circuiting @onSplit@ stops early.
foldGrid :: (Word64 -> r) -> (Word64 -> GridAxis -> Float -> r -> r -> r) -> GridNode -> r
foldGrid onPane onSplit = go
where
go (Pane pid) = onPane pid
go (Split sid axis ratio a b) = onSplit sid axis ratio (go a) (go b)
-- | Pane ids in the tree (depth-first, A then B).
treePanes :: GridNode -> [Word64]
treePanes = foldGrid pure (\_ _ _ a b -> a <> b)
-- | Number of panes.
treeSize :: GridNode -> Int
treeSize = foldGrid (const 1) (\_ _ _ a b -> a + b)
-- | Does a pane with the given id exist?
paneExist :: GridNode -> Word64 -> Bool
paneExist t p = foldGrid (== p) (\_ _ _ a b -> a || b) t
-- | Minimum (width, height) that must be reserved for a subtree under a
-- 'minSize' per-pane floor and 'spacing' between every split level.
subtreeMin :: Float -> Float -> GridNode -> (Float, Float)
subtreeMin minSize spacing = \case
Pane _ -> (minSize, minSize)
Split _ axis _ a b ->
let (wa, ha) = subtreeMin minSize spacing a
(wb, hb) = subtreeMin minSize spacing b
in case axis of
AxisV -> (wa + spacing + wb, max ha hb)
AxisH -> (max wa wb, ha + spacing + hb)
-- | Extent of a region along a split's main axis.
mainLen :: GridAxis -> Rect -> Float
mainLen AxisV = rectW
mainLen AxisH = rectH
-- | The subtree minima that apply along a split's main axis: widths for
-- 'AxisV' (panes left/right), heights for 'AxisH' (panes stacked).
mainMins :: GridAxis -> (Float, Float) -> (Float, Float) -> (Float, Float)
mainMins AxisV (wa, _) (wb, _) = (wa, wb)
mainMins AxisH (_, ha) (_, hb) = (ha, hb)
-- | A-side extent for a split along its main axis, honouring the subtree
-- minima. The ratio shares out the extent left after the gutter between the
-- sides, so a 0.5 split gives both sides the same length. Falls back to the
-- raw share when the region is too small to satisfy both minima.
splitLength :: Float -> Float -> Float -> Float -> Float -> Float
splitLength spacing avail minA minB ratio
| avail <= 0 = 0
| lo <= hi = clamp lo hi share
| otherwise = clamp 0 avail share
where
share = ratio * max 0 (avail - spacing)
lo = minA
hi = avail - spacing - minB
-- | Carve a region at offset @d@ along the main axis into (A, B, divider band).
splitBounds :: GridAxis -> Float -> Rect -> Float -> (Rect, Rect, Rect)
splitBounds AxisV spacing r d =
let avail = rectW r
in ( r {rectW = d}
, r {rectX = rectX r + d + spacing, rectW = avail - d - spacing}
, Rect (rectX r + d) (rectY r) spacing (rectH r)
)
splitBounds AxisH spacing r d =
let avail = rectH r
in ( r {rectH = d}
, r {rectY = rectY r + d + spacing, rectH = avail - d - spacing}
, Rect (rectX r) (rectY r + d) (rectW r) spacing
)
-- | Per-split divider information: the split's own region (where the ratio
-- applies), the exact spacing band, and the axis / ratio / id.
data DividerInfo = DividerInfo
{ diSplitId :: {-# UNPACK #-} !Word64
, diAxis :: !GridAxis
, diRegion :: !Rect
, diBand :: !Rect
, diRatio :: {-# UNPACK #-} !Float
}
deriving (Eq, Show)
-- | Lay out a tree into per-pane regions and divider bands within 'Rect'.
-- Dividers are reported parent-before-child so dragging a divider resizes its
-- immediate subtrees relative to the same region.
layoutNode :: Float -> Float -> GridNode -> Rect -> (Map Word64 Rect, [DividerInfo])
layoutNode minSize spacing sp r =
case sp of
Pane pid -> (M.singleton pid r, [])
Split sid axis ratio0 a b ->
let (wa, ha) = subtreeMin minSize spacing a
(wb, hb) = subtreeMin minSize spacing b
(mA, mB) = mainMins axis (wa, ha) (wb, hb)
(rA, rB, band) = splitBounds axis spacing r (splitLength spacing (mainLen axis r) mA mB ratio0)
self = DividerInfo sid axis r band ratio0
(regionsA, divsA) = layoutNode minSize spacing a rA
(regionsB, divsB) = layoutNode minSize spacing b rB
in (M.union regionsA regionsB, self : divsA <> divsB)
-- | Split the pane (first arg) along the axis with a 0.5 ratio, inserting the
-- new pane. 'newOnA' places the new pane on the A (left/top) side of the new
-- split; 'False' puts it on the B (right/bottom) side. Returns the updated
-- tree (unchanged if the pane does not exist).
treeSplit :: Word64 -> Word64 -> GridAxis -> Bool -> Word64 -> GridNode -> GridNode
treeSplit targetPaneId splitId axis newOnA newPaneId = foldGrid onPane Split
where
onPane p
| p /= targetPaneId = Pane p
| newOnA = Split splitId axis 0.5 (Pane newPaneId) (Pane p)
| otherwise = Split splitId axis 0.5 (Pane p) (Pane newPaneId)
-- | Set the raw ratio of a split (clamped to @[0,1]@).
treeSetRatio :: Word64 -> Float -> GridNode -> GridNode
treeSetRatio splitId r =
foldGrid Pane (\sid ax r0 -> Split sid ax (if sid == splitId then clamp01 r else r0))
-- | Remove a pane. The sibling subtree absorbs its space. @Nothing@ if the
-- pane does not exist or removing it would empty the tree.
treeRemovePane :: Word64 -> GridNode -> Maybe GridNode
treeRemovePane pid = foldGrid onPane onSplit
where
onPane p = if p == pid then Nothing else Just (Pane p)
onSplit sid ax r0 ma mb = case (ma, mb) of
(Just a, Just b) -> Just (Split sid ax r0 a b)
(Nothing, b) -> b
(a, Nothing) -> a
-- | Swap two panes by id (content follows the pane id).
treeSwapPanes :: Word64 -> Word64 -> GridNode -> GridNode
treeSwapPanes a b = foldGrid (\p -> Pane (if p == a then b else if p == b then a else p)) Split
-- | Move a pane onto a drop target. Center drops swap the two panes; edge
-- drops split the target pane with the given fresh split id and move the
-- dragged pane into the new child; top-level drops wrap the whole tree in a
-- new root split with the dragged pane on one side.
treeMovePane :: Word64 -> Word64 -> PaneDrop -> GridNode -> Maybe GridNode
treeMovePane moved splitId dt tree
| not (paneExist tree moved) = Nothing
| otherwise =
case dt of
DropSwap tgt
| tgt == moved -> Nothing
| not (paneExist tree tgt) -> Nothing
| otherwise -> Just (treeSwapPanes moved tgt tree)
DropSplit tgt axis onA
| tgt == moved -> Nothing
| not (paneExist tree tgt) -> Nothing
| otherwise -> do
t' <- treeRemovePane moved tree
Just (treeSplit tgt splitId axis onA moved t')
DropTop axis onA
| treeSize tree <= 1 -> Nothing
| otherwise -> do
t' <- treeRemovePane moved tree
Just
( if onA
then Split splitId axis 0.5 (Pane moved) t'
else Split splitId axis 0.5 t' (Pane moved)
)
-- | Find the split node with a given id (or 'Nothing').
findSplitNode :: GridNode -> Word64 -> Maybe GridNode
findSplitNode (Pane _) _ = Nothing
findSplitNode s@(Split sid0 _ _ a b) target
| sid0 == target = Just s
| otherwise = findSplitNode a target <|> findSplitNode b target
-- | Clamp a proposed ratio for a split so both subtrees keep at least their
-- minimum size within the given region.
clampTreeRatio :: GridNode -> Word64 -> Rect -> Float -> Float -> Float -> Float
clampTreeRatio tree splitId region spacing minSize r0 =
case findSplitNode tree splitId of
Nothing -> r0
Just (Pane _) -> r0
Just (Split _ ax _ a b) ->
let avail = mainLen ax region
usable = avail - spacing
in if usable <= 0
then r0
else
let (wa, ha) = subtreeMin minSize spacing a
(wb, hb) = subtreeMin minSize spacing b
(mA, mB) = mainMins ax (wa, ha) (wb, hb)
in splitLength spacing avail mA mB r0 / usable
-- | Which drop zone a pointer falls into for a target pane rect.
data EdgeZone = ZoneCenter | ZoneLeft | ZoneRight | ZoneTop | ZoneBottom
-- | Classify a drop point into a zone of the target pane.
edgeZone :: Rect -> V2 -> EdgeZone
edgeZone r (V2 mx my)
| not (rectNonEmpty r) = ZoneCenter
| tx < 0.25 = ZoneLeft
| tx > 0.75 = ZoneRight
| ty < 0.25 = ZoneTop
| ty > 0.75 = ZoneBottom
| otherwise = ZoneCenter
where
tx = (mx - rectX r) / rectW r
ty = (my - rectY r) / rectH r
-- | Classify a drop point on a target pane into the 'PaneDrop' the drop
-- performs: the pane's center swaps the two panes, an edge zone splits the
-- target along that edge's axis with the dragged pane on the near side.
dropTargetForPane :: Rect -> V2 -> Word64 -> PaneDrop
dropTargetForPane r mouse tgt =
case edgeZone r mouse of
ZoneCenter -> DropSwap tgt
ZoneLeft -> DropSplit tgt AxisV True
ZoneRight -> DropSplit tgt AxisV False
ZoneTop -> DropSplit tgt AxisH True
ZoneBottom -> DropSplit tgt AxisH False
-- | Classify a drop point against the grid's outer boundary. If the pointer
-- sits within @band@ px of a grid edge, return the 'DropTop' target for that
-- edge; otherwise 'Nothing'. Checked before pane-level drops so the outermost
-- edge always restructures the whole grid.
topLevelDropTarget :: Float -> Rect -> V2 -> Maybe PaneDrop
topLevelDropTarget band r@(Rect l t w h) (V2 x y)
| not (rectNonEmpty r) = Nothing
| x <= l + band = Just (DropTop AxisV True)
| x >= l + w - band = Just (DropTop AxisV False)
| y <= t + band = Just (DropTop AxisH True)
| y >= t + h - band = Just (DropTop AxisH False)
| otherwise = Nothing
-- | Drop preview for a drop target: the rect to highlight and the
-- 'PaneDrop' the drop performs. The highlight is found by simulating the
-- drop ('treeMovePane' with a throwaway split id) and laying the resulting
-- tree out ('layoutNode') into the grid rect, so it is exactly the region the
-- dragged pane will occupy after the drop, accounting for the restructuring
-- that removing the pane causes (its parent split collapses and sibling
-- subtrees expand) and for 'spacing' and min-size floors. Estimating the rect
-- from the target's pre-drop bounds goes wrong wherever mixed 'AxisV' /
-- 'AxisH' splits make those two layouts diverge. @spacing@ must be the gutter
-- actually laid out between panes: 'NanoUI.Widgets.PaneGrid' passes
-- @pgSpacing + 2 * pgLeeway@, not @pgSpacing@, or the preview regions drift
-- from the on-screen layout. 'Nothing' when the drop cannot be performed
-- (unknown pane ids, 'DropTop' on a single-pane grid).
dropPreview :: Float -> Float -> GridNode -> Word64 -> Rect -> PaneDrop -> Maybe (Rect, PaneDrop)
dropPreview minSize spacing tree moved baseRect dt = do
t' <- treeMovePane moved 0 dt tree
let (regions, _) = layoutNode minSize spacing t' baseRect
r <- M.lookup moved regions
pure (r, dt)