packages feed

ansiform-0.9.0.5: src/Ansiform/Internal/Line.hs

-- | Pure single-line editing state, keys and width-aware windowing.
module Ansiform.Internal.Line (LineState (..), LineKey (..), applyLineKey,
                               visibleLine) where

--------------------------------------------------------------------------------
import           Data.Char (isPrint)
import           Ansiform.Internal.Width (charWidth, textWidth)


-- Line State
--------------------------------------------------------------------------------

-- | An immutable single-line buffer and its cursor.
--   The cursor is a zero-based code-point index.
data LineState = LineState
    { lineText :: String
      -- ^ Current contents of the buffer.
    , lineCursor :: Int
      -- ^ Cursor position as a code-point index into 'lineText'.
    } deriving (Eq, Show)

-- | Editing keys understood by the line editor.
data LineKey
    = LineInsert Char
      -- ^ Insert a printable character at the cursor.
    | LineBackspace
      -- ^ Delete the character before the cursor.
    | LineDelete
      -- ^ Delete the character at the cursor.
    | LineMoveLeft
      -- ^ Move the cursor one code point to the left.
    | LineMoveRight
      -- ^ Move the cursor one code point to the right.
    | LineMoveHome
      -- ^ Move the cursor to the start of the buffer.
    | LineMoveEnd
      -- ^ Move the cursor to the end of the buffer.
    deriving (Eq, Show)


-- Pure Editing
--------------------------------------------------------------------------------

-- | Applies one key to the buffer, returning a new state.
--   Movement stops at the buffer boundaries, deletion at either boundary is a
--   no-op, and non-printable characters are ignored by 'LineInsert'.
applyLineKey :: LineKey -> LineState -> LineState
applyLineKey key (LineState text cursor) = case key of
    LineInsert c
        | isPrint c ->
            let (before, after) = splitAt position text
            in LineState (before ++ (c : after)) (position + 1)
        | otherwise -> LineState text position
    LineBackspace
        | position <= 0 -> LineState text position
        | otherwise ->
            let (before, after) = splitAt position text
            in LineState (init before ++ after) (position - 1)
    LineDelete
        | position >= length text -> LineState text position
        | otherwise ->
            LineState (take position text ++ drop (position + 1) text) position
    LineMoveLeft -> LineState text (max 0 (position - 1))
    LineMoveRight -> LineState text (min (length text) (position + 1))
    LineMoveHome -> LineState text 0
    LineMoveEnd -> LineState text (length text)
  where
    position = max 0 (min (length text) cursor)

-- | Selects the substring and cursor offset that fit in a terminal width.
--   The first argument is the number of columns available to the editor.
--   One column is reserved for the cursor, so a full window leaves the cursor
--   inside the terminal. When the buffer does not fit, the window scrolls so
--   that the cursor stays visible. Widths come from 'charWidth', so wide
--   characters and combining marks keep the cursor aligned.
visibleLine :: Int -> LineState -> (String, Int)
visibleLine width (LineState text cursor) = (window, windowCursor)
  where
    usable = max 1 (width - 1)
    position = max 0 (min (length text) cursor)
    cursorColumn = textWidth (take position text)
    start = windowStart usable cursorColumn text
    afterStart = drop start text
    window = takeWindow usable afterStart
    windowCursor = textWidth (take (position - start) afterStart)

-- Finds the first code point that leaves at most the usable width before the
-- cursor.
windowStart :: Int -> Int -> String -> Int
windowStart usable = go 0
  where
    go index remaining _
        | remaining <= usable = index
    go index remaining (c:cs) =
        go (index + 1) (remaining - charWidth c) cs
    go index _ [] = index

-- Takes code points from the start while they fit in the usable width.
takeWindow :: Int -> String -> String
takeWindow usable = go 0
  where
    go _ [] = []
    go used (c:cs)
        | used + charWidth c > usable = []
        | otherwise = c : go (used + charWidth c) cs