Tutorial 51 of 54 · for anyone whose terminal tool needs a clock or an exit code · Markdown

Keep time

You'll time a task with a clock a mount effect starts, quit with an exit code a script can test and lay the panes out again when the terminal is resized. It starts from the app you built in 660.

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.

Timers in an effect#

An effect with an empty dependency list is a mount effect: it runs once, after the first render. A setInterval it starts steps the page each time it is due. The callback runs like a handler, so it may call setters. The function the effect returns is its cleanup. clearInterval in it stops the timer when the component goes away.

The page keeps one clock for the whole session. Timing a task records the clock's reading when it starts and subtracts it when it stops.

Add the state#

Add useApp and useStdout to the ink import:

app/routes/page.tsx
import { Box, Spacer, Text, useApp, useFocus, useFocusManager, useInput, useStdout } from "ink";

A task now remembers how long was spent on it:

app/routes/page.tsx
type Task = { title: string; done: boolean; spent?: number };

Add a helper below save that prints seconds as minutes and seconds:

app/routes/page.tsx
function clock(seconds: number) {
  const minutes = Math.floor(seconds / 60);
  return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
}

In Tasks, below runs, add the clock, the task being timed, the terminal's width and exit:

app/routes/page.tsx
const [now, setNow] = useState(0);
const [timing, setTiming] = useState<number | null>(null);
const [startedAt, setStartedAt] = useState(0);
const { stdout } = useStdout();
const [columns, setColumns] = useState(stdout.columns);
const { exit } = useApp();

Below shown, work out the elapsed time and whether the panes stack:

app/routes/page.tsx
const elapsed = timing === selected ? now - startedAt : 0;
const narrow = columns < 60;

Start the clock#

Add two mount effects above the save effect. The first ticks the clock once a second. The second follows the terminal's size:

app/routes/page.tsx
useEffect(() => {
  const tick = setInterval(() => setNow((n) => n + 1), 1000);
  return () => clearInterval(tick);
}, []);

useEffect(() => {
  const onResize = () => setColumns(stdout.columns);
  stdout.on("resize", onResize);
  return () => stdout.off("resize", onResize);
}, []);

setNow((n) => n + 1) adds one to the clock as it stands when the timer fires. Write the updater form in a timer callback, as you would under Ink, since the callback was made on the first render.

stdout.columns and stdout.rows are the terminal's size. stdout.on("resize", f) runs f whenever the size changes and stdout.off in the cleanup takes it off.

Time a task and quit with a code#

Add timed below change:

app/routes/page.tsx
const change = (next: Task[]) => {
  setTasks(next);
  setSaving(true);
};

const timed = () => tasks.map((task, index) => (index === timing ? { ...task, spent: (task.spent ?? 0) + now - startedAt } : task));

timed is the list with the running time added to the task being timed. With no task timed it is the list as it is. Add two branches to the page's handler, after the g line:

app/routes/page.tsx
if (input === "t") {
  if (timing === null) {
    setTiming(selected);
    setStartedAt(now);
  } else {
    change(timed());
    setTiming(null);
  }
}
if (input === "q") {
  save(where, timed());
  exit(open === 0 ? 0 : 1);
}

t starts the timer on the selected task and stops it again. q writes the list at once, since the half-second save from 640 would never fire after the app has gone, then calls exit with a code: 0 when every task is done, 1 while any is open. fsr dev --terminal exits with that code.

Show the time and follow the width#

Each pane takes a stacked prop, the list marks the task being timed and the detail pane shows the time spent. Change the two prop types and the start of each pane:

app/routes/page.tsx
type ListProps = { stacked: boolean; tasks: Task[]; selected: number; timing: number | null; active: boolean; onMove: (by: number) => void; onToggle: () => void };

function ListPane({ stacked, tasks, selected, timing, active, onMove, onToggle }: ListProps) {
app/routes/page.tsx
type DetailProps = { stacked: boolean; task: Task | undefined; elapsed: number; active: boolean; onRemove: () => void };

function DetailPane({ stacked, task, elapsed, active, onRemove }: DetailProps) {

In both panes, the outer Box keeps flexGrow={1} and its basis follows stacked:

app/routes/page.tsx
<Box flexDirection="column" flexGrow={1} flexBasis={stacked ? "auto" : 0} borderStyle="round" borderColor={isFocused ? "cyan" : "gray"} paddingX={1}>

Side by side, a basis of 0 splits the row in halves as before. Stacked, "auto" gives each pane the height of its own lines.

In the list, add the marker after {task.title}:

app/routes/page.tsx
{index === timing ? " (timing)" : ""}

In the detail pane, add a line under the status:

app/routes/page.tsx
<Text>spent {clock((task.spent ?? 0) + elapsed)}</Text>

In Tasks, the outer Box follows the terminal up to 80 columns and the panes stack when it is narrow:

app/routes/page.tsx
<Box flexDirection="column" width={Math.min(columns, 80)}>
app/routes/page.tsx
<Box flexDirection={narrow ? "column" : "row"}>
  <ListPane stacked={narrow} tasks={shown} selected={selected} timing={timing} active={!adding} onMove={move} onToggle={toggle} />
  <DetailPane stacked={narrow} task={shown[selected]} elapsed={elapsed} active={!adding} onRemove={remove} />
</Box>

Change the footer's hint to "t times, q quits".

Run it#

$ fsr dev app --terminal

Press t. The list shows (timing) beside the task and the detail pane counts up. Press t again after a few seconds to stop it, then q:

 Tasks tasks.json                                                        1 open
╭──────────────────────────────────────╮╭──────────────────────────────────────╮
│ > Tag a release                      ││ Tag a release                        │
│   Answer the open issues             ││ open                                 │
│                                      ││ spent 0:04                           │
│                                      ││ x removes it                         │
╰──────────────────────────────────────╯╰──────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────────╮
│ git log                                                                      │
│ 467741c rerun git on g                                                       │
│ 80204f3 add a git pane                                                       │
│ 12d479d save tasks to a file                                                 │
│ 96f7519 start the task list                                                  │
╰──────────────────────────────────────────────────────────────────────────────╯
 t times, q quits                                                      ⠸ saving

The spinner is still running when the app quits. q wrote the file itself, so nothing is lost. One task is open, so the exit code is 1:

$ echo $?
1
$ cat ~/.tasks/tasks.json
[
  {
    "title": "Tag a release",
    "done": false,
    "spent": 4
  },
  {
    "title": "Answer the open issues",
    "done": true
  }
]

The time is counted in whole ticks of the clock, so it can read a second more or less than you waited. Mark every task done and q exits with 0, so a script can run fsr dev app --terminal && echo all done.

Run it again and narrow the terminal to 50 columns. The panes stack:

 Tasks tasks.json                          1 open
╭────────────────────────────────────────────────╮
│ > Tag a release                                │
│   Answer the open issues                       │
╰────────────────────────────────────────────────╯
╭────────────────────────────────────────────────╮
│ Tag a release                                  │
│ open                                           │
│ spent 0:04                                     │
│ x removes it                                   │
╰────────────────────────────────────────────────╯
╭────────────────────────────────────────────────╮
│ git log                                        │
│ 467741c rerun git on g                         │
│ 80204f3 add a git pane                         │
│ 12d479d save tasks to a file                   │
│ 96f7519 start the task list                    │
╰────────────────────────────────────────────────╯
 t times, q quits                           saved

Next up: 680. Bring an Ink tool over.