packages feed

nano-ui-0.1.0.0: lib/NanoUI/Widgets/TextEditor.hs

-- | The editing core every text field shares: a document, a selection and an
-- undo history, changed only by 'TextCommand's. Keys and menu rows map onto
-- commands, and an app can run the same commands on a field.
module NanoUI.Widgets.TextEditor
  ( -- * Commands
    TextCommand (..)
  , TextMotion (..)
    -- * Editors
  , Editor (..)
  , EditorMode (..)
  , singleLineMode
  , multiLineMode
  , editorModeCode
  , editorModeFromCode
  , editorFromBuffer
  , editorSelection
  , hasSelection
  , runCommand
  , runCommandIO
    -- * Key bindings
  , inputTextCommands
  , keyCommand
    -- * History
  , EditHistory (..)
  , EditGroup (..)
  , StoredEdit (..)
  , EditKind (..)
  , emptyHistory
  , sealHistory
  , canUndo
  , canRedo
  ) where

import Control.Monad (void, when)
import Data.Bits ((.&.), (.|.))
import Data.Char (isPrint, isSpace, toLower)
import Data.Text qualified as T
import Data.Text.Short qualified as TS
import NanoUI.Context (Context (..))
import NanoUI.Input (Input (..), Key (..), Modifiers (..))
import NanoUI.Widgets.TextBuffer (Cursor (..), TextBuffer, TextEdit (..))
import NanoUI.Widgets.TextCommand (TextCommand (..), TextMotion (..))
import NanoUI.Widgets.TextBuffer qualified as TB

-- | How a field lets its document be changed.
data EditorMode = EditorMode
  { modeMultiLine :: !Bool
  , modeEditable :: !Bool
  -- ^ Off for selectable labels: only motion, selection and copy apply.
  , modeCopyable :: !Bool
  -- ^ Off for passwords: nothing reaches the clipboard.
  }
  deriving (Eq, Show)

singleLineMode :: EditorMode
singleLineMode = EditorMode {modeMultiLine = False, modeEditable = True, modeCopyable = True}

multiLineMode :: EditorMode
multiLineMode = singleLineMode {modeMultiLine = True}

-- | A mode as a store integer, so a command sent to a widget id between
-- frames knows what kind of field it edits.
editorModeCode :: EditorMode -> Int
editorModeCode m =
  8
    .|. (if modeMultiLine m then 1 else 0)
    .|. (if modeEditable m then 0 else 2)
    .|. (if modeCopyable m then 0 else 4)

editorModeFromCode :: Int -> Maybe EditorMode
editorModeFromCode code
  | code .&. 8 == 0 = Nothing
  | otherwise =
      Just
        EditorMode
          { modeMultiLine = code .&. 1 /= 0
          , modeEditable = code .&. 2 == 0
          , modeCopyable = code .&. 4 == 0
          }

-- | A document with its selection (the cursor is the buffer's, the anchor
-- the other end) and history.
data Editor = Editor
  { editorBuffer :: !TextBuffer
  , editorAnchor :: !Cursor
  , editorHistory :: !EditHistory
  }
  deriving (Show)

editorFromBuffer :: TextBuffer -> Editor
editorFromBuffer buf = Editor buf (TB.getCursor buf) emptyHistory

-- | @(anchor, cursor)@.
editorSelection :: Editor -> (Cursor, Cursor)
editorSelection ed = (editorAnchor ed, TB.getCursor (editorBuffer ed))

hasSelection :: Editor -> Bool
hasSelection ed = editorAnchor ed /= TB.getCursor (editorBuffer ed)

--------------------------------------------------------------------------------
-- History
--------------------------------------------------------------------------------

-- | What started a group of edits, which decides what may join it.
data EditKind = EditTyping | EditDeleting | EditOther
  deriving (Eq, Show)

-- | An edit as history keeps it. Undo steps live for the life of a field and
-- are rarely replayed, so their texts are compact copies: they cost two
-- words less than a 'T.Text', and never keep alive the larger text a slice
-- was cut from.
data StoredEdit = StoredEdit !Cursor !TS.ShortText !TS.ShortText
  deriving (Eq, Show)

-- | Edits undone and redone as one step.
data EditGroup = EditGroup
  { groupKind :: !EditKind
  , groupEdits :: ![StoredEdit]
  -- ^ Newest first.
  , groupBefore :: !(Cursor, Cursor)
  -- ^ Anchor and cursor before the first edit.
  , groupAfter :: !(Cursor, Cursor)
  -- ^ Anchor and cursor after the last edit.
  }
  deriving (Eq, Show)

data EditHistory = EditHistory
  { historyUndo :: ![EditGroup]
  , historyRedo :: ![EditGroup]
  , historyDepth :: !Int
  -- ^ Length of 'historyUndo'.
  , historyOpen :: !Bool
  -- ^ Whether the next edit may join the newest group. Undo, redo, cursor
  -- moves and commands from outside the field close it.
  }
  deriving (Eq, Show)

emptyHistory :: EditHistory
emptyHistory = EditHistory [] [] 0 False

-- | Start the next edit in a group of its own.
sealHistory :: EditHistory -> EditHistory
sealHistory h = h {historyOpen = False}

canUndo :: EditHistory -> Bool
canUndo = not . null . historyUndo

canRedo :: EditHistory -> Bool
canRedo = not . null . historyRedo

-- | Undo steps kept per field. Older steps are dropped in batches.
maxHistoryDepth :: Int
maxHistoryDepth = 500

-- | Record an edit. Typing joins the group before it while the selection is
-- where that group left it, until a new word starts; consecutive deletes
-- join the same way. Anything else starts a group. Recording clears redo.
record :: EditKind -> (Cursor, Cursor) -> TextEdit -> (Cursor, Cursor) -> EditHistory -> EditHistory
record kind before edit after (EditHistory undos _ depth open) =
  case undos of
    g : rest
      | joins g ->
          EditHistory (g {groupEdits = stored : groupEdits g, groupAfter = after} : rest) [] depth True
    _ ->
      let depth' = depth + 1
          group = EditGroup kind [stored] before after
       in if depth' > maxHistoryDepth + 50
            then EditHistory (take maxHistoryDepth (group : undos)) [] maxHistoryDepth True
            else EditHistory (group : undos) [] depth' True
  where
    stored = StoredEdit (editAt edit) (TS.fromText (editRemoved edit)) (TS.fromText (editInserted edit))
    joins g =
      open
        && kind /= EditOther
        && groupKind g == kind
        && groupAfter g == before
        && (kind /= EditTyping || not (startsWord g))
    -- A letter typed after a space starts a new undo step.
    startsWord g = case (groupEdits g, T.uncons (editInserted edit)) of
      (StoredEdit _ _ prevInserted : _, Just (c, _)) -> not (isSpace c) && maybe False (isSpace . snd) (TS.unsnoc prevInserted)
      _ -> False

--------------------------------------------------------------------------------
-- Commands
--------------------------------------------------------------------------------

-- | Run a command that needs no clipboard. 'Cut', 'Copy' and 'Paste' do
-- nothing here; 'runCommandIO' runs them.
runCommand :: EditorMode -> TextCommand -> Editor -> Editor
runCommand mode cmd ed@(Editor buf anchor hist) =
  case cmd of
    InsertText raw
      | modeEditable mode ->
          let txt = singleLine raw
              kind
                | T.length txt == 1 && txt /= "\n" = EditTyping
                | otherwise = EditOther
           in if T.null txt && not (hasSelection ed) then ed else replaceSelection kind txt
    Delete motion
      | modeEditable mode ->
          if hasSelection ed
            then replaceSelection EditDeleting T.empty
            else
              let target = motionTarget motion
               in if target == cursor
                    then ed
                    else edit EditDeleting (TB.replaceEdit T.empty cursor target buf)
    Move motion extend ->
      let moved = moveBuffer motion
       in Editor moved (if extend then anchor else TB.getCursor moved) (sealHistory hist)
    SelectAll ->
      let end = TB.documentEnd buf
       in Editor (TB.withCursor end buf) (Cursor 0 0) (sealHistory hist)
    Select a c ->
      Editor (TB.withCursor c buf) (TB.clampCursor buf a) (sealHistory hist)
    Replace a b txt
      | modeEditable mode -> edit EditOther (TB.replaceEdit (singleLine txt) a b buf)
    ReplaceAll txt
      | modeEditable mode ->
          edit EditOther (TB.replaceEdit (singleLine txt) (Cursor 0 0) (TB.documentEnd buf) buf)
    Undo -> case historyUndo hist of
      g : rest ->
        let buf' = foldl (\b e -> TB.applyEdit (TB.invertEdit (replayed e)) b) buf (groupEdits g)
            (a, c) = groupBefore g
         in Editor (TB.withCursor c buf') a hist {historyUndo = rest, historyRedo = g : historyRedo hist, historyDepth = historyDepth hist - 1, historyOpen = False}
      [] -> ed
    Redo -> case historyRedo hist of
      g : rest ->
        let buf' = foldr (TB.applyEdit . replayed) buf (groupEdits g)
            (a, c) = groupAfter g
         in Editor (TB.withCursor c buf') a hist {historyUndo = g : historyUndo hist, historyRedo = rest, historyDepth = historyDepth hist + 1, historyOpen = False}
      [] -> ed
    _ -> ed
  where
    cursor = TB.getCursor buf
    replayed (StoredEdit at removed inserted) = TextEdit at (TS.toText removed) (TS.toText inserted)
    singleLine = (if modeMultiLine mode then id else T.filter (/= '\n')) . TB.insertableText
    replaceSelection kind txt = edit kind (TB.replaceEdit txt anchor cursor buf)
    edit kind e
      | editRemoved e == editInserted e = ed
      | otherwise =
      let buf' = TB.applyEdit e buf
          end = TB.getCursor buf'
       in Editor buf' end (record kind (anchor, cursor) e (end, end) hist)
    motionTarget = \case
      -- Deleting to the end of a line from its end takes the line break, so
      -- Ctrl+K keeps making progress.
      LineEnd | T.length (TB.lineAt (cursorRow cursor) buf) == cursorCol cursor -> TB.getCursor (TB.moveRight buf)
      motion -> TB.getCursor (moveBuffer motion)
    moveBuffer = \case
      CharLeft -> TB.moveLeft buf
      CharRight -> TB.moveRight buf
      WordLeft -> TB.moveWordLeft buf
      WordRight -> TB.moveWordRight buf
      LineStart -> TB.moveToBOL buf
      LineEnd -> TB.moveToEOL buf
      LineUp -> if modeMultiLine mode then TB.moveUp buf else buf
      LineDown -> if modeMultiLine mode then TB.moveDown buf else buf
      DocumentStart -> TB.moveToTop buf
      DocumentEnd -> TB.moveToBottom buf

-- | 'runCommand', with the clipboard commands going through the context's
-- clipboard.
runCommandIO :: Context -> EditorMode -> TextCommand -> Editor -> IO Editor
runCommandIO ctx mode cmd ed =
  case cmd of
    Copy -> ed <$ copySelection
    Cut
      | modeEditable mode && modeCopyable mode -> do
          copySelection
          pure (runCommand mode (Delete CharRight) (if hasSelection ed then ed else runCommand mode SelectAll ed))
      | otherwise -> pure ed
    Paste
      | modeEditable mode -> do
          clip <- ctxClipboardGet ctx
          pure (maybe ed (\txt -> runCommand mode (InsertText txt) ed) clip)
      | otherwise -> pure ed
    _ -> pure (runCommand mode cmd ed)
  where
    -- Copy without a selection takes the whole field.
    copySelection = when (modeCopyable mode) $ do
      let (a, c) = editorSelection ed
          buf = editorBuffer ed
          txt = if a /= c then TB.selectedText a c buf else TB.toText buf
      when (not (T.null txt)) $ void (ctxClipboardSet ctx txt)

-- | The command a key runs. Ctrl or Alt turns character and deletion keys
-- into word motions, and Shift extends the selection.
keyCommand :: EditorMode -> Modifiers -> Key -> Maybe TextCommand
keyCommand mode mods key =
  case key of
    KeyBackspace -> Just (Delete (if word then WordLeft else CharLeft))
    KeyDelete -> Just (Delete (if word then WordRight else CharRight))
    KeyLeft -> move (if word then WordLeft else CharLeft)
    KeyRight -> move (if word then WordRight else CharRight)
    KeyHome -> move (if modCtrl mods && multi then DocumentStart else LineStart)
    KeyEnd -> move (if modCtrl mods && multi then DocumentEnd else LineEnd)
    KeyUp | multi && not word -> move LineUp
    KeyDown | multi && not word -> move LineDown
    KeyEnter | multi && not word -> Just (InsertText "\n")
    _ -> Nothing
  where
    multi = modeMultiLine mode
    word = modCtrl mods || modAlt mods
    move m = Just (Move m (modShift mods))

-- | This frame's typing and keys as commands, typed characters first. Ctrl
-- turns characters into shortcuts. Ctrl with Alt is AltGr on many layouts, so
-- its characters are typed like plain ones.
inputTextCommands :: EditorMode -> Input -> [TextCommand]
inputTextCommands mode inp = T.foldr char keys (inputChars inp)
  where
    mods = inputModifiers inp
    shortcut = modCtrl mods && not (modAlt mods)
    char c rest
      | shortcut = maybe rest (: rest) (ctrlCharCommand mode mods c)
      | isPrint c = InsertText (T.singleton c) : rest
      | otherwise = rest
    keys = foldr (\k rest -> maybe rest (: rest) (keyCommand mode mods k)) [] (inputKeys inp)

-- | The command a character typed with Ctrl runs. Letters may arrive as the
-- letter or as their control code.
ctrlCharCommand :: EditorMode -> Modifiers -> Char -> Maybe TextCommand
ctrlCharCommand mode mods c =
  case toLower c of
    'a' -> Just SelectAll
    'c' -> Just Copy
    'x' -> Just Cut
    'v' -> Just Paste
    'z' | modShift mods || c == 'Z' -> Just Redo
    'z' -> Just Undo
    'y' -> Just Redo
    'k' | multi -> Just (Delete LineEnd)
    'u' | multi -> Just (Delete LineStart)
    'e' | multi -> Just (Move LineEnd False)
    '\x01' -> Just SelectAll
    '\x03' -> Just Copy
    '\x18' -> Just Cut
    '\x16' -> Just Paste
    '\x1a' -> Just Undo
    '\x19' -> Just Redo
    '\v' | multi -> Just (Delete LineEnd)
    '\NAK' | multi -> Just (Delete LineStart)
    '\ENQ' | multi -> Just (Move LineEnd False)
    _ -> Nothing
  where
    multi = modeMultiLine mode