Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,34 @@ detail, which is where the reasoning lives.
the merge and after it, and applying only the difference. A deck whose history
began before the log keeps it.

- **Images on cards.** A new eleventh deck column names a picture beside the
deck, drawn inside the card frame above the prompt. Terminals that speak the
kitty graphics protocol — kitty, Ghostty, WezTerm — need nothing installed:
the escape sequence names the *file*, so it stays about sixty bytes however
large the picture, which matters on a screen that redraws after every
keypress. Everything else draws the picture as coloured text blocks through
[chafa](https://hpjansson.org/chafa/) when it is installed, which needs no
graphics support at all and so works over `ssh` and inside `tmux`.
`FLASHTERM_IMAGE` overrides the guess with `kitty`, `chafa` or `none`.

Detection reads `$TERM` alone. `$TERM_PROGRAM` and `$KITTY_WINDOW_ID` are
inherited rather than set per session, so they outlive the terminal that set
them and are still present under `tmux`, over `ssh`, or in a screen recorder
— believing them reserved room for a picture that then could not be drawn,
leaving a hole in the card. Terminals that can draw but do not say so in
`$TERM`, such as WezTerm, want `FLASHTERM_IMAGE=kitty`.

PNG, GIF and JPEG are understood, header only, so a large photograph costs
no more to display than a thumbnail. Aspect ratio is preserved and the
picture is fitted to the frame — the terminal stretches to fill whatever box
it is handed, so a panorama would otherwise come out looking twice as wide
as it is.

A deck of pictures is still a deck: on a terminal that cannot draw them, in a
pipe, or with `FLASHTERM_IMAGE=none`, it reviews as ordinary text. A missing
file, or one that is not really an image, quietly becomes a card without a
picture rather than an error.

### Changed

- **CI builds under the sanitizers and with `-Werror`,** as a four-way matrix of
Expand Down
107 changes: 96 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,21 @@ any file from `examples/` — programming languages, tooling and human languages
ls examples/
```

Imported cards arrive in Box 1 and are due immediately. The examples are plain
`question,answer,tags` records, the shortest form of the deck format below, so
they double as a template for writing your own. Several use `|` to accept more
Imported cards arrive in Box 1 and are due immediately. Most of the examples are
plain `question,answer,tags` records, the shortest form of the deck format below,
so they double as a template for writing your own. Several use `|` to accept more
than one answer, which is worth copying: `mañana` really does mean both
*tomorrow* and *morning*, and `git init` should not be marked wrong because you
typed `init`.

`colores.csv` is the exception, and the one to copy if you want [pictures](#images):
each card carries a colour swatch from `examples/images/`. It also runs
**English → Spanish**, against the direction of every other language deck here,
and that is the point rather than an oversight. A picture shows you the
*meaning*, so on a `rojo → red` card it hands you the answer; on `red → rojo` it
cannot, because no picture spells a Spanish word. Put the picture on the side
that asks, not the side that answers.

## Key Features

* **Spaced repetition (Leitner system)** — Flashcards are sorted into 5 boxes. Answering correctly promotes a card to the next box (up to Box 5); answering incorrectly demotes it back to Box 1. You can choose to review specific boxes or prioritize weaker boxes first.
Expand All @@ -92,6 +100,7 @@ typed `init`.
* **Undo and fix in place** — After each answer, `u` takes it back — box, scores and due date restored exactly — and `e` edits the card on the spot, which is when you actually notice a bad question. Editing keeps the prompt open, so you can fix a card and *then* undo the answer it cost you.
* **Custom decks via CLI** — `./FlashTerm vocabulary.txt` loads any deck file; the default is `flashcards.txt`, or whatever `FLASHTERM_DECK` points at.
* **Works with the sync tool you already have** — Decks are plain text and saves are atomic, so Syncthing, Dropbox, `rsync` or git sync a deck between machines with no support needed from FlashTerm. And when two machines review before they sync, `--absorb-conflicts` merges the conflict copy your sync tool left behind back into the review log and puts the scheduling it recorded back on the cards. See [Syncing Between Machines](#syncing-between-machines).
* **Images** — A card can name a picture in the deck's eleventh column, drawn inside the card frame. Terminals that speak the kitty graphics protocol (kitty, Ghostty) need nothing installed at all; everything else draws it as coloured text blocks via [chafa](https://hpjansson.org/chafa/), which works even over `ssh` and inside `tmux`. Aspect ratio is preserved and the picture is fitted to the frame, so a panorama and a portrait both land inside the borders. A deck of pictures still reviews as plain text anywhere that cannot draw them. See [Images](#images).
* **Deck statistics** — Success rates, review counts, a box-by-box mastery breakdown with ASCII bars, automatic flagging of your hardest card, and how much you reviewed today alongside your current daily streak.
* **Review log** — Every answer is appended to a `deck.txt.log` beside the deck: what was asked, which way round, whether you got it, and when, to the second. The card counters say what a card's state *is*; the log says what actually happened, which is what streaks, retention over time and merging two machines' reviews all need. It is append-only, so it never rewrites history and never conflicts.
* **Single-keypress menus** — `2` enters review; no Enter, no waiting. Every screen that takes a key shows a legend of what the keys do. Guarded on `isatty`, so piped input still reads whole lines and every script, pipeline and recording keeps working unchanged. `Ctrl+C` at a menu saves and exits cleanly rather than killing the process.
Expand Down Expand Up @@ -161,10 +170,10 @@ and exit.

## Deck File Format

One CSV record per card, with the last seven fields optional:
One CSV record per card, with the last eight fields optional:

```
question,answer,tags,correct,incorrect,box,last_reviewed,due_date,id,audio
question,answer,tags,correct,incorrect,box,last_reviewed,due_date,id,audio,image
```

Dates are plain `YYYY-MM-DD`, blank when a card has never been reviewed. Answers may list alternatives separated by `|`. Questions and answers containing commas or quotes are quoted normally, so decks stay greppable and editable by hand.
Expand All @@ -176,9 +185,14 @@ not orphan the card's history.

`audio` is a recording of the *question*, as a path relative to the deck file —
so a deck and the audio directory beside it can be moved or synced as one thing.
It is written only when a card has one, which means a deck with no audio comes
out byte for byte as earlier versions wrote it, and syncing between a machine
that has updated and one that has not does not put the whole file in conflict.

`image` is a picture for the card, resolved the same way. See [Images](#images).

Both are written only as far as the last column a card actually uses, which
means a deck with neither comes out byte for byte as earlier versions wrote it,
and syncing between a machine that has updated and one that has not does not put
the whole file in conflict. A card with a picture and no recording still writes
the empty audio column, because position is what names a field in a CSV.

### Review Log Format

Expand Down Expand Up @@ -340,6 +354,74 @@ The command is split on whitespace and run directly — never through a shell
and the text or path is appended as its last argument. A card whose question is
`rm -rf ~` is a card about shell quoting and stays one.

## Images

A card can carry a picture, in the deck's eleventh column:

```
el perro,the dog,animals,0,0,1,,,a1b2c3d4e5f60718,,images/perro.png
```

The path is relative to the deck file, so a deck and the `images/` directory
beside it move, sync and back up as one thing. It is drawn inside the card
frame, above the prompt:

```
┌──────────────────────────────────────────┐
│ Box 1 · new · animals │
├──────────────────────────────────────────┤
│ │
│ ▄▄▄▄▄▄▄▄▄▄▄▄ │
│ █ a picture █ │
│ ▀▀▀▀▀▀▀▀▀▀▀▀ │
│ │
│ el perro │
│ │
└──────────────────────────────────────────┘
```

PNG, GIF and JPEG are understood. Only the header is read — enough to learn the
dimensions — so a large photograph costs no more to display than a thumbnail.

### What your terminal needs

Nothing, on a terminal that speaks the **kitty graphics protocol**: kitty,
Ghostty and WezTerm are drawn to directly, with no library linked and no tool
installed. FlashTerm sends the *path* rather than the picture, so the escape
sequence is about sixty bytes however big the file is — which matters on a
screen that redraws after every keypress.

For anything else, install [chafa](https://hpjansson.org/chafa/) and the picture
is drawn as **coloured text blocks** — which need no graphics support of any
kind, and so work in any terminal at all, including inside `tmux` and over
`ssh`. That is the whole demo GIF above: those trees are text. chafa is optional
in exactly the way `espeak-ng` is — present, pictures; absent, no pictures.

`FLASHTERM_IMAGE` overrides the guess: `kitty`, `chafa`, or `none` to turn
pictures off entirely.

**Set it to `kitty` if your terminal can draw graphics but does not say so in
`$TERM`** — WezTerm is the common case. Detection reads `$TERM` and nothing
else, on purpose. `$TERM_PROGRAM` and `$KITTY_WINDOW_ID` look tempting and are
traps: they are ordinary environment variables, so they are *inherited* and
outlive the terminal that set them. A shell opened inside `tmux`, over `ssh`, or
in a screen recorder still carries them, and believing them means reserving room
for a picture in a terminal that cannot draw one — leaving a hole in the card,
which is worse than showing no picture at all. `$TERM` is replaced per session,
so it tells the truth.

**A deck full of pictures is still a deck.** On a terminal that cannot draw
them, in a pipe, or with `FLASHTERM_IMAGE=none`, the same deck reviews as
ordinary text — the picture is simply not shown, and nothing about the card,
its scheduling or its log changes. The same is true of a card whose image file
has been deleted or is not really an image: it quietly becomes a card without a
picture rather than an error.

**One thing to decide for yourself:** the picture is shown with the *question*,
not held back until the answer. For a visual-vocabulary deck that is the whole
point. For a deck where the picture *is* the answer, do not add one to that
card — there is no per-card setting for which side it belongs to.

## Syncing Between Machines

There is no sync server and no account. A deck is a text file, so the tool you
Expand Down Expand Up @@ -449,8 +531,10 @@ import/export, legacy-deck migration, statistics), the review log (event
round-trips, damaged lines, card ids, streaks, and merging and replaying two
machines' logs), absorbing sync-conflict copies (which names count as one,
finding them, atomic log rewrites, and the differential replay that leaves
pre-log counters alone), text layout (column-accurate word wrapping), and
command-line and environment handling.
pre-log counters alone), images (PNG, GIF and JPEG header parsing including a
JPEG segment walk, aspect-preserving fitting, and protocol selection), text
layout (column-accurate word wrapping), and command-line and environment
handling.

### Golden End-to-End Tests

Expand All @@ -470,7 +554,7 @@ Adding a case means creating a directory under `tests/golden/cases/` with an
`KEY=VALUE` lines, an `audio/` directory for the deck's audio column to point
at, and a `files/` directory whose contents are copied in as they are — which is
how a case ships a review log and the conflict copies beside it, whose names the
sync client invents — and then:
sync client invents, or an `images/` directory for the image column — and then:

```bash
tests/golden/run.sh --update # write the expected transcripts
Expand Down Expand Up @@ -529,6 +613,7 @@ cannot drive an app that insists on a tty.
| `src/audio.*` | Finding a player or synthesiser on the PATH, and running it |
| `src/generate.*` | `--generate-audio`: rendering a deck's recordings in bulk |
| `src/voice.*` | Finding piper voices on disk, and explaining how to get one |
| `src/image.*` | Drawing a card's picture: image headers, aspect fitting, terminal protocols |
| `src/event.*` | The append-only review log: events, ids, timestamps, merge and replay |
| `src/sync.*` | `--absorb-conflicts`: finding a sync client's conflict copies and folding them back in |
| `src/deck.*` | The `Deck` class: load, atomic save, import/export, tags, statistics |
Expand Down
6 changes: 3 additions & 3 deletions demo/demo-deck.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
la biblioteca,library,spanish;vocabulary,3,1,2,2026-08-01,2026-08-04
el árbol,tree,spanish;vocabulary,1,0,1,2026-08-10,2026-08-11
la ciudad,city,spanish;places,5,0,3,2026-08-05,2027-01-05
la biblioteca,library,spanish;vocabulary,3,1,2,2026-08-01,2026-08-04,demo000000000001
el árbol,tree,spanish;vocabulary,1,0,1,2026-08-10,2026-08-11,demo000000000002,,images/arbol.png
la ciudad,city,spanish;places,5,0,3,2026-08-05,2027-01-05,demo000000000003
Binary file modified demo/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 17 additions & 1 deletion demo/demo.tape
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ Set Shell "bash"
# for the key legend that replaced its "press any key" line. A row is roughly
# 23.5px at this font size, which is what to add per new line.
#
# The card frame is now a second thing worth measuring: "el árbol" carries a
# picture, which adds its rows plus one to the frame. It still comes in under
# the dashboard, so the canvas is unchanged -- but a taller picture would not,
# and the card, not the dashboard, would become what sets the height.
#
# 900px is about 89 columns, and the prompt after an answer is 103 once audio is
# offered, so that legend wraps onto two lines here. That is deliberate and not
# a defect to widen the canvas for: it is what an 80-column terminal does too,
Expand All @@ -44,9 +49,20 @@ Set TypingSpeed 75ms

# Build, then work on a throwaway copy in a temp directory: the recording must
# never touch a real deck, and every run has to start from the same state.
#
# TERM_PROGRAM and KITTY_WINDOW_ID are cleared because vhs inherits the
# environment of whatever launched it, and both of those outlive the terminal
# that set them. Recorded from a Ghostty window they arrive here saying
# "Ghostty" about a terminal that is actually xterm.js and cannot draw a kitty
# graphic at all -- so chafa would pick a format nothing renders and the card
# would show an empty hole where its picture belongs. $TERM is the honest
# signal and vhs sets it correctly, so clearing the other two leaves chafa to
# work it out properly and draw the picture as coloured blocks, which is what a
# reader without a graphics terminal actually sees.
Hide
Type "make >/dev/null 2>&1" Enter
Type "DEMO=$(mktemp -d) && cp FlashTerm $DEMO/ && cp demo/demo-deck.txt $DEMO/spanish.txt && cd $DEMO" Enter
Type "DEMO=$(mktemp -d) && cp FlashTerm $DEMO/ && cp demo/demo-deck.txt $DEMO/spanish.txt && cp -R demo/images $DEMO/ && cd $DEMO" Enter
Type "unset TERM_PROGRAM KITTY_WINDOW_ID" Enter
Type "export PS1='$ ' && clear" Enter
Show

Expand Down
Binary file added demo/images/arbol.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions examples/colores.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
red,rojo,spanish;vocabulary;colours,0,0,1,,,6fa142717d43f842,,images/red.png
blue,azul,spanish;vocabulary;colours,0,0,1,,,ef2705418934083f,,images/blue.png
green,verde,spanish;vocabulary;colours,0,0,1,,,25f84da49e96b311,,images/green.png
yellow,amarillo,spanish;vocabulary;colours,0,0,1,,,ac6952ec50e54b79,,images/yellow.png
orange,naranja,spanish;vocabulary;colours,0,0,1,,,c9b72cec68d00055,,images/orange.png
purple,morado,spanish;vocabulary;colours,0,0,1,,,2ca0d558b2d770d7,,images/purple.png
pink,rosa,spanish;vocabulary;colours,0,0,1,,,13dc4fc6600db12e,,images/pink.png
brown,marrón,spanish;vocabulary;colours,0,0,1,,,e9f4550907c74ba0,,images/brown.png
grey,gris,spanish;vocabulary;colours,0,0,1,,,b9738dc91d5ca9c8,,images/grey.png
black,negro,spanish;vocabulary;colours,0,0,1,,,29a97489cacc8bbb,,images/black.png
Binary file added examples/images/black.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/images/blue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/images/brown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/images/green.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/images/grey.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/images/orange.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/images/pink.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/images/purple.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/images/red.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/images/yellow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions src/cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ whole lines when input is piped, so scripting still works.
theme says.
FLASHTERM_VOICES Extra directories to search for piper voices, separated by
colons like PATH. The usual places are searched anyway.
FLASHTERM_IMAGE How to draw a card's picture: "kitty" for terminals that
speak the kitty graphics protocol, "chafa" to draw it as
coloured text blocks instead, or "none" to draw nothing.
Worked out from $TERM otherwise. Worth setting to "kitty"
on a terminal that can manage it but does not say so in
$TERM, WezTerm being the common one. A deck full of
pictures still reviews perfectly well as text, so this is
never required.
FLASHTERM_TTS_RENDER
A synthesiser other than piper for --generate-audio, which
it then uses instead of --voice: "{out}" is replaced with
Expand All @@ -83,6 +91,12 @@ whole lines when input is piped, so scripting still works.
Audio is optional in every sense: nothing is linked against, nothing is
installed, and if none of the above is on the PATH then review simply does not
offer the key. Press "a" during a review to hear the card.

Pictures work the same way. A card's eleventh column names an image beside the
deck, drawn inside the card frame: as a real graphic on terminals that speak
the kitty protocol, and as coloured text blocks through chafa if it is
installed, which needs no graphics support at all. Everywhere else the same
deck is an ordinary deck of text.
)";
}

Expand Down
21 changes: 14 additions & 7 deletions src/deck.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,19 @@ std::string card_to_csv(const Flashcard& card) {
format_date(card.last_reviewed) + "," + format_date(card.due_date) + "," +
escape_csv_field(card.id);

// Written only when the card has one. A deck with no audio -- which is most
// decks -- then comes out byte for byte as every earlier version wrote it.
// That matters because decks are synced between machines as plain files: a
// trailing comma on every line would put the whole deck in conflict the
// first time one machine saved it and the other had not updated yet.
if (card.audio.empty()) return row;
return row + "," + escape_csv_field(card.audio);
// Written only as far as the last column the card actually uses. A deck with
// no audio and no pictures -- which is most decks -- then comes out byte for
// byte as every earlier version wrote it. That matters because decks are
// synced between machines as plain files: a trailing comma on every line
// would put the whole deck in conflict the first time one machine saved it
// and the other had not updated yet.
//
// A card with a picture and no recording still has to write the empty audio
// column, since position is what names a field in a CSV.
if (card.audio.empty() && card.image.empty()) return row;
const std::string with_audio = row + "," + escape_csv_field(card.audio);
if (card.image.empty()) return with_audio;
return with_audio + "," + escape_csv_field(card.image);
}

bool card_from_csv(const std::string& line, Flashcard* out) {
Expand All @@ -71,6 +77,7 @@ bool card_from_csv(const std::string& line, Flashcard* out) {
// recording.
out->id = (fields.size() >= 9) ? trim(fields[8]) : "";
out->audio = (fields.size() >= 10) ? trim(fields[9]) : "";
out->image = (fields.size() >= 11) ? trim(fields[10]) : "";
return true;
}

Expand Down
Loading
Loading