{-# LANGUAGE DeriveDataTypeable, FlexibleInstances, TypeSynonymInstances #-} -- |This module provides a 'List' widget for rendering a list of -- arbitrary widgets. A 'List' shows a number of elements and -- highlights the currently-selected widget. It supports key events -- to navigate the list and will automatically scroll based on the -- space available to the list along with the size of the widgets in -- the list. module Graphics.Vty.Widgets.List ( List , ListItem , ListError(..) , NewItemEvent(..) , RemoveItemEvent(..) , SelectionEvent(..) , ActivateItemEvent(..) -- ** List creation , newStringList , newList , addToList , removeFromList -- ** List manipulation , scrollBy , scrollUp , scrollDown , pageUp , pageDown , onSelectionChange , onItemAdded , onItemRemoved , onItemActivated , activateCurrentItem , clearList -- ** List inspection , getListSize , getSelected ) where import Data.Typeable import Control.Exception hiding (Handler) import Control.Monad import Control.Monad.Trans import Graphics.Vty import Graphics.Vty.Widgets.Core import Graphics.Vty.Widgets.Text import Graphics.Vty.Widgets.Events import Graphics.Vty.Widgets.Util data ListError = BadItemIndex Int -- ^The specified position could not be used to remove -- an item from the list. | ResizeError | BadListWidgetSizePolicy -- ^The type of widgets added to the list grow -- vertically, which is not permitted. deriving (Show, Typeable) instance Exception ListError -- |A list item. Each item contains an arbitrary internal value @a@ -- and a 'Widget' representing it. type ListItem a b = (a, Widget b) data SelectionEvent a b = SelectionOn Int a (Widget b) -- ^An item at the specified position with the -- specified internal value and widget was -- selected. | SelectionOff -- ^No item was selected, which means the -- list is empty. -- |A new item was added to the list at the specified position with -- the specified value and widget. data NewItemEvent a b = NewItemEvent Int a (Widget b) -- |An item was removed from the list at the specified position with -- the specified value and widget. data RemoveItemEvent a b = RemoveItemEvent Int a (Widget b) -- |An item in the list was activated at the specified position with -- the specified value and widget. data ActivateItemEvent a b = ActivateItemEvent Int a (Widget b) -- |The list widget type. Lists are parameterized over the /internal/ -- /value type/ @a@, the type of internal values used to refer to the -- visible representations of the list contents, and the /widget type/ -- @b@, the type of widgets used to represent the list visually. data List a b = List { selectedUnfocusedAttr :: Attr , selectedIndex :: Int -- ^The currently selected list index. , scrollTopIndex :: Int -- ^The start index of the window of visible list -- items. , scrollWindowSize :: Int -- ^The size of the window of visible list items. , listItems :: [ListItem a b] -- ^The items in the list. , selectionChangeHandlers :: Handlers (SelectionEvent a b) , itemAddHandlers :: Handlers (NewItemEvent a b) , itemRemoveHandlers :: Handlers (RemoveItemEvent a b) , itemActivateHandlers :: Handlers (ActivateItemEvent a b) , itemHeight :: Int , itemConstructor :: a -> IO (Widget b) -- ^Function to construct new items } instance Show (List a b) where show lst = concat [ "List { " , "selectedUnfocusedAttr = ", show $ selectedUnfocusedAttr lst , ", selectedIndex = ", show $ selectedIndex lst , ", scrollTopIndex = ", show $ scrollTopIndex lst , ", scrollWindowSize = ", show $ scrollWindowSize lst , ", listItems = <", show $ length $ listItems lst, " items>" , ", itemHeight = ", show $ itemHeight lst , " }" ] newListData :: (MonadIO m) => Attr -- ^The attribute of the selected item -> (a -> IO (Widget b)) -- ^Constructor for new item widgets -> m (List a b) newListData selAttr f = do schs <- newHandlers iahs <- newHandlers irhs <- newHandlers iacths <- newHandlers return $ List { selectedUnfocusedAttr = selAttr , selectedIndex = -1 , scrollTopIndex = 0 , scrollWindowSize = 0 , listItems = [] , selectionChangeHandlers = schs , itemAddHandlers = iahs , itemRemoveHandlers = irhs , itemActivateHandlers = iacths , itemHeight = 0 , itemConstructor = f } -- |Get the length of the list in elements. getListSize :: (MonadIO m) => Widget (List a b) -> m Int getListSize = ((length . listItems) <~~) -- |Remove an element from the list at the specified position. May -- throw 'BadItemIndex'. removeFromList :: (MonadIO m) => Widget (List a b) -> Int -> m (ListItem a b) removeFromList list pos = do st <- getState list let numItems = length $ listItems st oldScr = scrollTopIndex st when (pos < 0 || pos >= numItems) $ throw $ BadItemIndex pos -- Get the item from the list. let (label, w) = listItems st !! pos sel = selectedIndex st newScrollTop = if pos <= oldScr then if oldScr == 0 then oldScr else oldScr - 1 else oldScr -- If that item is currently selected, select a different item. newSelectedIndex = if pos > sel then sel else if pos < sel then if sel == 0 then 0 else sel - 1 else if sel == 0 then if numItems == 1 then (-1) else 0 else if sel == numItems - 1 then sel - 1 else sel updateWidgetState list $ \s -> s { selectedIndex = newSelectedIndex , listItems = take pos (listItems st) ++ drop (pos + 1) (listItems st) , scrollTopIndex = newScrollTop } -- Notify the removal handler. notifyItemRemoveHandler list pos label w -- Notify the selection handler, but only if the position we deleted -- from is the selected position; that means the selection changed. -- -- XXX this should probably be ==, not <=. Do some testing. when (pos <= selectedIndex st) $ notifySelectionHandler list -- Return the removed item. return (label, w) -- |Add an item to the list. Its widget will be constructed from the -- specified internal value using the widget constructor passed to -- 'newList'. addToList :: (MonadIO m, Show b) => Widget (List a b) -> a -> m (ListItem a b) addToList list key = do numItems <- (length . listItems) <~~ list makeWidget <- itemConstructor <~~ list w <- liftIO $ makeWidget key v <- growVertical w when (v) $ throw BadListWidgetSizePolicy h <- case numItems of 0 -> do -- We're adding the first element to the list, so we need -- to compute the item height based on this widget. We -- just render it in an unreasonably large space (since, -- really, list items should never be THAT big) and measure -- the result, assuming that all list widgets will have the -- same size. If you violate this, you'll have interesting -- results! img <- render w (DisplayRegion 100 100) defaultContext return $ fromEnum $ image_height img _ -> itemHeight <~~ list updateWidgetState list $ \s -> s { itemHeight = h , listItems = listItems s ++ [(key, w)] , selectedIndex = if numItems == 0 then 0 else selectedIndex s } notifyItemAddHandler list (numItems + 1) key w when (numItems == 0) $ notifySelectionHandler list return (key, w) -- |Register event handlers to be invoked when the list's selected -- item changes. onSelectionChange :: (MonadIO m) => Widget (List a b) -> (SelectionEvent a b -> IO ()) -> m () onSelectionChange = addHandler (selectionChangeHandlers <~~) -- |Register event handlers to be invoked when a new item is added to -- the list. onItemAdded :: (MonadIO m) => Widget (List a b) -> (NewItemEvent a b -> IO ()) -> m () onItemAdded = addHandler (itemAddHandlers <~~) -- |Register event handlers to be invoked when an item is removed from -- the list. onItemRemoved :: (MonadIO m) => Widget (List a b) -> (RemoveItemEvent a b -> IO ()) -> m () onItemRemoved = addHandler (itemRemoveHandlers <~~) -- |Register event handlers to be invoked when an item is activated, -- which happens when the user presses Enter on a selected element -- while the list has the focus. onItemActivated :: (MonadIO m) => Widget (List a b) -> (ActivateItemEvent a b -> IO ()) -> m () onItemActivated = addHandler (itemActivateHandlers <~~) -- |Clear the list, removing all elements. Does not invoke any -- handlers. clearList :: (MonadIO m) => Widget (List a b) -> m () clearList w = do updateWidgetState w $ \l -> l { selectedIndex = (-1) , scrollTopIndex = 0 , listItems = [] } -- |Create a new list using the specified attribute for the -- currently-selected element when the list does NOT have focus. Use -- the specified constructor function to create widgets for new items -- in the list. newList :: (MonadIO m, Show b) => Attr -- ^The attribute of the selected item -> (a -> IO (Widget b)) -- ^Constructor for new item widgets -> m (Widget (List a b)) newList selAttr f = do list <- newListData selAttr f wRef <- newWidget $ \w -> w { state = list , keyEventHandler = listKeyEvent , growVertical_ = const $ return True , growHorizontal_ = const $ return True , getCursorPosition_ = \this -> do st <- getState this pos <- getCurrentPosition this sz <- getCurrentSize this let newCol = max 0 (region_width pos + region_width sz - 1) newRow = region_height pos + toEnum (max 0 $ selectedIndex st - scrollTopIndex st) return $ Just (pos `withWidth` newCol `withHeight` newRow) , render_ = \this sz ctx -> do -- Get the item height *before* a potential resize, then -- get the list state below, after the resize. h <- itemHeight <~~ this -- Resize the list based on the available space and the -- height of each item. when (h > 0) $ resize this (max 1 ((fromEnum $ region_height sz) `div` h)) listData <- getState this foc <- focused <~ this renderListWidget foc listData sz ctx , setCurrentPosition_ = \this pos -> do ih <- itemHeight <~~ this items <- getVisibleItems this forM_ (zip [0..] items) $ \(i, ((_, iw), _)) -> setCurrentPosition iw (pos `plusHeight` (toEnum $ i * ih)) } return wRef listKeyEvent :: Widget (List a b) -> Key -> [Modifier] -> IO Bool listKeyEvent w KUp _ = scrollUp w >> return True listKeyEvent w KDown _ = scrollDown w >> return True listKeyEvent w KPageUp _ = pageUp w >> return True listKeyEvent w KPageDown _ = pageDown w >> return True listKeyEvent w KEnter _ = activateCurrentItem w >> return True listKeyEvent _ _ _ = return False renderListWidget :: (Show b) => Bool -> List a b -> DisplayRegion -> RenderContext -> IO Image renderListWidget foc list s ctx = do let items = map (\((_, w), sel) -> (w, sel)) $ getVisibleItems_ list defaultAttr = mergeAttrs [ overrideAttr ctx , normalAttr ctx ] renderVisible [] = return [] renderVisible ((w, sel):ws) = do let att = if sel then if foc then focusAttr ctx else mergeAttrs [ selectedUnfocusedAttr list , defaultAttr ] else defaultAttr img <- render w s $ ctx { overrideAttr = att } let actualHeight = min (region_height s) (toEnum $ itemHeight list) img' = img <|> char_fill att ' ' (region_width s - image_width img) actualHeight imgs <- renderVisible ws return (img':imgs) let filler = char_fill defaultAttr ' ' (region_width s) fill_height fill_height = if scrollWindowSize list == 0 then region_height s else toEnum $ ((scrollWindowSize list - length items) * itemHeight list) visible_imgs <- renderVisible items return $ vert_cat (visible_imgs ++ [filler]) -- |A convenience function to create a new list using 'String's as the -- internal values and 'FormattedText' widgets to represent those -- strings. newStringList :: (MonadIO m) => Attr -- ^The attribute of the selected item -> [String] -- ^The list items -> m (Widget (List String FormattedText)) newStringList selAttr labels = do list <- newList selAttr plainText mapM_ (addToList list) labels return list -- |Programmatically activate the currently-selected item in the list, -- if any. activateCurrentItem :: (MonadIO m) => Widget (List a b) -> m () activateCurrentItem wRef = do mSel <- getSelected wRef case mSel of Nothing -> return () Just (pos, (val, w)) -> fireEvent wRef (itemActivateHandlers <~~) $ ActivateItemEvent pos val w -- note that !! here will always succeed because selectedIndex will -- never be out of bounds and the list will always be non-empty. -- |Get the currently-selected list item. getSelected :: (MonadIO m) => Widget (List a b) -> m (Maybe (Int, ListItem a b)) getSelected wRef = do list <- state <~ wRef case selectedIndex list of (-1) -> return Nothing i -> return $ Just (i, (listItems list) !! i) resize :: (MonadIO m) => Widget (List a b) -> Int -> m () resize wRef newSize = do when (newSize == 0) $ throw ResizeError size <- (scrollWindowSize . state) <~ wRef case compare newSize size of EQ -> return () -- Do nothing if the window size isn't changing. GT -> updateWidgetState wRef $ \list -> list { scrollWindowSize = newSize , scrollTopIndex = max 0 (scrollTopIndex list - (newSize - scrollWindowSize list)) } -- Otherwise it's smaller, so we need to look at which item is -- selected and decide whether to change the scrollTopIndex. LT -> do list <- state <~ wRef -- If the currently selected item would be out of view in the -- new size, then we need to move the display top down to keep -- it visible. let newBottomPosition = scrollTopIndex list + newSize - 1 current = selectedIndex list newScrollTopIndex = if current > newBottomPosition then current - newSize + 1 else scrollTopIndex list updateWidgetState wRef $ const $ list { scrollWindowSize = newSize , scrollTopIndex = newScrollTopIndex } -- |Scroll a list up or down by the specified number of positions. -- Scrolling by a positive amount scrolls downward and scrolling by a -- negative amount scrolls upward. This automatically takes care of -- managing internal list state and invoking event handlers. scrollBy :: (MonadIO m) => Widget (List a b) -> Int -> m () scrollBy wRef amount = do updateWidgetState wRef $ scrollBy' amount notifySelectionHandler wRef scrollBy' :: Int -> List a b -> List a b scrollBy' amount list = let sel = selectedIndex list lastPos = (length $ listItems list) - 1 validPositions = [0..lastPos] newPosition = sel + amount newSelected = if newPosition `elem` validPositions then newPosition else if newPosition > lastPos then lastPos else 0 bottomPosition = min (scrollTopIndex list + scrollWindowSize list - 1) ((length $ listItems list) - 1) topPosition = scrollTopIndex list windowPositions = [topPosition..bottomPosition] adjustedTop = if newSelected `elem` windowPositions then topPosition else if newSelected >= bottomPosition then newSelected - scrollWindowSize list + 1 else newSelected in if scrollWindowSize list == 0 then list else list { scrollTopIndex = adjustedTop , selectedIndex = newSelected } notifySelectionHandler :: (MonadIO m) => Widget (List a b) -> m () notifySelectionHandler wRef = do sel <- getSelected wRef case sel of Nothing -> fireEvent wRef (selectionChangeHandlers <~~) SelectionOff Just (pos, (a, b)) -> fireEvent wRef (selectionChangeHandlers <~~) $ SelectionOn pos a b notifyItemRemoveHandler :: (MonadIO m) => Widget (List a b) -> Int -> a -> Widget b -> m () notifyItemRemoveHandler wRef pos k w = fireEvent wRef (itemRemoveHandlers <~~) $ RemoveItemEvent pos k w notifyItemAddHandler :: (MonadIO m) => Widget (List a b) -> Int -> a -> Widget b -> m () notifyItemAddHandler wRef pos k w = fireEvent wRef (itemAddHandlers <~~) $ NewItemEvent pos k w -- |Scroll a list down by one position. scrollDown :: (MonadIO m) => Widget (List a b) -> m () scrollDown wRef = scrollBy wRef 1 -- |Scroll a list up by one position. scrollUp :: (MonadIO m) => Widget (List a b) -> m () scrollUp wRef = scrollBy wRef (-1) -- |Scroll a list down by one page from the current cursor position. pageDown :: (MonadIO m) => Widget (List a b) -> m () pageDown wRef = do amt <- scrollWindowSize <~~ wRef scrollBy wRef amt -- |Scroll a list up by one page from the current cursor position. pageUp :: (MonadIO m) => Widget (List a b) -> m () pageUp wRef = do amt <- scrollWindowSize <~~ wRef scrollBy wRef (-1 * amt) getVisibleItems :: (MonadIO m) => Widget (List a b) -> m [(ListItem a b, Bool)] getVisibleItems wRef = do list <- state <~ wRef return $ getVisibleItems_ list getVisibleItems_ :: List a b -> [(ListItem a b, Bool)] getVisibleItems_ list = let start = scrollTopIndex list stop = scrollTopIndex list + scrollWindowSize list adjustedStop = (min stop $ length $ listItems list) - 1 in [ (listItems list !! i, i == selectedIndex list) | i <- [start..adjustedStop] ]