this repo has no description
1
fork

Configure Feed

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

tala: Refactor review to separate module

+819 -794
+11 -794
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, Direction}; 8 - use tala_srs::{CardSchedule, Grade, RectEntry, Schedule, Sidecar, is_due, next_schedule, today_str}; 7 + use tala_format::{CardKind}; 8 + use tala_srs::{CardSchedule, RectEntry, Schedule, Sidecar}; 9 + 10 + use crate::util::{TagMode, card_dir, set_card_dir, cards_path, extract_img_names}; 11 + use crate::review::Review; 12 + 13 + mod review; 14 + mod util; 9 15 10 16 const FAVICON: Asset = asset!("/assets/favicon.ico"); 11 17 const MAIN_CSS: Asset = asset!("/assets/main.css"); 12 18 13 - fn config_dir_file() -> Option<PathBuf> { 14 - let home = std::env::var("HOME").ok()?; 15 - Some(PathBuf::from(home).join(".config").join("tala").join("dir")) 16 - } 17 - 18 - fn load_saved_dir() -> Option<PathBuf> { 19 - let path = config_dir_file()?; 20 - let s = std::fs::read_to_string(path).ok()?; 21 - let p = PathBuf::from(s.trim()); 22 - if p.is_dir() { 23 - Some(p) 24 - } else { 25 - None 26 - } 27 - } 28 - 29 - fn persist_dir(path: &Path) { 30 - if let Some(f) = config_dir_file() { 31 - if let Some(parent) = f.parent() { 32 - let _ = std::fs::create_dir_all(parent); 33 - } 34 - let _ = std::fs::write(&f, path.display().to_string()); 35 - } 36 - } 37 - 38 - /// Directory containing cards.typ and images/. 39 - static CARD_DIR: GlobalSignal<PathBuf> = Signal::global(|| { 40 - let dir = std::env::args() 41 - .nth(1) 42 - .map(PathBuf::from) 43 - .or_else(load_saved_dir) 44 - .unwrap_or_else(|| std::env::current_dir().expect("cwd")); 45 - let dir = std::fs::canonicalize(&dir).unwrap_or(dir); 46 - let _ = std::fs::create_dir_all(dir.join("images")); 47 - dir 48 - }); 49 - 50 - fn card_dir() -> PathBuf { 51 - CARD_DIR.read().clone() 52 - } 53 - 54 - fn cards_path() -> PathBuf { 55 - card_dir().join("cards.typ") 56 - } 57 - 58 - fn set_card_dir(path: PathBuf) { 59 - let path = std::fs::canonicalize(&path).unwrap_or(path); 60 - let _ = std::fs::create_dir_all(path.join("images")); 61 - persist_dir(&path); 62 - *CARD_DIR.write() = path; 63 - } 64 19 65 20 // Ctrl+/- zoom and Ctrl+0 reset, persisted in sessionStorage. 66 21 const ZOOM_JS: &str = r#" ··· 120 75 /// Persistent shell: navbar + page content. 121 76 #[component] 122 77 fn Shell() -> Element { 123 - let dir = CARD_DIR.read(); 78 + let dir = card_dir(); 124 79 let dir_name = dir 125 80 .file_name() 126 81 .map(|n| n.to_string_lossy().into_owned()) ··· 140 95 141 96 #[component] 142 97 fn Home() -> Element { 143 - let dir_display = CARD_DIR.read().display().to_string(); 98 + let dir_display = card_dir().display().to_string(); 144 99 rsx! { 145 100 div { id: "home", 146 101 h2 { "tala" } ··· 255 210 || s.starts_with("#cloze(") 256 211 } 257 212 258 - /// Extract image source arguments in document order from a fragment. 259 - /// Always returns bare stems (no path prefix, no extension) so they match sidecar `src` fields. 260 - /// Handles both `#img("name")` (custom shorthand) and `#image("path")` (native typst). 261 - fn extract_img_names(source: &str) -> Vec<String> { 262 - let mut names = Vec::new(); 263 - let mut rest = source; 264 - while !rest.is_empty() { 265 - let img_pos = rest.find("#img(\""); 266 - let image_pos = rest.find("#image(\""); 267 - let (pos, skip) = match (img_pos, image_pos) { 268 - (Some(a), Some(b)) => if a <= b { (a, 6) } else { (b, 8) }, 269 - (Some(a), None) => (a, 6), 270 - (None, Some(b)) => (b, 8), 271 - (None, None) => break, 272 - }; 273 - rest = &rest[pos + skip..]; 274 - if let Some(end) = rest.find('"') { 275 - let raw = &rest[..end]; 276 - // Normalize to bare stem: strip leading "images/" and trailing extension. 277 - let after_prefix = raw.strip_prefix("images/").unwrap_or(raw); 278 - let stem = std::path::Path::new(after_prefix) 279 - .file_stem() 280 - .and_then(|s| s.to_str()) 281 - .unwrap_or(after_prefix); 282 - names.push(stem.to_string()); 283 - rest = &rest[end + 1..]; 284 - } 285 - } 286 - names 287 - } 288 213 289 214 /// Split source into paragraph segments (blank-line separated) and classify each. 290 215 /// Typst is never called here — classification is a pure prefix check. ··· 542 467 // ── Tag filter ──────────────────────────────────────────────────────────── 543 468 let mut editor_selected_tags: Signal<Vec<String>> = use_signal(Vec::new); 544 469 let mut editor_tag_mode = use_signal(|| TagMode::Or); 545 - let editor_all_tags = use_memo(move || collect_all_tags_from_source(&source.read())); 470 + let editor_all_tags = use_memo(move || util::collect_all_tags_from_source(&source.read())); 546 471 547 472 // ── Multi-card state ─────────────────────────────────────────────────────── 548 473 let mut active_idx = use_signal(|| 0usize); ··· 2045 1970 }) 2046 1971 } 2047 1972 2048 - // ── Review mode ─────────────────────────────────────────────────────────────── 2049 - 2050 - #[derive(Clone, PartialEq)] 2051 - enum TagMode { 2052 - Or, 2053 - And, 2054 - } 2055 - 2056 - #[derive(Clone)] 2057 - enum ReviewKind { 2058 - FrontBack, 2059 - Cloze, 2060 - ImageRect { src: String, rect: [f32; 4] }, 2061 - } 2062 - 2063 - #[derive(Clone)] 2064 - struct ReviewItem { 2065 - source: String, 2066 - card_id: String, 2067 - sub_id: String, 2068 - current: Option<Schedule>, 2069 - kind: ReviewKind, 2070 - tags: Vec<String>, 2071 - skipped: bool, 2072 - } 2073 - 2074 - struct ReviewSession { 2075 - queue: Vec<ReviewItem>, 2076 - sidecar: Sidecar, 2077 - } 2078 - 2079 - fn build_review_queue(tag_filter: &[String], mode: TagMode) -> (Vec<ReviewItem>, Sidecar) { 2080 - use std::collections::HashMap; 2081 - 2082 - let source = std::fs::read_to_string(cards_path()).unwrap_or_default(); 2083 - let cards = tala_format::parse_cards(&source); 2084 - let sidecar = Sidecar::load_or_empty_for(&cards_path()).unwrap_or_else(|_| Sidecar::empty()); 2085 - let mut queue = Vec::new(); 2086 - 2087 - for (ci, card) in cards.iter().enumerate() { 2088 - if !tag_filter.is_empty() { 2089 - let matches = match mode { 2090 - TagMode::And => tag_filter.iter().all(|t| card.tags.contains(t)), 2091 - TagMode::Or => tag_filter.iter().any(|t| card.tags.contains(t)), 2092 - }; 2093 - if !matches { 2094 - continue; 2095 - } 2096 - } 2097 - let card_id = ci.to_string(); 2098 - let card_src = source[card.span.start.saturating_sub(1)..card.span.end].to_string(); 2099 - let card_tags = card.tags.clone(); 2100 - match (&card.kind, sidecar.get(&card_id)) { 2101 - (CardKind::FrontBack { dir, .. }, sched) => { 2102 - let (fwd, rev) = match sched { 2103 - Some(CardSchedule::FrontBack { 2104 - forward_schedule, 2105 - reverse_schedule, 2106 - }) => (forward_schedule.clone(), reverse_schedule.clone()), 2107 - _ => (None, None), 2108 - }; 2109 - if fwd.as_ref().is_none_or(is_due) { 2110 - queue.push(ReviewItem { 2111 - source: card_src.clone(), 2112 - card_id: card_id.clone(), 2113 - sub_id: "forward".into(), 2114 - current: fwd, 2115 - kind: ReviewKind::FrontBack, 2116 - tags: card_tags.clone(), 2117 - skipped: false, 2118 - }); 2119 - } 2120 - if matches!(dir, Direction::Bidirectional) && rev.as_ref().is_none_or(is_due) { 2121 - queue.push(ReviewItem { 2122 - source: card_src.clone(), 2123 - card_id: card_id.clone(), 2124 - sub_id: "reverse".into(), 2125 - current: rev, 2126 - kind: ReviewKind::FrontBack, 2127 - tags: card_tags.clone(), 2128 - skipped: false, 2129 - }); 2130 - } 2131 - } 2132 - (CardKind::Cloze { blanks, .. }, sched) => { 2133 - let (blank_scheds, sidecar_rects): (HashMap<String, Schedule>, Vec<_>) = 2134 - match sched { 2135 - Some(CardSchedule::Cloze { blanks: b, rects: r }) => { 2136 - (b.clone(), r.clone()) 2137 - } 2138 - _ => (HashMap::new(), Vec::new()), 2139 - }; 2140 - for blank in blanks { 2141 - let key = format!("b{}", blank.index); 2142 - let current = blank_scheds.get(&key).cloned(); 2143 - if current.as_ref().is_none_or(is_due) { 2144 - queue.push(ReviewItem { 2145 - source: card_src.clone(), 2146 - card_id: card_id.clone(), 2147 - sub_id: key, 2148 - current, 2149 - kind: ReviewKind::Cloze, 2150 - tags: card_tags.clone(), 2151 - skipped: false, 2152 - }); 2153 - } 2154 - } 2155 - for rect_entry in sidecar_rects { 2156 - if is_due(&rect_entry.schedule) { 2157 - queue.push(ReviewItem { 2158 - source: card_src.clone(), 2159 - card_id: card_id.clone(), 2160 - sub_id: format!("rect-{}", rect_entry.id), 2161 - current: Some(rect_entry.schedule.clone()), 2162 - kind: ReviewKind::ImageRect { 2163 - src: rect_entry.src.clone(), 2164 - rect: rect_entry.rect, 2165 - }, 2166 - tags: card_tags.clone(), 2167 - skipped: false, 2168 - }); 2169 - } 2170 - } 2171 - } 2172 - } 2173 - } 2174 - 2175 - (queue, sidecar) 2176 - } 2177 - 2178 - fn render_review_b64( 2179 - dir: &std::path::Path, 2180 - source: &str, 2181 - preamble: tala_typst::Preamble, 2182 - ) -> Result<String, String> { 2183 - let result = tala_typst::render(dir, source, preamble, &[]).map_err(|e| e.to_string())?; 2184 - 2185 - let straight: Vec<u8> = result 2186 - .rgba 2187 - .chunks_exact(4) 2188 - .flat_map(|px| { 2189 - let a = px[3]; 2190 - if a == 0 { 2191 - [0u8, 0, 0, 0] 2192 - } else { 2193 - let af = a as f32 / 255.0; 2194 - [ 2195 - (px[0] as f32 / af).min(255.0) as u8, 2196 - (px[1] as f32 / af).min(255.0) as u8, 2197 - (px[2] as f32 / af).min(255.0) as u8, 2198 - a, 2199 - ] 2200 - } 2201 - }) 2202 - .collect(); 2203 - 2204 - let img = RgbaImage::from_raw(result.width, result.height, straight) 2205 - .ok_or("image buffer size mismatch")?; 2206 - let mut buf = std::io::Cursor::new(Vec::new()); 2207 - image::DynamicImage::ImageRgba8(img) 2208 - .write_to(&mut buf, image::ImageFormat::Png) 2209 - .map_err(|e| e.to_string())?; 2210 - Ok(format!( 2211 - "data:image/png;base64,{}", 2212 - base64::engine::general_purpose::STANDARD.encode(buf.into_inner()) 2213 - )) 2214 - } 2215 - 2216 - fn render_rect_question_b64( 2217 - dir: &std::path::Path, 2218 - source: &str, 2219 - img_src: &str, 2220 - rect: [f32; 4], 2221 - ) -> Result<String, String> { 2222 - let result = tala_typst::render(dir, source, tala_typst::Preamble::Authoring, &[]) 2223 - .map_err(|e| e.to_string())?; 2224 - 2225 - let img_names = extract_img_names(source); 2226 - let img_idx = img_names.iter().position(|n| n == img_src); 2227 - 2228 - let mut straight: Vec<u8> = result 2229 - .rgba 2230 - .chunks_exact(4) 2231 - .flat_map(|px| { 2232 - let a = px[3]; 2233 - if a == 0 { 2234 - [0u8, 0, 0, 0] 2235 - } else { 2236 - let af = a as f32 / 255.0; 2237 - [ 2238 - (px[0] as f32 / af).min(255.0) as u8, 2239 - (px[1] as f32 / af).min(255.0) as u8, 2240 - (px[2] as f32 / af).min(255.0) as u8, 2241 - a, 2242 - ] 2243 - } 2244 - }) 2245 - .collect(); 2246 - 2247 - if let Some(idx) = img_idx { 2248 - if let Some(box_) = result.image_boxes.get(idx) { 2249 - let [bx, by, bw, bh] = *box_; // f32 pixel coords from RenderResult 2250 - let col0 = (bx + rect[0] * bw) as u32; 2251 - let row0 = (by + rect[1] * bh) as u32; 2252 - let col1 = ((bx + (rect[0] + rect[2]) * bw) as u32).min(result.width); 2253 - let row1 = ((by + (rect[1] + rect[3]) * bh) as u32).min(result.height); 2254 - let stride = result.width as usize * 4; 2255 - for row in row0.min(result.height)..row1 { 2256 - for col in col0.min(result.width)..col1 { 2257 - let off = row as usize * stride + col as usize * 4; 2258 - if off + 3 < straight.len() { 2259 - straight[off] = 30; 2260 - straight[off + 1] = 35; 2261 - straight[off + 2] = 50; 2262 - straight[off + 3] = 255; 2263 - } 2264 - } 2265 - } 2266 - } 2267 - } 2268 - 2269 - let img = RgbaImage::from_raw(result.width, result.height, straight) 2270 - .ok_or("image buffer size mismatch")?; 2271 - let mut buf = std::io::Cursor::new(Vec::new()); 2272 - image::DynamicImage::ImageRgba8(img) 2273 - .write_to(&mut buf, image::ImageFormat::Png) 2274 - .map_err(|e| e.to_string())?; 2275 - Ok(format!( 2276 - "data:image/png;base64,{}", 2277 - base64::engine::general_purpose::STANDARD.encode(buf.into_inner()) 2278 - )) 2279 - } 2280 - 2281 - fn apply_review_schedule( 2282 - sidecar: &mut Sidecar, 2283 - card_id: &str, 2284 - sub_id: &str, 2285 - sched: Schedule, 2286 - kind: &ReviewKind, 2287 - ) { 2288 - match kind { 2289 - ReviewKind::FrontBack => { 2290 - let entry = 2291 - sidecar 2292 - .cards 2293 - .entry(card_id.to_owned()) 2294 - .or_insert(CardSchedule::FrontBack { 2295 - forward_schedule: None, 2296 - reverse_schedule: None, 2297 - }); 2298 - if let CardSchedule::FrontBack { 2299 - forward_schedule, 2300 - reverse_schedule, 2301 - } = entry 2302 - { 2303 - match sub_id { 2304 - "forward" => *forward_schedule = Some(sched), 2305 - "reverse" => *reverse_schedule = Some(sched), 2306 - _ => {} 2307 - } 2308 - } 2309 - } 2310 - ReviewKind::Cloze => { 2311 - let entry = sidecar 2312 - .cards 2313 - .entry(card_id.to_owned()) 2314 - .or_insert(CardSchedule::Cloze { 2315 - blanks: std::collections::HashMap::new(), 2316 - rects: Vec::new(), 2317 - }); 2318 - if let CardSchedule::Cloze { blanks, .. } = entry { 2319 - blanks.insert(sub_id.to_owned(), sched); 2320 - } 2321 - } 2322 - ReviewKind::ImageRect { .. } => { 2323 - let rect_id = sub_id.strip_prefix("rect-").unwrap_or(sub_id); 2324 - if let Some(CardSchedule::Cloze { rects, .. }) = sidecar.cards.get_mut(card_id) { 2325 - if let Some(r) = rects.iter_mut().find(|r| r.id == rect_id) { 2326 - r.schedule = sched; 2327 - } 2328 - } 2329 - } 2330 - } 2331 - } 2332 - 2333 - fn review_days_elapsed(current: &Option<Schedule>) -> u32 { 2334 - let Some(sched) = current else { return 0 }; 2335 - fn to_epoch(s: &str) -> i64 { 2336 - let parts: Vec<i64> = s.splitn(3, '-').filter_map(|p| p.parse().ok()).collect(); 2337 - if parts.len() != 3 { 2338 - return 0; 2339 - } 2340 - let (y, m, d) = (parts[0] as i32, parts[1] as u32, parts[2] as u32); 2341 - let (y2, m2) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) }; 2342 - let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400; 2343 - let yoe = y2 - era * 400; 2344 - let doy = (153 * m2 as i64 + 2) / 5 + d as i64 - 1; 2345 - let doe = yoe as i64 * 365 + yoe as i64 / 4 - yoe as i64 / 100 + doy; 2346 - era as i64 * 146097 + doe - 719468 2347 - } 2348 - let elapsed = to_epoch(&today_str()) - to_epoch(&sched.due); 2349 - elapsed.max(0) as u32 2350 - } 2351 - 2352 - fn do_advance( 2353 - grade: Grade, 2354 - mut session: Signal<ReviewSession>, 2355 - mut idx: Signal<usize>, 2356 - mut revealed: Signal<bool>, 2357 - mut show_answer: Signal<bool>, 2358 - mut done: Signal<bool>, 2359 - mut graded_count: Signal<usize>, 2360 - ) { 2361 - let cur_idx = *idx.read(); 2362 - let (card_id, sub_id, current, kind) = { 2363 - let sess = session.read(); 2364 - let item = &sess.queue[cur_idx]; 2365 - ( 2366 - item.card_id.clone(), 2367 - item.sub_id.clone(), 2368 - item.current.clone(), 2369 - item.kind.clone(), 2370 - ) 2371 - }; 2372 - let days_elapsed = review_days_elapsed(&current); 2373 - let new_sched = next_schedule(current.as_ref(), days_elapsed, grade); 2374 - { 2375 - let mut sess = session.write(); 2376 - apply_review_schedule(&mut sess.sidecar, &card_id, &sub_id, new_sched, &kind); 2377 - sess.sidecar.save_for(&cards_path()).ok(); 2378 - } 2379 - *graded_count.write() += 1; 2380 - let next = cur_idx + 1; 2381 - if next >= session.read().queue.len() { 2382 - done.set(true); 2383 - } else { 2384 - idx.set(next); 2385 - revealed.set(false); 2386 - show_answer.set(false); 2387 - } 2388 - } 2389 - 2390 - fn do_skip( 2391 - mut session: Signal<ReviewSession>, 2392 - idx: Signal<usize>, 2393 - mut revealed: Signal<bool>, 2394 - mut show_answer: Signal<bool>, 2395 - mut done: Signal<bool>, 2396 - ) { 2397 - let cur_idx = *idx.read(); 2398 - let all_skipped = { 2399 - let mut sess = session.write(); 2400 - let mut item = sess.queue.remove(cur_idx); 2401 - item.skipped = true; 2402 - sess.queue.push(item); 2403 - sess.queue[cur_idx..].iter().all(|i| i.skipped) 2404 - }; 2405 - if all_skipped { 2406 - done.set(true); 2407 - } else { 2408 - revealed.set(false); 2409 - show_answer.set(false); 2410 - } 2411 - } 2412 - 2413 - fn collect_all_tags_from_source(source: &str) -> Vec<String> { 2414 - let cards = tala_format::parse_cards(source); 2415 - let mut tags: Vec<String> = cards.into_iter().flat_map(|c| c.tags).collect(); 2416 - tags.sort(); 2417 - tags.dedup(); 2418 - tags 2419 - } 2420 - 2421 - fn collect_all_tags() -> Vec<String> { 2422 - let source = std::fs::read_to_string(cards_path()).unwrap_or_default(); 2423 - collect_all_tags_from_source(&source) 2424 - } 2425 - 2426 - #[component] 2427 - fn Review() -> Element { 2428 - let mut started = use_signal(|| false); 2429 - let mut selected_tags: Signal<Vec<String>> = use_signal(Vec::new); 2430 - let mut tag_mode = use_signal(|| TagMode::Or); 2431 - let all_tags = use_signal(collect_all_tags); 2432 - let mut session = use_signal(|| ReviewSession { queue: Vec::new(), sidecar: Sidecar::empty() }); 2433 - let mut idx = use_signal(|| 0usize); 2434 - let mut revealed = use_signal(|| false); 2435 - let mut show_answer = use_signal(|| false); 2436 - let mut done = use_signal(|| false); 2437 - let mut graded_count = use_signal(|| 0usize); 2438 - 2439 - let total = session.read().queue.len(); 2440 - 2441 - // Question-side image: re-renders when idx or session changes. 2442 - let q_img = use_resource(move || async move { 2443 - if !*started.read() { 2444 - return String::new(); 2445 - } 2446 - let item = { 2447 - let sess = session.read(); 2448 - sess.queue.get(*idx.read()).cloned() 2449 - }; 2450 - let Some(item) = item else { 2451 - return String::new(); 2452 - }; 2453 - let dir = card_dir(); 2454 - tokio::task::spawn_blocking(move || match item.kind { 2455 - ReviewKind::FrontBack => { 2456 - let preamble = if item.sub_id == "reverse" { 2457 - tala_typst::Preamble::ReviewBack 2458 - } else { 2459 - tala_typst::Preamble::ReviewFront 2460 - }; 2461 - render_review_b64(&dir, &item.source, preamble).unwrap_or_default() 2462 - } 2463 - ReviewKind::Cloze => { 2464 - render_review_b64(&dir, &item.source, tala_typst::Preamble::ReviewCloze) 2465 - .unwrap_or_default() 2466 - } 2467 - ReviewKind::ImageRect { src, rect } => { 2468 - render_rect_question_b64(&dir, &item.source, &src, rect).unwrap_or_default() 2469 - } 2470 - }) 2471 - .await 2472 - .unwrap_or_default() 2473 - }); 2474 - 2475 - // Answer-side image: starts loading once revealed; cached until idx changes. 2476 - let a_img = use_resource(move || async move { 2477 - if !*started.read() || !*revealed.read() { 2478 - return None; 2479 - } 2480 - let item = { 2481 - let sess = session.read(); 2482 - sess.queue.get(*idx.read()).cloned() 2483 - }; 2484 - let Some(item) = item else { return None }; 2485 - let dir = card_dir(); 2486 - tokio::task::spawn_blocking(move || { 2487 - render_review_b64(&dir, &item.source, tala_typst::Preamble::Authoring).ok() 2488 - }) 2489 - .await 2490 - .ok() 2491 - .flatten() 2492 - }); 2493 - 2494 - // ── Setup screen ───────────────────────────────────────────────────────── 2495 - if !*started.read() { 2496 - let available_tags = all_tags.read().clone(); 2497 - return rsx! { 2498 - div { id: "review-page", 2499 - div { class: "review-setup", 2500 - if available_tags.is_empty() { 2501 - p { class: "review-progress", "No tags defined in deck." } 2502 - } else { 2503 - p { class: "review-progress", "Filter by tags:" } 2504 - div { class: "tag-chip-row", 2505 - for tag in available_tags { 2506 - { 2507 - let tag2 = tag.clone(); 2508 - let is_sel = selected_tags.read().contains(&tag); 2509 - rsx! { 2510 - button { 2511 - class: if is_sel { "tag-chip selected" } else { "tag-chip" }, 2512 - onclick: move |_| { 2513 - let mut sel = selected_tags.write(); 2514 - if sel.contains(&tag2) { 2515 - sel.retain(|t| t != &tag2); 2516 - } else { 2517 - sel.push(tag2.clone()); 2518 - } 2519 - }, 2520 - "{tag}" 2521 - } 2522 - } 2523 - } 2524 - } 2525 - } 2526 - if selected_tags.read().len() > 1 { 2527 - div { class: "review-grade-row", 2528 - button { 2529 - class: if *tag_mode.read() == TagMode::Or { "btn active" } else { "btn" }, 2530 - onclick: move |_| tag_mode.set(TagMode::Or), 2531 - "Any tag (OR)" 2532 - } 2533 - button { 2534 - class: if *tag_mode.read() == TagMode::And { "btn active" } else { "btn" }, 2535 - onclick: move |_| tag_mode.set(TagMode::And), 2536 - "All tags (AND)" 2537 - } 2538 - } 2539 - } 2540 - } 2541 - button { 2542 - class: "btn", 2543 - onclick: move |_| { 2544 - let tags = selected_tags.read().clone(); 2545 - let mode = tag_mode.read().clone(); 2546 - let (queue, sidecar) = build_review_queue(&tags, mode); 2547 - session.set(ReviewSession { queue, sidecar }); 2548 - idx.set(0); 2549 - revealed.set(false); 2550 - show_answer.set(false); 2551 - done.set(false); 2552 - graded_count.set(0); 2553 - started.set(true); 2554 - }, 2555 - "Start review →" 2556 - } 2557 - } 2558 - } 2559 - }; 2560 - } 2561 - 2562 - // ── End / empty screens ────────────────────────────────────────────────── 2563 - if *done.read() || total == 0 { 2564 - return rsx! { 2565 - div { id: "review-page", 2566 - if total == 0 { 2567 - p { "No cards due." } 2568 - } else { 2569 - p { "Session complete — {graded_count} cards reviewed." } 2570 - } 2571 - button { 2572 - class: "btn", 2573 - onclick: move |_| { 2574 - started.set(false); 2575 - done.set(false); 2576 - graded_count.set(0); 2577 - idx.set(0); 2578 - }, 2579 - "Back to filter" 2580 - } 2581 - } 2582 - }; 2583 - } 2584 - 2585 - // ── Review screen ──────────────────────────────────────────────────────── 2586 - let cur_idx = *idx.read(); 2587 - let is_revealed = *revealed.read(); 2588 - let is_show_answer = *show_answer.read(); 2589 - let q_src = q_img.read(); 2590 - let a_src = a_img.read(); 2591 - let has_answer = matches!(a_src.as_ref(), Some(Some(_))); 2592 - let session_tags_snap = selected_tags.read().clone(); 2593 - let session_tag_mode_snap = tag_mode.read().clone(); 2594 - let session_all_tags_snap = all_tags.read().clone(); 2595 - 2596 - rsx! { 2597 - div { 2598 - id: "review-page", 2599 - tabindex: 0, 2600 - onmounted: move |e| { 2601 - spawn(async move { e.set_focus(true).await.ok(); }); 2602 - }, 2603 - onkeydown: move |e| match e.data().key().to_string().as_str() { 2604 - " " | "Enter" => { 2605 - if !*revealed.read() { 2606 - revealed.set(true); 2607 - show_answer.set(true); 2608 - } 2609 - } 2610 - "s" | "S" => { 2611 - do_skip(session, idx, revealed, show_answer, done); 2612 - } 2613 - "1" => { 2614 - if *revealed.read() { 2615 - do_advance(Grade::Again, session, idx, revealed, show_answer, done, graded_count); 2616 - } 2617 - } 2618 - "2" => { 2619 - if *revealed.read() { 2620 - do_advance(Grade::Hard, session, idx, revealed, show_answer, done, graded_count); 2621 - } 2622 - } 2623 - "3" => { 2624 - if *revealed.read() { 2625 - do_advance(Grade::Good, session, idx, revealed, show_answer, done, graded_count); 2626 - } 2627 - } 2628 - "4" => { 2629 - if *revealed.read() { 2630 - do_advance(Grade::Easy, session, idx, revealed, show_answer, done, graded_count); 2631 - } 2632 - } 2633 - _ => {} 2634 - }, 2635 - // ── Inline filter bar ──────────────────────────────────────────── 2636 - if !session_all_tags_snap.is_empty() { 2637 - div { class: "tag-chip-row", 2638 - for tag in session_all_tags_snap.clone() { 2639 - { 2640 - let tag2 = tag.clone(); 2641 - let is_sel = session_tags_snap.contains(&tag); 2642 - rsx! { 2643 - button { 2644 - class: if is_sel { "tag-chip selected" } else { "tag-chip" }, 2645 - onclick: move |_| { 2646 - let mut sel = selected_tags.write(); 2647 - if sel.contains(&tag2) { 2648 - sel.retain(|t| t != &tag2); 2649 - } else { 2650 - sel.push(tag2.clone()); 2651 - } 2652 - }, 2653 - "{tag}" 2654 - } 2655 - } 2656 - } 2657 - } 2658 - if session_tags_snap.len() > 1 { 2659 - button { 2660 - class: if session_tag_mode_snap == TagMode::Or { "btn active" } else { "btn" }, 2661 - onclick: move |_| tag_mode.set(TagMode::Or), 2662 - "OR" 2663 - } 2664 - button { 2665 - class: if session_tag_mode_snap == TagMode::And { "btn active" } else { "btn" }, 2666 - onclick: move |_| tag_mode.set(TagMode::And), 2667 - "AND" 2668 - } 2669 - } 2670 - button { 2671 - class: "btn", 2672 - onclick: move |_| { 2673 - let tags = selected_tags.read().clone(); 2674 - let mode = tag_mode.read().clone(); 2675 - let (queue, sidecar) = build_review_queue(&tags, mode); 2676 - session.set(ReviewSession { queue, sidecar }); 2677 - idx.set(0); 2678 - revealed.set(false); 2679 - show_answer.set(false); 2680 - done.set(false); 2681 - graded_count.set(0); 2682 - }, 2683 - "Restart \u{2192}" 2684 - } 2685 - } 2686 - } 2687 - span { class: "review-progress", "{cur_idx + 1} / {total}" } 2688 - if let Some(q) = q_src.as_ref().filter(|s| !s.is_empty()) { 2689 - div { 2690 - class: if has_answer { "review-card-stack clickable" } else { "review-card-stack" }, 2691 - onclick: move |_| { 2692 - if has_answer { 2693 - let cur = *show_answer.read(); 2694 - show_answer.set(!cur); 2695 - } 2696 - }, 2697 - img { 2698 - src: "{q}", 2699 - style: if is_revealed && is_show_answer { "opacity: 0" } else { "opacity: 1" }, 2700 - } 2701 - if let Some(Some(a)) = a_src.as_ref() { 2702 - img { 2703 - src: "{a}", 2704 - style: if is_show_answer { "opacity: 1" } else { "opacity: 0" }, 2705 - } 2706 - } 2707 - } 2708 - } else { 2709 - span { class: "status", "Rendering..." } 2710 - } 2711 - if is_revealed { 2712 - div { class: "review-grade-row", 2713 - button { 2714 - class: "btn grade-again", 2715 - onclick: move |_| do_advance(Grade::Again, session, idx, revealed, show_answer, done, graded_count), 2716 - "1 Again" 2717 - } 2718 - button { 2719 - class: "btn grade-hard", 2720 - onclick: move |_| do_advance(Grade::Hard, session, idx, revealed, show_answer, done, graded_count), 2721 - "2 Hard" 2722 - } 2723 - button { 2724 - class: "btn grade-good", 2725 - onclick: move |_| do_advance(Grade::Good, session, idx, revealed, show_answer, done, graded_count), 2726 - "3 Good" 2727 - } 2728 - button { 2729 - class: "btn grade-easy", 2730 - onclick: move |_| do_advance(Grade::Easy, session, idx, revealed, show_answer, done, graded_count), 2731 - "4 Easy" 2732 - } 2733 - button { 2734 - class: "btn", 2735 - onclick: move |_| do_skip(session, idx, revealed, show_answer, done), 2736 - "Skip [S]" 2737 - } 2738 - } 2739 - } else { 2740 - div { class: "review-grade-row", 2741 - button { 2742 - class: "btn", 2743 - onclick: move |_| { revealed.set(true); show_answer.set(true); }, 2744 - "Show answer [Space]" 2745 - } 2746 - button { 2747 - class: "btn", 2748 - onclick: move |_| do_skip(session, idx, revealed, show_answer, done), 2749 - "Skip [S]" 2750 - } 2751 - } 2752 - } 2753 - } 2754 - } 2755 - }
+699
crates/tala/src/review.rs
··· 1 + use base64::Engine as _; 2 + use dioxus::prelude::*; 3 + use image::RgbaImage; 4 + use tala_format::{CardKind, Direction}; 5 + use tala_srs::{CardSchedule, Grade, Schedule, Sidecar, is_due, next_schedule, today_str}; 6 + 7 + use crate::util::{TagMode, card_dir, cards_path, extract_img_names}; 8 + 9 + #[derive(Clone)] 10 + enum ReviewKind { 11 + FrontBack, 12 + Cloze, 13 + ImageRect { src: String, rect: [f32; 4] }, 14 + } 15 + 16 + #[derive(Clone)] 17 + struct ReviewItem { 18 + source: String, 19 + card_id: String, 20 + sub_id: String, 21 + current: Option<Schedule>, 22 + kind: ReviewKind, 23 + tags: Vec<String>, 24 + skipped: bool, 25 + } 26 + 27 + 28 + struct ReviewSession { 29 + queue: Vec<ReviewItem>, 30 + sidecar: Sidecar, 31 + } 32 + 33 + fn do_advance( 34 + grade: Grade, 35 + mut session: Signal<ReviewSession>, 36 + mut idx: Signal<usize>, 37 + mut revealed: Signal<bool>, 38 + mut show_answer: Signal<bool>, 39 + mut done: Signal<bool>, 40 + mut graded_count: Signal<usize>, 41 + ) { 42 + let cur_idx = *idx.read(); 43 + let (card_id, sub_id, current, kind) = { 44 + let sess = session.read(); 45 + let item = &sess.queue[cur_idx]; 46 + ( 47 + item.card_id.clone(), 48 + item.sub_id.clone(), 49 + item.current.clone(), 50 + item.kind.clone(), 51 + ) 52 + }; 53 + let days_elapsed = review_days_elapsed(&current); 54 + let new_sched = next_schedule(current.as_ref(), days_elapsed, grade); 55 + { 56 + let mut sess = session.write(); 57 + apply_review_schedule(&mut sess.sidecar, &card_id, &sub_id, new_sched, &kind); 58 + sess.sidecar.save_for(&cards_path()).ok(); 59 + } 60 + *graded_count.write() += 1; 61 + let next = cur_idx + 1; 62 + if next >= session.read().queue.len() { 63 + done.set(true); 64 + } else { 65 + idx.set(next); 66 + revealed.set(false); 67 + show_answer.set(false); 68 + } 69 + } 70 + 71 + fn do_skip( 72 + mut session: Signal<ReviewSession>, 73 + idx: Signal<usize>, 74 + mut revealed: Signal<bool>, 75 + mut show_answer: Signal<bool>, 76 + mut done: Signal<bool>, 77 + ) { 78 + let cur_idx = *idx.read(); 79 + let all_skipped = { 80 + let mut sess = session.write(); 81 + let mut item = sess.queue.remove(cur_idx); 82 + item.skipped = true; 83 + sess.queue.push(item); 84 + sess.queue[cur_idx..].iter().all(|i| i.skipped) 85 + }; 86 + if all_skipped { 87 + done.set(true); 88 + } else { 89 + revealed.set(false); 90 + show_answer.set(false); 91 + } 92 + } 93 + 94 + 95 + fn build_review_queue(tag_filter: &[String], mode: TagMode) -> (Vec<ReviewItem>, Sidecar) { 96 + use std::collections::HashMap; 97 + 98 + let source = std::fs::read_to_string(cards_path()).unwrap_or_default(); 99 + let cards = tala_format::parse_cards(&source); 100 + let sidecar = Sidecar::load_or_empty_for(&cards_path()).unwrap_or_else(|_| Sidecar::empty()); 101 + let mut queue = Vec::new(); 102 + 103 + for (ci, card) in cards.iter().enumerate() { 104 + if !tag_filter.is_empty() { 105 + let matches = match mode { 106 + TagMode::And => tag_filter.iter().all(|t| card.tags.contains(t)), 107 + TagMode::Or => tag_filter.iter().any(|t| card.tags.contains(t)), 108 + }; 109 + if !matches { 110 + continue; 111 + } 112 + } 113 + let card_id = ci.to_string(); 114 + let card_src = source[card.span.start.saturating_sub(1)..card.span.end].to_string(); 115 + let card_tags = card.tags.clone(); 116 + match (&card.kind, sidecar.get(&card_id)) { 117 + (CardKind::FrontBack { dir, .. }, sched) => { 118 + let (fwd, rev) = match sched { 119 + Some(CardSchedule::FrontBack { 120 + forward_schedule, 121 + reverse_schedule, 122 + }) => (forward_schedule.clone(), reverse_schedule.clone()), 123 + _ => (None, None), 124 + }; 125 + if fwd.as_ref().is_none_or(is_due) { 126 + queue.push(ReviewItem { 127 + source: card_src.clone(), 128 + card_id: card_id.clone(), 129 + sub_id: "forward".into(), 130 + current: fwd, 131 + kind: ReviewKind::FrontBack, 132 + tags: card_tags.clone(), 133 + skipped: false, 134 + }); 135 + } 136 + if matches!(dir, Direction::Bidirectional) && rev.as_ref().is_none_or(is_due) { 137 + queue.push(ReviewItem { 138 + source: card_src.clone(), 139 + card_id: card_id.clone(), 140 + sub_id: "reverse".into(), 141 + current: rev, 142 + kind: ReviewKind::FrontBack, 143 + tags: card_tags.clone(), 144 + skipped: false, 145 + }); 146 + } 147 + } 148 + (CardKind::Cloze { blanks, .. }, sched) => { 149 + let (blank_scheds, sidecar_rects): (HashMap<String, Schedule>, Vec<_>) = 150 + match sched { 151 + Some(CardSchedule::Cloze { blanks: b, rects: r }) => { 152 + (b.clone(), r.clone()) 153 + } 154 + _ => (HashMap::new(), Vec::new()), 155 + }; 156 + for blank in blanks { 157 + let key = format!("b{}", blank.index); 158 + let current = blank_scheds.get(&key).cloned(); 159 + if current.as_ref().is_none_or(is_due) { 160 + queue.push(ReviewItem { 161 + source: card_src.clone(), 162 + card_id: card_id.clone(), 163 + sub_id: key, 164 + current, 165 + kind: ReviewKind::Cloze, 166 + tags: card_tags.clone(), 167 + skipped: false, 168 + }); 169 + } 170 + } 171 + for rect_entry in sidecar_rects { 172 + if is_due(&rect_entry.schedule) { 173 + queue.push(ReviewItem { 174 + source: card_src.clone(), 175 + card_id: card_id.clone(), 176 + sub_id: format!("rect-{}", rect_entry.id), 177 + current: Some(rect_entry.schedule.clone()), 178 + kind: ReviewKind::ImageRect { 179 + src: rect_entry.src.clone(), 180 + rect: rect_entry.rect, 181 + }, 182 + tags: card_tags.clone(), 183 + skipped: false, 184 + }); 185 + } 186 + } 187 + } 188 + } 189 + } 190 + 191 + (queue, sidecar) 192 + } 193 + 194 + fn render_review_b64( 195 + dir: &std::path::Path, 196 + source: &str, 197 + preamble: tala_typst::Preamble, 198 + ) -> Result<String, String> { 199 + let result = tala_typst::render(dir, source, preamble, &[]).map_err(|e| e.to_string())?; 200 + 201 + let straight: Vec<u8> = result 202 + .rgba 203 + .chunks_exact(4) 204 + .flat_map(|px| { 205 + let a = px[3]; 206 + if a == 0 { 207 + [0u8, 0, 0, 0] 208 + } else { 209 + let af = a as f32 / 255.0; 210 + [ 211 + (px[0] as f32 / af).min(255.0) as u8, 212 + (px[1] as f32 / af).min(255.0) as u8, 213 + (px[2] as f32 / af).min(255.0) as u8, 214 + a, 215 + ] 216 + } 217 + }) 218 + .collect(); 219 + 220 + let img = RgbaImage::from_raw(result.width, result.height, straight) 221 + .ok_or("image buffer size mismatch")?; 222 + let mut buf = std::io::Cursor::new(Vec::new()); 223 + image::DynamicImage::ImageRgba8(img) 224 + .write_to(&mut buf, image::ImageFormat::Png) 225 + .map_err(|e| e.to_string())?; 226 + Ok(format!( 227 + "data:image/png;base64,{}", 228 + base64::engine::general_purpose::STANDARD.encode(buf.into_inner()) 229 + )) 230 + } 231 + 232 + fn render_rect_question_b64( 233 + dir: &std::path::Path, 234 + source: &str, 235 + img_src: &str, 236 + rect: [f32; 4], 237 + ) -> Result<String, String> { 238 + let result = tala_typst::render(dir, source, tala_typst::Preamble::Authoring, &[]) 239 + .map_err(|e| e.to_string())?; 240 + 241 + let img_names = extract_img_names(source); 242 + let img_idx = img_names.iter().position(|n| n == img_src); 243 + 244 + let mut straight: Vec<u8> = result 245 + .rgba 246 + .chunks_exact(4) 247 + .flat_map(|px| { 248 + let a = px[3]; 249 + if a == 0 { 250 + [0u8, 0, 0, 0] 251 + } else { 252 + let af = a as f32 / 255.0; 253 + [ 254 + (px[0] as f32 / af).min(255.0) as u8, 255 + (px[1] as f32 / af).min(255.0) as u8, 256 + (px[2] as f32 / af).min(255.0) as u8, 257 + a, 258 + ] 259 + } 260 + }) 261 + .collect(); 262 + 263 + if let Some(idx) = img_idx { 264 + if let Some(box_) = result.image_boxes.get(idx) { 265 + let [bx, by, bw, bh] = *box_; // f32 pixel coords from RenderResult 266 + let col0 = (bx + rect[0] * bw) as u32; 267 + let row0 = (by + rect[1] * bh) as u32; 268 + let col1 = ((bx + (rect[0] + rect[2]) * bw) as u32).min(result.width); 269 + let row1 = ((by + (rect[1] + rect[3]) * bh) as u32).min(result.height); 270 + let stride = result.width as usize * 4; 271 + for row in row0.min(result.height)..row1 { 272 + for col in col0.min(result.width)..col1 { 273 + let off = row as usize * stride + col as usize * 4; 274 + if off + 3 < straight.len() { 275 + straight[off] = 30; 276 + straight[off + 1] = 35; 277 + straight[off + 2] = 50; 278 + straight[off + 3] = 255; 279 + } 280 + } 281 + } 282 + } 283 + } 284 + 285 + let img = RgbaImage::from_raw(result.width, result.height, straight) 286 + .ok_or("image buffer size mismatch")?; 287 + let mut buf = std::io::Cursor::new(Vec::new()); 288 + image::DynamicImage::ImageRgba8(img) 289 + .write_to(&mut buf, image::ImageFormat::Png) 290 + .map_err(|e| e.to_string())?; 291 + Ok(format!( 292 + "data:image/png;base64,{}", 293 + base64::engine::general_purpose::STANDARD.encode(buf.into_inner()) 294 + )) 295 + } 296 + 297 + fn apply_review_schedule( 298 + sidecar: &mut Sidecar, 299 + card_id: &str, 300 + sub_id: &str, 301 + sched: Schedule, 302 + kind: &ReviewKind, 303 + ) { 304 + match kind { 305 + ReviewKind::FrontBack => { 306 + let entry = 307 + sidecar 308 + .cards 309 + .entry(card_id.to_owned()) 310 + .or_insert(CardSchedule::FrontBack { 311 + forward_schedule: None, 312 + reverse_schedule: None, 313 + }); 314 + if let CardSchedule::FrontBack { 315 + forward_schedule, 316 + reverse_schedule, 317 + } = entry 318 + { 319 + match sub_id { 320 + "forward" => *forward_schedule = Some(sched), 321 + "reverse" => *reverse_schedule = Some(sched), 322 + _ => {} 323 + } 324 + } 325 + } 326 + ReviewKind::Cloze => { 327 + let entry = sidecar 328 + .cards 329 + .entry(card_id.to_owned()) 330 + .or_insert(CardSchedule::Cloze { 331 + blanks: std::collections::HashMap::new(), 332 + rects: Vec::new(), 333 + }); 334 + if let CardSchedule::Cloze { blanks, .. } = entry { 335 + blanks.insert(sub_id.to_owned(), sched); 336 + } 337 + } 338 + ReviewKind::ImageRect { .. } => { 339 + let rect_id = sub_id.strip_prefix("rect-").unwrap_or(sub_id); 340 + if let Some(CardSchedule::Cloze { rects, .. }) = sidecar.cards.get_mut(card_id) { 341 + if let Some(r) = rects.iter_mut().find(|r| r.id == rect_id) { 342 + r.schedule = sched; 343 + } 344 + } 345 + } 346 + } 347 + } 348 + 349 + fn review_days_elapsed(current: &Option<Schedule>) -> u32 { 350 + let Some(sched) = current else { return 0 }; 351 + fn to_epoch(s: &str) -> i64 { 352 + let parts: Vec<i64> = s.splitn(3, '-').filter_map(|p| p.parse().ok()).collect(); 353 + if parts.len() != 3 { 354 + return 0; 355 + } 356 + let (y, m, d) = (parts[0] as i32, parts[1] as u32, parts[2] as u32); 357 + let (y2, m2) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) }; 358 + let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400; 359 + let yoe = y2 - era * 400; 360 + let doy = (153 * m2 as i64 + 2) / 5 + d as i64 - 1; 361 + let doe = yoe as i64 * 365 + yoe as i64 / 4 - yoe as i64 / 100 + doy; 362 + era as i64 * 146097 + doe - 719468 363 + } 364 + let elapsed = to_epoch(&today_str()) - to_epoch(&sched.due); 365 + elapsed.max(0) as u32 366 + } 367 + 368 + 369 + 370 + #[component] 371 + pub fn Review() -> Element { 372 + let mut started = use_signal(|| false); 373 + let mut selected_tags: Signal<Vec<String>> = use_signal(Vec::new); 374 + let mut tag_mode = use_signal(|| TagMode::Or); 375 + let all_tags = use_signal(crate::util::collect_all_tags); 376 + let mut session = use_signal(|| ReviewSession { queue: Vec::new(), sidecar: Sidecar::empty() }); 377 + let mut idx = use_signal(|| 0usize); 378 + let mut revealed = use_signal(|| false); 379 + let mut show_answer = use_signal(|| false); 380 + let mut done = use_signal(|| false); 381 + let mut graded_count = use_signal(|| 0usize); 382 + 383 + let total = session.read().queue.len(); 384 + 385 + // Question-side image: re-renders when idx or session changes. 386 + let q_img = use_resource(move || async move { 387 + if !*started.read() { 388 + return String::new(); 389 + } 390 + let item = { 391 + let sess = session.read(); 392 + sess.queue.get(*idx.read()).cloned() 393 + }; 394 + let Some(item) = item else { 395 + return String::new(); 396 + }; 397 + let dir = card_dir(); 398 + tokio::task::spawn_blocking(move || match item.kind { 399 + ReviewKind::FrontBack => { 400 + let preamble = if item.sub_id == "reverse" { 401 + tala_typst::Preamble::ReviewBack 402 + } else { 403 + tala_typst::Preamble::ReviewFront 404 + }; 405 + render_review_b64(&dir, &item.source, preamble).unwrap_or_default() 406 + } 407 + ReviewKind::Cloze => { 408 + render_review_b64(&dir, &item.source, tala_typst::Preamble::ReviewCloze) 409 + .unwrap_or_default() 410 + } 411 + ReviewKind::ImageRect { src, rect } => { 412 + render_rect_question_b64(&dir, &item.source, &src, rect).unwrap_or_default() 413 + } 414 + }) 415 + .await 416 + .unwrap_or_default() 417 + }); 418 + 419 + // Answer-side image: starts loading once revealed; cached until idx changes. 420 + let a_img = use_resource(move || async move { 421 + if !*started.read() || !*revealed.read() { 422 + return None; 423 + } 424 + let item = { 425 + let sess = session.read(); 426 + sess.queue.get(*idx.read()).cloned() 427 + }; 428 + let Some(item) = item else { return None }; 429 + let dir = card_dir(); 430 + tokio::task::spawn_blocking(move || { 431 + render_review_b64(&dir, &item.source, tala_typst::Preamble::Authoring).ok() 432 + }) 433 + .await 434 + .ok() 435 + .flatten() 436 + }); 437 + 438 + // ── Setup screen ───────────────────────────────────────────────────────── 439 + if !*started.read() { 440 + let available_tags = all_tags.read().clone(); 441 + return rsx! { 442 + div { id: "review-page", 443 + div { class: "review-setup", 444 + if available_tags.is_empty() { 445 + p { class: "review-progress", "No tags defined in deck." } 446 + } else { 447 + p { class: "review-progress", "Filter by tags:" } 448 + div { class: "tag-chip-row", 449 + for tag in available_tags { 450 + { 451 + let tag2 = tag.clone(); 452 + let is_sel = selected_tags.read().contains(&tag); 453 + rsx! { 454 + button { 455 + class: if is_sel { "tag-chip selected" } else { "tag-chip" }, 456 + onclick: move |_| { 457 + let mut sel = selected_tags.write(); 458 + if sel.contains(&tag2) { 459 + sel.retain(|t| t != &tag2); 460 + } else { 461 + sel.push(tag2.clone()); 462 + } 463 + }, 464 + "{tag}" 465 + } 466 + } 467 + } 468 + } 469 + } 470 + if selected_tags.read().len() > 1 { 471 + div { class: "review-grade-row", 472 + button { 473 + class: if *tag_mode.read() == TagMode::Or { "btn active" } else { "btn" }, 474 + onclick: move |_| tag_mode.set(TagMode::Or), 475 + "Any tag (OR)" 476 + } 477 + button { 478 + class: if *tag_mode.read() == TagMode::And { "btn active" } else { "btn" }, 479 + onclick: move |_| tag_mode.set(TagMode::And), 480 + "All tags (AND)" 481 + } 482 + } 483 + } 484 + } 485 + button { 486 + class: "btn", 487 + onclick: move |_| { 488 + let tags = selected_tags.read().clone(); 489 + let mode = tag_mode.read().clone(); 490 + let (queue, sidecar) = build_review_queue(&tags, mode); 491 + session.set(ReviewSession { queue, sidecar }); 492 + idx.set(0); 493 + revealed.set(false); 494 + show_answer.set(false); 495 + done.set(false); 496 + graded_count.set(0); 497 + started.set(true); 498 + }, 499 + "Start review →" 500 + } 501 + } 502 + } 503 + }; 504 + } 505 + 506 + // ── End / empty screens ────────────────────────────────────────────────── 507 + if *done.read() || total == 0 { 508 + return rsx! { 509 + div { id: "review-page", 510 + if total == 0 { 511 + p { "No cards due." } 512 + } else { 513 + p { "Session complete — {graded_count} cards reviewed." } 514 + } 515 + button { 516 + class: "btn", 517 + onclick: move |_| { 518 + started.set(false); 519 + done.set(false); 520 + graded_count.set(0); 521 + idx.set(0); 522 + }, 523 + "Back to filter" 524 + } 525 + } 526 + }; 527 + } 528 + 529 + // ── Review screen ──────────────────────────────────────────────────────── 530 + let cur_idx = *idx.read(); 531 + let is_revealed = *revealed.read(); 532 + let is_show_answer = *show_answer.read(); 533 + let q_src = q_img.read(); 534 + let a_src = a_img.read(); 535 + let has_answer = matches!(a_src.as_ref(), Some(Some(_))); 536 + let session_tags_snap = selected_tags.read().clone(); 537 + let session_tag_mode_snap = tag_mode.read().clone(); 538 + let session_all_tags_snap = all_tags.read().clone(); 539 + 540 + rsx! { 541 + div { 542 + id: "review-page", 543 + tabindex: 0, 544 + onmounted: move |e| { 545 + spawn(async move { e.set_focus(true).await.ok(); }); 546 + }, 547 + onkeydown: move |e| match e.data().key().to_string().as_str() { 548 + " " | "Enter" => { 549 + if !*revealed.read() { 550 + revealed.set(true); 551 + show_answer.set(true); 552 + } 553 + } 554 + "s" | "S" => { 555 + do_skip(session, idx, revealed, show_answer, done); 556 + } 557 + "1" => { 558 + if *revealed.read() { 559 + do_advance(Grade::Again, session, idx, revealed, show_answer, done, graded_count); 560 + } 561 + } 562 + "2" => { 563 + if *revealed.read() { 564 + do_advance(Grade::Hard, session, idx, revealed, show_answer, done, graded_count); 565 + } 566 + } 567 + "3" => { 568 + if *revealed.read() { 569 + do_advance(Grade::Good, session, idx, revealed, show_answer, done, graded_count); 570 + } 571 + } 572 + "4" => { 573 + if *revealed.read() { 574 + do_advance(Grade::Easy, session, idx, revealed, show_answer, done, graded_count); 575 + } 576 + } 577 + _ => {} 578 + }, 579 + // ── Inline filter bar ──────────────────────────────────────────── 580 + if !session_all_tags_snap.is_empty() { 581 + div { class: "tag-chip-row", 582 + for tag in session_all_tags_snap.clone() { 583 + { 584 + let tag2 = tag.clone(); 585 + let is_sel = session_tags_snap.contains(&tag); 586 + rsx! { 587 + button { 588 + class: if is_sel { "tag-chip selected" } else { "tag-chip" }, 589 + onclick: move |_| { 590 + let mut sel = selected_tags.write(); 591 + if sel.contains(&tag2) { 592 + sel.retain(|t| t != &tag2); 593 + } else { 594 + sel.push(tag2.clone()); 595 + } 596 + }, 597 + "{tag}" 598 + } 599 + } 600 + } 601 + } 602 + if session_tags_snap.len() > 1 { 603 + button { 604 + class: if session_tag_mode_snap == TagMode::Or { "btn active" } else { "btn" }, 605 + onclick: move |_| tag_mode.set(TagMode::Or), 606 + "OR" 607 + } 608 + button { 609 + class: if session_tag_mode_snap == TagMode::And { "btn active" } else { "btn" }, 610 + onclick: move |_| tag_mode.set(TagMode::And), 611 + "AND" 612 + } 613 + } 614 + button { 615 + class: "btn", 616 + onclick: move |_| { 617 + let tags = selected_tags.read().clone(); 618 + let mode = tag_mode.read().clone(); 619 + let (queue, sidecar) = build_review_queue(&tags, mode); 620 + session.set(ReviewSession { queue, sidecar }); 621 + idx.set(0); 622 + revealed.set(false); 623 + show_answer.set(false); 624 + done.set(false); 625 + graded_count.set(0); 626 + }, 627 + "Restart \u{2192}" 628 + } 629 + } 630 + } 631 + span { class: "review-progress", "{cur_idx + 1} / {total}" } 632 + if let Some(q) = q_src.as_ref().filter(|s| !s.is_empty()) { 633 + div { 634 + class: if has_answer { "review-card-stack clickable" } else { "review-card-stack" }, 635 + onclick: move |_| { 636 + if has_answer { 637 + let cur = *show_answer.read(); 638 + show_answer.set(!cur); 639 + } 640 + }, 641 + img { 642 + src: "{q}", 643 + style: if is_revealed && is_show_answer { "opacity: 0" } else { "opacity: 1" }, 644 + } 645 + if let Some(Some(a)) = a_src.as_ref() { 646 + img { 647 + src: "{a}", 648 + style: if is_show_answer { "opacity: 1" } else { "opacity: 0" }, 649 + } 650 + } 651 + } 652 + } else { 653 + span { class: "status", "Rendering..." } 654 + } 655 + if is_revealed { 656 + div { class: "review-grade-row", 657 + button { 658 + class: "btn grade-again", 659 + onclick: move |_| do_advance(Grade::Again, session, idx, revealed, show_answer, done, graded_count), 660 + "1 Again" 661 + } 662 + button { 663 + class: "btn grade-hard", 664 + onclick: move |_| do_advance(Grade::Hard, session, idx, revealed, show_answer, done, graded_count), 665 + "2 Hard" 666 + } 667 + button { 668 + class: "btn grade-good", 669 + onclick: move |_| do_advance(Grade::Good, session, idx, revealed, show_answer, done, graded_count), 670 + "3 Good" 671 + } 672 + button { 673 + class: "btn grade-easy", 674 + onclick: move |_| do_advance(Grade::Easy, session, idx, revealed, show_answer, done, graded_count), 675 + "4 Easy" 676 + } 677 + button { 678 + class: "btn", 679 + onclick: move |_| do_skip(session, idx, revealed, show_answer, done), 680 + "Skip [S]" 681 + } 682 + } 683 + } else { 684 + div { class: "review-grade-row", 685 + button { 686 + class: "btn", 687 + onclick: move |_| { revealed.set(true); show_answer.set(true); }, 688 + "Show answer [Space]" 689 + } 690 + button { 691 + class: "btn", 692 + onclick: move |_| do_skip(session, idx, revealed, show_answer, done), 693 + "Skip [S]" 694 + } 695 + } 696 + } 697 + } 698 + } 699 + }
+109
crates/tala/src/util.rs
··· 1 + use std::path::{Path, PathBuf}; 2 + 3 + use dioxus::prelude::*; 4 + 5 + 6 + fn config_dir_file() -> Option<PathBuf> { 7 + let home = std::env::var("HOME").ok()?; 8 + Some(PathBuf::from(home).join(".config").join("tala").join("dir")) 9 + } 10 + 11 + fn load_saved_dir() -> Option<PathBuf> { 12 + let path = config_dir_file()?; 13 + let s = std::fs::read_to_string(path).ok()?; 14 + let p = PathBuf::from(s.trim()); 15 + if p.is_dir() { 16 + Some(p) 17 + } else { 18 + None 19 + } 20 + } 21 + 22 + fn persist_dir(path: &Path) { 23 + if let Some(f) = config_dir_file() { 24 + if let Some(parent) = f.parent() { 25 + let _ = std::fs::create_dir_all(parent); 26 + } 27 + let _ = std::fs::write(&f, path.display().to_string()); 28 + } 29 + } 30 + 31 + 32 + #[derive(Clone, PartialEq)] 33 + pub enum TagMode { 34 + Or, 35 + And, 36 + } 37 + 38 + pub fn collect_all_tags_from_source(source: &str) -> Vec<String> { 39 + let cards = tala_format::parse_cards(source); 40 + let mut tags: Vec<String> = cards.into_iter().flat_map(|c| c.tags).collect(); 41 + tags.sort(); 42 + tags.dedup(); 43 + tags 44 + } 45 + 46 + pub fn collect_all_tags() -> Vec<String> { 47 + let source = std::fs::read_to_string(cards_path()).unwrap_or_default(); 48 + collect_all_tags_from_source(&source) 49 + } 50 + 51 + 52 + /// Directory containing cards.typ and images/. 53 + static CARD_DIR: GlobalSignal<PathBuf> = Signal::global(|| { 54 + let dir = std::env::args() 55 + .nth(1) 56 + .map(PathBuf::from) 57 + .or_else(load_saved_dir) 58 + .unwrap_or_else(|| std::env::current_dir().expect("cwd")); 59 + let dir = std::fs::canonicalize(&dir).unwrap_or(dir); 60 + let _ = std::fs::create_dir_all(dir.join("images")); 61 + dir 62 + }); 63 + 64 + pub fn card_dir() -> PathBuf { 65 + CARD_DIR.read().clone() 66 + } 67 + 68 + pub fn cards_path() -> PathBuf { 69 + card_dir().join("cards.typ") 70 + } 71 + 72 + pub fn set_card_dir(path: PathBuf) { 73 + let path = std::fs::canonicalize(&path).unwrap_or(path); 74 + let _ = std::fs::create_dir_all(path.join("images")); 75 + persist_dir(&path); 76 + *CARD_DIR.write() = path; 77 + } 78 + 79 + 80 + /// Extract image source arguments in document order from a fragment. 81 + /// Always returns bare stems (no path prefix, no extension) so they match sidecar `src` fields. 82 + /// Handles both `#img("name")` (custom shorthand) and `#image("path")` (native typst). 83 + pub fn extract_img_names(source: &str) -> Vec<String> { 84 + let mut names = Vec::new(); 85 + let mut rest = source; 86 + while !rest.is_empty() { 87 + let img_pos = rest.find("#img(\""); 88 + let image_pos = rest.find("#image(\""); 89 + let (pos, skip) = match (img_pos, image_pos) { 90 + (Some(a), Some(b)) => if a <= b { (a, 6) } else { (b, 8) }, 91 + (Some(a), None) => (a, 6), 92 + (None, Some(b)) => (b, 8), 93 + (None, None) => break, 94 + }; 95 + rest = &rest[pos + skip..]; 96 + if let Some(end) = rest.find('"') { 97 + let raw = &rest[..end]; 98 + // Normalize to bare stem: strip leading "images/" and trailing extension. 99 + let after_prefix = raw.strip_prefix("images/").unwrap_or(raw); 100 + let stem = Path::new(after_prefix) 101 + .file_stem() 102 + .and_then(|s| s.to_str()) 103 + .unwrap_or(after_prefix); 104 + names.push(stem.to_string()); 105 + rest = &rest[end + 1..]; 106 + } 107 + } 108 + names 109 + }