packages feed

glirc-2.6: src/Client/EditBox.hs

{-# LANGUAGE TemplateHaskell #-}

{-|
Module      : Client.EditBox
Description : Console-mode text box
Copyright   : (c) Eric Mertens, 2016
License     : ISC
Maintainer  : emertens@gmail.com

This module provides support for the text operations important for
providing a text input in the IRC client. It tracks user input
history, tab completion history, and provides many update operations
which are mapped to the keyboard in "Client.EventLoop".

-}

module Client.EditBox
  ( Line(Line)
  , endLine
  , HasLine(..)
  , Content
  , above
  , current
  , below
  , singleLine
  , firstLine
  , EditBox
  , content
  , tabSeed
  , delete
  , backspace
  , home
  , end
  , killHome
  , killEnd
  , killWordBackward
  , killWordForward
  , paste
  , left
  , right
  , leftWord
  , rightWord
  , insert
  , insertString
  , empty
  , earlier
  , later
  , success
  ) where

import           Control.Lens hiding (below)
import           Data.Char

data Line = Line
  { _pos  :: !Int
  , _text :: !String
  }
  deriving (Read, Show)

makeClassy ''Line

emptyLine :: Line
emptyLine = Line 0 ""

endLine :: String -> Line
endLine s = Line (length s) s

-- | Zipper-ish view of the multi-line content of an 'EditBox'.
-- Lines 'above' the 'current' are stored in reverse order.
data Content = Content
  { _above   :: ![String]
  , _current :: !Line
  , _below   :: ![String]
  }
  deriving (Read, Show)

makeLenses ''Content

instance HasLine Content where
  line = current

-- | Default 'Content' value
noContent :: Content
noContent = Content [] emptyLine []

-- | Single line 'Content'.
singleLine :: Line -> Content
singleLine l = Content [] l []

-- | Shifts the first line off of the 'Content', yielding the
-- text of the line and the rest of the content.
shift :: Content -> (String, Content)
shift (Content [] l []) = (view text l, noContent)
shift (Content a@(_:_) l b) = (last a, Content (init a) l b)
shift (Content [] l (b:bs)) = (view text l, Content [] (endLine b) bs)

firstLine :: Content -> String
firstLine (Content a c _) = head (reverse a ++ [_text c])

jumpLeft :: Content -> Content
jumpLeft c
  | view pos c == 0
  , a:as <- view above c
  = over below (cons $ view text c)
  . set text a
  . set above as
  $ c
  | otherwise = set pos 0 c

jumpRight :: Content -> Content
jumpRight c
  | view pos c == len
  , b:bs <- view below c
  = over above (cons $ view text c)
  . set text b
  . set pos len
  . set below bs
  $ c
  | otherwise = set pos len c
 where len = views text length c

-- Move the cursor left, across lines if necessary.
left :: Content -> Content
left c
  | n > 0
  = over (current.pos) (subtract 1) c

  | n == 0
  , a:as <- view above c
  = over below (cons s)
  . set above as
  . set current (endLine a)
  $ c

  | otherwise = c
 where Line n s = view current c

-- Move the cursor right, across lines if necessary.
right :: Content -> Content
right c
  | n < length s
  = over (current.pos) (+1) c

  | n == length s
  , b:bs <- view below c
  = over above (cons s)
  . set below bs
  . set current (Line 0 b)
  $ c

  | otherwise = c
 where Line n s = view current c

-- | Move the cursor left to the previous word boundary.
leftWord :: Content -> Content
leftWord c
  | n == 0
  = case view above c of
      [] -> c
      (a:as) -> leftWord
              . set  current (endLine a)
              . over below (cons txt)
              . set  above as
              $ c
  | otherwise
  = case search of
      [] -> set (current.pos) 0 c
      (i,_):_ -> set (current.pos) (i+1) c
  where
  Line n txt = view current c
  search = dropWhile (isAlphaNum . snd)
         $ dropWhile (not . isAlphaNum . snd)
         $ reverse
         $ take n
         $ zip [0..]
         $ txt

-- | Move the cursor right to the next word boundary.
rightWord :: Content -> Content
rightWord c
  | n == length txt
  = case view below c of
      [] -> c
      (b:bs) -> rightWord
              . set  current (endLine b)
              . over above (cons txt)
              . set  below bs
              $ c
  | otherwise
  = case search of
      [] -> set (current.pos) (length txt) c
      (i,_):_ -> set (current.pos) i c
  where
  Line n txt = view current c
  search = dropWhile (isAlphaNum . snd)
         $ dropWhile (not . isAlphaNum . snd)
         $ drop n
         $ zip [0..]
         $ txt

-- | Delete the character before the cursor.
backspace :: Content -> Content
backspace c
  | n == 0
  = case view above c of
      []   -> c
      a:as -> set above as
            . set current (Line (length a) (a ++ s))
            $ c

  | (preS, postS) <- splitAt (n-1) s
  = set current (Line (n-1) (preS ++ drop 1 postS)) c
 where
 Line n s = view current c

-- | Delete the character after/under the cursor.
delete :: Content -> Content
delete c
  | n == length s
  = case view below c of
      []   -> c
      b:bs -> set below bs
            . set current (Line n (s ++ b))
            $ c

  | (preS, postS) <- splitAt n s
  = set current (Line n (preS ++ drop 1 postS)) c
 where
 Line n s = view current c

insertString :: String -> Content -> Content
insertString ins c = case push (view above c) (preS ++ l) ls of
  (newAbove, newCurrent) -> set above newAbove . set current newCurrent $ c
 where
 l:ls = lines (ins ++ "\n")
 Line n txt = view current c
 (preS, postS) = splitAt n txt

 push stk x []     = (stk, Line (length x) (x ++ postS))
 push stk x (y:ys) = push (x:stk) y ys

data EditBox = EditBox
  { _content :: !Content
  , _history :: ![String]
  , _historyPos :: !Int
  , _yankBuffer :: !(String)
  , _tabSeed :: !(Maybe String)
  }
  deriving (Read, Show)

makeLenses ''EditBox

-- | Default 'EditBox' value
empty :: EditBox
empty = EditBox
  { _content = noContent
  , _history = []
  , _historyPos = -1
  , _yankBuffer = ""
  , _tabSeed = Nothing
  }

instance HasLine EditBox where
  line = content . line

-- | Sets the given string to the yank buffer unless the string is empty.
updateYankBuffer :: String -> EditBox -> EditBox
updateYankBuffer str
  | null str  = id
  | otherwise = set yankBuffer str

-- | Indicate that the contents of the text box were successfully used
-- by the program. This clears the first line of the contents and updates
-- the history.
success :: EditBox -> EditBox
success e
  = over history (cons sent)
  $ set  content c
  $ set  tabSeed Nothing
  $ set  historyPos (-1)
  $ e
 where
 (sent, c) = shift $ view content e

-- | Update the editbox to reflect the earlier element in the history.
earlier :: EditBox -> Maybe EditBox
earlier e =
  do let i = view historyPos e + 1
     x <- preview (history . ix i) e
     return $ set content (singleLine . endLine $ x)
            $ set historyPos i e

-- | Update the editbox to reflect the later element in the history.
later :: EditBox -> Maybe EditBox
later e
  | i <  0 = Nothing
  | i == 0 = Just
           $ set content noContent
           $ set historyPos (-1) e
  | otherwise =
      do x <- preview (history . ix (i-1)) e
         return $ set content (singleLine . endLine $ x)
                $ set historyPos (i-1) e
  where
  i = view historyPos e

-- | Jump the cursor to the beginning of the input.
home :: EditBox -> EditBox
home
  = set tabSeed Nothing
  . over content jumpLeft

-- | Jump the cursor to the end of the input.
end :: EditBox -> EditBox
end
  = set tabSeed Nothing
  . over content jumpRight

-- | Delete all text from the cursor to the end and store it in
-- the yank buffer.
killEnd :: EditBox -> EditBox
killEnd e
  | null kill
  = case view (content.below) e of
      []   -> e
      b:bs -> set (content.below) bs
            $ updateYankBuffer b e
  | otherwise
  = set (content.current) (endLine keep)
  $ updateYankBuffer kill e
  where
  Line n txt = view (content.current) e
  (keep,kill) = splitAt n txt

-- | Delete all text from the cursor to the beginning and store it in
-- the yank buffer.
killHome :: EditBox -> EditBox
killHome e
  | null kill
  = case view (content.above) e of
      []   -> e
      a:as -> set (content.above) as
            . set tabSeed Nothing
            $ updateYankBuffer a e

  | otherwise
  = set (content.current) (Line 0 keep)
  $ set tabSeed Nothing
  $ updateYankBuffer kill e
  where
  Line n txt = view (content.current) e
  (kill,keep) = splitAt n txt

-- | Insert the yank buffer at the cursor.
paste :: EditBox -> EditBox
paste e = over content (insertString (view yankBuffer e)) e

-- | Kill the content from the cursor back to the previous word boundary.
-- When @yank@ is set the yank buffer will be updated.
killWordBackward :: Bool {- ^ yank -} -> EditBox -> EditBox
killWordBackward yank e
  = sometimesUpdateYank
  $ set (content.current) (Line (length l') (l'++r))
  $ e
  where
  Line n txt = view (content.current) e
  (l,r) = splitAt n txt
  (sp,l1) = span  isSpace (reverse l)
  (wd,l2) = break isSpace l1
  l' = reverse l2
  yanked = reverse (sp++wd)

  sometimesUpdateYank
    | yank = updateYankBuffer yanked
    | otherwise = id

-- | Kill the content from the curser forward to the next word boundary.
-- When @yank@ is set the yank buffer will be updated
killWordForward :: Bool {- ^ yank -} -> EditBox -> EditBox
killWordForward yank e
  = sometimesUpdateYank
  $ set (content.current) (Line (length l) (l++r2))
  $ e
  where
  Line n txt = view (content.current) e
  (l,r) = splitAt n txt
  (sp,r1) = span  isSpace r
  (wd,r2) = break isSpace r1
  yanked = sp++wd

  sometimesUpdateYank
    | yank = updateYankBuffer yanked
    | otherwise = id

-- | Insert a character at the cursor and advance the cursor.
insert :: Char -> EditBox -> EditBox
insert c
  = set tabSeed Nothing
  . over content (insertString [c])