this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: review tag filter, skip, and updated CLAUDE.md

- Review setup screen with tag chip filter (OR/AND mode)
- Skip button and S key moves card to back of queue; terminates when all remaining are skipped
- CLAUDE.md rewritten to reflect current codebase (removed img_cloze/id field, added all GUI components, corrected Sidecar API and Preamble variants)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+365 -55
+162 -38
CLAUDE.md
··· 1 1 # CLAUDE.md 2 2 3 - This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 3 + Guidance for Claude Code when working in this repository. 4 4 5 5 ## Commands 6 6 7 7 ```bash 8 - # Build everything 9 - cargo build 10 - 11 - # Run all tests 12 - cargo test 13 - 14 - # Run tests for a single crate 15 - cargo test -p tala-format 16 - cargo test -p tala-srs 17 - 18 - # Run a single test by name 19 - cargo test -p tala-format card_count 20 - 21 - # Check without building artifacts 22 - cargo check 23 - 24 - # Run the CLI 8 + cargo build # build everything 9 + cargo check # check without artifacts 10 + cargo test # all tests 11 + cargo test -p tala-format card_count # single test 25 12 cargo run -p tala-cli -- check <deck-dir> 26 13 cargo run -p tala-cli -- review <deck-dir> 14 + cargo run -p tala # GUI (uses cwd or ~/.config/tala/dir) 27 15 ``` 28 16 29 17 ## Architecture 30 18 31 - Cargo workspace with edition 2024. Crates live under `crates/`. 32 - 33 - ### Crate dependency order 19 + Cargo workspace, edition 2024. All crates under `crates/`. 34 20 35 21 ``` 36 - tala-format → tala-srs → tala-typst → tala 22 + tala-format → tala-srs → tala-typst → tala (GUI) 37 23 38 - tala-cli (consumes both) 24 + tala-cli 39 25 ``` 40 26 27 + --- 28 + 41 29 ### `tala-format` 42 30 43 31 Parses `.typ` card files using `typst-syntax`. Entry point: `parse_cards(source: &str) -> Vec<CardEntry>`. 44 32 45 - Uses `LinkedNode` (not `SyntaxNode`) for byte-range extraction — `LinkedNode::range()` returns `Range<usize>`; `SyntaxNode` only exposes an opaque `Span`. Walker matches `SyntaxKind::FuncCall`, dispatches on function name. 33 + Uses `LinkedNode` (not `SyntaxNode`) — `LinkedNode::range()` returns `Range<usize>`; `SyntaxNode` only exposes an opaque `Span`. Walker matches `SyntaxKind::FuncCall`, dispatches on function name. 34 + 35 + **Card syntax:** 36 + ``` 37 + #card(tags: ("t1",), dir: "bi")[front][back] → CardKind::FrontBack 38 + #cloze(tags: ("t1",))[body #blank[hidden] ...] → CardKind::Cloze 39 + ``` 46 40 47 - Card types and their typst syntax: 48 - - `#card(id: "...", dir?: "bi")[front][back]` → `CardKind::FrontBack` 49 - - `#cloze(id: "...")[... #blank[text] ...]` → `CardKind::Cloze` 50 - - `#img_cloze(id: "...", src: "name")` → `CardKind::ImgCloze` 41 + No `#img_cloze` — removed. Image region overlays live in the sidecar as `RectEntry`. 42 + No `id:` field — cards are identified by **position index** in the file ("0", "1", ...). 43 + `dir` defaults to `Forward`; `"bi"` → `Bidirectional` (two review items: forward + reverse). 44 + 45 + **Key types:** 46 + ```rust 47 + pub struct CardEntry { 48 + pub tags: Vec<String>, 49 + pub kind: CardKind, 50 + pub span: Range<usize>, // byte range of the full #card(...)[...] call 51 + } 52 + 53 + pub enum CardKind { 54 + FrontBack { front_span, back_span, dir: Direction }, 55 + Cloze { body_span, blanks: Vec<BlankEntry> }, 56 + } 57 + 58 + pub struct BlankEntry { pub index: usize, pub span: Range<usize>, pub content_span: Range<usize> } 59 + pub enum Direction { Forward, Bidirectional } 60 + ``` 51 61 52 62 **Span gotcha:** the `#` sigil is outside the `FuncCall` AST node. `card.span` points to `card(...)` not `#card(...)`. Use `span.start - 1` when byte-splicing to include the sigil. 53 63 64 + --- 65 + 54 66 ### `tala-srs` 55 67 56 - Loads/saves `cards.srs.json` sidecar alongside `cards.typ`. All FSRS schedule data lives here, not in the typst source. ImgCloze rect overlays (normalized `[x, y, w, h]` in `[0,1]`) also live here as `Vec<RectEntry>`. 68 + Loads/saves `<cards>.srs.json` sidecar alongside `<cards>.typ`. All FSRS schedule data lives here, never in the typst source. Image rect overlays also live here. 57 69 58 - Key methods on `Sidecar`: `load_or_empty(deck_dir)`, `save_to_deck(deck_dir)`, `orphaned(known_ids)`, `missing(known_ids)`. 70 + **Primary API:** 71 + ```rust 72 + Sidecar::load_or_empty_for(typ_path: &Path) -> Result<Self> 73 + Sidecar::save_for(&self, typ_path: &Path) -> Result<()> 74 + Sidecar::path_for(typ_path: &Path) -> PathBuf // derives .srs.json path 75 + Sidecar::empty() -> Self 76 + sidecar.get(card_id: &str) -> Option<&CardSchedule> 77 + sidecar.orphaned(known_ids: &[String]) -> Vec<&str> 78 + sidecar.missing(known_ids: &[String]) -> Vec<&str> 79 + ``` 59 80 60 - `CardSchedule` is tagged-union JSON (`#[serde(tag = "type", rename_all = "snake_case")]`). 81 + `card_id` is always a stringified position index ("0", "1", ...). 61 82 62 - ### `tala-cli` 83 + **Key types:** 84 + ```rust 85 + // Tagged-union JSON: #[serde(tag = "type", rename_all = "snake_case")] 86 + pub enum CardSchedule { 87 + FrontBack { 88 + forward_schedule: Option<Schedule>, 89 + reverse_schedule: Option<Schedule>, 90 + }, 91 + Cloze { 92 + blanks: HashMap<String, Schedule>, // keys: "b0", "b1", ... 93 + rects: Vec<RectEntry>, // image region overlays 94 + }, 95 + } 63 96 64 - Binary crate producing the `tala` binary. Two subcommands: `check` (parse + sidecar cross-check) and `review` (stubbed). 97 + pub struct Schedule { pub due: String, pub stability: f32, pub difficulty: f32 } 98 + // due is "YYYY-MM-DD" 99 + 100 + pub struct RectEntry { 101 + pub id: String, // "r0", "r1", ... 102 + pub src: String, // bare image stem (no path, no extension) 103 + pub rect: [f32; 4], // normalized [x, y, w, h] in [0.0, 1.0] 104 + pub schedule: Schedule, 105 + } 106 + ``` 107 + 108 + **FSRS helpers:** `is_due(sched: &Schedule) -> bool`, `next_schedule(current, days_elapsed, grade) -> Schedule`, `today_str() -> String`. 109 + 110 + --- 65 111 66 112 ### `tala-typst` 67 113 68 - Render typst source strings to RGBA via the `typst` crate. Requires implementing `typst::World`. Two preambles: authoring (renders full card) and review (blanks replaced by boxes). Font loading must provide at least one valid font family or typst panics. 114 + Renders typst source strings to RGBA pixels via the `typst` crate. Implements `typst::World`. Font loading must provide at least one valid font family or typst panics. 69 115 116 + ```rust 117 + pub fn render(deck_dir: &Path, fragment: &str, preamble: Preamble, blank_spans: &[Range<usize>]) -> Result<RenderResult, Error> 118 + 119 + pub enum Preamble { 120 + Authoring, // full card, all content visible 121 + ReviewFront, // FrontBack: show front content block only 122 + ReviewBack, // FrontBack: show back content block only (for bidirectional reverse) 123 + ReviewCloze, // Cloze: replace all blank[] with opaque boxes 124 + } 125 + 126 + pub struct RenderResult { 127 + pub rgba: Vec<u8>, // premultiplied alpha, row-major 128 + pub width: u32, pub height: u32, 129 + pub blank_boxes: Vec<[f32; 4]>, // pixel [x,y,w,h] per blank (requires blank_spans) 130 + pub glyph_map: Vec<([f32; 4], Range<usize>, bool)>, // (px rect, frag range, is_math) 131 + pub math_spans: Vec<Range<usize>>, // fragment-relative Equation byte ranges 132 + pub image_boxes: Vec<[f32; 4]>, // pixel [x,y,w,h] per image, document order 133 + } 134 + ``` 135 + 136 + The RGBA output is **premultiplied alpha**. Convert to straight alpha before encoding to PNG (divide RGB by alpha). 137 + 138 + --- 139 + 140 + ### `tala-cli` 141 + 142 + Two subcommands: 143 + 144 + - **`check <path>`** — parses all `.typ` files, reports card counts, sidecar orphans/missing entries. 145 + - **`review <path> [--tag TAG]`** — interactive terminal review session. Collects all due items across files, prompts grade (1-4), saves to sidecar, git-commits each modified sidecar with message `"review session YYYY-MM-DD"`. 146 + 147 + The CLI uses `git2` for commits; the GUI does not. 148 + 149 + --- 150 + 151 + ### `tala` (GUI) 152 + 153 + Dioxus desktop app. Single binary. Routes: `Home`, `Editor`, `Images`, `Review`, `Settings`. 154 + 155 + **Global state:** 156 + ```rust 157 + static CARD_DIR: GlobalSignal<PathBuf> 158 + fn card_dir() -> PathBuf 159 + fn cards_path() -> PathBuf // card_dir().join("cards.typ") 160 + fn set_card_dir(path: PathBuf) // persists to ~/.config/tala/dir 161 + ``` 162 + 163 + Deck dir is loaded from: CLI arg → `~/.config/tala/dir` → cwd. 164 + 165 + **Editor** (`Editor` component): 166 + - Segments source file by blank lines: `SegKind::Card` or `SegKind::Text`, identified by prefix (`#card` / `#cloze`). 167 + - Each card segment rendered independently. Active card has an editable textarea; inactive cards show rendered preview. 168 + - Auto-saves to disk after 1s debounce. **No git commits from the GUI.** 169 + - Card identification: `active_card_key()` counts `Card` segments up to active index → stringified count → sidecar key. 170 + - **Draw mode**: drag to create rect overlays on images within a card. Saved to sidecar as `RectEntry`. Existing rects are draggable/resizable. Delete via button. 171 + - Image paste from clipboard (arboard, not web_sys) → saves to `<deck>/images/`. 172 + - Keyboard nav: Up/Down at textarea boundary switches active segment. 173 + 174 + **Images** (`Images` component): 175 + - Grid of all files in `<deck>/images/`. 176 + - Rename: updates filename on disk and rewrites matching `src` fields in all sidecar `RectEntry` records. 177 + - Copy-name button, lightbox preview. 178 + 179 + **Review** (`Review` component): 180 + - Setup screen: clickable tag chips (OR/AND toggle for 2+ tags), "Start review →" builds filtered queue. 181 + - Queue items: `ReviewKind::FrontBack`, `ReviewKind::Cloze`, `ReviewKind::ImageRect { src, rect }`. 182 + - Question → reveal → grade (1=Again 2=Hard 3=Good 4=Easy). Keyboard: Space/Enter=reveal, 1-4=grade, S=skip. 183 + - Skip: moves item to back of queue with `skipped: true`; terminates when all remaining are skipped. 184 + - Image rect review: renders full card via `Authoring`, then pixel-fills the rect region for the question image. 185 + - Crossfade between question/answer images via CSS grid overlap + `opacity` transition. Click image to toggle. 186 + - Saves sidecar to disk after each grade. No git commit. 187 + 188 + **Settings** (`Settings` component): 189 + - Change deck directory (file picker). 190 + - "Reset all schedules" — clears all schedule data from sidecar (preserves rect geometry), requires confirm dialog. 191 + 192 + --- 70 193 71 194 ## Deck directory layout 72 195 73 196 ``` 74 197 <deck>/ 75 - cards.typ # human-authored, git-tracked 76 - cards.srs.json # machine-managed schedule data 77 - images/ # referenced via #img("name") 198 + cards.typ # human-authored typst source, git-tracked 199 + cards.srs.json # machine-managed schedule + rect overlay data 200 + images/ # referenced via #img("stem") or #image("images/stem.ext") 78 201 ``` 79 202 80 - Every save is a `git2` commit. Dirty state = working tree differs from HEAD. Card file edits are byte-span splices — never reformat the whole file. 203 + Card edits are byte-span splices — never reformat the whole file. 204 + `extract_img_names(source)` normalizes both `#img("stem")` and `#image("images/stem.ext")` to bare stems, matching sidecar `RectEntry.src`.
+30
crates/tala/assets/main.css
··· 385 385 gap: 12px; 386 386 } 387 387 388 + /* ── Review setup ────────────────────────────────────────────────────────────── */ 389 + .review-setup { 390 + display: flex; 391 + flex-direction: column; 392 + align-items: center; 393 + gap: 16px; 394 + max-width: 560px; 395 + width: 100%; 396 + } 397 + 398 + .tag-chip-row { 399 + display: flex; 400 + flex-wrap: wrap; 401 + gap: 8px; 402 + justify-content: center; 403 + } 404 + 405 + .tag-chip { 406 + background: #1e2130; 407 + color: #8892aa; 408 + border: 1px solid #2a2f45; 409 + border-radius: 12px; 410 + font-size: 12px; 411 + padding: 4px 12px; 412 + cursor: pointer; 413 + } 414 + 415 + .tag-chip:hover { color: #fff; background: #252a3d; } 416 + .tag-chip.selected { color: #fff; background: #3a4070; border-color: #5060a0; } 417 + 388 418 .grade-again { border-color: #7a3040; } 389 419 .grade-again:hover { background: #5a2030; color: #fff; } 390 420 .grade-hard { border-color: #6a5020; }
+173 -17
crates/tala/src/main.rs
··· 1987 1987 1988 1988 // ── Review mode ─────────────────────────────────────────────────────────────── 1989 1989 1990 + #[derive(Clone, PartialEq)] 1991 + enum TagMode { 1992 + Or, 1993 + And, 1994 + } 1995 + 1990 1996 #[derive(Clone)] 1991 1997 enum ReviewKind { 1992 1998 FrontBack, ··· 2001 2007 sub_id: String, 2002 2008 current: Option<Schedule>, 2003 2009 kind: ReviewKind, 2010 + tags: Vec<String>, 2011 + skipped: bool, 2004 2012 } 2005 2013 2006 2014 struct ReviewSession { ··· 2008 2016 sidecar: Sidecar, 2009 2017 } 2010 2018 2011 - fn build_review_queue() -> (Vec<ReviewItem>, Sidecar) { 2019 + fn build_review_queue(tag_filter: &[String], mode: TagMode) -> (Vec<ReviewItem>, Sidecar) { 2012 2020 use std::collections::HashMap; 2013 2021 2014 2022 let source = std::fs::read_to_string(cards_path()).unwrap_or_default(); ··· 2017 2025 let mut queue = Vec::new(); 2018 2026 2019 2027 for (ci, card) in cards.iter().enumerate() { 2028 + if !tag_filter.is_empty() { 2029 + let matches = match mode { 2030 + TagMode::And => tag_filter.iter().all(|t| card.tags.contains(t)), 2031 + TagMode::Or => tag_filter.iter().any(|t| card.tags.contains(t)), 2032 + }; 2033 + if !matches { 2034 + continue; 2035 + } 2036 + } 2020 2037 let card_id = ci.to_string(); 2021 2038 let card_src = source[card.span.start.saturating_sub(1)..card.span.end].to_string(); 2039 + let card_tags = card.tags.clone(); 2022 2040 match (&card.kind, sidecar.get(&card_id)) { 2023 2041 (CardKind::FrontBack { dir, .. }, sched) => { 2024 2042 let (fwd, rev) = match sched { ··· 2035 2053 sub_id: "forward".into(), 2036 2054 current: fwd, 2037 2055 kind: ReviewKind::FrontBack, 2056 + tags: card_tags.clone(), 2057 + skipped: false, 2038 2058 }); 2039 2059 } 2040 2060 if matches!(dir, Direction::Bidirectional) && rev.as_ref().is_none_or(is_due) { ··· 2044 2064 sub_id: "reverse".into(), 2045 2065 current: rev, 2046 2066 kind: ReviewKind::FrontBack, 2067 + tags: card_tags.clone(), 2068 + skipped: false, 2047 2069 }); 2048 2070 } 2049 2071 } ··· 2065 2087 sub_id: key, 2066 2088 current, 2067 2089 kind: ReviewKind::Cloze, 2090 + tags: card_tags.clone(), 2091 + skipped: false, 2068 2092 }); 2069 2093 } 2070 2094 } ··· 2079 2103 src: rect_entry.src.clone(), 2080 2104 rect: rect_entry.rect, 2081 2105 }, 2106 + tags: card_tags.clone(), 2107 + skipped: false, 2082 2108 }); 2083 2109 } 2084 2110 } ··· 2301 2327 } 2302 2328 } 2303 2329 2330 + fn do_skip( 2331 + mut session: Signal<ReviewSession>, 2332 + idx: Signal<usize>, 2333 + mut revealed: Signal<bool>, 2334 + mut show_answer: Signal<bool>, 2335 + mut done: Signal<bool>, 2336 + ) { 2337 + let cur_idx = *idx.read(); 2338 + let all_skipped = { 2339 + let mut sess = session.write(); 2340 + let mut item = sess.queue.remove(cur_idx); 2341 + item.skipped = true; 2342 + sess.queue.push(item); 2343 + sess.queue[cur_idx..].iter().all(|i| i.skipped) 2344 + }; 2345 + if all_skipped { 2346 + done.set(true); 2347 + } else { 2348 + revealed.set(false); 2349 + show_answer.set(false); 2350 + } 2351 + } 2352 + 2353 + fn collect_all_tags() -> Vec<String> { 2354 + let source = std::fs::read_to_string(cards_path()).unwrap_or_default(); 2355 + let cards = tala_format::parse_cards(&source); 2356 + let mut tags: Vec<String> = cards.into_iter().flat_map(|c| c.tags).collect(); 2357 + tags.sort(); 2358 + tags.dedup(); 2359 + tags 2360 + } 2361 + 2304 2362 #[component] 2305 2363 fn Review() -> Element { 2306 - let session = use_signal(|| { 2307 - let (queue, sidecar) = build_review_queue(); 2308 - ReviewSession { queue, sidecar } 2309 - }); 2310 - let idx = use_signal(|| 0usize); 2364 + let mut started = use_signal(|| false); 2365 + let mut selected_tags: Signal<Vec<String>> = use_signal(Vec::new); 2366 + let mut tag_mode = use_signal(|| TagMode::Or); 2367 + let all_tags = use_signal(collect_all_tags); 2368 + let mut session = use_signal(|| ReviewSession { queue: Vec::new(), sidecar: Sidecar::empty() }); 2369 + let mut idx = use_signal(|| 0usize); 2311 2370 let mut revealed = use_signal(|| false); 2312 2371 let mut show_answer = use_signal(|| false); 2313 - let done = use_signal(|| false); 2314 - let graded_count = use_signal(|| 0usize); 2372 + let mut done = use_signal(|| false); 2373 + let mut graded_count = use_signal(|| 0usize); 2315 2374 2316 2375 let total = session.read().queue.len(); 2317 2376 2318 2377 // Question-side image: re-renders when idx or session changes. 2319 2378 let q_img = use_resource(move || async move { 2379 + if !*started.read() { 2380 + return String::new(); 2381 + } 2320 2382 let item = { 2321 2383 let sess = session.read(); 2322 2384 sess.queue.get(*idx.read()).cloned() ··· 2324 2386 let Some(item) = item else { 2325 2387 return String::new(); 2326 2388 }; 2327 - let dir = card_dir(); // capture in Dioxus runtime before spawn_blocking 2389 + let dir = card_dir(); 2328 2390 tokio::task::spawn_blocking(move || match item.kind { 2329 2391 ReviewKind::FrontBack => { 2330 2392 let preamble = if item.sub_id == "reverse" { ··· 2348 2410 2349 2411 // Answer-side image: starts loading once revealed; cached until idx changes. 2350 2412 let a_img = use_resource(move || async move { 2351 - if !*revealed.read() { 2413 + if !*started.read() || !*revealed.read() { 2352 2414 return None; 2353 2415 } 2354 2416 let item = { ··· 2356 2418 sess.queue.get(*idx.read()).cloned() 2357 2419 }; 2358 2420 let Some(item) = item else { return None }; 2359 - let dir = card_dir(); // capture in Dioxus runtime before spawn_blocking 2421 + let dir = card_dir(); 2360 2422 tokio::task::spawn_blocking(move || { 2361 2423 render_review_b64(&dir, &item.source, tala_typst::Preamble::Authoring).ok() 2362 2424 }) ··· 2365 2427 .flatten() 2366 2428 }); 2367 2429 2430 + // ── Setup screen ───────────────────────────────────────────────────────── 2431 + if !*started.read() { 2432 + let available_tags = all_tags.read().clone(); 2433 + return rsx! { 2434 + div { id: "review-page", 2435 + div { class: "review-setup", 2436 + if available_tags.is_empty() { 2437 + p { class: "review-progress", "No tags defined in deck." } 2438 + } else { 2439 + p { class: "review-progress", "Filter by tags:" } 2440 + div { class: "tag-chip-row", 2441 + for tag in available_tags { 2442 + { 2443 + let tag2 = tag.clone(); 2444 + let is_sel = selected_tags.read().contains(&tag); 2445 + rsx! { 2446 + button { 2447 + class: if is_sel { "tag-chip selected" } else { "tag-chip" }, 2448 + onclick: move |_| { 2449 + let mut sel = selected_tags.write(); 2450 + if sel.contains(&tag2) { 2451 + sel.retain(|t| t != &tag2); 2452 + } else { 2453 + sel.push(tag2.clone()); 2454 + } 2455 + }, 2456 + "{tag}" 2457 + } 2458 + } 2459 + } 2460 + } 2461 + } 2462 + if selected_tags.read().len() > 1 { 2463 + div { class: "review-grade-row", 2464 + button { 2465 + class: if *tag_mode.read() == TagMode::Or { "btn active" } else { "btn" }, 2466 + onclick: move |_| tag_mode.set(TagMode::Or), 2467 + "Any tag (OR)" 2468 + } 2469 + button { 2470 + class: if *tag_mode.read() == TagMode::And { "btn active" } else { "btn" }, 2471 + onclick: move |_| tag_mode.set(TagMode::And), 2472 + "All tags (AND)" 2473 + } 2474 + } 2475 + } 2476 + } 2477 + button { 2478 + class: "btn", 2479 + onclick: move |_| { 2480 + let tags = selected_tags.read().clone(); 2481 + let mode = tag_mode.read().clone(); 2482 + let (queue, sidecar) = build_review_queue(&tags, mode); 2483 + session.set(ReviewSession { queue, sidecar }); 2484 + idx.set(0); 2485 + revealed.set(false); 2486 + show_answer.set(false); 2487 + done.set(false); 2488 + graded_count.set(0); 2489 + started.set(true); 2490 + }, 2491 + "Start review →" 2492 + } 2493 + } 2494 + } 2495 + }; 2496 + } 2497 + 2498 + // ── End / empty screens ────────────────────────────────────────────────── 2368 2499 if *done.read() || total == 0 { 2369 2500 return rsx! { 2370 2501 div { id: "review-page", ··· 2373 2504 } else { 2374 2505 p { "Session complete — {graded_count} cards reviewed." } 2375 2506 } 2376 - Link { to: Route::Home {}, class: "btn", "Back to home" } 2507 + button { 2508 + class: "btn", 2509 + onclick: move |_| { 2510 + started.set(false); 2511 + done.set(false); 2512 + graded_count.set(0); 2513 + idx.set(0); 2514 + }, 2515 + "Back to filter" 2516 + } 2377 2517 } 2378 2518 }; 2379 2519 } 2380 2520 2521 + // ── Review screen ──────────────────────────────────────────────────────── 2381 2522 let cur_idx = *idx.read(); 2382 2523 let is_revealed = *revealed.read(); 2383 2524 let is_show_answer = *show_answer.read(); ··· 2399 2540 show_answer.set(true); 2400 2541 } 2401 2542 } 2543 + "s" | "S" => { 2544 + do_skip(session, idx, revealed, show_answer, done); 2545 + } 2402 2546 "1" => { 2403 2547 if *revealed.read() { 2404 2548 do_advance(Grade::Again, session, idx, revealed, show_answer, done, graded_count); ··· 2428 2572 onclick: move |_| { 2429 2573 if has_answer { 2430 2574 let cur = *show_answer.read(); 2431 - show_answer.set(!cur); 2575 + show_answer.set(!cur); 2432 2576 } 2433 2577 }, 2434 2578 img { ··· 2467 2611 onclick: move |_| do_advance(Grade::Easy, session, idx, revealed, show_answer, done, graded_count), 2468 2612 "4 Easy" 2469 2613 } 2614 + button { 2615 + class: "btn", 2616 + onclick: move |_| do_skip(session, idx, revealed, show_answer, done), 2617 + "Skip [S]" 2618 + } 2470 2619 } 2471 2620 } else { 2472 - button { 2473 - class: "btn", 2474 - onclick: move |_| { revealed.set(true); show_answer.set(true); }, 2475 - "Show answer [Space]" 2621 + div { class: "review-grade-row", 2622 + button { 2623 + class: "btn", 2624 + onclick: move |_| { revealed.set(true); show_answer.set(true); }, 2625 + "Show answer [Space]" 2626 + } 2627 + button { 2628 + class: "btn", 2629 + onclick: move |_| do_skip(session, idx, revealed, show_answer, done), 2630 + "Skip [S]" 2631 + } 2476 2632 } 2477 2633 } 2478 2634 }