tala#
Flashcard authoring tool. Write cards in Typst, draw cloze regions on rendered previews.
Usage#
# Run against a deck directory
cargo run -p tala -- /path/to/deck
# Or launch without arguments — picks up the last-used directory,
# or falls back to the current working directory.
cargo run -p tala
The deck directory must contain (or will create) a cards.typ file and an images/ subdirectory.
On first launch, use the Home page to pick a folder via the file dialog.
Keyboard shortcuts#
| Key | Action |
|---|---|
Ctrl + / Ctrl - |
Zoom in / out |
Ctrl 0 |
Reset zoom |
ArrowUp at first line of textarea |
Move focus to previous segment |
ArrowDown at last line of textarea |
Move focus to next segment |
Card syntax (Typst)#
#card(id: "abc123")[Front content][Back content]
#cloze(id: "def456")[The answer is #blank[hidden].]
#img_cloze(id: "ghi789", src: "diagram")
Cards are separated by blank lines. Any non-card text between blank lines is treated as a text segment (visible in the editor, ignored for review).
Draw Cloze#
With a #cloze card active, click Draw Cloze in the toolbar, then drag a rectangle over rendered text to wrap it in #blank[...]. The selection snaps to token boundaries and balances brackets automatically. For math, the nearest equation is split and the selection wrapped in #blank[$...$].
CLI#
# Parse cards and cross-check sidecar schedule data
cargo run -p tala-cli -- check /path/to/deck
# Review due cards (terminal UI)
cargo run -p tala-cli -- review /path/to/deck
Background#
Architecture#
Cargo workspace. Crates under crates/:
tala-format → tala-srs → tala-typst → tala
↑
tala-cli
- tala-format: parses
.typfiles viatypst-syntax. Entry point:parse_cards(source) -> Vec<CardEntry>. - tala-srs: loads/saves
cards.srs.jsonalongsidecards.typ. All FSRS schedule data lives here. - tala-typst: renders a Typst source string to RGBA via the
typstcrate. Returns pixel data, blank bounding boxes, a glyph map, and math spans. - tala: Dioxus desktop app (this crate).
- tala-cli:
checkandreviewsubcommands.
Dioxus routes: Home (folder picker) → Editor → Settings (stub), all inside Shell (navbar). Global state: CARD_DIR: GlobalSignal<PathBuf> persisted to ~/.config/tala/dir.
Editor state#
| Signal | Type | Purpose |
|---|---|---|
source |
Signal<String> |
Full cards.typ content |
save_status |
Signal<SaveStatus> |
Clean / Dirty / Saved / Error |
active_idx |
Signal<usize> |
Index of focused segment |
previews |
Signal<Vec<Option<Result<PreviewData,_>>>> |
Per-segment typst renders; None for text segments |
blank_rects_sig |
Signal<Vec<[f64;4]>> |
Normalized blank positions for the active card |
glyph_map_sig |
Signal<Vec<([f64;4], Range<usize>, bool)>> |
Glyph-to-byte-range map for draw mode |
math_spans_sig |
Signal<Vec<Range<usize>>> |
Equation spans for the active card |
draw_mode |
Signal<bool> |
Draw Cloze mode toggle |
drag_start/current |
Signal<Option<(f64,f64)>> |
Normalized drag coords |
drawn_boxes |
Signal<Vec<[f64;4]>> |
Committed draw rects (shown on failed insertions) |
insert_error |
Signal<Option<String>> |
Draw insertion error message |
Two resources fire on source change:
_saver: 1s debounce →fs::write_render: 300ms debounce →spawn_blocking→ per-card Typst compile →previews.set(...)
One effect fires on active_idx or previews change (peeks source): copies the active card's render data into the three draw-mode signals.
Algorithms#
Segmentation (make_segments)
split source on runs of \n\n+
for each non-empty chunk:
if chunk.trim starts with #card[ | #card( | #cloze[ | #cloze( | #img_cloze(:
Seg::Card { start, end } // byte offsets into full source
else:
Seg::Text { start, end }
Pure byte scan — Typst is never invoked. Each card segment is compiled independently by _render, so a broken card (unclosed bracket etc.) cannot corrupt adjacent cards.
Input splice (on_input)
segs = make_segments(source)
seg = segs[active_idx]
new_src = source[..seg.start] + textarea_value + source[seg.end..]
source = new_src
if make_segments(new_src).len() > segs.len(): // user typed \n\n
advance active_idx to the last new segment
JS: focus textarea, move cursor to end
Delete segment
// consume trailing \n\n if present, else leading \n\n
if source[seg.end..].starts_with("\n\n"): remove [seg.start, seg.end+2)
elif source[seg.start-2..seg.start] == "\n\n": remove [seg.start-2, seg.end)
else: remove [seg.start, seg.end)
clamp active_idx to new length
Keyboard boundary navigation
on ArrowUp | ArrowDown:
JS: is cursor on the first (or last) line of the textarea?
if yes:
active_idx ± 1
JS: focus new active textarea
Draw Cloze insertion
on mouseup:
normalize drag rect to [0,1] coords (correcting for CSS zoom)
if rect area < 0.001: discard
hits = glyphs from glyph_map_sig overlapping rect
if hits empty: show error, keep rect as visual feedback; return
seg = segs[active_idx]
fragment = source[seg.start..seg.end]
if all hits are math:
find containing equation span
expand selection to token / bracket boundaries
new_frag = insert_blank_wrap_math(fragment, sel, eq_bounds)
elif any hits are math:
error: "spans math and text — draw over one type only"
else:
new_frag = insert_blank_wrap(fragment, min_byte, max_byte)
source = source[..seg.start] + new_frag + source[seg.end..]
JS: sync textarea value
Math selection expansion (expand_math_selection)
Given raw glyph-hit byte range within an equation's inner content:
- If
sel_endis mid-identifier, extend to end of token. - If the token is immediately followed by
(, include the matched parenthesised group. - Re-balance any unclosed
()[]{}pairs by extendingendrightward.
RSX layout#
#editor (flex column)
.preview-toolbar
[Draw Cloze toggle] [save status] [insert error]
.card-list (flex column, overflow-y: auto)
for each seg:
.card-row [.active] onclick → activate + focus textarea
.card-actions
.btn-delete × onclick → delete segment
if active:
textarea.card-source onmounted seeds value via JS
match seg:
Seg::Text → pre.text-seg-preview (inactive only)
Seg::Card →
if render error → pre.render-error
elif no image yet → "Rendering…"
else:
.card-preview-wrap
img.card-preview
svg.cloze-overlay yellow blank rects; draw rects if active
if active && draw_mode && cloze:
.draw-capture mouse event handlers
Note: textarea value is seeded via a JS
onmountedcallback (base64-encoding the fragment) rather than Dioxus'svalueprop, because the webview's controlled-input behavior does not update the DOM value correctly after mount.