diff --git a/CHANGELOG.md b/CHANGELOG.md index 70021d1..bed157f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5ee92ad..c32b73a 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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. @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 | diff --git a/demo/demo-deck.txt b/demo/demo-deck.txt index 2a8ef27..b978ae2 100644 --- a/demo/demo-deck.txt +++ b/demo/demo-deck.txt @@ -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 diff --git a/demo/demo.gif b/demo/demo.gif index 0738e70..f55ea09 100644 Binary files a/demo/demo.gif and b/demo/demo.gif differ diff --git a/demo/demo.tape b/demo/demo.tape index 45419c1..5766f36 100644 --- a/demo/demo.tape +++ b/demo/demo.tape @@ -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, @@ -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 diff --git a/demo/images/arbol.png b/demo/images/arbol.png new file mode 100644 index 0000000..2105e7f Binary files /dev/null and b/demo/images/arbol.png differ diff --git a/examples/colores.csv b/examples/colores.csv new file mode 100644 index 0000000..ffca8f8 --- /dev/null +++ b/examples/colores.csv @@ -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 diff --git a/examples/images/black.png b/examples/images/black.png new file mode 100644 index 0000000..bd7f8f9 Binary files /dev/null and b/examples/images/black.png differ diff --git a/examples/images/blue.png b/examples/images/blue.png new file mode 100644 index 0000000..6b988d3 Binary files /dev/null and b/examples/images/blue.png differ diff --git a/examples/images/brown.png b/examples/images/brown.png new file mode 100644 index 0000000..dc0fde1 Binary files /dev/null and b/examples/images/brown.png differ diff --git a/examples/images/green.png b/examples/images/green.png new file mode 100644 index 0000000..408de06 Binary files /dev/null and b/examples/images/green.png differ diff --git a/examples/images/grey.png b/examples/images/grey.png new file mode 100644 index 0000000..84c23f5 Binary files /dev/null and b/examples/images/grey.png differ diff --git a/examples/images/orange.png b/examples/images/orange.png new file mode 100644 index 0000000..61b624b Binary files /dev/null and b/examples/images/orange.png differ diff --git a/examples/images/pink.png b/examples/images/pink.png new file mode 100644 index 0000000..f720ef0 Binary files /dev/null and b/examples/images/pink.png differ diff --git a/examples/images/purple.png b/examples/images/purple.png new file mode 100644 index 0000000..6a6d9b1 Binary files /dev/null and b/examples/images/purple.png differ diff --git a/examples/images/red.png b/examples/images/red.png new file mode 100644 index 0000000..f13c668 Binary files /dev/null and b/examples/images/red.png differ diff --git a/examples/images/yellow.png b/examples/images/yellow.png new file mode 100644 index 0000000..5af7cbe Binary files /dev/null and b/examples/images/yellow.png differ diff --git a/src/cli.cpp b/src/cli.cpp index 364f267..a068bae 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -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 @@ -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. )"; } diff --git a/src/deck.cpp b/src/deck.cpp index bfea9d7..56e1ebf 100644 --- a/src/deck.cpp +++ b/src/deck.cpp @@ -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) { @@ -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; } diff --git a/src/deck.h b/src/deck.h index d0fc06b..08c628c 100644 --- a/src/deck.h +++ b/src/deck.h @@ -8,7 +8,8 @@ namespace FlashTerm { // One card as a CSV record: -// 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 // Trailing columns are optional when reading, so a deck written by any earlier // version still loads; writing always emits all of them. std::string card_to_csv(const Flashcard& card); @@ -61,6 +62,11 @@ class Deck { return resolve(card.audio); } + // Likewise for its picture, so a deck and the images beside it move as one. + std::string image_path(const Flashcard& card) const { + return resolve(card.image); + } + // The review log lives beside the deck file and is loaded along with it: it // is part of the deck's representation on disk, not a separate thing the // caller has to know about. Appends go straight to the file, so unlike the diff --git a/src/flashcard.h b/src/flashcard.h index a2ab333..e50f92c 100644 --- a/src/flashcard.h +++ b/src/flashcard.h @@ -29,6 +29,11 @@ class Flashcard { // means the question gets spoken by a synthesiser instead; see audio.h. std::string audio; + // A picture for the card, resolved the same way `audio` is. Empty is the + // normal case; a card that names one shows it inside the frame on terminals + // that can draw it, and is an ordinary card everywhere else. See image.h. + std::string image; + Flashcard(const std::string& q, const std::string& a, const std::vector& t = {}, int correct = 0, int incorrect = 0, int leitner = 1); diff --git a/src/image.cpp b/src/image.cpp new file mode 100644 index 0000000..2581417 --- /dev/null +++ b/src/image.cpp @@ -0,0 +1,378 @@ +#include "image.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "audio.h" +#include "text.h" + +namespace FlashTerm { +namespace image { +namespace { + +// Cells are about twice as tall as they are wide in every terminal font worth +// the name. Used when the terminal will not say, which is the common case. +constexpr double kDefaultCellAspect = 2.0; + +// Enough for every header this reads: PNG needs 24 bytes, GIF 10, and JPEG +// wants to walk its segments but never far in practice. +constexpr std::size_t kHeaderBytes = 4096; + +std::string header_of(const std::string& path) { + std::ifstream file(path, std::ios::binary); + if (!file.is_open()) return {}; + std::string bytes(kHeaderBytes, '\0'); + file.read(&bytes[0], static_cast(kHeaderBytes)); + bytes.resize(static_cast(file.gcount())); + return bytes; +} + +std::uint8_t byte_at(const std::string& bytes, std::size_t index) { + return static_cast(bytes[index]); +} + +int big_endian_32(const std::string& bytes, std::size_t at) { + return static_cast((static_cast(byte_at(bytes, at)) << 24) | + (static_cast(byte_at(bytes, at + 1)) << 16) | + (static_cast(byte_at(bytes, at + 2)) << 8) | + static_cast(byte_at(bytes, at + 3))); +} + +int big_endian_16(const std::string& bytes, std::size_t at) { + return (byte_at(bytes, at) << 8) | byte_at(bytes, at + 1); +} + +int little_endian_16(const std::string& bytes, std::size_t at) { + return (byte_at(bytes, at + 1) << 8) | byte_at(bytes, at); +} + +bool starts_with(const std::string& bytes, const char* magic, std::size_t len) { + return bytes.size() >= len && std::memcmp(bytes.data(), magic, len) == 0; +} + +// The IHDR chunk is mandatory and must come first, so the dimensions are at a +// fixed offset rather than somewhere that has to be searched for. +Size png_size(const std::string& bytes) { + if (bytes.size() < 24) return {}; + Size size; + size.width = big_endian_32(bytes, 16); + size.height = big_endian_32(bytes, 20); + return size; +} + +Size gif_size(const std::string& bytes) { + if (bytes.size() < 10) return {}; + Size size; + size.width = little_endian_16(bytes, 6); + size.height = little_endian_16(bytes, 8); + return size; +} + +// JPEG keeps its dimensions in whichever start-of-frame segment it happens to +// use, so the segment chain has to be walked. The frame markers are C0-CF +// except C4, C8 and CC, which are Huffman and arithmetic tables that merely +// look like frames. +Size jpeg_size(const std::string& bytes) { + std::size_t at = 2; + while (at + 9 < bytes.size()) { + if (byte_at(bytes, at) != 0xFF) return {}; + const std::uint8_t marker = byte_at(bytes, at + 1); + if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && + marker != 0xCC) { + Size size; + size.height = big_endian_16(bytes, at + 5); + size.width = big_endian_16(bytes, at + 7); + return size; + } + const int length = big_endian_16(bytes, at + 2); + if (length < 2) return {}; + at += 2 + static_cast(length); + } + return {}; +} + +std::string environment(const char* name) { + const char* value = std::getenv(name); + return (value == nullptr) ? std::string() : value; +} + +// Terminals that speak the kitty graphics protocol. +// +// $TERM and nothing else, which is a deliberate narrowing rather than an +// oversight. $TERM is replaced for each session -- a multiplexer, an ssh +// session or a nested terminal all set their own -- while $TERM_PROGRAM and +// $KITTY_WINDOW_ID are ordinary environment variables that are *inherited* +// and outlive the terminal that set them. Reading those is how a session +// running under something else entirely still claims to be Ghostty. +// +// Getting this wrong in the optimistic direction is the expensive mistake: the +// frame reserves room for a picture, the escape sequence is ignored by a +// terminal that never understood it, and the card is left with a hole in it. +// A card with no picture is fine; a card with a gap where one should be is +// not. So terminals whose $TERM says nothing useful -- WezTerm defaults to +// plain xterm-256color -- are expected to say so with $FLASHTERM_IMAGE. +bool terminal_speaks_kitty() { + const std::string term = to_lowercase(environment("TERM")); + return term.find("kitty") != std::string::npos || + term.find("ghostty") != std::string::npos; +} + +const char kBase64Alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// The protocol carries its payload in base64, and the payload here is a file +// path -- so this never sees more than a few hundred bytes and does not need +// to be clever. +std::string base64(const std::string& input) { + std::string out; + out.reserve((input.size() + 2) / 3 * 4); + for (std::size_t i = 0; i < input.size(); i += 3) { + const std::uint32_t a = static_cast(input[i]); + const bool has_b = i + 1 < input.size(); + const bool has_c = i + 2 < input.size(); + const std::uint32_t b = has_b ? static_cast(input[i + 1]) : 0; + const std::uint32_t c = has_c ? static_cast(input[i + 2]) : 0; + const std::uint32_t triple = (a << 16) | (b << 8) | c; + + out += kBase64Alphabet[(triple >> 18) & 0x3F]; + out += kBase64Alphabet[(triple >> 12) & 0x3F]; + out += has_b ? kBase64Alphabet[(triple >> 6) & 0x3F] : '='; + out += has_c ? kBase64Alphabet[triple & 0x3F] : '='; + } + return out; +} + +// Runs `command` and returns everything it wrote to standard output. +// +// Captured rather than inherited, unlike the audio commands, because chafa's +// output is text that has to be *placed* rather than bytes that can simply be +// let through -- see draw_with_chafa. Nothing is written to the child's stdin, +// so filling the pipe cannot deadlock: the only reader is this loop. +bool capture(const audio::Command& command, std::string* output) { + if (!audio::runnable(command)) return false; + + int fds[2]; + if (pipe(fds) != 0) return false; + + std::vector argv; + argv.reserve(command.size() + 1); + for (const std::string& word : command) { + argv.push_back(const_cast(word.c_str())); + } + argv.push_back(nullptr); + + const pid_t pid = fork(); + if (pid < 0) { + close(fds[0]); + close(fds[1]); + return false; + } + if (pid == 0) { + close(fds[0]); + dup2(fds[1], STDOUT_FILENO); + close(fds[1]); + // A converter must not read the keystrokes meant for the review, and its + // complaints must not land in the middle of the card. + const int null = open("/dev/null", O_RDWR); + if (null >= 0) { + dup2(null, STDIN_FILENO); + dup2(null, STDERR_FILENO); + if (null > STDERR_FILENO) close(null); + } + execvp(argv[0], argv.data()); + _exit(127); + } + + close(fds[1]); + char buffer[4096]; + ssize_t got = 0; + while ((got = read(fds[0], buffer, sizeof(buffer))) != 0) { + if (got < 0) { + if (errno == EINTR) continue; + break; + } + output->append(buffer, static_cast(got)); + } + close(fds[0]); + + int status = 0; + while (waitpid(pid, &status, 0) < 0) { + if (errno != EINTR) return false; + } + return WIFEXITED(status) && WEXITSTATUS(status) == 0 && !output->empty(); +} + +// Draws through chafa, as coloured unicode blocks. +// +// The format is forced rather than left to chafa, which is the opposite of +// what it looks like it should be. Left alone, chafa picks the kitty protocol +// astonishingly readily -- it does so even with $TERM set to xterm-256color +// and its output going down a pipe -- and this path exists precisely for the +// terminals that cannot draw one. Its guess being wrong shows up as a hole in +// the card where the picture belongs, which is the worst outcome available. +// "symbols" is the one answer that is right everywhere. +// +// Which makes the picture ordinary text, several lines of it, and text has to +// be *placed*: every line after the first would otherwise begin at column zero +// and write straight through the frame's left border. So each line is put +// where it belongs rather than simply let through. +bool draw_with_chafa(const std::string& path, const Placement& where, + int indent, std::ostream& out) { + const std::string size = + std::to_string(where.columns) + "x" + std::to_string(where.rows); + std::string rendered; + if (!capture({"chafa", "--format=symbols", "--animate=off", "--size=" + size, + path}, + &rendered)) { + return false; + } + + // chafa brackets its output with "hide cursor" and "show cursor". Both have + // to go: the trailing one sits on a line of its own and would otherwise be + // counted as one more row than the frame reserved, overrunning the border -- + // and hiding the cursor on every redraw of a review would flicker it. + for (const char* control : {"\033[?25l", "\033[?25h"}) { + for (std::size_t at = rendered.find(control); at != std::string::npos; + at = rendered.find(control, at)) { + rendered.erase(at, std::strlen(control)); + } + } + + const std::vector lines = split(rendered, '\n'); + int drawn = 0; + for (const std::string& line : lines) { + // chafa ends with a newline, so the last piece is empty and is not a row. + if (line.empty()) continue; + if (drawn > 0) { + // Down one and back to the picture's own left edge. Never a bare "\n", + // which would scroll the screen if the frame happened to reach the + // bottom of it and take the whole card up with it. + out << "\r\033[1B"; + if (indent > 0) out << "\033[" << indent << "C"; + } + out << line; + ++drawn; + } + // Back to the row this started on, so that both ways of drawing leave the + // cursor in the same place and the caller needs to know which was used. + if (drawn > 1) out << "\r\033[" << (drawn - 1) << "A"; + out.flush(); + return drawn > 0; +} +} // namespace + +Size read_size(const std::string& path) { + if (path.empty()) return {}; + const std::string bytes = header_of(path); + if (bytes.size() < 10) return {}; + + Size size; + if (starts_with(bytes, "\x89PNG\r\n\x1a\n", 8)) { + size = png_size(bytes); + } else if (starts_with(bytes, "GIF87a", 6) || starts_with(bytes, "GIF89a", 6)) { + size = gif_size(bytes); + } else if (starts_with(bytes, "\xFF\xD8", 2)) { + size = jpeg_size(bytes); + } + // A header that parses to nonsense is treated as no picture rather than + // trusted: every later calculation divides by these. + if (!size.valid()) return {}; + return size; +} + +Protocol detect() { + const std::string asked = to_lowercase(trim(environment("FLASHTERM_IMAGE"))); + if (asked == "none" || asked == "off") return Protocol::kNone; + if (asked == "kitty") return Protocol::kKitty; + if (asked == "chafa") return Protocol::kChafa; + + if (terminal_speaks_kitty()) return Protocol::kKitty; + if (audio::runnable({"chafa"})) return Protocol::kChafa; + return Protocol::kNone; +} + +bool available() { return detect() != Protocol::kNone; } + +std::string protocol_name() { + switch (detect()) { + case Protocol::kKitty: return "kitty"; + case Protocol::kChafa: return "chafa"; + case Protocol::kNone: break; + } + return "none"; +} + +double cell_aspect() { + winsize window{}; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &window) == 0 && window.ws_col > 0 && + window.ws_row > 0 && window.ws_xpixel > 0 && window.ws_ypixel > 0) { + const double cell_width = + static_cast(window.ws_xpixel) / window.ws_col; + const double cell_height = + static_cast(window.ws_ypixel) / window.ws_row; + if (cell_width > 0.0) return cell_height / cell_width; + } + return kDefaultCellAspect; +} + +Placement fit(const Size& size, int max_columns, int max_rows, + double aspect) { + if (!size.valid() || max_columns <= 0 || max_rows <= 0 || aspect <= 0.0) { + return {}; + } + + // Columns the picture would need if it were given every row on offer. The + // cell aspect is what turns a ratio of pixels into a ratio of cells. + const double ratio = static_cast(size.width) / size.height; + const double wanted = max_rows * ratio * aspect; + + Placement where; + if (wanted <= max_columns) { + where.columns = static_cast(std::lround(wanted)); + where.rows = max_rows; + } else { + // Too wide for the frame, so width becomes the constraint and the picture + // gets fewer rows than it was offered. + where.columns = max_columns; + where.rows = static_cast(std::lround(max_columns / (ratio * aspect))); + } + // Rounding can take either side to zero for an extreme ratio, and a box with + // no area draws nothing at all. + if (where.columns < 1) where.columns = 1; + if (where.rows < 1) where.rows = 1; + return where; +} + +bool draw(const std::string& path, const Placement& where, int indent, + std::ostream& out) { + if (path.empty() || where.empty()) return false; + + switch (detect()) { + case Protocol::kNone: + return false; + case Protocol::kChafa: + return draw_with_chafa(path, where, indent, out); + case Protocol::kKitty: + break; + } + + // a=T transmit and display, f=100 the file is a PNG-or-whatever the terminal + // can decode, t=f the payload is a path rather than the picture itself, + // C=1 leave the cursor alone. + out << "\033_Ga=T,f=100,t=f,C=1,c=" << where.columns << ",r=" << where.rows + << ";" << base64(path) << "\033\\"; + out.flush(); + return true; +} +} // namespace image +} // namespace FlashTerm diff --git a/src/image.h b/src/image.h new file mode 100644 index 0000000..75d0169 --- /dev/null +++ b/src/image.h @@ -0,0 +1,102 @@ +#pragma once +#include +#include + +namespace FlashTerm { +namespace image { + +// Drawing a picture inside the card frame. +// +// Shaped by three things the terminal will not do for you, each established by +// experiment rather than by reading a specification: +// +// * A terminal that speaks the kitty graphics protocol scales an image into +// a box of cells you name. Give it only `r` and it preserves the aspect +// ratio; give it both `r` and `c` and it *stretches* to fill them. So the +// aspect arithmetic has to happen here, and `fit()` is where. +// * The image can be transmitted as a file path, so nothing decodes or +// re-encodes it: the escape sequence is about sixty bytes however large +// the picture is. That matters on a screen the review loop redraws after +// every keypress. +// * After drawing, the cursor is left inside the image rather than below it, +// so a frame is drawn first and the picture dropped into it afterwards. +// `draw()` therefore never moves the cursor and leaves that to the caller. + +// Pixel dimensions of an image file. +struct Size { + int width = 0; + int height = 0; + + bool valid() const { return width > 0 && height > 0; } +}; + +// Reads only the header, so the cost does not scale with the picture: PNG, GIF +// and JPEG keep their dimensions within the first few bytes or the first few +// segments. An unreadable file, or one in some other format, comes back +// invalid -- which the caller shows as a card without a picture rather than as +// an error, the same way a missing recording is not worth interrupting a +// review over. +Size read_size(const std::string& path); + +// How a terminal is willing to be sent a picture. +enum class Protocol { + kNone, // Draw nothing. Every deck still loads and reviews. + kKitty, // Built in: an escape sequence naming the file. No tools required. + kChafa, // Shell out to chafa for coloured unicode blocks, which need no + // graphics support of any kind and so work everywhere. +}; + +// $FLASHTERM_IMAGE wins when it names a protocol ("none", "kitty", "chafa"), +// because what a terminal can display is exactly the kind of thing automatic +// detection gets wrong on somebody else's setup -- and because a golden test +// must be able to pin it. Otherwise: the kitty protocol when $TERM or +// $TERM_PROGRAM says the terminal speaks it, then chafa if it is installed, +// then nothing. +Protocol detect(); + +// True when a picture would be drawn at all. Review asks before reserving room +// for one, the way audio::available() is asked before offering the "a" key. +bool available(); + +// The protocol in use, for --help and for saying why a card shows no picture. +std::string protocol_name(); + +// A box of terminal cells. +struct Placement { + int columns = 0; + int rows = 0; + + bool empty() const { return columns <= 0 || rows <= 0; } +}; + +// The largest box no bigger than `max_columns` by `max_rows` that keeps the +// picture's proportions. Cells are taller than they are wide, so this needs +// `cell_aspect` -- height divided by width -- or a 4:1 photograph comes out +// looking 8:1. +Placement fit(const Size& size, int max_columns, int max_rows, + double cell_aspect); + +// A cell's height divided by its width. Asked of the terminal, which usually +// declines to answer: ws_xpixel and ws_ypixel are zero more often than not, so +// the fallback is the ratio almost every terminal font actually has. +double cell_aspect(); + +// Draws `path` at the cursor, occupying at most `where`. +// +// `indent` is the column the picture starts at, counted from the left edge of +// the screen. Needed because one of the two ways of drawing produces lines of +// text rather than a single escape, and every line after the first has to be +// put back at that column instead of beginning at zero. +// +// Leaves the cursor on the row it started on, whichever way the picture was +// drawn -- the escape-sequence way never moves it, and the text way puts it +// back -- so the caller's arithmetic does not depend on which one ran. +// +// Deliberately does not clear what is underneath: the caller has already drawn +// a frame around this space and is responsible for stepping back out of it. +// Returns false when nothing was drawn, which is not an error worth +// interrupting a review over. +bool draw(const std::string& path, const Placement& where, int indent, + std::ostream& out); +} // namespace image +} // namespace FlashTerm diff --git a/src/review.cpp b/src/review.cpp index ba9d1d9..c83c0c7 100644 --- a/src/review.cpp +++ b/src/review.cpp @@ -13,6 +13,7 @@ #include "audio.h" #include "date.h" #include "event.h" +#include "image.h" #include "schedule.h" #include "terminal.h" #include "text.h" @@ -324,18 +325,41 @@ std::size_t frame_inner_width() { return frame_width - 4; } +// The box a card's picture gets, or an empty one when there is no picture, no +// terminal that can draw it, or a file that is not an image after all. +// +// Height is capped well under the terminal's own so that a picture cannot push +// the prompt off the screen: the thing being answered matters more than the +// thing being looked at. Width is the frame's, which is what keeps the borders +// where they are. +image::Placement card_image_box(const std::string& path) { + if (path.empty() || !image::available()) return {}; + const image::Size size = image::read_size(path); + if (!size.valid()) return {}; + + const int max_rows = std::max(3, std::min(12, terminal_height() / 3)); + return image::fit(size, static_cast(frame_inner_width()), max_rows, + image::cell_aspect()); +} + // How tall the frame will be, worked out before anything is printed so the // caller can centre it. Kept next to print_card because the two have to agree. int count_frame_lines(const std::string& summary, - const std::string& prompt_line) { + const std::string& prompt_line, + const image::Placement& picture) { const std::size_t inner = frame_inner_width(); const std::size_t lines = wrap(summary, inner).size() + wrap(prompt_line, inner - 2).size(); // Four border and blank rows, plus the separator. - return static_cast(lines) + 5; + int total = static_cast(lines) + 5; + // The picture's own rows, and the blank one that keeps it off the separator. + if (!picture.empty()) total += picture.rows + 1; + return total; } -void print_card(const std::string& summary, const std::string& prompt_line) { +void print_card(const std::string& summary, const std::string& prompt_line, + const std::string& picture_path, + const image::Placement& picture) { const std::size_t inner = frame_inner_width(); std::string rule; @@ -357,12 +381,38 @@ void print_card(const std::string& summary, const std::string& prompt_line) { edge("┌", "┐"); for (const auto& line : summary_lines) row(line, 0, color::reset); edge("├", "┤"); + // The picture's rows are drawn as ordinary empty frame rows and the picture + // is dropped into them afterwards. It has to be this way round: the terminal + // leaves the cursor inside the image rather than below it, so anything + // printed after a picture lands on top of it. + const std::size_t indent = + picture.empty() + ? 0 + : (inner - std::min(inner, static_cast(picture.columns))) / 2; + if (!picture.empty()) { + for (int i = 0; i < picture.rows; ++i) row("", 0, color::reset); + } row("", 0, color::reset); // The prompt is the one thing on screen actually worth reading, so it is // indented within the frame and given the emphasis colour. for (const auto& line : prompt_lines) row(line, 2, color::yellow); row("", 0, color::reset); edge("└", "┘"); + + if (picture.empty()) return; + + // Back up to the first reserved row, across to where the picture starts, + // draw, and return to where we were. Counted from the bottom edge: the + // reserved rows, the blank one under them, the prompt, its blank row, and + // the edge itself. + const int below = picture.rows + 1 + static_cast(prompt_lines.size()) + 2; + const int column = static_cast(indent) + 2; + std::cout << "\033[" << below << "A\r"; + if (column > 0) std::cout << "\033[" << column << "C"; + image::draw(picture_path, picture, column, std::cout); + // Counted from the top of the picture wherever drawing finished, so this has + // to return to a known row first rather than assume one. + std::cout << "\r\033[" << below << "B"; } // Pads the top of the screen so the card sits in the middle of the window @@ -642,13 +692,18 @@ void review_flashcards(Deck& deck) { const std::string summary = card_summary(card, today_days, session.reversed); const std::string shown = prompt_text(card, session.reversed); + // Recomputed on every redraw rather than once per card, because the box + // depends on the terminal's size and the terminal can be resized between + // one keypress and the next. + const std::string picture_path = deck.image_path(card); + const image::Placement picture = card_image_box(picture_path); // Two for the progress bar and its blank line, two for the prompt line // and the breathing room above it. - centre_vertically(count_frame_lines(summary, shown) + 4); + centre_vertically(count_frame_lines(summary, shown, picture) + 4); print_progress(idx + 1, matches.size()); std::cout << "\n"; - print_card(summary, shown); + print_card(summary, shown, picture_path, picture); std::cout << "\n"; if (hinted) { std::cout << color::yellow << "Hint: " << hint_for(expected) << "\n" diff --git a/src/ui.cpp b/src/ui.cpp index 196dd96..17da525 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -195,6 +195,10 @@ void edit_card_fields(Flashcard& card) { prompt("Enter audio path, relative to the deck (current: " + (card.audio.empty() ? std::string("none") : card.audio) + ") [\"none\" to clear]: "); + const std::string picture = + prompt("Enter image path, relative to the deck (current: " + + (card.image.empty() ? std::string("none") : card.image) + + ") [\"none\" to clear]: "); if (!question.empty()) card.question = question; if (!answer.empty()) card.answer = answer; @@ -204,6 +208,9 @@ void edit_card_fields(Flashcard& card) { if (!audio.empty()) { card.audio = (to_lowercase(audio) == "none") ? std::string() : audio; } + if (!picture.empty()) { + card.image = (to_lowercase(picture) == "none") ? std::string() : picture; + } } namespace { diff --git a/tests/golden/cases/absorb-and-generate/expected b/tests/golden/cases/absorb-and-generate/expected index 6c14912..4fc564b 100644 --- a/tests/golden/cases/absorb-and-generate/expected +++ b/tests/golden/cases/absorb-and-generate/expected @@ -51,6 +51,14 @@ Environment: 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 @@ -67,5 +75,11 @@ Environment: 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. --- exit status --- 2 diff --git a/tests/golden/cases/edit-card-audio/expected b/tests/golden/cases/edit-card-audio/expected index bccb787..976b039 100644 --- a/tests/golden/cases/edit-card-audio/expected +++ b/tests/golden/cases/edit-card-audio/expected @@ -42,7 +42,7 @@ Next review in 3 days (). [Enter] next card [a] hear the question [e] edit this card [u] undo this answer [q] end session -> Enter new question (current: Bonjour): Enter new answer (current: Hello) [use | to accept alternatives]: Enter new tags (semicolon;separated, current: french): Enter audio path, relative to the deck (current: none) ["none" to clear]: Card updated. +> Enter new question (current: Bonjour): Enter new answer (current: Hello) [use | to accept alternatives]: Enter new tags (semicolon;separated, current: french): Enter audio path, relative to the deck (current: none) ["none" to clear]: Enter image path, relative to the deck (current: none) ["none" to clear]: Card updated. [Enter] next card [a] hear the question [e] edit this card [u] undo this answer [q] end session diff --git a/tests/golden/cases/edit-card-audio/input b/tests/golden/cases/edit-card-audio/input index c8d9cc1..f03ebf3 100644 --- a/tests/golden/cases/edit-card-audio/input +++ b/tests/golden/cases/edit-card-audio/input @@ -9,4 +9,5 @@ e audio/bonjour.wav + 0 diff --git a/tests/golden/cases/edit-card-image/deck.txt b/tests/golden/cases/edit-card-image/deck.txt new file mode 100644 index 0000000..592e264 --- /dev/null +++ b/tests/golden/cases/edit-card-image/deck.txt @@ -0,0 +1 @@ +el perro,the dog,animals,0,0,1,,,card0000000000001, diff --git a/tests/golden/cases/edit-card-image/env b/tests/golden/cases/edit-card-image/env new file mode 100644 index 0000000..5ed73d3 --- /dev/null +++ b/tests/golden/cases/edit-card-image/env @@ -0,0 +1 @@ +FLASHTERM_IMAGE=kitty diff --git a/tests/golden/cases/edit-card-image/expected b/tests/golden/cases/edit-card-image/expected new file mode 100644 index 0000000..3b3b159 --- /dev/null +++ b/tests/golden/cases/edit-card-image/expected @@ -0,0 +1,57 @@ +--- output --- +Loaded 1 flashcards from deck.txt + + +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards (1 due) +[3] Manage flashcards +[4] Display progress +[5] Import flashcards +[6] Export flashcards +[7] List unique tags +[h] Help +[0] Save and exit +> +--- Manage Flashcards --- +[1] List flashcards +[2] Edit a flashcard +[3] Delete a flashcard +[4] Find flashcards +[q] Back to the main menu +> Search question, answer or tags (Enter to list all): +1. el perro - the dog [Tags: animals] (Box 1, due new) + +Enter the number of the flashcard to edit: Enter new question (current: el perro): Enter new answer (current: the dog) [use | to accept alternatives]: Enter new tags (semicolon;separated, current: animals): Enter audio path, relative to the deck (current: none) ["none" to clear]: Enter image path, relative to the deck (current: none) ["none" to clear]: Flashcard updated! + + +--- Manage Flashcards --- +[1] List flashcards +[2] Edit a flashcard +[3] Delete a flashcard +[4] Find flashcards +[q] Back to the main menu +> Invalid choice. Please try again. + +--- Manage Flashcards --- +[1] List flashcards +[2] Edit a flashcard +[3] Delete a flashcard +[4] Find flashcards +[q] Back to the main menu +> Invalid choice. Please try again. + +--- Manage Flashcards --- +[1] List flashcards +[2] Edit a flashcard +[3] Delete a flashcard +[4] Find flashcards +[q] Back to the main menu +> +Input stream closed/EOF. Flashcards saved. Goodbye! +--- exit status --- +0 +--- file deck.txt --- +el perro,the dog,animals,0,0,1,,,card0000000000001,,images/perro.png +--- file images/perro.png --- + diff --git a/tests/golden/cases/edit-card-image/files/images/perro.png b/tests/golden/cases/edit-card-image/files/images/perro.png new file mode 100644 index 0000000..00021c4 Binary files /dev/null and b/tests/golden/cases/edit-card-image/files/images/perro.png differ diff --git a/tests/golden/cases/edit-card-image/input b/tests/golden/cases/edit-card-image/input new file mode 100644 index 0000000..bafb3ce --- /dev/null +++ b/tests/golden/cases/edit-card-image/input @@ -0,0 +1,11 @@ +3 +2 + +1 + + + + +images/perro.png + +0 diff --git a/tests/golden/cases/force-without-generate/expected b/tests/golden/cases/force-without-generate/expected index 2c577e7..4098905 100644 --- a/tests/golden/cases/force-without-generate/expected +++ b/tests/golden/cases/force-without-generate/expected @@ -51,6 +51,14 @@ Environment: 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 @@ -67,5 +75,11 @@ Environment: 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. --- exit status --- 2 diff --git a/tests/golden/cases/help/expected b/tests/golden/cases/help/expected index 71c1284..da05506 100644 --- a/tests/golden/cases/help/expected +++ b/tests/golden/cases/help/expected @@ -49,6 +49,14 @@ Environment: 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 @@ -65,5 +73,11 @@ Environment: 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. --- exit status --- 0 diff --git a/tests/golden/cases/review-image-missing/deck.txt b/tests/golden/cases/review-image-missing/deck.txt new file mode 100644 index 0000000..9554a03 --- /dev/null +++ b/tests/golden/cases/review-image-missing/deck.txt @@ -0,0 +1 @@ +el perro,the dog,animals,0,0,1,,,card0000000000001,,images/gone.png diff --git a/tests/golden/cases/review-image-missing/env b/tests/golden/cases/review-image-missing/env new file mode 100644 index 0000000..5ed73d3 --- /dev/null +++ b/tests/golden/cases/review-image-missing/env @@ -0,0 +1 @@ +FLASHTERM_IMAGE=kitty diff --git a/tests/golden/cases/review-image-missing/expected b/tests/golden/cases/review-image-missing/expected new file mode 100644 index 0000000..67b5ee7 --- /dev/null +++ b/tests/golden/cases/review-image-missing/expected @@ -0,0 +1,74 @@ +--- output --- +Loaded 1 flashcards from deck.txt + + +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards (1 due) +[3] Manage flashcards +[4] Display progress +[5] Import flashcards +[6] Export flashcards +[7] List unique tags +[h] Help +[0] Save and exit +> +--- Review --- +[1] Due now (1 card) +[2] All cards +[3] By tag +[4] Difficult only (incorrect > correct) +[5] By Leitner box, weakest first +[q] Back to the main menu +> +--- Prompt Direction --- +[Enter] Normal — question shown, you type the answer +[r] Reversed — answer shown, you type the question +> Progress: [████████████████████] 100% (1/1 cards) + +┌──────────────────────────────────────────────────────────────┐ +│ Box 1 · new · animals │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ el perro │ +│ │ +└──────────────────────────────────────────────────────────────┘ + +[Enter] submit [a] play audio [?] hint [q] end session +Your answer: +✅ Correct! +Card promoted to Box 2! +Next review in 3 days (). + +[Enter] next card [a] hear the question [e] edit this card +[u] undo this answer [q] end session +> ================================================== + REVIEW COMPLETE +================================================== + Correct: 1 + Incorrect: 0 + Total Reviewed: 1 + Success Rate: 100.00% + Still Due: 0 +================================================== + +[any key] back to the main menu +> +--- FlashTerm · deck.txt --- +[1] Add flashcard +[2] Review flashcards +[3] Manage flashcards +[4] Display progress +[5] Import flashcards +[6] Export flashcards +[7] List unique tags +[h] Help +[0] Save and exit +> +Input stream closed/EOF. Flashcards saved. Goodbye! +--- exit status --- +0 +--- file deck.txt --- +el perro,the dog,animals,1,0,2,,,card0000000000001,,images/gone.png +--- file deck.txt.log --- +,card0000000000001,