nano-ui-0.1.0.0: lib/NanoUI/Frame/Chrome.hs
{-# LANGUAGE DataKinds #-}
-- | Widget paint helpers: labels, styles, rects, menu panels and display text.
module NanoUI.Frame.Chrome
( floatingAncestor
, displayText
, widgetVisualStyle
, textInputValue
, textInputFocused
, fillStyledRect
, strokeStyledRect
, paintStyledRect
, overlayWindowStyle
, overlayModalStyle
, overlayMenuStyle
, paintMenuPanel
, paintMenuAccent
, paintScrollBarLayout
, imageIdFromText
, paintTabHeader
, paintTableHeader
) where
import Control.Monad (when)
import Data.IORef (readIORef)
import qualified Data.IntMap.Strict as IM
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Read as TR
import NanoUI.Context
( Context (..)
, WidgetStore (..)
, getAnimationValue
, getStore
, intKey
, nodeTheme
)
import NanoUI.Draw (DrawArena, pushRect, pushRoundedRect, pushRoundedStroke)
import NanoUI.Font (menuAccentInset, menuAccentW)
import NanoUI.Frame.Scroll.Geometry (ScrollBarLayout (..))
import NanoUI.Id (hashWidgetId)
import NanoUI.Layout.Arena
( NodeIdx
, NodeType (..)
, getNodeType
, getNodeValue
, getOptions
, getParent
, getStyleIdx
, getText
, getWidgetId
, isFloatingNode
)
import NanoUI.Style
( Style (..)
, Theme (..)
, themeAccent
, themeButton
, themeFloatingWindow
, themeInput
, themeMuted
, themePanel
, themeWindow
, themeOnAccent
, themeShadow
)
import NanoUI.Types (Color (..), Rect (..), colorA, colorRGBA, lerpColor)
import NanoUI.WidgetText
( buttonFlagsFromStyle
, buttonVisualStyle
, isMenuBarStyle
, isMenuItemStyle
, isTableHeaderStyle
, selectDisplayText
, stripeColor
, tableHeaderDisplayText
, textInputFieldText
, textInputPasswordMode
, treeDecodeStripe
)
floatingAncestor :: Context -> NodeIdx -> IO (Maybe NodeType)
floatingAncestor ctx idx = go idx
where
go i
| i < 0 = pure Nothing
| otherwise = do
nt <- getNodeType (ctxNodeArena ctx) i
if isFloatingNode nt
then pure (Just nt)
else getParent (ctxNodeArena ctx) i >>= go
displayText :: Context -> NodeType -> NodeIdx -> IO Text
displayText ctx nt idx = do
txt <- getText (ctxNodeArena ctx) idx
case nt of
NodeButton -> do
si <- getStyleIdx (ctxNodeArena ctx) idx
pure $! if isTableHeaderStyle si then tableHeaderDisplayText txt else txt
NodeTextInput -> textInputFieldText txt <$> textInputValue ctx idx <*> textInputFocused ctx idx
NodeTextArea -> textInputValue ctx idx
NodeSelect -> selectDisplayText txt <$> selectCurrentOption ctx idx
_ -> pure txt
selectCurrentOption :: Context -> NodeIdx -> IO Text
selectCurrentOption ctx idx = do
store <- getStore ctx
opts <- getOptions (ctxNodeArena ctx) idx
wid <- getWidgetId (ctxNodeArena ctx) idx
let picked = IM.findWithDefault 0 (intKey wid) (storeInt store)
pure $ case drop picked opts of
(o : _) -> o
_ -> ""
-- | The text a field displays: its stored value, masked one character per
-- character for password inputs so caret and selection offsets still line up.
textInputValue :: Context -> NodeIdx -> IO Text
textInputValue ctx idx = do
let na = ctxNodeArena ctx
wid <- getWidgetId na idx
nt <- getNodeType na idx
si <- getStyleIdx na idx
store <- getStore ctx
let value = IM.findWithDefault "" (intKey wid) (storeText store)
pure $
if nt == NodeTextInput && textInputPasswordMode si
then T.replicate (T.length value) "*"
else value
textInputFocused :: Context -> NodeIdx -> IO Bool
textInputFocused ctx idx = do
wid <- getWidgetId (ctxNodeArena ctx) idx
focus <- readIORef (ctxFocusId ctx)
pure (focus == wid)
-- | Transparent fills and no border.
clearStyle :: Style -> Style
clearStyle s = s {styleBg = clear, styleHoverBg = clear, styleActiveBg = clear, styleBorderWidth = 0}
where
clear = colorRGBA 0 0 0 0
closeButtonStyle :: Theme -> Bool -> Float -> Style
closeButtonStyle theme isHot animT =
let btn = themeButton theme
muted = lerpColor (styleFg btn) (styleBg (themePanel theme)) 0.42
t = if isHot && not (animT > 0) then 1 else animT
in (clearStyle btn) {styleFg = lerpColor muted (styleFg btn) t}
tabHeaderVisualStyle :: Theme -> Int -> Bool -> Style
tabHeaderVisualStyle theme styleIdx isActive =
let panel = themePanel theme
btn = themeButton theme
muted = themeMuted theme
accent = themeAccent theme
clear = colorRGBA 0 0 0 0
hoverLift = lerpColor (themeWindow theme) (styleHoverBg btn) 0.55
(cr, activeBg, activeFg, activeBw, inactFg) = case styleIdx of
1 -> (6, accent, themeOnAccent theme, 0, muted)
2 -> (8, styleBg panel, styleFg panel, 1, muted)
_ -> (6, styleBg panel, styleFg panel, 1, lerpColor muted (styleFg panel) 0.78)
in if isActive
then panel
{ styleBg = activeBg
, styleHoverBg = activeBg
, styleFg = activeFg
, styleBorder = activeBg
, styleBorderWidth = activeBw
, styleCornerRadius = cr
}
else panel
{ styleBg = clear
, styleHoverBg = hoverLift
, styleFg = inactFg
, styleBorder = clear
, styleBorderWidth = 0
, styleCornerRadius = cr
}
-- | Flat menu row / menu-bar entry. Transparent at rest, a hover highlight
-- (matching the text-field context menu), and an accent-tinted fill while it
-- owns an open drop-down (@val > 0.5@, menu-bar titles only).
menuItemVisualStyle :: Theme -> Float -> Style
menuItemVisualStyle theme val =
let menu = overlayMenuStyle theme
accent = themeAccent theme
clear = colorRGBA 0 0 0 0
openBg = lerpColor (styleBg menu) accent 0.3
isOpen = val > 0.5
in menu
{ styleBg = if isOpen then openBg else clear
, styleHoverBg = if isOpen then openBg else styleHoverBg menu
, styleActiveBg = lerpColor (styleBg menu) accent 0.4
, styleBorder = clear
, styleBorderWidth = 0
-- The text-field context menu fills hovered rows with a square
-- pushRect; keep the generic menu identical.
, styleCornerRadius = 0
}
tableHeaderVisualStyle :: Theme -> Bool -> Style
tableHeaderVisualStyle theme isSorted =
let btn = themeButton theme
accent = themeAccent theme
headerBg = lerpColor (styleBg (themePanel theme)) (styleBg btn) 0.55
in btn
{ styleBg = headerBg
, styleHoverBg = lerpColor headerBg accent 0.18
, styleActiveBg = lerpColor headerBg accent 0.28
, styleFg = if isSorted then styleFg btn else themeMuted theme
, styleBorderWidth = 0
, styleCornerRadius = 0
}
paintTabHeader :: DrawArena -> Theme -> Int -> Bool -> Style -> Float -> Float -> Float -> Float -> IO ()
paintTabHeader da theme styleIdx isActive style x y w h = do
let rect = Rect x y w h
r = max 0 (styleCornerRadius style)
bg = styleBg style
if isActive
then case styleIdx `mod` 4 of
1 -> pushRoundedRect da rect r bg
2 -> do
pushRoundedRect da rect r bg
strokeStyledRect da style rect
_ -> do
pushRoundedRect da rect r bg
pushRoundedStroke da (Rect x y w (h + 1)) (min r (min (w / 2) (h / 2))) 1 (styleBorder (themePanel theme))
pushRect da (Rect x (y + h - 2) w 2) (themeAccent theme)
else when (bg /= colorRGBA 0 0 0 0) $ pushRoundedRect da rect r bg
paintTableHeader :: DrawArena -> Theme -> Bool -> Style -> Float -> Float -> Float -> Float -> IO ()
paintTableHeader da theme isSorted style x y w h = do
pushRect da (Rect x y w h) (styleBg style)
when isSorted $
pushRect da (Rect x (y + h - 2) w 2) (themeAccent theme)
widgetVisualStyle :: Context -> NodeType -> NodeIdx -> IO Style
widgetVisualStyle ctx nt idx = do
wid <- getWidgetId (ctxNodeArena ctx) idx
val <- getNodeValue (ctxNodeArena ctx) idx
hot <- readIORef (ctxHotId ctx)
active <- readIORef (ctxActiveId ctx)
focus <- readIORef (ctxFocusId ctx)
animT <- getAnimationValue ctx wid
-- Only these node types consult the floating ancestor; skip the parent
-- walk for the common panel/text/button path.
let modalAware = nt == NodeCheckbox || nt == NodeRadio || nt == NodeTree || nt == NodeSlider
mFloat <- if modalAware then floatingAncestor ctx idx else pure Nothing
styleIdx <-
if nt == NodeButton || nt == NodeTree
then getStyleIdx (ctxNodeArena ctx) idx
else pure 0
let (isClose, isTab, isTable) =
if nt == NodeButton
then buttonFlagsFromStyle styleIdx
else (False, False, False)
isMenu = nt == NodeButton && (isMenuItemStyle styleIdx || isMenuBarStyle styleIdx)
theme <- nodeTheme ctx idx
let isFocus = focus == wid
isHot = wid == hot
focusBorder s = if isFocus then s {styleBorder = themeAccent theme} else s
base =
case nt of
NodeTextInput -> focusBorder (themeInput theme)
NodeTextArea -> focusBorder (themeInput theme)
NodeSelect -> focusBorder (themeButton theme)
NodeColorPicker -> focusBorder (themeInput theme)
NodeSlider -> clearStyle (themeInput theme)
NodeCheckbox -> clearStyle (themeButton theme)
NodeRadio -> clearStyle (themeButton theme)
NodeTree ->
let btn = themeButton theme
accent = themeAccent theme
unselectedBg =
case stripeColor theme (treeDecodeStripe styleIdx) of
Just c -> c
Nothing -> styleBg (themePanel theme)
in if val > 0.5
then
btn
{ styleBg = lerpColor unselectedBg accent 0.25
, styleHoverBg = lerpColor unselectedBg accent 0.35
, styleActiveBg = lerpColor unselectedBg accent 0.45
, styleBorderWidth = 0
, styleCornerRadius = 0
}
else
btn
{ styleBg = unselectedBg
, styleHoverBg = lerpColor unselectedBg accent 0.12
, styleActiveBg = lerpColor unselectedBg accent 0.22
, styleBorderWidth = 0
, styleCornerRadius = 0
}
NodeButton
| isMenu -> menuItemVisualStyle theme val
| isClose -> closeButtonStyle theme isHot animT
| isTab -> tabHeaderVisualStyle theme (buttonVisualStyle styleIdx `mod` 4) (val > 0.5)
| isTable -> tableHeaderVisualStyle theme (val > 0.5)
| val > 0.5 ->
(themeButton theme)
{ styleBg = themeAccent theme
, styleHoverBg = themeAccent theme
, styleFg = themeOnAccent theme
, styleBorder = themeAccent theme
}
_ -> themeButton theme
widgetBase =
case mFloat of
Just NodeModal | modalAware -> overlayModalStyle theme
_ -> base
bg
| nt == NodeTextInput, isFocus = styleActiveBg widgetBase
| nt == NodeTextArea, isFocus = styleActiveBg widgetBase
| hashWidgetId wid == hashWidgetId active = styleActiveBg widgetBase
| nt == NodeCheckbox || nt == NodeRadio || nt == NodeSlider || isClose = styleBg widgetBase
| isMenu = if isHot then styleHoverBg widgetBase else styleBg widgetBase
| otherwise = hoverBackground widgetBase animT isHot
-- Idle widgets (no hover/active tint change) reuse the base style record
-- rather than allocating a fresh Style through a record update.
pure $! if bg == styleBg widgetBase then widgetBase else widgetBase {styleBg = bg}
hoverBackground :: Style -> Float -> Bool -> Color
hoverBackground base val isHot
| styleBg base == styleHoverBg base = styleBg base
| isHot = lerpColor (styleBg base) (styleHoverBg base) (if val > 0 then val else 1)
| otherwise = lerpColor (styleBg base) (styleHoverBg base) val
{-# INLINE fillStyledRect #-}
fillStyledRect :: DrawArena -> Style -> Rect -> IO ()
fillStyledRect da style rect =
if styleCornerRadius style <= 0
then pushRect da rect (styleBg style)
else pushRoundedRect da rect (styleCornerRadius style) (styleBg style)
{-# INLINE strokeStyledRect #-}
strokeStyledRect :: DrawArena -> Style -> Rect -> IO ()
strokeStyledRect da style rect@(Rect _ _ w h) =
when (styleBorderWidth style > 0) $ do
let rr = max 0 (min (styleCornerRadius style) (min (w / 2) (h / 2)))
pushRoundedStroke da rect rr (max 1 (styleBorderWidth style)) (styleBorder style)
-- | A style's fill, then its border.
{-# INLINE paintStyledRect #-}
paintStyledRect :: DrawArena -> Style -> Rect -> IO ()
paintStyledRect da style rect = do
fillStyledRect da style rect
strokeStyledRect da style rect
overlayMenuStyle :: Theme -> Style
overlayMenuStyle theme =
let panel = themePanel theme
hover =
if styleHoverBg panel == styleBg panel
then styleHoverBg (themeButton theme)
else styleHoverBg panel
in panel
{ styleCornerRadius = 2
, styleBorderWidth = 1
, styleHoverBg = hover
, styleActiveBg = lerpColor (styleBg panel) (themeAccent theme) 0.22
}
overlayWindowStyle :: Theme -> Style
overlayWindowStyle theme = (themeFloatingWindow theme) {styleCornerRadius = 2, styleBorderWidth = 1}
overlayModalStyle :: Theme -> Style
overlayModalStyle theme = (overlayMenuStyle theme) {styleCornerRadius = 2, styleBorderWidth = 1}
-- | Panel behind menus, dropdowns and floating windows: the theme's offset
-- shadow, then the styled fill and border.
paintMenuPanel :: DrawArena -> Theme -> Style -> Rect -> IO ()
paintMenuPanel da theme style rect@(Rect x y w h) = do
when (colorA (themeShadow theme) > 0) $
pushRoundedRect da (Rect (x + 3) (y + 3) w h) (styleCornerRadius style) (themeShadow theme)
paintStyledRect da style rect
-- | Accent marker at a menu row's left edge, inset from its top and bottom.
paintMenuAccent :: DrawArena -> Theme -> Rect -> IO ()
paintMenuAccent da theme (Rect x y _ h) =
pushRoundedRect
da
(Rect x (y + menuAccentInset) menuAccentW (max 0 (h - 2 * menuAccentInset)))
1
(themeAccent theme)
-- | Scrollbar track and thumb, each rounded to at most 4px.
paintScrollBarLayout :: DrawArena -> Color -> Color -> ScrollBarLayout -> IO ()
paintScrollBarLayout da trackCol thumbCol layout = do
pill (sbTrack layout) trackCol
pill (sbThumb layout) thumbCol
where
pill r@(Rect _ _ rw rh) = pushRoundedRect da r (min 4 (min rw rh / 2))
imageIdFromText :: Text -> Int
imageIdFromText txt =
case TR.decimal txt of
Right (n, rest) | T.null rest, n > 0 -> n
_ -> 0