gardesk/garcalc / 4afec87

Browse files

Improve function parsing in structured input and expand help modal

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
4afec8778e25545d5f580f6baf8a58c17c95063b
Parents
c2468a1
Tree
1b72c81

3 changed files

StatusFile+-
M garcalc-math/src/convert.rs 254 22
M garcalc-math/src/input.rs 160 2
M garcalc/src/ui.rs 161 58
garcalc-math/src/convert.rsmodified
@@ -501,57 +501,244 @@ fn convert_row(items: &[MathBox]) -> Result<Expr, ConvertError> {
501501
         return to_expr(&items[0]);
502502
     }
503503
 
504
-    // Simple parsing: collect operands and operators
504
+    // Parse a flat row with implicit multiplication and basic precedence.
505505
     let mut operands: Vec<Expr> = Vec::new();
506506
     let mut operators: Vec<Operator> = Vec::new();
507
+    let mut expecting_operand = true;
507508
 
508509
     let mut i = 0;
509510
     while i < items.len() {
510
-        match &items[i] {
511
-            MathBox::Operator(op) => {
512
-                operators.push(*op);
511
+        let mut push_operand = |expr: Expr| {
512
+            if !expecting_operand {
513
+                operators.push(Operator::Mul);
513514
             }
514
-            MathBox::Symbol(s) if s == "!" => {
515
+            operands.push(expr);
516
+            expecting_operand = false;
517
+        };
518
+
519
+        match (&items[i], items.get(i + 1)) {
520
+            (MathBox::Operator(op), _) => {
521
+                match op {
522
+                    Operator::Add if expecting_operand => {
523
+                        // Unary plus: no-op.
524
+                    }
525
+                    Operator::Sub if expecting_operand => {
526
+                        // Unary minus: rewrite as 0 - ...
527
+                        operands.push(Expr::Integer(0));
528
+                        operators.push(Operator::Sub);
529
+                    }
530
+                    _ => {
531
+                        operators.push(*op);
532
+                    }
533
+                }
534
+                expecting_operand = true;
535
+            }
536
+            (MathBox::Symbol(s), _) if s == "!" => {
515537
                 let Some(last) = operands.pop() else {
516538
                     return Err(ConvertError::MissingField(
517539
                         "factorial operand before '!'".to_string(),
518540
                     ));
519541
                 };
520542
                 operands.push(Expr::Func("factorial".to_string(), vec![last]));
543
+                expecting_operand = false;
521544
             }
522
-            other => {
523
-                operands.push(to_expr(other)?);
545
+            (MathBox::Symbol(name), Some(MathBox::Parens(inner)))
546
+                if is_function_name(name.as_str()) =>
547
+            {
548
+                let args = parse_paren_args(inner)?;
549
+                push_operand(Expr::Func(name.clone(), args));
550
+                i += 1; // Consume the following parens node.
551
+            }
552
+            (other, _) => {
553
+                push_operand(to_expr(other)?);
524554
             }
525555
         }
526556
         i += 1;
527557
     }
528558
 
529
-    // Simple left-to-right evaluation (no precedence for now)
530559
     if operands.is_empty() {
531560
         return Err(ConvertError::EmptySlot);
532561
     }
533562
 
563
+    // If there are adjacent operands but no explicit operators, treat as multiplication.
564
+    if operators.is_empty() {
565
+        return if operands.len() == 1 {
566
+            Ok(operands.pop().unwrap())
567
+        } else {
568
+            Ok(Expr::Mul(operands))
569
+        };
570
+    }
571
+
572
+    // Maintain operand/operator alignment by appending implicit multiplications
573
+    // when needed due malformed rows.
574
+    while operators.len() + 1 < operands.len() {
575
+        operators.push(Operator::Mul);
576
+    }
577
+
578
+    if operators.len() + 1 != operands.len() {
579
+        return Err(ConvertError::Unsupported);
580
+    }
581
+
582
+    // First pass: *, /
583
+    let mut idx = 0;
584
+    while idx < operators.len() {
585
+        match operators[idx] {
586
+            Operator::Mul | Operator::Div => {
587
+                let lhs = operands[idx].clone();
588
+                let rhs = operands[idx + 1].clone();
589
+                let merged = match operators[idx] {
590
+                    Operator::Mul => Expr::Mul(vec![lhs, rhs]),
591
+                    Operator::Div => Expr::Mul(vec![
592
+                        lhs,
593
+                        Expr::Pow(Box::new(rhs), Box::new(Expr::Integer(-1))),
594
+                    ]),
595
+                    _ => unreachable!(),
596
+                };
597
+                operands[idx] = merged;
598
+                operands.remove(idx + 1);
599
+                operators.remove(idx);
600
+            }
601
+            _ => idx += 1,
602
+        }
603
+    }
604
+
605
+    // Second pass: +, -, =
534606
     let mut result = operands[0].clone();
535607
     for (i, op) in operators.iter().enumerate() {
536
-        if i + 1 < operands.len() {
537
-            let rhs = operands[i + 1].clone();
538
-            result = match op {
539
-                Operator::Add => Expr::Add(vec![result, rhs]),
540
-                Operator::Sub => Expr::Add(vec![result, Expr::Neg(Box::new(rhs))]),
541
-                Operator::Mul => Expr::Mul(vec![result, rhs]),
542
-                Operator::Div => Expr::Mul(vec![
543
-                    result,
544
-                    Expr::Pow(Box::new(rhs), Box::new(Expr::Integer(-1))),
545
-                ]),
546
-                Operator::Eq => Expr::Equation(Box::new(result), Box::new(rhs)),
547
-                _ => result, // Ignore comparison operators for now
548
-            };
549
-        }
608
+        let rhs = operands[i + 1].clone();
609
+        result = match op {
610
+            Operator::Add => Expr::Add(vec![result, rhs]),
611
+            Operator::Sub => Expr::Add(vec![result, Expr::Neg(Box::new(rhs))]),
612
+            Operator::Eq => Expr::Equation(Box::new(result), Box::new(rhs)),
613
+            Operator::Lt => Expr::Inequality {
614
+                lhs: Box::new(result),
615
+                op: garcalc_cas::expr::InequalityOp::Lt,
616
+                rhs: Box::new(rhs),
617
+            },
618
+            Operator::Gt => Expr::Inequality {
619
+                lhs: Box::new(result),
620
+                op: garcalc_cas::expr::InequalityOp::Gt,
621
+                rhs: Box::new(rhs),
622
+            },
623
+            Operator::Le => Expr::Inequality {
624
+                lhs: Box::new(result),
625
+                op: garcalc_cas::expr::InequalityOp::Le,
626
+                rhs: Box::new(rhs),
627
+            },
628
+            Operator::Ge => Expr::Inequality {
629
+                lhs: Box::new(result),
630
+                op: garcalc_cas::expr::InequalityOp::Ge,
631
+                rhs: Box::new(rhs),
632
+            },
633
+            Operator::Ne => Expr::Inequality {
634
+                lhs: Box::new(result),
635
+                op: garcalc_cas::expr::InequalityOp::Ne,
636
+                rhs: Box::new(rhs),
637
+            },
638
+            Operator::Mul | Operator::Div | Operator::Comma => {
639
+                return Err(ConvertError::Unsupported);
640
+            }
641
+        };
550642
     }
551643
 
552644
     Ok(result)
553645
 }
554646
 
647
+fn parse_paren_args(inner: &MathBox) -> Result<Vec<Expr>, ConvertError> {
648
+    match inner {
649
+        MathBox::Row(items) => {
650
+            let mut args = Vec::new();
651
+            let mut current = Vec::new();
652
+
653
+            for item in items {
654
+                if matches!(item, MathBox::Operator(Operator::Comma)) {
655
+                    if current.is_empty() {
656
+                        return Err(ConvertError::MissingField(
657
+                            "function argument before comma".to_string(),
658
+                        ));
659
+                    }
660
+                    args.push(to_expr(&MathBox::Row(std::mem::take(&mut current)))?);
661
+                } else {
662
+                    current.push(item.clone());
663
+                }
664
+            }
665
+
666
+            if current.is_empty() && args.is_empty() {
667
+                return Err(ConvertError::EmptySlot);
668
+            }
669
+            if !current.is_empty() {
670
+                args.push(to_expr(&MathBox::Row(current))?);
671
+            }
672
+
673
+            Ok(args)
674
+        }
675
+        _ => Ok(vec![to_expr(inner)?]),
676
+    }
677
+}
678
+
679
+fn is_function_name(name: &str) -> bool {
680
+    matches!(
681
+        name,
682
+        "sin"
683
+            | "cos"
684
+            | "tan"
685
+            | "cot"
686
+            | "sec"
687
+            | "csc"
688
+            | "asin"
689
+            | "acos"
690
+            | "atan"
691
+            | "sinh"
692
+            | "cosh"
693
+            | "tanh"
694
+            | "asinh"
695
+            | "acosh"
696
+            | "atanh"
697
+            | "ln"
698
+            | "log"
699
+            | "log10"
700
+            | "log2"
701
+            | "exp"
702
+            | "sqrt"
703
+            | "cbrt"
704
+            | "abs"
705
+            | "floor"
706
+            | "ceil"
707
+            | "round"
708
+            | "trunc"
709
+            | "sign"
710
+            | "gamma"
711
+            | "factorial"
712
+            | "diff"
713
+            | "derivative"
714
+            | "integrate"
715
+            | "integral"
716
+            | "limit"
717
+            | "lim"
718
+            | "solve"
719
+            | "sum"
720
+            | "product"
721
+            | "prod"
722
+            | "simplify"
723
+            | "expand"
724
+            | "factor"
725
+            | "substitute"
726
+            | "subs"
727
+            | "min"
728
+            | "max"
729
+            | "gcd"
730
+            | "lcm"
731
+            | "det"
732
+            | "determinant"
733
+            | "inv"
734
+            | "inverse"
735
+            | "transpose"
736
+            | "trace"
737
+            | "matmul"
738
+            | "identity"
739
+    )
740
+}
741
+
555742
 fn format_float(f: f64) -> String {
556743
     if f == f.trunc() && f.abs() < 1e15 {
557744
         format!("{:.0}", f)
@@ -728,6 +915,51 @@ mod tests {
728915
         );
729916
     }
730917
 
918
+    #[test]
919
+    fn test_row_implicit_multiplication_converts_to_mul() {
920
+        let mb = MathBox::Row(vec![
921
+            MathBox::Number("2".to_string()),
922
+            MathBox::Symbol("x".to_string()),
923
+        ]);
924
+
925
+        let expr = to_expr(&mb).unwrap();
926
+        assert_eq!(
927
+            expr,
928
+            Expr::Mul(vec![Expr::Integer(2), Expr::Symbol(Symbol::new("x"))])
929
+        );
930
+    }
931
+
932
+    #[test]
933
+    fn test_row_symbol_parens_known_function_converts_to_func() {
934
+        let mb = MathBox::Row(vec![
935
+            MathBox::Symbol("sin".to_string()),
936
+            MathBox::Parens(Box::new(MathBox::Symbol("x".to_string()))),
937
+        ]);
938
+
939
+        let expr = to_expr(&mb).unwrap();
940
+        assert_eq!(
941
+            expr,
942
+            Expr::Func("sin".to_string(), vec![Expr::Symbol(Symbol::new("x"))])
943
+        );
944
+    }
945
+
946
+    #[test]
947
+    fn test_row_symbol_parens_unknown_name_stays_multiplication() {
948
+        let mb = MathBox::Row(vec![
949
+            MathBox::Symbol("f".to_string()),
950
+            MathBox::Parens(Box::new(MathBox::Symbol("x".to_string()))),
951
+        ]);
952
+
953
+        let expr = to_expr(&mb).unwrap();
954
+        assert_eq!(
955
+            expr,
956
+            Expr::Mul(vec![
957
+                Expr::Symbol(Symbol::new("f")),
958
+                Expr::Symbol(Symbol::new("x"))
959
+            ])
960
+        );
961
+    }
962
+
731963
     #[test]
732964
     fn test_from_expr_fraction_drops_unity_factor_in_numerator() {
733965
         let expr = Expr::Mul(vec![
garcalc-math/src/input.rsmodified
@@ -141,8 +141,10 @@ impl MathInput {
141141
                 InputResult::Consumed
142142
             }
143143
             '(' => {
144
-                self.insert_at_cursor(MathBox::Parens(Box::new(MathBox::Slot)));
145
-                self.cursor.enter(0);
144
+                if !self.wrap_current_symbol_as_function_call() {
145
+                    self.insert_at_cursor(MathBox::Parens(Box::new(MathBox::Slot)));
146
+                    self.cursor.enter(0);
147
+                }
146148
                 InputResult::Consumed
147149
             }
148150
             ')' => {
@@ -513,6 +515,94 @@ impl MathInput {
513515
         false
514516
     }
515517
 
518
+    fn wrap_current_symbol_as_function_call(&mut self) -> bool {
519
+        let path = self.cursor.path.clone();
520
+        let symbol_name = match Self::get_node_at_path(&self.root, &path) {
521
+            Some(MathBox::Symbol(name)) => name.clone(),
522
+            _ => return false,
523
+        };
524
+
525
+        let normalized = symbol_name.to_ascii_lowercase();
526
+        if !Self::is_known_function_name(&normalized) {
527
+            return false;
528
+        }
529
+
530
+        if let Some(node) = Self::get_node_mut_at_path(&mut self.root, &path) {
531
+            *node = MathBox::Func {
532
+                name: normalized,
533
+                args: vec![MathBox::Slot],
534
+            };
535
+            self.cursor.enter(0);
536
+            self.cursor.offset = 0;
537
+            true
538
+        } else {
539
+            false
540
+        }
541
+    }
542
+
543
+    fn is_known_function_name(name: &str) -> bool {
544
+        matches!(
545
+            name,
546
+            "sin"
547
+                | "cos"
548
+                | "tan"
549
+                | "cot"
550
+                | "sec"
551
+                | "csc"
552
+                | "asin"
553
+                | "acos"
554
+                | "atan"
555
+                | "sinh"
556
+                | "cosh"
557
+                | "tanh"
558
+                | "asinh"
559
+                | "acosh"
560
+                | "atanh"
561
+                | "ln"
562
+                | "log"
563
+                | "log10"
564
+                | "log2"
565
+                | "exp"
566
+                | "sqrt"
567
+                | "cbrt"
568
+                | "abs"
569
+                | "floor"
570
+                | "ceil"
571
+                | "round"
572
+                | "trunc"
573
+                | "sign"
574
+                | "gamma"
575
+                | "factorial"
576
+                | "diff"
577
+                | "derivative"
578
+                | "integrate"
579
+                | "integral"
580
+                | "limit"
581
+                | "lim"
582
+                | "solve"
583
+                | "sum"
584
+                | "product"
585
+                | "prod"
586
+                | "simplify"
587
+                | "expand"
588
+                | "factor"
589
+                | "substitute"
590
+                | "subs"
591
+                | "min"
592
+                | "max"
593
+                | "gcd"
594
+                | "lcm"
595
+                | "det"
596
+                | "determinant"
597
+                | "inv"
598
+                | "inverse"
599
+                | "transpose"
600
+                | "trace"
601
+                | "matmul"
602
+                | "identity"
603
+        )
604
+    }
605
+
516606
     /// Try to exit current container (parens, etc.)
517607
     fn try_exit_container(&mut self) {
518608
         if !self.cursor.is_at_root() {
@@ -1664,6 +1754,22 @@ mod tests {
16641754
         assert!(rendered.contains(", x, 0, 1)"), "rendered: {rendered}");
16651755
     }
16661756
 
1757
+    #[test]
1758
+    fn test_integral_body_typed_function_parses_as_function_call() {
1759
+        let mut input = MathInput::new();
1760
+        run_command(&mut input, "int");
1761
+
1762
+        input.handle_char('s');
1763
+        input.handle_char('i');
1764
+        input.handle_char('n');
1765
+        input.handle_char('(');
1766
+        input.handle_char('x');
1767
+        input.handle_char(')');
1768
+
1769
+        let expr = to_expr(input.mathbox()).expect("structured input should convert");
1770
+        assert_eq!(expr.to_string(), "integrate(sin(x), x)");
1771
+    }
1772
+
16671773
     #[test]
16681774
     fn test_diff_focus_and_tab_order_var_body() {
16691775
         let mut input = MathInput::new();
@@ -1686,6 +1792,58 @@ mod tests {
16861792
         assert_eq!(input.cursor_path(), &[0]);
16871793
     }
16881794
 
1795
+    #[test]
1796
+    fn test_open_paren_after_function_name_wraps_as_func() {
1797
+        let mut input = MathInput::new();
1798
+        input.handle_char('s');
1799
+        input.handle_char('i');
1800
+        input.handle_char('n');
1801
+        input.handle_char('(');
1802
+
1803
+        assert_eq!(input.cursor_path(), &[0, 0]);
1804
+        if let MathBox::Row(items) = &input.root {
1805
+            if let MathBox::Func { name, args } = &items[0] {
1806
+                assert_eq!(name, "sin");
1807
+                assert_eq!(args.len(), 1);
1808
+                assert!(matches!(&args[0], MathBox::Slot));
1809
+            } else {
1810
+                panic!("Expected function call");
1811
+            }
1812
+        } else {
1813
+            panic!("Expected row root");
1814
+        }
1815
+    }
1816
+
1817
+    #[test]
1818
+    fn test_open_paren_after_non_function_symbol_keeps_parens() {
1819
+        let mut input = MathInput::new();
1820
+        input.handle_char('f');
1821
+        input.handle_char('(');
1822
+
1823
+        assert_eq!(input.cursor_path(), &[1, 0]);
1824
+        if let MathBox::Row(items) = &input.root {
1825
+            assert!(matches!(&items[0], MathBox::Symbol(s) if s == "f"));
1826
+            assert!(matches!(&items[1], MathBox::Parens(_)));
1827
+        } else {
1828
+            panic!("Expected row root");
1829
+        }
1830
+    }
1831
+
1832
+    #[test]
1833
+    fn test_open_paren_after_mixed_case_function_normalizes_name() {
1834
+        let mut input = MathInput::new();
1835
+        input.handle_char('S');
1836
+        input.handle_char('i');
1837
+        input.handle_char('N');
1838
+        input.handle_char('(');
1839
+
1840
+        if let MathBox::Row(items) = &input.root {
1841
+            assert!(matches!(&items[0], MathBox::Func { name, .. } if name == "sin"));
1842
+        } else {
1843
+            panic!("Expected row root");
1844
+        }
1845
+    }
1846
+
16891847
     #[test]
16901848
     fn test_backspace_at_exponent_start_deletes_base() {
16911849
         let mut input = MathInput::new();
garcalc/src/ui.rsmodified
@@ -348,8 +348,8 @@ impl CalculatorUI {
348348
 
349349
     fn help_modal_rect(&self) -> Rect {
350350
         let size = self.renderer.size();
351
-        let width = ((size.width as f64 * 0.72).clamp(460.0, 760.0)).round() as u32;
352
-        let height = ((size.height as f64 * 0.74).clamp(360.0, 560.0)).round() as u32;
351
+        let width = ((size.width as f64 * 0.82).clamp(520.0, 920.0)).round() as u32;
352
+        let height = ((size.height as f64 * 0.82).clamp(420.0, 700.0)).round() as u32;
353353
         Rect::new(
354354
             ((size.width - width) / 2) as i32,
355355
             ((size.height - height) / 2) as i32,
@@ -430,83 +430,186 @@ impl CalculatorUI {
430430
             &title_style,
431431
         )?;
432432
 
433
+        let sub_style = TextStyle::new()
434
+            .font_family(&self.theme.font_family)
435
+            .font_size(11.0)
436
+            .color(self.theme.foreground.with_alpha(0.75));
437
+        self.renderer.text(
438
+            "Shortcuts, structured math input, and mode controls",
439
+            (modal.x + 20) as f64,
440
+            (modal.y + 36) as f64,
441
+            &sub_style,
442
+        )?;
443
+
433444
         let heading_style = TextStyle::new()
434445
             .font_family(&self.theme.font_family)
435446
             .font_size(13.0)
436447
             .color(self.theme.selection_foreground);
437448
         let body_style = TextStyle::new()
438449
             .font_family(&self.theme.font_family)
439
-            .font_size(12.0)
450
+            .font_size(11.5)
440451
             .color(self.theme.foreground.with_alpha(0.92));
441452
         let hint_style = TextStyle::new()
442453
             .font_family(&self.theme.font_family)
443454
             .font_size(11.0)
444455
             .color(self.theme.foreground.with_alpha(0.75));
445456
 
446
-        let mut y = modal.y + 48;
447
-        let x = modal.x + 20;
448
-
457
+        let content_top = modal.y + 56;
458
+        let content_left = modal.x + 20;
459
+        let content_width = modal.width as i32 - 40;
460
+        let column_gap = 24;
461
+        let column_width = (content_width - column_gap) / 2;
462
+        let left_x = content_left;
463
+        let right_x = content_left + column_width + column_gap;
464
+        let divider_x = content_left + column_width + (column_gap / 2);
465
+        let divider_rect = Rect::new(
466
+            divider_x,
467
+            content_top + 2,
468
+            1,
469
+            modal.height.saturating_sub(94),
470
+        );
449471
         self.renderer
450
-            .text("Common expressions", x as f64, y as f64, &heading_style)?;
451
-        y += 22;
452
-        for line in [
453
-            "2+2, sin(pi/2), sqrt(2), x^2 + 2*x + 1",
454
-            "diff(x^2, x), integrate(sin(x), x), solve(x^2-4, x)",
455
-            "sum(k, k, 1, n), product(k, k, 1, n)",
456
-        ] {
457
-            self.renderer
458
-                .text(&format!("- {line}"), x as f64, y as f64, &body_style)?;
459
-            y += 18;
460
-        }
461
-
462
-        y += 10;
463
-        self.renderer.text(
464
-            "Structured input commands",
465
-            x as f64,
466
-            y as f64,
472
+            .fill_rect(divider_rect, self.theme.border.with_alpha(0.45))?;
473
+
474
+        let mut left_y = content_top;
475
+        let mut right_y = content_top;
476
+
477
+        self.draw_help_section(
478
+            left_x,
479
+            &mut left_y,
480
+            "Quick Start",
481
+            &[
482
+                "Enter evaluates the current expression.",
483
+                "Esc clears input (or closes this help).",
484
+                "Type '?' or click ? to toggle this panel.",
485
+                "F1 calculator, F2 graph, F3 graph 3D.",
486
+            ],
467487
             &heading_style,
488
+            &body_style,
489
+        )?;
490
+        left_y += 6;
491
+
492
+        self.draw_help_section(
493
+            left_x,
494
+            &mut left_y,
495
+            "Structured Templates",
496
+            &[
497
+                "Type '\\' or ':' then command + Space/Enter.",
498
+                "\\frac \\sqrt \\nthroot \\abs \\matrix",
499
+                "\\sum \\prod \\int \\dint \\lim \\diff",
500
+                "\\solve \\sin \\cos \\tan \\ln \\exp",
501
+                "Tab / Shift+Tab moves between template slots.",
502
+            ],
503
+            &heading_style,
504
+            &body_style,
505
+        )?;
506
+        left_y += 6;
507
+
508
+        self.draw_help_section(
509
+            left_x,
510
+            &mut left_y,
511
+            "Editing And History",
512
+            &[
513
+                "Arrow keys move inside boxes and fractions.",
514
+                "Backspace/Delete remove chars or empty boxes.",
515
+                "Home/End jumps to start/end of input.",
516
+                "Use '^', '_', '/', '!' for power/sub/frac/factorial.",
517
+                "Up/Down on blank input recalls history.",
518
+                "Ctrl+Up/Down forces history browsing.",
519
+            ],
520
+            &heading_style,
521
+            &body_style,
468522
         )?;
469
-        y += 22;
470
-        for line in [
471
-            "Type '\\' then command and press Enter/Space.",
472
-            "\\frac  \\sqrt  \\sum  \\prod  \\diff  \\dint  \\lim  \\solve",
473
-        ] {
474
-            self.renderer
475
-                .text(&format!("- {line}"), x as f64, y as f64, &body_style)?;
476
-            y += 18;
477
-        }
478523
 
479
-        y += 10;
480
-        self.renderer
481
-            .text("Keybinds", x as f64, y as f64, &heading_style)?;
482
-        y += 22;
483
-        for line in [
484
-            "Enter evaluate, Tab/Shift+Tab move slot focus",
485
-            "Arrow keys move cursor, Up/Down recall history when input is blank",
486
-            "Ctrl+Up/Down force calculator history recall",
487
-            "F1 calculator, F2 graph, F3 3D, Esc closes this help",
488
-        ] {
489
-            self.renderer
490
-                .text(&format!("- {line}"), x as f64, y as f64, &body_style)?;
491
-            y += 18;
492
-        }
524
+        self.draw_help_section(
525
+            right_x,
526
+            &mut right_y,
527
+            "Calculator Examples",
528
+            &[
529
+                "2+2",
530
+                "diff(x^2, x)",
531
+                "integrate(sin(x), x)",
532
+                "sum(k^2+k, k, 1, n)",
533
+                "solve(x^2-4, x)",
534
+            ],
535
+            &heading_style,
536
+            &body_style,
537
+        )?;
538
+        right_y += 6;
539
+
540
+        self.draw_help_section(
541
+            right_x,
542
+            &mut right_y,
543
+            "Graph Mode",
544
+            &[
545
+                "Enter y=... (or expression) to add a curve.",
546
+                "Left-drag pans, mouse wheel zooms.",
547
+                "Right-click toggles trace cursor.",
548
+                "Ctrl+R reset view, Ctrl+C clear, Ctrl+T trace.",
549
+            ],
550
+            &heading_style,
551
+            &body_style,
552
+        )?;
553
+        right_y += 6;
554
+
555
+        self.draw_help_section(
556
+            right_x,
557
+            &mut right_y,
558
+            "3D Mode",
559
+            &[
560
+                "Enter z=... (or expression) to add a surface.",
561
+                "Left-drag rotates camera, mouse wheel zooms.",
562
+                "Ctrl+R reset camera, Ctrl+C clear surfaces.",
563
+                "Parametric surface: (x(u,v), y(u,v), z(u,v)).",
564
+            ],
565
+            &heading_style,
566
+            &body_style,
567
+        )?;
568
+        right_y += 6;
569
+
570
+        self.draw_help_section(
571
+            right_x,
572
+            &mut right_y,
573
+            "Behavior Notes",
574
+            &[
575
+                "Pretty output renders many symbolic forms directly.",
576
+                "Definite integrals with numeric bounds evaluate numerically.",
577
+                "Hard symbolic forms may remain as integrate(...).",
578
+                "Click outside modal (or x) to close.",
579
+            ],
580
+            &heading_style,
581
+            &body_style,
582
+        )?;
493583
 
494
-        y += 8;
495584
         let mode_hint = match mode {
496
-            Mode::Graph => "Graph mode: drag to pan, scroll to zoom, right-click trace toggle.",
497
-            Mode::Graph3D => "3D mode: drag to rotate camera, scroll to zoom depth.",
498
-            _ => "Calculator mode: use pretty templates, then evaluate with Enter.",
585
+            Mode::Graph => "Active mode: Graph.",
586
+            Mode::Graph3D => "Active mode: Graph 3D.",
587
+            _ => "Active mode: Calculator.",
499588
         };
589
+        let hint_y = modal.bottom() - 24;
500590
         self.renderer
501
-            .text(mode_hint, x as f64, y as f64, &hint_style)?;
502
-        y += 20;
503
-        self.renderer.text(
504
-            "Tip: click outside this panel (or x) to close.",
505
-            x as f64,
506
-            y as f64,
507
-            &hint_style,
508
-        )?;
591
+            .text(mode_hint, (modal.x + 20) as f64, hint_y as f64, &hint_style)?;
592
+
593
+        Ok(())
594
+    }
509595
 
596
+    fn draw_help_section(
597
+        &mut self,
598
+        x: i32,
599
+        y: &mut i32,
600
+        heading: &str,
601
+        lines: &[&str],
602
+        heading_style: &TextStyle,
603
+        body_style: &TextStyle,
604
+    ) -> Result<()> {
605
+        self.renderer
606
+            .text(heading, x as f64, *y as f64, heading_style)?;
607
+        *y += 20;
608
+        for line in lines {
609
+            self.renderer
610
+                .text(&format!("- {line}"), x as f64, *y as f64, body_style)?;
611
+            *y += 16;
612
+        }
510613
         Ok(())
511614
     }
512615