nano-ui-0.1.0.1: lib/NanoUI/Widgets/Menu.hs
{-# LANGUAGE OverloadedStrings #-}
module NanoUI.Widgets.Menu
( contextMenu
, contextMenuArea
, useContextMenu
, menuButton
, menuButton'
, MenuItem (..)
, menuItemWith
, menuItem
, menuItem'
, menuItemShortcut
, menuItemDisabled
, menuSeparator
, menuHeader
)
where
import Control.Monad (void, when)
import Data.IntMap.Strict qualified as IM
import Data.Text (Text)
import Effectful (Eff, type (:>))
import NanoUI.Context (Context (..), getStore, intKey, modifyStore)
import NanoUI.Font (menuItemPadX, menuItemRowH, menuMinW, menuOuterPad, menuSepH, widgetContentInset)
import NanoUI.Input (inputMousePos, inputMouseReleased)
import NanoUI.Monad (Ui, askContext, askDefaultLayout, askInput, nextId, uiIO)
import NanoUI.Store (WidgetStore (..), slotKey, Slot (..))
import NanoUI.Style (Layout (..), Padding (..), defaultLayout, fillW, fixedH, fontMuted, gap, minW, padXY, tight)
import NanoUI.Types (PopupAnchor (..), PopupPlacement (..), V2 (..))
import NanoUI.WidgetText (buttonFlagMenu, buttonFlagMenuBar)
import NanoUI.Widgets.Combinators (buttonStyled)
import NanoUI.Widgets.Layout (columnWith, labelEx, rowWith, separator)
import NanoUI.Layout.Arena (NodeType (..))
import NanoUI.Widgets.Node (HasResponse, Response (..), containerResponse, respClicked, respHovered, respRightClicked)
import NanoUI.Widgets.Popup (PopupConfig (..), popup)
-- | A context menu for any widget response, opened by right-clicking it.
-- Returns the menu body's result while the menu is open.
contextMenu ::
(Ui :> es, HasResponse r) =>
r ->
Eff es a ->
Eff es (Maybe a)
contextMenu target child = do
menu <- useContextMenu
runContextMenu menu (respRightClicked target) (const child)
-- | A container whose right-click opens a context menu. The menu body
-- receives the position it was opened at.
contextMenuArea ::
Ui :> es =>
(Layout -> Layout) ->
Eff es a ->
(V2 -> Eff es b) ->
Eff es (a, Maybe b)
contextMenuArea f areaContent menuContent = do
menu <- useContextMenu
base <- askDefaultLayout
(areaRes, areaResp) <- containerResponse NodeContainer (f base) areaContent
(,) areaRes <$> runContextMenu menu (respRightClicked areaResp) menuContent
-- | Open the menu at the pointer on a right click, show it while open, and
-- close it once a row is picked or it is dismissed.
runContextMenu ::
Ui :> es =>
(Bool, V2, V2 -> Eff es (), Eff es ()) ->
Bool ->
(V2 -> Eff es a) ->
Eff es (Maybe a)
runContextMenu (isOpen0, pos0, openAt, close) rightClick child = do
inp <- askInput
let mouse = inputMousePos inp
pos = if rightClick then mouse else pos0
cfg =
PopupConfig
{ cfgAnchor = AnchorPoint pos
, cfgPlacement = PlacementAtCursor
, cfgDismissable = True
, cfgOffset = 0
}
when rightClick (openAt mouse)
(popupResp, mBody) <- popup (isOpen0 || rightClick) cfg (columnWith (tight . gap 0) (child pos))
let picked = respHovered popupResp && inputMouseReleased inp
when (respClicked popupResp || picked) close
pure mBody
-- | Open state for a context menu you position yourself: whether it is open,
-- where it was opened, an action to open it at a point, and one to close it.
useContextMenu ::
Ui :> es =>
Eff es (Bool, V2, V2 -> Eff es (), Eff es ())
useContextMenu = do
wid <- nextId
ctx <- askContext
let key = intKey wid
openK = slotKey SlotMenuOpen key
posK = slotKey SlotMenuPos key
store <- uiIO (getStore ctx)
let isOpen = IM.findWithDefault 0 openK (storeInt store) /= 0
(px, py) = IM.findWithDefault (0, 0) posK (storePoint store)
openAt (V2 x y) =
uiIO $
modifyStore ctx $ \st ->
st
{ storeInt = IM.insert openK 1 (storeInt st)
, storePoint = IM.insert posK (x, y) (storePoint st)
}
close = uiIO $ modifyStore ctx $ \st -> st {storeInt = IM.delete openK (storeInt st)}
pure (isOpen, V2 px py, openAt, close)
-- | One context-menu row; the whole row is the button.
data MenuItem = MenuItem
{ menuItemLabel :: !Text
, menuItemHint :: !(Maybe Text)
-- ^ Shortcut hint shown after the label, e.g. @Ctrl+S@.
, menuItemEnabled :: !Bool
-- ^ Disabled rows are dimmed and cannot be clicked or focused.
}
deriving (Eq, Show)
-- | Render a menu row, returning its full 'Response'. A disabled row is a
-- muted label, not a disabled button: hover tracking does not know a button's
-- enabled flag and would still highlight it. Text nodes ignore padding, so the
-- label sits in a container that reproduces an enabled row's geometry: the
-- 'menuItemRowH' height and 'menuMinW' width, the label inset 'menuItemPadX'
-- plus the button's content inset, and the same total horizontal gutter the
-- solver reserves for menu buttons. Its response never reports interaction.
menuItemWith :: Ui :> es => MenuItem -> Eff es Response
menuItemWith (MenuItem lbl hint enabled)
| enabled = buttonStyled text 0 menuRowLayout buttonFlagMenu
| otherwise = do
ctx <- askContext
let (ix, _) = widgetContentInset (ctxFontMetrics ctx)
padLeft = menuItemPadX + ix
padRight = max 0 (2 * (menuOuterPad + menuItemPadX) - padLeft)
rowLayout = (minW menuMinW defaultLayout) {layoutPadding = Padding padLeft padRight 0 0}
(_, resp) <-
containerResponse NodeContainer rowLayout $
labelEx (fixedH menuItemRowH . tight . fontMuted $ defaultLayout) text
pure
resp
{ rawRespHovered = False
, rawRespPressed = False
, rawRespClicked = False
, rawRespRightPressed = False
, rawRespRightClicked = False
}
where
text = maybe lbl (\s -> mconcat [lbl, " ", s]) hint
-- | Menu row. 'True' on the frame it is clicked.
--
-- @
-- whenM (menuItem "Open...") openFile
-- @
{-# INLINE menuItem #-}
menuItem :: Ui :> es => Text -> Eff es Bool
menuItem txt = respClicked <$> menuItem' txt
{-# INLINE menuItem' #-}
menuItem' :: Ui :> es => Text -> Eff es Response
menuItem' txt = menuItemWith (MenuItem txt Nothing True)
-- | Menu row with a shortcut hint after the label. The hint is only text;
-- handle the key itself elsewhere.
--
-- @
-- whenM (menuItemShortcut "Save" "Ctrl+S") saveFile
-- @
menuItemShortcut :: Ui :> es => Text -> Text -> Eff es Bool
menuItemShortcut txt hint = respClicked <$> menuItemWith (MenuItem txt (Just hint) True)
-- | Dimmed menu row that cannot be clicked.
menuItemDisabled :: Ui :> es => Text -> Eff es ()
menuItemDisabled txt = void (menuItemWith (MenuItem txt Nothing False))
-- | Row layout shared by menu items, matching the text-field context menu:
-- 28px rows and a 148px minimum menu width (@menuItemRowH@ and @menuMinW@ in
-- @NanoUI.Font@).
menuRowLayout :: Layout
menuRowLayout = minW menuMinW . fixedH menuItemRowH . tight . fillW $ defaultLayout
-- | Menu-bar title: a flat, label-sized button. @open@ tints the title while
-- its drop-down is showing. 'True' on the frame it is clicked.
{-# INLINE menuButton #-}
menuButton :: Ui :> es => Text -> Bool -> Eff es Bool
menuButton txt open = respClicked <$> menuButton' txt open
-- | 'menuButton' returning its 'Response', whose rect anchors the drop-down.
menuButton' :: Ui :> es => Text -> Bool -> Eff es Response
menuButton' txt open =
buttonStyled txt (if open then 1 else 0) menuBarTitleLayout buttonFlagMenuBar
menuBarTitleLayout :: Layout
menuBarTitleLayout = tight $ defaultLayout
-- | Separator line inside a context menu, matching the text-field context
-- menu painter exactly: a 1px rule inset 'menuItemPadX' from the panel edge
-- (the popup already contributes 'menuOuterPad', the row adds the remainder)
-- centered in a 'menuSepH' band (@lineY = bandY + h\/2@ via 4.5px vertical
-- padding around a zero-height content box). The rule sits in a 'tight'
-- column so it stays horizontal ('separator' adapts to its parent's
-- direction and would grow vertically inside the padded row) and so the
-- default 3px container padding does not inset or stretch it.
menuSeparator :: Ui :> es => Eff es ()
menuSeparator = do
rowWith (fixedH menuSepH . padXY (menuItemPadX - menuOuterPad) 4.5 . fillW) $
columnWith (tight . fillW) separator
-- | Header / category title inside a context menu.
menuHeader :: Ui :> es => Text -> Eff es ()
menuHeader txt =
void (labelEx (padXY 6 2 defaultLayout) txt)