packages feed

ansiform-0.9.0.5: docs/tutorial.md

# Tutorial: a small bookmarks screen

This tutorial builds **Pocket Bookmarks**, a small but complete program. It keeps a list of bookmarks in memory, moves the selection with the keyboard and reads new entries through a form printed inside the page.

The complete program is in `examples/Tutorial.hs` and is compiled by `cabal build all`. Run it with:

```sh
cabal run ansiform-tutorial
```

The keys are `A` to add, `J` and `K` to move the selection, `D` to delete, `E` to edit, and `X` to exit.

## 1. Set up the project

Add `ansiform` to the dependencies of an executable. A minimal Cabal stanza looks like this:

```cabal
executable bookmarks
  main-is: Main.hs
  build-depends: base, ansiform
```

The program has two layers. The application model is a pure value, and the drawing layer writes pages to the terminal. Drawing functions return a `State` that records the page on screen and how many lines each section printed, so the next page can replace the previous one in place.

## 2. Describe the data

Start with the values the program needs. A bookmark has a title and a URL; the model keeps the list and the selected index:

```haskell
data Bookmark = Bookmark
  { bookmarkTitle :: String
  , bookmarkURL :: String
  } deriving (Eq, Show)

data Model = Model
  { modelBookmarks :: [Bookmark]
  , modelSelected :: Int
  } deriving (Eq, Show)

emptyModel :: Model
emptyModel = Model [] 0
```

Updates are pure functions that return a new `Model`. Nothing is mutated:

```haskell
addBookmark :: String -> String -> Model -> Model
addBookmark title url (Model bookmarks _) =
  Model (bookmarks ++ [Bookmark title url]) (length bookmarks)

deleteSelected :: Model -> Model
deleteSelected (Model [] _) = emptyModel
deleteSelected (Model bookmarks selected) =
  Model kept (clampSelection (length kept) selected)
  where
    kept = take selected bookmarks ++ drop (selected + 1) bookmarks

updateSelected :: String -> String -> Model -> Model
updateSelected title url model@(Model bookmarks selected) = case bookmarks of
  [] -> model
  _ -> let index = clampSelection (length bookmarks) selected
       in Model (replaceAt index (Bookmark title url) bookmarks) index

replaceAt :: Int -> a -> [a] -> [a]
replaceAt index value values =
  take index values ++ [value] ++ drop (index + 1) values

selectedBookmark :: Model -> Maybe Bookmark
selectedBookmark (Model bookmarks selected) =
  bookmarkAt (clampSelection (length bookmarks) selected) bookmarks

bookmarkAt :: Int -> [Bookmark] -> Maybe Bookmark
bookmarkAt index bookmarks = case drop index bookmarks of
  [] -> Nothing
  (bookmark:_) -> Just bookmark

moveSelection :: Int -> Model -> Model
moveSelection delta (Model bookmarks selected) =
  Model bookmarks (clampSelection (length bookmarks) (selected + delta))

clampSelection :: Int -> Int -> Int
clampSelection count index
  | count <= 0 = 0
  | otherwise = max 0 (min (count - 1) index)
```

`Model` is the application state. It is separate from the `State` type in `Ansiform.Draw`, which tracks the terminal page.

## 3. Assemble the shared components

The header, footer and rules are used by both pages, so they live in one place. This keeps spacing, colors and wording consistent:

```haskell
theme :: Theme
theme = defaultTheme

urlStyle :: Style
urlStyle = italic white

bookmarkSettings :: Settings
bookmarkSettings = settings [minSpace 2]

header :: Section
header = sectionTop 1
  [ printerSolidLineCenter 0 1 (themeBar theme)
      [ blockSimple "Pocket Bookmarks" (themeHeader theme) ]
  , printerEmpty 1
  , printerLineCenter 4
      [ blockSimple "A small Ansiform tutorial" (themeSubtitle theme) ]
  ]

bottom :: Section
bottom = sectionRule (ruleDefault (themeRule theme))

ruleBody, ruleFooter :: Rule
ruleBody = rule '%' (themeRule theme)
ruleFooter = rule ':' (themeRule theme)
```

`sectionTop 1` prints the header and then one empty line. `printerSolidLineCenter` fills a whole line with the theme bar color and centers the title on top. A `Theme` is a record of `Style` values, so replacing `defaultTheme` changes every component at once.

The URL uses `italic white` so it stays readable on dark terminals. The rules keep `themeRule`, which is black in the default theme; change `urlStyle` or `themeRule` independently if your terminal has a light background.

## 4. Draw the list

A `Page` is a list of sections. The list page has a header, a body, a footer and a bottom rule:

```haskell
listPage :: Model -> Page
listPage model = page [header, listBody model, listFooter, bottom]

listBody :: Model -> Section
listBody model = sectionBodyRule 1 ruleBody
  [ if null (modelBookmarks model)
      then printerLeft 2
        (blockSimple "No bookmarks yet. Press A to add one." (themeMuted theme))
      else printerColumn 2
        [ bookmarkBlock (index == modelSelected model) index bookmark
        | (index, bookmark) <- zip [0 ..] (modelBookmarks model) ]
  ]

bookmarkBlock :: Bool -> Int -> Bookmark -> Block
bookmarkBlock isSelected index (Bookmark title url) =
  blockCycle
    [ (if isSelected then "> " else "  ", style)
    , (show (index + 1) ++ ". ", style)
    , (title, style)
    , ("  " ++ url, urlStyle)
    ]
  where
    style = if isSelected then themeAccent theme else styleDefault
```

`sectionBodyRule 1` prints a rule, one empty line, the body and one empty line after it. `printerColumn` prints one block per line. The empty list gets a helpful message instead of an empty screen.

`blockCycle` builds a `Block` from text fragments with their styles. The selected row changes the marker and the style, so the selection is visible without a separate cursor data type.

The footer is a single line of menu entries:

```haskell
listFooter :: Section
listFooter = sectionBodyRule 1 ruleFooter
  [ printerLineCenter 4
      [ blockMenuStyle "A" "Add" (themeAccent theme)
      , blockMenuStyle "J" "Down" (themeAccent theme)
      , blockMenuStyle "K" "Up" (themeAccent theme)
      , blockMenuStyle "D" "Delete" (themeDanger theme)
      , blockMenuStyle "E" "Edit" (themeAccent theme)
      , blockMenuStyle "X" "Exit" (themeDanger theme) ]
  ]
```

`printerLineCenter` spaces the entries across the line and keeps at least the configured minimum space between them.

## 5. Build the form

The add and edit flows need two values and share the same page. The page reuses the header and bottom, adds a body with two labeled lines and takes its footer instruction as a parameter, so each flow explains its own keys:

```haskell
formPage :: String -> Page
formPage instruction = page [header, formBody, formFooter instruction, bottom]

formBody :: Section
formBody = sectionBodyRule 1 ruleBody
  [ printerLeft 2 (blockSimple "Title: " styleDefault)
  , printerEmpty 1
  , printerLeft 2 (blockSimple "URL:   " styleDefault)
  ]

formFooter :: String -> Section
formFooter instruction = sectionBodyRule 1 ruleFooter
  [ printerLineCenter 4
      [ blockSimple instruction (themeFooter theme) ]
  ]

addInstruction, editInstruction :: String
addInstruction =
  "Type a value and press Enter for each field; empty fields cancel."
editInstruction =
  "Edit the values and press Enter; Esc cancels."
```

Now describe where the input goes. A `Prompt` is placed by **section index** and by **row inside that section**, which is easier to keep correct than counting all the lines below it:

```haskell
fieldColumn :: String -> Offset
fieldColumn label = formNumber (2 + textWidth label)

formPrompts :: [Prompt]
formPrompts =
  [ promptAt 1 2 (fieldColumn "Title: ")
  , promptAt 1 4 (fieldColumn "URL:   ") ]

editPrompts :: Bookmark -> [Prompt]
editPrompts (Bookmark title url) =
  [ promptWithInitial 1 2 (fieldColumn "Title: ") title
  , promptWithInitial 1 4 (fieldColumn "URL:   ") url ]
```

The form body is section `1`. `sectionBodyRule 1` prints a rule on row `0` and an empty line on row `1`, so the title is row `2` and the URL is row `4`. The footer carries the instruction, so the body only reserves the two editable lines. The column is the label offset plus the display width of the label. `textWidth` understands wide characters and combining marks, so the cursor stays aligned even with Unicode labels.

`readForm` draws the page, places the cursor on each prompt in order and returns the typed values:

```haskell
readForm :: Page -> [Prompt] -> State -> IO (State, Either FormError [String])
```

The result is an ADT. `Right values` contains one string per prompt, `Left (PromptOutsidePage prompt)` means the prompt does not fit the drawn page, `Left FormInputEnded` means stdin ended, and `Left FormCancelled` means the user pressed Esc in an editable field. Empty field values are still valid strings; this program decides to cancel the add when either value is empty.

A form without editable fields enables line input once for the whole form. Do not wrap `readForm` in `withLineInput`.

### Editing an existing value

The edit flow uses `promptWithInitial` instead of `promptAt`:

```haskell
promptWithInitial :: Number -> Number -> Offset -> String -> Prompt
```

Each field opens with its current value in the editable buffer and the cursor at the end. The user can backspace, insert and move with the arrows, `Home`, `End` and `Delete`, then press Enter to submit. Esc cancels the whole form. The editor scrolls horizontally when a value is wider than the terminal, so a long URL stays editable. The prompt column and row are the same as in the add form, because both use the same page.

When stdin is not a terminal, `readForm` reads a line per prompt and ignores the initial value, which keeps the program usable in pipes and tests.

## 6. Run the interaction loop

The program starts in immediate input mode, draws the first page and reads one key at a time:

```haskell
main :: IO ()
main = withImmediateInput $ do
  state <- start (listPage emptyModel) bookmarkSettings
  loop emptyModel state

loop :: Model -> State -> IO ()
loop model state = do
  key <- readKeyPress
  case fmap toLower key of
    Nothing -> pure ()
    Just 'a' -> addFlow model state
    Just 'j' -> open (moveSelection 1 model) state
    Just 'k' -> open (moveSelection (-1) model) state
    Just 'd' -> open (deleteSelected model) state
    Just 'e' -> editFlow model state
    Just 'x' -> pure ()
    _ -> loop model state
  where
    open next st = draw (listPage next) st >>= loop next
```

Each action returns a new `Model`, draws the next page and continues the loop. The next `readKeyPress` needs no Enter. It also discards escape sequences, so pressing an arrow key by mistake does not reach the shortcut map as an `A` or `D`.

The add flow combines the form with the model update:

```haskell
addFlow :: Model -> State -> IO ()
addFlow model state = do
  (state', result) <- readForm (formPage addInstruction) formPrompts state
  let next = case result of
        Right [title, url]
          | not (null title) && not (null url) ->
              addBookmark title url model
        _ -> model
  draw (listPage next) state' >>= loop next
```

The edit flow prefills both fields with the selected bookmark and writes the result back:

```haskell
editFlow :: Model -> State -> IO ()
editFlow model state = case selectedBookmark model of
  Nothing -> draw (listPage model) state >>= loop model
  Just bookmark -> do
    (state', result) <- readForm (formPage editInstruction)
                                  (editPrompts bookmark) state
    let next = case result of
          Right [title, url]
            | not (null title) && not (null url) ->
                updateSelected title url model
          _ -> model
    draw (listPage next) state' >>= loop next
```

`readForm` restores the immediate input settings when it returns, so the list keeps responding to single keys. If the form is cancelled or stdin ends, the model is unchanged and the list is drawn again.

## 7. Try it

Run the program:

```sh
cabal run ansiform-tutorial
```

Then:

1. Press `A` to open the form.
2. Type a title and press Enter.
3. Type a URL and press Enter.
4. Use `J` and `K` to move the selection.
5. Press `E` to edit the selected bookmark, change part of a value and press Enter.
6. Press Esc during an edit to cancel it.
7. Press `D` to delete the selected bookmark, or `X` to exit.

The program has no persistence and no error dialogs; it is intentionally small. The important part is the boundary: pure builders describe the screen, the drawing layer prints it, and the loop decides what to draw next.

## 8. Where to go next

- Add persistence by reading and writing a file outside the pure model functions. Keep the builders pure.
- Replace `printerColumn` with `printerTable` or `printerSequenceSel` to experiment with tables and highlighted paragraphs. See [Content](content.md) and [Printers](printers.md).
- Use `renderPage` or `renderPrinter` from [Render](render.md) to capture output in a `String` for golden tests.
- Read [Input](input.md) for the full input API, including editable fields, and [Numbers](number.md) for checked arithmetic.
- Use `Ansiform.Line` directly to embed the pure line editor in a screen with its own layout.
- Copy `examples/Tutorial.hs` into your own project and change the model. The example is compiled with the library, so it stays aligned with the documented API.