this repo has no description
1
fork

Configure Feed

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

feat: GUI review mode

- Route /review with navbar link
- Builds due queue from cards.typ + sidecar (same logic as tala-cli)
- Renders question with ReviewFront/ReviewBack/ReviewCloze preamble
- Reveals answer with Authoring preamble on Space/Enter or button
- Grades 1-4 (Again/Hard/Good/Easy) via button or keyboard
- Saves sidecar after each card; no git commit from GUI
- Added Preamble::ReviewBack to tala-typst for bi-directional reverse review

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

+430 -2
+12
crates/tala-typst/src/lib.rs
··· 18 18 Authoring, 19 19 /// FrontBack: front side only. 20 20 ReviewFront, 21 + /// FrontBack: back side only (for bidirectional reverse review). 22 + ReviewBack, 21 23 /// Cloze: body rendered with #blank[] contents replaced by empty boxes. 22 24 ReviewCloze, 23 25 } ··· 383 385 match self { 384 386 Preamble::Authoring => PREAMBLE_AUTHORING, 385 387 Preamble::ReviewFront => PREAMBLE_REVIEW_FRONT, 388 + Preamble::ReviewBack => PREAMBLE_REVIEW_BACK, 386 389 Preamble::ReviewCloze => PREAMBLE_REVIEW_CLOZE, 387 390 } 388 391 } ··· 409 412 const PREAMBLE_REVIEW_FRONT: &str = r#" 410 413 #set page(width: 300pt, height: auto, margin: 10pt) 411 414 #let card(dir: "fwd", tags: (), ..args) = args.pos().at(0, default: []) 415 + #let blank(..args) = args.pos().at(0, default: []) 416 + #let cloze(tags: (), ..args) = args.pos().at(0, default: []) 417 + #let img(name) = image("images/" + name) 418 + "#; 419 + 420 + /// FrontBack: back side only (bidirectional reverse review). 421 + const PREAMBLE_REVIEW_BACK: &str = r#" 422 + #set page(width: 300pt, height: auto, margin: 10pt) 423 + #let card(dir: "fwd", tags: (), ..args) = args.pos().at(1, default: []) 412 424 #let blank(..args) = args.pos().at(0, default: []) 413 425 #let cloze(tags: (), ..args) = args.pos().at(0, default: []) 414 426 #let img(name) = image("images/" + name)
+45
crates/tala/assets/main.css
··· 337 337 padding: 2px 4px; 338 338 outline: none; 339 339 } 340 + 341 + /* ── Review page ─────────────────────────────────────────────────────────────── */ 342 + #review-page { 343 + display: flex; 344 + flex-direction: column; 345 + align-items: center; 346 + flex: 1; 347 + padding: 32px 24px; 348 + gap: 20px; 349 + overflow-y: auto; 350 + outline: none; 351 + } 352 + 353 + .review-progress { 354 + color: #555c70; 355 + font-size: 13px; 356 + font-family: 'JetBrains Mono', 'Fira Code', monospace; 357 + } 358 + 359 + .review-card-img { 360 + max-width: 100%; 361 + max-height: 40vh; 362 + object-fit: contain; 363 + border-radius: 4px; 364 + } 365 + 366 + .review-divider { 367 + width: 100%; 368 + max-width: 600px; 369 + border-top: 1px solid #1e2130; 370 + } 371 + 372 + .review-grade-row { 373 + display: flex; 374 + gap: 12px; 375 + } 376 + 377 + .grade-again { border-color: #7a3040; } 378 + .grade-again:hover { background: #5a2030; color: #fff; } 379 + .grade-hard { border-color: #6a5020; } 380 + .grade-hard:hover { background: #5a4010; color: #fff; } 381 + .grade-good { border-color: #2a5a30; } 382 + .grade-good:hover { background: #1a4a20; color: #fff; } 383 + .grade-easy { border-color: #2a3a7a; } 384 + .grade-easy:hover { background: #1a2a6a; color: #fff; }
+373 -2
crates/tala/src/main.rs
··· 4 4 use base64::Engine as _; 5 5 use dioxus::prelude::*; 6 6 use image::RgbaImage; 7 - use tala_format::CardKind; 8 - use tala_srs::{CardSchedule, RectEntry, Schedule, Sidecar}; 7 + use tala_format::{CardKind, Direction}; 8 + use tala_srs::{CardSchedule, Grade, RectEntry, Schedule, Sidecar, is_due, next_schedule, today_str}; 9 9 10 10 const FAVICON: Asset = asset!("/assets/favicon.ico"); 11 11 const MAIN_CSS: Asset = asset!("/assets/main.css"); ··· 96 96 Editor {}, 97 97 #[route("/images")] 98 98 Images {}, 99 + #[route("/review")] 100 + Review {}, 99 101 #[route("/settings")] 100 102 Settings {}, 101 103 } ··· 128 130 Link { to: Route::Home {}, "Home" } 129 131 Link { to: Route::Editor {}, "Editor" } 130 132 Link { to: Route::Images {}, "Images" } 133 + Link { to: Route::Review {}, "Review" } 131 134 Link { to: Route::Settings {}, "Settings" } 132 135 span { class: "nav-dir", "{dir_name}" } 133 136 } ··· 1912 1915 image_boxes, 1913 1916 }) 1914 1917 } 1918 + 1919 + // ── Review mode ─────────────────────────────────────────────────────────────── 1920 + 1921 + #[derive(Clone)] 1922 + enum ReviewKind { 1923 + FrontBack, 1924 + Cloze, 1925 + } 1926 + 1927 + #[derive(Clone)] 1928 + struct ReviewItem { 1929 + source: String, 1930 + card_id: String, 1931 + sub_id: String, 1932 + current: Option<Schedule>, 1933 + kind: ReviewKind, 1934 + } 1935 + 1936 + struct ReviewSession { 1937 + queue: Vec<ReviewItem>, 1938 + sidecar: Sidecar, 1939 + } 1940 + 1941 + fn build_review_queue() -> (Vec<ReviewItem>, Sidecar) { 1942 + use std::collections::HashMap; 1943 + 1944 + let source = std::fs::read_to_string(cards_path()).unwrap_or_default(); 1945 + let cards = tala_format::parse_cards(&source); 1946 + let sidecar = Sidecar::load_or_empty_for(&cards_path()).unwrap_or_else(|_| Sidecar::empty()); 1947 + let mut queue = Vec::new(); 1948 + 1949 + for (ci, card) in cards.iter().enumerate() { 1950 + let card_id = ci.to_string(); 1951 + let card_src = source[card.span.clone()].to_string(); 1952 + match (&card.kind, sidecar.get(&card_id)) { 1953 + (CardKind::FrontBack { dir, .. }, sched) => { 1954 + let (fwd, rev) = match sched { 1955 + Some(CardSchedule::FrontBack { 1956 + forward_schedule, 1957 + reverse_schedule, 1958 + }) => (forward_schedule.clone(), reverse_schedule.clone()), 1959 + _ => (None, None), 1960 + }; 1961 + if fwd.as_ref().is_none_or(is_due) { 1962 + queue.push(ReviewItem { 1963 + source: card_src.clone(), 1964 + card_id: card_id.clone(), 1965 + sub_id: "forward".into(), 1966 + current: fwd, 1967 + kind: ReviewKind::FrontBack, 1968 + }); 1969 + } 1970 + if matches!(dir, Direction::Bidirectional) && rev.as_ref().is_none_or(is_due) { 1971 + queue.push(ReviewItem { 1972 + source: card_src.clone(), 1973 + card_id: card_id.clone(), 1974 + sub_id: "reverse".into(), 1975 + current: rev, 1976 + kind: ReviewKind::FrontBack, 1977 + }); 1978 + } 1979 + } 1980 + (CardKind::Cloze { blanks, .. }, sched) => { 1981 + let blank_scheds: HashMap<String, Schedule> = match sched { 1982 + Some(CardSchedule::Cloze { blanks: b, .. }) => b.clone(), 1983 + _ => HashMap::new(), 1984 + }; 1985 + for blank in blanks { 1986 + let key = format!("b{}", blank.index); 1987 + let current = blank_scheds.get(&key).cloned(); 1988 + if current.as_ref().is_none_or(is_due) { 1989 + queue.push(ReviewItem { 1990 + source: card_src.clone(), 1991 + card_id: card_id.clone(), 1992 + sub_id: key, 1993 + current, 1994 + kind: ReviewKind::Cloze, 1995 + }); 1996 + } 1997 + } 1998 + } 1999 + } 2000 + } 2001 + 2002 + (queue, sidecar) 2003 + } 2004 + 2005 + fn render_review_b64(source: &str, preamble: tala_typst::Preamble) -> Result<String, String> { 2006 + let result = tala_typst::render(&card_dir(), source, preamble, &[]).map_err(|e| e.to_string())?; 2007 + 2008 + let straight: Vec<u8> = result 2009 + .rgba 2010 + .chunks_exact(4) 2011 + .flat_map(|px| { 2012 + let a = px[3]; 2013 + if a == 0 { 2014 + [0u8, 0, 0, 0] 2015 + } else { 2016 + let af = a as f32 / 255.0; 2017 + [ 2018 + (px[0] as f32 / af).min(255.0) as u8, 2019 + (px[1] as f32 / af).min(255.0) as u8, 2020 + (px[2] as f32 / af).min(255.0) as u8, 2021 + a, 2022 + ] 2023 + } 2024 + }) 2025 + .collect(); 2026 + 2027 + let img = RgbaImage::from_raw(result.width, result.height, straight) 2028 + .ok_or("image buffer size mismatch")?; 2029 + let mut buf = std::io::Cursor::new(Vec::new()); 2030 + image::DynamicImage::ImageRgba8(img) 2031 + .write_to(&mut buf, image::ImageFormat::Png) 2032 + .map_err(|e| e.to_string())?; 2033 + Ok(format!( 2034 + "data:image/png;base64,{}", 2035 + base64::engine::general_purpose::STANDARD.encode(buf.into_inner()) 2036 + )) 2037 + } 2038 + 2039 + fn apply_review_schedule( 2040 + sidecar: &mut Sidecar, 2041 + card_id: &str, 2042 + sub_id: &str, 2043 + sched: Schedule, 2044 + kind: &ReviewKind, 2045 + ) { 2046 + match kind { 2047 + ReviewKind::FrontBack => { 2048 + let entry = 2049 + sidecar 2050 + .cards 2051 + .entry(card_id.to_owned()) 2052 + .or_insert(CardSchedule::FrontBack { 2053 + forward_schedule: None, 2054 + reverse_schedule: None, 2055 + }); 2056 + if let CardSchedule::FrontBack { 2057 + forward_schedule, 2058 + reverse_schedule, 2059 + } = entry 2060 + { 2061 + match sub_id { 2062 + "forward" => *forward_schedule = Some(sched), 2063 + "reverse" => *reverse_schedule = Some(sched), 2064 + _ => {} 2065 + } 2066 + } 2067 + } 2068 + ReviewKind::Cloze => { 2069 + let entry = sidecar 2070 + .cards 2071 + .entry(card_id.to_owned()) 2072 + .or_insert(CardSchedule::Cloze { 2073 + blanks: std::collections::HashMap::new(), 2074 + rects: Vec::new(), 2075 + }); 2076 + if let CardSchedule::Cloze { blanks, .. } = entry { 2077 + blanks.insert(sub_id.to_owned(), sched); 2078 + } 2079 + } 2080 + } 2081 + } 2082 + 2083 + fn review_days_elapsed(current: &Option<Schedule>) -> u32 { 2084 + let Some(sched) = current else { return 0 }; 2085 + fn to_epoch(s: &str) -> i64 { 2086 + let parts: Vec<i64> = s.splitn(3, '-').filter_map(|p| p.parse().ok()).collect(); 2087 + if parts.len() != 3 { 2088 + return 0; 2089 + } 2090 + let (y, m, d) = (parts[0] as i32, parts[1] as u32, parts[2] as u32); 2091 + let (y2, m2) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) }; 2092 + let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400; 2093 + let yoe = y2 - era * 400; 2094 + let doy = (153 * m2 as i64 + 2) / 5 + d as i64 - 1; 2095 + let doe = yoe as i64 * 365 + yoe as i64 / 4 - yoe as i64 / 100 + doy; 2096 + era as i64 * 146097 + doe - 719468 2097 + } 2098 + let elapsed = to_epoch(&today_str()) - to_epoch(&sched.due); 2099 + elapsed.max(0) as u32 2100 + } 2101 + 2102 + fn do_advance( 2103 + grade: Grade, 2104 + mut session: Signal<ReviewSession>, 2105 + mut idx: Signal<usize>, 2106 + mut revealed: Signal<bool>, 2107 + mut done: Signal<bool>, 2108 + mut graded_count: Signal<usize>, 2109 + ) { 2110 + let cur_idx = *idx.read(); 2111 + let (card_id, sub_id, current, kind) = { 2112 + let sess = session.read(); 2113 + let item = &sess.queue[cur_idx]; 2114 + ( 2115 + item.card_id.clone(), 2116 + item.sub_id.clone(), 2117 + item.current.clone(), 2118 + item.kind.clone(), 2119 + ) 2120 + }; 2121 + let days_elapsed = review_days_elapsed(&current); 2122 + let new_sched = next_schedule(current.as_ref(), days_elapsed, grade); 2123 + { 2124 + let mut sess = session.write(); 2125 + apply_review_schedule(&mut sess.sidecar, &card_id, &sub_id, new_sched, &kind); 2126 + sess.sidecar.save_for(&cards_path()).ok(); 2127 + } 2128 + *graded_count.write() += 1; 2129 + let next = cur_idx + 1; 2130 + if next >= session.read().queue.len() { 2131 + done.set(true); 2132 + } else { 2133 + idx.set(next); 2134 + revealed.set(false); 2135 + } 2136 + } 2137 + 2138 + #[component] 2139 + fn Review() -> Element { 2140 + let session = use_signal(|| { 2141 + let (queue, sidecar) = build_review_queue(); 2142 + ReviewSession { queue, sidecar } 2143 + }); 2144 + let idx = use_signal(|| 0usize); 2145 + let mut revealed = use_signal(|| false); 2146 + let done = use_signal(|| false); 2147 + let graded_count = use_signal(|| 0usize); 2148 + 2149 + let total = session.read().queue.len(); 2150 + 2151 + // Question-side image: re-renders when idx or session changes. 2152 + let q_img = use_resource(move || async move { 2153 + let item = { 2154 + let sess = session.read(); 2155 + sess.queue.get(*idx.read()).cloned() 2156 + }; 2157 + let Some(item) = item else { 2158 + return String::new(); 2159 + }; 2160 + let preamble = match (&item.kind, item.sub_id.as_str()) { 2161 + (ReviewKind::FrontBack, "reverse") => tala_typst::Preamble::ReviewBack, 2162 + (ReviewKind::FrontBack, _) => tala_typst::Preamble::ReviewFront, 2163 + (ReviewKind::Cloze, _) => tala_typst::Preamble::ReviewCloze, 2164 + }; 2165 + tokio::task::spawn_blocking(move || { 2166 + render_review_b64(&item.source, preamble).unwrap_or_default() 2167 + }) 2168 + .await 2169 + .unwrap_or_default() 2170 + }); 2171 + 2172 + // Answer-side image: re-renders when revealed or idx changes. 2173 + let a_img = use_resource(move || async move { 2174 + if !*revealed.read() { 2175 + return None; 2176 + } 2177 + let item = { 2178 + let sess = session.read(); 2179 + sess.queue.get(*idx.read()).cloned() 2180 + }; 2181 + let Some(item) = item else { return None }; 2182 + tokio::task::spawn_blocking(move || { 2183 + render_review_b64(&item.source, tala_typst::Preamble::Authoring).ok() 2184 + }) 2185 + .await 2186 + .ok() 2187 + .flatten() 2188 + }); 2189 + 2190 + if *done.read() || total == 0 { 2191 + return rsx! { 2192 + div { id: "review-page", 2193 + if total == 0 { 2194 + p { "No cards due." } 2195 + } else { 2196 + p { "Session complete — {graded_count} cards reviewed." } 2197 + } 2198 + Link { to: Route::Home {}, class: "btn", "Back to home" } 2199 + } 2200 + }; 2201 + } 2202 + 2203 + let cur_idx = *idx.read(); 2204 + let is_revealed = *revealed.read(); 2205 + let q_src = q_img.read(); 2206 + let a_src = a_img.read(); 2207 + 2208 + rsx! { 2209 + div { 2210 + id: "review-page", 2211 + tabindex: 0, 2212 + onmounted: move |e| { 2213 + spawn(async move { e.set_focus(true).await.ok(); }); 2214 + }, 2215 + onkeydown: move |e| match e.data().key().to_string().as_str() { 2216 + " " | "Enter" => { 2217 + if !*revealed.read() { 2218 + revealed.set(true); 2219 + } 2220 + } 2221 + "1" => { 2222 + if *revealed.read() { 2223 + do_advance(Grade::Again, session, idx, revealed, done, graded_count); 2224 + } 2225 + } 2226 + "2" => { 2227 + if *revealed.read() { 2228 + do_advance(Grade::Hard, session, idx, revealed, done, graded_count); 2229 + } 2230 + } 2231 + "3" => { 2232 + if *revealed.read() { 2233 + do_advance(Grade::Good, session, idx, revealed, done, graded_count); 2234 + } 2235 + } 2236 + "4" => { 2237 + if *revealed.read() { 2238 + do_advance(Grade::Easy, session, idx, revealed, done, graded_count); 2239 + } 2240 + } 2241 + _ => {} 2242 + }, 2243 + span { class: "review-progress", "{cur_idx + 1} / {total}" } 2244 + if let Some(src) = q_src.as_ref().filter(|s| !s.is_empty()) { 2245 + img { class: "review-card-img", src: "{src}" } 2246 + } else { 2247 + span { class: "status", "Rendering..." } 2248 + } 2249 + if is_revealed { 2250 + div { class: "review-divider" } 2251 + if let Some(Some(src)) = a_src.as_ref() { 2252 + img { class: "review-card-img", src: "{src}" } 2253 + } 2254 + div { class: "review-grade-row", 2255 + button { 2256 + class: "btn grade-again", 2257 + onclick: move |_| do_advance(Grade::Again, session, idx, revealed, done, graded_count), 2258 + "1 Again" 2259 + } 2260 + button { 2261 + class: "btn grade-hard", 2262 + onclick: move |_| do_advance(Grade::Hard, session, idx, revealed, done, graded_count), 2263 + "2 Hard" 2264 + } 2265 + button { 2266 + class: "btn grade-good", 2267 + onclick: move |_| do_advance(Grade::Good, session, idx, revealed, done, graded_count), 2268 + "3 Good" 2269 + } 2270 + button { 2271 + class: "btn grade-easy", 2272 + onclick: move |_| do_advance(Grade::Easy, session, idx, revealed, done, graded_count), 2273 + "4 Easy" 2274 + } 2275 + } 2276 + } else { 2277 + button { 2278 + class: "btn", 2279 + onclick: move |_| revealed.set(true), 2280 + "Show answer [Space]" 2281 + } 2282 + } 2283 + } 2284 + } 2285 + }