this repo has no description
1
fork

Configure Feed

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

fix: zoom-invariant draw coords; add 15-test validation suite

Coordinate bug: `getBoundingClientRect().width/height` is multiplied by
the CSS zoom factor, but `element_coordinates()` (offsetX/Y) is not.
Dividing offset coords by BCR dims gave nx / zoom instead of nx. Fix:
fetch `offsetWidth` / `offsetHeight` from JS instead.

Refactored into `normalize_draw_coords()` for testability. Added 15
unit tests covering coordinate normalization (incl. zoom-invariant
invariant), rects_overlap, insert_blank_wrap, insert_blank_wrap_math,
and expand_math_selection edge cases.

Tagged v0.0.1 at prior HEAD before this fix.

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

+146 -10
+146 -10
crates/tala/src/main.rs
··· 271 271 onmousedown: move |e| { 272 272 let ox = e.data().element_coordinates().x; 273 273 let oy = e.data().element_coordinates().y; 274 - // Read cap_size via JS on each drag start for accuracy. 274 + // Read cap_size via JS on each drag start. 275 + // Use offsetWidth/offsetHeight (element-space px, zoom-invariant) 276 + // NOT getBoundingClientRect (which is scaled by CSS zoom). 275 277 let mut ds = drag_start; 276 278 let mut dc = drag_current; 277 279 let mut cs = cap_size; 278 280 spawn(async move { 279 281 let mut eval = document::eval(r#" 280 282 var el = document.querySelector('.draw-capture'); 281 - var r = el ? el.getBoundingClientRect() : {width:1,height:1}; 282 - dioxus.send([r.width, r.height]); 283 + dioxus.send([el ? el.offsetWidth : 1, el ? el.offsetHeight : 1]); 283 284 "#); 284 285 if let Ok(val) = eval.recv::<[f64; 2]>().await { 285 286 let w = val[0].max(1.0); 286 287 let h = val[1].max(1.0); 287 288 cs.set((w, h)); 288 - ds.set(Some((ox / w, oy / h))); 289 - dc.set(Some((ox / w, oy / h))); 289 + let (nx, ny) = normalize_draw_coords(ox, oy, w, h); 290 + ds.set(Some((nx, ny))); 291 + dc.set(Some((nx, ny))); 290 292 } 291 293 }); 292 294 }, 293 295 onmousemove: move |e| { 294 296 if drag_start.read().is_some() { 295 297 let (w, h) = *cap_size.read(); 296 - let nx = e.data().element_coordinates().x / w; 297 - let ny = e.data().element_coordinates().y / h; 298 - drag_current.set(Some((nx.clamp(0.0, 1.0), ny.clamp(0.0, 1.0)))); 298 + let (nx, ny) = normalize_draw_coords( 299 + e.data().element_coordinates().x, 300 + e.data().element_coordinates().y, 301 + w, h, 302 + ); 303 + drag_current.set(Some((nx, ny))); 299 304 } 300 305 }, 301 306 onmouseleave: move |_| { ··· 305 310 onmouseup: move |e| { 306 311 let Some((sx, sy)) = *drag_start.read() else { return; }; 307 312 let (w, h) = *cap_size.read(); 308 - let nx = (e.data().element_coordinates().x / w).clamp(0.0, 1.0); 309 - let ny = (e.data().element_coordinates().y / h).clamp(0.0, 1.0); 313 + let (nx, ny) = normalize_draw_coords( 314 + e.data().element_coordinates().x, 315 + e.data().element_coordinates().y, 316 + w, h, 317 + ); 310 318 let rx = sx.min(nx); 311 319 let ry = sy.min(ny); 312 320 let rw = (nx - sx).abs(); ··· 383 391 } 384 392 } 385 393 394 + /// Convert element-space (offsetX, offsetY) mouse coordinates to normalized [0,1]. 395 + /// 396 + /// IMPORTANT: `container_w` and `container_h` must be **offsetWidth / offsetHeight**, 397 + /// not `getBoundingClientRect().width / height`. With CSS `zoom` applied to 398 + /// `<html>`, BCR dimensions are multiplied by the zoom factor while offsetX/Y 399 + /// are not, so using BCR gives coords that are off by 1/zoom. 400 + fn normalize_draw_coords(offset_x: f64, offset_y: f64, container_w: f64, container_h: f64) -> (f64, f64) { 401 + ( 402 + (offset_x / container_w).clamp(0.0, 1.0), 403 + (offset_y / container_h).clamp(0.0, 1.0), 404 + ) 405 + } 406 + 386 407 /// True if two normalized [x, y, w, h] rects have any overlap. 387 408 fn rects_overlap(a: [f64; 4], b: [f64; 4]) -> bool { 388 409 a[0] < b[0] + b[2] && a[0] + a[2] > b[0] && a[1] < b[1] + b[3] && a[1] + a[3] > b[1] ··· 495 516 mod tests { 496 517 use super::*; 497 518 519 + // --- normalize_draw_coords --- 520 + 521 + #[test] 522 + fn normalize_coords_center() { 523 + let (nx, ny) = normalize_draw_coords(200.0, 100.0, 400.0, 200.0); 524 + assert_eq!(nx, 0.5); 525 + assert_eq!(ny, 0.5); 526 + } 527 + 528 + /// CSS `zoom` on `<html>` scales `getBoundingClientRect` dims by the zoom 529 + /// factor but leaves `offsetX`/`offsetY` (element coordinates) unchanged. 530 + /// Dividing by BCR dims instead of offset dims gives nx / zoom -- wrong. 531 + /// This test documents the invariant that normalize_draw_coords must receive 532 + /// offsetWidth/offsetHeight (element space), not BCR width/height. 533 + #[test] 534 + fn normalize_coords_zoom_invariant() { 535 + let offset_x = 200.0_f64; 536 + let el_w = 400.0_f64; // offsetWidth — same at any zoom level 537 + let zoom = 1.5_f64; 538 + let bcr_w = el_w * zoom; // getBoundingClientRect().width at zoom=1.5 539 + 540 + // Correct: element coords / offsetWidth 541 + let (nx_correct, _) = normalize_draw_coords(offset_x, 0.0, el_w, 1.0); 542 + assert_eq!(nx_correct, 0.5); 543 + 544 + // Buggy: element coords / BCR width — off by zoom factor 545 + let (nx_buggy, _) = normalize_draw_coords(offset_x, 0.0, bcr_w, 1.0); 546 + assert!((nx_buggy - 0.5 / zoom).abs() < 1e-10, "nx_buggy={nx_buggy}"); 547 + // Verify the buggy result is meaningfully wrong for zoom=1.5 548 + assert!((nx_buggy - nx_correct).abs() > 0.1); 549 + } 550 + 551 + #[test] 552 + fn normalize_coords_clamps_to_unit() { 553 + let (nx, ny) = normalize_draw_coords(500.0, -10.0, 400.0, 200.0); 554 + assert_eq!(nx, 1.0); 555 + assert_eq!(ny, 0.0); 556 + } 557 + 558 + // --- rects_overlap --- 559 + 560 + #[test] 561 + fn rects_overlap_yes() { 562 + assert!(rects_overlap([0.0, 0.0, 0.5, 0.5], [0.25, 0.25, 0.5, 0.5])); 563 + } 564 + 565 + #[test] 566 + fn rects_overlap_adjacent_no_overlap() { 567 + // Touching edges do not overlap (strict inequality in the check). 568 + assert!(!rects_overlap([0.0, 0.0, 0.5, 0.5], [0.5, 0.0, 0.5, 0.5])); 569 + } 570 + 571 + #[test] 572 + fn rects_overlap_contained() { 573 + assert!(rects_overlap([0.1, 0.1, 0.3, 0.3], [0.0, 0.0, 1.0, 1.0])); 574 + } 575 + 576 + // --- insert_blank_wrap --- 577 + 578 + #[test] 579 + fn blank_wrap_plain_word() { 580 + let src = "#cloze[The Gaussian integral is $x$]"; 581 + // "#cloze[The " = 11 bytes → "G" starts at 11, "Gaussian" = 8 bytes → end = 19 582 + let start = src.find("Gaussian").unwrap(); 583 + let end = start + "Gaussian".len(); 584 + let result = insert_blank_wrap(src, start, end); 585 + assert!(result.contains("#blank[Gaussian]"), "got: {result}"); 586 + } 587 + 588 + #[test] 589 + fn blank_wrap_trims_whitespace_outside() { 590 + // Leading space in the selection stays outside the blank. 591 + let src = "hello world"; 592 + // Select " world" (index 5..11): leading space → outside, "world" → inside 593 + let result = insert_blank_wrap(src, 5, 11); 594 + assert_eq!(result, "hello #blank[world]"); 595 + } 596 + 597 + // --- insert_blank_wrap_math --- 598 + 599 + #[test] 600 + fn wrap_math_middle_selection() { 601 + let src = "$a + b + c$"; 602 + // Select "b" 603 + let result = insert_blank_wrap_math(src, 5, 6, 0, 11); 604 + assert_eq!(result, "$a +$ #blank[$b$] $+ c$"); 605 + } 606 + 607 + #[test] 608 + fn wrap_math_full_equation() { 609 + let src = "$sqrt(pi)$"; 610 + let result = insert_blank_wrap_math(src, 1, 9, 0, 10); 611 + assert_eq!(result, "#blank[$sqrt(pi)$]"); 612 + } 613 + 614 + #[test] 615 + fn wrap_math_left_empty() { 616 + let src = "$e^x + 1$"; 617 + // Select from start 618 + let result = insert_blank_wrap_math(src, 1, 4, 0, 9); 619 + assert_eq!(result, "#blank[$e^x$] $+ 1$"); 620 + } 621 + 622 + // --- expand_math_selection --- 623 + 498 624 #[test] 499 625 fn expand_unbalanced_paren() { 500 626 // Glyph hits cover `e^(-x^2` but miss the closing `)` ··· 516 642 let eq = "a + b"; 517 643 let (s, e) = expand_math_selection(eq, 4, 5); // "b" 518 644 assert_eq!(&eq[s..e], "b"); 645 + } 646 + 647 + #[test] 648 + fn expand_nested_brackets() { 649 + // If selection starts inside `[...]`, it should close the bracket. 650 + let eq = "vec[1, 2, 3]"; 651 + // Glyph hits: "vec" (0..3), then "[" opens bracket. 652 + // simulate a hit that catches "vec[1" = 0..5, missing close "]" 653 + let (s, e) = expand_math_selection(eq, 0, 5); // "vec[1" 654 + assert_eq!(&eq[s..e], "vec[1, 2, 3]"); 519 655 } 520 656 } 521 657