vty-ui-1.6: src/Graphics/Vty/Widgets/Edit.hs
{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, CPP #-}
-- |This module provides a text-editing widget. Edit widgets can
-- operate in single- and multi-line modes.
--
-- Edit widgets support the following special keystrokes:
--
-- * Arrow keys to navigate the text
--
-- * @Enter@ - Activate single-line edit widgets or insert new lines
-- into multi-line widgets
--
-- * @Home@ / @Control-a@ - Go to beginning of the current line
--
-- * @End@ / @Control-e@ - Go to end of the current line
--
-- * @Control-k@ - Remove text from the cursor to the end of the line,
-- or remove the line if it is empty
--
-- * @Del@ / @Control-d@ - delete the current character
--
-- * @Backspace@ - delete the previous character
--
-- Edit widgets may be configured with a line limit which limits the
-- number of lines of text the widget will store. It does not provide
-- any limit control on the length of its lines, though.
--
-- Edit widgets support multi-column characters. (For some
-- information, see <http://www.unicode.org/reports/tr11/>.) When the
-- edit widget scrolling reaches a point where a wide character cannot
-- be drawn because it is bisected by the editing window's boundary,
-- it will be replaced with an indicator (\"$\") until the scrolling
-- window is moved enough to reveal the character. This is done to
-- preserve the relative alignment of all of the rows in the widget in
-- the presence of characters of different widths. Note that this is
-- a visual aid only and does not affect the underlying text content
-- of the widget.
module Graphics.Vty.Widgets.Edit
( Edit
, editWidget
, multiLineEditWidget
, getEditText
, getEditCurrentLine
, setEditText
, getEditCursorPosition
, setEditCursorPosition
, setEditLineLimit
, getEditLineLimit
, applyEdit
, onActivate
, onChange
, onCursorMove
#ifdef TESTING
, doClipping
, indicatorChar
#endif
)
where
import Control.Applicative ((<$>))
import Control.Monad
import qualified Data.Text as T
import Graphics.Vty
import Graphics.Vty.Widgets.Core
import Graphics.Vty.Widgets.Events
import Graphics.Vty.Widgets.Util
import Graphics.Vty.Widgets.TextClip
import qualified Graphics.Vty.Widgets.TextZipper as Z
data Edit = Edit { contents :: Z.TextZipper T.Text
, clipRect :: ClipRect
, activateHandlers :: Handlers (Widget Edit)
, changeHandlers :: Handlers T.Text
, cursorMoveHandlers :: Handlers (Int, Int)
, lineLimit :: Maybe Int
}
instance Show Edit where
show e = concat [ "Edit { "
, "contents = ", show $ contents e
, ", lineLimit = ", show $ lineLimit e
, ", clipRect = ", show $ clipRect e
, " }"
]
editWidget' :: IO (Widget Edit)
editWidget' = do
ahs <- newHandlers
chs <- newHandlers
cmhs <- newHandlers
let initSt = Edit { contents = Z.textZipper []
, clipRect = ClipRect { clipLeft = 0
, clipWidth = 0
, clipTop = 0
, clipHeight = 1
}
, activateHandlers = ahs
, changeHandlers = chs
, cursorMoveHandlers = cmhs
, lineLimit = Nothing
}
wRef <- newWidget initSt $ \w ->
w { growHorizontal_ = const $ return True
, growVertical_ =
\this -> do
case lineLimit this of
Just v | v == 1 -> return False
_ -> return True
, getCursorPosition_ = internalGetCursorPosition
, render_ = renderEditWidget
, keyEventHandler = editKeyEvent
}
return wRef
internalGetCursorPosition :: Widget Edit -> IO (Maybe DisplayRegion)
internalGetCursorPosition this = do
st <- getState this
f <- focused <~ this
pos <- getCurrentPosition this
let (cursorRow, _) = Z.cursorPosition (contents st)
Phys offset = physCursorCol st - clipLeft (clipRect st)
newPos = pos
`withWidth` (toEnum ((fromEnum $ region_width pos) + offset))
`plusHeight` (toEnum (cursorRow - (fromEnum $ clipTop $ clipRect st)))
return $ if f then Just newPos else Nothing
renderEditWidget :: Widget Edit -> DisplayRegion -> RenderContext -> IO Image
renderEditWidget this size ctx = do
resize this ( Phys $ fromEnum $ region_height size
, Phys $ fromEnum $ region_width size )
st <- getState this
isFocused <- focused <~ this
let nAttr = mergeAttrs [ overrideAttr ctx
, normalAttr ctx
]
attr = if isFocused then focusAttr ctx else nAttr
clipped = doClipping (Z.getText $ contents st) (clipRect st)
totalAllowedLines = fromEnum $ region_height size
numEmptyLines = lim - length clipped
where
lim = case lineLimit st of
Just v -> min v totalAllowedLines
Nothing -> totalAllowedLines
emptyLines = replicate numEmptyLines ""
lineWidget s = let Phys physLineLength = sum $ chWidth <$> s
in string attr s <|>
char_fill attr ' ' (region_width size - toEnum physLineLength) 1
return $ vert_cat $ lineWidget <$> (clipped ++ emptyLines)
doClipping :: [T.Text] -> ClipRect -> [String]
doClipping ls rect =
let sliced True = [indicatorChar]
sliced False = ""
truncatedLines = clip2d rect ls
in [ sliced lslice ++ (T.unpack r) ++ sliced rslice
| (r, lslice, rslice) <- truncatedLines ]
-- |Convert a logical column number (corresponding to a character) to
-- a physical column number (corresponding to a terminal cell).
toPhysical :: Int -> [Char] -> Phys
toPhysical col line = sum $ chWidth <$> take col line
indicatorChar :: Char
indicatorChar = '$'
-- |Construct a text widget for editing a single line of text.
-- Single-line edit widgets will send activation events when the user
-- presses @Enter@ (see 'onActivate').
editWidget :: IO (Widget Edit)
editWidget = do
wRef <- editWidget'
setNormalAttribute wRef $ style underline
setFocusAttribute wRef $ style underline
setEditLineLimit wRef $ Just 1
return wRef
-- |Construct a text widget for editing multi-line documents.
-- Multi-line edit widgets never send activation events, since the
-- @Enter@ key inserts a new line at the cursor position.
multiLineEditWidget :: IO (Widget Edit)
multiLineEditWidget = do
wRef <- editWidget'
setEditLineLimit wRef Nothing
return wRef
-- |Set the limit on the number of lines for the edit widget.
-- 'Nothing' indicates no limit, while 'Just' indicates a limit of the
-- specified number of lines.
setEditLineLimit :: Widget Edit -> Maybe Int -> IO ()
setEditLineLimit _ (Just v) | v <= 0 = return ()
setEditLineLimit w v = updateWidgetState w $ \st -> st { lineLimit = v }
-- |Get the current line limit, if any, for the edit widget.
getEditLineLimit :: Widget Edit -> IO (Maybe Int)
getEditLineLimit = (lineLimit <~~)
resize :: Widget Edit -> (Phys, Phys) -> IO ()
resize e (newHeight, newWidth) = do
updateWidgetState e $ \st ->
let newRect = (clipRect st) { clipHeight = newHeight
, clipWidth = newWidth
}
(cursorRow, _) = Z.cursorPosition $ contents st
adjusted = updateRect (Phys cursorRow, physCursorCol st) newRect
in st { clipRect = adjusted }
updateWidgetState e $ \s ->
let r = clipRect s
(_, cursorColumn) = Z.cursorPosition $ contents s
curLine = T.unpack $ Z.currentLine $ contents s
(_, _, ri) = clip1d (clipLeft r) (clipWidth r) (T.pack curLine)
newCharLen = if cursorColumn >= 0 && cursorColumn < length curLine
then chWidth $ curLine !! cursorColumn
else Phys 1
newPhysCol = toPhysical cursorColumn curLine
extra = if ri && newPhysCol >= ((clipLeft r) + (clipWidth r) - Phys 1)
then newCharLen - 1
else 0
newLeft = clipLeft (clipRect s) + extra
in s { clipRect = (clipRect s) { clipLeft = newLeft
}
}
-- |Register handlers to be invoked when the edit widget has been
-- ''activated'' (when the user presses Enter while the widget is
-- focused). These handlers will only be invoked when a single-line
-- edit widget is activated; multi-line widgets never generate these
-- events.
onActivate :: Widget Edit -> (Widget Edit -> IO ()) -> IO ()
onActivate = addHandler (activateHandlers <~~)
notifyActivateHandlers :: Widget Edit -> IO ()
notifyActivateHandlers wRef = fireEvent wRef (activateHandlers <~~) wRef
notifyChangeHandlers :: Widget Edit -> IO ()
notifyChangeHandlers wRef = do
s <- getEditText wRef
fireEvent wRef (changeHandlers <~~) s
notifyCursorMoveHandlers :: Widget Edit -> IO ()
notifyCursorMoveHandlers wRef = do
pos <- getEditCursorPosition wRef
fireEvent wRef (cursorMoveHandlers <~~) pos
-- |Register handlers to be invoked when the edit widget's contents
-- change. Handlers will be passed the new contents.
onChange :: Widget Edit -> (T.Text -> IO ()) -> IO ()
onChange = addHandler (changeHandlers <~~)
-- |Register handlers to be invoked when the edit widget's cursor
-- position changes. Handlers will be passed the new cursor position,
-- relative to the beginning of the text (position (0, 0)).
onCursorMove :: Widget Edit -> ((Int, Int) -> IO ()) -> IO ()
onCursorMove = addHandler (cursorMoveHandlers <~~)
-- |Get the current contents of the edit widget. This returns all of
-- the lines of text in the widget, separated by newlines.
getEditText :: Widget Edit -> IO T.Text
getEditText = (((T.intercalate (T.pack "\n")) . Z.getText . contents) <~~)
-- |Get the contents of the current line of the edit widget (the line
-- on which the cursor is positioned).
getEditCurrentLine :: Widget Edit -> IO T.Text
getEditCurrentLine = ((Z.currentLine . contents) <~~)
-- |Set the contents of the edit widget. Newlines will be used to
-- break up the text in multiline widgets. If the edit widget has a
-- line limit, only those lines within the limit will be set.
setEditText :: Widget Edit -> T.Text -> IO ()
setEditText wRef str = do
lim <- lineLimit <~~ wRef
let ls = case lim of
Nothing -> T.lines str
Just l -> take l $ T.lines str
updateWidgetState wRef $ \st -> st { contents = Z.textZipper ls
}
notifyChangeHandlers wRef
-- |Get the edit widget's current cursor position (row, column).
getEditCursorPosition :: Widget Edit -> IO (Int, Int)
getEditCursorPosition = ((Z.cursorPosition . contents) <~~)
-- |Set the cursor position to the specified row and column. Invalid
-- cursor positions will be ignored.
setEditCursorPosition :: (Int, Int) -> Widget Edit -> IO ()
setEditCursorPosition pos = applyEdit (Z.moveCursor pos)
-- |Compute the physical cursor position (column) for the cursor in a
-- given edit widget state. The physical position is relative to the
-- beginning of the current line (i.e., zero, as opposed to the
-- displayStart and related state).
physCursorCol :: Edit -> Phys
physCursorCol s =
let curLine = T.unpack $ Z.currentLine $ contents s
(_, cursorColumn) = Z.cursorPosition $ contents s
in toPhysical cursorColumn curLine
-- |Apply an editing transformation to the edit widget's text. If the
-- transformation modifies the text or the cursor, the appropriate
-- event handlers will be notified. If a line limit is in effect and
-- the transformation violates it, the transformation will be ignored.
applyEdit :: (Z.TextZipper T.Text -> Z.TextZipper T.Text)
-> Widget Edit
-> IO ()
applyEdit f this = do
oldC <- contents <~~ this
updateWidgetState this $ \s ->
let newSt = s { contents = f (contents s) }
in case lineLimit s of
Nothing -> newSt
Just l -> if length (Z.getText $ contents newSt) > l
then s
else newSt
newC <- contents <~~ this
when (Z.getText oldC /= Z.getText newC) $
notifyChangeHandlers this
when (Z.cursorPosition oldC /= Z.cursorPosition newC) $
notifyCursorMoveHandlers this
editKeyEvent :: Widget Edit -> Key -> [Modifier] -> IO Bool
editKeyEvent this k mods = do
let run f = applyEdit f this >> return True
case (k, mods) of
(KASCII 'a', [MCtrl]) -> run Z.gotoBOL
(KASCII 'k', [MCtrl]) -> run Z.killToEOL
(KASCII 'e', [MCtrl]) -> run Z.gotoEOL
(KASCII 'd', [MCtrl]) -> run Z.deleteChar
(KLeft, []) -> run Z.moveLeft
(KRight, []) -> run Z.moveRight
(KUp, []) -> run Z.moveUp
(KDown, []) -> run Z.moveDown
(KBS, []) -> run Z.deletePrevChar
(KDel, []) -> run Z.deleteChar
(KASCII ch, []) -> run (Z.insertChar ch)
(KHome, []) -> run Z.gotoBOL
(KEnd, []) -> run Z.gotoEOL
(KEnter, []) -> do
lim <- lineLimit <~~ this
case lim of
Just 1 -> notifyActivateHandlers this >> return True
_ -> run Z.breakLine
_ -> return False