packages feed

nano-ui-0.1.0.0: lib/NanoUI/Widgets/ColorPicker.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Colour picker. The saturation / value field, the hue bar, the alpha bar and
-- the Current / New preview are separate 'NodeColorPicker' nodes in one row,
-- so each bar is its own focus stop with its own keyboard control.
module NanoUI.Widgets.ColorPicker
  ( ColorPickerPart (..)
  , colorPickerPartOf
  , widgetStoreColor
  , widgetStoreBaseColor
  , colorPickerSvSquare
  , colorPickerPartRect
  , colorPickerPreviewGeom
  , drawColorPickerPart
  , colorPicker
  , colorPicker'
  , colorPickerRGBA
  , colorPickerRGBA'
  )
where

import Control.Monad (forM_, void, when)
import Data.Bits ((.&.))
import Data.IORef (readIORef, writeIORef)
import Data.IntMap.Strict qualified as IM
import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text)
import Data.Word (Word8)
import Effectful (Eff, type (:>))
import NanoUI.Context
  ( Context (..)
  , WidgetStore (..)
  , getMenuPointerGesture
  , getStore
  , intKey
  , recordStoreInt
  , registerFocusable
  , setStore
  , getsOverlay
  , OverlayState (..)
  , modifyStore
  )
import NanoUI.Draw
  ( DrawArena
  , pushQuadGradient
  , pushRect
  , pushRoundedRect
  , pushRoundedStroke
  )
import NanoUI.Font
  ( FontMetrics (..)
  )
import NanoUI.Id (WidgetId (..), hashWidgetId)
import NanoUI.Input (Input (..), Key (..), inputKeys, inputKeysElem, inputModifiers, inputMouseDown, inputMousePressed, modShift)
import NanoUI.Layout.Arena
  ( NodeArena
  , NodeIdx
  , NodeType (..)
  , getFirstChild
  , getNextSibling
  , getNodeType
  , getParent
  , getRect
  , getStyleIdx
  , getWidgetId
  )
import NanoUI.Monad (Ui, askContext, askInput, nextId, uiIO, withKey)
import NanoUI.Store (Slot (..), slotKey)
import NanoUI.Style
  ( AlignY (..)
  , Direction (..)
  , Layout (..)
  , Padding (..)
  , Sizing (..)
  , Style (..)
  , defaultLayout
  )
import NanoUI.Types
  ( Color (..)
  , Rect (..)
  , clamp
  , clamp01
  , colorA
  , colorB
  , colorFromWord32
  , colorG
  , colorR
  , colorRGBA
  , colorToWord32
  , hsvToRgb
  , rectH
  , rectW
  , rectX
  , rectY
  , rgbToHsv
  )
import NanoUI.WidgetText
  ( colorPickerGap
  , colorPickerParseHex
  , colorPickerSvH
  , colorToHex
  , colorToHexA
  )
import NanoUI.Widgets.Behavior
  ( DragAxis (..)
  , keyboardFocused
  , keyedDragHeld
  , useDrag1D
  )
import NanoUI.Widgets.Node
  ( Response (..)
  , addWidget
  , addWidgetStyled
  , container
  , respRect
  , setChanged
  )
import NanoUI.Widgets.NumericInput (NumericInputConfig (..), defaultNumericInputConfig, numericInputConfigured)
import NanoUI.Widgets.TextEditor (singleLineMode)
import NanoUI.Widgets.TextInput (editTextField)

colorPickerDefaultColor :: Color
colorPickerDefaultColor = colorRGBA 128 128 128 255

colorPickerBarW :: Float
colorPickerBarW = 14

colorPickerSwatchH :: Float
colorPickerSwatchH = 30

colorPickerSwatchW :: Float
colorPickerSwatchW = 80

-- Width reserved for the Current / New preview column (label plus swatch).
colorPickerPreviewW :: Float
colorPickerPreviewW = 112

-- | The piece of a colour picker a 'NodeColorPicker' node paints, kept in the
-- low bits of its style.
data ColorPickerPart = PickerSv | PickerHue | PickerAlpha | PickerPreview
  deriving (Eq, Show, Enum, Bounded)

{-# INLINE colorPickerPartOf #-}
colorPickerPartOf :: Int -> ColorPickerPart
colorPickerPartOf si = toEnum (si .&. 3)

storeColorAt :: WidgetStore -> Int -> Color -> Color
storeColorAt store key fallback =
  colorFromWord32
    ( fromIntegral
        ( IM.findWithDefault
            (fromIntegral (colorToWord32 fallback))
            key
            (storeInt store)
        )
    )

widgetStoreColor :: WidgetStore -> WidgetId -> Color -> Color
widgetStoreColor store wid fallback = storeColorAt store (intKey wid) fallback

widgetStoreBaseColor :: WidgetStore -> WidgetId -> Color -> Color
widgetStoreBaseColor store wid fallback =
  storeColorAt
    store
    (slotKey SlotColorBase (intKey wid))
    (widgetStoreColor store wid fallback)

-- RGB cannot tell hue 0 from 360. Keep the slider end the user last set.
widgetStoreHue :: WidgetStore -> WidgetId -> Color -> Float
widgetStoreHue store wid fallback =
  let
    (h0, _, _) = rgbToHsv (widgetStoreColor store wid fallback)
   in
    IM.findWithDefault h0 (intKey wid) (storeFloat store)

-- Black collapses S in RGB. Keep the last mouse S/V so the marker does not jitter.
widgetStoreSv :: WidgetStore -> WidgetId -> Color -> (Float, Float)
widgetStoreSv store wid fallback =
  let
    (_, s0, v0) = rgbToHsv (widgetStoreColor store wid fallback)
   in
    fromMaybe (s0, v0) (IM.lookup (intKey wid) (storePoint store))

-- | Store the live colour with the hue and S/V it was set through.
putColorState :: Int -> Color -> Float -> (Float, Float) -> WidgetStore -> WidgetStore
putColorState key col hue sv st =
  st
    { storeInt = IM.insert key (fromIntegral (colorToWord32 col)) (storeInt st)
    , storeFloat = IM.insert key hue (storeFloat st)
    , storePoint = IM.insert key sv (storePoint st)
    }

withAlpha :: Word8 -> Color -> Color
withAlpha a c = colorRGBA (colorR c) (colorG c) (colorB c) a

-- | The square the saturation / value field fills, centered in its node.
colorPickerSvSquare :: Rect -> Rect
colorPickerSvSquare (Rect x y w h) =
  let s = max 0 (min w h)
   in Rect (x + (w - s) / 2) (y + (h - s) / 2) s s

-- | The field node of the picker a part belongs to: the part's sibling that
-- paints the saturation / value square. Its widget id keys the picker's state.
pickerSvNode :: NodeArena -> NodeIdx -> IO NodeIdx
pickerSvNode na idx = do
  parent <- getParent na idx
  if parent < 0 then pure idx else getFirstChild na parent >>= go
  where
    go ci
      | ci < 0 = pure idx
      | otherwise = do
          nt <- getNodeType na ci
          si <- getStyleIdx na ci
          if nt == NodeColorPicker && colorPickerPartOf si == PickerSv
            then pure ci
            else getNextSibling na ci >>= go

-- | Where the part at @idx@ (laid out at @rect@) draws: the field's square, or
-- the part's column cut to the square's height so the bars and the preview
-- line up with the field.
colorPickerPartRect :: NodeArena -> NodeIdx -> Rect -> IO Rect
colorPickerPartRect na idx rect@(Rect x _ w _) = do
  si <- getStyleIdx na idx
  case colorPickerPartOf si of
    PickerSv -> pure (colorPickerSvSquare rect)
    _ -> do
      (sx, sy0, sw, sh) <- pickerSvNode na idx >>= getRect na
      let Rect _ sy _ side = colorPickerSvSquare (Rect sx sy0 sw sh)
      pure (Rect x sy w side)

-- | The preview column's rows, stacked and centered in its band: the Current
-- label's top, its swatch, the New label's top, and its swatch.
colorPickerPreviewGeom :: FontMetrics -> Rect -> (Float, Rect, Float, Rect)
colorPickerPreviewGeom fm (Rect x y w h) =
  let
    labelH = fmLineHeight fm
    swatchW = min colorPickerSwatchW w
    swatchH = clamp 0 colorPickerSwatchH (h - labelH * 2 - colorPickerGap)
    stackH = labelH + swatchH + colorPickerGap + labelH + swatchH
    top = y + max 0 ((h - stackH) / 2)
    currentY = top + labelH
    newLabelY = currentY + swatchH + colorPickerGap
   in
    (top, Rect x currentY swatchW swatchH, newLabelY, Rect x (newLabelY + labelH) swatchW swatchH)

-- Wider than the painted bar so the handle is easy to grab.
colorPickerBarHitRect :: Rect -> Rect
colorPickerBarHitRect (Rect x y w h) =
  let
    pad = 2
   in
    Rect (x - pad) y (w + pad * 2) h

drawSvField :: DrawArena -> Rect -> Float -> IO ()
drawSvField da rect hue = do
  let
    white = colorRGBA 255 255 255 255
    hueCol = hsvToRgb hue 1 1
    clear = colorRGBA 0 0 0 0
    black = colorRGBA 0 0 0 255
  -- Horizontal: white to hue. Vertical overlay: fade to black (alpha over).
  pushQuadGradient da rect white hueCol hueCol white
  pushQuadGradient da rect clear clear black black

-- Vertical rainbow: each stop band fades into the next.
drawHueBar :: DrawArena -> Rect -> IO ()
drawHueBar da rect =
  let
    stops = (6 :: Int)
    cellH = rectH rect / fromIntegral stops
    stopCol i = hsvToRgb (360 * fromIntegral i / fromIntegral stops) 1 1
   in
    mapM_
      ( \i ->
          let
            cell = Rect (rectX rect) (rectY rect + fromIntegral i * cellH) (rectW rect) cellH
           in
            pushQuadGradient
              da
              cell
              (stopCol i)
              (stopCol i)
              (stopCol (i + 1))
              (stopCol (i + 1))
      )
      [0 .. stops - 1]

drawChecker :: DrawArena -> Rect -> IO ()
drawChecker da (Rect x y w h) = goRows 0
  where
    s = 6 :: Float
    cols = ceiling (max 0 w / s) :: Int
    rows = ceiling (max 0 h / s) :: Int
    -- Nested range folds retain a shared column list under -O2. Explicit
    -- counters keep both loops numeric, without allocating that list.
    goRows !ry = when (ry < rows) $ do
      goCols ry 0
      goRows (ry + 1)
    goCols !ry !cx = when (cx < cols) $ do
      let
        col =
          if even (ry + cx) then colorRGBA 190 190 190 255 else colorRGBA 140 140 140 255
        rx = x + fromIntegral cx * s
        ry' = y + fromIntegral ry * s
        cw = clamp 0 s (x + w - rx)
        ch = clamp 0 s (y + h - ry')
      pushRect da (Rect rx ry' cw ch) col
      goCols ry (cx + 1)

drawAlphaBar :: DrawArena -> Rect -> Color -> IO ()
drawAlphaBar da rect col = do
  drawChecker da rect
  let
    c0 = withAlpha 0 col
    c1 = withAlpha 255 col
  pushQuadGradient da rect c0 c0 c1 c1

drawBarHandle :: DrawArena -> Rect -> Float -> Color -> IO ()
drawBarHandle da bar cy col = do
  let
    w = rectW bar
    x = rectX bar
    h = 4
    handle = Rect (x - 3) (cy - h / 2) (w + 6) h
  pushRoundedRect da handle 2 (colorRGBA 255 255 255 255)
  pushRoundedStroke da handle 2 1 col

-- | Paint one part of a picker from its state in the store.
drawColorPickerPart :: NodeArena -> NodeIdx -> FontMetrics -> DrawArena -> WidgetStore -> Style -> Rect -> IO ()
drawColorPickerPart na idx fm da store style rect = do
  si <- getStyleIdx na idx
  owner <- pickerSvNode na idx >>= getWidgetId na
  area <- colorPickerPartRect na idx rect
  let
    newCol = widgetStoreColor store owner colorPickerDefaultColor
    border = styleBorder style
    handleCol = colorRGBA 0 0 0 180
  case colorPickerPartOf si of
    PickerSv -> do
      let
        hue = widgetStoreHue store owner colorPickerDefaultColor
        (sat, val) = widgetStoreSv store owner colorPickerDefaultColor
        marker = 6
        mx = rectX area + sat * rectW area
        my = rectY area + (1 - val) * rectH area
        dot = Rect (mx - marker / 2) (my - marker / 2) marker marker
      drawSvField da area hue
      pushRoundedStroke da area 4 1 border
      pushRoundedRect da dot (marker / 2) (colorRGBA 255 255 255 255)
      pushRoundedStroke da dot (marker / 2) 1 handleCol
    PickerHue -> do
      let hue = widgetStoreHue store owner colorPickerDefaultColor
      drawHueBar da area
      pushRoundedStroke da area 3 1 border
      drawBarHandle da area (rectY area + (hue / 360) * rectH area) handleCol
    PickerAlpha -> do
      drawAlphaBar da area newCol
      pushRoundedStroke da area 3 1 border
      drawBarHandle da area (rectY area + (fromIntegral (colorA newCol) / 255) * rectH area) handleCol
    PickerPreview -> do
      let
        (_, current, _, new) = colorPickerPreviewGeom fm area
        swatch r col = do
          drawChecker da r
          pushRect da r col
          pushRoundedStroke da r 0 1 border
      swatch current (widgetStoreBaseColor store owner colorPickerDefaultColor)
      swatch new newCol

colorPickerLayout :: Layout
colorPickerLayout =
  defaultLayout
    { layoutDirection = Column
    , layoutWidth = Grow 1
    , layoutGap = colorPickerGap
    , layoutPadding = Padding 0 0 0 0
    }

-- The field, bars and preview side by side.
colorPickerCanvasLayout :: Layout
colorPickerCanvasLayout =
  colorPickerLayout {layoutDirection = Row}

-- The field grows up to a square as tall as the row.
colorPickerSvLayout :: Layout
colorPickerSvLayout =
  defaultLayout
    { layoutWidth = Grow 1
    , layoutHeight = Fixed colorPickerSvH
    , layoutMinW = 60
    , layoutMaxW = colorPickerSvH
    , layoutPadding = Padding 0 0 0 0
    }

colorPickerColumnLayout :: Float -> Layout
colorPickerColumnLayout w =
  colorPickerSvLayout {layoutWidth = Fixed w, layoutMinW = w, layoutMaxW = w}

-- A row of channel fields. Children are groups sized by 'percent' so the
-- R/G/B(/A) and H/S/V rows share the same column widths.
colorPickerRowLayout :: Layout
colorPickerRowLayout =
  colorPickerLayout {layoutDirection = Row, layoutAlignY = AlignMiddle}

-- One channel field: an inline label plus its bare box, taking @pct@ of the row.
colorPickerFieldGroupLayout :: Float -> Layout
colorPickerFieldGroupLayout pct =
  colorPickerLayout
    { layoutDirection = Row
    , layoutWidth = Percent pct
    , layoutAlignY = AlignMiddle
    }

colorPickerFieldLayout :: Layout
colorPickerFieldLayout =
  defaultLayout
    { layoutWidth = Grow 1
    , layoutMinW = 40
    , layoutPadding = Padding 0 0 0 0
    }

-- A numeric channel field: room for three digits beside its stepper.
colorPickerChannelLayout :: Layout
colorPickerChannelLayout = colorPickerFieldLayout {layoutMinW = 60}

colorPickerLabelLayout :: Layout
colorPickerLabelLayout =
  defaultLayout {layoutPadding = Padding 0 0 0 0, layoutAlignY = AlignMiddle}

-- | RGB colour picker: a saturation/value field, a hue bar, and RGB, HSV and
-- hex fields. Pass the current colour; the result is the colour after this
-- frame's edits.
--
-- The field and each bar take keyboard focus in turn. On the field the arrow
-- keys move the marker (left and right for saturation, up and down for
-- value); on a bar they move its handle, and Home and End jump to its ends.
-- Shift takes steps ten times larger.
{-# INLINE colorPicker #-}
colorPicker :: Ui :> es => Color -> Eff es Color
colorPicker value = snd <$> colorPickerWith False value

colorPicker' :: Ui :> es => Color -> Eff es (Response, Color)
colorPicker' = colorPickerWith False

-- | 'colorPicker' with an alpha bar and an A / @#RRGGBBAA@ field.
{-# INLINE colorPickerRGBA #-}
colorPickerRGBA :: Ui :> es => Color -> Eff es Color
colorPickerRGBA value = snd <$> colorPickerWith True value

colorPickerRGBA' :: Ui :> es => Color -> Eff es (Response, Color)
colorPickerRGBA' = colorPickerWith True

-- | The byte fields: label, the channel read, and the channel write.
rgbChannels, rgbaChannels :: [(Text, Color -> Word8, Word8 -> Color -> Color)]
rgbChannels =
  [ ("R", colorR, \v c -> colorRGBA v (colorG c) (colorB c) (colorA c))
  , ("G", colorG, \v c -> colorRGBA (colorR c) v (colorB c) (colorA c))
  , ("B", colorB, \v c -> colorRGBA (colorR c) (colorG c) v (colorA c))
  ]
rgbaChannels = rgbChannels ++ [("A", colorA, \v c -> colorRGBA (colorR c) (colorG c) (colorB c) v)]

-- | The HSV fields: label, the largest value, the shown value, and the
-- (hue, s, v) a typed value makes.
hsvChannels :: [(Text, Int, (Float, Float, Float) -> Int, Int -> (Float, Float, Float) -> (Float, Float, Float))]
hsvChannels =
  [ ("H", 360, \(h, _, _) -> round h, \n (_, s, v) -> (fromIntegral n, s, v))
  , ("S", 100, \(_, s, _) -> round (s * 100), \n (h, _, v) -> (h, percent n, v))
  , ("V", 100, \(_, _, v) -> round (v * 100), \n (h, s, _) -> (h, s, percent n))
  ]
  where
    percent n = fromIntegral n / 100

-- | Widget ids of a picker's parts. The field's id keys the picker's state.
data PickerParts = PickerParts
  { ppSv :: !WidgetId
  , ppHue :: !WidgetId
  , ppAlpha :: !WidgetId
  , ppPreview :: !WidgetId
  }

colorPickerWith ::
  Ui :> es => Bool -> Color -> Eff es (Response, Color)
colorPickerWith showAlpha value = do
  ctx <- askContext
  parts <- PickerParts <$> nextId <*> nextId <*> nextId <*> nextId
  let
    wid = ppSv parts
    key = intKey wid
    pct = 100 / (if showAlpha then 4 else 3)
    readColor = (\st -> widgetStoreColor st wid value) <$> uiIO (getStore ctx)
    writePicker col hue sv = uiIO (modifyStore ctx (putColorState key col hue sv))
    writeColor col =
      let (h, s, v) = rgbToHsv col
       in writePicker col (clamp 0 360 h) (s, v)
    -- Without the alpha bar the colour stays opaque.
    alphaOf c = if showAlpha then colorA c else 255
    part pid p lay = addWidgetStyled pid NodeColorPicker "" 0 lay (fromEnum p)
  uiIO $ do
    adoptColorPickerValue ctx wid value
    mapM_ (registerFocusable ctx) (wid : ppHue parts : [ppAlpha parts | showAlpha])
  (start, final, svResp) <- container NodeContainer colorPickerLayout $ do
    (svResp, hueResp, alphaResp) <-
      container NodeContainer colorPickerCanvasLayout $ do
        sv <- part wid PickerSv colorPickerSvLayout
        hue <- part (ppHue parts) PickerHue (colorPickerColumnLayout colorPickerBarW)
        alpha <-
          if showAlpha
            then Just <$> part (ppAlpha parts) PickerAlpha (colorPickerColumnLayout colorPickerBarW)
            else pure Nothing
        void (part (ppPreview parts) PickerPreview (colorPickerColumnLayout colorPickerPreviewW))
        pure (sv, hue, alpha)
    start <- colorPickerCanvas parts value svResp hueResp alphaResp
    -- Only the focused field edits, so each row's fields share one store read.
    rgb <- readColor
    _ <- container NodeContainer colorPickerRowLayout $
      forM_ (if showAlpha then rgbaChannels else rgbChannels) $ \(lbl, get, set) -> do
        let shown = fromIntegral (get rgb)
        n <- channelField pct lbl 255 shown
        when (n /= shown) $
          writeColor (set (fromIntegral n) (withAlpha (alphaOf rgb) rgb))
    hsvStore <- uiIO (getStore ctx)
    let
      (s0, v0) = widgetStoreSv hsvStore wid value
      hsv = (widgetStoreHue hsvStore wid value, s0, v0)
      alpha = alphaOf (widgetStoreColor hsvStore wid value)
    _ <- container NodeContainer colorPickerRowLayout $ do
      forM_ hsvChannels $ \(lbl, hi, shown, edit) -> do
        n <- channelField pct lbl hi (shown hsv)
        when (n /= shown hsv) $ do
          let (h, s, v) = edit n hsv
          writePicker (withAlpha alpha (hsvToRgb h s v)) h (s, v)
      when showAlpha $
        void (container NodeContainer (colorPickerFieldGroupLayout pct) (pure ()))
    hex <- readColor
    let hexText = if showAlpha then colorToHexA hex else colorToHex hex
    hexWid <- nextId
    (_, thex, fhex, _) <- editTextField hexWid singleLineMode hexText (Just hexText)
    _ <-
      container NodeContainer (colorPickerFieldGroupLayout 100) $
        addWidgetStyled hexWid NodeTextInput "" 0 colorPickerFieldLayout 0
    when (fhex && thex /= hexText) $
      forM_ (colorPickerParseHex thex) $ \(r, g, b, ma) ->
        writeColor (colorRGBA r g b (if showAlpha then fromMaybe (colorA hex) ma else 255))
    final <- readColor
    pure (start, final, svResp)
  uiIO $ recordStoreInt ctx key (fromIntegral (colorToWord32 final))
  pure (setChanged (final /= start) svResp, final)

-- | The field and the bars: pointer drags, then arrow keys on whichever part
-- holds focus, then committing the "current" swatch when a drag ends or a key
-- moved the colour. Returns the colour the frame started with.
colorPickerCanvas :: Ui :> es => PickerParts -> Color -> Response -> Response -> Maybe Response -> Eff es Color
colorPickerCanvas parts initial svResp hueResp alphaResp = do
  ctx <- askContext
  inp <- askInput
  active <- uiIO (readIORef (ctxActiveId ctx))
  blocked <- uiIO (getsOverlay ctx osLastPointerBlocked)
  gesture <- uiIO (getMenuPointerGesture ctx)
  store0 <- uiIO (getStore ctx)
  hueHeld0 <- keyedDragHeld ("hue" :: Text)
  alphaHeld0 <- keyedDragHeld ("alpha" :: Text)
  sHeld0 <- keyedDragHeld ("s" :: Text)
  vHeld0 <- keyedDragHeld ("v" :: Text)
  let
    wid = ppSv parts
    showAlpha = isJust alphaResp
    current0 = widgetStoreColor store0 wid initial
    h0 = widgetStoreHue store0 wid initial
    (s0, v0) = widgetStoreSv store0 wid initial
    svHeld0 = sHeld0 || vHeld0
    empty = Rect 0 0 0 0
    isActive = active == wid
    -- A press lands on whichever part is under the pointer; the picker then
    -- takes the active id over while it drags.
    ownsActive = active `elem` [wid, ppHue parts, ppAlpha parts, ppPreview parts]
    heldByOther =
      inputMouseDown inp
        && not (inputMousePressed inp)
        && hashWidgetId active /= 0
        && not ownsActive
    locked = blocked || heldByOther || gesture
    svSquare = colorPickerSvSquare (respRect svResp)
    band resp = Rect (rectX (respRect resp)) (rectY svSquare) (rectW (respRect resp)) (rectH svSquare)
    svRect = if locked || hueHeld0 || alphaHeld0 then empty else svSquare
    hueRect = if locked || svHeld0 || alphaHeld0 then empty else colorPickerBarHitRect (band hueResp)
    alphaRect =
      case alphaResp of
        Just r | not (locked || svHeld0 || hueHeld0) -> colorPickerBarHitRect (band r)
        _ -> empty
  (sDrag, sA) <- withKey ("s" :: Text) (useDrag1D DragAxisX 0 1 s0 svRect)
  (vDrag, vA) <- withKey ("v" :: Text) (useDrag1D DragAxisY 1 0 v0 svRect)
  let svA = sA || vA
  (hDrag, hA) <-
    withKey ("hue" :: Text) (useDrag1D DragAxisY 0 360 h0 (if svA then empty else hueRect))
  (aDrag, aA) <-
    withKey
      ("alpha" :: Text)
      (useDrag1D DragAxisY 0 255 (fromIntegral (colorA current0)) (if svA || hA then empty else alphaRect))
  let
    dragging = svA || hA || aA
    nextHue = if hA then hDrag else h0
    nextS = if sA then sDrag else s0
    nextV = if vA then vDrag else v0
    nextA =
      if aA then clamp 0 255 (round aDrag :: Int) else fromIntegral (colorA current0)
    base = hsvToRgb nextHue nextS nextV
    dragged
      | aA && not (svA || hA) = withAlpha (fromIntegral nextA) current0
      | otherwise = withAlpha (if showAlpha then fromIntegral nextA else 255) base
  when (dragging && not isActive) $ uiIO $ writeIORef (ctxActiveId ctx) wid
  when ((not dragging || blocked) && isActive) $
    uiIO $ writeIORef (ctxActiveId ctx) (WidgetId 0)
  when (dragging && (dragged /= current0 || nextHue /= h0 || nextS /= s0 || nextV /= v0)) $
    uiIO $ modifyStore ctx (putColorState (intKey wid) dragged nextHue (nextS, nextV))
  svFocus <- keyboardFocused wid
  hueFocus <- keyboardFocused (ppHue parts)
  alphaFocus <- if showAlpha then keyboardFocused (ppAlpha parts) else pure False
  keyMoved <-
    if not (svFocus || hueFocus || alphaFocus)
      then pure False
      else uiIO (applyColorPickerKeys ctx wid initial inp svFocus hueFocus)
  let releasedDrag = (hueHeld0 || alphaHeld0 || svHeld0) && not dragging
  when (releasedDrag || keyMoved) $
    uiIO $ do
      st <- getStore ctx
      commitColorPickerCurrent ctx wid (widgetStoreColor st wid initial)
  pure current0

-- | One channel field: an inline label and a numeric box over @0..hi@ that
-- shows @value@ while unfocused. Returns the value after this frame's edits.
channelField :: Ui :> es => Float -> Text -> Int -> Int -> Eff es Int
channelField pct label hi value =
  container NodeContainer (colorPickerFieldGroupLayout pct) $ do
    labelWid <- nextId
    void (addWidget labelWid NodeText label 0 colorPickerLabelLayout)
    round
      <$> numericInputConfigured
        defaultNumericInputConfig {nicMin = 0, nicMax = fromIntegral hi, nicLayout = colorPickerChannelLayout}
        (fromIntegral value)

-- | Adopt the caller's colour as 'NanoUI.Context.adoptStoreInt' does. A new
-- colour also resets the hue, S/V, and the "current" swatch.
adoptColorPickerValue :: Context -> WidgetId -> Color -> IO ()
adoptColorPickerValue ctx wid value = do
  store0 <- getStore ctx
  let
    key = intKey wid
    packed = fromIntegral (colorToWord32 value)
    seenKey = slotKey SlotSeen key
    ints = IM.insert seenKey packed (storeInt store0)
  when (IM.lookup seenKey (storeInt store0) /= Just packed) $
    setStore ctx $
      if IM.lookup key (storeInt store0) == Just packed
        then store0 {storeInt = ints}
        else
          let (h, s, v) = rgbToHsv value
           in putColorState key value (clamp 0 360 h) (s, v) $
                store0 {storeInt = IM.insert (slotKey SlotColorBase key) packed ints}

commitColorPickerCurrent :: Context -> WidgetId -> Color -> IO ()
commitColorPickerCurrent ctx wid col = do
  st <- getStore ctx
  let
    packed = fromIntegral (colorToWord32 col)
    k = slotKey SlotColorBase (intKey wid)
    old = IM.findWithDefault packed k (storeInt st)
  when (old /= packed) $
    setStore ctx (st {storeInt = IM.insert k packed (storeInt st)})

-- | Arrow, Home and End keys on the focused part: the field when @svFocus@,
-- the hue bar when @hueFocus@, otherwise the alpha bar. Arrows move a part
-- the way it is drawn: the marker right for more saturation and up for more
-- value, a bar's handle down (or right) towards its bottom end. Returns
-- whether the colour moved.
applyColorPickerKeys :: Context -> WidgetId -> Color -> Input -> Bool -> Bool -> IO Bool
applyColorPickerKeys ctx wid fallback inp svFocus hueFocus = do
  store <- getStore ctx
  let
    keys = inputKeys inp
    down k = inputKeysElem k keys
    step = if modShift (inputModifiers inp) then 10 else 1
    along neg pos = (if down pos then 1 else 0) - (if down neg then 1 else 0) :: Float
    dx = along KeyLeft KeyRight
    dy = along KeyUp KeyDown
    current = widgetStoreColor store wid fallback
    h = widgetStoreHue store wid current
    (s, v) = widgetStoreSv store wid current
    a = fromIntegral (colorA current) :: Float
    bar lo hi cur
      | down KeyHome = lo
      | down KeyEnd = hi
      | otherwise = clamp lo hi (cur + (dx + dy) * step)
    (col', h', sv')
      | svFocus =
          let sat = clamp01 (s + dx * step / 100)
              val = clamp01 (v - dy * step / 100)
           in (withAlpha (colorA current) (hsvToRgb h sat val), h, (sat, val))
      | hueFocus =
          let hue = bar 0 360 h
           in (withAlpha (colorA current) (hsvToRgb hue s v), hue, (s, v))
      | otherwise = (withAlpha (round (bar 0 255 a)) current, h, (s, v))
    moved = col' /= current || h' /= h || sv' /= (s, v)
  when moved $
    setStore ctx (putColorState (intKey wid) col' h' sv' store)
  pure moved