nano-ui-0.1.0.0: lib/NanoUI/Widgets/PaneGrid.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Interactive pane grid with resizable dividers, modelled on iced's
-- @PaneGrid@.
--
-- The grid is a binary split tree ('NanoUI.Widgets.SplitPane.GridNode')
-- persisted per widget as a "Data.Dynamic" value in the widget store.
--
-- Panes are rendered through the user-provided 'pgViewPane', which receives a
-- 'PaneGridCtx' with immediate-mode actions to split, close, maximize, or
-- restore the pane. Dividers can be dragged to resize; panes can be grabbed by
-- their pick rect and dropped onto another pane (center = swap, edge = split)
-- or onto the grid's outer edge to restructure the whole grid at top level;
-- arrow keys navigate between panes; @m@/@x@ maximize/close and @Escape@
-- restores while the grid is focused.
module NanoUI.Widgets.PaneGrid
( GridAxis (..)
, PaneGridConfig (..)
, defaultPaneGridConfig
, PaneGridCtx (..)
, PaneView (..)
, PaneGridResponse (..)
, paneGrid
) where
import Control.Monad (forM_, unless, void, when)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Dynamic (fromDynamic, toDyn)
import Data.Hashable (hash)
import Data.IntMap.Strict qualified as IM
import Data.List (find, minimumBy)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust, listToMaybe)
import Data.Ord (comparing)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Primitive.SmallArray (SmallArray)
import Data.Word (Word64)
import Effectful (Eff, type (:>))
import NanoUI.Context
( Context (..)
, bumpMirror
, damageWidget
, getFocusId
, getFocusVisible
, getPrevRect
, getStore
, intKey
, markDirty
, markEscapeConsumed
, getMenuPointerGesture
, overlayConsumesQuit
, registerCustomDrawing
, registerFocusable
, setStore
, modifyStore
)
import NanoUI.Draw (DrawOp)
import NanoUI.Input
( Input (..)
, Key (..)
, UiCursorKind (..)
, inputChars
, inputKeys
, inputKeysElem
, inputMouseDown
, inputMousePos
, inputMousePressed
)
import NanoUI.Monad (Ui, askContext, askInput, nextId, uiIO, withIdFrame, withKey)
import NanoUI.Id (IdContext (..), WidgetId, hashWidgetId)
import NanoUI.Frame.Hit (nodeInteractionHit, scrollHitRect)
import NanoUI.Frame.Input (isInteractiveNode)
import NanoUI.Store
( WidgetStore (..)
, slotKey
, Slot (..)
)
import NanoUI.Style
( AlignX (..)
, AlignY (..)
, Direction (..)
, Layout (..)
, Padding (..)
, Sizing (..)
, Style (..)
, Theme (..)
, defaultLayout
, fadeAlpha
, separatorTrackColor
)
import NanoUI.Types
( DamageBounds (..)
, Rect (..)
, V2 (..)
, lerpColor
, rectHit
, rectH
, rectInflate
, rectNonEmpty
, rectW
, rectX
, rectY
, v2X
, v2Y
)
import NanoUI.Widgets.Behavior (KeyNav (..), dragThresholdPx, useKeyNav)
import NanoUI.Widgets.Custom
( CustomWidgetSpec (..)
, CustomDrawContext (..)
, contentKey
, defaultCustomWidgetSpec
, customWidget
, drawRect
, drawRoundedRect
, drawStroke
, drawStrokeRoundedRect
, drawText
, runCanvas
)
import NanoUI.Widgets.Layout (column', row')
import NanoUI.Layout.Arena (NodeType (..), arenaCount, getNodeType, getWidgetId)
import NanoUI.Widgets.Node
( container
, containerResponse
, tagContainer
)
import NanoUI.Widgets.SplitPane
( DividerInfo (..)
, GridAxis (..)
, GridNode (..)
, clampTreeRatio
, dropPreview
, dropTargetForPane
, layoutNode
, mainLen
, mainMins
, PaneDrop (..)
, paneExist
, splitLength
, subtreeMin
, topLevelDropTarget
, treeMovePane
, treePanes
, treeRemovePane
, treeSetRatio
, treeSize
, treeSplit
)
-- -----------------------------------------------------------------------------
-- Public API
-- -----------------------------------------------------------------------------
-- | Configuration for a pane grid. 'pgViewPane' can run arbitrary widget code,
-- so the config carries the caller's effect row.
data PaneGridConfig es = PaneGridConfig
{ pgLayout :: !(Layout -> Layout)
-- ^ Layout modifier for the grid container (default 'id'); pass
-- @fillW . fillH@ to fill the parent area.
, pgSpacing :: !Float
-- ^ Gutter between panes per split level (default 4).
, pgMinSize :: !Float
-- ^ Minimum physical size any pane may shrink to (default 40).
, pgLeeway :: !Float
-- ^ Extra grab margin on each side of a divider, added to 'pgSpacing' to
-- form the divider's real layout gutter. The resize cursor and grab work
-- anywhere in that gutter while only 'pgSpacing' is drawn crisp, so the
-- interaction space is far wider than the visible line (default 6).
, pgEdgeBand :: !Float
-- ^ Thickness of the grid's outer edge that acts as a top-level drop zone
-- (default 20). Dragging a pane into this band restructures the whole grid
-- instead of a single pane: the tree is wrapped in a new top-level split
-- with the dragged pane on that side.
, pgViewPane :: !(Word64 -> PaneGridCtx es -> Eff es PaneView)
-- ^ Renders the content of one pane.
}
defaultPaneGridConfig :: PaneGridConfig es
defaultPaneGridConfig =
PaneGridConfig
{ pgLayout = id
, pgSpacing = 4
, pgMinSize = 40
, pgLeeway = 6
, pgEdgeBand = 20
, pgViewPane = \_ _ -> pure (PaneView "" False Nothing)
}
-- | Actions handed to a pane so it can mutate the grid immediately.
data PaneGridCtx es = PaneGridCtx
{ pgcPaneId :: !Word64
, pgcRect :: !Rect
-- ^ Prev-frame screen rect of this pane (zero until it has been laid
-- out once; the whole grid rect while maximized). Use it to build
-- 'pvDragPick' handles such as a title-bar sub-rect.
, pgcMaximized :: !Bool
-- ^ True when this pane currently fills the whole grid.
, pgcDragging :: !Bool
-- ^ True while this pane's drag is armed. Once the drag threshold is
-- crossed, the pane is omitted from the visible layout until release.
, pgcDndActive :: !Bool
-- ^ True while any pane drag-and-drop gesture is in progress.
, pgcSplit :: !(GridAxis -> Eff es Word64)
-- ^ Split this pane along the axis; returns the new pane id.
, pgcClose :: !(Eff es ())
, pgcMaximize :: !(Eff es ())
, pgcRestore :: !(Eff es ())
}
-- | What a pane renders to this frame. The pane's content (including any title
-- bar / header) is drawn entirely by the caller in 'pgViewPane'; a header is
-- purely optional and nothing here depends on one existing.
data PaneView = PaneView
{ pvTitle :: !Text
-- ^ Label shown (abbreviated to fit) on the compact drag indicator.
, pvDraggable :: !Bool
-- ^ Grab the pane anywhere inside its own region to drag-and-drop it. This
-- is the easy way to reorder panes without drawing a dedicated handle.
-- Interactive children keep their pointer presses. Pane still needs a
-- drag only on a sub-region? see 'pvDragPick'.
, pvDragPick :: !(Maybe Rect)
-- ^ Optional absolute sub-region (e.g. just a title bar; position it via
-- 'pgcRect') that also starts a drag. Both handles combine: the pane drags
-- if the press lands in this rect or (when 'pvDraggable') anywhere in the
-- pane. 'Nothing' here and 'pvDraggable' 'False' makes the pane immovable.
}
deriving (Eq, Show)
-- | Outcome of one frame of the grid. The pane list, focus, and maximize
-- fields report the state after this pass: actions run by pane content
-- ('pgcSplit', 'pgcClose', ...) and the keyboard handling below take effect
-- in these values and from the next frame's layout onward.
data PaneGridResponse = PaneGridResponse
{ pgrChanged :: !Bool
-- ^ Any structural or maximize change happened this frame.
, pgrPaneCount :: !Int
-- ^ Number of panes (0 once the last pane has been closed).
, pgrPanes :: ![Word64]
-- ^ Live pane ids, depth-first.
, pgrFocusedPane :: !Word64
-- ^ Focused pane id, 0 when the grid has no panes.
, pgrMaximizedPane :: !Word64
-- ^ Maximized pane id, 0 when none.
}
deriving (Eq, Show)
-- -----------------------------------------------------------------------------
-- Internal state
-- -----------------------------------------------------------------------------
data RenderedPane = RenderedPane
{ rpPaneId :: !Word64
, rpView :: !PaneView
, rpControlHit :: !Bool
}
-- | Per-frame shared environment.
data GridEnv es = GridEnv
{ geCtx :: !Context
, geKey :: !Int
, gePaneScope :: !IdContext
-- ^ Pane identity is rooted at the grid widget, independent of split
-- ancestry so rearranging or temporarily collapsing splits preserves state.
, geCfg :: !(PaneGridConfig es)
, geGutter :: !Float
, geThickness :: !Float
, geMinSize :: !Float
, geLeeway :: !Float
, geRegions :: !(Map Word64 Rect)
-- ^ Prev-frame pane regions; drives hit testing and 'pgcRect'.
, geBaseRect :: !Rect
-- ^ Prev-frame rect of the grid's root container.
, geTree :: !GridNode
, geSeed :: !Word64
-- ^ Next fresh split / pane id ('SlotPaneNext'); strictly monotonic per
-- grid, so ids are never reused and state keyed by pane id cannot
-- collide with a closed pane's state.
, geDrag0 :: !Int
, geMax :: !Word64
, geChangedRef :: !(IORef Bool)
, geMakeCtx :: Word64 -> Rect -> Bool -> PaneGridCtx es
}
-- | Computed drag-and-drop interaction state for one frame.
data DragInfo = DragInfo
{ dgiActive :: !Bool
, dgiMoved :: !Bool
, dgiGhost :: !(Maybe Rect)
, dgiZone :: !(Maybe (Rect, PaneDrop))
}
-- -----------------------------------------------------------------------------
-- Tree + focus state
-- -----------------------------------------------------------------------------
-- | The grid's split tree persisted in the widget store, if seeded.
lookupTree :: Int -> WidgetStore -> Maybe GridNode
lookupTree k st = IM.lookup k (storeDyn st) >>= fromDynamic
-- | A stored pane id that still exists in the tree, else 0.
validPane :: GridNode -> Int -> Word64
validPane t n =
let p = fromIntegral n
in if paneExist t p then p else 0
-- | Focused pane: a maximized pane wins, then the stored focus if the pane
-- still exists, then the first pane in the tree.
resolveFocus :: GridNode -> Word64 -> Word64 -> Word64
resolveFocus tree maxPane focus0
| maxPane /= 0 = maxPane
| paneExist tree focus0 = focus0
| otherwise = fromMaybe 1 (listToMaybe (treePanes tree))
-- -----------------------------------------------------------------------------
-- Entry point
-- -----------------------------------------------------------------------------
paneGrid :: (Ui :> es) => PaneGridConfig es -> Eff es PaneGridResponse
paneGrid cfg = do
wid <- nextId
ctx <- askContext
inp <- askInput
uiIO (registerFocusable ctx wid)
let key = intKey wid
gestK = slotKey SlotPaneGest key
grabK = slotKey SlotPaneGrab key
focusK = slotKey SlotPaneFocus key
maxK = slotKey SlotPaneMax key
seedK = slotKey SlotPaneNext key
spacing = max 0 (pgSpacing cfg)
minSize = max 0 (pgMinSize cfg)
leeway = max 0 (pgLeeway cfg)
edgeBand = max 0 (pgEdgeBand cfg)
gutter = spacing + 2 * leeway
st <- uiIO (getStore ctx)
(tree0, seed1) <- case lookupTree key st of
Just t ->
-- Init seeded the store before the tree existed, so the stored seed
-- is already above every id in the tree.
pure (t, fromIntegral (IM.findWithDefault 1 seedK (storeInt st)))
Nothing -> do
let seed = max 1 (fromIntegral (IM.findWithDefault 1 seedK (storeInt st)))
start = Pane seed
uiIO $
setStore
ctx
( bumpMirror
( st
{ storeInt = IM.insert seedK (fromIntegral (seed + 1)) (storeInt st)
, storeDyn = IM.insert key (toDyn start) (storeDyn st)
}
)
)
pure (start, seed + 1)
mPrev <- uiIO (getPrevRect ctx wid)
let baseRect = fromMaybe (Rect 0 0 0 0) mPrev
drag0 = IM.findWithDefault 0 gestK (storeInt st)
maxPane = validPane tree0 (IM.findWithDefault 0 maxK (storeInt st))
focus0 = IM.findWithDefault 0 focusK (storeInt st)
focusedInit = resolveFocus tree0 maxPane (fromIntegral focus0)
mouse = inputMousePos inp
(regions, dividers) = layoutNode minSize gutter tree0 baseRect
changedRef <- uiIO (newIORef False)
let mGrab = IM.lookup grabK (storePoint st)
dgi =
computeDragInfo
drag0
(IM.findWithDefault 0 grabK (storeInt st) /= 0)
DragGeom
{ dgMinSize = minSize
, dgGutter = gutter
, dgTree = tree0
, dgBaseRect = baseRect
, dgBand = edgeBand
, dgRegions = regions
}
mGrab
mouse
dgiShown = dgiActive dgi && dgiMoved dgi && inputMouseDown inp
-- Keep the committed tree for cancellation and exact drop previews,
-- but close up the dragged pane's space in the live layout.
visibleTree = if dgiShown then treeRemovePane (fromIntegral drag0) tree0 else Just tree0
(visibleRegions, visibleDividers)
| dgiShown = maybe (M.empty, []) (\t -> layoutNode minSize gutter t baseRect) visibleTree
| otherwise = (regions, dividers)
divMap = M.fromList [(diSplitId d, d) | d <- visibleDividers]
env =
GridEnv
{ geCtx = ctx
, geKey = key
, gePaneScope = IdContext (hashWidgetId wid) 0
, geCfg = cfg
, geGutter = gutter
, geThickness = spacing
, geMinSize = minSize
, geLeeway = leeway
, geRegions = visibleRegions
, geBaseRect = baseRect
, geTree = tree0
, geSeed = seed1
, geDrag0 = drag0
, geMax = maxPane
, geChangedRef = changedRef
, geMakeCtx = \pid rect dragging ->
PaneGridCtx
{ pgcPaneId = pid
, pgcRect = rect
, pgcMaximized = maxPane == pid
, pgcDragging = dragging
, pgcDndActive = dgiMoved dgi
, pgcSplit = \axis -> splitPane env pid axis
, pgcClose = closePane env pid
, pgcMaximize = maximizePane env pid
, pgcRestore = restorePane env
}
}
-- Root container. Tagged so its solved rect resolves via getPrevRect for
-- next frame's geometry.
container NodeContainer (gridRootLayout minSize (pgLayout cfg)) $ do
tagContainer wid
if maxPane /= 0
then void (renderMaxPane env maxPane)
else do
rendered <- maybe (pure []) (renderNode env divMap) visibleTree
runGestures env dividers rendered dgi
when (dgiShown && rectNonEmpty baseRect) $
drawDragOverlay env wid rendered (dgiGhost dgi) (fmap fst (dgiZone dgi))
-- Keyboard focus also rings the focused pane, so the arrow keys show
-- where they moved; the grid's own ring says the grid holds focus.
ringPane <- uiIO ((&&) <$> getFocusVisible ctx <*> ((== wid) <$> getFocusId ctx))
when (ringPane && not dgiShown) $
forM_ (M.lookup focusedInit visibleRegions) $ \r ->
uiIO $ registerCustomDrawing ctx wid (contentKey [1, rectX r, rectY r, rectW r, rectH r]) $ \cdc _ ->
runCanvas (drawStrokeRoundedRect (rectInflate (-2) r) 2 1.5 (themeAccent (cdcTheme cdc)))
-- Keyboard navigation for the focused grid. Escape restores a maximized
-- pane unless something earlier in the pass already consumed it (e.g. a
-- dismissable popup inside a pane); the grid then claims the key so
-- neither a nested overlay nor the app also acts on it.
focusedNow <- uiIO (getFocusId ctx)
when (focusedNow == wid) $ do
nav <- useKeyNav wid
let ch = inputChars inp
cur = focusedInit
when (knLeft nav) $ moveFocus env cur (-1, 0)
when (knRight nav) $ moveFocus env cur (1, 0)
when (knUp nav) $ moveFocus env cur (0, -1)
when (knDown nav) $ moveFocus env cur (0, 1)
when (knLeft nav || knRight nav || knUp nav || knDown nav) $
uiIO (damageWidget ctx wid (DamageInflated 0))
when (T.any (== 'm') ch) $ maximizePane env cur
when (T.any (== 'x') ch) $ closePane env cur
when (inputKeysElem KeyEscape (inputKeys inp)) $ do
taken <- uiIO (overlayConsumesQuit ctx inp)
unless taken $ do
restorePane env
uiIO (markEscapeConsumed ctx)
changed <- uiIO (readIORef changedRef)
stEnd <- uiIO (getStore ctx)
let treeEnd = lookupTree key stEnd
maxEnd = maybe 0 (\t -> validPane t (IM.findWithDefault 0 maxK (storeInt stEnd))) treeEnd
focusEnd =
maybe
0
(\t -> resolveFocus t maxEnd (fromIntegral (IM.findWithDefault 0 focusK (storeInt stEnd))))
treeEnd
pure
PaneGridResponse
{ pgrChanged = changed
, pgrPaneCount = maybe 0 treeSize treeEnd
, pgrPanes = maybe [] treePanes treeEnd
, pgrFocusedPane = focusEnd
, pgrMaximizedPane = maxEnd
}
-- -----------------------------------------------------------------------------
-- Layout helpers
-- -----------------------------------------------------------------------------
gridRootLayout :: Float -> (Layout -> Layout) -> Layout
gridRootLayout minSize f =
f
defaultLayout
{ layoutDirection = Column
, layoutGap = 0
, layoutPadding = Padding 0 0 0 0
, layoutWidth = Grow 1
, layoutHeight = Grow 1
, layoutMinW = minSize
, layoutMinH = minSize
}
sizingLay :: Sizing -> Sizing -> Layout
sizingLay wSiz hSiz =
defaultLayout
{ layoutDirection = Column
, layoutPadding = Padding 0 0 0 0
, layoutGap = 0
, layoutWidth = wSiz
, layoutHeight = hSiz
}
-- | Zero-gap, zero-padding, grow-to-fill layout.
fillLay :: Layout
fillLay = sizingLay (Grow 1) (Grow 1)
-- | A-side sizing for a split: fixed percent along the main axis. The B side
-- grows into the remainder.
splitSideLay :: GridAxis -> Float -> Layout
splitSideLay AxisV p = sizingLay (Percent p) (Grow 1)
splitSideLay AxisH p = sizingLay (Grow 1) (Percent p)
minSized :: Layout -> Float -> Float -> Layout
minSized l minW_ minH_ = l {layoutMinW = minW_, layoutMinH = minH_}
-- | The pane content wrapper: fills its cell, never below one minimum pane.
paneLay :: Float -> Layout
paneLay m = minSized fillLay m m
-- Percent of the main-axis extent for side A, after min clamping.
splitPct :: Float -> Float -> Float -> Float -> Float -> Float
splitPct spacing avail minA minB ratio
| avail <= 0 = 50
| otherwise = splitLength spacing avail minA minB ratio / avail * 100
-- -----------------------------------------------------------------------------
-- Rendering
-- -----------------------------------------------------------------------------
renderMaxPane :: (Ui :> es) => GridEnv es -> Word64 -> Eff es [RenderedPane]
renderMaxPane env pid =
renderPane env pid (geBaseRect env) (paneLay (geMinSize env)) False
-- | Enter a pane's grid-relative identity scope while leaving the split tree's
-- layout scopes intact. Consume one sibling just as 'withKey' does.
withPaneKey :: (Ui :> es) => GridEnv es -> Word64 -> Eff es a -> Eff es a
withPaneKey env pid =
withIdFrame (\parent -> (parent {siblingId = siblingId parent + 1}, gePaneScope env)) . withKey pid
-- | Render one pane's content via 'pgViewPane' under the pane's stable key.
renderPane ::
(Ui :> es) =>
GridEnv es ->
Word64 ->
Rect ->
Layout ->
Bool ->
Eff es [RenderedPane]
renderPane env pid rect lay dragging =
withPaneKey env pid $ do
inp <- askInput
let ctx = geCtx env
arena = ctxNodeArena ctx
start <- uiIO (arenaCount arena)
let ctxt = geMakeCtx env pid rect dragging
(view, _) <- containerResponse NodeContainer lay (pgViewPane (geCfg env) pid ctxt)
-- Press ownership must be checked against previous solved child rects:
-- ctxActiveId is only finalized after this frame's UI has been built.
controlHit <-
if not (inputMousePressed inp)
then pure False
else uiIO $ do
end <- arenaCount arena
let hitFrom idx
| idx >= end = pure False
| otherwise = do
nt <- getNodeType arena idx
hit <-
if isInteractiveNode nt
then do
child <- getWidgetId arena idx
r <- scrollHitRect ctx child
maybe (pure False) (\childRect -> nodeInteractionHit ctx idx childRect (inputMousePos inp)) r
else pure False
if hit then pure True else hitFrom (idx + 1)
hitFrom start
pure [RenderedPane pid view controlHit]
renderNode ::
(Ui :> es) =>
GridEnv es ->
Map Word64 DividerInfo ->
GridNode ->
Eff es [RenderedPane]
renderNode env dividers = \case
Pane pid ->
renderPane env pid (paneRect env pid) (paneLay (geMinSize env)) (draggingPane env pid)
Split sid0 ax _ a b ->
withKey sid0 $ do
let (wa, ha) = subtreeMin (geMinSize env) (geGutter env) a
(wb, hb) = subtreeMin (geMinSize env) (geGutter env) b
mDiv = M.lookup sid0 dividers
avail = maybe 0 (mainLen ax . diRegion) mDiv
(mA, mB) = mainMins ax (wa, ha) (wb, hb)
pct = splitPct (geGutter env) avail mA mB (maybe 0.5 diRatio mDiv)
aLay = minSized (splitSideLay ax pct) wa ha
bLay = minSized fillLay wb hb
inner = do
a' <- container NodeContainer aLay (renderNode env dividers a)
dividerWidget env ax
b' <- container NodeContainer bLay (renderNode env dividers b)
pure (a' <> b')
case ax of
AxisV -> row' fillLay inner
AxisH -> column' fillLay inner
-- | Prev-frame rect of a pane; zero until the pane has been laid out once.
paneRect :: GridEnv es -> Word64 -> Rect
paneRect env pid = fromMaybe (Rect 0 0 0 0) (M.lookup pid (geRegions env))
-- | Is this the pane being drag-and-dropped? A resize gesture (negative id)
-- wraps to a huge 'Word64' and never matches a pane id.
draggingPane :: GridEnv es -> Word64 -> Bool
draggingPane env pid = fromIntegral (geDrag0 env) == pid
-- | The divider: a 'NodeDrawing' spanning the full gutter (visible thickness
-- plus the invisible grab halo on each side). Its widget rect covers the whole
-- gutter, so the resize cursor and grab apply across the halo; the gutter is
-- drawn as a faint rail with the crisp 'geThickness' strip in the middle, so
-- the whole interaction space reads as one divider.
dividerWidget :: (Ui :> es) => GridEnv es -> GridAxis -> Eff es ()
dividerWidget env axis = do
void $
customWidget
defaultCustomWidgetSpec
{ widgetLayout = dLay
, widgetContent = contentKey [if axis == AxisV then 1 else 2, geThickness env, geLeeway env]
, widgetDraw = \cdc rect -> drawDivider cdc rect axis (geThickness env) (geLeeway env)
, widgetCursor = Just (const (if axis == AxisV then UiCursorEwResize else UiCursorNsResize))
}
where
dLay = case axis of
AxisV -> sizingLay (Fixed (geGutter env)) (Grow 1)
AxisH -> sizingLay (Grow 1) (Fixed (geGutter env))
drawDivider :: CustomDrawContext -> Rect -> GridAxis -> Float -> Float -> SmallArray DrawOp
drawDivider cdc rect axis thickness leeway =
runCanvas $ do
let theme = cdcTheme cdc
panel = themePanel theme
rail = lerpColor (styleBg panel) (themeSeparator theme) 0.12
track = separatorTrackColor panel theme
trackRect = case axis of
AxisV -> Rect (rectX rect + leeway) (rectY rect) thickness (rectH rect)
AxisH -> Rect (rectX rect) (rectY rect + leeway) (rectW rect) thickness
drawRect rect rail
drawRect trackRect track
when (cdcHovered cdc || cdcPressed cdc) $ do
-- Full accent while grabbed; a calmer tint while merely hovering.
let line
| cdcPressed cdc = themeAccent theme
| otherwise = lerpColor (themeAccent theme) (styleBg panel) 0.45
case axis of
AxisV ->
let cx = rectX rect + rectW rect / 2
in drawStroke (V2 cx (rectY rect)) (V2 cx (rectY rect + rectH rect)) 2 line
AxisH ->
let cy = rectY rect + rectH rect / 2
in drawStroke (V2 (rectX rect) cy) (V2 (rectX rect + rectW rect) cy) 2 line
-- | Drag ghost + drop-zone highlight, drawn on top of the grid via a custom
-- drawing registered on the grid's root container. Registering on the
-- container (instead of adding a flex sibling) keeps the overlay out of the
-- layout, so it never squeezes the panes and is clipped to the full grid rect.
drawDragOverlay ::
(Ui :> es) =>
GridEnv es ->
WidgetId ->
[RenderedPane] ->
Maybe Rect ->
Maybe Rect ->
Eff es ()
drawDragOverlay env wid rendered ghost zone = do
st <- uiIO (getStore (geCtx env))
let ctx = geCtx env
dragPane = fromIntegral (geDrag0 env)
cached = IM.lookup (slotKey SlotPaneGrab (geKey env)) (storeDyn st) >>= fromDynamic
title = maybe (fromMaybe "" cached) pvTitle (fmap rpView (find ((== dragPane) . rpPaneId) rendered))
rectKey = maybe [0, 0, 0, 0, 0] (\(Rect x y w h) -> [1, x, y, w, h])
key = contentKey (2 : fromIntegral (hash title) : rectKey ghost ++ rectKey zone)
uiIO $
registerCustomDrawing ctx wid key (\cdc _ -> drawOverlay (cdcTheme cdc) title ghost zone)
-- | A compact, translucent drag indicator leaves the full-size drop preview
-- visible. The indicator is offset from the pointer so it cannot obscure aim.
drawOverlay :: Theme -> Text -> Maybe Rect -> Maybe Rect -> SmallArray DrawOp
drawOverlay theme title ghost zone =
runCanvas $ do
let accent = themeAccent theme
win = themeFloatingWindow theme
panelFill = fadeAlpha accent 48
panelBorder = fadeAlpha accent 128
previewFill = fadeAlpha accent 32
shortTitle = if T.length title > 12 then T.take 11 title <> "…" else title
forM_ ghost $ \gr -> do
drawRoundedRect gr 2 panelFill
drawStrokeRoundedRect gr 2 2 panelBorder
when (not (T.null title)) $
drawText (V2 (rectX gr + 6) (rectY gr + 6)) AlignStart AlignTop shortTitle (fadeAlpha (styleFg win) 160)
forM_ zone $ \zr -> do
drawRoundedRect zr 2 previewFill
drawStrokeRoundedRect (rectInflate (-1) zr) 2 2 accent
-- -----------------------------------------------------------------------------
-- Gestures
-- -----------------------------------------------------------------------------
-- | Grid geometry 'computeDragInfo' needs for the current frame.
data DragGeom = DragGeom
{ dgMinSize :: !Float
-- ^ Per-pane size floor used by the preview layout.
, dgGutter :: !Float
-- ^ Layout gutter between panes ('pgSpacing' + 2 * 'pgLeeway').
, dgTree :: !GridNode
-- ^ Current split tree.
, dgBaseRect :: !Rect
-- ^ Prev-frame rect of the grid's root container.
, dgBand :: !Float
-- ^ Thickness of the grid's outer top-level drop band.
, dgRegions :: !(Map Word64 Rect)
-- ^ Prev-frame pane regions.
}
-- | Pure drag-and-drop geometry for the current frame. Geometry is computed
-- for as long as the gesture id is armed (not just while the button is held),
-- so the drop zone is still resolvable on the frame the button is released.
-- 'dgBaseRect' is the grid's own rect: its outer band (thickness 'dgBand') is
-- a top-level drop zone, and the pointer there restructures the whole grid;
-- otherwise the pane under the pointer is the target. Every candidate is
-- resolved through 'dropPreview', which simulates the drop and lays the tree
-- back out with the grid's real 'dgGutter' and 'dgMinSize', so the
-- highlighted rect is the exact region the pane lands in even when removing
-- it reshapes the rest of a mixed-split grid.
computeDragInfo :: Int -> Bool -> DragGeom -> Maybe (Float, Float) -> V2 -> DragInfo
computeDragInfo drag0 latched geom mGrab mouse
| drag0 <= 0 = DragInfo False False Nothing Nothing
| otherwise =
let DragGeom{dgMinSize = minSize, dgGutter = gutter, dgTree = tree, dgBaseRect = baseRect, dgBand = band, dgRegions = regions} = geom
pid = fromIntegral drag0
mFrom = M.lookup pid regions
(gx, gy) = fromMaybe (0, 0) mGrab
moved = latched || case mFrom of
Just (Rect px py _ _) ->
let vx = v2X mouse - (px + gx)
vy = v2Y mouse - (py + gy)
in vx * vx + vy * vy > dragThresholdPx * dragThresholdPx
Nothing -> False
ghost = case mFrom of
Just _
| moved -> Just (Rect (v2X mouse + 12) (v2Y mouse + 12) 112 28)
_ -> Nothing
targetRegions = maybe M.empty (\t -> fst (layoutNode minSize gutter t baseRect)) (treeRemovePane pid tree)
under =
[ (q, r)
| (q, r) <- M.toList targetRegions
, q /= pid
, rectHit r mouse
]
zone = case topLevelDropTarget band baseRect mouse of
Just dt -> dropPreview minSize gutter tree pid baseRect dt
Nothing -> case under of
(q, r) : _ ->
let dt = dropTargetForPane r mouse q
in dropPreview minSize gutter tree pid baseRect dt
[] -> Nothing
in DragInfo True moved ghost zone
-- | Apply resize / drag transitions, writing to the widget store.
runGestures ::
(Ui :> es) =>
GridEnv es ->
[DividerInfo] ->
[RenderedPane] ->
DragInfo ->
Eff es ()
runGestures env dividers rendered dgi = do
ctx <- askContext
inp <- askInput
let regions = geRegions env
mouse = inputMousePos inp
press = inputMousePressed inp
down = inputMouseDown inp
drag0 = geDrag0 env
busy = drag0 /= 0
-- diBand already spans spacing + both leeway margins. Inflating it
-- again steals presses from the neighboring pane, especially headers.
hitDiv =
find
(\d -> rectHit (diBand d) mouse)
dividers
-- The pane whose pick rect (or, when 'pvDraggable', whole region) is
-- under the pointer.
pickHit =
listToMaybe
[ p
| pane <- rendered
, let p = rpPaneId pane
v = rpView pane
, maybe False (`rectHit` mouse) (pvDragPick v)
|| (pvDraggable v && maybe False (`rectHit` mouse) (M.lookup p regions))
]
gestK = slotKey SlotPaneGest (geKey env)
grabK = slotKey SlotPaneGrab (geKey env)
menu <- uiIO (getMenuPointerGesture ctx)
-- A press arms the gesture slot (negative split id for a resize, pane id
-- for a drag) together with its start state in one store write. The resize
-- start keeps the divider's ratio and the pointer's main-axis coordinate so
-- drag frames move the divider by delta instead of snapping it to the
-- pointer; the drag start keeps the title and the grab offset (mouse - pane
-- origin) for the drag threshold.
when (press && not busy && not menu && not (any rpControlHit rendered)) $ do
case hitDiv of
Just d ->
storeWrite env True $ \st -> st
{ storeInt = IM.insert gestK (negate (fromIntegral (diSplitId d))) (storeInt st)
, storePoint = IM.insert (slotKey SlotPaneResize (geKey env)) (diRatio d, mouseMain d mouse) (storePoint st)
}
Nothing ->
forM_ pickHit $ \pid -> do
let title = maybe "" (pvTitle . rpView) (find ((== pid) . rpPaneId) rendered)
(gx, gy) = maybe (0, 0) (\(Rect px py _ _) -> (v2X mouse - px, v2Y mouse - py)) (M.lookup pid regions)
storeWrite env True $ \st -> st
{ storeDyn = IM.insert grabK (toDyn title) (storeDyn st)
, storeInt = IM.insert gestK (fromIntegral pid) (IM.delete grabK (storeInt st))
, storePoint = IM.insert grabK (gx, gy) (storePoint st)
}
when (drag0 < 0 && down) $ do
let sid = fromIntegral (negate drag0)
forM_ (find ((== sid) . diSplitId) dividers) $ \d -> do
st <- uiIO (getStore ctx)
let (ratio0, main0) =
IM.findWithDefault (diRatio d, mouseMain d mouse) (slotKey SlotPaneResize (geKey env)) (storePoint st)
-- The ratio shares out the region minus the divider gutter.
usable = mainLen (diAxis d) (diRegion d) - geGutter env
r0 =
if usable <= 0
then ratio0
else ratio0 + (mouseMain d mouse - main0) / usable
r' = clampTreeRatio (geTree env) sid (diRegion d) (geGutter env) (geMinSize env) r0
in putTree env (Just (treeSetRatio sid r' (geTree env)))
when (drag0 < 0 && not down) $ writeGest env 0
-- Keep the loop at the display cadence while a pane is being dragged: the
-- ghost follows the pointer, and without a dirty flag the debug HUD's slow
-- refresh paces the whole frame (4 fps). Window / scroll / resize drags mark
-- dirty every frame for the same reason.
when (drag0 > 0 && down) $ uiIO (markDirty ctx)
when (drag0 > 0 && down && dgiMoved dgi) $
storeWrite env False $ \st -> st {storeInt = IM.insert (slotKey SlotPaneGrab (geKey env)) 1 (storeInt st)}
-- A drop clears the gesture and, when it moved the pane, stores the new
-- tree, seed and focus in the same write.
when (drag0 > 0 && not down) $ do
let moved = fromIntegral drag0
dropped
| dgiMoved dgi = dgiZone dgi >>= \(_, dt) -> treeMovePane moved (geSeed env) dt (geTree env)
| otherwise = Nothing
storeWrite env True $ \st -> case dropped of
Nothing -> st {storeInt = IM.delete gestK (storeInt st)}
Just t' ->
st
{ storeDyn = IM.insert (geKey env) (toDyn t') (storeDyn st)
, storeInt =
IM.insert (slotKey SlotPaneNext (geKey env)) (fromIntegral (geSeed env + 1)) $
IM.insert (slotKey SlotPaneFocus (geKey env)) (fromIntegral moved) $
IM.delete gestK (storeInt st)
}
when (isJust dropped) (markChanged env)
mouseMain :: DividerInfo -> V2 -> Float
mouseMain d mouse = case diAxis d of
AxisV -> v2X mouse
AxisH -> v2Y mouse
-- -----------------------------------------------------------------------------
-- Keyboard navigation
-- -----------------------------------------------------------------------------
moveFocus ::
(Ui :> es) =>
GridEnv es ->
Word64 ->
(Float, Float) ->
Eff es ()
moveFocus env cur dir =
case neighborPane (geRegions env) cur dir of
Just pid -> putPaneSlot False SlotPaneFocus env pid
Nothing -> pure ()
neighborPane :: Map Word64 Rect -> Word64 -> (Float, Float) -> Maybe Word64
neighborPane regions cur (dx, dy) =
case M.lookup cur regions of
Nothing -> Nothing
Just curR ->
let (cx, cy) = centerOf curR
scored =
[ (pid, s)
| (pid, r) <- M.toList regions
, pid /= cur
, rectNonEmpty r
, let (px, py) = centerOf r
vx = px - cx
vy = py - cy
dotv = vx * dx + vy * dy
, dotv > 0
, let s = abs (vx * dy - vy * dx) / dotv
]
in case scored of
[] -> Nothing
_ -> Just (fst (minimumBy (comparing snd) scored))
centerOf :: Rect -> (Float, Float)
centerOf r = (rectX r + rectW r / 2, rectY r + rectH r / 2)
-- -----------------------------------------------------------------------------
-- Store mutation helpers
-- -----------------------------------------------------------------------------
splitPane :: (Ui :> es) => GridEnv es -> Word64 -> GridAxis -> Eff es Word64
splitPane env pid axis = do
let splitId = geSeed env
newPane = geSeed env + 1
putTree env (Just (treeSplit pid splitId axis False newPane (geTree env)))
putSeed env (geSeed env + 2)
putPaneSlot False SlotPaneFocus env newPane
pure newPane
closePane :: (Ui :> es) => GridEnv es -> Word64 -> Eff es ()
closePane env pid =
case treeRemovePane pid (geTree env) of
Nothing -> putTree env Nothing
Just t' -> do
putTree env (Just t')
when (geMax env == pid) (putPaneSlot True SlotPaneMax env 0)
maximizePane :: (Ui :> es) => GridEnv es -> Word64 -> Eff es ()
maximizePane env pid = do
let v = if geMax env == pid then 0 else pid
putPaneSlot True SlotPaneMax env v
-- Maximizing hides the dividers and every other pane, so an armed drag or
-- resize gesture could never complete; cancel it instead of leaking it.
when (v /= 0) (writeGest env 0)
restorePane :: (Ui :> es) => GridEnv es -> Eff es ()
restorePane env = putPaneSlot True SlotPaneMax env 0
-- | One store round-trip. @mirror@ bumps the mirror generation so the
-- running frame rebuilds its UI and layout with the new value (see
-- 'NanoUI.Frame'); the store write itself wakes the renderer.
storeWrite ::
(Ui :> es) =>
GridEnv es ->
Bool ->
(WidgetStore -> WidgetStore) ->
Eff es ()
storeWrite env mirror f =
uiIO $ modifyStore (geCtx env) ((if mirror then bumpMirror else id) . f)
-- | Flag 'pgrChanged' for this frame.
markChanged :: (Ui :> es) => GridEnv es -> Eff es ()
markChanged env = uiIO (writeIORef (geChangedRef env) True)
-- | Structural change (mirror + 'pgrChanged'): store the tree, or remove it
-- entirely when the last pane was closed. The pane-id seed keeps counting
-- across a removal, so the re-seeded pane gets a fresh id and state keyed by
-- pane id never collides with a closed pane's state.
putTree :: (Ui :> es) => GridEnv es -> Maybe GridNode -> Eff es ()
putTree env mTree = do
storeWrite env True $ \st ->
st {storeDyn = maybe (IM.delete k) (IM.insert k . toDyn) mTree (storeDyn st)}
markChanged env
where
k = geKey env
-- | Gesture slot: 0 none, positive = dragged pane id, negative = resized
-- split id.
writeGest :: (Ui :> es) => GridEnv es -> Int -> Eff es ()
writeGest env n =
storeWrite env True $ \st ->
st
{ storeInt =
if n == 0
then IM.delete (slotKey SlotPaneGest (geKey env)) (storeInt st)
else IM.insert (slotKey SlotPaneGest (geKey env)) n (storeInt st)
}
-- | Write a pane-id slot (maximized or focused pane) when it differs,
-- bumping the mirror; @structural@ also flags 'pgrChanged'.
putPaneSlot :: (Ui :> es) => Bool -> Slot -> GridEnv es -> Word64 -> Eff es ()
putPaneSlot structural slot env v = do
let k = slotKey slot (geKey env)
n = fromIntegral v
st <- uiIO (getStore (geCtx env))
when (IM.findWithDefault 0 k (storeInt st) /= n) $ do
storeWrite env True (\st' -> st' {storeInt = IM.insert k n (storeInt st')})
when structural (markChanged env)
-- | Advance the next-id seed ('SlotPaneNext').
putSeed :: (Ui :> es) => GridEnv es -> Word64 -> Eff es ()
putSeed env v =
storeWrite env False $ \st ->
st {storeInt = IM.insert (slotKey SlotPaneNext (geKey env)) (fromIntegral v) (storeInt st)}