Tutorial 45 of 54 · for anyone who has run the Ink counter from 600 · Markdown

Lay out a task list

You'll turn the counter into a task list with a header, a selected row you move with the arrow keys and a footer of keys, all laid out with Ink's flexbox props. It starts from the app you built in 600.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Boxes are flexbox#

A Box lays out its children the way a CSS flex container does. flexDirection="column" stacks them and the default row puts them side by side. width, padding, margin, gap, justifyContent and alignItems mean what they mean in CSS, counted in terminal cells. borderStyle draws a border: single, double, round, bold and classic among others, colored with borderColor.

Two more names help with layout. Spacer takes up the free space in its row, so whatever follows it is pushed to the far edge. Newline breaks the line inside a Text.

Write the list#

Replace the counter with this page:

app/routes/page.tsx
import { useState } from "react";
import { Box, Newline, Spacer, Text, useInput } from "ink";

type Task = { title: string; done: boolean };

const START: Task[] = [
  { title: "Write the README", done: true },
  { title: "Tag a release", done: false },
  { title: "Answer the open issues", done: false },
];

export default function Tasks() {
  const [tasks, setTasks] = useState(START);
  const [selected, setSelected] = useState(0);

  useInput((input, key) => {
    if (key.upArrow && selected > 0) setSelected(selected - 1);
    if (key.downArrow && selected < tasks.length - 1) setSelected(selected + 1);
    if (input === " ") {
      setTasks(tasks.map((task, index) => (index === selected ? { ...task, done: !task.done } : task)));
    }
  });

  const open = tasks.filter((task) => !task.done).length;

  return (
    <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={44}>
      <Box>
        <Text bold>Tasks</Text>
        <Spacer />
        <Text color={open === 0 ? "green" : "yellow"}>{open} open</Text>
      </Box>
      <Box flexDirection="column" marginY={1}>
        {tasks.map((task, index) => (
          <Box key={task.title}>
            <Text color="cyan">{index === selected ? "> " : "  "}</Text>
            <Text bold={index === selected} dimColor={task.done} strikethrough={task.done}>
              {task.title}
            </Text>
          </Box>
        ))}
      </Box>
      <Text dimColor>
        up and down move, space marks one done
        <Newline />
        Ctrl-C quits
      </Text>
    </Box>
  );
}

The header is a row: the title, a Spacer, then the count, which lands against the right border. The rows are a column with a margin above and below. The footer is one Text broken in two by a Newline.

Text takes the styles: color, backgroundColor, bold, italic, underline, strikethrough, inverse and dimColor. A color is a name such as cyan, a hex value such as #ff8800 or rgb(255, 136, 0).

Read the arrow keys#

useInput hands the handler a second argument, key. For a named key input is empty and key says which one it was: upArrow, downArrow, leftArrow, rightArrow, pageUp, pageDown, return, escape, tab, backspace and delete, plus ctrl, shift and meta for the modifiers. Space is a character, so it arrives as input === " ".

The handler only sets state. The list and the count are worked out again from that state on the next render.

Run it#

$ fsr dev app --terminal
╭──────────────────────────────────────────╮
│ Tasks                             2 open │
│                                          │
│ > Write the README                       │
│   Tag a release                          │
│   Answer the open issues                 │
│                                          │
│ up and down move, space marks one done   │
│ Ctrl-C quits                             │
╰──────────────────────────────────────────╯

The first task is done, so it is dimmed and struck through. Press down, space, down, down and space. The last down finds the selection already on the last row and does nothing, because the handler stops there:

╭──────────────────────────────────────────╮
│ Tasks                             0 open │
│                                          │
│   Write the README                       │
│   Tag a release                          │
│ > Answer the open issues                 │
│                                          │
│ up and down move, space marks one done   │
│ Ctrl-C quits                             │
╰──────────────────────────────────────────╯

The count turns green at 0.

Next up: 620. Read the command line.