···11+# tala
22+33+Flashcard authoring tool. Write cards in Typst, draw cloze regions on rendered previews.
44+55+## Usage
66+77+```bash
88+# Run against a deck directory
99+cargo run -p tala -- /path/to/deck
1010+1111+# Or launch without arguments — picks up the last-used directory,
1212+# or falls back to the current working directory.
1313+cargo run -p tala
1414+```
1515+1616+The deck directory must contain (or will create) a `cards.typ` file and an `images/` subdirectory.
1717+1818+On first launch, use the Home page to pick a folder via the file dialog.
1919+2020+### Keyboard shortcuts
2121+2222+| Key | Action |
2323+|---|---|
2424+| `Ctrl +` / `Ctrl -` | Zoom in / out |
2525+| `Ctrl 0` | Reset zoom |
2626+| `ArrowUp` at first line of textarea | Move focus to previous segment |
2727+| `ArrowDown` at last line of textarea | Move focus to next segment |
2828+2929+### Card syntax (Typst)
3030+3131+```typst
3232+#card(id: "abc123")[Front content][Back content]
3333+3434+#cloze(id: "def456")[The answer is #blank[hidden].]
3535+3636+#img_cloze(id: "ghi789", src: "diagram")
3737+```
3838+3939+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).
4040+4141+### Draw Cloze
4242+4343+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[$...$]`.
4444+4545+### CLI
4646+4747+```bash
4848+# Parse cards and cross-check sidecar schedule data
4949+cargo run -p tala-cli -- check /path/to/deck
5050+5151+# Review due cards (terminal UI)
5252+cargo run -p tala-cli -- review /path/to/deck
5353+```
5454+5555+---
5656+5757+## Background
5858+5959+### Architecture
6060+6161+Cargo workspace. Crates under `crates/`:
6262+6363+```
6464+tala-format → tala-srs → tala-typst → tala
6565+ ↑
6666+ tala-cli
6767+```
6868+6969+- **tala-format**: parses `.typ` files via `typst-syntax`. Entry point: `parse_cards(source) -> Vec<CardEntry>`.
7070+- **tala-srs**: loads/saves `cards.srs.json` alongside `cards.typ`. All FSRS schedule data lives here.
7171+- **tala-typst**: renders a Typst source string to RGBA via the `typst` crate. Returns pixel data, blank bounding boxes, a glyph map, and math spans.
7272+- **tala**: Dioxus desktop app (this crate).
7373+- **tala-cli**: `check` and `review` subcommands.
7474+7575+Dioxus routes: `Home` (folder picker) → `Editor` → `Settings` (stub), all inside `Shell` (navbar). Global state: `CARD_DIR: GlobalSignal<PathBuf>` persisted to `~/.config/tala/dir`.
7676+7777+### Editor state
7878+7979+| Signal | Type | Purpose |
8080+|---|---|---|
8181+| `source` | `Signal<String>` | Full `cards.typ` content |
8282+| `save_status` | `Signal<SaveStatus>` | Clean / Dirty / Saved / Error |
8383+| `active_idx` | `Signal<usize>` | Index of focused segment |
8484+| `previews` | `Signal<Vec<Option<Result<PreviewData,_>>>>` | Per-segment typst renders; `None` for text segments |
8585+| `blank_rects_sig` | `Signal<Vec<[f64;4]>>` | Normalized blank positions for the active card |
8686+| `glyph_map_sig` | `Signal<Vec<([f64;4], Range<usize>, bool)>>` | Glyph-to-byte-range map for draw mode |
8787+| `math_spans_sig` | `Signal<Vec<Range<usize>>>` | Equation spans for the active card |
8888+| `draw_mode` | `Signal<bool>` | Draw Cloze mode toggle |
8989+| `drag_start/current` | `Signal<Option<(f64,f64)>>` | Normalized drag coords |
9090+| `drawn_boxes` | `Signal<Vec<[f64;4]>>` | Committed draw rects (shown on failed insertions) |
9191+| `insert_error` | `Signal<Option<String>>` | Draw insertion error message |
9292+9393+Two resources fire on `source` change:
9494+- `_saver`: 1s debounce → `fs::write`
9595+- `_render`: 300ms debounce → `spawn_blocking` → per-card Typst compile → `previews.set(...)`
9696+9797+One effect fires on `active_idx` or `previews` change (peeks `source`): copies the active card's render data into the three draw-mode signals.
9898+9999+### Algorithms
100100+101101+**Segmentation** (`make_segments`)
102102+103103+```
104104+split source on runs of \n\n+
105105+for each non-empty chunk:
106106+ if chunk.trim starts with #card[ | #card( | #cloze[ | #cloze( | #img_cloze(:
107107+ Seg::Card { start, end } // byte offsets into full source
108108+ else:
109109+ Seg::Text { start, end }
110110+```
111111+112112+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.
113113+114114+**Input splice** (`on_input`)
115115+116116+```
117117+segs = make_segments(source)
118118+seg = segs[active_idx]
119119+new_src = source[..seg.start] + textarea_value + source[seg.end..]
120120+source = new_src
121121+if make_segments(new_src).len() > segs.len(): // user typed \n\n
122122+ advance active_idx to the last new segment
123123+ JS: focus textarea, move cursor to end
124124+```
125125+126126+**Delete segment**
127127+128128+```
129129+// consume trailing \n\n if present, else leading \n\n
130130+if source[seg.end..].starts_with("\n\n"): remove [seg.start, seg.end+2)
131131+elif source[seg.start-2..seg.start] == "\n\n": remove [seg.start-2, seg.end)
132132+else: remove [seg.start, seg.end)
133133+clamp active_idx to new length
134134+```
135135+136136+**Keyboard boundary navigation**
137137+138138+```
139139+on ArrowUp | ArrowDown:
140140+ JS: is cursor on the first (or last) line of the textarea?
141141+ if yes:
142142+ active_idx ± 1
143143+ JS: focus new active textarea
144144+```
145145+146146+**Draw Cloze insertion**
147147+148148+```
149149+on mouseup:
150150+ normalize drag rect to [0,1] coords (correcting for CSS zoom)
151151+ if rect area < 0.001: discard
152152+153153+ hits = glyphs from glyph_map_sig overlapping rect
154154+ if hits empty: show error, keep rect as visual feedback; return
155155+156156+ seg = segs[active_idx]
157157+ fragment = source[seg.start..seg.end]
158158+159159+ if all hits are math:
160160+ find containing equation span
161161+ expand selection to token / bracket boundaries
162162+ new_frag = insert_blank_wrap_math(fragment, sel, eq_bounds)
163163+ elif any hits are math:
164164+ error: "spans math and text — draw over one type only"
165165+ else:
166166+ new_frag = insert_blank_wrap(fragment, min_byte, max_byte)
167167+168168+ source = source[..seg.start] + new_frag + source[seg.end..]
169169+ JS: sync textarea value
170170+```
171171+172172+**Math selection expansion** (`expand_math_selection`)
173173+174174+Given raw glyph-hit byte range within an equation's inner content:
175175+1. If `sel_end` is mid-identifier, extend to end of token.
176176+2. If the token is immediately followed by `(`, include the matched parenthesised group.
177177+3. Re-balance any unclosed `()` `[]` `{}` pairs by extending `end` rightward.
178178+179179+### RSX layout
180180+181181+```
182182+#editor (flex column)
183183+ .preview-toolbar
184184+ [Draw Cloze toggle] [save status] [insert error]
185185+ .card-list (flex column, overflow-y: auto)
186186+ for each seg:
187187+ .card-row [.active] onclick → activate + focus textarea
188188+ .card-actions
189189+ .btn-delete × onclick → delete segment
190190+ if active:
191191+ textarea.card-source onmounted seeds value via JS
192192+ match seg:
193193+ Seg::Text → pre.text-seg-preview (inactive only)
194194+ Seg::Card →
195195+ if render error → pre.render-error
196196+ elif no image yet → "Rendering…"
197197+ else:
198198+ .card-preview-wrap
199199+ img.card-preview
200200+ svg.cloze-overlay yellow blank rects; draw rects if active
201201+ if active && draw_mode && cloze:
202202+ .draw-capture mouse event handlers
203203+```
204204+205205+> Note: textarea value is seeded via a JS `onmounted` callback (base64-encoding the fragment) rather than Dioxus's `value` prop, because the webview's controlled-input behavior does not update the DOM value correctly after mount.