this repo has no description
1
fork

Configure Feed

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

refactor: paragraph-based segmentation, per-card compilation, auto-split on new paragraph

- Segmentation is now purely blank-line based (no typst AST spans), eliminating
the data-loss bug where unclosed brackets bloated spans to EOF
- Each card fragment compiled independently by typst — a broken card cannot
affect rendering of adjacent cards
- Removed active_card_start/end, active_seg_key, n_segs_sig signals and the
two-effect complexity they required; splice bounds computed inline from
make_segments (fast prefix scan, no typst calls)
- Typing \n\n in a textarea auto-splits into a new segment and moves focus there

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

+86 -92
+86 -92
crates/tala/src/main.rs
··· 266 266 }); 267 267 268 268 // ── Multi-card state ─────────────────────────────────────────────────────── 269 - let mut active_idx = use_signal(|| 0usize); 270 - let mut previews = use_signal(Vec::<Result<PreviewData, String>>::new); 271 - let mut active_card_start = use_signal(|| 0usize); // byte offset of active segment in full source 272 - let mut active_card_end = use_signal(|| 0usize); 273 - let mut n_segs_sig = use_signal(|| 0usize); 274 - // Bumped whenever the active card changes; the bounds effect subscribes only 275 - // to this, not to source, so mid-edit parse errors can't expand the splice window. 276 - let mut active_seg_key = use_signal(|| 0u64); 269 + let mut active_idx = use_signal(|| 0usize); 270 + // Indexed by segment. None = text segment (no render attempt). 271 + let mut previews = use_signal(Vec::<Option<Result<PreviewData, String>>>::new); 277 272 278 - // Per-card render resource: re-runs whenever source changes (300ms debounce). 273 + // Per-segment render resource: each card fragment compiled independently. 279 274 let _render = use_resource(move || async move { 280 275 let text = source.read().clone(); 281 276 tokio::time::sleep(Duration::from_millis(300)).await; 282 277 let dir = card_dir(); 283 278 let results = tokio::task::spawn_blocking(move || { 284 - tala_format::parse_cards(&text) 285 - .iter() 286 - .map(|card| { 287 - let start = card.span.start.saturating_sub(1); 288 - let fragment = text[start..card.span.end].to_string(); 289 - render_preview(&fragment, &dir) 279 + make_segments(&text) 280 + .into_iter() 281 + .map(|seg| match seg { 282 + Seg::Card { start, end } => Some(render_preview(&text[start..end], &dir)), 283 + Seg::Text { .. } => None, 290 284 }) 291 285 .collect::<Vec<_>>() 292 286 }) ··· 295 289 previews.set(results); 296 290 }); 297 291 298 - // ── Active card glyph signals (for draw mode) ───────────────────────────── 292 + // ── Draw mode state ──────────────────────────────────────────────────────── 299 293 let mut blank_rects_sig = use_signal(Vec::<[f64; 4]>::new); 300 294 let mut glyph_map_sig = use_signal(Vec::<([f64; 4], std::ops::Range<usize>, bool)>::new); 301 295 let mut math_spans_sig = use_signal(Vec::<std::ops::Range<usize>>::new); 296 + let mut draw_mode = use_signal(|| false); 297 + let mut drag_start = use_signal(|| Option::<(f64, f64)>::None); 298 + let mut drag_current = use_signal(|| Option::<(f64, f64)>::None); 299 + let mut drawn_boxes = use_signal(Vec::<[f64; 4]>::new); 300 + let cap_dims = use_signal(|| [1.0f64, 1.0, 1.0]); 301 + let mut insert_error = use_signal(|| Option::<String>::None); 302 302 303 - // Effect 1: update n_segs_sig whenever source changes (for keyboard nav bounds). 303 + // Update glyph signals when the active card or its render result changes. 304 304 use_effect(move || { 305 - let text = source.read().clone(); 306 - let cards = tala_format::parse_cards(&text); 307 - n_segs_sig.set(make_segments(&text, &cards).len()); 308 - }); 309 - 310 - // Effect 2: update splice bounds and glyph signals ONLY when the active card 311 - // changes (active_seg_key bump), NOT on every source edit. Using peek() for 312 - // source/previews means this effect never re-subscribes to them, so a mid-edit 313 - // parse returning a bloated span cannot expand the splice window. 314 - use_effect(move || { 315 - let _key = *active_seg_key.read(); 316 - let text = source.peek().clone(); 317 - let idx = *active_idx.peek(); 318 - let cards = tala_format::parse_cards(&text); 319 - let segs = make_segments(&text, &cards); 320 - if let Some(seg) = segs.get(idx) { 321 - active_card_start.set(seg.start()); 322 - active_card_end.set(seg.end()); 323 - } 324 - let pvs = previews.peek(); 325 - match segs.get(idx) { 326 - Some(Seg::Card { card_idx, .. }) => { 327 - if let Some(Ok(data)) = pvs.get(*card_idx) { 305 + let idx = *active_idx.read(); // subscribe 306 + let pvs = previews.read().clone(); // subscribe 307 + let text = source.peek().clone(); // no subscription 308 + let segs = make_segments(&text); 309 + let ai = idx.min(segs.len().saturating_sub(1)); 310 + match segs.get(ai) { 311 + Some(Seg::Card { .. }) => { 312 + if let Some(Some(Ok(data))) = pvs.get(ai) { 328 313 blank_rects_sig.set(data.blank_rects.clone()); 329 314 glyph_map_sig.set(data.glyph_map.clone()); 330 315 math_spans_sig.set(data.math_spans.clone()); ··· 338 323 } 339 324 }); 340 325 341 - // ── Draw mode state ──────────────────────────────────────────────────────── 342 - let mut draw_mode = use_signal(|| false); 343 - let mut drag_start = use_signal(|| Option::<(f64, f64)>::None); 344 - let mut drag_current = use_signal(|| Option::<(f64, f64)>::None); 345 - let mut drawn_boxes = use_signal(Vec::<[f64; 4]>::new); 346 - let cap_dims = use_signal(|| [1.0f64, 1.0, 1.0]); 347 - let mut insert_error = use_signal(|| Option::<String>::None); 348 - 349 - // ── Shared keyboard nav: Up/Down at textarea boundary → switch segment ────── 326 + // ── Keyboard nav: Up/Down at textarea boundary → switch segment ──────────── 350 327 let on_keydown = move |e: Event<KeyboardData>| { 351 328 let key = e.data().key().to_string(); 352 329 if key == "ArrowUp" || key == "ArrowDown" { 353 330 let going_up = key == "ArrowUp"; 354 331 let cur_idx = *active_idx.read(); 355 - let n = *n_segs_sig.read(); 332 + let n = make_segments(&source.read().clone()).len(); 356 333 let mut ai = active_idx; 357 - let mut ask = active_seg_key; 358 334 spawn(async move { 359 335 let js = if going_up { 360 336 "var t=document.querySelector('.card-row.active textarea');\ ··· 372 348 }; 373 349 if new_i != cur_idx { 374 350 ai.set(new_i); 375 - { let k = *ask.peek(); ask.set(k + 1); }; 376 351 document::eval( 377 352 "setTimeout(()=>document.querySelector('.card-row.active textarea')?.focus(),0)" 378 353 ).await.ok(); ··· 383 358 } 384 359 }; 385 360 386 - // ── oninput: splice active segment fragment back into full source ───────── 361 + // ── oninput: splice active segment back into full source ─────────────────── 362 + // make_segments uses only blank-line positions, never typst spans, so an 363 + // unclosed bracket cannot bloat ce to end-of-file. 364 + // If the user types \n\n (creating a new paragraph), the new paragraphs are 365 + // spliced in and active_idx advances to the last one. 387 366 let on_input = move |e: FormEvent| { 388 367 let new_frag = e.value(); 389 - if new_frag.trim().is_empty() { 390 - // Don't splice empty content — card would vanish from parse_cards. 391 - // Deletion is an explicit action via the delete button. 392 - return; 393 - } 394 - let cs = *active_card_start.read(); 395 - let ce = *active_card_end.read(); 396 - let cur = source.read().clone(); 397 - if ce <= cur.len() { 398 - source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 368 + if new_frag.trim().is_empty() { return; } 369 + let cur = source.read().clone(); 370 + let segs = make_segments(&cur); 371 + let ai = (*active_idx.read()).min(segs.len().saturating_sub(1)); 372 + if let Some(seg) = segs.get(ai) { 373 + let (cs, ce) = (seg.start(), seg.end()); 374 + if ce <= cur.len() { 375 + let new_src = format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..]); 376 + let new_segs = make_segments(&new_src); 377 + source.set(new_src); 378 + if new_segs.len() > segs.len() { 379 + let edit_end = cs + new_frag.len(); 380 + let new_ai = new_segs.iter() 381 + .rposition(|s| s.start() <= edit_end) 382 + .unwrap_or(ai); 383 + active_idx.set(new_ai); 384 + spawn(async move { 385 + document::eval( 386 + "setTimeout(()=>{var t=document.querySelector('.card-row.active textarea');\ 387 + if(t){t.focus();t.setSelectionRange(t.value.length,t.value.length);}},50)" 388 + ).await.ok(); 389 + }); 390 + } 391 + } 399 392 } 400 393 save_status.set(SaveStatus::Dirty); 401 394 }; 402 395 403 - // ── Snapshot values for RSX (avoids holding multiple ReadGuards) ────────── 404 - let text_snap = source.read().clone(); 405 - let cards_snap = tala_format::parse_cards(&text_snap); 406 - let segs_snap = make_segments(&text_snap, &cards_snap); 407 - let n_segs = segs_snap.len(); 408 - let active_i = (*active_idx.read()).min(n_segs.saturating_sub(1)); 396 + // ── Snapshot values for RSX ──────────────────────────────────────────────── 397 + let text_snap = source.read().clone(); 398 + let segs_snap = make_segments(&text_snap); 399 + let n_segs = segs_snap.len(); 400 + let active_i = (*active_idx.read()).min(n_segs.saturating_sub(1)); 409 401 let active_is_cloze = match segs_snap.get(active_i) { 410 - Some(Seg::Card { card_idx, .. }) => matches!( 411 - cards_snap.get(*card_idx).map(|c| &c.kind), 402 + Some(Seg::Card { start, end }) => matches!( 403 + tala_format::parse_cards(&text_snap[*start..*end]).first().map(|c| &c.kind), 412 404 Some(CardKind::Cloze { .. }) 413 405 ), 414 406 _ => false, 415 407 }; 416 - let pvs_snap = previews.read().clone(); 417 - // Compute bounds directly from segs_snap -- never read the lagging signals here. 408 + let pvs_snap = previews.read().clone(); 418 409 let (active_cs, active_ce) = segs_snap.get(active_i) 419 410 .map(|s| (s.start(), s.end())) 420 411 .unwrap_or((0, 0)); 421 412 let active_fragment = text_snap[active_cs..active_ce.min(text_snap.len())].to_string(); 422 - let in_draw = *draw_mode.read(); 413 + let in_draw = *draw_mode.read(); 423 414 let ds = *drag_start.read(); 424 415 let dc = *drag_current.read(); 425 416 let drawn = drawn_boxes.read().clone(); ··· 475 466 onclick: move |_| { 476 467 if !is_active { 477 468 active_idx.set(i); 478 - { let k = *active_seg_key.peek(); active_seg_key.set(k + 1); }; 479 469 spawn(async move { 480 470 document::eval( 481 471 "setTimeout(()=>document.querySelector('.card-row.active textarea')?.focus(),0)" ··· 488 478 onclick: move |e| { 489 479 e.stop_propagation(); 490 480 let text = source.read().clone(); 491 - let cards = tala_format::parse_cards(&text); 492 - let segs = make_segments(&text, &cards); 481 + let segs = make_segments(&text); 493 482 if let Some(seg) = segs.get(i) { 494 483 let (cs, ce) = (seg.start(), seg.end()); 495 - source.set(format!("{}{}", &text[..cs], &text[ce..])); 484 + let ce2 = if text[ce..].starts_with("\n\n") { ce + 2 } else { ce }; 485 + let cs2 = if ce2 == ce && cs >= 2 && &text[cs-2..cs] == "\n\n" { cs - 2 } else { cs }; 486 + source.set(format!("{}{}", &text[..cs2], &text[ce2..])); 487 + let new_n = segs.len().saturating_sub(1); 488 + if *active_idx.read() >= new_n { active_idx.set(new_n.saturating_sub(1)); } 496 489 save_status.set(SaveStatus::Dirty); 497 490 } 498 491 }, ··· 525 518 } 526 519 } 527 520 // ── Card segment ────────────────────────────────────── 528 - Seg::Card { card_idx, .. } => { 529 - let card_idx = *card_idx; 530 - let card_result: Option<Result<PreviewData, String>> = pvs_snap.get(card_idx).cloned(); 521 + Seg::Card { .. } => { 522 + let card_result: Option<Result<PreviewData, String>> = 523 + pvs_snap.get(i).cloned().flatten(); 531 524 let iw = card_result.as_ref().and_then(|r| r.as_ref().ok()).map(|d| d.img_w as f64).unwrap_or(1.0); 532 525 let ih = card_result.as_ref().and_then(|r| r.as_ref().ok()).map(|d| d.img_h as f64).unwrap_or(1.0); 533 526 let vb = format!("0 0 {} {}", iw as u32, ih as u32); ··· 552 545 onclick: move |_| { 553 546 if !is_active { 554 547 active_idx.set(i); 555 - { let k = *active_seg_key.peek(); active_seg_key.set(k + 1); }; 556 548 spawn(async move { 557 549 document::eval( 558 550 "setTimeout(()=>document.querySelector('.card-row.active textarea')?.focus(),0)" ··· 568 560 onclick: move |e| { 569 561 e.stop_propagation(); 570 562 let text = source.read().clone(); 571 - let cards = tala_format::parse_cards(&text); 572 - if let Some(card) = cards.get(card_idx as usize) { 573 - let cs = card.span.start.saturating_sub(1); 574 - let ce = card.span.end; 575 - let ce = if ce < text.len() && text.as_bytes()[ce] == b'\n' { ce + 1 } else { ce }; 576 - source.set(format!("{}{}", &text[..cs], &text[ce..])); 563 + let segs = make_segments(&text); 564 + if let Some(seg) = segs.get(i) { 565 + let (cs, ce) = (seg.start(), seg.end()); 566 + let ce2 = if text[ce..].starts_with("\n\n") { ce + 2 } else { ce }; 567 + let cs2 = if ce2 == ce && cs >= 2 && &text[cs-2..cs] == "\n\n" { cs - 2 } else { cs }; 568 + source.set(format!("{}{}", &text[..cs2], &text[ce2..])); 569 + let new_n = segs.len().saturating_sub(1); 570 + if *active_idx.read() >= new_n { active_idx.set(new_n.saturating_sub(1)); } 577 571 save_status.set(SaveStatus::Dirty); 578 572 } 579 573 }, ··· 725 719 let min_start = hits.iter().map(|(_, r, _)| r.start).min().unwrap(); 726 720 let max_end = hits.iter().map(|(_, r, _)| r.end ).max().unwrap(); 727 721 728 - // Operate on the active card's fragment, then splice back. 729 - let cs = *active_card_start.read(); 730 - let ce = *active_card_end.read(); 731 - let cur = source.read().clone(); 722 + let cur = source.read().clone(); 723 + let segs = make_segments(&cur); 724 + let ai = (*active_idx.read()).min(segs.len().saturating_sub(1)); 725 + let (cs, ce) = segs.get(ai).map(|s| (s.start(), s.end())).unwrap_or((0, 0)); 732 726 let fragment = cur[cs..ce].to_string(); 733 727 734 728 if all_math {